feat(tests): add comprehensive tests for TokenRouter and related components
- Implement tests for TokenRouter downstream policy, covering allowedRouteIds and site weight multipliers. - Add tests for TokenRouter patterns and model mapping, including regex pattern matching and model mapping precedence. - Create tests for BrandIcon component to verify brand detection logic. - Develop tests for Accounts page, focusing on rebind panel behavior and refresh action on error. - Introduce fuzzy search utility tests to validate substring and subsequence matching. - Implement navigation focus helpers tests for building and parsing focus paths. - Add progressive render tests to ensure correct initial and next visible counts. - Create route model candidates index tests to validate route candidate indexing and matching logic.
This commit is contained in:
@@ -0,0 +1,59 @@
|
|||||||
|
name: Docs Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: docs-pages
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build Docs
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build docs site
|
||||||
|
run: npm run docs:build
|
||||||
|
|
||||||
|
- name: Setup Pages
|
||||||
|
uses: actions/configure-pages@v5
|
||||||
|
|
||||||
|
- name: Upload pages artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: docs/.vitepress/dist
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy Docs
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<img src="docs/logos/logo-full.png" alt="Metapi" width="280">
|
<img src="docs/logos/logo-full.png" alt="Metapi" width="280">
|
||||||
|
|
||||||
🔮 **中转站的中转站 — 将分散的 AI 中转站聚合为一个统一网关**
|
**中转站的中转站 — 将分散的 AI 中转站聚合为一个统一网关**
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
把你在各处注册的 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API 等站点,
|
把你在各处注册的 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API 等站点,
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="docs/README.md"><strong>📚 文档中心</strong></a> ·
|
<a href="docs/index.md"><strong>📚 文档中心</strong></a> ·
|
||||||
<a href="docs/getting-started.md">快速上手</a> ·
|
<a href="docs/getting-started.md">快速上手</a> ·
|
||||||
<a href="docs/deployment.md">部署指南</a> ·
|
<a href="docs/deployment.md">部署指南</a> ·
|
||||||
<a href="docs/configuration.md">配置说明</a> ·
|
<a href="docs/configuration.md">配置说明</a> ·
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
|
|
||||||
现在 AI 生态里有越来越多基于 New API / One API 系列的聚合中转站,要管理多个站点的余额、模型列表和 API 密钥,往往既分散又费时。
|
现在 AI 生态里有越来越多基于 New API / One API 系列的聚合中转站,要管理多个站点的余额、模型列表和 API 密钥,往往既分散又费时。
|
||||||
|
|
||||||
Metapi 作为这些中转站之上的聚合层,把多个站点统一到 **一个 API Key、一个入口**。当前已支持基于以下项目的中转站:
|
**Metapi** 作为这些中转站之上的**元聚合层(Meta-Aggregation Layer)**,把多个站点统一到 **一个入口(可按项目配置多个下游 API Key)**——下游所有工具(Cursor、Claude Code、Codex、Open WebUI 等)即可无感接入全部模型。当前已支持以下上游平台:
|
||||||
|
|
||||||
- [New API](https://github.com/QuantumNous/new-api)
|
- [New API](https://github.com/QuantumNous/new-api)
|
||||||
- [One API](https://github.com/songquanpeng/one-api)
|
- [One API](https://github.com/songquanpeng/one-api)
|
||||||
@@ -64,7 +64,7 @@ Metapi 作为这些中转站之上的聚合层,把多个站点统一到 **一
|
|||||||
|
|
||||||
| 痛点 | Metapi 怎么解决 |
|
| 痛点 | Metapi 怎么解决 |
|
||||||
| ------------------------------------- | --------------------------------------------------------------- |
|
| ------------------------------------- | --------------------------------------------------------------- |
|
||||||
| 🔑 每个站点一个 Key,下游工具配置一堆 | **一个 Key 统一代理**,所有站点的模型自动聚合到 `/v1/*` |
|
| 🔑 每个站点一个 Key,下游工具配置一堆 | **统一代理入口 + 可选多下游 Key 策略**,模型自动聚合到 `/v1/*` |
|
||||||
| 💸 不知道哪个站点用某个模型最便宜 | **智能路由** 自动按成本、余额、使用率选最优通道 |
|
| 💸 不知道哪个站点用某个模型最便宜 | **智能路由** 自动按成本、余额、使用率选最优通道 |
|
||||||
| 🔄 某个站点挂了,手动切换好麻烦 | **自动故障转移**,一个通道失败自动冷却并切到下一个 |
|
| 🔄 某个站点挂了,手动切换好麻烦 | **自动故障转移**,一个通道失败自动冷却并切到下一个 |
|
||||||
| 📊 余额分散在各处,不知道还剩多少 | **集中看板** 一目了然,余额不足自动告警 |
|
| 📊 余额分散在各处,不知道还剩多少 | **集中看板** 一目了然,余额不足自动告警 |
|
||||||
@@ -79,27 +79,82 @@ Metapi 作为这些中转站之上的聚合层,把多个站点统一到 **一
|
|||||||
<tr>
|
<tr>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<img src="docs/screenshots/dashboard.png" alt="dashboard" style="width:100%;height:auto;"/>
|
<img src="docs/screenshots/dashboard.png" alt="dashboard" style="width:100%;height:auto;"/>
|
||||||
<div>仪表盘</div>
|
<div><b>仪表盘</b> — 余额分布、消费趋势、系统概览</div>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<img src="docs/screenshots/model-marketplace.png" alt="model-marketplace" style="width:100%;height:auto;"/>
|
<img src="docs/screenshots/model-marketplace.png" alt="model-marketplace" style="width:100%;height:auto;"/>
|
||||||
<div>模型广场</div>
|
<div><b>模型广场</b> — 跨站模型覆盖、定价对比、实测指标</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<img src="docs/screenshots/routes.png" alt="routes" style="width:100%;height:auto;"/>
|
<img src="docs/screenshots/routes.png" alt="routes" style="width:100%;height:auto;"/>
|
||||||
<div>智能路由</div>
|
<div><b>智能路由</b> — 多通道概率分配、成本优先选路</div>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<img src="docs/screenshots/accounts.png" alt="accounts" style="width:100%;height:auto;"/>
|
<img src="docs/screenshots/accounts.png" alt="accounts" style="width:100%;height:auto;"/>
|
||||||
<div>账号管理</div>
|
<div><b>账号管理</b> — 多站点多账号、健康状态追踪</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<img src="docs/screenshots/sites.png" alt="sites" style="width:100%;height:auto;"/>
|
||||||
|
<div><b>站点管理</b> — 上游站点配置与状态一览</div>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<img src="docs/screenshots/tokens.png" alt="tokens" style="width:100%;height:auto;"/>
|
||||||
|
<div><b>令牌管理</b> — API Token 生命周期管理</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<img src="docs/screenshots/playground.png" alt="playground" style="width:100%;height:auto;"/>
|
||||||
|
<div><b>模型操练场</b> — 在线交互式模型测试</div>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<img src="docs/screenshots/checkin.png" alt="checkin" style="width:100%;height:auto;"/>
|
||||||
|
<div><b>签到记录</b> — 自动签到状态与奖励追踪</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<img src="docs/screenshots/proxy-logs.png" alt="proxy-logs" style="width:100%;height:auto;"/>
|
||||||
|
<div><b>使用日志</b> — 代理请求日志与成本明细</div>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<img src="docs/screenshots/monitor.png" alt="monitor" style="width:100%;height:auto;"/>
|
||||||
|
<div><b>可用性监控</b> — 通道健康度实时监测</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<img src="docs/screenshots/settings.png" alt="settings" style="width:100%;height:auto;"/>
|
||||||
|
<div><b>系统设置</b> — 全局参数与安全配置</div>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<img src="docs/screenshots/notification-settings.png" alt="notification-settings" style="width:100%;height:auto;"/>
|
||||||
|
<div><b>通知设置</b> — 多渠道告警与推送配置</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🏛️ 架构概览
|
||||||
|
|
||||||
|
**下游客户端**(Cursor · Claude Code · Codex · Open WebUI · Cherry Studio 等)
|
||||||
|
 ↓  `Authorization: Bearer <PROXY_TOKEN>`
|
||||||
|
**Metapi 网关**
|
||||||
|
 • 统一代理 `/v1/*` — 兼容 OpenAI / Claude 全接口
|
||||||
|
 • 智能路由引擎 — 按成本、余额、可用率加权选路,失败自动冷却与重试
|
||||||
|
 • 模型发现 — 自动聚合上游全部模型,零配置
|
||||||
|
 • 格式转换 — OpenAI ⇄ Claude 双向透明转换
|
||||||
|
 • 自动签到 · 余额管理 · 告警通知 · 数据看板
|
||||||
|
 ↓
|
||||||
|
**上游平台**(New API · One API · OneHub · DoneHub · Veloera · AnyRouter · Sub2API …)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ✨ 核心功能
|
## ✨ 核心功能
|
||||||
|
|
||||||
### 🌐 统一代理网关
|
### 🌐 统一代理网关
|
||||||
@@ -117,6 +172,11 @@ Metapi 作为这些中转站之上的聚合层,把多个站点统一到 **一
|
|||||||
- 请求失败自动重试,自动切换其他可用通道
|
- 请求失败自动重试,自动切换其他可用通道
|
||||||
- 路由决策可视化解释,每次选择透明可审计
|
- 路由决策可视化解释,每次选择透明可审计
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/routes.png" alt="smart-routing-detail" width="700"/>
|
||||||
|
<p><sub>智能路由配置界面 — 支持精确匹配、通配符、概率分配等多种路由策略</sub></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
### 📡 多平台聚合管理
|
### 📡 多平台聚合管理
|
||||||
|
|
||||||
| 平台 | 适配器 | 说明 |
|
| 平台 | 适配器 | 说明 |
|
||||||
@@ -146,6 +206,11 @@ Metapi 作为这些中转站之上的聚合层,把多个站点统一到 **一
|
|||||||
- 上游模型目录缓存与品牌分类(OpenAI、Anthropic、Google、DeepSeek 等)
|
- 上游模型目录缓存与品牌分类(OpenAI、Anthropic、Google、DeepSeek 等)
|
||||||
- 交互式模型测试器,在线验证模型可用性
|
- 交互式模型测试器,在线验证模型可用性
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/model-marketplace.png" alt="model-marketplace-detail" width="700"/>
|
||||||
|
<p><sub>模型广场 — 一站式浏览所有可用模型的覆盖率、定价和性能指标</sub></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
### ✅ 自动签到
|
### ✅ 自动签到
|
||||||
|
|
||||||
- Cron 定时自动签到(默认每天 08:00)
|
- Cron 定时自动签到(默认每天 08:00)
|
||||||
@@ -180,6 +245,22 @@ Metapi 作为这些中转站之上的聚合层,把多个站点统一到 **一
|
|||||||
- 全局搜索(站点、账号、模型)
|
- 全局搜索(站点、账号、模型)
|
||||||
- 系统事件日志、代理请求日志(含模型、状态、延迟、Token 消耗、成本估算)
|
- 系统事件日志、代理请求日志(含模型、状态、延迟、Token 消耗、成本估算)
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/dashboard.png" alt="dashboard-detail" width="700"/>
|
||||||
|
<p><sub>数据看板 — 余额分布、消费趋势、系统健康状态一目了然</sub></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
### 🔬 模型操练场
|
||||||
|
|
||||||
|
- 在线交互式对话测试,即时验证模型可用性与响应质量
|
||||||
|
- 支持选择任意已路由模型,对比不同通道的输出效果
|
||||||
|
- 流式 / 非流式双模式测试
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/playground.png" alt="playground-detail" width="700"/>
|
||||||
|
<p><sub>模型操练场 — 在线交互式测试,验证模型可用性与响应质量</sub></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
### 📦 轻量部署
|
### 📦 轻量部署
|
||||||
|
|
||||||
- **单 Docker 容器**,内置 SQLite,无外部依赖
|
- **单 Docker 容器**,内置 SQLite,无外部依赖
|
||||||
@@ -299,8 +380,25 @@ docker compose pull && docker compose up -d && docker image prune -f
|
|||||||
|
|
||||||
## 📚 文档中心
|
## 📚 文档中心
|
||||||
|
|
||||||
|
> 文档站(VitePress)本地预览:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> npm run docs:dev
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> 文档站构建:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> npm run docs:build
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> GitHub Actions 自动发布:推送到 `main` 分支后,会执行 `.github/workflows/docs-pages.yml` 并部署到 GitHub Pages。
|
||||||
|
> 首次使用需在仓库设置中开启:
|
||||||
|
> `Settings -> Pages -> Build and deployment -> Source: GitHub Actions`
|
||||||
|
|
||||||
| 分类 | 链接 | 说明 |
|
| 分类 | 链接 | 说明 |
|
||||||
| ------------- | ----------------------------------------------------- | -------------------------------------- |
|
| ------------- | ----------------------------------------------------- | -------------------------------------- |
|
||||||
|
| 🏠 文档站首页 | [docs/index.md](docs/index.md) | 可发布文档站入口(导航/侧栏/搜索) |
|
||||||
| 📖 文档总览 | [docs/README.md](docs/README.md) | 文档导航与索引 |
|
| 📖 文档总览 | [docs/README.md](docs/README.md) | 文档导航与索引 |
|
||||||
| 🚀 快速上手 | [docs/getting-started.md](docs/getting-started.md) | 10 分钟启动 |
|
| 🚀 快速上手 | [docs/getting-started.md](docs/getting-started.md) | 10 分钟启动 |
|
||||||
| 🚢 部署指南 | [docs/deployment.md](docs/deployment.md) | Docker Compose、反向代理、升级回滚 |
|
| 🚢 部署指南 | [docs/deployment.md](docs/deployment.md) | Docker Compose、反向代理、升级回滚 |
|
||||||
@@ -308,6 +406,7 @@ docker compose pull && docker compose up -d && docker image prune -f
|
|||||||
| 🔌 客户端接入 | [docs/client-integration.md](docs/client-integration.md) | Open WebUI / Cherry Studio / Cursor 等 |
|
| 🔌 客户端接入 | [docs/client-integration.md](docs/client-integration.md) | Open WebUI / Cherry Studio / Cursor 等 |
|
||||||
| 🔧 运维手册 | [docs/operations.md](docs/operations.md) | 备份恢复、日志排查、健康检查 |
|
| 🔧 运维手册 | [docs/operations.md](docs/operations.md) | 备份恢复、日志排查、健康检查 |
|
||||||
| ❓ 常见问题 | [docs/faq.md](docs/faq.md) | 常见报错与修复路径 |
|
| ❓ 常见问题 | [docs/faq.md](docs/faq.md) | 常见报错与修复路径 |
|
||||||
|
| 🧩 经验沉淀规范 | [docs/community/faq-tutorial-guidelines.md](docs/community/faq-tutorial-guidelines.md) | FAQ 与教程提交流程、模板与命名规则 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -395,6 +494,15 @@ Metapi 对下游暴露标准 OpenAI / Claude 兼容接口:
|
|||||||
|
|
||||||
请求头携带 `Authorization: Bearer <PROXY_TOKEN>` 即可访问。
|
请求头携带 `Authorization: Bearer <PROXY_TOKEN>` 即可访问。
|
||||||
|
|
||||||
|
默认可使用全局 `PROXY_TOKEN`。
|
||||||
|
从 `系统设置 -> 下游 API Key 策略` 可创建多个项目级下游 Key,并单独配置:
|
||||||
|
|
||||||
|
- 过期时间(ExpireAt)
|
||||||
|
- 费用上限与请求上限(MaxCost / MaxRequests)
|
||||||
|
- 模型白名单(SupportedModels,支持 exact/glob/re:regex)
|
||||||
|
- 路由白名单(AllowedRouteIds)
|
||||||
|
- 站点倍率(SiteWeightMultipliers,用于控制不同项目的上游偏好占比)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔌 接入下游客户端
|
## 🔌 接入下游客户端
|
||||||
@@ -582,7 +690,7 @@ Metapi 完全自托管,所有数据(账号、令牌、路由、日志)均
|
|||||||
|
|
||||||
## ⭐ Star History
|
## ⭐ Star History
|
||||||
|
|
||||||
[](https://www.star-history.com/#cita-777/metapi&type=date&legend=top-left)
|
[](https://www.star-history.com/#cita-777/metapi&type=date&legend=top-left)
|
||||||
---
|
---
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|||||||
+116
-8
@@ -36,7 +36,7 @@ into <strong>one API Key, one endpoint</strong>, with automatic model discovery,
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="docs/README.md"><strong>Docs</strong></a> ·
|
<a href="docs/index.md"><strong>Docs</strong></a> ·
|
||||||
<a href="docs/getting-started.md">Quick Start</a> ·
|
<a href="docs/getting-started.md">Quick Start</a> ·
|
||||||
<a href="docs/deployment.md">Deployment</a> ·
|
<a href="docs/deployment.md">Deployment</a> ·
|
||||||
<a href="docs/configuration.md">Configuration</a> ·
|
<a href="docs/configuration.md">Configuration</a> ·
|
||||||
@@ -52,7 +52,7 @@ into <strong>one API Key, one endpoint</strong>, with automatic model discovery,
|
|||||||
|
|
||||||
The AI ecosystem is seeing a growing number of aggregation relay stations based on New API / One API and similar projects. Managing balances, model lists, and API keys across multiple sites is scattered and time-consuming.
|
The AI ecosystem is seeing a growing number of aggregation relay stations based on New API / One API and similar projects. Managing balances, model lists, and API keys across multiple sites is scattered and time-consuming.
|
||||||
|
|
||||||
Metapi acts as an aggregation layer on top of these relay stations, unifying multiple sites into **one API Key, one endpoint**. Currently supported upstream platforms:
|
**Metapi** acts as the **Meta-Aggregation Layer** on top of these relay stations, unifying multiple sites into **one endpoint (with configurable per-project downstream API Keys)** — all downstream tools (Cursor, Claude Code, Codex, Open WebUI, etc.) can seamlessly access all models. Currently supported upstream platforms:
|
||||||
|
|
||||||
- [New API](https://github.com/QuantumNous/new-api)
|
- [New API](https://github.com/QuantumNous/new-api)
|
||||||
- [One API](https://github.com/songquanpeng/one-api)
|
- [One API](https://github.com/songquanpeng/one-api)
|
||||||
@@ -64,7 +64,7 @@ Metapi acts as an aggregation layer on top of these relay stations, unifying mul
|
|||||||
|
|
||||||
| Pain Point | How Metapi Solves It |
|
| Pain Point | How Metapi Solves It |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| One key per site, tedious client config | **One key, unified proxy** — all site models auto-aggregated under `/v1/*` |
|
| One key per site, tedious client config | **Unified proxy endpoint + optional per-project downstream keys** — all site models auto-aggregated under `/v1/*` |
|
||||||
| No idea which site offers the cheapest model | **Smart routing** auto-selects the optimal channel by cost, balance, and usage |
|
| No idea which site offers the cheapest model | **Smart routing** auto-selects the optimal channel by cost, balance, and usage |
|
||||||
| Site goes down, manual switching is a hassle | **Auto-failover** — failed channels cool down and traffic shifts automatically |
|
| Site goes down, manual switching is a hassle | **Auto-failover** — failed channels cool down and traffic shifts automatically |
|
||||||
| Balances scattered everywhere | **Centralized dashboard** — at-a-glance overview with low-balance alerts |
|
| Balances scattered everywhere | **Centralized dashboard** — at-a-glance overview with low-balance alerts |
|
||||||
@@ -79,27 +79,82 @@ Metapi acts as an aggregation layer on top of these relay stations, unifying mul
|
|||||||
<tr>
|
<tr>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<img src="docs/screenshots/dashboard.png" alt="dashboard" style="width:100%;height:auto;"/>
|
<img src="docs/screenshots/dashboard.png" alt="dashboard" style="width:100%;height:auto;"/>
|
||||||
<div>Dashboard</div>
|
<div><b>Dashboard</b> — Balance distribution, spending trends, system overview</div>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<img src="docs/screenshots/model-marketplace.png" alt="model-marketplace" style="width:100%;height:auto;"/>
|
<img src="docs/screenshots/model-marketplace.png" alt="model-marketplace" style="width:100%;height:auto;"/>
|
||||||
<div>Model Marketplace</div>
|
<div><b>Model Marketplace</b> — Cross-site model coverage, pricing comparison, measured metrics</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<img src="docs/screenshots/routes.png" alt="routes" style="width:100%;height:auto;"/>
|
<img src="docs/screenshots/routes.png" alt="routes" style="width:100%;height:auto;"/>
|
||||||
<div>Smart Routing</div>
|
<div><b>Smart Routing</b> — Multi-channel probability distribution, cost-priority routing</div>
|
||||||
</td>
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<img src="docs/screenshots/accounts.png" alt="accounts" style="width:100%;height:auto;"/>
|
<img src="docs/screenshots/accounts.png" alt="accounts" style="width:100%;height:auto;"/>
|
||||||
<div>Account Management</div>
|
<div><b>Account Management</b> — Multi-site multi-account, health state tracking</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<img src="docs/screenshots/sites.png" alt="sites" style="width:100%;height:auto;"/>
|
||||||
|
<div><b>Site Management</b> — Upstream site configuration and status overview</div>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<img src="docs/screenshots/tokens.png" alt="tokens" style="width:100%;height:auto;"/>
|
||||||
|
<div><b>Token Management</b> — API Token lifecycle management</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<img src="docs/screenshots/playground.png" alt="playground" style="width:100%;height:auto;"/>
|
||||||
|
<div><b>Model Playground</b> — Interactive online model testing</div>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<img src="docs/screenshots/checkin.png" alt="checkin" style="width:100%;height:auto;"/>
|
||||||
|
<div><b>Check-in Log</b> — Auto check-in status and reward tracking</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<img src="docs/screenshots/proxy-logs.png" alt="proxy-logs" style="width:100%;height:auto;"/>
|
||||||
|
<div><b>Usage Logs</b> — Proxy request logs and cost breakdown</div>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<img src="docs/screenshots/monitor.png" alt="monitor" style="width:100%;height:auto;"/>
|
||||||
|
<div><b>Availability Monitor</b> — Channel health real-time monitoring</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<img src="docs/screenshots/settings.png" alt="settings" style="width:100%;height:auto;"/>
|
||||||
|
<div><b>System Settings</b> — Global parameters and security configuration</div>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<img src="docs/screenshots/notification-settings.png" alt="notification-settings" style="width:100%;height:auto;"/>
|
||||||
|
<div><b>Notification Settings</b> — Multi-channel alert and push configuration</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
**Downstream Clients** (Cursor · Claude Code · Codex · Open WebUI · Cherry Studio, etc.)
|
||||||
|
 ↓  `Authorization: Bearer <PROXY_TOKEN>`
|
||||||
|
**Metapi Gateway**
|
||||||
|
 • Unified Proxy `/v1/*` — compatible with all OpenAI / Claude endpoints
|
||||||
|
 • Smart Routing Engine — weighted selection by cost, balance, and availability; auto-cooldown & retry on failure
|
||||||
|
 • Model Discovery — auto-aggregates all upstream models with zero config
|
||||||
|
 • Format Conversion — transparent bidirectional OpenAI ⇄ Claude conversion
|
||||||
|
 • Auto Check-in · Balance Management · Alerts & Notifications · Data Dashboard
|
||||||
|
 ↓
|
||||||
|
**Upstream Platforms** (New API · One API · OneHub · DoneHub · Veloera · AnyRouter · Sub2API …)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Unified Proxy Gateway
|
### Unified Proxy Gateway
|
||||||
@@ -117,6 +172,11 @@ Metapi acts as an aggregation layer on top of these relay stations, unifying mul
|
|||||||
- Auto-retry on failure with automatic channel switching
|
- Auto-retry on failure with automatic channel switching
|
||||||
- Routing decisions are visually explainable — every choice is transparent and auditable
|
- Routing decisions are visually explainable — every choice is transparent and auditable
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/routes.png" alt="smart-routing-detail" width="700"/>
|
||||||
|
<p><sub>Smart Routing UI — supports exact match, wildcards, probability distribution, and more routing strategies</sub></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
### Multi-Platform Aggregation
|
### Multi-Platform Aggregation
|
||||||
|
|
||||||
| Platform | Adapter | Description |
|
| Platform | Adapter | Description |
|
||||||
@@ -146,6 +206,11 @@ Each adapter supports: account login, balance queries, model enumeration, token
|
|||||||
- Upstream model catalog caching with brand classification (OpenAI, Anthropic, Google, DeepSeek, etc.)
|
- Upstream model catalog caching with brand classification (OpenAI, Anthropic, Google, DeepSeek, etc.)
|
||||||
- Interactive model tester for online verification
|
- Interactive model tester for online verification
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/model-marketplace.png" alt="model-marketplace-detail" width="700"/>
|
||||||
|
<p><sub>Model Marketplace — browse all available models' coverage, pricing, and performance metrics in one place</sub></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
### Auto Check-in
|
### Auto Check-in
|
||||||
|
|
||||||
- Cron-scheduled automatic check-in (default: daily at 08:00)
|
- Cron-scheduled automatic check-in (default: daily at 08:00)
|
||||||
@@ -180,6 +245,22 @@ Alert scenarios: low balance warning, site/account anomalies, check-in failures,
|
|||||||
- Global search (sites, accounts, models)
|
- Global search (sites, accounts, models)
|
||||||
- System event logs, proxy request logs (model, status, latency, token usage, cost estimation)
|
- System event logs, proxy request logs (model, status, latency, token usage, cost estimation)
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/dashboard.png" alt="dashboard-detail" width="700"/>
|
||||||
|
<p><sub>Data Dashboard — balance distribution, spending trends, system health at a glance</sub></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
### Model Playground
|
||||||
|
|
||||||
|
- Interactive chat testing to instantly verify model availability and response quality
|
||||||
|
- Select any routed model to compare outputs across different channels
|
||||||
|
- Streaming / non-streaming dual mode testing
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/playground.png" alt="playground-detail" width="700"/>
|
||||||
|
<p><sub>Model Playground — interactive online testing, verify model availability and response quality</sub></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
### Lightweight Deployment
|
### Lightweight Deployment
|
||||||
|
|
||||||
- **Single Docker container** with built-in SQLite — no external dependencies
|
- **Single Docker container** with built-in SQLite — no external dependencies
|
||||||
@@ -299,8 +380,25 @@ For more deployment options, see [Deployment Guide](docs/deployment.md).
|
|||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
> Docs site (VitePress) local preview:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> npm run docs:dev
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Docs site build:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> npm run docs:build
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> GitHub Actions auto publish: each push to `main` runs `.github/workflows/docs-pages.yml` and deploys to GitHub Pages.
|
||||||
|
> First-time setup in repository settings:
|
||||||
|
> `Settings -> Pages -> Build and deployment -> Source: GitHub Actions`
|
||||||
|
|
||||||
| Category | Link | Description |
|
| Category | Link | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| Docs Home | [docs/index.md](docs/index.md) | Publishable docs portal with nav/sidebar/search |
|
||||||
| Overview | [docs/README.md](docs/README.md) | Documentation index |
|
| Overview | [docs/README.md](docs/README.md) | Documentation index |
|
||||||
| Quick Start | [docs/getting-started.md](docs/getting-started.md) | Get running in 10 minutes |
|
| Quick Start | [docs/getting-started.md](docs/getting-started.md) | Get running in 10 minutes |
|
||||||
| Deployment | [docs/deployment.md](docs/deployment.md) | Docker Compose, reverse proxy, upgrades |
|
| Deployment | [docs/deployment.md](docs/deployment.md) | Docker Compose, reverse proxy, upgrades |
|
||||||
@@ -308,6 +406,7 @@ For more deployment options, see [Deployment Guide](docs/deployment.md).
|
|||||||
| Client Integration | [docs/client-integration.md](docs/client-integration.md) | Open WebUI / Cherry Studio / Cursor, etc. |
|
| Client Integration | [docs/client-integration.md](docs/client-integration.md) | Open WebUI / Cherry Studio / Cursor, etc. |
|
||||||
| Operations | [docs/operations.md](docs/operations.md) | Backup, logging, health checks |
|
| Operations | [docs/operations.md](docs/operations.md) | Backup, logging, health checks |
|
||||||
| FAQ | [docs/faq.md](docs/faq.md) | Common errors and fixes |
|
| FAQ | [docs/faq.md](docs/faq.md) | Common errors and fixes |
|
||||||
|
| FAQ/Tutorial Contribution | [docs/community/faq-tutorial-guidelines.md](docs/community/faq-tutorial-guidelines.md) | Templates and workflow for community knowledge |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -395,6 +494,15 @@ Metapi exposes standard OpenAI / Claude compatible endpoints:
|
|||||||
|
|
||||||
Include `Authorization: Bearer <PROXY_TOKEN>` in request headers.
|
Include `Authorization: Bearer <PROXY_TOKEN>` in request headers.
|
||||||
|
|
||||||
|
The global `PROXY_TOKEN` works by default.
|
||||||
|
From `System Settings -> Downstream API Key Strategy` you can create multiple project-level downstream keys with individual configuration:
|
||||||
|
|
||||||
|
- Expiration time (ExpireAt)
|
||||||
|
- Cost and request limits (MaxCost / MaxRequests)
|
||||||
|
- Model allowlist (SupportedModels, supports exact/glob/re:regex)
|
||||||
|
- Route allowlist (AllowedRouteIds)
|
||||||
|
- Site weight multipliers (SiteWeightMultipliers, control per-project upstream preference ratios)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Client Integration
|
## Client Integration
|
||||||
@@ -582,7 +690,7 @@ If you discover a security issue, please refer to [SECURITY.md](SECURITY.md) and
|
|||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://www.star-history.com/#cita-777/metapi&type=date&legend=top-left)
|
[](https://www.star-history.com/#cita-777/metapi&type=date&legend=top-left)
|
||||||
---
|
---
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
dist/
|
||||||
|
cache/
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { defineConfig } from 'vitepress';
|
||||||
|
import { withMermaid } from 'vitepress-plugin-mermaid';
|
||||||
|
|
||||||
|
export default withMermaid(
|
||||||
|
defineConfig({
|
||||||
|
lang: 'zh-CN',
|
||||||
|
title: 'Metapi 文档',
|
||||||
|
description: 'Metapi 使用文档、FAQ 与教程沉淀',
|
||||||
|
cleanUrls: true,
|
||||||
|
lastUpdated: true,
|
||||||
|
ignoreDeadLinks: true,
|
||||||
|
themeConfig: {
|
||||||
|
siteTitle: 'Metapi Docs',
|
||||||
|
logo: '/logos/logo-icon-512.png',
|
||||||
|
nav: [
|
||||||
|
{ text: '首页', link: '/' },
|
||||||
|
{ text: '快速上手', link: '/getting-started' },
|
||||||
|
{ text: 'FAQ', link: '/faq' },
|
||||||
|
{ text: '社区贡献', link: '/community/faq-tutorial-guidelines' },
|
||||||
|
{ text: '项目主页', link: 'https://github.com/cita-777/metapi' },
|
||||||
|
],
|
||||||
|
sidebar: [
|
||||||
|
{
|
||||||
|
text: '开始',
|
||||||
|
items: [
|
||||||
|
{ text: '文档首页', link: '/' },
|
||||||
|
{ text: '文档总览', link: '/README' },
|
||||||
|
{ text: '快速上手', link: '/getting-started' },
|
||||||
|
{ text: '部署指南', link: '/deployment' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '使用与配置',
|
||||||
|
items: [
|
||||||
|
{ text: '配置说明', link: '/configuration' },
|
||||||
|
{ text: '客户端接入', link: '/client-integration' },
|
||||||
|
{ text: '运维手册', link: '/operations' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '知识库',
|
||||||
|
items: [
|
||||||
|
{ text: '常见问题 FAQ', link: '/faq' },
|
||||||
|
{ text: '目录规范', link: '/project-structure' },
|
||||||
|
{ text: 'FAQ/教程贡献规范', link: '/community/faq-tutorial-guidelines' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
socialLinks: [
|
||||||
|
{ icon: 'github', link: 'https://github.com/cita-777/metapi' },
|
||||||
|
],
|
||||||
|
outline: {
|
||||||
|
level: [2, 3],
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
message: 'MIT Licensed',
|
||||||
|
copyright: 'Copyright (c) 2026 Metapi Contributors',
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
provider: 'local',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
+23
-26
@@ -10,6 +10,18 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 文档站运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run docs:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
构建静态站点:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run docs:build
|
||||||
|
```
|
||||||
|
|
||||||
## 快速导航
|
## 快速导航
|
||||||
|
|
||||||
| 文档 | 适合谁 | 解决的问题 |
|
| 文档 | 适合谁 | 解决的问题 |
|
||||||
@@ -20,36 +32,21 @@
|
|||||||
| 🔌 [客户端接入](./client-integration.md) | 下游应用接入者 | Open WebUI、Cherry Studio、Cursor 等接入 |
|
| 🔌 [客户端接入](./client-integration.md) | 下游应用接入者 | Open WebUI、Cherry Studio、Cursor 等接入 |
|
||||||
| 🔧 [运维手册](./operations.md) | 运维人员 | 备份恢复、日志排查、健康检查 |
|
| 🔧 [运维手册](./operations.md) | 运维人员 | 备份恢复、日志排查、健康检查 |
|
||||||
| ❓ [常见问题](./faq.md) | 所有用户 | 常见报错与修复路径 |
|
| ❓ [常见问题](./faq.md) | 所有用户 | 常见报错与修复路径 |
|
||||||
|
| 🧩 [FAQ/教程贡献规范](./community/faq-tutorial-guidelines.md) | 社区贡献者 | 统一沉淀 FAQ、教程与排障经验 |
|
||||||
| 📁 [目录规范](./project-structure.md) | 开发者 | 项目目录组织与约定 |
|
| 📁 [目录规范](./project-structure.md) | 开发者 | 项目目录组织与约定 |
|
||||||
|
|
||||||
## 架构概览
|
## 架构概览
|
||||||
|
|
||||||
```text
|
**下游客户端**(Cursor · Claude Code · Codex · Open WebUI · Cherry Studio 等)
|
||||||
┌──────────────────────────────────────────────────┐
|
 ↓  `Authorization: Bearer <PROXY_TOKEN>`
|
||||||
│ 下游客户端 │
|
**Metapi 网关**
|
||||||
│ Open WebUI / Cherry Studio / Cursor / Claude │
|
 • 统一代理 `/v1/*` — 兼容 OpenAI / Claude 全接口
|
||||||
│ Code / Roo Code / Kilo Code / ... │
|
 • 智能路由引擎 — 按成本、余额、可用率加权选路,失败自动冷却与重试
|
||||||
└───────────────────┬──────────────────────────────┘
|
 • 模型发现 — 自动聚合上游全部模型,零配置
|
||||||
│ Authorization: Bearer <PROXY_TOKEN>
|
 • 格式转换 — OpenAI ⇄ Claude 双向透明转换
|
||||||
▼
|
 • 自动签到 · 余额管理 · 告警通知 · 数据看板
|
||||||
┌──────────────────────────────────────────────────┐
|
 ↓
|
||||||
│ Metapi 网关 │
|
**上游平台**(New API · One API · OneHub · DoneHub · Veloera · AnyRouter · Sub2API …)
|
||||||
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
|
|
||||||
│ │ 统一代理 │ │ 智能路由 │ │ 格式转换 │ │
|
|
||||||
│ │ /v1/* │ │ 成本+余额 │ │ OpenAI⇄ │ │
|
|
||||||
│ │ │ │ +使用率 │ │ Claude │ │
|
|
||||||
│ └────────────┘ └────────────┘ └────────────┘ │
|
|
||||||
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
|
|
||||||
│ │ 自动签到 │ │ 余额管理 │ │ 多渠道告警 │ │
|
|
||||||
│ └────────────┘ └────────────┘ └────────────┘ │
|
|
||||||
└───────────────────┬──────────────────────────────┘
|
|
||||||
│
|
|
||||||
┌───────────┼───────────┐
|
|
||||||
▼ ▼ ▼
|
|
||||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
|
||||||
│ New API │ │ One API │ │ Veloera │ ...
|
|
||||||
└─────────┘ └─────────┘ └─────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## 核心概念
|
## 核心概念
|
||||||
|
|
||||||
|
|||||||
+55
-11
@@ -13,7 +13,7 @@ Metapi 暴露标准 OpenAI / Claude 兼容接口,下游客户端只需配置
|
|||||||
| 配置项 | 值 |
|
| 配置项 | 值 |
|
||||||
|--------|-----|
|
|--------|-----|
|
||||||
| **Base URL** | `https://your-domain.com`(不要拼接 `/v1`,客户端会自动加) |
|
| **Base URL** | `https://your-domain.com`(不要拼接 `/v1`,客户端会自动加) |
|
||||||
| **API Key** | 你设置的 `PROXY_TOKEN` 值 |
|
| **API Key** | 你设置的 `PROXY_TOKEN` 值,或创建的下游 API Key |
|
||||||
|
|
||||||
模型列表自动从 `GET /v1/models` 获取,无需手动配置。
|
模型列表自动从 `GET /v1/models` 获取,无需手动配置。
|
||||||
|
|
||||||
@@ -61,19 +61,46 @@ Metapi 暴露标准 OpenAI / Claude 兼容接口,下游客户端只需配置
|
|||||||
|
|
||||||
### Claude Code
|
### Claude Code
|
||||||
|
|
||||||
```bash
|
在 `~/.claude/settings.json` 中配置:
|
||||||
export ANTHROPIC_BASE_URL=https://your-domain.com
|
|
||||||
export ANTHROPIC_API_KEY=your-proxy-sk-token
|
```json
|
||||||
export CLAUDE_CODE_ATTRIBUTION_HEADER=0
|
{
|
||||||
export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
|
“env”: {
|
||||||
# 可选:进一步关闭非必要模型调用
|
“ANTHROPIC_BASE_URL”: “https://your-domain.com”,
|
||||||
export DISABLE_NON_ESSENTIAL_MODEL_CALLS=1
|
“ANTHROPIC_API_KEY”: “your-proxy-sk-token”,
|
||||||
|
“ANTHROPIC_AUTH_TOKEN”: “your-proxy-sk-token”,
|
||||||
|
“CLAUDE_CODE_ATTRIBUTION_HEADER”: “0”,
|
||||||
|
“CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC”: “1”
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
或在配置文件中设置相应的环境变量(例如 `~/.claude/settings.json`)。
|
> 说明:不同版本客户端可能读取 `ANTHROPIC_API_KEY` 或 `ANTHROPIC_AUTH_TOKEN`,建议同时设置。
|
||||||
|
> 上述变量由 Claude Code 客户端读取,属于客户端行为开关;
|
||||||
|
> `metapi` 服务端无法替代客户端”设置这些变量”,只能处理已经发来的请求。
|
||||||
|
|
||||||
> 说明:上述变量由 Claude Code 客户端读取,属于客户端行为开关;
|
### Codex CLI
|
||||||
> `metapi` 服务端无法替代客户端“设置这些变量”,只能处理已经发来的请求。
|
|
||||||
|
`~/.codex/config.toml`
|
||||||
|
|
||||||
|
```toml
|
||||||
|
model = “gpt-5”
|
||||||
|
model_provider = “metapi”
|
||||||
|
|
||||||
|
[model_providers.metapi]
|
||||||
|
name = “metapi”
|
||||||
|
base_url = “https://your-domain.com/v1”
|
||||||
|
```
|
||||||
|
|
||||||
|
`~/.codex/auth.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
“OPENAI_API_KEY”: “your-proxy-sk-token”
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 提示:`model` 需要是你在 Metapi `GET /v1/models` 可见的模型名。
|
||||||
|
|
||||||
### Roo Code / Kilo Code
|
### Roo Code / Kilo Code
|
||||||
|
|
||||||
@@ -83,6 +110,23 @@ export DISABLE_NON_ESSENTIAL_MODEL_CALLS=1
|
|||||||
|
|
||||||
所有支持 OpenAI API 格式的客户端均可接入,只需找到 Base URL 和 API Key 的配置位置即可。
|
所有支持 OpenAI API 格式的客户端均可接入,只需找到 Base URL 和 API Key 的配置位置即可。
|
||||||
|
|
||||||
|
## 下游 API Key 策略
|
||||||
|
|
||||||
|
除了全局 `PROXY_TOKEN`,你还可以在 **系统设置 → 下游 API Key 策略** 中创建多个项目级下游 Key,每个 Key 可独立配置:
|
||||||
|
|
||||||
|
| 配置项 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| **过期时间**(ExpiresAt) | Key 到期后自动失效 |
|
||||||
|
| **费用上限**(MaxCost) | 累计消费超限后拒绝请求 |
|
||||||
|
| **请求上限**(MaxRequests) | 累计请求数超限后拒绝请求 |
|
||||||
|
| **模型白名单**(SupportedModels) | 限制可用模型,支持 exact / glob / `re:regex` |
|
||||||
|
| **路由白名单**(AllowedRouteIds) | 限制可走的路由规则 |
|
||||||
|
| **站点倍率**(SiteWeightMultipliers) | 按站点调整路由权重,控制上游偏好 |
|
||||||
|
|
||||||
|
适用于多团队/多项目共用一个 Metapi 实例但需要独立计量和权限控制的场景。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 快速自检
|
## 快速自检
|
||||||
|
|
||||||
部署完成后,用以下命令验证链路:
|
部署完成后,用以下命令验证链路:
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# FAQ / 教程贡献规范
|
||||||
|
|
||||||
|
> 目标:让任何人都可以提交可复用、可检索、可维护的经验内容,避免口口相传丢失。
|
||||||
|
|
||||||
|
## 适用范围
|
||||||
|
|
||||||
|
- FAQ(常见问题与报错处理)
|
||||||
|
- 使用教程(从 0 到 1、进阶技巧、集成示例)
|
||||||
|
- 运维经验(排障、监控、升级、回滚)
|
||||||
|
|
||||||
|
## FAQ 模板
|
||||||
|
|
||||||
|
复制以下结构提交:
|
||||||
|
|
||||||
|
```md
|
||||||
|
## 问题标题(简短、可搜索)
|
||||||
|
|
||||||
|
### 现象
|
||||||
|
- 具体报错信息:
|
||||||
|
- 出现场景:
|
||||||
|
|
||||||
|
### 根因
|
||||||
|
- 触发原因 1
|
||||||
|
- 触发原因 2
|
||||||
|
|
||||||
|
### 解决步骤
|
||||||
|
1. 步骤一
|
||||||
|
2. 步骤二
|
||||||
|
3. 验证结果
|
||||||
|
|
||||||
|
### 预防建议
|
||||||
|
- 建议一
|
||||||
|
- 建议二
|
||||||
|
```
|
||||||
|
|
||||||
|
## 教程模板
|
||||||
|
|
||||||
|
复制以下结构提交:
|
||||||
|
|
||||||
|
```md
|
||||||
|
# 教程标题
|
||||||
|
|
||||||
|
## 适用人群
|
||||||
|
- 新手 / 运维 / 集成开发者
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
- 条件一
|
||||||
|
- 条件二
|
||||||
|
|
||||||
|
## 操作步骤
|
||||||
|
1. ...
|
||||||
|
2. ...
|
||||||
|
3. ...
|
||||||
|
|
||||||
|
## 验证方式
|
||||||
|
- 成功标准 1
|
||||||
|
- 成功标准 2
|
||||||
|
|
||||||
|
## 常见坑位
|
||||||
|
- 坑位 1 与规避方式
|
||||||
|
- 坑位 2 与规避方式
|
||||||
|
```
|
||||||
|
|
||||||
|
## 命名与归档建议
|
||||||
|
|
||||||
|
- FAQ 统一追加到 [`/docs/faq.md`](../faq.md)
|
||||||
|
- 教程建议新增 `docs/community/tutorial-<topic>.md`
|
||||||
|
- 文件名使用小写英文与 `-`,例如:`tutorial-openwebui.md`
|
||||||
|
|
||||||
|
## 提交要求
|
||||||
|
|
||||||
|
- 标题可检索:包含关键词(平台名、报错码、功能名)
|
||||||
|
- 步骤可复现:避免“试一下”“差不多”描述
|
||||||
|
- 版本要明确:注明适用版本、部署方式
|
||||||
|
- 结果可验证:给出成功判定标准
|
||||||
|
|
||||||
|
## 维护原则
|
||||||
|
|
||||||
|
- 过时内容要标记“已失效”并给替代方案
|
||||||
|
- 同类问题合并,避免 FAQ 膨胀重复
|
||||||
|
- 优先给“最短修复路径”,再补原理解释
|
||||||
@@ -78,6 +78,28 @@ Metapi 的路由引擎按多因子加权选择最优通道。
|
|||||||
|
|
||||||
示例:`ADMIN_IP_ALLOWLIST=192.168.1.0/24,10.0.0.1`
|
示例:`ADMIN_IP_ALLOWLIST=192.168.1.0/24,10.0.0.1`
|
||||||
|
|
||||||
|
## 下游 API Key 策略
|
||||||
|
|
||||||
|
除了全局 `PROXY_TOKEN`,Metapi 支持在管理后台「设置 → 下游 API Key」中创建多个项目级下游 Key。
|
||||||
|
|
||||||
|
每个 Key 可独立配置以下约束:
|
||||||
|
|
||||||
|
| 字段 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| `name` | Key 名称(仅供标识) | `team-backend` |
|
||||||
|
| `expiresAt` | 过期时间 | `2026-12-31T23:59:59Z` |
|
||||||
|
| `maxCost` | 累计费用上限 | `100`(超限后拒绝请求) |
|
||||||
|
| `maxRequests` | 累计请求数上限 | `10000` |
|
||||||
|
| `supportedModels` | 模型白名单(JSON 数组) | `["gpt-4o", "claude-*", "re:deepseek.*"]` |
|
||||||
|
| `allowedRouteIds` | 可走的路由 ID 白名单 | `[1, 3, 5]` |
|
||||||
|
| `siteWeightMultipliers` | 站点权重倍率 | `{"1": 2.0, "3": 0.5}` |
|
||||||
|
|
||||||
|
模型白名单支持三种匹配模式:
|
||||||
|
|
||||||
|
- **精确匹配**:`gpt-4o`
|
||||||
|
- **通配符**:`claude-*`(glob 风格)
|
||||||
|
- **正则表达式**:`re:deepseek.*`(`re:` 前缀)
|
||||||
|
|
||||||
## 通知渠道
|
## 通知渠道
|
||||||
|
|
||||||
### Webhook
|
### Webhook
|
||||||
|
|||||||
@@ -55,6 +55,57 @@ docker run -d --name metapi \
|
|||||||
> - `./data:/app/data` — 相对路径,数据存到当前目录下的 `data` 文件夹
|
> - `./data:/app/data` — 相对路径,数据存到当前目录下的 `data` 文件夹
|
||||||
> - 也可以使用绝对路径:`/your/custom/path:/app/data`
|
> - 也可以使用绝对路径:`/your/custom/path:/app/data`
|
||||||
|
|
||||||
|
## Release 包部署(免 Docker)
|
||||||
|
|
||||||
|
适用于没有 Docker 环境的服务器,或 Windows / macOS 桌面使用场景。
|
||||||
|
|
||||||
|
### 前置条件
|
||||||
|
|
||||||
|
- Node.js 20+(推荐 22 LTS)
|
||||||
|
|
||||||
|
### 步骤
|
||||||
|
|
||||||
|
1. 从 [Releases](https://github.com/cita-777/metapi/releases) 下载与你系统匹配的压缩包(Linux / Windows / macOS)
|
||||||
|
2. 解压后进入目录
|
||||||
|
|
||||||
|
Linux / macOS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export AUTH_TOKEN=your-admin-token
|
||||||
|
export PROXY_TOKEN=your-proxy-sk-token
|
||||||
|
export PORT=4000
|
||||||
|
export DATA_DIR=./data
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows(PowerShell):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:AUTH_TOKEN="your-admin-token"
|
||||||
|
$env:PROXY_TOKEN="your-proxy-sk-token"
|
||||||
|
$env:PORT="4000"
|
||||||
|
$env:DATA_DIR="./data"
|
||||||
|
.\start.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
`start.sh` / `start.bat` 的作用是:
|
||||||
|
|
||||||
|
- 先检查 `better-sqlite3` 与当前 Node.js ABI 是否兼容
|
||||||
|
- 若检测到 ABI 不匹配,会自动尝试 `npm rebuild better-sqlite3`
|
||||||
|
- 若重建失败,会回退到 `npm ci --omit=dev` 重新安装运行时依赖
|
||||||
|
- 最后执行数据库迁移并启动服务
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> 如果本机 Node 主版本与打包时不同(例如包内依赖基于 Node 22,而本机是 Node 24),首次启动可能触发自动重建,需联网。
|
||||||
|
|
||||||
|
### Release 包升级
|
||||||
|
|
||||||
|
1. 下载新版本的 Release 包
|
||||||
|
2. 解压覆盖旧文件(`data/` 目录不受影响)
|
||||||
|
3. 重新启动即可
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 反向代理
|
## 反向代理
|
||||||
|
|
||||||
### Nginx
|
### Nginx
|
||||||
@@ -151,6 +202,36 @@ Metapi 的所有运行数据存储在 SQLite 数据库中,位于 `DATA_DIR`(
|
|||||||
- 保留最近 7~30 天的备份
|
- 保留最近 7~30 天的备份
|
||||||
- 重要操作前手动快照
|
- 重要操作前手动快照
|
||||||
|
|
||||||
|
## 文档站部署
|
||||||
|
|
||||||
|
Metapi 使用 [VitePress](https://vitepress.dev) 构建文档站,支持本地预览和 GitHub Pages 自动部署。
|
||||||
|
|
||||||
|
### 本地预览
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run docs:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 `http://localhost:4173` 查看文档站。
|
||||||
|
|
||||||
|
### 构建静态站点
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run docs:build
|
||||||
|
```
|
||||||
|
|
||||||
|
构建产物位于 `docs/.vitepress/dist/`,可部署到任意静态站点托管服务。
|
||||||
|
|
||||||
|
### GitHub Pages 自动部署
|
||||||
|
|
||||||
|
推送到 `main` 分支后,`.github/workflows/docs-pages.yml` 会自动构建并部署到 GitHub Pages。
|
||||||
|
|
||||||
|
首次使用需在仓库设置中开启:
|
||||||
|
|
||||||
|
`Settings → Pages → Build and deployment → Source: GitHub Actions`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 下一步
|
## 下一步
|
||||||
|
|
||||||
- [配置说明](./configuration.md) — 详细环境变量
|
- [配置说明](./configuration.md) — 详细环境变量
|
||||||
|
|||||||
+31
-1
@@ -36,6 +36,15 @@ docker compose up -d
|
|||||||
|
|
||||||
或使用 `.env` 文件。
|
或使用 `.env` 文件。
|
||||||
|
|
||||||
|
### Q: Release 包启动失败
|
||||||
|
|
||||||
|
**A:** 排查步骤:
|
||||||
|
|
||||||
|
1. 确认 Node.js 已安装且版本 ≥ 20:`node --version`
|
||||||
|
2. 首次启动时如果报 `better-sqlite3` 相关错误,启动脚本会尝试自动重建,需要网络连接
|
||||||
|
3. 如果本机 Node 主版本与打包时不同(例如包内依赖基于 Node 22,而本机是 Node 24),首次启动需联网执行 `npm rebuild`
|
||||||
|
4. Windows 用户确认使用 PowerShell 而非 CMD 运行 `start.bat`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 代理相关
|
## 代理相关
|
||||||
@@ -86,6 +95,23 @@ docker compose up -d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 下游 API Key 相关
|
||||||
|
|
||||||
|
### Q: 如何限制不同项目/团队的用量
|
||||||
|
|
||||||
|
**A:** 在管理后台 **设置 → 下游 API Key** 中为每个项目创建独立的 Key,可单独配置:
|
||||||
|
|
||||||
|
- 费用上限(MaxCost)和请求上限(MaxRequests)
|
||||||
|
- 模型白名单(限制可用模型,支持通配符和正则)
|
||||||
|
- 路由白名单(限制可走的路由规则)
|
||||||
|
- 站点倍率(控制不同项目的上游偏好)
|
||||||
|
|
||||||
|
### Q: 下游 Key 和 PROXY_TOKEN 有什么区别
|
||||||
|
|
||||||
|
**A:** `PROXY_TOKEN` 是全局代理令牌,拥有完整权限。下游 Key 是项目级的细粒度控制,可设置过期时间、用量上限和模型限制,适合多团队共用的场景。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 签到相关
|
## 签到相关
|
||||||
|
|
||||||
### Q: 签到一直失败
|
### Q: 签到一直失败
|
||||||
@@ -128,5 +154,9 @@ docker compose up -d
|
|||||||
|
|
||||||
## 更多帮助
|
## 更多帮助
|
||||||
|
|
||||||
- [提交 Issue](https://github.com/cita-777/metapi/issues) — 报告 Bug 或提出建议
|
如果以上内容未能解决你的问题:
|
||||||
|
|
||||||
|
- [搜索已有 Issue](https://github.com/cita-777/metapi/issues?q=is%3Aissue) — 看看是否有人遇到过相同问题
|
||||||
|
- [提交新 Issue](https://github.com/cita-777/metapi/issues/new) — 报告 Bug 或提出功能建议
|
||||||
|
- [参与讨论](https://github.com/cita-777/metapi/discussions) — 使用疑问、经验分享
|
||||||
- [文档中心](./README.md) — 查看所有文档
|
- [文档中心](./README.md) — 查看所有文档
|
||||||
|
|||||||
+32
-2
@@ -9,6 +9,7 @@
|
|||||||
## 前置条件
|
## 前置条件
|
||||||
|
|
||||||
- Docker 与 Docker Compose(推荐)
|
- Docker 与 Docker Compose(推荐)
|
||||||
|
- 或下载 [Release 包](https://github.com/cita-777/metapi/releases) + Node.js 20+(免 Docker 运行)
|
||||||
- 或 Node.js 20+ 与 npm(本地开发)
|
- 或 Node.js 20+ 与 npm(本地开发)
|
||||||
|
|
||||||
## 方式一:Docker Compose 部署(推荐)
|
## 方式一:Docker Compose 部署(推荐)
|
||||||
@@ -59,7 +60,36 @@ docker compose up -d
|
|||||||
> 如果未显式设置(非 Compose 场景),默认值为 `change-me-admin-token`(仅建议本地调试)。
|
> 如果未显式设置(非 Compose 场景),默认值为 `change-me-admin-token`(仅建议本地调试)。
|
||||||
> 若你在后台「设置」里修改过管理员令牌,后续登录请使用新令牌。
|
> 若你在后台「设置」里修改过管理员令牌,后续登录请使用新令牌。
|
||||||
|
|
||||||
## 方式二:本地开发启动
|
## 方式二:Release 包启动(Linux / macOS / Windows)
|
||||||
|
|
||||||
|
如果不想用 Docker,可以直接下载预打包的 Release 产物运行:
|
||||||
|
|
||||||
|
1. 打开 [Releases](https://github.com/cita-777/metapi/releases) 下载与你系统匹配的压缩包
|
||||||
|
2. 解压后进入目录
|
||||||
|
3. 设置环境变量并启动
|
||||||
|
|
||||||
|
Linux / macOS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export AUTH_TOKEN=your-admin-token
|
||||||
|
export PROXY_TOKEN=your-proxy-sk-token
|
||||||
|
./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows(PowerShell):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:AUTH_TOKEN="your-admin-token"
|
||||||
|
$env:PROXY_TOKEN="your-proxy-sk-token"
|
||||||
|
.\start.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
`start.sh` / `start.bat` 会自动检查 `better-sqlite3` ABI 兼容性、执行数据库迁移并启动服务。
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Release 包依赖本机安装 Node.js(支持 20+,推荐 22 LTS)。
|
||||||
|
|
||||||
|
## 方式三:本地开发启动
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/cita-777/metapi.git
|
git clone https://github.com/cita-777/metapi.git
|
||||||
@@ -81,7 +111,7 @@ npm run dev
|
|||||||
进入 **站点管理**,添加你使用的上游中转站:
|
进入 **站点管理**,添加你使用的上游中转站:
|
||||||
|
|
||||||
- 填写站点名称和 URL
|
- 填写站点名称和 URL
|
||||||
- 选择平台类型(New API / One API / OneHub 等)
|
- 选择平台类型(New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API)
|
||||||
- 填写站点的管理员 API Key(可选,部分功能需要)
|
- 填写站点的管理员 API Key(可选,部分功能需要)
|
||||||
|
|
||||||
### 步骤 2:添加账号
|
### 步骤 2:添加账号
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
layout: home
|
||||||
|
|
||||||
|
hero:
|
||||||
|
name: "Metapi 文档中心"
|
||||||
|
text: "中转站的中转站"
|
||||||
|
tagline: "将分散的 AI 中转站聚合为一个统一网关"
|
||||||
|
image:
|
||||||
|
src: /logos/logo-full.png
|
||||||
|
alt: Metapi
|
||||||
|
actions:
|
||||||
|
- theme: brand
|
||||||
|
text: 10 分钟快速上手
|
||||||
|
link: /getting-started
|
||||||
|
- theme: alt
|
||||||
|
text: 常见问题 FAQ
|
||||||
|
link: /faq
|
||||||
|
- theme: alt
|
||||||
|
text: 贡献教程/FAQ
|
||||||
|
link: /community/faq-tutorial-guidelines
|
||||||
|
|
||||||
|
features:
|
||||||
|
- title: 快速上手
|
||||||
|
details: 从部署到第一条请求,按步骤完成最小可用环境搭建。
|
||||||
|
link: /getting-started
|
||||||
|
- title: 问题排查
|
||||||
|
details: 汇总高频报错、根因定位和标准修复路径,降低重复沟通成本。
|
||||||
|
link: /faq
|
||||||
|
- title: 运维与配置
|
||||||
|
details: 覆盖部署、配置、监控、备份、升级回滚等生产场景。
|
||||||
|
link: /operations
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文档定位
|
||||||
|
|
||||||
|
- 面向新用户:快速搭建和接入
|
||||||
|
- 面向维护者:稳定运行、排障和升级
|
||||||
|
- 面向贡献者:持续沉淀教程、FAQ 与最佳实践
|
||||||
|
|
||||||
|
## 推荐阅读路径
|
||||||
|
|
||||||
|
1. [快速上手](/getting-started)
|
||||||
|
2. [部署指南](/deployment)
|
||||||
|
3. [配置说明](/configuration)
|
||||||
|
4. [客户端接入](/client-integration)
|
||||||
|
5. [常见问题 FAQ](/faq)
|
||||||
|
|
||||||
|
## 协作维护
|
||||||
|
|
||||||
|
如需新增文档内容,请优先使用统一模板与提交流程:
|
||||||
|
[FAQ/教程贡献规范](/community/faq-tutorial-guidelines)
|
||||||
@@ -10,23 +10,35 @@ metapi/
|
|||||||
├── dist/ # 构建产物(前后端)
|
├── dist/ # 构建产物(前后端)
|
||||||
├── docker/ # 容器相关文件
|
├── docker/ # 容器相关文件
|
||||||
│ ├── Dockerfile # 多阶段构建(Alpine)
|
│ ├── Dockerfile # 多阶段构建(Alpine)
|
||||||
│ └── docker-compose.yml # Docker Compose 编排
|
│ ├── docker-compose.yml # Docker Compose 编排
|
||||||
|
│ └── docker-compose.override.yml # 开发覆盖配置
|
||||||
├── docs/ # 文档与资源
|
├── docs/ # 文档与资源
|
||||||
|
│ ├── .vitepress/ # VitePress 文档站配置
|
||||||
|
│ ├── community/ # 社区贡献规范
|
||||||
│ ├── logos/ # Logo 素材
|
│ ├── logos/ # Logo 素材
|
||||||
│ │ └── drafts/ # Logo 草稿
|
│ │ └── drafts/ # Logo 草稿
|
||||||
│ └── project-structure.md
|
│ ├── screenshots/ # 界面截图
|
||||||
|
│ └── *.md # 各文档页面
|
||||||
├── drizzle/ # Drizzle ORM 迁移 SQL 与元数据
|
├── drizzle/ # Drizzle ORM 迁移 SQL 与元数据
|
||||||
├── scripts/ # 项目脚本(按场景分组)
|
├── scripts/ # 项目脚本(按场景分组)
|
||||||
│ └── dev/
|
│ ├── dev/
|
||||||
│ └── restart.bat # Windows 开发环境快捷重启
|
│ │ └── restart.bat # Windows 开发环境快捷重启
|
||||||
|
│ └── release/
|
||||||
|
│ ├── start.sh # Release 包启动脚本(Linux/macOS)
|
||||||
|
│ └── start.bat # Release 包启动脚本(Windows)
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── server/ # Fastify 后端服务
|
│ ├── server/ # Fastify 后端服务
|
||||||
│ └── web/ # React 前端应用
|
│ └── web/ # React 前端应用
|
||||||
|
├── .github/workflows/ # CI/CD 工作流
|
||||||
|
│ ├── ci.yml # 测试与构建检查
|
||||||
|
│ ├── release.yml # 多平台 Release 包发布
|
||||||
|
│ └── docs-pages.yml # 文档站 GitHub Pages 部署
|
||||||
├── tmp/ # 临时调试文件(已 gitignore)
|
├── tmp/ # 临时调试文件(已 gitignore)
|
||||||
├── package.json
|
├── package.json
|
||||||
├── tsconfig.json
|
├── tsconfig.json
|
||||||
├── tsconfig.server.json
|
├── tsconfig.server.json
|
||||||
├── vite.config.ts
|
├── vite.config.ts
|
||||||
|
├── drizzle.config.ts
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -48,8 +60,8 @@ src/server/
|
|||||||
│ │ ├── sites.ts # 站点 CRUD
|
│ │ ├── sites.ts # 站点 CRUD
|
||||||
│ │ ├── accounts.ts # 账号管理
|
│ │ ├── accounts.ts # 账号管理
|
||||||
│ │ ├── accountTokens.ts # Token 同步与管理
|
│ │ ├── accountTokens.ts # Token 同步与管理
|
||||||
│ │ ├── tokens.ts # Token 批量操作
|
│ │ ├── tokens.ts # Token 批量操作与路由规则管理
|
||||||
│ │ ├── tokenRoutes.ts # 路由规则管理
|
│ │ ├── downstreamApiKeys.ts # 下游 API Key 管理
|
||||||
│ │ ├── checkin.ts # 签到触发与日志
|
│ │ ├── checkin.ts # 签到触发与日志
|
||||||
│ │ ├── stats.ts # 仪表盘统计
|
│ │ ├── stats.ts # 仪表盘统计
|
||||||
│ │ ├── search.ts # 全局搜索
|
│ │ ├── search.ts # 全局搜索
|
||||||
@@ -61,11 +73,14 @@ src/server/
|
|||||||
│ └── proxy/ # 代理路由
|
│ └── proxy/ # 代理路由
|
||||||
│ ├── router.ts # 代理路由注册
|
│ ├── router.ts # 代理路由注册
|
||||||
│ ├── chat.ts # Chat Completions & Claude Messages
|
│ ├── chat.ts # Chat Completions & Claude Messages
|
||||||
|
│ ├── responses.ts # OpenAI Responses 端点
|
||||||
│ ├── completions.ts # Legacy Completions
|
│ ├── completions.ts # Legacy Completions
|
||||||
│ ├── embeddings.ts # 向量嵌入
|
│ ├── embeddings.ts # 向量嵌入
|
||||||
│ ├── images.ts # 图像生成
|
│ ├── images.ts # 图像生成
|
||||||
│ ├── models.ts # 模型列表
|
│ ├── models.ts # 模型列表
|
||||||
│ └── chatFormats.ts # OpenAI <-> Claude 格式转换
|
│ ├── chatFormats.ts # OpenAI <-> Claude 格式转换
|
||||||
|
│ ├── upstreamEndpoint.ts # 上游端点处理与透传
|
||||||
|
│ └── downstreamPolicy.ts # 下游 API Key 策略校验
|
||||||
└── services/
|
└── services/
|
||||||
├── platforms/ # 平台适配器
|
├── platforms/ # 平台适配器
|
||||||
│ ├── base.ts # 适配器接口定义
|
│ ├── base.ts # 适配器接口定义
|
||||||
@@ -94,6 +109,9 @@ src/server/
|
|||||||
├── accountCredentialService.ts # 凭证加密
|
├── accountCredentialService.ts # 凭证加密
|
||||||
├── accountHealthService.ts # 健康状态管理
|
├── accountHealthService.ts # 健康状态管理
|
||||||
├── accountExtraConfig.ts # 平台专属配置
|
├── accountExtraConfig.ts # 平台专属配置
|
||||||
|
├── accountTokenService.ts # Token 管理服务
|
||||||
|
├── downstreamApiKeyService.ts # 下游 API Key 服务
|
||||||
|
├── downstreamPolicyTypes.ts # 下游策略类型定义
|
||||||
├── proxyRetryPolicy.ts # 重试策略
|
├── proxyRetryPolicy.ts # 重试策略
|
||||||
├── proxyUsageParser.ts # Token 用量解析
|
├── proxyUsageParser.ts # Token 用量解析
|
||||||
├── proxyUsageFallbackService.ts # 余额兜底估算
|
├── proxyUsageFallbackService.ts # 余额兜底估算
|
||||||
@@ -116,12 +134,16 @@ src/web/
|
|||||||
├── api.ts # 统一 API 请求客户端
|
├── api.ts # 统一 API 请求客户端
|
||||||
├── authSession.ts # 认证会话管理
|
├── authSession.ts # 认证会话管理
|
||||||
├── i18n.tsx # 国际化
|
├── i18n.tsx # 国际化
|
||||||
|
├── i18n.supplement.ts # 国际化补充翻译
|
||||||
├── components/ # 通用 UI 组件
|
├── components/ # 通用 UI 组件
|
||||||
│ ├── BrandIcon.tsx # 模型品牌图标
|
│ ├── BrandIcon.tsx # 模型品牌图标
|
||||||
|
│ ├── ChangeKeyModal.tsx # 修改管理令牌弹窗
|
||||||
│ ├── ModelAnalysisPanel.tsx # 消费分析图表
|
│ ├── ModelAnalysisPanel.tsx # 消费分析图表
|
||||||
|
│ ├── ModernSelect.tsx # 自定义下拉选择组件
|
||||||
│ ├── SearchModal.tsx # 全局搜索弹窗
|
│ ├── SearchModal.tsx # 全局搜索弹窗
|
||||||
│ ├── NotificationPanel.tsx # 实时事件面板
|
│ ├── NotificationPanel.tsx # 实时事件面板
|
||||||
│ ├── Toast.tsx # 通知提示
|
│ ├── Toast.tsx # 通知提示
|
||||||
|
│ └── charts/ # 图表组件
|
||||||
│ ├── SiteDistributionChart.tsx # 余额分布饼图
|
│ ├── SiteDistributionChart.tsx # 余额分布饼图
|
||||||
│ └── SiteTrendChart.tsx # 消费趋势图
|
│ └── SiteTrendChart.tsx # 消费趋势图
|
||||||
├── pages/ # 页面组件(路由页)
|
├── pages/ # 页面组件(路由页)
|
||||||
@@ -131,9 +153,14 @@ src/web/
|
|||||||
│ ├── Tokens.tsx # Token 管理
|
│ ├── Tokens.tsx # Token 管理
|
||||||
│ ├── TokenRoutes.tsx # 路由规则
|
│ ├── TokenRoutes.tsx # 路由规则
|
||||||
│ ├── Models.tsx # 模型广场
|
│ ├── Models.tsx # 模型广场
|
||||||
|
│ ├── ModelTester.tsx # 模型操练场
|
||||||
|
│ ├── Monitors.tsx # 可用性监控
|
||||||
│ ├── CheckinLog.tsx # 签到日志
|
│ ├── CheckinLog.tsx # 签到日志
|
||||||
│ ├── ProxyLogs.tsx # 代理日志
|
│ ├── ProxyLogs.tsx # 代理日志
|
||||||
|
│ ├── ProgramLogs.tsx # 系统事件日志
|
||||||
|
│ ├── ImportExport.tsx # 数据导入导出
|
||||||
│ ├── Settings.tsx # 系统设置
|
│ ├── Settings.tsx # 系统设置
|
||||||
|
│ ├── NotificationSettings.tsx # 通知设置
|
||||||
│ ├── About.tsx # 关于页面
|
│ ├── About.tsx # 关于页面
|
||||||
│ └── helpers/ # 页面级纯逻辑 / 工具函数(含测试)
|
│ └── helpers/ # 页面级纯逻辑 / 工具函数(含测试)
|
||||||
└── public/ # 静态资源
|
└── public/ # 静态资源
|
||||||
|
|||||||
Generated
+3302
File diff suppressed because it is too large
Load Diff
+7
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "metapi",
|
"name": "metapi",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"description": "Meta-layer management and unified proxy for AI API aggregation platforms",
|
"description": "Meta-layer management and unified proxy for AI API aggregation platforms",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ai",
|
"ai",
|
||||||
@@ -26,6 +26,9 @@
|
|||||||
"build:web": "vite build",
|
"build:web": "vite build",
|
||||||
"build:server": "tsc -p tsconfig.server.json",
|
"build:server": "tsc -p tsconfig.server.json",
|
||||||
"build": "npm run build:web && npm run build:server",
|
"build": "npm run build:web && npm run build:server",
|
||||||
|
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
|
||||||
|
"docs:build": "vitepress build docs",
|
||||||
|
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
|
||||||
"start": "node dist/server/index.js",
|
"start": "node dist/server/index.js",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "tsx src/server/db/migrate.ts",
|
"db:migrate": "tsx src/server/db/migrate.ts",
|
||||||
@@ -60,6 +63,7 @@
|
|||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"concurrently": "^9.1.0",
|
"concurrently": "^9.1.0",
|
||||||
"drizzle-kit": "^0.28.1",
|
"drizzle-kit": "^0.28.1",
|
||||||
|
"mermaid": "^11.12.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router-dom": "^6.28.0",
|
||||||
@@ -69,6 +73,8 @@
|
|||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.0.3",
|
"vite": "^6.0.3",
|
||||||
|
"vitepress": "^1.6.4",
|
||||||
|
"vitepress-plugin-mermaid": "^2.0.17",
|
||||||
"vitest": "^2.1.8"
|
"vitest": "^2.1.8"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
|||||||
@@ -113,8 +113,79 @@ function ensureSiteStatusSchema() {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureSiteProxySchema() {
|
||||||
|
if (!tableExists('sites')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tableColumnExists('sites', 'proxy_url')) {
|
||||||
|
sqlite.exec(`ALTER TABLE sites ADD COLUMN proxy_url text;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRouteGroupingSchema() {
|
||||||
|
if (!tableExists('token_routes') || !tableExists('route_channels')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tableColumnExists('token_routes', 'display_name')) {
|
||||||
|
sqlite.exec(`ALTER TABLE token_routes ADD COLUMN display_name text;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tableColumnExists('token_routes', 'display_icon')) {
|
||||||
|
sqlite.exec(`ALTER TABLE token_routes ADD COLUMN display_icon text;`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tableColumnExists('route_channels', 'source_model')) {
|
||||||
|
sqlite.exec(`ALTER TABLE route_channels ADD COLUMN source_model text;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDownstreamApiKeySchema() {
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS downstream_api_keys (
|
||||||
|
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
key text NOT NULL,
|
||||||
|
description text,
|
||||||
|
enabled integer DEFAULT true,
|
||||||
|
expires_at text,
|
||||||
|
max_cost real,
|
||||||
|
used_cost real DEFAULT 0,
|
||||||
|
max_requests integer,
|
||||||
|
used_requests integer DEFAULT 0,
|
||||||
|
supported_models text,
|
||||||
|
allowed_route_ids text,
|
||||||
|
site_weight_multipliers text,
|
||||||
|
last_used_at text,
|
||||||
|
created_at text DEFAULT (datetime('now')),
|
||||||
|
updated_at text DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS downstream_api_keys_key_unique
|
||||||
|
ON downstream_api_keys(key);
|
||||||
|
`);
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS downstream_api_keys_name_idx
|
||||||
|
ON downstream_api_keys(name);
|
||||||
|
`);
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS downstream_api_keys_enabled_idx
|
||||||
|
ON downstream_api_keys(enabled);
|
||||||
|
`);
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS downstream_api_keys_expires_at_idx
|
||||||
|
ON downstream_api_keys(expires_at);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
ensureTokenManagementSchema();
|
ensureTokenManagementSchema();
|
||||||
ensureSiteStatusSchema();
|
ensureSiteStatusSchema();
|
||||||
|
ensureSiteProxySchema();
|
||||||
|
ensureRouteGroupingSchema();
|
||||||
|
ensureDownstreamApiKeySchema();
|
||||||
|
|
||||||
export const db = drizzle(sqlite, { schema });
|
export const db = drizzle(sqlite, { schema });
|
||||||
export { schema };
|
export { schema };
|
||||||
|
|||||||
+29
-1
@@ -5,7 +5,8 @@ export const sites = sqliteTable('sites', {
|
|||||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
url: text('url').notNull(),
|
url: text('url').notNull(),
|
||||||
platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api'
|
platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini'
|
||||||
|
proxyUrl: text('proxy_url'),
|
||||||
status: text('status').notNull().default('active'), // 'active' | 'disabled'
|
status: text('status').notNull().default('active'), // 'active' | 'disabled'
|
||||||
isPinned: integer('is_pinned', { mode: 'boolean' }).default(false),
|
isPinned: integer('is_pinned', { mode: 'boolean' }).default(false),
|
||||||
sortOrder: integer('sort_order').default(0),
|
sortOrder: integer('sort_order').default(0),
|
||||||
@@ -101,6 +102,8 @@ export const tokenModelAvailability = sqliteTable('token_model_availability', {
|
|||||||
export const tokenRoutes = sqliteTable('token_routes', {
|
export const tokenRoutes = sqliteTable('token_routes', {
|
||||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
modelPattern: text('model_pattern').notNull(),
|
modelPattern: text('model_pattern').notNull(),
|
||||||
|
displayName: text('display_name'),
|
||||||
|
displayIcon: text('display_icon'),
|
||||||
modelMapping: text('model_mapping'), // JSON
|
modelMapping: text('model_mapping'), // JSON
|
||||||
enabled: integer('enabled', { mode: 'boolean' }).default(true),
|
enabled: integer('enabled', { mode: 'boolean' }).default(true),
|
||||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||||
@@ -115,6 +118,7 @@ export const routeChannels = sqliteTable('route_channels', {
|
|||||||
routeId: integer('route_id').notNull().references(() => tokenRoutes.id, { onDelete: 'cascade' }),
|
routeId: integer('route_id').notNull().references(() => tokenRoutes.id, { onDelete: 'cascade' }),
|
||||||
accountId: integer('account_id').notNull().references(() => accounts.id, { onDelete: 'cascade' }),
|
accountId: integer('account_id').notNull().references(() => accounts.id, { onDelete: 'cascade' }),
|
||||||
tokenId: integer('token_id').references(() => accountTokens.id, { onDelete: 'set null' }),
|
tokenId: integer('token_id').references(() => accountTokens.id, { onDelete: 'set null' }),
|
||||||
|
sourceModel: text('source_model'),
|
||||||
priority: integer('priority').default(0),
|
priority: integer('priority').default(0),
|
||||||
weight: integer('weight').default(10),
|
weight: integer('weight').default(10),
|
||||||
enabled: integer('enabled', { mode: 'boolean' }).default(true),
|
enabled: integer('enabled', { mode: 'boolean' }).default(true),
|
||||||
@@ -163,6 +167,30 @@ export const settings = sqliteTable('settings', {
|
|||||||
value: text('value'), // JSON
|
value: text('value'), // JSON
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const downstreamApiKeys = sqliteTable('downstream_api_keys', {
|
||||||
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
key: text('key').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
enabled: integer('enabled', { mode: 'boolean' }).default(true),
|
||||||
|
expiresAt: text('expires_at'),
|
||||||
|
maxCost: real('max_cost'),
|
||||||
|
usedCost: real('used_cost').default(0),
|
||||||
|
maxRequests: integer('max_requests'),
|
||||||
|
usedRequests: integer('used_requests').default(0),
|
||||||
|
supportedModels: text('supported_models'), // JSON array<string>
|
||||||
|
allowedRouteIds: text('allowed_route_ids'), // JSON array<number>
|
||||||
|
siteWeightMultipliers: text('site_weight_multipliers'), // JSON object { [siteId]: multiplier }
|
||||||
|
lastUsedAt: text('last_used_at'),
|
||||||
|
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
||||||
|
updatedAt: text('updated_at').default(sql`(datetime('now'))`),
|
||||||
|
}, (table) => ({
|
||||||
|
keyUnique: uniqueIndex('downstream_api_keys_key_unique').on(table.key),
|
||||||
|
nameIdx: index('downstream_api_keys_name_idx').on(table.name),
|
||||||
|
enabledIdx: index('downstream_api_keys_enabled_idx').on(table.enabled),
|
||||||
|
expiresAtIdx: index('downstream_api_keys_expires_at_idx').on(table.expiresAt),
|
||||||
|
}));
|
||||||
|
|
||||||
export const events = sqliteTable('events', {
|
export const events = sqliteTable('events', {
|
||||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
type: text('type').notNull(), // 'checkin' | 'balance' | 'token' | 'proxy' | 'status'
|
type: text('type').notNull(), // 'checkin' | 'balance' | 'token' | 'proxy' | 'status'
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { eventsRoutes } from './routes/api/events.js';
|
|||||||
import { taskRoutes } from './routes/api/tasks.js';
|
import { taskRoutes } from './routes/api/tasks.js';
|
||||||
import { testRoutes } from './routes/api/test.js';
|
import { testRoutes } from './routes/api/test.js';
|
||||||
import { monitorRoutes } from './routes/api/monitor.js';
|
import { monitorRoutes } from './routes/api/monitor.js';
|
||||||
|
import { downstreamApiKeysRoutes } from './routes/api/downstreamApiKeys.js';
|
||||||
import { proxyRoutes } from './routes/proxy/router.js';
|
import { proxyRoutes } from './routes/proxy/router.js';
|
||||||
import { startScheduler } from './services/checkinScheduler.js';
|
import { startScheduler } from './services/checkinScheduler.js';
|
||||||
import { buildStartupSummaryLines } from './services/startupInfo.js';
|
import { buildStartupSummaryLines } from './services/startupInfo.js';
|
||||||
@@ -151,6 +152,7 @@ await app.register(eventsRoutes);
|
|||||||
await app.register(taskRoutes);
|
await app.register(taskRoutes);
|
||||||
await app.register(testRoutes);
|
await app.register(testRoutes);
|
||||||
await app.register(monitorRoutes);
|
await app.register(monitorRoutes);
|
||||||
|
await app.register(downstreamApiKeysRoutes);
|
||||||
|
|
||||||
// Register OpenAI-compatible proxy routes
|
// Register OpenAI-compatible proxy routes
|
||||||
await app.register(proxyRoutes);
|
await app.register(proxyRoutes);
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
|
import { authorizeDownstreamToken, consumeManagedKeyRequest } from '../services/downstreamApiKeyService.js';
|
||||||
|
import { EMPTY_DOWNSTREAM_ROUTING_POLICY, type DownstreamRoutingPolicy } from '../services/downstreamPolicyTypes.js';
|
||||||
|
|
||||||
|
export interface ProxyAuthContext {
|
||||||
|
token: string;
|
||||||
|
source: 'managed' | 'global';
|
||||||
|
keyId: number | null;
|
||||||
|
keyName: string;
|
||||||
|
policy: DownstreamRoutingPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyAuthContextByRequest = new WeakMap<FastifyRequest, ProxyAuthContext>();
|
||||||
|
|
||||||
function normalizeIp(rawIp: string | null | undefined): string {
|
function normalizeIp(rawIp: string | null | undefined): string {
|
||||||
const ip = (rawIp || '').trim();
|
const ip = (rawIp || '').trim();
|
||||||
@@ -63,8 +75,25 @@ export async function proxyAuthMiddleware(request: FastifyRequest, reply: Fastif
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token !== config.proxyToken) {
|
const authResult = authorizeDownstreamToken(token);
|
||||||
reply.code(403).send({ error: 'Invalid API key' });
|
if (!authResult.ok) {
|
||||||
|
reply.code(authResult.statusCode).send({ error: authResult.error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (authResult.source === 'managed' && authResult.key) {
|
||||||
|
consumeManagedKeyRequest(authResult.key.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyAuthContextByRequest.set(request, {
|
||||||
|
token: authResult.token,
|
||||||
|
source: authResult.source,
|
||||||
|
keyId: authResult.key?.id ?? null,
|
||||||
|
keyName: authResult.key?.name || 'global',
|
||||||
|
policy: authResult.policy || EMPTY_DOWNSTREAM_ROUTING_POLICY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProxyAuthContext(request: FastifyRequest): ProxyAuthContext | null {
|
||||||
|
return proxyAuthContextByRequest.get(request) || null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { mkdtempSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const verifyTokenMock = vi.fn();
|
||||||
|
const getApiTokensMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../services/platforms/index.js', () => ({
|
||||||
|
getAdapter: () => ({
|
||||||
|
verifyToken: (...args: unknown[]) => verifyTokenMock(...args),
|
||||||
|
getApiTokens: (...args: unknown[]) => getApiTokensMock(...args),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type DbModule = typeof import('../../db/index.js');
|
||||||
|
|
||||||
|
describe('accounts add requires token verification success', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let db: DbModule['db'];
|
||||||
|
let schema: DbModule['schema'];
|
||||||
|
let dataDir = '';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-add-requires-verify-'));
|
||||||
|
process.env.DATA_DIR = dataDir;
|
||||||
|
|
||||||
|
await import('../../db/migrate.js');
|
||||||
|
const dbModule = await import('../../db/index.js');
|
||||||
|
const routesModule = await import('./accounts.js');
|
||||||
|
db = dbModule.db;
|
||||||
|
schema = dbModule.schema;
|
||||||
|
|
||||||
|
app = Fastify();
|
||||||
|
await app.register(routesModule.accountsRoutes);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
verifyTokenMock.mockReset();
|
||||||
|
getApiTokensMock.mockReset();
|
||||||
|
getApiTokensMock.mockResolvedValue([]);
|
||||||
|
|
||||||
|
db.delete(schema.proxyLogs).run();
|
||||||
|
db.delete(schema.checkinLogs).run();
|
||||||
|
db.delete(schema.routeChannels).run();
|
||||||
|
db.delete(schema.tokenRoutes).run();
|
||||||
|
db.delete(schema.tokenModelAvailability).run();
|
||||||
|
db.delete(schema.modelAvailability).run();
|
||||||
|
db.delete(schema.accountTokens).run();
|
||||||
|
db.delete(schema.accounts).run();
|
||||||
|
db.delete(schema.sites).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
delete process.env.DATA_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects binding when token verification is not successful', async () => {
|
||||||
|
verifyTokenMock.mockResolvedValueOnce({ tokenType: 'unknown' });
|
||||||
|
|
||||||
|
const site = db.insert(schema.sites).values({
|
||||||
|
name: 'Verify Site',
|
||||||
|
url: 'https://verify.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/accounts',
|
||||||
|
payload: {
|
||||||
|
siteId: site.id,
|
||||||
|
accessToken: 'invalid-token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
expect(db.select().from(schema.accounts).all()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns rebind hint when token verify reports invalid access token', async () => {
|
||||||
|
verifyTokenMock.mockRejectedValueOnce(new Error('无权进行此操作,access token 无效'));
|
||||||
|
|
||||||
|
const site = db.insert(schema.sites).values({
|
||||||
|
name: 'Verify Site',
|
||||||
|
url: 'https://verify.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/accounts',
|
||||||
|
payload: {
|
||||||
|
siteId: site.id,
|
||||||
|
accessToken: 'invalid-token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
message: '无权进行此操作,access token 无效,请在中转站重新生成系统访问令牌后重新绑定账号',
|
||||||
|
});
|
||||||
|
expect(db.select().from(schema.accounts).all()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows binding when token verification succeeds as api key', async () => {
|
||||||
|
verifyTokenMock.mockResolvedValueOnce({
|
||||||
|
tokenType: 'apikey',
|
||||||
|
models: ['gpt-4o-mini'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const site = db.insert(schema.sites).values({
|
||||||
|
name: 'API Key Site',
|
||||||
|
url: 'https://apikey.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/accounts',
|
||||||
|
payload: {
|
||||||
|
siteId: site.id,
|
||||||
|
accessToken: 'sk-valid-key',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json() as { tokenType?: string; apiTokenFound?: boolean };
|
||||||
|
expect(body.tokenType).toBe('apikey');
|
||||||
|
expect(body.apiTokenFound).toBe(true);
|
||||||
|
|
||||||
|
const accounts = db.select().from(schema.accounts).all();
|
||||||
|
expect(accounts).toHaveLength(1);
|
||||||
|
expect((accounts[0]?.apiToken || '').startsWith('sk-')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { mkdtempSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
const verifyTokenMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../services/platforms/index.js', () => ({
|
||||||
|
getAdapter: () => ({
|
||||||
|
verifyToken: (...args: unknown[]) => verifyTokenMock(...args),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type DbModule = typeof import('../../db/index.js');
|
||||||
|
|
||||||
|
describe('accounts rebind-session api', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let db: DbModule['db'];
|
||||||
|
let schema: DbModule['schema'];
|
||||||
|
let dataDir = '';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-rebind-session-'));
|
||||||
|
process.env.DATA_DIR = dataDir;
|
||||||
|
|
||||||
|
await import('../../db/migrate.js');
|
||||||
|
const dbModule = await import('../../db/index.js');
|
||||||
|
const routesModule = await import('./accounts.js');
|
||||||
|
db = dbModule.db;
|
||||||
|
schema = dbModule.schema;
|
||||||
|
|
||||||
|
app = Fastify();
|
||||||
|
await app.register(routesModule.accountsRoutes);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
verifyTokenMock.mockReset();
|
||||||
|
|
||||||
|
db.delete(schema.proxyLogs).run();
|
||||||
|
db.delete(schema.checkinLogs).run();
|
||||||
|
db.delete(schema.routeChannels).run();
|
||||||
|
db.delete(schema.tokenRoutes).run();
|
||||||
|
db.delete(schema.tokenModelAvailability).run();
|
||||||
|
db.delete(schema.modelAvailability).run();
|
||||||
|
db.delete(schema.accountTokens).run();
|
||||||
|
db.delete(schema.accounts).run();
|
||||||
|
db.delete(schema.sites).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
delete process.env.DATA_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects rebinding when token is not verified as session', async () => {
|
||||||
|
verifyTokenMock.mockResolvedValueOnce({ tokenType: 'unknown' });
|
||||||
|
|
||||||
|
const site = db.insert(schema.sites).values({
|
||||||
|
name: 'Rebind Site',
|
||||||
|
url: 'https://rebind.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'linuxdo_1001',
|
||||||
|
accessToken: 'old-access-token',
|
||||||
|
status: 'expired',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/accounts/${account.id}/rebind-session`,
|
||||||
|
payload: {
|
||||||
|
accessToken: 'new-access-token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const latest = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||||
|
expect(latest?.accessToken).toBe('old-access-token');
|
||||||
|
expect(latest?.status).toBe('expired');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns rebind hint when verify reports invalid access token', async () => {
|
||||||
|
verifyTokenMock.mockRejectedValueOnce(new Error('无权进行此操作,access token 无效'));
|
||||||
|
|
||||||
|
const site = db.insert(schema.sites).values({
|
||||||
|
name: 'Rebind Site',
|
||||||
|
url: 'https://rebind.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'linuxdo_1001',
|
||||||
|
accessToken: 'old-access-token',
|
||||||
|
status: 'expired',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/accounts/${account.id}/rebind-session`,
|
||||||
|
payload: {
|
||||||
|
accessToken: 'new-access-token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
message: '无权进行此操作,access token 无效,请在中转站重新生成系统访问令牌后重新绑定账号',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reactivates expired account when session token verification succeeds', async () => {
|
||||||
|
verifyTokenMock.mockResolvedValueOnce({
|
||||||
|
tokenType: 'session',
|
||||||
|
userInfo: { username: 'linuxdo_1002' },
|
||||||
|
apiToken: 'sk-rebound-token',
|
||||||
|
});
|
||||||
|
|
||||||
|
const site = db.insert(schema.sites).values({
|
||||||
|
name: 'Rebind Site',
|
||||||
|
url: 'https://rebind.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'linuxdo_1001',
|
||||||
|
accessToken: 'old-access-token',
|
||||||
|
status: 'expired',
|
||||||
|
extraConfig: JSON.stringify({ platformUserId: 1001 }),
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/accounts/${account.id}/rebind-session`,
|
||||||
|
payload: {
|
||||||
|
accessToken: 'new-session-token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json() as { success?: boolean; apiTokenFound?: boolean };
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.apiTokenFound).toBe(true);
|
||||||
|
|
||||||
|
const latest = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||||
|
expect(latest?.accessToken).toBe('new-session-token');
|
||||||
|
expect(latest?.apiToken).toBe('sk-rebound-token');
|
||||||
|
expect(latest?.username).toBe('linuxdo_1002');
|
||||||
|
expect(latest?.status).toBe('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@ import { refreshBalance } from '../../services/balanceService.js';
|
|||||||
import { getAdapter } from '../../services/platforms/index.js';
|
import { getAdapter } from '../../services/platforms/index.js';
|
||||||
import { refreshModelsForAccount, rebuildTokenRoutesFromAvailability } from '../../services/modelService.js';
|
import { refreshModelsForAccount, rebuildTokenRoutesFromAvailability } from '../../services/modelService.js';
|
||||||
import { ensureDefaultTokenForAccount, syncTokensFromUpstream } from '../../services/accountTokenService.js';
|
import { ensureDefaultTokenForAccount, syncTokensFromUpstream } from '../../services/accountTokenService.js';
|
||||||
import { guessPlatformUserIdFromUsername, mergeAccountExtraConfig } from '../../services/accountExtraConfig.js';
|
import { guessPlatformUserIdFromUsername, mergeAccountExtraConfig, resolvePlatformUserId } from '../../services/accountExtraConfig.js';
|
||||||
import { encryptAccountPassword } from '../../services/accountCredentialService.js';
|
import { encryptAccountPassword } from '../../services/accountCredentialService.js';
|
||||||
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
||||||
import { parseCheckinRewardAmount } from '../../services/checkinRewardParser.js';
|
import { parseCheckinRewardAmount } from '../../services/checkinRewardParser.js';
|
||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
setAccountRuntimeHealth,
|
setAccountRuntimeHealth,
|
||||||
type RuntimeHealthState,
|
type RuntimeHealthState,
|
||||||
} from '../../services/accountHealthService.js';
|
} from '../../services/accountHealthService.js';
|
||||||
|
import { appendSessionTokenRebindHint } from '../../services/alertRules.js';
|
||||||
|
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
||||||
|
|
||||||
type AccountWithSiteRow = {
|
type AccountWithSiteRow = {
|
||||||
accounts: typeof schema.accounts.$inferSelect;
|
accounts: typeof schema.accounts.$inferSelect;
|
||||||
@@ -318,7 +320,15 @@ export async function accountsRoutes(app: FastifyInstance) {
|
|||||||
const adapter = getAdapter(site.platform);
|
const adapter = getAdapter(site.platform);
|
||||||
if (!adapter) return { success: false, message: `婵炴垶鎸哥粔鐢稿极椤曗偓楠炴劖鎷呴悜妯兼殸濡ょ姷鍋涢崯鑳亹? ${site.platform}` };
|
if (!adapter) return { success: false, message: `婵炴垶鎸哥粔鐢稿极椤曗偓楠炴劖鎷呴悜妯兼殸濡ょ姷鍋涢崯鑳亹? ${site.platform}` };
|
||||||
|
|
||||||
const result = await adapter.verifyToken(site.url, accessToken, platformUserId);
|
let result: any;
|
||||||
|
try {
|
||||||
|
result = await adapter.verifyToken(site.url, accessToken, platformUserId);
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: appendSessionTokenRebindHint(err?.message || 'Token 验证失败'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (result.tokenType === 'session') {
|
if (result.tokenType === 'session') {
|
||||||
return {
|
return {
|
||||||
@@ -384,7 +394,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
for (const headers of headerVariants) {
|
for (const headers of headerVariants) {
|
||||||
try {
|
try {
|
||||||
const testRes = await fetch(`${site.url}/api/user/self`, { headers });
|
const testRes = await fetch(`${site.url}/api/user/self`, withExplicitProxyRequestInit(site.proxyUrl, { headers }));
|
||||||
const bodyText = await testRes.text();
|
const bodyText = await testRes.text();
|
||||||
const contentType = testRes.headers.get('content-type') || '';
|
const contentType = testRes.headers.get('content-type') || '';
|
||||||
const reason = parseFailureReason(bodyText, contentType);
|
const reason = parseFailureReason(bodyText, contentType);
|
||||||
@@ -415,36 +425,156 @@ export async function accountsRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Token invalid: cannot use it as session token or API key',
|
message: 'Token invalid: cannot use it as session cookie or API key',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Body: { accessToken: string; platformUserId?: number } }>(
|
||||||
|
'/api/accounts/:id/rebind-session',
|
||||||
|
async (request, reply) => {
|
||||||
|
const accountId = Number.parseInt(request.params.id, 10);
|
||||||
|
if (!Number.isFinite(accountId) || accountId <= 0) {
|
||||||
|
return reply.code(400).send({ success: false, message: '账号 ID 无效' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAccessToken = (request.body?.accessToken || '').trim();
|
||||||
|
if (!nextAccessToken) {
|
||||||
|
return reply.code(400).send({ success: false, message: '请提供新的 Session Token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = db.select()
|
||||||
|
.from(schema.accounts)
|
||||||
|
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||||
|
.where(eq(schema.accounts.id, accountId))
|
||||||
|
.get();
|
||||||
|
if (!row) {
|
||||||
|
return reply.code(404).send({ success: false, message: '账号不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = row.accounts;
|
||||||
|
const site = row.sites;
|
||||||
|
const adapter = getAdapter(site.platform);
|
||||||
|
if (!adapter) {
|
||||||
|
return reply.code(400).send({ success: false, message: `platform not supported: ${site.platform}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyPlatformUserId = Number.parseInt(String(request.body?.platformUserId ?? ''), 10);
|
||||||
|
const candidatePlatformUserId = Number.isFinite(bodyPlatformUserId) && bodyPlatformUserId > 0
|
||||||
|
? bodyPlatformUserId
|
||||||
|
: resolvePlatformUserId(account.extraConfig, account.username);
|
||||||
|
|
||||||
|
let verifyResult: any;
|
||||||
|
try {
|
||||||
|
verifyResult = await adapter.verifyToken(site.url, nextAccessToken, candidatePlatformUserId);
|
||||||
|
} catch (err: any) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
success: false,
|
||||||
|
message: appendSessionTokenRebindHint(err?.message || 'Token 验证失败'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verifyResult?.tokenType !== 'session') {
|
||||||
|
return reply.code(400).send({
|
||||||
|
success: false,
|
||||||
|
message: '新的 Token 验证失败:请提供可用的 Session Token',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextUsernameRaw = typeof verifyResult?.userInfo?.username === 'string'
|
||||||
|
? verifyResult.userInfo.username.trim()
|
||||||
|
: '';
|
||||||
|
const nextUsername = nextUsernameRaw || account.username || '';
|
||||||
|
const inferredPlatformUserId = resolvePlatformUserId(account.extraConfig, nextUsername);
|
||||||
|
const resolvedPlatformUserId = Number.isFinite(bodyPlatformUserId) && bodyPlatformUserId > 0
|
||||||
|
? bodyPlatformUserId
|
||||||
|
: inferredPlatformUserId;
|
||||||
|
const nextApiToken = typeof verifyResult?.apiToken === 'string' && verifyResult.apiToken.trim().length > 0
|
||||||
|
? verifyResult.apiToken.trim()
|
||||||
|
: (account.apiToken || '');
|
||||||
|
|
||||||
|
const updates: Record<string, unknown> = {
|
||||||
|
accessToken: nextAccessToken,
|
||||||
|
status: 'active',
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
if (nextUsername) {
|
||||||
|
updates.username = nextUsername;
|
||||||
|
}
|
||||||
|
if (nextApiToken) {
|
||||||
|
updates.apiToken = nextApiToken;
|
||||||
|
}
|
||||||
|
if (resolvedPlatformUserId) {
|
||||||
|
updates.extraConfig = mergeAccountExtraConfig(account.extraConfig, { platformUserId: resolvedPlatformUserId });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, accountId)).run();
|
||||||
|
|
||||||
|
if (nextApiToken) {
|
||||||
|
try {
|
||||||
|
ensureDefaultTokenForAccount(accountId, nextApiToken, { name: 'default', source: 'sync' });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await refreshBalance(accountId);
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
await refreshModelsForAccount(accountId);
|
||||||
|
rebuildTokenRoutesFromAvailability();
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const latest = db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)).get();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
account: latest,
|
||||||
|
apiTokenFound: !!nextApiToken,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Add an account (manual token input) - auto-detects token type and fetches info
|
// Add an account (manual token input) - auto-detects token type and fetches info
|
||||||
app.post<{ Body: { siteId: number; username?: string; accessToken: string; apiToken?: string; platformUserId?: number; checkinEnabled?: boolean } }>('/api/accounts', async (request) => {
|
app.post<{ Body: { siteId: number; username?: string; accessToken: string; apiToken?: string; platformUserId?: number; checkinEnabled?: boolean } }>('/api/accounts', async (request, reply) => {
|
||||||
const body = request.body;
|
const body = request.body;
|
||||||
const site = db.select().from(schema.sites).where(eq(schema.sites.id, body.siteId)).get();
|
const site = db.select().from(schema.sites).where(eq(schema.sites.id, body.siteId)).get();
|
||||||
|
if (!site) {
|
||||||
|
return reply.code(400).send({ success: false, message: 'site not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = getAdapter(site.platform);
|
||||||
|
if (!adapter) {
|
||||||
|
return reply.code(400).send({ success: false, message: `platform not supported: ${site.platform}` });
|
||||||
|
}
|
||||||
|
|
||||||
let username = body.username;
|
let username = body.username;
|
||||||
let accessToken = body.accessToken;
|
let accessToken = body.accessToken;
|
||||||
let apiToken = body.apiToken;
|
let apiToken = body.apiToken;
|
||||||
let tokenType = 'unknown';
|
let verifyResult: any;
|
||||||
|
try {
|
||||||
|
verifyResult = await adapter.verifyToken(site.url, body.accessToken, body.platformUserId);
|
||||||
|
} catch (err: any) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
success: false,
|
||||||
|
message: appendSessionTokenRebindHint(err?.message || 'Token 验证失败'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const tokenType = verifyResult.tokenType;
|
||||||
|
|
||||||
if (site) {
|
if (tokenType === 'unknown') {
|
||||||
const adapter = getAdapter(site.platform);
|
return reply.code(400).send({
|
||||||
if (adapter) {
|
success: false,
|
||||||
const verifyResult = await adapter.verifyToken(site.url, body.accessToken, body.platformUserId);
|
requiresVerification: true,
|
||||||
tokenType = verifyResult.tokenType;
|
message: 'Token 验证失败,请先点击“验证 Token”,验证成功后再绑定账号',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (verifyResult.tokenType === 'session') {
|
if (tokenType === 'session') {
|
||||||
// Token is a session token - can do management ops
|
// Token is a session cookie - can do management ops
|
||||||
if (!username && verifyResult.userInfo?.username) username = verifyResult.userInfo.username;
|
if (!username && verifyResult.userInfo?.username) username = verifyResult.userInfo.username;
|
||||||
if (!apiToken && verifyResult.apiToken) apiToken = verifyResult.apiToken;
|
if (!apiToken && verifyResult.apiToken) apiToken = verifyResult.apiToken;
|
||||||
} else if (verifyResult.tokenType === 'apikey') {
|
} else if (tokenType === 'apikey') {
|
||||||
// Token is an API key - store as apiToken, not accessToken
|
// Token is an API key - store as apiToken, not accessToken
|
||||||
apiToken = body.accessToken;
|
apiToken = body.accessToken;
|
||||||
accessToken = ''; // no session token available
|
accessToken = ''; // no session cookie available
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store platformUserId in extraConfig for NewAPI sites that need it
|
// Store platformUserId in extraConfig for NewAPI sites that need it
|
||||||
@@ -472,15 +602,12 @@ export async function accountsRoutes(app: FastifyInstance) {
|
|||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tokenType === 'session' && accessToken && site) {
|
if (tokenType === 'session' && accessToken) {
|
||||||
try {
|
try {
|
||||||
const adapter = getAdapter(site.platform);
|
|
||||||
if (adapter) {
|
|
||||||
const syncedTokens = await adapter.getApiTokens(site.url, accessToken, resolvedPlatformUserId);
|
const syncedTokens = await adapter.getApiTokens(site.url, accessToken, resolvedPlatformUserId);
|
||||||
if (syncedTokens.length > 0) {
|
if (syncedTokens.length > 0) {
|
||||||
syncTokensFromUpstream(result.id, syncedTokens);
|
syncTokensFromUpstream(result.id, syncedTokens);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { mkdtempSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const verifyTokenMock = vi.fn();
|
||||||
|
const undiciFetchMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../../services/platforms/index.js', () => ({
|
||||||
|
getAdapter: () => ({
|
||||||
|
verifyToken: (...args: unknown[]) => verifyTokenMock(...args),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('undici', () => ({
|
||||||
|
fetch: (...args: unknown[]) => undiciFetchMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type DbModule = typeof import('../../db/index.js');
|
||||||
|
|
||||||
|
describe('accounts verify-token shield detection', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let db: DbModule['db'];
|
||||||
|
let schema: DbModule['schema'];
|
||||||
|
let dataDir = '';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-verify-shield-'));
|
||||||
|
process.env.DATA_DIR = dataDir;
|
||||||
|
|
||||||
|
await import('../../db/migrate.js');
|
||||||
|
const dbModule = await import('../../db/index.js');
|
||||||
|
const routesModule = await import('./accounts.js');
|
||||||
|
db = dbModule.db;
|
||||||
|
schema = dbModule.schema;
|
||||||
|
|
||||||
|
app = Fastify();
|
||||||
|
await app.register(routesModule.accountsRoutes);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
verifyTokenMock.mockReset();
|
||||||
|
undiciFetchMock.mockReset();
|
||||||
|
|
||||||
|
db.delete(schema.proxyLogs).run();
|
||||||
|
db.delete(schema.checkinLogs).run();
|
||||||
|
db.delete(schema.routeChannels).run();
|
||||||
|
db.delete(schema.tokenRoutes).run();
|
||||||
|
db.delete(schema.tokenModelAvailability).run();
|
||||||
|
db.delete(schema.modelAvailability).run();
|
||||||
|
db.delete(schema.accountTokens).run();
|
||||||
|
db.delete(schema.accounts).run();
|
||||||
|
db.delete(schema.sites).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
delete process.env.DATA_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns rebind hint when verify-token reports invalid access token', async () => {
|
||||||
|
verifyTokenMock.mockRejectedValueOnce(new Error('invalid access token'));
|
||||||
|
|
||||||
|
const site = db.insert(schema.sites).values({
|
||||||
|
name: 'AnyRouter',
|
||||||
|
url: 'https://anyrouter.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/accounts/verify-token',
|
||||||
|
payload: {
|
||||||
|
siteId: site.id,
|
||||||
|
accessToken: 'session-or-cookie-token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
message: 'invalid access token,请在中转站重新生成系统访问令牌后重新绑定账号',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns shieldBlocked when upstream /api/user/self responds with challenge html', async () => {
|
||||||
|
verifyTokenMock.mockResolvedValueOnce({ tokenType: 'unknown' });
|
||||||
|
undiciFetchMock.mockResolvedValue({
|
||||||
|
text: async () => '<html><script>var arg1="ABC123";</script></html>',
|
||||||
|
headers: {
|
||||||
|
get: (name: string) => (name.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const site = db.insert(schema.sites).values({
|
||||||
|
name: 'AnyRouter',
|
||||||
|
url: 'https://anyrouter.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/accounts/verify-token',
|
||||||
|
payload: {
|
||||||
|
siteId: site.id,
|
||||||
|
accessToken: 'session-or-cookie-token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
shieldBlocked: true,
|
||||||
|
});
|
||||||
|
expect(undiciFetchMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db, schema } from '../../db/index.js';
|
||||||
|
import {
|
||||||
|
getDownstreamApiKeyById,
|
||||||
|
listDownstreamApiKeys,
|
||||||
|
normalizeDownstreamApiKeyPayload,
|
||||||
|
toDownstreamApiKeyPolicyView,
|
||||||
|
toPersistenceJson,
|
||||||
|
} from '../../services/downstreamApiKeyService.js';
|
||||||
|
|
||||||
|
function parseRouteId(raw: string): number | null {
|
||||||
|
const id = Number.parseInt(raw, 10);
|
||||||
|
if (!Number.isFinite(id) || id <= 0) return null;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateKeyShape(key: string): boolean {
|
||||||
|
return key.startsWith('sk-') && key.length >= 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeUniqueViolation(error: unknown): boolean {
|
||||||
|
const message = (error as Error | undefined)?.message || '';
|
||||||
|
return message.includes('UNIQUE constraint failed') && message.includes('downstream_api_keys.key');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downstreamApiKeysRoutes(app: FastifyInstance) {
|
||||||
|
app.get('/api/downstream-keys', async () => {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
items: listDownstreamApiKeys(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{
|
||||||
|
Body: {
|
||||||
|
name?: unknown;
|
||||||
|
key?: unknown;
|
||||||
|
description?: unknown;
|
||||||
|
enabled?: unknown;
|
||||||
|
expiresAt?: unknown;
|
||||||
|
maxCost?: unknown;
|
||||||
|
maxRequests?: unknown;
|
||||||
|
supportedModels?: unknown;
|
||||||
|
allowedRouteIds?: unknown;
|
||||||
|
siteWeightMultipliers?: unknown;
|
||||||
|
};
|
||||||
|
}>('/api/downstream-keys', async (request, reply) => {
|
||||||
|
let normalized: ReturnType<typeof normalizeDownstreamApiKeyPayload>;
|
||||||
|
try {
|
||||||
|
normalized = normalizeDownstreamApiKeyPayload(request.body || {});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return reply.code(400).send({ success: false, message: (error as Error)?.message || '参数无效' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalized.name) {
|
||||||
|
return reply.code(400).send({ success: false, message: 'name 不能为空' });
|
||||||
|
}
|
||||||
|
if (!normalized.key) {
|
||||||
|
return reply.code(400).send({ success: false, message: 'key 不能为空' });
|
||||||
|
}
|
||||||
|
if (!validateKeyShape(normalized.key)) {
|
||||||
|
return reply.code(400).send({ success: false, message: 'key 必须以 sk- 开头且长度至少 6' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inserted = db.insert(schema.downstreamApiKeys).values({
|
||||||
|
name: normalized.name,
|
||||||
|
key: normalized.key,
|
||||||
|
description: normalized.description,
|
||||||
|
enabled: normalized.enabled,
|
||||||
|
expiresAt: normalized.expiresAt,
|
||||||
|
maxCost: normalized.maxCost,
|
||||||
|
usedCost: 0,
|
||||||
|
maxRequests: normalized.maxRequests,
|
||||||
|
usedRequests: 0,
|
||||||
|
supportedModels: toPersistenceJson(normalized.supportedModels),
|
||||||
|
allowedRouteIds: toPersistenceJson(normalized.allowedRouteIds),
|
||||||
|
siteWeightMultipliers: toPersistenceJson(normalized.siteWeightMultipliers),
|
||||||
|
createdAt: nowIso,
|
||||||
|
updatedAt: nowIso,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
item: toDownstreamApiKeyPolicyView(inserted),
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (looksLikeUniqueViolation(error)) {
|
||||||
|
return reply.code(409).send({ success: false, message: 'API key 已存在' });
|
||||||
|
}
|
||||||
|
return reply.code(500).send({ success: false, message: (error as Error)?.message || '创建失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put<{
|
||||||
|
Params: { id: string };
|
||||||
|
Body: {
|
||||||
|
name?: unknown;
|
||||||
|
key?: unknown;
|
||||||
|
description?: unknown;
|
||||||
|
enabled?: unknown;
|
||||||
|
expiresAt?: unknown;
|
||||||
|
maxCost?: unknown;
|
||||||
|
maxRequests?: unknown;
|
||||||
|
supportedModels?: unknown;
|
||||||
|
allowedRouteIds?: unknown;
|
||||||
|
siteWeightMultipliers?: unknown;
|
||||||
|
};
|
||||||
|
}>('/api/downstream-keys/:id', async (request, reply) => {
|
||||||
|
const id = parseRouteId(request.params.id);
|
||||||
|
if (!id) {
|
||||||
|
return reply.code(400).send({ success: false, message: 'id 无效' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = db.select().from(schema.downstreamApiKeys)
|
||||||
|
.where(eq(schema.downstreamApiKeys.id, id))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return reply.code(404).send({ success: false, message: 'API key 不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingView = toDownstreamApiKeyPolicyView(existing);
|
||||||
|
let normalized: ReturnType<typeof normalizeDownstreamApiKeyPayload>;
|
||||||
|
try {
|
||||||
|
normalized = normalizeDownstreamApiKeyPayload({
|
||||||
|
name: request.body?.name ?? existing.name,
|
||||||
|
key: request.body?.key ?? existing.key,
|
||||||
|
description: request.body?.description ?? existing.description,
|
||||||
|
enabled: request.body?.enabled ?? existing.enabled,
|
||||||
|
expiresAt: request.body?.expiresAt ?? existing.expiresAt,
|
||||||
|
maxCost: request.body?.maxCost ?? existing.maxCost,
|
||||||
|
maxRequests: request.body?.maxRequests ?? existing.maxRequests,
|
||||||
|
supportedModels: request.body?.supportedModels ?? existingView.supportedModels,
|
||||||
|
allowedRouteIds: request.body?.allowedRouteIds ?? existingView.allowedRouteIds,
|
||||||
|
siteWeightMultipliers: request.body?.siteWeightMultipliers ?? existingView.siteWeightMultipliers,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return reply.code(400).send({ success: false, message: (error as Error)?.message || '参数无效' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalized.name) {
|
||||||
|
return reply.code(400).send({ success: false, message: 'name 不能为空' });
|
||||||
|
}
|
||||||
|
if (!normalized.key) {
|
||||||
|
return reply.code(400).send({ success: false, message: 'key 不能为空' });
|
||||||
|
}
|
||||||
|
if (!validateKeyShape(normalized.key)) {
|
||||||
|
return reply.code(400).send({ success: false, message: 'key 必须以 sk- 开头且长度至少 6' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
try {
|
||||||
|
db.update(schema.downstreamApiKeys).set({
|
||||||
|
name: normalized.name,
|
||||||
|
key: normalized.key,
|
||||||
|
description: normalized.description,
|
||||||
|
enabled: normalized.enabled,
|
||||||
|
expiresAt: normalized.expiresAt,
|
||||||
|
maxCost: normalized.maxCost,
|
||||||
|
maxRequests: normalized.maxRequests,
|
||||||
|
supportedModels: toPersistenceJson(normalized.supportedModels),
|
||||||
|
allowedRouteIds: toPersistenceJson(normalized.allowedRouteIds),
|
||||||
|
siteWeightMultipliers: toPersistenceJson(normalized.siteWeightMultipliers),
|
||||||
|
updatedAt: nowIso,
|
||||||
|
}).where(eq(schema.downstreamApiKeys.id, id)).run();
|
||||||
|
|
||||||
|
const updated = getDownstreamApiKeyById(id);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
item: updated,
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (looksLikeUniqueViolation(error)) {
|
||||||
|
return reply.code(409).send({ success: false, message: 'API key 已存在' });
|
||||||
|
}
|
||||||
|
return reply.code(500).send({ success: false, message: (error as Error)?.message || '更新失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>('/api/downstream-keys/:id/reset-usage', async (request, reply) => {
|
||||||
|
const id = parseRouteId(request.params.id);
|
||||||
|
if (!id) {
|
||||||
|
return reply.code(400).send({ success: false, message: 'id 无效' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = getDownstreamApiKeyById(id);
|
||||||
|
if (!existing) {
|
||||||
|
return reply.code(404).send({ success: false, message: 'API key 不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(schema.downstreamApiKeys).set({
|
||||||
|
usedCost: 0,
|
||||||
|
usedRequests: 0,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}).where(eq(schema.downstreamApiKeys.id, id)).run();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
item: getDownstreamApiKeyById(id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>('/api/downstream-keys/:id', async (request, reply) => {
|
||||||
|
const id = parseRouteId(request.params.id);
|
||||||
|
if (!id) {
|
||||||
|
return reply.code(400).send({ success: false, message: 'id 无效' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = getDownstreamApiKeyById(id);
|
||||||
|
if (!existing) {
|
||||||
|
return reply.code(404).send({ success: false, message: 'API key 不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.delete(schema.downstreamApiKeys)
|
||||||
|
.where(eq(schema.downstreamApiKeys.id, id))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -102,6 +102,36 @@ describe('settings and auth events', () => {
|
|||||||
expect(body.message).toContain('sk-');
|
expect(body.message).toContain('sk-');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects invalid bark url when bark channel is enabled', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/settings/runtime',
|
||||||
|
payload: {
|
||||||
|
barkEnabled: true,
|
||||||
|
barkUrl: 'juricek.chen@gmail.com',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
const body = response.json() as { message?: string };
|
||||||
|
expect(body.message).toContain('Bark URL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid webhook url when webhook channel is enabled', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/settings/runtime',
|
||||||
|
payload: {
|
||||||
|
webhookEnabled: true,
|
||||||
|
webhookUrl: 'not-a-url',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
const body = response.json() as { message?: string };
|
||||||
|
expect(body.message).toContain('Webhook URL');
|
||||||
|
});
|
||||||
|
|
||||||
it('persists and returns routing fallback unit cost from runtime settings', async () => {
|
it('persists and returns routing fallback unit cost from runtime settings', async () => {
|
||||||
const updateResponse = await app.inject({
|
const updateResponse = await app.inject({
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@@ -96,6 +96,17 @@ function toStringList(value: unknown): string[] {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidHttpUrl(raw: string): boolean {
|
||||||
|
const value = String(raw || '').trim();
|
||||||
|
if (!value) return false;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function applyImportedSettingToRuntime(key: string, value: unknown) {
|
function applyImportedSettingToRuntime(key: string, value: unknown) {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'checkin_cron': {
|
case 'checkin_cron': {
|
||||||
@@ -254,6 +265,38 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
const changedLabels: string[] = [];
|
const changedLabels: string[] = [];
|
||||||
const currentRequestIp = extractClientIp(request.ip, request.headers['x-forwarded-for']);
|
const currentRequestIp = extractClientIp(request.ip, request.headers['x-forwarded-for']);
|
||||||
|
|
||||||
|
const webhookTouched = body.webhookUrl !== undefined || body.webhookEnabled !== undefined;
|
||||||
|
const nextWebhookUrl = body.webhookUrl !== undefined
|
||||||
|
? String(body.webhookUrl || '').trim()
|
||||||
|
: config.webhookUrl;
|
||||||
|
const nextWebhookEnabled = body.webhookEnabled !== undefined
|
||||||
|
? !!body.webhookEnabled
|
||||||
|
: config.webhookEnabled;
|
||||||
|
if (webhookTouched && nextWebhookEnabled) {
|
||||||
|
if (!nextWebhookUrl) {
|
||||||
|
return reply.code(400).send({ success: false, message: 'Webhook URL 不能为空(启用 Webhook 时)' });
|
||||||
|
}
|
||||||
|
if (!isValidHttpUrl(nextWebhookUrl)) {
|
||||||
|
return reply.code(400).send({ success: false, message: 'Webhook URL 无效,请填写 http/https 地址' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const barkTouched = body.barkUrl !== undefined || body.barkEnabled !== undefined;
|
||||||
|
const nextBarkUrl = body.barkUrl !== undefined
|
||||||
|
? String(body.barkUrl || '').trim()
|
||||||
|
: config.barkUrl;
|
||||||
|
const nextBarkEnabled = body.barkEnabled !== undefined
|
||||||
|
? !!body.barkEnabled
|
||||||
|
: config.barkEnabled;
|
||||||
|
if (barkTouched && nextBarkEnabled) {
|
||||||
|
if (!nextBarkUrl) {
|
||||||
|
return reply.code(400).send({ success: false, message: 'Bark URL 不能为空(启用 Bark 时)' });
|
||||||
|
}
|
||||||
|
if (!isValidHttpUrl(nextBarkUrl)) {
|
||||||
|
return reply.code(400).send({ success: false, message: 'Bark URL 无效,请填写 http/https 地址' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (body.checkinCron !== undefined) {
|
if (body.checkinCron !== undefined) {
|
||||||
if (!cron.validate(body.checkinCron)) {
|
if (!cron.validate(body.checkinCron)) {
|
||||||
return reply.code(400).send({ success: false, message: '签到 Cron 表达式无效' });
|
return reply.code(400).send({ success: false, message: '签到 Cron 表达式无效' });
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import { describe, expect, it, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { mkdtempSync } from 'node:fs';
|
||||||
|
|
||||||
|
type DbModule = typeof import('../../db/index.js');
|
||||||
|
|
||||||
|
describe('sites proxy url settings', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let db: DbModule['db'];
|
||||||
|
let schema: DbModule['schema'];
|
||||||
|
let dataDir = '';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dataDir = mkdtempSync(join(tmpdir(), 'metapi-sites-proxy-url-'));
|
||||||
|
process.env.DATA_DIR = dataDir;
|
||||||
|
|
||||||
|
await import('../../db/migrate.js');
|
||||||
|
const dbModule = await import('../../db/index.js');
|
||||||
|
const routesModule = await import('./sites.js');
|
||||||
|
db = dbModule.db;
|
||||||
|
schema = dbModule.schema;
|
||||||
|
|
||||||
|
app = Fastify();
|
||||||
|
await app.register(routesModule.sitesRoutes);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db.delete(schema.accounts).run();
|
||||||
|
db.delete(schema.sites).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
delete process.env.DATA_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores proxy url when creating a site', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sites',
|
||||||
|
payload: {
|
||||||
|
name: 'proxy-site',
|
||||||
|
url: 'https://proxy-site.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
proxyUrl: 'http://127.0.0.1:7890',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const payload = response.json() as { proxyUrl?: string | null };
|
||||||
|
expect(payload.proxyUrl).toBe('http://127.0.0.1:7890');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid proxy url', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sites',
|
||||||
|
payload: {
|
||||||
|
name: 'proxy-site',
|
||||||
|
url: 'https://proxy-site.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
proxyUrl: 'not-a-url',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect((response.json() as { error?: string }).error).toContain('Invalid proxyUrl');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { FastifyInstance } from 'fastify';
|
|||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
import { and, eq } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
import { detectSite } from '../../services/siteDetector.js';
|
import { detectSite } from '../../services/siteDetector.js';
|
||||||
|
import { invalidateSiteProxyCache, parseSiteProxyUrlInput } from '../../services/siteProxy.js';
|
||||||
|
|
||||||
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
|
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
|
||||||
if (input === undefined || input === null) return null;
|
if (input === undefined || input === null) return null;
|
||||||
@@ -48,12 +49,16 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Add a site
|
// Add a site
|
||||||
app.post<{ Body: { name: string; url: string; platform?: string; apiKey?: string; status?: string; isPinned?: boolean; sortOrder?: number } }>('/api/sites', async (request, reply) => {
|
app.post<{ Body: { name: string; url: string; platform?: string; apiKey?: string; proxyUrl?: string | null; status?: string; isPinned?: boolean; sortOrder?: number } }>('/api/sites', async (request, reply) => {
|
||||||
const { name, url, platform, apiKey, status, isPinned, sortOrder } = request.body;
|
const { name, url, platform, apiKey, proxyUrl, status, isPinned, sortOrder } = request.body;
|
||||||
const normalizedStatus = normalizeSiteStatus(status);
|
const normalizedStatus = normalizeSiteStatus(status);
|
||||||
if (status !== undefined && !normalizedStatus) {
|
if (status !== undefined && !normalizedStatus) {
|
||||||
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
|
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
|
||||||
}
|
}
|
||||||
|
const parsedProxyUrl = parseSiteProxyUrlInput(proxyUrl);
|
||||||
|
if (!parsedProxyUrl.valid) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid proxyUrl. Expected a valid http(s)/socks proxy URL.' });
|
||||||
|
}
|
||||||
const normalizedPinned = normalizePinnedFlag(isPinned);
|
const normalizedPinned = normalizePinnedFlag(isPinned);
|
||||||
if (isPinned !== undefined && normalizedPinned === null) {
|
if (isPinned !== undefined && normalizedPinned === null) {
|
||||||
return reply.code(400).send({ error: 'Invalid isPinned value. Expected boolean.' });
|
return reply.code(400).send({ error: 'Invalid isPinned value. Expected boolean.' });
|
||||||
@@ -79,15 +84,17 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
url: url.replace(/\/+$/, ''),
|
url: url.replace(/\/+$/, ''),
|
||||||
platform: detectedPlatform,
|
platform: detectedPlatform,
|
||||||
apiKey,
|
apiKey,
|
||||||
|
proxyUrl: parsedProxyUrl.proxyUrl,
|
||||||
status: normalizedStatus ?? 'active',
|
status: normalizedStatus ?? 'active',
|
||||||
isPinned: normalizedPinned ?? false,
|
isPinned: normalizedPinned ?? false,
|
||||||
sortOrder: normalizedSortOrder ?? (maxSortOrder + 1),
|
sortOrder: normalizedSortOrder ?? (maxSortOrder + 1),
|
||||||
}).returning().get();
|
}).returning().get();
|
||||||
|
invalidateSiteProxyCache();
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update a site
|
// Update a site
|
||||||
app.put<{ Params: { id: string }; Body: { name?: string; url?: string; platform?: string; apiKey?: string; status?: string; isPinned?: boolean; sortOrder?: number } }>('/api/sites/:id', async (request, reply) => {
|
app.put<{ Params: { id: string }; Body: { name?: string; url?: string; platform?: string; apiKey?: string; proxyUrl?: string | null; status?: string; isPinned?: boolean; sortOrder?: number } }>('/api/sites/:id', async (request, reply) => {
|
||||||
const id = parseInt(request.params.id);
|
const id = parseInt(request.params.id);
|
||||||
if (Number.isNaN(id)) {
|
if (Number.isNaN(id)) {
|
||||||
return reply.code(400).send({ error: 'Invalid site id' });
|
return reply.code(400).send({ error: 'Invalid site id' });
|
||||||
@@ -104,6 +111,10 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
if (body.status !== undefined && !normalizedStatus) {
|
if (body.status !== undefined && !normalizedStatus) {
|
||||||
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
|
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
|
||||||
}
|
}
|
||||||
|
const parsedProxyUrl = parseSiteProxyUrlInput(body.proxyUrl);
|
||||||
|
if (!parsedProxyUrl.valid) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid proxyUrl. Expected a valid http(s)/socks proxy URL.' });
|
||||||
|
}
|
||||||
const normalizedPinned = normalizePinnedFlag(body.isPinned);
|
const normalizedPinned = normalizePinnedFlag(body.isPinned);
|
||||||
if (body.isPinned !== undefined && normalizedPinned === null) {
|
if (body.isPinned !== undefined && normalizedPinned === null) {
|
||||||
return reply.code(400).send({ error: 'Invalid isPinned value. Expected boolean.' });
|
return reply.code(400).send({ error: 'Invalid isPinned value. Expected boolean.' });
|
||||||
@@ -117,11 +128,13 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
|
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
|
||||||
if (body.platform !== undefined) updates.platform = body.platform;
|
if (body.platform !== undefined) updates.platform = body.platform;
|
||||||
if (body.apiKey !== undefined) updates.apiKey = body.apiKey;
|
if (body.apiKey !== undefined) updates.apiKey = body.apiKey;
|
||||||
|
if (parsedProxyUrl.present) updates.proxyUrl = parsedProxyUrl.proxyUrl;
|
||||||
if (body.status !== undefined) updates.status = normalizedStatus;
|
if (body.status !== undefined) updates.status = normalizedStatus;
|
||||||
if (body.isPinned !== undefined) updates.isPinned = normalizedPinned;
|
if (body.isPinned !== undefined) updates.isPinned = normalizedPinned;
|
||||||
if (body.sortOrder !== undefined) updates.sortOrder = normalizedSortOrder;
|
if (body.sortOrder !== undefined) updates.sortOrder = normalizedSortOrder;
|
||||||
updates.updatedAt = new Date().toISOString();
|
updates.updatedAt = new Date().toISOString();
|
||||||
db.update(schema.sites).set(updates).where(eq(schema.sites.id, id)).run();
|
db.update(schema.sites).set(updates).where(eq(schema.sites.id, id)).run();
|
||||||
|
invalidateSiteProxyCache();
|
||||||
|
|
||||||
if (body.status !== undefined && normalizedStatus) {
|
if (body.status !== undefined && normalizedStatus) {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@@ -167,6 +180,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
app.delete<{ Params: { id: string } }>('/api/sites/:id', async (request) => {
|
app.delete<{ Params: { id: string } }>('/api/sites/:id', async (request) => {
|
||||||
const id = parseInt(request.params.id);
|
const id = parseInt(request.params.id);
|
||||||
db.delete(schema.sites).where(eq(schema.sites.id, id)).run();
|
db.delete(schema.sites).where(eq(schema.sites.id, id)).run();
|
||||||
|
invalidateSiteProxyCache();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -99,4 +99,137 @@ describe('POST /api/routes/decision/batch', () => {
|
|||||||
expect(body.decisions['gpt-4o-mini']?.candidates.length).toBeGreaterThan(0);
|
expect(body.decisions['gpt-4o-mini']?.candidates.length).toBeGreaterThan(0);
|
||||||
expect(body.decisions['unknown-model']?.matched).toBe(false);
|
expect(body.decisions['unknown-model']?.matched).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns decisions scoped by route id to avoid wildcard channel mismatch', async () => {
|
||||||
|
const site = db.insert(schema.sites).values({
|
||||||
|
name: 'wildcard-site',
|
||||||
|
url: 'https://wildcard-site.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'wildcard-user',
|
||||||
|
accessToken: 'wildcard-access',
|
||||||
|
apiToken: 'wildcard-api',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const exactRoute = db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'claude-opus-4-6',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
db.insert(schema.routeChannels).values({
|
||||||
|
routeId: exactRoute.id,
|
||||||
|
accountId: account.id,
|
||||||
|
tokenId: null,
|
||||||
|
sourceModel: 'claude-opus-4-6',
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const wildcardRoute = db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 're:^claude-(opus|sonnet)-4-6$',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const wildcardChannel = db.insert(schema.routeChannels).values({
|
||||||
|
routeId: wildcardRoute.id,
|
||||||
|
accountId: account.id,
|
||||||
|
tokenId: null,
|
||||||
|
sourceModel: 'claude-opus-4-6',
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/routes/decision/by-route/batch',
|
||||||
|
payload: {
|
||||||
|
items: [{ routeId: wildcardRoute.id, model: 'claude-opus-4-6' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json() as {
|
||||||
|
success: boolean;
|
||||||
|
decisions: Record<string, Record<string, { routeId?: number; matched: boolean; candidates: Array<{ channelId: number }> }>>;
|
||||||
|
};
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
|
||||||
|
const decision = body.decisions[String(wildcardRoute.id)]?.['claude-opus-4-6'];
|
||||||
|
expect(decision?.matched).toBe(true);
|
||||||
|
expect(decision?.routeId).toBe(wildcardRoute.id);
|
||||||
|
expect(decision?.candidates.some((candidate) => candidate.channelId === wildcardChannel.id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns route-wide wildcard probabilities normalized to 100 across all channels', async () => {
|
||||||
|
const site = db.insert(schema.sites).values({
|
||||||
|
name: 'route-wide-site',
|
||||||
|
url: 'https://route-wide-site.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'route-wide-user',
|
||||||
|
accessToken: 'route-wide-access',
|
||||||
|
apiToken: 'route-wide-api',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const route = db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 're:^claude-(opus|sonnet)-4-6$',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const channelA = db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: account.id,
|
||||||
|
tokenId: null,
|
||||||
|
sourceModel: 'claude-opus-4-6',
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const channelB = db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: account.id,
|
||||||
|
tokenId: null,
|
||||||
|
sourceModel: 'claude-sonnet-4-6',
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/routes/decision/route-wide/batch',
|
||||||
|
payload: { routeIds: [route.id] },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json() as {
|
||||||
|
success: boolean;
|
||||||
|
decisions: Record<string, {
|
||||||
|
matched: boolean;
|
||||||
|
routeId?: number;
|
||||||
|
candidates: Array<{ channelId: number; probability: number }>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
|
||||||
|
const decision = body.decisions[String(route.id)];
|
||||||
|
expect(decision?.matched).toBe(true);
|
||||||
|
expect(decision?.routeId).toBe(route.id);
|
||||||
|
expect(decision?.candidates.some((candidate) => candidate.channelId === channelA.id)).toBe(true);
|
||||||
|
expect(decision?.candidates.some((candidate) => candidate.channelId === channelB.id)).toBe(true);
|
||||||
|
|
||||||
|
const totalProbability = (decision?.candidates || []).reduce((sum, candidate) => sum + (candidate.probability || 0), 0);
|
||||||
|
expect(totalProbability).toBeCloseTo(100, 1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
import { and, eq, inArray } from 'drizzle-orm';
|
import { and, eq, inArray } from 'drizzle-orm';
|
||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
||||||
|
|
||||||
function isExactModelPattern(modelPattern: string): boolean {
|
function isExactModelPattern(modelPattern: string): boolean {
|
||||||
return !/[\*\?\[]/.test(modelPattern);
|
const normalized = modelPattern.trim();
|
||||||
|
if (!normalized) return false;
|
||||||
|
if (normalized.toLowerCase().startsWith('re:')) return false;
|
||||||
|
return !/[\*\?\[]/.test(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultTokenId(accountId: number): number | null {
|
function getDefaultTokenId(accountId: number): number | null {
|
||||||
@@ -16,17 +19,36 @@ function getDefaultTokenId(accountId: number): number | null {
|
|||||||
return token?.id ?? null;
|
return token?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canonicalModelAlias(modelName: string): string {
|
||||||
|
const normalized = modelName.trim().toLowerCase();
|
||||||
|
if (!normalized) return '';
|
||||||
|
const slashIndex = normalized.lastIndexOf('/');
|
||||||
|
if (slashIndex >= 0 && slashIndex < normalized.length - 1) {
|
||||||
|
return normalized.slice(slashIndex + 1);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModelAliasEquivalent(left: string, right: string): boolean {
|
||||||
|
const a = canonicalModelAlias(left);
|
||||||
|
const b = canonicalModelAlias(right);
|
||||||
|
return !!a && !!b && a === b;
|
||||||
|
}
|
||||||
|
|
||||||
function tokenSupportsModel(tokenId: number, modelName: string): boolean {
|
function tokenSupportsModel(tokenId: number, modelName: string): boolean {
|
||||||
const row = db.select().from(schema.tokenModelAvailability)
|
const rows = db.select().from(schema.tokenModelAvailability)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(schema.tokenModelAvailability.tokenId, tokenId),
|
eq(schema.tokenModelAvailability.tokenId, tokenId),
|
||||||
eq(schema.tokenModelAvailability.modelName, modelName),
|
|
||||||
eq(schema.tokenModelAvailability.available, true),
|
eq(schema.tokenModelAvailability.available, true),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.get();
|
.all();
|
||||||
return !!row;
|
return rows.some((row) => {
|
||||||
|
const availableModelName = row.modelName?.trim();
|
||||||
|
if (!availableModelName) return false;
|
||||||
|
return availableModelName === modelName || isModelAliasEquivalent(availableModelName, modelName);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkTokenBelongsToAccount(tokenId: number, accountId: number): boolean {
|
function checkTokenBelongsToAccount(tokenId: number, accountId: number): boolean {
|
||||||
@@ -36,6 +58,117 @@ function checkTokenBelongsToAccount(tokenId: number, accountId: number): boolean
|
|||||||
return !!row;
|
return !!row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPatternTokenCandidates(modelPattern: string): Array<{ tokenId: number; accountId: number; sourceModel: string }> {
|
||||||
|
const rows = db.select().from(schema.tokenModelAvailability)
|
||||||
|
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
|
||||||
|
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
|
||||||
|
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.tokenModelAvailability.available, true),
|
||||||
|
eq(schema.accountTokens.enabled, true),
|
||||||
|
eq(schema.accounts.status, 'active'),
|
||||||
|
eq(schema.sites.status, 'active'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const result: Array<{ tokenId: number; accountId: number; sourceModel: string }> = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const modelName = row.token_model_availability.modelName?.trim();
|
||||||
|
if (!modelName) continue;
|
||||||
|
if (!matchesModelPattern(modelName, modelPattern)) continue;
|
||||||
|
result.push({
|
||||||
|
tokenId: row.account_tokens.id,
|
||||||
|
accountId: row.accounts.id,
|
||||||
|
sourceModel: modelName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMatchedExactRouteChannelCandidates(modelPattern: string): Array<{
|
||||||
|
tokenId: number | null;
|
||||||
|
accountId: number;
|
||||||
|
sourceModel: string;
|
||||||
|
priority: number;
|
||||||
|
weight: number;
|
||||||
|
enabled: boolean;
|
||||||
|
manualOverride: boolean;
|
||||||
|
}> {
|
||||||
|
const matchedRoutes = db.select().from(schema.tokenRoutes)
|
||||||
|
.where(eq(schema.tokenRoutes.enabled, true))
|
||||||
|
.all()
|
||||||
|
.filter((route) => isExactModelPattern(route.modelPattern) && matchesModelPattern(route.modelPattern, modelPattern));
|
||||||
|
|
||||||
|
if (matchedRoutes.length === 0) return [];
|
||||||
|
const routeMap = new Map<number, typeof matchedRoutes[number]>();
|
||||||
|
for (const route of matchedRoutes) routeMap.set(route.id, route);
|
||||||
|
|
||||||
|
const channels = db.select().from(schema.routeChannels)
|
||||||
|
.where(inArray(schema.routeChannels.routeId, matchedRoutes.map((route) => route.id)))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
return channels.map((channel) => ({
|
||||||
|
tokenId: channel.tokenId ?? null,
|
||||||
|
accountId: channel.accountId,
|
||||||
|
sourceModel: (channel.sourceModel || routeMap.get(channel.routeId)?.modelPattern || '').trim(),
|
||||||
|
priority: channel.priority ?? 0,
|
||||||
|
weight: channel.weight ?? 10,
|
||||||
|
enabled: !!channel.enabled,
|
||||||
|
manualOverride: !!channel.manualOverride,
|
||||||
|
})).filter((candidate) => candidate.sourceModel.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateRouteChannelsByModelPattern(routeId: number, modelPattern: string): number {
|
||||||
|
const routeCandidates = getMatchedExactRouteChannelCandidates(modelPattern);
|
||||||
|
const availabilityCandidates = getPatternTokenCandidates(modelPattern).map((candidate) => ({
|
||||||
|
tokenId: candidate.tokenId,
|
||||||
|
accountId: candidate.accountId,
|
||||||
|
sourceModel: candidate.sourceModel,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
manualOverride: false,
|
||||||
|
}));
|
||||||
|
const candidates = [...routeCandidates, ...availabilityCandidates];
|
||||||
|
if (candidates.length === 0) return 0;
|
||||||
|
|
||||||
|
const existingChannels = db.select().from(schema.routeChannels)
|
||||||
|
.where(eq(schema.routeChannels.routeId, routeId))
|
||||||
|
.all();
|
||||||
|
const existingPairs = new Set<string>(
|
||||||
|
existingChannels
|
||||||
|
.map((channel) => {
|
||||||
|
const tokenId = typeof channel.tokenId === 'number' && Number.isFinite(channel.tokenId) ? channel.tokenId : 0;
|
||||||
|
const sourceModel = (channel.sourceModel || '').trim().toLowerCase();
|
||||||
|
return `${channel.accountId}::${tokenId}::${sourceModel}`;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const tokenId = typeof candidate.tokenId === 'number' && Number.isFinite(candidate.tokenId) ? candidate.tokenId : 0;
|
||||||
|
const pairKey = `${candidate.accountId}::${tokenId}::${candidate.sourceModel.trim().toLowerCase()}`;
|
||||||
|
if (existingPairs.has(pairKey)) continue;
|
||||||
|
db.insert(schema.routeChannels).values({
|
||||||
|
routeId,
|
||||||
|
accountId: candidate.accountId,
|
||||||
|
tokenId: candidate.tokenId,
|
||||||
|
sourceModel: candidate.sourceModel,
|
||||||
|
priority: candidate.priority,
|
||||||
|
weight: candidate.weight,
|
||||||
|
enabled: candidate.enabled,
|
||||||
|
manualOverride: candidate.manualOverride,
|
||||||
|
}).run();
|
||||||
|
existingPairs.add(pairKey);
|
||||||
|
created += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
type BatchChannelPriorityUpdate = {
|
type BatchChannelPriorityUpdate = {
|
||||||
id: number;
|
id: number;
|
||||||
priority: number;
|
priority: number;
|
||||||
@@ -45,6 +178,17 @@ type BatchRouteDecisionModels = {
|
|||||||
models: string[];
|
models: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BatchRouteDecisionRouteModels = {
|
||||||
|
items: Array<{
|
||||||
|
routeId: number;
|
||||||
|
model: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BatchRouteWideDecisionRouteIds = {
|
||||||
|
routeIds: number[];
|
||||||
|
};
|
||||||
|
|
||||||
function parseBatchChannelUpdates(input: unknown): { ok: true; updates: BatchChannelPriorityUpdate[] } | { ok: false; message: string } {
|
function parseBatchChannelUpdates(input: unknown): { ok: true; updates: BatchChannelPriorityUpdate[] } | { ok: false; message: string } {
|
||||||
if (!input || typeof input !== 'object') {
|
if (!input || typeof input !== 'object') {
|
||||||
return { ok: false, message: '请求体必须是对象' };
|
return { ok: false, message: '请求体必须是对象' };
|
||||||
@@ -112,6 +256,75 @@ function parseBatchRouteDecisionModels(input: unknown): { ok: true; models: stri
|
|||||||
return { ok: true, models: normalized };
|
return { ok: true, models: normalized };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseBatchRouteDecisionRouteModels(
|
||||||
|
input: unknown,
|
||||||
|
): { ok: true; items: Array<{ routeId: number; model: string }> } | { ok: false; message: string } {
|
||||||
|
if (!input || typeof input !== 'object') {
|
||||||
|
return { ok: false, message: '请求体必须是对象' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = (input as BatchRouteDecisionRouteModels).items;
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
return { ok: false, message: 'items 必须是非空数组' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dedupe = new Set<string>();
|
||||||
|
const normalized: Array<{ routeId: number; model: string }> = [];
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item || typeof item !== 'object') continue;
|
||||||
|
const routeIdRaw = (item as { routeId?: unknown }).routeId;
|
||||||
|
const modelRaw = (item as { model?: unknown }).model;
|
||||||
|
if (typeof routeIdRaw !== 'number' || !Number.isFinite(routeIdRaw)) continue;
|
||||||
|
if (typeof modelRaw !== 'string') continue;
|
||||||
|
|
||||||
|
const routeId = Math.trunc(routeIdRaw);
|
||||||
|
const model = modelRaw.trim();
|
||||||
|
if (routeId <= 0 || !model) continue;
|
||||||
|
|
||||||
|
const key = `${routeId}::${model}`;
|
||||||
|
if (dedupe.has(key)) continue;
|
||||||
|
dedupe.add(key);
|
||||||
|
normalized.push({ routeId, model });
|
||||||
|
if (normalized.length >= 500) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.length === 0) {
|
||||||
|
return { ok: false, message: 'items 中没有有效 routeId/model' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, items: normalized };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBatchRouteWideDecisionRouteIds(
|
||||||
|
input: unknown,
|
||||||
|
): { ok: true; routeIds: number[] } | { ok: false; message: string } {
|
||||||
|
if (!input || typeof input !== 'object') {
|
||||||
|
return { ok: false, message: '请求体必须是对象' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeIds = (input as BatchRouteWideDecisionRouteIds).routeIds;
|
||||||
|
if (!Array.isArray(routeIds) || routeIds.length === 0) {
|
||||||
|
return { ok: false, message: 'routeIds 必须是非空数组' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dedupe = new Set<number>();
|
||||||
|
const normalized: number[] = [];
|
||||||
|
for (const raw of routeIds) {
|
||||||
|
if (typeof raw !== 'number' || !Number.isFinite(raw)) continue;
|
||||||
|
const routeId = Math.trunc(raw);
|
||||||
|
if (routeId <= 0 || dedupe.has(routeId)) continue;
|
||||||
|
dedupe.add(routeId);
|
||||||
|
normalized.push(routeId);
|
||||||
|
if (normalized.length >= 500) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.length === 0) {
|
||||||
|
return { ok: false, message: 'routeIds 中没有有效 routeId' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, routeIds: normalized };
|
||||||
|
}
|
||||||
|
|
||||||
export async function tokensRoutes(app: FastifyInstance) {
|
export async function tokensRoutes(app: FastifyInstance) {
|
||||||
// List all routes
|
// List all routes
|
||||||
app.get('/api/routes', async () => {
|
app.get('/api/routes', async () => {
|
||||||
@@ -177,14 +390,49 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
return { success: true, decisions };
|
return { success: true, decisions };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post<{ Body: BatchRouteDecisionRouteModels }>('/api/routes/decision/by-route/batch', async (request, reply) => {
|
||||||
|
const parsed = parseBatchRouteDecisionRouteModels(request.body);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
return reply.code(400).send({ success: false, message: parsed.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const decisions: Record<string, Record<string, ReturnType<typeof tokenRouter.explainSelection>>> = {};
|
||||||
|
for (const item of parsed.items) {
|
||||||
|
const routeKey = String(item.routeId);
|
||||||
|
if (!decisions[routeKey]) decisions[routeKey] = {};
|
||||||
|
decisions[routeKey][item.model] = tokenRouter.explainSelectionForRoute(item.routeId, item.model);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, decisions };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Body: BatchRouteWideDecisionRouteIds }>('/api/routes/decision/route-wide/batch', async (request, reply) => {
|
||||||
|
const parsed = parseBatchRouteWideDecisionRouteIds(request.body);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
return reply.code(400).send({ success: false, message: parsed.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const decisions: Record<string, ReturnType<typeof tokenRouter.explainSelection>> = {};
|
||||||
|
for (const routeId of parsed.routeIds) {
|
||||||
|
decisions[String(routeId)] = tokenRouter.explainSelectionRouteWide(routeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, decisions };
|
||||||
|
});
|
||||||
|
|
||||||
// Create a route
|
// Create a route
|
||||||
app.post<{ Body: { modelPattern: string; modelMapping?: string; enabled?: boolean } }>('/api/routes', async (request) => {
|
app.post<{ Body: { modelPattern: string; displayName?: string; displayIcon?: string; modelMapping?: string; enabled?: boolean } }>('/api/routes', async (request) => {
|
||||||
const body = request.body;
|
const body = request.body;
|
||||||
return db.insert(schema.tokenRoutes).values({
|
const route = db.insert(schema.tokenRoutes).values({
|
||||||
modelPattern: body.modelPattern,
|
modelPattern: body.modelPattern,
|
||||||
|
displayName: body.displayName,
|
||||||
|
displayIcon: body.displayIcon,
|
||||||
modelMapping: body.modelMapping,
|
modelMapping: body.modelMapping,
|
||||||
enabled: body.enabled ?? true,
|
enabled: body.enabled ?? true,
|
||||||
}).returning().get();
|
}).returning().get();
|
||||||
|
|
||||||
|
populateRouteChannelsByModelPattern(route.id, body.modelPattern);
|
||||||
|
return route;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update a route
|
// Update a route
|
||||||
@@ -193,6 +441,8 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
const body = request.body as Record<string, unknown>;
|
const body = request.body as Record<string, unknown>;
|
||||||
const updates: Record<string, unknown> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (body.displayName !== undefined) updates.displayName = body.displayName;
|
||||||
|
if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon;
|
||||||
if (body.modelPattern !== undefined) updates.modelPattern = body.modelPattern;
|
if (body.modelPattern !== undefined) updates.modelPattern = body.modelPattern;
|
||||||
if (body.modelMapping !== undefined) updates.modelMapping = body.modelMapping;
|
if (body.modelMapping !== undefined) updates.modelMapping = body.modelMapping;
|
||||||
if (body.enabled !== undefined) updates.enabled = body.enabled;
|
if (body.enabled !== undefined) updates.enabled = body.enabled;
|
||||||
@@ -210,7 +460,7 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Add a channel to a route
|
// Add a channel to a route
|
||||||
app.post<{ Params: { id: string }; Body: { accountId: number; tokenId?: number; priority?: number; weight?: number } }>('/api/routes/:id/channels', async (request, reply) => {
|
app.post<{ Params: { id: string }; Body: { accountId: number; tokenId?: number; sourceModel?: string; priority?: number; weight?: number } }>('/api/routes/:id/channels', async (request, reply) => {
|
||||||
const routeId = parseInt(request.params.id, 10);
|
const routeId = parseInt(request.params.id, 10);
|
||||||
const body = request.body;
|
const body = request.body;
|
||||||
|
|
||||||
@@ -219,6 +469,9 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
return reply.code(404).send({ success: false, message: '路由不存在' });
|
return reply.code(404).send({ success: false, message: '路由不存在' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sourceModel = typeof body.sourceModel === 'string'
|
||||||
|
? body.sourceModel.trim()
|
||||||
|
: (isExactModelPattern(route.modelPattern) ? route.modelPattern.trim() : '');
|
||||||
const effectiveTokenId = body.tokenId ?? getDefaultTokenId(body.accountId);
|
const effectiveTokenId = body.tokenId ?? getDefaultTokenId(body.accountId);
|
||||||
|
|
||||||
if (body.tokenId && !checkTokenBelongsToAccount(body.tokenId, body.accountId)) {
|
if (body.tokenId && !checkTokenBelongsToAccount(body.tokenId, body.accountId)) {
|
||||||
@@ -229,10 +482,23 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
return reply.code(400).send({ success: false, message: '该令牌不支持当前模型' });
|
return reply.code(400).send({ success: false, message: '该令牌不支持当前模型' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const duplicate = db.select().from(schema.routeChannels)
|
||||||
|
.where(eq(schema.routeChannels.routeId, routeId))
|
||||||
|
.all()
|
||||||
|
.some((channel) =>
|
||||||
|
channel.accountId === body.accountId
|
||||||
|
&& (channel.tokenId ?? null) === (body.tokenId ?? null)
|
||||||
|
&& (channel.sourceModel || '').trim().toLowerCase() === sourceModel.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (duplicate) {
|
||||||
|
return reply.code(400).send({ success: false, message: '该来源模型的通道已存在' });
|
||||||
|
}
|
||||||
|
|
||||||
return db.insert(schema.routeChannels).values({
|
return db.insert(schema.routeChannels).values({
|
||||||
routeId,
|
routeId,
|
||||||
accountId: body.accountId,
|
accountId: body.accountId,
|
||||||
tokenId: body.tokenId,
|
tokenId: body.tokenId,
|
||||||
|
sourceModel: sourceModel || null,
|
||||||
priority: body.priority ?? 0,
|
priority: body.priority ?? 0,
|
||||||
weight: body.weight ?? 10,
|
weight: body.weight ?? 10,
|
||||||
}).returning().get();
|
}).returning().get();
|
||||||
@@ -299,6 +565,11 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updates: Record<string, unknown> = { manualOverride: true };
|
const updates: Record<string, unknown> = { manualOverride: true };
|
||||||
|
if (body.sourceModel !== undefined) {
|
||||||
|
if (body.sourceModel === null) updates.sourceModel = null;
|
||||||
|
else updates.sourceModel = String(body.sourceModel).trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
for (const key of ['priority', 'weight', 'enabled', 'tokenId']) {
|
for (const key of ['priority', 'weight', 'enabled', 'tokenId']) {
|
||||||
if (body[key] !== undefined) updates[key] = body[key];
|
if (body[key] !== undefined) updates[key] = body[key];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -448,6 +448,89 @@ describe('chat proxy stream behavior', () => {
|
|||||||
expect(forwardedBody.thinking).toEqual({ type: 'enabled', budget_tokens: 1024 });
|
expect(forwardedBody.thinking).toEqual({ type: 'enabled', budget_tokens: 1024 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes Claude thinking adaptive type for legacy upstreams on /v1/messages', async () => {
|
||||||
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
id: 'msg_headers_adaptive',
|
||||||
|
type: 'message',
|
||||||
|
model: 'claude-opus-4-6',
|
||||||
|
content: [{ type: 'text', text: 'ok' }],
|
||||||
|
stop_reason: 'end_turn',
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/messages',
|
||||||
|
payload: {
|
||||||
|
model: 'claude-opus-4-6',
|
||||||
|
max_tokens: 256,
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
thinking: { type: 'adaptive', budget_tokens: 1024 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const [_targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
|
||||||
|
const forwardedBody = JSON.parse(options.body);
|
||||||
|
expect(forwardedBody.thinking).toEqual({ type: 'enabled', budget_tokens: 1024 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries /v1/messages with normalized Claude body when upstream says messages is required', async () => {
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
error: {
|
||||||
|
type: '<nil>',
|
||||||
|
message: 'messages is required',
|
||||||
|
},
|
||||||
|
}), {
|
||||||
|
status: 400,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}))
|
||||||
|
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
id: 'msg_retry_ok',
|
||||||
|
type: 'message',
|
||||||
|
model: 'claude-opus-4-6',
|
||||||
|
content: [{ type: 'text', text: 'ok after normalized fallback' }],
|
||||||
|
stop_reason: 'end_turn',
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/messages',
|
||||||
|
payload: {
|
||||||
|
model: 'claude-opus-4-6',
|
||||||
|
max_tokens: 256,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'hello' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(fetchMock.mock.calls.length).toBe(2);
|
||||||
|
|
||||||
|
const [_firstUrl, firstOptions] = fetchMock.mock.calls[0] as [string, any];
|
||||||
|
const [_secondUrl, secondOptions] = fetchMock.mock.calls[1] as [string, any];
|
||||||
|
const firstBody = JSON.parse(firstOptions.body);
|
||||||
|
const secondBody = JSON.parse(secondOptions.body);
|
||||||
|
|
||||||
|
expect(Array.isArray(firstBody.messages)).toBe(true);
|
||||||
|
expect(Array.isArray(secondBody.messages)).toBe(true);
|
||||||
|
expect(typeof secondBody.messages[0]?.content).toBe('string');
|
||||||
|
expect(secondBody.messages[0]?.content).toContain('hello');
|
||||||
|
});
|
||||||
|
|
||||||
it('passes through Claude tool_use SSE events on /v1/messages for CLI tool execution', async () => {
|
it('passes through Claude tool_use SSE events on /v1/messages for CLI tool execution', async () => {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const upstreamBody = new ReadableStream<Uint8Array>({
|
const upstreamBody = new ReadableStream<Uint8Array>({
|
||||||
@@ -805,6 +888,133 @@ describe('chat proxy stream behavior', () => {
|
|||||||
expect(targetUrl).toContain('/v1/messages');
|
expect(targetUrl).toContain('/v1/messages');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('forces openai platform to use /v1/chat/completions for claude downstream requests', async () => {
|
||||||
|
selectChannelMock.mockReturnValue({
|
||||||
|
channel: { id: 11, routeId: 22 },
|
||||||
|
site: { name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' },
|
||||||
|
account: { id: 33, username: 'demo-user' },
|
||||||
|
tokenName: 'default',
|
||||||
|
tokenValue: 'sk-openai',
|
||||||
|
actualModel: 'gpt-4o-mini',
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
id: 'chatcmpl-openai-for-claude-downstream',
|
||||||
|
object: 'chat.completion',
|
||||||
|
created: 1_706_000_003,
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
message: { role: 'assistant', content: 'openai endpoint selected' },
|
||||||
|
finish_reason: 'stop',
|
||||||
|
}],
|
||||||
|
usage: { prompt_tokens: 4, completion_tokens: 3, total_tokens: 7 },
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/messages',
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
max_tokens: 128,
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [targetUrl] = fetchMock.mock.calls[0] as [string, any];
|
||||||
|
expect(targetUrl).toContain('/v1/chat/completions');
|
||||||
|
expect(targetUrl).not.toContain('/v1/messages');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces claude platform to use /v1/messages with x-api-key auth for openai downstream requests', async () => {
|
||||||
|
selectChannelMock.mockReturnValue({
|
||||||
|
channel: { id: 11, routeId: 22 },
|
||||||
|
site: { name: 'claude-site', url: 'https://api.anthropic.com', platform: 'claude' },
|
||||||
|
account: { id: 33, username: 'demo-user' },
|
||||||
|
tokenName: 'default',
|
||||||
|
tokenValue: 'sk-claude',
|
||||||
|
actualModel: 'claude-sonnet-4-5-20250929',
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
id: 'msg_claude_upstream',
|
||||||
|
type: 'message',
|
||||||
|
model: 'claude-sonnet-4-5-20250929',
|
||||||
|
content: [{ type: 'text', text: 'claude endpoint selected' }],
|
||||||
|
stop_reason: 'end_turn',
|
||||||
|
usage: { input_tokens: 5, output_tokens: 2, total_tokens: 7 },
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/chat/completions',
|
||||||
|
payload: {
|
||||||
|
model: 'claude-sonnet-4-5-20250929',
|
||||||
|
stream: false,
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
|
||||||
|
expect(targetUrl).toContain('/v1/messages');
|
||||||
|
expect(options.headers['x-api-key']).toBe('sk-claude');
|
||||||
|
expect(options.headers['anthropic-version']).toBeTruthy();
|
||||||
|
expect(options.headers.Authorization).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes gemini platform to OpenAI-compatible upstream endpoint path', async () => {
|
||||||
|
selectChannelMock.mockReturnValue({
|
||||||
|
channel: { id: 11, routeId: 22 },
|
||||||
|
site: { name: 'gemini-site', url: 'https://generativelanguage.googleapis.com', platform: 'gemini' },
|
||||||
|
account: { id: 33, username: 'demo-user' },
|
||||||
|
tokenName: 'default',
|
||||||
|
tokenValue: 'gemini-key',
|
||||||
|
actualModel: 'gemini-2.5-flash',
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
id: 'chatcmpl-gemini-openai-compat',
|
||||||
|
object: 'chat.completion',
|
||||||
|
created: 1_706_000_004,
|
||||||
|
model: 'gemini-2.5-flash',
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
message: { role: 'assistant', content: 'gemini endpoint selected' },
|
||||||
|
finish_reason: 'stop',
|
||||||
|
}],
|
||||||
|
usage: { prompt_tokens: 6, completion_tokens: 4, total_tokens: 10 },
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/chat/completions',
|
||||||
|
payload: {
|
||||||
|
model: 'gemini-2.5-flash',
|
||||||
|
stream: false,
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
|
||||||
|
expect(targetUrl).toContain('/v1beta/openai/chat/completions');
|
||||||
|
expect(options.headers.Authorization).toBe('Bearer gemini-key');
|
||||||
|
});
|
||||||
|
|
||||||
it('chooses /v1/messages upstream when catalog indicates messages-only endpoint support', async () => {
|
it('chooses /v1/messages upstream when catalog indicates messages-only endpoint support', async () => {
|
||||||
fetchModelPricingCatalogMock.mockResolvedValue({
|
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||||
models: [
|
models: [
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { estimateProxyCost } from '../../services/modelPricingService.js';
|
|||||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
||||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||||
|
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
||||||
import {
|
import {
|
||||||
type DownstreamFormat,
|
type DownstreamFormat,
|
||||||
createStreamTransformContext,
|
createStreamTransformContext,
|
||||||
@@ -27,6 +28,11 @@ import {
|
|||||||
isEndpointDowngradeError,
|
isEndpointDowngradeError,
|
||||||
resolveUpstreamEndpointCandidates,
|
resolveUpstreamEndpointCandidates,
|
||||||
} from './upstreamEndpoint.js';
|
} from './upstreamEndpoint.js';
|
||||||
|
import {
|
||||||
|
ensureModelAllowedForDownstreamKey,
|
||||||
|
getDownstreamRoutingPolicy,
|
||||||
|
recordDownstreamCostUsage,
|
||||||
|
} from './downstreamPolicy.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
const CLAUDE_SSE_EVENT_NAMES = new Set([
|
const CLAUDE_SSE_EVENT_NAMES = new Set([
|
||||||
@@ -44,6 +50,18 @@ function withUpstreamPath(path: string, message: string): string {
|
|||||||
return `[upstream:${path}] ${message}`;
|
return `[upstream:${path}] ${message}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldRetryClaudeMessagesWithNormalizedBody(
|
||||||
|
downstreamFormat: DownstreamFormat,
|
||||||
|
endpointPath: string,
|
||||||
|
status: number,
|
||||||
|
upstreamErrorText: string,
|
||||||
|
): boolean {
|
||||||
|
if (downstreamFormat !== 'claude') return false;
|
||||||
|
if (!endpointPath.includes('/v1/messages')) return false;
|
||||||
|
if (status < 400 || status >= 500) return false;
|
||||||
|
return /messages\s+is\s+required/i.test(upstreamErrorText);
|
||||||
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return !!value && typeof value === 'object';
|
return !!value && typeof value === 'object';
|
||||||
}
|
}
|
||||||
@@ -116,18 +134,20 @@ async function handleChatProxyRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { requestedModel, isStream, upstreamBody, claudeOriginalBody } = parsedRequest.value!;
|
const { requestedModel, isStream, upstreamBody, claudeOriginalBody } = parsedRequest.value!;
|
||||||
|
if (!ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||||
|
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||||
|
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
|
|
||||||
while (retryCount <= MAX_RETRIES) {
|
while (retryCount <= MAX_RETRIES) {
|
||||||
let selected = retryCount === 0
|
let selected = retryCount === 0
|
||||||
? tokenRouter.selectChannel(requestedModel)
|
? tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||||
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds);
|
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||||
|
|
||||||
if (!selected && retryCount === 0) {
|
if (!selected && retryCount === 0) {
|
||||||
await refreshModelsAndRebuildRoutes();
|
await refreshModelsAndRebuildRoutes();
|
||||||
selected = tokenRouter.selectChannel(requestedModel);
|
selected = tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
@@ -165,6 +185,8 @@ async function handleChatProxyRequest(
|
|||||||
modelName,
|
modelName,
|
||||||
stream: isStream,
|
stream: isStream,
|
||||||
tokenValue: selected.tokenValue,
|
tokenValue: selected.tokenValue,
|
||||||
|
sitePlatform: selected.site.platform,
|
||||||
|
siteUrl: selected.site.url,
|
||||||
openaiBody: upstreamBody,
|
openaiBody: upstreamBody,
|
||||||
downstreamFormat,
|
downstreamFormat,
|
||||||
claudeOriginalBody,
|
claudeOriginalBody,
|
||||||
@@ -174,11 +196,11 @@ async function handleChatProxyRequest(
|
|||||||
const targetUrl = `${selected.site.url}${endpointRequest.path}`;
|
const targetUrl = `${selected.site.url}${endpointRequest.path}`;
|
||||||
startTime = Date.now();
|
startTime = Date.now();
|
||||||
|
|
||||||
const response = await fetch(targetUrl, {
|
const response = await fetch(targetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: endpointRequest.headers,
|
headers: endpointRequest.headers,
|
||||||
body: JSON.stringify(endpointRequest.body),
|
body: JSON.stringify(endpointRequest.body),
|
||||||
});
|
}));
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
upstream = response;
|
upstream = response;
|
||||||
@@ -187,6 +209,42 @@ async function handleChatProxyRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rawErrText = await response.text().catch(() => 'unknown error');
|
const rawErrText = await response.text().catch(() => 'unknown error');
|
||||||
|
|
||||||
|
if (shouldRetryClaudeMessagesWithNormalizedBody(downstreamFormat, endpointRequest.path, response.status, rawErrText)) {
|
||||||
|
const normalizedClaudeRequest = buildUpstreamEndpointRequest({
|
||||||
|
endpoint: endpointCandidates[endpointIndex],
|
||||||
|
modelName,
|
||||||
|
stream: isStream,
|
||||||
|
tokenValue: selected.tokenValue,
|
||||||
|
sitePlatform: selected.site.platform,
|
||||||
|
siteUrl: selected.site.url,
|
||||||
|
openaiBody: upstreamBody,
|
||||||
|
downstreamFormat,
|
||||||
|
claudeOriginalBody: undefined,
|
||||||
|
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
const normalizedTargetUrl = `${selected.site.url}${normalizedClaudeRequest.path}`;
|
||||||
|
const normalizedResponse = await fetch(normalizedTargetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: normalizedClaudeRequest.headers,
|
||||||
|
body: JSON.stringify(normalizedClaudeRequest.body),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (normalizedResponse.ok) {
|
||||||
|
upstream = normalizedResponse;
|
||||||
|
successfulUpstreamPath = normalizedClaudeRequest.path;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedErrText = await normalizedResponse.text().catch(() => 'unknown error');
|
||||||
|
finalStatus = normalizedResponse.status;
|
||||||
|
finalErrText = withUpstreamPath(
|
||||||
|
normalizedClaudeRequest.path,
|
||||||
|
`normalized-claude-body fallback failed: ${normalizedErrText}`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
const errText = withUpstreamPath(endpointRequest.path, rawErrText);
|
const errText = withUpstreamPath(endpointRequest.path, rawErrText);
|
||||||
const shouldDowngradeEndpoint = (
|
const shouldDowngradeEndpoint = (
|
||||||
endpointIndex < endpointCandidates.length - 1
|
endpointIndex < endpointCandidates.length - 1
|
||||||
@@ -332,6 +390,7 @@ async function handleChatProxyRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||||
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -470,6 +529,7 @@ async function handleChatProxyRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||||
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -530,6 +590,7 @@ async function handleChatProxyRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||||
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { estimateProxyCost } from '../../services/modelPricingService.js';
|
|||||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
||||||
import { mergeProxyUsage, parseProxyUsage, pullSseDataEvents } from '../../services/proxyUsageParser.js';
|
import { mergeProxyUsage, parseProxyUsage, pullSseDataEvents } from '../../services/proxyUsageParser.js';
|
||||||
|
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
||||||
|
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
@@ -19,6 +21,8 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
|||||||
if (!requestedModel) {
|
if (!requestedModel) {
|
||||||
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
|
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
|
||||||
}
|
}
|
||||||
|
if (!ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||||
|
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||||
|
|
||||||
const isStream = body.stream === true;
|
const isStream = body.stream === true;
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
@@ -26,12 +30,12 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
|||||||
|
|
||||||
while (retryCount <= MAX_RETRIES) {
|
while (retryCount <= MAX_RETRIES) {
|
||||||
let selected = retryCount === 0
|
let selected = retryCount === 0
|
||||||
? tokenRouter.selectChannel(requestedModel)
|
? tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||||
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds);
|
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||||
|
|
||||||
if (!selected && retryCount === 0) {
|
if (!selected && retryCount === 0) {
|
||||||
await refreshModelsAndRebuildRoutes();
|
await refreshModelsAndRebuildRoutes();
|
||||||
selected = tokenRouter.selectChannel(requestedModel);
|
selected = tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
@@ -51,14 +55,14 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
|||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const upstream = await fetch(targetUrl, {
|
const upstream = await fetch(targetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${selected.tokenValue}`,
|
'Authorization': `Bearer ${selected.tokenValue}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(forwardBody),
|
body: JSON.stringify(forwardBody),
|
||||||
});
|
}));
|
||||||
|
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
const errText = await upstream.text().catch(() => 'unknown error');
|
const errText = await upstream.text().catch(() => 'unknown error');
|
||||||
@@ -159,6 +163,7 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
|||||||
estimatedCost = resolvedUsage.estimatedCostFromQuota;
|
estimatedCost = resolvedUsage.estimatedCostFromQuota;
|
||||||
}
|
}
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||||
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected, requestedModel, 'success', 200, latency, null, retryCount,
|
selected, requestedModel, 'success', 200, latency, null, retryCount,
|
||||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
|
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
|
||||||
@@ -197,6 +202,7 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||||
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected, requestedModel, 'success', 200, latency, null, retryCount,
|
selected, requestedModel, 'success', 200, latency, null, retryCount,
|
||||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
|
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { getProxyAuthContext } from '../../middleware/auth.js';
|
||||||
|
import { isModelAllowedByPolicyOrAllowedRoutes, recordManagedKeyCostUsage } from '../../services/downstreamApiKeyService.js';
|
||||||
|
import { EMPTY_DOWNSTREAM_ROUTING_POLICY, type DownstreamRoutingPolicy } from '../../services/downstreamPolicyTypes.js';
|
||||||
|
|
||||||
|
export function getDownstreamRoutingPolicy(request: FastifyRequest): DownstreamRoutingPolicy {
|
||||||
|
return getProxyAuthContext(request)?.policy || EMPTY_DOWNSTREAM_ROUTING_POLICY;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureModelAllowedForDownstreamKey(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
requestedModel: string,
|
||||||
|
): boolean {
|
||||||
|
const authContext = getProxyAuthContext(request);
|
||||||
|
if (!authContext) return true;
|
||||||
|
|
||||||
|
if (isModelAllowedByPolicyOrAllowedRoutes(requestedModel, authContext.policy)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.code(403).send({
|
||||||
|
error: {
|
||||||
|
message: `Model not allowed for this API key: ${requestedModel}`,
|
||||||
|
type: 'permission_error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordDownstreamCostUsage(request: FastifyRequest, estimatedCost: number): void {
|
||||||
|
const authContext = getProxyAuthContext(request);
|
||||||
|
if (!authContext || authContext.keyId === null) return;
|
||||||
|
recordManagedKeyCostUsage(authContext.keyId, estimatedCost);
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import { estimateProxyCost } from '../../services/modelPricingService.js';
|
|||||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
||||||
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
|
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||||
|
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
||||||
|
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
@@ -19,18 +21,20 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
|
|||||||
if (!requestedModel) {
|
if (!requestedModel) {
|
||||||
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
|
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
|
||||||
}
|
}
|
||||||
|
if (!ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||||
|
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||||
|
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
|
|
||||||
while (retryCount <= MAX_RETRIES) {
|
while (retryCount <= MAX_RETRIES) {
|
||||||
let selected = retryCount === 0
|
let selected = retryCount === 0
|
||||||
? tokenRouter.selectChannel(requestedModel)
|
? tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||||
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds);
|
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||||
|
|
||||||
if (!selected && retryCount === 0) {
|
if (!selected && retryCount === 0) {
|
||||||
await refreshModelsAndRebuildRoutes();
|
await refreshModelsAndRebuildRoutes();
|
||||||
selected = tokenRouter.selectChannel(requestedModel);
|
selected = tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
@@ -48,14 +52,14 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
|
|||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const upstream = await fetch(targetUrl, {
|
const upstream = await fetch(targetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${selected.tokenValue}`,
|
'Authorization': `Bearer ${selected.tokenValue}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(forwardBody),
|
body: JSON.stringify(forwardBody),
|
||||||
});
|
}));
|
||||||
|
|
||||||
const text = await upstream.text();
|
const text = await upstream.text();
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
@@ -115,6 +119,7 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||||
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected, requestedModel, 'success', upstream.status, latency, null, retryCount,
|
selected, requestedModel, 'success', upstream.status, latency, null, retryCount,
|
||||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
|
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertSe
|
|||||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||||
import { estimateProxyCost } from '../../services/modelPricingService.js';
|
import { estimateProxyCost } from '../../services/modelPricingService.js';
|
||||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||||
|
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
||||||
|
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
@@ -14,17 +16,19 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
|||||||
app.post('/v1/images/generations', async (request: FastifyRequest, reply: FastifyReply) => {
|
app.post('/v1/images/generations', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const body = request.body as any;
|
const body = request.body as any;
|
||||||
const requestedModel = body?.model || 'gpt-image-1';
|
const requestedModel = body?.model || 'gpt-image-1';
|
||||||
|
if (!ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||||
|
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
|
|
||||||
while (retryCount <= MAX_RETRIES) {
|
while (retryCount <= MAX_RETRIES) {
|
||||||
let selected = retryCount === 0
|
let selected = retryCount === 0
|
||||||
? tokenRouter.selectChannel(requestedModel)
|
? tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||||
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds);
|
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||||
|
|
||||||
if (!selected && retryCount === 0) {
|
if (!selected && retryCount === 0) {
|
||||||
await refreshModelsAndRebuildRoutes();
|
await refreshModelsAndRebuildRoutes();
|
||||||
selected = tokenRouter.selectChannel(requestedModel);
|
selected = tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
@@ -44,14 +48,14 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
|||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const upstream = await fetch(targetUrl, {
|
const upstream = await fetch(targetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${selected.tokenValue}`,
|
'Authorization': `Bearer ${selected.tokenValue}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(forwardBody),
|
body: JSON.stringify(forwardBody),
|
||||||
});
|
}));
|
||||||
|
|
||||||
const text = await upstream.text();
|
const text = await upstream.text();
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
@@ -89,6 +93,7 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
|||||||
totalTokens: 0,
|
totalTokens: 0,
|
||||||
});
|
});
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||||
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, estimatedCost);
|
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, estimatedCost);
|
||||||
return reply.code(upstream.status).send(data);
|
return reply.code(upstream.status).send(data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ import { FastifyInstance } from 'fastify';
|
|||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
import { and, eq } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||||
|
import { getDownstreamRoutingPolicy } from './downstreamPolicy.js';
|
||||||
|
import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js';
|
||||||
|
|
||||||
export async function modelsProxyRoute(app: FastifyInstance) {
|
export async function modelsProxyRoute(app: FastifyInstance) {
|
||||||
app.get('/v1/models', async (request) => {
|
app.get('/v1/models', async (request) => {
|
||||||
|
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||||
|
|
||||||
const readModels = () => {
|
const readModels = () => {
|
||||||
const rows = db.select({ modelName: schema.modelAvailability.modelName })
|
const rows = db.select({ modelName: schema.modelAvailability.modelName })
|
||||||
.from(schema.modelAvailability)
|
.from(schema.modelAvailability)
|
||||||
@@ -18,7 +22,17 @@ export async function modelsProxyRoute(app: FastifyInstance) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.all();
|
.all();
|
||||||
return Array.from(new Set(rows.map((r) => r.modelName))).sort();
|
const routeAliases = db.select({ displayName: schema.tokenRoutes.displayName })
|
||||||
|
.from(schema.tokenRoutes)
|
||||||
|
.where(eq(schema.tokenRoutes.enabled, true))
|
||||||
|
.all()
|
||||||
|
.map((row) => (row.displayName || '').trim())
|
||||||
|
.filter((name) => name.length > 0);
|
||||||
|
const deduped = Array.from(new Set([
|
||||||
|
...rows.map((r) => r.modelName),
|
||||||
|
...routeAliases,
|
||||||
|
])).sort();
|
||||||
|
return deduped.filter((modelName) => isModelAllowedByPolicyOrAllowedRoutes(modelName, downstreamPolicy));
|
||||||
};
|
};
|
||||||
|
|
||||||
let models = readModels();
|
let models = readModels();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { estimateProxyCost } from '../../services/modelPricingService.js';
|
|||||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
||||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||||
|
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
||||||
import {
|
import {
|
||||||
createStreamTransformContext,
|
createStreamTransformContext,
|
||||||
normalizeUpstreamFinalResponse,
|
normalizeUpstreamFinalResponse,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
resolveUpstreamEndpointCandidates,
|
resolveUpstreamEndpointCandidates,
|
||||||
type UpstreamEndpoint,
|
type UpstreamEndpoint,
|
||||||
} from './upstreamEndpoint.js';
|
} from './upstreamEndpoint.js';
|
||||||
|
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
@@ -613,6 +615,8 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
if (!requestedModel) {
|
if (!requestedModel) {
|
||||||
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
|
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
|
||||||
}
|
}
|
||||||
|
if (!ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||||
|
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||||
|
|
||||||
const isStream = body.stream === true;
|
const isStream = body.stream === true;
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
@@ -620,12 +624,12 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
|
|
||||||
while (retryCount <= MAX_RETRIES) {
|
while (retryCount <= MAX_RETRIES) {
|
||||||
let selected = retryCount === 0
|
let selected = retryCount === 0
|
||||||
? tokenRouter.selectChannel(requestedModel)
|
? tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||||
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds);
|
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||||
|
|
||||||
if (!selected && retryCount === 0) {
|
if (!selected && retryCount === 0) {
|
||||||
await refreshModelsAndRebuildRoutes();
|
await refreshModelsAndRebuildRoutes();
|
||||||
selected = tokenRouter.selectChannel(requestedModel);
|
selected = tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
@@ -669,6 +673,8 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
modelName,
|
modelName,
|
||||||
stream: isStream,
|
stream: isStream,
|
||||||
tokenValue: selected.tokenValue,
|
tokenValue: selected.tokenValue,
|
||||||
|
sitePlatform: selected.site.platform,
|
||||||
|
siteUrl: selected.site.url,
|
||||||
openaiBody: openAiBody,
|
openaiBody: openAiBody,
|
||||||
downstreamFormat: 'responses',
|
downstreamFormat: 'responses',
|
||||||
responsesOriginalBody: body,
|
responsesOriginalBody: body,
|
||||||
@@ -676,11 +682,11 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
const targetUrl = `${selected.site.url}${endpointRequest.path}`;
|
const targetUrl = `${selected.site.url}${endpointRequest.path}`;
|
||||||
|
|
||||||
const response = await fetch(targetUrl, {
|
const response = await fetch(targetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: endpointRequest.headers,
|
headers: endpointRequest.headers,
|
||||||
body: JSON.stringify(endpointRequest.body),
|
body: JSON.stringify(endpointRequest.body),
|
||||||
});
|
}));
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
upstream = response;
|
upstream = response;
|
||||||
@@ -858,6 +864,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
estimatedCost = resolvedUsage.estimatedCostFromQuota;
|
estimatedCost = resolvedUsage.estimatedCostFromQuota;
|
||||||
}
|
}
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||||
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected, requestedModel, 'success', 200, latency, null, retryCount,
|
selected, requestedModel, 'success', 200, latency, null, retryCount,
|
||||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
|
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
|
||||||
@@ -909,6 +916,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||||
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected, requestedModel, 'success', 200, latency, null, retryCount,
|
selected, requestedModel, 'success', 200, latency, null, retryCount,
|
||||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
|
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ function asTrimmedString(value: unknown): string {
|
|||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePlatformName(platform: unknown): string {
|
||||||
|
return asTrimmedString(platform).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
function headerValueToString(value: unknown): string | null {
|
function headerValueToString(value: unknown): string | null {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -167,6 +171,28 @@ function sanitizeMessagesBodyForAnthropic(body: Record<string, unknown>): Record
|
|||||||
if (hasTemperature && hasTopP) {
|
if (hasTemperature && hasTopP) {
|
||||||
delete sanitized.top_p;
|
delete sanitized.top_p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Claude Code may send thinking.type = "adaptive". Many Anthropic-compatible
|
||||||
|
// upstreams only accept enabled/disabled.
|
||||||
|
const thinking = sanitized.thinking;
|
||||||
|
if (isRecord(thinking)) {
|
||||||
|
const rawType = asTrimmedString(thinking.type).toLowerCase();
|
||||||
|
if (rawType === 'adaptive') {
|
||||||
|
const budgetTokens = toFiniteNumber(thinking.budget_tokens);
|
||||||
|
if (budgetTokens !== null && budgetTokens > 0) {
|
||||||
|
sanitized.thinking = {
|
||||||
|
...thinking,
|
||||||
|
type: 'enabled',
|
||||||
|
budget_tokens: Math.trunc(budgetTokens),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
sanitized.thinking = { type: 'disabled' };
|
||||||
|
}
|
||||||
|
} else if (rawType && rawType !== 'enabled' && rawType !== 'disabled') {
|
||||||
|
sanitized.thinking = { type: 'disabled' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return sanitized;
|
return sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,7 +363,24 @@ function normalizeEndpointTypes(value: unknown): UpstreamEndpoint[] {
|
|||||||
return Array.from(normalized);
|
return Array.from(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
function preferredEndpointOrder(downstreamFormat: EndpointPreference): UpstreamEndpoint[] {
|
function preferredEndpointOrder(downstreamFormat: EndpointPreference, sitePlatform?: string): UpstreamEndpoint[] {
|
||||||
|
const platform = normalizePlatformName(sitePlatform);
|
||||||
|
|
||||||
|
if (platform === 'gemini') {
|
||||||
|
// Gemini upstream is routed through OpenAI-compatible chat endpoint.
|
||||||
|
return ['chat'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'openai') {
|
||||||
|
return downstreamFormat === 'responses'
|
||||||
|
? ['responses', 'chat']
|
||||||
|
: ['chat', 'responses'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platform === 'claude') {
|
||||||
|
return ['messages'];
|
||||||
|
}
|
||||||
|
|
||||||
if (downstreamFormat === 'responses') {
|
if (downstreamFormat === 'responses') {
|
||||||
return ['responses', 'chat', 'messages'];
|
return ['responses', 'chat', 'messages'];
|
||||||
}
|
}
|
||||||
@@ -352,14 +395,15 @@ export async function resolveUpstreamEndpointCandidates(
|
|||||||
modelName: string,
|
modelName: string,
|
||||||
downstreamFormat: EndpointPreference,
|
downstreamFormat: EndpointPreference,
|
||||||
): Promise<UpstreamEndpoint[]> {
|
): Promise<UpstreamEndpoint[]> {
|
||||||
if ((context.site.platform || '').toLowerCase() === 'anyrouter') {
|
const sitePlatform = normalizePlatformName(context.site.platform);
|
||||||
|
if (sitePlatform === 'anyrouter') {
|
||||||
// anyrouter deployments are effectively anthropic-protocol first.
|
// anyrouter deployments are effectively anthropic-protocol first.
|
||||||
return downstreamFormat === 'responses'
|
return downstreamFormat === 'responses'
|
||||||
? ['responses', 'messages', 'chat']
|
? ['responses', 'messages', 'chat']
|
||||||
: ['messages', 'chat'];
|
: ['messages', 'chat'];
|
||||||
}
|
}
|
||||||
|
|
||||||
const preferred = preferredEndpointOrder(downstreamFormat);
|
const preferred = preferredEndpointOrder(downstreamFormat, context.site.platform);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const catalog = await fetchModelPricingCatalog({
|
const catalog = await fetchModelPricingCatalog({
|
||||||
@@ -415,6 +459,8 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
modelName: string;
|
modelName: string;
|
||||||
stream: boolean;
|
stream: boolean;
|
||||||
tokenValue: string;
|
tokenValue: string;
|
||||||
|
sitePlatform?: string;
|
||||||
|
siteUrl?: string;
|
||||||
openaiBody: Record<string, unknown>;
|
openaiBody: Record<string, unknown>;
|
||||||
downstreamFormat: EndpointPreference;
|
downstreamFormat: EndpointPreference;
|
||||||
claudeOriginalBody?: Record<string, unknown>;
|
claudeOriginalBody?: Record<string, unknown>;
|
||||||
@@ -425,12 +471,49 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
headers: Record<string, string>;
|
headers: Record<string, string>;
|
||||||
body: Record<string, unknown>;
|
body: Record<string, unknown>;
|
||||||
} {
|
} {
|
||||||
|
const sitePlatform = normalizePlatformName(input.sitePlatform);
|
||||||
|
const isClaudeUpstream = sitePlatform === 'claude';
|
||||||
|
|
||||||
|
const resolveGeminiEndpointPath = (endpoint: UpstreamEndpoint): string => {
|
||||||
|
const normalizedSiteUrl = asTrimmedString(input.siteUrl).toLowerCase();
|
||||||
|
const openAiCompatBase = /\/openai(?:\/|$)/.test(normalizedSiteUrl);
|
||||||
|
if (openAiCompatBase) {
|
||||||
|
return endpoint === 'responses'
|
||||||
|
? '/responses'
|
||||||
|
: '/chat/completions';
|
||||||
|
}
|
||||||
|
return endpoint === 'responses'
|
||||||
|
? '/v1beta/openai/responses'
|
||||||
|
: '/v1beta/openai/chat/completions';
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveEndpointPath = (endpoint: UpstreamEndpoint): string => {
|
||||||
|
if (sitePlatform === 'gemini') {
|
||||||
|
return resolveGeminiEndpointPath(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sitePlatform === 'openai') {
|
||||||
|
if (endpoint === 'responses') return '/v1/responses';
|
||||||
|
return '/v1/chat/completions';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sitePlatform === 'claude') {
|
||||||
|
return '/v1/messages';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endpoint === 'messages') return '/v1/messages';
|
||||||
|
if (endpoint === 'responses') return '/v1/responses';
|
||||||
|
return '/v1/chat/completions';
|
||||||
|
};
|
||||||
|
|
||||||
const passthroughHeaders = extractSafePassthroughHeaders(input.downstreamHeaders);
|
const passthroughHeaders = extractSafePassthroughHeaders(input.downstreamHeaders);
|
||||||
const commonHeaders = {
|
const commonHeaders: Record<string, string> = {
|
||||||
...passthroughHeaders,
|
...passthroughHeaders,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${input.tokenValue}`,
|
|
||||||
};
|
};
|
||||||
|
if (!isClaudeUpstream) {
|
||||||
|
commonHeaders.Authorization = `Bearer ${input.tokenValue}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (input.endpoint === 'messages') {
|
if (input.endpoint === 'messages') {
|
||||||
const claudeHeaders = input.downstreamFormat === 'claude'
|
const claudeHeaders = input.downstreamFormat === 'claude'
|
||||||
@@ -460,7 +543,7 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
}, input.stream);
|
}, input.stream);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: '/v1/messages',
|
path: resolveEndpointPath('messages'),
|
||||||
headers,
|
headers,
|
||||||
body: sanitizedBody,
|
body: sanitizedBody,
|
||||||
};
|
};
|
||||||
@@ -486,7 +569,7 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
}, input.stream);
|
}, input.stream);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: '/v1/responses',
|
path: resolveEndpointPath('responses'),
|
||||||
headers,
|
headers,
|
||||||
body,
|
body,
|
||||||
};
|
};
|
||||||
@@ -494,7 +577,7 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
|
|
||||||
const headers = ensureStreamAcceptHeader(commonHeaders, input.stream);
|
const headers = ensureStreamAcceptHeader(commonHeaders, input.stream);
|
||||||
return {
|
return {
|
||||||
path: '/v1/chat/completions',
|
path: resolveEndpointPath('chat'),
|
||||||
headers,
|
headers,
|
||||||
body: {
|
body: {
|
||||||
...input.openaiBody,
|
...input.openaiBody,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { isCloudflareChallenge, isTokenExpiredError } from './alertRules.js';
|
import { appendSessionTokenRebindHint, isCloudflareChallenge, isTokenExpiredError } from './alertRules.js';
|
||||||
|
|
||||||
describe('alertRules', () => {
|
describe('alertRules', () => {
|
||||||
it('detects cloudflare challenge messages', () => {
|
it('detects cloudflare challenge messages', () => {
|
||||||
@@ -18,4 +18,15 @@ describe('alertRules', () => {
|
|||||||
expect(isTokenExpiredError({ message: '无权进行此操作,未登录且未提供 access token' })).toBe(false);
|
expect(isTokenExpiredError({ message: '无权进行此操作,未登录且未提供 access token' })).toBe(false);
|
||||||
expect(isTokenExpiredError({ status: 500, message: 'upstream error' })).toBe(false);
|
expect(isTokenExpiredError({ status: 500, message: 'upstream error' })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('appends rebind hint for invalid access token messages', () => {
|
||||||
|
expect(appendSessionTokenRebindHint('无权进行此操作,access token 无效'))
|
||||||
|
.toContain('请在中转站重新生成系统访问令牌后重新绑定账号');
|
||||||
|
expect(appendSessionTokenRebindHint('invalid access token'))
|
||||||
|
.toContain('请在中转站重新生成系统访问令牌后重新绑定账号');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not append rebind hint for unrelated messages', () => {
|
||||||
|
expect(appendSessionTokenRebindHint('network timeout')).toBe('network timeout');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export function isCloudflareChallenge(message?: string | null): boolean {
|
|||||||
return text.includes('cloudflare') || text.includes('cf challenge') || text.includes('challenge required');
|
return text.includes('cloudflare') || text.includes('cf challenge') || text.includes('challenge required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SESSION_TOKEN_REBIND_HINT = '请在中转站重新生成系统访问令牌后重新绑定账号';
|
||||||
|
|
||||||
export function isTokenExpiredError(input: { status?: number; message?: string | null }): boolean {
|
export function isTokenExpiredError(input: { status?: number; message?: string | null }): boolean {
|
||||||
if (input.status === 401 || input.status === 403) return true;
|
if (input.status === 401 || input.status === 403) return true;
|
||||||
const text = (input.message || '').toLowerCase();
|
const text = (input.message || '').toLowerCase();
|
||||||
@@ -27,3 +29,20 @@ export function isTokenExpiredError(input: { status?: number; message?: string |
|
|||||||
text.includes('forbidden')
|
text.includes('forbidden')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function appendSessionTokenRebindHint(message?: string | null): string {
|
||||||
|
const raw = String(message || '').trim();
|
||||||
|
if (!raw) return raw;
|
||||||
|
if (raw.includes(SESSION_TOKEN_REBIND_HINT)) return raw;
|
||||||
|
|
||||||
|
const text = raw.toLowerCase();
|
||||||
|
const looksLikeInvalidAccessToken = (
|
||||||
|
raw.includes('无权进行此操作,access token 无效') ||
|
||||||
|
/invalid\s+access\s+token/.test(text) ||
|
||||||
|
/access\s+token\s+is\s+invalid/.test(text) ||
|
||||||
|
/access\s+token.*无效/.test(raw)
|
||||||
|
);
|
||||||
|
if (!looksLikeInvalidAccessToken) return raw;
|
||||||
|
|
||||||
|
return `${raw},${SESSION_TOKEN_REBIND_HINT}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { db, schema } from '../db/index.js';
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { sendNotification } from './notifyService.js';
|
import { sendNotification } from './notifyService.js';
|
||||||
import { setAccountRuntimeHealth } from './accountHealthService.js';
|
import { setAccountRuntimeHealth } from './accountHealthService.js';
|
||||||
|
import { appendSessionTokenRebindHint } from './alertRules.js';
|
||||||
|
|
||||||
export async function reportTokenExpired(params: {
|
export async function reportTokenExpired(params: {
|
||||||
accountId: number;
|
accountId: number;
|
||||||
@@ -11,7 +12,8 @@ export async function reportTokenExpired(params: {
|
|||||||
}) {
|
}) {
|
||||||
const accountLabel = params.username || `ID:${params.accountId}`;
|
const accountLabel = params.username || `ID:${params.accountId}`;
|
||||||
const siteLabel = params.siteName || 'unknown-site';
|
const siteLabel = params.siteName || 'unknown-site';
|
||||||
const detail = params.detail ? ` (${params.detail})` : '';
|
const detailText = params.detail ? appendSessionTokenRebindHint(params.detail) : '';
|
||||||
|
const detail = detailText ? ` (${detailText})` : '';
|
||||||
|
|
||||||
db.insert(schema.events).values({
|
db.insert(schema.events).values({
|
||||||
type: 'token',
|
type: 'token',
|
||||||
@@ -29,7 +31,7 @@ export async function reportTokenExpired(params: {
|
|||||||
|
|
||||||
setAccountRuntimeHealth(params.accountId, {
|
setAccountRuntimeHealth(params.accountId, {
|
||||||
state: 'unhealthy',
|
state: 'unhealthy',
|
||||||
reason: params.detail ? `访问令牌失效:${params.detail}` : '访问令牌失效',
|
reason: detailText ? `访问令牌失效:${detailText}` : '访问令牌失效',
|
||||||
source: 'auth',
|
source: 'auth',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
|
|||||||
name: siteName,
|
name: siteName,
|
||||||
url: siteUrl,
|
url: siteUrl,
|
||||||
platform,
|
platform,
|
||||||
|
proxyUrl: null,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
isPinned: false,
|
isPinned: false,
|
||||||
sortOrder: sites.length,
|
sortOrder: sites.length,
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { db, schema } from '../db/index.js';
|
|||||||
import { getAdapter } from './platforms/index.js';
|
import { getAdapter } from './platforms/index.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { sendNotification } from './notifyService.js';
|
import { sendNotification } from './notifyService.js';
|
||||||
import { isTokenExpiredError } from './alertRules.js';
|
import { appendSessionTokenRebindHint, isTokenExpiredError } from './alertRules.js';
|
||||||
import { reportTokenExpired } from './alertService.js';
|
import { reportTokenExpired } from './alertService.js';
|
||||||
import { getAutoReloginConfig, resolvePlatformUserId } from './accountExtraConfig.js';
|
import { getAutoReloginConfig, resolvePlatformUserId } from './accountExtraConfig.js';
|
||||||
import { decryptAccountPassword } from './accountCredentialService.js';
|
import { decryptAccountPassword } from './accountCredentialService.js';
|
||||||
import { extractRuntimeHealth, setAccountRuntimeHealth } from './accountHealthService.js';
|
import { extractRuntimeHealth, setAccountRuntimeHealth } from './accountHealthService.js';
|
||||||
import { updateTodayIncomeSnapshot } from './todayIncomeRewardService.js';
|
import { updateTodayIncomeSnapshot } from './todayIncomeRewardService.js';
|
||||||
import type { BalanceInfo } from './platforms/base.js';
|
import type { BalanceInfo } from './platforms/base.js';
|
||||||
|
import { withExplicitProxyRequestInit } from './siteProxy.js';
|
||||||
|
|
||||||
function isSiteDisabled(status?: string | null): boolean {
|
function isSiteDisabled(status?: string | null): boolean {
|
||||||
return (status || 'active') === 'disabled';
|
return (status || 'active') === 'disabled';
|
||||||
@@ -128,6 +129,7 @@ async function fetchTodayIncomeFromLogs(params: {
|
|||||||
accessToken: string;
|
accessToken: string;
|
||||||
platform?: string | null;
|
platform?: string | null;
|
||||||
platformUserId?: number;
|
platformUserId?: number;
|
||||||
|
proxyUrl?: string | null;
|
||||||
}): Promise<number | null> {
|
}): Promise<number | null> {
|
||||||
const baseUrl = params.baseUrl.trim();
|
const baseUrl = params.baseUrl.trim();
|
||||||
const accessToken = params.accessToken.trim();
|
const accessToken = params.accessToken.trim();
|
||||||
@@ -162,10 +164,10 @@ async function fetchTodayIncomeFromLogs(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${baseUrl}/api/log/self?${query.toString()}`, {
|
const response = await fetch(`${baseUrl}/api/log/self?${query.toString()}`, withExplicitProxyRequestInit(params.proxyUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers,
|
headers,
|
||||||
});
|
}));
|
||||||
if (!response.ok) break;
|
if (!response.ok) break;
|
||||||
|
|
||||||
const payload = await response.json().catch(() => null);
|
const payload = await response.json().catch(() => null);
|
||||||
@@ -258,7 +260,7 @@ export async function refreshBalance(accountId: number) {
|
|||||||
|
|
||||||
const readBalance = async (token: string) => adapter.getBalance(site.url, token, platformUserId);
|
const readBalance = async (token: string) => adapter.getBalance(site.url, token, platformUserId);
|
||||||
const handleBalanceError = async (err: any) => {
|
const handleBalanceError = async (err: any) => {
|
||||||
const message = err?.message || 'unknown error';
|
const message = appendSessionTokenRebindHint(err?.message || 'unknown error');
|
||||||
setAccountRuntimeHealth(account.id, {
|
setAccountRuntimeHealth(account.id, {
|
||||||
state: 'unhealthy',
|
state: 'unhealthy',
|
||||||
reason: message,
|
reason: message,
|
||||||
@@ -272,7 +274,7 @@ export async function refreshBalance(accountId: number) {
|
|||||||
detail: message,
|
detail: message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
throw err;
|
throw new Error(message);
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -310,6 +312,7 @@ export async function refreshBalance(accountId: number) {
|
|||||||
accessToken: activeAccessToken,
|
accessToken: activeAccessToken,
|
||||||
platform: site.platform,
|
platform: site.platform,
|
||||||
platformUserId,
|
platformUserId,
|
||||||
|
proxyUrl: site.proxyUrl,
|
||||||
});
|
});
|
||||||
if (typeof fallbackIncome === 'number' && Number.isFinite(fallbackIncome)) {
|
if (typeof fallbackIncome === 'number' && Number.isFinite(fallbackIncome)) {
|
||||||
balanceInfo.todayIncome = fallbackIncome;
|
balanceInfo.todayIncome = fallbackIncome;
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { mkdtempSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
type DbModule = typeof import('../db/index.js');
|
||||||
|
type ServiceModule = typeof import('./downstreamApiKeyService.js');
|
||||||
|
type ConfigModule = typeof import('../config.js');
|
||||||
|
|
||||||
|
describe('downstreamApiKeyService', () => {
|
||||||
|
let db: DbModule['db'];
|
||||||
|
let schema: DbModule['schema'];
|
||||||
|
let service: ServiceModule;
|
||||||
|
let config: ConfigModule['config'];
|
||||||
|
let dataDir = '';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dataDir = mkdtempSync(join(tmpdir(), 'metapi-downstream-key-'));
|
||||||
|
process.env.DATA_DIR = dataDir;
|
||||||
|
|
||||||
|
await import('../db/migrate.js');
|
||||||
|
const dbModule = await import('../db/index.js');
|
||||||
|
const configModule = await import('../config.js');
|
||||||
|
const serviceModule = await import('./downstreamApiKeyService.js');
|
||||||
|
|
||||||
|
db = dbModule.db;
|
||||||
|
schema = dbModule.schema;
|
||||||
|
config = configModule.config;
|
||||||
|
service = serviceModule;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db.delete(schema.downstreamApiKeys).run();
|
||||||
|
db.delete(schema.tokenRoutes).run();
|
||||||
|
config.proxyToken = 'sk-global-proxy-token';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete process.env.DATA_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authorizes global proxy token when no managed key matches', () => {
|
||||||
|
const result = service.authorizeDownstreamToken('sk-global-proxy-token');
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.key).toBeNull();
|
||||||
|
expect(result.policy.allowedRouteIds).toEqual([]);
|
||||||
|
expect(result.policy.supportedModels).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects managed keys by lifecycle guards (disabled, expired, over budget, over requests)', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const disabled = db.insert(schema.downstreamApiKeys).values({
|
||||||
|
name: 'disabled',
|
||||||
|
key: 'sk-disabled',
|
||||||
|
enabled: false,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const expired = db.insert(schema.downstreamApiKeys).values({
|
||||||
|
name: 'expired',
|
||||||
|
key: 'sk-expired',
|
||||||
|
enabled: true,
|
||||||
|
expiresAt: new Date(now - 60_000).toISOString(),
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const overBudget = db.insert(schema.downstreamApiKeys).values({
|
||||||
|
name: 'over-budget',
|
||||||
|
key: 'sk-over-budget',
|
||||||
|
enabled: true,
|
||||||
|
maxCost: 1,
|
||||||
|
usedCost: 1.2,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const overRequests = db.insert(schema.downstreamApiKeys).values({
|
||||||
|
name: 'over-requests',
|
||||||
|
key: 'sk-over-requests',
|
||||||
|
enabled: true,
|
||||||
|
maxRequests: 10,
|
||||||
|
usedRequests: 10,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const r1 = service.authorizeDownstreamToken(disabled.key);
|
||||||
|
const r2 = service.authorizeDownstreamToken(expired.key);
|
||||||
|
const r3 = service.authorizeDownstreamToken(overBudget.key);
|
||||||
|
const r4 = service.authorizeDownstreamToken(overRequests.key);
|
||||||
|
|
||||||
|
expect(r1.ok).toBe(false);
|
||||||
|
expect(r2.ok).toBe(false);
|
||||||
|
expect(r3.ok).toBe(false);
|
||||||
|
expect(r4.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses policy fields and supports model matching patterns', () => {
|
||||||
|
const row = db.insert(schema.downstreamApiKeys).values({
|
||||||
|
name: 'project-a',
|
||||||
|
key: 'sk-project-a',
|
||||||
|
enabled: true,
|
||||||
|
supportedModels: JSON.stringify(['re:^claude-(opus|sonnet)-4-6$', 'gpt-4o-mini']),
|
||||||
|
allowedRouteIds: JSON.stringify([101, 102]),
|
||||||
|
siteWeightMultipliers: JSON.stringify({ '1': 2.5, '7': 0.4 }),
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const result = service.authorizeDownstreamToken(row.key);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
expect(result.key?.id).toBe(row.id);
|
||||||
|
expect(result.policy.allowedRouteIds).toEqual([101, 102]);
|
||||||
|
expect(result.policy.siteWeightMultipliers[1]).toBeCloseTo(2.5);
|
||||||
|
expect(result.policy.siteWeightMultipliers[7]).toBeCloseTo(0.4);
|
||||||
|
|
||||||
|
expect(service.isModelAllowedByPolicy('claude-opus-4-6', result.policy)).toBe(true);
|
||||||
|
expect(service.isModelAllowedByPolicy('gpt-4o-mini', result.policy)).toBe(true);
|
||||||
|
expect(service.isModelAllowedByPolicy('gemini-2.0-flash', result.policy)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats selected groups as additional allowed model scope (union semantics)', () => {
|
||||||
|
const claudeGroup = db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 're:^claude-(opus|sonnet)-4-6$',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const policy = {
|
||||||
|
supportedModels: ['gpt-4o-mini'],
|
||||||
|
allowedRouteIds: [claudeGroup.id],
|
||||||
|
siteWeightMultipliers: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(service.isModelAllowedByPolicy('claude-opus-4-6', policy)).toBe(false);
|
||||||
|
expect(service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-6', policy)).toBe(true);
|
||||||
|
expect(service.isModelAllowedByPolicyOrAllowedRoutes('gemini-2.0-flash', policy)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authorizes by selected group model pattern only, not arbitrary internal models', () => {
|
||||||
|
const virtualModelGroup = db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'claude-opus-4-6',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const policy = {
|
||||||
|
supportedModels: [],
|
||||||
|
allowedRouteIds: [virtualModelGroup.id],
|
||||||
|
siteWeightMultipliers: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-6', policy)).toBe(true);
|
||||||
|
expect(service.isModelAllowedByPolicyOrAllowedRoutes('claude-sonnet-4-6', policy)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authorizes models by selected route display name alias', () => {
|
||||||
|
const aliasRoute = db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 're:^claude-(opus|sonnet)-4-5$',
|
||||||
|
displayName: 'claude-opus-4-6',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const policy = {
|
||||||
|
supportedModels: [],
|
||||||
|
allowedRouteIds: [aliasRoute.id],
|
||||||
|
siteWeightMultipliers: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-6', policy)).toBe(true);
|
||||||
|
expect(service.isModelAllowedByPolicyOrAllowedRoutes('claude-sonnet-4-5', policy)).toBe(true);
|
||||||
|
expect(service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-5', policy)).toBe(true);
|
||||||
|
expect(service.isModelAllowedByPolicyOrAllowedRoutes('gpt-4o-mini', policy)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
import { and, eq, inArray } from 'drizzle-orm';
|
||||||
|
import { minimatch } from 'minimatch';
|
||||||
|
import { db, schema } from '../db/index.js';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
import { EMPTY_DOWNSTREAM_ROUTING_POLICY, type DownstreamRoutingPolicy } from './downstreamPolicyTypes.js';
|
||||||
|
|
||||||
|
export type DownstreamApiKeyRow = typeof schema.downstreamApiKeys.$inferSelect;
|
||||||
|
|
||||||
|
export type DownstreamApiKeyPolicyView = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
keyMasked: string;
|
||||||
|
description: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
expiresAt: string | null;
|
||||||
|
maxCost: number | null;
|
||||||
|
usedCost: number;
|
||||||
|
maxRequests: number | null;
|
||||||
|
usedRequests: number;
|
||||||
|
supportedModels: string[];
|
||||||
|
allowedRouteIds: number[];
|
||||||
|
siteWeightMultipliers: Record<number, number>;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DownstreamTokenAuthSuccess = {
|
||||||
|
ok: true;
|
||||||
|
source: 'managed' | 'global';
|
||||||
|
token: string;
|
||||||
|
key: DownstreamApiKeyPolicyView | null;
|
||||||
|
policy: DownstreamRoutingPolicy;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DownstreamTokenAuthFailure = {
|
||||||
|
ok: false;
|
||||||
|
statusCode: number;
|
||||||
|
error: string;
|
||||||
|
reason: 'missing' | 'invalid' | 'disabled' | 'expired' | 'over_cost' | 'over_requests';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DownstreamTokenAuthResult = DownstreamTokenAuthSuccess | DownstreamTokenAuthFailure;
|
||||||
|
|
||||||
|
function isRegexModelPattern(pattern: string): boolean {
|
||||||
|
return pattern.trim().toLowerCase().startsWith('re:');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRegexModelPattern(pattern: string): RegExp | null {
|
||||||
|
if (!isRegexModelPattern(pattern)) return null;
|
||||||
|
const body = pattern.trim().slice(3).trim();
|
||||||
|
if (!body) return null;
|
||||||
|
try {
|
||||||
|
return new RegExp(body);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToken(raw: string): string {
|
||||||
|
return (raw || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskSecret(value: string): string {
|
||||||
|
if (!value) return '';
|
||||||
|
if (value.length <= 8) return '****';
|
||||||
|
return `${value.slice(0, 4)}****${value.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePositiveNumberOrNull(value: unknown): number | null {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n) || n < 0) return null;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePositiveIntegerOrNull(value: unknown): number | null {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return null;
|
||||||
|
const normalized = Math.trunc(n);
|
||||||
|
if (normalized < 0) return null;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(value: string | null | undefined): unknown {
|
||||||
|
if (!value) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSupportedModelsInput(input: unknown): string[] {
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
return input
|
||||||
|
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||||
|
.filter((item, index, arr) => item.length > 0 && arr.indexOf(item) === index)
|
||||||
|
.slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
return input
|
||||||
|
.split(/\r?\n|,/g)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item, index, arr) => item.length > 0 && arr.indexOf(item) === index)
|
||||||
|
.slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAllowedRouteIdsInput(input: unknown): number[] {
|
||||||
|
const rawValues = Array.isArray(input)
|
||||||
|
? input
|
||||||
|
: (typeof input === 'string' ? input.split(/\r?\n|,/g) : []);
|
||||||
|
|
||||||
|
const routeIds: number[] = [];
|
||||||
|
for (const item of rawValues) {
|
||||||
|
const n = Number(item);
|
||||||
|
if (!Number.isFinite(n)) continue;
|
||||||
|
const normalized = Math.trunc(n);
|
||||||
|
if (normalized <= 0 || routeIds.includes(normalized)) continue;
|
||||||
|
routeIds.push(normalized);
|
||||||
|
if (routeIds.length >= 500) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return routeIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSiteWeightMultipliersInput(input: unknown): Record<number, number> {
|
||||||
|
const raw = (typeof input === 'string')
|
||||||
|
? parseJson(input)
|
||||||
|
: input;
|
||||||
|
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Record<number, number> = {};
|
||||||
|
for (const [rawSiteId, rawMultiplier] of Object.entries(raw as Record<string, unknown>)) {
|
||||||
|
const siteId = Number(rawSiteId);
|
||||||
|
const multiplier = Number(rawMultiplier);
|
||||||
|
if (!Number.isFinite(siteId) || !Number.isFinite(multiplier)) continue;
|
||||||
|
const normalizedSiteId = Math.trunc(siteId);
|
||||||
|
if (normalizedSiteId <= 0 || multiplier <= 0) continue;
|
||||||
|
result[normalizedSiteId] = multiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesDownstreamModelPattern(model: string, pattern: string): boolean {
|
||||||
|
const normalizedPattern = (pattern || '').trim();
|
||||||
|
if (!normalizedPattern) return false;
|
||||||
|
|
||||||
|
if (normalizedPattern === model) return true;
|
||||||
|
|
||||||
|
if (isRegexModelPattern(normalizedPattern)) {
|
||||||
|
const re = parseRegexModelPattern(normalizedPattern);
|
||||||
|
return !!re && re.test(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
return minimatch(model, normalizedPattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isModelAllowedByPolicy(model: string, policy: DownstreamRoutingPolicy): boolean {
|
||||||
|
const patterns = Array.isArray(policy.supportedModels)
|
||||||
|
? policy.supportedModels
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (patterns.length === 0) return true;
|
||||||
|
|
||||||
|
return patterns.some((pattern) => matchesDownstreamModelPattern(model, pattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModelMatchedByAllowedRoutes(model: string, allowedRouteIds: number[]): boolean {
|
||||||
|
if (allowedRouteIds.length === 0) return false;
|
||||||
|
|
||||||
|
const routes = db.select({
|
||||||
|
id: schema.tokenRoutes.id,
|
||||||
|
modelPattern: schema.tokenRoutes.modelPattern,
|
||||||
|
displayName: schema.tokenRoutes.displayName,
|
||||||
|
})
|
||||||
|
.from(schema.tokenRoutes)
|
||||||
|
.where(and(
|
||||||
|
inArray(schema.tokenRoutes.id, allowedRouteIds),
|
||||||
|
eq(schema.tokenRoutes.enabled, true),
|
||||||
|
))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
return routes.some((route) => {
|
||||||
|
if (matchesDownstreamModelPattern(model, route.modelPattern)) return true;
|
||||||
|
const alias = (route.displayName || '').trim();
|
||||||
|
return !!alias && alias === model;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isModelAllowedByPolicyOrAllowedRoutes(model: string, policy: DownstreamRoutingPolicy): boolean {
|
||||||
|
const patterns = normalizeSupportedModelsInput(policy.supportedModels);
|
||||||
|
const allowedRouteIds = normalizeAllowedRouteIdsInput(policy.allowedRouteIds);
|
||||||
|
const hasPatternRules = patterns.length > 0;
|
||||||
|
const hasRouteRules = allowedRouteIds.length > 0;
|
||||||
|
|
||||||
|
if (!hasPatternRules && !hasRouteRules) return true;
|
||||||
|
|
||||||
|
if (hasPatternRules && patterns.some((pattern) => matchesDownstreamModelPattern(model, pattern))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasRouteRules) return false;
|
||||||
|
|
||||||
|
return isModelMatchedByAllowedRoutes(model, allowedRouteIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDownstreamApiKeyPolicyView(row: DownstreamApiKeyRow): DownstreamApiKeyPolicyView {
|
||||||
|
const supportedModels = normalizeSupportedModelsInput(parseJson(row.supportedModels));
|
||||||
|
const allowedRouteIds = normalizeAllowedRouteIdsInput(parseJson(row.allowedRouteIds));
|
||||||
|
const siteWeightMultipliers = normalizeSiteWeightMultipliersInput(parseJson(row.siteWeightMultipliers));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
key: row.key,
|
||||||
|
keyMasked: maskSecret(row.key),
|
||||||
|
description: row.description || null,
|
||||||
|
enabled: !!row.enabled,
|
||||||
|
expiresAt: row.expiresAt || null,
|
||||||
|
maxCost: row.maxCost ?? null,
|
||||||
|
usedCost: Number(row.usedCost || 0),
|
||||||
|
maxRequests: row.maxRequests ?? null,
|
||||||
|
usedRequests: Number(row.usedRequests || 0),
|
||||||
|
supportedModels,
|
||||||
|
allowedRouteIds,
|
||||||
|
siteWeightMultipliers,
|
||||||
|
lastUsedAt: row.lastUsedAt || null,
|
||||||
|
createdAt: row.createdAt || null,
|
||||||
|
updatedAt: row.updatedAt || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toPolicyFromView(view: Pick<DownstreamApiKeyPolicyView, 'supportedModels' | 'allowedRouteIds' | 'siteWeightMultipliers'>): DownstreamRoutingPolicy {
|
||||||
|
return {
|
||||||
|
supportedModels: normalizeSupportedModelsInput(view.supportedModels),
|
||||||
|
allowedRouteIds: normalizeAllowedRouteIdsInput(view.allowedRouteIds),
|
||||||
|
siteWeightMultipliers: normalizeSiteWeightMultipliersInput(view.siteWeightMultipliers),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDownstreamApiKeys(): DownstreamApiKeyPolicyView[] {
|
||||||
|
return db.select().from(schema.downstreamApiKeys)
|
||||||
|
.all()
|
||||||
|
.map((row) => toDownstreamApiKeyPolicyView(row))
|
||||||
|
.sort((a, b) => b.id - a.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDownstreamApiKeyById(id: number): DownstreamApiKeyPolicyView | null {
|
||||||
|
const row = db.select().from(schema.downstreamApiKeys)
|
||||||
|
.where(eq(schema.downstreamApiKeys.id, id))
|
||||||
|
.get();
|
||||||
|
if (!row) return null;
|
||||||
|
return toDownstreamApiKeyPolicyView(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getManagedDownstreamApiKeyByToken(token: string): DownstreamApiKeyPolicyView | null {
|
||||||
|
const normalizedToken = normalizeToken(token);
|
||||||
|
if (!normalizedToken) return null;
|
||||||
|
|
||||||
|
const row = db.select().from(schema.downstreamApiKeys)
|
||||||
|
.where(eq(schema.downstreamApiKeys.key, normalizedToken))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
return toDownstreamApiKeyPolicyView(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultGlobalPolicy(): DownstreamRoutingPolicy {
|
||||||
|
return EMPTY_DOWNSTREAM_ROUTING_POLICY;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authorizeDownstreamToken(token: string): DownstreamTokenAuthResult {
|
||||||
|
const normalizedToken = normalizeToken(token);
|
||||||
|
if (!normalizedToken) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
statusCode: 401,
|
||||||
|
error: 'Missing Authorization or x-api-key header',
|
||||||
|
reason: 'missing',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const managed = getManagedDownstreamApiKeyByToken(normalizedToken);
|
||||||
|
if (managed) {
|
||||||
|
if (!managed.enabled) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
statusCode: 403,
|
||||||
|
error: 'API key is disabled',
|
||||||
|
reason: 'disabled',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (managed.expiresAt) {
|
||||||
|
const expiresAtTs = Date.parse(managed.expiresAt);
|
||||||
|
if (Number.isFinite(expiresAtTs) && expiresAtTs <= Date.now()) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
statusCode: 403,
|
||||||
|
error: 'API key is expired',
|
||||||
|
reason: 'expired',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (managed.maxCost !== null && managed.usedCost >= managed.maxCost) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
statusCode: 403,
|
||||||
|
error: 'API key has exceeded max cost',
|
||||||
|
reason: 'over_cost',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (managed.maxRequests !== null && managed.usedRequests >= managed.maxRequests) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
statusCode: 403,
|
||||||
|
error: 'API key has exceeded max requests',
|
||||||
|
reason: 'over_requests',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
source: 'managed',
|
||||||
|
token: normalizedToken,
|
||||||
|
key: managed,
|
||||||
|
policy: toPolicyFromView(managed),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedToken === config.proxyToken) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
source: 'global',
|
||||||
|
token: normalizedToken,
|
||||||
|
key: null,
|
||||||
|
policy: getDefaultGlobalPolicy(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
statusCode: 403,
|
||||||
|
error: 'Invalid API key',
|
||||||
|
reason: 'invalid',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumeManagedKeyRequest(keyId: number): void {
|
||||||
|
const row = db.select().from(schema.downstreamApiKeys)
|
||||||
|
.where(eq(schema.downstreamApiKeys.id, keyId))
|
||||||
|
.get();
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
db.update(schema.downstreamApiKeys).set({
|
||||||
|
usedRequests: Math.max(0, (row.usedRequests || 0) + 1),
|
||||||
|
lastUsedAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}).where(eq(schema.downstreamApiKeys.id, keyId)).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordManagedKeyCostUsage(keyId: number, estimatedCost: number): void {
|
||||||
|
const cost = Number(estimatedCost);
|
||||||
|
if (!Number.isFinite(cost) || cost <= 0) return;
|
||||||
|
|
||||||
|
const row = db.select().from(schema.downstreamApiKeys)
|
||||||
|
.where(eq(schema.downstreamApiKeys.id, keyId))
|
||||||
|
.get();
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
db.update(schema.downstreamApiKeys).set({
|
||||||
|
usedCost: Math.max(0, (row.usedCost || 0) + cost),
|
||||||
|
lastUsedAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}).where(eq(schema.downstreamApiKeys.id, keyId)).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDownstreamApiKeyPayload(input: {
|
||||||
|
name?: unknown;
|
||||||
|
key?: unknown;
|
||||||
|
description?: unknown;
|
||||||
|
enabled?: unknown;
|
||||||
|
expiresAt?: unknown;
|
||||||
|
maxCost?: unknown;
|
||||||
|
maxRequests?: unknown;
|
||||||
|
supportedModels?: unknown;
|
||||||
|
allowedRouteIds?: unknown;
|
||||||
|
siteWeightMultipliers?: unknown;
|
||||||
|
}) {
|
||||||
|
const name = typeof input.name === 'string' ? input.name.trim() : '';
|
||||||
|
const key = typeof input.key === 'string' ? input.key.trim() : '';
|
||||||
|
const description = typeof input.description === 'string'
|
||||||
|
? input.description.trim()
|
||||||
|
: '';
|
||||||
|
const enabled = input.enabled === undefined ? true : !!input.enabled;
|
||||||
|
|
||||||
|
const expiresAtRaw = typeof input.expiresAt === 'string'
|
||||||
|
? input.expiresAt.trim()
|
||||||
|
: (input.expiresAt === null ? '' : '');
|
||||||
|
let expiresAt: string | null = null;
|
||||||
|
if (expiresAtRaw) {
|
||||||
|
const ts = Date.parse(expiresAtRaw);
|
||||||
|
if (!Number.isFinite(ts)) {
|
||||||
|
throw new Error('expiresAt 必须是有效时间');
|
||||||
|
}
|
||||||
|
expiresAt = new Date(ts).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxCost = normalizePositiveNumberOrNull(input.maxCost);
|
||||||
|
const maxRequests = normalizePositiveIntegerOrNull(input.maxRequests);
|
||||||
|
const supportedModels = normalizeSupportedModelsInput(input.supportedModels);
|
||||||
|
const allowedRouteIds = normalizeAllowedRouteIdsInput(input.allowedRouteIds);
|
||||||
|
const siteWeightMultipliers = normalizeSiteWeightMultipliersInput(input.siteWeightMultipliers);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
key,
|
||||||
|
description: description || null,
|
||||||
|
enabled,
|
||||||
|
expiresAt,
|
||||||
|
maxCost,
|
||||||
|
maxRequests,
|
||||||
|
supportedModels,
|
||||||
|
allowedRouteIds,
|
||||||
|
siteWeightMultipliers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toPersistenceJson(value: unknown): string | null {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
if (Array.isArray(value) && value.length === 0) return null;
|
||||||
|
if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record<string, unknown>).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export interface DownstreamRoutingPolicy {
|
||||||
|
supportedModels: string[];
|
||||||
|
allowedRouteIds: number[];
|
||||||
|
siteWeightMultipliers: Record<number, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_DOWNSTREAM_ROUTING_POLICY: DownstreamRoutingPolicy = {
|
||||||
|
supportedModels: [],
|
||||||
|
allowedRouteIds: [],
|
||||||
|
siteWeightMultipliers: {},
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { RequestInit as UndiciRequestInit } from 'undici';
|
import type { RequestInit as UndiciRequestInit } from 'undici';
|
||||||
|
import { withSiteProxyRequestInit } from './siteProxy.js';
|
||||||
|
|
||||||
const PRICE_CACHE_TTL_MS = 10 * 60 * 1000;
|
const PRICE_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||||
const PRICE_CACHE_FAILURE_TTL_MS = 60 * 1000;
|
const PRICE_CACHE_FAILURE_TTL_MS = 60 * 1000;
|
||||||
@@ -258,6 +259,7 @@ async function fetchJson(url: string, options?: UndiciRequestInit): Promise<unkn
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
|
...withSiteProxyRequestInit(url, {
|
||||||
...options,
|
...options,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
body: options?.body ?? undefined,
|
body: options?.body ?? undefined,
|
||||||
@@ -265,6 +267,7 @@ async function fetchJson(url: string, options?: UndiciRequestInit): Promise<unkn
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...options?.headers,
|
...options?.headers,
|
||||||
},
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ function normalizeModels(models: string[]): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isExactModelPattern(modelPattern: string): boolean {
|
function isExactModelPattern(modelPattern: string): boolean {
|
||||||
return !/[\*\?\[]/.test(modelPattern);
|
const normalized = modelPattern.trim();
|
||||||
|
if (!normalized) return false;
|
||||||
|
if (normalized.toLowerCase().startsWith('re:')) return false;
|
||||||
|
return !/[\*\?\[]/.test(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
|
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
|
||||||
|
|||||||
@@ -77,6 +77,34 @@ describe('notifyService', () => {
|
|||||||
).rejects.toThrow(/smtp auth failed|通知发送失败/);
|
).rejects.toThrow(/smtp auth failed|通知发送失败/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes failed channel details when all enabled channels fail', async () => {
|
||||||
|
const { config } = await import('../config.js');
|
||||||
|
config.webhookEnabled = true;
|
||||||
|
config.webhookUrl = 'https://webhook.example.com/notify';
|
||||||
|
config.barkEnabled = true;
|
||||||
|
config.barkUrl = 'https://api.day.app/mock-key';
|
||||||
|
config.smtpEnabled = false;
|
||||||
|
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 503,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { sendNotification } = await import('./notifyService.js');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
(sendNotification as any)('测试通知', 'message', 'info', {
|
||||||
|
bypassThrottle: true,
|
||||||
|
throwOnFailure: true,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/webhook|bark|Webhook 响应状态|Bark 响应状态/i);
|
||||||
|
});
|
||||||
|
|
||||||
it('includes local time and utc time labels in smtp payload', async () => {
|
it('includes local time and utc time labels in smtp payload', async () => {
|
||||||
sendMailMock.mockResolvedValue({ accepted: ['receiver@example.com'] });
|
sendMailMock.mockResolvedValue({ accepted: ['receiver@example.com'] });
|
||||||
const { sendNotification } = await import('./notifyService.js');
|
const { sendNotification } = await import('./notifyService.js');
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { RequestInit as UndiciRequestInit } from 'undici';
|
import type { RequestInit as UndiciRequestInit } from 'undici';
|
||||||
|
import { withSiteProxyRequestInit } from '../siteProxy.js';
|
||||||
|
|
||||||
export interface CheckinResult {
|
export interface CheckinResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -183,7 +184,8 @@ export abstract class BasePlatformAdapter implements PlatformAdapter {
|
|||||||
...options?.headers,
|
...options?.headers,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const res = await fetch(url, requestOptions);
|
const proxiedRequestOptions = withSiteProxyRequestInit(url, requestOptions);
|
||||||
|
const res = await fetch(url, proxiedRequestOptions);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
|
||||||
|
|
||||||
|
const DEFAULT_ANTHROPIC_VERSION = '2023-06-01';
|
||||||
|
|
||||||
|
function normalizeBaseUrl(baseUrl: string): string {
|
||||||
|
return (baseUrl || '').replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveClaudeModelsUrl(baseUrl: string): string {
|
||||||
|
const normalized = normalizeBaseUrl(baseUrl);
|
||||||
|
if (/\/v\d+(\.\d+)?$/i.test(normalized)) {
|
||||||
|
return `${normalized}/models`;
|
||||||
|
}
|
||||||
|
return `${normalized}/v1/models`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClaudeAdapter extends BasePlatformAdapter {
|
||||||
|
readonly platformName = 'claude';
|
||||||
|
|
||||||
|
async detect(url: string): Promise<boolean> {
|
||||||
|
const normalized = (url || '').toLowerCase();
|
||||||
|
return normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1');
|
||||||
|
}
|
||||||
|
|
||||||
|
override async login(_baseUrl: string, _username: string, _password: string) {
|
||||||
|
return { success: false, message: 'login endpoint not supported' };
|
||||||
|
}
|
||||||
|
|
||||||
|
override async getUserInfo(_baseUrl: string, _accessToken: string): Promise<UserInfo | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
|
||||||
|
return { success: false, message: 'checkin endpoint not supported' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
|
||||||
|
// Official Anthropic API keys generally do not expose account balance via API.
|
||||||
|
return { balance: 0, used: 0, quota: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const res = await this.fetchJson<any>(resolveClaudeModelsUrl(baseUrl), {
|
||||||
|
headers: {
|
||||||
|
'x-api-key': apiToken,
|
||||||
|
'anthropic-version': DEFAULT_ANTHROPIC_VERSION,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return (res?.data || []).map((m: any) => m?.id).filter(Boolean);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
|
||||||
|
|
||||||
|
function normalizeBaseUrl(baseUrl: string): string {
|
||||||
|
return (baseUrl || '').replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripModelPrefix(name: string): string {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
return trimmed.replace(/^models\//i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModelList(models: string[]): string[] {
|
||||||
|
const deduped = new Set<string>();
|
||||||
|
for (const model of models) {
|
||||||
|
const normalized = stripModelPrefix(model);
|
||||||
|
if (normalized) deduped.add(normalized);
|
||||||
|
}
|
||||||
|
return Array.from(deduped);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpenAiCompatGeminiBase(baseUrl: string): boolean {
|
||||||
|
return /\/openai(?:\/|$)/i.test(baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGeminiOpenAiModelsUrl(baseUrl: string): string {
|
||||||
|
const normalized = normalizeBaseUrl(baseUrl);
|
||||||
|
if (/\/models$/i.test(normalized)) return normalized;
|
||||||
|
return `${normalized}/models`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGeminiNativeModelsUrl(baseUrl: string, apiToken: string): string {
|
||||||
|
const normalized = normalizeBaseUrl(baseUrl);
|
||||||
|
const withVersion = /\/v\d+(?:beta)?(?:\/|$)/i.test(normalized)
|
||||||
|
? normalized
|
||||||
|
: `${normalized}/v1beta`;
|
||||||
|
const listBase = /\/models$/i.test(withVersion)
|
||||||
|
? withVersion
|
||||||
|
: `${withVersion}/models`;
|
||||||
|
const separator = listBase.includes('?') ? '&' : '?';
|
||||||
|
return `${listBase}${separator}key=${encodeURIComponent(apiToken)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GeminiAdapter extends BasePlatformAdapter {
|
||||||
|
readonly platformName = 'gemini';
|
||||||
|
|
||||||
|
async detect(url: string): Promise<boolean> {
|
||||||
|
const normalized = (url || '').toLowerCase();
|
||||||
|
return (
|
||||||
|
normalized.includes('generativelanguage.googleapis.com')
|
||||||
|
|| normalized.includes('googleapis.com/v1beta/openai')
|
||||||
|
|| normalized.includes('gemini.google.com')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
override async login(_baseUrl: string, _username: string, _password: string) {
|
||||||
|
return { success: false, message: 'login endpoint not supported' };
|
||||||
|
}
|
||||||
|
|
||||||
|
override async getUserInfo(_baseUrl: string, _accessToken: string): Promise<UserInfo | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
|
||||||
|
return { success: false, message: 'checkin endpoint not supported' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
|
||||||
|
// Gemini API keys generally do not expose account balance via API.
|
||||||
|
return { balance: 0, used: 0, quota: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
|
||||||
|
const normalizedBase = normalizeBaseUrl(baseUrl);
|
||||||
|
|
||||||
|
if (isOpenAiCompatGeminiBase(normalizedBase)) {
|
||||||
|
try {
|
||||||
|
const res = await this.fetchJson<any>(resolveGeminiOpenAiModelsUrl(normalizedBase), {
|
||||||
|
headers: { Authorization: `Bearer ${apiToken}` },
|
||||||
|
});
|
||||||
|
const openAiModels = (res?.data || []).map((m: any) => String(m?.id || '').trim()).filter(Boolean);
|
||||||
|
if (openAiModels.length > 0) return normalizeModelList(openAiModels);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await this.fetchJson<any>(resolveGeminiNativeModelsUrl(normalizedBase, apiToken));
|
||||||
|
const nativeModels = (res?.models || [])
|
||||||
|
.map((m: any) => String(m?.name || '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (nativeModels.length > 0) return normalizeModelList(nativeModels);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (!isOpenAiCompatGeminiBase(normalizedBase)) {
|
||||||
|
try {
|
||||||
|
const res = await this.fetchJson<any>(`${normalizedBase}/v1beta/openai/models`, {
|
||||||
|
headers: { Authorization: `Bearer ${apiToken}` },
|
||||||
|
});
|
||||||
|
const openAiModels = (res?.data || []).map((m: any) => String(m?.id || '').trim()).filter(Boolean);
|
||||||
|
if (openAiModels.length > 0) return normalizeModelList(openAiModels);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,12 @@ describe('getAdapter platform aliases', () => {
|
|||||||
expect(getAdapter('unknown-platform')).toBeUndefined();
|
expect(getAdapter('unknown-platform')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('supports canonical openai/claude/gemini adapters', () => {
|
||||||
|
expect(getAdapter('openai')?.platformName).toBe('openai');
|
||||||
|
expect(getAdapter('claude')?.platformName).toBe('claude');
|
||||||
|
expect(getAdapter('gemini')?.platformName).toBe('gemini');
|
||||||
|
});
|
||||||
|
|
||||||
it('detects anyrouter URL before generic new-api adapter', async () => {
|
it('detects anyrouter URL before generic new-api adapter', async () => {
|
||||||
const adapter = await detectPlatform('https://anyrouter.top');
|
const adapter = await detectPlatform('https://anyrouter.top');
|
||||||
expect(adapter?.platformName).toBe('anyrouter');
|
expect(adapter?.platformName).toBe('anyrouter');
|
||||||
@@ -25,4 +31,14 @@ describe('getAdapter platform aliases', () => {
|
|||||||
const adapter = await detectPlatform('https://demo.donehub.example');
|
const adapter = await detectPlatform('https://demo.donehub.example');
|
||||||
expect(adapter?.platformName).toBe('done-hub');
|
expect(adapter?.platformName).toBe('done-hub');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('detects official openai/claude/gemini upstream URLs', async () => {
|
||||||
|
const openai = await detectPlatform('https://api.openai.com');
|
||||||
|
const claude = await detectPlatform('https://api.anthropic.com');
|
||||||
|
const gemini = await detectPlatform('https://generativelanguage.googleapis.com');
|
||||||
|
|
||||||
|
expect(openai?.platformName).toBe('openai');
|
||||||
|
expect(claude?.platformName).toBe('claude');
|
||||||
|
expect(gemini?.platformName).toBe('gemini');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,9 +6,15 @@ import { VeloeraAdapter } from './veloera.js';
|
|||||||
import { OneHubAdapter } from './oneHub.js';
|
import { OneHubAdapter } from './oneHub.js';
|
||||||
import { DoneHubAdapter } from './doneHub.js';
|
import { DoneHubAdapter } from './doneHub.js';
|
||||||
import { Sub2ApiAdapter } from './sub2api.js';
|
import { Sub2ApiAdapter } from './sub2api.js';
|
||||||
|
import { OpenAiAdapter } from './openai.js';
|
||||||
|
import { ClaudeAdapter } from './claude.js';
|
||||||
|
import { GeminiAdapter } from './gemini.js';
|
||||||
|
|
||||||
const adapters: PlatformAdapter[] = [
|
const adapters: PlatformAdapter[] = [
|
||||||
// Specific forks before generic adapters for better auto-detection.
|
// Specific forks before generic adapters for better auto-detection.
|
||||||
|
new OpenAiAdapter(),
|
||||||
|
new ClaudeAdapter(),
|
||||||
|
new GeminiAdapter(),
|
||||||
new AnyRouterAdapter(),
|
new AnyRouterAdapter(),
|
||||||
new DoneHubAdapter(),
|
new DoneHubAdapter(),
|
||||||
new OneHubAdapter(),
|
new OneHubAdapter(),
|
||||||
@@ -38,6 +44,12 @@ const platformAliases: Record<string, string> = {
|
|||||||
'one-hub': 'one-hub',
|
'one-hub': 'one-hub',
|
||||||
'done-hub': 'done-hub',
|
'done-hub': 'done-hub',
|
||||||
sub2api: 'sub2api',
|
sub2api: 'sub2api',
|
||||||
|
// Official upstream APIs
|
||||||
|
openai: 'openai',
|
||||||
|
anthropic: 'claude',
|
||||||
|
claude: 'claude',
|
||||||
|
gemini: 'gemini',
|
||||||
|
google: 'gemini',
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizePlatform(platform: string): string {
|
function normalizePlatform(platform: string): string {
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||||
|
import { AddressInfo } from 'node:net';
|
||||||
|
import { OpenAiAdapter } from './openai.js';
|
||||||
|
import { ClaudeAdapter } from './claude.js';
|
||||||
|
import { GeminiAdapter } from './gemini.js';
|
||||||
|
|
||||||
|
interface RequestSnapshot {
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
headers: IncomingMessage['headers'];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('official llm upstream adapters', () => {
|
||||||
|
let server: ReturnType<typeof createServer>;
|
||||||
|
let baseUrl: string;
|
||||||
|
let requests: RequestSnapshot[] = [];
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
requests = [];
|
||||||
|
server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||||
|
requests.push({
|
||||||
|
method: req.method || 'GET',
|
||||||
|
url: req.url || '/',
|
||||||
|
headers: req.headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.url === '/openai/v1/models') {
|
||||||
|
if (req.headers.authorization !== 'Bearer sk-openai') {
|
||||||
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: { message: 'unauthorized' } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
object: 'list',
|
||||||
|
data: [{ id: 'gpt-4.1' }, { id: 'gpt-4o-mini' }],
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.url === '/claude/v1/models') {
|
||||||
|
if (req.headers['x-api-key'] !== 'sk-claude' || req.headers['anthropic-version'] !== '2023-06-01') {
|
||||||
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: { message: 'unauthorized' } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
data: [{ id: 'claude-sonnet-4-5-20250929' }, { id: 'claude-haiku-4-5-20251001' }],
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.url?.startsWith('/gemini/v1beta/models')) {
|
||||||
|
const url = new URL(req.url, 'http://127.0.0.1');
|
||||||
|
if (url.searchParams.get('key') !== 'gemini-key') {
|
||||||
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: { message: 'unauthorized' } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
models: [
|
||||||
|
{ name: 'models/gemini-2.5-flash' },
|
||||||
|
{ name: 'models/gemini-2.5-pro' },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.url === '/gemini-openai/v1beta/openai/models') {
|
||||||
|
if (req.headers.authorization !== 'Bearer gemini-key') {
|
||||||
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: { message: 'unauthorized' } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
object: 'list',
|
||||||
|
data: [{ id: 'gemini-2.5-flash' }],
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'not found' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server.listen(0, '127.0.0.1', () => resolve());
|
||||||
|
});
|
||||||
|
const addr = server.address() as AddressInfo;
|
||||||
|
baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.close((err?: Error) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches models from openai upstream', async () => {
|
||||||
|
const adapter = new OpenAiAdapter();
|
||||||
|
const models = await adapter.getModels(`${baseUrl}/openai`, 'sk-openai');
|
||||||
|
expect(models).toEqual(['gpt-4.1', 'gpt-4o-mini']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches models from claude upstream with anthropic headers', async () => {
|
||||||
|
const adapter = new ClaudeAdapter();
|
||||||
|
const models = await adapter.getModels(`${baseUrl}/claude`, 'sk-claude');
|
||||||
|
expect(models).toEqual(['claude-sonnet-4-5-20250929', 'claude-haiku-4-5-20251001']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches models from gemini native endpoint and normalizes model names', async () => {
|
||||||
|
const adapter = new GeminiAdapter();
|
||||||
|
const models = await adapter.getModels(`${baseUrl}/gemini/v1beta`, 'gemini-key');
|
||||||
|
expect(models).toEqual(['gemini-2.5-flash', 'gemini-2.5-pro']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches models from gemini openai-compatible endpoint when configured', async () => {
|
||||||
|
const adapter = new GeminiAdapter();
|
||||||
|
const models = await adapter.getModels(`${baseUrl}/gemini-openai/v1beta/openai`, 'gemini-key');
|
||||||
|
expect(models).toEqual(['gemini-2.5-flash']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -322,7 +322,7 @@ describe('NewApiAdapter', () => {
|
|||||||
expect(token).toBe('api-key-from-token-list');
|
expect(token).toBe('api-key-from-token-list');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detects cookie session values as session tokens for anyrouter-like deployments', async () => {
|
it('detects cookie session values as session cookies for anyrouter-like deployments', async () => {
|
||||||
const adapter = new NewApiAdapter();
|
const adapter = new NewApiAdapter();
|
||||||
const result = await adapter.verifyToken(baseUrl, COOKIE_SESSION_TOKEN);
|
const result = await adapter.verifyToken(baseUrl, COOKIE_SESSION_TOKEN);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ApiTokenInfo, BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo, TokenVerifyResult, CreateApiTokenOptions } from './base.js';
|
import { ApiTokenInfo, BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo, TokenVerifyResult, CreateApiTokenOptions } from './base.js';
|
||||||
import type { RequestInit as UndiciRequestInit } from 'undici';
|
import type { RequestInit as UndiciRequestInit } from 'undici';
|
||||||
import { createContext, runInContext } from 'node:vm';
|
import { createContext, runInContext } from 'node:vm';
|
||||||
|
import { withSiteProxyRequestInit } from '../siteProxy.js';
|
||||||
|
|
||||||
export class NewApiAdapter extends BasePlatformAdapter {
|
export class NewApiAdapter extends BasePlatformAdapter {
|
||||||
readonly platformName: string = 'new-api';
|
readonly platformName: string = 'new-api';
|
||||||
@@ -453,7 +454,8 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
|||||||
body: options?.body ?? undefined,
|
body: options?.body ?? undefined,
|
||||||
headers,
|
headers,
|
||||||
};
|
};
|
||||||
const res = await fetch(url, requestOptions);
|
const proxiedRequestOptions = withSiteProxyRequestInit(url, requestOptions);
|
||||||
|
const res = await fetch(url, proxiedRequestOptions);
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
const parsed = this.parseJsonSafe<T>(text);
|
const parsed = this.parseJsonSafe<T>(text);
|
||||||
if (parsed) return parsed;
|
if (parsed) return parsed;
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js';
|
||||||
|
|
||||||
|
function normalizeBaseUrl(baseUrl: string): string {
|
||||||
|
return (baseUrl || '').replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOpenAiModelsUrl(baseUrl: string): string {
|
||||||
|
const normalized = normalizeBaseUrl(baseUrl);
|
||||||
|
if (/\/v\d+(\.\d+)?$/i.test(normalized)) {
|
||||||
|
return `${normalized}/models`;
|
||||||
|
}
|
||||||
|
return `${normalized}/v1/models`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OpenAiAdapter extends BasePlatformAdapter {
|
||||||
|
readonly platformName = 'openai';
|
||||||
|
|
||||||
|
async detect(url: string): Promise<boolean> {
|
||||||
|
const normalized = (url || '').toLowerCase();
|
||||||
|
return normalized.includes('api.openai.com');
|
||||||
|
}
|
||||||
|
|
||||||
|
override async login(_baseUrl: string, _username: string, _password: string) {
|
||||||
|
return { success: false, message: 'login endpoint not supported' };
|
||||||
|
}
|
||||||
|
|
||||||
|
override async getUserInfo(_baseUrl: string, _accessToken: string): Promise<UserInfo | null> {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkin(_baseUrl: string, _accessToken: string): Promise<CheckinResult> {
|
||||||
|
return { success: false, message: 'checkin endpoint not supported' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBalance(_baseUrl: string, _accessToken: string): Promise<BalanceInfo> {
|
||||||
|
// Official OpenAI API keys generally do not expose account balance via API.
|
||||||
|
return { balance: 0, used: 0, quota: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const res = await this.fetchJson<any>(resolveOpenAiModelsUrl(baseUrl), {
|
||||||
|
headers: { Authorization: `Bearer ${apiToken}` },
|
||||||
|
});
|
||||||
|
return (res?.data || []).map((m: any) => m?.id).filter(Boolean);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { resolvePlatformUserId } from './accountExtraConfig.js';
|
import { resolvePlatformUserId } from './accountExtraConfig.js';
|
||||||
|
import { withExplicitProxyRequestInit } from './siteProxy.js';
|
||||||
|
|
||||||
const SELF_LOG_FETCH_TIMEOUT_MS = 8_000;
|
const SELF_LOG_FETCH_TIMEOUT_MS = 8_000;
|
||||||
const SELF_LOG_PAGE_SIZE = 20;
|
const SELF_LOG_PAGE_SIZE = 20;
|
||||||
@@ -23,6 +24,7 @@ interface ProxyUsageFallbackInput {
|
|||||||
url: string;
|
url: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
apiKey?: string | null;
|
apiKey?: string | null;
|
||||||
|
proxyUrl?: string | null;
|
||||||
};
|
};
|
||||||
account: {
|
account: {
|
||||||
accessToken?: string | null;
|
accessToken?: string | null;
|
||||||
@@ -233,11 +235,11 @@ async function fetchSelfLogPayload(baseUrl: string, token: string, input: ProxyU
|
|||||||
headers['New-Api-User'] = String(userId);
|
headers['New-Api-User'] = String(userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const response = await fetch(`${baseUrl}/api/log/self?${query}`, {
|
const response = await fetch(`${baseUrl}/api/log/self?${query}`, withExplicitProxyRequestInit(input.site.proxyUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers,
|
headers,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
}));
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP ${response.status}`);
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { mkdtempSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
type DbModule = typeof import('../db/index.js');
|
||||||
|
|
||||||
|
describe('siteProxy', () => {
|
||||||
|
let db: DbModule['db'];
|
||||||
|
let schema: DbModule['schema'];
|
||||||
|
let dataDir = '';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dataDir = mkdtempSync(join(tmpdir(), 'metapi-site-proxy-'));
|
||||||
|
process.env.DATA_DIR = dataDir;
|
||||||
|
await import('../db/migrate.js');
|
||||||
|
const dbModule = await import('../db/index.js');
|
||||||
|
db = dbModule.db;
|
||||||
|
schema = dbModule.schema;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const { invalidateSiteProxyCache } = await import('./siteProxy.js');
|
||||||
|
db.delete(schema.accounts).run();
|
||||||
|
db.delete(schema.sites).run();
|
||||||
|
invalidateSiteProxyCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete process.env.DATA_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves longest matched site proxy url', async () => {
|
||||||
|
db.insert(schema.sites).values([
|
||||||
|
{
|
||||||
|
name: 'base-site',
|
||||||
|
url: 'https://relay.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
proxyUrl: 'http://127.0.0.1:7891',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'openai-site',
|
||||||
|
url: 'https://relay.example.com/openai',
|
||||||
|
platform: 'new-api',
|
||||||
|
proxyUrl: 'http://127.0.0.1:7890',
|
||||||
|
},
|
||||||
|
]).run();
|
||||||
|
|
||||||
|
const { resolveSiteProxyUrlByRequestUrl } = await import('./siteProxy.js');
|
||||||
|
expect(resolveSiteProxyUrlByRequestUrl('https://relay.example.com/openai/v1/models'))
|
||||||
|
.toBe('http://127.0.0.1:7890');
|
||||||
|
expect(resolveSiteProxyUrlByRequestUrl('https://relay.example.com/v1/models'))
|
||||||
|
.toBe('http://127.0.0.1:7891');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('injects dispatcher when proxy exists', async () => {
|
||||||
|
db.insert(schema.sites).values({
|
||||||
|
name: 'proxy-site',
|
||||||
|
url: 'https://proxy-site.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
proxyUrl: 'http://127.0.0.1:7890',
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const { withSiteProxyRequestInit } = await import('./siteProxy.js');
|
||||||
|
const requestInit = withSiteProxyRequestInit('https://proxy-site.example.com/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect('dispatcher' in requestInit).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { db, schema } from '../db/index.js';
|
||||||
|
import type { Dispatcher, RequestInit as UndiciRequestInit } from 'undici';
|
||||||
|
import { ProxyAgent } from 'undici';
|
||||||
|
|
||||||
|
const SITE_PROXY_CACHE_TTL_MS = 3_000;
|
||||||
|
const SUPPORTED_PROXY_PROTOCOLS = new Set([
|
||||||
|
'http:',
|
||||||
|
'https:',
|
||||||
|
'socks:',
|
||||||
|
'socks4:',
|
||||||
|
'socks4a:',
|
||||||
|
'socks5:',
|
||||||
|
'socks5h:',
|
||||||
|
]);
|
||||||
|
|
||||||
|
type SiteProxyRow = {
|
||||||
|
siteUrl: string;
|
||||||
|
proxyUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ParsedSiteProxyInput = {
|
||||||
|
present: boolean;
|
||||||
|
valid: boolean;
|
||||||
|
proxyUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let siteProxyCache: {
|
||||||
|
loadedAt: number;
|
||||||
|
rows: SiteProxyRow[];
|
||||||
|
} = {
|
||||||
|
loadedAt: 0,
|
||||||
|
rows: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const dispatcherCache = new Map<string, Dispatcher>();
|
||||||
|
|
||||||
|
function normalizeSiteUrl(value: string): string {
|
||||||
|
const trimmed = (value || '').trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(trimmed);
|
||||||
|
const pathname = parsed.pathname.replace(/\/+$/, '');
|
||||||
|
const normalizedPath = pathname === '/' ? '' : pathname;
|
||||||
|
return `${parsed.origin}${normalizedPath}`;
|
||||||
|
} catch {
|
||||||
|
return trimmed.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCachedSiteProxyRows(nowMs = Date.now()): SiteProxyRow[] {
|
||||||
|
if ((nowMs - siteProxyCache.loadedAt) < SITE_PROXY_CACHE_TTL_MS) {
|
||||||
|
return siteProxyCache.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = db
|
||||||
|
.select({
|
||||||
|
siteUrl: schema.sites.url,
|
||||||
|
proxyUrl: schema.sites.proxyUrl,
|
||||||
|
})
|
||||||
|
.from(schema.sites)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
siteProxyCache = {
|
||||||
|
loadedAt: nowMs,
|
||||||
|
rows: rows.map((row) => ({
|
||||||
|
siteUrl: normalizeSiteUrl(row.siteUrl),
|
||||||
|
proxyUrl: normalizeSiteProxyUrl(row.proxyUrl),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
siteProxyCache = { loadedAt: nowMs, rows: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return siteProxyCache.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDispatcherByProxyUrl(proxyUrl: string): Dispatcher | undefined {
|
||||||
|
const normalized = normalizeSiteProxyUrl(proxyUrl);
|
||||||
|
if (!normalized) return undefined;
|
||||||
|
|
||||||
|
const cached = dispatcherCache.get(normalized);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dispatcher = new ProxyAgent(normalized);
|
||||||
|
dispatcherCache.set(normalized, dispatcher);
|
||||||
|
return dispatcher;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSiteProxyUrl(input: unknown): string | null {
|
||||||
|
if (typeof input !== 'string') return null;
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(trimmed);
|
||||||
|
if (!SUPPORTED_PROXY_PROTOCOLS.has(parsed.protocol.toLowerCase())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed.toString().replace(/\/+$/, '');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSiteProxyUrlInput(input: unknown): ParsedSiteProxyInput {
|
||||||
|
if (input === undefined) {
|
||||||
|
return { present: false, valid: true, proxyUrl: null };
|
||||||
|
}
|
||||||
|
if (input === null) {
|
||||||
|
return { present: true, valid: true, proxyUrl: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof input !== 'string') {
|
||||||
|
return { present: true, valid: false, proxyUrl: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { present: true, valid: true, proxyUrl: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeSiteProxyUrl(trimmed);
|
||||||
|
if (!normalized) {
|
||||||
|
return { present: true, valid: false, proxyUrl: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
present: true,
|
||||||
|
valid: true,
|
||||||
|
proxyUrl: normalized,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateSiteProxyCache(): void {
|
||||||
|
siteProxyCache = { loadedAt: 0, rows: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSiteProxyUrlByRequestUrl(requestUrl: string): string | null {
|
||||||
|
const normalizedRequestUrl = normalizeSiteUrl(requestUrl);
|
||||||
|
if (!normalizedRequestUrl) return null;
|
||||||
|
|
||||||
|
const rows = getCachedSiteProxyRows();
|
||||||
|
let bestMatch: string | null = null;
|
||||||
|
let bestMatchLength = -1;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.proxyUrl) continue;
|
||||||
|
if (!row.siteUrl) continue;
|
||||||
|
|
||||||
|
const isPrefixMatch = (
|
||||||
|
normalizedRequestUrl === row.siteUrl
|
||||||
|
|| normalizedRequestUrl.startsWith(`${row.siteUrl}/`)
|
||||||
|
|| normalizedRequestUrl.startsWith(`${row.siteUrl}?`)
|
||||||
|
);
|
||||||
|
if (!isPrefixMatch) continue;
|
||||||
|
|
||||||
|
if (row.siteUrl.length > bestMatchLength) {
|
||||||
|
bestMatch = row.proxyUrl;
|
||||||
|
bestMatchLength = row.siteUrl.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withSiteProxyRequestInit(
|
||||||
|
requestUrl: string,
|
||||||
|
options?: UndiciRequestInit,
|
||||||
|
): UndiciRequestInit {
|
||||||
|
const proxyUrl = resolveSiteProxyUrlByRequestUrl(requestUrl);
|
||||||
|
if (!proxyUrl) return options ?? {};
|
||||||
|
|
||||||
|
const dispatcher = getDispatcherByProxyUrl(proxyUrl);
|
||||||
|
if (!dispatcher) return options ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(options || {}),
|
||||||
|
dispatcher,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withExplicitProxyRequestInit(
|
||||||
|
proxyUrl: string | null | undefined,
|
||||||
|
options?: UndiciRequestInit,
|
||||||
|
): UndiciRequestInit {
|
||||||
|
const normalized = normalizeSiteProxyUrl(proxyUrl);
|
||||||
|
if (!normalized) return options ?? {};
|
||||||
|
|
||||||
|
const dispatcher = getDispatcherByProxyUrl(normalized);
|
||||||
|
if (!dispatcher) return options ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(options || {}),
|
||||||
|
dispatcher,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { mkdtempSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
type DbModule = typeof import('../db/index.js');
|
||||||
|
type TokenRouterModule = typeof import('./tokenRouter.js');
|
||||||
|
|
||||||
|
describe('TokenRouter downstream policy', () => {
|
||||||
|
let db: DbModule['db'];
|
||||||
|
let schema: DbModule['schema'];
|
||||||
|
let TokenRouter: TokenRouterModule['TokenRouter'];
|
||||||
|
let dataDir = '';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dataDir = mkdtempSync(join(tmpdir(), 'metapi-token-router-policy-'));
|
||||||
|
process.env.DATA_DIR = dataDir;
|
||||||
|
|
||||||
|
await import('../db/migrate.js');
|
||||||
|
const dbModule = await import('../db/index.js');
|
||||||
|
const tokenRouterModule = await import('./tokenRouter.js');
|
||||||
|
db = dbModule.db;
|
||||||
|
schema = dbModule.schema;
|
||||||
|
TokenRouter = tokenRouterModule.TokenRouter;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db.delete(schema.routeChannels).run();
|
||||||
|
db.delete(schema.tokenRoutes).run();
|
||||||
|
db.delete(schema.accountTokens).run();
|
||||||
|
db.delete(schema.accounts).run();
|
||||||
|
db.delete(schema.sites).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete process.env.DATA_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects allowedRouteIds when selecting channels', () => {
|
||||||
|
const site = db.insert(schema.sites).values({
|
||||||
|
name: 'site-a',
|
||||||
|
url: 'https://a.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'user-a',
|
||||||
|
accessToken: 'access-a',
|
||||||
|
apiToken: 'sk-a',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const routeAllowed = db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'claude-opus-4-6',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const routeBlocked = db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'gpt-4o-mini',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
db.insert(schema.routeChannels).values({
|
||||||
|
routeId: routeAllowed.id,
|
||||||
|
accountId: account.id,
|
||||||
|
tokenId: null,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
db.insert(schema.routeChannels).values({
|
||||||
|
routeId: routeBlocked.id,
|
||||||
|
accountId: account.id,
|
||||||
|
tokenId: null,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const router = new TokenRouter();
|
||||||
|
|
||||||
|
const allowedPick = router.selectChannel('claude-opus-4-6', {
|
||||||
|
allowedRouteIds: [routeAllowed.id],
|
||||||
|
supportedModels: [],
|
||||||
|
siteWeightMultipliers: {},
|
||||||
|
});
|
||||||
|
const blockedPick = router.selectChannel('gpt-4o-mini', {
|
||||||
|
allowedRouteIds: [routeAllowed.id],
|
||||||
|
supportedModels: [],
|
||||||
|
siteWeightMultipliers: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(allowedPick).toBeTruthy();
|
||||||
|
expect(allowedPick?.channel.routeId).toBe(routeAllowed.id);
|
||||||
|
expect(blockedPick).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies site weight multipliers to probability explanation', () => {
|
||||||
|
const siteHigh = db.insert(schema.sites).values({
|
||||||
|
name: 'high-site',
|
||||||
|
url: 'https://high.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const siteLow = db.insert(schema.sites).values({
|
||||||
|
name: 'low-site',
|
||||||
|
url: 'https://low.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const accountHigh = db.insert(schema.accounts).values({
|
||||||
|
siteId: siteHigh.id,
|
||||||
|
username: 'user-high',
|
||||||
|
accessToken: 'access-high',
|
||||||
|
apiToken: 'sk-high',
|
||||||
|
status: 'active',
|
||||||
|
unitCost: 1,
|
||||||
|
balance: 100,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const accountLow = db.insert(schema.accounts).values({
|
||||||
|
siteId: siteLow.id,
|
||||||
|
username: 'user-low',
|
||||||
|
accessToken: 'access-low',
|
||||||
|
apiToken: 'sk-low',
|
||||||
|
status: 'active',
|
||||||
|
unitCost: 1,
|
||||||
|
balance: 100,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const route = db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'claude-sonnet-4-6',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const channelHigh = db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: accountHigh.id,
|
||||||
|
tokenId: null,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const channelLow = db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: accountLow.id,
|
||||||
|
tokenId: null,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const router = new TokenRouter();
|
||||||
|
const decision = router.explainSelectionForRoute(
|
||||||
|
route.id,
|
||||||
|
'claude-sonnet-4-6',
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
allowedRouteIds: [route.id],
|
||||||
|
supportedModels: [],
|
||||||
|
siteWeightMultipliers: {
|
||||||
|
[siteHigh.id]: 4,
|
||||||
|
[siteLow.id]: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const highCandidate = decision.candidates.find((candidate) => candidate.channelId === channelHigh.id);
|
||||||
|
const lowCandidate = decision.candidates.find((candidate) => candidate.channelId === channelLow.id);
|
||||||
|
|
||||||
|
expect(highCandidate).toBeTruthy();
|
||||||
|
expect(lowCandidate).toBeTruthy();
|
||||||
|
expect((highCandidate?.probability || 0)).toBeGreaterThan(lowCandidate?.probability || 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports union semantics between supportedModels and allowedRouteIds', () => {
|
||||||
|
const site = db.insert(schema.sites).values({
|
||||||
|
name: 'site-union',
|
||||||
|
url: 'https://union.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'user-union',
|
||||||
|
accessToken: 'access-union',
|
||||||
|
apiToken: 'sk-union',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const claudeGroupRoute = db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 're:^claude-(opus|sonnet)-4-6$',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const gptExactRoute = db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'gpt-4o-mini',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
db.insert(schema.routeChannels).values({
|
||||||
|
routeId: claudeGroupRoute.id,
|
||||||
|
accountId: account.id,
|
||||||
|
tokenId: null,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
db.insert(schema.routeChannels).values({
|
||||||
|
routeId: gptExactRoute.id,
|
||||||
|
accountId: account.id,
|
||||||
|
tokenId: null,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const router = new TokenRouter();
|
||||||
|
const policy = {
|
||||||
|
allowedRouteIds: [claudeGroupRoute.id],
|
||||||
|
supportedModels: ['gpt-4o-mini'],
|
||||||
|
siteWeightMultipliers: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const claudePick = router.selectChannel('claude-opus-4-6', policy);
|
||||||
|
const gptPick = router.selectChannel('gpt-4o-mini', policy);
|
||||||
|
|
||||||
|
expect(claudePick?.channel.routeId).toBe(claudeGroupRoute.id);
|
||||||
|
expect(gptPick?.channel.routeId).toBe(gptExactRoute.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { mkdtempSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
type DbModule = typeof import('../db/index.js');
|
||||||
|
type TokenRouterModule = typeof import('./tokenRouter.js');
|
||||||
|
|
||||||
|
describe('TokenRouter patterns and model mapping', () => {
|
||||||
|
let db: DbModule['db'];
|
||||||
|
let schema: DbModule['schema'];
|
||||||
|
let TokenRouter: TokenRouterModule['TokenRouter'];
|
||||||
|
let dataDir = '';
|
||||||
|
let idSeed = 0;
|
||||||
|
|
||||||
|
const nextId = () => {
|
||||||
|
idSeed += 1;
|
||||||
|
return idSeed;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dataDir = mkdtempSync(join(tmpdir(), 'metapi-token-router-patterns-'));
|
||||||
|
process.env.DATA_DIR = dataDir;
|
||||||
|
|
||||||
|
await import('../db/migrate.js');
|
||||||
|
const dbModule = await import('../db/index.js');
|
||||||
|
const tokenRouterModule = await import('./tokenRouter.js');
|
||||||
|
db = dbModule.db;
|
||||||
|
schema = dbModule.schema;
|
||||||
|
TokenRouter = tokenRouterModule.TokenRouter;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
idSeed = 0;
|
||||||
|
db.delete(schema.routeChannels).run();
|
||||||
|
db.delete(schema.tokenRoutes).run();
|
||||||
|
db.delete(schema.accountTokens).run();
|
||||||
|
db.delete(schema.accounts).run();
|
||||||
|
db.delete(schema.sites).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete process.env.DATA_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
|
function createSite(namePrefix: string) {
|
||||||
|
const id = nextId();
|
||||||
|
return db.insert(schema.sites).values({
|
||||||
|
name: `${namePrefix}-${id}`,
|
||||||
|
url: `https://${namePrefix}-${id}.example.com`,
|
||||||
|
platform: 'new-api',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAccount(siteId: number, usernamePrefix: string) {
|
||||||
|
const id = nextId();
|
||||||
|
return db.insert(schema.accounts).values({
|
||||||
|
siteId,
|
||||||
|
username: `${usernamePrefix}-${id}`,
|
||||||
|
accessToken: `access-${id}`,
|
||||||
|
apiToken: `sk-${id}`,
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRouteWithSingleChannel(
|
||||||
|
modelPattern: string,
|
||||||
|
modelMapping?: string,
|
||||||
|
options?: { displayName?: string; sourceModel?: string | null },
|
||||||
|
) {
|
||||||
|
const site = createSite('pattern-site');
|
||||||
|
const account = createAccount(site.id, 'pattern-user');
|
||||||
|
const route = db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern,
|
||||||
|
displayName: options?.displayName,
|
||||||
|
modelMapping,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
const channel = db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: account.id,
|
||||||
|
tokenId: null,
|
||||||
|
sourceModel: options?.sourceModel ?? null,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
return { route, channel };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('matches routes with re: regex patterns', () => {
|
||||||
|
createRouteWithSingleChannel('re:^claude-(opus|sonnet)-4-6$');
|
||||||
|
const router = new TokenRouter();
|
||||||
|
|
||||||
|
const matched = router.selectChannel('claude-opus-4-6');
|
||||||
|
const unmatched = router.selectChannel('claude-haiku-4-6');
|
||||||
|
|
||||||
|
expect(matched).toBeTruthy();
|
||||||
|
expect(matched?.actualModel).toBe('claude-opus-4-6');
|
||||||
|
expect(unmatched).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores invalid re: patterns and falls back to next matched route', () => {
|
||||||
|
const invalid = createRouteWithSingleChannel('re:([a-z');
|
||||||
|
const glob = createRouteWithSingleChannel('claude-*');
|
||||||
|
const router = new TokenRouter();
|
||||||
|
|
||||||
|
const selected = router.selectChannel('claude-opus-4-6');
|
||||||
|
expect(selected).toBeTruthy();
|
||||||
|
expect(selected?.channel.id).toBe(glob.channel.id);
|
||||||
|
expect(selected?.channel.id).not.toBe(invalid.channel.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports exact, glob and re: keys in modelMapping with exact taking precedence', () => {
|
||||||
|
const mapping = JSON.stringify({
|
||||||
|
'claude-sonnet-4-6': 'target-exact',
|
||||||
|
'claude-sonnet-*': 'target-glob',
|
||||||
|
're:^gpt-4o-mini-\\d+$': 'target-regex',
|
||||||
|
});
|
||||||
|
createRouteWithSingleChannel('*', mapping);
|
||||||
|
const router = new TokenRouter();
|
||||||
|
|
||||||
|
const exact = router.selectChannel('claude-sonnet-4-6');
|
||||||
|
const glob = router.selectChannel('claude-sonnet-4-7');
|
||||||
|
const regex = router.selectChannel('gpt-4o-mini-20250101');
|
||||||
|
|
||||||
|
expect(exact?.actualModel).toBe('target-exact');
|
||||||
|
expect(glob?.actualModel).toBe('target-glob');
|
||||||
|
expect(regex?.actualModel).toBe('target-regex');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches a route by display name alias as an exposed model', () => {
|
||||||
|
createRouteWithSingleChannel(
|
||||||
|
're:^claude-(opus|sonnet)-4-5$',
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
displayName: 'claude-opus-4-6',
|
||||||
|
sourceModel: 'claude-opus-4-5',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const router = new TokenRouter();
|
||||||
|
|
||||||
|
const selected = router.selectChannel('claude-opus-4-6');
|
||||||
|
const decision = router.explainSelection('claude-opus-4-6');
|
||||||
|
const exposedModels = router.getAvailableModels();
|
||||||
|
|
||||||
|
expect(selected).toBeTruthy();
|
||||||
|
expect(selected?.actualModel).toBe('claude-opus-4-5');
|
||||||
|
expect(decision.actualModel).toBe('claude-opus-4-5');
|
||||||
|
expect(exposedModels).toContain('claude-opus-4-6');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { minimatch } from 'minimatch';
|
|||||||
import { db, schema } from '../db/index.js';
|
import { db, schema } from '../db/index.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { getCachedModelRoutingReferenceCost } from './modelPricingService.js';
|
import { getCachedModelRoutingReferenceCost } from './modelPricingService.js';
|
||||||
|
import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js';
|
||||||
|
|
||||||
interface RouteMatch {
|
interface RouteMatch {
|
||||||
route: typeof schema.tokenRoutes.$inferSelect;
|
route: typeof schema.tokenRoutes.$inferSelect;
|
||||||
@@ -94,11 +95,147 @@ export interface RouteDecisionExplanation {
|
|||||||
candidates: RouteDecisionCandidate[];
|
candidates: RouteDecisionCandidate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_DOWNSTREAM_POLICY: DownstreamRoutingPolicy = EMPTY_DOWNSTREAM_ROUTING_POLICY;
|
||||||
|
|
||||||
|
type ExplainSelectionOptions = {
|
||||||
|
excludeChannelIds?: number[];
|
||||||
|
bypassSourceModelCheck?: boolean;
|
||||||
|
useChannelSourceModelForCost?: boolean;
|
||||||
|
downstreamPolicy?: DownstreamRoutingPolicy;
|
||||||
|
};
|
||||||
|
|
||||||
type CostSignal = {
|
type CostSignal = {
|
||||||
unitCost: number;
|
unitCost: number;
|
||||||
source: 'observed' | 'configured' | 'catalog' | 'fallback';
|
source: 'observed' | 'configured' | 'catalog' | 'fallback';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function isRegexModelPattern(pattern: string): boolean {
|
||||||
|
return pattern.trim().toLowerCase().startsWith('re:');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRegexModelPattern(pattern: string): RegExp | null {
|
||||||
|
if (!isRegexModelPattern(pattern)) return null;
|
||||||
|
const body = pattern.trim().slice(3).trim();
|
||||||
|
if (!body) return null;
|
||||||
|
try {
|
||||||
|
return new RegExp(body);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesModelPattern(model: string, pattern: string): boolean {
|
||||||
|
const normalizedPattern = (pattern || '').trim();
|
||||||
|
if (!normalizedPattern) return false;
|
||||||
|
|
||||||
|
if (normalizedPattern === model) return true;
|
||||||
|
|
||||||
|
if (isRegexModelPattern(normalizedPattern)) {
|
||||||
|
const re = parseRegexModelPattern(normalizedPattern);
|
||||||
|
return !!re && re.test(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
return minimatch(model, normalizedPattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRouteDisplayName(displayName: string | null | undefined): string {
|
||||||
|
return (displayName || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRouteDisplayNameMatch(model: string, displayName: string | null | undefined): boolean {
|
||||||
|
const alias = normalizeRouteDisplayName(displayName);
|
||||||
|
return !!alias && alias === model;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesRouteRequestModel(model: string, route: typeof schema.tokenRoutes.$inferSelect): boolean {
|
||||||
|
return matchesModelPattern(model, route.modelPattern) || isRouteDisplayNameMatch(model, route.displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExposedModelNameForRoute(route: typeof schema.tokenRoutes.$inferSelect): string {
|
||||||
|
return normalizeRouteDisplayName(route.displayName) || route.modelPattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeModelAlias(modelName: string): string {
|
||||||
|
const normalized = (modelName || '').trim().toLowerCase();
|
||||||
|
if (!normalized) return '';
|
||||||
|
const slashIndex = normalized.lastIndexOf('/');
|
||||||
|
if (slashIndex >= 0 && slashIndex < normalized.length - 1) {
|
||||||
|
return normalized.slice(slashIndex + 1);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModelAliasEquivalent(left: string, right: string): boolean {
|
||||||
|
const a = normalizeModelAlias(left);
|
||||||
|
const b = normalizeModelAlias(right);
|
||||||
|
return !!a && !!b && a === b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelSupportsRequestedModel(channelSourceModel: string | null | undefined, requestedModel: string): boolean {
|
||||||
|
const source = (channelSourceModel || '').trim();
|
||||||
|
if (!source) return true;
|
||||||
|
if (source === requestedModel) return true;
|
||||||
|
if (isModelAliasEquivalent(source, requestedModel)) return true;
|
||||||
|
if (matchesModelPattern(requestedModel, source)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModelAllowedByDownstreamPolicy(requestedModel: string, policy: DownstreamRoutingPolicy): boolean {
|
||||||
|
const supportedPatterns = Array.isArray(policy.supportedModels)
|
||||||
|
? policy.supportedModels
|
||||||
|
: [];
|
||||||
|
const matchedSupportedPattern = supportedPatterns.some((pattern) => matchesModelPattern(requestedModel, pattern));
|
||||||
|
if (matchedSupportedPattern) return true;
|
||||||
|
if (policy.allowedRouteIds.length > 0) return true;
|
||||||
|
return supportedPatterns.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMappedModel(requestedModel: string, modelMapping?: string | null): string {
|
||||||
|
if (!modelMapping) return requestedModel;
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(modelMapping);
|
||||||
|
} catch {
|
||||||
|
return requestedModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
return requestedModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Object.entries(parsed as Record<string, unknown>)
|
||||||
|
.filter(([, value]) => typeof value === 'string' && value.trim().length > 0) as Array<[string, string]>;
|
||||||
|
|
||||||
|
const exact = entries.find(([pattern]) => pattern === requestedModel);
|
||||||
|
if (exact) return exact[1].trim();
|
||||||
|
|
||||||
|
for (const [pattern, target] of entries) {
|
||||||
|
if (matchesModelPattern(requestedModel, pattern)) {
|
||||||
|
return target.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestedModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeChannelSourceModel(channelSourceModel: string | null | undefined): string {
|
||||||
|
return (channelSourceModel || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveActualModelForSelectedChannel(
|
||||||
|
requestedModel: string,
|
||||||
|
route: typeof schema.tokenRoutes.$inferSelect,
|
||||||
|
mappedModel: string,
|
||||||
|
channelSourceModel: string | null | undefined,
|
||||||
|
): string {
|
||||||
|
const sourceModel = normalizeChannelSourceModel(channelSourceModel);
|
||||||
|
if (isRouteDisplayNameMatch(requestedModel, route.displayName) && sourceModel) {
|
||||||
|
return sourceModel;
|
||||||
|
}
|
||||||
|
return mappedModel;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveEffectiveUnitCost(candidate: RouteChannelCandidate, modelName: string): CostSignal {
|
function resolveEffectiveUnitCost(candidate: RouteChannelCandidate, modelName: string): CostSignal {
|
||||||
const successCount = Math.max(0, candidate.channel.successCount ?? 0);
|
const successCount = Math.max(0, candidate.channel.successCount ?? 0);
|
||||||
const totalCost = Math.max(0, candidate.channel.totalCost ?? 0);
|
const totalCost = Math.max(0, candidate.channel.totalCost ?? 0);
|
||||||
@@ -141,23 +278,21 @@ export class TokenRouter {
|
|||||||
* Find matching route and select a channel for the given model.
|
* Find matching route and select a channel for the given model.
|
||||||
* Returns null if no route/channel available.
|
* Returns null if no route/channel available.
|
||||||
*/
|
*/
|
||||||
selectChannel(requestedModel: string): SelectedChannel | null {
|
selectChannel(requestedModel: string, downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY): SelectedChannel | null {
|
||||||
const match = this.findRoute(requestedModel);
|
if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null;
|
||||||
|
|
||||||
|
const match = this.findRoute(requestedModel, downstreamPolicy);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
|
|
||||||
// Apply model mapping if configured
|
const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping);
|
||||||
let actualModel = requestedModel;
|
const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName);
|
||||||
if (match.route.modelMapping) {
|
const bypassSourceModelCheck = requestedByDisplayName;
|
||||||
try {
|
|
||||||
const mapping = JSON.parse(match.route.modelMapping);
|
|
||||||
if (mapping[requestedModel]) actualModel = mapping[requestedModel];
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter channels: enabled, not in cooldown, account/site active with token
|
// Filter channels: enabled, not in cooldown, account/site active with token
|
||||||
const nowIso = new Date().toISOString();
|
const nowIso = new Date().toISOString();
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
const available = match.channels.filter((c) =>
|
const available = match.channels.filter((c) =>
|
||||||
|
(bypassSourceModelCheck || channelSupportsRequestedModel(c.channel.sourceModel, requestedModel)) &&
|
||||||
c.channel.enabled &&
|
c.channel.enabled &&
|
||||||
c.account.status === 'active' &&
|
c.account.status === 'active' &&
|
||||||
!isSiteDisabled(c.site.status) &&
|
!isSiteDisabled(c.site.status) &&
|
||||||
@@ -185,10 +320,22 @@ export class TokenRouter {
|
|||||||
nowMs,
|
nowMs,
|
||||||
RECENT_FAILURE_AVOID_SEC,
|
RECENT_FAILURE_AVOID_SEC,
|
||||||
);
|
);
|
||||||
const selected = this.weightedRandomSelect(candidates, actualModel);
|
const selected = this.weightedRandomSelect(
|
||||||
|
candidates,
|
||||||
|
requestedByDisplayName
|
||||||
|
? (candidate) => normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel
|
||||||
|
: mappedModel,
|
||||||
|
downstreamPolicy,
|
||||||
|
);
|
||||||
if (selected) {
|
if (selected) {
|
||||||
const tokenValue = this.resolveChannelTokenValue(selected);
|
const tokenValue = this.resolveChannelTokenValue(selected);
|
||||||
if (!tokenValue) continue;
|
if (!tokenValue) continue;
|
||||||
|
const actualModel = resolveActualModelForSelectedChannel(
|
||||||
|
requestedModel,
|
||||||
|
match.route,
|
||||||
|
mappedModel,
|
||||||
|
selected.channel.sourceModel,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...selected,
|
...selected,
|
||||||
@@ -205,21 +352,24 @@ export class TokenRouter {
|
|||||||
/**
|
/**
|
||||||
* Select next channel for failover (exclude already-tried channels).
|
* Select next channel for failover (exclude already-tried channels).
|
||||||
*/
|
*/
|
||||||
selectNextChannel(requestedModel: string, excludeChannelIds: number[]): SelectedChannel | null {
|
selectNextChannel(
|
||||||
const match = this.findRoute(requestedModel);
|
requestedModel: string,
|
||||||
|
excludeChannelIds: number[],
|
||||||
|
downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY,
|
||||||
|
): SelectedChannel | null {
|
||||||
|
if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null;
|
||||||
|
|
||||||
|
const match = this.findRoute(requestedModel, downstreamPolicy);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
|
|
||||||
let actualModel = requestedModel;
|
const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping);
|
||||||
if (match.route.modelMapping) {
|
const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName);
|
||||||
try {
|
const bypassSourceModelCheck = requestedByDisplayName;
|
||||||
const mapping = JSON.parse(match.route.modelMapping);
|
|
||||||
if (mapping[requestedModel]) actualModel = mapping[requestedModel];
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nowIso = new Date().toISOString();
|
const nowIso = new Date().toISOString();
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
const available = match.channels.filter((c) =>
|
const available = match.channels.filter((c) =>
|
||||||
|
(bypassSourceModelCheck || channelSupportsRequestedModel(c.channel.sourceModel, requestedModel)) &&
|
||||||
c.channel.enabled &&
|
c.channel.enabled &&
|
||||||
c.account.status === 'active' &&
|
c.account.status === 'active' &&
|
||||||
!isSiteDisabled(c.site.status) &&
|
!isSiteDisabled(c.site.status) &&
|
||||||
@@ -244,11 +394,23 @@ export class TokenRouter {
|
|||||||
nowMs,
|
nowMs,
|
||||||
RECENT_FAILURE_AVOID_SEC,
|
RECENT_FAILURE_AVOID_SEC,
|
||||||
);
|
);
|
||||||
const selected = this.weightedRandomSelect(candidates, actualModel);
|
const selected = this.weightedRandomSelect(
|
||||||
|
candidates,
|
||||||
|
requestedByDisplayName
|
||||||
|
? (candidate) => normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel
|
||||||
|
: mappedModel,
|
||||||
|
downstreamPolicy,
|
||||||
|
);
|
||||||
if (!selected) continue;
|
if (!selected) continue;
|
||||||
|
|
||||||
const tokenValue = this.resolveChannelTokenValue(selected);
|
const tokenValue = this.resolveChannelTokenValue(selected);
|
||||||
if (!tokenValue) continue;
|
if (!tokenValue) continue;
|
||||||
|
const actualModel = resolveActualModelForSelectedChannel(
|
||||||
|
requestedModel,
|
||||||
|
match.route,
|
||||||
|
mappedModel,
|
||||||
|
selected.channel.sourceModel,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...selected,
|
...selected,
|
||||||
@@ -261,8 +423,43 @@ export class TokenRouter {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
explainSelection(requestedModel: string, excludeChannelIds: number[] = []): RouteDecisionExplanation {
|
explainSelection(
|
||||||
const match = this.findRoute(requestedModel);
|
requestedModel: string,
|
||||||
|
excludeChannelIds: number[] = [],
|
||||||
|
downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY,
|
||||||
|
): RouteDecisionExplanation {
|
||||||
|
const match = this.findRoute(requestedModel, downstreamPolicy);
|
||||||
|
return this.explainSelectionFromMatch(match, requestedModel, { excludeChannelIds, downstreamPolicy });
|
||||||
|
}
|
||||||
|
|
||||||
|
explainSelectionForRoute(
|
||||||
|
routeId: number,
|
||||||
|
requestedModel: string,
|
||||||
|
excludeChannelIds: number[] = [],
|
||||||
|
downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY,
|
||||||
|
): RouteDecisionExplanation {
|
||||||
|
const match = this.findRouteById(routeId, downstreamPolicy);
|
||||||
|
return this.explainSelectionFromMatch(match, requestedModel, { excludeChannelIds, downstreamPolicy });
|
||||||
|
}
|
||||||
|
|
||||||
|
explainSelectionRouteWide(routeId: number, downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY): RouteDecisionExplanation {
|
||||||
|
const match = this.findRouteById(routeId, downstreamPolicy);
|
||||||
|
const fallbackRequestedModel = match?.route.modelPattern || `route:${routeId}`;
|
||||||
|
return this.explainSelectionFromMatch(match, fallbackRequestedModel, {
|
||||||
|
bypassSourceModelCheck: true,
|
||||||
|
useChannelSourceModelForCost: true,
|
||||||
|
downstreamPolicy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private explainSelectionFromMatch(
|
||||||
|
match: RouteMatch | null,
|
||||||
|
requestedModel: string,
|
||||||
|
options: ExplainSelectionOptions = {},
|
||||||
|
): RouteDecisionExplanation {
|
||||||
|
const excludeChannelIds = options.excludeChannelIds ?? [];
|
||||||
|
const downstreamPolicy = options.downstreamPolicy ?? DEFAULT_DOWNSTREAM_POLICY;
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return {
|
return {
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -273,23 +470,27 @@ export class TokenRouter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let actualModel = requestedModel;
|
const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName);
|
||||||
if (match.route.modelMapping) {
|
const bypassSourceModelCheck = (options.bypassSourceModelCheck ?? false) || requestedByDisplayName;
|
||||||
try {
|
const useChannelSourceModelForCost = (options.useChannelSourceModelForCost ?? false) || requestedByDisplayName;
|
||||||
const mapping = JSON.parse(match.route.modelMapping) as Record<string, string>;
|
const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping);
|
||||||
if (mapping[requestedModel]) actualModel = mapping[requestedModel];
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nowIso = new Date().toISOString();
|
const nowIso = new Date().toISOString();
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
const summary: string[] = [`命中路由:${match.route.modelPattern}`];
|
const summary: string[] = [`命中路由:${match.route.modelPattern}`];
|
||||||
|
if (requestedByDisplayName) {
|
||||||
|
summary.push(`按显示名命中:${normalizeRouteDisplayName(match.route.displayName)}`);
|
||||||
|
summary.push('显示名仅用于聚合展示,实际转发模型按选中通道来源模型决定');
|
||||||
|
}
|
||||||
const availableByPriority = new Map<number, RouteChannelCandidate[]>();
|
const availableByPriority = new Map<number, RouteChannelCandidate[]>();
|
||||||
const candidates: RouteDecisionCandidate[] = [];
|
const candidates: RouteDecisionCandidate[] = [];
|
||||||
const candidateMap = new Map<number, RouteDecisionCandidate>();
|
const candidateMap = new Map<number, RouteDecisionCandidate>();
|
||||||
|
|
||||||
for (const row of match.channels) {
|
for (const row of match.channels) {
|
||||||
const reasonParts: string[] = [];
|
const reasonParts: string[] = [];
|
||||||
|
if (!bypassSourceModelCheck && !channelSupportsRequestedModel(row.channel.sourceModel, requestedModel)) {
|
||||||
|
reasonParts.push(`来源模型不匹配=${row.channel.sourceModel || ''}`);
|
||||||
|
}
|
||||||
if (!row.channel.enabled) reasonParts.push('通道禁用');
|
if (!row.channel.enabled) reasonParts.push('通道禁用');
|
||||||
if (row.account.status !== 'active') reasonParts.push(`账号状态=${row.account.status}`);
|
if (row.account.status !== 'active') reasonParts.push(`账号状态=${row.account.status}`);
|
||||||
if (isSiteDisabled(row.site.status)) reasonParts.push(`站点状态=${row.site.status || 'disabled'}`);
|
if (isSiteDisabled(row.site.status)) reasonParts.push(`站点状态=${row.site.status || 'disabled'}`);
|
||||||
@@ -328,7 +529,7 @@ export class TokenRouter {
|
|||||||
summary.push('没有可用通道(全部被禁用、站点不可用、冷却或令牌不可用)');
|
summary.push('没有可用通道(全部被禁用、站点不可用、冷却或令牌不可用)');
|
||||||
return {
|
return {
|
||||||
requestedModel,
|
requestedModel,
|
||||||
actualModel,
|
actualModel: mappedModel,
|
||||||
matched: true,
|
matched: true,
|
||||||
routeId: match.route.id,
|
routeId: match.route.id,
|
||||||
modelPattern: match.route.modelPattern,
|
modelPattern: match.route.modelPattern,
|
||||||
@@ -356,7 +557,13 @@ export class TokenRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const weighted = this.calculateWeightedSelection(filteredLayer, actualModel);
|
const weighted = this.calculateWeightedSelection(
|
||||||
|
filteredLayer,
|
||||||
|
useChannelSourceModelForCost
|
||||||
|
? (candidate) => (normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel)
|
||||||
|
: mappedModel,
|
||||||
|
downstreamPolicy,
|
||||||
|
);
|
||||||
for (const detail of weighted.details) {
|
for (const detail of weighted.details) {
|
||||||
const target = candidateMap.get(detail.candidate.channel.id);
|
const target = candidateMap.get(detail.candidate.channel.id);
|
||||||
if (!target) continue;
|
if (!target) continue;
|
||||||
@@ -381,7 +588,7 @@ export class TokenRouter {
|
|||||||
summary.push('本次未选出通道');
|
summary.push('本次未选出通道');
|
||||||
return {
|
return {
|
||||||
requestedModel,
|
requestedModel,
|
||||||
actualModel,
|
actualModel: mappedModel,
|
||||||
matched: true,
|
matched: true,
|
||||||
routeId: match.route.id,
|
routeId: match.route.id,
|
||||||
modelPattern: match.route.modelPattern,
|
modelPattern: match.route.modelPattern,
|
||||||
@@ -394,7 +601,16 @@ export class TokenRouter {
|
|||||||
const selectedLabel = selectedChannel
|
const selectedLabel = selectedChannel
|
||||||
? `${selectedChannel.username} @ ${selectedChannel.siteName} / ${selectedChannel.tokenName}`
|
? `${selectedChannel.username} @ ${selectedChannel.siteName} / ${selectedChannel.tokenName}`
|
||||||
: `channel-${selected.channel.id}`;
|
: `channel-${selected.channel.id}`;
|
||||||
|
const actualModel = resolveActualModelForSelectedChannel(
|
||||||
|
requestedModel,
|
||||||
|
match.route,
|
||||||
|
mappedModel,
|
||||||
|
selected.channel.sourceModel,
|
||||||
|
);
|
||||||
summary.push(`最终选择:${selectedLabel}(P${selectedPriority})`);
|
summary.push(`最终选择:${selectedLabel}(P${selectedPriority})`);
|
||||||
|
if (actualModel !== mappedModel) {
|
||||||
|
summary.push(`实际转发模型:${actualModel}`);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -449,23 +665,52 @@ export class TokenRouter {
|
|||||||
getAvailableModels(): string[] {
|
getAvailableModels(): string[] {
|
||||||
const routes = db.select().from(schema.tokenRoutes)
|
const routes = db.select().from(schema.tokenRoutes)
|
||||||
.where(eq(schema.tokenRoutes.enabled, true)).all();
|
.where(eq(schema.tokenRoutes.enabled, true)).all();
|
||||||
return routes.map((r) => r.modelPattern);
|
const exposed = routes
|
||||||
|
.map((route) => getExposedModelNameForRoute(route).trim())
|
||||||
|
.filter((name) => name.length > 0);
|
||||||
|
return Array.from(new Set(exposed));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Private methods ---
|
// --- Private methods ---
|
||||||
|
|
||||||
private findRoute(model: string): RouteMatch | null {
|
private findRoute(model: string, downstreamPolicy: DownstreamRoutingPolicy): RouteMatch | null {
|
||||||
const routes = db.select().from(schema.tokenRoutes)
|
let routes = db.select().from(schema.tokenRoutes)
|
||||||
.where(eq(schema.tokenRoutes.enabled, true)).all();
|
.where(eq(schema.tokenRoutes.enabled, true)).all();
|
||||||
|
|
||||||
// Find matching route by pattern
|
const supportedPatterns = Array.isArray(downstreamPolicy.supportedModels)
|
||||||
|
? downstreamPolicy.supportedModels
|
||||||
|
: [];
|
||||||
|
const matchedSupportedPattern = supportedPatterns.some((pattern) => matchesModelPattern(model, pattern));
|
||||||
|
|
||||||
|
if (downstreamPolicy.allowedRouteIds.length > 0 && !matchedSupportedPattern) {
|
||||||
|
const allowSet = new Set(downstreamPolicy.allowedRouteIds);
|
||||||
|
routes = routes.filter((route) => allowSet.has(route.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find matching route by model pattern or display alias.
|
||||||
const matchedRoute = routes.find((r) => {
|
const matchedRoute = routes.find((r) => {
|
||||||
if (r.modelPattern === model) return true; // exact match
|
return matchesRouteRequestModel(model, r);
|
||||||
return minimatch(model, r.modelPattern); // glob match
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!matchedRoute) return null;
|
if (!matchedRoute) return null;
|
||||||
|
|
||||||
|
return this.loadRouteMatch(matchedRoute);
|
||||||
|
}
|
||||||
|
|
||||||
|
private findRouteById(routeId: number, downstreamPolicy: DownstreamRoutingPolicy): RouteMatch | null {
|
||||||
|
if (downstreamPolicy.allowedRouteIds.length > 0 && !downstreamPolicy.allowedRouteIds.includes(routeId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = db.select().from(schema.tokenRoutes)
|
||||||
|
.where(eq(schema.tokenRoutes.id, routeId))
|
||||||
|
.get();
|
||||||
|
if (!route || !route.enabled) return null;
|
||||||
|
|
||||||
|
return this.loadRouteMatch(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadRouteMatch(route: typeof schema.tokenRoutes.$inferSelect): RouteMatch {
|
||||||
// Get channels with account, site and token info
|
// Get channels with account, site and token info
|
||||||
const channels = db
|
const channels = db
|
||||||
.select()
|
.select()
|
||||||
@@ -473,7 +718,7 @@ export class TokenRouter {
|
|||||||
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
|
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
|
||||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||||
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
|
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
|
||||||
.where(eq(schema.routeChannels.routeId, matchedRoute.id))
|
.where(eq(schema.routeChannels.routeId, route.id))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const mapped = channels.map((row) => ({
|
const mapped = channels.map((row) => ({
|
||||||
@@ -483,12 +728,13 @@ export class TokenRouter {
|
|||||||
token: row.account_tokens,
|
token: row.account_tokens,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { route: matchedRoute, channels: mapped };
|
return { route, channels: mapped };
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveChannelTokenValue(candidate: {
|
private resolveChannelTokenValue(candidate: {
|
||||||
channel: typeof schema.routeChannels.$inferSelect;
|
channel: typeof schema.routeChannels.$inferSelect;
|
||||||
account: typeof schema.accounts.$inferSelect;
|
account: typeof schema.accounts.$inferSelect;
|
||||||
|
site?: typeof schema.sites.$inferSelect | null;
|
||||||
token: typeof schema.accountTokens.$inferSelect | null;
|
token: typeof schema.accountTokens.$inferSelect | null;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
if (candidate.channel.tokenId) {
|
if (candidate.channel.tokenId) {
|
||||||
@@ -499,17 +745,25 @@ export class TokenRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fallback = candidate.account.apiToken?.trim();
|
const fallback = candidate.account.apiToken?.trim();
|
||||||
return fallback ? fallback : null;
|
if (fallback) return fallback;
|
||||||
|
|
||||||
|
const siteApiKey = candidate.site?.apiKey?.trim();
|
||||||
|
return siteApiKey ? siteApiKey : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private weightedRandomSelect(
|
private weightedRandomSelect(
|
||||||
candidates: RouteChannelCandidate[],
|
candidates: RouteChannelCandidate[],
|
||||||
modelName: string,
|
modelName: string | ((candidate: RouteChannelCandidate) => string),
|
||||||
|
downstreamPolicy: DownstreamRoutingPolicy,
|
||||||
) {
|
) {
|
||||||
return this.calculateWeightedSelection(candidates, modelName).selected;
|
return this.calculateWeightedSelection(candidates, modelName, downstreamPolicy).selected;
|
||||||
}
|
}
|
||||||
|
|
||||||
private calculateWeightedSelection(candidates: RouteChannelCandidate[], modelName: string) {
|
private calculateWeightedSelection(
|
||||||
|
candidates: RouteChannelCandidate[],
|
||||||
|
modelName: string | ((candidate: RouteChannelCandidate) => string),
|
||||||
|
downstreamPolicy: DownstreamRoutingPolicy,
|
||||||
|
) {
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
return {
|
return {
|
||||||
selected: null as RouteChannelCandidate | null,
|
selected: null as RouteChannelCandidate | null,
|
||||||
@@ -529,7 +783,10 @@ export class TokenRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { baseWeightFactor, valueScoreFactor, costWeight, balanceWeight, usageWeight } = config.routingWeights;
|
const { baseWeightFactor, valueScoreFactor, costWeight, balanceWeight, usageWeight } = config.routingWeights;
|
||||||
const effectiveCosts = candidates.map((candidate) => resolveEffectiveUnitCost(candidate, modelName));
|
const resolveModelName = typeof modelName === 'function'
|
||||||
|
? modelName
|
||||||
|
: (() => modelName);
|
||||||
|
const effectiveCosts = candidates.map((candidate) => resolveEffectiveUnitCost(candidate, resolveModelName(candidate)));
|
||||||
|
|
||||||
const valueScores = candidates.map((c, i) => {
|
const valueScores = candidates.map((c, i) => {
|
||||||
const unitCost = effectiveCosts[i]?.unitCost || 1;
|
const unitCost = effectiveCosts[i]?.unitCost || 1;
|
||||||
@@ -559,6 +816,10 @@ export class TokenRouter {
|
|||||||
const contributions = candidates.map((candidate, i) => {
|
const contributions = candidates.map((candidate, i) => {
|
||||||
const siteChannels = Math.max(1, siteChannelCounts.get(candidate.site.id) || 1);
|
const siteChannels = Math.max(1, siteChannelCounts.get(candidate.site.id) || 1);
|
||||||
let contribution = baseContributions[i] / siteChannels;
|
let contribution = baseContributions[i] / siteChannels;
|
||||||
|
const siteMultiplier = downstreamPolicy.siteWeightMultipliers[candidate.site.id] ?? 1;
|
||||||
|
if (siteMultiplier > 0 && Number.isFinite(siteMultiplier)) {
|
||||||
|
contribution *= siteMultiplier;
|
||||||
|
}
|
||||||
|
|
||||||
// If upstream price is unknown and we are using fallback unit cost,
|
// If upstream price is unknown and we are using fallback unit cost,
|
||||||
// apply an explicit penalty so raising fallback cost meaningfully lowers probability.
|
// apply an explicit penalty so raising fallback cost meaningfully lowers probability.
|
||||||
@@ -578,10 +839,11 @@ export class TokenRouter {
|
|||||||
? '实测'
|
? '实测'
|
||||||
: (cost?.source === 'configured' ? '配置' : (cost?.source === 'catalog' ? '目录' : '默认'));
|
: (cost?.source === 'configured' ? '配置' : (cost?.source === 'catalog' ? '目录' : '默认'));
|
||||||
const siteChannels = Math.max(1, siteChannelCounts.get(candidate.site.id) || 1);
|
const siteChannels = Math.max(1, siteChannelCounts.get(candidate.site.id) || 1);
|
||||||
|
const siteMultiplier = downstreamPolicy.siteWeightMultipliers[candidate.site.id] ?? 1;
|
||||||
return {
|
return {
|
||||||
candidate,
|
candidate,
|
||||||
probability,
|
probability,
|
||||||
reason: `按权重随机(W=${weight},成本=${costSourceText}:${(cost?.unitCost || 1).toFixed(6)},同站点通道=${siteChannels},概率≈${(probability * 100).toFixed(1)}%)`,
|
reason: `按权重随机(W=${weight},成本=${costSourceText}:${(cost?.unitCost || 1).toFixed(6)},站点倍率=${siteMultiplier.toFixed(2)},同站点通道=${siteChannels},概率≈${(probability * 100).toFixed(1)}%)`,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -550,8 +550,10 @@ function AppShell() {
|
|||||||
>
|
>
|
||||||
{language === 'zh' ? 'EN' : '中'}
|
{language === 'zh' ? 'EN' : '中'}
|
||||||
</button>
|
</button>
|
||||||
<button className="topbar-icon-btn" title={t('搜索 (Ctrl+K)')} onClick={() => setShowSearch(true)}>
|
<button className="topbar-search-trigger" title={t('搜索 (Ctrl+K)')} onClick={() => setShowSearch(true)}>
|
||||||
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
|
||||||
|
<span className="topbar-search-label">{t('搜索')}</span>
|
||||||
|
<kbd className="topbar-search-kbd">Ctrl K</kbd>
|
||||||
</button>
|
</button>
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<button ref={notifBtnRef} className="topbar-icon-btn" title={t('通知')} onClick={() => setShowNotifications(!showNotifications)}>
|
<button ref={notifBtnRef} className="topbar-icon-btn" title={t('通知')} onClick={() => setShowNotifications(!showNotifications)}>
|
||||||
@@ -723,4 +725,3 @@ export default function App() {
|
|||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ export const api = {
|
|||||||
addAccount: (data: any) => request('/api/accounts', { method: 'POST', body: JSON.stringify(data) }),
|
addAccount: (data: any) => request('/api/accounts', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
loginAccount: (data: { siteId: number; username: string; password: string }) => request('/api/accounts/login', { method: 'POST', body: JSON.stringify(data) }),
|
loginAccount: (data: { siteId: number; username: string; password: string }) => request('/api/accounts/login', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
verifyToken: (data: { siteId: number; accessToken: string; platformUserId?: number }) => request('/api/accounts/verify-token', { method: 'POST', body: JSON.stringify(data) }),
|
verifyToken: (data: { siteId: number; accessToken: string; platformUserId?: number }) => request('/api/accounts/verify-token', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
rebindAccountSession: (id: number, data: { accessToken: string; platformUserId?: number }) =>
|
||||||
|
request(`/api/accounts/${id}/rebind-session`, { method: 'POST', body: JSON.stringify(data) }),
|
||||||
updateAccount: (id: number, data: any) => request(`/api/accounts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
updateAccount: (id: number, data: any) => request(`/api/accounts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
deleteAccount: (id: number) => request(`/api/accounts/${id}`, { method: 'DELETE' }),
|
deleteAccount: (id: number) => request(`/api/accounts/${id}`, { method: 'DELETE' }),
|
||||||
refreshBalance: (id: number) => request(`/api/accounts/${id}/balance`, { method: 'POST' }),
|
refreshBalance: (id: number) => request(`/api/accounts/${id}/balance`, { method: 'POST' }),
|
||||||
@@ -160,6 +162,14 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ models }),
|
body: JSON.stringify({ models }),
|
||||||
}),
|
}),
|
||||||
|
getRouteDecisionsByRouteBatch: (items: Array<{ routeId: number; model: string }>) => request('/api/routes/decision/by-route/batch', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ items }),
|
||||||
|
}),
|
||||||
|
getRouteWideDecisionsBatch: (routeIds: number[]) => request('/api/routes/decision/route-wide/batch', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ routeIds }),
|
||||||
|
}),
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
getDashboard: () => request('/api/stats/dashboard'),
|
getDashboard: () => request('/api/stats/dashboard'),
|
||||||
@@ -192,6 +202,21 @@ export const api = {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
}),
|
}),
|
||||||
|
getDownstreamApiKeys: () => request('/api/downstream-keys'),
|
||||||
|
createDownstreamApiKey: (data: any) => request('/api/downstream-keys', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
updateDownstreamApiKey: (id: number, data: any) => request(`/api/downstream-keys/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
deleteDownstreamApiKey: (id: number) => request(`/api/downstream-keys/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
resetDownstreamApiKeyUsage: (id: number) => request(`/api/downstream-keys/${id}/reset-usage`, {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
exportBackup: (type: 'all' | 'accounts' | 'preferences' = 'all') =>
|
exportBackup: (type: 'all' | 'accounts' | 'preferences' = 'all') =>
|
||||||
request(`/api/settings/backup/export?type=${encodeURIComponent(type)}`),
|
request(`/api/settings/backup/export?type=${encodeURIComponent(type)}`),
|
||||||
importBackup: (data: any) =>
|
importBackup: (data: any) =>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { getBrand } from './BrandIcon.js';
|
||||||
|
|
||||||
|
describe('getBrand', () => {
|
||||||
|
it('detects simple prefixed model names', () => {
|
||||||
|
expect(getBrand('claude-opus-4-6')?.name).toBe('Anthropic');
|
||||||
|
expect(getBrand('gpt-4o-mini')?.name).toBe('OpenAI');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects brand for regex and wrapped model patterns', () => {
|
||||||
|
expect(getBrand('re:^claude-(opus|sonnet)-4-5$')?.name).toBe('Anthropic');
|
||||||
|
expect(getBrand('[Summer] gpt-5.2-codex')?.name).toBe('OpenAI');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects brand from namespaced model paths', () => {
|
||||||
|
expect(getBrand('openrouter/anthropic/claude-3-7-sonnet')?.name).toBe('Anthropic');
|
||||||
|
expect(getBrand('openrouter/google/gemini-2.5-pro')?.name).toBe('Google');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unknown model names', () => {
|
||||||
|
expect(getBrand('totally-unknown-model')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -102,13 +102,72 @@ const BRAND_MAP: Array<{ prefixes: string[]; brand: BrandInfo }> = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
const BRAND_RULES = BRAND_MAP.map((entry) => ({
|
||||||
|
brand: entry.brand,
|
||||||
|
prefixMatchers: entry.prefixes.map((prefix) => ({
|
||||||
|
prefix,
|
||||||
|
boundaryRegex: new RegExp(`(^|[^a-z0-9])${escapeRegExp(prefix)}(?=$|[^a-z0-9])`),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function collectBrandCandidates(modelName: string): string[] {
|
||||||
|
const queue: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const push = (value: string) => {
|
||||||
|
const normalized = (value || '').trim().toLowerCase();
|
||||||
|
if (!normalized || seen.has(normalized)) return;
|
||||||
|
seen.add(normalized);
|
||||||
|
queue.push(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
push(modelName);
|
||||||
|
for (let i = 0; i < queue.length; i += 1) {
|
||||||
|
const candidate = queue[i];
|
||||||
|
|
||||||
|
// Strip common route/model wrappers, e.g. "[Summer] gpt-5.2".
|
||||||
|
push(candidate.replace(/^(?:\[[^\]]+\]|【[^】]+】)\s*/g, ''));
|
||||||
|
|
||||||
|
if (candidate.startsWith('re:')) {
|
||||||
|
push(candidate.slice(3).trim());
|
||||||
|
}
|
||||||
|
push(candidate.replace(/^\^+/, '').replace(/\$+$/, ''));
|
||||||
|
|
||||||
|
if (candidate.includes('/')) {
|
||||||
|
for (const part of candidate.split('/')) push(part);
|
||||||
|
}
|
||||||
|
if (candidate.includes(':')) {
|
||||||
|
for (const part of candidate.split(':')) push(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return queue;
|
||||||
|
}
|
||||||
|
|
||||||
export function getBrand(modelName: string): BrandInfo | null {
|
export function getBrand(modelName: string): BrandInfo | null {
|
||||||
const lower = modelName.toLowerCase();
|
const candidates = collectBrandCandidates(modelName);
|
||||||
for (const entry of BRAND_MAP) {
|
|
||||||
for (const prefix of entry.prefixes) {
|
// Prefer strict prefix matching first for predictable behavior.
|
||||||
if (lower.startsWith(prefix)) return entry.brand;
|
for (const candidate of candidates) {
|
||||||
|
for (const rule of BRAND_RULES) {
|
||||||
|
for (const matcher of rule.prefixMatchers) {
|
||||||
|
if (candidate.startsWith(matcher.prefix)) return rule.brand;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for regex/model wrappers where brand token appears inside the pattern.
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
for (const rule of BRAND_RULES) {
|
||||||
|
for (const matcher of rule.prefixMatchers) {
|
||||||
|
if (matcher.boundaryRegex.test(candidate)) return rule.brand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ type ModernSelectOption = {
|
|||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
iconUrl?: string;
|
||||||
|
iconText?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ModernSelectProps = {
|
type ModernSelectProps = {
|
||||||
@@ -63,6 +65,16 @@ export default function ModernSelect({
|
|||||||
if (disabled) setOpen(false);
|
if (disabled) setOpen(false);
|
||||||
}, [disabled]);
|
}, [disabled]);
|
||||||
|
|
||||||
|
const renderOptionIcon = (item: ModernSelectOption) => {
|
||||||
|
if (item.iconUrl) {
|
||||||
|
return <img className="modern-select-option-icon" src={item.iconUrl} alt="" loading="lazy" />;
|
||||||
|
}
|
||||||
|
if (item.iconText) {
|
||||||
|
return <span className="modern-select-option-icon-text">{item.iconText}</span>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
@@ -78,7 +90,14 @@ export default function ModernSelect({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<span className={`modern-select-value ${selected ? '' : 'is-placeholder'}`.trim()}>
|
<span className={`modern-select-value ${selected ? '' : 'is-placeholder'}`.trim()}>
|
||||||
{selected?.label || placeholder}
|
{selected ? (
|
||||||
|
<span className="modern-select-value-content">
|
||||||
|
{renderOptionIcon(selected)}
|
||||||
|
<span>{selected.label}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
placeholder
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<svg
|
<svg
|
||||||
className="modern-select-chevron"
|
className="modern-select-chevron"
|
||||||
@@ -110,12 +129,15 @@ export default function ModernSelect({
|
|||||||
}}
|
}}
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
>
|
>
|
||||||
|
<div className="modern-select-option-main">
|
||||||
|
{renderOptionIcon(item)}
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div className="modern-select-option-label">{item.label}</div>
|
<div className="modern-select-option-label">{item.label}</div>
|
||||||
{item.description && (
|
{item.description && (
|
||||||
<div className="modern-select-option-desc">{item.description}</div>
|
<div className="modern-select-option-desc">{item.description}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{active && (
|
{active && (
|
||||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
|
import { formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
|
||||||
|
import { buildEventNavigationPath } from '../pages/helpers/navigationFocus.js';
|
||||||
import { useI18n } from '../i18n.js';
|
import { useI18n } from '../i18n.js';
|
||||||
|
|
||||||
const levelColors: Record<string, string> = {
|
const levelColors: Record<string, string> = {
|
||||||
@@ -33,6 +35,7 @@ export default function NotificationPanel({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [filter, setFilter] = useState<string>('');
|
const [filter, setFilter] = useState<string>('');
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -110,12 +113,34 @@ export default function NotificationPanel({
|
|||||||
{tr('暂无通知')}
|
{tr('暂无通知')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{events.map((ev: any) => (
|
{events.map((ev: any) => {
|
||||||
<div key={ev.id} style={{
|
const targetPath = buildEventNavigationPath(ev);
|
||||||
|
const openTarget = () => {
|
||||||
|
onClose();
|
||||||
|
navigate(targetPath);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ev.id}
|
||||||
|
className="notification-event-item"
|
||||||
|
style={{
|
||||||
padding: '10px 16px',
|
padding: '10px 16px',
|
||||||
borderBottom: '1px solid var(--color-border-light)',
|
borderBottom: '1px solid var(--color-border-light)',
|
||||||
display: 'flex', gap: 10, alignItems: 'flex-start',
|
display: 'flex',
|
||||||
}}>
|
gap: 10,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={openTarget}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
openTarget();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 8, height: 8, borderRadius: '50%', flexShrink: 0, marginTop: 5,
|
width: 8, height: 8, borderRadius: '50%', flexShrink: 0, marginTop: 5,
|
||||||
background: levelColors[ev.level] || 'var(--color-info)',
|
background: levelColors[ev.level] || 'var(--color-info)',
|
||||||
@@ -133,7 +158,8 @@ export default function NotificationPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { formatDateLocal, formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
|
import { formatDateLocal, formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
|
||||||
|
import { buildAccountFocusPath, buildSiteFocusPath } from '../pages/helpers/navigationFocus.js';
|
||||||
import { useI18n } from '../i18n.js';
|
import { useI18n } from '../i18n.js';
|
||||||
|
|
||||||
interface SiteResult {
|
interface SiteResult {
|
||||||
@@ -13,6 +14,7 @@ interface SiteResult {
|
|||||||
interface AccountResult {
|
interface AccountResult {
|
||||||
id: number;
|
id: number;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
|
status?: string | null;
|
||||||
balance?: number | null;
|
balance?: number | null;
|
||||||
site?: { name: string } | null;
|
site?: { name: string } | null;
|
||||||
}
|
}
|
||||||
@@ -165,7 +167,7 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
|||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('站点')}</div>
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('站点')}</div>
|
||||||
{results.sites.map((s) => (
|
{results.sites.map((s) => (
|
||||||
<button key={s.id} className="search-result-item" onClick={() => goTo('/sites')}>
|
<button key={s.id} className="search-result-item" onClick={() => goTo(buildSiteFocusPath(s.id))}>
|
||||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -182,7 +184,11 @@ export default function SearchModal({ open, onClose }: { open: boolean; onClose:
|
|||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('账号')}</div>
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('账号')}</div>
|
||||||
{results.accounts.map((a) => (
|
{results.accounts.map((a) => (
|
||||||
<button key={a.id} className="search-result-item" onClick={() => goTo('/accounts')}>
|
<button
|
||||||
|
key={a.id}
|
||||||
|
className="search-result-item"
|
||||||
|
onClick={() => goTo(buildAccountFocusPath(a.id, { openRebind: a.status === 'expired' }))}
|
||||||
|
>
|
||||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -326,8 +326,12 @@ export const zhToEnSupplemental: Record<string, string> = {
|
|||||||
'模型广场刷新完成:新增路由 ${rebuild.createdRoutes},新增通道 ${rebuild.createdChannels},移除通道 ${rebuild.removedChannels}': 'The model square refresh is completed: add route ${rebuild.createdRoutes}, add channel ${rebuild.createdChannels}, remove channel ${rebuild.removedChannels}',
|
'模型广场刷新完成:新增路由 ${rebuild.createdRoutes},新增通道 ${rebuild.createdChannels},移除通道 ${rebuild.removedChannels}': 'The model square refresh is completed: add route ${rebuild.createdRoutes}, add channel ${rebuild.createdChannels}, remove channel ${rebuild.removedChannels}',
|
||||||
'模型广场刷新已完成': 'Model square refresh has been completed',
|
'模型广场刷新已完成': 'Model square refresh has been completed',
|
||||||
'模型路由': 'model routing',
|
'模型路由': 'model routing',
|
||||||
|
'群组': 'groups',
|
||||||
|
'全部群组': 'all groups',
|
||||||
|
'群组路由': 'group routes',
|
||||||
|
'查看群组路由': 'viewing group routes',
|
||||||
'模型名称': 'Model name',
|
'模型名称': 'Model name',
|
||||||
'模型匹配(如 gpt-4o、claude-*)': 'Model matching (such as gpt-4o, claude-*)',
|
'模型匹配(如 gpt-4o、claude-*、re:^claude-.*$)': 'Model matching (such as gpt-4o, claude-*, re:^claude-.*$)',
|
||||||
'模型已更新': 'Model updated',
|
'模型已更新': 'Model updated',
|
||||||
'模型映射 JSON(可选)': 'Model mapping JSON (optional)',
|
'模型映射 JSON(可选)': 'Model mapping JSON (optional)',
|
||||||
'默认': 'default',
|
'默认': 'default',
|
||||||
@@ -487,6 +491,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
|||||||
'刷新状态中...': 'Refreshing status...',
|
'刷新状态中...': 'Refreshing status...',
|
||||||
'思考中...': 'Thinking...',
|
'思考中...': 'Thinking...',
|
||||||
'松开以导入文件': 'Release to import file',
|
'松开以导入文件': 'Release to import file',
|
||||||
|
'搜索': 'Search',
|
||||||
'搜索 (Ctrl+K)': 'Search (Ctrl+K)',
|
'搜索 (Ctrl+K)': 'Search (Ctrl+K)',
|
||||||
'搜索模型(支持名称片段)': 'Search model (supports name fragments)',
|
'搜索模型(支持名称片段)': 'Search model (supports name fragments)',
|
||||||
'搜索模型路由...': 'Search model routes...',
|
'搜索模型路由...': 'Search model routes...',
|
||||||
@@ -511,6 +516,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
|||||||
'通道已添加': 'Channel added',
|
'通道已添加': 'Channel added',
|
||||||
'通道已移除': 'Channel removed',
|
'通道已移除': 'Channel removed',
|
||||||
'通配符路由按请求实时决策;概率解释仅在精确模型路由中展示。': 'Wildcard routing makes real-time decisions on a per-request basis; probabilistic interpretation is only shown in exact model routing.',
|
'通配符路由按请求实时决策;概率解释仅在精确模型路由中展示。': 'Wildcard routing makes real-time decisions on a per-request basis; probabilistic interpretation is only shown in exact model routing.',
|
||||||
|
'通配符路由按请求实时决策;概率解释在当前路由内统一估算。': 'Wildcard routing makes real-time decisions on a per-request basis; probabilistic interpretation is estimated uniformly within the current route.',
|
||||||
'通义千问': 'Tongyi Qianwen',
|
'通义千问': 'Tongyi Qianwen',
|
||||||
'通知': 'notify',
|
'通知': 'notify',
|
||||||
'通知发送失败:${failedResults[0].error}': 'Notification sending failed: ${failedResults[0].error}',
|
'通知发送失败:${failedResults[0].error}': 'Notification sending failed: ${failedResults[0].error}',
|
||||||
@@ -525,7 +531,7 @@ export const zhToEnSupplemental: Record<string, string> = {
|
|||||||
'同步失败': 'Sync failed',
|
'同步失败': 'Sync failed',
|
||||||
'同步失败:${resolveSyncMessage(res, \'请检查账号令牌或站点状态\')}': 'Sync failed: ${resolveSyncMessage(res, \'请检查账号令牌或站点状态\')}',
|
'同步失败:${resolveSyncMessage(res, \'请检查账号令牌或站点状态\')}': 'Sync failed: ${resolveSyncMessage(res, \'请检查账号令牌或站点状态\')}',
|
||||||
'同步完成:新增 ${res.created || 0},更新 ${res.updated || 0}': 'Synchronization completed: new ${res.created || 0}, updated ${res.updated || 0}',
|
'同步完成:新增 ${res.created || 0},更新 ${res.updated || 0}': 'Synchronization completed: new ${res.created || 0}, updated ${res.updated || 0}',
|
||||||
'同步已跳过:${resolveSyncMessage(res, \'账号缺少可用会话令牌\')}': 'Sync skipped: ${resolveSyncMessage(res, \'账号缺少可用会话令牌\')}',
|
'同步已跳过:${resolveSyncMessage(res, \'账号缺少可用 Session Cookie\')}': 'Sync skipped: ${resolveSyncMessage(res, \'账号缺少可用 Session Cookie\')}',
|
||||||
'同步站点令牌': 'Sync site token',
|
'同步站点令牌': 'Sync site token',
|
||||||
'同步站点令牌失败': 'Failed to sync site token',
|
'同步站点令牌失败': 'Failed to sync site token',
|
||||||
'统一代理网关': 'Unified proxy gateway',
|
'统一代理网关': 'Unified proxy gateway',
|
||||||
@@ -655,7 +661,11 @@ export const zhToEnSupplemental: Record<string, string> = {
|
|||||||
'暂无通知': 'No notification yet',
|
'暂无通知': 'No notification yet',
|
||||||
'粘贴 JSON 数据或通过上面的拖拽区域导入...': 'Paste JSON data or import via the drag area above...',
|
'粘贴 JSON 数据或通过上面的拖拽区域导入...': 'Paste JSON data or import via the drag area above...',
|
||||||
'粘贴 ld_auth_session 或 ld_auth_session=xxx': 'Paste ld_auth_session or ld_auth_session=xxx',
|
'粘贴 ld_auth_session 或 ld_auth_session=xxx': 'Paste ld_auth_session or ld_auth_session=xxx',
|
||||||
'粘贴 Session Token 或 API Key (系统会自动识别 Token 类型)': 'Paste Session Token or API Key (The system will automatically identify the Token type)',
|
'粘贴 Session Cookie 或 API Key (系统会自动识别凭证类型)': 'Paste Session Cookie or API Key (The system will automatically identify credential type)',
|
||||||
|
'支持两种凭证类型,系统自动识别': 'Supports two credential types; auto-detected by the system',
|
||||||
|
'获取 Session Cookie:': 'Get Session Cookie:',
|
||||||
|
'Session Cookie 有效': 'Session Cookie is valid',
|
||||||
|
'账号缺少可用 Session Cookie': 'No available Session Cookie for this account',
|
||||||
'展开': 'Expand',
|
'展开': 'Expand',
|
||||||
'展开高级参数': 'Expand advanced parameters',
|
'展开高级参数': 'Expand advanced parameters',
|
||||||
'占用统计已清理': 'Occupancy statistics cleared',
|
'占用统计已清理': 'Occupancy statistics cleared',
|
||||||
|
|||||||
+23
-1
@@ -68,6 +68,7 @@ const zhToEn: Record<string, string> = {
|
|||||||
'任务状态已更新': 'Task status updated',
|
'任务状态已更新': 'Task status updated',
|
||||||
'会话已过期,请重新登录': 'Session expired, please sign in again',
|
'会话已过期,请重新登录': 'Session expired, please sign in again',
|
||||||
'个人信息已保存': 'Profile saved',
|
'个人信息已保存': 'Profile saved',
|
||||||
|
'搜索': 'Search',
|
||||||
'搜索 (Ctrl+K)': 'Search (Ctrl+K)',
|
'搜索 (Ctrl+K)': 'Search (Ctrl+K)',
|
||||||
'通知': 'Notifications',
|
'通知': 'Notifications',
|
||||||
'浅色': 'Light',
|
'浅色': 'Light',
|
||||||
@@ -140,6 +141,13 @@ const zhToEn: Record<string, string> = {
|
|||||||
'自动重建': 'Auto Rebuild',
|
'自动重建': 'Auto Rebuild',
|
||||||
'手动增改路由': 'Manual Route Edit',
|
'手动增改路由': 'Manual Route Edit',
|
||||||
'隐藏手动模式': 'Hide Manual Mode',
|
'隐藏手动模式': 'Hide Manual Mode',
|
||||||
|
'新建群组': 'Create Group',
|
||||||
|
'收起群组创建': 'Hide Group Creator',
|
||||||
|
'用于创建群组路由(聚合多个上游模型为一个下游模型名,即模型重定向);自动路由仍会保持开启。': 'Use this to create a group route (aggregate multiple upstream models as one downstream model name); auto-routing remains enabled.',
|
||||||
|
'群组显示名(可选,例如 claude-opus-4-6)': 'Group display name (optional, e.g. claude-opus-4-6)',
|
||||||
|
'创建群组': 'Create Group',
|
||||||
|
'群组已创建': 'Group created',
|
||||||
|
'创建群组失败': 'Failed to create group',
|
||||||
'搜索模型路由...': 'Search model routes...',
|
'搜索模型路由...': 'Search model routes...',
|
||||||
'通道数量': 'Channel Count',
|
'通道数量': 'Channel Count',
|
||||||
'排序字段': 'Sort Field',
|
'排序字段': 'Sort Field',
|
||||||
@@ -147,7 +155,16 @@ const zhToEn: Record<string, string> = {
|
|||||||
'升序 ↑': 'Ascending ↑',
|
'升序 ↑': 'Ascending ↑',
|
||||||
'降序 ↓': 'Descending ↓',
|
'降序 ↓': 'Descending ↓',
|
||||||
'手动模式适合高级场景;自动路由仍会保持开启。': 'Manual mode fits advanced scenarios; auto-routing stays enabled.',
|
'手动模式适合高级场景;自动路由仍会保持开启。': 'Manual mode fits advanced scenarios; auto-routing stays enabled.',
|
||||||
'模型匹配(如 gpt-4o、claude-*)': 'Model pattern (e.g. gpt-4o, claude-*)',
|
'路由名称(可选,例如 claude 系列)': 'Route name (optional, e.g. Claude Series)',
|
||||||
|
'图标(可选,支持 emoji)': 'Icon (optional, supports emoji)',
|
||||||
|
'模型匹配(如 gpt-4o、claude-*、re:^claude-.*$)': 'Model pattern (e.g. gpt-4o, claude-*, re:^claude-.*$)',
|
||||||
|
'正则请使用 re: 前缀;例如 re:^claude-(opus|sonnet)-4-6$': 'Use re: prefix for regex, e.g. re:^claude-(opus|sonnet)-4-6$',
|
||||||
|
'模型映射 key 支持精确匹配、通配符和 re: 正则;按顺序匹配,精确优先。': 'Model mapping keys support exact, glob and re: regex; evaluated in order with exact priority.',
|
||||||
|
'规则预览:命中样本': 'Rule preview: matched samples',
|
||||||
|
'当前暂无可预览模型,请先同步模型。': 'No preview models yet. Sync models first.',
|
||||||
|
'当前规则未命中任何样本模型。': 'Current rule does not match any sample models.',
|
||||||
|
'仅展示前 12 个命中样本。': 'Showing only the first 12 matched samples.',
|
||||||
|
'映射预览': 'Mapping preview',
|
||||||
'启用': 'Enabled',
|
'启用': 'Enabled',
|
||||||
'禁用': 'Disabled',
|
'禁用': 'Disabled',
|
||||||
'通道': 'channels',
|
'通道': 'channels',
|
||||||
@@ -157,10 +174,15 @@ const zhToEn: Record<string, string> = {
|
|||||||
'选择账号': 'Select Account',
|
'选择账号': 'Select Account',
|
||||||
'条路由': 'routes',
|
'条路由': 'routes',
|
||||||
'品牌路由': 'Brand Routes',
|
'品牌路由': 'Brand Routes',
|
||||||
|
'群组': 'Groups',
|
||||||
|
'全部群组': 'All Groups',
|
||||||
|
'群组路由': 'Group Routes',
|
||||||
|
'查看群组路由': 'Viewing group routes',
|
||||||
'查看未归类品牌路由': 'Viewing uncategorized brand routes',
|
'查看未归类品牌路由': 'Viewing uncategorized brand routes',
|
||||||
'当前精确路由': 'Current exact routes',
|
'当前精确路由': 'Current exact routes',
|
||||||
'条,为避免首屏卡顿,默认不自动计算概率,点击“加载选择解释”后按需获取。': 'routes. To avoid first-screen lag, probabilities are not auto-calculated by default. Click "Load Selection Explanation" to fetch when needed.',
|
'条,为避免首屏卡顿,默认不自动计算概率,点击“加载选择解释”后按需获取。': 'routes. To avoid first-screen lag, probabilities are not auto-calculated by default. Click "Load Selection Explanation" to fetch when needed.',
|
||||||
'通配符路由按请求实时决策;概率解释仅在精确模型路由中展示。': 'Wildcard routes are decided in real time; probability explanation is shown only for exact model routes.',
|
'通配符路由按请求实时决策;概率解释仅在精确模型路由中展示。': 'Wildcard routes are decided in real time; probability explanation is shown only for exact model routes.',
|
||||||
|
'通配符路由按请求实时决策;概率解释在当前路由内统一估算。': 'Wildcard routes are decided in real time; probability explanation is estimated uniformly within the current route.',
|
||||||
'系统会根据模型可用性自动生成路由。精确模型路由会自动过滤只支持该模型的账号和令牌。优先级 P0 最高,数字越大优先级越低。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。': 'Routes are auto-generated by model availability. Exact model routes auto-filter accounts and tokens that support that model. Priority P0 is highest, larger numbers are lower. Selection probability is the chance a channel is chosen. Cost priority: measured cost -> account configured cost -> catalog reference price -> default fallback unit price.',
|
'系统会根据模型可用性自动生成路由。精确模型路由会自动过滤只支持该模型的账号和令牌。优先级 P0 最高,数字越大优先级越低。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。': 'Routes are auto-generated by model availability. Exact model routes auto-filter accounts and tokens that support that model. Priority P0 is highest, larger numbers are lower. Selection probability is the chance a channel is chosen. Cost priority: measured cost -> account configured cost -> catalog reference price -> default fallback unit price.',
|
||||||
'代理端点': 'Proxy Endpoints',
|
'代理端点': 'Proxy Endpoints',
|
||||||
'路由行为': 'Routing Behavior',
|
'路由行为': 'Routing Behavior',
|
||||||
|
|||||||
@@ -502,6 +502,50 @@ body {
|
|||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topbar-search-trigger {
|
||||||
|
height: 36px;
|
||||||
|
min-width: 170px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 10px 0 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-search-trigger:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 30%, var(--color-border));
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 7%, var(--color-bg-card));
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-search-trigger:focus-visible {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--color-primary) 45%, transparent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-search-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-search-kbd {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar-badge {
|
.topbar-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
@@ -1091,6 +1135,38 @@ body {
|
|||||||
background: rgba(99, 102, 241, 0.06);
|
background: rgba(99, 102, 241, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes row-focus-glow {
|
||||||
|
0% {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||||
|
box-shadow: inset 3px 0 0 color-mix(in srgb, var(--color-primary) 70%, transparent);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 4%, transparent);
|
||||||
|
box-shadow: inset 0 0 0 transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr.row-focus-highlight {
|
||||||
|
animation: row-focus-glow 2.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rebind-panel-glow {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 42%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--color-primary) 42%, var(--color-border));
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-bg-card));
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 transparent;
|
||||||
|
border-color: var(--color-border-light);
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rebind-panel-highlight {
|
||||||
|
animation: rebind-panel-glow 2.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
.program-logs-table {
|
.program-logs-table {
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
min-width: 1120px;
|
min-width: 1120px;
|
||||||
@@ -1621,6 +1697,14 @@ textarea {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modern-select-value-content {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.modern-select-value.is-placeholder {
|
.modern-select-value.is-placeholder {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
@@ -1691,6 +1775,33 @@ textarea {
|
|||||||
transition: background 0.15s ease, color 0.15s ease, transform 0.12s ease;
|
transition: background 0.15s ease, color 0.15s ease, transform 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modern-select-option-main {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modern-select-option-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modern-select-option-icon-text {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.modern-select.is-sm .modern-select-option {
|
.modern-select.is-sm .modern-select-option {
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
}
|
}
|
||||||
@@ -1964,6 +2075,14 @@ textarea {
|
|||||||
background: var(--color-primary-light);
|
background: var(--color-primary-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notification-event-item {
|
||||||
|
transition: background-color 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-event-item:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card));
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Chart Panel Entrance Animation ===== */
|
/* ===== Chart Panel Entrance Animation ===== */
|
||||||
.chart-panel-enter {
|
.chart-panel-enter {
|
||||||
animation: chartSlideIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
animation: chartSlideIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
@@ -2731,6 +2850,10 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1280px) {
|
@media (max-width: 1280px) {
|
||||||
|
.topbar-search-trigger {
|
||||||
|
min-width: 154px;
|
||||||
|
}
|
||||||
|
|
||||||
.accounts-page-actions {
|
.accounts-page-actions {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
@@ -2774,6 +2897,21 @@ textarea {
|
|||||||
|
|
||||||
/* ===== Responsive Adjustments ===== */
|
/* ===== Responsive Adjustments ===== */
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
|
.topbar-search-trigger {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0 8px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-search-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-search-kbd {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.accounts-page-actions {
|
.accounts-page-actions {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: stretch;
|
justify-content: stretch;
|
||||||
@@ -2836,3 +2974,9 @@ textarea {
|
|||||||
min-height: 460px;
|
min-height: 460px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.topbar-search-kbd {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { tr } from '../i18n.js';
|
import { tr } from '../i18n.js';
|
||||||
|
|
||||||
const VERSION = '1.0.0';
|
const VERSION = '1.2.0';
|
||||||
|
|
||||||
const FEATURES = [
|
const FEATURES = [
|
||||||
{ icon: '🌐', title: '统一代理网关', desc: '一个 Key、一个入口,兼容 OpenAI / Claude 下游格式' },
|
{ icon: '🌐', title: '统一代理网关', desc: '一个 Key、一个入口,兼容 OpenAI / Claude 下游格式' },
|
||||||
|
|||||||
+277
-14
@@ -1,15 +1,21 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
import ModernSelect from '../components/ModernSelect.js';
|
import ModernSelect from '../components/ModernSelect.js';
|
||||||
import { getAccountsAddPanelStyle } from './helpers/accountsPanelStyle.js';
|
import { getAccountsAddPanelStyle } from './helpers/accountsPanelStyle.js';
|
||||||
|
import { clearFocusParams, readFocusAccountIntent } from './helpers/navigationFocus.js';
|
||||||
import { tr } from '../i18n.js';
|
import { tr } from '../i18n.js';
|
||||||
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
|
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
|
||||||
|
|
||||||
export default function Accounts() {
|
export default function Accounts() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [accounts, setAccounts] = useState<any[]>([]);
|
const [accounts, setAccounts] = useState<any[]>([]);
|
||||||
const [sites, setSites] = useState<any[]>([]);
|
const [sites, setSites] = useState<any[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
const [sortMode, setSortMode] = useState<SortMode>('custom');
|
const [sortMode, setSortMode] = useState<SortMode>('custom');
|
||||||
|
const [highlightAccountId, setHighlightAccountId] = useState<number | null>(null);
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
const [addMode, setAddMode] = useState<'token' | 'login'>('token');
|
const [addMode, setAddMode] = useState<'token' | 'login'>('token');
|
||||||
const [loginForm, setLoginForm] = useState({ siteId: 0, username: '', password: '' });
|
const [loginForm, setLoginForm] = useState({ siteId: 0, username: '', password: '' });
|
||||||
@@ -18,19 +24,52 @@ export default function Accounts() {
|
|||||||
const [verifying, setVerifying] = useState(false);
|
const [verifying, setVerifying] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
|
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
|
||||||
|
const [rebindTarget, setRebindTarget] = useState<any | null>(null);
|
||||||
|
const [rebindForm, setRebindForm] = useState({ accessToken: '', platformUserId: '' });
|
||||||
|
const [rebindVerifyResult, setRebindVerifyResult] = useState<any>(null);
|
||||||
|
const [rebindVerifying, setRebindVerifying] = useState(false);
|
||||||
|
const [rebindSaving, setRebindSaving] = useState(false);
|
||||||
|
const [highlightRebindPanel, setHighlightRebindPanel] = useState(false);
|
||||||
|
const [rebindFocusTrigger, setRebindFocusTrigger] = useState(0);
|
||||||
|
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
|
||||||
|
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const rebindPanelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const rebindPanelTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const load = () => {
|
const load = async () => {
|
||||||
api.getAccounts().then(setAccounts).catch(() => toast.error('加载账号列表失败'));
|
const [accountsResult, sitesResult] = await Promise.allSettled([
|
||||||
api.getSites().then(setSites);
|
api.getAccounts(),
|
||||||
|
api.getSites(),
|
||||||
|
]);
|
||||||
|
if (accountsResult.status === 'fulfilled') {
|
||||||
|
setAccounts(accountsResult.value || []);
|
||||||
|
} else {
|
||||||
|
toast.error('加载账号列表失败');
|
||||||
|
}
|
||||||
|
if (sitesResult.status === 'fulfilled') {
|
||||||
|
setSites(sitesResult.value || []);
|
||||||
|
}
|
||||||
|
setLoaded(true);
|
||||||
};
|
};
|
||||||
useEffect(() => { load(); }, []);
|
useEffect(() => { void load(); }, []);
|
||||||
|
|
||||||
const sortedAccounts = useMemo(
|
const sortedAccounts = useMemo(
|
||||||
() => sortItemsForDisplay(accounts, sortMode, (account) => account.balance || 0),
|
() => sortItemsForDisplay(accounts, sortMode, (account) => account.balance || 0),
|
||||||
[accounts, sortMode],
|
[accounts, sortMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (highlightTimerRef.current) {
|
||||||
|
clearTimeout(highlightTimerRef.current);
|
||||||
|
}
|
||||||
|
if (rebindPanelTimerRef.current) {
|
||||||
|
clearTimeout(rebindPanelTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleLoginAdd = async () => {
|
const handleLoginAdd = async () => {
|
||||||
if (!loginForm.siteId || !loginForm.username || !loginForm.password) return;
|
if (!loginForm.siteId || !loginForm.username || !loginForm.password) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -80,6 +119,10 @@ export default function Accounts() {
|
|||||||
|
|
||||||
const handleTokenAdd = async () => {
|
const handleTokenAdd = async () => {
|
||||||
if (!tokenForm.siteId || !tokenForm.accessToken) return;
|
if (!tokenForm.siteId || !tokenForm.accessToken) return;
|
||||||
|
if (!verifyResult?.success) {
|
||||||
|
toast.error('请先验证 Token 成功后再添加账号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.addAccount({
|
const result = await api.addAccount({
|
||||||
@@ -109,9 +152,12 @@ export default function Accounts() {
|
|||||||
|
|
||||||
const withLoading = async (key: string, fn: () => Promise<any>, successMsg?: string) => {
|
const withLoading = async (key: string, fn: () => Promise<any>, successMsg?: string) => {
|
||||||
setActionLoading(s => ({ ...s, [key]: true }));
|
setActionLoading(s => ({ ...s, [key]: true }));
|
||||||
try { await fn(); if (successMsg) toast.success(successMsg); load(); }
|
try { await fn(); if (successMsg) toast.success(successMsg); }
|
||||||
catch (e: any) { toast.error(e.message || '操作失败'); }
|
catch (e: any) { toast.error(e.message || '操作失败'); }
|
||||||
finally { setActionLoading(s => ({ ...s, [key]: false })); }
|
finally {
|
||||||
|
setActionLoading(s => ({ ...s, [key]: false }));
|
||||||
|
void load();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
@@ -209,6 +255,127 @@ export default function Accounts() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const extractPlatformUserId = (account: any): string => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(account?.extraConfig || '{}');
|
||||||
|
const raw = parsed?.platformUserId;
|
||||||
|
const value = Number.parseInt(String(raw ?? ''), 10);
|
||||||
|
if (Number.isFinite(value) && value > 0) return String(value);
|
||||||
|
} catch {}
|
||||||
|
const guessed = Number.parseInt(String(account?.username || '').match(/(\d{3,8})$/)?.[1] || '', 10);
|
||||||
|
return Number.isFinite(guessed) && guessed > 0 ? String(guessed) : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRebindPanel = (account: any) => {
|
||||||
|
setRebindTarget(account);
|
||||||
|
setRebindForm({
|
||||||
|
accessToken: '',
|
||||||
|
platformUserId: extractPlatformUserId(account),
|
||||||
|
});
|
||||||
|
setRebindVerifyResult(null);
|
||||||
|
setRebindFocusTrigger((value) => value + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeRebindPanel = () => {
|
||||||
|
setRebindTarget(null);
|
||||||
|
setRebindForm({ accessToken: '', platformUserId: '' });
|
||||||
|
setRebindVerifyResult(null);
|
||||||
|
setRebindVerifying(false);
|
||||||
|
setRebindSaving(false);
|
||||||
|
setHighlightRebindPanel(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rebindTarget || rebindFocusTrigger <= 0) return;
|
||||||
|
|
||||||
|
setHighlightRebindPanel(true);
|
||||||
|
rebindPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
|
||||||
|
if (rebindPanelTimerRef.current) {
|
||||||
|
clearTimeout(rebindPanelTimerRef.current);
|
||||||
|
}
|
||||||
|
rebindPanelTimerRef.current = setTimeout(() => {
|
||||||
|
setHighlightRebindPanel(false);
|
||||||
|
}, 2200);
|
||||||
|
}, [rebindFocusTrigger, rebindTarget]);
|
||||||
|
|
||||||
|
const handleVerifyRebindToken = async () => {
|
||||||
|
if (!rebindTarget || !rebindForm.accessToken.trim()) return;
|
||||||
|
setRebindVerifying(true);
|
||||||
|
setRebindVerifyResult(null);
|
||||||
|
try {
|
||||||
|
const result = await api.verifyToken({
|
||||||
|
siteId: rebindTarget.siteId,
|
||||||
|
accessToken: rebindForm.accessToken.trim(),
|
||||||
|
platformUserId: rebindForm.platformUserId ? Number.parseInt(rebindForm.platformUserId, 10) : undefined,
|
||||||
|
});
|
||||||
|
setRebindVerifyResult(result);
|
||||||
|
if (result.success && result.tokenType === 'session') {
|
||||||
|
toast.success('Session Token 验证成功,可以重新绑定');
|
||||||
|
} else if (result.success && result.tokenType !== 'session') {
|
||||||
|
toast.error('当前是 API Key,不是 Session Token');
|
||||||
|
} else {
|
||||||
|
toast.error(result.message || 'Token 无效');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message || '验证失败');
|
||||||
|
setRebindVerifyResult({ success: false, message: e.message });
|
||||||
|
} finally {
|
||||||
|
setRebindVerifying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitRebind = async () => {
|
||||||
|
if (!rebindTarget || !rebindForm.accessToken.trim()) return;
|
||||||
|
if (!(rebindVerifyResult?.success && rebindVerifyResult?.tokenType === 'session')) {
|
||||||
|
toast.error('请先验证新的 Session Token 成功');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRebindSaving(true);
|
||||||
|
try {
|
||||||
|
await api.rebindAccountSession(rebindTarget.id, {
|
||||||
|
accessToken: rebindForm.accessToken.trim(),
|
||||||
|
platformUserId: rebindForm.platformUserId ? Number.parseInt(rebindForm.platformUserId, 10) : undefined,
|
||||||
|
});
|
||||||
|
toast.success('账号重新绑定成功,状态已恢复');
|
||||||
|
closeRebindPanel();
|
||||||
|
load();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message || '重新绑定失败');
|
||||||
|
} finally {
|
||||||
|
setRebindSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { accountId, openRebind } = readFocusAccountIntent(location.search);
|
||||||
|
if (!accountId || !loaded) return;
|
||||||
|
|
||||||
|
const target = sortedAccounts.find((account) => account.id === accountId);
|
||||||
|
const row = rowRefs.current.get(accountId);
|
||||||
|
const cleanedSearch = clearFocusParams(location.search);
|
||||||
|
if (!target || !row) {
|
||||||
|
navigate({ pathname: location.pathname, search: cleanedSearch }, { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
setHighlightAccountId(accountId);
|
||||||
|
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
|
||||||
|
highlightTimerRef.current = setTimeout(() => {
|
||||||
|
setHighlightAccountId((current) => (current === accountId ? null : current));
|
||||||
|
}, 2200);
|
||||||
|
|
||||||
|
if (openRebind && target.status === 'expired') {
|
||||||
|
setShowAdd(false);
|
||||||
|
if (!rebindTarget || rebindTarget.id !== target.id) {
|
||||||
|
openRebindPanel(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate({ pathname: location.pathname, search: cleanedSearch }, { replace: true });
|
||||||
|
}, [loaded, location.pathname, location.search, navigate, openRebindPanel, rebindTarget, sortedAccounts]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
@@ -273,11 +440,11 @@ export default function Accounts() {
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
<div className="info-tip">
|
<div className="info-tip">
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>支持两种 Token 类型,系统自动识别</div>
|
<div style={{ fontWeight: 600, marginBottom: 4 }}>支持两种凭证类型,系统自动识别</div>
|
||||||
<div><strong>API Key</strong>(在站点「令牌」页面生成)→ 用于代理转发</div>
|
<div><strong>API Key</strong>(在站点「令牌」页面生成)→ 用于代理转发</div>
|
||||||
<div><strong>Session Token</strong>(从浏览器获取)→ 支持签到、余额查询等全部功能</div>
|
<div><strong>Session Cookie</strong>(从浏览器获取)→ 支持签到、余额查询等全部功能</div>
|
||||||
<div style={{ opacity: 0.7, borderTop: '1px solid rgba(0,0,0,0.1)', paddingTop: 6, marginTop: 6 }}>
|
<div style={{ opacity: 0.7, borderTop: '1px solid rgba(0,0,0,0.1)', paddingTop: 6, marginTop: 6 }}>
|
||||||
获取 Session Token: <kbd style={{ padding: '1px 5px', background: 'var(--color-bg-card)', border: '1px solid var(--color-border)', borderRadius: 3, fontSize: 11 }}>F12</kbd> → Application → Local Storage</div>
|
获取 Session Cookie: <kbd style={{ padding: '1px 5px', background: 'var(--color-bg-card)', border: '1px solid var(--color-border)', borderRadius: 3, fontSize: 11 }}>F12</kbd> → Application → Local Storage</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ModernSelect
|
<ModernSelect
|
||||||
@@ -296,7 +463,7 @@ export default function Accounts() {
|
|||||||
]}
|
]}
|
||||||
placeholder="选择站点"
|
placeholder="选择站点"
|
||||||
/>
|
/>
|
||||||
<textarea placeholder="粘贴 Session Token 或 API Key (系统会自动识别 Token 类型)"
|
<textarea placeholder="粘贴 Session Cookie 或 API Key (系统会自动识别凭证类型)"
|
||||||
value={tokenForm.accessToken}
|
value={tokenForm.accessToken}
|
||||||
onChange={e => { setTokenForm(f => ({ ...f, accessToken: e.target.value.trim() })); setVerifyResult(null); }}
|
onChange={e => { setTokenForm(f => ({ ...f, accessToken: e.target.value.trim() })); setVerifyResult(null); }}
|
||||||
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', height: 72, resize: 'none' as const }} />
|
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', height: 72, resize: 'none' as const }} />
|
||||||
@@ -306,7 +473,7 @@ export default function Accounts() {
|
|||||||
<div className="alert alert-success animate-scale-in">
|
<div className="alert alert-success animate-scale-in">
|
||||||
<div className="alert-title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div className="alert-title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||||
会话令牌有效
|
Session Cookie 有效
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, lineHeight: 1.8 }}>
|
<div style={{ fontSize: 12, lineHeight: 1.8 }}>
|
||||||
<div>用户名: <strong>{verifyResult.userInfo?.username || '未知'}</strong></div>
|
<div>用户名: <strong>{verifyResult.userInfo?.username || '未知'}</strong></div>
|
||||||
@@ -353,11 +520,16 @@ export default function Accounts() {
|
|||||||
className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}>
|
className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}>
|
||||||
{verifying ? <><span className="spinner spinner-sm" />验证中...</> : '验证 Token'}
|
{verifying ? <><span className="spinner spinner-sm" />验证中...</> : '验证 Token'}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleTokenAdd} disabled={saving || !tokenForm.siteId || !tokenForm.accessToken}
|
<button onClick={handleTokenAdd} disabled={saving || !tokenForm.siteId || !tokenForm.accessToken || !verifyResult?.success}
|
||||||
className="btn btn-success">
|
className="btn btn-success">
|
||||||
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />添加中...</> : '添加账号'}
|
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />添加中...</> : '添加账号'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{!verifyResult?.success && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||||
|
请先点击“验证 Token”,验证成功后才能添加账号
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
@@ -390,6 +562,82 @@ export default function Accounts() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{rebindTarget && (
|
||||||
|
<div
|
||||||
|
ref={rebindPanelRef}
|
||||||
|
className={`card animate-scale-in rebind-panel ${highlightRebindPanel ? 'rebind-panel-highlight' : ''}`}
|
||||||
|
style={{ marginBottom: 16, padding: 16 }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--color-text-primary)' }}>
|
||||||
|
重新绑定 Session Token
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-ghost" onClick={closeRebindPanel}>关闭</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||||
|
账号: {rebindTarget.username || '未命名'} @ {rebindTarget.site?.name || '-'}。请粘贴新的 Session Token,验证成功后再绑定。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 220px', gap: 10, marginBottom: 10 }}>
|
||||||
|
<textarea
|
||||||
|
placeholder="粘贴新的 Session Token"
|
||||||
|
value={rebindForm.accessToken}
|
||||||
|
onChange={(e) => {
|
||||||
|
setRebindForm((prev) => ({ ...prev, accessToken: e.target.value.trim() }));
|
||||||
|
setRebindVerifyResult(null);
|
||||||
|
}}
|
||||||
|
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', height: 74, resize: 'none' as const }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="用户 ID(可选)"
|
||||||
|
value={rebindForm.platformUserId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setRebindForm((prev) => ({ ...prev, platformUserId: e.target.value.replace(/\D/g, '') }));
|
||||||
|
setRebindVerifyResult(null);
|
||||||
|
}}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rebindVerifyResult && rebindVerifyResult.success && rebindVerifyResult.tokenType === 'session' && (
|
||||||
|
<div className="alert alert-success animate-scale-in" style={{ marginBottom: 10 }}>
|
||||||
|
<div className="alert-title">Session Token 有效</div>
|
||||||
|
<div style={{ fontSize: 12, marginTop: 4 }}>
|
||||||
|
用户: {rebindVerifyResult.userInfo?.username || '未知'}
|
||||||
|
{rebindVerifyResult.apiToken ? `,已识别 API Key (${String(rebindVerifyResult.apiToken).slice(0, 8)}...)` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rebindVerifyResult && (!rebindVerifyResult.success || rebindVerifyResult.tokenType !== 'session') && (
|
||||||
|
<div className="alert alert-error animate-scale-in" style={{ marginBottom: 10 }}>
|
||||||
|
<div className="alert-title">
|
||||||
|
{rebindVerifyResult.message || 'Token 无效或类型不正确'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
onClick={handleVerifyRebindToken}
|
||||||
|
disabled={rebindVerifying || !rebindForm.accessToken.trim()}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
|
>
|
||||||
|
{rebindVerifying ? <><span className="spinner spinner-sm" />验证中...</> : '验证 Token'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmitRebind}
|
||||||
|
disabled={rebindSaving || !(rebindVerifyResult?.success && rebindVerifyResult?.tokenType === 'session')}
|
||||||
|
className="btn btn-success"
|
||||||
|
>
|
||||||
|
{rebindSaving
|
||||||
|
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />绑定中...</>
|
||||||
|
: '确认重新绑定'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Accounts Table */}
|
{/* Accounts Table */}
|
||||||
<div className="card" style={{ overflowX: 'auto' }}>
|
<div className="card" style={{ overflowX: 'auto' }}>
|
||||||
{accounts.length > 0 ? (
|
{accounts.length > 0 ? (
|
||||||
@@ -407,7 +655,14 @@ export default function Accounts() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sortedAccounts.map((a: any, i: number) => (
|
{sortedAccounts.map((a: any, i: number) => (
|
||||||
<tr key={a.id} className={`animate-slide-up stagger-${Math.min(i + 1, 5)}`}>
|
<tr
|
||||||
|
key={a.id}
|
||||||
|
ref={(node) => {
|
||||||
|
if (node) rowRefs.current.set(a.id, node);
|
||||||
|
else rowRefs.current.delete(a.id);
|
||||||
|
}}
|
||||||
|
className={`animate-slide-up stagger-${Math.min(i + 1, 5)} ${highlightAccountId === a.id ? 'row-focus-highlight' : ''}`}
|
||||||
|
>
|
||||||
<td style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>{a.username || '未命名'}</td>
|
<td style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>{a.username || '未命名'}</td>
|
||||||
<td>
|
<td>
|
||||||
{a.site?.url ? (
|
{a.site?.url ? (
|
||||||
@@ -517,6 +772,14 @@ export default function Accounts() {
|
|||||||
className="btn btn-link btn-link-warning">
|
className="btn btn-link btn-link-warning">
|
||||||
{actionLoading[`checkin-${a.id}`] ? <span className="spinner spinner-sm" /> : '签到'}
|
{actionLoading[`checkin-${a.id}`] ? <span className="spinner spinner-sm" /> : '签到'}
|
||||||
</button>
|
</button>
|
||||||
|
{a.status === 'expired' && (
|
||||||
|
<button
|
||||||
|
onClick={() => openRebindPanel(a)}
|
||||||
|
className="btn btn-link btn-link-warning"
|
||||||
|
>
|
||||||
|
重新绑定
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button onClick={() => withLoading(`delete-${a.id}`, () => api.deleteAccount(a.id), '已删除')} disabled={actionLoading[`delete-${a.id}`]}
|
<button onClick={() => withLoading(`delete-${a.id}`, () => api.deleteAccount(a.id), '已删除')} disabled={actionLoading[`delete-${a.id}`]}
|
||||||
className="btn btn-link btn-link-danger">
|
className="btn btn-link btn-link-danger">
|
||||||
{actionLoading[`delete-${a.id}`] ? <span className="spinner spinner-sm" /> : '删除'}
|
{actionLoading[`delete-${a.id}`] ? <span className="spinner spinner-sm" /> : '删除'}
|
||||||
|
|||||||
@@ -197,12 +197,16 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
|
|||||||
const totalTokens = safeNumber(data?.proxy24h?.totalTokens);
|
const totalTokens = safeNumber(data?.proxy24h?.totalTokens);
|
||||||
|
|
||||||
const latencyDot = (ms: number) => {
|
const latencyDot = (ms: number) => {
|
||||||
const color = ms < 200
|
const color = ms <= 500
|
||||||
? 'var(--color-success)'
|
? 'var(--color-success)'
|
||||||
: ms < 500
|
: ms <= 1000
|
||||||
|
? 'color-mix(in srgb, var(--color-success) 60%, var(--color-warning))'
|
||||||
|
: ms <= 1500
|
||||||
? 'var(--color-warning)'
|
? 'var(--color-warning)'
|
||||||
: ms < 1000
|
: ms <= 2000
|
||||||
? 'color-mix(in srgb, var(--color-warning) 78%, var(--color-danger))'
|
? 'color-mix(in srgb, var(--color-warning) 60%, var(--color-danger))'
|
||||||
|
: ms < 3000
|
||||||
|
? 'color-mix(in srgb, var(--color-warning) 30%, var(--color-danger))'
|
||||||
: 'var(--color-danger)';
|
: 'var(--color-danger)';
|
||||||
return `<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:${color};box-shadow:0 0 4px ${color};animation:pulse 1.5s ease-in-out infinite;margin-right:3px;vertical-align:middle"></span><span style="color:${color};font-weight:600">${ms}ms</span>`;
|
return `<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:${color};box-shadow:0 0 4px ${color};animation:pulse 1.5s ease-in-out infinite;margin-right:3px;vertical-align:middle"></span><span style="color:${color};font-weight:600">${ms}ms</span>`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,14 +68,17 @@ interface ModelsMarketplaceResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
function getMetricColor(latency: number) {
|
function getMetricColor(latency: number) {
|
||||||
if (latency > 3000) return 'var(--color-danger)';
|
if (latency >= 3000) return 'var(--color-danger)';
|
||||||
if (latency > 1200) return 'var(--color-warning)';
|
if (latency >= 2000) return 'color-mix(in srgb, var(--color-warning) 30%, var(--color-danger))';
|
||||||
|
if (latency >= 1500) return 'color-mix(in srgb, var(--color-warning) 60%, var(--color-danger))';
|
||||||
|
if (latency >= 1000) return 'var(--color-warning)';
|
||||||
|
if (latency > 500) return 'color-mix(in srgb, var(--color-success) 60%, var(--color-warning))';
|
||||||
return 'var(--color-success)';
|
return 'var(--color-success)';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLatencyBadgeClass(latency: number) {
|
function getLatencyBadgeClass(latency: number) {
|
||||||
if (latency > 3000) return 'badge-error';
|
if (latency >= 3000) return 'badge-error';
|
||||||
if (latency > 1200) return 'badge-warning';
|
if (latency >= 1000) return 'badge-warning';
|
||||||
return 'badge-success';
|
return 'badge-success';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,14 +37,17 @@ function formatLatency(ms: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function latencyColor(ms: number) {
|
function latencyColor(ms: number) {
|
||||||
if (ms > 10000) return 'var(--color-danger)';
|
if (ms >= 3000) return 'var(--color-danger)';
|
||||||
if (ms > 3000) return 'var(--color-warning)';
|
if (ms >= 2000) return 'color-mix(in srgb, var(--color-warning) 30%, var(--color-danger))';
|
||||||
|
if (ms >= 1500) return 'color-mix(in srgb, var(--color-warning) 60%, var(--color-danger))';
|
||||||
|
if (ms >= 1000) return 'var(--color-warning)';
|
||||||
|
if (ms > 500) return 'color-mix(in srgb, var(--color-success) 60%, var(--color-warning))';
|
||||||
return 'var(--color-success)';
|
return 'var(--color-success)';
|
||||||
}
|
}
|
||||||
|
|
||||||
function latencyBgColor(ms: number) {
|
function latencyBgColor(ms: number) {
|
||||||
if (ms > 10000) return 'color-mix(in srgb, var(--color-danger) 12%, transparent)';
|
if (ms >= 3000) return 'color-mix(in srgb, var(--color-danger) 12%, transparent)';
|
||||||
if (ms > 3000) return 'color-mix(in srgb, var(--color-warning) 12%, transparent)';
|
if (ms >= 1000) return 'color-mix(in srgb, var(--color-warning) 12%, transparent)';
|
||||||
return 'color-mix(in srgb, var(--color-success) 12%, transparent)';
|
return 'color-mix(in srgb, var(--color-success) 12%, transparent)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+736
-1
@@ -1,16 +1,20 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
import ChangeKeyModal from '../components/ChangeKeyModal.js';
|
import ChangeKeyModal from '../components/ChangeKeyModal.js';
|
||||||
|
import { InlineBrandIcon, getBrand, useIconCdn } from '../components/BrandIcon.js';
|
||||||
import {
|
import {
|
||||||
applyRoutingProfilePreset,
|
applyRoutingProfilePreset,
|
||||||
resolveRoutingProfilePreset,
|
resolveRoutingProfilePreset,
|
||||||
type RoutingWeights,
|
type RoutingWeights,
|
||||||
} from './helpers/routingProfiles.js';
|
} from './helpers/routingProfiles.js';
|
||||||
|
import { fuzzyMatch } from './helpers/fuzzySearch.js';
|
||||||
import { clearAuthSession } from '../authSession.js';
|
import { clearAuthSession } from '../authSession.js';
|
||||||
import { tr } from '../i18n.js';
|
import { tr } from '../i18n.js';
|
||||||
|
|
||||||
const PROXY_TOKEN_PREFIX = 'sk-';
|
const PROXY_TOKEN_PREFIX = 'sk-';
|
||||||
|
const ROUTE_BRAND_ICON_PREFIX = 'brand:';
|
||||||
|
|
||||||
type RuntimeSettings = {
|
type RuntimeSettings = {
|
||||||
checkinCron: string;
|
checkinCron: string;
|
||||||
@@ -22,6 +26,42 @@ type RuntimeSettings = {
|
|||||||
currentAdminIp?: string;
|
currentAdminIp?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DownstreamApiKeyItem = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
keyMasked: string;
|
||||||
|
description: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
expiresAt: string | null;
|
||||||
|
maxCost: number | null;
|
||||||
|
usedCost: number;
|
||||||
|
maxRequests: number | null;
|
||||||
|
usedRequests: number;
|
||||||
|
supportedModels: string[];
|
||||||
|
allowedRouteIds: number[];
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DownstreamCreateForm = {
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
description: string;
|
||||||
|
maxCost: string;
|
||||||
|
maxRequests: string;
|
||||||
|
expiresAt: string;
|
||||||
|
selectedModels: string[];
|
||||||
|
selectedGroupRouteIds: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type RouteSelectorItem = {
|
||||||
|
id: number;
|
||||||
|
modelPattern: string;
|
||||||
|
displayName?: string | null;
|
||||||
|
displayIcon?: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const defaultWeights: RoutingWeights = {
|
const defaultWeights: RoutingWeights = {
|
||||||
baseWeightFactor: 0.5,
|
baseWeightFactor: 0.5,
|
||||||
valueScoreFactor: 0.5,
|
valueScoreFactor: 0.5,
|
||||||
@@ -30,7 +70,37 @@ const defaultWeights: RoutingWeights = {
|
|||||||
usageWeight: 0.3,
|
usageWeight: 0.3,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isRegexModelPattern(pattern: string): boolean {
|
||||||
|
return pattern.trim().toLowerCase().startsWith('re:');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExactModelPattern(modelPattern: string): boolean {
|
||||||
|
const normalized = modelPattern.trim();
|
||||||
|
if (!normalized) return false;
|
||||||
|
if (isRegexModelPattern(normalized)) return false;
|
||||||
|
return !/[\*\?\[]/.test(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeTitle(route: RouteSelectorItem): string {
|
||||||
|
const displayName = (route.displayName || '').trim();
|
||||||
|
return displayName || route.modelPattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBrandIconValue(raw: string | null | undefined): string | null {
|
||||||
|
const normalized = (raw || '').trim();
|
||||||
|
if (!normalized.startsWith(ROUTE_BRAND_ICON_PREFIX)) return null;
|
||||||
|
const icon = normalized.slice(ROUTE_BRAND_ICON_PREFIX.length).trim();
|
||||||
|
return icon || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRouteBrandSource(route: RouteSelectorItem): string {
|
||||||
|
const title = routeTitle(route);
|
||||||
|
if (getBrand(title)) return title;
|
||||||
|
return route.modelPattern;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
|
const iconCdn = useIconCdn();
|
||||||
const [runtime, setRuntime] = useState<RuntimeSettings>({
|
const [runtime, setRuntime] = useState<RuntimeSettings>({
|
||||||
checkinCron: '0 8 * * *',
|
checkinCron: '0 8 * * *',
|
||||||
balanceRefreshCron: '0 * * * *',
|
balanceRefreshCron: '0 * * * *',
|
||||||
@@ -49,6 +119,27 @@ export default function Settings() {
|
|||||||
const [clearingCache, setClearingCache] = useState(false);
|
const [clearingCache, setClearingCache] = useState(false);
|
||||||
const [clearingUsage, setClearingUsage] = useState(false);
|
const [clearingUsage, setClearingUsage] = useState(false);
|
||||||
const [showChangeKey, setShowChangeKey] = useState(false);
|
const [showChangeKey, setShowChangeKey] = useState(false);
|
||||||
|
const [downstreamKeys, setDownstreamKeys] = useState<DownstreamApiKeyItem[]>([]);
|
||||||
|
const [downstreamLoading, setDownstreamLoading] = useState(false);
|
||||||
|
const [downstreamSaving, setDownstreamSaving] = useState(false);
|
||||||
|
const [downstreamOps, setDownstreamOps] = useState<Record<number, boolean>>({});
|
||||||
|
const [editingDownstreamId, setEditingDownstreamId] = useState<number | null>(null);
|
||||||
|
const [downstreamModalOpen, setDownstreamModalOpen] = useState(false);
|
||||||
|
const [selectorOpen, setSelectorOpen] = useState(false);
|
||||||
|
const [selectorLoading, setSelectorLoading] = useState(false);
|
||||||
|
const [selectorRoutes, setSelectorRoutes] = useState<RouteSelectorItem[]>([]);
|
||||||
|
const [selectorModelSearch, setSelectorModelSearch] = useState('');
|
||||||
|
const [selectorGroupSearch, setSelectorGroupSearch] = useState('');
|
||||||
|
const [downstreamCreate, setDownstreamCreate] = useState<DownstreamCreateForm>({
|
||||||
|
name: '',
|
||||||
|
key: '',
|
||||||
|
description: '',
|
||||||
|
maxCost: '',
|
||||||
|
maxRequests: '',
|
||||||
|
expiresAt: '',
|
||||||
|
selectedModels: [],
|
||||||
|
selectedGroupRouteIds: [],
|
||||||
|
});
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const activeRoutingProfile = useMemo(
|
const activeRoutingProfile = useMemo(
|
||||||
@@ -56,6 +147,35 @@ export default function Settings() {
|
|||||||
[runtime.routingWeights],
|
[runtime.routingWeights],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const exactModelOptions = useMemo(() => (
|
||||||
|
selectorRoutes
|
||||||
|
.filter((route) => isExactModelPattern(route.modelPattern))
|
||||||
|
.map((route) => route.modelPattern.trim())
|
||||||
|
.filter((item, index, arr) => item.length > 0 && arr.indexOf(item) === index)
|
||||||
|
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
|
||||||
|
), [selectorRoutes]);
|
||||||
|
|
||||||
|
const groupRouteOptions = useMemo(() => (
|
||||||
|
selectorRoutes
|
||||||
|
.filter((route) => !isExactModelPattern(route.modelPattern))
|
||||||
|
.sort((a, b) => routeTitle(a).localeCompare(routeTitle(b), undefined, { sensitivity: 'base' }))
|
||||||
|
), [selectorRoutes]);
|
||||||
|
|
||||||
|
const filteredExactModelOptions = useMemo(() => {
|
||||||
|
const query = selectorModelSearch.trim();
|
||||||
|
if (!query) return exactModelOptions;
|
||||||
|
return exactModelOptions.filter((modelName) => fuzzyMatch(modelName, query));
|
||||||
|
}, [exactModelOptions, selectorModelSearch]);
|
||||||
|
|
||||||
|
const filteredGroupRouteOptions = useMemo(() => {
|
||||||
|
const query = selectorGroupSearch.trim();
|
||||||
|
if (!query) return groupRouteOptions;
|
||||||
|
return groupRouteOptions.filter((route) => {
|
||||||
|
const matchText = `${routeTitle(route)} ${route.modelPattern} ${route.displayName || ''}`;
|
||||||
|
return fuzzyMatch(matchText, query);
|
||||||
|
});
|
||||||
|
}, [groupRouteOptions, selectorGroupSearch]);
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: '10px 14px',
|
padding: '10px 14px',
|
||||||
@@ -67,12 +187,58 @@ export default function Settings() {
|
|||||||
color: 'var(--color-text-primary)',
|
color: 'var(--color-text-primary)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toDateTimeLocal = (isoString: string | null | undefined): string => {
|
||||||
|
if (!isoString) return '';
|
||||||
|
const ts = Date.parse(isoString);
|
||||||
|
if (!Number.isFinite(ts)) return '';
|
||||||
|
const date = new Date(ts);
|
||||||
|
const yyyy = date.getFullYear();
|
||||||
|
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hh = String(date.getHours()).padStart(2, '0');
|
||||||
|
const mi = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${yyyy}-${mm}-${dd}T${hh}:${mi}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDownstreamKeys = async () => {
|
||||||
|
setDownstreamLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.getDownstreamApiKeys();
|
||||||
|
const items = Array.isArray(res?.items) ? res.items : [];
|
||||||
|
setDownstreamKeys(items);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.message || '加载下游 API Key 失败');
|
||||||
|
} finally {
|
||||||
|
setDownstreamLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRouteSelectorRoutes = async () => {
|
||||||
|
setSelectorLoading(true);
|
||||||
|
try {
|
||||||
|
const rows = await api.getRoutes();
|
||||||
|
setSelectorRoutes((Array.isArray(rows) ? rows : []).map((row: any) => ({
|
||||||
|
id: row.id,
|
||||||
|
modelPattern: row.modelPattern,
|
||||||
|
displayName: row.displayName,
|
||||||
|
displayIcon: row.displayIcon,
|
||||||
|
enabled: !!row.enabled,
|
||||||
|
})));
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.message || '加载路由列表失败');
|
||||||
|
} finally {
|
||||||
|
setSelectorLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [authInfo, runtimeInfo] = await Promise.all([
|
const [authInfo, runtimeInfo, downstreamInfo, routeRows] = await Promise.all([
|
||||||
api.getAuthInfo(),
|
api.getAuthInfo(),
|
||||||
api.getRuntimeSettings(),
|
api.getRuntimeSettings(),
|
||||||
|
api.getDownstreamApiKeys(),
|
||||||
|
api.getRoutes(),
|
||||||
]);
|
]);
|
||||||
setMaskedToken(authInfo.masked || '****');
|
setMaskedToken(authInfo.masked || '****');
|
||||||
setRuntime({
|
setRuntime({
|
||||||
@@ -96,6 +262,14 @@ export default function Settings() {
|
|||||||
? runtimeInfo.adminIpAllowlist.join('\n')
|
? runtimeInfo.adminIpAllowlist.join('\n')
|
||||||
: '',
|
: '',
|
||||||
);
|
);
|
||||||
|
setDownstreamKeys(Array.isArray(downstreamInfo?.items) ? downstreamInfo.items : []);
|
||||||
|
setSelectorRoutes((Array.isArray(routeRows) ? routeRows : []).map((row: any) => ({
|
||||||
|
id: row.id,
|
||||||
|
modelPattern: row.modelPattern,
|
||||||
|
displayName: row.displayName,
|
||||||
|
displayIcon: row.displayIcon,
|
||||||
|
enabled: !!row.enabled,
|
||||||
|
})));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error(err?.message || '加载设置失败');
|
toast.error(err?.message || '加载设置失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -149,6 +323,163 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetDownstreamForm = () => {
|
||||||
|
setEditingDownstreamId(null);
|
||||||
|
setDownstreamCreate({
|
||||||
|
name: '',
|
||||||
|
key: '',
|
||||||
|
description: '',
|
||||||
|
maxCost: '',
|
||||||
|
maxRequests: '',
|
||||||
|
expiresAt: '',
|
||||||
|
selectedModels: [],
|
||||||
|
selectedGroupRouteIds: [],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateDownstreamModal = () => {
|
||||||
|
resetDownstreamForm();
|
||||||
|
setDownstreamModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDownstreamModal = () => {
|
||||||
|
setDownstreamModalOpen(false);
|
||||||
|
resetDownstreamForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeSelectorModal = () => {
|
||||||
|
setSelectorOpen(false);
|
||||||
|
setSelectorModelSearch('');
|
||||||
|
setSelectorGroupSearch('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const beginEditDownstream = (item: DownstreamApiKeyItem) => {
|
||||||
|
setEditingDownstreamId(item.id);
|
||||||
|
setDownstreamCreate({
|
||||||
|
name: item.name || '',
|
||||||
|
key: item.key || '',
|
||||||
|
description: item.description || '',
|
||||||
|
maxCost: item.maxCost === null || item.maxCost === undefined ? '' : String(item.maxCost),
|
||||||
|
maxRequests: item.maxRequests === null || item.maxRequests === undefined ? '' : String(item.maxRequests),
|
||||||
|
expiresAt: toDateTimeLocal(item.expiresAt),
|
||||||
|
selectedModels: Array.isArray(item.supportedModels)
|
||||||
|
? [...new Set(item.supportedModels.map((model) => String(model).trim()).filter((model) => model.length > 0))]
|
||||||
|
: [],
|
||||||
|
selectedGroupRouteIds: Array.isArray(item.allowedRouteIds)
|
||||||
|
? [...new Set(item.allowedRouteIds.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0).map((id) => Math.trunc(id)))]
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
setDownstreamModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveDownstreamKey = async () => {
|
||||||
|
const name = downstreamCreate.name.trim();
|
||||||
|
const rawKey = downstreamCreate.key.trim();
|
||||||
|
if (!name) {
|
||||||
|
toast.info('请填写名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!rawKey) {
|
||||||
|
toast.info('请填写 API Key');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!rawKey.startsWith(PROXY_TOKEN_PREFIX)) {
|
||||||
|
toast.info('API Key 必须以 sk- 开头');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDownstreamSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
key: rawKey,
|
||||||
|
description: downstreamCreate.description.trim(),
|
||||||
|
expiresAt: downstreamCreate.expiresAt ? new Date(downstreamCreate.expiresAt).toISOString() : null,
|
||||||
|
maxCost: downstreamCreate.maxCost.trim() ? Number(downstreamCreate.maxCost.trim()) : null,
|
||||||
|
maxRequests: downstreamCreate.maxRequests.trim() ? Number(downstreamCreate.maxRequests.trim()) : null,
|
||||||
|
supportedModels: downstreamCreate.selectedModels,
|
||||||
|
allowedRouteIds: downstreamCreate.selectedGroupRouteIds,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingDownstreamId) {
|
||||||
|
await api.updateDownstreamApiKey(editingDownstreamId, payload);
|
||||||
|
toast.success('下游 API Key 已更新');
|
||||||
|
} else {
|
||||||
|
await api.createDownstreamApiKey(payload);
|
||||||
|
toast.success('下游 API Key 已创建');
|
||||||
|
}
|
||||||
|
setDownstreamModalOpen(false);
|
||||||
|
resetDownstreamForm();
|
||||||
|
await loadDownstreamKeys();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.message || '保存下游 API Key 失败');
|
||||||
|
} finally {
|
||||||
|
setDownstreamSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleModelSelection = (modelName: string) => {
|
||||||
|
setDownstreamCreate((prev) => {
|
||||||
|
const exists = prev.selectedModels.includes(modelName);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
selectedModels: exists
|
||||||
|
? prev.selectedModels.filter((item) => item !== modelName)
|
||||||
|
: [...prev.selectedModels, modelName],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleGroupRouteSelection = (routeId: number) => {
|
||||||
|
setDownstreamCreate((prev) => {
|
||||||
|
const exists = prev.selectedGroupRouteIds.includes(routeId);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
selectedGroupRouteIds: exists
|
||||||
|
? prev.selectedGroupRouteIds.filter((item) => item !== routeId)
|
||||||
|
: [...prev.selectedGroupRouteIds, routeId],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const runDownstreamOp = async (id: number, action: () => Promise<void>) => {
|
||||||
|
setDownstreamOps((prev) => ({ ...prev, [id]: true }));
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} finally {
|
||||||
|
setDownstreamOps((prev) => ({ ...prev, [id]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDownstreamEnabled = async (item: DownstreamApiKeyItem) => {
|
||||||
|
await runDownstreamOp(item.id, async () => {
|
||||||
|
await api.updateDownstreamApiKey(item.id, { enabled: !item.enabled });
|
||||||
|
await loadDownstreamKeys();
|
||||||
|
toast.success(item.enabled ? '已禁用' : '已启用');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetDownstreamUsage = async (item: DownstreamApiKeyItem) => {
|
||||||
|
await runDownstreamOp(item.id, async () => {
|
||||||
|
await api.resetDownstreamApiKeyUsage(item.id);
|
||||||
|
await loadDownstreamKeys();
|
||||||
|
toast.success('已重置用量');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteDownstreamKey = async (item: DownstreamApiKeyItem) => {
|
||||||
|
if (!window.confirm(`确认删除 API Key:${item.name}?`)) return;
|
||||||
|
await runDownstreamOp(item.id, async () => {
|
||||||
|
await api.deleteDownstreamApiKey(item.id);
|
||||||
|
if (editingDownstreamId === item.id) {
|
||||||
|
setDownstreamModalOpen(false);
|
||||||
|
resetDownstreamForm();
|
||||||
|
}
|
||||||
|
await loadDownstreamKeys();
|
||||||
|
toast.success('已删除');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const saveRouting = async () => {
|
const saveRouting = async () => {
|
||||||
setSavingRouting(true);
|
setSavingRouting(true);
|
||||||
try {
|
try {
|
||||||
@@ -334,6 +665,72 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
|
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游 API Key 策略(按项目/分组)</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||||
|
每个下游 Key 可独立配置过期、额度,并通过勾选界面限制可访问的模型与群组。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||||
|
<button onClick={openCreateDownstreamModal} className="btn btn-primary">
|
||||||
|
+ 新增 API Key
|
||||||
|
</button>
|
||||||
|
<button onClick={loadDownstreamKeys} disabled={downstreamLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||||
|
{downstreamLoading ? '刷新中...' : '刷新列表'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{downstreamKeys.length === 0 ? (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无下游 API Key</div>
|
||||||
|
) : downstreamKeys.map((item) => {
|
||||||
|
const opLoading = !!downstreamOps[item.id];
|
||||||
|
const quotaText = `${item.usedRequests}${item.maxRequests !== null ? `/${item.maxRequests}` : ''}`;
|
||||||
|
const costText = `$${item.usedCost.toFixed(6)}${item.maxCost !== null ? `/$${item.maxCost}` : ''}`;
|
||||||
|
return (
|
||||||
|
<div key={item.id} style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 10 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<strong style={{ fontSize: 13 }}>{item.name}</strong>
|
||||||
|
<code style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--color-text-secondary)' }}>{item.keyMasked}</code>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 12,
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 999,
|
||||||
|
background: item.enabled ? 'var(--color-success-bg)' : 'var(--color-danger-bg)',
|
||||||
|
color: item.enabled ? 'var(--color-success)' : 'var(--color-danger)',
|
||||||
|
}}>
|
||||||
|
{item.enabled ? '启用' : '禁用'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<button onClick={() => beginEditDownstream(item)} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button onClick={() => toggleDownstreamEnabled(item)} disabled={opLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||||
|
{item.enabled ? '禁用' : '启用'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => resetDownstreamUsage(item)} disabled={opLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||||
|
重置用量
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deleteDownstreamKey(item)} disabled={opLoading} className="btn btn-link btn-link-warning">
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 6, display: 'flex', gap: 14, flexWrap: 'wrap' }}>
|
||||||
|
<span>请求用量: {quotaText}</span>
|
||||||
|
<span>费用用量: {costText}</span>
|
||||||
|
<span>过期: {item.expiresAt ? new Date(item.expiresAt).toLocaleString() : '永久'}</span>
|
||||||
|
<span>模型规则: {item.supportedModels.length || 0}</span>
|
||||||
|
<span>群组限制: {item.allowedRouteIds.length || 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card animate-slide-up stagger-5" style={{ padding: 20 }}>
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>路由策略</div>
|
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>路由策略</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||||
先选择预设策略,只有需要精调时再展开高级参数。
|
先选择预设策略,只有需要精调时再展开高级参数。
|
||||||
@@ -483,6 +880,344 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{downstreamModalOpen && (() => {
|
||||||
|
const modal = (
|
||||||
|
<div className="modal-backdrop" onClick={closeDownstreamModal}>
|
||||||
|
<div
|
||||||
|
className="modal-content"
|
||||||
|
style={{ maxWidth: 860 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="modal-header">
|
||||||
|
{editingDownstreamId ? '编辑下游 API Key' : '新增下游 API Key'}
|
||||||
|
</div>
|
||||||
|
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 10 }}>
|
||||||
|
<input
|
||||||
|
value={downstreamCreate.name}
|
||||||
|
onChange={(e) => setDownstreamCreate((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
|
placeholder="名称(例如:cc-project)"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={downstreamCreate.key}
|
||||||
|
onChange={(e) => setDownstreamCreate((prev) => ({ ...prev, key: e.target.value.trim() }))}
|
||||||
|
placeholder="sk-xxxx"
|
||||||
|
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={downstreamCreate.maxCost}
|
||||||
|
onChange={(e) => setDownstreamCreate((prev) => ({ ...prev, maxCost: e.target.value }))}
|
||||||
|
placeholder="最大费用(可选)"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.000001}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={downstreamCreate.maxRequests}
|
||||||
|
onChange={(e) => setDownstreamCreate((prev) => ({ ...prev, maxRequests: e.target.value }))}
|
||||||
|
placeholder="最大请求数(可选)"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={downstreamCreate.expiresAt}
|
||||||
|
onChange={(e) => setDownstreamCreate((prev) => ({ ...prev, expiresAt: e.target.value }))}
|
||||||
|
type="datetime-local"
|
||||||
|
placeholder="过期时间(可选)"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={downstreamCreate.description}
|
||||||
|
onChange={(e) => setDownstreamCreate((prev) => ({ ...prev, description: e.target.value }))}
|
||||||
|
placeholder="备注(可选)"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||||
|
已选模型 {downstreamCreate.selectedModels.length} 个,已选群组 {downstreamCreate.selectedGroupRouteIds.length} 个
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (selectorRoutes.length === 0) await loadRouteSelectorRoutes();
|
||||||
|
setSelectorModelSearch('');
|
||||||
|
setSelectorGroupSearch('');
|
||||||
|
setSelectorOpen(true);
|
||||||
|
}}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
|
>
|
||||||
|
勾选模型和群组
|
||||||
|
</button>
|
||||||
|
{(downstreamCreate.selectedModels.length > 0 || downstreamCreate.selectedGroupRouteIds.length > 0) && (
|
||||||
|
<button
|
||||||
|
onClick={() => setDownstreamCreate((prev) => ({ ...prev, selectedModels: [], selectedGroupRouteIds: [] }))}
|
||||||
|
className="btn btn-link btn-link-warning"
|
||||||
|
>
|
||||||
|
清空选择
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button onClick={closeDownstreamModal} className="btn btn-ghost">取消</button>
|
||||||
|
<button onClick={saveDownstreamKey} disabled={downstreamSaving} className="btn btn-primary">
|
||||||
|
{downstreamSaving
|
||||||
|
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</>
|
||||||
|
: (editingDownstreamId ? '更新 API Key' : '新增 API Key')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
||||||
|
})()}
|
||||||
|
{selectorOpen && (() => {
|
||||||
|
const modal = (
|
||||||
|
<div className="modal-backdrop" onClick={closeSelectorModal}>
|
||||||
|
<div
|
||||||
|
className="modal-content"
|
||||||
|
style={{ maxWidth: 860 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="modal-header">勾选模型和群组</div>
|
||||||
|
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||||
|
选择结果会保存到当前下游 API Key:精确模型用于模型白名单,群组用于路由范围限制。
|
||||||
|
</div>
|
||||||
|
{selectorLoading ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-muted)' }}>
|
||||||
|
<span className="spinner spinner-sm" />
|
||||||
|
加载路由中...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 12 }}>
|
||||||
|
<div style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 10 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
|
||||||
|
精确模型 ({selectorModelSearch.trim()
|
||||||
|
? `${filteredExactModelOptions.length}/${exactModelOptions.length}`
|
||||||
|
: exactModelOptions.length})
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={selectorModelSearch}
|
||||||
|
onChange={(e) => setSelectorModelSearch(e.target.value)}
|
||||||
|
placeholder="搜索精确模型(支持模糊匹配)"
|
||||||
|
style={{ ...inputStyle, padding: '8px 10px', fontSize: 12, marginBottom: 8 }}
|
||||||
|
/>
|
||||||
|
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{exactModelOptions.length === 0 ? (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可选精确模型</div>
|
||||||
|
) : filteredExactModelOptions.length === 0 ? (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>没有匹配的精确模型</div>
|
||||||
|
) : filteredExactModelOptions.map((modelName) => {
|
||||||
|
const checked = downstreamCreate.selectedModels.includes(modelName);
|
||||||
|
const brand = getBrand(modelName);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={modelName}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: `1px solid ${checked ? 'color-mix(in srgb, var(--color-primary) 45%, transparent)' : 'var(--color-border-light)'}`,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '8px 10px',
|
||||||
|
background: checked
|
||||||
|
? 'color-mix(in srgb, var(--color-primary) 9%, var(--color-bg-card))'
|
||||||
|
: 'var(--color-bg-card)',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggleModelSelection(modelName)}
|
||||||
|
style={{ width: 18, height: 18, accentColor: 'var(--color-primary)', flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 12,
|
||||||
|
background: 'var(--color-bg)',
|
||||||
|
padding: '4px 10px',
|
||||||
|
borderRadius: 8,
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
maxWidth: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{brand ? (
|
||||||
|
<InlineBrandIcon model={modelName} size={18} />
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'var(--color-bg-card)',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 10,
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{modelName.slice(0, 1).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{modelName}
|
||||||
|
</span>
|
||||||
|
</code>
|
||||||
|
{brand && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', paddingLeft: 6 }}>
|
||||||
|
{brand.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 10 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
|
||||||
|
群组 ({selectorGroupSearch.trim()
|
||||||
|
? `${filteredGroupRouteOptions.length}/${groupRouteOptions.length}`
|
||||||
|
: groupRouteOptions.length})
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
value={selectorGroupSearch}
|
||||||
|
onChange={(e) => setSelectorGroupSearch(e.target.value)}
|
||||||
|
placeholder="搜索群组(名称/匹配规则)"
|
||||||
|
style={{ ...inputStyle, padding: '8px 10px', fontSize: 12, marginBottom: 8 }}
|
||||||
|
/>
|
||||||
|
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{groupRouteOptions.length === 0 ? (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可选群组</div>
|
||||||
|
) : filteredGroupRouteOptions.length === 0 ? (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>没有匹配的群组</div>
|
||||||
|
) : filteredGroupRouteOptions.map((route) => {
|
||||||
|
const checked = downstreamCreate.selectedGroupRouteIds.includes(route.id);
|
||||||
|
const explicitBrandIcon = parseBrandIconValue(route.displayIcon);
|
||||||
|
const textIcon = explicitBrandIcon ? '' : (route.displayIcon || '').trim();
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={route.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 10,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: `1px solid ${checked ? 'color-mix(in srgb, var(--color-primary) 45%, transparent)' : 'var(--color-border-light)'}`,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: '8px 10px',
|
||||||
|
background: checked
|
||||||
|
? 'color-mix(in srgb, var(--color-primary) 9%, var(--color-bg-card))'
|
||||||
|
: 'var(--color-bg-card)',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
style={{ marginTop: 4, width: 18, height: 18, accentColor: 'var(--color-primary)', flexShrink: 0 }}
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggleGroupRouteSelection(route.id)}
|
||||||
|
/>
|
||||||
|
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 12,
|
||||||
|
background: 'var(--color-bg)',
|
||||||
|
padding: '4px 10px',
|
||||||
|
borderRadius: 8,
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
maxWidth: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'var(--color-bg-card)',
|
||||||
|
flexShrink: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{explicitBrandIcon ? (
|
||||||
|
<img
|
||||||
|
src={`${iconCdn}/${explicitBrandIcon.replace(/\./g, '-')}.png`}
|
||||||
|
alt={routeTitle(route)}
|
||||||
|
style={{ width: 18, height: 18, objectFit: 'contain' }}
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : textIcon ? (
|
||||||
|
textIcon
|
||||||
|
) : (
|
||||||
|
<InlineBrandIcon model={resolveRouteBrandSource(route)} size={18} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{routeTitle(route)}
|
||||||
|
</span>
|
||||||
|
{!route.enabled && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
padding: '1px 6px',
|
||||||
|
borderRadius: 999,
|
||||||
|
background: 'var(--color-danger-bg)',
|
||||||
|
color: 'var(--color-danger)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
已禁用
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</code>
|
||||||
|
<code style={{ fontSize: 11, color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', paddingLeft: 6 }}>
|
||||||
|
{route.modelPattern}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button onClick={closeSelectorModal} className="btn btn-ghost">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-2
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
import ModernSelect from '../components/ModernSelect.js';
|
import ModernSelect from '../components/ModernSelect.js';
|
||||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||||
|
import { clearFocusParams, readFocusSiteId } from './helpers/navigationFocus.js';
|
||||||
import { tr } from '../i18n.js';
|
import { tr } from '../i18n.js';
|
||||||
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
|
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +22,7 @@ type SiteRow = {
|
|||||||
platform?: string;
|
platform?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
|
proxyUrl?: string | null;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
totalBalance?: number;
|
totalBalance?: number;
|
||||||
@@ -33,11 +36,18 @@ const platformColors: Record<string, string> = {
|
|||||||
'one-hub': 'badge-muted',
|
'one-hub': 'badge-muted',
|
||||||
'done-hub': 'badge-muted',
|
'done-hub': 'badge-muted',
|
||||||
sub2api: 'badge-muted',
|
sub2api: 'badge-muted',
|
||||||
|
openai: 'badge-success',
|
||||||
|
claude: 'badge-warning',
|
||||||
|
gemini: 'badge-info',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Sites() {
|
export default function Sites() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [sites, setSites] = useState<SiteRow[]>([]);
|
const [sites, setSites] = useState<SiteRow[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
const [sortMode, setSortMode] = useState<SortMode>('custom');
|
const [sortMode, setSortMode] = useState<SortMode>('custom');
|
||||||
|
const [highlightSiteId, setHighlightSiteId] = useState<number | null>(null);
|
||||||
const [editor, setEditor] = useState<SiteEditorState | null>(null);
|
const [editor, setEditor] = useState<SiteEditorState | null>(null);
|
||||||
const [form, setForm] = useState<SiteForm>(emptySiteForm());
|
const [form, setForm] = useState<SiteForm>(emptySiteForm());
|
||||||
const [detecting, setDetecting] = useState(false);
|
const [detecting, setDetecting] = useState(false);
|
||||||
@@ -46,6 +56,8 @@ export default function Sites() {
|
|||||||
const [togglingSiteId, setTogglingSiteId] = useState<number | null>(null);
|
const [togglingSiteId, setTogglingSiteId] = useState<number | null>(null);
|
||||||
const [orderingSiteId, setOrderingSiteId] = useState<number | null>(null);
|
const [orderingSiteId, setOrderingSiteId] = useState<number | null>(null);
|
||||||
const [pinningSiteId, setPinningSiteId] = useState<number | null>(null);
|
const [pinningSiteId, setPinningSiteId] = useState<number | null>(null);
|
||||||
|
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
|
||||||
|
const highlightTimerRef = useRef<number | null>(null);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const isEditing = editor?.mode === 'edit';
|
const isEditing = editor?.mode === 'edit';
|
||||||
@@ -57,6 +69,8 @@ export default function Sites() {
|
|||||||
setSites(rows || []);
|
setSites(rows || []);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('加载站点列表失败');
|
toast.error('加载站点列表失败');
|
||||||
|
} finally {
|
||||||
|
setLoaded(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,6 +83,35 @@ export default function Sites() {
|
|||||||
[sites, sortMode],
|
[sites, sortMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (highlightTimerRef.current) {
|
||||||
|
window.clearTimeout(highlightTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const focusSiteId = readFocusSiteId(location.search);
|
||||||
|
if (!focusSiteId || !loaded) return;
|
||||||
|
|
||||||
|
const row = rowRefs.current.get(focusSiteId);
|
||||||
|
const cleanedSearch = clearFocusParams(location.search);
|
||||||
|
if (!row) {
|
||||||
|
navigate({ pathname: location.pathname, search: cleanedSearch }, { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
setHighlightSiteId(focusSiteId);
|
||||||
|
if (highlightTimerRef.current) window.clearTimeout(highlightTimerRef.current);
|
||||||
|
highlightTimerRef.current = window.setTimeout(() => {
|
||||||
|
setHighlightSiteId((current) => (current === focusSiteId ? null : current));
|
||||||
|
}, 2200);
|
||||||
|
|
||||||
|
navigate({ pathname: location.pathname, search: cleanedSearch }, { replace: true });
|
||||||
|
}, [loaded, location.pathname, location.search, navigate, sortedSites]);
|
||||||
|
|
||||||
const closeEditor = () => {
|
const closeEditor = () => {
|
||||||
setEditor(null);
|
setEditor(null);
|
||||||
setForm(emptySiteForm());
|
setForm(emptySiteForm());
|
||||||
@@ -95,6 +138,7 @@ export default function Sites() {
|
|||||||
url: form.url.trim(),
|
url: form.url.trim(),
|
||||||
platform: form.platform.trim(),
|
platform: form.platform.trim(),
|
||||||
apiKey: form.apiKey.trim(),
|
apiKey: form.apiKey.trim(),
|
||||||
|
proxyUrl: form.proxyUrl.trim(),
|
||||||
};
|
};
|
||||||
if (!payload.name || !payload.url) {
|
if (!payload.name || !payload.url) {
|
||||||
toast.error('请填写站点名称和 URL');
|
toast.error('请填写站点名称和 URL');
|
||||||
@@ -308,6 +352,21 @@ export default function Sites() {
|
|||||||
color: 'var(--color-text-primary)',
|
color: 'var(--color-text-primary)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="出站代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)"
|
||||||
|
value={form.proxyUrl}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 14px',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: 13,
|
||||||
|
outline: 'none',
|
||||||
|
background: 'var(--color-bg)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving || !form.name.trim() || !form.url.trim()}
|
disabled={saving || !form.name.trim() || !form.url.trim()}
|
||||||
@@ -336,7 +395,14 @@ export default function Sites() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sortedSites.map((site, i) => (
|
{sortedSites.map((site, i) => (
|
||||||
<tr key={site.id} className={`animate-slide-up stagger-${Math.min(i + 1, 5)}`}>
|
<tr
|
||||||
|
key={site.id}
|
||||||
|
ref={(node) => {
|
||||||
|
if (node) rowRefs.current.set(site.id, node);
|
||||||
|
else rowRefs.current.delete(site.id);
|
||||||
|
}}
|
||||||
|
className={`animate-slide-up stagger-${Math.min(i + 1, 5)} ${highlightSiteId === site.id ? 'row-focus-highlight' : ''}`}
|
||||||
|
>
|
||||||
<td style={{ fontWeight: 600 }}>
|
<td style={{ fontWeight: 600 }}>
|
||||||
<a
|
<a
|
||||||
href={site.url}
|
href={site.url}
|
||||||
|
|||||||
+642
-163
File diff suppressed because it is too large
Load Diff
@@ -225,7 +225,7 @@ export default function Tokens() {
|
|||||||
if (status === 'failed') {
|
if (status === 'failed') {
|
||||||
toast.error(`同步失败:${resolveSyncMessage(res, '请检查账号令牌或站点状态')}`);
|
toast.error(`同步失败:${resolveSyncMessage(res, '请检查账号令牌或站点状态')}`);
|
||||||
} else if (status === 'skipped') {
|
} else if (status === 'skipped') {
|
||||||
toast.info(`同步已跳过:${resolveSyncMessage(res, '账号缺少可用会话令牌')}`);
|
toast.info(`同步已跳过:${resolveSyncMessage(res, '账号缺少可用 Session Cookie')}`);
|
||||||
} else {
|
} else {
|
||||||
toast.success(`同步完成:新增 ${res.created || 0},更新 ${res.updated || 0}`);
|
toast.success(`同步完成:新增 ${res.created || 0},更新 ${res.updated || 0}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { ToastProvider } from '../components/Toast.js';
|
||||||
|
import Accounts from './Accounts.js';
|
||||||
|
|
||||||
|
const { apiMock } = vi.hoisted(() => ({
|
||||||
|
apiMock: {
|
||||||
|
getAccounts: vi.fn(),
|
||||||
|
getSites: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api.js', () => ({
|
||||||
|
api: apiMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function collectText(node: ReactTestInstance): string {
|
||||||
|
const children = node.children || [];
|
||||||
|
return children.map((child) => {
|
||||||
|
if (typeof child === 'string') return child;
|
||||||
|
return collectText(child);
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMicrotasks() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Accounts rebind panel focus', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights rebind panel when clicking rebind action', async () => {
|
||||||
|
apiMock.getAccounts.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
username: 'tester',
|
||||||
|
balance: 100,
|
||||||
|
balanceUsed: 0,
|
||||||
|
todayReward: 0,
|
||||||
|
todaySpend: 0,
|
||||||
|
status: 'expired',
|
||||||
|
checkinEnabled: true,
|
||||||
|
siteId: 10,
|
||||||
|
site: { id: 10, name: 'Demo Site', status: 'active', url: 'https://example.com' },
|
||||||
|
runtimeHealth: { state: 'unhealthy', reason: '访问令牌失效' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
apiMock.getSites.mockResolvedValue([{ id: 10, name: 'Demo Site', platform: 'new-api', status: 'active' }]);
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/accounts']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<Accounts />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const rebindButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).trim() === '重新绑定'
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
rebindButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const panel = root.root.find((node) => (
|
||||||
|
node.type === 'div'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('rebind-panel')
|
||||||
|
));
|
||||||
|
expect(String(panel.props.className)).toContain('rebind-panel-highlight');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { ToastProvider } from '../components/Toast.js';
|
||||||
|
import Accounts from './Accounts.js';
|
||||||
|
|
||||||
|
const { apiMock } = vi.hoisted(() => ({
|
||||||
|
apiMock: {
|
||||||
|
getAccounts: vi.fn(),
|
||||||
|
getSites: vi.fn(),
|
||||||
|
refreshBalance: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api.js', () => ({
|
||||||
|
api: apiMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function collectText(node: ReactTestInstance): string {
|
||||||
|
const children = node.children || [];
|
||||||
|
return children.map((child) => {
|
||||||
|
if (typeof child === 'string') return child;
|
||||||
|
return collectText(child);
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMicrotasks() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Accounts refresh action', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reloads account list even when refresh balance fails', async () => {
|
||||||
|
apiMock.getAccounts
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
username: 'tester',
|
||||||
|
balance: 100,
|
||||||
|
balanceUsed: 0,
|
||||||
|
todayReward: 0,
|
||||||
|
todaySpend: 0,
|
||||||
|
status: 'active',
|
||||||
|
checkinEnabled: true,
|
||||||
|
site: { id: 10, name: 'Demo Site', status: 'active', url: 'https://example.com' },
|
||||||
|
runtimeHealth: { state: 'healthy', reason: 'ok' },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
username: 'tester',
|
||||||
|
balance: 100,
|
||||||
|
balanceUsed: 0,
|
||||||
|
todayReward: 0,
|
||||||
|
todaySpend: 0,
|
||||||
|
status: 'expired',
|
||||||
|
checkinEnabled: true,
|
||||||
|
site: { id: 10, name: 'Demo Site', status: 'active', url: 'https://example.com' },
|
||||||
|
runtimeHealth: { state: 'unhealthy', reason: '访问令牌失效' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
apiMock.getSites.mockResolvedValue([{ id: 10, name: 'Demo Site', platform: 'new-api', status: 'active' }]);
|
||||||
|
apiMock.refreshBalance.mockRejectedValueOnce(new Error('无权进行此操作,access token 无效'));
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/accounts']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<Accounts />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const refreshButtons = root.root.findAll((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('btn-link-primary')
|
||||||
|
&& collectText(node).trim() === '刷新'
|
||||||
|
));
|
||||||
|
expect(refreshButtons.length).toBeGreaterThan(0);
|
||||||
|
const refreshButton = refreshButtons[0]!;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await refreshButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.getAccounts).toHaveBeenCalledTimes(2);
|
||||||
|
const rendered = JSON.stringify(root.toJSON());
|
||||||
|
expect(rendered).toContain('异常');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { fuzzyMatch } from './fuzzySearch.js';
|
||||||
|
|
||||||
|
describe('fuzzyMatch', () => {
|
||||||
|
it('matches plain substring case-insensitively', () => {
|
||||||
|
expect(fuzzyMatch('claude-opus-4-6', 'OPUS')).toBe(true);
|
||||||
|
expect(fuzzyMatch('gpt-4o-mini', 'gpt')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches after removing separators', () => {
|
||||||
|
expect(fuzzyMatch('gpt-5.2-codex', 'gpt52')).toBe(true);
|
||||||
|
expect(fuzzyMatch('re:^claude-(opus|sonnet)-4-5$', 'claude45')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports subsequence fuzzy matching', () => {
|
||||||
|
expect(fuzzyMatch('cerebras-llama-3.1-8b', 'cll318')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when query cannot be matched', () => {
|
||||||
|
expect(fuzzyMatch('claude-opus-4-6', 'gemini')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
function normalize(value: string): string {
|
||||||
|
return (value || '').toLowerCase().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function compact(value: string): string {
|
||||||
|
return normalize(value).replace(/[^a-z0-9]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSubsequence(text: string, query: string): boolean {
|
||||||
|
if (!query) return true;
|
||||||
|
let qi = 0;
|
||||||
|
for (let i = 0; i < text.length; i += 1) {
|
||||||
|
if (text[i] === query[qi]) qi += 1;
|
||||||
|
if (qi >= query.length) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fuzzyMatch(text: string, query: string): boolean {
|
||||||
|
const q = normalize(query);
|
||||||
|
if (!q) return true;
|
||||||
|
|
||||||
|
const t = normalize(text);
|
||||||
|
if (t.includes(q)) return true;
|
||||||
|
|
||||||
|
const compactText = compact(t);
|
||||||
|
const compactQuery = compact(q);
|
||||||
|
if (!compactQuery) return true;
|
||||||
|
if (compactText.includes(compactQuery)) return true;
|
||||||
|
|
||||||
|
return isSubsequence(compactText, compactQuery);
|
||||||
|
}
|
||||||
@@ -125,7 +125,12 @@ const toNullableFiniteNumber = (value: unknown): number | null => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isExactModelPattern = (modelPattern: string): boolean => !/[\*\?\[]/.test(modelPattern);
|
const isExactModelPattern = (modelPattern: string): boolean => {
|
||||||
|
const normalized = modelPattern.trim();
|
||||||
|
if (!normalized) return false;
|
||||||
|
if (normalized.toLowerCase().startsWith('re:')) return false;
|
||||||
|
return !/[\*\?\[]/.test(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
export const collectModelTesterModelNames = (
|
export const collectModelTesterModelNames = (
|
||||||
marketplace: { models?: Array<{ name?: unknown }>; } | null | undefined,
|
marketplace: { models?: Array<{ name?: unknown }>; } | null | undefined,
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
buildAccountFocusPath,
|
||||||
|
buildEventNavigationPath,
|
||||||
|
buildSiteFocusPath,
|
||||||
|
clearFocusParams,
|
||||||
|
readFocusAccountIntent,
|
||||||
|
readFocusSiteId,
|
||||||
|
} from './navigationFocus.js';
|
||||||
|
|
||||||
|
describe('navigationFocus helpers', () => {
|
||||||
|
it('builds focus paths for site and account', () => {
|
||||||
|
expect(buildSiteFocusPath(12)).toBe('/sites?focusSiteId=12');
|
||||||
|
expect(buildSiteFocusPath(0)).toBe('/sites');
|
||||||
|
expect(buildAccountFocusPath(34)).toBe('/accounts?focusAccountId=34');
|
||||||
|
expect(buildAccountFocusPath(34, { openRebind: true })).toBe('/accounts?focusAccountId=34&openRebind=1');
|
||||||
|
expect(buildAccountFocusPath(-1)).toBe('/accounts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses focus params from query string', () => {
|
||||||
|
expect(readFocusSiteId('?focusSiteId=15')).toBe(15);
|
||||||
|
expect(readFocusSiteId('?focusSiteId=abc')).toBeNull();
|
||||||
|
|
||||||
|
expect(readFocusAccountIntent('?focusAccountId=22&openRebind=1')).toEqual({
|
||||||
|
accountId: 22,
|
||||||
|
openRebind: true,
|
||||||
|
});
|
||||||
|
expect(readFocusAccountIntent('?focusAccountId=22&openRebind=true')).toEqual({
|
||||||
|
accountId: 22,
|
||||||
|
openRebind: true,
|
||||||
|
});
|
||||||
|
expect(readFocusAccountIntent('?focusAccountId=22')).toEqual({
|
||||||
|
accountId: 22,
|
||||||
|
openRebind: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears focus params but keeps other params', () => {
|
||||||
|
expect(clearFocusParams('?focusSiteId=12&q=abc')).toBe('?q=abc');
|
||||||
|
expect(clearFocusParams('?focusAccountId=2&openRebind=1&type=token')).toBe('?type=token');
|
||||||
|
expect(clearFocusParams('?focusAccountId=2')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds event navigation path by related entity', () => {
|
||||||
|
expect(buildEventNavigationPath({
|
||||||
|
relatedType: 'account',
|
||||||
|
relatedId: 18,
|
||||||
|
type: 'token',
|
||||||
|
})).toBe('/accounts?focusAccountId=18&openRebind=1');
|
||||||
|
|
||||||
|
expect(buildEventNavigationPath({
|
||||||
|
relatedType: 'account',
|
||||||
|
relatedId: 18,
|
||||||
|
type: 'checkin',
|
||||||
|
})).toBe('/accounts?focusAccountId=18');
|
||||||
|
|
||||||
|
expect(buildEventNavigationPath({
|
||||||
|
relatedType: 'site',
|
||||||
|
relatedId: 9,
|
||||||
|
type: 'status',
|
||||||
|
})).toBe('/sites?focusSiteId=9');
|
||||||
|
|
||||||
|
expect(buildEventNavigationPath({
|
||||||
|
relatedType: 'route',
|
||||||
|
relatedId: null,
|
||||||
|
type: 'proxy',
|
||||||
|
})).toBe('/routes');
|
||||||
|
|
||||||
|
expect(buildEventNavigationPath({ type: 'proxy' })).toBe('/logs');
|
||||||
|
expect(buildEventNavigationPath({ type: 'checkin' })).toBe('/checkin');
|
||||||
|
expect(buildEventNavigationPath({})).toBe('/events');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
const FOCUS_SITE_ID_KEY = 'focusSiteId';
|
||||||
|
const FOCUS_ACCOUNT_ID_KEY = 'focusAccountId';
|
||||||
|
const OPEN_REBIND_KEY = 'openRebind';
|
||||||
|
|
||||||
|
function normalizePositiveId(input: unknown): number | null {
|
||||||
|
const value = Number.parseInt(String(input ?? ''), 10);
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return null;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTruthyFlag(value: string | null): boolean {
|
||||||
|
if (!value) return false;
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSiteFocusPath(siteId: number): string {
|
||||||
|
const normalizedId = normalizePositiveId(siteId);
|
||||||
|
if (!normalizedId) return '/sites';
|
||||||
|
return `/sites?${FOCUS_SITE_ID_KEY}=${normalizedId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAccountFocusPath(
|
||||||
|
accountId: number,
|
||||||
|
options?: { openRebind?: boolean },
|
||||||
|
): string {
|
||||||
|
const normalizedId = normalizePositiveId(accountId);
|
||||||
|
if (!normalizedId) return '/accounts';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set(FOCUS_ACCOUNT_ID_KEY, String(normalizedId));
|
||||||
|
if (options?.openRebind) params.set(OPEN_REBIND_KEY, '1');
|
||||||
|
return `/accounts?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readFocusSiteId(search: string): number | null {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
return normalizePositiveId(params.get(FOCUS_SITE_ID_KEY));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readFocusAccountIntent(search: string): { accountId: number | null; openRebind: boolean } {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
return {
|
||||||
|
accountId: normalizePositiveId(params.get(FOCUS_ACCOUNT_ID_KEY)),
|
||||||
|
openRebind: isTruthyFlag(params.get(OPEN_REBIND_KEY)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearFocusParams(search: string): string {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
params.delete(FOCUS_SITE_ID_KEY);
|
||||||
|
params.delete(FOCUS_ACCOUNT_ID_KEY);
|
||||||
|
params.delete(OPEN_REBIND_KEY);
|
||||||
|
const next = params.toString();
|
||||||
|
return next ? `?${next}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildEventNavigationPath(event: {
|
||||||
|
relatedType?: string | null;
|
||||||
|
relatedId?: number | null;
|
||||||
|
type?: string | null;
|
||||||
|
}): string {
|
||||||
|
const relatedType = (event.relatedType || '').toLowerCase();
|
||||||
|
const relatedId = normalizePositiveId(event.relatedId);
|
||||||
|
const eventType = (event.type || '').toLowerCase();
|
||||||
|
|
||||||
|
if (relatedType === 'account' && relatedId) {
|
||||||
|
return buildAccountFocusPath(relatedId, { openRebind: eventType === 'token' });
|
||||||
|
}
|
||||||
|
if (relatedType === 'site' && relatedId) {
|
||||||
|
return buildSiteFocusPath(relatedId);
|
||||||
|
}
|
||||||
|
if (relatedType === 'route') {
|
||||||
|
return '/routes';
|
||||||
|
}
|
||||||
|
if (eventType === 'proxy') {
|
||||||
|
return '/logs';
|
||||||
|
}
|
||||||
|
if (eventType === 'checkin') {
|
||||||
|
return '/checkin';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '/events';
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { getInitialVisibleCount, getNextVisibleCount } from './progressiveRender.js';
|
||||||
|
|
||||||
|
describe('progressiveRender', () => {
|
||||||
|
it('returns bounded initial visible count', () => {
|
||||||
|
expect(getInitialVisibleCount(0, 40)).toBe(0);
|
||||||
|
expect(getInitialVisibleCount(10, 40)).toBe(10);
|
||||||
|
expect(getInitialVisibleCount(120, 40)).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns next visible count without exceeding total', () => {
|
||||||
|
expect(getNextVisibleCount(0, 120, 40)).toBe(40);
|
||||||
|
expect(getNextVisibleCount(40, 120, 40)).toBe(80);
|
||||||
|
expect(getNextVisibleCount(80, 120, 40)).toBe(120);
|
||||||
|
expect(getNextVisibleCount(200, 120, 40)).toBe(120);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export function getInitialVisibleCount(total: number, chunkSize: number): number {
|
||||||
|
if (!Number.isFinite(total) || total <= 0) return 0;
|
||||||
|
const normalizedChunk = Number.isFinite(chunkSize) && chunkSize > 0 ? Math.trunc(chunkSize) : 1;
|
||||||
|
return Math.min(Math.trunc(total), normalizedChunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNextVisibleCount(currentVisible: number, total: number, chunkSize: number): number {
|
||||||
|
const normalizedTotal = Number.isFinite(total) && total > 0 ? Math.trunc(total) : 0;
|
||||||
|
if (normalizedTotal <= 0) return 0;
|
||||||
|
|
||||||
|
const normalizedCurrent = Number.isFinite(currentVisible) && currentVisible > 0
|
||||||
|
? Math.trunc(currentVisible)
|
||||||
|
: 0;
|
||||||
|
const normalizedChunk = Number.isFinite(chunkSize) && chunkSize > 0 ? Math.trunc(chunkSize) : 1;
|
||||||
|
|
||||||
|
return Math.min(normalizedTotal, normalizedCurrent + normalizedChunk);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
buildRouteModelCandidatesIndex,
|
||||||
|
type IndexedRouteModelCandidate,
|
||||||
|
type RouteModelCandidatesByModelName,
|
||||||
|
} from './routeModelCandidatesIndex.js';
|
||||||
|
|
||||||
|
function matchesModelPattern(model: string, pattern: string): boolean {
|
||||||
|
if (!pattern) return false;
|
||||||
|
if (pattern === '*') return true;
|
||||||
|
if (pattern.endsWith('*')) return model.startsWith(pattern.slice(0, -1));
|
||||||
|
return model === pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildRouteModelCandidatesIndex', () => {
|
||||||
|
it('precomputes route candidates, account options and token options by account', () => {
|
||||||
|
const routes = [
|
||||||
|
{ id: 1, modelPattern: 'claude-*' },
|
||||||
|
{ id: 2, modelPattern: 'gpt-4o-mini' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const modelCandidates: RouteModelCandidatesByModelName = {
|
||||||
|
'claude-opus-4-6': [
|
||||||
|
{
|
||||||
|
modelName: 'will-be-overwritten',
|
||||||
|
accountId: 11,
|
||||||
|
tokenId: 101,
|
||||||
|
tokenName: 'tk-opus',
|
||||||
|
isDefault: false,
|
||||||
|
username: 'alice',
|
||||||
|
siteId: 1,
|
||||||
|
siteName: 'site-a',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
modelName: 'dup',
|
||||||
|
accountId: 11,
|
||||||
|
tokenId: 101,
|
||||||
|
tokenName: 'tk-opus',
|
||||||
|
isDefault: false,
|
||||||
|
username: 'alice',
|
||||||
|
siteId: 1,
|
||||||
|
siteName: 'site-a',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'claude-sonnet-4-6': [
|
||||||
|
{
|
||||||
|
modelName: 'will-be-overwritten',
|
||||||
|
accountId: 11,
|
||||||
|
tokenId: 102,
|
||||||
|
tokenName: 'tk-sonnet',
|
||||||
|
isDefault: true,
|
||||||
|
username: 'alice',
|
||||||
|
siteId: 1,
|
||||||
|
siteName: 'site-a',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'gpt-4o-mini': [
|
||||||
|
{
|
||||||
|
modelName: 'will-be-overwritten',
|
||||||
|
accountId: 22,
|
||||||
|
tokenId: 201,
|
||||||
|
tokenName: 'tk-gpt',
|
||||||
|
isDefault: true,
|
||||||
|
username: 'bob',
|
||||||
|
siteId: 2,
|
||||||
|
siteName: 'site-b',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const index = buildRouteModelCandidatesIndex(routes, modelCandidates, matchesModelPattern);
|
||||||
|
|
||||||
|
expect(index[1]).toBeTruthy();
|
||||||
|
expect(index[2]).toBeTruthy();
|
||||||
|
|
||||||
|
const route1Candidates = index[1].routeCandidates;
|
||||||
|
expect(route1Candidates).toHaveLength(2);
|
||||||
|
expect(route1Candidates.map((item) => item.modelName).sort()).toEqual([
|
||||||
|
'claude-opus-4-6',
|
||||||
|
'claude-sonnet-4-6',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(index[1].accountOptions).toEqual([{ id: 11, label: 'alice @ site-a' }]);
|
||||||
|
|
||||||
|
const route1TokenOptions = index[1].tokenOptionsByAccountId[11];
|
||||||
|
expect(route1TokenOptions.map((item) => `${item.id}:${item.sourceModel}:${item.isDefault}`)).toEqual([
|
||||||
|
'102:claude-sonnet-4-6:true',
|
||||||
|
'101:claude-opus-4-6:false',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(index[2].routeCandidates).toHaveLength(1);
|
||||||
|
expect(index[2].routeCandidates[0].modelName).toBe('gpt-4o-mini');
|
||||||
|
expect(index[2].accountOptions).toEqual([{ id: 22, label: 'bob @ site-b' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty structures when route model pattern is blank', () => {
|
||||||
|
const routes = [{ id: 1, modelPattern: ' ' }];
|
||||||
|
const modelCandidates: RouteModelCandidatesByModelName = {
|
||||||
|
'gpt-4o-mini': [{
|
||||||
|
modelName: 'ignored',
|
||||||
|
accountId: 1,
|
||||||
|
tokenId: 2,
|
||||||
|
tokenName: 'tk',
|
||||||
|
isDefault: false,
|
||||||
|
username: null,
|
||||||
|
siteId: 1,
|
||||||
|
siteName: 'site',
|
||||||
|
} satisfies IndexedRouteModelCandidate],
|
||||||
|
};
|
||||||
|
|
||||||
|
const index = buildRouteModelCandidatesIndex(routes, modelCandidates, matchesModelPattern);
|
||||||
|
expect(index[1].routeCandidates).toEqual([]);
|
||||||
|
expect(index[1].accountOptions).toEqual([]);
|
||||||
|
expect(index[1].tokenOptionsByAccountId).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
export type IndexedRouteModelCandidate = {
|
||||||
|
modelName: string;
|
||||||
|
accountId: number;
|
||||||
|
tokenId: number;
|
||||||
|
tokenName: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
username: string | null;
|
||||||
|
siteId: number;
|
||||||
|
siteName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RouteModelCandidatesByModelName = Record<string, IndexedRouteModelCandidate[]>;
|
||||||
|
|
||||||
|
export type RouteAccountOption = {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RouteTokenOption = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
sourceModel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RouteCandidateView = {
|
||||||
|
routeCandidates: IndexedRouteModelCandidate[];
|
||||||
|
accountOptions: RouteAccountOption[];
|
||||||
|
tokenOptionsByAccountId: Record<number, RouteTokenOption[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RouteModelPatternLike = {
|
||||||
|
id: number;
|
||||||
|
modelPattern: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_ROUTE_CANDIDATE_VIEW: RouteCandidateView = {
|
||||||
|
routeCandidates: [],
|
||||||
|
accountOptions: [],
|
||||||
|
tokenOptionsByAccountId: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildRouteModelCandidatesIndex(
|
||||||
|
routes: RouteModelPatternLike[],
|
||||||
|
modelCandidates: RouteModelCandidatesByModelName,
|
||||||
|
matchesModelPattern: (model: string, pattern: string) => boolean,
|
||||||
|
): Record<number, RouteCandidateView> {
|
||||||
|
const index: Record<number, RouteCandidateView> = {};
|
||||||
|
|
||||||
|
for (const route of routes || []) {
|
||||||
|
const modelPattern = (route.modelPattern || '').trim();
|
||||||
|
if (!modelPattern) {
|
||||||
|
index[route.id] = EMPTY_ROUTE_CANDIDATE_VIEW;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deduped = new Map<string, IndexedRouteModelCandidate>();
|
||||||
|
for (const [modelName, candidates] of Object.entries(modelCandidates || {})) {
|
||||||
|
if (!matchesModelPattern(modelName, modelPattern)) continue;
|
||||||
|
for (const candidate of candidates || []) {
|
||||||
|
const key = `${candidate.tokenId}::${modelName}`;
|
||||||
|
if (!deduped.has(key)) {
|
||||||
|
deduped.set(key, {
|
||||||
|
...candidate,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeCandidates = Array.from(deduped.values()).sort((a, b) => {
|
||||||
|
if (a.accountId === b.accountId) return a.tokenId - b.tokenId;
|
||||||
|
return a.accountId - b.accountId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const accountMap = new Map<number, string>();
|
||||||
|
const tokenOptionsByAccountId: Record<number, RouteTokenOption[]> = {};
|
||||||
|
for (const candidate of routeCandidates) {
|
||||||
|
if (!accountMap.has(candidate.accountId)) {
|
||||||
|
accountMap.set(candidate.accountId, `${candidate.username || `account-${candidate.accountId}`} @ ${candidate.siteName}`);
|
||||||
|
}
|
||||||
|
if (!tokenOptionsByAccountId[candidate.accountId]) {
|
||||||
|
tokenOptionsByAccountId[candidate.accountId] = [];
|
||||||
|
}
|
||||||
|
tokenOptionsByAccountId[candidate.accountId].push({
|
||||||
|
id: candidate.tokenId,
|
||||||
|
name: candidate.tokenName,
|
||||||
|
isDefault: candidate.isDefault,
|
||||||
|
sourceModel: candidate.modelName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const accountIdText of Object.keys(tokenOptionsByAccountId)) {
|
||||||
|
const accountId = Number.parseInt(accountIdText, 10);
|
||||||
|
tokenOptionsByAccountId[accountId].sort((a, b) => {
|
||||||
|
if (a.isDefault === b.isDefault) {
|
||||||
|
if (a.id === b.id) return (a.sourceModel || '').localeCompare(b.sourceModel || '', undefined, { sensitivity: 'base' });
|
||||||
|
return a.id - b.id;
|
||||||
|
}
|
||||||
|
return a.isDefault ? -1 : 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountOptions = Array.from(accountMap.entries()).map(([id, label]) => ({ id, label }));
|
||||||
|
index[route.id] = {
|
||||||
|
routeCandidates,
|
||||||
|
accountOptions,
|
||||||
|
tokenOptionsByAccountId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}
|
||||||
@@ -5,7 +5,13 @@ describe('buildSiteSaveAction', () => {
|
|||||||
it('returns add action in add mode', () => {
|
it('returns add action in add mode', () => {
|
||||||
const action = buildSiteSaveAction(
|
const action = buildSiteSaveAction(
|
||||||
{ mode: 'add' },
|
{ mode: 'add' },
|
||||||
{ name: 'site-a', url: 'https://a.example.com/', platform: 'new-api', apiKey: 'sk-1' },
|
{
|
||||||
|
name: 'site-a',
|
||||||
|
url: 'https://a.example.com/',
|
||||||
|
platform: 'new-api',
|
||||||
|
apiKey: 'sk-1',
|
||||||
|
proxyUrl: 'http://127.0.0.1:7890',
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(action).toEqual({
|
expect(action).toEqual({
|
||||||
@@ -15,6 +21,7 @@ describe('buildSiteSaveAction', () => {
|
|||||||
url: 'https://a.example.com/',
|
url: 'https://a.example.com/',
|
||||||
platform: 'new-api',
|
platform: 'new-api',
|
||||||
apiKey: 'sk-1',
|
apiKey: 'sk-1',
|
||||||
|
proxyUrl: 'http://127.0.0.1:7890',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -22,7 +29,7 @@ describe('buildSiteSaveAction', () => {
|
|||||||
it('returns update action in edit mode with site id', () => {
|
it('returns update action in edit mode with site id', () => {
|
||||||
const action = buildSiteSaveAction(
|
const action = buildSiteSaveAction(
|
||||||
{ mode: 'edit', editingSiteId: 12 },
|
{ mode: 'edit', editingSiteId: 12 },
|
||||||
{ name: 'site-b', url: 'https://b.example.com', platform: 'one-api', apiKey: '' },
|
{ name: 'site-b', url: 'https://b.example.com', platform: 'one-api', apiKey: '', proxyUrl: '' },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(action).toEqual({
|
expect(action).toEqual({
|
||||||
@@ -33,6 +40,7 @@ describe('buildSiteSaveAction', () => {
|
|||||||
url: 'https://b.example.com',
|
url: 'https://b.example.com',
|
||||||
platform: 'one-api',
|
platform: 'one-api',
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
|
proxyUrl: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -41,7 +49,7 @@ describe('buildSiteSaveAction', () => {
|
|||||||
expect(() =>
|
expect(() =>
|
||||||
buildSiteSaveAction(
|
buildSiteSaveAction(
|
||||||
{ mode: 'edit' },
|
{ mode: 'edit' },
|
||||||
{ name: 'site-c', url: 'https://c.example.com', platform: '', apiKey: '' },
|
{ name: 'site-c', url: 'https://c.example.com', platform: '', apiKey: '', proxyUrl: '' },
|
||||||
),
|
),
|
||||||
).toThrow('editingSiteId is required in edit mode');
|
).toThrow('editingSiteId is required in edit mode');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type SiteForm = {
|
|||||||
url: string;
|
url: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
|
proxyUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SiteEditorState =
|
export type SiteEditorState =
|
||||||
@@ -14,15 +15,16 @@ type SiteSaveAction =
|
|||||||
| { kind: 'update'; id: number; payload: SiteForm };
|
| { kind: 'update'; id: number; payload: SiteForm };
|
||||||
|
|
||||||
export function emptySiteForm(): SiteForm {
|
export function emptySiteForm(): SiteForm {
|
||||||
return { name: '', url: '', platform: '', apiKey: '' };
|
return { name: '', url: '', platform: '', apiKey: '', proxyUrl: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function siteFormFromSite(site: Partial<SiteForm>): SiteForm {
|
export function siteFormFromSite(site: Partial<SiteForm> & { proxyUrl?: string | null }): SiteForm {
|
||||||
return {
|
return {
|
||||||
name: site.name ?? '',
|
name: site.name ?? '',
|
||||||
url: site.url ?? '',
|
url: site.url ?? '',
|
||||||
platform: site.platform ?? '',
|
platform: site.platform ?? '',
|
||||||
apiKey: site.apiKey ?? '',
|
apiKey: site.apiKey ?? '',
|
||||||
|
proxyUrl: site.proxyUrl ?? '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user