M0-M4: 推倒重来基线(基建+用户/密钥/核心代理+前端+管理后台+三协议互转)

- 后端 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 <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-08-15 15:34:06 +08:00
co-authored by Claude
parent b25e9ec8a7
commit ec4de8d913
92 changed files with 6203 additions and 3064 deletions
+7
View File
@@ -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
+2 -2
View File
@@ -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:
+251 -257
View File
@@ -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 统一定调。
### 2.3 部署
### 3.3 部署
- 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,17 +173,17 @@ 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 形状**作为"标准中间模型",三套协议都先转成它、再转成目标协议:
@@ -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,...}]`(结构相同) |
| `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 · 用量查询 · 充值(后置) · 个人设置 |
| 用户 | 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 状态、里程碑按"代理/管理/计费"三板块组织、补开放决策项;并已确认**推倒重来**(旧实现仅作参考)。*
+51 -38
View File
@@ -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 打磨上线(限流/监控/审计/全站复查) | ⏳ |
-15
View File
@@ -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"]
-12
View File
@@ -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
-54
View File
@@ -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:
-45
View File
@@ -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;
}
-42
View File
@@ -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;
}
+2 -2
View File
@@ -1,3 +1,3 @@
module mockupstream
module scripts/mockupstream
go 1.26.5
go 1.23.0
+52 -3
View File
@@ -1,5 +1,5 @@
// mockupstream 本地 mock OpenAI 上游服务(联调代理链路,无需真实 key)。
// 支持 /v1/chat/completions 与 /v1/responses,含流式与非流式。
// 支持 /v1/chat/completions 与 /v1/responses,含流式与非流式;/v1/models 返回模型列表。
package main
import (
@@ -46,13 +46,63 @@ 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{
@@ -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 {
+2 -23
View File
@@ -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:])
}
+1 -1
View File
@@ -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
+53 -43
View File
@@ -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})
}
+333
View File
@@ -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
}
+199
View File
@@ -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
+102
View File
@@ -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})
}
+18 -8
View File
@@ -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
+2 -2
View File
@@ -12,7 +12,7 @@ import (
)
type createKeyReq struct {
Name string `json:"name" binding:"required,min=1,max=64"`
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"`
@@ -38,7 +38,7 @@ func (h *Handler) CreateKey(c *gin.Context) {
}
k := store.APIKey{
UserID: u.ID,
Name: req.Name,
Name: *req.Name,
KeyHash: hash,
KeyPrefix: prefix,
QuotaTokensPerDay: req.QuotaTokensPerDay,
+21 -1
View File
@@ -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 预留)
}
}
+112
View File
@@ -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})
}
-102
View File
@@ -2,7 +2,6 @@ package api
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
@@ -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})
}
+9 -15
View File
@@ -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
}
+3 -5
View File
@@ -1,4 +1,5 @@
// Package config 加载服务配置:.env / 环境变量 / 默认值(viper)。
// 所有项均可用环境变量 OT_<KEY> 覆盖(点号转下划线,如 db.driver → OT_DB_DRIVER)。
package config
import (
@@ -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"),
-4
View File
@@ -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
+18 -14
View File
@@ -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)
}
}
}
+7 -6
View File
@@ -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 {
+19 -18
View File
@@ -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)
}
}
+1
View File
@@ -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"`
+22 -18
View File
@@ -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")
}
}
-5
View File
@@ -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}})
}
+157
View File
@@ -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
}
@@ -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)
}
}
+457
View File
@@ -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, &params)
}
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"
}
}
@@ -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,
},
})
}
+478
View File
@@ -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)
}
+86 -10
View File
@@ -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")
+65 -32
View File
@@ -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:
+121 -60
View File
@@ -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 处理)
out := line
if lineConv != nil {
out = lineConv(line)
}
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 {
outUsage(usageRaw)
}
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"`
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,20 +387,35 @@ 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,
UserID: uidVal,
KeyID: kidVal,
ChannelID: chID,
ModelID: modelID,
ModelName: mn,
Protocol: p,
@@ -367,16 +429,15 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
Cost: cost,
LatencyMS: latency,
Status: status,
ErrorCode: &errMsg,
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() }
+84 -66
View File
@@ -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)
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 err != nil {
break
// 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)
}
}
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)
}
}
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
}
+22
View File
@@ -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)
}
+12 -14
View File
@@ -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 (
@@ -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,7 +70,7 @@ 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"`
@@ -89,7 +87,7 @@ type Channel struct {
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"`
@@ -103,7 +101,7 @@ type Model struct {
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,7 +112,7 @@ 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
@@ -139,7 +137,7 @@ type UsageLog struct {
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"`
@@ -152,7 +150,7 @@ type UsageDaily struct {
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"`
@@ -167,7 +165,7 @@ type RechargeOrder struct {
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"`
+14 -6
View File
@@ -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,
@@ -127,9 +130,14 @@ func (r *Recorder) flush(logs []*store.UsageLog) error {
"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
-24
View File
@@ -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?
-5
View File
@@ -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 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
+3 -4
View File
@@ -1,12 +1,11 @@
<!doctype html>
<html lang="zh-CN">
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0c0d0f" />
<meta name="color-scheme" content="dark" />
<title>openteam · LLM 中转网关</title>
<title>openteam · LLM API 中转站</title>
</head>
<body>
<div id="app"></div>
+16 -19
View File
@@ -1,7 +1,7 @@
{
"name": "web",
"name": "openteam-web",
"private": true,
"version": "0.0.0",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -9,24 +9,21 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource/jetbrains-mono": "^5.3.0",
"@fontsource/outfit": "^5.3.0",
"axios": "^1.19.0",
"echarts": "^6.1.0",
"pinia": "^4.0.3",
"vue": "^3.5.40",
"vue-echarts": "^8.1.0",
"vue-router": "^4.6.4"
"@fontsource-variable/geist": "^5.2.5",
"@fontsource-variable/geist-mono": "^5.2.5",
"@phosphor-icons/vue": "^2.2.1",
"axios": "^1.7.9",
"pinia": "^2.3.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^24.13.3",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1",
"playwright": "^1.62.1",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"vite": "^8.2.0",
"vue-tsc": "^3.3.8"
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^22.10.0",
"@vitejs/plugin-vue": "^5.2.1",
"tailwindcss": "^4.0.0",
"typescript": "~5.6.2",
"vite": "^6.0.0",
"vue-tsc": "^2.1.10"
}
}
+625 -435
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -1 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#E5A13C"/><path d="M6 16h6l4-10 6 20 4-10h0" fill="none" stroke="#0C0D0F" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#0a0a0b"/>
<path d="M8 11h16M8 16h16M8 21h10" stroke="#34d399" stroke-width="2.4" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 250 B

After

Width:  |  Height:  |  Size: 221 B

-24
View File
@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

-32
View File
@@ -1,32 +0,0 @@
const { chromium } = require('playwright')
async function main() {
const browser = await chromium.launch()
const page = await browser.newPage()
const uname = 'probe' + Date.now().toString().slice(-6)
await page.goto('http://localhost:5173/register', { waitUntil: 'networkidle' })
await page.fill('input[autocomplete="username"]', uname)
await page.fill('input[autocomplete="email"]', uname + '@test.com')
await page.fill('input[autocomplete="new-password"]', 'password123')
await page.locator('input[autocomplete="new-password"]').nth(1).fill('password123')
await page.click('button[type="submit"]')
await page.waitForURL('**/console/dashboard', { timeout: 10000 })
await page.waitForTimeout(500)
const results = await page.evaluate(async () => {
const out = {}
for (const [name, url] of Object.entries({
balance: '/api/v1/user/balance',
stats: '/api/v1/usage/stats?group=day',
logs: '/api/v1/usage/logs?page_size=8',
})) {
try {
const r = await fetch(url, { headers: { Authorization: 'Bearer ' + localStorage.getItem('ot_access') } })
const j = await r.json()
out[name] = { status: r.status, body: JSON.stringify(j).slice(0, 220) }
} catch (e) { out[name] = { err: String(e) } }
}
return out
})
console.log(JSON.stringify(results, null, 1))
await browser.close()
}
main().catch(e => { console.error(e); process.exit(1) })
-29
View File
@@ -1,29 +0,0 @@
// 定位 pageerror 来源
const { chromium } = require('playwright')
async function main() {
const browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
page.on('console', (m) => { if (m.type() === 'error') console.log('CONSOLE ERR @', page.url(), '→', m.text().slice(0, 200)) })
page.on('pageerror', (e) => console.log('PAGE ERR @', page.url(), '→', e.stack?.split('\n').slice(0, 3).join(' | ')))
const uname = 'dbg' + Date.now().toString().slice(-6)
await page.goto('http://localhost:5173/register', { waitUntil: 'networkidle' })
await page.fill('input[autocomplete="username"]', uname)
await page.fill('input[autocomplete="email"]', uname + '@test.com')
await page.fill('input[autocomplete="new-password"]', 'password123')
await page.locator('input[autocomplete="new-password"]').nth(1).fill('password123')
await page.click('button[type="submit"]')
await page.waitForURL('**/console/dashboard', { timeout: 10000 })
await page.waitForTimeout(2500)
console.log('URL now:', page.url())
// 检查页面内容
const cards = await page.locator('.mt-6.grid .rounded-lg').count()
const chart = await page.locator('canvas').count()
const empty = await page.locator('text=暂无数据').count()
console.log('cards:', cards, 'canvas:', chart, 'emptyState:', empty)
const bodyText = (await page.locator('body').innerText()).slice(0, 300)
console.log('body:', bodyText.replace(/\n+/g, ' | '))
await browser.close()
}
main().catch((e) => { console.error(e); process.exit(1) })
-52
View File
@@ -1,52 +0,0 @@
// 前端截图验证脚本:登录 → 仪表盘 → 密钥页
const { chromium } = require('playwright')
async function main() {
const browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
const shots = process.argv[2] || '/tmp/shots'
const fs = require('fs')
fs.mkdirSync(shots, { recursive: true })
// 1. Landing
await page.goto('http://localhost:5173/', { waitUntil: 'networkidle' })
await page.screenshot({ path: `${shots}/01-landing.png` })
// 2. 注册
await page.goto('http://localhost:5173/register', { waitUntil: 'networkidle' })
const uname = 'dave' + Date.now().toString().slice(-6)
await page.fill('input[autocomplete="username"]', uname)
await page.fill('input[autocomplete="email"]', uname + '@test.com')
await page.fill('input[autocomplete="new-password"]', 'password123')
await page.locator('input[autocomplete="new-password"]').nth(1).fill('password123')
await page.screenshot({ path: `${shots}/02-register.png` })
await page.click('button[type="submit"]')
await page.waitForURL('**/console/dashboard', { timeout: 10000 })
await page.waitForTimeout(1200)
// 3. 跳过登录(注册后已自动登录)—— 直接看仪表盘
await page.goto('http://localhost:5173/console/dashboard', { waitUntil: 'networkidle' })
await page.waitForTimeout(1500)
await page.screenshot({ path: `${shots}/03-dashboard.png` })
// 4. 密钥页
await page.goto('http://localhost:5173/console/keys', { waitUntil: 'networkidle' })
await page.screenshot({ path: `${shots}/04-keys.png` })
await page.click('text=新建密钥')
await page.waitForTimeout(400)
await page.fill('input[placeholder*="本地开发"]', 'playwright-test')
await page.screenshot({ path: `${shots}/05-key-modal.png` })
await page.click('button:has-text("创建")')
await page.waitForTimeout(600)
await page.screenshot({ path: `${shots}/06-key-created.png` })
// 5. 用量页
await page.goto('http://localhost:5173/console/usage', { waitUntil: 'networkidle' })
await page.waitForTimeout(800)
await page.screenshot({ path: `${shots}/07-usage.png` })
await browser.close()
console.log('done')
}
main().catch((e) => { console.error(e); process.exit(1) })
-80
View File
@@ -1,80 +0,0 @@
// 前端渲染断言:检查关键元素与 console 错误
const { chromium } = require('playwright')
async function main() {
const browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
const errors = []
page.on('console', (m) => { if (m.type() === 'error') errors.push(page.url() + ' :: ' + m.text()) })
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message))
async function check(name, url, selectors) {
await page.goto(url, { waitUntil: 'networkidle' })
await page.waitForTimeout(800)
const results = []
for (const [label, sel] of Object.entries(selectors)) {
const n = await page.locator(sel).count()
results.push(`${label}:${n}`)
}
console.log(`${name}: ${results.join(' ')}`)
}
await check('landing', 'http://localhost:5173/', {
nav: 'header nav',
hero: 'h1',
protocols: '#protocols .grid > div',
footer: 'footer',
})
await check('login', 'http://localhost:5173/login', {
username: 'input[autocomplete="username"]',
password: 'input[autocomplete="current-password"]',
submit: 'button[type="submit"]',
})
// 注册一个测试用户并进控制台
const uname = 'verify' + Date.now().toString().slice(-6)
await page.goto('http://localhost:5173/register', { waitUntil: 'networkidle' })
await page.fill('input[autocomplete="username"]', uname)
await page.fill('input[autocomplete="email"]', uname + '@test.com')
await page.fill('input[autocomplete="new-password"]', 'password123')
await page.locator('input[autocomplete="new-password"]').nth(1).fill('password123')
await page.click('button[type="submit"]')
await page.waitForURL('**/console/dashboard', { timeout: 10000 })
await page.waitForTimeout(1200)
await check('dashboard', 'http://localhost:5173/console/dashboard', {
sidebar: 'aside',
cards: '.mt-6.grid .rounded-lg',
chart: '.divide-y, canvas',
recent: '.divide-y',
navKeys: 'a[href="/console/keys"]',
balance: 'aside .text-mint-400',
})
await check('keys', 'http://localhost:5173/console/keys', {
table: 'table',
createBtn: 'button:has-text("新建密钥")',
})
await check('usage', 'http://localhost:5173/console/usage', {
table: 'table',
filter: 'select',
pagination: 'button:has-text("下一页")',
})
// 创建密钥弹窗
await page.goto('http://localhost:5173/console/keys', { waitUntil: 'networkidle' })
await page.click('button:has-text("新建密钥")')
await page.waitForTimeout(300)
await page.fill('input[placeholder*="本地开发"]', 'verify-key')
await page.click('button:has-text("创建")')
await page.waitForTimeout(600)
const created = await page.locator('code.text-mint-300').count()
console.log(`key-created-modal: oneTimeKey:${created}`)
console.log('console-errors:', errors.length ? errors.slice(0, 5) : 'none')
await browser.close()
}
main().catch((e) => { console.error(e); process.exit(1) })
-98
View File
@@ -1,98 +0,0 @@
// WIG 合规验证:语义标签 / aria / focus / modal 交互
const { chromium } = require('playwright')
async function main() {
const browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
const errors = []
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message))
page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text().slice(0, 150)) })
const BASE = 'http://127.0.0.1:8088'
let pass = 0, fail = 0
const check = (name, ok) => { console.log(`${ok ? '✓' : '✗'} ${name}`); ok ? pass++ : fail++ }
// ---- Landing ----
await page.goto(BASE + '/', { waitUntil: 'networkidle' })
check('landing: h1 存在', await page.locator('h1').count() === 1)
check('landing: main 语义标签', await page.locator('main#landing-main').count() === 1)
check('landing: 导航用 router-link(<a>)', await page.locator('header nav a').count() >= 2)
check('landing: theme-color', await page.locator('meta[name="theme-color"]').count() === 1)
// ---- 注册(带字段级校验)----
const uname = 'wig' + Date.now().toString().slice(-6)
await page.goto(BASE + '/register', { waitUntil: 'networkidle' })
// 空提交 → 字段级错误
await page.click('button[type="submit"]')
await page.waitForTimeout(300)
check('register: 字段级错误显示', await page.locator('[role="alert"]').count() >= 1)
// 填错邮箱
await page.fill('input[name="username"]', uname)
await page.fill('input[name="email"]', 'not-an-email')
await page.fill('input[name="password"]', 'password123')
await page.fill('input[name="confirm"]', 'password123')
await page.click('button[type="submit"]')
await page.waitForTimeout(300)
check('register: 邮箱格式错误', await page.locator('text=邮箱格式不正确').count() === 1)
// 修正并提交
await page.fill('input[name="email"]', uname + '@test.com')
await page.click('button[type="submit"]')
await page.waitForURL('**/console/dashboard', { timeout: 10000 })
await page.waitForTimeout(1500)
// ---- Console ----
check('console: skip link', await page.locator('a[href="#main-content"]').count() === 1)
check('console: main 语义标签', await page.locator('main#main-content').count() === 1)
check('console: aside aria-label', await page.locator('aside[aria-label]').count() === 1)
check('console: 侧边栏卡片', await page.locator('section[aria-label="用量指标"] .rounded-lg').count() === 4)
check('console: 骨架屏已消失(loaded)', await page.locator('.animate-pulse').count() === 0)
// ---- Keys ----
await page.goto(BASE + '/console/keys', { waitUntil: 'networkidle' })
await page.waitForTimeout(500)
check('keys: 表格 caption', await page.locator('table caption.sr-only').count() === 1)
check('keys: 吊销按钮', await page.locator('button:has-text("吊销")').count() >= 0)
// 创建密钥 modal:Escape 关闭
await page.click('button:has-text("新建密钥")')
await page.waitForTimeout(300)
check('modal: dialog 打开', await page.locator('[role="dialog"]').count() === 1)
await page.keyboard.press('Escape')
await page.waitForTimeout(300)
check('modal: Escape 关闭', await page.locator('[role="dialog"]').count() === 0)
// 重新打开并创建
await page.click('button:has-text("新建密钥")')
await page.waitForTimeout(300)
await page.fill('input[name="key-name"]', 'wig-test')
await page.click('button:has-text("创建密钥")')
await page.waitForTimeout(600)
check('modal: 一次性明文展示', await page.locator('code.text-mint-300').count() === 1)
check('modal: 复制按钮 + toast', (await page.click('button:has-text("复制")'), true))
await page.waitForTimeout(300)
check('toast: aria-live 容器', await page.locator('[aria-live="polite"]').count() >= 1)
await page.click('button:has-text("完成")')
// ---- Usage ----
await page.goto(BASE + '/console/usage?model=gpt-4o-mini', { waitUntil: 'networkidle' })
await page.waitForTimeout(800)
check('usage: select 有 label', await page.locator('label:has(select)').count() === 1)
check('usage: URL 反映筛选状态', page.url().includes('model='))
check('usage: 分页 aria 标签', await page.locator('nav[aria-label="分页"]').count() === 1)
// ---- Tab 焦点可见性 ----
await page.goto(BASE + '/console/dashboard', { waitUntil: 'networkidle' })
await page.keyboard.press('Tab')
await page.waitForTimeout(200)
const focusedRing = await page.evaluate(() => {
const el = document.activeElement
if (!el) return 'none'
const s = getComputedStyle(el)
return s.outlineStyle === 'auto' || s.outlineWidth !== '0px' ? 'outline' : (s.boxShadow !== 'none' ? 'ring' : 'none')
})
check(`focus: Tab 首元素可见焦点 (${focusedRing})`, focusedRing !== 'none')
console.log(`\n结果: ${pass} 通过, ${fail} 失败`)
if (errors.length) console.log('控制台错误:', errors.slice(0, 5))
await browser.close()
process.exit(fail > 0 ? 1 : 0)
}
main().catch((e) => { console.error(e); process.exit(1) })
+4 -8
View File
@@ -1,17 +1,13 @@
<script setup lang="ts">
import { useAuthStore } from './stores/auth'
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import ToastHost from '@/components/ui/ToastHost.vue'
const auth = useAuthStore()
const router = useRouter()
onMounted(() => {
// 初始化时若已有 token 则拉取用户信息
if (auth.token && !auth.user) auth.fetchMe()
void router
})
onMounted(() => auth.bootstrap())
</script>
<template>
<router-view />
<ToastHost />
</template>
+30 -46
View File
@@ -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<string | null> | 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<string | null> {
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<T>(p: Promise<{ data: { data?: T; error?: { message?: string } } }>): Promise<T> {
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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

+97
View File
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { PhList } from '@phosphor-icons/vue'
import { useAuthStore } from '@/stores/auth'
import { fmtMoney } from '@/lib/format'
interface NavItem {
to: string
label: string
}
defineProps<{ sections: { title: string; items: NavItem[] }[] }>()
const auth = useAuthStore()
const router = useRouter()
const balance = computed(() => fmtMoney(auth.user?.balance ?? 0))
const sidebarOpen = ref(false)
function navTo() {
sidebarOpen.value = false
}
async function logout() {
await auth.logout()
router.push('/')
}
</script>
<template>
<div class="flex min-h-[100dvh] bg-zinc-950">
<!-- 移动端遮罩 -->
<div
v-if="sidebarOpen"
class="fixed inset-0 z-30 bg-black/60 md:hidden"
@click="sidebarOpen = false"
/>
<!-- 侧边栏 -->
<aside
class="fixed inset-y-0 left-0 z-40 flex w-56 transform flex-col border-r border-zinc-800/80 bg-zinc-950 transition-transform duration-200 md:translate-x-0"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
>
<div class="flex h-14 items-center gap-2 border-b border-zinc-800/80 px-4">
<img src="/favicon.svg" alt="" class="size-5" />
<span class="text-sm font-semibold tracking-tight">openteam</span>
</div>
<nav class="flex-1 overflow-y-auto px-3 py-4">
<template v-for="sec in sections" :key="sec.title">
<p class="mt-4 mb-1.5 px-2 text-[10px] font-medium tracking-[0.14em] text-zinc-600 uppercase first:mt-0">
{{ sec.title }}
</p>
<router-link
v-for="n in sec.items"
:key="n.to"
:to="n.to"
class="mb-0.5 flex items-center rounded-md px-2 py-1.5 text-sm text-zinc-400 transition hover:bg-zinc-800/50 hover:text-zinc-100"
active-class="bg-zinc-800/70 text-zinc-50"
@click="navTo"
>
{{ n.label }}
</router-link>
</template>
</nav>
</aside>
<div class="flex min-h-[100dvh] flex-1 flex-col md:ml-56">
<header class="flex h-14 items-center justify-between border-b border-zinc-800/80 px-4 md:px-6">
<div class="flex items-center gap-2">
<button
class="rounded-md p-1.5 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100 md:hidden"
aria-label="打开菜单"
@click="sidebarOpen = true"
>
<PhList :size="20" />
</button>
<span class="font-mono text-xs text-zinc-600">{{ auth.user?.username }}</span>
</div>
<div class="flex items-center gap-3">
<span class="mono-num rounded-md border border-zinc-800 bg-zinc-900 px-2.5 py-1 text-xs text-emerald-300">
余额 {{ balance }}
</span>
<button
class="rounded-md px-2 py-1 text-xs text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-200"
@click="logout"
>
退出
</button>
</div>
</header>
<main class="flex-1 px-4 py-6 md:px-6">
<router-view />
</main>
</div>
</div>
</template>
+24 -19
View File
@@ -1,25 +1,30 @@
<script setup lang="ts">
// 状态信号灯徽标:healthy/degraded/cooldown/active/revoked/success/error
const props = defineProps<{ tone: string; label?: string }>()
const tones: Record<string, { dot: string; text: string; bg: string }> = {
healthy: { dot: 'bg-mint-400', text: 'text-mint-300', bg: 'bg-mint-400/10' },
success: { dot: 'bg-mint-400', text: 'text-mint-300', bg: 'bg-mint-400/10' },
active: { dot: 'bg-mint-400', text: 'text-mint-300', bg: 'bg-mint-400/10' },
degraded: { dot: 'bg-signal-400', text: 'text-signal-300', bg: 'bg-signal-400/10' },
cooldown: { dot: 'bg-signal-400', text: 'text-signal-300', bg: 'bg-signal-400/10' },
error: { dot: 'bg-ember-400', text: 'text-ember-300', bg: 'bg-ember-400/10' },
revoked: { dot: 'bg-ember-400', text: 'text-ember-300', bg: 'bg-ember-400/10' },
disabled: { dot: 'bg-paper-600', text: 'text-paper-500', bg: 'bg-paper-600/10' },
pending: { dot: 'bg-sky-400', text: 'text-sky-300', bg: 'bg-sky-400/10' },
}
const t = tones[props.tone] ?? tones.disabled
withDefaults(defineProps<{ variant?: 'neutral' | 'ok' | 'warn' | 'err' | 'accent' }>(), {
variant: 'neutral',
})
</script>
<template>
<span class="inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium" :class="[t.bg, t.text]">
<span class="h-1.5 w-1.5 rounded-full" :class="t.dot" />
{{ label ?? tone }}
<span
class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-mono text-[11px] leading-5"
:class="{
neutral: 'bg-zinc-800/80 text-zinc-300',
ok: 'bg-ok/15 text-emerald-300',
warn: 'bg-warn/15 text-amber-300',
err: 'bg-err/15 text-red-300',
accent: 'bg-accent/15 text-emerald-300',
}[variant]"
>
<span
v-if="variant !== 'neutral'"
class="size-1.5 rounded-full"
:class="{
ok: 'bg-ok',
warn: 'bg-warn',
err: 'bg-err',
accent: 'bg-accent',
}[variant]"
/>
<slot />
</span>
</template>
+8 -23
View File
@@ -1,42 +1,27 @@
<script setup lang="ts">
withDefaults(
defineProps<{
variant?: 'primary' | 'ghost' | 'danger' | 'outline'
variant?: 'primary' | 'ghost' | 'danger'
size?: 'sm' | 'md'
type?: 'button' | 'submit'
loading?: boolean
disabled?: boolean
ariaLabel?: string
}>(),
{ variant: 'primary', size: 'md', type: 'button', loading: false, disabled: false },
{ variant: 'primary', size: 'md', loading: false, disabled: false },
)
defineEmits<{ (e: 'click', ev: MouseEvent): void }>()
</script>
<template>
<button
:type="type"
:disabled="disabled || loading"
:aria-label="ariaLabel"
:aria-busy="loading || undefined"
class="inline-flex touch-manipulation items-center justify-center gap-2 font-medium select-none
transition-[background-color,border-color,color,transform,opacity] duration-150
active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none cursor-pointer"
class="inline-flex items-center justify-center gap-2 rounded-md font-medium transition-[transform,background-color,border-color,color] duration-150 active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 select-none"
:class="[
size === 'sm' ? 'h-8 px-3 text-[13px] rounded-md' : 'h-10 px-4 text-sm rounded-md',
variant === 'primary' && 'bg-signal-400 text-ink-950 hover:bg-signal-300',
variant === 'outline' && 'border border-ink-600 text-paper-300 hover:border-signal-400 hover:text-signal-300 bg-transparent',
variant === 'ghost' && 'text-paper-500 hover:text-paper-100 hover:bg-ink-800',
variant === 'danger' && 'bg-ember-500/90 text-white hover:bg-ember-400',
size === 'sm' ? 'h-8 px-3 text-xs' : 'h-10 px-4 text-sm',
variant === 'primary' && 'bg-accent text-zinc-950 hover:bg-accent-strong',
variant === 'ghost' && 'border border-zinc-700 text-zinc-200 hover:border-zinc-500 hover:bg-zinc-800/60',
variant === 'danger' && 'border border-err/50 text-red-300 hover:border-err hover:bg-err/10',
]"
@click="$emit('click', $event)"
>
<span
v-if="loading"
class="h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent"
aria-hidden="true"
/>
<span v-if="loading" class="size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
<slot />
</button>
</template>
+18 -63
View File
@@ -1,79 +1,34 @@
<script setup lang="ts">
import { computed, ref, useId } from 'vue'
type InputMode = 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search'
type Props = {
withDefaults(
defineProps<{
label?: string
modelValue?: string | number
type?: string
placeholder?: string
modelValue?: string
error?: string
hint?: string
mono?: boolean
error?: string
autocomplete?: string
name?: string
inputmode?: InputMode
spellcheck?: boolean
required?: boolean
autofocus?: boolean
}
const props = withDefaults(defineProps<Props>(), {
type: 'text',
placeholder: '',
modelValue: '',
spellcheck: false,
})
defineEmits<{ (e: 'update:modelValue', v: string): void }>()
const uid = useId()
const inputId = `input-${uid}`
const descId = computed(() => (props.error || props.hint ? `desc-${uid}` : undefined))
const inputEl = ref<HTMLInputElement | null>(null)
// 暴露 focus 供父组件定位错误
function focus() {
inputEl.value?.focus()
}
defineExpose({ focus })
disabled?: boolean
}>(),
{ type: 'text', modelValue: '', disabled: false },
)
const emit = defineEmits<{ 'update:modelValue': [string | number] }>()
</script>
<template>
<div>
<label v-if="label" :for="inputId" class="mb-1.5 block text-[13px] font-medium text-paper-300">
{{ label }}<span v-if="required" class="ml-0.5 text-ember-400" aria-hidden="true">*</span>
</label>
<label class="block">
<span v-if="label" class="mb-1.5 block text-xs font-medium text-zinc-400">{{ label }}</span>
<input
ref="inputEl"
:id="inputId"
:type="type"
:name="name"
:value="modelValue"
:placeholder="placeholder"
:autocomplete="autocomplete"
:inputmode="inputmode"
:spellcheck="spellcheck"
:required="required"
:autofocus="autofocus"
:aria-invalid="error ? 'true' : undefined"
:aria-describedby="descId"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
class="h-10 w-full rounded-md border bg-ink-900 px-3 text-sm text-paper-100 transition-[border-color,box-shadow] placeholder:text-paper-600
focus:outline-none focus-visible:ring-2 focus-visible:ring-signal-400/60"
:class="[
mono ? 'font-mono' : '',
error ? 'border-ember-500' : 'border-ink-600 hover:border-ink-700',
]"
:disabled="disabled"
class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm text-zinc-100 placeholder-zinc-500 outline-none transition focus:border-accent focus:ring-2 focus:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-50"
:class="error && 'border-err focus:border-err focus:ring-err/30'"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value as string | number)"
/>
<p v-if="error" :id="descId" class="mt-1 flex items-start gap-1 text-xs text-ember-400" role="alert">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" class="mt-px shrink-0" aria-hidden="true">
<circle cx="8" cy="8" r="6.5" stroke="currentColor" stroke-width="1.3" />
<path d="M8 5v3.5M8 11h.01" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" />
</svg>
{{ error }}
</p>
<p v-else-if="hint" :id="descId" class="mt-1 text-xs text-paper-600">{{ hint }}</p>
</div>
<span v-if="hint && !error" class="mt-1.5 block text-xs text-zinc-500">{{ hint }}</span>
<span v-if="error" class="mt-1.5 block text-xs text-red-400">{{ error }}</span>
</label>
</template>
+50 -65
View File
@@ -1,100 +1,85 @@
<script setup lang="ts">
import { ref, watch, onBeforeUnmount } from 'vue'
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { PhX } from '@phosphor-icons/vue'
const props = withDefaults(
defineProps<{ title: string; open: boolean; width?: string }>(),
defineProps<{
open: boolean
title?: string
width?: string
}>(),
{ width: 'max-w-md' },
)
const emit = defineEmits<{ (e: 'close'): void }>()
const emit = defineEmits<{ close: [] }>()
const dialogRef = ref<HTMLElement | null>(null)
const lastFocus = ref<HTMLElement | null>(null)
const panel = ref<HTMLElement | null>(null)
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
emit('close')
return
}
if (e.key === 'Tab' && dialogRef.value) {
// focus trap:Tab 循环
const focusables = dialogRef.value.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
)
if (!focusables.length) return
const first = focusables[0]
const last = focusables[focusables.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && props.open) emit('close')
}
onMounted(() => window.addEventListener('keydown', onKey))
onUnmounted(() => window.removeEventListener('keydown', onKey))
// 打开时锁背景滚动并聚焦面板,关闭时恢复
watch(
() => props.open,
(open) => {
async (open) => {
document.body.style.overflow = open ? 'hidden' : ''
if (open) {
lastFocus.value = document.activeElement as HTMLElement
document.body.style.overflow = 'hidden'
// 等 DOM 渲染后聚焦第一个可聚焦元素
requestAnimationFrame(() => {
const first = dialogRef.value?.querySelector<HTMLElement>('button, [href], input, select, textarea')
first?.focus()
})
} else {
document.body.style.overflow = ''
lastFocus.value?.focus()
lastFocus.value = null
await nextTick()
panel.value?.focus()
}
},
)
onBeforeUnmount(() => {
onUnmounted(() => {
document.body.style.overflow = ''
})
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<Transition
enter-active-class="transition-opacity duration-150"
enter-from-class="opacity-0"
leave-active-class="transition-opacity duration-150"
leave-to-class="opacity-0"
>
<div
v-if="open"
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 pt-[12vh]"
style="overscroll-behavior: contain; touch-action: manipulation"
@keydown="onKeydown"
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 p-4 pt-[12vh] backdrop-blur-sm"
@mousedown.self="emit('close')"
>
<Transition
enter-active-class="transition-transform duration-150"
enter-from-class="scale-[0.97] opacity-0"
leave-active-class="transition-transform duration-150"
leave-to-class="scale-[0.97] opacity-0"
>
<div class="fixed inset-0 bg-black/60 backdrop-blur-[2px]" aria-hidden="true" @click="emit('close')" />
<div
ref="dialogRef"
class="relative w-full rounded-lg border border-ink-700 bg-ink-900 shadow-2xl"
:class="width"
v-if="open"
ref="panel"
role="dialog"
aria-modal="true"
:aria-labelledby="`modal-title-${title}`"
:aria-label="title || '对话框'"
tabindex="-1"
class="card w-full shadow-xl outline-none"
:class="width"
>
<div class="flex items-center justify-between border-b border-ink-700 px-5 py-3.5">
<h3 :id="`modal-title-${title}`" class="text-sm font-semibold text-paper-100">{{ title }}</h3>
<button
class="touch-manipulation rounded-md p-1 text-paper-500 transition-colors hover:bg-ink-800 hover:text-paper-100 focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none cursor-pointer"
@click="emit('close')"
aria-label="关闭对话框"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
<div class="flex items-center justify-between border-b border-zinc-800 px-5 py-3.5">
<h3 class="text-sm font-semibold text-zinc-100">{{ title }}</h3>
<button class="rounded-md p-1 text-zinc-500 hover:bg-zinc-800 hover:text-zinc-200" aria-label="关闭" @click="emit('close')">
<PhX :size="16" weight="bold" />
</button>
</div>
<div class="px-5 py-4"><slot /></div>
<div class="px-5 py-4">
<slot />
</div>
<div v-if="$slots.footer" class="flex justify-end gap-2 border-t border-zinc-800 px-5 py-3.5">
<slot name="footer" />
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-enter-active, .modal-leave-active { transition: opacity 0.15s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
.modal-enter-active .relative, .modal-leave-active .relative { transition: transform 0.15s ease; }
.modal-enter-from .relative, .modal-leave-to .relative { transform: translateY(6px) scale(0.99); }
</style>
-48
View File
@@ -1,48 +0,0 @@
<script setup lang="ts">
import { useToastStore } from '../../stores/toast'
const toast = useToastStore()
const styles: Record<string, string> = {
success: 'border-mint-500/40 bg-mint-400/10 text-mint-300',
error: 'border-ember-500/40 bg-ember-500/10 text-ember-300',
info: 'border-sky-500/40 bg-sky-400/10 text-sky-300',
}
</script>
<template>
<div
class="pointer-events-none fixed right-4 top-4 z-[60] flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2"
aria-live="polite"
aria-atomic="false"
>
<TransitionGroup name="toast">
<div
v-for="t in toast.items"
:key="t.id"
class="pointer-events-auto flex items-start gap-2.5 rounded-md border bg-ink-900/95 px-3.5 py-2.5 text-[13px] shadow-lg backdrop-blur"
:class="styles[t.kind]"
role="status"
>
<span class="mt-px shrink-0" aria-hidden="true">
<svg v-if="t.kind === 'success'" width="14" height="14" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6.5" stroke="currentColor" stroke-width="1.3"/><path d="M5 8.2l2 2 4-4.4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
<svg v-else-if="t.kind === 'error'" width="14" height="14" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6.5" stroke="currentColor" stroke-width="1.3"/><path d="M5.5 5.5l5 5M10.5 5.5l-5 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
<svg v-else width="14" height="14" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6.5" stroke="currentColor" stroke-width="1.3"/><path d="M8 5v3.5M8 11h.01" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
</span>
<span class="min-w-0 flex-1 break-words">{{ t.message }}</span>
<button
class="shrink-0 text-current opacity-60 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-current focus:outline-none cursor-pointer"
:aria-label="`关闭通知`"
@click="toast.dismiss(t.id)"
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
</div>
</TransitionGroup>
</div>
</template>
<style scoped>
.toast-enter-active, .toast-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(-6px); }
</style>
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { useToastStore } from '@/stores/toast'
const toast = useToastStore()
</script>
<template>
<div class="fixed right-4 bottom-4 z-[80] flex w-80 flex-col gap-2" aria-live="polite">
<TransitionGroup
enter-active-class="transition-all duration-200"
enter-from-class="translate-y-1 opacity-0"
leave-active-class="transition-all duration-200"
leave-to-class="translate-y-1 opacity-0"
>
<div
v-for="t in toast.items"
:key="t.id"
class="card flex items-start gap-2.5 px-4 py-3 shadow-lg"
:class="t.type === 'err' && 'border-err/40'"
>
<span
class="mt-0.5 size-1.5 shrink-0 rounded-full"
:class="t.type === 'err' ? 'bg-err' : t.type === 'ok' ? 'bg-ok' : 'bg-zinc-500'"
/>
<p class="text-sm text-zinc-200">{{ t.msg }}</p>
</div>
</TransitionGroup>
</div>
</template>
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
points: { label: string; value: number }[]
height?: number
format?: (v: number) => string
}>(),
{ height: 160, format: (v: number) => String(v) },
)
const chart = computed(() => {
const max = Math.max(...props.points.map((p) => p.value), 1)
const bw = 100 / props.points.length
const bars = props.points.map((p, i) => ({
x: i * bw,
w: Math.max(bw * 0.5, 2),
h: (p.value / max) * 100,
label: p.label,
value: props.format(p.value),
}))
return { bars, maxLabel: props.format(max) }
})
</script>
<template>
<div class="w-full">
<div class="mb-1 flex items-baseline justify-between">
<span class="mono-num text-xs text-zinc-500">max {{ chart.maxLabel }}</span>
</div>
<svg
:viewBox="`0 0 100 ${height}`"
:height="height"
class="w-full"
preserveAspectRatio="none"
role="img"
:aria-label="`用量趋势,共 ${points.length} 天`"
>
<!-- 基线 -->
<line x1="0" :y1="height - 18" x2="100" :y2="height - 18" stroke="rgb(39 39 42)" stroke-width="0.6" />
<g v-for="b in chart.bars" :key="b.label">
<rect
:x="b.x"
:y="height - 18 - b.h"
:width="b.w"
:height="b.h"
rx="0.8"
class="fill-accent/80 hover:fill-accent"
/>
</g>
</svg>
<div class="mt-1 flex justify-between font-mono text-[10px] text-zinc-600">
<span v-for="b in chart.bars" :key="'l-' + b.label" class="truncate">{{ b.label }}</span>
</div>
</div>
</template>
+20
View File
@@ -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)
}
+5 -2
View File
@@ -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())
+24 -12
View File
@@ -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
+38 -39
View File
@@ -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<LoginResp>(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')
},
},
+15 -13
View File
@@ -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)
},
},
})
+43 -99
View File
@@ -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;
}
}
+80
View File
@@ -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<T> {
items: T[]
total: number
page: number
page_size: number
}
+138 -69
View File
@@ -1,105 +1,174 @@
<script setup lang="ts">
import { useAuthStore } from '../stores/auth'
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const primaryAction = computed(() => (auth.isAuthed ? '/console/dashboard' : '/register'))
const protocols = [
{ name: 'POST /v1/chat/completions', desc: 'OpenAI Chat Completions · 流式 + 工具调用' },
{ name: 'POST /v1/responses', desc: 'OpenAI Responses API · 新一代生态' },
{ name: 'POST /v1/messages', desc: 'Anthropic Messages · Claude 原生格式' },
{ name: 'GET /v1/models', desc: 'OpenAI 风格模型列表' },
]
</script>
<template>
<div class="min-h-[100dvh] bg-ink-950 text-paper-100">
<!-- 顶部导航 -->
<header class="sticky top-0 z-40 border-b border-ink-800 bg-ink-950/90 backdrop-blur">
<div class="mx-auto flex h-16 max-w-6xl items-center justify-between px-6">
<router-link to="/" class="flex items-center gap-2.5" aria-label="openteam 首页">
<span class="flex h-7 w-7 items-center justify-center rounded-md bg-signal-400" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M2 8h3l2-5 3 10 2-5h2" stroke="#0c0d0f" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
<span class="text-[15px] font-semibold tracking-tight" translate="no">openteam</span>
<span class="ml-1 rounded border border-ink-700 px-1.5 py-px font-mono text-[10px] text-paper-500" translate="no">relay</span>
</router-link>
<nav class="flex items-center gap-2" aria-label="站内导航">
<div class="min-h-[100dvh] bg-zinc-950 text-zinc-100">
<!-- 导航 -->
<header class="sticky top-0 z-40 border-b border-zinc-800/60 bg-zinc-950/80 backdrop-blur">
<div class="mx-auto flex h-14 max-w-6xl items-center justify-between px-4">
<div class="flex items-center gap-2">
<img src="/favicon.svg" alt="" class="size-5" />
<span class="text-sm font-semibold tracking-tight">openteam</span>
</div>
<nav class="flex items-center gap-2">
<router-link
v-if="!auth.isAuthed"
to="/login"
class="rounded-md px-3 py-2 text-sm text-paper-500 transition-colors hover:bg-ink-800 hover:text-paper-100 focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none"
>登录</router-link>
class="rounded-md px-3 py-1.5 text-sm text-zinc-400 transition hover:text-zinc-100"
>
登录
</router-link>
<router-link v-else to="/console/dashboard" class="rounded-md px-3 py-1.5 text-sm text-zinc-400 transition hover:text-zinc-100">
控制台
</router-link>
<router-link
:to="primaryAction"
class="inline-flex h-9 touch-manipulation items-center rounded-md bg-signal-400 px-4 text-sm font-medium text-ink-950 transition-colors hover:bg-signal-300 focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none"
>开始使用</router-link>
v-if="!auth.isAuthed"
to="/register"
class="rounded-md bg-accent px-3.5 py-1.5 text-sm font-medium text-zinc-950 transition hover:bg-accent-strong"
>
免费注册
</router-link>
</nav>
</div>
</header>
<main id="landing-main">
<!-- Hero:左对齐,信号路径示意 -->
<section class="mx-auto grid max-w-6xl grid-cols-1 items-center gap-14 px-6 pt-16 pb-20 lg:grid-cols-[1.1fr_0.9fr]">
<div>
<p class="font-mono text-xs uppercase tracking-[0.2em] text-signal-400" translate="no">self-hosted llm relay</p>
<h1 class="mt-4 text-4xl leading-[1.05] font-bold tracking-tight text-balance md:text-5xl">
一个 Key,<br />接入全部模型
<!-- Hero:左文案 + 右真实调用演示 -->
<section class="relative overflow-hidden">
<div class="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-emerald-500/40 to-transparent" />
<div class="mx-auto grid max-w-6xl items-center gap-10 px-4 pt-20 pb-16 lg:grid-cols-2 lg:pt-24">
<div class="max-w-xl">
<p class="mb-3 font-mono text-xs tracking-wide text-emerald-400/80">LLM API 中转网关</p>
<h1 class="text-4xl leading-none font-semibold tracking-tight md:text-5xl">
一个 Key,调用所有主流模型
</h1>
<p class="mt-5 max-w-[52ch] text-base leading-relaxed text-paper-500">
自托管 LLM API 中转网关。统一 OpenAI 与 Anthropic 协议入口,背后对接任意上游渠道,用量计费一目了然。
<p class="mt-5 max-w-md text-base leading-relaxed text-zinc-400">
统一 OpenAI 与 Anthropic 协议入口,三套 API 自动互转,用量、计费与 API Key 管理开箱即用。
</p>
<div class="mt-8 flex items-center gap-3">
<router-link
to="/register"
class="inline-flex h-10 touch-manipulation items-center rounded-md bg-signal-400 px-5 text-sm font-medium text-ink-950 transition-[background-color,transform] hover:bg-signal-300 active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none"
>立即开始</router-link>
<a
href="#protocols"
class="inline-flex h-10 touch-manipulation items-center rounded-md border border-ink-600 px-5 text-sm text-paper-300 transition-colors hover:border-signal-400 hover:text-signal-300 focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none"
>查看端点</a>
class="inline-flex h-10 items-center rounded-md bg-accent px-5 text-sm font-medium text-zinc-950 transition hover:bg-accent-strong active:scale-[0.98]"
>
免费注册
</router-link>
<router-link
to="/login"
class="inline-flex h-10 items-center rounded-md border border-zinc-700 px-5 text-sm text-zinc-200 transition hover:border-zinc-500"
>
登录
</router-link>
</div>
<p class="mt-5 font-mono text-xs text-zinc-600">不用换 SDK,改一行 base_url 即可接入</p>
</div>
<!-- 信号路径:client → gateway → upstream -->
<div class="rounded-lg border border-ink-700 bg-ink-900 p-5 font-mono text-[13px]" aria-label="请求链路示意">
<div class="flex items-center gap-3 pb-4">
<span class="h-1.5 w-1.5 animate-pulse rounded-full bg-mint-400" aria-hidden="true" />
<span class="text-xs text-paper-500" translate="no">request path · live</span>
<!-- 调用演示(真实格式,非伪截图) -->
<div class="card overflow-hidden font-mono text-xs">
<div class="flex items-center gap-1.5 border-b border-zinc-800 px-4 py-2.5">
<span class="size-2.5 rounded-full bg-zinc-700" />
<span class="size-2.5 rounded-full bg-zinc-700" />
<span class="ml-2 text-zinc-500">curl api.openteam.dev</span>
</div>
<div class="space-y-3 p-4 leading-relaxed">
<div>
<p class="text-zinc-400"><span class="text-emerald-400">$</span> curl https://api.openteam.dev/v1/chat/completions</p>
<p class="text-zinc-400"> -H <span class="text-emerald-300">"Authorization: Bearer sk-..."</span> \</p>
<p class="text-zinc-400"> -d <span class="text-zinc-300">'{"model": "claude-sonnet-5", "messages": [{"role": "user", "content": "你好"}]}'</span></p>
</div>
<div class="border-t border-zinc-800 pt-3 text-zinc-500">
<p class="text-zinc-600"># OpenAI 格式请求,网关自动转 Anthropic 协议</p>
<p class="text-zinc-300">data: {"id":"resp_1","model":"claude-sonnet-5","choices":[{</p>
<p class="text-zinc-300">&nbsp;&nbsp;"delta":{"content":"你好,这是流式回复"}</p>
<p class="text-zinc-300">}]}</p>
<p class="text-zinc-300">data: [DONE]</p>
</div>
</div>
<div class="space-y-2">
<div class="rounded-md border border-ink-700 bg-ink-850 px-3 py-2 text-paper-300">client <span class="text-signal-400" aria-hidden="true">──▶</span> <span class="text-paper-500" translate="no">/v1/chat/completions</span></div>
<div class="rounded-md border border-ink-700 bg-ink-850 px-3 py-2 text-paper-300">gateway <span class="text-signal-400" aria-hidden="true">──▶</span> <span class="text-paper-500" translate="no">auth · quota · route</span></div>
<div class="rounded-md border border-ink-700 bg-ink-850 px-3 py-2 text-paper-300">upstream <span class="text-mint-400" aria-hidden="true">◀──</span> <span class="text-paper-500" translate="no">openai / anthropic</span></div>
<div class="rounded-md border border-ink-700 bg-ink-850 px-3 py-2 text-paper-300">billing <span class="text-mint-400" aria-hidden="true">──▶</span> <span class="num text-paper-500" translate="no">usage · cost · ledger</span></div>
</div>
</div>
</section>
<!-- 协议端点 -->
<section id="protocols" class="scroll-mt-16 border-t border-ink-800 bg-ink-900/50">
<div class="mx-auto max-w-6xl px-6 py-16">
<h2 class="text-xl font-semibold tracking-tight">三套协议,一个入口</h2>
<p class="mt-2 max-w-[60ch] text-sm leading-relaxed text-paper-500">
OpenAI 与 Anthropic 生态的 SDK 与客户端无需改动,直接指向本网关。协议间自动互转。
<!-- 协议入口 -->
<section class="border-t border-zinc-800/60">
<div class="mx-auto max-w-6xl px-4 py-16">
<h2 class="text-2xl font-semibold tracking-tight">三套协议,一个入口</h2>
<p class="mt-2 max-w-xl text-sm text-zinc-500">
对客户端暴露统一的 OpenAI 兼容入口;客户端协议与上游渠道不匹配时自动转换,无需关心背后接的是哪家。
</p>
<div class="mt-8 grid grid-cols-1 gap-3 md:grid-cols-2">
<div v-for="p in protocols" :key="p.name" class="rounded-lg border border-ink-700 bg-ink-900 p-4">
<code class="font-mono text-[13px] text-signal-300" translate="no">{{ p.name }}</code>
<p class="mt-1.5 text-[13px] text-paper-500">{{ p.desc }}</p>
<div class="card mt-8 divide-y divide-zinc-800/70">
<div v-for="p in [
{ path: 'POST /v1/chat/completions', desc: 'OpenAI Chat,兼容面最广,SDK 与工具链最全', tag: 'OpenAI' },
{ path: 'POST /v1/responses', desc: 'OpenAI Responses,新一代 SDK 与 Agents 首选', tag: 'OpenAI' },
{ path: 'POST /v1/messages', desc: 'Anthropic Messages,Claude 生态原生格式', tag: 'Anthropic' },
{ path: 'GET /v1/models', desc: 'OpenAI 风格模型列表', tag: 'List' },
]" :key="p.path" class="grid gap-1 px-5 py-3.5 sm:grid-cols-3 sm:items-center">
<code class="font-mono text-sm text-emerald-300">{{ p.path }}</code>
<p class="text-sm text-zinc-400 sm:col-span-1">{{ p.desc }}</p>
<span class="hidden justify-self-end font-mono text-[11px] text-zinc-600 sm:block">{{ p.tag }}</span>
</div>
</div>
</div>
</section>
<!-- 页脚 -->
<footer class="border-t border-ink-800">
<div class="mx-auto flex max-w-6xl items-center justify-between px-6 py-6 text-xs text-paper-600">
<span>openteam · 自托管 LLM 中转网关</span>
<span class="font-mono" translate="no">v0.1 · M1</span>
<!-- 网关能力 -->
<section class="border-t border-zinc-800/60">
<div class="mx-auto max-w-6xl px-4 py-16">
<h2 class="text-2xl font-semibold tracking-tight">网关替你处理的事</h2>
<div class="mt-8 grid gap-4 lg:grid-cols-3">
<div class="card p-5 lg:col-span-2">
<p class="font-mono text-xs text-emerald-400/80">responses → claude-sonnet-5</p>
<h3 class="mt-2 text-lg font-semibold">协议自动转换</h3>
<p class="mt-2 text-sm leading-relaxed text-zinc-500">
客户端按 Responses 调用 Claude 模型,网关转成 Anthropic 协议打给上游,再以 Responses 事件流式返回。直通优先,能力无损时零转换。
</p>
</div>
<div class="card p-5">
<p class="font-mono text-xs text-zinc-600">usage · daily</p>
<h3 class="mt-2 text-lg font-semibold">用量与计费</h3>
<p class="mt-2 text-sm leading-relaxed text-zinc-500">
请求级 token 统计,按模型价格自动扣费,日粒度报表与余额流水可追溯。
</p>
</div>
<div class="card p-5">
<p class="font-mono text-xs text-zinc-600">sk-…</p>
<h3 class="mt-2 text-lg font-semibold">API Key 管理</h3>
<p class="mt-2 text-sm leading-relaxed text-zinc-500">
密钥仅存哈希,支持配额、过期与模型白名单,创建时一次性展示。
</p>
</div>
<div class="card p-5 lg:col-span-2">
<p class="font-mono text-xs text-zinc-600">channels · lb · health</p>
<h3 class="mt-2 text-lg font-semibold">多渠道接入</h3>
<p class="mt-2 text-sm leading-relaxed text-zinc-500">
一个模型绑定多个上游渠道,按权重与健康状态选择,故障自动转移。管理员在后台一键接入新渠道、导入模型并定价。
</p>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section class="border-t border-zinc-800/60">
<div class="mx-auto max-w-6xl px-4 py-20 text-center">
<h2 class="text-2xl font-semibold tracking-tight">自托管,密钥在自己手里</h2>
<p class="mx-auto mt-3 max-w-md text-sm text-zinc-500">Docker Compose 一键部署,PostgreSQL 存账,渠道密钥加密存储。</p>
<router-link
to="/register"
class="mt-8 inline-flex h-10 items-center rounded-md bg-accent px-6 text-sm font-medium text-zinc-950 transition hover:bg-accent-strong active:scale-[0.98]"
>
开始使用
</router-link>
</div>
</section>
<footer class="border-t border-zinc-800/60">
<div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-6 text-xs text-zinc-600">
<div class="flex items-center gap-2">
<img src="/favicon.svg" alt="" class="size-4" />
<span>openteam · LLM API 中转站</span>
</div>
<span class="font-mono">© 2026</span>
</div>
</footer>
</main>
</div>
</template>
+30 -55
View File
@@ -1,32 +1,33 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import Input from '../components/ui/Input.vue'
import Button from '../components/ui/Button.vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
import { errMsg } from '@/api/client'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const toast = useToastStore()
const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
const error = ref('')
async function submit() {
error.value = ''
if (!username.value.trim() || !password.value) {
error.value = '请输入用户名和密码'
return
}
if (!username.value || !password.value) return
loading.value = true
error.value = ''
try {
await auth.login(username.value.trim(), password.value)
await auth.login(username.value, password.value)
toast.ok('登录成功')
const redirect = (route.query.redirect as string) || '/console/dashboard'
router.push(redirect)
} catch (e: any) {
error.value = e.response?.data?.error?.message || '登录失败,请检查用户名与密码'
} catch (e) {
error.value = errMsg(e)
} finally {
loading.value = false
}
@@ -34,52 +35,26 @@ async function submit() {
</script>
<template>
<div class="flex min-h-[100dvh] items-center justify-center bg-ink-950 px-4">
<div class="flex min-h-[100dvh] items-center justify-center bg-zinc-950 px-4">
<div class="w-full max-w-sm">
<div class="mb-8 flex items-center gap-2.5">
<span class="flex h-7 w-7 items-center justify-center rounded-md bg-signal-400" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M2 8h3l2-5 3 10 2-5h2" stroke="#0c0d0f" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
<span class="text-[15px] font-semibold tracking-tight" translate="no">openteam</span>
<div class="mb-8 text-center">
<div class="mb-3 inline-flex items-center gap-2">
<img src="/favicon.svg" alt="" class="size-7" />
<span class="text-lg font-semibold tracking-tight">openteam</span>
</div>
<p class="text-sm text-zinc-500">登录到控制台</p>
</div>
<h1 class="text-xl font-semibold tracking-tight">登录控制台</h1>
<p class="mt-1 text-sm text-paper-500">管理密钥、查看用量与余额</p>
<form class="mt-8 space-y-4" @submit.prevent="submit" novalidate>
<Input
v-model="username"
label="用户名或邮箱"
name="username"
placeholder="alice"
autocomplete="username"
:spellcheck="false"
autofocus
/>
<Input
v-model="password"
label="密码"
name="password"
type="password"
placeholder="输入密码…"
autocomplete="current-password"
required
/>
<p
v-if="error"
class="rounded-md border border-ember-500/40 bg-ember-500/10 px-3 py-2 text-[13px] text-ember-300"
role="alert"
aria-live="polite"
>{{ error }}</p>
<Button type="submit" class="w-full" :loading="loading">登录</Button>
<form class="card space-y-4 p-6" @submit.prevent="submit">
<Input v-model="username" label="用户名或邮箱" autocomplete="username" placeholder="alice" />
<Input v-model="password" label="密码" type="password" autocomplete="current-password" />
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
<Button class="w-full" :loading="loading" type="submit">登录</Button>
</form>
<p class="mt-6 text-center text-[13px] text-paper-500">
还没有账号?
<router-link to="/register" class="text-signal-300 transition-colors hover:text-signal-200">注册</router-link>
</p>
<p class="mt-4 text-center">
<router-link to="/" class="text-xs text-paper-600 transition-colors hover:text-paper-500">← 返回首页</router-link>
<p class="mt-5 text-center text-sm text-zinc-500">
还没有账号?
<router-link to="/register" class="text-accent hover:text-accent-strong">注册</router-link>
</p>
</div>
</div>
+38 -80
View File
@@ -1,44 +1,40 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import Input from '../components/ui/Input.vue'
import Button from '../components/ui/Button.vue'
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
import { errMsg } from '@/api/client'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
const auth = useAuthStore()
const router = useRouter()
const auth = useAuthStore()
const toast = useToastStore()
const username = ref('')
const email = ref('')
const password = ref('')
const confirm = ref('')
const formError = ref('')
const errors = ref<Record<string, string>>({})
const loading = ref(false)
function validate() {
const e: Record<string, string> = {}
if (!username.value.trim()) e.username = '请输入用户名'
else if (username.value.length < 3) e.username = '用户名至少 3 个字符'
if (!email.value.trim()) e.email = '请输入邮箱'
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value)) e.email = '邮箱格式不正确'
if (!password.value) e.password = '请输入密码'
else if (password.value.length < 8) e.password = '密码至少 8 位'
if (confirm.value !== password.value) e.confirm = '两次输入的密码不一致'
errors.value = e
return Object.keys(e).length === 0
}
const error = ref('')
async function submit() {
formError.value = ''
if (!validate()) return
if (!username.value || !email.value) {
error.value = '请填写用户名和邮箱'
return
}
if (password.value.length < 8) {
error.value = '密码至少 8 位'
return
}
loading.value = true
error.value = ''
try {
await auth.register(username.value.trim(), email.value.trim(), password.value)
await auth.login(username.value.trim(), password.value)
await auth.register(username.value, email.value, password.value)
await auth.login(username.value, password.value)
toast.ok('注册成功,欢迎使用')
router.push('/console/dashboard')
} catch (e: any) {
formError.value = e.response?.data?.error?.message || '注册失败,请稍后重试'
} catch (e) {
error.value = errMsg(e)
} finally {
loading.value = false
}
@@ -46,71 +42,33 @@ async function submit() {
</script>
<template>
<div class="flex min-h-[100dvh] items-center justify-center bg-ink-950 px-4">
<div class="flex min-h-[100dvh] items-center justify-center bg-zinc-950 px-4">
<div class="w-full max-w-sm">
<div class="mb-8 flex items-center gap-2.5">
<span class="flex h-7 w-7 items-center justify-center rounded-md bg-signal-400" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M2 8h3l2-5 3 10 2-5h2" stroke="#0c0d0f" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
<span class="text-[15px] font-semibold tracking-tight" translate="no">openteam</span>
<div class="mb-8 text-center">
<div class="mb-3 inline-flex items-center justify-center gap-2">
<img src="/favicon.svg" alt="" class="size-7" />
<span class="text-lg font-semibold tracking-tight">openteam</span>
</div>
<p class="text-sm text-zinc-500">一个 Key 访问多家模型</p>
</div>
<h1 class="text-xl font-semibold tracking-tight">创建账号</h1>
<p class="mt-1 text-sm text-paper-500">注册即赠体验额度,一个 Key 接入全部模型</p>
<form class="mt-8 space-y-4" @submit.prevent="submit" novalidate>
<Input
v-model="username"
label="用户名"
name="username"
placeholder="alice"
autocomplete="username"
:spellcheck="false"
:error="errors.username"
autofocus
/>
<Input
v-model="email"
label="邮箱"
name="email"
type="email"
inputmode="email"
placeholder="alice@example.com"
autocomplete="email"
:spellcheck="false"
:error="errors.email"
/>
<form class="card space-y-4 p-6" @submit.prevent="submit">
<Input v-model="username" label="用户名" autocomplete="username" placeholder="alice" />
<Input v-model="email" label="邮箱" type="email" autocomplete="email" placeholder="alice@example.com" />
<Input
v-model="password"
label="密码"
name="password"
type="password"
placeholder="至少 8 位…"
autocomplete="new-password"
hint="使用 argon2id 加密存储"
:error="errors.password"
hint="至少 8 位"
/>
<Input
v-model="confirm"
label="确认密码"
name="confirm"
type="password"
placeholder="再次输入…"
autocomplete="new-password"
:error="errors.confirm"
/>
<p
v-if="formError"
class="rounded-md border border-ember-500/40 bg-ember-500/10 px-3 py-2 text-[13px] text-ember-300"
role="alert"
aria-live="polite"
>{{ formError }}</p>
<Button type="submit" class="w-full" :loading="loading">注册</Button>
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
<Button class="w-full" :loading="loading" type="submit">注册</Button>
</form>
<p class="mt-6 text-center text-[13px] text-paper-500">
已有账号?
<router-link to="/login" class="text-signal-300 transition-colors hover:text-signal-200">登录</router-link>
<p class="mt-5 text-center text-sm text-zinc-500">
已有账号?
<router-link to="/login" class="text-accent hover:text-accent-strong">登录</router-link>
</p>
</div>
</div>
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import ShellLayout from '@/components/layout/ShellLayout.vue'
const auth = useAuthStore()
const sections = computed(() => {
const s: { title: string; items: { to: string; label: string }[] }[] = [
{ title: '管理', items: [
{ to: '/admin/overview', label: '运营总览' },
{ to: '/admin/channels', label: '渠道' },
{ to: '/admin/models', label: '模型与定价' },
{ to: '/admin/users', label: '用户' },
{ to: '/admin/config', label: '系统配置' },
] },
]
if (auth.user) {
s.push({ title: '控制台', items: [
{ to: '/console/dashboard', label: '仪表盘' },
{ to: '/console/keys', label: 'API Keys' },
{ to: '/console/usage', label: '用量' },
] })
}
return s
})
</script>
<template>
<ShellLayout :sections="sections">
<router-view />
</ShellLayout>
</template>
+216
View File
@@ -0,0 +1,216 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
import Modal from '@/components/ui/Modal.vue'
import Badge from '@/components/ui/Badge.vue'
import type { Channel } from '@/types'
const toast = useToastStore()
const channels = ref<Channel[]>([])
const editOpen = ref(false)
const editing = ref<Channel | null>(null)
const saving = ref(false)
const busyId = ref<number | null>(null)
const form = reactive({
name: '',
provider: 'openai' as 'openai' | 'anthropic' | 'compatible',
base_url: '',
api_key: '',
weight: 1,
priority: 0,
timeout_ms: 120000,
max_concurrency: 16,
enabled: true,
})
const providerMap: Record<string, string> = {
openai: 'OpenAI',
anthropic: 'Anthropic',
compatible: '兼容',
}
async function load() {
try {
const { data } = await http.get('/admin/channels')
channels.value = data.data.items
} catch (e) {
toast.err(errMsg(e))
}
}
function openCreate() {
editing.value = null
Object.assign(form, {
name: '', provider: 'openai', base_url: '', api_key: '',
weight: 1, priority: 0, timeout_ms: 120000, max_concurrency: 16, enabled: true,
})
editOpen.value = true
}
function openEdit(ch: Channel) {
editing.value = ch
Object.assign(form, {
name: ch.name, provider: ch.provider, base_url: ch.base_url, api_key: '',
weight: ch.weight, priority: ch.priority, timeout_ms: ch.timeout_ms,
max_concurrency: ch.max_concurrency, enabled: ch.enabled,
})
editOpen.value = true
}
async function save() {
saving.value = true
const payload = {
...form,
weight: Number(form.weight),
priority: Number(form.priority),
timeout_ms: Number(form.timeout_ms),
max_concurrency: Number(form.max_concurrency),
}
try {
if (editing.value) {
await http.put(`/admin/channels/${editing.value.id}`, payload)
toast.ok('渠道已更新')
} else {
await http.post('/admin/channels', payload)
toast.ok('渠道已创建')
}
editOpen.value = false
await load()
} catch (e) {
toast.err(errMsg(e))
} finally {
saving.value = false
}
}
async function remove(ch: Channel) {
if (!confirm(`删除渠道 ${ch.name}?关联的模型绑定也会清除。`)) return
try {
await http.delete(`/admin/channels/${ch.id}`)
toast.ok('渠道已删除')
await load()
} catch (e) {
toast.err(errMsg(e))
}
}
async function testChannel(ch: Channel) {
busyId.value = ch.id
try {
await http.post(`/admin/channels/${ch.id}/test`)
toast.ok(`渠道 ${ch.name} 连接正常`)
} catch (e) {
toast.err(`连接失败: ${errMsg(e)}`)
} finally {
busyId.value = null
await load()
}
}
async function importModels(ch: Channel) {
busyId.value = ch.id
try {
const { data } = await http.post(`/admin/channels/${ch.id}/models/import`)
toast.ok(`已导入 ${data.data.imported} 个模型`)
} catch (e) {
toast.err(errMsg(e))
} finally {
busyId.value = null
}
}
onMounted(load)
</script>
<template>
<div class="mx-auto max-w-6xl">
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-lg font-semibold">渠道</h1>
<p class="text-sm text-zinc-500">接入上游服务,API Key 加密存储</p>
</div>
<Button @click="openCreate">添加渠道</Button>
</div>
<div class="card">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-zinc-800 text-left text-xs text-zinc-500">
<th scope="col" class="px-4 py-2.5 font-medium">名称</th>
<th scope="col" class="px-4 py-2.5 font-medium">类型</th>
<th scope="col" class="px-4 py-2.5 font-medium">Base URL</th>
<th scope="col" class="px-4 py-2.5 font-medium">Key</th>
<th scope="col" class="px-4 py-2.5 font-medium">健康</th>
<th scope="col" class="px-4 py-2.5 font-medium">启用</th>
<th scope="col" class="px-4 py-2.5" />
</tr>
</thead>
<tbody>
<tr v-for="ch in channels" :key="ch.id" class="table-row">
<td class="px-4 py-2.5 text-zinc-200">{{ ch.name }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-400">{{ providerMap[ch.provider] }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ ch.base_url }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-600">{{ ch.api_key_masked || '****' }}</td>
<td class="px-4 py-2.5">
<Badge :variant="ch.health_status === 'healthy' ? 'ok' : ch.health_status === 'cooldown' ? 'err' : 'warn'">
{{ ch.health_status }}
</Badge>
</td>
<td class="px-4 py-2.5 text-xs text-zinc-400">{{ ch.enabled ? '是' : '否' }}</td>
<td class="px-4 py-2.5 text-right">
<div class="flex justify-end gap-2">
<button class="text-xs text-zinc-500 hover:text-accent" :disabled="busyId === ch.id" @click="testChannel(ch)">
{{ busyId === ch.id ? '测试中…' : '测试' }}
</button>
<button class="text-xs text-zinc-500 hover:text-accent" @click="importModels(ch)">导入模型</button>
<button class="text-xs text-zinc-500 hover:text-zinc-200" @click="openEdit(ch)">编辑</button>
<button class="text-xs text-zinc-500 hover:text-red-400" @click="remove(ch)">删除</button>
</div>
</td>
</tr>
<tr v-if="channels.length === 0">
<td colspan="7" class="px-4 py-10 text-center text-sm text-zinc-600">还没有渠道,点击「添加渠道」</td>
</tr>
</tbody>
</table>
</div>
</div>
<Modal :open="editOpen" :title="editing ? '编辑渠道' : '添加渠道'" @close="editOpen = false">
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<Input v-model="form.name" label="名称" placeholder="openai" />
<label class="block">
<span class="mb-1.5 block text-xs font-medium text-zinc-400">API 类型</span>
<select v-model="form.provider" class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm text-zinc-100 outline-none focus:border-accent">
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="compatible">兼容</option>
</select>
</label>
</div>
<Input v-model="form.base_url" label="Base URL" placeholder="https://api.openai.com" />
<Input
v-model="form.api_key"
label="上游 API Key"
:placeholder="editing ? '留空则不修改' : 'sk-...'"
/>
<div class="grid grid-cols-2 gap-4">
<Input v-model="form.weight" label="权重" type="number" />
<Input v-model="form.priority" label="优先级" type="number" />
<Input v-model="form.timeout_ms" label="超时 (ms)" type="number" />
<Input v-model="form.max_concurrency" label="最大并发" type="number" />
</div>
</div>
<template #footer>
<Button variant="ghost" @click="editOpen = false">取消</Button>
<Button :loading="saving" @click="save">{{ editing ? '保存' : '创建' }}</Button>
</template>
</Modal>
</div>
</template>
+96
View File
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
import Button from '@/components/ui/Button.vue'
const toast = useToastStore()
const config = reactive<Record<string, string>>({})
const loading = ref(false)
const saving = ref(false)
async function load() {
loading.value = true
try {
const { data } = await http.get('/admin/config')
Object.keys(config).forEach((k) => delete config[k])
Object.assign(config, data.data.config)
} catch (e) {
toast.err(errMsg(e))
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
try {
await http.put('/admin/config', config)
toast.ok('配置已保存')
} catch (e) {
toast.err(errMsg(e))
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<template>
<div class="mx-auto max-w-2xl">
<div class="mb-6">
<h1 class="text-lg font-semibold">系统配置</h1>
<p class="text-sm text-zinc-500">注册策略等平台级配置</p>
</div>
<div class="card space-y-5 p-6">
<div v-if="loading" class="py-8 text-center text-sm text-zinc-600">加载中…</div>
<template v-else>
<div class="space-y-4">
<div>
<p class="mb-1.5 text-xs font-medium text-zinc-400">注册模式</p>
<div class="flex gap-2">
<button
class="flex-1 rounded-md border px-3 py-2 text-sm transition"
:class="config.registration_mode === 'open' ? 'border-accent bg-accent/10 text-emerald-300' : 'border-zinc-700 text-zinc-400'"
@click="config.registration_mode = 'open'"
>
开放注册
</button>
<button
class="flex-1 rounded-md border px-3 py-2 text-sm transition"
:class="config.registration_mode === 'invite' ? 'border-accent bg-accent/10 text-emerald-300' : 'border-zinc-700 text-zinc-400'"
@click="config.registration_mode = 'invite'"
>
邀请码
</button>
</div>
<p class="mt-1.5 text-xs text-zinc-600">邀请模式下注册需填写有效邀请码(invite_codes 配置)</p>
</div>
<div>
<p class="mb-1.5 text-xs font-medium text-zinc-400">邀请码(逗号分隔)</p>
<input
v-model="config.invite_codes"
placeholder="code1,code2"
class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 font-mono text-xs outline-none focus:border-accent"
/>
</div>
<div class="flex items-center justify-between rounded-md border border-zinc-800 bg-zinc-900/60 px-4 py-3">
<div>
<p class="text-sm text-zinc-300">其他配置项</p>
<p class="text-xs text-zinc-600">汇率、限流阈值、维护开关在后续里程碑开放</p>
</div>
<span class="font-mono text-xs text-zinc-600">M3+</span>
</div>
</div>
<div class="flex justify-end">
<Button :loading="saving" @click="save">保存</Button>
</div>
</template>
</div>
</div>
</template>
+215
View File
@@ -0,0 +1,215 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
import Modal from '@/components/ui/Modal.vue'
import Badge from '@/components/ui/Badge.vue'
import type { Channel, Model } from '@/types'
const toast = useToastStore()
const models = ref<Model[]>([])
const channels = ref<Channel[]>([])
const editOpen = ref(false)
const editing = ref<Model | null>(null)
const saving = ref(false)
const bindOpen = ref(false)
const bindModel = ref<Model | null>(null)
const binding = reactive({ channel_id: 0, upstream_model: '', weight: 1 })
const form = reactive({
name: '',
display_name: '',
input_price: 0,
output_price: 0,
cache_read_price: 0,
enabled: true,
})
async function load() {
try {
const [m, c] = await Promise.all([http.get('/admin/models'), http.get('/admin/channels')])
models.value = m.data.data.items
channels.value = c.data.data.items
} catch (e) {
toast.err(errMsg(e))
}
}
function openCreate() {
editing.value = null
Object.assign(form, { name: '', display_name: '', input_price: 0, output_price: 0, cache_read_price: 0, enabled: true })
editOpen.value = true
}
function openEdit(m: Model) {
editing.value = m
Object.assign(form, {
name: m.name, display_name: m.display_name,
input_price: m.input_price, output_price: m.output_price, cache_read_price: m.cache_read_price,
enabled: m.enabled,
})
editOpen.value = true
}
async function save() {
saving.value = true
const payload = {
display_name: form.display_name || form.name,
input_price: Number(form.input_price),
output_price: Number(form.output_price),
cache_read_price: Number(form.cache_read_price),
enabled: form.enabled,
}
try {
if (editing.value) {
await http.put(`/admin/models/${editing.value.id}`, payload)
toast.ok('模型已更新')
} else {
await http.post('/admin/models', { name: form.name, ...payload })
toast.ok('模型已创建')
}
editOpen.value = false
await load()
} catch (e) {
toast.err(errMsg(e))
} finally {
saving.value = false
}
}
async function removeModel(m: Model) {
if (!confirm(`删除模型 ${m.name}?`)) return
try {
await http.delete(`/admin/models/${m.id}`)
toast.ok('模型已删除')
await load()
} catch (e) {
toast.err(errMsg(e))
}
}
function openBind(m: Model) {
bindModel.value = m
Object.assign(binding, { channel_id: channels.value[0]?.id ?? 0, upstream_model: m.name, weight: 1 })
bindOpen.value = true
}
async function saveBinding() {
if (!bindModel.value) return
try {
await http.post(`/admin/models/${bindModel.value.id}/bindings`, {
...binding,
weight: Number(binding.weight),
})
toast.ok('绑定已添加')
bindOpen.value = false
await load()
} catch (e) {
toast.err(errMsg(e))
}
}
async function removeBinding(m: Model, bid: number) {
try {
await http.delete(`/admin/models/${m.id}/bindings/${bid}`)
toast.ok('绑定已移除')
await load()
} catch (e) {
toast.err(errMsg(e))
}
}
onMounted(load)
</script>
<template>
<div class="mx-auto max-w-6xl">
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-lg font-semibold">模型与定价</h1>
<p class="text-sm text-zinc-500">价格按每百万 token (USD),历史用量按当时价格入账</p>
</div>
<Button @click="openCreate">添加模型</Button>
</div>
<div class="space-y-3">
<div v-for="m in models" :key="m.id" class="card">
<div class="flex items-center justify-between px-4 py-3">
<div class="flex items-center gap-3">
<span class="font-mono text-sm text-zinc-100">{{ m.name }}</span>
<Badge :variant="m.enabled ? 'ok' : 'neutral'">{{ m.enabled ? '启用' : '停用' }}</Badge>
</div>
<div class="flex items-center gap-3">
<span class="mono-num text-xs text-zinc-400">入 {{ m.input_price }}</span>
<span class="mono-num text-xs text-zinc-400">出 {{ m.output_price }}</span>
<span class="mono-num text-xs text-zinc-500">缓存读 {{ m.cache_read_price }}</span>
</div>
<div class="flex gap-2">
<button class="text-xs text-zinc-500 hover:text-accent" @click="openBind(m)">绑定渠道</button>
<button class="text-xs text-zinc-500 hover:text-zinc-200" @click="openEdit(m)">编辑</button>
<button class="text-xs text-zinc-500 hover:text-red-400" @click="removeModel(m)">删除</button>
</div>
</div>
<div v-if="m.channels.length" class="border-t border-zinc-800/70 px-4 py-2">
<div class="flex flex-wrap gap-2">
<span
v-for="b in m.channels"
:key="b.id"
class="inline-flex items-center gap-1.5 rounded-md border border-zinc-800 bg-zinc-900 px-2 py-0.5 font-mono text-[11px] text-zinc-400"
>
{{ b.channel_name }} → {{ b.upstream_model }}
<button class="text-zinc-400 hover:text-red-400" aria-label="移除绑定" @click="removeBinding(m, b.id)">×</button>
</span>
</div>
</div>
<p v-else class="border-t border-zinc-800/70 px-4 py-2 text-xs text-zinc-600">
未绑定渠道,客户端无法调用该模型
</p>
</div>
<p v-if="models.length === 0" class="card px-4 py-10 text-center text-sm text-zinc-600">
还没有模型,点击「添加模型」或到渠道页「导入模型」
</p>
</div>
<!-- 模型编辑 -->
<Modal :open="editOpen" :title="editing ? '编辑模型' : '添加模型'" @close="editOpen = false">
<div class="space-y-4">
<Input v-model="form.name" label="模型名" placeholder="claude-sonnet-5" :disabled="!!editing" />
<Input v-model="form.display_name" label="展示名" />
<div class="grid grid-cols-2 gap-4">
<Input v-model="form.input_price" label="输入价格 /1M" type="number" />
<Input v-model="form.output_price" label="输出价格 /1M" type="number" />
<Input v-model="form.cache_read_price" label="缓存读价格 /1M" type="number" />
</div>
</div>
<template #footer>
<Button variant="ghost" @click="editOpen = false">取消</Button>
<Button :loading="saving" @click="save">{{ editing ? '保存' : '创建' }}</Button>
</template>
</Modal>
<!-- 绑定渠道 -->
<Modal :open="bindOpen" title="绑定渠道" @close="bindOpen = false">
<div class="space-y-4">
<p class="text-xs text-zinc-500">模型 <span class="font-mono text-emerald-300">{{ bindModel?.name }}</span> 通过以下渠道提供</p>
<label class="block">
<span class="mb-1.5 block text-xs font-medium text-zinc-400">渠道</span>
<select v-model="binding.channel_id" class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm text-zinc-100 outline-none focus:border-accent">
<option v-for="ch in channels" :key="ch.id" :value="ch.id">{{ ch.name }}</option>
</select>
</label>
<Input v-model="binding.upstream_model" label="上游模型名" placeholder="与渠道侧一致" />
<Input v-model="binding.weight" label="权重" type="number" />
</div>
<template #footer>
<Button variant="ghost" @click="bindOpen = false">取消</Button>
<Button @click="saveBinding">绑定</Button>
</template>
</Modal>
</div>
</template>
+93
View File
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
import { fmtNum, fmtCost, fmtTime } from '@/lib/format'
import Badge from '@/components/ui/Badge.vue'
import TrendChart from '@/components/ui/TrendChart.vue'
import type { UsageLog } from '@/types'
const toast = useToastStore()
const data = ref({
total_users: 0, total_keys: 0, total_channels: 0, total_models: 0,
today: { requests: 0, cost: 0, tokens: 0 },
month: { requests: 0, cost: 0, tokens: 0 },
trend_14d: [] as { date: string; requests: number; cost: number }[],
})
const logs = ref<UsageLog[]>([])
async function load() {
try {
const [o, u] = await Promise.all([http.get('/admin/stats/overview'), http.get('/admin/usage?page_size=8')])
data.value = o.data.data
logs.value = u.data.data.items
} catch (e) {
toast.err(errMsg(e))
}
}
onMounted(load)
</script>
<template>
<div class="mx-auto max-w-6xl space-y-6">
<div class="mb-2">
<h1 class="text-lg font-semibold">运营总览</h1>
<p class="text-sm text-zinc-500">全局用户、渠道与营收</p>
</div>
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div class="card p-4">
<p class="text-xs text-zinc-500">用户 / 密钥</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(data.total_users) }} / {{ fmtNum(data.total_keys) }}</p>
</div>
<div class="card p-4">
<p class="text-xs text-zinc-500">渠道 / 模型</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(data.total_channels) }} / {{ fmtNum(data.total_models) }}</p>
</div>
<div class="card p-4">
<p class="text-xs text-zinc-500">今日请求</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(data.today.requests) }}</p>
</div>
<div class="card p-4">
<p class="text-xs text-zinc-500">本月营收</p>
<p class="mono-num mt-1 text-xl text-emerald-300">{{ fmtCost(data.month.cost) }}</p>
</div>
</div>
<div class="grid gap-6 lg:grid-cols-5">
<div class="card p-5 lg:col-span-3">
<div class="mb-4 flex items-baseline justify-between">
<h2 class="text-sm font-semibold">近 14 天全局成本</h2>
<span class="font-mono text-[11px] text-zinc-600">{{ fmtCost(data.month.cost) }} / 本月</span>
</div>
<TrendChart
:points="data.trend_14d.map((t) => ({ label: t.date.slice(5), value: t.cost }))"
:format="(v) => '$' + v.toExponential(2)"
/>
</div>
<div class="card p-5 lg:col-span-2">
<div class="mb-3 flex items-baseline justify-between">
<h2 class="text-sm font-semibold">全局最近请求</h2>
<span class="font-mono text-[11px] text-zinc-600">共 {{ data.month.requests }} / 月</span>
</div>
<ul class="divide-y divide-zinc-800/70">
<li v-for="l in logs" :key="l.id" class="flex items-center justify-between py-2">
<div class="min-w-0">
<p class="truncate text-xs text-zinc-300">
<span class="font-mono text-emerald-400/80">{{ l.user }}</span> · {{ l.model }}
</p>
<p class="font-mono text-[11px] text-zinc-600">{{ fmtTime(l.created_at) }}</p>
</div>
<div class="flex items-center gap-2">
<span class="mono-num text-xs text-zinc-500">{{ fmtCost(l.cost) }}</span>
<Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge>
</div>
</li>
<li v-if="logs.length === 0" class="py-6 text-center text-xs text-zinc-600">暂无请求</li>
</ul>
</div>
</div>
</div>
</template>
+199
View File
@@ -0,0 +1,199 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
import { useAuthStore } from '@/stores/auth'
import { fmtMoney, fmtTime } from '@/lib/format'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
import Modal from '@/components/ui/Modal.vue'
import Badge from '@/components/ui/Badge.vue'
import type { User } from '@/types'
const toast = useToastStore()
const auth = useAuthStore()
const users = ref<User[]>([])
const total = ref(0)
const page = ref(1)
const q = ref('')
const pageSize = 15
const editOpen = ref(false)
const editing = ref<User | null>(null)
const editForm = reactive({ role: 'user', status: 'active' })
const balanceOpen = ref(false)
const balanceUser = ref<User | null>(null)
const balanceForm = reactive({ amount: 0, remark: '' })
async function load() {
try {
const { data } = await http.get(`/admin/users?page=${page.value}&page_size=${pageSize}${q.value ? '&q=' + q.value : ''}`)
users.value = data.data.items
total.value = data.data.total
} catch (e) {
toast.err(errMsg(e))
}
}
function search() {
page.value = 1
load()
}
function openEdit(u: User) {
editing.value = u
Object.assign(editForm, { role: u.role, status: u.status })
editOpen.value = true
}
async function saveEdit() {
if (!editing.value) return
try {
await http.patch(`/admin/users/${editing.value.id}`, editForm)
toast.ok('已更新')
editOpen.value = false
await load()
} catch (e) {
toast.err(errMsg(e))
}
}
function openBalance(u: User) {
balanceUser.value = u
Object.assign(balanceForm, { amount: 0, remark: '' })
balanceOpen.value = true
}
async function saveBalance() {
if (!balanceUser.value || !balanceForm.amount) return
try {
await http.post(`/admin/users/${balanceUser.value.id}/balance`, {
amount: Number(balanceForm.amount),
remark: balanceForm.remark,
})
toast.ok('余额已调整')
balanceOpen.value = false
await load()
} catch (e) {
toast.err(errMsg(e))
}
}
function goPage(p: number) {
page.value = p
load()
}
onMounted(load)
</script>
<template>
<div class="mx-auto max-w-6xl">
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-lg font-semibold">用户</h1>
<p class="text-sm text-zinc-500">管理角色、状态与余额</p>
</div>
<div class="flex gap-2">
<input
v-model="q"
placeholder="搜索用户名 / 邮箱"
class="h-10 w-56 rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm outline-none focus:border-accent"
@keyup.enter="search"
/>
<Button variant="ghost" @click="search">搜索</Button>
</div>
</div>
<div class="card">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-zinc-800 text-left text-xs text-zinc-500">
<th scope="col" class="px-4 py-2.5 font-medium">ID</th>
<th scope="col" class="px-4 py-2.5 font-medium">用户名</th>
<th scope="col" class="px-4 py-2.5 font-medium">邮箱</th>
<th scope="col" class="px-4 py-2.5 font-medium">角色</th>
<th scope="col" class="px-4 py-2.5 font-medium">余额</th>
<th scope="col" class="px-4 py-2.5 font-medium">状态</th>
<th scope="col" class="px-4 py-2.5 font-medium">注册时间</th>
<th scope="col" class="px-4 py-2.5" />
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id" class="table-row">
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ u.id }}</td>
<td class="px-4 py-2.5 text-zinc-200">
{{ u.username }}
<span v-if="u.id === auth.user?.id" class="text-[11px] text-zinc-600">(我)</span>
</td>
<td class="px-4 py-2.5 text-xs text-zinc-400">{{ u.email }}</td>
<td class="px-4 py-2.5">
<Badge :variant="u.role === 'admin' ? 'accent' : 'neutral'">{{ u.role }}</Badge>
</td>
<td class="px-4 py-2.5 mono-num text-xs text-emerald-300/90">{{ fmtMoney(u.balance) }}</td>
<td class="px-4 py-2.5">
<Badge :variant="u.status === 'active' ? 'ok' : 'warn'">{{ u.status }}</Badge>
</td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ fmtTime(u.created_at) }}</td>
<td class="px-4 py-2.5 text-right">
<div class="flex justify-end gap-2">
<button class="text-xs text-zinc-500 hover:text-zinc-200" @click="openEdit(u)">编辑</button>
<button class="text-xs text-zinc-500 hover:text-accent" @click="openBalance(u)">调余额</button>
</div>
</td>
</tr>
<tr v-if="users.length === 0">
<td colspan="8" class="px-4 py-10 text-center text-sm text-zinc-600">无用户</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between border-t border-zinc-800 px-4 py-3">
<span class="font-mono text-xs text-zinc-600">共 {{ total }} 人</span>
<div class="flex gap-2">
<Button size="sm" variant="ghost" :disabled="page <= 1" @click="goPage(page - 1)">上一页</Button>
<Button size="sm" variant="ghost" :disabled="page * pageSize >= total" @click="goPage(page + 1)">下一页</Button>
</div>
</div>
</div>
<!-- 编辑用户 -->
<Modal :open="editOpen" title="编辑用户" @close="editOpen = false">
<div class="space-y-4">
<label class="block">
<span class="mb-1.5 block text-xs font-medium text-zinc-400">角色</span>
<select v-model="editForm.role" class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm outline-none focus:border-accent">
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</label>
<label class="block">
<span class="mb-1.5 block text-xs font-medium text-zinc-400">状态</span>
<select v-model="editForm.status" class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm outline-none focus:border-accent">
<option value="active">active</option>
<option value="disabled">disabled</option>
</select>
</label>
</div>
<template #footer>
<Button variant="ghost" @click="editOpen = false">取消</Button>
<Button @click="saveEdit">保存</Button>
</template>
</Modal>
<!-- 调整余额 -->
<Modal :open="balanceOpen" :title="`调整余额 · ${balanceUser?.username}`" @close="balanceOpen = false">
<div class="space-y-4">
<p class="text-xs text-zinc-500">当前余额 {{ fmtMoney(balanceUser?.balance ?? 0) }}</p>
<Input v-model="balanceForm.amount" label="调整金额" type="number" hint="正数增加,负数扣减" />
<Input v-model="balanceForm.remark" label="备注" placeholder="可选" />
</div>
<template #footer>
<Button variant="ghost" @click="balanceOpen = false">取消</Button>
<Button @click="saveBalance">确认</Button>
</template>
</Modal>
</div>
</template>
+26 -73
View File
@@ -1,85 +1,38 @@
<script setup lang="ts">
import { useAuthStore } from '../../stores/auth'
import { useRouter } from 'vue-router'
import { computed } from 'vue'
import Toast from '../../components/ui/Toast.vue'
import { useAuthStore } from '@/stores/auth'
import ShellLayout from '@/components/layout/ShellLayout.vue'
const auth = useAuthStore()
const router = useRouter()
const nav = [
{ to: '/console/dashboard', label: '仪表盘', icon: 'M3 12l9-9 9 9M5 10v10h5v-6h4v6h5V10' },
{ to: '/console/keys', label: 'API 密钥', icon: 'M15 7a4 4 0 11-8 0 4 4 0 018 0zM3 21v-1a6 6 0 0112 0v1' },
{ to: '/console/usage', label: '用量明细', icon: 'M4 20V10M10 20V4M16 20v-7M22 20H2' },
const sections = computed(() => {
const s: { title: string; items: { to: string; label: string }[] }[] = [
{
title: '控制台',
items: [
{ to: '/console/dashboard', label: '仪表盘' },
{ to: '/console/keys', label: 'API Keys' },
{ to: '/console/usage', label: '用量' },
],
},
]
const balanceFmt = computed(() => {
if (!auth.user) return '—'
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 4 }).format(auth.user.balance)
if (auth.isAdmin) {
s.push({
title: '管理',
items: [
{ to: '/admin/overview', label: '运营总览' },
{ to: '/admin/channels', label: '渠道' },
{ to: '/admin/models', label: '模型与定价' },
{ to: '/admin/users', label: '用户' },
{ to: '/admin/config', label: '系统配置' },
],
})
async function logout() {
await auth.logout()
router.push('/')
}
return s
})
</script>
<template>
<div class="min-h-[100dvh] bg-ink-950 text-paper-100">
<!-- 无障碍:跳过导航 -->
<a
href="#main-content"
class="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[70] focus:rounded-md focus:bg-signal-400 focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-ink-950"
>跳到主内容</a>
<!-- 侧边栏 -->
<aside aria-label="主导航" class="fixed inset-y-0 left-0 z-30 flex w-56 flex-col border-r border-ink-800 bg-ink-900/60">
<div class="flex h-16 items-center gap-2.5 border-b border-ink-800 px-5">
<span class="flex h-7 w-7 items-center justify-center rounded-md bg-signal-400" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><path d="M2 8h3l2-5 3 10 2-5h2" stroke="#0c0d0f" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
<span class="text-[15px] font-semibold tracking-tight" translate="no">openteam</span>
</div>
<nav class="flex-1 space-y-0.5 px-3 py-4" aria-label="控制台">
<router-link
v-for="item in nav"
:key="item.to"
:to="item.to"
class="flex items-center gap-3 rounded-md px-3 py-2 text-[13.5px] text-paper-500 transition-colors hover:bg-ink-800 hover:text-paper-100 focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none"
active-class="bg-ink-800 text-signal-300! font-medium"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path :d="item.icon" /></svg>
{{ item.label }}
</router-link>
</nav>
<div class="border-t border-ink-800 px-3 py-4">
<div class="flex items-center justify-between rounded-md bg-ink-850 px-3 py-2.5">
<div class="min-w-0">
<p class="truncate text-[13px] font-medium text-paper-100">{{ auth.user?.username }}</p>
<p class="text-xs text-paper-600">{{ auth.isAdmin ? 'admin' : 'user' }}</p>
</div>
<span class="num text-[13px] font-medium text-mint-400" translate="no">{{ balanceFmt }}</span>
</div>
<button
class="mt-2 flex w-full touch-manipulation items-center justify-center gap-2 rounded-md px-3 py-2 text-[13px] text-paper-500 transition-colors hover:bg-ink-800 hover:text-ember-300 focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none cursor-pointer"
@click="logout"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9" /></svg>
退出登录
</button>
</div>
</aside>
<!-- 主区域 -->
<div class="ml-56 flex-1">
<main id="main-content" class="mx-auto max-w-6xl scroll-mt-4 px-8 py-8" tabindex="-1">
<ShellLayout :sections="sections">
<router-view />
</main>
</div>
<!-- 全局通知 -->
<Toast />
</div>
</ShellLayout>
</template>
+83 -141
View File
@@ -1,167 +1,109 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { BarChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import client, { unwrap } from '../../api/client'
import Badge from '../../components/ui/Badge.vue'
import { onMounted, ref } from 'vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
import { fmtMoney, fmtNum, fmtCost, fmtTime } from '@/lib/format'
import Badge from '@/components/ui/Badge.vue'
import TrendChart from '@/components/ui/TrendChart.vue'
import type { UsageLog } from '@/types'
use([CanvasRenderer, BarChart, GridComponent, TooltipComponent])
const toast = useToastStore()
interface BalanceInfo { balance: number; spent_last_30d: number; today: { requests: number; tokens: number; cost: number }; models_available: number }
interface UsagePoint { date: string; requests: number; tokens: number; cost: number }
interface LogItem { id: number; model: string; protocol: string; input_tokens: number; output_tokens: number; cost: number; latency_ms: number; status: string; created_at: string }
const balance = ref(0)
const today = ref({ requests: 0, tokens: 0, cost: 0 })
const monthCost = ref(0)
const models = ref(0)
const trend = ref<{ label: string; value: number }[]>([])
const logs = ref<UsageLog[]>([])
const balance = ref<BalanceInfo | null>(null)
const stats = ref<UsagePoint[]>([])
const recentLogs = ref<LogItem[]>([])
const error = ref('')
const loaded = ref(false)
const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
const usdPrecise = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 6, maximumFractionDigits: 6 })
const balanceText = computed(() => (balance.value ? usd.format(balance.value.balance) : '—'))
const spent30d = computed(() => usd.format(balance.value?.spent_last_30d ?? 0))
const todayCostText = computed(() => usdPrecise.format(balance.value?.today.cost ?? 0))
const modelsCount = computed(() => balance.value?.models_available ?? '—')
const todayRequests = computed(() => balance.value?.today.requests ?? '—')
const todayTokens = computed(() => balance.value?.today.tokens ?? 0)
const chartOption = computed(() => ({
grid: { left: 8, right: 8, top: 24, bottom: 0, containLabel: true },
tooltip: {
trigger: 'axis',
backgroundColor: '#16191d',
borderColor: '#282d34',
textStyle: { color: '#eae8e3', fontSize: 12, fontFamily: 'JetBrains Mono' },
},
xAxis: {
type: 'category',
data: stats.value.map((s) => s.date.slice(5)),
axisLine: { lineStyle: { color: '#282d34' } },
axisLabel: { color: '#63686f', fontFamily: 'JetBrains Mono', fontSize: 11 },
axisTick: { show: false },
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: '#1c2025' } },
axisLabel: { color: '#63686f', fontFamily: 'JetBrains Mono', fontSize: 11 },
},
series: [
{
name: '请求数',
type: 'bar',
data: stats.value.map((s) => s.requests),
itemStyle: { color: '#e5a13c', borderRadius: [3, 3, 0, 0] },
barMaxWidth: 22,
},
],
}))
onMounted(async () => {
async function load() {
try {
const [b, s, logs] = await Promise.all([
unwrap<BalanceInfo>(client.get('/user/balance')),
unwrap<{ items: UsagePoint[] }>(client.get('/usage/stats', { params: { group: 'day' } })),
unwrap<{ items: LogItem[] }>(client.get('/usage/logs', { params: { page_size: 8 } })),
const [b, s, l] = await Promise.all([
http.get('/user/balance'),
http.get('/usage/stats?group=day&from=' + daysAgo(13)),
http.get('/usage/logs?page_size=8'),
])
balance.value = b
stats.value = s.items ?? []
recentLogs.value = logs.items ?? []
} catch (e: any) {
error.value = e.message || '加载失败'
} finally {
loaded.value = true
const d = b.data.data
balance.value = d.balance
today.value = d.today
monthCost.value = d.spent_last_30d
models.value = d.models_available
trend.value = s.data.data.items.map((x: { date: string; cost: number }) => ({ label: x.date.slice(5), value: x.cost }))
logs.value = l.data.data.items
} catch (e) {
toast.err(errMsg(e))
}
}
})
const fmtCost = (n: number) => usd.format(n)
function daysAgo(n: number): string {
const d = new Date(Date.now() - n * 864e5)
return d.toISOString().slice(0, 10)
}
onMounted(load)
</script>
<template>
<div>
<div class="flex items-end justify-between">
<div>
<h1 class="text-xl font-semibold tracking-tight">仪表盘</h1>
<p class="mt-1 text-[13px] text-paper-500">今日与近 30 日用量总览</p>
</div>
<router-link
to="/console/keys"
class="inline-flex h-10 touch-manipulation items-center rounded-md bg-signal-400 px-4 text-sm font-medium text-ink-950 transition-colors hover:bg-signal-300 focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none"
>新建密钥</router-link>
<div class="mx-auto max-w-5xl space-y-6">
<div class="mb-2">
<h1 class="text-lg font-semibold">仪表盘</h1>
<p class="text-sm text-zinc-500">余额、用量与最近请求</p>
</div>
<p v-if="error" class="mt-4 rounded-md border border-ember-500/40 bg-ember-500/10 px-3 py-2 text-[13px] text-ember-300" role="alert">{{ error }}</p>
<!-- 指标行 -->
<section aria-label="用量指标" class="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
<template v-if="!loaded">
<div v-for="i in 4" :key="i" class="rounded-lg border border-ink-700 bg-ink-900 p-4" aria-hidden="true">
<div class="h-3 w-14 animate-pulse rounded bg-ink-700" />
<div class="mt-3 h-7 w-24 animate-pulse rounded bg-ink-700" />
<div class="mt-2 h-3 w-20 animate-pulse rounded bg-ink-700" />
<!-- 指标条 -->
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div class="card p-4">
<p class="text-xs text-zinc-500">余额</p>
<p class="mono-num mt-1 text-xl text-emerald-300">{{ fmtMoney(balance) }}</p>
</div>
</template>
<template v-else>
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
<p class="text-xs text-paper-500">余额</p>
<p class="num mt-1.5 text-2xl font-semibold text-mint-400" translate="no">{{ balanceText }}</p>
<p class="num mt-1 text-[11px] text-paper-600">30 日消耗 <span translate="no">{{ spent30d }}</span></p>
<div class="card p-4">
<p class="text-xs text-zinc-500">今日请求</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(today.requests) }}</p>
</div>
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
<p class="text-xs text-paper-500">今日请求</p>
<p class="num mt-1.5 text-2xl font-semibold">{{ todayRequests }}</p>
<p class="num mt-1 text-[11px] text-paper-600">{{ todayTokens }} tokens</p>
<div class="card p-4">
<p class="text-xs text-zinc-500">今日 Token</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(today.tokens) }}</p>
</div>
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
<p class="text-xs text-paper-500">今日成本</p>
<p class="num mt-1.5 text-2xl font-semibold text-signal-300" translate="no">{{ todayCostText }}</p>
<p class="num mt-1 text-[11px] text-paper-600">按量计费 · USD</p>
<div class="card p-4">
<p class="text-xs text-zinc-500">近 30 日消耗</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtCost(monthCost) }}</p>
</div>
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
<p class="text-xs text-paper-500">可用模型</p>
<p class="num mt-1.5 text-2xl font-semibold">{{ modelsCount }}</p>
<p class="mt-1 text-[11px] text-paper-600">GET /v1/models 查看</p>
</div>
</template>
</section>
<!-- 图表 + 最近请求 -->
<div class="mt-6 grid grid-cols-1 gap-3 lg:grid-cols-[1.4fr_1fr]">
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
<div class="mb-2 flex items-center justify-between">
<h2 class="text-sm font-medium text-paper-300">近 30 日请求</h2>
<span class="font-mono text-[11px] text-paper-600" translate="no">usage/stats?group=day</span>
</div>
<VChart v-if="stats.length" class="h-56" :option="chartOption" autoresize />
<div v-else-if="loaded" class="flex h-56 items-center justify-center text-[13px] text-paper-600">暂无数据,发起第一次请求后这里会出现图表</div>
<div v-else class="h-56 animate-pulse rounded bg-ink-800" aria-hidden="true" />
</div>
<div class="rounded-lg border border-ink-700 bg-ink-900 p-4">
<div class="mb-2 flex items-center justify-between">
<h2 class="text-sm font-medium text-paper-300">最近请求</h2>
<router-link to="/console/usage" class="text-xs text-signal-300 transition-colors hover:text-signal-200">全部 →</router-link>
<div class="grid gap-6 lg:grid-cols-5">
<!-- 用量趋势 -->
<div class="card p-5 lg:col-span-3">
<div class="mb-4 flex items-baseline justify-between">
<h2 class="text-sm font-semibold">近 14 天成本</h2>
<span class="font-mono text-[11px] text-zinc-600">{{ models }} 个可用模型</span>
</div>
<div v-if="recentLogs.length" class="divide-y divide-ink-800">
<div v-for="l in recentLogs" :key="l.id" class="flex items-center justify-between gap-3 py-2.5">
<TrendChart :points="trend" :format="(v) => '$' + v.toExponential(2)" />
</div>
<!-- 最近请求 -->
<div class="card p-5 lg:col-span-2">
<div class="mb-3 flex items-baseline justify-between">
<h2 class="text-sm font-semibold">最近请求</h2>
<router-link to="/console/usage" class="text-xs text-accent hover:text-accent-strong">查看全部</router-link>
</div>
<ul class="divide-y divide-zinc-800/70">
<li v-for="l in logs" :key="l.id" class="flex items-center justify-between py-2">
<div class="min-w-0">
<p class="truncate font-mono text-[12.5px] text-paper-100" translate="no">{{ l.model }}</p>
<p class="num mt-0.5 text-[11px] text-paper-600" translate="no">{{ l.protocol }} · {{ l.input_tokens }}/{{ l.output_tokens }} tok · {{ l.latency_ms }}ms</p>
<p class="truncate font-mono text-xs text-zinc-300">{{ l.model }}</p>
<p class="text-[11px] text-zinc-600">{{ fmtTime(l.created_at) }}</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<span class="num text-[12.5px] text-paper-300" translate="no">{{ fmtCost(l.cost) }}</span>
<Badge :tone="l.status === 'success' ? 'success' : 'error'" />
</div>
</div>
</div>
<div v-else-if="loaded" class="flex h-48 items-center justify-center text-[13px] text-paper-600">还没有请求记录</div>
<div v-else class="space-y-3 pt-2" aria-hidden="true">
<div v-for="i in 4" :key="i" class="h-8 animate-pulse rounded bg-ink-800" />
<div class="flex items-center gap-2">
<span class="mono-num text-xs text-zinc-400">{{ l.input_tokens }}/{{ l.output_tokens }}</span>
<span class="mono-num w-16 text-right text-xs text-zinc-500">{{ fmtCost(l.cost) }}</span>
<Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge>
</div>
</li>
<li v-if="logs.length === 0" class="py-6 text-center text-xs text-zinc-600">
还没有请求记录,去
<router-link to="/console/keys" class="text-accent">API Keys</router-link>
创建密钥开始调用
</li>
</ul>
</div>
</div>
</div>
+95 -146
View File
@@ -1,205 +1,154 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import client, { unwrap } from '../../api/client'
import Badge from '../../components/ui/Badge.vue'
import Button from '../../components/ui/Button.vue'
import Modal from '../../components/ui/Modal.vue'
import Input from '../../components/ui/Input.vue'
import { useToastStore } from '../../stores/toast'
interface APIKey {
id: number
name: string
key_prefix: string
quota_tokens_per_day: number | null
quota_requests_per_day: number | null
status: string
last_used_at: string | null
created_at: string
}
import { onMounted, ref } from 'vue'
import { PhCopy, PhCheck } from '@phosphor-icons/vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
import { fmtTime } from '@/lib/format'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
import Modal from '@/components/ui/Modal.vue'
import Badge from '@/components/ui/Badge.vue'
import type { ApiKey } from '@/types'
const toast = useToastStore()
const keys = ref<ApiKey[]>([])
const keys = ref<APIKey[]>([])
const loading = ref(false)
const error = ref('')
// 创建
const showCreate = ref(false)
const newName = ref('')
const createOpen = ref(false)
const keyName = ref('')
const creating = ref(false)
const createdKey = ref('')
const createError = ref('')
const nameInput = ref<{ focus: () => void } | null>(null)
// 吊销
const revokeTarget = ref<APIKey | null>(null)
const revoking = ref(false)
const created = ref<{ name: string; key: string; key_prefix: string } | null>(null)
const copied = ref(false)
async function load() {
loading.value = true
try {
const data = await unwrap<{ items: APIKey[] }>(client.get('/keys'))
keys.value = data.items
} catch (e: any) {
error.value = e.message || '加载失败'
} finally {
loading.value = false
const { data } = await http.get('/keys')
keys.value = data.data.items
} catch (e) {
toast.err(errMsg(e))
}
}
async function create() {
createError.value = ''
if (!newName.value.trim()) {
createError.value = '请填写密钥名称'
await nextTick()
nameInput.value?.focus()
return
}
async function createKey() {
if (!keyName.value) return
creating.value = true
try {
const data = await unwrap<{ key: string }>(client.post('/keys', { name: newName.value.trim() }))
createdKey.value = data.key
newName.value = ''
const { data } = await http.post('/keys', { name: keyName.value })
created.value = data.data
createOpen.value = false
keyName.value = ''
await load()
} catch (e: any) {
createError.value = e.response?.data?.error?.message || '创建失败'
} catch (e) {
toast.err(errMsg(e))
} finally {
creating.value = false
}
}
async function revoke() {
if (!revokeTarget.value) return
revoking.value = true
async function revoke(k: ApiKey) {
if (!confirm(`吊销密钥 ${k.name}?吊销后立即失效。`)) return
try {
await unwrap(client.delete(`/keys/${revokeTarget.value.id}`))
toast.success(`密钥 ${revokeTarget.value.key_prefix}… 已吊销`)
revokeTarget.value = null
await http.delete(`/keys/${k.id}`)
toast.ok('密钥已吊销')
await load()
} catch (e: any) {
toast.error(e.message || '吊销失败')
} finally {
revoking.value = false
} catch (e) {
toast.err(errMsg(e))
}
}
async function copyKey(text: string) {
async function copyKey() {
if (!created.value) return
try {
await navigator.clipboard.writeText(text)
await navigator.clipboard.writeText(created.value.key)
copied.value = true
setTimeout(() => (copied.value = false), 1500)
} catch {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
ta.remove()
toast.err('复制失败,请手动复制')
}
toast.success('密钥已复制')
}
onMounted(load)
const fmtDate = (s: string | null) =>
s ? new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(s)) : '从未使用'
</script>
<template>
<div class="mx-auto max-w-5xl">
<div class="mb-6 flex items-center justify-between">
<div>
<div class="flex items-end justify-between">
<div>
<h1 class="text-xl font-semibold tracking-tight">API 密钥</h1>
<p class="mt-1 text-[13px] text-paper-500">密钥仅以 SHA-256 哈希存储,明文只在创建时展示一次</p>
<h1 class="text-lg font-semibold">API Keys</h1>
<p class="text-sm text-zinc-500">密钥明文仅在创建时展示一次,请立即保存</p>
</div>
<Button @click="showCreate = true">新建密钥</Button>
<Button @click="createOpen = true">新建密钥</Button>
</div>
<p v-if="error" class="mt-4 rounded-md border border-ember-500/40 bg-ember-500/10 px-3 py-2 text-[13px] text-ember-300" role="alert">{{ error }}</p>
<div class="mt-6 overflow-hidden rounded-lg border border-ink-700">
<table class="w-full text-left text-[13px]">
<caption class="sr-only">API 密钥列表</caption>
<div class="card">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-ink-700 bg-ink-900 text-xs text-paper-500">
<th scope="col" class="px-4 py-3 font-medium">名称</th>
<th scope="col" class="px-4 py-3 font-medium">密钥前缀</th>
<th scope="col" class="px-4 py-3 font-medium">每日限额</th>
<th scope="col" class="px-4 py-3 font-medium">最近使用</th>
<th scope="col" class="px-4 py-3 font-medium">状态</th>
<th scope="col" class="px-4 py-3 text-right font-medium">操作</th>
<tr class="border-b border-zinc-800 text-left text-xs text-zinc-500">
<th scope="col" class="px-4 py-2.5 font-medium">名称</th>
<th scope="col" class="px-4 py-2.5 font-medium">前缀</th>
<th scope="col" class="px-4 py-2.5 font-medium">状态</th>
<th scope="col" class="px-4 py-2.5 font-medium">最近使用</th>
<th scope="col" class="px-4 py-2.5 font-medium">创建时间</th>
<th scope="col" class="px-4 py-2.5" />
</tr>
</thead>
<tbody class="divide-y divide-ink-800 bg-ink-900/50">
<tr v-for="k in keys" :key="k.id" class="transition-colors hover:bg-ink-850">
<td class="px-4 py-3 font-medium text-paper-100">{{ k.name }}</td>
<td class="px-4 py-3"><code class="font-mono text-[12.5px] text-signal-300" translate="no">{{ k.key_prefix }}…</code></td>
<td class="num px-4 py-3 text-paper-500" translate="no">
{{ k.quota_tokens_per_day ? `${(k.quota_tokens_per_day / 1000).toFixed(0)}k tok` : '—' }}
/ {{ k.quota_requests_per_day ? `${k.quota_requests_per_day} req` : '—' }}
<tbody>
<tr v-for="k in keys" :key="k.id" class="table-row">
<td class="px-4 py-2.5 text-zinc-200">{{ k.name }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-400">{{ k.key_prefix }}…</td>
<td class="px-4 py-2.5">
<Badge :variant="k.status === 'active' ? 'ok' : 'neutral'">{{ k.status }}</Badge>
</td>
<td class="num px-4 py-3 text-paper-500" translate="no">{{ fmtDate(k.last_used_at) }}</td>
<td class="px-4 py-3"><Badge :tone="k.status" /></td>
<td class="px-4 py-3 text-right">
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ fmtTime(k.last_used_at) }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ fmtTime(k.created_at) }}</td>
<td class="px-4 py-2.5 text-right">
<button
v-if="k.status === 'active'"
class="touch-manipulation text-xs text-ember-400 transition-colors hover:text-ember-300 focus-visible:ring-2 focus-visible:ring-ember-400/60 focus:outline-none cursor-pointer"
@click="revokeTarget = k"
>吊销</button>
<span v-else class="text-xs text-paper-600">已吊销</span>
class="text-xs text-zinc-500 hover:text-red-400"
@click="revoke(k)"
>
吊销
</button>
</td>
</tr>
<tr v-if="!keys.length">
<td colspan="6" class="px-4 py-12 text-center text-[13px] text-paper-600">
{{ loading ? '加载中…' : '还没有密钥 — 点击右上角「新建密钥」创建第一个' }}
<tr v-if="keys.length === 0">
<td colspan="6" class="px-4 py-10 text-center text-sm text-zinc-600">
还没有密钥,点击右上角「新建密钥」
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 创建模态 -->
<Modal :open="showCreate" title="新建 API 密钥" @close="showCreate = false">
<template v-if="!createdKey">
<Input
ref="nameInput"
v-model="newName"
label="密钥名称"
name="key-name"
placeholder="例如:本地开发"
hint="用于在用量明细中区分来源"
:error="createError || undefined"
autocomplete="off"
:spellcheck="false"
@keydown.enter.prevent="create"
/>
<div class="mt-5 flex justify-end gap-2">
<Button variant="ghost" @click="showCreate = false">取消</Button>
<Button :loading="creating" @click="create">创建密钥</Button>
</div>
</template>
<template v-else>
<p class="text-[13px] leading-relaxed text-paper-500">密钥已生成。出于安全考虑,<span class="text-paper-300">明文只会展示这一次</span>,请立即复制保存。</p>
<div class="mt-3 flex items-center gap-2 rounded-md border border-mint-500/40 bg-mint-400/10 px-3 py-2.5">
<code class="min-w-0 flex-1 break-all font-mono text-[12.5px] text-mint-300" translate="no">{{ createdKey }}</code>
<Button variant="outline" size="sm" @click="copyKey(createdKey)">复制</Button>
</div>
<div class="mt-5 flex justify-end">
<Button @click="showCreate = false; createdKey = ''">完成</Button>
</div>
<!-- 新建密钥 -->
<Modal :open="createOpen" title="新建密钥" @close="createOpen = false">
<Input v-model="keyName" label="密钥名称" placeholder="例如 dev / prod" @keyup.enter="createKey" />
<template #footer>
<Button variant="ghost" @click="createOpen = false">取消</Button>
<Button :loading="creating" @click="createKey">创建</Button>
</template>
</Modal>
<!-- 吊销确认 -->
<Modal :open="!!revokeTarget" title="吊销密钥" @close="revokeTarget = null">
<p class="text-[13px] leading-relaxed text-paper-500">
吊销后 <code class="font-mono text-paper-300" translate="no">{{ revokeTarget?.key_prefix }}…</code> 将立即失效,使用它的请求会返回 401。此操作不可撤销。
</p>
<div class="mt-5 flex justify-end gap-2">
<Button variant="ghost" @click="revokeTarget = null">取消</Button>
<Button variant="danger" :loading="revoking" @click="revoke">确认吊销</Button>
<!-- 一次性展示密钥 -->
<Modal :open="!!created" title="密钥已创建" @close="created = null">
<div class="space-y-4">
<p class="text-xs text-zinc-500">请复制并妥善保存,关闭后不再显示。</p>
<div class="flex items-center gap-2">
<code class="mono-num flex-1 truncate rounded-md border border-accent/40 bg-zinc-900 px-3 py-2 text-xs text-emerald-300">
{{ created?.key }}
</code>
<Button size="sm" @click="copyKey">
<PhCheck v-if="copied" :size="14" />
<PhCopy v-else :size="14" />
{{ copied ? '已复制' : '复制' }}
</Button>
</div>
</div>
<template #footer>
<Button @click="created = null">完成</Button>
</template>
</Modal>
</div>
</template>
+120 -122
View File
@@ -1,149 +1,147 @@
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import client, { unwrap } from '../../api/client'
import Badge from '../../components/ui/Badge.vue'
import { onMounted, ref } from 'vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
import { fmtNum, fmtCost, fmtTime } from '@/lib/format'
import Badge from '@/components/ui/Badge.vue'
import TrendChart from '@/components/ui/TrendChart.vue'
import type { UsageLog } from '@/types'
interface LogItem {
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
created_at: string
}
const route = useRoute()
const router = useRouter()
const logs = ref<LogItem[]>([])
const toast = useToastStore()
const summary = ref({ today: { requests: 0, tokens: 0, cost: 0 }, month: { requests: 0, tokens: 0, cost: 0 } })
const group = ref<'day' | 'model'>('day')
const chart = ref<{ label: string; value: number }[]>([])
const logs = ref<UsageLog[]>([])
const page = ref(1)
const total = ref(0)
const page = ref(Number(route.query.page) || 1)
const pageSize = 20
const modelFilter = ref((route.query.model as string) || '')
const models = ref<string[]>([])
const loading = ref(false)
const pages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
const fmtDate = new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })
// 筛选/分页同步到 URL(可深链、可分享)
watch([page, modelFilter], () => {
router.replace({
query: {
...(modelFilter.value ? { model: modelFilter.value } : {}),
...(page.value > 1 ? { page: String(page.value) } : {}),
},
})
})
const modelFilter = ref('')
const pageSize = 15
async function load() {
loading.value = true
try {
const data = await unwrap<{ items: LogItem[]; total: number }>(
client.get('/usage/logs', { params: { page: page.value, page_size: pageSize, model: modelFilter.value || undefined } }),
)
logs.value = data.items ?? []
total.value = data.total
} catch {
// 加载失败静默,空态兜底
} finally {
loading.value = false
const [s, st, l] = await Promise.all([
http.get('/usage/summary'),
http.get(`/usage/stats?group=${group.value}`),
http.get(`/usage/logs?page=${page.value}&page_size=${pageSize}${modelFilter.value ? '&model=' + modelFilter.value : ''}`),
])
summary.value = s.data.data
const items = st.data.data.items as { date?: string; model?: string; cost: number; requests: number }[]
chart.value = items.map((x) => ({ label: (x.date || x.model || '') as string, value: x.cost }))
logs.value = l.data.data.items
total.value = l.data.data.total
} catch (e) {
toast.err(errMsg(e))
}
}
async function loadModels() {
try {
const data = await unwrap<{ items: string[] }>(client.get('/user/models'))
models.value = data.items ?? []
} catch { /* ignore */ }
}
onMounted(() => {
function switchGroup(g: 'day' | 'model') {
group.value = g
load()
loadModels()
})
}
const fmtCost = (n: number) => usd.format(n)
const fmtDateStr = (s: string) => fmtDate.format(new Date(s))
function goPage(p: number) {
page.value = p
load()
}
onMounted(load)
</script>
<template>
<div>
<div class="flex items-end justify-between">
<div>
<h1 class="text-xl font-semibold tracking-tight">用量明细</h1>
<p class="mt-1 text-[13px] text-paper-500">请求级记录 · 按当时价格入账</p>
</div>
<label class="flex items-center gap-2">
<span class="text-xs text-paper-600">模型</span>
<select
v-model="modelFilter"
class="h-9 touch-manipulation rounded-md border border-ink-600 bg-ink-900 px-3 text-[13px] text-paper-300 transition-colors hover:border-ink-700 focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none"
@change="page = 1; load()"
>
<option value="">全部模型</option>
<option v-for="m in models" :key="m" :value="m" translate="no">{{ m }}</option>
</select>
</label>
<div class="mx-auto max-w-5xl space-y-6">
<div class="mb-2">
<h1 class="text-lg font-semibold">用量</h1>
<p class="text-sm text-zinc-500">汇总、分布与请求明细</p>
</div>
<div class="mt-6 overflow-hidden rounded-lg border border-ink-700">
<table class="w-full text-left text-[13px]">
<caption class="sr-only">用量明细记录</caption>
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div class="card p-4">
<p class="text-xs text-zinc-500">今日请求</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(summary.today.requests) }}</p>
</div>
<div class="card p-4">
<p class="text-xs text-zinc-500">今日 Token</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(summary.today.tokens) }}</p>
</div>
<div class="card p-4">
<p class="text-xs text-zinc-500">本月请求</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(summary.month.requests) }}</p>
</div>
<div class="card p-4">
<p class="text-xs text-zinc-500">本月成本</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtCost(summary.month.cost) }}</p>
</div>
</div>
<div class="card p-5">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-sm font-semibold">成本分布</h2>
<div class="flex gap-1 rounded-md border border-zinc-800 p-0.5">
<button
class="rounded px-2.5 py-1 text-xs transition"
:class="group === 'day' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-500'"
@click="switchGroup('day')"
>
按天
</button>
<button
class="rounded px-2.5 py-1 text-xs transition"
:class="group === 'model' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-500'"
@click="switchGroup('model')"
>
按模型
</button>
</div>
</div>
<TrendChart :points="chart" :format="(v) => '$' + v.toExponential(2)" />
</div>
<div class="card">
<div class="flex items-center justify-between border-b border-zinc-800 px-4 py-3">
<h2 class="text-sm font-semibold">请求明细</h2>
<input
v-model="modelFilter"
placeholder="按模型过滤"
class="h-8 w-48 rounded-md border border-zinc-700 bg-zinc-900 px-2.5 font-mono text-xs outline-none focus:border-accent"
@keyup.enter="page = 1; load()"
/>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-ink-700 bg-ink-900 text-xs text-paper-500">
<th scope="col" class="px-4 py-3 font-medium">时间</th>
<th scope="col" class="px-4 py-3 font-medium">模型</th>
<th scope="col" class="px-4 py-3 font-medium">协议</th>
<th scope="col" class="px-4 py-3 text-right font-medium">输入 tok</th>
<th scope="col" class="px-4 py-3 text-right font-medium">输出 tok</th>
<th scope="col" class="px-4 py-3 text-right font-medium">成本</th>
<th scope="col" class="px-4 py-3 text-right font-medium">耗时</th>
<th scope="col" class="px-4 py-3 font-medium">状态</th>
<tr class="border-b border-zinc-800 text-left text-xs text-zinc-500">
<th scope="col" class="px-4 py-2.5 font-medium">模型</th>
<th scope="col" class="px-4 py-2.5 font-medium">协议</th>
<th scope="col" class="px-4 py-2.5 font-medium">Token 入/出</th>
<th scope="col" class="px-4 py-2.5 font-medium">成本</th>
<th scope="col" class="px-4 py-2.5 font-medium">耗时</th>
<th scope="col" class="px-4 py-2.5 font-medium">状态</th>
<th scope="col" class="px-4 py-2.5 font-medium">时间</th>
</tr>
</thead>
<tbody class="divide-y divide-ink-800 bg-ink-900/50">
<tr v-for="l in logs" :key="l.id" class="transition-colors hover:bg-ink-850">
<td class="num px-4 py-3 text-paper-500" translate="no">{{ fmtDateStr(l.created_at) }}</td>
<td class="px-4 py-3"><code class="font-mono text-[12.5px] text-paper-100" translate="no">{{ l.model }}</code></td>
<td class="px-4 py-3 font-mono text-[12px] text-paper-500" translate="no">{{ l.protocol }}</td>
<td class="num px-4 py-3 text-right text-paper-300" translate="no">{{ l.input_tokens }}</td>
<td class="num px-4 py-3 text-right text-paper-300" translate="no">{{ l.output_tokens }}</td>
<td class="num px-4 py-3 text-right text-signal-300" translate="no">{{ fmtCost(l.cost) }}</td>
<td class="num px-4 py-3 text-right text-paper-500" translate="no">{{ l.latency_ms }}ms</td>
<td class="px-4 py-3"><Badge :tone="l.status === 'success' ? 'success' : 'error'" /></td>
<tbody>
<tr v-for="l in logs" :key="l.id" class="table-row">
<td class="px-4 py-2.5 font-mono text-xs text-zinc-200">{{ l.model }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ l.protocol }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-zinc-400">{{ l.input_tokens }}/{{ l.output_tokens }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-zinc-300">{{ fmtCost(l.cost) }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-zinc-500">{{ l.latency_ms }}ms</td>
<td class="px-4 py-2.5"><Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge></td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ fmtTime(l.created_at) }}</td>
</tr>
<tr v-if="!logs.length">
<td colspan="8" class="px-4 py-12 text-center text-[13px] text-paper-600">{{ loading ? '加载中…' : '暂无用量记录' }}</td>
<tr v-if="logs.length === 0">
<td colspan="7" class="px-4 py-10 text-center text-sm text-zinc-600">暂无请求记录</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-4 flex items-center justify-between">
<p class="num text-xs text-paper-600">共 {{ total }} 条</p>
<nav aria-label="分页" class="flex items-center gap-1.5">
<button
class="touch-manipulation rounded-md border border-ink-600 px-3 py-1.5 text-xs text-paper-300 transition-colors hover:border-signal-400 disabled:opacity-40 cursor-pointer disabled:cursor-default focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none"
:disabled="page <= 1"
:aria-label="`上一页,当前第 ${page} 页`"
@click="page--; load()"
>上一页</button>
<span class="num px-2 text-xs text-paper-500" aria-current="page">第 {{ page }} / {{ pages }} 页</span>
<button
class="touch-manipulation rounded-md border border-ink-600 px-3 py-1.5 text-xs text-paper-300 transition-colors hover:border-signal-400 disabled:opacity-40 cursor-pointer disabled:cursor-default focus-visible:ring-2 focus-visible:ring-signal-400/60 focus:outline-none"
:disabled="page >= pages"
:aria-label="`下一页,当前第 ${page} 页`"
@click="page++; load()"
>下一页</button>
</nav>
<div class="flex items-center justify-between border-t border-zinc-800 px-4 py-3">
<span class="font-mono text-xs text-zinc-600">共 {{ total }} 条</span>
<div class="flex gap-2">
<Button size="sm" variant="ghost" :disabled="page <= 1" @click="goPage(page - 1)">上一页</Button>
<Button size="sm" variant="ghost" :disabled="page * pageSize >= total" @click="goPage(page + 1)">下一页</Button>
</div>
</div>
</div>
</div>
</template>
+6
View File
@@ -1 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
+17 -8
View File
@@ -1,15 +1,24 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
/* Linting */
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
}
+8 -17
View File
@@ -1,23 +1,14 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"composite": true,
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}
+8 -4
View File
@@ -1,16 +1,20 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5173,
proxy: {
// 开发代理到 Go API
'/api': 'http://localhost:8080',
'/v1': 'http://localhost:8080',
'/api': { target: 'http://localhost:8080', changeOrigin: true },
'/v1': { target: 'http://localhost:8080', changeOrigin: true },
},
},
})