diff --git a/.github/workflows/dataeyesai-desktop.yml b/.github/workflows/dataeyesai-desktop.yml
new file mode 100644
index 0000000000..efd61ed5db
--- /dev/null
+++ b/.github/workflows/dataeyesai-desktop.yml
@@ -0,0 +1,127 @@
+name: dataeyesai desktop packages
+
+on:
+ push:
+ tags:
+ - "dataeyesai-v*"
+ pull_request:
+ paths:
+ - ".github/workflows/dataeyesai-desktop.yml"
+ - "tools/channel-key-importer/**"
+ - "pnpm-lock.yaml"
+ - "pnpm-workspace.yaml"
+ workflow_dispatch:
+ inputs:
+ version:
+ description: "Release version without the v prefix"
+ required: true
+ default: "0.7.0"
+
+permissions:
+ contents: write
+
+jobs:
+ build:
+ name: Build ${{ matrix.label }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - label: macOS Apple Silicon
+ os: macos-15
+ command: --mac dmg --arm64
+ artifact: mac-arm64
+ - label: macOS Intel
+ os: macos-15-intel
+ command: --mac dmg --x64
+ artifact: mac-x64
+ - label: Windows x64
+ os: windows-2025
+ command: --win nsis --x64
+ artifact: windows-x64
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 11.7.0
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm test:channel-importer
+ - name: Build installer
+ run: pnpm --filter dataeyesai exec electron-builder ${{ matrix.command }} --publish never
+ env:
+ CSC_IDENTITY_AUTO_DISCOVERY: "false"
+ - uses: actions/upload-artifact@v4
+ with:
+ name: dataeyesai-${{ matrix.artifact }}
+ path: |
+ tools/channel-key-importer/dist/*.dmg
+ tools/channel-key-importer/dist/*.exe
+ if-no-files-found: error
+
+ release:
+ name: Publish GitHub release
+ if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/dataeyesai-v')
+ needs: build
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ pattern: dataeyesai-*
+ path: release-assets
+ merge-multiple: true
+ - name: Write release notes
+ run: |
+ cat > release-notes.md <<'EOF'
+ dataeyesai 桌面安装包。
+
+ - 单页长表单拆分为导入渠道、定时任务、用量监测、上 Key 记录和站点设置五个工作区
+ - 顶部标题、说明和站点状态会随当前工作区切换,减少无关信息干扰
+ - 视觉改为平面运营工具风格,统一无衬线字体、克制圆角和清晰数据层级
+ - 导入首页加入本地生成的渠道操作插画,不使用外部图片和第三方资源
+ - 手机端导航在自身区域横向滚动,不再撑宽整个页面
+ - 服务器版支持独立系统访问密钥、12 小时登录态和连续输错限流
+ - 多设备通过服务器 SQLite 共享站点、定时任务、用量和导入记录
+ - 记住的 New API 系统令牌使用 AES-256-GCM 加密后写入共享数据库
+ - 定时任务可把失败 Key 单独重新入队,不重复已经成功的 Key
+ - AWS Bedrock 自动识别 Key 类型和来源地区
+ - Global 模式使用一个来源地区并生成 `global.*` 模型映射
+ - 渠道名称和预览明确显示 AWS 来源地区
+ - 定时上 Key 支持精确到秒,并按最近任务动态唤醒
+ - 等待中或已暂停的定时任务可修改下一次执行时间、每批数量和时间间隔
+ - 批量独立建渠道可按 Key 原始顺序自动递减优先级,定时跨批次也不重置顺序
+ - 上 Key 记录按写入批次汇总时间、数量、额度、消耗和请求数,明细按需展开
+ - 金额、数量、日期等数据统一使用清晰的无衬线阿拉伯数字
+ - Key 和额度默认明文显示,可分别手动隐藏
+ - 批量 Key 可选择统一额度,只需填写一次金额;也可切换为逐条额度
+ - 创建、复制和更新渠道时可设置优先级与权重
+ - 优先级与权重位于批量写入方式下方,录入时无需返回上方查找
+ - 用量监测使用 New API 数据库汇总接口校准消费金额
+
+ - macOS Apple Silicon:`mac-arm64.dmg`
+ - macOS Intel:`mac-x64.dmg`
+ - Windows x64:`.exe`
+
+ 当前预览版尚未购买 Apple 和 Microsoft 代码签名证书。macOS 首次启动可能需要在“系统设置 → 隐私与安全性”中选择“仍要打开”;Windows 可能显示 SmartScreen 提示。
+ EOF
+ - name: Create prerelease
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ VERSION="${{ inputs.version }}"
+ TAG="dataeyesai-v${VERSION}"
+ else
+ TAG="${GITHUB_REF_NAME}"
+ VERSION="${TAG#dataeyesai-v}"
+ fi
+ gh release create "${TAG}" release-assets/* \
+ --repo "${{ github.repository }}" \
+ --target "${{ github.sha }}" \
+ --title "dataeyesai v${VERSION}" \
+ --notes-file release-notes.md \
+ --prerelease
diff --git a/.gitignore b/.gitignore
index 74e69f4233..c1bcccb3cb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -74,6 +74,8 @@ web-ext.config.ts
.env
.env.*
+tools/channel-key-importer/server.env
+tools/channel-key-importer/server-data/
src/public/react-devtools*
.ace-tool/
/All API Hub – AI Relay & New API Manager/
diff --git a/knip.ts b/knip.ts
index 0cc421cfad..573a0fcb90 100644
--- a/knip.ts
+++ b/knip.ts
@@ -1,38 +1,58 @@
import type { KnipConfig } from "knip"
-const config: KnipConfig = {
+const rootEntry = [
// WXT entrypoints are referenced via extension conventions and HTML files,
// so declare them explicitly instead of relying on Knip defaults.
- entry: [
- "wxt.config.ts",
- "i18next.config.ts",
- "vitest.config.ts",
- "scripts/diagnostics/collect-extension-memory.mjs",
- "scripts/diagnostics/compare-extension-memory.mjs",
- "scripts/diagnostics/compare-lazy-loading.mjs",
- "scripts/diagnostics/render-extension-memory-report.mjs",
- "scripts/diagnostics/render-lazy-loading-report.mjs",
- "src/entrypoints/background/index.ts",
- "src/entrypoints/content/index.ts",
- "src/entrypoints/options/main.tsx",
- "src/entrypoints/popup/main.tsx",
- "src/entrypoints/sidepanel/main.tsx",
- "tests/**/*.test.{ts,tsx}",
- "tests/setup.ts",
- "tests/setup.node.ts",
- "tests/setup.shared.ts",
- // Playwright runs this through the build dependency project in
- // playwright.config.ts, which Knip does not discover as a static import.
- "e2e/setup/build.setup.ts",
- ],
- project: [
- "src/**/*.{ts,tsx}",
- "tests/**/*.{ts,tsx}",
- "e2e/**/*.{ts,tsx}",
- "scripts/**/*.{js,mjs}",
- "plugins/**/*.ts",
- "*.{js,mjs,ts}",
- ],
+ "wxt.config.ts",
+ "i18next.config.ts",
+ "vitest.config.ts",
+ "scripts/diagnostics/collect-extension-memory.mjs",
+ "scripts/diagnostics/compare-extension-memory.mjs",
+ "scripts/diagnostics/compare-lazy-loading.mjs",
+ "scripts/diagnostics/render-extension-memory-report.mjs",
+ "scripts/diagnostics/render-lazy-loading-report.mjs",
+ "src/entrypoints/background/index.ts",
+ "src/entrypoints/content/index.ts",
+ "src/entrypoints/options/main.tsx",
+ "src/entrypoints/popup/main.tsx",
+ "src/entrypoints/sidepanel/main.tsx",
+ "tests/**/*.test.{ts,tsx}",
+ "tests/setup.ts",
+ "tests/setup.node.ts",
+ "tests/setup.shared.ts",
+ // Playwright runs this through the build dependency project in
+ // playwright.config.ts, which Knip does not discover as a static import.
+ "e2e/setup/build.setup.ts",
+]
+
+const rootProject = [
+ "src/**/*.{ts,tsx}",
+ "tests/**/*.{ts,tsx}",
+ "e2e/**/*.{ts,tsx}",
+ "scripts/**/*.{js,mjs}",
+ "plugins/**/*.ts",
+ "*.{js,mjs,ts}",
+]
+
+const config: KnipConfig = {
+ workspaces: {
+ ".": {
+ entry: rootEntry,
+ project: rootProject,
+ },
+ "tools/channel-key-importer": {
+ entry: [
+ "public/index.html",
+ "public/app.js",
+ "public/styles.css",
+ "public/login.html",
+ "public/login.js",
+ "public/login.css",
+ "tests/*.test.js",
+ ],
+ project: ["**/*.{js,html,css}"],
+ },
+ },
ignoreDependencies: [
// Ambient extension/browser types are consumed globally by TypeScript.
"@types/chrome",
diff --git a/package.json b/package.json
index e0ea1d3770..20b7086576 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,10 @@
"dev": "wxt",
"dev:firefox": "wxt -b firefox",
"dev:mobile:firefox": "node scripts/android-dev.js",
+ "channel-importer": "node tools/channel-key-importer/src/server.js",
+ "channel-importer:desktop": "pnpm --filter dataeyesai desktop",
+ "channel-importer:package": "pnpm --filter dataeyesai package:current",
+ "test:channel-importer": "node --test tools/channel-key-importer/tests/*.test.js",
"build": "wxt build",
"build:e2e": "node scripts/e2e-build.mjs",
"build:firefox": "wxt build -b firefox",
@@ -159,10 +163,5 @@
"web-ext": "^9.0.0",
"whatwg-fetch": "^3.6.20",
"wxt": "^0.20.16"
- },
- "pnpm": {
- "onlyBuiltDependencies": [
- "electron"
- ]
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index abd0fecb1e..9ac5fe9c9d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -280,6 +280,15 @@ importers:
specifier: ^0.20.16
version: 0.20.16(@types/node@24.12.0)(eslint@9.38.0(jiti@2.6.1))(jiti@2.6.1)(lightningcss@1.32.0)(rollup@4.60.1)(yaml@2.8.2)
+ tools/channel-key-importer:
+ devDependencies:
+ electron:
+ specifier: 43.0.0
+ version: 43.0.0
+ electron-builder:
+ specifier: 26.15.3
+ version: 26.15.3(electron-builder-squirrel-windows@26.15.3)
+
packages:
'@1natsu/wait-element@4.2.0':
@@ -529,11 +538,6 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
- '@babel/parser@7.28.5':
- resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==}
- engines: {node: '>=6.0.0'}
- hasBin: true
-
'@babel/parser@7.29.2':
resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==}
engines: {node: '>=6.0.0'}
@@ -765,10 +769,54 @@ packages:
peerDependencies:
'@noble/ciphers': ^1.0.0
+ '@electron-internal/extract-zip@1.0.4':
+ resolution: {integrity: sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg==}
+ engines: {node: '>=22.12.0'}
+
+ '@electron/asar@3.4.1':
+ resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==}
+ engines: {node: '>=10.12.0'}
+ hasBin: true
+
+ '@electron/fuses@1.8.0':
+ resolution: {integrity: sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==}
+ hasBin: true
+
'@electron/get@2.0.3':
resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==}
engines: {node: '>=12'}
+ '@electron/get@3.1.0':
+ resolution: {integrity: sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==}
+ engines: {node: '>=14'}
+
+ '@electron/get@5.0.0':
+ resolution: {integrity: sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==}
+ engines: {node: '>=22.12.0'}
+
+ '@electron/notarize@2.5.0':
+ resolution: {integrity: sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==}
+ engines: {node: '>= 10.0.0'}
+
+ '@electron/osx-sign@1.3.3':
+ resolution: {integrity: sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==}
+ engines: {node: '>=12.0.0'}
+ hasBin: true
+
+ '@electron/rebuild@4.1.0':
+ resolution: {integrity: sha512-GFky+1JeO8fpSqtZCh+2wFRN/MVbAhm7tXI2M7BA1Os79OR8LEoxRtAbRPyxvmKWhEMF/p10rNJkkgP1klI13g==}
+ engines: {node: '>=22.12.0'}
+ hasBin: true
+
+ '@electron/universal@2.0.3':
+ resolution: {integrity: sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==}
+ engines: {node: '>=16.4'}
+
+ '@electron/windows-sign@1.2.2':
+ resolution: {integrity: sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==}
+ engines: {node: '>=14.14'}
+ hasBin: true
+
'@emnapi/core@1.7.1':
resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==}
@@ -1484,6 +1532,10 @@ packages:
resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==}
engines: {node: 20 || >=22}
+ '@isaacs/fs-minipass@4.0.1':
+ resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
+ engines: {node: '>=18.0.0'}
+
'@jridgewell/gen-mapping@0.3.12':
resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==}
@@ -1537,6 +1589,14 @@ packages:
react: ^19.0.0
react-dom: ^19.0.0
+ '@malept/cross-spawn-promise@2.0.0':
+ resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==}
+ engines: {node: '>= 12.13.0'}
+
+ '@malept/flatpak-bundler@0.4.0':
+ resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==}
+ engines: {node: '>= 10.0.0'}
+
'@mdn/browser-compat-data@7.1.7':
resolution: {integrity: sha512-bpWZ7hidvjrwNWcMngZ8nTMTxn8WhnQntsGqEYgPr1vjy66kfwfDVizwXg6PvsgoANZ7nhuRBmvzjpCMk4ITDw==}
@@ -1580,6 +1640,10 @@ packages:
resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==}
engines: {node: ^14.21.3 || >=16}
+ '@noble/hashes@1.4.0':
+ resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==}
+ engines: {node: '>= 16'}
+
'@noble/hashes@1.8.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
@@ -1588,6 +1652,10 @@ packages:
resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
engines: {node: '>= 20.19.0'}
+ '@noble/hashes@2.2.0':
+ resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==}
+ engines: {node: '>= 20.19.0'}
+
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -1851,6 +1919,20 @@ packages:
cpu: [x64]
os: [win32]
+ '@peculiar/asn1-schema@2.8.0':
+ resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==}
+
+ '@peculiar/json-schema@1.1.12':
+ resolution: {integrity: sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==}
+ engines: {node: '>=8.0.0'}
+
+ '@peculiar/utils@2.0.3':
+ resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==}
+
+ '@peculiar/webcrypto@1.7.1':
+ resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==}
+ engines: {node: '>=14.18.0'}
+
'@plasmohq/storage@1.15.0':
resolution: {integrity: sha512-zBZ2NyVyvZlql2XHsjkZDL0+bqF6lir9RygeTnkYGF253M8DZttuSvjpsmpK6OWYmRXjhEqJeubd3+Zq9kuZ0w==}
peerDependencies:
@@ -2790,13 +2872,6 @@ packages:
react: '>=16.9.0'
react-dom: '>=16.9.0'
- '@rc-component/trigger@2.3.0':
- resolution: {integrity: sha512-iwaxZyzOuK0D7lS+0AQEtW52zUWxoGqTGkke3dRyb8pYiShmRpCjB/8TzPI4R6YySCH7Vm9BZj/31VPiiQTLBg==}
- engines: {node: '>=8.x'}
- peerDependencies:
- react: '>=16.9.0'
- react-dom: '>=16.9.0'
-
'@rc-component/trigger@2.3.1':
resolution: {integrity: sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==}
engines: {node: '>=8.x'}
@@ -3427,6 +3502,9 @@ packages:
'@types/firefox-webext-browser@120.0.4':
resolution: {integrity: sha512-lBrpf08xhiZBigrtdQfUaqX1UauwZ+skbFiL8u2Tdra/rklkKadYmIzTwkNZSWtuZ7OKpFqbE2HHfDoFqvZf6w==}
+ '@types/fs-extra@9.0.13':
+ resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==}
+
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
@@ -3578,6 +3656,7 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
+ deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
@@ -3784,6 +3863,14 @@ packages:
'@wxt-dev/storage@1.2.8':
resolution: {integrity: sha512-GWCFKgF5+d7eslOxUDFC70ypA9njupmJb1nQM8uZoX0J3sWT2BO5xJLzb1sYahWAfID9p2BMtnUBN1lkWxPsbQ==}
+ '@xmldom/xmldom@0.8.13':
+ resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
+ engines: {node: '>=10.0.0'}
+
+ abbrev@4.0.0:
+ resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==}
+ engines: {node: ^20.17.0 || >=22.9.0}
+
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -3863,6 +3950,9 @@ packages:
ajv@8.17.1:
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
+ ajv@8.20.0:
+ resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
+
ansi-align@2.0.0:
resolution: {integrity: sha512-TdlOggdA/zURfMYa7ABC66j+oqfMew58KpJMbUlH3bcZP1b+cBHIHDDn5uH9INsxrHBPjsqM0tDB4jPTF/vgJA==}
@@ -3925,6 +4015,13 @@ packages:
react: '>=16.9.0'
react-dom: '>=16.9.0'
+ app-builder-lib@26.15.3:
+ resolution: {integrity: sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ dmg-builder: 26.15.3
+ electron-builder-squirrel-windows: 26.15.3
+
are-docs-informative@0.0.2:
resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==}
engines: {node: '>=14'}
@@ -3975,6 +4072,10 @@ packages:
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
engines: {node: '>= 0.4'}
+ asn1js@3.0.10:
+ resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==}
+ engines: {node: '>=12.0.0'}
+
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@@ -3994,6 +4095,10 @@ packages:
resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==}
hasBin: true
+ async-exit-hook@2.0.1:
+ resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==}
+ engines: {node: '>=0.12.0'}
+
async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
@@ -4004,6 +4109,13 @@ packages:
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
+ asynckit@0.4.0:
+ resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
+
+ at-least-node@1.0.0:
+ resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==}
+ engines: {node: '>= 4.0.0'}
+
atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'}
@@ -4026,6 +4138,9 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
+ aws4@1.13.2:
+ resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==}
+
babel-plugin-macros@3.1.0:
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
engines: {node: '>=10', npm: '>=6'}
@@ -4055,6 +4170,9 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
+ base64-js@1.5.1:
+ resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
+
baseline-browser-mapping@2.9.11:
resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==}
hasBin: true
@@ -4121,6 +4239,14 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
+ builder-util-runtime@9.7.0:
+ resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==}
+ engines: {node: '>=12.0.0'}
+
+ builder-util@26.15.3:
+ resolution: {integrity: sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==}
+ engines: {node: '>=14.0.0'}
+
bundle-name@4.1.0:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
engines: {node: '>=18'}
@@ -4129,6 +4255,10 @@ packages:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
+ bytestreamjs@2.0.1:
+ resolution: {integrity: sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==}
+ engines: {node: '>=6.0.0'}
+
c12@3.3.4:
resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==}
peerDependencies:
@@ -4236,6 +4366,10 @@ packages:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
+ chownr@3.0.0:
+ resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
+ engines: {node: '>=18'}
+
chroma-js@3.1.2:
resolution: {integrity: sha512-IJnETTalXbsLx1eKEgx19d5L6SRM7cH4vINw/99p/M11HCuXGRWL+6YmCm7FWFGIo6dtWuQoQi1dc5yQ7ESIHg==}
@@ -4244,9 +4378,16 @@ packages:
engines: {node: '>=12.13.0'}
hasBin: true
+ chromium-pickle-js@0.2.0:
+ resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==}
+
ci-info@1.6.0:
resolution: {integrity: sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A==}
+ ci-info@4.3.1:
+ resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==}
+ engines: {node: '>=8'}
+
ci-info@4.4.0:
resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
engines: {node: '>=8'}
@@ -4346,6 +4487,10 @@ packages:
resolution: {integrity: sha512-lomjuFZKfM6MSAnV9aCZC9sc0qGbmZdfygNv+nCpqVkSKdCxCklLtd16O0EILGkImHw9ZpHkAnHaB+8Zxq5W6Q==}
engines: {node: '>=8.0.0'}
+ combined-stream@1.0.8:
+ resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
+ engines: {node: '>= 0.8'}
+
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
@@ -4365,6 +4510,10 @@ packages:
resolution: {integrity: sha512-bmkUukX8wAOjHdN26xj5c4ctEV22TQ7dQYhSmuckKhToXrkUn0iIaolHdIxYYqD55nhpSPA9zPQ1yP57GdXP2A==}
engines: {node: '>= 0.6.x'}
+ commander@5.1.0:
+ resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==}
+ engines: {node: '>= 6'}
+
commander@7.2.0:
resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
engines: {node: '>= 10'}
@@ -4385,6 +4534,10 @@ packages:
resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==}
engines: {node: '>=4.0.0'}
+ compare-version@0.1.2:
+ resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==}
+ engines: {node: '>=0.10.0'}
+
compute-scroll-into-view@3.1.1:
resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==}
@@ -4484,6 +4637,9 @@ packages:
resolution: {integrity: sha512-gYTKKexFO3kh200H1Nit76sRwRtOY32vQd3jpAQKpLtZqyNsSQNfI4N7o3eP2wUjV35pTWKRYqFUDBvUha/Pkw==}
engines: {node: '>=0.10.0'}
+ cross-dirname@0.1.0:
+ resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==}
+
cross-spawn@5.1.0:
resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==}
@@ -4832,6 +4988,10 @@ packages:
delaunator@5.0.1:
resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==}
+ delayed-stream@1.0.0:
+ resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
+ engines: {node: '>=0.4.0'}
+
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
@@ -4860,6 +5020,12 @@ packages:
resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==}
engines: {node: '>=0.3.1'}
+ dir-compare@4.2.0:
+ resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==}
+
+ dmg-builder@26.15.3:
+ resolution: {integrity: sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==}
+
doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
@@ -4901,6 +5067,10 @@ packages:
resolution: {integrity: sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==}
engines: {node: '>=18'}
+ dotenv-expand@11.0.7:
+ resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
+ engines: {node: '>=12'}
+
dotenv-expand@12.0.3:
resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==}
engines: {node: '>=12'}
@@ -4921,6 +5091,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
+ duplexer2@0.1.4:
+ resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==}
+
duplexer3@0.1.5:
resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==}
@@ -4937,17 +5110,42 @@ packages:
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
+ ejs@3.1.10:
+ resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==}
+ engines: {node: '>=0.10.0'}
+ hasBin: true
+
+ electron-builder-squirrel-windows@26.15.3:
+ resolution: {integrity: sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==}
+
+ electron-builder@26.15.3:
+ resolution: {integrity: sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==}
+ engines: {node: '>=14.0.0'}
+ hasBin: true
+
+ electron-publish@26.15.3:
+ resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==}
+
electron-to-chromium@1.5.187:
resolution: {integrity: sha512-cl5Jc9I0KGUoOoSbxvTywTa40uspGJt/BDBoDLoxJRSBpWh4FFXBsjNRHfQrONsV/OoEjDfHUmZQa2d6Ze4YgA==}
electron-to-chromium@1.5.267:
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
+ electron-winstaller@5.4.0:
+ resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
+ engines: {node: '>=8.0.0'}
+
electron@23.3.13:
resolution: {integrity: sha512-BaXtHEb+KYKLouUXlUVDa/lj9pj4F5kiE0kwFdJV84Y2EU7euIDgPthfKtchhr5MVHmjtavRMIV/zAwEiSQ9rQ==}
engines: {node: '>= 12.20.55'}
hasBin: true
+ electron@43.0.0:
+ resolution: {integrity: sha512-PV60GsWU6qufhuOhw3n+Yix3WPDcqDtBqE8orbEQGQGHEkgp9o/JCPgb7L4vIL0r1HnfPdqSRtboOTqbDkcFDQ==}
+ engines: {node: '>= 22.12.0'}
+ hasBin: true
+
emoji-mart@5.6.0:
resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==}
@@ -4987,12 +5185,16 @@ packages:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
+ env-paths@3.0.0:
+ resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+
environment@1.1.0:
resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==}
engines: {node: '>=18'}
- error-ex@1.3.2:
- resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
+ err-code@2.0.3:
+ resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==}
error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
@@ -5266,6 +5468,9 @@ packages:
resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==}
engines: {node: '>=12.0.0'}
+ exponential-backoff@3.1.3:
+ resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
+
express-rate-limit@7.5.1:
resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==}
engines: {node: '>= 16'}
@@ -5368,6 +5573,9 @@ packages:
resolution: {integrity: sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA==}
engines: {node: '>= 10'}
+ filelist@1.0.6:
+ resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==}
+
filesize@11.0.15:
resolution: {integrity: sha512-30TpbYxQxCpi4XdVjkwXYQ37CzZltV38+P7MYroQ+4NK/Dmx9mxixFNrolzcmEIBsjT/uowC9T7kiy2+C12r1A==}
engines: {node: '>= 10.8.0'}
@@ -5423,6 +5631,10 @@ packages:
resolution: {integrity: sha512-G6NsmEW15s0Uw9XnCg+33H3ViYRyiM0hMrMhhqQOR8NFc5GhYrI+6I3u7OTw7b91J2g8rtvMBZJDbcGb2YUniw==}
engines: {node: '>= 18'}
+ form-data@4.0.6:
+ resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
+ engines: {node: '>= 6'}
+
formatly@0.3.0:
resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==}
engines: {node: '>=18.3.0'}
@@ -5461,6 +5673,14 @@ packages:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
+ fs-extra@10.1.0:
+ resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
+ engines: {node: '>=12'}
+
+ fs-extra@11.3.1:
+ resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==}
+ engines: {node: '>=14.14'}
+
fs-extra@11.3.2:
resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==}
engines: {node: '>=14.14'}
@@ -5469,10 +5689,18 @@ packages:
resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
engines: {node: '>=14.14'}
+ fs-extra@7.0.1:
+ resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==}
+ engines: {node: '>=6 <7 || >=8'}
+
fs-extra@8.1.0:
resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
engines: {node: '>=6 <7 || >=8'}
+ fs-extra@9.1.0:
+ resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==}
+ engines: {node: '>=10'}
+
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
@@ -5599,7 +5827,7 @@ packages:
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
- deprecated: Glob versions prior to v9 are no longer supported
+ deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
global-agent@3.0.0:
resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==}
@@ -5703,6 +5931,10 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
+ hasown@2.0.4:
+ resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
+ engines: {node: '>= 0.4'}
+
hast-util-from-dom@5.0.1:
resolution: {integrity: sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==}
@@ -5758,6 +5990,10 @@ packages:
hookable@6.1.0:
resolution: {integrity: sha512-ZoKZSJgu8voGK2geJS+6YtYjvIzu9AOM/KZXsBxr83uhLL++e9pEv/dlgwgy3dvHg06kTz6JOh1hk3C8Ceiymw==}
+ hosted-git-info@4.1.0:
+ resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
+ engines: {node: '>=10'}
+
html-encoding-sniffer@4.0.0:
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
engines: {node: '>=18'}
@@ -6247,6 +6483,14 @@ packages:
isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
+ isbinaryfile@4.0.10:
+ resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==}
+ engines: {node: '>= 8.0.0'}
+
+ isbinaryfile@5.0.7:
+ resolution: {integrity: sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==}
+ engines: {node: '>= 18.0.0'}
+
isexe@1.1.2:
resolution: {integrity: sha512-d2eJzK691yZwPHcv1LbeAOa91yMJ9QmfTgSO1oXB65ezVhXQsxBac2vEB4bMVms9cGzaA99n6V2viHMq82VLDw==}
@@ -6257,6 +6501,10 @@ packages:
resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==}
engines: {node: '>=16'}
+ isexe@4.0.0:
+ resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==}
+ engines: {node: '>=20'}
+
isobject@3.0.1:
resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==}
engines: {node: '>=0.10.0'}
@@ -6277,6 +6525,11 @@ packages:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
+ jake@10.9.4:
+ resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
@@ -6432,6 +6685,9 @@ packages:
layout-base@2.0.1:
resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==}
+ lazy-val@1.0.5:
+ resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==}
+
leva@0.10.0:
resolution: {integrity: sha512-RiNJWmeqQdKIeHuVXgshmxIHu144a2AMYtLxKf8Nm1j93pisDPexuQDHKNdQlbo37wdyDQibLjY9JKGIiD7gaw==}
peerDependencies:
@@ -6715,6 +6971,10 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+ lru-cache@6.0.0:
+ resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
+ engines: {node: '>=10'}
+
lucide-react@0.469.0:
resolution: {integrity: sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==}
peerDependencies:
@@ -6991,14 +7251,27 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
+ mime-db@1.52.0:
+ resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
+ engines: {node: '>= 0.6'}
+
mime-db@1.54.0:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
engines: {node: '>= 0.6'}
+ mime-types@2.1.35:
+ resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
+ engines: {node: '>= 0.6'}
+
mime-types@3.0.2:
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
engines: {node: '>=18'}
+ mime@2.6.0:
+ resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
+ engines: {node: '>=4.0.0'}
+ hasBin: true
+
mimic-fn@2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
@@ -7038,6 +7311,10 @@ packages:
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
+ minimatch@5.1.9:
+ resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
+ engines: {node: '>=10'}
+
minimatch@9.0.5:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
engines: {node: '>=16 || 14 >=14.17'}
@@ -7049,10 +7326,18 @@ packages:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
+ minizlib@3.1.0:
+ resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
+ engines: {node: '>= 18'}
+
mixin-deep@1.3.2:
resolution: {integrity: sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==}
engines: {node: '>=0.10.0'}
+ mkdirp@0.5.6:
+ resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
+ hasBin: true
+
mlly@1.8.2:
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
@@ -7112,6 +7397,13 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
+ node-abi@4.33.0:
+ resolution: {integrity: sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==}
+ engines: {node: '>=22.12.0'}
+
+ node-api-version@0.2.1:
+ resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==}
+
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
@@ -7128,6 +7420,14 @@ packages:
resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==}
engines: {node: '>= 6.13.0'}
+ node-gyp@12.4.0:
+ resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==}
+ engines: {node: ^20.17.0 || >=22.9.0}
+ hasBin: true
+
+ node-int64@0.4.0:
+ resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
+
node-notifier@10.0.1:
resolution: {integrity: sha512-YX7TSyDukOZ0g+gmzjB6abKu+hTGvO8+8+gIFDsRCU2t8fLV/P2unmt+LGFaIa4y64aX98Qksa97rgz4vMNeLQ==}
@@ -7137,6 +7437,11 @@ packages:
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
+ nopt@9.0.0:
+ resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==}
+ engines: {node: ^20.17.0 || >=22.9.0}
+ hasBin: true
+
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
@@ -7422,6 +7727,10 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+ pe-library@0.4.1:
+ resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==}
+ engines: {node: '>=12', npm: '>=6'}
+
pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
@@ -7487,6 +7796,10 @@ packages:
pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
+ pkijs@3.4.0:
+ resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==}
+ engines: {node: '>=16.0.0'}
+
playwright-core@1.58.2:
resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
engines: {node: '>=18'}
@@ -7497,6 +7810,10 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ plist@3.1.0:
+ resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==}
+ engines: {node: '>=10.4.0'}
+
points-on-curve@0.2.0:
resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==}
@@ -7533,6 +7850,11 @@ packages:
posthog-js@1.392.0:
resolution: {integrity: sha512-dOZMzav59dRzI4SNmF+ZoRWj6evUVNxTMuHgbQWt+vTJzVl9UvdsUPFBBa13Skcw8eTwXpB54089yaCrEv11Ew==}
+ postject@1.0.0-alpha.6:
+ resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==}
+ engines: {node: '>=14.0.0'}
+ hasBin: true
+
powershell-utils@0.1.0:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
@@ -7616,6 +7938,10 @@ packages:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'}
+ proc-log@6.1.0:
+ resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==}
+ engines: {node: ^20.17.0 || >=22.9.0}
+
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
@@ -7626,6 +7952,10 @@ packages:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
+ promise-retry@2.0.1:
+ resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==}
+ engines: {node: '>=10'}
+
promise-toolbox@0.21.0:
resolution: {integrity: sha512-NV8aTmpwrZv+Iys54sSFOBx3tuVaOBvvrft5PNppnxy9xpU/akHbaWIril22AB22zaPgrgwKdD0KsrM0ptUtpg==}
engines: {node: '>=6'}
@@ -7637,6 +7967,9 @@ packages:
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+ proper-lockfile@4.1.2:
+ resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==}
+
property-information@6.5.0:
resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==}
@@ -7669,6 +8002,13 @@ packages:
resolution: {integrity: sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==}
engines: {node: '>=12.20'}
+ pvtsutils@1.3.6:
+ resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
+
+ pvutils@1.1.5:
+ resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
+ engines: {node: '>=16.0.0'}
+
qs@6.14.1:
resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==}
engines: {node: '>=0.6'}
@@ -7812,12 +8152,6 @@ packages:
react: '>=16.9.0'
react-dom: '>=16.9.0'
- rc-overflow@1.4.1:
- resolution: {integrity: sha512-3MoPQQPV1uKyOMVNd6SZfONi+f3st0r8PksexIdBTeIYbMX0Jr+k7pHEDvsXtR4BpCv90/Pv2MovVNhktKrwvw==}
- peerDependencies:
- react: '>=16.9.0'
- react-dom: '>=16.9.0'
-
rc-overflow@1.5.0:
resolution: {integrity: sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==}
peerDependencies:
@@ -8177,6 +8511,10 @@ packages:
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
engines: {node: '>=0.10.0'}
+ read-binary-file-arch@1.0.6:
+ resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==}
+ hasBin: true
+
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
@@ -8294,6 +8632,10 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
+ resedit@1.7.2:
+ resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==}
+ engines: {node: '>=12', npm: '>=6'}
+
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
@@ -8326,6 +8668,10 @@ packages:
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
engines: {node: '>=18'}
+ retry@0.12.0:
+ resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
+ engines: {node: '>= 4'}
+
rettime@0.7.0:
resolution: {integrity: sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==}
@@ -8336,6 +8682,11 @@ packages:
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
+ rimraf@2.6.3:
+ resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==}
+ deprecated: Rimraf versions prior to v4 are no longer supported
+ hasBin: true
+
rimraf@3.0.2:
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
deprecated: Rimraf versions prior to v4 are no longer supported
@@ -8405,6 +8756,9 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+ sanitize-filename@1.6.4:
+ resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==}
+
sax@1.4.1:
resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==}
@@ -8552,6 +8906,10 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
+ simple-update-notifier@2.0.0:
+ resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
+ engines: {node: '>=10'}
+
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
@@ -8632,6 +8990,10 @@ packages:
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+ stat-mode@1.0.0:
+ resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==}
+ engines: {node: '>= 6'}
+
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
@@ -8825,6 +9187,17 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
+ tar@7.5.19:
+ resolution: {integrity: sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==}
+ engines: {node: '>=18'}
+
+ temp-file@3.4.0:
+ resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==}
+
+ temp@0.9.4:
+ resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==}
+ engines: {node: '>=6.0.0'}
+
term-size@1.2.0:
resolution: {integrity: sha512-7dPUZQGy/+m3/wjVz3ZW5dobSoD/02NxJpoXUX0WIyjfVS3l0c+b/+9phIDFA7FHzkYtwtMFgeGZ/Y8jVTeqQQ==}
engines: {node: '>=4'}
@@ -8846,6 +9219,9 @@ packages:
resolution: {integrity: sha512-G7r3AhovYtr5YKOWQkta8RKAPb+J9IsO4uVmzjl8AZwfhs8UcUwTiD6gcJYSgOtzyjvQKrKYn41syHbUWMkafA==}
engines: {node: '>=0.10.0'}
+ tiny-async-pool@1.3.0:
+ resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==}
+
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -8877,6 +9253,9 @@ packages:
resolution: {integrity: sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==}
hasBin: true
+ tmp-promise@3.0.3:
+ resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==}
+
tmp@0.2.5:
resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==}
engines: {node: '>=14.14'}
@@ -8913,6 +9292,9 @@ packages:
trough@2.2.0:
resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
+ truncate-utf8-bytes@1.0.2:
+ resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==}
+
ts-api-utils@2.1.0:
resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==}
engines: {node: '>=18.12'}
@@ -9021,10 +9403,18 @@ packages:
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
+ undici@6.27.0:
+ resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==}
+ engines: {node: '>=18.17'}
+
undici@7.16.0:
resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==}
engines: {node: '>=20.18.1'}
+ undici@7.28.0:
+ resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
+ engines: {node: '>=20.18.1'}
+
unicorn-magic@0.3.0:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
@@ -9094,6 +9484,9 @@ packages:
resolution: {integrity: sha512-N0XH6lqDtFH84JxptQoZYmloF4nzrQqqrAymNj+/gW60AO2AZgOcf4O/nUXJcYfyQkqvMo9lSupBZmmgvuVXlw==}
engines: {node: '>=4'}
+ unzipper@0.12.5:
+ resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==}
+
upath@2.0.1:
resolution: {integrity: sha512-1uEe95xksV1O0CYKXo8vQvN1JEbtJp7lb7C5U9HMsIp6IVwntkH/oNUzyVNQSd4S1sYk2FpSSW44FqMc8qee5w==}
engines: {node: '>=4'}
@@ -9173,6 +9566,9 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ utf8-byte-length@1.0.5:
+ resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==}
+
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -9182,6 +9578,7 @@ packages:
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
+ deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
v8n@1.5.1:
@@ -9341,6 +9738,9 @@ packages:
web-vitals@5.3.0:
resolution: {integrity: sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==}
+ webcrypto-core@1.9.2:
+ resolution: {integrity: sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==}
+
webextension-polyfill@0.10.0:
resolution: {integrity: sha512-c5s35LgVa5tFaHhrZDnr3FpQpjj1BB+RXhLTYUxGqBVN460HkbM8TBtEqdXWbpTKfzwCcjAZVF7zXCYSKtcp9g==}
@@ -9354,6 +9754,7 @@ packages:
whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
+ deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
whatwg-fetch@3.6.20:
resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==}
@@ -9406,6 +9807,16 @@ packages:
engines: {node: ^16.13.0 || >=18.0.0}
hasBin: true
+ which@5.0.0:
+ resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==}
+ engines: {node: ^18.17.0 || >=20.5.0}
+ hasBin: true
+
+ which@6.0.1:
+ resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==}
+ engines: {node: ^20.17.0 || >=22.9.0}
+ hasBin: true
+
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
@@ -9509,6 +9920,10 @@ packages:
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
engines: {node: '>=4.0'}
+ xmlbuilder@15.1.1:
+ resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
+ engines: {node: '>=8.0'}
+
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
@@ -9522,6 +9937,13 @@ packages:
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+ yallist@4.0.0:
+ resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
+
+ yallist@5.0.0:
+ resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
+ engines: {node: '>=18'}
+
yaml@1.10.2:
resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}
engines: {node: '>= 6'}
@@ -9674,7 +10096,7 @@ snapshots:
'@ant-design/cssinjs@1.24.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
- '@babel/runtime': 7.28.4
+ '@babel/runtime': 7.29.2
'@emotion/hash': 0.8.0
'@emotion/unitless': 0.7.5
classnames: 2.5.1
@@ -9777,15 +10199,15 @@ snapshots:
'@babel/core@7.28.4':
dependencies:
- '@babel/code-frame': 7.27.1
- '@babel/generator': 7.28.3
+ '@babel/code-frame': 7.29.0
+ '@babel/generator': 7.28.5
'@babel/helper-compilation-targets': 7.27.2
'@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4)
'@babel/helpers': 7.28.4
- '@babel/parser': 7.28.4
+ '@babel/parser': 7.29.2
'@babel/template': 7.27.2
- '@babel/traverse': 7.28.4
- '@babel/types': 7.28.4
+ '@babel/traverse': 7.28.5
+ '@babel/types': 7.29.0
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3
@@ -9813,8 +10235,8 @@ snapshots:
'@babel/generator@7.28.5':
dependencies:
- '@babel/parser': 7.28.5
- '@babel/types': 7.28.5
+ '@babel/parser': 7.29.2
+ '@babel/types': 7.29.0
'@jridgewell/gen-mapping': 0.3.12
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
@@ -9827,7 +10249,7 @@ snapshots:
dependencies:
'@babel/compat-data': 7.28.0
'@babel/helper-validator-option': 7.27.1
- browserslist: 4.25.1
+ browserslist: 4.28.1
lru-cache: 5.1.1
semver: 6.3.1
@@ -9866,8 +10288,8 @@ snapshots:
'@babel/helper-module-imports@7.27.1':
dependencies:
- '@babel/traverse': 7.28.0
- '@babel/types': 7.28.4
+ '@babel/traverse': 7.28.5
+ '@babel/types': 7.29.0
transitivePeerDependencies:
- supports-color
@@ -9884,8 +10306,8 @@ snapshots:
dependencies:
'@babel/core': 7.28.4
'@babel/helper-module-imports': 7.27.1
- '@babel/helper-validator-identifier': 7.27.1
- '@babel/traverse': 7.28.4
+ '@babel/helper-validator-identifier': 7.28.5
+ '@babel/traverse': 7.28.5
transitivePeerDependencies:
- supports-color
@@ -9927,7 +10349,7 @@ snapshots:
'@babel/helpers@7.28.4':
dependencies:
'@babel/template': 7.27.2
- '@babel/types': 7.28.4
+ '@babel/types': 7.29.0
'@babel/parser@7.28.0':
dependencies:
@@ -9937,10 +10359,6 @@ snapshots:
dependencies:
'@babel/types': 7.28.4
- '@babel/parser@7.28.5':
- dependencies:
- '@babel/types': 7.28.5
-
'@babel/parser@7.29.2':
dependencies:
'@babel/types': 7.29.0
@@ -10015,9 +10433,9 @@ snapshots:
'@babel/template@7.27.2':
dependencies:
- '@babel/code-frame': 7.27.1
- '@babel/parser': 7.28.4
- '@babel/types': 7.28.4
+ '@babel/code-frame': 7.29.0
+ '@babel/parser': 7.29.2
+ '@babel/types': 7.29.0
'@babel/traverse@7.28.0':
dependencies:
@@ -10045,12 +10463,12 @@ snapshots:
'@babel/traverse@7.28.5':
dependencies:
- '@babel/code-frame': 7.27.1
+ '@babel/code-frame': 7.29.0
'@babel/generator': 7.28.5
'@babel/helper-globals': 7.28.0
- '@babel/parser': 7.28.5
+ '@babel/parser': 7.29.2
'@babel/template': 7.27.2
- '@babel/types': 7.28.5
+ '@babel/types': 7.29.0
debug: 4.4.3
transitivePeerDependencies:
- supports-color
@@ -10215,12 +10633,40 @@ snapshots:
dependencies:
'@noble/ciphers': 1.3.0
- '@electron/get@2.0.3':
+ '@electron-internal/extract-zip@1.0.4': {}
+
+ '@electron/asar@3.4.1':
dependencies:
- debug: 4.4.3
- env-paths: 2.2.1
- fs-extra: 8.1.0
- got: 11.8.6
+ commander: 5.1.0
+ glob: 7.2.3
+ minimatch: 3.1.2
+
+ '@electron/fuses@1.8.0':
+ dependencies:
+ chalk: 4.1.2
+ fs-extra: 9.1.0
+ minimist: 1.2.8
+
+ '@electron/get@2.0.3':
+ dependencies:
+ debug: 4.4.3
+ env-paths: 2.2.1
+ fs-extra: 8.1.0
+ got: 11.8.6
+ progress: 2.0.3
+ semver: 6.3.1
+ sumchecker: 3.0.1
+ optionalDependencies:
+ global-agent: 3.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ '@electron/get@3.1.0':
+ dependencies:
+ debug: 4.4.3
+ env-paths: 2.2.1
+ fs-extra: 8.1.0
+ got: 11.8.6
progress: 2.0.3
semver: 6.3.1
sumchecker: 3.0.1
@@ -10229,6 +10675,72 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@electron/get@5.0.0':
+ dependencies:
+ debug: 4.4.3
+ env-paths: 3.0.0
+ graceful-fs: 4.2.11
+ progress: 2.0.3
+ semver: 7.7.4
+ sumchecker: 3.0.1
+ optionalDependencies:
+ undici: 7.28.0
+ transitivePeerDependencies:
+ - supports-color
+
+ '@electron/notarize@2.5.0':
+ dependencies:
+ debug: 4.4.3
+ fs-extra: 9.1.0
+ promise-retry: 2.0.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@electron/osx-sign@1.3.3':
+ dependencies:
+ compare-version: 0.1.2
+ debug: 4.4.3
+ fs-extra: 10.1.0
+ isbinaryfile: 4.0.10
+ minimist: 1.2.8
+ plist: 3.1.0
+ transitivePeerDependencies:
+ - supports-color
+
+ '@electron/rebuild@4.1.0':
+ dependencies:
+ '@malept/cross-spawn-promise': 2.0.0
+ debug: 4.4.3
+ node-abi: 4.33.0
+ node-api-version: 0.2.1
+ node-gyp: 12.4.0
+ read-binary-file-arch: 1.0.6
+ transitivePeerDependencies:
+ - supports-color
+
+ '@electron/universal@2.0.3':
+ dependencies:
+ '@electron/asar': 3.4.1
+ '@malept/cross-spawn-promise': 2.0.0
+ debug: 4.4.3
+ dir-compare: 4.2.0
+ fs-extra: 11.3.4
+ minimatch: 9.0.5
+ plist: 3.1.0
+ transitivePeerDependencies:
+ - supports-color
+
+ '@electron/windows-sign@1.2.2':
+ dependencies:
+ cross-dirname: 0.1.0
+ debug: 4.4.3
+ fs-extra: 11.3.4
+ minimist: 1.2.8
+ postject: 1.0.0-alpha.6
+ transitivePeerDependencies:
+ - supports-color
+ optional: true
+
'@emnapi/core@1.7.1':
dependencies:
'@emnapi/wasi-threads': 1.1.0
@@ -10863,10 +11375,14 @@ snapshots:
dependencies:
'@isaacs/balanced-match': 4.0.1
+ '@isaacs/fs-minipass@4.0.1':
+ dependencies:
+ minipass: 7.1.3
+
'@jridgewell/gen-mapping@0.3.12':
dependencies:
- '@jridgewell/sourcemap-codec': 1.5.4
- '@jridgewell/trace-mapping': 0.3.29
+ '@jridgewell/sourcemap-codec': 1.5.5
+ '@jridgewell/trace-mapping': 0.3.31
'@jridgewell/remapping@2.3.5':
dependencies:
@@ -10887,7 +11403,7 @@ snapshots:
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
- '@jridgewell/sourcemap-codec': 1.5.4
+ '@jridgewell/sourcemap-codec': 1.5.5
'@lit-labs/ssr-dom-shim@1.4.0': {}
@@ -11013,6 +11529,19 @@ snapshots:
- micromark-util-types
- supports-color
+ '@malept/cross-spawn-promise@2.0.0':
+ dependencies:
+ cross-spawn: 7.0.6
+
+ '@malept/flatpak-bundler@0.4.0':
+ dependencies:
+ debug: 4.4.3
+ fs-extra: 9.1.0
+ lodash: 4.17.21
+ tmp-promise: 3.0.3
+ transitivePeerDependencies:
+ - supports-color
+
'@mdn/browser-compat-data@7.1.7': {}
'@mdx-js/mdx@3.1.1':
@@ -11106,10 +11635,14 @@ snapshots:
dependencies:
'@noble/hashes': 1.8.0
+ '@noble/hashes@1.4.0': {}
+
'@noble/hashes@1.8.0': {}
'@noble/hashes@2.0.1': {}
+ '@noble/hashes@2.2.0': {}
+
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -11259,6 +11792,28 @@ snapshots:
'@oxc-resolver/binding-win32-x64-msvc@11.19.1':
optional: true
+ '@peculiar/asn1-schema@2.8.0':
+ dependencies:
+ '@peculiar/utils': 2.0.3
+ asn1js: 3.0.10
+ tslib: 2.8.1
+
+ '@peculiar/json-schema@1.1.12':
+ dependencies:
+ tslib: 2.8.1
+
+ '@peculiar/utils@2.0.3':
+ dependencies:
+ tslib: 2.8.1
+
+ '@peculiar/webcrypto@1.7.1':
+ dependencies:
+ '@peculiar/asn1-schema': 2.8.0
+ '@peculiar/json-schema': 1.1.12
+ '@peculiar/utils': 2.0.3
+ tslib: 2.8.1
+ webcrypto-core: 1.9.2
+
'@plasmohq/storage@1.15.0(react@19.2.0)':
dependencies:
pify: 6.1.0
@@ -12328,17 +12883,6 @@ snapshots:
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
- '@rc-component/trigger@2.3.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
- dependencies:
- '@babel/runtime': 7.29.2
- '@rc-component/portal': 1.1.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- classnames: 2.5.1
- rc-motion: 2.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- rc-resize-observer: 1.4.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- rc-util: 5.44.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- react: 19.2.0
- react-dom: 19.2.0(react@19.2.0)
-
'@rc-component/trigger@2.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@babel/runtime': 7.29.2
@@ -12925,6 +13469,10 @@ snapshots:
'@types/firefox-webext-browser@120.0.4': {}
+ '@types/fs-extra@9.0.13':
+ dependencies:
+ '@types/node': 24.12.0
+
'@types/geojson@7946.0.16': {}
'@types/har-format@1.2.16': {}
@@ -13320,6 +13868,10 @@ snapshots:
async-mutex: 0.5.0
dequal: 2.0.3
+ '@xmldom/xmldom@0.8.13': {}
+
+ abbrev@4.0.0: {}
+
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -13429,6 +13981,13 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
+ ajv@8.20.0:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-uri: 3.1.0
+ json-schema-traverse: 1.0.0
+ require-from-string: 2.0.2
+
ansi-align@2.0.0:
dependencies:
string-width: 2.1.1
@@ -13540,6 +14099,54 @@ snapshots:
- luxon
- moment
+ app-builder-lib@26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3):
+ dependencies:
+ '@electron/asar': 3.4.1
+ '@electron/fuses': 1.8.0
+ '@electron/get': 3.1.0
+ '@electron/notarize': 2.5.0
+ '@electron/osx-sign': 1.3.3
+ '@electron/rebuild': 4.1.0
+ '@electron/universal': 2.0.3
+ '@malept/flatpak-bundler': 0.4.0
+ '@noble/hashes': 2.2.0
+ '@peculiar/webcrypto': 1.7.1
+ '@types/fs-extra': 9.0.13
+ ajv: 8.20.0
+ asn1js: 3.0.10
+ async-exit-hook: 2.0.1
+ builder-util: 26.15.3
+ builder-util-runtime: 9.7.0
+ chromium-pickle-js: 0.2.0
+ ci-info: 4.3.1
+ debug: 4.4.3
+ dmg-builder: 26.15.3(electron-builder-squirrel-windows@26.15.3)
+ dotenv: 16.6.1
+ dotenv-expand: 11.0.7
+ ejs: 3.1.10
+ electron-builder-squirrel-windows: 26.15.3(dmg-builder@26.15.3)
+ electron-publish: 26.15.3
+ fs-extra: 10.1.0
+ hosted-git-info: 4.1.0
+ isbinaryfile: 5.0.7
+ jiti: 2.6.1
+ js-yaml: 4.1.0
+ json5: 2.2.3
+ lazy-val: 1.0.5
+ minimatch: 10.2.5
+ pkijs: 3.4.0
+ plist: 3.1.0
+ proper-lockfile: 4.1.2
+ resedit: 1.7.2
+ semver: 7.7.4
+ tar: 7.5.19
+ temp-file: 3.4.0
+ tiny-async-pool: 1.3.0
+ unzipper: 0.12.5
+ which: 5.0.0
+ transitivePeerDependencies:
+ - supports-color
+
are-docs-informative@0.0.2: {}
argparse@2.0.1: {}
@@ -13608,6 +14215,12 @@ snapshots:
get-intrinsic: 1.3.0
is-array-buffer: 3.0.5
+ asn1js@3.0.10:
+ dependencies:
+ pvtsutils: 1.3.6
+ pvutils: 1.1.5
+ tslib: 2.8.1
+
assertion-error@2.0.1: {}
assign-symbols@1.0.0: {}
@@ -13624,6 +14237,8 @@ snapshots:
astring@1.9.0: {}
+ async-exit-hook@2.0.1: {}
+
async-function@1.0.0: {}
async-mutex@0.5.0:
@@ -13632,6 +14247,10 @@ snapshots:
async@3.2.6: {}
+ asynckit@0.4.0: {}
+
+ at-least-node@1.0.0: {}
+
atomic-sleep@1.0.0: {}
atomically@2.0.3:
@@ -13655,9 +14274,11 @@ snapshots:
dependencies:
possible-typed-array-names: 1.1.0
+ aws4@1.13.2: {}
+
babel-plugin-macros@3.1.0:
dependencies:
- '@babel/runtime': 7.28.4
+ '@babel/runtime': 7.29.2
cosmiconfig: 7.1.0
resolve: 1.22.10
@@ -13691,6 +14312,8 @@ snapshots:
balanced-match@4.0.4: {}
+ base64-js@1.5.1: {}
+
baseline-browser-mapping@2.9.11: {}
bidi-js@1.0.3:
@@ -13781,12 +14404,40 @@ snapshots:
buffer-from@1.1.2: {}
+ builder-util-runtime@9.7.0:
+ dependencies:
+ debug: 4.4.3
+ sax: 1.4.1
+ transitivePeerDependencies:
+ - supports-color
+
+ builder-util@26.15.3:
+ dependencies:
+ '@types/debug': 4.1.12
+ builder-util-runtime: 9.7.0
+ chalk: 4.1.2
+ cross-spawn: 7.0.6
+ debug: 4.4.3
+ fs-extra: 10.1.0
+ http-proxy-agent: 7.0.2
+ https-proxy-agent: 7.0.6
+ js-yaml: 4.1.0
+ sanitize-filename: 1.6.4
+ source-map-support: 0.5.21
+ stat-mode: 1.0.0
+ temp-file: 3.4.0
+ tiny-async-pool: 1.3.0
+ transitivePeerDependencies:
+ - supports-color
+
bundle-name@4.1.0:
dependencies:
run-applescript: 7.1.0
bytes@3.1.2: {}
+ bytestreamjs@2.0.1: {}
+
c12@3.3.4(magicast@0.5.2):
dependencies:
chokidar: 5.0.0
@@ -13915,6 +14566,8 @@ snapshots:
dependencies:
readdirp: 5.0.0
+ chownr@3.0.0: {}
+
chroma-js@3.1.2: {}
chrome-launcher@1.2.0:
@@ -13926,8 +14579,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ chromium-pickle-js@0.2.0: {}
+
ci-info@1.6.0: {}
+ ci-info@4.3.1: {}
+
ci-info@4.4.0: {}
citty@0.2.2: {}
@@ -14011,6 +14668,10 @@ snapshots:
strip-ansi: 6.0.1
wcwidth: 1.0.1
+ combined-stream@1.0.8:
+ dependencies:
+ delayed-stream: 1.0.0
+
comma-separated-tokens@2.0.3: {}
commander@11.1.0: {}
@@ -14023,6 +14684,8 @@ snapshots:
dependencies:
graceful-readlink: 1.0.1
+ commander@5.1.0: {}
+
commander@7.2.0: {}
commander@8.3.0: {}
@@ -14033,6 +14696,8 @@ snapshots:
common-tags@1.8.2: {}
+ compare-version@0.1.2: {}
+
compute-scroll-into-view@3.1.1: {}
concat-map@0.0.1: {}
@@ -14133,6 +14798,9 @@ snapshots:
dependencies:
capture-stack-trace: 1.0.2
+ cross-dirname@0.1.0:
+ optional: true
+
cross-spawn@5.1.0:
dependencies:
lru-cache: 4.1.5
@@ -14481,6 +15149,8 @@ snapshots:
dependencies:
robust-predicates: 3.0.2
+ delayed-stream@1.0.0: {}
+
depd@2.0.0: {}
dequal@2.0.3: {}
@@ -14500,6 +15170,21 @@ snapshots:
diff@8.0.2: {}
+ dir-compare@4.2.0:
+ dependencies:
+ minimatch: 3.1.2
+ p-limit: 3.1.0
+
+ dmg-builder@26.15.3(electron-builder-squirrel-windows@26.15.3):
+ dependencies:
+ app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)
+ builder-util: 26.15.3
+ fs-extra: 10.1.0
+ js-yaml: 4.1.0
+ transitivePeerDependencies:
+ - electron-builder-squirrel-windows
+ - supports-color
+
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
@@ -14546,6 +15231,10 @@ snapshots:
dependencies:
type-fest: 4.41.0
+ dotenv-expand@11.0.7:
+ dependencies:
+ dotenv: 16.6.1
+
dotenv-expand@12.0.3:
dependencies:
dotenv: 16.6.1
@@ -14562,6 +15251,10 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
+ duplexer2@0.1.4:
+ dependencies:
+ readable-stream: 2.3.8
+
duplexer3@0.1.5: {}
ecdsa-sig-formatter@1.0.11:
@@ -14582,10 +15275,65 @@ snapshots:
ee-first@1.1.1: {}
+ ejs@3.1.10:
+ dependencies:
+ jake: 10.9.4
+
+ electron-builder-squirrel-windows@26.15.3(dmg-builder@26.15.3):
+ dependencies:
+ app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)
+ builder-util: 26.15.3
+ electron-winstaller: 5.4.0
+ transitivePeerDependencies:
+ - dmg-builder
+ - supports-color
+
+ electron-builder@26.15.3(electron-builder-squirrel-windows@26.15.3):
+ dependencies:
+ app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3)
+ builder-util: 26.15.3
+ builder-util-runtime: 9.7.0
+ chalk: 4.1.2
+ ci-info: 4.4.0
+ dmg-builder: 26.15.3(electron-builder-squirrel-windows@26.15.3)
+ fs-extra: 10.1.0
+ lazy-val: 1.0.5
+ simple-update-notifier: 2.0.0
+ yargs: 17.7.2
+ transitivePeerDependencies:
+ - electron-builder-squirrel-windows
+ - supports-color
+
+ electron-publish@26.15.3:
+ dependencies:
+ '@types/fs-extra': 9.0.13
+ aws4: 1.13.2
+ builder-util: 26.15.3
+ builder-util-runtime: 9.7.0
+ chalk: 4.1.2
+ form-data: 4.0.6
+ fs-extra: 10.1.0
+ lazy-val: 1.0.5
+ mime: 2.6.0
+ transitivePeerDependencies:
+ - supports-color
+
electron-to-chromium@1.5.187: {}
electron-to-chromium@1.5.267: {}
+ electron-winstaller@5.4.0:
+ dependencies:
+ '@electron/asar': 3.4.1
+ debug: 4.4.3
+ fs-extra: 7.0.1
+ lodash: 4.17.21
+ temp: 0.9.4
+ optionalDependencies:
+ '@electron/windows-sign': 1.2.2
+ transitivePeerDependencies:
+ - supports-color
+
electron@23.3.13:
dependencies:
'@electron/get': 2.0.3
@@ -14594,6 +15342,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ electron@43.0.0:
+ dependencies:
+ '@electron-internal/extract-zip': 1.0.4
+ '@electron/get': 5.0.0
+ '@types/node': 24.12.0
+ transitivePeerDependencies:
+ - supports-color
+
emoji-mart@5.6.0: {}
emoji-regex@10.4.0: {}
@@ -14624,11 +15380,11 @@ snapshots:
env-paths@2.2.1: {}
+ env-paths@3.0.0: {}
+
environment@1.1.0: {}
- error-ex@1.3.2:
- dependencies:
- is-arrayish: 0.2.1
+ err-code@2.0.3: {}
error-ex@1.3.4:
dependencies:
@@ -15090,6 +15846,8 @@ snapshots:
expect-type@1.2.2: {}
+ exponential-backoff@3.1.3: {}
+
express-rate-limit@7.5.1(express@5.2.1):
dependencies:
express: 5.2.1
@@ -15223,6 +15981,10 @@ snapshots:
dependencies:
tslib: 2.8.1
+ filelist@1.0.6:
+ dependencies:
+ minimatch: 5.1.9
+
filesize@11.0.15: {}
fill-range@7.1.1:
@@ -15280,6 +16042,14 @@ snapshots:
form-data-encoder@4.1.0: {}
+ form-data@4.0.6:
+ dependencies:
+ asynckit: 0.4.0
+ combined-stream: 1.0.8
+ es-set-tostringtag: 2.1.0
+ hasown: 2.0.4
+ mime-types: 2.1.35
+
formatly@0.3.0:
dependencies:
fd-package-json: 2.0.0
@@ -15305,6 +16075,18 @@ snapshots:
fresh@2.0.0: {}
+ fs-extra@10.1.0:
+ dependencies:
+ graceful-fs: 4.2.11
+ jsonfile: 6.2.0
+ universalify: 2.0.1
+
+ fs-extra@11.3.1:
+ dependencies:
+ graceful-fs: 4.2.11
+ jsonfile: 6.2.0
+ universalify: 2.0.1
+
fs-extra@11.3.2:
dependencies:
graceful-fs: 4.2.11
@@ -15317,12 +16099,25 @@ snapshots:
jsonfile: 6.2.0
universalify: 2.0.1
+ fs-extra@7.0.1:
+ dependencies:
+ graceful-fs: 4.2.11
+ jsonfile: 4.0.0
+ universalify: 0.1.2
+
fs-extra@8.1.0:
dependencies:
graceful-fs: 4.2.11
jsonfile: 4.0.0
universalify: 0.1.2
+ fs-extra@9.1.0:
+ dependencies:
+ at-least-node: 1.0.0
+ graceful-fs: 4.2.11
+ jsonfile: 6.2.0
+ universalify: 2.0.1
+
fs.realpath@1.0.0: {}
fsevents@2.3.2:
@@ -15457,7 +16252,7 @@ snapshots:
es6-error: 4.1.1
matcher: 3.0.0
roarr: 2.15.4
- semver: 7.7.2
+ semver: 7.7.4
serialize-error: 7.0.1
optional: true
@@ -15558,6 +16353,10 @@ snapshots:
dependencies:
function-bind: 1.1.2
+ hasown@2.0.4:
+ dependencies:
+ function-bind: 1.1.2
+
hast-util-from-dom@5.0.1:
dependencies:
'@types/hast': 3.0.4
@@ -15709,6 +16508,10 @@ snapshots:
hookable@6.1.0: {}
+ hosted-git-info@4.1.0:
+ dependencies:
+ lru-cache: 6.0.0
+
html-encoding-sniffer@4.0.0:
dependencies:
whatwg-encoding: 3.1.1
@@ -16154,12 +16957,18 @@ snapshots:
isarray@2.0.5: {}
+ isbinaryfile@4.0.10: {}
+
+ isbinaryfile@5.0.7: {}
+
isexe@1.1.2: {}
isexe@2.0.0: {}
isexe@3.1.1: {}
+ isexe@4.0.0: {}
+
isobject@3.0.1: {}
istanbul-lib-coverage@3.2.2: {}
@@ -16183,6 +16992,12 @@ snapshots:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
+ jake@10.9.4:
+ dependencies:
+ async: 3.2.6
+ filelist: 1.0.6
+ picocolors: 1.1.1
+
jiti@2.6.1: {}
jose@5.9.6: {}
@@ -16363,6 +17178,8 @@ snapshots:
layout-base@2.0.1: {}
+ lazy-val@1.0.5: {}
+
leva@0.10.0(@types/react@19.1.12)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@radix-ui/react-portal': 1.0.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -16624,6 +17441,10 @@ snapshots:
dependencies:
yallist: 3.1.1
+ lru-cache@6.0.0:
+ dependencies:
+ yallist: 4.0.0
+
lucide-react@0.469.0(react@19.2.0):
dependencies:
react: 19.2.0
@@ -16660,7 +17481,7 @@ snapshots:
make-dir@4.0.0:
dependencies:
- semver: 7.7.2
+ semver: 7.7.4
make-error@1.3.6: {}
@@ -17201,12 +18022,20 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.1
+ mime-db@1.52.0: {}
+
mime-db@1.54.0: {}
+ mime-types@2.1.35:
+ dependencies:
+ mime-db: 1.52.0
+
mime-types@3.0.2:
dependencies:
mime-db: 1.54.0
+ mime@2.6.0: {}
+
mimic-fn@2.1.0: {}
mimic-function@5.0.1: {}
@@ -17235,6 +18064,10 @@ snapshots:
dependencies:
brace-expansion: 1.1.12
+ minimatch@5.1.9:
+ dependencies:
+ brace-expansion: 2.0.2
+
minimatch@9.0.5:
dependencies:
brace-expansion: 2.0.2
@@ -17243,11 +18076,19 @@ snapshots:
minipass@7.1.3: {}
+ minizlib@3.1.0:
+ dependencies:
+ minipass: 7.1.3
+
mixin-deep@1.3.2:
dependencies:
for-in: 1.0.2
is-extendable: 1.0.1
+ mkdirp@0.5.6:
+ dependencies:
+ minimist: 1.2.8
+
mlly@1.8.2:
dependencies:
acorn: 8.16.0
@@ -17311,6 +18152,14 @@ snapshots:
negotiator@1.0.0: {}
+ node-abi@4.33.0:
+ dependencies:
+ semver: 7.7.4
+
+ node-api-version@0.2.1:
+ dependencies:
+ semver: 7.7.4
+
node-domexception@1.0.0: {}
node-fetch-native@1.6.7: {}
@@ -17323,6 +18172,21 @@ snapshots:
node-forge@1.3.1: {}
+ node-gyp@12.4.0:
+ dependencies:
+ env-paths: 2.2.1
+ exponential-backoff: 3.1.3
+ graceful-fs: 4.2.11
+ nopt: 9.0.0
+ proc-log: 6.1.0
+ semver: 7.7.4
+ tar: 7.5.19
+ tinyglobby: 0.2.15
+ undici: 6.27.0
+ which: 6.0.1
+
+ node-int64@0.4.0: {}
+
node-notifier@10.0.1:
dependencies:
growly: 1.3.0
@@ -17336,6 +18200,10 @@ snapshots:
node-releases@2.0.27: {}
+ nopt@9.0.0:
+ dependencies:
+ abbrev: 4.0.0
+
normalize-path@3.0.0: {}
normalize-range@0.1.2: {}
@@ -17618,8 +18486,8 @@ snapshots:
parse-json@5.2.0:
dependencies:
- '@babel/code-frame': 7.27.1
- error-ex: 1.3.2
+ '@babel/code-frame': 7.29.0
+ error-ex: 1.3.4
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
@@ -17691,6 +18559,8 @@ snapshots:
pathe@2.0.3: {}
+ pe-library@0.4.1: {}
+
pend@1.2.0: {}
perfect-debounce@2.1.0: {}
@@ -17761,6 +18631,15 @@ snapshots:
exsolve: 1.0.8
pathe: 2.0.3
+ pkijs@3.4.0:
+ dependencies:
+ '@noble/hashes': 1.4.0
+ asn1js: 3.0.10
+ bytestreamjs: 2.0.1
+ pvtsutils: 1.3.6
+ pvutils: 1.1.5
+ tslib: 2.8.1
+
playwright-core@1.58.2: {}
playwright@1.58.2:
@@ -17769,6 +18648,12 @@ snapshots:
optionalDependencies:
fsevents: 2.3.2
+ plist@3.1.0:
+ dependencies:
+ '@xmldom/xmldom': 0.8.13
+ base64-js: 1.5.1
+ xmlbuilder: 15.1.1
+
points-on-curve@0.2.0: {}
points-on-path@0.2.1:
@@ -17817,6 +18702,11 @@ snapshots:
query-selector-shadow-dom: 1.0.1
web-vitals: 5.3.0
+ postject@1.0.0-alpha.6:
+ dependencies:
+ commander: 9.5.0
+ optional: true
+
powershell-utils@0.1.0: {}
preact@10.29.2: {}
@@ -17843,12 +18733,19 @@ snapshots:
dependencies:
parse-ms: 4.0.0
+ proc-log@6.1.0: {}
+
process-nextick-args@2.0.1: {}
process-warning@5.0.0: {}
progress@2.0.3: {}
+ promise-retry@2.0.1:
+ dependencies:
+ err-code: 2.0.3
+ retry: 0.12.0
+
promise-toolbox@0.21.0:
dependencies:
make-error: 1.3.6
@@ -17864,6 +18761,12 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
+ proper-lockfile@4.1.2:
+ dependencies:
+ graceful-fs: 4.2.11
+ retry: 0.12.0
+ signal-exit: 3.0.7
+
property-information@6.5.0: {}
property-information@7.1.0: {}
@@ -17900,6 +18803,12 @@ snapshots:
dependencies:
escape-goat: 4.0.0
+ pvtsutils@1.3.6:
+ dependencies:
+ tslib: 2.8.1
+
+ pvutils@1.1.5: {}
+
qs@6.14.1:
dependencies:
side-channel: 1.1.0
@@ -18116,10 +19025,10 @@ snapshots:
rc-menu@9.16.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@babel/runtime': 7.29.2
- '@rc-component/trigger': 2.3.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@rc-component/trigger': 2.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
classnames: 2.5.1
rc-motion: 2.9.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- rc-overflow: 1.4.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ rc-overflow: 1.5.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
rc-util: 5.44.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
@@ -18141,15 +19050,6 @@ snapshots:
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
- rc-overflow@1.4.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
- dependencies:
- '@babel/runtime': 7.29.2
- classnames: 2.5.1
- rc-resize-observer: 1.4.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- rc-util: 5.44.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
- react: 19.2.0
- react-dom: 19.2.0(react@19.2.0)
-
rc-overflow@1.5.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@babel/runtime': 7.29.2
@@ -18322,7 +19222,7 @@ snapshots:
rc-util@5.44.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
- '@babel/runtime': 7.28.4
+ '@babel/runtime': 7.29.2
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
react-is: 18.3.1
@@ -18567,6 +19467,12 @@ snapshots:
react@19.2.4: {}
+ read-binary-file-arch@1.0.6:
+ dependencies:
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
@@ -18781,6 +19687,10 @@ snapshots:
require-from-string@2.0.2: {}
+ resedit@1.7.2:
+ dependencies:
+ pe-library: 0.4.1
+
reselect@5.1.1: {}
reserved-identifiers@1.2.0: {}
@@ -18808,12 +19718,18 @@ snapshots:
onetime: 7.0.0
signal-exit: 4.1.0
+ retry@0.12.0: {}
+
rettime@0.7.0: {}
reusify@1.1.0: {}
rfdc@1.4.1: {}
+ rimraf@2.6.3:
+ dependencies:
+ glob: 7.2.3
+
rimraf@3.0.2:
dependencies:
glob: 7.2.3
@@ -18921,6 +19837,10 @@ snapshots:
safer-buffer@2.1.2: {}
+ sanitize-filename@1.6.4:
+ dependencies:
+ truncate-utf8-bytes: 1.0.2
+
sax@1.4.1: {}
saxes@6.0.0:
@@ -19155,6 +20075,10 @@ snapshots:
signal-exit@4.1.0: {}
+ simple-update-notifier@2.0.0:
+ dependencies:
+ semver: 7.7.4
+
sisteransi@1.0.5: {}
slice-ansi@7.1.2:
@@ -19225,6 +20149,8 @@ snapshots:
stackback@0.0.2: {}
+ stat-mode@1.0.0: {}
+
statuses@2.0.2: {}
std-env@3.10.0: {}
@@ -19408,6 +20334,24 @@ snapshots:
tapable@2.3.0: {}
+ tar@7.5.19:
+ dependencies:
+ '@isaacs/fs-minipass': 4.0.1
+ chownr: 3.0.0
+ minipass: 7.1.3
+ minizlib: 3.1.0
+ yallist: 5.0.0
+
+ temp-file@3.4.0:
+ dependencies:
+ async-exit-hook: 2.0.1
+ fs-extra: 10.1.0
+
+ temp@0.9.4:
+ dependencies:
+ mkdirp: 0.5.6
+ rimraf: 2.6.3
+
term-size@1.2.0:
dependencies:
execa: 0.7.0
@@ -19424,6 +20368,10 @@ snapshots:
timed-out@4.0.1: {}
+ tiny-async-pool@1.3.0:
+ dependencies:
+ semver: 5.7.2
+
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
@@ -19447,6 +20395,10 @@ snapshots:
dependencies:
tldts-core: 7.0.17
+ tmp-promise@3.0.3:
+ dependencies:
+ tmp: 0.2.5
+
tmp@0.2.5: {}
to-regex-range@5.0.1:
@@ -19478,6 +20430,10 @@ snapshots:
trough@2.2.0: {}
+ truncate-utf8-bytes@1.0.2:
+ dependencies:
+ utf8-byte-length: 1.0.5
+
ts-api-utils@2.1.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
@@ -19594,8 +20550,13 @@ snapshots:
undici-types@7.16.0: {}
+ undici@6.27.0: {}
+
undici@7.16.0: {}
+ undici@7.28.0:
+ optional: true
+
unicorn-magic@0.3.0: {}
unified@11.0.5:
@@ -19712,6 +20673,14 @@ snapshots:
unzip-response@2.0.1: {}
+ unzipper@0.12.5:
+ dependencies:
+ bluebird: 3.7.2
+ duplexer2: 0.1.4
+ fs-extra: 11.3.1
+ graceful-fs: 4.2.11
+ node-int64: 0.4.0
+
upath@2.0.1: {}
update-browserslist-db@1.1.3(browserslist@4.25.1):
@@ -19799,6 +20768,8 @@ snapshots:
dependencies:
react: 19.2.4
+ utf8-byte-length@1.0.5: {}
+
util-deprecate@1.0.2: {}
uuid@11.1.0: {}
@@ -20002,6 +20973,14 @@ snapshots:
web-vitals@5.3.0: {}
+ webcrypto-core@1.9.2:
+ dependencies:
+ '@peculiar/asn1-schema': 2.8.0
+ '@peculiar/json-schema': 1.1.12
+ '@peculiar/utils': 2.0.3
+ asn1js: 3.0.10
+ tslib: 2.8.1
+
webextension-polyfill@0.10.0: {}
webidl-conversions@8.0.0: {}
@@ -20083,6 +21062,14 @@ snapshots:
dependencies:
isexe: 3.1.1
+ which@5.0.0:
+ dependencies:
+ isexe: 3.1.1
+
+ which@6.0.1:
+ dependencies:
+ isexe: 4.0.0
+
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
@@ -20223,6 +21210,8 @@ snapshots:
xmlbuilder@11.0.1: {}
+ xmlbuilder@15.1.1: {}
+
xmlchars@2.2.0: {}
y18n@5.0.8: {}
@@ -20231,6 +21220,10 @@ snapshots:
yallist@3.1.1: {}
+ yallist@4.0.0: {}
+
+ yallist@5.0.0: {}
+
yaml@1.10.2: {}
yaml@2.8.1: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
new file mode 100644
index 0000000000..bc9da4fe9c
--- /dev/null
+++ b/pnpm-workspace.yaml
@@ -0,0 +1,13 @@
+packages:
+ - tools/channel-key-importer
+
+allowBuilds:
+ "@swc/core": false
+ core-js: false
+ electron: true
+ electron-winstaller: true
+ esbuild: false
+ msw: false
+ sharp: false
+ spawn-sync: false
+ unrs-resolver: false
diff --git a/tools/channel-key-importer/Dockerfile b/tools/channel-key-importer/Dockerfile
new file mode 100644
index 0000000000..bf7d11958b
--- /dev/null
+++ b/tools/channel-key-importer/Dockerfile
@@ -0,0 +1,24 @@
+FROM node:24-alpine
+
+WORKDIR /app
+
+COPY --chown=node:node package.json ./package.json
+COPY --chown=node:node public ./public
+COPY --chown=node:node src ./src
+
+RUN mkdir -p /data && chown node:node /data
+
+ENV CHANNEL_IMPORTER_NO_OPEN=1 \
+ CHANNEL_IMPORTER_PORT=4179 \
+ DATAEYESAI_DATA_DIR=/data \
+ DATAEYESAI_HOST=0.0.0.0 \
+ DATAEYESAI_STORAGE=sqlite
+
+USER node
+
+EXPOSE 4179
+
+HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
+ CMD wget -q --spider http://127.0.0.1:4179/ || exit 1
+
+CMD ["node", "src/server.js"]
diff --git a/tools/channel-key-importer/README.md b/tools/channel-key-importer/README.md
new file mode 100644
index 0000000000..b522001804
--- /dev/null
+++ b/tools/channel-key-importer/README.md
@@ -0,0 +1,115 @@
+# dataeyesai
+
+一个只监听本机回环地址的可视化渠道 Key 导入器。使用 New API 系统访问令牌连接站点后,可以明确选择复制已有同类型渠道、通过 New API 向供应商获取模型、使用 New API 内置模型或手动填写配置,再批量创建渠道。
+
+可以保存多个 OpenAI 兼容自定义供应商。每个预设记录自定义名称和 Base URL,按名称自然排序显示;选择后使用 New API 的 OpenAI 渠道类型写入,并完整复用批量 Key、模型读取、手动模型、额度、分组、优先级、定时队列、去重和用量记录流程。预设保存在本地状态;服务器版启用 SQLite 时会在多设备间共享。
+
+界面按任务拆成五个独立工作区:导入渠道、定时任务、用量监测、上 Key 记录和站点设置。每个工作区只展示完成当前任务需要的信息,顶部目标站点状态可随时进入站点设置。视觉采用平面的运营工具风格,统一使用清晰的无衬线数字,并在导入首页使用随安装包提供的本地流程插画;页面不加载外部图片、字体或第三方前端资源。
+
+批量写入默认使用“每条 Key 建立独立渠道”。工具会先写入 1 条验证当前站点可用,成功后将剩余 Key 以 AES-GCM 加密放入安全队列,按每分钟 1 条自动写入,避免批量请求触发 New API 的共享管理接口限流。若 New API 返回明确的 429,当前项和尚未提交的 Key 会立即停止请求并进入自动续传;已经成功的项不会重发。程序只在上游给出不超过 5 秒的明确 `Retry-After` 时原地重试一次,否则交给后台队列;未提供等待时间时默认等待 180 秒,可用 `DATAEYESAI_RATE_LIMIT_FALLBACK_SECONDS` 调整。预览只读取当前渠道类型,分组和默认模型会短时缓存;用量自动校准只抽样 3 条并串行执行,避免在上 Key 前耗尽同一 IP 的管理请求额度。也可以选择 New API 原生 `multi_to_single`,一次请求把所有 Key 写入一个多 Key 渠道,由 New API 随机轮询,同时利用日志里的 Key 索引保留单 Key 用量统计。
+
+点击导入按钮时只进行识别和预览,不会立即写入。预览弹窗会显示原始数量、去重后数量、跳过的重复数量和录入总额度,管理员核对后再手动确认。系统会同时跳过当前粘贴内容中的重复 Key、该 New API 站点已经录入的 Key,以及仍在定时队列中的 Key;真正写入前还会再次检查,避免并发操作造成重复渠道。上 Key 记录按每次操作分批展示,并可展开按序号核对该批次中的每一条 Key。
+
+发现同类渠道时,可以继续新建,也可以选择已有渠道:多 Key 渠道会追加新 Key,单 Key 渠道会在明确确认后替换原 Key,完成后自动启用渠道。更新已有渠道不会改动其模型或模型重定向。
+
+可以保存并切换多个 New API 站点。每一批 Key 的渠道类型由用户明确选择,工具不会猜测 Key 来源。Key 和额度输入默认明文显示,方便写入前核对,两个输入区都可以单独切换为隐藏。额度默认使用“统一额度”,整批 Key 只需填写一次金额;也可以切换为“逐条额度”并按行对应,不知道时填写 `x`,行数不一致时拒绝写入。AWS 等组合凭证可以在 Key 框原样填写。批量新建时每条 Key 对应一个独立渠道。工具会保存不含明文 Key 的导入台账,包括 Key 尾号、不可逆指纹、录入额度、目标站点、渠道和时间。能查询渠道余额时会据此估算消耗;多 Key 共用渠道或上游不支持余额查询时,不会伪造单 Key 消耗。
+
+共享服务器使用用户名和密码登录 New API 时,可以只保存登录后的 Session:密码始终不落盘,Session 使用服务器主密钥 AES-GCM 加密后写入 SQLite,使定时任务在容器重启后仍能继续。也可以关闭该选项,让 Session 只保留在当前进程内。
+
+连接站点后会读取该 New API 的管理员分组列表。创建渠道时可选择一个或多个分组,工具会按当前站点校验分组,并同时写入 New API 的 `group` 和 `groups` 字段;默认选择 `default`。
+
+创建渠道时可以设置优先级和权重:优先级越高的渠道越先使用,同优先级渠道按权重比例分流。该设置位于“批量写入方式”正下方;留空时新渠道使用 `0`,复制已有渠道时沿用原值,更新已有渠道时只有明确填写才修改。每条 Key 建立独立渠道时还可开启“批量 Key 优先级依次递减”,第一条使用所填优先级,后续按粘贴顺序和指定步长递减;定时任务跨批次执行时仍沿用整批 Key 的原始顺序。定时上 Key 的首次执行时间支持精确到秒,后台按最近任务时间动态唤醒。等待中或已暂停的任务可直接修改下一次执行时间、每批写入数量和执行间隔;修改保存后后台会立即按新时间重新排程,已完成、已取消或正在执行的任务不可修改。
+
+上 Key 记录按每次实际写入批次汇总,列表直接显示写入时间、成功数量、录入额度、累计消耗和请求次数,每条 Key 的渠道与用量明细按需展开。新记录使用批次 ID 精确归组;升级前的旧记录按站点、供应商、操作类型和相近写入时间归组。界面中的日期、数量和金额统一使用清晰的无衬线阿拉伯数字。
+
+定时任务中的失败 Key 不会被删除,仍以加密形式保存在任务中。任务卡会显示“重试失败 Key(数量)”,点击后只把失败项重新加入待写入队列,不会重复已经成功的 Key;上一次错误原因会保留到重试成功,方便排查临时网络错误和无效 Key。
+
+## 运行
+
+要求 Node.js 24+。
+
+```bash
+pnpm channel-importer
+```
+
+服务默认打开 `http://127.0.0.1:4179`。可通过 `CHANNEL_IMPORTER_PORT` 修改端口。
+
+## 桌面安装包
+
+GitHub Actions 会构建三个安装包:
+
+- macOS Apple Silicon(M1/M2/M3/M4 等):DMG
+- macOS Intel:DMG
+- Windows x64:NSIS EXE 安装程序
+
+本机启动桌面开发版:
+
+```bash
+pnpm channel-importer:desktop
+```
+
+构建当前系统安装包:
+
+```bash
+pnpm channel-importer:package
+```
+
+安装包输出到 `tools/channel-key-importer/dist/`。推送后可在 GitHub Actions 手动运行 `dataeyesai desktop packages`,成功后会建立带三个安装包的预发布版本。
+
+当前安装包尚未配置 Apple 和 Microsoft 付费代码签名证书,因此首次启动可能出现系统安全提示。应用本身仍固定监听随机本机回环端口,不对局域网或公网开放。
+
+## 多设备服务器版
+
+服务器版使用独立的 dataeyesai 系统访问密钥保护整个工作台。未登录时只能看到登录页,无法读取站点、任务、Key 记录或调用业务接口;登录态 12 小时后失效,同一来源连续输错 5 次会锁定 15 分钟。这个密钥与 New API 管理令牌完全分开,服务端只保存 `scrypt` 哈希。
+
+启用 `DATAEYESAI_STORAGE=sqlite` 后,站点配置、定时任务、额度快照和导入记录统一保存在服务器 SQLite 数据库。多台电脑和手机访问同一服务器时看到的是同一套数据。需要记住的 New API 系统令牌会先用服务器主密钥执行 AES-256-GCM 加密,再写入数据库;定时任务 Key 也保持加密存储。
+
+仓库提供 Docker 部署文件:
+
+```bash
+cd tools/channel-key-importer
+cp server.env.example server.env
+node src/generateAccessKey.js
+openssl rand -base64 32
+docker compose --env-file server.env -f compose.server.yml up -d --build
+```
+
+把生成结果中的 `accessKeyHash` 和主密钥写入 `server.env`,只把一次性显示的 `accessKey` 交给需要登录的设备。`server.env` 和 `server-data/` 已加入 Git 忽略,不会上传到 GitHub。
+
+直接使用 `http://IP:4179` 虽然有登录密钥保护,但浏览器到服务器之间的渠道 Key 仍没有传输加密。生产环境应使用域名和 HTTPS 反向代理,并将 `DATAEYESAI_PUBLIC_HOSTS`、`DATAEYESAI_PUBLIC_ORIGINS` 和 `DATAEYESAI_SECURE_COOKIE` 改成对应的 HTTPS 配置。
+
+## 安全约束
+
+- 服务固定监听 `127.0.0.1`,并校验 Host、Origin 和随机本地会话令牌。
+- 公网 New API 默认要求 HTTPS。旧部署只有 HTTP 时,界面会显示显式风险开关;未勾选时不会发送管理员密码、Session、访问令牌或渠道 Key。
+- 默认使用 New API 用户名和密码直接登录。密码只用于这一次登录请求,不写入浏览器、配置文件或日志;登录 Session 仅保存在本地服务内存中,服务关闭后失效。
+- 渠道凭证只在内存预览中保存五分钟,创建或过期后立即移除。
+- 启用两步验证、OAuth 或验证码的账号可改用系统访问令牌;网页开发版在 macOS 使用钥匙串,桌面安装版使用 Electron 对接的系统安全存储加密令牌。普通配置文件只保存目标地址和用户 ID。
+- 日志、错误信息和浏览器响应均不返回完整 Key。
+- 没有产品统计、远程日志或第三方前端资源。
+
+## 渠道范围
+
+渠道类型和值来自 New API 官方的 [`constant/channel.go`](https://github.com/QuantumNous/new-api/blob/main/constant/channel.go),目前展示 54 种默认类型。模型发现使用 New API 管理接口 `POST /api/channel/fetch_models`,由目标版本执行对应渠道协议。
+
+已收录厂商使用项目现有的 MIT 许可 [`@lobehub/icons`](https://github.com/lobehub/lobe-icons) 图标并从本机提供;无法准确归属官方厂商的兼容渠道保留字母徽标,避免误导。
+
+Codex 订阅渠道需要在 New API 内完成 OAuth,Advanced Custom 需要高级配置,因此这两种类型会显示,但不会伪装成“只凭一个 Key”就能导入。Azure、AWS、VertexAI、火山引擎等来源可能要求组合凭证或额外部署信息,界面会展示提示,并原样把用户提供的凭证交给 New API 校验。
+
+预览区包含模型工作台:可以补充模型、把“对外标准名”映射到“上游实际名”,也可以根据厂商前缀和日期后缀生成一组保守的名称建议。最终创建请求会把标准名加入 `models`,并按 New API 的 `标准名 → 实际名` 方向写入 `model_mapping`。手动新增、尚未配置倍率的模型可能需要在 New API 中启用自用模式或补充倍率/价格。
+
+OpenRouter 会自动把唯一的 `供应商/模型` 变成 `模型 → 供应商/模型` 重定向,并且只把简洁模型名暴露到新渠道。若多个供应商返回相同的简洁模型名,工具会保留这些完整模型名,避免把请求静默路由到错误供应商。
+
+云厂商按 New API 当前协议使用专用表单:AWS Bedrock 支持 `AccessKey|SecretAccessKey|Region` 与 `APIKey|Region` 两种模式并写入 `settings.aws_key_type`,还可把模型定向到系统或 Application Inference Profile ID/ARN;Vertex AI 支持服务账号 JSON 文件或 API Key,并把默认/模型专用地区写入 `other`;Azure 要求填写 Endpoint 和实际部署名称。这些渠道可以直接选择一个已配置好的同类型渠道,复制模型、重定向、地区、Base URL 和高级参数,只替换新 Key。OpenRouter 会自动按 `模型 → 供应商/模型` 写入重定向;存在同名冲突时保留完整名称。
+
+渠道创建后,工具会调用 New API 的单渠道余额刷新接口。支持该能力的上游会显示当前剩余额度(统一为 USD),并把首次查询值保存在本机非敏感快照中,用于计算“导入后已消耗”。充值或赠送导致余额升高时,工具会停止推算消耗,避免展示错误数字。部分上游没有可用的账单/余额接口,此时界面会明确显示无法自动查询。
+
+Key 台账的实时用量来自目标 New API 的管理员消费日志,并按站点配置、渠道 ID 和多 Key 索引统计;额度余额仍来自供应商支持的余额接口。两者统计口径分开显示,避免把 New API 网关计费误当成供应商账户余额。
+
+## 测试
+
+```bash
+pnpm test:channel-importer
+```
+
+测试不会连接真实 New API,也不会发送真实凭证。
diff --git a/tools/channel-key-importer/compose.server.yml b/tools/channel-key-importer/compose.server.yml
new file mode 100644
index 0000000000..14e4b7dddb
--- /dev/null
+++ b/tools/channel-key-importer/compose.server.yml
@@ -0,0 +1,16 @@
+services:
+ dataeyesai:
+ build:
+ context: .
+ container_name: dataeyesai-channel-manager
+ restart: unless-stopped
+ env_file:
+ - server.env
+ ports:
+ - "${DATAEYESAI_PUBLIC_PORT:-4179}:4179"
+ volumes:
+ - ./server-data:/data
+ security_opt:
+ - no-new-privileges:true
+ cap_drop:
+ - ALL
diff --git a/tools/channel-key-importer/desktop/main.js b/tools/channel-key-importer/desktop/main.js
new file mode 100644
index 0000000000..ba3df3c64c
--- /dev/null
+++ b/tools/channel-key-importer/desktop/main.js
@@ -0,0 +1,139 @@
+import { mkdir, readFile, writeFile } from "node:fs/promises"
+import { homedir } from "node:os"
+import { dirname, join } from "node:path"
+import { app, BrowserWindow, dialog, Menu, safeStorage, shell } from "electron"
+
+let importerServer = null
+let mainWindow = null
+
+class ElectronTokenStore {
+ #filePath
+ #mutation = Promise.resolve()
+
+ constructor(filePath) {
+ this.#filePath = filePath
+ }
+
+ async #readAll() {
+ try {
+ const value = JSON.parse(await readFile(this.#filePath, "utf8"))
+ return value && typeof value === "object" ? value : {}
+ } catch {
+ return {}
+ }
+ }
+
+ async save(account, token) {
+ if (!safeStorage.isEncryptionAvailable()) {
+ throw new Error("当前系统暂时无法安全保存管理员令牌")
+ }
+ const operation = this.#mutation.then(async () => {
+ const tokens = await this.#readAll()
+ tokens[account] = safeStorage.encryptString(token).toString("base64")
+ await mkdir(dirname(this.#filePath), { recursive: true, mode: 0o700 })
+ await writeFile(this.#filePath, `${JSON.stringify(tokens, null, 2)}\n`, {
+ encoding: "utf8",
+ mode: 0o600,
+ })
+ })
+ this.#mutation = operation.catch(() => {})
+ return await operation
+ }
+
+ async read(account) {
+ if (!safeStorage.isEncryptionAvailable()) return ""
+ try {
+ const encoded = (await this.#readAll())[account]
+ return encoded
+ ? safeStorage.decryptString(Buffer.from(encoded, "base64"))
+ : ""
+ } catch {
+ return ""
+ }
+ }
+}
+
+const isExternalWebUrl = (value) => {
+ try {
+ const url = new URL(value)
+ return url.protocol === "https:" || url.protocol === "http:"
+ } catch {
+ return false
+ }
+}
+
+async function createWindow() {
+ mainWindow = new BrowserWindow({
+ title: "dataeyesai",
+ width: 1440,
+ height: 960,
+ minWidth: 980,
+ minHeight: 700,
+ show: false,
+ backgroundColor: "#eef4fd",
+ webPreferences: {
+ contextIsolation: true,
+ nodeIntegration: false,
+ sandbox: true,
+ },
+ })
+ mainWindow.webContents.setWindowOpenHandler(({ url }) => {
+ if (isExternalWebUrl(url)) void shell.openExternal(url)
+ return { action: "deny" }
+ })
+ mainWindow.webContents.on("will-navigate", (event, url) => {
+ if (new URL(url).origin !== new URL(importerServer.url).origin) {
+ event.preventDefault()
+ if (isExternalWebUrl(url)) void shell.openExternal(url)
+ }
+ })
+ mainWindow.once("ready-to-show", () => mainWindow?.show())
+ mainWindow.on("closed", () => {
+ mainWindow = null
+ })
+ await mainWindow.loadURL(importerServer.url)
+}
+
+if (!app.requestSingleInstanceLock()) {
+ app.quit()
+} else {
+ app.on("second-instance", () => {
+ if (mainWindow) {
+ if (mainWindow.isMinimized()) mainWindow.restore()
+ mainWindow.show()
+ mainWindow.focus()
+ }
+ })
+
+ app.whenReady().then(async () => {
+ try {
+ app.setName("dataeyesai")
+ Menu.setApplicationMenu(null)
+ const dataDir = app.getPath("userData")
+ const { migrateDataFiles } = await import("../src/dataMigration.js")
+ await migrateDataFiles(join(homedir(), ".config", "dataeyesai"), dataDir)
+ process.env.DATAEYESAI_DATA_DIR = dataDir
+ const { startImporterServer } = await import("../src/server.js")
+ importerServer = await startImporterServer({
+ port: 0,
+ tokenStore: new ElectronTokenStore(join(dataDir, "secure-tokens.json")),
+ })
+ await createWindow()
+ } catch (error) {
+ dialog.showErrorBox(
+ "dataeyesai 启动失败",
+ error instanceof Error ? error.message : "未知错误",
+ )
+ app.quit()
+ }
+ })
+
+ app.on("activate", () => {
+ if (!mainWindow && importerServer) void createWindow()
+ })
+
+ app.on("window-all-closed", () => app.quit())
+ app.on("will-quit", () => {
+ if (importerServer) void importerServer.close()
+ })
+}
diff --git a/tools/channel-key-importer/package.json b/tools/channel-key-importer/package.json
new file mode 100644
index 0000000000..3eb8deaada
--- /dev/null
+++ b/tools/channel-key-importer/package.json
@@ -0,0 +1,62 @@
+{
+ "name": "dataeyesai",
+ "private": true,
+ "type": "module",
+ "version": "0.7.1",
+ "author": "dataeyesai contributors",
+ "license": "MIT",
+ "main": "desktop/main.js",
+ "description": "A loopback-only visual importer for adding provider API keys to New API channels.",
+ "scripts": {
+ "start": "node src/server.js",
+ "desktop": "electron desktop/main.js",
+ "package:current": "electron-builder --publish never",
+ "package:mac": "electron-builder --mac dmg --publish never",
+ "package:win": "electron-builder --win nsis --publish never",
+ "generate:access-key": "node src/generateAccessKey.js",
+ "test": "node --test tests/*.test.js"
+ },
+ "engines": {
+ "node": ">=24"
+ },
+ "devDependencies": {
+ "electron": "43.0.0",
+ "electron-builder": "26.15.3"
+ },
+ "build": {
+ "appId": "ai.dataeyes.channel-importer",
+ "productName": "dataeyesai",
+ "asar": true,
+ "directories": {
+ "output": "dist"
+ },
+ "files": [
+ "desktop/**/*",
+ "public/**/*",
+ "src/**/*",
+ "package.json"
+ ],
+ "artifactName": "dataeyesai-${version}-${os}-${arch}.${ext}",
+ "mac": {
+ "target": [
+ "dmg"
+ ],
+ "category": "public.app-category.developer-tools",
+ "icon": "../../src/assets/icon.png",
+ "identity": null
+ },
+ "win": {
+ "target": [
+ "nsis"
+ ],
+ "icon": "../../src/assets/icon.png"
+ },
+ "nsis": {
+ "oneClick": false,
+ "allowToChangeInstallationDirectory": true,
+ "createDesktopShortcut": true,
+ "createStartMenuShortcut": true,
+ "shortcutName": "dataeyesai"
+ }
+ }
+}
diff --git a/tools/channel-key-importer/public/app.js b/tools/channel-key-importer/public/app.js
new file mode 100644
index 0000000000..19b8b88d1e
--- /dev/null
+++ b/tools/channel-key-importer/public/app.js
@@ -0,0 +1,3117 @@
+import {
+ filterUsageDashboardRecords,
+ filterUsageRecords,
+ gatewaySpent,
+ groupImportRecords,
+ groupUsageDashboardByDay,
+ groupUsageDashboardByTarget,
+ localDateKey,
+ summarizeUsageDashboard,
+ summarizeUsageRecords,
+ usageState,
+} from "./usageStats.js"
+import { APP_VIEWS, normalizeAppView } from "./viewState.js"
+
+const state = {
+ sessionToken: "",
+ providers: [],
+ customProviders: [],
+ profiles: [],
+ records: [],
+ schedules: [],
+ channelTemplates: [],
+ activeProfileId: "",
+ profileNameEdited: false,
+ channelNameEdited: false,
+ selectedProvider: null,
+ category: "全部",
+ configured: false,
+ groups: [],
+ preview: null,
+ pendingSchedule: null,
+ mappings: [],
+ createdChannelId: null,
+ pendingInsecureLoginUrl: "",
+ credentialTargetUrl: "",
+ credentialUserId: "",
+ usageAutoRefreshStarted: false,
+ createInFlight: false,
+ activeView: "import",
+}
+
+const TOKEN_PLACEHOLDER = "粘贴管理员的系统访问令牌"
+const USAGE_REFRESH_INTERVAL_MS = 3_000
+const pause = (milliseconds) =>
+ new Promise((resolve) => setTimeout(resolve, milliseconds))
+
+const $ = (selector) => document.querySelector(selector)
+const elements = {
+ accessLogout: $("#access-logout"),
+ serviceLabel: $("#service-label"),
+ serviceDetail: $("#service-detail"),
+ pageEyebrow: $("#page-eyebrow"),
+ pageTitle: $("#page-title"),
+ pageDescription: $("#page-description"),
+ connectionPill: $("#connection-pill"),
+ configForm: $("#config-form"),
+ configStatus: $("#config-status"),
+ targetUrl: $("#target-url"),
+ profileSelect: $("#profile-select"),
+ profileName: $("#profile-name"),
+ newProfile: $("#new-profile"),
+ loginUsername: $("#login-username"),
+ loginPassword: $("#login-password"),
+ rememberSession: $("#remember-session"),
+ openLoginPage: $("#open-login-page"),
+ allowInsecureHttp: $("#allow-insecure-http"),
+ insecureHttpRow: $(".insecure-http-row"),
+ tokenForm: $("#token-form"),
+ userId: $("#user-id"),
+ adminToken: $("#admin-token"),
+ rememberToken: $("#remember-token"),
+ toggleConfig: $("#toggle-config"),
+ providerCount: $("#provider-count"),
+ providerSearch: $("#provider-search"),
+ categoryList: $("#category-list"),
+ providerList: $("#provider-list"),
+ openCustomProviders: $("#open-custom-providers"),
+ customProviderDialog: $("#custom-provider-dialog"),
+ closeCustomProviders: $("#close-custom-providers"),
+ customProviderForm: $("#custom-provider-form"),
+ customProviderId: $("#custom-provider-id"),
+ customProviderName: $("#custom-provider-name"),
+ customProviderBaseUrl: $("#custom-provider-base-url"),
+ resetCustomProvider: $("#reset-custom-provider"),
+ saveCustomProvider: $("#save-custom-provider"),
+ customProviderStatus: $("#custom-provider-status"),
+ customProviderList: $("#custom-provider-list"),
+ credentialEmpty: $("#credential-empty"),
+ credentialForm: $("#credential-form"),
+ credentialStatus: $("#credential-status"),
+ selectedType: $("#selected-type"),
+ providerMonogram: $("#provider-monogram"),
+ providerName: $("#provider-name"),
+ providerDescription: $("#provider-description"),
+ channelType: $("#channel-type"),
+ channelName: $("#channel-name"),
+ channelGroups: $("#channel-groups"),
+ channelGroupsHelp: $("#channel-groups-help"),
+ refreshGroups: $("#refresh-groups"),
+ channelPriority: $("#channel-priority"),
+ channelPriorityHelp: $("#channel-priority-help"),
+ channelWeight: $("#channel-weight"),
+ channelWeightHelp: $("#channel-weight-help"),
+ priorityDescending: $("#priority-descending"),
+ priorityStepField: $("#priority-step-field"),
+ priorityStep: $("#priority-step"),
+ sourceBaseUrl: $("#source-base-url"),
+ baseUrlHelp: $("#base-url-help"),
+ configSource: $("#config-source"),
+ configSourceHelp: $("#config-source-help"),
+ refreshChannelTemplates: $("#refresh-channel-templates"),
+ apiKey: $("#api-key"),
+ keyHelp: $("#key-help"),
+ rawKeyField: $("#raw-key-field"),
+ providerConfig: $("#provider-config"),
+ credentialModeField: $("#credential-mode-field"),
+ credentialMode: $("#credential-mode"),
+ providerConfigFields: $("#provider-config-fields"),
+ providerModelsField: $("#provider-models-field"),
+ providerModels: $("#provider-models"),
+ providerModelsHelp: $("#provider-models-help"),
+ providerModelMappingsField: $("#provider-model-mappings-field"),
+ providerModelMappingsLabel: $("#provider-model-mappings-label"),
+ providerModelMappings: $("#provider-model-mappings"),
+ providerModelMappingsHelp: $("#provider-model-mappings-help"),
+ quotaMode: $("#quota-mode"),
+ uniformQuotaField: $("#uniform-quota-field"),
+ uniformQuota: $("#uniform-quota"),
+ perLineQuotaField: $("#per-line-quota-field"),
+ keyQuotas: $("#key-quotas"),
+ quotaHelp: $("#quota-help"),
+ awsGlobalField: $("#aws-global-field"),
+ awsGlobalInference: $("#aws-global-inference"),
+ scheduleEnabled: $("#schedule-enabled"),
+ scheduleOptions: $("#schedule-options"),
+ scheduleStartAt: $("#schedule-start-at"),
+ scheduleBatchSize: $("#schedule-batch-size"),
+ scheduleIntervalMinutes: $("#schedule-interval-minutes"),
+ batchMode: $("#batch-mode"),
+ keyLabel: $("#key-label"),
+ unsupportedNote: $("#unsupported-note"),
+ previewButton: $("#preview-button"),
+ previewDialog: $("#preview-dialog"),
+ previewPanel: $("#preview-panel"),
+ previewName: $("#preview-name"),
+ previewProvider: $("#preview-provider"),
+ previewBaseUrl: $("#preview-base-url"),
+ previewGroups: $("#preview-groups"),
+ previewPriority: $("#preview-priority"),
+ previewWeight: $("#preview-weight"),
+ previewAwsRoutingFact: $("#preview-aws-routing-fact"),
+ previewAwsRouting: $("#preview-aws-routing"),
+ modelCount: $("#model-count"),
+ batchInputCount: $("#batch-input-count"),
+ batchKeyCount: $("#batch-key-count"),
+ batchSkippedCount: $("#batch-skipped-count"),
+ batchQuotaTotal: $("#batch-quota-total"),
+ batchDedupCopy: $("#batch-dedup-copy"),
+ modelList: $("#model-list"),
+ modelOverflow: $("#model-overflow"),
+ finalModelCount: $("#final-model-count"),
+ mappingCount: $("#mapping-count"),
+ mappingList: $("#mapping-list"),
+ actualModelOptions: $("#actual-model-options"),
+ suggestMappings: $("#suggest-mappings"),
+ addMapping: $("#add-mapping"),
+ manualModels: $("#manual-models"),
+ duplicateBox: $("#duplicate-box"),
+ duplicateCopy: $("#duplicate-copy"),
+ duplicateTarget: $("#duplicate-target"),
+ duplicateConfirmCopy: $("#duplicate-confirm-copy"),
+ confirmDuplicates: $("#confirm-duplicates"),
+ createChannel: $("#create-channel"),
+ discardPreview: $("#discard-preview"),
+ createStatus: $("#create-status"),
+ balanceCard: $("#balance-card"),
+ balanceMetrics: $("#balance-metrics"),
+ remainingBalance: $("#remaining-balance"),
+ spentBalance: $("#spent-balance"),
+ initialBalance: $("#initial-balance"),
+ balanceMessage: $("#balance-message"),
+ refreshBalance: $("#refresh-balance"),
+ refreshRecords: $("#refresh-records"),
+ refreshSchedules: $("#refresh-schedules"),
+ scheduleEmpty: $("#schedule-empty"),
+ scheduleList: $("#schedule-list"),
+ refreshUsageMonitor: $("#refresh-usage-monitor"),
+ usageMonitorSyncStatus: $("#usage-monitor-sync-status"),
+ usageMonitorTarget: $("#usage-monitor-target"),
+ usageMonitorStart: $("#usage-monitor-start"),
+ usageMonitorEnd: $("#usage-monitor-end"),
+ usageMonitorRange: $("#usage-monitor-range"),
+ monitorKeyCount: $("#monitor-key-count"),
+ monitorKeyDetail: $("#monitor-key-detail"),
+ monitorQuotaTotal: $("#monitor-quota-total"),
+ monitorQuotaDetail: $("#monitor-quota-detail"),
+ monitorSpentTotal: $("#monitor-spent-total"),
+ monitorSpentDetail: $("#monitor-spent-detail"),
+ monitorRemainingTotal: $("#monitor-remaining-total"),
+ monitorRemainingRing: $("#monitor-remaining-ring"),
+ monitorRemainingRingValue: $("#monitor-remaining-ring-value"),
+ monitorRemainingPercent: $("#monitor-remaining-percent"),
+ monitorRemainingDetail: $("#monitor-remaining-detail"),
+ monitorCoveragePercent: $("#monitor-coverage-percent"),
+ monitorCoverageDetail: $("#monitor-coverage-detail"),
+ usageMonitorGrid: $("#usage-monitor-grid"),
+ siteUsageList: $("#site-usage-list"),
+ dailyUsageChart: $("#daily-usage-chart"),
+ usageMonitorEmpty: $("#usage-monitor-empty"),
+ usageKeyCount: $("#usage-key-count"),
+ usageKeyDetail: $("#usage-key-detail"),
+ usageQuotaTotal: $("#usage-quota-total"),
+ usageQuotaDetail: $("#usage-quota-detail"),
+ usageSpentTotal: $("#usage-spent-total"),
+ usageCoverageDetail: $("#usage-coverage-detail"),
+ usageRemainingTotal: $("#usage-remaining-total"),
+ usageRequestCount: $("#usage-request-count"),
+ usageLastChecked: $("#usage-last-checked"),
+ usageTokenTotal: $("#usage-token-total"),
+ usageTokenDetail: $("#usage-token-detail"),
+ usageSummaryNote: $("#usage-summary-note"),
+ recordsTargetFilter: $("#records-target-filter"),
+ recordsProviderFilter: $("#records-provider-filter"),
+ recordsStatusFilter: $("#records-status-filter"),
+ recordsSearch: $("#records-search"),
+ resetRecordFilters: $("#reset-record-filters"),
+ recordsEmpty: $("#records-empty"),
+ recordsTableWrap: $("#records-table-wrap"),
+ recordsBody: $("#records-body"),
+ toast: $("#toast"),
+}
+
+elements.accessLogout.addEventListener("click", async () => {
+ elements.accessLogout.disabled = true
+ try {
+ await fetch("/api/auth/logout", { method: "POST" })
+ } finally {
+ window.location.reload()
+ }
+})
+
+function setAppView(value, { updateHash = true, scroll = true } = {}) {
+ const view = normalizeAppView(value)
+ const copy = APP_VIEWS[view]
+ state.activeView = view
+ elements.pageEyebrow.textContent = copy.eyebrow
+ elements.pageTitle.textContent = copy.title
+ elements.pageDescription.textContent = copy.description
+ document.querySelectorAll("[data-app-view]").forEach((section) => {
+ section.classList.toggle("view-hidden", section.dataset.appView !== view)
+ })
+ document.querySelectorAll("[data-view-target]").forEach((link) => {
+ const active = link.dataset.viewTarget === view
+ link.classList.toggle("active", active)
+ if (link.closest("nav")) {
+ if (active) link.setAttribute("aria-current", "page")
+ else link.removeAttribute("aria-current")
+ }
+ })
+ if (updateHash && window.location.hash !== `#${view}`) {
+ history.replaceState(null, "", `#${view}`)
+ }
+ if (scroll) window.scrollTo({ top: 0, behavior: "smooth" })
+}
+
+for (const link of document.querySelectorAll("[data-view-target]")) {
+ link.addEventListener("click", (event) => {
+ event.preventDefault()
+ setAppView(link.dataset.viewTarget)
+ })
+}
+elements.connectionPill.addEventListener("click", () => setAppView("sites"))
+window.addEventListener("hashchange", () =>
+ setAppView(window.location.hash, { updateHash: false }),
+)
+
+const setLoading = (button, loading) => {
+ button.classList.toggle("loading", loading)
+ button.disabled = loading
+}
+
+const showStatus = (
+ element,
+ message,
+ isError = false,
+ { recovering = false } = {},
+) => {
+ element.textContent = message
+ element.classList.remove("hidden")
+ element.classList.toggle("error", isError)
+ element.classList.toggle("recovering", recovering)
+}
+
+const hideStatus = (element) => {
+ element.textContent = ""
+ element.classList.add("hidden")
+ element.classList.remove("error")
+ element.classList.remove("recovering")
+}
+
+let toastTimer
+const toast = (message, isError = false) => {
+ clearTimeout(toastTimer)
+ elements.toast.textContent = message
+ elements.toast.classList.remove("hidden")
+ elements.toast.classList.toggle("error", isError)
+ toastTimer = setTimeout(() => elements.toast.classList.add("hidden"), 4200)
+}
+
+async function api(path, options = {}) {
+ const send = () =>
+ fetch(path, {
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ "X-Importer-Session": state.sessionToken,
+ ...options.headers,
+ },
+ })
+ let response = await send()
+ let payload
+ try {
+ payload = await response.json()
+ } catch {
+ throw new Error("本地服务返回异常")
+ }
+ if (response.status === 401) {
+ window.location.reload()
+ throw new Error("系统登录已失效,请重新输入访问密钥")
+ }
+ if (
+ response.status === 403 &&
+ payload.error === "本地会话已失效,请刷新页面"
+ ) {
+ const bootstrapResponse = await fetch("/api/bootstrap", {
+ cache: "no-store",
+ })
+ const bootstrapPayload = await bootstrapResponse.json()
+ if (bootstrapResponse.ok && bootstrapPayload.sessionToken) {
+ state.sessionToken = bootstrapPayload.sessionToken
+ response = await send()
+ payload = await response.json()
+ }
+ }
+ if (!response.ok) {
+ const error = new Error(payload.error || "操作失败")
+ error.status = response.status
+ error.retryAfterMs = Number.isFinite(payload.retryAfterMs)
+ ? payload.retryAfterMs
+ : null
+ throw error
+ }
+ return payload
+}
+
+const initials = (name) =>
+ name
+ .replace(/[^a-zA-Z0-9 ]/g, " ")
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((word) => word[0])
+ .join("")
+ .toUpperCase() || "AI"
+
+function renderProviderIcon(container, provider) {
+ container.replaceChildren()
+ if (!provider.hasIcon) {
+ container.textContent = initials(provider.name)
+ return
+ }
+ const image = document.createElement("img")
+ image.src = `/provider-icons/${encodeURIComponent(provider.id)}.svg`
+ image.alt = ""
+ image.loading = "lazy"
+ image.addEventListener("error", () => {
+ image.remove()
+ container.textContent = initials(provider.name)
+ })
+ container.append(image)
+}
+
+function renderConnection(config) {
+ state.configured = Boolean(
+ (config.hasSession || config.hasToken) && config.targetUrl && config.userId,
+ )
+ elements.targetUrl.value = config.targetUrl || ""
+ elements.profileName.value = config.name || ""
+ state.profileNameEdited = false
+ state.activeProfileId = config.profileId || ""
+ elements.profileSelect.value = state.activeProfileId
+ elements.loginUsername.value = config.username || ""
+ elements.userId.value = config.userId || "1"
+ elements.rememberToken.checked = config.rememberToken !== false
+ elements.rememberSession.checked = config.profileId
+ ? config.rememberSession === true
+ : true
+ elements.allowInsecureHttp.checked = config.allowInsecureHttp === true
+ updateInsecureHttpVisibility()
+ elements.connectionPill.classList.toggle("connected", state.configured)
+ elements.connectionPill.querySelector("strong").textContent = state.configured
+ ? new URL(config.targetUrl).host
+ : "尚未配置"
+ state.credentialTargetUrl = config.targetUrl || ""
+ state.credentialUserId = config.userId || ""
+ if (config.hasToken) {
+ elements.adminToken.required = false
+ elements.adminToken.placeholder = "已安全保存;修改时重新输入"
+ } else {
+ elements.adminToken.required = true
+ elements.adminToken.placeholder = TOKEN_PLACEHOLDER
+ }
+}
+
+function resetSavedTokenHintIfConnectionChanged() {
+ const target = elements.targetUrl.value.trim().replace(/\/+$/, "")
+ const savedTarget = state.credentialTargetUrl.replace(/\/+$/, "")
+ const userId = elements.userId.value.trim()
+ if (target === savedTarget && userId === state.credentialUserId) return
+ elements.adminToken.required = true
+ elements.adminToken.placeholder = TOKEN_PLACEHOLDER
+}
+
+function renderProfiles() {
+ elements.profileSelect.replaceChildren()
+ const emptyOption = document.createElement("option")
+ emptyOption.value = ""
+ emptyOption.textContent =
+ state.profiles.length === 0 ? "尚未添加站点" : "+ 添加新的 New API"
+ elements.profileSelect.append(emptyOption)
+ for (const profile of state.profiles) {
+ const option = document.createElement("option")
+ option.value = profile.profileId
+ option.textContent = `${profile.name} · ${profile.target}`
+ elements.profileSelect.append(option)
+ }
+ elements.profileSelect.value = state.activeProfileId
+}
+
+function renderGroups(groups = []) {
+ state.groups = groups
+ elements.channelGroups.replaceChildren()
+ for (const group of groups) {
+ const label = document.createElement("label")
+ label.className = "group-option"
+ const input = document.createElement("input")
+ input.type = "checkbox"
+ input.name = "channel-group"
+ input.value = group
+ input.checked = group === "default"
+ const text = document.createElement("span")
+ text.textContent = group
+ label.append(input, text)
+ elements.channelGroups.append(label)
+ }
+ if (groups.length > 0 && !groups.includes("default")) {
+ elements.channelGroups.querySelector("input").checked = true
+ }
+ if (groups.length === 0) {
+ const empty = document.createElement("span")
+ empty.className = "group-options-empty"
+ empty.textContent = state.configured
+ ? "没有读取到分组,请点右下角重新读取。"
+ : "连接 New API 后显示可选分组。"
+ elements.channelGroups.append(empty)
+ }
+ elements.channelGroupsHelp.textContent =
+ groups.length > 0
+ ? `已读取 ${groups.length} 个分组,直接点击即可多选。`
+ : "连接 New API 后读取该站点分组。"
+ elements.refreshGroups.disabled = !state.configured
+}
+
+async function loadGroups() {
+ if (!state.configured) {
+ renderGroups([])
+ return
+ }
+ elements.refreshGroups.disabled = true
+ elements.channelGroupsHelp.textContent = "正在读取当前站点分组…"
+ try {
+ const result = await api("/api/groups")
+ renderGroups(result.groups || [])
+ } catch (error) {
+ renderGroups([])
+ elements.channelGroupsHelp.textContent = `分组读取失败:${error.message}`
+ if (/认证|access token|Unauthorized/i.test(error.message)) {
+ state.configured = false
+ elements.connectionPill.classList.remove("connected")
+ elements.connectionPill.querySelector("strong").textContent = "令牌已失效"
+ elements.adminToken.required = true
+ elements.adminToken.placeholder =
+ "令牌已失效,请粘贴新的管理员系统访问令牌"
+ showStatus(elements.configStatus, error.message, true)
+ elements.configForm.classList.remove("hidden")
+ }
+ toast(error.message, true)
+ } finally {
+ elements.refreshGroups.disabled = !state.configured
+ }
+}
+
+const selectedGroups = () =>
+ [
+ ...elements.channelGroups.querySelectorAll(
+ 'input[type="checkbox"]:checked',
+ ),
+ ].map((input) => input.value)
+
+function renderCategories() {
+ const categories = [
+ "全部",
+ ...new Set(state.providers.map((item) => item.category)),
+ ]
+ elements.categoryList.replaceChildren()
+ for (const category of categories) {
+ const button = document.createElement("button")
+ button.type = "button"
+ button.textContent = category
+ button.classList.toggle("active", state.category === category)
+ button.addEventListener("click", () => {
+ state.category = category
+ renderCategories()
+ renderProviders()
+ })
+ elements.categoryList.append(button)
+ }
+}
+
+function filteredProviders() {
+ const query = elements.providerSearch.value.trim().toLowerCase()
+ const matches = state.providers.filter((provider) => {
+ const matchesCategory =
+ state.category === "全部" || provider.category === state.category
+ const haystack =
+ `${provider.name} ${provider.id} ${provider.baseUrl || ""} ${provider.description}`.toLowerCase()
+ return matchesCategory && (!query || haystack.includes(query))
+ })
+ const custom = matches
+ .filter((provider) => provider.customProvider)
+ .sort((left, right) =>
+ left.name.localeCompare(right.name, "zh-CN", {
+ numeric: true,
+ sensitivity: "base",
+ }),
+ )
+ return [...custom, ...matches.filter((provider) => !provider.customProvider)]
+}
+
+function renderProviders() {
+ const providers = filteredProviders()
+ elements.providerCount.textContent = `${providers.length} / ${state.providers.length}`
+ elements.providerList.replaceChildren()
+ if (providers.length === 0) {
+ const empty = document.createElement("div")
+ empty.className = "no-results"
+ empty.textContent = "没有匹配的渠道来源"
+ elements.providerList.append(empty)
+ return
+ }
+ for (const provider of providers) {
+ const button = document.createElement("button")
+ button.type = "button"
+ button.className = "provider-card"
+ button.classList.toggle(
+ "active",
+ state.selectedProvider?.id === provider.id,
+ )
+ button.classList.toggle("disabled", !provider.importable)
+ button.setAttribute("role", "option")
+ button.setAttribute(
+ "aria-selected",
+ String(state.selectedProvider?.id === provider.id),
+ )
+
+ const icon = document.createElement("div")
+ icon.className = "provider-icon"
+ renderProviderIcon(icon, provider)
+ const copy = document.createElement("div")
+ const name = document.createElement("strong")
+ name.textContent = provider.name
+ const description = document.createElement("small")
+ description.textContent = provider.category
+ copy.append(name, description)
+ const type = document.createElement("span")
+ type.textContent = provider.customProvider
+ ? "OPENAI"
+ : `#${provider.channelType}`
+ button.append(icon, copy, type)
+ button.addEventListener("click", () => selectProvider(provider))
+ elements.providerList.append(button)
+ }
+}
+
+function resetCustomProviderForm() {
+ elements.customProviderId.value = ""
+ elements.customProviderName.value = ""
+ elements.customProviderBaseUrl.value = ""
+ hideStatus(elements.customProviderStatus)
+ elements.saveCustomProvider.querySelector("span").textContent = "保存并使用"
+}
+
+function renderCustomProviders() {
+ elements.customProviderList.replaceChildren()
+ if (state.customProviders.length === 0) {
+ const empty = document.createElement("div")
+ empty.className = "custom-provider-empty"
+ empty.textContent = "还没有保存 OpenAI 兼容供应商。"
+ elements.customProviderList.append(empty)
+ return
+ }
+ for (const provider of state.customProviders) {
+ const row = document.createElement("article")
+ row.className = "custom-provider-row"
+ const copy = document.createElement("div")
+ const name = document.createElement("strong")
+ name.textContent = provider.name
+ const baseUrl = document.createElement("small")
+ baseUrl.textContent = provider.baseUrl
+ copy.append(name, baseUrl)
+
+ const actions = document.createElement("div")
+ actions.className = "custom-provider-row-actions"
+ const use = document.createElement("button")
+ use.type = "button"
+ use.className = "table-action"
+ use.textContent = "使用"
+ use.addEventListener("click", () => {
+ const selected = state.providers.find(
+ (item) => item.customProviderId === provider.id,
+ )
+ if (!selected) {
+ toast("自定义供应商已不存在,请刷新页面", true)
+ return
+ }
+ selectProvider(selected)
+ elements.customProviderDialog.close()
+ })
+ const edit = document.createElement("button")
+ edit.type = "button"
+ edit.className = "table-action"
+ edit.textContent = "编辑"
+ edit.addEventListener("click", () => {
+ elements.customProviderId.value = provider.id
+ elements.customProviderName.value = provider.name
+ elements.customProviderBaseUrl.value = provider.baseUrl
+ elements.saveCustomProvider.querySelector("span").textContent =
+ "保存修改并使用"
+ hideStatus(elements.customProviderStatus)
+ elements.customProviderName.focus()
+ })
+ const remove = document.createElement("button")
+ remove.type = "button"
+ remove.className = "table-action danger"
+ remove.textContent = "删除"
+ remove.addEventListener("click", async () => {
+ if (!window.confirm(`确认删除自定义供应商“${provider.name}”?`)) return
+ remove.disabled = true
+ try {
+ const result = await api(
+ `/api/custom-providers/${encodeURIComponent(provider.id)}`,
+ { method: "DELETE" },
+ )
+ state.customProviders = result.customProviders || []
+ state.providers = result.providers || []
+ if (state.selectedProvider?.customProviderId === provider.id) {
+ state.selectedProvider = null
+ elements.credentialForm.classList.add("hidden")
+ elements.credentialEmpty.classList.remove("hidden")
+ elements.selectedType.textContent = "等待选择"
+ }
+ renderCategories()
+ renderProviders()
+ renderCustomProviders()
+ resetCustomProviderForm()
+ toast(`已删除“${provider.name}”`)
+ } catch (error) {
+ showStatus(elements.customProviderStatus, error.message, true)
+ remove.disabled = false
+ }
+ })
+ actions.append(use, edit, remove)
+ row.append(copy, actions)
+ elements.customProviderList.append(row)
+ }
+}
+
+elements.openCustomProviders.addEventListener("click", () => {
+ resetCustomProviderForm()
+ renderCustomProviders()
+ elements.customProviderDialog.showModal()
+ elements.customProviderName.focus()
+})
+elements.closeCustomProviders.addEventListener("click", () =>
+ elements.customProviderDialog.close(),
+)
+elements.resetCustomProvider.addEventListener("click", () => {
+ resetCustomProviderForm()
+ elements.customProviderName.focus()
+})
+elements.customProviderDialog.addEventListener("click", (event) => {
+ if (event.target === elements.customProviderDialog) {
+ elements.customProviderDialog.close()
+ }
+})
+elements.customProviderForm.addEventListener("submit", async (event) => {
+ event.preventDefault()
+ hideStatus(elements.customProviderStatus)
+ setLoading(elements.saveCustomProvider, true)
+ try {
+ const result = await api("/api/custom-providers", {
+ method: "POST",
+ body: JSON.stringify({
+ id: elements.customProviderId.value,
+ name: elements.customProviderName.value,
+ baseUrl: elements.customProviderBaseUrl.value,
+ }),
+ })
+ state.customProviders = result.customProviders || []
+ state.providers = result.providers || []
+ renderCategories()
+ renderProviders()
+ const selected = state.providers.find(
+ (provider) => provider.customProviderId === result.provider.id,
+ )
+ if (!selected) throw new Error("供应商已保存,但暂时无法选中")
+ selectProvider(selected)
+ elements.customProviderDialog.close()
+ toast(`已保存并选择“${result.provider.name}”`)
+ } catch (error) {
+ showStatus(elements.customProviderStatus, error.message, true)
+ } finally {
+ setLoading(elements.saveCustomProvider, false)
+ }
+})
+
+function closePreviewDialog() {
+ if (elements.previewDialog.open) elements.previewDialog.close()
+ elements.previewPanel.classList.add("hidden")
+ state.preview = null
+ state.pendingSchedule = null
+}
+
+function selectProvider(provider) {
+ state.selectedProvider = provider
+ closePreviewDialog()
+ hideStatus(elements.credentialStatus)
+ elements.credentialEmpty.classList.add("hidden")
+ elements.credentialForm.classList.remove("hidden")
+ elements.selectedType.textContent = `TYPE ${provider.channelType}`
+ renderProviderIcon(elements.providerMonogram, provider)
+ elements.providerName.textContent = provider.name
+ elements.providerDescription.textContent = provider.category
+ elements.channelType.textContent = `TYPE ${provider.channelType}`
+ elements.channelName.value = `${provider.name} · ${new Date().toLocaleDateString("zh-CN")}`
+ state.channelNameEdited = false
+ elements.sourceBaseUrl.value = provider.baseUrl || ""
+ elements.sourceBaseUrl.required = provider.requiresBaseUrl
+ elements.baseUrlHelp.textContent = provider.requiresBaseUrl
+ ? "这个渠道必须填写完整的 Base URL。"
+ : "可以按实际部署修改;留空时由 New API 使用默认值。"
+ elements.apiKey.value = ""
+ elements.quotaMode.value = "uniform"
+ elements.uniformQuota.value = ""
+ elements.keyQuotas.value = ""
+ resetStaticCredentialVisibility()
+ updateQuotaModeForm()
+ elements.channelPriority.value = ""
+ elements.channelWeight.value = ""
+ elements.priorityDescending.checked = false
+ elements.priorityStep.value = "1"
+ updatePrioritySequenceForm()
+ elements.awsGlobalField.classList.toggle("hidden", provider.id !== "aws")
+ elements.awsGlobalInference.checked = false
+ state.channelTemplates = []
+ renderConfigSourceOptions()
+ elements.apiKey.required =
+ !provider.channelConfig.credentialModes?.length &&
+ !provider.keyOptional &&
+ provider.importable
+ elements.apiKey.disabled = !provider.importable
+ elements.keyLabel.textContent = provider.keyOptional
+ ? "API Key(可留空,可批量)"
+ : provider.channelConfig.credentialModes?.length
+ ? "批量完整凭证(可选,一行一条)"
+ : "API Key(可批量,一行一条)"
+ elements.keyHelp.textContent = provider.channelConfig.credentialModes?.length
+ ? "可整段粘贴多条完整凭证;默认明文显示,确认无误后可手动隐藏。"
+ : "可整段粘贴几十条 Key;默认明文显示,确认无误后可手动隐藏。"
+ elements.unsupportedNote.classList.toggle("hidden", provider.importable)
+ elements.unsupportedNote.textContent = provider.importable
+ ? ""
+ : provider.description
+ elements.previewButton.disabled = !provider.importable
+ elements.providerDescription.textContent = provider.description
+ renderProviderConfig(provider)
+ updateConfigSourceUi()
+ renderProviders()
+ elements.channelName.focus()
+ void loadChannelTemplates()
+}
+
+function renderConfigSourceOptions() {
+ const selected = elements.configSource.value
+ elements.configSource.replaceChildren()
+ const placeholder = document.createElement("option")
+ placeholder.value = ""
+ placeholder.textContent = "请先选择配置来源"
+ elements.configSource.append(placeholder)
+ if (state.channelTemplates.length > 0) {
+ const templates = document.createElement("optgroup")
+ templates.label = "复制已有同类型渠道(推荐)"
+ for (const channel of state.channelTemplates) {
+ const option = document.createElement("option")
+ option.value = `template:${channel.id}`
+ option.textContent = `#${channel.id} ${channel.name} · ${channel.modelCount} 个模型 · ${channel.status}`
+ templates.append(option)
+ }
+ elements.configSource.append(templates)
+ }
+ const modelSources = [
+ ["new-api", "从 New API 获取该类型模型"],
+ ["manual", "手动填写模型与配置"],
+ ]
+ if (state.selectedProvider?.channelConfig?.supportsModelFetch === true) {
+ modelSources.unshift(["fetch", "使用新 Key 从供应商获取模型(经 New API)"])
+ }
+ for (const [value, label] of modelSources) {
+ const option = document.createElement("option")
+ option.value = value
+ option.textContent = label
+ elements.configSource.append(option)
+ }
+ if (
+ [...elements.configSource.options].some((item) => item.value === selected)
+ ) {
+ elements.configSource.value = selected
+ }
+}
+
+async function loadChannelTemplates() {
+ if (!state.configured || !state.selectedProvider?.importable) return
+ setLoading(elements.refreshChannelTemplates, true)
+ try {
+ const result = await api("/api/channel-templates", {
+ method: "POST",
+ body: JSON.stringify({ providerId: state.selectedProvider.id }),
+ })
+ state.channelTemplates = result.templates || []
+ renderConfigSourceOptions()
+ updateConfigSourceUi()
+ elements.configSourceHelp.textContent = state.channelTemplates.length
+ ? `已找到 ${state.channelTemplates.length} 个同类型渠道,请选择要复制的配置。`
+ : "没有找到同类型渠道,也可以明确选择从 New API 获取模型。"
+ } catch (error) {
+ state.channelTemplates = []
+ renderConfigSourceOptions()
+ elements.configSourceHelp.textContent = error.message
+ } finally {
+ setLoading(elements.refreshChannelTemplates, false)
+ }
+}
+
+function selectedTemplateId() {
+ const match = elements.configSource.value.match(/^template:(\d+)$/)
+ return match ? Number(match[1]) : null
+}
+
+function updateConfigSourceUi() {
+ const templateId = selectedTemplateId()
+ const manual = elements.configSource.value === "manual"
+ const template = state.channelTemplates.find(
+ (channel) => channel.id === templateId,
+ )
+ elements.sourceBaseUrl.disabled = Boolean(template)
+ elements.baseUrlHelp.textContent = template
+ ? `将复制“${template.name}”的 Base URL 和高级配置。`
+ : state.selectedProvider?.requiresBaseUrl
+ ? "这个渠道必须填写完整的 Base URL。"
+ : "可以按实际部署修改;留空时由 New API 使用默认值。"
+ elements.channelPriorityHelp.textContent = template
+ ? `留空沿用“${template.name}”的优先级 ${template.priority}。`
+ : "数值越大越优先使用;留空时新渠道使用 0。"
+ elements.channelWeightHelp.textContent = template
+ ? `留空沿用“${template.name}”的权重 ${template.weight}。`
+ : "同优先级渠道按权重比例分流;留空时新渠道使用 0。"
+ elements.providerModelsField.classList.toggle("hidden", !manual)
+ elements.providerModelMappingsField.classList.toggle(
+ "hidden",
+ !manual || !state.selectedProvider?.channelConfig?.modelMappings,
+ )
+ elements.providerConfig
+ .querySelectorAll("input, select, textarea")
+ .forEach((input) => {
+ input.disabled = !manual
+ })
+ if (template) {
+ elements.providerConfig.classList.add("hidden")
+ elements.keyHelp.textContent =
+ "粘贴新 Key;云厂商组合凭证请保持完整格式,其他配置从所选渠道复制。"
+ } else if (state.selectedProvider) {
+ renderProviderConfigFields()
+ const config = state.selectedProvider.channelConfig || {}
+ elements.providerConfig.classList.toggle(
+ "hidden",
+ !manual ||
+ (!config.credentialModes?.length &&
+ !config.extra &&
+ !config.flags?.length),
+ )
+ }
+}
+
+function createCredentialField(field, valueKind) {
+ const label = document.createElement("label")
+ label.className = `field${field.multiline ? " field-wide" : ""}`
+ const title = document.createElement("span")
+ title.textContent = field.label
+ const input = field.multiline
+ ? document.createElement("textarea")
+ : document.createElement("input")
+ if (field.multiline) input.rows = 5
+ if (field.secret && !field.multiline) input.type = "text"
+ input.placeholder = field.placeholder || ""
+ input.value = field.defaultValue || ""
+ input.autocomplete = "new-password"
+ input.dataset[valueKind] = field.id
+ const sensitive = field.secret || Boolean(field.fileAccept)
+ if (sensitive) input.dataset.sensitive = "true"
+ label.append(title)
+ if (sensitive) {
+ const wrapper = document.createElement("div")
+ wrapper.className = `secret-input${field.multiline ? " textarea-secret" : ""}`
+ const toggle = document.createElement("button")
+ toggle.type = "button"
+ toggle.textContent = "隐藏"
+ toggle.addEventListener("click", () => {
+ const visible = isSecretInputVisible(input)
+ setSecretInputVisible(input, !visible)
+ toggle.textContent = visible ? "显示" : "隐藏"
+ })
+ wrapper.append(input, toggle)
+ label.append(wrapper)
+ } else {
+ label.append(input)
+ }
+
+ if (field.fileAccept) {
+ const fileRow = document.createElement("div")
+ fileRow.className = "credential-file-row"
+ const file = document.createElement("input")
+ file.type = "file"
+ file.accept = field.fileAccept
+ file.addEventListener("change", async () => {
+ const selected = file.files?.[0]
+ if (!selected) return
+ try {
+ const parsed = JSON.parse(await selected.text())
+ input.value = JSON.stringify(parsed)
+ toast(`已读取服务账号文件:${selected.name}`)
+ } catch {
+ file.value = ""
+ toast("服务账号文件不是有效 JSON", true)
+ }
+ })
+ const help = document.createElement("small")
+ help.textContent = "文件只在本机读取,选择后不会上传到第三方。"
+ fileRow.append(file, help)
+ label.append(fileRow)
+ }
+ return label
+}
+
+function renderProviderConfigFields() {
+ const config = state.selectedProvider?.channelConfig || {}
+ elements.providerConfigFields.replaceChildren()
+ const mode = config.credentialModes?.find(
+ (item) => item.id === elements.credentialMode.value,
+ )
+ for (const field of mode?.parts || []) {
+ elements.providerConfigFields.append(
+ createCredentialField(field, "credentialPart"),
+ )
+ }
+ if (config.extra) {
+ const extra = createCredentialField(config.extra, "providerExtra")
+ if (config.extra.help) {
+ const help = document.createElement("small")
+ help.textContent = config.extra.help
+ extra.append(help)
+ }
+ elements.providerConfigFields.append(extra)
+ }
+ for (const flag of config.flags || []) {
+ if (state.selectedProvider?.id === "aws" && flag.id === "globalInference") {
+ continue
+ }
+ const label = document.createElement("label")
+ label.className = "check-row field-wide"
+ const input = document.createElement("input")
+ input.type = "checkbox"
+ input.dataset.providerFlag = flag.id
+ const copy = document.createElement("span")
+ const title = document.createElement("strong")
+ title.textContent = flag.label
+ const help = document.createElement("small")
+ help.textContent = flag.help || ""
+ copy.append(title, help)
+ label.append(input, copy)
+ elements.providerConfigFields.append(label)
+ }
+ if (mode?.batchHelp) elements.keyHelp.textContent = mode.batchHelp
+}
+
+function renderProviderConfig(provider) {
+ const config = provider.channelConfig || {}
+ const modes = config.credentialModes || []
+ elements.rawKeyField.classList.remove("hidden")
+ elements.providerConfig.classList.toggle(
+ "hidden",
+ modes.length === 0 && !config.extra && !config.flags?.length,
+ )
+ elements.credentialModeField.classList.toggle("hidden", modes.length < 2)
+ elements.credentialMode.replaceChildren()
+ for (const mode of modes) {
+ const option = document.createElement("option")
+ option.value = mode.id
+ option.textContent = mode.label
+ elements.credentialMode.append(option)
+ }
+ renderProviderConfigFields()
+
+ elements.providerModelsField.classList.remove("hidden")
+ elements.providerModels.value = (config.defaultModels || []).join("\n")
+ elements.providerModelsHelp.textContent =
+ config.modelHelp ||
+ (config.supportsModelFetch
+ ? "可留空,默认直接使用当前 New API 内置的该渠道模型;需要覆盖时每行填写一个。"
+ : "该渠道没有可自动读取的模型,请确认 New API 渠道中使用的模型名。")
+
+ elements.providerModelMappingsField.classList.toggle(
+ "hidden",
+ !config.modelMappings,
+ )
+ elements.providerModelMappings.value = ""
+ elements.providerModelMappingsLabel.textContent =
+ config.modelMappings?.label || "模型专用配置"
+ elements.providerModelMappings.placeholder =
+ config.modelMappings?.placeholder || ""
+ elements.providerModelMappingsHelp.textContent =
+ config.modelMappings?.help || ""
+}
+
+elements.credentialMode.addEventListener("change", renderProviderConfigFields)
+elements.configSource.addEventListener("change", updateConfigSourceUi)
+elements.refreshChannelTemplates.addEventListener("click", loadChannelTemplates)
+
+function renderPreview(preview) {
+ state.preview = preview
+ state.mappings = [
+ ...(state.selectedProvider?.channelConfig?.autoMapProviderPrefix === true
+ ? suggestedProviderPrefixMappings(preview.models)
+ : []),
+ ...(preview.providerMappings || []),
+ ]
+ state.createdChannelId = null
+ elements.createChannel.disabled = false
+ elements.createChannel.querySelector("span").textContent =
+ state.pendingSchedule === null ? "确认写入 New API" : "确认保存定时任务"
+ elements.discardPreview.textContent = "返回修改"
+ elements.balanceCard.classList.add("hidden")
+ elements.previewName.textContent = preview.name
+ elements.previewProvider.textContent = `${preview.provider.name} / TYPE ${preview.provider.channelType}`
+ elements.previewBaseUrl.textContent =
+ preview.provider.baseUrl || "New API 默认"
+ elements.previewGroups.textContent = preview.groups.join("、")
+ elements.previewPriority.textContent = preview.prioritySequence?.enabled
+ ? `${preview.priority} → ${preview.priority - preview.prioritySequence.step * Math.max(0, preview.keyCount - 1)}(每条 -${preview.prioritySequence.step})`
+ : String(preview.priority)
+ elements.previewWeight.textContent = String(preview.weight)
+ elements.previewAwsRoutingFact.classList.toggle("hidden", !preview.awsRouting)
+ elements.previewAwsRouting.textContent = preview.awsRouting
+ ? `${preview.awsRouting.regions.join("、")} · ${
+ preview.awsRouting.globalInference ? "Global 全球路由" : "区域路由"
+ }`
+ : "—"
+ elements.modelCount.textContent = String(preview.models.length)
+ const deduplication = preview.deduplication || {
+ inputCount: preview.keyCount || 1,
+ inputDuplicateCount: 0,
+ existingDuplicateCount: 0,
+ queuedDuplicateCount: 0,
+ acceptedCount: preview.keyCount || 1,
+ skippedCount: 0,
+ }
+ elements.batchInputCount.textContent = String(deduplication.inputCount)
+ elements.batchKeyCount.textContent = String(preview.keyCount || 1)
+ elements.batchSkippedCount.textContent = String(deduplication.skippedCount)
+ elements.batchQuotaTotal.textContent = Number.isFinite(preview.quotaTotal)
+ ? formatUsd(preview.quotaTotal)
+ : preview.knownQuotaTotal > 0
+ ? `${formatUsd(preview.knownQuotaTotal)} + ${preview.unknownQuotaCount} 条 x`
+ : `${preview.unknownQuotaCount} 条 x`
+ const duplicateParts = [
+ [deduplication.inputDuplicateCount, "本批重复"],
+ [deduplication.existingDuplicateCount, "本站已录入"],
+ [deduplication.queuedDuplicateCount, "已在定时队列"],
+ ]
+ .filter(([count]) => count > 0)
+ .map(([count, label]) => `${label} ${count} 条`)
+ elements.batchDedupCopy.textContent =
+ duplicateParts.length > 0
+ ? `已自动跳过:${duplicateParts.join("、")}。请核对数量和额度后手动确认。`
+ : "未发现重复 Key。请核对数量和额度后手动确认。"
+ elements.modelList.replaceChildren()
+ const visibleModels = preview.models.slice(0, 36)
+ for (const model of visibleModels) {
+ const chip = document.createElement("span")
+ chip.textContent = model
+ elements.modelList.append(chip)
+ }
+ elements.modelOverflow.textContent =
+ preview.models.length > visibleModels.length
+ ? `另有 ${preview.models.length - visibleModels.length} 个模型`
+ : "已显示全部"
+ elements.actualModelOptions.replaceChildren()
+ for (const model of preview.models) {
+ const option = document.createElement("option")
+ option.value = model
+ elements.actualModelOptions.append(option)
+ }
+ elements.manualModels.value = ""
+ renderMappings()
+ const hasDuplicates =
+ state.pendingSchedule === null &&
+ preview.duplicates.length > 0 &&
+ !preview.templateChannelId
+ elements.duplicateBox.classList.toggle("hidden", !hasDuplicates)
+ elements.confirmDuplicates.checked = false
+ elements.duplicateTarget.replaceChildren()
+ const createOption = document.createElement("option")
+ createOption.value = ""
+ createOption.textContent = "新建独立渠道(不修改现有渠道)"
+ elements.duplicateTarget.append(createOption)
+ if (hasDuplicates) {
+ elements.duplicateCopy.textContent = preview.duplicates
+ .map((item) => `${item.name}(${item.status})`)
+ .join("、")
+ for (const channel of preview.duplicates) {
+ const option = document.createElement("option")
+ option.value = String(channel.id)
+ option.textContent = `${channel.name} · ${
+ channel.isMultiKey ? "追加 Key(多 Key)" : "替换 Key(单 Key)"
+ } · ${channel.status}`
+ option.disabled = preview.keyCount > 1 && !channel.isMultiKey
+ elements.duplicateTarget.append(option)
+ }
+ }
+ updateDuplicateAction()
+ hideStatus(elements.createStatus)
+ elements.previewPanel.classList.remove("hidden")
+ if (!elements.previewDialog.open) elements.previewDialog.showModal()
+}
+
+function selectedDuplicateChannel() {
+ const channelId = Number(elements.duplicateTarget.value)
+ return state.preview?.duplicates.find(
+ (channel) => Number(channel.id) === channelId,
+ )
+}
+
+function updateDuplicateAction() {
+ const channel = selectedDuplicateChannel()
+ elements.confirmDuplicates.checked = false
+ if (!channel) {
+ elements.duplicateConfirmCopy.textContent = "我确认仍要新增一个渠道"
+ elements.createChannel.querySelector("span").textContent =
+ state.pendingSchedule === null ? "确认写入 New API" : "确认保存定时任务"
+ return
+ }
+ elements.duplicateConfirmCopy.textContent = channel.isMultiKey
+ ? `我确认把 Key 追加到“${channel.name}”并启用该渠道`
+ : `我确认替换“${channel.name}”的原 Key 并启用该渠道`
+ elements.createChannel.querySelector("span").textContent = channel.isMultiKey
+ ? "追加到所选渠道"
+ : "更新所选渠道"
+}
+
+const formatUsd = (value) =>
+ new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 4,
+ }).format(value)
+
+const formatInteger = (value) =>
+ new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 }).format(
+ Number(value) || 0,
+ )
+
+const formatCompact = (value) =>
+ new Intl.NumberFormat("zh-CN", {
+ notation: "compact",
+ maximumFractionDigits: 2,
+ }).format(Number(value) || 0)
+
+const formatDateTime = (value) => {
+ const date = new Date(value)
+ return Number.isFinite(date.getTime()) ? date.toLocaleString("zh-CN") : "暂无"
+}
+
+const formatDateTimeInput = (date) => {
+ const offset = date.getTimezoneOffset() * 60_000
+ return new Date(date.getTime() - offset).toISOString().slice(0, 19)
+}
+
+const formatUsageTime = (timestamp) => {
+ if (timestamp == null || timestamp === "") return "尚未使用"
+ return Number.isFinite(Number(timestamp))
+ ? formatDateTime(Number(timestamp) * 1000)
+ : "尚未使用"
+}
+
+const isRecoverySchedule = (schedule) =>
+ schedule?.kind === "recovery" ||
+ /限流|自动续传/.test(String(schedule?.lastError || "")) ||
+ (schedule?.entries || []).some(
+ (entry) => entry.status === "pending" && /限流/.test(entry.error || ""),
+ )
+
+const isPacedSchedule = (schedule) => schedule?.kind === "paced"
+
+const scheduleStatusCopy = (status, schedule = null) => {
+ if (isRecoverySchedule(schedule)) {
+ if (status === "active") return "自动续传等待中"
+ if (status === "running") return "正在继续写入"
+ if (status === "paused") return "续传已暂停"
+ }
+ if (isPacedSchedule(schedule)) {
+ if (status === "active") return "安全队列等待中"
+ if (status === "running") return "正在安全写入"
+ if (status === "paused") return "安全队列已暂停"
+ }
+ return (
+ {
+ active: "等待执行",
+ running: "正在写入",
+ paused: "已暂停",
+ attention: "需要核对",
+ completed: "已完成",
+ cancelled: "已取消",
+ }[status] || status
+ )
+}
+
+const usageCopy = (record) => {
+ const spent = gatewaySpent(record)
+ if (spent != null) return formatUsd(spent)
+ if (record.sharedChannel && !Number.isInteger(record.keyIndex)) {
+ return "旧多 Key 记录无法拆分"
+ }
+ return record.channelId ? "等待刷新日志" : "未定位渠道"
+}
+
+const currentRecordFilters = () => ({
+ target: elements.recordsTargetFilter.value,
+ provider: elements.recordsProviderFilter.value,
+ status: elements.recordsStatusFilter.value,
+ query: elements.recordsSearch.value,
+})
+
+function replaceFilterOptions(select, emptyLabel, values) {
+ const selected = select.value
+ select.replaceChildren()
+ const empty = document.createElement("option")
+ empty.value = ""
+ empty.textContent = emptyLabel
+ select.append(empty)
+ for (const value of values) {
+ const option = document.createElement("option")
+ option.value = value
+ option.textContent = value
+ select.append(option)
+ }
+ select.value = values.includes(selected) ? selected : ""
+}
+
+function renderRecordFilterOptions() {
+ replaceFilterOptions(
+ elements.recordsTargetFilter,
+ "全部站点",
+ [
+ ...new Set(
+ state.records.map((record) => record.targetName).filter(Boolean),
+ ),
+ ].sort((left, right) => left.localeCompare(right, "zh-CN")),
+ )
+ replaceFilterOptions(
+ elements.recordsProviderFilter,
+ "全部供应商",
+ [
+ ...new Set(
+ state.records.map((record) => record.providerName).filter(Boolean),
+ ),
+ ].sort((left, right) => left.localeCompare(right, "zh-CN")),
+ )
+}
+
+const formatPercent = (value) =>
+ Number.isFinite(value)
+ ? `${new Intl.NumberFormat("zh-CN", {
+ maximumFractionDigits: 1,
+ }).format(Math.max(0, value))}%`
+ : "—"
+
+function markUsageMonitorRange(range = "") {
+ for (const button of document.querySelectorAll("[data-usage-range]")) {
+ button.classList.toggle("active", button.dataset.usageRange === range)
+ }
+}
+
+function setUsageMonitorRange(range) {
+ markUsageMonitorRange(range)
+ if (range === "all") {
+ elements.usageMonitorStart.value = ""
+ elements.usageMonitorEnd.value = ""
+ return
+ }
+ const end = new Date()
+ const start = new Date(end)
+ const days = range === "today" ? 1 : Number(range)
+ start.setDate(start.getDate() - Math.max(0, days - 1))
+ elements.usageMonitorStart.value = localDateKey(start)
+ elements.usageMonitorEnd.value = localDateKey(end)
+}
+
+function currentUsageMonitorFilters() {
+ return {
+ targetUrl: elements.usageMonitorTarget.value,
+ startDate: elements.usageMonitorStart.value,
+ endDate: elements.usageMonitorEnd.value,
+ }
+}
+
+function currentUsageMonitorRecords() {
+ return filterUsageDashboardRecords(
+ state.records,
+ currentUsageMonitorFilters(),
+ )
+}
+
+function renderUsageMonitorTargetOptions() {
+ const selected = elements.usageMonitorTarget.value
+ const targets = new Map()
+ for (const record of state.records) {
+ if (!record.targetUrl || targets.has(record.targetUrl)) continue
+ targets.set(record.targetUrl, record.targetName || "New API")
+ }
+ elements.usageMonitorTarget.replaceChildren()
+ const all = document.createElement("option")
+ all.value = ""
+ all.textContent = "全部 New API 地址"
+ elements.usageMonitorTarget.append(all)
+ for (const [targetUrl, targetName] of [...targets.entries()].sort(
+ (left, right) => left[1].localeCompare(right[1], "zh-CN"),
+ )) {
+ const option = document.createElement("option")
+ option.value = targetUrl
+ option.textContent = `${targetName} · ${targetUrl}`
+ elements.usageMonitorTarget.append(option)
+ }
+ elements.usageMonitorTarget.value = targets.has(selected) ? selected : ""
+}
+
+function appendSiteUsageCard(group) {
+ const { summary } = group
+ const card = document.createElement("article")
+ card.className = "site-usage-item"
+ const header = document.createElement("header")
+ const identity = document.createElement("div")
+ const name = document.createElement("strong")
+ name.textContent = group.targetName
+ const url = document.createElement("small")
+ url.textContent = group.targetUrl
+ identity.append(name, url)
+ const percent = document.createElement("b")
+ percent.textContent = formatPercent(summary.remainingPercent)
+ header.append(identity, percent)
+
+ const progress = document.createElement("progress")
+ progress.max = 100
+ progress.value = Number.isFinite(summary.remainingPercent)
+ ? Math.min(100, Math.max(0, summary.remainingPercent))
+ : 0
+ progress.className = Number.isFinite(summary.remainingPercent)
+ ? ""
+ : "unknown"
+ progress.setAttribute(
+ "aria-label",
+ Number.isFinite(summary.remainingPercent)
+ ? `${group.targetName} 剩余 ${formatPercent(summary.remainingPercent)}`
+ : `${group.targetName} 暂无可计算剩余比例`,
+ )
+
+ const metrics = document.createElement("div")
+ metrics.className = "site-usage-metrics"
+ for (const [label, value] of [
+ ["录入额度", formatUsd(summary.quotaTotal)],
+ ["已使用", formatUsd(summary.gatewaySpentTotal)],
+ ["已统计剩余", formatUsd(summary.trackedRemainingTotal)],
+ ["Key", `${summary.recordCount} 条`],
+ ]) {
+ const metric = document.createElement("span")
+ const metricLabel = document.createElement("small")
+ metricLabel.textContent = label
+ const metricValue = document.createElement("strong")
+ metricValue.textContent = value
+ metric.append(metricLabel, metricValue)
+ metrics.append(metric)
+ }
+ const coverage = document.createElement("p")
+ coverage.textContent = `用量已刷新 ${summary.trackedCount} / ${summary.recordCount} 条 · ${formatPercent(summary.coveragePercent)}`
+ card.append(header, progress, metrics, coverage)
+ elements.siteUsageList.append(card)
+}
+
+function appendDailyUsageRow(day, maxAmount) {
+ const row = document.createElement("article")
+ row.className = "daily-usage-row"
+ const date = document.createElement("strong")
+ date.textContent = new Date(`${day.date}T00:00:00`).toLocaleDateString(
+ "zh-CN",
+ { month: "numeric", day: "numeric", weekday: "short" },
+ )
+ const bars = document.createElement("div")
+ bars.className = "daily-usage-bars"
+ for (const [className, label, amount] of [
+ ["quota", "录入", day.summary.quotaTotal],
+ ["spent", "累计", day.summary.gatewaySpentTotal],
+ ]) {
+ const bar = document.createElement("div")
+ bar.className = className
+ const copy = document.createElement("span")
+ copy.textContent = label
+ const progress = document.createElement("progress")
+ progress.max = maxAmount
+ progress.value = Math.max(0, amount)
+ const value = document.createElement("b")
+ value.textContent = formatUsd(amount)
+ bar.append(copy, progress, value)
+ bars.append(bar)
+ }
+ const detail = document.createElement("small")
+ detail.textContent = `${day.summary.recordCount} 条 Key · 剩余 ${formatPercent(day.summary.remainingPercent)}`
+ row.append(date, bars, detail)
+ elements.dailyUsageChart.append(row)
+}
+
+function renderUsageMonitor() {
+ const filters = currentUsageMonitorFilters()
+ const records = currentUsageMonitorRecords()
+ const summary = summarizeUsageDashboard(records)
+ const hasRecords = records.length > 0
+ const rangeCopy =
+ filters.startDate || filters.endDate
+ ? `${filters.startDate || "最早记录"} 至 ${filters.endDate || "今天"}`
+ : "全部录入时间"
+ const targetCopy = filters.targetUrl || "全部 New API 地址"
+ elements.usageMonitorRange.textContent = `${targetCopy} · ${rangeCopy} · 共 ${summary.recordCount} 条 Key`
+ elements.monitorKeyCount.textContent = formatInteger(summary.recordCount)
+ elements.monitorKeyDetail.textContent = `${summary.knownQuotaCount} 个已填写额度 · ${summary.unknownQuotaCount} 个额度 x`
+ elements.monitorQuotaTotal.textContent = formatUsd(summary.quotaTotal)
+ elements.monitorQuotaDetail.textContent = `${summary.knownQuotaCount} 个已知额度`
+ elements.monitorSpentTotal.textContent = formatUsd(summary.gatewaySpentTotal)
+ elements.monitorSpentDetail.textContent = `${formatInteger(summary.requestCount)} 次请求 · ${summary.usedKeyCount} 个 Key 已使用`
+ elements.monitorRemainingTotal.textContent = formatUsd(
+ summary.trackedRemainingTotal,
+ )
+ elements.monitorRemainingPercent.textContent = formatPercent(
+ summary.remainingPercent,
+ )
+ const ringValue = Number.isFinite(summary.remainingPercent)
+ ? Math.min(100, Math.max(0, summary.remainingPercent))
+ : 0
+ elements.monitorRemainingRingValue.setAttribute(
+ "stroke-dasharray",
+ `${ringValue} 100`,
+ )
+ elements.monitorRemainingRing.classList.toggle(
+ "unknown",
+ !Number.isFinite(summary.remainingPercent),
+ )
+ elements.monitorRemainingDetail.textContent = Number.isFinite(
+ summary.remainingPercent,
+ )
+ ? `按 ${formatUsd(summary.trackedQuotaTotal)} 已刷新额度计算`
+ : "刷新用量后显示剩余比例"
+ elements.monitorCoveragePercent.textContent = formatPercent(
+ summary.coveragePercent,
+ )
+ elements.monitorCoverageDetail.textContent = `${summary.trackedCount} / ${summary.recordCount} 个已刷新`
+
+ elements.siteUsageList.replaceChildren()
+ for (const group of groupUsageDashboardByTarget(records)) {
+ appendSiteUsageCard(group)
+ }
+ elements.dailyUsageChart.replaceChildren()
+ const days = groupUsageDashboardByDay(records)
+ const maxAmount = Math.max(
+ 1,
+ ...days.flatMap((day) => [
+ day.summary.quotaTotal,
+ day.summary.gatewaySpentTotal,
+ ]),
+ )
+ for (const day of days) appendDailyUsageRow(day, maxAmount)
+ elements.usageMonitorEmpty.classList.toggle("hidden", hasRecords)
+ elements.usageMonitorGrid.classList.toggle("hidden", !hasRecords)
+}
+
+function renderUsageSummary(records) {
+ const summary = summarizeUsageRecords(records)
+ const totalRecords = state.records.length
+ elements.usageKeyCount.textContent =
+ records.length === totalRecords
+ ? formatInteger(records.length)
+ : `${formatInteger(records.length)} / ${formatInteger(totalRecords)}`
+ elements.usageKeyDetail.textContent = `${summary.usedKeyCount} 个已使用 · ${summary.unusedKeyCount} 个未使用`
+ elements.usageQuotaTotal.textContent = formatUsd(summary.quotaTotal)
+ elements.usageQuotaDetail.textContent = `${summary.knownQuotaCount} 个已填额度 · ${summary.unknownQuotaCount} 个未知`
+ elements.usageSpentTotal.textContent = formatUsd(summary.gatewaySpentTotal)
+ elements.usageCoverageDetail.textContent = `${summary.trackedCount} / ${summary.recordCount} 个已刷新统计`
+ elements.usageRemainingTotal.textContent = formatUsd(
+ summary.trackedRemainingTotal,
+ )
+ elements.usageRequestCount.textContent = formatInteger(summary.requestCount)
+ elements.usageLastChecked.textContent = summary.lastCheckedAt
+ ? `最近刷新 ${formatDateTime(summary.lastCheckedAt)}`
+ : "尚未刷新"
+ const totalTokens = summary.promptTokens + summary.completionTokens
+ elements.usageTokenTotal.textContent =
+ summary.detailIncompleteCount > 0 ? "—" : formatCompact(totalTokens)
+ elements.usageTokenDetail.textContent =
+ summary.detailIncompleteCount > 0
+ ? `${summary.detailIncompleteCount} 条 Token 明细未完整`
+ : `输入 ${formatCompact(summary.promptTokens)} · 输出 ${formatCompact(
+ summary.completionTokens,
+ )}`
+ const notes = []
+ if (summary.pendingCount > 0) {
+ notes.push(`${summary.pendingCount} 条等待刷新,不计入已消耗金额`)
+ }
+ if (summary.incompleteCount > 0) {
+ notes.push(`${summary.incompleteCount} 条日志量过大,当前统计未完整`)
+ }
+ if (summary.detailIncompleteCount > 0) {
+ notes.push(
+ `${summary.detailIncompleteCount} 条金额已校准,但 Token 明细未完整`,
+ )
+ }
+ notes.push("New API 消耗仅统计录入时间之后的消费日志")
+ elements.usageSummaryNote.textContent = notes.join(";")
+}
+
+function usageStateCopy(record) {
+ const status = usageState(record)
+ if (status === "used") return { copy: "已有消耗", className: "" }
+ if (status === "unused") {
+ return { copy: "已刷新 · 未使用", className: "idle" }
+ }
+ if (status === "incomplete") {
+ return { copy: "统计未完整", className: "warning" }
+ }
+ return { copy: "等待刷新", className: "warning" }
+}
+
+const importOperationCopy = (operation) =>
+ ({
+ created: "新建独立渠道",
+ "created-multi-key": "新建多 Key 渠道",
+ appended: "追加到已有渠道",
+ replaced: "替换已有渠道 Key",
+ })[operation] || "写入 New API"
+
+function appendRecordBatchMetric(container, label, value, detail = "") {
+ const metric = document.createElement("div")
+ const metricLabel = document.createElement("small")
+ metricLabel.textContent = label
+ const metricValue = document.createElement("strong")
+ metricValue.textContent = value
+ metric.append(metricLabel, metricValue)
+ if (detail) {
+ const metricDetail = document.createElement("span")
+ metricDetail.textContent = detail
+ metric.append(metricDetail)
+ }
+ container.append(metric)
+}
+
+function buildRecordDetailRow(record, fallbackIndex) {
+ const row = document.createElement("tr")
+ const sequence = document.createElement("td")
+ sequence.className = "record-sequence-cell"
+ sequence.textContent = `#${formatInteger(
+ Number.isInteger(record.batchItemIndex)
+ ? record.batchItemIndex
+ : fallbackIndex + 1,
+ )}`
+ const keyCell = document.createElement("td")
+ const source = document.createElement("strong")
+ source.textContent = record.providerName
+ const key = document.createElement("small")
+ key.textContent = `${record.keyHint} · ${record.keyFingerprint}`
+ keyCell.append(source, key)
+
+ const channel = document.createElement("td")
+ channel.className = "channel-cell"
+ const channelName = document.createElement("strong")
+ channelName.textContent = record.channelName || "未定位渠道"
+ const channelId = document.createElement("small")
+ channelId.textContent = record.channelId
+ ? `渠道 ID ${formatInteger(record.channelId)}`
+ : "尚未绑定渠道 ID"
+ channel.append(channelName, channelId)
+
+ const quotaUsage = document.createElement("td")
+ const statusInfo = usageStateCopy(record)
+ const status = document.createElement("span")
+ status.className = `usage-status ${statusInfo.className}`.trim()
+ status.textContent = statusInfo.copy
+ const quota = document.createElement("strong")
+ quota.textContent = Number.isFinite(record.quota)
+ ? `额度 ${formatUsd(record.quota)}`
+ : "额度 x"
+ const cost = document.createElement("small")
+ cost.className = "record-detail-cost"
+ cost.textContent = `已用 ${usageCopy(record)}`
+ quotaUsage.append(status, quota, cost)
+ const trackedSpent = gatewaySpent(record)
+ if (Number.isFinite(record.quota) && trackedSpent != null) {
+ const remaining = document.createElement("small")
+ remaining.textContent = `剩余 ${formatUsd(
+ Math.max(0, record.quota - trackedSpent),
+ )}`
+ quotaUsage.append(remaining)
+ const progress = document.createElement("div")
+ progress.className = `quota-progress ${
+ trackedSpent > record.quota ? "over" : ""
+ }`.trim()
+ const progressValue = document.createElement("span")
+ progressValue.style.width = `${Math.min(
+ 100,
+ record.quota > 0 ? (trackedSpent / record.quota) * 100 : 0,
+ )}%`
+ progress.append(progressValue)
+ quotaUsage.append(progress)
+ }
+
+ const traffic = document.createElement("td")
+ const requests = document.createElement("strong")
+ requests.textContent = `${formatInteger(record.requestCount || 0)} 次请求`
+ const tokens = document.createElement("small")
+ tokens.textContent =
+ record.usageDetailsComplete === false
+ ? "Token 明细未完整"
+ : `Token ${formatCompact(
+ (record.promptTokens || 0) + (record.completionTokens || 0),
+ )}`
+ const activity = document.createElement("small")
+ activity.textContent = `最近使用:${formatUsageTime(record.lastUsedAt)}`
+ const checked = document.createElement("small")
+ checked.textContent = record.checkedAt
+ ? `刷新:${formatDateTime(record.checkedAt)}`
+ : "尚未刷新用量"
+ traffic.append(requests, tokens, activity, checked)
+
+ const action = document.createElement("td")
+ const refresh = document.createElement("button")
+ refresh.type = "button"
+ refresh.className = "table-action"
+ refresh.textContent = "刷新消耗"
+ refresh.disabled =
+ !record.channelId ||
+ (record.sharedChannel && !Number.isInteger(record.keyIndex))
+ refresh.addEventListener("click", () => refreshImportRecord(record, refresh))
+ action.append(refresh)
+ row.append(sequence, keyCell, channel, quotaUsage, traffic, action)
+ return row
+}
+
+function appendRecordBatch(batch, index) {
+ const card = document.createElement("article")
+ card.className = "record-batch-card"
+ const header = document.createElement("header")
+ const identity = document.createElement("div")
+ identity.className = "record-batch-identity"
+ const time = document.createElement("strong")
+ time.textContent = `本次添加 · ${formatDateTime(batch.startedAt)}`
+ const destination = document.createElement("small")
+ destination.textContent = `${batch.targetName} · ${batch.providerName} · ${importOperationCopy(batch.operation)}`
+ const targetUrl = document.createElement("span")
+ targetUrl.textContent = batch.targetUrl
+ identity.append(time, destination, targetUrl)
+ const count = document.createElement("b")
+ count.className = "record-batch-count"
+ count.textContent = `成功写入 ${formatInteger(batch.records.length)} 条 Key`
+ header.append(identity, count)
+
+ const metrics = document.createElement("div")
+ metrics.className = "record-batch-metrics"
+ appendRecordBatchMetric(
+ metrics,
+ "本批 Key",
+ formatInteger(batch.summary.recordCount),
+ batch.legacy ? "历史记录按相近写入时间归组" : "同一次批量任务",
+ )
+ appendRecordBatchMetric(
+ metrics,
+ "本批录入额度",
+ formatUsd(batch.summary.quotaTotal),
+ batch.summary.unknownQuotaCount > 0
+ ? `${formatInteger(batch.summary.unknownQuotaCount)} 条额度 x`
+ : "额度均已填写",
+ )
+ appendRecordBatchMetric(
+ metrics,
+ "累计已用",
+ formatUsd(batch.summary.gatewaySpentTotal),
+ `${formatInteger(batch.summary.trackedCount)} 条已刷新`,
+ )
+ appendRecordBatchMetric(
+ metrics,
+ "请求次数",
+ formatInteger(batch.summary.requestCount),
+ `${formatInteger(batch.summary.usedKeyCount)} 条 Key 已使用`,
+ )
+
+ const details = document.createElement("details")
+ details.className = "record-batch-details"
+ details.open = index === 0
+ const summary = document.createElement("summary")
+ summary.textContent = `展开逐条核对本次添加的 ${formatInteger(batch.records.length)} 条 Key`
+ const tableWrap = document.createElement("div")
+ tableWrap.className = "record-detail-table-wrap"
+ const table = document.createElement("table")
+ table.className = "records-table record-detail-table"
+ const head = document.createElement("thead")
+ const headRow = document.createElement("tr")
+ for (const label of [
+ "序号",
+ "来源 / Key",
+ "渠道",
+ "额度 / 已用",
+ "请求 / 最近使用",
+ "操作",
+ ]) {
+ const cell = document.createElement("th")
+ cell.textContent = label
+ headRow.append(cell)
+ }
+ head.append(headRow)
+ const body = document.createElement("tbody")
+ for (const [recordIndex, record] of batch.records.entries()) {
+ body.append(buildRecordDetailRow(record, recordIndex))
+ }
+ table.append(head, body)
+ tableWrap.append(table)
+ details.append(summary, tableWrap)
+ card.append(header, metrics, details)
+ elements.recordsBody.append(card)
+}
+
+function renderRecords() {
+ elements.recordsBody.replaceChildren()
+ const records = filterUsageRecords(state.records, currentRecordFilters())
+ renderUsageSummary(records)
+ const hasRecords = records.length > 0
+ elements.recordsEmpty.textContent =
+ state.records.length > 0
+ ? "没有符合当前筛选的记录。"
+ : "还没有 Key 填入记录。"
+ elements.recordsEmpty.classList.toggle("hidden", hasRecords)
+ elements.recordsTableWrap.classList.toggle("hidden", !hasRecords)
+ groupImportRecords(records).forEach(appendRecordBatch)
+}
+
+async function loadRecords() {
+ const result = await api("/api/imports")
+ state.records = result.records
+ renderRecordFilterOptions()
+ renderUsageMonitorTargetOptions()
+ renderUsageMonitor()
+ renderRecords()
+}
+
+function renderSchedules() {
+ elements.scheduleList.replaceChildren()
+ const hasSchedules = state.schedules.length > 0
+ elements.scheduleEmpty.classList.toggle("hidden", hasSchedules)
+ for (const schedule of state.schedules) {
+ const recovering =
+ isRecoverySchedule(schedule) && schedule.counts.pending > 0
+ const paced = isPacedSchedule(schedule) && schedule.counts.pending > 0
+ const card = document.createElement("article")
+ card.className = `schedule-card${recovering ? " recovering" : ""}`
+ card.dataset.scheduleId = schedule.id
+ const header = document.createElement("header")
+ const titleWrap = document.createElement("div")
+ const title = document.createElement("h3")
+ title.textContent = schedule.name
+ const target = document.createElement("p")
+ target.textContent = `${schedule.targetName || "New API"} · ${
+ schedule.providerName
+ } · ${schedule.targetUrl || ""}`
+ titleWrap.append(title, target)
+ const status = document.createElement("span")
+ status.className = `schedule-status ${schedule.status}${
+ recovering ? " recovering" : ""
+ }`.trim()
+ status.textContent = scheduleStatusCopy(schedule.status, schedule)
+ header.append(titleWrap, status)
+
+ const metrics = document.createElement("div")
+ metrics.className = "schedule-metrics"
+ const metricItems = [
+ ["总 Key", `${schedule.counts.total}`],
+ ["待写入", `${schedule.counts.pending}`],
+ ["已写入", `${schedule.counts.imported}`],
+ ["失败", `${schedule.counts.failed}`],
+ ...(schedule.counts.uncertain > 0
+ ? [["待核对", `${schedule.counts.uncertain}`]]
+ : []),
+ [
+ "下一次",
+ schedule.status === "active"
+ ? formatDateTime(schedule.nextRunAt)
+ : scheduleStatusCopy(schedule.status, schedule),
+ ],
+ ]
+ for (const [label, value] of metricItems) {
+ const item = document.createElement("div")
+ const small = document.createElement("small")
+ small.textContent = label
+ const strong = document.createElement("strong")
+ strong.textContent = value
+ item.append(small, strong)
+ metrics.append(item)
+ }
+
+ const details = document.createElement("p")
+ const priorityCopy = schedule.prioritySequence?.enabled
+ ? `${schedule.priority} 起、每条 -${schedule.prioritySequence.step}`
+ : String(schedule.priority)
+ details.textContent = `每次 ${schedule.batchSize} 条;间隔 ${
+ schedule.intervalMinutes
+ } 分钟;优先级 ${priorityCopy};权重 ${schedule.weight};最近执行 ${formatDateTime(schedule.lastRunAt)}。`
+ if (recovering || paced) {
+ const recovery = document.createElement("p")
+ recovery.className = "schedule-recovery-note"
+ recovery.textContent = recovering
+ ? `${schedule.counts.pending} 条 Key 已加密保留;成功项不会重复写入。下次自动继续:${formatDateTime(schedule.nextRunAt)}。`
+ : `${schedule.counts.pending} 条 Key 已加密进入安全队列;为避免触发 New API 限流,每分钟自动写入 1 条。下一条:${formatDateTime(schedule.nextRunAt)}。`
+ card.append(header, metrics, details, recovery)
+ } else {
+ card.append(header, metrics, details)
+ }
+ if (schedule.lastError) {
+ const error = document.createElement("p")
+ error.className = recovering ? "schedule-recovery-note" : "schedule-error"
+ error.textContent = schedule.lastError
+ card.append(error)
+ }
+
+ const actions = document.createElement("div")
+ actions.className = "schedule-actions"
+ const retryFailed = document.createElement("button")
+ retryFailed.type = "button"
+ retryFailed.className = "table-action retry-failed"
+ retryFailed.textContent = `恢复失败 Key(${schedule.counts.failed})`
+ retryFailed.disabled =
+ schedule.counts.failed === 0 ||
+ ["running", "cancelled"].includes(schedule.status)
+ retryFailed.addEventListener("click", () =>
+ updateSchedule(schedule.id, "retry-failed", retryFailed),
+ )
+ const edit = document.createElement("button")
+ edit.type = "button"
+ edit.className = "table-action"
+ edit.textContent = "修改任务"
+ edit.disabled =
+ schedule.counts.pending === 0 ||
+ !["active", "paused"].includes(schedule.status)
+
+ const editor = document.createElement("form")
+ editor.className = "schedule-editor hidden"
+ editor.setAttribute("aria-label", `修改 ${schedule.name}`)
+ const nextRunField = document.createElement("label")
+ nextRunField.className = "field"
+ const nextRunLabel = document.createElement("span")
+ nextRunLabel.textContent = "下一次执行时间(精确到秒)"
+ const nextRunInput = document.createElement("input")
+ nextRunInput.type = "datetime-local"
+ nextRunInput.step = "1"
+ nextRunInput.required = true
+ const nextRunDate = new Date(schedule.nextRunAt)
+ nextRunInput.value = Number.isFinite(nextRunDate.getTime())
+ ? formatDateTimeInput(nextRunDate)
+ : formatDateTimeInput(new Date(Date.now() + 60_000))
+ nextRunField.append(nextRunLabel, nextRunInput)
+
+ const batchField = document.createElement("label")
+ batchField.className = "field"
+ const batchLabel = document.createElement("span")
+ batchLabel.textContent = "每次上几条"
+ const batchInput = document.createElement("input")
+ batchInput.type = "number"
+ batchInput.min = "1"
+ batchInput.max = "200"
+ batchInput.required = true
+ batchInput.value = String(schedule.batchSize)
+ batchField.append(batchLabel, batchInput)
+
+ const intervalField = document.createElement("label")
+ intervalField.className = "field"
+ const intervalLabel = document.createElement("span")
+ intervalLabel.textContent = "间隔分钟"
+ const intervalInput = document.createElement("input")
+ intervalInput.type = "number"
+ intervalInput.min = "0"
+ intervalInput.max = "43200"
+ intervalInput.required = true
+ intervalInput.value = String(schedule.intervalMinutes)
+ const intervalHelp = document.createElement("small")
+ intervalHelp.textContent = "填 0 表示执行下一批后暂停。"
+ intervalField.append(intervalLabel, intervalInput, intervalHelp)
+
+ const editorActions = document.createElement("div")
+ editorActions.className = "schedule-editor-actions"
+ const save = document.createElement("button")
+ save.type = "submit"
+ save.className = "table-action schedule-save"
+ save.textContent = "保存修改"
+ const discard = document.createElement("button")
+ discard.type = "button"
+ discard.className = "table-action"
+ discard.textContent = "放弃"
+ editorActions.append(save, discard)
+ editor.append(nextRunField, batchField, intervalField, editorActions)
+
+ edit.addEventListener("click", () => {
+ editor.classList.toggle("hidden")
+ edit.textContent = editor.classList.contains("hidden")
+ ? "修改任务"
+ : "收起修改"
+ if (!editor.classList.contains("hidden")) nextRunInput.focus()
+ })
+ discard.addEventListener("click", () => {
+ editor.classList.add("hidden")
+ edit.textContent = "修改任务"
+ })
+ editor.addEventListener("submit", (event) => {
+ event.preventDefault()
+ saveScheduleSettings(
+ schedule.id,
+ {
+ startAt: nextRunInput.value,
+ batchSize: batchInput.value,
+ intervalMinutes: intervalInput.value,
+ },
+ save,
+ )
+ })
+
+ const runNow = document.createElement("button")
+ runNow.type = "button"
+ runNow.className = "table-action"
+ runNow.textContent = recovering
+ ? "立即继续写入"
+ : paced
+ ? "立即上一条"
+ : "立即上一批"
+ runNow.disabled =
+ schedule.counts.pending === 0 ||
+ ["running", "cancelled"].includes(schedule.status)
+ runNow.addEventListener("click", () =>
+ updateSchedule(schedule.id, "run", runNow),
+ )
+ const toggle = document.createElement("button")
+ toggle.type = "button"
+ toggle.className = "table-action"
+ toggle.textContent = schedule.status === "paused" ? "恢复" : "暂停"
+ toggle.disabled = !["active", "paused"].includes(schedule.status)
+ toggle.addEventListener("click", () =>
+ updateSchedule(
+ schedule.id,
+ schedule.status === "paused" ? "resume" : "pause",
+ toggle,
+ ),
+ )
+ const cancel = document.createElement("button")
+ cancel.type = "button"
+ cancel.className = "table-action"
+ cancel.textContent = "取消"
+ cancel.disabled = ["completed", "cancelled"].includes(schedule.status)
+ cancel.addEventListener("click", () =>
+ updateSchedule(schedule.id, "cancel", cancel),
+ )
+ actions.append(retryFailed, edit, runNow, toggle, cancel)
+ card.append(editor, actions)
+ elements.scheduleList.append(card)
+ }
+}
+
+async function loadSchedules() {
+ const result = await api("/api/schedules")
+ state.schedules = result.schedules || []
+ renderSchedules()
+}
+
+async function updateSchedule(scheduleId, action, button) {
+ setLoading(button, true)
+ try {
+ const result = await api(`/api/schedules/${scheduleId}/${action}`, {
+ method: "POST",
+ body: "{}",
+ })
+ state.schedules = state.schedules.map((schedule) =>
+ schedule.id === result.schedule.id ? result.schedule : schedule,
+ )
+ renderSchedules()
+ if (action === "run") await loadRecords()
+ toast(
+ action === "run"
+ ? "已继续执行一批 Key"
+ : action === "retry-failed"
+ ? "失败 Key 已重新加入待写入队列"
+ : "定时任务已更新",
+ )
+ } catch (error) {
+ toast(error.message, true)
+ } finally {
+ setLoading(button, false)
+ }
+}
+
+async function saveScheduleSettings(scheduleId, schedule, button) {
+ setLoading(button, true)
+ try {
+ const result = await api(`/api/schedules/${scheduleId}/settings`, {
+ method: "POST",
+ body: JSON.stringify({ schedule }),
+ })
+ state.schedules = state.schedules.map((item) =>
+ item.id === result.schedule.id ? result.schedule : item,
+ )
+ renderSchedules()
+ toast("定时任务时间和批量设置已更新")
+ } catch (error) {
+ toast(error.message, true)
+ } finally {
+ setLoading(button, false)
+ }
+}
+
+async function refreshImportRecord(record, button) {
+ setLoading(button, true)
+ try {
+ const result = await api("/api/imports/refresh", {
+ method: "POST",
+ body: JSON.stringify({ recordId: record.id }),
+ })
+ state.records = state.records.map((item) =>
+ item.id === result.record.id ? result.record : item,
+ )
+ renderUsageMonitor()
+ renderRecords()
+ toast("Key 消耗记录已刷新")
+ } catch (error) {
+ toast(error.message, true)
+ } finally {
+ setLoading(button, false)
+ }
+}
+
+function renderBalance(balance, channelId) {
+ state.createdChannelId = channelId || null
+ elements.balanceCard.classList.remove("hidden")
+ elements.refreshBalance.disabled = !state.createdChannelId
+
+ if (balance?.status !== "available") {
+ elements.balanceMetrics.classList.add("hidden")
+ elements.balanceMessage.textContent =
+ balance?.reason || "该渠道暂时无法查询余额。"
+ return
+ }
+
+ elements.balanceMetrics.classList.remove("hidden")
+ elements.remainingBalance.textContent = formatUsd(balance.currentBalance)
+ elements.initialBalance.textContent = formatUsd(balance.initialBalance)
+ elements.spentBalance.textContent =
+ balance.spentSinceImport == null
+ ? "暂不可算"
+ : formatUsd(balance.spentSinceImport)
+
+ if (balance.balanceIncreased) {
+ elements.balanceMessage.textContent =
+ "当前额度高于首次查询值,可能发生过充值或赠送,因此暂不计算累计消耗。"
+ return
+ }
+ const checkedAt = balance.checkedAt
+ ? new Date(balance.checkedAt).toLocaleString("zh-CN")
+ : "刚刚"
+ elements.balanceMessage.textContent = `余额由 New API 向上游查询;“已消耗”从本工具首次查询开始计算。更新时间:${checkedAt}`
+}
+
+const parseManualModels = () => [
+ ...new Set(
+ elements.manualModels.value
+ .split(/[\n,]/)
+ .map((model) => model.trim())
+ .filter(Boolean),
+ ),
+]
+
+function updateModelPlanSummary() {
+ if (!state.preview) return
+ const aliases = new Set()
+ const mappedTargets = new Set()
+ for (const entry of state.mappings) {
+ const standardModel = entry.standardModel.trim()
+ const actualModel = entry.actualModel.trim()
+ if (standardModel && actualModel && standardModel !== actualModel) {
+ aliases.add(standardModel)
+ mappedTargets.add(actualModel)
+ }
+ }
+ const hideMappedTargets =
+ state.selectedProvider?.channelConfig?.autoMapProviderPrefix === true
+ const finalModels = new Set([
+ ...[...state.preview.models, ...parseManualModels()].filter(
+ (model) => !hideMappedTargets || !mappedTargets.has(model),
+ ),
+ ...aliases,
+ ])
+ elements.finalModelCount.textContent = String(finalModels.size)
+ elements.mappingCount.textContent = String(aliases.size)
+}
+
+function renderMappings() {
+ elements.mappingList.replaceChildren()
+ if (state.mappings.length === 0) {
+ const empty = document.createElement("div")
+ empty.className = "mapping-empty"
+ empty.textContent = "当前保持上游原名;可以生成建议或手动添加。"
+ elements.mappingList.append(empty)
+ updateModelPlanSummary()
+ return
+ }
+
+ state.mappings.forEach((mapping, index) => {
+ const row = document.createElement("div")
+ row.className = "mapping-row"
+ const standardInput = document.createElement("input")
+ standardInput.placeholder = "对外标准名"
+ standardInput.value = mapping.standardModel
+ standardInput.setAttribute("aria-label", "对外标准模型名")
+ const arrow = document.createElement("span")
+ arrow.textContent = "→"
+ const actualInput = document.createElement("input")
+ actualInput.placeholder = "上游实际模型"
+ actualInput.value = mapping.actualModel
+ actualInput.setAttribute("list", "actual-model-options")
+ actualInput.setAttribute("aria-label", "上游实际模型名")
+ const remove = document.createElement("button")
+ remove.type = "button"
+ remove.textContent = "×"
+ remove.setAttribute("aria-label", "删除这条映射")
+ standardInput.addEventListener("input", () => {
+ state.mappings[index].standardModel = standardInput.value
+ updateModelPlanSummary()
+ })
+ actualInput.addEventListener("input", () => {
+ state.mappings[index].actualModel = actualInput.value
+ updateModelPlanSummary()
+ })
+ remove.addEventListener("click", () => {
+ state.mappings.splice(index, 1)
+ renderMappings()
+ })
+ row.append(standardInput, arrow, actualInput, remove)
+ elements.mappingList.append(row)
+ })
+ updateModelPlanSummary()
+}
+
+function suggestedStandardName(actualModel) {
+ let result = actualModel.trim()
+ const slashIndex = result.lastIndexOf("/")
+ if (slashIndex >= 0) result = result.slice(slashIndex + 1)
+ const colonIndex = result.indexOf(":")
+ if (colonIndex >= 0) result = result.slice(0, colonIndex)
+ return result
+ .replace(/[-_](?:20\d{2})[-_]\d{2}[-_]\d{2}$/i, "")
+ .replace(/[-_](?:20\d{6})$/i, "")
+ .trim()
+}
+
+function suggestedProviderPrefixMappings(models) {
+ const candidates = new Map()
+ for (const actualModel of models) {
+ const slashIndex = actualModel.indexOf("/")
+ if (slashIndex < 1 || slashIndex === actualModel.length - 1) continue
+ const standardModel = actualModel.slice(slashIndex + 1)
+ const targets = candidates.get(standardModel) || []
+ targets.push(actualModel)
+ candidates.set(standardModel, targets)
+ }
+ return [...candidates.entries()]
+ .filter(([, targets]) => targets.length === 1)
+ .map(([standardModel, [actualModel]]) => ({ standardModel, actualModel }))
+}
+
+elements.suggestMappings.addEventListener("click", () => {
+ if (!state.preview) return
+ const actualSet = new Set(state.preview.models)
+ const usedAliases = new Set(
+ state.mappings.map((entry) => entry.standardModel),
+ )
+ let added = 0
+ for (const actualModel of state.preview.models) {
+ const standardModel = suggestedStandardName(actualModel)
+ if (
+ !standardModel ||
+ standardModel === actualModel ||
+ actualSet.has(standardModel) ||
+ usedAliases.has(standardModel)
+ ) {
+ continue
+ }
+ state.mappings.push({ standardModel, actualModel })
+ usedAliases.add(standardModel)
+ added += 1
+ }
+ renderMappings()
+ toast(
+ added > 0
+ ? `生成了 ${added} 条保守命名建议,请确认后写入`
+ : "没有发现需要改名的模型",
+ )
+})
+
+elements.addMapping.addEventListener("click", () => {
+ state.mappings.push({ standardModel: "", actualModel: "" })
+ renderMappings()
+ const rows = elements.mappingList.querySelectorAll(".mapping-row")
+ rows[rows.length - 1]?.querySelector("input")?.focus()
+})
+
+elements.manualModels.addEventListener("input", updateModelPlanSummary)
+elements.duplicateTarget.addEventListener("change", updateDuplicateAction)
+
+function isSecretInputVisible(input) {
+ return input.tagName === "TEXTAREA"
+ ? !input.classList.contains("masked")
+ : input.type !== "password"
+}
+
+function setSecretInputVisible(input, visible) {
+ if (input.tagName === "TEXTAREA") {
+ input.classList.toggle("masked", !visible)
+ return
+ }
+ input.type = visible ? "text" : "password"
+}
+
+function resetStaticCredentialVisibility() {
+ for (const input of [
+ elements.apiKey,
+ elements.uniformQuota,
+ elements.keyQuotas,
+ ]) {
+ setSecretInputVisible(input, true)
+ const button = document.querySelector(`[data-toggle-secret="${input.id}"]`)
+ if (button) button.textContent = "隐藏"
+ }
+}
+
+document.querySelectorAll("[data-toggle-secret]").forEach((button) => {
+ button.addEventListener("click", () => {
+ const input = document.getElementById(button.dataset.toggleSecret)
+ const visible = isSecretInputVisible(input)
+ setSecretInputVisible(input, !visible)
+ button.textContent = visible ? "显示" : "隐藏"
+ })
+})
+
+elements.toggleConfig.addEventListener("click", () => {
+ const isHidden = elements.configForm.classList.toggle("hidden")
+ elements.toggleConfig.textContent = isHidden ? "展开设置" : "收起设置"
+})
+
+elements.providerSearch.addEventListener("input", renderProviders)
+
+const resetOpenLoginWarning = () => {
+ state.pendingInsecureLoginUrl = ""
+ elements.openLoginPage.textContent = "打开网页登录"
+ elements.openLoginPage.classList.remove("insecure")
+}
+
+function updateInsecureHttpVisibility() {
+ let show = false
+ try {
+ const target = new URL(elements.targetUrl.value.trim())
+ show =
+ target.protocol === "http:" &&
+ !["localhost", "127.0.0.1", "::1"].includes(target.hostname)
+ } catch {
+ // Keep the opt-in hidden until the address is complete.
+ }
+ elements.insecureHttpRow.classList.toggle("hidden", !show)
+ if (!show) elements.allowInsecureHttp.checked = false
+}
+
+elements.targetUrl.addEventListener("input", () => {
+ resetOpenLoginWarning()
+ updateInsecureHttpVisibility()
+ resetSavedTokenHintIfConnectionChanged()
+})
+
+elements.userId.addEventListener(
+ "input",
+ resetSavedTokenHintIfConnectionChanged,
+)
+
+elements.profileName.addEventListener("input", () => {
+ state.profileNameEdited = true
+})
+
+elements.channelName.addEventListener("input", () => {
+ state.channelNameEdited = true
+})
+
+elements.profileSelect.addEventListener("change", async () => {
+ const profileId = elements.profileSelect.value
+ if (!profileId) return
+ hideStatus(elements.configStatus)
+ try {
+ const result = await api("/api/profiles/select", {
+ method: "POST",
+ body: JSON.stringify({ profileId }),
+ })
+ renderConnection(result.config)
+ renderGroups(result.groups || [])
+ closePreviewDialog()
+ elements.loginPassword.value = ""
+ elements.adminToken.value = ""
+ showStatus(
+ elements.configStatus,
+ state.configured
+ ? "已切换 New API,可以继续导入。"
+ : "已切换站点;本地登录已失效,请重新登录。",
+ !state.configured,
+ )
+ toast(`已切换到 ${result.config.name}`)
+ } catch (error) {
+ showStatus(elements.configStatus, error.message, true)
+ }
+})
+
+elements.newProfile.addEventListener("click", () => {
+ state.activeProfileId = ""
+ state.configured = false
+ renderGroups([])
+ state.profileNameEdited = false
+ elements.profileSelect.value = ""
+ elements.profileName.value = ""
+ elements.targetUrl.value = ""
+ elements.loginUsername.value = ""
+ elements.loginPassword.value = ""
+ elements.userId.value = "1"
+ elements.adminToken.value = ""
+ state.credentialTargetUrl = ""
+ state.credentialUserId = ""
+ elements.adminToken.required = true
+ elements.adminToken.placeholder = TOKEN_PLACEHOLDER
+ elements.connectionPill.classList.remove("connected")
+ elements.connectionPill.querySelector("strong").textContent = "新增站点"
+ updateInsecureHttpVisibility()
+ elements.configForm.classList.remove("hidden")
+ hideStatus(elements.configStatus)
+ elements.profileName.focus()
+})
+
+elements.openLoginPage.addEventListener("click", () => {
+ hideStatus(elements.configStatus)
+ try {
+ const target = new URL(elements.targetUrl.value.trim())
+ if (!["http:", "https:"].includes(target.protocol)) {
+ throw new Error("New API 地址必须使用 HTTP 或 HTTPS")
+ }
+ if (target.username || target.password) {
+ throw new Error("New API 地址中不能包含用户名或密码")
+ }
+ const isLoopback = ["localhost", "127.0.0.1", "::1"].includes(
+ target.hostname,
+ )
+ target.search = ""
+ target.hash = ""
+ const pathname = target.pathname.replace(/\/+$/, "")
+ target.pathname = pathname.endsWith("/login")
+ ? pathname
+ : `${pathname}/login`
+ if (
+ target.protocol !== "https:" &&
+ !isLoopback &&
+ state.pendingInsecureLoginUrl !== target.href
+ ) {
+ state.pendingInsecureLoginUrl = target.href
+ elements.openLoginPage.textContent = "仍然打开 HTTP 登录页"
+ elements.openLoginPage.classList.add("insecure")
+ showStatus(
+ elements.configStatus,
+ "风险提示:该站点没有 HTTPS,登录密码会通过公网明文传输,可能被截获。确认风险后再次点击打开。",
+ true,
+ )
+ return
+ }
+ resetOpenLoginWarning()
+ window.open(target.href, "_blank", "noopener,noreferrer")
+ } catch (error) {
+ showStatus(
+ elements.configStatus,
+ error instanceof Error ? error.message : "New API 地址不正确",
+ true,
+ )
+ }
+})
+
+elements.configForm.addEventListener("submit", async (event) => {
+ event.preventDefault()
+ hideStatus(elements.configStatus)
+ const button = $("#login-button")
+ setLoading(button, true)
+ try {
+ const result = await api("/api/login", {
+ method: "POST",
+ body: JSON.stringify({
+ profileId: state.activeProfileId,
+ profileName: elements.profileName.value,
+ profileNameEdited: state.profileNameEdited,
+ targetUrl: elements.targetUrl.value,
+ username: elements.loginUsername.value,
+ password: elements.loginPassword.value,
+ rememberSession: elements.rememberSession.checked,
+ allowInsecureHttp: elements.allowInsecureHttp.checked,
+ }),
+ })
+ elements.loginPassword.value = ""
+ state.configured = true
+ state.profileNameEdited = false
+ state.activeProfileId = result.profile.profileId
+ const profileSummary = {
+ profileId: result.profile.profileId,
+ name: result.profile.name,
+ target: result.target,
+ }
+ state.profiles = [
+ ...state.profiles.filter(
+ (profile) => profile.profileId !== profileSummary.profileId,
+ ),
+ profileSummary,
+ ]
+ renderProfiles()
+ renderGroups(result.groups || [])
+ elements.connectionPill.classList.add("connected")
+ elements.connectionPill.querySelector("strong").textContent = new URL(
+ elements.targetUrl.value,
+ ).host
+ showStatus(
+ elements.configStatus,
+ elements.rememberSession.checked
+ ? `已登录 ${result.username}:${result.target}。密码未保存,Session 已加密保存。`
+ : `已登录 ${result.username}:${result.target}。密码和 Session 均未保存。`,
+ )
+ toast("New API 登录成功")
+ } catch (error) {
+ elements.loginPassword.value = ""
+ showStatus(elements.configStatus, error.message, true)
+ toast(error.message, true)
+ } finally {
+ setLoading(button, false)
+ }
+})
+
+elements.tokenForm.addEventListener("submit", async (event) => {
+ event.preventDefault()
+ hideStatus(elements.configStatus)
+ const button = $("#save-token")
+ setLoading(button, true)
+ try {
+ const result = await api("/api/config", {
+ method: "POST",
+ body: JSON.stringify({
+ profileId: state.activeProfileId,
+ profileName: elements.profileName.value,
+ profileNameEdited: state.profileNameEdited,
+ targetUrl: elements.targetUrl.value,
+ adminToken: elements.adminToken.value.trim(),
+ rememberToken: elements.rememberToken.checked,
+ allowInsecureHttp: elements.allowInsecureHttp.checked,
+ }),
+ })
+ elements.adminToken.value = ""
+ state.configured = true
+ state.profileNameEdited = false
+ state.activeProfileId = result.profile.profileId
+ state.credentialTargetUrl = result.profile.targetUrl
+ state.credentialUserId = result.profile.userId
+ elements.adminToken.required = false
+ elements.adminToken.placeholder = "已安全保存;修改时重新输入"
+ const profileSummary = {
+ profileId: result.profile.profileId,
+ name: result.profile.name,
+ target: result.target,
+ }
+ state.profiles = [
+ ...state.profiles.filter(
+ (profile) => profile.profileId !== profileSummary.profileId,
+ ),
+ profileSummary,
+ ]
+ renderProfiles()
+ renderGroups(result.groups || [])
+ elements.connectionPill.classList.add("connected")
+ elements.connectionPill.querySelector("strong").textContent = new URL(
+ elements.targetUrl.value,
+ ).host
+ showStatus(elements.configStatus, `令牌连接成功:${result.target}`)
+ toast("New API 连接已经准备好")
+ } catch (error) {
+ elements.adminToken.value = ""
+ if (/access token|Unauthorized|认证/i.test(error.message)) {
+ elements.adminToken.placeholder = "令牌无效,请粘贴新的管理员系统访问令牌"
+ }
+ showStatus(elements.configStatus, error.message, true)
+ toast(error.message, true)
+ } finally {
+ setLoading(button, false)
+ }
+})
+
+function buildCredentialRequestBody() {
+ return {
+ providerId: state.selectedProvider.id,
+ name: elements.channelName.value,
+ automaticName: !state.channelNameEdited,
+ groups: selectedGroups(),
+ priority: elements.channelPriority.value,
+ weight: elements.channelWeight.value,
+ prioritySequence: {
+ enabled: elements.priorityDescending.checked,
+ step: elements.priorityStep.value,
+ },
+ configSource: selectedTemplateId()
+ ? "template"
+ : elements.configSource.value,
+ templateChannelId: selectedTemplateId(),
+ baseUrl: elements.sourceBaseUrl.value,
+ apiKey: elements.apiKey.value,
+ quotaMode: elements.quotaMode.value,
+ uniformQuota: elements.uniformQuota.value,
+ quotaLines: elements.keyQuotas.value,
+ credentialMode: elements.credentialMode.value,
+ credentialParts: Object.fromEntries(
+ [
+ ...elements.providerConfigFields.querySelectorAll(
+ "[data-credential-part]",
+ ),
+ ].map((input) => [input.dataset.credentialPart, input.value]),
+ ),
+ providerExtra:
+ elements.providerConfigFields.querySelector("[data-provider-extra]")
+ ?.value || "",
+ providerFlags: {
+ ...Object.fromEntries(
+ [
+ ...elements.providerConfigFields.querySelectorAll(
+ "[data-provider-flag]",
+ ),
+ ].map((input) => [input.dataset.providerFlag, input.checked]),
+ ),
+ ...(state.selectedProvider.id === "aws"
+ ? { globalInference: elements.awsGlobalInference.checked }
+ : {}),
+ },
+ providerModels: elements.providerModels.value,
+ providerModelMappings: elements.providerModelMappings.value,
+ }
+}
+
+function clearSensitiveCredentialInputs() {
+ elements.apiKey.value = ""
+ elements.uniformQuota.value = ""
+ elements.keyQuotas.value = ""
+ elements.providerConfigFields
+ .querySelectorAll('[data-sensitive="true"]')
+ .forEach((input) => {
+ input.value = ""
+ })
+ resetStaticCredentialVisibility()
+}
+
+elements.credentialForm.addEventListener("submit", async (event) => {
+ event.preventDefault()
+ if (!state.configured) {
+ showStatus(elements.credentialStatus, "请先在上方连接 New API。", true)
+ setAppView("sites")
+ $("#connection").scrollIntoView({ behavior: "smooth" })
+ return
+ }
+ hideStatus(elements.credentialStatus)
+ setLoading(elements.previewButton, true)
+ try {
+ const body = buildCredentialRequestBody()
+ state.pendingSchedule = elements.scheduleEnabled.checked
+ ? {
+ startAt: elements.scheduleStartAt.value,
+ batchSize: elements.scheduleBatchSize.value,
+ intervalMinutes: elements.scheduleIntervalMinutes.value,
+ }
+ : null
+ const preview = await api("/api/preview", {
+ method: "POST",
+ body: JSON.stringify(body),
+ })
+ elements.channelName.value = preview.name
+ renderPreview(preview)
+ toast(
+ `识别 ${preview.deduplication.inputCount} 条,去重后待添加 ${preview.keyCount} 条`,
+ )
+ } catch (error) {
+ state.pendingSchedule = null
+ showStatus(elements.credentialStatus, error.message, true)
+ } finally {
+ setLoading(elements.previewButton, false)
+ }
+})
+
+elements.discardPreview.addEventListener("click", () => {
+ if (state.createInFlight) return
+ closePreviewDialog()
+ toast("已返回,可继续修改 Key 或额度")
+})
+
+elements.previewDialog.addEventListener("cancel", (event) => {
+ if (state.createInFlight) {
+ event.preventDefault()
+ return
+ }
+ closePreviewDialog()
+})
+
+async function createCurrentPreview({ forceNew = false } = {}) {
+ if (!state.preview || state.createInFlight) return
+ const hasDuplicates =
+ state.pendingSchedule === null &&
+ state.preview.duplicates.length > 0 &&
+ !state.preview.templateChannelId
+ const existingChannel = forceNew ? null : selectedDuplicateChannel()
+ if (hasDuplicates && !forceNew && !elements.confirmDuplicates.checked) {
+ const message = existingChannel
+ ? existingChannel.isMultiKey
+ ? "请先确认把 Key 追加到所选多 Key 渠道。"
+ : "请先确认替换所选单 Key 渠道的原 Key。"
+ : "请先确认是否仍要新增同来源渠道。"
+ showStatus(elements.createStatus, message, true)
+ return
+ }
+ hideStatus(elements.createStatus)
+ state.createInFlight = true
+ elements.discardPreview.disabled = true
+ setLoading(elements.createChannel, true)
+ try {
+ if (state.pendingSchedule !== null) {
+ const result = await api("/api/schedules", {
+ method: "POST",
+ body: JSON.stringify({
+ previewId: state.preview.previewId,
+ combineKeys:
+ elements.batchMode.value === "multi_to_single" &&
+ state.preview.keyCount > 1,
+ manualModels: parseManualModels(),
+ mappings: state.mappings,
+ schedule: state.pendingSchedule,
+ }),
+ })
+ clearSensitiveCredentialInputs()
+ state.schedules = [
+ result.schedule,
+ ...state.schedules.filter(
+ (schedule) => schedule.id !== result.schedule.id,
+ ),
+ ]
+ renderSchedules()
+ showStatus(
+ elements.createStatus,
+ `定时任务已保存:去重后共 ${result.schedule.counts.total} 条 Key,首次执行 ${formatDateTime(result.schedule.nextRunAt)}。`,
+ )
+ toast("定时上 Key 任务已确认保存")
+ state.preview = null
+ state.pendingSchedule = null
+ elements.createChannel.querySelector("span").textContent = "任务已保存"
+ elements.discardPreview.textContent = "关闭"
+ return
+ }
+ const result = await api("/api/create", {
+ method: "POST",
+ body: JSON.stringify({
+ previewId: state.preview.previewId,
+ confirmDuplicates: forceNew || elements.confirmDuplicates.checked,
+ existingChannelId: existingChannel?.id ?? null,
+ manualModels: parseManualModels(),
+ mappings: state.mappings,
+ combineKeys:
+ elements.batchMode.value === "multi_to_single" &&
+ state.preview.keyCount > 1,
+ }),
+ })
+ clearSensitiveCredentialInputs()
+ if (result.continuationSchedule) {
+ state.schedules = [
+ result.continuationSchedule,
+ ...state.schedules.filter(
+ (schedule) => schedule.id !== result.continuationSchedule.id,
+ ),
+ ]
+ renderSchedules()
+ const pending = result.continuationSchedule.counts.pending
+ showStatus(
+ elements.createStatus,
+ `首条已写入,剩余 ${pending} 条已进入安全队列;每分钟自动写入 1 条,避免触发 New API 限流。下一条:${formatDateTime(result.continuationSchedule.nextRunAt)}。`,
+ )
+ toast(`已安全排队:${pending} 条 Key 将自动写入`)
+ } else if (result.recoverySchedule) {
+ state.schedules = [
+ result.recoverySchedule,
+ ...state.schedules.filter(
+ (schedule) => schedule.id !== result.recoverySchedule.id,
+ ),
+ ]
+ renderSchedules()
+ const pending = result.recoverySchedule.counts.pending
+ showStatus(
+ elements.createStatus,
+ `部分完成:成功 ${result.successCount} 条,待续传 ${pending} 条。New API 触发限流,剩余 Key 已加密保留;成功项不会重复写入。下次自动继续:${formatDateTime(result.recoverySchedule.nextRunAt)}。`,
+ false,
+ { recovering: true },
+ )
+ toast(`已转自动续传:${pending} 条 Key 等待继续写入`)
+ } else if (result.operation === "skipped") {
+ showStatus(
+ elements.createStatus,
+ `没有重复写入:${result.skippedCount} 条 Key 已由另一批任务先完成。`,
+ )
+ toast("重复 Key 已自动跳过")
+ } else if (result.operation === "updated") {
+ const action =
+ result.keyAction === "appended" ? "已追加 Key 到" : "已替换 Key:"
+ const enableCopy = result.channelEnabled
+ ? "渠道已启用"
+ : "但自动启用失败,请到 New API 检查状态"
+ showStatus(
+ elements.createStatus,
+ `${action}“${result.channelName}”:成功 ${result.successCount} 条,失败 ${result.failedCount} 条;${enableCopy}。`,
+ !result.success,
+ )
+ toast(
+ result.success ? "同类渠道已更新" : "同类渠道已部分更新",
+ !result.success,
+ )
+ } else if (result.operation === "created-multi-key") {
+ showStatus(
+ elements.createStatus,
+ `已建立多 Key 渠道“${result.channelName}”,一次写入 ${result.successCount} 条 Key。`,
+ )
+ toast(`多 Key 渠道写入完成:${result.successCount} 条`)
+ } else {
+ const summary = `成功 ${result.successCount} 条,失败 ${result.failedCount} 条`
+ showStatus(
+ elements.createStatus,
+ `批量写入完成:${summary};每个渠道包含 ${result.modelCount} 个模型。`,
+ result.failedCount > 0,
+ )
+ toast(
+ result.failedCount > 0 ? `批量写入部分完成:${summary}` : summary,
+ result.failedCount > 0,
+ )
+ }
+ if (result.balance) renderBalance(result.balance, result.channelId)
+ state.preview = null
+ state.pendingSchedule = null
+ elements.discardPreview.textContent = "关闭"
+ elements.createChannel.querySelector("span").textContent =
+ result.continuationSchedule
+ ? "已进入安全队列"
+ : result.recoverySchedule
+ ? "已转自动续传"
+ : result.operation === "updated"
+ ? "更新完成"
+ : result.operation === "skipped"
+ ? "已自动去重"
+ : "写入完成"
+ if (result.successCount > 0) {
+ try {
+ await loadRecords()
+ } catch (error) {
+ toast(`渠道已写入,但记录刷新失败:${error.message}`, true)
+ }
+ }
+ } catch (error) {
+ showStatus(elements.createStatus, error.message, true)
+ } finally {
+ state.createInFlight = false
+ setLoading(elements.createChannel, false)
+ elements.createChannel.disabled = !state.preview
+ elements.discardPreview.disabled = false
+ }
+}
+
+elements.createChannel.addEventListener("click", () => createCurrentPreview())
+
+elements.refreshBalance.addEventListener("click", async () => {
+ if (!state.createdChannelId) return
+ setLoading(elements.refreshBalance, true)
+ try {
+ const result = await api("/api/balance", {
+ method: "POST",
+ body: JSON.stringify({ channelId: state.createdChannelId }),
+ })
+ renderBalance(result.balance, result.channelId)
+ toast(
+ result.balance.status === "available"
+ ? "渠道余额已刷新"
+ : "该渠道暂时无法自动查询余额",
+ result.balance.status !== "available",
+ )
+ } catch (error) {
+ elements.balanceMessage.textContent = error.message
+ toast(error.message, true)
+ } finally {
+ setLoading(elements.refreshBalance, false)
+ }
+})
+
+function setUsageMonitorSyncStatus(copy, status = "") {
+ const dot = document.createElement("i")
+ elements.usageMonitorSyncStatus.replaceChildren(
+ dot,
+ document.createTextNode(` ${copy}`),
+ )
+ elements.usageMonitorSyncStatus.classList.toggle(
+ "warning",
+ status === "warning",
+ )
+ elements.usageMonitorSyncStatus.classList.toggle(
+ "syncing",
+ status === "syncing",
+ )
+}
+
+async function refreshUsageRecords(records, button, options = {}) {
+ const originalCopy = button.textContent
+ button.disabled = true
+ setUsageMonitorSyncStatus(
+ options.automatic ? "自动校准中" : "正在校准",
+ "syncing",
+ )
+ try {
+ const refreshable = records.filter(
+ (record) =>
+ record.channelId &&
+ (!record.sharedChannel || Number.isInteger(record.keyIndex)),
+ )
+ let updatedCount = 0
+ let failedCount = 0
+ let stoppedForRateLimit = false
+ for (let index = 0; index < refreshable.length; index += 1) {
+ button.textContent = `刷新中 ${index + 1}/${refreshable.length}`
+ try {
+ const result = await api("/api/imports/refresh", {
+ method: "POST",
+ body: JSON.stringify({ recordId: refreshable[index].id }),
+ })
+ updatedCount += 1
+ state.records = state.records.map((record) =>
+ record.id === result.record.id ? result.record : record,
+ )
+ } catch (error) {
+ failedCount += 1
+ if (/429|请求次数过多|限流/i.test(error.message)) {
+ stoppedForRateLimit = true
+ break
+ }
+ }
+ if (index < refreshable.length - 1) {
+ await pause(USAGE_REFRESH_INTERVAL_MS)
+ }
+ }
+ renderUsageMonitor()
+ renderRecords()
+ setUsageMonitorSyncStatus(
+ stoppedForRateLimit
+ ? `限流保护:已完成 ${updatedCount} 条`
+ : failedCount > 0
+ ? `${updatedCount} 条已校准`
+ : "刚刚已校准",
+ failedCount > 0 ? "warning" : "",
+ )
+ if (!options.silent) {
+ toast(
+ stoppedForRateLimit
+ ? `New API 已限流,已停止继续刷新,避免影响上 Key;本次完成 ${updatedCount} 条`
+ : failedCount > 0
+ ? `刷新完成:成功 ${updatedCount} 条,失败 ${failedCount} 条`
+ : `已校准 ${updatedCount} 条 Key 累计用量`,
+ failedCount > 0,
+ )
+ }
+ } catch (error) {
+ setUsageMonitorSyncStatus("校准失败", "warning")
+ toast(error.message, true)
+ } finally {
+ button.disabled = false
+ button.textContent = originalCopy
+ }
+}
+
+async function autoRefreshUsageMonitor() {
+ if (state.usageAutoRefreshStarted) return
+ state.usageAutoRefreshStarted = true
+ const staleBefore = Date.now() - 15 * 60 * 1000
+ const staleRecords = currentUsageMonitorRecords().filter((record) => {
+ if (!record.channelId || Number.isInteger(record.keyIndex)) return false
+ const checkedAt = Date.parse(record.usageCheckedAt || "")
+ return (
+ record.usageMethod !== "database-stat" ||
+ !Number.isFinite(checkedAt) ||
+ checkedAt < staleBefore
+ )
+ })
+ if (staleRecords.length === 0) {
+ setUsageMonitorSyncStatus("数据已是最新")
+ return
+ }
+ // Entering the usage page only refreshes a very small sample. A full stale
+ // ledger can contain hundreds of channels and would otherwise consume New
+ // API's shared management quota before the user starts importing Keys.
+ await refreshUsageRecords(
+ staleRecords.slice(0, 3),
+ elements.refreshUsageMonitor,
+ {
+ automatic: true,
+ silent: true,
+ },
+ )
+}
+
+elements.refreshRecords.addEventListener("click", async () => {
+ const refreshable = filterUsageRecords(state.records, currentRecordFilters())
+ await refreshUsageRecords(refreshable, elements.refreshRecords)
+})
+
+elements.refreshUsageMonitor.addEventListener("click", async () => {
+ await refreshUsageRecords(
+ currentUsageMonitorRecords(),
+ elements.refreshUsageMonitor,
+ )
+})
+
+elements.usageMonitorTarget.addEventListener("change", renderUsageMonitor)
+for (const input of [elements.usageMonitorStart, elements.usageMonitorEnd]) {
+ input.addEventListener("change", () => {
+ markUsageMonitorRange()
+ const start = elements.usageMonitorStart.value
+ const end = elements.usageMonitorEnd.value
+ if (start && end && start > end) {
+ if (input === elements.usageMonitorStart) {
+ elements.usageMonitorEnd.value = start
+ } else {
+ elements.usageMonitorStart.value = end
+ }
+ }
+ renderUsageMonitor()
+ })
+}
+for (const button of document.querySelectorAll("[data-usage-range]")) {
+ button.addEventListener("click", () => {
+ setUsageMonitorRange(button.dataset.usageRange)
+ renderUsageMonitor()
+ })
+}
+
+for (const filter of [
+ elements.recordsTargetFilter,
+ elements.recordsProviderFilter,
+ elements.recordsStatusFilter,
+]) {
+ filter.addEventListener("change", renderRecords)
+}
+elements.recordsSearch.addEventListener("input", renderRecords)
+elements.resetRecordFilters.addEventListener("click", () => {
+ elements.recordsTargetFilter.value = ""
+ elements.recordsProviderFilter.value = ""
+ elements.recordsStatusFilter.value = ""
+ elements.recordsSearch.value = ""
+ renderRecords()
+})
+
+elements.refreshGroups.addEventListener("click", loadGroups)
+elements.refreshSchedules.addEventListener("click", async () => {
+ setLoading(elements.refreshSchedules, true)
+ try {
+ await loadSchedules()
+ toast("定时队列已刷新")
+ } catch (error) {
+ toast(error.message, true)
+ } finally {
+ setLoading(elements.refreshSchedules, false)
+ }
+})
+
+function updateScheduleForm() {
+ const enabled = elements.scheduleEnabled.checked
+ elements.scheduleOptions.classList.toggle("hidden", !enabled)
+ elements.previewButton.querySelector("span").textContent = enabled
+ ? "识别并预览定时任务"
+ : "识别 Key 并预览"
+ if (enabled && !elements.scheduleStartAt.value) {
+ elements.scheduleStartAt.value = formatDateTimeInput(
+ new Date(Date.now() + 10 * 60 * 1000),
+ )
+ }
+}
+
+elements.scheduleEnabled.addEventListener("change", updateScheduleForm)
+
+function updateQuotaModeForm() {
+ const uniform = elements.quotaMode.value === "uniform"
+ elements.uniformQuotaField.classList.toggle("hidden", !uniform)
+ elements.perLineQuotaField.classList.toggle("hidden", uniform)
+ elements.uniformQuota.required = uniform
+ elements.keyQuotas.required = false
+ elements.quotaHelp.textContent = uniform
+ ? "这个额度会应用到本批全部 Key;支持 485、485u、$485、485刀或 x。"
+ : "每行对应左侧同一行 Key;可不填,未知额度填写 x。"
+}
+
+elements.quotaMode.addEventListener("change", updateQuotaModeForm)
+
+function updatePrioritySequenceForm() {
+ const multiKey = elements.batchMode.value === "multi_to_single"
+ if (multiKey) elements.priorityDescending.checked = false
+ elements.priorityDescending.disabled = multiKey
+ const enabled = elements.priorityDescending.checked && !multiKey
+ elements.priorityStepField.classList.toggle("hidden", !enabled)
+ elements.priorityStep.disabled = !enabled
+ if (enabled && !elements.channelPriority.value) {
+ elements.channelPriority.value = "100"
+ }
+}
+
+elements.batchMode.addEventListener("change", updatePrioritySequenceForm)
+elements.priorityDescending.addEventListener(
+ "change",
+ updatePrioritySequenceForm,
+)
+
+async function bootstrap() {
+ try {
+ const response = await fetch("/api/bootstrap", { cache: "no-store" })
+ const payload = await response.json()
+ if (!response.ok) throw new Error(payload.error || "初始化失败")
+ state.sessionToken = payload.sessionToken
+ state.providers = payload.providers
+ state.customProviders = payload.customProviders || []
+ state.profiles = payload.profiles || []
+ state.activeProfileId = payload.config.profileId || ""
+ renderProfiles()
+ renderConnection(payload.config)
+ renderCategories()
+ renderProviders()
+ renderGroups(payload.groups || [])
+ state.schedules = payload.schedules || []
+ renderSchedules()
+ if (payload.deployment?.sharedDatabase) {
+ elements.serviceLabel.textContent = "共享服务运行中"
+ elements.serviceDetail.textContent = "站点、任务和记录由数据库同步"
+ }
+ elements.accessLogout.classList.toggle(
+ "hidden",
+ !payload.deployment?.accessProtected,
+ )
+ if (payload.groupsError) {
+ elements.channelGroupsHelp.textContent = `分组读取失败:${payload.groupsError}`
+ }
+ setUsageMonitorRange("7")
+ await loadRecords()
+ updateScheduleForm()
+ void autoRefreshUsageMonitor()
+ } catch (error) {
+ toast(error.message, true)
+ }
+}
+
+setAppView(window.location.hash, { updateHash: false, scroll: false })
+bootstrap()
diff --git a/tools/channel-key-importer/public/assets/operations-board.jpg b/tools/channel-key-importer/public/assets/operations-board.jpg
new file mode 100644
index 0000000000..70fa194cfc
Binary files /dev/null and b/tools/channel-key-importer/public/assets/operations-board.jpg differ
diff --git a/tools/channel-key-importer/public/index.html b/tools/channel-key-importer/public/index.html
new file mode 100644
index 0000000000..b2a11c18fd
--- /dev/null
+++ b/tools/channel-key-importer/public/index.html
@@ -0,0 +1,1199 @@
+
+
+
+
+
+
+ dataeyesai · 渠道 Key 导入器
+
+
+
+
+
+
+
+
+
+
+
+
+ TARGET
+
连接你的 New API
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 使用管理员系统访问令牌
+ 只需要地址和令牌,用户 ID 自动识别
+
+
+
+
+
+
+
+
新建渠道
+
从一批 Key 到可用渠道
+
+ 当前页面只保留导入流程。任务、用量和历史记录已拆到独立工作区,录入时不再被其他信息打断。
+
+
+ -
+ 1选供应商明确渠道类型
+
+ -
+ 2填凭证批量 Key 与额度
+
+ -
+ 3确认写入模型、分组和路由
+
+
+
+
+
+ 本地处理 · 不保存完整 Key
+
+
+
+
+
+
+
+ SOURCE
+
选择渠道来源
+
+
—
+
+
+
+
+
+
+
+
+
+
+ CREDENTIAL
+
填写渠道凭证
+
+
等待选择
+
+
+
+
+
先选择这批 Key 的渠道
+
渠道由你指定,不自动猜测 Key 来源。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SCHEDULE
+
定时上 Key 队列
+
+
+
+
+ 待写入 Key
+ 会加密保存在本机,列表只显示尾号和指纹;服务长驻后,即使页面关掉也会按时间执行。
+
+ 还没有定时任务。
+
+
+
+
+
+
+
+ USAGE MONITOR
+ 等待校准
+
+
New API 用量监测
+
+
+
+
+ 日期筛选按 Key 的录入时间计算;已用额度是这些 Key
+ 从录入后至今的累计消费。金额使用 New API
+ 数据库汇总接口校准,剩余百分比只计算已填写额度的 Key。
+
+
+
+
+
+ 录入 Key
+ 0
+ 0 个已填写额度
+
+
+ 录入总额度
+ $0.00
+ 0 个未知额度
+
+
+ 这些 Key 累计已用
+ $0.00
+ 0 次请求
+
+
+ 已统计剩余额度
+ $0.00
+ 只计算已刷新且填写额度的 Key
+
+
+
+
+ —
+
+
+ 剩余用量百分比
+ 暂无可计算数据
+
+
+
+ 用量刷新覆盖率
+ 0%
+ 0 / 0 个已刷新
+
+
+
+
+ 当前 New API 地址和日期范围内没有 Key 录入记录。
+
+
+
+
+
+
+ KEY LEDGER
+
上 Key 批次记录
+
+
+
+
+ 先按每次写入任务显示时间、成功数量、录入额度和累计用量,再展开查看每条
+ Key。只保存 Key 尾号和不可逆指纹,不保存明文。
+
+
+
+ Key 数量
+ 0
+ 0 个已使用
+
+
+ 录入总额度
+ $0.00
+ 0 个未知额度
+
+
+ New API 已消耗
+ $0.00
+ 0 / 0 个已统计
+
+
+ 已统计额度剩余
+ $0.00
+ 仅计算已刷新且填过额度的 Key
+
+
+ 请求次数
+ 0
+ 尚未刷新
+
+
+ Token 用量
+ 0
+ 输入 0 · 输出 0
+
+
+
+
+
+
+
+
+
+
+
+ 还没有 Key 填入记录。
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/channel-key-importer/public/login.css b/tools/channel-key-importer/public/login.css
new file mode 100644
index 0000000000..e9843b42fe
--- /dev/null
+++ b/tools/channel-key-importer/public/login.css
@@ -0,0 +1,228 @@
+:root {
+ font-family:
+ Inter,
+ ui-sans-serif,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ sans-serif;
+ color: #172230;
+ background: #eef1f5;
+ font-synthesis: none;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ min-width: 320px;
+ min-height: 100vh;
+ margin: 0;
+ display: grid;
+ place-items: center;
+ padding: 32px;
+}
+
+button,
+input {
+ font: inherit;
+}
+
+.login-shell {
+ width: min(1040px, 100%);
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(360px, 0.9fr);
+ overflow: hidden;
+ border: 1px solid #d9dee6;
+ border-radius: 16px;
+ background: #ffffff;
+ box-shadow: 0 18px 50px rgba(15, 35, 60, 0.1);
+}
+
+.login-card {
+ padding: clamp(36px, 6vw, 72px);
+}
+
+.brand-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 64px;
+ font-size: 18px;
+}
+
+.brand-mark {
+ width: 32px;
+ height: 32px;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ padding: 7px;
+ border-radius: 7px;
+ background: #60a5fa;
+}
+
+.brand-mark span {
+ height: 2px;
+ border-radius: 2px;
+ background: #10243d;
+}
+
+.brand-mark span:nth-child(2) {
+ width: 70%;
+}
+
+.brand-mark span:nth-child(3) {
+ width: 40%;
+}
+
+.eyebrow {
+ margin: 0 0 12px;
+ color: #2563eb;
+ font-size: 12px;
+ font-weight: 750;
+ letter-spacing: 0.08em;
+}
+
+h1 {
+ margin: 0;
+ font-size: clamp(30px, 4vw, 44px);
+ letter-spacing: -0.04em;
+}
+
+.description {
+ max-width: 540px;
+ margin: 18px 0 36px;
+ color: #687587;
+ font-size: 15px;
+ line-height: 1.75;
+}
+
+label {
+ display: block;
+ margin-bottom: 10px;
+ font-size: 13px;
+ font-weight: 700;
+}
+
+.key-field {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ border: 1px solid #cfd6e0;
+ border-radius: 10px;
+ background: #ffffff;
+}
+
+.key-field:focus-within {
+ border-color: #2563eb;
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
+}
+
+input {
+ min-width: 0;
+ padding: 15px 16px;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: #172230;
+}
+
+.key-field button {
+ padding: 0 16px;
+ border: 0;
+ background: transparent;
+ color: #2563eb;
+ font-size: 12px;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.submit-button {
+ width: 100%;
+ margin-top: 18px;
+ padding: 15px 18px;
+ border: 0;
+ border-radius: 10px;
+ color: #ffffff;
+ background: #1d4ed8;
+ font-weight: 750;
+ cursor: pointer;
+}
+
+.submit-button:disabled {
+ cursor: wait;
+ opacity: 0.6;
+}
+
+.status {
+ margin: 14px 0 0;
+ padding: 11px 12px;
+ border-radius: 8px;
+ color: #963f35;
+ background: #fff0e9;
+ font-size: 13px;
+}
+
+.hidden {
+ display: none;
+}
+
+.security-note {
+ margin: 18px 0 0;
+ color: #8994a3;
+ font-size: 11px;
+}
+
+.login-visual {
+ position: relative;
+ min-height: 620px;
+ margin: 0;
+ background: #10243d;
+}
+
+.login-visual img {
+ width: 100%;
+ height: 100%;
+ display: block;
+ object-fit: cover;
+}
+
+.login-visual figcaption {
+ position: absolute;
+ right: 20px;
+ bottom: 20px;
+ padding: 8px 10px;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 7px;
+ color: #ffffff;
+ background: rgba(16, 36, 61, 0.88);
+ font-size: 11px;
+}
+
+@media (max-width: 760px) {
+ body {
+ align-items: start;
+ padding: 0;
+ background: #ffffff;
+ }
+
+ .login-shell {
+ grid-template-columns: 1fr;
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
+ }
+
+ .login-card {
+ padding: 28px 20px 36px;
+ }
+
+ .brand-row {
+ margin-bottom: 48px;
+ }
+
+ .login-visual {
+ min-height: 280px;
+ }
+}
diff --git a/tools/channel-key-importer/public/login.html b/tools/channel-key-importer/public/login.html
new file mode 100644
index 0000000000..22aeb084b0
--- /dev/null
+++ b/tools/channel-key-importer/public/login.html
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+ dataeyesai · 系统登录
+
+
+
+
+
+
+
+
+
+ dataeyesai
+
+ 受保护的渠道工作台
+ 输入系统访问密钥
+
+ 此密钥只用于进入 dataeyesai,不是 New API
+ 管理令牌。登录后可在多台设备上共用站点、任务、用量和写入记录。
+
+
+ 登录态 12 小时后失效;连续输错会暂时锁定。
+
+
+
+ 共享数据库 · 服务端加密 · 独立登录保护
+
+
+
+
+
diff --git a/tools/channel-key-importer/public/login.js b/tools/channel-key-importer/public/login.js
new file mode 100644
index 0000000000..516b111831
--- /dev/null
+++ b/tools/channel-key-importer/public/login.js
@@ -0,0 +1,34 @@
+const form = document.querySelector("#access-form")
+const input = document.querySelector("#access-key")
+const toggle = document.querySelector("#toggle-key")
+const status = document.querySelector("#login-status")
+const submit = document.querySelector("#login-button")
+
+toggle.addEventListener("click", () => {
+ const visible = input.type === "text"
+ input.type = visible ? "password" : "text"
+ toggle.textContent = visible ? "显示" : "隐藏"
+})
+
+form.addEventListener("submit", async (event) => {
+ event.preventDefault()
+ submit.disabled = true
+ status.classList.add("hidden")
+ try {
+ const response = await fetch("/api/auth/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ accessKey: input.value }),
+ })
+ const payload = await response.json()
+ if (!response.ok) throw new Error(payload.error || "登录失败")
+ window.location.reload()
+ } catch (error) {
+ status.textContent = error instanceof Error ? error.message : "登录失败"
+ status.classList.remove("hidden")
+ input.focus()
+ input.select()
+ } finally {
+ submit.disabled = false
+ }
+})
diff --git a/tools/channel-key-importer/public/styles.css b/tools/channel-key-importer/public/styles.css
new file mode 100644
index 0000000000..d20b1402c8
--- /dev/null
+++ b/tools/channel-key-importer/public/styles.css
@@ -0,0 +1,2913 @@
+:root {
+ --font-ui: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI",
+ sans-serif;
+ --font-data: Inter, "SF Pro Text", "Segoe UI", Arial, sans-serif;
+ --ink: #172230;
+ --muted: #687587;
+ --paper: #f2f4f7;
+ --surface: #ffffff;
+ --line: #dce1e8;
+ --green: #2563eb;
+ --green-dark: #1d4ed8;
+ --green-soft: #dbeafe;
+ --navy: #10243d;
+ --lime: #60a5fa;
+ --danger: #a94a3e;
+ --danger-soft: #fff0e9;
+ --shadow: 0 1px 2px rgba(15, 35, 60, 0.05);
+ font-family: var(--font-ui);
+ font-variant-numeric: lining-nums tabular-nums;
+ color: var(--ink);
+ background: var(--paper);
+ font-synthesis: none;
+}
+
+* {
+ box-sizing: border-box;
+}
+html {
+ scroll-behavior: smooth;
+}
+body {
+ margin: 0;
+ min-width: 320px;
+ background: var(--paper);
+}
+button,
+input,
+textarea {
+ font: inherit;
+}
+button {
+ cursor: pointer;
+}
+
+.app-shell {
+ display: grid;
+ grid-template-columns: 224px minmax(0, 1fr);
+ min-height: 100vh;
+}
+.sidebar {
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ padding: 24px 18px 20px;
+ color: #eff6ff;
+ background: var(--navy);
+ overflow: hidden;
+}
+.sidebar::before {
+ display: none;
+}
+.brand {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ color: inherit;
+ text-decoration: none;
+ font-size: 17px;
+ font-weight: 750;
+ letter-spacing: -0.02em;
+}
+.brand-mark {
+ display: flex;
+ width: 32px;
+ height: 32px;
+ padding: 7px;
+ flex-direction: column;
+ justify-content: space-between;
+ border-radius: 7px;
+ background: var(--lime);
+}
+.brand-mark span {
+ height: 2px;
+ background: var(--navy);
+ border-radius: 2px;
+}
+.brand-mark span:nth-child(2) {
+ width: 70%;
+}
+.brand-mark span:nth-child(3) {
+ width: 40%;
+}
+.side-nav {
+ position: relative;
+ display: grid;
+ gap: 8px;
+ margin-top: 42px;
+}
+.side-nav a {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 12px 13px;
+ border-radius: 8px;
+ color: #9db1cc;
+ text-decoration: none;
+ font-size: 13px;
+ transition: 0.18s ease;
+}
+.side-nav a:hover,
+.side-nav a.active {
+ color: white;
+ background: #1b3a60;
+}
+.side-nav span {
+ color: var(--lime);
+ font-size: 10px;
+ letter-spacing: 0.08em;
+}
+.sidebar-spacer {
+ flex: 1;
+}
+.local-card {
+ position: relative;
+ display: flex;
+ gap: 11px;
+ align-items: center;
+ padding: 13px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.04);
+}
+.local-card div {
+ display: grid;
+ gap: 3px;
+}
+.local-card strong {
+ font-size: 12px;
+}
+.local-card span:not(.pulse-dot) {
+ color: #91a7c4;
+ font-size: 10px;
+}
+.access-logout {
+ margin-top: 10px;
+ padding: 9px 11px;
+ border: 1px solid rgba(255, 255, 255, 0.14);
+ border-radius: 8px;
+ color: #b8c7da;
+ background: transparent;
+ font-size: 10px;
+ text-align: left;
+}
+.access-logout:hover {
+ color: #ffffff;
+ border-color: rgba(255, 255, 255, 0.3);
+}
+.pulse-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--lime);
+ box-shadow: 0 0 0 5px rgba(201, 243, 107, 0.1);
+}
+.sidebar-note {
+ position: relative;
+ display: flex;
+ gap: 10px;
+ align-items: flex-start;
+ margin-top: 14px;
+ color: #8fa4bf;
+ font-size: 10px;
+ line-height: 1.55;
+}
+.sidebar-note p {
+ margin: 0;
+}
+.shield-icon {
+ color: var(--lime);
+ font-size: 18px;
+ line-height: 1;
+}
+
+main {
+ width: min(1540px, 100%);
+ padding: 26px clamp(24px, 3.4vw, 52px) 32px;
+}
+.topbar {
+ display: flex;
+ justify-content: space-between;
+ gap: 24px;
+ align-items: center;
+ margin-bottom: 22px;
+ padding-bottom: 20px;
+ border-bottom: 1px solid var(--line);
+}
+.eyebrow {
+ margin: 0 0 6px;
+ color: var(--green);
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: 0.03em;
+}
+.kicker {
+ display: none;
+}
+h1 {
+ margin: 0;
+ font-family: var(--font-ui);
+ font-size: clamp(27px, 3vw, 34px);
+ font-weight: 780;
+ letter-spacing: -0.035em;
+ line-height: 1.1;
+}
+.page-description {
+ max-width: 680px;
+ margin: 7px 0 0;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.5;
+}
+.connection-pill {
+ display: flex;
+ gap: 11px;
+ align-items: center;
+ min-width: 190px;
+ padding: 11px 14px;
+ border: 1px solid var(--line);
+ border-radius: 9px;
+ color: var(--ink);
+ text-align: left;
+ background: white;
+}
+.connection-pill > span {
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+ background: #a8b5c7;
+}
+.connection-pill.connected > span {
+ background: var(--green);
+ box-shadow: 0 0 0 5px var(--green-soft);
+}
+.connection-pill div {
+ display: grid;
+ gap: 3px;
+}
+.connection-pill small {
+ color: var(--muted);
+ font-size: 9px;
+}
+.connection-pill strong {
+ max-width: 190px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-size: 11px;
+ white-space: nowrap;
+}
+.intro-grid {
+ display: flex;
+ justify-content: space-between;
+ gap: 24px;
+ align-items: flex-end;
+ margin: 38px 0 26px;
+}
+.manifesto {
+ display: flex;
+ gap: 16px;
+ max-width: 670px;
+}
+.section-number {
+ display: grid;
+ width: 34px;
+ height: 34px;
+ place-items: center;
+ flex: 0 0 auto;
+ border-radius: 50%;
+ color: var(--green-dark);
+ background: var(--green-soft);
+ font-size: 10px;
+ font-weight: 800;
+}
+.manifesto p {
+ margin: 0;
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.6;
+}
+.manifesto p:first-child {
+ color: var(--ink);
+ font-size: 16px;
+ font-weight: 700;
+}
+.trust-row {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+.trust-row span {
+ padding: 7px 9px;
+ border: 1px solid #cbd6e5;
+ border-radius: 999px;
+ color: #64748b;
+ font-size: 8px;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+}
+
+.panel {
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: var(--surface);
+ box-shadow: var(--shadow);
+}
+.connection-panel {
+ padding: 24px;
+}
+.profile-switcher {
+ display: flex;
+ align-items: end;
+ gap: 10px;
+ margin-top: 18px;
+ padding: 13px;
+ border: 1px solid #cbdcf2;
+ border-radius: 12px;
+ background: #f4f8ff;
+}
+.profile-switcher .field {
+ flex: 1;
+}
+.panel-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+}
+.panel-heading h2,
+.preview-title-row h2 {
+ margin: 0;
+ font-family: var(--font-ui);
+ font-size: 20px;
+ font-weight: 760;
+ letter-spacing: -0.02em;
+}
+.panel-heading .kicker,
+.preview-title-row .kicker {
+ margin-bottom: 5px;
+}
+.text-button {
+ padding: 7px 0;
+ border: 0;
+ color: var(--green-dark);
+ background: transparent;
+ font-size: 11px;
+ font-weight: 700;
+}
+.config-form {
+ display: grid;
+ grid-template-columns: minmax(240px, 1.4fr) 0.5fr 1fr auto auto;
+ gap: 14px;
+ align-items: end;
+ margin-top: 20px;
+}
+.login-form {
+ grid-template-areas:
+ "name url"
+ "warning warning"
+ "password-auth password-auth";
+ grid-template-columns: minmax(210px, 0.6fr) minmax(360px, 1.4fr);
+ align-items: start;
+ row-gap: 18px;
+}
+.profile-name-field {
+ grid-area: name;
+}
+.target-url-field {
+ grid-area: url;
+}
+.login-username-field {
+ grid-area: username;
+}
+.login-password-field {
+ grid-area: password;
+}
+.open-login-button {
+ grid-area: open;
+}
+.login-submit-button {
+ grid-area: submit;
+}
+.password-auth {
+ grid-area: password-auth;
+ border-top: 1px solid var(--line);
+ padding-top: 12px;
+}
+.password-auth summary {
+ width: fit-content;
+ cursor: pointer;
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 750;
+}
+.password-auth-grid {
+ display: grid;
+ grid-template-areas: "username password open submit";
+ grid-template-columns:
+ minmax(210px, 0.8fr) minmax(280px, 1.35fr) minmax(150px, auto)
+ minmax(190px, auto);
+ gap: 14px;
+ align-items: end;
+ margin-top: 14px;
+}
+.login-form .open-login-button,
+.login-form .login-submit-button {
+ align-self: end;
+ width: 100%;
+}
+.insecure-http-row {
+ grid-area: warning;
+ width: 100%;
+ margin-top: 0;
+ padding: 9px 11px;
+ border: 1px solid #efc68e;
+ border-radius: 9px;
+ color: #8a5a16;
+ background: #fff8e9;
+}
+.advanced-auth {
+ margin-top: 16px;
+ border-top: 1px solid var(--line);
+ padding-top: 13px;
+}
+.advanced-auth summary {
+ width: fit-content;
+ cursor: pointer;
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 750;
+}
+.token-auth-primary {
+ margin-top: 16px;
+ padding: 15px;
+ border: 1px solid #bfd3ff;
+ border-radius: 12px;
+ background: #f3f7ff;
+}
+.token-auth-heading {
+ display: flex;
+ gap: 8px;
+ align-items: baseline;
+}
+.token-auth-heading strong {
+ font-size: 11px;
+}
+.token-auth-heading span {
+ color: var(--muted);
+ font-size: 9px;
+}
+.token-form {
+ grid-template-columns: minmax(320px, 1fr) auto auto;
+ margin-top: 14px;
+}
+.field {
+ display: grid;
+ gap: 7px;
+ min-width: 0;
+}
+.field > span {
+ font-size: 10px;
+ font-weight: 800;
+ letter-spacing: 0.02em;
+}
+.field small,
+.check-row small {
+ display: block;
+ color: #7b8aa0;
+ font-size: 9px;
+ line-height: 1.4;
+}
+input,
+textarea,
+select {
+ width: 100%;
+ border: 1px solid #d7e0ec;
+ border-radius: 10px;
+ outline: none;
+ color: var(--ink);
+ background: #fbfdff;
+ transition: 0.16s ease;
+}
+input {
+ height: 42px;
+ padding: 0 12px;
+}
+select {
+ min-height: 42px;
+ padding: 0 12px;
+}
+select[multiple] {
+ min-height: 108px;
+ padding: 6px;
+}
+select[multiple] option {
+ padding: 6px 8px;
+ border-radius: 6px;
+}
+.group-options {
+ display: flex;
+ min-height: 54px;
+ flex-wrap: wrap;
+ align-content: flex-start;
+ gap: 8px;
+ padding: 10px;
+ border: 1px solid #d7e0ec;
+ border-radius: 10px;
+ background: #fbfdff;
+}
+.group-option {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ cursor: pointer;
+}
+.group-option input {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ opacity: 0;
+ pointer-events: none;
+}
+.group-option span {
+ display: inline-flex;
+ min-height: 32px;
+ align-items: center;
+ padding: 6px 11px;
+ border: 1px solid #cdd9e8;
+ border-radius: 999px;
+ color: #52647d;
+ background: white;
+ font-size: 10px;
+ font-weight: 750;
+ transition: 0.16s ease;
+}
+.group-option input:checked + span {
+ border-color: var(--green);
+ color: white;
+ background: var(--green);
+ box-shadow: 0 5px 14px rgba(37, 99, 235, 0.2);
+}
+.group-option input:focus-visible + span {
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.18);
+}
+.group-options-empty {
+ align-self: center;
+ color: #7b8aa0;
+ font-size: 10px;
+}
+.group-options-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+.group-options-footer .text-button {
+ flex: 0 0 auto;
+}
+textarea {
+ padding: 12px;
+ resize: vertical;
+ line-height: 1.5;
+}
+textarea.masked {
+ -webkit-text-security: disc;
+}
+input:focus,
+textarea:focus,
+select:focus {
+ border-color: var(--green);
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
+ background: white;
+}
+.secret-input {
+ position: relative;
+}
+.secret-input input,
+.secret-input textarea {
+ padding-right: 57px;
+}
+.secret-input button {
+ position: absolute;
+ right: 7px;
+ top: 7px;
+ height: 28px;
+ padding: 0 8px;
+ border: 0;
+ border-radius: 7px;
+ color: var(--green-dark);
+ background: var(--green-soft);
+ font-size: 9px;
+ font-weight: 800;
+}
+.textarea-secret button {
+ top: 8px;
+}
+.check-row {
+ display: flex;
+ gap: 9px;
+ align-items: center;
+ min-width: 180px;
+ padding: 0 4px 5px;
+}
+.check-row input,
+.duplicate-box input {
+ width: 15px;
+ height: 15px;
+ accent-color: var(--green);
+}
+.check-row strong {
+ display: block;
+ font-size: 10px;
+}
+.primary-button,
+.secondary-button {
+ display: inline-flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 26px;
+ min-height: 48px;
+ padding: 0 17px;
+ border-radius: 11px;
+ font-size: 11px;
+ font-weight: 800;
+ transition: 0.18s ease;
+}
+.primary-button {
+ border: 1px solid var(--green-dark);
+ color: white;
+ background: var(--green-dark);
+ box-shadow: 0 8px 20px rgba(17, 102, 71, 0.18);
+}
+.primary-button:hover {
+ transform: translateY(-1px);
+ background: #1e40af;
+}
+.primary-button:disabled {
+ cursor: not-allowed;
+ transform: none;
+ opacity: 0.46;
+}
+.primary-button i {
+ font-size: 17px;
+ font-style: normal;
+ font-weight: 400;
+}
+.primary-button.compact {
+ min-width: 135px;
+ height: 42px;
+ min-height: 42px;
+}
+.secondary-button {
+ border: 1px solid #d8e1ed;
+ color: var(--ink);
+ background: white;
+ box-shadow: none;
+}
+.secondary-button:hover {
+ border-color: #aebed2;
+}
+.secondary-button.insecure {
+ border-color: var(--danger);
+ color: var(--danger);
+ background: var(--danger-soft);
+}
+
+.import-overview {
+ display: grid;
+ grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
+ min-height: 236px;
+ margin-bottom: 16px;
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: #fff;
+ box-shadow: var(--shadow);
+}
+.import-overview-copy {
+ display: grid;
+ align-content: center;
+ padding: 28px 32px;
+}
+.view-label {
+ width: fit-content;
+ margin-bottom: 8px;
+ color: #315c96;
+ font-size: 10px;
+ font-weight: 800;
+}
+.import-overview h2 {
+ margin: 0;
+ font-size: 25px;
+ font-weight: 780;
+ letter-spacing: -0.035em;
+}
+.import-overview-copy > p {
+ max-width: 640px;
+ margin: 10px 0 18px;
+ color: var(--muted);
+ font-size: 11px;
+ line-height: 1.65;
+}
+.workflow-steps {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+.workflow-steps li {
+ display: grid;
+ grid-template-columns: 24px minmax(0, 1fr);
+ column-gap: 8px;
+ align-items: center;
+ min-width: 0;
+ padding: 10px;
+ border: 1px solid #e1e6ed;
+ border-radius: 8px;
+ background: #f8f9fb;
+}
+.workflow-steps li > span {
+ display: grid;
+ width: 24px;
+ height: 24px;
+ grid-row: 1 / 3;
+ place-items: center;
+ border-radius: 6px;
+ color: #fff;
+ background: #2457a6;
+ font-family: var(--font-data);
+ font-size: 10px;
+ font-weight: 800;
+}
+.workflow-steps strong {
+ overflow: hidden;
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.workflow-steps small {
+ overflow: hidden;
+ color: var(--muted);
+ font-size: 8px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.operations-visual {
+ position: relative;
+ min-height: 236px;
+ margin: 0;
+ overflow: hidden;
+ background: #0f2745;
+}
+.operations-visual img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ object-position: center;
+}
+.operations-visual figcaption {
+ position: absolute;
+ right: 12px;
+ bottom: 12px;
+ padding: 6px 8px;
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ border-radius: 6px;
+ color: #eff5fc;
+ background: rgba(10, 27, 49, 0.82);
+ font-size: 8px;
+ font-weight: 700;
+}
+
+[data-app-view]:not(.hidden) {
+ animation: view-in 140ms ease-out;
+}
+.view-hidden {
+ display: none !important;
+}
+@keyframes view-in {
+ from {
+ opacity: 0;
+ transform: translateY(3px);
+ }
+}
+
+.workspace {
+ display: grid;
+ grid-template-columns: minmax(330px, 0.83fr) minmax(390px, 1.17fr);
+ gap: 18px;
+}
+.source-column,
+.import-column {
+ min-height: 590px;
+ padding: 24px;
+}
+.count-badge,
+.selected-type {
+ padding: 6px 9px;
+ border-radius: 999px;
+ color: var(--green-dark);
+ background: var(--green-soft);
+ font-size: 9px;
+ font-weight: 800;
+}
+.search-box {
+ position: relative;
+ display: block;
+ margin: 20px 0 11px;
+}
+.custom-provider-launch {
+ display: grid;
+ grid-template-columns: 28px minmax(0, 1fr);
+ gap: 2px 10px;
+ width: 100%;
+ margin: 14px 0 0;
+ padding: 11px 12px;
+ border: 1px solid #bcd1ef;
+ border-radius: 11px;
+ color: var(--green-dark);
+ text-align: left;
+ background: #f2f7ff;
+}
+.custom-provider-launch > span {
+ grid-row: 1 / 3;
+ display: grid;
+ place-items: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 8px;
+ color: white;
+ background: var(--green);
+ font-size: 16px;
+}
+.custom-provider-launch strong {
+ font-size: 10px;
+}
+.custom-provider-launch small {
+ color: #7488a4;
+ font-size: 8px;
+}
+.custom-provider-launch:hover {
+ border-color: var(--green);
+ background: #eaf3ff;
+}
+.search-box span {
+ position: absolute;
+ left: 13px;
+ top: 9px;
+ color: var(--green);
+ font-size: 20px;
+}
+.search-box input {
+ padding-left: 38px;
+ background: #f7faff;
+}
+.category-list {
+ display: flex;
+ gap: 6px;
+ margin-bottom: 12px;
+ padding-bottom: 4px;
+ overflow-x: auto;
+ scrollbar-width: none;
+}
+.category-list::-webkit-scrollbar {
+ display: none;
+}
+.category-list button {
+ flex: 0 0 auto;
+ padding: 6px 10px;
+ border: 1px solid #dbe4ef;
+ border-radius: 999px;
+ color: #6b7b91;
+ background: white;
+ font-size: 9px;
+}
+.category-list button.active {
+ border-color: var(--green-dark);
+ color: white;
+ background: var(--green-dark);
+}
+.provider-list {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ max-height: 415px;
+ padding-right: 5px;
+ overflow-y: auto;
+ scrollbar-color: #c9d6e5 transparent;
+ scrollbar-width: thin;
+}
+.provider-card {
+ display: grid;
+ grid-template-columns: 34px minmax(0, 1fr) auto;
+ gap: 10px;
+ align-items: center;
+ min-height: 61px;
+ padding: 10px;
+ border: 1px solid #e2e8f0;
+ border-radius: 11px;
+ text-align: left;
+ background: #fff;
+ transition: 0.15s ease;
+}
+.provider-card:hover {
+ border-color: #93b4e6;
+ transform: translateY(-1px);
+}
+.provider-card.active {
+ border-color: var(--green);
+ background: #eff6ff;
+ box-shadow: inset 3px 0 var(--green);
+}
+.provider-card.disabled {
+ opacity: 0.62;
+}
+.provider-icon,
+.provider-monogram {
+ display: grid;
+ place-items: center;
+ border-radius: 9px;
+ color: var(--green-dark);
+ background: var(--green-soft);
+ font-size: 10px;
+ font-weight: 900;
+ letter-spacing: -0.02em;
+}
+.provider-icon {
+ width: 34px;
+ height: 34px;
+}
+.provider-icon img,
+.provider-monogram img {
+ width: 28px;
+ height: 28px;
+ object-fit: contain;
+}
+.provider-monogram img {
+ width: 32px;
+ height: 32px;
+}
+.provider-card div:not(.provider-icon) {
+ min-width: 0;
+}
+.provider-card strong {
+ display: block;
+ overflow: hidden;
+ color: var(--ink);
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.provider-card small {
+ display: block;
+ margin-top: 4px;
+ overflow: hidden;
+ color: #8291a7;
+ font-size: 8px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.provider-card > span {
+ color: #8b9ab0;
+ font-size: 8px;
+}
+.no-results {
+ grid-column: 1 / -1;
+ padding: 50px 10px;
+ color: var(--muted);
+ text-align: center;
+ font-size: 11px;
+}
+
+.custom-provider-dialog {
+ width: min(760px, calc(100vw - 32px));
+ max-width: none;
+ max-height: calc(100vh - 32px);
+ padding: 0;
+ overflow: hidden;
+ border: 0;
+ border-radius: 18px;
+ background: white;
+ box-shadow: 0 28px 80px rgb(15 35 68 / 28%);
+}
+.custom-provider-dialog::backdrop {
+ background: rgb(8 26 51 / 54%);
+ backdrop-filter: blur(5px);
+}
+.custom-provider-dialog > section {
+ max-height: calc(100vh - 32px);
+ padding: 24px;
+ overflow: auto;
+}
+.custom-provider-dialog-heading {
+ display: flex;
+ justify-content: space-between;
+ gap: 20px;
+ margin-bottom: 18px;
+}
+.custom-provider-dialog-heading h2 {
+ margin: 4px 0 6px;
+ font-size: 24px;
+}
+.custom-provider-dialog-heading p {
+ max-width: 560px;
+ margin: 0;
+ color: var(--muted);
+ font-size: 10px;
+ line-height: 1.6;
+}
+.dialog-close {
+ flex: 0 0 auto;
+ width: 34px;
+ height: 34px;
+ border: 1px solid #d8e2ee;
+ border-radius: 10px;
+ color: #61738d;
+ background: white;
+ font-size: 20px;
+}
+#custom-provider-form {
+ display: grid;
+ grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
+ gap: 12px;
+ padding: 16px;
+ border: 1px solid #dbe6f4;
+ border-radius: 14px;
+ background: #f8fbff;
+}
+.custom-provider-form-actions {
+ grid-column: 1 / -1;
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+#custom-provider-status {
+ grid-column: 1 / -1;
+ margin-top: 0;
+}
+.custom-provider-list-heading {
+ display: flex;
+ justify-content: space-between;
+ margin: 20px 0 9px;
+}
+.custom-provider-list-heading strong {
+ font-size: 11px;
+}
+.custom-provider-list-heading small {
+ color: var(--muted);
+ font-size: 9px;
+}
+.custom-provider-list {
+ display: grid;
+ gap: 8px;
+}
+.custom-provider-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 10px;
+ align-items: center;
+ padding: 11px 12px;
+ border: 1px solid #e1e8f1;
+ border-radius: 11px;
+}
+.custom-provider-row strong,
+.custom-provider-row small {
+ display: block;
+}
+.custom-provider-row strong {
+ color: var(--ink);
+ font-size: 10px;
+}
+.custom-provider-row small {
+ margin-top: 4px;
+ overflow: hidden;
+ color: var(--muted);
+ font-size: 8px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.custom-provider-row-actions {
+ display: flex;
+ gap: 6px;
+}
+.custom-provider-row-actions .danger {
+ color: var(--danger);
+}
+.custom-provider-empty {
+ padding: 24px;
+ border: 1px dashed #cfdcea;
+ border-radius: 11px;
+ color: var(--muted);
+ text-align: center;
+ font-size: 10px;
+}
+body:has(.custom-provider-dialog[open]) {
+ overflow: hidden;
+}
+
+.empty-state {
+ display: grid;
+ justify-items: center;
+ align-content: center;
+ min-height: 450px;
+ color: var(--muted);
+ text-align: center;
+}
+.empty-state h3 {
+ margin: 25px 0 7px;
+ color: var(--ink);
+ font-family: var(--font-ui);
+ font-size: 20px;
+}
+.empty-state p {
+ margin: 0;
+ font-size: 10px;
+}
+.orbit {
+ position: relative;
+ width: 90px;
+ height: 90px;
+ border: 1px solid #cbd8e8;
+ border-radius: 50%;
+}
+.orbit::after {
+ content: "";
+ position: absolute;
+ inset: 15px;
+ border: 1px dashed #b9c9dc;
+ border-radius: 50%;
+}
+.orbit span {
+ position: absolute;
+ width: 34px;
+ height: 34px;
+ left: 27px;
+ top: 27px;
+ z-index: 1;
+ border-radius: 10px;
+ background: var(--green-soft);
+ transform: rotate(45deg);
+}
+.orbit i {
+ position: absolute;
+ width: 9px;
+ height: 9px;
+ right: 5px;
+ top: 21px;
+ border-radius: 50%;
+ background: var(--green);
+ box-shadow: 0 0 0 5px var(--green-soft);
+}
+.credential-form {
+ display: grid;
+ gap: 15px;
+ margin-top: 20px;
+}
+.batch-input-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 2fr) minmax(120px, 0.7fr);
+ gap: 12px;
+ align-items: start;
+}
+.quota-input-panel {
+ display: grid;
+ gap: 10px;
+}
+.quota-mode-control,
+.quota-mode-field {
+ display: grid;
+ gap: 6px;
+}
+.quota-mode-control > span,
+.quota-mode-field > span {
+ color: var(--muted);
+ font-size: 9px;
+ font-weight: 750;
+}
+.quota-mode-field input {
+ width: 100%;
+}
+.routing-options {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+ padding: 14px;
+ border: 1px solid #cbdcf2;
+ border-radius: 14px;
+ background: #f3f7ff;
+}
+.routing-options-heading {
+ grid-column: 1 / -1;
+ display: grid;
+ gap: 3px;
+ padding-bottom: 2px;
+}
+.routing-options-heading strong {
+ color: var(--ink);
+ font-size: 13px;
+}
+.routing-options-heading small {
+ color: var(--muted);
+ font-size: 10px;
+}
+.routing-sequence-row {
+ align-self: stretch;
+ margin: 0;
+}
+.provider-summary {
+ display: grid;
+ grid-template-columns: 48px 1fr auto;
+ gap: 12px;
+ align-items: center;
+ padding: 13px;
+ border-radius: 12px;
+ background: #f2f7ff;
+}
+.provider-monogram {
+ width: 48px;
+ height: 48px;
+ font-size: 14px;
+}
+.provider-summary div:nth-child(2) {
+ display: grid;
+ gap: 4px;
+}
+.provider-summary strong {
+ font-size: 13px;
+}
+.provider-summary span {
+ color: var(--muted);
+ font-size: 9px;
+}
+.provider-summary > span {
+ padding: 6px 8px;
+ border: 1px solid #cedaea;
+ border-radius: 999px;
+ font-size: 8px;
+ font-weight: 800;
+}
+.unsupported-note {
+ padding: 12px 14px;
+ border: 1px solid #f0c8b7;
+ border-radius: 10px;
+ color: #8c4935;
+ background: var(--danger-soft);
+ font-size: 10px;
+ line-height: 1.5;
+}
+.inline-status {
+ margin-top: 14px;
+ padding: 11px 13px;
+ border-radius: 9px;
+ color: var(--green-dark);
+ background: var(--green-soft);
+ font-size: 10px;
+}
+.inline-status.error {
+ color: var(--danger);
+ background: var(--danger-soft);
+}
+.inline-status.recovering {
+ color: #76520a;
+ border: 1px solid #f0cf83;
+ background: #fff8df;
+}
+
+.preview-dialog {
+ width: min(1180px, calc(100vw - 32px));
+ max-width: none;
+ max-height: calc(100vh - 32px);
+ padding: 0;
+ overflow: hidden;
+ border: 0;
+ border-radius: 20px;
+ background: transparent;
+ box-shadow: 0 28px 80px rgb(15 35 68 / 28%);
+}
+.preview-dialog::backdrop {
+ background: rgb(8 26 51 / 54%);
+ backdrop-filter: blur(5px);
+}
+.preview-dialog .preview-panel {
+ max-height: calc(100vh - 32px);
+ margin: 0;
+ padding: 26px;
+ overflow: auto;
+ border: 0;
+}
+body:has(.preview-dialog[open]) {
+ overflow: hidden;
+}
+.preview-title-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 20px;
+ align-items: center;
+}
+.preview-expiry {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ color: var(--muted);
+ font-size: 9px;
+}
+.preview-expiry span {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: #e8aa48;
+}
+.preview-grid {
+ display: grid;
+ grid-template-columns: 0.7fr 1.3fr;
+ gap: 28px;
+ margin-top: 25px;
+ padding-top: 23px;
+ border-top: 1px solid var(--line);
+}
+.preview-facts {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 15px;
+}
+.preview-facts div {
+ min-width: 0;
+ padding: 12px;
+ border-radius: 10px;
+ background: #f5f8fc;
+}
+.preview-facts small {
+ display: block;
+ margin-bottom: 7px;
+ color: var(--muted);
+ font-size: 8px;
+ text-transform: uppercase;
+}
+.preview-facts strong {
+ display: block;
+ overflow-wrap: anywhere;
+ font-size: 10px;
+}
+.preview-facts em {
+ color: var(--green);
+ font-family: var(--font-data);
+ font-size: 24px;
+ font-style: normal;
+}
+.subheading {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 10px;
+}
+.subheading h3 {
+ margin: 0;
+ font-size: 11px;
+}
+.subheading span {
+ color: var(--muted);
+ font-size: 8px;
+}
+.model-chips {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+ max-height: 135px;
+ overflow: auto;
+}
+.model-chips span {
+ padding: 6px 8px;
+ border: 1px solid #d8e2ee;
+ border-radius: 7px;
+ color: #4c5f78;
+ background: white;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 8px;
+}
+.model-workbench {
+ margin-top: 22px;
+ padding: 20px;
+ border: 1px solid #dce5f0;
+ border-radius: 14px;
+ background: #f8faff;
+}
+.batch-summary {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(120px, 1fr));
+ gap: 12px;
+ align-items: center;
+ margin-top: 16px;
+ padding: 13px 15px;
+ border: 1px solid #cbdcf2;
+ border-radius: 11px;
+ background: #f3f7ff;
+}
+.batch-summary div {
+ display: grid;
+ gap: 3px;
+}
+.batch-summary small {
+ color: var(--muted);
+ font-size: 9px;
+}
+.batch-summary strong {
+ font-size: 14px;
+}
+.batch-summary p {
+ grid-column: 1 / -1;
+ margin: 0;
+ color: var(--muted);
+ font-size: 9px;
+ line-height: 1.5;
+}
+.schedule-options {
+ display: grid;
+ grid-template-columns: 1.2fr 0.8fr 0.8fr;
+ gap: 12px;
+ padding: 14px;
+ border: 1px solid #cbdcf2;
+ border-radius: 14px;
+ background: #f3f7ff;
+}
+.schedule-panel {
+ padding: 24px;
+}
+.schedule-list {
+ display: grid;
+ gap: 12px;
+}
+.schedule-empty {
+ padding: 22px;
+ border: 1px dashed #cbdcf2;
+ border-radius: 14px;
+ color: var(--muted);
+ background: #f8fbff;
+ text-align: center;
+}
+.schedule-card {
+ display: grid;
+ gap: 12px;
+ padding: 16px;
+ border: 1px solid #d7e2f1;
+ border-radius: 16px;
+ background: white;
+}
+.schedule-card.recovering {
+ border-color: #e9c76d;
+ background: #fffdf6;
+}
+.schedule-card header {
+ display: flex;
+ justify-content: space-between;
+ gap: 14px;
+ align-items: flex-start;
+}
+.schedule-card h3 {
+ margin: 0;
+ font-size: 15px;
+}
+.schedule-card p {
+ margin: 5px 0 0;
+ color: var(--muted);
+ font-size: 9px;
+ line-height: 1.5;
+}
+.schedule-status {
+ padding: 5px 9px;
+ border-radius: 999px;
+ color: #1d4ed8;
+ background: #dbeafe;
+ font-size: 9px;
+ font-weight: 800;
+ white-space: nowrap;
+}
+.schedule-status.completed {
+ color: #047857;
+ background: #d1fae5;
+}
+.schedule-status.paused,
+.schedule-status.cancelled {
+ color: #9a3412;
+ background: #ffedd5;
+}
+.schedule-status.running {
+ color: #6d28d9;
+ background: #ede9fe;
+}
+.schedule-status.attention {
+ color: #9a3412;
+ background: #ffedd5;
+}
+.schedule-status.recovering {
+ color: #76520a;
+ background: #fff0bd;
+}
+.schedule-metrics {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
+ gap: 10px;
+}
+.schedule-metrics div {
+ padding: 10px;
+ border-radius: 12px;
+ background: #f5f8ff;
+}
+.schedule-metrics small {
+ display: block;
+ color: var(--muted);
+ font-size: 8px;
+}
+.schedule-metrics strong {
+ display: block;
+ margin-top: 4px;
+ font-size: 13px;
+}
+.schedule-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+.schedule-actions .retry-failed:not(:disabled) {
+ border-color: #f0b45b;
+ color: #8a4b08;
+ background: #fff8e8;
+}
+.schedule-editor {
+ display: grid;
+ grid-template-columns: 1.2fr 0.7fr 0.7fr auto;
+ gap: 10px;
+ align-items: end;
+ padding: 14px;
+ border: 1px solid #bfd3ff;
+ border-radius: 14px;
+ background: #f3f7ff;
+}
+.schedule-editor-actions {
+ display: flex;
+ gap: 7px;
+ padding-bottom: 1px;
+}
+.schedule-editor .schedule-save {
+ color: white;
+ border-color: #2457d6;
+ background: #2457d6;
+}
+.schedule-error {
+ color: #b23b2d !important;
+}
+.schedule-recovery-note {
+ margin: 0 !important;
+ padding: 10px 12px;
+ border: 1px solid #efd489;
+ border-radius: 10px;
+ color: #76520a !important;
+ background: #fff8df;
+ font-size: 10px !important;
+}
+.workbench-heading {
+ display: flex;
+ justify-content: space-between;
+ gap: 20px;
+ align-items: flex-start;
+}
+.workbench-heading h3 {
+ margin: 0;
+ font-family: var(--font-ui);
+ font-size: 18px;
+}
+.workbench-heading p {
+ margin: 6px 0 0;
+ color: var(--muted);
+ font-size: 9px;
+ line-height: 1.5;
+}
+.workbench-summary {
+ display: grid;
+ grid-template-columns: auto auto;
+ gap: 1px 7px;
+ align-items: baseline;
+ min-width: 125px;
+ padding: 9px 11px;
+ border-radius: 10px;
+ background: white;
+}
+.workbench-summary strong {
+ color: var(--green-dark);
+ font-family: var(--font-data);
+ font-size: 17px;
+ text-align: right;
+}
+.workbench-summary span {
+ color: var(--muted);
+ font-size: 8px;
+}
+.mapping-toolbar {
+ display: flex;
+ gap: 8px;
+ margin-top: 16px;
+}
+.secondary-button.small {
+ min-height: 34px;
+ padding: 0 11px;
+ border-radius: 8px;
+ font-size: 9px;
+}
+.mapping-list {
+ display: grid;
+ gap: 7px;
+ margin-top: 11px;
+}
+.mapping-empty {
+ padding: 17px;
+ border: 1px dashed #d2dce9;
+ border-radius: 9px;
+ color: #8190a6;
+ text-align: center;
+ font-size: 9px;
+}
+.mapping-row {
+ display: grid;
+ grid-template-columns: minmax(150px, 1fr) 24px minmax(180px, 1.2fr) 30px;
+ gap: 8px;
+ align-items: center;
+}
+.mapping-row input {
+ height: 36px;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 9px;
+ background: white;
+}
+.mapping-row > span {
+ color: var(--green);
+ text-align: center;
+ font-size: 14px;
+}
+.mapping-row button {
+ width: 30px;
+ height: 30px;
+ border: 0;
+ border-radius: 8px;
+ color: #a04e43;
+ background: #fbe9e4;
+ font-size: 14px;
+}
+.manual-models-field {
+ margin-top: 16px;
+}
+.manual-models-field textarea {
+ min-height: 76px;
+ -webkit-text-security: none;
+ background: white;
+}
+.provider-config {
+ display: grid;
+ gap: 12px;
+ padding: 14px;
+ border: 1px solid #cbdcf2;
+ border-radius: 12px;
+ background: #f4f8ff;
+}
+.provider-config-fields {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+}
+.provider-config-fields .field-wide {
+ grid-column: 1 / -1;
+}
+.credential-file-row {
+ display: grid;
+ gap: 7px;
+}
+.credential-file-row input[type="file"] {
+ padding: 8px;
+ -webkit-text-security: none;
+}
+.provider-config textarea:not([data-sensitive="true"]) {
+ -webkit-text-security: none;
+}
+#provider-models {
+ -webkit-text-security: none;
+}
+.duplicate-box {
+ margin-top: 20px;
+ padding: 14px 16px;
+ border: 1px solid #efc68e;
+ border-radius: 11px;
+ background: #fff8e9;
+}
+.duplicate-box > div {
+ display: flex;
+ gap: 10px;
+}
+.duplicate-box > div > span {
+ display: grid;
+ width: 23px;
+ height: 23px;
+ place-items: center;
+ flex: 0 0 auto;
+ border-radius: 50%;
+ color: #8a5a16;
+ background: #f6dca8;
+ font-weight: 900;
+}
+.duplicate-box strong {
+ font-size: 10px;
+}
+.duplicate-box p {
+ margin: 4px 0 0;
+ color: #806a4a;
+ font-size: 9px;
+ line-height: 1.5;
+}
+.duplicate-target-field {
+ display: grid;
+ gap: 6px;
+ margin: 13px 0 0 33px;
+ color: #5f4c32;
+ font-size: 9px;
+ font-weight: 800;
+}
+.duplicate-target-field select {
+ width: 100%;
+ min-height: 34px;
+ padding: 7px 10px;
+ border: 1px solid #dfb978;
+ border-radius: 8px;
+ color: var(--ink);
+ background: white;
+ font: inherit;
+}
+.duplicate-box .duplicate-target-help {
+ margin: 6px 0 0 33px;
+ color: #806a4a;
+}
+.duplicate-confirm-row {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ margin: 12px 0 0 33px;
+ font-size: 9px;
+ font-weight: 700;
+}
+.preview-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 10px;
+ margin-top: 22px;
+}
+.balance-card {
+ margin-top: 18px;
+ padding: 18px;
+ border: 1px solid #cbdcf2;
+ border-radius: 13px;
+ background: #f5f7fa;
+}
+.balance-heading {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ align-items: center;
+}
+.balance-heading h3 {
+ margin: 0;
+ font-family: var(--font-ui);
+ font-size: 18px;
+}
+.balance-metrics {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 9px;
+ margin-top: 14px;
+}
+.balance-metrics div {
+ padding: 13px;
+ border: 1px solid #dbe7f5;
+ border-radius: 10px;
+ background: white;
+}
+.balance-metrics small {
+ display: block;
+ margin-bottom: 6px;
+ color: var(--muted);
+ font-size: 8px;
+}
+.balance-metrics strong {
+ color: var(--green-dark);
+ font-family: var(--font-data);
+ font-size: 21px;
+}
+.balance-card > p {
+ margin: 12px 0 0;
+ color: var(--muted);
+ font-size: 9px;
+ line-height: 1.55;
+}
+.records-panel {
+ padding: 22px 24px;
+}
+.records-note {
+ margin: 12px 0 16px;
+ color: var(--muted);
+ font-size: 10px;
+ line-height: 1.6;
+}
+.usage-monitor-panel {
+ position: relative;
+ overflow: hidden;
+ padding: 28px 30px 30px;
+ border-color: var(--line);
+ background: #fff;
+ box-shadow: var(--shadow);
+}
+.usage-monitor-panel::before {
+ display: none;
+}
+.usage-monitor-heading {
+ align-items: flex-start;
+}
+.usage-monitor-title h2 {
+ margin-top: 7px;
+ font-size: 29px;
+ letter-spacing: -0.025em;
+}
+.usage-monitor-eyebrow {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.usage-live-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 4px 8px;
+ border: 1px solid #c8daf6;
+ border-radius: 999px;
+ color: #3567bd;
+ background: rgba(237, 244, 255, 0.9);
+ font-size: 8px;
+ font-weight: 800;
+}
+.usage-live-badge i {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: #2d6cdf;
+ box-shadow: 0 0 0 4px rgba(45, 108, 223, 0.12);
+}
+.usage-live-badge.syncing i {
+ animation: usage-pulse 1.1s ease-in-out infinite;
+}
+.usage-live-badge.warning {
+ border-color: #f1cf9f;
+ color: #9b5b16;
+ background: #fff7e9;
+}
+.usage-live-badge.warning i {
+ background: #d18429;
+ box-shadow: 0 0 0 4px rgba(209, 132, 41, 0.12);
+}
+@keyframes usage-pulse {
+ 50% {
+ opacity: 0.35;
+ transform: scale(0.75);
+ }
+}
+.usage-refresh-button {
+ border-color: #2559d6;
+ color: white;
+ background: #2559d6;
+ box-shadow: 0 9px 22px rgba(37, 89, 214, 0.2);
+}
+.usage-refresh-button:hover {
+ border-color: #1d4fc8;
+ color: white;
+ background: #1d4fc8;
+}
+.usage-refresh-button > span {
+ display: inline-block;
+ margin-right: 4px;
+ font-size: 13px;
+}
+.usage-monitor-description {
+ max-width: 760px;
+ margin-top: 10px;
+ margin-bottom: 20px;
+ color: #657994;
+ font-size: 11px;
+}
+.usage-monitor-toolbar {
+ display: grid;
+ grid-template-columns:
+ minmax(230px, 1.5fr) minmax(145px, 0.7fr) minmax(145px, 0.7fr)
+ auto;
+ gap: 12px;
+ align-items: end;
+ padding: 16px;
+ border: 1px solid #d8e4f3;
+ border-radius: 15px;
+ background: rgba(244, 248, 253, 0.88);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
+}
+.usage-monitor-toolbar label {
+ display: grid;
+ gap: 7px;
+ min-width: 0;
+}
+.usage-monitor-toolbar label > span {
+ color: var(--muted);
+ font-size: 9px;
+ font-weight: 800;
+ letter-spacing: 0.02em;
+}
+.usage-monitor-toolbar select,
+.usage-monitor-toolbar input {
+ width: 100%;
+ min-height: 42px;
+ padding: 9px 11px;
+ border: 1px solid #c9d8eb;
+ border-radius: 10px;
+ color: var(--ink);
+ background: white;
+ box-shadow: 0 3px 10px rgba(42, 78, 126, 0.04);
+ font-size: 10px;
+ font-weight: 700;
+}
+.usage-monitor-shortcuts {
+ display: flex;
+ gap: 6px;
+ padding-bottom: 1px;
+}
+.usage-monitor-shortcuts button {
+ min-height: 40px;
+ padding: 0 11px;
+ border: 1px solid #cbdcf2;
+ border-radius: 8px;
+ color: #2459b8;
+ background: white;
+ font-size: 9px;
+ font-weight: 800;
+ white-space: nowrap;
+}
+.usage-monitor-shortcuts button:hover {
+ border-color: #78a6f5;
+ background: #edf4ff;
+}
+.usage-monitor-shortcuts button.active {
+ border-color: #2559d6;
+ color: white;
+ background: #2559d6;
+ box-shadow: 0 7px 16px rgba(37, 89, 214, 0.2);
+}
+.usage-monitor-range {
+ display: inline-flex;
+ margin: 11px 2px 14px;
+ padding: 5px 9px;
+ border-radius: 999px;
+ color: #4f6d94;
+ background: #edf4ff;
+ font-size: 9px;
+ font-weight: 700;
+}
+.usage-monitor-kpis {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+ margin-bottom: 14px;
+}
+.usage-monitor-kpis > article {
+ position: relative;
+ overflow: hidden;
+ min-width: 0;
+ min-height: 125px;
+ padding: 18px;
+ border: 1px solid #dce6f2;
+ border-radius: 15px;
+ background: rgba(255, 255, 255, 0.96);
+ box-shadow: 0 8px 22px rgba(37, 67, 105, 0.055);
+}
+.usage-monitor-kpis > article::after {
+ position: absolute;
+ top: 0;
+ right: 0;
+ left: 0;
+ height: 3px;
+ background: #a9c6f5;
+ content: "";
+}
+.usage-monitor-kpis > article.spent {
+ border-color: #b7cff7;
+ background: #f3f6fb;
+}
+.usage-monitor-kpis > article.spent::after {
+ background: #2559d6;
+}
+.usage-monitor-kpis > article.kpi-remaining::after,
+.usage-monitor-kpis > article.kpi-rate::after {
+ background: #4f80dc;
+}
+.usage-monitor-kpis > article.kpi-coverage::after {
+ background: #7ea5e9;
+}
+.usage-monitor-kpis small,
+.usage-monitor-kpis span {
+ display: block;
+ color: var(--muted);
+ font-size: 9px;
+ line-height: 1.55;
+}
+.usage-monitor-kpis small {
+ font-weight: 800;
+ letter-spacing: 0.04em;
+}
+.usage-monitor-kpis > article > strong {
+ display: block;
+ margin: 10px 0 7px;
+ overflow: hidden;
+ color: var(--ink);
+ font-family: var(--font-data);
+ font-size: 27px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.usage-monitor-kpis .spent > strong {
+ color: #2459b8;
+}
+.usage-monitor-kpis .remaining-card {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+}
+.remaining-ring {
+ position: relative;
+ width: 82px;
+ min-width: 82px;
+ height: 82px;
+}
+.remaining-ring svg {
+ width: 100%;
+ height: 100%;
+ transform: rotate(-90deg);
+}
+.remaining-ring circle {
+ fill: none;
+ stroke-width: 5;
+}
+.remaining-ring .ring-track {
+ stroke: #e6edf7;
+}
+.remaining-ring .ring-value {
+ stroke: #2d65db;
+ stroke-linecap: round;
+}
+.remaining-ring.unknown .ring-value {
+ stroke: #b8c4d3;
+}
+.remaining-ring > strong {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ color: #2459b8;
+ font-size: 15px;
+}
+.usage-monitor-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 14px;
+}
+.usage-visual-card {
+ min-width: 0;
+ padding: 20px;
+ border: 1px solid #d9e4f2;
+ border-radius: 16px;
+ background: rgba(255, 255, 255, 0.94);
+ box-shadow: 0 10px 28px rgba(37, 67, 105, 0.06);
+}
+.usage-visual-card > header {
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ gap: 10px;
+ margin-bottom: 16px;
+}
+.usage-visual-card > header small {
+ color: #2d65db;
+ font-size: 8px;
+ font-weight: 900;
+ letter-spacing: 0.14em;
+}
+.usage-visual-card > header h3 {
+ margin: 3px 0 0;
+ font-size: 15px;
+}
+.usage-visual-card > header > span {
+ color: var(--muted);
+ font-size: 8px;
+}
+.site-usage-list,
+.daily-usage-chart {
+ display: grid;
+ gap: 10px;
+ max-height: 430px;
+ overflow: auto;
+}
+.site-usage-item,
+.daily-usage-row {
+ padding: 14px;
+ border: 1px solid #dce6f2;
+ border-radius: 12px;
+ background: white;
+ transition:
+ border-color 160ms ease,
+ box-shadow 160ms ease;
+}
+.site-usage-item:hover,
+.daily-usage-row:hover {
+ border-color: #b9cff0;
+ box-shadow: 0 7px 18px rgba(40, 79, 132, 0.07);
+}
+.site-usage-item > header {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+}
+.site-usage-item > header strong,
+.site-usage-item > header small {
+ display: block;
+}
+.site-usage-item > header strong {
+ font-size: 12px;
+}
+.site-usage-item > header small {
+ max-width: 330px;
+ margin-top: 3px;
+ overflow: hidden;
+ color: var(--muted);
+ font-size: 8px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.site-usage-item > header b {
+ color: #2459b8;
+ font-family: var(--font-data);
+ font-size: 18px;
+}
+.site-usage-item > progress {
+ width: 100%;
+ height: 8px;
+ margin: 12px 0;
+ overflow: hidden;
+ border: 0;
+ border-radius: 999px;
+ background: #e7edf5;
+ appearance: none;
+}
+.site-usage-item > progress::-webkit-progress-bar {
+ border-radius: 999px;
+ background: #e7edf5;
+}
+.site-usage-item > progress::-webkit-progress-value {
+ border-radius: 999px;
+ background: #2d65db;
+}
+.site-usage-item > progress::-moz-progress-bar {
+ border-radius: 999px;
+ background: #2d65db;
+}
+.site-usage-item > progress.unknown::-webkit-progress-value {
+ background: #b8c4d3;
+}
+.site-usage-metrics {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 9px;
+}
+.site-usage-metrics span {
+ min-width: 0;
+}
+.site-usage-metrics small,
+.site-usage-metrics strong {
+ display: block;
+}
+.site-usage-metrics small {
+ color: var(--muted);
+ font-size: 8px;
+}
+.site-usage-metrics strong {
+ margin-top: 3px;
+ overflow: hidden;
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.site-usage-item > p {
+ margin: 10px 0 0;
+ color: var(--muted);
+ font-size: 8px;
+}
+.daily-usage-row {
+ display: grid;
+ grid-template-columns: 86px minmax(0, 1fr);
+ gap: 8px 12px;
+ align-items: center;
+}
+.daily-usage-row > strong {
+ grid-row: 1 / span 2;
+ color: #263b59;
+ font-size: 10px;
+}
+.daily-usage-row > small {
+ grid-column: 2;
+ color: var(--muted);
+ font-size: 8px;
+}
+.daily-usage-bars {
+ display: grid;
+ gap: 7px;
+}
+.daily-usage-bars > div {
+ display: grid;
+ grid-template-columns: 32px minmax(60px, 1fr) 72px;
+ gap: 8px;
+ align-items: center;
+}
+.daily-usage-bars span {
+ color: var(--muted);
+ font-size: 8px;
+}
+.daily-usage-bars b {
+ color: var(--ink);
+ font-size: 9px;
+ text-align: right;
+}
+.daily-usage-bars progress {
+ width: 100%;
+ height: 8px;
+ border: 0;
+ border-radius: 999px;
+ background: #e8eef6;
+ appearance: none;
+}
+.daily-usage-bars progress::-webkit-progress-bar {
+ border-radius: 999px;
+ background: #e8eef6;
+}
+.daily-usage-bars .quota progress::-webkit-progress-value {
+ border-radius: 999px;
+ background: #79a7f8;
+}
+.daily-usage-bars .quota progress::-moz-progress-bar {
+ border-radius: 999px;
+ background: #79a7f8;
+}
+.daily-usage-bars .spent progress::-webkit-progress-value {
+ border-radius: 999px;
+ background: #2559d6;
+}
+.daily-usage-bars .spent progress::-moz-progress-bar {
+ border-radius: 999px;
+ background: #2559d6;
+}
+.usage-monitor-empty {
+ padding: 28px;
+ border: 1px dashed #cbd6e5;
+ border-radius: 11px;
+ color: var(--muted);
+ text-align: center;
+ font-size: 10px;
+}
+.usage-summary {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 10px;
+ margin-bottom: 14px;
+}
+.usage-summary > div {
+ min-width: 0;
+ padding: 14px;
+ border: 1px solid #dbe6f3;
+ border-radius: 11px;
+ background: #f7faff;
+}
+.usage-summary > div.accent {
+ border-color: #b9d2ff;
+ background: #edf4ff;
+}
+.usage-summary small,
+.usage-summary span {
+ display: block;
+}
+.usage-summary small {
+ color: var(--muted);
+ font-size: 8px;
+ font-weight: 800;
+ letter-spacing: 0.04em;
+}
+.usage-summary strong {
+ display: block;
+ margin: 7px 0 5px;
+ overflow: hidden;
+ color: var(--ink);
+ font-family: var(--font-data);
+ font-size: 21px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.usage-summary .accent strong {
+ color: var(--green-dark);
+}
+.usage-summary span,
+.usage-summary-note {
+ color: var(--muted);
+ font-size: 8px;
+ line-height: 1.5;
+}
+.records-toolbar {
+ display: grid;
+ grid-template-columns:
+ minmax(125px, 0.8fr) minmax(125px, 0.8fr) minmax(135px, 0.9fr)
+ minmax(210px, 1.5fr) auto;
+ gap: 9px;
+ align-items: end;
+ margin-bottom: 8px;
+ padding: 12px;
+ border: 1px solid #dbe5f1;
+ border-radius: 11px;
+ background: white;
+}
+.records-toolbar label {
+ display: grid;
+ gap: 5px;
+ min-width: 0;
+}
+.records-toolbar label > span {
+ color: var(--muted);
+ font-size: 8px;
+ font-weight: 800;
+}
+.records-toolbar select,
+.records-toolbar input {
+ width: 100%;
+ min-height: 34px;
+ padding: 7px 9px;
+ border: 1px solid #cfdae8;
+ border-radius: 8px;
+ color: var(--ink);
+ background: #f9fbfe;
+ font-size: 9px;
+}
+.records-toolbar .table-action {
+ min-height: 34px;
+ padding-inline: 10px;
+}
+.usage-summary-note {
+ min-height: 14px;
+ margin: 0 0 10px;
+}
+.records-empty {
+ padding: 28px;
+ border: 1px dashed #cbd6e5;
+ border-radius: 11px;
+ color: var(--muted);
+ text-align: center;
+ font-size: 10px;
+}
+.records-table-wrap {
+ overflow: visible;
+}
+.record-batch-list {
+ display: grid;
+ gap: 13px;
+}
+.record-batch-card {
+ overflow: hidden;
+ border: 1px solid #d5e1f0;
+ border-radius: 14px;
+ background: white;
+ box-shadow: 0 8px 24px rgba(35, 72, 120, 0.06);
+}
+.record-batch-card > header {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ align-items: flex-start;
+ padding: 15px 16px 12px;
+ border-bottom: 1px solid #e3ebf5;
+ background: #f7f9fb;
+}
+.record-batch-identity {
+ display: grid;
+ gap: 3px;
+ min-width: 0;
+}
+.record-batch-identity > strong {
+ color: var(--ink);
+ font-family: var(--font-data);
+ font-size: 15px;
+ font-weight: 800;
+ letter-spacing: 0;
+}
+.record-batch-identity > small {
+ color: #42536d;
+ font-size: 9px;
+ font-weight: 750;
+}
+.record-batch-identity > span {
+ overflow: hidden;
+ color: var(--muted);
+ font-size: 8px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.record-batch-count {
+ flex: 0 0 auto;
+ padding: 7px 10px;
+ border-radius: 999px;
+ color: #174aa8;
+ background: #e8f0ff;
+ font-family: var(--font-data);
+ font-size: 10px;
+ font-weight: 850;
+}
+.record-batch-metrics {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 8px;
+ padding: 12px 16px;
+}
+.record-batch-metrics > div {
+ min-width: 0;
+ padding: 10px 11px;
+ border: 1px solid #e0e8f2;
+ border-radius: 10px;
+ background: #f8faff;
+}
+.record-batch-metrics small,
+.record-batch-metrics span {
+ display: block;
+}
+.record-batch-metrics small {
+ color: var(--muted);
+ font-size: 8px;
+ font-weight: 800;
+}
+.record-batch-metrics strong {
+ display: block;
+ margin: 5px 0 3px;
+ overflow: hidden;
+ color: var(--ink);
+ font-family: var(--font-data);
+ font-size: 17px;
+ font-weight: 800;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.record-batch-metrics span {
+ overflow: hidden;
+ color: var(--muted);
+ font-size: 8px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.record-batch-details {
+ border-top: 1px solid #e5ecf4;
+}
+.record-batch-details > summary {
+ padding: 10px 16px;
+ color: #2755a8;
+ background: #fbfcff;
+ font-size: 9px;
+ font-weight: 800;
+ cursor: pointer;
+ list-style-position: inside;
+}
+.record-batch-details[open] > summary {
+ border-bottom: 1px solid #e5ecf4;
+}
+.record-sequence-cell {
+ width: 62px;
+ color: var(--muted);
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+.record-detail-table-wrap {
+ overflow-x: auto;
+}
+.records-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 9px;
+}
+.records-table th,
+.records-table td {
+ padding: 10px 8px;
+ border-bottom: 1px solid var(--line);
+ text-align: left;
+ vertical-align: top;
+}
+.records-table th {
+ color: var(--muted);
+ font-weight: 800;
+ white-space: nowrap;
+}
+.records-table td strong,
+.records-table td small {
+ display: block;
+}
+.records-table td small {
+ margin-top: 3px;
+ color: var(--muted);
+}
+.records-table .channel-cell {
+ min-width: 200px;
+ max-width: 300px;
+ overflow-wrap: anywhere;
+}
+.record-detail-table th:nth-child(1) {
+ min-width: 145px;
+}
+.record-detail-table th:nth-child(3),
+.record-detail-table th:nth-child(4) {
+ min-width: 145px;
+}
+.record-detail-cost {
+ color: #1d4ed8 !important;
+ font-family: var(--font-data);
+ font-weight: 750;
+}
+.usage-status {
+ display: inline-flex;
+ width: max-content;
+ margin-bottom: 5px;
+ padding: 3px 6px;
+ border-radius: 999px;
+ color: #2459b8;
+ background: #e8f0ff;
+ font-size: 7px;
+ font-weight: 900;
+}
+.usage-status.idle {
+ color: #65748a;
+ background: #eef2f6;
+}
+.usage-status.warning {
+ color: #9b5b16;
+ background: #fff1d9;
+}
+.quota-progress {
+ width: 92px;
+ height: 4px;
+ margin-top: 6px;
+ overflow: hidden;
+ border-radius: 999px;
+ background: #e7edf5;
+}
+.quota-progress span {
+ display: block;
+ height: 100%;
+ border-radius: inherit;
+ background: #2d65db;
+}
+.quota-progress.over span {
+ background: #c45b49;
+}
+.usage-cost {
+ color: var(--green-dark);
+ font-family: var(--font-data);
+ font-size: 13px;
+}
+.table-action {
+ padding: 5px 7px;
+ border: 1px solid #cbdcf2;
+ border-radius: 7px;
+ color: var(--green-dark);
+ background: white;
+ font-size: 8px;
+ font-weight: 800;
+ white-space: nowrap;
+}
+.table-action:disabled {
+ color: #94a3b8;
+ cursor: not-allowed;
+}
+footer {
+ display: flex;
+ justify-content: space-between;
+ gap: 20px;
+ margin-top: 26px;
+ padding: 17px 2px 0;
+ border-top: 1px solid #d8e2ee;
+ color: #8492a6;
+ font-size: 8px;
+ letter-spacing: 0.08em;
+}
+footer p {
+ margin: 0;
+ letter-spacing: 0;
+}
+
+.toast {
+ position: fixed;
+ right: 25px;
+ bottom: 25px;
+ z-index: 10;
+ max-width: 360px;
+ padding: 13px 16px;
+ border-radius: 11px;
+ color: white;
+ background: var(--navy);
+ box-shadow: 0 18px 50px rgba(0, 0, 0, 0.22);
+ font-size: 11px;
+}
+.toast.error {
+ background: #873f35;
+}
+.hidden {
+ display: none !important;
+}
+.loading {
+ position: relative;
+ pointer-events: none;
+ color: transparent !important;
+}
+.loading::after {
+ content: "";
+ position: absolute;
+ width: 15px;
+ height: 15px;
+ left: calc(50% - 8px);
+ top: calc(50% - 8px);
+ border: 2px solid rgba(255, 255, 255, 0.35);
+ border-top-color: white;
+ border-radius: 50%;
+ animation: spin 0.7s linear infinite;
+}
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (max-width: 1100px) {
+ .app-shell {
+ grid-template-columns: 210px minmax(0, 1fr);
+ }
+ .sidebar {
+ padding-left: 18px;
+ padding-right: 18px;
+ }
+ .config-form {
+ grid-template-columns: 1fr 1fr;
+ }
+ .login-form {
+ grid-template-areas:
+ "name url"
+ "warning warning"
+ "password-auth password-auth";
+ }
+ .password-auth-grid {
+ grid-template-areas:
+ "username password"
+ "open submit";
+ grid-template-columns: 1fr 1fr;
+ }
+ .batch-summary {
+ grid-template-columns: 1fr 1fr;
+ }
+ .batch-summary p {
+ grid-column: 1 / -1;
+ }
+ .field-wide {
+ grid-column: 1 / -1;
+ }
+ .login-form .target-url-field {
+ grid-column: auto;
+ }
+ .workspace {
+ grid-template-columns: 1fr;
+ }
+ .import-overview {
+ grid-template-columns: 1fr;
+ }
+ .operations-visual {
+ min-height: 190px;
+ max-height: 250px;
+ }
+ .source-column,
+ .import-column {
+ min-height: auto;
+ }
+ .provider-list {
+ max-height: 360px;
+ }
+ .usage-monitor-toolbar,
+ .usage-monitor-kpis {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .usage-monitor-shortcuts {
+ grid-column: 1 / -1;
+ }
+ .usage-monitor-grid {
+ grid-template-columns: 1fr;
+ }
+ .records-toolbar {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .record-batch-metrics {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .records-search-field {
+ grid-column: 1 / -1;
+ }
+}
+@media (max-width: 760px) {
+ .batch-input-grid,
+ .routing-options,
+ .schedule-editor {
+ grid-template-columns: 1fr;
+ }
+ .app-shell {
+ display: block;
+ }
+ .sidebar {
+ position: sticky;
+ top: 0;
+ z-index: 20;
+ width: 100%;
+ height: auto;
+ padding: 10px 14px;
+ flex-direction: row;
+ align-items: center;
+ gap: 14px;
+ overflow-x: hidden;
+ }
+ .sidebar-note,
+ .sidebar-spacer,
+ .local-card,
+ .access-logout {
+ display: none;
+ }
+ .brand {
+ flex: 0 0 auto;
+ }
+ .brand > span:last-child {
+ display: none;
+ }
+ .brand-mark {
+ width: 30px;
+ height: 30px;
+ }
+ .side-nav {
+ display: flex;
+ flex: 1 1 auto;
+ gap: 4px;
+ margin: 0;
+ min-width: 0;
+ overflow-x: auto;
+ scrollbar-width: none;
+ }
+ .side-nav::-webkit-scrollbar {
+ display: none;
+ }
+ .side-nav a {
+ flex: 0 0 auto;
+ gap: 0;
+ padding: 9px 11px;
+ white-space: nowrap;
+ }
+ .side-nav a span {
+ display: none;
+ }
+ main {
+ padding: 28px 16px;
+ }
+ .topbar {
+ display: block;
+ }
+ h1 {
+ font-size: 28px;
+ }
+ .connection-pill {
+ margin-top: 22px;
+ }
+ .intro-grid,
+ .preview-title-row {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ .import-overview-copy {
+ padding: 22px;
+ }
+ .workflow-steps {
+ grid-template-columns: 1fr;
+ }
+ .operations-visual {
+ min-height: 170px;
+ }
+ .trust-row {
+ justify-content: flex-start;
+ }
+ .config-form {
+ grid-template-columns: 1fr;
+ }
+ .login-form {
+ grid-template-areas:
+ "name"
+ "url"
+ "warning"
+ "password-auth";
+ }
+ .password-auth-grid {
+ grid-template-areas:
+ "username"
+ "password"
+ "open"
+ "submit";
+ grid-template-columns: 1fr;
+ }
+ .profile-switcher {
+ align-items: stretch;
+ flex-direction: column;
+ }
+ .batch-summary {
+ grid-template-columns: 1fr;
+ }
+ .batch-summary p {
+ grid-column: auto;
+ }
+ .field-wide {
+ grid-column: auto;
+ }
+ .provider-list {
+ grid-template-columns: 1fr;
+ }
+ .provider-config-fields {
+ grid-template-columns: 1fr;
+ }
+ #custom-provider-form {
+ grid-template-columns: 1fr;
+ }
+ .provider-config-fields .field-wide {
+ grid-column: auto;
+ }
+ .preview-grid {
+ grid-template-columns: 1fr;
+ }
+ .workbench-heading {
+ flex-direction: column;
+ }
+ .mapping-row {
+ grid-template-columns: 1fr 22px 1fr 30px;
+ }
+ .balance-metrics {
+ grid-template-columns: 1fr;
+ }
+ .usage-monitor-heading {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ .usage-monitor-toolbar,
+ .usage-monitor-kpis {
+ grid-template-columns: 1fr;
+ }
+ .usage-monitor-shortcuts {
+ grid-column: auto;
+ flex-wrap: wrap;
+ }
+ .site-usage-metrics {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .daily-usage-row {
+ grid-template-columns: 1fr;
+ }
+ .daily-usage-row > strong,
+ .daily-usage-row > small {
+ grid-column: auto;
+ grid-row: auto;
+ }
+ .usage-summary,
+ .records-toolbar {
+ grid-template-columns: 1fr;
+ }
+ .records-search-field {
+ grid-column: auto;
+ }
+ .record-batch-card > header {
+ align-items: stretch;
+ flex-direction: column;
+ }
+ .record-batch-count {
+ width: max-content;
+ }
+ .record-batch-metrics {
+ grid-template-columns: 1fr;
+ }
+ footer {
+ flex-direction: column;
+ }
+}
diff --git a/tools/channel-key-importer/public/usageStats.js b/tools/channel-key-importer/public/usageStats.js
new file mode 100644
index 0000000000..5ff644c351
--- /dev/null
+++ b/tools/channel-key-importer/public/usageStats.js
@@ -0,0 +1,252 @@
+const finiteNumber = (value) => {
+ if (value == null || value === "") return null
+ return Number.isFinite(Number(value)) ? Number(value) : null
+}
+
+export function gatewaySpent(record) {
+ const stored = finiteNumber(record?.gatewaySpent)
+ if (stored != null) return stored
+ if (record?.usageStatus === "gateway-usage") {
+ return finiteNumber(record?.spent)
+ }
+ return null
+}
+
+export function usageState(record) {
+ if (record?.usageTruncated === true) return "incomplete"
+ const spent = gatewaySpent(record)
+ if (spent != null) {
+ return spent > 0 || Number(record?.requestCount) > 0 ? "used" : "unused"
+ }
+ return "pending"
+}
+
+export function filterUsageRecords(records, filters = {}) {
+ const query = String(filters.query || "")
+ .trim()
+ .toLocaleLowerCase("zh-CN")
+ return records.filter((record) => {
+ if (filters.target && record.targetName !== filters.target) return false
+ if (filters.provider && record.providerName !== filters.provider)
+ return false
+ if (filters.status && usageState(record) !== filters.status) return false
+ if (!query) return true
+ return [
+ record.targetName,
+ record.targetUrl,
+ record.providerName,
+ record.channelName,
+ record.channelId,
+ record.keyHint,
+ record.keyFingerprint,
+ ]
+ .filter(Boolean)
+ .some((value) => String(value).toLocaleLowerCase("zh-CN").includes(query))
+ })
+}
+
+export function summarizeUsageRecords(records) {
+ const summary = {
+ recordCount: records.length,
+ quotaTotal: 0,
+ knownQuotaCount: 0,
+ unknownQuotaCount: 0,
+ trackedCount: 0,
+ trackedQuotaTotal: 0,
+ trackedRemainingTotal: 0,
+ gatewaySpentTotal: 0,
+ requestCount: 0,
+ promptTokens: 0,
+ completionTokens: 0,
+ usedKeyCount: 0,
+ unusedKeyCount: 0,
+ pendingCount: 0,
+ incompleteCount: 0,
+ detailIncompleteCount: 0,
+ lastCheckedAt: null,
+ }
+
+ for (const record of records) {
+ const quota = finiteNumber(record.quota)
+ const spent = gatewaySpent(record)
+ const status = usageState(record)
+ if (quota == null) {
+ summary.unknownQuotaCount += 1
+ } else {
+ summary.knownQuotaCount += 1
+ summary.quotaTotal += quota
+ }
+ if (spent != null) {
+ summary.trackedCount += 1
+ summary.gatewaySpentTotal += spent
+ if (quota != null) {
+ summary.trackedQuotaTotal += quota
+ summary.trackedRemainingTotal += Math.max(0, quota - spent)
+ }
+ }
+ summary.requestCount += Number(record.requestCount) || 0
+ summary.promptTokens += Number(record.promptTokens) || 0
+ summary.completionTokens += Number(record.completionTokens) || 0
+ if (status === "used") summary.usedKeyCount += 1
+ if (status === "unused") summary.unusedKeyCount += 1
+ if (status === "pending") summary.pendingCount += 1
+ if (status === "incomplete") summary.incompleteCount += 1
+ if (record?.usageDetailsComplete === false) {
+ summary.detailIncompleteCount += 1
+ }
+ const checkedAt = Date.parse(record.checkedAt || "")
+ if (
+ Number.isFinite(checkedAt) &&
+ (!summary.lastCheckedAt || checkedAt > summary.lastCheckedAt)
+ ) {
+ summary.lastCheckedAt = checkedAt
+ }
+ }
+
+ return summary
+}
+
+const LEGACY_BATCH_GAP_MS = 45_000
+
+const recordTime = (record) => {
+ const timestamp = Date.parse(record?.importedAt || "")
+ return Number.isFinite(timestamp) ? timestamp : 0
+}
+
+const sameLegacyBatch = (group, record, timestamp) =>
+ group.legacy === true &&
+ group.targetUrl === (record.targetUrl || "") &&
+ group.providerName === (record.providerName || "") &&
+ group.operation === (record.operation || "") &&
+ Math.abs(group.lastRecordAt - timestamp) <= LEGACY_BATCH_GAP_MS
+
+export function groupImportRecords(records) {
+ const groups = []
+ const batches = new Map()
+ const sorted = [...records].sort(
+ (left, right) => recordTime(right) - recordTime(left),
+ )
+
+ for (const record of sorted) {
+ const timestamp = recordTime(record)
+ const batchId = String(record.importBatchId || "")
+ let group = batchId ? batches.get(batchId) : null
+ if (!group && !batchId) {
+ group = groups.find((candidate) =>
+ sameLegacyBatch(candidate, record, timestamp),
+ )
+ }
+ if (!group) {
+ group = {
+ id: batchId || `legacy:${record.id || groups.length}`,
+ legacy: !batchId,
+ targetName: record.targetName || "New API",
+ targetUrl: record.targetUrl || "",
+ providerName: record.providerName || "未知来源",
+ operation: record.operation || "",
+ startedAt: timestamp,
+ completedAt: timestamp,
+ lastRecordAt: timestamp,
+ records: [],
+ }
+ groups.push(group)
+ if (batchId) batches.set(batchId, group)
+ }
+ group.records.push(record)
+ group.startedAt = Math.min(group.startedAt || timestamp, timestamp)
+ group.completedAt = Math.max(group.completedAt || timestamp, timestamp)
+ group.lastRecordAt = timestamp
+ }
+
+ return groups
+ .map((group) => ({
+ ...group,
+ records: [...group.records].sort((left, right) => {
+ if (
+ Number.isInteger(left.batchItemIndex) &&
+ Number.isInteger(right.batchItemIndex)
+ ) {
+ return left.batchItemIndex - right.batchItemIndex
+ }
+ return recordTime(left) - recordTime(right)
+ }),
+ summary: summarizeUsageRecords(group.records),
+ }))
+ .sort((left, right) => right.completedAt - left.completedAt)
+}
+
+export function localDateKey(value) {
+ const date = new Date(value)
+ if (!Number.isFinite(date.getTime())) return ""
+ const year = date.getFullYear()
+ const month = String(date.getMonth() + 1).padStart(2, "0")
+ const day = String(date.getDate()).padStart(2, "0")
+ return `${year}-${month}-${day}`
+}
+
+export function filterUsageDashboardRecords(records, filters = {}) {
+ return records.filter((record) => {
+ if (filters.targetUrl && record.targetUrl !== filters.targetUrl)
+ return false
+ const importedDate = localDateKey(record.importedAt)
+ if (!importedDate) return false
+ if (filters.startDate && importedDate < filters.startDate) return false
+ if (filters.endDate && importedDate > filters.endDate) return false
+ return true
+ })
+}
+
+export function summarizeUsageDashboard(records) {
+ const summary = summarizeUsageRecords(records)
+ return {
+ ...summary,
+ remainingPercent:
+ summary.trackedQuotaTotal > 0
+ ? (summary.trackedRemainingTotal / summary.trackedQuotaTotal) * 100
+ : null,
+ coveragePercent:
+ summary.recordCount > 0
+ ? (summary.trackedCount / summary.recordCount) * 100
+ : 0,
+ }
+}
+
+export function groupUsageDashboardByTarget(records) {
+ const groups = new Map()
+ for (const record of records) {
+ const key = record.targetUrl || "unknown"
+ const group = groups.get(key) || {
+ targetUrl: record.targetUrl || "未记录地址",
+ targetName: record.targetName || "New API",
+ records: [],
+ }
+ group.records.push(record)
+ groups.set(key, group)
+ }
+ return [...groups.values()]
+ .map((group) => ({
+ targetUrl: group.targetUrl,
+ targetName: group.targetName,
+ summary: summarizeUsageDashboard(group.records),
+ }))
+ .sort((left, right) =>
+ left.targetName.localeCompare(right.targetName, "zh-CN"),
+ )
+}
+
+export function groupUsageDashboardByDay(records) {
+ const groups = new Map()
+ for (const record of records) {
+ const date = localDateKey(record.importedAt)
+ if (!date) continue
+ const group = groups.get(date) || []
+ group.push(record)
+ groups.set(date, group)
+ }
+ return [...groups.entries()]
+ .map(([date, dayRecords]) => ({
+ date,
+ summary: summarizeUsageDashboard(dayRecords),
+ }))
+ .sort((left, right) => left.date.localeCompare(right.date))
+}
diff --git a/tools/channel-key-importer/public/viewState.js b/tools/channel-key-importer/public/viewState.js
new file mode 100644
index 0000000000..076571f02a
--- /dev/null
+++ b/tools/channel-key-importer/public/viewState.js
@@ -0,0 +1,42 @@
+export const APP_VIEWS = Object.freeze({
+ import: {
+ eyebrow: "渠道操作",
+ title: "导入渠道",
+ description: "选择供应商,录入 Key,然后写入当前 New API。",
+ },
+ tasks: {
+ eyebrow: "自动执行",
+ title: "定时任务",
+ description: "查看待执行批次,调整时间、数量和执行间隔。",
+ },
+ usage: {
+ eyebrow: "额度与消耗",
+ title: "用量监测",
+ description: "按站点和录入日期查看额度、消耗与剩余比例。",
+ },
+ records: {
+ eyebrow: "写入历史",
+ title: "上 Key 记录",
+ description: "按批次查看什么时候写入、写入多少以及后续消耗。",
+ },
+ sites: {
+ eyebrow: "连接管理",
+ title: "站点设置",
+ description: "添加和切换 New API,管理系统访问令牌。",
+ },
+})
+
+const LEGACY_VIEW_HASHES = Object.freeze({
+ importer: "import",
+ connection: "sites",
+ schedules: "tasks",
+ "usage-monitor": "usage",
+})
+
+export function normalizeAppView(value) {
+ const key = String(value || "")
+ .trim()
+ .replace(/^#/, "")
+ const normalized = LEGACY_VIEW_HASHES[key] || key
+ return Object.hasOwn(APP_VIEWS, normalized) ? normalized : "import"
+}
diff --git a/tools/channel-key-importer/server.env.example b/tools/channel-key-importer/server.env.example
new file mode 100644
index 0000000000..8e8bd7759f
--- /dev/null
+++ b/tools/channel-key-importer/server.env.example
@@ -0,0 +1,26 @@
+CHANNEL_IMPORTER_NO_OPEN=1
+CHANNEL_IMPORTER_PORT=4179
+DATAEYESAI_PUBLIC_PORT=4179
+DATAEYESAI_HOST=0.0.0.0
+DATAEYESAI_STORAGE=sqlite
+DATAEYESAI_DATA_DIR=/data
+DATAEYESAI_DATABASE_PATH=/data/dataeyesai.sqlite
+
+# When New API omits Retry-After, wait this many seconds before auto-resume.
+DATAEYESAI_RATE_LIMIT_FALLBACK_SECONDS=180
+
+# Exact Host header accepted by the app. Include the external port.
+DATAEYESAI_PUBLIC_HOSTS=your-server.example:4179
+# Exact browser Origin accepted for write requests.
+DATAEYESAI_PUBLIC_ORIGINS=http://your-server.example:4179
+
+# Generate with: node src/generateAccessKey.js
+# Store only the hash here. Keep the one-time accessKey separately.
+DATAEYESAI_ACCESS_KEY_HASH=scrypt:v1:replace-with-generated-hash
+
+# Generate with: openssl rand -base64 32
+# This encrypts remembered New API system tokens in SQLite.
+DATAEYESAI_MASTER_KEY=replace-with-32-byte-base64-key
+
+# Set to 1 only when the external URL uses HTTPS.
+DATAEYESAI_SECURE_COOKIE=0
diff --git a/tools/channel-key-importer/src/accessAuth.js b/tools/channel-key-importer/src/accessAuth.js
new file mode 100644
index 0000000000..38893b863c
--- /dev/null
+++ b/tools/channel-key-importer/src/accessAuth.js
@@ -0,0 +1,120 @@
+import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto"
+
+const HASH_PREFIX = "scrypt:v1"
+const COOKIE_NAME = "dataeyesai_access"
+const DEFAULT_SESSION_MS = 12 * 60 * 60 * 1000
+const ATTEMPT_WINDOW_MS = 15 * 60 * 1000
+const MAX_ATTEMPTS = 5
+
+export function hashAccessKey(accessKey, salt = randomBytes(16)) {
+ const normalized = String(accessKey || "")
+ if (normalized.length < 12) {
+ throw new Error("系统访问密钥至少需要 12 个字符")
+ }
+ const derived = scryptSync(normalized, salt, 32)
+ return `${HASH_PREFIX}:${salt.toString("base64")}:${derived.toString("base64")}`
+}
+
+export function verifyAccessKey(accessKey, encodedHash) {
+ const parts = String(encodedHash || "").split(":")
+ if (parts.length !== 4 || `${parts[0]}:${parts[1]}` !== HASH_PREFIX) {
+ return false
+ }
+ try {
+ const salt = Buffer.from(parts[2], "base64")
+ const expected = Buffer.from(parts[3], "base64")
+ const actual = scryptSync(String(accessKey || ""), salt, expected.length)
+ return expected.length > 0 && timingSafeEqual(expected, actual)
+ } catch {
+ return false
+ }
+}
+
+function parseCookies(header) {
+ return Object.fromEntries(
+ String(header || "")
+ .split(";")
+ .map((part) => part.trim().split("="))
+ .filter(([name, value]) => name && value)
+ .map(([name, ...value]) => [name, value.join("=")]),
+ )
+}
+
+export class AccessSessionManager {
+ #sessions = new Map()
+ #attempts = new Map()
+
+ constructor({
+ accessKeyHash = process.env.DATAEYESAI_ACCESS_KEY_HASH || "",
+ secureCookie = process.env.DATAEYESAI_SECURE_COOKIE === "1",
+ sessionMs = DEFAULT_SESSION_MS,
+ now = () => Date.now(),
+ } = {}) {
+ this.accessKeyHash = String(accessKeyHash).trim()
+ this.secureCookie = secureCookie
+ this.sessionMs = sessionMs
+ this.now = now
+ }
+
+ get enabled() {
+ return Boolean(this.accessKeyHash)
+ }
+
+ #cookie(token, maxAgeSeconds) {
+ return [
+ `${COOKIE_NAME}=${token}`,
+ "Path=/",
+ "HttpOnly",
+ "SameSite=Strict",
+ this.secureCookie ? "Secure" : "",
+ `Max-Age=${maxAgeSeconds}`,
+ ]
+ .filter(Boolean)
+ .join("; ")
+ }
+
+ login(address, accessKey) {
+ if (!this.enabled) return { token: "", cookie: "" }
+ const now = this.now()
+ const key = String(address || "unknown")
+ const attempt = this.#attempts.get(key)
+ if (attempt?.blockedUntil > now) {
+ throw new Error("尝试次数过多,请 15 分钟后再试")
+ }
+ if (!verifyAccessKey(accessKey, this.accessKeyHash)) {
+ const withinWindow = attempt && now - attempt.firstAt < ATTEMPT_WINDOW_MS
+ const next = withinWindow
+ ? { ...attempt, count: attempt.count + 1 }
+ : { count: 1, firstAt: now, blockedUntil: 0 }
+ if (next.count >= MAX_ATTEMPTS) {
+ next.blockedUntil = now + ATTEMPT_WINDOW_MS
+ }
+ this.#attempts.set(key, next)
+ throw new Error("系统访问密钥不正确")
+ }
+ this.#attempts.delete(key)
+ const token = randomBytes(32).toString("base64url")
+ this.#sessions.set(token, now + this.sessionMs)
+ return {
+ token,
+ cookie: this.#cookie(token, Math.floor(this.sessionMs / 1000)),
+ }
+ }
+
+ authenticate(cookieHeader) {
+ if (!this.enabled) return true
+ const token = parseCookies(cookieHeader)[COOKIE_NAME]
+ const expiresAt = token ? this.#sessions.get(token) : 0
+ if (!expiresAt || expiresAt <= this.now()) {
+ if (token) this.#sessions.delete(token)
+ return false
+ }
+ return true
+ }
+
+ logout(cookieHeader) {
+ const token = parseCookies(cookieHeader)[COOKIE_NAME]
+ if (token) this.#sessions.delete(token)
+ return this.#cookie("", 0)
+ }
+}
diff --git a/tools/channel-key-importer/src/balanceStore.js b/tools/channel-key-importer/src/balanceStore.js
new file mode 100644
index 0000000000..ee0a833881
--- /dev/null
+++ b/tools/channel-key-importer/src/balanceStore.js
@@ -0,0 +1,59 @@
+import { join } from "node:path"
+
+import { DATA_DIR } from "./dataPath.js"
+import { createJsonStateStore } from "./sharedStorage.js"
+
+const BALANCE_PATH = join(DATA_DIR, "balances.json")
+
+export function calculateBalanceUsage(initialBalance, currentBalance) {
+ if (!Number.isFinite(initialBalance) || !Number.isFinite(currentBalance)) {
+ return { spentSinceImport: null, balanceIncreased: false }
+ }
+ if (currentBalance > initialBalance) {
+ return { spentSinceImport: null, balanceIncreased: true }
+ }
+ return {
+ spentSinceImport: Number((initialBalance - currentBalance).toFixed(8)),
+ balanceIncreased: false,
+ }
+}
+
+const entryKey = ({ targetUrl, userId, channelId }) =>
+ `${targetUrl}#${userId}#${channelId}`
+
+export class BalanceStore {
+ constructor({ stateStore } = {}) {
+ this.stateStore =
+ stateStore ||
+ createJsonStateStore({ key: "balances", path: BALANCE_PATH })
+ }
+
+ async #readAll() {
+ const parsed = await this.stateStore.read({})
+ return parsed && typeof parsed === "object" ? parsed : {}
+ }
+
+ async record(reference, currentBalance) {
+ const entries = await this.#readAll()
+ const key = entryKey(reference)
+ const existing = entries[key]
+ const initialBalance = Number.isFinite(existing?.initialBalance)
+ ? existing.initialBalance
+ : currentBalance
+ const checkedAt = new Date().toISOString()
+
+ entries[key] = {
+ initialBalance,
+ lastBalance: currentBalance,
+ checkedAt,
+ }
+ await this.stateStore.write(entries)
+
+ return {
+ currentBalance,
+ initialBalance,
+ checkedAt,
+ ...calculateBalanceUsage(initialBalance, currentBalance),
+ }
+ }
+}
diff --git a/tools/channel-key-importer/src/batchKeys.js b/tools/channel-key-importer/src/batchKeys.js
new file mode 100644
index 0000000000..9ae96e9674
--- /dev/null
+++ b/tools/channel-key-importer/src/batchKeys.js
@@ -0,0 +1,210 @@
+const MAX_BATCH_KEYS = 500
+const KNOWN_KEY_START =
+ "(?:sk-|AIza|AKIA|ASIA|xai-|gsk_|hf_|ghp_|github_pat_|key-)"
+const QUOTA_PATTERN = "(?:x|[$¥¥]?\\d+(?:\\.\\d+)?(?:u|刀|美元)?)"
+const NUMERIC_QUOTA_PATTERN = /^[$¥¥]?(\d+(?:\.\d+)?)(?:u|刀|美元)?$/i
+
+const isQuotaText = (value) =>
+ new RegExp(`^${QUOTA_PATTERN}$`, "i").test(String(value || "").trim())
+
+const parseQuotaText = (value) => {
+ const text = String(value || "").trim()
+ if (!text || text.toLowerCase() === "x") return null
+ const match = text.match(NUMERIC_QUOTA_PATTERN)
+ return match ? Number(match[1]) : Number.NaN
+}
+
+function splitSmartBatchLines(value) {
+ const raw = String(value || "").trim()
+ if (!raw || raw.startsWith("{") || raw.startsWith("[")) return [raw]
+
+ const keyBoundary = new RegExp(`[\\s,,;;]+(?=${KNOWN_KEY_START})`, "g")
+ const lines = raw
+ .replace(/\r/g, "")
+ .split(/\n+/)
+ .flatMap((line) => line.split(keyBoundary))
+ .map((line) => line.trim())
+ .filter(Boolean)
+
+ if (
+ lines.length >= 2 &&
+ lines.length % 2 === 0 &&
+ lines.every((line, index) =>
+ index % 2 === 0 ? !isQuotaText(line) : isQuotaText(line),
+ )
+ ) {
+ return Array.from(
+ { length: lines.length / 2 },
+ (_, index) => `${lines[index * 2]} | ${lines[index * 2 + 1]}`,
+ )
+ }
+
+ if (lines.length !== 1) return lines
+
+ // Some supplier exports use one long row: "key quota key quota". For
+ // keys without a recognizable prefix, alternating tokens are the only
+ // unambiguous boundary available.
+ const tokens = lines[0].split(/\s+/)
+ if (
+ tokens.length >= 4 &&
+ tokens.length % 2 === 0 &&
+ tokens.every((token, index) =>
+ index % 2 === 0 ? !isQuotaText(token) : isQuotaText(token),
+ )
+ ) {
+ return Array.from(
+ { length: tokens.length / 2 },
+ (_, index) => `${tokens[index * 2]} | ${tokens[index * 2 + 1]}`,
+ )
+ }
+ return lines
+}
+
+export function parseBatchKeys(
+ value,
+ defaultQuota,
+ { allowInlineQuota = true, deduplicate = true } = {},
+) {
+ const fallbackQuota = String(defaultQuota ?? "").trim()
+ const parsedFallback =
+ fallbackQuota === "" || fallbackQuota.toLowerCase() === "x"
+ ? null
+ : parseQuotaText(fallbackQuota)
+ if (
+ parsedFallback != null &&
+ (!Number.isFinite(parsedFallback) || parsedFallback < 0)
+ ) {
+ throw new Error("默认额度必须是大于或等于 0 的数字")
+ }
+
+ const rawValue = String(value || "").trim()
+ const lines = splitSmartBatchLines(rawValue)
+ const entries = lines
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .map((line) => {
+ // Take only a final numeric/x value as quota, so composite credentials
+ // such as AK|SK|Region remain intact.
+ const quotaMatch = allowInlineQuota
+ ? line.match(
+ new RegExp(
+ `^(.*?\\S)(?:\\s*\\|\\s*|\\s*[,,::=]\\s*|\\s+)(${QUOTA_PATTERN})$`,
+ "i",
+ ),
+ )
+ : null
+ const invalidQuotaMatch =
+ allowInlineQuota && !quotaMatch
+ ? line.match(/^(.*\S)\s+\|\s+(\S+)$/)
+ : null
+ if (invalidQuotaMatch) {
+ throw new Error(
+ `Key 尾号 ${invalidQuotaMatch[1].slice(-4)} 的额度格式不正确`,
+ )
+ }
+ const apiKey = (quotaMatch ? quotaMatch[1] : line).trim()
+ const quotaText = quotaMatch ? quotaMatch[2] : ""
+ const quota =
+ quotaText === "" || quotaText.toLowerCase() === "x"
+ ? quotaText === ""
+ ? parsedFallback
+ : null
+ : parseQuotaText(quotaText)
+ if (!apiKey) throw new Error("存在空 Key,请检查批量输入")
+ if (quota != null && (!Number.isFinite(quota) || quota < 0)) {
+ throw new Error(`Key 尾号 ${apiKey.slice(-4)} 的额度格式不正确`)
+ }
+ return { apiKey, quota }
+ })
+
+ if (entries.length === 0) throw new Error("请至少输入一条 Key")
+ if (entries.length > MAX_BATCH_KEYS) {
+ throw new Error(`一次最多导入 ${MAX_BATCH_KEYS} 条 Key`)
+ }
+ if (!deduplicate) return entries
+ const unique = new Map()
+ for (const entry of entries) unique.set(entry.apiKey, entry)
+ return [...unique.values()]
+}
+
+export function applyQuotaLines(entries, value) {
+ const raw = String(value || "").trim()
+ if (!raw) throw new Error("请在额度输入框逐行填写额度,未知填 x")
+ const lines = raw.split(/\r?\n/).map((line) => line.trim())
+ if (lines.some((line) => !line)) {
+ throw new Error("额度不能留空,未知额度请填写 x")
+ }
+ if (lines.length !== entries.length) {
+ throw new Error(
+ `Key 有 ${entries.length} 条,但额度有 ${lines.length} 条,请逐行对应`,
+ )
+ }
+ return entries.map((entry, index) => {
+ const text = lines[index]
+ const quota = parseQuotaText(text)
+ if (quota != null && (!Number.isFinite(quota) || quota < 0)) {
+ throw new Error(`第 ${index + 1} 行额度格式不正确,未知请填 x`)
+ }
+ return { ...entry, quota }
+ })
+}
+
+export function applyUniformQuota(entries, value) {
+ const text = String(value || "").trim()
+ if (!text) throw new Error("请输入统一额度,未知额度请填写 x")
+ const quota = parseQuotaText(text)
+ if (quota != null && (!Number.isFinite(quota) || quota < 0)) {
+ throw new Error("统一额度格式不正确,未知额度请填写 x")
+ }
+ return entries.map((entry) => ({ ...entry, quota }))
+}
+
+export function keyNameHint(apiKey) {
+ let value = String(apiKey || "")
+ .trim()
+ .split("|", 1)[0]
+ if (value.startsWith("{")) {
+ try {
+ const credential = JSON.parse(value)
+ value = String(
+ credential.project_id ||
+ credential.client_email?.split("@", 1)[0] ||
+ "json",
+ )
+ } catch {
+ value = "json"
+ }
+ }
+ value = value
+ .replace(/^sk-ant-api\d+-/i, "")
+ .replace(/^sk-or-v\d+-/i, "")
+ .replace(/^sk-proj-/i, "")
+ .replace(/^sk-/i, "")
+ .replace(/^(?:AKIA|ASIA|AIza|xai-|gsk_|hf_|ghp_|github_pat_|key-)/i, "")
+ return value.replace(/[^a-z0-9_-]/gi, "").slice(0, 8) || "unknown"
+}
+
+export function buildResourceChannelName(
+ providerName,
+ entries,
+ { date = new Date(), index = null, total = 1 } = {},
+) {
+ const dateText = new Intl.DateTimeFormat("en-CA", {
+ timeZone: "Asia/Shanghai",
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ }).format(date)
+ const quota = entries.every((entry) => Number.isFinite(entry.quota))
+ ? entries.reduce((sum, entry) => sum + entry.quota, 0)
+ : "x"
+ const suffix = total > 1 && Number.isInteger(index) ? `-${index + 1}` : ""
+ const hint =
+ entries.length === 1
+ ? keyNameHint(entries[0].apiKey)
+ : `${entries.length}Keys`
+ return `${dateText}-${providerName}-Key-${hint}-额度为${quota}资源${suffix}`.slice(
+ 0,
+ 80,
+ )
+}
diff --git a/tools/channel-key-importer/src/channelConfig.js b/tools/channel-key-importer/src/channelConfig.js
new file mode 100644
index 0000000000..ff3d415250
--- /dev/null
+++ b/tools/channel-key-importer/src/channelConfig.js
@@ -0,0 +1,596 @@
+const MODEL_FETCHABLE_TYPES = new Set([
+ 1, 4, 14, 17, 20, 23, 24, 25, 26, 27, 31, 34, 35, 40, 42, 43, 47, 48,
+])
+
+// New API maps these public names to Bedrock model IDs before invocation.
+// https://github.com/QuantumNous/new-api/blob/2281c9e3d8780c7f8c459f21bd0fff352cee98c6/relay/channel/aws/constants.go
+const AWS_MODEL_IDS = Object.freeze({
+ "claude-3-sonnet-20240229": "anthropic.claude-3-sonnet-20240229-v1:0",
+ "claude-3-opus-20240229": "anthropic.claude-3-opus-20240229-v1:0",
+ "claude-3-haiku-20240307": "anthropic.claude-3-haiku-20240307-v1:0",
+ "claude-3-5-sonnet-20240620": "anthropic.claude-3-5-sonnet-20240620-v1:0",
+ "claude-3-5-sonnet-20241022": "anthropic.claude-3-5-sonnet-20241022-v2:0",
+ "claude-3-5-haiku-20241022": "anthropic.claude-3-5-haiku-20241022-v1:0",
+ "claude-3-7-sonnet-20250219": "anthropic.claude-3-7-sonnet-20250219-v1:0",
+ "claude-sonnet-4-20250514": "anthropic.claude-sonnet-4-20250514-v1:0",
+ "claude-opus-4-20250514": "anthropic.claude-opus-4-20250514-v1:0",
+ "claude-opus-4-1-20250805": "anthropic.claude-opus-4-1-20250805-v1:0",
+ "claude-sonnet-4-5-20250929": "anthropic.claude-sonnet-4-5-20250929-v1:0",
+ "claude-sonnet-4-6": "anthropic.claude-sonnet-4-6",
+ "claude-haiku-4-5-20251001": "anthropic.claude-haiku-4-5-20251001-v1:0",
+ "claude-opus-4-5-20251101": "anthropic.claude-opus-4-5-20251101-v1:0",
+ "claude-opus-4-6": "anthropic.claude-opus-4-6-v1",
+ "claude-opus-4-7": "anthropic.claude-opus-4-7",
+ "claude-opus-4-8": "anthropic.claude-opus-4-8",
+ "nova-micro-v1:0": "amazon.nova-micro-v1:0",
+ "nova-lite-v1:0": "amazon.nova-lite-v1:0",
+ "nova-pro-v1:0": "amazon.nova-pro-v1:0",
+ "nova-premier-v1:0": "amazon.nova-premier-v1:0",
+ "nova-canvas-v1:0": "amazon.nova-canvas-v1:0",
+ "nova-reel-v1:0": "amazon.nova-reel-v1:0",
+ "nova-reel-v1:1": "amazon.nova-reel-v1:1",
+ "nova-sonic-v1:0": "amazon.nova-sonic-v1:0",
+})
+const AWS_MODELS = Object.keys(AWS_MODEL_IDS)
+
+const part = (id, label, options = {}) => ({ id, label, ...options })
+
+const CHANNEL_CONFIGS = Object.freeze({
+ aws: {
+ // New API accepts AK|SK|Region or APIKey|Region and records the selected
+ // format in settings.aws_key_type.
+ // https://github.com/QuantumNous/new-api/blob/e514db20f762649014bce8950ef85b182f5f1b3f/relay/channel/aws/relay-aws.go
+ credentialModes: [
+ {
+ id: "ak_sk",
+ label: "Access Key / Secret Key",
+ separator: "|",
+ settings: { aws_key_type: "ak_sk" },
+ batchHelp:
+ "批量框会逐条自动识别 APIKey|Region 或 AK|SK|Region;被换行折断的长 Key 也会自动合并。",
+ parts: [
+ part("accessKey", "Access Key ID"),
+ part("secretKey", "Secret Access Key", { secret: true }),
+ part("region", "推理地区", { placeholder: "例如:us-east-1" }),
+ ],
+ },
+ {
+ id: "api_key",
+ label: "Bedrock API Key",
+ separator: "|",
+ settings: { aws_key_type: "api_key" },
+ batchHelp:
+ "批量框会逐条自动识别 APIKey|Region 或 AK|SK|Region;被换行折断的长 Key 也会自动合并。",
+ parts: [
+ part("apiKey", "Bedrock API Key", { secret: true }),
+ part("region", "推理地区", { placeholder: "例如:us-east-1" }),
+ ],
+ },
+ ],
+ defaultModels: AWS_MODELS,
+ modelHelp:
+ "New API 不支持自动拉取 Bedrock 模型。这里使用 New API 当前支持的本地模型名,可按账号已开通模型删改。",
+ // New API derives supported system cross-region profile IDs from the
+ // source region. AWS also accepts a profile ID/ARN directly as modelId,
+ // so application profiles are represented as model mappings here.
+ // https://github.com/QuantumNous/new-api/blob/e514db20f762649014bce8950ef85b182f5f1b3f/relay/channel/aws/relay-aws.go
+ // https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-use.html
+ modelMappings: {
+ label: "推理配置文件(可选)",
+ placeholder:
+ "claude-sonnet-4-6=arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/your-profile",
+ help: "每行填写“对外模型名=Inference Profile ID 或 ARN”。留空时,New API 会按模型和源地区自动选择已支持的跨区推理配置。",
+ },
+ flags: [
+ {
+ id: "globalInference",
+ label: "Global 跨区域推理",
+ help: "为未手工指定 Profile 的模型使用 global. 推理配置 ID。请求可能路由到全球商业区域;需确认模型、源地区及 IAM/SCP 均支持。",
+ },
+ ],
+ },
+ "vertex-ai": {
+ // Vertex key mode uses settings.vertex_key_type; deployment regions are
+ // stored in channel.other with a required default entry.
+ // https://github.com/QuantumNous/new-api/blob/e514db20f762649014bce8950ef85b182f5f1b3f/controller/channel.go
+ credentialModes: [
+ {
+ id: "json",
+ label: "服务账号 JSON",
+ settings: { vertex_key_type: "json" },
+ parts: [
+ part("serviceAccountJson", "服务账号 JSON", {
+ multiline: true,
+ fileAccept: ".json,application/json",
+ }),
+ ],
+ },
+ {
+ id: "api_key",
+ label: "Vertex API Key",
+ settings: { vertex_key_type: "api_key" },
+ parts: [part("apiKey", "Vertex API Key", { secret: true })],
+ },
+ ],
+ extra: {
+ id: "region",
+ label: "部署地区",
+ multiline: true,
+ placeholder:
+ '{"default":"us-central1","claude-sonnet-4-6":"europe-west1"}',
+ help: "可填写单个地区,或用 JSON 为不同模型指定地区;必须包含 default。",
+ format: "vertex-region",
+ },
+ modelHelp:
+ "New API 不支持自动拉取 Vertex 模型;请填写 Gemini、Claude 或开放模型的 New API 本地模型名。",
+ },
+ azure: {
+ // New API stores Azure's default api-version in channel.other and builds
+ // /openai/deployments/{deployment}/... from each configured model name.
+ // https://github.com/QuantumNous/new-api/blob/2281c9e3d8780c7f8c459f21bd0fff352cee98c6/relay/channel/openai/adaptor.go
+ extra: {
+ id: "apiVersion",
+ label: "Azure API Version",
+ placeholder: "例如:2025-04-01-preview",
+ defaultValue: "2025-04-01-preview",
+ help: "写入 New API 的渠道 API Version;模型列表必须填写 Azure 部署名称。",
+ format: "raw",
+ },
+ modelHelp:
+ "每行填写一个 Azure 部署名称,不是 gpt-4o 等基础模型名称;New API 会拼接部署路径。",
+ },
+ baidu: {
+ credentialModes: [
+ {
+ id: "default",
+ label: "API Key / Secret Key",
+ separator: "|",
+ parts: [
+ part("apiKey", "API Key"),
+ part("secretKey", "Secret Key", { secret: true }),
+ ],
+ },
+ ],
+ },
+ xunfei: {
+ credentialModes: [
+ {
+ id: "default",
+ label: "讯飞组合凭证",
+ separator: "|",
+ parts: [
+ part("appId", "APP ID"),
+ part("apiSecret", "API Secret", { secret: true }),
+ part("apiKey", "API Key", { secret: true }),
+ ],
+ },
+ ],
+ extra: {
+ id: "version",
+ label: "模型版本",
+ placeholder: "例如:v2.1",
+ format: "raw",
+ },
+ },
+ "aiproxy-library": {
+ extra: {
+ id: "knowledgeBaseId",
+ label: "知识库 ID",
+ placeholder: "例如:123456",
+ format: "raw",
+ },
+ },
+ tencent: {
+ credentialModes: [
+ {
+ id: "default",
+ label: "腾讯云组合凭证",
+ separator: "|",
+ parts: [
+ part("appId", "App ID"),
+ part("secretId", "Secret ID"),
+ part("secretKey", "Secret Key", { secret: true }),
+ ],
+ },
+ ],
+ },
+ cloudflare: {
+ extra: {
+ id: "accountId",
+ label: "Cloudflare Account ID",
+ format: "raw",
+ },
+ },
+ kling: {
+ credentialModes: [
+ {
+ id: "ak_sk",
+ label: "Access Key / Secret Key",
+ separator: "|",
+ parts: [
+ part("accessKey", "Access Key"),
+ part("secretKey", "Secret Key", { secret: true }),
+ ],
+ },
+ {
+ id: "api_key",
+ label: "上游 New API Key",
+ parts: [part("apiKey", "API Key", { secret: true })],
+ },
+ ],
+ },
+ jimeng: {
+ credentialModes: [
+ {
+ id: "default",
+ label: "Access Key ID / Secret Access Key",
+ separator: "|",
+ parts: [
+ part("accessKey", "Access Key ID"),
+ part("secretKey", "Secret Access Key", { secret: true }),
+ ],
+ },
+ ],
+ },
+ openrouter: {
+ // OpenRouter /v1/models returns provider/model IDs. New API model_mapping
+ // maps the public model name to that complete upstream ID.
+ // https://github.com/QuantumNous/new-api/blob/main/relay/helper/model_mapped.go
+ autoMapProviderPrefix: true,
+ },
+})
+
+const normalizeModels = (value) => [
+ ...new Set(
+ String(value || "")
+ .split(/[\n,]/)
+ .map((model) => model.trim())
+ .filter(Boolean),
+ ),
+]
+
+const AWS_REGION_PATTERN = "[a-z]{2}(?:-[a-z0-9]+)+-\\d+"
+const AWS_QUOTA_PATTERN = "(?:x|[$¥¥]?\\d+(?:\\.\\d+)?(?:u|刀|美元)?)"
+const AWS_COMPLETE_CREDENTIAL = new RegExp(
+ `^(?:[^|\\s]+\\|[^|\\s]+\\|${AWS_REGION_PATTERN}|[^|\\s]+\\|${AWS_REGION_PATTERN})(?:\\s+${AWS_QUOTA_PATTERN})?$`,
+ "i",
+)
+const AWS_REGION_LINE = new RegExp(
+ `^${AWS_REGION_PATTERN}(?:\\s+${AWS_QUOTA_PATTERN})?$`,
+ "i",
+)
+const AWS_QUOTA_LINE = new RegExp(`^${AWS_QUOTA_PATTERN}$`, "i")
+
+export function normalizeAwsBatchCredentialInput(value) {
+ const lines = String(value || "")
+ .replace(/[|│]/g, "|")
+ .replace(/\r/g, "")
+ .split(/\n+/)
+ .map((line) => line.trim().replace(/\s*\|\s*/g, "|"))
+ .filter(Boolean)
+ const credentials = []
+ let pending = ""
+
+ for (let line of lines) {
+ if (AWS_QUOTA_LINE.test(line) && credentials.length > 0 && !pending) {
+ credentials[credentials.length - 1] += ` ${line}`
+ continue
+ }
+ line = line.replace(
+ new RegExp(
+ `^(\\S+)\\s+(${AWS_REGION_PATTERN})(\\s+${AWS_QUOTA_PATTERN})?$`,
+ "i",
+ ),
+ "$1|$2$3",
+ )
+ if (pending) {
+ line =
+ AWS_REGION_LINE.test(line) && !pending.endsWith("|")
+ ? `${pending}|${line}`
+ : `${pending}${line.replace(/\s+/g, "")}`
+ }
+ if (AWS_COMPLETE_CREDENTIAL.test(line)) {
+ credentials.push(line)
+ pending = ""
+ } else {
+ pending = line.replace(/\s+/g, "")
+ }
+ }
+ if (pending) credentials.push(pending)
+ return credentials.join("\n")
+}
+
+export function buildAwsInferenceProfileMappings(
+ models,
+ region,
+ forceGlobal = false,
+) {
+ const normalizedRegion = String(region || "").trim()
+ const prefix = forceGlobal
+ ? "global"
+ : normalizedRegion.startsWith("us-")
+ ? "us"
+ : normalizedRegion.startsWith("eu-")
+ ? "eu"
+ : "global"
+ return normalizeModels(models).map((model) => ({
+ standardModel: model,
+ actualModel: `${prefix}.${AWS_MODEL_IDS[model] || model}`,
+ }))
+}
+
+export function getAwsRuntimeBaseUrl(apiKey) {
+ const region = String(apiKey || "")
+ .split("|")
+ .at(-1)
+ ?.trim()
+ return region ? `https://bedrock-runtime.${region}.amazonaws.com` : ""
+}
+
+export function inferAwsCredentialMode(apiKey) {
+ const parts = String(apiKey || "")
+ .split("|")
+ .map((value) => value.trim())
+ if (parts.some((value) => !value)) return ""
+ if (parts.length === 2) return "api_key"
+ if (parts.length === 3) return "ak_sk"
+ return ""
+}
+
+export function summarizeAwsCredentials(entries, globalInference = false) {
+ const regions = [
+ ...new Set(
+ entries
+ .map((entry) =>
+ String(entry.apiKey || "")
+ .split("|")
+ .at(-1)
+ ?.trim(),
+ )
+ .filter(Boolean),
+ ),
+ ]
+ const modes = [
+ ...new Set(
+ entries
+ .map((entry) => inferAwsCredentialMode(entry.apiKey))
+ .filter(Boolean),
+ ),
+ ]
+ return { regions, modes, globalInference }
+}
+
+// Global inference starts from one requesting region and lets Bedrock route to
+// supported destination regions; it does not require duplicate channels.
+// https://docs.aws.amazon.com/bedrock/latest/userguide/global-cross-region-inference.html
+export function buildAwsGlobalMappings(models, existingMappings = []) {
+ const explicitMappings = existingMappings.filter((mapping) =>
+ String(mapping.actualModel || "").startsWith("arn:aws:bedrock:"),
+ )
+ const explicitlyMapped = new Set(
+ explicitMappings.map((mapping) => mapping.standardModel),
+ )
+ const routableModels = normalizeModels(models).filter(
+ (model) =>
+ !/^(?:global|us|eu)\./.test(model) &&
+ !model.startsWith("arn:aws:bedrock:") &&
+ !explicitlyMapped.has(model),
+ )
+ return [
+ ...explicitMappings,
+ ...buildAwsInferenceProfileMappings(routableModels, "", true),
+ ]
+}
+
+export function appendAwsRegionToChannelName(name, apiKey, maxLength = 80) {
+ const region = String(apiKey || "")
+ .split("|")
+ .at(-1)
+ ?.trim()
+ if (!region) return String(name || "").slice(0, maxLength)
+ const suffix = ` · ${region}`
+ return `${String(name || "").slice(0, maxLength - suffix.length)}${suffix}`
+}
+
+export function getAwsEntryChannelSettings(apiKey, channelSettings = {}) {
+ const awsKeyType = inferAwsCredentialMode(apiKey)
+ if (!awsKeyType) {
+ throw new Error("AWS 凭证必须是 APIKey|Region 或 AK|SK|Region")
+ }
+ return { ...channelSettings, aws_key_type: awsKeyType }
+}
+
+export function getPublicChannelConfig(providerId, channelType) {
+ const config = CHANNEL_CONFIGS[providerId] || {}
+ return {
+ ...config,
+ supportsModelFetch: MODEL_FETCHABLE_TYPES.has(channelType),
+ }
+}
+
+export function resolveChannelInput(provider, body) {
+ const config = getPublicChannelConfig(provider.id, provider.channelType)
+ const useTemplate = body.configSource === "template"
+ let apiKey = String(body.apiKey || "").trim()
+ let channelSettings = {}
+
+ if (config.credentialModes?.length) {
+ const mode =
+ config.credentialModes.find(
+ (item) => item.id === String(body.credentialMode || ""),
+ ) || config.credentialModes[0]
+ channelSettings = mode.settings || {}
+ if (body.useRawCredentials === true) {
+ apiKey = ""
+ } else {
+ const values = mode.parts.map((field) => {
+ const value = String(body.credentialParts?.[field.id] || "").trim()
+ if (!value) throw new Error(`请填写${field.label}`)
+ return value
+ })
+ apiKey = mode.separator ? values.join(mode.separator) : values[0]
+ }
+ if (
+ body.useRawCredentials !== true &&
+ provider.id === "vertex-ai" &&
+ mode.id === "json"
+ ) {
+ try {
+ const parsed = JSON.parse(apiKey)
+ if (
+ !parsed?.project_id ||
+ !parsed?.client_email ||
+ !parsed?.private_key
+ ) {
+ throw new Error("missing fields")
+ }
+ apiKey = JSON.stringify(parsed)
+ } catch {
+ throw new Error("Vertex AI 服务账号文件缺少必要字段或不是有效 JSON")
+ }
+ }
+ }
+
+ let channelOther = ""
+ if (config.extra && !useTemplate) {
+ const raw = String(body.providerExtra || "").trim()
+ if (!raw) throw new Error(`请填写${config.extra.label}`)
+ if (config.extra.format === "vertex-region") {
+ try {
+ const parsed = raw.startsWith("{") ? JSON.parse(raw) : { default: raw }
+ if (!parsed.default || typeof parsed.default !== "string") {
+ throw new Error("missing default")
+ }
+ channelOther = JSON.stringify(parsed)
+ } catch {
+ throw new Error("部署地区必须是包含 default 的 JSON,或单个地区名称")
+ }
+ } else {
+ channelOther = raw
+ }
+ }
+
+ const suppliedModels = normalizeModels(body.providerModels)
+ const models = config.supportsModelFetch
+ ? suppliedModels
+ : suppliedModels.length
+ ? suppliedModels
+ : config.defaultModels || []
+ if (
+ !useTemplate &&
+ body.configSource !== "fetch" &&
+ body.configSource !== "new-api" &&
+ !config.supportsModelFetch &&
+ models.length === 0
+ ) {
+ throw new Error("该渠道不能自动拉取模型,请先填写模型或部署名称")
+ }
+
+ const providerMappings = []
+ if (config.modelMappings && !useTemplate) {
+ const seen = new Set()
+ for (const line of String(body.providerModelMappings || "").split("\n")) {
+ const value = line.trim()
+ if (!value) continue
+ const separator = value.indexOf("=")
+ const standardModel = value.slice(0, separator).trim()
+ const actualModel = value.slice(separator + 1).trim()
+ if (separator < 1 || !standardModel || !actualModel) {
+ throw new Error("推理配置文件必须按“模型名=Profile ID 或 ARN”填写")
+ }
+ if (!models.includes(standardModel)) {
+ throw new Error(`推理配置对应的模型不在模型列表中:${standardModel}`)
+ }
+ if (seen.has(standardModel)) {
+ throw new Error(`同一模型填写了多个推理配置:${standardModel}`)
+ }
+ seen.add(standardModel)
+ providerMappings.push({ standardModel, actualModel })
+ }
+ }
+
+ const forceGlobal = body.providerFlags?.globalInference === true
+ const awsRegion = String(body.credentialParts?.region || "").trim()
+ const awsApiKeyMode = body.credentialMode === "api_key"
+ if (
+ provider.id === "aws" &&
+ (forceGlobal ||
+ (awsApiKeyMode && body.useRawCredentials !== true && awsRegion))
+ ) {
+ const manuallyMapped = new Set(
+ providerMappings.map((mapping) => mapping.standardModel),
+ )
+ for (const mapping of buildAwsInferenceProfileMappings(
+ models,
+ awsRegion,
+ forceGlobal,
+ )) {
+ if (manuallyMapped.has(mapping.standardModel)) continue
+ providerMappings.push(mapping)
+ }
+ }
+
+ return {
+ apiKey,
+ channelOther,
+ channelSettings,
+ models,
+ providerMappings,
+ awsEntryRouting:
+ provider.id === "aws" &&
+ awsApiKeyMode &&
+ body.useRawCredentials === true &&
+ !forceGlobal,
+ config,
+ }
+}
+
+export function validateBatchCredentialEntries(
+ provider,
+ credentialMode,
+ entries,
+) {
+ if (provider.id === "aws") {
+ for (const [index, entry] of entries.entries()) {
+ const inferredMode = inferAwsCredentialMode(entry.apiKey)
+ const mode =
+ credentialMode === "auto"
+ ? inferredMode
+ : credentialMode === "api_key"
+ ? "api_key"
+ : "ak_sk"
+ const expectedParts = mode === "api_key" ? 2 : 3
+ const parts = entry.apiKey.split("|").map((value) => value.trim())
+ if (
+ !mode ||
+ parts.length !== expectedParts ||
+ parts.some((value) => !value)
+ ) {
+ const format =
+ credentialMode === "auto"
+ ? "APIKey|Region 或 AK|SK|Region"
+ : mode === "api_key"
+ ? "APIKey|Region"
+ : "AK|SK|Region"
+ throw new Error(`第 ${index + 1} 条 AWS 凭证必须是 ${format}`)
+ }
+ const region = parts.at(-1)
+ if (!/^[a-z]{2}(?:-[a-z0-9]+)+-\d+$/.test(region)) {
+ throw new Error(`第 ${index + 1} 条 AWS 推理地区格式不正确`)
+ }
+ }
+ }
+
+ if (provider.id === "vertex-ai" && credentialMode !== "api_key") {
+ for (const [index, entry] of entries.entries()) {
+ try {
+ const parsed = JSON.parse(entry.apiKey)
+ if (!parsed.project_id || !parsed.client_email || !parsed.private_key) {
+ throw new Error("missing fields")
+ }
+ } catch {
+ throw new Error(`第 ${index + 1} 条 Vertex 服务账号不是有效 JSON`)
+ }
+ }
+ }
+}
diff --git a/tools/channel-key-importer/src/configStore.js b/tools/channel-key-importer/src/configStore.js
new file mode 100644
index 0000000000..6075bc6e6d
--- /dev/null
+++ b/tools/channel-key-importer/src/configStore.js
@@ -0,0 +1,291 @@
+import { execFile } from "node:child_process"
+import { createHash, randomUUID } from "node:crypto"
+import { platform } from "node:os"
+import { join } from "node:path"
+import { promisify } from "node:util"
+
+import { DATA_DIR } from "./dataPath.js"
+import { createJsonStateStore } from "./sharedStorage.js"
+
+const execFileAsync = promisify(execFile)
+const KEYCHAIN_SERVICE = "dataeyesai"
+const CONFIG_PATH = join(DATA_DIR, "config.json")
+
+const emptyConfig = () => ({
+ profileId: "",
+ name: "",
+ nameSource: "host",
+ targetUrl: "",
+ userId: "",
+ rememberToken: false,
+ rememberSession: false,
+ allowInsecureHttp: false,
+})
+
+const targetOrigin = (value) => {
+ try {
+ return new URL(value).origin
+ } catch {
+ return ""
+ }
+}
+
+export function findProfileForRecord(record, profiles) {
+ const recordOrigin = targetOrigin(record?.targetUrl)
+ if (!recordOrigin) return null
+ const profileById = profiles.find(
+ (profile) => profile.profileId === record.profileId,
+ )
+ if (profileById && targetOrigin(profileById.targetUrl) === recordOrigin) {
+ return profileById
+ }
+ return (
+ profiles.find(
+ (profile) => targetOrigin(profile.targetUrl) === recordOrigin,
+ ) || null
+ )
+}
+
+export function normalizeConfigState(value) {
+ if (Array.isArray(value?.profiles)) {
+ const profiles = value.profiles
+ .filter((profile) => profile?.targetUrl && profile?.userId)
+ .map((profile) => {
+ const host = new URL(profile.targetUrl).host
+ const name = String(profile.name || host)
+ return {
+ profileId: String(profile.profileId || randomUUID()),
+ name,
+ nameSource: ["host", "detected", "custom"].includes(
+ profile.nameSource,
+ )
+ ? profile.nameSource
+ : name === host
+ ? "host"
+ : "custom",
+ targetUrl: String(profile.targetUrl),
+ userId: String(profile.userId),
+ rememberToken: profile.rememberToken === true,
+ rememberSession: profile.rememberSession === true,
+ allowInsecureHttp: profile.allowInsecureHttp === true,
+ }
+ })
+ const requestedActiveId = String(value.activeProfileId || "")
+ return {
+ activeProfileId: profiles.some(
+ (profile) => profile.profileId === requestedActiveId,
+ )
+ ? requestedActiveId
+ : profiles[0]?.profileId || "",
+ profiles,
+ }
+ }
+
+ if (value?.targetUrl && value?.userId) {
+ const profileId = `legacy-${createHash("sha256")
+ .update(`${value.targetUrl}#${value.userId}`)
+ .digest("hex")
+ .slice(0, 12)}`
+ return {
+ activeProfileId: profileId,
+ profiles: [
+ {
+ profileId,
+ name: new URL(value.targetUrl).host,
+ nameSource: "host",
+ targetUrl: String(value.targetUrl),
+ userId: String(value.userId),
+ rememberToken: value.rememberToken === true,
+ rememberSession: false,
+ allowInsecureHttp: value.allowInsecureHttp === true,
+ },
+ ],
+ }
+ }
+ return { activeProfileId: "", profiles: [] }
+}
+
+export class ConfigStore {
+ #memoryTokens = new Map()
+ #sessions = new Map()
+ #tokenStore = null
+
+ constructor({ stateStore } = {}) {
+ this.stateStore =
+ stateStore || createJsonStateStore({ key: "config", path: CONFIG_PATH })
+ }
+
+ setTokenStore(tokenStore) {
+ this.#tokenStore = tokenStore || null
+ }
+
+ async #readState() {
+ return normalizeConfigState(await this.stateStore.read(null))
+ }
+
+ async #writeState(state) {
+ await this.stateStore.write(state)
+ }
+
+ async listProfiles() {
+ return (await this.#readState()).profiles
+ }
+
+ async readConfig(profileId = "") {
+ const state = await this.#readState()
+ const selectedId = profileId || state.activeProfileId
+ return (
+ state.profiles.find((profile) => profile.profileId === selectedId) ||
+ emptyConfig()
+ )
+ }
+
+ async saveConfig(config) {
+ const state = await this.#readState()
+ const existing = config.profileId
+ ? state.profiles.find((profile) => profile.profileId === config.profileId)
+ : state.profiles.find(
+ (profile) =>
+ profile.targetUrl === config.targetUrl &&
+ profile.userId === config.userId,
+ )
+ const profile = {
+ profileId: existing?.profileId || randomUUID(),
+ name: String(
+ config.name || existing?.name || new URL(config.targetUrl).host,
+ ),
+ nameSource: config.nameSource || existing?.nameSource || "host",
+ targetUrl: config.targetUrl,
+ userId: config.userId,
+ rememberToken: config.rememberToken === true,
+ rememberSession: config.rememberSession === true,
+ allowInsecureHttp: config.allowInsecureHttp === true,
+ }
+ const profiles = existing
+ ? state.profiles.map((item) =>
+ item.profileId === existing.profileId ? profile : item,
+ )
+ : [...state.profiles, profile]
+ await this.#writeState({
+ activeProfileId: profile.profileId,
+ profiles,
+ })
+ return profile
+ }
+
+ async saveDetectedName(profileId, name) {
+ const state = await this.#readState()
+ const profile = state.profiles.find((item) => item.profileId === profileId)
+ const detectedName = String(name || "")
+ .trim()
+ .slice(0, 80)
+ if (!profile || !detectedName || profile.nameSource === "custom") {
+ return profile || null
+ }
+ const updated = {
+ ...profile,
+ name: detectedName,
+ nameSource: "detected",
+ }
+ await this.#writeState({
+ ...state,
+ profiles: state.profiles.map((item) =>
+ item.profileId === profileId ? updated : item,
+ ),
+ })
+ return updated
+ }
+
+ async selectProfile(profileId) {
+ const state = await this.#readState()
+ if (!state.profiles.some((profile) => profile.profileId === profileId)) {
+ throw new Error("New API 配置不存在")
+ }
+ await this.#writeState({ ...state, activeProfileId: profileId })
+ return await this.readConfig(profileId)
+ }
+
+ async saveToken(account, token, remember) {
+ this.#memoryTokens.set(account, token)
+ if (!remember) return
+ if (this.#tokenStore) {
+ await this.#tokenStore.save(account, token)
+ return
+ }
+ if (platform() !== "darwin") {
+ throw new Error("当前系统暂不支持安全持久化管理员 Token")
+ }
+ await execFileAsync("security", [
+ "add-generic-password",
+ "-U",
+ "-a",
+ account,
+ "-s",
+ KEYCHAIN_SERVICE,
+ "-w",
+ token,
+ ])
+ }
+
+ async readToken(account, remember) {
+ if (this.#memoryTokens.has(account)) return this.#memoryTokens.get(account)
+ if (!remember) return ""
+ if (this.#tokenStore) {
+ const storedToken = await this.#tokenStore.read(account)
+ if (storedToken) return storedToken
+ }
+ if (platform() !== "darwin") return ""
+ try {
+ const { stdout } = await execFileAsync("security", [
+ "find-generic-password",
+ "-a",
+ account,
+ "-s",
+ KEYCHAIN_SERVICE,
+ "-w",
+ ])
+ return stdout.trim()
+ } catch {
+ return ""
+ }
+ }
+
+ async saveSession(session, remember = false) {
+ const account = getCredentialAccount(session.targetUrl, session.userId)
+ this.#sessions.set(account, { ...session })
+ if (!this.#tokenStore?.saveSession) return
+ if (remember) {
+ await this.#tokenStore.saveSession(account, session)
+ } else if (this.#tokenStore.deleteSession) {
+ await this.#tokenStore.deleteSession(account)
+ }
+ }
+
+ async readSession(targetUrl, userId, remember = false) {
+ const account = getCredentialAccount(targetUrl, userId)
+ const session = this.#sessions.get(account)
+ if (session) return { ...session }
+ if (!remember || !this.#tokenStore?.readSession) return null
+ const stored = await this.#tokenStore.readSession(account)
+ if (!stored?.sessionCookie) return null
+ const restored = { ...stored, targetUrl, userId: String(userId) }
+ this.#sessions.set(account, restored)
+ return { ...restored }
+ }
+
+ async clearSession(targetUrl, userId) {
+ if (targetUrl && userId) {
+ const account = getCredentialAccount(targetUrl, userId)
+ this.#sessions.delete(account)
+ if (this.#tokenStore?.deleteSession) {
+ await this.#tokenStore.deleteSession(account)
+ }
+ return
+ }
+ this.#sessions.clear()
+ }
+}
+
+export function getCredentialAccount(targetUrl, userId) {
+ return `${new URL(targetUrl).origin}#${userId}`
+}
diff --git a/tools/channel-key-importer/src/customProviderStore.js b/tools/channel-key-importer/src/customProviderStore.js
new file mode 100644
index 0000000000..f9629ab407
--- /dev/null
+++ b/tools/channel-key-importer/src/customProviderStore.js
@@ -0,0 +1,114 @@
+import { randomUUID } from "node:crypto"
+import { join } from "node:path"
+
+import { DATA_DIR } from "./dataPath.js"
+import { resolveProviderBaseUrl } from "./providers.js"
+import { createJsonStateStore } from "./sharedStorage.js"
+
+const CUSTOM_PROVIDER_PATH = join(DATA_DIR, "custom-openai-providers.json")
+const REQUIRED_BASE_URL_PROVIDER = Object.freeze({
+ baseUrl: "",
+ requiresBaseUrl: true,
+})
+
+const compareByName = (left, right) =>
+ left.name.localeCompare(right.name, "zh-CN", {
+ numeric: true,
+ sensitivity: "base",
+ })
+
+const normalizeName = (value) => {
+ const name = String(value || "").trim()
+ if (!name) throw new Error("请输入自定义供应商名称")
+ if (name.length > 60) throw new Error("供应商名称不能超过 60 个字符")
+ return name
+}
+
+const normalizeProvider = (value) => {
+ const name = normalizeName(value?.name)
+ return {
+ id: String(value?.id || randomUUID()),
+ name,
+ baseUrl: resolveProviderBaseUrl(REQUIRED_BASE_URL_PROVIDER, value?.baseUrl),
+ createdAt: String(value?.createdAt || new Date().toISOString()),
+ updatedAt: String(value?.updatedAt || new Date().toISOString()),
+ }
+}
+
+export function normalizeCustomProviderState(value) {
+ if (!Array.isArray(value)) return []
+ const byId = new Map()
+ for (const item of value) {
+ try {
+ const provider = normalizeProvider(item)
+ byId.set(provider.id, provider)
+ } catch {
+ // Ignore incomplete legacy rows while keeping every valid preset.
+ }
+ }
+ return [...byId.values()].sort(compareByName)
+}
+
+export class CustomProviderStore {
+ constructor({ stateStore, now = () => new Date() } = {}) {
+ this.stateStore =
+ stateStore ||
+ createJsonStateStore({
+ key: "custom-openai-providers",
+ path: CUSTOM_PROVIDER_PATH,
+ })
+ this.now = now
+ }
+
+ async list() {
+ return normalizeCustomProviderState(await this.stateStore.read([]))
+ }
+
+ async get(providerId) {
+ return (
+ (await this.list()).find(
+ (provider) => provider.id === String(providerId || ""),
+ ) || null
+ )
+ }
+
+ async save(input) {
+ const providers = await this.list()
+ const requestedId = String(input?.id || "")
+ const existing = requestedId
+ ? providers.find((provider) => provider.id === requestedId)
+ : null
+ if (requestedId && !existing) throw new Error("自定义供应商不存在")
+ const now = this.now().toISOString()
+ const provider = normalizeProvider({
+ ...input,
+ id: existing?.id || randomUUID(),
+ createdAt: existing?.createdAt || now,
+ updatedAt: now,
+ })
+ const duplicateName = providers.find(
+ (item) =>
+ item.id !== provider.id &&
+ item.name.localeCompare(provider.name, "zh-CN", {
+ sensitivity: "base",
+ }) === 0,
+ )
+ if (duplicateName) throw new Error("已经存在同名的自定义供应商")
+ const next = existing
+ ? providers.map((item) => (item.id === provider.id ? provider : item))
+ : [...providers, provider]
+ await this.stateStore.write(next.sort(compareByName))
+ return provider
+ }
+
+ async remove(providerId) {
+ const providers = await this.list()
+ const id = String(providerId || "")
+ if (!providers.some((provider) => provider.id === id)) {
+ throw new Error("自定义供应商不存在")
+ }
+ await this.stateStore.write(
+ providers.filter((provider) => provider.id !== id),
+ )
+ }
+}
diff --git a/tools/channel-key-importer/src/dataMigration.js b/tools/channel-key-importer/src/dataMigration.js
new file mode 100644
index 0000000000..07b6f24f1e
--- /dev/null
+++ b/tools/channel-key-importer/src/dataMigration.js
@@ -0,0 +1,27 @@
+import { access, copyFile, mkdir } from "node:fs/promises"
+import { join } from "node:path"
+
+const DATA_FILES = ["config.json", "imports.json", "balances.json"]
+
+export async function migrateDataFiles(sourceDir, targetDir) {
+ if (!sourceDir || !targetDir || sourceDir === targetDir) return []
+ const copied = []
+ await mkdir(targetDir, { recursive: true, mode: 0o700 })
+ for (const fileName of DATA_FILES) {
+ const source = join(sourceDir, fileName)
+ const target = join(targetDir, fileName)
+ try {
+ await access(target)
+ continue
+ } catch {
+ // Only migrate when the desktop destination has no newer local file.
+ }
+ try {
+ await copyFile(source, target)
+ copied.push(fileName)
+ } catch {
+ // Missing legacy files are normal on a clean installation.
+ }
+ }
+ return copied
+}
diff --git a/tools/channel-key-importer/src/dataPath.js b/tools/channel-key-importer/src/dataPath.js
new file mode 100644
index 0000000000..7334b3c7c2
--- /dev/null
+++ b/tools/channel-key-importer/src/dataPath.js
@@ -0,0 +1,5 @@
+import { homedir } from "node:os"
+import { join } from "node:path"
+
+export const DATA_DIR =
+ process.env.DATAEYESAI_DATA_DIR || join(homedir(), ".config", "dataeyesai")
diff --git a/tools/channel-key-importer/src/generateAccessKey.js b/tools/channel-key-importer/src/generateAccessKey.js
new file mode 100644
index 0000000000..08aa578a12
--- /dev/null
+++ b/tools/channel-key-importer/src/generateAccessKey.js
@@ -0,0 +1,15 @@
+import { randomBytes } from "node:crypto"
+
+import { hashAccessKey } from "./accessAuth.js"
+
+const accessKey = randomBytes(18).toString("base64url")
+process.stdout.write(
+ `${JSON.stringify(
+ {
+ accessKey,
+ accessKeyHash: hashAccessKey(accessKey),
+ },
+ null,
+ 2,
+ )}\n`,
+)
diff --git a/tools/channel-key-importer/src/importStore.js b/tools/channel-key-importer/src/importStore.js
new file mode 100644
index 0000000000..fd07a102d0
--- /dev/null
+++ b/tools/channel-key-importer/src/importStore.js
@@ -0,0 +1,200 @@
+import { createHash, randomUUID } from "node:crypto"
+import { join } from "node:path"
+
+import { DATA_DIR } from "./dataPath.js"
+import { createJsonStateStore } from "./sharedStorage.js"
+
+const IMPORTS_PATH = join(DATA_DIR, "imports.json")
+const MAX_RECORDS = 2000
+
+export function keyIdentity(apiKey) {
+ const normalized = String(apiKey)
+ const suffix = normalized.slice(-4)
+ return {
+ keyHint: suffix ? `••••${suffix}` : "••••",
+ keyFingerprint: createHash("sha256")
+ .update(normalized)
+ .digest("hex")
+ .slice(0, 12),
+ }
+}
+
+export function calculateQuotaUsage(
+ quota,
+ currentBalance,
+ sharedChannel = false,
+) {
+ if (!Number.isFinite(quota)) {
+ return { spent: null, usageStatus: "quota-missing" }
+ }
+ if (sharedChannel) {
+ return { spent: null, usageStatus: "shared-channel" }
+ }
+ if (!Number.isFinite(currentBalance)) {
+ return { spent: 0, usageStatus: "awaiting-balance" }
+ }
+ if (currentBalance > quota) {
+ return { spent: null, usageStatus: "balance-increased" }
+ }
+ return {
+ spent: Number((quota - currentBalance).toFixed(8)),
+ usageStatus: "available",
+ }
+}
+
+export function applyGatewayUsage(record, usage, binding = {}) {
+ const checkedAt = new Date().toISOString()
+ return {
+ ...record,
+ ...binding,
+ gatewaySpent: usage.spentUsd,
+ spent: usage.spentUsd,
+ requestCount: usage.requestCount,
+ promptTokens: usage.promptTokens,
+ completionTokens: usage.completionTokens,
+ lastUsedAt: usage.lastUsedAt,
+ usageLogCount: usage.scannedLogCount,
+ usageTotalLogCount: usage.totalLogCount,
+ usageTruncated: usage.truncated === true,
+ usageDetailsComplete: usage.usageDetailsComplete === true,
+ usageMethod: usage.usageMethod,
+ quotaPerUnit: usage.quotaPerUnit,
+ usageStatus: "gateway-usage",
+ usageCheckedAt: checkedAt,
+ checkedAt,
+ }
+}
+
+export function applyBalanceUsage(record, currentBalance) {
+ const balanceUsage = calculateQuotaUsage(
+ record.quota,
+ currentBalance,
+ record.sharedChannel,
+ )
+ const updated = {
+ ...record,
+ currentBalance,
+ upstreamSpent: balanceUsage.spent,
+ balanceStatus: balanceUsage.usageStatus,
+ checkedAt: new Date().toISOString(),
+ }
+ return Number.isFinite(record.gatewaySpent)
+ ? updated
+ : { ...updated, ...balanceUsage }
+}
+
+export class ImportStore {
+ #mutation = Promise.resolve()
+
+ constructor({ stateStore } = {}) {
+ this.stateStore =
+ stateStore || createJsonStateStore({ key: "imports", path: IMPORTS_PATH })
+ }
+
+ async #readAll() {
+ const value = await this.stateStore.read([])
+ return Array.isArray(value) ? value : []
+ }
+
+ async #writeAll(records) {
+ await this.stateStore.write(records.slice(0, MAX_RECORDS))
+ }
+
+ async list() {
+ return await this.#readAll()
+ }
+
+ async findExistingFingerprints({ profileId, targetUrl, apiKeys }) {
+ const requested = new Set(
+ (apiKeys || []).map((apiKey) => keyIdentity(apiKey).keyFingerprint),
+ )
+ if (requested.size === 0) return new Set()
+ const matchesTarget = (record) =>
+ (profileId && record.profileId === profileId) ||
+ (!record.profileId && targetUrl && record.targetUrl === targetUrl)
+ return new Set(
+ (await this.#readAll())
+ .filter(
+ (record) =>
+ matchesTarget(record) && requested.has(record.keyFingerprint),
+ )
+ .map((record) => record.keyFingerprint),
+ )
+ }
+
+ async #mutate(operation) {
+ const result = this.#mutation.then(operation, operation)
+ this.#mutation = result.catch(() => {})
+ return await result
+ }
+
+ async record(input) {
+ return await this.#mutate(async () => {
+ const quota = Number.isFinite(input.quota) ? input.quota : null
+ const currentBalance = Number.isFinite(input.currentBalance)
+ ? input.currentBalance
+ : null
+ const usage = calculateQuotaUsage(
+ quota,
+ currentBalance,
+ input.sharedChannel === true,
+ )
+ const record = {
+ id: randomUUID(),
+ profileId: input.profileId,
+ targetName: input.targetName,
+ targetUrl: input.targetUrl,
+ providerName: input.providerName,
+ ...keyIdentity(input.apiKey),
+ quota,
+ currentBalance,
+ upstreamSpent: usage.spent,
+ balanceStatus: usage.usageStatus,
+ ...usage,
+ sharedChannel: input.sharedChannel === true,
+ keyIndex: Number.isInteger(input.keyIndex) ? input.keyIndex : null,
+ gatewaySpent: null,
+ requestCount: 0,
+ promptTokens: 0,
+ completionTokens: 0,
+ lastUsedAt: null,
+ importBatchId:
+ typeof input.importBatchId === "string" && input.importBatchId
+ ? input.importBatchId
+ : null,
+ batchItemIndex: Number.isInteger(input.batchItemIndex)
+ ? input.batchItemIndex
+ : null,
+ operation: input.operation,
+ channelId: input.channelId || null,
+ channelName: input.channelName,
+ importedAt: new Date().toISOString(),
+ checkedAt: currentBalance == null ? null : new Date().toISOString(),
+ }
+ await this.#writeAll([record, ...(await this.#readAll())])
+ return record
+ })
+ }
+
+ async updateBalance(id, currentBalance) {
+ return await this.#mutate(async () => {
+ const records = await this.#readAll()
+ const record = records.find((item) => item.id === id)
+ if (!record) throw new Error("导入记录不存在")
+ Object.assign(record, applyBalanceUsage(record, currentBalance))
+ await this.#writeAll(records)
+ return record
+ })
+ }
+
+ async updateUsage(id, usage, binding = {}) {
+ return await this.#mutate(async () => {
+ const records = await this.#readAll()
+ const record = records.find((item) => item.id === id)
+ if (!record) throw new Error("导入记录不存在")
+ Object.assign(record, applyGatewayUsage(record, usage, binding))
+ await this.#writeAll(records)
+ return record
+ })
+ }
+}
diff --git a/tools/channel-key-importer/src/modelPlan.js b/tools/channel-key-importer/src/modelPlan.js
new file mode 100644
index 0000000000..d3a6dcea47
--- /dev/null
+++ b/tools/channel-key-importer/src/modelPlan.js
@@ -0,0 +1,95 @@
+const MAX_MODELS = 2000
+const MAX_MAPPINGS = 500
+const MAX_MODEL_NAME_LENGTH = 200
+
+const normalizeModelName = (value) => {
+ const model = String(value || "").trim()
+ if (!model) return ""
+ if (model.length > MAX_MODEL_NAME_LENGTH) {
+ throw new Error("模型名称过长")
+ }
+ if (model.includes(",")) {
+ throw new Error(`模型名称不能包含逗号:${model}`)
+ }
+ return model
+}
+
+const normalizeModelList = (values) => {
+ const result = []
+ const seen = new Set()
+ for (const value of Array.isArray(values) ? values : []) {
+ const model = normalizeModelName(value)
+ if (!model || seen.has(model)) continue
+ seen.add(model)
+ result.push(model)
+ if (result.length > MAX_MODELS) throw new Error("模型数量超过安全上限")
+ }
+ return result
+}
+
+export function buildProviderPrefixMappings(models) {
+ const candidates = new Map()
+ for (const actualModel of normalizeModelList(models)) {
+ const slashIndex = actualModel.indexOf("/")
+ if (slashIndex < 1 || slashIndex === actualModel.length - 1) continue
+ const standardModel = actualModel.slice(slashIndex + 1)
+ const targets = candidates.get(standardModel) || []
+ targets.push(actualModel)
+ candidates.set(standardModel, targets)
+ }
+
+ return [...candidates.entries()]
+ .filter(([, targets]) => targets.length === 1)
+ .map(([standardModel, [actualModel]]) => ({ standardModel, actualModel }))
+}
+
+export function buildModelPlan({
+ fetchedModels,
+ manualModels,
+ mappings,
+ hideMappedActualModels = false,
+ allowMappedStandardModels = false,
+}) {
+ const actualModels = normalizeModelList([
+ ...normalizeModelList(fetchedModels),
+ ...normalizeModelList(manualModels),
+ ])
+ const actualModelSet = new Set(actualModels)
+ const modelMapping = {}
+
+ if (!Array.isArray(mappings)) throw new Error("模型映射格式不正确")
+ if (mappings.length > MAX_MAPPINGS)
+ throw new Error("模型映射数量超过安全上限")
+
+ for (const entry of mappings) {
+ const standardModel = normalizeModelName(entry?.standardModel)
+ const actualModel = normalizeModelName(entry?.actualModel)
+ if (!standardModel && !actualModel) continue
+ if (!standardModel || !actualModel)
+ throw new Error("模型映射两边都必须填写")
+ if (!actualModelSet.has(actualModel)) {
+ throw new Error(`映射目标不在已发现或手动模型中:${actualModel}`)
+ }
+ if (standardModel === actualModel) continue
+ if (!allowMappedStandardModels && actualModelSet.has(standardModel)) {
+ throw new Error(`标准名称与已有实际模型冲突:${standardModel}`)
+ }
+ if (
+ modelMapping[standardModel] &&
+ modelMapping[standardModel] !== actualModel
+ ) {
+ throw new Error(`标准名称存在多个映射目标:${standardModel}`)
+ }
+ modelMapping[standardModel] = actualModel
+ }
+
+ const mappedTargets = new Set(Object.values(modelMapping))
+ const visibleActualModels = hideMappedActualModels
+ ? actualModels.filter((model) => !mappedTargets.has(model))
+ : actualModels
+ const models = normalizeModelList([
+ ...visibleActualModels,
+ ...Object.keys(modelMapping),
+ ])
+ return { models, modelMapping }
+}
diff --git a/tools/channel-key-importer/src/newApiClient.js b/tools/channel-key-importer/src/newApiClient.js
new file mode 100644
index 0000000000..0d2a30b88b
--- /dev/null
+++ b/tools/channel-key-importer/src/newApiClient.js
@@ -0,0 +1,904 @@
+const PAGE_SIZE = 100
+const MAX_CHANNEL_PAGES = 20
+const MAX_USAGE_PAGES = 500
+const RATE_LIMIT_PATTERN =
+ /(?:\b429\b|请求次数过多|too many requests|rate[ -]?limit)/i
+const READ_CACHE_TTL_MS = 30_000
+const readCache = new Map()
+
+const requestHeaders = (config) => {
+ const headers = {
+ "Content-Type": "application/json",
+ "New-API-User": config.userId,
+ }
+ if (config.sessionCookie) {
+ headers.Cookie = config.sessionCookie
+ } else {
+ headers.Authorization = `Bearer ${config.adminToken}`
+ }
+ return headers
+}
+
+const getResponseCookies = (response) => {
+ const values =
+ typeof response.headers.getSetCookie === "function"
+ ? response.headers.getSetCookie()
+ : [response.headers.get("set-cookie")].filter(Boolean)
+ return values
+ .map((value) => String(value).split(";", 1)[0].trim())
+ .filter((value) => value && !/[\r\n]/.test(value))
+ .join("; ")
+}
+
+const safeUpstreamMessage = (payload, secrets) => {
+ let message = String(payload?.message || payload?.error || "")
+ .replace(/[\r\n\t]+/g, " ")
+ .trim()
+ for (const secret of secrets) {
+ if (secret) message = message.split(String(secret)).join("[已隐藏]")
+ }
+ if (!message || / Date.now()) return cached.value
+ const value = await loader()
+ readCache.set(key, {
+ expiresAt: Date.now() + READ_CACHE_TTL_MS,
+ value,
+ })
+ return value
+}
+
+const channelListItems = (payload) => {
+ const data = payload?.data
+ if (Array.isArray(data)) return data
+ if (Array.isArray(data?.items)) return data.items
+ return []
+}
+
+const parseJsonObject = (value) => {
+ try {
+ const parsed = JSON.parse(String(value || "{}"))
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
+ ? parsed
+ : {}
+ } catch {
+ return {}
+ }
+}
+
+const splitChannelValues = (value) =>
+ [...new Set(String(value || "").split(","))]
+ .map((item) => item.trim())
+ .filter(Boolean)
+
+const channelTemplateSummary = (channel) => ({
+ id: Number(channel.id),
+ name: String(channel.name || `渠道 #${channel.id}`),
+ status: Number(channel.status) === 1 ? "正常" : "已停用",
+ baseUrl: String(channel.base_url || ""),
+ groups: splitChannelValues(channel.group),
+ modelCount: splitChannelValues(channel.models).length,
+ priority: Number(channel.priority) || 0,
+ weight: Number(channel.weight) || 0,
+})
+
+export async function verifyNewApi(config) {
+ await requestNewApiRead(
+ `${config.targetUrl}/api/channel/?p=1&page_size=1`,
+ { headers: requestHeaders(config) },
+ [config.adminToken, config.sessionCookie].filter(Boolean),
+ )
+}
+
+export async function discoverNewApiUserId({
+ targetUrl,
+ adminToken,
+ preferredUserId = "",
+}) {
+ const preferred = Number(preferredUserId)
+ const candidates = [
+ ...(Number.isInteger(preferred) && preferred > 0 ? [preferred] : []),
+ ...Array.from({ length: 50 }, (_, index) => index + 1),
+ ].filter((value, index, values) => values.indexOf(value) === index)
+
+ for (const userId of candidates) {
+ let response
+ try {
+ response = await fetch(`${targetUrl}/api/channel/?p=1&page_size=1`, {
+ headers: requestHeaders({
+ targetUrl,
+ adminToken,
+ userId: String(userId),
+ }),
+ signal: AbortSignal.timeout(10_000),
+ })
+ } catch {
+ throw new Error("无法连接 New API,请检查地址和网络")
+ }
+ let payload = null
+ try {
+ payload = await response.json()
+ } catch {
+ // Fall through to the generic response error below.
+ }
+ if (response.ok && payload?.success !== false) return String(userId)
+ if (response.status === 401) continue
+ const message = safeUpstreamMessage(payload, [adminToken])
+ if (message) throw new Error(`New API:${message}`)
+ throw new Error(`New API 管理员认证失败(HTTP ${response.status})`)
+ }
+ throw new Error("令牌有效,但未能自动找到对应用户 ID")
+}
+
+export async function fetchNewApiGroups(config) {
+ // New API exposes the admin-configured channel groups as a string array.
+ // https://github.com/QuantumNous/new-api/blob/main/controller/group.go
+ return await cachedRead(
+ `groups:${config.targetUrl}:${config.userId}`,
+ async () => {
+ const payload = await requestNewApiRead(
+ `${config.targetUrl}/api/group/`,
+ { headers: requestHeaders(config) },
+ [config.adminToken, config.sessionCookie].filter(Boolean),
+ )
+ const groups = Array.isArray(payload?.data) ? payload.data : []
+ return [...new Set(groups)]
+ .filter((group) => typeof group === "string")
+ .map((group) => group.trim())
+ .filter(Boolean)
+ .slice(0, 200)
+ },
+ )
+}
+
+export async function fetchNewApiDefaultModels(config, channelType) {
+ // New API publishes the model list built into each channel adaptor. This
+ // lets an administrator create channels without sending every provider key
+ // upstream merely to discover models first.
+ // https://github.com/QuantumNous/new-api/blob/main/controller/model.go
+ return await cachedRead(
+ `models:${config.targetUrl}:${config.userId}:${channelType}`,
+ async () => {
+ const payload = await requestNewApiRead(
+ `${config.targetUrl}/api/models`,
+ { headers: requestHeaders(config) },
+ [config.adminToken, config.sessionCookie].filter(Boolean),
+ )
+ const models = payload?.data?.[String(channelType)]
+ return [...new Set(Array.isArray(models) ? models : [])]
+ .filter((model) => typeof model === "string")
+ .map((model) => model.trim())
+ .filter(Boolean)
+ .slice(0, 2000)
+ },
+ )
+}
+
+export async function fetchNewApiSystemName(targetUrl) {
+ // New API exposes its configured display name through the public status API.
+ // https://github.com/QuantumNous/new-api/blob/main/controller/misc.go
+ const payload = await requestNewApiRead(`${targetUrl}/api/status`, {
+ headers: { "Content-Type": "application/json" },
+ signal: AbortSignal.timeout(4_000),
+ })
+ return String(payload?.data?.system_name || "")
+ .trim()
+ .slice(0, 80)
+}
+
+const wait = (milliseconds) =>
+ new Promise((resolve) => setTimeout(resolve, milliseconds))
+
+const NEW_API_READ_RETRY_DELAYS = [2_000]
+const MAX_INLINE_RATE_LIMIT_WAIT_MS = 5_000
+
+export function parseRetryAfterMs(value, now = Date.now()) {
+ const normalized = String(value ?? "").trim()
+ if (!normalized) return null
+ const seconds = Number(normalized)
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000
+ const timestamp = Date.parse(normalized)
+ if (!Number.isFinite(timestamp)) return null
+ return Math.max(0, timestamp - now)
+}
+
+export const isRateLimitError = (error) =>
+ error?.retryable === true ||
+ error?.rateLimited === true ||
+ error?.status === 429 ||
+ RATE_LIMIT_PATTERN.test(String(error?.message || error?.error || ""))
+
+async function requestChannelMutation(url, options, secrets) {
+ for (let attempt = 0; ; attempt += 1) {
+ try {
+ return await requestJson(url, options, secrets)
+ } catch (error) {
+ if (!isRateLimitError(error) || attempt >= 1) {
+ throw error
+ }
+ // Never blindly replay a channel mutation. Without an explicit short
+ // Retry-After, the caller encrypts the unsubmitted Key and resumes it
+ // from the paced queue instead of increasing New API's critical limit.
+ if (!Number.isFinite(error.retryAfterMs)) throw error
+ const delay = error.retryAfterMs
+ if (delay > MAX_INLINE_RATE_LIMIT_WAIT_MS) throw error
+ await wait(delay)
+ }
+ }
+}
+
+async function requestNewApiRead(url, options, secrets = []) {
+ for (let attempt = 0; ; attempt += 1) {
+ try {
+ return await requestJson(url, options, secrets)
+ } catch (error) {
+ if (
+ !isRateLimitError(error) ||
+ attempt >= NEW_API_READ_RETRY_DELAYS.length
+ ) {
+ throw error
+ }
+ const delay = error.retryAfterMs ?? NEW_API_READ_RETRY_DELAYS[attempt]
+ if (delay > MAX_INLINE_RATE_LIMIT_WAIT_MS) throw error
+ await wait(delay)
+ }
+ }
+}
+
+async function requestUsageJson(url, config) {
+ const secrets = [config.adminToken, config.sessionCookie].filter(Boolean)
+ for (let attempt = 0; ; attempt += 1) {
+ try {
+ return await requestJson(
+ url,
+ { headers: requestHeaders(config) },
+ secrets,
+ )
+ } catch (error) {
+ if (
+ error?.status !== 429 ||
+ attempt >= 1 ||
+ !Number.isFinite(error.retryAfterMs) ||
+ error.retryAfterMs > MAX_INLINE_RATE_LIMIT_WAIT_MS
+ ) {
+ throw error
+ }
+ await wait(error.retryAfterMs)
+ }
+ }
+}
+
+const usageQueryParams = (input) => {
+ const params = new URLSearchParams({
+ type: "2",
+ channel: String(input.channelId),
+ start_timestamp: String(input.startTimestamp),
+ })
+ if (Number.isFinite(input.endTimestamp)) {
+ params.set("end_timestamp", String(input.endTimestamp))
+ }
+ return params
+}
+
+async function scanChannelUsageLogs(config, input) {
+ const pageSize = 100
+ let quota = 0
+ let requestCount = 0
+ let promptTokens = 0
+ let completionTokens = 0
+ let lastUsedAt = null
+ let scannedLogCount = 0
+ let totalLogCount = null
+ let truncated = false
+
+ for (let page = 1; page <= MAX_USAGE_PAGES; page += 1) {
+ const params = usageQueryParams(input)
+ params.set("p", String(page))
+ params.set("page_size", String(pageSize))
+ const payload = await requestUsageJson(
+ `${config.targetUrl}/api/log/?${params}`,
+ config,
+ )
+ const items = Array.isArray(payload?.data?.items) ? payload.data.items : []
+ const reportedTotal = Number(payload?.data?.total)
+ if (Number.isFinite(reportedTotal) && reportedTotal >= 0) {
+ totalLogCount = reportedTotal
+ }
+ scannedLogCount += items.length
+ for (const item of items) {
+ if (Number.isInteger(input.keyIndex)) {
+ let other = null
+ try {
+ other = JSON.parse(item?.other || "{}")
+ } catch {
+ // A malformed historical log cannot be attributed to one multi-key.
+ }
+ if (Number(other?.admin_info?.multi_key_index) !== input.keyIndex) {
+ continue
+ }
+ }
+ quota += Number(item?.quota) || 0
+ promptTokens += Number(item?.prompt_tokens) || 0
+ completionTokens += Number(item?.completion_tokens) || 0
+ requestCount += 1
+ const createdAt = Number(item?.created_at)
+ if (
+ Number.isFinite(createdAt) &&
+ (!lastUsedAt || createdAt > lastUsedAt)
+ ) {
+ lastUsedAt = createdAt
+ }
+ }
+ if (
+ items.length < pageSize ||
+ (totalLogCount != null && scannedLogCount >= totalLogCount)
+ ) {
+ break
+ }
+ if (page === MAX_USAGE_PAGES) truncated = true
+ }
+
+ return {
+ quota,
+ requestCount,
+ promptTokens,
+ completionTokens,
+ lastUsedAt,
+ scannedLogCount,
+ totalLogCount,
+ truncated,
+ usageDetailsComplete: !truncated,
+ usageMethod: "log-scan",
+ }
+}
+
+async function fetchIndependentChannelUsage(config, input) {
+ const params = usageQueryParams(input)
+ // New API's admin stat endpoint performs the quota sum in the database. It
+ // is exact for a one-key channel and avoids hundreds of paginated requests.
+ // https://github.com/QuantumNous/new-api/blob/main/controller/log.go
+ // https://github.com/QuantumNous/new-api/blob/main/model/log.go
+ const statPayload = await requestUsageJson(
+ `${config.targetUrl}/api/log/stat?${params}`,
+ config,
+ )
+ const quota = Number(statPayload?.data?.quota)
+ if (!Number.isFinite(quota)) {
+ throw new Error("New API 没有返回可用的渠道消费统计")
+ }
+
+ let requestCount = null
+ let promptTokens = null
+ let completionTokens = null
+ let lastUsedAt = null
+ let scannedLogCount = 0
+ let totalLogCount = null
+ let usageDetailsComplete = false
+ try {
+ params.set("p", "1")
+ params.set("page_size", "100")
+ const detailPayload = await requestUsageJson(
+ `${config.targetUrl}/api/log/?${params}`,
+ config,
+ )
+ const items = Array.isArray(detailPayload?.data?.items)
+ ? detailPayload.data.items
+ : []
+ const reportedTotal = Number(detailPayload?.data?.total)
+ totalLogCount = Number.isFinite(reportedTotal)
+ ? reportedTotal
+ : items.length
+ requestCount = totalLogCount
+ scannedLogCount = items.length
+ for (const item of items) {
+ const createdAt = Number(item?.created_at)
+ if (
+ Number.isFinite(createdAt) &&
+ (!lastUsedAt || createdAt > lastUsedAt)
+ ) {
+ lastUsedAt = createdAt
+ }
+ }
+ if (totalLogCount <= items.length) {
+ promptTokens = items.reduce(
+ (total, item) => total + (Number(item?.prompt_tokens) || 0),
+ 0,
+ )
+ completionTokens = items.reduce(
+ (total, item) => total + (Number(item?.completion_tokens) || 0),
+ 0,
+ )
+ usageDetailsComplete = true
+ }
+ } catch (error) {
+ if (error?.status !== 429) throw error
+ // The exact cost is already available from /api/log/stat. Keep it instead
+ // of failing the whole refresh when optional log details are rate limited.
+ }
+
+ return {
+ quota,
+ requestCount,
+ promptTokens,
+ completionTokens,
+ lastUsedAt,
+ scannedLogCount,
+ totalLogCount,
+ truncated: false,
+ usageDetailsComplete,
+ usageMethod: "database-stat",
+ }
+}
+
+export async function fetchChannelUsage(config, input) {
+ let usage
+ if (Number.isInteger(input.keyIndex)) {
+ usage = await scanChannelUsageLogs(config, input)
+ } else {
+ try {
+ usage = await fetchIndependentChannelUsage(config, input)
+ } catch (error) {
+ if (![404, 405].includes(error?.status)) throw error
+ // Compatibility fallback for older New API forks without /api/log/stat.
+ usage = await scanChannelUsageLogs(config, input)
+ }
+ }
+
+ const statusPayload = await requestUsageJson(
+ `${config.targetUrl}/api/status`,
+ config,
+ )
+ const quotaPerUnit = Number(statusPayload?.data?.quota_per_unit) || 500_000
+ return {
+ spentUsd: Number((usage.quota / quotaPerUnit).toFixed(8)),
+ requestCount: usage.requestCount,
+ promptTokens: usage.promptTokens,
+ completionTokens: usage.completionTokens,
+ lastUsedAt: usage.lastUsedAt,
+ quotaPerUnit,
+ scannedLogCount: usage.scannedLogCount,
+ totalLogCount: usage.totalLogCount,
+ truncated: usage.truncated,
+ usageDetailsComplete: usage.usageDetailsComplete,
+ usageMethod: usage.usageMethod,
+ }
+}
+
+export async function loginNewApi({ targetUrl, username, password }) {
+ // New API password login establishes a server-side session and returns the
+ // user id used by the required New-Api-User header.
+ // https://github.com/QuantumNous/new-api/blob/main/controller/user.go
+ let response
+ try {
+ response = await fetch(`${targetUrl}/api/user/login`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username, password }),
+ redirect: "error",
+ signal: AbortSignal.timeout(15_000),
+ })
+ } catch {
+ throw new Error("无法连接 New API,请检查地址和网络")
+ }
+
+ let payload = null
+ try {
+ payload = await response.json()
+ } catch {
+ // Never expose upstream response bodies on a credential flow.
+ }
+ if (payload?.data?.require_2fa === true) {
+ throw new Error("该账号启用了两步验证,请改用下方的访问令牌连接")
+ }
+ const userId = Number(payload?.data?.id)
+ const sessionCookie = getResponseCookies(response)
+ if (
+ !response.ok ||
+ payload?.success !== true ||
+ !Number.isInteger(userId) ||
+ userId <= 0 ||
+ !sessionCookie
+ ) {
+ throw new Error(
+ "登录失败,请检查用户名和密码;启用验证码的站点请使用访问令牌",
+ )
+ }
+
+ return {
+ userId: String(userId),
+ username: String(payload.data.username || username),
+ role: Number(payload.data.role || 0),
+ sessionCookie,
+ }
+}
+
+export async function fetchChannelModels(config, input) {
+ // New API performs provider-specific model discovery for every built-in type.
+ // Contract: https://github.com/QuantumNous/new-api-docs/blob/main/docs/api/fei-channel-management.md#拉取全部渠道模型
+ const payload = await requestNewApiRead(
+ `${config.targetUrl}/api/channel/fetch_models`,
+ {
+ method: "POST",
+ headers: requestHeaders(config),
+ body: JSON.stringify({
+ base_url: input.baseUrl,
+ type: input.provider.channelType,
+ key: input.apiKey,
+ }),
+ },
+ [config.adminToken, config.sessionCookie, input.apiKey].filter(Boolean),
+ )
+ const rawModels = Array.isArray(payload?.data) ? payload.data : []
+ return [...new Set(rawModels)]
+ .filter((model) => typeof model === "string")
+ .map((model) => model.trim())
+ .filter(Boolean)
+ .sort((left, right) => left.localeCompare(right))
+ .slice(0, 2000)
+}
+
+export async function listChannelTemplates(config, provider) {
+ const templates = []
+ for (let page = 1; page <= MAX_CHANNEL_PAGES; page += 1) {
+ let payload
+ try {
+ payload = await requestNewApiRead(
+ `${config.targetUrl}/api/channel/?p=${page}&page_size=${PAGE_SIZE}&type=${provider.channelType}`,
+ { headers: requestHeaders(config) },
+ [config.adminToken, config.sessionCookie].filter(Boolean),
+ )
+ } catch (error) {
+ // Large New API installations need several pages. Keep already-read
+ // templates when a later page has a transient network failure.
+ if (page === 1 || templates.length === 0) throw error
+ break
+ }
+ const items = channelListItems(payload)
+ templates.push(
+ ...items
+ .filter((item) => Number(item?.type) === provider.channelType)
+ .map(channelTemplateSummary),
+ )
+ if (items.length < PAGE_SIZE) break
+ }
+ return [
+ ...new Map(
+ templates
+ .filter((item) => Number.isInteger(item.id) && item.id > 0)
+ .map((item) => [item.id, item]),
+ ).values(),
+ ]
+ .sort((left, right) => right.id - left.id)
+ .slice(0, 500)
+}
+
+export async function fetchChannelTemplate(config, channelId, provider) {
+ const id = Number(channelId)
+ if (!Number.isInteger(id) || id <= 0) throw new Error("请选择已有渠道")
+ const payload = await requestNewApiRead(
+ `${config.targetUrl}/api/channel/${id}`,
+ { headers: requestHeaders(config) },
+ [config.adminToken, config.sessionCookie].filter(Boolean),
+ )
+ const channel = payload?.data
+ if (!channel || Number(channel.type) !== provider.channelType) {
+ throw new Error("所选渠道类型与当前 Key 来源不一致")
+ }
+ const modelMapping = parseJsonObject(channel.model_mapping)
+ return {
+ ...channelTemplateSummary(channel),
+ models: splitChannelValues(channel.models),
+ modelMappings: Object.entries(modelMapping).map(
+ ([standardModel, actualModel]) => ({
+ standardModel,
+ actualModel: String(actualModel || ""),
+ }),
+ ),
+ channelOther: String(channel.other || ""),
+ channelSettings: parseJsonObject(channel.settings),
+ advanced: {
+ openai_organization: channel.openai_organization ?? "",
+ test_model: channel.test_model ?? "",
+ status_code_mapping: channel.status_code_mapping ?? "",
+ priority: Number(channel.priority) || 0,
+ weight: Number(channel.weight) || 0,
+ auto_ban: Number(channel.auto_ban ?? 1),
+ tag: channel.tag ?? "",
+ setting: channel.setting ?? "",
+ param_override: channel.param_override ?? "",
+ header_override: channel.header_override ?? "",
+ remark: channel.remark ?? "",
+ },
+ }
+}
+
+export async function findSimilarChannels(config, provider) {
+ const matches = []
+ // New API can filter the channel list by type. Duplicate warnings only need
+ // a representative set, so one filtered page avoids scanning up to twenty
+ // global pages and consuming the shared per-IP management API quota.
+ // https://github.com/QuantumNous/new-api-docs/blob/main/docs/api/fei-channel-management.md
+ const payload = await requestNewApiRead(
+ `${config.targetUrl}/api/channel/?p=1&page_size=${PAGE_SIZE}&type=${provider.channelType}`,
+ { headers: requestHeaders(config) },
+ [config.adminToken, config.sessionCookie].filter(Boolean),
+ )
+ for (const item of channelListItems(payload)) {
+ const sameType = Number(item?.type) === provider.channelType
+ const providerBaseUrl = String(
+ provider.resolvedBaseUrl || provider.baseUrl || "",
+ ).replace(/\/+$/, "")
+ const sameBaseUrl = Boolean(
+ providerBaseUrl &&
+ String(item?.base_url || "").replace(/\/+$/, "") === providerBaseUrl,
+ )
+ if (sameType || sameBaseUrl) {
+ matches.push({
+ id: item.id,
+ name: String(item.name || `渠道 #${item.id}`),
+ status: Number(item.status) === 1 ? "正常" : "已停用",
+ isMultiKey: item?.channel_info?.is_multi_key === true,
+ multiKeySize: Number(item?.channel_info?.multi_key_size) || 0,
+ })
+ }
+ if (matches.length >= 20) break
+ }
+ return matches.slice(0, 20)
+}
+
+export async function updateExistingChannelKey(config, input) {
+ // New API only applies key_mode=append to a channel already configured for
+ // multiple keys; otherwise its PUT contract replaces the single saved key.
+ // Its update contract also accepts optional priority and weight integers.
+ // https://github.com/QuantumNous/new-api/blob/main/controller/channel.go
+ // https://github.com/QuantumNous/new-api-docs/blob/main/docs/api/fei-channel-management.md
+ return await requestChannelMutation(
+ `${config.targetUrl}/api/channel/`,
+ {
+ method: "PUT",
+ headers: requestHeaders(config),
+ body: JSON.stringify({
+ id: input.channelId,
+ key: input.apiKey,
+ ...(input.append ? { key_mode: "append" } : {}),
+ ...(Number.isInteger(input.priority)
+ ? { priority: input.priority }
+ : {}),
+ ...(Number.isInteger(input.weight) ? { weight: input.weight } : {}),
+ }),
+ },
+ [config.adminToken, config.sessionCookie, input.apiKey].filter(Boolean),
+ )
+}
+
+export async function setChannelEnabled(config, channelId) {
+ return await requestChannelMutation(
+ `${config.targetUrl}/api/channel/${channelId}/status`,
+ {
+ method: "POST",
+ headers: requestHeaders(config),
+ body: JSON.stringify({ status: 1 }),
+ },
+ [config.adminToken, config.sessionCookie].filter(Boolean),
+ )
+}
+
+const buildChannelPayload = (input, key) => ({
+ ...(input.templateConfig || {}),
+ name: input.name,
+ type: input.provider.channelType,
+ key,
+ base_url: input.baseUrl,
+ models: input.models.join(","),
+ model_mapping: JSON.stringify(input.modelMapping || {}),
+ settings: JSON.stringify(input.channelSettings || {}),
+ other: input.channelOther || "",
+ group: input.groups.join(","),
+ groups: input.groups,
+ // New API accepts integer priority/weight fields when creating a channel.
+ // Higher priority wins; equal-priority channels split by weight ratio.
+ // https://github.com/QuantumNous/new-api-docs/blob/main/docs/api/fei-channel-management.md
+ priority: input.priority ?? input.templateConfig?.priority ?? 0,
+ weight: input.weight ?? input.templateConfig?.weight ?? 0,
+ status: 1,
+})
+
+export async function createNewApiChannel(config, input) {
+ // New API channel contract follows QuantumNous/new-api's POST /api/channel/.
+ const payload = {
+ mode: "single",
+ channel: buildChannelPayload(input, input.apiKey),
+ }
+
+ return await requestChannelMutation(
+ `${config.targetUrl}/api/channel/`,
+ {
+ method: "POST",
+ headers: requestHeaders(config),
+ body: JSON.stringify(payload),
+ },
+ [config.adminToken, config.sessionCookie, input.apiKey].filter(Boolean),
+ )
+}
+
+export async function createNewApiMultiKeyChannel(config, input) {
+ // New API's multi_to_single mode stores all submitted keys in one channel
+ // and records the selected key index in consumption logs.
+ // https://github.com/QuantumNous/new-api/blob/main/controller/channel.go
+ let key = input.apiKeys.join("\n")
+ if (
+ input.provider.channelType === 41 &&
+ input.channelSettings?.vertex_key_type !== "api_key"
+ ) {
+ try {
+ key = JSON.stringify(input.apiKeys.map((value) => JSON.parse(value)))
+ } catch {
+ throw new Error("Vertex AI 批量服务账号必须是有效 JSON")
+ }
+ }
+ return await requestChannelMutation(
+ `${config.targetUrl}/api/channel/`,
+ {
+ method: "POST",
+ headers: requestHeaders(config),
+ body: JSON.stringify({
+ mode: "multi_to_single",
+ multi_key_mode: "random",
+ channel: buildChannelPayload(input, key),
+ }),
+ },
+ [config.adminToken, config.sessionCookie, ...input.apiKeys].filter(Boolean),
+ )
+}
+
+export async function findCreatedChannel(config, input) {
+ const payload = await requestNewApiRead(
+ `${config.targetUrl}/api/channel/search?keyword=${encodeURIComponent(input.name)}`,
+ { headers: requestHeaders(config) },
+ [config.adminToken, config.sessionCookie].filter(Boolean),
+ )
+ const expectedBaseUrl = String(input.baseUrl || "").replace(/\/+$/, "")
+ const matches = channelListItems(payload).filter((channel) => {
+ const sameName = String(channel?.name || "") === input.name
+ const sameType = Number(channel?.type) === input.provider.channelType
+ const sameBaseUrl =
+ String(channel?.base_url || "").replace(/\/+$/, "") === expectedBaseUrl
+ return sameName && sameType && sameBaseUrl
+ })
+ return (
+ matches.sort((left, right) => Number(right.id) - Number(left.id))[0] ?? null
+ )
+}
+
+const createdChannelIdentity = (input) =>
+ [
+ input.name,
+ input.provider.channelType,
+ String(input.baseUrl || "").replace(/\/+$/, ""),
+ ].join("\u0000")
+
+export async function findCreatedChannels(config, inputs) {
+ const expected = new Map(
+ inputs.map((input) => [createdChannelIdentity(input), input]),
+ )
+ const matches = new Map()
+ if (expected.size === 0) return matches
+
+ for (let page = 1; page <= MAX_CHANNEL_PAGES; page += 1) {
+ const payload = await requestNewApiRead(
+ `${config.targetUrl}/api/channel/?p=${page}&page_size=${PAGE_SIZE}`,
+ { headers: requestHeaders(config) },
+ [config.adminToken, config.sessionCookie].filter(Boolean),
+ )
+ const items = channelListItems(payload)
+ for (const channel of items) {
+ const identity = createdChannelIdentity({
+ name: String(channel?.name || ""),
+ provider: { channelType: Number(channel?.type) },
+ baseUrl: String(channel?.base_url || ""),
+ })
+ if (!expected.has(identity) || matches.has(identity)) continue
+ matches.set(identity, channel)
+ }
+ if (matches.size === expected.size || items.length < PAGE_SIZE) break
+ }
+ return new Map(
+ inputs.map((input) => [input, matches.get(createdChannelIdentity(input))]),
+ )
+}
+
+export async function refreshChannelBalance(config, channelId) {
+ // New API converts provider-specific balance responses to a USD channel
+ // balance. Unsupported providers return a normal API failure instead.
+ // https://github.com/QuantumNous/new-api/blob/main/model/channel.go
+ // https://github.com/QuantumNous/new-api-docs-v1/blob/main/content/docs/ja/api/management/channel-management/channel-update_balance-get.mdx
+ let response
+ try {
+ response = await fetch(
+ `${config.targetUrl}/api/channel/update_balance/${channelId}`,
+ {
+ headers: requestHeaders(config),
+ signal: AbortSignal.timeout(15_000),
+ },
+ )
+ } catch {
+ return {
+ status: "unavailable",
+ reason: "余额查询暂时无法连接,请稍后重试",
+ }
+ }
+
+ let payload = null
+ try {
+ payload = await response.json()
+ } catch {
+ // Keep provider response bodies out of UI and logs.
+ }
+
+ const balance = Number(payload?.balance ?? payload?.data?.balance)
+ if (!response.ok || payload?.success === false || !Number.isFinite(balance)) {
+ return {
+ status: "unsupported",
+ reason: "该渠道或上游暂不支持自动查询余额",
+ }
+ }
+
+ return {
+ status: "available",
+ currency: "USD",
+ balance,
+ }
+}
diff --git a/tools/channel-key-importer/src/previewStore.js b/tools/channel-key-importer/src/previewStore.js
new file mode 100644
index 0000000000..f7b958479e
--- /dev/null
+++ b/tools/channel-key-importer/src/previewStore.js
@@ -0,0 +1,56 @@
+import { randomUUID } from "node:crypto"
+
+const PREVIEW_TTL_MS = 5 * 60 * 1000
+
+export class PreviewStore {
+ #entries = new Map()
+
+ create(value) {
+ this.prune()
+ const id = randomUUID()
+ this.#entries.set(id, {
+ expiresAt: Date.now() + PREVIEW_TTL_MS,
+ value,
+ })
+ return id
+ }
+
+ take(id) {
+ const value = this.claim(id)
+ this.delete(id)
+ return value
+ }
+
+ claim(id) {
+ const value = this.get(id)
+ const entry = this.#entries.get(id)
+ if (entry.claimed) throw new Error("这批 Key 正在写入,请勿重复提交")
+ entry.claimed = true
+ return value
+ }
+
+ release(id) {
+ const entry = this.#entries.get(id)
+ if (entry) entry.claimed = false
+ }
+
+ get(id) {
+ const entry = this.#entries.get(id)
+ if (!entry || entry.expiresAt <= Date.now()) {
+ this.#entries.delete(id)
+ throw new Error("预览已过期,请重新分析 Key")
+ }
+ return entry.value
+ }
+
+ delete(id) {
+ this.#entries.delete(id)
+ }
+
+ prune() {
+ const now = Date.now()
+ for (const [id, entry] of this.#entries) {
+ if (entry.expiresAt <= now) this.#entries.delete(id)
+ }
+ }
+}
diff --git a/tools/channel-key-importer/src/providerIcons.js b/tools/channel-key-importer/src/providerIcons.js
new file mode 100644
index 0000000000..8cf09700fb
--- /dev/null
+++ b/tools/channel-key-importer/src/providerIcons.js
@@ -0,0 +1,80 @@
+import { readFile } from "node:fs/promises"
+import { join } from "node:path"
+import { fileURLToPath } from "node:url"
+
+const LOBE_ICON_ROOT = fileURLToPath(
+ new URL("../../../node_modules/@lobehub/icons/es/", import.meta.url),
+)
+
+// Channel ids map only to the vendor represented by that channel. Third-party
+// OpenAI-compatible services intentionally keep their letter fallback.
+export const PROVIDER_ICON_NAMES = Object.freeze({
+ openai: "OpenAI",
+ midjourney: "Midjourney",
+ azure: "Azure",
+ ollama: "Ollama",
+ "midjourney-plus": "Midjourney",
+ palm: "PaLM",
+ anthropic: "Anthropic",
+ baidu: "Baidu",
+ zhipu: "Zhipu",
+ ali: "AlibabaCloud",
+ xunfei: "IFlyTekCloud",
+ 360: "Ai360",
+ openrouter: "OpenRouter",
+ fastgpt: "FastGPT",
+ tencent: "Hunyuan",
+ gemini: "Gemini",
+ moonshot: "Moonshot",
+ "zhipu-v4": "Zhipu",
+ perplexity: "Perplexity",
+ aws: "Bedrock",
+ cohere: "Cohere",
+ minimax: "Minimax",
+ suno: "Suno",
+ dify: "Dify",
+ jina: "Jina",
+ cloudflare: "Cloudflare",
+ siliconflow: "SiliconCloud",
+ "vertex-ai": "VertexAI",
+ mistral: "Mistral",
+ deepseek: "DeepSeek",
+ volcengine: "ByteDance",
+ "baidu-v2": "BaiduCloud",
+ xai: "Grok",
+ coze: "Coze",
+ kling: "Kling",
+ jimeng: "Jimeng",
+ vidu: "Vidu",
+ submodel: "SubModel",
+ "doubao-video": "Doubao",
+ sora: "OpenAI",
+ replicate: "Replicate",
+ codex: "OpenAI",
+})
+
+const svgCache = new Map()
+
+export async function getProviderIconSvg(providerId) {
+ const iconName = PROVIDER_ICON_NAMES[providerId]
+ if (!iconName) return null
+ if (svgCache.has(iconName)) return svgCache.get(iconName)
+
+ // @lobehub/icons is already the app's MIT-licensed icon source. Its Mono
+ // component contains the canonical 24px SVG paths, which are served locally.
+ const source = await readFile(
+ join(LOBE_ICON_ROOT, iconName, "components", "Mono.js"),
+ "utf8",
+ )
+ const viewBox = source.match(/viewBox: "([^"]+)"/)?.[1] || "0 0 24 24"
+ const paths = [...source.matchAll(/\bd: "([^"]+)"/g)].map((match) => match[1])
+ if (paths.length === 0) return null
+ const svg = [
+ `",
+ ].join("")
+ svgCache.set(iconName, svg)
+ return svg
+}
diff --git a/tools/channel-key-importer/src/providers.js b/tools/channel-key-importer/src/providers.js
new file mode 100644
index 0000000000..356547099c
--- /dev/null
+++ b/tools/channel-key-importer/src/providers.js
@@ -0,0 +1,269 @@
+// Source of truth: QuantumNous/new-api constant/channel.go.
+// The numeric type and default base URL are protocol values sent to New API.
+// https://github.com/QuantumNous/new-api/blob/main/constant/channel.go
+import { getPublicChannelConfig } from "./channelConfig.js"
+import { PROVIDER_ICON_NAMES } from "./providerIcons.js"
+
+const CHANNELS = [
+ [1, "openai", "OpenAI", "https://api.openai.com", "通用大模型"],
+ [2, "midjourney", "Midjourney", "https://oa.api2d.net", "图像与视频"],
+ [3, "azure", "Azure OpenAI", "", "云平台", "需要 Azure Endpoint"],
+ [
+ 4,
+ "ollama",
+ "Ollama",
+ "http://localhost:11434",
+ "本地与自定义",
+ "Key 可留空",
+ true,
+ ],
+ [
+ 5,
+ "midjourney-plus",
+ "MidjourneyPlus",
+ "https://api.openai-sb.com",
+ "图像与视频",
+ ],
+ [6, "openai-max", "OpenAIMax", "https://api.openaimax.com", "兼容服务"],
+ [7, "ohmygpt", "OhMyGPT", "https://api.ohmygpt.com", "兼容服务"],
+ [8, "custom", "Custom", "", "本地与自定义", "需要自定义 Base URL"],
+ [9, "ails", "AILS", "https://api.caipacity.com", "兼容服务"],
+ [10, "aiproxy", "AIProxy", "https://api.aiproxy.io", "兼容服务"],
+ [11, "palm", "PaLM", "", "Google"],
+ [12, "api2gpt", "API2GPT", "https://api.api2gpt.com", "兼容服务"],
+ [13, "aigc2d", "AIGC2D", "https://api.aigc2d.com", "兼容服务"],
+ [14, "anthropic", "Anthropic", "https://api.anthropic.com", "通用大模型"],
+ [15, "baidu", "Baidu", "https://aip.baidubce.com", "国内云平台"],
+ [16, "zhipu", "Zhipu", "https://open.bigmodel.cn", "国内云平台"],
+ [
+ 17,
+ "ali",
+ "Ali / DashScope",
+ "https://dashscope.aliyuncs.com",
+ "国内云平台",
+ ],
+ [18, "xunfei", "Xunfei", "", "国内云平台", "通常需要组合凭证"],
+ [19, "360", "360", "https://api.360.cn", "国内云平台"],
+ [20, "openrouter", "OpenRouter", "https://openrouter.ai/api", "聚合服务"],
+ [
+ 21,
+ "aiproxy-library",
+ "AIProxyLibrary",
+ "https://api.aiproxy.io",
+ "兼容服务",
+ ],
+ [22, "fastgpt", "FastGPT", "https://fastgpt.run/api/openapi", "兼容服务"],
+ [
+ 23,
+ "tencent",
+ "Tencent Hunyuan",
+ "https://hunyuan.tencentcloudapi.com",
+ "国内云平台",
+ "通常需要组合凭证",
+ ],
+ [
+ 24,
+ "gemini",
+ "Google Gemini",
+ "https://generativelanguage.googleapis.com",
+ "Google",
+ ],
+ [25, "moonshot", "Moonshot", "https://api.moonshot.cn", "通用大模型"],
+ [26, "zhipu-v4", "ZhipuV4", "https://open.bigmodel.cn", "国内云平台"],
+ [27, "perplexity", "Perplexity", "https://api.perplexity.ai", "通用大模型"],
+ [
+ 31,
+ "lingyiwanwu",
+ "LingYiWanWu",
+ "https://api.lingyiwanwu.com",
+ "国内云平台",
+ ],
+ [33, "aws", "AWS Bedrock", "", "云平台", "需要区域和 AWS 组合凭证"],
+ [34, "cohere", "Cohere", "https://api.cohere.ai", "通用大模型"],
+ [35, "minimax", "MiniMax", "https://api.minimax.chat", "通用大模型"],
+ [36, "suno", "SunoAPI", "", "音频"],
+ [37, "dify", "Dify", "https://api.dify.ai", "兼容服务"],
+ [38, "jina", "Jina", "https://api.jina.ai", "通用大模型"],
+ [
+ 39,
+ "cloudflare",
+ "Cloudflare",
+ "https://api.cloudflare.com",
+ "云平台",
+ "需要 Account ID 等组合信息",
+ ],
+ [40, "siliconflow", "SiliconFlow", "https://api.siliconflow.cn", "聚合服务"],
+ [41, "vertex-ai", "VertexAI", "", "Google", "需要服务账号 JSON 和区域配置"],
+ [42, "mistral", "Mistral", "https://api.mistral.ai", "通用大模型"],
+ [43, "deepseek", "DeepSeek", "https://api.deepseek.com", "通用大模型"],
+ [44, "moka-ai", "MokaAI", "https://api.moka.ai", "兼容服务"],
+ [
+ 45,
+ "volcengine",
+ "VolcEngine",
+ "https://ark.cn-beijing.volces.com",
+ "国内云平台",
+ "可能需要模型部署配置",
+ ],
+ [
+ 46,
+ "baidu-v2",
+ "BaiduV2",
+ "https://qianfan.baidubce.com",
+ "国内云平台",
+ "通常需要组合凭证",
+ ],
+ [47, "xinference", "Xinference", "", "本地与自定义", "需要自定义 Base URL"],
+ [48, "xai", "xAI", "https://api.x.ai", "通用大模型"],
+ [
+ 49,
+ "coze",
+ "Coze",
+ "https://api.coze.cn",
+ "国内云平台",
+ "可能需要 Bot 配置",
+ ],
+ [
+ 50,
+ "kling",
+ "Kling",
+ "https://api.klingai.com",
+ "图像与视频",
+ "通常需要组合凭证",
+ ],
+ [
+ 51,
+ "jimeng",
+ "Jimeng",
+ "https://visual.volcengineapi.com",
+ "图像与视频",
+ "通常需要组合凭证",
+ ],
+ [52, "vidu", "Vidu", "https://api.vidu.cn", "图像与视频"],
+ [53, "submodel", "Submodel", "https://llm.submodel.ai", "兼容服务"],
+ [
+ 54,
+ "doubao-video",
+ "DoubaoVideo",
+ "https://ark.cn-beijing.volces.com",
+ "图像与视频",
+ "可能需要模型部署配置",
+ ],
+ [55, "sora", "Sora", "https://api.openai.com", "图像与视频"],
+ [56, "replicate", "Replicate", "https://api.replicate.com", "图像与视频"],
+ [
+ 57,
+ "codex",
+ "ChatGPT Subscription (Codex)",
+ "https://chatgpt.com",
+ "订阅与 OAuth",
+ "需要在 New API 中完成 OAuth,不支持纯 Key 导入",
+ false,
+ false,
+ ],
+ [
+ 58,
+ "advanced-custom",
+ "Advanced Custom",
+ "",
+ "本地与自定义",
+ "需要完整的高级渠道配置,不支持纯 Key 导入",
+ false,
+ false,
+ ],
+]
+
+const PROVIDERS = Object.freeze(
+ Object.fromEntries(
+ CHANNELS.map(
+ ([
+ channelType,
+ id,
+ name,
+ baseUrl,
+ category,
+ hint = "API Key",
+ keyOptional = false,
+ importable = true,
+ ]) => [
+ id,
+ Object.freeze({
+ id,
+ name,
+ description: hint,
+ channelType,
+ baseUrl,
+ category,
+ hasIcon: Boolean(PROVIDER_ICON_NAMES[id]),
+ keyOptional,
+ importable,
+ requiresBaseUrl:
+ !baseUrl && ![11, 18, 33, 41, 57, 58].includes(channelType),
+ channelConfig: getPublicChannelConfig(id, channelType),
+ }),
+ ],
+ ),
+ ),
+)
+
+export const CUSTOM_OPENAI_PROVIDER_PREFIX = "custom-openai:"
+
+export function createCustomOpenAIProvider(preset) {
+ // New API's OpenAI channel type accepts a custom base_url and forwards the
+ // normal OpenAI request path to it. Presets change only the display name and
+ // base URL; the protocol value remains ChannelTypeOpenAI (1).
+ // https://github.com/QuantumNous/new-api-docs/blob/main/docs/api/fei-channel-management.md
+ const id = String(preset?.id || "")
+ const name = String(preset?.name || "").trim()
+ const baseUrl = String(preset?.baseUrl || "").trim()
+ if (!id || !name || !baseUrl) throw new Error("自定义供应商配置不完整")
+ let host = baseUrl
+ try {
+ host = new URL(baseUrl).host
+ } catch {
+ // The store validates URLs; keep a safe fallback for imported state.
+ }
+ return Object.freeze({
+ ...PROVIDERS.openai,
+ id: `${CUSTOM_OPENAI_PROVIDER_PREFIX}${id}`,
+ name,
+ description: `OpenAI 兼容格式 · ${host}`,
+ baseUrl,
+ category: "OpenAI 兼容",
+ hasIcon: false,
+ requiresBaseUrl: false,
+ customProvider: true,
+ customProviderId: id,
+ })
+}
+
+export function listPublicProviders() {
+ return CHANNELS.map(([, id]) => PROVIDERS[id])
+}
+
+export function getProvider(providerId) {
+ const provider = PROVIDERS[providerId]
+ if (!provider) throw new Error("暂不支持这个来源")
+ return provider
+}
+
+export function resolveProviderBaseUrl(provider, override) {
+ const value = String(override || provider.baseUrl || "").trim()
+ if (!value) {
+ if (provider.requiresBaseUrl) throw new Error("这个渠道需要填写 Base URL")
+ return ""
+ }
+ let url
+ try {
+ url = new URL(value)
+ } catch {
+ throw new Error("请输入完整的来源 Base URL")
+ }
+ if (!new Set(["https:", "http:"]).has(url.protocol)) {
+ throw new Error("来源 Base URL 只支持 HTTP 或 HTTPS")
+ }
+ if (url.username || url.password || url.search || url.hash) {
+ throw new Error("来源 Base URL 不能包含凭证、查询参数或锚点")
+ }
+ return url.toString().replace(/\/$/, "")
+}
diff --git a/tools/channel-key-importer/src/scheduleStore.js b/tools/channel-key-importer/src/scheduleStore.js
new file mode 100644
index 0000000000..c34a34c393
--- /dev/null
+++ b/tools/channel-key-importer/src/scheduleStore.js
@@ -0,0 +1,622 @@
+import {
+ createCipheriv,
+ createDecipheriv,
+ randomBytes,
+ randomUUID,
+} from "node:crypto"
+import { mkdir, readFile, writeFile } from "node:fs/promises"
+import { dirname, join } from "node:path"
+
+import { DATA_DIR } from "./dataPath.js"
+import { keyIdentity } from "./importStore.js"
+import { createJsonStateStore } from "./sharedStorage.js"
+
+const SCHEDULES_PATH = join(DATA_DIR, "schedules.json")
+const SECRET_PATH = join(DATA_DIR, "schedule-secret.key")
+const MAX_JOBS = 100
+const MIN_BATCH_SIZE = 1
+const MAX_BATCH_SIZE = 200
+const MAX_INTERVAL_MINUTES = 30 * 24 * 60
+const STALE_RUNNING_MS = 30 * 60 * 1000
+const DEFAULT_RATE_LIMIT_RETRY_MS = 3 * 60 * 1000
+
+const clampInteger = (value, fallback, min, max) => {
+ const integer = Number.parseInt(String(value ?? ""), 10)
+ if (!Number.isInteger(integer)) return fallback
+ return Math.min(max, Math.max(min, integer))
+}
+
+const isRateLimitFailure = (failure) =>
+ failure?.retryable === true ||
+ /(?:\b429\b|请求次数过多|too many requests|rate[ -]?limit)/i.test(
+ String(failure?.error || failure || ""),
+ )
+
+const publicEntry = (entry) => ({
+ id: entry.id,
+ status: entry.status,
+ keyHint: entry.keyHint,
+ keyFingerprint: entry.keyFingerprint,
+ quota: Number.isFinite(entry.quota) ? entry.quota : null,
+ error: entry.error || "",
+ channelId: entry.channelId || null,
+ channelName: entry.channelName || "",
+ importedAt: entry.importedAt || null,
+ retryCount: Number.isInteger(entry.retryCount) ? entry.retryCount : 0,
+ retryNotBefore: entry.retryNotBefore || null,
+})
+
+const jobCounts = (job) => {
+ const entries = Array.isArray(job.entries) ? job.entries : []
+ return {
+ total: entries.length,
+ pending: entries.filter((entry) => entry.status === "pending").length,
+ imported: entries.filter((entry) => entry.status === "imported").length,
+ failed: entries.filter((entry) => entry.status === "failed").length,
+ uncertain: entries.filter((entry) => entry.status === "uncertain").length,
+ }
+}
+
+const publicJob = (job) => ({
+ id: job.id,
+ name: job.name,
+ providerName: job.providerName,
+ targetName: job.targetName,
+ targetUrl: job.targetUrl,
+ kind: job.kind || "scheduled",
+ status: job.status,
+ batchSize: job.batchSize,
+ intervalMinutes: job.intervalMinutes,
+ priority: Number.isInteger(job.preview?.priority) ? job.preview.priority : 0,
+ prioritySequence: job.preview?.prioritySequence || {
+ enabled: false,
+ step: 1,
+ },
+ weight: Number.isInteger(job.preview?.weight) ? job.preview.weight : 0,
+ nextRunAt: job.nextRunAt,
+ lastRunAt: job.lastRunAt || null,
+ lastError: job.lastError || "",
+ lastResult: job.lastResult || null,
+ createdAt: job.createdAt,
+ counts: jobCounts(job),
+ entries: (job.entries || []).map(publicEntry),
+})
+
+export function normalizeScheduleOptions(input = {}) {
+ const startAt = new Date(String(input.startAt || ""))
+ if (Number.isNaN(startAt.getTime())) {
+ throw new Error("请选择定时开始时间")
+ }
+ const batchSize = clampInteger(
+ input.batchSize,
+ 1,
+ MIN_BATCH_SIZE,
+ MAX_BATCH_SIZE,
+ )
+ const intervalMinutes = clampInteger(
+ input.intervalMinutes,
+ 60,
+ 0,
+ MAX_INTERVAL_MINUTES,
+ )
+ return {
+ batchSize,
+ intervalMinutes,
+ nextRunAt: startAt.toISOString(),
+ }
+}
+
+export class ScheduleStore {
+ #mutation = Promise.resolve()
+ #secretPromise = null
+
+ constructor({
+ path = SCHEDULES_PATH,
+ secretPath = SECRET_PATH,
+ now = () => new Date(),
+ stateStore = null,
+ } = {}) {
+ this.path = path
+ this.secretPath = secretPath
+ this.now = now
+ this.stateStore =
+ stateStore ||
+ createJsonStateStore({
+ key: path === SCHEDULES_PATH ? "schedules" : `schedules:${path}`,
+ path,
+ })
+ }
+
+ async #secret() {
+ if (this.#secretPromise) return await this.#secretPromise
+ this.#secretPromise = this.#loadSecret()
+ return await this.#secretPromise
+ }
+
+ async #loadSecret() {
+ try {
+ const value = (await readFile(this.secretPath, "utf8")).trim()
+ const secret = Buffer.from(value, "base64")
+ if (secret.length === 32) return secret
+ } catch {
+ // A new local install gets a new file key. Existing scheduled jobs remain
+ // tied to this machine and data directory.
+ }
+ const secret = randomBytes(32)
+ await mkdir(dirname(this.secretPath), { recursive: true, mode: 0o700 })
+ await writeFile(this.secretPath, secret.toString("base64"), {
+ encoding: "utf8",
+ mode: 0o600,
+ })
+ return secret
+ }
+
+ async #encrypt(value) {
+ const iv = randomBytes(12)
+ const cipher = createCipheriv("aes-256-gcm", await this.#secret(), iv)
+ const ciphertext = Buffer.concat([
+ cipher.update(JSON.stringify(value), "utf8"),
+ cipher.final(),
+ ])
+ return {
+ iv: iv.toString("base64"),
+ tag: cipher.getAuthTag().toString("base64"),
+ ciphertext: ciphertext.toString("base64"),
+ }
+ }
+
+ async #decrypt(payload) {
+ const decipher = createDecipheriv(
+ "aes-256-gcm",
+ await this.#secret(),
+ Buffer.from(payload.iv, "base64"),
+ )
+ decipher.setAuthTag(Buffer.from(payload.tag, "base64"))
+ const plaintext = Buffer.concat([
+ decipher.update(Buffer.from(payload.ciphertext, "base64")),
+ decipher.final(),
+ ])
+ return JSON.parse(plaintext.toString("utf8"))
+ }
+
+ async #readAll() {
+ const value = await this.stateStore.read([])
+ return Array.isArray(value) ? value : []
+ }
+
+ async #writeAll(jobs) {
+ await this.stateStore.write(jobs.slice(0, MAX_JOBS))
+ }
+
+ async #mutate(operation) {
+ const result = this.#mutation.then(operation, operation)
+ this.#mutation = result.catch(() => {})
+ return await result
+ }
+
+ async list() {
+ return (await this.#readAll()).map(publicJob)
+ }
+
+ async nextRunAt() {
+ const timestamps = (await this.#readAll())
+ .filter(
+ (job) =>
+ job.status === "active" &&
+ (job.entries || []).some((entry) => entry.status === "pending"),
+ )
+ .map((job) => new Date(job.nextRunAt).getTime())
+ .filter(Number.isFinite)
+ return timestamps.length > 0
+ ? new Date(Math.min(...timestamps)).toISOString()
+ : null
+ }
+
+ async findQueuedFingerprints({ profileId, apiKeys }) {
+ const requested = new Set(
+ (apiKeys || []).map((apiKey) => keyIdentity(apiKey).keyFingerprint),
+ )
+ if (!profileId || requested.size === 0) return new Set()
+ const fingerprints = []
+ for (const job of await this.#readAll()) {
+ if (
+ !["active", "paused"].includes(job.status) ||
+ job.preview?.profileId !== profileId
+ ) {
+ continue
+ }
+ for (const entry of job.entries || []) {
+ if (
+ ["pending", "running", "uncertain", "failed"].includes(
+ entry.status,
+ ) &&
+ requested.has(entry.keyFingerprint)
+ ) {
+ fingerprints.push(entry.keyFingerprint)
+ }
+ }
+ }
+ return new Set(fingerprints)
+ }
+
+ async create({
+ preview,
+ createOptions,
+ schedule,
+ kind = "scheduled",
+ requestId = "",
+ }) {
+ return await this.#mutate(async () => {
+ const jobs = await this.#readAll()
+ const existing = requestId
+ ? jobs.find((job) => job.requestId === requestId)
+ : null
+ if (existing) return publicJob(existing)
+ const occupiedFingerprints = new Set(
+ jobs
+ .filter(
+ (job) =>
+ ["active", "paused"].includes(job.status) &&
+ job.preview?.profileId === preview.profileId,
+ )
+ .flatMap((job) => job.entries || [])
+ .filter((entry) =>
+ ["pending", "running", "uncertain", "failed"].includes(
+ entry.status,
+ ),
+ )
+ .map((entry) => entry.keyFingerprint),
+ )
+ preview = {
+ ...preview,
+ keys: preview.keys.filter(
+ (entry) =>
+ !entry.apiKey ||
+ !occupiedFingerprints.has(keyIdentity(entry.apiKey).keyFingerprint),
+ ),
+ }
+ const options = normalizeScheduleOptions(schedule)
+ const entries = await Promise.all(
+ preview.keys.map(async (entry, priorityIndex) => ({
+ id: randomUUID(),
+ status: "pending",
+ priorityIndex: Number.isInteger(entry.priorityIndex)
+ ? entry.priorityIndex
+ : priorityIndex,
+ quota: Number.isFinite(entry.quota) ? entry.quota : null,
+ ...keyIdentity(entry.apiKey),
+ encryptedKey: await this.#encrypt(entry.apiKey),
+ })),
+ )
+ if (entries.length === 0) throw new Error("没有可定时写入的 Key")
+ const { keys, ...storedPreview } = preview
+ const suffix =
+ kind === "recovery"
+ ? "自动续传"
+ : kind === "paced"
+ ? "安全节流队列"
+ : "定时上 Key"
+ const job = {
+ id: randomUUID(),
+ requestId: String(requestId || ""),
+ kind,
+ name: `${preview.name} · ${suffix}`.slice(0, 100),
+ providerName: preview.provider.name,
+ targetName: preview.targetName,
+ targetUrl: preview.targetUrl,
+ status: "active",
+ batchSize: options.batchSize,
+ intervalMinutes: options.intervalMinutes,
+ nextRunAt: options.nextRunAt,
+ lastRunAt: null,
+ lastError: "",
+ lastResult: null,
+ createdAt: this.now().toISOString(),
+ preview: storedPreview,
+ createOptions,
+ entries,
+ }
+ await this.#writeAll([job, ...jobs])
+ return publicJob(job)
+ })
+ }
+
+ async claimDueJob(now = this.now()) {
+ return await this.#claim((job) => {
+ const nowTime = now.getTime()
+ return (
+ job.status === "active" &&
+ new Date(job.nextRunAt).getTime() <= nowTime &&
+ (job.entries || []).some((entry) => entry.status === "pending")
+ )
+ }, now)
+ }
+
+ async claimJobNow(jobId, now = this.now()) {
+ return await this.#claim(
+ (job) =>
+ job.id === jobId &&
+ ["active", "paused"].includes(job.status) &&
+ (job.entries || []).some((entry) => entry.status === "pending"),
+ now,
+ )
+ }
+
+ async #claim(predicate, now) {
+ return await this.#mutate(async () => {
+ const jobs = await this.#readAll()
+ let recovered = false
+ for (const job of jobs) {
+ if (job.status !== "running") continue
+ const runningEntries = (job.entries || []).filter(
+ (entry) => entry.status === "running",
+ )
+ const stale = runningEntries.some(
+ (entry) =>
+ now.getTime() - new Date(entry.lockedAt || 0).getTime() >
+ STALE_RUNNING_MS,
+ )
+ if (!stale) continue
+ for (const entry of runningEntries) {
+ entry.status = "uncertain"
+ entry.error =
+ "上次写入在完成确认前中断,为避免重复渠道,已停止自动重试"
+ delete entry.lockedAt
+ }
+ job.status = "attention"
+ job.lastError =
+ "上次执行中断,存在未确认结果;为避免重复渠道,未自动重放。"
+ recovered = true
+ }
+ const job = jobs.find(predicate)
+ if (!job) {
+ if (recovered) await this.#writeAll(jobs)
+ return null
+ }
+ const pending = job.entries
+ .filter(
+ (entry) =>
+ entry.status === "pending" &&
+ (!entry.retryNotBefore ||
+ new Date(entry.retryNotBefore).getTime() <= now.getTime()),
+ )
+ .slice(0, job.batchSize)
+ if (pending.length === 0) {
+ if (recovered) await this.#writeAll(jobs)
+ return null
+ }
+ for (const entry of pending) {
+ entry.status = "running"
+ entry.lockedAt = now.toISOString()
+ }
+ job.status = "running"
+ job.lastError = ""
+ await this.#writeAll(jobs)
+ const keys = await Promise.all(
+ pending.map(async (entry) => ({
+ id: entry.id,
+ apiKey: await this.#decrypt(entry.encryptedKey),
+ quota: Number.isFinite(entry.quota) ? entry.quota : null,
+ priorityIndex: Number.isInteger(entry.priorityIndex)
+ ? entry.priorityIndex
+ : 0,
+ })),
+ )
+ return {
+ id: job.id,
+ preview: { ...job.preview, keys },
+ createOptions: job.createOptions || {},
+ }
+ })
+ }
+
+ async completeRun(jobId, result, now = this.now()) {
+ return await this.#mutate(async () => {
+ const jobs = await this.#readAll()
+ const job = jobs.find((item) => item.id === jobId)
+ if (!job) throw new Error("定时任务不存在")
+ const running = job.entries.filter((entry) => entry.status === "running")
+ const resultItems = [
+ ...(result.results || result.records || []),
+ ...(result.failures || []),
+ ].filter(Boolean)
+ const byEntryId = new Map(
+ resultItems
+ .filter((item) => item.entryId)
+ .map((item) => [String(item.entryId), item]),
+ )
+ const byIndex = new Map(
+ resultItems
+ .filter((item) => Number.isInteger(Number(item.keyIndex)))
+ .map((item) => [Number(item.keyIndex), item]),
+ )
+ let rateLimitedCount = 0
+ let uncertainCount = 0
+ let latestRetryNotBefore = 0
+ for (const [index, entry] of running.entries()) {
+ const keyIndex = index + 1
+ const item = byEntryId.get(entry.id) || byIndex.get(keyIndex)
+ if (!item) {
+ entry.status = "uncertain"
+ entry.error =
+ "未收到这条 Key 的明确写入结果,为避免重复渠道,已停止自动重试"
+ delete entry.lockedAt
+ delete entry.retryNotBefore
+ uncertainCount += 1
+ continue
+ }
+ if (item.success === false) {
+ if (isRateLimitFailure(item)) {
+ const retryDelay = Number.isFinite(item.retryAfterMs)
+ ? Math.max(0, item.retryAfterMs)
+ : DEFAULT_RATE_LIMIT_RETRY_MS
+ const retryNotBefore = now.getTime() + retryDelay
+ entry.status = "pending"
+ entry.error = "New API 限流,已自动排队重试"
+ entry.retryNotBefore = new Date(retryNotBefore).toISOString()
+ entry.retryCount = Number.isInteger(entry.retryCount)
+ ? entry.retryCount + 1
+ : 1
+ delete entry.lockedAt
+ rateLimitedCount += 1
+ latestRetryNotBefore = Math.max(
+ latestRetryNotBefore,
+ retryNotBefore,
+ )
+ continue
+ }
+ entry.status = "failed"
+ entry.error = item.error || "写入失败"
+ delete entry.lockedAt
+ delete entry.retryNotBefore
+ continue
+ }
+ const record = item.record || item || {}
+ entry.status = "imported"
+ entry.error = ""
+ entry.channelId = record.channelId || item.channelId
+ entry.channelName =
+ record.channelName || item.channelName || result.channelName || ""
+ entry.importedAt = now.toISOString()
+ delete entry.lockedAt
+ delete entry.retryNotBefore
+ }
+ const hasPending = job.entries.some((entry) => entry.status === "pending")
+ const hasAttention = job.entries.some((entry) =>
+ ["failed", "uncertain"].includes(entry.status),
+ )
+ job.status = hasPending
+ ? rateLimitedCount > 0 || job.intervalMinutes > 0
+ ? "active"
+ : "paused"
+ : hasAttention
+ ? "attention"
+ : "completed"
+ job.lastRunAt = now.toISOString()
+ job.lastResult = {
+ success: result.success !== false,
+ successCount: result.successCount || 0,
+ failedCount: result.failedCount || 0,
+ channelName: result.channelName || "",
+ }
+ const statusMessages = []
+ if (rateLimitedCount > 0) {
+ statusMessages.push(
+ `${rateLimitedCount} 条 Key 触发 New API 限流,已自动排队重试。`,
+ )
+ }
+ if (uncertainCount > 0) {
+ statusMessages.push(
+ `${uncertainCount} 条 Key 的结果未确认,为避免重复渠道,已停止自动重试。`,
+ )
+ }
+ if (job.status === "paused") {
+ statusMessages.push(
+ "还有 Key 未写入;间隔为 0,已暂停,手动立即执行可继续。",
+ )
+ }
+ job.lastError = statusMessages.join(" ")
+ if (job.status === "active") {
+ const intervalAt = now.getTime() + job.intervalMinutes * 60 * 1000
+ job.nextRunAt = new Date(
+ Math.max(intervalAt, latestRetryNotBefore),
+ ).toISOString()
+ }
+ await this.#writeAll(jobs)
+ return publicJob(job)
+ })
+ }
+
+ async failRun(jobId, error, now = this.now()) {
+ return await this.#mutate(async () => {
+ const jobs = await this.#readAll()
+ const job = jobs.find((item) => item.id === jobId)
+ if (!job) throw new Error("定时任务不存在")
+ for (const entry of job.entries) {
+ if (entry.status === "running") {
+ entry.status = "pending"
+ delete entry.lockedAt
+ }
+ }
+ job.status = "active"
+ job.lastRunAt = now.toISOString()
+ job.lastError = error instanceof Error ? error.message : "定时写入失败"
+ job.nextRunAt = new Date(
+ now.getTime() + Math.max(1, job.intervalMinutes || 5) * 60 * 1000,
+ ).toISOString()
+ await this.#writeAll(jobs)
+ return publicJob(job)
+ })
+ }
+
+ async updateSchedule(jobId, schedule) {
+ return await this.#mutate(async () => {
+ const jobs = await this.#readAll()
+ const job = jobs.find((item) => item.id === jobId)
+ if (!job) throw new Error("定时任务不存在")
+ if (!["active", "paused"].includes(job.status)) {
+ throw new Error("只有等待执行或已暂停的任务可以修改")
+ }
+ if (!(job.entries || []).some((entry) => entry.status === "pending")) {
+ throw new Error("该任务没有待写入的 Key")
+ }
+ const options = normalizeScheduleOptions(schedule)
+ job.batchSize = options.batchSize
+ job.intervalMinutes = options.intervalMinutes
+ job.nextRunAt = options.nextRunAt
+ await this.#writeAll(jobs)
+ return publicJob(job)
+ })
+ }
+
+ async retryFailed(jobId, now = this.now()) {
+ return await this.#mutate(async () => {
+ const jobs = await this.#readAll()
+ const job = jobs.find((item) => item.id === jobId)
+ if (!job) throw new Error("定时任务不存在")
+ if (job.status === "running") {
+ throw new Error("任务正在执行,请结束后再重试失败 Key")
+ }
+ if (job.status === "cancelled") {
+ throw new Error("已取消的任务不能重试失败 Key")
+ }
+ const failedEntries = (job.entries || []).filter(
+ (entry) => entry.status === "failed",
+ )
+ if (failedEntries.length === 0) throw new Error("没有可重试的失败 Key")
+ for (const entry of failedEntries) {
+ entry.status = "pending"
+ entry.retryCount = Number.isInteger(entry.retryCount)
+ ? entry.retryCount + 1
+ : 1
+ delete entry.lockedAt
+ delete entry.retryNotBefore
+ }
+ job.status = "active"
+ job.nextRunAt = now.toISOString()
+ job.lastError = ""
+ await this.#writeAll(jobs)
+ return publicJob(job)
+ })
+ }
+
+ async updateStatus(jobId, status) {
+ return await this.#mutate(async () => {
+ const jobs = await this.#readAll()
+ const job = jobs.find((item) => item.id === jobId)
+ if (!job) throw new Error("定时任务不存在")
+ if (!["active", "paused", "cancelled"].includes(status)) {
+ throw new Error("定时任务状态不正确")
+ }
+ job.status = status
+ if (
+ status === "active" &&
+ new Date(job.nextRunAt).getTime() < Date.now()
+ ) {
+ job.nextRunAt = this.now().toISOString()
+ }
+ await this.#writeAll(jobs)
+ return publicJob(job)
+ })
+ }
+}
diff --git a/tools/channel-key-importer/src/security.js b/tools/channel-key-importer/src/security.js
new file mode 100644
index 0000000000..e6235abfda
--- /dev/null
+++ b/tools/channel-key-importer/src/security.js
@@ -0,0 +1,94 @@
+const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"])
+
+export function normalizeTargetUrl(value, { allowInsecureHttp = false } = {}) {
+ let url
+ try {
+ url = new URL(String(value || "").trim())
+ } catch {
+ throw new Error("请输入完整的 New API 地址")
+ }
+
+ if (url.username || url.password) {
+ throw new Error("目标地址不能包含用户名或密码")
+ }
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
+ throw new Error("目标地址只支持 HTTPS")
+ }
+ // Legacy self-hosted New API deployments may expose only HTTP. Permit this
+ // only after an explicit per-target opt-in from the local UI.
+ if (
+ url.protocol === "http:" &&
+ !LOOPBACK_HOSTS.has(url.hostname) &&
+ !allowInsecureHttp
+ ) {
+ throw new Error("公网 New API 必须使用 HTTPS;HTTP 仅允许本机地址")
+ }
+ if (url.search || url.hash) {
+ throw new Error("目标地址不能包含查询参数或锚点")
+ }
+
+ url.pathname = url.pathname.replace(/\/+$/, "") || "/"
+ return url.toString().replace(/\/$/, "")
+}
+
+export function validateUserId(value) {
+ const userId = String(value || "").trim()
+ if (!/^\d+$/.test(userId) || Number(userId) <= 0) {
+ throw new Error("请输入有效的管理员用户 ID")
+ }
+ return userId
+}
+
+export function isAllowedHostHeader(hostHeader, port, additionalHosts = []) {
+ if (!hostHeader) return false
+ const allowed = new Set([
+ `127.0.0.1:${port}`,
+ `localhost:${port}`,
+ `[::1]:${port}`,
+ ...additionalHosts.map((host) => String(host).toLowerCase()),
+ ])
+ return allowed.has(hostHeader.toLowerCase())
+}
+
+export function isAllowedOrigin(origin, port, additionalOrigins = []) {
+ if (!origin) return false
+ try {
+ const url = new URL(origin)
+ const normalizedOrigin = url.origin.toLowerCase()
+ if (
+ additionalOrigins.some(
+ (allowedOrigin) =>
+ String(allowedOrigin).toLowerCase() === normalizedOrigin,
+ )
+ ) {
+ return true
+ }
+ return (
+ url.protocol === "http:" &&
+ LOOPBACK_HOSTS.has(url.hostname) &&
+ url.port === String(port)
+ )
+ } catch {
+ return false
+ }
+}
+
+export function isAllowedApiRequestOrigin(
+ method,
+ origin,
+ port,
+ additionalOrigins = [],
+) {
+ const readOnly = method === "GET" || method === "HEAD"
+ if (readOnly && !origin) return true
+ return isAllowedOrigin(origin, port, additionalOrigins)
+}
+
+export function maskTargetUrl(value) {
+ try {
+ const url = new URL(value)
+ return `${url.protocol}//${url.host}`
+ } catch {
+ return ""
+ }
+}
diff --git a/tools/channel-key-importer/src/server.js b/tools/channel-key-importer/src/server.js
new file mode 100644
index 0000000000..d640e1705c
--- /dev/null
+++ b/tools/channel-key-importer/src/server.js
@@ -0,0 +1,1949 @@
+import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto"
+import { readFile } from "node:fs/promises"
+import { createServer } from "node:http"
+import { join, resolve } from "node:path"
+import { fileURLToPath } from "node:url"
+
+import { AccessSessionManager } from "./accessAuth.js"
+import { BalanceStore } from "./balanceStore.js"
+import {
+ applyQuotaLines,
+ applyUniformQuota,
+ buildResourceChannelName,
+ parseBatchKeys,
+} from "./batchKeys.js"
+import {
+ appendAwsRegionToChannelName,
+ buildAwsGlobalMappings,
+ buildAwsInferenceProfileMappings,
+ getAwsEntryChannelSettings,
+ getAwsRuntimeBaseUrl,
+ inferAwsCredentialMode,
+ normalizeAwsBatchCredentialInput,
+ resolveChannelInput,
+ summarizeAwsCredentials,
+ validateBatchCredentialEntries,
+} from "./channelConfig.js"
+import {
+ ConfigStore,
+ findProfileForRecord,
+ getCredentialAccount,
+} from "./configStore.js"
+import { CustomProviderStore } from "./customProviderStore.js"
+import { ImportStore, keyIdentity } from "./importStore.js"
+import { buildModelPlan, buildProviderPrefixMappings } from "./modelPlan.js"
+import {
+ createNewApiChannel,
+ createNewApiMultiKeyChannel,
+ discoverNewApiUserId,
+ fetchChannelModels,
+ fetchChannelTemplate,
+ fetchChannelUsage,
+ fetchNewApiDefaultModels,
+ fetchNewApiGroups,
+ fetchNewApiSystemName,
+ findCreatedChannel,
+ findCreatedChannels,
+ findSimilarChannels,
+ isRateLimitError,
+ listChannelTemplates,
+ loginNewApi,
+ refreshChannelBalance,
+ setChannelEnabled,
+ updateExistingChannelKey,
+ verifyNewApi,
+} from "./newApiClient.js"
+import { PreviewStore } from "./previewStore.js"
+import { getProviderIconSvg } from "./providerIcons.js"
+import {
+ createCustomOpenAIProvider,
+ CUSTOM_OPENAI_PROVIDER_PREFIX,
+ getProvider,
+ listPublicProviders,
+ resolveProviderBaseUrl,
+} from "./providers.js"
+import { ScheduleStore } from "./scheduleStore.js"
+import {
+ isAllowedApiRequestOrigin,
+ isAllowedHostHeader,
+ maskTargetUrl,
+ normalizeTargetUrl,
+ validateUserId,
+} from "./security.js"
+import {
+ createSharedTokenStore,
+ sqliteStorageEnabled,
+} from "./sharedStorage.js"
+
+const HOST = process.env.DATAEYESAI_HOST || "127.0.0.1"
+const DEFAULT_PORT = Number(process.env.CHANNEL_IMPORTER_PORT || 4179)
+const MODULE_PATH = fileURLToPath(import.meta.url)
+const PUBLIC_DIR = fileURLToPath(new URL("../public", import.meta.url))
+const SESSION_TOKEN = randomBytes(32).toString("base64url")
+const MAX_BODY_BYTES = 64 * 1024
+const IDLE_SCHEDULE_CHECK_MS = 60_000
+const MAX_TIMER_DELAY_MS = 2_147_483_647
+const MIN_CHANNEL_PRIORITY = -2_147_483_648
+const MAX_CHANNEL_ROUTING_VALUE = 2_147_483_647
+const configuredRateLimitFallback = Number(
+ process.env.DATAEYESAI_RATE_LIMIT_FALLBACK_SECONDS || 180,
+)
+const RATE_LIMIT_RECOVERY_DELAY_MS =
+ (Number.isFinite(configuredRateLimitFallback)
+ ? Math.max(10, configuredRateLimitFallback)
+ : 180) * 1000
+const RATE_LIMIT_RECOVERY_BATCH_SIZE = 1
+// New API's default global limiter is shared by all management calls from the
+// same IP. A verified first mutation is followed by one Key per minute so the
+// importer does not exhaust that quota before the next scheduled write.
+// https://github.com/QuantumNous/new-api-docs-v1/blob/main/content/docs/zh/installation/config-maintenance/environment-variables.mdx
+const SAFE_BULK_CONTINUATION_DELAY_MS = 65_000
+
+const configStore = new ConfigStore()
+const customProviderStore = new CustomProviderStore()
+const balanceStore = new BalanceStore()
+const importStore = new ImportStore()
+const previewStore = new PreviewStore()
+const scheduleStore = new ScheduleStore()
+let channelOperationQueue = Promise.resolve()
+
+const runChannelOperation = async (operation) => {
+ const result = channelOperationQueue.then(operation, operation)
+ channelOperationQueue = result.catch(() => {})
+ return await result
+}
+
+const listAvailableProviders = async () => [
+ ...(await customProviderStore.list()).map(createCustomOpenAIProvider),
+ ...listPublicProviders(),
+]
+
+async function resolveRequestedProvider(providerId) {
+ const id = String(providerId || "")
+ if (!id.startsWith(CUSTOM_OPENAI_PROVIDER_PREFIX)) return getProvider(id)
+ const preset = await customProviderStore.get(
+ id.slice(CUSTOM_OPENAI_PROVIDER_PREFIX.length),
+ )
+ if (!preset) throw new Error("自定义供应商已不存在,请重新选择")
+ return createCustomOpenAIProvider(preset)
+}
+
+const sendJson = (response, status, payload, headers = {}) => {
+ response.writeHead(status, {
+ "Cache-Control": "no-store",
+ "Content-Type": "application/json; charset=utf-8",
+ ...headers,
+ })
+ response.end(JSON.stringify(payload))
+}
+
+const safeEqual = (left, right) => {
+ const leftBuffer = Buffer.from(String(left || ""))
+ const rightBuffer = Buffer.from(String(right || ""))
+ return (
+ leftBuffer.length === rightBuffer.length &&
+ timingSafeEqual(leftBuffer, rightBuffer)
+ )
+}
+
+async function readJsonBody(request) {
+ const chunks = []
+ let size = 0
+ for await (const chunk of request) {
+ size += chunk.length
+ if (size > MAX_BODY_BYTES) throw new Error("请求内容过大")
+ chunks.push(chunk)
+ }
+ try {
+ return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}")
+ } catch {
+ throw new Error("请求格式不正确")
+ }
+}
+
+const getRuntimeConfig = async (profileId = "") => {
+ const config = await configStore.readConfig(profileId)
+ const targetUrl = normalizeTargetUrl(config.targetUrl, {
+ allowInsecureHttp: config.allowInsecureHttp,
+ })
+ const userId = validateUserId(config.userId)
+ const session = await configStore.readSession(
+ targetUrl,
+ userId,
+ config.rememberSession,
+ )
+ if (session?.sessionCookie) {
+ return { ...config, targetUrl, userId, ...session }
+ }
+ const account = getCredentialAccount(targetUrl, userId)
+ const adminToken = await configStore.readToken(account, config.rememberToken)
+ if (!adminToken) throw new Error("请先登录 New API")
+ return { ...config, targetUrl, userId, adminToken }
+}
+
+const getRuntimeConfigForRecord = async (record) => {
+ const profile = findProfileForRecord(record, await configStore.listProfiles())
+ if (!profile) throw new Error("找不到该记录对应的 New API 站点")
+ return await getRuntimeConfig(profile.profileId)
+}
+
+const getPublicConfig = async (config) => {
+ if (!config?.targetUrl || !config?.userId) {
+ return {
+ ...config,
+ hasToken: false,
+ hasSession: false,
+ username: "",
+ }
+ }
+ const session = await configStore.readSession(
+ config.targetUrl,
+ config.userId,
+ config.rememberSession,
+ )
+ const token = await configStore.readToken(
+ getCredentialAccount(config.targetUrl, config.userId),
+ config.rememberToken,
+ )
+ return {
+ ...config,
+ hasToken: Boolean(token),
+ hasSession: Boolean(session?.sessionCookie),
+ username: session?.username || "",
+ }
+}
+
+const channelFailureResult = (error, entry, keyIndex) => ({
+ success: false,
+ keyIndex,
+ ...(entry?.id ? { entryId: entry.id } : {}),
+ error: error instanceof Error ? error.message : "写入失败",
+ retryable: isRateLimitError(error),
+ ...(Number.isFinite(error?.retryAfterMs)
+ ? { retryAfterMs: Math.max(0, error.retryAfterMs) }
+ : {}),
+})
+
+const deferredRateLimitResult = (entry, keyIndex, limited) => ({
+ success: false,
+ keyIndex,
+ ...(entry?.id ? { entryId: entry.id } : {}),
+ error: "New API 限流,已停止继续请求并转入自动续传",
+ retryable: true,
+ deferred: true,
+ ...(Number.isFinite(limited?.retryAfterMs)
+ ? { retryAfterMs: limited.retryAfterMs }
+ : {}),
+})
+
+const enrichBalance = async (runtimeConfig, channelId) => {
+ let balance = await refreshChannelBalance(runtimeConfig, channelId)
+ if (balance.status === "available") {
+ balance = {
+ ...balance,
+ ...(await balanceStore.record(
+ {
+ targetUrl: runtimeConfig.targetUrl,
+ userId: runtimeConfig.userId,
+ channelId,
+ },
+ balance.balance,
+ )),
+ }
+ }
+ return balance
+}
+
+const resolveProfileName = async (body, targetUrl, existingConfig) => {
+ const requestedName = String(body.profileName || "")
+ .trim()
+ .slice(0, 80)
+ if (body.profileNameEdited === true && requestedName) {
+ return { name: requestedName, nameSource: "custom" }
+ }
+ try {
+ const detectedName = await fetchNewApiSystemName(targetUrl)
+ if (detectedName) return { name: detectedName, nameSource: "detected" }
+ } catch {
+ // Status-name discovery is optional; connection verification remains valid.
+ }
+ return {
+ name: existingConfig?.name || requestedName || new URL(targetUrl).host,
+ nameSource: existingConfig?.nameSource || "host",
+ }
+}
+
+const buildPreviewModelPlan = (preview, body) => {
+ const automaticMappings =
+ preview.provider.channelConfig?.autoMapProviderPrefix === true
+ ? buildProviderPrefixMappings([
+ ...preview.models,
+ ...(Array.isArray(body.manualModels) ? body.manualModels : []),
+ ])
+ : []
+ const requiredMappings = [
+ ...automaticMappings,
+ ...(Array.isArray(preview.providerMappings)
+ ? preview.providerMappings
+ : []),
+ ]
+ const submittedMappings = Array.isArray(body.mappings) ? body.mappings : []
+ const submittedAliases = new Set(
+ submittedMappings.map((mapping) => mapping?.standardModel),
+ )
+ return buildModelPlan({
+ fetchedModels: [
+ ...preview.models,
+ ...(preview.providerMappings || []).map((mapping) => mapping.actualModel),
+ ],
+ manualModels: body.manualModels,
+ mappings: [
+ ...requiredMappings.filter(
+ (mapping) => !submittedAliases.has(mapping.standardModel),
+ ),
+ ...submittedMappings,
+ ],
+ hideMappedActualModels:
+ preview.provider.channelConfig?.autoMapProviderPrefix === true ||
+ (preview.providerMappings || []).length > 0,
+ allowMappedStandardModels: Number.isInteger(preview.templateChannelId),
+ })
+}
+
+const buildEntryModelPlan = (preview, modelPlan, entry) => {
+ if (!preview.awsEntryRouting) return modelPlan
+ const region = entry.apiKey.split("|").at(-1)?.trim()
+ const modelMapping = { ...modelPlan.modelMapping }
+ for (const mapping of buildAwsInferenceProfileMappings(
+ preview.models,
+ region,
+ )) {
+ if (!modelMapping[mapping.standardModel]) {
+ modelMapping[mapping.standardModel] = mapping.actualModel
+ }
+ }
+ return { ...modelPlan, modelMapping }
+}
+
+const buildEntryChannelSettings = (preview, entry) =>
+ preview.provider.id === "aws" && preview.awsAutoCredentialMode
+ ? getAwsEntryChannelSettings(entry.apiKey, preview.channelSettings)
+ : preview.channelSettings
+
+const buildEntryBaseUrl = (preview, entry) =>
+ preview.provider.id === "aws" &&
+ buildEntryChannelSettings(preview, entry)?.aws_key_type === "api_key"
+ ? getAwsRuntimeBaseUrl(entry.apiKey)
+ : preview.baseUrl
+
+export function normalizeChannelRoutingValue(
+ value,
+ label,
+ minimum = MIN_CHANNEL_PRIORITY,
+) {
+ if (value == null || String(value).trim() === "") return null
+ const number = Number(value)
+ if (
+ !Number.isInteger(number) ||
+ number < minimum ||
+ number > MAX_CHANNEL_ROUTING_VALUE
+ ) {
+ throw new Error(
+ `${label}必须是 ${minimum} 到 ${MAX_CHANNEL_ROUTING_VALUE} 之间的整数`,
+ )
+ }
+ return number
+}
+
+export function normalizePrioritySequence(input, priority, keyCount) {
+ const enabled = input?.enabled === true
+ const step = enabled
+ ? normalizeChannelRoutingValue(input.step, "优先级递减步长", 1)
+ : 1
+ if (enabled) {
+ const lastPriority = priority - step * Math.max(0, keyCount - 1)
+ if (lastPriority < MIN_CHANNEL_PRIORITY) {
+ throw new Error("批量递减后的优先级超出 New API 支持范围")
+ }
+ }
+ return { enabled, step }
+}
+
+export function resolveEntryPriority(preview, entry, localIndex) {
+ if (!preview.prioritySequence?.enabled) return preview.priority
+ const sequenceIndex = Number.isInteger(entry.priorityIndex)
+ ? entry.priorityIndex
+ : localIndex
+ return preview.priority - preview.prioritySequence.step * sequenceIndex
+}
+
+export function deduplicateCredentialEntries(
+ entries,
+ existingFingerprints = new Set(),
+ queuedFingerprints = new Set(),
+) {
+ const seen = new Set()
+ const keys = []
+ const summary = {
+ inputCount: entries.length,
+ inputDuplicateCount: 0,
+ existingDuplicateCount: 0,
+ queuedDuplicateCount: 0,
+ acceptedCount: 0,
+ skippedCount: 0,
+ }
+ for (const entry of entries) {
+ if (!entry.apiKey) {
+ keys.push(entry)
+ continue
+ }
+ const fingerprint = keyIdentity(entry.apiKey).keyFingerprint
+ if (seen.has(fingerprint)) {
+ summary.inputDuplicateCount += 1
+ continue
+ }
+ seen.add(fingerprint)
+ if (existingFingerprints.has(fingerprint)) {
+ summary.existingDuplicateCount += 1
+ continue
+ }
+ if (queuedFingerprints.has(fingerprint)) {
+ summary.queuedDuplicateCount += 1
+ continue
+ }
+ keys.push(entry)
+ }
+ summary.acceptedCount = keys.length
+ summary.skippedCount = summary.inputCount - summary.acceptedCount
+ return { keys, summary }
+}
+
+async function buildCredentialPreview(body) {
+ const provider = await resolveRequestedProvider(body.providerId)
+ if (!provider.importable) throw new Error(provider.description)
+ const configSource = String(body.configSource || "")
+ if (!["template", "fetch", "new-api", "manual"].includes(configSource)) {
+ throw new Error("请选择复制已有渠道、自动获取模型或手动填写")
+ }
+ if (
+ configSource === "fetch" &&
+ provider.channelConfig.supportsModelFetch !== true
+ ) {
+ throw new Error(
+ "该渠道不能从供应商自动获取模型,请复制已有渠道、从 New API 获取或手动填写模型",
+ )
+ }
+ const runtimeConfig = await getRuntimeConfig()
+ const template =
+ configSource === "template"
+ ? await fetchChannelTemplate(
+ runtimeConfig,
+ body.templateChannelId,
+ provider,
+ )
+ : null
+ const normalizedApiKey =
+ provider.id === "aws"
+ ? normalizeAwsBatchCredentialInput(body.apiKey)
+ : String(body.apiKey || "")
+ const useRawCredentials = Boolean(
+ provider.channelConfig.credentialModes?.length && normalizedApiKey.trim(),
+ )
+ const channelInput = resolveChannelInput(provider, {
+ ...body,
+ apiKey: normalizedApiKey,
+ configSource,
+ useRawCredentials,
+ })
+ let keys
+ if (useRawCredentials) {
+ keys = parseBatchKeys(normalizedApiKey, "", {
+ allowInlineQuota: true,
+ deduplicate: false,
+ })
+ } else if (provider.channelConfig.credentialModes?.length) {
+ keys = parseBatchKeys(channelInput.apiKey, "", {
+ allowInlineQuota: true,
+ deduplicate: false,
+ })
+ } else if (provider.keyOptional && !channelInput.apiKey) {
+ keys = [{ apiKey: "", quota: null }]
+ } else {
+ keys = parseBatchKeys(channelInput.apiKey, "", {
+ allowInlineQuota: true,
+ deduplicate: false,
+ })
+ }
+ if (body.quotaMode === "uniform") {
+ keys = applyUniformQuota(keys, body.uniformQuota)
+ } else if (String(body.quotaLines || "").trim()) {
+ keys = applyQuotaLines(keys, body.quotaLines)
+ }
+ const awsAutoCredentialMode = provider.id === "aws" && useRawCredentials
+ const credentialMode = awsAutoCredentialMode
+ ? "auto"
+ : template?.channelSettings?.aws_key_type ||
+ template?.channelSettings?.vertex_key_type ||
+ String(body.credentialMode || "")
+ validateBatchCredentialEntries(provider, credentialMode, keys)
+ const candidateApiKeys = keys.map((entry) => entry.apiKey).filter(Boolean)
+ const existingFingerprints = await importStore.findExistingFingerprints({
+ profileId: runtimeConfig.profileId,
+ targetUrl: maskTargetUrl(runtimeConfig.targetUrl),
+ apiKeys: candidateApiKeys,
+ })
+ const queuedFingerprints = await scheduleStore.findQueuedFingerprints({
+ profileId: runtimeConfig.profileId,
+ apiKeys: candidateApiKeys,
+ })
+ const deduplication = deduplicateCredentialEntries(
+ keys,
+ existingFingerprints,
+ queuedFingerprints,
+ )
+ keys = deduplication.keys
+ if (keys.length === 0) {
+ throw new Error(
+ `识别到 ${deduplication.summary.inputCount} 条 Key,但均为本批重复、已录入或已在定时队列中,无需再次添加`,
+ )
+ }
+ if (!provider.keyOptional && keys.some(({ apiKey }) => apiKey.length < 8)) {
+ throw new Error("存在不完整的 API Key 或组合凭证")
+ }
+ const baseUrl = template
+ ? template.baseUrl
+ : resolveProviderBaseUrl(provider, body.baseUrl)
+ const requestedName = String(body.name || "")
+ .trim()
+ .slice(0, 80)
+ if (!requestedName) throw new Error("请输入渠道名称")
+ const automaticName = body.automaticName === true
+ const name = automaticName
+ ? buildResourceChannelName(provider.name, keys)
+ : requestedName
+ const availableGroups = await fetchNewApiGroups(runtimeConfig)
+ const requestedGroups = Array.isArray(body.groups)
+ ? body.groups.map((group) => String(group).trim()).filter(Boolean)
+ : []
+ const groups = [...new Set(requestedGroups)]
+ if (groups.length === 0) throw new Error("请至少选择一个渠道分组")
+ const invalidGroup = groups.find((group) => !availableGroups.includes(group))
+ if (invalidGroup) throw new Error(`渠道分组已不存在:${invalidGroup}`)
+ if (groups.join(",").length > 64) {
+ throw new Error("所选渠道分组名称总长度不能超过 64 个字符")
+ }
+ const priorityOverride = normalizeChannelRoutingValue(
+ body.priority,
+ "渠道优先级",
+ )
+ const weightOverride = normalizeChannelRoutingValue(
+ body.weight,
+ "渠道权重",
+ 0,
+ )
+ const resolvedPriority = priorityOverride ?? template?.advanced?.priority ?? 0
+ const prioritySequence = normalizePrioritySequence(
+ body.prioritySequence,
+ resolvedPriority,
+ keys.length,
+ )
+ const resolvedProvider = { ...provider, resolvedBaseUrl: baseUrl }
+ const defaultModels =
+ configSource === "new-api"
+ ? await fetchNewApiDefaultModels(runtimeConfig, provider.channelType)
+ : configSource === "fetch"
+ ? await fetchChannelModels(runtimeConfig, {
+ provider,
+ baseUrl,
+ apiKey: keys[0]?.apiKey || "",
+ })
+ : []
+ const duplicates = await findSimilarChannels(runtimeConfig, resolvedProvider)
+ const models = template
+ ? template.models
+ : configSource === "new-api" || configSource === "fetch"
+ ? defaultModels
+ : channelInput.models
+ if (models.length === 0) {
+ throw new Error(
+ template
+ ? "所选已有渠道没有配置模型,请换一个渠道"
+ : configSource === "new-api" || configSource === "fetch"
+ ? "没有获取到模型,请改为复制已有渠道或手动填写"
+ : "请填写至少一个模型",
+ )
+ }
+ const globalInference =
+ provider.id === "aws" && body.providerFlags?.globalInference === true
+ let providerMappings = template
+ ? template.modelMappings
+ : channelInput.providerMappings
+ if (globalInference) {
+ providerMappings = buildAwsGlobalMappings(models, providerMappings)
+ }
+ const awsRouting =
+ provider.id === "aws"
+ ? summarizeAwsCredentials(keys, globalInference)
+ : null
+ return {
+ provider,
+ keys,
+ baseUrl,
+ name,
+ groups,
+ priority: resolvedPriority,
+ weight: weightOverride ?? template?.advanced?.weight ?? 0,
+ prioritySequence,
+ routingOverrides: {
+ priority: priorityOverride,
+ weight: weightOverride,
+ },
+ models,
+ duplicates,
+ profileId: runtimeConfig.profileId,
+ targetName: runtimeConfig.name,
+ targetUrl: maskTargetUrl(runtimeConfig.targetUrl),
+ channelOther: template?.channelOther ?? channelInput.channelOther,
+ channelSettings: template?.channelSettings ?? channelInput.channelSettings,
+ providerMappings,
+ awsAutoCredentialMode,
+ awsEntryRouting:
+ provider.id === "aws" && useRawCredentials
+ ? true
+ : template
+ ? false
+ : channelInput.awsEntryRouting,
+ templateConfig: template?.advanced || null,
+ templateChannelId: template?.id || null,
+ templateChannelName: template?.name || "",
+ automaticName,
+ awsRouting,
+ deduplication: deduplication.summary,
+ modelSource:
+ configSource === "template" ? `template:${template.name}` : configSource,
+ }
+}
+
+const buildPreviewResponse = (preview, previewId) => ({
+ previewId,
+ provider: {
+ name: preview.provider.name,
+ channelType: preview.provider.channelType,
+ baseUrl: preview.baseUrl,
+ },
+ name: preview.name,
+ groups: preview.groups,
+ priority: preview.priority,
+ weight: preview.weight,
+ prioritySequence: preview.prioritySequence,
+ models: preview.models,
+ duplicates: preview.duplicates,
+ keyCount: preview.keys.length,
+ quotaTotal: preview.keys.every(({ quota }) => Number.isFinite(quota))
+ ? preview.keys.reduce((total, { quota }) => total + quota, 0)
+ : null,
+ knownQuotaTotal: preview.keys.reduce(
+ (total, { quota }) => total + (Number.isFinite(quota) ? quota : 0),
+ 0,
+ ),
+ modelSource: preview.modelSource,
+ templateChannelId: preview.templateChannelId,
+ templateChannelName: preview.templateChannelName,
+ providerMappings: preview.providerMappings,
+ awsRouting: preview.awsRouting,
+ deduplication: preview.deduplication || {
+ inputCount: preview.keys.length,
+ inputDuplicateCount: 0,
+ existingDuplicateCount: 0,
+ queuedDuplicateCount: 0,
+ acceptedCount: preview.keys.length,
+ skippedCount: 0,
+ },
+ unknownQuotaCount: preview.keys.filter(({ quota }) => !Number.isFinite(quota))
+ .length,
+ expiresInSeconds: 300,
+})
+
+async function createChannelsFromPreview(preview, body) {
+ const originalKeyCount = preview.keys.length
+ const existingFingerprints = await importStore.findExistingFingerprints({
+ profileId: preview.profileId,
+ targetUrl: preview.targetUrl,
+ apiKeys: preview.keys.map((entry) => entry.apiKey).filter(Boolean),
+ })
+ if (existingFingerprints.size > 0) {
+ preview = {
+ ...preview,
+ keys: preview.keys.filter(
+ (entry) =>
+ !entry.apiKey ||
+ !existingFingerprints.has(keyIdentity(entry.apiKey).keyFingerprint),
+ ),
+ }
+ }
+ const lateDuplicateCount = originalKeyCount - preview.keys.length
+ if (preview.keys.length === 0) {
+ return {
+ success: true,
+ operation: "skipped",
+ keyCount: originalKeyCount,
+ successCount: 0,
+ failedCount: 0,
+ skippedCount: lateDuplicateCount,
+ modelCount: preview.models.length,
+ mappingCount: body.mappings?.length || 0,
+ balance: { status: "unavailable" },
+ results: [],
+ records: [],
+ }
+ }
+ const importBatchId = randomUUID()
+ const isTemplateClone = Number.isInteger(preview.templateChannelId)
+ if (
+ !isTemplateClone &&
+ (preview.duplicates || []).length > 0 &&
+ body.confirmDuplicates !== true
+ ) {
+ throw new Error("发现同来源渠道,请确认后再添加")
+ }
+ const existingChannelId = isTemplateClone
+ ? 0
+ : Number(body.existingChannelId || 0)
+ const existingChannel = existingChannelId
+ ? (preview.duplicates || []).find(
+ (channel) => Number(channel.id) === existingChannelId,
+ )
+ : null
+ if (existingChannelId && !existingChannel) {
+ throw new Error("所选同类渠道不在本次预览中,请重新预览")
+ }
+ const runtimeConfig = await getRuntimeConfig(preview.profileId)
+ if (
+ preview.prioritySequence?.enabled &&
+ preview.keys.length > 1 &&
+ (existingChannel || body.combineKeys === true)
+ ) {
+ throw new Error("优先级依次递减只适用于每条 Key 新建独立渠道")
+ }
+ if (existingChannel) {
+ if (preview.keys.length > 1 && !existingChannel.isMultiKey) {
+ throw new Error("单 Key 渠道不能批量写入,请选择多 Key 渠道或分别新建")
+ }
+ const updatedEntries = []
+ const updateFailures = []
+ let rateLimitedFailure = null
+ let nextKeyIndex = existingChannel.multiKeySize || 0
+ for (const [index, entry] of preview.keys.entries()) {
+ if (rateLimitedFailure) {
+ updateFailures.push(
+ deferredRateLimitResult(entry, index + 1, rateLimitedFailure),
+ )
+ continue
+ }
+ try {
+ await updateExistingChannelKey(runtimeConfig, {
+ channelId: existingChannel.id,
+ apiKey: entry.apiKey,
+ append: existingChannel.isMultiKey,
+ priority: preview.routingOverrides?.priority,
+ weight: preview.routingOverrides?.weight,
+ })
+ updatedEntries.push({
+ ...entry,
+ sourceKeyIndex: index + 1,
+ keyIndex: existingChannel.isMultiKey ? nextKeyIndex : null,
+ })
+ if (existingChannel.isMultiKey) nextKeyIndex += 1
+ } catch (error) {
+ const failure = channelFailureResult(error, entry, index + 1)
+ updateFailures.push(failure)
+ if (failure.retryable) rateLimitedFailure = failure
+ }
+ }
+ if (updatedEntries.length === 0) {
+ if (rateLimitedFailure) {
+ return {
+ success: false,
+ operation: "updated",
+ keyAction: existingChannel.isMultiKey ? "appended" : "replaced",
+ channelId: existingChannel.id,
+ channelName: existingChannel.name,
+ balance: { status: "unavailable" },
+ keyCount: preview.keys.length,
+ successCount: 0,
+ failedCount: updateFailures.length,
+ skippedCount: lateDuplicateCount,
+ channelEnabled: Number(existingChannel.status) === 1,
+ failures: updateFailures,
+ results: updateFailures,
+ records: [],
+ }
+ }
+ throw new Error(updateFailures[0]?.error || "Key 写入失败")
+ }
+ let channelEnabled = true
+ try {
+ await setChannelEnabled(runtimeConfig, existingChannel.id)
+ } catch {
+ channelEnabled = false
+ }
+ let balance = {
+ status: "unavailable",
+ reason: "渠道已更新,但暂时无法查询余额",
+ }
+ try {
+ balance = await enrichBalance(runtimeConfig, existingChannel.id)
+ } catch {
+ // The key update already succeeded; balance lookup is best-effort.
+ }
+ const records = []
+ const results = []
+ for (const entry of updatedEntries) {
+ const record = await importStore.record({
+ importBatchId,
+ profileId: runtimeConfig.profileId,
+ targetName: runtimeConfig.name,
+ targetUrl: maskTargetUrl(runtimeConfig.targetUrl),
+ providerName: preview.provider.name,
+ apiKey: entry.apiKey,
+ quota: entry.quota,
+ currentBalance:
+ !existingChannel.isMultiKey && balance.status === "available"
+ ? balance.currentBalance
+ : null,
+ sharedChannel: existingChannel.isMultiKey,
+ keyIndex: entry.keyIndex,
+ batchItemIndex: entry.sourceKeyIndex,
+ operation: existingChannel.isMultiKey ? "appended" : "replaced",
+ channelId: existingChannel.id,
+ channelName: existingChannel.name,
+ })
+ records.push(record)
+ results.push({
+ success: true,
+ keyIndex: entry.sourceKeyIndex,
+ ...(entry.id ? { entryId: entry.id } : {}),
+ channelId: existingChannel.id,
+ channelName: existingChannel.name,
+ record,
+ })
+ }
+ return {
+ success: updateFailures.length === 0 && channelEnabled,
+ operation: "updated",
+ keyAction: existingChannel.isMultiKey ? "appended" : "replaced",
+ channelId: existingChannel.id,
+ channelName: existingChannel.name,
+ balance,
+ keyCount: preview.keys.length,
+ successCount: updatedEntries.length,
+ failedCount: updateFailures.length,
+ skippedCount: lateDuplicateCount,
+ channelEnabled,
+ failures: updateFailures,
+ results: [...results, ...updateFailures].sort(
+ (left, right) => left.keyIndex - right.keyIndex,
+ ),
+ records,
+ }
+ }
+ const modelPlan = buildPreviewModelPlan(preview, body)
+ if (body.combineKeys === true && preview.keys.length > 1) {
+ if (
+ preview.provider.id === "vertex-ai" &&
+ preview.channelSettings?.vertex_key_type === "api_key"
+ ) {
+ throw new Error("Vertex API Key 模式不支持合并为多 Key 渠道")
+ }
+ if (preview.provider.id === "aws" && preview.awsAutoCredentialMode) {
+ const credentialModes = new Set(
+ preview.keys.map((entry) => inferAwsCredentialMode(entry.apiKey)),
+ )
+ if (credentialModes.size > 1) {
+ throw new Error(
+ "AWS API Key 与 AK/SK 不能合并到同一个多 Key 渠道,请使用每条 Key 独立渠道",
+ )
+ }
+ const baseUrls = new Set(
+ preview.keys.map((entry) => buildEntryBaseUrl(preview, entry)),
+ )
+ if (baseUrls.size > 1) {
+ throw new Error(
+ "不同 AWS 地区不能合并到同一个多 Key 渠道,请使用每条 Key 独立渠道",
+ )
+ }
+ }
+ const routedModelPlans = preview.keys.map((entry) =>
+ buildEntryModelPlan(preview, modelPlan, entry),
+ )
+ const mappingVariants = new Set(
+ routedModelPlans.map((plan) => JSON.stringify(plan.modelMapping)),
+ )
+ if (mappingVariants.size > 1) {
+ throw new Error(
+ "AWS Key 的地区需要不同模型映射,请使用每条 Key 独立渠道或启用 Global",
+ )
+ }
+ const createInput = {
+ ...preview,
+ apiKeys: preview.keys.map((entry) => entry.apiKey),
+ name:
+ preview.provider.id === "aws"
+ ? appendAwsRegionToChannelName(preview.name, preview.keys[0].apiKey)
+ : preview.name,
+ baseUrl: buildEntryBaseUrl(preview, preview.keys[0]),
+ channelSettings: buildEntryChannelSettings(preview, preview.keys[0]),
+ ...routedModelPlans[0],
+ }
+ await createNewApiMultiKeyChannel(runtimeConfig, createInput)
+ let createdChannel = null
+ try {
+ createdChannel = await findCreatedChannel(runtimeConfig, createInput)
+ } catch {
+ // The channel is already created; the ledger can still retain the keys.
+ }
+ const records = []
+ const results = []
+ for (const [keyIndex, entry] of preview.keys.entries()) {
+ const record = await importStore.record({
+ importBatchId,
+ profileId: runtimeConfig.profileId,
+ targetName: runtimeConfig.name,
+ targetUrl: maskTargetUrl(runtimeConfig.targetUrl),
+ providerName: preview.provider.name,
+ apiKey: entry.apiKey,
+ quota: entry.quota,
+ currentBalance: null,
+ sharedChannel: true,
+ keyIndex,
+ batchItemIndex: keyIndex + 1,
+ operation: "created-multi-key",
+ channelId: createdChannel?.id,
+ channelName: createInput.name,
+ })
+ records.push(record)
+ results.push({
+ success: true,
+ keyIndex: keyIndex + 1,
+ ...(entry.id ? { entryId: entry.id } : {}),
+ channelId: createdChannel?.id ?? null,
+ channelName: createInput.name,
+ record,
+ })
+ }
+ return {
+ success: true,
+ operation: "created-multi-key",
+ channelId: createdChannel?.id ?? null,
+ channelName: createInput.name,
+ keyCount: preview.keys.length,
+ successCount: preview.keys.length,
+ failedCount: 0,
+ skippedCount: lateDuplicateCount,
+ modelCount: modelPlan.models.length,
+ mappingCount: Object.keys(modelPlan.modelMapping).length,
+ balance: { status: "unavailable" },
+ results,
+ records,
+ }
+ }
+
+ const results = []
+ const acknowledged = []
+ let rateLimitedFailure = null
+ for (const [index, entry] of preview.keys.entries()) {
+ const originalIndex = Number.isInteger(entry.priorityIndex)
+ ? entry.priorityIndex
+ : index
+ if (rateLimitedFailure) {
+ results.push(
+ deferredRateLimitResult(entry, originalIndex + 1, rateLimitedFailure),
+ )
+ continue
+ }
+ const originalTotal = Number.isInteger(preview.originalKeyCount)
+ ? preview.originalKeyCount
+ : preview.keys.length
+ const entryModelPlan = buildEntryModelPlan(preview, modelPlan, entry)
+ const baseName = preview.automaticName
+ ? buildResourceChannelName(preview.provider.name, [entry], {
+ index: originalIndex,
+ total: originalTotal,
+ })
+ : originalTotal > 1
+ ? `${preview.name} · ${originalIndex + 1}`.slice(0, 80)
+ : preview.name
+ const createInput = {
+ ...preview,
+ priority: resolveEntryPriority(preview, entry, index),
+ apiKey: entry.apiKey,
+ baseUrl: buildEntryBaseUrl(preview, entry),
+ channelSettings: buildEntryChannelSettings(preview, entry),
+ name:
+ preview.provider.id === "aws"
+ ? appendAwsRegionToChannelName(baseName, entry.apiKey)
+ : baseName,
+ ...entryModelPlan,
+ }
+ try {
+ await createNewApiChannel(runtimeConfig, createInput)
+ acknowledged.push({ entry, index: originalIndex, createInput })
+ } catch (error) {
+ const failure = channelFailureResult(error, entry, originalIndex + 1)
+ results.push(failure)
+ if (failure.retryable) rateLimitedFailure = failure
+ }
+ }
+
+ // New API's successful AddChannel response does not include an id. For a
+ // bulk import, locate every acknowledged channel with paginated list calls
+ // instead of issuing one search request per Key. This roughly halves the
+ // management API traffic that shares New API's global per-IP rate limit.
+ // https://github.com/QuantumNous/new-api/blob/main/controller/channel.go
+ // https://github.com/QuantumNous/new-api/blob/main/router/api-router.go
+ let createdChannels = new Map()
+ try {
+ createdChannels =
+ acknowledged.length === 1
+ ? new Map([
+ [
+ acknowledged[0].createInput,
+ await findCreatedChannel(
+ runtimeConfig,
+ acknowledged[0].createInput,
+ ),
+ ],
+ ])
+ : await findCreatedChannels(
+ runtimeConfig,
+ acknowledged.map((item) => item.createInput),
+ )
+ } catch {
+ // A success:true mutation is authoritative. A lookup failure is retained
+ // as an acknowledged-but-unlocated record and must never retry the POST.
+ }
+
+ for (const { entry, index, createInput } of acknowledged) {
+ const createdChannel = createdChannels.get(createInput) || null
+ let balance = {
+ status: "unavailable",
+ reason: createdChannel?.id
+ ? "批量渠道已定位,可稍后刷新余额"
+ : "渠道已提交成功,但暂时无法定位渠道 ID",
+ }
+ if (createdChannel?.id && preview.keys.length === 1) {
+ try {
+ balance = await enrichBalance(runtimeConfig, createdChannel.id)
+ } catch {
+ // Channel creation already succeeded; balance lookup is best-effort.
+ }
+ }
+ const record = await importStore.record({
+ importBatchId,
+ profileId: runtimeConfig.profileId,
+ targetName: runtimeConfig.name,
+ targetUrl: maskTargetUrl(runtimeConfig.targetUrl),
+ providerName: preview.provider.name,
+ apiKey: entry.apiKey,
+ quota: entry.quota,
+ batchItemIndex: index + 1,
+ currentBalance:
+ balance.status === "available" ? balance.currentBalance : null,
+ operation: "created",
+ channelId: createdChannel?.id,
+ channelName: createInput.name,
+ })
+ results.push({
+ success: true,
+ keyIndex: index + 1,
+ ...(entry.id ? { entryId: entry.id } : {}),
+ channelId: createdChannel?.id ?? null,
+ channelName: createInput.name,
+ verification: createdChannel?.id ? "located" : "acknowledged",
+ balance,
+ record,
+ })
+ }
+ results.sort((left, right) => (left.keyIndex || 0) - (right.keyIndex || 0))
+ const successful = results.filter((result) => result.success)
+ const failed = results.filter((result) => !result.success)
+ const latest = successful.at(-1)
+ return {
+ success: failed.length === 0,
+ operation: "created",
+ channelId: latest?.channelId ?? null,
+ channelName: latest?.channelName ?? preview.name,
+ keyCount: preview.keys.length,
+ successCount: successful.length,
+ failedCount: failed.length,
+ skippedCount: lateDuplicateCount,
+ modelCount: preview.models.length,
+ mappingCount: body.mappings?.length || 0,
+ balance: latest?.balance || { status: "unavailable" },
+ results,
+ }
+}
+
+const resultFailures = (result) =>
+ [...(result.results || []), ...(result.failures || [])].filter(
+ (item) => item?.success === false,
+ )
+
+async function createRateLimitRecovery(
+ preview,
+ body,
+ result,
+ requestId,
+ now = new Date(),
+) {
+ const failures = resultFailures(result).filter(isRateLimitError)
+ const byIndex = new Map(failures.map((item) => [Number(item.keyIndex), item]))
+ const keys = preview.keys.flatMap((entry, index) => {
+ const failure = byIndex.get(index + 1)
+ if (!failure) return []
+ return [
+ {
+ ...entry,
+ priorityIndex: Number.isInteger(entry.priorityIndex)
+ ? entry.priorityIndex
+ : index,
+ },
+ ]
+ })
+ if (keys.length === 0) return null
+ const retryAfterValues = failures
+ .map((item) => item.retryAfterMs)
+ .filter(Number.isFinite)
+ const retryDelay =
+ retryAfterValues.length > 0
+ ? Math.max(0, ...retryAfterValues)
+ : RATE_LIMIT_RECOVERY_DELAY_MS
+ const combineKeys = body.combineKeys === true
+ return await scheduleStore.create({
+ preview: {
+ ...preview,
+ originalKeyCount: preview.originalKeyCount || preview.keys.length,
+ keys,
+ },
+ createOptions: {
+ confirmDuplicates: true,
+ existingChannelId: Number(body.existingChannelId || 0) || null,
+ manualModels: Array.isArray(body.manualModels) ? body.manualModels : [],
+ mappings: Array.isArray(body.mappings) ? body.mappings : [],
+ combineKeys,
+ },
+ schedule: {
+ startAt: new Date(now.getTime() + retryDelay).toISOString(),
+ batchSize: combineKeys
+ ? keys.length
+ : Math.min(RATE_LIMIT_RECOVERY_BATCH_SIZE, keys.length),
+ intervalMinutes: 1,
+ },
+ kind: "recovery",
+ requestId: `rate-limit:${requestId}`,
+ })
+}
+
+async function createPacedContinuation(
+ preview,
+ body,
+ keys,
+ requestId,
+ now = new Date(),
+) {
+ if (keys.length === 0) return null
+ return await scheduleStore.create({
+ preview: {
+ ...preview,
+ originalKeyCount: preview.originalKeyCount || preview.keys.length,
+ keys,
+ },
+ createOptions: {
+ confirmDuplicates: true,
+ existingChannelId: null,
+ manualModels: Array.isArray(body.manualModels) ? body.manualModels : [],
+ mappings: Array.isArray(body.mappings) ? body.mappings : [],
+ combineKeys: false,
+ },
+ schedule: {
+ startAt: new Date(
+ now.getTime() + SAFE_BULK_CONTINUATION_DELAY_MS,
+ ).toISOString(),
+ batchSize: 1,
+ intervalMinutes: 1,
+ },
+ kind: "paced",
+ requestId: `paced:${requestId}`,
+ })
+}
+
+async function runDueScheduleBatch(now = new Date()) {
+ const claim = await scheduleStore.claimDueJob(now)
+ if (!claim) return null
+ try {
+ const result = await runChannelOperation(() =>
+ createChannelsFromPreview(claim.preview, {
+ ...claim.createOptions,
+ confirmDuplicates: true,
+ }),
+ )
+ return await scheduleStore.completeRun(claim.id, result, new Date())
+ } catch (error) {
+ return await scheduleStore.failRun(claim.id, error, new Date())
+ }
+}
+
+async function handleApi(
+ request,
+ response,
+ url,
+ port,
+ onScheduleChanged = async () => {},
+ publicOrigins = [],
+ accessProtected = false,
+) {
+ if (request.method === "GET" && url.pathname === "/api/bootstrap") {
+ let profiles = await configStore.listProfiles()
+ const unnamedProfiles = profiles.filter(
+ (profile) => profile.nameSource === "host",
+ )
+ const detectedNames = await Promise.all(
+ unnamedProfiles.map(async (profile) => {
+ try {
+ return await fetchNewApiSystemName(profile.targetUrl)
+ } catch {
+ return ""
+ }
+ }),
+ )
+ for (const [index, profile] of unnamedProfiles.entries()) {
+ if (detectedNames[index]) {
+ await configStore.saveDetectedName(
+ profile.profileId,
+ detectedNames[index],
+ )
+ }
+ }
+ profiles = await configStore.listProfiles()
+ const config = await configStore.readConfig()
+ const publicConfig = await getPublicConfig(config)
+ let groups = []
+ let groupsError = ""
+ if (publicConfig.hasToken || publicConfig.hasSession) {
+ try {
+ groups = await fetchNewApiGroups(await getRuntimeConfig())
+ } catch (error) {
+ groupsError =
+ error instanceof Error ? error.message : "读取 New API 分组失败"
+ }
+ }
+ return sendJson(response, 200, {
+ sessionToken: SESSION_TOKEN,
+ providers: await listAvailableProviders(),
+ customProviders: await customProviderStore.list(),
+ profiles: profiles.map(({ profileId, name, targetUrl }) => ({
+ profileId,
+ name,
+ target: maskTargetUrl(targetUrl),
+ })),
+ config: publicConfig,
+ groups,
+ groupsError,
+ schedules: await scheduleStore.list(),
+ deployment: {
+ accessProtected,
+ sharedDatabase: sqliteStorageEnabled(),
+ },
+ })
+ }
+
+ if (
+ !isAllowedApiRequestOrigin(
+ request.method,
+ request.headers.origin,
+ port,
+ publicOrigins,
+ )
+ ) {
+ return sendJson(response, 403, { error: "来源校验失败" })
+ }
+ if (!safeEqual(request.headers["x-importer-session"], SESSION_TOKEN)) {
+ return sendJson(response, 403, { error: "本地会话已失效,请刷新页面" })
+ }
+
+ if (request.method === "POST" && url.pathname === "/api/profiles/select") {
+ const body = await readJsonBody(request)
+ const config = await configStore.selectProfile(String(body.profileId || ""))
+ return sendJson(response, 200, {
+ success: true,
+ config: await getPublicConfig(config),
+ groups: await fetchNewApiGroups(await getRuntimeConfig(config.profileId)),
+ })
+ }
+
+ if (request.method === "GET" && url.pathname === "/api/imports") {
+ return sendJson(response, 200, { records: await importStore.list() })
+ }
+
+ if (request.method === "GET" && url.pathname === "/api/schedules") {
+ return sendJson(response, 200, { schedules: await scheduleStore.list() })
+ }
+
+ if (request.method === "GET" && url.pathname === "/api/groups") {
+ const runtimeConfig = await getRuntimeConfig()
+ return sendJson(response, 200, {
+ groups: await fetchNewApiGroups(runtimeConfig),
+ })
+ }
+
+ if (request.method === "POST" && url.pathname === "/api/custom-providers") {
+ const body = await readJsonBody(request)
+ const provider = await customProviderStore.save({
+ id: body.id,
+ name: body.name,
+ baseUrl: body.baseUrl,
+ })
+ return sendJson(response, 200, {
+ provider,
+ customProviders: await customProviderStore.list(),
+ providers: await listAvailableProviders(),
+ })
+ }
+
+ const customProviderDeleteMatch = url.pathname.match(
+ /^\/api\/custom-providers\/([^/]+)$/,
+ )
+ if (request.method === "DELETE" && customProviderDeleteMatch) {
+ await customProviderStore.remove(
+ decodeURIComponent(customProviderDeleteMatch[1]),
+ )
+ return sendJson(response, 200, {
+ success: true,
+ customProviders: await customProviderStore.list(),
+ providers: await listAvailableProviders(),
+ })
+ }
+
+ if (request.method === "POST" && url.pathname === "/api/channel-templates") {
+ const body = await readJsonBody(request)
+ const provider = await resolveRequestedProvider(body.providerId)
+ if (!provider.importable) throw new Error(provider.description)
+ const templates = await listChannelTemplates(
+ await getRuntimeConfig(),
+ provider,
+ )
+ return sendJson(response, 200, { templates })
+ }
+
+ if (request.method === "POST" && url.pathname === "/api/login") {
+ const body = await readJsonBody(request)
+ const allowInsecureHttp = body.allowInsecureHttp === true
+ const targetUrl = normalizeTargetUrl(body.targetUrl, {
+ allowInsecureHttp,
+ })
+ const username = String(body.username || "").trim()
+ const password = String(body.password || "")
+ if (!username || !password) throw new Error("请输入用户名和密码")
+ const login = await loginNewApi({ targetUrl, username, password })
+ const runtimeConfig = {
+ targetUrl,
+ userId: login.userId,
+ sessionCookie: login.sessionCookie,
+ }
+ try {
+ await verifyNewApi(runtimeConfig)
+ } catch (error) {
+ if (error instanceof Error && error.message.includes("认证失败")) {
+ throw new Error("登录成功,但该账号没有渠道管理权限")
+ }
+ throw error
+ }
+ const existingConfig = body.profileId
+ ? await configStore.readConfig(String(body.profileId))
+ : null
+ const profileName = await resolveProfileName(
+ body,
+ targetUrl,
+ existingConfig,
+ )
+ const rememberSession = body.rememberSession === true
+ await configStore.saveSession({ targetUrl, ...login }, rememberSession)
+ const profile = await configStore.saveConfig({
+ profileId: String(body.profileId || ""),
+ ...profileName,
+ targetUrl,
+ userId: login.userId,
+ rememberToken: false,
+ rememberSession,
+ allowInsecureHttp,
+ })
+ return sendJson(response, 200, {
+ success: true,
+ target: maskTargetUrl(targetUrl),
+ username: login.username,
+ profile,
+ groups: await fetchNewApiGroups(runtimeConfig),
+ })
+ }
+
+ if (request.method === "POST" && url.pathname === "/api/config") {
+ const body = await readJsonBody(request)
+ const allowInsecureHttp = body.allowInsecureHttp === true
+ const targetUrl = normalizeTargetUrl(body.targetUrl, {
+ allowInsecureHttp,
+ })
+ const adminToken = String(body.adminToken || "").trim()
+ if (adminToken.length < 8) throw new Error("请输入完整的管理员 Token")
+ const rememberToken = body.rememberToken === true
+ const existingConfig = body.profileId
+ ? await configStore.readConfig(String(body.profileId))
+ : null
+ const userId = await discoverNewApiUserId({
+ targetUrl,
+ adminToken,
+ preferredUserId: existingConfig?.userId,
+ })
+ await configStore.saveToken(
+ getCredentialAccount(targetUrl, userId),
+ adminToken,
+ rememberToken,
+ )
+ const profileName = await resolveProfileName(
+ body,
+ targetUrl,
+ existingConfig,
+ )
+ await configStore.clearSession(targetUrl, userId)
+ const profile = await configStore.saveConfig({
+ profileId: String(body.profileId || ""),
+ ...profileName,
+ targetUrl,
+ userId,
+ rememberToken,
+ rememberSession: false,
+ allowInsecureHttp,
+ })
+ return sendJson(response, 200, {
+ success: true,
+ target: maskTargetUrl(targetUrl),
+ profile,
+ groups: await fetchNewApiGroups({
+ ...profile,
+ targetUrl,
+ userId,
+ adminToken,
+ }),
+ })
+ }
+
+ if (request.method === "POST" && url.pathname === "/api/preview") {
+ const body = await readJsonBody(request)
+ const preview = await buildCredentialPreview(body)
+ const previewId = previewStore.create(preview)
+ return sendJson(response, 200, buildPreviewResponse(preview, previewId))
+ }
+
+ if (request.method === "POST" && url.pathname === "/api/create") {
+ const body = await readJsonBody(request)
+ const previewId = String(body.previewId || "")
+ const preview = previewStore.claim(previewId)
+ try {
+ const shouldPaceBulkWrite =
+ body.combineKeys !== true &&
+ !Number(body.existingChannelId || 0) &&
+ preview.keys.length > 1
+ const indexedKeys = preview.keys.map((entry, index) => ({
+ ...entry,
+ priorityIndex: Number.isInteger(entry.priorityIndex)
+ ? entry.priorityIndex
+ : index,
+ }))
+ const operationPreview = shouldPaceBulkWrite
+ ? {
+ ...preview,
+ originalKeyCount: preview.originalKeyCount || preview.keys.length,
+ keys: [indexedKeys[0]],
+ }
+ : preview
+ const result = await runChannelOperation(() =>
+ createChannelsFromPreview(operationPreview, body),
+ )
+ const rateLimitedFailure = resultFailures(result).find(isRateLimitError)
+ let recoverySchedule = null
+ let continuationSchedule = null
+ if (shouldPaceBulkWrite && rateLimitedFailure) {
+ const queuedResult = {
+ ...result,
+ keyCount: preview.keys.length,
+ successCount: 0,
+ failedCount: preview.keys.length,
+ results: indexedKeys.map((entry, index) =>
+ index === 0
+ ? rateLimitedFailure
+ : deferredRateLimitResult(entry, index + 1, rateLimitedFailure),
+ ),
+ }
+ recoverySchedule = await createRateLimitRecovery(
+ { ...preview, keys: indexedKeys },
+ body,
+ queuedResult,
+ previewId,
+ )
+ } else if (shouldPaceBulkWrite && result.successCount > 0) {
+ continuationSchedule = await createPacedContinuation(
+ { ...preview, keys: indexedKeys },
+ body,
+ indexedKeys.slice(1),
+ previewId,
+ )
+ } else {
+ recoverySchedule = await createRateLimitRecovery(
+ preview,
+ body,
+ result,
+ previewId,
+ )
+ }
+ previewStore.delete(previewId)
+ if (recoverySchedule || continuationSchedule) await onScheduleChanged()
+ return sendJson(response, 200, {
+ ...result,
+ ...(shouldPaceBulkWrite
+ ? {
+ keyCount: preview.keys.length,
+ queuedCount:
+ continuationSchedule?.counts.pending ||
+ recoverySchedule?.counts.pending ||
+ 0,
+ }
+ : {}),
+ ...(recoverySchedule ? { recoverySchedule } : {}),
+ ...(continuationSchedule ? { continuationSchedule } : {}),
+ })
+ } catch (error) {
+ if (isRateLimitError(error)) {
+ const failure = channelFailureResult(error, preview.keys[0], 1)
+ const results = preview.keys.map((entry, index) =>
+ index === 0
+ ? failure
+ : deferredRateLimitResult(entry, index + 1, failure),
+ )
+ const queuedResult = {
+ success: false,
+ operation: "queued",
+ keyCount: preview.keys.length,
+ successCount: 0,
+ failedCount: preview.keys.length,
+ modelCount: preview.models.length,
+ mappingCount: body.mappings?.length || 0,
+ balance: { status: "unavailable" },
+ results,
+ }
+ const recoverySchedule = await createRateLimitRecovery(
+ preview,
+ body,
+ queuedResult,
+ previewId,
+ )
+ previewStore.delete(previewId)
+ await onScheduleChanged()
+ return sendJson(response, 200, {
+ ...queuedResult,
+ recoverySchedule,
+ })
+ }
+ previewStore.release(previewId)
+ throw error
+ }
+ }
+
+ if (request.method === "POST" && url.pathname === "/api/schedules") {
+ const body = await readJsonBody(request)
+ const previewId = String(body.previewId || "")
+ const preview = previewId
+ ? previewStore.claim(previewId)
+ : await buildCredentialPreview(body)
+ try {
+ const schedule = await scheduleStore.create({
+ preview,
+ createOptions: {
+ confirmDuplicates: true,
+ existingChannelId: null,
+ manualModels: Array.isArray(body.manualModels)
+ ? body.manualModels
+ : [],
+ mappings: Array.isArray(body.mappings) ? body.mappings : [],
+ combineKeys: body.combineKeys === true,
+ },
+ schedule: body.schedule,
+ requestId: previewId,
+ })
+ if (previewId) previewStore.delete(previewId)
+ await onScheduleChanged()
+ return sendJson(response, 200, { schedule })
+ } catch (error) {
+ if (previewId) previewStore.release(previewId)
+ throw error
+ }
+ }
+
+ const scheduleSettingsMatch = url.pathname.match(
+ /^\/api\/schedules\/([^/]+)\/settings$/,
+ )
+ if (request.method === "POST" && scheduleSettingsMatch) {
+ const body = await readJsonBody(request)
+ const schedule = await scheduleStore.updateSchedule(
+ scheduleSettingsMatch[1],
+ body.schedule,
+ )
+ await onScheduleChanged()
+ return sendJson(response, 200, { schedule })
+ }
+
+ const scheduleActionMatch = url.pathname.match(
+ /^\/api\/schedules\/([^/]+)\/(run|pause|resume|cancel|retry-failed)$/,
+ )
+ if (request.method === "POST" && scheduleActionMatch) {
+ const [, scheduleId, action] = scheduleActionMatch
+ if (action === "retry-failed") {
+ const schedule = await scheduleStore.retryFailed(scheduleId, new Date())
+ await onScheduleChanged()
+ return sendJson(response, 200, { schedule })
+ }
+ if (action === "run") {
+ const claim = await scheduleStore.claimJobNow(scheduleId, new Date())
+ if (!claim || claim.id !== scheduleId) {
+ return sendJson(response, 200, {
+ schedule: (await scheduleStore.list()).find(
+ (item) => item.id === scheduleId,
+ ),
+ })
+ }
+ try {
+ const result = await runChannelOperation(() =>
+ createChannelsFromPreview(claim.preview, {
+ ...claim.createOptions,
+ confirmDuplicates: true,
+ }),
+ )
+ const schedule = await scheduleStore.completeRun(
+ claim.id,
+ result,
+ new Date(),
+ )
+ await onScheduleChanged()
+ return sendJson(response, 200, { schedule })
+ } catch (error) {
+ const schedule = await scheduleStore.failRun(
+ claim.id,
+ error,
+ new Date(),
+ )
+ await onScheduleChanged()
+ return sendJson(response, 200, { schedule })
+ }
+ }
+ const status =
+ action === "cancel"
+ ? "cancelled"
+ : action === "pause"
+ ? "paused"
+ : "active"
+ const schedule = await scheduleStore.updateStatus(scheduleId, status)
+ await onScheduleChanged()
+ return sendJson(response, 200, { schedule })
+ }
+
+ if (request.method === "POST" && url.pathname === "/api/balance") {
+ const body = await readJsonBody(request)
+ const channelId = Number(body.channelId)
+ if (!Number.isInteger(channelId) || channelId <= 0) {
+ throw new Error("渠道 ID 不正确")
+ }
+ const runtimeConfig = await getRuntimeConfig()
+ let balance = await refreshChannelBalance(runtimeConfig, channelId)
+ if (balance.status === "available") {
+ balance = {
+ ...balance,
+ ...(await balanceStore.record(
+ {
+ targetUrl: runtimeConfig.targetUrl,
+ userId: runtimeConfig.userId,
+ channelId,
+ },
+ balance.balance,
+ )),
+ }
+ }
+ return sendJson(response, 200, { channelId, balance })
+ }
+
+ if (request.method === "POST" && url.pathname === "/api/imports/refresh") {
+ const body = await readJsonBody(request)
+ const record = (await importStore.list()).find(
+ (item) => item.id === String(body.recordId || ""),
+ )
+ if (!record?.channelId) throw new Error("该记录没有可查询的渠道")
+ if (record.sharedChannel && !Number.isInteger(record.keyIndex)) {
+ throw new Error("这条旧记录没有 Key 索引,无法拆分实时用量")
+ }
+ const runtimeConfig = await getRuntimeConfigForRecord(record)
+ if (!record.sharedChannel) {
+ try {
+ const balance = await enrichBalance(runtimeConfig, record.channelId)
+ if (balance.status === "available") {
+ await importStore.updateBalance(record.id, balance.currentBalance)
+ }
+ } catch {
+ // Gateway usage remains available even if the provider has no balance API.
+ }
+ }
+ const usage = await fetchChannelUsage(runtimeConfig, {
+ channelId: record.channelId,
+ keyIndex: record.keyIndex,
+ startTimestamp: Math.floor(new Date(record.importedAt).getTime() / 1000),
+ })
+ const updated = await importStore.updateUsage(record.id, usage, {
+ profileId: runtimeConfig.profileId,
+ targetName: runtimeConfig.name,
+ targetUrl: maskTargetUrl(runtimeConfig.targetUrl),
+ })
+ return sendJson(response, 200, { record: updated })
+ }
+
+ return sendJson(response, 404, { error: "接口不存在" })
+}
+
+const CONTENT_TYPES = {
+ ".css": "text/css; charset=utf-8",
+ ".html": "text/html; charset=utf-8",
+ ".jpg": "image/jpeg",
+ ".js": "text/javascript; charset=utf-8",
+}
+
+const STATIC_FILES = new Set([
+ "index.html",
+ "styles.css",
+ "app.js",
+ "usageStats.js",
+ "viewState.js",
+ "assets/operations-board.jpg",
+])
+
+const LOGIN_STATIC_FILES = new Set([
+ "login.html",
+ "login.css",
+ "login.js",
+ "assets/operations-board.jpg",
+])
+
+async function serveStatic(response, pathname, { login = false } = {}) {
+ const providerIconMatch = pathname.match(
+ /^\/provider-icons\/([a-z0-9-]+)\.svg$/,
+ )
+ if (providerIconMatch) {
+ const svg = await getProviderIconSvg(providerIconMatch[1])
+ if (!svg) {
+ response.writeHead(404).end("Not found")
+ return
+ }
+ response.writeHead(200, {
+ "Cache-Control": "public, max-age=86400",
+ "Content-Type": "image/svg+xml; charset=utf-8",
+ "X-Content-Type-Options": "nosniff",
+ })
+ response.end(svg)
+ return
+ }
+ const fileName =
+ pathname === "/" ? (login ? "login.html" : "index.html") : pathname.slice(1)
+ const allowedFiles = login ? LOGIN_STATIC_FILES : STATIC_FILES
+ if (!allowedFiles.has(fileName)) {
+ response.writeHead(404).end("Not found")
+ return
+ }
+ const body = await readFile(join(PUBLIC_DIR, fileName))
+ const extension = fileName.slice(fileName.lastIndexOf("."))
+ response.writeHead(200, {
+ "Cache-Control": "no-store",
+ "Content-Security-Policy":
+ "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
+ "Content-Type": CONTENT_TYPES[extension],
+ "Cross-Origin-Opener-Policy": "same-origin",
+ "Referrer-Policy": "no-referrer",
+ "X-Content-Type-Options": "nosniff",
+ "X-Frame-Options": "DENY",
+ })
+ response.end(body)
+}
+
+export function calculateScheduleDelay(nextRunAt, now = Date.now()) {
+ if (!nextRunAt) return IDLE_SCHEDULE_CHECK_MS
+ const timestamp = new Date(nextRunAt).getTime()
+ if (!Number.isFinite(timestamp)) return IDLE_SCHEDULE_CHECK_MS
+ return Math.min(MAX_TIMER_DELAY_MS, Math.max(0, timestamp - now))
+}
+
+export async function startImporterServer({
+ host = HOST,
+ port = DEFAULT_PORT,
+ openBrowser = false,
+ tokenStore = null,
+ accessSessions = new AccessSessionManager(),
+ publicHosts = String(process.env.DATAEYESAI_PUBLIC_HOSTS || "")
+ .split(",")
+ .map((value) => value.trim())
+ .filter(Boolean),
+ publicOrigins = String(process.env.DATAEYESAI_PUBLIC_ORIGINS || "")
+ .split(",")
+ .map((value) => value.trim())
+ .filter(Boolean),
+} = {}) {
+ configStore.setTokenStore(tokenStore || createSharedTokenStore())
+ let activePort = port
+ let scheduleTimer = null
+ let scheduleRunning = false
+ let scheduleGeneration = 0
+ let closing = false
+ const scheduleNextRun = async () => {
+ const generation = ++scheduleGeneration
+ if (scheduleTimer) {
+ clearTimeout(scheduleTimer)
+ scheduleTimer = null
+ }
+ const nextRunAt = await scheduleStore.nextRunAt()
+ if (closing || generation !== scheduleGeneration) return
+ scheduleTimer = setTimeout(runSchedules, calculateScheduleDelay(nextRunAt))
+ }
+ const runSchedules = async () => {
+ if (scheduleRunning) return
+ scheduleRunning = true
+ try {
+ while (await runDueScheduleBatch()) {
+ // Keep draining due batches so a sleeping laptop catches up gradually.
+ }
+ } catch (error) {
+ process.stderr.write(
+ `dataeyesai 定时上 Key 失败:${error instanceof Error ? error.message : "未知错误"}\n`,
+ )
+ } finally {
+ scheduleRunning = false
+ await scheduleNextRun()
+ }
+ }
+ const server = createServer(async (request, response) => {
+ try {
+ if (!isAllowedHostHeader(request.headers.host, activePort, publicHosts)) {
+ return sendJson(response, 403, { error: "Host 校验失败" })
+ }
+ const url = new URL(request.url || "/", `http://${request.headers.host}`)
+ if (request.method === "POST" && url.pathname === "/api/auth/login") {
+ if (
+ !isAllowedApiRequestOrigin(
+ request.method,
+ request.headers.origin,
+ activePort,
+ publicOrigins,
+ )
+ ) {
+ return sendJson(response, 403, { error: "来源校验失败" })
+ }
+ const body = await readJsonBody(request)
+ const session = accessSessions.login(
+ request.socket.remoteAddress,
+ body.accessKey,
+ )
+ return sendJson(
+ response,
+ 200,
+ { success: true },
+ session.cookie ? { "Set-Cookie": session.cookie } : {},
+ )
+ }
+ if (request.method === "POST" && url.pathname === "/api/auth/logout") {
+ return sendJson(
+ response,
+ 200,
+ { success: true },
+ { "Set-Cookie": accessSessions.logout(request.headers.cookie) },
+ )
+ }
+ const authenticated = accessSessions.authenticate(request.headers.cookie)
+ if (!authenticated) {
+ if (url.pathname.startsWith("/api/")) {
+ return sendJson(response, 401, { error: "请先输入系统访问密钥" })
+ }
+ if (request.method !== "GET") {
+ response.writeHead(405).end("Method not allowed")
+ return
+ }
+ return await serveStatic(response, url.pathname, { login: true })
+ }
+ if (url.pathname.startsWith("/api/")) {
+ return await handleApi(
+ request,
+ response,
+ url,
+ activePort,
+ scheduleNextRun,
+ publicOrigins,
+ accessSessions.enabled,
+ )
+ }
+ if (request.method !== "GET") {
+ response.writeHead(405).end("Method not allowed")
+ return
+ }
+ await serveStatic(response, url.pathname)
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "操作失败,请稍后重试"
+ sendJson(response, 400, { error: message })
+ }
+ })
+
+ await new Promise((resolveListen, reject) => {
+ const onError = (error) => reject(error)
+ server.once("error", onError)
+ server.listen(port, host, () => {
+ server.off("error", onError)
+ const address = server.address()
+ activePort = typeof address === "object" && address ? address.port : port
+ resolveListen()
+ })
+ })
+ const url = `http://${host}:${activePort}`
+ process.stdout.write(`dataeyesai 已启动:${url}\n`)
+ // Use the nearest queued timestamp instead of coarse polling so a task can
+ // start on its selected second. Network and upstream latency still apply.
+ void runSchedules()
+ if (openBrowser && process.platform === "darwin") {
+ const { execFile } = await import("node:child_process")
+ execFile("open", [url])
+ }
+ return {
+ server,
+ url,
+ close: async () => {
+ closing = true
+ scheduleGeneration += 1
+ if (scheduleTimer) clearTimeout(scheduleTimer)
+ await new Promise((resolveClose, reject) => {
+ server.close((error) => (error ? reject(error) : resolveClose()))
+ })
+ },
+ }
+}
+
+if (process.argv[1] && resolve(process.argv[1]) === MODULE_PATH) {
+ startImporterServer({
+ openBrowser:
+ process.env.CHANNEL_IMPORTER_NO_OPEN !== "1" &&
+ process.platform === "darwin",
+ }).catch((error) => {
+ process.stderr.write(
+ `dataeyesai 启动失败:${error instanceof Error ? error.message : "未知错误"}\n`,
+ )
+ process.exitCode = 1
+ })
+}
diff --git a/tools/channel-key-importer/src/sharedStorage.js b/tools/channel-key-importer/src/sharedStorage.js
new file mode 100644
index 0000000000..71c275c13b
--- /dev/null
+++ b/tools/channel-key-importer/src/sharedStorage.js
@@ -0,0 +1,241 @@
+import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"
+import { mkdir, readFile, writeFile } from "node:fs/promises"
+import { dirname, join } from "node:path"
+
+import { DATA_DIR } from "./dataPath.js"
+
+const DEFAULT_DATABASE_PATH = join(DATA_DIR, "dataeyesai.sqlite")
+const MASTER_KEY_PATH = join(DATA_DIR, "server-master.key")
+
+let databasePromise = null
+let masterKeyPromise = null
+
+const cloneFallback = (value) =>
+ value && typeof value === "object" ? structuredClone(value) : value
+
+export function sqliteStorageEnabled() {
+ return process.env.DATAEYESAI_STORAGE === "sqlite"
+}
+
+async function openDatabase() {
+ if (databasePromise) return await databasePromise
+ databasePromise = (async () => {
+ const databasePath =
+ process.env.DATAEYESAI_DATABASE_PATH || DEFAULT_DATABASE_PATH
+ await mkdir(dirname(databasePath), { recursive: true, mode: 0o700 })
+ const { DatabaseSync } = await import("node:sqlite")
+ const database = new DatabaseSync(databasePath)
+ database.exec("PRAGMA journal_mode = WAL")
+ database.exec("PRAGMA synchronous = NORMAL")
+ database.exec(`
+ CREATE TABLE IF NOT EXISTS app_state (
+ state_key TEXT PRIMARY KEY,
+ state_value TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ );
+ CREATE TABLE IF NOT EXISTS encrypted_secrets (
+ secret_key TEXT PRIMARY KEY,
+ secret_value TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ );
+ `)
+ return database
+ })()
+ return await databasePromise
+}
+
+async function readLegacyJson(path, fallback) {
+ try {
+ return JSON.parse(await readFile(path, "utf8"))
+ } catch {
+ return cloneFallback(fallback)
+ }
+}
+
+async function writeLegacyJson(path, value) {
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 })
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, {
+ encoding: "utf8",
+ mode: 0o600,
+ })
+}
+
+export function createJsonStateStore({ key, path }) {
+ return {
+ async read(fallback) {
+ if (!sqliteStorageEnabled()) {
+ return await readLegacyJson(path, fallback)
+ }
+ const database = await openDatabase()
+ const row = database
+ .prepare("SELECT state_value FROM app_state WHERE state_key = ?")
+ .get(key)
+ if (row?.state_value) {
+ try {
+ return JSON.parse(row.state_value)
+ } catch {
+ return cloneFallback(fallback)
+ }
+ }
+ const legacyValue = await readLegacyJson(path, fallback)
+ await this.write(legacyValue)
+ return legacyValue
+ },
+
+ async write(value) {
+ if (!sqliteStorageEnabled()) {
+ await writeLegacyJson(path, value)
+ return
+ }
+ const database = await openDatabase()
+ database
+ .prepare(
+ `INSERT INTO app_state (state_key, state_value, updated_at)
+ VALUES (?, ?, ?)
+ ON CONFLICT(state_key) DO UPDATE SET
+ state_value = excluded.state_value,
+ updated_at = excluded.updated_at`,
+ )
+ .run(key, JSON.stringify(value), new Date().toISOString())
+ },
+ }
+}
+
+async function loadMasterKey() {
+ if (masterKeyPromise) return await masterKeyPromise
+ masterKeyPromise = (async () => {
+ const configured = String(process.env.DATAEYESAI_MASTER_KEY || "").trim()
+ if (configured) {
+ const key = Buffer.from(configured, "base64")
+ if (key.length !== 32) {
+ throw new Error("DATAEYESAI_MASTER_KEY 必须是 32 字节 Base64 密钥")
+ }
+ return key
+ }
+ try {
+ const key = Buffer.from(
+ (await readFile(MASTER_KEY_PATH, "utf8")).trim(),
+ "base64",
+ )
+ if (key.length === 32) return key
+ } catch {
+ // The first server start creates a machine-local encryption key.
+ }
+ const key = randomBytes(32)
+ await mkdir(dirname(MASTER_KEY_PATH), { recursive: true, mode: 0o700 })
+ await writeFile(MASTER_KEY_PATH, key.toString("base64"), {
+ encoding: "utf8",
+ mode: 0o600,
+ })
+ return key
+ })()
+ return await masterKeyPromise
+}
+
+async function encryptSecret(value) {
+ const iv = randomBytes(12)
+ const cipher = createCipheriv("aes-256-gcm", await loadMasterKey(), iv)
+ const ciphertext = Buffer.concat([
+ cipher.update(String(value), "utf8"),
+ cipher.final(),
+ ])
+ return JSON.stringify({
+ version: 1,
+ iv: iv.toString("base64"),
+ tag: cipher.getAuthTag().toString("base64"),
+ ciphertext: ciphertext.toString("base64"),
+ })
+}
+
+async function decryptSecret(value) {
+ const payload = JSON.parse(value)
+ const decipher = createDecipheriv(
+ "aes-256-gcm",
+ await loadMasterKey(),
+ Buffer.from(payload.iv, "base64"),
+ )
+ decipher.setAuthTag(Buffer.from(payload.tag, "base64"))
+ return Buffer.concat([
+ decipher.update(Buffer.from(payload.ciphertext, "base64")),
+ decipher.final(),
+ ]).toString("utf8")
+}
+
+async function saveEncryptedSecret(secretKey, value) {
+ const database = await openDatabase()
+ database
+ .prepare(
+ `INSERT INTO encrypted_secrets (secret_key, secret_value, updated_at)
+ VALUES (?, ?, ?)
+ ON CONFLICT(secret_key) DO UPDATE SET
+ secret_value = excluded.secret_value,
+ updated_at = excluded.updated_at`,
+ )
+ .run(secretKey, await encryptSecret(value), new Date().toISOString())
+}
+
+async function readEncryptedSecret(secretKey) {
+ const database = await openDatabase()
+ const row = database
+ .prepare("SELECT secret_value FROM encrypted_secrets WHERE secret_key = ?")
+ .get(secretKey)
+ if (!row?.secret_value) return ""
+ try {
+ return await decryptSecret(row.secret_value)
+ } catch {
+ return ""
+ }
+}
+
+async function deleteEncryptedSecret(secretKey) {
+ const database = await openDatabase()
+ database
+ .prepare("DELETE FROM encrypted_secrets WHERE secret_key = ?")
+ .run(secretKey)
+}
+
+export function createSharedTokenStore() {
+ if (!sqliteStorageEnabled()) return null
+ return {
+ async save(account, token) {
+ await saveEncryptedSecret(`new-api-token:${account}`, token)
+ },
+
+ async read(account) {
+ return await readEncryptedSecret(`new-api-token:${account}`)
+ },
+
+ async delete(account) {
+ await deleteEncryptedSecret(`new-api-token:${account}`)
+ },
+
+ async saveSession(account, session) {
+ await saveEncryptedSecret(
+ `new-api-session:${account}`,
+ JSON.stringify(session),
+ )
+ },
+
+ async readSession(account) {
+ const value = await readEncryptedSecret(`new-api-session:${account}`)
+ if (!value) return null
+ try {
+ return JSON.parse(value)
+ } catch {
+ return null
+ }
+ },
+
+ async deleteSession(account) {
+ await deleteEncryptedSecret(`new-api-session:${account}`)
+ },
+ }
+}
+
+export async function closeSharedStorageForTests() {
+ if (!databasePromise) return
+ const database = await databasePromise
+ database.close()
+ databasePromise = null
+ masterKeyPromise = null
+}
diff --git a/tools/channel-key-importer/tests/accessAuth.test.js b/tools/channel-key-importer/tests/accessAuth.test.js
new file mode 100644
index 0000000000..473b5f4e28
--- /dev/null
+++ b/tools/channel-key-importer/tests/accessAuth.test.js
@@ -0,0 +1,46 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+
+import {
+ AccessSessionManager,
+ hashAccessKey,
+ verifyAccessKey,
+} from "../src/accessAuth.js"
+
+test("hashes the system access key and creates an expiring cookie session", () => {
+ let now = Date.parse("2026-08-02T00:00:00.000Z")
+ const accessKey = "server-access-key-123"
+ const accessKeyHash = hashAccessKey(accessKey, Buffer.alloc(16, 7))
+ assert.equal(verifyAccessKey(accessKey, accessKeyHash), true)
+ assert.equal(verifyAccessKey("wrong-access-key", accessKeyHash), false)
+
+ const sessions = new AccessSessionManager({
+ accessKeyHash,
+ sessionMs: 60_000,
+ now: () => now,
+ })
+ const login = sessions.login("198.51.100.10", accessKey)
+ assert.match(login.cookie, /dataeyesai_access=/)
+ assert.match(login.cookie, /HttpOnly/)
+ assert.match(login.cookie, /SameSite=Strict/)
+ assert.equal(sessions.authenticate(login.cookie), true)
+
+ now += 60_001
+ assert.equal(sessions.authenticate(login.cookie), false)
+})
+
+test("rate limits repeated invalid system access keys", () => {
+ const sessions = new AccessSessionManager({
+ accessKeyHash: hashAccessKey("correct-server-key"),
+ })
+ for (let index = 0; index < 5; index += 1) {
+ assert.throws(
+ () => sessions.login("203.0.113.8", "incorrect-key-value"),
+ /密钥不正确/,
+ )
+ }
+ assert.throws(
+ () => sessions.login("203.0.113.8", "correct-server-key"),
+ /15 分钟/,
+ )
+})
diff --git a/tools/channel-key-importer/tests/balanceStore.test.js b/tools/channel-key-importer/tests/balanceStore.test.js
new file mode 100644
index 0000000000..0568ec3aba
--- /dev/null
+++ b/tools/channel-key-importer/tests/balanceStore.test.js
@@ -0,0 +1,25 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+
+import { calculateBalanceUsage } from "../src/balanceStore.js"
+
+test("calculates spend since the first successful balance query", () => {
+ assert.deepEqual(calculateBalanceUsage(25, 21.375), {
+ spentSinceImport: 3.625,
+ balanceIncreased: false,
+ })
+})
+
+test("stops estimating spend after a recharge or grant", () => {
+ assert.deepEqual(calculateBalanceUsage(25, 30), {
+ spentSinceImport: null,
+ balanceIncreased: true,
+ })
+})
+
+test("does not calculate usage from invalid balances", () => {
+ assert.deepEqual(calculateBalanceUsage(Number.NaN, 10), {
+ spentSinceImport: null,
+ balanceIncreased: false,
+ })
+})
diff --git a/tools/channel-key-importer/tests/batchKeys.test.js b/tools/channel-key-importer/tests/batchKeys.test.js
new file mode 100644
index 0000000000..5ccb71d08e
--- /dev/null
+++ b/tools/channel-key-importer/tests/batchKeys.test.js
@@ -0,0 +1,169 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+
+import {
+ applyQuotaLines,
+ applyUniformQuota,
+ buildResourceChannelName,
+ keyNameHint,
+ parseBatchKeys,
+} from "../src/batchKeys.js"
+
+test("parses batch keys with per-line and fallback quotas", () => {
+ assert.deepEqual(
+ parseBatchKeys("sk-first | 25\nsk-second\nsk-first | 30", "10"),
+ [
+ { apiKey: "sk-first", quota: 30 },
+ { apiKey: "sk-second", quota: 10 },
+ ],
+ )
+})
+
+test("rejects invalid quotas without exposing the full key", () => {
+ assert.throws(() => parseBatchKeys("sk-sensitive-value | nope", ""), /alue/)
+ assert.throws(() => parseBatchKeys("sk-key", "-1"), /默认额度/)
+})
+
+test("keeps a multiline JSON credential as one entry", () => {
+ const credential = '{\n "client_email": "service@example.com"\n}'
+
+ assert.deepEqual(parseBatchKeys(credential, "12"), [
+ { apiKey: credential, quota: 12 },
+ ])
+})
+
+test("keeps composite provider credentials intact", () => {
+ assert.deepEqual(
+ parseBatchKeys(
+ "AKIAFIRST|secret|us-east-1\nAKIASECOND|secret|eu-west-1",
+ "20",
+ ),
+ [
+ { apiKey: "AKIAFIRST|secret|us-east-1", quota: 20 },
+ { apiKey: "AKIASECOND|secret|eu-west-1", quota: 20 },
+ ],
+ )
+})
+
+test("accepts x as an explicitly unknown quota", () => {
+ assert.deepEqual(parseBatchKeys("sk-known | 12\nsk-unknown | x", "x"), [
+ { apiKey: "sk-known", quota: 12 },
+ { apiKey: "sk-unknown", quota: null },
+ ])
+})
+
+test("splits many prefixed keys and trailing quotas from one pasted row", () => {
+ assert.deepEqual(
+ parseBatchKeys(
+ "sk-first 20 sk-ant-second | x sk-or-v1-third,50 AIzaFourth:12",
+ "",
+ ),
+ [
+ { apiKey: "sk-first", quota: 20 },
+ { apiKey: "sk-ant-second", quota: null },
+ { apiKey: "sk-or-v1-third", quota: 50 },
+ { apiKey: "AIzaFourth", quota: 12 },
+ ],
+ )
+})
+
+test("splits unprefixed key and quota pairs from one pasted row", () => {
+ assert.deepEqual(parseBatchKeys("abcdef123456 20 fedcba654321 x", ""), [
+ { apiKey: "abcdef123456", quota: 20 },
+ { apiKey: "fedcba654321", quota: null },
+ ])
+})
+
+test("keeps a composite credential while reading its trailing quota", () => {
+ assert.deepEqual(parseBatchKeys("AK|SK|us-east-1 | 50", ""), [
+ { apiKey: "AK|SK|us-east-1", quota: 50 },
+ ])
+})
+
+test("pairs keys with u-suffixed quotas on following lines", () => {
+ assert.deepEqual(
+ parseBatchKeys("sk-ant-first\n\n485u\n\nsk-ant-second\n\n35u", ""),
+ [
+ { apiKey: "sk-ant-first", quota: 485 },
+ { apiKey: "sk-ant-second", quota: 35 },
+ ],
+ )
+})
+
+test("parses colloquial dollar quota suffixes", () => {
+ assert.deepEqual(
+ parseBatchKeys(
+ "sk-or-v1-first\n412刀\nsk-or-v1-second\n$35\nsk-or-v1-third\n20美元",
+ "",
+ ),
+ [
+ { apiKey: "sk-or-v1-first", quota: 412 },
+ { apiKey: "sk-or-v1-second", quota: 35 },
+ { apiKey: "sk-or-v1-third", quota: 20 },
+ ],
+ )
+})
+
+test("applies quota lines to keys by matching line number", () => {
+ const entries = parseBatchKeys("sk-first\nsk-second", "", {
+ allowInlineQuota: false,
+ })
+ assert.deepEqual(applyQuotaLines(entries, "20\nx"), [
+ { apiKey: "sk-first", quota: 20 },
+ { apiKey: "sk-second", quota: null },
+ ])
+ assert.throws(() => applyQuotaLines(entries, "20"), /Key 有 2 条/)
+})
+
+test("applies one uniform quota to every key", () => {
+ const entries = parseBatchKeys("sk-first | 10\nsk-second | x", "", {
+ deduplicate: false,
+ })
+ assert.deepEqual(applyUniformQuota(entries, "485刀"), [
+ { apiKey: "sk-first", quota: 485 },
+ { apiKey: "sk-second", quota: 485 },
+ ])
+ assert.deepEqual(applyUniformQuota(entries, "x"), [
+ { apiKey: "sk-first", quota: null },
+ { apiKey: "sk-second", quota: null },
+ ])
+ assert.throws(() => applyUniformQuota(entries, ""), /请输入统一额度/)
+ assert.throws(() => applyUniformQuota(entries, "wrong"), /格式不正确/)
+})
+
+test("keeps duplicate input rows until their separate quotas are aligned", () => {
+ const entries = parseBatchKeys("sk-same\nsk-same", "", {
+ allowInlineQuota: false,
+ deduplicate: false,
+ })
+ assert.deepEqual(applyQuotaLines(entries, "10\n20"), [
+ { apiKey: "sk-same", quota: 10 },
+ { apiKey: "sk-same", quota: 20 },
+ ])
+})
+
+test("names an unknown-quota provider resource with date and x", () => {
+ assert.equal(
+ buildResourceChannelName("Anthropic", [{ apiKey: "secret", quota: null }], {
+ date: new Date("2026-07-04T17:00:00Z"),
+ }),
+ "2026-07-05-Anthropic-Key-secret-额度为x资源",
+ )
+})
+
+test("uses the entered quota and an index for separate batch channels", () => {
+ assert.equal(
+ buildResourceChannelName("OpenAI", [{ apiKey: "secret", quota: 20 }], {
+ date: new Date("2026-07-04T17:00:00Z"),
+ index: 1,
+ total: 3,
+ }),
+ "2026-07-05-OpenAI-Key-secret-额度为20资源-2",
+ )
+})
+
+test("uses the distinguishing start of common provider keys in channel names", () => {
+ assert.equal(keyNameHint("sk-ant-api03-01cLTDxqr27grMrd"), "01cLTDxq")
+ assert.equal(keyNameHint("sk-or-v1-bfcfbc86b09aeb54"), "bfcfbc86")
+ assert.equal(keyNameHint("AKIAEXAMPLE|secret|us-east-1"), "EXAMPLE")
+})
diff --git a/tools/channel-key-importer/tests/channelConfig.test.js b/tools/channel-key-importer/tests/channelConfig.test.js
new file mode 100644
index 0000000000..8d92531f59
--- /dev/null
+++ b/tools/channel-key-importer/tests/channelConfig.test.js
@@ -0,0 +1,345 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+
+import {
+ appendAwsRegionToChannelName,
+ buildAwsGlobalMappings,
+ buildAwsInferenceProfileMappings,
+ getAwsEntryChannelSettings,
+ getAwsRuntimeBaseUrl,
+ getPublicChannelConfig,
+ inferAwsCredentialMode,
+ normalizeAwsBatchCredentialInput,
+ resolveChannelInput,
+ summarizeAwsCredentials,
+ validateBatchCredentialEntries,
+} from "../src/channelConfig.js"
+
+const provider = (id, channelType) => ({ id, channelType })
+
+test("builds the two New API AWS credential formats with region", () => {
+ assert.equal(getPublicChannelConfig("aws", 33).supportsModelFetch, false)
+ assert.deepEqual(
+ resolveChannelInput(provider("aws", 33), {
+ credentialMode: "ak_sk",
+ credentialParts: {
+ accessKey: "AKIAEXAMPLE",
+ secretKey: "secret",
+ region: "us-east-1",
+ },
+ }),
+ {
+ apiKey: "AKIAEXAMPLE|secret|us-east-1",
+ channelOther: "",
+ channelSettings: { aws_key_type: "ak_sk" },
+ models: getPublicChannelConfig("aws", 33).defaultModels,
+ providerMappings: [],
+ awsEntryRouting: false,
+ config: getPublicChannelConfig("aws", 33),
+ },
+ )
+
+ const apiKeyMode = resolveChannelInput(provider("aws", 33), {
+ credentialMode: "api_key",
+ credentialParts: { apiKey: "bedrock-key", region: "eu-west-1" },
+ })
+ assert.equal(apiKeyMode.apiKey, "bedrock-key|eu-west-1")
+ assert.deepEqual(apiKeyMode.channelSettings, { aws_key_type: "api_key" })
+})
+
+test("maps an AWS public model to an inference profile ID or ARN", () => {
+ const result = resolveChannelInput(provider("aws", 33), {
+ credentialMode: "ak_sk",
+ credentialParts: {
+ accessKey: "AKIAEXAMPLE",
+ secretKey: "secret",
+ region: "us-east-1",
+ },
+ providerModels: "claude-sonnet-4-6",
+ providerModelMappings:
+ "claude-sonnet-4-6=arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/example",
+ })
+
+ assert.deepEqual(result.providerMappings, [
+ {
+ standardModel: "claude-sonnet-4-6",
+ actualModel:
+ "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/example",
+ },
+ ])
+})
+
+test("maps AWS models to global inference profile IDs", () => {
+ const result = resolveChannelInput(provider("aws", 33), {
+ credentialMode: "ak_sk",
+ credentialParts: {
+ accessKey: "AKIAEXAMPLE",
+ secretKey: "secret",
+ region: "us-east-1",
+ },
+ providerModels: "claude-sonnet-4-5-20250929\nclaude-sonnet-4-6",
+ providerFlags: { globalInference: true },
+ })
+
+ assert.deepEqual(result.providerMappings, [
+ {
+ standardModel: "claude-sonnet-4-5-20250929",
+ actualModel: "global.anthropic.claude-sonnet-4-5-20250929-v1:0",
+ },
+ {
+ standardModel: "claude-sonnet-4-6",
+ actualModel: "global.anthropic.claude-sonnet-4-6",
+ },
+ ])
+})
+
+test("summarizes AWS source regions without exposing credentials", () => {
+ assert.deepEqual(
+ summarizeAwsCredentials(
+ [{ apiKey: "ABSKFIRST|us-east-1" }, { apiKey: "ABSKSECOND|us-west-2" }],
+ true,
+ ),
+ {
+ regions: ["us-east-1", "us-west-2"],
+ modes: ["api_key"],
+ globalInference: true,
+ },
+ )
+})
+
+test("forces global mappings while preserving application profiles", () => {
+ assert.deepEqual(
+ buildAwsGlobalMappings(
+ ["claude-sonnet-4-6", "claude-opus-4-6"],
+ [
+ {
+ standardModel: "claude-opus-4-6",
+ actualModel:
+ "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/custom",
+ },
+ ],
+ ),
+ [
+ {
+ standardModel: "claude-opus-4-6",
+ actualModel:
+ "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/custom",
+ },
+ {
+ standardModel: "claude-sonnet-4-6",
+ actualModel: "global.anthropic.claude-sonnet-4-6",
+ },
+ ],
+ )
+})
+
+test("adds the detected AWS source region to channel names", () => {
+ assert.equal(
+ appendAwsRegionToChannelName("AWS resource", "ABSKEXAMPLE|us-east-1"),
+ "AWS resource · us-east-1",
+ )
+})
+
+test("maps an AWS API key channel according to its source region", () => {
+ const result = resolveChannelInput(provider("aws", 33), {
+ credentialMode: "api_key",
+ credentialParts: {
+ apiKey: "bedrock-key",
+ region: "eu-west-1",
+ },
+ providerModels: "claude-sonnet-4-6",
+ })
+
+ assert.deepEqual(result.providerMappings, [
+ {
+ standardModel: "claude-sonnet-4-6",
+ actualModel: "eu.anthropic.claude-sonnet-4-6",
+ },
+ ])
+})
+
+test("uses global inference for non-US and non-EU reference regions", () => {
+ assert.deepEqual(
+ buildAwsInferenceProfileMappings(["claude-sonnet-4-6"], "ap-southeast-1"),
+ [
+ {
+ standardModel: "claude-sonnet-4-6",
+ actualModel: "global.anthropic.claude-sonnet-4-6",
+ },
+ ],
+ )
+})
+
+test("builds the reference AWS runtime endpoint from the credential region", () => {
+ assert.equal(
+ getAwsRuntimeBaseUrl("bedrock-key|ca-central-1"),
+ "https://bedrock-runtime.ca-central-1.amazonaws.com",
+ )
+})
+
+test("infers each AWS batch credential mode independently", () => {
+ assert.equal(inferAwsCredentialMode("bedrock-key|us-east-2"), "api_key")
+ assert.equal(inferAwsCredentialMode("AKIAEXAMPLE|secret|eu-west-1"), "ak_sk")
+ assert.equal(inferAwsCredentialMode("invalid"), "")
+ assert.deepEqual(
+ getAwsEntryChannelSettings("bedrock-key|us-east-2", {
+ copied_setting: true,
+ aws_key_type: "ak_sk",
+ }),
+ { copied_setting: true, aws_key_type: "api_key" },
+ )
+})
+
+test("repairs wrapped and alternate-delimiter AWS batch credentials", () => {
+ assert.equal(
+ normalizeAwsBatchCredentialInput(
+ "ABSKEXAMPLELONG\nWRAPPEDVALUE\n|us-east-2",
+ ),
+ "ABSKEXAMPLELONGWRAPPEDVALUE|us-east-2",
+ )
+ assert.equal(
+ normalizeAwsBatchCredentialInput(
+ "ABSKFIRST|us-east-2\nAKIASECOND|secret|eu-west-1",
+ ),
+ "ABSKFIRST|us-east-2\nAKIASECOND|secret|eu-west-1",
+ )
+ assert.equal(
+ normalizeAwsBatchCredentialInput("ABSKTHIRD\nus-west-2\n50"),
+ "ABSKTHIRD|us-west-2 50",
+ )
+})
+
+test("keeps an explicit AWS application profile ahead of global mode", () => {
+ const result = resolveChannelInput(provider("aws", 33), {
+ credentialMode: "ak_sk",
+ credentialParts: {
+ accessKey: "AKIAEXAMPLE",
+ secretKey: "secret",
+ region: "us-east-1",
+ },
+ providerModels: "claude-sonnet-4-6",
+ providerModelMappings:
+ "claude-sonnet-4-6=arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/example",
+ providerFlags: { globalInference: true },
+ })
+
+ assert.equal(result.providerMappings.length, 1)
+ assert.match(result.providerMappings[0].actualModel, /^arn:aws:bedrock:/)
+})
+
+test("allows structured providers to use precomposed batch credentials", () => {
+ const result = resolveChannelInput(provider("aws", 33), {
+ credentialMode: "ak_sk",
+ useRawCredentials: true,
+ })
+
+ assert.equal(result.apiKey, "")
+ assert.deepEqual(result.channelSettings, { aws_key_type: "ak_sk" })
+})
+
+test("normalizes Vertex service account and model-specific regions", () => {
+ const result = resolveChannelInput(provider("vertex-ai", 41), {
+ credentialMode: "json",
+ credentialParts: {
+ serviceAccountJson: JSON.stringify({
+ project_id: "project",
+ client_email: "service@example.com",
+ private_key: "private-key",
+ }),
+ },
+ providerExtra:
+ '{"default":"us-central1","claude-sonnet-4-6":"europe-west1"}',
+ providerModels: "gemini-2.5-pro\nclaude-sonnet-4-6",
+ })
+
+ assert.deepEqual(result.channelSettings, { vertex_key_type: "json" })
+ assert.equal(
+ result.channelOther,
+ '{"default":"us-central1","claude-sonnet-4-6":"europe-west1"}',
+ )
+ assert.deepEqual(result.models, ["gemini-2.5-pro", "claude-sonnet-4-6"])
+})
+
+test("requires deployment names when New API cannot fetch models", () => {
+ assert.throws(
+ () =>
+ resolveChannelInput(provider("azure", 3), {
+ apiKey: "azure-key",
+ providerExtra: "2025-04-01-preview",
+ }),
+ /不能自动拉取模型/,
+ )
+})
+
+test("allows a selected channel template to supply cloud model configuration", () => {
+ const result = resolveChannelInput(provider("azure", 3), {
+ apiKey: "azure-key",
+ configSource: "template",
+ })
+
+ assert.deepEqual(result.models, [])
+ assert.equal(result.channelOther, "")
+})
+
+test("stores Azure API version and deployment names", () => {
+ const result = resolveChannelInput(provider("azure", 3), {
+ apiKey: "azure-key",
+ providerExtra: "2025-04-01-preview",
+ providerModels: "gpt-4o-production",
+ })
+
+ assert.equal(result.channelOther, "2025-04-01-preview")
+ assert.deepEqual(result.models, ["gpt-4o-production"])
+})
+
+test("validates AWS batch credential format and inference region", () => {
+ assert.doesNotThrow(() =>
+ validateBatchCredentialEntries(provider("aws", 33), "ak_sk", [
+ { apiKey: "AKIAEXAMPLE|secret|us-east-1" },
+ { apiKey: "AKIAEXAMPLE2|secret|ap-southeast-2" },
+ ]),
+ )
+ assert.throws(
+ () =>
+ validateBatchCredentialEntries(provider("aws", 33), "ak_sk", [
+ { apiKey: "AKIAEXAMPLE|secret" },
+ ]),
+ /AK\|SK\|Region/,
+ )
+ assert.throws(
+ () =>
+ validateBatchCredentialEntries(provider("aws", 33), "api_key", [
+ { apiKey: "bedrock-key|not-a-region" },
+ ]),
+ /推理地区/,
+ )
+ assert.doesNotThrow(() =>
+ validateBatchCredentialEntries(provider("aws", 33), "auto", [
+ { apiKey: "ABSKEXAMPLE|us-east-2" },
+ { apiKey: "AKIAEXAMPLE|secret|eu-central-1" },
+ ]),
+ )
+ assert.throws(
+ () =>
+ validateBatchCredentialEntries(provider("aws", 33), "auto", [
+ { apiKey: "invalid" },
+ ]),
+ /APIKey\|Region 或 AK\|SK\|Region/,
+ )
+})
+
+test("validates raw Vertex service-account batches", () => {
+ assert.throws(
+ () =>
+ validateBatchCredentialEntries(provider("vertex-ai", 41), "json", [
+ { apiKey: '{"project_id":"missing-fields"}' },
+ ]),
+ /Vertex 服务账号/,
+ )
+})
+
+test("marks OpenRouter for automatic provider-prefix remapping", () => {
+ const config = getPublicChannelConfig("openrouter", 20)
+ assert.equal(config.supportsModelFetch, true)
+ assert.equal(config.autoMapProviderPrefix, true)
+})
diff --git a/tools/channel-key-importer/tests/configStore.test.js b/tools/channel-key-importer/tests/configStore.test.js
new file mode 100644
index 0000000000..a9e5559f37
--- /dev/null
+++ b/tools/channel-key-importer/tests/configStore.test.js
@@ -0,0 +1,157 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+
+import {
+ ConfigStore,
+ findProfileForRecord,
+ normalizeConfigState,
+} from "../src/configStore.js"
+
+test("keeps a direct-login session only in memory for the matching target", async () => {
+ const store = new ConfigStore()
+ const session = {
+ targetUrl: "https://new-api.example.com",
+ userId: "8",
+ username: "admin",
+ sessionCookie: "session=opaque-value",
+ }
+
+ await store.saveSession(session)
+
+ assert.deepEqual(
+ await store.readSession(session.targetUrl, session.userId),
+ session,
+ )
+ assert.equal(await store.readSession("https://other.example.com", "8"), null)
+ await store.clearSession()
+ assert.equal(await store.readSession(session.targetUrl, session.userId), null)
+})
+
+test("restores an explicitly remembered direct-login session", async () => {
+ const sessions = new Map()
+ const tokenStore = {
+ async saveSession(account, session) {
+ sessions.set(account, structuredClone(session))
+ },
+ async readSession(account) {
+ return sessions.get(account) || null
+ },
+ async deleteSession(account) {
+ sessions.delete(account)
+ },
+ }
+ const session = {
+ targetUrl: "https://new-api.example.com",
+ userId: "8",
+ username: "admin",
+ sessionCookie: "session=opaque-value",
+ }
+ const writer = new ConfigStore()
+ writer.setTokenStore(tokenStore)
+ await writer.saveSession(session, true)
+
+ const reader = new ConfigStore()
+ reader.setTokenStore(tokenStore)
+ assert.deepEqual(
+ await reader.readSession(session.targetUrl, session.userId, true),
+ session,
+ )
+})
+
+test("uses a desktop token store for cross-platform encrypted persistence", async () => {
+ const encryptedValues = new Map()
+ const tokenStore = {
+ async save(account, token) {
+ encryptedValues.set(account, `encrypted:${token}`)
+ },
+ async read(account) {
+ return encryptedValues.get(account)?.replace("encrypted:", "") || ""
+ },
+ }
+ const writer = new ConfigStore()
+ writer.setTokenStore(tokenStore)
+ await writer.saveToken("site#1", "admin-token", true)
+
+ const reader = new ConfigStore()
+ reader.setTokenStore(tokenStore)
+ assert.equal(await reader.readToken("site#1", true), "admin-token")
+})
+
+test("migrates the previous single New API configuration to a profile", () => {
+ const state = normalizeConfigState({
+ targetUrl: "https://new-api.example.com",
+ userId: "8",
+ rememberToken: true,
+ allowInsecureHttp: false,
+ })
+
+ assert.equal(state.profiles.length, 1)
+ assert.equal(state.activeProfileId, state.profiles[0].profileId)
+ assert.equal(state.profiles[0].nameSource, "host")
+ assert.deepEqual(
+ {
+ name: state.profiles[0].name,
+ targetUrl: state.profiles[0].targetUrl,
+ userId: state.profiles[0].userId,
+ },
+ {
+ name: "new-api.example.com",
+ targetUrl: "https://new-api.example.com",
+ userId: "8",
+ },
+ )
+})
+
+test("rebinds a legacy import record by its New API target", () => {
+ const profiles = [
+ {
+ profileId: "old-profile",
+ targetUrl: "https://old.example.com",
+ },
+ {
+ profileId: "current-profile",
+ targetUrl: "http://47.77.237.243:3000",
+ },
+ ]
+
+ assert.equal(
+ findProfileForRecord(
+ {
+ profileId: "old-profile",
+ targetUrl: "http://47.77.237.243:3000",
+ },
+ profiles,
+ )?.profileId,
+ "current-profile",
+ )
+})
+
+test("keeps multiple New API profiles and repairs a missing active id", () => {
+ const state = normalizeConfigState({
+ activeProfileId: "missing",
+ profiles: [
+ {
+ profileId: "primary",
+ name: "主站",
+ targetUrl: "https://primary.example.com",
+ userId: "1",
+ },
+ {
+ profileId: "backup",
+ name: "备用站",
+ targetUrl: "https://backup.example.com",
+ userId: "2",
+ },
+ ],
+ })
+
+ assert.equal(state.activeProfileId, "primary")
+ assert.equal(state.profiles[0].nameSource, "custom")
+ assert.deepEqual(
+ state.profiles.map(({ profileId, name }) => ({ profileId, name })),
+ [
+ { profileId: "primary", name: "主站" },
+ { profileId: "backup", name: "备用站" },
+ ],
+ )
+})
diff --git a/tools/channel-key-importer/tests/customProviderServer.e2e.test.js b/tools/channel-key-importer/tests/customProviderServer.e2e.test.js
new file mode 100644
index 0000000000..62ede00021
--- /dev/null
+++ b/tools/channel-key-importer/tests/customProviderServer.e2e.test.js
@@ -0,0 +1,79 @@
+import assert from "node:assert/strict"
+import { mkdtemp, rm } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import test from "node:test"
+
+test("manages named OpenAI-compatible providers through the server API", async () => {
+ const root = await mkdtemp(join(tmpdir(), "dataeyesai-custom-provider-"))
+ const previousDataDir = process.env.DATAEYESAI_DATA_DIR
+ process.env.DATAEYESAI_DATA_DIR = root
+ let importer
+
+ try {
+ const { startImporterServer } = await import("../src/server.js")
+ importer = await startImporterServer({ port: 0 })
+ const bootstrapResponse = await fetch(`${importer.url}/api/bootstrap`)
+ const bootstrap = await bootstrapResponse.json()
+ const api = async (path, { method = "POST", body } = {}) => {
+ const response = await fetch(`${importer.url}${path}`, {
+ method,
+ headers: {
+ "Content-Type": "application/json",
+ "X-Importer-Session": bootstrap.sessionToken,
+ Origin: importer.url,
+ },
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+ })
+ const payload = await response.json()
+ assert.equal(response.status, 200, payload.error)
+ return payload
+ }
+
+ await api("/api/custom-providers", {
+ body: {
+ name: "供应商 10",
+ baseUrl: "https://ten.example.com/v1",
+ },
+ })
+ const created = await api("/api/custom-providers", {
+ body: {
+ name: "供应商 2",
+ baseUrl: "https://two.example.com/v1",
+ },
+ })
+
+ assert.deepEqual(
+ created.customProviders.map((provider) => provider.name),
+ ["供应商 2", "供应商 10"],
+ )
+ const customCards = created.providers.filter(
+ (provider) => provider.customProvider,
+ )
+ assert.equal(customCards.length, 2)
+ assert.equal(customCards[0].channelType, 1)
+ assert.equal(customCards[0].baseUrl, "https://two.example.com/v1")
+
+ const refreshed = await fetch(`${importer.url}/api/bootstrap`)
+ const refreshedBootstrap = await refreshed.json()
+ assert.deepEqual(
+ refreshedBootstrap.customProviders.map((provider) => provider.name),
+ ["供应商 2", "供应商 10"],
+ )
+
+ const selected = created.customProviders[0]
+ const removed = await api(
+ `/api/custom-providers/${encodeURIComponent(selected.id)}`,
+ { method: "DELETE" },
+ )
+ assert.deepEqual(
+ removed.customProviders.map((provider) => provider.name),
+ ["供应商 10"],
+ )
+ } finally {
+ if (importer) await importer.close()
+ if (previousDataDir === undefined) delete process.env.DATAEYESAI_DATA_DIR
+ else process.env.DATAEYESAI_DATA_DIR = previousDataDir
+ await rm(root, { force: true, recursive: true })
+ }
+})
diff --git a/tools/channel-key-importer/tests/customProviderStore.test.js b/tools/channel-key-importer/tests/customProviderStore.test.js
new file mode 100644
index 0000000000..64a4326e41
--- /dev/null
+++ b/tools/channel-key-importer/tests/customProviderStore.test.js
@@ -0,0 +1,98 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+
+import {
+ CustomProviderStore,
+ normalizeCustomProviderState,
+} from "../src/customProviderStore.js"
+
+const createMemoryStateStore = (initial = []) => {
+ let value = structuredClone(initial)
+ return {
+ async read(fallback) {
+ return value ?? structuredClone(fallback)
+ },
+ async write(next) {
+ value = structuredClone(next)
+ },
+ }
+}
+
+test("stores OpenAI-compatible providers in natural name order", async () => {
+ const store = new CustomProviderStore({
+ stateStore: createMemoryStateStore(),
+ now: () => new Date("2026-08-07T01:02:03.000Z"),
+ })
+
+ const ten = await store.save({
+ name: "供应商 10",
+ baseUrl: "https://ten.example.com/v1/",
+ })
+ const two = await store.save({
+ name: "供应商 2",
+ baseUrl: "https://two.example.com/v1",
+ })
+
+ assert.deepEqual(
+ (await store.list()).map((provider) => provider.name),
+ ["供应商 2", "供应商 10"],
+ )
+ assert.equal(ten.baseUrl, "https://ten.example.com/v1")
+ assert.equal((await store.get(two.id)).name, "供应商 2")
+})
+
+test("edits and removes a saved custom provider", async () => {
+ const store = new CustomProviderStore({
+ stateStore: createMemoryStateStore(),
+ })
+ const created = await store.save({
+ name: "旧名称",
+ baseUrl: "https://old.example.com",
+ })
+ const updated = await store.save({
+ id: created.id,
+ name: "新名称",
+ baseUrl: "https://new.example.com/api",
+ })
+
+ assert.equal(updated.id, created.id)
+ assert.equal((await store.list())[0].name, "新名称")
+ await store.remove(created.id)
+ assert.deepEqual(await store.list(), [])
+})
+
+test("rejects duplicate names and unsafe custom provider URLs", async () => {
+ const store = new CustomProviderStore({
+ stateStore: createMemoryStateStore(),
+ })
+ await store.save({
+ name: "内部 OpenAI",
+ baseUrl: "https://first.example.com",
+ })
+
+ await assert.rejects(
+ store.save({
+ name: "内部 openai",
+ baseUrl: "https://second.example.com",
+ }),
+ /同名/,
+ )
+ await assert.rejects(
+ store.save({ name: "文件地址", baseUrl: "file:///tmp/upstream" }),
+ /HTTP/,
+ )
+})
+
+test("keeps valid custom provider rows when legacy state is partial", () => {
+ assert.deepEqual(
+ normalizeCustomProviderState([
+ {
+ id: "valid",
+ name: "有效供应商",
+ baseUrl: "https://valid.example.com",
+ },
+ { id: "invalid", name: "", baseUrl: "" },
+ ]).map(({ id, name }) => ({ id, name })),
+ [{ id: "valid", name: "有效供应商" }],
+ )
+})
diff --git a/tools/channel-key-importer/tests/dataMigration.test.js b/tools/channel-key-importer/tests/dataMigration.test.js
new file mode 100644
index 0000000000..364a0a795f
--- /dev/null
+++ b/tools/channel-key-importer/tests/dataMigration.test.js
@@ -0,0 +1,28 @@
+import assert from "node:assert/strict"
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import test from "node:test"
+
+import { migrateDataFiles } from "../src/dataMigration.js"
+
+test("migrates legacy data without overwriting desktop data", async () => {
+ const root = await mkdtemp(join(tmpdir(), "dataeyesai-migration-"))
+ const source = join(root, "source")
+ const target = join(root, "target")
+ try {
+ await migrateDataFiles(source, target)
+ await mkdir(source, { recursive: true })
+ await writeFile(join(source, "config.json"), "legacy")
+ await writeFile(join(source, "imports.json"), "imports")
+ assert.deepEqual(await migrateDataFiles(source, target), [
+ "config.json",
+ "imports.json",
+ ])
+ await writeFile(join(source, "config.json"), "changed")
+ assert.deepEqual(await migrateDataFiles(source, target), [])
+ assert.equal(await readFile(join(target, "config.json"), "utf8"), "legacy")
+ } finally {
+ await rm(root, { recursive: true, force: true })
+ }
+})
diff --git a/tools/channel-key-importer/tests/importStore.test.js b/tools/channel-key-importer/tests/importStore.test.js
new file mode 100644
index 0000000000..ad0a379313
--- /dev/null
+++ b/tools/channel-key-importer/tests/importStore.test.js
@@ -0,0 +1,137 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+
+import {
+ applyBalanceUsage,
+ applyGatewayUsage,
+ calculateQuotaUsage,
+ ImportStore,
+ keyIdentity,
+} from "../src/importStore.js"
+
+test("stores only a masked hint and irreversible key fingerprint", () => {
+ const identity = keyIdentity("sk-super-secret-1234")
+
+ assert.equal(identity.keyHint, "••••1234")
+ assert.equal(identity.keyFingerprint.length, 12)
+ assert.equal(JSON.stringify(identity).includes("super-secret"), false)
+})
+
+test("finds previously imported keys only for the same New API profile", async () => {
+ let records = []
+ const store = new ImportStore({
+ stateStore: {
+ read: async () => records,
+ write: async (value) => {
+ records = value
+ },
+ },
+ })
+ await store.record({
+ profileId: "site-a",
+ targetName: "A",
+ targetUrl: "https://a.example",
+ providerName: "OpenAI",
+ apiKey: "sk-existing-key",
+ quota: 20,
+ operation: "created",
+ channelName: "A channel",
+ })
+
+ const sameSite = await store.findExistingFingerprints({
+ profileId: "site-a",
+ apiKeys: ["sk-existing-key", "sk-new-key"],
+ })
+ const otherSite = await store.findExistingFingerprints({
+ profileId: "site-b",
+ apiKeys: ["sk-existing-key"],
+ })
+
+ assert.deepEqual(
+ [...sameSite],
+ [keyIdentity("sk-existing-key").keyFingerprint],
+ )
+ assert.equal(otherSite.size, 0)
+})
+
+test("calculates consumption from entered quota and provider balance", () => {
+ assert.deepEqual(calculateQuotaUsage(20, 16.25), {
+ spent: 3.75,
+ usageStatus: "available",
+ })
+ assert.deepEqual(calculateQuotaUsage(20, null), {
+ spent: 0,
+ usageStatus: "awaiting-balance",
+ })
+})
+
+test("does not invent per-key consumption for a shared multi-key channel", () => {
+ assert.deepEqual(calculateQuotaUsage(20, 16.25, true), {
+ spent: null,
+ usageStatus: "shared-channel",
+ })
+})
+
+test("uses New API gateway logs as the per-key usage source", () => {
+ const updated = applyGatewayUsage(
+ { id: "record", spent: null, usageStatus: "shared-channel" },
+ {
+ spentUsd: 1.25,
+ requestCount: 3,
+ promptTokens: 120,
+ completionTokens: 30,
+ lastUsedAt: 1_750_000_000,
+ scannedLogCount: 3,
+ truncated: false,
+ quotaPerUnit: 500_000,
+ },
+ )
+
+ assert.equal(updated.spent, 1.25)
+ assert.equal(updated.usageStatus, "gateway-usage")
+ assert.equal(updated.requestCount, 3)
+ assert.equal(updated.usageLogCount, 3)
+ assert.equal(updated.usageTruncated, false)
+})
+
+test("persists a corrected New API profile with refreshed usage", () => {
+ const updated = applyGatewayUsage(
+ { id: "record", profileId: "old-profile" },
+ {
+ spentUsd: 0,
+ requestCount: 0,
+ promptTokens: 0,
+ completionTokens: 0,
+ lastUsedAt: null,
+ scannedLogCount: 0,
+ truncated: false,
+ quotaPerUnit: 500_000,
+ },
+ {
+ profileId: "current-profile",
+ targetName: "内部资源池",
+ targetUrl: "http://47.77.237.243:3000",
+ },
+ )
+
+ assert.equal(updated.profileId, "current-profile")
+ assert.equal(updated.targetName, "内部资源池")
+})
+
+test("keeps gateway usage when an upstream balance refresh arrives", () => {
+ const updated = applyBalanceUsage(
+ {
+ quota: 100,
+ gatewaySpent: 12,
+ spent: 12,
+ usageStatus: "gateway-usage",
+ },
+ 70,
+ )
+
+ assert.equal(updated.gatewaySpent, 12)
+ assert.equal(updated.spent, 12)
+ assert.equal(updated.usageStatus, "gateway-usage")
+ assert.equal(updated.upstreamSpent, 30)
+ assert.equal(updated.balanceStatus, "available")
+})
diff --git a/tools/channel-key-importer/tests/modelPlan.test.js b/tools/channel-key-importer/tests/modelPlan.test.js
new file mode 100644
index 0000000000..397774c5c7
--- /dev/null
+++ b/tools/channel-key-importer/tests/modelPlan.test.js
@@ -0,0 +1,118 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+
+import {
+ buildModelPlan,
+ buildProviderPrefixMappings,
+} from "../src/modelPlan.js"
+
+test("adds manual models and standard-to-actual mappings", () => {
+ const result = buildModelPlan({
+ fetchedModels: ["openai/gpt-4o", "claude-sonnet-4"],
+ manualModels: ["gpt-5-new", "gpt-5-new"],
+ mappings: [
+ { standardModel: "gpt-4o", actualModel: "openai/gpt-4o" },
+ { standardModel: "claude-sonnet-4", actualModel: "claude-sonnet-4" },
+ ],
+ })
+
+ assert.deepEqual(result.models, [
+ "openai/gpt-4o",
+ "claude-sonnet-4",
+ "gpt-5-new",
+ "gpt-4o",
+ ])
+ assert.deepEqual(result.modelMapping, {
+ "gpt-4o": "openai/gpt-4o",
+ })
+})
+
+test("rejects mappings to models the channel does not expose", () => {
+ assert.throws(
+ () =>
+ buildModelPlan({
+ fetchedModels: ["model-a"],
+ manualModels: [],
+ mappings: [{ standardModel: "official-a", actualModel: "missing" }],
+ }),
+ /映射目标不在/,
+ )
+})
+
+test("maps unique OpenRouter provider models to public model names", () => {
+ const mappings = buildProviderPrefixMappings([
+ "openai/gpt-4o",
+ "anthropic/claude-sonnet-4",
+ "provider-a/shared-model",
+ "provider-b/shared-model",
+ ])
+
+ assert.deepEqual(mappings, [
+ { standardModel: "gpt-4o", actualModel: "openai/gpt-4o" },
+ {
+ standardModel: "claude-sonnet-4",
+ actualModel: "anthropic/claude-sonnet-4",
+ },
+ ])
+
+ assert.deepEqual(
+ buildModelPlan({
+ fetchedModels: [
+ "openai/gpt-4o",
+ "anthropic/claude-sonnet-4",
+ "provider-a/shared-model",
+ "provider-b/shared-model",
+ ],
+ manualModels: [],
+ mappings,
+ hideMappedActualModels: true,
+ }),
+ {
+ models: [
+ "provider-a/shared-model",
+ "provider-b/shared-model",
+ "gpt-4o",
+ "claude-sonnet-4",
+ ],
+ modelMapping: {
+ "gpt-4o": "openai/gpt-4o",
+ "claude-sonnet-4": "anthropic/claude-sonnet-4",
+ },
+ },
+ )
+})
+
+test("rejects aliases that shadow a different actual model", () => {
+ assert.throws(
+ () =>
+ buildModelPlan({
+ fetchedModels: ["model-a", "model-b"],
+ manualModels: [],
+ mappings: [{ standardModel: "model-a", actualModel: "model-b" }],
+ }),
+ /冲突/,
+ )
+})
+
+test("keeps an existing channel template mapping that already exposes its alias", () => {
+ assert.deepEqual(
+ buildModelPlan({
+ fetchedModels: ["gemma-4-26b-a4b-it", "google/gemma-4-26b-a4b-it"],
+ manualModels: [],
+ mappings: [
+ {
+ standardModel: "gemma-4-26b-a4b-it",
+ actualModel: "google/gemma-4-26b-a4b-it",
+ },
+ ],
+ hideMappedActualModels: true,
+ allowMappedStandardModels: true,
+ }),
+ {
+ models: ["gemma-4-26b-a4b-it"],
+ modelMapping: {
+ "gemma-4-26b-a4b-it": "google/gemma-4-26b-a4b-it",
+ },
+ },
+ )
+})
diff --git a/tools/channel-key-importer/tests/newApiClient.test.js b/tools/channel-key-importer/tests/newApiClient.test.js
new file mode 100644
index 0000000000..1c35bdde83
--- /dev/null
+++ b/tools/channel-key-importer/tests/newApiClient.test.js
@@ -0,0 +1,845 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+
+import {
+ createNewApiChannel,
+ createNewApiMultiKeyChannel,
+ discoverNewApiUserId,
+ fetchChannelModels,
+ fetchChannelTemplate,
+ fetchChannelUsage,
+ fetchNewApiDefaultModels,
+ fetchNewApiGroups,
+ fetchNewApiSystemName,
+ findCreatedChannel,
+ findCreatedChannels,
+ findSimilarChannels,
+ listChannelTemplates,
+ loginNewApi,
+ parseRetryAfterMs,
+ refreshChannelBalance,
+ setChannelEnabled,
+ updateExistingChannelKey,
+ verifyNewApi,
+} from "../src/newApiClient.js"
+
+test("reads the configured New API system name from public status", async (context) => {
+ context.mock.method(globalThis, "fetch", async () =>
+ jsonResponse({ success: true, data: { system_name: "内部资源池" } }),
+ )
+
+ assert.equal(
+ await fetchNewApiSystemName("https://new-api.example.com"),
+ "内部资源池",
+ )
+})
+
+test("reads channel groups from the current New API site", async (context) => {
+ context.mock.method(globalThis, "fetch", async () =>
+ jsonResponse({
+ success: true,
+ data: ["default", "vip", "default", ""],
+ }),
+ )
+
+ assert.deepEqual(
+ await fetchNewApiGroups({
+ ...config,
+ targetUrl: "https://groups.new-api.example.com",
+ }),
+ ["default", "vip"],
+ )
+})
+
+test("retries rate-limited New API reads used by preview", async (context) => {
+ let requests = 0
+ context.mock.method(globalThis, "fetch", async () => {
+ requests += 1
+ if (requests === 1) {
+ return new Response(
+ JSON.stringify({ success: false, message: "请求次数过多" }),
+ {
+ status: 429,
+ headers: {
+ "Content-Type": "application/json",
+ "Retry-After": "0",
+ },
+ },
+ )
+ }
+ return jsonResponse({ success: true, data: ["default"] })
+ })
+
+ assert.deepEqual(
+ await fetchNewApiGroups({
+ ...config,
+ targetUrl: "https://retry-groups.new-api.example.com",
+ }),
+ ["default"],
+ )
+ assert.equal(requests, 2)
+})
+
+test("reads built-in models without validating provider keys", async (context) => {
+ context.mock.method(globalThis, "fetch", async () =>
+ jsonResponse({
+ success: true,
+ data: { 43: ["deepseek-chat", "deepseek-reasoner", "deepseek-chat"] },
+ }),
+ )
+
+ assert.deepEqual(await fetchNewApiDefaultModels(config, 43), [
+ "deepseek-chat",
+ "deepseek-reasoner",
+ ])
+})
+
+const config = {
+ targetUrl: "https://new-api.example.com",
+ userId: "1",
+ adminToken: "admin-secret-token",
+}
+
+const deepSeek = {
+ channelType: 43,
+ baseUrl: "https://api.deepseek.com",
+}
+
+const jsonResponse = (payload) =>
+ new Response(JSON.stringify(payload), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ })
+
+test("asks New API to discover provider-specific models", async (context) => {
+ let captured
+ context.mock.method(globalThis, "fetch", async (url, options) => {
+ captured = { url, options }
+ return jsonResponse({
+ success: true,
+ data: ["deepseek-reasoner", "deepseek-chat", "deepseek-chat", ""],
+ })
+ })
+
+ const models = await fetchChannelModels(config, {
+ provider: deepSeek,
+ apiKey: "sk-provider-secret",
+ baseUrl: deepSeek.baseUrl,
+ })
+
+ assert.deepEqual(models, ["deepseek-chat", "deepseek-reasoner"])
+ assert.equal(
+ captured.url,
+ "https://new-api.example.com/api/channel/fetch_models",
+ )
+ assert.equal(
+ captured.options.headers.Authorization,
+ "Bearer admin-secret-token",
+ )
+ assert.equal(captured.options.headers["New-API-User"], "1")
+ assert.deepEqual(JSON.parse(captured.options.body), {
+ base_url: "https://api.deepseek.com",
+ type: 43,
+ key: "sk-provider-secret",
+ })
+})
+
+test("logs in with New API credentials and keeps only the session cookie", async (context) => {
+ let captured
+ context.mock.method(globalThis, "fetch", async (url, options) => {
+ captured = { url, options }
+ return new Response(
+ JSON.stringify({
+ success: true,
+ data: { id: 8, username: "admin", role: 10 },
+ }),
+ {
+ status: 200,
+ headers: {
+ "Content-Type": "application/json",
+ "Set-Cookie": "session=opaque-value; Path=/; HttpOnly; SameSite=Lax",
+ },
+ },
+ )
+ })
+
+ const result = await loginNewApi({
+ targetUrl: config.targetUrl,
+ username: "admin",
+ password: "correct horse battery staple",
+ })
+
+ assert.equal(captured.url, "https://new-api.example.com/api/user/login")
+ assert.deepEqual(JSON.parse(captured.options.body), {
+ username: "admin",
+ password: "correct horse battery staple",
+ })
+ assert.deepEqual(result, {
+ userId: "8",
+ username: "admin",
+ role: 10,
+ sessionCookie: "session=opaque-value",
+ })
+})
+
+test("direct login explains when two-factor authentication needs a token", async (context) => {
+ context.mock.method(globalThis, "fetch", async () =>
+ jsonResponse({ success: true, data: { require_2fa: true } }),
+ )
+
+ await assert.rejects(
+ loginNewApi({
+ targetUrl: config.targetUrl,
+ username: "admin",
+ password: "password",
+ }),
+ /两步验证/,
+ )
+})
+
+test("uses the login session for subsequent channel requests", async (context) => {
+ let capturedHeaders
+ context.mock.method(globalThis, "fetch", async (_url, options) => {
+ capturedHeaders = options.headers
+ return jsonResponse({ success: true, data: { items: [] } })
+ })
+
+ await findSimilarChannels(
+ {
+ targetUrl: config.targetUrl,
+ userId: "8",
+ sessionCookie: "session=opaque-value",
+ },
+ deepSeek,
+ )
+
+ assert.equal(capturedHeaders.Cookie, "session=opaque-value")
+ assert.equal(capturedHeaders["New-API-User"], "8")
+ assert.equal(capturedHeaders.Authorization, undefined)
+})
+
+test("shows a safe New API error when HTTP 200 contains a failed response", async (context) => {
+ context.mock.method(globalThis, "fetch", async () =>
+ jsonResponse({
+ success: false,
+ message: "Unauthorized, invalid access token admin-secret-token",
+ }),
+ )
+
+ await assert.rejects(
+ verifyNewApi(config),
+ (error) =>
+ error.message.includes("invalid access token") &&
+ !error.message.includes("admin-secret-token"),
+ )
+})
+
+test("automatically finds the user id required by a system access token", async (context) => {
+ const attempted = []
+ context.mock.method(globalThis, "fetch", async (_url, options) => {
+ const userId = options.headers["New-API-User"]
+ attempted.push(userId)
+ if (userId === "2") {
+ return jsonResponse({ success: true, data: { items: [] } })
+ }
+ return new Response(
+ JSON.stringify({ success: false, message: "User ID mismatch" }),
+ { status: 401, headers: { "Content-Type": "application/json" } },
+ )
+ })
+
+ assert.equal(
+ await discoverNewApiUserId({
+ targetUrl: config.targetUrl,
+ adminToken: config.adminToken,
+ }),
+ "2",
+ )
+ assert.deepEqual(attempted, ["1", "2"])
+})
+
+test("finds channels sharing a type or a non-empty base URL", async (context) => {
+ let requestedUrl = ""
+ context.mock.method(globalThis, "fetch", async (url) => {
+ requestedUrl = String(url)
+ return jsonResponse({
+ success: true,
+ data: {
+ items: [
+ {
+ id: 1,
+ name: "DeepSeek A",
+ type: 43,
+ base_url: "",
+ status: 1,
+ channel_info: { is_multi_key: true },
+ },
+ {
+ id: 2,
+ name: "Proxy B",
+ type: 8,
+ base_url: "https://api.deepseek.com/",
+ status: 2,
+ },
+ { id: 3, name: "Other", type: 1, base_url: "", status: 1 },
+ ],
+ },
+ })
+ })
+
+ const matches = await findSimilarChannels(config, {
+ ...deepSeek,
+ resolvedBaseUrl: deepSeek.baseUrl,
+ })
+
+ assert.deepEqual(matches, [
+ {
+ id: 1,
+ name: "DeepSeek A",
+ status: "正常",
+ isMultiKey: true,
+ multiKeySize: 0,
+ },
+ {
+ id: 2,
+ name: "Proxy B",
+ status: "已停用",
+ isMultiKey: false,
+ multiKeySize: 0,
+ },
+ ])
+ assert.equal(new URL(requestedUrl).searchParams.get("type"), "43")
+})
+
+test("lists same-type channels for explicit template selection", async (context) => {
+ const requestedUrls = []
+ context.mock.method(globalThis, "fetch", async (url) => {
+ requestedUrls.push(String(url))
+ return jsonResponse({
+ success: true,
+ data: {
+ items: [
+ {
+ id: 26,
+ name: "Bedrock US",
+ type: 33,
+ base_url: "https://bedrock-runtime.us-east-1.amazonaws.com",
+ group: "default,aws",
+ models: "claude-sonnet-4-6,claude-opus-4-6",
+ priority: 7,
+ weight: 100,
+ status: 1,
+ },
+ { id: 25, name: "OpenAI", type: 1, status: 1 },
+ ],
+ },
+ })
+ })
+
+ assert.deepEqual(await listChannelTemplates(config, { channelType: 33 }), [
+ {
+ id: 26,
+ name: "Bedrock US",
+ status: "正常",
+ baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
+ groups: ["default", "aws"],
+ modelCount: 2,
+ priority: 7,
+ weight: 100,
+ },
+ ])
+ assert.ok(
+ requestedUrls.every(
+ (url) => new URL(url).searchParams.get("type") === "33",
+ ),
+ )
+})
+
+test("keeps unique templates when a later channel page is unavailable", async (context) => {
+ const firstPageItems = Array.from({ length: 100 }, (_, index) => ({
+ id: 200 - index,
+ name: `OpenRouter ${index + 1}`,
+ type: 20,
+ status: 1,
+ }))
+ firstPageItems[99] = { ...firstPageItems[0] }
+ let requestCount = 0
+ context.mock.method(globalThis, "fetch", async () => {
+ requestCount += 1
+ if (requestCount === 1) {
+ return jsonResponse({ success: true, data: { items: firstPageItems } })
+ }
+ throw new Error("temporary network failure")
+ })
+
+ const templates = await listChannelTemplates(config, { channelType: 20 })
+
+ assert.equal(requestCount, 2)
+ assert.equal(templates.length, 99)
+ assert.equal(templates[0].id, 200)
+ assert.equal(new Set(templates.map((item) => item.id)).size, templates.length)
+})
+
+test("reads a selected channel template without returning its key", async (context) => {
+ context.mock.method(globalThis, "fetch", async () =>
+ jsonResponse({
+ success: true,
+ data: {
+ id: 26,
+ name: "Bedrock US",
+ type: 33,
+ key: "must-not-be-returned",
+ base_url: "https://bedrock-runtime.us-east-1.amazonaws.com",
+ group: "aws",
+ models: "claude-sonnet-4-6",
+ model_mapping: '{"claude-sonnet-4-6":"us.anthropic.claude-sonnet-4-6"}',
+ settings: '{"aws_key_type":"api_key"}',
+ other: "region-config",
+ priority: 7,
+ weight: 11,
+ status: 1,
+ },
+ }),
+ )
+
+ const template = await fetchChannelTemplate(config, 26, { channelType: 33 })
+ assert.equal(template.key, undefined)
+ assert.deepEqual(template.models, ["claude-sonnet-4-6"])
+ assert.deepEqual(template.modelMappings, [
+ {
+ standardModel: "claude-sonnet-4-6",
+ actualModel: "us.anthropic.claude-sonnet-4-6",
+ },
+ ])
+ assert.deepEqual(template.channelSettings, { aws_key_type: "api_key" })
+ assert.equal(template.advanced.priority, 7)
+ assert.equal(template.advanced.weight, 11)
+})
+
+test("calculates per-key gateway usage from New API admin logs", async (context) => {
+ context.mock.method(globalThis, "fetch", async (url) => {
+ if (String(url).includes("/api/status")) {
+ return jsonResponse({ success: true, data: { quota_per_unit: 500000 } })
+ }
+ return jsonResponse({
+ success: true,
+ data: {
+ items: [
+ {
+ quota: 250000,
+ prompt_tokens: 100,
+ completion_tokens: 25,
+ created_at: 1_750_000_000,
+ other: '{"admin_info":{"multi_key_index":3}}',
+ },
+ {
+ quota: 500000,
+ prompt_tokens: 200,
+ completion_tokens: 50,
+ created_at: 1_750_000_100,
+ other: '{"admin_info":{"multi_key_index":4}}',
+ },
+ ],
+ },
+ })
+ })
+
+ assert.deepEqual(
+ await fetchChannelUsage(config, {
+ channelId: 9,
+ keyIndex: 3,
+ startTimestamp: 1_740_000_000,
+ }),
+ {
+ spentUsd: 0.5,
+ requestCount: 1,
+ promptTokens: 100,
+ completionTokens: 25,
+ lastUsedAt: 1_750_000_000,
+ quotaPerUnit: 500000,
+ scannedLogCount: 2,
+ totalLogCount: null,
+ truncated: false,
+ usageDetailsComplete: true,
+ usageMethod: "log-scan",
+ },
+ )
+})
+
+test("uses New API database stats for exact independent-channel cost", async (context) => {
+ let statRequests = 0
+ let detailRequests = 0
+ context.mock.method(globalThis, "fetch", async (url) => {
+ if (String(url).includes("/api/status")) {
+ return jsonResponse({ success: true, data: { quota_per_unit: 500000 } })
+ }
+ if (String(url).includes("/api/log/stat")) {
+ statRequests += 1
+ return jsonResponse({ success: true, data: { quota: 1_250_000 } })
+ }
+ detailRequests += 1
+ return jsonResponse({
+ success: true,
+ data: {
+ total: 2001,
+ items: Array.from({ length: 100 }, () => ({
+ quota: 1,
+ prompt_tokens: 2,
+ completion_tokens: 1,
+ created_at: 1_750_000_000,
+ })),
+ },
+ })
+ })
+
+ const usage = await fetchChannelUsage(config, {
+ channelId: 9,
+ startTimestamp: 1_740_000_000,
+ })
+
+ assert.equal(statRequests, 1)
+ assert.equal(detailRequests, 1)
+ assert.equal(usage.spentUsd, 2.5)
+ assert.equal(usage.requestCount, 2001)
+ assert.equal(usage.promptTokens, null)
+ assert.equal(usage.completionTokens, null)
+ assert.equal(usage.scannedLogCount, 100)
+ assert.equal(usage.totalLogCount, 2001)
+ assert.equal(usage.truncated, false)
+ assert.equal(usage.usageDetailsComplete, false)
+ assert.equal(usage.usageMethod, "database-stat")
+})
+
+test("appends a key only when the selected channel is multi-key", async (context) => {
+ const requests = []
+ context.mock.method(globalThis, "fetch", async (url, options) => {
+ requests.push({ url, body: JSON.parse(options.body) })
+ return jsonResponse({ success: true })
+ })
+
+ await updateExistingChannelKey(config, {
+ channelId: 7,
+ apiKey: "sk-new-multi-key",
+ append: true,
+ })
+ await updateExistingChannelKey(config, {
+ channelId: 8,
+ apiKey: "sk-replacement-key",
+ append: false,
+ priority: 12,
+ weight: 80,
+ })
+
+ assert.deepEqual(requests, [
+ {
+ url: "https://new-api.example.com/api/channel/",
+ body: { id: 7, key: "sk-new-multi-key", key_mode: "append" },
+ },
+ {
+ url: "https://new-api.example.com/api/channel/",
+ body: {
+ id: 8,
+ key: "sk-replacement-key",
+ priority: 12,
+ weight: 80,
+ },
+ },
+ ])
+})
+
+test("re-enables the selected existing channel after updating it", async (context) => {
+ let captured
+ context.mock.method(globalThis, "fetch", async (url, options) => {
+ captured = { url, options }
+ return jsonResponse({ success: true })
+ })
+
+ await setChannelEnabled(config, 7)
+
+ assert.equal(captured.url, "https://new-api.example.com/api/channel/7/status")
+ assert.equal(captured.options.method, "POST")
+ assert.deepEqual(JSON.parse(captured.options.body), { status: 1 })
+})
+
+test("creates a single New API channel with normalized fields", async (context) => {
+ let requestBody
+ context.mock.method(globalThis, "fetch", async (_url, options) => {
+ requestBody = JSON.parse(options.body)
+ return jsonResponse({ success: true, data: null })
+ })
+
+ await createNewApiChannel(config, {
+ provider: deepSeek,
+ apiKey: "sk-provider-secret",
+ baseUrl: deepSeek.baseUrl,
+ name: "DeepSeek production",
+ models: ["deepseek-chat", "deepseek-reasoner"],
+ modelMapping: { "deepseek-v4": "deepseek-v4-pro" },
+ groups: ["default", "vip"],
+ priority: 12,
+ weight: 80,
+ })
+
+ assert.equal(requestBody.mode, "single")
+ assert.deepEqual(requestBody.channel, {
+ name: "DeepSeek production",
+ type: 43,
+ key: "sk-provider-secret",
+ base_url: "https://api.deepseek.com",
+ models: "deepseek-chat,deepseek-reasoner",
+ model_mapping: '{"deepseek-v4":"deepseek-v4-pro"}',
+ settings: "{}",
+ other: "",
+ group: "default,vip",
+ groups: ["default", "vip"],
+ priority: 12,
+ weight: 80,
+ status: 1,
+ })
+})
+
+test("retries a rate-limited channel creation", async (context) => {
+ let requestCount = 0
+ context.mock.method(globalThis, "fetch", async () => {
+ requestCount += 1
+ if (requestCount === 1) {
+ return new Response(
+ JSON.stringify({ success: false, message: "请求次数过多,请稍后再试" }),
+ {
+ status: 429,
+ headers: {
+ "Content-Type": "application/json",
+ "Retry-After": "0",
+ },
+ },
+ )
+ }
+ return jsonResponse({ success: true })
+ })
+
+ await createNewApiChannel(config, {
+ name: "OpenRouter retry",
+ provider: { channelType: 20 },
+ apiKey: "sk-or-rate-limited",
+ baseUrl: "https://openrouter.ai/api",
+ models: ["openai/gpt-4o"],
+ modelMapping: {},
+ channelSettings: {},
+ groups: ["default"],
+ })
+
+ assert.equal(requestCount, 2)
+})
+
+test("does not blindly replay a rate-limited channel creation", async (context) => {
+ let requestCount = 0
+ context.mock.method(globalThis, "fetch", async () => {
+ requestCount += 1
+ return new Response(
+ JSON.stringify({ success: false, message: "请求次数过多,请稍后再试" }),
+ {
+ status: 429,
+ headers: { "Content-Type": "application/json" },
+ },
+ )
+ })
+
+ await assert.rejects(
+ createNewApiChannel(config, {
+ name: "OpenRouter queued retry",
+ provider: { channelType: 20 },
+ apiKey: "sk-or-rate-limited",
+ baseUrl: "https://openrouter.ai/api",
+ models: ["openai/gpt-4o"],
+ modelMapping: {},
+ channelSettings: {},
+ groups: ["default"],
+ }),
+ /管理接口正在限流/,
+ )
+
+ assert.equal(requestCount, 1)
+})
+
+test("passes provider-specific settings and extra configuration to New API", async (context) => {
+ let requestBody
+ context.mock.method(globalThis, "fetch", async (_url, options) => {
+ requestBody = JSON.parse(options.body)
+ return jsonResponse({ success: true })
+ })
+
+ await createNewApiChannel(config, {
+ provider: { channelType: 33 },
+ apiKey: "access|secret|us-east-1",
+ baseUrl: "",
+ name: "AWS Bedrock",
+ models: ["claude-3-5-sonnet-20241022"],
+ modelMapping: {},
+ channelSettings: { aws_key_type: "ak_sk" },
+ channelOther: '{"default":"us-east-1"}',
+ templateConfig: {
+ priority: 7,
+ weight: 11,
+ param_override: '{"temperature":0.2}',
+ },
+ priority: 15,
+ weight: 120,
+ groups: ["default"],
+ })
+
+ assert.equal(requestBody.channel.settings, '{"aws_key_type":"ak_sk"}')
+ assert.equal(requestBody.channel.other, '{"default":"us-east-1"}')
+ assert.equal(requestBody.channel.priority, 15)
+ assert.equal(requestBody.channel.weight, 120)
+ assert.equal(requestBody.channel.param_override, '{"temperature":0.2}')
+})
+
+test("creates many keys as one native New API multi-key channel", async (context) => {
+ let requestBody
+ context.mock.method(globalThis, "fetch", async (_url, options) => {
+ requestBody = JSON.parse(options.body)
+ return jsonResponse({ success: true })
+ })
+
+ await createNewApiMultiKeyChannel(config, {
+ provider: deepSeek,
+ apiKeys: ["sk-first", "sk-second"],
+ baseUrl: deepSeek.baseUrl,
+ name: "DeepSeek pool",
+ models: ["deepseek-chat"],
+ modelMapping: {},
+ groups: ["default"],
+ })
+
+ assert.equal(requestBody.mode, "multi_to_single")
+ assert.equal(requestBody.multi_key_mode, "random")
+ assert.equal(requestBody.channel.key, "sk-first\nsk-second")
+ assert.equal(requestBody.channel.name, "DeepSeek pool")
+})
+
+test("locates the newest exact channel after creation", async (context) => {
+ context.mock.method(globalThis, "fetch", async () =>
+ jsonResponse({
+ success: true,
+ data: {
+ items: [
+ {
+ id: 7,
+ name: "DeepSeek production",
+ type: 43,
+ base_url: "https://api.deepseek.com",
+ },
+ {
+ id: 9,
+ name: "DeepSeek production",
+ type: 43,
+ base_url: "https://api.deepseek.com/",
+ },
+ {
+ id: 11,
+ name: "Different name",
+ type: 43,
+ base_url: "https://api.deepseek.com",
+ },
+ ],
+ },
+ }),
+ )
+
+ const channel = await findCreatedChannel(config, {
+ provider: deepSeek,
+ baseUrl: deepSeek.baseUrl,
+ name: "DeepSeek production",
+ })
+
+ assert.equal(channel.id, 9)
+})
+
+test("locates a bulk channel batch with paginated list requests", async (context) => {
+ let requestCount = 0
+ context.mock.method(globalThis, "fetch", async () => {
+ requestCount += 1
+ if (requestCount === 1) {
+ return jsonResponse({
+ success: true,
+ data: {
+ items: [
+ {
+ id: 20,
+ name: "DeepSeek · 1",
+ type: 43,
+ base_url: "https://api.deepseek.com",
+ },
+ ...Array.from({ length: 99 }, (_, index) => ({
+ id: 1000 + index,
+ name: `Other ${index}`,
+ type: 1,
+ base_url: "",
+ })),
+ ],
+ },
+ })
+ }
+ return jsonResponse({
+ success: true,
+ data: {
+ items: [
+ {
+ id: 21,
+ name: "DeepSeek · 2",
+ type: 43,
+ base_url: "https://api.deepseek.com/",
+ },
+ ],
+ },
+ })
+ })
+ const inputs = [
+ {
+ provider: deepSeek,
+ baseUrl: deepSeek.baseUrl,
+ name: "DeepSeek · 1",
+ },
+ {
+ provider: deepSeek,
+ baseUrl: deepSeek.baseUrl,
+ name: "DeepSeek · 2",
+ },
+ ]
+
+ const channels = await findCreatedChannels(config, inputs)
+
+ assert.equal(requestCount, 2)
+ assert.equal(channels.get(inputs[0]).id, 20)
+ assert.equal(channels.get(inputs[1]).id, 21)
+})
+
+test("parses numeric and HTTP-date Retry-After values", () => {
+ const now = Date.parse("2026-08-03T00:00:00.000Z")
+ assert.equal(parseRetryAfterMs("12", now), 12_000)
+ assert.equal(parseRetryAfterMs("Mon, 03 Aug 2026 00:00:09 GMT", now), 9_000)
+ assert.equal(parseRetryAfterMs("invalid", now), null)
+})
+
+test("normalizes a supported New API channel balance", async (context) => {
+ context.mock.method(globalThis, "fetch", async () =>
+ jsonResponse({ success: true, balance: 18.625 }),
+ )
+
+ assert.deepEqual(await refreshChannelBalance(config, 9), {
+ status: "available",
+ currency: "USD",
+ balance: 18.625,
+ })
+})
+
+test("returns an explicit unsupported balance state", async (context) => {
+ context.mock.method(globalThis, "fetch", async () =>
+ jsonResponse({ success: false, message: "billing not supported" }),
+ )
+
+ assert.deepEqual(await refreshChannelBalance(config, 9), {
+ status: "unsupported",
+ reason: "该渠道或上游暂不支持自动查询余额",
+ })
+})
diff --git a/tools/channel-key-importer/tests/previewStore.test.js b/tools/channel-key-importer/tests/previewStore.test.js
new file mode 100644
index 0000000000..47d2a0519f
--- /dev/null
+++ b/tools/channel-key-importer/tests/previewStore.test.js
@@ -0,0 +1,26 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+
+import { PreviewStore } from "../src/previewStore.js"
+
+test("preview credentials can be consumed only once", () => {
+ const store = new PreviewStore()
+ const previewId = store.create({ apiKey: "secret", models: ["model-a"] })
+
+ assert.deepEqual(store.take(previewId), {
+ apiKey: "secret",
+ models: ["model-a"],
+ })
+ assert.throws(() => store.take(previewId), /已过期/)
+})
+
+test("blocks duplicate writes while allowing a failed attempt to release", () => {
+ const store = new PreviewStore()
+ const preview = { apiKey: "secret", models: ["model-a"] }
+ const previewId = store.create(preview)
+
+ assert.deepEqual(store.claim(previewId), preview)
+ assert.throws(() => store.claim(previewId), /正在写入/)
+ store.release(previewId)
+ assert.deepEqual(store.claim(previewId), preview)
+})
diff --git a/tools/channel-key-importer/tests/providerIcons.test.js b/tools/channel-key-importer/tests/providerIcons.test.js
new file mode 100644
index 0000000000..fbd92d9009
--- /dev/null
+++ b/tools/channel-key-importer/tests/providerIcons.test.js
@@ -0,0 +1,29 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+
+import {
+ getProviderIconSvg,
+ PROVIDER_ICON_NAMES,
+} from "../src/providerIcons.js"
+
+test("renders local vendor SVGs for mapped New API channels", async () => {
+ const svg = await getProviderIconSvg("openai")
+
+ assert.equal(PROVIDER_ICON_NAMES.deepseek, "DeepSeek")
+ assert.equal(PROVIDER_ICON_NAMES.zhipu, "Zhipu")
+ assert.match(svg, /^