31 Commits
Author SHA1 Message Date
Sakurasan ca4dc4b3b7 fix: 缓存 token 计费按上游协议区分口径
- 新增 ComputeCost:OpenAI 系 prompt 含缓存读需扣减(异常数据钳制为 0);
  Anthropic input_tokens 不含缓存,按原值计费,缓存写按输入价 ×1.25(原实现误用输出价)
- recordUsage 传入上游协议(用量语义跟随解析它的上游响应,而非客户端协议)
- 补充单元测试覆盖两种协议口径与边界情况
2026-09-02 02:36:28 +08:00
Sakurasan 0628d5050f feat: 用量统计页改版,月度汇总支持多指标图表
- 新增 GET /api/usage/monthly?year= 年度按自然月聚合,每月含按模型分解(token 降序)
- 月度汇总改为堆叠柱状图:Token/消费金额/调用次数三指标切换,按模型分色(图例取前 8,其余归入「其他」)
- 选中月份概览卡片(金额/次数/token 分解),点击柱体或图例联动切换
- 年份切换、悬停明细 tooltip、请求明细保留
2026-09-02 02:36:28 +08:00
Sakurasan a376ac0722 feat: add Redis support for distributed passkey sessions
- Implement SessionStore interface for pluggable storage backends
- Add redisStore for distributed environment
- Add memoryStore as fallback for single instance
- Update router to initialize Redis client when configured
- Update .env.example with Redis documentation
2026-09-01 23:03:39 +08:00
Sakurasan 9733b3c20b refactor: restructure passkey with dedicated package
- Create internal/passkey/ package with challenge session management
- Add HTTP handler layer in internal/api/passkey.go
- Simplify Passkey model to use blob credential storage
- Register passkey routes in router
- Update frontend to match new API endpoints
- Add .env.example template
2026-09-01 22:34:44 +08:00
Sakurasan d28fca8ee6 chore: sqlite 默认数据库文件调整为 db/openteam.db,运行时自动创建目录 2026-09-01 21:36:15 +08:00
Sakurasan a2cef00908 fix: 未登录访问管理后台不再闪现页面,路由守卫改为服务端校验
- 守卫先经 /profile 校验 token 有效后才放行受保护路由,无效 token 直接跳登录并带 redirect 回跳
- DashboardLayout 用户信息就绪前只渲染 spinner,不渲染后台内容
- manager 路由增加 requiresAdmin 校验(role >= 10),非管理员重定向回仪表盘
- 401 拦截器改用 router.push 软跳转,避免整页刷新与双重跳转
- pinia 先于 router 安装;登录页支持 redirect 参数回跳原目标
2026-09-01 20:20:05 +08:00
Sakurasan d41bcdc371 修复backend dist文件路径 2026-09-01 17:22:26 +08:00
Sakurasan 8d949eff18 修改图标配色 2026-09-01 15:38:32 +08:00
Sakurasan c65d497551 feat: add docker build commands to Makefile 2026-09-01 15:22:37 +08:00
Sakurasan 19232567f2 chore: 忽略 paseo 任务运行时目录 .pi/ 2026-09-01 03:41:18 +08:00
Sakurasan 20f365d11a fix: 识别上游 HTTP 200 错误体,正确记账并返回 502
问题:OpenRouter 等上游超时时返回 HTTP 200 但响应体/SSE 内含
error 字段(如 {"error":{"code":504}}),网关只检查 StatusCode>=400,
导致:1) 客户端收到假 200 + 空内容;2) 用量被错误记为 success。

修复:
- bufferResponse 检测 JSON 错误体(openai 风格 error / anthropic 风格 type=error),
  识别时以 502 返回给客户端,并向 Dispatch 返回错误码
- streamResponse 扫描 SSE data 载荷中的 error 字段,识别时返回错误码
- Dispatch 对带错误码的响应按失败记账(status=error, error_code=upstream_error),
  不产生费用
