Compare commits
89
Commits
dev
..
ca4dc4b3b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca4dc4b3b7 | ||
|
|
0628d5050f | ||
|
|
a376ac0722 | ||
|
|
9733b3c20b | ||
|
|
d28fca8ee6 | ||
|
|
a2cef00908 | ||
|
|
d41bcdc371 | ||
|
|
8d949eff18 | ||
|
|
c65d497551 | ||
|
|
19232567f2 | ||
|
|
20f365d11a | ||
|
|
104ecc4691 | ||
|
|
a259e5eb4d | ||
|
|
f9e9a1572f | ||
|
|
9f4d631fc4 | ||
|
|
f81b364436 | ||
|
|
e472ed93d5 | ||
|
|
80ecf38676 | ||
|
|
4b845e14fc | ||
|
|
8f6acb981d | ||
|
|
6cc76c6767 | ||
|
|
f3af42a65f | ||
|
|
094230a293 | ||
|
|
96e4853d6e | ||
|
|
253e968c9d | ||
|
|
eadf9525ee | ||
|
|
902ecaeacc | ||
|
|
ef3025dd80 | ||
|
|
aa0d87f132 | ||
|
|
963da99fd6 | ||
|
|
33f5e7b71a | ||
|
|
1e07c5903f | ||
|
|
ac0a6808ec | ||
|
|
4a68ff6162 | ||
|
|
6b2d78fe56 | ||
|
|
9c604460b1 | ||
|
|
8d34f8d6fe | ||
|
|
6d1d0f3b6b | ||
|
|
24529189d9 | ||
|
|
000162b1b1 | ||
|
|
6662ea5e04 | ||
|
|
5789d50e9e | ||
|
|
ca3d89751d | ||
|
|
2bc857cf88 | ||
|
|
a9ff7e1c94 | ||
|
|
51d4651c6c | ||
|
|
e112f3af12 | ||
|
|
73e53c2333 | ||
|
|
470e49b850 | ||
|
|
d426781e47 | ||
|
|
b80f0759a5 | ||
|
|
b83c6d9786 | ||
|
|
fe0f2a7e88 | ||
|
|
ed42f3ded7 | ||
|
|
54246c542a | ||
|
|
ca305f4199 | ||
|
|
f8e539c9b4 | ||
|
|
e0b531c578 | ||
|
|
afb51b0a94 | ||
|
|
3c4619b98c | ||
|
|
efd38034ac | ||
|
|
d4bf227cd8 | ||
|
|
fca67cae40 | ||
|
|
20c3cda4a7 | ||
|
|
9045130778 | ||
|
|
5403ed0cba | ||
|
|
87778326ea | ||
|
|
a32a9543e2 | ||
|
|
9e0c33ad93 | ||
|
|
c8e565c6a4 | ||
|
|
9ed162ff0c | ||
|
|
b0d68ba338 | ||
|
|
d4cbc27a77 | ||
|
|
2360e7d2bf | ||
|
|
81845e3f30 | ||
|
|
15f17f4e8d | ||
|
|
ffb4496fd8 | ||
|
|
7bb8f8d012 | ||
|
|
e7ffc9e8b9 | ||
|
|
1e00905dcb | ||
|
|
bc223d6530 | ||
|
|
8b5c6feb5e | ||
|
|
65d6d12972 | ||
|
|
1f5e1c221c | ||
|
|
24bac8e38d | ||
|
|
fb5b1a55ae | ||
|
|
07bcabc5d4 | ||
|
|
8f6f3313f5 | ||
|
|
f8b364a052 |
@@ -0,0 +1,21 @@
|
|||||||
|
# 版本管理
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# 前端依赖与本地构建产物(镜像内由 pnpm 重新安装、重新构建)
|
||||||
|
**/node_modules
|
||||||
|
frontend/dist
|
||||||
|
web
|
||||||
|
|
||||||
|
# Go 构建产物
|
||||||
|
bin
|
||||||
|
backend/cmd/openteam/dist
|
||||||
|
|
||||||
|
# 文档与 CI
|
||||||
|
doc
|
||||||
|
.envci.yaml
|
||||||
|
tag.yaml
|
||||||
|
|
||||||
|
# 日志与数据库
|
||||||
|
*.log
|
||||||
|
*.db
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# ===========================================
|
||||||
|
# OpenCatd-Open 配置文件
|
||||||
|
# 复制此文件为 .env 并修改相应配置
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
# --- 服务器配置 ---
|
||||||
|
PORT=80
|
||||||
|
READ_TIMEOUT=10
|
||||||
|
WRITE_TIMEOUT=10
|
||||||
|
|
||||||
|
# --- Passkey (WebAuthn) 配置 ---
|
||||||
|
# 应用名称(显示给用户)
|
||||||
|
APP_NAME=OpenTeam
|
||||||
|
# 依赖方 ID(通常为域名,生产环境需改为实际域名)
|
||||||
|
RPID=localhost
|
||||||
|
# 依赖方来源(前端 URL,逗号分隔)
|
||||||
|
RPORIGINS=http://localhost:5173,http://localhost:3000
|
||||||
|
|
||||||
|
# --- 数据库配置 ---
|
||||||
|
# 支持: sqlite, mysql, postgres
|
||||||
|
DB_TYPE=sqlite
|
||||||
|
# DSN 连接字符串(SQLite 可留空)
|
||||||
|
DB_DSN=
|
||||||
|
DB_MAX_OPEN_CONNS=10
|
||||||
|
DB_MAX_IDLE_CONNS=5
|
||||||
|
|
||||||
|
# --- Redis 配置(可选,用于分布式 passkey session)---
|
||||||
|
# REDIS_HOST=localhost
|
||||||
|
# REDIS_PORT=6379
|
||||||
|
# REDIS_PASSWORD=
|
||||||
|
# REDIS_DB=0
|
||||||
|
|
||||||
|
# --- 日志配置 ---
|
||||||
|
LOG_LEVEL=info
|
||||||
|
LOG_PATH=./logs/
|
||||||
|
|
||||||
|
# --- 功能开关 ---
|
||||||
|
# 允许注册(false=关闭注册)
|
||||||
|
ALLOW_REGISTER=false
|
||||||
|
# 无限制配额(true=不限制)
|
||||||
|
UNLIMITED_QUOTA=true
|
||||||
|
# 新用户默认激活
|
||||||
|
DEFAULT_ACTIVE=true
|
||||||
|
|
||||||
|
# --- 用量统计 ---
|
||||||
|
USAGE_WORKER=1
|
||||||
|
USAGE_CHAN_SIZE=1000
|
||||||
|
TASK_TIME_INTERVAL=60
|
||||||
+17
-1
@@ -1,5 +1,21 @@
|
|||||||
bin/
|
bin/
|
||||||
|
tmp/
|
||||||
test/
|
test/
|
||||||
|
demo/
|
||||||
*.log
|
*.log
|
||||||
*.db
|
*.db
|
||||||
demo/
|
.env
|
||||||
|
openteam
|
||||||
|
|
||||||
|
# paseo 任务运行时记录
|
||||||
|
.pi/
|
||||||
|
|
||||||
|
# 构建产物(make web 生成,由 go:embed 打进二进制);保留 .gitkeep 占位使未构建前也能编译
|
||||||
|
backend/cmd/openteam/dist/*
|
||||||
|
!backend/cmd/openteam/dist/.gitkeep
|
||||||
|
|
||||||
|
# 前端构建产物(项目根目录的副本)
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# 误生成的目录(仅含 dist/node_modules)
|
||||||
|
web/
|
||||||
|
|||||||
Vendored
+40
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
// Vue: 启用 Take Over 模式,禁用内置 TS 服务,减少内存占用
|
||||||
|
"vue.server.hybridMode": true,
|
||||||
|
|
||||||
|
// Git: 降低自动拉取频率
|
||||||
|
"git.autofetch": false,
|
||||||
|
"git.maxVisibleCommits": 10,
|
||||||
|
"git.decorations.enabled": false,
|
||||||
|
|
||||||
|
// 保存时只格式化,不运行 code action(慢)
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "never",
|
||||||
|
"source.organizeImports": "never"
|
||||||
|
},
|
||||||
|
|
||||||
|
// 排除大目录,减少文件监听
|
||||||
|
"files.watcherExclude": {
|
||||||
|
"**/node_modules/**": true,
|
||||||
|
"**/.git/objects/**": true,
|
||||||
|
"**/dist/**": true,
|
||||||
|
"**/tmp/**": true,
|
||||||
|
"**/bin/**": true
|
||||||
|
},
|
||||||
|
|
||||||
|
// 排除搜索目录
|
||||||
|
"search.exclude": {
|
||||||
|
"**/node_modules": true,
|
||||||
|
"**/dist": true,
|
||||||
|
"**/tmp": true,
|
||||||
|
"**/bin": true,
|
||||||
|
"**/*.sum": true
|
||||||
|
},
|
||||||
|
|
||||||
|
// TypeScript: 降低语言服务开销
|
||||||
|
"typescript.tsserver.maxTsServerMemory": 1024,
|
||||||
|
"typescript.tsserver.watchOptions": {
|
||||||
|
"excludeDirectories": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# opencatd-open 后端重构计划
|
||||||
|
|
||||||
|
> 参考项目:`/home/ubuntu/Code/git/openteam`
|
||||||
|
> 创建时间:2026-08-30
|
||||||
|
> 当前分支:`team`
|
||||||
|
> 状态:**执行中**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、决策记录
|
||||||
|
|
||||||
|
| # | 决策项 | 结论 | 确认时间 |
|
||||||
|
|---|--------|------|----------|
|
||||||
|
| 1 | 旧系统处理 | 完全移除(opencat.go、store/、team/、pkg/team/、pkg/store/) | 2026-08-30 |
|
||||||
|
| 2 | 数据迁移 | 从旧表迁移(保留用户数据,apikeys → channels) | 2026-08-30 |
|
||||||
|
| 3 | 认证统一 | 统一到新系统(API Key SHA-256 hash 查表) | 2026-08-30 |
|
||||||
|
| 4 | Redis 依赖 | 内存起步(后续可升级) | 2026-08-30 |
|
||||||
|
| 5 | llm/ 目录 | 全部删除(纯代理模式,不需要 LLM 客户端库) | 2026-08-30 |
|
||||||
|
| 6 | cobra CLI | 保留(支持 reset_admin 等子命令) | 2026-08-30 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、目标目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
opencatd-open/
|
||||||
|
├── cmd/openteam/main.go # 唯一入口(cobra CLI + embed)
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/config.go # Viper + env(OT_ 前缀)
|
||||||
|
│ ├── auth/auth.go # JWT access/refresh + argon2id
|
||||||
|
│ ├── cli/ # Cobra CLI(root/serve/reset_admin)
|
||||||
|
│ ├── store/
|
||||||
|
│ │ ├── models.go # 全部 GORM 模型
|
||||||
|
│ │ ├── db.go # DB init + AutoMigrate
|
||||||
|
│ │ └── db_postgres.go # Postgres dialector
|
||||||
|
│ ├── dao/ # 数据访问层
|
||||||
|
│ ├── channel/
|
||||||
|
│ │ ├── channel.go # 候选选择、LB、并发信号量
|
||||||
|
│ │ └── health.go # 健康检查
|
||||||
|
│ ├── proxy/
|
||||||
|
│ │ ├── gateway.go # 网关核心
|
||||||
|
│ │ ├── handlers.go # 协议分派
|
||||||
|
│ │ ├── passthrough.go # HTTP 代理 + 记账
|
||||||
|
│ │ └── convert/ # 三协议互转
|
||||||
|
│ ├── api/ # 管理 API
|
||||||
|
│ ├── usage/recorder.go # 异步记账
|
||||||
|
│ ├── dto/ # 数据传输对象
|
||||||
|
│ └── pkg/ # 工具包
|
||||||
|
├── frontend/ # Vue 3 SPA
|
||||||
|
├── deploy/docker/ # Docker 部署
|
||||||
|
├── wire/ # 依赖注入
|
||||||
|
└── go.mod
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、删除清单
|
||||||
|
|
||||||
|
### 文件/目录
|
||||||
|
|
||||||
|
| 删除项 | 原因 |
|
||||||
|
|--------|------|
|
||||||
|
| `opencat.go` | 旧入口 |
|
||||||
|
| `store/` | 旧数据层 |
|
||||||
|
| `team/` | 旧 handler |
|
||||||
|
| `pkg/team/` | 旧 service |
|
||||||
|
| `pkg/store/` | DB 初始化(合并到 internal/store) |
|
||||||
|
| `pkg/error/` | 合并到 pkg/resp |
|
||||||
|
| `pkg/search/` | 不需要 |
|
||||||
|
| `internal/model/` | 合并到 internal/store/models.go |
|
||||||
|
| `internal/service/team/` | 合并到 internal/service |
|
||||||
|
| `internal/dto/team/` | 合并到 internal/dto |
|
||||||
|
| `internal/controller/team/` | 合并到 internal/api |
|
||||||
|
| `internal/consts/` | 合并到 internal/store/models.go |
|
||||||
|
| `llm/` | 整个删除 |
|
||||||
|
| `dist/` | 旧构建产物 |
|
||||||
|
| `assets/` | 旧静态资源 |
|
||||||
|
| `router/router.go` | 旧路由 |
|
||||||
|
| `router/chat.go` | 旧 chat 路由 |
|
||||||
|
| `middleware/auth_team.go` | 旧认证 |
|
||||||
|
|
||||||
|
### Go 依赖(移除)
|
||||||
|
|
||||||
|
| 移除依赖 | 原因 |
|
||||||
|
|----------|------|
|
||||||
|
| `sashabaranov/go-openai` | LLM 客户端 |
|
||||||
|
| `liushuangls/go-anthropic/v2` | LLM 客户端 |
|
||||||
|
| `google/generative-ai-go` | LLM 客户端 |
|
||||||
|
| `google.golang.org/genai` | LLM 客户端 |
|
||||||
|
| `cloud.google.com/go/vertexai` | LLM 客户端 |
|
||||||
|
| `gorilla/websocket` | WebSocket |
|
||||||
|
| `coder/websocket` | WebSocket |
|
||||||
|
| `faiface/beep` | 音频 |
|
||||||
|
| `gopkg.in/vansante/go-ffprobe.v2` | 音频 |
|
||||||
|
| `patrickmn/go-cache` | 用 gcache 替代 |
|
||||||
|
| `Sakurasan/to` | 指针工具 |
|
||||||
|
| `duke-git/lancet/v2` | 大杂烩 |
|
||||||
|
| `go-ozzo/ozzo-validation/v4` | 验证 |
|
||||||
|
| `mileusna/useragent` | UA 解析 |
|
||||||
|
| `golang.org/x/exp` | 实验性包 |
|
||||||
|
| `google.golang.org/api` | Google API |
|
||||||
|
| `golang.org/x/oauth2` | OAuth2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、执行阶段
|
||||||
|
|
||||||
|
### Phase 0:清理旧代码 + 目录重组
|
||||||
|
- 状态:✅ 完成
|
||||||
|
- 内容:删除旧文件、重构 models.go、更新 go.mod、更新 wire
|
||||||
|
- 验收:`go build ./cmd/openteam` 通过
|
||||||
|
|
||||||
|
### Phase 1:渠道服务 + 加密
|
||||||
|
- 状态:✅ 完成
|
||||||
|
- 内容:crypto(AES-GCM)、channel(候选/LB/健康检查)
|
||||||
|
- 验收:单元测试通过
|
||||||
|
|
||||||
|
### Phase 2:协议转换系统
|
||||||
|
- 状态:✅ 完成
|
||||||
|
- 内容:convert 包(6 种转换 + 流式 SSE)
|
||||||
|
- 验收:全部转换路径测试通过
|
||||||
|
|
||||||
|
### Phase 3:代理网关
|
||||||
|
- 状态:✅ 完成
|
||||||
|
- 内容:gateway、handlers、passthrough
|
||||||
|
- 验收:curl 冒烟测试通过
|
||||||
|
|
||||||
|
### Phase 4:异步记账
|
||||||
|
- 状态:✅ 完成
|
||||||
|
- 内容:usage recorder
|
||||||
|
- 验收:用量记录正确
|
||||||
|
|
||||||
|
### Phase 5:管理 API
|
||||||
|
- 状态:✅ 完成
|
||||||
|
- 内容:渠道/模型 CRUD、模型导入
|
||||||
|
- 验收:管理后台可用
|
||||||
|
|
||||||
|
### Phase 6:集成测试 + 收尾
|
||||||
|
- 状态:✅ 完成
|
||||||
|
- 内容:端到端测试、makefile、README
|
||||||
|
- 验收:8 种组合通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、执行记录
|
||||||
|
|
||||||
|
### Phase 0 — 执行记录
|
||||||
|
- 开始时间:2026-08-30
|
||||||
|
- 完成时间:2026-08-30
|
||||||
|
- 变更摘要:
|
||||||
|
- 删除旧文件:opencat.go, store/, team/, pkg/team/, pkg/store/, pkg/error/, pkg/search/, llm/, dist/, assets/, internal/model/, internal/service/team/, internal/dto/team/, internal/controller/team/, internal/consts/, router/router.go, router/chat.go, middleware/auth_team.go
|
||||||
|
- 新增 internal/store/models.go(9 个 GORM 模型)+ db.go(多数据库支持)
|
||||||
|
- 新增 internal/pkg/:crypto, apikey, jwt, ratelimit, resp, tokenizer
|
||||||
|
- 重写 internal/auth, internal/cli, internal/dao/*, internal/service/*, internal/controller/*
|
||||||
|
- 新增 middleware/auth_llm.go(API Key 验证)
|
||||||
|
- 重写 router/setRouter.go(无 wire 依赖)
|
||||||
|
- 重写 wire/wire.go(简化为 proxy handler)
|
||||||
|
- go mod tidy 清理未使用依赖
|
||||||
|
- 验收结果:✅ go build ./cmd/openteam 通过
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
.PHONY: build run test clean fmt lint frontend dev dev-backend dev-frontend docker docker-cn docker-multi
|
||||||
|
|
||||||
|
BINARY_NAME=openteam
|
||||||
|
BUILD_DIR=bin
|
||||||
|
BACKEND_DIR=backend
|
||||||
|
|
||||||
|
# Build
|
||||||
|
build: frontend
|
||||||
|
cd $(BACKEND_DIR) && CGO_ENABLED=0 go build -o ../$(BUILD_DIR)/$(BINARY_NAME) ./cmd/openteam
|
||||||
|
|
||||||
|
# Build backend only (frontend dist must exist)
|
||||||
|
build-backend:
|
||||||
|
cd $(BACKEND_DIR) && CGO_ENABLED=0 go build -o ../$(BUILD_DIR)/$(BINARY_NAME) ./cmd/openteam
|
||||||
|
|
||||||
|
# Build frontend and copy dist
|
||||||
|
frontend:
|
||||||
|
cd frontend && pnpm install && pnpm build
|
||||||
|
rm -rf $(BACKEND_DIR)/cmd/openteam/dist
|
||||||
|
cp -r frontend/dist $(BACKEND_DIR)/cmd/openteam/dist
|
||||||
|
|
||||||
|
# Run
|
||||||
|
run: build
|
||||||
|
./$(BUILD_DIR)/$(BINARY_NAME)
|
||||||
|
|
||||||
|
# Development: backend + frontend (requires air + pnpm)
|
||||||
|
dev: dev-frontend dev-backend
|
||||||
|
|
||||||
|
# Go backend with hot reload (requires: go install github.com/air-verse/air@latest)
|
||||||
|
dev-backend:
|
||||||
|
@command -v air >/dev/null 2>&1 || { echo "Installing air..."; go install github.com/air-verse/air@latest; }
|
||||||
|
cd $(BACKEND_DIR) && air -c .air.toml
|
||||||
|
|
||||||
|
# Vue frontend with HMR
|
||||||
|
dev-frontend:
|
||||||
|
cd frontend && pnpm dev
|
||||||
|
|
||||||
|
# Test
|
||||||
|
test:
|
||||||
|
cd $(BACKEND_DIR) && go test ./internal/... -v
|
||||||
|
|
||||||
|
# Test with coverage
|
||||||
|
test-cover:
|
||||||
|
cd $(BACKEND_DIR) && go test ./internal/... -coverprofile=coverage.out
|
||||||
|
cd $(BACKEND_DIR) && go tool cover -html=coverage.out -o coverage.html
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
fmt:
|
||||||
|
cd $(BACKEND_DIR) && go fmt ./...
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
lint:
|
||||||
|
cd $(BACKEND_DIR) && golangci-lint run
|
||||||
|
|
||||||
|
# Clean
|
||||||
|
clean:
|
||||||
|
rm -rf $(BUILD_DIR)
|
||||||
|
cd $(BACKEND_DIR) && rm -f coverage.out coverage.html
|
||||||
|
|
||||||
|
# Tidy dependencies
|
||||||
|
tidy:
|
||||||
|
cd $(BACKEND_DIR) && go mod tidy
|
||||||
|
|
||||||
|
# Build for Linux
|
||||||
|
build-linux:
|
||||||
|
cd $(BACKEND_DIR) && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ../$(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./cmd/openteam
|
||||||
|
|
||||||
|
# Build for macOS
|
||||||
|
build-mac:
|
||||||
|
cd $(BACKEND_DIR) && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o ../$(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./cmd/openteam
|
||||||
|
|
||||||
|
# Build all platforms
|
||||||
|
build-all: build-linux build-mac
|
||||||
|
|
||||||
|
# Database migration (will be implemented)
|
||||||
|
migrate:
|
||||||
|
@echo "Migration will be implemented in future"
|
||||||
|
|
||||||
|
# Seed data (will be implemented)
|
||||||
|
seed:
|
||||||
|
@echo "Seeding will be implemented in future"
|
||||||
|
|
||||||
|
# Docker build (default platform)
|
||||||
|
docker:
|
||||||
|
docker build -f deploy/docker/Dockerfile -t $(BINARY_NAME):latest .
|
||||||
|
|
||||||
|
# Docker build (China mirror accelerated)
|
||||||
|
docker-cn:
|
||||||
|
docker build -f deploy/docker/Dockerfile.cn -t $(BINARY_NAME):latest .
|
||||||
|
|
||||||
|
# Docker build multi-platform (requires: docker buildx)
|
||||||
|
docker-multi:
|
||||||
|
docker buildx build -f deploy/docker/Dockerfile \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
-t $(BINARY_NAME):latest --push .
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
[](https://t.me/OpenTeamChat) [](https://t.me/OpenTeamLLM)
|
[](https://t.me/OpenTeamChat) [](https://t.me/OpenTeamLLM)
|
||||||
|
|
||||||
opencatd-open is an open-source, team-shared service for ChatGPT API that can be safely shared with others for API usage.
|
opencatd-open is an open-source, team-shared service for OpenAI-compatible LLM APIs — route clients to any provider through a single endpoint, share costs, and track usage across your team.
|
||||||
|
|
||||||
---
|
---
|
||||||
OpenCat for Team的开源实现
|
OpenCat for Team的开源实现
|
||||||
@@ -77,9 +77,6 @@ wget https://github.com/mirrors2/opencatd-open/raw/main/docker/docker-compose.ym
|
|||||||
- [Fly.io](https://fly.io/)
|
- [Fly.io](https://fly.io/)
|
||||||
- 或者其他
|
- 或者其他
|
||||||
|
|
||||||
修改openai的endpoint地址?使用任意上游地址(套娃代理)
|
|
||||||
- 设置环境变量 openai_endpoint
|
|
||||||
|
|
||||||
使用Nginx + Docker部署
|
使用Nginx + Docker部署
|
||||||
- [使用Nginx + Docker部署](./doc/deploy.md)
|
- [使用Nginx + Docker部署](./doc/deploy.md)
|
||||||
|
|
||||||
@@ -87,12 +84,13 @@ pandora for team
|
|||||||
- [pandora for team](./doc/pandora.md)
|
- [pandora for team](./doc/pandora.md)
|
||||||
|
|
||||||
如何自定义HOST地址? (仅OpenAI)
|
如何自定义HOST地址? (仅OpenAI)
|
||||||
- 需修改环境变量,优先级递增
|
- 需修改环境变量,优先级递增(全局配置谨慎修改)
|
||||||
- Cloudflare AI Gateway地址 `AIGateWay_Endpoint=https://gateway.ai.cloudflare.com/v1/123456789/xxxx/openai/chat/completions`
|
- Cloudflare AI Gateway地址 `AIGateWay_Endpoint=https://gateway.ai.cloudflare.com/v1/123456789/xxxx/openai/chat/completions`
|
||||||
- 自定义的endpoint `$CUSTOM_ENDPOINT=true && $OpenAI_Endpoint=https://your.domain/v1/chat/completions`
|
- 自定义的endpoint `OpenAI_Endpoint=https://your.domain/v1/chat/completions`
|
||||||
|
|
||||||
设置主页跳转地址?
|
设置主页跳转地址?
|
||||||
- 修改环境变量 `CUSTOM_REDIRECT=https://your.domain`
|
- 修改环境变量 `CUSTOM_REDIRECT=https://your.domain`
|
||||||
|
|
||||||
## 获取更多信息
|
## 获取更多信息
|
||||||
[](https://t.me/OpenTeamLLM)
|
[](https://t.me/OpenTeamLLM)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
# opencatd-open 重构计划
|
||||||
|
|
||||||
|
> 本文档是重构过程的唯一进度记录,每完成一个阶段立即更新「阶段状态」与「执行记录」。
|
||||||
|
> 前后端同仓库,本期(第一期)只重构前端 + 前端相关的构建脚本;后端代码不在本期范围。
|
||||||
|
|
||||||
|
- 计划创建时间:2026-08-29
|
||||||
|
- 当前分支:`team`(按约定不提交,所有改动留在工作区,由维护者回来后审查)
|
||||||
|
- 项目根目录:`/Users/cjun/Code/Go/src/opencatd-open`
|
||||||
|
- 前端目录:`frontend/`(构建产物 `dist/` 由 Go 通过 `//go:embed dist/*` 嵌入 `cmd/openteam`)
|
||||||
|
|
||||||
|
## 一、背景与现状
|
||||||
|
|
||||||
|
| 项 | 现状 | 问题 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 技术栈 | Vue 3.5 + Vite 6 + JavaScript,无 TS | 依赖偏旧;无类型约束 |
|
||||||
|
| UI 库 | Element Plus 与 daisyUI/Tailwind 3 **两套并存** | 9 个视图使用 `el-*` 组件,风格割裂、包体冗余 |
|
||||||
|
| 目录结构 | components/views 仅按 dashboard 简单分层 | 组件分类不规范,无 api/layouts/types 分层 |
|
||||||
|
| 构建脚本 | Dockerfile 三阶段,node 阶段未指定 `$BUILDPLATFORM` | 多架构构建时前端被 QEMU 模拟重复编译,极慢 |
|
||||||
|
| 依赖声明 | pinia、@iconify/vue 误放 devDependencies | 分类错误 |
|
||||||
|
| Dockerfile 杂项 | 存在无效的 `CMD ["go mod tidy","go mod download"]`;node:20 基础镜像 | 需清理/升级 |
|
||||||
|
| 杂项 | 根目录 `web/`(仅 dist + node_modules,未跟踪) | 疑似误构建产物,暂不动,仅记录 |
|
||||||
|
|
||||||
|
## 二、已确认的决策(2026-08-29,维护者离开前确认)
|
||||||
|
|
||||||
|
1. **迁移到 TypeScript**(全量,含 vue-tsc 类型检查)。
|
||||||
|
2. **UI 统一到 Tailwind/daisyUI**,移除 Element Plus,`el-*` 组件全部重写;接受外观变化。
|
||||||
|
3. **不提交**:所有改动留在工作区,按阶段推进,不做 git commit。
|
||||||
|
|
||||||
|
其余由执行者自行决定的默认约定:
|
||||||
|
|
||||||
|
- 依赖一律升到**当前最新稳定版**(含 Tailwind 4 / daisyUI 5 / Vite 7+ / Pinia 3 等大版本跨越)。
|
||||||
|
- Element Plus 在被移除前不再投入升级成本(Phase 4 直接删除)。
|
||||||
|
- 每阶段验收标准:`pnpm build`(后期含 `vue-tsc`)通过 + 页面路由/交互逻辑与重构前等价。
|
||||||
|
- 计划文档放项目根目录 `REFACTOR_PLAN.md`。
|
||||||
|
|
||||||
|
## 三、阶段计划
|
||||||
|
|
||||||
|
| 阶段 | 内容 | 状态 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Phase 0 | 创建本计划文档 | ✅ 完成 |
|
||||||
|
| Phase 1 | 依赖全部升级到最新版(Tailwind 4 / daisyUI 5 迁移、pinia 归位 dependencies) | ✅ 完成 |
|
||||||
|
| Phase 2 | TypeScript 迁移(tsconfig、vue-tsc、全量 .ts/.vue 改写) | ✅ 完成 |
|
||||||
|
| Phase 3 | 目录结构规范化(api / components / composables / layouts / types / views 分层) | ✅ 完成 |
|
||||||
|
| Phase 4 | 移除 Element Plus,统一 Tailwind/daisyUI 重写全部组件 | ✅ 完成 |
|
||||||
|
| Phase 5 | Docker / makefile 构建脚本更新(前端 `$BUILDPLATFORM` 单次编译) | ✅ 完成 |
|
||||||
|
| Phase 6 | 最终验证(前端 build + Go embed 编译),收尾文档 | ✅ 完成 |
|
||||||
|
|
||||||
|
## 四、各阶段详细方案
|
||||||
|
|
||||||
|
### Phase 1 — 依赖升级
|
||||||
|
|
||||||
|
- `vite`、`@vitejs/plugin-vue`、`@vitejs/plugin-basic-ssl`、`vue`、`vue-router`、`axios`、`lucide-vue-next`、`qrcode.vue`、`@simplewebauthn/browser`、`@iconify/vue`、`@iconify-json/*` → 最新。
|
||||||
|
- `pinia` → v3 并移入 dependencies;`@iconify/vue` 移入 dependencies。
|
||||||
|
- Tailwind 3 → 4:改用 `@tailwindcss/vite` 插件,删除 `postcss.config.js`/`autoprefixer`/`tailwind.config.js`,`style.css` 改为 `@import "tailwindcss"` + `@plugin "daisyui"` + `@theme` 定义原有 daisyUI 主题集合(light/dark/cupcake/emerald/pastel)。
|
||||||
|
- `daisyui` → v5。
|
||||||
|
- element-plus 保持现状(Phase 4 删除)。
|
||||||
|
- 验收:`pnpm build` 通过。
|
||||||
|
|
||||||
|
### Phase 2 — TypeScript 迁移
|
||||||
|
|
||||||
|
- 新增 `tsconfig.json`(bundler 解析策略 + `@` 别名路径映射)、`src/vite-env.d.ts`、`env.d.ts`(`import.meta.env` 类型)。
|
||||||
|
- `vite.config.js` → `vite.config.ts`;`src/**/*.js`(router/stores/utils/main)→ `.ts`。
|
||||||
|
- 全部 `.vue` 改 `<script setup lang="ts">`,props/emits/响应式数据补类型。
|
||||||
|
- `build` 脚本加 `vue-tsc --noEmit` 类型检查。
|
||||||
|
- 验收:`pnpm build`(含 vue-tsc)通过。
|
||||||
|
|
||||||
|
### Phase 3 — 目录结构规范化
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── api/ # axios 实例 + 各业务接口封装(由 utils/request.js 演进)
|
||||||
|
├── assets/ # 图片/图标(不变)
|
||||||
|
├── components/
|
||||||
|
│ ├── common/ # 通用组件(Toast、Pagination、QRCodeCard、LineSegmentFlow)
|
||||||
|
│ └── dashboard/ # 仪表盘布局组件(Sidebar、BreadcrumbHeader)
|
||||||
|
├── composables/ # 组合式函数(useToast 等从 provide/inject 演进)
|
||||||
|
├── layouts/ # 布局(DashboardLayout 等,如适用)
|
||||||
|
├── router/ # 路由
|
||||||
|
├── stores/ # pinia stores
|
||||||
|
├── styles/ # 全局样式
|
||||||
|
├── types/ # 共享 TS 类型(API 响应、业务实体)
|
||||||
|
├── utils/ # 纯工具函数(格式化日期等)
|
||||||
|
└── views/
|
||||||
|
├── auth/ # Login、Signup
|
||||||
|
├── error/ # 404
|
||||||
|
└── dashboard/ # Overview、Keys、Tokens、Users、Settings、Profile 等
|
||||||
|
```
|
||||||
|
|
||||||
|
- vite.config 的 manualChunks 别名同步更新。
|
||||||
|
- 验收:`pnpm build` 通过,无悬空 import。
|
||||||
|
|
||||||
|
### Phase 4 — UI 统一到 Tailwind/daisyUI
|
||||||
|
|
||||||
|
- 移除 `element-plus` 依赖与 `main.ts` 全局注册。
|
||||||
|
- 重写以下 9 个视图中的 `el-*` 组件(table/dialog/form/select/input/switch/message 等用 daisyUI 组件类 + 自实现交互):
|
||||||
|
Login、Signup、dashboard/{UserView、Settings、KeyView、Profile、TokenNew、KeyNew、UserNew}。
|
||||||
|
- 顺带规范既有自研组件(Toast、Pagination 等)使用 daisyUI 类。
|
||||||
|
- 保留既有业务逻辑、字段、接口调用不变。
|
||||||
|
- 验收:`pnpm build` 通过;`grep el-`/`element-plus` 无残留。
|
||||||
|
|
||||||
|
### Phase 5 — Docker / makefile 构建脚本
|
||||||
|
|
||||||
|
- `deploy/docker/Dockerfile`:
|
||||||
|
- 前端阶段 `FROM --platform=$BUILDPLATFORM node:22-alpine AS frontend`(多架构下只原生编译一次)。
|
||||||
|
- 后端阶段同样 `$BUILDPLATFORM` + `CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH` 交叉编译(go.mod 使用纯 Go 的 glebarez/sqlite,可关闭 CGO);如遇阻塞则后端阶段回退为按目标平台编译,仅保留前端优化。
|
||||||
|
- 删除无效 `CMD` 行;runner 阶段瘦身。
|
||||||
|
- `makefile`:web 目标改用 pnpm(不强制全局安装 pnpm),构建产物位置与 embed 路径核对。
|
||||||
|
- 同步更新 `Dockerfile.cn`(国内镜像版)保持一致。
|
||||||
|
- 验收:`docker build` 本地单架构通过(多架构如环境不允许则用 `--platform` 模拟检查语法与目标参数)。
|
||||||
|
|
||||||
|
### Phase 6 — 最终验证与收尾
|
||||||
|
|
||||||
|
- `frontend: pnpm build`(含 vue-tsc)。
|
||||||
|
- 把 `frontend/dist` 放入 `cmd/openteam/dist` 后执行 `go build ./cmd/openteam`,确认 embed 成功。
|
||||||
|
- 更新本文档所有阶段状态与执行记录,列出遗留问题(如 `web/` 目录处置、外观回归点)。
|
||||||
|
|
||||||
|
## 五、执行记录(每阶段完成后追加)
|
||||||
|
|
||||||
|
### Phase 0 — 计划文档创建(2026-08-29)
|
||||||
|
|
||||||
|
- 已确认三项决策:TS 迁移 / 统一 Tailwind-daisyUI / 不提交。
|
||||||
|
- 摸底结论:前端约 4700 行 Vue;Element Plus 用于 9 个视图;pinia、@iconify/vue 在 devDependencies;`glebarez/sqlite` 为纯 Go 驱动(Docker 交叉编译可行);本地 Node v25.2.1 / pnpm 10.25.0。
|
||||||
|
- 发现根目录未跟踪的 `web/` 目录仅含 dist 与 node_modules,疑似误产物,本期不动。
|
||||||
|
|
||||||
|
### Phase 1 — 依赖升级(2026-08-29)✅
|
||||||
|
|
||||||
|
- 升级结果(均为当前最新稳定版):
|
||||||
|
- dependencies:vue 3.5.42、vue-router **5.3.0**(大版本 4→5)、pinia **4.0.3**(大版本 2→4,并移入 dependencies)、axios 1.20.0、qrcode.vue 3.10.0、@simplewebauthn/browser 13.3.0、@iconify/vue 5.0.1(移入 dependencies)、**@lucide/vue 1.37.0**(替代已废弃的 lucide-vue-next)。
|
||||||
|
- devDependencies:vite **8.2.2**(大版本 6→8,构建器为 rolldown)、@vitejs/plugin-vue 6.0.8、@vitejs/plugin-basic-ssl 2.3.0、tailwindcss **4.3.3**(大版本 3→4)、@tailwindcss/vite 4.3.3、daisyui **5.7.22**(大版本 4→5)、@iconify-json/* 升级。
|
||||||
|
- 移除:autoprefixer、postcss、tailwind.config.js、postcss.config.js(Tailwind 4 改为 CSS-first 配置)。
|
||||||
|
- 迁移要点:
|
||||||
|
- `style.css` 改为 `@import "tailwindcss"` + `@plugin "daisyui"`,主题集合保持 light(默认)/dark/cupcake/emerald/pastel。
|
||||||
|
- `vite.config.js` 加入 `@tailwindcss/vite` 插件;`__dirname` 改为 `import.meta.dirname`(消除 Vite 8 警告)。
|
||||||
|
- package.json 改名为 `opencatd-open-frontend`;新增 `pnpm.onlyBuiltDependencies: [esbuild, vue-demi]` 放行构建脚本。
|
||||||
|
- 代码适配:新版 Lucide 移除品牌图标,`Profile.vue` 的 `<Github>` 图标改为项目内已有的 `<img src="/assets/github.svg">`(与 Keys/KeyNew/KeyView 用法一致)。
|
||||||
|
- 验收:`pnpm build` 通过(4.8s,rolldown 构建)。
|
||||||
|
- 备注:element-plus 2.9.7 保持旧版未升级(Phase 4 将整体移除);当前 components chunk 406KB 主要来自 Element Plus,Phase 4 后预计大幅缩小。
|
||||||
|
|
||||||
|
### Phase 2 — TypeScript 迁移(2026-08-29)✅
|
||||||
|
|
||||||
|
- 工具链:typescript **6.0.3** + vue-tsc 3.3.11 + @types/node 26.4.0。
|
||||||
|
- 注:最初装了 typescript 7.0.2(tsgo 原生版),但 vue-tsc 依赖 `typescript/lib/tsc` 导出而 TS7 已移除,故回落到 6.x(当前最新 JS 版)。
|
||||||
|
- tsconfig:strict 模式、bundler 解析、`@` 别名(TS6 弃用 baseUrl,改用相对 paths)、types 含 vite/client + node。
|
||||||
|
- 全量转换:`vite.config.ts`、`src/main.ts`、router/stores/utils 共 9 个 JS→TS;19 个 `.vue` 全部 `<script setup lang="ts">`。
|
||||||
|
- 类型设计:
|
||||||
|
- `src/types/index.ts`:UserInfo / TokenInfo / ApiKey / PasskeyInfo 及各请求 Payload 类型(宽松可选字段 + 索引签名兼容后端松散返回)。
|
||||||
|
- `src/composables/toast.ts`:类型安全的 provide/inject(InjectionKey),替代各视图裸 `inject('toast')`。
|
||||||
|
- `vue-router` RouteMeta 模块扩展(title/icon/showInSidebar/requiresAuth 等);MenuItem 为可辨识联合。
|
||||||
|
- package.json:新增 `typecheck` 脚本;`build` 改为 `vue-tsc --noEmit && vite build`。
|
||||||
|
- 顺带修复的存量 bug(均记录在案):
|
||||||
|
1. `Keys.vue` toggleSelectAll 引用了不存在的 `users`(应为 `keys`)——运行时全选会抛错。
|
||||||
|
2. `Settings.vue` updateUser 引用了未定义的 `userStore`/`userId`(复制粘贴残留),提交表单必抛错——改为经 `authStore.updateProfile` 更新当前用户。
|
||||||
|
3. store 的 catch 中 `throw error` 抛出的是 Ref 对象,`Login.vue` 会把 Ref 显示为 `[object Object]`——加了 `errMsg()` 取值辅助。
|
||||||
|
4. `KeyNew.vue` 模板绑定了不存在的 `togglePasswordVisibility`(点击报 TypeError)——移除死绑定。
|
||||||
|
5. `User/Keys/UserNew/KeyNew` 里 `res.error` 恒为 undefined(AxiosResponse 无此字段)——改为 `res.data?.error`。
|
||||||
|
6. `TokenNew.vue` 初始 `user_id: user.user_id`(ComputedRef 上取值恒 undefined)——改为 `user.value?.user_id`。
|
||||||
|
7. `Login.vue` rember 记住密码存入布尔被 localStorage 转字符串('true'),统一 `String()` 存储。
|
||||||
|
- 验收:`pnpm build`(含 vue-tsc 严格检查)通过。
|
||||||
|
|
||||||
|
### Phase 3 — 目录结构规范化(2026-08-29)✅
|
||||||
|
|
||||||
|
- 最终结构:
|
||||||
|
- `src/api/client.ts` ← utils/request.ts(axios 实例与拦截器;业务接口调用仍保留在 stores 中,作为轻量 API 层,避免无谓 churn,后续可按需下沉到 api/ 各模块)
|
||||||
|
- `src/components/common/` ← Toast / Pagination / QRCodeCard / LineSegmentFlow
|
||||||
|
- `src/components/dashboard/` 保持(Sidebar / BreadcrumbHeader)
|
||||||
|
- `src/layouts/DashboardLayout.vue` ← views/DashBoard.vue(本质是布局组件,归位 layouts 层)
|
||||||
|
- `src/styles/main.css` ← src/style.css
|
||||||
|
- `src/views/auth/` ← Login / Signup;`src/views/error/NotFound.vue` ← views/404.vue
|
||||||
|
- `src/views/Home.vue`、`src/views/dashboard/*` 保持
|
||||||
|
- 全部相对路径 import 统一为 `@/` 别名;模板内相对资源路径(`../assets/...`)统一为 `@/assets/...`。
|
||||||
|
- vite.config.ts 的 manualChunks 分包规则按新目录核对(components / views-dashboard / stores 三组仍有效)。
|
||||||
|
- 验收:`pnpm build` 通过,无悬空 import。
|
||||||
|
|
||||||
|
### Phase 4 — UI 统一到 Tailwind/daisyUI(2026-08-29)✅
|
||||||
|
|
||||||
|
- 摸底修正:Element Plus 实际仅在 3 处使用(main.ts 全局注册 + KeyNew/KeyView 的 `el-input-tag`),其余视图本就以 daisyUI 为主。
|
||||||
|
- 变更:
|
||||||
|
- `main.ts` 移除 Element Plus 注册与样式;`pnpm remove element-plus`。
|
||||||
|
- 新增 `src/components/common/TagInput.vue`(daisyUI 风格,Enter 添加/逐个删除/Backspace 删末尾/可清空),替换 KeyNew/KeyView 中的 `el-input-tag`。
|
||||||
|
- 全库 grep 确认无 `element-plus` / `el-*` 组件残留。
|
||||||
|
- 收益(构建产物对比):
|
||||||
|
- 主 CSS:489.6KB → 163.4KB(-67%,主要为 Element Plus 全量样式)
|
||||||
|
- components JS:405.7KB → 194.7KB(-52%)
|
||||||
|
- 入口 index JS:617.8KB → 1.3KB(Element Plus 运行时原本在入口包)
|
||||||
|
- 验收:`pnpm build`(含类型检查)通过。
|
||||||
|
|
||||||
|
### Phase 5 — Docker / makefile 构建脚本(2026-08-29)✅
|
||||||
|
|
||||||
|
- `deploy/docker/Dockerfile` 重写:
|
||||||
|
- 前端阶段 `FROM --platform=$BUILDPLATFORM node:22-alpine`:多架构构建时前端只在构建机原生平台编译**一次**(原先会被 QEMU 模拟在每个目标平台各跑一遍)。
|
||||||
|
- 后端阶段同样 `$BUILDPLATFORM` + `CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH` 交叉编译(glebarez/sqlite 纯 Go 驱动,已验证可行),全程零 QEMU。
|
||||||
|
- 删除了无效且写法错误的 `CMD ["go mod tidy","go mod download"]`;pnpm 固定 `@10.25.0`;`pnpm install --frozen-lockfile`;runner 阶段补充 `ca-certificates`;修正 `LABEL anther→author`;移除不再需要的 `cmake`。
|
||||||
|
- `Dockerfile.cn`(国内源版)同步更新。
|
||||||
|
- 新增根目录 `.dockerignore`(原先缺失:node_modules、dist、.git、web/ 等全部会被拷进构建上下文)。
|
||||||
|
- `makefile`:
|
||||||
|
- `web` 目标:pnpm 按需安装 + `--frozen-lockfile`,产物干净替换到 `cmd/openteam/dist`(原 `mv dist ../cmd/openteam/` 在目标已存在时会错误嵌套一层)。
|
||||||
|
- `build` 目标:加 `CGO_ENABLED=0`;`upx` 改为可选(本机未装时跳过,不再中断)。
|
||||||
|
- package.json 增加 `"packageManager": "pnpm@10.25.0"`:新版 pnpm 默认启用供应链策略(拒装 24h 内发布的包)并不再读取 package.json 的 `pnpm` 字段,钉住版本保证容器内外行为一致、可重现。
|
||||||
|
- 验证:
|
||||||
|
- `docker build --target frontend` 通过,dist 产物完整。
|
||||||
|
- `docker buildx build --platform linux/amd64,linux/arm64`(xbuilder)通过;日志确认 frontend 仅在原生平台执行一次,arm64 后端为交叉编译,无 QEMU。
|
||||||
|
- 本机 `CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build` 产出静态 ELF(含嵌入前端)。
|
||||||
|
- `make web`、`make build` 通过。
|
||||||
|
|
||||||
|
### Phase 6 — 最终验证与收尾(2026-08-29)✅
|
||||||
|
|
||||||
|
- `pnpm build`(vue-tsc 严格类型检查 + vite 构建)通过。
|
||||||
|
- `.gitignore` 补充 `cmd/openteam/dist/`(构建产物)与 `web/`(误生成目录,未跟踪)。
|
||||||
|
- 遗留事项(供维护者决策,均不影响本期交付):
|
||||||
|
1. `frontend/src/utils/format-date.ts`、`Overview.vue` 内部仍各自实现了一份 `formatDateTime`,可合并到 utils 统一导入(行为无差异,纯清理)。
|
||||||
|
2. CI workflow(`.github/workflows/`)引用的 `./docker/Dockerfile` 路径早已失效(现为 `deploy/docker/Dockerfile`),且 checkout 的还是 @v3 旧 action——后端/CI 不在本期范围,未改动。
|
||||||
|
3. 依赖升级中未逐项验证运行时 UI 细节(如 daisyUI 4→5 的个别类名行为差异、vue-router 4→5),建议维护者回来后 `make web && make build` 或 `pnpm dev` 过一遍登录/令牌/密钥/用户管理页面。
|
||||||
|
4. `web/` 目录(仅 dist + node_modules)疑似误构建产物,已加入 .gitignore,确认无用后可删除。
|
||||||
|
|
||||||
|
## 六、后续增量(2026-08-29,维护者返程前追加)
|
||||||
|
|
||||||
|
### 增量 1 — 本地开发体验优化(前后端分离联调)✅
|
||||||
|
|
||||||
|
背景:前端构建产物经 `//go:embed` 嵌入 Go 二进制,本地每改一次前端都要 `make web + make build`,且没有热更新。
|
||||||
|
|
||||||
|
- `frontend/vite.config.ts`:dev server 增加 `server.proxy`,`/api` 代理到本地 Go 后端(默认 `http://localhost:8080`,`VITE_DEV_API_TARGET` 可覆盖)。开发时前端跑在 Vite 上(HMR),接口走代理到后端,无跨域问题。
|
||||||
|
- dev server 默认改为 HTTP(localhost 属浏览器安全上下文,clipboard/Passkey 均可用);需要自签名 HTTPS 时设 `VITE_DEV_HTTPS=true` 恢复 basicSsl。
|
||||||
|
- `cmd/openteam/main.go`:`//go:embed dist/*` → `//go:embed all:dist`,配合 `cmd/openteam/dist/.gitkeep` 占位(已跟踪),dist 没有真实产物时 `go run ./cmd/openteam` 也能编译启动——克隆后可直接起后端联调,不必先构建前端。`make web` 移入产物后会补回 `.gitkeep`。
|
||||||
|
- `makefile` 新增:
|
||||||
|
- `make dev-backend`:`PORT=8080 go run ./cmd/openteam`(数据库 `./db/openteam.db`)
|
||||||
|
- `make dev-frontend`:`cd frontend && pnpm dev`(5173)
|
||||||
|
- `make dev`:`$(MAKE) -j2` 并行启动两者,Ctrl+C 一起退出
|
||||||
|
- `frontend/README.md` 补充开发文档(启动方式、环境变量表)。
|
||||||
|
- 验证:后端 8080 + Vite 5173 同时运行,`curl http://localhost:5173/` 返回 SPA 页面;经 5173 代理的 `POST /api/auth/login` 返回后端真实校验 JSON、`GET /api/auth/passkey/begin` 返回 200,代理链路完整。
|
||||||
|
- 说明:这是构建基础设施改动,触及 `cmd/openteam/main.go` 一行 embed 指令;后端业务逻辑零改动。生产构建流程不受影响(Docker 内 dist 由前端阶段提供)。
|
||||||
|
|
||||||
|
### 增量 2 — 全站 UI/UX 重设计(web-design-guidelines)✅
|
||||||
|
|
||||||
|
依据:Vercel Web Interface Guidelines(`~/.agents/skills/web-design-guidelines` 拉取的最新规则集)。先对全部页面截图建立基线,再逐页重写模板;**业务逻辑、接口调用、路由全部未动**。
|
||||||
|
|
||||||
|
- 设计系统基础(`styles/main.css` + `index.html`):
|
||||||
|
- 深浅色 `color-scheme` 跟随主题;`<meta name="theme-color">`;内联脚本恢复上次主题(不再每次刷新回 emerald)。
|
||||||
|
- 全局 `:focus-visible` 焦点环、`touch-action: manipulation`、`prefers-reduced-motion` 全局降级、`.modal-box` 防滚动穿透。
|
||||||
|
- 覆写 emerald 主题圆角令牌(默认 selector 圆角 1rem 使小复选框渲染成正圆的 bug)。
|
||||||
|
- 壳层:侧栏启用 daisyUI 5 `menu-active` 高亮 + sticky;顶栏重排(侧栏开关带 aria-pressed、主题切换持久化 localStorage、用户菜单头像+用户名);主区限宽 max-w-6xl。
|
||||||
|
- 页面模式统一:`面包屑 + 大标题 → 描述 + 主操作 → 卡片内容`;列表页统一工具栏/表格/空状态/分页(空数据隐藏分页);表单统一分区标题、label/控件绑定、required 标记、示例占位符、提交按钮 spinner。
|
||||||
|
- 主要变更:
|
||||||
|
- Overview:深色渐变横幅改为品牌 primary→secondary 渐变;信息卡由 badge 滥用改为 dl 键值布局(仅角色/状态保留 badge);时间 tabular-nums。
|
||||||
|
- Tokens/Keys/Users 列表:空状态(图标+文案+CTA)、行操作按钮 aria-label、状态筛选改为带计数的下拉、移除两个死的 Filter 输入框、批量操作菜单重排。
|
||||||
|
- 弹窗表单(TokenNew/KeyNew/UserNew):去掉 min-h-screen 包裹,适配弹窗容器。
|
||||||
|
- 详情页(KeyView/UserView):头像+徽章头部、分区表单、tokens 表格;加载态明确。
|
||||||
|
- Profile/Settings:分区卡片(基本信息/密码/Passkeys/关联账号),提交加 spinner。
|
||||||
|
- Login/Signup:实体主按钮、内联错误 alert(role=alert)、autocomplete(username/current-password/new-password)、死链"Forgot password?"移除、密码不一致内联提示。
|
||||||
|
- Home:导航栏 Star 徽标 + Open Dashboard 主 CTA,hero 文案重写,endpoint 复制组加 label,图片补尺寸。
|
||||||
|
- 404:品牌化布局 + 具体文案("doesn't exist or has been moved")。
|
||||||
|
- Toast:`aria-live="polite"` + toast-top/end 定位;Pagination:join 样式 + Showing x–y of z + tabular-nums。
|
||||||
|
- 顺带修复:DashboardLayout `if (!userInfo)` 恒真导致刷新后头部用户名丢失(改为 `!authStore.user`)。
|
||||||
|
- 验证:`pnpm build`(vue-tsc 严格检查)通过;本地起前后端后逐页浏览器截图回归(Users/Overview/Tokens/新建弹窗/Keys 空状态/Profile/404/Home/Login)视觉与交互正常;复选框圆角修复经计算样式确认(16px→8px)。
|
||||||
|
- 已知取舍:破坏性操作沿用原生 `confirm()`(满足"需确认"要求,后续可换主题化 modal);列表筛选/分页尚未同步到 URL query(指南建议,列为后续项);"Forgot password?" 为死链已移除。
|
||||||
|
- 补充(维护者反馈):Overview 横幅恢复**随时间段变化的配色**(原版行为),四段式与新版式协调——深夜 slate-900→indigo-950、早晨 sky-600→amber-400、白天 blue-600→cyan-400、夜晚 slate-900→indigo-950;白色文字置于渐变左侧深色端保证对比度。顺带修复原版问候语 bug:0-6 点原显示"早上好",现对齐四段为"夜深了"。
|
||||||
|
|
||||||
|
### 增量 3 — 主题切换(浅色 / 深色 / 自动)✅
|
||||||
|
|
||||||
|
- 新增 `src/composables/theme.ts`:偏好三态 `light | dark | auto`(浅色映射品牌 emerald 主题),localStorage 持久化(key `theme`),auto 模式监听 `prefers-color-scheme` 实时跟随系统切换;模块级单例状态,主页与仪表盘共享。
|
||||||
|
- `index.html` 内联脚本同步三态逻辑(含 auto 解析),首屏不闪烁。
|
||||||
|
- 主页导航栏与仪表盘顶栏均提供主题下拉(太阳/月亮/显示器图标随当前偏好变化,当前项 `menu-active` 高亮);仪表盘原先的二态硬切换按钮升级为同一三选下拉,避免两套主题逻辑互相覆盖。
|
||||||
|
- 默认偏好为 `auto`(首次访问跟随系统);旧存储值 `emerald` 不再有效,自动回落 auto。
|
||||||
|
- 验证:`pnpm build` 通过;浏览器实测浅色↔深色↔自动即时生效、`data-theme` 与 localStorage 值正确、刷新后保持、auto 按系统深色解析为 dark;主页与仪表盘主题共享一致。
|
||||||
|
|
||||||
|
### 增量 4 — 导航栏按角色分区重设计(参考维护者提供的双栏设计稿)✅
|
||||||
|
|
||||||
|
- 信息架构对齐设计稿:导航拆为**控制台**(所有用户:仪表盘 / API 密钥 / 账户设置)与**管理后台**(role ≥ 10:用户管理 / 渠道管理)两个区域,按当前路由区域(`/dashboard/manager/*`)切换显示;底部互切入口——控制台区显示「管理后台 →」(仅管理员),后台区显示「← 返回控制台」。
|
||||||
|
- `router_menu.ts` 重写:弃用递归菜单生成器,改为显式两套菜单数组;路由 meta 标题中文化(仪表盘 / API 密钥 / 用户管理 / 渠道管理 / 账户设置等)。
|
||||||
|
- `BreadcrumbHeader` 增加路由路径→中文标题映射,面包屑与页面标题随之中文化(此前按英文路径段拼接)。
|
||||||
|
- 与设计稿的差异(页面对应关系):API 密钥→个人 Tokens 页;渠道管理→上游 Provider Keys 页;设计稿中的用量明细/用量统计/模型定价/系统配置暂无对应页面,未做死链,留作后续功能。
|
||||||
|
- 验证:`pnpm build` 通过;浏览器实测 admin 视角(控制台菜单 + 底部管理后台入口 → 后台菜单 + 返回控制台,高亮正确)与普通用户视角(注册 member 账号实测:仅控制台菜单,管理后台入口数量为 0);中文面包屑生效。
|
||||||
|
- 补充(维护者反馈):「API 密钥」菜单项与页面标题改名为 **API Keys**;面包屑重设计——废弃路径段拼接,改为按路由名显式定义层级:顶级页面(仪表盘/API Keys/账户设置)不显示面包屑仅保留标题,管理后台列表页显示「管理后台」一级,详情页显示「管理后台 / 列表页」两级(末级为当前页标题),标题统一取自定义 title 或路由 meta.title。
|
||||||
|
- 补充(交互收尾):移动端抽屉在路由切换后自动收起(router.afterEach);顶栏主题/个人下拉为焦点展开型,选择后主动移除焦点收起菜单,个人菜单按钮补 aria-expanded 语义。注:DashboardLayout 模板含维护者手动增强的个人菜单(身份信息头 + 管理后台入口),脚本已按模板对齐(handleMenuAction / isAdminUser)。
|
||||||
|
|
||||||
|
### 增量 5 — 个人下拉修复与触屏可用性(维护者反馈)✅
|
||||||
|
|
||||||
|
- 问题 1(内容不对):身份信息行的 `email || '@username'` 回退会显示伪社交句柄,且已在管理后台时仍显示「管理后台」入口。修复:第二行仅在存在邮箱时显示邮箱、有显示名时显示 `@用户名`、否则不显示;名称行追加角色徽章(Root/Admin/User);「管理后台」入口在后台区域隐藏(`isAdminUser && !isAdminArea`)。
|
||||||
|
- 问题 2(手机点退出无反应):daisyUI CSS 下拉依赖焦点展开,触屏点击菜单项时按钮失焦、下拉先于 click 关闭导致点击落空。修复:主题与个人下拉改为**状态驱动**(`dropdown-open` class + `v-if` 遮罩点击关闭 + Escape 关闭 + 路由切换关闭),互斥打开;主页主题下拉同步修复。
|
||||||
|
- 验证:`pnpm build` 通过;桌面实测下拉内容(root + Root 徽章 + @admin,菜单项完整);移动端 390px 实测点「退出登录」成功跳转 /login 且 token 清除;随后已恢复 admin 会话与桌面视口。
|
||||||
|
- 补充(维护者反馈):个人下拉内的 `<hr>` 分割线被 daisyUI menu 的通用子元素样式选中(cursor: pointer + hover 背景,可点击),已为其 li 加 `pointer-events-none select-none`,实测恢复默认光标且不可交互。
|
||||||
|
|
||||||
|
### 增量 6 — 主按钮统一黑白配色(维护者反馈)✅
|
||||||
|
|
||||||
|
- 背景:daisyUI dark 主题的 primary 为紫色,普通主按钮(btn-primary)在深色模式下显示为紫底。
|
||||||
|
- 方案:`main.css` 覆写 `.btn-primary` 的 daisyUI 颜色变量(`--btn-color` / `--btn-fg`)——浅色主题黑底白字(#171717/#fff,hover 纯黑)、深色主题白底黑字(#fff/#171717,hover 浅灰)。仅影响 btn-primary;success/error/warning/outline/ghost 等特殊按钮与链接、开关、焦点环均保持原样。
|
||||||
|
- 验证:`pnpm build` 通过;浏览器实测深色(白底黑字 New Token)与浅色(黑底白字 Log In)两种主题,特殊按钮未受影响;已恢复维护者的 auto 主题偏好与 admin 会话。
|
||||||
|
|
||||||
|
### 增量 7 — Toast 重写:多实例堆叠(维护者反馈)✅
|
||||||
|
|
||||||
|
- 背景:原实现为串行队列(processQueue 一次展示一条),连续操作时提示互相阻塞。
|
||||||
|
- daisyUI 的 `toast` 组件本身只负责定位与堆叠(容器内多个 `alert` 自动纵向排列),队列/自动消失/动画需应用层实现——已按此重写:
|
||||||
|
- `composables/toast.ts`:模块级响应式 `toasts` 列表,`setToast(message, type?, duration?)` 推入带唯一 id 的条目并定时自动移除(默认 3s);`useToast()` 签名不变,13 个调用视图零改动;移除原 provide/inject 方案。
|
||||||
|
- `Toast.vue`:daisyUI `toast toast-top toast-end` 容器 + `TransitionGroup` 进出场动画(仅 transform/opacity,配合全局 reduced-motion 降级)、每条带关闭按钮(aria-label)、容器 `aria-live="polite"`。
|
||||||
|
- 验证:`pnpm build` 通过;浏览器实测连续触发两条 toast 同时堆叠展示、3s 后全部自动消失。
|
||||||
|
|
||||||
|
### 增量 8 — dev 镜像构建脚本(多平台)✅
|
||||||
|
|
||||||
|
- 新增 `deploy/docker/build-dev.sh` + `make image-dev`:
|
||||||
|
- 默认多平台 `linux/amd64,linux/arm64` 构建并推送(自动创建缺失的 buildx 容器构建器);`PUSH=0` 退化为本机架构构建并 `--load` 到本地。
|
||||||
|
- 标签:`dev`、`dev-<短SHA>`、`dev-<YYYYMMDD>`(`REGISTRY_IMAGE` / `PLATFORMS` / `BUILDER` / `DOCKERFILE` 均可用环境变量覆盖)。
|
||||||
|
- 推送时附带 registry inline cache(`--cache-from/--cache-to type=inline`)加速后续 dev 构建。
|
||||||
|
- 验证:`PUSH=0` 端到端实测通过(容器内前端构建 + Go 交叉编译 + upx 压缩 31.7MB→11.8MB + 本地加载);镜像冒烟测试 `/:200`、`/api/*:200`。
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px"><path fill="#8ce7f2" d="M44.475,24.803c0.774-2.528,0.53-5.23-0.722-7.583c-2.018-3.792-6.225-5.638-10.284-5.086 c-1.802-1.935-4.265-3.074-6.929-3.166c-4.294-0.149-7.996,2.572-9.547,6.363c-2.576,0.593-4.794,2.156-6.206,4.417 c-2.275,3.643-1.771,8.21,0.737,11.449c-0.774,2.528-0.53,5.23,0.722,7.583c2.018,3.792,6.225,5.638,10.284,5.086 c1.802,1.935,4.265,3.074,6.929,3.167c4.293,0.148,7.996-2.573,9.547-6.364c2.576-0.593,4.794-2.156,6.205-4.417 C47.486,32.608,46.982,28.042,44.475,24.803z"/><path fill="#18193f" d="M38.844,17.559l-7.523-4.343c-0.493-0.284-1.1-0.285-1.594-0.003l-10.245,5.855l0.021-4.018 l7.913-4.569c3.445-1.989,7.938-1.371,10.44,1.722c0.594,0.734,1.04,1.539,1.341,2.382c0.211,0.592,0.772,0.984,1.4,0.984 c1.037,0,1.772-1.03,1.421-2.006c-0.416-1.158-1.033-2.265-1.853-3.275c-2.488-3.065-6.393-4.357-10.151-3.807 c-1.987-2.124-4.699-3.373-7.63-3.473c-4.733-0.161-8.814,2.839-10.525,7.018c-2.842,0.654-5.289,2.378-6.847,4.873 c-3.318,5.313-1.284,12.41,4.142,15.543l7.523,4.343c0.493,0.284,1.1,0.285,1.594,0.003l10.245-5.855l-0.021,4.018l-7.902,4.563 c-3.448,1.991-7.945,1.378-10.451-1.715c-0.591-0.73-1.035-1.53-1.336-2.368c-0.212-0.591-0.772-0.982-1.4-0.982h0 c-1.039,0-1.774,1.033-1.421,2.01c0.326,0.901,0.774,1.771,1.344,2.589c2.43,3.487,6.613,5.039,10.645,4.465 c1.987,2.129,4.7,3.381,7.634,3.483c4.736,0.163,8.82-2.838,10.531-7.02c2.841-0.654,5.288-2.378,6.844-4.872 C46.303,27.788,44.269,20.691,38.844,17.559z M34,33.723c0,4.324-3.313,8.077-7.633,8.269c-1.837,0.082-3.585-0.463-5.024-1.496 c0.274-0.13,0.546-0.266,0.812-0.42l7.521-4.342c0.493-0.285,0.799-0.81,0.802-1.38l0.054-9.883c0.003-0.55-0.441-0.999-0.992-1 c-0.549-0.002-0.995,0.441-0.998,0.99l-0.011,2.172L18.498,32.37l-7.918-4.571c-3.745-2.163-5.339-6.908-3.345-10.745 c0.848-1.633,2.196-2.875,3.812-3.605C11.022,13.753,11,14.058,11,14.367v8.684c0,0.569,0.302,1.095,0.794,1.382l8.73,5.055 c0.475,0.275,1.082,0.113,1.358-0.361c0.277-0.476,0.114-1.085-0.362-1.361L14,23.42v-9.143c0-4.325,3.313-8.077,7.634-8.269 c1.835-0.081,3.582,0.462,5.02,1.494c-0.264,0.127-0.526,0.259-0.782,0.407l-7.548,4.357c-0.494,0.285-0.799,0.81-0.802,1.38 l-0.054,9.797c-0.003,0.55,0.441,0.999,0.992,1c0.549,0.002,0.995-0.441,0.998-0.99l0.011-2.087l4.552-2.603L34,24.58V33.723z M40.765,30.946c-0.848,1.633-2.195,2.875-3.812,3.604C36.978,34.248,37,33.944,37,33.636v-8.687c0-0.569-0.302-1.095-0.794-1.382 l-10.191-5.943l3.487-1.994l7.918,4.571C41.165,22.364,42.759,27.109,40.765,30.946z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,38 @@
|
|||||||
|
# Air configuration for Go hot reload
|
||||||
|
# Docs: https://github.com/air-verse/air
|
||||||
|
|
||||||
|
root = "."
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
bin = "./tmp/main"
|
||||||
|
cmd = "go build -o ./tmp/main ./cmd/openteam"
|
||||||
|
delay = 1000
|
||||||
|
exclude_dir = ["tmp", "vendor", "node_modules", "frontend"]
|
||||||
|
exclude_file = []
|
||||||
|
exclude_regex = ["_test.go", ".*_test.go"]
|
||||||
|
exclude_unchanged = false
|
||||||
|
follow_symlink = false
|
||||||
|
include_dir = []
|
||||||
|
include_ext = ["go", "tpl", "tmpl", "html", "sql"]
|
||||||
|
kill_delay = "0s"
|
||||||
|
log = "build-errors.log"
|
||||||
|
send_interrupt = false
|
||||||
|
stop_on_error = true
|
||||||
|
|
||||||
|
[color]
|
||||||
|
build = "yellow"
|
||||||
|
main = "magenta"
|
||||||
|
runner = "green"
|
||||||
|
watcher = "cyan"
|
||||||
|
|
||||||
|
[log]
|
||||||
|
main_only = false
|
||||||
|
time = false
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = false
|
||||||
|
|
||||||
|
[screen]
|
||||||
|
clear_on_rebuild = false
|
||||||
|
keep_scroll = true
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"opencatd-open/internal/cli"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
"opencatd-open/pkg/config"
|
||||||
|
"opencatd-open/router"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:dist
|
||||||
|
var web embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := store.InitDB(cfg)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
_ = db
|
||||||
|
|
||||||
|
rootCmd := &cobra.Command{
|
||||||
|
Use: "openteam",
|
||||||
|
Short: "openteam cli",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
router.SetRouter(cfg, db, &web)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rootCmd.AddCommand(cli.LoadCmd)
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printFilesAndDirs(fsys fs.FS, prefix string) error {
|
||||||
|
return fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
fmt.Printf("%s[DIR] %s\n", prefix, p)
|
||||||
|
} else {
|
||||||
|
info, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("%s[FILE] %s (%d bytes)\n", prefix, p, info.Size())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
module opencatd-open
|
||||||
|
|
||||||
|
go 1.26
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-contrib/cors v1.7.2
|
||||||
|
github.com/gin-gonic/gin v1.10.0
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.4.1
|
||||||
|
github.com/go-webauthn/webauthn v0.12.3
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/google/wire v0.6.0
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/lib/pq v1.10.9
|
||||||
|
github.com/ncruces/go-sqlite3/gormlite v0.34.0
|
||||||
|
github.com/pkoukk/tiktoken-go v0.1.7
|
||||||
|
github.com/sashabaranov/go-openai v1.42.0
|
||||||
|
github.com/spf13/cobra v1.9.1
|
||||||
|
golang.org/x/crypto v0.50.0
|
||||||
|
golang.org/x/time v0.10.0
|
||||||
|
gorm.io/driver/mysql v1.5.7
|
||||||
|
gorm.io/driver/postgres v1.5.11
|
||||||
|
gorm.io/gorm v1.31.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
github.com/bytedance/sonic v1.13.2 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||||
|
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||||
|
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||||
|
github.com/go-webauthn/x v0.1.20 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
|
github.com/google/go-tpm v0.9.3 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
|
github.com/kr/text v0.2.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/ncruces/go-sqlite3 v0.34.0 // indirect
|
||||||
|
github.com/ncruces/go-sqlite3-wasm/v2 v2.1.35300 // indirect
|
||||||
|
github.com/ncruces/julianday v1.0.0 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||||
|
github.com/redis/go-redis/v9 v9.22.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
|
golang.org/x/arch v0.16.0 // indirect
|
||||||
|
golang.org/x/net v0.52.0 // indirect
|
||||||
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
|
golang.org/x/text v0.36.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.6 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
+230
@@ -0,0 +1,230 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ=
|
||||||
|
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||||
|
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
||||||
|
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
|
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||||
|
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||||
|
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||||
|
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
|
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
||||||
|
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
|
||||||
|
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||||
|
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||||
|
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
|
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.4.1 h1:AQ3X8zHnXEuNE04pyc1H/nmIlroNjgZ7hcY7Xv/IgH8=
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.4.1/go.mod h1:4ZtPNefSnNq39wjL+2We8y2ysqEX/S4D5mPybufHd7Y=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
||||||
|
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||||
|
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
|
github.com/go-webauthn/webauthn v0.12.3 h1:hHQl1xkUuabUU9uS+ISNCMLs9z50p9mDUZI/FmkayNE=
|
||||||
|
github.com/go-webauthn/webauthn v0.12.3/go.mod h1:4JRe8Z3W7HIw8NGEWn2fnUwecoDzkkeach/NnvhkqGY=
|
||||||
|
github.com/go-webauthn/x v0.1.20 h1:brEBDqfiPtNNCdS/peu8gARtq8fIPsHz0VzpPjGvgiw=
|
||||||
|
github.com/go-webauthn/x v0.1.20/go.mod h1:n/gAc8ssZJGATM0qThE+W+vfgXiMedsWi3wf/C4lld0=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
|
||||||
|
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
|
||||||
|
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
||||||
|
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
|
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/ncruces/go-sqlite3 v0.34.0 h1:q2I6wHTLWIoz6ehYkKdG5dGQc66eJv7ZGnekhvuMfK8=
|
||||||
|
github.com/ncruces/go-sqlite3 v0.34.0/go.mod h1:qpBxsSdGPnO9K5OExuv5GEsrGQ7Rk6JsJFH6wn2DwwU=
|
||||||
|
github.com/ncruces/go-sqlite3-wasm/v2 v2.1.35300 h1:cRdxCt3BDfMu0vfSdoqaAPD+dzIXPkGREjqyZMLN2Ak=
|
||||||
|
github.com/ncruces/go-sqlite3-wasm/v2 v2.1.35300/go.mod h1:R2kJLPoSA/GBX/b8x7zwOq/KLAw6rLMY1l3Hi76SQIo=
|
||||||
|
github.com/ncruces/go-sqlite3/gormlite v0.34.0 h1:QLlOy/i7OabsFUQ+d5KyXmq2hw9sMh/CRW435+eQMRY=
|
||||||
|
github.com/ncruces/go-sqlite3/gormlite v0.34.0/go.mod h1:CMv+6YhqLmPBXYACiQtrWA0q/JLIMTKB4E65SUfLgF0=
|
||||||
|
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
|
||||||
|
github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||||
|
github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQDmw=
|
||||||
|
github.com/pkoukk/tiktoken-go v0.1.7/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
|
||||||
|
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
|
||||||
|
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||||
|
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/sashabaranov/go-openai v1.42.0 h1:fgeZx7/D8dRT//PwXAGe9ylOMtj6vrs999uWF71K+f8=
|
||||||
|
github.com/sashabaranov/go-openai v1.42.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
||||||
|
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||||
|
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||||
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
|
golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U=
|
||||||
|
golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
|
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||||
|
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||||
|
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
|
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||||
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||||
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||||
|
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
|
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||||
|
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
|
||||||
|
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
|
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
|
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||||
|
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||||
|
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
||||||
|
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||||
|
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||||
|
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||||
|
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"opencatd-open/internal/pkg/crypto"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminChannels GET /api/admin/channels — 渠道列表(不返回加密 key,返回掩码)。
|
||||||
|
func (h *Handler) AdminChannels(c *gin.Context) {
|
||||||
|
var chs []store.Channel
|
||||||
|
if err := h.db.Order("id ASC").Find(&chs).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load channels"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]gin.H, 0, len(chs))
|
||||||
|
for _, ch := range chs {
|
||||||
|
masked := ""
|
||||||
|
if key, err := crypto.Decrypt(ch.APIKeyEnc); err == nil && len(key) > 8 {
|
||||||
|
masked = maskAPIKey(key)
|
||||||
|
} else if err == nil {
|
||||||
|
masked = "****"
|
||||||
|
}
|
||||||
|
out = append(out, gin.H{
|
||||||
|
"id": ch.ID, "name": ch.Name, "provider": ch.Provider, "formats": ch.FormatsEffective(),
|
||||||
|
"base_url": ch.BaseURL, "base_urls": ch.BaseURLs,
|
||||||
|
"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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
type channelBody struct {
|
||||||
|
Name string `json:"name" binding:"required,min=1,max=64"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
Formats []string `json:"formats"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
BaseURLs map[string]string `json:"base_urls"`
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeBaseURLs 校验并清理分协议 base_url。
|
||||||
|
func normalizeBaseURLs(m map[string]string) map[string]string {
|
||||||
|
if len(m) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := map[string]string{}
|
||||||
|
for k, v := range m {
|
||||||
|
if validFormats[k] && strings.TrimSpace(v) != "" {
|
||||||
|
out[k] = strings.TrimRight(strings.TrimSpace(v), "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveBaseURL 渠道 base_url:留空按供应商默认;网关按内容智能识别前缀/完整端点。
|
||||||
|
func resolveBaseURL(provider, raw string) (string, error) {
|
||||||
|
base := strings.TrimRight(raw, "/")
|
||||||
|
if base == "" {
|
||||||
|
switch provider {
|
||||||
|
case store.ChannelProviderOpenAI:
|
||||||
|
base = "https://api.openai.com"
|
||||||
|
case store.ChannelProviderAnthropic:
|
||||||
|
base = "https://api.anthropic.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if base == "" {
|
||||||
|
return "", errors.New("base_url required for compatible channels")
|
||||||
|
}
|
||||||
|
return base, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateProvider(p string) bool {
|
||||||
|
return p == store.ChannelProviderOpenAI || p == store.ChannelProviderAnthropic || p == store.ChannelProviderCompatible
|
||||||
|
}
|
||||||
|
|
||||||
|
var validFormats = map[string]bool{
|
||||||
|
store.FormatChat: true, store.FormatResponses: true, store.FormatMessages: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// deriveProvider 按格式推断供应商(仅作内部字段/兼容用途,不参与路由)。
|
||||||
|
func deriveProvider(formats []string) string {
|
||||||
|
if len(formats) == 0 {
|
||||||
|
return store.ChannelProviderCompatible
|
||||||
|
}
|
||||||
|
messagesOnly, hasResponses := true, false
|
||||||
|
for _, f := range formats {
|
||||||
|
if f != store.FormatMessages {
|
||||||
|
messagesOnly = false
|
||||||
|
}
|
||||||
|
if f == store.FormatResponses {
|
||||||
|
hasResponses = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if messagesOnly {
|
||||||
|
return store.ChannelProviderAnthropic
|
||||||
|
}
|
||||||
|
if hasResponses {
|
||||||
|
return store.ChannelProviderOpenAI
|
||||||
|
}
|
||||||
|
return store.ChannelProviderCompatible
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveFormats 渠道协议格式:显式给出则校验去重;空则按 provider 推断默认。
|
||||||
|
func resolveFormats(provider string, formats []string) ([]string, error) {
|
||||||
|
if len(formats) == 0 {
|
||||||
|
switch provider {
|
||||||
|
case store.ChannelProviderAnthropic:
|
||||||
|
return []string{store.FormatMessages}, nil
|
||||||
|
case store.ChannelProviderOpenAI:
|
||||||
|
return []string{store.FormatChat, store.FormatResponses}, nil
|
||||||
|
default:
|
||||||
|
return []string{store.FormatChat}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := make([]string, 0, len(formats))
|
||||||
|
for _, f := range formats {
|
||||||
|
if !validFormats[f] {
|
||||||
|
return nil, fmt.Errorf("unsupported format %q", f)
|
||||||
|
}
|
||||||
|
if !seen[f] {
|
||||||
|
seen[f] = true
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreateChannel POST /api/admin/channels
|
||||||
|
func (h *Handler) AdminCreateChannel(c *gin.Context) {
|
||||||
|
var req channelBody
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Provider == "" {
|
||||||
|
req.Provider = deriveProvider(req.Formats)
|
||||||
|
}
|
||||||
|
if !validateProvider(req.Provider) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "provider must be openai, anthropic or compatible"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.APIKey == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "api_key required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
formats, err := resolveFormats(req.Provider, req.Formats)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
baseURL, err := resolveBaseURL(req.Provider, req.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enc, err := crypto.Encrypt(req.APIKey)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt api key"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch := store.Channel{
|
||||||
|
Name: req.Name, Provider: req.Provider, Formats: formats, BaseURL: baseURL,
|
||||||
|
BaseURLs: normalizeBaseURLs(req.BaseURLs),
|
||||||
|
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.db.Create(&ch).Error; err != nil {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "failed to create channel (name may already exist)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"id": ch.ID, "name": ch.Name})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateChannel PUT /api/admin/channels/:id
|
||||||
|
func (h *Handler) AdminUpdateChannel(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Provider *string `json:"provider"`
|
||||||
|
Formats *[]string `json:"formats"`
|
||||||
|
BaseURL *string `json:"base_url"`
|
||||||
|
BaseURLs *map[string]string `json:"base_urls"`
|
||||||
|
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 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var ch store.Channel
|
||||||
|
if err := h.db.First(&ch, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates := map[string]any{}
|
||||||
|
if body.Name != nil {
|
||||||
|
updates["name"] = *body.Name
|
||||||
|
}
|
||||||
|
if body.Provider != nil {
|
||||||
|
if !validateProvider(*body.Provider) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "provider must be openai, anthropic or compatible"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["provider"] = *body.Provider
|
||||||
|
}
|
||||||
|
if body.BaseURL != nil {
|
||||||
|
prov := ch.Provider
|
||||||
|
if body.Provider != nil {
|
||||||
|
prov = *body.Provider
|
||||||
|
}
|
||||||
|
b, berr := resolveBaseURL(prov, *body.BaseURL)
|
||||||
|
if berr != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": berr.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["base_url"] = b
|
||||||
|
}
|
||||||
|
if body.BaseURLs != nil {
|
||||||
|
raw, _ := json.Marshal(normalizeBaseURLs(*body.BaseURLs))
|
||||||
|
updates["base_urls"] = string(raw)
|
||||||
|
}
|
||||||
|
if body.APIKey != nil && *body.APIKey != "" {
|
||||||
|
enc, err := crypto.Encrypt(*body.APIKey)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "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 body.Formats != nil {
|
||||||
|
prov := ch.Provider
|
||||||
|
if body.Provider != nil {
|
||||||
|
prov = *body.Provider
|
||||||
|
}
|
||||||
|
formats, ferr := resolveFormats(prov, *body.Formats)
|
||||||
|
if ferr != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": ferr.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(formats)
|
||||||
|
updates["formats"] = string(raw)
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
if err := h.db.Model(&ch).Updates(updates).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDeleteChannel DELETE /api/admin/channels/:id
|
||||||
|
func (h *Handler) AdminDeleteChannel(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res := h.db.Delete(&store.Channel{}, id)
|
||||||
|
if res.Error != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.db.Where("channel_id = ?", id).Delete(&store.ChannelModelBinding{})
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminTestChannel POST /api/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 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var ch store.Channel
|
||||||
|
if err := h.db.First(&ch, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, err := crypto.Decrypt(ch.APIKeyEnc)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to decrypt channel key"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
url := ch.UpstreamURL("", "/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()
|
||||||
|
resp, 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 resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
status = store.ChannelHealthCooldown
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||||
|
msg = fmt.Sprintf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
h.db.Model(&store.Channel{}).Where("id = ?", ch.ID).Update("health_status", status)
|
||||||
|
if status != store.ChannelHealthHealthy {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": msg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency, "message": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminChannelRemoteModels GET /api/admin/channels/:id/models/remote — 拉取远端模型列表。
|
||||||
|
// 返回本渠道尚未允许的模型(新增候选),排除已绑定的模型。
|
||||||
|
func (h *Handler) AdminChannelRemoteModels(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var ch store.Channel
|
||||||
|
if err := h.db.First(&ch, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, err := crypto.Decrypt(ch.APIKeyEnc)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to decrypt channel key"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
url := ch.UpstreamURL("", "/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")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("http %d: %s", resp.StatusCode, string(body))})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 解析 OpenAI 格式的模型列表
|
||||||
|
var result struct {
|
||||||
|
Data []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse response: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 本渠道已允许的上游模型名:不作为新增候选
|
||||||
|
var boundNames []string
|
||||||
|
h.db.Model(&store.ChannelModelBinding{}).Where("channel_id = ?", id).Pluck("upstream_model", &boundNames)
|
||||||
|
boundSet := make(map[string]bool, len(boundNames))
|
||||||
|
for _, n := range boundNames {
|
||||||
|
boundSet[strings.TrimSpace(n)] = true
|
||||||
|
}
|
||||||
|
models := make([]string, 0, len(result.Data))
|
||||||
|
for _, m := range result.Data {
|
||||||
|
name := strings.TrimSpace(m.ID)
|
||||||
|
if name != "" && !boundSet[name] {
|
||||||
|
models = append(models, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": models})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminChannelModels GET /api/admin/channels/:id/models — 渠道绑定列表。
|
||||||
|
func (h *Handler) AdminChannelModels(c *gin.Context) {
|
||||||
|
channelID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var bindings []store.ChannelModelBinding
|
||||||
|
if err := h.db.Preload("Model").Where("channel_id = ?", channelID).Find(&bindings).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load bindings"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]gin.H, 0, len(bindings))
|
||||||
|
for _, b := range bindings {
|
||||||
|
out = append(out, gin.H{
|
||||||
|
"id": b.ID, "model_id": b.ModelID, "model_name": b.Model.Name,
|
||||||
|
"upstream_model": b.UpstreamModel, "weight": b.Weight,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminChannelAddModel POST /api/admin/channels/:id/models — 手工添加模型绑定。
|
||||||
|
// 无需渠道具备 /v1/models 接口:直接填上游模型名,可选自定义名称作为客户端调用名。
|
||||||
|
func (h *Handler) AdminChannelAddModel(c *gin.Context) {
|
||||||
|
channelID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
UpstreamModel string `json:"upstream_model" binding:"required"` // 渠道侧真实模型名
|
||||||
|
CustomName string `json:"custom_name"` // 客户端调用名,空=用上游名
|
||||||
|
Weight *int `json:"weight"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input: upstream_model required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
globalName := req.CustomName
|
||||||
|
if globalName == "" {
|
||||||
|
globalName = req.UpstreamModel
|
||||||
|
}
|
||||||
|
var ch store.Channel
|
||||||
|
if err := h.db.First(&ch, channelID).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 查找或创建全局模型
|
||||||
|
var m store.Model
|
||||||
|
if err := h.db.Where("name = ?", globalName).First(&m).Error; err != nil {
|
||||||
|
m = store.Model{Name: globalName, Enabled: true}
|
||||||
|
if err := h.db.Create(&m).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create model"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 查找已存在的绑定,如果存在则更新
|
||||||
|
var existing store.ChannelModelBinding
|
||||||
|
if err := h.db.Where("channel_id = ? AND model_id = ?", channelID, m.ID).First(&existing).Error; err == nil {
|
||||||
|
// 已存在,更新
|
||||||
|
existing.UpstreamModel = req.UpstreamModel
|
||||||
|
existing.Weight = intOr(req.Weight, 1)
|
||||||
|
if err := h.db.Save(&existing).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update binding"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"id": existing.ID, "model_id": m.ID, "model_name": m.Name, "upstream_model": existing.UpstreamModel, "weight": existing.Weight})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 不存在,创建新的
|
||||||
|
b := store.ChannelModelBinding{
|
||||||
|
ChannelID: channelID, ModelID: m.ID,
|
||||||
|
UpstreamModel: req.UpstreamModel, Weight: intOr(req.Weight, 1),
|
||||||
|
}
|
||||||
|
if err := h.db.Create(&b).Error; err != nil {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "binding may already exist"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"id": b.ID, "model_id": m.ID, "model_name": m.Name, "upstream_model": req.UpstreamModel, "weight": b.Weight})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminChannelUpdateModel PATCH /api/admin/channels/:id/models/:bid — 改映射名/权重。
|
||||||
|
func (h *Handler) AdminChannelUpdateModel(c *gin.Context) {
|
||||||
|
bid, err := strconv.ParseUint(c.Param("bid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid binding id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
UpstreamModel *string `json:"upstream_model"`
|
||||||
|
Weight *int `json:"weight"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var b store.ChannelModelBinding
|
||||||
|
if err := h.db.First(&b, bid).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "binding not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates := map[string]any{}
|
||||||
|
if req.UpstreamModel != nil {
|
||||||
|
updates["upstream_model"] = *req.UpstreamModel
|
||||||
|
}
|
||||||
|
if req.Weight != nil {
|
||||||
|
updates["weight"] = *req.Weight
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
if err := h.db.Model(&b).Updates(updates).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update binding"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminChannelDeleteModel DELETE /api/admin/channels/:id/models/:bid — 解除绑定。
|
||||||
|
func (h *Handler) AdminChannelDeleteModel(c *gin.Context) {
|
||||||
|
bid, err := strconv.ParseUint(c.Param("bid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid binding id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res := h.db.Delete(&store.ChannelModelBinding{}, bid)
|
||||||
|
if res.Error != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete binding"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "binding not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// maskAPIKey 掩码渠道密钥:保留前 7 位与后 4 位,中间固定 ****** 遮蔽。
|
||||||
|
func maskAPIKey(key string) string {
|
||||||
|
if len(key) <= 11 {
|
||||||
|
return strings.Repeat("*", len(key)-4) + key[len(key)-4:]
|
||||||
|
}
|
||||||
|
return key[:7] + "******" + key[len(key)-4:]
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminGetConfig GET /api/admin/config — 获取系统配置。
|
||||||
|
func (h *Handler) AdminGetConfig(c *gin.Context) {
|
||||||
|
configs := map[string]string{}
|
||||||
|
var rows []store.SystemConfig
|
||||||
|
h.db.Find(&rows)
|
||||||
|
for _, r := range rows {
|
||||||
|
configs[r.Key] = r.Value
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": configs})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateConfig PUT /api/admin/config — 更新系统配置。
|
||||||
|
func (h *Handler) AdminUpdateConfig(c *gin.Context) {
|
||||||
|
var req map[string]string
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for key, value := range req {
|
||||||
|
var sc store.SystemConfig
|
||||||
|
result := h.db.Where("key = ?", key).First(&sc)
|
||||||
|
if result.Error == nil {
|
||||||
|
sc.Value = value
|
||||||
|
h.db.Save(&sc)
|
||||||
|
} else {
|
||||||
|
sc = store.SystemConfig{Key: key, Value: value}
|
||||||
|
h.db.Create(&sc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGetRegistration GET /api/admin/config/registration — 获取注册配置。
|
||||||
|
func (h *Handler) AdminGetRegistration(c *gin.Context) {
|
||||||
|
var sc store.SystemConfig
|
||||||
|
enabled := "true"
|
||||||
|
if err := h.db.Where("key = ?", "registration_enabled").First(&sc).Error; err == nil {
|
||||||
|
enabled = sc.Value
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": gin.H{"enabled": enabled == "true"}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateRegistration PUT /api/admin/config/registration — 更新注册配置。
|
||||||
|
func (h *Handler) AdminUpdateRegistration(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value := "false"
|
||||||
|
if req.Enabled {
|
||||||
|
value = "true"
|
||||||
|
}
|
||||||
|
var sc store.SystemConfig
|
||||||
|
if err := h.db.Where("key = ?", "registration_enabled").First(&sc).Error; err == nil {
|
||||||
|
sc.Value = value
|
||||||
|
h.db.Save(&sc)
|
||||||
|
} else {
|
||||||
|
sc = store.SystemConfig{Key: "registration_enabled", Value: value}
|
||||||
|
h.db.Create(&sc)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGetPasswordLogin GET /api/admin/config/password-login — 获取密码登录配置。
|
||||||
|
func (h *Handler) AdminGetPasswordLogin(c *gin.Context) {
|
||||||
|
var sc store.SystemConfig
|
||||||
|
enabled := "true"
|
||||||
|
if err := h.db.Where("key = ?", "password_login_enabled").First(&sc).Error; err == nil {
|
||||||
|
enabled = sc.Value
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": gin.H{"enabled": enabled == "true"}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdatePasswordLogin PUT /api/admin/config/password-login — 更新密码登录配置。
|
||||||
|
func (h *Handler) AdminUpdatePasswordLogin(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
value := "false"
|
||||||
|
if req.Enabled {
|
||||||
|
value = "true"
|
||||||
|
}
|
||||||
|
var sc store.SystemConfig
|
||||||
|
if err := h.db.Where("key = ?", "password_login_enabled").First(&sc).Error; err == nil {
|
||||||
|
sc.Value = value
|
||||||
|
h.db.Save(&sc)
|
||||||
|
} else {
|
||||||
|
sc = store.SystemConfig{Key: "password_login_enabled", Value: value}
|
||||||
|
h.db.Create(&sc)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminModels GET /api/admin/models — 模型列表(含价格、渠道绑定、定价/禁止状态)。
|
||||||
|
func (h *Handler) AdminModels(c *gin.Context) {
|
||||||
|
var ms []store.Model
|
||||||
|
if err := h.db.Order("sort ASC, id ASC").Find(&ms).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load models"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
allow, deny := h.modelPolicyConfig()
|
||||||
|
|
||||||
|
out := make([]gin.H, 0, len(ms))
|
||||||
|
for _, m := range ms {
|
||||||
|
var bindings []store.ChannelModelBinding
|
||||||
|
h.db.Preload("Channel").Where("model_id = ?", m.ID).Find(&bindings)
|
||||||
|
chs := make([]gin.H, 0, len(bindings))
|
||||||
|
for _, b := range bindings {
|
||||||
|
if !b.Channel.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
chs = append(chs, gin.H{
|
||||||
|
"id": b.ID, "channel_id": b.ChannelID, "channel_name": b.Channel.Name,
|
||||||
|
"upstream_model": b.UpstreamModel, "weight": b.Weight,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
used := len(chs) > 0
|
||||||
|
needsPricing := used && m.InputPrice == 0 && m.OutputPrice == 0 && m.CacheReadPrice == 0
|
||||||
|
denied := containsStr(deny, m.Name) || (len(allow) > 0 && !containsStr(allow, m.Name))
|
||||||
|
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,
|
||||||
|
"used": used, "needs_pricing": needsPricing, "denied": denied,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var orphans []struct {
|
||||||
|
ChannelName string
|
||||||
|
UpstreamModel string
|
||||||
|
ModelID uint64
|
||||||
|
}
|
||||||
|
h.db.Raw(`SELECT c.name as channel_name, b.model_id, b.upstream_model
|
||||||
|
FROM channel_model_bindings b
|
||||||
|
LEFT JOIN models m ON m.id = b.model_id
|
||||||
|
LEFT JOIN channels c ON c.id = b.channel_id
|
||||||
|
WHERE m.id IS NULL`).Scan(&orphans)
|
||||||
|
missing := make([]gin.H, 0, len(orphans))
|
||||||
|
for _, o := range orphans {
|
||||||
|
missing = append(missing, gin.H{
|
||||||
|
"channel": o.ChannelName, "model_id": o.ModelID, "upstream_model": o.UpstreamModel,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
unpriced := 0
|
||||||
|
{
|
||||||
|
var usedBindings []struct {
|
||||||
|
ModelID uint64
|
||||||
|
}
|
||||||
|
h.db.Model(&store.ChannelModelBinding{}).Distinct("model_id").Scan(&usedBindings)
|
||||||
|
usedIDs := map[uint64]bool{}
|
||||||
|
for _, u := range usedBindings {
|
||||||
|
usedIDs[u.ModelID] = true
|
||||||
|
}
|
||||||
|
for _, m := range ms {
|
||||||
|
if usedIDs[m.ID] && m.InputPrice == 0 && m.OutputPrice == 0 && m.CacheReadPrice == 0 {
|
||||||
|
unpriced++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"data": out,
|
||||||
|
"summary": gin.H{
|
||||||
|
"total": len(ms),
|
||||||
|
"unpriced": unpriced,
|
||||||
|
"missing": missing,
|
||||||
|
"denied_count": len(deny),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelPolicyConfig 读取全局模型允许/禁止列表。
|
||||||
|
func (h *Handler) modelPolicyConfig() (allow, deny []string) {
|
||||||
|
var raw string
|
||||||
|
h.db.Model(&store.SystemConfig{}).Where("key = ?", "model_allowlist").Pluck("value", &raw)
|
||||||
|
_ = json.Unmarshal([]byte(raw), &allow)
|
||||||
|
raw = ""
|
||||||
|
h.db.Model(&store.SystemConfig{}).Where("key = ?", "model_denylist").Pluck("value", &raw)
|
||||||
|
_ = json.Unmarshal([]byte(raw), &deny)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsStr(list []string, s string) bool {
|
||||||
|
for _, v := range list {
|
||||||
|
if v == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreateModel POST /api/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"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m := store.Model{
|
||||||
|
Name: req.Name, DisplayName: req.DisplayName,
|
||||||
|
InputPrice: req.InputPrice, OutputPrice: req.OutputPrice, CacheReadPrice: req.CacheReadPrice,
|
||||||
|
Sort: req.Sort, Enabled: boolOr(req.Enabled, true),
|
||||||
|
}
|
||||||
|
if err := h.db.Create(&m).Error; err != nil {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "failed to create model (name may already exist)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"id": m.ID, "name": m.Name})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateModel PUT /api/admin/models/:id — 价格/启停/排序。
|
||||||
|
func (h *Handler) AdminUpdateModel(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "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 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var m store.Model
|
||||||
|
if err := h.db.First(&m, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "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.db.Model(&m).Updates(updates).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update model"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDeleteModel DELETE /api/admin/models/:id
|
||||||
|
func (h *Handler) AdminDeleteModel(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid model id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res := h.db.Delete(&store.Model{}, id)
|
||||||
|
if res.Error != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete model"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.db.Where("model_id = ?", id).Delete(&store.ChannelModelBinding{})
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDeleteUnusedModels DELETE /api/admin/models/unused — 一键清除未绑定任何渠道的模型。
|
||||||
|
func (h *Handler) AdminDeleteUnusedModels(c *gin.Context) {
|
||||||
|
var orphans []store.Model
|
||||||
|
if err := h.db.Where("id NOT IN (SELECT DISTINCT model_id FROM channel_model_bindings)").Find(&orphans).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load models"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(orphans))
|
||||||
|
ids := make([]uint64, 0, len(orphans))
|
||||||
|
for _, m := range orphans {
|
||||||
|
names = append(names, m.Name)
|
||||||
|
ids = append(ids, m.ID)
|
||||||
|
}
|
||||||
|
if len(ids) > 0 {
|
||||||
|
if err := h.db.Delete(&store.Model{}, ids).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete models"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"deleted": names, "count": len(names)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreateModelBinding POST /api/admin/models/:id/bindings
|
||||||
|
func (h *Handler) AdminCreateModelBinding(c *gin.Context) {
|
||||||
|
modelID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "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 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input: channel_id and upstream_model required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var m store.Model
|
||||||
|
if err := h.db.First(&m, modelID).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var ch store.Channel
|
||||||
|
if err := h.db.First(&ch, req.ChannelID).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b := store.ChannelModelBinding{
|
||||||
|
ChannelID: req.ChannelID, ModelID: modelID,
|
||||||
|
UpstreamModel: req.UpstreamModel, Weight: intOr(req.Weight, 1),
|
||||||
|
}
|
||||||
|
if err := h.db.Create(&b).Error; err != nil {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "binding may already exist"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"id": b.ID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDeleteModelBinding DELETE /api/admin/models/:id/bindings/:bid
|
||||||
|
func (h *Handler) AdminDeleteModelBinding(c *gin.Context) {
|
||||||
|
bid, err := strconv.ParseUint(c.Param("bid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid binding id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res := h.db.Delete(&store.ChannelModelBinding{}, bid)
|
||||||
|
if res.Error != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete binding"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "binding not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = gorm.ErrRecordNotFound
|
||||||
@@ -0,0 +1,886 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"opencatd-open/internal/dao"
|
||||||
|
"opencatd-open/internal/passkey"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
"opencatd-open/internal/pkg/apikey"
|
||||||
|
"opencatd-open/internal/pkg/crypto"
|
||||||
|
"opencatd-open/internal/pkg/jwt"
|
||||||
|
"opencatd-open/internal/auth"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
userDAO *dao.UserDAO
|
||||||
|
apiKeyDAO *dao.ApiKeyDAO
|
||||||
|
channelDAO *dao.ChannelDAO
|
||||||
|
modelDAO *dao.ModelDAO
|
||||||
|
usageDAO *dao.UsageDAO
|
||||||
|
dailyDAO *dao.DailyUsageDAO
|
||||||
|
passkeys *passkey.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(db *gorm.DB, passkeys *passkey.Service) *Handler {
|
||||||
|
return &Handler{
|
||||||
|
db: db,
|
||||||
|
userDAO: dao.NewUserDAO(db),
|
||||||
|
apiKeyDAO: dao.NewApiKeyDAO(db),
|
||||||
|
channelDAO: dao.NewChannelDAO(db),
|
||||||
|
modelDAO: dao.NewModelDAO(db),
|
||||||
|
usageDAO: dao.NewUsageDAO(db),
|
||||||
|
dailyDAO: dao.NewDailyUsageDAO(db),
|
||||||
|
passkeys: passkeys,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
func userToResponse(user *store.User) gin.H {
|
||||||
|
roleNum := 1
|
||||||
|
if user.Role == store.RoleAdmin {
|
||||||
|
roleNum = 10
|
||||||
|
}
|
||||||
|
return gin.H{
|
||||||
|
"id": user.ID,
|
||||||
|
"username": user.Username,
|
||||||
|
"email": user.Email,
|
||||||
|
"role": roleNum,
|
||||||
|
"status": user.Status,
|
||||||
|
"balance": user.Balance,
|
||||||
|
"created_at": user.CreatedAt,
|
||||||
|
"updated_at": user.UpdatedAt,
|
||||||
|
"last_login_at": user.LastLoginAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auth ---
|
||||||
|
|
||||||
|
func (h *Handler) Register(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
Email string `json:"email" binding:"omitempty,email"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if first user (becomes admin)
|
||||||
|
var count int64
|
||||||
|
h.db.Model(&store.User{}).Count(&count)
|
||||||
|
|
||||||
|
role := store.RoleUser
|
||||||
|
if count == 0 {
|
||||||
|
role = store.RoleAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := crypto.Sha256Hex(req.Password)
|
||||||
|
email := req.Email
|
||||||
|
if email == "" {
|
||||||
|
email = req.Username + "@placeholder.local"
|
||||||
|
}
|
||||||
|
user := &store.User{
|
||||||
|
Username: req.Username,
|
||||||
|
Email: email,
|
||||||
|
PasswordHash: hash,
|
||||||
|
Role: role,
|
||||||
|
Status: store.UserStatusActive,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.userDAO.Create(user); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "username or email already exists"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "registered"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Login(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.userDAO.GetByUsername(req.Username)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := crypto.Sha256Hex(req.Password)
|
||||||
|
if user.PasswordHash != hash {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
secret := auth.GetSecretKey()
|
||||||
|
accessToken, refreshToken, err := jwt.GenerateTokenPair(user.ID, user.Username, user.Role, secret, 24*time.Hour, 7*24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
now := time.Now()
|
||||||
|
user.LastLoginAt = &now
|
||||||
|
h.userDAO.Update(user)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": 200,
|
||||||
|
"data": gin.H{
|
||||||
|
"token": accessToken,
|
||||||
|
"access_token": accessToken,
|
||||||
|
"refresh_token": refreshToken,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Me(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
user, err := h.userDAO.GetByID(userID.(uint64))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": 200,
|
||||||
|
"data": userToResponse(user),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateProfile(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
user, err := h.userDAO.GetByID(userID.(uint64))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Email != "" {
|
||||||
|
user.Email = req.Email
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.userDAO.Update(user); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "profile updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdatePassword(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
user, err := h.userDAO.GetByID(userID.(uint64))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
NewPassword string `json:"newpassword" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify old password
|
||||||
|
oldHash := crypto.Sha256Hex(req.Password)
|
||||||
|
if user.PasswordHash != oldHash {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "incorrect password"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update to new password
|
||||||
|
user.PasswordHash = crypto.Sha256Hex(req.NewPassword)
|
||||||
|
if err := h.userDAO.Update(user); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "password updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Users ---
|
||||||
|
|
||||||
|
func (h *Handler) ListUsers(c *gin.Context) {
|
||||||
|
// Support both limit/offset and pageSize/page parameters
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||||
|
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||||
|
|
||||||
|
// If pageSize/page are provided, use them instead
|
||||||
|
if pageSize := c.Query("pageSize"); pageSize != "" {
|
||||||
|
if size, err := strconv.Atoi(pageSize); err == nil && size > 0 {
|
||||||
|
limit = size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if page := c.Query("page"); page != "" {
|
||||||
|
if p, err := strconv.Atoi(page); err == nil && p > 0 {
|
||||||
|
offset = (p - 1) * limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
users, total, err := h.userDAO.List(limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := make([]gin.H, len(users))
|
||||||
|
for i, u := range users {
|
||||||
|
data[i] = userToResponse(u)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": data, "total": total})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetUser(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := h.userDAO.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"data": userToResponse(user),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateUser(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
Email string `json:"email" binding:"required,email"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
role := store.RoleUser
|
||||||
|
if req.Role != "" {
|
||||||
|
role = req.Role
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := crypto.Sha256Hex(req.Password)
|
||||||
|
user := &store.User{
|
||||||
|
Username: req.Username,
|
||||||
|
Email: req.Email,
|
||||||
|
PasswordHash: hash,
|
||||||
|
Role: role,
|
||||||
|
Status: store.UserStatusActive,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.userDAO.Create(user); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "username or email already exists"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, userToResponse(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateUser(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.userDAO.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Username != "" {
|
||||||
|
user.Username = req.Username
|
||||||
|
}
|
||||||
|
if req.Email != "" {
|
||||||
|
user.Email = req.Email
|
||||||
|
}
|
||||||
|
if req.Password != "" {
|
||||||
|
user.PasswordHash = crypto.Sha256Hex(req.Password)
|
||||||
|
}
|
||||||
|
if req.Role != "" {
|
||||||
|
user.Role = req.Role
|
||||||
|
}
|
||||||
|
if req.Status != "" {
|
||||||
|
user.Status = req.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.userDAO.Update(user); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, userToResponse(user))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) BatchUsers(c *gin.Context) {
|
||||||
|
option := c.Param("option")
|
||||||
|
var req struct {
|
||||||
|
IDs []uint64 `json:"ids"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range req.IDs {
|
||||||
|
user, err := h.userDAO.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch option {
|
||||||
|
case "enable":
|
||||||
|
user.Status = store.UserStatusActive
|
||||||
|
case "disable":
|
||||||
|
user.Status = store.UserStatusDisabled
|
||||||
|
case "delete":
|
||||||
|
h.userDAO.Delete(id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h.userDAO.Update(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "batch operation completed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeleteUser(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.userDAO.Delete(id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API Keys ---
|
||||||
|
|
||||||
|
func (h *Handler) ListApiKeys(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
// Support both limit/offset and pageSize/page parameters
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||||
|
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||||
|
|
||||||
|
// If pageSize/page are provided, use them instead
|
||||||
|
if pageSize := c.Query("pageSize"); pageSize != "" {
|
||||||
|
if size, err := strconv.Atoi(pageSize); err == nil && size > 0 {
|
||||||
|
limit = size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if page := c.Query("page"); page != "" {
|
||||||
|
if p, err := strconv.Atoi(page); err == nil && p > 0 {
|
||||||
|
offset = (p - 1) * limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, total, err := h.apiKeyDAO.ListByUserID(userID.(uint64), limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": keys, "total": total})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetApiKey(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key, err := h.apiKeyDAO.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "key not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": key})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateApiKey(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day"`
|
||||||
|
QuotaRequestsPerDay *int `json:"quota_requests_per_day"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
keyValue, _ := apikey.Generate()
|
||||||
|
|
||||||
|
key := &store.APIKey{
|
||||||
|
UserID: userID.(uint64),
|
||||||
|
Name: req.Name,
|
||||||
|
KeyHash: apikey.Hash(keyValue),
|
||||||
|
KeyPlain: keyValue,
|
||||||
|
KeyPrefix: keyValue[:12],
|
||||||
|
QuotaTokensPerDay: req.QuotaTokensPerDay,
|
||||||
|
QuotaRequestsPerDay: req.QuotaRequestsPerDay,
|
||||||
|
Status: store.KeyStatusActive,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.apiKeyDAO.Create(key); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"key": keyValue,
|
||||||
|
"data": key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateApiKey(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := h.apiKeyDAO.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "key not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day"`
|
||||||
|
QuotaRequestsPerDay *int `json:"quota_requests_per_day"`
|
||||||
|
AllowedModels []string `json:"allowed_models"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name != "" {
|
||||||
|
key.Name = req.Name
|
||||||
|
}
|
||||||
|
if req.QuotaTokensPerDay != nil {
|
||||||
|
key.QuotaTokensPerDay = req.QuotaTokensPerDay
|
||||||
|
}
|
||||||
|
if req.QuotaRequestsPerDay != nil {
|
||||||
|
key.QuotaRequestsPerDay = req.QuotaRequestsPerDay
|
||||||
|
}
|
||||||
|
if req.AllowedModels != nil {
|
||||||
|
key.AllowedModels = req.AllowedModels
|
||||||
|
}
|
||||||
|
if req.Status != "" {
|
||||||
|
key.Status = req.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.apiKeyDAO.Update(key); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": key})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) BatchApiKeys(c *gin.Context) {
|
||||||
|
option := c.Param("option")
|
||||||
|
var req struct {
|
||||||
|
IDs []uint64 `json:"ids"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch option {
|
||||||
|
case "delete":
|
||||||
|
if err := h.apiKeyDAO.BatchDelete(req.IDs); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "batch operation completed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeleteApiKey(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.apiKeyDAO.Delete(id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Legacy Channel endpoints (kept for backward compatibility) ---
|
||||||
|
|
||||||
|
func (h *Handler) ListChannels(c *gin.Context) {
|
||||||
|
// Support both limit/offset and pageSize/page parameters
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||||
|
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||||
|
|
||||||
|
// If pageSize/page are provided, use them instead
|
||||||
|
if pageSize := c.Query("pageSize"); pageSize != "" {
|
||||||
|
if size, err := strconv.Atoi(pageSize); err == nil && size > 0 {
|
||||||
|
limit = size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if page := c.Query("page"); page != "" {
|
||||||
|
if p, err := strconv.Atoi(page); err == nil && p > 0 {
|
||||||
|
offset = (p - 1) * limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
channels, total, err := h.channelDAO.List(limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": channels, "total": total})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateChannel(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Provider string `json:"provider" binding:"required"`
|
||||||
|
BaseURL string `json:"base_url" binding:"required"`
|
||||||
|
APIKey string `json:"api_key" binding:"required"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Weight int `json:"weight"`
|
||||||
|
Formats []string `json:"formats"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted, err := crypto.Encrypt(req.APIKey)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Weight == 0 {
|
||||||
|
req.Weight = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := &store.Channel{
|
||||||
|
Name: req.Name,
|
||||||
|
Provider: req.Provider,
|
||||||
|
BaseURL: req.BaseURL,
|
||||||
|
APIKeyEnc: encrypted,
|
||||||
|
Weight: req.Weight,
|
||||||
|
Priority: req.Priority,
|
||||||
|
Formats: req.Formats,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.channelDAO.Create(ch); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "channel name already exists"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateChannel(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ch, err := h.channelDAO.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
Priority *int `json:"priority"`
|
||||||
|
Weight *int `json:"weight"`
|
||||||
|
Formats []string `json:"formats"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name != "" {
|
||||||
|
ch.Name = req.Name
|
||||||
|
}
|
||||||
|
if req.BaseURL != "" {
|
||||||
|
ch.BaseURL = req.BaseURL
|
||||||
|
}
|
||||||
|
if req.APIKey != "" {
|
||||||
|
encrypted, err := crypto.Encrypt(req.APIKey)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch.APIKeyEnc = encrypted
|
||||||
|
}
|
||||||
|
if req.Priority != nil {
|
||||||
|
ch.Priority = *req.Priority
|
||||||
|
}
|
||||||
|
if req.Weight != nil {
|
||||||
|
ch.Weight = *req.Weight
|
||||||
|
}
|
||||||
|
if req.Formats != nil {
|
||||||
|
ch.Formats = req.Formats
|
||||||
|
}
|
||||||
|
if req.Enabled != nil {
|
||||||
|
ch.Enabled = *req.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.channelDAO.Update(ch); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeleteChannel(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.channelDAO.Delete(id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Legacy Model endpoints (kept for backward compatibility) ---
|
||||||
|
|
||||||
|
func (h *Handler) ListModels(c *gin.Context) {
|
||||||
|
// Support both limit/offset and pageSize/page parameters
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||||
|
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||||
|
|
||||||
|
// If pageSize/page are provided, use them instead
|
||||||
|
if pageSize := c.Query("pageSize"); pageSize != "" {
|
||||||
|
if size, err := strconv.Atoi(pageSize); err == nil && size > 0 {
|
||||||
|
limit = size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if page := c.Query("page"); page != "" {
|
||||||
|
if p, err := strconv.Atoi(page); err == nil && p > 0 {
|
||||||
|
offset = (p - 1) * limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
models, total, err := h.modelDAO.List(limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": models, "total": total})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateModel(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
InputPrice float64 `json:"input_price"`
|
||||||
|
OutputPrice float64 `json:"output_price"`
|
||||||
|
CacheReadPrice float64 `json:"cache_read_price"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m := &store.Model{
|
||||||
|
Name: req.Name,
|
||||||
|
DisplayName: req.DisplayName,
|
||||||
|
InputPrice: req.InputPrice,
|
||||||
|
OutputPrice: req.OutputPrice,
|
||||||
|
CacheReadPrice: req.CacheReadPrice,
|
||||||
|
Sort: req.Sort,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.modelDAO.Create(m); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "model name already exists"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdateModel(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := h.modelDAO.GetByID(id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
|
||||||
|
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"`
|
||||||
|
Sort *int `json:"sort"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DisplayName != "" {
|
||||||
|
m.DisplayName = req.DisplayName
|
||||||
|
}
|
||||||
|
if req.InputPrice != nil {
|
||||||
|
m.InputPrice = *req.InputPrice
|
||||||
|
}
|
||||||
|
if req.OutputPrice != nil {
|
||||||
|
m.OutputPrice = *req.OutputPrice
|
||||||
|
}
|
||||||
|
if req.CacheReadPrice != nil {
|
||||||
|
m.CacheReadPrice = *req.CacheReadPrice
|
||||||
|
}
|
||||||
|
if req.Sort != nil {
|
||||||
|
m.Sort = *req.Sort
|
||||||
|
}
|
||||||
|
if req.Enabled != nil {
|
||||||
|
m.Enabled = *req.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.modelDAO.Update(m); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeleteModel(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.modelDAO.Delete(id); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Legacy Channel-Model Bindings (kept for backward compatibility) ---
|
||||||
|
|
||||||
|
func (h *Handler) BindChannelModels(c *gin.Context) {
|
||||||
|
channelID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Bindings []struct {
|
||||||
|
ModelID uint64 `json:"model_id"`
|
||||||
|
UpstreamModel string `json:"upstream_model"`
|
||||||
|
Weight int `json:"weight"`
|
||||||
|
} `json:"bindings"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bindings := make([]store.ChannelModelBinding, len(req.Bindings))
|
||||||
|
for i, b := range req.Bindings {
|
||||||
|
bindings[i] = store.ChannelModelBinding{
|
||||||
|
ChannelID: channelID,
|
||||||
|
ModelID: b.ModelID,
|
||||||
|
UpstreamModel: b.UpstreamModel,
|
||||||
|
Weight: b.Weight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.channelDAO.BindModels(channelID, bindings); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "bound"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetChannelModels(c *gin.Context) {
|
||||||
|
channelID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bindings, err := h.channelDAO.GetChannelModels(channelID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": bindings})
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"opencatd-open/internal/auth"
|
||||||
|
"opencatd-open/internal/pkg/jwt"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PasskeyRegisterBegin POST /api/webauthn/register/begin — 生成注册选项。
|
||||||
|
func (h *Handler) PasskeyRegisterBegin(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
u, err := h.passkeys.GetUserByID(userID.(uint64))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
creation, err := h.passkeys.BeginRegistration(u)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to begin registration: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": gin.H{"creation": creation, "challenge": creation.Response.Challenge}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyRegisterComplete POST /api/webauthn/register/complete — 校验并保存凭据。
|
||||||
|
func (h *Handler) PasskeyRegisterComplete(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
u, err := h.passkeys.GetUserByID(userID.(uint64))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Challenge string `json:"challenge"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Credential json.RawMessage `json:"credential"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || len(req.Credential) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.passkeys.FinishRegistration(u, req.Challenge, req.Credential, req.Name); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "passkey 注册失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyLoginBegin POST /api/auth/passkey/begin — 生成断言选项。
|
||||||
|
// 传 username 用指定用户;不传则用可发现凭据(平台 passkey)。
|
||||||
|
func (h *Handler) PasskeyLoginBegin(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
_ = c.ShouldBindJSON(&req)
|
||||||
|
if req.Username != "" {
|
||||||
|
u, err := h.passkeys.GetUserByUsername(req.Username)
|
||||||
|
if err != nil || u.Status != store.UserStatusActive {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assertion, err := h.passkeys.BeginLogin(u)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to begin login: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": gin.H{"assertion": assertion, "challenge": assertion.Response.Challenge, "user_id": u.ID}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assertion, err := h.passkeys.BeginDiscoverableLogin()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to begin login: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": gin.H{"assertion": assertion, "challenge": assertion.Response.Challenge}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyLoginComplete POST /api/auth/passkey/finish — 校验断言并发放令牌。
|
||||||
|
func (h *Handler) PasskeyLoginComplete(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Challenge string `json:"challenge"`
|
||||||
|
Credential json.RawMessage `json:"credential"`
|
||||||
|
UserID uint64 `json:"user_id"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || len(req.Credential) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var u *store.User
|
||||||
|
if req.UserID > 0 {
|
||||||
|
var err error
|
||||||
|
u, err = h.passkeys.GetUserByID(req.UserID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.passkeys.FinishLogin(u, req.Challenge, req.Credential); err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "passkey 校验失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
u, err = h.passkeys.FinishDiscoverableLogin(req.Challenge, req.Credential)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "passkey 校验失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if u.Status != store.UserStatusActive {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "user account disabled"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
secret := auth.GetSecretKey()
|
||||||
|
accessToken, refreshToken, err := jwt.GenerateTokenPair(u.ID, u.Username, u.Role, secret, 24*time.Hour, 7*24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to issue token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"data": gin.H{
|
||||||
|
"token": accessToken,
|
||||||
|
"access_token": accessToken,
|
||||||
|
"refresh_token": refreshToken,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyList GET /api/profile/passkeys — 当前用户的 passkey 列表。
|
||||||
|
func (h *Handler) PasskeyList(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
pks, err := h.passkeys.List(userID.(uint64))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load passkeys"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]gin.H, 0, len(pks))
|
||||||
|
for _, pk := range pks {
|
||||||
|
out = append(out, gin.H{"id": pk.ID, "name": pk.Name, "created_at": pk.CreatedAt})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasskeyDelete DELETE /api/profile/passkeys/:id — 解除绑定。
|
||||||
|
func (h *Handler) PasskeyDelete(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid passkey id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.passkeys.Delete(userID.(uint64), id); err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "passkey not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"opencatd-open/internal/dao"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- 普通用户:自身用量统计与明细 ---
|
||||||
|
|
||||||
|
// MyUsageStats GET /api/usage/stats?days=30 — 当前用户的每日用量聚合。
|
||||||
|
func (h *Handler) MyUsageStats(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
uid, _ := userID.(uint64)
|
||||||
|
|
||||||
|
days := 30
|
||||||
|
if d := c.Query("days"); d != "" {
|
||||||
|
if n, err := strconv.Atoi(d); err == nil && n > 0 && n <= 365 {
|
||||||
|
days = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end := time.Now()
|
||||||
|
start := end.AddDate(0, 0, -days)
|
||||||
|
|
||||||
|
dailies, err := h.dailyDAO.ListByDateRange(c.Request.Context(), uid, start, end)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load usage"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按日期聚合(每日可能多模型多行)
|
||||||
|
byDate := map[string]*store.UsageDaily{}
|
||||||
|
var dates []string
|
||||||
|
for i := range dailies {
|
||||||
|
d := dailies[i]
|
||||||
|
agg, ok := byDate[d.Date]
|
||||||
|
if !ok {
|
||||||
|
agg = &store.UsageDaily{Date: d.Date}
|
||||||
|
byDate[d.Date] = agg
|
||||||
|
dates = append(dates, d.Date)
|
||||||
|
}
|
||||||
|
agg.Requests += d.Requests
|
||||||
|
agg.InputTokens += d.InputTokens
|
||||||
|
agg.OutputTokens += d.OutputTokens
|
||||||
|
agg.CacheReadTokens += d.CacheReadTokens
|
||||||
|
agg.Cost += d.Cost
|
||||||
|
}
|
||||||
|
|
||||||
|
// 汇总
|
||||||
|
var totalRequests, totalInput, totalOutput, totalCache int64
|
||||||
|
var totalCost float64
|
||||||
|
for _, d := range byDate {
|
||||||
|
totalRequests += d.Requests
|
||||||
|
totalInput += d.InputTokens
|
||||||
|
totalOutput += d.OutputTokens
|
||||||
|
totalCache += d.CacheReadTokens
|
||||||
|
totalCost += d.Cost
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"data": gin.H{
|
||||||
|
"dates": dates,
|
||||||
|
"daily": byDate,
|
||||||
|
"totals": gin.H{
|
||||||
|
"requests": totalRequests,
|
||||||
|
"input_tokens": totalInput,
|
||||||
|
"output_tokens": totalOutput,
|
||||||
|
"cache_read_tokens": totalCache,
|
||||||
|
"cost": totalCost,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MyUsageMonthly GET /api/usage/monthly?year=2026 — 当前用户年度按自然月聚合,
|
||||||
|
// 每月含按模型分解(供月度堆叠柱状图使用)。
|
||||||
|
func (h *Handler) MyUsageMonthly(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
uid, _ := userID.(uint64)
|
||||||
|
|
||||||
|
year := time.Now().Year()
|
||||||
|
if y := c.Query("year"); y != "" {
|
||||||
|
if n, err := strconv.Atoi(y); err == nil && n >= 2000 && n <= 2100 {
|
||||||
|
year = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start := time.Date(year, 1, 1, 0, 0, 0, 0, time.Local)
|
||||||
|
end := start.AddDate(1, 0, -1)
|
||||||
|
|
||||||
|
dailies, err := h.dailyDAO.ListByDateRange(c.Request.Context(), uid, start, end)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load usage"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 补齐模型名(模型可能已被删除,回退为 模型#id)
|
||||||
|
modelIDs := make([]uint64, 0, len(dailies))
|
||||||
|
seen := map[uint64]bool{}
|
||||||
|
for _, d := range dailies {
|
||||||
|
if !seen[d.ModelID] {
|
||||||
|
seen[d.ModelID] = true
|
||||||
|
modelIDs = append(modelIDs, d.ModelID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
modelNames := map[uint64]string{}
|
||||||
|
if len(modelIDs) > 0 {
|
||||||
|
var models []store.Model
|
||||||
|
if err := h.db.Where("id IN ?", modelIDs).Find(&models).Error; err == nil {
|
||||||
|
for _, m := range models {
|
||||||
|
modelNames[m.ID] = m.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type modelAgg struct {
|
||||||
|
ModelID uint64 `json:"model_id"`
|
||||||
|
ModelName string `json:"model_name"`
|
||||||
|
Requests int64 `json:"requests"`
|
||||||
|
InputTokens int64 `json:"input_tokens"`
|
||||||
|
OutputTokens int64 `json:"output_tokens"`
|
||||||
|
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||||
|
Cost float64 `json:"cost"`
|
||||||
|
}
|
||||||
|
type monthAgg struct {
|
||||||
|
Month string `json:"month"`
|
||||||
|
Requests int64 `json:"requests"`
|
||||||
|
InputTokens int64 `json:"input_tokens"`
|
||||||
|
OutputTokens int64 `json:"output_tokens"`
|
||||||
|
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||||
|
Cost float64 `json:"cost"`
|
||||||
|
Models map[uint64]*modelAgg `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
months := make([]*monthAgg, 12)
|
||||||
|
for i := range months {
|
||||||
|
months[i] = &monthAgg{
|
||||||
|
Month: fmt.Sprintf("%d-%02d", year, i+1),
|
||||||
|
Models: map[uint64]*modelAgg{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, d := range dailies {
|
||||||
|
mm, err := strconv.Atoi(d.Date[5:7])
|
||||||
|
if err != nil || mm < 1 || mm > 12 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := months[mm-1]
|
||||||
|
m.Requests += d.Requests
|
||||||
|
m.InputTokens += d.InputTokens
|
||||||
|
m.OutputTokens += d.OutputTokens
|
||||||
|
m.CacheReadTokens += d.CacheReadTokens
|
||||||
|
m.Cost += d.Cost
|
||||||
|
ma, ok := m.Models[d.ModelID]
|
||||||
|
if !ok {
|
||||||
|
name := modelNames[d.ModelID]
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("模型#%d", d.ModelID)
|
||||||
|
}
|
||||||
|
ma = &modelAgg{ModelID: d.ModelID, ModelName: name}
|
||||||
|
m.Models[d.ModelID] = ma
|
||||||
|
}
|
||||||
|
ma.Requests += d.Requests
|
||||||
|
ma.InputTokens += d.InputTokens
|
||||||
|
ma.OutputTokens += d.OutputTokens
|
||||||
|
ma.CacheReadTokens += d.CacheReadTokens
|
||||||
|
ma.Cost += d.Cost
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]gin.H, 12)
|
||||||
|
for i, m := range months {
|
||||||
|
modelList := make([]*modelAgg, 0, len(m.Models))
|
||||||
|
for _, ma := range m.Models {
|
||||||
|
modelList = append(modelList, ma)
|
||||||
|
}
|
||||||
|
// 模型按 token 总量降序,柱状图图例顺序与之一致
|
||||||
|
sort.Slice(modelList, func(a, b int) bool {
|
||||||
|
ta := modelList[a].InputTokens + modelList[a].OutputTokens + modelList[a].CacheReadTokens
|
||||||
|
tb := modelList[b].InputTokens + modelList[b].OutputTokens + modelList[b].CacheReadTokens
|
||||||
|
return ta > tb
|
||||||
|
})
|
||||||
|
out[i] = gin.H{
|
||||||
|
"month": m.Month,
|
||||||
|
"requests": m.Requests,
|
||||||
|
"input_tokens": m.InputTokens,
|
||||||
|
"output_tokens": m.OutputTokens,
|
||||||
|
"cache_read_tokens": m.CacheReadTokens,
|
||||||
|
"cost": m.Cost,
|
||||||
|
"models": modelList,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"data": gin.H{
|
||||||
|
"year": year,
|
||||||
|
"months": out,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MyUsageLogs GET /api/usage/logs?page=1&pageSize=20 — 当前用户的用量明细(分页)。
|
||||||
|
func (h *Handler) MyUsageLogs(c *gin.Context) {
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
uid, _ := userID.(uint64)
|
||||||
|
|
||||||
|
limit, offset := paginate(c, 20)
|
||||||
|
logs, err := h.usageDAO.ListByUserID(c.Request.Context(), uid, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load usage logs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, err := h.usageDAO.CountByUserID(c.Request.Context(), uid)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count usage logs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
valLogs := make([]store.UsageLog, len(logs))
|
||||||
|
for i, l := range logs {
|
||||||
|
valLogs[i] = *l
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": usageLogsToResp(valLogs, nil), "total": total})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 管理后台:全量用量明细 ---
|
||||||
|
|
||||||
|
// AdminUsageLogs GET /api/admin/usage/logs?page=&pageSize=&protocol=&status=&model=&user_id=
|
||||||
|
func (h *Handler) AdminUsageLogs(c *gin.Context) {
|
||||||
|
f := daoUsageFilter(c)
|
||||||
|
logs, err := h.usageDAO.ListAll(c.Request.Context(), f)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load usage logs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total, err := h.usageDAO.CountAll(c.Request.Context(), f)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count usage logs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
names := h.userNames(logs)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"data": usageLogsToResp(logs, names),
|
||||||
|
"total": total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUsageSummary GET /api/admin/usage/summary?start=&end=&user_id= — 全量汇总。
|
||||||
|
func (h *Handler) AdminUsageSummary(c *gin.Context) {
|
||||||
|
var uidPtr *uint64
|
||||||
|
if v := c.Query("user_id"); v != "" {
|
||||||
|
if n, err := strconv.ParseUint(v, 10, 64); err == nil && n > 0 {
|
||||||
|
uidPtr = &n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dailies, err := h.dailyDAO.ListAll(c.Request.Context(), uidPtr, c.Query("start"), c.Query("end"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load usage"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalRequests, totalInput, totalOutput, totalCache int64
|
||||||
|
var totalCost float64
|
||||||
|
perUser := map[uint64]*gin.H{}
|
||||||
|
for _, d := range dailies {
|
||||||
|
totalRequests += d.Requests
|
||||||
|
totalInput += d.InputTokens
|
||||||
|
totalOutput += d.OutputTokens
|
||||||
|
totalCache += d.CacheReadTokens
|
||||||
|
totalCost += d.Cost
|
||||||
|
u, ok := perUser[d.UserID]
|
||||||
|
if !ok {
|
||||||
|
u = &gin.H{"user_id": d.UserID, "requests": int64(0), "input_tokens": int64(0), "output_tokens": int64(0), "cost": float64(0)}
|
||||||
|
perUser[d.UserID] = u
|
||||||
|
}
|
||||||
|
(*u)["requests"] = (*u)["requests"].(int64) + d.Requests
|
||||||
|
(*u)["input_tokens"] = (*u)["input_tokens"].(int64) + d.InputTokens
|
||||||
|
(*u)["output_tokens"] = (*u)["output_tokens"].(int64) + d.OutputTokens
|
||||||
|
(*u)["cost"] = (*u)["cost"].(float64) + d.Cost
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"data": gin.H{
|
||||||
|
"totals": gin.H{
|
||||||
|
"requests": totalRequests,
|
||||||
|
"input_tokens": totalInput,
|
||||||
|
"output_tokens": totalOutput,
|
||||||
|
"cache_read_tokens": totalCache,
|
||||||
|
"cost": totalCost,
|
||||||
|
},
|
||||||
|
"per_user": perUser,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
// paginate 解析 page/pageSize 查询参数,返回 limit/offset。
|
||||||
|
func paginate(c *gin.Context, defSize int) (int, int) {
|
||||||
|
limit := defSize
|
||||||
|
offset := 0
|
||||||
|
if pageSize := c.Query("pageSize"); pageSize != "" {
|
||||||
|
if n, err := strconv.Atoi(pageSize); err == nil && n > 0 && n <= 100 {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if page := c.Query("page"); page != "" {
|
||||||
|
if p, err := strconv.Atoi(page); err == nil && p > 0 {
|
||||||
|
offset = (p - 1) * limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return limit, offset
|
||||||
|
}
|
||||||
|
|
||||||
|
func daoUsageFilter(c *gin.Context) dao.UsageFilter {
|
||||||
|
limit, offset := paginate(c, 20)
|
||||||
|
f := dao.UsageFilter{Limit: limit, Offset: offset}
|
||||||
|
f.Protocol = c.Query("protocol")
|
||||||
|
f.Status = c.Query("status")
|
||||||
|
f.ModelName = c.Query("model")
|
||||||
|
if v := c.Query("user_id"); v != "" {
|
||||||
|
if n, err := strconv.ParseUint(v, 10, 64); err == nil && n > 0 {
|
||||||
|
f.UserID = &n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func usageLogsToResp(logs []store.UsageLog, names map[uint64]string) []gin.H {
|
||||||
|
out := make([]gin.H, 0, len(logs))
|
||||||
|
for _, l := range logs {
|
||||||
|
row := gin.H{
|
||||||
|
"id": l.ID,
|
||||||
|
"request_id": l.RequestID,
|
||||||
|
"user_id": l.UserID,
|
||||||
|
"channel_id": l.ChannelID,
|
||||||
|
"model_id": l.ModelID,
|
||||||
|
"model_name": l.ModelName,
|
||||||
|
"protocol": l.Protocol,
|
||||||
|
"input_tokens": l.InputTokens,
|
||||||
|
"output_tokens": l.OutputTokens,
|
||||||
|
"cache_read_tokens": l.CacheReadTokens,
|
||||||
|
"cache_creation_tokens": l.CacheCreationTokens,
|
||||||
|
"cost": l.Cost,
|
||||||
|
"latency_ms": l.LatencyMS,
|
||||||
|
"status": l.Status,
|
||||||
|
"error_code": l.ErrorCode,
|
||||||
|
"created_at": l.CreatedAt,
|
||||||
|
}
|
||||||
|
if names != nil {
|
||||||
|
if u, ok := names[l.UserID]; ok {
|
||||||
|
row["username"] = u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l.RawRequest != "" {
|
||||||
|
row["raw_request"] = l.RawRequest
|
||||||
|
}
|
||||||
|
if l.RawResponse != "" {
|
||||||
|
row["raw_response"] = l.RawResponse
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// userNames 批量查询 user_id → username 映射。
|
||||||
|
func (h *Handler) userNames(logs []store.UsageLog) map[uint64]string {
|
||||||
|
ids := map[uint64]bool{}
|
||||||
|
for _, l := range logs {
|
||||||
|
ids[l.UserID] = true
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
idList := make([]uint64, 0, len(ids))
|
||||||
|
for id := range ids {
|
||||||
|
idList = append(idList, id)
|
||||||
|
}
|
||||||
|
var users []store.User
|
||||||
|
if err := h.db.Where("id IN ?", idList).Find(&users).Error; err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := map[uint64]string{}
|
||||||
|
for _, u := range users {
|
||||||
|
out[u.ID] = u.Username
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID uint64 `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenPair struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateTokenPair(user *store.User, secret string, accessExpire, refreshExpire time.Duration) (*TokenPair, error) {
|
||||||
|
accessToken, err := generateToken(user, "access", secret, accessExpire)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
refreshToken, err := generateToken(user, "refresh", secret, refreshExpire)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &TokenPair{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateToken(user *store.User, tokenType, secret string, expire time.Duration) (string, error) {
|
||||||
|
now := time.Now()
|
||||||
|
claims := Claims{
|
||||||
|
UserID: user.ID,
|
||||||
|
Name: user.Username,
|
||||||
|
Type: tokenType,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(expire)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateToken(tokenString, secret string) (*Claims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, errors.New("unexpected signing method")
|
||||||
|
}
|
||||||
|
return []byte(secret), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
return nil, jwt.ErrInvalidKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSecretKey returns the JWT secret key from environment or config
|
||||||
|
func GetSecretKey() string {
|
||||||
|
secret := os.Getenv("SECRET_KEY")
|
||||||
|
if secret == "" {
|
||||||
|
secret = "default-secret-key-change-in-production"
|
||||||
|
}
|
||||||
|
return secret
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
package channel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math/rand"
|
||||||
|
"opencatd-open/internal/dao"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
"opencatd-open/internal/pkg/crypto"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
channelDAO *dao.ChannelDAO
|
||||||
|
modelDAO *dao.ModelDAO
|
||||||
|
|
||||||
|
// Health tracking
|
||||||
|
mu sync.RWMutex
|
||||||
|
healthStatus map[uint64]*channelHealth
|
||||||
|
|
||||||
|
// Concurrency control per channel
|
||||||
|
sems map[uint64]chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type channelHealth struct {
|
||||||
|
status string
|
||||||
|
consecutive int
|
||||||
|
lastCheck time.Time
|
||||||
|
cooldown time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(channelDAO *dao.ChannelDAO, modelDAO *dao.ModelDAO) *Service {
|
||||||
|
return &Service{
|
||||||
|
channelDAO: channelDAO,
|
||||||
|
modelDAO: modelDAO,
|
||||||
|
healthStatus: make(map[uint64]*channelHealth),
|
||||||
|
sems: make(map[uint64]chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectedRoute 一次路由决策的完整结果:渠道 + 命中的模型绑定。
|
||||||
|
// Binding 可能为 nil(渠道经回退路径选中、无绑定记录)。
|
||||||
|
type SelectedRoute struct {
|
||||||
|
Channel *store.Channel
|
||||||
|
Binding *store.ChannelModelBinding
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidates 返回可用渠道候选:健康 + 启用。
|
||||||
|
// model 非空时优先取绑定该模型的渠道(携带 upstream_model 映射,权重降序);
|
||||||
|
// 无绑定则回退到未绑定模型路径:按权重升序(闲置渠道优先探活)。
|
||||||
|
func (s *Service) Candidates(model string) []Candidate {
|
||||||
|
if model != "" {
|
||||||
|
var b []store.ChannelModelBinding
|
||||||
|
var modelIDs []uint64
|
||||||
|
s.modelDAO.DB().Model(&store.Model{}).Where("name = ? AND enabled = ?", model, true).Pluck("id", &modelIDs)
|
||||||
|
if len(modelIDs) > 0 {
|
||||||
|
s.channelDAO.DB().Where("model_id IN ?", modelIDs).Find(&b)
|
||||||
|
if cands := s.loadBound(b); len(cands) > 0 {
|
||||||
|
return cands
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 未绑定模型回退:取优先级最低的空闲健康渠道作为"备用渠道"承接搭车流量
|
||||||
|
// (排序与绑定候选一致:priority ASC, weight DESC, id ASC,取末位)。
|
||||||
|
// weight=0 的渠道不被加权随机选中,但可作为最后备用承接 unbound 流量。
|
||||||
|
var chs []store.Channel
|
||||||
|
s.channelDAO.DB().Where("enabled = ?", true).
|
||||||
|
Order("priority ASC, weight DESC, id ASC").Find(&chs)
|
||||||
|
all := make([]Candidate, 0, len(chs))
|
||||||
|
for i := range chs {
|
||||||
|
all = append(all, Candidate{Channel: &chs[i]})
|
||||||
|
}
|
||||||
|
healthy := s.FilterHealthy(all)
|
||||||
|
if len(healthy) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return healthy[len(healthy)-1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadBound 按绑定顺序加载渠道候选,过滤健康/启用,携带 upstream_model 映射。
|
||||||
|
func (s *Service) loadBound(bindings []store.ChannelModelBinding) []Candidate {
|
||||||
|
if len(bindings) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// channel_id -> 绑定(取该渠道对该模型的映射)
|
||||||
|
byChannel := map[uint64]store.ChannelModelBinding{}
|
||||||
|
ids := make([]uint64, 0, len(bindings))
|
||||||
|
for _, b := range bindings {
|
||||||
|
if _, ok := byChannel[b.ChannelID]; !ok {
|
||||||
|
ids = append(ids, b.ChannelID)
|
||||||
|
}
|
||||||
|
byChannel[b.ChannelID] = b
|
||||||
|
}
|
||||||
|
var chs []store.Channel
|
||||||
|
s.channelDAO.DB().Where("id IN ? AND enabled = ? AND health_status = ?", ids, true, store.ChannelHealthHealthy).
|
||||||
|
Order("priority ASC, weight DESC, id ASC").Find(&chs)
|
||||||
|
byID := map[uint64]*store.Channel{}
|
||||||
|
for i := range chs {
|
||||||
|
byID[chs[i].ID] = &chs[i]
|
||||||
|
}
|
||||||
|
out := make([]Candidate, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if ch, ok := byID[id]; ok {
|
||||||
|
b := byChannel[id]
|
||||||
|
out = append(out, Candidate{Channel: ch, Binding: &b})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChannelByKeyID decrypts the API key for a channel
|
||||||
|
func (s *Service) GetChannelByKeyID(ctx context.Context, channelID uint64) (*store.Channel, error) {
|
||||||
|
ch, err := s.channelDAO.GetByID(channelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAPIKey decrypts the channel's API key
|
||||||
|
func (s *Service) GetAPIKey(ch *store.Channel) (string, error) {
|
||||||
|
return crypto.Decrypt(ch.APIKeyEnc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordSuccess records a successful request to a channel
|
||||||
|
func (s *Service) RecordSuccess(channelID uint64) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
h := s.getOrCreateHealth(channelID)
|
||||||
|
h.consecutive = 0
|
||||||
|
h.status = store.ChannelHealthHealthy
|
||||||
|
h.lastCheck = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordFailure records a failed request to a channel.
|
||||||
|
// 连续 2 次失败进入 degraded(快速熔断):失败过的渠道让位给健康渠道,
|
||||||
|
// 健康检查成功或冷却过期后复位。
|
||||||
|
func (s *Service) RecordFailure(channelID uint64) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
h := s.getOrCreateHealth(channelID)
|
||||||
|
h.consecutive++
|
||||||
|
h.lastCheck = time.Now()
|
||||||
|
|
||||||
|
if h.consecutive >= 2 {
|
||||||
|
h.status = store.ChannelHealthDegraded
|
||||||
|
h.cooldown = time.Now().Add(5 * time.Minute)
|
||||||
|
}
|
||||||
|
if h.consecutive >= 5 {
|
||||||
|
h.status = store.ChannelHealthCooldown
|
||||||
|
h.cooldown = time.Now().Add(15 * time.Minute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordTimeout records a timeout to a channel
|
||||||
|
func (s *Service) RecordTimeout(channelID uint64) {
|
||||||
|
s.RecordFailure(channelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) getOrCreateHealth(channelID uint64) *channelHealth {
|
||||||
|
h, ok := s.healthStatus[channelID]
|
||||||
|
if !ok {
|
||||||
|
h = &channelHealth{
|
||||||
|
status: store.ChannelHealthHealthy,
|
||||||
|
}
|
||||||
|
s.healthStatus[channelID] = h
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) filterHealthy(channels []*store.Channel) []*store.Channel {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
var healthy []*store.Channel
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
for _, ch := range channels {
|
||||||
|
h, ok := s.healthStatus[ch.ID]
|
||||||
|
if !ok {
|
||||||
|
healthy = append(healthy, ch)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cooldown has expired
|
||||||
|
if now.After(h.cooldown) && h.cooldown.IsZero() == false {
|
||||||
|
h.consecutive = 0
|
||||||
|
h.status = store.ChannelHealthHealthy
|
||||||
|
healthy = append(healthy, ch)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.status == store.ChannelHealthHealthy || h.status == store.ChannelHealthDegraded {
|
||||||
|
healthy = append(healthy, ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return healthy
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHealthStatus returns the health status of a channel
|
||||||
|
func (s *Service) GetHealthStatus(channelID uint64) string {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
h, ok := s.healthStatus[channelID]
|
||||||
|
if !ok {
|
||||||
|
return store.ChannelHealthHealthy
|
||||||
|
}
|
||||||
|
return h.status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidate 一个候选渠道 + 该模型的映射关系。
|
||||||
|
type Candidate struct {
|
||||||
|
Channel *store.Channel
|
||||||
|
Binding *store.ChannelModelBinding // 全局模型在此渠道的映射(无绑定则 nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick 按权重加权随机选一个候选渠道(负载均衡;weight<=0 按 1 计)。
|
||||||
|
func (s *Service) Pick(cands []Candidate) *Candidate {
|
||||||
|
if len(cands) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
total := 0
|
||||||
|
for _, c := range cands {
|
||||||
|
w := c.Channel.Weight
|
||||||
|
if w <= 0 {
|
||||||
|
w = 1
|
||||||
|
}
|
||||||
|
total += w
|
||||||
|
}
|
||||||
|
r := rand.Intn(total)
|
||||||
|
acc := 0
|
||||||
|
for i := range cands {
|
||||||
|
w := cands[i].Channel.Weight
|
||||||
|
if w <= 0 {
|
||||||
|
w = 1
|
||||||
|
}
|
||||||
|
acc += w
|
||||||
|
if r < acc {
|
||||||
|
return &cands[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &cands[len(cands)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterHealthy 过滤掉内存健康状态异常的渠道候选(degraded/cooldown 均排除,
|
||||||
|
// 冷却/降级过期后复位放行)。degraded 由单次请求失败触发,作为快速熔断:
|
||||||
|
// 后续请求先走其他渠道,健康检查成功后恢复。
|
||||||
|
func (s *Service) FilterHealthy(cands []Candidate) []Candidate {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
out := make([]Candidate, 0, len(cands))
|
||||||
|
now := time.Now()
|
||||||
|
for _, c := range cands {
|
||||||
|
h, ok := s.healthStatus[c.Channel.ID]
|
||||||
|
if !ok || h.status == store.ChannelHealthHealthy {
|
||||||
|
out = append(out, c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 冷却/降级已过期:复位并放行
|
||||||
|
if !h.cooldown.IsZero() && now.After(h.cooldown) {
|
||||||
|
h.status = store.ChannelHealthHealthy
|
||||||
|
h.consecutive = 0
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// TryAcquire 尝试获取渠道并发槽;渠道满载返回 false(调用方可溢出到其他渠道)。
|
||||||
|
// MaxConcurrency<=0 视为不限制。
|
||||||
|
func (s *Service) TryAcquire(ch *store.Channel) (func(), bool) {
|
||||||
|
if ch.MaxConcurrency <= 0 {
|
||||||
|
return func() {}, true
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
sem, ok := s.sems[ch.ID]
|
||||||
|
if !ok {
|
||||||
|
sem = make(chan struct{}, ch.MaxConcurrency)
|
||||||
|
s.sems[ch.ID] = sem
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case sem <- struct{}{}:
|
||||||
|
return func() { <-sem }, true
|
||||||
|
default:
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package channel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChannelFormatsEffective(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
channel store.Channel
|
||||||
|
expected []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "anthropic default",
|
||||||
|
channel: store.Channel{
|
||||||
|
Provider: store.ChannelProviderAnthropic,
|
||||||
|
},
|
||||||
|
expected: []string{store.FormatMessages},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "openai default",
|
||||||
|
channel: store.Channel{
|
||||||
|
Provider: store.ChannelProviderOpenAI,
|
||||||
|
},
|
||||||
|
expected: []string{store.FormatChat, store.FormatResponses},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "compatible default",
|
||||||
|
channel: store.Channel{
|
||||||
|
Provider: store.ChannelProviderCompatible,
|
||||||
|
},
|
||||||
|
expected: []string{store.FormatChat},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom formats override",
|
||||||
|
channel: store.Channel{
|
||||||
|
Provider: store.ChannelProviderOpenAI,
|
||||||
|
Formats: []string{store.FormatChat},
|
||||||
|
},
|
||||||
|
expected: []string{store.FormatChat},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := tt.channel.FormatsEffective()
|
||||||
|
if len(result) != len(tt.expected) {
|
||||||
|
t.Errorf("FormatsEffective() returned %d formats, want %d", len(result), len(tt.expected))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i, f := range result {
|
||||||
|
if f != tt.expected[i] {
|
||||||
|
t.Errorf("FormatsEffective()[%d] = %q, want %q", i, f, tt.expected[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelUpstreamURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
channel store.Channel
|
||||||
|
proto string
|
||||||
|
path string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic openai",
|
||||||
|
channel: store.Channel{
|
||||||
|
BaseURL: "https://api.openai.com",
|
||||||
|
},
|
||||||
|
proto: "chat",
|
||||||
|
path: "/chat/completions",
|
||||||
|
expected: "https://api.openai.com/v1/chat/completions",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with trailing slash",
|
||||||
|
channel: store.Channel{
|
||||||
|
BaseURL: "https://api.openai.com/",
|
||||||
|
},
|
||||||
|
proto: "chat",
|
||||||
|
path: "/chat/completions",
|
||||||
|
expected: "https://api.openai.com/v1/chat/completions",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with version segment",
|
||||||
|
channel: store.Channel{
|
||||||
|
BaseURL: "https://api.openai.com/v1",
|
||||||
|
},
|
||||||
|
proto: "chat",
|
||||||
|
path: "/chat/completions",
|
||||||
|
expected: "https://api.openai.com/v1/chat/completions",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom base URL per protocol",
|
||||||
|
channel: store.Channel{
|
||||||
|
BaseURL: "https://default.openai.com",
|
||||||
|
BaseURLs: map[string]string{"chat": "https://chat.openai.com"},
|
||||||
|
},
|
||||||
|
proto: "chat",
|
||||||
|
path: "/chat/completions",
|
||||||
|
expected: "https://chat.openai.com/v1/chat/completions",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty base",
|
||||||
|
channel: store.Channel{
|
||||||
|
BaseURL: "",
|
||||||
|
},
|
||||||
|
proto: "chat",
|
||||||
|
path: "/chat/completions",
|
||||||
|
expected: "/chat/completions",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := tt.channel.UpstreamURL(tt.proto, tt.path)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("UpstreamURL() = %q, want %q", result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package channel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"opencatd-open/internal/dao"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
"opencatd-open/internal/pkg/crypto"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HealthConfig 健康检查配置
|
||||||
|
type HealthConfig struct {
|
||||||
|
Interval time.Duration // 检查间隔
|
||||||
|
Timeout time.Duration // 请求超时
|
||||||
|
FailureThreshold int // 连续失败次数阈值
|
||||||
|
DegradedCooldown time.Duration // degraded 冷却时间
|
||||||
|
CooldownCooldown time.Duration // cooldown 冷却时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultHealthConfig 返回默认健康检查配置
|
||||||
|
func DefaultHealthConfig() HealthConfig {
|
||||||
|
return HealthConfig{
|
||||||
|
Interval: 5 * time.Minute,
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
FailureThreshold: 3,
|
||||||
|
DegradedCooldown: 5 * time.Minute,
|
||||||
|
CooldownCooldown: 15 * time.Minute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type HealthChecker struct {
|
||||||
|
channelDAO *dao.ChannelDAO
|
||||||
|
service *Service
|
||||||
|
client *http.Client
|
||||||
|
config HealthConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHealthChecker(channelDAO *dao.ChannelDAO, service *Service, config ...HealthConfig) *HealthChecker {
|
||||||
|
cfg := DefaultHealthConfig()
|
||||||
|
if len(config) > 0 {
|
||||||
|
cfg = config[0]
|
||||||
|
}
|
||||||
|
return &HealthChecker{
|
||||||
|
channelDAO: channelDAO,
|
||||||
|
service: service,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: cfg.Timeout,
|
||||||
|
},
|
||||||
|
config: cfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckChannel performs a health check on a channel
|
||||||
|
func (hc *HealthChecker) CheckChannel(ctx context.Context, channel *store.Channel) error {
|
||||||
|
apiKey, err := crypto.Decrypt(channel.APIKeyEnc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decrypt API key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple health check: try to list models
|
||||||
|
var url string
|
||||||
|
switch channel.Provider {
|
||||||
|
case store.ChannelProviderOpenAI:
|
||||||
|
url = channel.UpstreamURL("chat", "/models")
|
||||||
|
case store.ChannelProviderAnthropic:
|
||||||
|
url = "https://api.anthropic.com/v1/models"
|
||||||
|
default:
|
||||||
|
url = channel.UpstreamURL("chat", "/models")
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set headers based on provider
|
||||||
|
switch channel.Provider {
|
||||||
|
case store.ChannelProviderOpenAI, store.ChannelProviderCompatible:
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
case store.ChannelProviderAnthropic:
|
||||||
|
req.Header.Set("x-api-key", apiKey)
|
||||||
|
req.Header.Set("anthropic-version", "2023-06-01")
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := hc.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
hc.service.RecordFailure(channel.ID)
|
||||||
|
return fmt.Errorf("health check failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
hc.service.RecordSuccess(channel.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hc.service.RecordFailure(channel.ID)
|
||||||
|
return fmt.Errorf("health check returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAllChannels checks health of all enabled channels
|
||||||
|
func (hc *HealthChecker) CheckAllChannels(ctx context.Context) error {
|
||||||
|
channels, err := hc.channelDAO.ListEnabled()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ch := range channels {
|
||||||
|
if err := hc.CheckChannel(ctx, ch); err != nil {
|
||||||
|
fmt.Printf("Channel %s health check failed: %v\n", ch.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartPeriodicCheck starts periodic health checks
|
||||||
|
func (hc *HealthChecker) StartPeriodicCheck(ctx context.Context, interval ...time.Duration) {
|
||||||
|
interval_ := hc.config.Interval
|
||||||
|
if len(interval) > 0 {
|
||||||
|
interval_ = interval[0]
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(interval_)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := hc.CheckAllChannels(ctx); err != nil {
|
||||||
|
fmt.Printf("Periodic health check error: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var LoadCmd = &cobra.Command{
|
||||||
|
Use: "load",
|
||||||
|
Short: "import user.json -> db",
|
||||||
|
Long: "\nimport user.json -> db",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
db := store.DB
|
||||||
|
var cont int64
|
||||||
|
if err := db.Model(&store.User{}).Count(&cont).Error; err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cont == 0 {
|
||||||
|
fmt.Println("创建管理员之后再操作")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := os.Stat("./db/user.json"); os.IsNotExist(err) {
|
||||||
|
log.Fatalln("404! user.json is not found.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, err := os.Open("./db/user.json")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Error opening file:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var usermap []map[string]string
|
||||||
|
if err := json.NewDecoder(file).Decode(&usermap); err != nil {
|
||||||
|
fmt.Println("解析文件失败:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, um := range usermap {
|
||||||
|
name := um["username"]
|
||||||
|
if name == "" {
|
||||||
|
name = um["name"]
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
fmt.Println("获取不到数据")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = "sk-ot-" + strings.ReplaceAll(uuid.New().String(), "-", "")
|
||||||
|
fmt.Printf("Import user: %s\n", name)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var SaveCmd = &cobra.Command{
|
||||||
|
Use: "save",
|
||||||
|
Short: "backup user info -> user.json",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"opencatd-open/internal/service"
|
||||||
|
"opencatd-open/pkg/config"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Api struct {
|
||||||
|
cfg *config.Config
|
||||||
|
db *gorm.DB
|
||||||
|
userService *service.UserServiceImpl
|
||||||
|
tokenService *service.TokenServiceImpl
|
||||||
|
keyService *service.ApiKeyServiceImpl
|
||||||
|
usageService *service.UsageService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApi(cfg *config.Config, db *gorm.DB, userService *service.UserServiceImpl, tokenService *service.TokenServiceImpl, keyService *service.ApiKeyServiceImpl, usageService *service.UsageService) *Api {
|
||||||
|
return &Api{
|
||||||
|
cfg: cfg,
|
||||||
|
db: db,
|
||||||
|
userService: userService,
|
||||||
|
tokenService: tokenService,
|
||||||
|
keyService: keyService,
|
||||||
|
usageService: usageService,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"opencatd-open/internal/channel"
|
||||||
|
"opencatd-open/internal/dao"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
"opencatd-open/pkg/config"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Proxy struct {
|
||||||
|
ctx context.Context
|
||||||
|
cfg *config.Config
|
||||||
|
db *gorm.DB
|
||||||
|
wg *sync.WaitGroup
|
||||||
|
httpClient *http.Client
|
||||||
|
|
||||||
|
userDAO *dao.UserDAO
|
||||||
|
apiKeyDAO *dao.ApiKeyDAO
|
||||||
|
usageDAO *dao.UsageDAO
|
||||||
|
dailyDAO *dao.DailyUsageDAO
|
||||||
|
channelSvc *channel.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProxy(ctx context.Context, cfg *config.Config, db *gorm.DB, wg *sync.WaitGroup, userDAO *dao.UserDAO, apiKeyDAO *dao.ApiKeyDAO, usageDAO *dao.UsageDAO, dailyDAO *dao.DailyUsageDAO) *Proxy {
|
||||||
|
client := http.DefaultClient
|
||||||
|
if os.Getenv("LOCAL_PROXY") != "" {
|
||||||
|
proxyUrl, err := url.Parse(os.Getenv("LOCAL_PROXY"))
|
||||||
|
if err == nil {
|
||||||
|
tr := &http.Transport{
|
||||||
|
Proxy: http.ProxyURL(proxyUrl),
|
||||||
|
}
|
||||||
|
client.Transport = tr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
np := &Proxy{
|
||||||
|
ctx: ctx,
|
||||||
|
cfg: cfg,
|
||||||
|
db: db,
|
||||||
|
wg: wg,
|
||||||
|
httpClient: client,
|
||||||
|
userDAO: userDAO,
|
||||||
|
apiKeyDAO: apiKeyDAO,
|
||||||
|
usageDAO: usageDAO,
|
||||||
|
dailyDAO: dailyDAO,
|
||||||
|
}
|
||||||
|
|
||||||
|
return np
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetChannelService sets the channel service (called after construction)
|
||||||
|
func (p *Proxy) SetChannelService(svc *channel.Service) {
|
||||||
|
p.channelSvc = svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Proxy) HandleProxy(c *gin.Context) {
|
||||||
|
path := c.Request.URL.Path
|
||||||
|
switch {
|
||||||
|
case path == "/v1/chat/completions":
|
||||||
|
// TODO: Phase 3 - implement chat completions handler
|
||||||
|
c.JSON(http.StatusNotImplemented, gin.H{"error": "chat completions not yet implemented"})
|
||||||
|
case strings.HasPrefix(path, "/v1/messages"):
|
||||||
|
// TODO: Phase 3 - implement messages handler
|
||||||
|
c.JSON(http.StatusNotImplemented, gin.H{"error": "messages not yet implemented"})
|
||||||
|
case path == "/v1/responses":
|
||||||
|
// TODO: Phase 3 - implement responses handler
|
||||||
|
c.JSON(http.StatusNotImplemented, gin.H{"error": "responses not yet implemented"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "unknown endpoint"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Proxy) HandleModels(c *gin.Context) {
|
||||||
|
// TODO: Phase 3 - implement models list
|
||||||
|
c.JSON(http.StatusOK, gin.H{"object": "list", "data": []interface{}{}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Proxy) GetDB() *gorm.DB {
|
||||||
|
return p.db
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectChannel selects the best channel for a model
|
||||||
|
func (p *Proxy) SelectChannel(modelName string) (*store.Channel, error) {
|
||||||
|
if p.channelSvc == nil {
|
||||||
|
return nil, fmt.Errorf("channel service not initialized")
|
||||||
|
}
|
||||||
|
cands := p.channelSvc.Candidates(modelName)
|
||||||
|
picked := p.channelSvc.Pick(cands)
|
||||||
|
if picked == nil {
|
||||||
|
return nil, fmt.Errorf("no enabled channels for model: %s", modelName)
|
||||||
|
}
|
||||||
|
return picked.Channel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordSuccess records a successful request
|
||||||
|
func (p *Proxy) RecordSuccess(channelID uint64) {
|
||||||
|
if p.channelSvc != nil {
|
||||||
|
p.channelSvc.RecordSuccess(channelID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordFailure records a failed request
|
||||||
|
func (p *Proxy) RecordFailure(channelID uint64) {
|
||||||
|
if p.channelSvc != nil {
|
||||||
|
p.channelSvc.RecordFailure(channelID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendUsagePlaceholder placeholder for usage processing
|
||||||
|
func (p *Proxy) SendUsagePlaceholder(model string, userID uint64, promptTokens, completionTokens int) {
|
||||||
|
log.Printf("Usage: model=%s user=%d prompt=%d completion=%d", model, userID, promptTokens, completionTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder to keep the file compilable
|
||||||
|
var _ = json.Marshal
|
||||||
|
var _ = io.ReadAll
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package dao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ApiKeyDAO struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApiKeyDAO(db *gorm.DB) *ApiKeyDAO {
|
||||||
|
return &ApiKeyDAO{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ApiKeyDAO) Create(apiKey *store.APIKey) error {
|
||||||
|
if apiKey == nil {
|
||||||
|
return errors.New("apiKey is nil")
|
||||||
|
}
|
||||||
|
return d.db.Create(apiKey).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ApiKeyDAO) GetByID(id uint64) (*store.APIKey, error) {
|
||||||
|
var apiKey store.APIKey
|
||||||
|
err := d.db.First(&apiKey, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &apiKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ApiKeyDAO) GetByHash(keyHash string) (*store.APIKey, error) {
|
||||||
|
var apiKey store.APIKey
|
||||||
|
err := d.db.Where("key_hash = ? AND status = ?", keyHash, store.KeyStatusActive).First(&apiKey).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &apiKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ApiKeyDAO) ListByUserID(userID uint64, limit, offset int) ([]*store.APIKey, int64, error) {
|
||||||
|
var apiKeys []*store.APIKey
|
||||||
|
var total int64
|
||||||
|
db := d.db.Where("user_id = ?", userID)
|
||||||
|
db.Model(&store.APIKey{}).Count(&total)
|
||||||
|
err := db.Limit(limit).Offset(offset).Order("created_at DESC").Find(&apiKeys).Error
|
||||||
|
return apiKeys, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ApiKeyDAO) Update(apiKey *store.APIKey) error {
|
||||||
|
if apiKey == nil {
|
||||||
|
return errors.New("apiKey is nil")
|
||||||
|
}
|
||||||
|
return d.db.Save(apiKey).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ApiKeyDAO) Delete(id uint64) error {
|
||||||
|
return d.db.Delete(&store.APIKey{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ApiKeyDAO) BatchDelete(ids []uint64) error {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return errors.New("ids is empty")
|
||||||
|
}
|
||||||
|
return d.db.Delete(&store.APIKey{}, ids).Error
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package dao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChannelDAO struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChannelDAO(db *gorm.DB) *ChannelDAO {
|
||||||
|
return &ChannelDAO{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB 暴露底层连接,供聚合查询使用(如渠道候选联表过滤)。
|
||||||
|
func (d *ChannelDAO) DB() *gorm.DB {
|
||||||
|
return d.db
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ChannelDAO) Create(channel *store.Channel) error {
|
||||||
|
return d.db.Create(channel).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ChannelDAO) GetByID(id uint64) (*store.Channel, error) {
|
||||||
|
var channel store.Channel
|
||||||
|
err := d.db.First(&channel, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &channel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ChannelDAO) GetByName(name string) (*store.Channel, error) {
|
||||||
|
var channel store.Channel
|
||||||
|
err := d.db.Where("name = ?", name).First(&channel).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &channel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ChannelDAO) List(limit, offset int) ([]*store.Channel, int64, error) {
|
||||||
|
var channels []*store.Channel
|
||||||
|
var total int64
|
||||||
|
d.db.Model(&store.Channel{}).Count(&total)
|
||||||
|
err := d.db.Limit(limit).Offset(offset).Order("priority DESC, weight DESC").Find(&channels).Error
|
||||||
|
return channels, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ChannelDAO) ListEnabled() ([]*store.Channel, error) {
|
||||||
|
var channels []*store.Channel
|
||||||
|
err := d.db.Where("enabled = ?", true).Order("priority DESC, weight DESC").Find(&channels).Error
|
||||||
|
return channels, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ChannelDAO) Update(channel *store.Channel) error {
|
||||||
|
return d.db.Save(channel).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ChannelDAO) Delete(id uint64) error {
|
||||||
|
return d.db.Delete(&store.Channel{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindModels binds models to a channel (replaces existing bindings)
|
||||||
|
func (d *ChannelDAO) BindModels(channelID uint64, bindings []store.ChannelModelBinding) error {
|
||||||
|
return d.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// Delete existing bindings
|
||||||
|
if err := tx.Where("channel_id = ?", channelID).Delete(&store.ChannelModelBinding{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Create new bindings
|
||||||
|
for i := range bindings {
|
||||||
|
bindings[i].ChannelID = channelID
|
||||||
|
}
|
||||||
|
return tx.Create(&bindings).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChannelModels returns all models bound to a channel
|
||||||
|
func (d *ChannelDAO) GetChannelModels(channelID uint64) ([]store.ChannelModelBinding, error) {
|
||||||
|
var bindings []store.ChannelModelBinding
|
||||||
|
err := d.db.Where("channel_id = ?", channelID).Find(&bindings).Error
|
||||||
|
return bindings, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetModelChannels returns all channels that support a given model (by model name)
|
||||||
|
func (d *ChannelDAO) GetModelChannels(modelName string) ([]store.ChannelModelBinding, error) {
|
||||||
|
var bindings []store.ChannelModelBinding
|
||||||
|
err := d.db.
|
||||||
|
Joins("JOIN channels ON channels.id = channel_model_bindings.channel_id").
|
||||||
|
Joins("JOIN models ON models.id = channel_model_bindings.model_id").
|
||||||
|
Where("models.name = ? AND channels.enabled = ?", modelName, true).
|
||||||
|
Find(&bindings).Error
|
||||||
|
return bindings, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEnabledChannelsByModel returns enabled channels for a model, ordered by priority/weight
|
||||||
|
func (d *ChannelDAO) GetEnabledChannelsByModel(modelName string) ([]*store.Channel, error) {
|
||||||
|
var channels []*store.Channel
|
||||||
|
err := d.db.
|
||||||
|
Distinct("channels.*").
|
||||||
|
Joins("JOIN channel_model_bindings ON channel_model_bindings.channel_id = channels.id").
|
||||||
|
Joins("JOIN models ON models.id = channel_model_bindings.model_id").
|
||||||
|
Where("models.name = ? AND channels.enabled = ?", modelName, true).
|
||||||
|
Order("channels.priority DESC, channels.weight DESC").
|
||||||
|
Find(&channels).Error
|
||||||
|
return channels, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package dao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelDAO struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewModelDAO(db *gorm.DB) *ModelDAO {
|
||||||
|
return &ModelDAO{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB 暴露底层连接,供聚合查询使用(如模型候选联表过滤)。
|
||||||
|
func (d *ModelDAO) DB() *gorm.DB {
|
||||||
|
return d.db
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ModelDAO) Create(model *store.Model) error {
|
||||||
|
return d.db.Create(model).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ModelDAO) GetByID(id uint64) (*store.Model, error) {
|
||||||
|
var model store.Model
|
||||||
|
err := d.db.First(&model, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &model, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ModelDAO) GetByName(name string) (*store.Model, error) {
|
||||||
|
var model store.Model
|
||||||
|
err := d.db.Where("name = ?", name).First(&model).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &model, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ModelDAO) List(limit, offset int) ([]*store.Model, int64, error) {
|
||||||
|
var models []*store.Model
|
||||||
|
var total int64
|
||||||
|
d.db.Model(&store.Model{}).Count(&total)
|
||||||
|
err := d.db.Limit(limit).Offset(offset).Order("sort ASC, name ASC").Find(&models).Error
|
||||||
|
return models, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ModelDAO) ListEnabled() ([]*store.Model, error) {
|
||||||
|
var models []*store.Model
|
||||||
|
err := d.db.Where("enabled = ?", true).Order("sort ASC, name ASC").Find(&models).Error
|
||||||
|
return models, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ModelDAO) Update(model *store.Model) error {
|
||||||
|
return d.db.Save(model).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *ModelDAO) Delete(id uint64) error {
|
||||||
|
return d.db.Delete(&store.Model{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert creates or updates a model by name
|
||||||
|
func (d *ModelDAO) Upsert(model *store.Model) error {
|
||||||
|
return d.db.Where("name = ?", model.Name).Assign(store.Model{
|
||||||
|
DisplayName: model.DisplayName,
|
||||||
|
InputPrice: model.InputPrice,
|
||||||
|
OutputPrice: model.OutputPrice,
|
||||||
|
CacheReadPrice: model.CacheReadPrice,
|
||||||
|
Enabled: model.Enabled,
|
||||||
|
Sort: model.Sort,
|
||||||
|
}).FirstOrCreate(model).Error
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package dao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TokenDAO struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTokenDAO(db *gorm.DB) *TokenDAO {
|
||||||
|
return &TokenDAO{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *TokenDAO) GetByKey(key string) (*store.User, error) {
|
||||||
|
var user store.User
|
||||||
|
err := d.db.Where("username = ?", key).First(&user).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *TokenDAO) GetByID(id uint64) (*store.User, error) {
|
||||||
|
var user store.User
|
||||||
|
err := d.db.First(&user, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder to avoid compile errors - will be expanded in Phase 1
|
||||||
|
var _ = errors.New
|
||||||
|
var _ = gorm.ErrRecordNotFound
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package dao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UsageDAO struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
type DailyUsageDAO struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUsageDAO(db *gorm.DB) *UsageDAO {
|
||||||
|
return &UsageDAO{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDailyUsageDAO(db *gorm.DB) *DailyUsageDAO {
|
||||||
|
return &DailyUsageDAO{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageLog DAO
|
||||||
|
func (d *UsageDAO) Create(ctx context.Context, log *store.UsageLog) error {
|
||||||
|
return d.db.WithContext(ctx).Create(log).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *UsageDAO) BatchCreate(ctx context.Context, logs []*store.UsageLog) error {
|
||||||
|
return d.db.WithContext(ctx).Create(logs).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *UsageDAO) ListByUserID(ctx context.Context, userID uint64, limit, offset int) ([]*store.UsageLog, error) {
|
||||||
|
var logs []*store.UsageLog
|
||||||
|
err := d.db.WithContext(ctx).
|
||||||
|
Where("user_id = ?", userID).
|
||||||
|
Order("created_at DESC").
|
||||||
|
Limit(limit).
|
||||||
|
Offset(offset).
|
||||||
|
Find(&logs).Error
|
||||||
|
return logs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *UsageDAO) Delete(ctx context.Context, id uint64) error {
|
||||||
|
return d.db.WithContext(ctx).Delete(&store.UsageLog{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *UsageDAO) CountByUserID(ctx context.Context, userID uint64) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := d.db.WithContext(ctx).Model(&store.UsageLog{}).Where("user_id = ?", userID).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageFilter 用量明细筛选条件(管理后台)。
|
||||||
|
type UsageFilter struct {
|
||||||
|
UserID *uint64 // 指定用户(nil=全部)
|
||||||
|
Protocol string // 协议 chat/messages/responses(空=全部)
|
||||||
|
Status string // success/error/canceled(空=全部)
|
||||||
|
ModelName string // 模型名模糊(空=全部)
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAll 管理后台全量用量明细(分页 + 筛选),并带用户名。
|
||||||
|
func (d *UsageDAO) ListAll(ctx context.Context, f UsageFilter) ([]store.UsageLog, error) {
|
||||||
|
q := d.db.WithContext(ctx).Model(&store.UsageLog{})
|
||||||
|
q = applyUsageFilter(q, f)
|
||||||
|
var logs []store.UsageLog
|
||||||
|
err := q.Order("created_at DESC").Limit(f.Limit).Offset(f.Offset).Find(&logs).Error
|
||||||
|
return logs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountAll 统计符合筛选条件的明细总数。
|
||||||
|
func (d *UsageDAO) CountAll(ctx context.Context, f UsageFilter) (int64, error) {
|
||||||
|
q := d.db.WithContext(ctx).Model(&store.UsageLog{})
|
||||||
|
q = applyUsageFilter(q, f)
|
||||||
|
var count int64
|
||||||
|
err := q.Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyUsageFilter(q *gorm.DB, f UsageFilter) *gorm.DB {
|
||||||
|
if f.UserID != nil {
|
||||||
|
q = q.Where("user_id = ?", *f.UserID)
|
||||||
|
}
|
||||||
|
if f.Protocol != "" {
|
||||||
|
q = q.Where("protocol = ?", f.Protocol)
|
||||||
|
}
|
||||||
|
if f.Status != "" {
|
||||||
|
q = q.Where("status = ?", f.Status)
|
||||||
|
}
|
||||||
|
if f.ModelName != "" {
|
||||||
|
q = q.Where("model_name LIKE ?", "%"+f.ModelName+"%")
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageDaily DAO
|
||||||
|
func (d *DailyUsageDAO) Create(ctx context.Context, log *store.UsageDaily) error {
|
||||||
|
return d.db.WithContext(ctx).Create(log).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DailyUsageDAO) ListByUserID(ctx context.Context, userID uint64, limit, offset int) ([]*store.UsageDaily, error) {
|
||||||
|
var logs []*store.UsageDaily
|
||||||
|
err := d.db.WithContext(ctx).
|
||||||
|
Where("user_id = ?", userID).
|
||||||
|
Order("date DESC").
|
||||||
|
Limit(limit).
|
||||||
|
Offset(offset).
|
||||||
|
Find(&logs).Error
|
||||||
|
return logs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DailyUsageDAO) GetByDate(ctx context.Context, userID uint64, date string) (*store.UsageDaily, error) {
|
||||||
|
var log store.UsageDaily
|
||||||
|
err := d.db.WithContext(ctx).
|
||||||
|
Where("user_id = ? AND date = ?", userID, date).
|
||||||
|
First(&log).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &log, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertDailyUsage 按 (user_id, model_id, date) 累加式 upsert:
|
||||||
|
// 行不存在则插入;存在则在原值基础上增量累加(不能用 AssignmentColumns 覆盖,
|
||||||
|
// 否则多次 flush 会互相清零)。非限定列名在 SQLite/MySQL/PG 的 upsert 语义下都指向目标行。
|
||||||
|
func (d *DailyUsageDAO) UpsertDailyUsage(ctx context.Context, log *store.UsageDaily) error {
|
||||||
|
return d.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "user_id"}, {Name: "model_id"}, {Name: "date"}},
|
||||||
|
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||||
|
"requests": gorm.Expr("requests + ?", log.Requests),
|
||||||
|
"input_tokens": gorm.Expr("input_tokens + ?", log.InputTokens),
|
||||||
|
"output_tokens": gorm.Expr("output_tokens + ?", log.OutputTokens),
|
||||||
|
"cache_read_tokens": gorm.Expr("cache_read_tokens + ?", log.CacheReadTokens),
|
||||||
|
"cost": gorm.Expr("cost + ?", log.Cost),
|
||||||
|
}),
|
||||||
|
}).Create(log).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DailyUsageDAO) ListByDateRange(ctx context.Context, userID uint64, start, end time.Time) ([]*store.UsageDaily, error) {
|
||||||
|
var logs []*store.UsageDaily
|
||||||
|
err := d.db.WithContext(ctx).
|
||||||
|
Where("user_id = ? AND date >= ? AND date <= ?", userID, start.Format("2006-01-02"), end.Format("2006-01-02")).
|
||||||
|
Order("date DESC").
|
||||||
|
Find(&logs).Error
|
||||||
|
return logs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAll 管理后台:全部用户的日聚合(可选按用户/日期范围筛选),按日期倒序。
|
||||||
|
func (d *DailyUsageDAO) ListAll(ctx context.Context, userID *uint64, start, end string) ([]store.UsageDaily, error) {
|
||||||
|
q := d.db.WithContext(ctx).Model(&store.UsageDaily{})
|
||||||
|
if userID != nil {
|
||||||
|
q = q.Where("user_id = ?", *userID)
|
||||||
|
}
|
||||||
|
if start != "" {
|
||||||
|
q = q.Where("date >= ?", start)
|
||||||
|
}
|
||||||
|
if end != "" {
|
||||||
|
q = q.Where("date <= ?", end)
|
||||||
|
}
|
||||||
|
var logs []store.UsageDaily
|
||||||
|
err := q.Order("date DESC, user_id ASC").Find(&logs).Error
|
||||||
|
return logs, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package dao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserDAO struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserDAO(db *gorm.DB) *UserDAO {
|
||||||
|
return &UserDAO{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *UserDAO) Create(user *store.User) error {
|
||||||
|
return d.db.Create(user).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *UserDAO) GetByID(id uint64) (*store.User, error) {
|
||||||
|
var user store.User
|
||||||
|
err := d.db.First(&user, id).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *UserDAO) GetByUsername(username string) (*store.User, error) {
|
||||||
|
var user store.User
|
||||||
|
err := d.db.Where("username = ?", username).First(&user).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *UserDAO) GetByEmail(email string) (*store.User, error) {
|
||||||
|
var user store.User
|
||||||
|
err := d.db.Where("email = ?", email).First(&user).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *UserDAO) List(limit, offset int) ([]*store.User, int64, error) {
|
||||||
|
var users []*store.User
|
||||||
|
var total int64
|
||||||
|
d.db.Model(&store.User{}).Count(&total)
|
||||||
|
err := d.db.Limit(limit).Offset(offset).Order("created_at DESC").Find(&users).Error
|
||||||
|
return users, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *UserDAO) Update(user *store.User) error {
|
||||||
|
return d.db.Save(user).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *UserDAO) Delete(id uint64) error {
|
||||||
|
return d.db.Delete(&store.User{}, id).Error
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
type BatchIDRequest struct {
|
||||||
|
UserID *int64 `json:"user_id"`
|
||||||
|
IDs []int64 `json:"ids" binding:"required"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Error struct {
|
||||||
|
Code int `json:"code,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func WrapErrorAsOpenAI(c *gin.Context, code int, msg string) {
|
||||||
|
c.JSON(code, gin.H{
|
||||||
|
"error": Error{
|
||||||
|
Code: code,
|
||||||
|
Message: msg,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TeamKey 结构体定义
|
||||||
|
type TeamKey struct {
|
||||||
|
ID *int64 `json:"id,omitempty"`
|
||||||
|
UserID *int64 `json:"userID,omitempty"`
|
||||||
|
Name *string `json:"name,omitempty"` // 必须
|
||||||
|
Key *string `json:"key,omitempty"`
|
||||||
|
Status *int64 `json:"status,omitempty"` // 默认1 允许,0禁止
|
||||||
|
Quota *int64 `json:"quota,omitempty"` // UnlimitedQuota不为1 的时候必须
|
||||||
|
UnlimitedQuota *bool `json:"unlimitedQuota,omitempty"` // 默认1 不限制,0限制
|
||||||
|
UsedQuota *int64 `json:"usedQuota,omitempty"`
|
||||||
|
CreatedAt *int64 `json:"createdAt,omitempty"`
|
||||||
|
ExpiredAt *int64 `json:"expiredAt,omitempty"` // 可选
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultTeamKey 创建一个具有默认值的 TeamKey
|
||||||
|
func DefaultTeamKey() TeamKey {
|
||||||
|
status := int64(1) // 默认允许
|
||||||
|
unlimitedQuota := true // 默认不限制
|
||||||
|
createdAt := time.Now().Unix()
|
||||||
|
|
||||||
|
return TeamKey{
|
||||||
|
Status: &status,
|
||||||
|
UnlimitedQuota: &unlimitedQuota,
|
||||||
|
CreatedAt: &createdAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate 验证 TeamKey 结构体
|
||||||
|
func (t TeamKey) Validate() error {
|
||||||
|
// 自定义验证规则
|
||||||
|
var quotaRule validation.Rule = validation.Skip
|
||||||
|
if t.UnlimitedQuota != nil && !*t.UnlimitedQuota {
|
||||||
|
quotaRule = validation.Required.Error("当 UnlimitedQuota 为 false 时,Quota 是必填项")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过期时间校验
|
||||||
|
var expiredAtRule validation.Rule = validation.Skip
|
||||||
|
if t.ExpiredAt != nil {
|
||||||
|
expiredAtRule = validation.Min(time.Now().Unix()).Error("过期时间不能早于当前时间")
|
||||||
|
}
|
||||||
|
|
||||||
|
return validation.ValidateStruct(&t,
|
||||||
|
// ID 通常由系统生成,不需要验证
|
||||||
|
|
||||||
|
// UserID 可选,但如果提供必须大于 0
|
||||||
|
validation.Field(&t.UserID,
|
||||||
|
validation.When(t.UserID != nil, validation.Min(int64(1)).Error("用户 ID 必须大于 0"))),
|
||||||
|
|
||||||
|
// Name 是必填字段
|
||||||
|
validation.Field(&t.Name,
|
||||||
|
validation.Required.Error("名称不能为空"),
|
||||||
|
validation.When(t.Name != nil, validation.Length(1, 100).Error("名称长度应在 1-100 之间"))),
|
||||||
|
|
||||||
|
// Key 可选,但如果提供需要符合特定格式
|
||||||
|
validation.Field(&t.Key,
|
||||||
|
validation.When(t.Key != nil,
|
||||||
|
validation.Length(1, 255).Error("Key 长度应在 1-255 之间")),
|
||||||
|
validation.Match(regexp.MustCompile(`^[^\s]+$`)).Error("Key 不能包含空格"),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Status 只能是 0 或 1
|
||||||
|
validation.Field(&t.Status,
|
||||||
|
validation.When(t.Status != nil, validation.In(int64(0), int64(1)).Error("状态只能是 0(禁止) 或 1(允许)"))),
|
||||||
|
|
||||||
|
// Quota 要求依赖于 UnlimitedQuota
|
||||||
|
validation.Field(&t.Quota, quotaRule,
|
||||||
|
validation.When(t.Quota != nil, validation.Min(int64(1)).Error("配额必须大于 0"))),
|
||||||
|
|
||||||
|
// UnlimitedQuota 是否限制配额
|
||||||
|
validation.Field(&t.UnlimitedQuota),
|
||||||
|
|
||||||
|
// UsedQuota 系统维护,不需要验证
|
||||||
|
validation.Field(&t.UsedQuota,
|
||||||
|
validation.When(t.UsedQuota != nil, validation.Min(int64(0)).Error("已使用配额不能为负数"))),
|
||||||
|
|
||||||
|
// CreatedAt 系统维护,不需要验证
|
||||||
|
validation.Field(&t.CreatedAt),
|
||||||
|
|
||||||
|
// ExpiredAt 可选,但如果提供必须大于当前时间
|
||||||
|
validation.Field(&t.ExpiredAt, expiredAtRule),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateCreate 创建时的特殊验证
|
||||||
|
func (t TeamKey) ValidateCreate() error {
|
||||||
|
// 首先进行基本验证
|
||||||
|
if err := t.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建时的额外验证
|
||||||
|
if t.Name == nil {
|
||||||
|
return errors.New("创建时必须提供名称")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data any `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Success(ctx *gin.Context, data any) {
|
||||||
|
ctx.JSON(http.StatusOK, Result{
|
||||||
|
Code: 200,
|
||||||
|
Data: data,
|
||||||
|
Msg: "success",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func Fail(c *gin.Context, code int, err string) {
|
||||||
|
c.AbortWithStatusJSON(code, gin.H{
|
||||||
|
"code": code,
|
||||||
|
"error": err,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Username string `json:"username" binding:"required,min=3,max=32"`
|
||||||
|
Password string `json:"password" binding:"required,min=4"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Auth struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
ExpiresIn int64 `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChangePassword struct {
|
||||||
|
Password string `json:"password" binding:"required,min=4"`
|
||||||
|
NewPassword string `json:"newpassword" binding:"required,min=4"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
// Package passkey 封装 WebAuthn(passkey)注册与登录。
|
||||||
|
package passkey
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sessionPrefix = "passkey:session:"
|
||||||
|
sessionTTL = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
RPID string
|
||||||
|
Origins []string
|
||||||
|
Name string
|
||||||
|
Redis *redis.Client // 可选,nil 时使用内存存储
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionStore challenge 会话存储接口
|
||||||
|
type SessionStore interface {
|
||||||
|
Set(ctx context.Context, session *webauthn.SessionData) error
|
||||||
|
Get(ctx context.Context, challenge string) (*webauthn.SessionData, bool, error)
|
||||||
|
Delete(ctx context.Context, challenge string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// memoryStore 内存存储(单实例)
|
||||||
|
type memoryStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions map[string]webauthn.SessionData
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMemoryStore() *memoryStore {
|
||||||
|
return &memoryStore{sessions: make(map[string]webauthn.SessionData)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryStore) Set(_ context.Context, session *webauthn.SessionData) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.sessions[session.Challenge] = *session
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryStore) Get(_ context.Context, challenge string) (*webauthn.SessionData, bool, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
sess, ok := m.sessions[challenge]
|
||||||
|
m.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
// 检查过期
|
||||||
|
if !sess.Expires.IsZero() && time.Now().After(sess.Expires) {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
return &sess, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryStore) Delete(_ context.Context, challenge string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
delete(m.sessions, challenge)
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// redisStore Redis 存储(分布式)
|
||||||
|
type redisStore struct {
|
||||||
|
rdb *redis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRedisStore(rdb *redis.Client) *redisStore {
|
||||||
|
return &redisStore{rdb: rdb}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisStore) Set(ctx context.Context, session *webauthn.SessionData) error {
|
||||||
|
data, err := json.Marshal(session)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal session: %w", err)
|
||||||
|
}
|
||||||
|
key := sessionPrefix + session.Challenge
|
||||||
|
return r.rdb.Set(ctx, key, data, sessionTTL).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisStore) Get(ctx context.Context, challenge string) (*webauthn.SessionData, bool, error) {
|
||||||
|
key := sessionPrefix + challenge
|
||||||
|
data, err := r.rdb.Get(ctx, key).Bytes()
|
||||||
|
if err == redis.Nil {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("redis get: %w", err)
|
||||||
|
}
|
||||||
|
var sess webauthn.SessionData
|
||||||
|
if err := json.Unmarshal(data, &sess); err != nil {
|
||||||
|
return nil, false, fmt.Errorf("unmarshal session: %w", err)
|
||||||
|
}
|
||||||
|
return &sess, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *redisStore) Delete(ctx context.Context, challenge string) error {
|
||||||
|
key := sessionPrefix + challenge
|
||||||
|
return r.rdb.Del(ctx, key).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service WebAuthn 服务:凭据存储 + challenge 会话。
|
||||||
|
type Service struct {
|
||||||
|
wa *webauthn.WebAuthn
|
||||||
|
db *gorm.DB
|
||||||
|
sessions SessionStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db *gorm.DB, cfg Config) (*Service, error) {
|
||||||
|
wa, err := webauthn.New(&webauthn.Config{
|
||||||
|
RPDisplayName: cfg.Name,
|
||||||
|
RPID: cfg.RPID,
|
||||||
|
RPOrigins: cfg.Origins,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据配置选择存储后端
|
||||||
|
var store SessionStore
|
||||||
|
if cfg.Redis != nil {
|
||||||
|
store = newRedisStore(cfg.Redis)
|
||||||
|
} else {
|
||||||
|
store = newMemoryStore()
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Service{wa: wa, db: db, sessions: store}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// webUser 实现 go-webauthn 的 User 接口。
|
||||||
|
type webUser struct {
|
||||||
|
id uint64
|
||||||
|
name string
|
||||||
|
displayName string
|
||||||
|
credentials []webauthn.Credential
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *webUser) WebAuthnID() []byte { return []byte(strconv.FormatUint(u.id, 10)) }
|
||||||
|
func (u *webUser) WebAuthnName() string { return u.name }
|
||||||
|
func (u *webUser) WebAuthnDisplayName() string { return u.displayName }
|
||||||
|
func (u *webUser) WebAuthnIcon() string { return "" }
|
||||||
|
func (u *webUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
|
||||||
|
|
||||||
|
func (s *Service) loadWebUser(u *store.User) (*webUser, error) {
|
||||||
|
var pks []store.Passkey
|
||||||
|
s.db.Where("user_id = ?", u.ID).Find(&pks)
|
||||||
|
creds := make([]webauthn.Credential, 0, len(pks))
|
||||||
|
for _, pk := range pks {
|
||||||
|
var c webauthn.Credential
|
||||||
|
if err := json.Unmarshal(pk.Credential, &c); err == nil {
|
||||||
|
creds = append(creds, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &webUser{id: u.ID, name: u.Username, displayName: u.Username, credentials: creds}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByUsername 通过用户名或邮箱查找用户
|
||||||
|
func (s *Service) GetUserByUsername(username string) (*store.User, error) {
|
||||||
|
var u store.User
|
||||||
|
if err := s.db.Where("username = ? OR email = ?", username, username).First(&u).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByID 通过 ID 查找用户
|
||||||
|
func (s *Service) GetUserByID(id uint64) (*store.User, error) {
|
||||||
|
var u store.User
|
||||||
|
if err := s.db.First(&u, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 注册
|
||||||
|
|
||||||
|
// BeginRegistration 生成注册选项并暂存 challenge。
|
||||||
|
func (s *Service) BeginRegistration(u *store.User) (*protocol.CredentialCreation, error) {
|
||||||
|
wu, err := s.loadWebUser(u)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
creation, session, err := s.wa.BeginRegistration(wu)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.sessions.Set(context.Background(), session); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return creation, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishRegistration 校验浏览器返回的凭据并落库。
|
||||||
|
func (s *Service) FinishRegistration(u *store.User, challenge string, body []byte, name string) error {
|
||||||
|
session, ok, err := s.sessions.Get(context.Background(), challenge)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return errors.New("challenge 已过期或不存在")
|
||||||
|
}
|
||||||
|
// 删除已使用的 challenge
|
||||||
|
_ = s.sessions.Delete(context.Background(), challenge)
|
||||||
|
|
||||||
|
wu, err := s.loadWebUser(u)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||||
|
cred, err := s.wa.FinishRegistration(wu, *session, req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(cred)
|
||||||
|
nm := name
|
||||||
|
if nm == "" {
|
||||||
|
nm = "passkey"
|
||||||
|
}
|
||||||
|
return s.db.Create(&store.Passkey{
|
||||||
|
UserID: u.ID, Name: nm, CredentialID: cred.ID, Credential: raw,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 登录
|
||||||
|
|
||||||
|
// BeginLogin 已知用户(按用户名)发起断言。
|
||||||
|
func (s *Service) BeginLogin(u *store.User) (*protocol.CredentialAssertion, error) {
|
||||||
|
wu, err := s.loadWebUser(u)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
assertion, session, err := s.wa.BeginLogin(wu)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.sessions.Set(context.Background(), session); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return assertion, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeginDiscoverableLogin 无用户名(使用平台/漫游器上的可发现凭据)。
|
||||||
|
func (s *Service) BeginDiscoverableLogin() (*protocol.CredentialAssertion, error) {
|
||||||
|
assertion, session, err := s.wa.BeginDiscoverableLogin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.sessions.Set(context.Background(), session); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return assertion, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishLogin 校验断言并更新签名计数。
|
||||||
|
func (s *Service) FinishLogin(u *store.User, challenge string, body []byte) error {
|
||||||
|
session, ok, err := s.sessions.Get(context.Background(), challenge)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return errors.New("challenge 已过期或不存在")
|
||||||
|
}
|
||||||
|
_ = s.sessions.Delete(context.Background(), challenge)
|
||||||
|
|
||||||
|
wu, err := s.loadWebUser(u)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||||
|
cred, err := s.wa.FinishLogin(wu, *session, req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.updateCredential(u.ID, cred)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishDiscoverableLogin 通过凭据定位用户并校验断言。
|
||||||
|
func (s *Service) FinishDiscoverableLogin(challenge string, body []byte) (*store.User, error) {
|
||||||
|
session, ok, err := s.sessions.Get(context.Background(), challenge)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("challenge 已过期或不存在")
|
||||||
|
}
|
||||||
|
_ = s.sessions.Delete(context.Background(), challenge)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||||
|
|
||||||
|
// 先通过 credential_id 反查用户
|
||||||
|
var pks []store.Passkey
|
||||||
|
if err := s.db.Find(&pks).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 遍历所有 passkey 找到匹配的
|
||||||
|
for _, pk := range pks {
|
||||||
|
var c webauthn.Credential
|
||||||
|
if err := json.Unmarshal(pk.Credential, &c); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 尝试用这个用户的凭据进行登录
|
||||||
|
var u store.User
|
||||||
|
if err := s.db.First(&u, pk.UserID).Error; err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wu, err := s.loadWebUser(&u)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cred, err := s.wa.FinishLogin(wu, *session, req)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = s.updateCredential(u.ID, cred)
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("no matching passkey found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 管理
|
||||||
|
|
||||||
|
// List 列出用户的 passkey。
|
||||||
|
func (s *Service) List(userID uint64) ([]store.Passkey, error) {
|
||||||
|
var pks []store.Passkey
|
||||||
|
err := s.db.Where("user_id = ?", userID).Order("id DESC").Find(&pks).Error
|
||||||
|
return pks, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除用户的 passkey。
|
||||||
|
func (s *Service) Delete(userID, id uint64) error {
|
||||||
|
res := s.db.Where("id = ? AND user_id = ?", id, userID).Delete(&store.Passkey{})
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) updateCredential(userID uint64, cred *webauthn.Credential) error {
|
||||||
|
raw, _ := json.Marshal(cred)
|
||||||
|
return s.db.Model(&store.Passkey{}).
|
||||||
|
Where("user_id = ? AND credential_id = ?", userID, cred.ID).
|
||||||
|
Update("credential", raw).Error
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package apikey
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"opencatd-open/internal/pkg/crypto"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Prefix = "sk-ot-"
|
||||||
|
|
||||||
|
// Generate 生成新的 API Key,返回明文和哈希
|
||||||
|
func Generate() (plaintext, hash string) {
|
||||||
|
b := make([]byte, 24)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
raw := hex.EncodeToString(b)
|
||||||
|
plaintext = Prefix + raw
|
||||||
|
hash = crypto.Sha256Hex(plaintext)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid 校验 API Key 格式
|
||||||
|
func Valid(key string) bool {
|
||||||
|
return strings.HasPrefix(key, Prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash 计算 API Key 的 SHA-256 哈希
|
||||||
|
func Hash(key string) string {
|
||||||
|
return crypto.Sha256Hex(key)
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func defaultKey() []byte {
|
||||||
|
key := os.Getenv("ENCRYPT_KEY")
|
||||||
|
if key == "" {
|
||||||
|
key = "opencatd-default-key-change-me"
|
||||||
|
}
|
||||||
|
h := sha256.Sum256([]byte(key))
|
||||||
|
return h[:] // 32 bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt encrypts plaintext using AES-GCM with the default key
|
||||||
|
func Encrypt(plaintext string) (string, error) {
|
||||||
|
enc, err := NewEncryptor(defaultKey())
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return enc.Encrypt(plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt decrypts ciphertext using AES-GCM with the default key
|
||||||
|
func Decrypt(encoded string) (string, error) {
|
||||||
|
enc, err := NewEncryptor(defaultKey())
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return enc.Decrypt(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sha256Hex is a convenience wrapper for SHA-256 hex hashing
|
||||||
|
func Sha256Hex(data string) string {
|
||||||
|
h := sha256.Sum256([]byte(data))
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encryptor AES-GCM 加密器
|
||||||
|
type Encryptor struct {
|
||||||
|
key []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEncryptor 创建加密器(key 为 16/24/32 字节)
|
||||||
|
func NewEncryptor(key []byte) (*Encryptor, error) {
|
||||||
|
switch len(key) {
|
||||||
|
case 16, 24, 32:
|
||||||
|
default:
|
||||||
|
return nil, errors.New("crypto: invalid key length, must be 16, 24, or 32 bytes")
|
||||||
|
}
|
||||||
|
return &Encryptor{key: key}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt AES-GCM 加密,返回 base64 编码的密文
|
||||||
|
func (e *Encryptor) Encrypt(plaintext string) (string, error) {
|
||||||
|
block, err := aes.NewCipher(e.key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||||
|
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt AES-GCM 解密
|
||||||
|
func (e *Encryptor) Decrypt(encoded string) (string, error) {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(e.key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
nonceSize := gcm.NonceSize()
|
||||||
|
if len(data) < nonceSize {
|
||||||
|
return "", errors.New("ciphertext too short")
|
||||||
|
}
|
||||||
|
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(plaintext), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package crypto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEncryptDecrypt(t *testing.T) {
|
||||||
|
plaintext := "sk-test-api-key-12345"
|
||||||
|
|
||||||
|
encrypted, err := Encrypt(plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encrypt() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if encrypted == plaintext {
|
||||||
|
t.Error("Encrypt() returned plaintext")
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := Decrypt(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decrypt() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if decrypted != plaintext {
|
||||||
|
t.Errorf("Decrypt() = %q, want %q", decrypted, plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSha256Hex(t *testing.T) {
|
||||||
|
input := "test"
|
||||||
|
result := Sha256Hex(input)
|
||||||
|
|
||||||
|
if len(result) != 64 {
|
||||||
|
t.Errorf("Sha256Hex() returned %d chars, want 64", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same input should produce same hash
|
||||||
|
result2 := Sha256Hex(input)
|
||||||
|
if result != result2 {
|
||||||
|
t.Error("Sha256Hex() not deterministic")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different input should produce different hash
|
||||||
|
result3 := Sha256Hex("different")
|
||||||
|
if result == result3 {
|
||||||
|
t.Error("Sha256Hex() same hash for different inputs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptorInvalidKey(t *testing.T) {
|
||||||
|
_, err := NewEncryptor([]byte("short"))
|
||||||
|
if err == nil {
|
||||||
|
t.Error("NewEncryptor() should error with invalid key length")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package jwt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
gojwt "github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID uint64 `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
gojwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateTokenPair 生成 access + refresh token
|
||||||
|
func GenerateTokenPair(userID uint64, name, role, secret string, accessExpire, refreshExpire time.Duration) (accessToken, refreshToken string, err error) {
|
||||||
|
accessToken, err = generateToken(userID, name, role, "access", secret, accessExpire)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
refreshToken, err = generateToken(userID, name, role, "refresh", secret, refreshExpire)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateToken(userID uint64, name, role, tokenType, secret string, expire time.Duration) (string, error) {
|
||||||
|
now := time.Now()
|
||||||
|
claims := Claims{
|
||||||
|
UserID: userID,
|
||||||
|
Name: name,
|
||||||
|
Role: role,
|
||||||
|
RegisteredClaims: gojwt.RegisteredClaims{
|
||||||
|
ExpiresAt: gojwt.NewNumericDate(now.Add(expire)),
|
||||||
|
IssuedAt: gojwt.NewNumericDate(now),
|
||||||
|
NotBefore: gojwt.NewNumericDate(now),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
token := gojwt.NewWithClaims(gojwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateToken 校验 JWT
|
||||||
|
func ValidateToken(tokenString, secret string) (*Claims, error) {
|
||||||
|
token, err := gojwt.ParseWithClaims(tokenString, &Claims{}, func(token *gojwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*gojwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, errors.New("unexpected signing method")
|
||||||
|
}
|
||||||
|
return []byte(secret), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
return nil, gojwt.ErrInvalidKey
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Limiter 内存限流器
|
||||||
|
type Limiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
// 每用户每秒请求数
|
||||||
|
userRPS map[uint64]*tokenBucket
|
||||||
|
|
||||||
|
// 密钥每日请求计数
|
||||||
|
keyDailyReq map[uint64]*dailyCounter
|
||||||
|
|
||||||
|
// 密钥每日 token 计数
|
||||||
|
keyDailyTokens map[uint64]*dailyCounter
|
||||||
|
}
|
||||||
|
|
||||||
|
type tokenBucket struct {
|
||||||
|
tokens float64
|
||||||
|
maxTokens float64
|
||||||
|
refillRate float64
|
||||||
|
lastRefill time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type dailyCounter struct {
|
||||||
|
date string
|
||||||
|
count int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Limiter {
|
||||||
|
return &Limiter{
|
||||||
|
userRPS: make(map[uint64]*tokenBucket),
|
||||||
|
keyDailyReq: make(map[uint64]*dailyCounter),
|
||||||
|
keyDailyTokens: make(map[uint64]*dailyCounter),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowRequest 检查用户级每秒请求限制
|
||||||
|
func (l *Limiter) AllowRequest(userID uint64, rps int) bool {
|
||||||
|
if rps <= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
bucket, ok := l.userRPS[userID]
|
||||||
|
if !ok {
|
||||||
|
bucket = &tokenBucket{
|
||||||
|
tokens: float64(rps),
|
||||||
|
maxTokens: float64(rps),
|
||||||
|
refillRate: float64(rps),
|
||||||
|
lastRefill: time.Now(),
|
||||||
|
}
|
||||||
|
l.userRPS[userID] = bucket
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
elapsed := now.Sub(bucket.lastRefill).Seconds()
|
||||||
|
bucket.tokens += elapsed * bucket.refillRate
|
||||||
|
if bucket.tokens > bucket.maxTokens {
|
||||||
|
bucket.tokens = bucket.maxTokens
|
||||||
|
}
|
||||||
|
bucket.lastRefill = now
|
||||||
|
|
||||||
|
if bucket.tokens < 1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
bucket.tokens--
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowRequestDaily 检查密钥每日请求配额
|
||||||
|
func (l *Limiter) AllowRequestDaily(keyID uint64, quota int) bool {
|
||||||
|
if quota <= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
counter, ok := l.keyDailyReq[keyID]
|
||||||
|
if !ok || counter.date != today {
|
||||||
|
l.keyDailyReq[keyID] = &dailyCounter{date: today, count: 1}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if counter.count >= int64(quota) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
counter.count++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokensUsed 返回密钥今日 token 用量
|
||||||
|
func (l *Limiter) TokensUsed(keyID uint64) int64 {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
counter, ok := l.keyDailyTokens[keyID]
|
||||||
|
if !ok || counter.date != today {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return counter.count
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTokens 累加密钥今日 token 用量
|
||||||
|
func (l *Limiter) AddTokens(keyID uint64, tokens int64) {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
counter, ok := l.keyDailyTokens[keyID]
|
||||||
|
if !ok || counter.date != today {
|
||||||
|
l.keyDailyTokens[keyID] = &dailyCounter{date: today, count: tokens}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
counter.count += tokens
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package resp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Error 按 OpenAI 格式返回错误
|
||||||
|
func Error(c *gin.Context, status int, message string) {
|
||||||
|
c.AbortWithStatusJSON(status, gin.H{
|
||||||
|
"error": gin.H{
|
||||||
|
"message": message,
|
||||||
|
"type": "api_error",
|
||||||
|
"param": nil,
|
||||||
|
"code": nil,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorWithType 按 OpenAI 格式返回带类型的错误
|
||||||
|
func ErrorWithType(c *gin.Context, status int, errType, code, message string) {
|
||||||
|
c.AbortWithStatusJSON(status, gin.H{
|
||||||
|
"error": gin.H{
|
||||||
|
"message": message,
|
||||||
|
"type": errType,
|
||||||
|
"param": nil,
|
||||||
|
"code": code,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorAsAnthropic 按 Anthropic 格式返回错误
|
||||||
|
func ErrorAsAnthropic(c *gin.Context, status int, errType, message string) {
|
||||||
|
c.AbortWithStatusJSON(status, gin.H{
|
||||||
|
"type": "error",
|
||||||
|
"error": gin.H{
|
||||||
|
"type": errType,
|
||||||
|
"message": message,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// OK 返回成功 JSON
|
||||||
|
func OK(c *gin.Context, data interface{}) {
|
||||||
|
c.JSON(http.StatusOK, data)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package tokenizer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pkoukk/tiktoken-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Count 计算字符串的 token 数量
|
||||||
|
func Count(text, model string) int {
|
||||||
|
tkm, err := tiktoken.EncodingForModel(model)
|
||||||
|
if err != nil {
|
||||||
|
tkm, _ = tiktoken.GetEncoding("cl100k_base")
|
||||||
|
}
|
||||||
|
return len(tkm.Encode(text, nil, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cost 计算模型调用成本(USD,按每百万 token 定价)
|
||||||
|
func Cost(model string, inputTokens, outputTokens int) float64 {
|
||||||
|
var inputPrice, outputPrice float64
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.Contains(model, "gpt-4o-mini"):
|
||||||
|
inputPrice = 0.15
|
||||||
|
outputPrice = 0.60
|
||||||
|
case strings.Contains(model, "gpt-4o"):
|
||||||
|
inputPrice = 2.50
|
||||||
|
outputPrice = 10.00
|
||||||
|
case strings.Contains(model, "gpt-4-turbo"):
|
||||||
|
inputPrice = 10.00
|
||||||
|
outputPrice = 30.00
|
||||||
|
case strings.Contains(model, "gpt-4"):
|
||||||
|
inputPrice = 30.00
|
||||||
|
outputPrice = 60.00
|
||||||
|
case strings.Contains(model, "gpt-3.5-turbo"):
|
||||||
|
inputPrice = 0.50
|
||||||
|
outputPrice = 1.50
|
||||||
|
case strings.Contains(model, "claude-3-5-sonnet"):
|
||||||
|
inputPrice = 3.00
|
||||||
|
outputPrice = 15.00
|
||||||
|
case strings.Contains(model, "claude-3-opus"):
|
||||||
|
inputPrice = 15.00
|
||||||
|
outputPrice = 75.00
|
||||||
|
case strings.Contains(model, "claude-3-haiku"):
|
||||||
|
inputPrice = 0.25
|
||||||
|
outputPrice = 1.25
|
||||||
|
case strings.Contains(model, "claude"):
|
||||||
|
inputPrice = 8.00
|
||||||
|
outputPrice = 24.00
|
||||||
|
case strings.Contains(model, "gemini-1.5-pro"):
|
||||||
|
inputPrice = 3.50
|
||||||
|
outputPrice = 10.50
|
||||||
|
case strings.Contains(model, "gemini-1.5-flash"):
|
||||||
|
inputPrice = 0.35
|
||||||
|
outputPrice = 0.53
|
||||||
|
case strings.Contains(model, "gemini"):
|
||||||
|
inputPrice = 0.50
|
||||||
|
outputPrice = 1.50
|
||||||
|
default:
|
||||||
|
inputPrice = 0.15
|
||||||
|
outputPrice = 0.60
|
||||||
|
}
|
||||||
|
|
||||||
|
cost := float64(inputTokens)/1e6*inputPrice + float64(outputTokens)/1e6*outputPrice
|
||||||
|
if cost < 0.000001 {
|
||||||
|
cost = 0.000001
|
||||||
|
}
|
||||||
|
return cost
|
||||||
|
}
|
||||||
|
|
||||||
|
// CostWithModel 从数据库模型记录获取定价
|
||||||
|
func CostWithModel(inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens int64, inputPrice, outputPrice, cacheReadPrice float64) float64 {
|
||||||
|
cost := float64(inputTokens)/1e6*inputPrice +
|
||||||
|
float64(outputTokens)/1e6*outputPrice +
|
||||||
|
float64(cacheReadTokens)/1e6*cacheReadPrice +
|
||||||
|
float64(cacheCreationTokens)/1e6*inputPrice*1.25
|
||||||
|
if cost < 0.000001 {
|
||||||
|
cost = 0.000001
|
||||||
|
}
|
||||||
|
return cost
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
_ = fmt.Sprintf // ensure fmt is used
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package convert
|
||||||
|
|
||||||
|
// ChatCompletionRequest represents an OpenAI Chat Completions request
|
||||||
|
type ChatCompletionRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []Message `json:"messages"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
|
N *int `json:"n,omitempty"`
|
||||||
|
Stream bool `json:"stream,omitempty"`
|
||||||
|
Stop interface{} `json:"stop,omitempty"`
|
||||||
|
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||||
|
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
|
||||||
|
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
|
||||||
|
LogitBias map[string]int `json:"logit_bias,omitempty"`
|
||||||
|
User string `json:"user,omitempty"`
|
||||||
|
Tools []Tool `json:"tools,omitempty"`
|
||||||
|
ToolChoice interface{} `json:"tool_choice,omitempty"`
|
||||||
|
ResponseFormat interface{} `json:"response_format,omitempty"`
|
||||||
|
Seed *int `json:"seed,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatCompletionResponse represents an OpenAI Chat Completions response
|
||||||
|
type ChatCompletionResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Choices []Choice `json:"choices"`
|
||||||
|
Usage *Usage `json:"usage,omitempty"`
|
||||||
|
SystemFingerprint string `json:"system_fingerprint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Choice struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Message Message `json:"message"`
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatCompletionStreamChunk represents a streaming chunk
|
||||||
|
type ChatCompletionStreamChunk struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Choices []StreamChoice `json:"choices"`
|
||||||
|
Usage *Usage `json:"usage,omitempty"`
|
||||||
|
SystemFingerprint string `json:"system_fingerprint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StreamChoice struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Delta StreamDelta `json:"delta"`
|
||||||
|
FinishReason *string `json:"finish_reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StreamDelta struct {
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package convert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChatToMessages converts a Chat Completions request to Anthropic Messages format
|
||||||
|
func ChatToMessages(req *ChatCompletionRequest) (*MessagesRequest, error) {
|
||||||
|
msgs := make([]Message, 0, len(req.Messages))
|
||||||
|
var systemParts []ContentPart
|
||||||
|
|
||||||
|
for _, m := range req.Messages {
|
||||||
|
if m.Role == "system" {
|
||||||
|
// Extract system message content
|
||||||
|
switch v := m.Content.(type) {
|
||||||
|
case string:
|
||||||
|
systemParts = append(systemParts, ContentPart{
|
||||||
|
Type: "text",
|
||||||
|
Text: v,
|
||||||
|
})
|
||||||
|
case []interface{}:
|
||||||
|
for _, part := range v {
|
||||||
|
if p, ok := part.(map[string]interface{}); ok {
|
||||||
|
if t, ok := p["type"].(string); ok && t == "text" {
|
||||||
|
if text, ok := p["text"].(string); ok {
|
||||||
|
systemParts = append(systemParts, ContentPart{
|
||||||
|
Type: "text",
|
||||||
|
Text: text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msgs = append(msgs, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := &MessagesRequest{
|
||||||
|
Model: req.Model,
|
||||||
|
Messages: msgs,
|
||||||
|
Stream: req.Stream,
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(systemParts) > 0 {
|
||||||
|
out.System = systemParts
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.MaxTokens != nil {
|
||||||
|
out.MaxTokens = *req.MaxTokens
|
||||||
|
} else {
|
||||||
|
defaultMax := 4096
|
||||||
|
out.MaxTokens = defaultMax
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Temperature != nil {
|
||||||
|
out.Temperature = req.Temperature
|
||||||
|
}
|
||||||
|
if req.TopP != nil {
|
||||||
|
out.TopP = req.TopP
|
||||||
|
}
|
||||||
|
if req.Tools != nil {
|
||||||
|
out.Tools = req.Tools
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessagesToChat converts an Anthropic Messages response to Chat Completions format
|
||||||
|
func MessagesToChat(resp *MessagesResponse) (*ChatCompletionResponse, error) {
|
||||||
|
choices := make([]Choice, 0)
|
||||||
|
|
||||||
|
for _, block := range resp.Content {
|
||||||
|
switch block.Type {
|
||||||
|
case "text":
|
||||||
|
choices = append(choices, Choice{
|
||||||
|
Index: len(choices),
|
||||||
|
Message: Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: block.Text,
|
||||||
|
},
|
||||||
|
FinishReason: mapStopReason(resp.StopReason),
|
||||||
|
})
|
||||||
|
case "tool_use":
|
||||||
|
toolCall := ToolCall{
|
||||||
|
ID: block.ID,
|
||||||
|
Type: "function",
|
||||||
|
Function: FunctionCall{
|
||||||
|
Name: block.Name,
|
||||||
|
Arguments: toJSON(block.Input),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if len(choices) == 0 {
|
||||||
|
choices = append(choices, Choice{
|
||||||
|
Index: 0,
|
||||||
|
Message: Message{
|
||||||
|
Role: "assistant",
|
||||||
|
ToolCalls: []ToolCall{toolCall},
|
||||||
|
},
|
||||||
|
FinishReason: "tool_calls",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
choices[0].Message.ToolCalls = append(choices[0].Message.ToolCalls, toolCall)
|
||||||
|
choices[0].FinishReason = "tool_calls"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(choices) == 0 {
|
||||||
|
choices = append(choices, Choice{
|
||||||
|
Index: 0,
|
||||||
|
Message: Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "",
|
||||||
|
},
|
||||||
|
FinishReason: "stop",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ChatCompletionResponse{
|
||||||
|
ID: resp.ID,
|
||||||
|
Object: "chat.completion",
|
||||||
|
Model: resp.Model,
|
||||||
|
Choices: choices,
|
||||||
|
Usage: &Usage{
|
||||||
|
PromptTokens: resp.Usage.PromptTokens,
|
||||||
|
CompletionTokens: resp.Usage.CompletionTokens,
|
||||||
|
TotalTokens: resp.Usage.PromptTokens + resp.Usage.CompletionTokens,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessagesStreamToChatStream converts Anthropic streaming chunks to Chat Completions format
|
||||||
|
func MessagesStreamToChatStream(anthropicEvents []AnthropicStreamEvent, model string) []ChatCompletionStreamChunk {
|
||||||
|
var chunks []ChatCompletionStreamChunk
|
||||||
|
id := fmt.Sprintf("chatcmpl-%d", len(anthropicEvents))
|
||||||
|
|
||||||
|
for _, event := range anthropicEvents {
|
||||||
|
switch event.Type {
|
||||||
|
case "message_start":
|
||||||
|
// Initial chunk with role
|
||||||
|
chunks = append(chunks, ChatCompletionStreamChunk{
|
||||||
|
ID: id,
|
||||||
|
Object: "chat.completion.chunk",
|
||||||
|
Model: model,
|
||||||
|
Choices: []StreamChoice{{
|
||||||
|
Index: 0,
|
||||||
|
Delta: StreamDelta{
|
||||||
|
Role: "assistant",
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
case "content_block_delta":
|
||||||
|
if event.Delta != nil && event.Delta.Text != "" {
|
||||||
|
chunks = append(chunks, ChatCompletionStreamChunk{
|
||||||
|
ID: id,
|
||||||
|
Object: "chat.completion.chunk",
|
||||||
|
Model: model,
|
||||||
|
Choices: []StreamChoice{{
|
||||||
|
Index: 0,
|
||||||
|
Delta: StreamDelta{
|
||||||
|
Content: event.Delta.Text,
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case "message_delta":
|
||||||
|
finishReason := "stop"
|
||||||
|
if event.Delta != nil && event.Delta.StopReason != "" {
|
||||||
|
finishReason = mapStopReason(event.Delta.StopReason)
|
||||||
|
}
|
||||||
|
chunk := ChatCompletionStreamChunk{
|
||||||
|
ID: id,
|
||||||
|
Object: "chat.completion.chunk",
|
||||||
|
Model: model,
|
||||||
|
Choices: []StreamChoice{{
|
||||||
|
Index: 0,
|
||||||
|
FinishReason: &finishReason,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if event.Usage != nil {
|
||||||
|
chunk.Usage = event.Usage
|
||||||
|
}
|
||||||
|
chunks = append(chunks, chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapStopReason(reason string) string {
|
||||||
|
switch reason {
|
||||||
|
case "end_turn", "stop_sequence":
|
||||||
|
return "stop"
|
||||||
|
case "tool_use":
|
||||||
|
return "tool_calls"
|
||||||
|
case "max_tokens":
|
||||||
|
return "length"
|
||||||
|
default:
|
||||||
|
return "stop"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toJSON(v interface{}) string {
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
package convert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChatToResponses converts a Chat Completions request to Responses API format
|
||||||
|
func ChatToResponses(req *ChatCompletionRequest) (*ResponsesRequest, error) {
|
||||||
|
var inputItems []InputItem
|
||||||
|
var instructions string
|
||||||
|
|
||||||
|
for _, m := range req.Messages {
|
||||||
|
if m.Role == "system" {
|
||||||
|
if s, ok := m.Content.(string); ok {
|
||||||
|
if instructions != "" {
|
||||||
|
instructions += "\n\n"
|
||||||
|
}
|
||||||
|
instructions += s
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
item := InputItem{
|
||||||
|
Role: m.Role,
|
||||||
|
Content: m.Content,
|
||||||
|
}
|
||||||
|
inputItems = append(inputItems, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := &ResponsesRequest{
|
||||||
|
Model: req.Model,
|
||||||
|
Input: marshalInputItems(inputItems),
|
||||||
|
Instructions: instructions,
|
||||||
|
Stream: req.Stream,
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.MaxTokens != nil {
|
||||||
|
out.MaxOutputTokens = req.MaxTokens
|
||||||
|
}
|
||||||
|
if req.Temperature != nil {
|
||||||
|
out.Temperature = req.Temperature
|
||||||
|
}
|
||||||
|
if req.TopP != nil {
|
||||||
|
out.TopP = req.TopP
|
||||||
|
}
|
||||||
|
if req.Tools != nil {
|
||||||
|
out.Tools = req.Tools
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResponsesToChat converts a Responses API response to Chat Completions format
|
||||||
|
func ResponsesToChat(resp *ResponsesResponse) (*ChatCompletionResponse, error) {
|
||||||
|
choices := make([]Choice, 0)
|
||||||
|
|
||||||
|
for _, output := range resp.Output {
|
||||||
|
switch output.Type {
|
||||||
|
case "message":
|
||||||
|
for _, content := range output.Content {
|
||||||
|
switch content.Type {
|
||||||
|
case "output_text":
|
||||||
|
choices = append(choices, Choice{
|
||||||
|
Index: len(choices),
|
||||||
|
Message: Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: content.Text,
|
||||||
|
},
|
||||||
|
FinishReason: "stop",
|
||||||
|
})
|
||||||
|
case "function_call":
|
||||||
|
toolCall := ToolCall{
|
||||||
|
ID: content.ID,
|
||||||
|
Type: "function",
|
||||||
|
Function: FunctionCall{
|
||||||
|
Name: content.Name,
|
||||||
|
Arguments: toJSON(content.Input),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if len(choices) == 0 {
|
||||||
|
choices = append(choices, Choice{
|
||||||
|
Index: 0,
|
||||||
|
Message: Message{
|
||||||
|
Role: "assistant",
|
||||||
|
ToolCalls: []ToolCall{toolCall},
|
||||||
|
},
|
||||||
|
FinishReason: "tool_calls",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
choices[0].Message.ToolCalls = append(choices[0].Message.ToolCalls, toolCall)
|
||||||
|
choices[0].FinishReason = "tool_calls"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "function_call_output":
|
||||||
|
// This would be in a user message context
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(choices) == 0 {
|
||||||
|
choices = append(choices, Choice{
|
||||||
|
Index: 0,
|
||||||
|
Message: Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "",
|
||||||
|
},
|
||||||
|
FinishReason: "stop",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ChatCompletionResponse{
|
||||||
|
ID: resp.ID,
|
||||||
|
Object: "chat.completion",
|
||||||
|
Model: resp.Model,
|
||||||
|
Choices: choices,
|
||||||
|
Usage: &Usage{
|
||||||
|
PromptTokens: resp.Usage.PromptTokens,
|
||||||
|
CompletionTokens: resp.Usage.CompletionTokens,
|
||||||
|
TotalTokens: resp.Usage.PromptTokens + resp.Usage.CompletionTokens,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResponsesStreamToChatStream converts Responses API streaming to Chat Completions format
|
||||||
|
func ResponsesStreamToChatStream(events []ResponsesStreamEvent, model string) []ChatCompletionStreamChunk {
|
||||||
|
var chunks []ChatCompletionStreamChunk
|
||||||
|
id := fmt.Sprintf("chatcmpl-%d", len(events))
|
||||||
|
|
||||||
|
for _, event := range events {
|
||||||
|
switch event.Type {
|
||||||
|
case "response.created":
|
||||||
|
chunks = append(chunks, ChatCompletionStreamChunk{
|
||||||
|
ID: id,
|
||||||
|
Object: "chat.completion.chunk",
|
||||||
|
Model: model,
|
||||||
|
Choices: []StreamChoice{{
|
||||||
|
Index: 0,
|
||||||
|
Delta: StreamDelta{
|
||||||
|
Role: "assistant",
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
case "response.output_item.added":
|
||||||
|
if event.Item != nil && event.Item.Type == "message" {
|
||||||
|
chunks = append(chunks, ChatCompletionStreamChunk{
|
||||||
|
ID: id,
|
||||||
|
Object: "chat.completion.chunk",
|
||||||
|
Model: model,
|
||||||
|
Choices: []StreamChoice{{
|
||||||
|
Index: 0,
|
||||||
|
Delta: StreamDelta{
|
||||||
|
Role: "assistant",
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case "response.content_part.delta":
|
||||||
|
if event.Delta != "" {
|
||||||
|
chunks = append(chunks, ChatCompletionStreamChunk{
|
||||||
|
ID: id,
|
||||||
|
Object: "chat.completion.chunk",
|
||||||
|
Model: model,
|
||||||
|
Choices: []StreamChoice{{
|
||||||
|
Index: 0,
|
||||||
|
Delta: StreamDelta{
|
||||||
|
Content: event.Delta,
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
case "response.completed":
|
||||||
|
finishReason := "stop"
|
||||||
|
chunk := ChatCompletionStreamChunk{
|
||||||
|
ID: id,
|
||||||
|
Object: "chat.completion.chunk",
|
||||||
|
Model: model,
|
||||||
|
Choices: []StreamChoice{{
|
||||||
|
Index: 0,
|
||||||
|
FinishReason: &finishReason,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
chunks = append(chunks, chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessagesToResponses converts an Anthropic Messages request to Responses API format
|
||||||
|
func MessagesToResponses(req *MessagesRequest) (*ResponsesRequest, error) {
|
||||||
|
var inputItems []InputItem
|
||||||
|
var instructions string
|
||||||
|
|
||||||
|
// Handle system message
|
||||||
|
if req.System != nil {
|
||||||
|
switch v := req.System.(type) {
|
||||||
|
case string:
|
||||||
|
instructions = v
|
||||||
|
case []ContentPart:
|
||||||
|
for _, p := range v {
|
||||||
|
if p.Type == "text" {
|
||||||
|
if instructions != "" {
|
||||||
|
instructions += "\n\n"
|
||||||
|
}
|
||||||
|
instructions += p.Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range req.Messages {
|
||||||
|
item := InputItem{
|
||||||
|
Role: m.Role,
|
||||||
|
Content: m.Content,
|
||||||
|
}
|
||||||
|
inputItems = append(inputItems, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := &ResponsesRequest{
|
||||||
|
Model: req.Model,
|
||||||
|
Input: marshalInputItems(inputItems),
|
||||||
|
Instructions: instructions,
|
||||||
|
Stream: req.Stream,
|
||||||
|
}
|
||||||
|
|
||||||
|
out.MaxOutputTokens = &req.MaxTokens
|
||||||
|
|
||||||
|
if req.Temperature != nil {
|
||||||
|
out.Temperature = req.Temperature
|
||||||
|
}
|
||||||
|
if req.TopP != nil {
|
||||||
|
out.TopP = req.TopP
|
||||||
|
}
|
||||||
|
if req.Tools != nil {
|
||||||
|
out.Tools = req.Tools
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResponsesToMessages converts a Responses API response to Anthropic Messages format
|
||||||
|
func ResponsesToMessages(resp *ResponsesResponse) (*MessagesResponse, error) {
|
||||||
|
var content []ContentBlock
|
||||||
|
|
||||||
|
for _, output := range resp.Output {
|
||||||
|
switch output.Type {
|
||||||
|
case "message":
|
||||||
|
for _, c := range output.Content {
|
||||||
|
switch c.Type {
|
||||||
|
case "output_text":
|
||||||
|
content = append(content, ContentBlock{
|
||||||
|
Type: "text",
|
||||||
|
Text: c.Text,
|
||||||
|
})
|
||||||
|
case "function_call":
|
||||||
|
content = append(content, ContentBlock{
|
||||||
|
Type: "tool_use",
|
||||||
|
ID: c.ID,
|
||||||
|
Name: c.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var stopReason string
|
||||||
|
if len(content) > 0 {
|
||||||
|
last := content[len(content)-1]
|
||||||
|
if last.Type == "tool_use" {
|
||||||
|
stopReason = "tool_use"
|
||||||
|
} else {
|
||||||
|
stopReason = "end_turn"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stopReason = "end_turn"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &MessagesResponse{
|
||||||
|
ID: resp.ID,
|
||||||
|
Type: "message",
|
||||||
|
Role: "assistant",
|
||||||
|
Content: content,
|
||||||
|
Model: resp.Model,
|
||||||
|
StopReason: stopReason,
|
||||||
|
Usage: resp.Usage,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toJSON is a helper to convert a value to JSON string
|
||||||
|
func toJSONStr(v interface{}) string {
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// 三协议互转注册表:OpenAI Chat / OpenAI Responses / Anthropic Messages。
|
||||||
|
// 网关以 Chat 形状作为标准中间模型:非跨 chat 的转换经 chat 中转。
|
||||||
|
// 请求/响应(非流式)走 JSON 转换;流式走逐行 SSE 转换(stream_transform.go)。
|
||||||
|
package convert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 协议标识。
|
||||||
|
const (
|
||||||
|
ProtoChat = "chat"
|
||||||
|
ProtoMessages = "messages"
|
||||||
|
ProtoResponses = "responses"
|
||||||
|
)
|
||||||
|
|
||||||
|
// trimBody 去掉首尾空白。部分上游(如 OpenRouter)会在 JSON 前输出空白或
|
||||||
|
// SSE 注释行再跟正文,直接 Unmarshal 会失败。
|
||||||
|
func trimBody(body []byte) []byte {
|
||||||
|
return bytes.TrimSpace(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanJSON 剥离非 JSON 前缀(空白、SSE 注释、`data:` 行)并压缩为标准 JSON。
|
||||||
|
// 部分上游(如 OpenRouter)的 non-stream 响应在 JSON 前夹带空白/注释;
|
||||||
|
// 原样透传会让客户端解析失败。找不到 JSON 对象时原样返回。
|
||||||
|
func CleanJSON(body []byte) []byte {
|
||||||
|
i := bytes.IndexByte(body, '{')
|
||||||
|
if i < 0 {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
var v any
|
||||||
|
if err := json.Unmarshal(bytes.TrimSpace(body[i:]), &v); err != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
out, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertRequest 转换请求体。from==to 时原样返回。
|
||||||
|
func ConvertRequest(body []byte, from, to string) ([]byte, error) {
|
||||||
|
if from == to {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
body = trimBody(body)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
body = trimBody(body)
|
||||||
|
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 _, p := range parts {
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if out != "" {
|
||||||
|
out += sep
|
||||||
|
}
|
||||||
|
out += p
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawOrObject 把 RawMessage 解为 map;非对象返回空对象。
|
||||||
|
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{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// intOrNil 取指针值,nil 时返回默认值。
|
||||||
|
func intOrNil(p *int, def int) any {
|
||||||
|
if p == nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
// strField 取 any 中的字符串字段。
|
||||||
|
func strField(v any) string {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package convert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChatToMessages(t *testing.T) {
|
||||||
|
maxTokens := 1024
|
||||||
|
temp := 0.7
|
||||||
|
|
||||||
|
req := &ChatCompletionRequest{
|
||||||
|
Model: "claude-3-sonnet-20240229",
|
||||||
|
Messages: []Message{
|
||||||
|
{Role: "system", Content: "You are a helpful assistant."},
|
||||||
|
{Role: "user", Content: "Hello!"},
|
||||||
|
},
|
||||||
|
MaxTokens: &maxTokens,
|
||||||
|
Temperature: &temp,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ChatToMessages(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ChatToMessages() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Model != "claude-3-sonnet-20240229" {
|
||||||
|
t.Errorf("Model = %q, want %q", result.Model, "claude-3-sonnet-20240229")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Messages) != 1 {
|
||||||
|
t.Errorf("Messages length = %d, want 1", len(result.Messages))
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Messages[0].Role != "user" {
|
||||||
|
t.Errorf("Messages[0].Role = %q, want %q", result.Messages[0].Role, "user")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.System == nil {
|
||||||
|
t.Error("System is nil, want non-nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.MaxTokens != 1024 {
|
||||||
|
t.Errorf("MaxTokens = %d, want 1024", result.MaxTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessagesToChat(t *testing.T) {
|
||||||
|
resp := &MessagesResponse{
|
||||||
|
ID: "msg-123",
|
||||||
|
Model: "claude-3-sonnet-20240229",
|
||||||
|
Content: []ContentBlock{
|
||||||
|
{Type: "text", Text: "Hello! How can I help?"},
|
||||||
|
},
|
||||||
|
StopReason: "end_turn",
|
||||||
|
Usage: Usage{
|
||||||
|
PromptTokens: 10,
|
||||||
|
CompletionTokens: 20,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := MessagesToChat(resp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MessagesToChat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.ID != "msg-123" {
|
||||||
|
t.Errorf("ID = %q, want %q", result.ID, "msg-123")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Object != "chat.completion" {
|
||||||
|
t.Errorf("Object = %q, want %q", result.Object, "chat.completion")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Choices) != 1 {
|
||||||
|
t.Errorf("Choices length = %d, want 1", len(result.Choices))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Choices[0].Message.Role != "assistant" {
|
||||||
|
t.Errorf("Choices[0].Message.Role = %q, want %q", result.Choices[0].Message.Role, "assistant")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Choices[0].Message.Content != "Hello! How can I help?" {
|
||||||
|
t.Errorf("Choices[0].Message.Content = %q, want %q", result.Choices[0].Message.Content, "Hello! How can I help?")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Choices[0].FinishReason != "stop" {
|
||||||
|
t.Errorf("FinishReason = %q, want %q", result.Choices[0].FinishReason, "stop")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Usage.TotalTokens != 30 {
|
||||||
|
t.Errorf("Usage.TotalTokens = %d, want 30", result.Usage.TotalTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatToResponses(t *testing.T) {
|
||||||
|
maxTokens := 2048
|
||||||
|
|
||||||
|
req := &ChatCompletionRequest{
|
||||||
|
Model: "gpt-4o",
|
||||||
|
Messages: []Message{
|
||||||
|
{Role: "system", Content: "You are a helpful assistant."},
|
||||||
|
{Role: "user", Content: "What is 2+2?"},
|
||||||
|
},
|
||||||
|
MaxTokens: &maxTokens,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ChatToResponses(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ChatToResponses() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Model != "gpt-4o" {
|
||||||
|
t.Errorf("Model = %q, want %q", result.Model, "gpt-4o")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Input) == 0 {
|
||||||
|
t.Errorf("Input empty, want 1 item")
|
||||||
|
} else {
|
||||||
|
var items []InputItem
|
||||||
|
if err := json.Unmarshal(result.Input, &items); err != nil {
|
||||||
|
t.Fatalf("Input unmarshal = %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Errorf("Input length = %d, want 1", len(items))
|
||||||
|
} else if items[0].Role != "user" {
|
||||||
|
t.Errorf("Input[0].Role = %q, want %q", items[0].Role, "user")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Instructions != "You are a helpful assistant." {
|
||||||
|
t.Errorf("Instructions = %q, want %q", result.Instructions, "You are a helpful assistant.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponsesToChat(t *testing.T) {
|
||||||
|
resp := &ResponsesResponse{
|
||||||
|
ID: "resp-123",
|
||||||
|
Model: "gpt-4o",
|
||||||
|
Status: "completed",
|
||||||
|
Output: []OutputItem{
|
||||||
|
{
|
||||||
|
Type: "message",
|
||||||
|
Content: []OutputContent{
|
||||||
|
{Type: "output_text", Text: "2+2 equals 4."},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Usage: Usage{
|
||||||
|
PromptTokens: 15,
|
||||||
|
CompletionTokens: 10,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ResponsesToChat(resp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResponsesToChat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.ID != "resp-123" {
|
||||||
|
t.Errorf("ID = %q, want %q", result.ID, "resp-123")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Choices) != 1 {
|
||||||
|
t.Errorf("Choices length = %d, want 1", len(result.Choices))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Choices[0].Message.Content != "2+2 equals 4." {
|
||||||
|
t.Errorf("Content = %q, want %q", result.Choices[0].Message.Content, "2+2 equals 4.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapStopReason(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"end_turn", "stop"},
|
||||||
|
{"stop_sequence", "stop"},
|
||||||
|
{"tool_use", "tool_calls"},
|
||||||
|
{"max_tokens", "length"},
|
||||||
|
{"unknown", "stop"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
result := mapStopReason(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("mapStopReason(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessagesToChatToolUse(t *testing.T) {
|
||||||
|
resp := &MessagesResponse{
|
||||||
|
ID: "msg-456",
|
||||||
|
Model: "claude-3-sonnet-20240229",
|
||||||
|
Content: []ContentBlock{
|
||||||
|
{Type: "text", Text: "Let me search for that."},
|
||||||
|
{Type: "tool_use", ID: "toolu-123", Name: "web_search"},
|
||||||
|
},
|
||||||
|
StopReason: "tool_use",
|
||||||
|
Usage: Usage{
|
||||||
|
PromptTokens: 20,
|
||||||
|
CompletionTokens: 30,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := MessagesToChat(resp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MessagesToChat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Choices) != 1 {
|
||||||
|
t.Errorf("Choices length = %d, want 1", len(result.Choices))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Choices[0].FinishReason != "tool_calls" {
|
||||||
|
t.Errorf("FinishReason = %q, want %q", result.Choices[0].FinishReason, "tool_calls")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Choices[0].Message.ToolCalls) != 1 {
|
||||||
|
t.Errorf("ToolCalls length = %d, want 1", len(result.Choices[0].Message.ToolCalls))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Choices[0].Message.ToolCalls[0].ID != "toolu-123" {
|
||||||
|
t.Errorf("ToolCall ID = %q, want %q", result.Choices[0].Message.ToolCalls[0].ID, "toolu-123")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Choices[0].Message.ToolCalls[0].Function.Name != "web_search" {
|
||||||
|
t.Errorf("Function.Name = %q, want %q", result.Choices[0].Message.ToolCalls[0].Function.Name, "web_search")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
package convert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 请求:Chat → Messages
|
||||||
|
|
||||||
|
type chatTool struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Parameters json.RawMessage `json:"parameters"`
|
||||||
|
} `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatMsg struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content json.RawMessage `json:"content"`
|
||||||
|
ToolCallID string `json:"tool_call_id"`
|
||||||
|
ToolCalls []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Function struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
} `json:"function"`
|
||||||
|
} `json:"tool_calls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatReq struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []chatMsg `json:"messages"`
|
||||||
|
Tools []chatTool `json:"tools"`
|
||||||
|
Temperature *float64 `json:"temperature"`
|
||||||
|
TopP *float64 `json:"top_p"`
|
||||||
|
MaxTokens *int `json:"max_tokens"`
|
||||||
|
Stop []string `json:"stop"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatToMessagesReq 将 OpenAI Chat 请求转为 Anthropic Messages 请求。
|
||||||
|
func chatToMessagesReq(body []byte) ([]byte, error) {
|
||||||
|
var req chatReq
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := map[string]any{
|
||||||
|
"model": req.Model,
|
||||||
|
"max_tokens": intOrNil(req.MaxTokens, 1024), // Anthropic 必填
|
||||||
|
}
|
||||||
|
if req.Stream {
|
||||||
|
out["stream"] = true
|
||||||
|
}
|
||||||
|
if req.Temperature != nil {
|
||||||
|
out["temperature"] = *req.Temperature
|
||||||
|
}
|
||||||
|
if req.TopP != nil {
|
||||||
|
out["top_p"] = *req.TopP
|
||||||
|
}
|
||||||
|
if len(req.Stop) > 0 {
|
||||||
|
out["stop_sequences"] = req.Stop
|
||||||
|
}
|
||||||
|
|
||||||
|
var system []string
|
||||||
|
msgs := make([]any, 0, len(req.Messages))
|
||||||
|
for _, m := range req.Messages {
|
||||||
|
if m.Role == "system" {
|
||||||
|
if s := str(m.Content); s != "" {
|
||||||
|
system = append(system, s)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msgs = append(msgs, chatMsgToAnthropic(m))
|
||||||
|
}
|
||||||
|
if len(system) > 0 {
|
||||||
|
out["system"] = strings.Join(system, "\n")
|
||||||
|
}
|
||||||
|
out["messages"] = msgs
|
||||||
|
|
||||||
|
if len(req.Tools) > 0 {
|
||||||
|
tools := make([]any, 0, len(req.Tools))
|
||||||
|
for _, t := range req.Tools {
|
||||||
|
var params any
|
||||||
|
if len(t.Function.Parameters) > 0 && string(t.Function.Parameters) != "null" {
|
||||||
|
_ = json.Unmarshal(t.Function.Parameters, ¶ms)
|
||||||
|
}
|
||||||
|
tools = append(tools, map[string]any{
|
||||||
|
"name": t.Function.Name,
|
||||||
|
"description": t.Function.Description,
|
||||||
|
"input_schema": params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
out["tools"] = tools
|
||||||
|
}
|
||||||
|
return json.Marshal(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatMsgToAnthropic 单条消息转 Anthropic 内容。
|
||||||
|
func chatMsgToAnthropic(m chatMsg) any {
|
||||||
|
switch m.Role {
|
||||||
|
case "assistant":
|
||||||
|
content := make([]any, 0, 2)
|
||||||
|
if s := str(m.Content); s != "" {
|
||||||
|
content = append(content, map[string]any{"type": "text", "text": s})
|
||||||
|
}
|
||||||
|
for _, tc := range m.ToolCalls {
|
||||||
|
var input any
|
||||||
|
if tc.Function.Arguments != "" {
|
||||||
|
_ = json.Unmarshal([]byte(tc.Function.Arguments), &input)
|
||||||
|
}
|
||||||
|
content = append(content, map[string]any{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": tc.ID,
|
||||||
|
"name": tc.Function.Name,
|
||||||
|
"input": input,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return map[string]any{"role": "assistant", "content": content}
|
||||||
|
case "tool":
|
||||||
|
return map[string]any{"role": "user", "content": []any{
|
||||||
|
map[string]any{"type": "tool_result", "tool_use_id": m.ToolCallID, "content": str(m.Content)},
|
||||||
|
}}
|
||||||
|
default: // user
|
||||||
|
var arr []map[string]any
|
||||||
|
if json.Unmarshal(m.Content, &arr) == nil && arr != nil {
|
||||||
|
blocks := make([]any, 0, len(arr))
|
||||||
|
for _, b := range arr {
|
||||||
|
switch b["type"] {
|
||||||
|
case "text", "input_text":
|
||||||
|
if t, _ := b["text"].(string); t != "" {
|
||||||
|
blocks = append(blocks, map[string]any{"type": "text", "text": t})
|
||||||
|
}
|
||||||
|
case "image_url":
|
||||||
|
var url string
|
||||||
|
if iu, ok := b["image_url"].(map[string]any); ok {
|
||||||
|
url, _ = iu["url"].(string)
|
||||||
|
} else if s, ok := b["image_url"].(string); ok {
|
||||||
|
url = s
|
||||||
|
}
|
||||||
|
if url != "" {
|
||||||
|
blocks = append(blocks, anthropicImageBlock(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 toolMsgs []any // tool_result 单独收集,保证排在 assistant(tool_calls) 之后
|
||||||
|
var textParts []string
|
||||||
|
var contentBlocks []any // text / image_url 块,保留原始顺序
|
||||||
|
var toolCalls []any
|
||||||
|
for _, b := range blocks {
|
||||||
|
switch b["type"] {
|
||||||
|
case "text":
|
||||||
|
if t, _ := b["text"].(string); t != "" {
|
||||||
|
textParts = append(textParts, t)
|
||||||
|
contentBlocks = append(contentBlocks, map[string]any{"type": "text", "text": t})
|
||||||
|
}
|
||||||
|
case "image":
|
||||||
|
if cb := chatImageBlock(b); cb != nil {
|
||||||
|
contentBlocks = append(contentBlocks, cb)
|
||||||
|
}
|
||||||
|
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"])
|
||||||
|
toolMsgs = append(toolMsgs, map[string]any{"role": "tool", "tool_call_id": callID, "content": res})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hasImage := false
|
||||||
|
for _, cb := range contentBlocks {
|
||||||
|
if m, _ := cb.(map[string]any); m["type"] == "image_url" {
|
||||||
|
hasImage = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasImage || len(textParts) > 0 || len(toolCalls) > 0 {
|
||||||
|
msg := map[string]any{"role": role}
|
||||||
|
switch {
|
||||||
|
case hasImage:
|
||||||
|
msg["content"] = contentBlocks
|
||||||
|
case len(textParts) > 0:
|
||||||
|
msg["content"] = strings.Join(textParts, "")
|
||||||
|
}
|
||||||
|
if len(toolCalls) > 0 {
|
||||||
|
msg["tool_calls"] = toolCalls
|
||||||
|
}
|
||||||
|
out = append(out, msg)
|
||||||
|
}
|
||||||
|
out = append(out, toolMsgs...)
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 辅助
|
||||||
|
|
||||||
|
// splitDataURL 解析 data:media_type;base64,data 形式的 URL;非该形式返回 ok=false。
|
||||||
|
func splitDataURL(url string) (media, data string, ok bool) {
|
||||||
|
if !strings.HasPrefix(url, "data:") {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
i := strings.Index(url, ";base64,")
|
||||||
|
if i < 0 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return url[len("data:"):i], url[i+len(";base64,"):], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatImageBlock 把 Anthropic image 块转 OpenAI image_url 块。
|
||||||
|
// 仅支持 base64 与 url source;其他类型(如 Files API 的 file_id)不支持,跳过。
|
||||||
|
func chatImageBlock(b map[string]any) any {
|
||||||
|
src, ok := b["source"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch src["type"] {
|
||||||
|
case "base64":
|
||||||
|
media, _ := src["media_type"].(string)
|
||||||
|
data, _ := src["data"].(string)
|
||||||
|
if data == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if media == "" {
|
||||||
|
media = "image/png"
|
||||||
|
}
|
||||||
|
return map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:" + media + ";base64," + data}}
|
||||||
|
case "url":
|
||||||
|
url, _ := src["url"].(string)
|
||||||
|
if url == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return map[string]any{"type": "image_url", "image_url": map[string]any{"url": url}}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// anthropicImageBlock 把 OpenAI image_url 的 url 转 Anthropic image 块。
|
||||||
|
// data URL → base64 source;http(s) URL → url source。
|
||||||
|
func anthropicImageBlock(url string) any {
|
||||||
|
if media, data, ok := splitDataURL(url); ok {
|
||||||
|
if media == "" {
|
||||||
|
media = "image/png"
|
||||||
|
}
|
||||||
|
return map[string]any{"type": "image", "source": map[string]any{"type": "base64", "media_type": media, "data": data}}
|
||||||
|
}
|
||||||
|
return map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": url}}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,376 @@
|
|||||||
|
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。
|
||||||
|
// input 支持字符串或条目数组(message / function_call / function_call_output)。
|
||||||
|
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":
|
||||||
|
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
|
||||||
|
var contentBlocks []any
|
||||||
|
for _, b := range blocks {
|
||||||
|
bm, ok := b.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch bm["type"] {
|
||||||
|
case "input_text", "text":
|
||||||
|
if t, _ := bm["text"].(string); t != "" {
|
||||||
|
text = append(text, t)
|
||||||
|
contentBlocks = append(contentBlocks, map[string]any{"type": "text", "text": t})
|
||||||
|
}
|
||||||
|
case "input_image":
|
||||||
|
var url string
|
||||||
|
if s, ok := bm["image_url"].(string); ok {
|
||||||
|
url = s
|
||||||
|
} else if m, ok := bm["image_url"].(map[string]any); ok {
|
||||||
|
url, _ = m["url"].(string)
|
||||||
|
}
|
||||||
|
if url != "" {
|
||||||
|
contentBlocks = append(contentBlocks, map[string]any{"type": "image_url", "image_url": map[string]any{"url": url}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hasImage := false
|
||||||
|
for _, cb := range contentBlocks {
|
||||||
|
if m, _ := cb.(map[string]any); m["type"] == "image_url" {
|
||||||
|
hasImage = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasImage {
|
||||||
|
out = append(out, map[string]any{"role": role, "content": contentBlocks})
|
||||||
|
} else {
|
||||||
|
out = append(out, map[string]any{"role": role, "content": strings.Join(text, "")})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatContentToResponsesBlocks 把 Chat 用户消息 content 转 Responses input 块数组(input_text / input_image)。
|
||||||
|
func chatContentToResponsesBlocks(content json.RawMessage) []any {
|
||||||
|
// 纯字符串 → 单个 input_text
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(content, &s) == nil && s != "" {
|
||||||
|
return []any{map[string]any{"type": "input_text", "text": s}}
|
||||||
|
}
|
||||||
|
// 数组 → 按块转换(text / image_url)
|
||||||
|
var arr []map[string]any
|
||||||
|
if json.Unmarshal(content, &arr) == nil && arr != nil {
|
||||||
|
var out []any
|
||||||
|
for _, b := range arr {
|
||||||
|
switch b["type"] {
|
||||||
|
case "text", "input_text":
|
||||||
|
if t, _ := b["text"].(string); t != "" {
|
||||||
|
out = append(out, map[string]any{"type": "input_text", "text": t})
|
||||||
|
}
|
||||||
|
case "image_url":
|
||||||
|
var url string
|
||||||
|
if iu, ok := b["image_url"].(map[string]any); ok {
|
||||||
|
url, _ = iu["url"].(string)
|
||||||
|
} else if s, ok := b["image_url"].(string); ok {
|
||||||
|
url = s
|
||||||
|
}
|
||||||
|
if url != "" {
|
||||||
|
out = append(out, map[string]any{"type": "input_image", "image_url": url})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 请求: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 blocks := chatContentToResponsesBlocks(m.Content); len(blocks) > 0 {
|
||||||
|
input = append(input, map[string]any{"type": "message", "role": "user", "content": blocks})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(system) > 0 {
|
||||||
|
out["instructions"] = strings.Join(system, "\n")
|
||||||
|
}
|
||||||
|
// input 必须是数组:部分上游只接受数组,单对象会被拒(400 Mismatch type)。
|
||||||
|
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"
|
||||||
|
switch {
|
||||||
|
case string(rawJSON(m, "status")) == `"incomplete"`:
|
||||||
|
finish = "length" // 截断优先,客户端可据此区分
|
||||||
|
case len(toolCalls) > 0:
|
||||||
|
finish = "tool_calls"
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json.Marshal(map[string]any{
|
||||||
|
"id": "resp_" + strings.TrimPrefix(r.ID, "chatcmpl-"),
|
||||||
|
"object": "response",
|
||||||
|
"model": r.Model,
|
||||||
|
"status": finish,
|
||||||
|
"output": output,
|
||||||
|
"usage": map[string]any{
|
||||||
|
"input_tokens": r.Usage.PromptTokens,
|
||||||
|
"output_tokens": r.Usage.CompletionTokens,
|
||||||
|
"total_tokens": r.Usage.PromptTokens + r.Usage.CompletionTokens,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package convert
|
||||||
|
|
||||||
|
// MessagesRequest represents an Anthropic Messages API request
|
||||||
|
type MessagesRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []Message `json:"messages"`
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
System interface{} `json:"system,omitempty"` // string or []ContentPart
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
|
TopK *int `json:"top_k,omitempty"`
|
||||||
|
StopSequences []string `json:"stop_sequences,omitempty"`
|
||||||
|
Stream bool `json:"stream,omitempty"`
|
||||||
|
Tools []Tool `json:"tools,omitempty"`
|
||||||
|
ToolChoice interface{} `json:"tool_choice,omitempty"`
|
||||||
|
Metadata interface{} `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessagesResponse represents an Anthropic Messages API response
|
||||||
|
type MessagesResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content []ContentBlock `json:"content"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
StopReason string `json:"stop_reason"`
|
||||||
|
StopSequence string `json:"stop_sequence,omitempty"`
|
||||||
|
Usage Usage `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnthropicStreamEvent represents an Anthropic streaming event
|
||||||
|
type AnthropicStreamEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Index int `json:"index,omitempty"`
|
||||||
|
Delta *Delta `json:"delta,omitempty"`
|
||||||
|
Usage *Usage `json:"usage,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package convert
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// ResponsesRequest represents an OpenAI Responses API request
|
||||||
|
type ResponsesRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Input json.RawMessage `json:"input,omitempty"`
|
||||||
|
Instructions string `json:"instructions,omitempty"`
|
||||||
|
MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
|
||||||
|
Tools []Tool `json:"tools,omitempty"`
|
||||||
|
ToolChoice interface{} `json:"tool_choice,omitempty"`
|
||||||
|
Stream bool `json:"stream,omitempty"`
|
||||||
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
|
TopP *float64 `json:"top_p,omitempty"`
|
||||||
|
Metadata interface{} `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InputItem represents a single input item
|
||||||
|
type InputItem struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content interface{} `json:"content,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// marshalInputItems 把 input 条目序列化为 Responses input 的 json.RawMessage 形态。
|
||||||
|
// Input 字段用 RawMessage 以兼容字符串与条目数组两种客户端写法。
|
||||||
|
func marshalInputItems(items []InputItem) json.RawMessage {
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(items)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResponsesResponse represents an OpenAI Responses API response
|
||||||
|
type ResponsesResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Output []OutputItem `json:"output"`
|
||||||
|
Usage Usage `json:"usage"`
|
||||||
|
Error interface{} `json:"error,omitempty"`
|
||||||
|
Incomplete *Incomplete `json:"incomplete,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OutputItem struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Content []OutputContent `json:"content,omitempty"`
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OutputContent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Input interface{} `json:"input,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Incomplete struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResponsesStreamEvent represents a Responses API streaming event
|
||||||
|
type ResponsesStreamEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Item *OutputItem `json:"item,omitempty"`
|
||||||
|
Delta string `json:"delta,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package convert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SSEWriter writes Server-Sent Events
|
||||||
|
type SSEWriter struct {
|
||||||
|
writer io.Writer
|
||||||
|
flusher http.Flusher
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSSEWriter creates a new SSE writer
|
||||||
|
func NewSSEWriter(w http.ResponseWriter) *SSEWriter {
|
||||||
|
flusher, _ := w.(http.Flusher)
|
||||||
|
return &SSEWriter{
|
||||||
|
writer: w,
|
||||||
|
flusher: flusher,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteEvent writes a single SSE event
|
||||||
|
func (w *SSEWriter) WriteEvent(event string, data interface{}) error {
|
||||||
|
var dataStr string
|
||||||
|
switch v := data.(type) {
|
||||||
|
case string:
|
||||||
|
dataStr = v
|
||||||
|
default:
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dataStr = string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := fmt.Fprintf(w.writer, "event: %s\ndata: %s\n\n", event, dataStr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.flusher != nil {
|
||||||
|
w.flusher.Flush()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteChunk writes a streaming chunk in SSE format
|
||||||
|
func (w *SSEWriter) WriteChunk(chunk interface{}) error {
|
||||||
|
b, err := json.Marshal(chunk)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = fmt.Fprintf(w.writer, "data: %s\n\n", string(b))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.flusher != nil {
|
||||||
|
w.flusher.Flush()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteDone writes the [DONE] marker
|
||||||
|
func (w *SSEWriter) WriteDone() error {
|
||||||
|
_, err := fmt.Fprintf(w.writer, "data: [DONE]\n\n")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.flusher != nil {
|
||||||
|
w.flusher.Flush()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSEParser parses Server-Sent Events from a reader
|
||||||
|
type SSEParser struct {
|
||||||
|
reader *bufio.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSSEParser creates a new SSE parser
|
||||||
|
func NewSSEParser(r io.Reader) *SSEParser {
|
||||||
|
return &SSEParser{
|
||||||
|
reader: bufio.NewReader(r),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSEEvent represents a parsed SSE event
|
||||||
|
type SSEEvent struct {
|
||||||
|
Event string
|
||||||
|
Data string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadEvent reads the next SSE event
|
||||||
|
func (p *SSEParser) ReadEvent() (*SSEEvent, error) {
|
||||||
|
event := &SSEEvent{}
|
||||||
|
|
||||||
|
for {
|
||||||
|
line, err := p.reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
line = strings.TrimRight(line, "\r\n")
|
||||||
|
|
||||||
|
if line == "" {
|
||||||
|
// Empty line means end of event
|
||||||
|
if event.Data != "" || event.Event != "" {
|
||||||
|
return event, nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(line, "event:") {
|
||||||
|
event.Event = strings.TrimSpace(line[6:])
|
||||||
|
} else if strings.HasPrefix(line, "data:") {
|
||||||
|
data := strings.TrimSpace(line[5:])
|
||||||
|
if event.Data != "" {
|
||||||
|
event.Data += "\n" + data
|
||||||
|
} else {
|
||||||
|
event.Data = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ignore comments (lines starting with :) and unknown fields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseChatStreamChunk parses an OpenAI Chat Completions streaming chunk
|
||||||
|
func ParseChatStreamChunk(data string) (*ChatCompletionStreamChunk, error) {
|
||||||
|
if data == "[DONE]" {
|
||||||
|
return nil, io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunk ChatCompletionStreamChunk
|
||||||
|
err := json.Unmarshal([]byte(data), &chunk)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &chunk, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseMessagesStreamEvent parses an Anthropic Messages streaming event
|
||||||
|
func ParseMessagesStreamEvent(data string) (*AnthropicStreamEvent, error) {
|
||||||
|
var event AnthropicStreamEvent
|
||||||
|
err := json.Unmarshal([]byte(data), &event)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &event, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseResponsesStreamChunk parses an OpenAI Responses API streaming chunk
|
||||||
|
func ParseResponsesStreamChunk(data string) (*ResponsesStreamEvent, error) {
|
||||||
|
if data == "[DONE]" {
|
||||||
|
return nil, io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
var event ResponsesStreamEvent
|
||||||
|
err := json.Unmarshal([]byte(data), &event)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &event, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,642 @@
|
|||||||
|
package convert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sseState 记录上一行 event 名。
|
||||||
|
type sseState struct {
|
||||||
|
event string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLine 解析一行 SSE;返回是否 data 行及其内容、是否 [DONE]。
|
||||||
|
// data: 后可跟空格(标准)或紧贴 JSON(部分上游会省略空格)。
|
||||||
|
func (s *sseState) parseLine(line []byte) (isData bool, data string, done bool) {
|
||||||
|
strLine := strings.TrimRight(string(line), "\r\n")
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(strLine, "event: "):
|
||||||
|
s.event = strings.TrimSpace(strings.TrimPrefix(strLine, "event: "))
|
||||||
|
return false, "", false
|
||||||
|
case strLine == "data: [DONE]" || strLine == "data:[DONE]":
|
||||||
|
return true, "[DONE]", true
|
||||||
|
case strings.HasPrefix(strLine, "data:"):
|
||||||
|
return true, strings.TrimLeft(strings.TrimPrefix(strLine, "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')
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinLines 拼接多条 SSE 行。
|
||||||
|
func joinLines(lines [][]byte) []byte {
|
||||||
|
var s []string
|
||||||
|
for _, l := range lines {
|
||||||
|
s = append(s, string(l))
|
||||||
|
}
|
||||||
|
return []byte(strings.Join(s, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Messages → Chat
|
||||||
|
|
||||||
|
type messagesToChat struct {
|
||||||
|
sseState
|
||||||
|
id, model string
|
||||||
|
toolIdx map[int]int // messages content block index → chat tool_calls index(顺序编号,避开文本块)
|
||||||
|
nextTool int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMessagesToChat() *messagesToChat { return &messagesToChat{toolIdx: map[int]int{}} }
|
||||||
|
|
||||||
|
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_start":
|
||||||
|
cb, _ := m["content_block"].(map[string]any)
|
||||||
|
if cb == nil || cb["type"] != "tool_use" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
blockIdx, _ := m["index"].(float64)
|
||||||
|
tool := t.nextTool
|
||||||
|
t.nextTool++
|
||||||
|
t.toolIdx[int(blockIdx)] = tool
|
||||||
|
toolID, _ := cb["id"].(string)
|
||||||
|
name, _ := cb["name"].(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{
|
||||||
|
"tool_calls": []any{map[string]any{"index": tool, "id": toolID, "type": "function", "function": map[string]any{"name": name, "arguments": ""}}},
|
||||||
|
}, "finish_reason": nil}},
|
||||||
|
})
|
||||||
|
case "content_block_delta":
|
||||||
|
delta, _ := m["delta"].(map[string]any)
|
||||||
|
deltaType, _ := delta["type"].(string)
|
||||||
|
if deltaType == "input_json_delta" {
|
||||||
|
blockIdx, _ := m["index"].(float64)
|
||||||
|
tool, ok := t.toolIdx[int(blockIdx)]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
partial, _ := delta["partial_json"].(string)
|
||||||
|
if partial == "" {
|
||||||
|
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{
|
||||||
|
"tool_calls": []any{map[string]any{"index": tool, "function": map[string]any{"arguments": partial}}},
|
||||||
|
}, "finish_reason": nil}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chat → Messages
|
||||||
|
|
||||||
|
type chatToMessages struct {
|
||||||
|
sseState
|
||||||
|
started bool // message_start 已发出
|
||||||
|
nextIndex int // 下一个 content block index(顺序分配)
|
||||||
|
textIndex int // 文本块 index;-1 = 未开始
|
||||||
|
toolIdx map[int]int // chat delta.tool_calls[].index → messages block index
|
||||||
|
openBlocks []int // 已开始未停止的 block index,按开始顺序
|
||||||
|
model string
|
||||||
|
stopReason string
|
||||||
|
usage any
|
||||||
|
}
|
||||||
|
|
||||||
|
func newChatToMessages() *chatToMessages {
|
||||||
|
return &chatToMessages{textIndex: -1, toolIdx: map[int]int{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *chatToMessages) line(line []byte) []byte {
|
||||||
|
isData, data, done := t.parseLine(line)
|
||||||
|
if !isData {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if done {
|
||||||
|
// 汇聚最终:先对每个已开始未停止的块发 content_block_stop,再 message_delta + message_stop
|
||||||
|
var out [][]byte
|
||||||
|
for _, idx := range t.openBlocks {
|
||||||
|
out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": idx}))
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
out = append(out, eventLine("message_delta", md))
|
||||||
|
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 只在实际有内容(文本或工具)时发出,避免 reasoning_content 块
|
||||||
|
//(带 role 无 content)提前开出一个空文本块。
|
||||||
|
ensureStarted := func() {
|
||||||
|
if t.started {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文本:delta.content(string;兼容 {type:text,text} 数组)
|
||||||
|
if content := deltaText(delta); content != "" {
|
||||||
|
if t.textIndex < 0 {
|
||||||
|
t.textIndex = t.nextIndex
|
||||||
|
t.nextIndex++
|
||||||
|
ensureStarted()
|
||||||
|
out = append(out, eventLine("content_block_start", map[string]any{
|
||||||
|
"type": "content_block_start", "index": t.textIndex, "content_block": map[string]any{"type": "text", "text": ""},
|
||||||
|
}))
|
||||||
|
t.openBlocks = append(t.openBlocks, t.textIndex)
|
||||||
|
}
|
||||||
|
out = append(out, eventLine("content_block_delta", map[string]any{
|
||||||
|
"type": "content_block_delta", "index": t.textIndex, "delta": map[string]any{"type": "text_delta", "text": content},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具调用:delta.tool_calls(并行调用各 index 独立成块;arguments 支持整段/分段两种流式)
|
||||||
|
if tcs, ok := delta["tool_calls"].([]any); ok {
|
||||||
|
for _, tc := range tcs {
|
||||||
|
call, ok := tc.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx, _ := call["index"].(float64)
|
||||||
|
tcIdx := int(idx)
|
||||||
|
fn, _ := call["function"].(map[string]any)
|
||||||
|
name, _ := fn["name"].(string)
|
||||||
|
args, _ := fn["arguments"].(string)
|
||||||
|
blockIdx, seen := t.toolIdx[tcIdx]
|
||||||
|
if !seen {
|
||||||
|
blockIdx = t.nextIndex
|
||||||
|
t.nextIndex++
|
||||||
|
t.toolIdx[tcIdx] = blockIdx
|
||||||
|
toolID, _ := call["id"].(string)
|
||||||
|
ensureStarted()
|
||||||
|
out = append(out, eventLine("content_block_start", map[string]any{
|
||||||
|
"type": "content_block_start", "index": blockIdx, "content_block": map[string]any{
|
||||||
|
"type": "tool_use", "id": toolID, "name": name, "input": map[string]any{},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
t.openBlocks = append(t.openBlocks, blockIdx)
|
||||||
|
}
|
||||||
|
if args != "" {
|
||||||
|
out = append(out, eventLine("content_block_delta", map[string]any{
|
||||||
|
"type": "content_block_delta", "index": blockIdx, "delta": map[string]any{"type": "input_json_delta", "partial_json": args},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if u, ok := m["usage"]; ok {
|
||||||
|
t.usage = u
|
||||||
|
}
|
||||||
|
return joinLines(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deltaText 取 chat delta.content 文本(string 或 [{type:text,text}] 数组拼接)。
|
||||||
|
func deltaText(delta map[string]any) string {
|
||||||
|
if s, ok := delta["content"].(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if arr, ok := delta["content"].([]any); ok {
|
||||||
|
var parts []string
|
||||||
|
for _, b := range arr {
|
||||||
|
if bm, ok := b.(map[string]any); ok {
|
||||||
|
if t, _ := bm["text"].(string); t != "" {
|
||||||
|
parts = append(parts, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
nextIndex int // 下一个 content block index(顺序分配)
|
||||||
|
textIndex int // 文本块 index;-1 = 未开始
|
||||||
|
toolIdx map[string]int // function_call item_id → messages block index
|
||||||
|
openBlocks []int // 已开始未停止的 block index,按开始顺序
|
||||||
|
anyTool bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newResponsesToMessages() *responsesToMessages {
|
||||||
|
return &responsesToMessages{textIndex: -1, toolIdx: map[string]int{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// message_start 只在 response.created 时发出;文本/工具块在对应事件到达时再开,
|
||||||
|
// 避免纯函数调用响应提前开出一个空文本块。
|
||||||
|
ensureStarted := func() {
|
||||||
|
if t.started {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.started = true
|
||||||
|
rid := ""
|
||||||
|
if resp, ok := m["response"].(map[string]any); ok {
|
||||||
|
rid, _ = resp["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{},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
switch evt {
|
||||||
|
case "response.created":
|
||||||
|
ensureStarted()
|
||||||
|
case "response.output_text.delta":
|
||||||
|
delta, _ := m["delta"].(string)
|
||||||
|
if delta == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if t.textIndex < 0 {
|
||||||
|
t.textIndex = t.nextIndex
|
||||||
|
t.nextIndex++
|
||||||
|
ensureStarted()
|
||||||
|
out = append(out, eventLine("content_block_start", map[string]any{
|
||||||
|
"type": "content_block_start", "index": t.textIndex, "content_block": map[string]any{"type": "text", "text": ""},
|
||||||
|
}))
|
||||||
|
t.openBlocks = append(t.openBlocks, t.textIndex)
|
||||||
|
}
|
||||||
|
out = append(out, eventLine("content_block_delta", map[string]any{
|
||||||
|
"type": "content_block_delta", "index": t.textIndex, "delta": map[string]any{"type": "text_delta", "text": delta},
|
||||||
|
}))
|
||||||
|
case "response.output_item.added":
|
||||||
|
item, _ := m["item"].(map[string]any)
|
||||||
|
if item == nil || item["type"] != "function_call" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
blockIdx := t.nextIndex
|
||||||
|
t.nextIndex++
|
||||||
|
t.anyTool = true
|
||||||
|
itemID, _ := item["id"].(string)
|
||||||
|
t.toolIdx[itemID] = blockIdx
|
||||||
|
toolUseID, _ := item["call_id"].(string)
|
||||||
|
if toolUseID == "" {
|
||||||
|
toolUseID = itemID
|
||||||
|
}
|
||||||
|
name, _ := item["name"].(string)
|
||||||
|
ensureStarted()
|
||||||
|
out = append(out, eventLine("content_block_start", map[string]any{
|
||||||
|
"type": "content_block_start", "index": blockIdx, "content_block": map[string]any{
|
||||||
|
"type": "tool_use", "id": toolUseID, "name": name, "input": map[string]any{},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
t.openBlocks = append(t.openBlocks, blockIdx)
|
||||||
|
case "response.function_call_arguments.delta":
|
||||||
|
itemID, _ := m["item_id"].(string)
|
||||||
|
blockIdx, ok := t.toolIdx[itemID]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
delta, _ := m["delta"].(string)
|
||||||
|
if delta == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out = append(out, eventLine("content_block_delta", map[string]any{
|
||||||
|
"type": "content_block_delta", "index": blockIdx, "delta": map[string]any{"type": "input_json_delta", "partial_json": delta},
|
||||||
|
}))
|
||||||
|
case "response.completed":
|
||||||
|
for _, idx := range t.openBlocks {
|
||||||
|
out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": idx}))
|
||||||
|
}
|
||||||
|
stop := "end_turn"
|
||||||
|
if t.anyTool {
|
||||||
|
stop = "tool_use"
|
||||||
|
}
|
||||||
|
md := map[string]any{"type": "message_delta", "delta": map[string]any{"stop_reason": stop, "stop_sequence": nil}}
|
||||||
|
if t.usage != nil {
|
||||||
|
md["usage"] = t.usage
|
||||||
|
}
|
||||||
|
out = append(out, eventLine("message_delta", md))
|
||||||
|
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
|
||||||
|
finishSeen bool
|
||||||
|
done bool
|
||||||
|
createdSent 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 {
|
||||||
|
// 流结束兜底:finish 后 usage 未随块到达时在此补发 completed
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if finish != "" {
|
||||||
|
t.finishSeen = true
|
||||||
|
}
|
||||||
|
var out [][]byte
|
||||||
|
// 只发一次 response.created:部分上游(如 OpenRouter 的 reasoning 模型)会在
|
||||||
|
// 每个 chunk 的 delta 里都带 role:"assistant",不加守卫会刷出数十条 created。
|
||||||
|
if !t.createdSent && delta["role"] == "assistant" {
|
||||||
|
t.createdSent = true
|
||||||
|
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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
// 上游 usage 块(choices 为空)通常晚于 finish_reason:此时再发 completed,携带 usage
|
||||||
|
if _, hasUsage := m["usage"]; hasUsage && t.finishSeen && !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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package convert
|
||||||
|
|
||||||
|
// Common types shared across all protocols
|
||||||
|
|
||||||
|
// Message represents a unified message format
|
||||||
|
type Message struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content interface{} `json:"content,omitempty"` // string or []ContentPart
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||||
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentPart represents a part of a multi-part message content
|
||||||
|
type ContentPart struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
ImageURL *ImageURL `json:"image_url,omitempty"`
|
||||||
|
Source *ImageSource `json:"source,omitempty"`
|
||||||
|
ToolUse *ToolUse `json:"tool_use,omitempty"`
|
||||||
|
ToolResult *ToolResult `json:"tool_result,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageURL struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageSource struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
MediaType string `json:"media_type"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolCall struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function FunctionCall `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FunctionCall struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolUse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Input interface{} `json:"input"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolResult struct {
|
||||||
|
ToolUseID string `json:"tool_use_id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool definition
|
||||||
|
type Tool struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function ToolDefinition `json:"function,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"` // Anthropic style
|
||||||
|
Input interface{} `json:"input_schema,omitempty"` // Anthropic style
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolDefinition struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Parameters interface{} `json:"parameters,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamEvent represents a unified streaming event
|
||||||
|
type StreamEvent struct {
|
||||||
|
Type string `json:"type"` // "message_start", "content_block_start", "content_block_delta", "message_delta", "message_stop"
|
||||||
|
Delta *Delta `json:"delta,omitempty"`
|
||||||
|
Usage *Usage `json:"usage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Delta struct {
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
StopReason string `json:"stop_reason,omitempty"`
|
||||||
|
ContentBlock *ContentBlock `json:"content_block,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContentBlock struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Input interface{} `json:"input,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage represents token usage
|
||||||
|
type Usage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens,omitempty"`
|
||||||
|
CacheReadTokens int `json:"cache_read_input_tokens,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package convert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenUsage 从上游响应提取的 token 用量。
|
||||||
|
// 三种协议的字段名不同,此处统一为:input / output / cache_read / cache_creation,
|
||||||
|
// 供用量记录与计费使用。
|
||||||
|
type TokenUsage struct {
|
||||||
|
InputTokens int
|
||||||
|
OutputTokens int
|
||||||
|
CacheReadTokens int
|
||||||
|
CacheCreationTokens int
|
||||||
|
}
|
||||||
|
|
||||||
|
// has 判断是否真的拿到了非零用量(过滤掉没有 usage 字段的响应)。
|
||||||
|
func (u *TokenUsage) has() bool {
|
||||||
|
return u.InputTokens > 0 || u.OutputTokens > 0 ||
|
||||||
|
u.CacheReadTokens > 0 || u.CacheCreationTokens > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeJSON 把一张 usage 对象并入累计值。proto 决定字段名(chat/responses 与 messages 不同)。
|
||||||
|
func (u *TokenUsage) mergeJSON(raw map[string]any, proto string) {
|
||||||
|
switch proto {
|
||||||
|
case ProtoChat, ProtoResponses:
|
||||||
|
in, _ := raw["prompt_tokens"].(float64)
|
||||||
|
out, _ := raw["completion_tokens"].(float64)
|
||||||
|
if in == 0 && out == 0 {
|
||||||
|
in, _ = raw["input_tokens"].(float64)
|
||||||
|
out, _ = raw["output_tokens"].(float64)
|
||||||
|
}
|
||||||
|
u.InputTokens += int(in)
|
||||||
|
u.OutputTokens += int(out)
|
||||||
|
if d, ok := raw["prompt_tokens_details"].(map[string]any); ok {
|
||||||
|
if c, _ := d["cached_tokens"].(float64); c > 0 {
|
||||||
|
u.CacheReadTokens += int(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d, ok := raw["input_tokens_details"].(map[string]any); ok {
|
||||||
|
if c, _ := d["cached_tokens"].(float64); c > 0 {
|
||||||
|
u.CacheReadTokens += int(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ProtoMessages:
|
||||||
|
in, _ := raw["input_tokens"].(float64)
|
||||||
|
out, _ := raw["output_tokens"].(float64)
|
||||||
|
u.InputTokens += int(in)
|
||||||
|
u.OutputTokens += int(out)
|
||||||
|
if c, _ := raw["cache_read_input_tokens"].(float64); c > 0 {
|
||||||
|
u.CacheReadTokens += int(c)
|
||||||
|
}
|
||||||
|
if c, _ := raw["cache_creation_input_tokens"].(float64); c > 0 {
|
||||||
|
u.CacheCreationTokens += int(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractUsageJSON 从完整非流式响应体中提取用量。proto 为上游协议。
|
||||||
|
// 返回 (用量, 是否有效)。
|
||||||
|
func ExtractUsageJSON(body []byte, proto string) (TokenUsage, bool) {
|
||||||
|
var top map[string]any
|
||||||
|
if err := json.Unmarshal(body, &top); err != nil {
|
||||||
|
return TokenUsage{}, false
|
||||||
|
}
|
||||||
|
var u TokenUsage
|
||||||
|
if usage, ok := top["usage"].(map[string]any); ok {
|
||||||
|
u.mergeJSON(usage, proto)
|
||||||
|
}
|
||||||
|
return u, u.has()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamUsageAccum 流式用量累计器。逐行喂入上游 SSE 的 data 载荷,
|
||||||
|
// 按协议分别取各事件里的 usage 字段(各事件只会携带一部分字段,取最大值合并)。
|
||||||
|
type StreamUsageAccum struct {
|
||||||
|
u TokenUsage
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStreamUsageAccum 创建一个流式用量累计器。
|
||||||
|
func NewStreamUsageAccum() *StreamUsageAccum {
|
||||||
|
return &StreamUsageAccum{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed 喂入一行 SSE data 载荷(不含 "data:" 前缀与换行)。
|
||||||
|
func (a *StreamUsageAccum) Feed(payload []byte, proto string) {
|
||||||
|
var top map[string]any
|
||||||
|
if json.Unmarshal(payload, &top) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var t TokenUsage
|
||||||
|
switch proto {
|
||||||
|
case ProtoChat:
|
||||||
|
if usage, ok := top["usage"].(map[string]any); ok {
|
||||||
|
t.mergeJSON(usage, proto)
|
||||||
|
}
|
||||||
|
case ProtoResponses:
|
||||||
|
// response.completed 事件把用量放在 response.usage 下。
|
||||||
|
if resp, ok := top["response"].(map[string]any); ok {
|
||||||
|
if usage, ok := resp["usage"].(map[string]any); ok {
|
||||||
|
t.mergeJSON(usage, proto)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ProtoMessages:
|
||||||
|
// message_start: {message: {usage: {input_tokens, cache_*}}}
|
||||||
|
// message_delta: {usage: {output_tokens}}
|
||||||
|
if msg, ok := top["message"].(map[string]any); ok {
|
||||||
|
if usage, ok := msg["usage"].(map[string]any); ok {
|
||||||
|
t.mergeJSON(usage, proto)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if usage, ok := top["usage"].(map[string]any); ok {
|
||||||
|
var t2 TokenUsage
|
||||||
|
t2.mergeJSON(usage, proto)
|
||||||
|
t.InputTokens = max(t.InputTokens, t2.InputTokens)
|
||||||
|
t.OutputTokens = max(t.OutputTokens, t2.OutputTokens)
|
||||||
|
t.CacheReadTokens = max(t.CacheReadTokens, t2.CacheReadTokens)
|
||||||
|
t.CacheCreationTokens = max(t.CacheCreationTokens, t2.CacheCreationTokens)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.u.InputTokens = max(a.u.InputTokens, t.InputTokens)
|
||||||
|
a.u.OutputTokens = max(a.u.OutputTokens, t.OutputTokens)
|
||||||
|
a.u.CacheReadTokens = max(a.u.CacheReadTokens, t.CacheReadTokens)
|
||||||
|
a.u.CacheCreationTokens = max(a.u.CacheCreationTokens, t.CacheCreationTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage 返回当前累计用量。
|
||||||
|
func (a *StreamUsageAccum) Usage() TokenUsage {
|
||||||
|
return a.u
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package convert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- ExtractUsageJSON: 非流式各协议 ----
|
||||||
|
|
||||||
|
func TestExtractUsageJSONChat(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"id": "chatcmpl-1",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"choices": [{"index": 0, "message": {"role": "assistant", "content": "hi"}}],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 11,
|
||||||
|
"completion_tokens": 7,
|
||||||
|
"total_tokens": 18,
|
||||||
|
"prompt_tokens_details": {"cached_tokens": 4}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
u, ok := ExtractUsageJSON(body, ProtoChat)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected ok=true")
|
||||||
|
}
|
||||||
|
if u.InputTokens != 11 || u.OutputTokens != 7 {
|
||||||
|
t.Fatalf("chat usage = %+v, want input=11 output=7", u)
|
||||||
|
}
|
||||||
|
if u.CacheReadTokens != 4 {
|
||||||
|
t.Fatalf("chat cacheRead = %d, want 4", u.CacheReadTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractUsageJSONMessages(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"id": "msg_1",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{"type": "text", "text": "hi"}],
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 15,
|
||||||
|
"output_tokens": 8,
|
||||||
|
"cache_read_input_tokens": 3,
|
||||||
|
"cache_creation_input_tokens": 2
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
u, ok := ExtractUsageJSON(body, ProtoMessages)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected ok=true")
|
||||||
|
}
|
||||||
|
if u.InputTokens != 15 || u.OutputTokens != 8 || u.CacheReadTokens != 3 || u.CacheCreationTokens != 2 {
|
||||||
|
t.Fatalf("messages usage = %+v", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractUsageJSONResponses(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"id": "resp_1",
|
||||||
|
"object": "response",
|
||||||
|
"output": [],
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 13,
|
||||||
|
"output_tokens": 9,
|
||||||
|
"input_tokens_details": {"cached_tokens": 5}
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
u, ok := ExtractUsageJSON(body, ProtoResponses)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected ok=true")
|
||||||
|
}
|
||||||
|
if u.InputTokens != 13 || u.OutputTokens != 9 || u.CacheReadTokens != 5 {
|
||||||
|
t.Fatalf("responses usage = %+v", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractUsageJSONInvalidAndMissing(t *testing.T) {
|
||||||
|
if _, ok := ExtractUsageJSON([]byte("not json"), ProtoChat); ok {
|
||||||
|
t.Fatalf("invalid json should not report ok")
|
||||||
|
}
|
||||||
|
if _, ok := ExtractUsageJSON([]byte(`{"id": "x"}`), ProtoChat); ok {
|
||||||
|
t.Fatalf("missing usage should not report ok")
|
||||||
|
}
|
||||||
|
// 空对象 usage:全 0 视为无效
|
||||||
|
if _, ok := ExtractUsageJSON([]byte(`{"usage": {}}`), ProtoChat); ok {
|
||||||
|
t.Fatalf("empty usage should not report ok")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- StreamUsageAccum: 流式各协议 ----
|
||||||
|
|
||||||
|
func feedLines(t *testing.T, proto string, lines ...string) TokenUsage {
|
||||||
|
t.Helper()
|
||||||
|
acc := NewStreamUsageAccum()
|
||||||
|
for _, ln := range lines {
|
||||||
|
acc.Feed([]byte(ln), proto)
|
||||||
|
}
|
||||||
|
return acc.Usage()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamUsageChatFinalChunk(t *testing.T) {
|
||||||
|
// 前面的 chunk 不带 usage;最后一个 chunk 带完整 usage
|
||||||
|
u := feedLines(t, ProtoChat,
|
||||||
|
`{"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{"content":"he"}}]}`,
|
||||||
|
`{"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{"content":"llo"}}]}`,
|
||||||
|
`{"id":"c1","object":"chat.completion.chunk","choices":[],"usage":{"prompt_tokens":11,"completion_tokens":7,"prompt_tokens_details":{"cached_tokens":4}}}`,
|
||||||
|
)
|
||||||
|
if u.InputTokens != 11 || u.OutputTokens != 7 || u.CacheReadTokens != 4 {
|
||||||
|
t.Fatalf("chat stream usage = %+v", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamUsageMessagesStartAndDelta(t *testing.T) {
|
||||||
|
// message_start 带 input/cache,message_delta 带 output;逐字段取 max 合并
|
||||||
|
u := feedLines(t, ProtoMessages,
|
||||||
|
`{"type":"message_start","message":{"id":"msg_1","usage":{"input_tokens":15,"cache_read_input_tokens":3,"cache_creation_input_tokens":2}}}`,
|
||||||
|
`{"type":"content_block_delta","delta":{"type":"text_delta","text":"hi"}}`,
|
||||||
|
`{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":8}}`,
|
||||||
|
)
|
||||||
|
if u.InputTokens != 15 || u.OutputTokens != 8 || u.CacheReadTokens != 3 || u.CacheCreationTokens != 2 {
|
||||||
|
t.Fatalf("messages stream usage = %+v", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamUsageResponsesCompleted(t *testing.T) {
|
||||||
|
// response.completed 事件的用量嵌在 response.usage 下
|
||||||
|
u := feedLines(t, ProtoResponses,
|
||||||
|
`{"type":"response.output_text.delta","delta":"hi"}`,
|
||||||
|
`{"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":13,"output_tokens":9,"input_tokens_details":{"cached_tokens":5}}}}`,
|
||||||
|
)
|
||||||
|
if u.InputTokens != 13 || u.OutputTokens != 9 || u.CacheReadTokens != 5 {
|
||||||
|
t.Fatalf("responses stream usage = %+v", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamUsageIgnoresNonDataPayloads(t *testing.T) {
|
||||||
|
// [DONE]、垃圾行、空对象都不应产生用量
|
||||||
|
u := feedLines(t, ProtoChat, `[DONE]`, `{`, ``, `{"choices":[]}`)
|
||||||
|
if u.has() {
|
||||||
|
t.Fatalf("expected zero usage, got %+v", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamUsageFeedKeepsMaxAcrossEvents(t *testing.T) {
|
||||||
|
// 同一字段在多个事件出现时取较大值(防乱序/重复)
|
||||||
|
u := feedLines(t, ProtoMessages,
|
||||||
|
`{"type":"message_start","message":{"usage":{"input_tokens":15}}}`,
|
||||||
|
`{"type":"message_delta","usage":{"output_tokens":5}}`,
|
||||||
|
`{"type":"message_delta","usage":{"output_tokens":8}}`,
|
||||||
|
)
|
||||||
|
if u.InputTokens != 15 || u.OutputTokens != 8 {
|
||||||
|
t.Fatalf("max-merge usage = %+v", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- usage JSON 结构合法性(防止手写 struct 漂移)----
|
||||||
|
|
||||||
|
func TestUsageJSONRoundTrip(t *testing.T) {
|
||||||
|
u := TokenUsage{InputTokens: 10, OutputTokens: 5, CacheReadTokens: 2, CacheCreationTokens: 1}
|
||||||
|
b, err := json.Marshal(u)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
var back TokenUsage
|
||||||
|
if err := json.Unmarshal(b, &back); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if back != u {
|
||||||
|
t.Fatalf("round trip = %+v, want %+v", back, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"opencatd-open/internal/proxy/convert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cacheWriteInputMultiplier 缓存写(cache creation)相对输入价的倍数。
|
||||||
|
// Anthropic 官方口径:缓存写按基础输入价的 1.25 倍计费(5m TTL);OpenAI 系无缓存写概念。
|
||||||
|
const cacheWriteInputMultiplier = 1.25
|
||||||
|
|
||||||
|
// ComputeCost 按上游协议的 token 语义计算一次请求的费用(USD)。
|
||||||
|
// 价格均为每百万 token 的 USD 单价。tok 的 token 语义由解析它的上游协议决定:
|
||||||
|
// - chat / responses(OpenAI 系):prompt_tokens 包含缓存读,
|
||||||
|
// 非缓存输入 = input − cacheRead;该协议没有缓存写,cacheCreation 恒为 0。
|
||||||
|
// - messages(Anthropic):input_tokens 不含缓存读/写(三个字段相互独立),
|
||||||
|
// 非缓存输入 = input 原值,不得再扣减;缓存写按输入价 ×1.25。
|
||||||
|
func ComputeCost(upstreamProto string, input, output, cacheRead, cacheCreation int, inputPrice, outputPrice, cacheReadPrice float64) float64 {
|
||||||
|
uncached := input
|
||||||
|
if upstreamProto != convert.ProtoMessages {
|
||||||
|
uncached -= cacheRead
|
||||||
|
if uncached < 0 {
|
||||||
|
uncached = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (float64(uncached)*inputPrice +
|
||||||
|
float64(cacheRead)*cacheReadPrice +
|
||||||
|
float64(cacheCreation)*inputPrice*cacheWriteInputMultiplier +
|
||||||
|
float64(output)*outputPrice) / 1e6
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestComputeCost(t *testing.T) {
|
||||||
|
// 标准三价:输入 0.5 / 输出 1.5 / 缓存读 0.05($/M)
|
||||||
|
const (
|
||||||
|
inPrice = 0.5
|
||||||
|
outPrice = 1.5
|
||||||
|
cchPrice = 0.05
|
||||||
|
)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
upstreamProto string
|
||||||
|
input int
|
||||||
|
output int
|
||||||
|
cacheRead int
|
||||||
|
cacheCreation int
|
||||||
|
inP float64
|
||||||
|
outP float64
|
||||||
|
cchP float64
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
// OpenAI:prompt 含缓存读,需扣减:(11000−10000)×0.5 + 10000×0.05 + 500×1.5
|
||||||
|
name: "openai prompt includes cache read",
|
||||||
|
upstreamProto: "chat",
|
||||||
|
input: 11000, output: 500, cacheRead: 10000,
|
||||||
|
inP: inPrice, outP: outPrice, cchP: cchPrice,
|
||||||
|
want: (1000*inPrice + 10000*cchPrice + 500*outPrice) / 1e6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Anthropic:input 不含缓存;缓存写按输入价 ×1.25
|
||||||
|
name: "anthropic cache write at 1.25x input price",
|
||||||
|
upstreamProto: "messages",
|
||||||
|
input: 1000, output: 500, cacheRead: 10000, cacheCreation: 2000,
|
||||||
|
inP: inPrice, outP: outPrice, cchP: cchPrice,
|
||||||
|
want: (1000*inPrice + 10000*cchPrice + 2000*inPrice*1.25 + 500*outPrice) / 1e6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Anthropic 口径不得扣减缓存读(否则这里非缓存输入会算成负数)
|
||||||
|
name: "anthropic does not subtract cache read",
|
||||||
|
upstreamProto: "messages",
|
||||||
|
input: 100, output: 0, cacheRead: 1000,
|
||||||
|
inP: inPrice, outP: outPrice, cchP: cchPrice,
|
||||||
|
want: (100*inPrice + 1000*cchPrice) / 1e6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// OpenAI 异常数据:cached > prompt 时非缓存输入钳制为 0,不出现负费用
|
||||||
|
name: "openai clamps negative uncached input",
|
||||||
|
upstreamProto: "responses",
|
||||||
|
input: 100, output: 0, cacheRead: 5000,
|
||||||
|
inP: inPrice, outP: outPrice, cchP: cchPrice,
|
||||||
|
want: (5000 * cchPrice) / 1e6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 未配置价格时费用为 0
|
||||||
|
name: "no prices no cost",
|
||||||
|
upstreamProto: "messages",
|
||||||
|
input: 1000, output: 1000, cacheRead: 1000, cacheCreation: 1000,
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := ComputeCost(tt.upstreamProto, tt.input, tt.output, tt.cacheRead, tt.cacheCreation, tt.inP, tt.outP, tt.cchP)
|
||||||
|
if diff := got - tt.want; diff > 1e-12 || diff < -1e-12 {
|
||||||
|
t.Fatalf("ComputeCost(%q, in=%d, out=%d, cr=%d, cw=%d) = %v, want %v",
|
||||||
|
tt.upstreamProto, tt.input, tt.output, tt.cacheRead, tt.cacheCreation, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,666 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"opencatd-open/internal/channel"
|
||||||
|
"opencatd-open/internal/dao"
|
||||||
|
"opencatd-open/internal/proxy/convert"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
"opencatd-open/internal/usage"
|
||||||
|
"opencatd-open/pkg/config"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Gateway struct {
|
||||||
|
ctx context.Context
|
||||||
|
cfg *config.Config
|
||||||
|
db *gorm.DB
|
||||||
|
wg *sync.WaitGroup
|
||||||
|
httpClient *http.Client
|
||||||
|
|
||||||
|
userDAO *dao.UserDAO
|
||||||
|
apiKeyDAO *dao.ApiKeyDAO
|
||||||
|
usageDAO *dao.UsageDAO
|
||||||
|
dailyDAO *dao.DailyUsageDAO
|
||||||
|
modelDAO *dao.ModelDAO
|
||||||
|
channelSvc *channel.Service
|
||||||
|
usageRec *usage.Recorder
|
||||||
|
|
||||||
|
// 原始请求/响应记录开关(系统配置 log_raw_requests,带 TTL 缓存避免每次查库)。
|
||||||
|
rawLogMu sync.Mutex
|
||||||
|
rawLogVal bool
|
||||||
|
rawLogSet time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGateway(ctx context.Context, cfg *config.Config, db *gorm.DB, wg *sync.WaitGroup, userDAO *dao.UserDAO, apiKeyDAO *dao.ApiKeyDAO, usageDAO *dao.UsageDAO, dailyDAO *dao.DailyUsageDAO) *Gateway {
|
||||||
|
client := &http.Client{Timeout: 120 * time.Second}
|
||||||
|
if os.Getenv("LOCAL_PROXY") != "" {
|
||||||
|
proxyUrl, err := url.Parse(os.Getenv("LOCAL_PROXY"))
|
||||||
|
if err == nil {
|
||||||
|
tr := &http.Transport{
|
||||||
|
Proxy: http.ProxyURL(proxyUrl),
|
||||||
|
}
|
||||||
|
client.Transport = tr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Gateway{
|
||||||
|
ctx: ctx,
|
||||||
|
cfg: cfg,
|
||||||
|
db: db,
|
||||||
|
wg: wg,
|
||||||
|
httpClient: client,
|
||||||
|
userDAO: userDAO,
|
||||||
|
apiKeyDAO: apiKeyDAO,
|
||||||
|
usageDAO: usageDAO,
|
||||||
|
dailyDAO: dailyDAO,
|
||||||
|
modelDAO: dao.NewModelDAO(db),
|
||||||
|
channelSvc: nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) SetChannelService(svc *channel.Service) {
|
||||||
|
g.channelSvc = svc
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUsageRecorder 注入异步用量记录器;nil 时网关跳过用量上报。
|
||||||
|
func (g *Gateway) SetUsageRecorder(r *usage.Recorder) {
|
||||||
|
g.usageRec = r
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawLogEnabled 读取系统配置 log_raw_requests(10s TTL 缓存),决定是否记录原始请求/响应。
|
||||||
|
func (g *Gateway) rawLogEnabled() bool {
|
||||||
|
g.rawLogMu.Lock()
|
||||||
|
defer g.rawLogMu.Unlock()
|
||||||
|
if time.Since(g.rawLogSet) < 10*time.Second {
|
||||||
|
return g.rawLogVal
|
||||||
|
}
|
||||||
|
var sc store.SystemConfig
|
||||||
|
g.rawLogVal = false
|
||||||
|
if err := g.db.Where("key = ?", "log_raw_requests").First(&sc).Error; err == nil {
|
||||||
|
g.rawLogVal = strings.TrimSpace(sc.Value) == "true"
|
||||||
|
}
|
||||||
|
g.rawLogSet = time.Now()
|
||||||
|
return g.rawLogVal
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRequestID 生成请求级唯一 ID,用于用量明细关联与排障。
|
||||||
|
func generateRequestID() string {
|
||||||
|
b := make([]byte, 12)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return fmt.Sprintf("req-%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
return "req-" + hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request represents a parsed incoming request
|
||||||
|
type Request struct {
|
||||||
|
Model string
|
||||||
|
Stream bool
|
||||||
|
Protocol string // "chat", "messages", "responses"
|
||||||
|
Body []byte
|
||||||
|
APIKey *store.APIKey
|
||||||
|
UserID uint64
|
||||||
|
KeyID uint64
|
||||||
|
RequestID string
|
||||||
|
|
||||||
|
CaptureRaw bool // 原始请求/响应记录(管理员 + 系统开关开启)
|
||||||
|
|
||||||
|
rawBuf *strings.Builder // 上游原始响应累积器(仅 CaptureRaw 时非 nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseRequest parses the incoming request and extracts key fields
|
||||||
|
func (g *Gateway) ParseRequest(c *gin.Context, protocol string) (*Request, error) {
|
||||||
|
body, err := io.ReadAll(c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey, _ := c.Get("api_key")
|
||||||
|
userID, _ := c.Get("user_id")
|
||||||
|
userRole, _ := c.Get("user_role")
|
||||||
|
|
||||||
|
req := &Request{
|
||||||
|
Protocol: protocol,
|
||||||
|
Body: body,
|
||||||
|
UserID: userID.(uint64),
|
||||||
|
RequestID: c.GetHeader("X-Request-Id"),
|
||||||
|
}
|
||||||
|
if req.RequestID == "" {
|
||||||
|
req.RequestID = generateRequestID()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ak, ok := apiKey.(*store.APIKey); ok {
|
||||||
|
req.APIKey = ak
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原始请求/响应记录:仅管理员 且 系统开关 log_raw_requests 开启。
|
||||||
|
if role, _ := userRole.(string); role == store.RoleAdmin && g.rawLogEnabled() {
|
||||||
|
req.CaptureRaw = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse model and stream based on protocol
|
||||||
|
switch protocol {
|
||||||
|
case "chat":
|
||||||
|
var parsed convert.ChatCompletionRequest
|
||||||
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid chat request: %w", err)
|
||||||
|
}
|
||||||
|
req.Model = parsed.Model
|
||||||
|
req.Stream = parsed.Stream
|
||||||
|
case "messages":
|
||||||
|
var parsed convert.MessagesRequest
|
||||||
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid messages request: %w", err)
|
||||||
|
}
|
||||||
|
req.Model = parsed.Model
|
||||||
|
req.Stream = parsed.Stream
|
||||||
|
case "responses":
|
||||||
|
var parsed convert.ResponsesRequest
|
||||||
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid responses request: %w", err)
|
||||||
|
}
|
||||||
|
req.Model = parsed.Model
|
||||||
|
req.Stream = parsed.Stream
|
||||||
|
}
|
||||||
|
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch routes the request to the appropriate upstream.
|
||||||
|
// 遍历候选渠道(绑定优先,全局回退;按优先级/权重排序),可重试性失败自动故障转移。
|
||||||
|
func (g *Gateway) Dispatch(c *gin.Context, req *Request) {
|
||||||
|
if g.channelSvc == nil {
|
||||||
|
g.writeError(c, http.StatusBadGateway, "channel service not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原始请求/响应捕获:仅管理员 + 系统开关开启(req.CaptureRaw 已在 ParseRequest 判定)。
|
||||||
|
// 客户端原始请求体即 req.Body;上游原始响应由 stream/bufferResponse 累积进 rawBuf。
|
||||||
|
if req.CaptureRaw {
|
||||||
|
req.rawBuf = &strings.Builder{}
|
||||||
|
}
|
||||||
|
|
||||||
|
cands := g.channelSvc.Candidates(req.Model)
|
||||||
|
// 内存健康过滤:连续失败进入 cooldown 的渠道不再尝试(渠道级健康自愈靠冷却过期)。
|
||||||
|
cands = g.channelSvc.FilterHealthy(cands)
|
||||||
|
if len(cands) == 0 {
|
||||||
|
g.writeError(c, http.StatusServiceUnavailable, "no enabled channels for model: "+req.Model)
|
||||||
|
g.recordUsage(req, nil, nil, usage.Event{
|
||||||
|
IsError: true, ErrorCode: "no_channel",
|
||||||
|
}, convert.TokenUsage{}, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastCh *store.Channel
|
||||||
|
_ = lastCh // 保留变量名便于断点排查;失败渠道已在循环内各自 RecordFailure
|
||||||
|
lastErrStatus := http.StatusBadGateway
|
||||||
|
lastErrBody := "all upstream channels failed"
|
||||||
|
|
||||||
|
for i := range cands {
|
||||||
|
cand := &cands[i]
|
||||||
|
ch := cand.Channel
|
||||||
|
lastCh = ch
|
||||||
|
|
||||||
|
apiKey, err := g.channelSvc.GetAPIKey(ch)
|
||||||
|
if err != nil {
|
||||||
|
lastErrStatus, lastErrBody = http.StatusBadGateway, "failed to decrypt API key"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine target format: channel declares support for the client protocol
|
||||||
|
// then passthrough, otherwise convert to its first supported protocol
|
||||||
|
// (chat > messages > responses).
|
||||||
|
targetFormat := g.conversionTarget(ch, req.Protocol)
|
||||||
|
if targetFormat == "" {
|
||||||
|
continue // 渠道不支持该协议,换下一个
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build upstream URL
|
||||||
|
upstreamURL := ch.UpstreamURL(targetFormat, g.getUpstreamPath(targetFormat))
|
||||||
|
|
||||||
|
// Convert request if needed
|
||||||
|
var requestBody []byte
|
||||||
|
if targetFormat != req.Protocol {
|
||||||
|
var err error
|
||||||
|
requestBody, err = convert.ConvertRequest(req.Body, req.Protocol, targetFormat)
|
||||||
|
if err != nil {
|
||||||
|
lastErrStatus, lastErrBody = http.StatusBadRequest, "conversion failed: "+err.Error()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
requestBody = req.Body
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绑定了 upstream_model 时把请求体里的 model 重写为上游模型名(别名映射)。
|
||||||
|
if cand.Binding != nil && cand.Binding.UpstreamModel != "" &&
|
||||||
|
cand.Binding.UpstreamModel != req.Model {
|
||||||
|
requestBody = rewriteModel(requestBody, cand.Binding.UpstreamModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create upstream request
|
||||||
|
httpReq, err := http.NewRequestWithContext(g.ctx, "POST", upstreamURL, bytes.NewReader(requestBody))
|
||||||
|
if err != nil {
|
||||||
|
lastErrStatus, lastErrBody = http.StatusBadGateway, "failed to create request"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
g.setHeaders(httpReq, ch, apiKey, targetFormat)
|
||||||
|
|
||||||
|
// Execute request
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := g.httpClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
g.channelSvc.RecordFailure(ch.ID)
|
||||||
|
lastErrStatus = http.StatusBadGateway
|
||||||
|
lastErrBody = fmt.Sprintf("upstream error: %v", err)
|
||||||
|
g.recordUsage(req, cand, ch, usage.Event{
|
||||||
|
IsError: true,
|
||||||
|
ErrorCode: "upstream_error",
|
||||||
|
LatencyMS: int(time.Since(start).Milliseconds()),
|
||||||
|
}, convert.TokenUsage{}, targetFormat)
|
||||||
|
continue // 可重试:换下一个渠道
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle upstream error responses
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
log.Printf("Upstream error: status=%d body=%s", resp.StatusCode, string(body))
|
||||||
|
if req.rawBuf != nil {
|
||||||
|
req.rawBuf.Write(body)
|
||||||
|
}
|
||||||
|
g.recordUsage(req, cand, ch, usage.Event{
|
||||||
|
IsError: true,
|
||||||
|
ErrorCode: fmt.Sprintf("upstream_%d", resp.StatusCode),
|
||||||
|
LatencyMS: int(time.Since(start).Milliseconds()),
|
||||||
|
}, convert.TokenUsage{}, targetFormat)
|
||||||
|
// 429/5xx 可换渠道重试;4xx 直接透传
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
|
||||||
|
lastErrStatus, lastErrBody = resp.StatusCode, string(body)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.Data(resp.StatusCode, "application/json", body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g.channelSvc.RecordSuccess(ch.ID)
|
||||||
|
|
||||||
|
// Stream or buffer response;tok 从上游响应(SSE usage 块或非流式 JSON)提取。
|
||||||
|
// 上游可能返回 HTTP 200 但 body/SSE 内带 error(OpenRouter 超时等),
|
||||||
|
// 此时按失败记账(errCode 非空),非流式错误体以 502 返回给客户端。
|
||||||
|
var tok convert.TokenUsage
|
||||||
|
var errCode string
|
||||||
|
if req.Stream {
|
||||||
|
tok, errCode = g.streamResponse(c, resp, req.Protocol, targetFormat, req.rawBuf)
|
||||||
|
} else {
|
||||||
|
tok, errCode, _ = g.bufferResponse(c, resp, req.Protocol, targetFormat, req.rawBuf)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if errCode != "" {
|
||||||
|
// 记账为失败(错误码),不产生费用;响应内容已由 buffer/stream 写出
|
||||||
|
g.recordUsage(req, cand, ch, usage.Event{
|
||||||
|
IsError: true,
|
||||||
|
ErrorCode: errCode,
|
||||||
|
LatencyMS: int(time.Since(start).Milliseconds()),
|
||||||
|
}, tok, targetFormat)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 成功记录:用量 + 定价计费。
|
||||||
|
g.recordUsage(req, cand, ch, usage.Event{
|
||||||
|
LatencyMS: int(time.Since(start).Milliseconds()),
|
||||||
|
}, tok, targetFormat)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全部候选失败(每个候选失败时已各自 RecordFailure,不再重复计数)
|
||||||
|
g.writeError(c, lastErrStatus, lastErrBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rewriteModel 把 JSON 请求体顶层的 model 字段替换为 upstreamModel。
|
||||||
|
func rewriteModel(body []byte, upstreamModel string) []byte {
|
||||||
|
var m map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(body, &m) != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
if _, ok := m["model"]; !ok {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
m["model"], _ = json.Marshal(upstreamModel)
|
||||||
|
out, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordUsage 汇总一次请求的用量事件并异步落库。tok 为从上游响应提取的用量,
|
||||||
|
// 其 token 语义由 upstreamProto(渠道实际使用的上游协议)决定。
|
||||||
|
// cand/ch 可为 nil(无可用渠道的失败场景)。
|
||||||
|
func (g *Gateway) recordUsage(req *Request, cand *channel.Candidate, ch *store.Channel, ev usage.Event, tok convert.TokenUsage, upstreamProto string) {
|
||||||
|
if g.usageRec == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ev.UserID = req.UserID
|
||||||
|
ev.ModelName = req.Model
|
||||||
|
ev.Protocol = req.Protocol
|
||||||
|
ev.RequestID = req.RequestID
|
||||||
|
if req.APIKey != nil {
|
||||||
|
ev.KeyID = req.APIKey.ID
|
||||||
|
}
|
||||||
|
if ch != nil {
|
||||||
|
ev.ChannelID = ch.ID
|
||||||
|
}
|
||||||
|
if cand != nil && cand.Binding != nil {
|
||||||
|
ev.ModelID = cand.Binding.ModelID
|
||||||
|
}
|
||||||
|
ev.PromptTokens = tok.InputTokens
|
||||||
|
ev.CompletionTokens = tok.OutputTokens
|
||||||
|
ev.CacheReadTokens = tok.CacheReadTokens
|
||||||
|
ev.CacheCreationTokens = tok.CacheCreationTokens
|
||||||
|
// 原始请求/响应(仅管理员+开关开启时捕获)。
|
||||||
|
if req.CaptureRaw {
|
||||||
|
ev.RawRequest = string(req.Body)
|
||||||
|
if req.rawBuf != nil {
|
||||||
|
ev.RawResponse = req.rawBuf.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 定价与成本(价格按每百万 token 的 USD 单价)。
|
||||||
|
// 成本口径按上游协议区分(详见 ComputeCost):OpenAI 系 prompt 含缓存读需扣减;
|
||||||
|
// Anthropic 的 input_tokens 不含缓存,缓存写按输入价 ×1.25。
|
||||||
|
if ev.ModelID != 0 {
|
||||||
|
if m, err := g.modelDAO.GetByID(ev.ModelID); err == nil {
|
||||||
|
ev.InputPrice = m.InputPrice
|
||||||
|
ev.OutputPrice = m.OutputPrice
|
||||||
|
ev.CacheReadPrice = m.CacheReadPrice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ev.IsError {
|
||||||
|
ev.Cost = ComputeCost(upstreamProto, tok.InputTokens, tok.OutputTokens, tok.CacheReadTokens, tok.CacheCreationTokens,
|
||||||
|
ev.InputPrice, ev.OutputPrice, ev.CacheReadPrice)
|
||||||
|
}
|
||||||
|
g.usageRec.Record(ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
// conversionTarget 决定客户端协议在渠道上的处理方式:
|
||||||
|
// 渠道声明支持该协议则直通;否则转为其首选支持协议(chat > messages > responses)。
|
||||||
|
func (g *Gateway) conversionTarget(ch *store.Channel, clientProto string) string {
|
||||||
|
formats := ch.FormatsEffective()
|
||||||
|
for _, f := range formats {
|
||||||
|
if f == clientProto {
|
||||||
|
return clientProto
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range []string{convert.ProtoChat, convert.ProtoMessages, convert.ProtoResponses} {
|
||||||
|
for _, f := range formats {
|
||||||
|
if f == p {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) getUpstreamPath(protocol string) string {
|
||||||
|
switch protocol {
|
||||||
|
case "chat":
|
||||||
|
return "/chat/completions"
|
||||||
|
case "messages":
|
||||||
|
return "/messages"
|
||||||
|
case "responses":
|
||||||
|
return "/responses"
|
||||||
|
default:
|
||||||
|
return "/chat/completions"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) setHeaders(req *http.Request, ch *store.Channel, apiKey string, format string) {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
switch ch.Provider {
|
||||||
|
case store.ChannelProviderOpenAI, store.ChannelProviderCompatible:
|
||||||
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
|
case store.ChannelProviderAnthropic:
|
||||||
|
req.Header.Set("x-api-key", apiKey)
|
||||||
|
req.Header.Set("anthropic-version", "2023-06-01")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// streamResponse 流式响应:按 \n\n 分块零缓冲转发;跨协议时逐行转换。
|
||||||
|
// 返回从上游 SSE usage 块累计的 token 用量(按上游协议解析)。
|
||||||
|
// capture 非 nil 时把上游原始行累积进去(原始响应记录)。
|
||||||
|
// 上游部分实现(如 OpenRouter)在超时时返回 HTTP 200 但 SSE data 内带
|
||||||
|
// error 字段;检测到则返回错误码,供 Dispatch 按失败记账。
|
||||||
|
func (g *Gateway) streamResponse(c *gin.Context, resp *http.Response, clientProto, upstreamProto string, capture *strings.Builder) (convert.TokenUsage, string) {
|
||||||
|
w := c.Writer
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
|
||||||
|
flusher, _ := w.(http.Flusher)
|
||||||
|
|
||||||
|
// 跨协议时按行转换;同协议直通(lineConv 为 nil)。
|
||||||
|
var lineConv func([]byte) []byte
|
||||||
|
if upstreamProto != clientProto {
|
||||||
|
lineConv = convert.NewStreamTransformer(upstreamProto, clientProto)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上游原始行按 \n\n 分块,避免把 data 行内的转义换行当成事件边界。
|
||||||
|
// 同时喂入用量累计器(usage 块可能出现在任一事件)。
|
||||||
|
r := bufio.NewReaderSize(resp.Body, 32*1024)
|
||||||
|
accum := convert.NewStreamUsageAccum()
|
||||||
|
errCode := ""
|
||||||
|
for {
|
||||||
|
buf := []byte{}
|
||||||
|
for {
|
||||||
|
line, err := r.ReadSlice('\n')
|
||||||
|
if err == bufio.ErrBufferFull {
|
||||||
|
buf = append(buf, line...)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
buf = append(buf, line...)
|
||||||
|
if err == io.EOF {
|
||||||
|
if len(buf) == 0 {
|
||||||
|
return accum.Usage(), errCode
|
||||||
|
}
|
||||||
|
if !bytes.HasSuffix(buf, []byte("\n")) {
|
||||||
|
buf = append(buf, '\n')
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
log.Printf("stream read error: %v", err)
|
||||||
|
return accum.Usage(), errCode
|
||||||
|
}
|
||||||
|
if len(buf) >= 2 && bytes.HasSuffix(buf, []byte("\n\n")) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原始响应捕获(仅管理员+开关开启时启用)。
|
||||||
|
if capture != nil {
|
||||||
|
capture.Write(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先解析用量(data: {...} 行),再决定转发内容。
|
||||||
|
for _, data := range sseDataPayloads(buf) {
|
||||||
|
accum.Feed(data, upstreamProto)
|
||||||
|
if errCode == "" && streamChunkHasError(data) {
|
||||||
|
errCode = "upstream_stream_error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := buf
|
||||||
|
if lineConv != nil {
|
||||||
|
out = lineConv(buf)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := w.Write(out); err != nil {
|
||||||
|
return accum.Usage(), errCode // 客户端已断开
|
||||||
|
}
|
||||||
|
if flusher != nil {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流结束标记:chat/messages 上游以 data: [DONE] 收尾。部分上游(keep-alive)
|
||||||
|
// 发完 [DONE] 后不关连接,继续读会阻塞到超时;据此主动收尾。
|
||||||
|
// responses 协议没有 [DONE],以 response.completed 事件收尾。
|
||||||
|
if streamTerminated(buf, upstreamProto) {
|
||||||
|
return accum.Usage(), errCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamChunkHasError 判断一块 SSE data 载荷是否带 error 字段(OpenRouter 超时等)。
|
||||||
|
func streamChunkHasError(data []byte) bool {
|
||||||
|
var m map[string]any
|
||||||
|
if json.Unmarshal(data, &m) != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, ok := m["error"]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// responses 协议错误事件可能形如 {"type":"error",...}
|
||||||
|
return m["type"] == "error"
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamTerminated 判断一块 SSE 是否为上游流的结束事件。
|
||||||
|
func streamTerminated(chunk []byte, proto string) bool {
|
||||||
|
switch proto {
|
||||||
|
case convert.ProtoChat:
|
||||||
|
// chat 上游以 data: [DONE] 收尾;keep-alive 上游发完不关连接。
|
||||||
|
return bytes.Contains(chunk, []byte("data: [DONE]"))
|
||||||
|
case convert.ProtoMessages:
|
||||||
|
// messages 上游以 message_stop 事件结束(无 [DONE])。
|
||||||
|
return bytes.Contains(chunk, []byte(`"type":"message_stop"`)) ||
|
||||||
|
bytes.Contains(chunk, []byte(`"type": "message_stop"`)) ||
|
||||||
|
bytes.Contains(chunk, []byte("data: [DONE]"))
|
||||||
|
case convert.ProtoResponses:
|
||||||
|
return bytes.Contains(chunk, []byte(`"response.completed"`)) ||
|
||||||
|
bytes.Contains(chunk, []byte(`"type":"response.completed"`))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// sseDataPayloads 从一块 SSE(一个完整事件,\n\n 结尾)中取出所有 data 行的原始载荷。
|
||||||
|
func sseDataPayloads(chunk []byte) [][]byte {
|
||||||
|
var out [][]byte
|
||||||
|
for _, line := range bytes.Split(chunk, []byte("\n")) {
|
||||||
|
line = bytes.TrimSuffix(line, []byte("\r"))
|
||||||
|
if !bytes.HasPrefix(line, []byte("data:")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
payload := bytes.TrimPrefix(line, []byte("data:"))
|
||||||
|
payload = bytes.TrimPrefix(payload, []byte(" "))
|
||||||
|
if len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, payload)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// bufferResponse 非流式响应:整体读取、可选转换后写回。
|
||||||
|
// 返回 (用量, 错误码, 是否错误)。部分上游(如 OpenRouter)在超时时返回
|
||||||
|
// HTTP 200 但 JSON 内含 error 字段,需要识别并让调用方按失败处理。
|
||||||
|
func (g *Gateway) bufferResponse(c *gin.Context, resp *http.Response, clientProto, upstreamProto string, capture *strings.Builder) (convert.TokenUsage, string, bool) {
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
g.writeError(c, http.StatusBadGateway, "failed to read response")
|
||||||
|
return convert.TokenUsage{}, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原始响应捕获(仅管理员+开关开启时启用)。
|
||||||
|
if capture != nil {
|
||||||
|
capture.Write(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用量从上游原始响应体提取(先于转换,转换会改字段名)。
|
||||||
|
tok, _ := convert.ExtractUsageJSON(body, upstreamProto)
|
||||||
|
|
||||||
|
// HTTP 200 但带 error 字段(OpenRouter 超时 504 等):识别并转失败。
|
||||||
|
errCode, isErr := bodyHasError(body)
|
||||||
|
|
||||||
|
out := body
|
||||||
|
if upstreamProto != clientProto {
|
||||||
|
if converted, cerr := convert.ConvertResponse(body, upstreamProto, clientProto); cerr == nil {
|
||||||
|
out = converted
|
||||||
|
} else {
|
||||||
|
// 转换失败时至少剥掉非 JSON 前缀,让客户端能解析出正文
|
||||||
|
out = convert.CleanJSON(body)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 直通:部分上游(如 OpenRouter)的 non-stream 响应在 JSON 前夹带空白/注释
|
||||||
|
out = convert.CleanJSON(body)
|
||||||
|
}
|
||||||
|
// 上游错误体:用 502 返回,让客户端感知失败(不伪装成 200)。
|
||||||
|
if isErr {
|
||||||
|
c.Data(http.StatusBadGateway, "application/json", out)
|
||||||
|
return tok, errCode, true
|
||||||
|
}
|
||||||
|
c.Data(resp.StatusCode, "application/json", out)
|
||||||
|
return tok, errCode, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// bodyHasError 判断 JSON 响应体是否带 error 字段(openai 风格 {"error":{...}} 或
|
||||||
|
// anthropic 风格 {"type":"error",...})。返回 (错误码, 是否错误)。找不到 JSON 返回 ("", false)。
|
||||||
|
func bodyHasError(body []byte) (string, bool) {
|
||||||
|
var m map[string]any
|
||||||
|
if json.Unmarshal(bytes.TrimSpace(body), &m) != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if _, ok := m["error"]; ok {
|
||||||
|
return "upstream_error", true
|
||||||
|
}
|
||||||
|
if m["type"] == "error" {
|
||||||
|
return "upstream_error", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) writeError(c *gin.Context, status int, message string) {
|
||||||
|
protocol := c.GetHeader("X-Protocol")
|
||||||
|
if protocol == "" {
|
||||||
|
protocol = "chat"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.Contains(c.GetHeader("Accept"), "text/event-stream"):
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Status(status)
|
||||||
|
fmt.Fprintf(c.Writer, "data: {\"error\":{\"message\":\"%s\"}}\n\n", message)
|
||||||
|
fmt.Fprintf(c.Writer, "data: [DONE]\n\n")
|
||||||
|
case protocol == "messages":
|
||||||
|
c.JSON(status, gin.H{
|
||||||
|
"type": "error",
|
||||||
|
"error": gin.H{
|
||||||
|
"type": "api_error",
|
||||||
|
"message": message,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
c.JSON(status, gin.H{
|
||||||
|
"error": gin.H{
|
||||||
|
"message": message,
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"opencatd-open/internal/dao"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleChat handles POST /v1/chat/completions
|
||||||
|
func (g *Gateway) HandleChat(c *gin.Context) {
|
||||||
|
req, err := g.ParseRequest(c, "chat")
|
||||||
|
if err != nil {
|
||||||
|
g.writeError(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Dispatch(c, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleMessages handles POST /v1/messages
|
||||||
|
func (g *Gateway) HandleMessages(c *gin.Context) {
|
||||||
|
req, err := g.ParseRequest(c, "messages")
|
||||||
|
if err != nil {
|
||||||
|
g.writeError(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Dispatch(c, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleResponses handles POST /v1/responses
|
||||||
|
func (g *Gateway) HandleResponses(c *gin.Context) {
|
||||||
|
req, err := g.ParseRequest(c, "responses")
|
||||||
|
if err != nil {
|
||||||
|
g.writeError(c, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Dispatch(c, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleModels handles GET /v1/models
|
||||||
|
func (g *Gateway) HandleModels(c *gin.Context) {
|
||||||
|
modelDAO := dao.NewModelDAO(g.db)
|
||||||
|
models, _, err := modelDAO.List(1000, 0)
|
||||||
|
if err != nil {
|
||||||
|
g.writeError(c, http.StatusInternalServerError, "failed to list models")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now().Unix()
|
||||||
|
data := make([]gin.H, 0, len(models))
|
||||||
|
for _, m := range models {
|
||||||
|
if !m.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data = append(data, gin.H{
|
||||||
|
"id": m.Name,
|
||||||
|
"object": "model",
|
||||||
|
"created": now,
|
||||||
|
"owned_by": "opencatd-open",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"object": "list",
|
||||||
|
"data": data,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"opencatd-open/internal/dao"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ApiKeyServiceImpl struct {
|
||||||
|
db *gorm.DB
|
||||||
|
apiKeyRepo *dao.ApiKeyDAO
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApiKeyService(db *gorm.DB, apiKeyDao *dao.ApiKeyDAO) *ApiKeyServiceImpl {
|
||||||
|
return &ApiKeyServiceImpl{db: db, apiKeyRepo: apiKeyDao}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ApiKeyServiceImpl) CreateApiKey(ctx context.Context, apikey *store.APIKey) error {
|
||||||
|
return s.apiKeyRepo.Create(apikey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ApiKeyServiceImpl) GetApiKey(ctx context.Context, id uint64) (*store.APIKey, error) {
|
||||||
|
return s.apiKeyRepo.GetByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ApiKeyServiceImpl) ListApiKey(ctx context.Context, userID uint64, limit, offset int) ([]*store.APIKey, int64, error) {
|
||||||
|
return s.apiKeyRepo.ListByUserID(userID, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ApiKeyServiceImpl) UpdateApiKey(ctx context.Context, apikey *store.APIKey) error {
|
||||||
|
return s.apiKeyRepo.Update(apikey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ApiKeyServiceImpl) DeleteApiKey(ctx context.Context, id uint64) error {
|
||||||
|
return s.apiKeyRepo.Delete(id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"opencatd-open/internal/channel"
|
||||||
|
"opencatd-open/internal/dao"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
"opencatd-open/internal/pkg/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChannelServiceImpl struct {
|
||||||
|
channelDAO *dao.ChannelDAO
|
||||||
|
channelSvc *channel.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChannelService(channelDAO *dao.ChannelDAO, channelSvc *channel.Service) *ChannelServiceImpl {
|
||||||
|
return &ChannelServiceImpl{
|
||||||
|
channelDAO: channelDAO,
|
||||||
|
channelSvc: channelSvc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelServiceImpl) Create(ctx context.Context, ch *store.Channel) error {
|
||||||
|
return s.channelDAO.Create(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelServiceImpl) GetByID(ctx context.Context, id uint64) (*store.Channel, error) {
|
||||||
|
return s.channelDAO.GetByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelServiceImpl) List(ctx context.Context, limit, offset int) ([]*store.Channel, int64, error) {
|
||||||
|
return s.channelDAO.List(limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelServiceImpl) ListEnabled(ctx context.Context) ([]*store.Channel, error) {
|
||||||
|
return s.channelDAO.ListEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelServiceImpl) Update(ctx context.Context, ch *store.Channel) error {
|
||||||
|
return s.channelDAO.Update(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ChannelServiceImpl) Delete(ctx context.Context, id uint64) error {
|
||||||
|
return s.channelDAO.Delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAPIKey decrypts the channel's API key
|
||||||
|
func (s *ChannelServiceImpl) GetAPIKey(ctx context.Context, channelID uint64) (string, error) {
|
||||||
|
ch, err := s.channelDAO.GetByID(channelID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return crypto.Decrypt(ch.APIKeyEnc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectForModel selects the best channel for a model
|
||||||
|
func (s *ChannelServiceImpl) SelectForModel(ctx context.Context, modelName string) (*store.Channel, error) {
|
||||||
|
cands := s.channelSvc.Candidates(modelName)
|
||||||
|
picked := s.channelSvc.Pick(cands)
|
||||||
|
if picked == nil {
|
||||||
|
return nil, fmt.Errorf("no enabled channels for model: %s", modelName)
|
||||||
|
}
|
||||||
|
return picked.Channel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindModels binds models to a channel
|
||||||
|
func (s *ChannelServiceImpl) BindModels(ctx context.Context, channelID uint64, bindings []store.ChannelModelBinding) error {
|
||||||
|
return s.channelDAO.BindModels(channelID, bindings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChannelModels returns models bound to a channel
|
||||||
|
func (s *ChannelServiceImpl) GetChannelModels(ctx context.Context, channelID uint64) ([]store.ChannelModelBinding, error) {
|
||||||
|
return s.channelDAO.GetChannelModels(channelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetModelChannels returns channels for a model
|
||||||
|
func (s *ChannelServiceImpl) GetModelChannels(ctx context.Context, modelName string) ([]*store.Channel, error) {
|
||||||
|
return s.channelDAO.GetEnabledChannelsByModel(modelName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordSuccess records a successful request
|
||||||
|
func (s *ChannelServiceImpl) RecordSuccess(channelID uint64) {
|
||||||
|
s.channelSvc.RecordSuccess(channelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordFailure records a failed request
|
||||||
|
func (s *ChannelServiceImpl) RecordFailure(channelID uint64) {
|
||||||
|
s.channelSvc.RecordFailure(channelID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"opencatd-open/internal/dao"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ModelServiceImpl struct {
|
||||||
|
modelDAO *dao.ModelDAO
|
||||||
|
channelDAO *dao.ChannelDAO
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewModelService(modelDAO *dao.ModelDAO, channelDAO *dao.ChannelDAO) *ModelServiceImpl {
|
||||||
|
return &ModelServiceImpl{
|
||||||
|
modelDAO: modelDAO,
|
||||||
|
channelDAO: channelDAO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ModelServiceImpl) Create(ctx context.Context, model *store.Model) error {
|
||||||
|
return s.modelDAO.Create(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ModelServiceImpl) GetByID(ctx context.Context, id uint64) (*store.Model, error) {
|
||||||
|
return s.modelDAO.GetByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ModelServiceImpl) GetByName(ctx context.Context, name string) (*store.Model, error) {
|
||||||
|
return s.modelDAO.GetByName(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ModelServiceImpl) List(ctx context.Context, limit, offset int) ([]*store.Model, int64, error) {
|
||||||
|
return s.modelDAO.List(limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ModelServiceImpl) ListEnabled(ctx context.Context) ([]*store.Model, error) {
|
||||||
|
return s.modelDAO.ListEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ModelServiceImpl) Update(ctx context.Context, model *store.Model) error {
|
||||||
|
return s.modelDAO.Update(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ModelServiceImpl) Delete(ctx context.Context, id uint64) error {
|
||||||
|
return s.modelDAO.Delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ModelServiceImpl) Upsert(ctx context.Context, model *store.Model) error {
|
||||||
|
return s.modelDAO.Upsert(model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindChannel binds a model to a channel
|
||||||
|
func (s *ModelServiceImpl) BindChannel(ctx context.Context, modelID, channelID uint64, upstreamModel string, weight int) error {
|
||||||
|
binding := store.ChannelModelBinding{
|
||||||
|
ModelID: modelID,
|
||||||
|
ChannelID: channelID,
|
||||||
|
UpstreamModel: upstreamModel,
|
||||||
|
Weight: weight,
|
||||||
|
}
|
||||||
|
return s.channelDAO.BindModels(channelID, []store.ChannelModelBinding{binding})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListChannelModels lists all models bound to a channel
|
||||||
|
func (s *ModelServiceImpl) ListChannelModels(ctx context.Context, channelID uint64) ([]store.ChannelModelBinding, error) {
|
||||||
|
return s.channelDAO.GetChannelModels(channelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListModelChannels lists all channels for a model
|
||||||
|
func (s *ModelServiceImpl) ListModelChannels(ctx context.Context, modelName string) ([]*store.Channel, error) {
|
||||||
|
return s.channelDAO.GetEnabledChannelsByModel(modelName)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"opencatd-open/internal/dao"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TokenServiceImpl struct {
|
||||||
|
db *gorm.DB
|
||||||
|
tokenRepo *dao.TokenDAO
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTokenService(db *gorm.DB, tokenRepo *dao.TokenDAO) *TokenServiceImpl {
|
||||||
|
return &TokenServiceImpl{
|
||||||
|
db: db,
|
||||||
|
tokenRepo: tokenRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TokenServiceImpl) GetByKey(ctx context.Context, key string) (*store.User, error) {
|
||||||
|
return t.tokenRepo.GetByKey(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *TokenServiceImpl) GetByID(ctx context.Context, id uint64) (*store.User, error) {
|
||||||
|
return t.tokenRepo.GetByID(id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"opencatd-open/pkg/config"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UsageService struct {
|
||||||
|
Ctx context.Context
|
||||||
|
Cfg *config.Config
|
||||||
|
DB *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUsageService(ctx context.Context, cfg *config.Config, db *gorm.DB) *UsageService {
|
||||||
|
return &UsageService{
|
||||||
|
Ctx: ctx,
|
||||||
|
Cfg: cfg,
|
||||||
|
DB: db,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"opencatd-open/internal/dao"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
"opencatd-open/pkg/config"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserServiceImpl struct {
|
||||||
|
cfg *config.Config
|
||||||
|
db *gorm.DB
|
||||||
|
userRepo *dao.UserDAO
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserService(cfg *config.Config, db *gorm.DB, userRepo *dao.UserDAO) *UserServiceImpl {
|
||||||
|
return &UserServiceImpl{
|
||||||
|
cfg: cfg,
|
||||||
|
db: db,
|
||||||
|
userRepo: userRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserServiceImpl) GetByID(ctx context.Context, id uint64) (*store.User, error) {
|
||||||
|
return s.userRepo.GetByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserServiceImpl) GetByUsername(ctx context.Context, username string) (*store.User, error) {
|
||||||
|
return s.userRepo.GetByUsername(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserServiceImpl) List(ctx context.Context, limit, offset int) ([]*store.User, int64, error) {
|
||||||
|
return s.userRepo.List(limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserServiceImpl) Create(ctx context.Context, user *store.User) error {
|
||||||
|
return s.userRepo.Create(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserServiceImpl) Update(ctx context.Context, user *store.User) error {
|
||||||
|
return s.userRepo.Update(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserServiceImpl) Delete(ctx context.Context, id uint64) error {
|
||||||
|
return s.userRepo.Delete(id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"opencatd-open/pkg/config"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
"github.com/ncruces/go-sqlite3/gormlite"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var DB *gorm.DB
|
||||||
|
|
||||||
|
func InitDB(cfg *config.Config) (*gorm.DB, error) {
|
||||||
|
var (
|
||||||
|
dialector gorm.Dialector
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
switch cfg.DB_Type {
|
||||||
|
case "sqlite":
|
||||||
|
dialector, err = sqliteDialector(cfg.DSN)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
case "postgres":
|
||||||
|
dialector = postgresDialector(cfg.DSN)
|
||||||
|
case "mysql":
|
||||||
|
dialector = mysqlDialector(cfg.DSN)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported database type: %s", cfg.DB_Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := gorm.Open(dialector, &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get underlying *sql.DB: %w", err)
|
||||||
|
}
|
||||||
|
sqlDB.SetMaxOpenConns(cfg.DBMaxOpenConns)
|
||||||
|
sqlDB.SetMaxIdleConns(cfg.DBMaxIdleConns)
|
||||||
|
|
||||||
|
if err := db.AutoMigrate(AllModels()...); err != nil {
|
||||||
|
log.Printf("AutoMigrate warning: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
DB = db
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sqliteDialector(dsn string) (gorm.Dialector, error) {
|
||||||
|
if dsn == "" {
|
||||||
|
dsn = "db/openteam.db"
|
||||||
|
}
|
||||||
|
// sqlite 不会自动创建上级目录,先确保它存在(与 docker-compose 挂载的 /app/db 对应)
|
||||||
|
if dir := filepath.Dir(dsn); dir != "." && dir != string(filepath.Separator) {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create database directory %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gormlite.Open(dsn), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func postgresDialector(dsn string) gorm.Dialector {
|
||||||
|
if dsn == "" {
|
||||||
|
dsn = "host=localhost user=postgres password=postgres dbname=opencatd port=5432 sslmode=disable"
|
||||||
|
}
|
||||||
|
return postgres.Open(dsn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mysqlDialector(dsn string) gorm.Dialector {
|
||||||
|
if dsn == "" {
|
||||||
|
dsn = "root:password@tcp(127.0.0.1:3306)/opencatd?charset=utf8mb4&parseTime=True&loc=Local"
|
||||||
|
}
|
||||||
|
return mysql.Open(dsn)
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 角色 / 状态枚举
|
||||||
|
const (
|
||||||
|
RoleUser = "user"
|
||||||
|
RoleAdmin = "admin"
|
||||||
|
|
||||||
|
UserStatusActive = "active"
|
||||||
|
UserStatusDisabled = "disabled"
|
||||||
|
|
||||||
|
KeyStatusActive = "active"
|
||||||
|
KeyStatusRevoked = "revoked"
|
||||||
|
|
||||||
|
ChannelProviderOpenAI = "openai"
|
||||||
|
ChannelProviderAnthropic = "anthropic"
|
||||||
|
ChannelProviderCompatible = "compatible"
|
||||||
|
ChannelHealthHealthy = "healthy"
|
||||||
|
ChannelHealthDegraded = "degraded"
|
||||||
|
ChannelHealthCooldown = "cooldown"
|
||||||
|
|
||||||
|
FormatChat = "chat"
|
||||||
|
FormatResponses = "responses"
|
||||||
|
FormatMessages = "messages"
|
||||||
|
|
||||||
|
UsageStatusSuccess = "success"
|
||||||
|
UsageStatusError = "error"
|
||||||
|
UsageStatusCanceled = "canceled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User 用户
|
||||||
|
type User struct {
|
||||||
|
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
Username string `gorm:"uniqueIndex;size:64;not null" json:"username"`
|
||||||
|
Email string `gorm:"uniqueIndex;size:255;not null" json:"email"`
|
||||||
|
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||||
|
Role string `gorm:"size:16;not null;default:user" json:"role"`
|
||||||
|
Balance float64 `gorm:"type:numeric(20,8);not null;default:0" json:"balance"`
|
||||||
|
Status string `gorm:"size:16;not null;default:active" json:"status"`
|
||||||
|
AllowedModels []string `gorm:"type:jsonb;serializer:json" json:"allowed_models,omitempty"`
|
||||||
|
DeniedModels []string `gorm:"type:jsonb;serializer:json" json:"denied_models,omitempty"`
|
||||||
|
InviteCode *string `json:"invite_code,omitempty"`
|
||||||
|
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIKey 密钥(SHA-256 hash 存储)
|
||||||
|
type APIKey struct {
|
||||||
|
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
UserID uint64 `gorm:"index;not null" json:"user_id"`
|
||||||
|
Name string `gorm:"size:64;not null" json:"name"`
|
||||||
|
KeyHash string `gorm:"uniqueIndex;size:64;not null" json:"-"`
|
||||||
|
KeyPlain string `gorm:"size:255;not null" json:"key_plain"`
|
||||||
|
KeyPrefix string `gorm:"size:32;not null" json:"key_prefix"`
|
||||||
|
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day,omitempty"`
|
||||||
|
QuotaRequestsPerDay *int `json:"quota_requests_per_day,omitempty"`
|
||||||
|
AllowedModels []string `gorm:"type:jsonb;serializer:json" json:"allowed_models,omitempty"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
|
Status string `gorm:"size:16;not null;default:active" json:"status"`
|
||||||
|
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel 上游渠道
|
||||||
|
type Channel struct {
|
||||||
|
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
Name string `gorm:"uniqueIndex;size:64;not null" json:"name"`
|
||||||
|
Provider string `gorm:"size:16;not null" json:"provider"`
|
||||||
|
Formats []string `gorm:"type:jsonb;serializer:json" json:"formats,omitempty"`
|
||||||
|
BaseURL string `gorm:"size:255;not null" json:"base_url"`
|
||||||
|
BaseURLs map[string]string `gorm:"type:jsonb;serializer:json" json:"base_urls,omitempty"`
|
||||||
|
APIKeyEnc string `gorm:"size:1024;not null" json:"-"`
|
||||||
|
// Weight 为 0 表示不参与加权随机选择(探活/回退语义),因此不能加 gorm
|
||||||
|
// default 标签 —— 零值字段会被 default 值覆盖,导致 0 被静默改写为 1。
|
||||||
|
Weight int `gorm:"not null" json:"weight"`
|
||||||
|
Priority int `gorm:"not null;default:0" json:"priority"`
|
||||||
|
TimeoutMS int `gorm:"not null;default:120000" json:"timeout_ms"`
|
||||||
|
MaxConcurrency int `gorm:"not null;default:16" json:"max_concurrency"`
|
||||||
|
HealthStatus string `gorm:"size:16;not null;default:healthy" json:"health_status"`
|
||||||
|
Enabled bool `gorm:"not null;default:true" json:"enabled"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatsEffective 返回渠道实际支持的原生协议
|
||||||
|
func (c *Channel) FormatsEffective() []string {
|
||||||
|
if len(c.Formats) > 0 {
|
||||||
|
return c.Formats
|
||||||
|
}
|
||||||
|
switch c.Provider {
|
||||||
|
case ChannelProviderAnthropic:
|
||||||
|
return []string{FormatMessages}
|
||||||
|
case ChannelProviderOpenAI:
|
||||||
|
return []string{FormatChat, FormatResponses}
|
||||||
|
default:
|
||||||
|
return []string{FormatChat}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var versionSegRe = regexp.MustCompile(`/v[0-9]+/?$`)
|
||||||
|
|
||||||
|
// UpstreamURL 按协议选 base_url,拼资源路径
|
||||||
|
func (c *Channel) UpstreamURL(proto, path string) string {
|
||||||
|
base := c.BaseURL
|
||||||
|
if len(c.BaseURLs) > 0 && c.BaseURLs[proto] != "" {
|
||||||
|
base = c.BaseURLs[proto]
|
||||||
|
}
|
||||||
|
base = strings.TrimRight(base, "/")
|
||||||
|
if base == "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(base, path) {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
if versionSegRe.MatchString(base) {
|
||||||
|
return base + path
|
||||||
|
}
|
||||||
|
return base + "/v1" + path
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model 全局模型 + 定价(价格按每百万 token,USD)
|
||||||
|
type Model struct {
|
||||||
|
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
Name string `gorm:"uniqueIndex;size:128;not null" json:"name"`
|
||||||
|
DisplayName string `gorm:"size:128" json:"display_name"`
|
||||||
|
InputPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"input_price"`
|
||||||
|
OutputPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"output_price"`
|
||||||
|
CacheReadPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"cache_read_price"`
|
||||||
|
Enabled bool `gorm:"not null;default:true" json:"enabled"`
|
||||||
|
Sort int `gorm:"not null;default:0" json:"sort"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelModelBinding 渠道↔模型绑定(多对多)
|
||||||
|
type ChannelModelBinding struct {
|
||||||
|
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
ChannelID uint64 `gorm:"index:idx_ch_model,unique;not null" json:"channel_id"`
|
||||||
|
ModelID uint64 `gorm:"index:idx_ch_model,unique;not null" json:"model_id"`
|
||||||
|
UpstreamModel string `gorm:"size:255;not null" json:"upstream_model"`
|
||||||
|
Weight int `gorm:"not null;default:1" json:"weight"`
|
||||||
|
Channel Channel `gorm:"foreignKey:ChannelID" json:"-"`
|
||||||
|
Model Model `gorm:"foreignKey:ModelID" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageLog 请求级用量明细
|
||||||
|
type UsageLog struct {
|
||||||
|
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
RequestID string `gorm:"size:128" json:"request_id"`
|
||||||
|
TraceID string `gorm:"size:64;index" json:"trace_id"`
|
||||||
|
UserID uint64 `gorm:"index:idx_user_created;not null" json:"user_id"`
|
||||||
|
KeyID uint64 `json:"key_id"`
|
||||||
|
ChannelID uint64 `json:"channel_id"`
|
||||||
|
ModelID uint64 `json:"model_id"`
|
||||||
|
ModelName string `gorm:"size:128" json:"model_name"`
|
||||||
|
Protocol string `gorm:"size:32" json:"protocol"`
|
||||||
|
InputTokens int64 `json:"input_tokens"`
|
||||||
|
OutputTokens int64 `json:"output_tokens"`
|
||||||
|
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||||
|
CacheCreationTokens int64 `json:"cache_creation_tokens"`
|
||||||
|
InputPrice float64 `gorm:"type:numeric(20,8)" json:"input_price"`
|
||||||
|
OutputPrice float64 `gorm:"type:numeric(20,8)" json:"output_price"`
|
||||||
|
CacheReadPrice float64 `gorm:"type:numeric(20,8)" json:"cache_read_price"`
|
||||||
|
Cost float64 `gorm:"type:numeric(20,8)" json:"cost"`
|
||||||
|
LatencyMS int `json:"latency_ms"`
|
||||||
|
Status string `gorm:"size:16;not null" json:"status"`
|
||||||
|
ErrorCode *string `json:"error_code,omitempty"`
|
||||||
|
RawRequest string `gorm:"type:text" json:"raw_request,omitempty"` // 客户端原始请求体(未转换;仅管理员+开关开启时记录)
|
||||||
|
RawResponse string `gorm:"type:text" json:"raw_response,omitempty"` // 上游原始响应(未转换;流式为全部 SSE 事件)
|
||||||
|
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageDaily 日粒度预聚合
|
||||||
|
type UsageDaily struct {
|
||||||
|
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
UserID uint64 `gorm:"index:idx_daily_user_model,unique" json:"user_id"`
|
||||||
|
ModelID uint64 `gorm:"index:idx_daily_user_model,unique" json:"model_id"`
|
||||||
|
Date string `gorm:"size:10;index:idx_daily_user_model,unique" json:"date"`
|
||||||
|
Requests int64 `json:"requests"`
|
||||||
|
InputTokens int64 `json:"input_tokens"`
|
||||||
|
OutputTokens int64 `json:"output_tokens"`
|
||||||
|
CacheReadTokens int64 `json:"cache_read_tokens"`
|
||||||
|
Cost float64 `gorm:"type:numeric(20,8)" json:"cost"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passkey WebAuthn 凭据
|
||||||
|
type Passkey struct {
|
||||||
|
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
UserID uint64 `gorm:"index;not null" json:"user_id"`
|
||||||
|
Name string `gorm:"size:64" json:"name"`
|
||||||
|
CredentialID []byte `gorm:"size:255;not null" json:"-"`
|
||||||
|
Credential []byte `gorm:"type:blob;not null" json:"-"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemConfig 系统配置
|
||||||
|
type SystemConfig struct {
|
||||||
|
Key string `gorm:"primaryKey;size:64" json:"key"`
|
||||||
|
Value string `gorm:"type:jsonb;not null" json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllModels 返回所有需要迁移的模型
|
||||||
|
func AllModels() []any {
|
||||||
|
return []any{
|
||||||
|
&User{},
|
||||||
|
&APIKey{},
|
||||||
|
&Channel{},
|
||||||
|
&Model{},
|
||||||
|
&ChannelModelBinding{},
|
||||||
|
&UsageLog{},
|
||||||
|
&UsageDaily{},
|
||||||
|
&Passkey{},
|
||||||
|
&SystemConfig{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashAPIKey hashes an API key using SHA-256
|
||||||
|
func HashAPIKey(key string) string {
|
||||||
|
h := sha256.Sum256([]byte(key))
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package usage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"opencatd-open/internal/dao"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event represents a usage event to be recorded
|
||||||
|
type Event struct {
|
||||||
|
UserID uint64
|
||||||
|
ModelName string
|
||||||
|
ChannelID uint64
|
||||||
|
PromptTokens int
|
||||||
|
CompletionTokens int
|
||||||
|
CacheReadTokens int
|
||||||
|
CacheCreationTokens int
|
||||||
|
Cost float64
|
||||||
|
IsError bool
|
||||||
|
IsCanceled bool
|
||||||
|
RequestID string
|
||||||
|
KeyID uint64
|
||||||
|
Protocol string
|
||||||
|
ErrorCode string
|
||||||
|
LatencyMS int
|
||||||
|
InputPrice float64
|
||||||
|
OutputPrice float64
|
||||||
|
CacheReadPrice float64
|
||||||
|
TraceID string // TraceID for distributed tracing
|
||||||
|
ModelID uint64 // Model ID from channel-model binding
|
||||||
|
RawRequest string // 客户端原始请求体(仅管理员+开关开启时记录)
|
||||||
|
RawResponse string // 上游原始响应(未转换;流式为全部 SSE 事件)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recorder handles async usage recording
|
||||||
|
type Recorder struct {
|
||||||
|
usageDAO *dao.UsageDAO
|
||||||
|
dailyDAO *dao.DailyUsageDAO
|
||||||
|
ch chan Event
|
||||||
|
batchSize int
|
||||||
|
flushInterval time.Duration
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRecorder creates a new usage recorder
|
||||||
|
func NewRecorder(usageDAO *dao.UsageDAO, dailyDAO *dao.DailyUsageDAO) *Recorder {
|
||||||
|
return &Recorder{
|
||||||
|
usageDAO: usageDAO,
|
||||||
|
dailyDAO: dailyDAO,
|
||||||
|
ch: make(chan Event, 10000),
|
||||||
|
batchSize: 100,
|
||||||
|
flushInterval: 5 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the recorder's background workers
|
||||||
|
func (r *Recorder) Start(ctx context.Context) {
|
||||||
|
r.wg.Add(1)
|
||||||
|
go r.processLoop(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop gracefully stops the recorder
|
||||||
|
func (r *Recorder) Stop() {
|
||||||
|
close(r.ch)
|
||||||
|
r.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record queues a usage event for async recording
|
||||||
|
func (r *Recorder) Record(event Event) {
|
||||||
|
select {
|
||||||
|
case r.ch <- event:
|
||||||
|
default:
|
||||||
|
log.Printf("Usage channel full, dropping event for user %d model %s", event.UserID, event.ModelName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Recorder) processLoop(ctx context.Context) {
|
||||||
|
defer r.wg.Done()
|
||||||
|
|
||||||
|
batch := make([]Event, 0, r.batchSize)
|
||||||
|
ticker := time.NewTicker(r.flushInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
if len(batch) > 0 {
|
||||||
|
r.flush(batch)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case event, ok := <-r.ch:
|
||||||
|
if !ok {
|
||||||
|
if len(batch) > 0 {
|
||||||
|
r.flush(batch)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
batch = append(batch, event)
|
||||||
|
if len(batch) >= r.batchSize {
|
||||||
|
r.flush(batch)
|
||||||
|
batch = make([]Event, 0, r.batchSize)
|
||||||
|
}
|
||||||
|
case <-ticker.C:
|
||||||
|
if len(batch) > 0 {
|
||||||
|
r.flush(batch)
|
||||||
|
batch = make([]Event, 0, r.batchSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Recorder) flush(events []Event) {
|
||||||
|
if len(events) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch create usage logs
|
||||||
|
logs := make([]*store.UsageLog, 0, len(events))
|
||||||
|
|
||||||
|
for _, e := range events {
|
||||||
|
status := store.UsageStatusSuccess
|
||||||
|
if e.IsError {
|
||||||
|
status = store.UsageStatusError
|
||||||
|
}
|
||||||
|
if e.IsCanceled {
|
||||||
|
status = store.UsageStatusCanceled
|
||||||
|
}
|
||||||
|
|
||||||
|
var errCode *string
|
||||||
|
if e.ErrorCode != "" {
|
||||||
|
errCode = &e.ErrorCode
|
||||||
|
}
|
||||||
|
|
||||||
|
log := &store.UsageLog{
|
||||||
|
UserID: e.UserID,
|
||||||
|
KeyID: e.KeyID,
|
||||||
|
ChannelID: e.ChannelID,
|
||||||
|
ModelID: e.ModelID,
|
||||||
|
ModelName: e.ModelName,
|
||||||
|
Protocol: e.Protocol,
|
||||||
|
InputTokens: int64(e.PromptTokens),
|
||||||
|
OutputTokens: int64(e.CompletionTokens),
|
||||||
|
CacheReadTokens: int64(e.CacheReadTokens),
|
||||||
|
CacheCreationTokens: int64(e.CacheCreationTokens),
|
||||||
|
InputPrice: e.InputPrice,
|
||||||
|
OutputPrice: e.OutputPrice,
|
||||||
|
CacheReadPrice: e.CacheReadPrice,
|
||||||
|
Cost: e.Cost,
|
||||||
|
LatencyMS: e.LatencyMS,
|
||||||
|
Status: status,
|
||||||
|
ErrorCode: errCode,
|
||||||
|
RequestID: e.RequestID,
|
||||||
|
TraceID: e.TraceID,
|
||||||
|
RawRequest: e.RawRequest,
|
||||||
|
RawResponse: e.RawResponse,
|
||||||
|
}
|
||||||
|
logs = append(logs, log)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to database
|
||||||
|
if err := r.usageDAO.BatchCreate(context.Background(), logs); err != nil {
|
||||||
|
log.Printf("Failed to batch create usage logs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daily rollup for success and canceled requests
|
||||||
|
dailyMap := make(map[string]*store.UsageDaily)
|
||||||
|
for _, e := range events {
|
||||||
|
if e.IsError {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
date := time.Now().Format("2006-01-02")
|
||||||
|
key := fmt.Sprintf("%d:%d:%s", e.UserID, e.ModelID, date)
|
||||||
|
d := dailyMap[key]
|
||||||
|
if d == nil {
|
||||||
|
d = &store.UsageDaily{
|
||||||
|
UserID: e.UserID,
|
||||||
|
ModelID: e.ModelID,
|
||||||
|
Date: date,
|
||||||
|
Requests: 0,
|
||||||
|
InputTokens: 0,
|
||||||
|
OutputTokens: 0,
|
||||||
|
CacheReadTokens: 0,
|
||||||
|
Cost: 0,
|
||||||
|
}
|
||||||
|
dailyMap[key] = d
|
||||||
|
}
|
||||||
|
d.Requests++
|
||||||
|
d.InputTokens += int64(e.PromptTokens)
|
||||||
|
d.OutputTokens += int64(e.CompletionTokens)
|
||||||
|
d.CacheReadTokens += int64(e.CacheReadTokens)
|
||||||
|
d.Cost += e.Cost
|
||||||
|
}
|
||||||
|
for _, d := range dailyMap {
|
||||||
|
if err := r.dailyDAO.UpsertDailyUsage(context.Background(), d); err != nil {
|
||||||
|
log.Printf("Failed to upsert daily usage: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Flushed %d usage logs", len(logs))
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
func StringToBool(strSlice []string) []bool {
|
||||||
|
boolSlice := make([]bool, len(strSlice))
|
||||||
|
for i, str := range strSlice {
|
||||||
|
str = strings.ToLower(str)
|
||||||
|
if str == "true" {
|
||||||
|
boolSlice[i] = true
|
||||||
|
} else if str == "false" {
|
||||||
|
boolSlice[i] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return boolSlice
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func MergeJSONObjects(dst, src map[string]interface{}) map[string]interface{} {
|
||||||
|
|
||||||
|
result := make(map[string]interface{})
|
||||||
|
for k, v := range dst {
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value2 := range src {
|
||||||
|
value1, exists := result[key]
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
map1Val, map1IsMap := value1.(map[string]interface{})
|
||||||
|
map2Val, map2IsMap := value2.(map[string]interface{})
|
||||||
|
|
||||||
|
if map1IsMap && map2IsMap {
|
||||||
|
result[key] = MergeJSONObjects(map1Val, map2Val)
|
||||||
|
} else {
|
||||||
|
// 覆盖第一个map中的值
|
||||||
|
result[key] = value2
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 添加新的键值对
|
||||||
|
result[key] = value2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func StructToMap(in interface{}) (map[string]interface{}, error) {
|
||||||
|
out := make(map[string]interface{})
|
||||||
|
|
||||||
|
v := reflect.ValueOf(in)
|
||||||
|
// If it's a pointer, dereference it
|
||||||
|
if v.Kind() == reflect.Ptr {
|
||||||
|
v = v.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a struct
|
||||||
|
if v.Kind() != reflect.Struct {
|
||||||
|
return nil, fmt.Errorf("StructToMap only accepts structs or pointers to structs; got %T", v.Interface())
|
||||||
|
}
|
||||||
|
|
||||||
|
t := v.Type() // Get the type of the struct
|
||||||
|
for i := 0; i < v.NumField(); i++ {
|
||||||
|
// Get the field Value and Type
|
||||||
|
fieldV := v.Field(i)
|
||||||
|
fieldT := t.Field(i)
|
||||||
|
|
||||||
|
// Skip unexported fields
|
||||||
|
if !fieldT.IsExported() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Handle JSON Tag ---
|
||||||
|
tag := fieldT.Tag.Get("json")
|
||||||
|
key := fieldT.Name // Default key is the field name
|
||||||
|
omitempty := false
|
||||||
|
|
||||||
|
if tag != "" {
|
||||||
|
parts := strings.Split(tag, ",")
|
||||||
|
tagName := parts[0]
|
||||||
|
|
||||||
|
if tagName == "-" {
|
||||||
|
// Skip fields tagged with "-"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tagName != "" {
|
||||||
|
key = tagName // Use tag name as key
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for omitempty option
|
||||||
|
for _, part := range parts[1:] {
|
||||||
|
if part == "omitempty" {
|
||||||
|
omitempty = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Handle omitempty ---
|
||||||
|
val := fieldV.Interface()
|
||||||
|
if omitempty && fieldV.IsZero() {
|
||||||
|
continue // Skip zero-value fields if omitempty is set
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Handle Nested Structs/Pointers to Structs (Recursion) ---
|
||||||
|
// Check for pointer first
|
||||||
|
if fieldV.Kind() == reflect.Ptr {
|
||||||
|
// If pointer is nil and omitempty is set, it was already skipped
|
||||||
|
// If pointer is nil and omitempty is not set, add nil to map
|
||||||
|
if fieldV.IsNil() {
|
||||||
|
// Only add nil if omitempty is not set (already handled above)
|
||||||
|
if !omitempty {
|
||||||
|
out[key] = nil
|
||||||
|
}
|
||||||
|
continue // Move to next field
|
||||||
|
}
|
||||||
|
// If it points to a struct, dereference and recurse
|
||||||
|
if fieldV.Elem().Kind() == reflect.Struct {
|
||||||
|
nestedMap, err := StructToMap(fieldV.Interface()) // Pass the pointer
|
||||||
|
if err != nil {
|
||||||
|
// Decide how to handle nested errors, e.g., log or return
|
||||||
|
fmt.Printf("Warning: could not convert nested struct pointer %s: %v\n", fieldT.Name, err)
|
||||||
|
out[key] = val // Store original value on error? Or skip?
|
||||||
|
} else {
|
||||||
|
out[key] = nestedMap
|
||||||
|
}
|
||||||
|
continue // Move to next field after handling pointer
|
||||||
|
}
|
||||||
|
// If pointer to non-struct, just get the interface value (handled below)
|
||||||
|
val = fieldV.Interface() // Use the actual pointer value
|
||||||
|
|
||||||
|
} else if fieldV.Kind() == reflect.Struct {
|
||||||
|
// If it's a struct (not a pointer), recurse
|
||||||
|
nestedMap, err := StructToMap(fieldV.Interface()) // Pass the struct value
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: could not convert nested struct %s: %v\n", fieldT.Name, err)
|
||||||
|
out[key] = val // Store original value on error? Or skip?
|
||||||
|
} else {
|
||||||
|
out[key] = nestedMap
|
||||||
|
}
|
||||||
|
continue // Move to next field after handling struct
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign the value (primitive, slice, map, non-struct pointer, etc.)
|
||||||
|
out[key] = val
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
return string(bytes), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckPassword(hash, password string) bool {
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
func ToPtr[T any](v T) *T {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdatePtrField[T any](target *T, value *T) {
|
||||||
|
if value != nil {
|
||||||
|
*target = *value
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"opencatd-open/internal/auth"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
"opencatd-open/internal/pkg/jwt"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Auth(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
authToken := c.GetHeader("Authorization")
|
||||||
|
if authToken == "" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"code": http.StatusUnauthorized,
|
||||||
|
"error": "未提供认证信息",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(authToken) > 7 {
|
||||||
|
authToken = authToken[7:]
|
||||||
|
}
|
||||||
|
claim, err := jwt.ValidateToken(authToken, auth.GetSecretKey())
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"code": http.StatusUnauthorized,
|
||||||
|
"error": "无效的认证信息",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var user store.User
|
||||||
|
if err := db.First(&user, claim.UserID).Error; err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"code": http.StatusUnauthorized,
|
||||||
|
"error": "无效的认证信息",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set("user", &user)
|
||||||
|
c.Set("user_id", claim.UserID)
|
||||||
|
c.Set("user_role", user.Role)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckRole(role string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
userRole, _ := c.Get("user_role")
|
||||||
|
if roleStr, ok := userRole.(string); !ok || roleStr != role {
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "权限不足"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminOnly 管理后台中间件:要求 user_role 为 admin。
|
||||||
|
// 由 middleware.Auth 先行设置 user_role;缺失时拒绝。
|
||||||
|
func AdminOnly() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
role, _ := c.Get("user_role")
|
||||||
|
roleStr, _ := role.(string)
|
||||||
|
if roleStr != store.RoleAdmin {
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// keyPrefixLen 是 key_prefix 列的截断长度,必须与 api.go:459 的 keyValue[:12] 一致。
|
||||||
|
// 真实 key 为 sk-ot- + 48 位 hex(54 字符),故 12 位足够唯一。
|
||||||
|
const keyPrefixLen = 12
|
||||||
|
|
||||||
|
func AuthLLM(db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
key := extractAPIKey(c.GetHeader("Authorization"))
|
||||||
|
|
||||||
|
// 区分「没传」和「传了但不对」,便于排查客户端配置。
|
||||||
|
if strings.TrimSpace(c.GetHeader("Authorization")) == "" {
|
||||||
|
unauthorized(c, "未提供认证信息")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 长度不足时直接拒绝:避免下方 authToken[:12] 越界 panic 打崩进程。
|
||||||
|
if len(key) < keyPrefixLen {
|
||||||
|
unauthorized(c, "无效的API密钥")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiKey store.APIKey
|
||||||
|
if err := db.Where("key_prefix = ? AND status = ?", key[:keyPrefixLen], store.KeyStatusActive).First(&apiKey).Error; err != nil {
|
||||||
|
unauthorized(c, "无效的API密钥")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify full key hash
|
||||||
|
if apiKey.KeyHash != store.HashAPIKey(key) {
|
||||||
|
unauthorized(c, "无效的API密钥")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 附带用户角色(判断是否管理员,供原始请求/响应记录等管理能力使用)。
|
||||||
|
var user store.User
|
||||||
|
if err := db.First(&user, apiKey.UserID).Error; err != nil {
|
||||||
|
unauthorized(c, "无效的API密钥")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("api_key", &apiKey)
|
||||||
|
c.Set("user_id", apiKey.UserID)
|
||||||
|
c.Set("user_role", user.Role)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractAPIKey 从 Authorization 头取 Bearer token,兼容无 "Bearer " 前缀的直传。
|
||||||
|
func extractAPIKey(auth string) string {
|
||||||
|
auth = strings.TrimSpace(auth)
|
||||||
|
if strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
return strings.TrimSpace(auth[len("Bearer "):])
|
||||||
|
}
|
||||||
|
return auth
|
||||||
|
}
|
||||||
|
|
||||||
|
func unauthorized(c *gin.Context, message string) {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"error": map[string]interface{}{
|
||||||
|
"message": message,
|
||||||
|
"type": "invalid_request_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-contrib/cors"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CORS() gin.HandlerFunc {
|
||||||
|
config := cors.DefaultConfig()
|
||||||
|
config.AllowAllOrigins = true
|
||||||
|
config.AllowCredentials = true
|
||||||
|
config.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}
|
||||||
|
config.AllowHeaders = []string{"*"}
|
||||||
|
return cors.New(config)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IPRateLimiter struct {
|
||||||
|
ips map[string]*rate.Limiter
|
||||||
|
mu *sync.RWMutex
|
||||||
|
r rate.Limit
|
||||||
|
b int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIPRateLimiter(r rate.Limit, b int) *IPRateLimiter {
|
||||||
|
return &IPRateLimiter{
|
||||||
|
ips: make(map[string]*rate.Limiter),
|
||||||
|
mu: &sync.RWMutex{},
|
||||||
|
r: r,
|
||||||
|
b: b,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *IPRateLimiter) GetLimiter(ip string) *rate.Limiter {
|
||||||
|
i.mu.Lock()
|
||||||
|
defer i.mu.Unlock()
|
||||||
|
|
||||||
|
limiter, exists := i.ips[ip]
|
||||||
|
if !exists {
|
||||||
|
limiter = rate.NewLimiter(i.r, i.b)
|
||||||
|
i.ips[ip] = limiter
|
||||||
|
}
|
||||||
|
|
||||||
|
return limiter
|
||||||
|
}
|
||||||
|
|
||||||
|
func RateLimit(limiter *IPRateLimiter) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
ip := c.ClientIP()
|
||||||
|
if !limiter.GetLimiter(ip).Allow() {
|
||||||
|
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||||
|
"code": 429,
|
||||||
|
"message": "too many requests",
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/joho/godotenv/autoload"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Cfg *Config
|
||||||
|
|
||||||
|
// Config 结构体存储应用配置
|
||||||
|
type Config struct {
|
||||||
|
// 服务器配置
|
||||||
|
Port int
|
||||||
|
ReadTimeout time.Duration
|
||||||
|
WriteTimeout time.Duration
|
||||||
|
|
||||||
|
// PassKey配置
|
||||||
|
AppName string // 依赖方(Relying Party)显示名称
|
||||||
|
RPID string // 依赖方ID(通常为域名)
|
||||||
|
RPOrigins []string // 依赖方源(URL)
|
||||||
|
WebAuthnTimeout time.Duration
|
||||||
|
ChallengeExpiration time.Duration
|
||||||
|
|
||||||
|
// 数据库配置
|
||||||
|
DB_Type string
|
||||||
|
DSN string
|
||||||
|
DBMaxOpenConns int
|
||||||
|
DBMaxIdleConns int
|
||||||
|
// DBHost string
|
||||||
|
// DBPort int
|
||||||
|
// DBUser string
|
||||||
|
// DBPassword string
|
||||||
|
// DBName string
|
||||||
|
|
||||||
|
// 缓存配置
|
||||||
|
RedisHost string
|
||||||
|
RedisPort int
|
||||||
|
RedisPassword string
|
||||||
|
RedisDB int
|
||||||
|
|
||||||
|
// 日志配置
|
||||||
|
LogLevel string
|
||||||
|
LogPath string
|
||||||
|
|
||||||
|
// 其他应用特定配置
|
||||||
|
AllowRegister bool
|
||||||
|
UnlimitedQuota bool
|
||||||
|
DefaultActive bool
|
||||||
|
|
||||||
|
UsageWorker int
|
||||||
|
UsageChanSize int
|
||||||
|
|
||||||
|
TaskTimeInterval int
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// 加载配置
|
||||||
|
cfg, err := LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("加载配置失败: %v", err))
|
||||||
|
}
|
||||||
|
Cfg = cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadConfig 从环境变量加载配置
|
||||||
|
func LoadConfig() (*Config, error) {
|
||||||
|
cfg := &Config{
|
||||||
|
AppName: "OpenTeam",
|
||||||
|
RPID: "localhost", // 域名
|
||||||
|
RPOrigins: []string{"https://localhost:5173"},
|
||||||
|
// 默认值设置
|
||||||
|
Port: 80,
|
||||||
|
|
||||||
|
ReadTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 10 * time.Second,
|
||||||
|
|
||||||
|
LogLevel: "info",
|
||||||
|
LogPath: "./logs/",
|
||||||
|
|
||||||
|
DB_Type: "sqlite",
|
||||||
|
DSN: "",
|
||||||
|
DBMaxOpenConns: 10,
|
||||||
|
DBMaxIdleConns: 5,
|
||||||
|
|
||||||
|
RedisDB: 0,
|
||||||
|
|
||||||
|
// 系统设置
|
||||||
|
AllowRegister: false,
|
||||||
|
UnlimitedQuota: true,
|
||||||
|
DefaultActive: true,
|
||||||
|
|
||||||
|
UsageWorker: 1,
|
||||||
|
UsageChanSize: 1000,
|
||||||
|
TaskTimeInterval: 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
// PassKey配置
|
||||||
|
if appName := os.Getenv("APP_NAME"); appName != "" {
|
||||||
|
cfg.AppName = appName
|
||||||
|
}
|
||||||
|
if domain := os.Getenv("RPID"); domain != "" {
|
||||||
|
cfg.RPID = domain
|
||||||
|
}
|
||||||
|
if origin := os.Getenv("RPORIGINS"); origin != "" {
|
||||||
|
var rpos []string
|
||||||
|
list := strings.Split(origin, ",")
|
||||||
|
for _, l := range list {
|
||||||
|
trimmedl := strings.TrimSpace(l)
|
||||||
|
if trimmedl != "" {
|
||||||
|
rpos = append(rpos, trimmedl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg.RPOrigins = rpos
|
||||||
|
}
|
||||||
|
|
||||||
|
// 服务器配置
|
||||||
|
if port := os.Getenv("PORT"); port != "" {
|
||||||
|
if p, err := strconv.Atoi(port); err == nil {
|
||||||
|
cfg.Port = p
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("PORT: %s", port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if timeout := os.Getenv("READ_TIMEOUT"); timeout != "" {
|
||||||
|
if t, err := strconv.Atoi(timeout); err == nil {
|
||||||
|
cfg.ReadTimeout = time.Duration(t) * time.Second
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("无效的READ_TIMEOUT: %s", timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if timeout := os.Getenv("WRITE_TIMEOUT"); timeout != "" {
|
||||||
|
if t, err := strconv.Atoi(timeout); err == nil {
|
||||||
|
cfg.WriteTimeout = time.Duration(t) * time.Second
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("无效的WRITE_TIMEOUT: %s", timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据库配置
|
||||||
|
if dbType := os.Getenv("DB_TYPE"); dbType != "" {
|
||||||
|
cfg.DB_Type = dbType
|
||||||
|
} else {
|
||||||
|
cfg.DB_Type = "sqlite"
|
||||||
|
}
|
||||||
|
|
||||||
|
if dsn := os.Getenv("DB_DSN"); dsn != "" {
|
||||||
|
cfg.DSN = dsn
|
||||||
|
}
|
||||||
|
|
||||||
|
if conns := os.Getenv("DB_MAX_OPEN_CONNS"); conns != "" {
|
||||||
|
if c, err := strconv.Atoi(conns); err == nil {
|
||||||
|
cfg.DBMaxOpenConns = c
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("无效的DB_MAX_OPEN_CONNS: %s", conns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if conns := os.Getenv("DB_MAX_IDLE_CONNS"); conns != "" {
|
||||||
|
if c, err := strconv.Atoi(conns); err == nil {
|
||||||
|
cfg.DBMaxIdleConns = c
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("无效的DB_MAX_IDLE_CONNS: %s", conns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redis配置
|
||||||
|
if host := os.Getenv("REDIS_HOST"); host != "" {
|
||||||
|
cfg.RedisHost = host
|
||||||
|
}
|
||||||
|
|
||||||
|
if port := os.Getenv("REDIS_PORT"); port != "" {
|
||||||
|
if p, err := strconv.Atoi(port); err == nil {
|
||||||
|
cfg.RedisPort = p
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("无效的REDIS_PORT: %s", port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if password := os.Getenv("REDIS_PASSWORD"); password != "" {
|
||||||
|
cfg.RedisPassword = password
|
||||||
|
}
|
||||||
|
|
||||||
|
if db := os.Getenv("REDIS_DB"); db != "" {
|
||||||
|
if d, err := strconv.Atoi(db); err == nil {
|
||||||
|
cfg.RedisDB = d
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("无效的REDIS_DB: %s", db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 日志配置
|
||||||
|
if level := os.Getenv("LOG_LEVEL"); level != "" {
|
||||||
|
cfg.LogLevel = level
|
||||||
|
}
|
||||||
|
|
||||||
|
if path := os.Getenv("LOG_PATH"); path != "" {
|
||||||
|
cfg.LogPath = path
|
||||||
|
}
|
||||||
|
|
||||||
|
// 功能标志
|
||||||
|
if allowRegister := os.Getenv("ALLOW_REGISTER"); allowRegister != "" {
|
||||||
|
if b, err := strconv.ParseBool(allowRegister); err == nil {
|
||||||
|
cfg.AllowRegister = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if unlimitedQuota := os.Getenv("UNLIMITED_QUOTA"); unlimitedQuota != "" {
|
||||||
|
if b, err := strconv.ParseBool(unlimitedQuota); err == nil {
|
||||||
|
cfg.UnlimitedQuota = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if defaultActive := os.Getenv("DEFAULT_ACTIVE"); defaultActive != "" {
|
||||||
|
if b, err := strconv.ParseBool(defaultActive); err == nil {
|
||||||
|
cfg.DefaultActive = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if worker := os.Getenv("USAGE_WORKER"); worker != "" {
|
||||||
|
if w, err := strconv.Atoi(worker); err == nil {
|
||||||
|
cfg.UsageWorker = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if size := os.Getenv("USAGE_CHAN_SIZE"); size != "" {
|
||||||
|
if s, err := strconv.Atoi(size); err == nil {
|
||||||
|
cfg.UsageChanSize = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if interval := os.Getenv("TASK_TIME_INTERVAL"); interval != "" {
|
||||||
|
if i, err := strconv.Atoi(interval); err == nil {
|
||||||
|
cfg.TaskTimeInterval = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
@@ -109,14 +109,24 @@ func Cost(model string, promptCount, completionCount int) float64 {
|
|||||||
cost = 0.015*float64(prompt/1000) + 0.06*float64(completion/1000)
|
cost = 0.015*float64(prompt/1000) + 0.06*float64(completion/1000)
|
||||||
case "o1-mini", "o1-mini-2024-09-12":
|
case "o1-mini", "o1-mini-2024-09-12":
|
||||||
cost = 0.003*float64(prompt/1000) + 0.012*float64(completion/1000)
|
cost = 0.003*float64(prompt/1000) + 0.012*float64(completion/1000)
|
||||||
// Realtime API
|
case "o3-mini", "o3-mini-2025-01-31":
|
||||||
// Audio*
|
cost = 0.003*float64(prompt/1000) + 0.012*float64(completion/1000)
|
||||||
// $0.1 / 1K input tokens
|
// Realtime API
|
||||||
// $0.2 / 1K output tokens
|
// Audio*
|
||||||
|
// $0.1 / 1K input tokens
|
||||||
|
// $0.2 / 1K output tokens
|
||||||
|
case "gpt-4o-audio-preview", "gpt-4o-audio-preview-2024-12-17":
|
||||||
|
cost = 0.0025*float64(prompt/1000) + 0.01*float64(completion/1000)
|
||||||
case "gpt-4o-realtime-preview", "gpt-4o-realtime-preview-2024-10-01":
|
case "gpt-4o-realtime-preview", "gpt-4o-realtime-preview-2024-10-01":
|
||||||
cost = 0.005*float64(prompt/1000) + 0.020*float64(completion/1000)
|
cost = 0.005*float64(prompt/1000) + 0.020*float64(completion/1000)
|
||||||
case "gpt-4o-realtime-preview.audio", "gpt-4o-realtime-preview-2024-10-01.audio":
|
case "gpt-4o-realtime-preview.audio", "gpt-4o-realtime-preview-2024-10-01.audio":
|
||||||
cost = 0.1*float64(prompt/1000) + 0.2*float64(completion/1000)
|
cost = 0.1*float64(prompt/1000) + 0.2*float64(completion/1000)
|
||||||
|
|
||||||
|
case "gpt-4o-mini-audio-preview", "gpt-4o-mini-audio-preview-2024-12-17":
|
||||||
|
cost = 0.00015*float64(prompt/1000) + 0.0006*float64(completion/1000)
|
||||||
|
case "gpt-4o-mini-realtime-preview", "gpt-4o-mini-realtime-preview-2024-12-17":
|
||||||
|
cost = 0.0006*float64(prompt/1000) + 0.0024*float64(completion/1000)
|
||||||
|
|
||||||
case "whisper-1":
|
case "whisper-1":
|
||||||
// 0.006$/min
|
// 0.006$/min
|
||||||
cost = 0.006 * float64(prompt+completion) / 60
|
cost = 0.006 * float64(prompt+completion) / 60
|
||||||
@@ -183,6 +193,8 @@ func Cost(model string, promptCount, completionCount int) float64 {
|
|||||||
cost = (0.00035/1000)*float64(prompt) + (0.00053/1000)*float64(completion)
|
cost = (0.00035/1000)*float64(prompt) + (0.00053/1000)*float64(completion)
|
||||||
case "gemini-2.0-flash-exp":
|
case "gemini-2.0-flash-exp":
|
||||||
cost = (0.00035/1000)*float64(prompt) + (0.00053/1000)*float64(completion)
|
cost = (0.00035/1000)*float64(prompt) + (0.00053/1000)*float64(completion)
|
||||||
|
case "gemini-2.0-flash-thinking-exp-1219", "gemini-2.0-flash-thinking-exp-01-21":
|
||||||
|
cost = (0.00035/1000)*float64(prompt) + (0.00053/1000)*float64(completion)
|
||||||
case "learnlm-1.5-pro-experimental", " gemini-exp-1114", "gemini-exp-1121", "gemini-exp-1206":
|
case "learnlm-1.5-pro-experimental", " gemini-exp-1114", "gemini-exp-1121", "gemini-exp-1206":
|
||||||
cost = (0.00035/1000)*float64(prompt) + (0.00053/1000)*float64(completion)
|
cost = (0.00035/1000)*float64(prompt) + (0.00053/1000)*float64(completion)
|
||||||
|
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"opencatd-open/internal/api"
|
||||||
|
"opencatd-open/internal/channel"
|
||||||
|
"opencatd-open/internal/dao"
|
||||||
|
"opencatd-open/internal/passkey"
|
||||||
|
"opencatd-open/internal/proxy"
|
||||||
|
"opencatd-open/internal/usage"
|
||||||
|
"opencatd-open/middleware"
|
||||||
|
"opencatd-open/pkg/config"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
if cfg == nil || db == nil {
|
||||||
|
panic("cfg or db is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to get underlying *sql.DB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize DAOs
|
||||||
|
userDAO := dao.NewUserDAO(db)
|
||||||
|
apiKeyDAO := dao.NewApiKeyDAO(db)
|
||||||
|
usageDAO := dao.NewUsageDAO(db)
|
||||||
|
dailyDAO := dao.NewDailyUsageDAO(db)
|
||||||
|
channelDAO := dao.NewChannelDAO(db)
|
||||||
|
modelDAO := dao.NewModelDAO(db)
|
||||||
|
|
||||||
|
// Initialize channel service
|
||||||
|
channelSvc := channel.NewService(channelDAO, modelDAO)
|
||||||
|
|
||||||
|
// Initialize health checker and start periodic checks
|
||||||
|
healthChecker := channel.NewHealthChecker(channelDAO, channelSvc)
|
||||||
|
go healthChecker.StartPeriodicCheck(ctx)
|
||||||
|
|
||||||
|
// Initialize usage recorder and start background worker
|
||||||
|
usageRecorder := usage.NewRecorder(usageDAO, dailyDAO)
|
||||||
|
usageRecorder.Start(ctx)
|
||||||
|
defer usageRecorder.Stop()
|
||||||
|
|
||||||
|
// Initialize gateway
|
||||||
|
gateway := proxy.NewGateway(ctx, cfg, db, &wg, userDAO, apiKeyDAO, usageDAO, dailyDAO)
|
||||||
|
gateway.SetChannelService(channelSvc)
|
||||||
|
gateway.SetUsageRecorder(usageRecorder)
|
||||||
|
|
||||||
|
// Initialize passkey service
|
||||||
|
var rdb *redis.Client
|
||||||
|
if cfg.RedisHost != "" {
|
||||||
|
rdb = redis.NewClient(&redis.Options{
|
||||||
|
Addr: fmt.Sprintf("%s:%d", cfg.RedisHost, cfg.RedisPort),
|
||||||
|
Password: cfg.RedisPassword,
|
||||||
|
DB: cfg.RedisDB,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
passkeySvc, err := passkey.New(db, passkey.Config{
|
||||||
|
RPID: cfg.RPID,
|
||||||
|
Origins: cfg.RPOrigins,
|
||||||
|
Name: cfg.AppName,
|
||||||
|
Redis: rdb,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to initialize passkey service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize API handler
|
||||||
|
apiHandler := api.NewHandler(db, passkeySvc)
|
||||||
|
|
||||||
|
r := gin.Default()
|
||||||
|
r.Use(middleware.CORS())
|
||||||
|
|
||||||
|
// Public auth routes
|
||||||
|
public := r.Group("/api/auth")
|
||||||
|
{
|
||||||
|
public.POST("/register", apiHandler.Register)
|
||||||
|
public.POST("/login", apiHandler.Login)
|
||||||
|
public.POST("/passkey/begin", apiHandler.PasskeyLoginBegin)
|
||||||
|
public.POST("/passkey/finish", apiHandler.PasskeyLoginComplete)
|
||||||
|
}
|
||||||
|
|
||||||
|
// API routes (authenticated)
|
||||||
|
apiGroup := r.Group("/api", middleware.Auth(db))
|
||||||
|
{
|
||||||
|
// User profile
|
||||||
|
apiGroup.GET("/me", apiHandler.Me)
|
||||||
|
apiGroup.GET("/profile", apiHandler.Me)
|
||||||
|
apiGroup.POST("/profile/update", apiHandler.UpdateProfile)
|
||||||
|
apiGroup.POST("/profile/update/password", apiHandler.UpdatePassword)
|
||||||
|
|
||||||
|
// Passkey management
|
||||||
|
apiGroup.POST("/webauthn/register/begin", apiHandler.PasskeyRegisterBegin)
|
||||||
|
apiGroup.POST("/webauthn/register/complete", apiHandler.PasskeyRegisterComplete)
|
||||||
|
apiGroup.GET("/webauthn/passkeys", apiHandler.PasskeyList)
|
||||||
|
apiGroup.DELETE("/webauthn/passkeys/:id", apiHandler.PasskeyDelete)
|
||||||
|
|
||||||
|
// User management (admin)
|
||||||
|
apiGroup.GET("/users", apiHandler.ListUsers)
|
||||||
|
apiGroup.GET("/users/:id", apiHandler.GetUser)
|
||||||
|
apiGroup.POST("/users", apiHandler.CreateUser)
|
||||||
|
apiGroup.PUT("/users/:id", apiHandler.UpdateUser)
|
||||||
|
apiGroup.DELETE("/users/:id", apiHandler.DeleteUser)
|
||||||
|
apiGroup.POST("/users/batch/:option", apiHandler.BatchUsers)
|
||||||
|
|
||||||
|
// API Key management
|
||||||
|
apiGroup.GET("/keys", apiHandler.ListApiKeys)
|
||||||
|
apiGroup.GET("/keys/:id", apiHandler.GetApiKey)
|
||||||
|
apiGroup.POST("/keys", apiHandler.CreateApiKey)
|
||||||
|
apiGroup.PUT("/keys/:id", apiHandler.UpdateApiKey)
|
||||||
|
apiGroup.DELETE("/keys/:id", apiHandler.DeleteApiKey)
|
||||||
|
apiGroup.POST("/keys/batch/:option", apiHandler.BatchApiKeys)
|
||||||
|
|
||||||
|
// Channel management (legacy endpoints)
|
||||||
|
apiGroup.GET("/channels", apiHandler.ListChannels)
|
||||||
|
apiGroup.POST("/channels", apiHandler.CreateChannel)
|
||||||
|
apiGroup.PUT("/channels/:id", apiHandler.UpdateChannel)
|
||||||
|
apiGroup.DELETE("/channels/:id", apiHandler.DeleteChannel)
|
||||||
|
apiGroup.GET("/channels/:id/models", apiHandler.GetChannelModels)
|
||||||
|
apiGroup.POST("/channels/:id/models", apiHandler.BindChannelModels)
|
||||||
|
|
||||||
|
// Model management (legacy endpoints)
|
||||||
|
apiGroup.GET("/models", apiHandler.ListModels)
|
||||||
|
apiGroup.POST("/models", apiHandler.CreateModel)
|
||||||
|
apiGroup.PUT("/models/:id", apiHandler.UpdateModel)
|
||||||
|
apiGroup.DELETE("/models/:id", apiHandler.DeleteModel)
|
||||||
|
|
||||||
|
// 用户自身用量统计
|
||||||
|
apiGroup.GET("/usage/stats", apiHandler.MyUsageStats)
|
||||||
|
apiGroup.GET("/usage/monthly", apiHandler.MyUsageMonthly)
|
||||||
|
apiGroup.GET("/usage/logs", apiHandler.MyUsageLogs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin API (requires admin role)
|
||||||
|
adminGroup := r.Group("/api/admin", middleware.Auth(db), middleware.AdminOnly())
|
||||||
|
{
|
||||||
|
// Admin channel management (enhanced)
|
||||||
|
adminGroup.GET("/channels", apiHandler.AdminChannels)
|
||||||
|
adminGroup.POST("/channels", apiHandler.AdminCreateChannel)
|
||||||
|
adminGroup.PUT("/channels/:id", apiHandler.AdminUpdateChannel)
|
||||||
|
adminGroup.DELETE("/channels/:id", apiHandler.AdminDeleteChannel)
|
||||||
|
adminGroup.POST("/channels/:id/test", apiHandler.AdminTestChannel)
|
||||||
|
adminGroup.GET("/channels/:id/models/remote", apiHandler.AdminChannelRemoteModels)
|
||||||
|
adminGroup.GET("/channels/:id/models", apiHandler.AdminChannelModels)
|
||||||
|
adminGroup.POST("/channels/:id/models", apiHandler.AdminChannelAddModel)
|
||||||
|
adminGroup.PATCH("/channels/:id/models/:bid", apiHandler.AdminChannelUpdateModel)
|
||||||
|
adminGroup.DELETE("/channels/:id/models/:bid", apiHandler.AdminChannelDeleteModel)
|
||||||
|
|
||||||
|
// Admin model management (enhanced)
|
||||||
|
adminGroup.GET("/models", apiHandler.AdminModels)
|
||||||
|
adminGroup.DELETE("/models/unused", apiHandler.AdminDeleteUnusedModels)
|
||||||
|
adminGroup.POST("/models", apiHandler.AdminCreateModel)
|
||||||
|
adminGroup.PUT("/models/:id", apiHandler.AdminUpdateModel)
|
||||||
|
adminGroup.DELETE("/models/:id", apiHandler.AdminDeleteModel)
|
||||||
|
adminGroup.POST("/models/:id/bindings", apiHandler.AdminCreateModelBinding)
|
||||||
|
adminGroup.DELETE("/models/:id/bindings/:bid", apiHandler.AdminDeleteModelBinding)
|
||||||
|
|
||||||
|
// Admin system config
|
||||||
|
adminGroup.GET("/config", apiHandler.AdminGetConfig)
|
||||||
|
adminGroup.PUT("/config", apiHandler.AdminUpdateConfig)
|
||||||
|
adminGroup.GET("/config/registration", apiHandler.AdminGetRegistration)
|
||||||
|
adminGroup.PUT("/config/registration", apiHandler.AdminUpdateRegistration)
|
||||||
|
adminGroup.GET("/config/password-login", apiHandler.AdminGetPasswordLogin)
|
||||||
|
adminGroup.PUT("/config/password-login", apiHandler.AdminUpdatePasswordLogin)
|
||||||
|
|
||||||
|
// Admin usage
|
||||||
|
adminGroup.GET("/usage/logs", apiHandler.AdminUsageLogs)
|
||||||
|
adminGroup.GET("/usage/summary", apiHandler.AdminUsageSummary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLM proxy routes
|
||||||
|
v1 := r.Group("/v1")
|
||||||
|
v1.Use(middleware.AuthLLM(db))
|
||||||
|
{
|
||||||
|
v1.POST("/chat/completions", gateway.HandleChat)
|
||||||
|
v1.POST("/messages", gateway.HandleMessages)
|
||||||
|
v1.POST("/responses", gateway.HandleResponses)
|
||||||
|
v1.GET("/models", gateway.HandleModels)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SPA fallback
|
||||||
|
idxFS, err := fs.Sub(web, "dist")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assetsFS, err := fs.Sub(web, "dist/assets")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
r.StaticFS("/assets", http.FS(assetsFS))
|
||||||
|
|
||||||
|
r.NoRoute(func(c *gin.Context) {
|
||||||
|
if c.Writer.Status() == http.StatusNotFound {
|
||||||
|
c.FileFromFS("/", http.FS(idxFS))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||||
|
Handler: r,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
fmt.Println("Starting server at port:", cfg.Port)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("listen: %s\n", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
fmt.Println("\nShutdown Server ...")
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
|
||||||
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
|
log.Fatalln("Server Shutdown:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
sqlDB.Close()
|
||||||
|
|
||||||
|
waitChan := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(waitChan)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-waitChan:
|
||||||
|
fmt.Println("All goroutines have finished")
|
||||||
|
case <-shutdownCtx.Done():
|
||||||
|
fmt.Println("⚠️ Shutdown timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Server exited")
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
//go:build wireinject
|
||||||
|
// +build wireinject
|
||||||
|
|
||||||
|
package wire
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"opencatd-open/internal/channel"
|
||||||
|
"opencatd-open/internal/controller/proxy"
|
||||||
|
"opencatd-open/internal/dao"
|
||||||
|
"opencatd-open/pkg/config"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/google/wire"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var daoSet = wire.NewSet(
|
||||||
|
dao.NewUserDAO,
|
||||||
|
dao.NewApiKeyDAO,
|
||||||
|
dao.NewTokenDAO,
|
||||||
|
dao.NewUsageDAO,
|
||||||
|
dao.NewDailyUsageDAO,
|
||||||
|
dao.NewChannelDAO,
|
||||||
|
dao.NewModelDAO,
|
||||||
|
)
|
||||||
|
|
||||||
|
var channelSet = wire.NewSet(
|
||||||
|
channel.NewService,
|
||||||
|
channel.NewHealthChecker,
|
||||||
|
)
|
||||||
|
|
||||||
|
func InitProxyHandler(ctx context.Context, cfg *config.Config, db *gorm.DB, wg *sync.WaitGroup) (*proxy.Proxy, error) {
|
||||||
|
wire.Build(daoSet, channelSet, proxy.NewProxy)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# 前端阶段固定在构建机原生平台编译:多架构构建(linux/amd64,linux/arm64)时
|
||||||
|
# 只编译一次,不再被 QEMU 模拟执行两遍
|
||||||
|
FROM --platform=$BUILDPLATFORM node:22-alpine AS frontend
|
||||||
|
WORKDIR /frontend-build
|
||||||
|
COPY ./frontend ./
|
||||||
|
RUN npm install -g pnpm@10.25.0 \
|
||||||
|
&& pnpm install --frozen-lockfile \
|
||||||
|
&& pnpm build
|
||||||
|
|
||||||
|
# 后端:go.mod 使用纯 Go 的 glebarez/sqlite,可关闭 CGO 直接交叉编译到目标架构,
|
||||||
|
# 因此同样固定在原生平台构建
|
||||||
|
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS backend
|
||||||
|
LABEL author="github.com/Sakurasan"
|
||||||
|
ARG TARGETOS
|
||||||
|
ARG TARGETARCH
|
||||||
|
RUN apk --no-cache add make upx
|
||||||
|
WORKDIR /build
|
||||||
|
COPY . .
|
||||||
|
COPY --from=frontend /frontend-build/dist /build/backend/cmd/openteam/dist
|
||||||
|
ENV GO111MODULE=on \
|
||||||
|
CGO_ENABLED=0 \
|
||||||
|
GOOS=$TARGETOS \
|
||||||
|
GOARCH=$TARGETARCH
|
||||||
|
RUN make build-backend
|
||||||
|
|
||||||
|
FROM alpine:latest AS runner
|
||||||
|
# 设置alpine 时间为上海时间
|
||||||
|
RUN apk update && apk --no-cache add tzdata ffmpeg ca-certificates && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||||
|
&& echo "Asia/Shanghai" > /etc/timezone
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=backend /build/bin/openteam /app/openteam
|
||||||
|
ENV GIN_MODE=release
|
||||||
|
ENV PATH=$PATH:/app
|
||||||
|
EXPOSE 80 443
|
||||||
|
ENTRYPOINT ["/app/openteam"]
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# 国内镜像加速版:npm/apk/go proxy 均走国内源
|
||||||
|
|
||||||
|
# 前端阶段固定在构建机原生平台编译:多架构构建(linux/amd64,linux/arm64)时
|
||||||
|
# 只编译一次,不再被 QEMU 模拟执行两遍
|
||||||
|
FROM --platform=$BUILDPLATFORM node:22-alpine AS frontend
|
||||||
|
WORKDIR /frontend-build
|
||||||
|
COPY ./frontend ./
|
||||||
|
RUN npm config set registry https://registry.npmmirror.com \
|
||||||
|
&& npm install -g pnpm@10.25.0 --registry=https://registry.npmmirror.com \
|
||||||
|
&& pnpm install --frozen-lockfile \
|
||||||
|
&& pnpm build
|
||||||
|
|
||||||
|
# 后端:go.mod 使用纯 Go 的 glebarez/sqlite,可关闭 CGO 直接交叉编译到目标架构,
|
||||||
|
# 因此同样固定在原生平台构建
|
||||||
|
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS backend
|
||||||
|
LABEL author="github.com/Sakurasan"
|
||||||
|
ARG TARGETOS
|
||||||
|
ARG TARGETARCH
|
||||||
|
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
|
||||||
|
&& apk --no-cache add make upx
|
||||||
|
WORKDIR /build
|
||||||
|
COPY . .
|
||||||
|
COPY --from=frontend /frontend-build/dist /build/backend/cmd/openteam/dist
|
||||||
|
ENV GO111MODULE=on \
|
||||||
|
GOPROXY=https://goproxy.cn,direct \
|
||||||
|
CGO_ENABLED=0 \
|
||||||
|
GOOS=$TARGETOS \
|
||||||
|
GOARCH=$TARGETARCH
|
||||||
|
RUN make build-backend
|
||||||
|
|
||||||
|
FROM alpine:latest AS runner
|
||||||
|
# 设置alpine 时间为上海时间
|
||||||
|
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
|
||||||
|
&& apk update && apk --no-cache add tzdata ffmpeg ca-certificates && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||||
|
&& echo "Asia/Shanghai" > /etc/timezone
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=backend /build/bin/openteam /app/openteam
|
||||||
|
ENV GIN_MODE=release
|
||||||
|
ENV PATH=$PATH:/app
|
||||||
|
EXPOSE 80 443
|
||||||
|
ENTRYPOINT ["/app/openteam"]
|
||||||
Executable
+48
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# 构建 dev 镜像:默认多平台(amd64/arm64)并推送,PUSH=0 时按本机架构构建并加载到本地
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# sh deploy/docker/build-dev.sh # 多平台构建并推送 dev 标签
|
||||||
|
# PUSH=0 sh deploy/docker/build-dev.sh # 本机架构构建 --load,仅本地
|
||||||
|
# REGISTRY_IMAGE=demo/openteam PLATFORMS=linux/amd64 sh deploy/docker/build-dev.sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/../.."
|
||||||
|
|
||||||
|
REGISTRY_IMAGE=${REGISTRY_IMAGE:-mirrors2/opencatd-open}
|
||||||
|
PLATFORMS=${PLATFORMS:-linux/amd64,linux/arm64}
|
||||||
|
BUILDER=${BUILDER:-xbuilder}
|
||||||
|
PUSH=${PUSH:-1}
|
||||||
|
DOCKERFILE=${DOCKERFILE:-deploy/docker/Dockerfile}
|
||||||
|
|
||||||
|
SHORT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||||
|
DATE_TAG=$(date +%Y%m%d)
|
||||||
|
|
||||||
|
# 确保 buildx 容器构建器存在(默认 docker driver 不支持多平台)
|
||||||
|
docker buildx inspect "$BUILDER" >/dev/null 2>&1 || docker buildx create --name "$BUILDER" --driver docker-container >/dev/null
|
||||||
|
|
||||||
|
TAGS="-t $REGISTRY_IMAGE:dev -t $REGISTRY_IMAGE:dev-$SHORT_SHA -t $REGISTRY_IMAGE:dev-$DATE_TAG"
|
||||||
|
|
||||||
|
if [ "$PUSH" = "1" ]; then
|
||||||
|
echo ">>> 多平台构建并推送: $PLATFORMS"
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
docker buildx build \
|
||||||
|
--builder "$BUILDER" \
|
||||||
|
--platform "$PLATFORMS" \
|
||||||
|
$TAGS \
|
||||||
|
--cache-from type=registry,ref="$REGISTRY_IMAGE:dev" \
|
||||||
|
--cache-to type=inline \
|
||||||
|
-f "$DOCKERFILE" \
|
||||||
|
. --push
|
||||||
|
echo ">>> 已推送: $REGISTRY_IMAGE:dev (dev-$SHORT_SHA, dev-$DATE_TAG)"
|
||||||
|
else
|
||||||
|
HOST_ARCH=$(docker version -f '{{.Server.Arch}}')
|
||||||
|
echo ">>> 本机架构构建并加载: linux/$HOST_ARCH"
|
||||||
|
docker buildx build \
|
||||||
|
--builder "$BUILDER" \
|
||||||
|
--platform "linux/$HOST_ARCH" \
|
||||||
|
$TAGS \
|
||||||
|
-f "$DOCKERFILE" \
|
||||||
|
. --load
|
||||||
|
echo ">>> 已加载本地镜像: $REGISTRY_IMAGE:dev (dev-$SHORT_SHA, dev-$DATE_TAG)"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
version: '3.9'
|
||||||
|
|
||||||
|
services:
|
||||||
|
adminer:
|
||||||
|
image: adminer
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- 8080:8080
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
version: '3.9'
|
||||||
|
|
||||||
|
services:
|
||||||
|
mariadb:
|
||||||
|
image: mariadb
|
||||||
|
container_name: mysql
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
volumes:
|
||||||
|
- ${PWD}/mysqldb:/var/lib/mysql
|
||||||
|
command:
|
||||||
|
- --character-set-server=utf8mb4
|
||||||
|
- --collation-server=utf8mb4_unicode_ci
|
||||||
|
- --skip-character-set-client-handshake
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: openteam
|
||||||
|
MYSQL_DATABASE: openteam
|
||||||
|
MYSQL_USER: openteam
|
||||||
|
MYSQL_PASSWORD: openteam
|
||||||
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# CREATE EXTENSION vector;
|
||||||
|
# SELECT * FROM pg_extension;
|
||||||
|
# SELECT * FROM pg_available_extensions;
|
||||||
|
|
||||||
|
version: '3.9'
|
||||||
|
|
||||||
|
services:
|
||||||
|
pg:
|
||||||
|
image: pgvector/pgvector:pg17
|
||||||
|
# image: paradedb/paradedb
|
||||||
|
container_name: pg
|
||||||
|
restart: always
|
||||||
|
# network_mode: host
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: openteam
|
||||||
|
POSTGRES_USER: openteam
|
||||||
|
POSTGRES_PASSWORD: openteam
|
||||||
|
volumes:
|
||||||
|
- $PWD/pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
version: '3.7'
|
||||||
|
services:
|
||||||
|
sqlite-web:
|
||||||
|
image: vaalacat/sqlite-web
|
||||||
|
ports:
|
||||||
|
- 8800:8080
|
||||||
|
volumes:
|
||||||
|
- $PWD/db:/data
|
||||||
|
environment:
|
||||||
|
- SQLITE_DATABASE=openteam.db
|
||||||
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px"><path fill="#8ce7f2" d="M44.475,24.803c0.774-2.528,0.53-5.23-0.722-7.583c-2.018-3.792-6.225-5.638-10.284-5.086 c-1.802-1.935-4.265-3.074-6.929-3.166c-4.294-0.149-7.996,2.572-9.547,6.363c-2.576,0.593-4.794,2.156-6.206,4.417 c-2.275,3.643-1.771,8.21,0.737,11.449c-0.774,2.528-0.53,5.23,0.722,7.583c2.018,3.792,6.225,5.638,10.284,5.086 c1.802,1.935,4.265,3.074,6.929,3.167c4.293,0.148,7.996-2.573,9.547-6.364c2.576-0.593,4.794-2.156,6.205-4.417 C47.486,32.608,46.982,28.042,44.475,24.803z"/><path fill="#18193f" d="M38.844,17.559l-7.523-4.343c-0.493-0.284-1.1-0.285-1.594-0.003l-10.245,5.855l0.021-4.018 l7.913-4.569c3.445-1.989,7.938-1.371,10.44,1.722c0.594,0.734,1.04,1.539,1.341,2.382c0.211,0.592,0.772,0.984,1.4,0.984 c1.037,0,1.772-1.03,1.421-2.006c-0.416-1.158-1.033-2.265-1.853-3.275c-2.488-3.065-6.393-4.357-10.151-3.807 c-1.987-2.124-4.699-3.373-7.63-3.473c-4.733-0.161-8.814,2.839-10.525,7.018c-2.842,0.654-5.289,2.378-6.847,4.873 c-3.318,5.313-1.284,12.41,4.142,15.543l7.523,4.343c0.493,0.284,1.1,0.285,1.594,0.003l10.245-5.855l-0.021,4.018l-7.902,4.563 c-3.448,1.991-7.945,1.378-10.451-1.715c-0.591-0.73-1.035-1.53-1.336-2.368c-0.212-0.591-0.772-0.982-1.4-0.982h0 c-1.039,0-1.774,1.033-1.421,2.01c0.326,0.901,0.774,1.771,1.344,2.589c2.43,3.487,6.613,5.039,10.645,4.465 c1.987,2.129,4.7,3.381,7.634,3.483c4.736,0.163,8.82-2.838,10.531-7.02c2.841-0.654,5.288-2.378,6.844-4.872 C46.303,27.788,44.269,20.691,38.844,17.559z M34,33.723c0,4.324-3.313,8.077-7.633,8.269c-1.837,0.082-3.585-0.463-5.024-1.496 c0.274-0.13,0.546-0.266,0.812-0.42l7.521-4.342c0.493-0.285,0.799-0.81,0.802-1.38l0.054-9.883c0.003-0.55-0.441-0.999-0.992-1 c-0.549-0.002-0.995,0.441-0.998,0.99l-0.011,2.172L18.498,32.37l-7.918-4.571c-3.745-2.163-5.339-6.908-3.345-10.745 c0.848-1.633,2.196-2.875,3.812-3.605C11.022,13.753,11,14.058,11,14.367v8.684c0,0.569,0.302,1.095,0.794,1.382l8.73,5.055 c0.475,0.275,1.082,0.113,1.358-0.361c0.277-0.476,0.114-1.085-0.362-1.361L14,23.42v-9.143c0-4.325,3.313-8.077,7.634-8.269 c1.835-0.081,3.582,0.462,5.02,1.494c-0.264,0.127-0.526,0.259-0.782,0.407l-7.548,4.357c-0.494,0.285-0.799,0.81-0.802,1.38 l-0.054,9.797c-0.003,0.55,0.441,0.999,0.992,1c0.549,0.002,0.995-0.441,0.998-0.99l0.011-2.087l4.552-2.603L34,24.58V33.723z M40.765,30.946c-0.848,1.633-2.195,2.875-3.812,3.604C36.978,34.248,37,33.944,37,33.636v-8.687c0-0.569-0.302-1.095-0.794-1.382 l-10.191-5.943l3.487-1.994l7.918,4.571C41.165,22.364,42.759,27.109,40.765,30.946z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 2.5 KiB |
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 239 KiB |
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 201 KiB |
Vendored
-15
@@ -1,15 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/logo-4312ea85.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>opencatd-open</title>
|
|
||||||
<script type="module" crossorigin src="/assets/index-9a4663b1.js"></script>
|
|
||||||
<link rel="stylesheet" href="/assets/index-ef8ba4ac.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 48 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user