forked from boos/BoosAPI
feat(tests): enhance modelTesterSession tests and add proxy logs tests
- Updated modelTesterSession tests to serialize assistant history as output_text and removed mime_type from input_file. - Introduced new tests for stats proxy logs routes, covering paginated log retrieval and detailed log views with parsed billing details. - Added server-driven tests for ProxyLogs component to validate API interactions and UI rendering based on server data.
This commit is contained in:
+19
-1
@@ -15,11 +15,29 @@ We are committed to providing a welcoming, harassment-free community for everyon
|
||||
- Harassment, discrimination, or abusive language
|
||||
- Personal attacks or intimidation
|
||||
- Publishing others' private information without consent
|
||||
- Deliberate disruption, trolling, or bad-faith escalation
|
||||
- Repeatedly ignoring maintainer moderation requests
|
||||
|
||||
## Reporting
|
||||
|
||||
- Email `cita-777@users.noreply.github.com` with subject prefix `[metapi conduct]`.
|
||||
- Include links, screenshots, timestamps, and any context that helps reconstruct the incident.
|
||||
- If the incident is happening directly on GitHub and needs urgent platform action, also use GitHub's built-in report tools.
|
||||
- We will keep reporter details as private as practical and do not allow retaliation for good-faith reports.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Project maintainers are responsible for clarifying and enforcing this code of conduct.
|
||||
They may remove, edit, or reject comments, commits, code, and contributions that do not align with this policy.
|
||||
|
||||
Possible actions include:
|
||||
|
||||
- editing or removing comments, issues, discussions, or PR content
|
||||
- warning the contributor
|
||||
- locking threads or limiting participation in project spaces
|
||||
- rejecting contributions or blocking a contributor from repository spaces
|
||||
- escalating serious cases to GitHub or another hosting provider
|
||||
|
||||
We aim to acknowledge conduct reports within 7 days when possible and will review the available evidence before taking action.
|
||||
|
||||
## Scope
|
||||
|
||||
|
||||
+58
-7
@@ -2,7 +2,7 @@
|
||||
|
||||
Thanks for contributing to Metapi.
|
||||
|
||||
## Development Setup
|
||||
## Local Setup
|
||||
|
||||
1. Install dependencies:
|
||||
|
||||
@@ -10,33 +10,84 @@ Thanks for contributing to Metapi.
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Copy environment template:
|
||||
2. Create a local environment file:
|
||||
|
||||
```powershell
|
||||
Copy-Item .env.example .env
|
||||
```
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. Run migration and start development:
|
||||
3. Initialize the default SQLite database:
|
||||
|
||||
```bash
|
||||
npm run db:migrate
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Quality Checks
|
||||
## Common Commands
|
||||
|
||||
Run before opening a PR:
|
||||
### App development
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
npm run dev:server
|
||||
restart.bat
|
||||
```
|
||||
|
||||
- `npm run dev` starts the Fastify server and Vite together.
|
||||
- `npm run dev:server` runs only the backend watcher.
|
||||
- `restart.bat` is the Windows-friendly restart entrypoint; it forwards to `scripts\dev\restart.bat`, clears stale listeners, and starts `npm run dev`.
|
||||
|
||||
### Docs
|
||||
|
||||
```bash
|
||||
npm run docs:dev
|
||||
npm run docs:build
|
||||
npm run docs:preview
|
||||
```
|
||||
|
||||
### Desktop
|
||||
|
||||
```bash
|
||||
npm run dev:desktop
|
||||
npm run build:desktop
|
||||
npm run dist:desktop
|
||||
npm run package:desktop
|
||||
```
|
||||
|
||||
`npm run dev:desktop` expects the backend on `http://127.0.0.1:4000` and the Vite frontend on `http://127.0.0.1:5173`.
|
||||
|
||||
### Test, build, and smoke checks
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run test:watch
|
||||
npm run build
|
||||
npm run build:web
|
||||
npm run build:server
|
||||
npm run smoke:db
|
||||
npm run smoke:db:sqlite
|
||||
npm run smoke:db:mysql -- --db-url mysql://user:pass@host:3306/db
|
||||
npm run smoke:db:postgres -- --db-url postgres://user:pass@host:5432/db
|
||||
```
|
||||
|
||||
## Windows Notes
|
||||
|
||||
- Prefer `Copy-Item` or Explorer copy/paste over `cp` if you are working in PowerShell or `cmd.exe`.
|
||||
- If a previous dev process keeps ports busy, use `restart.bat` instead of manually hunting PIDs.
|
||||
- If dependencies or `.cmd` shims look broken after a Node.js upgrade, rerun `npm install` before assuming the scripts are wrong.
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
- Keep PRs focused and small.
|
||||
- Add or update tests for behavior changes.
|
||||
- Update docs when user-facing behavior/config changes.
|
||||
- Update docs when user-facing behavior, commands, ports, or configuration change.
|
||||
- Run the checks that match your change set before opening a PR:
|
||||
- docs only: `npm run docs:build`
|
||||
- app code: `npm test` and the relevant `npm run build:*`
|
||||
- runtime DB work: one of the `npm run smoke:db*` commands
|
||||
- Avoid committing runtime data (`data/`) or temporary files (`tmp/`).
|
||||
|
||||
## Commit Messages
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
### 🌐 统一代理网关
|
||||
|
||||
- 兼容 **OpenAI** 与 **Claude** 下游格式,对接所有主流客户端
|
||||
- 支持 Responses / Chat Completions / Messages / Completions(Legacy)/ Embeddings / Images / Models 全接口
|
||||
- 支持 Responses / Chat Completions / Messages / Completions(Legacy)/ Embeddings / Images / Models,以及标准 `/v1/files` 文件接口
|
||||
- 完整的 SSE 流式传输支持,自动格式转换(OpenAI ⇄ Claude)
|
||||
|
||||
### 🧠 智能路由引擎
|
||||
@@ -181,12 +181,12 @@
|
||||
| **New API** | `new-api` | 新一代大模型网关 |
|
||||
| **One API** | `one-api` | 经典 OpenAI 接口聚合 |
|
||||
| **OneHub** | `onehub` | One API 增强分支 |
|
||||
| **DoneHub** | `donehub` | OneHub 增强分支 |
|
||||
| **DoneHub** | `done-hub` | OneHub 增强分支 |
|
||||
| **Veloera** | `veloera` | API 网关平台 |
|
||||
| **AnyRouter** | `anyrouter` | 通用路由平台 |
|
||||
| **Sub2API** | `sub2api` | 订阅制中转平台 |
|
||||
|
||||
每种平台适配器均支持:账号登录、余额查询、模型枚举、Token 同步、每日签到、用户信息获取等完整生命周期管理。
|
||||
各平台适配器覆盖模型枚举、余额查询、Token 管理、代理接入等通用能力;登录、签到、用户信息等能力按平台而异。
|
||||
|
||||
### 👥 账号与 Token 管理
|
||||
|
||||
@@ -222,7 +222,7 @@
|
||||
|
||||
### 📦 轻量部署
|
||||
|
||||
- **单 Docker 容器**,内置 SQLite,无外部依赖
|
||||
- **单 Docker 容器**,默认本地数据目录部署,开箱即用
|
||||
- Alpine 基础镜像,体积精简
|
||||
- 数据完整导入导出,迁移无忧
|
||||
|
||||
@@ -263,7 +263,7 @@ docker run -d --name metapi -p 4000:4000 \
|
||||
| **前端框架** | [React 18](https://react.dev) + [Vite](https://vitejs.dev) |
|
||||
| **语言** | [TypeScript](https://www.typescriptlang.org) — 端到端类型安全 |
|
||||
| **样式** | [Tailwind CSS v4](https://tailwindcss.com) — 原子化样式框架 |
|
||||
| **数据库** | SQLite ([better-sqlite3](https://github.com/WiseLibs/better-sqlite3)) + [Drizzle ORM](https://orm.drizzle.team) |
|
||||
| **数据库** | SQLite / MySQL / PostgreSQL + [Drizzle ORM](https://orm.drizzle.team) |
|
||||
| **数据可视化** | [VChart](https://visactor.io/vchart) (@visactor/react-vchart) |
|
||||
| **定时任务** | [node-cron](https://github.com/node-cron/node-cron) |
|
||||
| **容器化** | Docker (Alpine) + Docker Compose |
|
||||
@@ -319,7 +319,7 @@ npm run db:generate # 生成 Drizzle 迁移文件
|
||||
|
||||
## 🔒 数据与隐私
|
||||
|
||||
Metapi 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。
|
||||
Metapi 完全自托管,所有数据(账号、令牌、路由、日志)均存储在你自己的部署环境中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+14
-110
@@ -160,7 +160,7 @@ The AI ecosystem is seeing a growing number of aggregation relay stations based
|
||||
### Unified Proxy Gateway
|
||||
|
||||
- Compatible with **OpenAI** and **Claude** downstream formats, works with all mainstream clients
|
||||
- Supports Responses / Chat Completions / Messages / Completions (Legacy) / Embeddings / Images / Models (current core coverage)
|
||||
- Supports Responses / Chat Completions / Messages / Completions (Legacy) / Embeddings / Images / Models, plus standard `/v1/files`
|
||||
- Full SSE streaming support with automatic format conversion (OpenAI <-> Claude)
|
||||
|
||||
### Smart Routing Engine
|
||||
@@ -184,12 +184,12 @@ The AI ecosystem is seeing a growing number of aggregation relay stations based
|
||||
| **New API** | `new-api` | Next-gen LLM gateway |
|
||||
| **One API** | `one-api` | Classic OpenAI API aggregation |
|
||||
| **OneHub** | `onehub` | Enhanced One API fork |
|
||||
| **DoneHub** | `donehub` | Enhanced OneHub fork |
|
||||
| **DoneHub** | `done-hub` | Enhanced OneHub fork |
|
||||
| **Veloera** | `veloera` | API gateway platform |
|
||||
| **AnyRouter** | `anyrouter` | Universal routing platform |
|
||||
| **Sub2API** | `sub2api` | Subscription-based relay |
|
||||
|
||||
Each adapter supports: account login, balance queries, model enumeration, token sync, daily check-in, user info retrieval, and full lifecycle management.
|
||||
Adapters cover shared capabilities such as model discovery, balance access, token management, and proxy integration; login, check-in, and user-info flows vary by platform.
|
||||
|
||||
### Account & Token Management
|
||||
|
||||
@@ -264,7 +264,7 @@ Alert scenarios: low balance warning, site/account anomalies, check-in failures,
|
||||
|
||||
### Lightweight Deployment
|
||||
|
||||
- **Single Docker container** with built-in SQLite — no external dependencies
|
||||
- **Single Docker container** with a default local data directory, plus optional external MySQL / PostgreSQL runtime DB
|
||||
- Alpine base image, minimal footprint
|
||||
- Full data import/export for worry-free migration
|
||||
|
||||
@@ -330,31 +330,7 @@ After starting, visit `http://localhost:4000` and log in with your `AUTH_TOKEN`!
|
||||
> If running outside Compose without explicitly setting `AUTH_TOKEN`, the default is `change-me-admin-token` (for local debugging only).
|
||||
> If you change the admin token in the Settings panel, use the new token for subsequent logins.
|
||||
|
||||
### Desktop App (Windows / macOS / Linux)
|
||||
|
||||
For personal-computer deployments, download the desktop installer from [Releases](https://github.com/cita-777/metapi/releases):
|
||||
|
||||
1. Download the installer for your platform
|
||||
2. Install and launch Metapi Desktop
|
||||
3. The desktop shell starts the local service automatically and stores data under the app data directory
|
||||
|
||||
Desktop builds include:
|
||||
|
||||
- Embedded local Metapi service, started automatically by Electron
|
||||
- Tray menu for reopen / restart backend / launch at login
|
||||
- In-app update checks backed by GitHub Releases
|
||||
|
||||
> [!NOTE]
|
||||
> Server deployments are now Docker-first.
|
||||
> GitHub Release assets are intended for the desktop app rather than raw Node.js server archives.
|
||||
|
||||
### Upgrade
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d && docker image prune -f
|
||||
```
|
||||
|
||||
For more deployment options, see [Deployment Guide](docs/deployment.md).
|
||||
For Docker Compose, desktop installers, reverse proxy, upgrades, and database options, see [Deployment Guide](docs/deployment.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -379,7 +355,7 @@ For more deployment options, see [Deployment Guide](docs/deployment.md).
|
||||
| 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 |
|
||||
| Docs Maintenance | [docs/README.md](docs/README.md) | Maintenance and contributor-oriented docs entry |
|
||||
| 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 |
|
||||
| Configuration | [docs/configuration.md](docs/configuration.md) | All environment variables and routing params |
|
||||
@@ -399,7 +375,7 @@ For more deployment options, see [Deployment Guide](docs/deployment.md).
|
||||
| `AUTH_TOKEN` | Admin panel login token (**must change**) | `change-me-admin-token` |
|
||||
| `PROXY_TOKEN` | Proxy API Bearer Token (**must change**) | `change-me-proxy-sk-token` |
|
||||
| `PORT` | Service listening port | `4000` |
|
||||
| `DATA_DIR` | Data directory (SQLite database) | `./data` |
|
||||
| `DATA_DIR` | Data directory for local runtime data | `./data` |
|
||||
| `TZ` | Timezone | `Asia/Shanghai` |
|
||||
| `ACCOUNT_CREDENTIAL_SECRET` | Account credential encryption key | Defaults to `AUTH_TOKEN` |
|
||||
|
||||
@@ -470,6 +446,9 @@ Metapi exposes standard OpenAI / Claude compatible endpoints:
|
||||
| `/v1/completions` | POST | OpenAI Completions (Legacy) |
|
||||
| `/v1/embeddings` | POST | Embeddings |
|
||||
| `/v1/images/generations` | POST | Image Generation |
|
||||
| `/v1/files` | POST / GET | OpenAI Files upload and list |
|
||||
| `/v1/files/:fileId` | GET / DELETE | OpenAI Files retrieve metadata and delete |
|
||||
| `/v1/files/:fileId/content` | GET | OpenAI Files raw content |
|
||||
| `/v1/models` | GET | List all available models |
|
||||
|
||||
Include `Authorization: Bearer <PROXY_TOKEN>` in request headers.
|
||||
@@ -495,84 +474,9 @@ Compatible with all OpenAI API-compatible clients:
|
||||
| **API Key** | Your configured `PROXY_TOKEN` |
|
||||
| **Model List** | Auto-fetched from `GET /v1/models` |
|
||||
|
||||
### CLI Quick Setup (Claude Code / Codex)
|
||||
Standard OpenAI `/v1/files` workflows are also supported for clients that use the official file API.
|
||||
|
||||
#### Claude Code (`~/.claude/settings.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://your-domain.com",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Note: Different client versions may read `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN`; setting both is recommended.
|
||||
|
||||
#### Codex (`~/.codex/config.toml` + `~/.codex/auth.json`)
|
||||
|
||||
`~/.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"
|
||||
}
|
||||
```
|
||||
|
||||
> Tip: `model` must exist in Metapi `GET /v1/models`.
|
||||
|
||||
### Verified Compatible Clients
|
||||
|
||||
- [ChatGPT-Next-Web](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web)
|
||||
- [Open WebUI](https://github.com/open-webui/open-webui)
|
||||
- [Cherry Studio](https://github.com/kangfenmao/cherry-studio)
|
||||
- [Cursor](https://cursor.sh)
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
||||
- Codex CLI
|
||||
- [Roo Code](https://github.com/RooVetGit/Roo-Code)
|
||||
- [Kilo Code](https://github.com/kilocode/kilocode)
|
||||
- And any client that supports the OpenAI API format
|
||||
|
||||
<details>
|
||||
<summary><strong>Troubleshooting: Streaming Response Issues</strong></summary>
|
||||
|
||||
If non-streaming works but streaming fails, check:
|
||||
|
||||
1. Whether the reverse proxy has SSE buffering disabled (Nginx: `proxy_buffering off`)
|
||||
2. Whether a middleware layer is rewriting the `text/event-stream` Content-Type
|
||||
3. Whether the client requires a specific streaming format
|
||||
|
||||
**Nginx reference configuration:**
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:4000;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_set_header Connection '';
|
||||
proxy_http_version 1.1;
|
||||
chunked_transfer_encoding off;
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
For detailed integration instructions: [docs/client-integration.md](docs/client-integration.md)
|
||||
For detailed per-client setup, examples, and troubleshooting, see [docs/client-integration.md](docs/client-integration.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -584,7 +488,7 @@ For detailed integration instructions: [docs/client-integration.md](docs/client-
|
||||
| **Frontend** | [React 18](https://react.dev) + [Vite](https://vitejs.dev) |
|
||||
| **Language** | [TypeScript](https://www.typescriptlang.org) — End-to-end type safety |
|
||||
| **Styling** | [Tailwind CSS v4](https://tailwindcss.com) — Utility-first CSS framework |
|
||||
| **Database** | SQLite ([better-sqlite3](https://github.com/WiseLibs/better-sqlite3)) + [Drizzle ORM](https://orm.drizzle.team) |
|
||||
| **Database** | SQLite / MySQL / PostgreSQL + [Drizzle ORM](https://orm.drizzle.team) |
|
||||
| **Charts** | [VChart](https://visactor.io/vchart) (@visactor/react-vchart) |
|
||||
| **Scheduling** | [node-cron](https://github.com/node-cron/node-cron) |
|
||||
| **Containerization** | Docker (Alpine) + Docker Compose |
|
||||
@@ -640,7 +544,7 @@ npm run db:generate # Generate Drizzle migration files
|
||||
|
||||
## Data & Privacy
|
||||
|
||||
Metapi is fully self-hosted. All data (accounts, tokens, routes, logs) is stored in the local SQLite database. No data is sent to any third party. Proxy requests are transmitted directly between your server and upstream sites only.
|
||||
Metapi is fully self-hosted. All data (accounts, tokens, routes, logs) stays in your own deployment environment. No data is sent to any third party. Proxy requests are transmitted directly between your server and upstream sites only.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+23
-9
@@ -2,22 +2,36 @@
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Only the latest `main` branch state is supported for security fixes.
|
||||
- The latest release on GitHub.
|
||||
- The current `main` branch.
|
||||
|
||||
Older tags and ad-hoc forks may receive guidance, but they are not guaranteed security backports.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please do not report security issues in public issues.
|
||||
Please do not report security issues in public issues, discussions, or pull requests.
|
||||
|
||||
Use one of these channels:
|
||||
|
||||
- GitHub Security Advisory private report (preferred)
|
||||
- Maintainer contact listed in project profile
|
||||
1. GitHub Security Advisory private report: `https://github.com/cita-777/metapi/security/advisories/new`
|
||||
2. Email: `cita-777@users.noreply.github.com` with subject prefix `[metapi security]`
|
||||
|
||||
When reporting, include:
|
||||
|
||||
- Affected endpoint/module
|
||||
- Reproduction steps
|
||||
- Potential impact
|
||||
- Suggested fix (if available)
|
||||
- Affected version, commit, or deployment mode
|
||||
- Affected endpoint, module, or configuration surface
|
||||
- Reproduction steps or proof-of-concept
|
||||
- Expected impact and attack preconditions
|
||||
- Any logs, screenshots, or packets with secrets redacted
|
||||
- Suggested mitigation or patch ideas, if you already have them
|
||||
|
||||
We will acknowledge reports as soon as possible and coordinate a fix and disclosure timeline.
|
||||
## Response Process
|
||||
|
||||
- We aim to acknowledge private reports within 3 business days.
|
||||
- We aim to provide an initial triage or follow-up questions within 7 business days.
|
||||
- We may ask for extra reproduction details, redacted logs, or environment information before confirming severity.
|
||||
- We coordinate disclosure with the reporter when possible. Please keep the report private until a fix or mitigation is ready.
|
||||
|
||||
## Handling Public Reports
|
||||
|
||||
If a vulnerability is posted publicly by mistake, maintainers may redact sensitive details, convert the report to a private channel, or close the public thread after redirecting the reporter to the private process above.
|
||||
|
||||
@@ -5,7 +5,7 @@ export default withMermaid(
|
||||
defineConfig({
|
||||
lang: 'zh-CN',
|
||||
title: 'Metapi 文档',
|
||||
description: 'Metapi 使用文档、FAQ 与教程沉淀',
|
||||
description: 'Metapi 使用文档、FAQ 与维护协作指南',
|
||||
cleanUrls: true,
|
||||
lastUpdated: true,
|
||||
ignoreDeadLinks: true,
|
||||
@@ -16,7 +16,7 @@ export default withMermaid(
|
||||
{ text: '首页', link: '/' },
|
||||
{ text: '快速上手', link: '/getting-started' },
|
||||
{ text: 'FAQ', link: '/faq' },
|
||||
{ text: '社区贡献', link: '/community/faq-tutorial-guidelines' },
|
||||
{ text: '文档维护', link: '/README' },
|
||||
{ text: '项目主页', link: 'https://github.com/cita-777/metapi' },
|
||||
],
|
||||
sidebar: [
|
||||
@@ -24,23 +24,23 @@ export default withMermaid(
|
||||
text: '开始',
|
||||
items: [
|
||||
{ text: '文档首页', link: '/' },
|
||||
{ text: '文档总览', link: '/README' },
|
||||
{ text: '快速上手', link: '/getting-started' },
|
||||
{ text: '部署指南', link: '/deployment' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: '使用与配置',
|
||||
text: '使用与运维',
|
||||
items: [
|
||||
{ text: '配置说明', link: '/configuration' },
|
||||
{ text: '客户端接入', link: '/client-integration' },
|
||||
{ text: '运维手册', link: '/operations' },
|
||||
{ text: '常见问题 FAQ', link: '/faq' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: '知识库',
|
||||
text: '文档维护',
|
||||
items: [
|
||||
{ text: '常见问题 FAQ', link: '/faq' },
|
||||
{ text: '文档维护与贡献', link: '/README' },
|
||||
{ text: '目录规范', link: '/project-structure' },
|
||||
{ text: 'FAQ/教程贡献规范', link: '/community/faq-tutorial-guidelines' },
|
||||
],
|
||||
|
||||
+35
-33
@@ -1,17 +1,17 @@
|
||||
# 📚 Metapi 文档中心
|
||||
# 文档维护与贡献
|
||||
|
||||
<div align="center">
|
||||
> `/README` 路由为兼容旧链接与维护工作流而保留。面向所有用户的公开入口始终是 [文档首页](/)。
|
||||
|
||||
**中转站的中转站 — 将分散的 AI 中转站聚合为一个统一网关**
|
||||
## 这页适合谁
|
||||
|
||||
[返回项目主页](../README.md)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
- 文档维护者:调整导航、重构内容结构、检查首页是否仍然聚焦对外说明。
|
||||
- FAQ / 教程贡献者:补充排障经验、接入说明、案例沉淀。
|
||||
- 功能发布或运维负责人:在能力变更后同步校正文档入口、截图和索引。
|
||||
|
||||
## 文档站运行
|
||||
|
||||
本地预览:
|
||||
|
||||
```bash
|
||||
npm run docs:dev
|
||||
```
|
||||
@@ -22,36 +22,38 @@ npm run docs:dev
|
||||
npm run docs:build
|
||||
```
|
||||
|
||||
## 快速导航
|
||||
## 内容地图
|
||||
|
||||
| 文档 | 适合谁 | 解决的问题 |
|
||||
| 内容领域 | 首选页面 | 什么时候改这里 |
|
||||
|------|--------|------------|
|
||||
| 🚀 [快速上手](./getting-started.md) | 首次使用者 | 10 分钟完成部署与首次请求 |
|
||||
| 🚢 [部署指南](./deployment.md) | 部署维护者 | Docker Compose、反向代理、升级回滚 |
|
||||
| ⚙️ [配置说明](./configuration.md) | 管理员 | 全部环境变量、路由参数、通知渠道 |
|
||||
| 🔌 [客户端接入](./client-integration.md) | 下游应用接入者 | Open WebUI、Cherry Studio、Cursor 等接入 |
|
||||
| 🔧 [运维手册](./operations.md) | 运维人员 | 备份恢复、日志排查、健康检查 |
|
||||
| ❓ [常见问题](./faq.md) | 所有用户 | 常见报错与修复路径 |
|
||||
| 🧩 [FAQ/教程贡献规范](./community/faq-tutorial-guidelines.md) | 社区贡献者 | 统一沉淀 FAQ、教程与排障经验 |
|
||||
| 📁 [目录规范](./project-structure.md) | 开发者 | 项目目录组织与约定 |
|
||||
| 对外第一印象、产品定位、核心入口 | [文档首页](/) | 需要调整公开落地页信息架构、首页 CTA 或首屏导航时 |
|
||||
| 新用户部署与首条请求 | [快速上手](./getting-started.md) | 新安装流程、默认端口、首次调用步骤变化时 |
|
||||
| 生产部署与回滚 | [部署指南](./deployment.md) | Docker Compose、反向代理、升级回滚策略变更时 |
|
||||
| 环境变量、参数和配置项 | [配置说明](./configuration.md) | 新增配置、默认值变化、兼容行为变化时 |
|
||||
| 客户端与工具接入 | [客户端接入](./client-integration.md) | Open WebUI、Cherry Studio、Cursor 等接入方式变化时 |
|
||||
| 运维排障与日常维护 | [运维手册](./operations.md) / [常见问题](./faq.md) | 新排障案例、备份恢复、健康检查、典型报错变化时 |
|
||||
| FAQ / 教程协作沉淀 | [FAQ/教程贡献规范](./community/faq-tutorial-guidelines.md) | 需要新增教程、FAQ 模板、内容提交流程时 |
|
||||
| 仓库目录与组织约定 | [目录规范](./project-structure.md) | 目录结构、归档策略或命名约定变化时 |
|
||||
|
||||
## 🏛️ 架构概览
|
||||
## 维护约定
|
||||
|
||||
<div align="center">
|
||||
<img src="./screenshots/metapi-architecture.png" alt="Metapi: Federated AI Model Aggregation Gateway Architecture" style="max-width: 100%; height: auto;" />
|
||||
</div>
|
||||
- [文档首页](/) 是唯一公共落地页。不要在 `/README` 或其他主题页重新堆一份 `文档中心总览`。
|
||||
- 优先补强现有页面,再决定是否新增页面。只有在读者对象、内容边界和长期维护者都明确时再扩展路由。
|
||||
- 导航、侧边栏和页面标题应反映公开入口和维护入口的分工,避免两个首页争夺注意力。
|
||||
- 内链优先使用站内 clean URL 形式,保证 VitePress 构建结果和部署路径一致。
|
||||
- 涉及界面、架构或流程变化时,同步检查首页截图、架构图和相关说明是否仍然匹配当前产品。
|
||||
|
||||
## 核心概念
|
||||
## 更新前自检
|
||||
|
||||
- **站点 (Site)**:一个上游中转站实例(如 New API、OneHub 等)
|
||||
- **账号 (Account)**:在某站点上注册的用户账号
|
||||
- **Token**:账号下的 API Key,用于访问该站点的 API
|
||||
- **路由 (Route)**:一条模型匹配规则,如 `claude-sonnet-4-6`
|
||||
- **通道 (Channel)**:路由下的一条 Token 链路,一个路由可有多个通道
|
||||
- **代理 (Proxy)**:Metapi 对下游暴露的统一 API 入口
|
||||
1. 先确认目标读者是谁,以及内容该落在哪个现有页面。
|
||||
2. 如果入口结构变了,同步更新 `docs/.vitepress/config.ts` 的 `nav` 和 `sidebar`。
|
||||
3. 检查新增或修改的内链是否还能在站内自然发现,不要把关键入口只藏在单个页面里。
|
||||
4. 运行 `npm run docs:build`,确认没有引入构建错误,并额外抽查关键入口与内链。
|
||||
|
||||
## 开源协作
|
||||
## 相关入口
|
||||
|
||||
- 📝 贡献流程:[CONTRIBUTING.md](../CONTRIBUTING.md)
|
||||
- 🛡️ 安全策略:[SECURITY.md](../SECURITY.md)
|
||||
- 📜 行为准则:[CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md)
|
||||
- [FAQ/教程贡献规范](./community/faq-tutorial-guidelines.md)
|
||||
- [返回文档首页](/)
|
||||
- [项目贡献流程](https://github.com/cita-777/metapi/blob/main/CONTRIBUTING.md)
|
||||
- [安全策略](https://github.com/cita-777/metapi/blob/main/SECURITY.md)
|
||||
- [行为准则](https://github.com/cita-777/metapi/blob/main/CODE_OF_CONDUCT.md)
|
||||
|
||||
+22
-20
@@ -8,11 +8,11 @@
|
||||
|
||||
## 通用配置
|
||||
|
||||
Metapi 暴露标准 OpenAI / Claude 兼容接口,下游客户端只需配置两项:
|
||||
Metapi 暴露标准 OpenAI / Claude 兼容接口,下游客户端通常只需配置两项:
|
||||
|
||||
| 配置项 | 值 |
|
||||
|--------|-----|
|
||||
| **Base URL** | `https://your-domain.com`(不要拼接 `/v1`,客户端会自动加) |
|
||||
| **Base URL** | 按客户端字段行为填写:会自动补 `/v1` 的字段填 `https://your-domain.com`;要求完整 API URL 的字段填 `https://your-domain.com/v1` |
|
||||
| **API Key** | 你设置的 `PROXY_TOKEN` 值,或创建的下游 API Key |
|
||||
|
||||
模型列表自动从 `GET /v1/models` 获取,无需手动配置。
|
||||
@@ -26,7 +26,9 @@ Metapi 暴露标准 OpenAI / Claude 兼容接口,下游客户端只需配置
|
||||
| `/v1/messages` | POST | Claude Messages |
|
||||
| `/v1/completions` | POST | OpenAI Completions(Legacy) |
|
||||
| `/v1/embeddings` | POST | 向量嵌入 |
|
||||
| `/v1/files` | POST / GET / DELETE | OpenAI Files 上传、列举、读取与删除 |
|
||||
| `/v1/files` | POST / GET | OpenAI Files 上传与列举 |
|
||||
| `/v1/files/:fileId` | GET / DELETE | OpenAI Files 读取元信息与删除 |
|
||||
| `/v1/files/:fileId/content` | GET | OpenAI Files 原始内容读取 |
|
||||
| `/v1/images/generations` | POST | 图像生成 |
|
||||
| `/v1/models` | GET | 模型列表 |
|
||||
|
||||
@@ -36,7 +38,7 @@ Metapi 暴露标准 OpenAI / Claude 兼容接口,下游客户端只需配置
|
||||
|
||||
| 配置项 | 值 |
|
||||
|--------|-----|
|
||||
| Settings → Custom Endpoint | `https://your-domain.com` |
|
||||
| Settings → Custom Endpoint | `https://your-domain.com`(该字段会自动访问 `/v1/*`) |
|
||||
| API Key | `PROXY_TOKEN` |
|
||||
|
||||
### Open WebUI
|
||||
@@ -50,7 +52,7 @@ Metapi 暴露标准 OpenAI / Claude 兼容接口,下游客户端只需配置
|
||||
|
||||
| 配置项 | 值 |
|
||||
|--------|-----|
|
||||
| 模型提供商 → OpenAI → API 地址 | `https://your-domain.com` |
|
||||
| 模型提供商 → OpenAI → API 地址 | `https://your-domain.com/v1` |
|
||||
| API Key | `PROXY_TOKEN` |
|
||||
|
||||
> 说明:Metapi 现在支持标准 OpenAI `/v1/files` 文件链路。
|
||||
@@ -70,38 +72,38 @@ Metapi 暴露标准 OpenAI / Claude 兼容接口,下游客户端只需配置
|
||||
|
||||
```json
|
||||
{
|
||||
“env”: {
|
||||
“ANTHROPIC_BASE_URL”: “https://your-domain.com”,
|
||||
“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”
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://your-domain.com",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 说明:不同版本客户端可能读取 `ANTHROPIC_API_KEY` 或 `ANTHROPIC_AUTH_TOKEN`,建议同时设置。
|
||||
> 说明:`ANTHROPIC_BASE_URL` 这里填根域名,不要手动拼接 `/v1`;不同版本客户端可能读取 `ANTHROPIC_API_KEY` 或 `ANTHROPIC_AUTH_TOKEN`,建议同时设置。
|
||||
> 上述变量由 Claude Code 客户端读取,属于客户端行为开关;
|
||||
> `metapi` 服务端无法替代客户端”设置这些变量”,只能处理已经发来的请求。
|
||||
> `metapi` 服务端无法替代客户端自行设置这些变量,只能处理已经发来的请求。
|
||||
|
||||
### Codex CLI
|
||||
|
||||
`~/.codex/config.toml`
|
||||
|
||||
```toml
|
||||
model = “gpt-5”
|
||||
model_provider = “metapi”
|
||||
model = "gpt-5"
|
||||
model_provider = "metapi"
|
||||
|
||||
[model_providers.metapi]
|
||||
name = “metapi”
|
||||
base_url = “https://your-domain.com/v1”
|
||||
name = "metapi"
|
||||
base_url = "https://your-domain.com/v1"
|
||||
```
|
||||
|
||||
`~/.codex/auth.json`
|
||||
|
||||
```json
|
||||
{
|
||||
“OPENAI_API_KEY”: “your-proxy-sk-token”
|
||||
"OPENAI_API_KEY": "your-proxy-sk-token"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -109,11 +111,11 @@ base_url = “https://your-domain.com/v1”
|
||||
|
||||
### Roo Code / Kilo Code
|
||||
|
||||
配置方式与 Cursor 类似,在设置中填入 Base URL 和 API Key。
|
||||
配置方式与 Cursor 类似,在设置中填入 `https://your-domain.com/v1` 和 API Key。
|
||||
|
||||
### 其他客户端
|
||||
|
||||
所有支持 OpenAI API 格式的客户端均可接入,只需找到 Base URL 和 API Key 的配置位置即可。
|
||||
所有支持 OpenAI API 格式的客户端均可接入,只需找到 Base URL 和 API Key 的配置位置即可:如果客户端会自动补 `/v1`,填根域名;如果字段要求完整 OpenAI API URL,就填 `https://your-domain.com/v1`。
|
||||
|
||||
## 下游 API Key 策略
|
||||
|
||||
|
||||
@@ -8,6 +8,22 @@
|
||||
- 使用教程(从 0 到 1、进阶技巧、集成示例)
|
||||
- 运维经验(排障、监控、升级、回滚)
|
||||
|
||||
## 维护元数据(必填)
|
||||
|
||||
所有 FAQ / 教程内容都应在标题下方补一段维护元数据:
|
||||
|
||||
```md
|
||||
> owner: @github-id / team
|
||||
> audience: 新手 / 运维 / 集成开发者
|
||||
> last verified: YYYY-MM-DD
|
||||
> obsolete: false
|
||||
```
|
||||
|
||||
- `owner`:后续负责校对、修订、答疑的人或团队。
|
||||
- `audience`:目标读者,避免把新手教程写成排障手册,或把运维经验写成泛泛介绍。
|
||||
- `last verified`:最近一次按文档步骤、截图或行为重新核对的日期。
|
||||
- `obsolete`:默认写 `false`;如果内容已失效,改为 `true`,并在正文开头补替代方案、迁移路径或删除计划。
|
||||
|
||||
## FAQ 模板
|
||||
|
||||
复制以下结构提交:
|
||||
@@ -15,6 +31,11 @@
|
||||
```md
|
||||
## 问题标题(简短、可搜索)
|
||||
|
||||
> owner: @github-id / team
|
||||
> audience: 新手 / 运维 / 集成开发者
|
||||
> last verified: YYYY-MM-DD
|
||||
> obsolete: false
|
||||
|
||||
### 现象
|
||||
- 具体报错信息:
|
||||
- 出现场景:
|
||||
@@ -40,6 +61,11 @@
|
||||
```md
|
||||
# 教程标题
|
||||
|
||||
> owner: @github-id / team
|
||||
> audience: 新手 / 运维 / 集成开发者
|
||||
> last verified: YYYY-MM-DD
|
||||
> obsolete: false
|
||||
|
||||
## 适用人群
|
||||
- 新手 / 运维 / 集成开发者
|
||||
|
||||
@@ -70,12 +96,15 @@
|
||||
## 提交要求
|
||||
|
||||
- 标题可检索:包含关键词(平台名、报错码、功能名)
|
||||
- 步骤可复现:避免“试一下”“差不多”描述
|
||||
- 步骤可复现:避免 `试一下`、`差不多` 这类描述
|
||||
- 版本要明确:注明适用版本、部署方式
|
||||
- 结果可验证:给出成功判定标准
|
||||
- 元数据完整:`owner / audience / last verified / obsolete` 四项缺一不可
|
||||
|
||||
## 维护原则
|
||||
|
||||
- 过时内容要标记“已失效”并给替代方案
|
||||
- 过时内容要标记 `已失效` 并给替代方案
|
||||
- 同类问题合并,避免 FAQ 膨胀重复
|
||||
- 优先给“最短修复路径”,再补原理解释
|
||||
- 优先给最短修复路径,再补原理解释
|
||||
- 正文有实质更新时,同步刷新 `last verified`
|
||||
- 负责人变更时,及时更新 `owner`,不要留下无人维护的教程
|
||||
|
||||
+42
-8
@@ -1,11 +1,18 @@
|
||||
# ⚙️ 配置说明
|
||||
|
||||
本文档列出 Metapi 的全部环境变量配置。
|
||||
本文档列出 Metapi 的环境变量,并单独说明管理后台中的运行时设置。
|
||||
|
||||
[返回文档中心](./README.md)
|
||||
|
||||
---
|
||||
|
||||
## 配置来源
|
||||
|
||||
| 配置类型 | 入口 | 生效方式 | 适用场景 |
|
||||
|----------|------|----------|----------|
|
||||
| 环境变量 | `.env` / 容器环境变量 | 服务启动时读取,作为启动配置或默认值 | 部署、容器编排、首次初始化 |
|
||||
| 运行时设置 | 管理后台「设置」 | 持久化到当前运行数据库;大部分即时生效,数据库运行配置保存后需重启 | 在线调整、运维排障 |
|
||||
|
||||
## 必填配置
|
||||
|
||||
> ⚠️ 以下变量**必须修改**,不要使用默认值。
|
||||
@@ -22,8 +29,19 @@
|
||||
| `PORT` | 服务监听端口 | `4000` |
|
||||
| `DATA_DIR` | 数据目录(SQLite 数据库存储位置) | `./data` |
|
||||
| `TZ` | 时区 | `Asia/Shanghai` |
|
||||
| `SYSTEM_PROXY_URL` | 系统代理 URL(留空表示不使用) | 空 |
|
||||
| `ACCOUNT_CREDENTIAL_SECRET` | 账号凭证加密密钥(用于加密存储的上游账号密码) | 默认使用 `AUTH_TOKEN` |
|
||||
|
||||
## 数据库配置
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `DB_TYPE` | 运行数据库类型:`sqlite` / `mysql` / `postgres` | `sqlite` |
|
||||
| `DB_URL` | 数据库连接串;使用 `mysql` / `postgres` 时必填 | 空 |
|
||||
| `DB_SSL` | 连接 MySQL / Postgres 时启用 SSL | `false` |
|
||||
|
||||
`DB_TYPE=sqlite` 时,`DB_URL` 留空即可,服务会使用默认 SQLite 路径。
|
||||
|
||||
## 定时任务
|
||||
|
||||
| 变量名 | 说明 | 默认值 | 示例 |
|
||||
@@ -61,6 +79,12 @@ Metapi 的路由引擎按多因子加权选择最优通道。
|
||||
|
||||
> 三个权重之和建议为 1.0,但不强制。
|
||||
|
||||
### 路由缓存
|
||||
|
||||
| 变量名 | 说明 | 默认值 | 范围 |
|
||||
|--------|------|--------|------|
|
||||
| `TOKEN_ROUTER_CACHE_TTL_MS` | Token 路由缓存 TTL(毫秒) | `1500` | >= 100 |
|
||||
|
||||
### 路由预设建议
|
||||
|
||||
| 场景 | COST_WEIGHT | BALANCE_WEIGHT | USAGE_WEIGHT |
|
||||
@@ -70,6 +94,13 @@ Metapi 的路由引擎按多因子加权选择最优通道。
|
||||
| **稳定优先** | 0.2 | 0.5 | 0.3 |
|
||||
| **轮转均匀** | 0.1 | 0.1 | 0.8 |
|
||||
|
||||
## 代理日志保留
|
||||
|
||||
| 变量名 | 说明 | 默认值 | 范围 |
|
||||
|--------|------|--------|------|
|
||||
| `PROXY_LOG_RETENTION_DAYS` | 代理日志保留天数 | `30` | >= 0 |
|
||||
| `PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES` | 代理日志清理任务执行间隔(分钟) | `30` | >= 1 |
|
||||
|
||||
## 安全配置
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
@@ -160,16 +191,19 @@ Metapi 的路由引擎按多因子加权选择最优通道。
|
||||
|--------|------|--------|
|
||||
| `NOTIFY_COOLDOWN_SEC` | 相同告警冷却时间(秒),防止同一事件重复通知 | `300` |
|
||||
|
||||
## 运行时配置
|
||||
## 运行时设置
|
||||
|
||||
除环境变量外,以下参数可在管理后台「设置」页面中动态调整,无需重启:
|
||||
管理后台「设置」中的运行时设置与环境变量分开管理:环境变量负责启动默认值;保存后的运行时设置会持久化到当前运行数据库,并覆盖对应默认值。
|
||||
|
||||
- 路由权重参数
|
||||
- 通知渠道地址
|
||||
- SMTP 配置
|
||||
- 告警冷却时间
|
||||
| 分类 | 对应项 | 生效方式 |
|
||||
|------|--------|----------|
|
||||
| 代理访问与网络 | `PROXY_TOKEN`、`SYSTEM_PROXY_URL`、`ADMIN_IP_ALLOWLIST` | 保存后即时生效 |
|
||||
| 定时任务 | `CHECKIN_CRON`、`BALANCE_REFRESH_CRON` | 保存后即时生效 |
|
||||
| 智能路由 | `ROUTING_FALLBACK_UNIT_COST`、各路由权重 | 保存后即时生效 |
|
||||
| 通知渠道 | Webhook / Bark / Telegram / Server酱 / SMTP / `NOTIFY_COOLDOWN_SEC` | 保存后即时生效 |
|
||||
| 数据库运行配置 | `DB_TYPE`、`DB_URL`、`DB_SSL` | 保存后在下次 Metapi 后端启动时生效 |
|
||||
|
||||
运行时配置存储在 SQLite 数据库中,优先级高于环境变量默认值。
|
||||
> `TOKEN_ROUTER_CACHE_TTL_MS`、`PROXY_LOG_RETENTION_DAYS`、`PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES` 当前仍属于部署级环境变量,不在后台运行时设置里单独维护。
|
||||
|
||||
## 下一步
|
||||
|
||||
|
||||
+76
-2
@@ -4,6 +4,18 @@
|
||||
|
||||
---
|
||||
|
||||
## 支持的运行方式
|
||||
|
||||
| 场景 | 推荐方式 | 对外访问方式 | 数据位置 |
|
||||
|------|----------|--------------|----------|
|
||||
| 云服务器 / NAS / 家用主机长期运行 | Docker / Docker Compose / Zeabur | 固定服务地址,例如 `http://your-host:4000` 或反向代理域名 | 你挂载的 `DATA_DIR` / 持久化卷 |
|
||||
| 个人电脑本地使用 | 桌面版安装包 | 桌面窗口;如需本机客户端直连,使用日志中打印的 `http://127.0.0.1:<port>` | Electron `app.getPath('userData')/data` |
|
||||
| 二次开发 / 调试 | 本地开发 | 前端 `http://localhost:5173`,后端默认 `http://localhost:4000` | 仓库内 `./data` 或自定义 `DATA_DIR` |
|
||||
|
||||
> [!NOTE]
|
||||
> - 当前不再提供 `Release` 压缩包 + Node.js 运行时的独立部署路径。
|
||||
> - 生产/长期运行请用 Docker 系列方案;桌面版面向单机本地使用;源码运行请走本地开发流程。
|
||||
|
||||
## Zeabur 一键部署
|
||||
|
||||
<a href="https://zeabur.com/templates/DOX5PR">
|
||||
@@ -91,6 +103,18 @@ docker run -d --name metapi \
|
||||
2. 安装并启动 Metapi Desktop
|
||||
3. 桌面壳会自动启动本地服务并将数据保存到应用数据目录
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 适用场景 | 单机本地使用、个人电脑常驻、需要免 Docker 的桌面体验 |
|
||||
| 后端监听 | 绑定 `127.0.0.1`,默认会在 `4310..4399` 中选择空闲端口;只有设置 `METAPI_DESKTOP_SERVER_PORT` 时才固定 |
|
||||
| 管理界面 | 直接在桌面窗口打开,不建议把桌面版当成固定 `localhost:4000` 的服务来写文档或脚本 |
|
||||
| 数据目录 | `app.getPath('userData')/data` |
|
||||
| 日志目录 | `app.getPath('userData')/logs`,托盘菜单提供 `Open Logs Folder` |
|
||||
|
||||
> [!TIP]
|
||||
> - Windows 下常见路径是 `%APPDATA%\Metapi\data` 和 `%APPDATA%\Metapi\logs`。
|
||||
> - 如果你想让本机其他客户端调用桌面版内置 `/v1/*`,先从日志确认当前端口。
|
||||
|
||||
桌面版特性:
|
||||
|
||||
- 内置本地 Metapi 服务,无需手动准备 Node.js 运行环境
|
||||
@@ -100,15 +124,57 @@ docker run -d --name metapi \
|
||||
> [!NOTE]
|
||||
> 服务器部署不再提供裸 Node.js Release 压缩包,统一推荐 Docker / Docker Compose。
|
||||
|
||||
### 桌面版如何找到当前本地地址
|
||||
|
||||
桌面版启动后,后端会把当前地址打印到日志中。定位方式:
|
||||
|
||||
1. 打开托盘菜单,点击 `Open Logs Folder`
|
||||
2. 打开最新日志文件
|
||||
3. 搜索 `Dashboard:` 或 `Proxy API:` 行
|
||||
|
||||
常见日志内容如下:
|
||||
|
||||
```text
|
||||
Dashboard: http://127.0.0.1:4312
|
||||
Proxy API: http://127.0.0.1:4312/v1/chat/completions
|
||||
```
|
||||
|
||||
如果你只是通过桌面窗口使用管理后台,可以完全忽略这个端口;只有在本机其他客户端需要直连桌面版内置后端时,才需要读取这里的地址。
|
||||
|
||||
### 桌面版升级
|
||||
|
||||
1. 通过应用内更新提示安装新版本,或从 Releases 下载最新安装包覆盖安装
|
||||
2. 用户数据目录会保留,升级后自动继续使用原有数据
|
||||
3. 如需排查启动问题,优先查看 `app.getPath('userData')/logs` 下的最新日志
|
||||
|
||||
## 本地开发运行(源码调试)
|
||||
|
||||
如果你的目标是开发、调试或提交 PR,请直接跑源码:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/cita-777/metapi.git
|
||||
cd metapi
|
||||
npm install
|
||||
npm run db:migrate
|
||||
npm run dev
|
||||
```
|
||||
|
||||
默认访问地址:
|
||||
|
||||
| 服务 | 地址 |
|
||||
|------|------|
|
||||
| 前端(Vite) | `http://localhost:5173` |
|
||||
| 后端 API | `http://localhost:4000` |
|
||||
|
||||
> [!NOTE]
|
||||
> 这条路径是开发流程,不是下载 `Release` 包后再手动跑 Node.js 的替代说法。
|
||||
|
||||
---
|
||||
|
||||
## 反向代理
|
||||
|
||||
以下反向代理配置面向 Docker / 服务器模式。桌面版内置后端默认只绑定本机 `127.0.0.1`,通常不作为公网服务直接暴露。
|
||||
|
||||
### Nginx
|
||||
|
||||
流式请求(SSE)需要关闭缓冲,否则流式输出会异常:
|
||||
@@ -193,9 +259,17 @@ docker compose up -d
|
||||
|
||||
## 数据持久化
|
||||
|
||||
Metapi 的所有运行数据存储在 SQLite 数据库中,位于 `DATA_DIR`(默认 `./data`)目录下。
|
||||
不同运行方式的数据目录不同:
|
||||
|
||||
只要挂载了该目录,升级、重启都不会丢失数据。
|
||||
| 运行方式 | 数据目录 | 说明 |
|
||||
|----------|----------|------|
|
||||
| Docker / Docker Compose / Zeabur | 容器内 `DATA_DIR`(常见为 `/app/data`) | 需要映射到宿主机目录或平台持久化卷 |
|
||||
| 本地开发 | `DATA_DIR`,默认 `./data` | 位于当前仓库工作目录 |
|
||||
| 桌面版 | `app.getPath('userData')/data` | 不在仓库目录里,升级桌面应用时会保留 |
|
||||
|
||||
桌面版日志位于 `app.getPath('userData')/logs`;Docker / 本地开发模式的日志则跟随各自进程输出或你配置的日志目录。
|
||||
|
||||
只要备份了对应的数据目录,升级、重启通常都不会丢失现有配置和 SQLite 数据。
|
||||
|
||||
### 备份策略建议
|
||||
|
||||
|
||||
+16
-11
@@ -37,16 +37,20 @@
|
||||
|
||||
### Q: AnyRouter 这类魔改站点为什么更容易报错?
|
||||
|
||||
**A:** AnyRouter 为魔改实现,删除了Access Token入口,也有其他类似的魔改中转站。建议:
|
||||
**A:** AnyRouter 在 Metapi 中按 NewAPI 兼容站点处理,但不少实例会隐藏 Access Token 入口,或额外加登录盾。建议:
|
||||
|
||||
1. 先尝试 Session 模式 + Access Token
|
||||
2. 若站点不支持或报认证相关错误,再尝试 Cookie
|
||||
1. 如果站点后台能生成系统访问令牌,优先用 Session 模式 + Access Token
|
||||
2. 如果没有 Access Token 入口,或验证时被盾/认证页拦截,再改用 Cookie 导入
|
||||
3. 若返回 HTML 盾页错误,先在浏览器完成验证后再重新绑定
|
||||
|
||||
### Q: Sub2API 站点怎么处理?
|
||||
|
||||
**A:** Sub2API 常见 JWT 短期会话机制,和传统 NewAPI 站点差异较大。当前建议:
|
||||
|
||||
|
||||
1. 在「凭证模式」里选择 Session 模式,粘贴 JWT / Bearer Token 进行验证
|
||||
2. 不要使用账号密码登录,Metapi 不支持代替 Sub2API 做登录
|
||||
3. Sub2API 不支持签到;如果你只关心代理调用,也可以直接改用 API Key 模式
|
||||
4. 若 `GET /v1/models` 为空,先确认该账号下已有可用用户 API Key,Metapi 会再尝试用它发现模型
|
||||
|
||||
## 部署相关
|
||||
|
||||
@@ -61,11 +65,11 @@
|
||||
|
||||
### Q: 登录失败,提示令牌无效
|
||||
|
||||
**A:** 先确认你输入的是“管理员令牌”而不是代理令牌。登录后台使用的是 `AUTH_TOKEN`,注意:
|
||||
**A:** 先确认你输入的是管理员令牌,而不是代理令牌。登录后台使用的是 `AUTH_TOKEN`,注意:
|
||||
|
||||
- 初始管理员令牌 = 启动时设置的 `AUTH_TOKEN`
|
||||
- 如果你在非 Compose 场景未显式设置 `AUTH_TOKEN`,默认值是 `change-me-admin-token`(仅建议本地调试)
|
||||
- 若复用旧 `data/` 目录,系统会优先读取数据库中的 `auth_token` 设置(可能覆盖当前环境变量)
|
||||
- 若复用旧 SQLite `data/` 目录,或当前实例已切到 MySQL / Postgres,系统会优先读取当前运行数据库中的 `auth_token` 设置(可能覆盖当前环境变量)
|
||||
- 使用 `.env` 文件时,确认文件路径正确,且值不需要加引号
|
||||
|
||||
### Q: Docker Compose 启动报错 `AUTH_TOKEN is required`
|
||||
@@ -85,7 +89,7 @@ docker compose up -d
|
||||
**A:** 排查步骤:
|
||||
|
||||
1. 确认下载的是与你系统匹配的桌面安装包
|
||||
2. Windows 首次运行若出现“未知发布者”,先确认安装包来自官方 Releases
|
||||
2. Windows 首次运行若出现未知发布者提示,先确认安装包来自官方 Releases
|
||||
3. 如果桌面版提示后端启动失败,优先查看托盘菜单中的日志目录
|
||||
4. 如需部署到服务器,请改用 Docker / Docker Compose,而不是桌面安装包
|
||||
|
||||
@@ -108,7 +112,7 @@ docker compose up -d
|
||||
1. 未添加任何站点或账号
|
||||
2. 账号处于 `unhealthy` 状态 — 在账号管理页面检查并刷新
|
||||
3. 未同步 Token — 在 Token 管理页面点击「同步」
|
||||
4. 模型未发现 — 手动触发模型刷新
|
||||
4. 模型未发现 — 手动触发模型刷新;若近期改过站点/账号/路由,可到「设置 → 清除缓存并重建路由」或「TokenRoutes → 重建路由」后再试
|
||||
|
||||
### Q: 非流式正常,但流式输出异常(卡住、乱码、截断)
|
||||
|
||||
@@ -178,12 +182,13 @@ docker compose up -d
|
||||
|
||||
**A:** 两种方式:
|
||||
|
||||
1. **应用内导入导出**(推荐):在管理后台 → 导入/导出 页面操作,支持选择性导出
|
||||
2. **目录迁移**:直接拷贝 `data/` 目录到新环境
|
||||
1. **应用内导入导出**(推荐):在管理后台 → 导入/导出 页面操作,适合 SQLite / MySQL / Postgres / Desktop 之间迁移
|
||||
2. **目录迁移**:仅适用于当前运行在 SQLite 的实例(包括桌面版本地数据目录)
|
||||
3. **数据库原生迁移**:如果当前运行库是 MySQL / Postgres,请使用数据库自己的备份/恢复工具,而不是只拷贝 `data/`
|
||||
|
||||
### Q: 如何清理历史数据
|
||||
|
||||
**A:** 代理日志和签到日志会持续增长。在管理后台对应页面可以清理历史记录。
|
||||
**A:** 代理日志和签到日志会持续增长。在管理后台对应页面可以清理历史记录;如果还需要把路由占用统计一并归零,可到「设置 → 清除占用与使用日志」。
|
||||
|
||||
### Q: 开源发布时如何避免泄露敏感信息
|
||||
|
||||
|
||||
+49
-5
@@ -8,9 +8,17 @@
|
||||
|
||||
## 前置条件
|
||||
|
||||
- Docker 与 Docker Compose(推荐)
|
||||
- 或下载 [Release 包](https://github.com/cita-777/metapi/releases) + Node.js 20+(免 Docker 运行)
|
||||
- 或 Node.js 20+ 与 npm(本地开发)
|
||||
按你的使用场景准备对应环境:
|
||||
|
||||
| 场景 | 推荐方式 | 需要准备 |
|
||||
|------|----------|----------|
|
||||
| 云服务器 / NAS / 家用主机长期运行 | Docker / Docker Compose | Docker 与 Docker Compose |
|
||||
| 个人电脑本地使用 | 桌面版安装包 | 从 [Releases](https://github.com/cita-777/metapi/releases) 下载对应系统的桌面安装包 |
|
||||
| 二次开发 / 调试 | 本地开发 | Node.js 20+ 与 npm |
|
||||
|
||||
> [!NOTE]
|
||||
> - 当前不再把 `Release` 压缩包 + Node.js 运行时作为独立部署路径。
|
||||
> - 想直接运行成品,请用 Docker 或桌面版;想改代码,请走本地开发流程。
|
||||
|
||||
## 方式一:Docker Compose 部署(推荐)
|
||||
|
||||
@@ -68,6 +76,17 @@ docker compose up -d
|
||||
2. 安装并启动 Metapi Desktop
|
||||
3. 桌面壳会自动启动本地服务并保存数据,无需手动准备 Node.js 环境
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 管理界面 | 应用启动后会直接打开桌面窗口,不需要假设固定的 `http://localhost:4000` |
|
||||
| 本地后端地址 | 桌面版把内置服务绑定到 `127.0.0.1`,默认会在 `4310..4399` 中挑选空闲端口;只有显式设置 `METAPI_DESKTOP_SERVER_PORT` 时才会固定 |
|
||||
| 数据目录 | 保存在 `app.getPath('userData')/data`,不是仓库里的 `./data` |
|
||||
| 日志目录 | 保存在 `app.getPath('userData')/logs`;托盘菜单提供 `Open Logs Folder` |
|
||||
|
||||
> [!TIP]
|
||||
> - Windows 下常见路径是 `%APPDATA%\Metapi\data` 和 `%APPDATA%\Metapi\logs`。
|
||||
> - 如果你要把本机其他客户端接到桌面版内置后端,先到日志里查当前端口,不要写死 `4000`。
|
||||
|
||||
> [!NOTE]
|
||||
> 服务器部署统一推荐 Docker / Docker Compose,不再提供裸 Node.js 的 Release 压缩包。
|
||||
|
||||
@@ -83,6 +102,7 @@ npm run dev
|
||||
|
||||
- 前端地址:`http://localhost:5173`(Vite dev server)
|
||||
- 后端地址:`http://localhost:4000`
|
||||
- 这是源码开发流程,不是免 Docker 的成品部署包
|
||||
|
||||
## 首次使用流程
|
||||
|
||||
@@ -144,7 +164,15 @@ npm run dev
|
||||
|
||||
### 步骤 5:验证代理
|
||||
|
||||
使用 curl 快速验证:
|
||||
按运行方式选择验证入口:
|
||||
|
||||
| 运行方式 | 管理界面 | 代理接口基地址 |
|
||||
|----------|----------|----------------|
|
||||
| Docker / Docker Compose | `http://localhost:4000` | `http://localhost:4000` |
|
||||
| 本地开发 | `http://localhost:5173` | `http://localhost:4000` |
|
||||
| 桌面版 | 直接使用桌面窗口 | 先从日志里的 `Proxy API:` 行确认当前 `http://127.0.0.1:<port>` |
|
||||
|
||||
### Docker / 本地开发:直接用 curl 验证
|
||||
|
||||
```bash
|
||||
# 检查模型列表
|
||||
@@ -158,7 +186,23 @@ curl -sS http://localhost:4000/v1/chat/completions \
|
||||
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}'
|
||||
```
|
||||
|
||||
返回正常响应,说明一切就绪。
|
||||
### 桌面版:先确认当前端口再验证
|
||||
|
||||
打开托盘菜单的 `Open Logs Folder`,在最新日志里查找类似下面的启动信息:
|
||||
|
||||
```text
|
||||
Dashboard: http://127.0.0.1:4312
|
||||
Proxy API: http://127.0.0.1:4312/v1/chat/completions
|
||||
```
|
||||
|
||||
然后把日志里的实际端口替换进 curl:
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:4312/v1/models \
|
||||
-H "Authorization: Bearer your-proxy-sk-token"
|
||||
```
|
||||
|
||||
返回正常响应,说明代理链路已经可用。
|
||||
|
||||
## 下一步
|
||||
|
||||
|
||||
+12
-17
@@ -16,8 +16,8 @@ hero:
|
||||
text: 常见问题 FAQ
|
||||
link: /faq
|
||||
- theme: alt
|
||||
text: 贡献教程/FAQ
|
||||
link: /community/faq-tutorial-guidelines
|
||||
text: 文档维护与贡献
|
||||
link: /README
|
||||
|
||||
features:
|
||||
- title: 快速上手
|
||||
@@ -286,21 +286,16 @@ onBeforeUnmount(() => {
|
||||
});
|
||||
</script>
|
||||
|
||||
## 文档定位
|
||||
## 从这里开始
|
||||
|
||||
- 面向新用户:快速搭建和接入
|
||||
- 面向维护者:稳定运行、排障和升级
|
||||
- 面向贡献者:持续沉淀教程、FAQ 与最佳实践
|
||||
- 初次部署或首次接入:从 [快速上手](/getting-started) 开始,先跑通最小可用链路。
|
||||
- 准备上线或升级回滚:查看 [部署指南](/deployment) 与 [运维手册](/operations)。
|
||||
- 需要补齐环境变量或路由参数:直接查 [配置说明](/configuration)。
|
||||
- 正在处理客户端或第三方工具接入:优先看 [客户端接入](/client-integration)。
|
||||
- 遇到高频报错或异常表现:去 [常见问题 FAQ](/faq) 快速定位根因。
|
||||
|
||||
## 推荐阅读路径
|
||||
## 文档维护入口
|
||||
|
||||
1. [快速上手](/getting-started)
|
||||
2. [部署指南](/deployment)
|
||||
3. [配置说明](/configuration)
|
||||
4. [客户端接入](/client-integration)
|
||||
5. [常见问题 FAQ](/faq)
|
||||
|
||||
## 协作维护
|
||||
|
||||
如需新增文档内容,请优先使用统一模板与提交流程:
|
||||
[FAQ/教程贡献规范](/community/faq-tutorial-guidelines)
|
||||
- `/` 保持为面向所有读者的公开落地页,不再承担维护说明或贡献说明的二次首页职责。
|
||||
- 维护文档站、梳理导航、补充内容地图时,请进入 [文档维护与贡献](/README)。
|
||||
- 新增 FAQ 或教程前,请先阅读 [FAQ/教程贡献规范](/community/faq-tutorial-guidelines)。
|
||||
|
||||
+68
-5
@@ -6,9 +6,12 @@
|
||||
|
||||
## 数据备份
|
||||
|
||||
### 方式一:目录备份(推荐)
|
||||
### 方式一:目录备份(SQLite / Desktop,推荐)
|
||||
|
||||
Metapi 的所有数据存储在 `data/` 目录下的 SQLite 数据库中。最简单的备份方式:
|
||||
如果当前运行的是 SQLite,最简单的备份方式是直接备份数据目录:
|
||||
|
||||
- Docker / 本地开发:仓库内的 `data/`
|
||||
- Desktop:应用用户数据目录下的 `data/` 子目录
|
||||
|
||||
```bash
|
||||
# 手动备份
|
||||
@@ -21,9 +24,20 @@ cp -r data/ data-backup-$(date +%Y%m%d)/
|
||||
建议:
|
||||
- 每日自动备份一次
|
||||
- 保留最近 7~30 天
|
||||
- Desktop 备份前先退出应用
|
||||
- 如果当前运行库已切到 MySQL / Postgres,不能只备份 `data/`
|
||||
- 备份文件不要提交到 Git
|
||||
|
||||
### 方式二:应用内导出
|
||||
### 方式二:数据库原生备份(MySQL / PostgreSQL)
|
||||
|
||||
如果当前运行库已经切到 MySQL / Postgres,请使用数据库自己的备份工具或云快照,例如 `mysqldump`、`pg_dump`、RDS 快照等。
|
||||
|
||||
建议:
|
||||
- 升级、迁移、执行「重新初始化系统」前先做一次库级备份
|
||||
- 备份对象是当前 metapi 正在使用的运行库,而不只是本地 `data/`
|
||||
- 恢复后重启 Metapi 一次,确认它重新连接到了正确的运行库
|
||||
|
||||
### 方式三:应用内导出
|
||||
|
||||
在管理后台 → 「导入/导出」页面:
|
||||
|
||||
@@ -35,7 +49,9 @@ cp -r data/ data-backup-$(date +%Y%m%d)/
|
||||
|
||||
## 数据恢复
|
||||
|
||||
### 目录恢复
|
||||
### 目录恢复(SQLite / Desktop)
|
||||
|
||||
服务端 SQLite 可按下面流程恢复;Desktop 则先退出应用,再替换应用用户数据目录下的 `data/` 后重新启动。
|
||||
|
||||
```bash
|
||||
# 1. 停止容器
|
||||
@@ -53,6 +69,10 @@ docker compose up -d
|
||||
|
||||
在管理后台 → 「导入/导出」页面上传之前导出的 JSON 文件。系统会自动校验数据完整性。
|
||||
|
||||
### 数据库恢复(MySQL / PostgreSQL)
|
||||
|
||||
如果当前运行库是 MySQL / Postgres,请先用数据库自己的恢复流程把备份恢复到目标库,再重启 Metapi。若实例保存过运行库配置,重启后仍会优先连接该外部库,而不是自动回退到本地 SQLite。
|
||||
|
||||
## 日志排查
|
||||
|
||||
### Docker 环境
|
||||
@@ -75,6 +95,11 @@ npm run dev
|
||||
# 日志直接输出到终端
|
||||
```
|
||||
|
||||
### Desktop
|
||||
|
||||
- 优先使用托盘菜单的 `Open Logs Folder`
|
||||
- Desktop 内置后端的数据目录和日志目录都位于应用用户数据目录下
|
||||
|
||||
### 重点关注的日志
|
||||
|
||||
| 关键词 | 含义 | 处理方式 |
|
||||
@@ -101,11 +126,14 @@ curl -sS http://localhost:4000/v1/chat/completions \
|
||||
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"ping"}]}'
|
||||
```
|
||||
|
||||
以上示例默认服务端部署监听 `localhost:4000`。Desktop 内置后端运行在 `127.0.0.1` 的动态端口,优先通过应用界面、托盘日志或健康检查日志排查,不要假定固定端口。
|
||||
|
||||
### 自动化监控建议
|
||||
|
||||
- 定时请求 `/v1/models`,检查返回状态码和模型数量
|
||||
- 定时抽样请求 `/v1/chat/completions`,检查端到端可用性
|
||||
- 监控磁盘空间(SQLite WAL 日志可能增长)
|
||||
- SQLite / Desktop:监控磁盘空间(SQLite WAL 日志可能增长)
|
||||
- MySQL / Postgres:监控外部数据库空间、连接数和慢查询
|
||||
- 监控 Docker 容器状态
|
||||
|
||||
## 常见运维操作
|
||||
@@ -130,6 +158,41 @@ curl -sS http://localhost:4000/v1/chat/completions \
|
||||
- 模型刷新:重新发现所有上游模型
|
||||
- 签到:立即执行一次签到
|
||||
|
||||
### 系统代理
|
||||
|
||||
在管理后台「设置 → 系统代理」中保存全局代理地址后,只有启用了「使用系统代理」的站点才会走这条出站代理。
|
||||
|
||||
- 单个站点可在站点页直接开关
|
||||
- 站点页支持批量开启 / 关闭系统代理
|
||||
- 修改系统代理地址后,Metapi 会自动失效站点代理缓存,通常无需手工重启
|
||||
|
||||
### 清理缓存并重建路由
|
||||
|
||||
- 「设置 → 清除缓存并重建路由」会清空模型发现缓存、自动路由和自动通道,再后台触发一次模型刷新与路由重建
|
||||
- 「TokenRoutes → 重建路由」适合在调整账号、Token、路由规则后手动重新生成自动路由
|
||||
- 如果 `GET /v1/models`、路由列表或选择概率明显滞后,优先执行这一操作
|
||||
|
||||
### 批量操作
|
||||
|
||||
管理后台支持以下批量运维动作:
|
||||
|
||||
- 站点:批量启用、禁用、删除、开启系统代理、关闭系统代理
|
||||
- 账号:批量刷新余额、启用、禁用、删除
|
||||
- Token:批量启用、禁用、删除
|
||||
|
||||
批量操作完成后,界面会返回成功/失败数量;删除站点或账号后,路由相关缓存会自动失效。
|
||||
|
||||
### 重新初始化系统
|
||||
|
||||
这是高风险操作,位于「设置 → 危险操作」。
|
||||
|
||||
- 会清空当前 metapi 正在使用的全部业务数据
|
||||
- 如果当前运行在外部 MySQL / Postgres,会先清空该外部库中的 metapi 数据,再切回默认 SQLite
|
||||
- 管理员 Token 会重置为 `change-me-admin-token`
|
||||
- 当前登录会话会立即退出,页面刷新后回到首装状态
|
||||
|
||||
执行前建议先做一次导出或数据库备份。
|
||||
|
||||
## 发布前检查清单
|
||||
|
||||
如果你在本地开发并准备发布:
|
||||
|
||||
+80
-151
@@ -1,175 +1,104 @@
|
||||
# Metapi 项目目录规范
|
||||
# Metapi 项目结构
|
||||
|
||||
本文档说明 Metapi 的目录组织方式,目标是让新功能落位更稳定、查找更直接、临时文件不污染根目录。
|
||||
本文档只说明当前仓库里长期维护的主目录,帮助贡献者快速判断代码、脚本和文档应当放在哪里。
|
||||
|
||||
## 顶层目录
|
||||
|
||||
```text
|
||||
metapi/
|
||||
├── data/ # 运行时数据(SQLite 数据库)
|
||||
├── dist/ # 构建产物(前后端)
|
||||
├── docker/ # 容器相关文件
|
||||
│ ├── Dockerfile # 多阶段构建(Alpine)
|
||||
│ ├── docker-compose.yml # Docker Compose 编排
|
||||
│ └── docker-compose.override.yml # 开发覆盖配置
|
||||
├── docs/ # 文档与资源
|
||||
│ ├── .vitepress/ # VitePress 文档站配置
|
||||
│ ├── community/ # 社区贡献规范
|
||||
│ ├── logos/ # Logo 素材
|
||||
│ │ └── drafts/ # Logo 草稿
|
||||
│ ├── screenshots/ # 界面截图
|
||||
│ └── *.md # 各文档页面
|
||||
├── drizzle/ # Drizzle ORM 迁移 SQL 与元数据
|
||||
├── scripts/ # 项目脚本(按场景分组)
|
||||
│ ├── dev/
|
||||
│ │ └── restart.bat # Windows 开发环境快捷重启
|
||||
│ └── desktop/
|
||||
│ └── afterSign.mjs # Electron macOS 签名后处理 / 可选公证
|
||||
├── build/ # 打包静态资源(如 Electron 图标)
|
||||
├── data/ # 默认运行时数据目录(SQLite、日志、导出文件)
|
||||
├── dist/ # 构建产物(web / server / desktop)
|
||||
├── docker/ # Dockerfile、Compose 与部署模板
|
||||
├── docs/ # VitePress 文档、截图、Logo 与社区规范
|
||||
├── drizzle/ # Drizzle SQL 迁移与 meta 快照
|
||||
├── scripts/ # 开发脚本、桌面打包钩子、一次性 codemod
|
||||
├── src/
|
||||
│ ├── desktop/ # Electron 主进程与桌面运行时
|
||||
│ ├── server/ # Fastify 后端服务
|
||||
│ └── web/ # React 前端应用
|
||||
├── .github/workflows/ # CI/CD 工作流
|
||||
│ ├── ci.yml # 测试与构建检查
|
||||
│ ├── release.yml # 多平台 Release 包发布
|
||||
│ └── docs-pages.yml # 文档站 GitHub Pages 部署
|
||||
│ ├── server/ # Fastify 服务、数据库、代理路由与业务服务
|
||||
│ └── web/ # React 管理后台
|
||||
├── tmp/ # 临时调试文件(已 gitignore)
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── tsconfig.server.json
|
||||
├── vite.config.ts
|
||||
├── drizzle.config.ts
|
||||
└── README.md
|
||||
├── restart.bat # Windows 快捷重启入口,转发到 scripts/dev/restart.bat
|
||||
├── package.json # 脚本入口与依赖清单
|
||||
├── electron-builder.yml # 桌面打包配置
|
||||
├── drizzle.config.ts # Drizzle 配置
|
||||
├── vite.config.ts # Web 构建配置
|
||||
└── tsconfig*.json # TypeScript 配置
|
||||
```
|
||||
|
||||
## 后端目录约定(`src/server`)
|
||||
## 源码目录
|
||||
|
||||
### `src/server`
|
||||
|
||||
```text
|
||||
src/server/
|
||||
├── index.ts # 服务启动入口
|
||||
├── config.ts # 环境变量与配置加载
|
||||
├── middleware/
|
||||
│ └── auth.ts # 认证中间件
|
||||
├── db/
|
||||
│ ├── index.ts # 数据库连接
|
||||
│ ├── schema.ts # Drizzle 表定义(全部 Schema)
|
||||
│ └── migrate.ts # 迁移执行器
|
||||
├── index.ts # Fastify 启动、运行时初始化、启动摘要输出
|
||||
├── config.ts # 环境变量解析与 Fastify 配置
|
||||
├── desktop.ts # 桌面模式下的静态资源与公开路由适配
|
||||
├── nativeModuleGuard.ts # better-sqlite3 ABI 兼容检查
|
||||
├── db/ # schema、连接、迁移、兼容列修复
|
||||
├── middleware/ # 认证等通用中间件
|
||||
├── routes/
|
||||
│ ├── api/ # 管理 API 路由
|
||||
│ │ ├── auth.ts # 登录 / 登出
|
||||
│ │ ├── sites.ts # 站点 CRUD
|
||||
│ │ ├── accounts.ts # 账号管理
|
||||
│ │ ├── accountTokens.ts # Token 同步与管理
|
||||
│ │ ├── tokens.ts # Token 批量操作与路由规则管理
|
||||
│ │ ├── downstreamApiKeys.ts # 下游 API Key 管理
|
||||
│ │ ├── checkin.ts # 签到触发与日志
|
||||
│ │ ├── stats.ts # 仪表盘统计
|
||||
│ │ ├── search.ts # 全局搜索
|
||||
│ │ ├── events.ts # 事件日志
|
||||
│ │ ├── tasks.ts # 后台任务状态
|
||||
│ │ ├── settings.ts # 运行时配置
|
||||
│ │ ├── monitor.ts # 外部监控集成
|
||||
│ │ └── test.ts # 测试 / 验证端点
|
||||
│ └── proxy/ # 代理路由
|
||||
│ ├── router.ts # 代理路由注册
|
||||
│ ├── chat.ts # Chat Completions & Claude Messages
|
||||
│ ├── responses.ts # OpenAI Responses 端点
|
||||
│ ├── completions.ts # Legacy Completions
|
||||
│ ├── embeddings.ts # 向量嵌入
|
||||
│ ├── images.ts # 图像生成
|
||||
│ ├── models.ts # 模型列表
|
||||
│ ├── chatFormats.ts # OpenAI <-> Claude 格式转换
|
||||
│ ├── upstreamEndpoint.ts # 上游端点处理与透传
|
||||
│ └── downstreamPolicy.ts # 下游 API Key 策略校验
|
||||
└── services/
|
||||
├── platforms/ # 平台适配器
|
||||
│ ├── base.ts # 适配器接口定义
|
||||
│ ├── index.ts # 适配器注册表
|
||||
│ ├── newApi.ts # New API 适配器
|
||||
│ ├── oneApi.ts # One API 适配器
|
||||
│ ├── oneHub.ts # OneHub 适配器
|
||||
│ ├── doneHub.ts # DoneHub 适配器
|
||||
│ ├── veloera.ts # Veloera 适配器
|
||||
│ ├── anyrouter.ts # AnyRouter 适配器
|
||||
│ └── sub2api.ts # Sub2API 适配器
|
||||
├── tokenRouter.ts # 智能路由引擎
|
||||
├── checkinService.ts # 签到执行
|
||||
├── checkinScheduler.ts # 签到调度
|
||||
├── checkinRewardParser.ts # 奖励金额解析
|
||||
├── balanceService.ts # 余额刷新
|
||||
├── modelService.ts # 模型发现与管理
|
||||
├── modelPricingService.ts # 模型定价
|
||||
├── modelAnalysisService.ts # 使用分析
|
||||
├── notifyService.ts # 多渠道通知
|
||||
├── notificationThrottle.ts # 通知节流
|
||||
├── alertService.ts # 告警事件
|
||||
├── alertRules.ts # 告警规则
|
||||
├── backupService.ts # 数据导入导出
|
||||
├── backgroundTaskService.ts # 后台任务管理
|
||||
├── accountCredentialService.ts # 凭证加密
|
||||
├── accountHealthService.ts # 健康状态管理
|
||||
├── accountExtraConfig.ts # 平台专属配置
|
||||
├── accountTokenService.ts # Token 管理服务
|
||||
├── downstreamApiKeyService.ts # 下游 API Key 服务
|
||||
├── downstreamPolicyTypes.ts # 下游策略类型定义
|
||||
├── proxyRetryPolicy.ts # 重试策略
|
||||
├── proxyUsageParser.ts # Token 用量解析
|
||||
├── proxyUsageFallbackService.ts # 余额兜底估算
|
||||
├── failureReasonService.ts # 错误分类
|
||||
├── siteDetector.ts # 平台自动检测
|
||||
├── dailySummaryService.ts # 每日摘要
|
||||
├── todayIncomeRewardService.ts # 今日收入快照
|
||||
├── localTimeService.ts # 时区处理
|
||||
├── upstreamModelDescriptionService.ts # 上游模型描述缓存
|
||||
├── startupInfo.ts # 启动信息
|
||||
└── settings.ts # 运行时配置管理
|
||||
│ ├── api/ # 管理端 API(sites / accounts / tokens / settings ...)
|
||||
│ └── proxy/ # OpenAI / Claude / Gemini 兼容代理入口
|
||||
├── services/ # 业务服务、平台适配器、日志 / 文件 / 路由 / 迁移能力
|
||||
└── transformers/ # 协议转换与共享归一化层
|
||||
```
|
||||
|
||||
## 前端目录约定(`src/web`)
|
||||
- 新的管理接口优先放在 `routes/api/`。
|
||||
- 新的 `/v1/*`、多模态、文件、视频等兼容代理逻辑放在 `routes/proxy/`。
|
||||
- 协议格式转换或跨协议共享归一化,优先落在 `transformers/` 而不是路由文件里。
|
||||
|
||||
### `src/web`
|
||||
|
||||
```text
|
||||
src/web/
|
||||
├── App.tsx # 应用入口与路由配置
|
||||
├── main.tsx # Vite 入口
|
||||
├── api.ts # 统一 API 请求客户端
|
||||
├── authSession.ts # 认证会话管理
|
||||
├── i18n.tsx # 国际化
|
||||
├── i18n.supplement.ts # 国际化补充翻译
|
||||
├── components/ # 通用 UI 组件
|
||||
│ ├── BrandIcon.tsx # 模型品牌图标
|
||||
│ ├── ChangeKeyModal.tsx # 修改管理令牌弹窗
|
||||
│ ├── ModelAnalysisPanel.tsx # 消费分析图表
|
||||
│ ├── ModernSelect.tsx # 自定义下拉选择组件
|
||||
│ ├── SearchModal.tsx # 全局搜索弹窗
|
||||
│ ├── NotificationPanel.tsx # 实时事件面板
|
||||
│ ├── Toast.tsx # 通知提示
|
||||
│ └── charts/ # 图表组件
|
||||
│ ├── SiteDistributionChart.tsx # 余额分布饼图
|
||||
│ └── SiteTrendChart.tsx # 消费趋势图
|
||||
├── pages/ # 页面组件(路由页)
|
||||
│ ├── Dashboard.tsx # 仪表盘
|
||||
│ ├── Sites.tsx # 站点管理
|
||||
│ ├── Accounts.tsx # 账号管理
|
||||
│ ├── Tokens.tsx # Token 管理
|
||||
│ ├── TokenRoutes.tsx # 路由规则
|
||||
│ ├── Models.tsx # 模型广场
|
||||
│ ├── ModelTester.tsx # 模型操练场
|
||||
│ ├── Monitors.tsx # 可用性监控
|
||||
│ ├── CheckinLog.tsx # 签到日志
|
||||
│ ├── ProxyLogs.tsx # 代理日志
|
||||
│ ├── ProgramLogs.tsx # 系统事件日志
|
||||
│ ├── ImportExport.tsx # 数据导入导出
|
||||
│ ├── Settings.tsx # 系统设置
|
||||
│ ├── NotificationSettings.tsx # 通知设置
|
||||
│ ├── About.tsx # 关于页面
|
||||
│ └── helpers/ # 页面级纯逻辑 / 工具函数(含测试)
|
||||
└── public/ # 静态资源
|
||||
├── App.tsx # 路由与页面装配
|
||||
├── api.ts # 管理端 API 客户端
|
||||
├── authSession.ts # 登录态管理
|
||||
├── appLocalState.ts # 本地安装 / UI 状态
|
||||
├── docsLink.ts # 文档链接映射
|
||||
├── i18n*.ts* # 国际化
|
||||
├── components/ # 通用组件与图表组件
|
||||
├── pages/ # 路由页与页面级 helpers
|
||||
└── public/ # Web 静态资源
|
||||
```
|
||||
|
||||
## 目录卫生规则
|
||||
- 页面级测试和 helper 与页面代码同目录维护。
|
||||
- 通用展示组件放 `components/`;只被单页消费的纯逻辑优先放 `pages/helpers/`。
|
||||
|
||||
- 所有调试临时文件放入 `tmp/`,不要散落在项目根目录。
|
||||
- 开发脚本统一放入 `scripts/<scene>/`,根目录仅保留必要入口文件。
|
||||
- 素材草稿统一归档到 `docs/logos/drafts/`,避免根目录堆积二进制文件。
|
||||
- 测试文件与被测文件同目录(`*.test.ts`),方便就近维护。
|
||||
- 平台适配器新增时,在 `services/platforms/` 中创建独立文件并注册到 `index.ts`。
|
||||
### `src/desktop`
|
||||
|
||||
```text
|
||||
src/desktop/
|
||||
├── main.ts # Electron 主进程入口
|
||||
├── runtime.ts # 桌面运行时端口 / 路径解析
|
||||
└── runtime.test.ts # 桌面运行时测试
|
||||
```
|
||||
|
||||
## 脚本与文档目录
|
||||
|
||||
```text
|
||||
scripts/
|
||||
├── dev/ # 本地开发脚本(run-server.ts / restart.bat / db-smoke.ts)
|
||||
├── desktop/ # Electron 打包钩子(afterPack / afterSign)
|
||||
└── codemods/ # 一次性仓库级重构脚本
|
||||
```
|
||||
|
||||
```text
|
||||
docs/
|
||||
├── .vitepress/ # 文档站导航与主题配置
|
||||
├── community/ # 社区贡献规范
|
||||
├── public/ # 文档站公开静态资源
|
||||
├── logos/ # 可编辑 Logo 源文件与草稿
|
||||
├── screenshots/ # 文档截图
|
||||
└── *.md # 面向用户和维护者的文档页面
|
||||
```
|
||||
|
||||
## 放置规则
|
||||
|
||||
- 测试文件尽量与被测源码同目录,命名使用 `*.test.ts` 或 `*.test.tsx`。
|
||||
- 运行时数据放 `data/`,临时排障文件放 `tmp/`,不要散落在仓库根目录。
|
||||
- 桌面打包脚本统一放 `scripts/desktop/`,不要把一次性签名或打包命令写进根目录批处理。
|
||||
- 文档站真正对外可访问的静态资源放 `docs/public/`;仍需继续编辑的素材保留在 `docs/logos/` 或 `docs/screenshots/`。
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
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';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
|
||||
type DbModule = typeof import('../../db/index.js');
|
||||
|
||||
describe('stats proxy logs routes', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-stats-proxy-logs-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../../db/migrate.js');
|
||||
const dbModule = await import('../../db/index.js');
|
||||
const routesModule = await import('./stats.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
|
||||
app = Fastify();
|
||||
await app.register(routesModule.statsRoutes);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete(schema.proxyLogs).run();
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.sites).run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('returns paginated proxy logs with server-side filters and summary metadata', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'proxy-site',
|
||||
url: 'https://proxy-site.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'proxy-user',
|
||||
accessToken: 'proxy-token',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const timestamps = [
|
||||
formatUtcSqlDateTime(new Date('2026-03-09T08:00:00.000Z')),
|
||||
formatUtcSqlDateTime(new Date('2026-03-09T08:01:00.000Z')),
|
||||
formatUtcSqlDateTime(new Date('2026-03-09T08:02:00.000Z')),
|
||||
formatUtcSqlDateTime(new Date('2026-03-09T08:03:00.000Z')),
|
||||
];
|
||||
|
||||
await db.insert(schema.proxyLogs).values([
|
||||
{
|
||||
accountId: account.id,
|
||||
modelRequested: 'gpt-4o',
|
||||
modelActual: 'gpt-4o',
|
||||
status: 'success',
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
totalTokens: 15,
|
||||
estimatedCost: 0.1,
|
||||
createdAt: timestamps[0],
|
||||
billingDetails: JSON.stringify({ id: 'success-gpt' }),
|
||||
},
|
||||
{
|
||||
accountId: account.id,
|
||||
modelRequested: 'gpt-4o-mini',
|
||||
modelActual: 'gpt-4o-mini',
|
||||
status: 'failed',
|
||||
promptTokens: 8,
|
||||
completionTokens: 2,
|
||||
totalTokens: 10,
|
||||
estimatedCost: 0.2,
|
||||
createdAt: timestamps[1],
|
||||
billingDetails: JSON.stringify({ id: 'failed-gpt' }),
|
||||
},
|
||||
{
|
||||
accountId: account.id,
|
||||
modelRequested: 'gpt-4.1',
|
||||
modelActual: 'gpt-4.1',
|
||||
status: 'retried',
|
||||
promptTokens: 20,
|
||||
completionTokens: 4,
|
||||
totalTokens: 24,
|
||||
estimatedCost: 0.3,
|
||||
createdAt: timestamps[2],
|
||||
billingDetails: JSON.stringify({ id: 'retried-gpt' }),
|
||||
},
|
||||
{
|
||||
accountId: account.id,
|
||||
modelRequested: 'claude-3-7-sonnet',
|
||||
modelActual: 'claude-3-7-sonnet',
|
||||
status: 'success',
|
||||
promptTokens: 40,
|
||||
completionTokens: 10,
|
||||
totalTokens: 50,
|
||||
estimatedCost: 0.4,
|
||||
createdAt: timestamps[3],
|
||||
billingDetails: JSON.stringify({ id: 'success-claude' }),
|
||||
},
|
||||
]).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/stats/proxy-logs?limit=1&offset=1&status=failed&search=gpt',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as {
|
||||
items: Array<Record<string, unknown>>;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
summary: {
|
||||
totalCount: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
totalCost: number;
|
||||
totalTokensAll: number;
|
||||
};
|
||||
};
|
||||
|
||||
expect(body.page).toBe(2);
|
||||
expect(body.pageSize).toBe(1);
|
||||
expect(body.total).toBe(2);
|
||||
expect(body.items).toHaveLength(1);
|
||||
expect(body.items[0]?.modelRequested).toBe('gpt-4o-mini');
|
||||
expect(body.items[0]?.status).toBe('failed');
|
||||
expect(body.items[0]).not.toHaveProperty('billingDetails');
|
||||
expect(body.summary).toEqual({
|
||||
totalCount: 3,
|
||||
successCount: 1,
|
||||
failedCount: 2,
|
||||
totalCost: 0.6,
|
||||
totalTokensAll: 49,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a single proxy log detail with parsed billing details', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'detail-site',
|
||||
url: 'https://detail-site.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'detail-user',
|
||||
accessToken: 'detail-token',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const inserted = await db.insert(schema.proxyLogs).values({
|
||||
accountId: account.id,
|
||||
modelRequested: 'gpt-5',
|
||||
modelActual: 'gpt-5',
|
||||
status: 'success',
|
||||
promptTokens: 100,
|
||||
completionTokens: 20,
|
||||
totalTokens: 120,
|
||||
estimatedCost: 0.12,
|
||||
errorMessage: 'downstream: /v1/chat upstream: /api/chat',
|
||||
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T08:05:00.000Z')),
|
||||
billingDetails: JSON.stringify({
|
||||
breakdown: { totalCost: 0.12 },
|
||||
usage: { promptTokens: 100, completionTokens: 20 },
|
||||
}),
|
||||
}).run();
|
||||
|
||||
const logId = Number(inserted.lastInsertRowid || 0);
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/stats/proxy-logs/${logId}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as {
|
||||
id: number;
|
||||
siteName: string | null;
|
||||
username: string | null;
|
||||
billingDetails: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
expect(body.id).toBe(logId);
|
||||
expect(body.siteName).toBe('detail-site');
|
||||
expect(body.username).toBe('detail-user');
|
||||
expect(body.billingDetails).toMatchObject({
|
||||
breakdown: { totalCost: 0.12 },
|
||||
usage: { promptTokens: 100, completionTokens: 20 },
|
||||
});
|
||||
});
|
||||
});
|
||||
+163
-15
@@ -73,6 +73,86 @@ function proxyCostSqlExpression() {
|
||||
`;
|
||||
}
|
||||
|
||||
type ProxyLogStatusFilter = 'all' | 'success' | 'failed';
|
||||
|
||||
function normalizeProxyLogPageSize(raw?: string): number {
|
||||
const parsed = Number.parseInt(raw || '50', 10);
|
||||
if (!Number.isFinite(parsed)) return 50;
|
||||
return Math.max(1, Math.min(100, parsed));
|
||||
}
|
||||
|
||||
function normalizeProxyLogOffset(raw?: string): number {
|
||||
const parsed = Number.parseInt(raw || '0', 10);
|
||||
if (!Number.isFinite(parsed)) return 0;
|
||||
return Math.max(0, parsed);
|
||||
}
|
||||
|
||||
function normalizeProxyLogStatusFilter(raw?: string): ProxyLogStatusFilter {
|
||||
const normalized = (raw || '').trim().toLowerCase();
|
||||
if (normalized === 'success') return 'success';
|
||||
if (normalized === 'failed') return 'failed';
|
||||
return 'all';
|
||||
}
|
||||
|
||||
function normalizeProxyLogSearch(raw?: string): string {
|
||||
return (raw || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function buildProxyLogSearchCondition(search: string) {
|
||||
if (!search) return null;
|
||||
const likeTerm = `%${search}%`;
|
||||
return sql<boolean>`(
|
||||
lower(coalesce(${schema.proxyLogs.modelRequested}, '')) like ${likeTerm}
|
||||
or lower(coalesce(${schema.proxyLogs.modelActual}, '')) like ${likeTerm}
|
||||
)`;
|
||||
}
|
||||
|
||||
function buildProxyLogStatusCondition(status: ProxyLogStatusFilter) {
|
||||
if (status === 'success') {
|
||||
return eq(schema.proxyLogs.status, 'success');
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return sql<boolean>`coalesce(${schema.proxyLogs.status}, '') <> 'success'`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildProxyLogWhereClause(params: {
|
||||
status?: ProxyLogStatusFilter;
|
||||
search?: string;
|
||||
}) {
|
||||
const conditions = [
|
||||
params.status ? buildProxyLogStatusCondition(params.status) : null,
|
||||
params.search ? buildProxyLogSearchCondition(params.search) : null,
|
||||
].filter((condition): condition is NonNullable<typeof condition> => condition !== null);
|
||||
|
||||
if (conditions.length === 0) return undefined;
|
||||
return conditions.length === 1 ? conditions[0] : and(...conditions);
|
||||
}
|
||||
|
||||
function toRoundedMicroNumber(value: number | null | undefined): number {
|
||||
return Math.round(Number(value || 0) * 1_000_000) / 1_000_000;
|
||||
}
|
||||
|
||||
function mapProxyLogRow(
|
||||
row: {
|
||||
proxy_logs: Record<string, unknown> & { billingDetails?: string | null };
|
||||
accounts: { username?: string | null } | null;
|
||||
sites: { name?: string | null; url?: string | null } | null;
|
||||
},
|
||||
options?: { includeBillingDetails?: boolean },
|
||||
) {
|
||||
return {
|
||||
...row.proxy_logs,
|
||||
...(options?.includeBillingDetails
|
||||
? { billingDetails: parseProxyLogBillingDetails(row.proxy_logs.billingDetails) }
|
||||
: {}),
|
||||
username: row.accounts?.username || null,
|
||||
siteName: row.sites?.name || null,
|
||||
siteUrl: row.sites?.url || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function statsRoutes(app: FastifyInstance) {
|
||||
const proxyLogBaseFields = getProxyLogBaseSelectFields();
|
||||
|
||||
@@ -204,10 +284,80 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// Proxy logs
|
||||
app.get<{ Querystring: { limit?: string; offset?: string } }>('/api/stats/proxy-logs', async (request) => {
|
||||
const limit = parseInt(request.query.limit || '50', 10);
|
||||
const offset = parseInt(request.query.offset || '0', 10);
|
||||
const rows = await withProxyLogSelectFields(({ fields }) => (
|
||||
app.get<{ Querystring: { limit?: string; offset?: string; status?: string; search?: string } }>('/api/stats/proxy-logs', async (request) => {
|
||||
const limit = normalizeProxyLogPageSize(request.query.limit);
|
||||
const offset = normalizeProxyLogOffset(request.query.offset);
|
||||
const status = normalizeProxyLogStatusFilter(request.query.status);
|
||||
const search = normalizeProxyLogSearch(request.query.search);
|
||||
const listWhere = buildProxyLogWhereClause({ status, search });
|
||||
const summaryWhere = buildProxyLogWhereClause({ search });
|
||||
|
||||
const listRows = await withProxyLogSelectFields(({ fields }) => {
|
||||
let query = db.select({
|
||||
proxy_logs: fields,
|
||||
accounts: schema.accounts,
|
||||
sites: schema.sites,
|
||||
}).from(schema.proxyLogs)
|
||||
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
|
||||
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id));
|
||||
|
||||
if (listWhere) {
|
||||
query = query.where(listWhere) as typeof query;
|
||||
}
|
||||
|
||||
return query
|
||||
.orderBy(desc(schema.proxyLogs.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all();
|
||||
}, { includeBillingDetails: false }) as Array<{
|
||||
proxy_logs: Record<string, unknown> & { billingDetails?: string | null };
|
||||
accounts: { username?: string | null } | null;
|
||||
sites: { name?: string | null; url?: string | null } | null;
|
||||
}>;
|
||||
|
||||
let totalQuery = db.select({
|
||||
total: sql<number>`count(*)`,
|
||||
}).from(schema.proxyLogs);
|
||||
if (listWhere) {
|
||||
totalQuery = totalQuery.where(listWhere) as typeof totalQuery;
|
||||
}
|
||||
const totalRow = await totalQuery.get();
|
||||
|
||||
let summaryQuery = db.select({
|
||||
totalCount: sql<number>`count(*)`,
|
||||
successCount: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
|
||||
failedCount: sql<number>`coalesce(sum(case when coalesce(${schema.proxyLogs.status}, '') <> 'success' then 1 else 0 end), 0)`,
|
||||
totalCost: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`,
|
||||
totalTokensAll: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`,
|
||||
}).from(schema.proxyLogs);
|
||||
if (summaryWhere) {
|
||||
summaryQuery = summaryQuery.where(summaryWhere) as typeof summaryQuery;
|
||||
}
|
||||
const summaryRow = await summaryQuery.get();
|
||||
|
||||
return {
|
||||
items: listRows.map((row) => mapProxyLogRow(row)),
|
||||
total: Number(totalRow?.total || 0),
|
||||
page: Math.floor(offset / limit) + 1,
|
||||
pageSize: limit,
|
||||
summary: {
|
||||
totalCount: Number(summaryRow?.totalCount || 0),
|
||||
successCount: Number(summaryRow?.successCount || 0),
|
||||
failedCount: Number(summaryRow?.failedCount || 0),
|
||||
totalCost: toRoundedMicroNumber(summaryRow?.totalCost),
|
||||
totalTokensAll: Number(summaryRow?.totalTokensAll || 0),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/stats/proxy-logs/:id', async (request, reply) => {
|
||||
const id = Number.parseInt(request.params.id, 10);
|
||||
if (!Number.isFinite(id) || id <= 0) {
|
||||
return reply.code(400).send({ message: 'proxy log id is invalid' });
|
||||
}
|
||||
|
||||
const row = await withProxyLogSelectFields(({ fields }) => (
|
||||
db.select({
|
||||
proxy_logs: fields,
|
||||
accounts: schema.accounts,
|
||||
@@ -215,21 +365,19 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
}).from(schema.proxyLogs)
|
||||
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
|
||||
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.orderBy(desc(schema.proxyLogs.createdAt))
|
||||
.limit(limit).offset(offset).all()
|
||||
), { includeBillingDetails: true }) as Array<{
|
||||
.where(eq(schema.proxyLogs.id, id))
|
||||
.get()
|
||||
), { includeBillingDetails: true }) as {
|
||||
proxy_logs: Record<string, unknown> & { billingDetails?: string | null };
|
||||
accounts: { username?: string | null } | null;
|
||||
sites: { name?: string | null; url?: string | null } | null;
|
||||
}>;
|
||||
} | undefined;
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row.proxy_logs,
|
||||
billingDetails: parseProxyLogBillingDetails(row.proxy_logs.billingDetails),
|
||||
username: row.accounts?.username || null,
|
||||
siteName: row.sites?.name || null,
|
||||
siteUrl: row.sites?.url || null,
|
||||
}));
|
||||
if (!row) {
|
||||
return reply.code(404).send({ message: 'proxy log not found' });
|
||||
}
|
||||
|
||||
return mapProxyLogRow(row, { includeBillingDetails: true });
|
||||
});
|
||||
|
||||
// Models marketplace - refresh upstream models and aggregate.
|
||||
|
||||
@@ -501,7 +501,6 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
type: 'input_file',
|
||||
file_id: 'file_local_123',
|
||||
filename: 'paper.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
file_data: 'JVBERi0xLjQK',
|
||||
},
|
||||
],
|
||||
@@ -707,7 +706,6 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
type: 'input_file',
|
||||
filename: 'notes.txt',
|
||||
file_data: 'aGVsbG8=',
|
||||
mime_type: 'text/plain',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -72,7 +72,6 @@ describe('proxyInputFileResolver', () => {
|
||||
file_id: 'file-metapi-123',
|
||||
filename: 'brief.pdf',
|
||||
file_data: Buffer.from('%PDF-local').toString('base64'),
|
||||
mime_type: 'application/pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -214,7 +214,6 @@ function toResponsesResolvedBlock(file: { fileId?: string; filename: string; fil
|
||||
...(file.fileId ? { file_id: file.fileId } : {}),
|
||||
filename: file.filename,
|
||||
file_data: file.fileData,
|
||||
mime_type: file.mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -427,7 +427,6 @@ describe('convertOpenAiBodyToResponsesBody', () => {
|
||||
type: 'input_file',
|
||||
file_id: 'file_local_123',
|
||||
filename: 'report.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
file_data: 'JVBERi0xLjQK',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -96,8 +96,6 @@ export function toResponsesInputFileBlock(file: NormalizedInputFile): Record<str
|
||||
if (file.fileId) block.file_id = file.fileId;
|
||||
if (file.fileData) block.file_data = parsedDataUrl?.data || file.fileData;
|
||||
if (file.filename) block.filename = file.filename;
|
||||
if (file.mimeType) block.mime_type = file.mimeType;
|
||||
else if (parsedDataUrl?.mimeType) block.mime_type = parsedDataUrl.mimeType;
|
||||
return block;
|
||||
}
|
||||
|
||||
|
||||
+95
-1
@@ -84,6 +84,17 @@ async function request(url: string, options: RequestOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildQueryString(params?: Record<string, string | number | boolean | null | undefined>) {
|
||||
if (!params) return '';
|
||||
const searchParams = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
searchParams.set(key, String(value));
|
||||
}
|
||||
const serialized = searchParams.toString();
|
||||
return serialized ? `?${serialized}` : '';
|
||||
}
|
||||
|
||||
type TestChatRequestPayload = {
|
||||
model: string;
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
@@ -130,6 +141,88 @@ export type ProxyTestJobResponse = {
|
||||
expiresAt?: string;
|
||||
};
|
||||
|
||||
export type ProxyLogStatusFilter = 'all' | 'success' | 'failed';
|
||||
|
||||
export type ProxyLogBillingDetails = {
|
||||
quotaType: number;
|
||||
usage: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
billablePromptTokens: number;
|
||||
promptTokensIncludeCache: boolean | null;
|
||||
};
|
||||
pricing: {
|
||||
modelRatio: number;
|
||||
completionRatio: number;
|
||||
cacheRatio: number;
|
||||
cacheCreationRatio: number;
|
||||
groupRatio: number;
|
||||
};
|
||||
breakdown: {
|
||||
inputPerMillion: number;
|
||||
outputPerMillion: number;
|
||||
cacheReadPerMillion: number;
|
||||
cacheCreationPerMillion: number;
|
||||
inputCost: number;
|
||||
outputCost: number;
|
||||
cacheReadCost: number;
|
||||
cacheCreationCost: number;
|
||||
totalCost: number;
|
||||
};
|
||||
} | null;
|
||||
|
||||
export type ProxyLogListItem = {
|
||||
id: number;
|
||||
createdAt: string;
|
||||
modelRequested: string;
|
||||
modelActual: string;
|
||||
status: string;
|
||||
latencyMs: number;
|
||||
totalTokens: number | null;
|
||||
retryCount: number;
|
||||
accountId?: number | null;
|
||||
username?: string | null;
|
||||
siteName?: string | null;
|
||||
siteUrl?: string | null;
|
||||
errorMessage?: string | null;
|
||||
promptTokens?: number | null;
|
||||
completionTokens?: number | null;
|
||||
estimatedCost?: number | null;
|
||||
};
|
||||
|
||||
export type ProxyLogDetail = ProxyLogListItem & {
|
||||
routeId?: number | null;
|
||||
channelId?: number | null;
|
||||
httpStatus?: number | null;
|
||||
billingDetails?: ProxyLogBillingDetails;
|
||||
};
|
||||
|
||||
export type ProxyLogsSummary = {
|
||||
totalCount: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
totalCost: number;
|
||||
totalTokensAll: number;
|
||||
};
|
||||
|
||||
export type ProxyLogsQuery = {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
status?: ProxyLogStatusFilter;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export type ProxyLogsResponse = {
|
||||
items: ProxyLogListItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
summary: ProxyLogsSummary;
|
||||
};
|
||||
|
||||
export const api = {
|
||||
// Sites
|
||||
getSites: () => request('/api/sites'),
|
||||
@@ -221,7 +314,8 @@ export const api = {
|
||||
|
||||
// Stats
|
||||
getDashboard: () => request('/api/stats/dashboard'),
|
||||
getProxyLogs: (params?: string) => request(`/api/stats/proxy-logs${params ? '?' + params : ''}`),
|
||||
getProxyLogs: (params?: ProxyLogsQuery) => request(`/api/stats/proxy-logs${buildQueryString(params)}`) as Promise<ProxyLogsResponse>,
|
||||
getProxyLogDetail: (id: number) => request(`/api/stats/proxy-logs/${id}`) as Promise<ProxyLogDetail>,
|
||||
checkModels: (accountId: number) => request(`/api/models/check/${accountId}`, { method: 'POST' }),
|
||||
getSiteDistribution: () => request('/api/stats/site-distribution'),
|
||||
getSiteTrend: (days = 7) => request(`/api/stats/site-trend?days=${days}`),
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import ProxyLogs from './ProxyLogs.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getProxyLogs: vi.fn(),
|
||||
getProxyLogDetail: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.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();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function buildListResponse(overrides?: Partial<{
|
||||
items: any[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
summary: {
|
||||
totalCount: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
totalCost: number;
|
||||
totalTokensAll: number;
|
||||
};
|
||||
}>) {
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
id: 101,
|
||||
createdAt: '2026-03-09 16:00:00',
|
||||
modelRequested: 'gpt-4o',
|
||||
modelActual: 'gpt-4o',
|
||||
status: 'success',
|
||||
latencyMs: 120,
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
totalTokens: 15,
|
||||
retryCount: 0,
|
||||
estimatedCost: 1.23,
|
||||
errorMessage: 'downstream: /v1/chat upstream: /api/chat',
|
||||
username: 'tester',
|
||||
siteName: 'main-site',
|
||||
siteUrl: 'https://main-site.example.com',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
summary: {
|
||||
totalCount: 12,
|
||||
successCount: 8,
|
||||
failedCount: 4,
|
||||
totalCost: 1.23,
|
||||
totalTokensAll: 15,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ProxyLogs server-driven page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getProxyLogs.mockResolvedValue(buildListResponse());
|
||||
apiMock.getProxyLogDetail.mockResolvedValue({
|
||||
id: 101,
|
||||
createdAt: '2026-03-09 16:00:00',
|
||||
modelRequested: 'gpt-4o',
|
||||
modelActual: 'gpt-4o',
|
||||
status: 'success',
|
||||
latencyMs: 120,
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
totalTokens: 15,
|
||||
retryCount: 0,
|
||||
estimatedCost: 1.23,
|
||||
errorMessage: 'downstream: /v1/chat upstream: /api/chat',
|
||||
username: 'tester',
|
||||
siteName: 'main-site',
|
||||
siteUrl: 'https://main-site.example.com',
|
||||
billingDetails: {
|
||||
breakdown: {
|
||||
inputPerMillion: 1,
|
||||
outputPerMillion: 2,
|
||||
cacheReadPerMillion: 0,
|
||||
cacheCreationPerMillion: 0,
|
||||
inputCost: 0.1,
|
||||
outputCost: 0.2,
|
||||
cacheReadCost: 0,
|
||||
cacheCreationCost: 0,
|
||||
totalCost: 0.3,
|
||||
},
|
||||
pricing: {
|
||||
modelRatio: 1,
|
||||
completionRatio: 1,
|
||||
cacheRatio: 0,
|
||||
cacheCreationRatio: 0,
|
||||
groupRatio: 1,
|
||||
},
|
||||
usage: {
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
totalTokens: 15,
|
||||
cacheReadTokens: 0,
|
||||
cacheCreationTokens: 0,
|
||||
billablePromptTokens: 10,
|
||||
promptTokensIncludeCache: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('requests paginated data from the server and renders server summary counts', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<ProxyLogs />
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.getProxyLogs).toHaveBeenCalledWith({
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
status: 'all',
|
||||
search: '',
|
||||
});
|
||||
|
||||
const text = collectText(root!.root);
|
||||
expect(text).toContain('消耗总额 $1.2300');
|
||||
expect(text).toContain('全部 12');
|
||||
expect(text).toContain('成功 8');
|
||||
expect(text).toContain('失败 4');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('re-queries the server for status and search changes instead of filtering locally', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<ProxyLogs />
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const failedTab = root!.root.findAll((node) => (
|
||||
node.type === 'button' && collectText(node).includes('失败')
|
||||
))[0];
|
||||
await act(async () => {
|
||||
failedTab.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const searchInput = root!.root.find((node) => (
|
||||
node.type === 'input' && node.props.placeholder === '搜索模型名称...'
|
||||
));
|
||||
await act(async () => {
|
||||
searchInput.props.onChange({ target: { value: 'mini' } });
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.getProxyLogs).toHaveBeenNthCalledWith(2, {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
status: 'failed',
|
||||
search: '',
|
||||
});
|
||||
expect(apiMock.getProxyLogs).toHaveBeenLastCalledWith({
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
status: 'failed',
|
||||
search: 'mini',
|
||||
});
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('loads detail on first expand and reuses the cached detail on re-expand', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<ProxyLogs />
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const row = root!.root.find((node) => (
|
||||
node.type === 'tr' && node.props['data-testid'] === 'proxy-log-row-101'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
row.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.getProxyLogDetail).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
row.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
await act(async () => {
|
||||
row.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.getProxyLogDetail).toHaveBeenCalledTimes(1);
|
||||
expect(apiMock.getProxyLogDetail).toHaveBeenCalledWith(101);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
+169
-152
@@ -1,5 +1,12 @@
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import React, { useDeferredValue, useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import {
|
||||
api,
|
||||
type ProxyLogBillingDetails,
|
||||
type ProxyLogDetail,
|
||||
type ProxyLogListItem,
|
||||
type ProxyLogsSummary,
|
||||
type ProxyLogStatusFilter,
|
||||
} from '../api.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import { ModelBadge } from '../components/BrandIcon.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
@@ -7,58 +14,28 @@ import ModernSelect from '../components/ModernSelect.js';
|
||||
import { parseProxyLogPathMeta } from './helpers/proxyLogPathMeta.js';
|
||||
import { tr } from '../i18n.js';
|
||||
|
||||
type StatusFilter = 'all' | 'success' | 'failed';
|
||||
|
||||
interface ProxyLog {
|
||||
id: number;
|
||||
createdAt: string;
|
||||
modelRequested: string;
|
||||
modelActual: string;
|
||||
status: string;
|
||||
latencyMs: number;
|
||||
totalTokens: number | null;
|
||||
retryCount: number;
|
||||
accountId?: number;
|
||||
type ProxyLogRenderItem = ProxyLogListItem & {
|
||||
billingDetails?: ProxyLogBillingDetails;
|
||||
username?: string | null;
|
||||
siteName?: string | null;
|
||||
siteUrl?: string | null;
|
||||
errorMessage?: string;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
estimatedCost?: number;
|
||||
billingDetails?: {
|
||||
quotaType: number;
|
||||
usage: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheCreationTokens: number;
|
||||
billablePromptTokens: number;
|
||||
promptTokensIncludeCache: boolean | null;
|
||||
};
|
||||
pricing: {
|
||||
modelRatio: number;
|
||||
completionRatio: number;
|
||||
cacheRatio: number;
|
||||
cacheCreationRatio: number;
|
||||
groupRatio: number;
|
||||
};
|
||||
breakdown: {
|
||||
inputPerMillion: number;
|
||||
outputPerMillion: number;
|
||||
cacheReadPerMillion: number;
|
||||
cacheCreationPerMillion: number;
|
||||
inputCost: number;
|
||||
outputCost: number;
|
||||
cacheReadCost: number;
|
||||
cacheCreationCost: number;
|
||||
totalCost: number;
|
||||
};
|
||||
} | null;
|
||||
}
|
||||
errorMessage?: string | null;
|
||||
};
|
||||
|
||||
type ProxyLogDetailState = {
|
||||
loading: boolean;
|
||||
data?: ProxyLogDetail;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const PAGE_SIZES = [20, 50, 100];
|
||||
const EMPTY_SUMMARY: ProxyLogsSummary = {
|
||||
totalCount: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
totalCost: 0,
|
||||
totalTokensAll: 0,
|
||||
};
|
||||
|
||||
function formatLatency(ms: number) {
|
||||
if (ms >= 1000) {
|
||||
@@ -92,13 +69,13 @@ function formatPerMillionPrice(value: number) {
|
||||
return `$${formatCompactNumber(value)} / 1M tokens`;
|
||||
}
|
||||
|
||||
function formatBillingDetailSummary(log: ProxyLog) {
|
||||
function formatBillingDetailSummary(log: ProxyLogRenderItem) {
|
||||
const detail = log.billingDetails;
|
||||
if (!detail) return null;
|
||||
return `模型倍率 ${formatCompactNumber(detail.pricing.modelRatio)},输出倍率 ${formatCompactNumber(detail.pricing.completionRatio)},缓存倍率 ${formatCompactNumber(detail.pricing.cacheRatio)},缓存创建倍率 ${formatCompactNumber(detail.pricing.cacheCreationRatio)},分组倍率 ${formatCompactNumber(detail.pricing.groupRatio)}`;
|
||||
}
|
||||
|
||||
function buildBillingProcessLines(log: ProxyLog) {
|
||||
function buildBillingProcessLines(log: ProxyLogRenderItem) {
|
||||
const detail = log.billingDetails;
|
||||
if (!detail) return [];
|
||||
|
||||
@@ -134,73 +111,111 @@ function buildBillingProcessLines(log: ProxyLog) {
|
||||
}
|
||||
|
||||
export default function ProxyLogs() {
|
||||
const [logs, setLogs] = useState<ProxyLog[]>([]);
|
||||
const [logs, setLogs] = useState<ProxyLogListItem[]>([]);
|
||||
const [summary, setSummary] = useState<ProxyLogsSummary>(EMPTY_SUMMARY);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<ProxyLogStatusFilter>('all');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const deferredSearchInput = useDeferredValue(searchInput.trim());
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [expanded, setExpanded] = useState<number | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [detailById, setDetailById] = useState<Record<number, ProxyLogDetailState>>({});
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
setSearchQuery(deferredSearchInput);
|
||||
}, [deferredSearchInput]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const currentOffset = (safePage - 1) * pageSize;
|
||||
const displayedStart = total === 0 ? 0 : currentOffset + 1;
|
||||
const displayedEnd = total === 0 ? 0 : Math.min(currentOffset + logs.length, total);
|
||||
|
||||
const pageNumbers = useMemo(() => (
|
||||
Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
|
||||
if (totalPages <= 7) return i + 1;
|
||||
if (safePage <= 4) return i + 1;
|
||||
if (safePage >= totalPages - 3) return totalPages - 6 + i;
|
||||
return safePage - 3 + i;
|
||||
})
|
||||
), [safePage, totalPages]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.getProxyLogs('limit=500');
|
||||
setLogs(data);
|
||||
const data = await api.getProxyLogs({
|
||||
limit: pageSize,
|
||||
offset: currentOffset,
|
||||
status: statusFilter,
|
||||
search: searchQuery,
|
||||
});
|
||||
setLogs(Array.isArray(data.items) ? data.items : []);
|
||||
setTotal(Number(data.total || 0));
|
||||
setSummary(data.summary || EMPTY_SUMMARY);
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '加载日志失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [currentOffset, pageSize, searchQuery, statusFilter, toast]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
/* ---- derived ---- */
|
||||
const filtered = useMemo(() => logs.filter(log => {
|
||||
if (statusFilter === 'success' && log.status !== 'success') return false;
|
||||
if (statusFilter === 'failed' && log.status === 'success') return false;
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
return (log.modelRequested || '').toLowerCase().includes(q) ||
|
||||
(log.modelActual || '').toLowerCase().includes(q);
|
||||
}
|
||||
return true;
|
||||
}), [logs, statusFilter, search]);
|
||||
useEffect(() => {
|
||||
if (page <= totalPages) return;
|
||||
setPage(totalPages);
|
||||
}, [page, totalPages]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const paged = useMemo(
|
||||
() => filtered.slice((safePage - 1) * pageSize, safePage * pageSize),
|
||||
[filtered, safePage, pageSize],
|
||||
);
|
||||
|
||||
useEffect(() => { setPage(1); }, [statusFilter, search, pageSize]);
|
||||
|
||||
/* ---- stats ---- */
|
||||
const summary = useMemo(() => {
|
||||
let successCount = 0;
|
||||
let totalCost = 0;
|
||||
let totalTokensAll = 0;
|
||||
for (const log of logs) {
|
||||
if (log.status === 'success') successCount += 1;
|
||||
totalCost += log.estimatedCost || 0;
|
||||
totalTokensAll += log.totalTokens || 0;
|
||||
}
|
||||
const totalCount = logs.length;
|
||||
return {
|
||||
totalCount,
|
||||
successCount,
|
||||
failedCount: totalCount - successCount,
|
||||
totalCost,
|
||||
totalTokensAll,
|
||||
};
|
||||
useEffect(() => {
|
||||
setExpanded((current) => (
|
||||
current !== null && logs.some((log) => log.id === current)
|
||||
? current
|
||||
: null
|
||||
));
|
||||
}, [logs]);
|
||||
|
||||
const loadDetail = useCallback(async (id: number) => {
|
||||
const existing = detailById[id];
|
||||
if (existing?.loading || existing?.data) return;
|
||||
|
||||
setDetailById((current) => ({
|
||||
...current,
|
||||
[id]: { loading: true },
|
||||
}));
|
||||
|
||||
try {
|
||||
const data = await api.getProxyLogDetail(id);
|
||||
setDetailById((current) => ({
|
||||
...current,
|
||||
[id]: { loading: false, data },
|
||||
}));
|
||||
} catch (e: any) {
|
||||
const message = e?.message || '加载日志详情失败';
|
||||
setDetailById((current) => ({
|
||||
...current,
|
||||
[id]: { loading: false, error: message },
|
||||
}));
|
||||
toast.error(message);
|
||||
}
|
||||
}, [detailById, toast]);
|
||||
|
||||
const handleToggleExpand = useCallback((id: number) => {
|
||||
const shouldExpand = expanded !== id;
|
||||
setExpanded(shouldExpand ? id : null);
|
||||
if (shouldExpand) {
|
||||
void loadDetail(id);
|
||||
}
|
||||
}, [expanded, loadDetail]);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Header + Stat Badges */}
|
||||
<div className="page-header" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<h2 className="page-title">{tr('使用日志')}</h2>
|
||||
@@ -219,18 +234,20 @@ export default function ProxyLogs() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar: Filter + Search */}
|
||||
<div className="toolbar" style={{ marginBottom: 12 }}>
|
||||
<div className="pill-tabs">
|
||||
{([
|
||||
{ key: 'all' as StatusFilter, label: '全部', count: summary.totalCount },
|
||||
{ key: 'success' as StatusFilter, label: '成功', count: summary.successCount },
|
||||
{ key: 'failed' as StatusFilter, label: '失败', count: summary.failedCount },
|
||||
]).map(tab => (
|
||||
{ key: 'all' as ProxyLogStatusFilter, label: '全部', count: summary.totalCount },
|
||||
{ key: 'success' as ProxyLogStatusFilter, label: '成功', count: summary.successCount },
|
||||
{ key: 'failed' as ProxyLogStatusFilter, label: '失败', count: summary.failedCount },
|
||||
]).map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`pill-tab ${statusFilter === tab.key ? 'active' : ''}`}
|
||||
onClick={() => setStatusFilter(tab.key)}
|
||||
onClick={() => {
|
||||
setStatusFilter(tab.key);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{tab.label} <span style={{ fontVariantNumeric: 'tabular-nums', opacity: 0.7 }}>{tab.count}</span>
|
||||
</button>
|
||||
@@ -240,11 +257,10 @@ export default function ProxyLogs() {
|
||||
<svg width="14" height="14" 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>
|
||||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="搜索模型名称..." />
|
||||
<input value={searchInput} onChange={(e) => setSearchInput(e.target.value)} placeholder="搜索模型名称..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card" style={{ overflowX: 'auto' }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
@@ -275,14 +291,19 @@ export default function ProxyLogs() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.map(log => {
|
||||
const pathMeta = parseProxyLogPathMeta(log.errorMessage);
|
||||
const billingDetailSummary = formatBillingDetailSummary(log);
|
||||
const billingProcessLines = buildBillingProcessLines(log);
|
||||
{logs.map((log) => {
|
||||
const detailState = detailById[log.id];
|
||||
const detail = detailState?.data;
|
||||
const detailLog: ProxyLogRenderItem = detail ? { ...log, ...detail } : log;
|
||||
const pathMeta = parseProxyLogPathMeta(detailLog.errorMessage);
|
||||
const billingDetailSummary = detail ? formatBillingDetailSummary(detailLog) : null;
|
||||
const billingProcessLines = detail ? buildBillingProcessLines(detailLog) : [];
|
||||
|
||||
return (
|
||||
<React.Fragment key={log.id}>
|
||||
<tr
|
||||
onClick={() => setExpanded(expanded === log.id ? null : log.id)}
|
||||
data-testid={`proxy-log-row-${log.id}`}
|
||||
onClick={() => handleToggleExpand(log.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: expanded === log.id ? 'var(--color-primary-light)' : undefined,
|
||||
@@ -356,41 +377,45 @@ export default function ProxyLogs() {
|
||||
lineHeight: 1.9,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}>
|
||||
{/* 鏃ュ織璇︽儏 section */}
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-warning)', flexShrink: 0 }}>日志详情</span>
|
||||
<div>
|
||||
<div>
|
||||
请求模型: <strong style={{ color: 'var(--color-text-primary)' }}>{log.modelRequested}</strong>
|
||||
{log.modelActual && log.modelActual !== log.modelRequested && (
|
||||
<>{' -> '}实际模型: <strong style={{ color: 'var(--color-text-primary)' }}>{log.modelActual}</strong></>
|
||||
请求模型: <strong style={{ color: 'var(--color-text-primary)' }}>{detailLog.modelRequested}</strong>
|
||||
{detailLog.modelActual && detailLog.modelActual !== detailLog.modelRequested && (
|
||||
<>{' -> '}实际模型: <strong style={{ color: 'var(--color-text-primary)' }}>{detailLog.modelActual}</strong></>
|
||||
)}
|
||||
,状态: <strong style={{ color: detailLog.status === 'success' ? 'var(--color-success)' : 'var(--color-danger)' }}>{detailLog.status === 'success' ? '成功' : '失败'}</strong>
|
||||
,用时: <strong style={{ color: latencyColor(detailLog.latencyMs) }}>{formatLatency(detailLog.latencyMs)}</strong>
|
||||
{detail && (
|
||||
<>
|
||||
,站点: <strong style={{ color: 'var(--color-text-primary)' }}>{detailLog.siteName || '未知站点'}</strong>
|
||||
,账号: <strong style={{ color: 'var(--color-text-primary)' }}>{detailLog.username || '未知账号'}</strong>
|
||||
</>
|
||||
)}
|
||||
,状态: <strong style={{ color: log.status === 'success' ? 'var(--color-success)' : 'var(--color-danger)' }}>{log.status === 'success' ? '成功' : '失败'}</strong>
|
||||
,用时: <strong style={{ color: latencyColor(log.latencyMs) }}>{formatLatency(log.latencyMs)}</strong>
|
||||
,站点: <strong style={{ color: 'var(--color-text-primary)' }}>{log.siteName || '未知站点'}</strong>
|
||||
,账号: <strong style={{ color: 'var(--color-text-primary)' }}>{log.username || '未知账号'}</strong>
|
||||
</div>
|
||||
{detailState?.loading && <div style={{ color: 'var(--color-text-muted)' }}>加载详情中...</div>}
|
||||
{detailState?.error && <div style={{ color: 'var(--color-danger)' }}>{detailState.error}</div>}
|
||||
{billingDetailSummary && (
|
||||
<div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{log.billingDetails && log.billingDetails.usage.cacheReadTokens > 0 && (
|
||||
{detailLog.billingDetails && detailLog.billingDetails.usage.cacheReadTokens > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-warning)', flexShrink: 0 }}>缓存 Tokens</span>
|
||||
<span>{log.billingDetails.usage.cacheReadTokens.toLocaleString()}</span>
|
||||
<span>{detailLog.billingDetails.usage.cacheReadTokens.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.billingDetails && log.billingDetails.usage.cacheCreationTokens > 0 && (
|
||||
{detailLog.billingDetails && detailLog.billingDetails.usage.cacheCreationTokens > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-warning)', flexShrink: 0 }}>缓存创建 Tokens</span>
|
||||
<span>{log.billingDetails.usage.cacheCreationTokens.toLocaleString()}</span>
|
||||
<span>{detailLog.billingDetails.usage.cacheCreationTokens.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 璁¤垂杩囩▼ section */}
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-info)', flexShrink: 0 }}>计费过程</span>
|
||||
{billingProcessLines.length > 0 ? (
|
||||
@@ -402,20 +427,19 @@ export default function ProxyLogs() {
|
||||
</div>
|
||||
) : (
|
||||
<span>
|
||||
输入 {log.promptTokens?.toLocaleString() || 0} tokens
|
||||
{' + '}输出 {log.completionTokens?.toLocaleString() || 0} tokens
|
||||
{' = '}总计 {log.totalTokens?.toLocaleString() || 0} tokens
|
||||
{typeof log.estimatedCost === 'number' && (
|
||||
<>,预估费用 <strong style={{ color: 'var(--color-text-primary)' }}>${log.estimatedCost.toFixed(6)}</strong></>
|
||||
输入 {detailLog.promptTokens?.toLocaleString() || 0} tokens
|
||||
{' + '}输出 {detailLog.completionTokens?.toLocaleString() || 0} tokens
|
||||
{' = '}总计 {detailLog.totalTokens?.toLocaleString() || 0} tokens
|
||||
{typeof detailLog.estimatedCost === 'number' && (
|
||||
<>,预估费用 <strong style={{ color: 'var(--color-text-primary)' }}>${detailLog.estimatedCost.toFixed(6)}</strong></>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 涓嬫父璇锋眰璺緞 */}
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-primary)', flexShrink: 0 }}>下游请求路径</span>
|
||||
{pathMeta.downstreamPath ? (
|
||||
{detail && pathMeta.downstreamPath ? (
|
||||
<code style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12,
|
||||
background: 'var(--color-bg-card)', padding: '1px 8px', borderRadius: 4,
|
||||
@@ -428,10 +452,9 @@ export default function ProxyLogs() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 涓婃父璇锋眰璺緞 */}
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-primary)', flexShrink: 0 }}>上游请求路径</span>
|
||||
{pathMeta.upstreamPath ? (
|
||||
{detail && pathMeta.upstreamPath ? (
|
||||
<code style={{
|
||||
fontFamily: 'var(--font-mono)', fontSize: 12,
|
||||
background: 'var(--color-bg-card)', padding: '1px 8px', borderRadius: 4,
|
||||
@@ -444,8 +467,7 @@ export default function ProxyLogs() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 閿欒淇℃伅 */}
|
||||
{pathMeta.errorMessage.trim().length > 0 && (
|
||||
{detail && pathMeta.errorMessage.trim().length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--color-danger)', flexShrink: 0 }}>错误信息</span>
|
||||
<span style={{ color: 'var(--color-danger)' }}>{pathMeta.errorMessage}</span>
|
||||
@@ -458,11 +480,12 @@ export default function ProxyLogs() {
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)})}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{!loading && filtered.length === 0 && (
|
||||
{!loading && logs.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg>
|
||||
<div className="empty-state-title">{tr('暂无使用日志')}</div>
|
||||
@@ -471,28 +494,20 @@ export default function ProxyLogs() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{filtered.length > 0 && (
|
||||
{total > 0 && (
|
||||
<div className="pagination">
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginRight: 'auto' }}>
|
||||
显示第 {(safePage - 1) * pageSize + 1} - {Math.min(safePage * pageSize, filtered.length)} 条,共 {filtered.length} 条
|
||||
显示第 {displayedStart} - {displayedEnd} 条,共 {total} 条
|
||||
</div>
|
||||
<button className="pagination-btn" disabled={safePage <= 1} onClick={() => setPage(p => p - 1)}>
|
||||
<button className="pagination-btn" disabled={safePage <= 1} onClick={() => setPage((current) => current - 1)}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
|
||||
</button>
|
||||
{Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
|
||||
let num: number;
|
||||
if (totalPages <= 7) num = i + 1;
|
||||
else if (safePage <= 4) num = i + 1;
|
||||
else if (safePage >= totalPages - 3) num = totalPages - 6 + i;
|
||||
else num = safePage - 3 + i;
|
||||
return (
|
||||
{pageNumbers.map((num) => (
|
||||
<button key={num} className={`pagination-btn ${safePage === num ? 'active' : ''}`} onClick={() => setPage(num)}>
|
||||
{num}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button className="pagination-btn" disabled={safePage >= totalPages} onClick={() => setPage(p => p + 1)}>
|
||||
))}
|
||||
<button className="pagination-btn" disabled={safePage >= totalPages} onClick={() => setPage((current) => current + 1)}>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
<div className="pagination-size">
|
||||
@@ -501,7 +516,10 @@ export default function ProxyLogs() {
|
||||
<ModernSelect
|
||||
size="sm"
|
||||
value={String(pageSize)}
|
||||
onChange={(nextValue) => setPageSize(Number(nextValue))}
|
||||
onChange={(nextValue) => {
|
||||
setPageSize(Number(nextValue));
|
||||
setPage(1);
|
||||
}}
|
||||
options={PAGE_SIZES.map((s) => ({ value: String(s), label: String(s) }))}
|
||||
placeholder={String(pageSize)}
|
||||
/>
|
||||
@@ -512,4 +530,3 @@ export default function ProxyLogs() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -252,7 +252,42 @@ describe('modelTesterSession', () => {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_text', text: '请总结上传文件' },
|
||||
{ type: 'input_file', file_id: 'file_123', filename: 'notes.txt', mime_type: 'text/plain' },
|
||||
{ type: 'input_file', file_id: 'file_123', filename: 'notes.txt' },
|
||||
],
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
temperature: 0.7,
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes assistant history as output_text for responses payloads', () => {
|
||||
const payload = buildApiPayload(
|
||||
[
|
||||
{ id: 'u1', role: 'user', content: '先看附件', createAt: 1 },
|
||||
{ id: 'a1', role: 'assistant', content: '我已经看过摘要', createAt: 2 },
|
||||
],
|
||||
{
|
||||
...DEFAULT_INPUTS,
|
||||
model: 'gpt-5.2',
|
||||
protocol: 'responses',
|
||||
},
|
||||
DEFAULT_PARAMETER_ENABLED,
|
||||
);
|
||||
|
||||
expect(payload.jsonBody).toEqual({
|
||||
model: 'gpt-5.2',
|
||||
input: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'input_text', text: '先看附件' },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'output_text', text: '我已经看过摘要' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -357,7 +392,6 @@ describe('modelTesterSession', () => {
|
||||
type: 'input_file',
|
||||
file_id: 'file_metapi_123',
|
||||
filename: 'paper.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -343,7 +343,6 @@ const toResponsesContentPart = (part: ConversationContentPart): Record<string, u
|
||||
if (typeof part.fileId === 'string' && part.fileId.trim()) fileBlock.file_id = part.fileId.trim();
|
||||
if (typeof part.filename === 'string' && part.filename.trim()) fileBlock.filename = part.filename.trim();
|
||||
if (typeof part.data === 'string' && part.data.trim()) fileBlock.file_data = part.data.trim();
|
||||
if (typeof part.mimeType === 'string' && part.mimeType.trim()) fileBlock.mime_type = part.mimeType.trim();
|
||||
if (Object.keys(fileBlock).length === 1) return null;
|
||||
return fileBlock;
|
||||
}
|
||||
@@ -383,10 +382,12 @@ const toResponsesInput = (messages: ApiChatMessage[]) => {
|
||||
if (!hasStructuredParts && messages.length === 1 && messages[0].role === 'user') {
|
||||
return messages[0].content;
|
||||
}
|
||||
const toResponsesTextType = (role: ApiChatMessage['role']) =>
|
||||
role === 'assistant' ? 'output_text' : 'input_text';
|
||||
return messages.map((message) => ({
|
||||
role: message.role === 'assistant' ? 'assistant' : 'user',
|
||||
content: [
|
||||
...(message.content.trim() ? [{ type: 'input_text', text: message.content }] : []),
|
||||
...(message.content.trim() ? [{ type: toResponsesTextType(message.role), text: message.content }] : []),
|
||||
...((Array.isArray(message.parts)
|
||||
? message.parts
|
||||
.map((part) => toResponsesContentPart(part))
|
||||
@@ -394,7 +395,7 @@ const toResponsesInput = (messages: ApiChatMessage[]) => {
|
||||
: [])),
|
||||
].length > 0
|
||||
? [
|
||||
...(message.content.trim() ? [{ type: 'input_text', text: message.content }] : []),
|
||||
...(message.content.trim() ? [{ type: toResponsesTextType(message.role), text: message.content }] : []),
|
||||
...((Array.isArray(message.parts)
|
||||
? message.parts
|
||||
.map((part) => toResponsesContentPart(part))
|
||||
|
||||
Reference in New Issue
Block a user