2026-09-01 03:31:55 +08:00
Sakurasan 104ecc4691 fix: 原始数据弹窗改为标签页切换,手机友好
- 原始请求/响应不再上下堆叠,改为「请求 / 响应」两个标签页
- 单个内容区(pre 内滚动 + 自动换行),避免手机上需要长距离滚动
- 打开时默认停在有内容的标签(请求优先);仅有一个数据时只显示对应标签
- 弹窗标题补充模型名与协议信息
2026-09-01 03:16:22 +08:00
Sakurasan a259e5eb4d feat: 普通用户用量统计 + 管理后台用量明细
后端
- /api/usage/stats:当前用户按日聚合统计(请求数/输入/输出/缓存 tokens/费用)
- /api/usage/logs:当前用户用量明细分页
- /api/admin/usage/logs:全量明细(分页 + 协议/状态/模型/用户筛选 + 用户名关联)
- /api/admin/usage/summary:全量汇总(按用户分组)
- dao:UsageFilter 支持筛选分页;DailyUsageDAO.ListAll
- 路由加固:新增 middleware.AdminOnly,既有 /api/admin/* 全部迁移到
  带管理员角色校验的分组(此前登录即可访问,属安全隐患)

前端
- 普通用户「用量统计」页:统计卡片 + 纯 CSS 每日请求量条形图 + 明细表格分页
- 管理后台「用量明细」页:汇总卡片 + 协议/状态/模型/用户筛选 +
  明细表格 + 原始请求/响应查看弹窗(记录开关开启时)
- stores/usage.ts 与类型定义;控制台/管理菜单挂载
2026-09-01 02:55:55 +08:00
Sakurasan f9e9a1572f feat: 系统设置新增原始请求/响应记录开关(仅管理员)
- 系统配置键 log_raw_requests:开启后,仅管理员账号的每次请求
  在用量明细中保存客户端原始请求体与上游原始响应体
  (流式含全部 SSE 事件),用于排障
- UsageLog 新增 raw_request / raw_response 字段(type:text)
- AuthLLM 附带 user_role 供网关判断管理员
- gateway:10s TTL 缓存开关;streamResponse/bufferResponse
  支持累积上游原始响应;recordUsage 填充原始字段
- 前端 SystemConfig 新增开关(会显著增加存储的提示)
- 新增 doc/flow.md 网关调用流程示意图
2026-09-01 02:32:31 +08:00
Sakurasan 9f4d631fc4 feat: 网关路由对齐参考实现 + 用量落库 + e2e 全绿
路由与故障转移(参考 openteam 语义)
- channel.Candidates:绑定模型优先(携带 upstream_model 映射),
  未绑定模型回退到权重最低的健康备用渠道;新增 Pick 加权随机与 FilterHealthy 内存健康过滤
- gateway.Dispatch:遍历候选渠道,可重试失败(连接错误/429/5xx)自动故障转移,
  4xx 透传;不再使用单一 SelectChannel
- 修复 gorm default 标签把渠道 weight=0 静默改写为 1 的问题(去掉 default,
  权重 0 语义 = 不参与加权选择,仅作备用承接 unbound 流量)
- RecordFailure 连续 2 次进入 degraded 快速熔断,健康检查成功或冷却过期后复位

网关功能补全
- /v1/models 返回 DB 中启用的模型列表(替换 TODO 存根)
- 请求级 request_id 生成与用量记录接入:流式 SSE 逐块累计 usage、
  非流式从响应提取,按模型定价计算成本后经 usage.Recorder 异步落库
- 流式结束检测:chat 的 [DONE]、messages 的 message_stop、responses 的
  response.completed,避免 keep-alive 上游发完不关连接导致读阻塞到超时
- ResponsesRequest.input 兼容字符串与条目数组两种客户端写法

测试
- 修复 convert_test 对新 input 形态的断言
- 网关 e2e(/tmp/test_gateway.py + mock upstream)72/72 全部通过,连续 3 次稳定
2026-09-01 00:46:05 +08:00
Sakurasan f81b364436 feat: 三协议互转网关 + 鉴权修复 + 管理端增强
后端
- 新增 proxy/convert 三协议(chat/messages/responses)请求、响应与 SSE 流式互转,
  以 Chat 为中间模型;usage.go 统一提取三协议 token 用量(含单测)
- gateway: 跨协议调度(渠道未声明客户端协议时转为渠道首选格式),
  streamResponse 按 \n\n 分块逐行转换直通,bufferResponse 转换失败时剥非 JSON 前缀
- gateway: 新增 SetUsageRecorder 注入异步用量记录器
- auth_llm: 修复 key_prefix 查询长度错配([:8] vs 存储的 [:12])导致全部 401;
  修复长度 8-11 的 key 切片越界 panic;统一 unauthorized 响应
- usage: 日报表改为增量累加 upsert,避免多次 flush 互相清零;记录协议/错误码/时延等字段
- channel: 新增渠道并发槽 TryAcquire;健康检查支持可配置参数
- api: 新增 admin 渠道/模型/系统配置管理端点(旧端点保留兼容)

前端
- 新增渠道管理、模型管理、系统配置视图与 ChannelModelsDrawer
- 新增 ui 基础组件(Button/Badge/Input/Modal)与 protocol.ts
- 调整 Toast 样式、密钥页、路由菜单;dev 代理默认指向 3000 端口
2026-08-31 22:29:09 +08:00
Sakurasan e472ed93d5 交换图表位置 2026-08-30 18:07:04 +08:00
Sakurasan 80ecf38676 fix: responsive Home header buttons for mobile and desktop 2026-08-30 17:57:24 +08:00
Sakurasan 4b845e14fc feat: redesign LineSegmentFlow + dev improvements
- LineSegmentFlow: LobeHub brand icons, concave left curves, glow effects
- Dark mode: line colors adapt, animated lines glow in dark theme
- Vite: enable LAN access (host: 0.0.0.0)
- VSCode: workspace settings for performance optimization
- Toast: redesign with icons, progress bar, better animations
2026-08-30 17:05:41 +08:00
Sakurasan 8f6acb981d 缩短dashboard名称 调整通知样式 2026-08-30 15:53:11 +08:00
Sakurasan 6cc76c6767 chore: add hot reload for development
- Makefile: add dev-backend (air) and dev-frontend (vite) targets
- backend/.air.toml: air config for Go live reload
- .gitignore: exclude backend/tmp/
2026-08-30 15:33:16 +08:00
Sakurasan f3af42a65f chore: upgrade Go version to 1.26
- backend/go.mod: go 1.26
- deploy/docker/Dockerfile: golang:1.26-alpine
- deploy/docker/Dockerfile.cn: golang:1.26-alpine
2026-08-30 15:29:02 +08:00
Sakurasan 094230a293 feat: API key management improvements
- Rename routes: /dashboard/tokens → /dashboard/apikeys, /dashboard/manager/keys → /dashboard/manager/channels
- Add KeyPlain field to store plaintext API keys for re-viewing
- API key list shows masked key (sk-ot-123456****abcd) with eye toggle to reveal
- Copy button with 2s feedback on key list
- TokenNew shows full key + copy after creation
- Increase key prefix display to 12 characters
- Fix SQLite driver: replace gorm.io/driver/sqlite with ncruces/go-sqlite3/gormlite
- Fix user.status === 'active' checks across frontend views
- Add channel store for new channels API
- Update Makefile frontend build to work reliably

BREAKING CHANGE: Existing API keys created before this change will not show their plaintext value (only prefix visible).
2026-08-30 15:21:49 +08:00
Sakurasan 96e4853d6e 移除旧文档 2026-08-30 13:45:29 +08:00
Sakurasan 253e968c9d chore: restore REFACTOR_PLAN.md (frontend refactoring plan) 2026-08-30 12:12:12 +08:00
Sakurasan eadf9525ee chore: clean up unused files
Removed:
- openteam binary from root (use bin/)
- opencatd.db (runtime artifact)
- dist/ (duplicate, already in backend/cmd/openteam/dist/)
- REFACTOR_PLAN.md (superseded by BACKEND_REFACTOR_PLAN.md)
- .pi/ (IDE artifact)

Updated .gitignore for new backend/ paths.
2026-08-30 12:10:23 +08:00
Sakurasan 902ecaeacc refactor: move backend files to backend/ directory
Reorganize project structure:
- backend/cmd/openteam/ — entry point
- backend/internal/ — core packages
- backend/middleware/ — HTTP middleware
- backend/router/ — route setup
- backend/wire/ — dependency injection
- backend/pkg/ — shared utilities
- backend/go.mod, go.sum — Go module files

Updated Makefile to work from backend/ directory.
Removed old lowercase makefile.
2026-08-30 12:02:52 +08:00
Sakurasan ef3025dd80 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)
2026-08-30 11:49:31 +08:00
Sakurasan aa0d87f132 fix(proxy): guard nil SupportModels before dereference in setModelCache 2026-08-30 03:16:50 +08:00
Sakurasan 963da99fd6 ci: add multi-arch dev image build script
- deploy/docker/build-dev.sh + make image-dev: linux/amd64+arm64 build & push,
  dev/dev-<sha>/dev-<date> tags, registry inline cache, auto create buildx builder
- PUSH=0 falls back to host-arch build with --load for local verification
- verified end-to-end (PUSH=0): container frontend build, CGO-free cross compile,
  upx pack 31.7MB->11.8MB, image smoke test 200
2026-08-30 02:11:11 +08:00
Sakurasan 33f5e7b71a rewrite: toast stacking with daisyui toast container
- module-level reactive toast list in composables/toast (setToast signature unchanged, 13 call sites untouched)
- multiple toasts stack simultaneously in daisyUI toast container, each auto-dismisses (3s) with close button
- TransitionGroup enter/leave animation (transform/opacity, honors reduced-motion)
- drop provide/inject queue that blocked consecutive toasts
2026-08-30 01:54:57 +08:00
230 changed files with 14592 additions and 12634 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ web
# Go 构建产物 # Go 构建产物
bin bin
cmd/openteam/dist backend/cmd/openteam/dist
# 文档与 CI # 文档与 CI
doc doc
+48
View File
@@ -0,0 +1,48 @@
# ===========================================
# OpenCatd-Open 配置文件
# 复制此文件为 .env 并修改相应配置
# ===========================================
# --- 服务器配置 ---
PORT=80
READ_TIMEOUT=10
WRITE_TIMEOUT=10
# --- Passkey (WebAuthn) 配置 ---
# 应用名称(显示给用户)
APP_NAME=OpenTeam
# 依赖方 ID(通常为域名,生产环境需改为实际域名)
RPID=localhost
# 依赖方来源(前端 URL,逗号分隔)
RPORIGINS=http://localhost:5173,http://localhost:3000
# --- 数据库配置 ---
# 支持: sqlite, mysql, postgres
DB_TYPE=sqlite
# DSN 连接字符串(SQLite 可留空)
DB_DSN=
DB_MAX_OPEN_CONNS=10
DB_MAX_IDLE_CONNS=5
# --- Redis 配置(可选,用于分布式 passkey session)---
# REDIS_HOST=localhost
# REDIS_PORT=6379
# REDIS_PASSWORD=
# REDIS_DB=0
# --- 日志配置 ---
LOG_LEVEL=info
LOG_PATH=./logs/
# --- 功能开关 ---
# 允许注册(false=关闭注册)
ALLOW_REGISTER=false
# 无限制配额(true=不限制)
UNLIMITED_QUOTA=true
# 新用户默认激活
DEFAULT_ACTIVE=true
# --- 用量统计 ---
USAGE_WORKER=1
USAGE_CHAN_SIZE=1000
TASK_TIME_INTERVAL=60
+10 -2
View File
@@ -1,13 +1,21 @@
bin/ bin/
tmp/
test/ test/
demo/ demo/
*.log *.log
*.db *.db
.env .env
openteam
# paseo 任务运行时记录
.pi/
# 构建产物(make web 生成,由 go:embed 打进二进制);保留 .gitkeep 占位使未构建前也能编译 # 构建产物(make web 生成,由 go:embed 打进二进制);保留 .gitkeep 占位使未构建前也能编译
cmd/openteam/dist/* backend/cmd/openteam/dist/*
!cmd/openteam/dist/.gitkeep !backend/cmd/openteam/dist/.gitkeep
# 前端构建产物(项目根目录的副本)
dist/
# 误生成的目录(仅含 dist/node_modules) # 误生成的目录(仅含 dist/node_modules)
web/ web/
+40
View File
@@ -0,0 +1,40 @@
{
// Vue: 启用 Take Over 模式,禁用内置 TS 服务,减少内存占用
"vue.server.hybridMode": true,
// Git: 降低自动拉取频率
"git.autofetch": false,
"git.maxVisibleCommits": 10,
"git.decorations.enabled": false,
// 保存时只格式化,不运行 code action(慢)
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "never",
"source.organizeImports": "never"
},
// 排除大目录,减少文件监听
"files.watcherExclude": {
"**/node_modules/**": true,
"**/.git/objects/**": true,
"**/dist/**": true,
"**/tmp/**": true,
"**/bin/**": true
},
// 排除搜索目录
"search.exclude": {
"**/node_modules": true,
"**/dist": true,
"**/tmp": true,
"**/bin": true,
"**/*.sum": true
},
// TypeScript: 降低语言服务开销
"typescript.tsserver.maxTsServerMemory": 1024,
"typescript.tsserver.watchOptions": {
"excludeDirectories": ["node_modules", "dist"]
}
}
+159
View File
@@ -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 通过
+95
View File
@@ -0,0 +1,95 @@
.PHONY: build run test clean fmt lint frontend dev dev-backend dev-frontend docker docker-cn docker-multi
BINARY_NAME=openteam
BUILD_DIR=bin
BACKEND_DIR=backend
# Build
build: frontend
cd $(BACKEND_DIR) && CGO_ENABLED=0 go build -o ../$(BUILD_DIR)/$(BINARY_NAME) ./cmd/openteam
# Build backend only (frontend dist must exist)
build-backend:
cd $(BACKEND_DIR) && CGO_ENABLED=0 go build -o ../$(BUILD_DIR)/$(BINARY_NAME) ./cmd/openteam
# Build frontend and copy dist
frontend:
cd frontend && pnpm install && pnpm build
rm -rf $(BACKEND_DIR)/cmd/openteam/dist
cp -r frontend/dist $(BACKEND_DIR)/cmd/openteam/dist
# Run
run: build
./$(BUILD_DIR)/$(BINARY_NAME)
# Development: backend + frontend (requires air + pnpm)
dev: dev-frontend dev-backend
# Go backend with hot reload (requires: go install github.com/air-verse/air@latest)
dev-backend:
@command -v air >/dev/null 2>&1 || { echo "Installing air..."; go install github.com/air-verse/air@latest; }
cd $(BACKEND_DIR) && air -c .air.toml
# Vue frontend with HMR
dev-frontend:
cd frontend && pnpm dev
# Test
test:
cd $(BACKEND_DIR) && go test ./internal/... -v
# Test with coverage
test-cover:
cd $(BACKEND_DIR) && go test ./internal/... -coverprofile=coverage.out
cd $(BACKEND_DIR) && go tool cover -html=coverage.out -o coverage.html
# Format code
fmt:
cd $(BACKEND_DIR) && go fmt ./...
# Lint
lint:
cd $(BACKEND_DIR) && golangci-lint run
# Clean
clean:
rm -rf $(BUILD_DIR)
cd $(BACKEND_DIR) && rm -f coverage.out coverage.html
# Tidy dependencies
tidy:
cd $(BACKEND_DIR) && go mod tidy
# Build for Linux
build-linux:
cd $(BACKEND_DIR) && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ../$(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./cmd/openteam
# Build for macOS
build-mac:
cd $(BACKEND_DIR) && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o ../$(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./cmd/openteam
# Build all platforms
build-all: build-linux build-mac
# Database migration (will be implemented)
migrate:
@echo "Migration will be implemented in future"
# Seed data (will be implemented)
seed:
@echo "Seeding will be implemented in future"
# Docker build (default platform)
docker:
docker build -f deploy/docker/Dockerfile -t $(BINARY_NAME):latest .
# Docker build (China mirror accelerated)
docker-cn:
docker build -f deploy/docker/Dockerfile.cn -t $(BINARY_NAME):latest .
# Docker build multi-platform (requires: docker buildx)
docker-multi:
docker buildx build -f deploy/docker/Dockerfile \
--platform linux/amd64,linux/arm64 \
-t $(BINARY_NAME):latest --push .
+1 -1
View File
@@ -8,7 +8,7 @@
[![Telegram group](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.swo.moe%2Fstats%2Ftelegram%2FOpenTeamChat&query=count&color=2CA5E0&label=Telegram%20Group&logo=telegram&cacheSeconds=3600&style=flat-square)](https://t.me/OpenTeamChat) [![Telegram channel](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.swo.moe%2Fstats%2Ftelegram%2FOpenTeamLLM&query=count&color=2CA5E0&label=Telegram%20Channel&logo=telegram&cacheSeconds=3600&style=flat-square)](https://t.me/OpenTeamLLM) [![Telegram group](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.swo.moe%2Fstats%2Ftelegram%2FOpenTeamChat&query=count&color=2CA5E0&label=Telegram%20Group&logo=telegram&cacheSeconds=3600&style=flat-square)](https://t.me/OpenTeamChat) [![Telegram channel](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.swo.moe%2Fstats%2Ftelegram%2FOpenTeamLLM&query=count&color=2CA5E0&label=Telegram%20Channel&logo=telegram&cacheSeconds=3600&style=flat-square)](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的开源实现
+16
View File
@@ -284,3 +284,19 @@ src/
- 背景:daisyUI dark 主题的 primary 为紫色,普通主按钮(btn-primary)在深色模式下显示为紫底。 - 背景:daisyUI dark 主题的 primary 为紫色,普通主按钮(btn-primary)在深色模式下显示为紫底。
- 方案:`main.css` 覆写 `.btn-primary` 的 daisyUI 颜色变量(`--btn-color` / `--btn-fg`)——浅色主题黑底白字(#171717/#fff,hover 纯黑)、深色主题白底黑字(#fff/#171717,hover 浅灰)。仅影响 btn-primary;success/error/warning/outline/ghost 等特殊按钮与链接、开关、焦点环均保持原样。 - 方案:`main.css` 覆写 `.btn-primary` 的 daisyUI 颜色变量(`--btn-color` / `--btn-fg`)——浅色主题黑底白字(#171717/#fff,hover 纯黑)、深色主题白底黑字(#fff/#171717,hover 浅灰)。仅影响 btn-primary;success/error/warning/outline/ghost 等特殊按钮与链接、开关、焦点环均保持原样。
- 验证:`pnpm build` 通过;浏览器实测深色(白底黑字 New Token)与浅色(黑底白字 Log In)两种主题,特殊按钮未受影响;已恢复维护者的 auto 主题偏好与 admin 会话。 - 验证:`pnpm build` 通过;浏览器实测深色(白底黑字 New Token)与浅色(黑底白字 Log In)两种主题,特殊按钮未受影响;已恢复维护者的 auto 主题偏好与 admin 会话。
### 增量 7 — Toast 重写:多实例堆叠(维护者反馈)✅
- 背景:原实现为串行队列(processQueue 一次展示一条),连续操作时提示互相阻塞。
- daisyUI 的 `toast` 组件本身只负责定位与堆叠(容器内多个 `alert` 自动纵向排列),队列/自动消失/动画需应用层实现——已按此重写:
- `composables/toast.ts`:模块级响应式 `toasts` 列表,`setToast(message, type?, duration?)` 推入带唯一 id 的条目并定时自动移除(默认 3s);`useToast()` 签名不变,13 个调用视图零改动;移除原 provide/inject 方案。
- `Toast.vue`:daisyUI `toast toast-top toast-end` 容器 + `TransitionGroup` 进出场动画(仅 transform/opacity,配合全局 reduced-motion 降级)、每条带关闭按钮(aria-label)、容器 `aria-live="polite"`。
- 验证:`pnpm build` 通过;浏览器实测连续触发两条 toast 同时堆叠展示、3s 后全部自动消失。
### 增量 8 — dev 镜像构建脚本(多平台)✅
- 新增 `deploy/docker/build-dev.sh` + `make image-dev`:
- 默认多平台 `linux/amd64,linux/arm64` 构建并推送(自动创建缺失的 buildx 容器构建器);`PUSH=0` 退化为本机架构构建并 `--load` 到本地。
- 标签:`dev`、`dev-<短SHA>`、`dev-<YYYYMMDD>`(`REGISTRY_IMAGE` / `PLATFORMS` / `BUILDER` / `DOCKERFILE` 均可用环境变量覆盖)。
- 推送时附带 registry inline cache(`--cache-from/--cache-to type=inline`)加速后续 dev 构建。
- 验证:`PUSH=0` 端到端实测通过(容器内前端构建 + Go 交叉编译 + upx 压缩 31.7MB→11.8MB + 本地加载);镜像冒烟测试 `/:200`、`/api/*:200`。
-3
View File
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

+38
View File
@@ -0,0 +1,38 @@
# Air configuration for Go hot reload
# Docs: https://github.com/air-verse/air
root = "."
tmp_dir = "tmp"
[build]
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ./cmd/openteam"
delay = 1000
exclude_dir = ["tmp", "vendor", "node_modules", "frontend"]
exclude_file = []
exclude_regex = ["_test.go", ".*_test.go"]
exclude_unchanged = false
follow_symlink = false
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html", "sql"]
kill_delay = "0s"
log = "build-errors.log"
send_interrupt = false
stop_on_error = true
[color]
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
keep_scroll = true
@@ -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 {
+76
View File
@@ -0,0 +1,76 @@
module opencatd-open
go 1.26
require (
github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0
github.com/go-ozzo/ozzo-validation/v4 v4.4.1
github.com/go-webauthn/webauthn v0.12.3
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/google/wire v0.6.0
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/ncruces/go-sqlite3/gormlite v0.34.0
github.com/pkoukk/tiktoken-go v0.1.7
github.com/sashabaranov/go-openai v1.42.0
github.com/spf13/cobra v1.9.1
golang.org/x/crypto v0.50.0
golang.org/x/time v0.10.0
gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.5.11
gorm.io/gorm v1.31.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-webauthn/x v0.1.20 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-tpm v0.9.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-sqlite3 v0.34.0 // indirect
github.com/ncruces/go-sqlite3-wasm/v2 v2.1.35300 // indirect
github.com/ncruces/julianday v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/redis/go-redis/v9 v9.22.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.16.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+37 -190
View File
@@ -1,81 +1,36 @@
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=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= github.com/cloudwego/base64x v0.1.5 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 +48,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 +71,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 +83,16 @@ 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/mewkiz/flac v1.0.7/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU=
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,27 @@ 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-sqlite3 v0.34.0 h1:q2I6wHTLWIoz6ehYkKdG5dGQc66eJv7ZGnekhvuMfK8=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-sqlite3 v0.34.0/go.mod h1:qpBxsSdGPnO9K5OExuv5GEsrGQ7Rk6JsJFH6wn2DwwU=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/ncruces/go-sqlite3-wasm/v2 v2.1.35300 h1:cRdxCt3BDfMu0vfSdoqaAPD+dzIXPkGREjqyZMLN2Ak=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/ncruces/go-sqlite3-wasm/v2 v2.1.35300/go.mod h1:R2kJLPoSA/GBX/b8x7zwOq/KLAw6rLMY1l3Hi76SQIo=
github.com/ncruces/go-sqlite3/gormlite v0.34.0 h1:QLlOy/i7OabsFUQ+d5KyXmq2hw9sMh/CRW435+eQMRY=
github.com/ncruces/go-sqlite3/gormlite v0.34.0/go.mod h1:CMv+6YhqLmPBXYACiQtrWA0q/JLIMTKB4E65SUfLgF0=
github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3 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/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/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 +138,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,43 +145,20 @@ 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.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
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=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
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=
@@ -273,33 +166,27 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 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.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
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=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-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=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -313,8 +200,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -323,28 +210,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=
@@ -354,30 +225,6 @@ gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkD
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/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.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
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=
+588
View File
@@ -0,0 +1,588 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"opencatd-open/internal/pkg/crypto"
"opencatd-open/internal/store"
"github.com/gin-gonic/gin"
)
// AdminChannels GET /api/admin/channels — 渠道列表(不返回加密 key,返回掩码)。
func (h *Handler) AdminChannels(c *gin.Context) {
var chs []store.Channel
if err := h.db.Order("id ASC").Find(&chs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load channels"})
return
}
out := make([]gin.H, 0, len(chs))
for _, ch := range chs {
masked := ""
if key, err := crypto.Decrypt(ch.APIKeyEnc); err == nil && len(key) > 8 {
masked = maskAPIKey(key)
} else if err == nil {
masked = "****"
}
out = append(out, gin.H{
"id": ch.ID, "name": ch.Name, "provider": ch.Provider, "formats": ch.FormatsEffective(),
"base_url": ch.BaseURL, "base_urls": ch.BaseURLs,
"api_key_masked": masked, "weight": ch.Weight, "priority": ch.Priority,
"timeout_ms": ch.TimeoutMS, "max_concurrency": ch.MaxConcurrency,
"health_status": ch.HealthStatus, "enabled": ch.Enabled,
"created_at": ch.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
type channelBody struct {
Name string `json:"name" binding:"required,min=1,max=64"`
Provider string `json:"provider"`
Formats []string `json:"formats"`
BaseURL string `json:"base_url"`
BaseURLs map[string]string `json:"base_urls"`
APIKey string `json:"api_key"`
Weight *int `json:"weight"`
Priority *int `json:"priority"`
TimeoutMS *int `json:"timeout_ms"`
MaxConcurrency *int `json:"max_concurrency"`
Enabled *bool `json:"enabled"`
}
// normalizeBaseURLs 校验并清理分协议 base_url。
func normalizeBaseURLs(m map[string]string) map[string]string {
if len(m) == 0 {
return nil
}
out := map[string]string{}
for k, v := range m {
if validFormats[k] && strings.TrimSpace(v) != "" {
out[k] = strings.TrimRight(strings.TrimSpace(v), "/")
}
}
if len(out) == 0 {
return nil
}
return out
}
// resolveBaseURL 渠道 base_url:留空按供应商默认;网关按内容智能识别前缀/完整端点。
func resolveBaseURL(provider, raw string) (string, error) {
base := strings.TrimRight(raw, "/")
if base == "" {
switch provider {
case store.ChannelProviderOpenAI:
base = "https://api.openai.com"
case store.ChannelProviderAnthropic:
base = "https://api.anthropic.com"
}
}
if base == "" {
return "", errors.New("base_url required for compatible channels")
}
return base, nil
}
func validateProvider(p string) bool {
return p == store.ChannelProviderOpenAI || p == store.ChannelProviderAnthropic || p == store.ChannelProviderCompatible
}
var validFormats = map[string]bool{
store.FormatChat: true, store.FormatResponses: true, store.FormatMessages: true,
}
// deriveProvider 按格式推断供应商(仅作内部字段/兼容用途,不参与路由)。
func deriveProvider(formats []string) string {
if len(formats) == 0 {
return store.ChannelProviderCompatible
}
messagesOnly, hasResponses := true, false
for _, f := range formats {
if f != store.FormatMessages {
messagesOnly = false
}
if f == store.FormatResponses {
hasResponses = true
}
}
if messagesOnly {
return store.ChannelProviderAnthropic
}
if hasResponses {
return store.ChannelProviderOpenAI
}
return store.ChannelProviderCompatible
}
// resolveFormats 渠道协议格式:显式给出则校验去重;空则按 provider 推断默认。
func resolveFormats(provider string, formats []string) ([]string, error) {
if len(formats) == 0 {
switch provider {
case store.ChannelProviderAnthropic:
return []string{store.FormatMessages}, nil
case store.ChannelProviderOpenAI:
return []string{store.FormatChat, store.FormatResponses}, nil
default:
return []string{store.FormatChat}, nil
}
}
seen := map[string]bool{}
out := make([]string, 0, len(formats))
for _, f := range formats {
if !validFormats[f] {
return nil, fmt.Errorf("unsupported format %q", f)
}
if !seen[f] {
seen[f] = true
out = append(out, f)
}
}
return out, nil
}
// AdminCreateChannel POST /api/admin/channels
func (h *Handler) AdminCreateChannel(c *gin.Context) {
var req channelBody
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input: " + err.Error()})
return
}
if req.Provider == "" {
req.Provider = deriveProvider(req.Formats)
}
if !validateProvider(req.Provider) {
c.JSON(http.StatusBadRequest, gin.H{"error": "provider must be openai, anthropic or compatible"})
return
}
if req.APIKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "api_key required"})
return
}
formats, err := resolveFormats(req.Provider, req.Formats)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
baseURL, err := resolveBaseURL(req.Provider, req.BaseURL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
enc, err := crypto.Encrypt(req.APIKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt api key"})
return
}
ch := store.Channel{
Name: req.Name, Provider: req.Provider, Formats: formats, BaseURL: baseURL,
BaseURLs: normalizeBaseURLs(req.BaseURLs),
APIKeyEnc: enc, Weight: intOr(req.Weight, 1), Priority: intOr(req.Priority, 0),
TimeoutMS: intOr(req.TimeoutMS, 120000), MaxConcurrency: intOr(req.MaxConcurrency, 16),
HealthStatus: store.ChannelHealthHealthy, Enabled: boolOr(req.Enabled, true),
}
if err := h.db.Create(&ch).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "failed to create channel (name may already exist)"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": ch.ID, "name": ch.Name})
}
// AdminUpdateChannel PUT /api/admin/channels/:id
func (h *Handler) AdminUpdateChannel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
return
}
var body struct {
Name *string `json:"name"`
Provider *string `json:"provider"`
Formats *[]string `json:"formats"`
BaseURL *string `json:"base_url"`
BaseURLs *map[string]string `json:"base_urls"`
APIKey *string `json:"api_key"`
Weight *int `json:"weight"`
Priority *int `json:"priority"`
TimeoutMS *int `json:"timeout_ms"`
MaxConcurrency *int `json:"max_concurrency"`
HealthStatus *string `json:"health_status"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
return
}
var ch store.Channel
if err := h.db.First(&ch, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
updates := map[string]any{}
if body.Name != nil {
updates["name"] = *body.Name
}
if body.Provider != nil {
if !validateProvider(*body.Provider) {
c.JSON(http.StatusBadRequest, gin.H{"error": "provider must be openai, anthropic or compatible"})
return
}
updates["provider"] = *body.Provider
}
if body.BaseURL != nil {
prov := ch.Provider
if body.Provider != nil {
prov = *body.Provider
}
b, berr := resolveBaseURL(prov, *body.BaseURL)
if berr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": berr.Error()})
return
}
updates["base_url"] = b
}
if body.BaseURLs != nil {
raw, _ := json.Marshal(normalizeBaseURLs(*body.BaseURLs))
updates["base_urls"] = string(raw)
}
if body.APIKey != nil && *body.APIKey != "" {
enc, err := crypto.Encrypt(*body.APIKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt api key"})
return
}
updates["api_key_enc"] = enc
}
if body.Weight != nil {
updates["weight"] = *body.Weight
}
if body.Priority != nil {
updates["priority"] = *body.Priority
}
if body.TimeoutMS != nil {
updates["timeout_ms"] = *body.TimeoutMS
}
if body.MaxConcurrency != nil {
updates["max_concurrency"] = *body.MaxConcurrency
}
if body.HealthStatus != nil {
updates["health_status"] = *body.HealthStatus
}
if body.Enabled != nil {
updates["enabled"] = *body.Enabled
}
if body.Formats != nil {
prov := ch.Provider
if body.Provider != nil {
prov = *body.Provider
}
formats, ferr := resolveFormats(prov, *body.Formats)
if ferr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": ferr.Error()})
return
}
raw, _ := json.Marshal(formats)
updates["formats"] = string(raw)
}
if len(updates) > 0 {
if err := h.db.Model(&ch).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"})
return
}
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminDeleteChannel DELETE /api/admin/channels/:id
func (h *Handler) AdminDeleteChannel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
return
}
res := h.db.Delete(&store.Channel{}, id)
if res.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"})
return
}
if res.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
h.db.Where("channel_id = ?", id).Delete(&store.ChannelModelBinding{})
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminTestChannel POST /api/admin/channels/:id/test — 请求渠道 /v1/models 测连通性。
func (h *Handler) AdminTestChannel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
return
}
var ch store.Channel
if err := h.db.First(&ch, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
key, err := crypto.Decrypt(ch.APIKeyEnc)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to decrypt channel key"})
return
}
url := ch.UpstreamURL("", "/models")
client := &http.Client{Timeout: 10 * time.Second}
req, _ := http.NewRequest(http.MethodGet, url, nil)
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Accept", "application/json")
start := time.Now()
resp, err := client.Do(req)
status := store.ChannelHealthHealthy
msg := "ok"
latency := 0
if err != nil {
status = store.ChannelHealthCooldown
msg = err.Error()
} else {
latency = int(time.Since(start).Milliseconds())
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
status = store.ChannelHealthCooldown
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
msg = fmt.Sprintf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
resp.Body.Close()
}
h.db.Model(&store.Channel{}).Where("id = ?", ch.ID).Update("health_status", status)
if status != store.ChannelHealthHealthy {
c.JSON(http.StatusBadGateway, gin.H{"error": msg})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency, "message": msg})
}
// AdminChannelRemoteModels GET /api/admin/channels/:id/models/remote — 拉取远端模型列表。
// 返回本渠道尚未允许的模型(新增候选),排除已绑定的模型。
func (h *Handler) AdminChannelRemoteModels(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
return
}
var ch store.Channel
if err := h.db.First(&ch, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
key, err := crypto.Decrypt(ch.APIKeyEnc)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to decrypt channel key"})
return
}
url := ch.UpstreamURL("", "/models")
client := &http.Client{Timeout: 10 * time.Second}
req, _ := http.NewRequest(http.MethodGet, url, nil)
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("http %d: %s", resp.StatusCode, string(body))})
return
}
// 解析 OpenAI 格式的模型列表
var result struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(body, &result); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse response: " + err.Error()})
return
}
// 本渠道已允许的上游模型名:不作为新增候选
var boundNames []string
h.db.Model(&store.ChannelModelBinding{}).Where("channel_id = ?", id).Pluck("upstream_model", &boundNames)
boundSet := make(map[string]bool, len(boundNames))
for _, n := range boundNames {
boundSet[strings.TrimSpace(n)] = true
}
models := make([]string, 0, len(result.Data))
for _, m := range result.Data {
name := strings.TrimSpace(m.ID)
if name != "" && !boundSet[name] {
models = append(models, name)
}
}
c.JSON(http.StatusOK, gin.H{"data": models})
}
// AdminChannelModels GET /api/admin/channels/:id/models — 渠道绑定列表。
func (h *Handler) AdminChannelModels(c *gin.Context) {
channelID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
return
}
var bindings []store.ChannelModelBinding
if err := h.db.Preload("Model").Where("channel_id = ?", channelID).Find(&bindings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load bindings"})
return
}
out := make([]gin.H, 0, len(bindings))
for _, b := range bindings {
out = append(out, gin.H{
"id": b.ID, "model_id": b.ModelID, "model_name": b.Model.Name,
"upstream_model": b.UpstreamModel, "weight": b.Weight,
})
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
// AdminChannelAddModel POST /api/admin/channels/:id/models — 手工添加模型绑定。
// 无需渠道具备 /v1/models 接口:直接填上游模型名,可选自定义名称作为客户端调用名。
func (h *Handler) AdminChannelAddModel(c *gin.Context) {
channelID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
return
}
var req struct {
UpstreamModel string `json:"upstream_model" binding:"required"` // 渠道侧真实模型名
CustomName string `json:"custom_name"` // 客户端调用名,空=用上游名
Weight *int `json:"weight"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input: upstream_model required"})
return
}
globalName := req.CustomName
if globalName == "" {
globalName = req.UpstreamModel
}
var ch store.Channel
if err := h.db.First(&ch, channelID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
// 查找或创建全局模型
var m store.Model
if err := h.db.Where("name = ?", globalName).First(&m).Error; err != nil {
m = store.Model{Name: globalName, Enabled: true}
if err := h.db.Create(&m).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create model"})
return
}
}
// 查找已存在的绑定,如果存在则更新
var existing store.ChannelModelBinding
if err := h.db.Where("channel_id = ? AND model_id = ?", channelID, m.ID).First(&existing).Error; err == nil {
// 已存在,更新
existing.UpstreamModel = req.UpstreamModel
existing.Weight = intOr(req.Weight, 1)
if err := h.db.Save(&existing).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update binding"})
return
}
c.JSON(http.StatusOK, gin.H{"id": existing.ID, "model_id": m.ID, "model_name": m.Name, "upstream_model": existing.UpstreamModel, "weight": existing.Weight})
return
}
// 不存在,创建新的
b := store.ChannelModelBinding{
ChannelID: channelID, ModelID: m.ID,
UpstreamModel: req.UpstreamModel, Weight: intOr(req.Weight, 1),
}
if err := h.db.Create(&b).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "binding may already exist"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": b.ID, "model_id": m.ID, "model_name": m.Name, "upstream_model": req.UpstreamModel, "weight": b.Weight})
}
// AdminChannelUpdateModel PATCH /api/admin/channels/:id/models/:bid — 改映射名/权重。
func (h *Handler) AdminChannelUpdateModel(c *gin.Context) {
bid, err := strconv.ParseUint(c.Param("bid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid binding id"})
return
}
var req struct {
UpstreamModel *string `json:"upstream_model"`
Weight *int `json:"weight"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
return
}
var b store.ChannelModelBinding
if err := h.db.First(&b, bid).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "binding not found"})
return
}
updates := map[string]any{}
if req.UpstreamModel != nil {
updates["upstream_model"] = *req.UpstreamModel
}
if req.Weight != nil {
updates["weight"] = *req.Weight
}
if len(updates) > 0 {
if err := h.db.Model(&b).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update binding"})
return
}
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminChannelDeleteModel DELETE /api/admin/channels/:id/models/:bid — 解除绑定。
func (h *Handler) AdminChannelDeleteModel(c *gin.Context) {
bid, err := strconv.ParseUint(c.Param("bid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid binding id"})
return
}
res := h.db.Delete(&store.ChannelModelBinding{}, bid)
if res.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete binding"})
return
}
if res.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "binding not found"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// maskAPIKey 掩码渠道密钥:保留前 7 位与后 4 位,中间固定 ****** 遮蔽。
func maskAPIKey(key string) string {
if len(key) <= 11 {
return strings.Repeat("*", len(key)-4) + key[len(key)-4:]
}
return key[:7] + "******" + key[len(key)-4:]
}
func intOr(p *int, def int) int {
if p == nil {
return def
}
return *p
}
func boolOr(p *bool, def bool) bool {
if p == nil {
return def
}
return *p
}
+109
View File
@@ -0,0 +1,109 @@
package api
import (
"net/http"
"opencatd-open/internal/store"
"github.com/gin-gonic/gin"
)
// AdminGetConfig GET /api/admin/config — 获取系统配置。
func (h *Handler) AdminGetConfig(c *gin.Context) {
configs := map[string]string{}
var rows []store.SystemConfig
h.db.Find(&rows)
for _, r := range rows {
configs[r.Key] = r.Value
}
c.JSON(http.StatusOK, gin.H{"data": configs})
}
// AdminUpdateConfig PUT /api/admin/config — 更新系统配置。
func (h *Handler) AdminUpdateConfig(c *gin.Context) {
var req map[string]string
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
return
}
for key, value := range req {
var sc store.SystemConfig
result := h.db.Where("key = ?", key).First(&sc)
if result.Error == nil {
sc.Value = value
h.db.Save(&sc)
} else {
sc = store.SystemConfig{Key: key, Value: value}
h.db.Create(&sc)
}
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminGetRegistration GET /api/admin/config/registration — 获取注册配置。
func (h *Handler) AdminGetRegistration(c *gin.Context) {
var sc store.SystemConfig
enabled := "true"
if err := h.db.Where("key = ?", "registration_enabled").First(&sc).Error; err == nil {
enabled = sc.Value
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"enabled": enabled == "true"}})
}
// AdminUpdateRegistration PUT /api/admin/config/registration — 更新注册配置。
func (h *Handler) AdminUpdateRegistration(c *gin.Context) {
var req struct {
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
return
}
value := "false"
if req.Enabled {
value = "true"
}
var sc store.SystemConfig
if err := h.db.Where("key = ?", "registration_enabled").First(&sc).Error; err == nil {
sc.Value = value
h.db.Save(&sc)
} else {
sc = store.SystemConfig{Key: "registration_enabled", Value: value}
h.db.Create(&sc)
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminGetPasswordLogin GET /api/admin/config/password-login — 获取密码登录配置。
func (h *Handler) AdminGetPasswordLogin(c *gin.Context) {
var sc store.SystemConfig
enabled := "true"
if err := h.db.Where("key = ?", "password_login_enabled").First(&sc).Error; err == nil {
enabled = sc.Value
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"enabled": enabled == "true"}})
}
// AdminUpdatePasswordLogin PUT /api/admin/config/password-login — 更新密码登录配置。
func (h *Handler) AdminUpdatePasswordLogin(c *gin.Context) {
var req struct {
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
return
}
value := "false"
if req.Enabled {
value = "true"
}
var sc store.SystemConfig
if err := h.db.Where("key = ?", "password_login_enabled").First(&sc).Error; err == nil {
sc.Value = value
h.db.Save(&sc)
} else {
sc = store.SystemConfig{Key: "password_login_enabled", Value: value}
h.db.Create(&sc)
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+291
View File
@@ -0,0 +1,291 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"opencatd-open/internal/store"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// AdminModels GET /api/admin/models — 模型列表(含价格、渠道绑定、定价/禁止状态)。
func (h *Handler) AdminModels(c *gin.Context) {
var ms []store.Model
if err := h.db.Order("sort ASC, id ASC").Find(&ms).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load models"})
return
}
allow, deny := h.modelPolicyConfig()
out := make([]gin.H, 0, len(ms))
for _, m := range ms {
var bindings []store.ChannelModelBinding
h.db.Preload("Channel").Where("model_id = ?", m.ID).Find(&bindings)
chs := make([]gin.H, 0, len(bindings))
for _, b := range bindings {
if !b.Channel.Enabled {
continue
}
chs = append(chs, gin.H{
"id": b.ID, "channel_id": b.ChannelID, "channel_name": b.Channel.Name,
"upstream_model": b.UpstreamModel, "weight": b.Weight,
})
}
used := len(chs) > 0
needsPricing := used && m.InputPrice == 0 && m.OutputPrice == 0 && m.CacheReadPrice == 0
denied := containsStr(deny, m.Name) || (len(allow) > 0 && !containsStr(allow, m.Name))
out = append(out, gin.H{
"id": m.ID, "name": m.Name, "display_name": m.DisplayName,
"input_price": m.InputPrice, "output_price": m.OutputPrice, "cache_read_price": m.CacheReadPrice,
"enabled": m.Enabled, "sort": m.Sort, "channels": chs,
"used": used, "needs_pricing": needsPricing, "denied": denied,
})
}
var orphans []struct {
ChannelName string
UpstreamModel string
ModelID uint64
}
h.db.Raw(`SELECT c.name as channel_name, b.model_id, b.upstream_model
FROM channel_model_bindings b
LEFT JOIN models m ON m.id = b.model_id
LEFT JOIN channels c ON c.id = b.channel_id
WHERE m.id IS NULL`).Scan(&orphans)
missing := make([]gin.H, 0, len(orphans))
for _, o := range orphans {
missing = append(missing, gin.H{
"channel": o.ChannelName, "model_id": o.ModelID, "upstream_model": o.UpstreamModel,
})
}
unpriced := 0
{
var usedBindings []struct {
ModelID uint64
}
h.db.Model(&store.ChannelModelBinding{}).Distinct("model_id").Scan(&usedBindings)
usedIDs := map[uint64]bool{}
for _, u := range usedBindings {
usedIDs[u.ModelID] = true
}
for _, m := range ms {
if usedIDs[m.ID] && m.InputPrice == 0 && m.OutputPrice == 0 && m.CacheReadPrice == 0 {
unpriced++
}
}
}
c.JSON(http.StatusOK, gin.H{
"data": out,
"summary": gin.H{
"total": len(ms),
"unpriced": unpriced,
"missing": missing,
"denied_count": len(deny),
},
})
}
// modelPolicyConfig 读取全局模型允许/禁止列表。
func (h *Handler) modelPolicyConfig() (allow, deny []string) {
var raw string
h.db.Model(&store.SystemConfig{}).Where("key = ?", "model_allowlist").Pluck("value", &raw)
_ = json.Unmarshal([]byte(raw), &allow)
raw = ""
h.db.Model(&store.SystemConfig{}).Where("key = ?", "model_denylist").Pluck("value", &raw)
_ = json.Unmarshal([]byte(raw), &deny)
return
}
func containsStr(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
// AdminCreateModel POST /api/admin/models
func (h *Handler) AdminCreateModel(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required,min=1,max=128"`
DisplayName string `json:"display_name"`
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
CacheReadPrice float64 `json:"cache_read_price"`
Sort int `json:"sort"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input: " + err.Error()})
return
}
m := store.Model{
Name: req.Name, DisplayName: req.DisplayName,
InputPrice: req.InputPrice, OutputPrice: req.OutputPrice, CacheReadPrice: req.CacheReadPrice,
Sort: req.Sort, Enabled: boolOr(req.Enabled, true),
}
if err := h.db.Create(&m).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "failed to create model (name may already exist)"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": m.ID, "name": m.Name})
}
// AdminUpdateModel PUT /api/admin/models/:id — 价格/启停/排序。
func (h *Handler) AdminUpdateModel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid model id"})
return
}
var req struct {
DisplayName *string `json:"display_name"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
Enabled *bool `json:"enabled"`
Sort *int `json:"sort"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
return
}
var m store.Model
if err := h.db.First(&m, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
return
}
updates := map[string]any{}
if req.DisplayName != nil {
updates["display_name"] = *req.DisplayName
}
if req.InputPrice != nil {
updates["input_price"] = *req.InputPrice
}
if req.OutputPrice != nil {
updates["output_price"] = *req.OutputPrice
}
if req.CacheReadPrice != nil {
updates["cache_read_price"] = *req.CacheReadPrice
}
if req.Enabled != nil {
updates["enabled"] = *req.Enabled
}
if req.Sort != nil {
updates["sort"] = *req.Sort
}
if len(updates) > 0 {
if err := h.db.Model(&m).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update model"})
return
}
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminDeleteModel DELETE /api/admin/models/:id
func (h *Handler) AdminDeleteModel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid model id"})
return
}
res := h.db.Delete(&store.Model{}, id)
if res.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete model"})
return
}
if res.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
return
}
h.db.Where("model_id = ?", id).Delete(&store.ChannelModelBinding{})
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminDeleteUnusedModels DELETE /api/admin/models/unused — 一键清除未绑定任何渠道的模型。
func (h *Handler) AdminDeleteUnusedModels(c *gin.Context) {
var orphans []store.Model
if err := h.db.Where("id NOT IN (SELECT DISTINCT model_id FROM channel_model_bindings)").Find(&orphans).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load models"})
return
}
names := make([]string, 0, len(orphans))
ids := make([]uint64, 0, len(orphans))
for _, m := range orphans {
names = append(names, m.Name)
ids = append(ids, m.ID)
}
if len(ids) > 0 {
if err := h.db.Delete(&store.Model{}, ids).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete models"})
return
}
}
c.JSON(http.StatusOK, gin.H{"deleted": names, "count": len(names)})
}
// AdminCreateModelBinding POST /api/admin/models/:id/bindings
func (h *Handler) AdminCreateModelBinding(c *gin.Context) {
modelID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid model id"})
return
}
var req struct {
ChannelID uint64 `json:"channel_id" binding:"required"`
UpstreamModel string `json:"upstream_model" binding:"required"`
Weight *int `json:"weight"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input: channel_id and upstream_model required"})
return
}
var m store.Model
if err := h.db.First(&m, modelID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
return
}
var ch store.Channel
if err := h.db.First(&ch, req.ChannelID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
b := store.ChannelModelBinding{
ChannelID: req.ChannelID, ModelID: modelID,
UpstreamModel: req.UpstreamModel, Weight: intOr(req.Weight, 1),
}
if err := h.db.Create(&b).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "binding may already exist"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": b.ID})
}
// AdminDeleteModelBinding DELETE /api/admin/models/:id/bindings/:bid
func (h *Handler) AdminDeleteModelBinding(c *gin.Context) {
bid, err := strconv.ParseUint(c.Param("bid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid binding id"})
return
}
res := h.db.Delete(&store.ChannelModelBinding{}, bid)
if res.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete binding"})
return
}
if res.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "binding not found"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
var _ = gorm.ErrRecordNotFound
+886
View File
@@ -0,0 +1,886 @@
package api
import (
"net/http"
"opencatd-open/internal/dao"
"opencatd-open/internal/passkey"
"opencatd-open/internal/store"
"opencatd-open/internal/pkg/apikey"
"opencatd-open/internal/pkg/crypto"
"opencatd-open/internal/pkg/jwt"
"opencatd-open/internal/auth"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type Handler struct {
db *gorm.DB
userDAO *dao.UserDAO
apiKeyDAO *dao.ApiKeyDAO
channelDAO *dao.ChannelDAO
modelDAO *dao.ModelDAO
usageDAO *dao.UsageDAO
dailyDAO *dao.DailyUsageDAO
passkeys *passkey.Service
}
func NewHandler(db *gorm.DB, passkeys *passkey.Service) *Handler {
return &Handler{
db: db,
userDAO: dao.NewUserDAO(db),
apiKeyDAO: dao.NewApiKeyDAO(db),
channelDAO: dao.NewChannelDAO(db),
modelDAO: dao.NewModelDAO(db),
usageDAO: dao.NewUsageDAO(db),
dailyDAO: dao.NewDailyUsageDAO(db),
passkeys: passkeys,
}
}
// --- Helpers ---
func userToResponse(user *store.User) gin.H {
roleNum := 1
if user.Role == store.RoleAdmin {
roleNum = 10
}
return gin.H{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"role": roleNum,
"status": user.Status,
"balance": user.Balance,
"created_at": user.CreatedAt,
"updated_at": user.UpdatedAt,
"last_login_at": user.LastLoginAt,
}
}
// --- Auth ---
func (h *Handler) Register(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
Email string `json:"email" binding:"omitempty,email"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Check if first user (becomes admin)
var count int64
h.db.Model(&store.User{}).Count(&count)
role := store.RoleUser
if count == 0 {
role = store.RoleAdmin
}
hash := crypto.Sha256Hex(req.Password)
email := req.Email
if email == "" {
email = req.Username + "@placeholder.local"
}
user := &store.User{
Username: req.Username,
Email: email,
PasswordHash: hash,
Role: role,
Status: store.UserStatusActive,
}
if err := h.userDAO.Create(user); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "username or email already exists"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "registered"})
}
func (h *Handler) Login(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := h.userDAO.GetByUsername(req.Username)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
hash := crypto.Sha256Hex(req.Password)
if user.PasswordHash != hash {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
secret := auth.GetSecretKey()
accessToken, refreshToken, err := jwt.GenerateTokenPair(user.ID, user.Username, user.Role, secret, 24*time.Hour, 7*24*time.Hour)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
return
}
// Update last login
now := time.Now()
user.LastLoginAt = &now
h.userDAO.Update(user)
c.JSON(http.StatusOK, gin.H{
"code": 200,
"data": gin.H{
"token": accessToken,
"access_token": accessToken,
"refresh_token": refreshToken,
},
})
}
func (h *Handler) Me(c *gin.Context) {
userID, _ := c.Get("user_id")
user, err := h.userDAO.GetByID(userID.(uint64))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"data": userToResponse(user),
})
}
func (h *Handler) UpdateProfile(c *gin.Context) {
userID, _ := c.Get("user_id")
user, err := h.userDAO.GetByID(userID.(uint64))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
var req struct {
Email string `json:"email"`
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Email != "" {
user.Email = req.Email
}
if err := h.userDAO.Update(user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "profile updated"})
}
func (h *Handler) UpdatePassword(c *gin.Context) {
userID, _ := c.Get("user_id")
user, err := h.userDAO.GetByID(userID.(uint64))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
var req struct {
Password string `json:"password" binding:"required"`
NewPassword string `json:"newpassword" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify old password
oldHash := crypto.Sha256Hex(req.Password)
if user.PasswordHash != oldHash {
c.JSON(http.StatusBadRequest, gin.H{"error": "incorrect password"})
return
}
// Update to new password
user.PasswordHash = crypto.Sha256Hex(req.NewPassword)
if err := h.userDAO.Update(user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "password updated"})
}
// --- Users ---
func (h *Handler) ListUsers(c *gin.Context) {
// Support both limit/offset and pageSize/page parameters
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
// If pageSize/page are provided, use them instead
if pageSize := c.Query("pageSize"); pageSize != "" {
if size, err := strconv.Atoi(pageSize); err == nil && size > 0 {
limit = size
}
}
if page := c.Query("page"); page != "" {
if p, err := strconv.Atoi(page); err == nil && p > 0 {
offset = (p - 1) * limit
}
}
users, total, err := h.userDAO.List(limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
data := make([]gin.H, len(users))
for i, u := range users {
data[i] = userToResponse(u)
}
c.JSON(http.StatusOK, gin.H{"data": data, "total": total})
}
func (h *Handler) GetUser(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
user, err := h.userDAO.GetByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusOK, gin.H{
"data": userToResponse(user),
})
}
func (h *Handler) CreateUser(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
Email string `json:"email" binding:"required,email"`
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
role := store.RoleUser
if req.Role != "" {
role = req.Role
}
hash := crypto.Sha256Hex(req.Password)
user := &store.User{
Username: req.Username,
Email: req.Email,
PasswordHash: hash,
Role: role,
Status: store.UserStatusActive,
}
if err := h.userDAO.Create(user); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "username or email already exists"})
return
}
c.JSON(http.StatusOK, userToResponse(user))
}
func (h *Handler) UpdateUser(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
user, err := h.userDAO.GetByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
var req struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Role string `json:"role"`
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Username != "" {
user.Username = req.Username
}
if req.Email != "" {
user.Email = req.Email
}
if req.Password != "" {
user.PasswordHash = crypto.Sha256Hex(req.Password)
}
if req.Role != "" {
user.Role = req.Role
}
if req.Status != "" {
user.Status = req.Status
}
if err := h.userDAO.Update(user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, userToResponse(user))
}
func (h *Handler) BatchUsers(c *gin.Context) {
option := c.Param("option")
var req struct {
IDs []uint64 `json:"ids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
for _, id := range req.IDs {
user, err := h.userDAO.GetByID(id)
if err != nil {
continue
}
switch option {
case "enable":
user.Status = store.UserStatusActive
case "disable":
user.Status = store.UserStatusDisabled
case "delete":
h.userDAO.Delete(id)
continue
}
h.userDAO.Update(user)
}
c.JSON(http.StatusOK, gin.H{"message": "batch operation completed"})
}
func (h *Handler) DeleteUser(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.userDAO.Delete(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
// --- API Keys ---
func (h *Handler) ListApiKeys(c *gin.Context) {
userID, _ := c.Get("user_id")
// Support both limit/offset and pageSize/page parameters
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
// If pageSize/page are provided, use them instead
if pageSize := c.Query("pageSize"); pageSize != "" {
if size, err := strconv.Atoi(pageSize); err == nil && size > 0 {
limit = size
}
}
if page := c.Query("page"); page != "" {
if p, err := strconv.Atoi(page); err == nil && p > 0 {
offset = (p - 1) * limit
}
}
keys, total, err := h.apiKeyDAO.ListByUserID(userID.(uint64), limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": keys, "total": total})
}
func (h *Handler) GetApiKey(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
key, err := h.apiKeyDAO.GetByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "key not found"})
return
}
c.JSON(http.StatusOK, gin.H{"data": key})
}
func (h *Handler) CreateApiKey(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day"`
QuotaRequestsPerDay *int `json:"quota_requests_per_day"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, _ := c.Get("user_id")
keyValue, _ := apikey.Generate()
key := &store.APIKey{
UserID: userID.(uint64),
Name: req.Name,
KeyHash: apikey.Hash(keyValue),
KeyPlain: keyValue,
KeyPrefix: keyValue[:12],
QuotaTokensPerDay: req.QuotaTokensPerDay,
QuotaRequestsPerDay: req.QuotaRequestsPerDay,
Status: store.KeyStatusActive,
}
if err := h.apiKeyDAO.Create(key); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"key": keyValue,
"data": key,
})
}
func (h *Handler) UpdateApiKey(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
key, err := h.apiKeyDAO.GetByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "key not found"})
return
}
var req struct {
Name string `json:"name"`
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day"`
QuotaRequestsPerDay *int `json:"quota_requests_per_day"`
AllowedModels []string `json:"allowed_models"`
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Name != "" {
key.Name = req.Name
}
if req.QuotaTokensPerDay != nil {
key.QuotaTokensPerDay = req.QuotaTokensPerDay
}
if req.QuotaRequestsPerDay != nil {
key.QuotaRequestsPerDay = req.QuotaRequestsPerDay
}
if req.AllowedModels != nil {
key.AllowedModels = req.AllowedModels
}
if req.Status != "" {
key.Status = req.Status
}
if err := h.apiKeyDAO.Update(key); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": key})
}
func (h *Handler) BatchApiKeys(c *gin.Context) {
option := c.Param("option")
var req struct {
IDs []uint64 `json:"ids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
switch option {
case "delete":
if err := h.apiKeyDAO.BatchDelete(req.IDs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"message": "batch operation completed"})
}
func (h *Handler) DeleteApiKey(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.apiKeyDAO.Delete(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
// --- Legacy Channel endpoints (kept for backward compatibility) ---
func (h *Handler) ListChannels(c *gin.Context) {
// Support both limit/offset and pageSize/page parameters
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
// If pageSize/page are provided, use them instead
if pageSize := c.Query("pageSize"); pageSize != "" {
if size, err := strconv.Atoi(pageSize); err == nil && size > 0 {
limit = size
}
}
if page := c.Query("page"); page != "" {
if p, err := strconv.Atoi(page); err == nil && p > 0 {
offset = (p - 1) * limit
}
}
channels, total, err := h.channelDAO.List(limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": channels, "total": total})
}
func (h *Handler) CreateChannel(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Provider string `json:"provider" binding:"required"`
BaseURL string `json:"base_url" binding:"required"`
APIKey string `json:"api_key" binding:"required"`
Priority int `json:"priority"`
Weight int `json:"weight"`
Formats []string `json:"formats"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
encrypted, err := crypto.Encrypt(req.APIKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
return
}
if req.Weight == 0 {
req.Weight = 1
}
ch := &store.Channel{
Name: req.Name,
Provider: req.Provider,
BaseURL: req.BaseURL,
APIKeyEnc: encrypted,
Weight: req.Weight,
Priority: req.Priority,
Formats: req.Formats,
Enabled: true,
}
if err := h.channelDAO.Create(ch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "channel name already exists"})
return
}
c.JSON(http.StatusOK, ch)
}
func (h *Handler) UpdateChannel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
ch, err := h.channelDAO.GetByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
var req struct {
Name string `json:"name"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Priority *int `json:"priority"`
Weight *int `json:"weight"`
Formats []string `json:"formats"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Name != "" {
ch.Name = req.Name
}
if req.BaseURL != "" {
ch.BaseURL = req.BaseURL
}
if req.APIKey != "" {
encrypted, err := crypto.Encrypt(req.APIKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
return
}
ch.APIKeyEnc = encrypted
}
if req.Priority != nil {
ch.Priority = *req.Priority
}
if req.Weight != nil {
ch.Weight = *req.Weight
}
if req.Formats != nil {
ch.Formats = req.Formats
}
if req.Enabled != nil {
ch.Enabled = *req.Enabled
}
if err := h.channelDAO.Update(ch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, ch)
}
func (h *Handler) DeleteChannel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.channelDAO.Delete(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
// --- Legacy Model endpoints (kept for backward compatibility) ---
func (h *Handler) ListModels(c *gin.Context) {
// Support both limit/offset and pageSize/page parameters
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
// If pageSize/page are provided, use them instead
if pageSize := c.Query("pageSize"); pageSize != "" {
if size, err := strconv.Atoi(pageSize); err == nil && size > 0 {
limit = size
}
}
if page := c.Query("page"); page != "" {
if p, err := strconv.Atoi(page); err == nil && p > 0 {
offset = (p - 1) * limit
}
}
models, total, err := h.modelDAO.List(limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": models, "total": total})
}
func (h *Handler) CreateModel(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
DisplayName string `json:"display_name"`
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
CacheReadPrice float64 `json:"cache_read_price"`
Sort int `json:"sort"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
m := &store.Model{
Name: req.Name,
DisplayName: req.DisplayName,
InputPrice: req.InputPrice,
OutputPrice: req.OutputPrice,
CacheReadPrice: req.CacheReadPrice,
Sort: req.Sort,
Enabled: true,
}
if err := h.modelDAO.Create(m); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "model name already exists"})
return
}
c.JSON(http.StatusOK, m)
}
func (h *Handler) UpdateModel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
m, err := h.modelDAO.GetByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
return
}
var req struct {
DisplayName string `json:"display_name"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
Sort *int `json:"sort"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.DisplayName != "" {
m.DisplayName = req.DisplayName
}
if req.InputPrice != nil {
m.InputPrice = *req.InputPrice
}
if req.OutputPrice != nil {
m.OutputPrice = *req.OutputPrice
}
if req.CacheReadPrice != nil {
m.CacheReadPrice = *req.CacheReadPrice
}
if req.Sort != nil {
m.Sort = *req.Sort
}
if req.Enabled != nil {
m.Enabled = *req.Enabled
}
if err := h.modelDAO.Update(m); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, m)
}
func (h *Handler) DeleteModel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.modelDAO.Delete(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
// --- Legacy Channel-Model Bindings (kept for backward compatibility) ---
func (h *Handler) BindChannelModels(c *gin.Context) {
channelID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
return
}
var req struct {
Bindings []struct {
ModelID uint64 `json:"model_id"`
UpstreamModel string `json:"upstream_model"`
Weight int `json:"weight"`
} `json:"bindings"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
bindings := make([]store.ChannelModelBinding, len(req.Bindings))
for i, b := range req.Bindings {
bindings[i] = store.ChannelModelBinding{
ChannelID: channelID,
ModelID: b.ModelID,
UpstreamModel: b.UpstreamModel,
Weight: b.Weight,
}
}
if err := h.channelDAO.BindModels(channelID, bindings); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "bound"})
}
func (h *Handler) GetChannelModels(c *gin.Context) {
channelID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
return
}
bindings, err := h.channelDAO.GetChannelModels(channelID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": bindings})
}
+162
View File
@@ -0,0 +1,162 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"opencatd-open/internal/auth"
"opencatd-open/internal/pkg/jwt"
"opencatd-open/internal/store"
)
// PasskeyRegisterBegin POST /api/webauthn/register/begin — 生成注册选项。
func (h *Handler) PasskeyRegisterBegin(c *gin.Context) {
userID, _ := c.Get("user_id")
u, err := h.passkeys.GetUserByID(userID.(uint64))
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return
}
creation, err := h.passkeys.BeginRegistration(u)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to begin registration: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"creation": creation, "challenge": creation.Response.Challenge}})
}
// PasskeyRegisterComplete POST /api/webauthn/register/complete — 校验并保存凭据。
func (h *Handler) PasskeyRegisterComplete(c *gin.Context) {
userID, _ := c.Get("user_id")
u, err := h.passkeys.GetUserByID(userID.(uint64))
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return
}
var req struct {
Challenge string `json:"challenge"`
Name string `json:"name"`
Credential json.RawMessage `json:"credential"`
}
if err := c.ShouldBindJSON(&req); err != nil || len(req.Credential) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
return
}
if err := h.passkeys.FinishRegistration(u, req.Challenge, req.Credential, req.Name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "passkey 注册失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
}
// PasskeyLoginBegin POST /api/auth/passkey/begin — 生成断言选项。
// 传 username 用指定用户;不传则用可发现凭据(平台 passkey)。
func (h *Handler) PasskeyLoginBegin(c *gin.Context) {
var req struct {
Username string `json:"username"`
}
_ = c.ShouldBindJSON(&req)
if req.Username != "" {
u, err := h.passkeys.GetUserByUsername(req.Username)
if err != nil || u.Status != store.UserStatusActive {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
assertion, err := h.passkeys.BeginLogin(u)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to begin login: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"assertion": assertion, "challenge": assertion.Response.Challenge, "user_id": u.ID}})
return
}
assertion, err := h.passkeys.BeginDiscoverableLogin()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to begin login: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"assertion": assertion, "challenge": assertion.Response.Challenge}})
}
// PasskeyLoginComplete POST /api/auth/passkey/finish — 校验断言并发放令牌。
func (h *Handler) PasskeyLoginComplete(c *gin.Context) {
var req struct {
Challenge string `json:"challenge"`
Credential json.RawMessage `json:"credential"`
UserID uint64 `json:"user_id"`
}
if err := c.ShouldBindJSON(&req); err != nil || len(req.Credential) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
return
}
var u *store.User
if req.UserID > 0 {
var err error
u, err = h.passkeys.GetUserByID(req.UserID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
return
}
if err := h.passkeys.FinishLogin(u, req.Challenge, req.Credential); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "passkey 校验失败: " + err.Error()})
return
}
} else {
var err error
u, err = h.passkeys.FinishDiscoverableLogin(req.Challenge, req.Credential)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "passkey 校验失败: " + err.Error()})
return
}
}
if u.Status != store.UserStatusActive {
c.JSON(http.StatusForbidden, gin.H{"error": "user account disabled"})
return
}
secret := auth.GetSecretKey()
accessToken, refreshToken, err := jwt.GenerateTokenPair(u.ID, u.Username, u.Role, secret, 24*time.Hour, 7*24*time.Hour)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to issue token"})
return
}
c.JSON(http.StatusOK, gin.H{
"data": gin.H{
"token": accessToken,
"access_token": accessToken,
"refresh_token": refreshToken,
},
})
}
// PasskeyList GET /api/profile/passkeys — 当前用户的 passkey 列表。
func (h *Handler) PasskeyList(c *gin.Context) {
userID, _ := c.Get("user_id")
pks, err := h.passkeys.List(userID.(uint64))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load passkeys"})
return
}
out := make([]gin.H, 0, len(pks))
for _, pk := range pks {
out = append(out, gin.H{"id": pk.ID, "name": pk.Name, "created_at": pk.CreatedAt})
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
// PasskeyDelete DELETE /api/profile/passkeys/:id — 解除绑定。
func (h *Handler) PasskeyDelete(c *gin.Context) {
userID, _ := c.Get("user_id")
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid passkey id"})
return
}
if err := h.passkeys.Delete(userID.(uint64), id); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "passkey not found"})
return
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
}
+391
View File
@@ -0,0 +1,391 @@
package api
import (
"fmt"
"net/http"
"sort"
"strconv"
"time"
"opencatd-open/internal/dao"
"opencatd-open/internal/store"
"github.com/gin-gonic/gin"
)
// --- 普通用户:自身用量统计与明细 ---
// MyUsageStats GET /api/usage/stats?days=30 — 当前用户的每日用量聚合。
func (h *Handler) MyUsageStats(c *gin.Context) {
userID, _ := c.Get("user_id")
uid, _ := userID.(uint64)
days := 30
if d := c.Query("days"); d != "" {
if n, err := strconv.Atoi(d); err == nil && n > 0 && n <= 365 {
days = n
}
}
end := time.Now()
start := end.AddDate(0, 0, -days)
dailies, err := h.dailyDAO.ListByDateRange(c.Request.Context(), uid, start, end)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load usage"})
return
}
// 按日期聚合(每日可能多模型多行)
byDate := map[string]*store.UsageDaily{}
var dates []string
for i := range dailies {
d := dailies[i]
agg, ok := byDate[d.Date]
if !ok {
agg = &store.UsageDaily{Date: d.Date}
byDate[d.Date] = agg
dates = append(dates, d.Date)
}
agg.Requests += d.Requests
agg.InputTokens += d.InputTokens
agg.OutputTokens += d.OutputTokens
agg.CacheReadTokens += d.CacheReadTokens
agg.Cost += d.Cost
}
// 汇总
var totalRequests, totalInput, totalOutput, totalCache int64
var totalCost float64
for _, d := range byDate {
totalRequests += d.Requests
totalInput += d.InputTokens
totalOutput += d.OutputTokens
totalCache += d.CacheReadTokens
totalCost += d.Cost
}
c.JSON(http.StatusOK, gin.H{
"data": gin.H{
"dates": dates,
"daily": byDate,
"totals": gin.H{
"requests": totalRequests,
"input_tokens": totalInput,
"output_tokens": totalOutput,
"cache_read_tokens": totalCache,
"cost": totalCost,
},
},
})
}
// MyUsageMonthly GET /api/usage/monthly?year=2026 — 当前用户年度按自然月聚合,
// 每月含按模型分解(供月度堆叠柱状图使用)。
func (h *Handler) MyUsageMonthly(c *gin.Context) {
userID, _ := c.Get("user_id")
uid, _ := userID.(uint64)
year := time.Now().Year()
if y := c.Query("year"); y != "" {
if n, err := strconv.Atoi(y); err == nil && n >= 2000 && n <= 2100 {
year = n
}
}
start := time.Date(year, 1, 1, 0, 0, 0, 0, time.Local)
end := start.AddDate(1, 0, -1)
dailies, err := h.dailyDAO.ListByDateRange(c.Request.Context(), uid, start, end)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load usage"})
return
}
// 补齐模型名(模型可能已被删除,回退为 模型#id)
modelIDs := make([]uint64, 0, len(dailies))
seen := map[uint64]bool{}
for _, d := range dailies {
if !seen[d.ModelID] {
seen[d.ModelID] = true
modelIDs = append(modelIDs, d.ModelID)
}
}
modelNames := map[uint64]string{}
if len(modelIDs) > 0 {
var models []store.Model
if err := h.db.Where("id IN ?", modelIDs).Find(&models).Error; err == nil {
for _, m := range models {
modelNames[m.ID] = m.Name
}
}
}
type modelAgg struct {
ModelID uint64 `json:"model_id"`
ModelName string `json:"model_name"`
Requests int64 `json:"requests"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens"`
Cost float64 `json:"cost"`
}
type monthAgg struct {
Month string `json:"month"`
Requests int64 `json:"requests"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens"`
Cost float64 `json:"cost"`
Models map[uint64]*modelAgg `json:"-"`
}
months := make([]*monthAgg, 12)
for i := range months {
months[i] = &monthAgg{
Month: fmt.Sprintf("%d-%02d", year, i+1),
Models: map[uint64]*modelAgg{},
}
}
for _, d := range dailies {
mm, err := strconv.Atoi(d.Date[5:7])
if err != nil || mm < 1 || mm > 12 {
continue
}
m := months[mm-1]
m.Requests += d.Requests
m.InputTokens += d.InputTokens
m.OutputTokens += d.OutputTokens
m.CacheReadTokens += d.CacheReadTokens
m.Cost += d.Cost
ma, ok := m.Models[d.ModelID]
if !ok {
name := modelNames[d.ModelID]
if name == "" {
name = fmt.Sprintf("模型#%d", d.ModelID)
}
ma = &modelAgg{ModelID: d.ModelID, ModelName: name}
m.Models[d.ModelID] = ma
}
ma.Requests += d.Requests
ma.InputTokens += d.InputTokens
ma.OutputTokens += d.OutputTokens
ma.CacheReadTokens += d.CacheReadTokens
ma.Cost += d.Cost
}
out := make([]gin.H, 12)
for i, m := range months {
modelList := make([]*modelAgg, 0, len(m.Models))
for _, ma := range m.Models {
modelList = append(modelList, ma)
}
// 模型按 token 总量降序,柱状图图例顺序与之一致
sort.Slice(modelList, func(a, b int) bool {
ta := modelList[a].InputTokens + modelList[a].OutputTokens + modelList[a].CacheReadTokens
tb := modelList[b].InputTokens + modelList[b].OutputTokens + modelList[b].CacheReadTokens
return ta > tb
})
out[i] = gin.H{
"month": m.Month,
"requests": m.Requests,
"input_tokens": m.InputTokens,
"output_tokens": m.OutputTokens,
"cache_read_tokens": m.CacheReadTokens,
"cost": m.Cost,
"models": modelList,
}
}
c.JSON(http.StatusOK, gin.H{
"data": gin.H{
"year": year,
"months": out,
},
})
}
// MyUsageLogs GET /api/usage/logs?page=1&pageSize=20 — 当前用户的用量明细(分页)。
func (h *Handler) MyUsageLogs(c *gin.Context) {
userID, _ := c.Get("user_id")
uid, _ := userID.(uint64)
limit, offset := paginate(c, 20)
logs, err := h.usageDAO.ListByUserID(c.Request.Context(), uid, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load usage logs"})
return
}
total, err := h.usageDAO.CountByUserID(c.Request.Context(), uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count usage logs"})
return
}
valLogs := make([]store.UsageLog, len(logs))
for i, l := range logs {
valLogs[i] = *l
}
c.JSON(http.StatusOK, gin.H{"data": usageLogsToResp(valLogs, nil), "total": total})
}
// --- 管理后台:全量用量明细 ---
// AdminUsageLogs GET /api/admin/usage/logs?page=&pageSize=&protocol=&status=&model=&user_id=
func (h *Handler) AdminUsageLogs(c *gin.Context) {
f := daoUsageFilter(c)
logs, err := h.usageDAO.ListAll(c.Request.Context(), f)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load usage logs"})
return
}
total, err := h.usageDAO.CountAll(c.Request.Context(), f)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count usage logs"})
return
}
names := h.userNames(logs)
c.JSON(http.StatusOK, gin.H{
"data": usageLogsToResp(logs, names),
"total": total,
})
}
// AdminUsageSummary GET /api/admin/usage/summary?start=&end=&user_id= — 全量汇总。
func (h *Handler) AdminUsageSummary(c *gin.Context) {
var uidPtr *uint64
if v := c.Query("user_id"); v != "" {
if n, err := strconv.ParseUint(v, 10, 64); err == nil && n > 0 {
uidPtr = &n
}
}
dailies, err := h.dailyDAO.ListAll(c.Request.Context(), uidPtr, c.Query("start"), c.Query("end"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load usage"})
return
}
var totalRequests, totalInput, totalOutput, totalCache int64
var totalCost float64
perUser := map[uint64]*gin.H{}
for _, d := range dailies {
totalRequests += d.Requests
totalInput += d.InputTokens
totalOutput += d.OutputTokens
totalCache += d.CacheReadTokens
totalCost += d.Cost
u, ok := perUser[d.UserID]
if !ok {
u = &gin.H{"user_id": d.UserID, "requests": int64(0), "input_tokens": int64(0), "output_tokens": int64(0), "cost": float64(0)}
perUser[d.UserID] = u
}
(*u)["requests"] = (*u)["requests"].(int64) + d.Requests
(*u)["input_tokens"] = (*u)["input_tokens"].(int64) + d.InputTokens
(*u)["output_tokens"] = (*u)["output_tokens"].(int64) + d.OutputTokens
(*u)["cost"] = (*u)["cost"].(float64) + d.Cost
}
c.JSON(http.StatusOK, gin.H{
"data": gin.H{
"totals": gin.H{
"requests": totalRequests,
"input_tokens": totalInput,
"output_tokens": totalOutput,
"cache_read_tokens": totalCache,
"cost": totalCost,
},
"per_user": perUser,
},
})
}
// --- helpers ---
// paginate 解析 page/pageSize 查询参数,返回 limit/offset。
func paginate(c *gin.Context, defSize int) (int, int) {
limit := defSize
offset := 0
if pageSize := c.Query("pageSize"); pageSize != "" {
if n, err := strconv.Atoi(pageSize); err == nil && n > 0 && n <= 100 {
limit = n
}
}
if page := c.Query("page"); page != "" {
if p, err := strconv.Atoi(page); err == nil && p > 0 {
offset = (p - 1) * limit
}
}
return limit, offset
}
func daoUsageFilter(c *gin.Context) dao.UsageFilter {
limit, offset := paginate(c, 20)
f := dao.UsageFilter{Limit: limit, Offset: offset}
f.Protocol = c.Query("protocol")
f.Status = c.Query("status")
f.ModelName = c.Query("model")
if v := c.Query("user_id"); v != "" {
if n, err := strconv.ParseUint(v, 10, 64); err == nil && n > 0 {
f.UserID = &n
}
}
return f
}
func usageLogsToResp(logs []store.UsageLog, names map[uint64]string) []gin.H {
out := make([]gin.H, 0, len(logs))
for _, l := range logs {
row := gin.H{
"id": l.ID,
"request_id": l.RequestID,
"user_id": l.UserID,
"channel_id": l.ChannelID,
"model_id": l.ModelID,
"model_name": l.ModelName,
"protocol": l.Protocol,
"input_tokens": l.InputTokens,
"output_tokens": l.OutputTokens,
"cache_read_tokens": l.CacheReadTokens,
"cache_creation_tokens": l.CacheCreationTokens,
"cost": l.Cost,
"latency_ms": l.LatencyMS,
"status": l.Status,
"error_code": l.ErrorCode,
"created_at": l.CreatedAt,
}
if names != nil {
if u, ok := names[l.UserID]; ok {
row["username"] = u
}
}
if l.RawRequest != "" {
row["raw_request"] = l.RawRequest
}
if l.RawResponse != "" {
row["raw_response"] = l.RawResponse
}
out = append(out, row)
}
return out
}
// userNames 批量查询 user_id → username 映射。
func (h *Handler) userNames(logs []store.UsageLog) map[uint64]string {
ids := map[uint64]bool{}
for _, l := range logs {
ids[l.UserID] = true
}
if len(ids) == 0 {
return nil
}
idList := make([]uint64, 0, len(ids))
for id := range ids {
idList = append(idList, id)
}
var users []store.User
if err := h.db.Where("id IN ?", idList).Find(&users).Error; err != nil {
return nil
}
out := map[uint64]string{}
for _, u := range users {
out[u.ID] = u.Username
}
return out
}
@@ -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
}
+292
View File
@@ -0,0 +1,292 @@
package channel
import (
"context"
"math/rand"
"opencatd-open/internal/dao"
"opencatd-open/internal/store"
"opencatd-open/internal/pkg/crypto"
"sync"
"time"
)
type Service struct {
channelDAO *dao.ChannelDAO
modelDAO *dao.ModelDAO
// Health tracking
mu sync.RWMutex
healthStatus map[uint64]*channelHealth
// Concurrency control per channel
sems map[uint64]chan struct{}
}
type channelHealth struct {
status string
consecutive int
lastCheck time.Time
cooldown time.Time
}
func NewService(channelDAO *dao.ChannelDAO, modelDAO *dao.ModelDAO) *Service {
return &Service{
channelDAO: channelDAO,
modelDAO: modelDAO,
healthStatus: make(map[uint64]*channelHealth),
sems: make(map[uint64]chan struct{}),
}
}
// SelectedRoute 一次路由决策的完整结果:渠道 + 命中的模型绑定。
// Binding 可能为 nil(渠道经回退路径选中、无绑定记录)。
type SelectedRoute struct {
Channel *store.Channel
Binding *store.ChannelModelBinding
}
// Candidates 返回可用渠道候选:健康 + 启用。
// model 非空时优先取绑定该模型的渠道(携带 upstream_model 映射,权重降序);
// 无绑定则回退到未绑定模型路径:按权重升序(闲置渠道优先探活)。
func (s *Service) Candidates(model string) []Candidate {
if model != "" {
var b []store.ChannelModelBinding
var modelIDs []uint64
s.modelDAO.DB().Model(&store.Model{}).Where("name = ? AND enabled = ?", model, true).Pluck("id", &modelIDs)
if len(modelIDs) > 0 {
s.channelDAO.DB().Where("model_id IN ?", modelIDs).Find(&b)
if cands := s.loadBound(b); len(cands) > 0 {
return cands
}
}
}
// 未绑定模型回退:取优先级最低的空闲健康渠道作为"备用渠道"承接搭车流量
// (排序与绑定候选一致:priority ASC, weight DESC, id ASC,取末位)。
// weight=0 的渠道不被加权随机选中,但可作为最后备用承接 unbound 流量。
var chs []store.Channel
s.channelDAO.DB().Where("enabled = ?", true).
Order("priority ASC, weight DESC, id ASC").Find(&chs)
all := make([]Candidate, 0, len(chs))
for i := range chs {
all = append(all, Candidate{Channel: &chs[i]})
}
healthy := s.FilterHealthy(all)
if len(healthy) == 0 {
return nil
}
return healthy[len(healthy)-1:]
}
// loadBound 按绑定顺序加载渠道候选,过滤健康/启用,携带 upstream_model 映射。
func (s *Service) loadBound(bindings []store.ChannelModelBinding) []Candidate {
if len(bindings) == 0 {
return nil
}
// channel_id -> 绑定(取该渠道对该模型的映射)
byChannel := map[uint64]store.ChannelModelBinding{}
ids := make([]uint64, 0, len(bindings))
for _, b := range bindings {
if _, ok := byChannel[b.ChannelID]; !ok {
ids = append(ids, b.ChannelID)
}
byChannel[b.ChannelID] = b
}
var chs []store.Channel
s.channelDAO.DB().Where("id IN ? AND enabled = ? AND health_status = ?", ids, true, store.ChannelHealthHealthy).
Order("priority ASC, weight DESC, id ASC").Find(&chs)
byID := map[uint64]*store.Channel{}
for i := range chs {
byID[chs[i].ID] = &chs[i]
}
out := make([]Candidate, 0, len(ids))
for _, id := range ids {
if ch, ok := byID[id]; ok {
b := byChannel[id]
out = append(out, Candidate{Channel: ch, Binding: &b})
}
}
return out
}
// GetChannelByKeyID decrypts the API key for a channel
func (s *Service) GetChannelByKeyID(ctx context.Context, channelID uint64) (*store.Channel, error) {
ch, err := s.channelDAO.GetByID(channelID)
if err != nil {
return nil, err
}
return ch, nil
}
// GetAPIKey decrypts the channel's API key
func (s *Service) GetAPIKey(ch *store.Channel) (string, error) {
return crypto.Decrypt(ch.APIKeyEnc)
}
// RecordSuccess records a successful request to a channel
func (s *Service) RecordSuccess(channelID uint64) {
s.mu.Lock()
defer s.mu.Unlock()
h := s.getOrCreateHealth(channelID)
h.consecutive = 0
h.status = store.ChannelHealthHealthy
h.lastCheck = time.Now()
}
// RecordFailure records a failed request to a channel.
// 连续 2 次失败进入 degraded(快速熔断):失败过的渠道让位给健康渠道,
// 健康检查成功或冷却过期后复位。
func (s *Service) RecordFailure(channelID uint64) {
s.mu.Lock()
defer s.mu.Unlock()
h := s.getOrCreateHealth(channelID)
h.consecutive++
h.lastCheck = time.Now()
if h.consecutive >= 2 {
h.status = store.ChannelHealthDegraded
h.cooldown = time.Now().Add(5 * time.Minute)
}
if h.consecutive >= 5 {
h.status = store.ChannelHealthCooldown
h.cooldown = time.Now().Add(15 * time.Minute)
}
}
// RecordTimeout records a timeout to a channel
func (s *Service) RecordTimeout(channelID uint64) {
s.RecordFailure(channelID)
}
func (s *Service) getOrCreateHealth(channelID uint64) *channelHealth {
h, ok := s.healthStatus[channelID]
if !ok {
h = &channelHealth{
status: store.ChannelHealthHealthy,
}
s.healthStatus[channelID] = h
}
return h
}
func (s *Service) filterHealthy(channels []*store.Channel) []*store.Channel {
s.mu.RLock()
defer s.mu.RUnlock()
var healthy []*store.Channel
now := time.Now()
for _, ch := range channels {
h, ok := s.healthStatus[ch.ID]
if !ok {
healthy = append(healthy, ch)
continue
}
// Check if cooldown has expired
if now.After(h.cooldown) && h.cooldown.IsZero() == false {
h.consecutive = 0
h.status = store.ChannelHealthHealthy
healthy = append(healthy, ch)
continue
}
if h.status == store.ChannelHealthHealthy || h.status == store.ChannelHealthDegraded {
healthy = append(healthy, ch)
}
}
return healthy
}
// GetHealthStatus returns the health status of a channel
func (s *Service) GetHealthStatus(channelID uint64) string {
s.mu.RLock()
defer s.mu.RUnlock()
h, ok := s.healthStatus[channelID]
if !ok {
return store.ChannelHealthHealthy
}
return h.status
}
// Candidate 一个候选渠道 + 该模型的映射关系。
type Candidate struct {
Channel *store.Channel
Binding *store.ChannelModelBinding // 全局模型在此渠道的映射(无绑定则 nil)
}
// Pick 按权重加权随机选一个候选渠道(负载均衡;weight<=0 按 1 计)。
func (s *Service) Pick(cands []Candidate) *Candidate {
if len(cands) == 0 {
return nil
}
total := 0
for _, c := range cands {
w := c.Channel.Weight
if w <= 0 {
w = 1
}
total += w
}
r := rand.Intn(total)
acc := 0
for i := range cands {
w := cands[i].Channel.Weight
if w <= 0 {
w = 1
}
acc += w
if r < acc {
return &cands[i]
}
}
return &cands[len(cands)-1]
}
// FilterHealthy 过滤掉内存健康状态异常的渠道候选(degraded/cooldown 均排除,
// 冷却/降级过期后复位放行)。degraded 由单次请求失败触发,作为快速熔断:
// 后续请求先走其他渠道,健康检查成功后恢复。
func (s *Service) FilterHealthy(cands []Candidate) []Candidate {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Candidate, 0, len(cands))
now := time.Now()
for _, c := range cands {
h, ok := s.healthStatus[c.Channel.ID]
if !ok || h.status == store.ChannelHealthHealthy {
out = append(out, c)
continue
}
// 冷却/降级已过期:复位并放行
if !h.cooldown.IsZero() && now.After(h.cooldown) {
h.status = store.ChannelHealthHealthy
h.consecutive = 0
out = append(out, c)
}
}
return out
}
// TryAcquire 尝试获取渠道并发槽;渠道满载返回 false(调用方可溢出到其他渠道)。
// MaxConcurrency<=0 视为不限制。
func (s *Service) TryAcquire(ch *store.Channel) (func(), bool) {
if ch.MaxConcurrency <= 0 {
return func() {}, true
}
s.mu.Lock()
sem, ok := s.sems[ch.ID]
if !ok {
sem = make(chan struct{}, ch.MaxConcurrency)
s.sems[ch.ID] = sem
}
s.mu.Unlock()
select {
case sem <- struct{}{}:
return func() { <-sem }, true
default:
return nil, false
}
}
+125
View File
@@ -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)
}
})
}
}
+138
View File
@@ -0,0 +1,138 @@
package channel
import (
"context"
"fmt"
"opencatd-open/internal/dao"
"opencatd-open/internal/store"
"opencatd-open/internal/pkg/crypto"
"net/http"
"time"
)
// HealthConfig 健康检查配置
type HealthConfig struct {
Interval time.Duration // 检查间隔
Timeout time.Duration // 请求超时
FailureThreshold int // 连续失败次数阈值
DegradedCooldown time.Duration // degraded 冷却时间
CooldownCooldown time.Duration // cooldown 冷却时间
}
// DefaultHealthConfig 返回默认健康检查配置
func DefaultHealthConfig() HealthConfig {
return HealthConfig{
Interval: 5 * time.Minute,
Timeout: 10 * time.Second,
FailureThreshold: 3,
DegradedCooldown: 5 * time.Minute,
CooldownCooldown: 15 * time.Minute,
}
}
type HealthChecker struct {
channelDAO *dao.ChannelDAO
service *Service
client *http.Client
config HealthConfig
}
func NewHealthChecker(channelDAO *dao.ChannelDAO, service *Service, config ...HealthConfig) *HealthChecker {
cfg := DefaultHealthConfig()
if len(config) > 0 {
cfg = config[0]
}
return &HealthChecker{
channelDAO: channelDAO,
service: service,
client: &http.Client{
Timeout: cfg.Timeout,
},
config: cfg,
}
}
// CheckChannel performs a health check on a channel
func (hc *HealthChecker) CheckChannel(ctx context.Context, channel *store.Channel) error {
apiKey, err := crypto.Decrypt(channel.APIKeyEnc)
if err != nil {
return fmt.Errorf("failed to decrypt API key: %w", err)
}
// Simple health check: try to list models
var url string
switch channel.Provider {
case store.ChannelProviderOpenAI:
url = channel.UpstreamURL("chat", "/models")
case store.ChannelProviderAnthropic:
url = "https://api.anthropic.com/v1/models"
default:
url = channel.UpstreamURL("chat", "/models")
}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
// Set headers based on provider
switch channel.Provider {
case store.ChannelProviderOpenAI, store.ChannelProviderCompatible:
req.Header.Set("Authorization", "Bearer "+apiKey)
case store.ChannelProviderAnthropic:
req.Header.Set("x-api-key", apiKey)
req.Header.Set("anthropic-version", "2023-06-01")
}
req.Header.Set("Content-Type", "application/json")
resp, err := hc.client.Do(req)
if err != nil {
hc.service.RecordFailure(channel.ID)
return fmt.Errorf("health check failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
hc.service.RecordSuccess(channel.ID)
return nil
}
hc.service.RecordFailure(channel.ID)
return fmt.Errorf("health check returned status %d", resp.StatusCode)
}
// CheckAllChannels checks health of all enabled channels
func (hc *HealthChecker) CheckAllChannels(ctx context.Context) error {
channels, err := hc.channelDAO.ListEnabled()
if err != nil {
return err
}
for _, ch := range channels {
if err := hc.CheckChannel(ctx, ch); err != nil {
fmt.Printf("Channel %s health check failed: %v\n", ch.Name, err)
}
}
return nil
}
// StartPeriodicCheck starts periodic health checks
func (hc *HealthChecker) StartPeriodicCheck(ctx context.Context, interval ...time.Duration) {
interval_ := hc.config.Interval
if len(interval) > 0 {
interval_ = interval[0]
}
ticker := time.NewTicker(interval_)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := hc.CheckAllChannels(ctx); err != nil {
fmt.Printf("Periodic health check error: %v\n", err)
}
}
}
}
@@ -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")
}
+28
View File
@@ -0,0 +1,28 @@
package controller
import (
"opencatd-open/internal/service"
"opencatd-open/pkg/config"
"gorm.io/gorm"
)
type Api struct {
cfg *config.Config
db *gorm.DB
userService *service.UserServiceImpl
tokenService *service.TokenServiceImpl
keyService *service.ApiKeyServiceImpl
usageService *service.UsageService
}
func NewApi(cfg *config.Config, db *gorm.DB, userService *service.UserServiceImpl, tokenService *service.TokenServiceImpl, keyService *service.ApiKeyServiceImpl, usageService *service.UsageService) *Api {
return &Api{
cfg: cfg,
db: db,
userService: userService,
tokenService: tokenService,
keyService: keyService,
usageService: usageService,
}
}
+129
View File
@@ -0,0 +1,129 @@
package proxy
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"opencatd-open/internal/channel"
"opencatd-open/internal/dao"
"opencatd-open/internal/store"
"opencatd-open/pkg/config"
"os"
"strings"
"sync"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type Proxy struct {
ctx context.Context
cfg *config.Config
db *gorm.DB
wg *sync.WaitGroup
httpClient *http.Client
userDAO *dao.UserDAO
apiKeyDAO *dao.ApiKeyDAO
usageDAO *dao.UsageDAO
dailyDAO *dao.DailyUsageDAO
channelSvc *channel.Service
}
func NewProxy(ctx context.Context, cfg *config.Config, db *gorm.DB, wg *sync.WaitGroup, userDAO *dao.UserDAO, apiKeyDAO *dao.ApiKeyDAO, usageDAO *dao.UsageDAO, dailyDAO *dao.DailyUsageDAO) *Proxy {
client := http.DefaultClient
if os.Getenv("LOCAL_PROXY") != "" {
proxyUrl, err := url.Parse(os.Getenv("LOCAL_PROXY"))
if err == nil {
tr := &http.Transport{
Proxy: http.ProxyURL(proxyUrl),
}
client.Transport = tr
}
}
np := &Proxy{
ctx: ctx,
cfg: cfg,
db: db,
wg: wg,
httpClient: client,
userDAO: userDAO,
apiKeyDAO: apiKeyDAO,
usageDAO: usageDAO,
dailyDAO: dailyDAO,
}
return np
}
// SetChannelService sets the channel service (called after construction)
func (p *Proxy) SetChannelService(svc *channel.Service) {
p.channelSvc = svc
}
func (p *Proxy) HandleProxy(c *gin.Context) {
path := c.Request.URL.Path
switch {
case path == "/v1/chat/completions":
// TODO: Phase 3 - implement chat completions handler
c.JSON(http.StatusNotImplemented, gin.H{"error": "chat completions not yet implemented"})
case strings.HasPrefix(path, "/v1/messages"):
// TODO: Phase 3 - implement messages handler
c.JSON(http.StatusNotImplemented, gin.H{"error": "messages not yet implemented"})
case path == "/v1/responses":
// TODO: Phase 3 - implement responses handler
c.JSON(http.StatusNotImplemented, gin.H{"error": "responses not yet implemented"})
default:
c.JSON(http.StatusNotFound, gin.H{"error": "unknown endpoint"})
}
}
func (p *Proxy) HandleModels(c *gin.Context) {
// TODO: Phase 3 - implement models list
c.JSON(http.StatusOK, gin.H{"object": "list", "data": []interface{}{}})
}
func (p *Proxy) GetDB() *gorm.DB {
return p.db
}
// SelectChannel selects the best channel for a model
func (p *Proxy) SelectChannel(modelName string) (*store.Channel, error) {
if p.channelSvc == nil {
return nil, fmt.Errorf("channel service not initialized")
}
cands := p.channelSvc.Candidates(modelName)
picked := p.channelSvc.Pick(cands)
if picked == nil {
return nil, fmt.Errorf("no enabled channels for model: %s", modelName)
}
return picked.Channel, nil
}
// RecordSuccess records a successful request
func (p *Proxy) RecordSuccess(channelID uint64) {
if p.channelSvc != nil {
p.channelSvc.RecordSuccess(channelID)
}
}
// RecordFailure records a failed request
func (p *Proxy) RecordFailure(channelID uint64) {
if p.channelSvc != nil {
p.channelSvc.RecordFailure(channelID)
}
}
// SendUsagePlaceholder placeholder for usage processing
func (p *Proxy) SendUsagePlaceholder(model string, userID uint64, promptTokens, completionTokens int) {
log.Printf("Usage: model=%s user=%d prompt=%d completion=%d", model, userID, promptTokens, completionTokens)
}
// Placeholder to keep the file compilable
var _ = json.Marshal
var _ = io.ReadAll
+68
View File
@@ -0,0 +1,68 @@
package dao
import (
"errors"
"opencatd-open/internal/store"
"gorm.io/gorm"
)
type ApiKeyDAO struct {
db *gorm.DB
}
func NewApiKeyDAO(db *gorm.DB) *ApiKeyDAO {
return &ApiKeyDAO{db: db}
}
func (d *ApiKeyDAO) Create(apiKey *store.APIKey) error {
if apiKey == nil {
return errors.New("apiKey is nil")
}
return d.db.Create(apiKey).Error
}
func (d *ApiKeyDAO) GetByID(id uint64) (*store.APIKey, error) {
var apiKey store.APIKey
err := d.db.First(&apiKey, id).Error
if err != nil {
return nil, err
}
return &apiKey, nil
}
func (d *ApiKeyDAO) GetByHash(keyHash string) (*store.APIKey, error) {
var apiKey store.APIKey
err := d.db.Where("key_hash = ? AND status = ?", keyHash, store.KeyStatusActive).First(&apiKey).Error
if err != nil {
return nil, err
}
return &apiKey, nil
}
func (d *ApiKeyDAO) ListByUserID(userID uint64, limit, offset int) ([]*store.APIKey, int64, error) {
var apiKeys []*store.APIKey
var total int64
db := d.db.Where("user_id = ?", userID)
db.Model(&store.APIKey{}).Count(&total)
err := db.Limit(limit).Offset(offset).Order("created_at DESC").Find(&apiKeys).Error
return apiKeys, total, err
}
func (d *ApiKeyDAO) Update(apiKey *store.APIKey) error {
if apiKey == nil {
return errors.New("apiKey is nil")
}
return d.db.Save(apiKey).Error
}
func (d *ApiKeyDAO) Delete(id uint64) error {
return d.db.Delete(&store.APIKey{}, id).Error
}
func (d *ApiKeyDAO) BatchDelete(ids []uint64) error {
if len(ids) == 0 {
return errors.New("ids is empty")
}
return d.db.Delete(&store.APIKey{}, ids).Error
}
+110
View File
@@ -0,0 +1,110 @@
package dao
import (
"opencatd-open/internal/store"
"gorm.io/gorm"
)
type ChannelDAO struct {
db *gorm.DB
}
func NewChannelDAO(db *gorm.DB) *ChannelDAO {
return &ChannelDAO{db: db}
}
// DB 暴露底层连接,供聚合查询使用(如渠道候选联表过滤)。
func (d *ChannelDAO) DB() *gorm.DB {
return d.db
}
func (d *ChannelDAO) Create(channel *store.Channel) error {
return d.db.Create(channel).Error
}
func (d *ChannelDAO) GetByID(id uint64) (*store.Channel, error) {
var channel store.Channel
err := d.db.First(&channel, id).Error
if err != nil {
return nil, err
}
return &channel, nil
}
func (d *ChannelDAO) GetByName(name string) (*store.Channel, error) {
var channel store.Channel
err := d.db.Where("name = ?", name).First(&channel).Error
if err != nil {
return nil, err
}
return &channel, nil
}
func (d *ChannelDAO) List(limit, offset int) ([]*store.Channel, int64, error) {
var channels []*store.Channel
var total int64
d.db.Model(&store.Channel{}).Count(&total)
err := d.db.Limit(limit).Offset(offset).Order("priority DESC, weight DESC").Find(&channels).Error
return channels, total, err
}
func (d *ChannelDAO) ListEnabled() ([]*store.Channel, error) {
var channels []*store.Channel
err := d.db.Where("enabled = ?", true).Order("priority DESC, weight DESC").Find(&channels).Error
return channels, err
}
func (d *ChannelDAO) Update(channel *store.Channel) error {
return d.db.Save(channel).Error
}
func (d *ChannelDAO) Delete(id uint64) error {
return d.db.Delete(&store.Channel{}, id).Error
}
// BindModels binds models to a channel (replaces existing bindings)
func (d *ChannelDAO) BindModels(channelID uint64, bindings []store.ChannelModelBinding) error {
return d.db.Transaction(func(tx *gorm.DB) error {
// Delete existing bindings
if err := tx.Where("channel_id = ?", channelID).Delete(&store.ChannelModelBinding{}).Error; err != nil {
return err
}
// Create new bindings
for i := range bindings {
bindings[i].ChannelID = channelID
}
return tx.Create(&bindings).Error
})
}
// GetChannelModels returns all models bound to a channel
func (d *ChannelDAO) GetChannelModels(channelID uint64) ([]store.ChannelModelBinding, error) {
var bindings []store.ChannelModelBinding
err := d.db.Where("channel_id = ?", channelID).Find(&bindings).Error
return bindings, err
}
// GetModelChannels returns all channels that support a given model (by model name)
func (d *ChannelDAO) GetModelChannels(modelName string) ([]store.ChannelModelBinding, error) {
var bindings []store.ChannelModelBinding
err := d.db.
Joins("JOIN channels ON channels.id = channel_model_bindings.channel_id").
Joins("JOIN models ON models.id = channel_model_bindings.model_id").
Where("models.name = ? AND channels.enabled = ?", modelName, true).
Find(&bindings).Error
return bindings, err
}
// GetEnabledChannelsByModel returns enabled channels for a model, ordered by priority/weight
func (d *ChannelDAO) GetEnabledChannelsByModel(modelName string) ([]*store.Channel, error) {
var channels []*store.Channel
err := d.db.
Distinct("channels.*").
Joins("JOIN channel_model_bindings ON channel_model_bindings.channel_id = channels.id").
Joins("JOIN models ON models.id = channel_model_bindings.model_id").
Where("models.name = ? AND channels.enabled = ?", modelName, true).
Order("channels.priority DESC, channels.weight DESC").
Find(&channels).Error
return channels, err
}
+76
View File
@@ -0,0 +1,76 @@
package dao
import (
"opencatd-open/internal/store"
"gorm.io/gorm"
)
type ModelDAO struct {
db *gorm.DB
}
func NewModelDAO(db *gorm.DB) *ModelDAO {
return &ModelDAO{db: db}
}
// DB 暴露底层连接,供聚合查询使用(如模型候选联表过滤)。
func (d *ModelDAO) DB() *gorm.DB {
return d.db
}
func (d *ModelDAO) Create(model *store.Model) error {
return d.db.Create(model).Error
}
func (d *ModelDAO) GetByID(id uint64) (*store.Model, error) {
var model store.Model
err := d.db.First(&model, id).Error
if err != nil {
return nil, err
}
return &model, nil
}
func (d *ModelDAO) GetByName(name string) (*store.Model, error) {
var model store.Model
err := d.db.Where("name = ?", name).First(&model).Error
if err != nil {
return nil, err
}
return &model, nil
}
func (d *ModelDAO) List(limit, offset int) ([]*store.Model, int64, error) {
var models []*store.Model
var total int64
d.db.Model(&store.Model{}).Count(&total)
err := d.db.Limit(limit).Offset(offset).Order("sort ASC, name ASC").Find(&models).Error
return models, total, err
}
func (d *ModelDAO) ListEnabled() ([]*store.Model, error) {
var models []*store.Model
err := d.db.Where("enabled = ?", true).Order("sort ASC, name ASC").Find(&models).Error
return models, err
}
func (d *ModelDAO) Update(model *store.Model) error {
return d.db.Save(model).Error
}
func (d *ModelDAO) Delete(id uint64) error {
return d.db.Delete(&store.Model{}, id).Error
}
// Upsert creates or updates a model by name
func (d *ModelDAO) Upsert(model *store.Model) error {
return d.db.Where("name = ?", model.Name).Assign(store.Model{
DisplayName: model.DisplayName,
InputPrice: model.InputPrice,
OutputPrice: model.OutputPrice,
CacheReadPrice: model.CacheReadPrice,
Enabled: model.Enabled,
Sort: model.Sort,
}).FirstOrCreate(model).Error
}
+38
View File
@@ -0,0 +1,38 @@
package dao
import (
"errors"
"opencatd-open/internal/store"
"gorm.io/gorm"
)
type TokenDAO struct {
db *gorm.DB
}
func NewTokenDAO(db *gorm.DB) *TokenDAO {
return &TokenDAO{db: db}
}
func (d *TokenDAO) GetByKey(key string) (*store.User, error) {
var user store.User
err := d.db.Where("username = ?", key).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (d *TokenDAO) GetByID(id uint64) (*store.User, error) {
var user store.User
err := d.db.First(&user, id).Error
if err != nil {
return nil, err
}
return &user, nil
}
// Placeholder to avoid compile errors - will be expanded in Phase 1
var _ = errors.New
var _ = gorm.ErrRecordNotFound
+169
View File
@@ -0,0 +1,169 @@
package dao
import (
"context"
"opencatd-open/internal/store"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type UsageDAO struct {
db *gorm.DB
}
type DailyUsageDAO struct {
db *gorm.DB
}
func NewUsageDAO(db *gorm.DB) *UsageDAO {
return &UsageDAO{db: db}
}
func NewDailyUsageDAO(db *gorm.DB) *DailyUsageDAO {
return &DailyUsageDAO{db: db}
}
// UsageLog DAO
func (d *UsageDAO) Create(ctx context.Context, log *store.UsageLog) error {
return d.db.WithContext(ctx).Create(log).Error
}
func (d *UsageDAO) BatchCreate(ctx context.Context, logs []*store.UsageLog) error {
return d.db.WithContext(ctx).Create(logs).Error
}
func (d *UsageDAO) ListByUserID(ctx context.Context, userID uint64, limit, offset int) ([]*store.UsageLog, error) {
var logs []*store.UsageLog
err := d.db.WithContext(ctx).
Where("user_id = ?", userID).
Order("created_at DESC").
Limit(limit).
Offset(offset).
Find(&logs).Error
return logs, err
}
func (d *UsageDAO) Delete(ctx context.Context, id uint64) error {
return d.db.WithContext(ctx).Delete(&store.UsageLog{}, id).Error
}
func (d *UsageDAO) CountByUserID(ctx context.Context, userID uint64) (int64, error) {
var count int64
err := d.db.WithContext(ctx).Model(&store.UsageLog{}).Where("user_id = ?", userID).Count(&count).Error
return count, err
}
// UsageFilter 用量明细筛选条件(管理后台)。
type UsageFilter struct {
UserID *uint64 // 指定用户(nil=全部)
Protocol string // 协议 chat/messages/responses(空=全部)
Status string // success/error/canceled(空=全部)
ModelName string // 模型名模糊(空=全部)
Limit int
Offset int
}
// ListAll 管理后台全量用量明细(分页 + 筛选),并带用户名。
func (d *UsageDAO) ListAll(ctx context.Context, f UsageFilter) ([]store.UsageLog, error) {
q := d.db.WithContext(ctx).Model(&store.UsageLog{})
q = applyUsageFilter(q, f)
var logs []store.UsageLog
err := q.Order("created_at DESC").Limit(f.Limit).Offset(f.Offset).Find(&logs).Error
return logs, err
}
// CountAll 统计符合筛选条件的明细总数。
func (d *UsageDAO) CountAll(ctx context.Context, f UsageFilter) (int64, error) {
q := d.db.WithContext(ctx).Model(&store.UsageLog{})
q = applyUsageFilter(q, f)
var count int64
err := q.Count(&count).Error
return count, err
}
func applyUsageFilter(q *gorm.DB, f UsageFilter) *gorm.DB {
if f.UserID != nil {
q = q.Where("user_id = ?", *f.UserID)
}
if f.Protocol != "" {
q = q.Where("protocol = ?", f.Protocol)
}
if f.Status != "" {
q = q.Where("status = ?", f.Status)
}
if f.ModelName != "" {
q = q.Where("model_name LIKE ?", "%"+f.ModelName+"%")
}
return q
}
// UsageDaily DAO
func (d *DailyUsageDAO) Create(ctx context.Context, log *store.UsageDaily) error {
return d.db.WithContext(ctx).Create(log).Error
}
func (d *DailyUsageDAO) ListByUserID(ctx context.Context, userID uint64, limit, offset int) ([]*store.UsageDaily, error) {
var logs []*store.UsageDaily
err := d.db.WithContext(ctx).
Where("user_id = ?", userID).
Order("date DESC").
Limit(limit).
Offset(offset).
Find(&logs).Error
return logs, err
}
func (d *DailyUsageDAO) GetByDate(ctx context.Context, userID uint64, date string) (*store.UsageDaily, error) {
var log store.UsageDaily
err := d.db.WithContext(ctx).
Where("user_id = ? AND date = ?", userID, date).
First(&log).Error
if err != nil {
return nil, err
}
return &log, nil
}
// UpsertDailyUsage 按 (user_id, model_id, date) 累加式 upsert:
// 行不存在则插入;存在则在原值基础上增量累加(不能用 AssignmentColumns 覆盖,
// 否则多次 flush 会互相清零)。非限定列名在 SQLite/MySQL/PG 的 upsert 语义下都指向目标行。
func (d *DailyUsageDAO) UpsertDailyUsage(ctx context.Context, log *store.UsageDaily) error {
return d.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "model_id"}, {Name: "date"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"requests": gorm.Expr("requests + ?", log.Requests),
"input_tokens": gorm.Expr("input_tokens + ?", log.InputTokens),
"output_tokens": gorm.Expr("output_tokens + ?", log.OutputTokens),
"cache_read_tokens": gorm.Expr("cache_read_tokens + ?", log.CacheReadTokens),
"cost": gorm.Expr("cost + ?", log.Cost),
}),
}).Create(log).Error
}
func (d *DailyUsageDAO) ListByDateRange(ctx context.Context, userID uint64, start, end time.Time) ([]*store.UsageDaily, error) {
var logs []*store.UsageDaily
err := d.db.WithContext(ctx).
Where("user_id = ? AND date >= ? AND date <= ?", userID, start.Format("2006-01-02"), end.Format("2006-01-02")).
Order("date DESC").
Find(&logs).Error
return logs, err
}
// ListAll 管理后台:全部用户的日聚合(可选按用户/日期范围筛选),按日期倒序。
func (d *DailyUsageDAO) ListAll(ctx context.Context, userID *uint64, start, end string) ([]store.UsageDaily, error) {
q := d.db.WithContext(ctx).Model(&store.UsageDaily{})
if userID != nil {
q = q.Where("user_id = ?", *userID)
}
if start != "" {
q = q.Where("date >= ?", start)
}
if end != "" {
q = q.Where("date <= ?", end)
}
var logs []store.UsageDaily
err := q.Order("date DESC, user_id ASC").Find(&logs).Error
return logs, err
}
+62
View File
@@ -0,0 +1,62 @@
package dao
import (
"opencatd-open/internal/store"
"gorm.io/gorm"
)
type UserDAO struct {
db *gorm.DB
}
func NewUserDAO(db *gorm.DB) *UserDAO {
return &UserDAO{db: db}
}
func (d *UserDAO) Create(user *store.User) error {
return d.db.Create(user).Error
}
func (d *UserDAO) GetByID(id uint64) (*store.User, error) {
var user store.User
err := d.db.First(&user, id).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (d *UserDAO) GetByUsername(username string) (*store.User, error) {
var user store.User
err := d.db.Where("username = ?", username).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (d *UserDAO) GetByEmail(email string) (*store.User, error) {
var user store.User
err := d.db.Where("email = ?", email).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (d *UserDAO) List(limit, offset int) ([]*store.User, int64, error) {
var users []*store.User
var total int64
d.db.Model(&store.User{}).Count(&total)
err := d.db.Limit(limit).Offset(offset).Order("created_at DESC").Find(&users).Error
return users, total, err
}
func (d *UserDAO) Update(user *store.User) error {
return d.db.Save(user).Error
}
func (d *UserDAO) Delete(id uint64) error {
return d.db.Delete(&store.User{}, id).Error
}
+367
View File
@@ -0,0 +1,367 @@
// Package passkey 封装 WebAuthn(passkey)注册与登录。
package passkey
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"sync"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/redis/go-redis/v9"
"opencatd-open/internal/store"
"gorm.io/gorm"
)
const (
sessionPrefix = "passkey:session:"
sessionTTL = 5 * time.Minute
)
type Config struct {
RPID string
Origins []string
Name string
Redis *redis.Client // 可选,nil 时使用内存存储
}
// SessionStore challenge 会话存储接口
type SessionStore interface {
Set(ctx context.Context, session *webauthn.SessionData) error
Get(ctx context.Context, challenge string) (*webauthn.SessionData, bool, error)
Delete(ctx context.Context, challenge string) error
}
// memoryStore 内存存储(单实例)
type memoryStore struct {
mu sync.Mutex
sessions map[string]webauthn.SessionData
}
func newMemoryStore() *memoryStore {
return &memoryStore{sessions: make(map[string]webauthn.SessionData)}
}
func (m *memoryStore) Set(_ context.Context, session *webauthn.SessionData) error {
m.mu.Lock()
m.sessions[session.Challenge] = *session
m.mu.Unlock()
return nil
}
func (m *memoryStore) Get(_ context.Context, challenge string) (*webauthn.SessionData, bool, error) {
m.mu.Lock()
sess, ok := m.sessions[challenge]
m.mu.Unlock()
if !ok {
return nil, false, nil
}
// 检查过期
if !sess.Expires.IsZero() && time.Now().After(sess.Expires) {
return nil, false, nil
}
return &sess, true, nil
}
func (m *memoryStore) Delete(_ context.Context, challenge string) error {
m.mu.Lock()
delete(m.sessions, challenge)
m.mu.Unlock()
return nil
}
// redisStore Redis 存储(分布式)
type redisStore struct {
rdb *redis.Client
}
func newRedisStore(rdb *redis.Client) *redisStore {
return &redisStore{rdb: rdb}
}
func (r *redisStore) Set(ctx context.Context, session *webauthn.SessionData) error {
data, err := json.Marshal(session)
if err != nil {
return fmt.Errorf("marshal session: %w", err)
}
key := sessionPrefix + session.Challenge
return r.rdb.Set(ctx, key, data, sessionTTL).Err()
}
func (r *redisStore) Get(ctx context.Context, challenge string) (*webauthn.SessionData, bool, error) {
key := sessionPrefix + challenge
data, err := r.rdb.Get(ctx, key).Bytes()
if err == redis.Nil {
return nil, false, nil
}
if err != nil {
return nil, false, fmt.Errorf("redis get: %w", err)
}
var sess webauthn.SessionData
if err := json.Unmarshal(data, &sess); err != nil {
return nil, false, fmt.Errorf("unmarshal session: %w", err)
}
return &sess, true, nil
}
func (r *redisStore) Delete(ctx context.Context, challenge string) error {
key := sessionPrefix + challenge
return r.rdb.Del(ctx, key).Err()
}
// Service WebAuthn 服务:凭据存储 + challenge 会话。
type Service struct {
wa *webauthn.WebAuthn
db *gorm.DB
sessions SessionStore
}
func New(db *gorm.DB, cfg Config) (*Service, error) {
wa, err := webauthn.New(&webauthn.Config{
RPDisplayName: cfg.Name,
RPID: cfg.RPID,
RPOrigins: cfg.Origins,
})
if err != nil {
return nil, err
}
// 根据配置选择存储后端
var store SessionStore
if cfg.Redis != nil {
store = newRedisStore(cfg.Redis)
} else {
store = newMemoryStore()
}
return &Service{wa: wa, db: db, sessions: store}, nil
}
// webUser 实现 go-webauthn 的 User 接口。
type webUser struct {
id uint64
name string
displayName string
credentials []webauthn.Credential
}
func (u *webUser) WebAuthnID() []byte { return []byte(strconv.FormatUint(u.id, 10)) }
func (u *webUser) WebAuthnName() string { return u.name }
func (u *webUser) WebAuthnDisplayName() string { return u.displayName }
func (u *webUser) WebAuthnIcon() string { return "" }
func (u *webUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
func (s *Service) loadWebUser(u *store.User) (*webUser, error) {
var pks []store.Passkey
s.db.Where("user_id = ?", u.ID).Find(&pks)
creds := make([]webauthn.Credential, 0, len(pks))
for _, pk := range pks {
var c webauthn.Credential
if err := json.Unmarshal(pk.Credential, &c); err == nil {
creds = append(creds, c)
}
}
return &webUser{id: u.ID, name: u.Username, displayName: u.Username, credentials: creds}, nil
}
// GetUserByUsername 通过用户名或邮箱查找用户
func (s *Service) GetUserByUsername(username string) (*store.User, error) {
var u store.User
if err := s.db.Where("username = ? OR email = ?", username, username).First(&u).Error; err != nil {
return nil, err
}
return &u, nil
}
// GetUserByID 通过 ID 查找用户
func (s *Service) GetUserByID(id uint64) (*store.User, error) {
var u store.User
if err := s.db.First(&u, id).Error; err != nil {
return nil, err
}
return &u, nil
}
// ---------------------------------------------------------------------------
// 注册
// BeginRegistration 生成注册选项并暂存 challenge。
func (s *Service) BeginRegistration(u *store.User) (*protocol.CredentialCreation, error) {
wu, err := s.loadWebUser(u)
if err != nil {
return nil, err
}
creation, session, err := s.wa.BeginRegistration(wu)
if err != nil {
return nil, err
}
if err := s.sessions.Set(context.Background(), session); err != nil {
return nil, err
}
return creation, nil
}
// FinishRegistration 校验浏览器返回的凭据并落库。
func (s *Service) FinishRegistration(u *store.User, challenge string, body []byte, name string) error {
session, ok, err := s.sessions.Get(context.Background(), challenge)
if err != nil {
return err
}
if !ok {
return errors.New("challenge 已过期或不存在")
}
// 删除已使用的 challenge
_ = s.sessions.Delete(context.Background(), challenge)
wu, err := s.loadWebUser(u)
if err != nil {
return err
}
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
cred, err := s.wa.FinishRegistration(wu, *session, req)
if err != nil {
return err
}
raw, _ := json.Marshal(cred)
nm := name
if nm == "" {
nm = "passkey"
}
return s.db.Create(&store.Passkey{
UserID: u.ID, Name: nm, CredentialID: cred.ID, Credential: raw,
}).Error
}
// ---------------------------------------------------------------------------
// 登录
// BeginLogin 已知用户(按用户名)发起断言。
func (s *Service) BeginLogin(u *store.User) (*protocol.CredentialAssertion, error) {
wu, err := s.loadWebUser(u)
if err != nil {
return nil, err
}
assertion, session, err := s.wa.BeginLogin(wu)
if err != nil {
return nil, err
}
if err := s.sessions.Set(context.Background(), session); err != nil {
return nil, err
}
return assertion, nil
}
// BeginDiscoverableLogin 无用户名(使用平台/漫游器上的可发现凭据)。
func (s *Service) BeginDiscoverableLogin() (*protocol.CredentialAssertion, error) {
assertion, session, err := s.wa.BeginDiscoverableLogin()
if err != nil {
return nil, err
}
if err := s.sessions.Set(context.Background(), session); err != nil {
return nil, err
}
return assertion, nil
}
// FinishLogin 校验断言并更新签名计数。
func (s *Service) FinishLogin(u *store.User, challenge string, body []byte) error {
session, ok, err := s.sessions.Get(context.Background(), challenge)
if err != nil {
return err
}
if !ok {
return errors.New("challenge 已过期或不存在")
}
_ = s.sessions.Delete(context.Background(), challenge)
wu, err := s.loadWebUser(u)
if err != nil {
return err
}
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
cred, err := s.wa.FinishLogin(wu, *session, req)
if err != nil {
return err
}
return s.updateCredential(u.ID, cred)
}
// FinishDiscoverableLogin 通过凭据定位用户并校验断言。
func (s *Service) FinishDiscoverableLogin(challenge string, body []byte) (*store.User, error) {
session, ok, err := s.sessions.Get(context.Background(), challenge)
if err != nil {
return nil, err
}
if !ok {
return nil, errors.New("challenge 已过期或不存在")
}
_ = s.sessions.Delete(context.Background(), challenge)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
// 先通过 credential_id 反查用户
var pks []store.Passkey
if err := s.db.Find(&pks).Error; err != nil {
return nil, err
}
// 遍历所有 passkey 找到匹配的
for _, pk := range pks {
var c webauthn.Credential
if err := json.Unmarshal(pk.Credential, &c); err != nil {
continue
}
// 尝试用这个用户的凭据进行登录
var u store.User
if err := s.db.First(&u, pk.UserID).Error; err != nil {
continue
}
wu, err := s.loadWebUser(&u)
if err != nil {
continue
}
cred, err := s.wa.FinishLogin(wu, *session, req)
if err != nil {
continue
}
_ = s.updateCredential(u.ID, cred)
return &u, nil
}
return nil, errors.New("no matching passkey found")
}
// ---------------------------------------------------------------------------
// 管理
// List 列出用户的 passkey。
func (s *Service) List(userID uint64) ([]store.Passkey, error) {
var pks []store.Passkey
err := s.db.Where("user_id = ?", userID).Order("id DESC").Find(&pks).Error
return pks, err
}
// Delete 删除用户的 passkey。
func (s *Service) Delete(userID, id uint64) error {
res := s.db.Where("id = ? AND user_id = ?", id, userID).Delete(&store.Passkey{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (s *Service) updateCredential(userID uint64, cred *webauthn.Credential) error {
raw, _ := json.Marshal(cred)
return s.db.Model(&store.Passkey{}).
Where("user_id = ? AND credential_id = ?", userID, cred.ID).
Update("credential", raw).Error
}
+30
View File
@@ -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)
}
+105
View File
@@ -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")
}
}
+61
View File
@@ -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
}
+122
View File
@@ -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
}
+47
View File
@@ -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
}
+61
View File
@@ -0,0 +1,61 @@
package convert
// ChatCompletionRequest represents an OpenAI Chat Completions request
type ChatCompletionRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
N *int `json:"n,omitempty"`
Stream bool `json:"stream,omitempty"`
Stop interface{} `json:"stop,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
LogitBias map[string]int `json:"logit_bias,omitempty"`
User string `json:"user,omitempty"`
Tools []Tool `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
ResponseFormat interface{} `json:"response_format,omitempty"`
Seed *int `json:"seed,omitempty"`
}
// ChatCompletionResponse represents an OpenAI Chat Completions response
type ChatCompletionResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []Choice `json:"choices"`
Usage *Usage `json:"usage,omitempty"`
SystemFingerprint string `json:"system_fingerprint,omitempty"`
}
type Choice struct {
Index int `json:"index"`
Message Message `json:"message"`
FinishReason string `json:"finish_reason"`
}
// ChatCompletionStreamChunk represents a streaming chunk
type ChatCompletionStreamChunk struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []StreamChoice `json:"choices"`
Usage *Usage `json:"usage,omitempty"`
SystemFingerprint string `json:"system_fingerprint,omitempty"`
}
type StreamChoice struct {
Index int `json:"index"`
Delta StreamDelta `json:"delta"`
FinishReason *string `json:"finish_reason"`
}
type StreamDelta struct {
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
}
@@ -0,0 +1,212 @@
package convert
import (
"encoding/json"
"fmt"
)
// ChatToMessages converts a Chat Completions request to Anthropic Messages format
func ChatToMessages(req *ChatCompletionRequest) (*MessagesRequest, error) {
msgs := make([]Message, 0, len(req.Messages))
var systemParts []ContentPart
for _, m := range req.Messages {
if m.Role == "system" {
// Extract system message content
switch v := m.Content.(type) {
case string:
systemParts = append(systemParts, ContentPart{
Type: "text",
Text: v,
})
case []interface{}:
for _, part := range v {
if p, ok := part.(map[string]interface{}); ok {
if t, ok := p["type"].(string); ok && t == "text" {
if text, ok := p["text"].(string); ok {
systemParts = append(systemParts, ContentPart{
Type: "text",
Text: text,
})
}
}
}
}
}
continue
}
msgs = append(msgs, m)
}
out := &MessagesRequest{
Model: req.Model,
Messages: msgs,
Stream: req.Stream,
}
if len(systemParts) > 0 {
out.System = systemParts
}
if req.MaxTokens != nil {
out.MaxTokens = *req.MaxTokens
} else {
defaultMax := 4096
out.MaxTokens = defaultMax
}
if req.Temperature != nil {
out.Temperature = req.Temperature
}
if req.TopP != nil {
out.TopP = req.TopP
}
if req.Tools != nil {
out.Tools = req.Tools
}
return out, nil
}
// MessagesToChat converts an Anthropic Messages response to Chat Completions format
func MessagesToChat(resp *MessagesResponse) (*ChatCompletionResponse, error) {
choices := make([]Choice, 0)
for _, block := range resp.Content {
switch block.Type {
case "text":
choices = append(choices, Choice{
Index: len(choices),
Message: Message{
Role: "assistant",
Content: block.Text,
},
FinishReason: mapStopReason(resp.StopReason),
})
case "tool_use":
toolCall := ToolCall{
ID: block.ID,
Type: "function",
Function: FunctionCall{
Name: block.Name,
Arguments: toJSON(block.Input),
},
}
if len(choices) == 0 {
choices = append(choices, Choice{
Index: 0,
Message: Message{
Role: "assistant",
ToolCalls: []ToolCall{toolCall},
},
FinishReason: "tool_calls",
})
} else {
choices[0].Message.ToolCalls = append(choices[0].Message.ToolCalls, toolCall)
choices[0].FinishReason = "tool_calls"
}
}
}
if len(choices) == 0 {
choices = append(choices, Choice{
Index: 0,
Message: Message{
Role: "assistant",
Content: "",
},
FinishReason: "stop",
})
}
return &ChatCompletionResponse{
ID: resp.ID,
Object: "chat.completion",
Model: resp.Model,
Choices: choices,
Usage: &Usage{
PromptTokens: resp.Usage.PromptTokens,
CompletionTokens: resp.Usage.CompletionTokens,
TotalTokens: resp.Usage.PromptTokens + resp.Usage.CompletionTokens,
},
}, nil
}
// MessagesStreamToChatStream converts Anthropic streaming chunks to Chat Completions format
func MessagesStreamToChatStream(anthropicEvents []AnthropicStreamEvent, model string) []ChatCompletionStreamChunk {
var chunks []ChatCompletionStreamChunk
id := fmt.Sprintf("chatcmpl-%d", len(anthropicEvents))
for _, event := range anthropicEvents {
switch event.Type {
case "message_start":
// Initial chunk with role
chunks = append(chunks, ChatCompletionStreamChunk{
ID: id,
Object: "chat.completion.chunk",
Model: model,
Choices: []StreamChoice{{
Index: 0,
Delta: StreamDelta{
Role: "assistant",
},
}},
})
case "content_block_delta":
if event.Delta != nil && event.Delta.Text != "" {
chunks = append(chunks, ChatCompletionStreamChunk{
ID: id,
Object: "chat.completion.chunk",
Model: model,
Choices: []StreamChoice{{
Index: 0,
Delta: StreamDelta{
Content: event.Delta.Text,
},
}},
})
}
case "message_delta":
finishReason := "stop"
if event.Delta != nil && event.Delta.StopReason != "" {
finishReason = mapStopReason(event.Delta.StopReason)
}
chunk := ChatCompletionStreamChunk{
ID: id,
Object: "chat.completion.chunk",
Model: model,
Choices: []StreamChoice{{
Index: 0,
FinishReason: &finishReason,
}},
}
if event.Usage != nil {
chunk.Usage = event.Usage
}
chunks = append(chunks, chunk)
}
}
return chunks
}
func mapStopReason(reason string) string {
switch reason {
case "end_turn", "stop_sequence":
return "stop"
case "tool_use":
return "tool_calls"
case "max_tokens":
return "length"
default:
return "stop"
}
}
func toJSON(v interface{}) string {
b, err := json.Marshal(v)
if err != nil {
return "{}"
}
return string(b)
}
@@ -0,0 +1,298 @@
package convert
import (
"encoding/json"
"fmt"
)
// ChatToResponses converts a Chat Completions request to Responses API format
func ChatToResponses(req *ChatCompletionRequest) (*ResponsesRequest, error) {
var inputItems []InputItem
var instructions string
for _, m := range req.Messages {
if m.Role == "system" {
if s, ok := m.Content.(string); ok {
if instructions != "" {
instructions += "\n\n"
}
instructions += s
}
continue
}
item := InputItem{
Role: m.Role,
Content: m.Content,
}
inputItems = append(inputItems, item)
}
out := &ResponsesRequest{
Model: req.Model,
Input: marshalInputItems(inputItems),
Instructions: instructions,
Stream: req.Stream,
}
if req.MaxTokens != nil {
out.MaxOutputTokens = req.MaxTokens
}
if req.Temperature != nil {
out.Temperature = req.Temperature
}
if req.TopP != nil {
out.TopP = req.TopP
}
if req.Tools != nil {
out.Tools = req.Tools
}
return out, nil
}
// ResponsesToChat converts a Responses API response to Chat Completions format
func ResponsesToChat(resp *ResponsesResponse) (*ChatCompletionResponse, error) {
choices := make([]Choice, 0)
for _, output := range resp.Output {
switch output.Type {
case "message":
for _, content := range output.Content {
switch content.Type {
case "output_text":
choices = append(choices, Choice{
Index: len(choices),
Message: Message{
Role: "assistant",
Content: content.Text,
},
FinishReason: "stop",
})
case "function_call":
toolCall := ToolCall{
ID: content.ID,
Type: "function",
Function: FunctionCall{
Name: content.Name,
Arguments: toJSON(content.Input),
},
}
if len(choices) == 0 {
choices = append(choices, Choice{
Index: 0,
Message: Message{
Role: "assistant",
ToolCalls: []ToolCall{toolCall},
},
FinishReason: "tool_calls",
})
} else {
choices[0].Message.ToolCalls = append(choices[0].Message.ToolCalls, toolCall)
choices[0].FinishReason = "tool_calls"
}
}
}
case "function_call_output":
// This would be in a user message context
continue
}
}
if len(choices) == 0 {
choices = append(choices, Choice{
Index: 0,
Message: Message{
Role: "assistant",
Content: "",
},
FinishReason: "stop",
})
}
return &ChatCompletionResponse{
ID: resp.ID,
Object: "chat.completion",
Model: resp.Model,
Choices: choices,
Usage: &Usage{
PromptTokens: resp.Usage.PromptTokens,
CompletionTokens: resp.Usage.CompletionTokens,
TotalTokens: resp.Usage.PromptTokens + resp.Usage.CompletionTokens,
},
}, nil
}
// ResponsesStreamToChatStream converts Responses API streaming to Chat Completions format
func ResponsesStreamToChatStream(events []ResponsesStreamEvent, model string) []ChatCompletionStreamChunk {
var chunks []ChatCompletionStreamChunk
id := fmt.Sprintf("chatcmpl-%d", len(events))
for _, event := range events {
switch event.Type {
case "response.created":
chunks = append(chunks, ChatCompletionStreamChunk{
ID: id,
Object: "chat.completion.chunk",
Model: model,
Choices: []StreamChoice{{
Index: 0,
Delta: StreamDelta{
Role: "assistant",
},
}},
})
case "response.output_item.added":
if event.Item != nil && event.Item.Type == "message" {
chunks = append(chunks, ChatCompletionStreamChunk{
ID: id,
Object: "chat.completion.chunk",
Model: model,
Choices: []StreamChoice{{
Index: 0,
Delta: StreamDelta{
Role: "assistant",
},
}},
})
}
case "response.content_part.delta":
if event.Delta != "" {
chunks = append(chunks, ChatCompletionStreamChunk{
ID: id,
Object: "chat.completion.chunk",
Model: model,
Choices: []StreamChoice{{
Index: 0,
Delta: StreamDelta{
Content: event.Delta,
},
}},
})
}
case "response.completed":
finishReason := "stop"
chunk := ChatCompletionStreamChunk{
ID: id,
Object: "chat.completion.chunk",
Model: model,
Choices: []StreamChoice{{
Index: 0,
FinishReason: &finishReason,
}},
}
chunks = append(chunks, chunk)
}
}
return chunks
}
// MessagesToResponses converts an Anthropic Messages request to Responses API format
func MessagesToResponses(req *MessagesRequest) (*ResponsesRequest, error) {
var inputItems []InputItem
var instructions string
// Handle system message
if req.System != nil {
switch v := req.System.(type) {
case string:
instructions = v
case []ContentPart:
for _, p := range v {
if p.Type == "text" {
if instructions != "" {
instructions += "\n\n"
}
instructions += p.Text
}
}
}
}
for _, m := range req.Messages {
item := InputItem{
Role: m.Role,
Content: m.Content,
}
inputItems = append(inputItems, item)
}
out := &ResponsesRequest{
Model: req.Model,
Input: marshalInputItems(inputItems),
Instructions: instructions,
Stream: req.Stream,
}
out.MaxOutputTokens = &req.MaxTokens
if req.Temperature != nil {
out.Temperature = req.Temperature
}
if req.TopP != nil {
out.TopP = req.TopP
}
if req.Tools != nil {
out.Tools = req.Tools
}
return out, nil
}
// ResponsesToMessages converts a Responses API response to Anthropic Messages format
func ResponsesToMessages(resp *ResponsesResponse) (*MessagesResponse, error) {
var content []ContentBlock
for _, output := range resp.Output {
switch output.Type {
case "message":
for _, c := range output.Content {
switch c.Type {
case "output_text":
content = append(content, ContentBlock{
Type: "text",
Text: c.Text,
})
case "function_call":
content = append(content, ContentBlock{
Type: "tool_use",
ID: c.ID,
Name: c.Name,
})
}
}
}
}
var stopReason string
if len(content) > 0 {
last := content[len(content)-1]
if last.Type == "tool_use" {
stopReason = "tool_use"
} else {
stopReason = "end_turn"
}
} else {
stopReason = "end_turn"
}
return &MessagesResponse{
ID: resp.ID,
Type: "message",
Role: "assistant",
Content: content,
Model: resp.Model,
StopReason: stopReason,
Usage: resp.Usage,
}, nil
}
// toJSON is a helper to convert a value to JSON string
func toJSONStr(v interface{}) string {
b, err := json.Marshal(v)
if err != nil {
return "{}"
}
return string(b)
}
+203
View File
@@ -0,0 +1,203 @@
// 三协议互转注册表:OpenAI Chat / OpenAI Responses / Anthropic Messages。
// 网关以 Chat 形状作为标准中间模型:非跨 chat 的转换经 chat 中转。
// 请求/响应(非流式)走 JSON 转换;流式走逐行 SSE 转换(stream_transform.go)。
package convert
import (
"bytes"
"encoding/json"
"fmt"
)
// 协议标识。
const (
ProtoChat = "chat"
ProtoMessages = "messages"
ProtoResponses = "responses"
)
// trimBody 去掉首尾空白。部分上游(如 OpenRouter)会在 JSON 前输出空白或
// SSE 注释行再跟正文,直接 Unmarshal 会失败。
func trimBody(body []byte) []byte {
return bytes.TrimSpace(body)
}
// CleanJSON 剥离非 JSON 前缀(空白、SSE 注释、`data:` 行)并压缩为标准 JSON。
// 部分上游(如 OpenRouter)的 non-stream 响应在 JSON 前夹带空白/注释;
// 原样透传会让客户端解析失败。找不到 JSON 对象时原样返回。
func CleanJSON(body []byte) []byte {
i := bytes.IndexByte(body, '{')
if i < 0 {
return body
}
var v any
if err := json.Unmarshal(bytes.TrimSpace(body[i:]), &v); err != nil {
return body
}
out, err := json.Marshal(v)
if err != nil {
return body
}
return out
}
// ConvertRequest 转换请求体。from==to 时原样返回。
func ConvertRequest(body []byte, from, to string) ([]byte, error) {
if from == to {
return body, nil
}
body = trimBody(body)
switch {
case from == ProtoMessages && to == ProtoChat:
return messagesToChatReq(body)
case from == ProtoChat && to == ProtoMessages:
return chatToMessagesReq(body)
case from == ProtoResponses && to == ProtoChat:
return responsesToChatReq(body)
case from == ProtoChat && to == ProtoResponses:
return chatToResponsesReq(body)
case from == ProtoResponses && to == ProtoMessages:
mid, err := responsesToChatReq(body)
if err != nil {
return nil, err
}
return chatToMessagesReq(mid)
case from == ProtoMessages && to == ProtoResponses:
mid, err := messagesToChatReq(body)
if err != nil {
return nil, err
}
return chatToResponsesReq(mid)
}
return nil, fmt.Errorf("unsupported request conversion %s->%s", from, to)
}
// ConvertResponse 转换响应体(非流式)。from==to 时原样返回。
func ConvertResponse(body []byte, from, to string) ([]byte, error) {
if from == to {
return body, nil
}
body = trimBody(body)
switch {
case from == ProtoMessages && to == ProtoChat:
return messagesToChatResp(body)
case from == ProtoChat && to == ProtoMessages:
return chatToMessagesResp(body)
case from == ProtoResponses && to == ProtoChat:
return responsesToChatResp(body)
case from == ProtoChat && to == ProtoResponses:
return chatToResponsesResp(body)
case from == ProtoResponses && to == ProtoMessages:
mid, err := responsesToChatResp(body)
if err != nil {
return nil, err
}
return chatToMessagesResp(mid)
case from == ProtoMessages && to == ProtoResponses:
mid, err := messagesToChatResp(body)
if err != nil {
return nil, err
}
return chatToResponsesResp(mid)
}
return nil, fmt.Errorf("unsupported response conversion %s->%s", from, to)
}
// NewStreamTransformer 构造流式逐行转换器:输入上游 SSE 一行,返回客户端 SSE 行。
// 返回 nil 表示丢弃该行或无需转换(from==to)。
func NewStreamTransformer(from, to string) func([]byte) []byte {
switch {
case from == ProtoMessages && to == ProtoChat:
return newMessagesToChat().line
case from == ProtoChat && to == ProtoMessages:
return newChatToMessages().line
case from == ProtoResponses && to == ProtoChat:
return newResponsesToChat().line
case from == ProtoChat && to == ProtoResponses:
return newChatToResponses().line
case from == ProtoResponses && to == ProtoMessages:
return newResponsesToMessages().line
case from == ProtoMessages && to == ProtoResponses:
return newMessagesToResponses().line
}
return nil
}
// ---------------------------------------------------------------------------
// 工具函数
// str 返回字符串字段;json.RawMessage 为字符串字面量时去引号。
func str(raw json.RawMessage) string {
if len(raw) == 0 || string(raw) == "null" {
return ""
}
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
// 数组/对象:尝试取 type=text 的 text
var arr []map[string]any
if json.Unmarshal(raw, &arr) == nil {
var parts []string
for _, b := range arr {
if t, _ := b["type"].(string); t == "text" || t == "input_text" || t == "output_text" {
if txt, _ := b["text"].(string); txt != "" {
parts = append(parts, txt)
}
}
}
return joinNonEmpty(parts, "\n")
}
return ""
}
func joinNonEmpty(parts []string, sep string) string {
out := ""
for _, p := range parts {
if p == "" {
continue
}
if out != "" {
out += sep
}
out += p
}
return out
}
// rawJSON 安全取字段;不存在或 null 返回 nil。
func rawJSON(m map[string]json.RawMessage, key string) json.RawMessage {
raw, ok := m[key]
if !ok || string(raw) == "null" {
return nil
}
return raw
}
// rawOrObject 把 RawMessage 解为 map;非对象返回空对象。
func rawOrObject(raw json.RawMessage) any {
if len(raw) == 0 || string(raw) == "null" {
return map[string]any{}
}
var m map[string]any
if json.Unmarshal(raw, &m) == nil {
return m
}
return map[string]any{}
}
// intOrNil 取指针值,nil 时返回默认值。
func intOrNil(p *int, def int) any {
if p == nil {
return def
}
return *p
}
// strField 取 any 中的字符串字段。
func strField(v any) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
@@ -0,0 +1,238 @@
package convert
import (
"encoding/json"
"testing"
)
func TestChatToMessages(t *testing.T) {
maxTokens := 1024
temp := 0.7
req := &ChatCompletionRequest{
Model: "claude-3-sonnet-20240229",
Messages: []Message{
{Role: "system", Content: "You are a helpful assistant."},
{Role: "user", Content: "Hello!"},
},
MaxTokens: &maxTokens,
Temperature: &temp,
}
result, err := ChatToMessages(req)
if err != nil {
t.Fatalf("ChatToMessages() error = %v", err)
}
if result.Model != "claude-3-sonnet-20240229" {
t.Errorf("Model = %q, want %q", result.Model, "claude-3-sonnet-20240229")
}
if len(result.Messages) != 1 {
t.Errorf("Messages length = %d, want 1", len(result.Messages))
}
if result.Messages[0].Role != "user" {
t.Errorf("Messages[0].Role = %q, want %q", result.Messages[0].Role, "user")
}
if result.System == nil {
t.Error("System is nil, want non-nil")
}
if result.MaxTokens != 1024 {
t.Errorf("MaxTokens = %d, want 1024", result.MaxTokens)
}
}
func TestMessagesToChat(t *testing.T) {
resp := &MessagesResponse{
ID: "msg-123",
Model: "claude-3-sonnet-20240229",
Content: []ContentBlock{
{Type: "text", Text: "Hello! How can I help?"},
},
StopReason: "end_turn",
Usage: Usage{
PromptTokens: 10,
CompletionTokens: 20,
},
}
result, err := MessagesToChat(resp)
if err != nil {
t.Fatalf("MessagesToChat() error = %v", err)
}
if result.ID != "msg-123" {
t.Errorf("ID = %q, want %q", result.ID, "msg-123")
}
if result.Object != "chat.completion" {
t.Errorf("Object = %q, want %q", result.Object, "chat.completion")
}
if len(result.Choices) != 1 {
t.Errorf("Choices length = %d, want 1", len(result.Choices))
return
}
if result.Choices[0].Message.Role != "assistant" {
t.Errorf("Choices[0].Message.Role = %q, want %q", result.Choices[0].Message.Role, "assistant")
}
if result.Choices[0].Message.Content != "Hello! How can I help?" {
t.Errorf("Choices[0].Message.Content = %q, want %q", result.Choices[0].Message.Content, "Hello! How can I help?")
}
if result.Choices[0].FinishReason != "stop" {
t.Errorf("FinishReason = %q, want %q", result.Choices[0].FinishReason, "stop")
}
if result.Usage.TotalTokens != 30 {
t.Errorf("Usage.TotalTokens = %d, want 30", result.Usage.TotalTokens)
}
}
func TestChatToResponses(t *testing.T) {
maxTokens := 2048
req := &ChatCompletionRequest{
Model: "gpt-4o",
Messages: []Message{
{Role: "system", Content: "You are a helpful assistant."},
{Role: "user", Content: "What is 2+2?"},
},
MaxTokens: &maxTokens,
}
result, err := ChatToResponses(req)
if err != nil {
t.Fatalf("ChatToResponses() error = %v", err)
}
if result.Model != "gpt-4o" {
t.Errorf("Model = %q, want %q", result.Model, "gpt-4o")
}
if len(result.Input) == 0 {
t.Errorf("Input empty, want 1 item")
} else {
var items []InputItem
if err := json.Unmarshal(result.Input, &items); err != nil {
t.Fatalf("Input unmarshal = %v", err)
}
if len(items) != 1 {
t.Errorf("Input length = %d, want 1", len(items))
} else if items[0].Role != "user" {
t.Errorf("Input[0].Role = %q, want %q", items[0].Role, "user")
}
}
if result.Instructions != "You are a helpful assistant." {
t.Errorf("Instructions = %q, want %q", result.Instructions, "You are a helpful assistant.")
}
}
func TestResponsesToChat(t *testing.T) {
resp := &ResponsesResponse{
ID: "resp-123",
Model: "gpt-4o",
Status: "completed",
Output: []OutputItem{
{
Type: "message",
Content: []OutputContent{
{Type: "output_text", Text: "2+2 equals 4."},
},
},
},
Usage: Usage{
PromptTokens: 15,
CompletionTokens: 10,
},
}
result, err := ResponsesToChat(resp)
if err != nil {
t.Fatalf("ResponsesToChat() error = %v", err)
}
if result.ID != "resp-123" {
t.Errorf("ID = %q, want %q", result.ID, "resp-123")
}
if len(result.Choices) != 1 {
t.Errorf("Choices length = %d, want 1", len(result.Choices))
return
}
if result.Choices[0].Message.Content != "2+2 equals 4." {
t.Errorf("Content = %q, want %q", result.Choices[0].Message.Content, "2+2 equals 4.")
}
}
func TestMapStopReason(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"end_turn", "stop"},
{"stop_sequence", "stop"},
{"tool_use", "tool_calls"},
{"max_tokens", "length"},
{"unknown", "stop"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := mapStopReason(tt.input)
if result != tt.expected {
t.Errorf("mapStopReason(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestMessagesToChatToolUse(t *testing.T) {
resp := &MessagesResponse{
ID: "msg-456",
Model: "claude-3-sonnet-20240229",
Content: []ContentBlock{
{Type: "text", Text: "Let me search for that."},
{Type: "tool_use", ID: "toolu-123", Name: "web_search"},
},
StopReason: "tool_use",
Usage: Usage{
PromptTokens: 20,
CompletionTokens: 30,
},
}
result, err := MessagesToChat(resp)
if err != nil {
t.Fatalf("MessagesToChat() error = %v", err)
}
if len(result.Choices) != 1 {
t.Errorf("Choices length = %d, want 1", len(result.Choices))
return
}
if result.Choices[0].FinishReason != "tool_calls" {
t.Errorf("FinishReason = %q, want %q", result.Choices[0].FinishReason, "tool_calls")
}
if len(result.Choices[0].Message.ToolCalls) != 1 {
t.Errorf("ToolCalls length = %d, want 1", len(result.Choices[0].Message.ToolCalls))
return
}
if result.Choices[0].Message.ToolCalls[0].ID != "toolu-123" {
t.Errorf("ToolCall ID = %q, want %q", result.Choices[0].Message.ToolCalls[0].ID, "toolu-123")
}
if result.Choices[0].Message.ToolCalls[0].Function.Name != "web_search" {
t.Errorf("Function.Name = %q, want %q", result.Choices[0].Message.ToolCalls[0].Function.Name, "web_search")
}
}
+504
View File
@@ -0,0 +1,504 @@
package convert
import (
"encoding/json"
"strings"
)
// ---------------------------------------------------------------------------
// 请求:Chat → Messages
type chatTool struct {
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
} `json:"function"`
}
type chatMsg struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
ToolCallID string `json:"tool_call_id"`
ToolCalls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
}
type chatReq struct {
Model string `json:"model"`
Messages []chatMsg `json:"messages"`
Tools []chatTool `json:"tools"`
Temperature *float64 `json:"temperature"`
TopP *float64 `json:"top_p"`
MaxTokens *int `json:"max_tokens"`
Stop []string `json:"stop"`
Stream bool `json:"stream"`
}
// chatToMessagesReq 将 OpenAI Chat 请求转为 Anthropic Messages 请求。
func chatToMessagesReq(body []byte) ([]byte, error) {
var req chatReq
if err := json.Unmarshal(body, &req); err != nil {
return nil, err
}
out := map[string]any{
"model": req.Model,
"max_tokens": intOrNil(req.MaxTokens, 1024), // Anthropic 必填
}
if req.Stream {
out["stream"] = true
}
if req.Temperature != nil {
out["temperature"] = *req.Temperature
}
if req.TopP != nil {
out["top_p"] = *req.TopP
}
if len(req.Stop) > 0 {
out["stop_sequences"] = req.Stop
}
var system []string
msgs := make([]any, 0, len(req.Messages))
for _, m := range req.Messages {
if m.Role == "system" {
if s := str(m.Content); s != "" {
system = append(system, s)
}
continue
}
msgs = append(msgs, chatMsgToAnthropic(m))
}
if len(system) > 0 {
out["system"] = strings.Join(system, "\n")
}
out["messages"] = msgs
if len(req.Tools) > 0 {
tools := make([]any, 0, len(req.Tools))
for _, t := range req.Tools {
var params any
if len(t.Function.Parameters) > 0 && string(t.Function.Parameters) != "null" {
_ = json.Unmarshal(t.Function.Parameters, &params)
}
tools = append(tools, map[string]any{
"name": t.Function.Name,
"description": t.Function.Description,
"input_schema": params,
})
}
out["tools"] = tools
}
return json.Marshal(out)
}
// chatMsgToAnthropic 单条消息转 Anthropic 内容。
func chatMsgToAnthropic(m chatMsg) any {
switch m.Role {
case "assistant":
content := make([]any, 0, 2)
if s := str(m.Content); s != "" {
content = append(content, map[string]any{"type": "text", "text": s})
}
for _, tc := range m.ToolCalls {
var input any
if tc.Function.Arguments != "" {
_ = json.Unmarshal([]byte(tc.Function.Arguments), &input)
}
content = append(content, map[string]any{
"type": "tool_use",
"id": tc.ID,
"name": tc.Function.Name,
"input": input,
})
}
return map[string]any{"role": "assistant", "content": content}
case "tool":
return map[string]any{"role": "user", "content": []any{
map[string]any{"type": "tool_result", "tool_use_id": m.ToolCallID, "content": str(m.Content)},
}}
default: // user
var arr []map[string]any
if json.Unmarshal(m.Content, &arr) == nil && arr != nil {
blocks := make([]any, 0, len(arr))
for _, b := range arr {
switch b["type"] {
case "text", "input_text":
if t, _ := b["text"].(string); t != "" {
blocks = append(blocks, map[string]any{"type": "text", "text": t})
}
case "image_url":
var url string
if iu, ok := b["image_url"].(map[string]any); ok {
url, _ = iu["url"].(string)
} else if s, ok := b["image_url"].(string); ok {
url = s
}
if url != "" {
blocks = append(blocks, anthropicImageBlock(url))
}
}
}
if len(blocks) > 0 {
return map[string]any{"role": "user", "content": blocks}
}
}
return map[string]any{"role": "user", "content": str(m.Content)}
}
}
// ---------------------------------------------------------------------------
// 请求:Messages → Chat
type messagesReq struct {
Model string `json:"model"`
System json.RawMessage `json:"system"`
Messages []struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
} `json:"messages"`
Tools []struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"input_schema"`
} `json:"tools"`
Temperature *float64 `json:"temperature"`
TopP *float64 `json:"top_p"`
MaxTokens *int `json:"max_tokens"`
StopSequence []string `json:"stop_sequences"`
Stream bool `json:"stream"`
}
// messagesToChatReq 将 Anthropic Messages 请求转为 OpenAI Chat 请求。
func messagesToChatReq(body []byte) ([]byte, error) {
var req messagesReq
if err := json.Unmarshal(body, &req); err != nil {
return nil, err
}
out := map[string]any{"model": req.Model}
if req.Stream {
out["stream"] = true
}
if req.Temperature != nil {
out["temperature"] = *req.Temperature
}
if req.TopP != nil {
out["top_p"] = *req.TopP
}
if req.MaxTokens != nil {
out["max_tokens"] = *req.MaxTokens
}
if len(req.StopSequence) > 0 {
out["stop"] = req.StopSequence
}
msgs := make([]any, 0, len(req.Messages)+1)
if s := str(req.System); s != "" {
msgs = append(msgs, map[string]any{"role": "system", "content": s})
}
for _, m := range req.Messages {
msgs = append(msgs, anthropicMsgToChat(m.Role, m.Content)...)
}
out["messages"] = msgs
if len(req.Tools) > 0 {
tools := make([]any, 0, len(req.Tools))
for _, t := range req.Tools {
tools = append(tools, map[string]any{
"type": "function",
"function": map[string]any{
"name": t.Name,
"description": t.Description,
"parameters": rawOrObject(t.InputSchema),
},
})
}
out["tools"] = tools
}
return json.Marshal(out)
}
// anthropicMsgToChat 将一条 Anthropic 消息拆成 0..N 条 Chat 消息。
func anthropicMsgToChat(role string, content json.RawMessage) []any {
// 块数组优先(tool_use / tool_result 需要分块解析)
var blocks []map[string]any
if json.Unmarshal(content, &blocks) == nil && blocks != nil {
var out []any
var toolMsgs []any // tool_result 单独收集,保证排在 assistant(tool_calls) 之后
var textParts []string
var contentBlocks []any // text / image_url 块,保留原始顺序
var toolCalls []any
for _, b := range blocks {
switch b["type"] {
case "text":
if t, _ := b["text"].(string); t != "" {
textParts = append(textParts, t)
contentBlocks = append(contentBlocks, map[string]any{"type": "text", "text": t})
}
case "image":
if cb := chatImageBlock(b); cb != nil {
contentBlocks = append(contentBlocks, cb)
}
case "tool_use":
id, _ := b["id"].(string)
name, _ := b["name"].(string)
args, _ := json.Marshal(b["input"])
toolCalls = append(toolCalls, map[string]any{
"id": id,
"type": "function",
"function": map[string]any{
"name": name,
"arguments": string(args),
},
})
case "tool_result":
callID, _ := b["tool_use_id"].(string)
res := strField(b["content"])
toolMsgs = append(toolMsgs, map[string]any{"role": "tool", "tool_call_id": callID, "content": res})
}
}
hasImage := false
for _, cb := range contentBlocks {
if m, _ := cb.(map[string]any); m["type"] == "image_url" {
hasImage = true
break
}
}
if hasImage || len(textParts) > 0 || len(toolCalls) > 0 {
msg := map[string]any{"role": role}
switch {
case hasImage:
msg["content"] = contentBlocks
case len(textParts) > 0:
msg["content"] = strings.Join(textParts, "")
}
if len(toolCalls) > 0 {
msg["tool_calls"] = toolCalls
}
out = append(out, msg)
}
out = append(out, toolMsgs...)
if len(out) > 0 {
return out
}
}
// 纯文本
if s := str(content); s != "" {
return []any{map[string]any{"role": role, "content": s}}
}
return nil
}
// ---------------------------------------------------------------------------
// 响应:Messages → Chat
type messagesResp struct {
ID string `json:"id"`
Model string `json:"model"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
ID string `json:"id"`
Name string `json:"name"`
Input json.RawMessage `json:"input"`
} `json:"content"`
StopReason string `json:"stop_reason"`
Usage struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
} `json:"usage"`
}
// messagesToChatResp 将 Anthropic Messages 响应(非流式)转为 Chat 响应。
func messagesToChatResp(body []byte) ([]byte, error) {
var r messagesResp
if err := json.Unmarshal(body, &r); err != nil {
return nil, err
}
var text string
var toolCalls []any
for _, c := range r.Content {
switch c.Type {
case "text":
text += c.Text
case "tool_use":
args, _ := json.Marshal(c.Input)
toolCalls = append(toolCalls, map[string]any{
"id": c.ID,
"type": "function",
"function": map[string]any{
"name": c.Name,
"arguments": string(args),
},
})
}
}
msg := map[string]any{"role": "assistant", "content": text}
if len(toolCalls) > 0 {
msg["tool_calls"] = toolCalls
}
return json.Marshal(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(r.ID, "msg_"),
"object": "chat.completion",
"model": r.Model,
"created": 0,
"choices": []any{map[string]any{
"index": 0,
"message": msg,
"finish_reason": messagesStopToChat(r.StopReason),
}},
"usage": map[string]any{
"prompt_tokens": r.Usage.InputTokens,
"completion_tokens": r.Usage.OutputTokens,
"total_tokens": r.Usage.InputTokens + r.Usage.OutputTokens,
},
})
}
// ---------------------------------------------------------------------------
// 响应:Chat → Messages
type chatResp struct {
ID string `json:"id"`
Model string `json:"model"`
Choices []struct {
Message struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
} `json:"usage"`
}
// chatToMessagesResp 将 Chat 响应(非流式)转为 Messages 响应。
func chatToMessagesResp(body []byte) ([]byte, error) {
var r chatResp
if err := json.Unmarshal(body, &r); err != nil {
return nil, err
}
content := make([]any, 0, 2)
var finish = "end_turn"
if len(r.Choices) > 0 {
msg := r.Choices[0].Message
if msg.Content != "" {
content = append(content, map[string]any{"type": "text", "text": msg.Content})
}
for _, tc := range msg.ToolCalls {
var input any
_ = json.Unmarshal([]byte(tc.Function.Arguments), &input)
content = append(content, map[string]any{
"type": "tool_use",
"id": tc.ID,
"name": tc.Function.Name,
"input": input,
})
}
finish = chatStopToMessages(r.Choices[0].FinishReason)
}
return json.Marshal(map[string]any{
"id": "msg_" + strings.TrimPrefix(r.ID, "chatcmpl-"),
"type": "message",
"role": "assistant",
"model": r.Model,
"content": content,
"stop_reason": finish,
"usage": map[string]any{
"input_tokens": r.Usage.PromptTokens,
"output_tokens": r.Usage.CompletionTokens,
},
})
}
// ---------------------------------------------------------------------------
// 辅助
// splitDataURL 解析 data:media_type;base64,data 形式的 URL;非该形式返回 ok=false。
func splitDataURL(url string) (media, data string, ok bool) {
if !strings.HasPrefix(url, "data:") {
return "", "", false
}
i := strings.Index(url, ";base64,")
if i < 0 {
return "", "", false
}
return url[len("data:"):i], url[i+len(";base64,"):], true
}
// chatImageBlock 把 Anthropic image 块转 OpenAI image_url 块。
// 仅支持 base64 与 url source;其他类型(如 Files API 的 file_id)不支持,跳过。
func chatImageBlock(b map[string]any) any {
src, ok := b["source"].(map[string]any)
if !ok {
return nil
}
switch src["type"] {
case "base64":
media, _ := src["media_type"].(string)
data, _ := src["data"].(string)
if data == "" {
return nil
}
if media == "" {
media = "image/png"
}
return map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:" + media + ";base64," + data}}
case "url":
url, _ := src["url"].(string)
if url == "" {
return nil
}
return map[string]any{"type": "image_url", "image_url": map[string]any{"url": url}}
}
return nil
}
// anthropicImageBlock 把 OpenAI image_url 的 url 转 Anthropic image 块。
// data URL → base64 source;http(s) URL → url source。
func anthropicImageBlock(url string) any {
if media, data, ok := splitDataURL(url); ok {
if media == "" {
media = "image/png"
}
return map[string]any{"type": "image", "source": map[string]any{"type": "base64", "media_type": media, "data": data}}
}
return map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": url}}
}
func messagesStopToChat(s string) string {
switch s {
case "tool_use":
return "tool_calls"
case "max_tokens":
return "length"
default:
return "stop"
}
}
func chatStopToMessages(s string) string {
switch s {
case "tool_calls":
return "tool_use"
case "length":
return "max_tokens"
default:
return "end_turn"
}
}
@@ -0,0 +1,376 @@
package convert
import (
"encoding/json"
"strings"
)
// ---------------------------------------------------------------------------
// 请求:Responses → Chat
// responsesToChatReq 将 OpenAI Responses 请求转为 Chat 请求。
func responsesToChatReq(body []byte) ([]byte, error) {
var m map[string]json.RawMessage
if err := json.Unmarshal(body, &m); err != nil {
return nil, err
}
out := map[string]any{"model": str(rawJSON(m, "model"))}
if v, ok := m["stream"]; ok && string(v) == "true" {
out["stream"] = true
}
if v, ok := m["temperature"]; ok {
out["temperature"] = v
}
if v, ok := m["top_p"]; ok {
out["top_p"] = v
}
if v, ok := m["max_output_tokens"]; ok {
out["max_tokens"] = v
}
var msgs []any
if ins := str(rawJSON(m, "instructions")); ins != "" {
msgs = append(msgs, map[string]any{"role": "system", "content": ins})
}
msgs = append(msgs, responsesInputToChat(rawJSON(m, "input"))...)
out["messages"] = msgs
if raw := rawJSON(m, "tools"); raw != nil {
var tools []map[string]any
if json.Unmarshal(raw, &tools) == nil {
chatTools := make([]any, 0, len(tools))
for _, t := range tools {
chatTools = append(chatTools, map[string]any{
"type": "function",
"function": map[string]any{
"name": t["name"],
"description": t["description"],
"parameters": t["parameters"],
},
})
}
out["tools"] = chatTools
}
}
return json.Marshal(out)
}
// responsesInputToChat 把 Responses input 转成 Chat messages。
// input 支持字符串或条目数组(message / function_call / function_call_output)。
func responsesInputToChat(raw json.RawMessage) []any {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
// 字符串输入
if s := str(raw); s != "" {
return []any{map[string]any{"role": "user", "content": s}}
}
var items []map[string]any
if err := json.Unmarshal(raw, &items); err != nil || items == nil {
return nil
}
var out []any
for _, item := range items {
switch item["type"] {
case "function_call":
out = append(out, map[string]any{
"role": "assistant",
"content": "",
"tool_calls": []any{map[string]any{
"id": strField(item["call_id"]),
"type": "function",
"function": map[string]any{
"name": strField(item["name"]),
"arguments": strField(item["arguments"]),
},
}},
})
case "function_call_output":
out = append(out, map[string]any{
"role": "tool",
"tool_call_id": strField(item["call_id"]),
"content": strField(item["output"]),
})
default: // message 条目
role, _ := item["role"].(string)
if role == "" {
role = "user"
}
if content, ok := item["content"].(string); ok {
out = append(out, map[string]any{"role": role, "content": content})
} else if blocks, ok := item["content"].([]any); ok {
var text []string
var contentBlocks []any
for _, b := range blocks {
bm, ok := b.(map[string]any)
if !ok {
continue
}
switch bm["type"] {
case "input_text", "text":
if t, _ := bm["text"].(string); t != "" {
text = append(text, t)
contentBlocks = append(contentBlocks, map[string]any{"type": "text", "text": t})
}
case "input_image":
var url string
if s, ok := bm["image_url"].(string); ok {
url = s
} else if m, ok := bm["image_url"].(map[string]any); ok {
url, _ = m["url"].(string)
}
if url != "" {
contentBlocks = append(contentBlocks, map[string]any{"type": "image_url", "image_url": map[string]any{"url": url}})
}
}
}
hasImage := false
for _, cb := range contentBlocks {
if m, _ := cb.(map[string]any); m["type"] == "image_url" {
hasImage = true
break
}
}
if hasImage {
out = append(out, map[string]any{"role": role, "content": contentBlocks})
} else {
out = append(out, map[string]any{"role": role, "content": strings.Join(text, "")})
}
}
}
}
return out
}
// chatContentToResponsesBlocks 把 Chat 用户消息 content 转 Responses input 块数组(input_text / input_image)。
func chatContentToResponsesBlocks(content json.RawMessage) []any {
// 纯字符串 → 单个 input_text
var s string
if json.Unmarshal(content, &s) == nil && s != "" {
return []any{map[string]any{"type": "input_text", "text": s}}
}
// 数组 → 按块转换(text / image_url)
var arr []map[string]any
if json.Unmarshal(content, &arr) == nil && arr != nil {
var out []any
for _, b := range arr {
switch b["type"] {
case "text", "input_text":
if t, _ := b["text"].(string); t != "" {
out = append(out, map[string]any{"type": "input_text", "text": t})
}
case "image_url":
var url string
if iu, ok := b["image_url"].(map[string]any); ok {
url, _ = iu["url"].(string)
} else if s, ok := b["image_url"].(string); ok {
url = s
}
if url != "" {
out = append(out, map[string]any{"type": "input_image", "image_url": url})
}
}
}
return out
}
return nil
}
// ---------------------------------------------------------------------------
// 请求:Chat → Responses
// chatToResponsesReq 将 Chat 请求转为 Responses 请求。
func chatToResponsesReq(body []byte) ([]byte, error) {
var req chatReq
if err := json.Unmarshal(body, &req); err != nil {
return nil, err
}
out := map[string]any{"model": req.Model}
if req.Stream {
out["stream"] = true
}
if req.Temperature != nil {
out["temperature"] = *req.Temperature
}
if req.TopP != nil {
out["top_p"] = *req.TopP
}
if req.MaxTokens != nil {
out["max_output_tokens"] = *req.MaxTokens
}
var system []string
var input []any
for _, m := range req.Messages {
if m.Role == "system" {
if s := str(m.Content); s != "" {
system = append(system, s)
}
continue
}
switch m.Role {
case "tool":
input = append(input, map[string]any{
"type": "function_call_output",
"call_id": m.ToolCallID,
"output": str(m.Content),
})
case "assistant":
if len(m.ToolCalls) > 0 {
for _, tc := range m.ToolCalls {
input = append(input, map[string]any{
"type": "function_call",
"call_id": tc.ID,
"name": tc.Function.Name,
"arguments": tc.Function.Arguments,
})
}
} else if s := str(m.Content); s != "" {
input = append(input, map[string]any{"type": "message", "role": "assistant", "content": []any{
map[string]any{"type": "input_text", "text": s},
}})
}
default:
if blocks := chatContentToResponsesBlocks(m.Content); len(blocks) > 0 {
input = append(input, map[string]any{"type": "message", "role": "user", "content": blocks})
}
}
}
if len(system) > 0 {
out["instructions"] = strings.Join(system, "\n")
}
// input 必须是数组:部分上游只接受数组,单对象会被拒(400 Mismatch type)。
out["input"] = input
if len(req.Tools) > 0 {
tools := make([]any, 0, len(req.Tools))
for _, t := range req.Tools {
tools = append(tools, map[string]any{
"type": "function",
"name": t.Function.Name,
"description": t.Function.Description,
"parameters": rawOrObject(t.Function.Parameters),
})
}
out["tools"] = tools
}
return json.Marshal(out)
}
// ---------------------------------------------------------------------------
// 响应:Responses → Chat
// responsesToChatResp 将 Responses 响应(非流式)转为 Chat 响应。
func responsesToChatResp(body []byte) ([]byte, error) {
var m map[string]json.RawMessage
if err := json.Unmarshal(body, &m); err != nil {
return nil, err
}
var text string
var toolCalls []any
if raw := rawJSON(m, "output"); raw != nil {
var outputs []map[string]any
if json.Unmarshal(raw, &outputs) == nil {
for _, o := range outputs {
switch o["type"] {
case "message":
if content, ok := o["content"].([]any); ok {
for _, c := range content {
if cm, ok := c.(map[string]any); ok {
if t, _ := cm["text"].(string); t != "" {
text += t
}
}
}
}
case "function_call":
toolCalls = append(toolCalls, map[string]any{
"id": strField(o["call_id"]),
"type": "function",
"function": map[string]any{
"name": strField(o["name"]),
"arguments": strField(o["arguments"]),
},
})
}
}
}
}
msg := map[string]any{"role": "assistant", "content": text}
if len(toolCalls) > 0 {
msg["tool_calls"] = toolCalls
}
finish := "stop"
switch {
case string(rawJSON(m, "status")) == `"incomplete"`:
finish = "length" // 截断优先,客户端可据此区分
case len(toolCalls) > 0:
finish = "tool_calls"
}
var prompt, completion int64
if u := rawJSON(m, "usage"); u != nil {
var us struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
}
_ = json.Unmarshal(u, &us)
prompt, completion = us.InputTokens, us.OutputTokens
}
return json.Marshal(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(str(rawJSON(m, "id")), "resp_"),
"object": "chat.completion",
"model": str(rawJSON(m, "model")),
"choices": []any{map[string]any{"index": 0, "message": msg, "finish_reason": finish}},
"usage": map[string]any{
"prompt_tokens": prompt,
"completion_tokens": completion,
"total_tokens": prompt + completion,
},
})
}
// ---------------------------------------------------------------------------
// 响应:Chat → Responses
// chatToResponsesResp 将 Chat 响应(非流式)转为 Responses 响应。
func chatToResponsesResp(body []byte) ([]byte, error) {
var r chatResp
if err := json.Unmarshal(body, &r); err != nil {
return nil, err
}
output := make([]any, 0, 2)
var finish = "completed"
if len(r.Choices) > 0 {
msg := r.Choices[0].Message
if msg.Content != "" {
output = append(output, map[string]any{
"type": "message",
"role": "assistant",
"content": []any{map[string]any{"type": "output_text", "text": msg.Content}},
})
}
for _, tc := range msg.ToolCalls {
output = append(output, map[string]any{
"type": "function_call",
"call_id": tc.ID,
"name": tc.Function.Name,
"arguments": tc.Function.Arguments,
})
}
if r.Choices[0].FinishReason == "length" {
finish = "incomplete"
}
}
return json.Marshal(map[string]any{
"id": "resp_" + strings.TrimPrefix(r.ID, "chatcmpl-"),
"object": "response",
"model": r.Model,
"status": finish,
"output": output,
"usage": map[string]any{
"input_tokens": r.Usage.PromptTokens,
"output_tokens": r.Usage.CompletionTokens,
"total_tokens": r.Usage.PromptTokens + r.Usage.CompletionTokens,
},
})
}
@@ -0,0 +1,37 @@
package convert
// MessagesRequest represents an Anthropic Messages API request
type MessagesRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
MaxTokens int `json:"max_tokens"`
System interface{} `json:"system,omitempty"` // string or []ContentPart
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
TopK *int `json:"top_k,omitempty"`
StopSequences []string `json:"stop_sequences,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools []Tool `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
Metadata interface{} `json:"metadata,omitempty"`
}
// MessagesResponse represents an Anthropic Messages API response
type MessagesResponse struct {
ID string `json:"id"`
Type string `json:"type"`
Role string `json:"role"`
Content []ContentBlock `json:"content"`
Model string `json:"model"`
StopReason string `json:"stop_reason"`
StopSequence string `json:"stop_sequence,omitempty"`
Usage Usage `json:"usage"`
}
// AnthropicStreamEvent represents an Anthropic streaming event
type AnthropicStreamEvent struct {
Type string `json:"type"`
Index int `json:"index,omitempty"`
Delta *Delta `json:"delta,omitempty"`
Usage *Usage `json:"usage,omitempty"`
}
@@ -0,0 +1,74 @@
package convert
import "encoding/json"
// ResponsesRequest represents an OpenAI Responses API request
type ResponsesRequest struct {
Model string `json:"model"`
Input json.RawMessage `json:"input,omitempty"`
Instructions string `json:"instructions,omitempty"`
MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
Tools []Tool `json:"tools,omitempty"`
ToolChoice interface{} `json:"tool_choice,omitempty"`
Stream bool `json:"stream,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Metadata interface{} `json:"metadata,omitempty"`
}
// InputItem represents a single input item
type InputItem struct {
Role string `json:"role"`
Content interface{} `json:"content,omitempty"`
}
// marshalInputItems 把 input 条目序列化为 Responses input 的 json.RawMessage 形态。
// Input 字段用 RawMessage 以兼容字符串与条目数组两种客户端写法。
func marshalInputItems(items []InputItem) json.RawMessage {
if len(items) == 0 {
return nil
}
b, err := json.Marshal(items)
if err != nil {
return nil
}
return b
}
// ResponsesResponse represents an OpenAI Responses API response
type ResponsesResponse struct {
ID string `json:"id"`
Object string `json:"object"`
CreatedAt int64 `json:"created_at"`
Status string `json:"status"`
Model string `json:"model"`
Output []OutputItem `json:"output"`
Usage Usage `json:"usage"`
Error interface{} `json:"error,omitempty"`
Incomplete *Incomplete `json:"incomplete,omitempty"`
}
type OutputItem struct {
Type string `json:"type"`
Content []OutputContent `json:"content,omitempty"`
Role string `json:"role,omitempty"`
}
type OutputContent struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input interface{} `json:"input,omitempty"`
}
type Incomplete struct {
Reason string `json:"reason"`
}
// ResponsesStreamEvent represents a Responses API streaming event
type ResponsesStreamEvent struct {
Type string `json:"type"`
Item *OutputItem `json:"item,omitempty"`
Delta string `json:"delta,omitempty"`
}
+171
View File
@@ -0,0 +1,171 @@
package convert
import (
"bufio"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
// SSEWriter writes Server-Sent Events
type SSEWriter struct {
writer io.Writer
flusher http.Flusher
}
// NewSSEWriter creates a new SSE writer
func NewSSEWriter(w http.ResponseWriter) *SSEWriter {
flusher, _ := w.(http.Flusher)
return &SSEWriter{
writer: w,
flusher: flusher,
}
}
// WriteEvent writes a single SSE event
func (w *SSEWriter) WriteEvent(event string, data interface{}) error {
var dataStr string
switch v := data.(type) {
case string:
dataStr = v
default:
b, err := json.Marshal(v)
if err != nil {
return err
}
dataStr = string(b)
}
_, err := fmt.Fprintf(w.writer, "event: %s\ndata: %s\n\n", event, dataStr)
if err != nil {
return err
}
if w.flusher != nil {
w.flusher.Flush()
}
return nil
}
// WriteChunk writes a streaming chunk in SSE format
func (w *SSEWriter) WriteChunk(chunk interface{}) error {
b, err := json.Marshal(chunk)
if err != nil {
return err
}
_, err = fmt.Fprintf(w.writer, "data: %s\n\n", string(b))
if err != nil {
return err
}
if w.flusher != nil {
w.flusher.Flush()
}
return nil
}
// WriteDone writes the [DONE] marker
func (w *SSEWriter) WriteDone() error {
_, err := fmt.Fprintf(w.writer, "data: [DONE]\n\n")
if err != nil {
return err
}
if w.flusher != nil {
w.flusher.Flush()
}
return nil
}
// SSEParser parses Server-Sent Events from a reader
type SSEParser struct {
reader *bufio.Reader
}
// NewSSEParser creates a new SSE parser
func NewSSEParser(r io.Reader) *SSEParser {
return &SSEParser{
reader: bufio.NewReader(r),
}
}
// SSEEvent represents a parsed SSE event
type SSEEvent struct {
Event string
Data string
}
// ReadEvent reads the next SSE event
func (p *SSEParser) ReadEvent() (*SSEEvent, error) {
event := &SSEEvent{}
for {
line, err := p.reader.ReadString('\n')
if err != nil {
return nil, err
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
// Empty line means end of event
if event.Data != "" || event.Event != "" {
return event, nil
}
continue
}
if strings.HasPrefix(line, "event:") {
event.Event = strings.TrimSpace(line[6:])
} else if strings.HasPrefix(line, "data:") {
data := strings.TrimSpace(line[5:])
if event.Data != "" {
event.Data += "\n" + data
} else {
event.Data = data
}
}
// Ignore comments (lines starting with :) and unknown fields
}
}
// ParseChatStreamChunk parses an OpenAI Chat Completions streaming chunk
func ParseChatStreamChunk(data string) (*ChatCompletionStreamChunk, error) {
if data == "[DONE]" {
return nil, io.EOF
}
var chunk ChatCompletionStreamChunk
err := json.Unmarshal([]byte(data), &chunk)
if err != nil {
return nil, err
}
return &chunk, nil
}
// ParseMessagesStreamEvent parses an Anthropic Messages streaming event
func ParseMessagesStreamEvent(data string) (*AnthropicStreamEvent, error) {
var event AnthropicStreamEvent
err := json.Unmarshal([]byte(data), &event)
if err != nil {
return nil, err
}
return &event, nil
}
// ParseResponsesStreamChunk parses an OpenAI Responses API streaming chunk
func ParseResponsesStreamChunk(data string) (*ResponsesStreamEvent, error) {
if data == "[DONE]" {
return nil, io.EOF
}
var event ResponsesStreamEvent
err := json.Unmarshal([]byte(data), &event)
if err != nil {
return nil, err
}
return &event, nil
}
@@ -0,0 +1,642 @@
package convert
import (
"encoding/json"
"strings"
)
// sseState 记录上一行 event 名。
type sseState struct {
event string
}
// parseLine 解析一行 SSE;返回是否 data 行及其内容、是否 [DONE]。
// data: 后可跟空格(标准)或紧贴 JSON(部分上游会省略空格)。
func (s *sseState) parseLine(line []byte) (isData bool, data string, done bool) {
strLine := strings.TrimRight(string(line), "\r\n")
switch {
case strings.HasPrefix(strLine, "event: "):
s.event = strings.TrimSpace(strings.TrimPrefix(strLine, "event: "))
return false, "", false
case strLine == "data: [DONE]" || strLine == "data:[DONE]":
return true, "[DONE]", true
case strings.HasPrefix(strLine, "data:"):
return true, strings.TrimLeft(strings.TrimPrefix(strLine, "data:"), " "), false
default:
return false, "", false
}
}
func eventData(line string) map[string]any {
var m map[string]any
_ = json.Unmarshal([]byte(line), &m)
return m
}
func dataLine(obj any) []byte {
b, _ := json.Marshal(obj)
return append(append([]byte("data: "), b...), '\n', '\n')
}
func eventLine(name string, obj any) []byte {
b, _ := json.Marshal(obj)
out := append([]byte("event: "+name+"\ndata: "), b...)
return append(out, '\n', '\n')
}
// joinLines 拼接多条 SSE 行。
func joinLines(lines [][]byte) []byte {
var s []string
for _, l := range lines {
s = append(s, string(l))
}
return []byte(strings.Join(s, ""))
}
// ---------------------------------------------------------------------------
// Messages → Chat
type messagesToChat struct {
sseState
id, model string
toolIdx map[int]int // messages content block index → chat tool_calls index(顺序编号,避开文本块)
nextTool int
}
func newMessagesToChat() *messagesToChat { return &messagesToChat{toolIdx: map[int]int{}} }
func (t *messagesToChat) line(line []byte) []byte {
isData, data, done := t.parseLine(line)
if !isData {
return nil
}
if done {
return []byte("data: [DONE]\n\n")
}
m := eventData(data)
evt, _ := m["type"].(string)
switch evt {
case "message_start":
msg, _ := m["message"].(map[string]any)
t.id, _ = msg["id"].(string)
t.model, _ = msg["model"].(string)
return dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"role": "assistant"}, "finish_reason": nil}},
})
case "content_block_start":
cb, _ := m["content_block"].(map[string]any)
if cb == nil || cb["type"] != "tool_use" {
return nil
}
blockIdx, _ := m["index"].(float64)
tool := t.nextTool
t.nextTool++
t.toolIdx[int(blockIdx)] = tool
toolID, _ := cb["id"].(string)
name, _ := cb["name"].(string)
return dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{
"tool_calls": []any{map[string]any{"index": tool, "id": toolID, "type": "function", "function": map[string]any{"name": name, "arguments": ""}}},
}, "finish_reason": nil}},
})
case "content_block_delta":
delta, _ := m["delta"].(map[string]any)
deltaType, _ := delta["type"].(string)
if deltaType == "input_json_delta" {
blockIdx, _ := m["index"].(float64)
tool, ok := t.toolIdx[int(blockIdx)]
if !ok {
return nil
}
partial, _ := delta["partial_json"].(string)
if partial == "" {
return nil
}
return dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{
"tool_calls": []any{map[string]any{"index": tool, "function": map[string]any{"arguments": partial}}},
}, "finish_reason": nil}},
})
}
text, _ := delta["text"].(string)
if text == "" {
return nil
}
return dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"content": text}, "finish_reason": nil}},
})
case "message_delta":
delta, _ := m["delta"].(map[string]any)
stop, _ := delta["stop_reason"].(string)
var out [][]byte
out = append(out, dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": messagesStopToChat(stop)}},
}))
if u, ok := m["usage"]; ok {
out = append(out, dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{}, "usage": u,
}))
}
return joinLines(out)
case "message_stop":
return []byte("data: [DONE]\n\n")
}
return nil
}
// ---------------------------------------------------------------------------
// Chat → Messages
type chatToMessages struct {
sseState
started bool // message_start 已发出
nextIndex int // 下一个 content block index(顺序分配)
textIndex int // 文本块 index;-1 = 未开始
toolIdx map[int]int // chat delta.tool_calls[].index → messages block index
openBlocks []int // 已开始未停止的 block index,按开始顺序
model string
stopReason string
usage any
}
func newChatToMessages() *chatToMessages {
return &chatToMessages{textIndex: -1, toolIdx: map[int]int{}}
}
func (t *chatToMessages) line(line []byte) []byte {
isData, data, done := t.parseLine(line)
if !isData {
return nil
}
if done {
// 汇聚最终:先对每个已开始未停止的块发 content_block_stop,再 message_delta + message_stop
var out [][]byte
for _, idx := range t.openBlocks {
out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": idx}))
}
md := map[string]any{"type": "message_delta", "delta": map[string]any{
"stop_reason": stopReasonOrEnd(t.stopReason), "stop_sequence": nil,
}}
if t.usage != nil {
md["usage"] = t.usage
}
out = append(out, eventLine("message_delta", md))
out = append(out, eventLine("message_stop", map[string]any{"type": "message_stop"}))
return joinLines(out)
}
m := eventData(data)
// chat 块:delta / finish_reason 在 choices[0] 内
delta := map[string]any{}
if choices, ok := m["choices"].([]any); ok && len(choices) > 0 {
if c0, ok := choices[0].(map[string]any); ok {
if d, ok := c0["delta"].(map[string]any); ok {
delta = d
}
if fr, _ := c0["finish_reason"].(string); fr != "" {
t.stopReason = fr
}
}
}
if t.model == "" {
t.model, _ = m["model"].(string)
}
id, _ := m["id"].(string)
var out [][]byte
// message_start 只在实际有内容(文本或工具)时发出,避免 reasoning_content 块
//(带 role 无 content)提前开出一个空文本块。
ensureStarted := func() {
if t.started {
return
}
t.started = true
out = append(out, eventLine("message_start", map[string]any{
"type": "message_start",
"message": map[string]any{
"id": "msg_" + strings.TrimPrefix(id, "chatcmpl-"), "type": "message", "role": "assistant",
"model": t.model, "content": []any{}, "usage": map[string]any{"input_tokens": 0, "output_tokens": 0},
},
}))
}
// 文本:delta.content(string;兼容 {type:text,text} 数组)
if content := deltaText(delta); content != "" {
if t.textIndex < 0 {
t.textIndex = t.nextIndex
t.nextIndex++
ensureStarted()
out = append(out, eventLine("content_block_start", map[string]any{
"type": "content_block_start", "index": t.textIndex, "content_block": map[string]any{"type": "text", "text": ""},
}))
t.openBlocks = append(t.openBlocks, t.textIndex)
}
out = append(out, eventLine("content_block_delta", map[string]any{
"type": "content_block_delta", "index": t.textIndex, "delta": map[string]any{"type": "text_delta", "text": content},
}))
}
// 工具调用:delta.tool_calls(并行调用各 index 独立成块;arguments 支持整段/分段两种流式)
if tcs, ok := delta["tool_calls"].([]any); ok {
for _, tc := range tcs {
call, ok := tc.(map[string]any)
if !ok {
continue
}
idx, _ := call["index"].(float64)
tcIdx := int(idx)
fn, _ := call["function"].(map[string]any)
name, _ := fn["name"].(string)
args, _ := fn["arguments"].(string)
blockIdx, seen := t.toolIdx[tcIdx]
if !seen {
blockIdx = t.nextIndex
t.nextIndex++
t.toolIdx[tcIdx] = blockIdx
toolID, _ := call["id"].(string)
ensureStarted()
out = append(out, eventLine("content_block_start", map[string]any{
"type": "content_block_start", "index": blockIdx, "content_block": map[string]any{
"type": "tool_use", "id": toolID, "name": name, "input": map[string]any{},
},
}))
t.openBlocks = append(t.openBlocks, blockIdx)
}
if args != "" {
out = append(out, eventLine("content_block_delta", map[string]any{
"type": "content_block_delta", "index": blockIdx, "delta": map[string]any{"type": "input_json_delta", "partial_json": args},
}))
}
}
}
if u, ok := m["usage"]; ok {
t.usage = u
}
return joinLines(out)
}
// deltaText 取 chat delta.content 文本(string 或 [{type:text,text}] 数组拼接)。
func deltaText(delta map[string]any) string {
if s, ok := delta["content"].(string); ok {
return s
}
if arr, ok := delta["content"].([]any); ok {
var parts []string
for _, b := range arr {
if bm, ok := b.(map[string]any); ok {
if t, _ := bm["text"].(string); t != "" {
parts = append(parts, t)
}
}
}
return strings.Join(parts, "")
}
return ""
}
func stopReasonOrEnd(s string) string {
if s == "" {
return "end_turn"
}
return chatStopToMessages(s)
}
// ---------------------------------------------------------------------------
// Responses → Messages
type responsesToMessages struct {
sseState
started bool
model string
usage any
nextIndex int // 下一个 content block index(顺序分配)
textIndex int // 文本块 index;-1 = 未开始
toolIdx map[string]int // function_call item_id → messages block index
openBlocks []int // 已开始未停止的 block index,按开始顺序
anyTool bool
}
func newResponsesToMessages() *responsesToMessages {
return &responsesToMessages{textIndex: -1, toolIdx: map[string]int{}}
}
func (t *responsesToMessages) line(line []byte) []byte {
isData, data, done := t.parseLine(line)
if !isData || done {
return nil
}
m := eventData(data)
evt, _ := m["type"].(string)
if resp, ok := m["response"].(map[string]any); ok {
if t.model == "" {
t.model, _ = resp["model"].(string)
}
if u, ok := resp["usage"]; ok {
t.usage = u
}
}
var out [][]byte
// message_start 只在 response.created 时发出;文本/工具块在对应事件到达时再开,
// 避免纯函数调用响应提前开出一个空文本块。
ensureStarted := func() {
if t.started {
return
}
t.started = true
rid := ""
if resp, ok := m["response"].(map[string]any); ok {
rid, _ = resp["id"].(string)
}
out = append(out, eventLine("message_start", map[string]any{
"type": "message_start",
"message": map[string]any{
"id": "msg_" + strings.TrimPrefix(rid, "resp_"), "type": "message", "role": "assistant",
"model": t.model, "content": []any{},
},
}))
}
switch evt {
case "response.created":
ensureStarted()
case "response.output_text.delta":
delta, _ := m["delta"].(string)
if delta == "" {
return nil
}
if t.textIndex < 0 {
t.textIndex = t.nextIndex
t.nextIndex++
ensureStarted()
out = append(out, eventLine("content_block_start", map[string]any{
"type": "content_block_start", "index": t.textIndex, "content_block": map[string]any{"type": "text", "text": ""},
}))
t.openBlocks = append(t.openBlocks, t.textIndex)
}
out = append(out, eventLine("content_block_delta", map[string]any{
"type": "content_block_delta", "index": t.textIndex, "delta": map[string]any{"type": "text_delta", "text": delta},
}))
case "response.output_item.added":
item, _ := m["item"].(map[string]any)
if item == nil || item["type"] != "function_call" {
return nil
}
blockIdx := t.nextIndex
t.nextIndex++
t.anyTool = true
itemID, _ := item["id"].(string)
t.toolIdx[itemID] = blockIdx
toolUseID, _ := item["call_id"].(string)
if toolUseID == "" {
toolUseID = itemID
}
name, _ := item["name"].(string)
ensureStarted()
out = append(out, eventLine("content_block_start", map[string]any{
"type": "content_block_start", "index": blockIdx, "content_block": map[string]any{
"type": "tool_use", "id": toolUseID, "name": name, "input": map[string]any{},
},
}))
t.openBlocks = append(t.openBlocks, blockIdx)
case "response.function_call_arguments.delta":
itemID, _ := m["item_id"].(string)
blockIdx, ok := t.toolIdx[itemID]
if !ok {
return nil
}
delta, _ := m["delta"].(string)
if delta == "" {
return nil
}
out = append(out, eventLine("content_block_delta", map[string]any{
"type": "content_block_delta", "index": blockIdx, "delta": map[string]any{"type": "input_json_delta", "partial_json": delta},
}))
case "response.completed":
for _, idx := range t.openBlocks {
out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": idx}))
}
stop := "end_turn"
if t.anyTool {
stop = "tool_use"
}
md := map[string]any{"type": "message_delta", "delta": map[string]any{"stop_reason": stop, "stop_sequence": nil}}
if t.usage != nil {
md["usage"] = t.usage
}
out = append(out, eventLine("message_delta", md))
out = append(out, eventLine("message_stop", map[string]any{"type": "message_stop"}))
}
return joinLines(out)
}
// ---------------------------------------------------------------------------
// Messages → Responses
type messagesToResponses struct {
sseState
model string
usage any
done bool
}
func newMessagesToResponses() *messagesToResponses { return &messagesToResponses{} }
func (t *messagesToResponses) line(line []byte) []byte {
isData, data, done := t.parseLine(line)
if !isData || done {
return nil
}
m := eventData(data)
evt, _ := m["type"].(string)
if msg, ok := m["message"].(map[string]any); ok {
if t.model == "" {
t.model, _ = msg["model"].(string)
}
if u, ok := msg["usage"]; ok {
t.usage = u
}
}
if u, ok := m["usage"]; ok {
t.usage = u
}
var out [][]byte
switch evt {
case "message_start":
id, _ := m["message"].(map[string]any)
rid := ""
if id != nil {
rid, _ = id["id"].(string)
}
out = append(out, eventLine("response.created", map[string]any{
"type": "response.created",
"response": map[string]any{
"id": "resp_" + strings.TrimPrefix(rid, "msg_"), "object": "response", "model": t.model, "status": "in_progress",
},
}))
case "content_block_delta":
delta, _ := m["delta"].(map[string]any)
text, _ := delta["text"].(string)
if text != "" {
out = append(out, eventLine("response.output_text.delta", map[string]any{
"type": "response.output_text.delta", "delta": text, "item_id": "msg_1", "output_index": 0, "content_index": 0,
}))
}
case "message_stop":
if !t.done {
t.done = true
out = append(out, eventLine("response.completed", map[string]any{
"type": "response.completed",
"response": map[string]any{
"id": "resp_stream", "object": "response", "model": t.model, "status": "completed", "usage": t.usage,
},
}))
}
}
return joinLines(out)
}
// ---------------------------------------------------------------------------
// Responses → Chat
type responsesToChat struct {
sseState
id, model string
}
func newResponsesToChat() *responsesToChat { return &responsesToChat{} }
func (t *responsesToChat) line(line []byte) []byte {
isData, data, done := t.parseLine(line)
if !isData {
return nil
}
if done {
return nil
}
m := eventData(data)
evt, _ := m["type"].(string)
if resp, ok := m["response"].(map[string]any); ok {
if t.model == "" {
t.model, _ = resp["model"].(string)
}
if t.id == "" {
t.id, _ = resp["id"].(string)
}
}
var out [][]byte
switch evt {
case "response.created":
out = append(out, dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "resp_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"role": "assistant"}, "finish_reason": nil}},
}))
case "response.output_text.delta":
delta, _ := m["delta"].(string)
if delta != "" {
out = append(out, dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "resp_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"content": delta}, "finish_reason": nil}},
}))
}
case "response.completed":
out = append(out, dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "resp_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{}, "finish_reason": "stop"}},
}))
if u, ok := m["response"].(map[string]any); ok {
if usage, ok := u["usage"]; ok {
out = append(out, dataLine(map[string]any{
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "resp_"), "object": "chat.completion.chunk", "model": t.model,
"choices": []any{}, "usage": usage,
}))
}
}
out = append(out, []byte("data: [DONE]\n\n"))
}
return joinLines(out)
}
// ---------------------------------------------------------------------------
// Chat → Responses
type chatToResponses struct {
sseState
model string
usage any
finishSeen bool
done bool
createdSent bool
}
func newChatToResponses() *chatToResponses { return &chatToResponses{} }
func (t *chatToResponses) line(line []byte) []byte {
isData, data, done := t.parseLine(line)
if !isData {
return nil
}
if done {
// 流结束兜底:finish 后 usage 未随块到达时在此补发 completed
if !t.done {
t.done = true
return eventLine("response.completed", map[string]any{
"type": "response.completed",
"response": map[string]any{
"id": "resp_stream", "object": "response", "model": t.model, "status": "completed", "usage": t.usage,
},
})
}
return nil
}
m := eventData(data)
if t.model == "" {
t.model, _ = m["model"].(string)
}
if u, ok := m["usage"]; ok {
t.usage = u
}
delta := map[string]any{}
var finish string
if choices, ok := m["choices"].([]any); ok && len(choices) > 0 {
if c0, ok := choices[0].(map[string]any); ok {
if d, ok := c0["delta"].(map[string]any); ok {
delta = d
}
finish, _ = c0["finish_reason"].(string)
}
}
if finish != "" {
t.finishSeen = true
}
var out [][]byte
// 只发一次 response.created:部分上游(如 OpenRouter 的 reasoning 模型)会在
// 每个 chunk 的 delta 里都带 role:"assistant",不加守卫会刷出数十条 created。
if !t.createdSent && delta["role"] == "assistant" {
t.createdSent = true
out = append(out, eventLine("response.created", map[string]any{
"type": "response.created",
"response": map[string]any{"id": "resp_stream", "object": "response", "model": t.model, "status": "in_progress"},
}))
}
if content, _ := delta["content"].(string); content != "" {
out = append(out, eventLine("response.output_text.delta", map[string]any{
"type": "response.output_text.delta", "delta": content, "item_id": "msg_1", "output_index": 0, "content_index": 0,
}))
}
// 上游 usage 块(choices 为空)通常晚于 finish_reason:此时再发 completed,携带 usage
if _, hasUsage := m["usage"]; hasUsage && t.finishSeen && !t.done {
t.done = true
out = append(out, eventLine("response.completed", map[string]any{
"type": "response.completed",
"response": map[string]any{
"id": "resp_stream", "object": "response", "model": t.model, "status": "completed", "usage": t.usage,
},
}))
}
return joinLines(out)
}
+99
View File
@@ -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"`
}
+131
View File
@@ -0,0 +1,131 @@
package convert
import (
"encoding/json"
)
// TokenUsage 从上游响应提取的 token 用量。
// 三种协议的字段名不同,此处统一为:input / output / cache_read / cache_creation,
// 供用量记录与计费使用。
type TokenUsage struct {
InputTokens int
OutputTokens int
CacheReadTokens int
CacheCreationTokens int
}
// has 判断是否真的拿到了非零用量(过滤掉没有 usage 字段的响应)。
func (u *TokenUsage) has() bool {
return u.InputTokens > 0 || u.OutputTokens > 0 ||
u.CacheReadTokens > 0 || u.CacheCreationTokens > 0
}
// mergeJSON 把一张 usage 对象并入累计值。proto 决定字段名(chat/responses 与 messages 不同)。
func (u *TokenUsage) mergeJSON(raw map[string]any, proto string) {
switch proto {
case ProtoChat, ProtoResponses:
in, _ := raw["prompt_tokens"].(float64)
out, _ := raw["completion_tokens"].(float64)
if in == 0 && out == 0 {
in, _ = raw["input_tokens"].(float64)
out, _ = raw["output_tokens"].(float64)
}
u.InputTokens += int(in)
u.OutputTokens += int(out)
if d, ok := raw["prompt_tokens_details"].(map[string]any); ok {
if c, _ := d["cached_tokens"].(float64); c > 0 {
u.CacheReadTokens += int(c)
}
}
if d, ok := raw["input_tokens_details"].(map[string]any); ok {
if c, _ := d["cached_tokens"].(float64); c > 0 {
u.CacheReadTokens += int(c)
}
}
case ProtoMessages:
in, _ := raw["input_tokens"].(float64)
out, _ := raw["output_tokens"].(float64)
u.InputTokens += int(in)
u.OutputTokens += int(out)
if c, _ := raw["cache_read_input_tokens"].(float64); c > 0 {
u.CacheReadTokens += int(c)
}
if c, _ := raw["cache_creation_input_tokens"].(float64); c > 0 {
u.CacheCreationTokens += int(c)
}
}
}
// ExtractUsageJSON 从完整非流式响应体中提取用量。proto 为上游协议。
// 返回 (用量, 是否有效)。
func ExtractUsageJSON(body []byte, proto string) (TokenUsage, bool) {
var top map[string]any
if err := json.Unmarshal(body, &top); err != nil {
return TokenUsage{}, false
}
var u TokenUsage
if usage, ok := top["usage"].(map[string]any); ok {
u.mergeJSON(usage, proto)
}
return u, u.has()
}
// StreamUsageAccum 流式用量累计器。逐行喂入上游 SSE 的 data 载荷,
// 按协议分别取各事件里的 usage 字段(各事件只会携带一部分字段,取最大值合并)。
type StreamUsageAccum struct {
u TokenUsage
}
// NewStreamUsageAccum 创建一个流式用量累计器。
func NewStreamUsageAccum() *StreamUsageAccum {
return &StreamUsageAccum{}
}
// Feed 喂入一行 SSE data 载荷(不含 "data:" 前缀与换行)。
func (a *StreamUsageAccum) Feed(payload []byte, proto string) {
var top map[string]any
if json.Unmarshal(payload, &top) != nil {
return
}
var t TokenUsage
switch proto {
case ProtoChat:
if usage, ok := top["usage"].(map[string]any); ok {
t.mergeJSON(usage, proto)
}
case ProtoResponses:
// response.completed 事件把用量放在 response.usage 下。
if resp, ok := top["response"].(map[string]any); ok {
if usage, ok := resp["usage"].(map[string]any); ok {
t.mergeJSON(usage, proto)
}
}
case ProtoMessages:
// message_start: {message: {usage: {input_tokens, cache_*}}}
// message_delta: {usage: {output_tokens}}
if msg, ok := top["message"].(map[string]any); ok {
if usage, ok := msg["usage"].(map[string]any); ok {
t.mergeJSON(usage, proto)
}
}
if usage, ok := top["usage"].(map[string]any); ok {
var t2 TokenUsage
t2.mergeJSON(usage, proto)
t.InputTokens = max(t.InputTokens, t2.InputTokens)
t.OutputTokens = max(t.OutputTokens, t2.OutputTokens)
t.CacheReadTokens = max(t.CacheReadTokens, t2.CacheReadTokens)
t.CacheCreationTokens = max(t.CacheCreationTokens, t2.CacheCreationTokens)
}
default:
return
}
a.u.InputTokens = max(a.u.InputTokens, t.InputTokens)
a.u.OutputTokens = max(a.u.OutputTokens, t.OutputTokens)
a.u.CacheReadTokens = max(a.u.CacheReadTokens, t.CacheReadTokens)
a.u.CacheCreationTokens = max(a.u.CacheCreationTokens, t.CacheCreationTokens)
}
// Usage 返回当前累计用量。
func (a *StreamUsageAccum) Usage() TokenUsage {
return a.u
}
@@ -0,0 +1,170 @@
package convert
import (
"encoding/json"
"testing"
)
// ---- ExtractUsageJSON: 非流式各协议 ----
func TestExtractUsageJSONChat(t *testing.T) {
body := []byte(`{
"id": "chatcmpl-1",
"object": "chat.completion",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "hi"}}],
"usage": {
"prompt_tokens": 11,
"completion_tokens": 7,
"total_tokens": 18,
"prompt_tokens_details": {"cached_tokens": 4}
}
}`)
u, ok := ExtractUsageJSON(body, ProtoChat)
if !ok {
t.Fatalf("expected ok=true")
}
if u.InputTokens != 11 || u.OutputTokens != 7 {
t.Fatalf("chat usage = %+v, want input=11 output=7", u)
}
if u.CacheReadTokens != 4 {
t.Fatalf("chat cacheRead = %d, want 4", u.CacheReadTokens)
}
}
func TestExtractUsageJSONMessages(t *testing.T) {
body := []byte(`{
"id": "msg_1",
"type": "message",
"role": "assistant",
"content": [{"type": "text", "text": "hi"}],
"usage": {
"input_tokens": 15,
"output_tokens": 8,
"cache_read_input_tokens": 3,
"cache_creation_input_tokens": 2
}
}`)
u, ok := ExtractUsageJSON(body, ProtoMessages)
if !ok {
t.Fatalf("expected ok=true")
}
if u.InputTokens != 15 || u.OutputTokens != 8 || u.CacheReadTokens != 3 || u.CacheCreationTokens != 2 {
t.Fatalf("messages usage = %+v", u)
}
}
func TestExtractUsageJSONResponses(t *testing.T) {
body := []byte(`{
"id": "resp_1",
"object": "response",
"output": [],
"usage": {
"input_tokens": 13,
"output_tokens": 9,
"input_tokens_details": {"cached_tokens": 5}
}
}`)
u, ok := ExtractUsageJSON(body, ProtoResponses)
if !ok {
t.Fatalf("expected ok=true")
}
if u.InputTokens != 13 || u.OutputTokens != 9 || u.CacheReadTokens != 5 {
t.Fatalf("responses usage = %+v", u)
}
}
func TestExtractUsageJSONInvalidAndMissing(t *testing.T) {
if _, ok := ExtractUsageJSON([]byte("not json"), ProtoChat); ok {
t.Fatalf("invalid json should not report ok")
}
if _, ok := ExtractUsageJSON([]byte(`{"id": "x"}`), ProtoChat); ok {
t.Fatalf("missing usage should not report ok")
}
// 空对象 usage:全 0 视为无效
if _, ok := ExtractUsageJSON([]byte(`{"usage": {}}`), ProtoChat); ok {
t.Fatalf("empty usage should not report ok")
}
}
// ---- StreamUsageAccum: 流式各协议 ----
func feedLines(t *testing.T, proto string, lines ...string) TokenUsage {
t.Helper()
acc := NewStreamUsageAccum()
for _, ln := range lines {
acc.Feed([]byte(ln), proto)
}
return acc.Usage()
}
func TestStreamUsageChatFinalChunk(t *testing.T) {
// 前面的 chunk 不带 usage;最后一个 chunk 带完整 usage
u := feedLines(t, ProtoChat,
`{"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{"content":"he"}}]}`,
`{"id":"c1","object":"chat.completion.chunk","choices":[{"delta":{"content":"llo"}}]}`,
`{"id":"c1","object":"chat.completion.chunk","choices":[],"usage":{"prompt_tokens":11,"completion_tokens":7,"prompt_tokens_details":{"cached_tokens":4}}}`,
)
if u.InputTokens != 11 || u.OutputTokens != 7 || u.CacheReadTokens != 4 {
t.Fatalf("chat stream usage = %+v", u)
}
}
func TestStreamUsageMessagesStartAndDelta(t *testing.T) {
// message_start 带 input/cache,message_delta 带 output;逐字段取 max 合并
u := feedLines(t, ProtoMessages,
`{"type":"message_start","message":{"id":"msg_1","usage":{"input_tokens":15,"cache_read_input_tokens":3,"cache_creation_input_tokens":2}}}`,
`{"type":"content_block_delta","delta":{"type":"text_delta","text":"hi"}}`,
`{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":8}}`,
)
if u.InputTokens != 15 || u.OutputTokens != 8 || u.CacheReadTokens != 3 || u.CacheCreationTokens != 2 {
t.Fatalf("messages stream usage = %+v", u)
}
}
func TestStreamUsageResponsesCompleted(t *testing.T) {
// response.completed 事件的用量嵌在 response.usage 下
u := feedLines(t, ProtoResponses,
`{"type":"response.output_text.delta","delta":"hi"}`,
`{"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":13,"output_tokens":9,"input_tokens_details":{"cached_tokens":5}}}}`,
)
if u.InputTokens != 13 || u.OutputTokens != 9 || u.CacheReadTokens != 5 {
t.Fatalf("responses stream usage = %+v", u)
}
}
func TestStreamUsageIgnoresNonDataPayloads(t *testing.T) {
// [DONE]、垃圾行、空对象都不应产生用量
u := feedLines(t, ProtoChat, `[DONE]`, `{`, ``, `{"choices":[]}`)
if u.has() {
t.Fatalf("expected zero usage, got %+v", u)
}
}
func TestStreamUsageFeedKeepsMaxAcrossEvents(t *testing.T) {
// 同一字段在多个事件出现时取较大值(防乱序/重复)
u := feedLines(t, ProtoMessages,
`{"type":"message_start","message":{"usage":{"input_tokens":15}}}`,
`{"type":"message_delta","usage":{"output_tokens":5}}`,
`{"type":"message_delta","usage":{"output_tokens":8}}`,
)
if u.InputTokens != 15 || u.OutputTokens != 8 {
t.Fatalf("max-merge usage = %+v", u)
}
}
// ---- usage JSON 结构合法性(防止手写 struct 漂移)----
func TestUsageJSONRoundTrip(t *testing.T) {
u := TokenUsage{InputTokens: 10, OutputTokens: 5, CacheReadTokens: 2, CacheCreationTokens: 1}
b, err := json.Marshal(u)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var back TokenUsage
if err := json.Unmarshal(b, &back); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if back != u {
t.Fatalf("round trip = %+v, want %+v", back, u)
}
}
+29
View File
@@ -0,0 +1,29 @@
package proxy
import (
"opencatd-open/internal/proxy/convert"
)
// cacheWriteInputMultiplier 缓存写(cache creation)相对输入价的倍数。
// Anthropic 官方口径:缓存写按基础输入价的 1.25 倍计费(5m TTL);OpenAI 系无缓存写概念。
const cacheWriteInputMultiplier = 1.25
// ComputeCost 按上游协议的 token 语义计算一次请求的费用(USD)。
// 价格均为每百万 token 的 USD 单价。tok 的 token 语义由解析它的上游协议决定:
// - chat / responses(OpenAI 系):prompt_tokens 包含缓存读,
// 非缓存输入 = input − cacheRead;该协议没有缓存写,cacheCreation 恒为 0。
// - messages(Anthropic):input_tokens 不含缓存读/写(三个字段相互独立),
// 非缓存输入 = input 原值,不得再扣减;缓存写按输入价 ×1.25。
func ComputeCost(upstreamProto string, input, output, cacheRead, cacheCreation int, inputPrice, outputPrice, cacheReadPrice float64) float64 {
uncached := input
if upstreamProto != convert.ProtoMessages {
uncached -= cacheRead
if uncached < 0 {
uncached = 0
}
}
return (float64(uncached)*inputPrice +
float64(cacheRead)*cacheReadPrice +
float64(cacheCreation)*inputPrice*cacheWriteInputMultiplier +
float64(output)*outputPrice) / 1e6
}
+75
View File
@@ -0,0 +1,75 @@
package proxy
import "testing"
func TestComputeCost(t *testing.T) {
// 标准三价:输入 0.5 / 输出 1.5 / 缓存读 0.05($/M)
const (
inPrice = 0.5
outPrice = 1.5
cchPrice = 0.05
)
tests := []struct {
name string
upstreamProto string
input int
output int
cacheRead int
cacheCreation int
inP float64
outP float64
cchP float64
want float64
}{
{
// OpenAI:prompt 含缓存读,需扣减:(11000−10000)×0.5 + 10000×0.05 + 500×1.5
name: "openai prompt includes cache read",
upstreamProto: "chat",
input: 11000, output: 500, cacheRead: 10000,
inP: inPrice, outP: outPrice, cchP: cchPrice,
want: (1000*inPrice + 10000*cchPrice + 500*outPrice) / 1e6,
},
{
// Anthropic:input 不含缓存;缓存写按输入价 ×1.25
name: "anthropic cache write at 1.25x input price",
upstreamProto: "messages",
input: 1000, output: 500, cacheRead: 10000, cacheCreation: 2000,
inP: inPrice, outP: outPrice, cchP: cchPrice,
want: (1000*inPrice + 10000*cchPrice + 2000*inPrice*1.25 + 500*outPrice) / 1e6,
},
{
// Anthropic 口径不得扣减缓存读(否则这里非缓存输入会算成负数)
name: "anthropic does not subtract cache read",
upstreamProto: "messages",
input: 100, output: 0, cacheRead: 1000,
inP: inPrice, outP: outPrice, cchP: cchPrice,
want: (100*inPrice + 1000*cchPrice) / 1e6,
},
{
// OpenAI 异常数据:cached > prompt 时非缓存输入钳制为 0,不出现负费用
name: "openai clamps negative uncached input",
upstreamProto: "responses",
input: 100, output: 0, cacheRead: 5000,
inP: inPrice, outP: outPrice, cchP: cchPrice,
want: (5000 * cchPrice) / 1e6,
},
{
// 未配置价格时费用为 0
name: "no prices no cost",
upstreamProto: "messages",
input: 1000, output: 1000, cacheRead: 1000, cacheCreation: 1000,
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ComputeCost(tt.upstreamProto, tt.input, tt.output, tt.cacheRead, tt.cacheCreation, tt.inP, tt.outP, tt.cchP)
if diff := got - tt.want; diff > 1e-12 || diff < -1e-12 {
t.Fatalf("ComputeCost(%q, in=%d, out=%d, cr=%d, cw=%d) = %v, want %v",
tt.upstreamProto, tt.input, tt.output, tt.cacheRead, tt.cacheCreation, got, tt.want)
}
})
}
}
+666
View File
@@ -0,0 +1,666 @@
package proxy
import (
"bufio"
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"opencatd-open/internal/channel"
"opencatd-open/internal/dao"
"opencatd-open/internal/proxy/convert"
"opencatd-open/internal/store"
"opencatd-open/internal/usage"
"opencatd-open/pkg/config"
"os"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type Gateway struct {
ctx context.Context
cfg *config.Config
db *gorm.DB
wg *sync.WaitGroup
httpClient *http.Client
userDAO *dao.UserDAO
apiKeyDAO *dao.ApiKeyDAO
usageDAO *dao.UsageDAO
dailyDAO *dao.DailyUsageDAO
modelDAO *dao.ModelDAO
channelSvc *channel.Service
usageRec *usage.Recorder
// 原始请求/响应记录开关(系统配置 log_raw_requests,带 TTL 缓存避免每次查库)。
rawLogMu sync.Mutex
rawLogVal bool
rawLogSet time.Time
}
func NewGateway(ctx context.Context, cfg *config.Config, db *gorm.DB, wg *sync.WaitGroup, userDAO *dao.UserDAO, apiKeyDAO *dao.ApiKeyDAO, usageDAO *dao.UsageDAO, dailyDAO *dao.DailyUsageDAO) *Gateway {
client := &http.Client{Timeout: 120 * time.Second}
if os.Getenv("LOCAL_PROXY") != "" {
proxyUrl, err := url.Parse(os.Getenv("LOCAL_PROXY"))
if err == nil {
tr := &http.Transport{
Proxy: http.ProxyURL(proxyUrl),
}
client.Transport = tr
}
}
return &Gateway{
ctx: ctx,
cfg: cfg,
db: db,
wg: wg,
httpClient: client,
userDAO: userDAO,
apiKeyDAO: apiKeyDAO,
usageDAO: usageDAO,
dailyDAO: dailyDAO,
modelDAO: dao.NewModelDAO(db),
channelSvc: nil,
}
}
func (g *Gateway) SetChannelService(svc *channel.Service) {
g.channelSvc = svc
}
// SetUsageRecorder 注入异步用量记录器;nil 时网关跳过用量上报。
func (g *Gateway) SetUsageRecorder(r *usage.Recorder) {
g.usageRec = r
}
// rawLogEnabled 读取系统配置 log_raw_requests(10s TTL 缓存),决定是否记录原始请求/响应。
func (g *Gateway) rawLogEnabled() bool {
g.rawLogMu.Lock()
defer g.rawLogMu.Unlock()
if time.Since(g.rawLogSet) < 10*time.Second {
return g.rawLogVal
}
var sc store.SystemConfig
g.rawLogVal = false
if err := g.db.Where("key = ?", "log_raw_requests").First(&sc).Error; err == nil {
g.rawLogVal = strings.TrimSpace(sc.Value) == "true"
}
g.rawLogSet = time.Now()
return g.rawLogVal
}
// generateRequestID 生成请求级唯一 ID,用于用量明细关联与排障。
func generateRequestID() string {
b := make([]byte, 12)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("req-%d", time.Now().UnixNano())
}
return "req-" + hex.EncodeToString(b)
}
// Request represents a parsed incoming request
type Request struct {
Model string
Stream bool
Protocol string // "chat", "messages", "responses"
Body []byte
APIKey *store.APIKey
UserID uint64
KeyID uint64
RequestID string
CaptureRaw bool // 原始请求/响应记录(管理员 + 系统开关开启)
rawBuf *strings.Builder // 上游原始响应累积器(仅 CaptureRaw 时非 nil)
}
// ParseRequest parses the incoming request and extracts key fields
func (g *Gateway) ParseRequest(c *gin.Context, protocol string) (*Request, error) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
return nil, fmt.Errorf("failed to read body: %w", err)
}
apiKey, _ := c.Get("api_key")
userID, _ := c.Get("user_id")
userRole, _ := c.Get("user_role")
req := &Request{
Protocol: protocol,
Body: body,
UserID: userID.(uint64),
RequestID: c.GetHeader("X-Request-Id"),
}
if req.RequestID == "" {
req.RequestID = generateRequestID()
}
if ak, ok := apiKey.(*store.APIKey); ok {
req.APIKey = ak
}
// 原始请求/响应记录:仅管理员 且 系统开关 log_raw_requests 开启。
if role, _ := userRole.(string); role == store.RoleAdmin && g.rawLogEnabled() {
req.CaptureRaw = true
}
// Parse model and stream based on protocol
switch protocol {
case "chat":
var parsed convert.ChatCompletionRequest
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, fmt.Errorf("invalid chat request: %w", err)
}
req.Model = parsed.Model
req.Stream = parsed.Stream
case "messages":
var parsed convert.MessagesRequest
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, fmt.Errorf("invalid messages request: %w", err)
}
req.Model = parsed.Model
req.Stream = parsed.Stream
case "responses":
var parsed convert.ResponsesRequest
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, fmt.Errorf("invalid responses request: %w", err)
}
req.Model = parsed.Model
req.Stream = parsed.Stream
}
return req, nil
}
// Dispatch routes the request to the appropriate upstream.
// 遍历候选渠道(绑定优先,全局回退;按优先级/权重排序),可重试性失败自动故障转移。
func (g *Gateway) Dispatch(c *gin.Context, req *Request) {
if g.channelSvc == nil {
g.writeError(c, http.StatusBadGateway, "channel service not available")
return
}
// 原始请求/响应捕获:仅管理员 + 系统开关开启(req.CaptureRaw 已在 ParseRequest 判定)。
// 客户端原始请求体即 req.Body;上游原始响应由 stream/bufferResponse 累积进 rawBuf。
if req.CaptureRaw {
req.rawBuf = &strings.Builder{}
}
cands := g.channelSvc.Candidates(req.Model)
// 内存健康过滤:连续失败进入 cooldown 的渠道不再尝试(渠道级健康自愈靠冷却过期)。
cands = g.channelSvc.FilterHealthy(cands)
if len(cands) == 0 {
g.writeError(c, http.StatusServiceUnavailable, "no enabled channels for model: "+req.Model)
g.recordUsage(req, nil, nil, usage.Event{
IsError: true, ErrorCode: "no_channel",
}, convert.TokenUsage{}, "")
return
}
var lastCh *store.Channel
_ = lastCh // 保留变量名便于断点排查;失败渠道已在循环内各自 RecordFailure
lastErrStatus := http.StatusBadGateway
lastErrBody := "all upstream channels failed"
for i := range cands {
cand := &cands[i]
ch := cand.Channel
lastCh = ch
apiKey, err := g.channelSvc.GetAPIKey(ch)
if err != nil {
lastErrStatus, lastErrBody = http.StatusBadGateway, "failed to decrypt API key"
continue
}
// Determine target format: channel declares support for the client protocol
// then passthrough, otherwise convert to its first supported protocol
// (chat > messages > responses).
targetFormat := g.conversionTarget(ch, req.Protocol)
if targetFormat == "" {
continue // 渠道不支持该协议,换下一个
}
// Build upstream URL
upstreamURL := ch.UpstreamURL(targetFormat, g.getUpstreamPath(targetFormat))
// Convert request if needed
var requestBody []byte
if targetFormat != req.Protocol {
var err error
requestBody, err = convert.ConvertRequest(req.Body, req.Protocol, targetFormat)
if err != nil {
lastErrStatus, lastErrBody = http.StatusBadRequest, "conversion failed: "+err.Error()
continue
}
} else {
requestBody = req.Body
}
// 绑定了 upstream_model 时把请求体里的 model 重写为上游模型名(别名映射)。
if cand.Binding != nil && cand.Binding.UpstreamModel != "" &&
cand.Binding.UpstreamModel != req.Model {
requestBody = rewriteModel(requestBody, cand.Binding.UpstreamModel)
}
// Create upstream request
httpReq, err := http.NewRequestWithContext(g.ctx, "POST", upstreamURL, bytes.NewReader(requestBody))
if err != nil {
lastErrStatus, lastErrBody = http.StatusBadGateway, "failed to create request"
continue
}
g.setHeaders(httpReq, ch, apiKey, targetFormat)
// Execute request
start := time.Now()
resp, err := g.httpClient.Do(httpReq)
if err != nil {
g.channelSvc.RecordFailure(ch.ID)
lastErrStatus = http.StatusBadGateway
lastErrBody = fmt.Sprintf("upstream error: %v", err)
g.recordUsage(req, cand, ch, usage.Event{
IsError: true,
ErrorCode: "upstream_error",
LatencyMS: int(time.Since(start).Milliseconds()),
}, convert.TokenUsage{}, targetFormat)
continue // 可重试:换下一个渠道
}
// Handle upstream error responses
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
log.Printf("Upstream error: status=%d body=%s", resp.StatusCode, string(body))
if req.rawBuf != nil {
req.rawBuf.Write(body)
}
g.recordUsage(req, cand, ch, usage.Event{
IsError: true,
ErrorCode: fmt.Sprintf("upstream_%d", resp.StatusCode),
LatencyMS: int(time.Since(start).Milliseconds()),
}, convert.TokenUsage{}, targetFormat)
// 429/5xx 可换渠道重试;4xx 直接透传
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
lastErrStatus, lastErrBody = resp.StatusCode, string(body)
continue
}
c.Data(resp.StatusCode, "application/json", body)
return
}
g.channelSvc.RecordSuccess(ch.ID)
// Stream or buffer response;tok 从上游响应(SSE usage 块或非流式 JSON)提取。
// 上游可能返回 HTTP 200 但 body/SSE 内带 error(OpenRouter 超时等),
// 此时按失败记账(errCode 非空),非流式错误体以 502 返回给客户端。
var tok convert.TokenUsage
var errCode string
if req.Stream {
tok, errCode = g.streamResponse(c, resp, req.Protocol, targetFormat, req.rawBuf)
} else {
tok, errCode, _ = g.bufferResponse(c, resp, req.Protocol, targetFormat, req.rawBuf)
}
resp.Body.Close()
if errCode != "" {
// 记账为失败(错误码),不产生费用;响应内容已由 buffer/stream 写出
g.recordUsage(req, cand, ch, usage.Event{
IsError: true,
ErrorCode: errCode,
LatencyMS: int(time.Since(start).Milliseconds()),
}, tok, targetFormat)
return
}
// 成功记录:用量 + 定价计费。
g.recordUsage(req, cand, ch, usage.Event{
LatencyMS: int(time.Since(start).Milliseconds()),
}, tok, targetFormat)
return
}
// 全部候选失败(每个候选失败时已各自 RecordFailure,不再重复计数)
g.writeError(c, lastErrStatus, lastErrBody)
}
// rewriteModel 把 JSON 请求体顶层的 model 字段替换为 upstreamModel。
func rewriteModel(body []byte, upstreamModel string) []byte {
var m map[string]json.RawMessage
if json.Unmarshal(body, &m) != nil {
return body
}
if _, ok := m["model"]; !ok {
return body
}
m["model"], _ = json.Marshal(upstreamModel)
out, err := json.Marshal(m)
if err != nil {
return body
}
return out
}
// recordUsage 汇总一次请求的用量事件并异步落库。tok 为从上游响应提取的用量,
// 其 token 语义由 upstreamProto(渠道实际使用的上游协议)决定。
// cand/ch 可为 nil(无可用渠道的失败场景)。
func (g *Gateway) recordUsage(req *Request, cand *channel.Candidate, ch *store.Channel, ev usage.Event, tok convert.TokenUsage, upstreamProto string) {
if g.usageRec == nil {
return
}
ev.UserID = req.UserID
ev.ModelName = req.Model
ev.Protocol = req.Protocol
ev.RequestID = req.RequestID
if req.APIKey != nil {
ev.KeyID = req.APIKey.ID
}
if ch != nil {
ev.ChannelID = ch.ID
}
if cand != nil && cand.Binding != nil {
ev.ModelID = cand.Binding.ModelID
}
ev.PromptTokens = tok.InputTokens
ev.CompletionTokens = tok.OutputTokens
ev.CacheReadTokens = tok.CacheReadTokens
ev.CacheCreationTokens = tok.CacheCreationTokens
// 原始请求/响应(仅管理员+开关开启时捕获)。
if req.CaptureRaw {
ev.RawRequest = string(req.Body)
if req.rawBuf != nil {
ev.RawResponse = req.rawBuf.String()
}
}
// 定价与成本(价格按每百万 token 的 USD 单价)。
// 成本口径按上游协议区分(详见 ComputeCost):OpenAI 系 prompt 含缓存读需扣减;
// Anthropic 的 input_tokens 不含缓存,缓存写按输入价 ×1.25。
if ev.ModelID != 0 {
if m, err := g.modelDAO.GetByID(ev.ModelID); err == nil {
ev.InputPrice = m.InputPrice
ev.OutputPrice = m.OutputPrice
ev.CacheReadPrice = m.CacheReadPrice
}
}
if !ev.IsError {
ev.Cost = ComputeCost(upstreamProto, tok.InputTokens, tok.OutputTokens, tok.CacheReadTokens, tok.CacheCreationTokens,
ev.InputPrice, ev.OutputPrice, ev.CacheReadPrice)
}
g.usageRec.Record(ev)
}
// conversionTarget 决定客户端协议在渠道上的处理方式:
// 渠道声明支持该协议则直通;否则转为其首选支持协议(chat > messages > responses)。
func (g *Gateway) conversionTarget(ch *store.Channel, clientProto string) string {
formats := ch.FormatsEffective()
for _, f := range formats {
if f == clientProto {
return clientProto
}
}
for _, p := range []string{convert.ProtoChat, convert.ProtoMessages, convert.ProtoResponses} {
for _, f := range formats {
if f == p {
return p
}
}
}
return ""
}
func (g *Gateway) getUpstreamPath(protocol string) string {
switch protocol {
case "chat":
return "/chat/completions"
case "messages":
return "/messages"
case "responses":
return "/responses"
default:
return "/chat/completions"
}
}
func (g *Gateway) setHeaders(req *http.Request, ch *store.Channel, apiKey string, format string) {
req.Header.Set("Content-Type", "application/json")
switch ch.Provider {
case store.ChannelProviderOpenAI, store.ChannelProviderCompatible:
req.Header.Set("Authorization", "Bearer "+apiKey)
case store.ChannelProviderAnthropic:
req.Header.Set("x-api-key", apiKey)
req.Header.Set("anthropic-version", "2023-06-01")
}
}
// streamResponse 流式响应:按 \n\n 分块零缓冲转发;跨协议时逐行转换。
// 返回从上游 SSE usage 块累计的 token 用量(按上游协议解析)。
// capture 非 nil 时把上游原始行累积进去(原始响应记录)。
// 上游部分实现(如 OpenRouter)在超时时返回 HTTP 200 但 SSE data 内带
// error 字段;检测到则返回错误码,供 Dispatch 按失败记账。
func (g *Gateway) streamResponse(c *gin.Context, resp *http.Response, clientProto, upstreamProto string, capture *strings.Builder) (convert.TokenUsage, string) {
w := c.Writer
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Status(http.StatusOK)
flusher, _ := w.(http.Flusher)
// 跨协议时按行转换;同协议直通(lineConv 为 nil)。
var lineConv func([]byte) []byte
if upstreamProto != clientProto {
lineConv = convert.NewStreamTransformer(upstreamProto, clientProto)
}
// 上游原始行按 \n\n 分块,避免把 data 行内的转义换行当成事件边界。
// 同时喂入用量累计器(usage 块可能出现在任一事件)。
r := bufio.NewReaderSize(resp.Body, 32*1024)
accum := convert.NewStreamUsageAccum()
errCode := ""
for {
buf := []byte{}
for {
line, err := r.ReadSlice('\n')
if err == bufio.ErrBufferFull {
buf = append(buf, line...)
continue
}
buf = append(buf, line...)
if err == io.EOF {
if len(buf) == 0 {
return accum.Usage(), errCode
}
if !bytes.HasSuffix(buf, []byte("\n")) {
buf = append(buf, '\n')
}
} else if err != nil {
log.Printf("stream read error: %v", err)
return accum.Usage(), errCode
}
if len(buf) >= 2 && bytes.HasSuffix(buf, []byte("\n\n")) {
break
}
}
// 原始响应捕获(仅管理员+开关开启时启用)。
if capture != nil {
capture.Write(buf)
}
// 先解析用量(data: {...} 行),再决定转发内容。
for _, data := range sseDataPayloads(buf) {
accum.Feed(data, upstreamProto)
if errCode == "" && streamChunkHasError(data) {
errCode = "upstream_stream_error"
}
}
out := buf
if lineConv != nil {
out = lineConv(buf)
}
if len(out) == 0 {
continue
}
if _, err := w.Write(out); err != nil {
return accum.Usage(), errCode // 客户端已断开
}
if flusher != nil {
flusher.Flush()
}
// 流结束标记:chat/messages 上游以 data: [DONE] 收尾。部分上游(keep-alive)
// 发完 [DONE] 后不关连接,继续读会阻塞到超时;据此主动收尾。
// responses 协议没有 [DONE],以 response.completed 事件收尾。
if streamTerminated(buf, upstreamProto) {
return accum.Usage(), errCode
}
}
}
// streamChunkHasError 判断一块 SSE data 载荷是否带 error 字段(OpenRouter 超时等)。
func streamChunkHasError(data []byte) bool {
var m map[string]any
if json.Unmarshal(data, &m) != nil {
return false
}
if _, ok := m["error"]; ok {
return true
}
// responses 协议错误事件可能形如 {"type":"error",...}
return m["type"] == "error"
}
// streamTerminated 判断一块 SSE 是否为上游流的结束事件。
func streamTerminated(chunk []byte, proto string) bool {
switch proto {
case convert.ProtoChat:
// chat 上游以 data: [DONE] 收尾;keep-alive 上游发完不关连接。
return bytes.Contains(chunk, []byte("data: [DONE]"))
case convert.ProtoMessages:
// messages 上游以 message_stop 事件结束(无 [DONE])。
return bytes.Contains(chunk, []byte(`"type":"message_stop"`)) ||
bytes.Contains(chunk, []byte(`"type": "message_stop"`)) ||
bytes.Contains(chunk, []byte("data: [DONE]"))
case convert.ProtoResponses:
return bytes.Contains(chunk, []byte(`"response.completed"`)) ||
bytes.Contains(chunk, []byte(`"type":"response.completed"`))
}
return false
}
// sseDataPayloads 从一块 SSE(一个完整事件,\n\n 结尾)中取出所有 data 行的原始载荷。
func sseDataPayloads(chunk []byte) [][]byte {
var out [][]byte
for _, line := range bytes.Split(chunk, []byte("\n")) {
line = bytes.TrimSuffix(line, []byte("\r"))
if !bytes.HasPrefix(line, []byte("data:")) {
continue
}
payload := bytes.TrimPrefix(line, []byte("data:"))
payload = bytes.TrimPrefix(payload, []byte(" "))
if len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) {
continue
}
out = append(out, payload)
}
return out
}
// bufferResponse 非流式响应:整体读取、可选转换后写回。
// 返回 (用量, 错误码, 是否错误)。部分上游(如 OpenRouter)在超时时返回
// HTTP 200 但 JSON 内含 error 字段,需要识别并让调用方按失败处理。
func (g *Gateway) bufferResponse(c *gin.Context, resp *http.Response, clientProto, upstreamProto string, capture *strings.Builder) (convert.TokenUsage, string, bool) {
body, err := io.ReadAll(resp.Body)
if err != nil {
g.writeError(c, http.StatusBadGateway, "failed to read response")
return convert.TokenUsage{}, "", false
}
// 原始响应捕获(仅管理员+开关开启时启用)。
if capture != nil {
capture.Write(body)
}
// 用量从上游原始响应体提取(先于转换,转换会改字段名)。
tok, _ := convert.ExtractUsageJSON(body, upstreamProto)
// HTTP 200 但带 error 字段(OpenRouter 超时 504 等):识别并转失败。
errCode, isErr := bodyHasError(body)
out := body
if upstreamProto != clientProto {
if converted, cerr := convert.ConvertResponse(body, upstreamProto, clientProto); cerr == nil {
out = converted
} else {
// 转换失败时至少剥掉非 JSON 前缀,让客户端能解析出正文
out = convert.CleanJSON(body)
}
} else {
// 直通:部分上游(如 OpenRouter)的 non-stream 响应在 JSON 前夹带空白/注释
out = convert.CleanJSON(body)
}
// 上游错误体:用 502 返回,让客户端感知失败(不伪装成 200)。
if isErr {
c.Data(http.StatusBadGateway, "application/json", out)
return tok, errCode, true
}
c.Data(resp.StatusCode, "application/json", out)
return tok, errCode, false
}
// bodyHasError 判断 JSON 响应体是否带 error 字段(openai 风格 {"error":{...}} 或
// anthropic 风格 {"type":"error",...})。返回 (错误码, 是否错误)。找不到 JSON 返回 ("", false)。
func bodyHasError(body []byte) (string, bool) {
var m map[string]any
if json.Unmarshal(bytes.TrimSpace(body), &m) != nil {
return "", false
}
if _, ok := m["error"]; ok {
return "upstream_error", true
}
if m["type"] == "error" {
return "upstream_error", true
}
return "", false
}
func (g *Gateway) writeError(c *gin.Context, status int, message string) {
protocol := c.GetHeader("X-Protocol")
if protocol == "" {
protocol = "chat"
}
switch {
case strings.Contains(c.GetHeader("Accept"), "text/event-stream"):
c.Header("Content-Type", "text/event-stream")
c.Status(status)
fmt.Fprintf(c.Writer, "data: {\"error\":{\"message\":\"%s\"}}\n\n", message)
fmt.Fprintf(c.Writer, "data: [DONE]\n\n")
case protocol == "messages":
c.JSON(status, gin.H{
"type": "error",
"error": gin.H{
"type": "api_error",
"message": message,
},
})
default:
c.JSON(status, gin.H{
"error": gin.H{
"message": message,
"type": "invalid_request_error",
},
})
}
}
+69
View File
@@ -0,0 +1,69 @@
package proxy
import (
"net/http"
"opencatd-open/internal/dao"
"time"
"github.com/gin-gonic/gin"
)
// HandleChat handles POST /v1/chat/completions
func (g *Gateway) HandleChat(c *gin.Context) {
req, err := g.ParseRequest(c, "chat")
if err != nil {
g.writeError(c, http.StatusBadRequest, err.Error())
return
}
g.Dispatch(c, req)
}
// HandleMessages handles POST /v1/messages
func (g *Gateway) HandleMessages(c *gin.Context) {
req, err := g.ParseRequest(c, "messages")
if err != nil {
g.writeError(c, http.StatusBadRequest, err.Error())
return
}
g.Dispatch(c, req)
}
// HandleResponses handles POST /v1/responses
func (g *Gateway) HandleResponses(c *gin.Context) {
req, err := g.ParseRequest(c, "responses")
if err != nil {
g.writeError(c, http.StatusBadRequest, err.Error())
return
}
g.Dispatch(c, req)
}
// HandleModels handles GET /v1/models
func (g *Gateway) HandleModels(c *gin.Context) {
modelDAO := dao.NewModelDAO(g.db)
models, _, err := modelDAO.List(1000, 0)
if err != nil {
g.writeError(c, http.StatusInternalServerError, "failed to list models")
return
}
now := time.Now().Unix()
data := make([]gin.H, 0, len(models))
for _, m := range models {
if !m.Enabled {
continue
}
data = append(data, gin.H{
"id": m.Name,
"object": "model",
"created": now,
"owned_by": "opencatd-open",
})
}
c.JSON(http.StatusOK, gin.H{
"object": "list",
"data": data,
})
}
+38
View File
@@ -0,0 +1,38 @@
package service
import (
"context"
"opencatd-open/internal/dao"
"opencatd-open/internal/store"
"gorm.io/gorm"
)
type ApiKeyServiceImpl struct {
db *gorm.DB
apiKeyRepo *dao.ApiKeyDAO
}
func NewApiKeyService(db *gorm.DB, apiKeyDao *dao.ApiKeyDAO) *ApiKeyServiceImpl {
return &ApiKeyServiceImpl{db: db, apiKeyRepo: apiKeyDao}
}
func (s *ApiKeyServiceImpl) CreateApiKey(ctx context.Context, apikey *store.APIKey) error {
return s.apiKeyRepo.Create(apikey)
}
func (s *ApiKeyServiceImpl) GetApiKey(ctx context.Context, id uint64) (*store.APIKey, error) {
return s.apiKeyRepo.GetByID(id)
}
func (s *ApiKeyServiceImpl) ListApiKey(ctx context.Context, userID uint64, limit, offset int) ([]*store.APIKey, int64, error) {
return s.apiKeyRepo.ListByUserID(userID, limit, offset)
}
func (s *ApiKeyServiceImpl) UpdateApiKey(ctx context.Context, apikey *store.APIKey) error {
return s.apiKeyRepo.Update(apikey)
}
func (s *ApiKeyServiceImpl) DeleteApiKey(ctx context.Context, id uint64) error {
return s.apiKeyRepo.Delete(id)
}
+90
View File
@@ -0,0 +1,90 @@
package service
import (
"context"
"fmt"
"opencatd-open/internal/channel"
"opencatd-open/internal/dao"
"opencatd-open/internal/store"
"opencatd-open/internal/pkg/crypto"
)
type ChannelServiceImpl struct {
channelDAO *dao.ChannelDAO
channelSvc *channel.Service
}
func NewChannelService(channelDAO *dao.ChannelDAO, channelSvc *channel.Service) *ChannelServiceImpl {
return &ChannelServiceImpl{
channelDAO: channelDAO,
channelSvc: channelSvc,
}
}
func (s *ChannelServiceImpl) Create(ctx context.Context, ch *store.Channel) error {
return s.channelDAO.Create(ch)
}
func (s *ChannelServiceImpl) GetByID(ctx context.Context, id uint64) (*store.Channel, error) {
return s.channelDAO.GetByID(id)
}
func (s *ChannelServiceImpl) List(ctx context.Context, limit, offset int) ([]*store.Channel, int64, error) {
return s.channelDAO.List(limit, offset)
}
func (s *ChannelServiceImpl) ListEnabled(ctx context.Context) ([]*store.Channel, error) {
return s.channelDAO.ListEnabled()
}
func (s *ChannelServiceImpl) Update(ctx context.Context, ch *store.Channel) error {
return s.channelDAO.Update(ch)
}
func (s *ChannelServiceImpl) Delete(ctx context.Context, id uint64) error {
return s.channelDAO.Delete(id)
}
// GetAPIKey decrypts the channel's API key
func (s *ChannelServiceImpl) GetAPIKey(ctx context.Context, channelID uint64) (string, error) {
ch, err := s.channelDAO.GetByID(channelID)
if err != nil {
return "", err
}
return crypto.Decrypt(ch.APIKeyEnc)
}
// SelectForModel selects the best channel for a model
func (s *ChannelServiceImpl) SelectForModel(ctx context.Context, modelName string) (*store.Channel, error) {
cands := s.channelSvc.Candidates(modelName)
picked := s.channelSvc.Pick(cands)
if picked == nil {
return nil, fmt.Errorf("no enabled channels for model: %s", modelName)
}
return picked.Channel, nil
}
// BindModels binds models to a channel
func (s *ChannelServiceImpl) BindModels(ctx context.Context, channelID uint64, bindings []store.ChannelModelBinding) error {
return s.channelDAO.BindModels(channelID, bindings)
}
// GetChannelModels returns models bound to a channel
func (s *ChannelServiceImpl) GetChannelModels(ctx context.Context, channelID uint64) ([]store.ChannelModelBinding, error) {
return s.channelDAO.GetChannelModels(channelID)
}
// GetModelChannels returns channels for a model
func (s *ChannelServiceImpl) GetModelChannels(ctx context.Context, modelName string) ([]*store.Channel, error) {
return s.channelDAO.GetEnabledChannelsByModel(modelName)
}
// RecordSuccess records a successful request
func (s *ChannelServiceImpl) RecordSuccess(channelID uint64) {
s.channelSvc.RecordSuccess(channelID)
}
// RecordFailure records a failed request
func (s *ChannelServiceImpl) RecordFailure(channelID uint64) {
s.channelSvc.RecordFailure(channelID)
}
+72
View File
@@ -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)
}
+29
View File
@@ -0,0 +1,29 @@
package service
import (
"context"
"opencatd-open/internal/dao"
"opencatd-open/internal/store"
"gorm.io/gorm"
)
type TokenServiceImpl struct {
db *gorm.DB
tokenRepo *dao.TokenDAO
}
func NewTokenService(db *gorm.DB, tokenRepo *dao.TokenDAO) *TokenServiceImpl {
return &TokenServiceImpl{
db: db,
tokenRepo: tokenRepo,
}
}
func (t *TokenServiceImpl) GetByKey(ctx context.Context, key string) (*store.User, error) {
return t.tokenRepo.GetByKey(key)
}
func (t *TokenServiceImpl) GetByID(ctx context.Context, id uint64) (*store.User, error) {
return t.tokenRepo.GetByID(id)
}
+48
View File
@@ -0,0 +1,48 @@
package service
import (
"context"
"opencatd-open/internal/dao"
"opencatd-open/internal/store"
"opencatd-open/pkg/config"
"gorm.io/gorm"
)
type UserServiceImpl struct {
cfg *config.Config
db *gorm.DB
userRepo *dao.UserDAO
}
func NewUserService(cfg *config.Config, db *gorm.DB, userRepo *dao.UserDAO) *UserServiceImpl {
return &UserServiceImpl{
cfg: cfg,
db: db,
userRepo: userRepo,
}
}
func (s *UserServiceImpl) GetByID(ctx context.Context, id uint64) (*store.User, error) {
return s.userRepo.GetByID(id)
}
func (s *UserServiceImpl) GetByUsername(ctx context.Context, username string) (*store.User, error) {
return s.userRepo.GetByUsername(username)
}
func (s *UserServiceImpl) List(ctx context.Context, limit, offset int) ([]*store.User, int64, error) {
return s.userRepo.List(limit, offset)
}
func (s *UserServiceImpl) Create(ctx context.Context, user *store.User) error {
return s.userRepo.Create(user)
}
func (s *UserServiceImpl) Update(ctx context.Context, user *store.User) error {
return s.userRepo.Update(user)
}
func (s *UserServiceImpl) Delete(ctx context.Context, id uint64) error {
return s.userRepo.Delete(id)
}
+84
View File
@@ -0,0 +1,84 @@
package store
import (
"fmt"
"log"
"os"
"path/filepath"
"opencatd-open/pkg/config"
_ "github.com/lib/pq"
"github.com/ncruces/go-sqlite3/gormlite"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
var DB *gorm.DB
func InitDB(cfg *config.Config) (*gorm.DB, error) {
var (
dialector gorm.Dialector
err error
)
switch cfg.DB_Type {
case "sqlite":
dialector, err = sqliteDialector(cfg.DSN)
if err != nil {
return nil, err
}
case "postgres":
dialector = postgresDialector(cfg.DSN)
case "mysql":
dialector = mysqlDialector(cfg.DSN)
default:
return nil, fmt.Errorf("unsupported database type: %s", cfg.DB_Type)
}
db, err := gorm.Open(dialector, &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("failed to connect database: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("failed to get underlying *sql.DB: %w", err)
}
sqlDB.SetMaxOpenConns(cfg.DBMaxOpenConns)
sqlDB.SetMaxIdleConns(cfg.DBMaxIdleConns)
if err := db.AutoMigrate(AllModels()...); err != nil {
log.Printf("AutoMigrate warning: %v", err)
}
DB = db
return db, nil
}
func sqliteDialector(dsn string) (gorm.Dialector, error) {
if dsn == "" {
dsn = "db/openteam.db"
}
// sqlite 不会自动创建上级目录,先确保它存在(与 docker-compose 挂载的 /app/db 对应)
if dir := filepath.Dir(dsn); dir != "." && dir != string(filepath.Separator) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("failed to create database directory %s: %w", dir, err)
}
}
return gormlite.Open(dsn), nil
}
func postgresDialector(dsn string) gorm.Dialector {
if dsn == "" {
dsn = "host=localhost user=postgres password=postgres dbname=opencatd port=5432 sslmode=disable"
}
return postgres.Open(dsn)
}
func mysqlDialector(dsn string) gorm.Dialector {
if dsn == "" {
dsn = "root:password@tcp(127.0.0.1:3306)/opencatd?charset=utf8mb4&parseTime=True&loc=Local"
}
return mysql.Open(dsn)
}
+230
View File
@@ -0,0 +1,230 @@
package store
import (
"crypto/sha256"
"encoding/hex"
"regexp"
"strings"
"time"
)
// 角色 / 状态枚举
const (
RoleUser = "user"
RoleAdmin = "admin"
UserStatusActive = "active"
UserStatusDisabled = "disabled"
KeyStatusActive = "active"
KeyStatusRevoked = "revoked"
ChannelProviderOpenAI = "openai"
ChannelProviderAnthropic = "anthropic"
ChannelProviderCompatible = "compatible"
ChannelHealthHealthy = "healthy"
ChannelHealthDegraded = "degraded"
ChannelHealthCooldown = "cooldown"
FormatChat = "chat"
FormatResponses = "responses"
FormatMessages = "messages"
UsageStatusSuccess = "success"
UsageStatusError = "error"
UsageStatusCanceled = "canceled"
)
// User 用户
type User struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
Username string `gorm:"uniqueIndex;size:64;not null" json:"username"`
Email string `gorm:"uniqueIndex;size:255;not null" json:"email"`
PasswordHash string `gorm:"size:255;not null" json:"-"`
Role string `gorm:"size:16;not null;default:user" json:"role"`
Balance float64 `gorm:"type:numeric(20,8);not null;default:0" json:"balance"`
Status string `gorm:"size:16;not null;default:active" json:"status"`
AllowedModels []string `gorm:"type:jsonb;serializer:json" json:"allowed_models,omitempty"`
DeniedModels []string `gorm:"type:jsonb;serializer:json" json:"denied_models,omitempty"`
InviteCode *string `json:"invite_code,omitempty"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// APIKey 密钥(SHA-256 hash 存储)
type APIKey struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
UserID uint64 `gorm:"index;not null" json:"user_id"`
Name string `gorm:"size:64;not null" json:"name"`
KeyHash string `gorm:"uniqueIndex;size:64;not null" json:"-"`
KeyPlain string `gorm:"size:255;not null" json:"key_plain"`
KeyPrefix string `gorm:"size:32;not null" json:"key_prefix"`
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day,omitempty"`
QuotaRequestsPerDay *int `json:"quota_requests_per_day,omitempty"`
AllowedModels []string `gorm:"type:jsonb;serializer:json" json:"allowed_models,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
Status string `gorm:"size:16;not null;default:active" json:"status"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Channel 上游渠道
type Channel struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"uniqueIndex;size:64;not null" json:"name"`
Provider string `gorm:"size:16;not null" json:"provider"`
Formats []string `gorm:"type:jsonb;serializer:json" json:"formats,omitempty"`
BaseURL string `gorm:"size:255;not null" json:"base_url"`
BaseURLs map[string]string `gorm:"type:jsonb;serializer:json" json:"base_urls,omitempty"`
APIKeyEnc string `gorm:"size:1024;not null" json:"-"`
// Weight 为 0 表示不参与加权随机选择(探活/回退语义),因此不能加 gorm
// default 标签 —— 零值字段会被 default 值覆盖,导致 0 被静默改写为 1。
Weight int `gorm:"not null" json:"weight"`
Priority int `gorm:"not null;default:0" json:"priority"`
TimeoutMS int `gorm:"not null;default:120000" json:"timeout_ms"`
MaxConcurrency int `gorm:"not null;default:16" json:"max_concurrency"`
HealthStatus string `gorm:"size:16;not null;default:healthy" json:"health_status"`
Enabled bool `gorm:"not null;default:true" json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// FormatsEffective 返回渠道实际支持的原生协议
func (c *Channel) FormatsEffective() []string {
if len(c.Formats) > 0 {
return c.Formats
}
switch c.Provider {
case ChannelProviderAnthropic:
return []string{FormatMessages}
case ChannelProviderOpenAI:
return []string{FormatChat, FormatResponses}
default:
return []string{FormatChat}
}
}
var versionSegRe = regexp.MustCompile(`/v[0-9]+/?$`)
// UpstreamURL 按协议选 base_url,拼资源路径
func (c *Channel) UpstreamURL(proto, path string) string {
base := c.BaseURL
if len(c.BaseURLs) > 0 && c.BaseURLs[proto] != "" {
base = c.BaseURLs[proto]
}
base = strings.TrimRight(base, "/")
if base == "" {
return path
}
if strings.HasSuffix(base, path) {
return base
}
if versionSegRe.MatchString(base) {
return base + path
}
return base + "/v1" + path
}
// Model 全局模型 + 定价(价格按每百万 token,USD)
type Model struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"uniqueIndex;size:128;not null" json:"name"`
DisplayName string `gorm:"size:128" json:"display_name"`
InputPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"input_price"`
OutputPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"output_price"`
CacheReadPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"cache_read_price"`
Enabled bool `gorm:"not null;default:true" json:"enabled"`
Sort int `gorm:"not null;default:0" json:"sort"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ChannelModelBinding 渠道↔模型绑定(多对多)
type ChannelModelBinding struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
ChannelID uint64 `gorm:"index:idx_ch_model,unique;not null" json:"channel_id"`
ModelID uint64 `gorm:"index:idx_ch_model,unique;not null" json:"model_id"`
UpstreamModel string `gorm:"size:255;not null" json:"upstream_model"`
Weight int `gorm:"not null;default:1" json:"weight"`
Channel Channel `gorm:"foreignKey:ChannelID" json:"-"`
Model Model `gorm:"foreignKey:ModelID" json:"-"`
}
// UsageLog 请求级用量明细
type UsageLog struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
RequestID string `gorm:"size:128" json:"request_id"`
TraceID string `gorm:"size:64;index" json:"trace_id"`
UserID uint64 `gorm:"index:idx_user_created;not null" json:"user_id"`
KeyID uint64 `json:"key_id"`
ChannelID uint64 `json:"channel_id"`
ModelID uint64 `json:"model_id"`
ModelName string `gorm:"size:128" json:"model_name"`
Protocol string `gorm:"size:32" json:"protocol"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens"`
CacheCreationTokens int64 `json:"cache_creation_tokens"`
InputPrice float64 `gorm:"type:numeric(20,8)" json:"input_price"`
OutputPrice float64 `gorm:"type:numeric(20,8)" json:"output_price"`
CacheReadPrice float64 `gorm:"type:numeric(20,8)" json:"cache_read_price"`
Cost float64 `gorm:"type:numeric(20,8)" json:"cost"`
LatencyMS int `json:"latency_ms"`
Status string `gorm:"size:16;not null" json:"status"`
ErrorCode *string `json:"error_code,omitempty"`
RawRequest string `gorm:"type:text" json:"raw_request,omitempty"` // 客户端原始请求体(未转换;仅管理员+开关开启时记录)
RawResponse string `gorm:"type:text" json:"raw_response,omitempty"` // 上游原始响应(未转换;流式为全部 SSE 事件)
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
// UsageDaily 日粒度预聚合
type UsageDaily struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
UserID uint64 `gorm:"index:idx_daily_user_model,unique" json:"user_id"`
ModelID uint64 `gorm:"index:idx_daily_user_model,unique" json:"model_id"`
Date string `gorm:"size:10;index:idx_daily_user_model,unique" json:"date"`
Requests int64 `json:"requests"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens"`
Cost float64 `gorm:"type:numeric(20,8)" json:"cost"`
}
// Passkey WebAuthn 凭据
type Passkey struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
UserID uint64 `gorm:"index;not null" json:"user_id"`
Name string `gorm:"size:64" json:"name"`
CredentialID []byte `gorm:"size:255;not null" json:"-"`
Credential []byte `gorm:"type:blob;not null" json:"-"`
CreatedAt time.Time `json:"created_at"`
}
// SystemConfig 系统配置
type SystemConfig struct {
Key string `gorm:"primaryKey;size:64" json:"key"`
Value string `gorm:"type:jsonb;not null" json:"value"`
}
// AllModels 返回所有需要迁移的模型
func AllModels() []any {
return []any{
&User{},
&APIKey{},
&Channel{},
&Model{},
&ChannelModelBinding{},
&UsageLog{},
&UsageDaily{},
&Passkey{},
&SystemConfig{},
}
}
// HashAPIKey hashes an API key using SHA-256
func HashAPIKey(key string) string {
h := sha256.Sum256([]byte(key))
return hex.EncodeToString(h[:])
}
+204
View File
@@ -0,0 +1,204 @@
package usage
import (
"context"
"fmt"
"log"
"opencatd-open/internal/dao"
"opencatd-open/internal/store"
"sync"
"time"
)
// Event represents a usage event to be recorded
type Event struct {
UserID uint64
ModelName string
ChannelID uint64
PromptTokens int
CompletionTokens int
CacheReadTokens int
CacheCreationTokens int
Cost float64
IsError bool
IsCanceled bool
RequestID string
KeyID uint64
Protocol string
ErrorCode string
LatencyMS int
InputPrice float64
OutputPrice float64
CacheReadPrice float64
TraceID string // TraceID for distributed tracing
ModelID uint64 // Model ID from channel-model binding
RawRequest string // 客户端原始请求体(仅管理员+开关开启时记录)
RawResponse string // 上游原始响应(未转换;流式为全部 SSE 事件)
}
// Recorder handles async usage recording
type Recorder struct {
usageDAO *dao.UsageDAO
dailyDAO *dao.DailyUsageDAO
ch chan Event
batchSize int
flushInterval time.Duration
wg sync.WaitGroup
}
// NewRecorder creates a new usage recorder
func NewRecorder(usageDAO *dao.UsageDAO, dailyDAO *dao.DailyUsageDAO) *Recorder {
return &Recorder{
usageDAO: usageDAO,
dailyDAO: dailyDAO,
ch: make(chan Event, 10000),
batchSize: 100,
flushInterval: 5 * time.Second,
}
}
// Start starts the recorder's background workers
func (r *Recorder) Start(ctx context.Context) {
r.wg.Add(1)
go r.processLoop(ctx)
}
// Stop gracefully stops the recorder
func (r *Recorder) Stop() {
close(r.ch)
r.wg.Wait()
}
// Record queues a usage event for async recording
func (r *Recorder) Record(event Event) {
select {
case r.ch <- event:
default:
log.Printf("Usage channel full, dropping event for user %d model %s", event.UserID, event.ModelName)
}
}
func (r *Recorder) processLoop(ctx context.Context) {
defer r.wg.Done()
batch := make([]Event, 0, r.batchSize)
ticker := time.NewTicker(r.flushInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
if len(batch) > 0 {
r.flush(batch)
}
return
case event, ok := <-r.ch:
if !ok {
if len(batch) > 0 {
r.flush(batch)
}
return
}
batch = append(batch, event)
if len(batch) >= r.batchSize {
r.flush(batch)
batch = make([]Event, 0, r.batchSize)
}
case <-ticker.C:
if len(batch) > 0 {
r.flush(batch)
batch = make([]Event, 0, r.batchSize)
}
}
}
}
func (r *Recorder) flush(events []Event) {
if len(events) == 0 {
return
}
// Batch create usage logs
logs := make([]*store.UsageLog, 0, len(events))
for _, e := range events {
status := store.UsageStatusSuccess
if e.IsError {
status = store.UsageStatusError
}
if e.IsCanceled {
status = store.UsageStatusCanceled
}
var errCode *string
if e.ErrorCode != "" {
errCode = &e.ErrorCode
}
log := &store.UsageLog{
UserID: e.UserID,
KeyID: e.KeyID,
ChannelID: e.ChannelID,
ModelID: e.ModelID,
ModelName: e.ModelName,
Protocol: e.Protocol,
InputTokens: int64(e.PromptTokens),
OutputTokens: int64(e.CompletionTokens),
CacheReadTokens: int64(e.CacheReadTokens),
CacheCreationTokens: int64(e.CacheCreationTokens),
InputPrice: e.InputPrice,
OutputPrice: e.OutputPrice,
CacheReadPrice: e.CacheReadPrice,
Cost: e.Cost,
LatencyMS: e.LatencyMS,
Status: status,
ErrorCode: errCode,
RequestID: e.RequestID,
TraceID: e.TraceID,
RawRequest: e.RawRequest,
RawResponse: e.RawResponse,
}
logs = append(logs, log)
}
// Write to database
if err := r.usageDAO.BatchCreate(context.Background(), logs); err != nil {
log.Printf("Failed to batch create usage logs: %v", err)
}
// Daily rollup for success and canceled requests
dailyMap := make(map[string]*store.UsageDaily)
for _, e := range events {
if e.IsError {
continue
}
date := time.Now().Format("2006-01-02")
key := fmt.Sprintf("%d:%d:%s", e.UserID, e.ModelID, date)
d := dailyMap[key]
if d == nil {
d = &store.UsageDaily{
UserID: e.UserID,
ModelID: e.ModelID,
Date: date,
Requests: 0,
InputTokens: 0,
OutputTokens: 0,
CacheReadTokens: 0,
Cost: 0,
}
dailyMap[key] = d
}
d.Requests++
d.InputTokens += int64(e.PromptTokens)
d.OutputTokens += int64(e.CompletionTokens)
d.CacheReadTokens += int64(e.CacheReadTokens)
d.Cost += e.Cost
}
for _, d := range dailyMap {
if err := r.dailyDAO.UpsertDailyUsage(context.Background(), d); err != nil {
log.Printf("Failed to upsert daily usage: %v", err)
}
}
log.Printf("Flushed %d usage logs", len(logs))
}
+72
View File
@@ -0,0 +1,72 @@
package middleware
import (
"net/http"
"opencatd-open/internal/auth"
"opencatd-open/internal/store"
"opencatd-open/internal/pkg/jwt"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func Auth(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
authToken := c.GetHeader("Authorization")
if authToken == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"code": http.StatusUnauthorized,
"error": "未提供认证信息",
})
return
}
if len(authToken) > 7 {
authToken = authToken[7:]
}
claim, err := jwt.ValidateToken(authToken, auth.GetSecretKey())
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"code": http.StatusUnauthorized,
"error": "无效的认证信息",
})
return
}
var user store.User
if err := db.First(&user, claim.UserID).Error; err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"code": http.StatusUnauthorized,
"error": "无效的认证信息",
})
return
}
c.Set("user", &user)
c.Set("user_id", claim.UserID)
c.Set("user_role", user.Role)
c.Next()
}
}
func CheckRole(role string) gin.HandlerFunc {
return func(c *gin.Context) {
userRole, _ := c.Get("user_role")
if roleStr, ok := userRole.(string); !ok || roleStr != role {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "权限不足"})
return
}
c.Next()
}
}
// AdminOnly 管理后台中间件:要求 user_role 为 admin。
// 由 middleware.Auth 先行设置 user_role;缺失时拒绝。
func AdminOnly() gin.HandlerFunc {
return func(c *gin.Context) {
role, _ := c.Get("user_role")
roleStr, _ := role.(string)
if roleStr != store.RoleAdmin {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "需要管理员权限"})
return
}
c.Next()
}
}
+73
View File
@@ -0,0 +1,73 @@
package middleware
import (
"net/http"
"opencatd-open/internal/store"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// keyPrefixLen 是 key_prefix 列的截断长度,必须与 api.go:459 的 keyValue[:12] 一致。
// 真实 key 为 sk-ot- + 48 位 hex(54 字符),故 12 位足够唯一。
const keyPrefixLen = 12
func AuthLLM(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
key := extractAPIKey(c.GetHeader("Authorization"))
// 区分「没传」和「传了但不对」,便于排查客户端配置。
if strings.TrimSpace(c.GetHeader("Authorization")) == "" {
unauthorized(c, "未提供认证信息")
return
}
// 长度不足时直接拒绝:避免下方 authToken[:12] 越界 panic 打崩进程。
if len(key) < keyPrefixLen {
unauthorized(c, "无效的API密钥")
return
}
var apiKey store.APIKey
if err := db.Where("key_prefix = ? AND status = ?", key[:keyPrefixLen], store.KeyStatusActive).First(&apiKey).Error; err != nil {
unauthorized(c, "无效的API密钥")
return
}
// Verify full key hash
if apiKey.KeyHash != store.HashAPIKey(key) {
unauthorized(c, "无效的API密钥")
return
}
// 附带用户角色(判断是否管理员,供原始请求/响应记录等管理能力使用)。
var user store.User
if err := db.First(&user, apiKey.UserID).Error; err != nil {
unauthorized(c, "无效的API密钥")
return
}
c.Set("api_key", &apiKey)
c.Set("user_id", apiKey.UserID)
c.Set("user_role", user.Role)
c.Next()
}
}
// extractAPIKey 从 Authorization 头取 Bearer token,兼容无 "Bearer " 前缀的直传。
func extractAPIKey(auth string) string {
auth = strings.TrimSpace(auth)
if strings.HasPrefix(auth, "Bearer ") {
return strings.TrimSpace(auth[len("Bearer "):])
}
return auth
}
func unauthorized(c *gin.Context, message string) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": map[string]interface{}{
"message": message,
"type": "invalid_request_error",
},
})
}
+257
View File
@@ -0,0 +1,257 @@
package router
import (
"context"
"embed"
"fmt"
"io/fs"
"log"
"net/http"
"opencatd-open/internal/api"
"opencatd-open/internal/channel"
"opencatd-open/internal/dao"
"opencatd-open/internal/passkey"
"opencatd-open/internal/proxy"
"opencatd-open/internal/usage"
"opencatd-open/middleware"
"opencatd-open/pkg/config"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) {
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
if cfg == nil || db == nil {
panic("cfg or db is nil")
}
sqlDB, err := db.DB()
if err != nil {
log.Fatalf("Failed to get underlying *sql.DB: %v", err)
}
// Initialize DAOs
userDAO := dao.NewUserDAO(db)
apiKeyDAO := dao.NewApiKeyDAO(db)
usageDAO := dao.NewUsageDAO(db)
dailyDAO := dao.NewDailyUsageDAO(db)
channelDAO := dao.NewChannelDAO(db)
modelDAO := dao.NewModelDAO(db)
// Initialize channel service
channelSvc := channel.NewService(channelDAO, modelDAO)
// Initialize health checker and start periodic checks
healthChecker := channel.NewHealthChecker(channelDAO, channelSvc)
go healthChecker.StartPeriodicCheck(ctx)
// Initialize usage recorder and start background worker
usageRecorder := usage.NewRecorder(usageDAO, dailyDAO)
usageRecorder.Start(ctx)
defer usageRecorder.Stop()
// Initialize gateway
gateway := proxy.NewGateway(ctx, cfg, db, &wg, userDAO, apiKeyDAO, usageDAO, dailyDAO)
gateway.SetChannelService(channelSvc)
gateway.SetUsageRecorder(usageRecorder)
// Initialize passkey service
var rdb *redis.Client
if cfg.RedisHost != "" {
rdb = redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", cfg.RedisHost, cfg.RedisPort),
Password: cfg.RedisPassword,
DB: cfg.RedisDB,
})
}
passkeySvc, err := passkey.New(db, passkey.Config{
RPID: cfg.RPID,
Origins: cfg.RPOrigins,
Name: cfg.AppName,
Redis: rdb,
})
if err != nil {
log.Fatalf("Failed to initialize passkey service: %v", err)
}
// Initialize API handler
apiHandler := api.NewHandler(db, passkeySvc)
r := gin.Default()
r.Use(middleware.CORS())
// Public auth routes
public := r.Group("/api/auth")
{
public.POST("/register", apiHandler.Register)
public.POST("/login", apiHandler.Login)
public.POST("/passkey/begin", apiHandler.PasskeyLoginBegin)
public.POST("/passkey/finish", apiHandler.PasskeyLoginComplete)
}
// API routes (authenticated)
apiGroup := r.Group("/api", middleware.Auth(db))
{
// User profile
apiGroup.GET("/me", apiHandler.Me)
apiGroup.GET("/profile", apiHandler.Me)
apiGroup.POST("/profile/update", apiHandler.UpdateProfile)
apiGroup.POST("/profile/update/password", apiHandler.UpdatePassword)
// Passkey management
apiGroup.POST("/webauthn/register/begin", apiHandler.PasskeyRegisterBegin)
apiGroup.POST("/webauthn/register/complete", apiHandler.PasskeyRegisterComplete)
apiGroup.GET("/webauthn/passkeys", apiHandler.PasskeyList)
apiGroup.DELETE("/webauthn/passkeys/:id", apiHandler.PasskeyDelete)
// User management (admin)
apiGroup.GET("/users", apiHandler.ListUsers)
apiGroup.GET("/users/:id", apiHandler.GetUser)
apiGroup.POST("/users", apiHandler.CreateUser)
apiGroup.PUT("/users/:id", apiHandler.UpdateUser)
apiGroup.DELETE("/users/:id", apiHandler.DeleteUser)
apiGroup.POST("/users/batch/:option", apiHandler.BatchUsers)
// API Key management
apiGroup.GET("/keys", apiHandler.ListApiKeys)
apiGroup.GET("/keys/:id", apiHandler.GetApiKey)
apiGroup.POST("/keys", apiHandler.CreateApiKey)
apiGroup.PUT("/keys/:id", apiHandler.UpdateApiKey)
apiGroup.DELETE("/keys/:id", apiHandler.DeleteApiKey)
apiGroup.POST("/keys/batch/:option", apiHandler.BatchApiKeys)
// Channel management (legacy endpoints)
apiGroup.GET("/channels", apiHandler.ListChannels)
apiGroup.POST("/channels", apiHandler.CreateChannel)
apiGroup.PUT("/channels/:id", apiHandler.UpdateChannel)
apiGroup.DELETE("/channels/:id", apiHandler.DeleteChannel)
apiGroup.GET("/channels/:id/models", apiHandler.GetChannelModels)
apiGroup.POST("/channels/:id/models", apiHandler.BindChannelModels)
// Model management (legacy endpoints)
apiGroup.GET("/models", apiHandler.ListModels)
apiGroup.POST("/models", apiHandler.CreateModel)
apiGroup.PUT("/models/:id", apiHandler.UpdateModel)
apiGroup.DELETE("/models/:id", apiHandler.DeleteModel)
// 用户自身用量统计
apiGroup.GET("/usage/stats", apiHandler.MyUsageStats)
apiGroup.GET("/usage/monthly", apiHandler.MyUsageMonthly)
apiGroup.GET("/usage/logs", apiHandler.MyUsageLogs)
}
// Admin API (requires admin role)
adminGroup := r.Group("/api/admin", middleware.Auth(db), middleware.AdminOnly())
{
// Admin channel management (enhanced)
adminGroup.GET("/channels", apiHandler.AdminChannels)
adminGroup.POST("/channels", apiHandler.AdminCreateChannel)
adminGroup.PUT("/channels/:id", apiHandler.AdminUpdateChannel)
adminGroup.DELETE("/channels/:id", apiHandler.AdminDeleteChannel)
adminGroup.POST("/channels/:id/test", apiHandler.AdminTestChannel)
adminGroup.GET("/channels/:id/models/remote", apiHandler.AdminChannelRemoteModels)
adminGroup.GET("/channels/:id/models", apiHandler.AdminChannelModels)
adminGroup.POST("/channels/:id/models", apiHandler.AdminChannelAddModel)
adminGroup.PATCH("/channels/:id/models/:bid", apiHandler.AdminChannelUpdateModel)
adminGroup.DELETE("/channels/:id/models/:bid", apiHandler.AdminChannelDeleteModel)
// Admin model management (enhanced)
adminGroup.GET("/models", apiHandler.AdminModels)
adminGroup.DELETE("/models/unused", apiHandler.AdminDeleteUnusedModels)
adminGroup.POST("/models", apiHandler.AdminCreateModel)
adminGroup.PUT("/models/:id", apiHandler.AdminUpdateModel)
adminGroup.DELETE("/models/:id", apiHandler.AdminDeleteModel)
adminGroup.POST("/models/:id/bindings", apiHandler.AdminCreateModelBinding)
adminGroup.DELETE("/models/:id/bindings/:bid", apiHandler.AdminDeleteModelBinding)
// Admin system config
adminGroup.GET("/config", apiHandler.AdminGetConfig)
adminGroup.PUT("/config", apiHandler.AdminUpdateConfig)
adminGroup.GET("/config/registration", apiHandler.AdminGetRegistration)
adminGroup.PUT("/config/registration", apiHandler.AdminUpdateRegistration)
adminGroup.GET("/config/password-login", apiHandler.AdminGetPasswordLogin)
adminGroup.PUT("/config/password-login", apiHandler.AdminUpdatePasswordLogin)
// Admin usage
adminGroup.GET("/usage/logs", apiHandler.AdminUsageLogs)
adminGroup.GET("/usage/summary", apiHandler.AdminUsageSummary)
}
// LLM proxy routes
v1 := r.Group("/v1")
v1.Use(middleware.AuthLLM(db))
{
v1.POST("/chat/completions", gateway.HandleChat)
v1.POST("/messages", gateway.HandleMessages)
v1.POST("/responses", gateway.HandleResponses)
v1.GET("/models", gateway.HandleModels)
}
// SPA fallback
idxFS, err := fs.Sub(web, "dist")
if err != nil {
panic(err)
}
assetsFS, err := fs.Sub(web, "dist/assets")
if err != nil {
panic(err)
}
r.StaticFS("/assets", http.FS(assetsFS))
r.NoRoute(func(c *gin.Context) {
if c.Writer.Status() == http.StatusNotFound {
c.FileFromFS("/", http.FS(idxFS))
}
})
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: r,
}
go func() {
fmt.Println("Starting server at port:", cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
fmt.Println("\nShutdown Server ...")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Fatalln("Server Shutdown:", err)
}
cancel()
sqlDB.Close()
waitChan := make(chan struct{})
go func() {
wg.Wait()
close(waitChan)
}()
select {
case <-waitChan:
fmt.Println("All goroutines have finished")
case <-shutdownCtx.Done():
fmt.Println("⚠️ Shutdown timeout")
}
fmt.Println("Server exited")
}
+36
View File
@@ -0,0 +1,36 @@
//go:build wireinject
// +build wireinject
package wire
import (
"context"
"opencatd-open/internal/channel"
"opencatd-open/internal/controller/proxy"
"opencatd-open/internal/dao"
"opencatd-open/pkg/config"
"sync"
"github.com/google/wire"
"gorm.io/gorm"
)
var daoSet = wire.NewSet(
dao.NewUserDAO,
dao.NewApiKeyDAO,
dao.NewTokenDAO,
dao.NewUsageDAO,
dao.NewDailyUsageDAO,
dao.NewChannelDAO,
dao.NewModelDAO,
)
var channelSet = wire.NewSet(
channel.NewService,
channel.NewHealthChecker,
)
func InitProxyHandler(ctx context.Context, cfg *config.Config, db *gorm.DB, wg *sync.WaitGroup) (*proxy.Proxy, error) {
wire.Build(daoSet, channelSet, proxy.NewProxy)
return nil, nil
}
View File
+3 -3
View File
@@ -11,19 +11,19 @@ RUN npm install -g pnpm@10.25.0 \
# 后端:go.mod 使用纯 Go 的 glebarez/sqlite,可关闭 CGO 直接交叉编译到目标架构, # 后端:go.mod 使用纯 Go 的 glebarez/sqlite,可关闭 CGO 直接交叉编译到目标架构,
# 因此同样固定在原生平台构建 # 因此同样固定在原生平台构建
FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS backend FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS backend
LABEL author="github.com/Sakurasan" LABEL author="github.com/Sakurasan"
ARG TARGETOS ARG TARGETOS
ARG TARGETARCH ARG TARGETARCH
RUN apk --no-cache add make upx RUN apk --no-cache add make upx
WORKDIR /build WORKDIR /build
COPY . . COPY . .
COPY --from=frontend /frontend-build/dist /build/cmd/openteam/dist COPY --from=frontend /frontend-build/dist /build/backend/cmd/openteam/dist
ENV GO111MODULE=on \ ENV GO111MODULE=on \
CGO_ENABLED=0 \ CGO_ENABLED=0 \
GOOS=$TARGETOS \ GOOS=$TARGETOS \
GOARCH=$TARGETARCH GOARCH=$TARGETARCH
RUN make build RUN make build-backend
FROM alpine:latest AS runner FROM alpine:latest AS runner
# 设置alpine 时间为上海时间 # 设置alpine 时间为上海时间
+3 -3
View File
@@ -13,7 +13,7 @@ RUN npm config set registry https://registry.npmmirror.com \
# 后端:go.mod 使用纯 Go 的 glebarez/sqlite,可关闭 CGO 直接交叉编译到目标架构, # 后端:go.mod 使用纯 Go 的 glebarez/sqlite,可关闭 CGO 直接交叉编译到目标架构,
# 因此同样固定在原生平台构建 # 因此同样固定在原生平台构建
FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS backend FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS backend
LABEL author="github.com/Sakurasan" LABEL author="github.com/Sakurasan"
ARG TARGETOS ARG TARGETOS
ARG TARGETARCH ARG TARGETARCH
@@ -21,13 +21,13 @@ RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
&& apk --no-cache add make upx && apk --no-cache add make upx
WORKDIR /build WORKDIR /build
COPY . . COPY . .
COPY --from=frontend /frontend-build/dist /build/cmd/openteam/dist COPY --from=frontend /frontend-build/dist /build/backend/cmd/openteam/dist
ENV GO111MODULE=on \ ENV GO111MODULE=on \
GOPROXY=https://goproxy.cn,direct \ GOPROXY=https://goproxy.cn,direct \
CGO_ENABLED=0 \ CGO_ENABLED=0 \
GOOS=$TARGETOS \ GOOS=$TARGETOS \
GOARCH=$TARGETARCH GOARCH=$TARGETARCH
RUN make build RUN make build-backend
FROM alpine:latest AS runner FROM alpine:latest AS runner
# 设置alpine 时间为上海时间 # 设置alpine 时间为上海时间
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env sh
# 构建 dev 镜像:默认多平台(amd64/arm64)并推送,PUSH=0 时按本机架构构建并加载到本地
#
# 用法:
# sh deploy/docker/build-dev.sh # 多平台构建并推送 dev 标签
# PUSH=0 sh deploy/docker/build-dev.sh # 本机架构构建 --load,仅本地
# REGISTRY_IMAGE=demo/openteam PLATFORMS=linux/amd64 sh deploy/docker/build-dev.sh
set -e
cd "$(dirname "$0")/../.."
REGISTRY_IMAGE=${REGISTRY_IMAGE:-mirrors2/opencatd-open}
PLATFORMS=${PLATFORMS:-linux/amd64,linux/arm64}
BUILDER=${BUILDER:-xbuilder}
PUSH=${PUSH:-1}
DOCKERFILE=${DOCKERFILE:-deploy/docker/Dockerfile}
SHORT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)
DATE_TAG=$(date +%Y%m%d)
# 确保 buildx 容器构建器存在(默认 docker driver 不支持多平台)
docker buildx inspect "$BUILDER" >/dev/null 2>&1 || docker buildx create --name "$BUILDER" --driver docker-container >/dev/null
TAGS="-t $REGISTRY_IMAGE:dev -t $REGISTRY_IMAGE:dev-$SHORT_SHA -t $REGISTRY_IMAGE:dev-$DATE_TAG"
if [ "$PUSH" = "1" ]; then
echo ">>> 多平台构建并推送: $PLATFORMS"
# shellcheck disable=SC2086
docker buildx build \
--builder "$BUILDER" \
--platform "$PLATFORMS" \
$TAGS \
--cache-from type=registry,ref="$REGISTRY_IMAGE:dev" \
--cache-to type=inline \
-f "$DOCKERFILE" \
. --push
echo ">>> 已推送: $REGISTRY_IMAGE:dev (dev-$SHORT_SHA, dev-$DATE_TAG)"
else
HOST_ARCH=$(docker version -f '{{.Server.Arch}}')
echo ">>> 本机架构构建并加载: linux/$HOST_ARCH"
docker buildx build \
--builder "$BUILDER" \
--platform "linux/$HOST_ARCH" \
$TAGS \
-f "$DOCKERFILE" \
. --load
echo ">>> 已加载本地镜像: $REGISTRY_IMAGE:dev (dev-$SHORT_SHA, dev-$DATE_TAG)"
fi
-1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -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

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

