From 915b004924ea40647e4632cb2253418daf68a5d9 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 15 Apr 2026 16:53:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9D=E5=A7=8B=E5=8C=96=20AI=20Gate?= =?UTF-8?q?way=20=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现支持 OpenAI 和 Anthropic 双协议的统一大模型 API 网关 MVP 版本,包含: - OpenAI 和 Anthropic 协议代理 - 供应商和模型管理 - 用量统计 - 前端配置界面 --- .gitignore | 335 +++++++++++++ AGENTS.md | 1 + CLAUDE.md | 1 + README.md | 105 ++++ backend/README.md | 188 +++++++ backend/cmd/server/main.go | 119 +++++ backend/go.mod | 41 ++ backend/go.sum | 88 ++++ backend/internal/config/config.go | 32 ++ backend/internal/config/database.go | 58 +++ backend/internal/config/model.go | 119 +++++ backend/internal/config/models.go | 57 +++ backend/internal/config/provider.go | 102 ++++ backend/internal/config/stats.go | 79 +++ backend/internal/handler/anthropic_handler.go | 243 +++++++++ backend/internal/handler/model_handler.go | 161 ++++++ backend/internal/handler/openai_handler.go | 167 +++++++ backend/internal/handler/provider_handler.go | 167 +++++++ backend/internal/handler/stats_handler.go | 184 +++++++ .../internal/protocol/anthropic/converter.go | 234 +++++++++ .../protocol/anthropic/stream_converter.go | 164 ++++++ backend/internal/protocol/anthropic/types.go | 118 +++++ backend/internal/protocol/openai/adapter.go | 86 ++++ backend/internal/protocol/openai/types.go | 131 +++++ backend/internal/provider/client.go | 177 +++++++ backend/internal/router/model_router.go | 71 +++ frontend/.gitignore | 24 + frontend/README.md | 92 ++++ frontend/bun.lock | 473 ++++++++++++++++++ frontend/package.json | 31 ++ frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.tsx | 37 ++ frontend/src/api/client.ts | 129 +++++ frontend/src/assets/hero.png | Bin 0 -> 44919 bytes frontend/src/assets/react.svg | 1 + frontend/src/assets/vite.svg | 1 + frontend/src/components/ModelForm.tsx | 117 +++++ frontend/src/components/ProviderForm.tsx | 130 +++++ frontend/src/main.tsx | 10 + frontend/src/pages/ProvidersPage.tsx | 182 +++++++ frontend/src/pages/StatsPage.tsx | 110 ++++ frontend/tsconfig.app.json | 25 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 24 + frontend/vite.config.ts | 7 + openspec/config.yaml | 14 + .../specs/anthropic-protocol-proxy/spec.md | 178 +++++++ openspec/specs/frontend-config-ui/spec.md | 208 ++++++++ openspec/specs/model-management/spec.md | 174 +++++++ openspec/specs/openai-protocol-proxy/spec.md | 129 +++++ openspec/specs/provider-management/spec.md | 151 ++++++ openspec/specs/usage-statistics/spec.md | 155 ++++++ 53 files changed, 5662 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 backend/README.md create mode 100644 backend/cmd/server/main.go create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/config/database.go create mode 100644 backend/internal/config/model.go create mode 100644 backend/internal/config/models.go create mode 100644 backend/internal/config/provider.go create mode 100644 backend/internal/config/stats.go create mode 100644 backend/internal/handler/anthropic_handler.go create mode 100644 backend/internal/handler/model_handler.go create mode 100644 backend/internal/handler/openai_handler.go create mode 100644 backend/internal/handler/provider_handler.go create mode 100644 backend/internal/handler/stats_handler.go create mode 100644 backend/internal/protocol/anthropic/converter.go create mode 100644 backend/internal/protocol/anthropic/stream_converter.go create mode 100644 backend/internal/protocol/anthropic/types.go create mode 100644 backend/internal/protocol/openai/adapter.go create mode 100644 backend/internal/protocol/openai/types.go create mode 100644 backend/internal/provider/client.go create mode 100644 backend/internal/router/model_router.go create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/bun.lock create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/assets/hero.png create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/components/ModelForm.tsx create mode 100644 frontend/src/components/ProviderForm.tsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/ProvidersPage.tsx create mode 100644 frontend/src/pages/StatsPage.tsx create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 openspec/config.yaml create mode 100644 openspec/specs/anthropic-protocol-proxy/spec.md create mode 100644 openspec/specs/frontend-config-ui/spec.md create mode 100644 openspec/specs/model-management/spec.md create mode 100644 openspec/specs/openai-protocol-proxy/spec.md create mode 100644 openspec/specs/provider-management/spec.md create mode 100644 openspec/specs/usage-statistics/spec.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ce69db4 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..874fa8e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +严格遵守openspec/config.yaml中context声明的项目规范 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..874fa8e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +严格遵守openspec/config.yaml中context声明的项目规范 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1b04cc0 --- /dev/null +++ b/README.md @@ -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 diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..550bedf --- /dev/null +++ b/backend/README.md @@ -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 或更高版本 diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..71a388b --- /dev/null +++ b/backend/cmd/server/main.go @@ -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"}) + }) +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..b778fb4 --- /dev/null +++ b/backend/go.mod @@ -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 +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..e074724 --- /dev/null +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..cb5c81b --- /dev/null +++ b/backend/internal/config/config.go @@ -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 +} diff --git a/backend/internal/config/database.go b/backend/internal/config/database.go new file mode 100644 index 0000000..b9067b9 --- /dev/null +++ b/backend/internal/config/database.go @@ -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 +} diff --git a/backend/internal/config/model.go b/backend/internal/config/model.go new file mode 100644 index 0000000..e77c105 --- /dev/null +++ b/backend/internal/config/model.go @@ -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 +} diff --git a/backend/internal/config/models.go b/backend/internal/config/models.go new file mode 100644 index 0000000..37916fa --- /dev/null +++ b/backend/internal/config/models.go @@ -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 = "***" + } +} diff --git a/backend/internal/config/provider.go b/backend/internal/config/provider.go new file mode 100644 index 0000000..b24175f --- /dev/null +++ b/backend/internal/config/provider.go @@ -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 +} diff --git a/backend/internal/config/stats.go b/backend/internal/config/stats.go new file mode 100644 index 0000000..272b44b --- /dev/null +++ b/backend/internal/config/stats.go @@ -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 +} diff --git a/backend/internal/handler/anthropic_handler.go b/backend/internal/handler/anthropic_handler.go new file mode 100644 index 0000000..931fe02 --- /dev/null +++ b/backend/internal/handler/anthropic_handler.go @@ -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(), + }, + }) + } +} diff --git a/backend/internal/handler/model_handler.go b/backend/internal/handler/model_handler.go new file mode 100644 index 0000000..5e52ac5 --- /dev/null +++ b/backend/internal/handler/model_handler.go @@ -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) +} diff --git a/backend/internal/handler/openai_handler.go b/backend/internal/handler/openai_handler.go new file mode 100644 index 0000000..dec3c18 --- /dev/null +++ b/backend/internal/handler/openai_handler.go @@ -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", + }, + }) + } +} diff --git a/backend/internal/handler/provider_handler.go b/backend/internal/handler/provider_handler.go new file mode 100644 index 0000000..4016419 --- /dev/null +++ b/backend/internal/handler/provider_handler.go @@ -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) +} diff --git a/backend/internal/handler/stats_handler.go b/backend/internal/handler/stats_handler.go new file mode 100644 index 0000000..9042ac0 --- /dev/null +++ b/backend/internal/handler/stats_handler.go @@ -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 +} diff --git a/backend/internal/protocol/anthropic/converter.go b/backend/internal/protocol/anthropic/converter.go new file mode 100644 index 0000000..8b9675b --- /dev/null +++ b/backend/internal/protocol/anthropic/converter.go @@ -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 格式无效") +} diff --git a/backend/internal/protocol/anthropic/stream_converter.go b/backend/internal/protocol/anthropic/stream_converter.go new file mode 100644 index 0000000..f6e6f5b --- /dev/null +++ b/backend/internal/protocol/anthropic/stream_converter.go @@ -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 +} diff --git a/backend/internal/protocol/anthropic/types.go b/backend/internal/protocol/anthropic/types.go new file mode 100644 index 0000000..c2fae14 --- /dev/null +++ b/backend/internal/protocol/anthropic/types.go @@ -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) // 返回错误 +} diff --git a/backend/internal/protocol/openai/adapter.go b/backend/internal/protocol/openai/adapter.go new file mode 100644 index 0000000..533fe8c --- /dev/null +++ b/backend/internal/protocol/openai/adapter.go @@ -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 +} diff --git a/backend/internal/protocol/openai/types.go b/backend/internal/protocol/openai/types.go new file mode 100644 index 0000000..6367723 --- /dev/null +++ b/backend/internal/protocol/openai/types.go @@ -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 +} diff --git a/backend/internal/provider/client.go b/backend/internal/provider/client.go new file mode 100644 index 0000000..b019955 --- /dev/null +++ b/backend/internal/provider/client.go @@ -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)} + } + } + } + } +} + diff --git a/backend/internal/router/model_router.go b/backend/internal/router/model_router.go new file mode 100644 index 0000000..aa0120f --- /dev/null +++ b/backend/internal/router/model_router.go @@ -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 +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -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? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e8b33b2 --- /dev/null +++ b/frontend/README.md @@ -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. 在导航栏添加链接 diff --git a/frontend/bun.lock b/frontend/bun.lock new file mode 100644 index 0000000..037e4d4 --- /dev/null +++ b/frontend/bun.lock @@ -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=="], + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7bfa5de --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..212b869 --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ( +
+ + +
+ {currentPage === 'providers' && } + {currentPage === 'stats' && } +
+
+ ); +} + +export default App; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..4378543 --- /dev/null +++ b/frontend/src/api/client.ts @@ -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 { + 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): Promise { + 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): Promise { + 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 { + 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 { + 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): Promise { + 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): Promise { + 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 { + 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 { + 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 { + 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(); +} diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..cc51a3d20ad4bc961b596a6adfd686685cd84bb0 GIT binary patch literal 44919 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 JPDHLkV1myZcL)Fg literal 0 HcmV?d00001 diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/ModelForm.tsx b/frontend/src/components/ModelForm.tsx new file mode 100644 index 0000000..e6d3258 --- /dev/null +++ b/frontend/src/components/ModelForm.tsx @@ -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(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 ( +
+
+

{isEdit ? '编辑模型' : '添加模型'}

+ + {error &&
{error}
} + +
+
+ + setId(e.target.value)} + disabled={isEdit} + required + /> +
+ +
+ + +
+ +
+ + setModelName(e.target.value)} + required + /> +
+ +
+ +
+ +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/ProviderForm.tsx b/frontend/src/components/ProviderForm.tsx new file mode 100644 index 0000000..9bf6518 --- /dev/null +++ b/frontend/src/components/ProviderForm.tsx @@ -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(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 ( +
+
+

{isEdit ? '编辑供应商' : '添加供应商'}

+ + {error &&
{error}
} + +
+
+ + setId(e.target.value)} + disabled={isEdit} + required + /> +
+ +
+ + setName(e.target.value)} + required + /> +
+ +
+ + setApiKey(e.target.value)} + required={!isEdit} + /> +
+ +
+ + setBaseUrl(e.target.value)} + placeholder="例如: https://api.openai.com/v1 或 https://open.bigmodel.cn/api/paas/v4" + required + /> + + 配置到 API 版本路径,不包含 /chat/completions + +
+ +
+ +
+ +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + , +) diff --git a/frontend/src/pages/ProvidersPage.tsx b/frontend/src/pages/ProvidersPage.tsx new file mode 100644 index 0000000..2c9d85b --- /dev/null +++ b/frontend/src/pages/ProvidersPage.tsx @@ -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([]); + const [models, setModels] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // 表单状态 + const [showProviderForm, setShowProviderForm] = useState(false); + const [editingProvider, setEditingProvider] = useState(null); + const [showModelForm, setShowModelForm] = useState(false); + const [editingModel, setEditingModel] = useState(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
加载中...
; + if (error) return
{error}
; + + return ( +
+

供应商管理

+ +
+

供应商列表

+ + + + + + + + + + + + + + + {providers.map(p => ( + + + + + + + + + ))} + +
ID名称API KeyBase URL状态操作
{p.id}{p.name}{p.api_key}{p.base_url}{p.enabled ? '启用' : '禁用'} + + +
+
+ +
+

模型列表

+ + + + + + + + + + + + + + {models.map(m => { + const provider = providers.find(p => p.id === m.provider_id); + return ( + + + + + + + + ); + })} + +
ID供应商模型名称状态操作
{m.id}{provider?.name || m.provider_id}{m.model_name}{m.enabled ? '启用' : '禁用'} + + +
+
+ + {/* 供应商表单 */} + {showProviderForm && ( + { + setShowProviderForm(false); + setEditingProvider(null); + loadData(); + }} + onCancel={() => { + setShowProviderForm(false); + setEditingProvider(null); + }} + /> + )} + + {/* 模型表单 */} + {showModelForm && ( + { + setShowModelForm(false); + setEditingModel(null); + loadData(); + }} + onCancel={() => { + setShowModelForm(false); + setEditingModel(null); + }} + /> + )} +
+ ); +} diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx new file mode 100644 index 0000000..4e8f669 --- /dev/null +++ b/frontend/src/pages/StatsPage.tsx @@ -0,0 +1,110 @@ +import { useState, useEffect } from 'react'; +import * as api from '../api/client'; + +export function StatsPage() { + const [stats, setStats] = useState([]); + const [providers, setProviders] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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
加载中...
; + if (error) return
{error}
; + + return ( +
+

用量统计

+ +
+ + + setModelName(e.target.value)} + /> + + setStartDate(e.target.value)} + /> + + setEndDate(e.target.value)} + /> + + +
+ + + + + + + + + + + + {stats.map(s => { + const provider = providers.find(p => p.id === s.provider_id); + return ( + + + + + + + ); + })} + +
供应商模型日期请求数
{provider?.name || s.provider_id}{s.model_name}{s.date}{s.request_count}
+
+ ); +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..1d29c88 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -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"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..d3c52ea --- /dev/null +++ b/frontend/tsconfig.node.json @@ -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"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..8b0f57b --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +}) diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000..af3ede9 --- /dev/null +++ b/openspec/config.yaml @@ -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 diff --git a/openspec/specs/anthropic-protocol-proxy/spec.md b/openspec/specs/anthropic-protocol-proxy/spec.md new file mode 100644 index 0000000..86458e3 --- /dev/null +++ b/openspec/specs/anthropic-protocol-proxy/spec.md @@ -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": }}` + +#### 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": }]` + +#### 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 在转换后的请求中保留这些参数 diff --git a/openspec/specs/frontend-config-ui/spec.md b/openspec/specs/frontend-config-ui/spec.md new file mode 100644 index 0000000..2e4e79c --- /dev/null +++ b/openspec/specs/frontend-config-ui/spec.md @@ -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 集中化 diff --git a/openspec/specs/model-management/spec.md b/openspec/specs/model-management/spec.md new file mode 100644 index 0000000..d7f8655 --- /dev/null +++ b/openspec/specs/model-management/spec.md @@ -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=` 发送 GET 请求 +- **THEN** 网关 SHALL 返回指定供应商的模型列表 + +#### Scenario: 列出不存在供应商的模型 + +- **WHEN** 向 `/api/models?provider_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 自动删除 diff --git a/openspec/specs/openai-protocol-proxy/spec.md b/openspec/specs/openai-protocol-proxy/spec.md new file mode 100644 index 0000000..9a030d4 --- /dev/null +++ b/openspec/specs/openai-protocol-proxy/spec.md @@ -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 释放相关资源 diff --git a/openspec/specs/provider-management/spec.md b/openspec/specs/provider-management/spec.md new file mode 100644 index 0000000..c5228a7 --- /dev/null +++ b/openspec/specs/provider-management/spec.md @@ -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 不支持其他供应商类型 diff --git a/openspec/specs/usage-statistics/spec.md b/openspec/specs/usage-statistics/spec.md new file mode 100644 index 0000000..2bb8181 --- /dev/null +++ b/openspec/specs/usage-statistics/spec.md @@ -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=` 发送 GET 请求 +- **THEN** 网关 SHALL 仅返回指定供应商的统计 + +#### Scenario: 查询不存在供应商的统计 + +- **WHEN** 向 `/api/stats?provider_id=` 发送 GET 请求 +- **THEN** 网关 SHALL 返回空结果或零计数 + +### Requirement: 按模型查询统计 + +网关 SHALL 允许按模型过滤查询统计。 + +#### Scenario: 查询特定模型的统计 + +- **WHEN** 向 `/api/stats?model_name=` 发送 GET 请求 +- **THEN** 网关 SHALL 仅返回指定模型的统计 + +#### Scenario: 查询不存在模型的统计 + +- **WHEN** 向 `/api/stats?model_name=` 发送 GET 请求 +- **THEN** 网关 SHALL 返回空结果或零计数 + +### Requirement: 按日期范围查询统计 + +网关 SHALL 允许在日期范围内查询统计。 + +#### Scenario: 使用日期范围查询统计 + +- **WHEN** 向 `/api/stats?start=&end=` 发送 GET 请求 +- **THEN** 网关 SHALL 仅返回指定范围内的日期统计 +- **THEN** 日期格式 SHALL 为 YYYY-MM-DD + +#### Scenario: 不使用日期范围查询统计 + +- **WHEN** 向 `/api/stats` 发送 GET 请求,不带 start 和 end 参数 +- **THEN** 网关 SHALL 返回所有可用日期的统计 + +#### Scenario: 仅使用开始日期查询统计 + +- **WHEN** 向 `/api/stats?start=` 发送 GET 请求 +- **THEN** 网关 SHALL 返回从开始日期到当前日期的统计 + +#### Scenario: 仅使用结束日期查询统计 + +- **WHEN** 向 `/api/stats?end=` 发送 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 按日期排序(最近的在前)