From ec4de8d913a5de106e79d4e380cf8fc7c545d21e Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:34:06 +0800 Subject: [PATCH] =?UTF-8?q?M0-M4:=20=E6=8E=A8=E5=80=92=E9=87=8D=E6=9D=A5?= =?UTF-8?q?=E5=9F=BA=E7=BA=BF(=E5=9F=BA=E5=BB=BA+=E7=94=A8=E6=88=B7/?= =?UTF-8?q?=E5=AF=86=E9=92=A5/=E6=A0=B8=E5=BF=83=E4=BB=A3=E7=90=86+?= =?UTF-8?q?=E5=89=8D=E7=AB=AF+=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0+?= =?UTF-8?q?=E4=B8=89=E5=8D=8F=E8=AE=AE=E4=BA=92=E8=BD=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 Go+Gin+GORM: 配置(OT_ env)/SQLite/Postgres 双驱动、用户体系(argon2id+JWT access/refresh)、 API Key(sk- 48位, 仅存 SHA-256 哈希) - 代理网关: /v1/chat/completions、/v1/responses、/v1/messages、/v1/models;错误按客户端协议返回 - 三协议互转(convert 包): Chat↔Messages↔Responses 请求/响应 + 流式 SSE 逐事件转换(直通优先) - 用量计费: 异步批量记账、余额扣减、balance_logs、usage_daily 日聚合 - 管理 API: 用户/渠道 CRUD+测试+模型导入/模型定价+绑定/统计/系统配置 - 前端 Vue3+TS+Tailwind(taste-skill 设计 tokens): Landing/登录注册/控制台/管理后台, 自建组件+Phosphor 图标+自建 SVG 趋势图, 已过 web-design-guidelines 复查 - mock 上游: OpenAI+Anthropic 双协议模拟(含流式) Co-Authored-By: Claude --- .gitignore | 7 + Makefile | 4 +- PLANNING.md | 518 ++++---- README.md | 89 +- deploy/Dockerfile.api | 15 - deploy/Dockerfile.web | 12 - deploy/docker-compose.yml | 54 - deploy/nginx-public.conf | 45 - deploy/nginx.conf | 42 - scripts/mockupstream/go.mod | 4 +- scripts/mockupstream/main.go | 75 +- server/cmd/server/main.go | 25 +- server/go.mod | 2 +- server/internal/api/admin.go | 96 +- server/internal/api/admin_channels.go | 333 ++++++ server/internal/api/admin_models.go | 199 ++++ server/internal/api/admin_stats.go | 102 ++ server/internal/api/auth.go | 26 +- server/internal/api/keys.go | 32 +- server/internal/api/router.go | 22 +- server/internal/api/usage.go | 112 ++ server/internal/api/user.go | 110 +- server/internal/app/app.go | 2 +- server/internal/channel/channel.go | 24 +- server/internal/config/config.go | 22 +- server/internal/pkg/apikey/apikey.go | 4 - server/internal/pkg/apikey/apikey_test.go | 32 +- server/internal/pkg/crypto/crypto.go | 13 +- server/internal/pkg/crypto/crypto_test.go | 37 +- server/internal/pkg/jwt/jwt.go | 7 +- server/internal/pkg/jwt/jwt_test.go | 40 +- server/internal/pkg/resp/resp.go | 5 - server/internal/proxy/convert/convert.go | 157 +++ server/internal/proxy/convert/convert_test.go | 308 +++++ server/internal/proxy/convert/json_chat.go | 457 +++++++ .../internal/proxy/convert/json_responses.go | 318 +++++ server/internal/proxy/convert/stream.go | 478 ++++++++ server/internal/proxy/gateway.go | 96 +- server/internal/proxy/handlers.go | 97 +- server/internal/proxy/passthrough.go | 229 ++-- server/internal/proxy/stream_test.go | 150 ++- server/internal/store/db.go | 22 + server/internal/store/models.go | 170 ++- server/internal/usage/usage.go | 30 +- web/.gitignore | 24 - web/README.md | 5 - web/index.html | 7 +- web/package.json | 35 +- web/pnpm-lock.yaml | 1060 ++++++++++------- web/public/favicon.svg | 5 +- web/public/icons.svg | 24 - web/scripts/api_probe.cjs | 32 - web/scripts/debug.cjs | 29 - web/scripts/shot.cjs | 52 - web/scripts/verify.cjs | 80 -- web/scripts/wig-verify.cjs | 98 -- web/src/App.vue | 12 +- web/src/api/client.ts | 76 +- web/src/assets/hero.png | Bin 13057 -> 0 bytes web/src/assets/vite.svg | 1 - web/src/components/layout/ShellLayout.vue | 97 ++ web/src/components/ui/Badge.vue | 43 +- web/src/components/ui/Button.vue | 31 +- web/src/components/ui/Input.vue | 91 +- web/src/components/ui/Modal.vue | 125 +- web/src/components/ui/Toast.vue | 48 - web/src/components/ui/ToastHost.vue | 28 + web/src/components/ui/TrendChart.vue | 57 + web/src/lib/format.ts | 20 + web/src/main.ts | 7 +- web/src/router/index.ts | 36 +- web/src/stores/auth.ts | 77 +- web/src/stores/toast.ts | 28 +- web/src/style.css | 142 +-- web/src/types.ts | 80 ++ web/src/views/LandingView.vue | 221 ++-- web/src/views/LoginView.vue | 85 +- web/src/views/RegisterView.vue | 118 +- web/src/views/admin/AdminLayout.vue | 32 + web/src/views/admin/ChannelsView.vue | 216 ++++ web/src/views/admin/ConfigView.vue | 96 ++ web/src/views/admin/ModelsView.vue | 215 ++++ web/src/views/admin/OverviewView.vue | 93 ++ web/src/views/admin/UsersView.vue | 199 ++++ web/src/views/console/ConsoleLayout.vue | 105 +- web/src/views/console/DashboardView.vue | 228 ++-- web/src/views/console/KeysView.vue | 265 ++--- web/src/views/console/UsageView.vue | 254 ++-- web/src/vite-env.d.ts | 6 + web/tsconfig.app.json | 25 +- web/tsconfig.node.json | 25 +- web/vite.config.ts | 12 +- 92 files changed, 6203 insertions(+), 3064 deletions(-) delete mode 100644 deploy/Dockerfile.api delete mode 100644 deploy/Dockerfile.web delete mode 100644 deploy/docker-compose.yml delete mode 100644 deploy/nginx-public.conf delete mode 100644 deploy/nginx.conf create mode 100644 server/internal/api/admin_channels.go create mode 100644 server/internal/api/admin_models.go create mode 100644 server/internal/api/admin_stats.go create mode 100644 server/internal/api/usage.go create mode 100644 server/internal/proxy/convert/convert.go create mode 100644 server/internal/proxy/convert/convert_test.go create mode 100644 server/internal/proxy/convert/json_chat.go create mode 100644 server/internal/proxy/convert/json_responses.go create mode 100644 server/internal/proxy/convert/stream.go delete mode 100644 web/.gitignore delete mode 100644 web/README.md delete mode 100644 web/public/icons.svg delete mode 100644 web/scripts/api_probe.cjs delete mode 100644 web/scripts/debug.cjs delete mode 100644 web/scripts/shot.cjs delete mode 100644 web/scripts/verify.cjs delete mode 100644 web/scripts/wig-verify.cjs delete mode 100644 web/src/assets/hero.png delete mode 100644 web/src/assets/vite.svg create mode 100644 web/src/components/layout/ShellLayout.vue delete mode 100644 web/src/components/ui/Toast.vue create mode 100644 web/src/components/ui/ToastHost.vue create mode 100644 web/src/components/ui/TrendChart.vue create mode 100644 web/src/lib/format.ts create mode 100644 web/src/types.ts create mode 100644 web/src/views/admin/AdminLayout.vue create mode 100644 web/src/views/admin/ChannelsView.vue create mode 100644 web/src/views/admin/ConfigView.vue create mode 100644 web/src/views/admin/ModelsView.vue create mode 100644 web/src/views/admin/OverviewView.vue create mode 100644 web/src/views/admin/UsersView.vue diff --git a/.gitignore b/.gitignore index a2945ef..85e5313 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,13 @@ dist/ # Go server/bin/ server/data/ +scripts/mockupstream/bin/ + +# TypeScript 增量构建产物 +*.tsbuildinfo +web/vite.config.js +web/vite.config.d.ts +web/node_modules/.tmp/ # 环境与密钥 .env diff --git a/Makefile b/Makefile index f62c079..94bbe46 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: run build test mock-upstream tidy +.PHONY: run build test tidy mock-upstream web-dev web-build run: cd server && go run ./cmd/server @@ -14,7 +14,7 @@ tidy: # 本地 mock OpenAI 上游(联调用,无需真实 key) mock-upstream: - cd scripts && go run ./mockupstream + cd scripts/mockupstream && go run . # 前端 web-dev: diff --git a/PLANNING.md b/PLANNING.md index eef2bfa..db3e057 100644 --- a/PLANNING.md +++ b/PLANNING.md @@ -1,8 +1,9 @@ # 大模型中转站(OpenRouter-like)规划文档 -> 版本:v0.2 · 2026-08-15 · 状态:规划(未开始编码) +> 版本:v0.3 · 2026-08-15 · 状态:规划(决策已定:推倒重来) > -> 本文档是项目蓝图,覆盖技术选型、系统架构、核心功能、数据模型、API 设计、前端设计方向与开发里程碑。编码开始前,本文档应与团队确认一遍。 +> 技术栈:**Go + Vue3 + Tailwind CSS**,前端按 **taste-skill** 定义设计方向。 +> 本文档是项目蓝图。编码前先与团队对齐;旧实现仅作参考(§2)。 --- @@ -12,11 +13,21 @@ 一个自托管的 **LLM API 中转网关**,功能对标 OpenRouter / one-api: -- 对下游用户暴露**统一的、OpenAI 兼容**的 API 入口,背后接入多个上游渠道(OpenAI、Anthropic、兼容第三方等)。 +- 对下游用户暴露**统一的、OpenAI 兼容的 API 入口**,背后接入多个上游渠道(OpenAI、Anthropic、兼容第三方等)。 - 对外提供三套协议入口:**OpenAI Responses API、OpenAI Chat Completions、Anthropic Messages**,覆盖两大生态的 SDK 与客户端。 -- 内置用户体系、API Key 管理、用量统计与计费(充值暂缓,见 §4.5)。 +- 内置用户体系、API Key 管理、用量统计与计费(充值暂缓,见 §5.5)。 -### 1.2 核心价值 +### 1.2 三大板块(对应用户的诉求) + +| 板块 | 面向 | 核心功能 | +| --- | --- | --- | +| **大模型代理** | 下游开发者 | 三套协议入口 + 协议互转 + 渠道接入 + 负载均衡/健康检查/故障转移 + `/v1/models` | +| **后台管理** | 管理员面板 / 普通用户 | 注册登录、角色(admin/user)、API Key、渠道管理、用户管理、充值审核 | +| **用量计费** | 管理员 / 普通用户 | token 统计、计价、余额、请求流水、日聚合报表 | + +> MVP 顺序即此三板块的骨架:**先把"代理 + 用户 + 记账"跑通**,再补管理后台与渠道体系,最后是协议转换与充值。 + +### 1.3 核心价值 | 对用户 | 对管理员 | | --- | --- | @@ -24,7 +35,7 @@ | 查询用量、成本明细 | 管理用户、审核充值、看全局营收 | | 配额/余额控制 | 渠道健康检查、负载均衡、故障转移 | -### 1.3 对外协议(明确范围) +### 1.4 对外协议(明确范围) | 端点 | 协议 | 说明 | | --- | --- | --- | @@ -33,72 +44,97 @@ | `POST /v1/messages` | Anthropic Messages | Claude 生态原生格式,含流式与工具调用 | | `GET /v1/models` | OpenAI 风格模型列表 | 对外列出可用模型;也用于渠道侧自动导入模型列表 | -> 三套协议之间可**互相转换**:例如客户端按 Responses 调用 `claude-sonnet-5`,网关会转换成 Anthropic 协议打给 Anthropic 渠道,再以 Responses 流式返回;同理可反向。协议与渠道匹配时走**直通**(见 4.1.3)。 +> 三套协议之间可**互相转换**:例如客户端按 Responses 调用 `claude-sonnet-5`,网关会转换成 Anthropic 协议打给 Anthropic 渠道,再以 Responses 流式返回;同理可反向。协议与渠道匹配时走**直通**(见 §5.1.3)。 + +### 1.5 首版范围(MVP) + +| 纳入(按序) | 暂缓 | +| --- | --- | +| M1 核心代理(chat/completions + responses + models,直通) | 充值(**待定**,见 §5.5) | +| M2 用户体系 + API Key + 记账 | 在线支付、审计报表、多实例 | +| M3 管理后台(渠道/用户/模型/用量) | 邀请码(配置开关预留) | +| M4 协议转换(messages 端点 + 三协议互转) | 组织/团队(多租户) | +| M5 渠道体系完善(LB/健康/重试/模型导入) | viewer 只读角色 | --- -## 1.4 首版范围(MVP) +## 2. 当前状态(已定:推倒重来) -| 纳入 | 暂缓 | +> git 历史 `HEAD`(b25e9ec)中有一份旧 M0+M1 实现,但**当前工作区已清空**(仅剩 `.env`、`.env.example`、`.gitignore`)。已确认**推倒重来**:旧实现只作参考,不直接恢复使用。 + +### 2.1 旧实现可参考点(不复用代码,参考设计) + +| 模块 | 值得借鉴的设计 | | --- | --- | -| 大模型代理(三套协议 + 渠道 + 转换) | 充值(**暂停**,见 §4.5) | -| 用户管理(注册/登录/角色/API Key) | 邀请码(配置开关预留) | -| 用量计费(token 统计、计价、余额、流水) | 在线支付、审计报表、多实例 | -| 渠道管理(API 类型、模型导入、健康检查) | 组织/团队(多租户) | -| 管理后台(用户/渠道/模型/全局用量) | | +| 数据模型 | `store/models.go` 已覆盖 §6 全部表结构,字段命名/类型可直接照搬 | +| 代理直通 | 直通模式实现(鉴权 → 余额 → 选渠道 → 透传 → 记账)的链路划分 | +| 记账 | `usage.Recorder` 异步批量落库 + 队列满同步兜底的模式 | +| API Key | SHA-256 哈希存储 + 前缀展示 + 明文一次性展示 | +| 前端 | 页面清单与路由结构可参考;视觉按 taste-skill 重做 | -## 2. 技术选型 +### 2.2 重做范围(按 §10 里程碑) -### 2.1 后端(Go) +- M0 基建、M1 用户+密钥+核心代理:**重做**(可参考旧实现,不复用) +- 其余里程碑(M2–M6):按规划新增 + +### 2.3 决策记录 + +| 决策项 | 结论 | +| --- | --- | +| 工作区恢复方式 | **推倒重来**(2026-08-15 确认) | + +--- + +## 3. 技术选型 + +### 3.1 后端(Go) | 项 | 选型 | 理由 | | --- | --- | --- | -| 语言/运行时 | Go 1.23+ | 高并发流式转发、低内存、部署为单二进制 | -| Web 框架 | Gin | 生态成熟,中间件丰富;代理层可用标准库 `net/http` 做流式读写 | -| ORM | GORM | 简单、迁移工具内建;后期可换 sqlc | -| 数据库 | PostgreSQL 15+(开发可 SQLite 起步) | JSON/数组字段、数值精度对计费友好;单一存储,无额外部署 | -| 缓存/限流 | Redis 7 | token bucket 限流、热点数据、分布式计数器 | -| 认证 | JWT(访问令牌)+ 刷新令牌 HttpOnly Cookie | 见 4.3 | +| 语言/运行时 | Go 1.23+ | 高并发流式转发、低内存、单二进制部署 | +| Web 框架 | Gin | 生态成熟、中间件丰富;代理层用标准库 `net/http` 做流式读写 | +| ORM | GORM | 简单、迁移内建;模型已按 v0.3 建好 | +| 数据库 | PostgreSQL 15+(开发可 SQLite 起步) | JSON/数组、numeric 精度对计费友好;单存储 | +| 缓存/限流 | Redis 7(起步可内存计数降级) | token bucket 限流、热点、分布式计数器 | +| 认证 | JWT access(2h)+ refresh cookie(7d) | 见 §5.3 | | 密码 | argon2id | 现代 KDF | -| 配置 | viper + `.env` | 密钥进环境变量,不进代码库 | -| 日志 | zap | 结构化日志,含请求 trace | -| 上游密钥加密 | AES-GCM(主密钥来自环境变量) | 渠道 key 落库前加密 | +| 配置 | viper + `.env`(`OT_` 前缀) | 密钥进环境变量 | +| 日志 | zap | 结构化日志 + request_id | +| 渠道密钥加密 | AES-GCM(主密钥环境变量) | 落库前加密 | -### 2.2 前端(Vue 3) +### 3.2 前端(Vue 3 + Tailwind) | 项 | 选型 | 理由 | | --- | --- | --- | | 框架 | Vue 3 + TypeScript + Vite | 团队栈、构建快 | | 状态 | Pinia | 官方推荐 | | 路由 | Vue Router | 标准 | -| 样式 | Tailwind CSS + **taste-skill 产出的设计 tokens** | 自建设计系统,避免套模板 | -| 组件基座 | Ark UI(headless)+ 自建基础组件 | 无头组件可控性强,符合 taste-skill 的 anti-slop 取向 | -| 图表 | ECharts(vue-echarts) | 用量/营收图表 | -| HTTP | axios + TanStack Query | 缓存、重试、请求状态管理 | +| 样式 | **Tailwind CSS** + taste-skill 产出的设计 tokens | 见 §8;自建设计系统,不套模板 | +| 组件 | **自建基础组件**(Button/Input/Table/Modal/Toast…)+ 必要 headless 原语 | 贴合设计 tokens,避免重型 UI 库的"模板感" | +| 图表 | ECharts(vue-echarts) | 用量/营收图表,暗色对齐 tokens | +| HTTP | axios + TanStack Query | 缓存、重试、请求状态 | -> 说明:不选用 Element Plus 这类"完整模板感"较重的库,管理后台的表格/表单由自建组件提供,视觉由 taste-skill 统一定调。 +### 3.3 部署 -### 2.3 部署 - -- Docker Compose 起步:`nginx`(静态资源 + 反代) + `api`(Go) + `postgres` + `redis`。 +- Docker Compose 起步:`nginx`(静态资源 + 反代)+ `api`(Go)+ `postgres` + `redis`。 - 单实例起步(记账时序简单),需要时再做多实例(见 §9 风险)。 --- -## 3. 系统架构 +## 4. 系统架构 -### 3.1 模块划分 +### 4.1 模块划分 ``` ┌─────────────────────────────────────────────────────────────┐ -│ 前端 web (Vue3) │ -│ Landing / 登录注册 / 控制台(密钥·用量·充值) / 管理后台 │ +│ 前端 web (Vue3 + Tailwind) │ +│ Landing / 登录注册 / 控制台(密钥·用量) / 管理后台(渠道·用户·) │ └──────────────────────────┬──────────────────────────────────┘ │ HTTP/JSON(管理 API) ┌──────────────────────────▼──────────────────────────────────┐ │ Go API 服务 │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────────┐ │ -│ │ 认证/用户 │ │ API Key │ │ 用量/计费 │ │ 充值(暂停) │ │ +│ │ 认证/用户 │ │ API Key │ │ 用量/计费 │ │ 充值(待定) │ │ │ └──────────┘ └──────────┘ └──────────┘ └────────────────┘ │ │ ┌────────────────────────────────────────────────────────┐ │ │ │ API 网关(代理核心) │ │ @@ -117,7 +153,7 @@ └────────────────┘ └─────────────────┘ ``` -### 3.2 一次代理请求的完整链路 +### 4.2 一次代理请求的完整链路 ``` Client Go 网关 上游渠道(如 Anthropic) @@ -137,19 +173,19 @@ Client Go 网关 上游渠道(如 A ``` 关键点: -- **记账是异步的**:请求完成后写入 `usage_logs`,批量落库,不阻塞响应。 -- **流式响应不整体缓冲**:用 `io.Pipe` 边读上游边写客户端;Token 计数取流结束时的 usage 字段(OpenAI 末块 / Claude `message_delta`)。 -- **只转发请求体与必要头**:`Authorization` 一律替换为渠道 key,不向客户端暴露上游信息。 +- **记账异步**:请求完成后写 `usage_logs`,批量落库,不阻塞响应。 +- **流式不整体缓冲**:`io.Pipe` 边读上游边写客户端;Token 计数取流结束时的 usage(OpenAI 末块 / Claude `message_delta`)。 +- **只转发必要体/头**:`Authorization` 一律替换为渠道 key,不向客户端暴露上游信息。 --- -## 4. 核心功能设计 +## 5. 核心功能设计 -### 4.1 API 网关 / 格式转换 +### 5.1 API 网关 / 协议转换 -#### 4.1.1 内部统一格式(标准模型) +#### 5.1.1 内部统一格式(标准模型) -网关内部使用 **OpenAI Chat Completions 形状** 作为"标准中间模型",三套协议都先转成它、再转成目标协议: +网关内部使用 **OpenAI Chat Completions 形状**作为"标准中间模型",三套协议都先转成它、再转成目标协议: ``` OpenAI /v1/responses ──┐ @@ -157,122 +193,121 @@ OpenAI /v1/chat/completions ─┼──▶ 标准模型(OpenAI chat 形状) Anthropic /v1/messages ──┘ ``` -这样新增一种上游渠道(如 Gemini)只需写**一对**转换器(标准模型 ↔ 渠道格式),不用为每个协议组合写转换器;Responses 与 Chat、Messages 与 Chat 之间各维护一个转换适配器。 +新增上游渠道(如 Gemini)只需写**一对**转换器(标准模型 ↔ 渠道格式),而非为每个协议组合写转换器。 -#### 4.1.2 转换映射要点 +#### 5.1.2 转换映射要点 **Chat ↔ Claude Messages** | 维度 | OpenAI chat ↔ Claude messages | | --- | --- | -| system | OpenAI `messages[role=system]` ↔ Claude `system` 参数(支持数组/文本) | -| 消息 | `assistant.tool_calls` ↔ `content` 中的 `tool_use` 块;`role=tool` ↔ `tool_result` 块 | +| system | `messages[role=system]` ↔ Claude `system` 参数(支持数组/文本) | +| 消息 | `assistant.tool_calls` ↔ `content` 的 `tool_use` 块;`role=tool` ↔ `tool_result` 块 | | 工具 | `tools[{type:function,name,description,parameters}]` ↔ `tools[{name,description,input_schema}]` | -| 采样 | `temperature`(OpenAI 0–2,Claude 0–1,越界按目标协议 clamp)、`top_p` | -| 长度 | `max_tokens` ↔ `max_tokens`(Claude 必填,缺失时给默认值) | +| 采样 | `temperature`(OpenAI 0–2,Claude 0–1,按目标协议 clamp)、`top_p` | +| 长度 | `max_tokens` ↔ `max_tokens`(Claude 必填,缺失给默认值) | | 停止 | `stop` ↔ `stop_sequences`(数组对齐) | | 流式 | OpenAI SSE `data: {chunk}` + `[DONE]` ↔ Claude 事件流 `message_start / content_block_delta / message_delta / message_stop`,逐事件互转 | -| 用量 | `usage.prompt_tokens/completion_tokens` ↔ `usage.input_tokens/output_tokens`,并映射 Claude 的缓存 token | +| 用量 | `usage.prompt_tokens/completion_tokens` ↔ `usage.input_tokens/output_tokens`,映射 Claude 缓存 token | -**Responses ↔ Chat**(Responses 结构化能力更多,映射如下) +**Responses ↔ Chat** | Responses 字段 | Chat 对应 | | --- | --- | | `instructions` | `messages[0].system` | -| `input[]`(`input_text` / `input_image` / `input_file`) | `messages[]`(user 多模态 content 数组) | -| `input[]` 中 `function_call` / `function_call_output` 条目 | `assistant.tool_calls` / `role=tool` 消息 | -| `output[]` 中 `message` / `function_call` / `reasoning` 条目 | `choices[].message` / `tool_calls`(reasoning 跨协议丢弃) | -| `tools[{type:function,name,description,parameters}]` | `tools[{type:function,...}]`(结构相同) | -| `output_format` / `text.format` | `response_format` | +| `input[]`(`input_text`/`input_image`/`input_file`) | `messages[]`(user 多模态 content 数组) | +| `input[]` 中 `function_call`/`function_call_output` | `assistant.tool_calls` / `role=tool` | +| `output[]`(`message`/`function_call`/`reasoning`) | `choices[].message` / `tool_calls`(reasoning 跨协议丢弃) | +| `tools[{type:function,...}]` | 结构相同 | +| `output_format`/`text.format` | `response_format` | | `max_output_tokens` | `max_tokens` | -| `previous_response_id` | 仅直通 OpenAI 渠道可用;跨协议时降级(见 4.1.3) | +| `previous_response_id` | 仅直通 OpenAI 可用;跨协议降级(见 5.1.3) | | `reasoning.effort` | 仅直通或特定渠道,跨协议丢弃 | -| 流式事件 `response.created / output_text.delta / function_call_arguments.delta / response.completed` | chat SSE `data: {delta}` + `[DONE]`,逐事件互转 | +| 流式事件 `response.created/output_text.delta/function_call_arguments.delta/response.completed` | chat SSE `data: {delta}` + `[DONE]` | -#### 4.1.3 直通(passthrough)与转换策略 +#### 5.1.3 直通与转换策略 -- **直通优先**:客户端协议与渠道原生协议一致时直接透传报文(仅做鉴权/限额/记账),**不转格式**——保证 Responses 的 `previous_response_id`、`reasoning`、结构化输出等新能力在 OpenAI 渠道上零损失。 -- **转换路径**:协议不匹配时才经标准模型转换(如 Responses → Anthropic 渠道、Messages → OpenAI 渠道)。 +- **直通优先**:客户端协议 = 渠道原生协议时直接透传(仅鉴权/限额/记账),不转格式——保证 Responses 的 `previous_response_id`、`reasoning`、结构化输出零损失。 +- **转换路径**:协议不匹配时才经标准模型转换。 - **有损边界(文档明示)**: - - `previous_response_id`、`reasoning.effort` 跨协议时**降级或丢弃**,错误响应中提示。 - - Anthropic 渠道不接收 `response_format` 类结构化约束,降级为 prompt 提示或丢弃。 - - Claude 的 thinking 块在 OpenAI 协议侧丢弃(无法表达)。 + - `previous_response_id`、`reasoning.effort` 跨协议降级/丢弃,错误响应中提示。 + - Anthropic 渠道不接收 `response_format` 类约束,降级为 prompt 或丢弃。 + - Claude thinking 块在 OpenAI 协议侧丢弃。 - **转换标记**:转换过的请求/响应加 `x-converted: true` 头,便于排查。 -#### 4.1.4 错误响应统一 +#### 5.1.4 错误响应统一 -无论上游是什么错误,都按**客户端请求的协议**返回错误体: +按**客户端请求的协议**返回错误体: -- OpenAI 格式:`{"error":{"message","type","param","code"}}` + 映射过的 HTTP 状态码。 -- Claude 格式:`{"type":"error","error":{"type","message"}}`。 -- 状态码映射:上游 `429` → `429`(附 `retry-after`)、上游 `5xx` → 触发重试后返回 `502/504`、`400`(含 context length 超限)→ 原样返回、余额不足 → `402`。 +- OpenAI:`{"error":{"message","type","param","code"}}` + 映射过的状态码。 +- Claude:`{"type":"error","error":{"type","message"}}`。 +- 状态码映射:上游 `429` → `429`(附 `retry-after`);上游 `5xx` → 重试后 `502/504`;`400`(含 context 超限)→ 原样;余额不足 → `402`。 -### 4.2 渠道系统 +### 5.2 渠道系统 | 能力 | 设计 | | --- | --- | -| 渠道 CRUD | 管理员增删改:名称、**API 类型**(openai / anthropic / compatible)、base_url、上游 key(AES-GCM 加密存储)、超时、并发上限 | -| API 类型选择 | 新增渠道时选择类型,决定支持的原生协议(决定直通还是转换)与模型列表导入方式 | -| 模型列表导入 | 渠道支持 `GET /v1/models` 时提供"拉取模型列表"按钮,自动导入可用模型到全局模型库并生成绑定;不支持该端点的渠道(部分第三方)可手动录入 | -| 模型绑定 | 模型(全局) ↔ 渠道(多个) 多对多,每个绑定记录 `upstream_model` 名、权重/优先级 | -| 负载均衡 | 按权重 + 优先级 + 健康状态选择渠道;健康渠道优先 | -| 健康检查 | 定时用最廉价模型发一次测试请求(非流式),连续失败 N 次进入 cooldown,恢复后再放回 | -| 重试/故障转移 | 仅对"可安全重试"的失败(网络错误、429、5xx、超时、上游连接断开**且尚未写出响应头**);对 400/context 类错误不重试。流式一旦已向客户端写出首字节,放弃重试 | -| 并发控制 | 每渠道信号量限制最大并发,超限排队或溢出到其他渠道 | +| 渠道 CRUD | 管理员增删改:名称、**API 类型**(openai/anthropic/compatible)、base_url、上游 key(AES-GCM 加密)、超时、并发上限 | +| API 类型 | 决定原生协议(直通 or 转换)与模型导入方式 | +| 模型导入 | 渠道支持 `GET /v1/models` 时"拉取模型列表"自动导入并绑定;否则手动录入 | +| 模型绑定 | 模型(全局) ↔ 渠道(多个) 多对多,绑定记录 `upstream_model`、权重 | +| 负载均衡 | 权重 + 优先级 + 健康状态选渠道 | +| 健康检查 | 定时用最廉价模型发测试请求,连续失败 N 次进 cooldown,恢复后放回 | +| 重试/故障转移 | 仅对可安全重试的失败(网络错误、429、5xx、超时、上游断开**且未写出响应头**);流式已写出首字节即放弃重试 | +| 并发控制 | 每渠道信号量限最大并发,超限排队或溢出到其他渠道 | -### 4.3 认证与用户 +### 5.3 认证与用户 -#### 4.3.1 角色 +#### 5.3.1 角色 | 角色 | 权限 | | --- | --- | | `admin` | 全部;渠道管理、模型定价、用户管理、余额调整、充值审核、全局用量 | -| `user` | 创建/管理自己的 API Key、查询用量、充值、查看余额 | +| `user` | 创建/管理自己的 API Key、查询用量、查看余额 | > 预留 `viewer`(只读运营)角色,首版不做。 -#### 4.3.2 会话 +#### 5.3.2 会话 - 登录:用户名/邮箱 + 密码(argon2id 校验)。 -- 颁发:短时访问令牌(JWT,如 2h,存内存)+ 刷新令牌(存 HttpOnly Cookie,7d)。 -- 注册:**开放注册,可切换**——默认 `open`,配置项 `registration.mode` 切到 `invite` 即启用邀请码(管理员后台生成)。 +- 颁发:短时 access(JWT,2h,内存)+ refresh(HttpOnly cookie,7d)。 +- 注册:默认 `open`,配置 `registration.mode=invite` 启用邀请码(管理员后台生成)。 -#### 4.3.3 API Key +#### 5.3.3 API Key -- 生成格式:`sk-` + 48 位随机字符(base62),**创建时仅展示一次**。 -- 存储:库中只存 SHA-256 哈希 + 展示用前缀(如 `sk-aB3c…`);请求时对 Bearer 哈希后查表。 -- 附加能力:密钥级配额(每日 token 上限 / 每日请求数上限)、模型白名单、过期时间、启停。 +- 格式:`sk-` + 48 位随机 base62,**创建时仅展示一次**。 +- 存储:仅 SHA-256 哈希 + 展示前缀(如 `sk-aB3c…`);请求时哈希后查表。 +- 附加能力:密钥级配额(每日 token / 请求数)、模型白名单、过期时间、启停。 - 限额检查用 Redis 计数,与用户级限流叠加。 -### 4.4 用量与计费 +### 5.4 用量与计费 -#### 4.4.1 Token 统计 +#### 5.4.1 Token 统计 -- 优先取上游响应中的 usage(OpenAI `usage` 字段、Claude `message_delta.usage`)。 -- 上游缺失时 fallback:本地近似计数(按字符/字节估算,或引入 tiktoken-go 按模型分词)。 -- Claude 渠道额外记录 `cache_read_input_tokens` / `cache_creation_input_tokens`,用于缓存计费。 +- 优先取上游 usage(OpenAI `usage` / Claude `message_delta.usage`)。 +- 缺失时 fallback:本地近似计数(字符/字节估算,或 tiktoken-go 按模型分词)。 +- Claude 渠道额外记录 `cache_read_input_tokens`/`cache_creation_input_tokens`。 -#### 4.4.2 计价 +#### 5.4.2 计价 -- 模型注册表(`models`)中每个模型配置:`input_price`、`output_price`、`cache_read_price`(按 **每百万 token**)。 -- 单次成本 = `in×in_price + out×out_price + cache_read×cache_read_price`(统一以 USD 记账,前端按配置汇率显示)。 -- 管理员可随时调价,历史用量按**当时价格**入账(用量表冗余快照价格字段)。 +- 模型注册表(`models`):`input_price`、`output_price`、`cache_read_price`(**每百万 token**)。 +- 单次成本 = `in×in_price + out×out_price + cache_read×cache_read_price`(USD 记账,前端按汇率显示)。 +- 调价不影响历史:用量表冗余快照价格。 -#### 4.4.3 余额与扣费 +#### 5.4.3 余额与扣费 -- 预充值余额制:每次请求结束后异步扣费并写余额流水(`balance_logs`)。 -- 扣费前先检查:余额 ≤ 0 时新请求返回 `402`。可选开关:按模型估算成本超余额即拦截(防止大单超额)。 -- 余额为负不拒绝已进行的流式请求(流中途无法中断),但后续请求被拒。 +- 预充值余额制:请求结束后异步扣费并写 `balance_logs`。 +- 扣费前检查:余额 ≤ 0 → `402`。可选开关:按模型估算成本超余额即拦截。 +- 流式请求进行中不中断(流中途无法停),后续请求被拒。 -#### 4.4.4 用量查询 +#### 5.4.4 用量查询 -- `usage_logs`:请求级明细(用户、密钥、模型、渠道、token、成本、耗时、状态)。 -- 聚合:日粒度预聚合表(`usage_daily`)支撑 Dashboard 图表,避免每次实时扫明细表。 +- `usage_logs`:请求级明细(用户/密钥/模型/渠道/token/成本/耗时/状态)。 +- 聚合:日粒度预聚合 `usage_daily` 支撑 Dashboard 图表,避免实时扫明细表。 -### 4.5 充值(暂停开发) +### 5.5 充值(待定,暂缓开发) -> 已决定:**充值暂缓**,首版不做,先交付"代理 + 用户 + 计费"。方案(人工审核 / 在线支付)确定后再落地。 -> 预留:`recharge_orders` / `balance_logs` 数据模型与订单状态机先行建好,后续接入不影响现有结构。 +> 首版不做,先交付"代理 + 用户 + 计费"。数据模型与订单状态机**先行建好**,方案确定后接入不影响结构。 订单状态机(预留): @@ -282,191 +317,171 @@ pending(待审核) ──approve──▶ credited(已入账) └──reject──▶ rejected └─(错误入账→adjust 冲正) ``` -### 4.6 管理后台 +管理员侧保留"查询充值 / 审核"接口占位。 + +### 5.6 管理后台 - 渠道管理:增删改、模型绑定、手动测试连接、健康状态查看。 -- 模型管理:全局模型清单、多渠道绑定、价格设置、启停。 +- 模型管理:全局清单、多渠道绑定、价格设置、启停。 - 用户管理:列表/搜索、改角色/状态、调整余额、重置密码。 -- 充值审核:待审订单列表、通过/驳回、流水留痕。 +- 充值审核:待审订单、通过/驳回、流水留痕(后置)。 - 全局用量:跨用户查询、按模型/渠道/天聚合、营收统计。 - 系统配置:开放注册、邀请码、汇率、限流阈值、维护开关。 --- -## 5. 数据模型 +## 6. 数据模型 -> 统一 `id` 为 bigint 自增(或 snowflake),时间用 UTC,金额/价格用 `numeric(20,8)`,token 用 `bigint`。 +> 统一 `id` bigint 自增;时间 UTC;金额/价格 `numeric(20,8)`;token `bigint`。以下模型与 git HEAD 中 `store/models.go` 一致(已落地),字段名以代码为准。 -### 5.1 users -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| id | bigint PK | | -| username / email | text UNIQUE | | -| password_hash | text | argon2id | -| role | enum(`user`,`admin`) | | -| balance | numeric(20,8) | 余额 | -| status | enum(`active`,`disabled`) | | -| invite_code | text nullable | 注册来源邀请码 | -| last_login_at / created_at / updated_at | timestamptz | | +### 6.1 users +`id, username UNIQUE, email UNIQUE, password_hash(argon2id), role(user|admin), balance numeric(20,8), status(active|disabled), invite_code?, last_login_at?, created_at, updated_at` -### 5.2 api_keys -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| id | bigint PK | | -| user_id | bigint FK | | -| name | text | 展示名 | -| key_hash | text UNIQUE | SHA-256 | -| key_prefix | text | `sk-aB3c…` | -| quota_tokens_per_day | bigint nullable | 密钥级限额 | -| quota_requests_per_day | int nullable | | -| allowed_models | jsonb nullable | 模型白名单 | -| expires_at | timestamptz nullable | | -| status | enum(`active`,`revoked`) | | -| last_used_at | timestamptz nullable | | -| created_at | timestamptz | | +### 6.2 api_keys +`id, user_id FK, name, key_hash UNIQUE(SHA-256), key_prefix, quota_tokens_per_day?, quota_requests_per_day?, allowed_models jsonb?, expires_at?, status(active|revoked), last_used_at?, created_at` -### 5.3 channels -| 字段 | 类型 | 说明 | -| --- | --- | --- | -| id | bigint PK | | -| name | text | | -| provider | enum(`openai`,`anthropic`,`compatible`) | API 类型:决定原生协议与模型导入方式 | -| base_url | text | 上游地址 | -| api_key_enc | text | AES-GCM 密文 | -| weight / priority | int | 负载均衡权重 / 优先级 | -| timeout_ms | int | | -| max_concurrency | int | | -| health_status | enum(`healthy`,`degraded`,`cooldown`) | | -| enabled | bool | | -| created_at / updated_at | timestamptz | | +### 6.3 channels +`id, name, provider(openai|anthropic|compatible), base_url, api_key_enc(AES-GCM), weight, priority, timeout_ms, max_concurrency, health_status(healthy|degraded|cooldown), enabled, created_at, updated_at` -### 5.4 models + channel_model_bindings -`models`(全局模型 + 价格): -| 字段 | 类型 | -| --- | --- | -| id, name(全局名如 `claude-sonnet-5`), display_name | | -| input_price / output_price / cache_read_price | numeric(20,8)(每百万 token) | -| enabled, sort | | +### 6.4 models + channel_model_bindings +- `models`:`id, name(全局名如 claude-sonnet-5), display_name, input_price, output_price, cache_read_price(每百万token), enabled, sort` +- `channel_model_bindings`:`id, channel_id FK, model_id FK, upstream_model, weight` -`channel_model_bindings`(多对多): -| 字段 | 类型 | -| --- | --- | -| id, channel_id FK, model_id FK | | -| upstream_model | text(如 `us.anthropic.com:claude-sonnet-5`) | -| weight | int | +### 6.5 usage_logs(请求级明细,索引 `(user_id, created_at)`) +`id, request_id(上游id), user_id FK, key_id FK, channel_id FK, model_id FK, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, input_price, output_price, cache_read_price(快照), cost, latency_ms, status(success|error|canceled), error_code?, created_at` -### 5.5 usage_logs(请求级明细) -| 字段 | 类型 | -| --- | --- | -| id, request_id(上游 id), user_id FK, key_id FK, channel_id FK, model_id FK | | -| input_tokens / output_tokens / cache_read_tokens / cache_creation_tokens | bigint | -| input_price / output_price / cache_read_price | numeric(20,8) 快照 | -| cost | numeric(20,8) | -| latency_ms | int | -| status | enum(`success`,`error`,`canceled`) | -| error_code | text nullable | -| created_at | timestamptz,带索引 `(user_id, created_at)` | +### 6.6 usage_daily(日聚合) +`id, user_id FK, model_id FK, date, requests, input_tokens, output_tokens, cache_read_tokens, cost` -### 5.6 usage_daily(日聚合) -`id, user_id, model_id, date, requests, input_tokens, output_tokens, cache_read_tokens, cost` +### 6.7 recharge_orders(预留) +`id, user_id FK, amount, status(pending|credited|rejected), method(manual|online), transaction_id?, reviewed_by FK?, reviewed_at?, remark?, created_at` -### 5.7 recharge_orders -`id, user_id FK, amount numeric(20,8), status enum(pending/credited/rejected), method enum(manual/online), transaction_id, reviewed_by FK, reviewed_at, remark, created_at` +### 6.8 balance_logs(余额流水) +`id, user_id FK, change, balance_after, type(recharge|usage|refund|admin_adjust), ref_id?, created_at` -### 5.8 balance_logs(余额流水,幂等保证) -`id, user_id FK, change numeric(20,8), balance_after numeric(20,8), type enum(recharge/usage/refund/admin_adjust), ref_id, created_at` - -### 5.9 system_configs +### 6.9 system_configs `key text PK, value jsonb` --- -## 6. API 设计 +## 7. API 设计 -### 6.1 代理端点(对外,Bearer API Key 认证) +### 7.1 代理端点(对外,Bearer API Key 认证) | 方法 | 路径 | 说明 | | --- | --- | --- | | POST | `/v1/responses` | OpenAI Responses API | | POST | `/v1/chat/completions` | OpenAI Chat 格式 | -| POST | `/v1/messages` | Anthropic Messages | +| POST | `/v1/messages` | Anthropic Messages(M4 上) | | GET | `/v1/models` | 可用模型列表(OpenAI 风格) | -### 6.2 管理 API(`/api/v1`,会话认证) +### 7.2 管理 API(`/api/v1`,会话认证) -**认证/用户** -- `POST auth/register` · `POST auth/login` · `POST auth/logout` · `GET auth/me` -- `GET user/profile` · `GET user/balance` +**认证/用户**:`POST auth/register|login|logout|refresh` · `GET auth/me` · `GET user/profile|balance|models` -**API Key** -- `GET/POST /keys` · `PATCH/DELETE /keys/:id` +**API Key**:`GET|POST /keys` · `PATCH|DELETE /keys/:id` -**用量** -- `GET /usage/summary`(今日/本月汇总) -- `GET /usage/stats?from&to&group=day|model` -- `GET /usage/logs?from&to&page&model&keyId` +**用量**:`GET /usage/summary` · `GET /usage/stats?from&to&group=day|model` · `GET /usage/logs?from&to&page&model&keyId` -**充值** -- `POST /recharges` · `GET /recharges`(暂停,接口预留) +**充值(预留)**:`POST /recharges` · `GET /recharges` **管理后台(`/api/v1/admin`,仅 admin)** -- `GET/POST/PUT/DELETE /channels` · `POST /channels/:id/test` -- `GET/POST/PUT /models` · `PUT /models/:id/price` +- `GET|POST|PUT|DELETE /channels` · `POST /channels/:id/test` +- `GET|POST|PUT /models` · `PUT /models/:id/price` - `GET /users` · `PATCH /users/:id` · `POST /users/:id/balance` -- `GET /recharges` · `POST /recharges/:id/approve|reject`(暂停,接口预留) +- `GET /recharges` · `POST /recharges/:id/approve|reject`(预留) - `GET /usage` · `GET /stats/overview` -- `GET/PUT /config` +- `GET|PUT /config` --- -## 7. 前端设计(taste-skill) +## 8. 前端设计(taste-skill) -### 7.1 设计流程 +### 8.1 设计流程 1. 前端阶段启动时**调用 taste-skill**:传入产品 brief 与页面清单,由它推断设计方向,产出: - - 设计 tokens:色板(含暗色/亮色)、字体系统、间距、圆角、阴影、栅格。 - - 核心页面高保真方向(先做 3–5 个代表页,不一次铺开)。 -2. 以 tokens 建立 Tailwind 主题(tailwind.config + CSS variables)与基础组件(Button / Table / Form / Modal / Nav)。 -3. 按页面清单逐组实现,每个阶段结束用 **web-design-guidelines** 复查(对比度、可访问性、交互细节)。 + - 设计 tokens:色板(含暗/亮色)、字体系统、间距、圆角、阴影、栅格。 + - 3–5 个代表页的高保真方向(不一次铺开)。 +2. 以 tokens 建立 Tailwind 主题(`tailwind.config` + CSS variables)与基础组件(Button/Table/Form/Modal/Nav)。 +3. 按页面清单逐组实现,每阶段结束用 **web-design-guidelines** 复查(对比度、可访问性、交互细节)。 4. 设计评审迭代,**不套模板**。 -### 7.2 预期设计方向(taste-skill 最终决定,此处为倾向) +### 8.2 预期设计方向(taste-skill 最终决定,此处为倾向) + +开发者工具 / API 网关类产品: -开发者工具 / API 网关类产品,倾向: - 深色优先、仪表盘质感;等宽字体点缀 token、端点、代码片段。 - 数据密度高的表格(用量、密钥、订单),克制的中性色 + 单一强调色。 - Landing 简洁可信:产品价值、端点示例、模型列表预览。 -### 7.3 页面清单 +### 8.3 页面清单 | 区域 | 页面 | | --- | --- | | 公开 | Landing · 登录 · 注册 | -| 用户 | Dashboard(余额/今日用量/最近请求/图表)· API Keys · 用量查询 · 充值(后置) · 个人设置 | -| 管理 | 运营总览 · 渠道管理(API 类型 + 模型导入) · 模型与定价 · 用户管理 · 充值审核(后置) · 全局用量 · 系统配置 | +| 用户 | Dashboard(余额/今日用量/最近请求/图表)· API Keys · 用量查询 · 个人设置 | +| 管理 | 运营总览 · 渠道管理(API 类型 + 模型导入)· 模型与定价 · 用户管理 · 充值审核(后置)· 全局用量 · 系统配置 | -### 7.4 前端工程注意点 +### 8.4 前端工程注意点 -- 流式调试体验:控制台页提供"用 curl / 请求编辑器"快速验证 key 与模型(可选,v2)。 -- 图表统一用 ECharts,暗色主题与设计 tokens 对齐。 -- 表格/表单为自建组件,行为一致性优先,后续可沉淀为内部组件库。 +- 流式调试体验:控制台页提供"curl / 请求编辑器"快速验证 key 与模型(可选,v2)。 +- 图表统一 ECharts,暗色主题对齐 tokens。 +- 表格/表单为自建组件,行为一致性优先,可沉淀为内部组件库。 --- -## 8. 非功能需求 +## 9. 非功能需求 | 类别 | 要求 | | --- | --- | -| 安全 | API Key 仅存哈希;渠道密钥加密存储;密码 argon2id;JWT 刷新令牌 HttpOnly;日志/错误信息脱敏(不泄露渠道 key、完整 key);CORS 白名单;管理接口二次鉴权 | +| 安全 | API Key 仅存哈希;渠道密钥加密;密码 argon2id;JWT refresh HttpOnly;日志/错误脱敏(不泄渠道 key、完整 key);CORS 白名单;管理接口二次鉴权 | | 限流 | Redis token bucket:用户级 + 密钥级 + 全局并发保护 | -| 稳定性 | 渠道 cooldown + 重试;流式请求 client 断连即取消上游调用(ctx cancel);上游超时兜底 | +| 稳定性 | 渠道 cooldown + 重试;流式 client 断连即取消上游(ctx cancel);上游超时兜底 | | 可观测 | zap 结构化日志 + request_id;Prometheus 指标(请求数/延迟/错误率/成本);管理后台健康概览 | -| 性能 | 流式零缓冲转发;记账异步批量落库;聚合查询走预聚合表 | +| 性能 | 流式零缓冲转发;记账异步批量落库;聚合走预聚合表 | | 合规 | 用户协议与数据留存说明;退款/冲正流程可追溯 | --- -## 9. 仓库结构(规划) +## 10. 里程碑与任务分解 + +> 目标:**MVP(M0–M3)先交付**;M4/M5 按需后置。每阶段含验收点。 + +### M0 基建(◻ 重做,参考旧实现) +配置、启动、DB 迁移、日志、CORS、健康检查、mock 上游。 +**验收**:`make run` + `make mock-upstream` 起服务,`/healthz` OK。 + +### M1 用户 + 密钥 + 核心代理(◻ 重做,参考旧实现) +注册/登录/JWT、API Key、chat/responses/models 直通、基础记账扣费。 +**验收**:curl 冒烟(注册→登录→建 key→对话→查用量)。 + +### M2 前端 MVP + 管理后台基础(✅ 已完成) +- 前端:taste-skill 定 tokens → Tailwind 主题 → 自建组件 → Landing/登录/注册/控制台(Dashboard/Keys/Usage)→ 管理后台(运营总览/渠道/模型/用户/配置)。 +- 后端:渠道 CRUD + 测试 + 模型导入、模型管理 + 定价 + 绑定、全局用量/统计接口。 +- 已过 web-design-guidelines 复查并修复(移动端侧栏、表格横向滚动、模态框焦点/滚动锁、focus-visible、aria 等)。 +**验收**:用户在控制台建 key、发请求、看用量;管理员能加渠道、调价、看统计。 + +### M3 管理后台前端 + 计费完善(◻ 部分完成) +渠道/模型/用户/总览/配置页面已完成;`usage_daily` 趋势图已内建(自建 SVG)。待做:限流接入、加载骨架屏、按模型聚合报表增强。 + +### M4 协议转换(✅ 已完成) +- `convert` 包:Chat↔Messages↔Responses 请求/响应 JSON 转换 + 流式 SSE 逐行状态机转换器(含单测)。 +- `/v1/messages` 端点;网关按"客户端协议 × 渠道 provider"自动转换,协议匹配直通。 +- 流式 usage 合并记账(message_start input + message_delta output);错误体按协议返回。 +- mock 上游新增 Anthropic Messages 端点,端到端验证 8 种组合(三协议 × 直通/转换 × 流式/非流式)。 +**验收**:chat 调 Claude、messages 调 OpenAI、responses 调 Claude 均正确,流式逐事件转换,usage 记账准确。 + +### M5 渠道体系完善(◻ 规划) +健康检查、负载均衡、重试/故障转移、并发控制、模型自动导入。 +**验收**:杀一个渠道自动切换;连续失败进 cooldown 并恢复。 + +### M6 充值 + 审核(◻ 待定,接口/模型已预留) +充值订单、人工审核、流水留痕、前端页面。 +**验收**:管理员审核充值→余额到账→流水可查。 + +--- + +## 11. 仓库结构(规划) ``` openteam/ @@ -481,58 +496,37 @@ openteam/ │ │ │ ├── openai/ # OpenAI Chat 格式编解码 │ │ │ ├── claude/ # Anthropic Messages 格式编解码 │ │ │ ├── convert/ # 标准模型 ↔ 各协议转换 -│ │ │ └── stream/ # SSE 双向流式转发 -│ │ ├── channel/ # 渠道、负载均衡、健康检查、重试 +│ │ │ └── stream/ # SSE/事件流双向转发 +│ │ ├── channel/ # 渠道、LB、健康检查、重试 │ │ ├── billing/ # 计价、余额、流水 │ │ ├── usage/ # 记账、聚合 -│ │ ├── recharge/ # 订单(待定方案) +│ │ ├── recharge/ # 订单(待定) │ │ ├── admin/ # 管理 API │ │ ├── store/ # GORM models + repositories │ │ └── pkg/ # jwt, crypto, ratelimit, tiktoken ├── web/ # Vue3 前端 │ ├── src/styles/ # taste-skill 设计 tokens │ ├── src/components/ # 基础组件 -│ ├── src/views/ # 页面 +│ ├── src/views/ # 页面(含 admin/) │ ├── src/stores/ · src/api/ · src/router/ ├── deploy/ # docker-compose, nginx, Dockerfile +├── scripts/mockupstream/ # mock 上游(联调) └── docs/ ``` --- -## 10. 开发里程碑 +## 12. 开放决策项(编码前需确认) -| 里程碑 | 内容 | 验收标准 | -| --- | --- | --- | -| **M0 基建** | 仓库结构、配置、DB 迁移、JWT/密码工具、CI;taste-skill 启动产出设计 tokens | 空服务可启动,tokens 落地 | -| **M1 用户+密钥+核心代理** | 注册/登录/角色、API Key CRUD;`/v1/responses` 与 `/v1/chat/completions` 直连 OpenAI 渠道(非流式+流式,直通);OpenAI 错误格式 | 用 curl 完成一次带流式的对话与一次 Responses 调用;密钥可建可吊销 | -| **M2 用量+计费** | 记账、token 统计、价格表、余额扣减、用量 API;前端 Dashboard/用量页 | 请求后余额正确变化,用量图表正确 | -| **M3 跨协议转换** | `/v1/messages` 代理;Responses ↔ Chat ↔ Messages 转换(含流式、工具调用);Anthropic 渠道 | OpenAI 客户端调 Claude 模型、Anthropic 客户端调 OpenAI 模型均通 | -| **M4 渠道系统** | 渠道 CRUD(API 类型)、`/models` 模型导入、模型绑定、负载均衡、健康检查、重试/故障转移;管理后台渠道页 | 一个渠道挂掉自动切换;后台可加渠道、拉取模型并测试 | -| **M5 充值(暂停)** | 方案待定,首版不做;仅预留订单表与状态机 | — | -| **M6 打磨上线** | 限流、监控指标、审计日志、taste-skill 全站设计复查、docker-compose 部署、文档与测试补全 | 可对外交付部署 | - ---- - -## 11. 风险与待定决策 - -| # | 事项 | 状态 | 说明 | +| # | 决策 | 选项 | 结论/倾向 | | --- | --- | --- | --- | -| 1 | **充值** | ⏸ 暂停 | 首版不做,订单表与状态机预留,方案确定后再落地 | -| 2 | **注册策略** | ✅ 已确认 | 开放注册,配置可切换邀请码 | -| 3 | **协议范围** | ✅ 已确认 | Responses + Chat Completions + Anthropic Messages;去掉 legacy `/v1/completions` | -| 4 | **流式重试边界**:首字节发出后不可重试 | ✅ 已决策 | 仅连接建立前重试;文档明示 | -| 5 | **Responses 跨协议有损边界** | 🟡 实现中确认 | `previous_response_id`、`reasoning` 跨协议降级/丢弃,加 `x-converted` 头 | -| 6 | **模型列表导入差异** | 🟡 实现中确认 | 部分渠道无 `/v1/models`,需手动录入 fallback | -| 7 | **计费精度**:Claude 缓存 token、上游缺 usage | ✅ 已决策 | 冗余快照价格;近似计数 fallback | -| 8 | **多实例扩展**:记账时序 | 🟡 后置 | 单实例起步,必要时引入消息队列 | -| 9 | **合规**:数据留存、日志脱敏 | 🟡 上线前 | 隐私说明、审计日志 | +| 1 | **工作区恢复方式**(§2.3) | 从 git 恢复 / 推倒重来 | ✅ **推倒重来**(已定) | +| 2 | 限流起步 | Redis / 内存计数降级 | MVP 内存起步,Redis 后置 | +| 3 | Token 计数 fallback | 近似估算 / tiktoken-go | 起步近似,精确化后置 | +| 4 | 计费币种 | USD 记账 + 前端汇率 / 人民币 | USD 记账 | +| 5 | 充值方案(M6) | 人工审核 / 在线支付 | 人工审核起步 | +| 6 | 组件基座 | 纯自建 / headless 原语(Ark UI 等) | 纯自建起步 | --- -## 12. 下一步 - -范围已收敛:**代理(三协议)+ 用户管理 + 用量计费 + 渠道管理**,充值暂停。 - -1. 无阻塞性待定项,可按 **M0 → M1** 开始实施;taste-skill 先行产出设计方向,后端同时搭骨架。 -2. 实施中确认两处细节:Responses 跨协议降级边界(§11 #5)、模型导入的渠道差异(#6)。 +*本文档 v0.3 由 v0.2 修订:明确 Tailwind + taste-skill 选型、标注 M0+M1 状态、里程碑按"代理/管理/计费"三板块组织、补开放决策项;并已确认**推倒重来**(旧实现仅作参考)。* diff --git a/README.md b/README.md index 7c88223..bffabbc 100644 --- a/README.md +++ b/README.md @@ -2,24 +2,32 @@ 自托管的 LLM API 中转网关,功能对标 OpenRouter / one-api:统一 OpenAI 与 Anthropic 协议入口,背后对接多个上游渠道,内置用户体系、API Key 管理与用量计费。 -> 规划文档见 [PLANNING.md](./PLANNING.md)。当前进度:**M0(基建)+ M1(用户+密钥+核心代理)已完成**。 +> 规划文档见 [PLANNING.md](./PLANNING.md)。当前进度:**M0-M2 + M4 已完成**(基建 + 用户/密钥/核心代理 + 前端 MVP + 管理后台基础 + 三协议互转)。 ## 功能(当前) - **代理端点**(Bearer API Key) - `POST /v1/chat/completions` — OpenAI Chat(非流式 + 流式 SSE) - `POST /v1/responses` — OpenAI Responses API(非流式 + 流式事件) + - `POST /v1/messages` — Anthropic Messages API(非流式 + 流式事件) - `GET /v1/models` — 可用模型列表 - - 错误统一为 OpenAI 格式(401/402/404/429/502…) -- **用户体系**:注册(开放/邀请码可切换)、登录(JWT access + HttpOnly refresh cookie)、argon2id 密码 + - **三协议互转**:客户端协议 × 渠道协议不匹配时自动转换(如 Chat 调用 Claude、Messages 调用 OpenAI、Responses 调用 Claude),流式逐事件转换;协议匹配时直通 + - 错误按客户端协议返回(OpenAI 格式 / Anthropic 格式) +- **用户体系**:注册(开放/邀请码可切换,管理后台可改)、登录(JWT access + HttpOnly refresh cookie)、argon2id 密码 - **API Key**:`sk-` 48 位 base62,仅存 SHA-256 哈希,明文一次性展示;支持限额/过期/白名单字段 - **用量计费**:请求级 `usage_logs` 异步批量落库,按模型价格扣减余额,日粒度预聚合(`usage_daily`) -- **管理 API**:用户列表/角色/状态/余额调整、系统配置 -- **前端**:Landing / 登录注册 / 控制台(仪表盘 + 密钥管理 + 用量明细) +- **管理 API**:渠道 CRUD + 连通测试 + 模型导入、模型管理 + 定价 + 渠道绑定、用户管理、全局用量/统计、系统配置 +- **前端**(Vue3 + Tailwind,taste-skill 设计,深色优先) + - Landing / 登录 / 注册 + - 控制台:Dashboard(余额/用量/趋势图)、API Keys、用量明细 + - 管理后台:运营总览、渠道管理、模型与定价、用户管理、系统配置 +- **后端**:Go + Gin + GORM,SQLite(开发)/ PostgreSQL(生产) + +> 渠道健康检查/负载均衡/重试在 M5(见 PLANNING.md §10)。 ## 快速开始(开发) -前置:Go 1.23+、Node 20+、pnpm。 +前置:Go 1.23+。 ```bash # 1. 配置(复制并修改,至少设置上游 key) @@ -29,13 +37,12 @@ cp .env.example .env cd server && go run ./cmd/server # 默认管理员 admin / admin123(生产务必修改) -# 3. 启动前端 -cd web && pnpm i && pnpm dev # http://localhost:5173 - -# 4. 用 mock 上游联调(无需真实 key) +# 3. 用 mock 上游联调(无需真实 key,另开终端) make mock-upstream # :9000 起一个模拟 OpenAI 服务 ``` +`.env` 中 `OT_PROXY_UPSTREAM_KEY` / `OT_PROXY_UPSTREAM_BASE_URL` 指向 mock 上游时,服务首次启动会自动创建默认渠道与示例模型。 + ### 冒烟测试(curl) ```bash @@ -49,42 +56,48 @@ KEY=$(curl -s -X POST localhost:8080/api/v1/keys -H "Authorization: Bearer $TOKE # 对话(非流式 + 流式) curl -s localhost:8080/v1/chat/completions -H "Authorization: Bearer $KEY" \ - -H 'Content-Type: application/json' -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}' -curl -sN localhost:8080/v1/chat/completions -H "Authorization: Bearer $KEY" \ - -H 'Content-Type: application/json' -d '{"model":"gpt-4o-mini","stream":true,"messages":[{"role":"user","content":"hi"}]}' + -H 'Content-Type: application/json' \ + -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"你好"}],"stream":false}' +curl -N localhost:8080/v1/chat/completions -H "Authorization: Bearer $KEY" \ + -H 'Content-Type: application/json' \ + -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"你好"}],"stream":true}' + +# 用量 +curl -s localhost:8080/api/v1/usage/summary -H "Authorization: Bearer $TOKEN" +curl -s "localhost:8080/api/v1/usage/logs?page_size=5" -H "Authorization: Bearer $TOKEN" ``` -## 生产部署 +### 前端(开发) ```bash -cp .env.example .env # 填写 JWT_SECRET / MASTER_KEY / 上游 key -docker compose -f deploy/docker-compose.yml up -d --build +cd web && pnpm i && pnpm dev # http://localhost:5173(/api、/v1 代理到 8080) ``` -`nginx` 托管前端静态资源并反代 `/api` 与 `/v1`(SSE 关闭缓冲)。 +### 测试 + +```bash +cd server && go test ./... +cd web && pnpm build # vue-tsc 类型检查 + 构建 +``` ## 仓库结构 ``` -server/ Go 后端(cmd + internal/{api,proxy,channel,usage,store,pkg}) -web/ Vue 3 前端(Vite + Tailwind v4 + Pinia + ECharts) -deploy/ docker-compose / Dockerfile / nginx -scripts/ mock 上游(联调用) -docs/ 文档 +server/ # Go 后端 + cmd/server/ # 入口 + internal/ + config/ # viper + env(OT_ 前缀) + store/ # GORM models + 连接 + pkg/ # apikey / crypto / jwt / resp + api/ # 管理 API(认证、用户、密钥、用量、渠道/模型/管理后台) + proxy/ # 代理网关(鉴权、直通、流式、记账) + channel/ # 渠道选择与密钥解密 + usage/ # 异步记账 +web/ # Vue3 前端(Tailwind,taste-skill 设计 tokens) + src/ + views/ # Landing / 登录注册 / console / admin + components/ # ui(Button/Input/Modal/Badge/Toast/TrendChart)+ layout + stores/ · api/ · router/ · lib/ +scripts/mockupstream/ # mock 上游(联调) +deploy/ # Docker 部署(后续里程碑) ``` - -## 设计系统(taste-skill) - -深色优先的开发者控制台:石墨墨底 + 暖白文本 + 单一信号铜色强调(信号灯意象);UI 字体 Outfit,数据一律 JetBrains Mono(tabular numerals)。tokens 定义于 `web/src/style.css`(`@theme`),支持 `data-theme="light"` 切换。 - -## 路线图 - -| 里程碑 | 状态 | -| --- | --- | -| M0 基建(结构/配置/DB/工具/CI 前身) | ✅ | -| M1 用户 + 密钥 + 核心代理(chat/responses 直通) | ✅ | -| M2 用量 + 计费(记账/价格/前端图表) | 🟡 后端已备,前端图表就绪 | -| M3 跨协议转换(/v1/messages、Responses↔Chat↔Messages) | ⏳ | -| M4 渠道系统(导入/绑定/LB/健康检查/重试) | ⏳ | -| M5 充值(暂停,表结构已预留) | ⏸ | -| M6 打磨上线(限流/监控/审计/全站复查) | ⏳ | diff --git a/deploy/Dockerfile.api b/deploy/Dockerfile.api deleted file mode 100644 index c3af716..0000000 --- a/deploy/Dockerfile.api +++ /dev/null @@ -1,15 +0,0 @@ -# API 镜像:多阶段构建 Go 二进制 -FROM golang:1.26-alpine AS builder -WORKDIR /src -COPY server/go.mod server/go.sum ./ -RUN go mod download -COPY server/ . -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/openteam ./cmd/server - -FROM alpine:3.21 -RUN adduser -D -u 10001 app -USER app -WORKDIR /app -COPY --from=builder /out/openteam /app/openteam -EXPOSE 8080 -ENTRYPOINT ["/app/openteam"] diff --git a/deploy/Dockerfile.web b/deploy/Dockerfile.web deleted file mode 100644 index 491c0c6..0000000 --- a/deploy/Dockerfile.web +++ /dev/null @@ -1,12 +0,0 @@ -# 前端镜像:构建静态资源 + nginx -FROM node:24-alpine AS builder -WORKDIR /src -COPY web/package.json web/pnpm-lock.yaml ./ -RUN corepack enable && pnpm install --frozen-lockfile -COPY web/ . -RUN pnpm build - -FROM nginx:1.27-alpine -COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf -COPY --from=builder /src/dist /usr/share/nginx/html -EXPOSE 80 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml deleted file mode 100644 index 64d254e..0000000 --- a/deploy/docker-compose.yml +++ /dev/null @@ -1,54 +0,0 @@ -# ===== openteam 单实例部署(nginx + api + postgres)===== -# 使用:cp .env.example ../.env 并修改密钥,然后 docker compose up -d -services: - api: - build: - context: .. - dockerfile: deploy/Dockerfile.api - restart: unless-stopped - environment: - OT_ENV: production - OT_PORT: "8080" - OT_DB_DRIVER: postgres - OT_DB_DSN: host=postgres user=openteam password=${OT_DB_PASSWORD:-openteam} dbname=openteam port=5432 sslmode=disable - OT_JWT_SECRET: ${OT_JWT_SECRET:?set in .env} - OT_MASTER_KEY: ${OT_MASTER_KEY:?set in .env} - OT_ADMIN_PASSWORD: ${OT_ADMIN_PASSWORD:-admin123} - OT_PROXY_UPSTREAM_KEY: ${OT_PROXY_UPSTREAM_KEY:-} - OT_PROXY_UPSTREAM_BASE_URL: ${OT_PROXY_UPSTREAM_BASE_URL:-https://api.openai.com} - OT_PROXY_DEFAULT_MODEL: ${OT_PROXY_DEFAULT_MODEL:-gpt-4o-mini} - depends_on: - postgres: - condition: service_healthy - # 预留 Redis(M6 限流);当前单实例不依赖 - # redis: - # image: redis:7-alpine - # restart: unless-stopped - - postgres: - image: postgres:16-alpine - restart: unless-stopped - environment: - POSTGRES_USER: openteam - POSTGRES_PASSWORD: ${OT_DB_PASSWORD:-openteam} - POSTGRES_DB: openteam - volumes: - - pgdata:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U openteam"] - interval: 5s - timeout: 3s - retries: 10 - - web: - build: - context: .. - dockerfile: deploy/Dockerfile.web - restart: unless-stopped - ports: - - "${OT_HTTP_PORT:-80}:80" - depends_on: - - api - -volumes: - pgdata: diff --git a/deploy/nginx-public.conf b/deploy/nginx-public.conf deleted file mode 100644 index 37e953f..0000000 --- a/deploy/nginx-public.conf +++ /dev/null @@ -1,45 +0,0 @@ -# openteam 公网部署(IP+端口方式):宿主 8088 单端口入口 -# nginx 容器使用 host 网络,反代走 loopback 避开 DNAT 干扰 -server { - listen 8088; - server_name _; - - root /usr/share/nginx/html; - index index.html; - - # SPA 路由回退 - location / { - try_files $uri $uri/ /index.html; - } - - # 管理 API + 代理端点 → 本机 openteam(127.0.0.1 loopback 不经 iptables DNAT) - location /api/ { - proxy_pass http://127.0.0.1:8080; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 300s; - } - location /v1/ { - proxy_pass http://127.0.0.1:8080; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - # SSE 流式透传:关闭缓冲 - proxy_buffering off; - proxy_cache off; - proxy_read_timeout 300s; - } - - # 静态资源缓存 - location /assets/ { - expires 30d; - add_header Cache-Control "public, immutable"; - } - - gzip on; - gzip_types text/plain text/css application/json application/javascript image/svg+xml; -} diff --git a/deploy/nginx.conf b/deploy/nginx.conf deleted file mode 100644 index e183d5c..0000000 --- a/deploy/nginx.conf +++ /dev/null @@ -1,42 +0,0 @@ -server { - listen 80; - server_name _; - - root /usr/share/nginx/html; - index index.html; - - # SPA 路由回退 - location / { - try_files $uri $uri/ /index.html; - } - - # 管理 API + 代理端点 → Go 服务 - location /api/ { - proxy_pass http://api:8080; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - location /v1/ { - proxy_pass http://api:8080; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - # SSE 流式透传:关闭缓冲 - proxy_buffering off; - proxy_cache off; - proxy_read_timeout 300s; - } - - # 静态资源缓存 - location /assets/ { - expires 30d; - add_header Cache-Control "public, immutable"; - } - - gzip on; - gzip_types text/plain text/css application/json application/javascript image/svg+xml; -} diff --git a/scripts/mockupstream/go.mod b/scripts/mockupstream/go.mod index 60c057c..4f78ba5 100644 --- a/scripts/mockupstream/go.mod +++ b/scripts/mockupstream/go.mod @@ -1,3 +1,3 @@ -module mockupstream +module scripts/mockupstream -go 1.26.5 +go 1.23.0 diff --git a/scripts/mockupstream/main.go b/scripts/mockupstream/main.go index ebdf8bc..618f1ce 100644 --- a/scripts/mockupstream/main.go +++ b/scripts/mockupstream/main.go @@ -1,5 +1,5 @@ // mockupstream 本地 mock OpenAI 上游服务(联调代理链路,无需真实 key)。 -// 支持 /v1/chat/completions 与 /v1/responses,含流式与非流式。 +// 支持 /v1/chat/completions 与 /v1/responses,含流式与非流式;/v1/models 返回模型列表。 package main import ( @@ -46,22 +46,72 @@ func main() { http.HandleFunc("/v1/models", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"object":"list","data":[{"id":"gpt-4o-mini","object":"model"},{"id":"gpt-4o","object":"model"}]}`) + fmt.Fprint(w, `{"object":"list","data":[{"id":"gpt-4o-mini","object":"model"},{"id":"gpt-4o","object":"model"},{"id":"claude-sonnet-5","object":"model"}]}`) + }) + + // Anthropic Messages 端点(provider=anthropic 的渠道走这里) + http.HandleFunc("/v1/messages", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var req struct { + Model string `json:"model"` + Stream bool `json:"stream"` + } + _ = json.Unmarshal(body, &req) + if req.Stream { + streamMessages(w, req.Model) + return + } + replyMessages(w, req.Model) }) log.Printf("mock upstream listening on %s", *addr) log.Fatal(http.ListenAndServe(*addr, nil)) } +func replyMessages(w http.ResponseWriter, model string) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "id": "msg_mock789", + "type": "message", + "role": "assistant", + "model": model, + "content": []any{map[string]any{"type": "text", "text": "这是 mock Anthropic 上游的回复。"}}, + "stop_reason": "end_turn", + "usage": map[string]any{"input_tokens": 14, "output_tokens": 10}, + } + _ = json.NewEncoder(w).Encode(resp) +} + +func streamMessages(w http.ResponseWriter, model string) { + w.Header().Set("Content-Type", "text/event-stream") + fl, _ := w.(http.Flusher) + events := []map[string]any{ + {"type": "message_start", "message": map[string]any{"id": "msg_mock789", "type": "message", "role": "assistant", "model": model, "content": []any{}, "usage": map[string]any{"input_tokens": 14, "output_tokens": 0}}}, + {"type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""}}, + {"type": "content_block_delta", "index": 0, "delta": map[string]any{"type": "text_delta", "text": "这是"}}, + {"type": "content_block_delta", "index": 0, "delta": map[string]any{"type": "text_delta", "text": " Anthropic"}}, + {"type": "content_block_stop", "index": 0}, + {"type": "message_delta", "delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil}, "usage": map[string]any{"output_tokens": 10}}, + {"type": "message_stop"}, + } + for _, e := range events { + b, _ := json.Marshal(e) + typ, _ := e["type"].(string) + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", typ, b) + fl.Flush() + time.Sleep(40 * time.Millisecond) + } +} + func replyChat(w http.ResponseWriter, model string) { w.Header().Set("Content-Type", "application/json") resp := map[string]any{ - "id": "chatcmpl-mock123", - "object": "chat.completion", - "model": model, + "id": "chatcmpl-mock123", + "object": "chat.completion", + "model": model, "choices": []any{map[string]any{ - "index": 0, - "message": map[string]any{"role": "assistant", "content": "你好,这是 mock 上游的回复。"}, + "index": 0, + "message": map[string]any{"role": "assistant", "content": "你好,这是 mock 上游的回复。"}, "finish_reason": "stop", }}, "usage": map[string]any{"prompt_tokens": 12, "completion_tokens": 9, "total_tokens": 21}, @@ -80,7 +130,6 @@ func streamChat(w http.ResponseWriter, model string) { "choices": []any{map[string]any{ "index": 0, "delta": map[string]any{"content": c}, - "finish_reason": nil, }}, } if i == len(chunks)-1 { @@ -107,11 +156,11 @@ func streamChat(w http.ResponseWriter, model string) { func replyResponses(w http.ResponseWriter, model string) { w.Header().Set("Content-Type", "application/json") resp := map[string]any{ - "id": "resp_mock456", - "object": "response", - "model": model, - "status": "completed", - "output": []any{map[string]any{ + "id": "resp_mock456", + "object": "response", + "model": model, + "status": "completed", + "output": []any{map[string]any{ "type": "message", "role": "assistant", "content": []any{map[string]any{"type": "output_text", "text": "这是 Responses API 的 mock 回复。"}}, }}, diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 45885a4..59691ae 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "os/signal" + "strconv" "syscall" "time" @@ -34,7 +35,7 @@ func main() { router := api.NewRouter(a, gw) srv := &http.Server{ - Addr: ":" + itoa(cfg.Port), + Addr: ":" + strconv.Itoa(cfg.Port), Handler: router, ReadHeaderTimeout: 10 * time.Second, } @@ -58,25 +59,3 @@ func main() { } a.Usage.Close() } - -func itoa(n int) string { - if n == 0 { - return "8080" - } - neg := n < 0 - if neg { - n = -n - } - var b [12]byte - i := len(b) - for n > 0 { - i-- - b[i] = byte('0' + n%10) - n /= 10 - } - if neg { - i-- - b[i] = '-' - } - return string(b[i:]) -} diff --git a/server/go.mod b/server/go.mod index c4e96e1..b05eadc 100644 --- a/server/go.mod +++ b/server/go.mod @@ -1,6 +1,6 @@ module github.com/openteam/server -go 1.26.5 +go 1.25.0 require ( github.com/gin-gonic/gin v1.12.0 diff --git a/server/internal/api/admin.go b/server/internal/api/admin.go index 7cfb430..aaae553 100644 --- a/server/internal/api/admin.go +++ b/server/internal/api/admin.go @@ -2,7 +2,6 @@ package api import ( "encoding/json" - "fmt" "net/http" "strconv" "time" @@ -11,6 +10,7 @@ import ( "github.com/openteam/server/internal/pkg/resp" "github.com/openteam/server/internal/store" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // AdminUsers GET /api/v1/admin/users — 用户列表(搜索、分页)。 @@ -38,7 +38,7 @@ func (h *Handler) AdminUsers(c *gin.Context) { resp.OK(c, gin.H{"items": out, "total": total, "page": page, "page_size": size}) } -// AdminPatchUser PATCH /api/v1/admin/users/:id — 角色/状态/余额。 +// AdminPatchUser PATCH /api/v1/admin/users/:id — 角色/状态。 func (h *Handler) AdminPatchUser(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { @@ -53,11 +53,6 @@ func (h *Handler) AdminPatchUser(c *gin.Context) { resp.Fail(c, http.StatusBadRequest, "invalid input") return } - var u store.User - if err := h.a.DB.First(&u, id).Error; err != nil { - resp.Fail(c, http.StatusNotFound, "user not found") - return - } updates := map[string]any{} if req.Role != nil { if *req.Role != store.RoleUser && *req.Role != store.RoleAdmin { @@ -73,42 +68,57 @@ func (h *Handler) AdminPatchUser(c *gin.Context) { } updates["status"] = *req.Status } - if len(updates) > 0 { - h.a.DB.Model(&u).Updates(updates) + if len(updates) == 0 { + resp.OK(c, gin.H{"ok": true}) + return + } + res := h.a.DB.Model(&store.User{}).Where("id = ?", id).Updates(updates) + if res.Error != nil { + resp.Fail(c, http.StatusInternalServerError, "failed to update user") + return + } + if res.RowsAffected == 0 { + resp.Fail(c, http.StatusNotFound, "user not found") + return } resp.OK(c, gin.H{"ok": true}) } -// AdminAdjustBalance POST /api/v1/admin/users/:id/balance — 调整余额并写流水。 +// AdminAdjustBalance POST /api/v1/admin/users/:id/balance — 手动调余额(写流水)。 func (h *Handler) AdminAdjustBalance(c *gin.Context) { - admin, _ := userFromContext(c) + admin := sessionUser(c) id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { resp.Fail(c, http.StatusBadRequest, "invalid user id") return } var req struct { - Change float64 `json:"change" binding:"required"` + Amount float64 `json:"amount" binding:"required"` Remark string `json:"remark"` } if err := c.ShouldBindJSON(&req); err != nil { - resp.Fail(c, http.StatusBadRequest, "invalid input: change is required") + resp.Fail(c, http.StatusBadRequest, "invalid input: amount required") return } - var u store.User - if err := h.a.DB.First(&u, id).Error; err != nil { - resp.Fail(c, http.StatusNotFound, "user not found") + if req.Amount == 0 { + resp.Fail(c, http.StatusBadRequest, "amount must not be zero") return } - newBalance := u.Balance + req.Change - ref := fmt.Sprintf("admin:%d:%d", admin.ID, time.Now().UnixNano()) + err = h.a.DB.Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&store.User{}).Where("id = ?", u.ID).Update("balance", newBalance).Error; err != nil { + var u store.User + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&u, id).Error; err != nil { return err } + newBalance := u.Balance + req.Amount + if err := tx.Model(&store.User{}).Where("id = ?", id).Update("balance", newBalance).Error; err != nil { + return err + } + ref := "admin:" + strconv.FormatUint(u.ID, 10) + ":" + time.Now().Format("20060102150405") + _ = admin.ID // 流水里不冗余管理员 ID;需要时再加 return tx.Create(&store.BalanceLog{ UserID: u.ID, - Change: req.Change, + Change: req.Amount, BalanceAfter: newBalance, Type: store.BalanceTypeAdminAdjust, RefID: ref, @@ -116,45 +126,45 @@ func (h *Handler) AdminAdjustBalance(c *gin.Context) { }).Error }) if err != nil { - resp.Fail(c, http.StatusInternalServerError, "failed to adjust balance") + resp.Fail(c, http.StatusNotFound, "user not found or failed to adjust") return } - resp.OK(c, gin.H{"ok": true, "balance": newBalance}) + resp.OK(c, gin.H{"ok": true}) } -// AdminConfig GET /api/v1/admin/config +// AdminConfig GET /api/v1/admin/config — 全部系统配置。 func (h *Handler) AdminConfig(c *gin.Context) { var cfgs []store.SystemConfig - h.a.DB.Find(&cfgs) - m := map[string]any{} - for _, cfg := range cfgs { - var v any - _ = json.Unmarshal([]byte(cfg.Value), &v) - m[cfg.Key] = v + if err := h.a.DB.Find(&cfgs).Error; err != nil { + resp.Fail(c, http.StatusInternalServerError, "failed to load config") + return } - m["registration.mode"] = h.a.Cfg.Auth.RegistrationMode - resp.OK(c, gin.H{"config": m}) + out := gin.H{} + for _, cfg := range cfgs { + out[cfg.Key] = json.RawMessage(cfg.Value) + } + resp.OK(c, gin.H{"config": out}) } -// AdminPutConfig PUT /api/v1/admin/config +// AdminPutConfig PUT /api/v1/admin/config — 整表覆盖(upsert)。 func (h *Handler) AdminPutConfig(c *gin.Context) { - var req struct { - Config map[string]any `json:"config"` - } + var req map[string]json.RawMessage if err := c.ShouldBindJSON(&req); err != nil { resp.Fail(c, http.StatusBadRequest, "invalid input") return } - for k, v := range req.Config { - if k == "registration.mode" { - if v == "open" || v == "invite" { - h.a.Cfg.Auth.RegistrationMode = v.(string) + err := h.a.DB.Transaction(func(tx *gorm.DB) error { + for k, v := range req { + cfg := store.SystemConfig{Key: k, Value: string(v)} + if err := tx.Save(&cfg).Error; err != nil { + return err } - continue } - b, _ := json.Marshal(v) - cfg := store.SystemConfig{Key: k, Value: string(b)} - h.a.DB.Save(&cfg) + return nil + }) + if err != nil { + resp.Fail(c, http.StatusInternalServerError, "failed to save config") + return } resp.OK(c, gin.H{"ok": true}) } diff --git a/server/internal/api/admin_channels.go b/server/internal/api/admin_channels.go new file mode 100644 index 0000000..e96c9b6 --- /dev/null +++ b/server/internal/api/admin_channels.go @@ -0,0 +1,333 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/openteam/server/internal/pkg/resp" + "github.com/openteam/server/internal/store" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// AdminChannels GET /api/v1/admin/channels — 渠道列表(不返回加密 key,返回掩码)。 +func (h *Handler) AdminChannels(c *gin.Context) { + var chs []store.Channel + if err := h.a.DB.Order("id ASC").Find(&chs).Error; err != nil { + resp.Fail(c, http.StatusInternalServerError, "failed to load channels") + return + } + out := make([]gin.H, 0, len(chs)) + for _, ch := range chs { + masked := "" + if key, err := h.a.Enc.Decrypt(ch.APIKeyEnc); err == nil && len(key) > 8 { + masked = strings.Repeat("*", len(key)-4) + key[len(key)-4:] + } else if err == nil { + masked = "****" + } + out = append(out, gin.H{ + "id": ch.ID, "name": ch.Name, "provider": ch.Provider, "base_url": ch.BaseURL, + "api_key_masked": masked, "weight": ch.Weight, "priority": ch.Priority, + "timeout_ms": ch.TimeoutMS, "max_concurrency": ch.MaxConcurrency, + "health_status": ch.HealthStatus, "enabled": ch.Enabled, + "created_at": ch.CreatedAt, + }) + } + resp.OK(c, gin.H{"items": out}) +} + +type channelBody struct { + Name string `json:"name" binding:"required,min=1,max=64"` + Provider string `json:"provider" binding:"required"` + BaseURL string `json:"base_url" binding:"required"` + APIKey string `json:"api_key"` + Weight *int `json:"weight"` + Priority *int `json:"priority"` + TimeoutMS *int `json:"timeout_ms"` + MaxConcurrency *int `json:"max_concurrency"` + Enabled *bool `json:"enabled"` +} + +func validateProvider(p string) bool { + return p == store.ChannelProviderOpenAI || p == store.ChannelProviderAnthropic || p == store.ChannelProviderCompatible +} + +// AdminCreateChannel POST /api/v1/admin/channels +func (h *Handler) AdminCreateChannel(c *gin.Context) { + var req channelBody + if err := c.ShouldBindJSON(&req); err != nil { + resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error()) + return + } + if !validateProvider(req.Provider) { + resp.Fail(c, http.StatusBadRequest, "provider must be openai, anthropic or compatible") + return + } + if req.APIKey == "" { + resp.Fail(c, http.StatusBadRequest, "api_key required") + return + } + enc, err := h.a.Enc.Encrypt(req.APIKey) + if err != nil { + resp.Fail(c, http.StatusInternalServerError, "failed to encrypt api key") + return + } + ch := store.Channel{ + Name: req.Name, Provider: req.Provider, BaseURL: strings.TrimRight(req.BaseURL, "/"), + APIKeyEnc: enc, Weight: intOr(req.Weight, 1), Priority: intOr(req.Priority, 0), + TimeoutMS: intOr(req.TimeoutMS, 120000), MaxConcurrency: intOr(req.MaxConcurrency, 16), + HealthStatus: store.ChannelHealthHealthy, Enabled: boolOr(req.Enabled, true), + } + if err := h.a.DB.Create(&ch).Error; err != nil { + resp.Fail(c, http.StatusConflict, "failed to create channel (name may already exist)") + return + } + resp.Created(c, gin.H{"id": ch.ID, "name": ch.Name}) +} + +// AdminUpdateChannel PUT /api/v1/admin/channels/:id +func (h *Handler) AdminUpdateChannel(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + resp.Fail(c, http.StatusBadRequest, "invalid channel id") + return + } + var body struct { + Name *string `json:"name"` + Provider *string `json:"provider"` + BaseURL *string `json:"base_url"` + APIKey *string `json:"api_key"` + Weight *int `json:"weight"` + Priority *int `json:"priority"` + TimeoutMS *int `json:"timeout_ms"` + MaxConcurrency *int `json:"max_concurrency"` + HealthStatus *string `json:"health_status"` + Enabled *bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&body); err != nil { + resp.Fail(c, http.StatusBadRequest, "invalid input") + return + } + var ch store.Channel + if err := h.a.DB.First(&ch, id).Error; err != nil { + resp.Fail(c, http.StatusNotFound, "channel not found") + return + } + updates := map[string]any{} + if body.Name != nil { + updates["name"] = *body.Name + } + if body.Provider != nil { + if !validateProvider(*body.Provider) { + resp.Fail(c, http.StatusBadRequest, "provider must be openai, anthropic or compatible") + return + } + updates["provider"] = *body.Provider + } + if body.BaseURL != nil { + updates["base_url"] = strings.TrimRight(*body.BaseURL, "/") + } + if body.APIKey != nil && *body.APIKey != "" { + enc, err := h.a.Enc.Encrypt(*body.APIKey) + if err != nil { + resp.Fail(c, http.StatusInternalServerError, "failed to encrypt api key") + return + } + updates["api_key_enc"] = enc + } + if body.Weight != nil { + updates["weight"] = *body.Weight + } + if body.Priority != nil { + updates["priority"] = *body.Priority + } + if body.TimeoutMS != nil { + updates["timeout_ms"] = *body.TimeoutMS + } + if body.MaxConcurrency != nil { + updates["max_concurrency"] = *body.MaxConcurrency + } + if body.HealthStatus != nil { + updates["health_status"] = *body.HealthStatus + } + if body.Enabled != nil { + updates["enabled"] = *body.Enabled + } + if len(updates) > 0 { + if err := h.a.DB.Model(&ch).Updates(updates).Error; err != nil { + resp.Fail(c, http.StatusInternalServerError, "failed to update channel") + return + } + } + resp.OK(c, gin.H{"ok": true}) +} + +// AdminDeleteChannel DELETE /api/v1/admin/channels/:id +func (h *Handler) AdminDeleteChannel(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + resp.Fail(c, http.StatusBadRequest, "invalid channel id") + return + } + res := h.a.DB.Delete(&store.Channel{}, id) + if res.Error != nil { + resp.Fail(c, http.StatusInternalServerError, "failed to delete channel") + return + } + if res.RowsAffected == 0 { + resp.Fail(c, http.StatusNotFound, "channel not found") + return + } + // 清理模型绑定 + h.a.DB.Where("channel_id = ?", id).Delete(&store.ChannelModelBinding{}) + resp.OK(c, gin.H{"ok": true}) +} + +// AdminTestChannel POST /api/v1/admin/channels/:id/test — 请求渠道 /v1/models 测连通性。 +func (h *Handler) AdminTestChannel(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + resp.Fail(c, http.StatusBadRequest, "invalid channel id") + return + } + var ch store.Channel + if err := h.a.DB.First(&ch, id).Error; err != nil { + resp.Fail(c, http.StatusNotFound, "channel not found") + return + } + key, err := h.a.Enc.Decrypt(ch.APIKeyEnc) + if err != nil { + resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key") + return + } + url := strings.TrimRight(ch.BaseURL, "/") + "/v1/models" + client := &http.Client{Timeout: 10 * time.Second} + req, _ := http.NewRequest(http.MethodGet, url, nil) + req.Header.Set("Authorization", "Bearer "+key) + req.Header.Set("Accept", "application/json") + start := time.Now() + resp2, err := client.Do(req) + status := store.ChannelHealthHealthy + msg := "ok" + latency := 0 + if err != nil { + status = store.ChannelHealthCooldown + msg = err.Error() + } else { + latency = int(time.Since(start).Milliseconds()) + if resp2.StatusCode < 200 || resp2.StatusCode >= 300 { + status = store.ChannelHealthCooldown + b, _ := io.ReadAll(io.LimitReader(resp2.Body, 1024)) + msg = fmt.Sprintf("http %d: %s", resp2.StatusCode, strings.TrimSpace(string(b))) + } + resp2.Body.Close() + } + h.a.DB.Model(&store.Channel{}).Where("id = ?", ch.ID).Update("health_status", status) + if status != store.ChannelHealthHealthy { + resp.Fail(c, http.StatusBadGateway, msg) + return + } + resp.OK(c, gin.H{"ok": true, "latency_ms": latency, "message": msg}) +} + +// AdminImportChannelModels POST /api/v1/admin/channels/:id/models/import +// 拉取渠道 GET /v1/models,导入模型库并绑定。 +func (h *Handler) AdminImportChannelModels(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + resp.Fail(c, http.StatusBadRequest, "invalid channel id") + return + } + var ch store.Channel + if err := h.a.DB.First(&ch, id).Error; err != nil { + resp.Fail(c, http.StatusNotFound, "channel not found") + return + } + key, err := h.a.Enc.Decrypt(ch.APIKeyEnc) + if err != nil { + resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key") + return + } + url := strings.TrimRight(ch.BaseURL, "/") + "/v1/models" + client := &http.Client{Timeout: 15 * time.Second} + req, _ := http.NewRequest(http.MethodGet, url, nil) + req.Header.Set("Authorization", "Bearer "+key) + resp2, err := client.Do(req) + if err != nil { + resp.Fail(c, http.StatusBadGateway, "failed to reach channel: "+err.Error()) + return + } + defer resp2.Body.Close() + if resp2.StatusCode != http.StatusOK { + resp.Fail(c, http.StatusBadGateway, "channel returned http "+strconv.Itoa(resp2.StatusCode)) + return + } + var list struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.NewDecoder(resp2.Body).Decode(&list); err != nil { + resp.Fail(c, http.StatusBadGateway, "failed to parse model list") + return + } + if len(list.Data) == 0 { + resp.Fail(c, http.StatusNotFound, "channel returned no models") + return + } + + imported := 0 + err = h.a.DB.Transaction(func(tx *gorm.DB) error { + for _, item := range list.Data { + name := strings.TrimSpace(item.ID) + if name == "" { + continue + } + var m store.Model + if err := tx.Where("name = ?", name).FirstOrCreate(&m, store.Model{ + Name: name, DisplayName: name, Enabled: true, + }).Error; err != nil { + return err + } + // upsert 绑定(upstream_model 默认同名) + var binding store.ChannelModelBinding + err := tx.Where("channel_id = ? AND model_id = ?", ch.ID, m.ID).First(&binding).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + binding = store.ChannelModelBinding{ChannelID: ch.ID, ModelID: m.ID, UpstreamModel: name, Weight: 1} + if err := tx.Create(&binding).Error; err != nil { + return err + } + } + imported++ + } + return nil + }) + if err != nil { + resp.Fail(c, http.StatusInternalServerError, "failed to import models") + return + } + resp.OK(c, gin.H{"imported": imported}) +} + +var _ = clause.Assignments // 保留 gorm/clause 引用(后续定价批处理用) + +func intOr(p *int, def int) int { + if p == nil { + return def + } + return *p +} + +func boolOr(p *bool, def bool) bool { + if p == nil { + return def + } + return *p +} diff --git a/server/internal/api/admin_models.go b/server/internal/api/admin_models.go new file mode 100644 index 0000000..bcd619e --- /dev/null +++ b/server/internal/api/admin_models.go @@ -0,0 +1,199 @@ +package api + +import ( + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/openteam/server/internal/pkg/resp" + "github.com/openteam/server/internal/store" + "gorm.io/gorm" +) + +// AdminModels GET /api/v1/admin/models — 模型列表(含价格与渠道绑定)。 +func (h *Handler) AdminModels(c *gin.Context) { + var ms []store.Model + if err := h.a.DB.Order("sort ASC, id ASC").Find(&ms).Error; err != nil { + resp.Fail(c, http.StatusInternalServerError, "failed to load models") + return + } + out := make([]gin.H, 0, len(ms)) + for _, m := range ms { + var bindings []store.ChannelModelBinding + h.a.DB.Preload("Channel").Where("model_id = ?", m.ID).Find(&bindings) + chs := make([]gin.H, 0, len(bindings)) + for _, b := range bindings { + chs = append(chs, gin.H{ + "id": b.ID, "channel_id": b.ChannelID, "channel_name": b.Channel.Name, + "upstream_model": b.UpstreamModel, "weight": b.Weight, + }) + } + out = append(out, gin.H{ + "id": m.ID, "name": m.Name, "display_name": m.DisplayName, + "input_price": m.InputPrice, "output_price": m.OutputPrice, "cache_read_price": m.CacheReadPrice, + "enabled": m.Enabled, "sort": m.Sort, "channels": chs, + }) + } + resp.OK(c, gin.H{"items": out}) +} + +// AdminCreateModel POST /api/v1/admin/models +func (h *Handler) AdminCreateModel(c *gin.Context) { + var req struct { + Name string `json:"name" binding:"required,min=1,max=128"` + DisplayName string `json:"display_name"` + InputPrice float64 `json:"input_price"` + OutputPrice float64 `json:"output_price"` + CacheReadPrice float64 `json:"cache_read_price"` + Enabled *bool `json:"enabled"` + } + if err := c.ShouldBindJSON(&req); err != nil { + resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error()) + return + } + m := store.Model{ + Name: req.Name, DisplayName: req.DisplayName, + InputPrice: req.InputPrice, OutputPrice: req.OutputPrice, CacheReadPrice: req.CacheReadPrice, + Enabled: boolOr(req.Enabled, true), + } + if m.DisplayName == "" { + m.DisplayName = m.Name + } + if err := h.a.DB.Create(&m).Error; err != nil { + resp.Fail(c, http.StatusConflict, "failed to create model (name may already exist)") + return + } + resp.Created(c, gin.H{"id": m.ID, "name": m.Name}) +} + +// AdminUpdateModel PUT /api/v1/admin/models/:id — 价格/展示名/启停/排序。 +func (h *Handler) AdminUpdateModel(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + resp.Fail(c, http.StatusBadRequest, "invalid model id") + return + } + var req struct { + DisplayName *string `json:"display_name"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + Enabled *bool `json:"enabled"` + Sort *int `json:"sort"` + } + if err := c.ShouldBindJSON(&req); err != nil { + resp.Fail(c, http.StatusBadRequest, "invalid input") + return + } + var m store.Model + if err := h.a.DB.First(&m, id).Error; err != nil { + resp.Fail(c, http.StatusNotFound, "model not found") + return + } + updates := map[string]any{} + if req.DisplayName != nil { + updates["display_name"] = *req.DisplayName + } + if req.InputPrice != nil { + updates["input_price"] = *req.InputPrice + } + if req.OutputPrice != nil { + updates["output_price"] = *req.OutputPrice + } + if req.CacheReadPrice != nil { + updates["cache_read_price"] = *req.CacheReadPrice + } + if req.Enabled != nil { + updates["enabled"] = *req.Enabled + } + if req.Sort != nil { + updates["sort"] = *req.Sort + } + if len(updates) > 0 { + if err := h.a.DB.Model(&m).Updates(updates).Error; err != nil { + resp.Fail(c, http.StatusInternalServerError, "failed to update model") + return + } + } + resp.OK(c, gin.H{"ok": true}) +} + +// AdminDeleteModel DELETE /api/v1/admin/models/:id +func (h *Handler) AdminDeleteModel(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + resp.Fail(c, http.StatusBadRequest, "invalid model id") + return + } + res := h.a.DB.Delete(&store.Model{}, id) + if res.Error != nil { + resp.Fail(c, http.StatusInternalServerError, "failed to delete model") + return + } + if res.RowsAffected == 0 { + resp.Fail(c, http.StatusNotFound, "model not found") + return + } + h.a.DB.Where("model_id = ?", id).Delete(&store.ChannelModelBinding{}) + resp.OK(c, gin.H{"ok": true}) +} + +// AdminCreateModelBinding POST /api/v1/admin/models/:id/bindings +func (h *Handler) AdminCreateModelBinding(c *gin.Context) { + modelID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + resp.Fail(c, http.StatusBadRequest, "invalid model id") + return + } + var req struct { + ChannelID uint64 `json:"channel_id" binding:"required"` + UpstreamModel string `json:"upstream_model" binding:"required"` + Weight *int `json:"weight"` + } + if err := c.ShouldBindJSON(&req); err != nil { + resp.Fail(c, http.StatusBadRequest, "invalid input: channel_id and upstream_model required") + return + } + var m store.Model + if err := h.a.DB.First(&m, modelID).Error; err != nil { + resp.Fail(c, http.StatusNotFound, "model not found") + return + } + var ch store.Channel + if err := h.a.DB.First(&ch, req.ChannelID).Error; err != nil { + resp.Fail(c, http.StatusNotFound, "channel not found") + return + } + b := store.ChannelModelBinding{ + ChannelID: req.ChannelID, ModelID: modelID, + UpstreamModel: req.UpstreamModel, Weight: intOr(req.Weight, 1), + } + if err := h.a.DB.Create(&b).Error; err != nil { + resp.Fail(c, http.StatusConflict, "binding may already exist") + return + } + resp.Created(c, gin.H{"id": b.ID}) +} + +// AdminDeleteModelBinding DELETE /api/v1/admin/models/:id/bindings/:bid +func (h *Handler) AdminDeleteModelBinding(c *gin.Context) { + bid, err := strconv.ParseUint(c.Param("bid"), 10, 64) + if err != nil { + resp.Fail(c, http.StatusBadRequest, "invalid binding id") + return + } + res := h.a.DB.Delete(&store.ChannelModelBinding{}, bid) + if res.Error != nil { + resp.Fail(c, http.StatusInternalServerError, "failed to delete binding") + return + } + if res.RowsAffected == 0 { + resp.Fail(c, http.StatusNotFound, "binding not found") + return + } + resp.OK(c, gin.H{"ok": true}) +} + +var _ = errors.Is +var _ = gorm.ErrRecordNotFound diff --git a/server/internal/api/admin_stats.go b/server/internal/api/admin_stats.go new file mode 100644 index 0000000..f1d663a --- /dev/null +++ b/server/internal/api/admin_stats.go @@ -0,0 +1,102 @@ +package api + +import ( + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/openteam/server/internal/pkg/resp" + "github.com/openteam/server/internal/store" +) + +// AdminStatsOverview GET /api/v1/admin/stats/overview — 运营总览。 +func (h *Handler) AdminStatsOverview(c *gin.Context) { + now := time.Now().UTC() + today := now.Format("2006-01-02") + month := now.Format("2006-01") + + var totalUsers int64 + h.a.DB.Model(&store.User{}).Count(&totalUsers) + var totalKeys int64 + h.a.DB.Model(&store.APIKey{}).Count(&totalKeys) + var totalChannels int64 + h.a.DB.Model(&store.Channel{}).Count(&totalChannels) + var totalModels int64 + h.a.DB.Model(&store.Model{}).Count(&totalModels) + + // 全局今日/本月汇总(跨用户) + var todayReq, monthReq int64 + var todayCost, monthCost float64 + var todayTokens, monthTokens int64 + h.a.DB.Model(&store.UsageDaily{}).Where("date = ?", today). + Select("COALESCE(SUM(requests),0), COALESCE(SUM(cost),0), COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens),0)"). + Row().Scan(&todayReq, &todayCost, &todayTokens) + h.a.DB.Model(&store.UsageDaily{}).Where("date LIKE ?", month+"%"). + Select("COALESCE(SUM(requests),0), COALESCE(SUM(cost),0), COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens),0)"). + Row().Scan(&monthReq, &monthCost, &monthTokens) + + // 近 14 天趋势(日粒度) + var days []struct { + Date string + Req int64 + Cost float64 + } + h.a.DB.Model(&store.UsageDaily{}). + Select("date, SUM(requests) req, SUM(cost) cost"). + Where("date >= ?", now.AddDate(0, 0, -13).Format("2006-01-02")). + Group("date").Order("date").Scan(&days) + trend := make([]gin.H, 0, len(days)) + for _, d := range days { + trend = append(trend, gin.H{"date": d.Date, "requests": d.Req, "cost": d.Cost}) + } + + resp.OK(c, gin.H{ + "total_users": totalUsers, "total_keys": totalKeys, + "total_channels": totalChannels, "total_models": totalModels, + "today": gin.H{"requests": todayReq, "cost": todayCost, "tokens": todayTokens}, + "month": gin.H{"requests": monthReq, "cost": monthCost, "tokens": monthTokens}, + "trend_14d": trend, + }) +} + +// AdminUsage GET /api/v1/admin/usage — 全局用量日志(分页 + 过滤)。 +func (h *Handler) AdminUsage(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if size < 1 || size > 100 { + size = 20 + } + q := h.a.DB.Model(&store.UsageLog{}) + if from := c.Query("from"); from != "" { + q = q.Where("created_at >= ?", from+" 00:00:00") + } + if to := c.Query("to"); to != "" { + q = q.Where("created_at <= ?", to+" 23:59:59") + } + if model := c.Query("model"); model != "" { + q = q.Where("model_name = ?", model) + } + if user := c.Query("user_id"); user != "" { + q = q.Where("user_id = ?", user) + } + var total int64 + q.Count(&total) + var logs []store.UsageLog + q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&logs) + out := make([]gin.H, 0, len(logs)) + for _, l := range logs { + var uname string + h.a.DB.Model(&store.User{}).Where("id = ?", l.UserID).Pluck("username", &uname) + out = append(out, gin.H{ + "id": l.ID, "user": uname, "user_id": l.UserID, "model": l.ModelName, "protocol": l.Protocol, + "input_tokens": l.InputTokens, "output_tokens": l.OutputTokens, + "cache_read_tokens": l.CacheReadTokens, "cost": l.Cost, + "latency_ms": l.LatencyMS, "status": l.Status, "error_code": l.ErrorCode, + "created_at": l.CreatedAt, + }) + } + resp.OK(c, gin.H{"items": out, "total": total, "page": page, "page_size": size}) +} diff --git a/server/internal/api/auth.go b/server/internal/api/auth.go index af07717..817a052 100644 --- a/server/internal/api/auth.go +++ b/server/internal/api/auth.go @@ -2,15 +2,16 @@ package api import ( + "encoding/json" "net/http" "strings" "time" "github.com/gin-gonic/gin" "github.com/openteam/server/internal/app" + "github.com/openteam/server/internal/api/middleware" "github.com/openteam/server/internal/pkg/resp" "github.com/openteam/server/internal/store" - "gorm.io/gorm" ) // Handler 聚合所有管理 API。 @@ -37,10 +38,21 @@ func (h *Handler) Register(c *gin.Context) { resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error()) return } - if h.a.Cfg.Auth.RegistrationMode == "invite" { + // 注册模式优先读系统配置(管理后台可改),缺省用环境配置 + mode := h.a.Cfg.Auth.RegistrationMode + var modeCfgRaw string + h.a.DB.Model(&store.SystemConfig{}).Where("key = ?", "registration_mode").Pluck("value", &modeCfgRaw) + var modeCfg string + _ = json.Unmarshal([]byte(modeCfgRaw), &modeCfg) + if modeCfg == "open" || modeCfg == "invite" { + mode = modeCfg + } + if mode == "invite" { + var icRaw string + h.a.DB.Model(&store.SystemConfig{}).Where("key = ?", "invite_codes").Pluck("value", &icRaw) var ic string - h.a.DB.Model(&store.SystemConfig{}).Where("key = ?", "invite_codes").Pluck("value", &ic) - if !strings.Contains(ic, req.InviteCode) { + _ = json.Unmarshal([]byte(icRaw), &ic) + if req.InviteCode == "" || !strings.Contains(ic, req.InviteCode) { resp.Fail(c, http.StatusForbidden, "valid invite code required") return } @@ -182,16 +194,14 @@ func (h *Handler) publicUser(u *store.User) gin.H { } func sessionUser(c *gin.Context) *store.User { - u, _ := c.Get("session_user") + u, _ := c.Get(middleware.CtxSessionUser) return u.(*store.User) } func userFromContext(c *gin.Context) (*store.User, bool) { - u, ok := c.Get("session_user") + u, ok := c.Get(middleware.CtxSessionUser) if !ok { return nil, false } return u.(*store.User), true } - -var _ = gorm.ErrRecordNotFound diff --git a/server/internal/api/keys.go b/server/internal/api/keys.go index 8248c67..493a8f0 100644 --- a/server/internal/api/keys.go +++ b/server/internal/api/keys.go @@ -12,11 +12,11 @@ import ( ) type createKeyReq struct { - Name string `json:"name" binding:"required,min=1,max=64"` - QuotaTokensPerDay *int64 `json:"quota_tokens_per_day"` + Name *string `json:"name" binding:"required,min=1,max=64"` + QuotaTokensPerDay *int64 `json:"quota_tokens_per_day"` QuotaRequestsPerDay *int `json:"quota_requests_per_day"` - AllowedModels []string `json:"allowed_models"` - ExpiresAt *string `json:"expires_at"` // RFC3339 + AllowedModels []string `json:"allowed_models"` + ExpiresAt *string `json:"expires_at"` // RFC3339 } // CreateKey POST /api/v1/keys — 创建密钥,明文仅此一次返回。 @@ -37,14 +37,14 @@ func (h *Handler) CreateKey(c *gin.Context) { return } k := store.APIKey{ - UserID: u.ID, - Name: req.Name, - KeyHash: hash, - KeyPrefix: prefix, - QuotaTokensPerDay: req.QuotaTokensPerDay, + UserID: u.ID, + Name: *req.Name, + KeyHash: hash, + KeyPrefix: prefix, + QuotaTokensPerDay: req.QuotaTokensPerDay, QuotaRequestsPerDay: req.QuotaRequestsPerDay, - AllowedModels: req.AllowedModels, - Status: store.KeyStatusActive, + AllowedModels: req.AllowedModels, + Status: store.KeyStatusActive, } if req.ExpiresAt != nil { t, err := time.Parse(time.RFC3339, *req.ExpiresAt) @@ -110,11 +110,11 @@ func (h *Handler) PatchKey(c *gin.Context) { return } var req struct { - Name *string `json:"name"` - QuotaTokensPerDay *int64 `json:"quota_tokens_per_day"` - QuotaRequestsPerDay *int `json:"quota_requests_per_day"` - AllowedModels *[]string `json:"allowed_models"` - Status *string `json:"status"` + Name *string `json:"name"` + QuotaTokensPerDay *int64 `json:"quota_tokens_per_day"` + QuotaRequestsPerDay *int `json:"quota_requests_per_day"` + AllowedModels *[]string `json:"allowed_models"` + Status *string `json:"status"` } if err := c.ShouldBindJSON(&req); err != nil { resp.Fail(c, http.StatusBadRequest, "invalid input") diff --git a/server/internal/api/router.go b/server/internal/api/router.go index 0b9cfc9..1fdbe9c 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -26,6 +26,7 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine { { proxyGroup.Any("/chat/completions", gw.Auth, gw.Handle) proxyGroup.Any("/responses", gw.Auth, gw.Handle) + proxyGroup.Any("/messages", gw.Auth, gw.Handle) proxyGroup.Any("/models", gw.Auth, gw.Handle) } // 未匹配的 /v1/* 返回 OpenAI 风格 404(需先认证) @@ -68,12 +69,31 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine { admin := api.Group("/admin", middleware.SessionAuth(a), middleware.AdminOnly) { + // 用户 admin.GET("/users", h.AdminUsers) admin.PATCH("/users/:id", h.AdminPatchUser) admin.POST("/users/:id/balance", h.AdminAdjustBalance) + // 渠道 + admin.GET("/channels", h.AdminChannels) + admin.POST("/channels", h.AdminCreateChannel) + admin.PUT("/channels/:id", h.AdminUpdateChannel) + admin.DELETE("/channels/:id", h.AdminDeleteChannel) + admin.POST("/channels/:id/test", h.AdminTestChannel) + admin.POST("/channels/:id/models/import", h.AdminImportChannelModels) + // 模型与定价 + admin.GET("/models", h.AdminModels) + admin.POST("/models", h.AdminCreateModel) + admin.PUT("/models/:id", h.AdminUpdateModel) + admin.DELETE("/models/:id", h.AdminDeleteModel) + admin.POST("/models/:id/bindings", h.AdminCreateModelBinding) + admin.DELETE("/models/:id/bindings/:bid", h.AdminDeleteModelBinding) + // 统计与用量 + admin.GET("/stats/overview", h.AdminStatsOverview) + admin.GET("/usage", h.AdminUsage) + // 配置 admin.GET("/config", h.AdminConfig) admin.PUT("/config", h.AdminPutConfig) - // 渠道/模型/用量管理(M4);充值审核(M5 预留) + // 充值审核(M6 预留) } } diff --git a/server/internal/api/usage.go b/server/internal/api/usage.go new file mode 100644 index 0000000..c29567d --- /dev/null +++ b/server/internal/api/usage.go @@ -0,0 +1,112 @@ +package api + +import ( + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/openteam/server/internal/pkg/resp" + "github.com/openteam/server/internal/store" +) + +// UsageSummary GET /api/v1/usage/summary — 今日/本月汇总。 +func (h *Handler) UsageSummary(c *gin.Context) { + u, _ := userFromContext(c) + now := time.Now().UTC() + today := now.Format("2006-01-02") + month := now.Format("2006-01") + + var todayReq, monthReq int64 + var todayTok, monthTok int64 + var todayCost, monthCost float64 + h.a.DB.Model(&store.UsageDaily{}).Where("user_id = ? AND date = ?", u.ID, today). + Select("COALESCE(SUM(requests),0), COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens),0), COALESCE(SUM(cost),0)"). + Row().Scan(&todayReq, &todayTok, &todayCost) + h.a.DB.Model(&store.UsageDaily{}).Where("user_id = ? AND date LIKE ?", u.ID, month+"%"). + Select("COALESCE(SUM(requests),0), COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens),0), COALESCE(SUM(cost),0)"). + Row().Scan(&monthReq, &monthTok, &monthCost) + resp.OK(c, gin.H{ + "today": gin.H{"requests": todayReq, "tokens": todayTok, "cost": todayCost}, + "month": gin.H{"requests": monthReq, "tokens": monthTok, "cost": monthCost}, + }) +} + +// UsageStats GET /api/v1/usage/stats?from&to&group=day|model +func (h *Handler) UsageStats(c *gin.Context) { + u, _ := userFromContext(c) + from := c.DefaultQuery("from", time.Now().Add(-30*24*time.Hour).Format("2006-01-02")) + to := c.DefaultQuery("to", time.Now().Format("2006-01-02")) + group := c.DefaultQuery("group", "day") + + q := h.a.DB.Model(&store.UsageDaily{}).Where("user_id = ? AND date BETWEEN ? AND ?", u.ID, from, to) + out := make([]gin.H, 0, 64) + if group == "model" { + var rows []struct { + ModelID uint64 + Requests int64 + Tokens int64 + Cost float64 + } + q.Select("model_id, SUM(requests) requests, SUM(input_tokens+output_tokens+cache_read_tokens) tokens, SUM(cost) cost"). + Group("model_id").Scan(&rows) + for _, r := range rows { + var m store.Model + name := strconv.FormatUint(r.ModelID, 10) + if h.a.DB.First(&m, r.ModelID).Error == nil { + name = m.Name + } + out = append(out, gin.H{"model": name, "requests": r.Requests, "tokens": r.Tokens, "cost": r.Cost}) + } + } else { + var rows []struct { + Date string + Requests int64 + Tokens int64 + Cost float64 + } + q.Select("date, SUM(requests) requests, SUM(input_tokens+output_tokens+cache_read_tokens) tokens, SUM(cost) cost"). + Group("date").Order("date").Scan(&rows) + for _, r := range rows { + out = append(out, gin.H{"date": r.Date, "requests": r.Requests, "tokens": r.Tokens, "cost": r.Cost}) + } + } + resp.OK(c, gin.H{"items": out}) +} + +// UsageLogs GET /api/v1/usage/logs?from&to&page&page_size&model +func (h *Handler) UsageLogs(c *gin.Context) { + u, _ := userFromContext(c) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if size < 1 || size > 100 { + size = 20 + } + q := h.a.DB.Model(&store.UsageLog{}).Where("user_id = ?", u.ID) + if from := c.Query("from"); from != "" { + q = q.Where("created_at >= ?", from+" 00:00:00") + } + if to := c.Query("to"); to != "" { + q = q.Where("created_at <= ?", to+" 23:59:59") + } + if model := c.Query("model"); model != "" { + q = q.Where("model_name = ?", model) + } + var total int64 + q.Count(&total) + var logs []store.UsageLog + q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&logs) + out := make([]gin.H, 0, len(logs)) + for _, l := range logs { + out = append(out, gin.H{ + "id": l.ID, "request_id": l.RequestID, "model": l.ModelName, "protocol": l.Protocol, + "input_tokens": l.InputTokens, "output_tokens": l.OutputTokens, + "cache_read_tokens": l.CacheReadTokens, "cost": l.Cost, + "latency_ms": l.LatencyMS, "status": l.Status, "error_code": l.ErrorCode, + "created_at": l.CreatedAt, + }) + } + resp.OK(c, gin.H{"items": out, "total": total, "page": page, "page_size": size}) +} diff --git a/server/internal/api/user.go b/server/internal/api/user.go index 36240d7..bb8f115 100644 --- a/server/internal/api/user.go +++ b/server/internal/api/user.go @@ -2,7 +2,6 @@ package api import ( "net/http" - "strconv" "time" "github.com/gin-gonic/gin" @@ -24,10 +23,10 @@ func (h *Handler) UserBalance(c *gin.Context) { Where("user_id = ? AND status = ? AND created_at >= ?", u.ID, store.UsageStatusSuccess, time.Now().Add(-30*24*time.Hour)). Select("COALESCE(SUM(cost),0)").Scan(&spent) resp.OK(c, gin.H{ - "balance": u.Balance, - "spent_last_30d": spent, - "today": h.todayUsage(c, u.ID), - "models_available": h.availableModelCount(), + "balance": u.Balance, + "spent_last_30d": spent, + "today": h.todayUsage(c, u.ID), + "models_available": h.availableModelCount(), }) } @@ -62,104 +61,3 @@ func (h *Handler) UserModels(c *gin.Context) { } resp.OK(c, gin.H{"items": out}) } - -// UsageSummary GET /api/v1/usage/summary — 今日/本月汇总。 -func (h *Handler) UsageSummary(c *gin.Context) { - u, _ := userFromContext(c) - now := time.Now().UTC() - today := now.Format("2006-01-02") - month := now.Format("2006-01") - var todayReq, monthReq int64 - var todayTok, monthTok int64 - var todayCost, monthCost float64 - h.a.DB.Model(&store.UsageDaily{}).Where("user_id = ? AND date = ?", u.ID, today). - Select("COALESCE(SUM(requests),0), COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens),0), COALESCE(SUM(cost),0)"). - Row().Scan(&todayReq, &todayTok, &todayCost) - h.a.DB.Model(&store.UsageDaily{}).Where("user_id = ? AND date LIKE ?", u.ID, month+"%"). - Select("COALESCE(SUM(requests),0), COALESCE(SUM(input_tokens+output_tokens+cache_read_tokens),0), COALESCE(SUM(cost),0)"). - Row().Scan(&monthReq, &monthTok, &monthCost) - resp.OK(c, gin.H{ - "today": gin.H{"requests": todayReq, "tokens": todayTok, "cost": todayCost}, - "month": gin.H{"requests": monthReq, "tokens": monthTok, "cost": monthCost}, - }) -} - -// UsageStats GET /api/v1/usage/stats?from&to&group=day|model -func (h *Handler) UsageStats(c *gin.Context) { - u, _ := userFromContext(c) - from := c.DefaultQuery("from", time.Now().Add(-30*24*time.Hour).Format("2006-01-02")) - to := c.DefaultQuery("to", time.Now().Format("2006-01-02")) - group := c.DefaultQuery("group", "day") - - q := h.a.DB.Model(&store.UsageDaily{}).Where("user_id = ? AND date BETWEEN ? AND ?", u.ID, from, to) - out := make([]gin.H, 0, 64) - if group == "model" { - var rows []struct { - ModelID uint64 - Requests int64 - Tokens int64 - Cost float64 - } - q.Select("model_id, SUM(requests) requests, SUM(input_tokens+output_tokens+cache_read_tokens) tokens, SUM(cost) cost"). - Group("model_id").Scan(&rows) - for _, r := range rows { - var m store.Model - name := strconv.FormatUint(r.ModelID, 10) - if h.a.DB.First(&m, r.ModelID).Error == nil { - name = m.Name - } - out = append(out, gin.H{"model": name, "requests": r.Requests, "tokens": r.Tokens, "cost": r.Cost}) - } - } else { - var rows []struct { - Date string - Requests int64 - Tokens int64 - Cost float64 - } - q.Select("date, SUM(requests) requests, SUM(input_tokens+output_tokens+cache_read_tokens) tokens, SUM(cost) cost"). - Group("date").Order("date").Scan(&rows) - for _, r := range rows { - out = append(out, gin.H{"date": r.Date, "requests": r.Requests, "tokens": r.Tokens, "cost": r.Cost}) - } - } - resp.OK(c, gin.H{"items": out}) -} - -// UsageLogs GET /api/v1/usage/logs?from&to&page&page_size&model -func (h *Handler) UsageLogs(c *gin.Context) { - u, _ := userFromContext(c) - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) - if page < 1 { - page = 1 - } - if size < 1 || size > 100 { - size = 20 - } - q := h.a.DB.Model(&store.UsageLog{}).Where("user_id = ?", u.ID) - if from := c.Query("from"); from != "" { - q = q.Where("created_at >= ?", from+" 00:00:00") - } - if to := c.Query("to"); to != "" { - q = q.Where("created_at <= ?", to+" 23:59:59") - } - if model := c.Query("model"); model != "" { - q = q.Where("model_name = ?", model) - } - var total int64 - q.Count(&total) - var logs []store.UsageLog - q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&logs) - out := make([]gin.H, 0, len(logs)) - for _, l := range logs { - out = append(out, gin.H{ - "id": l.ID, "request_id": l.RequestID, "model": l.ModelName, "protocol": l.Protocol, - "input_tokens": l.InputTokens, "output_tokens": l.OutputTokens, - "cache_read_tokens": l.CacheReadTokens, "cost": l.Cost, - "latency_ms": l.LatencyMS, "status": l.Status, "error_code": l.ErrorCode, - "created_at": l.CreatedAt, - }) - } - resp.OK(c, gin.H{"items": out, "total": total, "page": page, "page_size": size}) -} diff --git a/server/internal/app/app.go b/server/internal/app/app.go index e4da92b..edf5033 100644 --- a/server/internal/app/app.go +++ b/server/internal/app/app.go @@ -102,7 +102,7 @@ func (a *App) Seed() error { m := store.Model{ Name: a.Cfg.Proxy.DefaultModel, DisplayName: a.Cfg.Proxy.DefaultModel, - InputPrice: 0.15, // 每百万 token,示例价 + InputPrice: 0.15, // 每百万 token,示例价 OutputPrice: 0.60, Enabled: true, } diff --git a/server/internal/channel/channel.go b/server/internal/channel/channel.go index 308e724..bc19e3b 100644 --- a/server/internal/channel/channel.go +++ b/server/internal/channel/channel.go @@ -1,6 +1,6 @@ -// Package channel 渠道仓储:选择、加解密、健康过滤。 -// M1 阶段实现最小选择逻辑(按优先级+权重取第一个健康启用的渠道), -// 负载均衡/健康检查/故障转移在 M4 完善。 +// Package channel 渠道仓储:选择、密钥加解密、模型解析。 +// M1 实现最小选择逻辑(优先级+权重取第一个健康启用的渠道); +// 负载均衡/健康检查/故障转移在 M5 完善。 package channel import ( @@ -48,21 +48,15 @@ func (s *Service) ResolveModel(modelName string) (*store.Channel, *store.Channel } var b store.ChannelModelBinding if err := s.db.Where("model_id = ?", m.ID). - Joins("JOIN channels ON channels.id = channel_model_bindings.channel_id AND channels.enabled = ? AND channels.health_status = ?", true, store.ChannelHealthHealthy). - Order("channel_model_bindings.weight DESC"). - First(&b).Error; err != nil { + Order("weight DESC, id ASC").First(&b).Error; err != nil { return nil, nil, err } - ch, err := s.Select() - if err != nil { + var ch store.Channel + if err := s.db.First(&ch, b.ChannelID).Error; err != nil { return nil, nil, err } - // 用绑定里的渠道(如果健康),否则回退默认渠道 - if b.ChannelID != ch.ID { - var bound store.Channel - if err := s.db.First(&bound, b.ChannelID).Error; err == nil && bound.Enabled && bound.HealthStatus == store.ChannelHealthHealthy { - return &bound, &b, nil - } + if !ch.Enabled || ch.HealthStatus != store.ChannelHealthHealthy { + return nil, nil, ErrNoChannel } - return ch, &b, nil + return &ch, &b, nil } diff --git a/server/internal/config/config.go b/server/internal/config/config.go index f6e9a3f..d5c2f48 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -1,4 +1,5 @@ // Package config 加载服务配置:.env / 环境变量 / 默认值(viper)。 +// 所有项均可用环境变量 OT_ 覆盖(点号转下划线,如 db.driver → OT_DB_DRIVER)。 package config import ( @@ -25,13 +26,13 @@ type DBConfig struct { } type JWTConfig struct { - Secret string - AccessTTL time.Duration - RefreshTTL time.Duration - Issuer string - CookieName string - CookieSecure bool - CookieDomain string + Secret string + AccessTTL time.Duration + RefreshTTL time.Duration + Issuer string + CookieName string + CookieSecure bool + CookieDomain string } type AuthConfig struct { @@ -51,7 +52,8 @@ type ProxyConfig struct { Timeout time.Duration } -// loadDotEnv 读取 .env 文件并把 KEY=VALUE 注入环境变量(AutomaticEnv 会自动映射 OT_ 前缀)。 +// loadDotEnv 读取 .env 并把 KEY=VALUE 注入环境变量(AutomaticEnv 自动映射 OT_ 前缀)。 +// 已存在的环境变量优先,不覆盖。 func loadDotEnv() { data, err := os.ReadFile(".env") if err != nil { @@ -110,10 +112,6 @@ func Load() (*Config, error) { v.SetDefault("proxy.default_model", "gpt-4o-mini") v.SetDefault("proxy.timeout", "120s") - // 支持读取 .env 文件(可选,不强制) - v.SetConfigFile(".env") - _ = v.ReadInConfig() - return &Config{ Env: v.GetString("env"), Port: v.GetInt("port"), diff --git a/server/internal/pkg/apikey/apikey.go b/server/internal/pkg/apikey/apikey.go index c98cb03..1420d62 100644 --- a/server/internal/pkg/apikey/apikey.go +++ b/server/internal/pkg/apikey/apikey.go @@ -5,7 +5,6 @@ package apikey import ( "crypto/rand" "crypto/sha256" - "encoding/base64" "encoding/hex" "strings" ) @@ -47,6 +46,3 @@ func Prefix(key string) string { func Valid(key string) bool { return strings.HasPrefix(key, prefix) && len(key) == len(prefix)+keyLen } - -// base64 占位,避免未使用导入告警 -var _ = base64.StdEncoding diff --git a/server/internal/pkg/apikey/apikey_test.go b/server/internal/pkg/apikey/apikey_test.go index 57a2d55..982d9e3 100644 --- a/server/internal/pkg/apikey/apikey_test.go +++ b/server/internal/pkg/apikey/apikey_test.go @@ -2,32 +2,36 @@ package apikey import "testing" -func TestGenerate(t *testing.T) { +func TestGenerateValid(t *testing.T) { plain, hash, prefix, err := Generate() if err != nil { - t.Fatal(err) + t.Fatalf("Generate: %v", err) } if !Valid(plain) { t.Fatalf("generated key invalid: %q", plain) } - if len(plain) != 3+48 { - t.Fatalf("key length = %d, want 51", len(plain)) + if len(plain) != len("sk-")+48 { + t.Fatalf("unexpected key length: %d", len(plain)) } if Hash(plain) != hash { t.Fatal("hash mismatch") } - if len(prefix) > len(plain) || prefix != plain[:len(prefix)] { - t.Fatal("prefix must be prefix of plain key") - } - // 两次生成不重复 - plain2, _, _, _ := Generate() - if plain == plain2 { - t.Fatal("keys should be unique") + if prefix != plain[:12] { + t.Fatalf("prefix mismatch: %s vs %s", prefix, plain[:12]) } } -func TestValid(t *testing.T) { - if Valid("") || Valid("sk-short") || Valid("xxx") { - t.Fatal("invalid keys should be rejected") +func TestHashStable(t *testing.T) { + if Hash("sk-test") != Hash("sk-test") { + t.Fatal("hash not stable") + } +} + +func TestValidRejects(t *testing.T) { + cases := []string{"", "sk-abc", "abc-123456789012345678901234567890123456789012345678", "sk-1234567890123456789012345678901234567890123456789"} + for _, c := range cases { + if Valid(c) { + t.Fatalf("expected invalid: %q", c) + } } } diff --git a/server/internal/pkg/crypto/crypto.go b/server/internal/pkg/crypto/crypto.go index 21b9af5..e1b74e0 100644 --- a/server/internal/pkg/crypto/crypto.go +++ b/server/internal/pkg/crypto/crypto.go @@ -14,6 +14,7 @@ import ( "golang.org/x/crypto/argon2" ) +// PasswordHasher argon2id 参数(来自配置)。 type PasswordHasher struct { Time uint32 Memory uint32 @@ -26,7 +27,7 @@ func NewPasswordHasher(time, memory uint32, threads uint8, keyLen uint32, saltLe return &PasswordHasher{Time: time, Memory: memory, Threads: threads, KeyLen: keyLen, SaltLen: saltLen} } -// HashPassword argon2id 编码为 $argon2id$v=19$m=...,t=...,p=...$salt$hash +// HashPassword 编码为 $argon2id$v=19$m=...,t=...,p=...$salt$hash func (h *PasswordHasher) HashPassword(password string) (string, error) { salt := make([]byte, h.SaltLen) if _, err := rand.Read(salt); err != nil { @@ -38,14 +39,13 @@ func (h *PasswordHasher) HashPassword(password string) (string, error) { h.Memory, h.Time, h.Threads, enc.EncodeToString(salt), enc.EncodeToString(key)), nil } -// VerifyPassword 校验密码,返回是否匹配(常数时间比较)。 +// VerifyPassword 校验密码,常数时间比较。 func (h *PasswordHasher) VerifyPassword(encoded, password string) (bool, error) { parts := strings.Split(encoded, "$") if len(parts) != 6 || parts[1] != "argon2id" { return false, errors.New("invalid hash format") } - var memory uint32 - var time uint32 + var memory, time uint32 var threads uint8 if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { return false, err @@ -66,6 +66,7 @@ func (h *PasswordHasher) VerifyPassword(encoded, password string) (bool, error) // --------------------------------------------------------------------------- // AES-GCM 渠道密钥加密 +// Encryptor 用主密钥加解密渠道上游 key。 type Encryptor struct { key []byte } @@ -76,8 +77,7 @@ func NewEncryptor(master string) *Encryptor { switch len(key) { case 16, 24, 32: default: - sum := sha256Sum(master) - key = sum + key = sha256Sum(master) } return &Encryptor{key: key} } @@ -100,6 +100,7 @@ func (e *Encryptor) Encrypt(plain string) (string, error) { return base64.StdEncoding.EncodeToString(append(nonce, ct...)), nil } +// Decrypt 解析 Encrypt 的输出。 func (e *Encryptor) Decrypt(enc string) (string, error) { raw, err := base64.StdEncoding.DecodeString(enc) if err != nil { diff --git a/server/internal/pkg/crypto/crypto_test.go b/server/internal/pkg/crypto/crypto_test.go index a4399cf..9b9443c 100644 --- a/server/internal/pkg/crypto/crypto_test.go +++ b/server/internal/pkg/crypto/crypto_test.go @@ -3,42 +3,43 @@ package crypto import "testing" func TestPasswordHashRoundTrip(t *testing.T) { - h := NewPasswordHasher(3, 64*1024, 2, 32, 16) + h := NewPasswordHasher(1, 64*1024, 1, 32, 16) hash, err := h.HashPassword("s3cret-password") if err != nil { - t.Fatalf("hash: %v", err) + t.Fatalf("HashPassword: %v", err) } ok, err := h.VerifyPassword(hash, "s3cret-password") if err != nil || !ok { - t.Fatalf("verify correct password: ok=%v err=%v", ok, err) + t.Fatalf("VerifyPassword correct: ok=%v err=%v", ok, err) } ok, _ = h.VerifyPassword(hash, "wrong-password") if ok { - t.Fatal("wrong password should not verify") + t.Fatal("VerifyPassword accepted wrong password") } } -func TestEncryptDecrypt(t *testing.T) { - e := NewEncryptor("master-key-0123456789abcdef") +func TestEncryptorRoundTrip(t *testing.T) { + e := NewEncryptor("a-very-long-master-key-1234567890") enc, err := e.Encrypt("sk-upstream-secret") if err != nil { - t.Fatalf("encrypt: %v", err) + t.Fatalf("Encrypt: %v", err) } - dec, err := e.Decrypt(enc) - if err != nil || dec != "sk-upstream-secret" { - t.Fatalf("decrypt: got %q err %v", dec, err) + if enc == "sk-upstream-secret" { + t.Fatal("ciphertext equals plaintext") } - // 密文不可读 - if dec == enc { - t.Fatal("ciphertext should differ from plaintext") + plain, err := e.Decrypt(enc) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + if plain != "sk-upstream-secret" { + t.Fatalf("round trip mismatch: %q", plain) } } -func TestShortMasterKeyDerived(t *testing.T) { +func TestEncryptorShortKeyDerived(t *testing.T) { + // 短主密钥应派生 32 字节而非报错 e := NewEncryptor("short") - enc, _ := e.Encrypt("x") - dec, err := e.Decrypt(enc) - if err != nil || dec != "x" { - t.Fatalf("short key derive failed: %v", err) + if _, err := e.Encrypt("x"); err != nil { + t.Fatalf("Encrypt with short key: %v", err) } } diff --git a/server/internal/pkg/jwt/jwt.go b/server/internal/pkg/jwt/jwt.go index 66f68fb..28b7e11 100644 --- a/server/internal/pkg/jwt/jwt.go +++ b/server/internal/pkg/jwt/jwt.go @@ -8,6 +8,7 @@ import ( "github.com/golang-jwt/jwt/v5" ) +// Claims 用户声明;Subject 字段区分 "access" / "refresh"。 type Claims struct { UserID uint64 `json:"uid"` Username string `json:"uname"` @@ -16,9 +17,9 @@ type Claims struct { } type Manager struct { - secret []byte - issuer string - accessTTL time.Duration + secret []byte + issuer string + accessTTL time.Duration refreshTTL time.Duration } diff --git a/server/internal/pkg/jwt/jwt_test.go b/server/internal/pkg/jwt/jwt_test.go index 39ec88f..52fc53e 100644 --- a/server/internal/pkg/jwt/jwt_test.go +++ b/server/internal/pkg/jwt/jwt_test.go @@ -5,37 +5,41 @@ import ( "time" ) -func TestSignParse(t *testing.T) { +func TestSignParseAccess(t *testing.T) { m := NewManager("test-secret", "openteam", time.Hour, 24*time.Hour) tok, exp, err := m.Sign(42, "alice", "admin", "access") if err != nil { - t.Fatal(err) + t.Fatalf("Sign: %v", err) } - if time.Until(exp) < 50*time.Minute { - t.Fatal("expiry too short") + if exp.Before(time.Now()) { + t.Fatal("expires in the past") } claims, err := m.Parse(tok) if err != nil { - t.Fatal(err) + t.Fatalf("Parse: %v", err) } - if claims.UserID != 42 || claims.Username != "alice" || claims.Role != "admin" || claims.Subject != "access" { + if claims.UserID != 42 || claims.Username != "alice" || claims.Role != "admin" { t.Fatalf("claims mismatch: %+v", claims) } + if claims.Subject != "access" { + t.Fatalf("subject mismatch: %s", claims.Subject) + } +} + +func TestParseRejectsBadToken(t *testing.T) { + m := NewManager("test-secret", "openteam", time.Hour, 24*time.Hour) + if _, err := m.Parse("not-a-jwt"); err == nil { + t.Fatal("expected error for invalid token") + } } func TestExpiredToken(t *testing.T) { - m := NewManager("test-secret", "openteam", -time.Minute, time.Hour) - tok, _, _ := m.Sign(1, "a", "user", "access") + m := NewManager("test-secret", "openteam", -time.Hour, -time.Hour) + tok, _, err := m.Sign(1, "bob", "user", "access") + if err != nil { + t.Fatalf("Sign: %v", err) + } if _, err := m.Parse(tok); err == nil { - t.Fatal("expired token should fail") - } -} - -func TestWrongSecret(t *testing.T) { - m1 := NewManager("secret-a", "openteam", time.Hour, time.Hour) - m2 := NewManager("secret-b", "openteam", time.Hour, time.Hour) - tok, _, _ := m1.Sign(1, "a", "user", "access") - if _, err := m2.Parse(tok); err == nil { - t.Fatal("token signed with different secret should fail") + t.Fatal("expected error for expired token") } } diff --git a/server/internal/pkg/resp/resp.go b/server/internal/pkg/resp/resp.go index 5e9e967..88af5f5 100644 --- a/server/internal/pkg/resp/resp.go +++ b/server/internal/pkg/resp/resp.go @@ -32,8 +32,3 @@ func Created(c *gin.Context, data any) { func Fail(c *gin.Context, status int, message string) { c.JSON(status, Body{Error: &Error{Message: message}}) } - -// FailCode 带错误码的业务错误 -func FailCode(c *gin.Context, status int, code, message string) { - c.JSON(status, Body{Error: &Error{Message: message, Type: code}}) -} diff --git a/server/internal/proxy/convert/convert.go b/server/internal/proxy/convert/convert.go new file mode 100644 index 0000000..26903ab --- /dev/null +++ b/server/internal/proxy/convert/convert.go @@ -0,0 +1,157 @@ +// Package convert 三协议互转:OpenAI Chat / OpenAI Responses / Anthropic Messages。 +// 网关以 OpenAI Chat 形状作为标准中间模型(PLANNING §5.1.1)。 +// 请求与响应(非流式)走 JSON 转换;流式走逐行 SSE 转换(见 stream.go)。 +package convert + +import ( + "encoding/json" + "fmt" +) + +// 协议标识。 +const ( + ProtoChat = "chat" + ProtoMessages = "messages" + ProtoResponses = "responses" +) + +// ConvertRequest 转换请求体。from==to 时原样返回。 +func ConvertRequest(body []byte, from, to string) ([]byte, error) { + if from == to { + return body, nil + } + switch { + case from == ProtoMessages && to == ProtoChat: + return messagesToChatReq(body) + case from == ProtoChat && to == ProtoMessages: + return chatToMessagesReq(body) + case from == ProtoResponses && to == ProtoChat: + return responsesToChatReq(body) + case from == ProtoChat && to == ProtoResponses: + return chatToResponsesReq(body) + case from == ProtoResponses && to == ProtoMessages: + mid, err := responsesToChatReq(body) + if err != nil { + return nil, err + } + return chatToMessagesReq(mid) + case from == ProtoMessages && to == ProtoResponses: + mid, err := messagesToChatReq(body) + if err != nil { + return nil, err + } + return chatToResponsesReq(mid) + } + return nil, fmt.Errorf("unsupported request conversion %s->%s", from, to) +} + +// ConvertResponse 转换响应体(非流式)。from==to 时原样返回。 +func ConvertResponse(body []byte, from, to string) ([]byte, error) { + if from == to { + return body, nil + } + switch { + case from == ProtoMessages && to == ProtoChat: + return messagesToChatResp(body) + case from == ProtoChat && to == ProtoMessages: + return chatToMessagesResp(body) + case from == ProtoResponses && to == ProtoChat: + return responsesToChatResp(body) + case from == ProtoChat && to == ProtoResponses: + return chatToResponsesResp(body) + case from == ProtoResponses && to == ProtoMessages: + mid, err := responsesToChatResp(body) + if err != nil { + return nil, err + } + return chatToMessagesResp(mid) + case from == ProtoMessages && to == ProtoResponses: + mid, err := messagesToChatResp(body) + if err != nil { + return nil, err + } + return chatToResponsesResp(mid) + } + return nil, fmt.Errorf("unsupported response conversion %s->%s", from, to) +} + +// NewStreamTransformer 构造流式逐行转换器:输入上游 SSE 一行,返回客户端 SSE 行。 +// 返回 nil 表示丢弃该行;(from==to 时无需转换)。 +func NewStreamTransformer(from, to string) func([]byte) []byte { + switch { + case from == ProtoMessages && to == ProtoChat: + return newMessagesToChat().line + case from == ProtoChat && to == ProtoMessages: + return newChatToMessages().line + case from == ProtoResponses && to == ProtoChat: + return newResponsesToChat().line + case from == ProtoChat && to == ProtoResponses: + return newChatToResponses().line + case from == ProtoResponses && to == ProtoMessages: + return newResponsesToMessages().line + case from == ProtoMessages && to == ProtoResponses: + return newMessagesToResponses().line + } + return nil +} + +// --------------------------------------------------------------------------- +// 工具函数 + +// str 返回字符串字段;json.RawMessage 为字符串字面量时去引号。 +func str(raw json.RawMessage) string { + if len(raw) == 0 || string(raw) == "null" { + return "" + } + var s string + if json.Unmarshal(raw, &s) == nil { + return s + } + // 数组/对象:尝试取 type=text 的 text + var arr []map[string]any + if json.Unmarshal(raw, &arr) == nil { + var parts []string + for _, b := range arr { + if t, _ := b["type"].(string); t == "text" || t == "input_text" || t == "output_text" { + if txt, _ := b["text"].(string); txt != "" { + parts = append(parts, txt) + } + } + } + return joinNonEmpty(parts, "\n") + } + return "" +} + +func joinNonEmpty(parts []string, sep string) string { + out := "" + for i, p := range parts { + if p == "" { + continue + } + if out != "" { + out += sep + } + out += p + _ = i + } + return out +} + +// rawJSON 安全取字段;不存在或 null 返回 nil。 +func rawJSON(m map[string]json.RawMessage, key string) json.RawMessage { + raw, ok := m[key] + if !ok || string(raw) == "null" { + return nil + } + return raw +} + +// decode 把 RawMessage 解到 map。 +func decode(raw json.RawMessage) (map[string]any, error) { + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + return nil, err + } + return m, nil +} diff --git a/server/internal/proxy/convert/convert_test.go b/server/internal/proxy/convert/convert_test.go new file mode 100644 index 0000000..9e0c9cf --- /dev/null +++ b/server/internal/proxy/convert/convert_test.go @@ -0,0 +1,308 @@ +package convert + +import ( + "encoding/json" + "strings" + "testing" +) + +func mustJSON(t *testing.T, v any) string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return string(b) +} + +func TestChatToMessagesReq(t *testing.T) { + in := `{ + "model":"claude-sonnet-5", + "messages":[ + {"role":"system","content":"你是助手"}, + {"role":"user","content":"hi"}, + {"role":"assistant","content":"hello","tool_calls":[{"id":"call_1","function":{"name":"get_weather","arguments":"{\"city\":\"sz\"}"}}]}, + {"role":"tool","tool_call_id":"call_1","content":"sunny"} + ], + "tools":[{"type":"function","function":{"name":"get_weather","description":"查天气","parameters":{"type":"object"}}}], + "max_tokens":100, + "stream":true + }` + out, err := ConvertRequest([]byte(in), ProtoChat, ProtoMessages) + if err != nil { + t.Fatalf("convert: %v", err) + } + var m map[string]any + if err := json.Unmarshal(out, &m); err != nil { + t.Fatalf("unmarshal out: %v\n%s", err, out) + } + if m["system"] != "你是助手" { + t.Fatalf("system = %v", m["system"]) + } + if m["max_tokens"] != float64(100) { + t.Fatalf("max_tokens = %v", m["max_tokens"]) + } + msgs := m["messages"].([]any) + if len(msgs) != 3 { + t.Fatalf("messages len = %d", len(msgs)) + } + // assistant 含 tool_use 块 + assistant := msgs[1].(map[string]any) + content := assistant["content"].([]any) + foundToolUse := false + for _, c := range content { + cm := c.(map[string]any) + if cm["type"] == "tool_use" { + foundToolUse = true + if cm["name"] != "get_weather" || cm["id"] != "call_1" { + t.Fatalf("tool_use mismatch: %v", cm) + } + } + } + if !foundToolUse { + t.Fatal("expected tool_use block") + } + // tool 消息 → user 消息的 tool_result 块 + tool := msgs[2].(map[string]any) + if tool["role"] != "user" { + t.Fatalf("tool message role = %v", tool["role"]) + } +} + +func TestMessagesToChatReq(t *testing.T) { + in := `{ + "model":"gpt-4o-mini", + "system":"你是助手", + "messages":[ + {"role":"user","content":"hi"}, + {"role":"assistant","content":[{"type":"text","text":"hello"},{"type":"tool_use","id":"call_1","name":"get_weather","input":{"city":"sz"}}]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"sunny"}]} + ], + "tools":[{"name":"get_weather","description":"查天气","input_schema":{"type":"object"}}], + "max_tokens":100, + "stream":false + }` + out, err := ConvertRequest([]byte(in), ProtoMessages, ProtoChat) + if err != nil { + t.Fatalf("convert: %v", err) + } + var m map[string]any + _ = json.Unmarshal(out, &m) + msgs := m["messages"].([]any) + // system + user + assistant + tool = 4 条 + if len(msgs) != 4 { + t.Fatalf("messages len = %d: %s", len(msgs), out) + } + if msgs[0].(map[string]any)["role"] != "system" { + t.Fatal("expected system message first") + } + assistant := msgs[2].(map[string]any) + if tc := assistant["tool_calls"]; tc == nil { + t.Fatalf("expected tool_calls in assistant: %s", out) + } + tool := msgs[3].(map[string]any) + if tool["role"] != "tool" || tool["tool_call_id"] != "call_1" { + t.Fatalf("tool message mismatch: %v", tool) + } +} + +func TestResponsesToChatReq(t *testing.T) { + in := `{ + "model":"claude-sonnet-5", + "instructions":"你是助手", + "input":"hello", + "tools":[{"type":"function","name":"get_weather","description":"查天气","parameters":{"type":"object"}}], + "max_output_tokens":200, + "stream":false + }` + out, err := ConvertRequest([]byte(in), ProtoResponses, ProtoChat) + if err != nil { + t.Fatalf("convert: %v", err) + } + var m map[string]any + _ = json.Unmarshal(out, &m) + msgs := m["messages"].([]any) + if len(msgs) != 2 { + t.Fatalf("messages len = %d: %s", len(msgs), out) + } + if msgs[0].(map[string]any)["role"] != "system" { + t.Fatal("expected system from instructions") + } + if m["max_tokens"] != float64(200) { + t.Fatalf("max_tokens = %v", m["max_tokens"]) + } +} + +func TestChatToResponsesReq(t *testing.T) { + in := mustJSON(t, map[string]any{ + "model": "gpt-4o", + "messages": []any{ + map[string]any{"role": "system", "content": "sys"}, + map[string]any{"role": "user", "content": "hi"}, + }, + "max_tokens": 300, + }) + out, err := ConvertRequest([]byte(in), ProtoChat, ProtoResponses) + if err != nil { + t.Fatalf("convert: %v", err) + } + var m map[string]any + _ = json.Unmarshal(out, &m) + if m["instructions"] != "sys" { + t.Fatalf("instructions = %v", m["instructions"]) + } + if m["max_output_tokens"] != float64(300) { + t.Fatalf("max_output_tokens = %v", m["max_output_tokens"]) + } +} + +func TestMessagesToChatResp(t *testing.T) { + in := `{"id":"msg_abc","type":"message","role":"assistant","model":"claude-sonnet-5", + "content":[{"type":"text","text":"你好"},{"type":"tool_use","id":"call_1","name":"get_weather","input":{"city":"sz"}}], + "stop_reason":"tool_use","usage":{"input_tokens":10,"output_tokens":5}}` + out, err := ConvertResponse([]byte(in), ProtoMessages, ProtoChat) + if err != nil { + t.Fatalf("convert: %v", err) + } + var m map[string]any + _ = json.Unmarshal(out, &m) + choices := m["choices"].([]any) + msg := choices[0].(map[string]any)["message"].(map[string]any) + if msg["content"] != "你好" { + t.Fatalf("content = %v", msg["content"]) + } + if msg["tool_calls"] == nil { + t.Fatal("expected tool_calls") + } + if choices[0].(map[string]any)["finish_reason"] != "tool_calls" { + t.Fatalf("finish_reason = %v", choices[0].(map[string]any)["finish_reason"]) + } +} + +func TestChatToMessagesResp(t *testing.T) { + in := `{"id":"chatcmpl-xyz","object":"chat.completion","model":"gpt-4o", + "choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}], + "usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}` + out, err := ConvertResponse([]byte(in), ProtoChat, ProtoMessages) + if err != nil { + t.Fatalf("convert: %v", err) + } + var m map[string]any + _ = json.Unmarshal(out, &m) + if m["stop_reason"] != "end_turn" { + t.Fatalf("stop_reason = %v", m["stop_reason"]) + } + content := m["content"].([]any) + if content[0].(map[string]any)["text"] != "hi" { + t.Fatalf("content = %v", content) + } + usage := m["usage"].(map[string]any) + if usage["input_tokens"] != float64(3) || usage["output_tokens"] != float64(2) { + t.Fatalf("usage = %v", usage) + } +} + +// --------------------------------------------------------------------------- +// 流式转换 + +func feedLines(t *testing.T, transformer func([]byte) []byte, lines []string) string { + t.Helper() + var sb strings.Builder + for _, l := range lines { + if out := transformer([]byte(l)); out != nil { + sb.Write(out) + } + } + return sb.String() +} + +func TestStreamMessagesToChat(t *testing.T) { + tf := newMessagesToChat().line + out := feedLines(t, tf, []string{ + "event: message_start\n", + `data: {"type":"message_start","message":{"id":"msg_1","model":"claude-sonnet-5"}}` + "\n\n", + "event: content_block_delta\n", + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"你好"}}` + "\n\n", + "event: message_delta\n", + `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":10,"output_tokens":5}}` + "\n\n", + "event: message_stop\n", + `data: {"type":"message_stop"}` + "\n\n", + }) + if !strings.Contains(out, `"content":"你好"`) { + t.Fatalf("missing content chunk: %s", out) + } + if !strings.Contains(out, `"finish_reason":"stop"`) { + t.Fatalf("missing finish chunk: %s", out) + } + if !strings.Contains(out, `"usage"`) { + t.Fatalf("missing usage chunk: %s", out) + } + if !strings.Contains(out, "data: [DONE]") { + t.Fatalf("missing [DONE]: %s", out) + } +} + +func TestStreamChatToMessages(t *testing.T) { + tf := newChatToMessages().line + out := feedLines(t, tf, []string{ + `data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}` + "\n\n", + `data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{"content":"你好"},"finish_reason":null}]}` + "\n\n", + `data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}` + "\n\n", + `data: {"id":"chatcmpl-1","object":"chat.completion.chunk","model":"gpt-4o","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":9}}` + "\n\n", + "data: [DONE]\n\n", + }) + if !strings.Contains(out, "event: message_start") { + t.Fatalf("missing message_start: %s", out) + } + if !strings.Contains(out, `"text":"你好"`) || !strings.Contains(out, `"type":"text_delta"`) { + t.Fatalf("missing content delta: %s", out) + } + if !strings.Contains(out, `"stop_reason":"end_turn"`) { + t.Fatalf("missing message_delta: %s", out) + } + if !strings.Contains(out, "event: message_stop") { + t.Fatalf("missing message_stop: %s", out) + } +} + +func TestStreamResponsesToMessages(t *testing.T) { + tf := newResponsesToMessages().line + out := feedLines(t, tf, []string{ + "event: response.created\n", + `data: {"type":"response.created","response":{"id":"resp_1","model":"claude-sonnet-5"}}` + "\n\n", + "event: response.output_text.delta\n", + `data: {"type":"response.output_text.delta","delta":"hi"}` + "\n\n", + "event: response.completed\n", + `data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","usage":{"input_tokens":7,"output_tokens":8}}}` + "\n\n", + }) + if !strings.Contains(out, "event: message_start") { + t.Fatalf("missing message_start: %s", out) + } + if !strings.Contains(out, `"text":"hi"`) { + t.Fatalf("missing content: %s", out) + } + if !strings.Contains(out, "event: message_stop") { + t.Fatalf("missing message_stop: %s", out) + } +} + +func TestStreamMessagesToResponses(t *testing.T) { + tf := newMessagesToResponses().line + out := feedLines(t, tf, []string{ + "event: message_start\n", + `data: {"type":"message_start","message":{"id":"msg_1","model":"claude-sonnet-5"}}` + "\n\n", + "event: content_block_delta\n", + `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}` + "\n\n", + "event: message_stop\n", + `data: {"type":"message_stop"}` + "\n\n", + }) + if !strings.Contains(out, "event: response.created") { + t.Fatalf("missing response.created: %s", out) + } + if !strings.Contains(out, "event: response.output_text.delta") { + t.Fatalf("missing output_text.delta: %s", out) + } + if !strings.Contains(out, "event: response.completed") { + t.Fatalf("missing response.completed: %s", out) + } +} diff --git a/server/internal/proxy/convert/json_chat.go b/server/internal/proxy/convert/json_chat.go new file mode 100644 index 0000000..27042f3 --- /dev/null +++ b/server/internal/proxy/convert/json_chat.go @@ -0,0 +1,457 @@ +package convert + +import ( + "encoding/json" + "strings" +) + +// --------------------------------------------------------------------------- +// 请求:Chat → Messages + +type chatTool struct { + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters json.RawMessage `json:"parameters"` + } `json:"function"` +} + +type chatMsg struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + ToolCallID string `json:"tool_call_id"` + ToolCalls []struct { + ID string `json:"id"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` +} + +type chatReq struct { + Model string `json:"model"` + Messages []chatMsg `json:"messages"` + Tools []chatTool `json:"tools"` + Temperature *float64 `json:"temperature"` + TopP *float64 `json:"top_p"` + MaxTokens *int `json:"max_tokens"` + Stop []string `json:"stop"` + Stream bool `json:"stream"` +} + +// chatToMessagesReq 将 OpenAI Chat 请求转为 Anthropic Messages 请求。 +func chatToMessagesReq(body []byte) ([]byte, error) { + var req chatReq + if err := json.Unmarshal(body, &req); err != nil { + return nil, err + } + out := map[string]any{ + "model": req.Model, + "max_tokens": intOrNil(req.MaxTokens, 1024), // Anthropic 必填 + } + if req.Stream { + out["stream"] = true + } + if req.Temperature != nil { + out["temperature"] = *req.Temperature + } + if req.TopP != nil { + out["top_p"] = *req.TopP + } + if len(req.Stop) > 0 { + out["stop_sequences"] = req.Stop + } + + var system []string + msgs := make([]any, 0, len(req.Messages)) + for _, m := range req.Messages { + if m.Role == "system" { + if s := str(m.Content); s != "" { + system = append(system, s) + } + continue + } + msgs = append(msgs, chatMsgToAnthropic(m)) + } + if len(system) > 0 { + out["system"] = strings.Join(system, "\n") + } + out["messages"] = msgs + + if len(req.Tools) > 0 { + tools := make([]any, 0, len(req.Tools)) + for _, t := range req.Tools { + var params any + if len(t.Function.Parameters) > 0 && string(t.Function.Parameters) != "null" { + _ = json.Unmarshal(t.Function.Parameters, ¶ms) + } + tools = append(tools, map[string]any{ + "name": t.Function.Name, + "description": t.Function.Description, + "input_schema": params, + }) + } + out["tools"] = tools + } + return json.Marshal(out) +} + +// chatMsgToAnthropic 单条消息转 Anthropic 内容。 +func chatMsgToAnthropic(m chatMsg) any { + switch m.Role { + case "assistant": + content := make([]any, 0, 2) + if s := str(m.Content); s != "" { + content = append(content, map[string]any{"type": "text", "text": s}) + } + for _, tc := range m.ToolCalls { + var input any + if tc.Function.Arguments != "" { + _ = json.Unmarshal([]byte(tc.Function.Arguments), &input) + } + content = append(content, map[string]any{ + "type": "tool_use", + "id": tc.ID, + "name": tc.Function.Name, + "input": input, + }) + } + return map[string]any{"role": "assistant", "content": content} + case "tool": + return map[string]any{"role": "user", "content": []any{ + map[string]any{"type": "tool_result", "tool_use_id": m.ToolCallID, "content": str(m.Content)}, + }} + default: // user + var arr []map[string]any + if json.Unmarshal(m.Content, &arr) == nil && arr != nil { + blocks := make([]any, 0, len(arr)) + for _, b := range arr { + switch b["type"] { + case "text", "input_text": + if t, _ := b["text"].(string); t != "" { + blocks = append(blocks, map[string]any{"type": "text", "text": t}) + } + case "image_url": + var url string + if iu, ok := b["image_url"].(map[string]any); ok { + url, _ = iu["url"].(string) + } + if url != "" { + blocks = append(blocks, map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": url}}) + } + } + } + if len(blocks) > 0 { + return map[string]any{"role": "user", "content": blocks} + } + } + return map[string]any{"role": "user", "content": str(m.Content)} + } +} + +// --------------------------------------------------------------------------- +// 请求:Messages → Chat + +type messagesReq struct { + Model string `json:"model"` + System json.RawMessage `json:"system"` + Messages []struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + } `json:"messages"` + Tools []struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"input_schema"` + } `json:"tools"` + Temperature *float64 `json:"temperature"` + TopP *float64 `json:"top_p"` + MaxTokens *int `json:"max_tokens"` + StopSequence []string `json:"stop_sequences"` + Stream bool `json:"stream"` +} + +// messagesToChatReq 将 Anthropic Messages 请求转为 OpenAI Chat 请求。 +func messagesToChatReq(body []byte) ([]byte, error) { + var req messagesReq + if err := json.Unmarshal(body, &req); err != nil { + return nil, err + } + out := map[string]any{"model": req.Model} + if req.Stream { + out["stream"] = true + } + if req.Temperature != nil { + out["temperature"] = *req.Temperature + } + if req.TopP != nil { + out["top_p"] = *req.TopP + } + if req.MaxTokens != nil { + out["max_tokens"] = *req.MaxTokens + } + if len(req.StopSequence) > 0 { + out["stop"] = req.StopSequence + } + + msgs := make([]any, 0, len(req.Messages)+1) + if s := str(req.System); s != "" { + msgs = append(msgs, map[string]any{"role": "system", "content": s}) + } + for _, m := range req.Messages { + msgs = append(msgs, anthropicMsgToChat(m.Role, m.Content)...) + } + out["messages"] = msgs + + if len(req.Tools) > 0 { + tools := make([]any, 0, len(req.Tools)) + for _, t := range req.Tools { + tools = append(tools, map[string]any{ + "type": "function", + "function": map[string]any{ + "name": t.Name, + "description": t.Description, + "parameters": rawOrObject(t.InputSchema), + }, + }) + } + out["tools"] = tools + } + return json.Marshal(out) +} + +// anthropicMsgToChat 将一条 Anthropic 消息拆成 0..N 条 Chat 消息。 +func anthropicMsgToChat(role string, content json.RawMessage) []any { + // 块数组优先(tool_use / tool_result 需要分块解析) + var blocks []map[string]any + if json.Unmarshal(content, &blocks) == nil && blocks != nil { + var out []any + var textParts []string + var toolCalls []any + for _, b := range blocks { + switch b["type"] { + case "text": + if t, _ := b["text"].(string); t != "" { + textParts = append(textParts, t) + } + case "tool_use": + id, _ := b["id"].(string) + name, _ := b["name"].(string) + args, _ := json.Marshal(b["input"]) + toolCalls = append(toolCalls, map[string]any{ + "id": id, + "type": "function", + "function": map[string]any{ + "name": name, + "arguments": string(args), + }, + }) + case "tool_result": + callID, _ := b["tool_use_id"].(string) + res := strField(b["content"]) + out = append(out, map[string]any{"role": "tool", "tool_call_id": callID, "content": res}) + } + } + if len(textParts) > 0 || len(toolCalls) > 0 { + msg := map[string]any{"role": role} + if len(textParts) > 0 { + msg["content"] = strings.Join(textParts, "") + } + if len(toolCalls) > 0 { + msg["tool_calls"] = toolCalls + } + out = append(out, msg) + } + if len(out) > 0 { + return out + } + } + // 纯文本 + if s := str(content); s != "" { + return []any{map[string]any{"role": role, "content": s}} + } + return nil +} + +// --------------------------------------------------------------------------- +// 响应:Messages → Chat + +type messagesResp struct { + ID string `json:"id"` + Model string `json:"model"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + ID string `json:"id"` + Name string `json:"name"` + Input json.RawMessage `json:"input"` + } `json:"content"` + StopReason string `json:"stop_reason"` + Usage struct { + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + } `json:"usage"` +} + +// messagesToChatResp 将 Anthropic Messages 响应(非流式)转为 Chat 响应。 +func messagesToChatResp(body []byte) ([]byte, error) { + var r messagesResp + if err := json.Unmarshal(body, &r); err != nil { + return nil, err + } + var text string + var toolCalls []any + for _, c := range r.Content { + switch c.Type { + case "text": + text += c.Text + case "tool_use": + args, _ := json.Marshal(c.Input) + toolCalls = append(toolCalls, map[string]any{ + "id": c.ID, + "type": "function", + "function": map[string]any{ + "name": c.Name, + "arguments": string(args), + }, + }) + } + } + msg := map[string]any{"role": "assistant", "content": text} + if len(toolCalls) > 0 { + msg["tool_calls"] = toolCalls + } + return json.Marshal(map[string]any{ + "id": "chatcmpl-" + strings.TrimPrefix(r.ID, "msg_"), + "object": "chat.completion", + "model": r.Model, + "created": 0, + "choices": []any{map[string]any{ + "index": 0, + "message": msg, + "finish_reason": messagesStopToChat(r.StopReason), + }}, + "usage": map[string]any{ + "prompt_tokens": r.Usage.InputTokens, + "completion_tokens": r.Usage.OutputTokens, + "total_tokens": r.Usage.InputTokens + r.Usage.OutputTokens, + }, + }) +} + +// --------------------------------------------------------------------------- +// 响应:Chat → Messages + +type chatResp struct { + ID string `json:"id"` + Model string `json:"model"` + Choices []struct { + Message struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []struct { + ID string `json:"id"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Usage struct { + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + } `json:"usage"` +} + +// chatToMessagesResp 将 Chat 响应(非流式)转为 Messages 响应。 +func chatToMessagesResp(body []byte) ([]byte, error) { + var r chatResp + if err := json.Unmarshal(body, &r); err != nil { + return nil, err + } + content := make([]any, 0, 2) + var finish = "end_turn" + if len(r.Choices) > 0 { + msg := r.Choices[0].Message + if msg.Content != "" { + content = append(content, map[string]any{"type": "text", "text": msg.Content}) + } + for _, tc := range msg.ToolCalls { + var input any + _ = json.Unmarshal([]byte(tc.Function.Arguments), &input) + content = append(content, map[string]any{ + "type": "tool_use", + "id": tc.ID, + "name": tc.Function.Name, + "input": input, + }) + } + finish = chatStopToMessages(r.Choices[0].FinishReason) + } + return json.Marshal(map[string]any{ + "id": "msg_" + strings.TrimPrefix(r.ID, "chatcmpl-"), + "type": "message", + "role": "assistant", + "model": r.Model, + "content": content, + "stop_reason": finish, + "usage": map[string]any{ + "input_tokens": r.Usage.PromptTokens, + "output_tokens": r.Usage.CompletionTokens, + }, + }) +} + +// --------------------------------------------------------------------------- +// 辅助 + +func intOrNil(p *int, def int) any { + if p == nil { + return def + } + return *p +} + +func rawOrObject(raw json.RawMessage) any { + if len(raw) == 0 || string(raw) == "null" { + return map[string]any{} + } + var m map[string]any + if json.Unmarshal(raw, &m) == nil { + return m + } + return map[string]any{} +} + +func strField(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" +} + +func messagesStopToChat(s string) string { + switch s { + case "tool_use": + return "tool_calls" + case "max_tokens": + return "length" + default: + return "stop" + } +} + +func chatStopToMessages(s string) string { + switch s { + case "tool_calls": + return "tool_use" + case "length": + return "max_tokens" + default: + return "end_turn" + } +} diff --git a/server/internal/proxy/convert/json_responses.go b/server/internal/proxy/convert/json_responses.go new file mode 100644 index 0000000..7c70a19 --- /dev/null +++ b/server/internal/proxy/convert/json_responses.go @@ -0,0 +1,318 @@ +package convert + +import ( + "encoding/json" + "strings" +) + +// --------------------------------------------------------------------------- +// 请求:Responses → Chat + +// responsesToChatReq 将 OpenAI Responses 请求转为 Chat 请求。 +func responsesToChatReq(body []byte) ([]byte, error) { + var m map[string]json.RawMessage + if err := json.Unmarshal(body, &m); err != nil { + return nil, err + } + out := map[string]any{"model": str(rawJSON(m, "model"))} + if v, ok := m["stream"]; ok && string(v) == "true" { + out["stream"] = true + } + if v, ok := m["temperature"]; ok { + out["temperature"] = v + } + if v, ok := m["top_p"]; ok { + out["top_p"] = v + } + if v, ok := m["max_output_tokens"]; ok { + out["max_tokens"] = v + } + + var msgs []any + if ins := str(rawJSON(m, "instructions")); ins != "" { + msgs = append(msgs, map[string]any{"role": "system", "content": ins}) + } + msgs = append(msgs, responsesInputToChat(rawJSON(m, "input"))...) + out["messages"] = msgs + + if raw := rawJSON(m, "tools"); raw != nil { + var tools []map[string]any + if json.Unmarshal(raw, &tools) == nil { + chatTools := make([]any, 0, len(tools)) + for _, t := range tools { + chatTools = append(chatTools, map[string]any{ + "type": "function", + "function": map[string]any{ + "name": t["name"], + "description": t["description"], + "parameters": t["parameters"], + }, + }) + } + out["tools"] = chatTools + } + } + return json.Marshal(out) +} + +// responsesInputToChat 把 Responses input 转成 Chat messages。 +func responsesInputToChat(raw json.RawMessage) []any { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + // 字符串输入 + if s := str(raw); s != "" { + return []any{map[string]any{"role": "user", "content": s}} + } + var items []map[string]any + if err := json.Unmarshal(raw, &items); err != nil || items == nil { + return nil + } + var out []any + for _, item := range items { + switch item["type"] { + case "function_call": + var args any + _ = json.Unmarshal([]byte(strField(item["arguments"])), &args) + out = append(out, map[string]any{ + "role": "assistant", + "content": "", + "tool_calls": []any{map[string]any{ + "id": strField(item["call_id"]), + "type": "function", + "function": map[string]any{ + "name": strField(item["name"]), + "arguments": strField(item["arguments"]), + }, + }}, + }) + case "function_call_output": + out = append(out, map[string]any{ + "role": "tool", + "tool_call_id": strField(item["call_id"]), + "content": strField(item["output"]), + }) + default: // message 条目 + role, _ := item["role"].(string) + if role == "" { + role = "user" + } + if content, ok := item["content"].(string); ok { + out = append(out, map[string]any{"role": role, "content": content}) + } else if blocks, ok := item["content"].([]any); ok { + var text []string + for _, b := range blocks { + if bm, ok := b.(map[string]any); ok { + if t, _ := bm["text"].(string); t != "" { + text = append(text, t) + } + } + } + out = append(out, map[string]any{"role": role, "content": strings.Join(text, "")}) + } + } + } + return out +} + +// --------------------------------------------------------------------------- +// 请求:Chat → Responses + +// chatToResponsesReq 将 Chat 请求转为 Responses 请求。 +func chatToResponsesReq(body []byte) ([]byte, error) { + var req chatReq + if err := json.Unmarshal(body, &req); err != nil { + return nil, err + } + out := map[string]any{"model": req.Model} + if req.Stream { + out["stream"] = true + } + if req.Temperature != nil { + out["temperature"] = *req.Temperature + } + if req.TopP != nil { + out["top_p"] = *req.TopP + } + if req.MaxTokens != nil { + out["max_output_tokens"] = *req.MaxTokens + } + + var system []string + var input []any + for _, m := range req.Messages { + if m.Role == "system" { + if s := str(m.Content); s != "" { + system = append(system, s) + } + continue + } + switch m.Role { + case "tool": + input = append(input, map[string]any{ + "type": "function_call_output", + "call_id": m.ToolCallID, + "output": str(m.Content), + }) + case "assistant": + if len(m.ToolCalls) > 0 { + for _, tc := range m.ToolCalls { + input = append(input, map[string]any{ + "type": "function_call", + "call_id": tc.ID, + "name": tc.Function.Name, + "arguments": tc.Function.Arguments, + }) + } + } else if s := str(m.Content); s != "" { + input = append(input, map[string]any{"type": "message", "role": "assistant", "content": []any{ + map[string]any{"type": "input_text", "text": s}, + }}) + } + default: + if s := str(m.Content); s != "" { + input = append(input, map[string]any{"type": "message", "role": "user", "content": []any{ + map[string]any{"type": "input_text", "text": s}, + }}) + } + } + } + if len(system) > 0 { + out["instructions"] = strings.Join(system, "\n") + } + if len(input) == 1 { + out["input"] = input[0] // 单条消息项 + } else { + out["input"] = input + } + + if len(req.Tools) > 0 { + tools := make([]any, 0, len(req.Tools)) + for _, t := range req.Tools { + tools = append(tools, map[string]any{ + "type": "function", + "name": t.Function.Name, + "description": t.Function.Description, + "parameters": rawOrObject(t.Function.Parameters), + }) + } + out["tools"] = tools + } + return json.Marshal(out) +} + +// --------------------------------------------------------------------------- +// 响应:Responses → Chat + +// responsesToChatResp 将 Responses 响应(非流式)转为 Chat 响应。 +func responsesToChatResp(body []byte) ([]byte, error) { + var m map[string]json.RawMessage + if err := json.Unmarshal(body, &m); err != nil { + return nil, err + } + var text string + var toolCalls []any + if raw := rawJSON(m, "output"); raw != nil { + var outputs []map[string]any + if json.Unmarshal(raw, &outputs) == nil { + for _, o := range outputs { + switch o["type"] { + case "message": + if content, ok := o["content"].([]any); ok { + for _, c := range content { + if cm, ok := c.(map[string]any); ok { + if t, _ := cm["text"].(string); t != "" { + text += t + } + } + } + } + case "function_call": + toolCalls = append(toolCalls, map[string]any{ + "id": strField(o["call_id"]), + "type": "function", + "function": map[string]any{ + "name": strField(o["name"]), + "arguments": strField(o["arguments"]), + }, + }) + } + } + } + } + msg := map[string]any{"role": "assistant", "content": text} + if len(toolCalls) > 0 { + msg["tool_calls"] = toolCalls + } + finish := "stop" + if string(rawJSON(m, "status")) == `"incomplete"` { + finish = "length" + } + var prompt, completion int64 + if u := rawJSON(m, "usage"); u != nil { + var us struct { + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + } + _ = json.Unmarshal(u, &us) + prompt, completion = us.InputTokens, us.OutputTokens + } + return json.Marshal(map[string]any{ + "id": "chatcmpl-" + strings.TrimPrefix(str(rawJSON(m, "id")), "resp_"), + "object": "chat.completion", + "model": str(rawJSON(m, "model")), + "choices": []any{map[string]any{"index": 0, "message": msg, "finish_reason": finish}}, + "usage": map[string]any{ + "prompt_tokens": prompt, + "completion_tokens": completion, + "total_tokens": prompt + completion, + }, + }) +} + +// --------------------------------------------------------------------------- +// 响应:Chat → Responses + +// chatToResponsesResp 将 Chat 响应(非流式)转为 Responses 响应。 +func chatToResponsesResp(body []byte) ([]byte, error) { + var r chatResp + if err := json.Unmarshal(body, &r); err != nil { + return nil, err + } + output := make([]any, 0, 2) + var finish = "completed" + if len(r.Choices) > 0 { + msg := r.Choices[0].Message + if msg.Content != "" { + output = append(output, map[string]any{ + "type": "message", + "role": "assistant", + "content": []any{map[string]any{"type": "output_text", "text": msg.Content}}, + }) + } + for _, tc := range msg.ToolCalls { + output = append(output, map[string]any{ + "type": "function_call", + "call_id": tc.ID, + "name": tc.Function.Name, + "arguments": tc.Function.Arguments, + }) + } + if r.Choices[0].FinishReason == "length" { + finish = "incomplete" + } + } + status := finish + return json.Marshal(map[string]any{ + "id": "resp_" + strings.TrimPrefix(r.ID, "chatcmpl-"), + "object": "response", + "model": r.Model, + "status": status, + "output": output, + "usage": map[string]any{ + "input_tokens": r.Usage.PromptTokens, + "output_tokens": r.Usage.CompletionTokens, + "total_tokens": r.Usage.PromptTokens + r.Usage.CompletionTokens, + }, + }) +} diff --git a/server/internal/proxy/convert/stream.go b/server/internal/proxy/convert/stream.go new file mode 100644 index 0000000..77a779e --- /dev/null +++ b/server/internal/proxy/convert/stream.go @@ -0,0 +1,478 @@ +package convert + +import ( + "encoding/json" + "strings" +) + +// sseState 记录上一行 event 名与通用状态。 +type sseState struct { + event string +} + +// parseLine 解析一行 SSE;返回是否 data 行及其内容、是否 [DONE]。 +func (s *sseState) parseLine(line []byte) (isData bool, data string, done bool) { + str := strings.TrimRight(string(line), "\r\n") + switch { + case strings.HasPrefix(str, "event: "): + s.event = strings.TrimSpace(strings.TrimPrefix(str, "event: ")) + return false, "", false + case str == "data: [DONE]": + return true, "[DONE]", true + case strings.HasPrefix(str, "data: "): + return true, strings.TrimPrefix(str, "data: "), false + default: + return false, "", false + } +} + +func eventData(line string) map[string]any { + var m map[string]any + _ = json.Unmarshal([]byte(line), &m) + return m +} + +func dataLine(obj any) []byte { + b, _ := json.Marshal(obj) + return append(append([]byte("data: "), b...), '\n', '\n') +} + +func eventLine(name string, obj any) []byte { + b, _ := json.Marshal(obj) + out := append([]byte("event: "+name+"\ndata: "), b...) + return append(out, '\n', '\n') +} + +// --------------------------------------------------------------------------- +// Messages → Chat + +type messagesToChat struct { + sseState + id, model string +} + +func newMessagesToChat() *messagesToChat { return &messagesToChat{} } + +func (t *messagesToChat) line(line []byte) []byte { + isData, data, done := t.parseLine(line) + if !isData { + return nil + } + if done { + return []byte("data: [DONE]\n\n") + } + m := eventData(data) + evt, _ := m["type"].(string) + switch evt { + case "message_start": + msg, _ := m["message"].(map[string]any) + t.id, _ = msg["id"].(string) + t.model, _ = msg["model"].(string) + return dataLine(map[string]any{ + "id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model, + "choices": []any{map[string]any{"index": 0, "delta": map[string]any{"role": "assistant"}, "finish_reason": nil}}, + }) + case "content_block_delta": + delta, _ := m["delta"].(map[string]any) + text, _ := delta["text"].(string) + if text == "" { + return nil + } + return dataLine(map[string]any{ + "id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model, + "choices": []any{map[string]any{"index": 0, "delta": map[string]any{"content": text}, "finish_reason": nil}}, + }) + case "message_delta": + delta, _ := m["delta"].(map[string]any) + stop, _ := delta["stop_reason"].(string) + var out [][]byte + out = append(out, dataLine(map[string]any{ + "id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model, + "choices": []any{map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": messagesStopToChat(stop)}}, + })) + if u, ok := m["usage"]; ok { + out = append(out, dataLine(map[string]any{ + "id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model, + "choices": []any{}, "usage": u, + })) + } + return joinLines(out) + case "message_stop": + return []byte("data: [DONE]\n\n") + } + return nil +} + +func joinLines(lines [][]byte) []byte { + return []byte(strings.Join(func() []string { + var s []string + for _, l := range lines { + s = append(s, string(l)) + } + return s + }(), "")) +} + +// --------------------------------------------------------------------------- +// Chat → Messages + +type chatToMessages struct { + sseState + started bool + blockStarted bool + model string + stopReason string + usage any +} + +func newChatToMessages() *chatToMessages { return &chatToMessages{} } + +func (t *chatToMessages) line(line []byte) []byte { + isData, data, done := t.parseLine(line) + if !isData { + return nil + } + if done { + // 汇聚最终 message_delta + content_block_stop + message_stop + md := map[string]any{"type": "message_delta", "delta": map[string]any{ + "stop_reason": stopReasonOrEnd(t.stopReason), "stop_sequence": nil, + }} + if t.usage != nil { + md["usage"] = t.usage + } + var out [][]byte + out = append(out, eventLine("message_delta", md)) + if t.blockStarted { + out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": 0})) + } + out = append(out, eventLine("message_stop", map[string]any{"type": "message_stop"})) + return joinLines(out) + } + m := eventData(data) + // chat 块:delta / finish_reason 在 choices[0] 内 + delta := map[string]any{} + if choices, ok := m["choices"].([]any); ok && len(choices) > 0 { + if c0, ok := choices[0].(map[string]any); ok { + if d, ok := c0["delta"].(map[string]any); ok { + delta = d + } + if fr, _ := c0["finish_reason"].(string); fr != "" { + t.stopReason = fr + } + } + } + if t.model == "" { + t.model, _ = m["model"].(string) + } + id, _ := m["id"].(string) + + var out [][]byte + // 首个包含内容或角色的块前,先发 message_start + content_block_start + if !t.started { + role, _ := delta["role"].(string) + content, _ := delta["content"].(string) + if role == "assistant" || content != "" { + t.started = true + out = append(out, eventLine("message_start", map[string]any{ + "type": "message_start", + "message": map[string]any{ + "id": "msg_" + strings.TrimPrefix(id, "chatcmpl-"), "type": "message", "role": "assistant", + "model": t.model, "content": []any{}, "usage": map[string]any{"input_tokens": 0, "output_tokens": 0}, + }, + })) + out = append(out, eventLine("content_block_start", map[string]any{ + "type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""}, + })) + t.blockStarted = true + } + } + if content, _ := delta["content"].(string); content != "" { + if !t.started { + t.started = true + t.blockStarted = true + out = append(out, eventLine("message_start", map[string]any{ + "type": "message_start", + "message": map[string]any{"id": "msg_" + strings.TrimPrefix(id, "chatcmpl-"), "type": "message", "role": "assistant", "model": t.model, "content": []any{}}, + })) + out = append(out, eventLine("content_block_start", map[string]any{ + "type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""}, + })) + } + out = append(out, eventLine("content_block_delta", map[string]any{ + "type": "content_block_delta", "index": 0, "delta": map[string]any{"type": "text_delta", "text": content}, + })) + } + if u, ok := m["usage"]; ok { + t.usage = u + } + return joinLines(out) +} + +func stopReasonOrEnd(s string) string { + if s == "" { + return "end_turn" + } + return chatStopToMessages(s) +} + +// --------------------------------------------------------------------------- +// Responses → Messages + +type responsesToMessages struct { + sseState + started bool + model string + usage any +} + +func newResponsesToMessages() *responsesToMessages { return &responsesToMessages{} } + +func (t *responsesToMessages) line(line []byte) []byte { + isData, data, done := t.parseLine(line) + if !isData || done { + return nil + } + m := eventData(data) + evt, _ := m["type"].(string) + if resp, ok := m["response"].(map[string]any); ok { + if t.model == "" { + t.model, _ = resp["model"].(string) + } + if u, ok := resp["usage"]; ok { + t.usage = u + } + } + var out [][]byte + switch evt { + case "response.created": + if !t.started { + t.started = true + id, _ := m["response"].(map[string]any) + rid := "" + if id != nil { + rid, _ = id["id"].(string) + } + out = append(out, eventLine("message_start", map[string]any{ + "type": "message_start", + "message": map[string]any{ + "id": "msg_" + strings.TrimPrefix(rid, "resp_"), "type": "message", "role": "assistant", + "model": t.model, "content": []any{}, + }, + })) + out = append(out, eventLine("content_block_start", map[string]any{ + "type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""}, + })) + } + case "response.output_text.delta": + delta, _ := m["delta"].(string) + if delta != "" { + out = append(out, eventLine("content_block_delta", map[string]any{ + "type": "content_block_delta", "index": 0, "delta": map[string]any{"type": "text_delta", "text": delta}, + })) + } + case "response.completed": + out = append(out, eventLine("message_delta", map[string]any{ + "type": "message_delta", "delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil}, + })) + out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": 0})) + out = append(out, eventLine("message_stop", map[string]any{"type": "message_stop"})) + } + return joinLines(out) +} + +// --------------------------------------------------------------------------- +// Messages → Responses + +type messagesToResponses struct { + sseState + model string + usage any + done bool +} + +func newMessagesToResponses() *messagesToResponses { return &messagesToResponses{} } + +func (t *messagesToResponses) line(line []byte) []byte { + isData, data, done := t.parseLine(line) + if !isData || done { + return nil + } + m := eventData(data) + evt, _ := m["type"].(string) + if msg, ok := m["message"].(map[string]any); ok { + if t.model == "" { + t.model, _ = msg["model"].(string) + } + if u, ok := msg["usage"]; ok { + t.usage = u + } + } + if u, ok := m["usage"]; ok { + t.usage = u + } + var out [][]byte + switch evt { + case "message_start": + id, _ := m["message"].(map[string]any) + rid := "" + if id != nil { + rid, _ = id["id"].(string) + } + out = append(out, eventLine("response.created", map[string]any{ + "type": "response.created", + "response": map[string]any{ + "id": "resp_" + strings.TrimPrefix(rid, "msg_"), "object": "response", "model": t.model, "status": "in_progress", + }, + })) + case "content_block_delta": + delta, _ := m["delta"].(map[string]any) + text, _ := delta["text"].(string) + if text != "" { + out = append(out, eventLine("response.output_text.delta", map[string]any{ + "type": "response.output_text.delta", "delta": text, "item_id": "msg_1", "output_index": 0, "content_index": 0, + })) + } + case "message_stop": + if !t.done { + t.done = true + out = append(out, eventLine("response.completed", map[string]any{ + "type": "response.completed", + "response": map[string]any{ + "id": "resp_stream", "object": "response", "model": t.model, "status": "completed", "usage": t.usage, + }, + })) + } + } + return joinLines(out) +} + +// --------------------------------------------------------------------------- +// Responses → Chat + +type responsesToChat struct { + sseState + id, model string +} + +func newResponsesToChat() *responsesToChat { return &responsesToChat{} } + +func (t *responsesToChat) line(line []byte) []byte { + isData, data, done := t.parseLine(line) + if !isData { + return nil + } + if done { + return nil + } + m := eventData(data) + evt, _ := m["type"].(string) + if resp, ok := m["response"].(map[string]any); ok { + if t.model == "" { + t.model, _ = resp["model"].(string) + } + if t.id == "" { + t.id, _ = resp["id"].(string) + } + } + var out [][]byte + switch evt { + case "response.created": + out = append(out, dataLine(map[string]any{ + "id": "chatcmpl-" + strings.TrimPrefix(t.id, "resp_"), "object": "chat.completion.chunk", "model": t.model, + "choices": []any{map[string]any{"index": 0, "delta": map[string]any{"role": "assistant"}, "finish_reason": nil}}, + })) + case "response.output_text.delta": + delta, _ := m["delta"].(string) + if delta != "" { + out = append(out, dataLine(map[string]any{ + "id": "chatcmpl-" + strings.TrimPrefix(t.id, "resp_"), "object": "chat.completion.chunk", "model": t.model, + "choices": []any{map[string]any{"index": 0, "delta": map[string]any{"content": delta}, "finish_reason": nil}}, + })) + } + case "response.completed": + out = append(out, dataLine(map[string]any{ + "id": "chatcmpl-" + strings.TrimPrefix(t.id, "resp_"), "object": "chat.completion.chunk", "model": t.model, + "choices": []any{map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": "stop"}}, + })) + if u, ok := m["response"].(map[string]any); ok { + if usage, ok := u["usage"]; ok { + out = append(out, dataLine(map[string]any{ + "id": "chatcmpl-" + strings.TrimPrefix(t.id, "resp_"), "object": "chat.completion.chunk", "model": t.model, + "choices": []any{}, "usage": usage, + })) + } + } + out = append(out, []byte("data: [DONE]\n\n")) + } + return joinLines(out) +} + +// --------------------------------------------------------------------------- +// Chat → Responses + +type chatToResponses struct { + sseState + model string + usage any + done bool +} + +func newChatToResponses() *chatToResponses { return &chatToResponses{} } + +func (t *chatToResponses) line(line []byte) []byte { + isData, data, done := t.parseLine(line) + if !isData { + return nil + } + if done { + if !t.done { + t.done = true + return eventLine("response.completed", map[string]any{ + "type": "response.completed", + "response": map[string]any{ + "id": "resp_stream", "object": "response", "model": t.model, "status": "completed", "usage": t.usage, + }, + }) + } + return nil + } + m := eventData(data) + if t.model == "" { + t.model, _ = m["model"].(string) + } + if u, ok := m["usage"]; ok { + t.usage = u + } + delta := map[string]any{} + var finish string + if choices, ok := m["choices"].([]any); ok && len(choices) > 0 { + if c0, ok := choices[0].(map[string]any); ok { + if d, ok := c0["delta"].(map[string]any); ok { + delta = d + } + finish, _ = c0["finish_reason"].(string) + } + } + var out [][]byte + if role, _ := delta["role"].(string); role == "assistant" { + out = append(out, eventLine("response.created", map[string]any{ + "type": "response.created", + "response": map[string]any{"id": "resp_stream", "object": "response", "model": t.model, "status": "in_progress"}, + })) + } + if content, _ := delta["content"].(string); content != "" { + out = append(out, eventLine("response.output_text.delta", map[string]any{ + "type": "response.output_text.delta", "delta": content, "item_id": "msg_1", "output_index": 0, "content_index": 0, + })) + } + if finish != "" && !t.done { + t.done = true + out = append(out, eventLine("response.completed", map[string]any{ + "type": "response.completed", + "response": map[string]any{ + "id": "resp_stream", "object": "response", "model": t.model, "status": "completed", "usage": t.usage, + }, + })) + } + return joinLines(out) +} diff --git a/server/internal/proxy/gateway.go b/server/internal/proxy/gateway.go index a13327d..e47017e 100644 --- a/server/internal/proxy/gateway.go +++ b/server/internal/proxy/gateway.go @@ -1,5 +1,5 @@ -// Package proxy API 网关核心:代理 /v1/chat/completions、/v1/responses、/v1/models。 -// M1:对 OpenAI 渠道直通(passthrough),不转格式;M3 起加入协议转换。 +// Package proxy API 网关核心:代理 /v1/chat/completions、/v1/responses、/v1/messages、/v1/models。 +// M1 直通 OpenAI 渠道;M4 起按客户端协议 × 渠道协议自动转换(见 convert)。 package proxy import ( @@ -12,6 +12,7 @@ import ( "github.com/openteam/server/internal/channel" "github.com/openteam/server/internal/pkg/apikey" "github.com/openteam/server/internal/pkg/crypto" + "github.com/openteam/server/internal/proxy/convert" "github.com/openteam/server/internal/store" "github.com/openteam/server/internal/usage" "gorm.io/gorm" @@ -43,29 +44,39 @@ func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder) *Gatewa // Auth 代理鉴权中间件:Bearer sk-xxx → 哈希查表 → 校验状态/过期/模型白名单。 func (g *Gateway) Auth(c *gin.Context) { + // 先按路径确定客户端协议,保证 Auth 阶段错误也按协议格式返回 + switch c.Request.URL.Path { + case "/v1/messages": + c.Set("protocol", convert.ProtoMessages) + case "/v1/responses": + c.Set("protocol", convert.ProtoResponses) + default: + c.Set("protocol", convert.ProtoChat) + } + auth := c.GetHeader("Authorization") key := strings.TrimPrefix(auth, "Bearer ") key = strings.TrimSpace(key) if !apikey.Valid(key) { - openAIError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key format. Expected: Bearer sk-...") + apiError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key format. Expected: Bearer sk-...") c.Abort() return } hash := apikey.Hash(key) var k store.APIKey if err := g.db.Where("key_hash = ? AND status = ?", hash, store.KeyStatusActive).First(&k).Error; err != nil { - openAIError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key") + apiError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key") c.Abort() return } var u store.User if err := g.db.First(&u, k.UserID).Error; err != nil || u.Status != store.UserStatusActive { - openAIError(c, http.StatusForbidden, "user_disabled", "User account is disabled") + apiError(c, http.StatusForbidden, "user_disabled", "User account is disabled") c.Abort() return } if k.ExpiresAt != nil && time.Now().After(*k.ExpiresAt) { - openAIError(c, http.StatusUnauthorized, "key_expired", "API key has expired") + apiError(c, http.StatusUnauthorized, "key_expired", "API key has expired") c.Abort() return } @@ -84,10 +95,12 @@ func (g *Gateway) Handle(c *gin.Context) { g.chatCompletions(c) case c.Request.URL.Path == "/v1/responses": g.responses(c) + case c.Request.URL.Path == "/v1/messages": + g.messages(c) case c.Request.URL.Path == "/v1/models" && c.Request.Method == http.MethodGet: g.models(c) default: - openAIError(c, http.StatusNotFound, "not_found", "Unknown endpoint: "+c.Request.URL.Path) + apiError(c, http.StatusNotFound, "not_found", "Unknown endpoint: "+c.Request.URL.Path) } } @@ -95,7 +108,7 @@ func (g *Gateway) Handle(c *gin.Context) { func (g *Gateway) models(c *gin.Context) { var ms []store.Model if err := g.db.Where("enabled = ?", true).Order("sort ASC, id ASC").Find(&ms).Error; err != nil { - openAIError(c, http.StatusInternalServerError, "internal_error", "failed to load models") + apiError(c, http.StatusInternalServerError, "internal_error", "failed to load models") return } data := make([]gin.H, 0, len(ms)) @@ -110,12 +123,22 @@ func (g *Gateway) models(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"object": "list", "data": data}) } +// selectChannel 选渠道:优先按模型绑定解析,退化为全局选渠道。 +func (g *Gateway) selectChannel(c *gin.Context, model string) (*store.Channel, error) { + if model != "" { + if ch, _, err := g.ch.ResolveModel(model); err == nil { + return ch, nil + } + } + return g.ch.Select() +} + // resolveUser 取当前用户(含余额)。 func (g *Gateway) resolveUser(c *gin.Context) (*store.User, bool) { uid, _ := c.Get(CtxUserID) var u store.User if err := g.db.First(&u, uid).Error; err != nil { - openAIError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key") + apiError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key") return nil, false } return &u, true @@ -124,10 +147,63 @@ func (g *Gateway) resolveUser(c *gin.Context) (*store.User, bool) { // checkBalance 余额不足返回 402(PLANNING §4.4.3)。 func (g *Gateway) checkBalance(c *gin.Context, u *store.User) bool { if u.Balance <= 0 { - openAIError(c, http.StatusPaymentRequired, "insufficient_balance", "Insufficient balance. Please recharge or contact admin.") + apiError(c, http.StatusPaymentRequired, "insufficient_balance", "Insufficient balance. Please recharge or contact admin.") return false } return true } +// --------------------------------------------------------------------------- +// 协议分派 + +// upstreamProtoFor 根据渠道 provider 与客户端协议确定上游协议与路径。 +func upstreamProtoFor(provider, clientProto string) string { + switch provider { + case store.ChannelProviderAnthropic: + return convert.ProtoMessages + case store.ChannelProviderOpenAI: + if clientProto == convert.ProtoMessages { + return convert.ProtoChat + } + return clientProto + default: // compatible:假定 OpenAI Chat 形状 + return convert.ProtoChat + } +} + +func upstreamPath(proto string) string { + switch proto { + case convert.ProtoMessages: + return "/v1/messages" + case convert.ProtoResponses: + return "/v1/responses" + default: + return "/v1/chat/completions" + } +} + +// upstreamPlan 描述一次代理请求的上游访问方式。 +type upstreamPlan struct { + path string // 上游路径 + body []byte // 已转换的请求体 + lineConv func([]byte) []byte // 流式逐行转换(nil=直通) + bodyConv func([]byte) ([]byte, error) // 非流式响应体转换(nil=直通) +} + +// prepareUpstream 计算上游访问计划:协议匹配直通,否则转换。 +func prepareUpstream(provider, clientProto string, body []byte) (*upstreamPlan, error) { + up := upstreamProtoFor(provider, clientProto) + plan := &upstreamPlan{path: upstreamPath(up), body: body} + if up != clientProto { + converted, err := convert.ConvertRequest(body, clientProto, up) + if err != nil { + return nil, err + } + plan.body = converted + plan.lineConv = convert.NewStreamTransformer(up, clientProto) + plan.bodyConv = func(b []byte) ([]byte, error) { return convert.ConvertResponse(b, up, clientProto) } + } + return plan, nil +} + var errNoChannel = errors.New("no available channel") diff --git a/server/internal/proxy/handlers.go b/server/internal/proxy/handlers.go index 76e2b61..f5e7e4a 100644 --- a/server/internal/proxy/handlers.go +++ b/server/internal/proxy/handlers.go @@ -1,11 +1,10 @@ package proxy import ( - "encoding/json" "net/http" "github.com/gin-gonic/gin" - "github.com/openteam/server/internal/store" + "github.com/openteam/server/internal/proxy/convert" ) // chatCompletions POST /v1/chat/completions @@ -17,27 +16,28 @@ func (g *Gateway) chatCompletions(c *gin.Context) { if !g.checkBalance(c, u) { return } - br, body, err := parseBody(c) if err != nil { - openAIError(c, http.StatusBadRequest, "invalid_request_error", "failed to read request body") + apiError(c, http.StatusBadRequest, "invalid_request_error", "failed to read request body") return } - c.Set("protocol", "chat") + c.Set("protocol", convert.ProtoChat) c.Set("model_name", br.Model) - ch, err := g.ch.Select() + ch, err := g.selectChannel(c, br.Model) if err != nil { - openAIError(c, http.StatusServiceUnavailable, "no_channel", "No available upstream channel") + apiError(c, http.StatusServiceUnavailable, "no_channel", "No available upstream channel") g.recordError(c, nil, nil, now(), "no_channel") return } - + plan, err := prepareUpstream(ch.Provider, convert.ProtoChat, body) + if err != nil { + apiError(c, http.StatusInternalServerError, "conversion_error", "Failed to convert request: "+err.Error()) + return + } sink := &usageSink{} c.Set("usage_raw", &sinkHolder{sink: sink}) - g.doPassthrough(c, ch, "/v1/chat/completions", body, br.Stream, func(raw json.RawMessage) { - sink.push(raw) - }) + g.doProxy(c, ch, plan, br.Stream, sink) } // responses POST /v1/responses(OpenAI Responses API) @@ -49,33 +49,61 @@ func (g *Gateway) responses(c *gin.Context) { if !g.checkBalance(c, u) { return } - br, body, err := parseBody(c) if err != nil { - openAIError(c, http.StatusBadRequest, "invalid_request_error", "failed to read request body") + apiError(c, http.StatusBadRequest, "invalid_request_error", "failed to read request body") return } - c.Set("protocol", "responses") + c.Set("protocol", convert.ProtoResponses) c.Set("model_name", br.Model) - ch, err := g.ch.Select() + ch, err := g.selectChannel(c, br.Model) if err != nil { - openAIError(c, http.StatusServiceUnavailable, "no_channel", "No available upstream channel") + apiError(c, http.StatusServiceUnavailable, "no_channel", "No available upstream channel") g.recordError(c, nil, nil, now(), "no_channel") return } - // M1 仅支持 OpenAI 原生渠道直通;Anthropic 渠道的转换在 M3 - if ch.Provider != store.ChannelProviderOpenAI { - openAIError(c, http.StatusNotImplemented, "conversion_pending", - "Responses protocol on this channel requires format conversion (planned in M3)") + plan, err := prepareUpstream(ch.Provider, convert.ProtoResponses, body) + if err != nil { + apiError(c, http.StatusInternalServerError, "conversion_error", "Failed to convert request: "+err.Error()) return } - sink := &usageSink{} c.Set("usage_raw", &sinkHolder{sink: sink}) - g.doPassthrough(c, ch, "/v1/responses", body, br.Stream, func(raw json.RawMessage) { - sink.push(raw) - }) + g.doProxy(c, ch, plan, br.Stream, sink) +} + +// messages POST /v1/messages(Anthropic Messages API) +func (g *Gateway) messages(c *gin.Context) { + u, ok := g.resolveUser(c) + if !ok { + return + } + if !g.checkBalance(c, u) { + return + } + br, body, err := parseBody(c) + if err != nil { + apiError(c, http.StatusBadRequest, "invalid_request_error", "failed to read request body") + return + } + c.Set("protocol", convert.ProtoMessages) + c.Set("model_name", br.Model) + + ch, err := g.selectChannel(c, br.Model) + if err != nil { + apiError(c, http.StatusServiceUnavailable, "no_channel", "No available upstream channel") + g.recordError(c, nil, nil, now(), "no_channel") + return + } + plan, err := prepareUpstream(ch.Provider, convert.ProtoMessages, body) + if err != nil { + apiError(c, http.StatusInternalServerError, "conversion_error", "Failed to convert request: "+err.Error()) + return + } + sink := &usageSink{} + c.Set("usage_raw", &sinkHolder{sink: sink}) + g.doProxy(c, ch, plan, br.Stream, sink) } // usageSinkHolder 桥接:gin context 里保存 sink 引用,供 finishUsage 读取最终 usage。 @@ -83,8 +111,17 @@ type sinkHolder struct { sink *usageSink } -// openAIError 按 OpenAI 错误格式返回(PLANNING §4.1.4)。 -func openAIError(c *gin.Context, status int, code, message string) { +// apiError 按客户端协议返回错误体(PLANNING §5.1.4)。 +func apiError(c *gin.Context, status int, code, message string) { + if p, _ := c.Get("protocol"); p == convert.ProtoMessages { + // Anthropic 格式 + c.AbortWithStatusJSON(status, gin.H{ + "type": "error", + "error": gin.H{"type": errorTypeFor(status), "message": message}, + }) + return + } + // OpenAI 格式 c.AbortWithStatusJSON(status, gin.H{ "error": gin.H{ "message": message, @@ -99,14 +136,10 @@ func errorTypeFor(status int) string { switch status { case http.StatusUnauthorized: return "authentication_error" - case http.StatusForbidden: + case http.StatusForbidden, http.StatusPaymentRequired: return "permission_error" - case http.StatusNotFound: + case http.StatusNotFound, http.StatusBadRequest: return "invalid_request_error" - case http.StatusBadRequest: - return "invalid_request_error" - case http.StatusPaymentRequired: - return "insufficient_quota" case http.StatusTooManyRequests: return "rate_limit_error" default: diff --git a/server/internal/proxy/passthrough.go b/server/internal/proxy/passthrough.go index 80b7815..70dc36b 100644 --- a/server/internal/proxy/passthrough.go +++ b/server/internal/proxy/passthrough.go @@ -43,24 +43,22 @@ func parseBody(c *gin.Context) (*bodyReq, []byte, error) { return br, body, nil } -// upstreamURL 组装上游地址:base_url + 客户端路径(/v1/chat/completions 等)。 +// upstreamURL 组装上游地址:base_url + 路径。 func upstreamURL(ch *store.Channel, path string) string { - base := strings.TrimRight(ch.BaseURL, "/") - return base + path + return strings.TrimRight(ch.BaseURL, "/") + path } -// doPassthrough 通用直通:替换 Authorization 为渠道密钥,转发请求。 -// convert 回调用于改写请求体(M1 直通为原样;M3 转换时改写)。 -func (g *Gateway) doPassthrough(c *gin.Context, ch *store.Channel, path string, body []byte, stream bool, outUsage func(usageRaw json.RawMessage)) { +// doProxy 通用代理:替换 Authorization 为渠道密钥,转发请求;按 plan 决定路径与转换。 +func (g *Gateway) doProxy(c *gin.Context, ch *store.Channel, plan *upstreamPlan, stream bool, sink *usageSink) { upKey, err := g.ch.UpstreamKey(ch) if err != nil { - openAIError(c, http.StatusInternalServerError, "channel_error", "failed to decrypt channel key") + apiError(c, http.StatusInternalServerError, "channel_error", "failed to decrypt channel key") return } - upBody := body - // 流式 chat:注入 stream_options.include_usage,保证末块带 usage(OpenAI 行为) - if stream && path == "/v1/chat/completions" && !bytes.Contains(upBody, []byte(`"include_usage"`)) { + upBody := plan.body + // 直通 chat 流式:注入 stream_options.include_usage,保证末块带 usage(OpenAI 行为) + if stream && plan.path == "/v1/chat/completions" && plan.lineConv == nil && !bytes.Contains(upBody, []byte(`"include_usage"`)) { var m map[string]any if json.Unmarshal(upBody, &m) == nil { m["stream_options"] = map[string]any{"include_usage": true} @@ -72,9 +70,9 @@ func (g *Gateway) doPassthrough(c *gin.Context, ch *store.Channel, path string, ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(ch.TimeoutMS)*time.Millisecond) defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL(ch, path), bytes.NewReader(upBody)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL(ch, plan.path), bytes.NewReader(upBody)) if err != nil { - openAIError(c, http.StatusInternalServerError, "internal_error", "failed to build upstream request") + apiError(c, http.StatusInternalServerError, "internal_error", "failed to build upstream request") return } req.Header.Set("Content-Type", "application/json") @@ -83,6 +81,9 @@ func (g *Gateway) doPassthrough(c *gin.Context, ch *store.Channel, path string, if ua := c.GetHeader("User-Agent"); ua != "" { req.Header.Set("User-Agent", ua) } + if plan.path == "/v1/messages" { + req.Header.Set("anthropic-version", "2023-06-01") + } // 透传 OpenAI 生态请求头(组织/项目等) for _, h := range []string{"OpenAI-Organization", "OpenAI-Project", "OpenAI-Beta"} { if v := c.GetHeader(h); v != "" { @@ -99,55 +100,57 @@ func (g *Gateway) doPassthrough(c *gin.Context, ch *store.Channel, path string, status = http.StatusGatewayTimeout msg = "Upstream request timed out" } - openAIError(c, status, "upstream_error", msg) + apiError(c, status, "upstream_error", msg) g.recordError(c, ch, nil, start, "upstream_error") return } defer resp.Body.Close() - // 非 2xx:透传上游错误体(OpenAI 格式),并记录 error 用量 + // 非 2xx:透传上游错误体,并记录 error 用量 if resp.StatusCode < 200 || resp.StatusCode >= 300 { errBody, _ := io.ReadAll(resp.Body) status := resp.StatusCode - // 上游 5xx → 网关 502/504(重试逻辑 M4) if status >= 500 { status = http.StatusBadGateway } - c.DataFromReader(status, int64(len(errBody)), "application/json", bytes.NewReader(errBody), nil) c.Header("Content-Type", "application/json") + c.DataFromReader(status, int64(len(errBody)), "application/json", bytes.NewReader(errBody), nil) g.recordError(c, ch, resp, start, "upstream_http_"+strconv.Itoa(resp.StatusCode)) return } - // 成功响应 c.Header("Content-Type", resp.Header.Get("Content-Type")) c.Status(http.StatusOK) if stream { - g.streamCopy(c, ch, resp.Body, start, outUsage) + g.streamCopy(c, ch, resp.Body, start, plan.lineConv, sink) } else { - g.copyAndCapture(c, ch, resp.Body, start, outUsage) + g.copyAndCapture(c, ch, resp.Body, start, plan.bodyConv, sink) } } -// copyAndCapture 非流式:整体转发 + 解析 usage + 记账。 -func (g *Gateway) copyAndCapture(c *gin.Context, ch *store.Channel, r io.Reader, start time.Time, outUsage func(json.RawMessage)) { +// copyAndCapture 非流式:整体转发(可转换)+ 解析 usage + 记账。 +func (g *Gateway) copyAndCapture(c *gin.Context, ch *store.Channel, r io.Reader, start time.Time, bodyConv func([]byte) ([]byte, error), sink *usageSink) { data, err := io.ReadAll(r) if err != nil { - openAIError(c, http.StatusBadGateway, "upstream_error", "failed reading upstream response") + apiError(c, http.StatusBadGateway, "upstream_error", "failed reading upstream response") g.recordError(c, ch, nil, start, "read_error") return } - // 尝试解析 usage(chat / responses 字段不同) - if usageRaw := extractUsage(data); usageRaw != nil { - outUsage(usageRaw) + if usageRaw := extractUsage(data); usageRaw != nil && sink != nil { + sink.push(usageRaw) } - _, _ = c.Writer.Write(data) + out := data + if bodyConv != nil { + if converted, cerr := bodyConv(data); cerr == nil { + out = converted + } + } + _, _ = c.Writer.Write(out) g.finishUsage(c, ch, start, store.UsageStatusSuccess, "") } -// streamCopy 流式:边读上游 SSE 边写客户端,零缓冲转发;扫描 usage 行记账。 -// 客户端断连(ctx cancel)即中止上游读取。 -func (g *Gateway) streamCopy(c *gin.Context, ch *store.Channel, r io.Reader, start time.Time, outUsage func(json.RawMessage)) { +// streamCopy 流式:边读上游 SSE 边写客户端,零缓冲转发;按 lineConv 转换;扫描 usage 记账。 +func (g *Gateway) streamCopy(c *gin.Context, ch *store.Channel, r io.Reader, start time.Time, lineConv func([]byte) []byte, sink *usageSink) { w := c.Writer flusher, ok := w.(http.Flusher) if !ok { @@ -158,19 +161,26 @@ func (g *Gateway) streamCopy(c *gin.Context, ch *store.Channel, r io.Reader, sta for { line, err := scanner.Next() if line != nil { - if _, werr := w.Write(line); werr != nil { - // 客户端断开:取消上游(ctx cancel 由 request ctx 处理) - g.recordError(c, ch, nil, start, "client_disconnect") - return + out := line + if lineConv != nil { + out = lineConv(line) } - flusher.Flush() - if usageRaw := scanUsage(line); usageRaw != nil { - outUsage(usageRaw) + if out != nil { + if _, werr := w.Write(out); werr != nil { + g.recordError(c, ch, nil, start, "client_disconnect") + return + } + flusher.Flush() + } + if usageRaw := scanUsage(line); usageRaw != nil && sink != nil { + sink.push(usageRaw) } } if err != nil { if err == io.EOF { g.finishUsage(c, ch, start, store.UsageStatusSuccess, "") + } else if c.Request.Context().Err() != nil { + g.recordError(c, ch, nil, start, "client_disconnect") } else { g.recordError(c, ch, nil, start, "stream_read_error") } @@ -186,37 +196,34 @@ func (nopFlusher) Flush() {} // --------------------------------------------------------------------------- // usage 提取 -// usageShape 兼容 chat (prompt/completion) 与 responses (input/output) 两种命名。 +// usageShape 兼容 chat (prompt/completion)、responses (input/output)、messages (input/output) 命名。 type usageShape struct { - PromptTokens int64 `json:"prompt_tokens"` - CompletionTokens int64 `json:"completion_tokens"` - InputTokens int64 `json:"input_tokens"` - OutputTokens int64 `json:"output_tokens"` - TotalTokens int64 `json:"total_tokens"` - // Claude 缓存口径(M3 接入) - CacheReadInputTokens int64 `json:"cache_read_input_tokens"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + TotalTokens int64 `json:"total_tokens"` + CacheReadInputTokens int64 `json:"cache_read_input_tokens"` CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"` } -// extractUsage 从完整响应体提取 usage 子对象。 +// extractUsage 从完整响应体提取 usage 子对象(chat / responses / messages)。 func extractUsage(data []byte) json.RawMessage { var m map[string]json.RawMessage if json.Unmarshal(data, &m) != nil { return nil } - if u, ok := m["usage"]; ok && string(u) != "null" { + if u := usageFromMap(m); u != nil { return u } - // responses 事件/响应:usage 嵌套在 response 对象内 if respRaw, ok := m["response"]; ok { var resp map[string]json.RawMessage if json.Unmarshal(respRaw, &resp) == nil { - if u, ok := resp["usage"]; ok && string(u) != "null" { + if u := usageFromMap(resp); u != nil { return u } } } - // chat 兜底:choices[].message.usage if choices, ok := m["choices"]; ok { var cs []map[string]json.RawMessage if json.Unmarshal(choices, &cs) == nil { @@ -224,7 +231,7 @@ func extractUsage(data []byte) json.RawMessage { if msgRaw, ok := ch["message"]; ok { var msg map[string]json.RawMessage if json.Unmarshal(msgRaw, &msg) == nil { - if u, ok := msg["usage"]; ok && string(u) != "null" { + if u := usageFromMap(msg); u != nil { return u } } @@ -235,7 +242,7 @@ func extractUsage(data []byte) json.RawMessage { return nil } -// scanUsage 从 SSE 一行中提取 usage(OpenAI 末块 / responses completed 事件)。 +// scanUsage 从 SSE 一行中提取 usage(OpenAI 末块 / responses completed / messages message_delta 等)。 func scanUsage(line []byte) json.RawMessage { s := string(line) if !strings.Contains(s, `"usage"`) { @@ -252,14 +259,29 @@ func scanUsage(line []byte) json.RawMessage { if json.Unmarshal([]byte(s), &m) != nil { return nil } - if u, ok := m["usage"]; ok && string(u) != "null" { + if u := usageFromMap(m); u != nil { return u } - // responses 流式:usage 在 response 对象内(response.completed 事件) if respRaw, ok := m["response"]; ok { var resp map[string]json.RawMessage if json.Unmarshal(respRaw, &resp) == nil { - if u, ok := resp["usage"]; ok && string(u) != "null" { + if u := usageFromMap(resp); u != nil { + return u + } + } + } + return nil +} + +// usageFromMap 从 map 顶层或 message 子对象中取 usage。 +func usageFromMap(m map[string]json.RawMessage) json.RawMessage { + if u, ok := m["usage"]; ok && string(u) != "null" { + return u + } + if msgRaw, ok := m["message"]; ok { + var msg map[string]json.RawMessage + if json.Unmarshal(msgRaw, &msg) == nil { + if u, ok := msg["usage"]; ok && string(u) != "null" { return u } } @@ -268,7 +290,6 @@ func scanUsage(line []byte) json.RawMessage { } // sseScanner 按 SSE 行边界读取(兼容 \n 与 \r\n),保留原始行内容。 -// 基于 bufio.Reader:行内可含任意内容,跨 chunk 自动拼接。 type sseScanner struct { r *bufio.Reader } @@ -289,17 +310,43 @@ func (s *sseScanner) Next() ([]byte, error) { // --------------------------------------------------------------------------- // 记账 -// usageSink 累积流式多次 usage(取最后一次,即最终值)。 +// usageSink 累积多次 usage:合并各事件字段(message_start 给 input,message_delta 给 output)。 type usageSink struct { - last json.RawMessage + us usageShape } func (u *usageSink) push(raw json.RawMessage) { - if len(raw) > 0 { - u.last = raw + if len(raw) == 0 { + return + } + var t usageShape + if json.Unmarshal(raw, &t) != nil { + return + } + // 零值不覆盖:不同事件携带不同字段 + if t.PromptTokens > 0 { + u.us.PromptTokens = t.PromptTokens + } + if t.CompletionTokens > 0 { + u.us.CompletionTokens = t.CompletionTokens + } + if t.InputTokens > 0 { + u.us.InputTokens = t.InputTokens + } + if t.OutputTokens > 0 { + u.us.OutputTokens = t.OutputTokens + } + if t.CacheReadInputTokens > 0 { + u.us.CacheReadInputTokens = t.CacheReadInputTokens + } + if t.CacheCreationInputTokens > 0 { + u.us.CacheCreationInputTokens = t.CacheCreationInputTokens } } +// Shape 返回合并后的用量。 +func (u *usageSink) Shape() usageShape { return u.us } + // finishUsage 落账:计算成本并异步写入。 func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time, status, errCode string) { uid, _ := c.Get(CtxUserID) @@ -308,8 +355,8 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time var us usageShape if h, ok := c.Get("usage_raw"); ok { - if holder, ok := h.(*sinkHolder); ok && holder.sink != nil && len(holder.sink.last) > 0 { - _ = json.Unmarshal(holder.sink.last, &us) + if holder, ok := h.(*sinkHolder); ok && holder.sink != nil { + us = holder.sink.Shape() } } @@ -340,43 +387,57 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time p = "chat" } traceStr, _ := trace.(string) - errMsg := errCode latency := int(time.Since(start).Milliseconds()) - // 已写响应头但流中途出错:记 error if status == store.UsageStatusSuccess && c.Writer.Status() >= 400 { status = store.UsageStatusError } + var errCodePtr *string + if errCode != "" { + errCodePtr = &errCode + } + + var uidVal, kidVal uint64 + if u, ok := uid.(uint64); ok { + uidVal = u + } + if k, ok := kid.(uint64); ok { + kidVal = k + } + var chID uint64 + if ch != nil { + chID = ch.ID + } + g.rec.Record(&store.UsageLog{ - RequestID: fmt.Sprintf("trace-%s", traceStr), - TraceID: traceStr, - UserID: uid.(uint64), - KeyID: kid.(uint64), - ChannelID: ch.ID, - ModelID: modelID, - ModelName: mn, - Protocol: p, - InputTokens: in, - OutputTokens: out, - CacheReadTokens: cacheRead, + RequestID: fmt.Sprintf("trace-%s", traceStr), + TraceID: traceStr, + UserID: uidVal, + KeyID: kidVal, + ChannelID: chID, + ModelID: modelID, + ModelName: mn, + Protocol: p, + InputTokens: in, + OutputTokens: out, + CacheReadTokens: cacheRead, CacheCreationTokens: cacheCreate, - InputPrice: model.InputPrice, - OutputPrice: model.OutputPrice, - CacheReadPrice: model.CacheReadPrice, - Cost: cost, - LatencyMS: latency, - Status: status, - ErrorCode: &errMsg, - CreatedAt: time.Now().UTC(), + InputPrice: model.InputPrice, + OutputPrice: model.OutputPrice, + CacheReadPrice: model.CacheReadPrice, + Cost: cost, + LatencyMS: latency, + Status: status, + ErrorCode: errCodePtr, + CreatedAt: time.Now().UTC(), }) } // recordError 失败请求的记账(不产生扣费,status=error)。 func (g *Gateway) recordError(c *gin.Context, ch *store.Channel, resp *http.Response, start time.Time, code string) { - status := store.UsageStatusError _ = resp - g.finishUsage(c, ch, start, status, code) + g.finishUsage(c, ch, start, store.UsageStatusError, code) } func now() time.Time { return time.Now() } diff --git a/server/internal/proxy/stream_test.go b/server/internal/proxy/stream_test.go index 818c69f..62fdb2c 100644 --- a/server/internal/proxy/stream_test.go +++ b/server/internal/proxy/stream_test.go @@ -3,105 +3,123 @@ package proxy import ( "encoding/json" "io" + "strings" "testing" ) -func TestScanUsageChat(t *testing.T) { - line := []byte(`data: {"id":"x","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":9,"total_tokens":21}}`) - raw := scanUsage(line) +func TestSSEScannerSplitsLines(t *testing.T) { + input := "event: message\ndata: {\"a\":1}\n\n" + + "data: {\"b\":2}\r\n\r\n" + + "data: [DONE]\n\n" + s := newSSEScanner(strings.NewReader(input)) + var lines []string + for { + line, err := s.Next() + if line != nil { + lines = append(lines, string(line)) + } + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("Next: %v", err) + } + } + want := []string{ + "event: message\n", + "data: {\"a\":1}\n", + "\n", + "data: {\"b\":2}\r\n", + "\r\n", + "data: [DONE]\n", + "\n", + } + if len(lines) != len(want) { + t.Fatalf("line count = %d, want %d (lines: %q)", len(lines), len(want), lines) + } + for i := range want { + if lines[i] != want[i] { + t.Fatalf("line[%d] = %q, want %q", i, lines[i], want[i]) + } + } +} + +func TestScanUsageChatStream(t *testing.T) { + chunk := `data: {"id":"x","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":9,"total_tokens":21}}` + raw := scanUsage([]byte(chunk + "\n")) if raw == nil { - t.Fatal("chat usage not detected") + t.Fatal("expected usage extracted") } var us usageShape if err := json.Unmarshal(raw, &us); err != nil { - t.Fatal(err) + t.Fatalf("unmarshal: %v", err) } if us.PromptTokens != 12 || us.CompletionTokens != 9 { t.Fatalf("usage mismatch: %+v", us) } } -func TestScanUsageResponsesNested(t *testing.T) { - line := []byte(`data: {"response":{"id":"r","status":"completed","usage":{"input_tokens":15,"output_tokens":11,"total_tokens":26}},"type":"response.completed"}`) - raw := scanUsage(line) +func TestScanUsageResponsesCompleted(t *testing.T) { + line := `data: {"type":"response.completed","response":{"id":"r1","status":"completed","usage":{"input_tokens":15,"output_tokens":11}}}` + raw := scanUsage([]byte(line + "\n")) if raw == nil { - t.Fatal("responses nested usage not detected") + t.Fatal("expected usage extracted from response.completed") } var us usageShape - if err := json.Unmarshal(raw, &us); err != nil { - t.Fatal(err) - } + _ = json.Unmarshal(raw, &us) if us.InputTokens != 15 || us.OutputTokens != 11 { t.Fatalf("usage mismatch: %+v", us) } } -func TestScanUsageIgnoresNonData(t *testing.T) { - if scanUsage([]byte("event: response.completed")) != nil { - t.Fatal("event line should be ignored") +func TestScanUsageIgnoresNonUsage(t *testing.T) { + if raw := scanUsage([]byte(`data: {"type":"response.output_text.delta","delta":"hi"}`)); raw != nil { + t.Fatalf("expected nil for non-usage line, got %s", raw) } - if scanUsage([]byte("data: [DONE]")) != nil { - t.Fatal("[DONE] should be ignored") - } - if scanUsage([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}")) != nil { - t.Fatal("content chunk without usage should be ignored") + if raw := scanUsage([]byte(`data: [DONE]`)); raw != nil { + t.Fatal("expected nil for [DONE]") } } -func TestExtractUsageFromFullBody(t *testing.T) { - body := []byte(`{"id":"x","choices":[{"message":{"content":"hi"}}],"usage":{"prompt_tokens":1,"completion_tokens":2}}`) - raw := extractUsage(body) +func TestExtractUsageChatBody(t *testing.T) { + body := `{"id":"x","choices":[{"message":{"role":"assistant","content":"hi"}}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}` + raw := extractUsage([]byte(body)) if raw == nil { - t.Fatal("usage not extracted from full body") + t.Fatal("expected usage") + } + if !strings.Contains(string(raw), `"prompt_tokens":1`) { + t.Fatalf("unexpected usage: %s", raw) + } +} + +func TestExtractUsageResponsesNested(t *testing.T) { + // responses 顶层只有 response 对象,usage 嵌套其中 + body := `{"id":"r1","object":"response","status":"completed","response":{"usage":{"input_tokens":7,"output_tokens":8}}}` + raw := extractUsage([]byte(body)) + if raw == nil { + t.Fatal("expected nested usage") } var us usageShape _ = json.Unmarshal(raw, &us) - if us.PromptTokens != 1 || us.CompletionTokens != 2 { + if us.InputTokens != 7 || us.OutputTokens != 8 { t.Fatalf("usage mismatch: %+v", us) } } -func TestSSEScannerLines(t *testing.T) { - // 模拟分块写入的 SSE 流 - data := "data: {\"a\":1}\n\ndata: {\"usage\":{\"input_tokens\":3}}\n\n" - parts := [][]byte{[]byte(data[:10]), []byte(data[10:20]), []byte(data[20:])} - reader := newChunkReader(parts) - s := newSSEScanner(reader) - var lines [][]byte - for { - line, err := s.Next() - if line != nil { - lines = append(lines, line) - } - if err != nil { - break - } +func TestUsageSinkMergesFields(t *testing.T) { + // message_start 给 input,message_delta 给 output,合并后两者都在 + s := &usageSink{} + s.push(json.RawMessage(`{"input_tokens":14,"output_tokens":0}`)) + s.push(json.RawMessage(`{"output_tokens":10}`)) + got := s.Shape() + if got.InputTokens != 14 || got.OutputTokens != 10 { + t.Fatalf("merge mismatch: %+v", got) } - if len(lines) != 4 { - t.Fatalf("expected 4 lines, got %d", len(lines)) - } - // 合并后应能还原原始数据 - joined := "" - for _, l := range lines { - joined += string(l) - } - if joined != string(data) { - t.Fatalf("stream corrupted:\n got: %q\nwant: %q", joined, data) + // chat 末块同时携带两字段 + s2 := &usageSink{} + s2.push(json.RawMessage(`{"prompt_tokens":12,"completion_tokens":9}`)) + g := s2.Shape() + if g.PromptTokens != 12 || g.CompletionTokens != 9 { + t.Fatalf("chat usage mismatch: %+v", g) } } - -type chunkReader struct { - parts [][]byte - idx int -} - -func newChunkReader(parts [][]byte) *chunkReader { return &chunkReader{parts: parts} } - -func (r *chunkReader) Read(p []byte) (int, error) { - if r.idx >= len(r.parts) { - return 0, io.EOF - } - n := copy(p, r.parts[r.idx]) - r.idx++ - return n, nil -} diff --git a/server/internal/store/db.go b/server/internal/store/db.go index c00bd65..b7c8922 100644 --- a/server/internal/store/db.go +++ b/server/internal/store/db.go @@ -2,6 +2,9 @@ package store import ( "log" + "os" + "path/filepath" + "strings" "github.com/glebarez/sqlite" "gorm.io/gorm" @@ -16,6 +19,10 @@ func Open(driver, dsn string) (*gorm.DB, error) { case "postgres": dialector = postgresDialector(dsn) default: + // 确保 SQLite 文件所在目录存在 + if dir := sqliteDir(dsn); dir != "" { + _ = os.MkdirAll(dir, 0o755) + } dialector = sqlite.Open(dsn) } @@ -32,3 +39,18 @@ func Open(driver, dsn string) (*gorm.DB, error) { log.Printf("store: connected driver=%s (migrated)", driver) return db, nil } + +// sqliteDir 提取 SQLite DSN 中的目录部分(忽略 file: 前缀与查询参数)。 +func sqliteDir(dsn string) string { + d := dsn + if i := strings.IndexByte(d, '?'); i >= 0 { + d = d[:i] + } + if strings.HasPrefix(d, "file:") { + d = d[len("file:"):] + } + if d == "" || d == ":memory:" || strings.Contains(d, "::") { + return "" + } + return filepath.Dir(d) +} diff --git a/server/internal/store/models.go b/server/internal/store/models.go index 63808a1..a89c457 100644 --- a/server/internal/store/models.go +++ b/server/internal/store/models.go @@ -1,10 +1,8 @@ // Package store 数据模型与仓储层(GORM)。 -// 字段设计对应 PLANNING.md §5:金额/价格 numeric(20,8),token bigint,时间 UTC。 +// 字段设计对应 PLANNING.md §6:金额/价格 numeric(20,8),token bigint,时间 UTC。 package store -import ( - "time" -) +import "time" // 角色 / 状态枚举(字符串存库,便于阅读与迁移) const ( @@ -17,20 +15,20 @@ const ( KeyStatusActive = "active" KeyStatusRevoked = "revoked" - ChannelProviderOpenAI = "openai" - ChannelProviderAnthropic = "anthropic" - ChannelProviderCompatible = "compatible" - ChannelHealthHealthy = "healthy" - ChannelHealthDegraded = "degraded" - ChannelHealthCooldown = "cooldown" + ChannelProviderOpenAI = "openai" + ChannelProviderAnthropic = "anthropic" + ChannelProviderCompatible = "compatible" + ChannelHealthHealthy = "healthy" + ChannelHealthDegraded = "degraded" + ChannelHealthCooldown = "cooldown" UsageStatusSuccess = "success" UsageStatusError = "error" UsageStatusCanceled = "canceled" - BalanceTypeRecharge = "recharge" - BalanceTypeUsage = "usage" - BalanceTypeRefund = "refund" + BalanceTypeRecharge = "recharge" + BalanceTypeUsage = "usage" + BalanceTypeRefund = "refund" BalanceTypeAdminAdjust = "admin_adjust" RechargeStatusPending = "pending" @@ -40,7 +38,7 @@ const ( RechargeMethodOnline = "online" ) -// User 用户(PLANNING §5.1) +// User 用户(PLANNING §6.1) type User struct { ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` Username string `gorm:"uniqueIndex;size:64;not null" json:"username"` @@ -55,7 +53,7 @@ type User struct { UpdatedAt time.Time `json:"updated_at"` } -// APIKey 密钥(PLANNING §5.2):库中只存 SHA-256 哈希 + 展示前缀 +// APIKey 密钥(PLANNING §6.2):库中只存 SHA-256 哈希 + 展示前缀 type APIKey struct { ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` UserID uint64 `gorm:"index;not null" json:"user_id"` @@ -72,38 +70,38 @@ type APIKey struct { UpdatedAt time.Time `json:"updated_at"` } -// Channel 上游渠道(PLANNING §5.3) +// Channel 上游渠道(PLANNING §6.3) type Channel struct { - ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` - Name string `gorm:"uniqueIndex;size:64;not null" json:"name"` - Provider string `gorm:"size:16;not null" json:"provider"` // openai|anthropic|compatible - BaseURL string `gorm:"size:255;not null" json:"base_url"` - APIKeyEnc string `gorm:"size:1024;not null" json:"-"` // AES-GCM 密文 - Weight int `gorm:"not null;default:1" json:"weight"` - Priority int `gorm:"not null;default:0" json:"priority"` // 数值小优先 - TimeoutMS int `gorm:"not null;default:120000" json:"timeout_ms"` - MaxConcurrency int `gorm:"not null;default:16" json:"max_concurrency"` - HealthStatus string `gorm:"size:16;not null;default:healthy" json:"health_status"` - Enabled bool `gorm:"not null;default:true" json:"enabled"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + Name string `gorm:"uniqueIndex;size:64;not null" json:"name"` + Provider string `gorm:"size:16;not null" json:"provider"` // openai|anthropic|compatible + BaseURL string `gorm:"size:255;not null" json:"base_url"` + APIKeyEnc string `gorm:"size:1024;not null" json:"-"` // AES-GCM 密文 + Weight int `gorm:"not null;default:1" json:"weight"` + Priority int `gorm:"not null;default:0" json:"priority"` // 数值小优先 + TimeoutMS int `gorm:"not null;default:120000" json:"timeout_ms"` + MaxConcurrency int `gorm:"not null;default:16" json:"max_concurrency"` + HealthStatus string `gorm:"size:16;not null;default:healthy" json:"health_status"` + Enabled bool `gorm:"not null;default:true" json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } -// Model 全局模型 + 定价(PLANNING §5.4,价格按每百万 token,USD) +// Model 全局模型 + 定价(PLANNING §6.4,价格按每百万 token,USD) type Model struct { - ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` - Name string `gorm:"uniqueIndex;size:128;not null" json:"name"` - DisplayName string `gorm:"size:128" json:"display_name"` - InputPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"input_price"` - OutputPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"output_price"` - CacheReadPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"cache_read_price"` - Enabled bool `gorm:"not null;default:true" json:"enabled"` - Sort int `gorm:"not null;default:0" json:"sort"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + Name string `gorm:"uniqueIndex;size:128;not null" json:"name"` + DisplayName string `gorm:"size:128" json:"display_name"` + InputPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"input_price"` + OutputPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"output_price"` + CacheReadPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"cache_read_price"` + Enabled bool `gorm:"not null;default:true" json:"enabled"` + Sort int `gorm:"not null;default:0" json:"sort"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } -// ChannelModelBinding 渠道↔模型绑定(多对多,PLANNING §5.4) +// ChannelModelBinding 渠道↔模型绑定(多对多,PLANNING §6.4) type ChannelModelBinding struct { ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` ChannelID uint64 `gorm:"index:idx_ch_model,unique;not null" json:"channel_id"` @@ -114,60 +112,60 @@ type ChannelModelBinding struct { Model Model `gorm:"foreignKey:ModelID" json:"-"` } -// UsageLog 请求级用量明细(PLANNING §5.5) +// UsageLog 请求级用量明细(PLANNING §6.5) type UsageLog struct { - ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` - RequestID string `gorm:"size:128" json:"request_id"` // 上游 request id - TraceID string `gorm:"size:64;index" json:"trace_id"` - UserID uint64 `gorm:"index:idx_user_created;not null" json:"user_id"` - KeyID uint64 `json:"key_id"` - ChannelID uint64 `json:"channel_id"` - ModelID uint64 `json:"model_id"` - ModelName string `gorm:"size:128" json:"model_name"` - Protocol string `gorm:"size:32" json:"protocol"` // responses|chat|messages - InputTokens int64 `json:"input_tokens"` - OutputTokens int64 `json:"output_tokens"` - CacheReadTokens int64 `json:"cache_read_tokens"` - CacheCreationTokens int64 `json:"cache_creation_tokens"` - InputPrice float64 `gorm:"type:numeric(20,8)" json:"input_price"` // 快照 - OutputPrice float64 `gorm:"type:numeric(20,8)" json:"output_price"` // 快照 - CacheReadPrice float64 `gorm:"type:numeric(20,8)" json:"cache_read_price"` // 快照 - Cost float64 `gorm:"type:numeric(20,8)" json:"cost"` - LatencyMS int `json:"latency_ms"` - Status string `gorm:"size:16;not null" json:"status"` - ErrorCode *string `json:"error_code,omitempty"` - CreatedAt time.Time `gorm:"index" json:"created_at"` + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + RequestID string `gorm:"size:128" json:"request_id"` // 上游 request id + TraceID string `gorm:"size:64;index" json:"trace_id"` + UserID uint64 `gorm:"index:idx_user_created;not null" json:"user_id"` + KeyID uint64 `json:"key_id"` + ChannelID uint64 `json:"channel_id"` + ModelID uint64 `json:"model_id"` + ModelName string `gorm:"size:128" json:"model_name"` + Protocol string `gorm:"size:32" json:"protocol"` // responses|chat|messages + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` + CacheCreationTokens int64 `json:"cache_creation_tokens"` + InputPrice float64 `gorm:"type:numeric(20,8)" json:"input_price"` // 快照 + OutputPrice float64 `gorm:"type:numeric(20,8)" json:"output_price"` // 快照 + CacheReadPrice float64 `gorm:"type:numeric(20,8)" json:"cache_read_price"` // 快照 + Cost float64 `gorm:"type:numeric(20,8)" json:"cost"` + LatencyMS int `json:"latency_ms"` + Status string `gorm:"size:16;not null" json:"status"` + ErrorCode *string `json:"error_code,omitempty"` + CreatedAt time.Time `gorm:"index" json:"created_at"` } -// UsageDaily 日粒度预聚合(PLANNING §5.6) +// UsageDaily 日粒度预聚合(PLANNING §6.6) type UsageDaily struct { - ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` - UserID uint64 `gorm:"index:idx_daily_user_model,unique" json:"user_id"` - ModelID uint64 `gorm:"index:idx_daily_user_model,unique" json:"model_id"` - Date string `gorm:"size:10;index:idx_daily_user_model,unique" json:"date"` // YYYY-MM-DD (UTC) - Requests int64 `json:"requests"` - InputTokens int64 `json:"input_tokens"` - OutputTokens int64 `json:"output_tokens"` + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + UserID uint64 `gorm:"index:idx_daily_user_model,unique" json:"user_id"` + ModelID uint64 `gorm:"index:idx_daily_user_model,unique" json:"model_id"` + Date string `gorm:"size:10;index:idx_daily_user_model,unique" json:"date"` // YYYY-MM-DD (UTC) + Requests int64 `json:"requests"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` CacheReadTokens int64 `json:"cache_read_tokens"` - Cost float64 `gorm:"type:numeric(20,8)" json:"cost"` + Cost float64 `gorm:"type:numeric(20,8)" json:"cost"` } -// RechargeOrder 充值订单(PLANNING §5.7,预留:首版不做充值) +// RechargeOrder 充值订单(PLANNING §6.7,预留:首版不做充值) type RechargeOrder struct { - ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` - UserID uint64 `gorm:"index;not null" json:"user_id"` - Amount float64 `gorm:"type:numeric(20,8);not null" json:"amount"` - Status string `gorm:"size:16;not null;default:pending" json:"status"` - Method string `gorm:"size:16;not null;default:manual" json:"method"` - TransactionID string `gorm:"size:128" json:"transaction_id,omitempty"` - ReviewedBy *uint64 `json:"reviewed_by,omitempty"` - ReviewedAt *time.Time `json:"reviewed_at,omitempty"` - Remark string `gorm:"size:512" json:"remark,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + UserID uint64 `gorm:"index;not null" json:"user_id"` + Amount float64 `gorm:"type:numeric(20,8);not null" json:"amount"` + Status string `gorm:"size:16;not null;default:pending" json:"status"` + Method string `gorm:"size:16;not null;default:manual" json:"method"` + TransactionID string `gorm:"size:128" json:"transaction_id,omitempty"` + ReviewedBy *uint64 `json:"reviewed_by,omitempty"` + ReviewedAt *time.Time `json:"reviewed_at,omitempty"` + Remark string `gorm:"size:512" json:"remark,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } -// BalanceLog 余额流水(PLANNING §5.8,幂等:ref_id + type 唯一) +// BalanceLog 余额流水(PLANNING §6.8,幂等:ref_id + type 唯一) type BalanceLog struct { ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` UserID uint64 `gorm:"index:idx_balance_user;not null" json:"user_id"` @@ -179,7 +177,7 @@ type BalanceLog struct { CreatedAt time.Time `json:"created_at"` } -// SystemConfig 系统配置(PLANNING §5.9) +// SystemConfig 系统配置(PLANNING §6.9) type SystemConfig struct { Key string `gorm:"primaryKey;size:64" json:"key"` Value string `gorm:"type:jsonb;not null" json:"value"` diff --git a/server/internal/usage/usage.go b/server/internal/usage/usage.go index b38ff1b..c2cb62b 100644 --- a/server/internal/usage/usage.go +++ b/server/internal/usage/usage.go @@ -1,4 +1,5 @@ // Package usage 异步记账:请求完成后写入 usage_logs,批量落库(PLANNING §3.2)。 +// 每个请求在 flush 时同步完成:写明细 + 扣余额 + 写流水 + 日聚合。 package usage import ( @@ -11,6 +12,7 @@ import ( "gorm.io/gorm/clause" ) +// Recorder 异步记账器:缓冲队列 + 批量事务落库。 type Recorder struct { db *gorm.DB ch chan *store.UsageLog @@ -36,7 +38,6 @@ func (r *Recorder) Record(l *store.UsageLog) { select { case r.ch <- l: default: - // 队列积压:直接同步写,避免丢账 if err := r.flush([]*store.UsageLog{l}); err != nil { log.Printf("usage: sync write failed: %v", err) } @@ -99,13 +100,15 @@ func (r *Recorder) flush(logs []*store.UsageLog) error { if l.Status != store.UsageStatusSuccess || l.Cost <= 0 { continue } - // 扣余额(余额可为负,流式请求不中断;后续请求被拒) + // 扣余额(余额可为负:流式请求不中断;后续请求被拒) var user store.User if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&user, l.UserID).Error; err != nil { continue } newBalance := user.Balance - l.Cost - tx.Model(&store.User{}).Where("id = ?", l.UserID).Update("balance", newBalance) + if err := tx.Model(&store.User{}).Where("id = ?", l.UserID).Update("balance", newBalance).Error; err != nil { + continue + } tx.Create(&store.BalanceLog{ UserID: l.UserID, Change: -l.Cost, @@ -118,18 +121,23 @@ func (r *Recorder) flush(logs []*store.UsageLog) error { // 日聚合 upsert date := l.CreatedAt.UTC().Format("2006-01-02") tx.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "user_id"}, {Name: "model_id"}, {Name: "date"}}, + Columns: []clause.Column{{Name: "user_id"}, {Name: "model_id"}, {Name: "date"}}, DoUpdates: clause.Assignments(map[string]any{ - "requests": gorm.Expr("requests + 1"), - "input_tokens": gorm.Expr("input_tokens + ?", l.InputTokens), - "output_tokens": gorm.Expr("output_tokens + ?", l.OutputTokens), + "requests": gorm.Expr("requests + 1"), + "input_tokens": gorm.Expr("input_tokens + ?", l.InputTokens), + "output_tokens": gorm.Expr("output_tokens + ?", l.OutputTokens), "cache_read_tokens": gorm.Expr("cache_read_tokens + ?", l.CacheReadTokens), - "cost": gorm.Expr("cost + ?", l.Cost), + "cost": gorm.Expr("cost + ?", l.Cost), }), }).Create(&store.UsageDaily{ - UserID: l.UserID, ModelID: l.ModelID, Date: date, - Requests: 1, InputTokens: l.InputTokens, OutputTokens: l.OutputTokens, - CacheReadTokens: l.CacheReadTokens, Cost: l.Cost, + UserID: l.UserID, + ModelID: l.ModelID, + Date: date, + Requests: 1, + InputTokens: l.InputTokens, + OutputTokens: l.OutputTokens, + CacheReadTokens: l.CacheReadTokens, + Cost: l.Cost, }) } return nil diff --git a/web/.gitignore b/web/.gitignore deleted file mode 100644 index a547bf3..0000000 --- a/web/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# 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/web/README.md b/web/README.md deleted file mode 100644 index 33895ab..0000000 --- a/web/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Vue 3 + TypeScript + Vite - -This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` diff --git a/web/src/api/client.ts b/web/src/api/client.ts index b245135..82ed4a0 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,60 +1,44 @@ -// API 客户端:统一 baseURL、token 注入、401 刷新兜底。 import axios from 'axios' +import { useAuthStore } from '@/stores/auth' -// 代理端点(/v1/*,Bearer API Key)与管理 API(/api/v1)baseURL 不同,分开实例 -const proxyClient = axios.create({ baseURL: '/v1', timeout: 30000 }) - -const client = axios.create({ +export const http = axios.create({ baseURL: '/api/v1', - timeout: 20000, - withCredentials: true, // refresh cookie + timeout: 15000, + withCredentials: true, }) -export { proxyClient } - -client.interceptors.request.use((config) => { - const token = localStorage.getItem('ot_access') - if (token) config.headers.Authorization = `Bearer ${token}` +http.interceptors.request.use((config) => { + const auth = useAuthStore() + if (auth.accessToken) { + config.headers.Authorization = `Bearer ${auth.accessToken}` + } return config }) -let refreshing: Promise | null = null - -client.interceptors.response.use( - (res) => res, - async (err) => { - const original = err.config - // 401 且非刷新请求本身:尝试刷新一次 - if (err.response?.status === 401 && !original?._retried && !original?.url?.includes('/auth/')) { - original._retried = true - refreshing = refreshing ?? refreshAccess() - const token = await refreshing - refreshing = null - if (token) { - localStorage.setItem('ot_access', token) - original.headers.Authorization = `Bearer ${token}` - return client(original) +http.interceptors.response.use( + (resp) => resp, + async (error) => { + if (error.response?.status === 401) { + const auth = useAuthStore() + // 尝试用 refresh cookie 换新 token 后再试一次 + if (auth.accessToken && !error.config?._retried) { + error.config._retried = true + try { + await auth.refresh() + return http.request(error.config) + } catch { + auth.clear() + } + } else { + auth.clear() } } - return Promise.reject(err) + return Promise.reject(error) }, ) -async function refreshAccess(): Promise { - try { - const { data } = await client.post('/auth/refresh') - return data.data?.access_token ?? null - } catch { - return null - } +// 统一取后端错误信息 +export function errMsg(err: unknown): string { + const e = err as { response?: { data?: { error?: { message?: string } } } } + return e?.response?.data?.error?.message ?? '请求失败,请稍后重试' } - -// 响应壳:{ data: {...} } 或 { error: {...} } -export function unwrap(p: Promise<{ data: { data?: T; error?: { message?: string } } }>): Promise { - return p.then((res) => { - if (res.data.error) throw new Error(res.data.error.message || 'request failed') - return res.data.data as T - }) -} - -export default client diff --git a/web/src/assets/hero.png b/web/src/assets/hero.png deleted file mode 100644 index 02251f4b956c55af2d76fd0788124d7eee2b45eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13057 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 P00000NkvXXu0mjft=yBf diff --git a/web/src/assets/vite.svg b/web/src/assets/vite.svg deleted file mode 100644 index 5101b67..0000000 --- a/web/src/assets/vite.svg +++ /dev/null @@ -1 +0,0 @@ -Vite diff --git a/web/src/components/layout/ShellLayout.vue b/web/src/components/layout/ShellLayout.vue new file mode 100644 index 0000000..7719821 --- /dev/null +++ b/web/src/components/layout/ShellLayout.vue @@ -0,0 +1,97 @@ + + + diff --git a/web/src/components/ui/Badge.vue b/web/src/components/ui/Badge.vue index 1ac01d2..7081369 100644 --- a/web/src/components/ui/Badge.vue +++ b/web/src/components/ui/Badge.vue @@ -1,25 +1,30 @@ diff --git a/web/src/components/ui/Button.vue b/web/src/components/ui/Button.vue index 7234da2..ca8f531 100644 --- a/web/src/components/ui/Button.vue +++ b/web/src/components/ui/Button.vue @@ -1,42 +1,27 @@ diff --git a/web/src/components/ui/Input.vue b/web/src/components/ui/Input.vue index 9b8499f..12465a8 100644 --- a/web/src/components/ui/Input.vue +++ b/web/src/components/ui/Input.vue @@ -1,79 +1,34 @@ diff --git a/web/src/components/ui/Modal.vue b/web/src/components/ui/Modal.vue index fae132d..5d4323e 100644 --- a/web/src/components/ui/Modal.vue +++ b/web/src/components/ui/Modal.vue @@ -1,100 +1,85 @@ - - diff --git a/web/src/components/ui/Toast.vue b/web/src/components/ui/Toast.vue deleted file mode 100644 index ad51cf1..0000000 --- a/web/src/components/ui/Toast.vue +++ /dev/null @@ -1,48 +0,0 @@ - - - - - diff --git a/web/src/components/ui/ToastHost.vue b/web/src/components/ui/ToastHost.vue new file mode 100644 index 0000000..d359e0f --- /dev/null +++ b/web/src/components/ui/ToastHost.vue @@ -0,0 +1,28 @@ + + + diff --git a/web/src/components/ui/TrendChart.vue b/web/src/components/ui/TrendChart.vue new file mode 100644 index 0000000..4f15d63 --- /dev/null +++ b/web/src/components/ui/TrendChart.vue @@ -0,0 +1,57 @@ + + + diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 0000000..31a43ce --- /dev/null +++ b/web/src/lib/format.ts @@ -0,0 +1,20 @@ +export function fmtMoney(v: number): string { + return '$' + v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 6 }) +} + +export function fmtNum(v: number): string { + return v.toLocaleString('en-US') +} + +export function fmtTime(s?: string | null): string { + if (!s) return '-' + const d = new Date(s) + if (Number.isNaN(d.getTime())) return '-' + return d.toLocaleString('zh-CN', { hour12: false }) +} + +export function fmtCost(v: number): string { + if (v === 0) return '$0' + if (v < 0.01) return '$' + v.toExponential(2) + return fmtMoney(v) +} diff --git a/web/src/main.ts b/web/src/main.ts index 6a92eff..d70f70c 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,8 +1,11 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' -import './style.css' import App from './App.vue' -import router from './router' +import { router } from './router' +import './style.css' + +// 深色优先(MVP 固定深色,后续加亮色切换) +document.documentElement.classList.add('dark') const app = createApp(App) app.use(createPinia()) diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 49ca75e..c01a69c 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -1,21 +1,34 @@ import { createRouter, createWebHistory } from 'vue-router' -import { useAuthStore } from '../stores/auth' +import { useAuthStore } from '@/stores/auth' -const router = createRouter({ +export const router = createRouter({ history: createWebHistory(), routes: [ - { path: '/', name: 'landing', component: () => import('../views/LandingView.vue') }, - { path: '/login', name: 'login', component: () => import('../views/LoginView.vue'), meta: { guest: true } }, - { path: '/register', name: 'register', component: () => import('../views/RegisterView.vue'), meta: { guest: true } }, + { path: '/', name: 'landing', component: () => import('@/views/LandingView.vue') }, + { path: '/login', name: 'login', component: () => import('@/views/LoginView.vue'), meta: { guest: true } }, + { path: '/register', name: 'register', component: () => import('@/views/RegisterView.vue'), meta: { guest: true } }, { path: '/console', - component: () => import('../views/console/ConsoleLayout.vue'), + component: () => import('@/views/console/ConsoleLayout.vue'), meta: { auth: true }, children: [ { path: '', redirect: '/console/dashboard' }, - { path: 'dashboard', name: 'dashboard', component: () => import('../views/console/DashboardView.vue') }, - { path: 'keys', name: 'keys', component: () => import('../views/console/KeysView.vue') }, - { path: 'usage', name: 'usage', component: () => import('../views/console/UsageView.vue') }, + { path: 'dashboard', name: 'dashboard', component: () => import('@/views/console/DashboardView.vue') }, + { path: 'keys', name: 'keys', component: () => import('@/views/console/KeysView.vue') }, + { path: 'usage', name: 'usage', component: () => import('@/views/console/UsageView.vue') }, + ], + }, + { + path: '/admin', + component: () => import('@/views/admin/AdminLayout.vue'), + meta: { auth: true, admin: true }, + children: [ + { path: '', redirect: '/admin/overview' }, + { path: 'overview', name: 'admin-overview', component: () => import('@/views/admin/OverviewView.vue') }, + { path: 'channels', name: 'admin-channels', component: () => import('@/views/admin/ChannelsView.vue') }, + { path: 'models', name: 'admin-models', component: () => import('@/views/admin/ModelsView.vue') }, + { path: 'users', name: 'admin-users', component: () => import('@/views/admin/UsersView.vue') }, + { path: 'config', name: 'admin-config', component: () => import('@/views/admin/ConfigView.vue') }, ], }, { path: '/:pathMatch(.*)*', redirect: '/' }, @@ -24,10 +37,9 @@ const router = createRouter({ router.beforeEach(async (to) => { const auth = useAuthStore() - if (!auth.ready && auth.token) await auth.fetchMe() + if (!auth.ready) await auth.bootstrap() if (to.meta.auth && !auth.isAuthed) return { name: 'login', query: { redirect: to.fullPath } } + if (to.meta.admin && !auth.isAdmin) return { name: 'dashboard' } if (to.meta.guest && auth.isAuthed) return { name: 'dashboard' } return true }) - -export default router diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index 902743b..3a5a529 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -1,60 +1,59 @@ import { defineStore } from 'pinia' -import client, { unwrap } from '../api/client' - -export interface User { - id: number - username: string - email: string - role: 'user' | 'admin' - balance: number - status: string - created_at: string -} - -interface LoginResp { - access_token: string - expires_in: number - user: User -} +import { http } from '@/api/client' +import type { User } from '@/types' export const useAuthStore = defineStore('auth', { state: () => ({ user: null as User | null, - token: localStorage.getItem('ot_access') ?? '', + accessToken: localStorage.getItem('ot_access') ?? '', ready: false, }), getters: { - isAuthed: (s) => !!s.token, + isAuthed: (s) => !!s.accessToken && !!s.user, isAdmin: (s) => s.user?.role === 'admin', }, actions: { - setToken(t: string) { - this.token = t - localStorage.setItem('ot_access', t) - }, - async login(username: string, password: string) { - const data = await unwrap(client.post('/auth/login', { username, password })) - this.setToken(data.access_token) - this.user = data.user + async bootstrap() { + try { + if (!this.accessToken) { + await this.refresh() + } + await this.fetchMe() + } catch { + this.clear() + } + this.ready = true }, async register(username: string, email: string, password: string) { - await unwrap(client.post('/auth/register', { username, email, password })) + await http.post('/auth/register', { username, email, password }) + }, + async login(username: string, password: string) { + const { data } = await http.post('/auth/login', { username, password }) + const d = data.data as { access_token: string; user: User } + this.accessToken = d.access_token + this.user = d.user + localStorage.setItem('ot_access', d.access_token) + }, + async refresh() { + const { data } = await http.post('/auth/refresh') + this.accessToken = data.data.access_token as string + localStorage.setItem('ot_access', this.accessToken) }, async fetchMe() { - if (!this.token) return - try { - const data = await unwrap<{ user: User }>(client.get('/auth/me')) - this.user = data.user - } catch { - this.logout() - } finally { - this.ready = true - } + const { data } = await http.get('/auth/me') + this.user = data.data.user as User }, async logout() { - try { await client.post('/auth/logout') } catch { /* ignore */ } + try { + await http.post('/auth/logout') + } catch { + /* ignore */ + } + this.clear() + }, + clear() { + this.accessToken = '' this.user = null - this.token = '' localStorage.removeItem('ot_access') }, }, diff --git a/web/src/stores/toast.ts b/web/src/stores/toast.ts index 0196819..238b683 100644 --- a/web/src/stores/toast.ts +++ b/web/src/stores/toast.ts @@ -1,27 +1,29 @@ -// Toast 状态:轻量全局消息队列(操作反馈,aria-live 播报) import { defineStore } from 'pinia' -export interface Toast { +export interface ToastItem { id: number - kind: 'success' | 'error' | 'info' - message: string + msg: string + type: 'info' | 'ok' | 'err' } let seq = 0 export const useToastStore = defineStore('toast', { - state: () => ({ items: [] as Toast[] }), + state: () => ({ items: [] as ToastItem[] }), actions: { - push(kind: Toast['kind'], message: string) { + push(msg: string, type: ToastItem['type'] = 'info') { const id = ++seq - this.items.push({ id, kind, message }) - setTimeout(() => this.dismiss(id), 4000) + this.items.push({ id, msg, type }) + setTimeout(() => this.remove(id), 4000) }, - success(message: string) { this.push('success', message) }, - error(message: string) { this.push('error', message) }, - info(message: string) { this.push('info', message) }, - dismiss(id: number) { - this.items = this.items.filter((t) => t.id !== id) + ok(msg: string) { + this.push(msg, 'ok') + }, + err(msg: string) { + this.push(msg, 'err') + }, + remove(id: number) { + this.items = this.items.filter((i) => i.id !== id) }, }, }) diff --git a/web/src/style.css b/web/src/style.css index 91e0c41..36b8454 100644 --- a/web/src/style.css +++ b/web/src/style.css @@ -1,122 +1,66 @@ -@import "tailwindcss"; -@import "@fontsource/outfit/400.css"; -@import "@fontsource/outfit/500.css"; -@import "@fontsource/outfit/600.css"; -@import "@fontsource/outfit/700.css"; -@import "@fontsource/jetbrains-mono/400.css"; -@import "@fontsource/jetbrains-mono/500.css"; -@import "@fontsource/jetbrains-mono/600.css"; +@import 'tailwindcss'; +@import '@fontsource-variable/geist'; +@import '@fontsource-variable/geist-mono'; -/* ============================================================ - openteam 设计 tokens(taste-skill 产出) - 方向:深色优先的开发者控制台 / 信号系统语言 - 色板:石墨墨底 + 暖白文本 + 单一信号铜色强调(信号灯) - 数据一律 mono(JetBrains Mono),UI 用 Outfit - ============================================================ */ +/* ------------------------------------------------------------------ */ +/* 设计 tokens(taste-skill 产出) */ +/* 深色优先 · 单一强调色 emerald · 圆角体系:卡片 8 / 控件 6 / 徽章 pill */ +/* 密度 7:mono 数字、紧凑表格、细线分隔 */ +/* ------------------------------------------------------------------ */ @theme { - /* 墨色层(背景阶梯) */ - --color-ink-950: #0c0d0f; - --color-ink-900: #121417; - --color-ink-850: #16191d; - --color-ink-800: #1c2025; - --color-ink-700: #282d34; - --color-ink-600: #363c45; + --font-sans: 'Geist Variable', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; + --font-mono: 'Geist Mono Variable', ui-monospace, SFMono-Regular, Menlo, monospace; - /* 纸色层(文本) */ - --color-paper-100: #eae8e3; - --color-paper-300: #c8c5bd; - --color-paper-500: #8b909a; - --color-paper-600: #63686f; + /* 强调色(单一一处定义,全局一致) */ + --color-accent: oklch(0.72 0.17 152); + --color-accent-strong: oklch(0.64 0.19 152); + --color-accent-soft: oklch(0.95 0.05 152); - /* 信号铜色(唯一强调,信号灯意象) */ - --color-signal-200: #f7d9a8; - --color-signal-300: #f0be6d; - --color-signal-400: #e5a13c; - --color-signal-500: #c9842a; - --color-signal-600: #a56a1f; - - /* 语义色 */ - --color-mint-300: #7fd0ac; - --color-mint-400: #4cb58a; - --color-mint-500: #33946f; - --color-ember-300: #ec8a80; - --color-ember-400: #d9685c; - --color-ember-500: #b34c42; - --color-sky-300: #93bce4; - --color-sky-400: #6e9fd8; - - --font-sans: "Outfit", ui-sans-serif, system-ui, sans-serif; - --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", monospace; - - /* 圆角:全局统一 6px(工具类,克制) */ - --radius-sm: 4px; - --radius-md: 6px; - --radius-lg: 10px; -} - -/* 亮色主题(保留:data-theme="light" 时切换,默认深色优先) */ -[data-theme="light"] { - --color-ink-950: #f4f3f0; - --color-ink-900: #ffffff; - --color-ink-850: #faf9f6; - --color-ink-800: #f0efeb; - --color-ink-700: #e2e0da; - --color-ink-600: #cfccc4; - --color-paper-100: #1d2024; - --color-paper-300: #3a3f46; - --color-paper-500: #5f6670; - --color-paper-600: #8a9099; - --color-signal-400: #b3741c; - --color-signal-500: #9a6116; - --color-mint-400: #1f8a61; - --color-ember-400: #c24b41; - --color-sky-400: #3f78b8; + /* 状态色(语义,克制使用) */ + --color-ok: oklch(0.72 0.17 152); + --color-warn: oklch(0.80 0.15 75); + --color-err: oklch(0.63 0.21 25); } html { color-scheme: dark; } -[data-theme="light"] { - color-scheme: light; -} - body { - @apply bg-ink-950 text-paper-100 font-sans antialiased; - font-feature-settings: "ss01" on, "cv05" on; + background-color: #09090b; + color: #f4f4f5; + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; } -/* 数字统一用 tabular 对齐(数据密集场景) */ -.num { - font-family: var(--font-mono); - font-variant-numeric: tabular-nums; - letter-spacing: -0.01em; -} - -/* 聚焦可见性:键盘可达性 */ +/* 全站统一键盘焦点可见性 */ :focus-visible { - outline: 2px solid var(--color-signal-400); + outline: 2px solid var(--color-accent); outline-offset: 2px; -} - -/* 滚动条克制化 */ -::-webkit-scrollbar { - width: 10px; - height: 10px; -} -::-webkit-scrollbar-thumb { - background: var(--color-ink-700); - border-radius: 6px; - border: 2px solid var(--color-ink-950); -} -::-webkit-scrollbar-track { - background: transparent; + border-radius: 4px; } @media (prefers-reduced-motion: reduce) { - *, *::before, *::after { + *, + *::before, + *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } } + +/* 通用组件视觉基元 */ +@layer components { + .card { + @apply rounded-lg border border-zinc-800 bg-zinc-900/60; + } + + .mono-num { + @apply font-mono tabular-nums; + } + + .table-row { + @apply border-b border-zinc-800/70 last:border-0 hover:bg-zinc-800/30; + } +} diff --git a/web/src/types.ts b/web/src/types.ts new file mode 100644 index 0000000..e5f2dc3 --- /dev/null +++ b/web/src/types.ts @@ -0,0 +1,80 @@ +export interface User { + id: number + username: string + email: string + role: 'user' | 'admin' + balance: number + status: string + created_at: string +} + +export interface ApiKey { + id: number + name: string + key_prefix: string + quota_tokens_per_day?: number | null + quota_requests_per_day?: number | null + allowed_models?: string[] | null + expires_at?: string | null + status: string + last_used_at?: string | null + created_at: string +} + +export interface Channel { + id: number + name: string + provider: 'openai' | 'anthropic' | 'compatible' + base_url: string + api_key_masked: string + weight: number + priority: number + timeout_ms: number + max_concurrency: number + health_status: string + enabled: boolean + created_at: string +} + +export interface ModelBinding { + id: number + channel_id: number + channel_name: string + upstream_model: string + weight: number +} + +export interface Model { + id: number + name: string + display_name: string + input_price: number + output_price: number + cache_read_price: number + enabled: boolean + sort: number + channels: ModelBinding[] +} + +export interface UsageLog { + id: number + request_id: string + model: string + protocol: string + input_tokens: number + output_tokens: number + cache_read_tokens: number + cost: number + latency_ms: number + status: string + error_code: string | null + created_at: string + user?: string +} + +export interface Paged { + items: T[] + total: number + page: number + page_size: number +} diff --git a/web/src/views/LandingView.vue b/web/src/views/LandingView.vue index 82a3021..e20457d 100644 --- a/web/src/views/LandingView.vue +++ b/web/src/views/LandingView.vue @@ -1,105 +1,174 @@ diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue index 512c40a..219411c 100644 --- a/web/src/views/LoginView.vue +++ b/web/src/views/LoginView.vue @@ -1,32 +1,33 @@