-15
View File
@@ -1,15 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/assets/logo-4312ea85.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>opencatd-open</title>
<script type="module" crossorigin src="/assets/index-9a4663b1.js"></script>
<link rel="stylesheet" href="/assets/index-ef8ba4ac.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

-25
View File
@@ -1,25 +0,0 @@
# Azure OpenAI for team
1.需要获取 api-key和endpoint [https://[resource name].openai.azure.com/)
![](./azure_key%26endpoint.png)
> 2.Pleause use model name as deployment name
| model name | deployment name |
| --- | --- |
|gpt-35-turbo | gpt-35-turbo |
|gpt-35-turbo-16k | gpt-35-turbo-16k |
| gpt-4 | gpt-4 |
## How to use
- opencat 使用方式
- key name以 azure.[resource name]的方式添加
- 密钥任取一个
- <img src="./azure_openai_for_team.png" alt="azure_openai_for_team" height="600">
- [AMA(问天)](http://bytemyth.com/ama) 使用方式
- ![](azure_ama.png)
- 每个 team server 用户旁边有一个复制按钮,点击后,把复制的链接粘贴到浏览器,可以一键设置
## Claude
- opencat 添加Claude api, key name以 "claude.key名称",即("Api类型.Key名称")

Some files were not shown because too many files have changed in this diff Show More