feat: 初始化 AI Gateway 项目
实现支持 OpenAI 和 Anthropic 双协议的统一大模型 API 网关 MVP 版本,包含: - OpenAI 和 Anthropic 协议代理 - 供应商和模型管理 - 用量统计 - 前端配置界面
This commit is contained in:
335
.gitignore
vendored
Normal file
335
.gitignore
vendored
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
### Git.gitignore ###
|
||||||
|
# Created by git for backups. To disable backups in Git:
|
||||||
|
# $ git config --global mergetool.keepBackup false
|
||||||
|
*.orig
|
||||||
|
|
||||||
|
# Created by git when using merge tools for conflicts
|
||||||
|
*.BACKUP.*
|
||||||
|
*.BASE.*
|
||||||
|
*.LOCAL.*
|
||||||
|
*.REMOTE.*
|
||||||
|
*_BACKUP_*.txt
|
||||||
|
*_BASE_*.txt
|
||||||
|
*_LOCAL_*.txt
|
||||||
|
*_REMOTE_*.txt
|
||||||
|
|
||||||
|
### Go.gitignore ###
|
||||||
|
# Binaries for programs and plugins
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directories (remove the comment below to include it)
|
||||||
|
# vendor/
|
||||||
|
|
||||||
|
### Go.patch ###
|
||||||
|
/vendor/
|
||||||
|
/Godeps/
|
||||||
|
|
||||||
|
### JetBrains+all.gitignore ###
|
||||||
|
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||||
|
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||||
|
|
||||||
|
# User-specific stuff
|
||||||
|
.idea/**/workspace.xml
|
||||||
|
.idea/**/tasks.xml
|
||||||
|
.idea/**/usage.statistics.xml
|
||||||
|
.idea/**/dictionaries
|
||||||
|
.idea/**/shelf
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
.idea/**/contentModel.xml
|
||||||
|
|
||||||
|
# Sensitive or high-churn files
|
||||||
|
.idea/**/dataSources/
|
||||||
|
.idea/**/dataSources.ids
|
||||||
|
.idea/**/dataSources.local.xml
|
||||||
|
.idea/**/sqlDataSources.xml
|
||||||
|
.idea/**/dynamic.xml
|
||||||
|
.idea/**/uiDesigner.xml
|
||||||
|
.idea/**/dbnavigator.xml
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
.idea/**/gradle.xml
|
||||||
|
.idea/**/libraries
|
||||||
|
|
||||||
|
# Gradle and Maven with auto-import
|
||||||
|
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||||
|
# since they will be recreated, and may cause churn. Uncomment if using
|
||||||
|
# auto-import.
|
||||||
|
# .idea/artifacts
|
||||||
|
# .idea/compiler.xml
|
||||||
|
# .idea/jarRepositories.xml
|
||||||
|
# .idea/modules.xml
|
||||||
|
# .idea/*.iml
|
||||||
|
# .idea/modules
|
||||||
|
# *.iml
|
||||||
|
# *.ipr
|
||||||
|
|
||||||
|
# CMake
|
||||||
|
cmake-build-*/
|
||||||
|
|
||||||
|
# Mongo Explorer plugin
|
||||||
|
.idea/**/mongoSettings.xml
|
||||||
|
|
||||||
|
# File-based project format
|
||||||
|
*.iws
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
out/
|
||||||
|
|
||||||
|
# mpeltonen/sbt-idea plugin
|
||||||
|
.idea_modules/
|
||||||
|
|
||||||
|
# JIRA plugin
|
||||||
|
atlassian-ide-plugin.xml
|
||||||
|
|
||||||
|
# Cursive Clojure plugin
|
||||||
|
.idea/replstate.xml
|
||||||
|
|
||||||
|
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||||
|
com_crashlytics_export_strings.xml
|
||||||
|
crashlytics.properties
|
||||||
|
crashlytics-build.properties
|
||||||
|
fabric.properties
|
||||||
|
|
||||||
|
# Editor-based Rest Client
|
||||||
|
.idea/httpRequests
|
||||||
|
|
||||||
|
# Android studio 3.1+ serialized cache file
|
||||||
|
.idea/caches/build_file_checksums.ser
|
||||||
|
|
||||||
|
### JetBrains+all.patch ###
|
||||||
|
# Ignores the whole .idea folder and all .iml files
|
||||||
|
# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360
|
||||||
|
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023
|
||||||
|
|
||||||
|
*.iml
|
||||||
|
modules.xml
|
||||||
|
.idea/misc.xml
|
||||||
|
*.ipr
|
||||||
|
|
||||||
|
# Sonarlint plugin
|
||||||
|
.idea/sonarlint
|
||||||
|
|
||||||
|
### Linux.gitignore ###
|
||||||
|
*~
|
||||||
|
|
||||||
|
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||||
|
.fuse_hidden*
|
||||||
|
|
||||||
|
# KDE directory preferences
|
||||||
|
.directory
|
||||||
|
|
||||||
|
# Linux trash folder which might appear on any partition or disk
|
||||||
|
.Trash-*
|
||||||
|
|
||||||
|
# .nfs files are created when an open file is removed but is still being accessed
|
||||||
|
.nfs*
|
||||||
|
|
||||||
|
### Node.gitignore ###
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# TypeScript v1 declaration files
|
||||||
|
typings/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variables file
|
||||||
|
.env
|
||||||
|
.env.test
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
### VisualStudioCode.gitignore ###
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
|
### VisualStudioCode.patch ###
|
||||||
|
# Ignore all local history of files
|
||||||
|
.history
|
||||||
|
|
||||||
|
### Web.gitignore ###
|
||||||
|
*.asp
|
||||||
|
*.cer
|
||||||
|
*.csr
|
||||||
|
*.css
|
||||||
|
*.htm
|
||||||
|
*.html
|
||||||
|
*.js
|
||||||
|
*.jsp
|
||||||
|
*.php
|
||||||
|
*.rss
|
||||||
|
*.wasm
|
||||||
|
*.wat
|
||||||
|
*.xhtml
|
||||||
|
|
||||||
|
### Windows.gitignore ###
|
||||||
|
# Windows thumbnail cache files
|
||||||
|
Thumbs.db
|
||||||
|
Thumbs.db:encryptable
|
||||||
|
ehthumbs.db
|
||||||
|
ehthumbs_vista.db
|
||||||
|
|
||||||
|
# Dump file
|
||||||
|
*.stackdump
|
||||||
|
|
||||||
|
# Folder config file
|
||||||
|
[Dd]esktop.ini
|
||||||
|
|
||||||
|
# Recycle Bin used on file shares
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
|
||||||
|
# Windows Installer files
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# Windows shortcuts
|
||||||
|
*.lnk
|
||||||
|
|
||||||
|
### macOS.gitignore ###
|
||||||
|
# General
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
|
||||||
|
# Icon must end with two \r
|
||||||
|
Icon
|
||||||
|
|
||||||
|
# Thumbnails
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Files that might appear in the root of a volume
|
||||||
|
.DocumentRevisions-V100
|
||||||
|
.fseventsd
|
||||||
|
.Spotlight-V100
|
||||||
|
.TemporaryItems
|
||||||
|
.Trashes
|
||||||
|
.VolumeIcon.icns
|
||||||
|
.com.apple.timemachine.donotpresent
|
||||||
|
|
||||||
|
# Directories potentially created on remote AFP share
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
||||||
|
|
||||||
|
# Custom
|
||||||
|
.claude
|
||||||
|
.opencode
|
||||||
|
openspec/changes/archive
|
||||||
|
temp
|
||||||
105
README.md
Normal file
105
README.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Nex - AI Gateway
|
||||||
|
|
||||||
|
一个统一的大模型 API 网关,支持 OpenAI 和 Anthropic 双协议,让应用只需配置一个地址即可透明使用多个供应商的大模型服务。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
nex/
|
||||||
|
├── backend/ # Go 后端服务
|
||||||
|
│ ├── main.go
|
||||||
|
│ ├── go.mod
|
||||||
|
│ └── internal/
|
||||||
|
│ ├── handler/ # HTTP 处理器
|
||||||
|
│ ├── protocol/ # 协议适配器
|
||||||
|
│ ├── provider/ # 供应商客户端
|
||||||
|
│ ├── router/ # 模型路由
|
||||||
|
│ ├── stats/ # 统计记录
|
||||||
|
│ └── config/ # 配置与数据库
|
||||||
|
│
|
||||||
|
├── frontend/ # React 前端界面
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── main.tsx
|
||||||
|
│ │ ├── App.tsx
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ ├── api/
|
||||||
|
│ │ └── styles/
|
||||||
|
│ └── package.json
|
||||||
|
│
|
||||||
|
└── README.md # 本文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- **双协议支持**:同时支持 OpenAI 和 Anthropic 协议
|
||||||
|
- **透明代理**:对 OpenAI 兼容供应商透传请求
|
||||||
|
- **流式响应**:完整支持 SSE 流式传输
|
||||||
|
- **Function Calling**:支持工具调用(Tools)
|
||||||
|
- **多供应商管理**:配置和管理多个供应商
|
||||||
|
- **用量统计**:按供应商、模型、日期统计请求数量
|
||||||
|
- **Web 配置界面**:提供供应商和模型配置管理
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
- **Go 1.21+**
|
||||||
|
- **Gin** - HTTP 框架
|
||||||
|
- **GORM** - ORM
|
||||||
|
- **SQLite** - 数据库
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
- **Bun** - 运行时
|
||||||
|
- **Vite** - 构建工具
|
||||||
|
- **TypeScript** - 类型系统
|
||||||
|
- **React** - UI 框架
|
||||||
|
- **SCSS** - 样式预处理
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go mod download
|
||||||
|
go run main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
后端服务将在 `http://localhost:9826` 启动。
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
bun install
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
前端开发服务器将在 `http://localhost:5173` 启动。
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### 代理接口(对外部应用)
|
||||||
|
|
||||||
|
- `POST /v1/chat/completions` - OpenAI Chat Completions API
|
||||||
|
- `POST /v1/messages` - Anthropic Messages API
|
||||||
|
|
||||||
|
### 管理接口(对前端)
|
||||||
|
|
||||||
|
- `GET/POST/PUT/DELETE /api/providers` - 供应商管理
|
||||||
|
- `GET/POST/PUT/DELETE /api/models` - 模型管理
|
||||||
|
- `GET /api/stats` - 统计查询
|
||||||
|
|
||||||
|
## 配置存储
|
||||||
|
|
||||||
|
配置数据存储在用户目录:`~/.nex/config.db`
|
||||||
|
|
||||||
|
## 开发规范
|
||||||
|
|
||||||
|
详见各子项目的 README.md:
|
||||||
|
- [后端 README](backend/README.md)
|
||||||
|
- [前端 README](frontend/README.md)
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
MIT
|
||||||
188
backend/README.md
Normal file
188
backend/README.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# AI Gateway Backend
|
||||||
|
|
||||||
|
AI 网关后端服务,提供统一的大模型 API 代理接口。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- 支持 OpenAI 协议(`/v1/chat/completions`)
|
||||||
|
- 支持 Anthropic 协议(`/v1/messages`)
|
||||||
|
- 支持流式响应(SSE)
|
||||||
|
- 支持 Function Calling / Tools
|
||||||
|
- 多供应商配置和路由
|
||||||
|
- 用量统计
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **语言**: Go
|
||||||
|
- **HTTP 框架**: Gin
|
||||||
|
- **ORM**: GORM
|
||||||
|
- **数据库**: SQLite
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── cmd/
|
||||||
|
│ └── server/
|
||||||
|
│ └── main.go # 主程序入口
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/ # 配置和数据库
|
||||||
|
│ │ ├── config.go # 配置目录管理
|
||||||
|
│ │ ├── database.go # 数据库连接
|
||||||
|
│ │ ├── models.go # 数据模型
|
||||||
|
│ │ ├── provider.go # 供应商 CRUD
|
||||||
|
│ │ ├── model.go # 模型 CRUD
|
||||||
|
│ │ └── stats.go # 统计记录
|
||||||
|
│ ├── handler/ # HTTP 处理器
|
||||||
|
│ │ ├── openai_handler.go
|
||||||
|
│ │ ├── anthropic_handler.go
|
||||||
|
│ │ ├── provider_handler.go
|
||||||
|
│ │ ├── model_handler.go
|
||||||
|
│ │ └── stats_handler.go
|
||||||
|
│ ├── protocol/ # 协议适配器
|
||||||
|
│ │ ├── openai/
|
||||||
|
│ │ │ ├── types.go
|
||||||
|
│ │ │ └── adapter.go
|
||||||
|
│ │ └── anthropic/
|
||||||
|
│ │ ├── types.go
|
||||||
|
│ │ ├── converter.go
|
||||||
|
│ │ └── stream_converter.go
|
||||||
|
│ ├── provider/ # 供应商客户端
|
||||||
|
│ │ └── client.go
|
||||||
|
│ └── router/ # 模型路由
|
||||||
|
│ └── model_router.go
|
||||||
|
├── go.mod
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行方式
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go mod download
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run cmd/server/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
服务将在端口 9826 启动。
|
||||||
|
|
||||||
|
## API 文档
|
||||||
|
|
||||||
|
### 代理接口
|
||||||
|
|
||||||
|
#### OpenAI Chat Completions
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /v1/chat/completions
|
||||||
|
```
|
||||||
|
|
||||||
|
请求示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "gpt-4",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "Hello"}
|
||||||
|
],
|
||||||
|
"stream": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Anthropic Messages
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /v1/messages
|
||||||
|
```
|
||||||
|
|
||||||
|
请求示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": [{"type": "text", "text": "Hello"}]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 管理接口
|
||||||
|
|
||||||
|
#### 供应商管理
|
||||||
|
|
||||||
|
- `GET /api/providers` - 列出所有供应商
|
||||||
|
- `POST /api/providers` - 创建供应商
|
||||||
|
- `GET /api/providers/:id` - 获取供应商
|
||||||
|
- `PUT /api/providers/:id` - 更新供应商
|
||||||
|
- `DELETE /api/providers/:id` - 删除供应商
|
||||||
|
|
||||||
|
创建供应商示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "openai",
|
||||||
|
"name": "OpenAI",
|
||||||
|
"api_key": "sk-...",
|
||||||
|
"base_url": "https://api.openai.com/v1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**重要说明:**
|
||||||
|
- `base_url` 应配置到 API 版本路径,不包含具体端点
|
||||||
|
- OpenAI: `https://api.openai.com/v1`
|
||||||
|
- GLM: `https://open.bigmodel.cn/api/paas/v4`
|
||||||
|
- 其他 OpenAI 兼容供应商根据其文档配置版本路径
|
||||||
|
|
||||||
|
#### 模型管理
|
||||||
|
|
||||||
|
- `GET /api/models` - 列出模型(支持 `?provider_id=xxx` 过滤)
|
||||||
|
- `POST /api/models` - 创建模型
|
||||||
|
- `GET /api/models/:id` - 获取模型
|
||||||
|
- `PUT /api/models/:id` - 更新模型
|
||||||
|
- `DELETE /api/models/:id` - 删除模型
|
||||||
|
|
||||||
|
创建模型示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "gpt-4",
|
||||||
|
"provider_id": "openai",
|
||||||
|
"model_name": "gpt-4"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 统计查询
|
||||||
|
|
||||||
|
- `GET /api/stats` - 查询统计
|
||||||
|
- `GET /api/stats/aggregate` - 聚合统计
|
||||||
|
|
||||||
|
查询参数:
|
||||||
|
|
||||||
|
- `provider_id` - 供应商 ID
|
||||||
|
- `model_name` - 模型名称
|
||||||
|
- `start_date` - 开始日期(YYYY-MM-DD)
|
||||||
|
- `end_date` - 结束日期(YYYY-MM-DD)
|
||||||
|
- `group_by` - 聚合维度(provider/model/date)
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
配置和数据存储在 `~/.nex/` 目录:
|
||||||
|
|
||||||
|
- `~/.nex/config.db` - SQLite 数据库
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
### 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o ai-gateway cmd/server/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Go 1.21 或更高版本
|
||||||
119
backend/cmd/server/main.go
Normal file
119
backend/cmd/server/main.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"nex/backend/internal/config"
|
||||||
|
"nex/backend/internal/handler"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 初始化数据库
|
||||||
|
if err := config.InitDB(); err != nil {
|
||||||
|
log.Fatalf("初始化数据库失败: %v", err)
|
||||||
|
}
|
||||||
|
defer config.CloseDB()
|
||||||
|
|
||||||
|
// 创建 Gin 引擎
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
r := gin.Default()
|
||||||
|
|
||||||
|
// 配置 CORS
|
||||||
|
r.Use(func(c *gin.Context) {
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 注册路由
|
||||||
|
setupRoutes(r)
|
||||||
|
|
||||||
|
// 创建 HTTP 服务器
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: ":9826",
|
||||||
|
Handler: r,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动服务器(在 goroutine 中)
|
||||||
|
go func() {
|
||||||
|
log.Printf("AI Gateway 启动在端口 9826")
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("服务器启动失败: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 等待中断信号以优雅关闭服务器
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
log.Println("正在关闭服务器...")
|
||||||
|
|
||||||
|
// 给服务器 5 秒时间完成当前请求
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
|
log.Fatal("服务器强制关闭:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("服务器已关闭")
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupRoutes 配置路由
|
||||||
|
func setupRoutes(r *gin.Engine) {
|
||||||
|
// OpenAI 协议代理
|
||||||
|
openaiHandler := handler.NewOpenAIHandler()
|
||||||
|
r.POST("/v1/chat/completions", openaiHandler.HandleChatCompletions)
|
||||||
|
|
||||||
|
// Anthropic 协议代理
|
||||||
|
anthropicHandler := handler.NewAnthropicHandler()
|
||||||
|
r.POST("/v1/messages", anthropicHandler.HandleMessages)
|
||||||
|
|
||||||
|
// 供应商管理 API
|
||||||
|
providerHandler := handler.NewProviderHandler()
|
||||||
|
providers := r.Group("/api/providers")
|
||||||
|
{
|
||||||
|
providers.GET("", providerHandler.ListProviders)
|
||||||
|
providers.POST("", providerHandler.CreateProvider)
|
||||||
|
providers.GET("/:id", providerHandler.GetProvider)
|
||||||
|
providers.PUT("/:id", providerHandler.UpdateProvider)
|
||||||
|
providers.DELETE("/:id", providerHandler.DeleteProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模型管理 API
|
||||||
|
modelHandler := handler.NewModelHandler()
|
||||||
|
models := r.Group("/api/models")
|
||||||
|
{
|
||||||
|
models.GET("", modelHandler.ListModels)
|
||||||
|
models.POST("", modelHandler.CreateModel)
|
||||||
|
models.GET("/:id", modelHandler.GetModel)
|
||||||
|
models.PUT("/:id", modelHandler.UpdateModel)
|
||||||
|
models.DELETE("/:id", modelHandler.DeleteModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计查询 API
|
||||||
|
statsHandler := handler.NewStatsHandler()
|
||||||
|
stats := r.Group("/api/stats")
|
||||||
|
{
|
||||||
|
stats.GET("", statsHandler.GetStats)
|
||||||
|
stats.GET("/aggregate", statsHandler.AggregateStats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 健康检查
|
||||||
|
r.GET("/health", func(c *gin.Context) {
|
||||||
|
c.JSON(200, gin.H{"status": "ok"})
|
||||||
|
})
|
||||||
|
}
|
||||||
41
backend/go.mod
Normal file
41
backend/go.mod
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
module nex/backend
|
||||||
|
|
||||||
|
go 1.26.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/gin-gonic/gin v1.12.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
golang.org/x/net v0.51.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
gorm.io/driver/sqlite v1.6.0 // indirect
|
||||||
|
gorm.io/gorm v1.31.1 // indirect
|
||||||
|
)
|
||||||
88
backend/go.sum
Normal file
88
backend/go.sum
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
|
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
|
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||||
|
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
|
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||||
|
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
|
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
|
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
32
backend/internal/config/config.go
Normal file
32
backend/internal/config/config.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetConfigDir 获取配置目录路径(~/.nex/)
|
||||||
|
func GetConfigDir() (string, error) {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
configDir := filepath.Join(homeDir, ".nex")
|
||||||
|
|
||||||
|
// 确保目录存在
|
||||||
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return configDir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDBPath 获取数据库文件路径
|
||||||
|
func GetDBPath() (string, error) {
|
||||||
|
configDir, err := GetConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(configDir, "config.db"), nil
|
||||||
|
}
|
||||||
58
backend/internal/config/database.go
Normal file
58
backend/internal/config/database.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
var db *gorm.DB
|
||||||
|
|
||||||
|
// InitDB 初始化数据库连接并创建表
|
||||||
|
func InitDB() error {
|
||||||
|
dbPath, err := GetDBPath()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取数据库路径失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开数据库连接
|
||||||
|
db, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Info),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("连接数据库失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启用 WAL 模式以提升并发性能
|
||||||
|
if err := db.Exec("PRAGMA journal_mode=WAL").Error; err != nil {
|
||||||
|
log.Printf("警告: 启用 WAL 模式失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动迁移表结构
|
||||||
|
if err := db.AutoMigrate(&Provider{}, &Model{}, &UsageStats{}); err != nil {
|
||||||
|
return fmt.Errorf("创建表失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("数据库初始化成功: %s", dbPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDB 获取数据库连接
|
||||||
|
func GetDB() *gorm.DB {
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseDB 关闭数据库连接
|
||||||
|
func CloseDB() error {
|
||||||
|
if db != nil {
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sqlDB.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
119
backend/internal/config/model.go
Normal file
119
backend/internal/config/model.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateModel 创建模型
|
||||||
|
func CreateModel(model *Model) error {
|
||||||
|
db := GetDB()
|
||||||
|
if db == nil {
|
||||||
|
return errors.New("数据库未初始化")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证供应商是否存在
|
||||||
|
var provider Provider
|
||||||
|
err := db.First(&provider, "id = ?", model.ProviderID).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return errors.New("供应商不存在")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
model.CreatedAt = time.Now()
|
||||||
|
|
||||||
|
return db.Create(model).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetModel 获取模型
|
||||||
|
func GetModel(id string) (*Model, error) {
|
||||||
|
db := GetDB()
|
||||||
|
if db == nil {
|
||||||
|
return nil, errors.New("数据库未初始化")
|
||||||
|
}
|
||||||
|
|
||||||
|
var model Model
|
||||||
|
err := db.First(&model, "id = ?", id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListModels 列出模型
|
||||||
|
func ListModels(providerID string) ([]Model, error) {
|
||||||
|
db := GetDB()
|
||||||
|
if db == nil {
|
||||||
|
return nil, errors.New("数据库未初始化")
|
||||||
|
}
|
||||||
|
|
||||||
|
var models []Model
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if providerID != "" {
|
||||||
|
err = db.Where("provider_id = ?", providerID).Find(&models).Error
|
||||||
|
} else {
|
||||||
|
err = db.Find(&models).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return models, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateModel 更新模型
|
||||||
|
func UpdateModel(id string, updates map[string]interface{}) error {
|
||||||
|
db := GetDB()
|
||||||
|
if db == nil {
|
||||||
|
return errors.New("数据库未初始化")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果更新了 provider_id,验证新供应商是否存在
|
||||||
|
if providerID, ok := updates["provider_id"].(string); ok {
|
||||||
|
var provider Provider
|
||||||
|
err := db.First(&provider, "id = ?", providerID).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return errors.New("供应商不存在")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := db.Model(&Model{}).Where("id = ?", id).Updates(updates)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteModel 删除模型
|
||||||
|
func DeleteModel(id string) error {
|
||||||
|
db := GetDB()
|
||||||
|
if db == nil {
|
||||||
|
return errors.New("数据库未初始化")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := db.Delete(&Model{}, "id = ?", id)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
57
backend/internal/config/models.go
Normal file
57
backend/internal/config/models.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Provider 供应商模型
|
||||||
|
type Provider struct {
|
||||||
|
ID string `gorm:"primaryKey" json:"id"`
|
||||||
|
Name string `gorm:"not null" json:"name"`
|
||||||
|
APIKey string `gorm:"not null" json:"api_key"`
|
||||||
|
BaseURL string `gorm:"not null" json:"base_url"`
|
||||||
|
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
Models []Model `gorm:"foreignKey:ProviderID;constraint:OnDelete:CASCADE" json:"models,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model 模型配置
|
||||||
|
type Model struct {
|
||||||
|
ID string `gorm:"primaryKey" json:"id"`
|
||||||
|
ProviderID string `gorm:"not null;index" json:"provider_id"`
|
||||||
|
ModelName string `gorm:"not null;index" json:"model_name"`
|
||||||
|
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageStats 用量统计
|
||||||
|
type UsageStats struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
ProviderID string `gorm:"not null;index" json:"provider_id"`
|
||||||
|
ModelName string `gorm:"not null;index" json:"model_name"`
|
||||||
|
RequestCount int `gorm:"default:0" json:"request_count"`
|
||||||
|
Date time.Time `gorm:"type:date;not null;uniqueIndex:idx_provider_model_date" json:"date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名
|
||||||
|
func (Provider) TableName() string {
|
||||||
|
return "providers"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Model) TableName() string {
|
||||||
|
return "models"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UsageStats) TableName() string {
|
||||||
|
return "usage_stats"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaskAPIKey 掩码 API Key(仅显示最后 4 个字符)
|
||||||
|
func (p *Provider) MaskAPIKey() {
|
||||||
|
if len(p.APIKey) > 4 {
|
||||||
|
p.APIKey = "***" + p.APIKey[len(p.APIKey)-4:]
|
||||||
|
} else {
|
||||||
|
p.APIKey = "***"
|
||||||
|
}
|
||||||
|
}
|
||||||
102
backend/internal/config/provider.go
Normal file
102
backend/internal/config/provider.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateProvider 创建供应商
|
||||||
|
func CreateProvider(provider *Provider) error {
|
||||||
|
db := GetDB()
|
||||||
|
if db == nil {
|
||||||
|
return errors.New("数据库未初始化")
|
||||||
|
}
|
||||||
|
|
||||||
|
provider.CreatedAt = time.Now()
|
||||||
|
provider.UpdatedAt = time.Now()
|
||||||
|
|
||||||
|
return db.Create(provider).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProvider 获取供应商
|
||||||
|
func GetProvider(id string, maskKey bool) (*Provider, error) {
|
||||||
|
db := GetDB()
|
||||||
|
if db == nil {
|
||||||
|
return nil, errors.New("数据库未初始化")
|
||||||
|
}
|
||||||
|
|
||||||
|
var provider Provider
|
||||||
|
err := db.First(&provider, "id = ?", id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if maskKey {
|
||||||
|
provider.MaskAPIKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &provider, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProviders 列出所有供应商
|
||||||
|
func ListProviders() ([]Provider, error) {
|
||||||
|
db := GetDB()
|
||||||
|
if db == nil {
|
||||||
|
return nil, errors.New("数据库未初始化")
|
||||||
|
}
|
||||||
|
|
||||||
|
var providers []Provider
|
||||||
|
err := db.Find(&providers).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 掩码所有 API Key
|
||||||
|
for i := range providers {
|
||||||
|
providers[i].MaskAPIKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
return providers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProvider 更新供应商
|
||||||
|
func UpdateProvider(id string, updates map[string]interface{}) error {
|
||||||
|
db := GetDB()
|
||||||
|
if db == nil {
|
||||||
|
return errors.New("数据库未初始化")
|
||||||
|
}
|
||||||
|
|
||||||
|
updates["updated_at"] = time.Now()
|
||||||
|
|
||||||
|
result := db.Model(&Provider{}).Where("id = ?", id).Updates(updates)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteProvider 删除供应商
|
||||||
|
func DeleteProvider(id string) error {
|
||||||
|
db := GetDB()
|
||||||
|
if db == nil {
|
||||||
|
return errors.New("数据库未初始化")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := db.Delete(&Provider{}, "id = ?", id)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
79
backend/internal/config/stats.go
Normal file
79
backend/internal/config/stats.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RecordRequest 记录请求统计
|
||||||
|
func RecordRequest(providerID, modelName string) error {
|
||||||
|
db := GetDB()
|
||||||
|
if db == nil {
|
||||||
|
return errors.New("数据库未初始化")
|
||||||
|
}
|
||||||
|
|
||||||
|
today := time.Now().Format("2006-01-02")
|
||||||
|
todayTime, _ := time.Parse("2006-01-02", today)
|
||||||
|
|
||||||
|
// 使用事务确保并发安全
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var stats UsageStats
|
||||||
|
|
||||||
|
// 查找或创建统计记录
|
||||||
|
err := tx.Where("provider_id = ? AND model_name = ? AND date = ?",
|
||||||
|
providerID, modelName, todayTime).
|
||||||
|
First(&stats).Error
|
||||||
|
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
// 创建新记录
|
||||||
|
stats = UsageStats{
|
||||||
|
ProviderID: providerID,
|
||||||
|
ModelName: modelName,
|
||||||
|
RequestCount: 1,
|
||||||
|
Date: todayTime,
|
||||||
|
}
|
||||||
|
return tx.Create(&stats).Error
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新计数
|
||||||
|
return tx.Model(&stats).Update("request_count", gorm.Expr("request_count + 1")).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats 查询统计
|
||||||
|
func GetStats(providerID, modelName string, startDate, endDate *time.Time) ([]UsageStats, error) {
|
||||||
|
db := GetDB()
|
||||||
|
if db == nil {
|
||||||
|
return nil, errors.New("数据库未初始化")
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats []UsageStats
|
||||||
|
query := db.Model(&UsageStats{})
|
||||||
|
|
||||||
|
if providerID != "" {
|
||||||
|
query = query.Where("provider_id = ?", providerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if modelName != "" {
|
||||||
|
query = query.Where("model_name = ?", modelName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if startDate != nil {
|
||||||
|
query = query.Where("date >= ?", startDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
if endDate != nil {
|
||||||
|
query = query.Where("date <= ?", endDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := query.Order("date DESC").Find(&stats).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
243
backend/internal/handler/anthropic_handler.go
Normal file
243
backend/internal/handler/anthropic_handler.go
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"nex/backend/internal/config"
|
||||||
|
"nex/backend/internal/protocol/anthropic"
|
||||||
|
"nex/backend/internal/protocol/openai"
|
||||||
|
"nex/backend/internal/provider"
|
||||||
|
"nex/backend/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AnthropicHandler Anthropic 协议处理器
|
||||||
|
type AnthropicHandler struct {
|
||||||
|
client *provider.Client
|
||||||
|
router *router.Router
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAnthropicHandler 创建 Anthropic 处理器
|
||||||
|
func NewAnthropicHandler() *AnthropicHandler {
|
||||||
|
return &AnthropicHandler{
|
||||||
|
client: provider.NewClient(),
|
||||||
|
router: router.NewRouter(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleMessages 处理 Messages 请求
|
||||||
|
func (h *AnthropicHandler) HandleMessages(c *gin.Context) {
|
||||||
|
// 解析 Anthropic 请求
|
||||||
|
var req anthropic.MessagesRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, anthropic.ErrorResponse{
|
||||||
|
Type: "error",
|
||||||
|
Error: anthropic.ErrorDetail{
|
||||||
|
Type: "invalid_request_error",
|
||||||
|
Message: "无效的请求格式: " + err.Error(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查多模态内容
|
||||||
|
if err := h.checkMultimodalContent(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, anthropic.ErrorResponse{
|
||||||
|
Type: "error",
|
||||||
|
Error: anthropic.ErrorDetail{
|
||||||
|
Type: "invalid_request_error",
|
||||||
|
Message: err.Error(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为 OpenAI 请求
|
||||||
|
openaiReq, err := anthropic.ConvertRequest(&req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, anthropic.ErrorResponse{
|
||||||
|
Type: "error",
|
||||||
|
Error: anthropic.ErrorDetail{
|
||||||
|
Type: "invalid_request_error",
|
||||||
|
Message: "请求转换失败: " + err.Error(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 路由到供应商
|
||||||
|
routeResult, err := h.router.Route(openaiReq.Model)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据是否流式选择处理方式
|
||||||
|
if req.Stream {
|
||||||
|
h.handleStreamRequest(c, openaiReq, routeResult)
|
||||||
|
} else {
|
||||||
|
h.handleNonStreamRequest(c, openaiReq, routeResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNonStreamRequest 处理非流式请求
|
||||||
|
func (h *AnthropicHandler) handleNonStreamRequest(c *gin.Context, openaiReq *openai.ChatCompletionRequest, routeResult *router.RouteResult) {
|
||||||
|
// 发送请求到供应商
|
||||||
|
openaiResp, err := h.client.SendRequest(c.Request.Context(), openaiReq, routeResult.Provider.APIKey, routeResult.Provider.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, anthropic.ErrorResponse{
|
||||||
|
Type: "error",
|
||||||
|
Error: anthropic.ErrorDetail{
|
||||||
|
Type: "api_error",
|
||||||
|
Message: "供应商请求失败: " + err.Error(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为 Anthropic 响应
|
||||||
|
anthropicResp, err := anthropic.ConvertResponse(openaiResp)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, anthropic.ErrorResponse{
|
||||||
|
Type: "error",
|
||||||
|
Error: anthropic.ErrorDetail{
|
||||||
|
Type: "api_error",
|
||||||
|
Message: "响应转换失败: " + err.Error(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录统计
|
||||||
|
go func() {
|
||||||
|
_ = config.RecordRequest(routeResult.Provider.ID, openaiReq.Model)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 返回响应
|
||||||
|
c.JSON(http.StatusOK, anthropicResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStreamRequest 处理流式请求
|
||||||
|
func (h *AnthropicHandler) handleStreamRequest(c *gin.Context, openaiReq *openai.ChatCompletionRequest, routeResult *router.RouteResult) {
|
||||||
|
// 发送流式请求到供应商
|
||||||
|
eventChan, err := h.client.SendStreamRequest(c.Request.Context(), openaiReq, routeResult.Provider.APIKey, routeResult.Provider.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, anthropic.ErrorResponse{
|
||||||
|
Type: "error",
|
||||||
|
Error: anthropic.ErrorDetail{
|
||||||
|
Type: "api_error",
|
||||||
|
Message: "供应商请求失败: " + err.Error(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 SSE 响应头
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
|
||||||
|
// 创建流写入器
|
||||||
|
writer := bufio.NewWriter(c.Writer)
|
||||||
|
|
||||||
|
// 创建流式转换器
|
||||||
|
converter := anthropic.NewStreamConverter(
|
||||||
|
fmt.Sprintf("msg_%s", routeResult.Provider.ID),
|
||||||
|
openaiReq.Model,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 流式转发事件
|
||||||
|
for event := range eventChan {
|
||||||
|
if event.Error != nil {
|
||||||
|
fmt.Printf("流错误: %v\n", event.Error)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.Done {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 OpenAI 流块
|
||||||
|
chunk, err := openai.NewAdapter().ParseStreamChunk(event.Data)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("解析流块失败: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为 Anthropic 事件
|
||||||
|
anthropicEvents, err := converter.ConvertChunk(chunk)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("转换事件失败: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入事件
|
||||||
|
for _, ae := range anthropicEvents {
|
||||||
|
eventStr, err := anthropic.SerializeEvent(ae)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("序列化事件失败: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
writer.WriteString(eventStr)
|
||||||
|
writer.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录统计
|
||||||
|
go func() {
|
||||||
|
_ = config.RecordRequest(routeResult.Provider.ID, openaiReq.Model)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkMultimodalContent 检查多模态内容
|
||||||
|
func (h *AnthropicHandler) checkMultimodalContent(req *anthropic.MessagesRequest) error {
|
||||||
|
for _, msg := range req.Messages {
|
||||||
|
for _, block := range msg.Content {
|
||||||
|
if block.Type == "image" {
|
||||||
|
return fmt.Errorf("MVP 不支持多模态内容(图片)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleError 处理路由错误
|
||||||
|
func (h *AnthropicHandler) handleError(c *gin.Context, err error) {
|
||||||
|
switch err {
|
||||||
|
case router.ErrModelNotFound:
|
||||||
|
c.JSON(http.StatusNotFound, anthropic.ErrorResponse{
|
||||||
|
Type: "error",
|
||||||
|
Error: anthropic.ErrorDetail{
|
||||||
|
Type: "not_found_error",
|
||||||
|
Message: "模型未找到",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case router.ErrModelDisabled:
|
||||||
|
c.JSON(http.StatusNotFound, anthropic.ErrorResponse{
|
||||||
|
Type: "error",
|
||||||
|
Error: anthropic.ErrorDetail{
|
||||||
|
Type: "not_found_error",
|
||||||
|
Message: "模型已禁用",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case router.ErrProviderDisabled:
|
||||||
|
c.JSON(http.StatusNotFound, anthropic.ErrorResponse{
|
||||||
|
Type: "error",
|
||||||
|
Error: anthropic.ErrorDetail{
|
||||||
|
Type: "not_found_error",
|
||||||
|
Message: "供应商已禁用",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, anthropic.ErrorResponse{
|
||||||
|
Type: "error",
|
||||||
|
Error: anthropic.ErrorDetail{
|
||||||
|
Type: "internal_error",
|
||||||
|
Message: "内部错误: " + err.Error(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
161
backend/internal/handler/model_handler.go
Normal file
161
backend/internal/handler/model_handler.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"nex/backend/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ModelHandler 模型管理处理器
|
||||||
|
type ModelHandler struct{}
|
||||||
|
|
||||||
|
// NewModelHandler 创建模型处理器
|
||||||
|
func NewModelHandler() *ModelHandler {
|
||||||
|
return &ModelHandler{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateModel 创建模型
|
||||||
|
func (h *ModelHandler) CreateModel(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
ID string `json:"id" binding:"required"`
|
||||||
|
ProviderID string `json:"provider_id" binding:"required"`
|
||||||
|
ModelName string `json:"model_name" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "缺少必需字段: id, provider_id, model_name",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建模型对象
|
||||||
|
model := &config.Model{
|
||||||
|
ID: req.ID,
|
||||||
|
ProviderID: req.ProviderID,
|
||||||
|
ModelName: req.ModelName,
|
||||||
|
Enabled: true, // 默认启用
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存到数据库
|
||||||
|
err := config.CreateModel(model)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "供应商不存在" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "供应商不存在",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "创建模型失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListModels 列出模型
|
||||||
|
func (h *ModelHandler) ListModels(c *gin.Context) {
|
||||||
|
providerID := c.Query("provider_id")
|
||||||
|
|
||||||
|
models, err := config.ListModels(providerID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "查询模型失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetModel 获取模型
|
||||||
|
func (h *ModelHandler) GetModel(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
model, err := config.GetModel(id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"error": "模型未找到",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "查询模型失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateModel 更新模型
|
||||||
|
func (h *ModelHandler) UpdateModel(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
var req map[string]interface{}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "无效的请求格式",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新模型
|
||||||
|
err := config.UpdateModel(id, req)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"error": "模型未找到",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err.Error() == "供应商不存在" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "供应商不存在",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "更新模型失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回更新后的模型
|
||||||
|
model, err := config.GetModel(id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "查询更新后的模型失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteModel 删除模型
|
||||||
|
func (h *ModelHandler) DeleteModel(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
err := config.DeleteModel(id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"error": "模型未找到",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "删除模型失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
167
backend/internal/handler/openai_handler.go
Normal file
167
backend/internal/handler/openai_handler.go
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"nex/backend/internal/config"
|
||||||
|
"nex/backend/internal/protocol/openai"
|
||||||
|
"nex/backend/internal/provider"
|
||||||
|
"nex/backend/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OpenAIHandler OpenAI 协议处理器
|
||||||
|
type OpenAIHandler struct {
|
||||||
|
client *provider.Client
|
||||||
|
router *router.Router
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOpenAIHandler 创建 OpenAI 处理器
|
||||||
|
func NewOpenAIHandler() *OpenAIHandler {
|
||||||
|
return &OpenAIHandler{
|
||||||
|
client: provider.NewClient(),
|
||||||
|
router: router.NewRouter(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleChatCompletions 处理 Chat Completions 请求
|
||||||
|
func (h *OpenAIHandler) HandleChatCompletions(c *gin.Context) {
|
||||||
|
// 解析请求
|
||||||
|
var req openai.ChatCompletionRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, openai.ErrorResponse{
|
||||||
|
Error: openai.ErrorDetail{
|
||||||
|
Message: "无效的请求格式: " + err.Error(),
|
||||||
|
Type: "invalid_request_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 路由到供应商
|
||||||
|
routeResult, err := h.router.Route(req.Model)
|
||||||
|
if err != nil {
|
||||||
|
h.handleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据是否流式选择处理方式
|
||||||
|
if req.Stream {
|
||||||
|
h.handleStreamRequest(c, &req, routeResult)
|
||||||
|
} else {
|
||||||
|
h.handleNonStreamRequest(c, &req, routeResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNonStreamRequest 处理非流式请求
|
||||||
|
func (h *OpenAIHandler) handleNonStreamRequest(c *gin.Context, req *openai.ChatCompletionRequest, routeResult *router.RouteResult) {
|
||||||
|
// 发送请求到供应商
|
||||||
|
resp, err := h.client.SendRequest(c.Request.Context(), req, routeResult.Provider.APIKey, routeResult.Provider.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, openai.ErrorResponse{
|
||||||
|
Error: openai.ErrorDetail{
|
||||||
|
Message: "供应商请求失败: " + err.Error(),
|
||||||
|
Type: "api_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录统计
|
||||||
|
go func() {
|
||||||
|
_ = config.RecordRequest(routeResult.Provider.ID, req.Model)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 返回响应
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStreamRequest 处理流式请求
|
||||||
|
func (h *OpenAIHandler) handleStreamRequest(c *gin.Context, req *openai.ChatCompletionRequest, routeResult *router.RouteResult) {
|
||||||
|
// 发送流式请求到供应商
|
||||||
|
eventChan, err := h.client.SendStreamRequest(c.Request.Context(), req, routeResult.Provider.APIKey, routeResult.Provider.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, openai.ErrorResponse{
|
||||||
|
Error: openai.ErrorDetail{
|
||||||
|
Message: "供应商请求失败: " + err.Error(),
|
||||||
|
Type: "api_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 SSE 响应头
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
|
||||||
|
// 创建流写入器
|
||||||
|
writer := bufio.NewWriter(c.Writer)
|
||||||
|
|
||||||
|
// 流式转发事件
|
||||||
|
for event := range eventChan {
|
||||||
|
if event.Error != nil {
|
||||||
|
// 流错误,记录日志
|
||||||
|
fmt.Printf("流错误: %v\n", event.Error)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.Done {
|
||||||
|
// 流结束
|
||||||
|
writer.WriteString("data: [DONE]\n\n")
|
||||||
|
writer.Flush()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入事件数据
|
||||||
|
writer.WriteString("data: ")
|
||||||
|
writer.Write(event.Data)
|
||||||
|
writer.WriteString("\n\n")
|
||||||
|
writer.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录统计
|
||||||
|
go func() {
|
||||||
|
_ = config.RecordRequest(routeResult.Provider.ID, req.Model)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleError 处理路由错误
|
||||||
|
func (h *OpenAIHandler) handleError(c *gin.Context, err error) {
|
||||||
|
switch err {
|
||||||
|
case router.ErrModelNotFound:
|
||||||
|
c.JSON(http.StatusNotFound, openai.ErrorResponse{
|
||||||
|
Error: openai.ErrorDetail{
|
||||||
|
Message: "模型未找到",
|
||||||
|
Type: "invalid_request_error",
|
||||||
|
Code: "model_not_found",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case router.ErrModelDisabled:
|
||||||
|
c.JSON(http.StatusNotFound, openai.ErrorResponse{
|
||||||
|
Error: openai.ErrorDetail{
|
||||||
|
Message: "模型已禁用",
|
||||||
|
Type: "invalid_request_error",
|
||||||
|
Code: "model_disabled",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case router.ErrProviderDisabled:
|
||||||
|
c.JSON(http.StatusNotFound, openai.ErrorResponse{
|
||||||
|
Error: openai.ErrorDetail{
|
||||||
|
Message: "供应商已禁用",
|
||||||
|
Type: "invalid_request_error",
|
||||||
|
Code: "provider_disabled",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, openai.ErrorResponse{
|
||||||
|
Error: openai.ErrorDetail{
|
||||||
|
Message: "内部错误: " + err.Error(),
|
||||||
|
Type: "internal_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
167
backend/internal/handler/provider_handler.go
Normal file
167
backend/internal/handler/provider_handler.go
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"nex/backend/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProviderHandler 供应商管理处理器
|
||||||
|
type ProviderHandler struct{}
|
||||||
|
|
||||||
|
// NewProviderHandler 创建供应商处理器
|
||||||
|
func NewProviderHandler() *ProviderHandler {
|
||||||
|
return &ProviderHandler{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateProvider 创建供应商
|
||||||
|
func (h *ProviderHandler) CreateProvider(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
ID string `json:"id" binding:"required"`
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
APIKey string `json:"api_key" binding:"required"`
|
||||||
|
BaseURL string `json:"base_url" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "缺少必需字段: id, name, api_key, base_url",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建供应商对象
|
||||||
|
provider := &config.Provider{
|
||||||
|
ID: req.ID,
|
||||||
|
Name: req.Name,
|
||||||
|
APIKey: req.APIKey,
|
||||||
|
BaseURL: req.BaseURL,
|
||||||
|
Enabled: true, // 默认启用
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存到数据库
|
||||||
|
err := config.CreateProvider(provider)
|
||||||
|
if err != nil {
|
||||||
|
// 检查是否是唯一约束错误(ID 重复)
|
||||||
|
if err.Error() == "UNIQUE constraint failed: providers.id" {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{
|
||||||
|
"error": "供应商 ID 已存在",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "创建供应商失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 掩码 API Key 后返回
|
||||||
|
provider.MaskAPIKey()
|
||||||
|
c.JSON(http.StatusCreated, provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListProviders 列出所有供应商
|
||||||
|
func (h *ProviderHandler) ListProviders(c *gin.Context) {
|
||||||
|
providers, err := config.ListProviders()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "查询供应商失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, providers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProvider 获取供应商
|
||||||
|
func (h *ProviderHandler) GetProvider(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
provider, err := config.GetProvider(id, true) // 掩码 API Key
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"error": "供应商未找到",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "查询供应商失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProvider 更新供应商
|
||||||
|
func (h *ProviderHandler) UpdateProvider(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
var req map[string]interface{}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "无效的请求格式",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新供应商
|
||||||
|
err := config.UpdateProvider(id, req)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"error": "供应商未找到",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "更新供应商失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回更新后的供应商
|
||||||
|
provider, err := config.GetProvider(id, true)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "查询更新后的供应商失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteProvider 删除供应商
|
||||||
|
func (h *ProviderHandler) DeleteProvider(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
|
||||||
|
// 删除供应商(级联删除模型)
|
||||||
|
err := config.DeleteProvider(id)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"error": "供应商未找到",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "删除供应商失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除关联的模型
|
||||||
|
models, _ := config.ListModels("")
|
||||||
|
for _, model := range models {
|
||||||
|
if model.ProviderID == id {
|
||||||
|
_ = config.DeleteModel(model.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
184
backend/internal/handler/stats_handler.go
Normal file
184
backend/internal/handler/stats_handler.go
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"nex/backend/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatsHandler 统计处理器
|
||||||
|
type StatsHandler struct{}
|
||||||
|
|
||||||
|
// NewStatsHandler 创建统计处理器
|
||||||
|
func NewStatsHandler() *StatsHandler {
|
||||||
|
return &StatsHandler{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats 查询统计
|
||||||
|
func (h *StatsHandler) GetStats(c *gin.Context) {
|
||||||
|
// 解析查询参数
|
||||||
|
providerID := c.Query("provider_id")
|
||||||
|
modelName := c.Query("model_name")
|
||||||
|
startDateStr := c.Query("start_date")
|
||||||
|
endDateStr := c.Query("end_date")
|
||||||
|
|
||||||
|
var startDate, endDate *time.Time
|
||||||
|
|
||||||
|
// 解析日期
|
||||||
|
if startDateStr != "" {
|
||||||
|
t, err := time.Parse("2006-01-02", startDateStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "无效的 start_date 格式,应为 YYYY-MM-DD",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startDate = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
if endDateStr != "" {
|
||||||
|
t, err := time.Parse("2006-01-02", endDateStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "无效的 end_date 格式,应为 YYYY-MM-DD",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
endDate = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询统计
|
||||||
|
stats, err := config.GetStats(providerID, modelName, startDate, endDate)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "查询统计失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AggregateStats 聚合统计
|
||||||
|
func (h *StatsHandler) AggregateStats(c *gin.Context) {
|
||||||
|
// 解析查询参数
|
||||||
|
providerID := c.Query("provider_id")
|
||||||
|
modelName := c.Query("model_name")
|
||||||
|
startDateStr := c.Query("start_date")
|
||||||
|
endDateStr := c.Query("end_date")
|
||||||
|
groupBy := c.Query("group_by") // "provider", "model", "date"
|
||||||
|
|
||||||
|
var startDate, endDate *time.Time
|
||||||
|
|
||||||
|
// 解析日期
|
||||||
|
if startDateStr != "" {
|
||||||
|
t, err := time.Parse("2006-01-02", startDateStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "无效的 start_date 格式,应为 YYYY-MM-DD",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startDate = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
if endDateStr != "" {
|
||||||
|
t, err := time.Parse("2006-01-02", endDateStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "无效的 end_date 格式,应为 YYYY-MM-DD",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
endDate = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询统计
|
||||||
|
stats, err := config.GetStats(providerID, modelName, startDate, endDate)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "查询统计失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 聚合
|
||||||
|
result := h.aggregate(stats, groupBy)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// aggregate 执行聚合
|
||||||
|
func (h *StatsHandler) aggregate(stats []config.UsageStats, groupBy string) []map[string]interface{} {
|
||||||
|
switch groupBy {
|
||||||
|
case "provider":
|
||||||
|
return h.aggregateByProvider(stats)
|
||||||
|
case "model":
|
||||||
|
return h.aggregateByModel(stats)
|
||||||
|
case "date":
|
||||||
|
return h.aggregateByDate(stats)
|
||||||
|
default:
|
||||||
|
// 默认按供应商聚合
|
||||||
|
return h.aggregateByProvider(stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// aggregateByProvider 按供应商聚合
|
||||||
|
func (h *StatsHandler) aggregateByProvider(stats []config.UsageStats) []map[string]interface{} {
|
||||||
|
aggregated := make(map[string]int)
|
||||||
|
for _, stat := range stats {
|
||||||
|
aggregated[stat.ProviderID] += stat.RequestCount
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]map[string]interface{}, 0, len(aggregated))
|
||||||
|
for providerID, count := range aggregated {
|
||||||
|
result = append(result, map[string]interface{}{
|
||||||
|
"provider_id": providerID,
|
||||||
|
"request_count": count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// aggregateByModel 按模型聚合
|
||||||
|
func (h *StatsHandler) aggregateByModel(stats []config.UsageStats) []map[string]interface{} {
|
||||||
|
aggregated := make(map[string]int)
|
||||||
|
for _, stat := range stats {
|
||||||
|
key := stat.ProviderID + "/" + stat.ModelName
|
||||||
|
aggregated[key] += stat.RequestCount
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]map[string]interface{}, 0, len(aggregated))
|
||||||
|
for key, count := range aggregated {
|
||||||
|
result = append(result, map[string]interface{}{
|
||||||
|
"provider_id": key[:len(key)/2],
|
||||||
|
"model_name": key[len(key)/2+1:],
|
||||||
|
"request_count": count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// aggregateByDate 按日期聚合
|
||||||
|
func (h *StatsHandler) aggregateByDate(stats []config.UsageStats) []map[string]interface{} {
|
||||||
|
aggregated := make(map[string]int)
|
||||||
|
for _, stat := range stats {
|
||||||
|
key := stat.Date.Format("2006-01-02")
|
||||||
|
aggregated[key] += stat.RequestCount
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]map[string]interface{}, 0, len(aggregated))
|
||||||
|
for date, count := range aggregated {
|
||||||
|
result = append(result, map[string]interface{}{
|
||||||
|
"date": date,
|
||||||
|
"request_count": count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
234
backend/internal/protocol/anthropic/converter.go
Normal file
234
backend/internal/protocol/anthropic/converter.go
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
package anthropic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"nex/backend/internal/protocol/openai"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConvertRequest 将 Anthropic 请求转换为 OpenAI 请求
|
||||||
|
func ConvertRequest(anthropicReq *MessagesRequest) (*openai.ChatCompletionRequest, error) {
|
||||||
|
openaiReq := &openai.ChatCompletionRequest{
|
||||||
|
Model: anthropicReq.Model,
|
||||||
|
Temperature: anthropicReq.Temperature,
|
||||||
|
TopP: anthropicReq.TopP,
|
||||||
|
Stream: anthropicReq.Stream,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 max_tokens(Anthropic 要求必须有,默认 4096)
|
||||||
|
if anthropicReq.MaxTokens > 0 {
|
||||||
|
openaiReq.MaxTokens = &anthropicReq.MaxTokens
|
||||||
|
} else {
|
||||||
|
defaultMax := 4096
|
||||||
|
openaiReq.MaxTokens = &defaultMax
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 stop_sequences
|
||||||
|
if len(anthropicReq.StopSequences) > 0 {
|
||||||
|
openaiReq.Stop = anthropicReq.StopSequences
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换 system 消息
|
||||||
|
messages := make([]openai.Message, 0)
|
||||||
|
if anthropicReq.System != "" {
|
||||||
|
messages = append(messages, openai.Message{
|
||||||
|
Role: "system",
|
||||||
|
Content: anthropicReq.System,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换 messages
|
||||||
|
for _, msg := range anthropicReq.Messages {
|
||||||
|
openaiMsg, err := convertMessage(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
messages = append(messages, openaiMsg...)
|
||||||
|
}
|
||||||
|
openaiReq.Messages = messages
|
||||||
|
|
||||||
|
// 转换 tools
|
||||||
|
if len(anthropicReq.Tools) > 0 {
|
||||||
|
openaiReq.Tools = make([]openai.Tool, len(anthropicReq.Tools))
|
||||||
|
for i, tool := range anthropicReq.Tools {
|
||||||
|
openaiReq.Tools[i] = openai.Tool{
|
||||||
|
Type: "function",
|
||||||
|
Function: openai.FunctionDefinition{
|
||||||
|
Name: tool.Name,
|
||||||
|
Description: tool.Description,
|
||||||
|
Parameters: tool.InputSchema,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换 tool_choice
|
||||||
|
if anthropicReq.ToolChoice != nil {
|
||||||
|
toolChoice, err := convertToolChoice(anthropicReq.ToolChoice)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
openaiReq.ToolChoice = toolChoice
|
||||||
|
}
|
||||||
|
|
||||||
|
return openaiReq, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertResponse 将 OpenAI 响应转换为 Anthropic 响应
|
||||||
|
func ConvertResponse(openaiResp *openai.ChatCompletionResponse) (*MessagesResponse, error) {
|
||||||
|
anthropicResp := &MessagesResponse{
|
||||||
|
ID: openaiResp.ID,
|
||||||
|
Type: "message",
|
||||||
|
Role: "assistant",
|
||||||
|
Model: openaiResp.Model,
|
||||||
|
Usage: Usage{
|
||||||
|
InputTokens: openaiResp.Usage.PromptTokens,
|
||||||
|
OutputTokens: openaiResp.Usage.CompletionTokens,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换 content
|
||||||
|
if len(openaiResp.Choices) > 0 {
|
||||||
|
choice := openaiResp.Choices[0]
|
||||||
|
content := make([]ContentBlock, 0)
|
||||||
|
|
||||||
|
if choice.Message != nil {
|
||||||
|
// 文本内容
|
||||||
|
if choice.Message.Content != "" {
|
||||||
|
if str, ok := choice.Message.Content.(string); ok && str != "" {
|
||||||
|
content = append(content, ContentBlock{
|
||||||
|
Type: "text",
|
||||||
|
Text: str,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool calls
|
||||||
|
if len(choice.Message.ToolCalls) > 0 {
|
||||||
|
for _, tc := range choice.Message.ToolCalls {
|
||||||
|
// 解析 arguments JSON
|
||||||
|
var input interface{}
|
||||||
|
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
|
||||||
|
return nil, fmt.Errorf("解析 tool_call arguments 失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content = append(content, ContentBlock{
|
||||||
|
Type: "tool_use",
|
||||||
|
ID: tc.ID,
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
Input: input,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
anthropicResp.Content = content
|
||||||
|
|
||||||
|
// 转换 finish_reason
|
||||||
|
switch choice.FinishReason {
|
||||||
|
case "stop":
|
||||||
|
anthropicResp.StopReason = "end_turn"
|
||||||
|
case "tool_calls":
|
||||||
|
anthropicResp.StopReason = "tool_use"
|
||||||
|
case "length":
|
||||||
|
anthropicResp.StopReason = "max_tokens"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return anthropicResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertMessage 转换单条消息
|
||||||
|
func convertMessage(msg AnthropicMessage) ([]openai.Message, error) {
|
||||||
|
var messages []openai.Message
|
||||||
|
|
||||||
|
// 处理 content
|
||||||
|
for _, block := range msg.Content {
|
||||||
|
switch block.Type {
|
||||||
|
case "text":
|
||||||
|
// 文本内容
|
||||||
|
messages = append(messages, openai.Message{
|
||||||
|
Role: msg.Role,
|
||||||
|
Content: block.Text,
|
||||||
|
})
|
||||||
|
|
||||||
|
case "tool_result":
|
||||||
|
// 工具结果
|
||||||
|
content := ""
|
||||||
|
if str, ok := block.Content.(string); ok {
|
||||||
|
content = str
|
||||||
|
} else {
|
||||||
|
// 如果是数组或其他类型,序列化为 JSON
|
||||||
|
bytes, err := json.Marshal(block.Content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("序列化 tool_result 内容失败: %w", err)
|
||||||
|
}
|
||||||
|
content = string(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = append(messages, openai.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: content,
|
||||||
|
ToolCallID: block.ToolUseID,
|
||||||
|
})
|
||||||
|
|
||||||
|
case "image":
|
||||||
|
// MVP 不支持多模态
|
||||||
|
return nil, fmt.Errorf("MVP 不支持多模态内容(图片)")
|
||||||
|
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("未知的内容块类型: %s", block.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有 content,创建空消息(不应该发生)
|
||||||
|
if len(messages) == 0 {
|
||||||
|
messages = append(messages, openai.Message{
|
||||||
|
Role: msg.Role,
|
||||||
|
Content: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertToolChoice 转换工具选择
|
||||||
|
func convertToolChoice(choice interface{}) (interface{}, error) {
|
||||||
|
// 如果是字符串
|
||||||
|
if str, ok := choice.(string); ok {
|
||||||
|
// "auto" 或 "any" 都映射为 "auto"
|
||||||
|
if str == "auto" || str == "any" {
|
||||||
|
return "auto", nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("无效的 tool_choice 字符串: %s", str)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是对象
|
||||||
|
if obj, ok := choice.(map[string]interface{}); ok {
|
||||||
|
choiceType, ok := obj["type"].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("tool_choice 对象缺少 type 字段")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch choiceType {
|
||||||
|
case "auto", "any":
|
||||||
|
return "auto", nil
|
||||||
|
case "tool":
|
||||||
|
name, ok := obj["name"].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("tool_choice type=tool 缺少 name 字段")
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": "function",
|
||||||
|
"function": map[string]string{
|
||||||
|
"name": name,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("无效的 tool_choice type: %s", choiceType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("tool_choice 格式无效")
|
||||||
|
}
|
||||||
164
backend/internal/protocol/anthropic/stream_converter.go
Normal file
164
backend/internal/protocol/anthropic/stream_converter.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package anthropic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"nex/backend/internal/protocol/openai"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StreamConverter 流式转换器
|
||||||
|
type StreamConverter struct {
|
||||||
|
messageID string
|
||||||
|
model string
|
||||||
|
index int // 当前 content block index
|
||||||
|
toolCallArgs map[int]string // 缓存每个 tool_call 的 arguments
|
||||||
|
sentStart bool // 是否已发送 message_start
|
||||||
|
sentBlockStart map[int]bool // 每个 index 是否已发送 content_block_start
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStreamConverter 创建流式转换器
|
||||||
|
func NewStreamConverter(messageID, model string) *StreamConverter {
|
||||||
|
return &StreamConverter{
|
||||||
|
messageID: messageID,
|
||||||
|
model: model,
|
||||||
|
index: 0,
|
||||||
|
toolCallArgs: make(map[int]string),
|
||||||
|
sentStart: false,
|
||||||
|
sentBlockStart: make(map[int]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertChunk 转换 OpenAI 流块为 Anthropic 事件
|
||||||
|
func (c *StreamConverter) ConvertChunk(chunk *openai.StreamChunk) ([]StreamEvent, error) {
|
||||||
|
var events []StreamEvent
|
||||||
|
|
||||||
|
// 发送 message_start(仅一次)
|
||||||
|
if !c.sentStart {
|
||||||
|
events = append(events, StreamEvent{
|
||||||
|
Type: "message_start",
|
||||||
|
Message: &MessagesResponse{
|
||||||
|
ID: c.messageID,
|
||||||
|
Type: "message",
|
||||||
|
Role: "assistant",
|
||||||
|
Model: c.model,
|
||||||
|
Content: []ContentBlock{},
|
||||||
|
Usage: Usage{
|
||||||
|
InputTokens: 0,
|
||||||
|
OutputTokens: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
c.sentStart = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理每个 choice
|
||||||
|
for _, choice := range chunk.Choices {
|
||||||
|
// 处理 content delta
|
||||||
|
if choice.Delta.Content != "" {
|
||||||
|
// 发送 content_block_start(如果还没发送)
|
||||||
|
if !c.sentBlockStart[c.index] {
|
||||||
|
events = append(events, StreamEvent{
|
||||||
|
Type: "content_block_start",
|
||||||
|
Index: c.index,
|
||||||
|
ContentBlock: &ContentBlock{
|
||||||
|
Type: "text",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
c.sentBlockStart[c.index] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送 text delta
|
||||||
|
events = append(events, StreamEvent{
|
||||||
|
Type: "content_block_delta",
|
||||||
|
Index: c.index,
|
||||||
|
Delta: &Delta{
|
||||||
|
Type: "text_delta",
|
||||||
|
Text: choice.Delta.Content,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 tool_calls delta
|
||||||
|
if len(choice.Delta.ToolCalls) > 0 {
|
||||||
|
for _, tc := range choice.Delta.ToolCalls {
|
||||||
|
// 确定 tool_call index
|
||||||
|
toolIndex := c.index + len(c.toolCallArgs)
|
||||||
|
|
||||||
|
// 发送 content_block_start(如果还没发送)
|
||||||
|
if !c.sentBlockStart[toolIndex] {
|
||||||
|
events = append(events, StreamEvent{
|
||||||
|
Type: "content_block_start",
|
||||||
|
Index: toolIndex,
|
||||||
|
ContentBlock: &ContentBlock{
|
||||||
|
Type: "tool_use",
|
||||||
|
ID: tc.ID,
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
c.sentBlockStart[toolIndex] = true
|
||||||
|
c.toolCallArgs[toolIndex] = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存 arguments
|
||||||
|
c.toolCallArgs[toolIndex] += tc.Function.Arguments
|
||||||
|
|
||||||
|
// 发送 input delta
|
||||||
|
events = append(events, StreamEvent{
|
||||||
|
Type: "content_block_delta",
|
||||||
|
Index: toolIndex,
|
||||||
|
Delta: &Delta{
|
||||||
|
Type: "input_json_delta",
|
||||||
|
Input: tc.Function.Arguments,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 finish_reason
|
||||||
|
if choice.FinishReason != "" {
|
||||||
|
// 发送 content_block_stop
|
||||||
|
for idx := range c.sentBlockStart {
|
||||||
|
events = append(events, StreamEvent{
|
||||||
|
Type: "content_block_stop",
|
||||||
|
Index: idx,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换 stop_reason
|
||||||
|
stopReason := ""
|
||||||
|
switch choice.FinishReason {
|
||||||
|
case "stop":
|
||||||
|
stopReason = "end_turn"
|
||||||
|
case "tool_calls":
|
||||||
|
stopReason = "tool_use"
|
||||||
|
case "length":
|
||||||
|
stopReason = "max_tokens"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送 message_delta
|
||||||
|
events = append(events, StreamEvent{
|
||||||
|
Type: "message_delta",
|
||||||
|
Delta: &Delta{
|
||||||
|
StopReason: stopReason,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 发送 message_stop
|
||||||
|
events = append(events, StreamEvent{
|
||||||
|
Type: "message_stop",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return events, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SerializeEvent 序列化事件为 SSE 格式
|
||||||
|
func SerializeEvent(event StreamEvent) (string, error) {
|
||||||
|
bytes, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("event: %s\ndata: %s\n\n", event.Type, string(bytes)), nil
|
||||||
|
}
|
||||||
118
backend/internal/protocol/anthropic/types.go
Normal file
118
backend/internal/protocol/anthropic/types.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package anthropic
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// MessagesRequest Anthropic Messages API 请求结构
|
||||||
|
type MessagesRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []AnthropicMessage `json:"messages"`
|
||||||
|
System string `json:"system,omitempty"`
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
|
TopK *int `json:"top_k,omitempty"`
|
||||||
|
StopSequences []string `json:"stop_sequences,omitempty"`
|
||||||
|
Stream bool `json:"stream,omitempty"`
|
||||||
|
Tools []AnthropicTool `json:"tools,omitempty"`
|
||||||
|
ToolChoice interface{} `json:"tool_choice,omitempty"` // 可以是字符串或对象
|
||||||
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthropicMessage Anthropic 消息结构
|
||||||
|
type AnthropicMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content []ContentBlock `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentBlock 内容块
|
||||||
|
type ContentBlock struct {
|
||||||
|
Type string `json:"type"` // "text", "image", "tool_use", "tool_result"
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Input interface{} `json:"input,omitempty"` // 用于 tool_use
|
||||||
|
|
||||||
|
// tool_use 字段
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
|
||||||
|
// tool_result 字段
|
||||||
|
ToolUseID string `json:"tool_use_id,omitempty"`
|
||||||
|
Content interface{} `json:"content,omitempty"` // 可以是字符串或数组
|
||||||
|
|
||||||
|
// 多模态字段(MVP 不支持)
|
||||||
|
Source interface{} `json:"source,omitempty"` // 用于 image
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthropicTool Anthropic 工具定义
|
||||||
|
type AnthropicTool struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
InputSchema map[string]interface{} `json:"input_schema"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolChoice 工具选择
|
||||||
|
type ToolChoice struct {
|
||||||
|
Type string `json:"type"` // "auto", "any", "tool"
|
||||||
|
Name string `json:"name,omitempty"` // 当 type="tool" 时使用
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessagesResponse Anthropic Messages API 响应结构
|
||||||
|
type MessagesResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"` // "message"
|
||||||
|
Role string `json:"role"` // "assistant"
|
||||||
|
Content []ContentBlock `json:"content"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
StopReason string `json:"stop_reason,omitempty"` // "end_turn", "max_tokens", "stop_sequence", "tool_use"
|
||||||
|
StopSequence string `json:"stop_sequence,omitempty"`
|
||||||
|
Usage Usage `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage 使用统计
|
||||||
|
type Usage struct {
|
||||||
|
InputTokens int `json:"input_tokens"`
|
||||||
|
OutputTokens int `json:"output_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamEvent 流式事件
|
||||||
|
type StreamEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message *MessagesResponse `json:"message,omitempty"` // 用于 message_start
|
||||||
|
Index int `json:"index,omitempty"` // 用于 content_block_* 事件
|
||||||
|
ContentBlock *ContentBlock `json:"content_block,omitempty"` // 用于 content_block_start
|
||||||
|
Delta *Delta `json:"delta,omitempty"` // 用于 content_block_delta
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delta 增量内容
|
||||||
|
type Delta struct {
|
||||||
|
Type string `json:"type,omitempty"` // "text_delta", "input_json_delta"
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Input string `json:"input,omitempty"` // 用于 tool_use 的部分 JSON
|
||||||
|
StopReason string `json:"stop_reason,omitempty"` // 用于 message_delta
|
||||||
|
Usage *Usage `json:"usage,omitempty"` // 用于 message_delta
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorResponse Anthropic 错误响应
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Type string `json:"type"` // "error"
|
||||||
|
Error ErrorDetail `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorDetail 错误详情
|
||||||
|
type ErrorDetail struct {
|
||||||
|
Type string `json:"type"` // "invalid_request_error", "authentication_error", etc.
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseInputJSON 解析 tool_use 的 input(从 JSON 字符串转为 map)
|
||||||
|
func (cb *ContentBlock) ParseInputJSON() (map[string]interface{}, error) {
|
||||||
|
if str, ok := cb.Input.(string); ok {
|
||||||
|
var result map[string]interface{}
|
||||||
|
err := json.Unmarshal([]byte(str), &result)
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
// 如果已经是对象,直接返回
|
||||||
|
if obj, ok := cb.Input.(map[string]interface{}); ok {
|
||||||
|
return obj, nil
|
||||||
|
}
|
||||||
|
return nil, json.Unmarshal([]byte{}, nil) // 返回错误
|
||||||
|
}
|
||||||
86
backend/internal/protocol/openai/adapter.go
Normal file
86
backend/internal/protocol/openai/adapter.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Adapter OpenAI 协议适配器(透传)
|
||||||
|
type Adapter struct{}
|
||||||
|
|
||||||
|
// NewAdapter 创建 OpenAI 适配器
|
||||||
|
func NewAdapter() *Adapter {
|
||||||
|
return &Adapter{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrepareRequest 准备发送给供应商的请求(透传)
|
||||||
|
func (a *Adapter) PrepareRequest(req *ChatCompletionRequest, apiKey, baseURL string) (*http.Request, error) {
|
||||||
|
// 序列化请求体
|
||||||
|
body, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调试日志:打印请求体
|
||||||
|
fmt.Printf("[DEBUG] 请求Body: %s\n", string(body))
|
||||||
|
|
||||||
|
// 创建 HTTP 请求
|
||||||
|
// baseURL 已包含版本路径(如 /v1 或 /v4),只需添加端点路径
|
||||||
|
httpReq, err := http.NewRequest("POST", baseURL+"/chat/completions", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置请求头
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
|
||||||
|
return httpReq, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseResponse 解析供应商响应(透传)
|
||||||
|
func (a *Adapter) ParseResponse(resp *http.Response) (*ChatCompletionResponse, error) {
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result ChatCompletionResponse
|
||||||
|
err = json.Unmarshal(body, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseErrorResponse 解析错误响应
|
||||||
|
func (a *Adapter) ParseErrorResponse(resp *http.Response) (*ErrorResponse, error) {
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result ErrorResponse
|
||||||
|
err = json.Unmarshal(body, &result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseStreamChunk 解析流式响应块
|
||||||
|
func (a *Adapter) ParseStreamChunk(data []byte) (*StreamChunk, error) {
|
||||||
|
var chunk StreamChunk
|
||||||
|
err := json.Unmarshal(data, &chunk)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &chunk, nil
|
||||||
|
}
|
||||||
131
backend/internal/protocol/openai/types.go
Normal file
131
backend/internal/protocol/openai/types.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package openai
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// ChatCompletionRequest OpenAI Chat Completions API 请求结构
|
||||||
|
type ChatCompletionRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []Message `json:"messages"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||||
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
|
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
|
||||||
|
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
|
||||||
|
Stop interface{} `json:"stop,omitempty"` // 可以是字符串或字符串数组
|
||||||
|
N *int `json:"n,omitempty"`
|
||||||
|
Stream bool `json:"stream,omitempty"`
|
||||||
|
Tools []Tool `json:"tools,omitempty"`
|
||||||
|
ToolChoice interface{} `json:"tool_choice,omitempty"` // 可以是字符串或对象
|
||||||
|
User string `json:"user,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message OpenAI 消息结构
|
||||||
|
type Message struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content interface{} `json:"content"` // 可以是字符串或数组(多模态,MVP不支持)
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||||
|
ToolCallID string `json:"tool_call_id,omitempty"` // 用于 role="tool" 的消息
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool OpenAI 工具定义
|
||||||
|
type Tool struct {
|
||||||
|
Type string `json:"type"` // 目前只有 "function"
|
||||||
|
Function FunctionDefinition `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FunctionDefinition 函数定义
|
||||||
|
type FunctionDefinition struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Parameters map[string]interface{} `json:"parameters,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToolCall 工具调用
|
||||||
|
type ToolCall struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"` // "function"
|
||||||
|
Function FunctionCall `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FunctionCall 函数调用
|
||||||
|
type FunctionCall struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"` // JSON 字符串
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatCompletionResponse OpenAI Chat Completions API 响应结构
|
||||||
|
type ChatCompletionResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Choices []Choice `json:"choices"`
|
||||||
|
Usage Usage `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choice 响应选项
|
||||||
|
type Choice struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Message *Message `json:"message,omitempty"`
|
||||||
|
Delta *Delta `json:"delta,omitempty"` // 用于流式响应
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delta 流式响应增量
|
||||||
|
type Delta struct {
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage Token 使用统计
|
||||||
|
type Usage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamChunk 流式响应块
|
||||||
|
type StreamChunk struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Choices []StreamChoice `json:"choices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamChoice 流式响应选项
|
||||||
|
type StreamChoice struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Delta Delta `json:"delta"`
|
||||||
|
FinishReason string `json:"finish_reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorResponse OpenAI 错误响应
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Error ErrorDetail `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorDetail 错误详情
|
||||||
|
type ErrorDetail struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseToolCallArguments 解析 tool_call 的 arguments(从 JSON 字符串转为 map)
|
||||||
|
func (tc *ToolCall) ParseToolCallArguments() (map[string]interface{}, error) {
|
||||||
|
var args map[string]interface{}
|
||||||
|
err := json.Unmarshal([]byte(tc.Function.Arguments), &args)
|
||||||
|
return args, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SerializeToolCallArguments 序列化 tool_call 的 arguments(从 map 转为 JSON 字符串)
|
||||||
|
func SerializeToolCallArguments(args map[string]interface{}) (string, error) {
|
||||||
|
bytes, err := json.Marshal(args)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(bytes), nil
|
||||||
|
}
|
||||||
177
backend/internal/provider/client.go
Normal file
177
backend/internal/provider/client.go
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
package provider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"nex/backend/internal/protocol/openai"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client OpenAI 兼容供应商客户端
|
||||||
|
type Client struct {
|
||||||
|
httpClient *http.Client
|
||||||
|
adapter *openai.Adapter
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient 创建供应商客户端
|
||||||
|
func NewClient() *Client {
|
||||||
|
return &Client{
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 30 * time.Second, // 非流式请求超时
|
||||||
|
},
|
||||||
|
adapter: openai.NewAdapter(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendRequest 发送非流式请求
|
||||||
|
func (c *Client) SendRequest(ctx context.Context, req *openai.ChatCompletionRequest, apiKey, baseURL string) (*openai.ChatCompletionResponse, error) {
|
||||||
|
// 准备请求
|
||||||
|
httpReq, err := c.adapter.PrepareRequest(req, apiKey, baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("准备请求失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调试日志:打印完整请求信息
|
||||||
|
fmt.Printf("[DEBUG] 请求URL: %s\n", httpReq.URL.String())
|
||||||
|
fmt.Printf("[DEBUG] 请求Method: %s\n", httpReq.Method)
|
||||||
|
fmt.Printf("[DEBUG] 请求Headers: %v\n", httpReq.Header)
|
||||||
|
|
||||||
|
// 设置上下文
|
||||||
|
httpReq = httpReq.WithContext(ctx)
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
resp, err := c.httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("发送请求失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查状态码
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
// 解析错误响应
|
||||||
|
errorResp, parseErr := c.adapter.ParseErrorResponse(resp)
|
||||||
|
if parseErr != nil {
|
||||||
|
return nil, fmt.Errorf("供应商返回错误: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("供应商错误: %s", errorResp.Error.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析响应
|
||||||
|
result, err := c.adapter.ParseResponse(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("解析响应失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendStreamRequest 发送流式请求
|
||||||
|
func (c *Client) SendStreamRequest(ctx context.Context, req *openai.ChatCompletionRequest, apiKey, baseURL string) (<-chan StreamEvent, error) {
|
||||||
|
// 确保请求设置为流式
|
||||||
|
req.Stream = true
|
||||||
|
|
||||||
|
// 准备请求
|
||||||
|
httpReq, err := c.adapter.PrepareRequest(req, apiKey, baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("准备请求失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置上下文
|
||||||
|
httpReq = httpReq.WithContext(ctx)
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
resp, err := c.httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("发送请求失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查状态码
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
errorResp, parseErr := c.adapter.ParseErrorResponse(resp)
|
||||||
|
if parseErr != nil {
|
||||||
|
return nil, fmt.Errorf("供应商返回错误: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("供应商错误: %s", errorResp.Error.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建事件通道
|
||||||
|
eventChan := make(chan StreamEvent, 100)
|
||||||
|
|
||||||
|
// 启动 goroutine 读取流
|
||||||
|
go c.readStream(ctx, resp.Body, eventChan)
|
||||||
|
|
||||||
|
return eventChan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamEvent 流事件
|
||||||
|
type StreamEvent struct {
|
||||||
|
Data []byte
|
||||||
|
Error error
|
||||||
|
Done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// readStream 读取 SSE 流
|
||||||
|
func (c *Client) readStream(ctx context.Context, body io.ReadCloser, eventChan chan<- StreamEvent) {
|
||||||
|
defer close(eventChan)
|
||||||
|
defer body.Close()
|
||||||
|
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
var dataBuf []byte
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
eventChan <- StreamEvent{Error: ctx.Err()}
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := body.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
// 流结束
|
||||||
|
return
|
||||||
|
}
|
||||||
|
eventChan <- StreamEvent{Error: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dataBuf = append(dataBuf, buf[:n]...)
|
||||||
|
|
||||||
|
// 处理完整的 SSE 事件
|
||||||
|
for {
|
||||||
|
// 查找事件边界(双换行)
|
||||||
|
idx := bytes.Index(dataBuf, []byte("\n\n"))
|
||||||
|
if idx == -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取事件
|
||||||
|
event := dataBuf[:idx]
|
||||||
|
dataBuf = dataBuf[idx+2:]
|
||||||
|
|
||||||
|
// 解析 data 行
|
||||||
|
lines := strings.Split(string(event), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.HasPrefix(line, "data: ") {
|
||||||
|
data := strings.TrimPrefix(line, "data: ")
|
||||||
|
|
||||||
|
// 检查是否是结束标记
|
||||||
|
if data == "[DONE]" {
|
||||||
|
eventChan <- StreamEvent{Done: true}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送数据
|
||||||
|
eventChan <- StreamEvent{Data: []byte(data)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
71
backend/internal/router/model_router.go
Normal file
71
backend/internal/router/model_router.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"nex/backend/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrModelNotFound = errors.New("模型未找到")
|
||||||
|
ErrModelDisabled = errors.New("模型已禁用")
|
||||||
|
ErrProviderDisabled = errors.New("供应商已禁用")
|
||||||
|
)
|
||||||
|
|
||||||
|
// RouteResult 路由结果
|
||||||
|
type RouteResult struct {
|
||||||
|
Provider *config.Provider
|
||||||
|
Model *config.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
// Router 模型路由器
|
||||||
|
type Router struct{}
|
||||||
|
|
||||||
|
// NewRouter 创建路由器
|
||||||
|
func NewRouter() *Router {
|
||||||
|
return &Router{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route 根据模型名称路由到供应商
|
||||||
|
func (r *Router) Route(modelName string) (*RouteResult, error) {
|
||||||
|
// 查询模型
|
||||||
|
models, err := config.ListModels("")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询模型失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找匹配的模型
|
||||||
|
var targetModel *config.Model
|
||||||
|
for i := range models {
|
||||||
|
if models[i].ModelName == modelName {
|
||||||
|
targetModel = &models[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetModel == nil {
|
||||||
|
return nil, ErrModelNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查模型是否启用
|
||||||
|
if !targetModel.Enabled {
|
||||||
|
return nil, ErrModelDisabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询供应商
|
||||||
|
provider, err := config.GetProvider(targetModel.ProviderID, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询供应商失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查供应商是否启用
|
||||||
|
if !provider.Enabled {
|
||||||
|
return nil, ErrProviderDisabled
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RouteResult{
|
||||||
|
Provider: provider,
|
||||||
|
Model: targetModel,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
92
frontend/README.md
Normal file
92
frontend/README.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# AI Gateway Frontend
|
||||||
|
|
||||||
|
AI 网关管理前端,提供供应商配置和用量统计界面。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **运行时**: Bun
|
||||||
|
- **构建工具**: Vite
|
||||||
|
- **语言**: TypeScript
|
||||||
|
- **框架**: React
|
||||||
|
- **样式**: SCSS
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── api/
|
||||||
|
│ │ └── client.ts # API 客户端封装
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ ├── ProvidersPage.tsx # 供应商管理页面
|
||||||
|
│ │ └── StatsPage.tsx # 统计查看页面
|
||||||
|
│ ├── App.tsx # 主应用组件
|
||||||
|
│ ├── App.css # 样式
|
||||||
|
│ └── main.tsx # 入口文件
|
||||||
|
├── package.json
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行方式
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 开发模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
前端将在端口 5173 启动。
|
||||||
|
|
||||||
|
### 构建生产版本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
### 供应商管理
|
||||||
|
|
||||||
|
- 查看供应商列表
|
||||||
|
- 添加新供应商
|
||||||
|
- 编辑供应商配置
|
||||||
|
- 删除供应商
|
||||||
|
- 启用/禁用供应商
|
||||||
|
|
||||||
|
### 模型管理
|
||||||
|
|
||||||
|
- 查看模型列表
|
||||||
|
- 添加新模型
|
||||||
|
- 编辑模型配置
|
||||||
|
- 删除模型
|
||||||
|
- 按供应商过滤模型
|
||||||
|
|
||||||
|
### 用量统计
|
||||||
|
|
||||||
|
- 查看统计数据
|
||||||
|
- 按供应商过滤
|
||||||
|
- 按模型过滤
|
||||||
|
- 按日期范围过滤
|
||||||
|
- 查看聚合统计
|
||||||
|
|
||||||
|
## API 配置
|
||||||
|
|
||||||
|
API 基础地址默认为 `http://localhost:9826/api`,可在 `src/api/client.ts` 中修改。
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Bun 1.0 或更高版本
|
||||||
|
|
||||||
|
### 添加新页面
|
||||||
|
|
||||||
|
1. 在 `src/pages/` 创建页面组件
|
||||||
|
2. 在 `src/App.tsx` 添加路由
|
||||||
|
3. 在导航栏添加链接
|
||||||
473
frontend/bun.lock
Normal file
473
frontend/bun.lock
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "frontend",
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"sass": "^1.99.0",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@types/node": "^24.12.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"typescript-eslint": "^8.58.0",
|
||||||
|
"vite": "^8.0.4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@babel/code-frame": ["@babel/code-frame@7.29.0", "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||||
|
|
||||||
|
"@babel/compat-data": ["@babel/compat-data@7.29.0", "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.0.tgz", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||||
|
|
||||||
|
"@babel/core": ["@babel/core@7.29.0", "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
|
||||||
|
|
||||||
|
"@babel/generator": ["@babel/generator@7.29.1", "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
|
||||||
|
|
||||||
|
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
||||||
|
|
||||||
|
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||||
|
|
||||||
|
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
||||||
|
|
||||||
|
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
||||||
|
|
||||||
|
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||||
|
|
||||||
|
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||||
|
|
||||||
|
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||||
|
|
||||||
|
"@babel/helpers": ["@babel/helpers@7.29.2", "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.2.tgz", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
|
||||||
|
|
||||||
|
"@babel/parser": ["@babel/parser@7.29.2", "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
|
||||||
|
|
||||||
|
"@babel/template": ["@babel/template@7.28.6", "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||||
|
|
||||||
|
"@babel/traverse": ["@babel/traverse@7.29.0", "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||||
|
|
||||||
|
"@babel/types": ["@babel/types@7.29.0", "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||||
|
|
||||||
|
"@emnapi/core": ["@emnapi/core@1.9.2", "https://registry.npmmirror.com/@emnapi/core/-/core-1.9.2.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
||||||
|
|
||||||
|
"@emnapi/runtime": ["@emnapi/runtime@1.9.2", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.9.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||||
|
|
||||||
|
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||||
|
|
||||||
|
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
||||||
|
|
||||||
|
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||||
|
|
||||||
|
"@eslint/config-array": ["@eslint/config-array@0.21.2", "https://registry.npmmirror.com/@eslint/config-array/-/config-array-0.21.2.tgz", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="],
|
||||||
|
|
||||||
|
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "https://registry.npmmirror.com/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
|
||||||
|
|
||||||
|
"@eslint/core": ["@eslint/core@0.17.0", "https://registry.npmmirror.com/@eslint/core/-/core-0.17.0.tgz", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
|
||||||
|
|
||||||
|
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="],
|
||||||
|
|
||||||
|
"@eslint/js": ["@eslint/js@9.39.4", "https://registry.npmmirror.com/@eslint/js/-/js-9.39.4.tgz", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="],
|
||||||
|
|
||||||
|
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-2.1.7.tgz", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
|
||||||
|
|
||||||
|
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "https://registry.npmmirror.com/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
||||||
|
|
||||||
|
"@humanfs/core": ["@humanfs/core@0.19.1", "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.1.tgz", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||||
|
|
||||||
|
"@humanfs/node": ["@humanfs/node@0.16.7", "https://registry.npmmirror.com/@humanfs/node/-/node-0.16.7.tgz", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
||||||
|
|
||||||
|
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||||
|
|
||||||
|
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||||
|
|
||||||
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
|
|
||||||
|
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||||
|
|
||||||
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="],
|
||||||
|
|
||||||
|
"@oxc-project/types": ["@oxc-project/types@0.124.0", "https://registry.npmmirror.com/@oxc-project/types/-/types-0.124.0.tgz", {}, "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg=="],
|
||||||
|
|
||||||
|
"@parcel/watcher": ["@parcel/watcher@2.5.6", "https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.5.6.tgz", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],
|
||||||
|
|
||||||
|
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "https://registry.npmmirror.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="],
|
||||||
|
|
||||||
|
"@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "https://registry.npmmirror.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="],
|
||||||
|
|
||||||
|
"@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "https://registry.npmmirror.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="],
|
||||||
|
|
||||||
|
"@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "https://registry.npmmirror.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="],
|
||||||
|
|
||||||
|
"@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "https://registry.npmmirror.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="],
|
||||||
|
|
||||||
|
"@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "https://registry.npmmirror.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="],
|
||||||
|
|
||||||
|
"@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="],
|
||||||
|
|
||||||
|
"@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="],
|
||||||
|
|
||||||
|
"@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "https://registry.npmmirror.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="],
|
||||||
|
|
||||||
|
"@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "https://registry.npmmirror.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="],
|
||||||
|
|
||||||
|
"@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "https://registry.npmmirror.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="],
|
||||||
|
|
||||||
|
"@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "https://registry.npmmirror.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="],
|
||||||
|
|
||||||
|
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "https://registry.npmmirror.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "arm" }, "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "x64" }, "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", { "os": "linux", "cpu": "x64" }, "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", { "os": "none", "cpu": "arm64" }, "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", { "dependencies": { "@emnapi/core": "1.9.2", "@emnapi/runtime": "1.9.2", "@napi-rs/wasm-runtime": "^1.1.3" }, "cpu": "none" }, "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA=="],
|
||||||
|
|
||||||
|
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", { "os": "win32", "cpu": "x64" }, "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g=="],
|
||||||
|
|
||||||
|
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
|
||||||
|
|
||||||
|
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||||
|
|
||||||
|
"@types/estree": ["@types/estree@1.0.8", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
|
"@types/json-schema": ["@types/json-schema@7.0.15", "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@24.12.2", "https://registry.npmmirror.com/@types/node/-/node-24.12.2.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="],
|
||||||
|
|
||||||
|
"@types/react": ["@types/react@19.2.14", "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||||
|
|
||||||
|
"@types/react-dom": ["@types/react-dom@19.2.3", "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/type-utils": "8.58.2", "@typescript-eslint/utils": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.58.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.58.2.tgz", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.2", "@typescript-eslint/types": "^8.58.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", { "dependencies": { "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2" } }, "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", { "dependencies": { "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/utils": "8.58.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/types": ["@typescript-eslint/types@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.58.2.tgz", {}, "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", { "dependencies": { "@typescript-eslint/project-service": "8.58.2", "@typescript-eslint/tsconfig-utils": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/visitor-keys": "8.58.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.58.2.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.2", "@typescript-eslint/types": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.2", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", { "dependencies": { "@typescript-eslint/types": "8.58.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA=="],
|
||||||
|
|
||||||
|
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
|
||||||
|
|
||||||
|
"acorn": ["acorn@8.16.0", "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||||
|
|
||||||
|
"acorn-jsx": ["acorn-jsx@5.3.2", "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||||
|
|
||||||
|
"ajv": ["ajv@6.14.0", "https://registry.npmmirror.com/ajv/-/ajv-6.14.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
|
||||||
|
|
||||||
|
"ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
|
"argparse": ["argparse@2.0.1", "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
|
"balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.19", "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g=="],
|
||||||
|
|
||||||
|
"brace-expansion": ["brace-expansion@1.1.14", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
|
||||||
|
|
||||||
|
"browserslist": ["browserslist@4.28.2", "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||||
|
|
||||||
|
"callsites": ["callsites@3.1.0", "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||||
|
|
||||||
|
"caniuse-lite": ["caniuse-lite@1.0.30001788", "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="],
|
||||||
|
|
||||||
|
"chalk": ["chalk@4.1.2", "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|
||||||
|
"chokidar": ["chokidar@4.0.3", "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||||
|
|
||||||
|
"color-convert": ["color-convert@2.0.1", "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
|
"color-name": ["color-name@1.1.4", "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
||||||
|
"concat-map": ["concat-map@0.0.1", "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||||
|
|
||||||
|
"convert-source-map": ["convert-source-map@2.0.0", "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||||
|
|
||||||
|
"cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.2.3", "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
|
"debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"deep-is": ["deep-is@0.1.4", "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||||
|
|
||||||
|
"detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
|
"electron-to-chromium": ["electron-to-chromium@1.5.336", "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.336.tgz", {}, "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ=="],
|
||||||
|
|
||||||
|
"escalade": ["escalade@3.2.0", "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
|
"escape-string-regexp": ["escape-string-regexp@4.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||||
|
|
||||||
|
"eslint": ["eslint@9.39.4", "https://registry.npmmirror.com/eslint/-/eslint-9.39.4.tgz", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="],
|
||||||
|
|
||||||
|
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "https://registry.npmmirror.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="],
|
||||||
|
|
||||||
|
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.2", "https://registry.npmmirror.com/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA=="],
|
||||||
|
|
||||||
|
"eslint-scope": ["eslint-scope@8.4.0", "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-8.4.0.tgz", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||||
|
|
||||||
|
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||||
|
|
||||||
|
"espree": ["espree@10.4.0", "https://registry.npmmirror.com/espree/-/espree-10.4.0.tgz", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
|
||||||
|
|
||||||
|
"esquery": ["esquery@1.7.0", "https://registry.npmmirror.com/esquery/-/esquery-1.7.0.tgz", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
|
||||||
|
|
||||||
|
"esrecurse": ["esrecurse@4.3.0", "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||||
|
|
||||||
|
"estraverse": ["estraverse@5.3.0", "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||||
|
|
||||||
|
"esutils": ["esutils@2.0.3", "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||||
|
|
||||||
|
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||||
|
|
||||||
|
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||||
|
|
||||||
|
"fast-levenshtein": ["fast-levenshtein@2.0.6", "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||||
|
|
||||||
|
"fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"file-entry-cache": ["file-entry-cache@8.0.0", "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||||
|
|
||||||
|
"find-up": ["find-up@5.0.0", "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||||
|
|
||||||
|
"flat-cache": ["flat-cache@4.0.1", "https://registry.npmmirror.com/flat-cache/-/flat-cache-4.0.1.tgz", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||||
|
|
||||||
|
"flatted": ["flatted@3.4.2", "https://registry.npmmirror.com/flatted/-/flatted-3.4.2.tgz", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"gensync": ["gensync@1.0.0-beta.2", "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||||
|
|
||||||
|
"glob-parent": ["glob-parent@6.0.2", "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||||
|
|
||||||
|
"globals": ["globals@17.5.0", "https://registry.npmmirror.com/globals/-/globals-17.5.0.tgz", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="],
|
||||||
|
|
||||||
|
"has-flag": ["has-flag@4.0.0", "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||||
|
|
||||||
|
"hermes-estree": ["hermes-estree@0.25.1", "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.25.1.tgz", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
|
||||||
|
|
||||||
|
"hermes-parser": ["hermes-parser@0.25.1", "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.25.1.tgz", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||||
|
|
||||||
|
"ignore": ["ignore@5.3.2", "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
|
|
||||||
|
"immutable": ["immutable@5.1.5", "https://registry.npmmirror.com/immutable/-/immutable-5.1.5.tgz", {}, "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A=="],
|
||||||
|
|
||||||
|
"import-fresh": ["import-fresh@3.3.1", "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||||
|
|
||||||
|
"imurmurhash": ["imurmurhash@0.1.4", "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||||
|
|
||||||
|
"is-extglob": ["is-extglob@2.1.1", "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||||
|
|
||||||
|
"is-glob": ["is-glob@4.0.3", "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||||
|
|
||||||
|
"isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
|
"js-tokens": ["js-tokens@4.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||||
|
|
||||||
|
"js-yaml": ["js-yaml@4.1.1", "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||||
|
|
||||||
|
"jsesc": ["jsesc@3.1.0", "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||||
|
|
||||||
|
"json-buffer": ["json-buffer@3.0.1", "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||||
|
|
||||||
|
"json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||||
|
|
||||||
|
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
||||||
|
|
||||||
|
"json5": ["json5@2.2.3", "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||||
|
|
||||||
|
"keyv": ["keyv@4.5.4", "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||||
|
|
||||||
|
"levn": ["levn@0.4.1", "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||||
|
|
||||||
|
"lightningcss": ["lightningcss@1.32.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||||
|
|
||||||
|
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||||
|
|
||||||
|
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||||
|
|
||||||
|
"locate-path": ["locate-path@6.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||||
|
|
||||||
|
"lodash.merge": ["lodash.merge@4.6.2", "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||||
|
|
||||||
|
"lru-cache": ["lru-cache@5.1.1", "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
|
|
||||||
|
"minimatch": ["minimatch@3.1.5", "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||||
|
|
||||||
|
"ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"nanoid": ["nanoid@3.3.11", "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
|
"natural-compare": ["natural-compare@1.4.0", "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||||
|
|
||||||
|
"node-addon-api": ["node-addon-api@7.1.1", "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
|
||||||
|
|
||||||
|
"node-releases": ["node-releases@2.0.37", "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.37.tgz", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
|
||||||
|
|
||||||
|
"optionator": ["optionator@0.9.4", "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||||
|
|
||||||
|
"p-limit": ["p-limit@3.1.0", "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||||
|
|
||||||
|
"p-locate": ["p-locate@5.0.0", "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||||
|
|
||||||
|
"parent-module": ["parent-module@1.0.1", "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||||
|
|
||||||
|
"path-exists": ["path-exists@4.0.0", "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||||
|
|
||||||
|
"path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||||
|
|
||||||
|
"picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"picomatch": ["picomatch@4.0.4", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||||
|
|
||||||
|
"postcss": ["postcss@8.5.9", "https://registry.npmmirror.com/postcss/-/postcss-8.5.9.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="],
|
||||||
|
|
||||||
|
"prelude-ls": ["prelude-ls@1.2.1", "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||||
|
|
||||||
|
"punycode": ["punycode@2.3.1", "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
|
"react": ["react@19.2.5", "https://registry.npmmirror.com/react/-/react-19.2.5.tgz", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
|
||||||
|
|
||||||
|
"react-dom": ["react-dom@19.2.5", "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.5.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
|
||||||
|
|
||||||
|
"readdirp": ["readdirp@4.1.2", "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||||
|
|
||||||
|
"resolve-from": ["resolve-from@4.0.0", "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||||
|
|
||||||
|
"rolldown": ["rolldown@1.0.0-rc.15", "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.15.tgz", { "dependencies": { "@oxc-project/types": "=0.124.0", "@rolldown/pluginutils": "1.0.0-rc.15" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", "@rolldown/binding-darwin-x64": "1.0.0-rc.15", "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g=="],
|
||||||
|
|
||||||
|
"sass": ["sass@1.99.0", "https://registry.npmmirror.com/sass/-/sass-1.99.0.tgz", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q=="],
|
||||||
|
|
||||||
|
"scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
|
"semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
|
"shebang-command": ["shebang-command@2.0.0", "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||||
|
|
||||||
|
"shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||||
|
|
||||||
|
"source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
|
"strip-json-comments": ["strip-json-comments@3.1.1", "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||||
|
|
||||||
|
"supports-color": ["supports-color@7.2.0", "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||||
|
|
||||||
|
"tinyglobby": ["tinyglobby@0.2.16", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||||
|
|
||||||
|
"ts-api-utils": ["ts-api-utils@2.5.0", "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||||
|
|
||||||
|
"tslib": ["tslib@2.8.1", "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"type-check": ["type-check@0.4.0", "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@6.0.2", "https://registry.npmmirror.com/typescript/-/typescript-6.0.2.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="],
|
||||||
|
|
||||||
|
"typescript-eslint": ["typescript-eslint@8.58.2", "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.58.2.tgz", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.58.2", "@typescript-eslint/parser": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/utils": "8.58.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.16.0", "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
|
"update-browserslist-db": ["update-browserslist-db@1.2.3", "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||||
|
|
||||||
|
"uri-js": ["uri-js@4.4.1", "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||||
|
|
||||||
|
"vite": ["vite@8.0.8", "https://registry.npmmirror.com/vite/-/vite-8.0.8.tgz", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw=="],
|
||||||
|
|
||||||
|
"which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
|
"word-wrap": ["word-wrap@1.2.5", "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||||
|
|
||||||
|
"yallist": ["yallist@3.1.1", "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
|
"yocto-queue": ["yocto-queue@0.1.0", "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||||
|
|
||||||
|
"zod": ["zod@4.3.6", "https://registry.npmmirror.com/zod/-/zod-4.3.6.tgz", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||||
|
|
||||||
|
"zod-validation-error": ["zod-validation-error@4.0.2", "https://registry.npmmirror.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
|
||||||
|
|
||||||
|
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||||
|
|
||||||
|
"@eslint/eslintrc/globals": ["globals@14.0.0", "https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||||
|
|
||||||
|
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.15", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", {}, "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
31
frontend/package.json
Normal file
31
frontend/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"sass": "^1.99.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@types/node": "^24.12.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"typescript-eslint": "^8.58.0",
|
||||||
|
"vite": "^8.0.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend/public/icons.svg
Normal file
24
frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
37
frontend/src/App.tsx
Normal file
37
frontend/src/App.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ProvidersPage } from './pages/ProvidersPage';
|
||||||
|
import { StatsPage } from './pages/StatsPage';
|
||||||
|
import './App.css';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [currentPage, setCurrentPage] = useState<'providers' | 'stats'>('providers');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<nav className="navbar">
|
||||||
|
<h1>AI Gateway</h1>
|
||||||
|
<div className="nav-links">
|
||||||
|
<button
|
||||||
|
className={currentPage === 'providers' ? 'active' : ''}
|
||||||
|
onClick={() => setCurrentPage('providers')}
|
||||||
|
>
|
||||||
|
供应商管理
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={currentPage === 'stats' ? 'active' : ''}
|
||||||
|
onClick={() => setCurrentPage('stats')}
|
||||||
|
>
|
||||||
|
用量统计
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main className="content">
|
||||||
|
{currentPage === 'providers' && <ProvidersPage />}
|
||||||
|
{currentPage === 'stats' && <StatsPage />}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
129
frontend/src/api/client.ts
Normal file
129
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
const API_BASE = 'http://localhost:9826/api';
|
||||||
|
|
||||||
|
export interface Provider {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
api_key: string;
|
||||||
|
base_url: string;
|
||||||
|
enabled: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Model {
|
||||||
|
id: string;
|
||||||
|
provider_id: string;
|
||||||
|
model_name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageStats {
|
||||||
|
id: number;
|
||||||
|
provider_id: string;
|
||||||
|
model_name: string;
|
||||||
|
request_count: number;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider API
|
||||||
|
export async function listProviders(): Promise<Provider[]> {
|
||||||
|
const response = await fetch(`${API_BASE}/providers`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch providers');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProvider(provider: Omit<Provider, 'created_at' | 'updated_at'>): Promise<Provider> {
|
||||||
|
const response = await fetch(`${API_BASE}/providers`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(provider),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to create provider');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProvider(id: string, updates: Partial<Provider>): Promise<Provider> {
|
||||||
|
const response = await fetch(`${API_BASE}/providers/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to update provider');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProvider(id: string): Promise<void> {
|
||||||
|
const response = await fetch(`${API_BASE}/providers/${id}`, { method: 'DELETE' });
|
||||||
|
if (!response.ok) throw new Error('Failed to delete provider');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model API
|
||||||
|
export async function listModels(providerId?: string): Promise<Model[]> {
|
||||||
|
const url = providerId ? `${API_BASE}/models?provider_id=${providerId}` : `${API_BASE}/models`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch models');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createModel(model: Omit<Model, 'created_at'>): Promise<Model> {
|
||||||
|
const response = await fetch(`${API_BASE}/models`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(model),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to create model');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateModel(id: string, updates: Partial<Model>): Promise<Model> {
|
||||||
|
const response = await fetch(`${API_BASE}/models/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('Failed to update model');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteModel(id: string): Promise<void> {
|
||||||
|
const response = await fetch(`${API_BASE}/models/${id}`, { method: 'DELETE' });
|
||||||
|
if (!response.ok) throw new Error('Failed to delete model');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats API
|
||||||
|
export async function getStats(params?: {
|
||||||
|
provider_id?: string;
|
||||||
|
model_name?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
}): Promise<UsageStats[]> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.provider_id) query.set('provider_id', params.provider_id);
|
||||||
|
if (params?.model_name) query.set('model_name', params.model_name);
|
||||||
|
if (params?.start_date) query.set('start_date', params.start_date);
|
||||||
|
if (params?.end_date) query.set('end_date', params.end_date);
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/stats?${query}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch stats');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAggregatedStats(params?: {
|
||||||
|
provider_id?: string;
|
||||||
|
model_name?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
group_by?: 'provider' | 'model' | 'date';
|
||||||
|
}): Promise<any[]> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.provider_id) query.set('provider_id', params.provider_id);
|
||||||
|
if (params?.model_name) query.set('model_name', params.model_name);
|
||||||
|
if (params?.start_date) query.set('start_date', params.start_date);
|
||||||
|
if (params?.end_date) query.set('end_date', params.end_date);
|
||||||
|
if (params?.group_by) query.set('group_by', params.group_by);
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/stats/aggregate?${query}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch aggregated stats');
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
117
frontend/src/components/ModelForm.tsx
Normal file
117
frontend/src/components/ModelForm.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import * as api from '../api/client';
|
||||||
|
|
||||||
|
interface ModelFormProps {
|
||||||
|
model?: api.Model;
|
||||||
|
providers: api.Provider[];
|
||||||
|
onSave: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelForm({ model, providers, onSave, onCancel }: ModelFormProps) {
|
||||||
|
const [id, setId] = useState(model?.id || '');
|
||||||
|
const [providerId, setProviderId] = useState(model?.provider_id || (providers[0]?.id || ''));
|
||||||
|
const [modelName, setModelName] = useState(model?.model_name || '');
|
||||||
|
const [enabled, setEnabled] = useState(model?.enabled ?? true);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isEdit = !!model;
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEdit) {
|
||||||
|
const updates: any = {};
|
||||||
|
if (providerId !== model.provider_id) updates.provider_id = providerId;
|
||||||
|
if (modelName !== model.model_name) updates.model_name = modelName;
|
||||||
|
if (enabled !== model.enabled) updates.enabled = enabled;
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await api.updateModel(model.id, updates);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await api.createModel({
|
||||||
|
id,
|
||||||
|
provider_id: providerId,
|
||||||
|
model_name: modelName,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onSave();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal">
|
||||||
|
<div className="modal-content">
|
||||||
|
<h2>{isEdit ? '编辑模型' : '添加模型'}</h2>
|
||||||
|
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={id}
|
||||||
|
onChange={e => setId(e.target.value)}
|
||||||
|
disabled={isEdit}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>供应商</label>
|
||||||
|
<select
|
||||||
|
value={providerId}
|
||||||
|
onChange={e => setProviderId(e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
{providers.map(p => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>模型名称</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={modelName}
|
||||||
|
onChange={e => setModelName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={e => setEnabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
启用
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="submit" disabled={loading}>
|
||||||
|
{loading ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onCancel} disabled={loading}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
frontend/src/components/ProviderForm.tsx
Normal file
130
frontend/src/components/ProviderForm.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import * as api from '../api/client';
|
||||||
|
|
||||||
|
interface ProviderFormProps {
|
||||||
|
provider?: api.Provider;
|
||||||
|
onSave: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderForm({ provider, onSave, onCancel }: ProviderFormProps) {
|
||||||
|
const [id, setId] = useState(provider?.id || '');
|
||||||
|
const [name, setName] = useState(provider?.name || '');
|
||||||
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
const [baseUrl, setBaseUrl] = useState(provider?.base_url || '');
|
||||||
|
const [enabled, setEnabled] = useState(provider?.enabled ?? true);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isEdit = !!provider;
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEdit) {
|
||||||
|
const updates: any = {};
|
||||||
|
if (name !== provider.name) updates.name = name;
|
||||||
|
if (apiKey) updates.api_key = apiKey;
|
||||||
|
if (baseUrl !== provider.base_url) updates.base_url = baseUrl;
|
||||||
|
if (enabled !== provider.enabled) updates.enabled = enabled;
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await api.updateProvider(provider.id, updates);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await api.createProvider({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
api_key: apiKey,
|
||||||
|
base_url: baseUrl,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onSave();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal">
|
||||||
|
<div className="modal-content">
|
||||||
|
<h2>{isEdit ? '编辑供应商' : '添加供应商'}</h2>
|
||||||
|
|
||||||
|
{error && <div className="error">{error}</div>}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={id}
|
||||||
|
onChange={e => setId(e.target.value)}
|
||||||
|
disabled={isEdit}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>名称</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>API Key {isEdit && '(留空则不修改)'}</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={e => setApiKey(e.target.value)}
|
||||||
|
required={!isEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Base URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={e => setBaseUrl(e.target.value)}
|
||||||
|
placeholder="例如: https://api.openai.com/v1 或 https://open.bigmodel.cn/api/paas/v4"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small style={{color: '#666', fontSize: '0.85rem'}}>
|
||||||
|
配置到 API 版本路径,不包含 /chat/completions
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={e => setEnabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
启用
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="submit" disabled={loading}>
|
||||||
|
{loading ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onCancel} disabled={loading}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
182
frontend/src/pages/ProvidersPage.tsx
Normal file
182
frontend/src/pages/ProvidersPage.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import * as api from '../api/client';
|
||||||
|
import { ProviderForm } from '../components/ProviderForm';
|
||||||
|
import { ModelForm } from '../components/ModelForm';
|
||||||
|
|
||||||
|
export function ProvidersPage() {
|
||||||
|
const [providers, setProviders] = useState<api.Provider[]>([]);
|
||||||
|
const [models, setModels] = useState<api.Model[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 表单状态
|
||||||
|
const [showProviderForm, setShowProviderForm] = useState(false);
|
||||||
|
const [editingProvider, setEditingProvider] = useState<api.Provider | null>(null);
|
||||||
|
const [showModelForm, setShowModelForm] = useState(false);
|
||||||
|
const [editingModel, setEditingModel] = useState<api.Model | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [providersData, modelsData] = await Promise.all([
|
||||||
|
api.listProviders(),
|
||||||
|
api.listModels(),
|
||||||
|
]);
|
||||||
|
setProviders(providersData);
|
||||||
|
setModels(modelsData);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteProvider(id: string) {
|
||||||
|
if (!confirm('确定要删除这个供应商吗?关联的模型也会被删除。')) return;
|
||||||
|
try {
|
||||||
|
await api.deleteProvider(id);
|
||||||
|
loadData();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteModel(id: string) {
|
||||||
|
if (!confirm('确定要删除这个模型吗?')) return;
|
||||||
|
try {
|
||||||
|
await api.deleteModel(id);
|
||||||
|
loadData();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="loading">加载中...</div>;
|
||||||
|
if (error) return <div className="error">{error}</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="providers-page">
|
||||||
|
<h1>供应商管理</h1>
|
||||||
|
|
||||||
|
<div className="section">
|
||||||
|
<h2>供应商列表</h2>
|
||||||
|
<button onClick={() => {
|
||||||
|
setEditingProvider(null);
|
||||||
|
setShowProviderForm(true);
|
||||||
|
}}>
|
||||||
|
添加供应商
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>名称</th>
|
||||||
|
<th>API Key</th>
|
||||||
|
<th>Base URL</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{providers.map(p => (
|
||||||
|
<tr key={p.id}>
|
||||||
|
<td>{p.id}</td>
|
||||||
|
<td>{p.name}</td>
|
||||||
|
<td>{p.api_key}</td>
|
||||||
|
<td>{p.base_url}</td>
|
||||||
|
<td>{p.enabled ? '启用' : '禁用'}</td>
|
||||||
|
<td>
|
||||||
|
<button onClick={() => {
|
||||||
|
setEditingProvider(p);
|
||||||
|
setShowProviderForm(true);
|
||||||
|
}}>编辑</button>
|
||||||
|
<button onClick={() => handleDeleteProvider(p.id)}>删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section">
|
||||||
|
<h2>模型列表</h2>
|
||||||
|
<button onClick={() => {
|
||||||
|
setEditingModel(null);
|
||||||
|
setShowModelForm(true);
|
||||||
|
}}>
|
||||||
|
添加模型
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>供应商</th>
|
||||||
|
<th>模型名称</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{models.map(m => {
|
||||||
|
const provider = providers.find(p => p.id === m.provider_id);
|
||||||
|
return (
|
||||||
|
<tr key={m.id}>
|
||||||
|
<td>{m.id}</td>
|
||||||
|
<td>{provider?.name || m.provider_id}</td>
|
||||||
|
<td>{m.model_name}</td>
|
||||||
|
<td>{m.enabled ? '启用' : '禁用'}</td>
|
||||||
|
<td>
|
||||||
|
<button onClick={() => {
|
||||||
|
setEditingModel(m);
|
||||||
|
setShowModelForm(true);
|
||||||
|
}}>编辑</button>
|
||||||
|
<button onClick={() => handleDeleteModel(m.id)}>删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 供应商表单 */}
|
||||||
|
{showProviderForm && (
|
||||||
|
<ProviderForm
|
||||||
|
provider={editingProvider || undefined}
|
||||||
|
onSave={() => {
|
||||||
|
setShowProviderForm(false);
|
||||||
|
setEditingProvider(null);
|
||||||
|
loadData();
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowProviderForm(false);
|
||||||
|
setEditingProvider(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 模型表单 */}
|
||||||
|
{showModelForm && (
|
||||||
|
<ModelForm
|
||||||
|
model={editingModel || undefined}
|
||||||
|
providers={providers}
|
||||||
|
onSave={() => {
|
||||||
|
setShowModelForm(false);
|
||||||
|
setEditingModel(null);
|
||||||
|
loadData();
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowModelForm(false);
|
||||||
|
setEditingModel(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
frontend/src/pages/StatsPage.tsx
Normal file
110
frontend/src/pages/StatsPage.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import * as api from '../api/client';
|
||||||
|
|
||||||
|
export function StatsPage() {
|
||||||
|
const [stats, setStats] = useState<api.UsageStats[]>([]);
|
||||||
|
const [providers, setProviders] = useState<api.Provider[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 过滤条件
|
||||||
|
const [providerId, setProviderId] = useState('');
|
||||||
|
const [modelName, setModelName] = useState('');
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [endDate, setEndDate] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [statsData, providersData] = await Promise.all([
|
||||||
|
api.getStats({
|
||||||
|
provider_id: providerId || undefined,
|
||||||
|
model_name: modelName || undefined,
|
||||||
|
start_date: startDate || undefined,
|
||||||
|
end_date: endDate || undefined,
|
||||||
|
}),
|
||||||
|
api.listProviders(),
|
||||||
|
]);
|
||||||
|
setStats(statsData);
|
||||||
|
setProviders(providersData);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFilter(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="loading">加载中...</div>;
|
||||||
|
if (error) return <div className="error">{error}</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stats-page">
|
||||||
|
<h1>用量统计</h1>
|
||||||
|
|
||||||
|
<form onSubmit={handleFilter} className="filter-form">
|
||||||
|
<select value={providerId} onChange={e => setProviderId(e.target.value)}>
|
||||||
|
<option value="">所有供应商</option>
|
||||||
|
{providers.map(p => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="模型名称"
|
||||||
|
value={modelName}
|
||||||
|
onChange={e => setModelName(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
placeholder="开始日期"
|
||||||
|
value={startDate}
|
||||||
|
onChange={e => setStartDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
placeholder="结束日期"
|
||||||
|
value={endDate}
|
||||||
|
onChange={e => setEndDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button type="submit">查询</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>供应商</th>
|
||||||
|
<th>模型</th>
|
||||||
|
<th>日期</th>
|
||||||
|
<th>请求数</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{stats.map(s => {
|
||||||
|
const provider = providers.find(p => p.id === s.provider_id);
|
||||||
|
return (
|
||||||
|
<tr key={s.id}>
|
||||||
|
<td>{provider?.name || s.provider_id}</td>
|
||||||
|
<td>{s.model_name}</td>
|
||||||
|
<td>{s.date}</td>
|
||||||
|
<td>{s.request_count}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
frontend/tsconfig.app.json
Normal file
25
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
24
frontend/tsconfig.node.json
Normal file
24
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
7
frontend/vite.config.ts
Normal file
7
frontend/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
14
openspec/config.yaml
Normal file
14
openspec/config.yaml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
|
||||||
|
context: |
|
||||||
|
- **优先阅读README.md**获取项目结构与开发规范,所有代码风格、命名、注解、依赖、API等规范以README为准
|
||||||
|
- 新增代码优先复用已有组件、工具、依赖库,不引入新依赖
|
||||||
|
- 涉及模块结构、API、实体等变更时同步更新README.md
|
||||||
|
- Git提交: 仅中文; 格式"类型: 简短描述", 类型: feat/fix/refactor/docs/style/test/chore; 多行描述空行后写详细说明
|
||||||
|
- 禁止创建git操作task
|
||||||
|
- 积极使用subagents精心设计并行任务,节省上下文空间,加速任务执行
|
||||||
|
- 优先使用提问工具对用户进行提问
|
||||||
|
|
||||||
|
rules:
|
||||||
|
proposal:
|
||||||
|
- 仔细审查每一个过往spec判断是否存在Modified Capabilities
|
||||||
178
openspec/specs/anthropic-protocol-proxy/spec.md
Normal file
178
openspec/specs/anthropic-protocol-proxy/spec.md
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# Anthropic 协议代理
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
TBD - 提供 Anthropic Messages API 的代理功能,通过协议转换实现与 OpenAI 兼容供应商的互操作
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: 支持 Anthropic Messages API 端点
|
||||||
|
|
||||||
|
网关 SHALL 提供 Anthropic Messages API 端点 `POST /v1/messages` 供外部应用调用。
|
||||||
|
|
||||||
|
#### Scenario: 成功的非流式请求
|
||||||
|
|
||||||
|
- **WHEN** 应用发送 POST 请求到 `/v1/messages`,携带有效的 Anthropic 请求格式(非流式)
|
||||||
|
- **THEN** 网关 SHALL 将 Anthropic 请求转换为 OpenAI 格式
|
||||||
|
- **THEN** 网关 SHALL 将转换后的请求转发到配置的供应商
|
||||||
|
- **THEN** 网关 SHALL 将 OpenAI 响应转换回 Anthropic 格式
|
||||||
|
- **THEN** 网关 SHALL 将转换后的响应返回给应用
|
||||||
|
|
||||||
|
#### Scenario: 成功的流式请求
|
||||||
|
|
||||||
|
- **WHEN** 应用发送 POST 请求到 `/v1/messages`,携带 `stream: true`
|
||||||
|
- **THEN** 网关 SHALL 将 Anthropic 请求转换为 OpenAI 格式
|
||||||
|
- **THEN** 网关 SHALL 将转换后的请求转发给供应商
|
||||||
|
- **THEN** 网关 SHALL 将 OpenAI 流事件转换为 Anthropic 流事件
|
||||||
|
- **THEN** 网关 SHALL 使用 SSE 格式将转换后的事件流式返回给应用
|
||||||
|
|
||||||
|
### Requirement: 将 Anthropic 请求转换为 OpenAI 格式
|
||||||
|
|
||||||
|
网关 SHALL 将 Anthropic Messages API 请求转换为 OpenAI Chat Completions API 格式。
|
||||||
|
|
||||||
|
#### Scenario: System 消息转换
|
||||||
|
|
||||||
|
- **WHEN** Anthropic 请求包含 `system` 字段
|
||||||
|
- **THEN** 网关 SHALL 将其转换为 `messages` 数组中 `role: "system"` 的消息
|
||||||
|
|
||||||
|
#### Scenario: Messages 转换
|
||||||
|
|
||||||
|
- **WHEN** Anthropic 请求包含 `messages` 数组
|
||||||
|
- **THEN** 网关 SHALL 在转换后的 OpenAI 请求中保留这些消息
|
||||||
|
- **THEN** 网关 SHALL 保留每条消息的 role 和 content
|
||||||
|
|
||||||
|
#### Scenario: Tools 转换
|
||||||
|
|
||||||
|
- **WHEN** Anthropic 请求包含带有 `input_schema` 的 `tools`
|
||||||
|
- **THEN** 网关 SHALL 将每个工具转换为 OpenAI 格式,使用 `function.parameters` 替代 `input_schema`
|
||||||
|
- **THEN** 网关 SHALL 保留工具名称和描述
|
||||||
|
|
||||||
|
#### Scenario: Tool choice 转换
|
||||||
|
|
||||||
|
- **WHEN** Anthropic 请求包含 `type: "auto"` 的 `tool_choice`
|
||||||
|
- **THEN** 网关 SHALL 将其转换为 OpenAI 格式的 `"auto"`
|
||||||
|
- **WHEN** Anthropic 请求包含 `type: "any"` 的 `tool_choice`
|
||||||
|
- **THEN** 网关 SHALL 将其转换为 OpenAI 格式的 `"auto"`
|
||||||
|
- **WHEN** Anthropic 请求包含 `type: "tool"` 和 `name` 的 `tool_choice`
|
||||||
|
- **THEN** 网关 SHALL 将其转换为 OpenAI 格式的 `{"type": "function", "function": {"name": <name>}}`
|
||||||
|
|
||||||
|
#### Scenario: Tool result 转换
|
||||||
|
|
||||||
|
- **WHEN** Anthropic 请求包含用户消息,其 `content` 数组包含 `type: "tool_result"` 块
|
||||||
|
- **THEN** 网关 SHALL 将每个工具结果转换为 `role: "tool"` 的消息
|
||||||
|
- **THEN** 网关 SHALL 从 `tool_use_id` 设置 `tool_call_id`
|
||||||
|
- **THEN** 网关 SHALL 保留 content
|
||||||
|
|
||||||
|
#### Scenario: Max tokens 处理
|
||||||
|
|
||||||
|
- **WHEN** Anthropic 请求包含 `max_tokens`
|
||||||
|
- **THEN** 网关 SHALL 在 OpenAI 请求中包含它作为 `max_tokens`
|
||||||
|
- **WHEN** Anthropic 请求不包含 `max_tokens`
|
||||||
|
- **THEN** 网关 SHALL 设置默认值(4096)以满足 Anthropic 的要求
|
||||||
|
|
||||||
|
### Requirement: 将 OpenAI 响应转换为 Anthropic 格式
|
||||||
|
|
||||||
|
网关 SHALL 将 OpenAI Chat Completions API 响应转换为 Anthropic Messages API 格式。
|
||||||
|
|
||||||
|
#### Scenario: Content 转换
|
||||||
|
|
||||||
|
- **WHEN** OpenAI 响应包含 `choices[0].message.content`
|
||||||
|
- **THEN** 网关 SHALL 将其转换为 Anthropic 格式的 `content: [{"type": "text", "text": <content>}]`
|
||||||
|
|
||||||
|
#### Scenario: Tool calls 转换
|
||||||
|
|
||||||
|
- **WHEN** OpenAI 响应包含 `choices[0].message.tool_calls`
|
||||||
|
- **THEN** 网关 SHALL 将每个工具调用转换为 `type: "tool_use"` 的内容块
|
||||||
|
- **THEN** 网关 SHALL 从 `tool_calls[].id` 设置 `id`
|
||||||
|
- **THEN** 网关 SHALL 从 `tool_calls[].function.name` 设置 `name`
|
||||||
|
- **THEN** 网关 SHALL 解析 `arguments` JSON 字符串并将其设置为 `input` 对象
|
||||||
|
|
||||||
|
#### Scenario: Finish reason 转换
|
||||||
|
|
||||||
|
- **WHEN** OpenAI 响应的 `finish_reason` 为 `"stop"`
|
||||||
|
- **THEN** 网关 SHALL 在 Anthropic 响应中设置 `stop_reason: "end_turn"`
|
||||||
|
- **WHEN** OpenAI 响应的 `finish_reason` 为 `"tool_calls"`
|
||||||
|
- **THEN** 网关 SHALL 在 Anthropic 响应中设置 `stop_reason: "tool_use"`
|
||||||
|
|
||||||
|
#### Scenario: Usage 转换
|
||||||
|
|
||||||
|
- **WHEN** OpenAI 响应包含带有 `prompt_tokens` 和 `completion_tokens` 的 `usage`
|
||||||
|
- **THEN** 网关 SHALL 转换为 Anthropic 格式,使用 `input_tokens` 和 `output_tokens`
|
||||||
|
|
||||||
|
### Requirement: 转换流式事件
|
||||||
|
|
||||||
|
网关 SHALL 实时将 OpenAI 流事件转换为 Anthropic 流事件。
|
||||||
|
|
||||||
|
#### Scenario: Message start 事件
|
||||||
|
|
||||||
|
- **WHEN** 网关开始流式传输 Anthropic 响应
|
||||||
|
- **THEN** 网关 SHALL 发送带有消息元数据的 `message_start` 事件
|
||||||
|
|
||||||
|
#### Scenario: Content block start 事件
|
||||||
|
|
||||||
|
- **WHEN** OpenAI 流开始返回内容
|
||||||
|
- **THEN** 网关 SHALL 发送带有 `type: "text"` 的 `content_block_start` 事件
|
||||||
|
|
||||||
|
#### Scenario: Content delta 事件
|
||||||
|
|
||||||
|
- **WHEN** OpenAI 流发送带有内容的 delta
|
||||||
|
- **THEN** 网关 SHALL 发送带有 `type: "text_delta"` 的 `content_block_delta` 事件,包含文本
|
||||||
|
|
||||||
|
#### Scenario: Tool use 流式传输
|
||||||
|
|
||||||
|
- **WHEN** OpenAI 流发送工具调用 delta
|
||||||
|
- **THEN** 网关 SHALL 缓冲 `arguments` 块
|
||||||
|
- **THEN** 网关 SHALL 在工具调用开始时发送带有 `type: "tool_use"` 的 `content_block_start`
|
||||||
|
- **THEN** 网关 SHALL 发送带有部分 JSON 的 `input_delta` 事件
|
||||||
|
|
||||||
|
#### Scenario: Content block stop 事件
|
||||||
|
|
||||||
|
- **WHEN** 内容块完成
|
||||||
|
- **THEN** 网关 SHALL 发送 `content_block_stop` 事件
|
||||||
|
|
||||||
|
#### Scenario: Message stop 事件
|
||||||
|
|
||||||
|
- **WHEN** OpenAI 流完成
|
||||||
|
- **THEN** 网关 SHALL 发送 `message_stop` 事件
|
||||||
|
|
||||||
|
### Requirement: 支持 Anthropic 特有功能
|
||||||
|
|
||||||
|
网关 SHALL 支持映射到 OpenAI 能力的 Anthropic 特有功能。
|
||||||
|
|
||||||
|
#### Scenario: System prompt 作为独立字段
|
||||||
|
|
||||||
|
- **WHEN** Anthropic 请求包含 `system` 字段
|
||||||
|
- **THEN** 网关 SHALL 将其作为 OpenAI 格式的 system 消息处理
|
||||||
|
|
||||||
|
#### Scenario: 必需的 max_tokens
|
||||||
|
|
||||||
|
- **WHEN** 收到 Anthropic 请求
|
||||||
|
- **THEN** 网关 SHALL 确保 `max_tokens` 存在(如果未提供则使用默认值)
|
||||||
|
|
||||||
|
### Requirement: 处理纯文本内容
|
||||||
|
|
||||||
|
网关 SHALL 在 Anthropic 请求和响应中支持纯文本内容。
|
||||||
|
|
||||||
|
#### Scenario: 消息中的文本内容
|
||||||
|
|
||||||
|
- **WHEN** Anthropic 请求在消息中包含文本内容
|
||||||
|
- **THEN** 网关 SHALL 正确处理和转发文本内容
|
||||||
|
|
||||||
|
#### Scenario: 拒绝多模态内容
|
||||||
|
|
||||||
|
- **WHEN** Anthropic 请求包含多模态内容(图片、文档)
|
||||||
|
- **THEN** 网关 SHALL 返回错误,指示 MVP 不支持多模态内容
|
||||||
|
|
||||||
|
### Requirement: 保留请求元数据
|
||||||
|
|
||||||
|
网关 SHALL 在转换过程中保留请求元数据。
|
||||||
|
|
||||||
|
#### Scenario: 模型名称保留
|
||||||
|
|
||||||
|
- **WHEN** Anthropic 请求指定模型名称
|
||||||
|
- **THEN** 网关 SHALL 在转换后的 OpenAI 请求中保留模型名称
|
||||||
|
|
||||||
|
#### Scenario: 自定义参数
|
||||||
|
|
||||||
|
- **WHEN** Anthropic 请求包含自定义参数(temperature, top_p 等)
|
||||||
|
- **THEN** 网关 SHALL 在转换后的请求中保留这些参数
|
||||||
208
openspec/specs/frontend-config-ui/spec.md
Normal file
208
openspec/specs/frontend-config-ui/spec.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# 前端配置界面
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: 提供供应商管理页面
|
||||||
|
|
||||||
|
前端 SHALL 提供用于管理供应商配置的网页。
|
||||||
|
|
||||||
|
#### Scenario: 显示供应商列表
|
||||||
|
|
||||||
|
- **WHEN** 加载供应商管理页面
|
||||||
|
- **THEN** 前端 SHALL 显示所有已配置供应商的列表
|
||||||
|
- **THEN** 每个供应商 SHALL 显示 id, name, base_url 和 enabled 状态
|
||||||
|
- **THEN** API Key SHALL 被掩码
|
||||||
|
|
||||||
|
#### Scenario: 添加新供应商
|
||||||
|
|
||||||
|
- **WHEN** 用户点击"添加供应商"按钮
|
||||||
|
- **THEN** 前端 SHALL 显示输入供应商详情的表单
|
||||||
|
- **THEN** 表单 SHALL 包含 id, name, api_key, base_url 字段
|
||||||
|
- **WHEN** 用户提交包含有效数据的表单
|
||||||
|
- **THEN** 前端 SHALL 向 `/api/providers` 发送 POST 请求
|
||||||
|
- **THEN** 前端 SHALL 刷新供应商列表
|
||||||
|
|
||||||
|
#### Scenario: 编辑现有供应商
|
||||||
|
|
||||||
|
- **WHEN** 用户点击供应商的"编辑"按钮
|
||||||
|
- **THEN** 前端 SHALL 显示预填充供应商当前数据的表单
|
||||||
|
- **WHEN** 用户提交包含更新数据的表单
|
||||||
|
- **THEN** 前端 SHALL 向 `/api/providers/:id` 发送 PUT 请求
|
||||||
|
- **THEN** 前端 SHALL 刷新供应商列表
|
||||||
|
|
||||||
|
#### Scenario: 删除供应商
|
||||||
|
|
||||||
|
- **WHEN** 用户点击供应商的"删除"按钮
|
||||||
|
- **THEN** 前端 SHALL 提示确认
|
||||||
|
- **WHEN** 用户确认删除
|
||||||
|
- **THEN** 前端 SHALL 向 `/api/providers/:id` 发送 DELETE 请求
|
||||||
|
- **THEN** 前端 SHALL 刷新供应商列表
|
||||||
|
|
||||||
|
### Requirement: 提供模型管理界面
|
||||||
|
|
||||||
|
前端 SHALL 在供应商页面中提供管理模型配置的界面。
|
||||||
|
|
||||||
|
#### Scenario: 显示供应商的模型
|
||||||
|
|
||||||
|
- **WHEN** 选择或展开供应商
|
||||||
|
- **THEN** 前端 SHALL 显示该供应商的模型列表
|
||||||
|
- **THEN** 每个模型 SHALL 显示 model_name 和 enabled 状态
|
||||||
|
|
||||||
|
#### Scenario: 为供应商添加模型
|
||||||
|
|
||||||
|
- **WHEN** 用户点击供应商的"添加模型"
|
||||||
|
- **THEN** 前端 SHALL 显示输入 model_name 的表单
|
||||||
|
- **WHEN** 用户提交表单
|
||||||
|
- **THEN** 前端 SHALL 向 `/api/models` 发送 POST 请求,携带 provider_id
|
||||||
|
- **THEN** 前端 SHALL 刷新模型列表
|
||||||
|
|
||||||
|
#### Scenario: 编辑模型
|
||||||
|
|
||||||
|
- **WHEN** 用户点击模型的"编辑"
|
||||||
|
- **THEN** 前端 SHALL 显示编辑 model_name 的表单
|
||||||
|
- **WHEN** 用户提交表单
|
||||||
|
- **THEN** 前端 SHALL 向 `/api/models/:id` 发送 PUT 请求
|
||||||
|
- **THEN** 前端 SHALL 刷新模型列表
|
||||||
|
|
||||||
|
#### Scenario: 删除模型
|
||||||
|
|
||||||
|
- **WHEN** 用户点击模型的"删除"
|
||||||
|
- **THEN** 前端 SHALL 提示确认
|
||||||
|
- **WHEN** 用户确认删除
|
||||||
|
- **THEN** 前端 SHALL 向 `/api/models/:id` 发送 DELETE 请求
|
||||||
|
- **THEN** 前端 SHALL 刷新模型列表
|
||||||
|
|
||||||
|
### Requirement: 提供统计查看页面
|
||||||
|
|
||||||
|
前端 SHALL 提供查看用量统计的页面。
|
||||||
|
|
||||||
|
#### Scenario: 显示统计概览
|
||||||
|
|
||||||
|
- **WHEN** 加载统计页面
|
||||||
|
- **THEN** 前端 SHALL 显示所有供应商和模型的统计
|
||||||
|
- **THEN** 前端 SHALL 按供应商和模型分组显示请求计数
|
||||||
|
|
||||||
|
#### Scenario: 按供应商过滤统计
|
||||||
|
|
||||||
|
- **WHEN** 用户从下拉菜单选择供应商
|
||||||
|
- **THEN** 前端 SHALL 过滤统计,仅显示该供应商的数据
|
||||||
|
|
||||||
|
#### Scenario: 按模型过滤统计
|
||||||
|
|
||||||
|
- **WHEN** 用户从下拉菜单选择模型
|
||||||
|
- **THEN** 前端 SHALL 过滤统计,仅显示该模型的数据
|
||||||
|
|
||||||
|
#### Scenario: 按日期范围过滤统计
|
||||||
|
|
||||||
|
- **WHEN** 用户选择开始和结束日期
|
||||||
|
- **THEN** 前端 SHALL 过滤统计,仅显示该范围内的数据
|
||||||
|
|
||||||
|
### Requirement: 优雅处理 API 错误
|
||||||
|
|
||||||
|
前端 SHALL 处理 API 错误并显示用户友好的消息。
|
||||||
|
|
||||||
|
#### Scenario: API 请求失败
|
||||||
|
|
||||||
|
- **WHEN** API 请求失败(网络错误、4xx、5xx)
|
||||||
|
- **THEN** 前端 SHALL 向用户显示错误消息
|
||||||
|
- **THEN** 错误消息 SHALL 具有描述性和可操作性
|
||||||
|
|
||||||
|
#### Scenario: 验证错误
|
||||||
|
|
||||||
|
- **WHEN** 用户提交包含无效数据的表单
|
||||||
|
- **THEN** 前端 SHALL 在相关字段旁显示验证错误
|
||||||
|
- **THEN** 前端 SHALL 阻止表单提交
|
||||||
|
|
||||||
|
### Requirement: 提供响应式布局
|
||||||
|
|
||||||
|
前端 SHALL 提供适应不同屏幕尺寸的响应式布局。
|
||||||
|
|
||||||
|
#### Scenario: 桌面布局
|
||||||
|
|
||||||
|
- **WHEN** 在桌面屏幕上查看前端
|
||||||
|
- **THEN** 布局 SHALL 使用多列设计以高效利用空间
|
||||||
|
|
||||||
|
#### Scenario: 移动布局
|
||||||
|
|
||||||
|
- **WHEN** 在移动屏幕上查看前端
|
||||||
|
- **THEN** 布局 SHALL 适应为单列设计
|
||||||
|
- **THEN** 所有功能 SHALL 保持可访问
|
||||||
|
|
||||||
|
### Requirement: 使用无组件库的最小 UI
|
||||||
|
|
||||||
|
前端 SHALL 使用自定义组件,不使用外部 UI 库。
|
||||||
|
|
||||||
|
#### Scenario: 自定义组件
|
||||||
|
|
||||||
|
- **WHEN** 实现前端
|
||||||
|
- **THEN** 它 SHALL 使用自定义 HTML/CSS 组件
|
||||||
|
- **THEN** 它 SHALL NOT 使用外部 UI 库,如 Ant Design、Material-UI 或 shadcn/ui
|
||||||
|
|
||||||
|
#### Scenario: SCSS 样式
|
||||||
|
|
||||||
|
- **WHEN** 编写样式
|
||||||
|
- **THEN** 前端 SHALL 使用 SCSS 进行样式设计
|
||||||
|
- **THEN** 样式 SHALL 有组织且可维护
|
||||||
|
|
||||||
|
### Requirement: 提供导航
|
||||||
|
|
||||||
|
前端 SHALL 在不同页面间提供导航。
|
||||||
|
|
||||||
|
#### Scenario: 导航到供应商页面
|
||||||
|
|
||||||
|
- **WHEN** 用户点击导航中的"供应商"
|
||||||
|
- **THEN** 前端 SHALL 导航到供应商管理页面
|
||||||
|
|
||||||
|
#### Scenario: 导航到统计页面
|
||||||
|
|
||||||
|
- **WHEN** 用户点击导航中的"统计"
|
||||||
|
- **THEN** 前端 SHALL 导航到统计查看页面
|
||||||
|
|
||||||
|
### Requirement: 使用 React 和 TypeScript
|
||||||
|
|
||||||
|
前端 SHALL 使用 React 和 TypeScript 实现。
|
||||||
|
|
||||||
|
#### Scenario: TypeScript 使用
|
||||||
|
|
||||||
|
- **WHEN** 编写前端代码
|
||||||
|
- **THEN** 它 SHALL 使用 TypeScript 提供类型安全
|
||||||
|
- **THEN** 所有组件和函数 SHALL 具有适当的类型定义
|
||||||
|
|
||||||
|
#### Scenario: React 组件
|
||||||
|
|
||||||
|
- **WHEN** 实现 UI
|
||||||
|
- **THEN** 它 SHALL 使用 React 函数组件
|
||||||
|
- **THEN** 它 SHALL 使用 React hooks 进行状态管理
|
||||||
|
|
||||||
|
### Requirement: 使用 Vite 构建
|
||||||
|
|
||||||
|
前端 SHALL 使用 Vite 作为构建工具。
|
||||||
|
|
||||||
|
#### Scenario: 开发服务器
|
||||||
|
|
||||||
|
- **WHEN** 在开发模式下启动前端
|
||||||
|
- **THEN** Vite SHALL 使用热模块替换服务应用
|
||||||
|
|
||||||
|
#### Scenario: 生产构建
|
||||||
|
|
||||||
|
- **WHEN** 为生产构建前端
|
||||||
|
- **THEN** Vite SHALL 生成优化的静态文件
|
||||||
|
|
||||||
|
### Requirement: 与后端 API 通信
|
||||||
|
|
||||||
|
前端 SHALL 使用 fetch 或类似方法与后端 API 通信。
|
||||||
|
|
||||||
|
#### Scenario: API 基础 URL 配置
|
||||||
|
|
||||||
|
- **WHEN** 前端发起 API 请求
|
||||||
|
- **THEN** 它 SHALL 使用配置的后端 API 基础 URL(默认:http://localhost:9826)
|
||||||
|
|
||||||
|
#### Scenario: API 客户端封装
|
||||||
|
|
||||||
|
- **WHEN** 进行 API 调用
|
||||||
|
- **THEN** 它们 SHALL 封装在专用的 API 客户端模块中
|
||||||
|
- **THEN** 错误处理 SHALL 集中化
|
||||||
174
openspec/specs/model-management/spec.md
Normal file
174
openspec/specs/model-management/spec.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# 模型管理
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
TBD - 提供模型配置的管理功能,模型关联到供应商
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: 创建模型配置
|
||||||
|
|
||||||
|
网关 SHALL 允许为供应商创建新的模型配置。
|
||||||
|
|
||||||
|
#### Scenario: 使用有效数据创建模型
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/models` 发送 POST 请求,携带有效的模型数据(id, provider_id, model_name)
|
||||||
|
- **THEN** 网关 SHALL 在数据库中创建新的模型记录
|
||||||
|
- **THEN** 网关 SHALL 返回创建的模型,状态码为 201
|
||||||
|
- **THEN** 模型 SHALL 默认启用
|
||||||
|
|
||||||
|
#### Scenario: 使用不存在的供应商创建模型
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/models` 发送 POST 请求,携带不存在的 provider_id
|
||||||
|
- **THEN** 网关 SHALL 返回错误,状态码为 400 (Bad Request)
|
||||||
|
- **THEN** 错误 SHALL 指示供应商不存在
|
||||||
|
|
||||||
|
#### Scenario: 使用重复 ID 创建模型
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/models` 发送 POST 请求,携带已存在的 ID
|
||||||
|
- **THEN** 网关 SHALL 返回错误,状态码为 409 (Conflict)
|
||||||
|
|
||||||
|
#### Scenario: 创建模型时缺少必需字段
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/models` 发送 POST 请求,缺少必需字段(id, provider_id 或 model_name)
|
||||||
|
- **THEN** 网关 SHALL 返回错误,状态码为 400 (Bad Request)
|
||||||
|
- **THEN** 错误 SHALL 指示缺少哪些字段
|
||||||
|
|
||||||
|
### Requirement: 列出所有模型
|
||||||
|
|
||||||
|
网关 SHALL 允许获取所有模型配置。
|
||||||
|
|
||||||
|
#### Scenario: 成功列出模型
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/models` 发送 GET 请求
|
||||||
|
- **THEN** 网关 SHALL 返回所有模型的列表
|
||||||
|
- **THEN** 每个模型 SHALL 包含 id, provider_id, model_name, enabled, created_at
|
||||||
|
|
||||||
|
#### Scenario: 列出模型时为空
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/models` 发送 GET 请求,且不存在模型
|
||||||
|
- **THEN** 网关 SHALL 返回空列表
|
||||||
|
|
||||||
|
### Requirement: 按供应商列出模型
|
||||||
|
|
||||||
|
网关 SHALL 允许获取特定供应商的模型。
|
||||||
|
|
||||||
|
#### Scenario: 列出存在供应商的模型
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/models?provider_id=<provider_id>` 发送 GET 请求
|
||||||
|
- **THEN** 网关 SHALL 返回指定供应商的模型列表
|
||||||
|
|
||||||
|
#### Scenario: 列出不存在供应商的模型
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/models?provider_id=<non_existent_id>` 发送 GET 请求
|
||||||
|
- **THEN** 网关 SHALL 返回空列表
|
||||||
|
|
||||||
|
### Requirement: 获取特定模型
|
||||||
|
|
||||||
|
网关 SHALL 允许通过 ID 获取特定模型。
|
||||||
|
|
||||||
|
#### Scenario: 获取存在的模型
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/models/:id` 发送 GET 请求,携带有效的模型 ID
|
||||||
|
- **THEN** 网关 SHALL 返回模型详情
|
||||||
|
|
||||||
|
#### Scenario: 获取不存在的模型
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/models/:id` 发送 GET 请求,携带不存在的 ID
|
||||||
|
- **THEN** 网关 SHALL 返回错误,状态码为 404 (Not Found)
|
||||||
|
|
||||||
|
### Requirement: 更新模型配置
|
||||||
|
|
||||||
|
网关 SHALL 允许更新现有模型配置。
|
||||||
|
|
||||||
|
#### Scenario: 使用有效数据更新模型
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/models/:id` 发送 PUT 请求,携带有效的模型数据
|
||||||
|
- **THEN** 网关 SHALL 更新数据库中的模型记录
|
||||||
|
- **THEN** 网关 SHALL 返回更新后的模型
|
||||||
|
|
||||||
|
#### Scenario: 更新不存在的模型
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/models/:id` 发送 PUT 请求,携带不存在的 ID
|
||||||
|
- **THEN** 网关 SHALL 返回错误,状态码为 404 (Not Found)
|
||||||
|
|
||||||
|
#### Scenario: 更新模型供应商
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/models/:id` 发送 PUT 请求,携带新的 provider_id
|
||||||
|
- **THEN** 网关 SHALL 验证新供应商是否存在
|
||||||
|
- **THEN** 网关 SHALL 更新模型的供应商关联
|
||||||
|
|
||||||
|
#### Scenario: 部分更新
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/models/:id` 发送 PUT 请求,仅包含部分字段
|
||||||
|
- **THEN** 网关 SHALL 仅更新提供的字段
|
||||||
|
- **THEN** 网关 SHALL 保留未更改的字段
|
||||||
|
|
||||||
|
### Requirement: 删除模型配置
|
||||||
|
|
||||||
|
网关 SHALL 允许删除模型配置。
|
||||||
|
|
||||||
|
#### Scenario: 删除存在的模型
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/models/:id` 发送 DELETE 请求,携带有效的模型 ID
|
||||||
|
- **THEN** 网关 SHALL 删除模型记录
|
||||||
|
- **THEN** 网关 SHALL 返回状态码 204 (No Content)
|
||||||
|
|
||||||
|
#### Scenario: 删除不存在的模型
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/models/:id` 发送 DELETE 请求,携带不存在的 ID
|
||||||
|
- **THEN** 网关 SHALL 返回错误,状态码为 404 (Not Found)
|
||||||
|
|
||||||
|
### Requirement: 启用和禁用模型
|
||||||
|
|
||||||
|
网关 SHALL 支持启用和禁用模型。
|
||||||
|
|
||||||
|
#### Scenario: 禁用模型
|
||||||
|
|
||||||
|
- **WHEN** 模型的 `enabled` 字段设置为 false
|
||||||
|
- **THEN** 网关 SHALL 不向该模型路由请求
|
||||||
|
- **THEN** 模型 SHALL 保留在数据库中
|
||||||
|
|
||||||
|
#### Scenario: 启用模型
|
||||||
|
|
||||||
|
- **WHEN** 已禁用模型的 `enabled` 字段设置为 true
|
||||||
|
- **THEN** 网关 SHALL 恢复向该模型路由请求
|
||||||
|
|
||||||
|
### Requirement: 验证模型配置
|
||||||
|
|
||||||
|
网关 SHALL 验证模型配置数据。
|
||||||
|
|
||||||
|
#### Scenario: 验证供应商存在
|
||||||
|
|
||||||
|
- **WHEN** 创建或更新模型时携带 provider_id
|
||||||
|
- **THEN** 网关 SHALL 验证供应商存在于数据库中
|
||||||
|
|
||||||
|
#### Scenario: 验证必需字段
|
||||||
|
|
||||||
|
- **WHEN** 创建或更新模型
|
||||||
|
- **THEN** 网关 SHALL 验证 id, provider_id 和 model_name 存在且非空
|
||||||
|
|
||||||
|
### Requirement: 支持透明的模型名称
|
||||||
|
|
||||||
|
网关 SHALL 使用模型名称透明传输,不做转换。
|
||||||
|
|
||||||
|
#### Scenario: 模型名称保留
|
||||||
|
|
||||||
|
- **WHEN** 模型配置了 model_name
|
||||||
|
- **THEN** 网关 SHALL 在路由请求时使用该确切名称
|
||||||
|
- **THEN** 网关 SHALL 不修改或转换模型名称
|
||||||
|
|
||||||
|
#### Scenario: 不同供应商的同名模型
|
||||||
|
|
||||||
|
- **WHEN** 多个供应商拥有相同 model_name 的模型
|
||||||
|
- **THEN** 每个模型 SHALL 通过其唯一 ID 和 provider_id 区分
|
||||||
|
- **THEN** 网关 SHALL 基于模型名称和供应商关联的组合进行路由
|
||||||
|
|
||||||
|
### Requirement: 随供应商级联删除
|
||||||
|
|
||||||
|
网关 SHALL 在删除关联供应商时删除模型。
|
||||||
|
|
||||||
|
#### Scenario: 供应商删除级联到模型
|
||||||
|
|
||||||
|
- **WHEN** 供应商被删除
|
||||||
|
- **THEN** 该供应商关联的所有模型 SHALL 自动删除
|
||||||
129
openspec/specs/openai-protocol-proxy/spec.md
Normal file
129
openspec/specs/openai-protocol-proxy/spec.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# OpenAI 协议代理
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
TBD - 提供 OpenAI Chat Completions API 的代理功能
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: 支持 OpenAI Chat Completions API 端点
|
||||||
|
|
||||||
|
网关 SHALL 提供 OpenAI Chat Completions API 端点 `POST /v1/chat/completions` 供外部应用调用。
|
||||||
|
|
||||||
|
#### Scenario: 成功的非流式请求
|
||||||
|
|
||||||
|
- **WHEN** 应用发送 POST 请求到 `/v1/chat/completions`,携带有效的 OpenAI 请求格式(非流式)
|
||||||
|
- **THEN** 网关 SHALL 将请求转发到配置的供应商
|
||||||
|
- **THEN** 网关 SHALL 将供应商的响应以 OpenAI 格式返回给应用
|
||||||
|
|
||||||
|
#### Scenario: 成功的流式请求
|
||||||
|
|
||||||
|
- **WHEN** 应用发送 POST 请求到 `/v1/chat/completions`,携带 `stream: true`
|
||||||
|
- **THEN** 网关 SHALL 将请求转发到配置的供应商
|
||||||
|
- **THEN** 网关 SHALL 使用 SSE 格式将响应流式返回给应用
|
||||||
|
- **THEN** 网关 SHALL 在流完成时发送 `data: [DONE]`
|
||||||
|
|
||||||
|
### Requirement: 支持 Function Calling
|
||||||
|
|
||||||
|
网关 SHALL 在非流式和流式模式下都支持 OpenAI Function Calling。
|
||||||
|
|
||||||
|
#### Scenario: 非流式函数调用
|
||||||
|
|
||||||
|
- **WHEN** 应用发送包含 `tools` 定义的请求
|
||||||
|
- **AND** 供应商返回包含 `tool_calls` 的响应
|
||||||
|
- **THEN** 网关 SHALL 在响应中原样转发 `tool_calls`
|
||||||
|
|
||||||
|
#### Scenario: 流式函数调用
|
||||||
|
|
||||||
|
- **WHEN** 应用发送包含 `tools` 定义的流式请求
|
||||||
|
- **AND** 供应商在 delta 块中流式返回 `tool_calls`
|
||||||
|
- **THEN** 网关 SHALL 将 `tool_calls` 块流式发送给应用
|
||||||
|
- **THEN** 网关 SHALL 在完成时设置 `finish_reason: "tool_calls"`
|
||||||
|
|
||||||
|
#### Scenario: 工具结果提交
|
||||||
|
|
||||||
|
- **WHEN** 应用发送包含 `role: "tool"` 消息的后续请求,携带函数结果
|
||||||
|
- **THEN** 网关 SHALL 将工具结果原样转发给供应商
|
||||||
|
|
||||||
|
### Requirement: 根据模型名称路由请求
|
||||||
|
|
||||||
|
网关 SHALL 根据请求中的 `model` 字段将请求路由到相应的供应商。
|
||||||
|
|
||||||
|
#### Scenario: 有效模型路由
|
||||||
|
|
||||||
|
- **WHEN** 请求包含存在于配置模型中的 `model` 字段
|
||||||
|
- **AND** 该模型已启用
|
||||||
|
- **THEN** 网关 SHALL 将请求路由到该模型关联的供应商
|
||||||
|
|
||||||
|
#### Scenario: 模型未找到
|
||||||
|
|
||||||
|
- **WHEN** 请求包含不存在于配置模型中的 `model` 字段
|
||||||
|
- **THEN** 网关 SHALL 返回带有适当错误消息的错误响应
|
||||||
|
|
||||||
|
#### Scenario: 模型已禁用
|
||||||
|
|
||||||
|
- **WHEN** 请求包含已禁用模型的 `model` 字段
|
||||||
|
- **THEN** 网关 SHALL 返回错误响应,指示模型不可用
|
||||||
|
|
||||||
|
### Requirement: 对 OpenAI 兼容供应商透明代理
|
||||||
|
|
||||||
|
网关 SHALL 对 OpenAI 兼容供应商的请求和响应进行透明转发,不做修改。
|
||||||
|
|
||||||
|
#### Scenario: 请求转发
|
||||||
|
|
||||||
|
- **WHEN** 网关收到 OpenAI 协议请求
|
||||||
|
- **AND** 目标供应商是 OpenAI 兼容的
|
||||||
|
- **THEN** 网关 SHALL 将请求体原样转发给供应商
|
||||||
|
- **THEN** 网关 SHALL 在 Authorization 头中设置供应商的 API Key
|
||||||
|
- **THEN** 网关 SHALL 使用供应商的 base URL
|
||||||
|
|
||||||
|
#### Scenario: 响应转发
|
||||||
|
|
||||||
|
- **WHEN** 供应商返回响应
|
||||||
|
- **THEN** 网关 SHALL 将响应体原样返回给应用
|
||||||
|
- **THEN** 网关 SHALL 保留所有响应头和状态码
|
||||||
|
|
||||||
|
### Requirement: 处理供应商错误
|
||||||
|
|
||||||
|
网关 SHALL 将供应商错误透明返回给应用。
|
||||||
|
|
||||||
|
#### Scenario: 供应商返回错误
|
||||||
|
|
||||||
|
- **WHEN** 供应商返回错误响应(4xx 或 5xx)
|
||||||
|
- **THEN** 网关 SHALL 将相同的错误响应返回给应用
|
||||||
|
- **THEN** 网关 SHALL 保留错误消息和状态码
|
||||||
|
|
||||||
|
#### Scenario: 供应商超时
|
||||||
|
|
||||||
|
- **WHEN** 供应商在超时时间内未响应
|
||||||
|
- **THEN** 网关 SHALL 向应用返回超时错误
|
||||||
|
|
||||||
|
#### Scenario: 供应商连接失败
|
||||||
|
|
||||||
|
- **WHEN** 网关无法连接到供应商
|
||||||
|
- **THEN** 网关 SHALL 向应用返回连接错误
|
||||||
|
|
||||||
|
### Requirement: 支持标准 OpenAI 请求字段
|
||||||
|
|
||||||
|
网关 SHALL 支持所有标准 OpenAI Chat Completions API 请求字段。
|
||||||
|
|
||||||
|
#### Scenario: 支持标准字段
|
||||||
|
|
||||||
|
- **WHEN** 请求包含标准字段(model, messages, temperature, max_tokens, top_p, frequency_penalty, presence_penalty, stop, n, stream, tools, tool_choice, user)
|
||||||
|
- **THEN** 网关 SHALL 接受并将所有字段转发给供应商
|
||||||
|
|
||||||
|
### Requirement: 维护流式连接稳定性
|
||||||
|
|
||||||
|
网关 SHALL 维护稳定的流式连接并优雅处理中断。
|
||||||
|
|
||||||
|
#### Scenario: 流中断
|
||||||
|
|
||||||
|
- **WHEN** 供应商流在传输过程中中断
|
||||||
|
- **THEN** 网关 SHALL 优雅关闭客户端连接
|
||||||
|
- **THEN** 网关 SHALL 记录中断日志以便调试
|
||||||
|
|
||||||
|
#### Scenario: 客户端提前断开
|
||||||
|
|
||||||
|
- **WHEN** 客户端在流完成前断开连接
|
||||||
|
- **THEN** 网关 SHALL 取消供应商请求
|
||||||
|
- **THEN** 网关 SHALL 释放相关资源
|
||||||
151
openspec/specs/provider-management/spec.md
Normal file
151
openspec/specs/provider-management/spec.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# 供应商管理
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
TBD - 提供供应商配置的管理功能(创建、查询、更新、删除)
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: 创建供应商配置
|
||||||
|
|
||||||
|
网关 SHALL 允许通过管理 API 创建新的供应商配置。
|
||||||
|
|
||||||
|
#### Scenario: 使用有效数据创建供应商
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/providers` 发送 POST 请求,携带有效的供应商数据(id, name, api_key, base_url)
|
||||||
|
- **THEN** 网关 SHALL 在数据库中创建新的供应商记录
|
||||||
|
- **THEN** 网关 SHALL 返回创建的供应商,状态码为 201
|
||||||
|
- **THEN** 供应商 SHALL 默认启用
|
||||||
|
|
||||||
|
#### Scenario: 使用重复 ID 创建供应商
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/providers` 发送 POST 请求,携带已存在的 ID
|
||||||
|
- **THEN** 网关 SHALL 返回错误,状态码为 409 (Conflict)
|
||||||
|
|
||||||
|
#### Scenario: 创建供应商时缺少必需字段
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/providers` 发送 POST 请求,缺少必需字段(id, name, api_key 或 base_url)
|
||||||
|
- **THEN** 网关 SHALL 返回错误,状态码为 400 (Bad Request)
|
||||||
|
- **THEN** 错误 SHALL 指示缺少哪些字段
|
||||||
|
|
||||||
|
### Requirement: 列出所有供应商
|
||||||
|
|
||||||
|
网关 SHALL 允许获取所有供应商配置。
|
||||||
|
|
||||||
|
#### Scenario: 成功列出供应商
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/providers` 发送 GET 请求
|
||||||
|
- **THEN** 网关 SHALL 返回所有供应商的列表
|
||||||
|
- **THEN** 每个供应商 SHALL 包含 id, name, api_key(已掩码), base_url, enabled, created_at, updated_at
|
||||||
|
- **THEN** api_key SHALL 被掩码(仅显示最后 4 个字符)
|
||||||
|
|
||||||
|
#### Scenario: 列出供应商时为空
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/providers` 发送 GET 请求,且不存在供应商
|
||||||
|
- **THEN** 网关 SHALL 返回空列表
|
||||||
|
|
||||||
|
### Requirement: 获取特定供应商
|
||||||
|
|
||||||
|
网关 SHALL 允许通过 ID 获取特定供应商。
|
||||||
|
|
||||||
|
#### Scenario: 获取存在的供应商
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/providers/:id` 发送 GET 请求,携带有效的供应商 ID
|
||||||
|
- **THEN** 网关 SHALL 返回供应商详情
|
||||||
|
- **THEN** api_key SHALL 被掩码
|
||||||
|
|
||||||
|
#### Scenario: 获取不存在的供应商
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/providers/:id` 发送 GET 请求,携带不存在的 ID
|
||||||
|
- **THEN** 网关 SHALL 返回错误,状态码为 404 (Not Found)
|
||||||
|
|
||||||
|
### Requirement: 更新供应商配置
|
||||||
|
|
||||||
|
网关 SHALL 允许更新现有供应商配置。
|
||||||
|
|
||||||
|
#### Scenario: 使用有效数据更新供应商
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/providers/:id` 发送 PUT 请求,携带有效的供应商数据
|
||||||
|
- **THEN** 网关 SHALL 更新数据库中的供应商记录
|
||||||
|
- **THEN** 网关 SHALL 返回更新后的供应商
|
||||||
|
- **THEN** updated_at 时间戳 SHALL 被更新
|
||||||
|
|
||||||
|
#### Scenario: 更新不存在的供应商
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/providers/:id` 发送 PUT 请求,携带不存在的 ID
|
||||||
|
- **THEN** 网关 SHALL 返回错误,状态码为 404 (Not Found)
|
||||||
|
|
||||||
|
#### Scenario: 部分更新
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/providers/:id` 发送 PUT 请求,仅包含部分字段
|
||||||
|
- **THEN** 网关 SHALL 仅更新提供的字段
|
||||||
|
- **THEN** 网关 SHALL 保留未更改的字段
|
||||||
|
|
||||||
|
### Requirement: 删除供应商配置
|
||||||
|
|
||||||
|
网关 SHALL 允许删除供应商配置。
|
||||||
|
|
||||||
|
#### Scenario: 删除存在的供应商
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/providers/:id` 发送 DELETE 请求,携带有效的供应商 ID
|
||||||
|
- **THEN** 网关 SHALL 删除供应商记录
|
||||||
|
- **THEN** 网关 SHALL 删除所有关联的模型(CASCADE)
|
||||||
|
- **THEN** 网关 SHALL 返回状态码 204 (No Content)
|
||||||
|
|
||||||
|
#### Scenario: 删除不存在的供应商
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/providers/:id` 发送 DELETE 请求,携带不存在的 ID
|
||||||
|
- **THEN** 网关 SHALL 返回错误,状态码为 404 (Not Found)
|
||||||
|
|
||||||
|
### Requirement: 启用和禁用供应商
|
||||||
|
|
||||||
|
网关 SHALL 支持启用和禁用供应商。
|
||||||
|
|
||||||
|
#### Scenario: 禁用供应商
|
||||||
|
|
||||||
|
- **WHEN** 供应商的 `enabled` 字段设置为 false
|
||||||
|
- **THEN** 网关 SHALL 不向该供应商路由请求
|
||||||
|
- **THEN** 供应商 SHALL 保留在数据库中
|
||||||
|
|
||||||
|
#### Scenario: 启用供应商
|
||||||
|
|
||||||
|
- **WHEN** 已禁用供应商的 `enabled` 字段设置为 true
|
||||||
|
- **THEN** 网关 SHALL 恢复向该供应商路由请求
|
||||||
|
|
||||||
|
### Requirement: 验证供应商配置
|
||||||
|
|
||||||
|
网关 SHALL 验证供应商配置数据。
|
||||||
|
|
||||||
|
#### Scenario: 验证 base_url 格式
|
||||||
|
|
||||||
|
- **WHEN** 创建或更新供应商时使用无效的 base_url 格式
|
||||||
|
- **THEN** 网关 SHALL 返回错误,状态码为 400 (Bad Request)
|
||||||
|
|
||||||
|
#### Scenario: 验证必需字段
|
||||||
|
|
||||||
|
- **WHEN** 创建或更新供应商
|
||||||
|
- **THEN** 网关 SHALL 验证 id, name, api_key 和 base_url 存在且非空
|
||||||
|
|
||||||
|
### Requirement: 安全存储供应商配置
|
||||||
|
|
||||||
|
网关 SHALL 安全存储供应商 API Key。
|
||||||
|
|
||||||
|
#### Scenario: 存储 API Key
|
||||||
|
|
||||||
|
- **WHEN** 创建或更新供应商时携带 API Key
|
||||||
|
- **THEN** 网关 SHALL 将 API Key 存储在数据库中
|
||||||
|
|
||||||
|
#### Scenario: 在响应中掩码 API Key
|
||||||
|
|
||||||
|
- **WHEN** 在 API 响应中返回供应商数据
|
||||||
|
- **THEN** API Key SHALL 被掩码(仅显示最后 4 个字符)
|
||||||
|
|
||||||
|
### Requirement: 仅支持 OpenAI 兼容供应商
|
||||||
|
|
||||||
|
网关 SHALL 在 MVP 中仅支持 OpenAI 兼容供应商。
|
||||||
|
|
||||||
|
#### Scenario: 供应商类型验证
|
||||||
|
|
||||||
|
- **WHEN** 创建供应商
|
||||||
|
- **THEN** 供应商类型 SHALL 隐式设置为 "openai-compatible"
|
||||||
|
- **THEN** MVP 中 SHALL 不支持其他供应商类型
|
||||||
155
openspec/specs/usage-statistics/spec.md
Normal file
155
openspec/specs/usage-statistics/spec.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# 用量统计
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
TBD - 提供请求用量统计的记录和查询功能
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: 记录请求统计
|
||||||
|
|
||||||
|
网关 SHALL 为每次 API 调用记录请求统计。
|
||||||
|
|
||||||
|
#### Scenario: 记录成功请求
|
||||||
|
|
||||||
|
- **WHEN** 请求成功转发到供应商
|
||||||
|
- **THEN** 网关 SHALL 增加该供应商和模型的请求计数
|
||||||
|
- **THEN** 网关 SHALL 记录当前日期的统计
|
||||||
|
|
||||||
|
#### Scenario: 记录流式请求
|
||||||
|
|
||||||
|
- **WHEN** 流式请求成功完成
|
||||||
|
- **THEN** 网关 SHALL 增加该供应商和模型的请求计数
|
||||||
|
- **THEN** 网关 SHALL 在流结束后记录统计
|
||||||
|
|
||||||
|
#### Scenario: 不记录失败请求
|
||||||
|
|
||||||
|
- **WHEN** 请求在到达供应商前失败(路由错误、验证错误)
|
||||||
|
- **THEN** 网关 SHALL NOT 增加请求计数
|
||||||
|
|
||||||
|
#### Scenario: 记录供应商错误
|
||||||
|
|
||||||
|
- **WHEN** 请求到达供应商但供应商返回错误
|
||||||
|
- **THEN** 网关 SHALL 仍然增加请求计数(请求已被处理)
|
||||||
|
|
||||||
|
### Requirement: 按供应商查询统计
|
||||||
|
|
||||||
|
网关 SHALL 允许按供应商过滤查询统计。
|
||||||
|
|
||||||
|
#### Scenario: 查询特定供应商的统计
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/stats?provider_id=<provider_id>` 发送 GET 请求
|
||||||
|
- **THEN** 网关 SHALL 仅返回指定供应商的统计
|
||||||
|
|
||||||
|
#### Scenario: 查询不存在供应商的统计
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/stats?provider_id=<non_existent_id>` 发送 GET 请求
|
||||||
|
- **THEN** 网关 SHALL 返回空结果或零计数
|
||||||
|
|
||||||
|
### Requirement: 按模型查询统计
|
||||||
|
|
||||||
|
网关 SHALL 允许按模型过滤查询统计。
|
||||||
|
|
||||||
|
#### Scenario: 查询特定模型的统计
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/stats?model_name=<model_name>` 发送 GET 请求
|
||||||
|
- **THEN** 网关 SHALL 仅返回指定模型的统计
|
||||||
|
|
||||||
|
#### Scenario: 查询不存在模型的统计
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/stats?model_name=<non_existent_name>` 发送 GET 请求
|
||||||
|
- **THEN** 网关 SHALL 返回空结果或零计数
|
||||||
|
|
||||||
|
### Requirement: 按日期范围查询统计
|
||||||
|
|
||||||
|
网关 SHALL 允许在日期范围内查询统计。
|
||||||
|
|
||||||
|
#### Scenario: 使用日期范围查询统计
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/stats?start=<start_date>&end=<end_date>` 发送 GET 请求
|
||||||
|
- **THEN** 网关 SHALL 仅返回指定范围内的日期统计
|
||||||
|
- **THEN** 日期格式 SHALL 为 YYYY-MM-DD
|
||||||
|
|
||||||
|
#### Scenario: 不使用日期范围查询统计
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/stats` 发送 GET 请求,不带 start 和 end 参数
|
||||||
|
- **THEN** 网关 SHALL 返回所有可用日期的统计
|
||||||
|
|
||||||
|
#### Scenario: 仅使用开始日期查询统计
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/stats?start=<start_date>` 发送 GET 请求
|
||||||
|
- **THEN** 网关 SHALL 返回从开始日期到当前日期的统计
|
||||||
|
|
||||||
|
#### Scenario: 仅使用结束日期查询统计
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/stats?end=<end_date>` 发送 GET 请求
|
||||||
|
- **THEN** 网关 SHALL 返回从最早可用日期到结束日期的统计
|
||||||
|
|
||||||
|
### Requirement: 聚合统计
|
||||||
|
|
||||||
|
网关 SHALL 按日期聚合统计。
|
||||||
|
|
||||||
|
#### Scenario: 同一天多次请求
|
||||||
|
|
||||||
|
- **WHEN** 同一天对同一供应商和模型发起多次请求
|
||||||
|
- **THEN** 网关 SHALL 为该天维护单条统计记录
|
||||||
|
- **THEN** 请求计数 SHALL 为所有请求的总和
|
||||||
|
|
||||||
|
#### Scenario: 跨多天请求
|
||||||
|
|
||||||
|
- **WHEN** 跨不同天发起请求
|
||||||
|
- **THEN** 网关 SHALL 为每一天维护独立的统计记录
|
||||||
|
|
||||||
|
### Requirement: 以结构化格式返回统计
|
||||||
|
|
||||||
|
网关 SHALL 以结构化 JSON 格式返回统计。
|
||||||
|
|
||||||
|
#### Scenario: 统计响应格式
|
||||||
|
|
||||||
|
- **WHEN** 查询统计
|
||||||
|
- **THEN** 响应 SHALL 为统计对象数组
|
||||||
|
- **THEN** 每个对象 SHALL 包含 provider_id, model_name, request_count 和 date
|
||||||
|
|
||||||
|
#### Scenario: 空统计
|
||||||
|
|
||||||
|
- **WHEN** 没有统计匹配查询条件
|
||||||
|
- **THEN** 网关 SHALL 返回空数组
|
||||||
|
|
||||||
|
### Requirement: 支持并发统计记录
|
||||||
|
|
||||||
|
网关 SHALL 支持并发请求统计记录而无冲突。
|
||||||
|
|
||||||
|
#### Scenario: 并发请求
|
||||||
|
|
||||||
|
- **WHEN** 同时处理多个并发请求
|
||||||
|
- **THEN** 网关 SHALL 正确为每个请求增加请求计数
|
||||||
|
- **THEN** 不 SHALL 因并发写入而丢失统计
|
||||||
|
|
||||||
|
### Requirement: 仅将统计限制为请求计数
|
||||||
|
|
||||||
|
网关 SHALL 在 MVP 中仅记录请求计数,不记录其他指标。
|
||||||
|
|
||||||
|
#### Scenario: 仅请求计数
|
||||||
|
|
||||||
|
- **WHEN** 记录统计
|
||||||
|
- **THEN** 网关 SHALL 仅跟踪请求数量
|
||||||
|
- **THEN** 网关 SHALL NOT 在 MVP 中跟踪 token 使用、成本、延迟或其他指标
|
||||||
|
|
||||||
|
### Requirement: 为新组合初始化统计
|
||||||
|
|
||||||
|
网关 SHALL 为新的供应商-模型-日期组合自动创建统计记录。
|
||||||
|
|
||||||
|
#### Scenario: 组合的首次请求
|
||||||
|
|
||||||
|
- **WHEN** 在新日期首次对供应商-模型组合发起请求
|
||||||
|
- **THEN** 网关 SHALL 创建新的统计记录,request_count = 1
|
||||||
|
|
||||||
|
### Requirement: 查询所有统计
|
||||||
|
|
||||||
|
网关 SHALL 允许不带过滤条件查询所有统计。
|
||||||
|
|
||||||
|
#### Scenario: 查询所有统计
|
||||||
|
|
||||||
|
- **WHEN** 向 `/api/stats` 发送 GET 请求,不带任何查询参数
|
||||||
|
- **THEN** 网关 SHALL 返回所有可用统计
|
||||||
|
- **THEN** 结果 SHALL 按日期排序(最近的在前)
|
||||||
Reference in New Issue
Block a user