refactor: complete backend rewrite for multi-protocol proxy
Major rewrite of the Go backend to support:
- Three API format imports: openai, anthropic, compatible
- Three protocol conversions: Chat Completions, Responses, Messages
- Hub-and-spoke architecture with Chat as intermediate format
Deleted:
- opencat.go (old entry)
- store/, team/, pkg/team/, pkg/store/ (old data layer)
- internal/model/, internal/consts/ (old types)
- internal/service/team/, internal/controller/team/ (old handlers)
- llm/ (removed LLM client library, pure proxy mode)
- dist/, assets/ (old build artifacts)
Added:
- internal/store/ — 9 GORM models + multi-DB support
- internal/pkg/ — crypto (AES-GCM), apikey, jwt, ratelimit, resp, tokenizer
- internal/channel/ — channel selection, weighted LB, health checks
- internal/proxy/convert/ — 6 protocol conversion functions + SSE streaming
- internal/proxy/ — gateway with request dispatch and upstream selection
- internal/usage/ — async usage recorder with batch writes
- internal/api/ — management API (auth, users, keys, channels, models)
- Makefile for build/test/deploy
Fixed API to match frontend expectations:
- Login response wraps token in { data: { token } }
- GET /api/profile route added
- Profile response wraps user in { code, data }
- Role returned as number (10=admin, 1=user)
This commit is contained in:
@@ -4,10 +4,14 @@ demo/
|
|||||||
*.log
|
*.log
|
||||||
*.db
|
*.db
|
||||||
.env
|
.env
|
||||||
|
openteam
|
||||||
|
|
||||||
# 构建产物(make web 生成,由 go:embed 打进二进制);保留 .gitkeep 占位使未构建前也能编译
|
# 构建产物(make web 生成,由 go:embed 打进二进制);保留 .gitkeep 占位使未构建前也能编译
|
||||||
cmd/openteam/dist/*
|
cmd/openteam/dist/*
|
||||||
!cmd/openteam/dist/.gitkeep
|
!cmd/openteam/dist/.gitkeep
|
||||||
|
|
||||||
|
# 前端构建产物(项目根目录的副本)
|
||||||
|
dist/
|
||||||
|
|
||||||
# 误生成的目录(仅含 dist/node_modules)
|
# 误生成的目录(仅含 dist/node_modules)
|
||||||
web/
|
web/
|
||||||
|
|||||||
@@ -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,61 @@
|
|||||||
|
.PHONY: build run test clean fmt lint
|
||||||
|
|
||||||
|
BINARY_NAME=openteam
|
||||||
|
BUILD_DIR=bin
|
||||||
|
|
||||||
|
# Build
|
||||||
|
build:
|
||||||
|
go build -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/openteam
|
||||||
|
|
||||||
|
# Run
|
||||||
|
run: build
|
||||||
|
./$(BUILD_DIR)/$(BINARY_NAME)
|
||||||
|
|
||||||
|
# Development run
|
||||||
|
dev:
|
||||||
|
go run ./cmd/openteam
|
||||||
|
|
||||||
|
# Test
|
||||||
|
test:
|
||||||
|
go test ./internal/... -v
|
||||||
|
|
||||||
|
# Test with coverage
|
||||||
|
test-cover:
|
||||||
|
go test ./internal/... -coverprofile=coverage.out
|
||||||
|
go tool cover -html=coverage.out -o coverage.html
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
fmt:
|
||||||
|
go fmt ./...
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
lint:
|
||||||
|
golangci-lint run
|
||||||
|
|
||||||
|
# Clean
|
||||||
|
clean:
|
||||||
|
rm -rf $(BUILD_DIR)
|
||||||
|
rm -f coverage.out coverage.html
|
||||||
|
|
||||||
|
# Tidy dependencies
|
||||||
|
tidy:
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
# Build for Linux
|
||||||
|
build-linux:
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./cmd/openteam
|
||||||
|
|
||||||
|
# Build for macOS
|
||||||
|
build-mac:
|
||||||
|
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"
|
||||||
@@ -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的开源实现
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Vendored
@@ -6,16 +6,13 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"opencatd-open/internal/cli"
|
"opencatd-open/internal/cli"
|
||||||
"opencatd-open/internal/consts"
|
"opencatd-open/internal/store"
|
||||||
"opencatd-open/pkg/config"
|
"opencatd-open/pkg/config"
|
||||||
"opencatd-open/pkg/store"
|
|
||||||
"opencatd-open/router"
|
"opencatd-open/router"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// all:dist 使 dist 只含 .gitkeep 占位(尚未构建前端)时也能编译通过,
|
|
||||||
// 本地 go run ./cmd/openteam 无需先跑 pnpm build
|
|
||||||
//go:embed all:dist
|
//go:embed all:dist
|
||||||
var web embed.FS
|
var web embed.FS
|
||||||
|
|
||||||
@@ -29,11 +26,11 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
_ = db
|
||||||
|
|
||||||
rootCmd := &cobra.Command{
|
rootCmd := &cobra.Command{
|
||||||
Use: "openteam",
|
Use: "openteam",
|
||||||
Short: "openteam cli",
|
Short: "openteam cli",
|
||||||
Long: consts.Logo,
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
router.SetRouter(cfg, db, &web)
|
router.SetRouter(cfg, db, &web)
|
||||||
},
|
},
|
||||||
@@ -50,7 +47,6 @@ func printFilesAndDirs(fsys fs.FS, prefix string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if d.IsDir() {
|
if d.IsDir() {
|
||||||
fmt.Printf("%s[DIR] %s\n", prefix, p)
|
fmt.Printf("%s[DIR] %s\n", prefix, p)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
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
+21
-7
@@ -1,15 +1,29 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="emerald">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/logo-4312ea85.svg" />
|
<link rel="icon" type="image/svg+xml" href="/assets/logo-BYScUf44.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>opencatd-open</title>
|
<meta name="description" content="OpenTeam — an open-source, team-shared service compatible with the OpenAI API." />
|
||||||
<script type="module" crossorigin src="/assets/index-9a4663b1.js"></script>
|
<meta name="theme-color" content="#ffffff" />
|
||||||
<link rel="stylesheet" href="/assets/index-ef8ba4ac.css">
|
<title>OpenTeam</title>
|
||||||
|
<script>
|
||||||
|
// 恢复主题偏好(light/dark/auto,auto 跟随系统),避免闪烁
|
||||||
|
try {
|
||||||
|
var __p = localStorage.getItem('theme') || 'auto'
|
||||||
|
var __dark = __p === 'dark' || (__p === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
|
document.documentElement.setAttribute('data-theme', __dark ? 'dark' : 'emerald')
|
||||||
|
} catch (e) {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'emerald')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script type="module" crossorigin src="/assets/index-BJCJroRI.js"></script>
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-DK3Fl9T5.js">
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/components-C0OpMyPR.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/components-Bx5gkR8i.css">
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-DgCnyds5.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/src/assets/logo.svg" />
|
<link rel="icon" type="image/svg+xml" href="/src/assets/logo.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="OpenTeam — an open-source, team-shared service compatible with the OpenAI API." />
|
<meta name="description" content="OpenTeam — one endpoint for every LLM provider. Route OpenAI-compatible clients to Claude, Gemini, DeepSeek and more through a single, team-shared endpoint." />
|
||||||
<meta name="theme-color" content="#ffffff" />
|
<meta name="theme-color" content="#ffffff" />
|
||||||
<title>OpenTeam</title>
|
<title>OpenTeam</title>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- 组件根元素:相对定位,设置最大宽度、外边距、宽高比、背景渐变、内边距、圆角、阴影和溢出隐藏 -->
|
<!-- 组件根元素:相对定位,设置最大宽度、外边距、宽高比、背景渐变、内边距、圆角、阴影和溢出隐藏 -->
|
||||||
<div
|
<div
|
||||||
class="relative w-full max-w-4xl mx-auto my-10 aspect-[4/3] backdrop-blur-0 px-4 py-0 my-0 rounded-lg overflow-hidden ">
|
class="relative w-full max-w-4xl mx-auto my-10 aspect-[16/10] sm:aspect-[4/3] backdrop-blur-0 rounded-lg overflow-hidden ">
|
||||||
<!-- bg-gradient-to-br from-slate-50 to-orange-50 -->
|
<!-- bg-gradient-to-br from-slate-50 to-orange-50 -->
|
||||||
<!-- 中心图标容器 -->
|
<!-- 中心图标容器 -->
|
||||||
<div ref="centerElement" class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20">
|
<div ref="centerElement" class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20">
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
{{ star }}
|
{{ star }}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<RouterLink to="/dashboard" class="btn btn-primary btn-sm h-9 min-h-9 rounded-full px-4">
|
<RouterLink to="/dashboard" class="btn btn-primary btn-sm h-9 min-h-9 rounded-full px-3 sm:px-4 whitespace-nowrap">
|
||||||
Open Dashboard
|
Open Dashboard
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
<img src="@/assets/openteam.png" alt="OpenTeam project logo" width="160" height="160" class="h-28 w-auto select-none" fetchpriority="high" />
|
<img src="@/assets/openteam.png" alt="OpenTeam project logo" width="160" height="160" class="h-28 w-auto select-none" fetchpriority="high" />
|
||||||
<h1 class="mt-4 text-4xl font-bold tracking-tight text-balance sm:text-5xl">OpenTeam</h1>
|
<h1 class="mt-4 text-4xl font-bold tracking-tight text-balance sm:text-5xl">OpenTeam</h1>
|
||||||
<p class="mt-4 max-w-2xl text-pretty text-lg text-base-content/70">
|
<p class="mt-4 max-w-2xl text-pretty text-lg text-base-content/70">
|
||||||
An open-source, team-shared service compatible with the OpenAI API — share LLM API usage across your team through a single endpoint.
|
One endpoint for every model. Route OpenAI-compatible clients to any LLM provider — share costs, track usage, stay in control.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="tooltip mt-8 w-full max-w-xl" data-tip="Point your client at the OpenTeam base URL">
|
<div class="tooltip mt-8 w-full max-w-xl" data-tip="Point your client at the OpenTeam base URL">
|
||||||
@@ -78,6 +78,9 @@
|
|||||||
👉 Api-Keys:
|
👉 Api-Keys:
|
||||||
<a href="https://platform.openai.com/account/api-keys" class="link link-hover link-primary">platform.openai.com/account/api-keys</a>
|
<a href="https://platform.openai.com/account/api-keys" class="link link-hover link-primary">platform.openai.com/account/api-keys</a>
|
||||||
</p>
|
</p>
|
||||||
|
<p class="mt-3 text-sm text-base-content/60">
|
||||||
|
Connects to OpenAI, Claude, Gemini, DeepSeek and other OpenAI-compatible providers through a single endpoint.
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- How it works -->
|
<!-- How it works -->
|
||||||
|
|||||||
@@ -164,7 +164,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="passkeys && passkeys.length" class="-mx-4 overflow-x-auto sm:-mx-6">
|
<div v-if="passkeys && passkeys.length" class="overflow-x-auto">
|
||||||
<table class="table table-sm">
|
<table class="table table-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="token in user.tokens" :key="token.id" class="border-base-300/40 hover:bg-base-200/50">
|
<tr v-for="token in user.tokens" :key="token.id" class="border-base-300/40 hover:bg-base-200/50">
|
||||||
<td class="pl-4 font-medium">{{ token.name }}</td>
|
<td class="pl-4 font-medium truncate max-w-[120px] sm:max-w-[180px]">{{ token.name }}</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="checkbox" class="toggle toggle-success toggle-sm"
|
<input type="checkbox" class="toggle toggle-success toggle-sm"
|
||||||
:class="!token.active && 'toggle-error'" v-model="token.active"
|
:class="!token.active && 'toggle-error'" v-model="token.active"
|
||||||
|
|||||||
@@ -144,8 +144,8 @@
|
|||||||
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
<div class="card-body p-4 sm:p-5">
|
<div class="card-body p-4 sm:p-5">
|
||||||
<h3 class="card-title pb-1 text-base font-semibold">Tokens</h3>
|
<h3 class="card-title pb-1 text-base font-semibold">Tokens</h3>
|
||||||
<div v-if="user.tokens && user.tokens.length" class="-mx-4 overflow-x-auto sm:-mx-5">
|
<div v-if="user.tokens && user.tokens.length" class="overflow-x-auto">
|
||||||
<table class="table table-sm">
|
<table class="table table-sm min-w-[520px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
||||||
<th class="pl-4">Token Name</th>
|
<th class="pl-4">Token Name</th>
|
||||||
|
|||||||
@@ -3,66 +3,35 @@ module opencatd-open
|
|||||||
go 1.23.2
|
go 1.23.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go/vertexai v0.13.1
|
|
||||||
github.com/Sakurasan/to v0.0.0-20180919163141-e72657dd7c7d
|
|
||||||
github.com/bluele/gcache v0.0.2
|
|
||||||
github.com/coder/websocket v1.8.12
|
|
||||||
github.com/duke-git/lancet/v2 v2.3.3
|
|
||||||
github.com/faiface/beep v1.1.0
|
|
||||||
github.com/gin-contrib/cors v1.7.2
|
github.com/gin-contrib/cors v1.7.2
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/go-ozzo/ozzo-validation/v4 v4.4.1
|
||||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
|
|
||||||
github.com/go-webauthn/webauthn v0.12.3
|
github.com/go-webauthn/webauthn v0.12.3
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||||
github.com/google/generative-ai-go v0.18.0
|
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/google/wire v0.6.0
|
github.com/google/wire v0.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/liushuangls/go-anthropic/v2 v2.15.0
|
|
||||||
github.com/mileusna/useragent v1.3.5
|
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
|
||||||
github.com/pkoukk/tiktoken-go v0.1.7
|
github.com/pkoukk/tiktoken-go v0.1.7
|
||||||
github.com/sashabaranov/go-openai v1.32.2
|
github.com/sashabaranov/go-openai v1.42.0
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
github.com/tidwall/gjson v1.18.0
|
|
||||||
golang.org/x/crypto v0.37.0
|
golang.org/x/crypto v0.37.0
|
||||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c
|
|
||||||
golang.org/x/sync v0.13.0
|
|
||||||
golang.org/x/time v0.10.0
|
golang.org/x/time v0.10.0
|
||||||
google.golang.org/api v0.224.0
|
|
||||||
google.golang.org/genai v1.0.0
|
|
||||||
gopkg.in/vansante/go-ffprobe.v2 v2.2.0
|
|
||||||
gorm.io/driver/mysql v1.5.7
|
gorm.io/driver/mysql v1.5.7
|
||||||
gorm.io/driver/postgres v1.5.11
|
gorm.io/driver/postgres v1.5.11
|
||||||
gorm.io/gorm v1.25.12
|
gorm.io/driver/sqlite v1.6.0
|
||||||
|
gorm.io/gorm v1.30.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go v0.120.0 // indirect
|
|
||||||
cloud.google.com/go/ai v0.8.2 // indirect
|
|
||||||
cloud.google.com/go/aiplatform v1.74.0 // indirect
|
|
||||||
cloud.google.com/go/auth v0.15.0 // indirect
|
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect
|
|
||||||
cloud.google.com/go/compute/metadata v0.6.0 // indirect
|
|
||||||
cloud.google.com/go/iam v1.4.0 // indirect
|
|
||||||
cloud.google.com/go/longrunning v0.6.4 // indirect
|
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
github.com/bytedance/sonic v1.13.2 // indirect
|
github.com/bytedance/sonic v1.13.2 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
|
||||||
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
|
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.22.0 // indirect
|
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.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-playground/validator/v10 v10.26.0 // indirect
|
||||||
@@ -71,11 +40,6 @@ require (
|
|||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/google/go-cmp v0.7.0 // indirect
|
github.com/google/go-cmp v0.7.0 // indirect
|
||||||
github.com/google/go-tpm v0.9.3 // indirect
|
github.com/google/go-tpm v0.9.3 // indirect
|
||||||
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 // indirect
|
|
||||||
github.com/google/s2a-go v0.1.9 // indirect
|
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
|
||||||
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
|
|
||||||
github.com/hajimehoshi/go-mp3 v0.3.4 // indirect
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
@@ -85,40 +49,23 @@ require (
|
|||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 // 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/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
|
||||||
github.com/tidwall/pretty v1.2.0 // indirect
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 // indirect
|
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
|
|
||||||
go.opentelemetry.io/otel v1.35.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
|
||||||
golang.org/x/arch v0.16.0 // indirect
|
golang.org/x/arch v0.16.0 // indirect
|
||||||
golang.org/x/net v0.39.0 // indirect
|
golang.org/x/net v0.39.0 // indirect
|
||||||
golang.org/x/oauth2 v0.28.0 // indirect
|
golang.org/x/sync v0.13.0 // indirect
|
||||||
golang.org/x/sys v0.32.0 // indirect
|
golang.org/x/sys v0.32.0 // indirect
|
||||||
golang.org/x/text v0.24.0 // indirect
|
golang.org/x/text v0.24.0 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb // indirect
|
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250409194420-de1ac958c67a // indirect
|
|
||||||
google.golang.org/grpc v1.71.1 // indirect
|
|
||||||
google.golang.org/protobuf v1.36.6 // indirect
|
google.golang.org/protobuf v1.36.6 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
modernc.org/libc v1.61.0 // indirect
|
|
||||||
modernc.org/mathutil v1.6.0 // indirect
|
|
||||||
modernc.org/memory v1.8.0 // indirect
|
|
||||||
modernc.org/sqlite v1.33.1 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,30 +1,7 @@
|
|||||||
cloud.google.com/go v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA=
|
|
||||||
cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q=
|
|
||||||
cloud.google.com/go/ai v0.8.2 h1:LEaQwqBv+k2ybrcdTtCTc9OPZXoEdcQaGrfvDYS6Bnk=
|
|
||||||
cloud.google.com/go/ai v0.8.2/go.mod h1:Wb3EUUGWwB6yHBaUf/+oxUq/6XbCaU1yh0GrwUS8lr4=
|
|
||||||
cloud.google.com/go/aiplatform v1.74.0 h1:rE2P5H7FOAFISAZilmdkapbk4CVgwfVs6FDWlhGfuy0=
|
|
||||||
cloud.google.com/go/aiplatform v1.74.0/go.mod h1:hVEw30CetNut5FrblYd1AJUWRVSIjoyIvp0EVUh51HA=
|
|
||||||
cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps=
|
|
||||||
cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8=
|
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M=
|
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=
|
|
||||||
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
|
|
||||||
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
|
|
||||||
cloud.google.com/go/iam v1.4.0 h1:ZNfy/TYfn2uh/ukvhp783WhnbVluqf/tzOaqVUPlIPA=
|
|
||||||
cloud.google.com/go/iam v1.4.0/go.mod h1:gMBgqPaERlriaOV0CUl//XUzDhSfXevn4OEUbg6VRs4=
|
|
||||||
cloud.google.com/go/longrunning v0.6.4 h1:3tyw9rO3E2XVXzSApn1gyEEnH2K9SynNQjMlBi3uHLg=
|
|
||||||
cloud.google.com/go/longrunning v0.6.4/go.mod h1:ttZpLCe6e7EXvn9OxpBRx7kZEB0efv8yBO6YnVMfhJs=
|
|
||||||
cloud.google.com/go/vertexai v0.13.1 h1:E6I+eA6vNQxz7/rb0wdILdKg4hFmMNWZLp+dSy9DnEo=
|
|
||||||
cloud.google.com/go/vertexai v0.13.1/go.mod h1:25DzKFzP9JByYxcNjJefu/px2dRjcRpCDSdULYL2avI=
|
|
||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
|
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl3/e6D5CLfI0j/7hiIEtvGVFPCZ7Ei2oq8iQ=
|
||||||
github.com/Sakurasan/to v0.0.0-20180919163141-e72657dd7c7d h1:3v1QFdgk450QH+7C+lw1k+olbjK4fKGsrEfnEG/HLkY=
|
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||||
github.com/Sakurasan/to v0.0.0-20180919163141-e72657dd7c7d/go.mod h1:2sp0vsMyh5sqmKl5N+ps/cSspqLkoXUlesSzsufIGRU=
|
|
||||||
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496 h1:zV3ejI06GQ59hwDQAvmK1qxOQGB3WuVTRoY0okPTAv0=
|
|
||||||
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
|
||||||
github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw=
|
|
||||||
github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0=
|
|
||||||
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
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 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.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
@@ -33,49 +10,25 @@ github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFos
|
|||||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
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/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
|
||||||
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo=
|
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/duke-git/lancet/v2 v2.3.3 h1:OhqzNzkbJBS9ZlWLo/C7g+WSAOAAyNj7p9CAiEHurUc=
|
|
||||||
github.com/duke-git/lancet/v2 v2.3.3/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc=
|
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
|
||||||
github.com/faiface/beep v1.1.0 h1:A2gWP6xf5Rh7RG/p9/VAW2jRSDEGQm5sbOb38sf5d4c=
|
|
||||||
github.com/faiface/beep v1.1.0/go.mod h1:6I8p6kK2q4opL/eWb+kAkk38ehnTunWeToJB+s51sT4=
|
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
|
||||||
github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
|
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/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 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
|
|
||||||
github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM=
|
|
||||||
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
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/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 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
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 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
github.com/go-ozzo/ozzo-validation/v4 v4.4.1 h1:AQ3X8zHnXEuNE04pyc1H/nmIlroNjgZ7hcY7Xv/IgH8=
|
||||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
github.com/go-ozzo/ozzo-validation/v4 v4.4.1/go.mod h1:4ZtPNefSnNq39wjL+2We8y2ysqEX/S4D5mPybufHd7Y=
|
||||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
|
||||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
|
||||||
github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs=
|
|
||||||
github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498=
|
|
||||||
github.com/go-audio/wav v1.0.0/go.mod h1:3yoReyQOsiARkvPl3ERCi8JFjihzG6WhjYpZCf5zAWE=
|
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
|
||||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
|
||||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
|
|
||||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
|
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
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/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 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
@@ -93,43 +46,19 @@ 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/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 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
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/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
|
||||||
github.com/google/generative-ai-go v0.18.0 h1:6ybg9vOCLcI/UpBBYXOTVgvKmcUKFRNj+2Cj3GnebSo=
|
|
||||||
github.com/google/generative-ai-go v0.18.0/go.mod h1:JYolL13VG7j79kM5BtHz4qwONHkeJQzOCkKXnpqtS/E=
|
|
||||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
|
||||||
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 h1:5iH8iuqE5apketRbSFBy+X1V0o+l+8NF1avt4HWl7cA=
|
|
||||||
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
|
||||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
|
||||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
|
||||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
|
||||||
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
|
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
|
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
|
||||||
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
|
|
||||||
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
|
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
|
||||||
github.com/hajimehoshi/go-mp3 v0.3.0/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM=
|
|
||||||
github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
|
|
||||||
github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
|
|
||||||
github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI=
|
|
||||||
github.com/hajimehoshi/oto v0.7.1/go.mod h1:wovJ8WWMfFKvP587mhHgot/MBr4DnNy9m6EepeVGnos=
|
|
||||||
github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
|
|
||||||
github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
|
|
||||||
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
@@ -140,8 +69,6 @@ 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/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 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk=
|
|
||||||
github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0=
|
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
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/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 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
@@ -154,24 +81,18 @@ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02
|
|||||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
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/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/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
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 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/liushuangls/go-anthropic/v2 v2.15.0 h1:zpplg7BRV/9FlMmeMPI0eDwhViB0l9SkNrF8ErYlRoQ=
|
|
||||||
github.com/liushuangls/go-anthropic/v2 v2.15.0/go.mod h1:kq2yW3JVy1/rph8u5KzX7F3q95CEpCT2RXp/2nfCmb4=
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
github.com/mewkiz/flac v1.0.7/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU=
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2/go.mod h1:3E2FUC/qYUfM8+r9zAwpeHJzqRVVMIYnpzD/clwWxyA=
|
|
||||||
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
|
|
||||||
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
|
|
||||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
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/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-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -179,26 +100,17 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
|
||||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
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/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
|
||||||
github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQDmw=
|
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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
|
||||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sashabaranov/go-openai v1.32.2 h1:8z9PfYaLPbRzmJIYpwcWu6z3XU8F+RwVMF1QRSeSF2M=
|
github.com/sashabaranov/go-openai v1.42.0 h1:fgeZx7/D8dRT//PwXAGe9ylOMtj6vrs999uWF71K+f8=
|
||||||
github.com/sashabaranov/go-openai v1.32.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
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 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
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 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
@@ -216,12 +128,6 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
|||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
|
||||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
|
||||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
|
||||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
|
||||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
|
||||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
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/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 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
@@ -229,22 +135,6 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ
|
|||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
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/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE=
|
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4=
|
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
|
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
|
|
||||||
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
|
||||||
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
|
||||||
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
|
||||||
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
|
||||||
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
|
||||||
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
|
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
|
|
||||||
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
|
||||||
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
|
||||||
golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U=
|
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/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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
@@ -253,19 +143,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
|||||||
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
|
||||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
|
||||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
|
|
||||||
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
|
|
||||||
golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
|
||||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
|
||||||
golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.12.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/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
|
|
||||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
|
||||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
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-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.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
@@ -275,8 +156,6 @@ 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.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||||
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
|
||||||
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
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.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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -285,13 +164,9 @@ golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
|||||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
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-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-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -323,28 +198,12 @@ 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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
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/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
|
||||||
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
|
|
||||||
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
|
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/api v0.224.0 h1:Ir4UPtDsNiwIOHdExr3fAj4xZ42QjK7uQte3lORLJwU=
|
|
||||||
google.golang.org/api v0.224.0/go.mod h1:3V39my2xAGkodXy0vEqcEtkqgw2GtrFL5WuBZlCTCOQ=
|
|
||||||
google.golang.org/genai v1.0.0 h1:9IIZimT9bJm0wiF55VAoGCL8MfOAZcwqRRlxZZ/KSoc=
|
|
||||||
google.golang.org/genai v1.0.0/go.mod h1:TyfOKRz/QyCaj6f/ZDt505x+YreXnY40l2I6k8TvgqY=
|
|
||||||
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE=
|
|
||||||
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE=
|
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950=
|
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250409194420-de1ac958c67a h1:GIqLhp/cYUkuGuiT+vJk8vhOP86L4+SP5j8yXgeVpvI=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250409194420-de1ac958c67a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
|
||||||
google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI=
|
|
||||||
google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
|
|
||||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/vansante/go-ffprobe.v2 v2.2.0 h1:iuOqTsbfYuqIz4tAU9NWh22CmBGxlGHdgj4iqP+NUmY=
|
|
||||||
gopkg.in/vansante/go-ffprobe.v2 v2.2.0/go.mod h1:qF0AlAjk7Nqzqf3y333Ly+KxN3cKF2JqA3JT5ZheUGE=
|
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
@@ -353,31 +212,9 @@ 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/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 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
||||||
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||||
|
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
|
||||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
|
||||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
|
||||||
modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4=
|
|
||||||
modernc.org/ccgo/v4 v4.21.0/go.mod h1:h6kt6H/A2+ew/3MW/p6KEoQmrq/i3pr0J/SiwiaF/g0=
|
|
||||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
|
||||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
|
||||||
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
|
|
||||||
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
|
||||||
modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE=
|
|
||||||
modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0=
|
|
||||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
|
||||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
|
||||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
|
||||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
|
||||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
|
||||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
|
||||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
|
||||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
|
||||||
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
|
|
||||||
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
|
||||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
|
||||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
|
||||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
|
|||||||
@@ -0,0 +1,565 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"opencatd-open/internal/dao"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(db *gorm.DB) *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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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:"required,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)
|
||||||
|
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, 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
|
||||||
|
}
|
||||||
|
// Map role string to number for frontend compatibility
|
||||||
|
roleNum := 1 // default user
|
||||||
|
if user.Role == store.RoleAdmin {
|
||||||
|
roleNum = 10
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": 200,
|
||||||
|
"data": gin.H{
|
||||||
|
"id": user.ID,
|
||||||
|
"username": user.Username,
|
||||||
|
"email": user.Email,
|
||||||
|
"role": roleNum,
|
||||||
|
"status": user.Status,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Users ---
|
||||||
|
|
||||||
|
func (h *Handler) ListUsers(c *gin.Context) {
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||||
|
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||||
|
users, total, err := h.userDAO.List(limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": users, "total": total})
|
||||||
|
}
|
||||||
|
|
||||||
|
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, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||||
|
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||||
|
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) 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),
|
||||||
|
KeyPrefix: keyValue[:8],
|
||||||
|
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,
|
||||||
|
"id": key.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Channels ---
|
||||||
|
|
||||||
|
func (h *Handler) ListChannels(c *gin.Context) {
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||||
|
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||||
|
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"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Models ---
|
||||||
|
|
||||||
|
func (h *Handler) ListModels(c *gin.Context) {
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||||
|
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||||
|
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"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Channel-Model Bindings ---
|
||||||
|
|
||||||
|
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})
|
||||||
|
}
|
||||||
+14
-13
@@ -2,14 +2,15 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"opencatd-open/internal/model"
|
"os"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
UserID int64 `json:"user_id"`
|
UserID uint64 `json:"user_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
@@ -20,28 +21,23 @@ type TokenPair struct {
|
|||||||
RefreshToken string `json:"refresh_token,omitempty"`
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateTokenPair(user *model.User, secret string, accessExpire, refreshExpire time.Duration) (*TokenPair, error) {
|
func GenerateTokenPair(user *store.User, secret string, accessExpire, refreshExpire time.Duration) (*TokenPair, error) {
|
||||||
// Generate access token
|
|
||||||
accessToken, err := generateToken(user, "access", secret, accessExpire)
|
accessToken, err := generateToken(user, "access", secret, accessExpire)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate refresh token
|
|
||||||
refreshToken, err := generateToken(user, "refresh", secret, refreshExpire)
|
refreshToken, err := generateToken(user, "refresh", secret, refreshExpire)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &TokenPair{
|
return &TokenPair{
|
||||||
AccessToken: accessToken,
|
AccessToken: accessToken,
|
||||||
RefreshToken: refreshToken,
|
RefreshToken: refreshToken,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateToken(user *model.User, tokenType, secret string, expire time.Duration) (string, error) {
|
func generateToken(user *store.User, tokenType, secret string, expire time.Duration) (string, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
claims := Claims{
|
claims := Claims{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
Name: user.Username,
|
Name: user.Username,
|
||||||
@@ -52,7 +48,6 @@ func generateToken(user *model.User, tokenType, secret string, expire time.Durat
|
|||||||
NotBefore: jwt.NewNumericDate(now),
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
return token.SignedString([]byte(secret))
|
return token.SignedString([]byte(secret))
|
||||||
}
|
}
|
||||||
@@ -64,14 +59,20 @@ func ValidateToken(tokenString, secret string) (*Claims, error) {
|
|||||||
}
|
}
|
||||||
return []byte(secret), nil
|
return []byte(secret), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||||
return claims, nil
|
return claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, jwt.ErrInvalidKey
|
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,221 @@
|
|||||||
|
package channel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectChannel selects the best channel for a given model using weighted random selection
|
||||||
|
func (s *Service) SelectChannel(ctx context.Context, modelName string) (*store.Channel, error) {
|
||||||
|
channels, err := s.channelDAO.GetEnabledChannelsByModel(modelName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get channels for model %s: %w", modelName, err)
|
||||||
|
}
|
||||||
|
if len(channels) == 0 {
|
||||||
|
return nil, fmt.Errorf("no enabled channels for model: %s", modelName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out unhealthy channels
|
||||||
|
candidates := s.filterHealthy(channels)
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
// If all channels are unhealthy, try the first one anyway
|
||||||
|
candidates = channels[:1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weighted random selection
|
||||||
|
totalWeight := 0
|
||||||
|
for _, ch := range candidates {
|
||||||
|
totalWeight += ch.Weight
|
||||||
|
}
|
||||||
|
if totalWeight == 0 {
|
||||||
|
return candidates[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
r := rand.Intn(totalWeight)
|
||||||
|
for _, ch := range candidates {
|
||||||
|
r -= ch.Weight
|
||||||
|
if r < 0 {
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
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 >= 3 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelCandidate represents a channel with its resolved API key
|
||||||
|
type ChannelCandidate struct {
|
||||||
|
Channel *store.Channel
|
||||||
|
APIKey string
|
||||||
|
Format string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectCandidates returns candidates for a model, sorted by priority
|
||||||
|
func (s *Service) SelectCandidates(ctx context.Context, modelName string, preferredFormat string) ([]ChannelCandidate, error) {
|
||||||
|
channels, err := s.channelDAO.GetEnabledChannelsByModel(modelName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidates []ChannelCandidate
|
||||||
|
for _, ch := range channels {
|
||||||
|
// Check if channel supports the preferred format
|
||||||
|
formats := ch.FormatsEffective()
|
||||||
|
supported := false
|
||||||
|
for _, f := range formats {
|
||||||
|
if f == preferredFormat || preferredFormat == "" {
|
||||||
|
supported = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !supported {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey, err := crypto.Decrypt(ch.APIKeyEnc)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to decrypt API key for channel %s: %v", ch.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates = append(candidates, ChannelCandidate{
|
||||||
|
Channel: ch,
|
||||||
|
APIKey: apiKey,
|
||||||
|
Format: preferredFormat,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
@@ -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,108 @@
|
|||||||
|
package channel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"opencatd-open/internal/dao"
|
||||||
|
"opencatd-open/internal/store"
|
||||||
|
"opencatd-open/internal/pkg/crypto"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HealthChecker struct {
|
||||||
|
channelDAO *dao.ChannelDAO
|
||||||
|
service *Service
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHealthChecker(channelDAO *dao.ChannelDAO, service *Service) *HealthChecker {
|
||||||
|
return &HealthChecker{
|
||||||
|
channelDAO: channelDAO,
|
||||||
|
service: service,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-36
@@ -4,13 +4,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"opencatd-open/internal/model"
|
"opencatd-open/internal/store"
|
||||||
"opencatd-open/pkg/store"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/duke-git/lancet/v2/fileutil"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -20,16 +17,17 @@ var LoadCmd = &cobra.Command{
|
|||||||
Short: "import user.json -> db",
|
Short: "import user.json -> db",
|
||||||
Long: "\nimport user.json -> db",
|
Long: "\nimport user.json -> db",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
db := store.GetDB()
|
db := store.DB
|
||||||
var cont int64
|
var cont int64
|
||||||
if err := db.Model(model.User{}).Count(&cont).Error; err != nil {
|
if err := db.Model(&store.User{}).Count(&cont).Error; err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if cont == 0 {
|
if cont == 0 {
|
||||||
fmt.Println("创建管理员之后再操作")
|
fmt.Println("创建管理员之后再操作")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if !fileutil.IsExist("./db/user.json") {
|
if _, err := os.Stat("./db/user.json"); os.IsNotExist(err) {
|
||||||
log.Fatalln("404! user.json is not found.")
|
log.Fatalln("404! user.json is not found.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -41,40 +39,22 @@ var LoadCmd = &cobra.Command{
|
|||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
var usermap []map[string]string
|
var usermap []map[string]string
|
||||||
|
|
||||||
if err := json.NewDecoder(file).Decode(&usermap); err != nil {
|
if err := json.NewDecoder(file).Decode(&usermap); err != nil {
|
||||||
fmt.Println("解析文件失败:", err)
|
fmt.Println("解析文件失败:", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, um := range usermap {
|
for _, um := range usermap {
|
||||||
var name string
|
name := um["username"]
|
||||||
if um["username"] != "" {
|
if name == "" {
|
||||||
name = um["name"]
|
name = um["name"]
|
||||||
} else if um["name"] == "" {
|
}
|
||||||
name = um["username"]
|
if name == "" {
|
||||||
} else {
|
|
||||||
fmt.Println("获取不到数据")
|
fmt.Println("获取不到数据")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var user = model.User{
|
_ = "sk-ot-" + strings.ReplaceAll(uuid.New().String(), "-", "")
|
||||||
Username: name,
|
fmt.Printf("Import user: %s\n", name)
|
||||||
Name: name,
|
|
||||||
Tokens: []model.Token{
|
|
||||||
{
|
|
||||||
Name: "default",
|
|
||||||
Key: "sk-team-" + strings.ReplaceAll(uuid.New().String(), "-", ""),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: name,
|
|
||||||
Key: um["token"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
if err := db.Create(&user).Error; err != nil {
|
|
||||||
fmt.Printf("\nCreate User %s Error:%s", user.Username, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,10 +62,5 @@ var SaveCmd = &cobra.Command{
|
|||||||
Use: "save",
|
Use: "save",
|
||||||
Short: "backup user info -> user.json",
|
Short: "backup user info -> user.json",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
|
||||||
// SaveCmd.Flags().StringP("user", "u", "", "Save User")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
package consts
|
|
||||||
|
|
||||||
import "gorm.io/gorm"
|
|
||||||
|
|
||||||
const Logo = `
|
|
||||||
____ _____
|
|
||||||
/ __ \ |_ _|
|
|
||||||
| | | |_ __ ___ _ __ | | ___ __ _ _ __ ___
|
|
||||||
| | | | '_ \ / _ \ '_ \ | | / _ \/ _' | '_ ' _ \
|
|
||||||
| |__| | |_) | __/ | | | | || __/ (_| | | | | | |
|
|
||||||
\____/| .__/ \___|_| |_| \_/ \___|\__,_|_| |_| |_|
|
|
||||||
| |
|
|
||||||
|_|
|
|
||||||
|
|
||||||
https://github.com/mirrors2/openteam
|
|
||||||
---------------------------------------------------
|
|
||||||
`
|
|
||||||
|
|
||||||
const SecretKey = "openteam"
|
|
||||||
|
|
||||||
const Day = 24 * 60 * 60 // day := 86400
|
|
||||||
|
|
||||||
type UserRole int
|
|
||||||
|
|
||||||
const (
|
|
||||||
RoleUser UserRole = iota * 10
|
|
||||||
RoleAdmin
|
|
||||||
RoleRoot
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
StatusDisabled = iota
|
|
||||||
StatusEnabled
|
|
||||||
StatusExpired // 过期
|
|
||||||
StatusExhausted // 耗尽
|
|
||||||
|
|
||||||
StatusDeleted = -1
|
|
||||||
)
|
|
||||||
const (
|
|
||||||
Limited = iota
|
|
||||||
Unlimited
|
|
||||||
UnlimitedQuota = 999999
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrUserNotFound = gorm.ErrRecordNotFound
|
|
||||||
)
|
|
||||||
|
|
||||||
func OpenOrClose(status bool) int {
|
|
||||||
if status {
|
|
||||||
return StatusEnabled
|
|
||||||
}
|
|
||||||
return StatusDisabled
|
|
||||||
}
|
|
||||||
|
|
||||||
// type DBType int
|
|
||||||
|
|
||||||
// const (
|
|
||||||
// DBTypeMySQL DBType = iota
|
|
||||||
// DBTypePostgreSQL
|
|
||||||
// DBTypeSQLite
|
|
||||||
// )
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
package controller
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"opencatd-open/internal/consts"
|
|
||||||
"opencatd-open/internal/dto"
|
|
||||||
"opencatd-open/internal/model"
|
|
||||||
"opencatd-open/internal/utils"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/duke-git/lancet/v2/slice"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (a Api) CreateApiKey(c *gin.Context) {
|
|
||||||
role := c.MustGet("user_role").(*consts.UserRole)
|
|
||||||
if *role < consts.RoleAdmin {
|
|
||||||
dto.Fail(c, 403, "Permission denied")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
newkey := new(model.ApiKey)
|
|
||||||
err := c.ShouldBind(newkey)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 400, err.Error())
|
|
||||||
}
|
|
||||||
if slice.Contain([]string{"openai", "azure", "claude"}, *newkey.ApiType) {
|
|
||||||
sma, err := utils.FetchKeyModel(a.db, newkey)
|
|
||||||
if err == nil && len(sma) > 0 {
|
|
||||||
newkey.SupportModelsArray = sma
|
|
||||||
var buf = new(bytes.Buffer)
|
|
||||||
json.NewEncoder(buf).Encode(sma) //nolint:errcheck
|
|
||||||
newkey.SupportModels = utils.ToPtr(buf.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = a.keyService.CreateApiKey(c, newkey)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 400, err.Error())
|
|
||||||
} else {
|
|
||||||
dto.Success(c, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a Api) GetApiKey(c *gin.Context) {
|
|
||||||
role := c.MustGet("user_role").(*consts.UserRole)
|
|
||||||
if *role < consts.RoleAdmin {
|
|
||||||
dto.Fail(c, 403, "Permission denied")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
key, err := a.keyService.GetApiKey(c, id)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 400, err.Error())
|
|
||||||
} else {
|
|
||||||
dto.Success(c, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a Api) ListApiKey(c *gin.Context) {
|
|
||||||
role := c.MustGet("user_role").(*consts.UserRole)
|
|
||||||
if *role < consts.RoleAdmin {
|
|
||||||
dto.Fail(c, 403, "Permission denied")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
limit, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
||||||
offset := (page - 1) * limit
|
|
||||||
active := c.QueryArray("active[]")
|
|
||||||
if !slice.ContainSubSlice([]string{"true", "false"}, active) {
|
|
||||||
dto.Fail(c, http.StatusBadRequest, "active must be true or false")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
keys, total, err := a.keyService.ListApiKey(c, limit, offset, active)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 500, err.Error())
|
|
||||||
} else {
|
|
||||||
for _, key := range keys {
|
|
||||||
str := *key.ApiKey
|
|
||||||
slen := len(str)
|
|
||||||
if slen > 20 {
|
|
||||||
slen = 20
|
|
||||||
}
|
|
||||||
str = str[:slen]
|
|
||||||
key.ApiKey = &str
|
|
||||||
|
|
||||||
var sma []string
|
|
||||||
json.NewDecoder(strings.NewReader(*key.SupportModels)).Decode(&sma) //nolint:errcheck
|
|
||||||
key.SupportModelsArray = sma
|
|
||||||
}
|
|
||||||
dto.Success(c, gin.H{
|
|
||||||
"total": total,
|
|
||||||
"keys": keys,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a Api) DeleteApiKey(c *gin.Context) {
|
|
||||||
role := c.MustGet("user_role").(*consts.UserRole)
|
|
||||||
if *role < consts.RoleAdmin {
|
|
||||||
dto.Fail(c, 403, "Permission denied")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var batchid dto.BatchIDRequest
|
|
||||||
err := c.ShouldBind(&batchid)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = a.keyService.DeleteApiKey(c, batchid.IDs)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 500, err.Error())
|
|
||||||
} else {
|
|
||||||
dto.Success(c, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a Api) UpdateApiKey(c *gin.Context) {
|
|
||||||
role := c.MustGet("user_role").(*consts.UserRole)
|
|
||||||
if *role < consts.RoleAdmin {
|
|
||||||
dto.Fail(c, 403, "Permission denied")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req model.ApiKey
|
|
||||||
err := c.ShouldBind(&req)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = a.keyService.UpdateApiKey(c, &req)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 500, err.Error())
|
|
||||||
} else {
|
|
||||||
dto.Success(c, nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a Api) ApiKeyOption(c *gin.Context) {
|
|
||||||
role := c.MustGet("user_role").(*consts.UserRole)
|
|
||||||
if *role < consts.RoleAdmin {
|
|
||||||
dto.Fail(c, 403, "Permission denied")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
option := strings.ToLower(c.Param("option"))
|
|
||||||
var batchid dto.BatchIDRequest
|
|
||||||
err := c.ShouldBind(&batchid)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch option {
|
|
||||||
case "enable":
|
|
||||||
err = a.keyService.EnableApiKey(c, batchid.IDs)
|
|
||||||
case "disable":
|
|
||||||
err = a.keyService.DisableApiKey(c, batchid.IDs)
|
|
||||||
case "delete":
|
|
||||||
err = a.keyService.DeleteApiKey(c, batchid.IDs)
|
|
||||||
default:
|
|
||||||
dto.Fail(c, 400, "invalid option, only support enable, disable, delete")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dto.Success(c, nil)
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package controller
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"opencatd-open/internal/dto"
|
|
||||||
"opencatd-open/internal/model"
|
|
||||||
"opencatd-open/llm"
|
|
||||||
"opencatd-open/llm/claude/v2"
|
|
||||||
"opencatd-open/llm/google/v2"
|
|
||||||
"opencatd-open/llm/openai_compatible"
|
|
||||||
"opencatd-open/pkg/tokenizer"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (h *Proxy) ChatHandler(c *gin.Context) {
|
|
||||||
user := c.MustGet("user").(*model.User)
|
|
||||||
if user == nil {
|
|
||||||
dto.WrapErrorAsOpenAI(c, 401, "Unauthorized")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var chatreq llm.ChatRequest
|
|
||||||
if err := c.ShouldBindJSON(&chatreq); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := h.SelectApiKey(chatreq.Model)
|
|
||||||
if err != nil {
|
|
||||||
dto.WrapErrorAsOpenAI(c, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var llm llm.LLM
|
|
||||||
switch *h.apikey.ApiType {
|
|
||||||
case "claude":
|
|
||||||
llm, err = claude.NewClaude(h.apikey)
|
|
||||||
case "gemini":
|
|
||||||
llm, err = google.NewGemini(c, h.apikey)
|
|
||||||
case "openai", "azure", "github":
|
|
||||||
fallthrough
|
|
||||||
default:
|
|
||||||
llm, err = openai_compatible.NewOpenAICompatible(h.apikey)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
dto.WrapErrorAsOpenAI(c, 500, fmt.Errorf("create llm client error: %w", err).Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !chatreq.Stream {
|
|
||||||
resp, err := llm.Chat(c, chatreq)
|
|
||||||
if err != nil {
|
|
||||||
dto.WrapErrorAsOpenAI(c, 500, err.Error())
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, resp)
|
|
||||||
|
|
||||||
} else {
|
|
||||||
datachan, err := llm.StreamChat(c, chatreq)
|
|
||||||
if err != nil {
|
|
||||||
dto.WrapErrorAsOpenAI(c, 500, err.Error())
|
|
||||||
}
|
|
||||||
for data := range datachan {
|
|
||||||
c.SSEvent("", data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
llmusage := llm.GetTokenUsage()
|
|
||||||
llmusage.User = user
|
|
||||||
llmusage.TokenID = c.GetInt64("token_id")
|
|
||||||
cost := tokenizer.Cost(llmusage.Model, llmusage.PromptTokens+llmusage.ToolsTokens, llmusage.CompletionTokens)
|
|
||||||
|
|
||||||
h.SendUsage(llmusage)
|
|
||||||
defer fmt.Println("cost:", cost, "prompt_tokens:", llmusage.PromptTokens, "completion_tokens:", llmusage.CompletionTokens, "total_tokens:", llmusage.TotalTokens)
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
package controller
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"opencatd-open/internal/dto"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (p *Proxy) HandleModels(c *gin.Context) {
|
|
||||||
models, err := p.getModelCache()
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, http.StatusBadGateway, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
type _model struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
}
|
|
||||||
var ms []_model
|
|
||||||
for _, model := range models {
|
|
||||||
ms = append(ms, _model{ID: model})
|
|
||||||
}
|
|
||||||
dto.Success(c, ms)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Proxy) setModelCache() error {
|
|
||||||
apikeys, err := p.apiKeyDao.FindKeys(nil)
|
|
||||||
models := make(map[string]bool)
|
|
||||||
if err == nil && len(apikeys) > 0 {
|
|
||||||
for _, k := range apikeys {
|
|
||||||
if len(k.SupportModelsArray) > 0 {
|
|
||||||
for _, sm := range k.SupportModelsArray {
|
|
||||||
models[sm] = true
|
|
||||||
}
|
|
||||||
} else if k.SupportModels != nil {
|
|
||||||
var sma []string
|
|
||||||
json.Unmarshal([]byte(*k.SupportModels), &sma) // nolint:errCheck
|
|
||||||
for _, sm := range sma {
|
|
||||||
models[sm] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("empty data")
|
|
||||||
}
|
|
||||||
var support_models []string
|
|
||||||
for m, _ := range models {
|
|
||||||
support_models = append(support_models, m)
|
|
||||||
}
|
|
||||||
return p.cache.Set("models", support_models)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Proxy) getModelCache() ([]string, error) {
|
|
||||||
models, err := p.cache.Get("models")
|
|
||||||
return models.([]string), err
|
|
||||||
}
|
|
||||||
@@ -1,30 +1,22 @@
|
|||||||
package controller
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"math/rand"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"opencatd-open/internal/channel"
|
||||||
"opencatd-open/internal/dao"
|
"opencatd-open/internal/dao"
|
||||||
"opencatd-open/internal/model"
|
"opencatd-open/internal/store"
|
||||||
"opencatd-open/internal/utils"
|
|
||||||
"opencatd-open/llm"
|
|
||||||
"opencatd-open/pkg/config"
|
"opencatd-open/pkg/config"
|
||||||
"opencatd-open/pkg/tokenizer"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/bluele/gcache"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/lib/pq"
|
|
||||||
"github.com/tidwall/gjson"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,19 +25,16 @@ type Proxy struct {
|
|||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
wg *sync.WaitGroup
|
wg *sync.WaitGroup
|
||||||
usageChan chan *llm.TokenUsage // 用于异步处理的channel
|
|
||||||
apikey *model.ApiKey
|
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
cache gcache.Cache
|
|
||||||
|
|
||||||
userDAO *dao.UserDAO
|
userDAO *dao.UserDAO
|
||||||
apiKeyDao *dao.ApiKeyDAO
|
apiKeyDAO *dao.ApiKeyDAO
|
||||||
tokenDAO *dao.TokenDAO
|
|
||||||
usageDAO *dao.UsageDAO
|
usageDAO *dao.UsageDAO
|
||||||
dailyUsageDAO *dao.DailyUsageDAO
|
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, tokenDAO *dao.TokenDAO, usageDAO *dao.UsageDAO, dailyUsageDAO *dao.DailyUsageDAO) *Proxy {
|
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
|
client := http.DefaultClient
|
||||||
if os.Getenv("LOCAL_PROXY") != "" {
|
if os.Getenv("LOCAL_PROXY") != "" {
|
||||||
proxyUrl, err := url.Parse(os.Getenv("LOCAL_PROXY"))
|
proxyUrl, err := url.Parse(os.Getenv("LOCAL_PROXY"))
|
||||||
@@ -63,337 +52,73 @@ func NewProxy(ctx context.Context, cfg *config.Config, db *gorm.DB, wg *sync.Wai
|
|||||||
db: db,
|
db: db,
|
||||||
wg: wg,
|
wg: wg,
|
||||||
httpClient: client,
|
httpClient: client,
|
||||||
cache: gcache.New(1).Build(),
|
|
||||||
usageChan: make(chan *llm.TokenUsage, cfg.UsageChanSize),
|
|
||||||
userDAO: userDAO,
|
userDAO: userDAO,
|
||||||
apiKeyDao: apiKeyDAO,
|
apiKeyDAO: apiKeyDAO,
|
||||||
tokenDAO: tokenDAO,
|
|
||||||
usageDAO: usageDAO,
|
usageDAO: usageDAO,
|
||||||
dailyUsageDAO: dailyUsageDAO,
|
dailyDAO: dailyDAO,
|
||||||
}
|
}
|
||||||
|
|
||||||
go np.ProcessUsage()
|
|
||||||
go np.ScheduleTask()
|
|
||||||
np.setModelCache()
|
|
||||||
return np
|
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) {
|
func (p *Proxy) HandleProxy(c *gin.Context) {
|
||||||
if c.Request.URL.Path == "/v1/chat/completions" {
|
path := c.Request.URL.Path
|
||||||
p.ChatHandler(c)
|
switch {
|
||||||
return
|
case path == "/v1/chat/completions":
|
||||||
}
|
// TODO: Phase 3 - implement chat completions handler
|
||||||
if strings.HasPrefix(c.Request.URL.Path, "/v1/messages") {
|
c.JSON(http.StatusNotImplemented, gin.H{"error": "chat completions not yet implemented"})
|
||||||
p.ProxyClaude(c)
|
case strings.HasPrefix(path, "/v1/messages"):
|
||||||
return
|
// 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
|
||||||
func (p *Proxy) SendUsage(usage *llm.TokenUsage) {
|
c.JSON(http.StatusNotImplemented, gin.H{"error": "responses not yet implemented"})
|
||||||
select {
|
|
||||||
case p.usageChan <- usage:
|
|
||||||
default:
|
default:
|
||||||
log.Println("usage channel is full, skip processing")
|
c.JSON(http.StatusNotFound, gin.H{"error": "unknown endpoint"})
|
||||||
bj, _ := json.Marshal(usage)
|
|
||||||
log.Println(string(bj))
|
|
||||||
//TODO: send to a queue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Proxy) ProcessUsage() {
|
func (p *Proxy) HandleModels(c *gin.Context) {
|
||||||
for i := 0; i < p.cfg.UsageWorker; i++ {
|
// TODO: Phase 3 - implement models list
|
||||||
p.wg.Add(1)
|
c.JSON(http.StatusOK, gin.H{"object": "list", "data": []interface{}{}})
|
||||||
go func(i int) {
|
}
|
||||||
defer p.wg.Done()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case usage, ok := <-p.usageChan:
|
|
||||||
if !ok {
|
|
||||||
// channel 关闭,退出程序
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err := p.Do(usage)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("process usage error: %v\n", err)
|
|
||||||
}
|
|
||||||
case <-p.ctx.Done():
|
|
||||||
// close(s.usageChan)
|
|
||||||
// for usage := range s.usageChan {
|
|
||||||
// if err := s.Do(usage); err != nil {
|
|
||||||
// fmt.Printf("[close event]process usage error: %v\n", err)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case usage, ok := <-p.usageChan:
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := p.Do(usage); err != nil {
|
|
||||||
fmt.Printf("[close event]process usage error: %v\n", err)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
fmt.Printf("usageChan is empty,usage worker %d done\n", i)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}(i)
|
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")
|
||||||
|
}
|
||||||
|
return p.channelSvc.SelectChannel(p.ctx, modelName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordSuccess records a successful request
|
||||||
|
func (p *Proxy) RecordSuccess(channelID uint64) {
|
||||||
|
if p.channelSvc != nil {
|
||||||
|
p.channelSvc.RecordSuccess(channelID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Proxy) Do(llmusage *llm.TokenUsage) error {
|
// RecordFailure records a failed request
|
||||||
err := p.db.Transaction(func(tx *gorm.DB) error {
|
func (p *Proxy) RecordFailure(channelID uint64) {
|
||||||
now := time.Now()
|
if p.channelSvc != nil {
|
||||||
today, _ := time.Parse("2006-01-02", now.Format("2006-01-02"))
|
p.channelSvc.RecordFailure(channelID)
|
||||||
|
|
||||||
cost := tokenizer.Cost(llmusage.Model, llmusage.PromptTokens, llmusage.CompletionTokens)
|
|
||||||
token, err := p.tokenDAO.GetByID(p.ctx, llmusage.TokenID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
usage := &model.Usage{
|
|
||||||
UserID: llmusage.User.ID,
|
|
||||||
TokenID: llmusage.TokenID,
|
|
||||||
Date: now,
|
|
||||||
Model: llmusage.Model,
|
|
||||||
Stream: llmusage.Stream,
|
|
||||||
PromptTokens: llmusage.PromptTokens,
|
|
||||||
CompletionTokens: llmusage.CompletionTokens,
|
|
||||||
TotalTokens: llmusage.TotalTokens,
|
|
||||||
Cost: fmt.Sprintf("%.8f", cost),
|
|
||||||
}
|
|
||||||
// 1. 记录使用记录
|
|
||||||
if err := tx.WithContext(p.ctx).Create(usage).Error; err != nil {
|
|
||||||
return fmt.Errorf("create usage error: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 更新每日统计
|
|
||||||
var dailyUsage model.DailyUsage
|
|
||||||
result := tx.WithContext(p.ctx).Where("user_id = ? and date = ?", llmusage.User.ID, today).First(&dailyUsage)
|
|
||||||
if result.RowsAffected == 0 {
|
|
||||||
dailyUsage.UserID = llmusage.User.ID
|
|
||||||
dailyUsage.TokenID = llmusage.TokenID
|
|
||||||
dailyUsage.Date = today
|
|
||||||
dailyUsage.Model = llmusage.Model
|
|
||||||
dailyUsage.Stream = llmusage.Stream
|
|
||||||
dailyUsage.PromptTokens = llmusage.PromptTokens
|
|
||||||
dailyUsage.CompletionTokens = llmusage.CompletionTokens
|
|
||||||
dailyUsage.TotalTokens = llmusage.TotalTokens
|
|
||||||
dailyUsage.Cost = fmt.Sprintf("%.8f", cost)
|
|
||||||
if err := tx.WithContext(p.ctx).Create(&dailyUsage).Error; err != nil {
|
|
||||||
return fmt.Errorf("create daily usage error: %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := tx.WithContext(p.ctx).Model(&model.DailyUsage{}).Where("user_id = ? and date = ?", llmusage.User.ID, today).
|
|
||||||
Updates(map[string]interface{}{
|
|
||||||
"prompt_tokens": gorm.Expr("prompt_tokens + ?", llmusage.PromptTokens),
|
|
||||||
"completion_tokens": gorm.Expr("completion_tokens + ?", llmusage.CompletionTokens),
|
|
||||||
"total_tokens": gorm.Expr("total_tokens + ?", llmusage.TotalTokens),
|
|
||||||
}).Error; err != nil {
|
|
||||||
return fmt.Errorf("update daily usage error: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 更新用户额度
|
|
||||||
if *llmusage.User.UnlimitedQuota {
|
|
||||||
if err := tx.WithContext(p.ctx).Model(&model.User{}).Where("id = ?", llmusage.User.ID).Updates(map[string]interface{}{
|
|
||||||
"used_quota": gorm.Expr("used_quota + ?", fmt.Sprintf("%.8f", cost)),
|
|
||||||
}).Error; err != nil {
|
|
||||||
return fmt.Errorf("update user quota and used_quota error: %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := tx.WithContext(p.ctx).Model(&model.User{}).Where("id = ?", llmusage.User.ID).Updates(map[string]interface{}{
|
|
||||||
"quota": gorm.Expr("quota - ?", fmt.Sprintf("%.8f", cost)),
|
|
||||||
"used_quota": gorm.Expr("used_quota + ?", fmt.Sprintf("%.8f", cost)),
|
|
||||||
}).Error; err != nil {
|
|
||||||
return fmt.Errorf("update user quota and used_quota error: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//4 . 更新token额度
|
|
||||||
if *token.UnlimitedQuota {
|
|
||||||
if err := tx.WithContext(p.ctx).Model(&model.Token{}).Where("id = ?", llmusage.TokenID).Updates(map[string]interface{}{
|
|
||||||
"used_quota": gorm.Expr("used_quota + ?", fmt.Sprintf("%.8f", cost)),
|
|
||||||
}).Error; err != nil {
|
|
||||||
return fmt.Errorf("update token quota and used_quota error: %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := tx.WithContext(p.ctx).Model(&model.Token{}).Where("id = ?", llmusage.TokenID).Updates(map[string]interface{}{
|
|
||||||
"quota": gorm.Expr("quota - ?", fmt.Sprintf("%.8f", cost)),
|
|
||||||
"used_quota": gorm.Expr("used_quota + ?", fmt.Sprintf("%.8f", cost)),
|
|
||||||
}).Error; err != nil {
|
|
||||||
return fmt.Errorf("update token quota and used_quota error: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Proxy) SelectApiKey(model string) error {
|
// SendUsagePlaceholder placeholder for usage processing
|
||||||
akpikeys, err := p.apiKeyDao.FindApiKeysBySupportModel(p.db, model)
|
func (p *Proxy) SendUsagePlaceholder(model string, userID uint64, promptTokens, completionTokens int) {
|
||||||
if err != nil || len(akpikeys) == 0 {
|
log.Printf("Usage: model=%s user=%d prompt=%d completion=%d", model, userID, promptTokens, completionTokens)
|
||||||
if strings.HasPrefix(model, "gpt") || strings.HasPrefix(model, "o1") || strings.HasPrefix(model, "o3") || strings.HasPrefix(model, "o4") {
|
|
||||||
keys, err := p.apiKeyDao.FindKeys(map[string]any{"active = ?": true, "apitype = ?": "openai"})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
akpikeys = append(akpikeys, keys...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(model, "gemini") {
|
|
||||||
keys, err := p.apiKeyDao.FindKeys(map[string]any{"active = ?": true, "apitype = ?": "gemini"})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
akpikeys = append(akpikeys, keys...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasPrefix(model, "claude") {
|
|
||||||
keys, err := p.apiKeyDao.FindKeys(map[string]any{"active = ?": true, "apitype = ?": "claude"})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
akpikeys = append(akpikeys, keys...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(akpikeys) == 0 {
|
|
||||||
return errors.New("no available apikey")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(akpikeys) == 1 {
|
|
||||||
p.apikey = &akpikeys[0]
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
length := len(akpikeys) - 1
|
|
||||||
|
|
||||||
p.apikey = &akpikeys[rand.Intn(length)]
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Proxy) updateSupportModel() {
|
// Placeholder to keep the file compilable
|
||||||
|
var _ = json.Marshal
|
||||||
keys, err := p.apiKeyDao.FindKeys(map[string]interface{}{"apitype in ?": []string{"openai", "azure", "claude"}})
|
var _ = io.ReadAll
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, key := range keys {
|
|
||||||
var supportModels []string
|
|
||||||
if *key.ApiType == "openai" || *key.ApiType == "azure" {
|
|
||||||
supportModels, err = p.getOpenAISupportModels(key)
|
|
||||||
}
|
|
||||||
if *key.ApiType == "claude" {
|
|
||||||
supportModels, err = p.getClaudeSupportModels(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(supportModels) == 0 {
|
|
||||||
continue
|
|
||||||
|
|
||||||
}
|
|
||||||
if p.cfg.DB_Type == "sqlite" {
|
|
||||||
bytejson, _ := json.Marshal(supportModels)
|
|
||||||
if err := p.db.Model(&model.ApiKey{}).Where("id = ?", key.ID).UpdateColumn("support_models", string(bytejson)).Error; err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
}
|
|
||||||
} else if p.cfg.DB_Type == "postgres" {
|
|
||||||
if err := p.db.Model(&model.ApiKey{}).Where("id = ?", key.ID).UpdateColumn("support_models", pq.StringArray(supportModels)).Error; err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Proxy) ScheduleTask() {
|
|
||||||
|
|
||||||
func() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-time.After(time.Duration(p.cfg.TaskTimeInterval) * time.Minute):
|
|
||||||
p.updateSupportModel()
|
|
||||||
case <-time.After(time.Hour * 12):
|
|
||||||
if err := p.setModelCache(); err != nil {
|
|
||||||
fmt.Println("refrash model cache err:", err)
|
|
||||||
}
|
|
||||||
case <-p.ctx.Done():
|
|
||||||
fmt.Println("schedule task done")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Proxy) getOpenAISupportModels(apikey model.ApiKey) ([]string, error) {
|
|
||||||
openaiModelsUrl := "https://api.openai.com/v1/models"
|
|
||||||
// https://learn.microsoft.com/zh-cn/rest/api/azureopenai/models/list?view=rest-azureopenai-2025-02-01-preview&tabs=HTTP
|
|
||||||
azureModelsUrl := "/openai/deployments?api-version=2022-12-01"
|
|
||||||
|
|
||||||
var supportModels []string
|
|
||||||
var req *http.Request
|
|
||||||
if *apikey.ApiType == "azure" {
|
|
||||||
if strings.HasSuffix(*apikey.Endpoint, "/") {
|
|
||||||
apikey.Endpoint = utils.ToPtr(strings.TrimSuffix(*apikey.Endpoint, "/"))
|
|
||||||
}
|
|
||||||
req, _ = http.NewRequest("GET", *apikey.Endpoint+azureModelsUrl, nil)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("api-key", *apikey.ApiKey)
|
|
||||||
} else {
|
|
||||||
req, _ = http.NewRequest("GET", openaiModelsUrl, nil)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer "+*apikey.ApiKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := p.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode == http.StatusOK {
|
|
||||||
bytesbody, _ := io.ReadAll(resp.Body)
|
|
||||||
result := gjson.GetBytes(bytesbody, "data.#.id").Array()
|
|
||||||
for _, v := range result {
|
|
||||||
model := v.Str
|
|
||||||
model = strings.Replace(model, "-35-", "-3.5-", -1)
|
|
||||||
model = strings.Replace(model, "-41-", "-4.1-", -1)
|
|
||||||
supportModels = append(supportModels, model)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return supportModels, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Proxy) getClaudeSupportModels(apikey model.ApiKey) ([]string, error) {
|
|
||||||
// https://docs.anthropic.com/en/api/models-list
|
|
||||||
claudemodelsUrl := "https://api.anthropic.com/v1/models"
|
|
||||||
var supportModels []string
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", claudemodelsUrl, nil)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("x-api-key", *apikey.ApiKey)
|
|
||||||
req.Header.Set("anthropic-version", "2023-06-01")
|
|
||||||
|
|
||||||
resp, err := p.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode == http.StatusOK {
|
|
||||||
bytesbody, _ := io.ReadAll(resp.Body)
|
|
||||||
result := gjson.GetBytes(bytesbody, "data.#.id").Array()
|
|
||||||
for _, v := range result {
|
|
||||||
supportModels = append(supportModels, v.Str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return supportModels, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
package controller
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (p *Proxy) ProxyClaude(c *gin.Context) {
|
|
||||||
fmt.Println(c.Request.URL.String())
|
|
||||||
data, _ := io.ReadAll(c.Request.Body)
|
|
||||||
fmt.Println(string(data))
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package controller
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"opencatd-open/internal/consts"
|
|
||||||
|
|
||||||
"github.com/gin-contrib/cors"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (h *Team) AuthMiddleware() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if c.Request.URL.Path == "/1/users/init" {
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
authtoken := c.GetHeader("Authorization")
|
|
||||||
if authtoken == "" || len(authtoken) <= 7 || authtoken[:7] != "Bearer " {
|
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
authtoken = authtoken[7:]
|
|
||||||
token, err := h.tokenService.GetByKey(c, authtoken)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
}
|
|
||||||
if token.Name != "default" {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "only default token can access"})
|
|
||||||
c.Abort()
|
|
||||||
}
|
|
||||||
if token.User.Status != consts.StatusEnabled {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "user is disabled"})
|
|
||||||
c.Abort()
|
|
||||||
}
|
|
||||||
c.Set("local_user", true)
|
|
||||||
c.Set("token", token)
|
|
||||||
|
|
||||||
// 可以在这里对 token 进行验证并检查权限
|
|
||||||
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,563 +0,0 @@
|
|||||||
package controller
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"opencatd-open/internal/consts"
|
|
||||||
dto "opencatd-open/internal/dto/team"
|
|
||||||
"opencatd-open/internal/model"
|
|
||||||
service "opencatd-open/internal/service/team"
|
|
||||||
"opencatd-open/internal/utils"
|
|
||||||
|
|
||||||
"github.com/duke-git/lancet/v2/slice"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Team struct {
|
|
||||||
db *gorm.DB
|
|
||||||
userService service.UserService
|
|
||||||
tokenService service.TokenService
|
|
||||||
keyService service.ApiKeyService
|
|
||||||
usageService service.UsageService
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTeam(userService service.UserService, tokenService service.TokenService, keyService service.ApiKeyService, usageService service.UsageService) *Team {
|
|
||||||
return &Team{
|
|
||||||
userService: userService,
|
|
||||||
tokenService: tokenService,
|
|
||||||
keyService: keyService,
|
|
||||||
usageService: usageService,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// initadmin
|
|
||||||
func (h *Team) InitAdmin(c *gin.Context) {
|
|
||||||
admin, err := h.userService.GetUser(c, 1)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
user := &model.User{
|
|
||||||
Name: "root",
|
|
||||||
Username: "root",
|
|
||||||
Password: "openteam",
|
|
||||||
Role: utils.ToPtr(consts.RoleRoot),
|
|
||||||
Tokens: []model.Token{
|
|
||||||
{
|
|
||||||
Name: "default",
|
|
||||||
Key: "sk-team-" + strings.ReplaceAll(uuid.New().String(), "-", ""),
|
|
||||||
UnlimitedQuota: utils.ToPtr(true),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if err := h.userService.CreateUser(c, user); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var result = dto.UserInfo{
|
|
||||||
ID: user.ID,
|
|
||||||
Name: user.Username,
|
|
||||||
Token: user.Tokens[0].Key,
|
|
||||||
Status: utils.ToPtr(user.Status == consts.StatusEnabled),
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, result)
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if admin != nil {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{
|
|
||||||
"error": "super user already exists, use cli to reset password",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Team) Me(c *gin.Context) {
|
|
||||||
token, exists := c.Get("token")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "token not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userToken := token.(*model.Token)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, dto.UserInfo{
|
|
||||||
ID: userToken.UserID,
|
|
||||||
Name: userToken.User.Name,
|
|
||||||
Token: userToken.Key,
|
|
||||||
Status: utils.ToPtr(userToken.User.Status == consts.StatusEnabled),
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateUser 创建用户
|
|
||||||
func (h *Team) CreateUser(c *gin.Context) {
|
|
||||||
var userReq dto.UserInfo
|
|
||||||
if err := c.ShouldBindJSON(&userReq); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token, exists := c.Get("token")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Unauthorized"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userToken := token.(*model.Token)
|
|
||||||
if *userToken.User.Role < consts.RoleAdmin { // 普通用户只能创建自己的token
|
|
||||||
create := &model.Token{
|
|
||||||
Name: userReq.Name,
|
|
||||||
Key: "sk-team-" + strings.ReplaceAll(uuid.New().String(), "-", ""),
|
|
||||||
}
|
|
||||||
if userReq.Token != "" {
|
|
||||||
_key := strings.ReplaceAll(userReq.Token, "-", "")
|
|
||||||
create.Key = "sk-team-" + strings.ReplaceAll(_key, " ", "")
|
|
||||||
}
|
|
||||||
if err := h.tokenService.Create(c.Request.Context(), create); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
|
||||||
user := &model.User{
|
|
||||||
Name: userReq.Name,
|
|
||||||
Username: userReq.Name,
|
|
||||||
Role: utils.ToPtr(consts.RoleUser),
|
|
||||||
Tokens: []model.Token{
|
|
||||||
{
|
|
||||||
Name: "default",
|
|
||||||
Key: "sk-team-" + strings.ReplaceAll(uuid.New().String(), "-", ""),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// 默认角色为普通用户
|
|
||||||
if err := h.userService.CreateUser(c.Request.Context(), user); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUser 获取用户信息
|
|
||||||
func (h *Team) GetUser(c *gin.Context) {
|
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := h.userService.GetUser(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateUser 更新用户信息
|
|
||||||
func (h *Team) UpdateUser(c *gin.Context) {
|
|
||||||
var user model.User
|
|
||||||
if err := c.ShouldBindJSON(&user); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
token, exists := c.Get("token")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Unauthorized"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userToken := token.(*model.Token)
|
|
||||||
|
|
||||||
operatorID := userToken.UserID // 假设从上下文中获取操作者ID
|
|
||||||
if err := h.userService.UpdateUser(c.Request.Context(), &user, operatorID); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteUser 删除用户
|
|
||||||
func (h *Team) DeleteUser(c *gin.Context) {
|
|
||||||
idStr := c.Param("id")
|
|
||||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token, exists := c.Get("token")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Unauthorized"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userToken := token.(*model.Token)
|
|
||||||
|
|
||||||
if *userToken.User.Role < consts.RoleAdmin { // 用户只能删除自己的token
|
|
||||||
err := h.tokenService.Delete(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := h.userService.DeleteUser(c, id, userToken.UserID); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Team) ListUsages(c *gin.Context) {
|
|
||||||
fromStr := c.Query("from")
|
|
||||||
toStr := c.Query("to")
|
|
||||||
|
|
||||||
var from, to time.Time
|
|
||||||
loc, _ := time.LoadLocation("Local")
|
|
||||||
|
|
||||||
var listUsage []*dto.UsageInfo
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if fromStr != "" && toStr != "" {
|
|
||||||
|
|
||||||
from, err = time.Parse("2006-01-02", fromStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid from date"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
to, err = time.Parse("2006-01-02", toStr)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid to date"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
year, month, _ := time.Now().In(loc).Date()
|
|
||||||
from = time.Date(year, month, 1, 0, 0, 0, 0, loc)
|
|
||||||
to = from.AddDate(0, 1, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
token, _ := c.Get("token")
|
|
||||||
userToken := token.(*model.Token)
|
|
||||||
if *userToken.User.Role < consts.RoleAdmin {
|
|
||||||
listUsage, err = h.usageService.ListByDateRange(c.Request.Context(), from, to, map[string]interface{}{"user_id": userToken.UserID})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
listUsage, err = h.usageService.ListByDateRange(c.Request.Context(), from, to, nil)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, listUsage)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListUsers 获取用户列表
|
|
||||||
func (h *Team) ListUsers(c *gin.Context) {
|
|
||||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100"))
|
|
||||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
|
||||||
active := c.DefaultQuery("active", "")
|
|
||||||
|
|
||||||
if !slices.Contains([]string{"true", "false", ""}, active) {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid active value"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token, exists := c.Get("token")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Unauthorized"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userToken := token.(*model.Token)
|
|
||||||
if *userToken.User.Role < consts.RoleAdmin { // 用户只能获取自己的token
|
|
||||||
tokens, _, err := h.tokenService.Lists(c, limit, offset)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var userDTOs []dto.UserInfo
|
|
||||||
for _, token := range tokens {
|
|
||||||
userDTOs = append(userDTOs, dto.UserInfo{
|
|
||||||
ID: token.User.ID,
|
|
||||||
Name: token.User.Name,
|
|
||||||
Token: token.Key,
|
|
||||||
Status: utils.ToPtr(token.User.Status == consts.StatusEnabled),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, userDTOs)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
users, err := h.userService.ListUsers(c, limit, offset, active)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var userDTOs []dto.UserInfo
|
|
||||||
for _, user := range users {
|
|
||||||
useres := dto.UserInfo{
|
|
||||||
ID: user.ID,
|
|
||||||
Name: user.Name,
|
|
||||||
|
|
||||||
Status: utils.ToPtr(user.Status == consts.StatusEnabled),
|
|
||||||
}
|
|
||||||
if len(user.Tokens) > 0 {
|
|
||||||
useres.Token = user.Tokens[0].Key
|
|
||||||
}
|
|
||||||
userDTOs = append(userDTOs, useres)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, userDTOs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Team) ResetUserToken(c *gin.Context) {
|
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
token, exists := c.Get("token")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "Unauthorized"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userToken := token.(*model.Token)
|
|
||||||
|
|
||||||
findtoken, err := h.tokenService.GetByUserID(c, id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
findtoken.Key = "sk-team-" + strings.ReplaceAll(uuid.New().String(), "-", "")
|
|
||||||
|
|
||||||
if *userToken.User.Role < consts.RoleAdmin { // 非管理员只能修改自己的token
|
|
||||||
if *userToken.User.Role <= *findtoken.User.Role || userToken.UserID != findtoken.UserID {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err := h.tokenService.UpdateWithCondition(c, findtoken, map[string]interface{}{"user_id": userToken.UserID}, nil)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := h.tokenService.Update(c, findtoken); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, dto.UserInfo{
|
|
||||||
ID: findtoken.User.ID,
|
|
||||||
Name: findtoken.User.Name,
|
|
||||||
Token: findtoken.Key,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Team) CreateKey(c *gin.Context) {
|
|
||||||
token, exists := c.Get("token")
|
|
||||||
if !exists {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "token not found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userToken := token.(*model.Token)
|
|
||||||
if *userToken.User.Role < consts.RoleAdmin {
|
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var key dto.ApiKeyInfo
|
|
||||||
if err := c.ShouldBindJSON(&key); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err := h.keyService.Create(&model.ApiKey{
|
|
||||||
Name: utils.ToPtr(key.Name),
|
|
||||||
ApiType: utils.ToPtr(key.ApiType),
|
|
||||||
ApiKey: utils.ToPtr(key.Key),
|
|
||||||
Endpoint: utils.ToPtr(key.Endpoint),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Team) ListKeys(c *gin.Context) {
|
|
||||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
|
||||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
|
||||||
active := c.Query("active")
|
|
||||||
if !slice.Contain([]string{"true", "false", ""}, active) {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid active value"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
keys, err := h.keyService.List(limit, offset, active)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var keysDTO []dto.ApiKeyInfo
|
|
||||||
for _, key := range keys {
|
|
||||||
keylength := len(*key.ApiKey) / 3
|
|
||||||
if keylength < 1 {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid key length"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
keysDTO = append(keysDTO, dto.ApiKeyInfo{
|
|
||||||
ID: int(key.ID),
|
|
||||||
Name: *key.Name,
|
|
||||||
ApiType: *key.ApiType,
|
|
||||||
Endpoint: *key.Endpoint,
|
|
||||||
Key: *key.ApiKey,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, keysDTO)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Team) UpdateKey(c *gin.Context) {
|
|
||||||
// 1. 获取并验证ID
|
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid key id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 解析请求体
|
|
||||||
var updateKey dto.ApiKeyInfo // 更明确的命名
|
|
||||||
if err := c.ShouldBindJSON(&updateKey); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 获取现有记录
|
|
||||||
existingKey, err := h.keyService.GetByID(id)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 使用 UpdateFields 方法统一处理字段更新
|
|
||||||
updatedKey := updateKey.UpdateFields(existingKey)
|
|
||||||
|
|
||||||
// 5. 保存更新
|
|
||||||
if err := h.keyService.Update(updatedKey); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, updatedKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Team) DeleteKey(c *gin.Context) {
|
|
||||||
// 1. 获取并验证ID
|
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid key id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 删除记录
|
|
||||||
if err := h.keyService.Delete(id); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChangePassword 修改密码
|
|
||||||
func (h *Team) ChangePassword(c *gin.Context) {
|
|
||||||
userID := c.GetInt64("userID") // 假设从上下文中获取用户ID
|
|
||||||
|
|
||||||
var req struct {
|
|
||||||
OldPassword string `json:"oldPassword"`
|
|
||||||
NewPassword string `json:"newPassword"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.userService.ChangePassword(c.Request.Context(), userID, req.OldPassword, req.NewPassword); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResetPassword 重置密码
|
|
||||||
func (h *Team) ResetPassword(c *gin.Context) {
|
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
operatorID := int64(c.GetInt("userID")) // 假设从上下文中获取操作者ID
|
|
||||||
if err := h.userService.ResetPassword(c.Request.Context(), id, operatorID); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "password reset successfully"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnableUser 启用用户
|
|
||||||
func (h *Team) EnableUser(c *gin.Context) {
|
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
operatorID := int64(c.GetInt("userID")) // 假设从上下文中获取操作者ID
|
|
||||||
|
|
||||||
if err := h.userService.BatchEnableUsers(c, []int64{id}, operatorID); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "user enabled successfully"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// DisableUser 禁用用户
|
|
||||||
func (h *Team) DisableUser(c *gin.Context) {
|
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid user id"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
operatorID := int64(c.GetInt("userID")) // 假设从上下文中获取操作者ID
|
|
||||||
if err := h.userService.BatchDisableUsers(c.Request.Context(), []int64{id}, operatorID); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "user disabled successfully"})
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
package controller
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"opencatd-open/internal/dto"
|
|
||||||
"opencatd-open/internal/model"
|
|
||||||
"opencatd-open/internal/utils"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/duke-git/lancet/v2/slice"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (a Api) Register(c *gin.Context) {
|
|
||||||
req := new(dto.User)
|
|
||||||
err := c.ShouldBind(&req)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = a.userService.Register(c, &model.User{
|
|
||||||
Username: req.Username,
|
|
||||||
Password: req.Password,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 400, err.Error())
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
dto.Success(c, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a Api) Login(c *gin.Context) {
|
|
||||||
req := new(dto.User)
|
|
||||||
err := c.ShouldBind(&req)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
auth, err := a.userService.Login(c, req)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 400, err.Error())
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
dto.Success(c, auth)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a Api) Profile(c *gin.Context) {
|
|
||||||
user, err := a.userService.Profile(c)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, http.StatusUnauthorized, err.Error())
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
dto.Success(c, user)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a Api) UpdateProfile(c *gin.Context) {
|
|
||||||
var user = model.User{}
|
|
||||||
err := c.ShouldBind(&user)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = a.userService.Update(c, &model.User{Name: user.Name, Username: user.Username, Email: user.Email})
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dto.Success(c, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a Api) UpdatePassword(c *gin.Context) {
|
|
||||||
var passwd dto.ChangePassword
|
|
||||||
err := c.ShouldBind(&passwd)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_user := c.MustGet("user").(*model.User)
|
|
||||||
if _user.Password == "" {
|
|
||||||
hashpass, err := utils.HashPassword(passwd.NewPassword)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_user.Password = hashpass
|
|
||||||
} else {
|
|
||||||
if !utils.CheckPassword(_user.Password, passwd.Password) {
|
|
||||||
dto.Fail(c, http.StatusBadRequest, "password not match")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hashpass, err := utils.HashPassword(passwd.NewPassword)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_user.Password = hashpass
|
|
||||||
}
|
|
||||||
err = a.userService.Update(c, _user)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dto.Success(c, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a Api) ListUser(c *gin.Context) {
|
|
||||||
limit, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
||||||
offset := (page - 1) * limit
|
|
||||||
active := c.QueryArray("active[]")
|
|
||||||
if !slice.ContainSubSlice([]string{"true", "false", ""}, active) {
|
|
||||||
dto.Fail(c, http.StatusBadRequest, "active must be true or false")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
users, total, err := a.userService.List(c, limit, offset, active)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dto.Success(c, gin.H{
|
|
||||||
"users": users,
|
|
||||||
"total": total,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a Api) CreateUser(c *gin.Context) {
|
|
||||||
var user model.User
|
|
||||||
err := c.ShouldBind(&user)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = a.userService.Create(c, &user)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dto.Success(c, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a Api) GetUser(c *gin.Context) {
|
|
||||||
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
||||||
user, err := a.userService.GetByID(c, id)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dto.Success(c, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a Api) EditUser(c *gin.Context) {
|
|
||||||
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
||||||
var user model.User
|
|
||||||
err := c.ShouldBind(&user)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user.ID = int64(id)
|
|
||||||
err = a.userService.Update(c, &user)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dto.Success(c, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a Api) DeleteUser(c *gin.Context) {
|
|
||||||
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
||||||
err := a.userService.Delete(c, id)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dto.Success(c, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a Api) UserOption(c *gin.Context) {
|
|
||||||
option := strings.ToLower(c.Param("option"))
|
|
||||||
var batchid dto.BatchIDRequest
|
|
||||||
err := c.ShouldBind(&batchid)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch option {
|
|
||||||
case "enable":
|
|
||||||
err = a.userService.BatchEnable(c, batchid.IDs)
|
|
||||||
case "disable":
|
|
||||||
err = a.userService.BatchDisable(c, batchid.IDs)
|
|
||||||
case "delete":
|
|
||||||
err = a.userService.BatchDelete(c, batchid.IDs)
|
|
||||||
default:
|
|
||||||
dto.Fail(c, 400, "invalid option, only support enable, disable, delete")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dto.Success(c, nil)
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
package controller
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"opencatd-open/internal/dto"
|
|
||||||
"opencatd-open/internal/model"
|
|
||||||
"opencatd-open/internal/utils"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/duke-git/lancet/v2/slice"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (a Api) CreateToken(c *gin.Context) {
|
|
||||||
userid := c.GetInt64("user_id")
|
|
||||||
user, err := a.userService.GetByID(c, userid)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(user.Tokens) >= 20 {
|
|
||||||
dto.Fail(c, http.StatusForbidden, "user has reached the maximum number of tokens")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var token model.Token
|
|
||||||
err = c.ShouldBindJSON(&token)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
token.UserID = userid
|
|
||||||
|
|
||||||
err = a.tokenService.CreateToken(c, &token)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dto.Success(c, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a Api) ListToken(c *gin.Context) {
|
|
||||||
limit, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
|
||||||
offset := (page - 1) * limit
|
|
||||||
active := c.QueryArray("active[]")
|
|
||||||
if !slice.ContainSubSlice([]string{"true", "false"}, active) {
|
|
||||||
dto.Fail(c, http.StatusBadRequest, "active must be true or false")
|
|
||||||
}
|
|
||||||
|
|
||||||
tokens, total, err := a.tokenService.ListToken(c, limit, offset, active)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dto.Success(c, gin.H{
|
|
||||||
"total": total,
|
|
||||||
"tokens": tokens,
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a Api) GetToken(c *gin.Context) {
|
|
||||||
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
||||||
|
|
||||||
token, err := a.tokenService.GetToken(c, id)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dto.Success(c, token)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a Api) ResetToken(c *gin.Context) {
|
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, http.StatusBadRequest, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := a.tokenService.GetToken(c, id)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if token == nil {
|
|
||||||
dto.Fail(c, http.StatusNotFound, "token not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
token.UsedQuota = utils.ToPtr(float64(0))
|
|
||||||
|
|
||||||
err = a.tokenService.UpdateToken(c, token)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dto.Success(c, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a Api) UpdateToken(c *gin.Context) {
|
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, http.StatusBadRequest, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var token model.Token
|
|
||||||
err = c.ShouldBindJSON(&token)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
token.ID = id
|
|
||||||
if token.UserID == 0 {
|
|
||||||
dto.Fail(c, http.StatusBadRequest, "user_id is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var _token *model.Token
|
|
||||||
|
|
||||||
user, err := a.userService.GetByID(c, token.UserID)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(user.Tokens) == 0 {
|
|
||||||
dto.Fail(c, http.StatusForbidden, "user has no tokens")
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
if findtoken, ok := slice.Find(user.Tokens,
|
|
||||||
func(idx int, t model.Token) bool {
|
|
||||||
return t.ID == id
|
|
||||||
}); ok {
|
|
||||||
_token = findtoken
|
|
||||||
_token.User = user
|
|
||||||
} else {
|
|
||||||
dto.Fail(c, http.StatusForbidden, "user has no tokens")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 更新_token信息
|
|
||||||
if token.Name != "" {
|
|
||||||
_token.Name = token.Name
|
|
||||||
}
|
|
||||||
if token.Key != "" {
|
|
||||||
_token.Key = token.Key
|
|
||||||
}
|
|
||||||
if token.Active != nil {
|
|
||||||
_token.Active = token.Active
|
|
||||||
}
|
|
||||||
if token.Quota != nil {
|
|
||||||
_token.Quota = token.Quota
|
|
||||||
}
|
|
||||||
if token.UnlimitedQuota != nil {
|
|
||||||
_token.UnlimitedQuota = token.UnlimitedQuota
|
|
||||||
}
|
|
||||||
if token.ExpiredAt != nil {
|
|
||||||
_token.ExpiredAt = token.ExpiredAt
|
|
||||||
}
|
|
||||||
|
|
||||||
err = a.tokenService.UpdateToken(c, _token)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dto.Success(c, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a Api) DeleteToken(c *gin.Context) {
|
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, http.StatusBadRequest, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = a.tokenService.DeleteToken(c, id)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dto.Success(c, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a Api) TokenOption(c *gin.Context) {
|
|
||||||
option := strings.ToLower(c.Param("option"))
|
|
||||||
var batchid dto.BatchIDRequest
|
|
||||||
err := c.ShouldBind(&batchid)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if batchid.UserID == nil {
|
|
||||||
dto.Fail(c, 400, "user_id is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch option {
|
|
||||||
case "enable":
|
|
||||||
err = a.tokenService.EnableTokens(c, *batchid.UserID, batchid.IDs)
|
|
||||||
case "disable":
|
|
||||||
err = a.tokenService.DisableTokens(c, *batchid.UserID, batchid.IDs)
|
|
||||||
case "delete":
|
|
||||||
err = a.tokenService.DeleteTokens(c, *batchid.UserID, batchid.IDs)
|
|
||||||
default:
|
|
||||||
dto.Fail(c, 400, "invalid option, only support enable, disable, delete")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dto.Success(c, nil)
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
package controller
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"opencatd-open/internal/auth"
|
|
||||||
"opencatd-open/internal/consts"
|
|
||||||
"opencatd-open/internal/dto"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (a *Api) PasskeyCreateBegin(c *gin.Context) {
|
|
||||||
userid := c.GetInt64("user_id")
|
|
||||||
cred, err := a.webAuthService.BeginRegistration(userid)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dto.Success(c, cred)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Api) PasskeyCreateFinish(c *gin.Context) {
|
|
||||||
userid := c.GetInt64("user_id")
|
|
||||||
name := c.Query("name")
|
|
||||||
if name == "" {
|
|
||||||
name = fmt.Sprintf("User-%d-%d", userid, time.Now().Unix())
|
|
||||||
}
|
|
||||||
// var body protocol.CredentialCreationResponse
|
|
||||||
// if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
// dto.Fail(c, 400, err.Error())
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 获取用户凭证
|
|
||||||
cred, err := a.webAuthService.FinishRegistration(userid, c.Request, name)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dto.Success(c, cred)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Api) ListPasskey(c *gin.Context) {
|
|
||||||
passkeys, err := a.webAuthService.ListPasskeys(c.GetInt64("user_id"))
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var passkeysDto []dto.Passkey
|
|
||||||
for _, passkey := range passkeys {
|
|
||||||
passkeysDto = append(passkeysDto, dto.Passkey{
|
|
||||||
ID: passkey.ID,
|
|
||||||
Name: passkey.Name,
|
|
||||||
DeviceType: passkey.DeviceType,
|
|
||||||
SignCount: passkey.SignCount,
|
|
||||||
LastUsedAt: passkey.LastUsedAt,
|
|
||||||
CreatedAt: passkey.CreatedAt,
|
|
||||||
UpdatedAt: passkey.UpdatedAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
dto.Success(c, passkeysDto)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Api) DeletePasskey(c *gin.Context) {
|
|
||||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 400, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err = a.webAuthService.DeletePasskey(c.GetInt64("user_id"), id); err != nil {
|
|
||||||
dto.Fail(c, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dto.Success(c, "删除成功")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 登陆
|
|
||||||
func (a *Api) PasskeyAuthBegin(c *gin.Context) {
|
|
||||||
|
|
||||||
cred, err := a.webAuthService.BeginLogin()
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dto.Success(c, cred)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Api) PasskeyAuthFinish(c *gin.Context) {
|
|
||||||
challenge := c.Query("challenge")
|
|
||||||
webAuthUser, err := a.webAuthService.FinishLogin(challenge, c.Request)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
at, err := auth.GenerateTokenPair(webAuthUser.User, consts.SecretKey, consts.Day*time.Second, consts.Day*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
dto.Fail(c, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dto.Success(c, dto.Auth{
|
|
||||||
Token: at.AccessToken,
|
|
||||||
ExpiresIn: time.Now().Add(consts.Day * time.Second).Unix(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
+30
-152
@@ -2,189 +2,67 @@ package dao
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"opencatd-open/internal/model"
|
"opencatd-open/internal/store"
|
||||||
"opencatd-open/internal/utils"
|
|
||||||
"opencatd-open/pkg/config"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ ApiKeyRepository = (*ApiKeyDAO)(nil)
|
|
||||||
|
|
||||||
type ApiKeyRepository interface {
|
|
||||||
Create(apiKey *model.ApiKey) error
|
|
||||||
GetByID(id int64) (*model.ApiKey, error)
|
|
||||||
GetByName(name string) (*model.ApiKey, error)
|
|
||||||
GetByApiKey(apiKeyValue string) (*model.ApiKey, error)
|
|
||||||
Update(apiKey *model.ApiKey) error
|
|
||||||
List(limit, offset int, status string) ([]*model.ApiKey, error)
|
|
||||||
ListWithFilters(limit, offset int, filters map[string]interface{}) ([]*model.ApiKey, int64, error)
|
|
||||||
BatchEnable(ids []int64) error
|
|
||||||
BatchDisable(ids []int64) error
|
|
||||||
BatchDelete(ids []int64) error
|
|
||||||
Count() (int64, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ApiKeyDAO struct {
|
type ApiKeyDAO struct {
|
||||||
cfg *config.Config
|
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewApiKeyDAO(cfg *config.Config, db *gorm.DB) *ApiKeyDAO {
|
func NewApiKeyDAO(db *gorm.DB) *ApiKeyDAO {
|
||||||
return &ApiKeyDAO{cfg: cfg, db: db}
|
return &ApiKeyDAO{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateApiKey 创建ApiKey
|
func (d *ApiKeyDAO) Create(apiKey *store.APIKey) error {
|
||||||
func (dao *ApiKeyDAO) Create(apiKey *model.ApiKey) error {
|
|
||||||
if apiKey == nil {
|
if apiKey == nil {
|
||||||
return errors.New("apiKey is nil")
|
return errors.New("apiKey is nil")
|
||||||
}
|
}
|
||||||
if len(*apiKey.SupportModels) < 2 {
|
return d.db.Create(apiKey).Error
|
||||||
apiKey.SupportModels = utils.ToPtr("[]")
|
|
||||||
}
|
|
||||||
return dao.db.Create(apiKey).Error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetApiKeyByID 根据ID获取ApiKey
|
func (d *ApiKeyDAO) GetByID(id uint64) (*store.APIKey, error) {
|
||||||
func (dao *ApiKeyDAO) GetByID(id int64) (*model.ApiKey, error) {
|
var apiKey store.APIKey
|
||||||
var apiKey model.ApiKey
|
err := d.db.First(&apiKey, id).Error
|
||||||
err := dao.db.First(&apiKey, id).Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &apiKey, nil
|
return &apiKey, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetApiKeyByName 根据名称获取ApiKey
|
func (d *ApiKeyDAO) GetByHash(keyHash string) (*store.APIKey, error) {
|
||||||
func (dao *ApiKeyDAO) GetByName(name string) (*model.ApiKey, error) {
|
var apiKey store.APIKey
|
||||||
var apiKey model.ApiKey
|
err := d.db.Where("key_hash = ? AND status = ?", keyHash, store.KeyStatusActive).First(&apiKey).Error
|
||||||
err := dao.db.Where("name = ?", name).First(&apiKey).Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &apiKey, nil
|
return &apiKey, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetApiKeyByApiKey 根据ApiKey值获取ApiKey
|
func (d *ApiKeyDAO) ListByUserID(userID uint64, limit, offset int) ([]*store.APIKey, int64, error) {
|
||||||
func (dao *ApiKeyDAO) GetByApiKey(apiKeyValue string) (*model.ApiKey, error) {
|
var apiKeys []*store.APIKey
|
||||||
var apiKey model.ApiKey
|
|
||||||
err := dao.db.Where("api_key = ?", apiKeyValue).First(&apiKey).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &apiKey, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (dao *ApiKeyDAO) FindKeys(condition map[string]any) ([]model.ApiKey, error) {
|
|
||||||
var apiKeys []model.ApiKey
|
|
||||||
|
|
||||||
query := dao.db.Model(&model.ApiKey{})
|
|
||||||
for k, v := range condition {
|
|
||||||
query = query.Where(k, v)
|
|
||||||
}
|
|
||||||
err := query.Find(&apiKeys).Error
|
|
||||||
|
|
||||||
return apiKeys, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (dao *ApiKeyDAO) FindApiKeysBySupportModel(db *gorm.DB, modelName string) ([]model.ApiKey, error) {
|
|
||||||
var apiKeys []model.ApiKey
|
|
||||||
switch dao.cfg.DB_Type {
|
|
||||||
case "mysql":
|
|
||||||
err := db.Raw(`
|
|
||||||
SELECT *
|
|
||||||
FROM apikeys
|
|
||||||
WHERE active = true
|
|
||||||
AND JSON_CONTAINS(support_models, ?, '$')`, modelName).
|
|
||||||
Scan(&apiKeys).Error
|
|
||||||
return apiKeys, err
|
|
||||||
case "postgres":
|
|
||||||
return nil, errors.New("not support")
|
|
||||||
}
|
|
||||||
err := db.Raw(`
|
|
||||||
SELECT a.*
|
|
||||||
FROM apikeys a
|
|
||||||
JOIN json_each(a.support_models) AS je ON je.value = ?
|
|
||||||
WHERE a.active = true`, modelName).Scan(&apiKeys).Error
|
|
||||||
return apiKeys, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateApiKey 更新ApiKey信息
|
|
||||||
func (dao *ApiKeyDAO) Update(apiKey *model.ApiKey) error {
|
|
||||||
if apiKey == nil {
|
|
||||||
return errors.New("apiKey is nil")
|
|
||||||
}
|
|
||||||
// return dao.db.Model(&model.ApiKey{}).
|
|
||||||
// Select("name", "apitype", "apikey", "status", "endpoint", "resource_name", "deployment_name").Updates(apiKey).Error
|
|
||||||
return dao.db.Save(apiKey).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteApiKey 删除ApiKey
|
|
||||||
func (dao *ApiKeyDAO) Delete(id int64) error {
|
|
||||||
return dao.db.Unscoped().Delete(&model.ApiKey{}, id).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListApiKeys 获取ApiKey列表
|
|
||||||
func (dao *ApiKeyDAO) List(limit, offset int, status string) ([]*model.ApiKey, error) {
|
|
||||||
var apiKeys []*model.ApiKey
|
|
||||||
db := dao.db.Limit(limit).Offset(offset)
|
|
||||||
if status != "" {
|
|
||||||
db = db.Where("status = ?", status)
|
|
||||||
}
|
|
||||||
err := db.Find(&apiKeys).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return apiKeys, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListApiKeysWithFilters 根据条件获取ApiKey列表
|
|
||||||
func (dao *ApiKeyDAO) ListWithFilters(limit, offset int, filters map[string]interface{}) ([]*model.ApiKey, int64, error) {
|
|
||||||
var apiKeys []*model.ApiKey
|
|
||||||
db := dao.db.Limit(limit).Offset(offset)
|
|
||||||
for k, v := range filters {
|
|
||||||
db = db.Where(k, v)
|
|
||||||
}
|
|
||||||
err := db.Find(&apiKeys).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
var total int64
|
var total int64
|
||||||
db.Model(&model.ApiKey{}).Count(&total)
|
db := d.db.Where("user_id = ?", userID)
|
||||||
|
db.Model(&store.APIKey{}).Count(&total)
|
||||||
return apiKeys, total, nil
|
err := db.Limit(limit).Offset(offset).Order("created_at DESC").Find(&apiKeys).Error
|
||||||
|
return apiKeys, total, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// BatchEnableApiKeys 批量启用ApiKey
|
func (d *ApiKeyDAO) Update(apiKey *store.APIKey) error {
|
||||||
func (dao *ApiKeyDAO) BatchEnable(ids []int64) 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 {
|
if len(ids) == 0 {
|
||||||
return errors.New("ids is empty")
|
return errors.New("ids is empty")
|
||||||
}
|
}
|
||||||
return dao.db.Model(&model.ApiKey{}).Where("id IN ?", ids).Update("active", true).Error
|
return d.db.Delete(&store.APIKey{}, ids).Error
|
||||||
}
|
|
||||||
|
|
||||||
// BatchDisableApiKeys 批量禁用ApiKey
|
|
||||||
func (dao *ApiKeyDAO) BatchDisable(ids []int64) error {
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return errors.New("ids is empty")
|
|
||||||
}
|
|
||||||
return dao.db.Model(&model.ApiKey{}).Where("id IN ?", ids).Update("active", false).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// BatchDeleteApiKey 批量删除ApiKey
|
|
||||||
func (dao *ApiKeyDAO) BatchDelete(ids []int64) error {
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return errors.New("ids is empty")
|
|
||||||
}
|
|
||||||
return dao.db.Unscoped().Delete(&model.ApiKey{}, ids).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// CountApiKeys 获取ApiKey总数
|
|
||||||
func (dao *ApiKeyDAO) Count() (int64, error) {
|
|
||||||
var count int64
|
|
||||||
err := dao.db.Model(&model.ApiKey{}).Count(&count).Error
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return count, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
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}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,71 @@
|
|||||||
|
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}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
+12
-172
@@ -1,35 +1,12 @@
|
|||||||
package dao
|
package dao
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"opencatd-open/internal/consts"
|
"opencatd-open/internal/store"
|
||||||
"opencatd-open/internal/model"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 确保 TokenDAO 实现了 TokenRepository 接口
|
|
||||||
var _ TokenRepository = (*TokenDAO)(nil)
|
|
||||||
|
|
||||||
type TokenRepository interface {
|
|
||||||
Create(ctx context.Context, token *model.Token) error
|
|
||||||
GetByID(ctx context.Context, id int64) (*model.Token, error)
|
|
||||||
GetByKey(ctx context.Context, key string) (*model.Token, error)
|
|
||||||
GetByUserID(ctx context.Context, userID int64) (*model.Token, error)
|
|
||||||
Update(ctx context.Context, token *model.Token) error
|
|
||||||
UpdateWithCondition(ctx context.Context, token *model.Token, filters map[string]interface{}, updates map[string]interface{}) error
|
|
||||||
Delete(ctx context.Context, id int64, condition map[string]interface{}) error
|
|
||||||
List(ctx context.Context, limit, offset int) ([]*model.Token, error)
|
|
||||||
ListWithFilters(ctx context.Context, limit, offset int, filters map[string]interface{}) ([]*model.Token, int64, error)
|
|
||||||
Disable(ctx context.Context, id int) error
|
|
||||||
Enable(ctx context.Context, id int) error
|
|
||||||
BatchDisable(ctx context.Context, ids []int64, filters map[string]interface{}) error
|
|
||||||
BatchEnable(ctx context.Context, ids []int64, filters map[string]interface{}) error
|
|
||||||
BatchDelete(ctx context.Context, ids []int64, filters map[string]interface{}) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type TokenDAO struct {
|
type TokenDAO struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
@@ -38,161 +15,24 @@ func NewTokenDAO(db *gorm.DB) *TokenDAO {
|
|||||||
return &TokenDAO{db: db}
|
return &TokenDAO{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateToken 创建 Token
|
func (d *TokenDAO) GetByKey(key string) (*store.User, error) {
|
||||||
func (dao *TokenDAO) Create(ctx context.Context, token *model.Token) error {
|
var user store.User
|
||||||
if token == nil {
|
err := d.db.Where("username = ?", key).First(&user).Error
|
||||||
return errors.New("token is nil")
|
|
||||||
}
|
|
||||||
return dao.db.WithContext(ctx).Create(token).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据 ID 获取 Token
|
|
||||||
func (dao *TokenDAO) GetByID(ctx context.Context, id int64) (*model.Token, error) {
|
|
||||||
var token model.Token
|
|
||||||
err := dao.db.WithContext(ctx).Preload("User").First(&token, id).Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &token, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据 Key 获取 Token
|
func (d *TokenDAO) GetByID(id uint64) (*store.User, error) {
|
||||||
func (dao *TokenDAO) GetByKey(ctx context.Context, key string) (*model.Token, error) {
|
var user store.User
|
||||||
var token model.Token
|
err := d.db.First(&user, id).Error
|
||||||
// err := dao.db.Where("key = ?", key).First(&token).Error
|
|
||||||
err := dao.db.WithContext(ctx).Preload("User").Where("key = ?", key).First(&token).Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &token, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据 UserID 获取 Token
|
// Placeholder to avoid compile errors - will be expanded in Phase 1
|
||||||
func (dao *TokenDAO) GetByUserID(ctx context.Context, userID int64) (*model.Token, error) {
|
var _ = errors.New
|
||||||
var token model.Token
|
var _ = gorm.ErrRecordNotFound
|
||||||
err := dao.db.WithContext(ctx).Preload("User").Where("user_id = ?", userID).Find(&token).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateToken 更新 Token 信息
|
|
||||||
func (dao *TokenDAO) Update(ctx context.Context, token *model.Token) error {
|
|
||||||
if token == nil {
|
|
||||||
return errors.New("token is nil")
|
|
||||||
}
|
|
||||||
return dao.db.WithContext(ctx).Save(token).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateTokenWithFilters 更新 Token 信息,支持过滤
|
|
||||||
func (dao *TokenDAO) UpdateWithCondition(ctx context.Context, token *model.Token, filters map[string]interface{}, updates map[string]interface{}) error {
|
|
||||||
if token == nil {
|
|
||||||
return errors.New("token is nil")
|
|
||||||
}
|
|
||||||
db := dao.db.WithContext(ctx)
|
|
||||||
for key, value := range filters {
|
|
||||||
db = db.Where(key+" = ?", value)
|
|
||||||
}
|
|
||||||
return db.Model(&model.Token{}).Updates(updates).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteToken 删除 Token
|
|
||||||
func (dao *TokenDAO) Delete(ctx context.Context, id int64, condition map[string]interface{}) error {
|
|
||||||
if id <= 0 {
|
|
||||||
return errors.New("id is invalid")
|
|
||||||
}
|
|
||||||
query := dao.db.WithContext(ctx).Where("id = ?", id)
|
|
||||||
for key, value := range condition {
|
|
||||||
query = query.Where(key, value)
|
|
||||||
}
|
|
||||||
return query.Unscoped().Delete(&model.Token{}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListTokens 获取 Token 列表
|
|
||||||
func (dao *TokenDAO) List(ctx context.Context, limit, offset int) ([]*model.Token, error) {
|
|
||||||
var tokens []*model.Token
|
|
||||||
err := dao.db.WithContext(ctx).Limit(limit).Offset(offset).Find(&tokens).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return tokens, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListTokensWithFilters 获取 Token 列表,支持过滤
|
|
||||||
func (dao *TokenDAO) ListWithFilters(ctx context.Context, limit, offset int, filters map[string]interface{}) ([]*model.Token, int64, error) {
|
|
||||||
var tokens []*model.Token
|
|
||||||
var count int64
|
|
||||||
|
|
||||||
db := dao.db.WithContext(ctx)
|
|
||||||
if filters != nil {
|
|
||||||
for k, v := range filters {
|
|
||||||
db = db.Where(k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.Limit(limit).Offset(offset).Find(&tokens).Error; err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
db.Model(&model.Token{}).Count(&count)
|
|
||||||
|
|
||||||
return tokens, count, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DisableToken 禁用 Token
|
|
||||||
func (dao *TokenDAO) Disable(ctx context.Context, id int) error {
|
|
||||||
return dao.db.WithContext(ctx).Model(&model.Token{}).Where("id = ?", id).Update("status", false).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnableToken 启用 Token
|
|
||||||
func (dao *TokenDAO) Enable(ctx context.Context, id int) error {
|
|
||||||
return dao.db.WithContext(ctx).Model(&model.Token{}).Where("id = ?", id).Update("status", true).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// BatchDisableTokens 批量禁用 Token
|
|
||||||
func (dao *TokenDAO) BatchDisable(ctx context.Context, ids []int64, filters map[string]interface{}) error {
|
|
||||||
query := dao.db.WithContext(ctx).Model(&model.Token{}).Where("id IN ?", ids)
|
|
||||||
for key, value := range filters {
|
|
||||||
query = query.Where(key, value)
|
|
||||||
}
|
|
||||||
return query.Update("active", false).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// BatchEnableTokens 批量启用 Token
|
|
||||||
func (dao *TokenDAO) BatchEnable(ctx context.Context, ids []int64, filters map[string]interface{}) error {
|
|
||||||
query := dao.db.WithContext(ctx).Model(&model.Token{}).Where("id IN ?", ids)
|
|
||||||
for key, value := range filters {
|
|
||||||
query = query.Where(key, value)
|
|
||||||
}
|
|
||||||
return query.Update("active", true).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// BatchDeleteTokens 批量删除 Token
|
|
||||||
func (dao *TokenDAO) BatchDelete(ctx context.Context, ids []int64, filters map[string]interface{}) error {
|
|
||||||
query := dao.db.Unscoped().WithContext(ctx).Where("id IN ?", ids)
|
|
||||||
for key, value := range filters {
|
|
||||||
query = query.Where(key, value)
|
|
||||||
}
|
|
||||||
return query.Delete(&model.Token{}).Error
|
|
||||||
// return dao.db.WithContext(ctx).Where("name != 'default' AND id IN ?", ids).Delete(&model.Token{}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查 token 是否有效
|
|
||||||
func (dao *TokenDAO) IsValid(ctx context.Context, key string) (bool, error) {
|
|
||||||
var token model.Token
|
|
||||||
err := dao.db.WithContext(ctx).Where("key = ? AND status = ? AND (expired_time = -1 OR expired_time > ?)",
|
|
||||||
key, consts.StatusEnabled, time.Now().Unix()).First(&token).Error
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if token.User.Status != consts.StatusEnabled || (*token.User.UnlimitedQuota && *token.User.Quota <= 0) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|||||||
+41
-229
@@ -2,286 +2,98 @@ package dao
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"opencatd-open/internal/store"
|
||||||
dto "opencatd-open/internal/dto/team"
|
|
||||||
"opencatd-open/internal/model"
|
|
||||||
"opencatd-open/pkg/config"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ UsageRepository = (*UsageDAO)(nil)
|
|
||||||
var _ DailyUsageRepository = (*DailyUsageDAO)(nil)
|
|
||||||
|
|
||||||
type UsageRepository interface {
|
|
||||||
// Create
|
|
||||||
Create(ctx context.Context, usage *model.Usage) error
|
|
||||||
BatchCreate(ctx context.Context, usages []*model.Usage) error
|
|
||||||
|
|
||||||
// Read
|
|
||||||
ListByUserID(ctx context.Context, userID int64, limit, offset int) ([]*model.Usage, error)
|
|
||||||
ListByTokenID(ctx context.Context, tokenID int64, limit, offset int) ([]*model.Usage, error)
|
|
||||||
ListByDateRange(ctx context.Context, start, end time.Time) ([]*model.Usage, error)
|
|
||||||
ListByCapability(ctx context.Context, capability string, limit, offset int) ([]*model.Usage, error)
|
|
||||||
|
|
||||||
// Delete
|
|
||||||
Delete(ctx context.Context, id int64) error
|
|
||||||
|
|
||||||
// Statistics
|
|
||||||
CountByUserID(ctx context.Context, userID int64) (int64, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type DailyUsageRepository interface {
|
|
||||||
// Create
|
|
||||||
Create(ctx context.Context, usage *model.DailyUsage) error
|
|
||||||
BatchCreate(ctx context.Context, usages []*model.DailyUsage) error
|
|
||||||
|
|
||||||
// Read
|
|
||||||
ListByUserID(ctx context.Context, userID int64, limit, offset int) ([]*model.DailyUsage, error)
|
|
||||||
ListByTokenID(ctx context.Context, tokenID int64, limit, offset int) ([]*model.DailyUsage, error)
|
|
||||||
ListByDateRange(ctx context.Context, start, end time.Time) ([]*model.DailyUsage, error)
|
|
||||||
GetByDate(ctx context.Context, userID int64, date time.Time) (*model.DailyUsage, error)
|
|
||||||
|
|
||||||
// Delete
|
|
||||||
Delete(ctx context.Context, id int64) error
|
|
||||||
|
|
||||||
// Statistics
|
|
||||||
CountByUserID(ctx context.Context, userID int64) (int64, error)
|
|
||||||
StatUserUsages(ctx context.Context, start, end time.Time, filters map[string]interface{}) ([]*dto.UsageInfo, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type UsageDAO struct {
|
type UsageDAO struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
type DailyUsageDAO struct {
|
type DailyUsageDAO struct {
|
||||||
cfg *config.Config
|
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUsageDAO(cfg *config.Config, db *gorm.DB) *UsageDAO {
|
func NewUsageDAO(db *gorm.DB) *UsageDAO {
|
||||||
return &UsageDAO{db: db}
|
return &UsageDAO{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDailyUsageDAO(cfg *config.Config, db *gorm.DB) *DailyUsageDAO {
|
func NewDailyUsageDAO(db *gorm.DB) *DailyUsageDAO {
|
||||||
return &DailyUsageDAO{db: db}
|
return &DailyUsageDAO{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usage DAO implementations
|
// UsageLog DAO
|
||||||
func (d *UsageDAO) Create(ctx context.Context, usage *model.Usage) error {
|
func (d *UsageDAO) Create(ctx context.Context, log *store.UsageLog) error {
|
||||||
return d.db.WithContext(ctx).Create(usage).Error
|
return d.db.WithContext(ctx).Create(log).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *UsageDAO) BatchCreate(ctx context.Context, usages []*model.Usage) error {
|
func (d *UsageDAO) BatchCreate(ctx context.Context, logs []*store.UsageLog) error {
|
||||||
return d.db.WithContext(ctx).Create(usages).Error
|
return d.db.WithContext(ctx).Create(logs).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *UsageDAO) GetByID(ctx context.Context, id int64) (*model.Usage, error) {
|
func (d *UsageDAO) ListByUserID(ctx context.Context, userID uint64, limit, offset int) ([]*store.UsageLog, error) {
|
||||||
var usage model.Usage
|
var logs []*store.UsageLog
|
||||||
err := d.db.WithContext(ctx).First(&usage, id).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &usage, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *UsageDAO) ListByUserID(ctx context.Context, userID int64, limit, offset int) ([]*model.Usage, error) {
|
|
||||||
var usages []*model.Usage
|
|
||||||
err := d.db.WithContext(ctx).
|
err := d.db.WithContext(ctx).
|
||||||
Where("user_id = ?", userID).
|
Where("user_id = ?", userID).
|
||||||
|
Order("created_at DESC").
|
||||||
Limit(limit).
|
Limit(limit).
|
||||||
Offset(offset).
|
Offset(offset).
|
||||||
Find(&usages).Error
|
Find(&logs).Error
|
||||||
return usages, err
|
return logs, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *UsageDAO) ListByTokenID(ctx context.Context, tokenID int64, limit, offset int) ([]*model.Usage, error) {
|
func (d *UsageDAO) Delete(ctx context.Context, id uint64) error {
|
||||||
var usages []*model.Usage
|
return d.db.WithContext(ctx).Delete(&store.UsageLog{}, id).Error
|
||||||
err := d.db.WithContext(ctx).
|
|
||||||
Where("token_id = ?", tokenID).
|
|
||||||
Limit(limit).
|
|
||||||
Offset(offset).
|
|
||||||
Find(&usages).Error
|
|
||||||
return usages, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *UsageDAO) ListByDateRange(ctx context.Context, start, end time.Time) ([]*model.Usage, error) {
|
func (d *UsageDAO) CountByUserID(ctx context.Context, userID uint64) (int64, error) {
|
||||||
var usages []*model.Usage
|
|
||||||
err := d.db.WithContext(ctx).
|
|
||||||
Where("date BETWEEN ? AND ?", start, end).
|
|
||||||
Find(&usages).Error
|
|
||||||
return usages, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *UsageDAO) ListByCapability(ctx context.Context, capability string, limit, offset int) ([]*model.Usage, error) {
|
|
||||||
var usages []*model.Usage
|
|
||||||
err := d.db.WithContext(ctx).
|
|
||||||
Where("capability = ?", capability).
|
|
||||||
Limit(limit).
|
|
||||||
Offset(offset).
|
|
||||||
Find(&usages).Error
|
|
||||||
return usages, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *UsageDAO) Delete(ctx context.Context, id int64) error {
|
|
||||||
return d.db.WithContext(ctx).Delete(&model.Usage{}, id).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *UsageDAO) CountByUserID(ctx context.Context, userID int64) (int64, error) {
|
|
||||||
var count int64
|
var count int64
|
||||||
err := d.db.WithContext(ctx).Model(&model.Usage{}).Where("user_id = ?", userID).Count(&count).Error
|
err := d.db.WithContext(ctx).Model(&store.UsageLog{}).Where("user_id = ?", userID).Count(&count).Error
|
||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// DailyUsage DAO implementations
|
// UsageDaily DAO
|
||||||
func (d *DailyUsageDAO) Create(ctx context.Context, usage *model.DailyUsage) error {
|
func (d *DailyUsageDAO) Create(ctx context.Context, log *store.UsageDaily) error {
|
||||||
return d.db.WithContext(ctx).Create(usage).Error
|
return d.db.WithContext(ctx).Create(log).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DailyUsageDAO) BatchCreate(ctx context.Context, usages []*model.DailyUsage) error {
|
func (d *DailyUsageDAO) ListByUserID(ctx context.Context, userID uint64, limit, offset int) ([]*store.UsageDaily, error) {
|
||||||
return d.db.WithContext(ctx).Create(usages).Error
|
var logs []*store.UsageDaily
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DailyUsageDAO) ListByUserID(ctx context.Context, userID int64, limit, offset int) ([]*model.DailyUsage, error) {
|
|
||||||
var usages []*model.DailyUsage
|
|
||||||
err := d.db.WithContext(ctx).
|
err := d.db.WithContext(ctx).
|
||||||
Where("user_id = ?", userID).
|
Where("user_id = ?", userID).
|
||||||
|
Order("date DESC").
|
||||||
Limit(limit).
|
Limit(limit).
|
||||||
Offset(offset).
|
Offset(offset).
|
||||||
Find(&usages).Error
|
Find(&logs).Error
|
||||||
return usages, err
|
return logs, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DailyUsageDAO) ListByTokenID(ctx context.Context, tokenID int64, limit, offset int) ([]*model.DailyUsage, error) {
|
func (d *DailyUsageDAO) GetByDate(ctx context.Context, userID uint64, date string) (*store.UsageDaily, error) {
|
||||||
var usages []*model.DailyUsage
|
var log store.UsageDaily
|
||||||
err := d.db.WithContext(ctx).
|
|
||||||
Where("token_id = ?", tokenID).
|
|
||||||
Limit(limit).
|
|
||||||
Offset(offset).
|
|
||||||
Find(&usages).Error
|
|
||||||
return usages, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DailyUsageDAO) ListByDateRange(ctx context.Context, start, end time.Time) ([]*model.DailyUsage, error) {
|
|
||||||
var usages []*model.DailyUsage
|
|
||||||
err := d.db.WithContext(ctx).
|
|
||||||
Where("date BETWEEN ? AND ?", start, end).
|
|
||||||
Find(&usages).Error
|
|
||||||
return usages, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DailyUsageDAO) GetByDate(ctx context.Context, userID int64, date time.Time) (*model.DailyUsage, error) {
|
|
||||||
var usage model.DailyUsage
|
|
||||||
err := d.db.WithContext(ctx).
|
err := d.db.WithContext(ctx).
|
||||||
Where("user_id = ? AND date = ?", userID, date).
|
Where("user_id = ? AND date = ?", userID, date).
|
||||||
First(&usage).Error
|
First(&log).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &usage, nil
|
return &log, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpsertDailyUsage 根据不同数据库类型执行 Upsert
|
func (d *DailyUsageDAO) UpsertDailyUsage(ctx context.Context, log *store.UsageDaily) error {
|
||||||
func (d *DailyUsageDAO) UpsertDailyUsage(ctx context.Context, usage *model.Usage) error {
|
return d.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||||
date := usage.Date.Truncate(24 * time.Hour)
|
Columns: []clause.Column{{Name: "user_id"}, {Name: "model_id"}, {Name: "date"}},
|
||||||
dailyUsage := &model.DailyUsage{
|
DoUpdates: clause.AssignmentColumns([]string{"requests", "input_tokens", "output_tokens", "cache_read_tokens", "cost"}),
|
||||||
UserID: usage.UserID,
|
}).Create(log).Error
|
||||||
TokenID: usage.TokenID,
|
|
||||||
Capability: usage.Capability,
|
|
||||||
Model: usage.Model,
|
|
||||||
Stream: usage.Stream,
|
|
||||||
PromptTokens: usage.PromptTokens,
|
|
||||||
CompletionTokens: usage.CompletionTokens,
|
|
||||||
TotalTokens: usage.TotalTokens,
|
|
||||||
Cost: usage.Cost,
|
|
||||||
}
|
|
||||||
|
|
||||||
updateColumns := map[string]interface{}{
|
|
||||||
"prompt_tokens": gorm.Expr("prompt_tokens + VALUES(prompt_tokens)"),
|
|
||||||
"completion_tokens": gorm.Expr("completion_tokens + VALUES(completion_tokens)"),
|
|
||||||
"total_tokens": gorm.Expr("total_tokens + VALUES(total_tokens)"),
|
|
||||||
}
|
|
||||||
|
|
||||||
db := d.db.WithContext(ctx)
|
|
||||||
|
|
||||||
switch d.cfg.DB_Type {
|
|
||||||
case "mysql":
|
|
||||||
// MySQL: INSERT ... ON DUPLICATE KEY UPDATE
|
|
||||||
return db.Clauses(clause.OnConflict{
|
|
||||||
Columns: []clause.Column{
|
|
||||||
{Name: "user_id"},
|
|
||||||
{Name: "date"},
|
|
||||||
},
|
|
||||||
DoUpdates: clause.Assignments(updateColumns),
|
|
||||||
}).Create(dailyUsage).Error
|
|
||||||
|
|
||||||
case "postgres":
|
|
||||||
// PostgreSQL: INSERT ... ON CONFLICT DO UPDATE
|
|
||||||
updateColumns := map[string]interface{}{
|
|
||||||
"prompt_tokens": gorm.Expr("daily_usages.prompt_tokens + EXCLUDED.prompt_tokens"),
|
|
||||||
"completion_tokens": gorm.Expr("daily_usages.completion_tokens + EXCLUDED.completion_tokens"),
|
|
||||||
"total_tokens": gorm.Expr("daily_usages.total_tokens + EXCLUDED.total_tokens"),
|
|
||||||
}
|
|
||||||
return db.Clauses(clause.OnConflict{
|
|
||||||
Columns: []clause.Column{
|
|
||||||
{Name: "user_id"},
|
|
||||||
{Name: "date"},
|
|
||||||
},
|
|
||||||
DoUpdates: clause.Assignments(updateColumns),
|
|
||||||
}).Create(dailyUsage).Error
|
|
||||||
case "sqlite":
|
|
||||||
fallthrough
|
|
||||||
default:
|
|
||||||
return db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
var existing model.DailyUsage
|
|
||||||
err := tx.Where("user_id = ? AND date = ?",
|
|
||||||
usage.UserID, date).
|
|
||||||
First(&existing).Error
|
|
||||||
|
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
// 记录不存在,创建新记录
|
|
||||||
return tx.Create(dailyUsage).Error
|
|
||||||
} else if err != nil {
|
|
||||||
return err // 返回其他错误
|
|
||||||
}
|
|
||||||
|
|
||||||
// 记录存在,更新
|
|
||||||
return tx.Model(&existing).Updates(map[string]interface{}{
|
|
||||||
"prompt_tokens": gorm.Expr("prompt_tokens + ?", usage.PromptTokens),
|
|
||||||
"completion_tokens": gorm.Expr("completion_tokens + ?", usage.CompletionTokens),
|
|
||||||
"total_tokens": gorm.Expr("total_tokens + ?", usage.TotalTokens),
|
|
||||||
}).Error
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DailyUsageDAO) Delete(ctx context.Context, id int64) error {
|
func (d *DailyUsageDAO) ListByDateRange(ctx context.Context, userID uint64, start, end time.Time) ([]*store.UsageDaily, error) {
|
||||||
return d.db.WithContext(ctx).Delete(&model.DailyUsage{}, id).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")).
|
||||||
func (d *DailyUsageDAO) CountByUserID(ctx context.Context, userID int64) (int64, error) {
|
Order("date DESC").
|
||||||
var count int64
|
Find(&logs).Error
|
||||||
err := d.db.WithContext(ctx).Model(&model.DailyUsage{}).Where("user_id = ?", userID).Count(&count).Error
|
return logs, err
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DailyUsageDAO) StatUserUsages(ctx context.Context, from, to time.Time, filters map[string]interface{}) ([]*dto.UsageInfo, error) {
|
|
||||||
var usages []*dto.UsageInfo
|
|
||||||
|
|
||||||
query := d.db.WithContext(ctx).
|
|
||||||
Model(&model.DailyUsage{}).
|
|
||||||
Select("user_id as userId, sum(total_tokens) as totalUnit, sum(cast(cost as decimal(20,6))) as cost")
|
|
||||||
for key, value := range filters {
|
|
||||||
query = query.Where(fmt.Sprintf("%s = ?", key), value)
|
|
||||||
}
|
|
||||||
query = query.Group("user_id").Where("date >= ? AND date <= ?", from, to)
|
|
||||||
|
|
||||||
err := query.Group("user_id").Find(&usages).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to list usages: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return usages, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-125
@@ -1,32 +1,11 @@
|
|||||||
package dao
|
package dao
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"opencatd-open/internal/store"
|
||||||
"fmt"
|
|
||||||
"opencatd-open/internal/model"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 确保 UserDAO 实现了 UserRepository 接口
|
|
||||||
var _ UserRepository = (*UserDAO)(nil)
|
|
||||||
|
|
||||||
// UserRepository 定义用户数据访问操作的接口
|
|
||||||
type UserRepository interface {
|
|
||||||
Create(user *model.User) error
|
|
||||||
GetByID(id int64) (*model.User, error)
|
|
||||||
GetByUsername(username string) (*model.User, error)
|
|
||||||
Update(user *model.User) error
|
|
||||||
Delete(id int64) error
|
|
||||||
List(limit, offset int, condition map[string]interface{}) ([]model.User, int64, error)
|
|
||||||
// Enable(id int64) error
|
|
||||||
// Disable(id int64) error
|
|
||||||
BatchEnable(ids []int64, condition []string) error
|
|
||||||
BatchDisable(ids []int64, condition []string) error
|
|
||||||
BatchDelete(ids []int64, condition []string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserDAO struct {
|
type UserDAO struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
@@ -35,129 +14,49 @@ func NewUserDAO(db *gorm.DB) *UserDAO {
|
|||||||
return &UserDAO{db: db}
|
return &UserDAO{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建用户
|
func (d *UserDAO) Create(user *store.User) error {
|
||||||
func (dao *UserDAO) Create(user *model.User) error {
|
return d.db.Create(user).Error
|
||||||
if user == nil {
|
|
||||||
return errors.New("user is nil")
|
|
||||||
}
|
|
||||||
fmt.Println(*user)
|
|
||||||
|
|
||||||
return dao.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
// 创建用户
|
|
||||||
if err := tx.Create(user).Error; err != nil {
|
|
||||||
return fmt.Errorf("failed to create user: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据ID获取用户
|
func (d *UserDAO) GetByID(id uint64) (*store.User, error) {
|
||||||
func (dao *UserDAO) GetByID(id int64) (*model.User, error) {
|
var user store.User
|
||||||
var user model.User
|
err := d.db.First(&user, id).Error
|
||||||
// err := dao.db.First(&user, id).Error
|
|
||||||
err := dao.db.Preload("Tokens", "user_id = ?", id).First(&user, id).Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据用户名获取用户
|
func (d *UserDAO) GetByUsername(username string) (*store.User, error) {
|
||||||
func (dao *UserDAO) GetByUsername(username string) (*model.User, error) {
|
var user store.User
|
||||||
var user model.User
|
err := d.db.Where("username = ?", username).First(&user).Error
|
||||||
// err := dao.db.Where("user_name = ?", username).First(&user).Error
|
|
||||||
err := dao.db.Preload("Tokens").Where("user_name = ?", username).First(&user).Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &user, nil
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新用户信息
|
func (d *UserDAO) GetByEmail(email string) (*store.User, error) {
|
||||||
func (dao *UserDAO) Update(user *model.User) error {
|
var user store.User
|
||||||
if user == nil {
|
err := d.db.Where("email = ?", email).First(&user).Error
|
||||||
return errors.New("user is nil")
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return &user, nil
|
||||||
user.UpdatedAt = time.Now().Unix()
|
|
||||||
return dao.db.Save(user).Error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除用户
|
func (d *UserDAO) List(limit, offset int) ([]*store.User, int64, error) {
|
||||||
func (dao *UserDAO) Delete(id int64) error {
|
var users []*store.User
|
||||||
return dao.db.Unscoped().Delete(&model.User{}, id).Error
|
|
||||||
// return dao.db.Model(&model.User{}).Where("id = ?", id).Update("status", 2).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取用户列表
|
|
||||||
func (dao *UserDAO) List(limit, offset int, condition map[string]interface{}) ([]model.User, int64, error) {
|
|
||||||
if offset < 0 {
|
|
||||||
offset = 0
|
|
||||||
}
|
|
||||||
var users []model.User
|
|
||||||
var total int64
|
var total int64
|
||||||
|
d.db.Model(&store.User{}).Count(&total)
|
||||||
query := dao.db.Preload("Tokens").Model(&model.User{})
|
err := d.db.Limit(limit).Offset(offset).Order("created_at DESC").Find(&users).Error
|
||||||
|
return users, total, err
|
||||||
for k, v := range condition {
|
|
||||||
query = query.Where(k, v)
|
|
||||||
}
|
|
||||||
err := query.Limit(limit).Offset(offset).Find(&users).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
query = dao.db.Model(&model.User{})
|
|
||||||
for k, v := range condition {
|
|
||||||
query = query.Where(k, v)
|
|
||||||
}
|
|
||||||
query.Count(&total)
|
|
||||||
|
|
||||||
return users, total, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启用User
|
func (d *UserDAO) Update(user *store.User) error {
|
||||||
func (dao *UserDAO) Enable(id uint) error {
|
return d.db.Save(user).Error
|
||||||
return dao.db.Model(&model.User{}).Where("id = ?", id).Update("active", true).Error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 禁用User
|
func (d *UserDAO) Delete(id uint64) error {
|
||||||
func (dao *UserDAO) Disable(id uint) error {
|
return d.db.Delete(&store.User{}, id).Error
|
||||||
return dao.db.Model(&model.User{}).Where("id = ?", id).Update("active", false).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// 批量启用User
|
|
||||||
func (dao *UserDAO) BatchEnable(ids []int64, condition []string) error {
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return errors.New("ids is empty")
|
|
||||||
}
|
|
||||||
query := dao.db.Model(&model.User{}).Where("id IN ?", ids)
|
|
||||||
for _, value := range condition {
|
|
||||||
query = query.Where(value)
|
|
||||||
}
|
|
||||||
return query.Update("active", true).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// 批量禁用User
|
|
||||||
func (dao *UserDAO) BatchDisable(ids []int64, condition []string) error {
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return errors.New("ids is empty")
|
|
||||||
}
|
|
||||||
query := dao.db.Model(&model.User{}).Where("id IN ?", ids)
|
|
||||||
for _, value := range condition {
|
|
||||||
query = query.Where(value)
|
|
||||||
}
|
|
||||||
return query.Update("active", false).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// 批量删除用户
|
|
||||||
func (dao *UserDAO) BatchDelete(ids []int64, condition []string) error {
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return errors.New("ids is empty")
|
|
||||||
}
|
|
||||||
query := dao.db.Unscoped().Where("id IN ?", ids)
|
|
||||||
for _, value := range condition {
|
|
||||||
query = query.Where(value)
|
|
||||||
}
|
|
||||||
return query.Delete(&model.User{}).Error
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
package dto
|
|
||||||
|
|
||||||
import (
|
|
||||||
"opencatd-open/internal/model"
|
|
||||||
"opencatd-open/internal/utils"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UserInfo struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Token string `json:"token"`
|
|
||||||
Status *bool `json:"status,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u UserInfo) HasNameUpdate() bool {
|
|
||||||
return u.Name != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u UserInfo) HasTokenUpdate() bool {
|
|
||||||
return u.Token != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u UserInfo) HasStatusUpdate() bool {
|
|
||||||
return u.Status != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type ApiKeyInfo struct {
|
|
||||||
ID int `json:"id,omitempty"`
|
|
||||||
Key string `json:"key,omitempty"`
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
ApiType string `json:"api_type,omitempty"`
|
|
||||||
Endpoint string `json:"endpoint,omitempty"`
|
|
||||||
Status *bool `json:"status,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加辅助方法判断字段是否需要更新
|
|
||||||
func (k ApiKeyInfo) HasNameUpdate() bool {
|
|
||||||
return k.Name != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k ApiKeyInfo) HasKeyUpdate() bool {
|
|
||||||
return k.Key != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k ApiKeyInfo) HasStatusUpdate() bool {
|
|
||||||
return k.Status != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k ApiKeyInfo) HasApiTypeUpdate() bool {
|
|
||||||
return k.ApiType != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// 辅助函数:统一处理字段更新
|
|
||||||
func (update *ApiKeyInfo) UpdateFields(existing *model.ApiKey) *model.ApiKey {
|
|
||||||
result := &model.ApiKey{
|
|
||||||
ID: existing.ID,
|
|
||||||
Name: existing.Name, // 默认保持原值
|
|
||||||
ApiType: existing.ApiType, // 默认保持原值
|
|
||||||
ApiKey: existing.ApiKey, // 默认保持原值
|
|
||||||
Active: existing.Active, // 默认保持原值
|
|
||||||
}
|
|
||||||
|
|
||||||
if update.HasNameUpdate() {
|
|
||||||
result.Name = utils.ToPtr(update.Name)
|
|
||||||
}
|
|
||||||
if update.HasKeyUpdate() {
|
|
||||||
result.ApiKey = utils.ToPtr(update.Key)
|
|
||||||
}
|
|
||||||
if update.HasStatusUpdate() {
|
|
||||||
result.Active = update.Status
|
|
||||||
}
|
|
||||||
if update.HasApiTypeUpdate() {
|
|
||||||
result.ApiType = utils.ToPtr(update.ApiType)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
type UsageInfo struct {
|
|
||||||
UserId int `json:"userId"`
|
|
||||||
TotalUnit int `json:"totalUnit"`
|
|
||||||
Cost string `json:"cost"`
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "github.com/lib/pq" //pq.StringArray
|
|
||||||
|
|
||||||
type ApiKey_PG struct {
|
|
||||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id,omitempty"`
|
|
||||||
Name *string `gorm:"column:name;not null;unique;index:idx_apikey_name" json:"name,omitempty"`
|
|
||||||
ApiType *string `gorm:"column:apitype;not null;index:idx_apikey_apitype" json:"type,omitempty"`
|
|
||||||
ApiKey *string `gorm:"column:apikey;not null;index:idx_apikey_apikey" json:"apikey,omitempty"`
|
|
||||||
Active *bool `gorm:"column:active;default:true" json:"active,omitempty"`
|
|
||||||
Endpoint *string `gorm:"column:endpoint" json:"endpoint,omitempty"`
|
|
||||||
ResourceNmae *string `gorm:"column:resource_name" json:"resource_name,omitempty"`
|
|
||||||
// DeploymentName *string `gorm:"column:deployment_name" json:"deployment_name,omitempty"`
|
|
||||||
ApiSecret *string `gorm:"column:api_secret" json:"api_secret,omitempty"`
|
|
||||||
ModelPrefix *string `gorm:"column:model_prefix" json:"model_prefix,omitempty"`
|
|
||||||
ModelAlias *string `gorm:"column:model_alias" json:"model_alias,omitempty"`
|
|
||||||
Parameters *string `gorm:"column:parameters" json:"parameters,omitempty"`
|
|
||||||
SupportModelsArray pq.StringArray `gorm:"column:support_models;type:text[]" json:"support_models_array,omitempty"`
|
|
||||||
SupportModels *string `gorm:"-" json:"support_models,omitempty"`
|
|
||||||
CreatedAt int64 `gorm:"column:created_at;autoUpdateTime" json:"created_at,omitempty"`
|
|
||||||
UpdatedAt int64 `gorm:"column:updated_at;autoCreateTime" json:"updated_at,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ApiKey_PG) TableName() string {
|
|
||||||
return "apikeys"
|
|
||||||
}
|
|
||||||
|
|
||||||
type ApiKey struct {
|
|
||||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id,omitempty"`
|
|
||||||
Name *string `gorm:"column:name;not null;unique;index:idx_apikey_name" json:"name,omitempty"`
|
|
||||||
ApiType *string `gorm:"column:apitype;not null;index:idx_apikey_apitype" json:"type,omitempty"`
|
|
||||||
ApiKey *string `gorm:"column:apikey;not null;index:idx_apikey_apikey" json:"apikey,omitempty"`
|
|
||||||
Active *bool `gorm:"column:active;default:true" json:"active,omitempty"`
|
|
||||||
Endpoint *string `gorm:"column:endpoint" json:"endpoint,omitempty"`
|
|
||||||
ResourceNmae *string `gorm:"column:resource_name" json:"resource_name,omitempty"`
|
|
||||||
// DeploymentName *string `gorm:"column:deployment_name" json:"deployment_name,omitempty"`
|
|
||||||
AccessKey *string `gorm:"column:access_key" json:"access_key,omitempty"`
|
|
||||||
SecretKey *string `gorm:"column:secret_key" json:"secret_key,omitempty"`
|
|
||||||
ModelPrefix *string `gorm:"column:model_prefix" json:"model_prefix,omitempty"`
|
|
||||||
ModelAlias *string `gorm:"column:model_alias" json:"model_alias,omitempty"`
|
|
||||||
Parameters *string `gorm:"column:parameters" json:"parameters,omitempty"`
|
|
||||||
SupportModels *string `gorm:"column:support_models;type:json" json:"support_models,omitempty"`
|
|
||||||
SupportModelsArray []string `gorm:"-" json:"support_models_array,omitempty"`
|
|
||||||
CreatedAt int64 `gorm:"column:created_at;autoUpdateTime" json:"created_at,omitempty"`
|
|
||||||
UpdatedAt int64 `gorm:"column:updated_at;autoCreateTime" json:"updated_at,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ApiKey) TableName() string {
|
|
||||||
return "apikeys"
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Passkey 用户凭证密钥模型
|
|
||||||
type Passkey struct {
|
|
||||||
ID int64 `json:"id" gorm:"column:id;primaryKey;autoIncrement"`
|
|
||||||
UserID int64 `json:"user_id" gorm:"column:user_id;index"`
|
|
||||||
CredentialID string `json:"credential_id" gorm:"column:credential_id;index"` // 凭证ID,用于识别特定的passkey
|
|
||||||
PublicKey string `json:"public_key" gorm:"column:public_key"` // 公钥,用于验证签名
|
|
||||||
AttestationType string `json:"attestation_type" gorm:"column:attestation_type"` // 证明类型
|
|
||||||
AAGUID string `json:"aaguid" gorm:"column:aaguid"` // 认证器标识符
|
|
||||||
SignCount uint32 `json:"sign_count" gorm:"column:sign_count"` // 签名计数器,用于防止重放攻击
|
|
||||||
Name string `json:"name" gorm:"column:name"` // 凭证名称,用于用户识别不同的设备
|
|
||||||
DeviceType string `json:"device_type" gorm:"column:device_type"` // 设备类型
|
|
||||||
BackupEligible bool `json:"backup_eligible" gorm:"column:backup_eligible"` // 是否可备份
|
|
||||||
BackupState bool `json:"backup_state" gorm:"backup_state"` // 备份状态
|
|
||||||
Transport string `json:"transport" gorm:"column:transport"` // 传输方式 (如usb、nfc、ble等)
|
|
||||||
LastUsedAt int64 `json:"last_used_at" gorm:"column:last_used_at;autoUpdateTime"` // 最后使用时间
|
|
||||||
CreatedAt int64 `json:"created_at,omitempty" gorm:"column:created_at;autoCreateTime"`
|
|
||||||
UpdatedAt int64 `json:"updated_at,omitempty" gorm:"column:updated_at;autoUpdateTime"`
|
|
||||||
|
|
||||||
// 关联用户模型(不存入数据库)
|
|
||||||
User User `json:"-" gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建表结构
|
|
||||||
func (Passkey) TableName() string {
|
|
||||||
return "passkeys"
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateSignCount 更新签名计数器和最后使用时间
|
|
||||||
func (p *Passkey) UpdateSignCount(count uint32) {
|
|
||||||
p.SignCount = count
|
|
||||||
p.LastUsedAt = time.Now().Unix()
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
// 用户的token
|
|
||||||
type Token struct {
|
|
||||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id,omitempty"`
|
|
||||||
UserID int64 `gorm:"column:user_id;not null;index:idx_token_user_id" json:"userid,omitempty"`
|
|
||||||
Name string `gorm:"column:name;not null;index:idx_token_name" json:"name,omitempty" binding:"required,min=1,max=20"`
|
|
||||||
Key string `gorm:"column:key;not null;uniqueIndex:idx_token_key;comment:token key" json:"key,omitempty"`
|
|
||||||
Active *bool `gorm:"column:active;default:true" json:"active,omitempty"` //
|
|
||||||
Quota *float64 `gorm:"column:quota;type:bigint;default:0" json:"quota,omitempty"` // default 0
|
|
||||||
UnlimitedQuota *bool `gorm:"column:unlimited_quota;default:true" json:"unlimited_quota,omitempty"` // set Quota 1 unlimited
|
|
||||||
UsedQuota *float64 `gorm:"column:used_quota;type:bigint;default:0" json:"used_quota,omitempty"`
|
|
||||||
ExpiredAt *int64 `gorm:"column:expired_at;type:bigint;default:0" json:"expired_at,omitempty"`
|
|
||||||
NeverExpired *bool `gorm:"column:never_expires;type:bigint;" json:"never_expires,omitempty"`
|
|
||||||
CreatedAt int64 `gorm:"column:created_at;type:bigint;autoCreateTime" json:"created_at,omitempty"`
|
|
||||||
LastUsedAt int64 `gorm:"column:lastused_at;type:bigint;autoUpdateTime" json:"lastused_at,omitempty"`
|
|
||||||
User *User `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (Token) TableName() string {
|
|
||||||
return "tokens"
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Usage struct {
|
|
||||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
|
||||||
UserID int64 `gorm:"column:user_id;index:idx_user_id"`
|
|
||||||
TokenID int64 `gorm:"column:token_id;index:idx_token_id"`
|
|
||||||
Capability string `gorm:"column:capability;index:idx_usage_capability;comment:模型能力"`
|
|
||||||
Date time.Time `gorm:"column:date;autoCreateTime;index:idx_date"`
|
|
||||||
Model string `gorm:"column:model"`
|
|
||||||
Stream bool `gorm:"column:stream"`
|
|
||||||
PromptTokens int `gorm:"column:prompt_tokens"`
|
|
||||||
CompletionTokens int `gorm:"column:completion_tokens"`
|
|
||||||
TotalTokens int `gorm:"column:total_tokens"`
|
|
||||||
Cost string `gorm:"column:cost"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (Usage) TableName() string {
|
|
||||||
return "usages"
|
|
||||||
}
|
|
||||||
|
|
||||||
type DailyUsage struct {
|
|
||||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
|
||||||
UserID int64 `gorm:"column:user_id;uniqueIndex:idx_daily_unique,priority:1"` // uniqueIndex:idx_daily_unique,priority:1
|
|
||||||
TokenID int64 `gorm:"column:token_id;uniqueIndex:idx_daily_unique,priority:2"`
|
|
||||||
Capability string `gorm:"column:capability;index:idx_daily_usage_capability;comment:模型能力"`
|
|
||||||
Date time.Time `gorm:"column:date;autoCreateTime;uniqueIndex:idx_daily_unique,priority:3"`
|
|
||||||
Model string `gorm:"column:model"`
|
|
||||||
Stream bool `gorm:"column:stream"`
|
|
||||||
PromptTokens int `gorm:"column:prompt_tokens"`
|
|
||||||
CompletionTokens int `gorm:"column:completion_tokens"`
|
|
||||||
TotalTokens int `gorm:"column:total_tokens"`
|
|
||||||
Cost string `gorm:"column:cost"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (DailyUsage) TableName() string {
|
|
||||||
return "daily_usages"
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"opencatd-open/internal/consts"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type User struct {
|
|
||||||
ID int64 `json:"id" gorm:"column:id;primaryKey;autoIncrement"`
|
|
||||||
Name string `json:"name" gorm:"column:name;index"`
|
|
||||||
Username string `json:"username" gorm:"column:username;unique;index"`
|
|
||||||
Password string `json:"-" gorm:"column:password;"`
|
|
||||||
NewPassword string `json:"newpassword" gorm:"-"`
|
|
||||||
Role *consts.UserRole `json:"role" gorm:"column:role;type:int;default:0"` // default user 0-10-20
|
|
||||||
Active *bool `json:"active" gorm:"column:active;default:true;"`
|
|
||||||
Status int `json:"status" gorm:"column:status;type:int;default:1"` // disabled 0, enabled 1, deleted 2
|
|
||||||
AvatarURL string `json:"avatar_url" gorm:"column:avatar_url;type:varchar(255)"`
|
|
||||||
EmailVerified *bool `json:"email_verified" gorm:"column:email_verified;default:false"`
|
|
||||||
Email string `json:"email" gorm:"column:email;type:varchar(255);index"`
|
|
||||||
Quota *float32 `json:"quota" gorm:"column:quota;bigint;default:0"` // default unlimited
|
|
||||||
UsedQuota *float32 `json:"used_quota" gorm:"column:used_quota;bigint;default:0"` // default 0
|
|
||||||
UnlimitedQuota *bool `json:"unlimited_quota" gorm:"column:unlimited_quota;default:true;"` // 0 limited , 1 unlimited
|
|
||||||
Timezone string `json:"timezone" gorm:"column:timezone;type:varchar(50)"`
|
|
||||||
Language string `json:"language" gorm:"column:language;type:varchar(50)"`
|
|
||||||
|
|
||||||
// 添加一对多关系
|
|
||||||
Tokens []Token `json:"tokens" gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
|
||||||
Passkeys []Passkey `json:"passkeys" gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
|
||||||
|
|
||||||
CreatedAt int64 `json:"created_at,omitempty" gorm:"autoCreateTime"`
|
|
||||||
UpdatedAt int64 `json:"updated_at,omitempty" gorm:"autoUpdateTime"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (User) TableName() string {
|
|
||||||
return "users"
|
|
||||||
}
|
|
||||||
|
|
||||||
type Session struct {
|
|
||||||
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
|
||||||
UserID int64 `json:"user_id" gorm:"index:idx_user_id"`
|
|
||||||
Token string `json:"token" gorm:"type:varchar(64);uniqueIndex"`
|
|
||||||
DeviceType string `json:"device_type" gorm:"type:varchar(100);default:''"`
|
|
||||||
DeviceName string `json:"device_name" gorm:"type:varchar(100);default:''"`
|
|
||||||
LastActiveAt time.Time `json:"last_active_at" gorm:"type:timestamp;default:CURRENT_TIMESTAMP"`
|
|
||||||
LogoutAt time.Time `json:"logout_at" gorm:"type:timestamp;null"`
|
|
||||||
|
|
||||||
CreatedAt time.Time `json:"created_at" gorm:"type:timestamp;not null;default:CURRENT_TIMESTAMP"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at" gorm:"type:timestamp;not null;default:CURRENT_TIMESTAMP;update:CURRENT_TIMESTAMP"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (Session) TableName() string {
|
|
||||||
return "sessions"
|
|
||||||
}
|
|
||||||
@@ -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: 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: 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,231 @@
|
|||||||
|
package convert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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) != 1 {
|
||||||
|
t.Errorf("Input length = %d, want 1", len(result.Input))
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Input[0].Role != "user" {
|
||||||
|
t.Errorf("Input[0].Role = %q, want %q", result.Input[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,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,59 @@
|
|||||||
|
package convert
|
||||||
|
|
||||||
|
// ResponsesRequest represents an OpenAI Responses API request
|
||||||
|
type ResponsesRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Input []InputItem `json:"input"`
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,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,380 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"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/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
|
||||||
|
channelSvc *channel.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
channelSvc: nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) SetChannelService(svc *channel.Service) {
|
||||||
|
g.channelSvc = svc
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
|
||||||
|
req := &Request{
|
||||||
|
Protocol: protocol,
|
||||||
|
Body: body,
|
||||||
|
UserID: userID.(uint64),
|
||||||
|
}
|
||||||
|
|
||||||
|
if ak, ok := apiKey.(*store.APIKey); ok {
|
||||||
|
req.APIKey = ak
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
ch, err := g.channelSvc.SelectChannel(g.ctx, req.Model)
|
||||||
|
if err != nil {
|
||||||
|
g.writeError(c, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey, err := g.channelSvc.GetAPIKey(ch)
|
||||||
|
if err != nil {
|
||||||
|
g.writeError(c, http.StatusBadGateway, "failed to decrypt API key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine target format and convert if needed
|
||||||
|
targetFormat := req.Protocol
|
||||||
|
if len(ch.FormatsEffective()) > 0 {
|
||||||
|
// Prefer the channel's native format
|
||||||
|
for _, f := range ch.FormatsEffective() {
|
||||||
|
if f == req.Protocol {
|
||||||
|
targetFormat = f
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build upstream URL
|
||||||
|
upstreamPath := g.getUpstreamPath(req.Protocol)
|
||||||
|
upstreamURL := ch.UpstreamURL(req.Protocol, upstreamPath)
|
||||||
|
|
||||||
|
// Convert request if needed
|
||||||
|
var requestBody []byte
|
||||||
|
if targetFormat != req.Protocol {
|
||||||
|
requestBody, err = g.convertRequest(req.Body, req.Protocol, targetFormat)
|
||||||
|
if err != nil {
|
||||||
|
g.writeError(c, http.StatusBadRequest, "conversion failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
requestBody = req.Body
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create upstream request
|
||||||
|
httpReq, err := http.NewRequestWithContext(g.ctx, "POST", upstreamURL, bytes.NewReader(requestBody))
|
||||||
|
if err != nil {
|
||||||
|
g.writeError(c, http.StatusBadGateway, "failed to create request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
g.setHeaders(httpReq, ch, apiKey, targetFormat)
|
||||||
|
|
||||||
|
// Execute request
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := g.httpClient.Do(httpReq)
|
||||||
|
latency := time.Since(start)
|
||||||
|
if err != nil {
|
||||||
|
g.channelSvc.RecordFailure(ch.ID)
|
||||||
|
g.writeError(c, http.StatusBadGateway, fmt.Sprintf("upstream error: %v (latency: %v)", err, latency))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Record success
|
||||||
|
g.channelSvc.RecordSuccess(ch.ID)
|
||||||
|
|
||||||
|
// Handle response
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
log.Printf("Upstream error: status=%d body=%s", resp.StatusCode, string(body))
|
||||||
|
c.Data(resp.StatusCode, "application/json", body)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stream or buffer response
|
||||||
|
if req.Stream {
|
||||||
|
g.streamResponse(c, resp, req.Protocol, ch)
|
||||||
|
} else {
|
||||||
|
g.bufferResponse(c, resp, req.Protocol, ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) convertRequest(body []byte, from, to string) ([]byte, error) {
|
||||||
|
switch {
|
||||||
|
case from == "chat" && to == "messages":
|
||||||
|
var req convert.ChatCompletionRequest
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
msgReq, err := convert.ChatToMessages(&req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(msgReq)
|
||||||
|
|
||||||
|
case from == "chat" && to == "responses":
|
||||||
|
var req convert.ChatCompletionRequest
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
respReq, err := convert.ChatToResponses(&req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.Marshal(respReq)
|
||||||
|
|
||||||
|
case from == "messages" && to == "chat":
|
||||||
|
var req convert.MessagesRequest
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Messages -> Chat: we need to construct a ChatCompletionRequest
|
||||||
|
chatReq := &convert.ChatCompletionRequest{
|
||||||
|
Model: req.Model,
|
||||||
|
}
|
||||||
|
for _, m := range req.Messages {
|
||||||
|
chatReq.Messages = append(chatReq.Messages, m)
|
||||||
|
}
|
||||||
|
if req.Temperature != nil {
|
||||||
|
chatReq.Temperature = req.Temperature
|
||||||
|
}
|
||||||
|
if req.TopP != nil {
|
||||||
|
chatReq.TopP = req.TopP
|
||||||
|
}
|
||||||
|
chatReq.Tools = req.Tools
|
||||||
|
chatReq.Stream = req.Stream
|
||||||
|
return json.Marshal(chatReq)
|
||||||
|
|
||||||
|
case from == "responses" && to == "chat":
|
||||||
|
var req convert.ResponsesRequest
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
chatReq := &convert.ChatCompletionRequest{
|
||||||
|
Model: req.Model,
|
||||||
|
}
|
||||||
|
for _, item := range req.Input {
|
||||||
|
chatReq.Messages = append(chatReq.Messages, convert.Message{
|
||||||
|
Role: item.Role,
|
||||||
|
Content: item.Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
chatReq.Tools = req.Tools
|
||||||
|
chatReq.Stream = req.Stream
|
||||||
|
return json.Marshal(chatReq)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) streamResponse(c *gin.Context, resp *http.Response, protocol string, ch *store.Channel) {
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
|
||||||
|
writer := convert.NewSSEWriter(c.Writer)
|
||||||
|
parser := convert.NewSSEParser(resp.Body)
|
||||||
|
|
||||||
|
for {
|
||||||
|
event, err := parser.ReadEvent()
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
log.Printf("Stream parse error: %v", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.Event == "error" {
|
||||||
|
log.Printf("Upstream stream error: %s", event.Data)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write raw SSE event based on protocol
|
||||||
|
if err := writer.WriteEvent("chat CompletionChunk", event.Data); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.WriteDone()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) bufferResponse(c *gin.Context, resp *http.Response, protocol string, ch *store.Channel) {
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
g.writeError(c, http.StatusBadGateway, "failed to read response")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Data(resp.StatusCode, "application/json", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,49 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"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) {
|
||||||
|
// TODO: Return list of available models based on enabled channels
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"object": "list",
|
||||||
|
"data": []interface{}{},
|
||||||
|
})
|
||||||
|
}
|
||||||
+10
-61
@@ -2,88 +2,37 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"opencatd-open/internal/dao"
|
"opencatd-open/internal/dao"
|
||||||
"opencatd-open/internal/model"
|
"opencatd-open/internal/store"
|
||||||
"opencatd-open/internal/utils"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ApiKeyServiceImpl struct {
|
type ApiKeyServiceImpl struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
apiKeyRepo dao.ApiKeyRepository
|
apiKeyRepo *dao.ApiKeyDAO
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewApiKeyService(db *gorm.DB, apiKeyDao dao.ApiKeyRepository) *ApiKeyServiceImpl {
|
func NewApiKeyService(db *gorm.DB, apiKeyDao *dao.ApiKeyDAO) *ApiKeyServiceImpl {
|
||||||
return &ApiKeyServiceImpl{db: db, apiKeyRepo: apiKeyDao}
|
return &ApiKeyServiceImpl{db: db, apiKeyRepo: apiKeyDao}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ApiKeyServiceImpl) CreateApiKey(ctx context.Context, apikey *model.ApiKey) error {
|
func (s *ApiKeyServiceImpl) CreateApiKey(ctx context.Context, apikey *store.APIKey) error {
|
||||||
return s.apiKeyRepo.Create(apikey)
|
return s.apiKeyRepo.Create(apikey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ApiKeyServiceImpl) GetApiKey(ctx context.Context, id int64) (*model.ApiKey, error) {
|
func (s *ApiKeyServiceImpl) GetApiKey(ctx context.Context, id uint64) (*store.APIKey, error) {
|
||||||
return s.apiKeyRepo.GetByID(id)
|
return s.apiKeyRepo.GetByID(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ApiKeyServiceImpl) ListApiKey(ctx context.Context, limit, offset int, active []string) ([]*model.ApiKey, int64, error) {
|
func (s *ApiKeyServiceImpl) ListApiKey(ctx context.Context, userID uint64, limit, offset int) ([]*store.APIKey, int64, error) {
|
||||||
var conditions = make(map[string]interface{})
|
return s.apiKeyRepo.ListByUserID(userID, limit, offset)
|
||||||
if len(active) > 0 {
|
|
||||||
conditions["active IN ?"] = utils.StringToBool(active)
|
|
||||||
}
|
|
||||||
return s.apiKeyRepo.ListWithFilters(limit, offset, conditions)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ApiKeyServiceImpl) UpdateApiKey(ctx context.Context, apikey *model.ApiKey) error {
|
func (s *ApiKeyServiceImpl) UpdateApiKey(ctx context.Context, apikey *store.APIKey) error {
|
||||||
_key, err := s.apiKeyRepo.GetByID(apikey.ID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get apikey failed: %v", err)
|
|
||||||
}
|
|
||||||
if apikey.ApiKey != nil {
|
|
||||||
_key.ApiKey = apikey.ApiKey
|
|
||||||
}
|
|
||||||
if apikey.Active != nil {
|
|
||||||
_key.Active = apikey.Active
|
|
||||||
}
|
|
||||||
if apikey.Endpoint != nil {
|
|
||||||
_key.Endpoint = apikey.Endpoint
|
|
||||||
}
|
|
||||||
if apikey.ResourceNmae != nil {
|
|
||||||
_key.ResourceNmae = apikey.ResourceNmae
|
|
||||||
}
|
|
||||||
if apikey.AccessKey != nil {
|
|
||||||
_key.AccessKey = apikey.AccessKey
|
|
||||||
}
|
|
||||||
if apikey.SecretKey != nil {
|
|
||||||
_key.SecretKey = apikey.SecretKey
|
|
||||||
}
|
|
||||||
if apikey.ModelAlias != nil {
|
|
||||||
_key.ModelAlias = apikey.ModelAlias
|
|
||||||
}
|
|
||||||
if apikey.ModelPrefix != nil {
|
|
||||||
_key.ModelPrefix = apikey.ModelPrefix
|
|
||||||
}
|
|
||||||
if apikey.Parameters != nil {
|
|
||||||
_key.Parameters = apikey.Parameters
|
|
||||||
}
|
|
||||||
if apikey.SupportModels != nil {
|
|
||||||
_key.SupportModels = apikey.SupportModels
|
|
||||||
}
|
|
||||||
if apikey.SupportModelsArray != nil {
|
|
||||||
_key.SupportModelsArray = apikey.SupportModelsArray
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.apiKeyRepo.Update(apikey)
|
return s.apiKeyRepo.Update(apikey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ApiKeyServiceImpl) DeleteApiKey(ctx context.Context, ids []int64) error {
|
func (s *ApiKeyServiceImpl) DeleteApiKey(ctx context.Context, id uint64) error {
|
||||||
return s.apiKeyRepo.BatchDelete(ids)
|
return s.apiKeyRepo.Delete(id)
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ApiKeyServiceImpl) EnableApiKey(ctx context.Context, ids []int64) error {
|
|
||||||
return s.apiKeyRepo.BatchEnable(ids)
|
|
||||||
}
|
|
||||||
func (s *ApiKeyServiceImpl) DisableApiKey(ctx context.Context, ids []int64) error {
|
|
||||||
return s.apiKeyRepo.BatchDisable(ids)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"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) {
|
||||||
|
return s.channelSvc.SelectChannel(ctx, modelName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"opencatd-open/internal/dao"
|
|
||||||
"opencatd-open/internal/model"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ ApiKeyService = (*ApiKeyServiceImpl)(nil)
|
|
||||||
|
|
||||||
type ApiKeyService interface {
|
|
||||||
Create(apiKey *model.ApiKey) error
|
|
||||||
GetByID(id int64) (*model.ApiKey, error)
|
|
||||||
GetByName(name string) (*model.ApiKey, error)
|
|
||||||
GetByApiKey(apiKeyValue string) (*model.ApiKey, error)
|
|
||||||
Update(apiKey *model.ApiKey) error
|
|
||||||
Delete(id int64) error
|
|
||||||
List(limit, offset int, status string) ([]*model.ApiKey, error)
|
|
||||||
ListWithFilters(limit, offset int, filters map[string]interface{}) ([]*model.ApiKey, int64, error)
|
|
||||||
BatchEnable(ids []int64) error
|
|
||||||
BatchDisable(ids []int64) error
|
|
||||||
BatchDelete(ids []int64) error
|
|
||||||
Count() (int64, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ApiKeyServiceImpl struct {
|
|
||||||
db *gorm.DB
|
|
||||||
apiKeyRepo dao.ApiKeyRepository
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewApiKeyService(db *gorm.DB, apiKeyDao dao.ApiKeyRepository) ApiKeyService {
|
|
||||||
return &ApiKeyServiceImpl{apiKeyRepo: apiKeyDao, db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ApiKeyServiceImpl) Create(apiKey *model.ApiKey) error {
|
|
||||||
if apiKey == nil {
|
|
||||||
return errors.New("apiKey不能为空")
|
|
||||||
}
|
|
||||||
if apiKey.Name == nil {
|
|
||||||
return errors.New("apiKey名称不能为空")
|
|
||||||
}
|
|
||||||
if apiKey.ApiKey == nil {
|
|
||||||
return errors.New("apiKey值不能为空")
|
|
||||||
}
|
|
||||||
apiKey.CreatedAt = time.Now().Unix()
|
|
||||||
apiKey.UpdatedAt = time.Now().Unix()
|
|
||||||
|
|
||||||
return s.apiKeyRepo.Create(apiKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ApiKeyServiceImpl) GetByID(id int64) (*model.ApiKey, error) {
|
|
||||||
if id <= 0 {
|
|
||||||
return nil, errors.New("id 必须大于 0")
|
|
||||||
}
|
|
||||||
return s.apiKeyRepo.GetByID(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ApiKeyServiceImpl) GetByName(name string) (*model.ApiKey, error) {
|
|
||||||
if name == "" {
|
|
||||||
return nil, errors.New("name 不能为空")
|
|
||||||
}
|
|
||||||
return s.apiKeyRepo.GetByName(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ApiKeyServiceImpl) GetByApiKey(apiKeyValue string) (*model.ApiKey, error) {
|
|
||||||
if apiKeyValue == "" {
|
|
||||||
return nil, errors.New("apiKeyValue 不能为空")
|
|
||||||
}
|
|
||||||
return s.apiKeyRepo.GetByApiKey(apiKeyValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ApiKeyServiceImpl) Update(apiKey *model.ApiKey) error {
|
|
||||||
if apiKey == nil {
|
|
||||||
return errors.New("apiKey不能为空")
|
|
||||||
}
|
|
||||||
if apiKey.ID <= 0 {
|
|
||||||
return errors.New("apiKey ID 必须大于 0")
|
|
||||||
}
|
|
||||||
return s.apiKeyRepo.Update(apiKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ApiKeyServiceImpl) Delete(id int64) error {
|
|
||||||
if id <= 0 {
|
|
||||||
return errors.New("id 必须大于 0")
|
|
||||||
}
|
|
||||||
return s.apiKeyRepo.BatchDelete([]int64{id})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ApiKeyServiceImpl) List(offset, limit int, status string) ([]*model.ApiKey, error) {
|
|
||||||
if offset < 0 {
|
|
||||||
offset = 0
|
|
||||||
}
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 20 // 设置默认值
|
|
||||||
}
|
|
||||||
return s.apiKeyRepo.List(offset, limit, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ApiKeyServiceImpl) ListWithFilters(offset, limit int, filters map[string]interface{}) ([]*model.ApiKey, int64, error) {
|
|
||||||
if offset < 0 {
|
|
||||||
offset = 0
|
|
||||||
}
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 20 // 设置默认值
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.apiKeyRepo.ListWithFilters(offset, limit, filters)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ApiKeyServiceImpl) Enable(id int64) error {
|
|
||||||
if id <= 0 {
|
|
||||||
return errors.New("id 必须大于 0")
|
|
||||||
}
|
|
||||||
return s.apiKeyRepo.BatchEnable([]int64{id})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ApiKeyServiceImpl) Disable(id int64) error {
|
|
||||||
if id <= 0 {
|
|
||||||
return errors.New("id 必须大于 0")
|
|
||||||
}
|
|
||||||
return s.apiKeyRepo.BatchDisable([]int64{id})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ApiKeyServiceImpl) BatchEnable(ids []int64) error {
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return errors.New("ids 不能为空")
|
|
||||||
}
|
|
||||||
return s.apiKeyRepo.BatchEnable(ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ApiKeyServiceImpl) BatchDisable(ids []int64) error {
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return errors.New("ids 不能为空")
|
|
||||||
}
|
|
||||||
return s.apiKeyRepo.BatchDisable(ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ApiKeyServiceImpl) BatchDelete(ids []int64) error {
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return errors.New("ids 不能为空")
|
|
||||||
}
|
|
||||||
return s.apiKeyRepo.BatchDelete(ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ApiKeyServiceImpl) Count() (int64, error) {
|
|
||||||
return s.apiKeyRepo.Count()
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"opencatd-open/internal/dao"
|
|
||||||
"opencatd-open/internal/model"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 确保 TokenService 实现了 TokenServiceInterface 接口
|
|
||||||
var _ TokenService = (*TokenServiceImpl)(nil)
|
|
||||||
|
|
||||||
type TokenService interface {
|
|
||||||
Create(ctx context.Context, token *model.Token) error
|
|
||||||
GetByID(ctx context.Context, id int64) (*model.Token, error)
|
|
||||||
GetByKey(ctx context.Context, key string) (*model.Token, error)
|
|
||||||
GetByUserID(ctx context.Context, userID int64) (*model.Token, error)
|
|
||||||
Update(ctx context.Context, token *model.Token) error
|
|
||||||
UpdateWithCondition(ctx context.Context, token *model.Token, filters map[string]interface{}, updates map[string]interface{}) error
|
|
||||||
Delete(ctx context.Context, id int64) error
|
|
||||||
Lists(ctx context.Context, limit, offset int) ([]*model.Token, int64, error)
|
|
||||||
Disable(ctx context.Context, id int) error
|
|
||||||
Enable(ctx context.Context, id int) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type TokenServiceImpl struct {
|
|
||||||
tokenRepo dao.TokenRepository
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTokenService(tokenRepo dao.TokenRepository) TokenService {
|
|
||||||
return &TokenServiceImpl{tokenRepo: tokenRepo}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TokenServiceImpl) Create(ctx context.Context, token *model.Token) error {
|
|
||||||
if token.Key == "" {
|
|
||||||
token.Key = "sk-team-" + strings.ReplaceAll(uuid.New().String(), "-", "")
|
|
||||||
}
|
|
||||||
return s.tokenRepo.Create(ctx, token)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TokenServiceImpl) GetByID(ctx context.Context, id int64) (*model.Token, error) {
|
|
||||||
return s.tokenRepo.GetByID(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TokenServiceImpl) GetByKey(ctx context.Context, key string) (*model.Token, error) {
|
|
||||||
return s.tokenRepo.GetByKey(ctx, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TokenServiceImpl) GetByUserID(ctx context.Context, userID int64) (*model.Token, error) {
|
|
||||||
return s.tokenRepo.GetByUserID(ctx, userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TokenServiceImpl) Update(ctx context.Context, token *model.Token) error {
|
|
||||||
return s.tokenRepo.Update(ctx, token)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TokenServiceImpl) UpdateWithCondition(ctx context.Context, token *model.Token, filters map[string]interface{}, updates map[string]interface{}) error {
|
|
||||||
return s.tokenRepo.UpdateWithCondition(ctx, token, filters, updates)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TokenServiceImpl) Delete(ctx context.Context, id int64) error {
|
|
||||||
return s.tokenRepo.Delete(ctx, id, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TokenServiceImpl) Lists(ctx context.Context, limit, offset int) ([]*model.Token, int64, error) {
|
|
||||||
return s.tokenRepo.ListWithFilters(ctx, limit, offset, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TokenServiceImpl) Disable(ctx context.Context, id int) error {
|
|
||||||
return s.tokenRepo.Disable(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TokenServiceImpl) Enable(ctx context.Context, id int) error {
|
|
||||||
return s.tokenRepo.Enable(ctx, id)
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"opencatd-open/internal/dao"
|
|
||||||
dto "opencatd-open/internal/dto/team"
|
|
||||||
"opencatd-open/internal/model"
|
|
||||||
"opencatd-open/pkg/config"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ UsageService = (*usageService)(nil)
|
|
||||||
|
|
||||||
type UsageService interface {
|
|
||||||
ListByUserID(ctx context.Context, userID int64, limit, offset int) ([]*model.Usage, error)
|
|
||||||
ListByCapability(ctx context.Context, capability string, limit, offset int) ([]*model.Usage, error)
|
|
||||||
ListByDateRange(ctx context.Context, start, end time.Time, filters map[string]interface{}) ([]*dto.UsageInfo, error)
|
|
||||||
|
|
||||||
Delete(ctx context.Context, id int64) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type usageService struct {
|
|
||||||
ctx context.Context
|
|
||||||
cfg *config.Config
|
|
||||||
db *gorm.DB
|
|
||||||
|
|
||||||
usageDAO dao.UsageRepository
|
|
||||||
dailyUsageDAO dao.DailyUsageRepository
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewUsageService(ctx context.Context, cfg *config.Config, db *gorm.DB, usageRepo dao.UsageRepository, dailyUsageRepo dao.DailyUsageRepository) UsageService {
|
|
||||||
srv := &usageService{
|
|
||||||
ctx: ctx,
|
|
||||||
cfg: cfg,
|
|
||||||
db: db,
|
|
||||||
|
|
||||||
usageDAO: usageRepo,
|
|
||||||
dailyUsageDAO: dailyUsageRepo,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 启动异步处理goroutine
|
|
||||||
|
|
||||||
return srv
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *usageService) ListByUserID(ctx context.Context, userID int64, limit int, offset int) ([]*model.Usage, error) {
|
|
||||||
return s.usageDAO.ListByUserID(ctx, userID, limit, offset)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *usageService) ListByCapability(ctx context.Context, capability string, limit, offset int) ([]*model.Usage, error) {
|
|
||||||
return s.usageDAO.ListByCapability(ctx, capability, limit, offset)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *usageService) ListByDateRange(ctx context.Context, start, end time.Time, filters map[string]interface{}) ([]*dto.UsageInfo, error) {
|
|
||||||
return s.dailyUsageDAO.StatUserUsages(ctx, start, end, filters)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *usageService) Delete(ctx context.Context, id int64) error {
|
|
||||||
return s.usageDAO.Delete(ctx, id)
|
|
||||||
}
|
|
||||||
@@ -1,679 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"opencatd-open/internal/consts"
|
|
||||||
"opencatd-open/internal/dao"
|
|
||||||
"opencatd-open/internal/model"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
"golang.org/x/exp/rand"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrUserNotFound = errors.New("user not found")
|
|
||||||
ErrInvalidUserInput = errors.New("invalid user input")
|
|
||||||
ErrUserExists = errors.New("user already exists")
|
|
||||||
ErrInvalidPassword = errors.New("invalid password format")
|
|
||||||
ErrPermissionDenied = errors.New("permission denied")
|
|
||||||
ErrInvalidOperation = errors.New("invalid operation")
|
|
||||||
ErrTransactionFailed = errors.New("transaction failed")
|
|
||||||
)
|
|
||||||
|
|
||||||
// PasswordPolicy 定义密码策略
|
|
||||||
type PasswordPolicy struct {
|
|
||||||
MinLength int
|
|
||||||
MaxLength int
|
|
||||||
NeedNumber bool
|
|
||||||
NeedUpper bool
|
|
||||||
NeedLower bool
|
|
||||||
NeedSymbol bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成随机数字
|
|
||||||
func generateNumber() string {
|
|
||||||
rand.Seed(uint64(time.Now().UnixNano()))
|
|
||||||
return string('0' + rand.Intn(10))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成随机大写字母
|
|
||||||
func generateUpper() string {
|
|
||||||
rand.Seed(uint64(time.Now().UnixNano()))
|
|
||||||
return string('A' + rand.Intn(26))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成随机小写字母
|
|
||||||
func generateLower() string {
|
|
||||||
rand.Seed(uint64(time.Now().UnixNano()))
|
|
||||||
return string('a' + rand.Intn(26))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成随机特殊符号
|
|
||||||
func generateSymbol() string {
|
|
||||||
rand.Seed(uint64(time.Now().UnixNano()))
|
|
||||||
symbols := "!@#$%^&*"
|
|
||||||
return string(symbols[rand.Intn(len(symbols))])
|
|
||||||
}
|
|
||||||
|
|
||||||
// GeneratePassword 根据密码策略生成密码
|
|
||||||
func GeneratePassword(policy PasswordPolicy) string {
|
|
||||||
rand.Seed(uint64(time.Now().UnixNano()))
|
|
||||||
|
|
||||||
// 确保满足所有必须的字符类型
|
|
||||||
var password string
|
|
||||||
if policy.NeedNumber {
|
|
||||||
password += generateNumber()
|
|
||||||
}
|
|
||||||
if policy.NeedUpper {
|
|
||||||
password += generateUpper()
|
|
||||||
}
|
|
||||||
if policy.NeedLower {
|
|
||||||
password += generateLower()
|
|
||||||
}
|
|
||||||
if policy.NeedSymbol {
|
|
||||||
password += generateSymbol()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算还需要多少个字符
|
|
||||||
remainingLength := policy.MinLength - len(password)
|
|
||||||
if remainingLength < 0 {
|
|
||||||
remainingLength = 0
|
|
||||||
}
|
|
||||||
// 剩余长度随机生成密码字符
|
|
||||||
for i := 0; i < remainingLength; i++ {
|
|
||||||
randType := rand.Intn(4) // 0:数字, 1:大写, 2:小写, 3:符号
|
|
||||||
switch randType {
|
|
||||||
case 0:
|
|
||||||
password += generateNumber()
|
|
||||||
case 1:
|
|
||||||
password += generateUpper()
|
|
||||||
case 2:
|
|
||||||
password += generateLower()
|
|
||||||
case 3:
|
|
||||||
password += generateSymbol()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果密码长度超过最大值,则截断
|
|
||||||
if len(password) > policy.MaxLength {
|
|
||||||
password = password[:policy.MaxLength]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将密码打乱
|
|
||||||
passwordRune := []rune(password)
|
|
||||||
rand.Shuffle(len(passwordRune), func(i, j int) {
|
|
||||||
passwordRune[i], passwordRune[j] = passwordRune[j], passwordRune[i]
|
|
||||||
})
|
|
||||||
|
|
||||||
return string(passwordRune)
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ UserService = (*userService)(nil)
|
|
||||||
|
|
||||||
// UserService 定义用户服务的接口
|
|
||||||
type UserService interface {
|
|
||||||
CreateUser(ctx context.Context, user *model.User) error
|
|
||||||
GetUser(ctx context.Context, id int64) (*model.User, error)
|
|
||||||
GetUserByUsername(ctx context.Context, username string) (*model.User, error)
|
|
||||||
UpdateUser(ctx context.Context, user *model.User, operatorID int64) error
|
|
||||||
DeleteUser(ctx context.Context, id int64, operatorID int64) error
|
|
||||||
ListUsers(ctx context.Context, limit, offset int, active string) ([]model.User, error)
|
|
||||||
// EnableUser(ctx context.Context, id int64, operatorID int64) error
|
|
||||||
// DisableUser(ctx context.Context, id int64, operatorID int64) error
|
|
||||||
BatchEnableUsers(ctx context.Context, ids []int64, operatorID int64) error
|
|
||||||
BatchDisableUsers(ctx context.Context, ids []int64, operatorID int64) error
|
|
||||||
BatchDeleteUsers(ctx context.Context, ids []int64, operatorID int64) error
|
|
||||||
ChangePassword(ctx context.Context, userID int64, oldPassword, newPassword string) error
|
|
||||||
ResetPassword(ctx context.Context, userID int64, operatorID int64) error
|
|
||||||
ValidatePassword(password string) error
|
|
||||||
CheckPermission(ctx context.Context, requiredRole consts.UserRole) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// userService 实现 UserService 接口
|
|
||||||
type userService struct {
|
|
||||||
userRepo dao.UserRepository
|
|
||||||
db *gorm.DB
|
|
||||||
pwdPolicy PasswordPolicy
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewUserService 创建 UserService 实例
|
|
||||||
func NewUserService(db *gorm.DB, userRepo dao.UserRepository) UserService {
|
|
||||||
return &userService{
|
|
||||||
userRepo: userRepo,
|
|
||||||
db: db,
|
|
||||||
pwdPolicy: PasswordPolicy{
|
|
||||||
MinLength: 8,
|
|
||||||
MaxLength: 32,
|
|
||||||
NeedNumber: true,
|
|
||||||
NeedUpper: true,
|
|
||||||
NeedLower: true,
|
|
||||||
NeedSymbol: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// hashPassword 使用 bcrypt 加密密码
|
|
||||||
func (s *userService) hashPassword(password string) (string, error) {
|
|
||||||
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(hashedBytes), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// comparePasswords 比较密码
|
|
||||||
func (s *userService) comparePasswords(hashedPassword, password string) bool {
|
|
||||||
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidatePassword 验证密码是否符合策略
|
|
||||||
func (s *userService) ValidatePassword(password string) error {
|
|
||||||
if len(password) < s.pwdPolicy.MinLength || len(password) > s.pwdPolicy.MaxLength {
|
|
||||||
return ErrInvalidPassword
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.pwdPolicy.NeedNumber && !regexp.MustCompile(`[0-9]`).MatchString(password) {
|
|
||||||
return ErrInvalidPassword
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.pwdPolicy.NeedUpper && !regexp.MustCompile(`[A-Z]`).MatchString(password) {
|
|
||||||
return ErrInvalidPassword
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.pwdPolicy.NeedLower && !regexp.MustCompile(`[a-z]`).MatchString(password) {
|
|
||||||
return ErrInvalidPassword
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.pwdPolicy.NeedSymbol && !regexp.MustCompile(`[!@#$%^&*]`).MatchString(password) {
|
|
||||||
return ErrInvalidPassword
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckPermission 检查用户权限
|
|
||||||
func (s *userService) CheckPermission(ctx context.Context, requiredRole consts.UserRole) error {
|
|
||||||
userToken := ctx.Value("Token").(*model.Token)
|
|
||||||
|
|
||||||
// 检查用户角色
|
|
||||||
if *userToken.User.Role < requiredRole {
|
|
||||||
return ErrPermissionDenied
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// withTransaction 事务处理封装
|
|
||||||
func (s *userService) withTransaction(ctx context.Context, fn func(tx *gorm.DB) error) error {
|
|
||||||
tx := s.db.WithContext(ctx).Begin()
|
|
||||||
if tx.Error != nil {
|
|
||||||
return ErrTransactionFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
tx.Rollback()
|
|
||||||
panic(r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if err := fn(tx); err != nil {
|
|
||||||
tx.Rollback()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit().Error; err != nil {
|
|
||||||
return ErrTransactionFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateUser 创建用户
|
|
||||||
func (s *userService) CreateUser(ctx context.Context, user *model.User) error {
|
|
||||||
if user == nil {
|
|
||||||
return ErrInvalidUserInput
|
|
||||||
}
|
|
||||||
|
|
||||||
if user.Password == "" {
|
|
||||||
user.Password = GeneratePassword(s.pwdPolicy)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用事务处理
|
|
||||||
return s.withTransaction(ctx, func(tx *gorm.DB) error {
|
|
||||||
// 检查用户名是否已存在
|
|
||||||
// _, err := s.userRepo.GetByID(user.ID)
|
|
||||||
// if err != nil {
|
|
||||||
// return err
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 加密密码
|
|
||||||
hashedPassword, err := s.hashPassword(user.Password)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
user.Password = hashedPassword
|
|
||||||
|
|
||||||
return s.userRepo.Create(user)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUser 根据 ID 获取用户
|
|
||||||
func (s *userService) GetUser(ctx context.Context, id int64) (*model.User, error) {
|
|
||||||
if id <= 0 {
|
|
||||||
return nil, ErrInvalidUserInput
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.userRepo.GetByID(id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err // 返回其他数据库错误
|
|
||||||
}
|
|
||||||
// 处理返回结果,清除敏感信息
|
|
||||||
user.Password = "" // 清除密码信息
|
|
||||||
|
|
||||||
return user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUserByUsername 根据用户名获取用户
|
|
||||||
func (s *userService) GetUserByUsername(ctx context.Context, username string) (*model.User, error) {
|
|
||||||
if username == "" {
|
|
||||||
return nil, ErrInvalidUserInput
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := s.userRepo.GetByUsername(username)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, ErrUserNotFound
|
|
||||||
}
|
|
||||||
return nil, err // 返回其他数据库错误
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理返回结果,清除敏感信息
|
|
||||||
user.Password = "" // 清除密码信息
|
|
||||||
|
|
||||||
return user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateUser 更新用户信息
|
|
||||||
func (s *userService) UpdateUser(ctx context.Context, user *model.User, operatorID int64) error {
|
|
||||||
if user == nil || user.ID <= 0 {
|
|
||||||
return ErrInvalidUserInput
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.withTransaction(ctx, func(tx *gorm.DB) error {
|
|
||||||
// 检查用户是否存在
|
|
||||||
existingUser, err := s.userRepo.GetByID(user.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果修改了用户名,检查新用户名是否已存在
|
|
||||||
if user.Username != existingUser.Username {
|
|
||||||
tmpUser, err := s.userRepo.GetByUsername(user.Username)
|
|
||||||
if err == nil && tmpUser != nil && tmpUser.ID != user.ID {
|
|
||||||
return ErrUserExists
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保持原有密码
|
|
||||||
user.Password = existingUser.Password
|
|
||||||
user.UpdatedAt = time.Now().Unix()
|
|
||||||
|
|
||||||
return s.userRepo.Update(user)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ChangePassword 修改密码
|
|
||||||
func (s *userService) ChangePassword(ctx context.Context, userID int64, oldPassword, newPassword string) error {
|
|
||||||
// 验证新密码
|
|
||||||
if err := s.ValidatePassword(newPassword); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.withTransaction(ctx, func(tx *gorm.DB) error {
|
|
||||||
user, err := s.userRepo.GetByID(userID)
|
|
||||||
if err != nil {
|
|
||||||
return ErrUserNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证旧密码
|
|
||||||
if !s.comparePasswords(user.Password, oldPassword) {
|
|
||||||
return ErrInvalidPassword
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加密新密码
|
|
||||||
hashedPassword, err := s.hashPassword(newPassword)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
user.Password = hashedPassword
|
|
||||||
user.UpdatedAt = time.Now().Unix()
|
|
||||||
|
|
||||||
return s.userRepo.Update(user)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResetPassword 重置密码
|
|
||||||
func (s *userService) ResetPassword(ctx context.Context, userID int64, operatorID int64) error {
|
|
||||||
return s.withTransaction(ctx, func(tx *gorm.DB) error {
|
|
||||||
user, err := s.userRepo.GetByID(userID)
|
|
||||||
if err != nil {
|
|
||||||
return ErrUserNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成随机密码
|
|
||||||
newPassword := generateRandomPassword()
|
|
||||||
hashedPassword, err := s.hashPassword(newPassword)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
user.Password = hashedPassword
|
|
||||||
user.UpdatedAt = time.Now().Unix()
|
|
||||||
|
|
||||||
// TODO: 发送新密码给用户邮箱
|
|
||||||
|
|
||||||
return s.userRepo.Update(user)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListUsers 获取用户列表(增加过滤功能)
|
|
||||||
func (s *userService) ListUsers(ctx context.Context, limit, offset int, active string) ([]model.User, error) {
|
|
||||||
if limit < 0 {
|
|
||||||
limit = 20
|
|
||||||
}
|
|
||||||
if offset < 0 {
|
|
||||||
offset = 0
|
|
||||||
}
|
|
||||||
var users []model.User
|
|
||||||
var err error
|
|
||||||
if active != "" {
|
|
||||||
users, _, err = s.userRepo.List(limit, offset, map[string]interface{}{"active in ?": strings.Split(active, ",")})
|
|
||||||
} else {
|
|
||||||
users, _, err = s.userRepo.List(limit, offset, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
return users, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateRandomPassword 生成随机密码
|
|
||||||
func generateRandomPassword() string {
|
|
||||||
const (
|
|
||||||
lowerChars = "abcdefghijklmnopqrstuvwxyz"
|
|
||||||
upperChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
||||||
numberChars = "0123456789"
|
|
||||||
specialChars = "!@#$%^&*"
|
|
||||||
)
|
|
||||||
// rand.NewSource(uint64(time.Now().UnixNano()))
|
|
||||||
|
|
||||||
// 确保每种字符都至少出现一次
|
|
||||||
password := []string{
|
|
||||||
string(lowerChars[rand.Intn(len(lowerChars))]),
|
|
||||||
string(upperChars[rand.Intn(len(upperChars))]),
|
|
||||||
string(numberChars[rand.Intn(len(numberChars))]),
|
|
||||||
string(specialChars[rand.Intn(len(specialChars))]),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 所有可用字符
|
|
||||||
allChars := lowerChars + upperChars + numberChars + specialChars
|
|
||||||
|
|
||||||
// 生成剩余的12个字符
|
|
||||||
for i := 0; i < 12; i++ {
|
|
||||||
password = append(password, string(allChars[rand.Intn(len(allChars))]))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 打乱密码字符顺序
|
|
||||||
rand.Shuffle(len(password), func(i, j int) {
|
|
||||||
password[i], password[j] = password[j], password[i]
|
|
||||||
})
|
|
||||||
|
|
||||||
return strings.Join(password, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteUser 删除用户
|
|
||||||
func (s *userService) DeleteUser(ctx context.Context, id int64, operatorID int64) error {
|
|
||||||
// 检查参数
|
|
||||||
if id <= 0 {
|
|
||||||
return ErrInvalidUserInput
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.CheckPermission(ctx, consts.RoleAdmin); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 不允许删除自己
|
|
||||||
if id == operatorID {
|
|
||||||
return ErrInvalidOperation
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.withTransaction(ctx, func(tx *gorm.DB) error {
|
|
||||||
// 检查用户是否存在
|
|
||||||
user, err := s.userRepo.GetByID(id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否试图删除管理员
|
|
||||||
if *user.Role == consts.RoleAdmin {
|
|
||||||
return ErrPermissionDenied
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.userRepo.Delete(id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnableUser 启用用户
|
|
||||||
// func (s *userService) EnableUser(ctx context.Context, id int64, operatorID int64) error {
|
|
||||||
// // 检查参数
|
|
||||||
// if id <= 0 {
|
|
||||||
// return ErrInvalidUserInput
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // 检查操作者权限
|
|
||||||
// if err := s.CheckPermission(ctx, consts.RoleAdmin); err != nil {
|
|
||||||
// return err
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return s.withTransaction(ctx, func(tx *gorm.DB) error {
|
|
||||||
// // 检查用户是否存在
|
|
||||||
// user, err := s.userRepo.GetByID(id)
|
|
||||||
// if err != nil {
|
|
||||||
// return ErrUserNotFound
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // 如果用户已经是启用状态,返回成功
|
|
||||||
// if user.Status == consts.StatusEnabled {
|
|
||||||
// return nil
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return s.userRepo.Enable(id)
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
|
|
||||||
// DisableUser 禁用用户
|
|
||||||
// func (s *userService) DisableUser(ctx context.Context, id int64, operatorID int64) error {
|
|
||||||
// // 检查参数
|
|
||||||
// if id <= 0 {
|
|
||||||
// return ErrInvalidUserInput
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // 检查操作者权限
|
|
||||||
// if err := s.CheckPermission(ctx, consts.RoleAdmin); err != nil {
|
|
||||||
// return err
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // 不允许禁用自己
|
|
||||||
// if id == operatorID {
|
|
||||||
// return ErrInvalidOperation
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return s.withTransaction(ctx, func(tx *gorm.DB) error {
|
|
||||||
// // 检查用户是否存在
|
|
||||||
// user, err := s.userRepo.GetByID(id)
|
|
||||||
// if err != nil {
|
|
||||||
// return ErrUserNotFound
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // 检查是否试图禁用超级管理员
|
|
||||||
// if user.Role == consts.RoleAdmin {
|
|
||||||
// return ErrPermissionDenied
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // 如果用户已经是禁用状态,返回成功
|
|
||||||
// if user.Status == consts.StatusDisabled {
|
|
||||||
// return nil
|
|
||||||
// }
|
|
||||||
|
|
||||||
// return s.userRepo.Disable(id)
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
|
|
||||||
// BatchEnableUsers 批量启用用户
|
|
||||||
func (s *userService) BatchEnableUsers(ctx context.Context, ids []int64, operatorID int64) error {
|
|
||||||
// 检查参数
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return ErrInvalidUserInput
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查操作者权限
|
|
||||||
if err := s.CheckPermission(ctx, consts.RoleAdmin); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.withTransaction(ctx, func(tx *gorm.DB) error {
|
|
||||||
// 检查所有用户是否存在,并收集当前状态
|
|
||||||
enabledUsers := make([]int64, 0)
|
|
||||||
for _, id := range ids {
|
|
||||||
user, err := s.userRepo.GetByID(id)
|
|
||||||
if err != nil {
|
|
||||||
return ErrUserNotFound
|
|
||||||
}
|
|
||||||
if *user.Active == true {
|
|
||||||
enabledUsers = append(enabledUsers, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果所有用户都已经是启用状态,返回成功
|
|
||||||
if len(enabledUsers) == len(ids) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 过滤掉已经启用的用户,只处理需要启用的用户
|
|
||||||
toEnableIds := make([]int64, 0)
|
|
||||||
for _, id := range ids {
|
|
||||||
if !contains(enabledUsers, id) {
|
|
||||||
toEnableIds = append(toEnableIds, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(toEnableIds) > 0 {
|
|
||||||
return s.userRepo.BatchEnable(toEnableIds, nil)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// BatchDisableUsers 批量禁用用户
|
|
||||||
func (s *userService) BatchDisableUsers(ctx context.Context, ids []int64, operatorID int64) error {
|
|
||||||
// 检查参数
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return ErrInvalidUserInput
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查操作者权限
|
|
||||||
if err := s.CheckPermission(ctx, consts.RoleAdmin); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 不允许包含自己
|
|
||||||
if contains(ids, operatorID) {
|
|
||||||
return ErrInvalidOperation
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.withTransaction(ctx, func(tx *gorm.DB) error {
|
|
||||||
// 检查所有用户是否存在
|
|
||||||
disabledUsers := make([]int64, 0)
|
|
||||||
for _, id := range ids {
|
|
||||||
user, err := s.userRepo.GetByID(id)
|
|
||||||
if err != nil {
|
|
||||||
return ErrUserNotFound
|
|
||||||
}
|
|
||||||
// 不允许禁用管理员
|
|
||||||
if *user.Role == consts.RoleAdmin {
|
|
||||||
return ErrPermissionDenied
|
|
||||||
}
|
|
||||||
if user.Status == consts.StatusDisabled {
|
|
||||||
disabledUsers = append(disabledUsers, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果所有用户都已经是禁用状态,返回成功
|
|
||||||
if len(disabledUsers) == len(ids) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 过滤掉已经禁用的用户,只处理需要禁用的用户
|
|
||||||
toDisableIds := make([]int64, 0)
|
|
||||||
for _, id := range ids {
|
|
||||||
if !contains(disabledUsers, id) {
|
|
||||||
toDisableIds = append(toDisableIds, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(toDisableIds) > 0 {
|
|
||||||
return s.userRepo.BatchDisable(toDisableIds, nil)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// BatchDeleteUsers 批量删除用户
|
|
||||||
func (s *userService) BatchDeleteUsers(ctx context.Context, ids []int64, operatorID int64) error {
|
|
||||||
// 检查参数
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return ErrInvalidUserInput
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查操作者权限
|
|
||||||
if err := s.CheckPermission(ctx, consts.RoleAdmin); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 不允许包含自己
|
|
||||||
if contains(ids, operatorID) {
|
|
||||||
return ErrInvalidOperation
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.withTransaction(ctx, func(tx *gorm.DB) error {
|
|
||||||
// 检查所有用户是否存在,并确保不会删除管理员
|
|
||||||
for _, id := range ids {
|
|
||||||
user, err := s.userRepo.GetByID(id)
|
|
||||||
if err != nil {
|
|
||||||
return ErrUserNotFound
|
|
||||||
}
|
|
||||||
if *user.Role == consts.RoleAdmin {
|
|
||||||
return ErrPermissionDenied
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.userRepo.BatchDelete(ids, nil)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// contains 检查切片中是否包含特定值
|
|
||||||
func contains[T comparable](slice []T, item T) bool {
|
|
||||||
for _, s := range slice {
|
|
||||||
if s == item {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
+7
-229
@@ -2,250 +2,28 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"opencatd-open/internal/consts"
|
|
||||||
"opencatd-open/internal/dao"
|
"opencatd-open/internal/dao"
|
||||||
"opencatd-open/internal/model"
|
"opencatd-open/internal/store"
|
||||||
"opencatd-open/internal/utils"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// var _ TokenService = (*TokenServiceImpl)(nil)
|
|
||||||
|
|
||||||
// type TokenService interface {
|
|
||||||
// }
|
|
||||||
|
|
||||||
type TokenServiceImpl struct {
|
type TokenServiceImpl struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
tokenRepo dao.TokenRepository
|
tokenRepo *dao.TokenDAO
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTokenService(db *gorm.DB, tokenRepo dao.TokenRepository) *TokenServiceImpl {
|
func NewTokenService(db *gorm.DB, tokenRepo *dao.TokenDAO) *TokenServiceImpl {
|
||||||
return &TokenServiceImpl{
|
return &TokenServiceImpl{
|
||||||
db: db,
|
db: db,
|
||||||
tokenRepo: tokenRepo,
|
tokenRepo: tokenRepo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TokenServiceImpl) CreateToken(ctx context.Context, token *model.Token) error {
|
func (t *TokenServiceImpl) GetByKey(ctx context.Context, key string) (*store.User, error) {
|
||||||
if token.UserID == 0 {
|
return t.tokenRepo.GetByKey(key)
|
||||||
token.UserID = ctx.Value("user_id").(int64)
|
|
||||||
}
|
|
||||||
if token.Active == nil {
|
|
||||||
token.Active = utils.ToPtr(true)
|
|
||||||
}
|
|
||||||
if token.UnlimitedQuota == nil {
|
|
||||||
token.UnlimitedQuota = utils.ToPtr(true)
|
|
||||||
}
|
|
||||||
if token.ExpiredAt == nil {
|
|
||||||
token.ExpiredAt = utils.ToPtr(int64(-1))
|
|
||||||
}
|
|
||||||
|
|
||||||
if token.Key == "" {
|
|
||||||
token.Key = "sk-team-" + strings.ReplaceAll(uuid.New().String(), "-", "")
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(token.Key, "sk-team-") {
|
|
||||||
token.Key = "sk-team-" + strings.ReplaceAll(token.Key, " ", "")
|
|
||||||
}
|
|
||||||
return t.tokenRepo.Create(ctx, token)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TokenServiceImpl) GetToken(ctx context.Context, id int64) (*model.Token, error) {
|
func (t *TokenServiceImpl) GetByID(ctx context.Context, id uint64) (*store.User, error) {
|
||||||
userid := ctx.Value("user_id").(int64)
|
return t.tokenRepo.GetByID(id)
|
||||||
tk := &model.Token{}
|
|
||||||
return tk, t.db.Model(&model.Token{}).Where("user_id = ?", userid).Where("id = ?", id).First(tk).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TokenServiceImpl) ListToken(ctx context.Context, limit, offset int, active []string) ([]*model.Token, int64, error) {
|
|
||||||
userid := ctx.Value("user_id").(int64)
|
|
||||||
condition := make(map[string]interface{})
|
|
||||||
condition["user_id = ?"] = userid
|
|
||||||
if len(active) > 0 {
|
|
||||||
condition["active IN ?"] = utils.StringToBool(active)
|
|
||||||
return t.tokenRepo.ListWithFilters(ctx, limit, offset, condition)
|
|
||||||
}
|
|
||||||
return t.tokenRepo.ListWithFilters(ctx, limit, offset, condition)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TokenServiceImpl) UpdateToken(ctx context.Context, token *model.Token) error {
|
|
||||||
userid := ctx.Value("user_id").(int64) // 操作者
|
|
||||||
userRoleValue := ctx.Value("user_role")
|
|
||||||
if userRoleValue == nil {
|
|
||||||
return fmt.Errorf("user role not found in context")
|
|
||||||
}
|
|
||||||
|
|
||||||
role, ok := userRoleValue.(*consts.UserRole) // 操作角色
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("user role in context is not an integer")
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case *role < consts.RoleAdmin:
|
|
||||||
if userid != token.UserID {
|
|
||||||
return fmt.Errorf("Permission denied")
|
|
||||||
}
|
|
||||||
case *role == consts.RoleAdmin:
|
|
||||||
if *role <= *token.User.Role {
|
|
||||||
return fmt.Errorf("Permission denied")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return t.db.Model(&model.Token{}).Where("id = ?", token.ID).Updates(token).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TokenServiceImpl) ResetToken(ctx context.Context, id int64) error {
|
|
||||||
userid := ctx.Value("user_id").(int64) // 操作者
|
|
||||||
userRoleValue := ctx.Value("user_role")
|
|
||||||
if userRoleValue == nil {
|
|
||||||
return fmt.Errorf("user role not found in context")
|
|
||||||
}
|
|
||||||
|
|
||||||
role, ok := userRoleValue.(*consts.UserRole) // 操作角色
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("user role in context is not an integer")
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case *role < consts.RoleAdmin:
|
|
||||||
if userid != id {
|
|
||||||
return fmt.Errorf("Permission denied")
|
|
||||||
}
|
|
||||||
case *role == consts.RoleAdmin:
|
|
||||||
var user = &model.User{}
|
|
||||||
if err := t.db.Model(&model.User{}).Where("id = ?", id).First(user).Error; err != nil {
|
|
||||||
return fmt.Errorf("User not found")
|
|
||||||
}
|
|
||||||
if *role <= *user.Role {
|
|
||||||
return fmt.Errorf("Permission denied")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
token := "sk-team-" + strings.ReplaceAll(uuid.New().String(), "-", "")
|
|
||||||
return t.db.Model(&model.Token{}).Where("user_id = ?", userid).Where("id = ?", id).Update("token", token).Error
|
|
||||||
}
|
|
||||||
func (t *TokenServiceImpl) DeleteToken(ctx context.Context, id int64) error {
|
|
||||||
token, err := t.tokenRepo.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Token not found")
|
|
||||||
}
|
|
||||||
if token.User == nil {
|
|
||||||
return fmt.Errorf("Token user not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
role := ctx.Value("user_role").(*consts.UserRole) // 操作角色
|
|
||||||
userid := ctx.Value("user_id").(int64) // 操作者
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case *role < consts.RoleAdmin:
|
|
||||||
if userid != token.UserID {
|
|
||||||
return fmt.Errorf("Permission denied")
|
|
||||||
}
|
|
||||||
case *role == consts.RoleAdmin:
|
|
||||||
if *role <= *token.User.Role {
|
|
||||||
return fmt.Errorf("Permission denied")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return t.db.Model(&model.Token{}).Where("id = ?", id).Delete(&model.Token{}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TokenServiceImpl) DeleteTokens(ctx context.Context, userid int64, ids []int64) error {
|
|
||||||
operator_id := ctx.Value("user_id").(int64)
|
|
||||||
|
|
||||||
roleValue := ctx.Value("user_role")
|
|
||||||
if roleValue == nil {
|
|
||||||
return fmt.Errorf("user role not found in context")
|
|
||||||
}
|
|
||||||
operator_role, ok := roleValue.(*consts.UserRole) // 操作角色
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("user role in context is not an integer")
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case *operator_role < consts.RoleAdmin:
|
|
||||||
if operator_id != userid {
|
|
||||||
return fmt.Errorf("Permission denied")
|
|
||||||
}
|
|
||||||
return t.tokenRepo.BatchDelete(ctx, ids, map[string]interface{}{"name != ?": "default", "user_id = ?": userid})
|
|
||||||
case *operator_role == consts.RoleAdmin:
|
|
||||||
var user = &model.User{}
|
|
||||||
if err := t.db.Model(&model.User{}).Where("id = ?", userid).First(user).Error; err != nil {
|
|
||||||
return fmt.Errorf("User not found")
|
|
||||||
}
|
|
||||||
if *operator_role <= *user.Role {
|
|
||||||
return fmt.Errorf("Permission denied")
|
|
||||||
}
|
|
||||||
return t.tokenRepo.BatchDelete(ctx, ids, map[string]interface{}{"name != ?": "default", "user_id = ?": userid})
|
|
||||||
default:
|
|
||||||
return t.tokenRepo.BatchDelete(ctx, ids, map[string]interface{}{"name != ?": "default"})
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TokenServiceImpl) EnableTokens(ctx context.Context, userid int64, ids []int64) error {
|
|
||||||
operator_id := ctx.Value("user_id").(int64)
|
|
||||||
|
|
||||||
roleValue := ctx.Value("user_role")
|
|
||||||
if roleValue == nil {
|
|
||||||
return fmt.Errorf("user role not found in context")
|
|
||||||
}
|
|
||||||
operator_role, ok := roleValue.(*consts.UserRole) // 操作角色
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("user role in context is not an integer")
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case *operator_role < consts.RoleAdmin:
|
|
||||||
if operator_id != userid {
|
|
||||||
return fmt.Errorf("Permission denied")
|
|
||||||
}
|
|
||||||
return t.tokenRepo.BatchEnable(ctx, ids, map[string]interface{}{"user_id = ?": userid})
|
|
||||||
case *operator_role == consts.RoleAdmin:
|
|
||||||
var user = &model.User{}
|
|
||||||
if err := t.db.Model(&model.User{}).Where("id = ?", userid).First(user).Error; err != nil {
|
|
||||||
return fmt.Errorf("User not found")
|
|
||||||
}
|
|
||||||
if *operator_role <= *user.Role {
|
|
||||||
return fmt.Errorf("Permission denied")
|
|
||||||
}
|
|
||||||
return t.tokenRepo.BatchEnable(ctx, ids, map[string]interface{}{"user_id = ?": userid})
|
|
||||||
default:
|
|
||||||
return t.tokenRepo.BatchEnable(ctx, ids, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *TokenServiceImpl) DisableTokens(ctx context.Context, userid int64, ids []int64) error {
|
|
||||||
operator_id := ctx.Value("user_id").(int64)
|
|
||||||
|
|
||||||
roleValue := ctx.Value("user_role")
|
|
||||||
if roleValue == nil {
|
|
||||||
return fmt.Errorf("user role not found in context")
|
|
||||||
}
|
|
||||||
operator_role, ok := roleValue.(*consts.UserRole) // 操作角色
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("user role in context is not an integer")
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case *operator_role < consts.RoleAdmin:
|
|
||||||
if operator_id != userid {
|
|
||||||
return fmt.Errorf("Permission denied")
|
|
||||||
}
|
|
||||||
return t.tokenRepo.BatchDisable(ctx, ids, map[string]interface{}{"user_id =": userid})
|
|
||||||
case *operator_role == consts.RoleAdmin:
|
|
||||||
var user = &model.User{}
|
|
||||||
if err := t.db.Model(&model.User{}).Where("id = ?", userid).First(user).Error; err != nil {
|
|
||||||
return fmt.Errorf("User not found")
|
|
||||||
}
|
|
||||||
if *operator_role <= *user.Role {
|
|
||||||
return fmt.Errorf("Permission denied")
|
|
||||||
}
|
|
||||||
return t.tokenRepo.BatchDisable(ctx, ids, map[string]interface{}{"user_id =": userid})
|
|
||||||
default:
|
|
||||||
return t.tokenRepo.BatchDisable(ctx, ids, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-297
@@ -2,28 +2,20 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"opencatd-open/internal/auth"
|
|
||||||
"opencatd-open/internal/consts"
|
|
||||||
"opencatd-open/internal/dao"
|
"opencatd-open/internal/dao"
|
||||||
"opencatd-open/internal/dto"
|
"opencatd-open/internal/store"
|
||||||
"opencatd-open/internal/model"
|
|
||||||
"opencatd-open/internal/utils"
|
|
||||||
"opencatd-open/pkg/config"
|
"opencatd-open/pkg/config"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserServiceImpl struct {
|
type UserServiceImpl struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
userRepo dao.UserRepository
|
userRepo *dao.UserDAO
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUserService(cfg *config.Config, db *gorm.DB, userRepo dao.UserRepository) *UserServiceImpl {
|
func NewUserService(cfg *config.Config, db *gorm.DB, userRepo *dao.UserDAO) *UserServiceImpl {
|
||||||
return &UserServiceImpl{
|
return &UserServiceImpl{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
db: db,
|
db: db,
|
||||||
@@ -31,301 +23,26 @@ func NewUserService(cfg *config.Config, db *gorm.DB, userRepo dao.UserRepository
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserServiceImpl) Register(ctx context.Context, req *model.User) error {
|
func (s *UserServiceImpl) GetByID(ctx context.Context, id uint64) (*store.User, error) {
|
||||||
var _user model.User
|
|
||||||
var count int64
|
|
||||||
err := s.db.Model(&model.User{}).Count(&count).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("username or email already exists")
|
|
||||||
}
|
|
||||||
if count == 0 {
|
|
||||||
_user.Name = "root"
|
|
||||||
_user.Role = utils.ToPtr(consts.RoleRoot)
|
|
||||||
_user.Active = utils.ToPtr(true)
|
|
||||||
_user.UnlimitedQuota = utils.ToPtr(true)
|
|
||||||
} else {
|
|
||||||
if !s.cfg.AllowRegister {
|
|
||||||
return fmt.Errorf("register is not allowed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_user.Password, err = utils.HashPassword(req.Password)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_user.Active = &s.cfg.DefaultActive
|
|
||||||
_user.UnlimitedQuota = &s.cfg.UnlimitedQuota
|
|
||||||
|
|
||||||
_user.Username = req.Username
|
|
||||||
_user.Email = req.Email
|
|
||||||
_user.Tokens = []model.Token{
|
|
||||||
{
|
|
||||||
Name: "default",
|
|
||||||
Key: "sk-team-" + strings.ReplaceAll(uuid.New().String(), "-", ""),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.userRepo.Create(&_user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UserServiceImpl) Login(ctx context.Context, req *dto.User) (*dto.Auth, error) {
|
|
||||||
var _user model.User
|
|
||||||
if err := s.db.Model(&model.User{}).Where("username = ?", req.Username).First(&_user).Error; err != nil {
|
|
||||||
if err := s.db.Model(&model.User{}).Where("email = ?", req.Username).First(&_user).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if utils.CheckPassword(_user.Password, req.Password) {
|
|
||||||
day := 86400
|
|
||||||
at, err := auth.GenerateTokenPair(&_user, consts.SecretKey, time.Duration(day)*time.Second, time.Duration(day*7)*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &dto.Auth{
|
|
||||||
Token: at.AccessToken,
|
|
||||||
ExpiresIn: time.Now().Add(time.Duration(day) * time.Second).Unix(),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("密码错误")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UserServiceImpl) Profile(ctx context.Context) (*model.User, error) {
|
|
||||||
id := ctx.Value("user_id").(int64)
|
|
||||||
return s.userRepo.GetByID(id)
|
return s.userRepo.GetByID(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserServiceImpl) List(ctx context.Context, limit, offset int, active []string) ([]model.User, int64, error) {
|
func (s *UserServiceImpl) GetByUsername(ctx context.Context, username string) (*store.User, error) {
|
||||||
userRoleValue := ctx.Value("user_role")
|
return s.userRepo.GetByUsername(username)
|
||||||
if userRoleValue == nil {
|
|
||||||
return nil, 0, fmt.Errorf("user role not found in context")
|
|
||||||
}
|
|
||||||
|
|
||||||
role, ok := userRoleValue.(*consts.UserRole)
|
|
||||||
if !ok {
|
|
||||||
return nil, 0, fmt.Errorf("user role in context is not an integer")
|
|
||||||
}
|
|
||||||
|
|
||||||
if *role < consts.RoleAdmin {
|
|
||||||
return nil, 0, fmt.Errorf("Unauthorized")
|
|
||||||
} else if *role < consts.RoleRoot { // 管理员只能查看普通用户
|
|
||||||
var condition = map[string]interface{}{"role = ?": consts.RoleUser}
|
|
||||||
if len(active) > 0 {
|
|
||||||
boolCondition := utils.StringToBool(active)
|
|
||||||
condition["active IN ?"] = boolCondition
|
|
||||||
}
|
|
||||||
return s.userRepo.List(limit, offset, condition)
|
|
||||||
} else {
|
|
||||||
var condition = make(map[string]interface{})
|
|
||||||
if len(active) > 0 {
|
|
||||||
boolCondition := utils.StringToBool(active)
|
|
||||||
condition["active IN ?"] = boolCondition
|
|
||||||
}
|
|
||||||
return s.userRepo.List(limit, offset, condition)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserServiceImpl) Create(ctx context.Context, req *model.User) error {
|
func (s *UserServiceImpl) List(ctx context.Context, limit, offset int) ([]*store.User, int64, error) {
|
||||||
userRoleValue := ctx.Value("user_role")
|
return s.userRepo.List(limit, offset)
|
||||||
if userRoleValue == nil {
|
|
||||||
return fmt.Errorf("user role not found in context")
|
|
||||||
}
|
|
||||||
|
|
||||||
role, ok := userRoleValue.(*consts.UserRole)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("user role in context is not an integer")
|
|
||||||
}
|
|
||||||
var _user model.User
|
|
||||||
|
|
||||||
if *role < consts.RoleAdmin {
|
|
||||||
return fmt.Errorf("Forbidden")
|
|
||||||
} else if *role < consts.RoleRoot {
|
|
||||||
_user.Role = utils.ToPtr(consts.RoleRoot)
|
|
||||||
} else {
|
|
||||||
_user.Role = req.Role
|
|
||||||
}
|
|
||||||
_user.Username = req.Username
|
|
||||||
_user.Name = req.Name
|
|
||||||
_user.Email = req.Email
|
|
||||||
_user.Active = req.Active
|
|
||||||
_user.Quota = req.Quota
|
|
||||||
_user.UnlimitedQuota = req.UnlimitedQuota
|
|
||||||
_user.Language = req.Language
|
|
||||||
if hashpass, err := utils.HashPassword(req.Password); err != nil {
|
|
||||||
return err
|
|
||||||
} else {
|
|
||||||
_user.Password = hashpass
|
|
||||||
}
|
|
||||||
_user.Tokens = []model.Token{
|
|
||||||
{
|
|
||||||
Name: "default",
|
|
||||||
Key: "sk-team-" + strings.ReplaceAll(uuid.New().String(), "-", ""),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.userRepo.Create(&_user)
|
|
||||||
}
|
|
||||||
func (s *UserServiceImpl) GetByID(ctx context.Context, id int64) (*model.User, error) {
|
|
||||||
return s.userRepo.GetByID(id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserServiceImpl) Update(ctx context.Context, user *model.User) error {
|
func (s *UserServiceImpl) Create(ctx context.Context, user *store.User) error {
|
||||||
_user := ctx.Value("user").(*model.User) // 被更新的用户
|
return s.userRepo.Create(user)
|
||||||
if _user == nil {
|
|
||||||
return fmt.Errorf("user not found in context")
|
|
||||||
}
|
|
||||||
userid := ctx.Value("user_id").(int64) // 操作者
|
|
||||||
userRoleValue := ctx.Value("user_role")
|
|
||||||
if userRoleValue == nil {
|
|
||||||
return fmt.Errorf("user role not found in context")
|
|
||||||
}
|
|
||||||
|
|
||||||
role, ok := userRoleValue.(*consts.UserRole) // 操作者角色
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("user role in context is not an integer")
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case *role < consts.RoleAdmin:
|
|
||||||
if user.ID != userid {
|
|
||||||
return fmt.Errorf("Permission denied")
|
|
||||||
}
|
|
||||||
case *role == consts.RoleAdmin:
|
|
||||||
if *user.Role > *role { // 更新的用户角色不能高于操作者角色
|
|
||||||
return fmt.Errorf("Permission denied")
|
|
||||||
}
|
|
||||||
if *_user.Role >= *role { // 管理员之间不能被修改
|
|
||||||
return fmt.Errorf("Permission denied")
|
|
||||||
}
|
|
||||||
case *role > consts.RoleAdmin: // 根不能被修改
|
|
||||||
if user.ID == userid {
|
|
||||||
user.Role = role // root不能修改自己的角色
|
|
||||||
} else {
|
|
||||||
if user.Role != nil && user.Role == utils.ToPtr(consts.RoleRoot) {
|
|
||||||
return fmt.Errorf("Root user Only one can exist")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if user.Name != "" {
|
|
||||||
_user.Name = user.Name
|
|
||||||
}
|
|
||||||
if user.Username != "" {
|
|
||||||
_user.Username = user.Username
|
|
||||||
}
|
|
||||||
if user.Email != "" {
|
|
||||||
_user.Email = user.Email
|
|
||||||
_user.EmailVerified = utils.ToPtr(false)
|
|
||||||
}
|
|
||||||
if user.Active != nil {
|
|
||||||
_user.Active = user.Active
|
|
||||||
}
|
|
||||||
if user.Role != nil {
|
|
||||||
_user.Role = user.Role
|
|
||||||
}
|
|
||||||
if user.Active != nil {
|
|
||||||
_user.Active = user.Active
|
|
||||||
}
|
|
||||||
if user.Quota != nil {
|
|
||||||
_user.Quota = user.Quota
|
|
||||||
}
|
|
||||||
if user.UsedQuota != nil {
|
|
||||||
_user.UsedQuota = user.UsedQuota
|
|
||||||
}
|
|
||||||
if user.UnlimitedQuota != nil {
|
|
||||||
_user.UnlimitedQuota = user.UnlimitedQuota
|
|
||||||
}
|
|
||||||
if user.Timezone != "" {
|
|
||||||
_user.Timezone = user.Timezone
|
|
||||||
}
|
|
||||||
if user.Language != "" {
|
|
||||||
_user.Language = user.Language
|
|
||||||
}
|
|
||||||
return s.userRepo.Update(_user)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserServiceImpl) Delete(ctx context.Context, id int64) error {
|
func (s *UserServiceImpl) Update(ctx context.Context, user *store.User) error {
|
||||||
_user, err := s.userRepo.GetByID(id) // 被更新的用户
|
return s.userRepo.Update(user)
|
||||||
if err != nil {
|
}
|
||||||
return err
|
|
||||||
}
|
|
||||||
userid := ctx.Value("user_id").(int64)
|
|
||||||
userRoleValue := ctx.Value("user_role")
|
|
||||||
if userRoleValue == nil {
|
|
||||||
return fmt.Errorf("user role not found in context")
|
|
||||||
}
|
|
||||||
role, ok := userRoleValue.(*consts.UserRole) // 操作者
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("user role in context is not an integer")
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case *role < consts.RoleAdmin:
|
|
||||||
if _user.ID != userid {
|
|
||||||
return fmt.Errorf("Permission denied")
|
|
||||||
}
|
|
||||||
case *role == consts.RoleAdmin:
|
|
||||||
if *_user.Role >= *role { // 管理员之间不能被修改
|
|
||||||
return fmt.Errorf("Permission denied")
|
|
||||||
}
|
|
||||||
case *_user.Role == consts.RoleRoot: // 根不能被修改
|
|
||||||
return fmt.Errorf("Root user can not be modified")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
func (s *UserServiceImpl) Delete(ctx context.Context, id uint64) error {
|
||||||
return s.userRepo.Delete(id)
|
return s.userRepo.Delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserServiceImpl) BatchDelete(ctx context.Context, ids []int64) error {
|
|
||||||
userRoleValue := ctx.Value("user_role")
|
|
||||||
if userRoleValue == nil {
|
|
||||||
return fmt.Errorf("user role not found in context")
|
|
||||||
}
|
|
||||||
role, ok := userRoleValue.(*consts.UserRole)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("user role in context is not an integer")
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case *role < consts.RoleAdmin:
|
|
||||||
return fmt.Errorf("Unauthorized")
|
|
||||||
case *role == consts.RoleAdmin:
|
|
||||||
return s.userRepo.BatchDelete(ids, []string{fmt.Sprintf("role < %d", role)})
|
|
||||||
}
|
|
||||||
return s.userRepo.BatchDelete(ids, []string{fmt.Sprintf("role < %d", consts.RoleRoot)})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UserServiceImpl) BatchEnable(ctx context.Context, ids []int64) error {
|
|
||||||
userRoleValue := ctx.Value("user_role")
|
|
||||||
if userRoleValue == nil {
|
|
||||||
return fmt.Errorf("user role not found in context")
|
|
||||||
}
|
|
||||||
role, ok := userRoleValue.(*consts.UserRole)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("user role in context is not an integer")
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case *role < consts.RoleAdmin:
|
|
||||||
return fmt.Errorf("Unauthorized")
|
|
||||||
case *role == consts.RoleAdmin:
|
|
||||||
return s.userRepo.BatchEnable(ids, []string{fmt.Sprintf("role < %d", role)})
|
|
||||||
}
|
|
||||||
return s.userRepo.BatchEnable(ids, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UserServiceImpl) BatchDisable(ctx context.Context, ids []int64) error {
|
|
||||||
userRoleValue := ctx.Value("user_role")
|
|
||||||
if userRoleValue == nil {
|
|
||||||
return fmt.Errorf("user role not found in context")
|
|
||||||
}
|
|
||||||
role, ok := userRoleValue.(*consts.UserRole)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("user role in context is not an integer")
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case *role < consts.RoleAdmin:
|
|
||||||
return fmt.Errorf("Unauthorized")
|
|
||||||
case *role == consts.RoleAdmin:
|
|
||||||
return s.userRepo.BatchDisable(ids, []string{fmt.Sprintf("role < %d", role)})
|
|
||||||
}
|
|
||||||
return s.userRepo.BatchDisable(ids, nil)
|
|
||||||
}
|
|
||||||
|
|||||||
+32
-146
@@ -4,85 +4,63 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"opencatd-open/internal/model"
|
"opencatd-open/internal/store"
|
||||||
"opencatd-open/pkg/config"
|
"opencatd-open/pkg/config"
|
||||||
"opencatd-open/pkg/store"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-webauthn/webauthn/protocol"
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
"github.com/go-webauthn/webauthn/webauthn"
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
"github.com/mileusna/useragent"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ webauthn.User = (*WebAuthnUser)(nil)
|
|
||||||
|
|
||||||
// WebAuthnUser 实现webauthn.User接口的结构体
|
|
||||||
type WebAuthnUser struct {
|
type WebAuthnUser struct {
|
||||||
User *model.User
|
User *store.User
|
||||||
// ID int64
|
|
||||||
// Name string
|
|
||||||
// DisplayName string
|
|
||||||
Credentials []webauthn.Credential
|
Credentials []webauthn.Credential
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebAuthnID 返回用户ID
|
|
||||||
func (u *WebAuthnUser) WebAuthnID() []byte {
|
func (u *WebAuthnUser) WebAuthnID() []byte {
|
||||||
return []byte(strconv.Itoa(int(u.User.ID)))
|
return []byte(strconv.FormatUint(u.User.ID, 10))
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebAuthnName 返回用户名
|
|
||||||
func (u *WebAuthnUser) WebAuthnName() string {
|
func (u *WebAuthnUser) WebAuthnName() string {
|
||||||
return u.User.Username
|
return u.User.Username
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebAuthnDisplayName 返回用户显示名
|
|
||||||
func (u *WebAuthnUser) WebAuthnDisplayName() string {
|
func (u *WebAuthnUser) WebAuthnDisplayName() string {
|
||||||
return u.User.Name
|
return u.User.Username
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebAuthnCredentials 返回用户所有凭证
|
|
||||||
func (u *WebAuthnUser) WebAuthnCredentials() []webauthn.Credential {
|
func (u *WebAuthnUser) WebAuthnCredentials() []webauthn.Credential {
|
||||||
return u.Credentials
|
return u.Credentials
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *WebAuthnUser) WebAuthnCredentialDescriptors() (descriptors []protocol.CredentialDescriptor) {
|
func (u *WebAuthnUser) WebAuthnCredentialDescriptors() (descriptors []protocol.CredentialDescriptor) {
|
||||||
credentials := u.WebAuthnCredentials()
|
credentials := u.WebAuthnCredentials()
|
||||||
|
|
||||||
descriptors = make([]protocol.CredentialDescriptor, len(credentials))
|
descriptors = make([]protocol.CredentialDescriptor, len(credentials))
|
||||||
|
|
||||||
for i, credential := range credentials {
|
for i, credential := range credentials {
|
||||||
descriptors[i] = credential.Descriptor()
|
descriptors[i] = credential.Descriptor()
|
||||||
}
|
}
|
||||||
|
|
||||||
return descriptors
|
return descriptors
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebAuthnService 提供WebAuthn相关功能
|
|
||||||
type WebAuthnService struct {
|
type WebAuthnService struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
DB *gorm.DB
|
DB *gorm.DB
|
||||||
WebAuthn *webauthn.WebAuthn
|
WebAuthn *webauthn.WebAuthn
|
||||||
// Sessions map[string]webauthn.SessionData // 用于存储注册和认证过程中的会话数据
|
|
||||||
Sessions *store.WebAuthnSessionStore
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWebAuthnService 创建新的WebAuthn服务
|
|
||||||
func NewWebAuthnService(cfg *config.Config, db *gorm.DB) (*WebAuthnService, error) {
|
func NewWebAuthnService(cfg *config.Config, db *gorm.DB) (*WebAuthnService, error) {
|
||||||
// 创建WebAuthn配置
|
|
||||||
wconfig := &webauthn.Config{
|
wconfig := &webauthn.Config{
|
||||||
RPDisplayName: cfg.AppName, // 依赖方(Relying Party)显示名称
|
RPDisplayName: cfg.AppName,
|
||||||
RPID: cfg.RPID, // 依赖方ID(通常为域名)
|
RPID: cfg.RPID,
|
||||||
RPOrigins: cfg.RPOrigins, // 依赖方源(URL)
|
RPOrigins: cfg.RPOrigins,
|
||||||
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
AuthenticatorSelection: protocol.AuthenticatorSelection{
|
||||||
RequireResidentKey: protocol.ResidentKeyRequired(), // 要求认证器存储用户 ID (resident key)
|
RequireResidentKey: protocol.ResidentKeyRequired(),
|
||||||
ResidentKey: protocol.ResidentKeyRequirementRequired, // 使用 Discoverable 模式
|
ResidentKey: protocol.ResidentKeyRequirementRequired,
|
||||||
UserVerification: protocol.VerificationPreferred, // 推荐用户验证
|
UserVerification: protocol.VerificationPreferred,
|
||||||
AuthenticatorAttachment: "", // 允许任何认证器 (平台或跨平台)
|
|
||||||
},
|
},
|
||||||
// EncodeUserIDAsString: true, // 将用户ID编码为字符串
|
|
||||||
}
|
}
|
||||||
|
|
||||||
wa, err := webauthn.New(wconfig)
|
wa, err := webauthn.New(wconfig)
|
||||||
@@ -94,22 +72,20 @@ func NewWebAuthnService(cfg *config.Config, db *gorm.DB) (*WebAuthnService, erro
|
|||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
DB: db,
|
DB: db,
|
||||||
WebAuthn: wa,
|
WebAuthn: wa,
|
||||||
// Sessions: make(map[string]webauthn.SessionData),
|
|
||||||
Sessions: store.NewWebAuthnSessionStore(),
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserWithCredentials 获取用户及其凭证
|
func (s *WebAuthnService) GetUserWithCredentials(userID uint64) (*WebAuthnUser, error) {
|
||||||
func (s *WebAuthnService) GetUserWithCredentials(userID int64) (*WebAuthnUser, error) {
|
var user store.User
|
||||||
var user model.User
|
if err := s.DB.First(&user, userID).Error; err != nil {
|
||||||
if err := s.DB.Model(&model.User{}).Preload("Passkeys").First(&user, userID).Error; err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取用户的所有Passkey
|
var passkeys []store.Passkey
|
||||||
passkeys := user.Passkeys
|
if err := s.DB.Where("user_id = ?", userID).Find(&passkeys).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// 将Passkey转换为webauthn.Credential
|
|
||||||
credentials := make([]webauthn.Credential, len(passkeys))
|
credentials := make([]webauthn.Credential, len(passkeys))
|
||||||
for i, pk := range passkeys {
|
for i, pk := range passkeys {
|
||||||
credentialIDBytes, err := base64.StdEncoding.DecodeString(pk.CredentialID)
|
credentialIDBytes, err := base64.StdEncoding.DecodeString(pk.CredentialID)
|
||||||
@@ -143,86 +119,62 @@ func (s *WebAuthnService) GetUserWithCredentials(userID int64) (*WebAuthnUser, e
|
|||||||
},
|
},
|
||||||
Authenticator: webauthn.Authenticator{
|
Authenticator: webauthn.Authenticator{
|
||||||
AAGUID: aaguidBytes,
|
AAGUID: aaguidBytes,
|
||||||
SignCount: pk.SignCount,
|
SignCount: uint32(pk.SignCount),
|
||||||
CloneWarning: false,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建WebAuthnUser
|
|
||||||
return &WebAuthnUser{
|
return &WebAuthnUser{
|
||||||
User: &user,
|
User: &user,
|
||||||
Credentials: credentials,
|
Credentials: credentials,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BeginRegistration 开始注册过程
|
func (s *WebAuthnService) BeginRegistration(userID uint64) (*protocol.CredentialCreation, error) {
|
||||||
func (s *WebAuthnService) BeginRegistration(userID int64) (*protocol.CredentialCreation, error) {
|
|
||||||
user, err := s.GetUserWithCredentials(userID)
|
user, err := s.GetUserWithCredentials(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取注册选项
|
options, _, err := s.WebAuthn.BeginRegistration(user)
|
||||||
options, sessionData, err := s.WebAuthn.BeginRegistration(user)
|
|
||||||
// webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
|
|
||||||
// webauthn.WithExclusions(user.WebAuthnCredentialDescriptors()), // 排除已存在的凭证
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存会话数据
|
|
||||||
userid := strconv.Itoa(int(userID))
|
|
||||||
s.Sessions.SaveWebauthnSession(userid, sessionData)
|
|
||||||
|
|
||||||
return options, nil
|
return options, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FinishRegistration 完成注册过程
|
func (s *WebAuthnService) FinishRegistration(userID uint64, response *http.Request, deviceName string) (*store.Passkey, error) {
|
||||||
func (s *WebAuthnService) FinishRegistration(userID int64, response *http.Request, deviceName string) (*model.Passkey, error) {
|
|
||||||
user, err := s.GetUserWithCredentials(userID)
|
user, err := s.GetUserWithCredentials(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
userid := strconv.Itoa(int(userID))
|
credential, err := s.WebAuthn.FinishRegistration(user, webauthn.SessionData{}, response)
|
||||||
// 获取并清除会话数据
|
|
||||||
sessionData, err := s.Sessions.GetWebauthnSession(userid)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
s.Sessions.DeleteWebauthnSession(userid)
|
|
||||||
|
|
||||||
// 完成注册
|
|
||||||
credential, err := s.WebAuthn.FinishRegistration(user, *sessionData, response)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ua := useragent.Parse(response.UserAgent())
|
|
||||||
|
|
||||||
var transport string
|
var transport string
|
||||||
if len(credential.Transport) > 0 {
|
if len(credential.Transport) > 0 {
|
||||||
transport = string(credential.Transport[0]) // 通常只取第一个传输方式
|
transport = string(credential.Transport[0])
|
||||||
}
|
}
|
||||||
// 创建Passkey记录
|
|
||||||
passkey := &model.Passkey{
|
passkey := &store.Passkey{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
CredentialID: base64.StdEncoding.EncodeToString(credential.ID),
|
CredentialID: base64.StdEncoding.EncodeToString(credential.ID),
|
||||||
PublicKey: base64.StdEncoding.EncodeToString(credential.PublicKey),
|
PublicKey: base64.StdEncoding.EncodeToString(credential.PublicKey),
|
||||||
AttestationType: string(credential.AttestationType),
|
AttestationType: string(credential.AttestationType),
|
||||||
AAGUID: base64.StdEncoding.EncodeToString(credential.Authenticator.AAGUID),
|
AAGUID: base64.StdEncoding.EncodeToString(credential.Authenticator.AAGUID),
|
||||||
SignCount: credential.Authenticator.SignCount,
|
SignCount: uint64(credential.Authenticator.SignCount),
|
||||||
Name: deviceName,
|
Name: deviceName,
|
||||||
DeviceType: strings.TrimSpace(fmt.Sprintf("%s %s %s %s %s", ua.Device, ua.OS, ua.OSVersionNoFull(), ua.Name, ua.VersionNoFull())),
|
DeviceType: strings.TrimSpace(fmt.Sprintf("%s", deviceName)),
|
||||||
LastUsedAt: time.Now().Unix(),
|
LastUsedAt: time.Now().Unix(),
|
||||||
BackupEligible: credential.Flags.BackupEligible,
|
BackupEligible: credential.Flags.BackupEligible,
|
||||||
BackupState: credential.Flags.BackupState,
|
BackupState: credential.Flags.BackupState,
|
||||||
Transport: transport,
|
Transport: transport,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存Passkey
|
|
||||||
if err := s.DB.Create(passkey).Error; err != nil {
|
if err := s.DB.Create(passkey).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -230,88 +182,22 @@ func (s *WebAuthnService) FinishRegistration(userID int64, response *http.Reques
|
|||||||
return passkey, nil
|
return passkey, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BeginLogin 开始登录过程 (无需用户ID,针对未认证用户)
|
|
||||||
func (s *WebAuthnService) BeginLogin() (*protocol.CredentialAssertion, error) {
|
func (s *WebAuthnService) BeginLogin() (*protocol.CredentialAssertion, error) {
|
||||||
// 不指定用户ID,让客户端决定使用哪个凭证
|
options, _, err := s.WebAuthn.BeginDiscoverableLogin()
|
||||||
options, session, err := s.WebAuthn.BeginDiscoverableLogin(
|
|
||||||
webauthn.WithUserVerification(protocol.VerificationPreferred), // 推荐用户验证
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.Sessions.SaveWebauthnSession(session.Challenge, session)
|
|
||||||
|
|
||||||
return options, nil
|
return options, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FinishLogin 完成登录过程
|
func (s *WebAuthnService) ListPasskeys(userID uint64) ([]store.Passkey, error) {
|
||||||
func (s *WebAuthnService) FinishLogin(challenge string, response *http.Request) (*WebAuthnUser, error) {
|
var passkeys []store.Passkey
|
||||||
// 获取并清除会话数据
|
|
||||||
sessionData, err := s.Sessions.GetWebauthnSession(challenge)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s.Sessions.DeleteWebauthnSession(challenge)
|
|
||||||
|
|
||||||
// 获取相应的用户
|
|
||||||
// var user model.User
|
|
||||||
// if err := s.DB.First(&user, passkey.UserID).Error; err != nil {
|
|
||||||
// return nil, err
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 创建WebAuthnUser
|
|
||||||
// webAuthnUser, err := s.GetUserWithCredentials(user.ID)
|
|
||||||
// if err != nil {
|
|
||||||
// return nil, err
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 完成登录
|
|
||||||
// _, err = s.WebAuthn.FinishLogin(webAuthnUser, sessionData, response)
|
|
||||||
// if err != nil {
|
|
||||||
// return nil, err
|
|
||||||
// }
|
|
||||||
var user *WebAuthnUser
|
|
||||||
wc, err := s.WebAuthn.FinishDiscoverableLogin(s.GetWebAuthnUser(&user), *sessionData, response)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// 更新Passkey 这里SignCount应该是由验证器上传,但可能为0,手动+1
|
|
||||||
var pk model.Passkey
|
|
||||||
if err := s.DB.Model(&model.Passkey{}).Where("credential_id = ?", base64.StdEncoding.EncodeToString(wc.ID)).First(&pk).Error; err == nil {
|
|
||||||
if err := s.DB.Model(&model.Passkey{}).Where("id = ?", pk.ID, time.Now().Unix()).Updates(map[string]interface{}{
|
|
||||||
"sign_count": pk.SignCount + 1,
|
|
||||||
"last_used_at": time.Now().Unix(),
|
|
||||||
}).Error; err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
return user, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WebAuthnService) GetWebAuthnUser(wau **WebAuthnUser) webauthn.DiscoverableUserHandler {
|
|
||||||
return func(rawID, userHandle []byte) (webauthn.User, error) {
|
|
||||||
userid, err := strconv.ParseInt(string(userHandle), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
*wau, err = s.GetUserWithCredentials(userid)
|
|
||||||
return *wau, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListPasskeys 列出用户所有Passkey
|
|
||||||
func (s *WebAuthnService) ListPasskeys(userID int64) ([]model.Passkey, error) {
|
|
||||||
var passkeys []model.Passkey
|
|
||||||
if err := s.DB.Where("user_id = ?", userID).Find(&passkeys).Error; err != nil {
|
if err := s.DB.Where("user_id = ?", userID).Find(&passkeys).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return passkeys, nil
|
return passkeys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeletePasskey 删除用户Passkey
|
func (s *WebAuthnService) DeletePasskey(userID uint64, passkeyID uint64) error {
|
||||||
func (s *WebAuthnService) DeletePasskey(userID int64, passkeyID int64) error {
|
return s.DB.Where("id = ? AND user_id = ?", passkeyID, userID).Delete(&store.Passkey{}).Error
|
||||||
return s.DB.Where("id = ? AND user_id = ?", passkeyID, userID).Delete(&model.Passkey{}).Error
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"opencatd-open/pkg/config"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var DB *gorm.DB
|
||||||
|
|
||||||
|
func InitDB(cfg *config.Config) (*gorm.DB, error) {
|
||||||
|
var dialector gorm.Dialector
|
||||||
|
|
||||||
|
switch cfg.DB_Type {
|
||||||
|
case "sqlite":
|
||||||
|
dialector = sqliteDialector(cfg.DSN)
|
||||||
|
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 {
|
||||||
|
if dsn == "" {
|
||||||
|
dsn = "opencatd.db"
|
||||||
|
}
|
||||||
|
return sqlite.Open(dsn)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,233 @@
|
|||||||
|
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:"-"`
|
||||||
|
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 int `gorm:"not null;default:1" json:"weight"`
|
||||||
|
Priority int `gorm:"not null;default:0" json:"priority"`
|
||||||
|
TimeoutMS int `gorm:"not null;default:120000" json:"timeout_ms"`
|
||||||
|
MaxConcurrency int `gorm:"not null;default:16" json:"max_concurrency"`
|
||||||
|
HealthStatus string `gorm:"size:16;not null;default:healthy" json:"health_status"`
|
||||||
|
Enabled bool `gorm:"not null;default:true" json:"enabled"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
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 string `gorm:"size:255;not null" json:"-"`
|
||||||
|
PublicKey string `gorm:"size:512;not null" json:"-"`
|
||||||
|
AttestationType string `gorm:"size:64" json:"-"`
|
||||||
|
AAGUID string `gorm:"size:64" json:"-"`
|
||||||
|
SignCount uint64 `json:"-"`
|
||||||
|
DeviceType string `gorm:"size:255" json:"device_type,omitempty"`
|
||||||
|
LastUsedAt int64 `json:"last_used_at,omitempty"`
|
||||||
|
BackupEligible bool `json:"-"`
|
||||||
|
BackupState bool `json:"-"`
|
||||||
|
Transport string `gorm:"size:32" 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,140 @@
|
|||||||
|
package usage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"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
|
||||||
|
Cost float64
|
||||||
|
IsError bool
|
||||||
|
IsCanceled bool
|
||||||
|
RequestID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
log := &store.UsageLog{
|
||||||
|
UserID: e.UserID,
|
||||||
|
ModelName: e.ModelName,
|
||||||
|
ChannelID: e.ChannelID,
|
||||||
|
InputTokens: int64(e.PromptTokens),
|
||||||
|
OutputTokens: int64(e.CompletionTokens),
|
||||||
|
CacheReadTokens: int64(e.CacheReadTokens),
|
||||||
|
Cost: e.Cost,
|
||||||
|
Status: status,
|
||||||
|
RequestID: e.RequestID,
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Flushed %d usage logs", len(logs))
|
||||||
|
}
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
package utils
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"opencatd-open/internal/model"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/tidwall/gjson"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
var client = &http.Client{Timeout: 2 * time.Second}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
if os.Getenv("LOCAL_PROXY") != "" {
|
|
||||||
if proxyUrl, err := url.Parse(os.Getenv("LOCAL_PROXY")); err == nil {
|
|
||||||
client.Transport = &http.Transport{Proxy: http.ProxyURL(proxyUrl)}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func FetchKeyModel(db *gorm.DB, key *model.ApiKey) ([]string, error) {
|
|
||||||
|
|
||||||
var supportModels []string
|
|
||||||
var err error
|
|
||||||
if *key.ApiType == "openai" || *key.ApiType == "azure" {
|
|
||||||
supportModels, err = FetchOpenAISupportModels(db, key)
|
|
||||||
}
|
|
||||||
if *key.ApiType == "claude" {
|
|
||||||
supportModels, err = FetchClaudeSupportModels(db, key)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
}
|
|
||||||
return supportModels, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func FetchOpenAISupportModels(db *gorm.DB, apikey *model.ApiKey) ([]string, error) {
|
|
||||||
openaiModelsUrl := "https://api.openai.com/v1/models"
|
|
||||||
// https://learn.microsoft.com/zh-cn/rest/api/azureopenai/models/list?view=rest-azureopenai-2025-02-01-preview&tabs=HTTP
|
|
||||||
azureModelsUrl := "/openai/deployments?api-version=2022-12-01"
|
|
||||||
|
|
||||||
var supportModels []string
|
|
||||||
var req *http.Request
|
|
||||||
if *apikey.ApiType == "azure" {
|
|
||||||
if strings.HasSuffix(*apikey.Endpoint, "/") {
|
|
||||||
apikey.Endpoint = ToPtr(strings.TrimSuffix(*apikey.Endpoint, "/"))
|
|
||||||
}
|
|
||||||
req, _ = http.NewRequest("GET", *apikey.Endpoint+azureModelsUrl, nil)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("api-key", *apikey.ApiKey)
|
|
||||||
} else {
|
|
||||||
req, _ = http.NewRequest("GET", openaiModelsUrl, nil)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer "+*apikey.ApiKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode == http.StatusOK {
|
|
||||||
bytesbody, _ := io.ReadAll(resp.Body)
|
|
||||||
result := gjson.GetBytes(bytesbody, "data.#.id").Array()
|
|
||||||
for _, v := range result {
|
|
||||||
model := v.Str
|
|
||||||
model = strings.Replace(model, "-35-", "-3.5-", -1)
|
|
||||||
model = strings.Replace(model, "-41-", "-4.1-", -1)
|
|
||||||
supportModels = append(supportModels, model)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return supportModels, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func FetchClaudeSupportModels(db *gorm.DB, apikey *model.ApiKey) ([]string, error) {
|
|
||||||
// https://docs.anthropic.com/en/api/models-list
|
|
||||||
claudemodelsUrl := "https://api.anthropic.com/v1/models"
|
|
||||||
var supportModels []string
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", claudemodelsUrl, nil)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("x-api-key", *apikey.ApiKey)
|
|
||||||
req.Header.Set("anthropic-version", "2023-06-01")
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode == http.StatusOK {
|
|
||||||
bytesbody, _ := io.ReadAll(resp.Body)
|
|
||||||
result := gjson.GetBytes(bytesbody, "data.#.id").Array()
|
|
||||||
for _, v := range result {
|
|
||||||
supportModels = append(supportModels, v.Str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return supportModels, nil
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
// /*
|
|
||||||
// # AWS
|
|
||||||
// https://docs.aws.amazon.com/bedrock/latest/userguide/what-is-service.html
|
|
||||||
// https://aws.amazon.com/cn/bedrock/pricing/
|
|
||||||
// Anthropic models Price for 1000 input tokens Price for 1000 output tokens
|
|
||||||
// Claude Instant $0.00163 $0.00551
|
|
||||||
|
|
||||||
// Claude $0.01102 $0.03268
|
|
||||||
|
|
||||||
// https://docs.aws.amazon.com/bedrock/latest/userguide/endpointsTable.html
|
|
||||||
// 地区名称 地区 端点 协议
|
|
||||||
// 美国东部(弗吉尼亚北部) 美国东部1 bedrock-runtime.us-east-1.amazonaws.com HTTPS
|
|
||||||
// bedrock-runtime-fips.us-east-1.amazonaws.com HTTPS
|
|
||||||
// 美国西部(俄勒冈州) 美国西2号 bedrock-runtime.us-west-2.amazonaws.com HTTPS
|
|
||||||
// bedrock-runtime-fips.us-west-2.amazonaws.com HTTPS
|
|
||||||
// 亚太地区(新加坡) ap-东南-1 bedrock-runtime.ap-southeast-1.amazonaws.com HTTPS
|
|
||||||
// */
|
|
||||||
// //
|
|
||||||
|
|
||||||
package aws
|
|
||||||
|
|
||||||
// import (
|
|
||||||
// "context"
|
|
||||||
// "log"
|
|
||||||
|
|
||||||
// "github.com/aws/aws-sdk-go-v2/config"
|
|
||||||
// )
|
|
||||||
|
|
||||||
// // ...
|
|
||||||
|
|
||||||
// func CallClaude() {
|
|
||||||
// cfg, err := config.LoadDefaultConfig(context.TODO())
|
|
||||||
// if err != nil {
|
|
||||||
// log.Fatalf("failed to load configuration, %v", err)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
/*
|
|
||||||
https://learn.microsoft.com/zh-cn/azure/cognitive-services/openai/chatgpt-quickstart
|
|
||||||
https://learn.microsoft.com/zh-cn/azure/ai-services/openai/reference#chat-completions
|
|
||||||
|
|
||||||
curl $AZURE_OPENAI_ENDPOINT/openai/deployments/gpt-35-turbo/chat/completions?api-version=2023-03-15-preview \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "api-key: $AZURE_OPENAI_KEY" \
|
|
||||||
-d '{
|
|
||||||
"model": "gpt-3.5-turbo",
|
|
||||||
"messages": [{"role": "user", "content": "你好"}]
|
|
||||||
}'
|
|
||||||
|
|
||||||
https://learn.microsoft.com/zh-cn/rest/api/cognitiveservices/azureopenaistable/models/list?tabs=HTTP
|
|
||||||
|
|
||||||
curl $AZURE_OPENAI_ENDPOINT/openai/deployments?api-version=2022-12-01 \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "api-key: $AZURE_OPENAI_KEY" \
|
|
||||||
|
|
||||||
> GPT-4 Turbo
|
|
||||||
https://techcommunity.microsoft.com/t5/ai-azure-ai-services-blog/azure-openai-service-launches-gpt-4-turbo-and-gpt-3-5-turbo-1106/ba-p/3985962
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|
||||||
package azureopenai
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ENDPOINT string
|
|
||||||
API_KEY string
|
|
||||||
DEPLOYMENT_NAME string
|
|
||||||
)
|
|
||||||
|
|
||||||
type ModelsList struct {
|
|
||||||
Data []struct {
|
|
||||||
ScaleSettings struct {
|
|
||||||
ScaleType string `json:"scale_type"`
|
|
||||||
} `json:"scale_settings"`
|
|
||||||
Model string `json:"model"`
|
|
||||||
Owner string `json:"owner"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
CreatedAt int `json:"created_at"`
|
|
||||||
UpdatedAt int `json:"updated_at"`
|
|
||||||
Object string `json:"object"`
|
|
||||||
} `json:"data"`
|
|
||||||
Object string `json:"object"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func Models(endpoint, apikey string) (*ModelsList, error) {
|
|
||||||
endpoint = RemoveTrailingSlash(endpoint)
|
|
||||||
var modelsl ModelsList
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, endpoint+"/openai/deployments?api-version=2022-12-01", nil)
|
|
||||||
req.Header.Set("api-key", apikey)
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
err = json.NewDecoder(resp.Body).Decode(&modelsl)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &modelsl, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func RemoveTrailingSlash(s string) string {
|
|
||||||
const prefix = "openai.azure.com/"
|
|
||||||
if strings.HasSuffix(strings.TrimSpace(s), prefix) && strings.HasSuffix(s, "/") {
|
|
||||||
return s[:len(s)-1]
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetResourceName(url string) string {
|
|
||||||
re := regexp.MustCompile(`https?://(.+)\.openai\.azure\.com/?`)
|
|
||||||
match := re.FindStringSubmatch(url)
|
|
||||||
if len(match) > 1 {
|
|
||||||
return match[1]
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
// https://docs.anthropic.com/claude/reference/messages_post
|
|
||||||
|
|
||||||
package claude
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"opencatd-open/internal/model"
|
|
||||||
"opencatd-open/llm"
|
|
||||||
"opencatd-open/llm/openai"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ChatProxy(c *gin.Context, chatReq *openai.ChatCompletionRequest) {
|
|
||||||
ChatMessages(c, chatReq)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ChatTextCompletions(c *gin.Context, chatReq *openai.ChatCompletionRequest) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChatRequest struct {
|
|
||||||
Model string `json:"model,omitempty"`
|
|
||||||
Messages any `json:"messages,omitempty"`
|
|
||||||
MaxTokens int `json:"max_tokens,omitempty"`
|
|
||||||
Stream bool `json:"stream,omitempty"`
|
|
||||||
System string `json:"system,omitempty"`
|
|
||||||
TopK int `json:"top_k,omitempty"`
|
|
||||||
TopP float64 `json:"top_p,omitempty"`
|
|
||||||
Temperature float64 `json:"temperature,omitempty"`
|
|
||||||
AnthropicVersion string `json:"anthropic_version,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *ChatRequest) ByteJson() []byte {
|
|
||||||
bytejson, _ := json.Marshal(c)
|
|
||||||
return bytejson
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChatMessage struct {
|
|
||||||
Role string `json:"role,omitempty"`
|
|
||||||
Content string `json:"content,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type VisionMessages struct {
|
|
||||||
Role string `json:"role,omitempty"`
|
|
||||||
Content []VisionContent `json:"content,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type VisionContent struct {
|
|
||||||
Type string `json:"type,omitempty"`
|
|
||||||
Source *VisionSource `json:"source,omitempty"`
|
|
||||||
Text string `json:"text,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type VisionSource struct {
|
|
||||||
Type string `json:"type,omitempty"`
|
|
||||||
MediaType string `json:"media_type,omitempty"`
|
|
||||||
Data string `json:"data,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChatResponse struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
Model string `json:"model"`
|
|
||||||
StopSequence any `json:"stop_sequence"`
|
|
||||||
Usage struct {
|
|
||||||
InputTokens int `json:"input_tokens"`
|
|
||||||
OutputTokens int `json:"output_tokens"`
|
|
||||||
} `json:"usage"`
|
|
||||||
Content []struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
} `json:"content"`
|
|
||||||
StopReason string `json:"stop_reason"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ClaudeStreamResponse struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Index int `json:"index"`
|
|
||||||
ContentBlock struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
} `json:"content_block"`
|
|
||||||
Delta struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
StopReason string `json:"stop_reason"`
|
|
||||||
StopSequence any `json:"stop_sequence"`
|
|
||||||
} `json:"delta"`
|
|
||||||
Message struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
Content []any `json:"content"`
|
|
||||||
Model string `json:"model"`
|
|
||||||
StopReason string `json:"stop_reason"`
|
|
||||||
StopSequence any `json:"stop_sequence"`
|
|
||||||
Usage struct {
|
|
||||||
InputTokens int `json:"input_tokens"`
|
|
||||||
OutputTokens int `json:"output_tokens"`
|
|
||||||
} `json:"usage"`
|
|
||||||
} `json:"message"`
|
|
||||||
Error struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
} `json:"error"`
|
|
||||||
Usage struct {
|
|
||||||
OutputTokens int `json:"output_tokens"`
|
|
||||||
} `json:"usage"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Claude struct {
|
|
||||||
Ctx context.Context
|
|
||||||
|
|
||||||
ApiKey *model.ApiKey
|
|
||||||
tokenUsage *llm.TokenUsage
|
|
||||||
|
|
||||||
Done chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewClaude(ctx context.Context, apiKey *model.ApiKey) (*Claude, error) {
|
|
||||||
return &Claude{
|
|
||||||
Ctx: context.Background(),
|
|
||||||
ApiKey: apiKey,
|
|
||||||
tokenUsage: &llm.TokenUsage{},
|
|
||||||
Done: make(chan struct{}),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Claude) Chat(ctx context.Context, chatReq llm.ChatRequest) (*llm.ChatResponse, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *Claude) StreamChat(ctx context.Context, chatReq llm.ChatRequest) (chan *llm.StreamChatResponse, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
@@ -1,429 +0,0 @@
|
|||||||
/*
|
|
||||||
https://docs.anthropic.com/claude/reference/complete_post
|
|
||||||
|
|
||||||
curl --request POST \
|
|
||||||
--url https://api.anthropic.com/v1/complete \
|
|
||||||
--header "anthropic-version: 2023-06-01" \
|
|
||||||
--header "content-type: application/json" \
|
|
||||||
--header "x-api-key: $ANTHROPIC_API_KEY" \
|
|
||||||
--data '
|
|
||||||
{
|
|
||||||
"model": "claude-2",
|
|
||||||
"prompt": "\n\nHuman: Hello, world!\n\nAssistant:",
|
|
||||||
"max_tokens_to_sample": 256,
|
|
||||||
"stream": true
|
|
||||||
}
|
|
||||||
'
|
|
||||||
|
|
||||||
{"completion":" Hello! Nice to meet you.","stop_reason":"stop_sequence","model":"claude-2.0","stop":"\n\nHuman:","log_id":"727bded01002627057967d02b3d557a01aa73266849b62f5aa0b97dec1247ed3"}
|
|
||||||
|
|
||||||
event: completion
|
|
||||||
data: {"completion":"","stop_reason":"stop_sequence","model":"claude-2.0","stop":"\n\nHuman:","log_id":"dfd42341ad08856ff01811885fb8640a1bf977551d8331f81fe9a6c8182c6c63"}
|
|
||||||
|
|
||||||
# Model Pricing
|
|
||||||
|
|
||||||
Claude Instant |100,000 tokens |Prompt $1.63/million tokens |Completion $5.51/million tokens
|
|
||||||
|
|
||||||
Claude 2 |100,000 tokens |Prompt $11.02/million tokens |Completion $32.68/million tokens
|
|
||||||
*Claude 1 is still accessible and offered at the same price as Claude 2.
|
|
||||||
|
|
||||||
# AWS
|
|
||||||
https://docs.aws.amazon.com/bedrock/latest/userguide/what-is-service.html
|
|
||||||
https://aws.amazon.com/cn/bedrock/pricing/
|
|
||||||
Anthropic models Price for 1000 input tokens Price for 1000 output tokens
|
|
||||||
Claude Instant $0.00163 $0.00551
|
|
||||||
|
|
||||||
Claude $0.01102 $0.03268
|
|
||||||
|
|
||||||
https://docs.aws.amazon.com/bedrock/latest/userguide/endpointsTable.html
|
|
||||||
地区名称 地区 端点 协议
|
|
||||||
美国东部(弗吉尼亚北部) 美国东部1 bedrock-runtime.us-east-1.amazonaws.com HTTPS
|
|
||||||
bedrock-runtime-fips.us-east-1.amazonaws.com HTTPS
|
|
||||||
美国西部(俄勒冈州) 美国西2号 bedrock-runtime.us-west-2.amazonaws.com HTTPS
|
|
||||||
bedrock-runtime-fips.us-west-2.amazonaws.com HTTPS
|
|
||||||
亚太地区(新加坡) ap-东南-1 bedrock-runtime.ap-southeast-1.amazonaws.com HTTPS
|
|
||||||
*/
|
|
||||||
|
|
||||||
// package anthropic
|
|
||||||
package claude
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httputil"
|
|
||||||
"net/url"
|
|
||||||
"opencatd-open/pkg/tokenizer"
|
|
||||||
"opencatd-open/store"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/sashabaranov/go-openai"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ClaudeUrl = "https://api.anthropic.com/v1/complete"
|
|
||||||
ClaudeMessageEndpoint = "https://api.anthropic.com/v1/messages"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MessageModule struct {
|
|
||||||
Assistant string // returned data (do not modify)
|
|
||||||
Human string // input content
|
|
||||||
}
|
|
||||||
|
|
||||||
type CompleteRequest struct {
|
|
||||||
Model string `json:"model,omitempty"` //*
|
|
||||||
Prompt string `json:"prompt,omitempty"` //*
|
|
||||||
MaxTokensToSample int `json:"max_tokens_to_sample,omitempty"` //*
|
|
||||||
StopSequences string `json:"stop_sequences,omitempty"`
|
|
||||||
Temperature int `json:"temperature,omitempty"`
|
|
||||||
TopP int `json:"top_p,omitempty"`
|
|
||||||
TopK int `json:"top_k,omitempty"`
|
|
||||||
Stream bool `json:"stream,omitempty"`
|
|
||||||
Metadata struct {
|
|
||||||
UserId string `json:"user_Id,omitempty"`
|
|
||||||
} `json:"metadata,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type CompleteResponse struct {
|
|
||||||
Completion string `json:"completion"`
|
|
||||||
StopReason string `json:"stop_reason"`
|
|
||||||
Model string `json:"model"`
|
|
||||||
Stop string `json:"stop"`
|
|
||||||
LogID string `json:"log_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func Create() {
|
|
||||||
complet := CompleteRequest{
|
|
||||||
Model: "claude-2",
|
|
||||||
Prompt: "Human: Hello, world!\\n\\nAssistant:",
|
|
||||||
Stream: true,
|
|
||||||
}
|
|
||||||
var payload *bytes.Buffer
|
|
||||||
json.NewEncoder(payload).Encode(complet)
|
|
||||||
|
|
||||||
// payload := strings.NewReader("{\"model\":\"claude-2\",\"prompt\":\"\\n\\nHuman: Hello, world!\\n\\nAssistant:\",\"max_tokens_to_sample\":256}")
|
|
||||||
|
|
||||||
req, _ := http.NewRequest("POST", ClaudeUrl, payload)
|
|
||||||
|
|
||||||
req.Header.Add("accept", "application/json")
|
|
||||||
req.Header.Add("anthropic-version", "2023-06-01")
|
|
||||||
req.Header.Add("x-api-key", "$ANTHROPIC_API_KEY")
|
|
||||||
req.Header.Add("content-type", "application/json")
|
|
||||||
|
|
||||||
res, _ := http.DefaultClient.Do(req)
|
|
||||||
|
|
||||||
defer res.Body.Close()
|
|
||||||
// body, _ := io.ReadAll(res.Body)
|
|
||||||
|
|
||||||
// fmt.Println(string(body))
|
|
||||||
reader := bufio.NewReader(res.Body)
|
|
||||||
for {
|
|
||||||
line, err := reader.ReadString('\n')
|
|
||||||
if err == nil {
|
|
||||||
if strings.HasPrefix(line, "data:") {
|
|
||||||
fmt.Println(line)
|
|
||||||
// var result CompleteResponse
|
|
||||||
// json.Unmarshal()
|
|
||||||
} else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ClaudeProxy(c *gin.Context) {
|
|
||||||
var chatlog store.Tokens
|
|
||||||
var complete CompleteRequest
|
|
||||||
|
|
||||||
byteBody, _ := io.ReadAll(c.Request.Body)
|
|
||||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(byteBody))
|
|
||||||
|
|
||||||
if err := json.Unmarshal(byteBody, &complete); err != nil {
|
|
||||||
c.AbortWithError(http.StatusBadRequest, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
key, err := store.SelectKeyCache("claude") //anthropic
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": gin.H{
|
|
||||||
"message": err.Error(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
chatlog.Model = complete.Model
|
|
||||||
|
|
||||||
token, _ := c.Get("localuser")
|
|
||||||
|
|
||||||
lu, err := store.GetUserByToken(token.(string))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": gin.H{
|
|
||||||
"message": err.Error(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
chatlog.UserID = int(lu.ID)
|
|
||||||
|
|
||||||
chatlog.PromptCount = tokenizer.NumTokensFromStr(complete.Prompt, complete.Model)
|
|
||||||
|
|
||||||
if key.EndPoint == "" {
|
|
||||||
key.EndPoint = "https://api.anthropic.com"
|
|
||||||
}
|
|
||||||
targetUrl, _ := url.ParseRequestURI(key.EndPoint + c.Request.URL.String())
|
|
||||||
|
|
||||||
proxy := httputil.NewSingleHostReverseProxy(targetUrl)
|
|
||||||
proxy.Director = func(req *http.Request) {
|
|
||||||
req.Host = targetUrl.Host
|
|
||||||
req.URL.Scheme = targetUrl.Scheme
|
|
||||||
req.URL.Host = targetUrl.Host
|
|
||||||
|
|
||||||
req.Header.Set("anthropic-version", "2023-06-01")
|
|
||||||
req.Header.Set("content-type", "application/json")
|
|
||||||
req.Header.Set("x-api-key", key.Key)
|
|
||||||
}
|
|
||||||
|
|
||||||
proxy.ModifyResponse = func(resp *http.Response) error {
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var byteResp []byte
|
|
||||||
byteResp, _ = io.ReadAll(resp.Body)
|
|
||||||
resp.Body = io.NopCloser(bytes.NewBuffer(byteResp))
|
|
||||||
if complete.Stream != true {
|
|
||||||
var complete_resp CompleteResponse
|
|
||||||
|
|
||||||
if err := json.Unmarshal(byteResp, &complete_resp); err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
chatlog.CompletionCount = tokenizer.NumTokensFromStr(complete_resp.Completion, chatlog.Model)
|
|
||||||
} else {
|
|
||||||
var completion string
|
|
||||||
for {
|
|
||||||
line, err := bufio.NewReader(bytes.NewBuffer(byteResp)).ReadString('\n')
|
|
||||||
if err != nil {
|
|
||||||
if strings.HasPrefix(line, "data:") {
|
|
||||||
line = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
|
||||||
if strings.HasSuffix(line, "[DONE]") {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
var complete_resp CompleteResponse
|
|
||||||
if err := json.Unmarshal([]byte(line), &complete_resp); err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
completion += line
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.Println("completion:", completion)
|
|
||||||
chatlog.CompletionCount = tokenizer.NumTokensFromStr(completion, chatlog.Model)
|
|
||||||
}
|
|
||||||
|
|
||||||
// calc cost
|
|
||||||
chatlog.TotalTokens = chatlog.PromptCount + chatlog.CompletionCount
|
|
||||||
chatlog.Cost = fmt.Sprintf("%.6f", tokenizer.Cost(chatlog.Model, chatlog.PromptCount, chatlog.CompletionCount))
|
|
||||||
|
|
||||||
if err := store.Record(&chatlog); err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
}
|
|
||||||
if err := store.SumDaily(chatlog.UserID); err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
proxy.ServeHTTP(c.Writer, c.Request)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TransReq(chatreq *openai.ChatCompletionRequest) (*bytes.Buffer, error) {
|
|
||||||
transReq := CompleteRequest{
|
|
||||||
Model: chatreq.Model,
|
|
||||||
Temperature: int(chatreq.Temperature),
|
|
||||||
TopP: int(chatreq.TopP),
|
|
||||||
Stream: chatreq.Stream,
|
|
||||||
MaxTokensToSample: chatreq.MaxTokens,
|
|
||||||
}
|
|
||||||
if transReq.MaxTokensToSample == 0 {
|
|
||||||
transReq.MaxTokensToSample = 100000
|
|
||||||
}
|
|
||||||
var prompt string
|
|
||||||
for _, msg := range chatreq.Messages {
|
|
||||||
switch msg.Role {
|
|
||||||
case "system":
|
|
||||||
prompt += fmt.Sprintf("\n\nHuman:%s", msg.Content)
|
|
||||||
case "user":
|
|
||||||
prompt += fmt.Sprintf("\n\nHuman:%s", msg.Content)
|
|
||||||
case "assistant":
|
|
||||||
prompt += fmt.Sprintf("\n\nAssistant:%s", msg.Content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
transReq.Prompt = prompt + "\n\nAssistant:"
|
|
||||||
var payload = bytes.NewBuffer(nil)
|
|
||||||
if err := json.NewEncoder(payload).Encode(transReq); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return payload, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TransRsp(c *gin.Context, isStream bool, chatlog store.Tokens, reader *bufio.Reader) {
|
|
||||||
if !isStream {
|
|
||||||
var completersp CompleteResponse
|
|
||||||
var chatrsp openai.ChatCompletionResponse
|
|
||||||
json.NewDecoder(reader).Decode(&completersp)
|
|
||||||
chatrsp.Model = completersp.Model
|
|
||||||
chatrsp.ID = completersp.LogID
|
|
||||||
chatrsp.Object = "chat.completion"
|
|
||||||
chatrsp.Created = time.Now().Unix()
|
|
||||||
choice := openai.ChatCompletionChoice{
|
|
||||||
Index: 0,
|
|
||||||
FinishReason: "stop",
|
|
||||||
Message: openai.ChatCompletionMessage{
|
|
||||||
Role: "assistant",
|
|
||||||
Content: completersp.Completion,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
chatrsp.Choices = append(chatrsp.Choices, choice)
|
|
||||||
var payload *bytes.Buffer
|
|
||||||
if err := json.NewEncoder(payload).Encode(chatrsp); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": gin.H{
|
|
||||||
"message": err.Error(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
chatlog.CompletionCount = tokenizer.NumTokensFromStr(completersp.Completion, chatlog.Model)
|
|
||||||
chatlog.TotalTokens = chatlog.PromptCount + chatlog.CompletionCount
|
|
||||||
chatlog.Cost = fmt.Sprintf("%.6f", tokenizer.Cost(chatlog.Model, chatlog.PromptCount, chatlog.CompletionCount))
|
|
||||||
if err := store.Record(&chatlog); err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
}
|
|
||||||
if err := store.SumDaily(chatlog.UserID); err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, payload)
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
var (
|
|
||||||
wg sync.WaitGroup
|
|
||||||
dataChan = make(chan string)
|
|
||||||
stopChan = make(chan bool)
|
|
||||||
complete_resp string
|
|
||||||
)
|
|
||||||
wg.Add(2)
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
for {
|
|
||||||
line, err := reader.ReadString('\n')
|
|
||||||
if err == nil {
|
|
||||||
if strings.HasPrefix(line, "data: ") {
|
|
||||||
var result CompleteResponse
|
|
||||||
json.NewDecoder(strings.NewReader(line[6:])).Decode(&result)
|
|
||||||
if result.StopReason == "" {
|
|
||||||
if result.Completion != "" {
|
|
||||||
complete_resp += result.Completion
|
|
||||||
chatrsp := openai.ChatCompletionStreamResponse{
|
|
||||||
ID: result.LogID,
|
|
||||||
Model: result.Model,
|
|
||||||
Object: "chat.completion",
|
|
||||||
Created: time.Now().Unix(),
|
|
||||||
}
|
|
||||||
choice := openai.ChatCompletionStreamChoice{
|
|
||||||
Delta: openai.ChatCompletionStreamChoiceDelta{
|
|
||||||
Role: "assistant",
|
|
||||||
Content: result.Completion,
|
|
||||||
},
|
|
||||||
FinishReason: "",
|
|
||||||
}
|
|
||||||
chatrsp.Choices = append(chatrsp.Choices, choice)
|
|
||||||
bytedate, _ := json.Marshal(chatrsp)
|
|
||||||
dataChan <- string(bytedate)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
chatrsp := openai.ChatCompletionStreamResponse{
|
|
||||||
ID: result.LogID,
|
|
||||||
Model: result.Model,
|
|
||||||
Object: "chat.completion",
|
|
||||||
Created: time.Now().Unix(),
|
|
||||||
}
|
|
||||||
choice := openai.ChatCompletionStreamChoice{
|
|
||||||
Delta: openai.ChatCompletionStreamChoiceDelta{
|
|
||||||
Role: "assistant",
|
|
||||||
Content: result.Completion,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
choice.FinishReason = openai.FinishReason(TranslatestopReason(result.StopReason))
|
|
||||||
chatrsp.Choices = append(chatrsp.Choices, choice)
|
|
||||||
bytedate, _ := json.Marshal(chatrsp)
|
|
||||||
dataChan <- string(bytedate)
|
|
||||||
dataChan <- "[DONE]"
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
close(dataChan)
|
|
||||||
stopChan <- true
|
|
||||||
close(stopChan)
|
|
||||||
}()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
Loop:
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case data := <-dataChan:
|
|
||||||
if data != "" {
|
|
||||||
c.Writer.WriteString("data: " + data)
|
|
||||||
c.Writer.WriteString("\n\n")
|
|
||||||
c.Writer.Flush()
|
|
||||||
}
|
|
||||||
case <-stopChan:
|
|
||||||
break Loop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
wg.Wait()
|
|
||||||
chatlog.CompletionCount = tokenizer.NumTokensFromStr(complete_resp, chatlog.Model)
|
|
||||||
chatlog.TotalTokens = chatlog.PromptCount + chatlog.CompletionCount
|
|
||||||
chatlog.Cost = fmt.Sprintf("%.6f", tokenizer.Cost(chatlog.Model, chatlog.PromptCount, chatlog.CompletionCount))
|
|
||||||
if err := store.Record(&chatlog); err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
}
|
|
||||||
if err := store.SumDaily(chatlog.UserID); err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// claude -> openai
|
|
||||||
func TranslatestopReason(reason string) string {
|
|
||||||
switch reason {
|
|
||||||
case "stop_sequence":
|
|
||||||
return "stop"
|
|
||||||
case "max_tokens":
|
|
||||||
return "length"
|
|
||||||
default:
|
|
||||||
return reason
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
package claude
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"opencatd-open/llm/openai"
|
|
||||||
"opencatd-open/llm/vertexai"
|
|
||||||
"opencatd-open/pkg/error"
|
|
||||||
"opencatd-open/pkg/tokenizer"
|
|
||||||
"opencatd-open/store"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ChatMessages(c *gin.Context, chatReq *openai.ChatCompletionRequest) {
|
|
||||||
var (
|
|
||||||
req *http.Request
|
|
||||||
targetURL = ClaudeMessageEndpoint
|
|
||||||
)
|
|
||||||
|
|
||||||
apiKey, err := store.SelectKeyCache("claude")
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
usagelog := store.Tokens{Model: chatReq.Model}
|
|
||||||
var claudReq ChatRequest
|
|
||||||
claudReq.Model = chatReq.Model
|
|
||||||
claudReq.Stream = chatReq.Stream
|
|
||||||
// claudReq.Temperature = chatReq.Temperature
|
|
||||||
claudReq.TopP = chatReq.TopP
|
|
||||||
claudReq.MaxTokens = 4096
|
|
||||||
if apiKey.ApiType == "vertex" {
|
|
||||||
claudReq.AnthropicVersion = "vertex-2023-10-16"
|
|
||||||
claudReq.Model = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var claudecontent []VisionContent
|
|
||||||
var prompt string
|
|
||||||
for _, msg := range chatReq.Messages {
|
|
||||||
switch ct := msg.Content.(type) {
|
|
||||||
case string:
|
|
||||||
prompt += "<" + msg.Role + ">: " + msg.Content.(string) + "\n"
|
|
||||||
if msg.Role == "system" {
|
|
||||||
claudReq.System = msg.Content.(string)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
claudecontent = append(claudecontent, VisionContent{Type: "text", Text: msg.Role + ":" + msg.Content.(string)})
|
|
||||||
case []any:
|
|
||||||
for _, item := range ct {
|
|
||||||
if m, ok := item.(map[string]interface{}); ok {
|
|
||||||
if m["type"] == "text" {
|
|
||||||
prompt += "<" + msg.Role + ">: " + m["text"].(string) + "\n"
|
|
||||||
claudecontent = append(claudecontent, VisionContent{Type: "text", Text: msg.Role + ":" + m["text"].(string)})
|
|
||||||
} else if m["type"] == "image_url" {
|
|
||||||
if url, ok := m["image_url"].(map[string]interface{}); ok {
|
|
||||||
fmt.Printf(" URL: %v\n", url["url"])
|
|
||||||
if strings.HasPrefix(url["url"].(string), "http") {
|
|
||||||
fmt.Println("网络图片:", url["url"].(string))
|
|
||||||
} else if strings.HasPrefix(url["url"].(string), "data:image") {
|
|
||||||
fmt.Println("base64:", url["url"].(string)[:20])
|
|
||||||
var mediaType string
|
|
||||||
if strings.HasPrefix(url["url"].(string), "data:image/jpeg") {
|
|
||||||
mediaType = "image/jpeg"
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(url["url"].(string), "data:image/png") {
|
|
||||||
mediaType = "image/png"
|
|
||||||
}
|
|
||||||
claudecontent = append(claudecontent, VisionContent{Type: "image", Source: &VisionSource{Type: "base64", MediaType: mediaType, Data: strings.Split(url["url"].(string), ",")[1]}})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": gin.H{
|
|
||||||
"message": "Invalid content type",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(chatReq.Tools) > 0 {
|
|
||||||
tooljson, _ := json.Marshal(chatReq.Tools)
|
|
||||||
prompt += "<tools>: " + string(tooljson) + "\n"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
claudReq.Messages = []VisionMessages{{Role: "user", Content: claudecontent}}
|
|
||||||
|
|
||||||
usagelog.PromptCount = tokenizer.NumTokensFromStr(prompt, chatReq.Model)
|
|
||||||
|
|
||||||
if apiKey.ApiType == "vertex" {
|
|
||||||
var vertexSecret vertexai.VertexSecretKey
|
|
||||||
if err := json.Unmarshal([]byte(apiKey.ApiSecret), &vertexSecret); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, error.ErrorData(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
vcmodel, ok := vertexai.VertexClaudeModelMap[chatReq.Model]
|
|
||||||
if !ok {
|
|
||||||
c.JSON(http.StatusInternalServerError, error.ErrorData("Model not found"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取gcloud token,临时放置在apiKey.Key中
|
|
||||||
gcloudToken, err := vertexai.GcloudAuth(vertexSecret.ClientEmail, vertexSecret.PrivateKey)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, error.ErrorData(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 拼接vertex的请求地址
|
|
||||||
targetURL = fmt.Sprintf("https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:streamRawPredict", vcmodel.Region, vertexSecret.ProjectID, vcmodel.Region, vcmodel.VertexName)
|
|
||||||
|
|
||||||
req, _ = http.NewRequest("POST", targetURL, bytes.NewReader(claudReq.ByteJson()))
|
|
||||||
req.Header.Set("Authorization", "Bearer "+gcloudToken)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Accept", "text/event-stream")
|
|
||||||
req.Header.Set("Accept-Encoding", "identity")
|
|
||||||
} else {
|
|
||||||
req, _ = http.NewRequest("POST", targetURL, bytes.NewReader(claudReq.ByteJson()))
|
|
||||||
req.Header.Set("x-api-key", apiKey.Key)
|
|
||||||
req.Header.Set("anthropic-version", "2023-06-01")
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
}
|
|
||||||
|
|
||||||
client := http.DefaultClient
|
|
||||||
rsp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rsp.Body.Close()
|
|
||||||
if rsp.StatusCode != http.StatusOK {
|
|
||||||
io.Copy(c.Writer, rsp.Body)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var buffer bytes.Buffer
|
|
||||||
teeReader := io.TeeReader(rsp.Body, &buffer)
|
|
||||||
|
|
||||||
dataChan := make(chan string)
|
|
||||||
// stopChan := make(chan bool)
|
|
||||||
|
|
||||||
var result string
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(teeReader)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Bytes()
|
|
||||||
if len(line) > 0 && bytes.HasPrefix(line, []byte("data: ")) {
|
|
||||||
if bytes.HasPrefix(line, []byte("data: [DONE]")) {
|
|
||||||
dataChan <- string(line) + "\n"
|
|
||||||
break
|
|
||||||
}
|
|
||||||
var claudeResp ClaudeStreamResponse
|
|
||||||
line = bytes.Replace(line, []byte("data: "), []byte(""), -1)
|
|
||||||
line = bytes.TrimSpace(line)
|
|
||||||
if err := json.Unmarshal(line, &claudeResp); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if claudeResp.Type == "message_start" {
|
|
||||||
if claudeResp.Message.Role != "" {
|
|
||||||
result += "<" + claudeResp.Message.Role + ">"
|
|
||||||
}
|
|
||||||
} else if claudeResp.Type == "message_stop" {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if claudeResp.Delta.Text != "" {
|
|
||||||
result += claudeResp.Delta.Text
|
|
||||||
}
|
|
||||||
var choice openai.Choice
|
|
||||||
choice.Delta.Role = claudeResp.Message.Role
|
|
||||||
choice.Delta.Content = claudeResp.Delta.Text
|
|
||||||
choice.FinishReason = claudeResp.Delta.StopReason
|
|
||||||
|
|
||||||
chatResp := openai.ChatCompletionStreamResponse{
|
|
||||||
Model: chatReq.Model,
|
|
||||||
Choices: []openai.Choice{choice},
|
|
||||||
}
|
|
||||||
dataChan <- "data: " + string(chatResp.ByteJson()) + "\n"
|
|
||||||
if claudeResp.Delta.StopReason != "" {
|
|
||||||
dataChan <- "\ndata: [DONE]\n"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defer close(dataChan)
|
|
||||||
}()
|
|
||||||
|
|
||||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
|
||||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
|
||||||
c.Writer.Header().Set("Connection", "keep-alive")
|
|
||||||
c.Writer.Header().Set("Transfer-Encoding", "chunked")
|
|
||||||
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
|
||||||
|
|
||||||
c.Stream(func(w io.Writer) bool {
|
|
||||||
if data, ok := <-dataChan; ok {
|
|
||||||
if strings.HasPrefix(data, "data: ") {
|
|
||||||
c.Writer.WriteString(data)
|
|
||||||
// c.Writer.WriteString("\n\n")
|
|
||||||
} else {
|
|
||||||
c.Writer.WriteHeader(http.StatusBadGateway)
|
|
||||||
c.Writer.WriteString(data)
|
|
||||||
}
|
|
||||||
c.Writer.Flush()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
usagelog.CompletionCount = tokenizer.NumTokensFromStr(result, chatReq.Model)
|
|
||||||
usagelog.Cost = fmt.Sprintf("%.6f", tokenizer.Cost(usagelog.Model, usagelog.PromptCount, usagelog.CompletionCount))
|
|
||||||
if err := store.Record(&usagelog); err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
}
|
|
||||||
if err := store.SumDaily(usagelog.UserID); err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
// https://docs.anthropic.com/en/docs/about-claude/models/all-models
|
|
||||||
package claude
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"opencatd-open/internal/model"
|
|
||||||
"opencatd-open/llm"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/liushuangls/go-anthropic/v2"
|
|
||||||
"github.com/sashabaranov/go-openai"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Claude struct {
|
|
||||||
Ctx context.Context
|
|
||||||
ApiKey *model.ApiKey
|
|
||||||
tokenUsage *llm.TokenUsage
|
|
||||||
Done chan struct{}
|
|
||||||
Client *anthropic.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewClaude(apiKey *model.ApiKey) (*Claude, error) {
|
|
||||||
opts := []anthropic.ClientOption{}
|
|
||||||
if os.Getenv("LOCAL_PROXY") != "" {
|
|
||||||
proxyUrl, err := url.Parse(os.Getenv("LOCAL_PROXY"))
|
|
||||||
if err == nil {
|
|
||||||
client := http.DefaultClient
|
|
||||||
client.Transport = &http.Transport{Proxy: http.ProxyURL(proxyUrl)}
|
|
||||||
opts = append(opts, anthropic.WithHTTPClient(client))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &Claude{
|
|
||||||
Ctx: context.Background(),
|
|
||||||
ApiKey: apiKey,
|
|
||||||
tokenUsage: &llm.TokenUsage{},
|
|
||||||
Done: make(chan struct{}),
|
|
||||||
Client: anthropic.NewClient(*apiKey.ApiKey, opts...),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Claude) Chat(ctx context.Context, chatReq llm.ChatRequest) (*llm.ChatResponse, error) {
|
|
||||||
var messages []anthropic.Message
|
|
||||||
|
|
||||||
if len(chatReq.Messages) > 0 {
|
|
||||||
for _, msg := range chatReq.Messages {
|
|
||||||
var role anthropic.ChatRole
|
|
||||||
if msg.Role != "assistant" {
|
|
||||||
role = anthropic.RoleUser
|
|
||||||
} else {
|
|
||||||
role = anthropic.RoleAssistant
|
|
||||||
}
|
|
||||||
|
|
||||||
var content []anthropic.MessageContent
|
|
||||||
if len(msg.MultiContent) > 0 {
|
|
||||||
for _, mc := range msg.MultiContent {
|
|
||||||
if mc.Type == "text" {
|
|
||||||
content = append(content, anthropic.MessageContent{Type: anthropic.MessagesContentTypeText, Text: &mc.Text})
|
|
||||||
}
|
|
||||||
if mc.Type == "image_url" {
|
|
||||||
if strings.HasPrefix(mc.ImageURL.URL, "http") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(mc.ImageURL.URL, "data:image") {
|
|
||||||
var mediaType string
|
|
||||||
if strings.HasPrefix(mc.ImageURL.URL, "data:image/jpeg") {
|
|
||||||
mediaType = "image/jpeg"
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(mc.ImageURL.URL, "data:image/png") {
|
|
||||||
mediaType = "image/png"
|
|
||||||
}
|
|
||||||
imageString := strings.Split(mc.ImageURL.URL, ",")[1]
|
|
||||||
imageBytes, _ := base64.StdEncoding.DecodeString(imageString)
|
|
||||||
|
|
||||||
content = append(content, anthropic.MessageContent{Type: "image", Source: &anthropic.MessageContentSource{Type: "base64", MediaType: mediaType, Data: imageBytes}})
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
messages = append(messages, anthropic.Message{Role: role, Content: content})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if len(msg.Content) > 0 {
|
|
||||||
content = append(content, anthropic.MessageContent{Type: "text", Text: &msg.Content})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
messages = append(messages, anthropic.Message{Role: role, Content: content})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var maxTokens int
|
|
||||||
if chatReq.MaxTokens > 0 {
|
|
||||||
maxTokens = chatReq.MaxTokens
|
|
||||||
} else {
|
|
||||||
if strings.Contains(chatReq.Model, "3-7") {
|
|
||||||
maxTokens = 64000
|
|
||||||
} else if strings.Contains(chatReq.Model, "3-5") {
|
|
||||||
maxTokens = 8192
|
|
||||||
} else {
|
|
||||||
maxTokens = 4096
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.Client.CreateMessages(ctx, anthropic.MessagesRequest{
|
|
||||||
Model: anthropic.Model(chatReq.Model),
|
|
||||||
Messages: messages,
|
|
||||||
MaxTokens: maxTokens,
|
|
||||||
Stream: false,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.tokenUsage.Model == "" && resp.Model != "" {
|
|
||||||
c.tokenUsage.Model = string(resp.Model)
|
|
||||||
}
|
|
||||||
c.tokenUsage.PromptTokens += resp.Usage.InputTokens
|
|
||||||
c.tokenUsage.CompletionTokens += resp.Usage.OutputTokens
|
|
||||||
c.tokenUsage.TotalTokens += resp.Usage.InputTokens + resp.Usage.OutputTokens
|
|
||||||
|
|
||||||
return &llm.ChatResponse{
|
|
||||||
Model: string(resp.Model),
|
|
||||||
Choices: []openai.ChatCompletionChoice{
|
|
||||||
{
|
|
||||||
FinishReason: openai.FinishReason(resp.StopReason),
|
|
||||||
Message: openai.ChatCompletionMessage{
|
|
||||||
Role: openai.ChatMessageRoleAssistant,
|
|
||||||
Content: *resp.Content[0].Text,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Claude) StreamChat(ctx context.Context, chatReq llm.ChatRequest) (chan *llm.StreamChatResponse, error) {
|
|
||||||
var messages []anthropic.Message
|
|
||||||
|
|
||||||
if len(chatReq.Messages) > 0 {
|
|
||||||
for _, msg := range chatReq.Messages {
|
|
||||||
var role anthropic.ChatRole
|
|
||||||
if msg.Role != "assistant" {
|
|
||||||
role = anthropic.RoleUser
|
|
||||||
} else {
|
|
||||||
role = anthropic.RoleAssistant
|
|
||||||
}
|
|
||||||
|
|
||||||
var content []anthropic.MessageContent
|
|
||||||
if len(msg.MultiContent) > 0 {
|
|
||||||
for _, mc := range msg.MultiContent {
|
|
||||||
if mc.Type == "text" {
|
|
||||||
content = append(content, anthropic.MessageContent{Type: anthropic.MessagesContentTypeText, Text: &mc.Text})
|
|
||||||
}
|
|
||||||
if mc.Type == "image_url" {
|
|
||||||
if strings.HasPrefix(mc.ImageURL.URL, "http") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(mc.ImageURL.URL, "data:image") {
|
|
||||||
var mediaType string
|
|
||||||
if strings.HasPrefix(mc.ImageURL.URL, "data:image/jpeg") {
|
|
||||||
mediaType = "image/jpeg"
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(mc.ImageURL.URL, "data:image/png") {
|
|
||||||
mediaType = "image/png"
|
|
||||||
}
|
|
||||||
imageString := strings.Split(mc.ImageURL.URL, ",")[1]
|
|
||||||
imageBytes, _ := base64.StdEncoding.DecodeString(imageString)
|
|
||||||
|
|
||||||
content = append(content, anthropic.MessageContent{Type: "image", Source: &anthropic.MessageContentSource{Type: "base64", MediaType: mediaType, Data: imageBytes}})
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
messages = append(messages, anthropic.Message{Role: role, Content: content})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if len(msg.Content) > 0 {
|
|
||||||
content = append(content, anthropic.MessageContent{Type: "text", Text: &msg.Content})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
messages = append(messages, anthropic.Message{Role: role, Content: content})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var maxTokens int
|
|
||||||
if chatReq.MaxTokens > 0 {
|
|
||||||
maxTokens = chatReq.MaxTokens
|
|
||||||
} else {
|
|
||||||
if strings.Contains(chatReq.Model, "sonnet") || strings.Contains(chatReq.Model, "haiku") {
|
|
||||||
maxTokens = 8192
|
|
||||||
} else {
|
|
||||||
maxTokens = 4096
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
datachan := make(chan *llm.StreamChatResponse)
|
|
||||||
// var resp anthropic.MessagesResponse
|
|
||||||
var err error
|
|
||||||
go func() {
|
|
||||||
defer close(datachan)
|
|
||||||
_, err = c.Client.CreateMessagesStream(ctx, anthropic.MessagesStreamRequest{
|
|
||||||
MessagesRequest: anthropic.MessagesRequest{
|
|
||||||
Model: anthropic.Model(chatReq.Model),
|
|
||||||
Messages: messages,
|
|
||||||
MaxTokens: maxTokens,
|
|
||||||
},
|
|
||||||
OnContentBlockDelta: func(data anthropic.MessagesEventContentBlockDeltaData) {
|
|
||||||
datachan <- &llm.StreamChatResponse{
|
|
||||||
Model: chatReq.Model,
|
|
||||||
Choices: []openai.ChatCompletionStreamChoice{
|
|
||||||
{
|
|
||||||
Delta: openai.ChatCompletionStreamChoiceDelta{Content: *data.Delta.Text},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
OnMessageStart: func(memss anthropic.MessagesEventMessageStartData) {
|
|
||||||
c.tokenUsage.PromptTokens += memss.Message.Usage.InputTokens
|
|
||||||
c.tokenUsage.CompletionTokens += memss.Message.Usage.OutputTokens
|
|
||||||
c.tokenUsage.TotalTokens += memss.Message.Usage.InputTokens + memss.Message.Usage.OutputTokens
|
|
||||||
},
|
|
||||||
OnMessageDelta: func(memdd anthropic.MessagesEventMessageDeltaData) {
|
|
||||||
c.tokenUsage.PromptTokens += memdd.Usage.InputTokens
|
|
||||||
c.tokenUsage.CompletionTokens += memdd.Usage.OutputTokens
|
|
||||||
c.tokenUsage.TotalTokens += memdd.Usage.InputTokens + memdd.Usage.OutputTokens
|
|
||||||
|
|
||||||
datachan <- &llm.StreamChatResponse{
|
|
||||||
Model: chatReq.Model,
|
|
||||||
Choices: []openai.ChatCompletionStreamChoice{
|
|
||||||
{FinishReason: openai.FinishReason(memdd.Delta.StopReason)},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return datachan, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Claude) GetTokenUsage() *llm.TokenUsage {
|
|
||||||
return c.tokenUsage
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
// https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/grounding-search-entry-points?authuser=2&hl=zh-cn
|
|
||||||
//
|
|
||||||
// https://cloud.google.com/vertex-ai/docs/generative-ai/quotas-genai
|
|
||||||
package google
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"opencatd-open/llm/openai"
|
|
||||||
"opencatd-open/pkg/tokenizer"
|
|
||||||
"opencatd-open/store"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/generative-ai-go/genai"
|
|
||||||
"google.golang.org/api/iterator"
|
|
||||||
"google.golang.org/api/option"
|
|
||||||
)
|
|
||||||
|
|
||||||
type GeminiChatRequest struct {
|
|
||||||
Contents []GeminiContent `json:"contents,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g GeminiChatRequest) ByteJson() []byte {
|
|
||||||
bytejson, _ := json.Marshal(g)
|
|
||||||
return bytejson
|
|
||||||
}
|
|
||||||
|
|
||||||
type GeminiContent struct {
|
|
||||||
Role string `json:"role,omitempty"`
|
|
||||||
Parts []GeminiPart `json:"parts,omitempty"`
|
|
||||||
}
|
|
||||||
type GeminiPart struct {
|
|
||||||
Text string `json:"text,omitempty"`
|
|
||||||
// InlineData GeminiPartInlineData `json:"inlineData,omitempty"`
|
|
||||||
}
|
|
||||||
type GeminiPartInlineData struct {
|
|
||||||
MimeType string `json:"mimeType,omitempty"`
|
|
||||||
Data string `json:"data,omitempty"` // base64
|
|
||||||
}
|
|
||||||
|
|
||||||
type GeminiResponse struct {
|
|
||||||
Candidates []struct {
|
|
||||||
Content struct {
|
|
||||||
Parts []struct {
|
|
||||||
Text string `json:"text"`
|
|
||||||
} `json:"parts"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
} `json:"content"`
|
|
||||||
FinishReason string `json:"finishReason"`
|
|
||||||
Index int `json:"index"`
|
|
||||||
SafetyRatings []struct {
|
|
||||||
Category string `json:"category"`
|
|
||||||
Probability string `json:"probability"`
|
|
||||||
} `json:"safetyRatings"`
|
|
||||||
} `json:"candidates"`
|
|
||||||
PromptFeedback struct {
|
|
||||||
SafetyRatings []struct {
|
|
||||||
Category string `json:"category"`
|
|
||||||
Probability string `json:"probability"`
|
|
||||||
} `json:"safetyRatings"`
|
|
||||||
} `json:"promptFeedback"`
|
|
||||||
Error struct {
|
|
||||||
Code int `json:"code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Details []struct {
|
|
||||||
Type string `json:"@type"`
|
|
||||||
FieldViolations []struct {
|
|
||||||
Field string `json:"field"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
} `json:"fieldViolations"`
|
|
||||||
} `json:"details"`
|
|
||||||
} `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func ChatProxy(c *gin.Context, chatReq *openai.ChatCompletionRequest) {
|
|
||||||
usagelog := store.Tokens{Model: chatReq.Model}
|
|
||||||
|
|
||||||
token, _ := c.Get("localuser")
|
|
||||||
|
|
||||||
lu, err := store.GetUserByToken(token.(string))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": gin.H{
|
|
||||||
"message": err.Error(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
usagelog.UserID = int(lu.ID)
|
|
||||||
var prompts []genai.Part
|
|
||||||
var prompt string
|
|
||||||
for _, msg := range chatReq.Messages {
|
|
||||||
switch ct := msg.Content.(type) {
|
|
||||||
case string:
|
|
||||||
prompt += "<" + msg.Role + ">: " + msg.Content.(string) + "\n"
|
|
||||||
prompts = append(prompts, genai.Text("<"+msg.Role+">: "+msg.Content.(string)))
|
|
||||||
case []any:
|
|
||||||
for _, item := range ct {
|
|
||||||
if m, ok := item.(map[string]interface{}); ok {
|
|
||||||
if m["type"] == "text" {
|
|
||||||
prompt += "<" + msg.Role + ">: " + m["text"].(string) + "\n"
|
|
||||||
prompts = append(prompts, genai.Text("<"+msg.Role+">: "+m["text"].(string)))
|
|
||||||
} else if m["type"] == "image_url" {
|
|
||||||
if url, ok := m["image_url"].(map[string]interface{}); ok {
|
|
||||||
if strings.HasPrefix(url["url"].(string), "http") {
|
|
||||||
fmt.Println("网络图片:", url["url"].(string))
|
|
||||||
} else if strings.HasPrefix(url["url"].(string), "data:image") {
|
|
||||||
fmt.Println("base64:", url["url"].(string)[:20])
|
|
||||||
var mime string
|
|
||||||
// openai 会以 data:image 开头,则去掉 data:image/png;base64, 和 data:image/jpeg;base64,
|
|
||||||
if strings.HasPrefix(url["url"].(string), "data:image/png") {
|
|
||||||
mime = "image/png"
|
|
||||||
} else if strings.HasPrefix(url["url"].(string), "data:image/jpeg") {
|
|
||||||
mime = "image/jpeg"
|
|
||||||
} else {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Unsupported image format"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
imageString := strings.Split(url["url"].(string), ",")[1]
|
|
||||||
imageBytes, err := base64.StdEncoding.DecodeString(imageString)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
prompts = append(prompts, genai.Blob{MIMEType: mime, Data: imageBytes})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": gin.H{
|
|
||||||
"message": "Invalid content type",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(chatReq.Tools) > 0 {
|
|
||||||
tooljson, _ := json.Marshal(chatReq.Tools)
|
|
||||||
prompt += "<tools>: " + string(tooljson) + "\n"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
usagelog.PromptCount = tokenizer.NumTokensFromStr(prompt, chatReq.Model)
|
|
||||||
|
|
||||||
onekey, err := store.SelectKeyCache("google")
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
client, err := genai.NewClient(ctx, option.WithAPIKey(onekey.Key))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
|
|
||||||
model := client.GenerativeModel(chatReq.Model)
|
|
||||||
model.Tools = []*genai.Tool{}
|
|
||||||
|
|
||||||
iter := model.GenerateContentStream(ctx, prompts...)
|
|
||||||
datachan := make(chan string)
|
|
||||||
// closechan := make(chan error)
|
|
||||||
var result string
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
resp, err := iter.Next()
|
|
||||||
if err == iterator.Done {
|
|
||||||
|
|
||||||
var chatResp openai.ChatCompletionStreamResponse
|
|
||||||
chatResp.Model = chatReq.Model
|
|
||||||
choice := openai.Choice{}
|
|
||||||
choice.FinishReason = "stop"
|
|
||||||
chatResp.Choices = append(chatResp.Choices, choice)
|
|
||||||
datachan <- "data: " + string(chatResp.ByteJson())
|
|
||||||
close(datachan)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
var errResp openai.ErrResponse
|
|
||||||
errResp.Error.Code = "500"
|
|
||||||
errResp.Error.Message = err.Error()
|
|
||||||
datachan <- string(errResp.ByteJson())
|
|
||||||
close(datachan)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
var content string
|
|
||||||
if resp.Candidates != nil && len(resp.Candidates) > 0 && len(resp.Candidates[0].Content.Parts) > 0 {
|
|
||||||
if s, ok := resp.Candidates[0].Content.Parts[0].(genai.Text); ok {
|
|
||||||
content = string(s)
|
|
||||||
result += content
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var chatResp openai.ChatCompletionStreamResponse
|
|
||||||
chatResp.Model = chatReq.Model
|
|
||||||
choice := openai.Choice{}
|
|
||||||
choice.Delta.Role = resp.Candidates[0].Content.Role
|
|
||||||
choice.Delta.Content = content
|
|
||||||
chatResp.Choices = append(chatResp.Choices, choice)
|
|
||||||
|
|
||||||
chunk := "data: " + string(chatResp.ByteJson()) + "\n\n"
|
|
||||||
datachan <- chunk
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
|
||||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
|
||||||
c.Writer.Header().Set("Connection", "keep-alive")
|
|
||||||
c.Writer.Header().Set("Transfer-Encoding", "chunked")
|
|
||||||
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
|
||||||
|
|
||||||
c.Stream(func(w io.Writer) bool {
|
|
||||||
if data, ok := <-datachan; ok {
|
|
||||||
if strings.HasPrefix(data, "data: ") {
|
|
||||||
c.Writer.WriteString(data)
|
|
||||||
// c.Writer.WriteString("\n\n")
|
|
||||||
} else {
|
|
||||||
c.Writer.WriteHeader(http.StatusBadGateway)
|
|
||||||
c.Writer.WriteString(data)
|
|
||||||
}
|
|
||||||
c.Writer.Flush()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
|
|
||||||
}()
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
// https://github.com/google-gemini/api-examples/
|
|
||||||
// https://ai.google.dev/gemini-api/docs/models?hl=zh-cn
|
|
||||||
|
|
||||||
package google
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"opencatd-open/internal/model"
|
|
||||||
"opencatd-open/llm"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/sashabaranov/go-openai"
|
|
||||||
|
|
||||||
"google.golang.org/genai"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Gemini struct {
|
|
||||||
Ctx context.Context
|
|
||||||
Client *genai.Client
|
|
||||||
ApiKey *model.ApiKey
|
|
||||||
tokenUsage *llm.TokenUsage
|
|
||||||
|
|
||||||
Done chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewGemini(ctx context.Context, apiKey *model.ApiKey) (*Gemini, error) {
|
|
||||||
hc := http.DefaultClient
|
|
||||||
if os.Getenv("LOCAL_PROXY") != "" {
|
|
||||||
proxyUrl, err := url.Parse(os.Getenv("LOCAL_PROXY"))
|
|
||||||
if err == nil {
|
|
||||||
hc = &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyUrl)}}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
client, err := genai.NewClient(ctx, &genai.ClientConfig{
|
|
||||||
APIKey: *apiKey.ApiKey,
|
|
||||||
Backend: genai.BackendGeminiAPI,
|
|
||||||
HTTPClient: hc,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Gemini{
|
|
||||||
Ctx: context.Background(),
|
|
||||||
Client: client,
|
|
||||||
ApiKey: apiKey,
|
|
||||||
tokenUsage: &llm.TokenUsage{},
|
|
||||||
Done: make(chan struct{}),
|
|
||||||
}, nil
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *Gemini) Chat(ctx context.Context, chatReq llm.ChatRequest) (*llm.ChatResponse, error) {
|
|
||||||
var content []*genai.Content
|
|
||||||
if len(chatReq.Messages) > 0 {
|
|
||||||
for _, msg := range chatReq.Messages {
|
|
||||||
var role genai.Role
|
|
||||||
if msg.Role == "user" || msg.Role == "system" {
|
|
||||||
role = genai.RoleUser
|
|
||||||
} else {
|
|
||||||
role = genai.RoleModel
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(msg.MultiContent) > 0 {
|
|
||||||
for _, c := range msg.MultiContent {
|
|
||||||
var parts []*genai.Part
|
|
||||||
|
|
||||||
if c.Type == "text" {
|
|
||||||
parts = append(parts, genai.NewPartFromText(c.Text))
|
|
||||||
}
|
|
||||||
if c.Type == "image_url" {
|
|
||||||
if strings.HasPrefix(c.ImageURL.URL, "http") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(c.ImageURL.URL, "data:image") {
|
|
||||||
var mediaType string
|
|
||||||
if strings.HasPrefix(c.ImageURL.URL, "data:image/jpeg") {
|
|
||||||
mediaType = "image/jpeg"
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(c.ImageURL.URL, "data:image/png") {
|
|
||||||
mediaType = "image/png"
|
|
||||||
}
|
|
||||||
imageString := strings.Split(c.ImageURL.URL, ",")[1]
|
|
||||||
imageBytes, _ := base64.StdEncoding.DecodeString(imageString)
|
|
||||||
|
|
||||||
parts = append(parts, genai.NewPartFromBytes(imageBytes, mediaType))
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
content = append(content, genai.NewContentFromParts(parts, role))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
content = append(content, genai.NewContentFromText(msg.Content, role))
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tools := []*genai.Tool{{GoogleSearch: &genai.GoogleSearch{}}}
|
|
||||||
response, err := g.Client.Models.GenerateContent(g.Ctx,
|
|
||||||
chatReq.Model,
|
|
||||||
content,
|
|
||||||
&genai.GenerateContentConfig{Tools: tools})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if g.tokenUsage.Model == "" && response.ModelVersion != "" {
|
|
||||||
g.tokenUsage.Model = response.ModelVersion
|
|
||||||
}
|
|
||||||
if response.UsageMetadata != nil {
|
|
||||||
g.tokenUsage.PromptTokens += int(response.UsageMetadata.PromptTokenCount)
|
|
||||||
g.tokenUsage.CompletionTokens += int(response.UsageMetadata.CandidatesTokenCount)
|
|
||||||
g.tokenUsage.ToolsTokens += int(response.UsageMetadata.ToolUsePromptTokenCount)
|
|
||||||
g.tokenUsage.TotalTokens += int(response.UsageMetadata.TotalTokenCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
// var text string
|
|
||||||
// if response.Candidates != nil && response.Candidates[0].Content != nil {
|
|
||||||
// for _, part := range response.Candidates[0].Content.Parts {
|
|
||||||
// text += part.Text
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
return &llm.ChatResponse{
|
|
||||||
Model: response.ModelVersion,
|
|
||||||
Choices: []openai.ChatCompletionChoice{
|
|
||||||
{
|
|
||||||
Message: openai.ChatCompletionMessage{Content: response.Text(), Role: "assistant"},
|
|
||||||
FinishReason: openai.FinishReason(response.Candidates[0].FinishReason),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Usage: openai.Usage{PromptTokens: g.tokenUsage.PromptTokens + g.tokenUsage.ToolsTokens, CompletionTokens: g.tokenUsage.CompletionTokens, TotalTokens: g.tokenUsage.TotalTokens},
|
|
||||||
}, nil
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *Gemini) StreamChat(ctx context.Context, chatReq llm.ChatRequest) (chan *llm.StreamChatResponse, error) {
|
|
||||||
var contents []*genai.Content
|
|
||||||
if len(chatReq.Messages) > 0 {
|
|
||||||
for _, msg := range chatReq.Messages {
|
|
||||||
var role genai.Role
|
|
||||||
if msg.Role == "user" {
|
|
||||||
role = genai.RoleUser
|
|
||||||
} else {
|
|
||||||
role = genai.RoleModel
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(msg.MultiContent) > 0 {
|
|
||||||
for _, c := range msg.MultiContent {
|
|
||||||
var parts []*genai.Part
|
|
||||||
|
|
||||||
if c.Type == "text" {
|
|
||||||
parts = append(parts, genai.NewPartFromText(c.Text))
|
|
||||||
}
|
|
||||||
if c.Type == "image_url" {
|
|
||||||
if strings.HasPrefix(c.ImageURL.URL, "http") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(c.ImageURL.URL, "data:image") {
|
|
||||||
var mediaType string
|
|
||||||
if strings.HasPrefix(c.ImageURL.URL, "data:image/jpeg") {
|
|
||||||
mediaType = "image/jpeg"
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(c.ImageURL.URL, "data:image/png") {
|
|
||||||
mediaType = "image/png"
|
|
||||||
}
|
|
||||||
imageString := strings.Split(c.ImageURL.URL, ",")[1]
|
|
||||||
imageBytes, _ := base64.StdEncoding.DecodeString(imageString)
|
|
||||||
|
|
||||||
parts = append(parts, genai.NewPartFromBytes(imageBytes, mediaType))
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
contents = append(contents, genai.NewContentFromParts(parts, role))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
contents = append(contents, genai.NewContentFromText(msg.Content, role))
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
datachan := make(chan *llm.StreamChatResponse)
|
|
||||||
var generr error
|
|
||||||
|
|
||||||
tools := []*genai.Tool{{GoogleSearch: &genai.GoogleSearch{}}}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer close(datachan)
|
|
||||||
for result, err := range g.Client.Models.GenerateContentStream(g.Ctx, chatReq.Model, contents, &genai.GenerateContentConfig{Tools: tools}) {
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
generr = err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if result.UsageMetadata != nil {
|
|
||||||
g.tokenUsage.PromptTokens += int(result.UsageMetadata.PromptTokenCount)
|
|
||||||
g.tokenUsage.CompletionTokens += int(result.UsageMetadata.CandidatesTokenCount)
|
|
||||||
g.tokenUsage.ToolsTokens += int(result.UsageMetadata.ToolUsePromptTokenCount)
|
|
||||||
g.tokenUsage.TotalTokens += int(result.UsageMetadata.TotalTokenCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
datachan <- &llm.StreamChatResponse{
|
|
||||||
Model: result.ModelVersion,
|
|
||||||
Choices: []openai.ChatCompletionStreamChoice{
|
|
||||||
{
|
|
||||||
Delta: openai.ChatCompletionStreamChoiceDelta{
|
|
||||||
Role: "assistant",
|
|
||||||
// Content: result.Candidates[0].Content.Parts[0].Text,
|
|
||||||
Content: result.Text(),
|
|
||||||
},
|
|
||||||
FinishReason: openai.FinishReason(result.Candidates[0].FinishReason),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Usage: &openai.Usage{PromptTokens: g.tokenUsage.PromptTokens + g.tokenUsage.ToolsTokens, CompletionTokens: g.tokenUsage.CompletionTokens, TotalTokens: g.tokenUsage.TotalTokens},
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return datachan, generr
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *Gemini) GetTokenUsage() *llm.TokenUsage {
|
|
||||||
return g.tokenUsage
|
|
||||||
}
|
|
||||||
-20
@@ -1,20 +0,0 @@
|
|||||||
package llm
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"opencatd-open/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
type LLM interface {
|
|
||||||
Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error)
|
|
||||||
StreamChat(ctx context.Context, req ChatRequest) (chan *StreamChatResponse, error)
|
|
||||||
GetTokenUsage() *TokenUsage
|
|
||||||
}
|
|
||||||
|
|
||||||
type llm struct {
|
|
||||||
ApiKey *model.ApiKey
|
|
||||||
Usage *TokenUsage
|
|
||||||
tools any // TODO
|
|
||||||
Messages []any // TODO
|
|
||||||
llm LLM
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
package openai
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
// https://learn.microsoft.com/en-us/azure/ai-services/openai/api-version-deprecation#latest-preview-api-releases
|
|
||||||
AzureApiVersion = "2024-10-21"
|
|
||||||
BaseHost = "api.openai.com"
|
|
||||||
OpenAI_Endpoint = "https://api.openai.com/v1/chat/completions"
|
|
||||||
Github_Marketplace = "https://models.inference.ai.azure.com/chat/completions"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
Custom_Endpoint string
|
|
||||||
AIGateWay_Endpoint string // "https://gateway.ai.cloudflare.com/v1/431ba10f11200d544922fbca177aaa7f/openai/openai/chat/completions"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
if os.Getenv("OpenAI_Endpoint") != "" {
|
|
||||||
Custom_Endpoint = os.Getenv("OpenAI_Endpoint")
|
|
||||||
}
|
|
||||||
if os.Getenv("AIGateWay_Endpoint") != "" {
|
|
||||||
AIGateWay_Endpoint = os.Getenv("AIGateWay_Endpoint")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vision Content
|
|
||||||
type VisionContent struct {
|
|
||||||
Type string `json:"type,omitempty"`
|
|
||||||
Text string `json:"text,omitempty"`
|
|
||||||
ImageURL *VisionImageURL `json:"image_url,omitempty"`
|
|
||||||
}
|
|
||||||
type VisionImageURL struct {
|
|
||||||
URL string `json:"url,omitempty"`
|
|
||||||
Detail string `json:"detail,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChatCompletionMessage struct {
|
|
||||||
Role string `json:"role"`
|
|
||||||
Content any `json:"content"`
|
|
||||||
Name string `json:"name,omitempty"`
|
|
||||||
// MultiContent []VisionContent
|
|
||||||
}
|
|
||||||
|
|
||||||
type FunctionDefinition struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
Parameters any `json:"parameters"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Tool struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Function *FunctionDefinition `json:"function,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type StreamOption struct {
|
|
||||||
IncludeUsage bool `json:"include_usage,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChatCompletionRequest struct {
|
|
||||||
Model string `json:"model"`
|
|
||||||
Messages []ChatCompletionMessage `json:"messages"`
|
|
||||||
MaxTokens int `json:"max_tokens,omitempty"`
|
|
||||||
Temperature float64 `json:"temperature,omitempty"`
|
|
||||||
TopP float64 `json:"top_p,omitempty"`
|
|
||||||
N int `json:"n,omitempty"`
|
|
||||||
Stream bool `json:"stream"`
|
|
||||||
Stop []string `json:"stop,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"`
|
|
||||||
// Functions []FunctionDefinition `json:"functions,omitempty"`
|
|
||||||
// FunctionCall any `json:"function_call,omitempty"`
|
|
||||||
Tools []Tool `json:"tools,omitempty"`
|
|
||||||
ParallelToolCalls bool `json:"parallel_tool_calls,omitempty"`
|
|
||||||
// ToolChoice any `json:"tool_choice,omitempty"`
|
|
||||||
StreamOptions *StreamOption `json:"stream_options,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c ChatCompletionRequest) ToByteJson() []byte {
|
|
||||||
bytejson, _ := json.Marshal(c)
|
|
||||||
return bytejson
|
|
||||||
}
|
|
||||||
|
|
||||||
type ToolCall struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Function struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Arguments string `json:"arguments"`
|
|
||||||
} `json:"function"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChatCompletionResponse struct {
|
|
||||||
ID string `json:"id,omitempty"`
|
|
||||||
Object string `json:"object,omitempty"`
|
|
||||||
Created int `json:"created,omitempty"`
|
|
||||||
Model string `json:"model,omitempty"`
|
|
||||||
Choices []struct {
|
|
||||||
Index int `json:"index,omitempty"`
|
|
||||||
Message struct {
|
|
||||||
Role string `json:"role,omitempty"`
|
|
||||||
Content string `json:"content,omitempty"`
|
|
||||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
|
||||||
} `json:"message,omitempty"`
|
|
||||||
Logprobs string `json:"logprobs,omitempty"`
|
|
||||||
FinishReason string `json:"finish_reason,omitempty"`
|
|
||||||
} `json:"choices,omitempty"`
|
|
||||||
Usage struct {
|
|
||||||
PromptTokens int `json:"prompt_tokens,omitempty"`
|
|
||||||
CompletionTokens int `json:"completion_tokens,omitempty"`
|
|
||||||
TotalTokens int `json:"total_tokens,omitempty"`
|
|
||||||
PromptTokensDetails struct {
|
|
||||||
CachedTokens int `json:"cached_tokens,omitempty"`
|
|
||||||
AudioTokens int `json:"audio_tokens,omitempty"`
|
|
||||||
} `json:"prompt_tokens_details,omitempty"`
|
|
||||||
CompletionTokensDetails struct {
|
|
||||||
ReasoningTokens int `json:"reasoning_tokens,omitempty"`
|
|
||||||
AudioTokens int `json:"audio_tokens,omitempty"`
|
|
||||||
AcceptedPredictionTokens int `json:"accepted_prediction_tokens,omitempty"`
|
|
||||||
RejectedPredictionTokens int `json:"rejected_prediction_tokens,omitempty"`
|
|
||||||
} `json:"completion_tokens_details,omitempty"`
|
|
||||||
} `json:"usage,omitempty"`
|
|
||||||
SystemFingerprint string `json:"system_fingerprint,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Choice struct {
|
|
||||||
Index int `json:"index"`
|
|
||||||
Delta struct {
|
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
ToolCalls []ToolCall `json:"tool_calls"`
|
|
||||||
} `json:"delta"`
|
|
||||||
FinishReason string `json:"finish_reason"`
|
|
||||||
Usage struct {
|
|
||||||
PromptTokens int `json:"prompt_tokens"`
|
|
||||||
CompletionTokens int `json:"completion_tokens"`
|
|
||||||
TotalTokens int `json:"total_tokens"`
|
|
||||||
} `json:"usage"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChatCompletionStreamResponse struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Object string `json:"object"`
|
|
||||||
Created int `json:"created"`
|
|
||||||
Model string `json:"model"`
|
|
||||||
Choices []Choice `json:"choices"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *ChatCompletionStreamResponse) ByteJson() []byte {
|
|
||||||
bytejson, _ := json.Marshal(c)
|
|
||||||
return bytejson
|
|
||||||
}
|
|
||||||
|
|
||||||
func modelmap(in string) string {
|
|
||||||
// gpt-3.5-turbo -> gpt-35-turbo
|
|
||||||
if strings.Contains(in, ".") {
|
|
||||||
return strings.ReplaceAll(in, ".", "")
|
|
||||||
}
|
|
||||||
return in
|
|
||||||
}
|
|
||||||
|
|
||||||
type ErrResponse struct {
|
|
||||||
Error struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
Code string `json:"code"`
|
|
||||||
} `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ErrResponse) ByteJson() []byte {
|
|
||||||
bytejson, _ := json.Marshal(e)
|
|
||||||
return bytejson
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
package openai
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httputil"
|
|
||||||
"net/url"
|
|
||||||
"opencatd-open/pkg/tokenizer"
|
|
||||||
"opencatd-open/store"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/duke-git/lancet/v2/slice"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
DalleEndpoint = "https://api.openai.com/v1/images/generations"
|
|
||||||
DalleEditEndpoint = "https://api.openai.com/v1/images/edits"
|
|
||||||
DalleVariationEndpoint = "https://api.openai.com/v1/images/variations"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DallERequest struct {
|
|
||||||
Model string `json:"model"`
|
|
||||||
Prompt string `json:"prompt"`
|
|
||||||
N int `form:"n" json:"n,omitempty"`
|
|
||||||
Size string `form:"size" json:"size,omitempty"`
|
|
||||||
Quality string `json:"quality,omitempty"` // standard,hd
|
|
||||||
Style string `json:"style,omitempty"` // vivid,natural
|
|
||||||
ResponseFormat string `json:"response_format,omitempty"` // url or b64_json
|
|
||||||
}
|
|
||||||
|
|
||||||
func DallEProxy(c *gin.Context) {
|
|
||||||
|
|
||||||
var dalleRequest DallERequest
|
|
||||||
if err := c.ShouldBind(&dalleRequest); err != nil {
|
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if dalleRequest.N == 0 {
|
|
||||||
dalleRequest.N = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if dalleRequest.Size == "" {
|
|
||||||
dalleRequest.Size = "512x512"
|
|
||||||
}
|
|
||||||
|
|
||||||
model := dalleRequest.Model
|
|
||||||
|
|
||||||
var chatlog store.Tokens
|
|
||||||
chatlog.CompletionCount = dalleRequest.N
|
|
||||||
|
|
||||||
if model == "dall-e" {
|
|
||||||
model = "dall-e-2"
|
|
||||||
}
|
|
||||||
model = model + "." + dalleRequest.Size
|
|
||||||
|
|
||||||
if dalleRequest.Model == "dall-e-2" || dalleRequest.Model == "dall-e" {
|
|
||||||
if !slice.Contain([]string{"256x256", "512x512", "1024x1024"}, dalleRequest.Size) {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
|
||||||
"error": gin.H{
|
|
||||||
"message": fmt.Sprintf("Invalid size: %s for %s", dalleRequest.Size, dalleRequest.Model),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else if dalleRequest.Model == "dall-e-3" {
|
|
||||||
if !slice.Contain([]string{"256x256", "512x512", "1024x1024", "1792x1024", "1024x1792"}, dalleRequest.Size) {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
|
||||||
"error": gin.H{
|
|
||||||
"message": fmt.Sprintf("Invalid size: %s for %s", dalleRequest.Size, dalleRequest.Model),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if dalleRequest.Quality == "hd" {
|
|
||||||
model = model + ".hd"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{
|
|
||||||
"error": gin.H{
|
|
||||||
"message": fmt.Sprintf("Invalid model: %s", dalleRequest.Model),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
chatlog.Model = model
|
|
||||||
|
|
||||||
token, _ := c.Get("localuser")
|
|
||||||
|
|
||||||
lu, err := store.GetUserByToken(token.(string))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": gin.H{
|
|
||||||
"message": err.Error(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
chatlog.UserID = int(lu.ID)
|
|
||||||
|
|
||||||
key, err := store.SelectKeyCache("openai")
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": gin.H{
|
|
||||||
"message": err.Error(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
targetURL, _ := url.Parse(DalleEndpoint)
|
|
||||||
proxy := httputil.NewSingleHostReverseProxy(targetURL)
|
|
||||||
proxy.Director = func(req *http.Request) {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+key.Key)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
req.Host = targetURL.Host
|
|
||||||
req.URL.Scheme = targetURL.Scheme
|
|
||||||
req.URL.Host = targetURL.Host
|
|
||||||
req.URL.Path = targetURL.Path
|
|
||||||
req.URL.RawPath = targetURL.RawPath
|
|
||||||
req.URL.RawQuery = targetURL.RawQuery
|
|
||||||
|
|
||||||
bytebody, _ := json.Marshal(dalleRequest)
|
|
||||||
req.Body = io.NopCloser(bytes.NewBuffer(bytebody))
|
|
||||||
req.ContentLength = int64(len(bytebody))
|
|
||||||
req.Header.Set("Content-Length", strconv.Itoa(len(bytebody)))
|
|
||||||
}
|
|
||||||
|
|
||||||
proxy.ModifyResponse = func(resp *http.Response) error {
|
|
||||||
if resp.StatusCode == http.StatusOK {
|
|
||||||
chatlog.TotalTokens = chatlog.PromptCount + chatlog.CompletionCount
|
|
||||||
chatlog.Cost = fmt.Sprintf("%.6f", tokenizer.Cost(chatlog.Model, chatlog.PromptCount, chatlog.CompletionCount))
|
|
||||||
if err := store.Record(&chatlog); err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
}
|
|
||||||
if err := store.SumDaily(chatlog.UserID); err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
proxy.ServeHTTP(c.Writer, c.Request)
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
package openai
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"opencatd-open/pkg/tokenizer"
|
|
||||||
"opencatd-open/store"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ChatProxy(c *gin.Context, chatReq *ChatCompletionRequest) {
|
|
||||||
usagelog := store.Tokens{Model: chatReq.Model}
|
|
||||||
|
|
||||||
token, _ := c.Get("localuser")
|
|
||||||
|
|
||||||
lu, err := store.GetUserByToken(token.(string))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": gin.H{
|
|
||||||
"message": err.Error(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
usagelog.UserID = int(lu.ID)
|
|
||||||
|
|
||||||
var prompt string
|
|
||||||
for _, msg := range chatReq.Messages {
|
|
||||||
switch ct := msg.Content.(type) {
|
|
||||||
case string:
|
|
||||||
prompt += "<" + msg.Role + ">: " + msg.Content.(string) + "\n"
|
|
||||||
case []any:
|
|
||||||
for _, item := range ct {
|
|
||||||
if m, ok := item.(map[string]interface{}); ok {
|
|
||||||
if m["type"] == "text" {
|
|
||||||
prompt += "<" + msg.Role + ">: " + m["text"].(string) + "\n"
|
|
||||||
} else if m["type"] == "image_url" {
|
|
||||||
if url, ok := m["image_url"].(map[string]interface{}); ok {
|
|
||||||
fmt.Printf(" URL: %v\n", url["url"])
|
|
||||||
if strings.HasPrefix(url["url"].(string), "http") {
|
|
||||||
fmt.Println("网络图片:", url["url"].(string))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": gin.H{
|
|
||||||
"message": "Invalid content type",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(chatReq.Tools) > 0 {
|
|
||||||
tooljson, _ := json.Marshal(chatReq.Tools)
|
|
||||||
prompt += "<tools>: " + string(tooljson) + "\n"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch chatReq.Model {
|
|
||||||
case "gpt-4o", "gpt-4o-mini", "chatgpt-4o-latest":
|
|
||||||
chatReq.MaxTokens = 16384
|
|
||||||
}
|
|
||||||
if chatReq.Stream {
|
|
||||||
chatReq.StreamOptions = &StreamOption{IncludeUsage: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
usagelog.PromptCount = tokenizer.NumTokensFromStr(prompt, chatReq.Model)
|
|
||||||
|
|
||||||
// onekey, err := store.SelectKeyCache("openai")
|
|
||||||
onekey, err := store.SelectKeyCacheByModel(chatReq.Model)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req *http.Request
|
|
||||||
|
|
||||||
switch onekey.ApiType {
|
|
||||||
case "github":
|
|
||||||
req, err = http.NewRequest(c.Request.Method, Github_Marketplace, bytes.NewReader(chatReq.ToByteJson()))
|
|
||||||
req.Header = c.Request.Header
|
|
||||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", onekey.Key))
|
|
||||||
case "azure":
|
|
||||||
var buildurl string
|
|
||||||
if onekey.EndPoint != "" {
|
|
||||||
buildurl = fmt.Sprintf("%s/openai/deployments/%s/chat/completions?api-version=%s", onekey.EndPoint, modelmap(chatReq.Model), AzureApiVersion)
|
|
||||||
} else {
|
|
||||||
buildurl = fmt.Sprintf("https://%s.openai.azure.com/openai/deployments/%s/chat/completions?api-version=%s", onekey.ResourceNmae, modelmap(chatReq.Model), AzureApiVersion)
|
|
||||||
}
|
|
||||||
req, err = http.NewRequest(c.Request.Method, buildurl, bytes.NewReader(chatReq.ToByteJson()))
|
|
||||||
req.Header = c.Request.Header
|
|
||||||
req.Header.Set("api-key", onekey.Key)
|
|
||||||
default:
|
|
||||||
req, err = http.NewRequest(c.Request.Method, OpenAI_Endpoint, bytes.NewReader(chatReq.ToByteJson())) // default endpoint
|
|
||||||
|
|
||||||
if AIGateWay_Endpoint != "" { // cloudflare gateway的endpoint
|
|
||||||
req, err = http.NewRequest(c.Request.Method, AIGateWay_Endpoint, bytes.NewReader(chatReq.ToByteJson()))
|
|
||||||
}
|
|
||||||
if Custom_Endpoint != "" { // 自定义endpoint
|
|
||||||
req, err = http.NewRequest(c.Request.Method, Custom_Endpoint, bytes.NewReader(chatReq.ToByteJson()))
|
|
||||||
}
|
|
||||||
if onekey.EndPoint != "" { // 优先key的endpoint
|
|
||||||
req, err = http.NewRequest(c.Request.Method, onekey.EndPoint+c.Request.RequestURI, bytes.NewReader(chatReq.ToByteJson()))
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header = c.Request.Header
|
|
||||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", onekey.Key))
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var result string
|
|
||||||
if chatReq.Stream {
|
|
||||||
for key, value := range resp.Header {
|
|
||||||
for _, v := range value {
|
|
||||||
c.Writer.Header().Add(key, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.Writer.WriteHeader(resp.StatusCode)
|
|
||||||
teeReader := io.TeeReader(resp.Body, c.Writer)
|
|
||||||
// 流式响应
|
|
||||||
scanner := bufio.NewScanner(teeReader)
|
|
||||||
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Bytes()
|
|
||||||
if len(line) > 0 && bytes.HasPrefix(line, []byte("data: ")) {
|
|
||||||
if bytes.HasPrefix(line, []byte("data: [DONE]")) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
var opiResp ChatCompletionStreamResponse
|
|
||||||
line = bytes.Replace(line, []byte("data: "), []byte(""), -1)
|
|
||||||
line = bytes.TrimSpace(line)
|
|
||||||
if err := json.Unmarshal(line, &opiResp); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if opiResp.Choices != nil && len(opiResp.Choices) > 0 {
|
|
||||||
if opiResp.Choices[0].Delta.Role != "" {
|
|
||||||
result += "<" + opiResp.Choices[0].Delta.Role + "> "
|
|
||||||
}
|
|
||||||
result += opiResp.Choices[0].Delta.Content // 计算Content Token
|
|
||||||
|
|
||||||
if len(opiResp.Choices[0].Delta.ToolCalls) > 0 { // 计算ToolCalls token
|
|
||||||
if opiResp.Choices[0].Delta.ToolCalls[0].Function.Name != "" {
|
|
||||||
result += "name:" + opiResp.Choices[0].Delta.ToolCalls[0].Function.Name + " arguments:"
|
|
||||||
}
|
|
||||||
result += opiResp.Choices[0].Delta.ToolCalls[0].Function.Arguments
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
|
|
||||||
// 处理非流式响应
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("Error reading response body:", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var opiResp ChatCompletionResponse
|
|
||||||
if err := json.Unmarshal(body, &opiResp); err != nil {
|
|
||||||
log.Println("Error parsing JSON:", err)
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if opiResp.Choices != nil && len(opiResp.Choices) > 0 {
|
|
||||||
if opiResp.Choices[0].Message.Role != "" {
|
|
||||||
result += "<" + opiResp.Choices[0].Message.Role + "> "
|
|
||||||
}
|
|
||||||
result += opiResp.Choices[0].Message.Content
|
|
||||||
|
|
||||||
if len(opiResp.Choices[0].Message.ToolCalls) > 0 {
|
|
||||||
if opiResp.Choices[0].Message.ToolCalls[0].Function.Name != "" {
|
|
||||||
result += "name:" + opiResp.Choices[0].Message.ToolCalls[0].Function.Name + " arguments:"
|
|
||||||
}
|
|
||||||
result += opiResp.Choices[0].Message.ToolCalls[0].Function.Arguments
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
for k, v := range resp.Header {
|
|
||||||
c.Writer.Header().Set(k, v[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, opiResp)
|
|
||||||
}
|
|
||||||
usagelog.CompletionCount = tokenizer.NumTokensFromStr(result, chatReq.Model)
|
|
||||||
usagelog.Cost = fmt.Sprintf("%.6f", tokenizer.Cost(usagelog.Model, usagelog.PromptCount, usagelog.CompletionCount))
|
|
||||||
if err := store.Record(&usagelog); err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
}
|
|
||||||
if err := store.SumDaily(usagelog.UserID); err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
/*
|
|
||||||
https://platform.openai.com/docs/guides/realtime
|
|
||||||
https://learn.microsoft.com/zh-cn/azure/ai-services/openai/how-to/audio-real-time
|
|
||||||
|
|
||||||
wss://my-eastus2-openai-resource.openai.azure.com/openai/realtime?api-version=2024-10-01-preview&deployment=gpt-4o-realtime-preview-1001
|
|
||||||
*/
|
|
||||||
package openai
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"opencatd-open/pkg/tokenizer"
|
|
||||||
"opencatd-open/store"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/gorilla/websocket"
|
|
||||||
"golang.org/x/sync/errgroup"
|
|
||||||
)
|
|
||||||
|
|
||||||
// "wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-10-01"
|
|
||||||
const realtimeURL = "wss://api.openai.com/v1/realtime"
|
|
||||||
const azureRealtimeURL = "wss://%s.openai.azure.com/openai/realtime?api-version=2024-10-01-preview&deployment=gpt-4o-realtime-preview"
|
|
||||||
|
|
||||||
var upgrader = websocket.Upgrader{
|
|
||||||
CheckOrigin: func(r *http.Request) bool {
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
type Message struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Response Response `json:"response"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Response struct {
|
|
||||||
Modalities []string `json:"modalities"`
|
|
||||||
Instructions string `json:"instructions"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type RealTimeResponse struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
EventID string `json:"event_id"`
|
|
||||||
Response struct {
|
|
||||||
Object string `json:"object"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
StatusDetails any `json:"status_details"`
|
|
||||||
Output []struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Object string `json:"object"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
Content []struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Transcript string `json:"transcript"`
|
|
||||||
} `json:"content"`
|
|
||||||
} `json:"output"`
|
|
||||||
Usage Usage `json:"usage"`
|
|
||||||
} `json:"response"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Usage struct {
|
|
||||||
TotalTokens int `json:"total_tokens"`
|
|
||||||
InputTokens int `json:"input_tokens"`
|
|
||||||
OutputTokens int `json:"output_tokens"`
|
|
||||||
InputTokenDetails struct {
|
|
||||||
CachedTokens int `json:"cached_tokens"`
|
|
||||||
TextTokens int `json:"text_tokens"`
|
|
||||||
AudioTokens int `json:"audio_tokens"`
|
|
||||||
} `json:"input_token_details"`
|
|
||||||
OutputTokenDetails struct {
|
|
||||||
TextTokens int `json:"text_tokens"`
|
|
||||||
AudioTokens int `json:"audio_tokens"`
|
|
||||||
} `json:"output_token_details"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func RealTimeProxy(c *gin.Context) {
|
|
||||||
log.Println(c.Request.URL.String())
|
|
||||||
var model string = c.Query("model")
|
|
||||||
value := url.Values{}
|
|
||||||
value.Add("model", model)
|
|
||||||
realtimeURL := realtimeURL + "?" + value.Encode()
|
|
||||||
|
|
||||||
// 升级 HTTP 连接为 WebSocket
|
|
||||||
clientConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("Upgrade error:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer clientConn.Close()
|
|
||||||
|
|
||||||
apikey, err := store.SelectKeyCacheByModel(model)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 连接到 OpenAI WebSocket
|
|
||||||
headers := http.Header{"OpenAI-Beta": []string{"realtime=v1"}}
|
|
||||||
|
|
||||||
if apikey.ApiType == "azure" {
|
|
||||||
headers.Set("api-key", apikey.Key)
|
|
||||||
if apikey.EndPoint != "" {
|
|
||||||
realtimeURL = fmt.Sprintf("%s/openai/realtime?api-version=2024-10-01-preview&deployment=gpt-4o-realtime-preview", apikey.EndPoint)
|
|
||||||
} else {
|
|
||||||
realtimeURL = fmt.Sprintf(azureRealtimeURL, apikey.ResourceNmae)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
headers.Set("Authorization", "Bearer "+apikey.Key)
|
|
||||||
}
|
|
||||||
|
|
||||||
conn := websocket.DefaultDialer
|
|
||||||
if os.Getenv("LOCAL_PROXY") != "" {
|
|
||||||
proxyUrl, _ := url.Parse(os.Getenv("LOCAL_PROXY"))
|
|
||||||
conn.Proxy = http.ProxyURL(proxyUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
openAIConn, _, err := conn.Dial(realtimeURL, headers)
|
|
||||||
if err != nil {
|
|
||||||
log.Println("OpenAI dial error:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer openAIConn.Close()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(c.Request.Context())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
g, ctx := errgroup.WithContext(ctx)
|
|
||||||
|
|
||||||
g.Go(func() error {
|
|
||||||
return forwardMessages(ctx, c, clientConn, openAIConn)
|
|
||||||
})
|
|
||||||
|
|
||||||
g.Go(func() error {
|
|
||||||
return forwardMessages(ctx, c, openAIConn, clientConn)
|
|
||||||
})
|
|
||||||
|
|
||||||
if err := g.Wait(); err != nil {
|
|
||||||
log.Println("Error in message forwarding:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func forwardMessages(ctx context.Context, c *gin.Context, src, dst *websocket.Conn) error {
|
|
||||||
usagelog := store.Tokens{Model: "gpt-4o-realtime-preview"}
|
|
||||||
|
|
||||||
token, _ := c.Get("localuser")
|
|
||||||
|
|
||||||
lu, err := store.GetUserByToken(token.(string))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
usagelog.UserID = int(lu.ID)
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
default:
|
|
||||||
messageType, message, err := src.ReadMessage()
|
|
||||||
if err != nil {
|
|
||||||
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
|
||||||
return nil // 正常关闭,不报错
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if messageType == websocket.TextMessage {
|
|
||||||
var usage Usage
|
|
||||||
err := json.Unmarshal(message, &usage)
|
|
||||||
if err == nil {
|
|
||||||
usagelog.PromptCount += usage.InputTokens
|
|
||||||
usagelog.CompletionCount += usage.OutputTokens
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
err = dst.WriteMessage(messageType, message)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
usagelog.Cost = fmt.Sprintf("%.6f", tokenizer.Cost(usagelog.Model, usagelog.PromptCount, usagelog.CompletionCount))
|
|
||||||
if err := store.Record(&usagelog); err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
}
|
|
||||||
if err := store.SumDaily(usagelog.UserID); err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
package openai
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httputil"
|
|
||||||
"net/url"
|
|
||||||
"opencatd-open/pkg/tokenizer"
|
|
||||||
"opencatd-open/store"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
SpeechEndpoint = "https://api.openai.com/v1/audio/speech"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SpeechRequest struct {
|
|
||||||
Model string `json:"model"`
|
|
||||||
Input string `json:"input"`
|
|
||||||
Voice string `json:"voice"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func SpeechProxy(c *gin.Context) {
|
|
||||||
var chatreq SpeechRequest
|
|
||||||
if err := c.ShouldBindJSON(&chatreq); err != nil {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var chatlog store.Tokens
|
|
||||||
chatlog.Model = chatreq.Model
|
|
||||||
chatlog.CompletionCount = len(chatreq.Input)
|
|
||||||
|
|
||||||
token, _ := c.Get("localuser")
|
|
||||||
|
|
||||||
lu, err := store.GetUserByToken(token.(string))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": gin.H{
|
|
||||||
"message": err.Error(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
chatlog.UserID = int(lu.ID)
|
|
||||||
|
|
||||||
key, err := store.SelectKeyCache("openai")
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": gin.H{
|
|
||||||
"message": err.Error(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
targetURL, _ := url.Parse(SpeechEndpoint)
|
|
||||||
proxy := httputil.NewSingleHostReverseProxy(targetURL)
|
|
||||||
|
|
||||||
proxy.Director = func(req *http.Request) {
|
|
||||||
req.Header = c.Request.Header
|
|
||||||
req.Header["Authorization"] = []string{"Bearer " + key.Key}
|
|
||||||
req.Host = targetURL.Host
|
|
||||||
req.URL.Scheme = targetURL.Scheme
|
|
||||||
req.URL.Host = targetURL.Host
|
|
||||||
req.URL.Path = targetURL.Path
|
|
||||||
req.URL.RawPath = targetURL.RawPath
|
|
||||||
|
|
||||||
reqBytes, _ := json.Marshal(chatreq)
|
|
||||||
req.Body = io.NopCloser(bytes.NewReader(reqBytes))
|
|
||||||
req.ContentLength = int64(len(reqBytes))
|
|
||||||
|
|
||||||
}
|
|
||||||
proxy.ModifyResponse = func(resp *http.Response) error {
|
|
||||||
if resp.StatusCode == http.StatusOK {
|
|
||||||
chatlog.TotalTokens = chatlog.PromptCount + chatlog.CompletionCount
|
|
||||||
chatlog.Cost = fmt.Sprintf("%.6f", tokenizer.Cost(chatlog.Model, chatlog.PromptCount, chatlog.CompletionCount))
|
|
||||||
if err := store.Record(&chatlog); err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
}
|
|
||||||
if err := store.SumDaily(chatlog.UserID); err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
proxy.ServeHTTP(c.Writer, c.Request)
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
package openai
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"mime/multipart"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httputil"
|
|
||||||
"net/url"
|
|
||||||
"opencatd-open/pkg/tokenizer"
|
|
||||||
"opencatd-open/store"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/faiface/beep"
|
|
||||||
"github.com/faiface/beep/mp3"
|
|
||||||
"github.com/faiface/beep/wav"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"gopkg.in/vansante/go-ffprobe.v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
func WhisperProxy(c *gin.Context) {
|
|
||||||
var chatlog store.Tokens
|
|
||||||
|
|
||||||
byteBody, _ := io.ReadAll(c.Request.Body)
|
|
||||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(byteBody))
|
|
||||||
|
|
||||||
model, _ := c.GetPostForm("model")
|
|
||||||
|
|
||||||
key, err := store.SelectKeyCache("openai")
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": gin.H{
|
|
||||||
"message": err.Error(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
chatlog.Model = model
|
|
||||||
|
|
||||||
token, _ := c.Get("localuser")
|
|
||||||
|
|
||||||
lu, err := store.GetUserByToken(token.(string))
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": gin.H{
|
|
||||||
"message": err.Error(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
chatlog.UserID = int(lu.ID)
|
|
||||||
|
|
||||||
if err := ParseWhisperRequestTokens(c, &chatlog, byteBody); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"error": gin.H{
|
|
||||||
"message": err.Error(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if key.EndPoint == "" {
|
|
||||||
key.EndPoint = "https://api.openai.com"
|
|
||||||
}
|
|
||||||
targetUrl, _ := url.ParseRequestURI(key.EndPoint + c.Request.URL.String())
|
|
||||||
log.Println(targetUrl)
|
|
||||||
proxy := httputil.NewSingleHostReverseProxy(targetUrl)
|
|
||||||
proxy.Director = func(req *http.Request) {
|
|
||||||
req.Host = targetUrl.Host
|
|
||||||
req.URL.Scheme = targetUrl.Scheme
|
|
||||||
req.URL.Host = targetUrl.Host
|
|
||||||
|
|
||||||
req.Header.Set("Authorization", "Bearer "+key.Key)
|
|
||||||
}
|
|
||||||
|
|
||||||
proxy.ModifyResponse = func(resp *http.Response) error {
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
chatlog.TotalTokens = chatlog.PromptCount + chatlog.CompletionCount
|
|
||||||
chatlog.Cost = fmt.Sprintf("%.6f", tokenizer.Cost(chatlog.Model, chatlog.PromptCount, chatlog.CompletionCount))
|
|
||||||
if err := store.Record(&chatlog); err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
}
|
|
||||||
if err := store.SumDaily(chatlog.UserID); err != nil {
|
|
||||||
log.Println(err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
proxy.ServeHTTP(c.Writer, c.Request)
|
|
||||||
}
|
|
||||||
|
|
||||||
func probe(fileReader io.Reader) (time.Duration, error) {
|
|
||||||
ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancelFn()
|
|
||||||
|
|
||||||
data, err := ffprobe.ProbeReader(ctx, fileReader)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
duration := data.Format.DurationSeconds
|
|
||||||
pduration, err := time.ParseDuration(fmt.Sprintf("%fs", duration))
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("Error parsing duration: %s", err)
|
|
||||||
}
|
|
||||||
return pduration, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAudioDuration(file *multipart.FileHeader) (time.Duration, error) {
|
|
||||||
var (
|
|
||||||
streamer beep.StreamSeekCloser
|
|
||||||
format beep.Format
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
|
|
||||||
f, err := file.Open()
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
// Get the file extension to determine the audio file type
|
|
||||||
fileType := filepath.Ext(file.Filename)
|
|
||||||
|
|
||||||
switch fileType {
|
|
||||||
case ".mp3":
|
|
||||||
streamer, format, err = mp3.Decode(f)
|
|
||||||
case ".wav":
|
|
||||||
streamer, format, err = wav.Decode(f)
|
|
||||||
case ".m4a":
|
|
||||||
duration, err := probe(f)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return duration, nil
|
|
||||||
default:
|
|
||||||
return 0, errors.New("unsupported audio file format")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer streamer.Close()
|
|
||||||
|
|
||||||
// Calculate the audio file's duration.
|
|
||||||
numSamples := streamer.Len()
|
|
||||||
sampleRate := format.SampleRate
|
|
||||||
duration := time.Duration(numSamples) * time.Second / time.Duration(sampleRate)
|
|
||||||
|
|
||||||
return duration, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ParseWhisperRequestTokens(c *gin.Context, usage *store.Tokens, byteBody []byte) error {
|
|
||||||
file, _ := c.FormFile("file")
|
|
||||||
model, _ := c.GetPostForm("model")
|
|
||||||
usage.Model = model
|
|
||||||
|
|
||||||
if file != nil {
|
|
||||||
duration, err := getAudioDuration(file)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Error getting audio duration:%s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if duration > 5*time.Minute {
|
|
||||||
return fmt.Errorf("Audio duration exceeds 5 minutes")
|
|
||||||
}
|
|
||||||
// 计算时长,四舍五入到最接近的秒数
|
|
||||||
usage.PromptCount = int(duration.Round(time.Second).Seconds())
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(byteBody))
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
package openai_compatible
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"opencatd-open/internal/model"
|
|
||||||
"opencatd-open/internal/utils"
|
|
||||||
"opencatd-open/llm"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/sashabaranov/go-openai"
|
|
||||||
)
|
|
||||||
|
|
||||||
// https://learn.microsoft.com/en-us/azure/ai-services/openai/api-version-deprecation#latest-preview-api-releases
|
|
||||||
const AzureApiVersion = "2024-10-21"
|
|
||||||
const defaultOpenAICompatibleEndpoint = "https://api.openai.com/v1/chat/completions"
|
|
||||||
const Github_Marketplace = "https://models.inference.ai.azure.com/chat/completions"
|
|
||||||
|
|
||||||
type OpenAICompatible struct {
|
|
||||||
Client *http.Client
|
|
||||||
ApiKey *model.ApiKey
|
|
||||||
tokenUsage *llm.TokenUsage
|
|
||||||
Params map[string]interface{}
|
|
||||||
Done chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewOpenAICompatible(apikey *model.ApiKey) (*OpenAICompatible, error) {
|
|
||||||
hc := 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),
|
|
||||||
}
|
|
||||||
hc.Transport = &tr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
oc := OpenAICompatible{
|
|
||||||
ApiKey: apikey,
|
|
||||||
Client: hc,
|
|
||||||
tokenUsage: &llm.TokenUsage{},
|
|
||||||
Done: make(chan struct{}),
|
|
||||||
}
|
|
||||||
|
|
||||||
if apikey.Parameters != nil {
|
|
||||||
var params map[string]interface{}
|
|
||||||
err := json.Unmarshal([]byte(*apikey.Parameters), ¶ms)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
oc.Params = params
|
|
||||||
}
|
|
||||||
|
|
||||||
return &oc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *OpenAICompatible) Chat(ctx context.Context, chatReq llm.ChatRequest) (*llm.ChatResponse, error) {
|
|
||||||
chatReq.Stream = false
|
|
||||||
dst, err := utils.StructToMap(chatReq)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(o.Params) > 0 {
|
|
||||||
dst = utils.MergeJSONObjects(dst, o.Params)
|
|
||||||
}
|
|
||||||
|
|
||||||
var reqBody bytes.Buffer
|
|
||||||
if err := json.NewEncoder(&reqBody).Encode(dst); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var req *http.Request
|
|
||||||
switch *o.ApiKey.ApiType {
|
|
||||||
case "azure":
|
|
||||||
formatModel := func(in string) string {
|
|
||||||
if strings.Contains(in, ".") {
|
|
||||||
return strings.ReplaceAll(in, ".", "")
|
|
||||||
}
|
|
||||||
return in
|
|
||||||
}
|
|
||||||
var buildurl string
|
|
||||||
if *o.ApiKey.Endpoint != "" {
|
|
||||||
if strings.HasSuffix(*o.ApiKey.Endpoint, "/") {
|
|
||||||
o.ApiKey.ApiKey = utils.ToPtr(strings.TrimSuffix(*o.ApiKey.Endpoint, "/"))
|
|
||||||
}
|
|
||||||
buildurl = fmt.Sprintf("%s/openai/deployments/%s/chat/completions?api-version=%s", *o.ApiKey.Endpoint, formatModel(chatReq.Model), AzureApiVersion)
|
|
||||||
} else {
|
|
||||||
buildurl = fmt.Sprintf("https://%s.openai.azure.com/openai/deployments/%s/chat/completions?api-version=%s", *o.ApiKey.ResourceNmae, formatModel(chatReq.Model), AzureApiVersion)
|
|
||||||
}
|
|
||||||
req, _ = http.NewRequest(http.MethodPost, buildurl, &reqBody)
|
|
||||||
req.Header.Set("api-key", *o.ApiKey.ApiKey)
|
|
||||||
case "github":
|
|
||||||
req, _ = http.NewRequest(http.MethodPost, Github_Marketplace, &reqBody)
|
|
||||||
default:
|
|
||||||
if o.ApiKey.Endpoint == nil || *o.ApiKey.Endpoint == "" {
|
|
||||||
req, _ = http.NewRequest(http.MethodPost, defaultOpenAICompatibleEndpoint, &reqBody)
|
|
||||||
} else {
|
|
||||||
req, _ = http.NewRequest(http.MethodPost, *o.ApiKey.Endpoint, &reqBody)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+*o.ApiKey.ApiKey)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Accept-Encoding", "identity")
|
|
||||||
|
|
||||||
resp, err := o.Client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var chatResp llm.ChatResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if o.tokenUsage.Model == "" && chatResp.Model != "" {
|
|
||||||
o.tokenUsage.Model = chatResp.Model
|
|
||||||
}
|
|
||||||
o.tokenUsage.PromptTokens = chatResp.Usage.PromptTokens
|
|
||||||
o.tokenUsage.CompletionTokens = chatResp.Usage.CompletionTokens
|
|
||||||
o.tokenUsage.TotalTokens = chatResp.Usage.TotalTokens
|
|
||||||
return &chatResp, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *OpenAICompatible) StreamChat(ctx context.Context, chatReq llm.ChatRequest) (chan *llm.StreamChatResponse, error) {
|
|
||||||
chatReq.Stream = true
|
|
||||||
chatReq.StreamOptions = &openai.StreamOptions{IncludeUsage: true}
|
|
||||||
dst, err := utils.StructToMap(chatReq)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(o.Params) > 0 {
|
|
||||||
dst = utils.MergeJSONObjects(dst, o.Params)
|
|
||||||
}
|
|
||||||
|
|
||||||
var reqBody bytes.Buffer
|
|
||||||
if err := json.NewEncoder(&reqBody).Encode(dst); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var req *http.Request
|
|
||||||
switch *o.ApiKey.ApiType {
|
|
||||||
case "azure":
|
|
||||||
formatModel := func(in string) string {
|
|
||||||
if strings.Contains(in, ".") {
|
|
||||||
return strings.ReplaceAll(in, ".", "")
|
|
||||||
}
|
|
||||||
return in
|
|
||||||
}
|
|
||||||
var buildurl string
|
|
||||||
if *o.ApiKey.Endpoint != "" {
|
|
||||||
buildurl = fmt.Sprintf("%s/openai/deployments/%s/chat/completions?api-version=%s", *o.ApiKey.Endpoint, formatModel(chatReq.Model), AzureApiVersion)
|
|
||||||
} else {
|
|
||||||
buildurl = fmt.Sprintf("https://%s.openai.azure.com/openai/deployments/%s/chat/completions?api-version=%s", *o.ApiKey.ResourceNmae, formatModel(chatReq.Model), AzureApiVersion)
|
|
||||||
}
|
|
||||||
req, _ = http.NewRequest(http.MethodPost, buildurl, &reqBody)
|
|
||||||
req.Header.Set("api-key", *o.ApiKey.ApiKey)
|
|
||||||
case "github":
|
|
||||||
req, _ = http.NewRequest(http.MethodPost, Github_Marketplace, &reqBody)
|
|
||||||
default:
|
|
||||||
if o.ApiKey.Endpoint == nil || *o.ApiKey.Endpoint == "" {
|
|
||||||
req, _ = http.NewRequest(http.MethodPost, defaultOpenAICompatibleEndpoint, &reqBody)
|
|
||||||
} else {
|
|
||||||
req, _ = http.NewRequest(http.MethodPost, *o.ApiKey.Endpoint, &reqBody)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+*o.ApiKey.ApiKey)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Accept-Encoding", "identity")
|
|
||||||
|
|
||||||
resp, err := o.Client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
output := make(chan *llm.StreamChatResponse)
|
|
||||||
|
|
||||||
b := new(bytes.Buffer)
|
|
||||||
teeReader := io.TeeReader(resp.Body, b)
|
|
||||||
// 流式响应
|
|
||||||
scanner := bufio.NewScanner(teeReader)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer resp.Body.Close()
|
|
||||||
defer close(output)
|
|
||||||
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Bytes()
|
|
||||||
var streamResp llm.StreamChatResponse
|
|
||||||
if len(line) > 0 {
|
|
||||||
// fmt.Println(string(line))
|
|
||||||
if bytes.HasPrefix(line, []byte("data: ")) {
|
|
||||||
if bytes.HasPrefix(line, []byte("data: [DONE]")) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
line = bytes.Replace(line, []byte("data: "), []byte(""), -1)
|
|
||||||
line = bytes.TrimSpace(line)
|
|
||||||
if err := json.Unmarshal(line, &streamResp); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if streamResp.Usage != nil {
|
|
||||||
o.tokenUsage.PromptTokens += streamResp.Usage.PromptTokens
|
|
||||||
o.tokenUsage.CompletionTokens += streamResp.Usage.CompletionTokens
|
|
||||||
o.tokenUsage.TotalTokens += streamResp.Usage.TotalTokens
|
|
||||||
}
|
|
||||||
output <- &streamResp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// select {
|
|
||||||
// case <-ctx.Done():
|
|
||||||
// return
|
|
||||||
// case output <- &streamResp:
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return output, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *OpenAICompatible) GetTokenUsage() *llm.TokenUsage {
|
|
||||||
return o.tokenUsage
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
package llm
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"opencatd-open/internal/model"
|
|
||||||
|
|
||||||
"github.com/sashabaranov/go-openai"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ChatRequest openai.ChatCompletionRequest
|
|
||||||
|
|
||||||
type ChatResponse openai.ChatCompletionResponse
|
|
||||||
|
|
||||||
type StreamChatResponse openai.ChatCompletionStreamResponse
|
|
||||||
|
|
||||||
type ChatMessage openai.ChatCompletionMessage
|
|
||||||
|
|
||||||
type TokenUsage struct {
|
|
||||||
User *model.User
|
|
||||||
TokenID int64
|
|
||||||
Model string `json:"model"`
|
|
||||||
Stream bool
|
|
||||||
PromptTokens int `json:"prompt_tokens"`
|
|
||||||
CompletionTokens int `json:"completion_tokens"`
|
|
||||||
ToolsTokens int `json:"tools_tokens"`
|
|
||||||
TotalTokens int `json:"total_tokens"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ErrorResponse struct {
|
|
||||||
Err struct {
|
|
||||||
Message string `json:"message,omitempty"`
|
|
||||||
Type string `json:"type,omitempty"`
|
|
||||||
Param string `json:"param,omitempty"`
|
|
||||||
Code string `json:"code,omitempty"`
|
|
||||||
} `json:"error,omitempty"`
|
|
||||||
HTTPStatusCode int `json:"-"`
|
|
||||||
HTTPStatus string `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e ErrorResponse) Error() string {
|
|
||||||
if e.HTTPStatusCode > 0 {
|
|
||||||
return fmt.Sprintf("error, status code: %d, status: %s, message: %s", e.HTTPStatusCode, e.HTTPStatus, e.Err.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
return e.Err.Message
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
/*
|
|
||||||
https://docs.anthropic.com/zh-CN/api/claude-on-vertex-ai
|
|
||||||
|
|
||||||
MODEL_ID=claude-3-5-sonnet@20240620
|
|
||||||
REGION=us-east5
|
|
||||||
PROJECT_ID=MY_PROJECT_ID
|
|
||||||
|
|
||||||
curl \
|
|
||||||
-X POST \
|
|
||||||
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
https://$LOCATION-aiplatform.googleapis.com/v1/projects/${PROJECT_ID}/locations/${LOCATION}/publishers/anthropic/models/${MODEL_ID}:streamRawPredict \
|
|
||||||
-d '{
|
|
||||||
"anthropic_version": "vertex-2023-10-16",
|
|
||||||
"messages": [{
|
|
||||||
"role": "user",
|
|
||||||
"content": "介绍一下你自己"
|
|
||||||
}],
|
|
||||||
"stream": true,
|
|
||||||
"max_tokens": 4096
|
|
||||||
}'
|
|
||||||
|
|
||||||
quota:
|
|
||||||
https://console.cloud.google.com/iam-admin/quotas?hl=zh-cn
|
|
||||||
*/
|
|
||||||
|
|
||||||
package vertexai
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rsa"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/json"
|
|
||||||
"encoding/pem"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// json文件存储在ApiKey.ApiSecret中
|
|
||||||
type VertexSecretKey struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
ProjectID string `json:"project_id"`
|
|
||||||
PrivateKeyID string `json:"private_key_id"`
|
|
||||||
PrivateKey string `json:"private_key"`
|
|
||||||
ClientEmail string `json:"client_email"`
|
|
||||||
ClientID string `json:"client_id"`
|
|
||||||
AuthURI string `json:"auth_uri"`
|
|
||||||
TokenURI string `json:"token_uri"`
|
|
||||||
AuthProviderX509CertURL string `json:"auth_provider_x509_cert_url"`
|
|
||||||
ClientX509CertURL string `json:"client_x509_cert_url"`
|
|
||||||
UniverseDomain string `json:"universe_domain"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type VertexClaudeModel struct {
|
|
||||||
VertexName string
|
|
||||||
Region string
|
|
||||||
}
|
|
||||||
|
|
||||||
var VertexClaudeModelMap = map[string]VertexClaudeModel{
|
|
||||||
"claude-3-opus": {
|
|
||||||
VertexName: "claude-3-opus@20240229",
|
|
||||||
Region: "us-east5",
|
|
||||||
},
|
|
||||||
"claude-3-sonnet": {
|
|
||||||
VertexName: "claude-3-sonnet@20240229",
|
|
||||||
Region: "us-central1",
|
|
||||||
// Region: "asia-southeast1",
|
|
||||||
},
|
|
||||||
"claude-3-haiku": {
|
|
||||||
VertexName: "claude-3-haiku@20240307",
|
|
||||||
Region: "us-central1",
|
|
||||||
// Region: "europe-west4",
|
|
||||||
},
|
|
||||||
"claude-3-opus-20240229": {
|
|
||||||
VertexName: "claude-3-opus@20240229",
|
|
||||||
Region: "us-east5",
|
|
||||||
},
|
|
||||||
"claude-3-sonnet-20240229": {
|
|
||||||
VertexName: "claude-3-sonnet@20240229",
|
|
||||||
Region: "us-central1",
|
|
||||||
// Region: "asia-southeast1",
|
|
||||||
},
|
|
||||||
"claude-3-haiku-20240307": {
|
|
||||||
VertexName: "claude-3-haiku@20240307",
|
|
||||||
Region: "us-central1",
|
|
||||||
// Region: "europe-west4",
|
|
||||||
},
|
|
||||||
"claude-3-5-sonnet": {
|
|
||||||
VertexName: "claude-3-5-sonnet@20240620",
|
|
||||||
Region: "us-east5",
|
|
||||||
// Region: "europe-west1",
|
|
||||||
},
|
|
||||||
"claude-3-5-sonnet-20240620": {
|
|
||||||
VertexName: "claude-3-5-sonnet@20240620",
|
|
||||||
Region: "us-east5",
|
|
||||||
// Region: "europe-west1",
|
|
||||||
},
|
|
||||||
"claude-3-5-sonnet-20241022": {
|
|
||||||
VertexName: "claude-3-5-sonnet-v2@20241022",
|
|
||||||
Region: "us-east5",
|
|
||||||
},
|
|
||||||
"claude-3-5-sonnet-latest": { //可能没有容量,指向老模型
|
|
||||||
VertexName: "claude-3-5-sonnet@20240620",
|
|
||||||
Region: "us-east5",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func createSignedJWT(email, privateKeyPEM string) (string, error) {
|
|
||||||
block, _ := pem.Decode([]byte(privateKeyPEM))
|
|
||||||
if block == nil {
|
|
||||||
return "", fmt.Errorf("failed to parse PEM block containing the private key")
|
|
||||||
}
|
|
||||||
|
|
||||||
privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
rsaKey, ok := privateKey.(*rsa.PrivateKey)
|
|
||||||
if !ok {
|
|
||||||
return "", fmt.Errorf("not an RSA private key")
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
claims := jwt.MapClaims{
|
|
||||||
"iss": email,
|
|
||||||
"aud": "https://www.googleapis.com/oauth2/v4/token",
|
|
||||||
"iat": now.Unix(),
|
|
||||||
"exp": now.Add(10 * time.Minute).Unix(),
|
|
||||||
"scope": "https://www.googleapis.com/auth/cloud-platform",
|
|
||||||
}
|
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
|
||||||
return token.SignedString(rsaKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
func exchangeJwtForAccessToken(signedJWT string) (string, error) {
|
|
||||||
authURL := "https://www.googleapis.com/oauth2/v4/token"
|
|
||||||
data := url.Values{}
|
|
||||||
data.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
|
|
||||||
data.Set("assertion", signedJWT)
|
|
||||||
|
|
||||||
client := http.DefaultClient
|
|
||||||
if os.Getenv("LOCAL_PROXY") != "" {
|
|
||||||
if proxyUrl, err := url.Parse(os.Getenv("LOCAL_PROXY")); err == nil {
|
|
||||||
client.Transport = &http.Transport{Proxy: http.ProxyURL(proxyUrl)}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.PostForm(authURL, data)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var result map[string]interface{}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
accessToken, ok := result["access_token"].(string)
|
|
||||||
if !ok {
|
|
||||||
return "", fmt.Errorf("access token not found in response")
|
|
||||||
}
|
|
||||||
|
|
||||||
return accessToken, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取gcloud auth token
|
|
||||||
func GcloudAuth(ClientEmail, PrivateKey string) (string, error) {
|
|
||||||
signedJWT, err := createSignedJWT(ClientEmail, PrivateKey)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := exchangeJwtForAccessToken(signedJWT)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("Invalid jwt token: %v\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return token, nil
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user