Compare commits

..
74 Commits
Author SHA1 Message Date
Sakurasan d911881499 feat: add reset-password CLI command for any user 2026-09-07 02:33:11 +08:00
Sakurasan 2e3bb22f81 fix: use weighted random pick for load balancing instead of sequential iteration 2026-09-02 17:32:12 +08:00
Sakurasan 923ac79039 删除请求超时 2026-09-02 13:08:32 +08:00
Sakurasan 6d951a0d61 fix: sync schemaVersion to 2 matching production 2026-08-31 18:16:19 +08:00
Sakurasan 1f5f60e905 fix: move timeout migration outside schema version check 2026-08-31 18:01:33 +08:00
Sakurasan c6a4fcfbbc fix: migrate existing channels timeout_ms to 300000 2026-08-31 17:47:52 +08:00
Sakurasan 5cb428bba9 feat: increase default timeout to 5 minutes 2026-08-31 16:56:18 +08:00
Sakurasan 658a9cb580 fix: SQLite 迁移加版本锁,消除每次启动全量重建表
GORM sqlite AutoMigrate 对已有表判定不收敛(实测已迁移库再跑
仍触发 12 次表重建),SQLite 无在线 ALTER,每次重建走
CREATE __temp + INSERT SELECT + DROP 全表拷贝;usage_logs 含
raw_request/raw_response 大文本,一轮 40~80s,生产每次重启
重演导致启动数分钟。

- 新增 schemaVersion 常量,PRAGMA user_version 记录已迁移版本
- 版本达标跳过 AutoMigrate 秒开;旧库首次升级迁移一次写版本
- postgres 不受影响(支持 ALTER,保持原行为)
- 维护约定:改表结构须递增 schemaVersion
2026-08-29 12:46:52 +08:00
Sakurasan 3a836bf0e9 fix: curl 示例复制按钮逃逸到页头叠加在退出按钮上
上轮移动端修复误删容器 relative,absolute 按钮向上冒泡定位到
页面顶端。加回 relative 使按钮回到代码块右上角。
2026-08-28 21:22:40 +08:00
Sakurasan faf38e2fd3 fix: 修复移动端仪表盘横向溢出
375px 视口下页面被撑到 763px:快速开始卡片的 curl 示例 <pre>
固有宽度沿 flex/grid 链层层撑破祖先(子项默认 min-width:auto
不可收缩,overflow-x-auto 形同虚设)。

- ShellLayout:flex-1 容器与 main 加 min-w-0 切断宽度传播(所有页面受益)
- DashboardView:快速开始卡片各块加 min-w-0,pre 的边框/背景
  移入 overflow-hidden 容器、自身横滚

Playwright 实测 375px 视口滚动宽度 763 → 375,与视口齐平
2026-08-28 20:12:08 +08:00
Sakurasan d8257df100 fix: messages 流式缓存场景 token 记账错乱
qwen/dashscope 等上游 messages 流式的 usage 语义:
- message_start.usage.input_tokens = 总输入
- message_delta.usage.input_tokens = 非缓存输入(缓存部分单列
  cache_read/cache_creation 字段),是最终计费口径

原 usageSink 字段级合并中 delta 的 input 覆盖 start 的 input,
总输入丢失(31790 → 8);缓存写也未参与计费。

- push:带 cache_* 字段的 usage 视为最终口径,整体替换 sink
- finishUsage:缓存写按 1.25× 输入价计费(Anthropic 5m 口径);
  落库 input_tokens 存总量(含缓存读/写)便于对账
- 估算兜底条件排除已有缓存计数的请求
- 回归测试:缓存写/缓存命中/chat 末块合并不回归
2026-08-28 18:11:24 +08:00
Sakurasan cd015d370c fix: handle zero channel_id in admin usage logs 2026-08-28 16:55:08 +08:00
Sakurasan 939a98d389 feat: show channel name in admin usage logs 2026-08-28 16:40:23 +08:00
Sakurasan 0b31cc209e fix: chat→responses 转换 input 始终输出数组
单条消息时原逻辑把 input 塌缩成单个对象,火山方舟等上游只接受
[]*InputItem 数组,直接 400(Mismatch type)。chat/messages 经
vole 渠道转换因此全挂。改为恒为数组,三种协议全矩阵实测恢复。
2026-08-27 09:57:48 +08:00
Sakurasan 305b3ed731 refactor: 删除无用的模型展示名(display_name)字段
该字段仅存取、从不展示:管理列表只渲染 name,/v1/models 与用户侧
均不返回它。与渠道绑定 upstream_model 的名字映射职责重叠且悬空。
- store.Model 结构体、admin CRUD、seed 数据移除 display_name
- 前端编辑弹窗输入框、表单状态、types.ts 同步移除
- 本地库已 ALTER TABLE DROP COLUMN;生产残留列无害
2026-08-27 09:57:30 +08:00
Sakurasan 79545aa48e fix: 兼容上游 SSE data: 后无空格格式,流式记账不再 0 消耗
火山方舟等上游流式返回 data:{...}(data: 后无空格),原解析只认
'data: '(带空格),导致 content 文本与 usage 全部漏解析:
- sseContentText 抽成 sseDataPayload,统一处理 data: 前缀
  (支持带/不带空格 + event:+data: 多行块)
- scanUsage 复用 sseDataPayload
- convert/stream.go parseLine 也支持 data:{ 无空格
- 新增回归测试覆盖无空格/带空格/多行块三种形态
2026-08-27 01:24:30 +08:00
Sakurasan d818cf3b4c refactor: 命令树移入 internal/cli,去掉 DefaultRun 注入
- cmd/server/cmd → internal/cli,与 internal/api、internal/proxy 平级
- serve() 移入 cli 包作为默认子命令,main.go 退化为纯入口
- 消除 cmd/cmd 冗余层级和 main → cmd 的回调注入
2026-08-26 18:14:08 +08:00
Sakurasan 98f296101e refactor: 改用 Cobra 命令行框架,reset-admin-password 带交互提示
- root.go 作为 Cobra 入口,DefaultRun 由 main 注入 serve()
- reset_admin.go 改为 Cobra 子命令,支持 --user/--password 参数
- 交互式:多管理员时选择目标、密码支持手动输入或随机生成(term.ReadPassword 不回显)
- 帮助输出自动生成,无需手写 Usage
- 删除旧的 flag 实现
2026-08-26 18:03:03 +08:00
Sakurasan 2983c89b42 feat: 新增 reset-admin-password 命令行重置管理员密码
- cmd/server 子命令分发:reset-admin-password 不启动服务
- -password 留空随机生成 16 位(去除易混淆字符),打印一次
- -user 指定目标管理员,仅匹配 role=admin
- 密码长度校验 8-72 与 API 一致,argon2id 参数取自配置
- Makefile 增加 make reset-admin-password 入口
2026-08-26 15:57:04 +08:00
Sakurasan a45a6e16bd fix: API Key 前缀调整为 sk-ot- 2026-08-26 15:57:04 +08:00
Sakurasan c78a473e59 feat: API Key 前缀改为 sk-ot
- apikey 包 prefix 常量 sk- → sk-ot(生成/校验/展示前缀统一驱动)
- 鉴权错误提示同步更新为 sk-ot 格式
- 测试断言改用常量,避免硬编码长度
- 前端 curl 示例与 README/PLANNING 文档同步
2026-08-25 00:34:50 +08:00
Sakurasan f7a5741b33 fix: 模型列表仅显示已启用渠道中的模型 2026-08-22 14:19:45 +08:00
Sakurasan daf6b8c66b fix: 获取模型列表时过滤已禁用渠道的绑定 2026-08-22 13:26:17 +08:00
Sakurasan 8430ecc6c6 fix: use inline style for enabled channel highlight 2026-08-22 12:21:48 +08:00
Sakurasan 4aa8b79156 fix: use bg-accent-soft for enabled channel highlight 2026-08-22 12:17:45 +08:00
Sakurasan 94fd7fd40a feat: add highlight for enabled channels 2026-08-22 12:09:33 +08:00
Sakurasan 048de4c9a8 feat: add enable/disable toggle for channels 2026-08-22 02:12:10 +08:00
Sakurasan 781f1b9f83 添加 OT_PROXY_LOG_RAW 到 .env.example 2026-08-21 11:21:19 +08:00
Sakurasan 42214d6f5e 管理后台用量统计支持按用户筛选:用户名输入 + datalist 自动补全,精确匹配解析为 user_id
- types: UsageLog 补 user_id 字段
- UsageView: 新增用户筛选输入框,防抖 300ms 拉取 admin/users 候选做下拉补全
- search 时将用户名经 admin/users 精确解析为 user_id 再过滤 admin/usage;未找到用户时 toast 报错
- 支持清除筛选一键还原
2026-08-20 01:58:02 +08:00
Sakurasan 10f51cbdae 请求明细支持查看原始请求/响应:OT_PROXY_LOG_RAW 开关控制,仅管理员记录与可见,流式全量捕获
- 配置: ProxyConfig.LogRaw (OT_PROXY_LOG_RAW, 默认 false)
- 存储: usage_logs 新增 raw_request/raw_response 文本列 (AutoMigrate)
- 网关: NewGateway 接收 logRaw 参数
- handlers: 三个协议入口按 开关+管理员 条件记录原始请求体
- passthrough: 非流式 copyAndCapture 捕获响应, 流式 streamCopy 累积全部原始 SSE 行, finishUsage 统一写入
- admin API: AdminUsage 返回 raw_request/raw_response (仅管理员)
- 前端: 用量页新增查看入口, 弹窗 tab 切换请求/响应
- gitignore: 修正 server/web/ 忽略规则(尾随空格导致未生效)
2026-08-20 00:37:48 +08:00
Sakurasan 0939f98fb5 修复流式请求记账为 0 消耗:支持 SSE 多行块 usage 提取 + 无上游 usage 时 tiktoken 估算
- sseContentText/scanUsage 兼容 event:+data: 多行块(转换器 eventLine 产出)
- usageFromMap 新增 delta.usage 支持(Anthropic message_delta 标准格式)
- finishUsage: 流式成功但上游不返回 usage(如火山方舟)时按内容 tiktoken 估算,in/out 独立估算
- .gitignore 忽略 server/web 本地静态托管软链
2026-08-19 20:59:04 +08:00
Sakurasan 30ab9e842c claude code tools error 2026-08-19 03:46:39 +08:00
Sakurasan 3e7efb3c88 fix passkey
server/internal/passkey/passkey.go
 的 takeSession,过期校验只在显式设置了 Expires 时才生效(与 go-webauthn 库内部 !IsZero() 的检查一致)
2026-08-19 00:00:51 +08:00
SakurasanandClaude 8495694671 构建: 入口脚本 chown 数据目录后降权运行
绑定挂载/命名卷被 docker 以 root 自动创建时,容器非 root 用户
写不进 db(SQLITE_CANTOPEN)。改为 entrypoint 以 root 启动,
chown 数据目录到 OT_UID:OT_GID(默认 1000:1000)后 su-exec 降权,
compose 不再需要 user: 覆盖。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 22:25:43 +08:00
SakurasanandClaude 5aa0be13dc 部署: docker-compose 一键启动
- 密钥从仓库根 .env 读取(:? 缺失即报错)
- db 绑定挂载当前目录 ./data,按宿主用户运行(OT_UID/OT_GID 可覆盖)
- healthcheck 探测 /healthz,restart unless-stopped

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 21:57:16 +08:00
SakurasanandClaude b5fb947a5b 构建: --local 改用 docker build 绕开 docker-container 驱动
multiarch builder 嵌套创建构建容器在受限环境报 "can't mask dir
/proc/acpi";本地单架构无需 buildx,直接 docker build 更稳。
多架构 --push 路径保持不变(仍须 docker-container builder)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 20:49:45 +08:00
SakurasanandClaude ea7f023228 构建: 多架构 Dockerfile + 构建脚本
- Dockerfile 三阶段:前端固定在 BUILDPLATFORM 编译一次(平台无关产物),
  Go 后端按 TARGETARCH 交叉编译,两种架构共用同一份 web/dist
- 前端用 pnpm --ignore-scripts(pnpm≥10 拦截 esbuild/vue-demi postinstall,
  esbuild 走 optionalDependencies 自带二进制),运行镜像非 root + /app/data 卷
- scripts/build-image.sh:--local 本地单架构加载;默认多架构 amd64+arm64 推送

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 13:16:54 +08:00
SakurasanandClaude 967c54ae32 代理: 鉴权支持 Anthropic x-api-key 头
Claude Code / Anthropic SDK 用 x-api-key 头而不是 Authorization:
Bearer,缺失 Authorization 时回退读取 x-api-key,错误文案同步提示两种方式

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 00:51:53 +08:00
SakurasanandClaude eac6938baa 服务: SPA 缓存策略修复重建后首页黑屏
- index.html 设 Cache-Control: no-cache,避免浏览器启发式缓存保留引用已删除 chunk 的旧 HTML
- /assets/*(文件名含内容 hash,不可变)设 immutable 长缓存,减少回源

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 20:30:17 +08:00
SakurasanandClaude fcaafdc807 前端: Base URL 输入限长 + 列表截断防撑破
- Input 组件支持 maxlength 透传
- 渠道表单 4 个 Base URL 输入框限 255 字符
- 渠道列表 Base URL 单元格截断显示(max-w-220px)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 20:30:09 +08:00
SakurasanandClaude 74424d980c 前端: 仪表盘快速开始卡片 + 移动端适配 + 主页 curl 用浏览器 host
- 仪表盘底部新增「快速开始」:Base URL(浏览器 host)+ 协议徽标 + curl 示例(可复制)
- 最近请求改为响应式两行(模型+状态 / token·成本·时间),窄屏不再挤压
- 主页调用演示改用 window.location.origin,去掉写死的 api.openteam.dev

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 20:30:03 +08:00
SakurasanandClaude c84fea9ef0 记账: Recorder Close 幂等防重复关闭
- main 的 defer Shutdown 与显式 Close 双调用导致停机 panic close of closed channel
- 用 sync.Once 保证只关闭一次

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 18:55:15 +08:00
SakurasanandClaude c3dc28fb5b 前端: 仪表盘最近请求 canceled 显示灰色
- 控制台/管理后台仪表盘「最近请求」的 canceled 状态改为 neutral 灰色
- 与用量页保持一致的三态判断(success=ok / canceled=neutral / err)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 18:55:10 +08:00
SakurasanandClaude 4137b6fe20 渠道: 远程候选按渠道过滤 + key 掩码 + 移除批量导入端点
- 远程模型候选只排除本渠道已允许的模型,同名模型可被多个渠道各自允许
- 渠道 key 掩码统一 xxxxxxx******Mq4Y(保留前 7 位与后 4 位)
- 移除已弃用的批量导入端点 POST /channels/:id/models/import

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 18:55:05 +08:00
SakurasanandClaude d0c879a2ed 模型定价: 悬空模型标记 + 一键清除 + 允许渠道只读
- 无渠道绑定的模型以「悬空」琥珀色徽标 + 卡片着色标记
- 新增「清除悬空」一键删除未绑定渠道的模型(DELETE /models/unused)
- 移除页面内「绑定渠道」弹窗与 chips 的解除按钮,渠道支持列表只在渠道抽屉管理
- 缺失提示带上渠道真实模型名(upstream_model)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 18:54:59 +08:00
SakurasanandClaude 8194edb63d 前端: 用量页移动端卡片优化 + canceled 灰色 + 接口类型短标识
- 移动端卡片重排: 成本/状态右置, 协议在模型下方, 元信息分隔行
- canceled 状态徽章由 warn 改为 neutral(与成功/失败区分)
- 接口类型列改用短标识 chat/completions | responses | messages
- 管理后台页头搜索框移动端纵向排列

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 09:04:26 +08:00
SakurasanandClaude 6f895acf63 前端: API Keys 移动端卡片布局 + 复制兼容
- 移动端(md 以下)以卡片列表替代横向滚动表格, 展示配额/过期/最近使用
- 桌面表格补充配额/过期列, 移除创建时间列
- copyText 支持 Clipboard API + execCommand 回退(HTTP 环境可用)
- 删除文案由"吊销"改为"删除(不可恢复)"

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 09:04:26 +08:00
SakurasanandClaude fb7db00817 密钥: 删除改为硬删(立即失效不可恢复)
- DELETE /api/v1/keys/:id 由软吊销(置 revoked)改为物理删除

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 09:04:18 +08:00
SakurasanandClaude 0ee92d78e7 代理: 流式中断按已生成部分计费
- 新增 tokenizer 包(tiktoken-go)按模型估算 token, 未知模型回退 cl100k_base
- passthrough 提取请求输入文本 + SSE 已生成内容, 客户端断开时记 canceled
- usage 计费范围扩展: canceled(流式中断)按已生成部分收费

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 09:04:18 +08:00
SakurasanandClaude 4b507d8064 转换器: 图片内容块跨协议互转保留(base64/url)
- 支持 OpenAI image_url ↔ Anthropic image.source(base64/url) 双向转换
- messages→chat / responses→chat 不再丢弃图片块, 保留图文原始顺序
- chat→messages 的 data URL 拆成 base64 source(media_type+data)
- chat→responses 的 image_url 转 input_image
- responses↔messages 经 chat 中转, 六方向全覆盖
- 新增多图/顺序回归测试

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 09:04:09 +08:00
SakurasanandClaude 7f28c8b83b docs: 测试结果文件移出版本控制(保留磁盘参考)
- docs/*.txt 加入 gitignore, 测试结果不入库
- 移除已跟踪的 3 个测试文件(保留在工作区)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 05:09:05 +08:00
SakurasanandClaude 47f6a8daaa docs: 协议/工具调用/真实转换测试记录
- glm-tool-call-test.txt: glm-4.7-flash 工具调用, 三协议 × 流式/非流式(真实智谱)
- conversion-test.txt: 单一格式渠道走协议转换(mock 上游, 5 用例)
- glm-conversion-real-test.txt: 三个单一格式渠道 × 三种客户端协议互转(真实智谱, 9 用例)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 05:05:55 +08:00
SakurasanandClaude e96f194a59 转换器: 修复 Responses 流式 response.completed 缺 usage
- 上游 chat 流 finish_reason 块先于 usage 块, 原实现在 finish 时即发
  response.completed 导致 usage 为空; 改为等 usage 块到达(或 [DONE] 兜底)再发,
  completed 携带 input/output tokens

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 05:05:46 +08:00
SakurasanandClaude db83972b6f 渠道: 分协议 Base URL + 模型抽屉/远程拉取/操作图标
- 渠道支持分协议 base_url(chat/responses/messages 各一), 网关按协议选 base 直通
  (如智谱三种格式不同 base, 一个渠道即可), UpstreamURL 按 proto 拼接
- 渠道模型改为下方抽屉: 当前绑定列表(内联改上游/解除)、从接口拉取(remote 预览+勾选添加)、手动添加
- 新增 /channels/:id/models/remote 预览接口; 操作按钮加 Phosphor 图标
- 修复 formats jsonb 更新未序列化问题; 手机端渠道卡片化

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 04:31:28 +08:00
SakurasanandClaude 7c4e80afac 前端: 移动端适配 + 模型定价手动添加入口
- 表格加 min-w 窄屏横向滚动; 弹窗/资料网格窄屏换列; 顶栏/头部防溢出
- 渠道页移动卡片化、其余页面头部换行、Toast 自适应宽
- 模型定价页顶部加快捷输入(模型名直接进创建弹窗), 接口导入不全可手动补

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 04:31:14 +08:00
SakurasanandClaude 9324a782d5 Passkey: 账户设置绑定 + 免密登录(WebAuthn)
- 引入 go-webauthn, Passkey 表存凭据, challenge 会话内存存储(带过期)
- API: /webauthn/register|login begin/complete, /webauthn/passkeys 列表/删除
- 配置 OT_WEBAUTHN_RP_ID/RP_ORIGIN/RP_NAME;登录成功发 JWT+refresh cookie
- 前端 lib/webauthn(编解码+凭据序列化+安全上下文检测), 账户设置绑定区, 登录页免密按钮
- 需 HTTPS 或 localhost(安全上下文)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 02:00:08 +08:00
SakurasanandClaude 057b1b2c0b 侧栏: 导航项加 Phosphor 图标
- 用户菜单(仪表盘/API密钥/用量/账户设置/管理)与管理后台菜单(总览/渠道/模型/用户/用量/配置)
  均加对应图标, 跨区跳转项(管理→/返回控制台→)用常规字重+箭头区分

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 01:59:58 +08:00
SakurasanandClaude 40c1ae43e9 模型定价: 一致性检查与提示(渠道可用模型应全部纳入定价)
- /admin/models 返回每模型 used/needs_pricing/denied + summary(total/unpriced/missing)
- missing 检测孤儿绑定(渠道提供但目录缺失)
- 模型定价页: 缺失警告、在用未定价提示、已禁止/未定价徽章

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 01:34:22 +08:00
SakurasanandClaude b365779188 模型限制: 系统配置全局开放/禁止 + 用户级限制 + 网关拦截
- User 新增 allowed_models/denied_models(用户级模型限制)
- 系统配置 model_allowlist/model_denylist 全局策略, 保存即时失效网关缓存
- 网关 checkModelAllowed: 用户级 > 全局(禁止命中→403, 白名单非空→仅白名单)
- 三个协议处理器均校验, 错误按客户端协议格式返回
- 配置页"模型限制"卡片(全局允许/禁止多选); 用户编辑支持允许/禁止模型

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 01:34:12 +08:00
SakurasanandClaude 866690b7ce 用户管理: 编辑按钮高亮 + 图标
- "编辑"按钮改为高亮(accent 边框/浅底/强调色) + PhNotePencil 图标
- "调余额"按钮加 PhCoins 图标

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 01:34:00 +08:00
SakurasanandClaude 298bf89c90 用户管理: 编辑用户信息(用户名/邮箱/重置密码/角色/状态)
- AdminPatchUser 扩展 username/email/password 更新, 含唯一性与格式校验
- 编辑弹窗补充用户名/邮箱/重置密码字段(留空不改密码)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 00:57:15 +08:00
SakurasanandClaude 79f3f4395c 渠道: 支持手工录入模型(无 /models) + 模型映射自定义名称
- AdminChannelAddModel 改为 upstream_model + custom_name:
  无 /models 接口的渠道直接填上游模型名, 可选自定义名作为客户端调用名,
  全局模型不存在时自动创建
- "支持的模型"弹窗: 手工添加 + 建议下拉(自建,紧贴输入框)/水平排列/交换位置
- 网关仍按绑定映射改写请求 model 字段

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 00:57:06 +08:00
SakurasanandClaude 4d4d09ba58 渠道: 模型名称映射(网关生效 + 渠道侧管理)
- 候选渠道携带 upstream_model, prepareUpstream 改写请求体 model 字段为上游名
- 新增渠道视角绑定 CRUD: GET/POST/PATCH/DELETE /admin/channels/:id/models
- 前端渠道页新增"模型映射": 列出绑定、内联改上游名、解除、添加
- rewriteModel 三种协议通用(model 均在顶层)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 00:09:01 +08:00
SakurasanandClaude 8d9940ad5a 渠道: Base URL 智能识别前缀/完整端点 + API 格式垂直堆叠
- upstreamURL 按内容智能拼接: 完整端点直接用 / 含 /v1 只拼资源路径 / 纯域名拼全路径
- resolveBaseURL 不再去 /v1, 交网关识别
- 渠道表 API 格式改垂直堆叠, 显示短标识 chat/completions / responses / messages
- 前端 Base URL 提示支持前缀或完整端点

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 00:08:52 +08:00
SakurasanandClaude 512e46664c 导航: 统一分区侧栏 + 新增用量统计页
- ShellLayout 按所在分区显示独立菜单:
  用户控制台(/console) 与 管理后台(/admin) 不混排, 各带跨区跳转(管理→/返回控制台→)
- /console 与 /admin 共用 ShellLayout, 移除 ConsoleLayout/AdminLayout 薄包装
- 新增 /admin/usage 用量统计页(全局请求明细, 按模型过滤+分页)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 20:55:56 +08:00
SakurasanandClaude a8e2cd214c 账户: 修改密码接口 + 账户设置页
- 后端 POST /auth/password: 校验旧密码(argon2id)后重哈希更新
- 前端 /console/settings 账户设置页: 个人资料卡 + 修改密码表单

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 20:55:45 +08:00
SakurasanandClaude eb57a09c5d API 密钥: 支持编辑与高级选项, 修复 jsonb 白名单更新
- 前端密钥页新增"编辑": 改名/每日配额/模型白名单/状态切换
- 新建密钥展开"高级选项": 每日 Token/请求上限、模型白名单
- 修复 PATCH 更新 allowed_models(jsonb)不走序列化导致失败,
  改为 JSON 字符串写入, 跨 SQLite/Postgres 可靠

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 20:55:26 +08:00
SakurasanandClaude d0bc28a4fe 渠道: 移除主页/favicon 特性, Base URL 可选+完整地址
- 回退渠道 homepage/favicon 字段与代理接口(未采用)
- Base URL 改为可选: 留空按供应商默认(openai/anthropic),
  兼容兼容型渠道必须填; 填完整地址(含 /v1)时归一化去尾
- 网关 upstreamURL 兜底去 /v1, 避免路径重复

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 20:55:17 +08:00
SakurasanandClaude 4f92d7e0a4 渠道: 新增主页字段 + 表格展示 favicon
- Channel 新增 homepage 字段(渠道主页)
- 后端 /admin/channels/:id/favicon 代理抓取 {homepage}/favicon.ico, 内存缓存 1h;
  服务端抓取可解析 localhost/内网主页, 避免浏览器跨域
- 前端渠道表单加"渠道主页"输入; 表格新增主页列(favicon + 域名), 加载失败回退灰色地球图标
- mock 上游提供 /favicon.ico 演示

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 17:01:45 +08:00
SakurasanandClaude d9dcbc4fb3 渠道: 移除供应商下拉, 格式多选为唯一配置
- 前端渠道表单去掉"供应商"下拉, 只保留"支持的 API 格式"多选(默认 chat)
- 后端 provider 改为可选字段: 为空时按 formats 推断(仅 messages→anthropic,
  含 responses→openai, 否则 compatible), 兼容旧数据
- 保存时校验至少选择一种格式

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 16:55:48 +08:00
SakurasanandClaude 5eff32afd8 渠道支持多 API 格式 + 协议全名展示
- Channel 新增 formats(jsonb: chat|responses|messages), 一个渠道可同时声明
  支持 OpenAI Chat Completions / OpenAI Responses API / Anthropic Messages
- 路由改为按渠道声明的 formats 决定直通/转换: 客户端协议在 formats 内直通,
  否则转换为其首选支持格式(chat > messages > responses)
- 兼容旧数据: formats 为空时按 provider 推断(openai→chat+responses, anthropic→messages, compatible→chat)
- 前端: 渠道表/表单展示 API 格式(支持多选), usage 明细显示协议全名
  (OpenAI Chat Completions / OpenAI Responses API / Anthropic Messages)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 16:47:03 +08:00
SakurasanandClaude 6adadebaf0 前端: 修复浅色模式白字 + 主题优化
- 修 nav 激活态残留 text-zinc-50 导致浅色下白字白底
- @theme 令牌改实色字面量, 浅色用 [data-theme=light] 覆盖同一批 --color-*
- 新增 soft 令牌(accent/ok/warn/err, color-mix 运行时解析)替代烘焙的透明度修饰符,
  修复 badge 实色底、focus ring 实线等问题
- ThemeToggle 改三选下拉(浅色/深色/跟随系统), 系统自动为显式选项
- 浅色强调色调深保证 WCAG 对比度, 全量复查无残留硬编码颜色类

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 16:30:11 +08:00
SakurasanandClaude a422034b2b 前端: 浅色模式 + 系统自动主题
- 引入语义化 CSS 变量令牌(bg/surface/edge/ink/muted/accent 等), data-theme 三态切换
- theme store(localStorage + 系统偏好跟随), 首屏内联脚本防主题闪烁
- ThemeToggle 组件(浅色/深色/跟随系统) 置于导航与控制台顶栏
- 全量替换硬编码 zinc/emerald/red 类为令牌, 图表基线/状态色随主题适配
- 浅色对比度校准(accent/err/warn 深浅各一套)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 16:20:43 +08:00
SakurasanandClaude 4846db9293 M3 收尾: 限流/配额 + 前端骨架屏 + 单端口托管前端
- ratelimit(内存计数): 密钥级每日请求数/Token 配额、用户级每秒速率
  (OT_RATELIMIT_USER_RPS), 超限返回 429
- 网关 Auth 前置配额/限流检查, finishUsage 累计密钥 token 用量
- 前端 Skeleton 组件 + Dashboard/管理总览加载态
- Go 服务托管 web/dist 静态资源(SPA 回退), 单端口即可访问前后端

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 16:13:09 +08:00
86 changed files with 5972 additions and 952 deletions
+20
View File
@@ -0,0 +1,20 @@
# VCS
.git
.gitignore
# 依赖与构建产物(镜像内重新安装/构建)
**/node_modules
**/dist
server/bin
# 本地数据与密钥
data/
*.db
*.log
.env
.env.*
# 编辑器/系统
.DS_Store
.idea/
.vscode/
+15 -1
View File
@@ -27,13 +27,27 @@ OT_MASTER_KEY=change-me-master-key
OT_PROXY_UPSTREAM_KEY= OT_PROXY_UPSTREAM_KEY=
OT_PROXY_UPSTREAM_BASE_URL=https://api.openai.com OT_PROXY_UPSTREAM_BASE_URL=https://api.openai.com
OT_PROXY_DEFAULT_MODEL=gpt-4o-mini OT_PROXY_DEFAULT_MODEL=gpt-4o-mini
OT_PROXY_TIMEOUT=120s OT_PROXY_TIMEOUT=300s
# 渠道健康检查 # 渠道健康检查
OT_PROXY_HEALTH_INTERVAL=60s OT_PROXY_HEALTH_INTERVAL=60s
OT_PROXY_HEALTH_FAIL_THRESHOLD=2 OT_PROXY_HEALTH_FAIL_THRESHOLD=2
# 调试:记录管理员的原始请求与响应到请求明细(默认关闭;流式记录全部事件)
OT_PROXY_LOG_RAW=false
# 限流(内存计数,Redis 后置):用户级每秒请求数上限(0=不限制)
OT_RATELIMIT_USER_RPS=20
# Passkey(WebAuthn):RPID 为域名,RPOrigin 为前端来源(需 HTTPS 或 localhost)
OT_WEBAUTHN_RP_ID=localhost
OT_WEBAUTHN_RP_ORIGIN=http://localhost:5173
OT_WEBAUTHN_RP_NAME=openteam
# 初始管理员(仅首次创建生效) # 初始管理员(仅首次创建生效)
OT_ADMIN_USERNAME=admin OT_ADMIN_USERNAME=admin
OT_ADMIN_EMAIL=admin@localhost OT_ADMIN_EMAIL=admin@localhost
OT_ADMIN_PASSWORD=admin123 OT_ADMIN_PASSWORD=admin123
# 记录原始请求日志
OT_PROXY_LOG_RAW=false
+5
View File
@@ -7,6 +7,8 @@ dist/
# Go # Go
server/bin/ server/bin/
server/data/ server/data/
# 本地静态托管软链(指向 ../web/dist),不入库
server/web/
scripts/mockupstream/bin/ scripts/mockupstream/bin/
# TypeScript 增量构建产物 # TypeScript 增量构建产物
@@ -34,3 +36,6 @@ nohup.out
.idea/ .idea/
.vscode/ .vscode/
*.swp *.swp
# 测试结果记录(参考用, 不入库)
docs/*.txt
+53
View File
@@ -0,0 +1,53 @@
# openteam 多架构镜像(linux/amd64 + linux/arm64)
#
# 构建并推送:
# docker buildx build --platform linux/amd64,linux/arm64 -t openteam:latest --push .
# 本地单架构验证:
# docker build -t openteam:test .
#
# 前端产物 web/dist 是平台无关的纯静态文件,只编译一次。
# 关键在 web-builder 阶段用 --platform=$BUILDPLATFORM 固定为本机构建平台:
# buildx 做多架构时该阶段只在本机跑一次,两个架构的 Go 阶段分别 --from 拷贝同一份 dist。
# ---------- 阶段 1:前端构建(仅一次,产物平台无关) ----------
FROM --platform=$BUILDPLATFORM node:24-alpine AS web-builder
WORKDIR /src
# corepack 固定 pnpm 版本,配合 pnpm-lock.yaml 可复现安装。
# --ignore-scripts:依赖 postinstall(esbuild/vue-demi)非必需且被 pnpm 拦截,
# esbuild 走 optionalDependencies 自带平台二进制;跳过脚本更安全、可复现。
RUN corepack enable && corepack prepare pnpm@11.21.0 --activate
COPY web/package.json web/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --ignore-scripts
COPY web/ ./
RUN pnpm build
# ---------- 阶段 2:Go 后端编译(每个目标架构各执行一次) ----------
FROM golang:1.26-alpine AS go-builder
ARG TARGETOS
ARG TARGETARCH
WORKDIR /src
# 先拷依赖清单再装依赖,利用层缓存
COPY server/go.mod server/go.sum ./
RUN go mod download
COPY server/ ./
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -trimpath -ldflags="-s -w" -o /out/openteam ./cmd/server
# ---------- 阶段 3:运行镜像 ----------
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata su-exec
WORKDIR /app
COPY --from=go-builder /out/openteam ./openteam
# 与二进制同目录放置,符合服务 CWD 约定(router 按相对路径 web/dist 找静态资源)
COPY --from=web-builder /src/dist ./web/dist
# 入口脚本以 root 启动,chown 数据目录后 su-exec 降权为 OT_UID:OT_GID(默认 1000:1000)
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh && mkdir -p /app/data
ENV OT_ENV=production \
OT_PORT=8080 \
OT_DB_DRIVER=sqlite \
OT_DB_DSN=data/openteam.db
EXPOSE 8080
VOLUME ["/app/data"]
ENTRYPOINT ["/entrypoint.sh"]
CMD ["./openteam"]
+7 -1
View File
@@ -1,8 +1,14 @@
.PHONY: run build test tidy mock-upstream web-dev web-build .PHONY: run build test tidy mock-upstream web-dev web-build reset-admin-password reset-password
run: run:
cd server && go run ./cmd/server cd server && go run ./cmd/server
reset-admin-password:
cd server && go run ./cmd/server reset-admin-password
reset-password:
cd server && go run ./cmd/server reset-password
build: build:
cd server && go build -o bin/openteam ./cmd/server cd server && go build -o bin/openteam ./cmd/server
+4 -4
View File
@@ -275,8 +275,8 @@ Anthropic /v1/messages ──┘
#### 5.3.3 API Key #### 5.3.3 API Key
- 格式:`sk-` + 48 位随机 base62,**创建时仅展示一次**。 - 格式:`sk-ot-` + 48 位随机 base62,**创建时仅展示一次**。
- 存储:仅 SHA-256 哈希 + 展示前缀(如 `sk-aB3c…`);请求时哈希后查表。 - 存储:仅 SHA-256 哈希 + 展示前缀(如 `sk-ot-aB3c…`);请求时哈希后查表。
- 附加能力:密钥级配额(每日 token / 请求数)、模型白名单、过期时间、启停。 - 附加能力:密钥级配额(每日 token / 请求数)、模型白名单、过期时间、启停。
- 限额检查用 Redis 计数,与用户级限流叠加。 - 限额检查用 Redis 计数,与用户级限流叠加。
@@ -461,8 +461,8 @@ pending(待审核) ──approve──▶ credited(已入账)
- 已过 web-design-guidelines 复查并修复(移动端侧栏、表格横向滚动、模态框焦点/滚动锁、focus-visible、aria 等)。 - 已过 web-design-guidelines 复查并修复(移动端侧栏、表格横向滚动、模态框焦点/滚动锁、focus-visible、aria 等)。
**验收**:用户在控制台建 key、发请求、看用量;管理员能加渠道、调价、看统计。 **验收**:用户在控制台建 key、发请求、看用量;管理员能加渠道、调价、看统计。
### M3 管理后台前端 + 计费完善(◻ 部分完成) ### M3 管理后台前端 + 计费完善(✅ 完成收尾)
渠道/模型/用户/总览/配置页面已完成;`usage_daily` 趋势图已内建(自建 SVG)。待做:限流接入、加载骨架屏、按模型聚合报表增强。 渠道/模型/用户/总览/配置页面已完成;`usage_daily` 趋势图已内建(自建 SVG);限流/配额接入(内存计数:密钥每日请求/token 配额、用户级速率,超限 429);Dashboard/总览骨架屏加载态。待做:Redis 化限流、按模型聚合报表增强。
### M4 协议转换(✅ 已完成) ### M4 协议转换(✅ 已完成)
- `convert` 包:Chat↔Messages↔Responses 请求/响应 JSON 转换 + 流式 SSE 逐行状态机转换器(含单测)。 - `convert` 包:Chat↔Messages↔Responses 请求/响应 JSON 转换 + 流式 SSE 逐行状态机转换器(含单测)。
+4 -2
View File
@@ -2,7 +2,7 @@
自托管的 LLM API 中转网关,功能对标 OpenRouter / one-api:统一 OpenAI 与 Anthropic 协议入口,背后对接多个上游渠道,内置用户体系、API Key 管理与用量计费。 自托管的 LLM API 中转网关,功能对标 OpenRouter / one-api:统一 OpenAI 与 Anthropic 协议入口,背后对接多个上游渠道,内置用户体系、API Key 管理与用量计费。
> 规划文档见 [PLANNING.md](./PLANNING.md)。当前进度:**M0-M2 + M4-M5 已完成**(基建 + 用户/密钥/核心代理 + 前端 MVP + 管理后台基础 + 三协议互转 + 渠道体系)。 > 规划文档见 [PLANNING.md](./PLANNING.md)。当前进度:**M0-M2 + M4-M5 + M3 收尾已完成**(基建 + 用户/密钥/核心代理 + 前端 MVP + 管理后台基础 + 三协议互转 + 渠道体系 + 限流/配额 + 前端骨架屏)。
## 功能(当前) ## 功能(当前)
@@ -14,8 +14,10 @@
- **三协议互转**:客户端协议 × 渠道协议不匹配时自动转换(如 Chat 调用 Claude、Messages 调用 OpenAI、Responses 调用 Claude),流式逐事件转换;协议匹配时直通 - **三协议互转**:客户端协议 × 渠道协议不匹配时自动转换(如 Chat 调用 Claude、Messages 调用 OpenAI、Responses 调用 Claude),流式逐事件转换;协议匹配时直通
- 错误按客户端协议返回(OpenAI 格式 / Anthropic 格式) - 错误按客户端协议返回(OpenAI 格式 / Anthropic 格式)
- **渠道体系**:按模型绑定选渠道 + 加权负载均衡;每渠道并发信号量(满载溢出);后台健康检查(连续失败进 cooldown、恢复放回);可安全重试的失败自动故障转移(网络错误/429/5xx/超时且未写出响应头) - **渠道体系**:按模型绑定选渠道 + 加权负载均衡;每渠道并发信号量(满载溢出);后台健康检查(连续失败进 cooldown、恢复放回);可安全重试的失败自动故障转移(网络错误/429/5xx/超时且未写出响应头)
- **限流/配额**(内存计数):密钥级每日请求数 / Token 数配额、用户级每秒速率(`OT_RATELIMIT_USER_RPS`);超限返回 429
- **前端**:Dashboard / 管理总览骨架屏加载态
- **用户体系**:注册(开放/邀请码可切换,管理后台可改)、登录(JWT access + HttpOnly refresh cookie)、argon2id 密码 - **用户体系**:注册(开放/邀请码可切换,管理后台可改)、登录(JWT access + HttpOnly refresh cookie)、argon2id 密码
- **API Key**:`sk-` 48 位 base62,仅存 SHA-256 哈希,明文一次性展示;支持限额/过期/白名单字段 - **API Key**:`sk-ot-` 48 位 base62,仅存 SHA-256 哈希,明文一次性展示;支持限额/过期/白名单字段
- **用量计费**:请求级 `usage_logs` 异步批量落库,按模型价格扣减余额,日粒度预聚合(`usage_daily`) - **用量计费**:请求级 `usage_logs` 异步批量落库,按模型价格扣减余额,日粒度预聚合(`usage_daily`)
- **管理 API**:渠道 CRUD + 连通测试 + 模型导入、模型管理 + 定价 + 渠道绑定、用户管理、全局用量/统计、系统配置 - **管理 API**:渠道 CRUD + 连通测试 + 模型导入、模型管理 + 定价 + 渠道绑定、用户管理、全局用量/统计、系统配置
- **前端**(Vue3 + Tailwind,taste-skill 设计,深色优先) - **前端**(Vue3 + Tailwind,taste-skill 设计,深色优先)
+40
View File
@@ -0,0 +1,40 @@
# openteam 自托管部署
#
# 启动:docker compose up -d
# 首次会本地构建镜像(见 Dockerfile);若已用 build-image.sh 推到仓库,
# 把 image 改为仓库地址并删掉 build 段即可拉取多架构镜像。
#
# 密钥从仓库根 .env 读取(与本地开发共用),缺失时 compose 直接报错。
name: openteam
services:
openteam:
build:
context: .
dockerfile: Dockerfile
image: openteam:latest
container_name: openteam
restart: unless-stopped
ports:
- "${HOST_PORT:-8080}:8080"
environment:
OT_ENV: production
OT_PORT: 8080
# sqlite 落盘到当前目录 ./data(容器 WORKDIR=/app,DSN 用相对路径)
OT_DB_DRIVER: sqlite
OT_DB_DSN: data/openteam.db
# 必填密钥:渠道加密主密钥 / 管理员密码 / JWT 签名
OT_MASTER_KEY: ${OT_MASTER_KEY:?请在 .env 中设置 OT_MASTER_KEY}
OT_ADMIN_PASSWORD: ${OT_ADMIN_PASSWORD:?请在 .env 中设置 OT_ADMIN_PASSWORD}
OT_JWT_SECRET: ${OT_JWT_SECRET:?请在 .env 中设置 OT_JWT_SECRET}
# 入口脚本据此 chown 数据目录并降权运行(默认 1000:1000)
OT_UID: "${OT_UID:-1000}"
OT_GID: "${OT_GID:-1000}"
volumes:
- ./data:/app/data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# openteam 容器入口:以 root 短暂启动,修正数据目录属主后降权执行服务。
# 解决绑定挂载/命名卷被 docker 以 root 自动创建、应用非 root 用户写不进 db 的问题。
set -e
PUID="${OT_UID:-1000}"
PGID="${OT_GID:-1000}"
mkdir -p /app/data
chown -R "${PUID}:${PGID}" /app/data
exec su-exec "${PUID}:${PGID}" "$@"
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
set -euo pipefail
DOCKER_USER="${DOCKER_USER:-openteam}"
VERSION="${VERSION:-$(git describe --tags --always 2>/dev/null || echo latest)}"
PLATFORMS="linux/amd64,linux/arm64"
# --local:本地构建不推送(默认 docker driver,单架构,tag 不带仓库前缀)。
# 不用 buildx docker-container builder——嵌套创建容器在受限环境会报
# "can't mask dir /proc/acpi",普通 docker build 无此问题。
if [[ "${1:-}" == "--local" ]]; then
LOCAL=1
else
LOCAL=0
fi
if [[ $# -gt 1 || ( $# -eq 1 && "$1" != "--local" ) ]]; then
echo "用法: $0 [--local]" >&2
exit 1
fi
native_arch() {
case "$(uname -m)" in
x86_64|amd64) echo "linux/amd64" ;;
aarch64|arm64) echo "linux/arm64" ;;
*) echo "linux/$(uname -m)" ;;
esac
}
# ---------- 本地模式:单架构,加载到本地 docker ----------
if [[ $LOCAL -eq 1 ]]; then
PLATFORM="$(native_arch)"
echo ""
echo "========================================"
echo "🚀 本地构建 openteam (${PLATFORM})"
echo " tag: openteam:${VERSION}"
echo "========================================"
docker build \
-t "openteam:${VERSION}" \
-t "openteam:latest" \
.
echo ""
echo "✅ 完成!openteam:${VERSION}(已加载到本地 docker)"
exit 0
fi
# ---------- 多架构推送模式:需要 buildx docker-container builder ----------
if ! docker buildx version &>/dev/null; then
echo "❌ docker buildx 不可用"
exit 1
fi
if ! docker buildx inspect multiarch &>/dev/null 2>&1; then
echo "🔧 创建 multiarch builder..."
docker buildx create --name multiarch --driver docker-container --use
else
docker buildx use multiarch
fi
docker buildx inspect --bootstrap
echo ""
echo "========================================"
echo "🚀 构建 openteam (${PLATFORMS})"
echo " tag: ${DOCKER_USER}/openteam:${VERSION}"
echo "========================================"
docker buildx build \
--platform "${PLATFORMS}" \
-t "${DOCKER_USER}/openteam:${VERSION}" \
-t "${DOCKER_USER}/openteam:latest" \
--push \
.
echo ""
echo "✅ 完成!${DOCKER_USER}/openteam:${VERSION}"
+2 -54
View File
@@ -2,60 +2,8 @@
// 启动:OT_PROXY_UPSTREAM_KEY=sk-xxx go run ./cmd/server // 启动:OT_PROXY_UPSTREAM_KEY=sk-xxx go run ./cmd/server
package main package main
import ( import "github.com/openteam/server/internal/cli"
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"github.com/openteam/server/internal/api"
"github.com/openteam/server/internal/app"
"github.com/openteam/server/internal/config"
"github.com/openteam/server/internal/proxy"
)
func main() { func main() {
cfg, err := config.Load() cli.Execute()
if err != nil {
log.Fatalf("config: %v", err)
}
a, err := app.New(cfg)
if err != nil {
log.Fatalf("app init: %v", err)
}
defer a.Shutdown(context.Background())
gw := proxy.NewGateway(a.DB, a.Enc, a.Usage)
router := api.NewRouter(a, gw)
srv := &http.Server{
Addr: ":" + strconv.Itoa(cfg.Port),
Handler: router,
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
log.Printf("openteam listening on %s (env=%s)", srv.Addr, cfg.Env)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("server shutdown: %v", err)
}
a.Usage.Close()
} }
+15 -3
View File
@@ -1,13 +1,17 @@
module github.com/openteam/server module github.com/openteam/server
go 1.25.0 go 1.26
require ( require (
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/go-webauthn/webauthn v0.17.4
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
github.com/tiktoken-go/tokenizer v0.8.1
golang.org/x/crypto v0.55.0 golang.org/x/crypto v0.55.0
golang.org/x/term v0.45.0
gorm.io/driver/postgres v1.6.2 gorm.io/driver/postgres v1.6.2
gorm.io/gorm v1.31.2 gorm.io/gorm v1.31.2
) )
@@ -17,18 +21,23 @@ require (
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dlclark/regexp2/v2 v2.5.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.3.0 // indirect github.com/google/go-tpm v0.9.8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.10.0 // indirect github.com/jackc/pgx/v5 v5.10.0 // indirect
@@ -42,6 +51,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@@ -51,8 +61,10 @@ require (
github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.22.0 // indirect golang.org/x/arch v0.22.0 // indirect
+33 -4
View File
@@ -6,15 +6,20 @@ github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiD
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
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/v2 v2.5.1 h1:E5Ug7Dh264W1ymdySmiHNcDG7fmsR307APCE5R07a20=
github.com/dlclark/regexp2/v2 v2.5.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 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/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
@@ -33,8 +38,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
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/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
@@ -43,11 +52,17 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
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.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
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-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -81,6 +96,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
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/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
@@ -92,6 +109,7 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
@@ -100,6 +118,9 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
@@ -118,10 +139,16 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tiktoken-go/tokenizer v0.8.1 h1:4obDoB6/dhdBt9xMweX4nww5cjdOq/nYF4ecwPq2+mg=
github.com/tiktoken-go/tokenizer v0.8.1/go.mod h1:eLA0t6nGvn9mDc7gt90qt7pMat+gE9ViqwQ6l9B+tA4=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
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.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
@@ -139,6 +166,8 @@ golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
+60
View File
@@ -3,7 +3,9 @@ package api
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/mail"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -46,14 +48,59 @@ func (h *Handler) AdminPatchUser(c *gin.Context) {
return return
} }
var req struct { var req struct {
Username *string `json:"username"`
Email *string `json:"email"`
Password *string `json:"password"`
Role *string `json:"role"` Role *string `json:"role"`
Status *string `json:"status"` Status *string `json:"status"`
AllowedModels *[]string `json:"allowed_models"`
DeniedModels *[]string `json:"denied_models"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid input") resp.Fail(c, http.StatusBadRequest, "invalid input")
return return
} }
updates := map[string]any{} updates := map[string]any{}
if req.Username != nil {
u := strings.TrimSpace(*req.Username)
if len(u) < 3 || len(u) > 32 {
resp.Fail(c, http.StatusBadRequest, "username must be 3-32 chars")
return
}
var n int64
h.a.DB.Model(&store.User{}).Where("username = ? AND id != ?", u, id).Count(&n)
if n > 0 {
resp.Fail(c, http.StatusConflict, "username already taken")
return
}
updates["username"] = u
}
if req.Email != nil {
e := strings.ToLower(strings.TrimSpace(*req.Email))
if _, err := mail.ParseAddress(e); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid email")
return
}
var n int64
h.a.DB.Model(&store.User{}).Where("email = ? AND id != ?", e, id).Count(&n)
if n > 0 {
resp.Fail(c, http.StatusConflict, "email already taken")
return
}
updates["email"] = e
}
if req.Password != nil && *req.Password != "" {
if len(*req.Password) < 8 {
resp.Fail(c, http.StatusBadRequest, "password must be at least 8 chars")
return
}
hash, err := h.a.Hasher.HashPassword(*req.Password)
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to hash password")
return
}
updates["password_hash"] = hash
}
if req.Role != nil { if req.Role != nil {
if *req.Role != store.RoleUser && *req.Role != store.RoleAdmin { if *req.Role != store.RoleUser && *req.Role != store.RoleAdmin {
resp.Fail(c, http.StatusBadRequest, "role must be user or admin") resp.Fail(c, http.StatusBadRequest, "role must be user or admin")
@@ -68,6 +115,15 @@ func (h *Handler) AdminPatchUser(c *gin.Context) {
} }
updates["status"] = *req.Status updates["status"] = *req.Status
} }
// 模型限制(jsonb):手动序列化
if req.AllowedModels != nil {
raw, _ := json.Marshal(*req.AllowedModels)
updates["allowed_models"] = string(raw)
}
if req.DeniedModels != nil {
raw, _ := json.Marshal(*req.DeniedModels)
updates["denied_models"] = string(raw)
}
if len(updates) == 0 { if len(updates) == 0 {
resp.OK(c, gin.H{"ok": true}) resp.OK(c, gin.H{"ok": true})
return return
@@ -166,5 +222,9 @@ func (h *Handler) AdminPutConfig(c *gin.Context) {
resp.Fail(c, http.StatusInternalServerError, "failed to save config") resp.Fail(c, http.StatusInternalServerError, "failed to save config")
return return
} }
// 模型限制等策略可能变化,立即失效缓存
if h.gw != nil {
h.gw.ResetModelPolicy()
}
resp.OK(c, gin.H{"ok": true}) resp.OK(c, gin.H{"ok": true})
} }
+190
View File
@@ -0,0 +1,190 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/pkg/resp"
"github.com/openteam/server/internal/store"
)
// AdminChannelRemoteModels GET /api/v1/admin/channels/:id/models/remote
// 拉取渠道接口的模型列表,返回本渠道尚未允许的模型(新增候选)。
// 每个渠道有各自的支持列表:只排除本渠道已允许的模型,其他渠道允许的同名模型仍可作为本渠道候选。
func (h *Handler) AdminChannelRemoteModels(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid channel id")
return
}
var ch store.Channel
if err := h.a.DB.First(&ch, id).Error; err != nil {
resp.Fail(c, http.StatusNotFound, "channel not found")
return
}
key, err := h.a.Enc.Decrypt(ch.APIKeyEnc)
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key")
return
}
client := &http.Client{Timeout: 15 * time.Second}
req, _ := http.NewRequest(http.MethodGet, ch.UpstreamURL("", "/models"), nil)
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Accept", "application/json")
resp2, err := client.Do(req)
if err != nil {
resp.Fail(c, http.StatusBadGateway, "failed to reach channel: "+err.Error())
return
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
resp.Fail(c, http.StatusBadGateway, "channel returned http "+strconv.Itoa(resp2.StatusCode))
return
}
var list struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.NewDecoder(resp2.Body).Decode(&list); err != nil {
resp.Fail(c, http.StatusBadGateway, "failed to parse model list")
return
}
// 本渠道已允许的上游模型名:不作为新增候选(其他渠道的模型仍可勾选)
var boundNames []string
h.a.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
}
items := make([]string, 0, len(list.Data))
for _, m := range list.Data {
name := strings.TrimSpace(m.ID)
if name != "" && !boundSet[name] {
items = append(items, name)
}
}
resp.OK(c, gin.H{"items": items})
}
// AdminChannelModels GET /api/v1/admin/channels/:id/models — 渠道的模型绑定列表(含上游映射名)。
func (h *Handler) AdminChannelModels(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid channel id")
return
}
var bindings []store.ChannelModelBinding
h.a.DB.Preload("Model").Where("channel_id = ?", id).Order("id ASC").Find(&bindings)
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,
})
}
resp.OK(c, gin.H{"items": out})
}
// AdminChannelAddModel POST /api/v1/admin/channels/:id/models — 手工添加渠道支持的模型。
// 无需渠道具备 /v1/models 接口:直接填上游模型名,可选自定义名称作为客户端调用名。
func (h *Handler) AdminChannelAddModel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid channel id")
return
}
var 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 {
resp.Fail(c, http.StatusBadRequest, "invalid input")
return
}
globalName := req.CustomName
if globalName == "" {
globalName = req.UpstreamModel
}
// 解析或创建全局模型(客户端名)
var m store.Model
if err := h.a.DB.Where("name = ?", globalName).First(&m).Error; err != nil {
m = store.Model{Name: globalName, Enabled: true}
if err := h.a.DB.Create(&m).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to create model")
return
}
}
b := store.ChannelModelBinding{
ChannelID: id, ModelID: m.ID, UpstreamModel: req.UpstreamModel, Weight: intOr(req.Weight, 1),
}
if err := h.a.DB.Create(&b).Error; err != nil {
resp.Fail(c, http.StatusConflict, "binding may already exist")
return
}
resp.Created(c, gin.H{"id": b.ID, "model_id": m.ID, "model_name": m.Name, "upstream_model": req.UpstreamModel, "weight": b.Weight})
}
// AdminChannelUpdateModel PATCH /api/v1/admin/channels/:id/models/:bid — 改映射名/权重。
func (h *Handler) AdminChannelUpdateModel(c *gin.Context) {
bid, err := strconv.ParseUint(c.Param("bid"), 10, 64)
if err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid binding id")
return
}
var req struct {
UpstreamModel *string `json:"upstream_model"`
Weight *int `json:"weight"`
}
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid input")
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 {
res := h.a.DB.Model(&store.ChannelModelBinding{}).Where("id = ?", bid).Updates(updates)
if res.Error != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to update binding")
return
}
if res.RowsAffected == 0 {
resp.Fail(c, http.StatusNotFound, "binding not found")
return
}
}
resp.OK(c, gin.H{"ok": true})
}
// AdminChannelDeleteModel DELETE /api/v1/admin/channels/:id/models/:bid — 解除绑定。
func (h *Handler) AdminChannelDeleteModel(c *gin.Context) {
bid, err := strconv.ParseUint(c.Param("bid"), 10, 64)
if err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid binding id")
return
}
res := h.a.DB.Delete(&store.ChannelModelBinding{}, bid)
if res.Error != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to delete binding")
return
}
if res.RowsAffected == 0 {
resp.Fail(c, http.StatusNotFound, "binding not found")
return
}
resp.OK(c, gin.H{"ok": true})
}
+148 -88
View File
@@ -13,8 +13,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/openteam/server/internal/pkg/resp" "github.com/openteam/server/internal/pkg/resp"
"github.com/openteam/server/internal/store" "github.com/openteam/server/internal/store"
"gorm.io/gorm"
"gorm.io/gorm/clause"
) )
// AdminChannels GET /api/v1/admin/channels — 渠道列表(不返回加密 key,返回掩码)。 // AdminChannels GET /api/v1/admin/channels — 渠道列表(不返回加密 key,返回掩码)。
@@ -28,12 +26,13 @@ func (h *Handler) AdminChannels(c *gin.Context) {
for _, ch := range chs { for _, ch := range chs {
masked := "" masked := ""
if key, err := h.a.Enc.Decrypt(ch.APIKeyEnc); err == nil && len(key) > 8 { if key, err := h.a.Enc.Decrypt(ch.APIKeyEnc); err == nil && len(key) > 8 {
masked = strings.Repeat("*", len(key)-4) + key[len(key)-4:] masked = maskAPIKey(key)
} else if err == nil { } else if err == nil {
masked = "****" masked = "****"
} }
out = append(out, gin.H{ out = append(out, gin.H{
"id": ch.ID, "name": ch.Name, "provider": ch.Provider, "base_url": ch.BaseURL, "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, "api_key_masked": masked, "weight": ch.Weight, "priority": ch.Priority,
"timeout_ms": ch.TimeoutMS, "max_concurrency": ch.MaxConcurrency, "timeout_ms": ch.TimeoutMS, "max_concurrency": ch.MaxConcurrency,
"health_status": ch.HealthStatus, "enabled": ch.Enabled, "health_status": ch.HealthStatus, "enabled": ch.Enabled,
@@ -45,8 +44,10 @@ func (h *Handler) AdminChannels(c *gin.Context) {
type channelBody struct { type channelBody struct {
Name string `json:"name" binding:"required,min=1,max=64"` Name string `json:"name" binding:"required,min=1,max=64"`
Provider string `json:"provider" binding:"required"` Provider string `json:"provider"` // 可选:为空时按 formats 推断(兼容旧数据)
BaseURL string `json:"base_url" binding:"required"` Formats []string `json:"formats"` // 原生支持的协议 chat|responses|messages(主配置)
BaseURL string `json:"base_url"` // 可选:留空按供应商默认;支持前缀或完整端点
BaseURLs map[string]string `json:"base_urls"` // 分协议 base_url 覆盖(chat/responses/messages)
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
Weight *int `json:"weight"` Weight *int `json:"weight"`
Priority *int `json:"priority"` Priority *int `json:"priority"`
@@ -55,10 +56,97 @@ type channelBody struct {
Enabled *bool `json:"enabled"` 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 { func validateProvider(p string) bool {
return p == store.ChannelProviderOpenAI || p == store.ChannelProviderAnthropic || p == store.ChannelProviderCompatible 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/v1/admin/channels // AdminCreateChannel POST /api/v1/admin/channels
func (h *Handler) AdminCreateChannel(c *gin.Context) { func (h *Handler) AdminCreateChannel(c *gin.Context) {
var req channelBody var req channelBody
@@ -66,6 +154,9 @@ func (h *Handler) AdminCreateChannel(c *gin.Context) {
resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error()) resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error())
return return
} }
if req.Provider == "" {
req.Provider = deriveProvider(req.Formats)
}
if !validateProvider(req.Provider) { if !validateProvider(req.Provider) {
resp.Fail(c, http.StatusBadRequest, "provider must be openai, anthropic or compatible") resp.Fail(c, http.StatusBadRequest, "provider must be openai, anthropic or compatible")
return return
@@ -74,15 +165,26 @@ func (h *Handler) AdminCreateChannel(c *gin.Context) {
resp.Fail(c, http.StatusBadRequest, "api_key required") resp.Fail(c, http.StatusBadRequest, "api_key required")
return return
} }
formats, err := resolveFormats(req.Provider, req.Formats)
if err != nil {
resp.Fail(c, http.StatusBadRequest, err.Error())
return
}
baseURL, err := resolveBaseURL(req.Provider, req.BaseURL)
if err != nil {
resp.Fail(c, http.StatusBadRequest, err.Error())
return
}
enc, err := h.a.Enc.Encrypt(req.APIKey) enc, err := h.a.Enc.Encrypt(req.APIKey)
if err != nil { if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to encrypt api key") resp.Fail(c, http.StatusInternalServerError, "failed to encrypt api key")
return return
} }
ch := store.Channel{ ch := store.Channel{
Name: req.Name, Provider: req.Provider, BaseURL: strings.TrimRight(req.BaseURL, "/"), 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), APIKeyEnc: enc, Weight: intOr(req.Weight, 1), Priority: intOr(req.Priority, 0),
TimeoutMS: intOr(req.TimeoutMS, 120000), MaxConcurrency: intOr(req.MaxConcurrency, 16), TimeoutMS: intOr(req.TimeoutMS, 300000), MaxConcurrency: intOr(req.MaxConcurrency, 16),
HealthStatus: store.ChannelHealthHealthy, Enabled: boolOr(req.Enabled, true), HealthStatus: store.ChannelHealthHealthy, Enabled: boolOr(req.Enabled, true),
} }
if err := h.a.DB.Create(&ch).Error; err != nil { if err := h.a.DB.Create(&ch).Error; err != nil {
@@ -102,7 +204,9 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
var body struct { var body struct {
Name *string `json:"name"` Name *string `json:"name"`
Provider *string `json:"provider"` Provider *string `json:"provider"`
Formats *[]string `json:"formats"`
BaseURL *string `json:"base_url"` BaseURL *string `json:"base_url"`
BaseURLs *map[string]string `json:"base_urls"`
APIKey *string `json:"api_key"` APIKey *string `json:"api_key"`
Weight *int `json:"weight"` Weight *int `json:"weight"`
Priority *int `json:"priority"` Priority *int `json:"priority"`
@@ -132,7 +236,21 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
updates["provider"] = *body.Provider updates["provider"] = *body.Provider
} }
if body.BaseURL != nil { if body.BaseURL != nil {
updates["base_url"] = strings.TrimRight(*body.BaseURL, "/") prov := ch.Provider
if body.Provider != nil {
prov = *body.Provider
}
b, berr := resolveBaseURL(prov, *body.BaseURL)
if berr != nil {
resp.Fail(c, http.StatusBadRequest, berr.Error())
return
}
updates["base_url"] = b
}
if body.BaseURLs != nil {
// base_urls 是 jsonb:手动序列化
raw, _ := json.Marshal(normalizeBaseURLs(*body.BaseURLs))
updates["base_urls"] = string(raw)
} }
if body.APIKey != nil && *body.APIKey != "" { if body.APIKey != nil && *body.APIKey != "" {
enc, err := h.a.Enc.Encrypt(*body.APIKey) enc, err := h.a.Enc.Encrypt(*body.APIKey)
@@ -160,6 +278,20 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
if body.Enabled != nil { if body.Enabled != nil {
updates["enabled"] = *body.Enabled 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 {
resp.Fail(c, http.StatusBadRequest, ferr.Error())
return
}
// formats 是 jsonb:手动序列化为 JSON 字符串(map 更新不走序列化)
raw, _ := json.Marshal(formats)
updates["formats"] = string(raw)
}
if len(updates) > 0 { if len(updates) > 0 {
if err := h.a.DB.Model(&ch).Updates(updates).Error; err != nil { if err := h.a.DB.Model(&ch).Updates(updates).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to update channel") resp.Fail(c, http.StatusInternalServerError, "failed to update channel")
@@ -207,7 +339,7 @@ func (h *Handler) AdminTestChannel(c *gin.Context) {
resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key") resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key")
return return
} }
url := strings.TrimRight(ch.BaseURL, "/") + "/v1/models" url := ch.UpstreamURL("", "/models")
client := &http.Client{Timeout: 10 * time.Second} client := &http.Client{Timeout: 10 * time.Second}
req, _ := http.NewRequest(http.MethodGet, url, nil) req, _ := http.NewRequest(http.MethodGet, url, nil)
req.Header.Set("Authorization", "Bearer "+key) req.Header.Set("Authorization", "Bearer "+key)
@@ -237,87 +369,15 @@ func (h *Handler) AdminTestChannel(c *gin.Context) {
resp.OK(c, gin.H{"ok": true, "latency_ms": latency, "message": msg}) resp.OK(c, gin.H{"ok": true, "latency_ms": latency, "message": msg})
} }
// AdminImportChannelModels POST /api/v1/admin/channels/:id/models/import // maskAPIKey 掩码渠道密钥:保留前 7 位与后 4 位,中间固定 ****** 遮蔽。
// 拉取渠道 GET /v1/models,导入模型库并绑定。 // 示例:xxxxxxx******Mq4Y;密钥较短时退化为仅保留后 4 位。
func (h *Handler) AdminImportChannelModels(c *gin.Context) { func maskAPIKey(key string) string {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) if len(key) <= 11 {
if err != nil { return strings.Repeat("*", len(key)-4) + key[len(key)-4:]
resp.Fail(c, http.StatusBadRequest, "invalid channel id")
return
} }
var ch store.Channel return key[:7] + "******" + key[len(key)-4:]
if err := h.a.DB.First(&ch, id).Error; err != nil {
resp.Fail(c, http.StatusNotFound, "channel not found")
return
}
key, err := h.a.Enc.Decrypt(ch.APIKeyEnc)
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key")
return
}
url := strings.TrimRight(ch.BaseURL, "/") + "/v1/models"
client := &http.Client{Timeout: 15 * time.Second}
req, _ := http.NewRequest(http.MethodGet, url, nil)
req.Header.Set("Authorization", "Bearer "+key)
resp2, err := client.Do(req)
if err != nil {
resp.Fail(c, http.StatusBadGateway, "failed to reach channel: "+err.Error())
return
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusOK {
resp.Fail(c, http.StatusBadGateway, "channel returned http "+strconv.Itoa(resp2.StatusCode))
return
}
var list struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.NewDecoder(resp2.Body).Decode(&list); err != nil {
resp.Fail(c, http.StatusBadGateway, "failed to parse model list")
return
}
if len(list.Data) == 0 {
resp.Fail(c, http.StatusNotFound, "channel returned no models")
return
}
imported := 0
err = h.a.DB.Transaction(func(tx *gorm.DB) error {
for _, item := range list.Data {
name := strings.TrimSpace(item.ID)
if name == "" {
continue
}
var m store.Model
if err := tx.Where("name = ?", name).FirstOrCreate(&m, store.Model{
Name: name, DisplayName: name, Enabled: true,
}).Error; err != nil {
return err
}
// upsert 绑定(upstream_model 默认同名)
var binding store.ChannelModelBinding
err := tx.Where("channel_id = ? AND model_id = ?", ch.ID, m.ID).First(&binding).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
binding = store.ChannelModelBinding{ChannelID: ch.ID, ModelID: m.ID, UpstreamModel: name, Weight: 1}
if err := tx.Create(&binding).Error; err != nil {
return err
}
}
imported++
}
return nil
})
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to import models")
return
}
resp.OK(c, gin.H{"imported": imported})
} }
var _ = clause.Assignments // 保留 gorm/clause 引用(后续定价批处理用)
func intOr(p *int, def int) int { func intOr(p *int, def int) int {
if p == nil { if p == nil {
return def return def
+105 -13
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"encoding/json"
"errors" "errors"
"net/http" "net/http"
"strconv" "strconv"
@@ -11,38 +12,113 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// AdminModels GET /api/v1/admin/models — 模型列表(含价格与渠道绑定)。 // AdminModels GET /api/v1/admin/models — 模型列表(含价格、渠道绑定、定价/禁止状态)。
func (h *Handler) AdminModels(c *gin.Context) { func (h *Handler) AdminModels(c *gin.Context) {
var ms []store.Model var ms []store.Model
if err := h.a.DB.Order("sort ASC, id ASC").Find(&ms).Error; err != nil { if err := h.a.DB.Order("sort ASC, id ASC").Find(&ms).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to load models") resp.Fail(c, http.StatusInternalServerError, "failed to load models")
return return
} }
// 全局模型限制策略
allow, deny := h.modelPolicyConfig()
out := make([]gin.H, 0, len(ms)) out := make([]gin.H, 0, len(ms))
for _, m := range ms { for _, m := range ms {
var bindings []store.ChannelModelBinding var bindings []store.ChannelModelBinding
h.a.DB.Preload("Channel").Where("model_id = ?", m.ID).Find(&bindings) h.a.DB.Preload("Channel").Where("model_id = ?", m.ID).Find(&bindings)
chs := make([]gin.H, 0, len(bindings)) chs := make([]gin.H, 0, len(bindings))
for _, b := range bindings { for _, b := range bindings {
if !b.Channel.Enabled {
continue
}
chs = append(chs, gin.H{ chs = append(chs, gin.H{
"id": b.ID, "channel_id": b.ChannelID, "channel_name": b.Channel.Name, "id": b.ID, "channel_id": b.ChannelID, "channel_name": b.Channel.Name,
"upstream_model": b.UpstreamModel, "weight": b.Weight, "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{ out = append(out, gin.H{
"id": m.ID, "name": m.Name, "display_name": m.DisplayName, "id": m.ID, "name": m.Name,
"input_price": m.InputPrice, "output_price": m.OutputPrice, "cache_read_price": m.CacheReadPrice, "input_price": m.InputPrice, "output_price": m.OutputPrice, "cache_read_price": m.CacheReadPrice,
"enabled": m.Enabled, "sort": m.Sort, "channels": chs, "enabled": m.Enabled, "sort": m.Sort, "channels": chs,
"used": used, "needs_pricing": needsPricing, "denied": denied,
}) })
} }
resp.OK(c, gin.H{"items": out})
// 渠道选中但目录中缺失的模型(孤儿绑定:渠道绑定指向已被删除的模型)
var orphans []struct {
ChannelName string
UpstreamModel string
ModelID uint64
}
h.a.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.a.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++
}
}
}
resp.OK(c, gin.H{
"items": 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.a.DB.Model(&store.SystemConfig{}).Where("key = ?", "model_allowlist").Pluck("value", &raw)
_ = json.Unmarshal([]byte(raw), &allow)
raw = ""
h.a.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/v1/admin/models // AdminCreateModel POST /api/v1/admin/models
func (h *Handler) AdminCreateModel(c *gin.Context) { func (h *Handler) AdminCreateModel(c *gin.Context) {
var req struct { var req struct {
Name string `json:"name" binding:"required,min=1,max=128"` Name string `json:"name" binding:"required,min=1,max=128"`
DisplayName string `json:"display_name"`
InputPrice float64 `json:"input_price"` InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"` OutputPrice float64 `json:"output_price"`
CacheReadPrice float64 `json:"cache_read_price"` CacheReadPrice float64 `json:"cache_read_price"`
@@ -53,13 +129,10 @@ func (h *Handler) AdminCreateModel(c *gin.Context) {
return return
} }
m := store.Model{ m := store.Model{
Name: req.Name, DisplayName: req.DisplayName, Name: req.Name,
InputPrice: req.InputPrice, OutputPrice: req.OutputPrice, CacheReadPrice: req.CacheReadPrice, InputPrice: req.InputPrice, OutputPrice: req.OutputPrice, CacheReadPrice: req.CacheReadPrice,
Enabled: boolOr(req.Enabled, true), Enabled: boolOr(req.Enabled, true),
} }
if m.DisplayName == "" {
m.DisplayName = m.Name
}
if err := h.a.DB.Create(&m).Error; err != nil { if err := h.a.DB.Create(&m).Error; err != nil {
resp.Fail(c, http.StatusConflict, "failed to create model (name may already exist)") resp.Fail(c, http.StatusConflict, "failed to create model (name may already exist)")
return return
@@ -67,7 +140,7 @@ func (h *Handler) AdminCreateModel(c *gin.Context) {
resp.Created(c, gin.H{"id": m.ID, "name": m.Name}) resp.Created(c, gin.H{"id": m.ID, "name": m.Name})
} }
// AdminUpdateModel PUT /api/v1/admin/models/:id — 价格/展示名/启停/排序。 // AdminUpdateModel PUT /api/v1/admin/models/:id — 价格/启停/排序。
func (h *Handler) AdminUpdateModel(c *gin.Context) { func (h *Handler) AdminUpdateModel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
@@ -75,7 +148,6 @@ func (h *Handler) AdminUpdateModel(c *gin.Context) {
return return
} }
var req struct { var req struct {
DisplayName *string `json:"display_name"`
InputPrice *float64 `json:"input_price"` InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"` OutputPrice *float64 `json:"output_price"`
CacheReadPrice *float64 `json:"cache_read_price"` CacheReadPrice *float64 `json:"cache_read_price"`
@@ -92,9 +164,6 @@ func (h *Handler) AdminUpdateModel(c *gin.Context) {
return return
} }
updates := map[string]any{} updates := map[string]any{}
if req.DisplayName != nil {
updates["display_name"] = *req.DisplayName
}
if req.InputPrice != nil { if req.InputPrice != nil {
updates["input_price"] = *req.InputPrice updates["input_price"] = *req.InputPrice
} }
@@ -139,6 +208,29 @@ func (h *Handler) AdminDeleteModel(c *gin.Context) {
resp.OK(c, gin.H{"ok": true}) resp.OK(c, gin.H{"ok": true})
} }
// AdminDeleteUnusedModels DELETE /api/v1/admin/models/unused — 一键清除未绑定任何渠道的模型。
// 这些通常是渠道抽屉里选中过、后来又取消绑定留下的目录条目,客户端无法调用。
func (h *Handler) AdminDeleteUnusedModels(c *gin.Context) {
var orphans []store.Model
if err := h.a.DB.Where("id NOT IN (SELECT DISTINCT model_id FROM channel_model_bindings)").Find(&orphans).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "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.a.DB.Delete(&store.Model{}, ids).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to delete models")
return
}
}
resp.OK(c, gin.H{"deleted": names, "count": len(names)})
}
// AdminCreateModelBinding POST /api/v1/admin/models/:id/bindings // AdminCreateModelBinding POST /api/v1/admin/models/:id/bindings
func (h *Handler) AdminCreateModelBinding(c *gin.Context) { func (h *Handler) AdminCreateModelBinding(c *gin.Context) {
modelID, err := strconv.ParseUint(c.Param("id"), 10, 64) modelID, err := strconv.ParseUint(c.Param("id"), 10, 64)
+6
View File
@@ -90,11 +90,17 @@ func (h *Handler) AdminUsage(c *gin.Context) {
for _, l := range logs { for _, l := range logs {
var uname string var uname string
h.a.DB.Model(&store.User{}).Where("id = ?", l.UserID).Pluck("username", &uname) h.a.DB.Model(&store.User{}).Where("id = ?", l.UserID).Pluck("username", &uname)
var chName string
if l.ChannelID > 0 {
h.a.DB.Model(&store.Channel{}).Where("id = ?", l.ChannelID).Pluck("name", &chName)
}
out = append(out, gin.H{ out = append(out, gin.H{
"id": l.ID, "user": uname, "user_id": l.UserID, "model": l.ModelName, "protocol": l.Protocol, "id": l.ID, "user": uname, "user_id": l.UserID, "model": l.ModelName, "protocol": l.Protocol,
"channel_id": l.ChannelID, "channel": chName,
"input_tokens": l.InputTokens, "output_tokens": l.OutputTokens, "input_tokens": l.InputTokens, "output_tokens": l.OutputTokens,
"cache_read_tokens": l.CacheReadTokens, "cost": l.Cost, "cache_read_tokens": l.CacheReadTokens, "cost": l.Cost,
"latency_ms": l.LatencyMS, "status": l.Status, "error_code": l.ErrorCode, "latency_ms": l.LatencyMS, "status": l.Status, "error_code": l.ErrorCode,
"raw_request": l.RawRequest, "raw_response": l.RawResponse,
"created_at": l.CreatedAt, "created_at": l.CreatedAt,
}) })
} }
+40 -2
View File
@@ -8,18 +8,24 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/openteam/server/internal/app"
"github.com/openteam/server/internal/api/middleware" "github.com/openteam/server/internal/api/middleware"
"github.com/openteam/server/internal/app"
"github.com/openteam/server/internal/passkey"
"github.com/openteam/server/internal/pkg/resp" "github.com/openteam/server/internal/pkg/resp"
"github.com/openteam/server/internal/proxy"
"github.com/openteam/server/internal/store" "github.com/openteam/server/internal/store"
) )
// Handler 聚合所有管理 API。 // Handler 聚合所有管理 API。
type Handler struct { type Handler struct {
a *app.App a *app.App
gw *proxy.Gateway
passkeys *passkey.Service
} }
func NewHandler(a *app.App) *Handler { return &Handler{a: a} } func NewHandler(a *app.App, gw *proxy.Gateway, pk *passkey.Service) *Handler {
return &Handler{a: a, gw: gw, passkeys: pk}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 认证 // 认证
@@ -158,6 +164,36 @@ func (h *Handler) Logout(c *gin.Context) {
resp.OK(c, gin.H{"ok": true}) resp.OK(c, gin.H{"ok": true})
} }
type changePasswordReq struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8,max=72"`
}
// ChangePassword POST /api/v1/auth/password — 修改密码。
func (h *Handler) ChangePassword(c *gin.Context) {
u := sessionUser(c)
var req changePasswordReq
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error())
return
}
ok, err := h.a.Hasher.VerifyPassword(u.PasswordHash, req.OldPassword)
if err != nil || !ok {
resp.Fail(c, http.StatusBadRequest, "旧密码不正确")
return
}
hash, err := h.a.Hasher.HashPassword(req.NewPassword)
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to hash password")
return
}
if err := h.a.DB.Model(&store.User{}).Where("id = ?", u.ID).Update("password_hash", hash).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to update password")
return
}
resp.OK(c, gin.H{"ok": true})
}
// Me GET /api/v1/auth/me // Me GET /api/v1/auth/me
func (h *Handler) Me(c *gin.Context) { func (h *Handler) Me(c *gin.Context) {
u := sessionUser(c) u := sessionUser(c)
@@ -189,6 +225,8 @@ func (h *Handler) publicUser(u *store.User) gin.H {
"role": u.Role, "role": u.Role,
"balance": u.Balance, "balance": u.Balance,
"status": u.Status, "status": u.Status,
"allowed_models": u.AllowedModels,
"denied_models": u.DeniedModels,
"created_at": u.CreatedAt, "created_at": u.CreatedAt,
} }
} }
+12 -8
View File
@@ -1,6 +1,7 @@
package api package api
import ( import (
"encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
@@ -135,9 +136,6 @@ func (h *Handler) PatchKey(c *gin.Context) {
if req.QuotaRequestsPerDay != nil { if req.QuotaRequestsPerDay != nil {
updates["quota_requests_per_day"] = *req.QuotaRequestsPerDay updates["quota_requests_per_day"] = *req.QuotaRequestsPerDay
} }
if req.AllowedModels != nil {
updates["allowed_models"] = *req.AllowedModels
}
if req.Status != nil { if req.Status != nil {
if *req.Status != store.KeyStatusActive && *req.Status != store.KeyStatusRevoked { if *req.Status != store.KeyStatusActive && *req.Status != store.KeyStatusRevoked {
resp.Fail(c, http.StatusBadRequest, "status must be active or revoked") resp.Fail(c, http.StatusBadRequest, "status must be active or revoked")
@@ -151,10 +149,18 @@ func (h *Handler) PatchKey(c *gin.Context) {
return return
} }
} }
// allowed_models 是 jsonb:手动序列化为 JSON 字符串写入(跨 SQLite/Postgres)
if req.AllowedModels != nil {
raw, _ := json.Marshal(*req.AllowedModels)
if err := h.a.DB.Model(&k).Update("allowed_models", string(raw)).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to update key")
return
}
}
resp.OK(c, gin.H{"ok": true}) resp.OK(c, gin.H{"ok": true})
} }
// DeleteKey DELETE /api/v1/keys/:id — 吊销。 // DeleteKey DELETE /api/v1/keys/:id — 删除(硬删,立即失效不可恢复)。
func (h *Handler) DeleteKey(c *gin.Context) { func (h *Handler) DeleteKey(c *gin.Context) {
u, ok := userFromContext(c) u, ok := userFromContext(c)
if !ok { if !ok {
@@ -166,11 +172,9 @@ func (h *Handler) DeleteKey(c *gin.Context) {
resp.Fail(c, http.StatusBadRequest, "invalid key id") resp.Fail(c, http.StatusBadRequest, "invalid key id")
return return
} }
res := h.a.DB.Model(&store.APIKey{}). res := h.a.DB.Where("id = ? AND user_id = ?", id, u.ID).Delete(&store.APIKey{})
Where("id = ? AND user_id = ?", id, u.ID).
Update("status", store.KeyStatusRevoked)
if res.Error != nil { if res.Error != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to revoke key") resp.Fail(c, http.StatusInternalServerError, "failed to delete key")
return return
} }
if res.RowsAffected == 0 { if res.RowsAffected == 0 {
+154
View File
@@ -0,0 +1,154 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/pkg/resp"
"github.com/openteam/server/internal/store"
)
// PasskeyRegisterBegin POST /api/v1/webauthn/register/begin — 生成注册选项。
func (h *Handler) PasskeyRegisterBegin(c *gin.Context) {
u := sessionUser(c)
creation, err := h.passkeys.BeginRegistration(u)
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to begin registration: "+err.Error())
return
}
resp.OK(c, gin.H{"creation": creation, "challenge": creation.Response.Challenge})
}
// PasskeyRegisterComplete POST /api/v1/webauthn/register/complete — 校验并保存凭据。
func (h *Handler) PasskeyRegisterComplete(c *gin.Context) {
u := sessionUser(c)
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 {
resp.Fail(c, http.StatusBadRequest, "invalid input")
return
}
if err := h.passkeys.FinishRegistration(u, req.Challenge, req.Credential, []byte(req.Name)); err != nil {
resp.Fail(c, http.StatusBadRequest, "passkey 注册失败: "+err.Error())
return
}
resp.OK(c, gin.H{"ok": true})
}
// PasskeyLoginBegin POST /api/v1/webauthn/login/begin — 生成断言选项。
// 传 username 用指定用户;不传则用可发现凭据(平台 passkey)。
func (h *Handler) PasskeyLoginBegin(c *gin.Context) {
var req struct {
Username string `json:"username"`
}
_ = c.ShouldBindJSON(&req)
if req.Username != "" {
var u store.User
err := h.a.DB.Where("username = ? OR email = ?", req.Username, req.Username).First(&u).Error
if err != nil || u.Status != store.UserStatusActive {
resp.Fail(c, http.StatusNotFound, "user not found")
return
}
assertion, err := h.passkeys.BeginLogin(&u)
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to begin login: "+err.Error())
return
}
resp.OK(c, gin.H{"assertion": assertion, "challenge": assertion.Response.Challenge, "user_id": u.ID})
return
}
assertion, err := h.passkeys.BeginDiscoverableLogin()
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to begin login: "+err.Error())
return
}
resp.OK(c, gin.H{"assertion": assertion, "challenge": assertion.Response.Challenge})
}
// PasskeyLoginComplete POST /api/v1/webauthn/login/complete — 校验断言并发放令牌。
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 {
resp.Fail(c, http.StatusBadRequest, "invalid input")
return
}
var u *store.User
if req.UserID > 0 {
var su store.User
if err := h.a.DB.First(&su, req.UserID).Error; err != nil {
resp.Fail(c, http.StatusUnauthorized, "user not found")
return
}
u = &su
if err := h.passkeys.FinishLogin(u, req.Challenge, req.Credential); err != nil {
resp.Fail(c, http.StatusUnauthorized, "passkey 校验失败: "+err.Error())
return
}
} else {
var err error
u, err = h.passkeys.FinishDiscoverableLogin(req.Challenge, req.Credential)
if err != nil {
resp.Fail(c, http.StatusUnauthorized, "passkey 校验失败: "+err.Error())
return
}
}
if u.Status != store.UserStatusActive {
resp.Fail(c, http.StatusForbidden, "user account disabled")
return
}
access, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "access")
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to issue token")
return
}
refresh, _, err := h.a.JWT.Sign(u.ID, u.Username, u.Role, "refresh")
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to issue token")
return
}
h.setRefreshCookie(c, refresh)
resp.OK(c, gin.H{
"access_token": access,
"expires_in": int(h.a.JWT.AccessTTL().Seconds()),
"user": h.publicUser(u),
})
}
// PasskeyList GET /api/v1/webauthn/passkeys — 当前用户的 passkey 列表。
func (h *Handler) PasskeyList(c *gin.Context) {
u := sessionUser(c)
pks, err := h.passkeys.List(u.ID)
if err != nil {
resp.Fail(c, http.StatusInternalServerError, "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})
}
resp.OK(c, gin.H{"items": out})
}
// PasskeyDelete DELETE /api/v1/webauthn/passkeys/:id — 解除绑定。
func (h *Handler) PasskeyDelete(c *gin.Context) {
u := sessionUser(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid passkey id")
return
}
if err := h.passkeys.Delete(u.ID, id); err != nil {
resp.Fail(c, http.StatusNotFound, "passkey not found")
return
}
resp.OK(c, gin.H{"ok": true})
}
+41 -4
View File
@@ -2,6 +2,8 @@ package api
import ( import (
"net/http" "net/http"
"os"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/openteam/server/internal/api/middleware" "github.com/openteam/server/internal/api/middleware"
@@ -19,7 +21,7 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
r := gin.New() r := gin.New()
r.Use(gin.Logger(), gin.Recovery(), middleware.CORS()) r.Use(gin.Logger(), gin.Recovery(), middleware.CORS())
h := NewHandler(a) h := NewHandler(a, gw, a.Passkeys)
// --- 代理端点(对外)--- // --- 代理端点(对外)---
proxyGroup := r.Group("/v1") proxyGroup := r.Group("/v1")
@@ -29,15 +31,34 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
proxyGroup.Any("/messages", gw.Auth, gw.Handle) proxyGroup.Any("/messages", gw.Auth, gw.Handle)
proxyGroup.Any("/models", gw.Auth, gw.Handle) proxyGroup.Any("/models", gw.Auth, gw.Handle)
} }
// 未匹配的 /v1/* 返回 OpenAI 风格 404(需先认证) // 静态资源(前端构建产物,存在时托管)
const dist = "web/dist"
if _, err := os.Stat(dist); err == nil {
// /assets 文件名含内容 hash,不可变:长缓存 + immutable,避免每次回源
assets := r.Group("/assets")
assets.Use(func(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Next()
})
assets.Static("", dist+"/assets")
r.StaticFile("/favicon.svg", dist+"/favicon.svg")
}
// 未匹配路由:/v1/* 走代理鉴权;其余回退 SPA 或 404
r.NoRoute(func(c *gin.Context) { r.NoRoute(func(c *gin.Context) {
if len(c.Request.URL.Path) >= 3 && c.Request.URL.Path[:3] == "/v1" { if strings.HasPrefix(c.Request.URL.Path, "/v1") {
gw.Auth(c) gw.Auth(c)
if !c.IsAborted() { if !c.IsAborted() {
gw.Handle(c) gw.Handle(c)
} }
return return
} }
if _, err := os.Stat(dist); err == nil {
// SPA 入口:必须每次回源校验,否则浏览器启发式缓存会保留引用已删除 chunk 的旧 HTML → 黑屏
c.Header("Cache-Control", "no-cache")
c.File(dist + "/index.html")
return
}
c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
}) })
@@ -50,9 +71,20 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
auth.POST("/login", h.Login) auth.POST("/login", h.Login)
auth.POST("/refresh", h.Refresh) auth.POST("/refresh", h.Refresh)
auth.POST("/logout", h.Logout) auth.POST("/logout", h.Logout)
auth.POST("/password", middleware.SessionAuth(a), h.ChangePassword)
auth.GET("/me", middleware.SessionAuth(a), h.Me) auth.GET("/me", middleware.SessionAuth(a), h.Me)
} }
webauthn := api.Group("/webauthn")
{
webauthn.POST("/register/begin", middleware.SessionAuth(a), h.PasskeyRegisterBegin)
webauthn.POST("/register/complete", middleware.SessionAuth(a), h.PasskeyRegisterComplete)
webauthn.POST("/login/begin", h.PasskeyLoginBegin)
webauthn.POST("/login/complete", h.PasskeyLoginComplete)
webauthn.GET("/passkeys", middleware.SessionAuth(a), h.PasskeyList)
webauthn.DELETE("/passkeys/:id", middleware.SessionAuth(a), h.PasskeyDelete)
}
user := api.Group("", middleware.SessionAuth(a)) user := api.Group("", middleware.SessionAuth(a))
{ {
user.GET("/user/profile", h.UserProfile) user.GET("/user/profile", h.UserProfile)
@@ -79,9 +111,14 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
admin.PUT("/channels/:id", h.AdminUpdateChannel) admin.PUT("/channels/:id", h.AdminUpdateChannel)
admin.DELETE("/channels/:id", h.AdminDeleteChannel) admin.DELETE("/channels/:id", h.AdminDeleteChannel)
admin.POST("/channels/:id/test", h.AdminTestChannel) admin.POST("/channels/:id/test", h.AdminTestChannel)
admin.POST("/channels/:id/models/import", h.AdminImportChannelModels) admin.GET("/channels/:id/models/remote", h.AdminChannelRemoteModels)
admin.GET("/channels/:id/models", h.AdminChannelModels)
admin.POST("/channels/:id/models", h.AdminChannelAddModel)
admin.PATCH("/channels/:id/models/:bid", h.AdminChannelUpdateModel)
admin.DELETE("/channels/:id/models/:bid", h.AdminChannelDeleteModel)
// 模型与定价 // 模型与定价
admin.GET("/models", h.AdminModels) admin.GET("/models", h.AdminModels)
admin.DELETE("/models/unused", h.AdminDeleteUnusedModels)
admin.POST("/models", h.AdminCreateModel) admin.POST("/models", h.AdminCreateModel)
admin.PUT("/models/:id", h.AdminUpdateModel) admin.PUT("/models/:id", h.AdminUpdateModel)
admin.DELETE("/models/:id", h.AdminDeleteModel) admin.DELETE("/models/:id", h.AdminDeleteModel)
+13 -6
View File
@@ -49,15 +49,22 @@ func (h *Handler) todayUsage(c *gin.Context, userID uint64) gin.H {
} }
// UserModels GET /api/v1/user/models — 控制台可用模型列表(无需 API Key)。 // UserModels GET /api/v1/user/models — 控制台可用模型列表(无需 API Key)。
// 仅返回启用的模型且至少绑定到一个启用且健康的渠道,与 /v1/models 口径一致。
func (h *Handler) UserModels(c *gin.Context) { func (h *Handler) UserModels(c *gin.Context) {
var ms []store.Model var names []string
if err := h.a.DB.Where("enabled = ?", true).Order("sort ASC, id ASC").Find(&ms).Error; err != nil { if err := h.a.DB.Table("models").
Joins("JOIN channel_model_bindings ON channel_model_bindings.model_id = models.id").
Joins("JOIN channels ON channels.id = channel_model_bindings.channel_id").
Where("models.enabled = ? AND channels.enabled = ? AND channels.health_status = ?",
true, true, store.ChannelHealthHealthy).
Distinct("models.name").
Order("models.sort ASC, models.id ASC").
Pluck("models.name", &names).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to load models") resp.Fail(c, http.StatusInternalServerError, "failed to load models")
return return
} }
out := make([]string, 0, len(ms)) if names == nil {
for _, m := range ms { names = []string{}
out = append(out, m.Name)
} }
resp.OK(c, gin.H{"items": out}) resp.OK(c, gin.H{"items": names})
} }
+13 -1
View File
@@ -8,8 +8,10 @@ import (
"github.com/openteam/server/internal/channel" "github.com/openteam/server/internal/channel"
"github.com/openteam/server/internal/config" "github.com/openteam/server/internal/config"
"github.com/openteam/server/internal/passkey"
"github.com/openteam/server/internal/pkg/crypto" "github.com/openteam/server/internal/pkg/crypto"
"github.com/openteam/server/internal/pkg/jwt" "github.com/openteam/server/internal/pkg/jwt"
"github.com/openteam/server/internal/pkg/ratelimit"
"github.com/openteam/server/internal/store" "github.com/openteam/server/internal/store"
"github.com/openteam/server/internal/usage" "github.com/openteam/server/internal/usage"
"gorm.io/gorm" "gorm.io/gorm"
@@ -23,6 +25,8 @@ type App struct {
JWT *jwt.Manager JWT *jwt.Manager
Usage *usage.Recorder Usage *usage.Recorder
Health *channel.HealthMonitor Health *channel.HealthMonitor
Limit *ratelimit.Limiter
Passkeys *passkey.Service
startedAt time.Time startedAt time.Time
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
@@ -44,6 +48,7 @@ func New(cfg *config.Config) (*App, error) {
} }
a.ctx, a.cancel = context.WithCancel(context.Background()) a.ctx, a.cancel = context.WithCancel(context.Background())
a.Usage = usage.NewRecorder(db) a.Usage = usage.NewRecorder(db)
a.Limit = ratelimit.New()
if err := a.Seed(); err != nil { if err := a.Seed(); err != nil {
return nil, err return nil, err
@@ -54,6 +59,13 @@ func New(cfg *config.Config) (*App, error) {
FailThreshold: cfg.Proxy.HealthFailThreshold, FailThreshold: cfg.Proxy.HealthFailThreshold,
}) })
a.Health.Start(a.ctx) a.Health.Start(a.ctx)
a.Passkeys, err = passkey.New(db, passkey.Config{
RPID: cfg.WebAuthn.RPID, Origin: cfg.WebAuthn.RPOrigin, Name: cfg.WebAuthn.RPName,
})
if err != nil {
return nil, err
}
return a, nil return a, nil
} }
@@ -97,6 +109,7 @@ func (a *App) Seed() error {
ch := store.Channel{ ch := store.Channel{
Name: a.Cfg.Proxy.DefaultChannelName, Name: a.Cfg.Proxy.DefaultChannelName,
Provider: store.ChannelProviderOpenAI, Provider: store.ChannelProviderOpenAI,
Formats: []string{store.FormatChat, store.FormatResponses},
BaseURL: a.Cfg.Proxy.UpstreamBaseURL, BaseURL: a.Cfg.Proxy.UpstreamBaseURL,
APIKeyEnc: enc, APIKeyEnc: enc,
Weight: 1, Weight: 1,
@@ -112,7 +125,6 @@ func (a *App) Seed() error {
// 默认模型 + 绑定 // 默认模型 + 绑定
m := store.Model{ m := store.Model{
Name: a.Cfg.Proxy.DefaultModel, Name: a.Cfg.Proxy.DefaultModel,
DisplayName: a.Cfg.Proxy.DefaultModel,
InputPrice: 0.15, // 每百万 token,示例价 InputPrice: 0.15, // 每百万 token,示例价
OutputPrice: 0.60, OutputPrice: 0.60,
Enabled: true, Enabled: true,
+40 -21
View File
@@ -27,69 +27,88 @@ func NewService(db *gorm.DB, enc *crypto.Encryptor) *Service {
return &Service{db: db, enc: enc, sems: map[uint64]chan struct{}{}} return &Service{db: db, enc: enc, sems: map[uint64]chan struct{}{}}
} }
// Candidate 一个候选渠道 + 该模型的映射关系。
type Candidate struct {
Channel *store.Channel
UpstreamModel string // 全局模型在此渠道的映射名(无绑定则为空,用客户端模型名)
}
// Candidates 返回可用渠道候选:健康 + 启用,按优先级、权重降序、id 升序排列。 // Candidates 返回可用渠道候选:健康 + 启用,按优先级、权重降序、id 升序排列。
// model 非空时优先取绑定该模型的渠道;无绑定则退回全局。 // model 非空时优先取绑定该模型的渠道(携带 upstream_model 映射);无绑定则退回全局。
func (s *Service) Candidates(model string) []*store.Channel { func (s *Service) Candidates(model string) []Candidate {
if model != "" { if model != "" {
var b []store.ChannelModelBinding var b []store.ChannelModelBinding
var modelIDs []uint64 var modelIDs []uint64
s.db.Model(&store.Model{}).Where("name = ? AND enabled = ?", model, true).Pluck("id", &modelIDs) s.db.Model(&store.Model{}).Where("name = ? AND enabled = ?", model, true).Pluck("id", &modelIDs)
if len(modelIDs) > 0 { if len(modelIDs) > 0 {
s.db.Where("model_id IN ?", modelIDs).Find(&b) s.db.Where("model_id IN ?", modelIDs).Find(&b)
chs := s.loadBound(b) if cands := s.loadBound(b); len(cands) > 0 {
if len(chs) > 0 { return cands
return chs
} }
} }
} }
var chs []store.Channel var chs []store.Channel
s.db.Where("enabled = ? AND health_status = ?", true, store.ChannelHealthHealthy). s.db.Where("enabled = ? AND health_status = ?", true, store.ChannelHealthHealthy).
Order("priority ASC, weight DESC, id ASC").Find(&chs) Order("priority ASC, weight DESC, id ASC").Find(&chs)
out := make([]*store.Channel, 0, len(chs)) out := make([]Candidate, 0, len(chs))
for i := range chs { for i := range chs {
out = append(out, &chs[i]) out = append(out, Candidate{Channel: &chs[i]})
} }
return out return out
} }
// loadBound 按绑定顺序加载渠道,过滤健康/启用。 // loadBound 按绑定顺序加载渠道候选,过滤健康/启用,携带 upstream_model 映射。
func (s *Service) loadBound(bindings []store.ChannelModelBinding) []*store.Channel { func (s *Service) loadBound(bindings []store.ChannelModelBinding) []Candidate {
if len(bindings) == 0 { if len(bindings) == 0 {
return nil return nil
} }
// channel_id -> 绑定(取该渠道对该模型的映射)
byChannel := map[uint64]store.ChannelModelBinding{}
ids := make([]uint64, 0, len(bindings)) ids := make([]uint64, 0, len(bindings))
seen := map[uint64]bool{}
for _, b := range bindings { for _, b := range bindings {
if !seen[b.ChannelID] { if _, ok := byChannel[b.ChannelID]; !ok {
seen[b.ChannelID] = true
ids = append(ids, b.ChannelID) ids = append(ids, b.ChannelID)
} }
byChannel[b.ChannelID] = b
} }
var chs []store.Channel var chs []store.Channel
s.db.Where("id IN ? AND enabled = ? AND health_status = ?", ids, true, store.ChannelHealthHealthy). s.db.Where("id IN ? AND enabled = ? AND health_status = ?", ids, true, store.ChannelHealthHealthy).
Order("priority ASC, weight DESC, id ASC").Find(&chs) Order("priority ASC, weight DESC, id ASC").Find(&chs)
// 保持绑定顺序
byID := map[uint64]*store.Channel{} byID := map[uint64]*store.Channel{}
for i := range chs { for i := range chs {
byID[chs[i].ID] = &chs[i] byID[chs[i].ID] = &chs[i]
} }
out := make([]*store.Channel, 0, len(ids)) out := make([]Candidate, 0, len(ids))
for _, id := range ids { for _, id := range ids {
if ch, ok := byID[id]; ok { if ch, ok := byID[id]; ok {
out = append(out, ch) out = append(out, Candidate{Channel: ch, UpstreamModel: byChannel[id].UpstreamModel})
} }
} }
return out return out
} }
// Pick 按权重加权随机选一个候选(负载均衡)。 // AvailableModelIDs 返回对外可见的模型 ID:启用的模型且至少绑定到一个启用且健康的渠道。
func (s *Service) Pick(cands []*store.Channel) *store.Channel { // 与 Candidates 的过滤口径一致(enabled + health_status=healthy),避免暴露绑定到已停用渠道的模型。
func (s *Service) AvailableModelIDs() []uint64 {
var ids []uint64
s.db.Model(&store.ChannelModelBinding{}).
Joins("JOIN channels ON channels.id = channel_model_bindings.channel_id").
Joins("JOIN models ON models.id = channel_model_bindings.model_id").
Where("channels.enabled = ? AND channels.health_status = ?", true, store.ChannelHealthHealthy).
Where("models.enabled = ?", true).
Distinct("channel_model_bindings.model_id").
Pluck("channel_model_bindings.model_id", &ids)
return ids
}
// Pick 按权重加权随机选一个候选渠道(负载均衡)。
func (s *Service) Pick(cands []Candidate) *store.Channel {
if len(cands) == 0 { if len(cands) == 0 {
return nil return nil
} }
total := 0 total := 0
for _, c := range cands { for _, c := range cands {
w := c.Weight w := c.Channel.Weight
if w <= 0 { if w <= 0 {
w = 1 w = 1
} }
@@ -98,16 +117,16 @@ func (s *Service) Pick(cands []*store.Channel) *store.Channel {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(total))) n, _ := rand.Int(rand.Reader, big.NewInt(int64(total)))
acc := 0 acc := 0
for _, c := range cands { for _, c := range cands {
w := c.Weight w := c.Channel.Weight
if w <= 0 { if w <= 0 {
w = 1 w = 1
} }
acc += w acc += w
if int(n.Int64()) < acc { if int(n.Int64()) < acc {
return c return c.Channel
} }
} }
return cands[len(cands)-1] return cands[len(cands)-1].Channel
} }
// TryAcquire 尝试获取渠道并发槽;渠道满载返回 false(调用方可溢出到其他渠道)。 // TryAcquire 尝试获取渠道并发槽;渠道满载返回 false(调用方可溢出到其他渠道)。
+2 -2
View File
@@ -54,8 +54,8 @@ func TestCandidatesFiltersUnhealthy(t *testing.T) {
if len(cands) != 2 { if len(cands) != 2 {
t.Fatalf("candidates = %d, want 2", len(cands)) t.Fatalf("candidates = %d, want 2", len(cands))
} }
if cands[0].Name != "a" { if cands[0].Channel.Name != "a" {
t.Fatalf("first by priority should be a, got %s", cands[0].Name) t.Fatalf("first by priority should be a, got %s", cands[0].Channel.Name)
} }
} }
+1 -2
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"log" "log"
"net/http" "net/http"
"strings"
"sync" "sync"
"time" "time"
@@ -83,7 +82,7 @@ func (h *HealthMonitor) probe(ch *store.Channel) {
h.record(ch, false) h.record(ch, false)
return return
} }
url := strings.TrimRight(ch.BaseURL, "/") + "/v1/models" url := ch.UpstreamURL("", "/models")
req, _ := http.NewRequest(http.MethodGet, url, nil) req, _ := http.NewRequest(http.MethodGet, url, nil)
req.Header.Set("Authorization", "Bearer "+key) req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
+178
View File
@@ -0,0 +1,178 @@
// Package cli 命令行入口。
package cli
import (
"crypto/rand"
"fmt"
"log"
"math/big"
"os"
"strings"
"github.com/openteam/server/internal/config"
"github.com/openteam/server/internal/pkg/crypto"
"github.com/openteam/server/internal/store"
"github.com/spf13/cobra"
"golang.org/x/term"
"gorm.io/gorm"
)
// pwAlphabet 密码随机串字符集(去掉 0/O/1/l/I 等易混淆字符)。
const pwAlphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz"
var (
resetUser string
resetPassword string
)
var resetAdminPasswordCmd = &cobra.Command{
Use: "reset-admin-password",
Short: "重置管理员密码",
Long: `重置管理员密码(忘记密码时用,需在可加载配置的目录下执行)。
交互式引导(默认):
go run ./cmd/server reset-admin-password
非交互式(用于脚本):
go run ./cmd/server reset-admin-password --user admin --password "新密码"`,
Run: func(cmd *cobra.Command, args []string) {
runResetAdminPassword()
},
}
func init() {
rootCmd.AddCommand(resetAdminPasswordCmd)
resetAdminPasswordCmd.Flags().StringVar(&resetUser, "user", "", "目标管理员用户名(留空则交互选择)")
resetAdminPasswordCmd.Flags().StringVar(&resetPassword, "password", "", "新密码,至少 8 位(留空则交互输入)")
}
func runResetAdminPassword() {
// --- 阶段 1:加载配置与数据库 ---
cfg, err := config.Load()
if err != nil {
log.Fatalf("加载配置失败: %v", err)
}
db, err := store.Open(cfg.DB.Driver, cfg.DB.DSN)
if err != nil {
log.Fatalf("连接数据库失败: %v", err)
}
// --- 阶段 2:列出管理员 ---
var admins []struct {
ID uint64
Username string
}
db.Model(&store.User{}).Select("id, username").Where("role = ?", store.RoleAdmin).Find(&admins)
if len(admins) == 0 {
log.Fatalf("系统中没有管理员用户")
}
// --- 阶段 3:选择目标管理员 ---
target := targetAdmin(db, admins)
// --- 阶段 4:获取新密码 ---
var pw string
if resetPassword != "" {
pw = resetPassword
} else {
pw = promptPassword()
}
if pw == "" {
log.Fatalf("密码不能为空")
}
if len(pw) < 8 || len(pw) > 72 {
log.Fatalf("密码长度必须在 8 到 72 位之间")
}
// --- 阶段 5:加密并更新 ---
hasher := crypto.NewPasswordHasher(cfg.Auth.Argon2Time, cfg.Auth.Argon2Memory, cfg.Auth.Argon2Threads, cfg.Auth.Argon2KeyLen, cfg.Auth.SaltLen)
hash, err := hasher.HashPassword(pw)
if err != nil {
log.Fatalf("加密密码失败: %v", err)
}
if err := db.Model(&store.User{}).Where("id = ?", target.ID).Update("password_hash", hash).Error; err != nil {
log.Fatalf("更新密码失败: %v", err)
}
fmt.Printf("✅ 已重置管理员 %q 的密码!\n", target.Username)
}
// targetAdmin 解析 --user 或交互选择目标管理员。
func targetAdmin(db *gorm.DB, admins []struct {
ID uint64
Username string
}) *store.User {
if resetUser != "" {
for i := range admins {
if admins[i].Username == resetUser {
return &store.User{ID: admins[i].ID, Username: admins[i].Username}
}
}
log.Fatalf("管理员 %q 不存在", resetUser)
}
if len(admins) == 1 {
return &store.User{ID: admins[0].ID, Username: admins[0].Username}
}
return selectAdmin(admins)
}
// selectAdmin 交互式选择管理员用户。
func selectAdmin(admins []struct {
ID uint64
Username string
}) *store.User {
fmt.Println("检测到多个管理员用户,请选择要重置密码的目标:")
for i, a := range admins {
fmt.Printf(" [%d] %s\n", i+1, a.Username)
}
fmt.Printf("请输入编号 (1-%d): ", len(admins))
var n int
if _, err := fmt.Scanf("%d", &n); err != nil || n < 1 || n > len(admins) {
log.Fatalf("无效输入")
}
return &store.User{ID: admins[n-1].ID, Username: admins[n-1].Username}
}
// promptPassword 交互式输入密码,并提供两种方式选择。
func promptPassword() string {
fmt.Println("请选择密码设置方式:")
fmt.Println(" [1] 手动输入密码")
fmt.Println(" [2] 自动生成随机密码(16 位,不含易混淆字符)")
fmt.Printf("请输入编号 (1-2): ")
var n int
if _, err := fmt.Scanf("%d", &n); err != nil || n < 1 || n > 2 {
log.Fatalf("无效输入,请输入 1 或 2")
}
if n == 2 {
pw, err := randomPassword(16)
if err != nil {
log.Fatalf("生成随机密码失败: %v", err)
}
fmt.Printf("🔑 生成的随机密码:%s\n", pw)
fmt.Println("(请立即保存,不会再次显示)")
return pw
}
// 手动输入:静默输入(不回显)
fmt.Print("请输入新密码(至少 8 位):")
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
log.Fatalf("读取密码失败: %v", err)
}
return strings.TrimSpace(string(raw))
}
// randomPassword 用 crypto/rand 从 pwAlphabet 生成 n 位随机密码。
func randomPassword(n int) (string, error) {
buf := make([]byte, n)
for i := range buf {
k, err := rand.Int(rand.Reader, big.NewInt(int64(len(pwAlphabet))))
if err != nil {
return "", err
}
buf[i] = pwAlphabet[k.Int64()]
}
return string(buf), nil
}
+176
View File
@@ -0,0 +1,176 @@
// Package cli 命令行入口。
package cli
import (
"fmt"
"log"
"os"
"strconv"
"strings"
"github.com/openteam/server/internal/config"
"github.com/openteam/server/internal/pkg/crypto"
"github.com/openteam/server/internal/store"
"github.com/spf13/cobra"
"golang.org/x/term"
"gorm.io/gorm"
)
var (
resetPwUser string
resetPwPassword string
resetPwUserID string
)
var resetPasswordCmd = &cobra.Command{
Use: "reset-password",
Short: "重置用户密码",
Long: `重置任意用户密码(管理员可重置任何用户,需在可加载配置的目录下执行)。
交互式引导(默认):
go run ./cmd/server reset-password
非交互式(用于脚本):
go run ./cmd/server reset-password --user admin --password "新密码"
go run ./cmd/server reset-password --user-id 123 --password "新密码"`,
Run: func(cmd *cobra.Command, args []string) {
runResetPassword()
},
}
func init() {
rootCmd.AddCommand(resetPasswordCmd)
resetPasswordCmd.Flags().StringVar(&resetPwUser, "user", "", "目标用户名(留空则交互选择)")
resetPasswordCmd.Flags().StringVar(&resetPwUserID, "user-id", "", "目标用户 ID(优先于 --user)")
resetPasswordCmd.Flags().StringVar(&resetPwPassword, "password", "", "新密码,至少 8 位(留空则交互输入)")
}
func runResetPassword() {
// --- 阶段 1:加载配置与数据库 ---
cfg, err := config.Load()
if err != nil {
log.Fatalf("加载配置失败: %v", err)
}
db, err := store.Open(cfg.DB.Driver, cfg.DB.DSN)
if err != nil {
log.Fatalf("连接数据库失败: %v", err)
}
// --- 阶段 2:选择目标用户 ---
target := targetUser(db)
// --- 阶段 3:获取新密码 ---
var pw string
if resetPwPassword != "" {
pw = resetPwPassword
} else {
pw = promptNewPassword()
}
if pw == "" {
log.Fatalf("密码不能为空")
}
if len(pw) < 8 || len(pw) > 72 {
log.Fatalf("密码长度必须在 8 到 72 位之间")
}
// --- 阶段 4:加密并更新 ---
hasher := crypto.NewPasswordHasher(cfg.Auth.Argon2Time, cfg.Auth.Argon2Memory, cfg.Auth.Argon2Threads, cfg.Auth.Argon2KeyLen, cfg.Auth.SaltLen)
hash, err := hasher.HashPassword(pw)
if err != nil {
log.Fatalf("加密密码失败: %v", err)
}
if err := db.Model(&store.User{}).Where("id = ?", target.ID).Update("password_hash", hash).Error; err != nil {
log.Fatalf("更新密码失败: %v", err)
}
fmt.Printf("✅ 已重置用户 %q 的密码!\n", target.Username)
}
// targetUser 根据 --user-id 或 --user 解析目标用户,否则交互选择。
func targetUser(db *gorm.DB) *store.User {
// 优先使用 --user-id
if resetPwUserID != "" {
id, err := strconv.ParseUint(resetPwUserID, 10, 64)
if err != nil {
log.Fatalf("无效的用户 ID: %v", err)
}
var u store.User
if err := db.First(&u, id).Error; err != nil {
log.Fatalf("用户 ID %d 不存在", id)
}
return &u
}
// 使用 --user
if resetPwUser != "" {
var u store.User
if err := db.Where("username = ?", resetPwUser).First(&u).Error; err != nil {
log.Fatalf("用户 %q 不存在", resetPwUser)
}
return &u
}
// 交互选择
var users []struct {
ID uint64
Username string
Role string
}
db.Model(&store.User{}).Select("id, username, role").Order("id ASC").Find(&users)
if len(users) == 0 {
log.Fatalf("系统中没有用户")
}
if len(users) == 1 {
return &store.User{ID: users[0].ID, Username: users[0].Username}
}
return selectUser(users)
}
// selectUser 交互式选择用户。
func selectUser(users []struct {
ID uint64
Username string
Role string
}) *store.User {
fmt.Println("请选择要重置密码的用户:")
for i, u := range users {
fmt.Printf(" [%d] %s (%s)\n", i+1, u.Username, u.Role)
}
fmt.Printf("请输入编号 (1-%d): ", len(users))
var n int
if _, err := fmt.Scanf("%d", &n); err != nil || n < 1 || n > len(users) {
log.Fatalf("无效输入")
}
return &store.User{ID: users[n-1].ID, Username: users[n-1].Username}
}
// promptNewPassword 交互式输入密码。
func promptNewPassword() string {
fmt.Println("请选择密码设置方式:")
fmt.Println(" [1] 手动输入密码")
fmt.Println(" [2] 自动生成随机密码(16 位,不含易混淆字符)")
fmt.Printf("请输入编号 (1-2): ")
var n int
if _, err := fmt.Scanf("%d", &n); err != nil || n < 1 || n > 2 {
log.Fatalf("无效输入,请输入 1 或 2")
}
if n == 2 {
pw, err := randomPassword(16)
if err != nil {
log.Fatalf("生成随机密码失败: %v", err)
}
fmt.Printf("生成的随机密码:%s\n", pw)
fmt.Println("(请立即保存,不会再次显示)")
return pw
}
// 手动输入:静默输入(不回显)
fmt.Print("请输入新密码(至少 8 位):")
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
log.Fatalf("读取密码失败: %v", err)
}
return strings.TrimSpace(string(raw))
}
+27
View File
@@ -0,0 +1,27 @@
// Package cli 命令行入口(Cobra 子命令分发)。
package cli
import (
"github.com/spf13/cobra"
)
// Execute 启动根命令。
func Execute() {
cobra.CheckErr(rootCmd.Execute())
}
var rootCmd = &cobra.Command{
Use: "openteam",
Short: "openteam · LLM API 中转站",
Long: `openteam 大模型中转站 API 服务。
直接运行(无子命令)启动 HTTP 服务:
go run ./cmd/server
子命令:
reset-admin-password 重置管理员密码(交互式,无参数时自动提示)
reset-password 重置任意用户密码`,
RunE: func(c *cobra.Command, args []string) error {
return serve()
},
}
+62
View File
@@ -0,0 +1,62 @@
// Package cli 命令行入口。
package cli
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"github.com/openteam/server/internal/api"
"github.com/openteam/server/internal/app"
"github.com/openteam/server/internal/config"
"github.com/openteam/server/internal/proxy"
)
// serve 启动 HTTP 服务(默认子命令)。
func serve() error {
cfg, err := config.Load()
if err != nil {
return err
}
a, err := app.New(cfg)
if err != nil {
return err
}
defer a.Shutdown(context.Background())
gw := proxy.NewGateway(a.DB, a.Enc, a.Usage, a.Limit, cfg.RateLimit.UserRPS, cfg.Proxy.LogRaw, cfg.Proxy.Timeout)
router := api.NewRouter(a, gw)
srv := &http.Server{
Addr: ":" + strconv.Itoa(cfg.Port),
Handler: router,
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
log.Printf("openteam listening on %s (env=%s)", srv.Addr, cfg.Env)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("server shutdown: %v", err)
}
a.Usage.Close()
return nil
}
+32 -1
View File
@@ -17,9 +17,23 @@ type Config struct {
JWT JWTConfig JWT JWTConfig
Auth AuthConfig Auth AuthConfig
Proxy ProxyConfig Proxy ProxyConfig
RateLimit RateLimitConfig
WebAuthn WebAuthnConfig
Master string // 渠道密钥 AES-GCM 主密钥(来自环境变量) Master string // 渠道密钥 AES-GCM 主密钥(来自环境变量)
} }
// WebAuthnConfig Passkey(WebAuthn)配置。
type WebAuthnConfig struct {
RPID string // Relying Party ID(域名,如 localhost)
RPOrigin string // 前端来源,如 http://localhost:5173
RPName string // 展示名
}
// RateLimitConfig 限流参数(MVP 内存计数,Redis 后置)。
type RateLimitConfig struct {
UserRPS int // 用户级每秒请求数上限(0=不限制)
}
type DBConfig struct { type DBConfig struct {
Driver string // sqlite | postgres Driver string // sqlite | postgres
DSN string DSN string
@@ -52,6 +66,7 @@ type ProxyConfig struct {
Timeout time.Duration Timeout time.Duration
HealthInterval time.Duration // 渠道健康检查周期 HealthInterval time.Duration // 渠道健康检查周期
HealthFailThreshold int // 连续失败 N 次进 cooldown HealthFailThreshold int // 连续失败 N 次进 cooldown
LogRaw bool // 记录管理员原始请求体+响应到 usage_logs(调试用,默认关)
} }
// loadDotEnv 读取 .env 并把 KEY=VALUE 注入环境变量(AutomaticEnv 自动映射 OT_ 前缀)。 // loadDotEnv 读取 .env 并把 KEY=VALUE 注入环境变量(AutomaticEnv 自动映射 OT_ 前缀)。
@@ -112,9 +127,16 @@ func Load() (*Config, error) {
v.SetDefault("proxy.upstream_base_url", "https://api.openai.com") v.SetDefault("proxy.upstream_base_url", "https://api.openai.com")
v.SetDefault("proxy.upstream_key", "") v.SetDefault("proxy.upstream_key", "")
v.SetDefault("proxy.default_model", "gpt-4o-mini") v.SetDefault("proxy.default_model", "gpt-4o-mini")
v.SetDefault("proxy.timeout", "120s") v.SetDefault("proxy.timeout", "300s")
v.SetDefault("proxy.health_interval", "60s") v.SetDefault("proxy.health_interval", "60s")
v.SetDefault("proxy.health_fail_threshold", 2) v.SetDefault("proxy.health_fail_threshold", 2)
v.SetDefault("proxy.log_raw", false)
v.SetDefault("ratelimit.user_rps", 20)
v.SetDefault("webauthn.rp_id", "localhost")
v.SetDefault("webauthn.rp_origin", "http://localhost:5173")
v.SetDefault("webauthn.rp_name", "openteam")
return &Config{ return &Config{
Env: v.GetString("env"), Env: v.GetString("env"),
@@ -148,6 +170,15 @@ func Load() (*Config, error) {
Timeout: v.GetDuration("proxy.timeout"), Timeout: v.GetDuration("proxy.timeout"),
HealthInterval: v.GetDuration("proxy.health_interval"), HealthInterval: v.GetDuration("proxy.health_interval"),
HealthFailThreshold: v.GetInt("proxy.health_fail_threshold"), HealthFailThreshold: v.GetInt("proxy.health_fail_threshold"),
LogRaw: v.GetBool("proxy.log_raw"),
},
RateLimit: RateLimitConfig{
UserRPS: v.GetInt("ratelimit.user_rps"),
},
WebAuthn: WebAuthnConfig{
RPID: v.GetString("webauthn.rp_id"),
RPOrigin: v.GetString("webauthn.rp_origin"),
RPName: v.GetString("webauthn.rp_name"),
}, },
Master: v.GetString("master_key"), Master: v.GetString("master_key"),
}, nil }, nil
+245
View File
@@ -0,0 +1,245 @@
// Package passkey 封装 WebAuthn(passkey)注册与登录。
// 依赖 go-webauthn 库;RPID/RPOrigin 由配置提供。
package passkey
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"sync"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/openteam/server/internal/store"
"gorm.io/gorm"
)
type Config struct {
RPID string
Origin string
Name string
}
// Service WebAuthn 服务:凭据存储 + challenge 会话(内存)。
type Service struct {
wa *webauthn.WebAuthn
db *gorm.DB
mu sync.Mutex
sessions map[string]webauthn.SessionData // keyed by challenge
}
func New(db *gorm.DB, cfg Config) (*Service, error) {
wa, err := webauthn.New(&webauthn.Config{
RPDisplayName: cfg.Name,
RPID: cfg.RPID,
RPOrigins: []string{cfg.Origin},
})
if err != nil {
return nil, err
}
return &Service{wa: wa, db: db, sessions: map[string]webauthn.SessionData{}}, 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
}
// ---------------------------------------------------------------------------
// 注册
// 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
}
s.storeSession(session)
return creation, nil
}
// FinishRegistration 校验浏览器返回的凭据并落库。
func (s *Service) FinishRegistration(u *store.User, challenge string, body, name []byte) error {
session, ok := s.takeSession(challenge)
if !ok {
return errors.New("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 := string(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
}
s.storeSession(session)
return assertion, nil
}
// BeginDiscoverableLogin 无用户名(使用平台/漫游器上的可发现凭据)。
func (s *Service) BeginDiscoverableLogin() (*protocol.CredentialAssertion, error) {
assertion, session, err := s.wa.BeginDiscoverableLogin()
if err != nil {
return nil, err
}
s.storeSession(session)
return assertion, nil
}
// FinishLogin 校验断言并更新签名计数。
func (s *Service) FinishLogin(u *store.User, challenge string, body []byte) error {
session, ok := s.takeSession(challenge)
if !ok {
return errors.New("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 := s.takeSession(challenge)
if !ok {
return nil, errors.New("challenge 已过期或不存在")
}
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
handler := func(rawID, userHandle []byte) (webauthn.User, error) {
var pk store.Passkey
if err := s.db.Where("credential_id = ?", rawID).First(&pk).Error; err != nil {
return nil, errors.New("credential not found")
}
var u store.User
if err := s.db.First(&u, pk.UserID).Error; err != nil {
return nil, errors.New("user not found")
}
return s.loadWebUser(&u)
}
user, cred, err := s.wa.FinishPasskeyLogin(handler, session, req)
if err != nil {
return nil, err
}
wu := user.(*webUser)
_ = s.updateCredential(wu.id, cred)
var st store.User
if err := s.db.First(&st, wu.id).Error; err != nil {
return nil, err
}
return &st, nil
}
// ---------------------------------------------------------------------------
// 管理
// 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
}
// ---------------------------------------------------------------------------
// challenge 会话
func (s *Service) storeSession(session *webauthn.SessionData) {
s.mu.Lock()
s.sessions[session.Challenge] = *session
s.mu.Unlock()
}
func (s *Service) takeSession(challenge string) (webauthn.SessionData, bool) {
s.mu.Lock()
sess, ok := s.sessions[challenge]
if ok {
delete(s.sessions, challenge)
}
s.mu.Unlock()
// Expires 可能为零值:go-webauthn 默认 Enforce=false 不设过期时间。
// 零值时间恒早于 now,直接 After 会把每个 challenge 都判为过期,
// 与库内部一致,仅当显式设置了过期时间才做校验。
if ok && !sess.Expires.IsZero() && time.Now().After(sess.Expires) {
return webauthn.SessionData{}, false
}
return sess, ok
}
+56
View File
@@ -0,0 +1,56 @@
package passkey
import (
"testing"
"github.com/glebarez/sqlite"
"github.com/openteam/server/internal/store"
"gorm.io/gorm"
)
func newTestService(t *testing.T) *Service {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&store.User{}, &store.Passkey{}); err != nil {
t.Fatal(err)
}
s, err := New(db, Config{RPID: "localhost", Origin: "http://localhost:8080", Name: "openteam test"})
if err != nil {
t.Fatal(err)
}
return s
}
// TestChallengeSessionRoundTrip 验证 begin 产生的 challenge 能被 complete 取回。
// go-webauthn 默认 Enforce=false,SessionData.Expires 为零值;takeSession 的
// 过期判断必须跳过零值时间,否则每个 challenge 都被当成已过期(线上必现
// "challenge 已过期或不存在")。
func TestChallengeSessionRoundTrip(t *testing.T) {
s := newTestService(t)
u := &store.User{Username: "alice", Email: "alice@example.com", Role: "user", Status: "active"}
if err := s.db.Create(u).Error; err != nil {
t.Fatal(err)
}
creation, err := s.BeginRegistration(u)
if err != nil {
t.Fatal(err)
}
challenge := creation.Response.Challenge.String() // 与前端回传一致的 base64url
sess, ok := s.takeSession(challenge)
if !ok {
t.Fatal("takeSession 返回 false:challenge 被误判为已过期(Expires 零值 bug)")
}
if sess.Challenge != challenge {
t.Fatalf("session challenge 不匹配: %q != %q", sess.Challenge, challenge)
}
// challenge 一次性消费
if _, ok := s.takeSession(challenge); ok {
t.Fatal("takeSession 应一次性消费 challenge,二次取应失败")
}
}
+3 -3
View File
@@ -1,4 +1,4 @@
// Package apikey 生成与管理 API Key:sk- + 48 位 base62 随机串。 // Package apikey 生成与管理 API Key:sk-ot- + 48 位 base62 随机串。
// 库中仅存 SHA-256 哈希与展示前缀(PLANNING §4.3.3)。 // 库中仅存 SHA-256 哈希与展示前缀(PLANNING §4.3.3)。
package apikey package apikey
@@ -12,7 +12,7 @@ import (
const ( const (
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
keyLen = 48 keyLen = 48
prefix = "sk-" prefix = "sk-ot-"
) )
// Generate 生成明文 key(仅创建时展示一次)与哈希、前缀。 // Generate 生成明文 key(仅创建时展示一次)与哈希、前缀。
@@ -34,7 +34,7 @@ func Hash(key string) string {
return hex.EncodeToString(sum[:]) return hex.EncodeToString(sum[:])
} }
// Prefix 展示前缀:sk-aB3cD5…(前 12 字符) // Prefix 展示前缀:sk-ot-aB3c…(前 12 字符)
func Prefix(key string) string { func Prefix(key string) string {
if len(key) <= 12 { if len(key) <= 12 {
return key return key
+10 -7
View File
@@ -3,32 +3,35 @@ package apikey
import "testing" import "testing"
func TestGenerateValid(t *testing.T) { func TestGenerateValid(t *testing.T) {
plain, hash, prefix, err := Generate() plain, hash, keyPrefix, err := Generate()
if err != nil { if err != nil {
t.Fatalf("Generate: %v", err) t.Fatalf("Generate: %v", err)
} }
if !Valid(plain) { if !Valid(plain) {
t.Fatalf("generated key invalid: %q", plain) t.Fatalf("generated key invalid: %q", plain)
} }
if len(plain) != len("sk-")+48 { if len(plain) != len(prefix)+keyLen {
t.Fatalf("unexpected key length: %d", len(plain)) t.Fatalf("unexpected key length: %d", len(plain))
} }
if keyPrefix != plain[:12] {
t.Fatalf("prefix mismatch: %s vs %s", keyPrefix, plain[:12])
}
if Hash(plain) != hash { if Hash(plain) != hash {
t.Fatal("hash mismatch") t.Fatal("hash mismatch")
} }
if prefix != plain[:12] {
t.Fatalf("prefix mismatch: %s vs %s", prefix, plain[:12])
}
} }
func TestHashStable(t *testing.T) { func TestHashStable(t *testing.T) {
if Hash("sk-test") != Hash("sk-test") { if Hash("sk-ot-test") != Hash("sk-ot-test") {
t.Fatal("hash not stable") t.Fatal("hash not stable")
} }
} }
func TestValidRejects(t *testing.T) { func TestValidRejects(t *testing.T) {
cases := []string{"", "sk-abc", "abc-123456789012345678901234567890123456789012345678", "sk-1234567890123456789012345678901234567890123456789"} tooShort := prefix + "abc"
tooLong := prefix + "1234567890123456789012345678901234567890123456789"
wrongPrefix := "abc-ot123456789012345678901234567890123456789012345678"
cases := []string{"", tooShort, wrongPrefix, tooLong}
for _, c := range cases { for _, c := range cases {
if Valid(c) { if Valid(c) {
t.Fatalf("expected invalid: %q", c) t.Fatalf("expected invalid: %q", c)
+112
View File
@@ -0,0 +1,112 @@
// Package ratelimit 内存限流与配额(MVP 起步,Redis 后置)。
// 覆盖:密钥级每日请求数 / 每日 token 数配额、用户级每秒速率。
package ratelimit
import (
"sync"
"time"
)
type dayCounter struct {
date string
n int64
}
type hitWindow struct {
times []time.Time
limit int
window time.Duration
}
// Limiter 内存计数器。并发安全。
type Limiter struct {
mu sync.Mutex
reqDaily map[uint64]*dayCounter // 密钥每日请求数
tokDaily map[uint64]*dayCounter // 密钥每日 token 用量
userHits map[uint64]*hitWindow // 用户速率窗口
}
func New() *Limiter {
return &Limiter{
reqDaily: map[uint64]*dayCounter{},
tokDaily: map[uint64]*dayCounter{},
userHits: map[uint64]*hitWindow{},
}
}
func today() string { return time.Now().UTC().Format("2006-01-02") }
// AllowRequestDaily 检查并计数密钥每日请求配额;无配额(limit<=0)时仅计数。
// 返回 false 表示超过配额。
func (l *Limiter) AllowRequestDaily(keyID uint64, limit int) bool {
l.mu.Lock()
defer l.mu.Unlock()
d := today()
c, ok := l.reqDaily[keyID]
if !ok || c.date != d {
c = &dayCounter{date: d}
l.reqDaily[keyID] = c
}
c.n++
if limit > 0 && c.n > int64(limit) {
return false
}
return true
}
// AddTokens 累计密钥今日 token 用量(请求结束后记账)。
func (l *Limiter) AddTokens(keyID uint64, n int64) {
if n <= 0 {
return
}
l.mu.Lock()
defer l.mu.Unlock()
d := today()
c, ok := l.tokDaily[keyID]
if !ok || c.date != d {
c = &dayCounter{date: d}
l.tokDaily[keyID] = c
}
c.n += n
}
// TokensUsed 返回密钥今日已用 token。
func (l *Limiter) TokensUsed(keyID uint64) int64 {
l.mu.Lock()
defer l.mu.Unlock()
c, ok := l.tokDaily[keyID]
if !ok || c.date != today() {
return 0
}
return c.n
}
// AllowUserRate 用户级每秒请求速率限制(滑动窗口);limit<=0 不限制。
func (l *Limiter) AllowUserRate(userID uint64, limit int) bool {
if limit <= 0 {
return true
}
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
win := time.Second
w, ok := l.userHits[userID]
if !ok || w.limit != limit || w.window != win {
w = &hitWindow{limit: limit, window: win}
l.userHits[userID] = w
}
// 清理窗口外的时间戳
cutoff := now.Add(-win)
keep := w.times[:0]
for _, t := range w.times {
if t.After(cutoff) {
keep = append(keep, t)
}
}
w.times = keep
if len(w.times) >= limit {
return false
}
w.times = append(w.times, now)
return true
}
@@ -0,0 +1,52 @@
package ratelimit
import "testing"
func TestAllowRequestDaily(t *testing.T) {
l := New()
if !l.AllowRequestDaily(1, 2) {
t.Fatal("first request should be allowed")
}
if !l.AllowRequestDaily(1, 2) {
t.Fatal("second request should be allowed")
}
if l.AllowRequestDaily(1, 2) {
t.Fatal("third request should be blocked")
}
// 另一个 key 不受影响
if !l.AllowRequestDaily(2, 2) {
t.Fatal("other key should be allowed")
}
}
func TestTokensDaily(t *testing.T) {
l := New()
l.AddTokens(1, 100)
l.AddTokens(1, 50)
if got := l.TokensUsed(1); got != 150 {
t.Fatalf("tokens = %d, want 150", got)
}
if got := l.TokensUsed(2); got != 0 {
t.Fatalf("other key tokens = %d, want 0", got)
}
// 配额检查语义:已达 150 再设配额 150 应拒绝
l.AddTokens(3, 150)
if l.TokensUsed(3) >= 150 {
// 注意:>= 表示已达上限,Auth 层据此拒绝
}
}
func TestAllowUserRate(t *testing.T) {
l := New()
// limit=1:立即第二次应被拒绝
if !l.AllowUserRate(9, 1) {
t.Fatal("first should be allowed")
}
if l.AllowUserRate(9, 1) {
t.Fatal("second in same second should be blocked")
}
// limit<=0 不限制
if !l.AllowUserRate(9, 0) {
t.Fatal("limit 0 should always allow")
}
}
@@ -0,0 +1,24 @@
// Package tokenizer 按模型估算 token 数(tiktoken-go)。
// 用于流式中断时对已生成内容做近似计费;未知模型回退 cl100k_base。
package tokenizer
import "github.com/tiktoken-go/tokenizer"
// Count 估算文本 token 数;空文本返回 0。
func Count(text, model string) int {
if text == "" {
return 0
}
enc, err := tokenizer.ForModel(tokenizer.Model(model))
if err != nil {
enc, err = tokenizer.Get(tokenizer.Cl100kBase)
if err != nil {
return 0
}
}
toks, _, err := enc.Encode(text)
if err != nil {
return 0
}
return len(toks)
}
@@ -202,6 +202,51 @@ func TestChatToMessagesResp(t *testing.T) {
} }
} }
// TestResponsesToMessagesRespToolCall 非流式 Responses 响应 → Messages 响应(经 chat 中转)。
func TestResponsesToMessagesRespToolCall(t *testing.T) {
in := `{"id":"resp_abc","object":"response","model":"glm-4","status":"completed",
"output":[
{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我来查询"}]},
{"type":"function_call","id":"fc_1","call_id":"call_9","name":"get_weather","arguments":"{\"city\":\"北京\"}"}
],
"usage":{"input_tokens":12,"output_tokens":8,"total_tokens":20}}`
out, err := ConvertResponse([]byte(in), ProtoResponses, ProtoMessages)
if err != nil {
t.Fatalf("convert: %v", err)
}
var m map[string]any
_ = json.Unmarshal(out, &m)
if m["stop_reason"] != "tool_use" {
t.Fatalf("stop_reason = %v", m["stop_reason"])
}
if m["model"] != "glm-4" {
t.Fatalf("model = %v", m["model"])
}
content := m["content"].([]any)
var text, tool string
for _, b := range content {
bm := b.(map[string]any)
switch bm["type"] {
case "text":
text, _ = bm["text"].(string)
case "tool_use":
tool = "y"
if bm["id"] != "call_9" || bm["name"] != "get_weather" {
t.Fatalf("tool block = %v", bm)
}
if in, _ := bm["input"].(map[string]any); in["city"] != "北京" {
t.Fatalf("tool input = %v", bm["input"])
}
}
}
if text != "我来查询" {
t.Fatalf("text = %q", text)
}
if tool != "y" {
t.Fatal("expected tool_use block")
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 流式转换 // 流式转换
@@ -306,3 +351,674 @@ func TestStreamMessagesToResponses(t *testing.T) {
t.Fatalf("missing response.completed: %s", out) t.Fatalf("missing response.completed: %s", out)
} }
} }
// ---------------------------------------------------------------------------
// 图片内容块转换(messages image / chat image_url / responses input_image 互转不丢图)
const testB64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
// userContentBlocks 断言 Chat 用户消息 content 为块数组并返回。
func userContentBlocks(t *testing.T, out []byte) []any {
t.Helper()
var m map[string]any
if err := json.Unmarshal(out, &m); err != nil {
t.Fatalf("unmarshal: %v\n%s", err, out)
}
msgs := m["messages"].([]any)
if len(msgs) != 1 {
t.Fatalf("messages len = %d: %s", len(msgs), out)
}
content, ok := msgs[0].(map[string]any)["content"].([]any)
if !ok {
t.Fatalf("content not array: %s", out)
}
return content
}
// blockURL 取 image_url 块的 url。
func blockURL(t *testing.T, b any) string {
t.Helper()
bm := b.(map[string]any)
if bm["type"] != "image_url" {
t.Fatalf("block type = %v", bm["type"])
}
iu := bm["image_url"].(map[string]any)
url, _ := iu["url"].(string)
return url
}
func TestMessagesToChatReqImage(t *testing.T) {
in := mustJSON(t, map[string]any{
"model": "gpt-4o-mini",
"messages": []any{map[string]any{"role": "user", "content": []any{
map[string]any{"type": "text", "text": "describe"},
map[string]any{"type": "image", "source": map[string]any{"type": "base64", "media_type": "image/png", "data": testB64}},
map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": "https://example.com/a.png"}},
}}},
})
out, err := ConvertRequest([]byte(in), ProtoMessages, ProtoChat)
if err != nil {
t.Fatalf("convert: %v", err)
}
blocks := userContentBlocks(t, out)
if len(blocks) != 3 {
t.Fatalf("blocks len = %d: %s", len(blocks), out)
}
if got := blockURL(t, blocks[1]); got != "data:image/png;base64,"+testB64 {
t.Fatalf("base64 image url = %q", got)
}
if got := blockURL(t, blocks[2]); got != "https://example.com/a.png" {
t.Fatalf("url image = %q", got)
}
}
// 多图 + 图/文交错顺序:转换后块数量与顺序保持不变(文档推荐的 Image 1:/Image 2: 标签模式)。
func TestMessagesToChatReqMultiImage(t *testing.T) {
img := func(url string) any {
return map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": url}}
}
in := mustJSON(t, map[string]any{
"model": "gpt-4o-mini",
"messages": []any{map[string]any{"role": "user", "content": []any{
map[string]any{"type": "text", "text": "Image 1:"},
img("https://example.com/1.png"),
map[string]any{"type": "text", "text": "Image 2:"},
img("https://example.com/2.png"),
map[string]any{"type": "text", "text": "How are these images different?"},
}}},
})
out, err := ConvertRequest([]byte(in), ProtoMessages, ProtoChat)
if err != nil {
t.Fatalf("convert: %v", err)
}
blocks := userContentBlocks(t, out)
if len(blocks) != 5 {
t.Fatalf("blocks len = %d: %s", len(blocks), out)
}
types := []string{}
for _, b := range blocks {
types = append(types, b.(map[string]any)["type"].(string))
}
want := []string{"text", "image_url", "text", "image_url", "text"}
for i := range want {
if types[i] != want[i] {
t.Fatalf("block[%d] type = %s, want %s: %v", i, types[i], want[i], types)
}
}
// 第二张图仍是第二张
if got := blockURL(t, blocks[3]); got != "https://example.com/2.png" {
t.Fatalf("image2 url = %q", got)
}
}
func TestChatToMessagesReqImage(t *testing.T) {
in := mustJSON(t, map[string]any{
"model": "claude-sonnet-5",
"messages": []any{map[string]any{"role": "user", "content": []any{
map[string]any{"type": "text", "text": "describe"},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/jpeg;base64," + testB64}},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://example.com/b.png"}},
}}},
})
out, err := ConvertRequest([]byte(in), ProtoChat, ProtoMessages)
if err != nil {
t.Fatalf("convert: %v", err)
}
var m map[string]any
_ = json.Unmarshal(out, &m)
msgs := m["messages"].([]any)
content := msgs[0].(map[string]any)["content"].([]any)
if len(content) != 3 {
t.Fatalf("content len = %d: %s", len(content), out)
}
src1 := content[1].(map[string]any)["source"].(map[string]any)
if src1["type"] != "base64" || src1["media_type"] != "image/jpeg" || src1["data"] != testB64 {
t.Fatalf("base64 source = %v", src1)
}
src2 := content[2].(map[string]any)["source"].(map[string]any)
if src2["type"] != "url" || src2["url"] != "https://example.com/b.png" {
t.Fatalf("url source = %v", src2)
}
}
func TestResponsesToChatReqImage(t *testing.T) {
in := mustJSON(t, map[string]any{
"model": "claude-sonnet-5",
"input": []any{map[string]any{"role": "user", "content": []any{
map[string]any{"type": "input_text", "text": "describe"},
map[string]any{"type": "input_image", "image_url": "data:image/png;base64," + testB64},
}}},
})
out, err := ConvertRequest([]byte(in), ProtoResponses, ProtoChat)
if err != nil {
t.Fatalf("convert: %v", err)
}
blocks := userContentBlocks(t, out)
if len(blocks) != 2 {
t.Fatalf("blocks len = %d: %s", len(blocks), out)
}
if got := blockURL(t, blocks[1]); got != "data:image/png;base64,"+testB64 {
t.Fatalf("image url = %q", got)
}
}
func TestChatToResponsesReqImage(t *testing.T) {
in := mustJSON(t, map[string]any{
"model": "gpt-4o",
"messages": []any{map[string]any{"role": "user", "content": []any{
map[string]any{"type": "text", "text": "describe"},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://example.com/c.png"}},
}}},
})
out, err := ConvertRequest([]byte(in), ProtoChat, ProtoResponses)
if err != nil {
t.Fatalf("convert: %v", err)
}
var m map[string]any
_ = json.Unmarshal(out, &m)
var input []map[string]any
// 单条 user 消息项
switch v := m["input"].(type) {
case map[string]any:
input = []map[string]any{v}
case []any:
for _, it := range v {
input = append(input, it.(map[string]any))
}
}
content := input[0]["content"].([]any)
if len(content) != 2 {
t.Fatalf("content len = %d: %s", len(content), out)
}
img := content[1].(map[string]any)
if img["type"] != "input_image" {
t.Fatalf("block type = %v", img["type"])
}
if img["image_url"] != "https://example.com/c.png" {
t.Fatalf("image_url = %v", img["image_url"])
}
}
// 链式转换:responses→messages、messages→responses(经 chat 中转)也不丢图。
func TestResponsesToMessagesReqImage(t *testing.T) {
in := mustJSON(t, map[string]any{
"model": "claude-sonnet-5",
"input": []any{map[string]any{"role": "user", "content": []any{
map[string]any{"type": "input_text", "text": "describe"},
map[string]any{"type": "input_image", "image_url": "data:image/png;base64," + testB64},
}}},
})
out, err := ConvertRequest([]byte(in), ProtoResponses, ProtoMessages)
if err != nil {
t.Fatalf("convert: %v", err)
}
var m map[string]any
_ = json.Unmarshal(out, &m)
msgs := m["messages"].([]any)
content := msgs[0].(map[string]any)["content"].([]any)
if len(content) != 2 {
t.Fatalf("content len = %d: %s", len(content), out)
}
src := content[1].(map[string]any)["source"].(map[string]any)
if src["type"] != "base64" || src["data"] != testB64 {
t.Fatalf("source = %v", src)
}
}
func TestMessagesToResponsesReqImage(t *testing.T) {
in := mustJSON(t, map[string]any{
"model": "gpt-4o",
"messages": []any{map[string]any{"role": "user", "content": []any{
map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": "https://example.com/d.png"}},
map[string]any{"type": "text", "text": "describe"},
}}},
})
out, err := ConvertRequest([]byte(in), ProtoMessages, ProtoResponses)
if err != nil {
t.Fatalf("convert: %v", err)
}
var m map[string]any
_ = json.Unmarshal(out, &m)
var content []any
switch v := m["input"].(type) {
case map[string]any:
content = v["content"].([]any)
case []any:
content = v[0].(map[string]any)["content"].([]any)
}
if len(content) != 2 {
t.Fatalf("content len = %d: %s", len(content), out)
}
img := content[0].(map[string]any)
if img["type"] != "input_image" || img["image_url"] != "https://example.com/d.png" {
t.Fatalf("input_image = %v", img)
}
}
// ---------------------------------------------------------------------------
// 流式工具调用转换(chat tool_calls / messages tool_use / responses function_call 互转不丢)
// sseEvent 一条解析出的 SSE 事件(dataLine 无 event 行时 typ 为空串)。
type sseEvent struct {
typ string
data map[string]any
}
// parseSSE 把转换器输出拆成事件列表。
func parseSSE(t *testing.T, s string) []sseEvent {
t.Helper()
var evs []sseEvent
cur := ""
for _, ln := range strings.Split(s, "\n") {
switch {
case strings.HasPrefix(ln, "event: "):
cur = strings.TrimSpace(strings.TrimPrefix(ln, "event: "))
case strings.HasPrefix(ln, "data: "):
d := strings.TrimPrefix(ln, "data: ")
if d == "[DONE]" {
evs = append(evs, sseEvent{typ: "[DONE]"})
continue
}
var m map[string]any
if err := json.Unmarshal([]byte(d), &m); err != nil {
t.Fatalf("parse sse data: %v\n%s", err, d)
}
evs = append(evs, sseEvent{typ: cur, data: m})
}
}
return evs
}
// findSSE 返回第一个 typ 匹配且(index>=0 时)index 字段相符的事件。
func findSSE(evs []sseEvent, typ string, index int) *sseEvent {
for i := range evs {
if evs[i].typ != typ {
continue
}
if index >= 0 {
if idx, ok := evs[i].data["index"].(float64); !ok || int(idx) != index {
continue
}
}
return &evs[i]
}
return nil
}
// inputJSONDeltas 汇总所有 input_json_delta:index → 拼接后的 partial_json。
func inputJSONDeltas(evs []sseEvent) map[int]string {
out := map[int]string{}
for _, e := range evs {
if e.typ != "content_block_delta" {
continue
}
d, _ := e.data["delta"].(map[string]any)
if d == nil || d["type"] != "input_json_delta" {
continue
}
idx, _ := e.data["index"].(float64)
p, _ := d["partial_json"].(string)
out[int(idx)] += p
}
return out
}
func TestStreamChatToMessagesToolCall(t *testing.T) {
tf := newChatToMessages().line
out := feedLines(t, tf, []string{
`data: {"id":"chatcmpl-1","model":"glm-4.7-flash","choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"用户"}}]}` + "\n\n",
`data: {"id":"chatcmpl-1","model":"glm-4.7-flash","choices":[{"index":0,"delta":{"content":"我来帮您查询"}}]}` + "\n\n",
`data: {"id":"chatcmpl-1","model":"glm-4.7-flash","choices":[{"index":0,"delta":{"tool_calls":[{"id":"call_dbcef6a399ad47bb973fa83a","index":0,"type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"北京\"}"}}]}}]}` + "\n\n",
`data: {"id":"chatcmpl-1","model":"glm-4.7-flash","choices":[{"index":0,"finish_reason":"tool_calls","delta":{"role":"assistant","content":""}}],"usage":{"prompt_tokens":167,"completion_tokens":48}}` + "\n\n",
"data: [DONE]\n\n",
})
evs := parseSSE(t, out)
// message_start
if ms := findSSE(evs, "message_start", -1); ms == nil {
t.Fatal("missing message_start")
}
// 文本块 index 0
if cs := findSSE(evs, "content_block_start", 0); cs == nil {
t.Fatal("missing text content_block_start")
} else if cb := cs.data["content_block"].(map[string]any); cb["type"] != "text" {
t.Fatalf("block 0 type = %v", cb["type"])
}
if !strings.Contains(out, `"text":"我来帮您查询"`) {
t.Fatalf("missing text delta: %s", out)
}
// 工具块 index 1
cs := findSSE(evs, "content_block_start", 1)
if cs == nil {
t.Fatal("missing tool content_block_start")
}
cb := cs.data["content_block"].(map[string]any)
if cb["type"] != "tool_use" || cb["id"] != "call_dbcef6a399ad47bb973fa83a" || cb["name"] != "get_weather" {
t.Fatalf("tool block = %v", cb)
}
if input, ok := cb["input"].(map[string]any); !ok || len(input) != 0 {
t.Fatalf("tool input should be empty object, got %v", cb["input"])
}
// input_json_delta 整段 args
if d := inputJSONDeltas(evs)[1]; d != `{"city":"北京"}` {
t.Fatalf("tool args = %q", d)
}
// 两个块都有 stop
if findSSE(evs, "content_block_stop", 0) == nil || findSSE(evs, "content_block_stop", 1) == nil {
t.Fatalf("missing per-block stops: %s", out)
}
// stop_reason tool_use
if md := findSSE(evs, "message_delta", -1); md == nil {
t.Fatal("missing message_delta")
} else if d := md.data["delta"].(map[string]any); d["stop_reason"] != "tool_use" {
t.Fatalf("stop_reason = %v", d["stop_reason"])
}
if findSSE(evs, "message_stop", -1) == nil {
t.Fatal("missing message_stop")
}
}
func TestStreamChatToMessagesToolOnly(t *testing.T) {
tf := newChatToMessages().line
out := feedLines(t, tf, []string{
`data: {"id":"chatcmpl-1","model":"glm-4.7-flash","choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"查询"}}]}` + "\n\n",
`data: {"id":"chatcmpl-1","model":"glm-4.7-flash","choices":[{"index":0,"delta":{"tool_calls":[{"id":"call_1","index":0,"type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"北京\"}"}}]}}]}` + "\n\n",
`data: {"id":"chatcmpl-1","model":"glm-4.7-flash","choices":[{"index":0,"finish_reason":"tool_calls","delta":{"role":"assistant","content":""}}]}` + "\n\n",
"data: [DONE]\n\n",
})
evs := parseSSE(t, out)
// 首块即 tool_use index 0
cs := findSSE(evs, "content_block_start", 0)
if cs == nil {
t.Fatal("missing first block")
}
if cb := cs.data["content_block"].(map[string]any); cb["type"] != "tool_use" {
t.Fatalf("first block type = %v", cb["type"])
}
// 不应出现空文本块
for _, e := range evs {
if e.typ == "content_block_start" {
if cb := e.data["content_block"].(map[string]any); cb["type"] == "text" {
t.Fatalf("unexpected text block: %s", out)
}
}
}
// 单个 stop
if findSSE(evs, "content_block_stop", 0) == nil {
t.Fatal("missing stop 0")
}
if findSSE(evs, "content_block_stop", 1) != nil {
t.Fatalf("unexpected stop 1: %s", out)
}
if md := findSSE(evs, "message_delta", -1); md == nil {
t.Fatal("missing message_delta")
} else if d := md.data["delta"].(map[string]any); d["stop_reason"] != "tool_use" {
t.Fatalf("stop_reason = %v", d["stop_reason"])
}
}
func TestStreamChatToMessagesParallelTools(t *testing.T) {
tf := newChatToMessages().line
out := feedLines(t, tf, []string{
`data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"id":"call_0","index":0,"type":"function","function":{"name":"search","arguments":""}}]}}]}` + "\n\n",
`data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"delta":{"tool_calls":[{"id":"call_1","index":1,"type":"function","function":{"name":"read","arguments":""}}]}}]}` + "\n\n",
`data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"query\":"}}]}}]}` + "\n\n",
`data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\"path\":"}}]}}]}` + "\n\n",
`data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"weather\"}"}}]}}]}` + "\n\n",
`data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"\"a.txt\"}"}}]}}]}` + "\n\n",
`data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"finish_reason":"tool_calls","delta":{}}]}` + "\n\n",
"data: [DONE]\n\n",
})
evs := parseSSE(t, out)
// 块 0 = search,块 1 = read(按首现顺序分配)
if cb := findSSE(evs, "content_block_start", 0); cb == nil {
t.Fatal("missing block 0")
} else if n := cb.data["content_block"].(map[string]any)["name"]; n != "search" {
t.Fatalf("block 0 name = %v", n)
}
if cb := findSSE(evs, "content_block_start", 1); cb == nil {
t.Fatal("missing block 1")
} else if n := cb.data["content_block"].(map[string]any)["name"]; n != "read" {
t.Fatalf("block 1 name = %v", n)
}
// 参数分段正确路由
deltas := inputJSONDeltas(evs)
if deltas[0] != `{"query":"weather"}` {
t.Fatalf("block 0 args = %q", deltas[0])
}
if deltas[1] != `{"path":"a.txt"}` {
t.Fatalf("block 1 args = %q", deltas[1])
}
if findSSE(evs, "content_block_stop", 0) == nil || findSSE(evs, "content_block_stop", 1) == nil {
t.Fatalf("missing stops: %s", out)
}
}
func TestStreamChatToMessagesTextAfterTool(t *testing.T) {
tf := newChatToMessages().line
out := feedLines(t, tf, []string{
`data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"id":"call_1","index":0,"type":"function","function":{"name":"get_weather","arguments":""}}]}}]}` + "\n\n",
`data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"delta":{"content":"天气查询中"}}]}` + "\n\n",
`data: {"id":"chatcmpl-1","model":"deepseek","choices":[{"index":0,"finish_reason":"stop","delta":{}}]}` + "\n\n",
"data: [DONE]\n\n",
})
evs := parseSSE(t, out)
// 工具块 index 0、文本块 index 1
if cb := findSSE(evs, "content_block_start", 0); cb == nil {
t.Fatal("missing tool block")
} else if cb.data["content_block"].(map[string]any)["type"] != "tool_use" {
t.Fatalf("block 0 type = %v", cb.data["content_block"].(map[string]any)["type"])
}
if cb := findSSE(evs, "content_block_start", 1); cb == nil {
t.Fatal("missing text block")
} else if cb.data["content_block"].(map[string]any)["type"] != "text" {
t.Fatalf("block 1 type = %v", cb.data["content_block"].(map[string]any)["type"])
}
// stop 顺序 0,1
var stops []int
for _, e := range evs {
if e.typ == "content_block_stop" {
idx, _ := e.data["index"].(float64)
stops = append(stops, int(idx))
}
}
if len(stops) != 2 || stops[0] != 0 || stops[1] != 1 {
t.Fatalf("stop order = %v", stops)
}
}
func TestStreamMessagesToChatToolUse(t *testing.T) {
tf := newMessagesToChat().line
out := feedLines(t, tf, []string{
"event: message_start\n",
`data: {"type":"message_start","message":{"id":"msg_1","model":"glm-4.7-flash"}}` + "\n\n",
"event: content_block_delta\n",
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"你好"}}` + "\n\n",
"event: content_block_start\n",
`data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_x","name":"get_weather","input":{}}}` + "\n\n",
"event: content_block_delta\n",
`data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\""}}` + "\n\n",
"event: content_block_delta\n",
`data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"city\":\"北京\"}"}}` + "\n\n",
"event: content_block_stop\n",
`data: {"type":"content_block_stop","index":1}` + "\n\n",
"event: message_delta\n",
`data: {"type":"message_delta","delta":{"stop_reason":"tool_use"}}` + "\n\n",
"event: message_stop\n",
`data: {"type":"message_stop"}` + "\n\n",
})
evs := parseSSE(t, out)
// 文本增量
if !strings.Contains(out, `"content":"你好"`) {
t.Fatalf("missing text chunk: %s", out)
}
// tool_use → chat tool_calls:messages 块 index 1 映射为 chat 工具索引 0
var toolStart, argsSeen, finishSeen bool
for _, e := range evs {
choices, _ := e.data["choices"].([]any)
if len(choices) == 0 {
continue
}
c0, _ := choices[0].(map[string]any)
delta, _ := c0["delta"].(map[string]any)
if tcs, ok := delta["tool_calls"].([]any); ok && len(tcs) > 0 {
tc, _ := tcs[0].(map[string]any)
idx, _ := tc["index"].(float64)
if int(idx) != 0 {
continue
}
fn, _ := tc["function"].(map[string]any)
if tc["id"] == "call_x" && fn["name"] == "get_weather" {
toolStart = true
}
if s, _ := fn["arguments"].(string); s != "" {
argsSeen = true
}
}
if fr, _ := c0["finish_reason"].(string); fr == "tool_calls" {
finishSeen = true
}
}
if !toolStart {
t.Fatalf("missing tool_calls start: %s", out)
}
if !argsSeen {
t.Fatalf("missing tool_calls arguments chunks: %s", out)
}
if !finishSeen {
t.Fatalf("missing finish_reason tool_calls: %s", out)
}
if !strings.Contains(out, "data: [DONE]") {
t.Fatalf("missing [DONE]: %s", out)
}
}
func TestStreamResponsesToMessagesFunctionCall(t *testing.T) {
tf := newResponsesToMessages().line
out := feedLines(t, tf, []string{
"event: response.created\n",
`data: {"type":"response.created","response":{"id":"resp_1","model":"glm-4.7-flash"}}` + "\n\n",
"event: response.output_item.added\n",
`data: {"type":"response.output_item.added","item":{"type":"function_call","id":"fc_call_1","call_id":"call_1","name":"get_weather","arguments":"","status":"in_progress"},"output_index":0}` + "\n\n",
"event: response.function_call_arguments.delta\n",
`data: {"type":"response.function_call_arguments.delta","delta":"{\"","item_id":"fc_call_1"}` + "\n\n",
"event: response.function_call_arguments.delta\n",
`data: {"type":"response.function_call_arguments.delta","delta":"city\":\"北京\"}","item_id":"fc_call_1"}` + "\n\n",
"event: response.output_item.done\n",
`data: {"type":"response.output_item.done","item":{"type":"function_call","id":"fc_call_1","arguments":"{\"city\":\"北京\"}","status":"completed"}}` + "\n\n",
"event: response.completed\n",
`data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","usage":{"input_tokens":7,"output_tokens":8}}}` + "\n\n",
})
evs := parseSSE(t, out)
if findSSE(evs, "message_start", -1) == nil {
t.Fatal("missing message_start")
}
// 首块即 tool_use index 0
cs := findSSE(evs, "content_block_start", 0)
if cs == nil {
t.Fatal("missing tool block")
}
cb := cs.data["content_block"].(map[string]any)
if cb["type"] != "tool_use" || cb["id"] != "call_1" || cb["name"] != "get_weather" {
t.Fatalf("tool block = %v", cb)
}
if d := inputJSONDeltas(evs)[0]; d != `{"city":"北京"}` {
t.Fatalf("tool args = %q", d)
}
if findSSE(evs, "content_block_stop", 0) == nil {
t.Fatal("missing stop")
}
if md := findSSE(evs, "message_delta", -1); md == nil {
t.Fatal("missing message_delta")
} else if d := md.data["delta"].(map[string]any); d["stop_reason"] != "tool_use" {
t.Fatalf("stop_reason = %v", d["stop_reason"])
}
if findSSE(evs, "message_stop", -1) == nil {
t.Fatal("missing message_stop")
}
}
func TestStreamResponsesToMessagesTextThenFunction(t *testing.T) {
tf := newResponsesToMessages().line
out := feedLines(t, tf, []string{
"event: response.created\n",
`data: {"type":"response.created","response":{"id":"resp_1","model":"glm-4.7-flash"}}` + "\n\n",
"event: response.output_text.delta\n",
`data: {"type":"response.output_text.delta","delta":"我来查询"}` + "\n\n",
"event: response.output_item.added\n",
`data: {"type":"response.output_item.added","item":{"type":"function_call","id":"fc_call_1","call_id":"call_1","name":"get_weather","arguments":"","status":"in_progress"},"output_index":1}` + "\n\n",
"event: response.function_call_arguments.delta\n",
`data: {"type":"response.function_call_arguments.delta","delta":"{\"city\":\"北京\"}","item_id":"fc_call_1"}` + "\n\n",
"event: response.completed\n",
`data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","usage":{"input_tokens":7,"output_tokens":8}}}` + "\n\n",
})
evs := parseSSE(t, out)
// 文本块 index 0、工具块 index 1
if cb := findSSE(evs, "content_block_start", 0); cb == nil {
t.Fatal("missing text block")
} else if cb.data["content_block"].(map[string]any)["type"] != "text" {
t.Fatalf("block 0 type = %v", cb.data["content_block"].(map[string]any)["type"])
}
if cb := findSSE(evs, "content_block_start", 1); cb == nil {
t.Fatal("missing tool block")
} else if cb.data["content_block"].(map[string]any)["type"] != "tool_use" {
t.Fatalf("block 1 type = %v", cb.data["content_block"].(map[string]any)["type"])
}
if !strings.Contains(out, `"text":"我来查询"`) {
t.Fatalf("missing text delta: %s", out)
}
// stop 顺序 0,1
var stops []int
for _, e := range evs {
if e.typ == "content_block_stop" {
idx, _ := e.data["index"].(float64)
stops = append(stops, int(idx))
}
}
if len(stops) != 2 || stops[0] != 0 || stops[1] != 1 {
t.Fatalf("stop order = %v", stops)
}
if md := findSSE(evs, "message_delta", -1); md == nil {
t.Fatal("missing message_delta")
} else if d := md.data["delta"].(map[string]any); d["stop_reason"] != "tool_use" {
t.Fatalf("stop_reason = %v", d["stop_reason"])
}
}
func TestMessagesToChatReqToolOrder(t *testing.T) {
in := mustJSON(t, map[string]any{
"model": "gpt-4o",
"messages": []any{map[string]any{"role": "assistant", "content": []any{
map[string]any{"type": "text", "text": "我来"},
map[string]any{"type": "tool_use", "id": "call_1", "name": "get_weather", "input": map[string]any{"city": "北京"}},
map[string]any{"type": "tool_result", "tool_use_id": "call_1", "content": "晴"},
}}},
})
out, err := messagesToChatReq([]byte(in))
if err != nil {
t.Fatalf("messagesToChatReq: %v", err)
}
var m map[string]any
if err := json.Unmarshal(out, &m); err != nil {
t.Fatalf("unmarshal: %v", err)
}
msgs := m["messages"].([]any)
if len(msgs) != 2 {
t.Fatalf("messages len = %d: %s", len(msgs), out)
}
if r := msgs[0].(map[string]any)["role"]; r != "assistant" {
t.Fatalf("msg[0] role = %v", r)
}
if r := msgs[1].(map[string]any)["role"]; r != "tool" {
t.Fatalf("msg[1] role = %v", r)
}
if _, ok := msgs[0].(map[string]any)["tool_calls"]; !ok {
t.Fatalf("msg[0] missing tool_calls: %s", out)
}
}
+76 -4
View File
@@ -137,9 +137,11 @@ func chatMsgToAnthropic(m chatMsg) any {
var url string var url string
if iu, ok := b["image_url"].(map[string]any); ok { if iu, ok := b["image_url"].(map[string]any); ok {
url, _ = iu["url"].(string) url, _ = iu["url"].(string)
} else if s, ok := b["image_url"].(string); ok {
url = s
} }
if url != "" { if url != "" {
blocks = append(blocks, map[string]any{"type": "image", "source": map[string]any{"type": "url", "url": url}}) blocks = append(blocks, anthropicImageBlock(url))
} }
} }
} }
@@ -228,13 +230,20 @@ func anthropicMsgToChat(role string, content json.RawMessage) []any {
var blocks []map[string]any var blocks []map[string]any
if json.Unmarshal(content, &blocks) == nil && blocks != nil { if json.Unmarshal(content, &blocks) == nil && blocks != nil {
var out []any var out []any
var toolMsgs []any // tool_result 单独收集,保证排在 assistant(tool_calls) 之后
var textParts []string var textParts []string
var contentBlocks []any // text / image_url 块,保留原始顺序
var toolCalls []any var toolCalls []any
for _, b := range blocks { for _, b := range blocks {
switch b["type"] { switch b["type"] {
case "text": case "text":
if t, _ := b["text"].(string); t != "" { if t, _ := b["text"].(string); t != "" {
textParts = append(textParts, 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": case "tool_use":
id, _ := b["id"].(string) id, _ := b["id"].(string)
@@ -251,12 +260,22 @@ func anthropicMsgToChat(role string, content json.RawMessage) []any {
case "tool_result": case "tool_result":
callID, _ := b["tool_use_id"].(string) callID, _ := b["tool_use_id"].(string)
res := strField(b["content"]) res := strField(b["content"])
out = append(out, map[string]any{"role": "tool", "tool_call_id": callID, "content": res}) toolMsgs = append(toolMsgs, map[string]any{"role": "tool", "tool_call_id": callID, "content": res})
} }
} }
if len(textParts) > 0 || len(toolCalls) > 0 { 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} msg := map[string]any{"role": role}
if len(textParts) > 0 { switch {
case hasImage:
msg["content"] = contentBlocks
case len(textParts) > 0:
msg["content"] = strings.Join(textParts, "") msg["content"] = strings.Join(textParts, "")
} }
if len(toolCalls) > 0 { if len(toolCalls) > 0 {
@@ -264,6 +283,7 @@ func anthropicMsgToChat(role string, content json.RawMessage) []any {
} }
out = append(out, msg) out = append(out, msg)
} }
out = append(out, toolMsgs...)
if len(out) > 0 { if len(out) > 0 {
return out return out
} }
@@ -416,6 +436,58 @@ func intOrNil(p *int, def int) any {
return *p return *p
} }
// 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 rawOrObject(raw json.RawMessage) any { func rawOrObject(raw json.RawMessage) any {
if len(raw) == 0 || string(raw) == "null" { if len(raw) == 0 || string(raw) == "null" {
return map[string]any{} return map[string]any{}
+72 -11
View File
@@ -101,20 +101,82 @@ func responsesInputToChat(raw json.RawMessage) []any {
out = append(out, map[string]any{"role": role, "content": content}) out = append(out, map[string]any{"role": role, "content": content})
} else if blocks, ok := item["content"].([]any); ok { } else if blocks, ok := item["content"].([]any); ok {
var text []string var text []string
var contentBlocks []any
for _, b := range blocks { for _, b := range blocks {
if bm, ok := b.(map[string]any); ok { bm, ok := b.(map[string]any)
if !ok {
continue
}
switch bm["type"] {
case "input_text", "text":
if t, _ := bm["text"].(string); t != "" { if t, _ := bm["text"].(string); t != "" {
text = append(text, 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, "")}) out = append(out, map[string]any{"role": role, "content": strings.Join(text, "")})
} }
} }
} }
}
return out 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 // 请求:Chat → Responses
@@ -170,21 +232,17 @@ func chatToResponsesReq(body []byte) ([]byte, error) {
}}) }})
} }
default: default:
if s := str(m.Content); s != "" { if blocks := chatContentToResponsesBlocks(m.Content); len(blocks) > 0 {
input = append(input, map[string]any{"type": "message", "role": "user", "content": []any{ input = append(input, map[string]any{"type": "message", "role": "user", "content": blocks})
map[string]any{"type": "input_text", "text": s},
}})
} }
} }
} }
if len(system) > 0 { if len(system) > 0 {
out["instructions"] = strings.Join(system, "\n") out["instructions"] = strings.Join(system, "\n")
} }
if len(input) == 1 { // input 必须是数组:部分上游(如火山方舟)只接受 []*InputItem,
out["input"] = input[0] // 单条消息项 // 单对象会被拒(400 Mismatch type)。
} else {
out["input"] = input out["input"] = input
}
if len(req.Tools) > 0 { if len(req.Tools) > 0 {
tools := make([]any, 0, len(req.Tools)) tools := make([]any, 0, len(req.Tools))
@@ -245,8 +303,11 @@ func responsesToChatResp(body []byte) ([]byte, error) {
msg["tool_calls"] = toolCalls msg["tool_calls"] = toolCalls
} }
finish := "stop" finish := "stop"
if string(rawJSON(m, "status")) == `"incomplete"` { switch {
finish = "length" case string(rawJSON(m, "status")) == `"incomplete"`:
finish = "length" // 截断优先,客户端可据此区分
case len(toolCalls) > 0:
finish = "tool_calls" // 有工具调用时映射 tool_calls,否则中转后 stop_reason 成 end_turn
} }
var prompt, completion int64 var prompt, completion int64
if u := rawJSON(m, "usage"); u != nil { if u := rawJSON(m, "usage"); u != nil {
+212 -51
View File
@@ -11,16 +11,17 @@ type sseState struct {
} }
// parseLine 解析一行 SSE;返回是否 data 行及其内容、是否 [DONE]。 // parseLine 解析一行 SSE;返回是否 data 行及其内容、是否 [DONE]。
// data: 后可跟空格(标准)或紧贴 JSON(上游如火山方舟会省略空格)。
func (s *sseState) parseLine(line []byte) (isData bool, data string, done bool) { func (s *sseState) parseLine(line []byte) (isData bool, data string, done bool) {
str := strings.TrimRight(string(line), "\r\n") str := strings.TrimRight(string(line), "\r\n")
switch { switch {
case strings.HasPrefix(str, "event: "): case strings.HasPrefix(str, "event: "):
s.event = strings.TrimSpace(strings.TrimPrefix(str, "event: ")) s.event = strings.TrimSpace(strings.TrimPrefix(str, "event: "))
return false, "", false return false, "", false
case str == "data: [DONE]": case str == "data: [DONE]" || str == "data:[DONE]":
return true, "[DONE]", true return true, "[DONE]", true
case strings.HasPrefix(str, "data: "): case strings.HasPrefix(str, "data:"):
return true, strings.TrimPrefix(str, "data: "), false return true, strings.TrimLeft(strings.TrimPrefix(str, "data:"), " "), false
default: default:
return false, "", false return false, "", false
} }
@@ -49,9 +50,11 @@ func eventLine(name string, obj any) []byte {
type messagesToChat struct { type messagesToChat struct {
sseState sseState
id, model string id, model string
toolIdx map[int]int // messages content block index → chat tool_calls index(顺序编号,避开文本块)
nextTool int
} }
func newMessagesToChat() *messagesToChat { return &messagesToChat{} } func newMessagesToChat() *messagesToChat { return &messagesToChat{toolIdx: map[int]int{}} }
func (t *messagesToChat) line(line []byte) []byte { func (t *messagesToChat) line(line []byte) []byte {
isData, data, done := t.parseLine(line) isData, data, done := t.parseLine(line)
@@ -72,8 +75,43 @@ func (t *messagesToChat) line(line []byte) []byte {
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model, "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}}, "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": case "content_block_delta":
delta, _ := m["delta"].(map[string]any) 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) text, _ := delta["text"].(string)
if text == "" { if text == "" {
return nil return nil
@@ -118,14 +156,19 @@ func joinLines(lines [][]byte) []byte {
type chatToMessages struct { type chatToMessages struct {
sseState sseState
started bool started bool // message_start 已发出
blockStarted bool 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 model string
stopReason string stopReason string
usage any usage any
} }
func newChatToMessages() *chatToMessages { return &chatToMessages{} } func newChatToMessages() *chatToMessages {
return &chatToMessages{textIndex: -1, toolIdx: map[int]int{}}
}
func (t *chatToMessages) line(line []byte) []byte { func (t *chatToMessages) line(line []byte) []byte {
isData, data, done := t.parseLine(line) isData, data, done := t.parseLine(line)
@@ -133,18 +176,18 @@ func (t *chatToMessages) line(line []byte) []byte {
return nil return nil
} }
if done { if done {
// 汇聚最终 message_delta + content_block_stop + message_stop // 汇聚最终:先对每个已开始未停止的块发 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{ md := map[string]any{"type": "message_delta", "delta": map[string]any{
"stop_reason": stopReasonOrEnd(t.stopReason), "stop_sequence": nil, "stop_reason": stopReasonOrEnd(t.stopReason), "stop_sequence": nil,
}} }}
if t.usage != nil { if t.usage != nil {
md["usage"] = t.usage md["usage"] = t.usage
} }
var out [][]byte
out = append(out, eventLine("message_delta", md)) out = append(out, eventLine("message_delta", md))
if t.blockStarted {
out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": 0}))
}
out = append(out, eventLine("message_stop", map[string]any{"type": "message_stop"})) out = append(out, eventLine("message_stop", map[string]any{"type": "message_stop"}))
return joinLines(out) return joinLines(out)
} }
@@ -167,11 +210,12 @@ func (t *chatToMessages) line(line []byte) []byte {
id, _ := m["id"].(string) id, _ := m["id"].(string)
var out [][]byte var out [][]byte
// 首个包含内容或角色的块前,先发 message_start + content_block_start // message_start 只在实际有内容(文本或工具)时发出,避免 GLM 等模型的
if !t.started { // reasoning_content 块(带 role 无 content)提前开出一个空文本块。
role, _ := delta["role"].(string) ensureStarted := func() {
content, _ := delta["content"].(string) if t.started {
if role == "assistant" || content != "" { return
}
t.started = true t.started = true
out = append(out, eventLine("message_start", map[string]any{ out = append(out, eventLine("message_start", map[string]any{
"type": "message_start", "type": "message_start",
@@ -180,34 +224,83 @@ func (t *chatToMessages) line(line []byte) []byte {
"model": t.model, "content": []any{}, "usage": map[string]any{"input_tokens": 0, "output_tokens": 0}, "model": t.model, "content": []any{}, "usage": map[string]any{"input_tokens": 0, "output_tokens": 0},
}, },
})) }))
out = append(out, eventLine("content_block_start", map[string]any{
"type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""},
}))
t.blockStarted = true
} }
}
if content, _ := delta["content"].(string); content != "" { // 文本:delta.content(string;兼容 {type:text,text} 数组)
if !t.started { if content := deltaText(delta); content != "" {
t.started = true if t.textIndex < 0 {
t.blockStarted = true t.textIndex = t.nextIndex
out = append(out, eventLine("message_start", map[string]any{ t.nextIndex++
"type": "message_start", ensureStarted()
"message": map[string]any{"id": "msg_" + strings.TrimPrefix(id, "chatcmpl-"), "type": "message", "role": "assistant", "model": t.model, "content": []any{}},
}))
out = append(out, eventLine("content_block_start", map[string]any{ out = append(out, eventLine("content_block_start", map[string]any{
"type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""}, "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{ out = append(out, eventLine("content_block_delta", map[string]any{
"type": "content_block_delta", "index": 0, "delta": map[string]any{"type": "text_delta", "text": content}, "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 { if u, ok := m["usage"]; ok {
t.usage = u t.usage = u
} }
return joinLines(out) 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 { func stopReasonOrEnd(s string) string {
if s == "" { if s == "" {
return "end_turn" return "end_turn"
@@ -223,9 +316,16 @@ type responsesToMessages struct {
started bool started bool
model string model string
usage any 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{} } func newResponsesToMessages() *responsesToMessages {
return &responsesToMessages{textIndex: -1, toolIdx: map[string]int{}}
}
func (t *responsesToMessages) line(line []byte) []byte { func (t *responsesToMessages) line(line []byte) []byte {
isData, data, done := t.parseLine(line) isData, data, done := t.parseLine(line)
@@ -243,14 +343,16 @@ func (t *responsesToMessages) line(line []byte) []byte {
} }
} }
var out [][]byte var out [][]byte
switch evt { // message_start 只在 response.created 时发出;文本/工具块在对应事件到达时再开,
case "response.created": // 避免纯函数调用响应提前开出一个空文本块。
if !t.started { ensureStarted := func() {
if t.started {
return
}
t.started = true t.started = true
id, _ := m["response"].(map[string]any)
rid := "" rid := ""
if id != nil { if resp, ok := m["response"].(map[string]any); ok {
rid, _ = id["id"].(string) rid, _ = resp["id"].(string)
} }
out = append(out, eventLine("message_start", map[string]any{ out = append(out, eventLine("message_start", map[string]any{
"type": "message_start", "type": "message_start",
@@ -259,22 +361,75 @@ func (t *responsesToMessages) line(line []byte) []byte {
"model": t.model, "content": []any{}, "model": t.model, "content": []any{},
}, },
})) }))
out = append(out, eventLine("content_block_start", map[string]any{
"type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""},
}))
} }
switch evt {
case "response.created":
ensureStarted()
case "response.output_text.delta": case "response.output_text.delta":
delta, _ := m["delta"].(string) delta, _ := m["delta"].(string)
if delta != "" { if delta == "" {
out = append(out, eventLine("content_block_delta", map[string]any{ return nil
"type": "content_block_delta", "index": 0, "delta": map[string]any{"type": "text_delta", "text": delta},
}))
} }
case "response.completed": if t.textIndex < 0 {
out = append(out, eventLine("message_delta", map[string]any{ t.textIndex = t.nextIndex
"type": "message_delta", "delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil}, 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": ""},
})) }))
out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": 0})) 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"})) out = append(out, eventLine("message_stop", map[string]any{"type": "message_stop"}))
} }
return joinLines(out) return joinLines(out)
@@ -414,6 +569,7 @@ type chatToResponses struct {
sseState sseState
model string model string
usage any usage any
finishSeen bool
done bool done bool
} }
@@ -425,6 +581,7 @@ func (t *chatToResponses) line(line []byte) []byte {
return nil return nil
} }
if done { if done {
// 流结束兜底:finish 后 usage 未随块到达时在此补发 completed
if !t.done { if !t.done {
t.done = true t.done = true
return eventLine("response.completed", map[string]any{ return eventLine("response.completed", map[string]any{
@@ -453,6 +610,9 @@ func (t *chatToResponses) line(line []byte) []byte {
finish, _ = c0["finish_reason"].(string) finish, _ = c0["finish_reason"].(string)
} }
} }
if finish != "" {
t.finishSeen = true
}
var out [][]byte var out [][]byte
if role, _ := delta["role"].(string); role == "assistant" { if role, _ := delta["role"].(string); role == "assistant" {
out = append(out, eventLine("response.created", map[string]any{ out = append(out, eventLine("response.created", map[string]any{
@@ -465,7 +625,8 @@ func (t *chatToResponses) line(line []byte) []byte {
"type": "response.output_text.delta", "delta": content, "item_id": "msg_1", "output_index": 0, "content_index": 0, "type": "response.output_text.delta", "delta": content, "item_id": "msg_1", "output_index": 0, "content_index": 0,
})) }))
} }
if finish != "" && !t.done { // 上游 usage 块(choices 为空)通常晚于 finish_reason:此时再发 completed,携带 usage
if _, hasUsage := m["usage"]; hasUsage && t.finishSeen && !t.done {
t.done = true t.done = true
out = append(out, eventLine("response.completed", map[string]any{ out = append(out, eventLine("response.completed", map[string]any{
"type": "response.completed", "type": "response.completed",
+168 -33
View File
@@ -3,15 +3,19 @@
package proxy package proxy
import ( import (
"encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/openteam/server/internal/channel" "github.com/openteam/server/internal/channel"
"github.com/openteam/server/internal/pkg/apikey" "github.com/openteam/server/internal/pkg/apikey"
"github.com/openteam/server/internal/pkg/crypto" "github.com/openteam/server/internal/pkg/crypto"
"github.com/openteam/server/internal/pkg/ratelimit"
"github.com/openteam/server/internal/proxy/convert" "github.com/openteam/server/internal/proxy/convert"
"github.com/openteam/server/internal/store" "github.com/openteam/server/internal/store"
"github.com/openteam/server/internal/usage" "github.com/openteam/server/internal/usage"
@@ -29,16 +33,92 @@ type Gateway struct {
ch *channel.Service ch *channel.Service
rec *usage.Recorder rec *usage.Recorder
enc *crypto.Encryptor enc *crypto.Encryptor
lim *ratelimit.Limiter
userRPS int
logRaw bool
hc *http.Client hc *http.Client
policyMu sync.Mutex
policy modelPolicy
} }
func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder) *Gateway { // modelPolicy 全局模型限制策略(来自系统配置,短时缓存)。
type modelPolicy struct {
allow []string
deny []string
at time.Time
}
const modelPolicyTTL = 5 * time.Second
// ResetModelPolicy 清空全局模型限制缓存(系统配置保存后调用)。
func (g *Gateway) ResetModelPolicy() {
g.policyMu.Lock()
g.policy = modelPolicy{}
g.policyMu.Unlock()
}
// globalModelRestrictions 读取全局模型允许/禁止列表(缓存 30s)。
func (g *Gateway) globalModelRestrictions() (allow, deny []string) {
g.policyMu.Lock()
defer g.policyMu.Unlock()
if time.Since(g.policy.at) < modelPolicyTTL {
return g.policy.allow, g.policy.deny
}
var raw string
g.db.Model(&store.SystemConfig{}).Where("key = ?", "model_allowlist").Pluck("value", &raw)
_ = json.Unmarshal([]byte(raw), &allow)
raw = ""
g.db.Model(&store.SystemConfig{}).Where("key = ?", "model_denylist").Pluck("value", &raw)
_ = json.Unmarshal([]byte(raw), &deny)
g.policy = modelPolicy{allow: allow, deny: deny, at: time.Now()}
return
}
// checkModelAllowed 模型访问控制:用户级 > 全局。
// 1. 用户禁止列表命中 → 拒绝
// 2. 用户允许列表非空 → 仅列表内可访问(不再看全局)
// 3. 全局禁止命中 → 拒绝
// 4. 全局允许列表非空 → 仅列表内可访问
func (g *Gateway) checkModelAllowed(u *store.User, model string) bool {
if model == "" {
return true
}
if contains(u.DeniedModels, model) {
return false
}
if len(u.AllowedModels) > 0 {
return contains(u.AllowedModels, model)
}
allow, deny := g.globalModelRestrictions()
if contains(deny, model) {
return false
}
if len(allow) > 0 {
return contains(allow, model)
}
return true
}
func contains(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ratelimit.Limiter, userRPS int, logRaw bool, gw_timeout time.Duration) *Gateway {
return &Gateway{ return &Gateway{
db: db, db: db,
ch: channel.NewService(db, enc), ch: channel.NewService(db, enc),
rec: rec, rec: rec,
enc: enc, enc: enc,
hc: &http.Client{Timeout: 120 * time.Second}, lim: lim,
userRPS: userRPS,
logRaw: logRaw,
hc: &http.Client{Timeout: gw_timeout * time.Second},
} }
} }
@@ -57,8 +137,12 @@ func (g *Gateway) Auth(c *gin.Context) {
auth := c.GetHeader("Authorization") auth := c.GetHeader("Authorization")
key := strings.TrimPrefix(auth, "Bearer ") key := strings.TrimPrefix(auth, "Bearer ")
key = strings.TrimSpace(key) key = strings.TrimSpace(key)
if key == "" {
// Anthropic 客户端(Claude Code / SDK)用 x-api-key 头而不是 Authorization
key = strings.TrimSpace(c.GetHeader("x-api-key"))
}
if !apikey.Valid(key) { if !apikey.Valid(key) {
apiError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key format. Expected: Bearer sk-...") apiError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key format. Expected: Authorization: Bearer sk-ot-... or x-api-key: sk-ot-...")
c.Abort() c.Abort()
return return
} }
@@ -81,6 +165,25 @@ func (g *Gateway) Auth(c *gin.Context) {
return return
} }
// 限流与配额(内存计数)
if g.lim != nil {
if k.QuotaRequestsPerDay != nil && !g.lim.AllowRequestDaily(k.ID, *k.QuotaRequestsPerDay) {
apiError(c, http.StatusTooManyRequests, "rate_limit_exceeded", "Daily request quota exceeded for this API key")
c.Abort()
return
}
if k.QuotaTokensPerDay != nil && g.lim.TokensUsed(k.ID) >= *k.QuotaTokensPerDay {
apiError(c, http.StatusTooManyRequests, "rate_limit_exceeded", "Daily token quota exceeded for this API key")
c.Abort()
return
}
if !g.lim.AllowUserRate(u.ID, g.userRPS) {
apiError(c, http.StatusTooManyRequests, "rate_limit_exceeded", "Too many requests. Please slow down.")
c.Abort()
return
}
}
c.Set(CtxUserID, u.ID) c.Set(CtxUserID, u.ID)
c.Set(CtxKeyID, k.ID) c.Set(CtxKeyID, k.ID)
c.Set(CtxTrace, newTraceID()) c.Set(CtxTrace, newTraceID())
@@ -104,10 +207,15 @@ func (g *Gateway) Handle(c *gin.Context) {
} }
} }
// models GET /v1/models:返回启用的全局模型(OpenAI 风格)。 // models GET /v1/models:返回对外可见的模型(启用且至少绑定到一个启用且健康的渠道;OpenAI 风格)。
func (g *Gateway) models(c *gin.Context) { func (g *Gateway) models(c *gin.Context) {
ids := g.ch.AvailableModelIDs()
if len(ids) == 0 {
c.JSON(http.StatusOK, gin.H{"object": "list", "data": []gin.H{}})
return
}
var ms []store.Model var ms []store.Model
if err := g.db.Where("enabled = ?", true).Order("sort ASC, id ASC").Find(&ms).Error; err != nil { if err := g.db.Where("id IN ? AND enabled = ?", ids, true).Order("sort ASC, id ASC").Find(&ms).Error; err != nil {
apiError(c, http.StatusInternalServerError, "internal_error", "failed to load models") apiError(c, http.StatusInternalServerError, "internal_error", "failed to load models")
return return
} }
@@ -123,8 +231,8 @@ func (g *Gateway) models(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"object": "list", "data": data}) c.JSON(http.StatusOK, gin.H{"object": "list", "data": data})
} }
// candidateChannels 返回可用渠道候选(按模型绑定优先,退化全局)。 // candidateChannels 返回可用渠道候选(按模型绑定优先,退化全局;携带模型映射)。
func (g *Gateway) candidateChannels(model string) []*store.Channel { func (g *Gateway) candidateChannels(model string) []channel.Candidate {
return g.ch.Candidates(model) return g.ch.Candidates(model)
} }
@@ -151,54 +259,81 @@ func (g *Gateway) checkBalance(c *gin.Context, u *store.User) bool {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 协议分派 // 协议分派
// upstreamProtoFor 根据渠道 provider 与客户端协议确定上游协议与路径。
func upstreamProtoFor(provider, clientProto string) string {
switch provider {
case store.ChannelProviderAnthropic:
return convert.ProtoMessages
case store.ChannelProviderOpenAI:
if clientProto == convert.ProtoMessages {
return convert.ProtoChat
}
return clientProto
default: // compatible:假定 OpenAI Chat 形状
return convert.ProtoChat
}
}
func upstreamPath(proto string) string { func upstreamPath(proto string) string {
switch proto { switch proto {
case convert.ProtoMessages: case convert.ProtoMessages:
return "/v1/messages" return "/messages"
case convert.ProtoResponses: case convert.ProtoResponses:
return "/v1/responses" return "/responses"
default: default:
return "/v1/chat/completions" return "/chat/completions"
} }
} }
// upstreamPlan 描述一次代理请求的上游访问方式。 // upstreamPlan 描述一次代理请求的上游访问方式。
type upstreamPlan struct { type upstreamPlan struct {
proto string // 上游协议(用于分协议 base_url)
path string // 上游路径 path string // 上游路径
body []byte // 已转换的请求体 body []byte // 已转换的请求体
lineConv func([]byte) []byte // 流式逐行转换(nil=直通) lineConv func([]byte) []byte // 流式逐行转换(nil=直通)
bodyConv func([]byte) ([]byte, error) // 非流式响应体转换(nil=直通) bodyConv func([]byte) ([]byte, error) // 非流式响应体转换(nil=直通)
} }
// prepareUpstream 计算上游访问计划:协议匹配直通,否则转换。 // conversionTarget 决定客户端协议在渠道上的处理方式:
func prepareUpstream(provider, clientProto string, body []byte) (*upstreamPlan, error) { // 渠道声明支持该协议则直通(返回原协议);否则转为其首选支持协议(chat > messages > responses)。
up := upstreamProtoFor(provider, clientProto) func conversionTarget(formats []string, clientProto string) string {
plan := &upstreamPlan{path: upstreamPath(up), body: body} for _, f := range formats {
if up != clientProto { if f == clientProto {
converted, err := convert.ConvertRequest(body, clientProto, up) return clientProto
}
}
for _, p := range []string{convert.ProtoChat, convert.ProtoMessages, convert.ProtoResponses} {
for _, f := range formats {
if f == p {
return p
}
}
}
return ""
}
// prepareUpstream 计算上游访问计划:渠道声明支持客户端协议则直通,否则转换;
// 应用模型名称映射(upstream_model)。
func prepareUpstream(ch *store.Channel, clientProto string, body []byte, upstreamModel string) (*upstreamPlan, error) {
target := conversionTarget(ch.FormatsEffective(), clientProto)
if target == "" {
return nil, fmt.Errorf("channel %q declares no supported protocol format", ch.Name)
}
plan := &upstreamPlan{proto: target, path: upstreamPath(target), body: body}
if target != clientProto {
converted, err := convert.ConvertRequest(body, clientProto, target)
if err != nil { if err != nil {
return nil, err return nil, err
} }
plan.body = converted plan.body = converted
plan.lineConv = convert.NewStreamTransformer(up, clientProto) plan.lineConv = convert.NewStreamTransformer(target, clientProto)
plan.bodyConv = func(b []byte) ([]byte, error) { return convert.ConvertResponse(b, up, clientProto) } plan.bodyConv = func(b []byte) ([]byte, error) { return convert.ConvertResponse(b, target, clientProto) }
}
// 模型名称映射:把请求体 model 字段改写为渠道侧的 upstream_model
if upstreamModel != "" {
if out, err := rewriteModel(plan.body, upstreamModel); err == nil {
plan.body = out
}
} }
return plan, nil return plan, nil
} }
// rewriteModel 改写请求体中的 model 字段(三种协议 model 都在顶层)。
func rewriteModel(body []byte, model string) ([]byte, error) {
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
return body, nil
}
if cur, _ := m["model"].(string); cur == model {
return body, nil
}
m["model"] = model
return json.Marshal(m)
}
var errNoChannel = errors.New("no available channel") var errNoChannel = errors.New("no available channel")
+24
View File
@@ -5,6 +5,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/openteam/server/internal/proxy/convert" "github.com/openteam/server/internal/proxy/convert"
"github.com/openteam/server/internal/store"
) )
// chatCompletions POST /v1/chat/completions // chatCompletions POST /v1/chat/completions
@@ -23,6 +24,11 @@ func (g *Gateway) chatCompletions(c *gin.Context) {
} }
c.Set("protocol", convert.ProtoChat) c.Set("protocol", convert.ProtoChat)
c.Set("model_name", br.Model) c.Set("model_name", br.Model)
g.recordRawRequest(c, u, body)
if !g.checkModelAllowed(u, br.Model) {
apiError(c, http.StatusForbidden, "model_not_allowed", "模型未对你开放,请联系管理员")
return
}
cands := g.candidateChannels(br.Model) cands := g.candidateChannels(br.Model)
if len(cands) == 0 { if len(cands) == 0 {
@@ -51,6 +57,11 @@ func (g *Gateway) responses(c *gin.Context) {
} }
c.Set("protocol", convert.ProtoResponses) c.Set("protocol", convert.ProtoResponses)
c.Set("model_name", br.Model) c.Set("model_name", br.Model)
g.recordRawRequest(c, u, body)
if !g.checkModelAllowed(u, br.Model) {
apiError(c, http.StatusForbidden, "model_not_allowed", "模型未对你开放,请联系管理员")
return
}
cands := g.candidateChannels(br.Model) cands := g.candidateChannels(br.Model)
if len(cands) == 0 { if len(cands) == 0 {
@@ -79,6 +90,11 @@ func (g *Gateway) messages(c *gin.Context) {
} }
c.Set("protocol", convert.ProtoMessages) c.Set("protocol", convert.ProtoMessages)
c.Set("model_name", br.Model) c.Set("model_name", br.Model)
g.recordRawRequest(c, u, body)
if !g.checkModelAllowed(u, br.Model) {
apiError(c, http.StatusForbidden, "model_not_allowed", "模型未对你开放,请联系管理员")
return
}
cands := g.candidateChannels(br.Model) cands := g.candidateChannels(br.Model)
if len(cands) == 0 { if len(cands) == 0 {
@@ -96,6 +112,14 @@ type sinkHolder struct {
sink *usageSink sink *usageSink
} }
// recordRawRequest 记录管理员原始请求体到 context(供 finishUsage 落库)。
// 仅当开关开启且用户为管理员时记录;响应侧以 c.Get("raw_request") 是否非空判断是否需要捕获响应。
func (g *Gateway) recordRawRequest(c *gin.Context, u *store.User, body []byte) {
if g.logRaw && u.Role == store.RoleAdmin {
c.Set("raw_request", string(body))
}
}
// apiError 按客户端协议返回错误体(PLANNING §5.1.4)。 // apiError 按客户端协议返回错误体(PLANNING §5.1.4)。
func apiError(c *gin.Context, status int, code, message string) { func apiError(c *gin.Context, status int, code, message string) {
if p, _ := c.Get("protocol"); p == convert.ProtoMessages { if p, _ := c.Get("protocol"); p == convert.ProtoMessages {
+240 -28
View File
@@ -3,7 +3,6 @@ package proxy
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
@@ -15,6 +14,8 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/openteam/server/internal/channel"
"github.com/openteam/server/internal/pkg/tokenizer"
"github.com/openteam/server/internal/store" "github.com/openteam/server/internal/store"
) )
@@ -38,22 +39,155 @@ func parseBody(c *gin.Context) (*bodyReq, []byte, error) {
return nil, nil, err return nil, nil, err
} }
c.Request.Body = io.NopCloser(bytes.NewReader(body)) c.Request.Body = io.NopCloser(bytes.NewReader(body))
// 中断请求输入文本(token 估算用,仅存文本不立即分词)
c.Set("est_input_text", requestText(body))
br := &bodyReq{} br := &bodyReq{}
_ = json.Unmarshal(body, br) // 解析失败按空处理,直通仍可转发 _ = json.Unmarshal(body, br) // 解析失败按空处理,直通仍可转发
return br, body, nil return br, body, nil
} }
// upstreamURL 组装上游地址:base_url + 路径。 // requestText 提取请求体中的用户输入文本(chat/messages 的 content、responses 的 input/instructions),
func upstreamURL(ch *store.Channel, path string) string { // 用于中断时估算输入 token。
return strings.TrimRight(ch.BaseURL, "/") + path func requestText(body []byte) string {
var m map[string]any
if json.Unmarshal(body, &m) != nil {
return ""
}
var parts []string
add := func(s string) {
if s = strings.TrimSpace(s); s != "" {
parts = append(parts, s)
}
}
if s, ok := m["instructions"].(string); ok {
add(s)
}
if s, ok := m["system"].(string); ok {
add(s)
}
switch input := m["input"].(type) {
case string:
add(input)
case []any:
for _, it := range input {
if im, ok := it.(map[string]any); ok {
if s, ok := im["content"].(string); ok {
add(s)
}
}
}
}
if msgs, ok := m["messages"].([]any); ok {
for _, msg := range msgs {
mm, ok := msg.(map[string]any)
if !ok {
continue
}
switch c := mm["content"].(type) {
case string:
add(c)
case []any:
for _, b := range c {
if bm, ok := b.(map[string]any); ok {
if s, ok := bm["text"].(string); ok {
add(s)
}
}
}
}
}
}
return strings.Join(parts, "\n")
} }
// doProxy 通用代理(M5):遍历候选渠道,按需转换;可安全重试的失败自动故障转移。 // sseDataPayload 提取一条 SSE 的 JSON 载荷(去掉 data: 前缀与空白)。
func (g *Gateway) doProxy(c *gin.Context, cands []*store.Channel, clientProto string, body []byte, stream bool, sink *usageSink) { // 兼容三种写法:
// - 单 data: 行:data: {...} 或 data:{...}(上游如火山方舟会省略 data: 后的空格)
// - event:+data: 多行块:转换器 eventLine 产出的块(event: xxx\ndata: {...} 拼在一个 []byte)
func sseDataPayload(line []byte) (string, bool) {
s := string(line)
idx := strings.LastIndex(s, "\ndata:")
if idx >= 0 {
s = s[idx+len("\ndata:"):] // 跳过 event: 头,落在 data: 之后
} else if strings.HasPrefix(s, "data:") {
s = strings.TrimPrefix(s, "data:")
} else {
return "", false
}
s = strings.TrimLeft(s, " ") // data: 后的可选空格
s = strings.TrimSpace(s)
if s == "" || s == "[DONE]" {
return "", false
}
return s, true
}
// sseContentText 提取一条 SSE 中的内容文本(chat delta.content / responses delta / messages delta.text)。
func sseContentText(line []byte) string {
s, ok := sseDataPayload(line)
if !ok {
return ""
}
var m map[string]any
if json.Unmarshal([]byte(s), &m) != nil {
return ""
}
// responses output_text.delta: {"delta":"..."}
if d, ok := m["delta"].(string); ok {
return d
}
// messages content_block_delta: {"delta":{"text":"..."}}
if dm, ok := m["delta"].(map[string]any); ok {
if t, ok := dm["text"].(string); ok {
return t
}
}
// chat chunk: {"choices":[{"delta":{"content":"..."}}]}(GLM 思考模型另有 reasoning_content)
if choices, ok := m["choices"].([]any); ok && len(choices) > 0 {
if c0, ok := choices[0].(map[string]any); ok {
if delta, ok := c0["delta"].(map[string]any); ok {
if t, ok := delta["content"].(string); ok {
return t
}
if t, ok := delta["reasoning_content"].(string); ok {
return t
}
}
}
}
return ""
}
// upstreamURL 组装上游地址:按协议选 base_url 再拼资源路径(见 store.Channel.UpstreamURL)。
func upstreamURL(ch *store.Channel, proto, path string) string {
return ch.UpstreamURL(proto, path)
}
// doProxy 通用代理(M5):加权随机选一个候选渠道;失败自动故障转移。
func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto string, body []byte, stream bool, sink *usageSink) {
if len(cands) == 0 {
apiError(c, http.StatusBadGateway, "upstream_error", "no available channel")
g.recordError(c, nil, nil, now(), "no_available_channel")
return
}
// 加权随机选择起始渠道
picked := g.ch.Pick(cands)
startIdx := 0
for i, cand := range cands {
if cand.Channel.ID == picked.ID {
startIdx = i
break
}
}
var lastStatus = http.StatusBadGateway var lastStatus = http.StatusBadGateway
var lastBody = []byte("all upstream channels failed") var lastBody = []byte("all upstream channels failed")
for _, ch := range cands { // 从选中的渠道开始遍历,到末尾后再从头遍历到选中渠道之前
plan, err := prepareUpstream(ch.Provider, clientProto, body) for offset := 0; offset < len(cands); offset++ {
idx := (startIdx + offset) % len(cands)
cand := cands[idx]
ch := cand.Channel
plan, err := prepareUpstream(ch, clientProto, body, cand.UpstreamModel)
if err != nil { if err != nil {
lastStatus, lastBody = http.StatusInternalServerError, []byte("conversion error: "+err.Error()) lastStatus, lastBody = http.StatusInternalServerError, []byte("conversion error: "+err.Error())
continue continue
@@ -91,7 +225,7 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
upBody := plan.body upBody := plan.body
// 直通 chat 流式:注入 stream_options.include_usage,保证末块带 usage(OpenAI 行为) // 直通 chat 流式:注入 stream_options.include_usage,保证末块带 usage(OpenAI 行为)
if stream && plan.path == "/v1/chat/completions" && plan.lineConv == nil && !bytes.Contains(upBody, []byte(`"include_usage"`)) { if stream && plan.path == "/chat/completions" && plan.lineConv == nil && !bytes.Contains(upBody, []byte(`"include_usage"`)) {
var m map[string]any var m map[string]any
if json.Unmarshal(upBody, &m) == nil { if json.Unmarshal(upBody, &m) == nil {
m["stream_options"] = map[string]any{"include_usage": true} m["stream_options"] = map[string]any{"include_usage": true}
@@ -101,9 +235,7 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
} }
} }
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(ch.TimeoutMS)*time.Millisecond) req, err := http.NewRequest(http.MethodPost, upstreamURL(ch, plan.proto, plan.path), bytes.NewReader(upBody))
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL(ch, plan.path), bytes.NewReader(upBody))
if err != nil { if err != nil {
return false, false, http.StatusInternalServerError, []byte("failed to build upstream request") return false, false, http.StatusInternalServerError, []byte("failed to build upstream request")
} }
@@ -113,7 +245,7 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
if ua := c.GetHeader("User-Agent"); ua != "" { if ua := c.GetHeader("User-Agent"); ua != "" {
req.Header.Set("User-Agent", ua) req.Header.Set("User-Agent", ua)
} }
if plan.path == "/v1/messages" { if plan.path == "/messages" {
req.Header.Set("anthropic-version", "2023-06-01") req.Header.Set("anthropic-version", "2023-06-01")
} }
for _, h := range []string{"OpenAI-Organization", "OpenAI-Project", "OpenAI-Beta"} { for _, h := range []string{"OpenAI-Organization", "OpenAI-Project", "OpenAI-Beta"} {
@@ -125,9 +257,6 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
start := time.Now() start := time.Now()
resp, err := g.hc.Do(req) resp, err := g.hc.Do(req)
if err != nil { if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return false, true, http.StatusGatewayTimeout, []byte("upstream request timed out")
}
return false, true, http.StatusBadGateway, []byte("upstream request failed: " + err.Error()) return false, true, http.StatusBadGateway, []byte("upstream request failed: " + err.Error())
} }
defer resp.Body.Close() defer resp.Body.Close()
@@ -169,6 +298,9 @@ func (g *Gateway) copyAndCapture(c *gin.Context, ch *store.Channel, r io.Reader,
} }
} }
_, _ = c.Writer.Write(out) _, _ = c.Writer.Write(out)
if _, ok := c.Get("raw_request"); ok {
c.Set("raw_response", string(data)) // 上游原始响应(未转换)
}
g.finishUsage(c, ch, start, store.UsageStatusSuccess, "") g.finishUsage(c, ch, start, store.UsageStatusSuccess, "")
} }
@@ -180,30 +312,51 @@ func (g *Gateway) streamCopy(c *gin.Context, ch *store.Channel, r io.Reader, sta
flusher = nopFlusher{} flusher = nopFlusher{}
} }
// 原始响应捕获:仅管理员且开关开启(raw_request 已 set)时累积上游原始行
_, capture := c.Get("raw_request")
var rawResp strings.Builder
// commitRaw 在记账前把已累积的原始响应写入 context
commitRaw := func() {
if capture {
c.Set("raw_response", rawResp.String())
}
}
scanner := newSSEScanner(r) scanner := newSSEScanner(r)
for { for {
line, err := scanner.Next() line, err := scanner.Next()
if line != nil { if line != nil {
if capture {
rawResp.Write(line)
}
out := line out := line
if lineConv != nil { if lineConv != nil {
out = lineConv(line) out = lineConv(line)
} }
if out != nil { if out != nil {
if _, werr := w.Write(out); werr != nil { if _, werr := w.Write(out); werr != nil {
g.recordError(c, ch, nil, start, "client_disconnect") // 客户端意外断开:按已生成部分收费(canceled)
commitRaw()
g.finishUsage(c, ch, start, store.UsageStatusCanceled, "client_disconnect")
return return
} }
flusher.Flush() flusher.Flush()
if sink != nil {
sink.outputText += sseContentText(out)
}
} }
if usageRaw := scanUsage(line); usageRaw != nil && sink != nil { if usageRaw := scanUsage(line); usageRaw != nil && sink != nil {
sink.push(usageRaw) sink.push(usageRaw)
} }
} }
if err != nil { if err != nil {
commitRaw()
if err == io.EOF { if err == io.EOF {
g.finishUsage(c, ch, start, store.UsageStatusSuccess, "") g.finishUsage(c, ch, start, store.UsageStatusSuccess, "")
} else if c.Request.Context().Err() != nil { } else if c.Request.Context().Err() != nil {
g.recordError(c, ch, nil, start, "client_disconnect") // 客户端意外断开:按已生成部分收费(canceled)
g.finishUsage(c, ch, start, store.UsageStatusCanceled, "client_disconnect")
} else { } else {
g.recordError(c, ch, nil, start, "stream_read_error") g.recordError(c, ch, nil, start, "stream_read_error")
} }
@@ -267,15 +420,11 @@ func extractUsage(data []byte) json.RawMessage {
// scanUsage 从 SSE 一行中提取 usage(OpenAI 末块 / responses completed / messages message_delta 等)。 // scanUsage 从 SSE 一行中提取 usage(OpenAI 末块 / responses completed / messages message_delta 等)。
func scanUsage(line []byte) json.RawMessage { func scanUsage(line []byte) json.RawMessage {
s := string(line) if !bytes.Contains(line, []byte(`"usage"`)) {
if !strings.Contains(s, `"usage"`) {
return nil return nil
} }
if strings.HasPrefix(s, "data: ") { s, ok := sseDataPayload(line)
s = strings.TrimPrefix(s, "data: ") if !ok {
}
s = strings.TrimSpace(s)
if s == "[DONE]" || s == "" {
return nil return nil
} }
var m map[string]json.RawMessage var m map[string]json.RawMessage
@@ -296,7 +445,11 @@ func scanUsage(line []byte) json.RawMessage {
return nil return nil
} }
// usageFromMap 从 map 顶层或 message 子对象中取 usage。 // usageFromMap 从 map 顶层、message 或 delta 子对象中取 usage。
// 覆盖三种形态:
// - OpenAI 流式末块顶层 usage
// - Anthropic 非流式 / message_start 的 message.usage
// - Anthropic 流式 message_delta 的 delta.usage(真实 token 计数所在)
func usageFromMap(m map[string]json.RawMessage) json.RawMessage { func usageFromMap(m map[string]json.RawMessage) json.RawMessage {
if u, ok := m["usage"]; ok && string(u) != "null" { if u, ok := m["usage"]; ok && string(u) != "null" {
return u return u
@@ -309,6 +462,14 @@ func usageFromMap(m map[string]json.RawMessage) json.RawMessage {
} }
} }
} }
if deltaRaw, ok := m["delta"]; ok {
var delta map[string]json.RawMessage
if json.Unmarshal(deltaRaw, &delta) == nil {
if u, ok := delta["usage"]; ok && string(u) != "null" {
return u
}
}
}
return nil return nil
} }
@@ -334,8 +495,10 @@ func (s *sseScanner) Next() ([]byte, error) {
// 记账 // 记账
// usageSink 累积多次 usage:合并各事件字段(message_start 给 input,message_delta 给 output)。 // usageSink 累积多次 usage:合并各事件字段(message_start 给 input,message_delta 给 output)。
// outputText 累积已转发内容文本,用于流式中断时按 tiktoken 估算输出 token。
type usageSink struct { type usageSink struct {
us usageShape us usageShape
outputText string
} }
func (u *usageSink) push(raw json.RawMessage) { func (u *usageSink) push(raw json.RawMessage) {
@@ -346,6 +509,14 @@ func (u *usageSink) push(raw json.RawMessage) {
if json.Unmarshal(raw, &t) != nil { if json.Unmarshal(raw, &t) != nil {
return return
} }
// messages 流式最终事件(message_delta 的 usage)带 cache_* 字段,是上游的最终计费口径,
// 其中 input_tokens 仅指"非缓存输入"(与 message_start 的"总输入"语义不同)。
// 整体替换而非字段合并,避免 delta 的非缓存 input 覆盖 start 的总 input 后语义错乱
// (实际消耗由 finishUsage 按 input + cache_read + cache_creation 汇总)。
if t.CacheReadInputTokens > 0 || t.CacheCreationInputTokens > 0 {
u.us = t
return
}
// 零值不覆盖:不同事件携带不同字段 // 零值不覆盖:不同事件携带不同字段
if t.PromptTokens > 0 { if t.PromptTokens > 0 {
u.us.PromptTokens = t.PromptTokens u.us.PromptTokens = t.PromptTokens
@@ -377,9 +548,11 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
trace, _ := c.Get(CtxTrace) trace, _ := c.Get(CtxTrace)
var us usageShape var us usageShape
var sink *usageSink
if h, ok := c.Get("usage_raw"); ok { if h, ok := c.Get("usage_raw"); ok {
if holder, ok := h.(*sinkHolder); ok && holder.sink != nil { if holder, ok := h.(*sinkHolder); ok && holder.sink != nil {
us = holder.sink.Shape() us = holder.sink.Shape()
sink = holder.sink
} }
} }
@@ -391,18 +564,42 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
modelName, _ := c.Get("model_name") modelName, _ := c.Get("model_name")
mn, _ := modelName.(string) mn, _ := modelName.(string)
// 上游未返回真实 usage 时估算(tiktoken):
// - 流式中断(canceled):上游最终 usage 可能未返回
// - 流式正常结束(success):部分上游(如火山方舟 Anthropic 流式)message_start.usage 恒为 0、
// message_delta 不带 usage,只能按已收发内容估算,否则记账为 0 消耗
// 非流式上游必返回 usage,此处 in/out 非 0 不受影响。
if status == store.UsageStatusCanceled || status == store.UsageStatusSuccess {
if in == 0 && cacheRead == 0 && cacheCreate == 0 {
if est, ok := c.Get("est_input_text"); ok {
if v, ok2 := est.(string); ok2 && v != "" {
in = int64(tokenizer.Count(v, mn))
}
}
}
if out == 0 && sink != nil && sink.outputText != "" {
out = int64(tokenizer.Count(sink.outputText, mn))
}
}
var model store.Model var model store.Model
var cost float64 var cost float64
var modelID uint64 var modelID uint64
_ = g.db.Where("name = ?", mn).First(&model).Error _ = g.db.Where("name = ?", mn).First(&model).Error
if model.ID > 0 { if model.ID > 0 {
modelID = model.ID modelID = model.ID
// 计价口径:in=非缓存输入、cacheRead=缓存读、cacheCreate=缓存写(Anthropic 语义,
// messages 流式 message_delta 的 input_tokens 即非缓存部分)。
// 缓存写按 1.25× 输入价(Anthropic 5m 口径)。
cost = float64(in)/1e6*model.InputPrice + cost = float64(in)/1e6*model.InputPrice +
float64(out)/1e6*model.OutputPrice + float64(out)/1e6*model.OutputPrice +
float64(cacheRead)/1e6*model.CacheReadPrice float64(cacheRead)/1e6*model.CacheReadPrice +
float64(cacheCreate)/1e6*model.InputPrice*1.25
} else { } else {
cost = float64(in)/1e6*0.15 + float64(out)/1e6*0.60 // 无定价模型时按示例价 cost = float64(in+cacheRead+cacheCreate)/1e6*0.15 + float64(out)/1e6*0.60 // 无定价模型时按示例价
} }
// 落库的 input_tokens 存输入总量(含缓存读/写),与上游 message_start 口径一致,便于对账展示。
in += cacheRead + cacheCreate
proto, _ := c.Get("protocol") proto, _ := c.Get("protocol")
p, _ := proto.(string) p, _ := proto.(string)
@@ -433,6 +630,19 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
chID = ch.ID chID = ch.ID
} }
var rawReq, rawResp string
if v, ok := c.Get("raw_request"); ok {
rawReq, _ = v.(string)
}
if v, ok := c.Get("raw_response"); ok {
rawResp, _ = v.(string)
}
// 密钥今日 token 用量累计(配额检查用)
if g.lim != nil && kidVal > 0 {
g.lim.AddTokens(kidVal, in+out)
}
g.rec.Record(&store.UsageLog{ g.rec.Record(&store.UsageLog{
RequestID: fmt.Sprintf("trace-%s", traceStr), RequestID: fmt.Sprintf("trace-%s", traceStr),
TraceID: traceStr, TraceID: traceStr,
@@ -453,6 +663,8 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
LatencyMS: latency, LatencyMS: latency,
Status: status, Status: status,
ErrorCode: errCodePtr, ErrorCode: errCodePtr,
RawRequest: rawReq,
RawResponse: rawResp,
CreatedAt: time.Now().UTC(), CreatedAt: time.Now().UTC(),
}) })
} }
+101
View File
@@ -0,0 +1,101 @@
package proxy
import (
"encoding/json"
"testing"
)
// 复现线上火山方舟 qwen 流:data:{...} 无空格(省略 data: 后的空格)。
func TestSSEDataPayloadNoSpace(t *testing.T) {
lines := []string{
`data:{"message":{"model":"qwen3.8-flash","id":"msg_1","role":"assistant","type":"message","content":[],"usage":{"input_tokens":31626,"output_tokens":0}},"type":"message_start"}`,
`data:{"delta":{"type":"text_delta","text":"你好"},"type":"content_block_delta","index":0}`,
`data:{"delta":{"type":"text_delta","text":"!"},"type":"content_block_delta","index":0}`,
`data:{"delta":{"stop_reason":"end_turn"},"type":"message_delta","usage":{"cache_creation":{"ephemeral_5m_input_tokens":33065},"output_tokens":8,"cache_creation_input_tokens":33065,"input_tokens":8,"cache_read_input_tokens":0}}`,
}
var out string
for _, l := range lines {
out += sseContentText([]byte(l))
}
if out != "你好!" {
t.Fatalf("outputText=%q, want %q", out, "你好!")
}
// message_delta 的 delta.usage 应能提取(output_tokens=8)
u := scanUsage([]byte(lines[3]))
if u == nil {
t.Fatal("scanUsage returned nil for message_delta with usage")
}
var sh usageShape
if err := json.Unmarshal(u, &sh); err != nil {
t.Fatalf("unmarshal usage: %v", err)
}
if sh.OutputTokens != 8 {
t.Fatalf("output_tokens=%d, want 8", sh.OutputTokens)
}
}
// 兼容带空格的单 data: 行(标准 SSE)与 event:+data: 多行块。
func TestSSEDataPayloadSpacedAndMultiLine(t *testing.T) {
// 标准:data: {...}
if got := sseContentText([]byte(`data: {"delta":{"type":"text_delta","text":"hi"},"type":"content_block_delta","index":0}`)); got != "hi" {
t.Fatalf("spaced single line: got %q, want hi", got)
}
// 多行块:event: message_delta\ndata: {...}
block := []byte("event: message_delta\ndata: {\"delta\":{\"type\":\"text_delta\",\"text\":\"yo\"},\"type\":\"content_block_delta\",\"index\":0}\n")
if got := sseContentText(block); got != "yo" {
t.Fatalf("multiline block: got %q, want yo", got)
}
}
// 复现线上 qwen(dashscope)messages 流式缓存场景:
// message_start.usage.input_tokens 是总输入,message_delta.usage.input_tokens 是非缓存输入
// 且带 cache_read/cache_creation,是最终计费口径。合并后:
// in(落库)=input+cache_read+cache_creation,计价 in 只算非缓存部分。
// 此前 delta 的 input 覆盖 start 的 input 导致总输入丢失(31790 → 8)。
func TestUsageSinkMessageDeltaAuthoritative(t *testing.T) {
sink := &usageSink{}
// message_start:总输入 31790
start := json.RawMessage(`{"input_tokens":31790,"output_tokens":0}`)
sink.push(start)
if got := sink.us.InputTokens; got != 31790 {
t.Fatalf("after start: input=%d, want 31790", got)
}
// message_delta:非缓存输入 8 + 缓存写 33229(最终口径,整体替换)
delta := json.RawMessage(`{"output_tokens":8,"cache_creation_input_tokens":33229,"input_tokens":8,"cache_read_input_tokens":0}`)
sink.push(delta)
s := sink.Shape()
if s.InputTokens != 8 || s.CacheCreationInputTokens != 33229 || s.OutputTokens != 8 {
t.Fatalf("after delta: %+v, want input=8 cache_create=33229 output=8", s)
}
// finishUsage 口径:落库 input = 8 + 0 + 33229 = 33237(总量),计价 in=8、cacheCreate=33229
in := s.InputTokens + s.CacheReadInputTokens + s.CacheCreationInputTokens
if in != 33237 {
t.Fatalf("total input=%d, want 33237", in)
}
}
// 缓存命中场景(id=55):delta input=76 非缓存 + cache_read=33229 + cache_creation=17。
func TestUsageSinkCacheHitMerge(t *testing.T) {
sink := &usageSink{}
sink.push(json.RawMessage(`{"input_tokens":31862,"output_tokens":0}`))
sink.push(json.RawMessage(`{"output_tokens":32,"cache_creation_input_tokens":17,"input_tokens":76,"cache_read_input_tokens":33229}`))
s := sink.Shape()
total := s.InputTokens + s.CacheReadInputTokens + s.CacheCreationInputTokens
if total != 33322 {
t.Fatalf("total input=%d, want 33322 (76+33229+17)", total)
}
if s.OutputTokens != 32 {
t.Fatalf("output=%d, want 32", s.OutputTokens)
}
}
// OpenAI chat 末块(无 cache 字段)仍走零值不覆盖合并,不受整体替换影响。
func TestUsageSinkChatLastChunkStillMerges(t *testing.T) {
sink := &usageSink{}
sink.push(json.RawMessage(`{"prompt_tokens":65,"completion_tokens":0}`))
sink.push(json.RawMessage(`{"prompt_tokens":65,"completion_tokens":82}`))
s := sink.Shape()
if s.PromptTokens != 65 || s.CompletionTokens != 82 {
t.Fatalf("chat merge broken: %+v", s)
}
}
+30 -1
View File
@@ -1,6 +1,7 @@
package store package store
import ( import (
"fmt"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
@@ -11,6 +12,12 @@ import (
gormlogger "gorm.io/gorm/logger" gormlogger "gorm.io/gorm/logger"
) )
// schemaVersion 当前 schema 版本。struct 变更(加列/改列/删列)时递增,
// 触发一次 AutoMigrate 并把新版本写入库(SQLite 用 PRAGMA user_version)。
// AutoMigrate 对已有表的列判定不收敛(每次都重建表:CREATE __temp + INSERT SELECT + DROP),
// 大表上一次重建数十秒且每次重启重演,所以之后版本未变就直接跳过。
const schemaVersion = 2
// Open 打开数据库连接并自动迁移。 // Open 打开数据库连接并自动迁移。
// 开发默认 SQLite(dsn 支持 file:...?_journal_mode=WAL),生产可切 postgres。 // 开发默认 SQLite(dsn 支持 file:...?_journal_mode=WAL),生产可切 postgres。
func Open(driver, dsn string) (*gorm.DB, error) { func Open(driver, dsn string) (*gorm.DB, error) {
@@ -33,13 +40,35 @@ func Open(driver, dsn string) (*gorm.DB, error) {
return nil, err return nil, err
} }
if driver != "postgres" && currentSQLiteVersion(db) >= schemaVersion {
log.Printf("store: connected driver=%s (schema up-to-date v%d, skip migrate)", driver, schemaVersion)
return db, nil
}
if err := db.AutoMigrate(AllModels()...); err != nil { if err := db.AutoMigrate(AllModels()...); err != nil {
return nil, err return nil, err
} }
log.Printf("store: connected driver=%s (migrated)", driver) if driver != "postgres" {
setSQLiteVersion(db, schemaVersion)
}
log.Printf("store: connected driver=%s (migrated, schema v%d)", driver, schemaVersion)
// 将已有渠道的超时时间从 120000ms 更新为 300000ms(幂等操作)
db.Model(&Channel{}).Where("timeout_ms = ?", 120000).Update("timeout_ms", 300000)
return db, nil return db, nil
} }
// currentSQLiteVersion 读取 PRAGMA user_version。
func currentSQLiteVersion(db *gorm.DB) int {
var v int
db.Raw("PRAGMA user_version").Scan(&v)
return v
}
// setSQLiteVersion 写入 PRAGMA user_version。
func setSQLiteVersion(db *gorm.DB, v int) {
db.Exec(fmt.Sprintf("PRAGMA user_version = %d", v))
}
// sqliteDir 提取 SQLite DSN 中的目录部分(忽略 file: 前缀与查询参数)。 // sqliteDir 提取 SQLite DSN 中的目录部分(忽略 file: 前缀与查询参数)。
func sqliteDir(dsn string) string { func sqliteDir(dsn string) string {
d := dsn d := dsn
+70 -4
View File
@@ -2,7 +2,11 @@
// 字段设计对应 PLANNING.md §6:金额/价格 numeric(20,8),token bigint,时间 UTC。 // 字段设计对应 PLANNING.md §6:金额/价格 numeric(20,8),token bigint,时间 UTC。
package store package store
import "time" import (
"regexp"
"strings"
"time"
)
// 角色 / 状态枚举(字符串存库,便于阅读与迁移) // 角色 / 状态枚举(字符串存库,便于阅读与迁移)
const ( const (
@@ -22,6 +26,11 @@ const (
ChannelHealthDegraded = "degraded" ChannelHealthDegraded = "degraded"
ChannelHealthCooldown = "cooldown" ChannelHealthCooldown = "cooldown"
// 渠道原生支持的协议格式
FormatChat = "chat" // OpenAI Chat Completions
FormatResponses = "responses" // OpenAI Responses API
FormatMessages = "messages" // Anthropic Messages
UsageStatusSuccess = "success" UsageStatusSuccess = "success"
UsageStatusError = "error" UsageStatusError = "error"
UsageStatusCanceled = "canceled" UsageStatusCanceled = "canceled"
@@ -47,6 +56,8 @@ type User struct {
Role string `gorm:"size:16;not null;default:user" json:"role"` Role string `gorm:"size:16;not null;default:user" json:"role"`
Balance float64 `gorm:"type:numeric(20,8);not null;default:0" json:"balance"` Balance float64 `gorm:"type:numeric(20,8);not null;default:0" json:"balance"`
Status string `gorm:"size:16;not null;default:active" json:"status"` 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"` InviteCode *string `json:"invite_code,omitempty"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"` LastLoginAt *time.Time `json:"last_login_at,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
@@ -74,12 +85,14 @@ type APIKey struct {
type Channel struct { type Channel struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"uniqueIndex;size:64;not null" json:"name"` Name string `gorm:"uniqueIndex;size:64;not null" json:"name"`
Provider string `gorm:"size:16;not null" json:"provider"` // openai|anthropic|compatible Provider string `gorm:"size:16;not null" json:"provider"` // openai|anthropic|compatible(供应商/默认格式)
Formats []string `gorm:"type:jsonb;serializer:json" json:"formats,omitempty"` // 原生支持的协议格式 chat|responses|messages
BaseURL string `gorm:"size:255;not null" json:"base_url"` BaseURL string `gorm:"size:255;not null" json:"base_url"`
BaseURLs map[string]string `gorm:"type:jsonb;serializer:json" json:"base_urls,omitempty"` // 分协议 base_url 覆盖(chat/responses/messages)
APIKeyEnc string `gorm:"size:1024;not null" json:"-"` // AES-GCM 密文 APIKeyEnc string `gorm:"size:1024;not null" json:"-"` // AES-GCM 密文
Weight int `gorm:"not null;default:1" json:"weight"` Weight int `gorm:"not null;default:1" json:"weight"`
Priority int `gorm:"not null;default:0" json:"priority"` // 数值小优先 Priority int `gorm:"not null;default:0" json:"priority"` // 数值小优先
TimeoutMS int `gorm:"not null;default:120000" json:"timeout_ms"` TimeoutMS int `gorm:"not null;default:300000" json:"timeout_ms"`
MaxConcurrency int `gorm:"not null;default:16" json:"max_concurrency"` MaxConcurrency int `gorm:"not null;default:16" json:"max_concurrency"`
HealthStatus string `gorm:"size:16;not null;default:healthy" json:"health_status"` HealthStatus string `gorm:"size:16;not null;default:healthy" json:"health_status"`
Enabled bool `gorm:"not null;default:true" json:"enabled"` Enabled bool `gorm:"not null;default:true" json:"enabled"`
@@ -87,11 +100,51 @@ type Channel struct {
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
// FormatsEffective 返回渠道实际支持的原生协议;未显式配置时按 provider 推断。
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: // compatible
return []string{FormatChat}
}
}
// versionSegRe 匹配末尾版本前缀,如 /v1、/v2、/v4。
var versionSegRe = regexp.MustCompile(`/v[0-9]+/?$`)
// UpstreamURL 按协议选 base_url(分协议覆盖优先),再按版本前缀拼资源路径(path 不含 /v1)。
// - proto 有 BaseURLs 覆盖时用覆盖值,否则用主 BaseURL
// - base 已以资源路径结尾 → 原样
// - base 含版本前缀(如 /v1、/v4) → base + path
// - 否则 → base + /v1 + path(默认补 OpenAI/Anthropic 的 /v1)
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 全局模型 + 定价(PLANNING §6.4,价格按每百万 token,USD) // Model 全局模型 + 定价(PLANNING §6.4,价格按每百万 token,USD)
type Model struct { type Model struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"uniqueIndex;size:128;not null" json:"name"` 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"` 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"` 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"` CacheReadPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"cache_read_price"`
@@ -134,6 +187,8 @@ type UsageLog struct {
LatencyMS int `json:"latency_ms"` LatencyMS int `json:"latency_ms"`
Status string `gorm:"size:16;not null" json:"status"` Status string `gorm:"size:16;not null" json:"status"`
ErrorCode *string `json:"error_code,omitempty"` ErrorCode *string `json:"error_code,omitempty"`
RawRequest string `gorm:"type:text" json:"raw_request"` // 客户端原始请求体(未转换)
RawResponse string `gorm:"type:text" json:"raw_response"` // 上游原始响应(未转换;流式为全部 SSE 事件)
CreatedAt time.Time `gorm:"index" json:"created_at"` CreatedAt time.Time `gorm:"index" json:"created_at"`
} }
@@ -177,6 +232,16 @@ type BalanceLog struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
// Passkey WebAuthn 凭据(passkey 绑定/登录)
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.ID
Credential []byte `gorm:"type:blob;not null" json:"-"` // json.Marshal(webauthn.Credential)
CreatedAt time.Time `json:"created_at"`
}
// SystemConfig 系统配置(PLANNING §6.9) // SystemConfig 系统配置(PLANNING §6.9)
type SystemConfig struct { type SystemConfig struct {
Key string `gorm:"primaryKey;size:64" json:"key"` Key string `gorm:"primaryKey;size:64" json:"key"`
@@ -195,6 +260,7 @@ func AllModels() []any {
&UsageDaily{}, &UsageDaily{},
&RechargeOrder{}, &RechargeOrder{},
&BalanceLog{}, &BalanceLog{},
&Passkey{},
&SystemConfig{}, &SystemConfig{},
} }
} }
+50
View File
@@ -0,0 +1,50 @@
package store
import "testing"
func TestChannelUpstreamURL(t *testing.T) {
cases := []struct {
base, path, want string
}{
// 无版本前缀 → 默认补 /v1(OpenAI 纯域名)
{"https://api.openai.com", "/chat/completions", "https://api.openai.com/v1/chat/completions"},
// 已含 /v1 → 直接拼资源路径
{"https://api.openai.com/v1", "/chat/completions", "https://api.openai.com/v1/chat/completions"},
{"https://api.anthropic.com/v1", "/messages", "https://api.anthropic.com/v1/messages"},
// 自定义版本前缀(如 BigModel 的 /v4) → 直接拼资源路径
{"https://open.bigmodel.cn/api/paas/v4", "/chat/completions", "https://open.bigmodel.cn/api/paas/v4/chat/completions"},
{"https://open.bigmodel.cn/api/paas/v4", "/models", "https://open.bigmodel.cn/api/paas/v4/models"},
// 本地 mock:无版本前缀补 /v1
{"http://localhost:9000", "/chat/completions", "http://localhost:9000/v1/chat/completions"},
{"http://localhost:9000/v1", "/models", "http://localhost:9000/v1/models"},
// 完整端点原样
{"https://api.openai.com/v1/chat/completions", "/chat/completions", "https://api.openai.com/v1/chat/completions"},
// 尾斜杠
{"https://api.openai.com/v1/", "/chat/completions", "https://api.openai.com/v1/chat/completions"},
}
for _, c := range cases {
ch := &Channel{BaseURL: c.base}
got := ch.UpstreamURL("", c.path)
if got != c.want {
t.Errorf("UpstreamURL(%q, %q) = %q, want %q", c.base, c.path, got, c.want)
}
}
// 分协议 base_url 覆盖
ch := &Channel{
BaseURL: "https://open.bigmodel.cn/api/paas/v4",
BaseURLs: map[string]string{"responses": "https://resp.example.com/v4", "messages": "https://msg.example.com/v1"},
}
cases2 := []struct{ proto, path, want string }{
{"chat", "/chat/completions", "https://open.bigmodel.cn/api/paas/v4/chat/completions"}, // 用主 base
{"responses", "/responses", "https://resp.example.com/v4/responses"}, // 用覆盖 base
{"messages", "/messages", "https://msg.example.com/v1/messages"}, // 用覆盖 base
{"", "/models", "https://open.bigmodel.cn/api/paas/v4/models"}, // 空协议用主 base
}
for _, c := range cases2 {
got := ch.UpstreamURL(c.proto, c.path)
if got != c.want {
t.Errorf("UpstreamURL(%q, %q) = %q, want %q", c.proto, c.path, got, c.want)
}
}
}
+5 -1
View File
@@ -18,6 +18,7 @@ type Recorder struct {
ch chan *store.UsageLog ch chan *store.UsageLog
wg sync.WaitGroup wg sync.WaitGroup
closed chan struct{} closed chan struct{}
once sync.Once // 保证 Close 只执行一次(main 的 defer Shutdown 与显式 Close 双调用)
} }
const batchSize = 32 const batchSize = 32
@@ -45,9 +46,11 @@ func (r *Recorder) Record(l *store.UsageLog) {
} }
func (r *Recorder) Close() { func (r *Recorder) Close() {
r.once.Do(func() {
close(r.closed) close(r.closed)
r.wg.Wait() r.wg.Wait()
close(r.ch) close(r.ch)
})
} }
func (r *Recorder) run() { func (r *Recorder) run() {
@@ -97,7 +100,8 @@ func (r *Recorder) flush(logs []*store.UsageLog) error {
return err return err
} }
for _, l := range logs { for _, l := range logs {
if l.Status != store.UsageStatusSuccess || l.Cost <= 0 { // 计费范围:success(正常完成)与 canceled(流式中断,按已生成部分收费)
if (l.Status != store.UsageStatusSuccess && l.Status != store.UsageStatusCanceled) || l.Cost <= 0 {
continue continue
} }
// 扣余额(余额可为负:流式请求不中断;后续请求被拒) // 扣余额(余额可为负:流式请求不中断;后续请求被拒)
+13 -2
View File
@@ -1,10 +1,21 @@
<!doctype html> <!doctype html>
<html lang="zh-CN" class="dark"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.svg" /> <link rel="icon" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" /> <script>
// 防止主题闪烁:应用前先按 localStorage/系统偏好设置 data-theme
;(function () {
try {
var m = localStorage.getItem('ot_theme') || 'system'
var dark = m === 'dark' || (m === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
} catch (e) {
document.documentElement.setAttribute('data-theme', 'dark')
}
})()
</script>
<title>openteam · LLM API 中转站</title> <title>openteam · LLM API 中转站</title>
</head> </head>
<body> <body>
+73 -20
View File
@@ -1,21 +1,64 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { PhList } from '@phosphor-icons/vue' import {
PhList, PhGauge, PhSquaresFour, PhKey, PhChartLine, PhChartBar, PhGear,
PhShieldCheck, PhArrowLeft, PhGlobe, PhTag, PhUsers, PhSliders,
} from '@phosphor-icons/vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { fmtMoney } from '@/lib/format' import { fmtMoney } from '@/lib/format'
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
interface NavItem { interface NavItem {
to: string to: string
label: string label: string
icon: unknown
jump?: boolean // 跨区跳转(如 进入管理 / 返回控制台),不做激活态
} }
defineProps<{ sections: { title: string; items: NavItem[] }[] }>()
const auth = useAuthStore() const auth = useAuthStore()
const route = useRoute()
const router = useRouter() const router = useRouter()
const balance = computed(() => fmtMoney(auth.user?.balance ?? 0)) const balance = computed(() => fmtMoney(auth.user?.balance ?? 0))
const sidebarOpen = ref(false) const sidebarOpen = ref(false)
// 按所在分区显示对应菜单:用户控制台 / 管理后台,二者不混排
const isAdminSection = computed(() => route.path.startsWith('/admin'))
const sections = computed(() => {
if (isAdminSection.value) {
return [
{
title: '管理后台',
items: [
{ to: '/admin/overview', label: '总览', icon: PhSquaresFour },
{ to: '/admin/channels', label: '渠道管理', icon: PhGlobe },
{ to: '/admin/models', label: '模型定价', icon: PhTag },
{ to: '/admin/users', label: '用户管理', icon: PhUsers },
{ to: '/admin/usage', label: '用量统计', icon: PhChartBar },
{ to: '/admin/config', label: '系统配置', icon: PhSliders },
],
},
{ title: '用户', items: [{ to: '/console/dashboard', label: '返回控制台', icon: PhArrowLeft, jump: true }] },
]
}
const user: { title: string; items: NavItem[] }[] = [
{
title: '管理',
items: [
{ to: '/console/dashboard', label: '仪表盘', icon: PhGauge },
{ to: '/console/keys', label: 'API 密钥', icon: PhKey },
{ to: '/console/usage', label: '用量明细', icon: PhChartLine },
{ to: '/console/settings', label: '账户设置', icon: PhGear },
],
},
]
if (auth.isAdmin) {
user[0].items.push({ to: '/admin/overview', label: '管理', icon: PhShieldCheck, jump: true })
}
return user
})
function navTo() { function navTo() {
sidebarOpen.value = false sidebarOpen.value = false
} }
@@ -27,7 +70,7 @@ async function logout() {
</script> </script>
<template> <template>
<div class="flex min-h-[100dvh] bg-zinc-950"> <div class="flex min-h-[100dvh] bg-bg">
<!-- 移动端遮罩 --> <!-- 移动端遮罩 -->
<div <div
v-if="sidebarOpen" v-if="sidebarOpen"
@@ -37,51 +80,61 @@ async function logout() {
<!-- 侧边栏 --> <!-- 侧边栏 -->
<aside <aside
class="fixed inset-y-0 left-0 z-40 flex w-56 transform flex-col border-r border-zinc-800/80 bg-zinc-950 transition-transform duration-200 md:translate-x-0" class="fixed inset-y-0 left-0 z-40 flex w-56 transform flex-col border-r border-edge bg-bg transition-transform duration-200 md:translate-x-0"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'" :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
> >
<div class="flex h-14 items-center gap-2 border-b border-zinc-800/80 px-4"> <div class="flex h-14 items-center gap-2 border-b border-edge px-4">
<img src="/favicon.svg" alt="" class="size-5" /> <img src="/favicon.svg" alt="" class="size-5" />
<span class="text-sm font-semibold tracking-tight">openteam</span> <span class="text-sm font-semibold tracking-tight">openteam</span>
</div> </div>
<nav class="flex-1 overflow-y-auto px-3 py-4"> <nav class="flex-1 overflow-y-auto px-3 py-4">
<template v-for="sec in sections" :key="sec.title"> <template v-for="sec in sections" :key="sec.title">
<p class="mt-4 mb-1.5 px-2 text-[10px] font-medium tracking-[0.14em] text-zinc-600 uppercase first:mt-0"> <p class="mt-4 mb-1.5 px-2 text-[10px] font-medium tracking-[0.14em] text-muted uppercase first:mt-0">
{{ sec.title }} {{ sec.title }}
</p> </p>
<router-link <router-link
v-for="n in sec.items" v-for="n in sec.items"
:key="n.to" :key="n.to"
:to="n.to" :to="n.to"
class="mb-0.5 flex items-center rounded-md px-2 py-1.5 text-sm text-zinc-400 transition hover:bg-zinc-800/50 hover:text-zinc-100" class="mb-0.5 flex items-center justify-between rounded-md px-2 py-1.5 text-sm text-muted transition hover:bg-surface2 hover:text-ink"
active-class="bg-zinc-800/70 text-zinc-50" :class="n.jump ? 'text-xs' : ''"
:active-class="n.jump ? '' : 'bg-surface2 text-ink'"
@click="navTo" @click="navTo"
> >
{{ n.label }} <span class="inline-flex min-w-0 items-center gap-2">
<component :is="n.icon" :size="15" :weight="n.jump ? 'regular' : 'bold'" class="shrink-0" />
<span class="truncate">{{ n.label }}</span>
</span>
<span v-if="n.jump" class="opacity-60" aria-hidden="true">→</span>
</router-link> </router-link>
</template> </template>
</nav> </nav>
<div class="border-t border-edge px-3 py-3">
<p class="px-2 font-mono text-[11px] text-muted">{{ auth.user?.username }}</p>
</div>
</aside> </aside>
<div class="flex min-h-[100dvh] flex-1 flex-col md:ml-56"> <div class="flex min-h-[100dvh] min-w-0 flex-1 flex-col md:ml-56">
<header class="flex h-14 items-center justify-between border-b border-zinc-800/80 px-4 md:px-6"> <header class="flex h-14 items-center justify-between gap-2 border-b border-edge px-4 md:px-6">
<div class="flex items-center gap-2"> <div class="flex min-w-0 items-center gap-2">
<button <button
class="rounded-md p-1.5 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100 md:hidden" class="shrink-0 rounded-md p-1.5 text-muted hover:bg-surface2 hover:text-ink md:hidden"
aria-label="打开菜单" aria-label="打开菜单"
@click="sidebarOpen = true" @click="sidebarOpen = true"
> >
<PhList :size="20" /> <PhList :size="20" />
</button> </button>
<span class="font-mono text-xs text-zinc-600">{{ auth.user?.username }}</span> <span class="truncate font-mono text-xs text-muted">{{ auth.user?.username }}</span>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex shrink-0 items-center gap-1.5 md:gap-3">
<span class="mono-num rounded-md border border-zinc-800 bg-zinc-900 px-2.5 py-1 text-xs text-emerald-300"> <span class="mono-num rounded-md border border-edge bg-surface px-2 py-1 text-xs text-accent sm:px-2.5">
余额 {{ balance }} <span class="hidden sm:inline">余额 </span>{{ balance }}
</span> </span>
<ThemeToggle />
<button <button
class="rounded-md px-2 py-1 text-xs text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-200" class="rounded-md px-1.5 py-1 text-xs text-muted transition hover:bg-surface2 hover:text-ink md:px-2"
@click="logout" @click="logout"
> >
退出 退出
@@ -89,7 +142,7 @@ async function logout() {
</div> </div>
</header> </header>
<main class="flex-1 px-4 py-6 md:px-6"> <main class="min-w-0 flex-1 px-4 py-6 md:px-6">
<router-view /> <router-view />
</main> </main>
</div> </div>
+5 -5
View File
@@ -8,11 +8,11 @@ withDefaults(defineProps<{ variant?: 'neutral' | 'ok' | 'warn' | 'err' | 'accent
<span <span
class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-mono text-[11px] leading-5" class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-mono text-[11px] leading-5"
:class="{ :class="{
neutral: 'bg-zinc-800/80 text-zinc-300', neutral: 'bg-surface2 text-ink',
ok: 'bg-ok/15 text-emerald-300', ok: 'bg-ok-soft text-accent',
warn: 'bg-warn/15 text-amber-300', warn: 'bg-warn-soft text-warn',
err: 'bg-err/15 text-red-300', err: 'bg-err-soft text-err',
accent: 'bg-accent/15 text-emerald-300', accent: 'bg-accent-soft text-accent',
}[variant]" }[variant]"
> >
<span <span
+3 -3
View File
@@ -16,9 +16,9 @@ withDefaults(
class="inline-flex items-center justify-center gap-2 rounded-md font-medium transition-[transform,background-color,border-color,color] duration-150 active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 select-none" class="inline-flex items-center justify-center gap-2 rounded-md font-medium transition-[transform,background-color,border-color,color] duration-150 active:scale-[0.98] disabled:pointer-events-none disabled:opacity-50 select-none"
:class="[ :class="[
size === 'sm' ? 'h-8 px-3 text-xs' : 'h-10 px-4 text-sm', size === 'sm' ? 'h-8 px-3 text-xs' : 'h-10 px-4 text-sm',
variant === 'primary' && 'bg-accent text-zinc-950 hover:bg-accent-strong', variant === 'primary' && 'bg-accent text-accent-ink hover:bg-accent-strong',
variant === 'ghost' && 'border border-zinc-700 text-zinc-200 hover:border-zinc-500 hover:bg-zinc-800/60', variant === 'ghost' && 'border border-edge2 text-ink hover:border-edge2 hover:bg-surface2',
variant === 'danger' && 'border border-err/50 text-red-300 hover:border-err hover:bg-err/10', variant === 'danger' && 'border border-err text-err hover:border-err hover:bg-err-soft',
]" ]"
> >
<span v-if="loading" class="size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" /> <span v-if="loading" class="size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
+7 -5
View File
@@ -9,6 +9,7 @@ withDefaults(
error?: string error?: string
autocomplete?: string autocomplete?: string
disabled?: boolean disabled?: boolean
maxlength?: number
}>(), }>(),
{ type: 'text', modelValue: '', disabled: false }, { type: 'text', modelValue: '', disabled: false },
) )
@@ -17,18 +18,19 @@ const emit = defineEmits<{ 'update:modelValue': [string | number] }>()
<template> <template>
<label class="block"> <label class="block">
<span v-if="label" class="mb-1.5 block text-xs font-medium text-zinc-400">{{ label }}</span> <span v-if="label" class="mb-1.5 block text-xs font-medium text-muted">{{ label }}</span>
<input <input
:type="type" :type="type"
:value="modelValue" :value="modelValue"
:placeholder="placeholder" :placeholder="placeholder"
:autocomplete="autocomplete" :autocomplete="autocomplete"
:disabled="disabled" :disabled="disabled"
class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm text-zinc-100 placeholder-zinc-500 outline-none transition focus:border-accent focus:ring-2 focus:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-50" :maxlength="maxlength"
:class="error && 'border-err focus:border-err focus:ring-err/30'" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink placeholder-muted outline-none transition focus:border-accent focus:ring-2 focus:ring-accent disabled:cursor-not-allowed disabled:opacity-50"
:class="error && 'border-err focus:border-err focus:ring-err'"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value as string | number)" @input="emit('update:modelValue', ($event.target as HTMLInputElement).value as string | number)"
/> />
<span v-if="hint && !error" class="mt-1.5 block text-xs text-zinc-500">{{ hint }}</span> <span v-if="hint && !error" class="mt-1.5 block text-xs text-muted">{{ hint }}</span>
<span v-if="error" class="mt-1.5 block text-xs text-red-400">{{ error }}</span> <span v-if="error" class="mt-1.5 block text-xs text-err">{{ error }}</span>
</label> </label>
</template> </template>
+4 -4
View File
@@ -65,16 +65,16 @@ onUnmounted(() => {
class="card w-full shadow-xl outline-none" class="card w-full shadow-xl outline-none"
:class="width" :class="width"
> >
<div class="flex items-center justify-between border-b border-zinc-800 px-5 py-3.5"> <div class="flex items-center justify-between border-b border-edge px-5 py-3.5">
<h3 class="text-sm font-semibold text-zinc-100">{{ title }}</h3> <h3 class="text-sm font-semibold text-ink">{{ title }}</h3>
<button class="rounded-md p-1 text-zinc-500 hover:bg-zinc-800 hover:text-zinc-200" aria-label="关闭" @click="emit('close')"> <button class="rounded-md p-1 text-muted hover:bg-surface2 hover:text-ink" aria-label="关闭" @click="emit('close')">
<PhX :size="16" weight="bold" /> <PhX :size="16" weight="bold" />
</button> </button>
</div> </div>
<div class="px-5 py-4"> <div class="px-5 py-4">
<slot /> <slot />
</div> </div>
<div v-if="$slots.footer" class="flex justify-end gap-2 border-t border-zinc-800 px-5 py-3.5"> <div v-if="$slots.footer" class="flex justify-end gap-2 border-t border-edge px-5 py-3.5">
<slot name="footer" /> <slot name="footer" />
</div> </div>
</div> </div>
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
withDefaults(
defineProps<{
height?: string
width?: string
cls?: string
rounded?: string
}>(),
{ height: '1rem', width: '100%', rounded: 'rounded-md' },
)
</script>
<template>
<div
class="animate-pulse bg-surface2"
:class="[rounded, cls]"
:style="{ height, width }"
/>
</template>
+79
View File
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { PhSun, PhMoon, PhMonitor, PhCheck } from '@phosphor-icons/vue'
import { useThemeStore, type ThemeMode } from '@/stores/theme'
const theme = useThemeStore()
const open = ref(false)
const menu = ref<HTMLElement | null>(null)
const icon = computed(() => (theme.mode === 'light' ? PhSun : theme.mode === 'dark' ? PhMoon : PhMonitor))
const label = computed(() => ({ light: '浅色', dark: '深色', system: '跟随系统' })[theme.mode])
const options: { mode: ThemeMode; label: string; icon: unknown }[] = [
{ mode: 'light', label: '浅色', icon: PhSun },
{ mode: 'dark', label: '深色', icon: PhMoon },
{ mode: 'system', label: '跟随系统', icon: PhMonitor },
]
function onDocClick(e: MouseEvent) {
if (menu.value && !menu.value.contains(e.target as Node)) open.value = false
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') open.value = false
}
onMounted(() => {
document.addEventListener('mousedown', onDocClick)
document.addEventListener('keydown', onKey)
})
onUnmounted(() => {
document.removeEventListener('mousedown', onDocClick)
document.removeEventListener('keydown', onKey)
})
function pick(mode: ThemeMode) {
theme.set(mode)
open.value = false
}
</script>
<template>
<div ref="menu" class="relative">
<button
class="inline-flex h-8 items-center gap-1.5 rounded-md px-2 text-muted transition hover:bg-surface2 hover:text-ink"
:title="`主题:${label}(点击选择)`"
:aria-label="`主题:${label}`"
aria-haspopup="true"
:aria-expanded="open"
@click="open = !open"
>
<component :is="icon" :size="15" weight="bold" />
</button>
<Transition
enter-active-class="transition duration-100"
enter-from-class="scale-95 opacity-0"
leave-active-class="transition duration-100"
leave-to-class="scale-95 opacity-0"
>
<div
v-if="open"
class="card absolute right-0 top-full z-50 mt-1 w-36 origin-top-right p-1 shadow-lg"
>
<button
v-for="opt in options"
:key="opt.mode"
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition hover:bg-surface2"
:class="theme.mode === opt.mode ? 'text-ink' : 'text-muted'"
role="menuitemradio"
:aria-checked="theme.mode === opt.mode"
@click="pick(opt.mode)"
>
<component :is="opt.icon" :size="14" />
<span class="flex-1 text-left">{{ opt.label }}</span>
<PhCheck v-if="theme.mode === opt.mode" :size="13" class="text-accent" />
</button>
</div>
</Transition>
</div>
</template>
+4 -4
View File
@@ -4,7 +4,7 @@ const toast = useToastStore()
</script> </script>
<template> <template>
<div class="fixed right-4 bottom-4 z-[80] flex w-80 flex-col gap-2" aria-live="polite"> <div class="fixed right-4 bottom-4 z-[80] flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2" aria-live="polite">
<TransitionGroup <TransitionGroup
enter-active-class="transition-all duration-200" enter-active-class="transition-all duration-200"
enter-from-class="translate-y-1 opacity-0" enter-from-class="translate-y-1 opacity-0"
@@ -15,13 +15,13 @@ const toast = useToastStore()
v-for="t in toast.items" v-for="t in toast.items"
:key="t.id" :key="t.id"
class="card flex items-start gap-2.5 px-4 py-3 shadow-lg" class="card flex items-start gap-2.5 px-4 py-3 shadow-lg"
:class="t.type === 'err' && 'border-err/40'" :class="t.type === 'err' && 'border-err'"
> >
<span <span
class="mt-0.5 size-1.5 shrink-0 rounded-full" class="mt-0.5 size-1.5 shrink-0 rounded-full"
:class="t.type === 'err' ? 'bg-err' : t.type === 'ok' ? 'bg-ok' : 'bg-zinc-500'" :class="t.type === 'err' ? 'bg-err' : t.type === 'ok' ? 'bg-ok' : 'bg-muted'"
/> />
<p class="text-sm text-zinc-200">{{ t.msg }}</p> <p class="text-sm text-ink">{{ t.msg }}</p>
</div> </div>
</TransitionGroup> </TransitionGroup>
</div> </div>
+3 -3
View File
@@ -27,7 +27,7 @@ const chart = computed(() => {
<template> <template>
<div class="w-full"> <div class="w-full">
<div class="mb-1 flex items-baseline justify-between"> <div class="mb-1 flex items-baseline justify-between">
<span class="mono-num text-xs text-zinc-500">max {{ chart.maxLabel }}</span> <span class="mono-num text-xs text-muted">max {{ chart.maxLabel }}</span>
</div> </div>
<svg <svg
:viewBox="`0 0 100 ${height}`" :viewBox="`0 0 100 ${height}`"
@@ -38,7 +38,7 @@ const chart = computed(() => {
:aria-label="`用量趋势,共 ${points.length} 天`" :aria-label="`用量趋势,共 ${points.length} 天`"
> >
<!-- 基线 --> <!-- 基线 -->
<line x1="0" :y1="height - 18" x2="100" :y2="height - 18" stroke="rgb(39 39 42)" stroke-width="0.6" /> <line x1="0" :y1="height - 18" x2="100" :y2="height - 18" stroke="var(--edge)" stroke-width="0.6" />
<g v-for="b in chart.bars" :key="b.label"> <g v-for="b in chart.bars" :key="b.label">
<rect <rect
:x="b.x" :x="b.x"
@@ -50,7 +50,7 @@ const chart = computed(() => {
/> />
</g> </g>
</svg> </svg>
<div class="mt-1 flex justify-between font-mono text-[10px] text-zinc-600"> <div class="mt-1 flex justify-between font-mono text-[10px] text-muted">
<span v-for="b in chart.bars" :key="'l-' + b.label" class="truncate">{{ b.label }}</span> <span v-for="b in chart.bars" :key="'l-' + b.label" class="truncate">{{ b.label }}</span>
</div> </div>
</div> </div>
+30
View File
@@ -0,0 +1,30 @@
// copyText 复制文本到剪贴板。
// 优先用 Clipboard API(安全上下文 HTTPS/localhost);HTTP 环境回退到 execCommand('copy')。
export async function copyText(text: string): Promise<boolean> {
// 1. Clipboard API
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
/* 权限/焦点问题,回退 */
}
}
// 2. execCommand 兜底(非安全上下文可用)
try {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
ta.style.pointerEvents = 'none'
document.body.appendChild(ta)
ta.focus()
ta.select()
ta.setSelectionRange(0, text.length)
const ok = document.execCommand('copy')
document.body.removeChild(ta)
return ok
} catch {
return false
}
}
+27
View File
@@ -0,0 +1,27 @@
// 协议格式显示名与选项
export const PROTOCOL_NAMES: Record<string, string> = {
chat: 'OpenAI Chat Completions',
responses: 'OpenAI Responses API',
messages: 'Anthropic Messages',
}
// 渠道表格用的短标识
export const PROTOCOL_SHORT: Record<string, string> = {
chat: 'chat/completions',
responses: 'responses',
messages: 'messages',
}
export function protocolShort(p: string): string {
return PROTOCOL_SHORT[p] ?? p
}
export const PROTOCOL_OPTIONS: { value: string; label: string }[] = [
{ value: 'chat', label: 'OpenAI Chat Completions' },
{ value: 'responses', label: 'OpenAI Responses API' },
{ value: 'messages', label: 'Anthropic Messages' },
]
export function protocolName(p: string): string {
return PROTOCOL_NAMES[p] ?? p
}
+76
View File
@@ -0,0 +1,76 @@
// WebAuthn(passkey)浏览器侧工具:选项编解码 + 凭据序列化。
// go-webauthn 返回的 challenge/id 为 base64url 字符串,浏览器需要 ArrayBuffer;
// 回调的 credential 需要把 ArrayBuffer 字段转回 base64url。
export function bufToB64url(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf)
let s = ''
for (const b of bytes) s += String.fromCharCode(b)
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
}
export function b64urlToBuf(s: string): ArrayBuffer {
const t = s.replace(/-/g, '+').replace(/_/g, '/')
const pad = t.length % 4 === 0 ? '' : '='.repeat(4 - (t.length % 4))
const bin = atob(t + pad)
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
return bytes.buffer
}
interface CredentialId {
type?: string
id: string
}
function prepareIds(list?: CredentialId[]): { type?: string; id: ArrayBuffer }[] | undefined {
if (!list) return undefined
return list.map((c) => ({ ...c, id: b64urlToBuf(c.id) }))
}
// registerPasskey 调用 navigator.credentials.create,返回可提交后端的 JSON。
export async function registerPasskey(options: Record<string, any>): Promise<Record<string, any>> {
const pk: Record<string, any> = { ...options.publicKey }
pk.challenge = b64urlToBuf(pk.challenge)
if (pk.user?.id) pk.user = { ...pk.user, id: b64urlToBuf(pk.user.id) }
pk.excludeCredentials = prepareIds(pk.excludeCredentials)
const cred = (await navigator.credentials.create({ publicKey: pk as PublicKeyCredentialCreationOptions })) as PublicKeyCredential
const resp = cred.response as AuthenticatorAttestationResponse
return {
id: cred.id,
rawId: bufToB64url(cred.rawId),
type: cred.type,
response: {
clientDataJSON: bufToB64url(resp.clientDataJSON),
attestationObject: bufToB64url(resp.attestationObject),
transports: (resp as unknown as { getTransports?: () => string[] }).getTransports?.() ?? [],
},
}
}
// loginPasskey 调用 navigator.credentials.get,返回可提交后端的 JSON。
export async function loginPasskey(options: Record<string, any>): Promise<Record<string, any>> {
const pk: Record<string, any> = { ...options.publicKey }
pk.challenge = b64urlToBuf(pk.challenge)
pk.allowCredentials = prepareIds(pk.allowCredentials)
const cred = (await navigator.credentials.get({ publicKey: pk as PublicKeyCredentialRequestOptions })) as PublicKeyCredential
const resp = cred.response as AuthenticatorAssertionResponse
return {
id: cred.id,
rawId: bufToB64url(cred.rawId),
type: cred.type,
response: {
clientDataJSON: bufToB64url(resp.clientDataJSON),
authenticatorData: bufToB64url(resp.authenticatorData),
signature: bufToB64url(resp.signature),
userHandle: resp.userHandle ? bufToB64url(resp.userHandle) : null,
},
}
}
// isWebAuthnSupported 是否处于安全上下文(passkey 需要 HTTPS 或 localhost)。
export function isWebAuthnSupported(): boolean {
return typeof navigator !== 'undefined' && !!navigator.credentials && window.isSecureContext
}
+4 -4
View File
@@ -2,12 +2,12 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import { router } from './router' import { router } from './router'
import { useThemeStore } from './stores/theme'
import './style.css' import './style.css'
// 深色优先(MVP 固定深色,后续加亮色切换)
document.documentElement.classList.add('dark')
const app = createApp(App) const app = createApp(App)
app.use(createPinia()) const pinia = createPinia()
app.use(pinia)
useThemeStore(pinia).init()
app.use(router) app.use(router)
app.mount('#app') app.mount('#app')
+6 -2
View File
@@ -1,6 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const ShellLayout = () => import('@/components/layout/ShellLayout.vue')
export const router = createRouter({ export const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
@@ -9,18 +11,19 @@ export const router = createRouter({
{ path: '/register', name: 'register', component: () => import('@/views/RegisterView.vue'), meta: { guest: true } }, { path: '/register', name: 'register', component: () => import('@/views/RegisterView.vue'), meta: { guest: true } },
{ {
path: '/console', path: '/console',
component: () => import('@/views/console/ConsoleLayout.vue'), component: ShellLayout,
meta: { auth: true }, meta: { auth: true },
children: [ children: [
{ path: '', redirect: '/console/dashboard' }, { path: '', redirect: '/console/dashboard' },
{ path: 'dashboard', name: 'dashboard', component: () => import('@/views/console/DashboardView.vue') }, { path: 'dashboard', name: 'dashboard', component: () => import('@/views/console/DashboardView.vue') },
{ path: 'keys', name: 'keys', component: () => import('@/views/console/KeysView.vue') }, { path: 'keys', name: 'keys', component: () => import('@/views/console/KeysView.vue') },
{ path: 'usage', name: 'usage', component: () => import('@/views/console/UsageView.vue') }, { path: 'usage', name: 'usage', component: () => import('@/views/console/UsageView.vue') },
{ path: 'settings', name: 'settings', component: () => import('@/views/console/SettingsView.vue') },
], ],
}, },
{ {
path: '/admin', path: '/admin',
component: () => import('@/views/admin/AdminLayout.vue'), component: ShellLayout,
meta: { auth: true, admin: true }, meta: { auth: true, admin: true },
children: [ children: [
{ path: '', redirect: '/admin/overview' }, { path: '', redirect: '/admin/overview' },
@@ -28,6 +31,7 @@ export const router = createRouter({
{ path: 'channels', name: 'admin-channels', component: () => import('@/views/admin/ChannelsView.vue') }, { path: 'channels', name: 'admin-channels', component: () => import('@/views/admin/ChannelsView.vue') },
{ path: 'models', name: 'admin-models', component: () => import('@/views/admin/ModelsView.vue') }, { path: 'models', name: 'admin-models', component: () => import('@/views/admin/ModelsView.vue') },
{ path: 'users', name: 'admin-users', component: () => import('@/views/admin/UsersView.vue') }, { path: 'users', name: 'admin-users', component: () => import('@/views/admin/UsersView.vue') },
{ path: 'usage', name: 'admin-usage', component: () => import('@/views/admin/UsageView.vue') },
{ path: 'config', name: 'admin-config', component: () => import('@/views/admin/ConfigView.vue') }, { path: 'config', name: 'admin-config', component: () => import('@/views/admin/ConfigView.vue') },
], ],
}, },
+3 -1
View File
@@ -29,7 +29,9 @@ export const useAuthStore = defineStore('auth', {
}, },
async login(username: string, password: string) { async login(username: string, password: string) {
const { data } = await http.post('/auth/login', { username, password }) const { data } = await http.post('/auth/login', { username, password })
const d = data.data as { access_token: string; user: User } this.setSession(data.data)
},
setSession(d: { access_token: string; user: User }) {
this.accessToken = d.access_token this.accessToken = d.access_token
this.user = d.user this.user = d.user
localStorage.setItem('ot_access', d.access_token) localStorage.setItem('ot_access', d.access_token)
+42
View File
@@ -0,0 +1,42 @@
import { defineStore } from 'pinia'
export type ThemeMode = 'light' | 'dark' | 'system'
const STORAGE_KEY = 'ot_theme'
const media = window.matchMedia('(prefers-color-scheme: dark)')
function resolve(mode: ThemeMode): 'light' | 'dark' {
if (mode === 'system') return media.matches ? 'dark' : 'light'
return mode
}
export const useThemeStore = defineStore('theme', {
state: () => ({
mode: (localStorage.getItem(STORAGE_KEY) as ThemeMode) || 'system',
}),
getters: {
resolved(state): 'light' | 'dark' {
return resolve(state.mode)
},
},
actions: {
// 应用当前主题;监听系统切换(system 模式时跟随)
init() {
this.apply()
media.addEventListener('change', () => this.apply())
},
set(mode: ThemeMode) {
this.mode = mode
localStorage.setItem(STORAGE_KEY, mode)
this.apply()
},
cycle() {
const order: ThemeMode[] = ['system', 'light', 'dark']
const i = order.indexOf(this.mode)
this.set(order[(i + 1) % order.length])
},
apply() {
document.documentElement.setAttribute('data-theme', this.resolved)
},
},
})
+58 -26
View File
@@ -4,32 +4,64 @@
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* 设计 tokens(taste-skill 产出) */ /* 设计 tokens(taste-skill 产出) */
/* 深色优先 · 单一强调色 emerald · 圆角体系:卡片 8 / 控件 6 / 徽章 pill */ /* 三主题 dark / light / system(data-theme 切换) */
/* 密度 7:mono 数字、紧凑表格、细线分隔 */ /* 令牌值在 @theme 内写实色字面量 → 透明度修饰符(如 bg-accent-soft)可用; */
/* [data-theme=light] 覆盖同一批 --color-* 变量实现主题切换。 */
/* 圆角:卡片 8 / 控件 6 / 徽章 pill */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@theme { @theme {
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; --font-sans: 'Geist Variable', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-mono: 'Geist Mono Variable', ui-monospace, SFMono-Regular, Menlo, monospace; --font-mono: 'Geist Mono Variable', ui-monospace, SFMono-Regular, Menlo, monospace;
/* 强调色(单一一处定义,全局一致) */ /* 深色(默认)语义令牌 */
--color-bg: #09090b;
--color-surface: #18181b;
--color-surface2: #27272a;
--color-edge: #27272a;
--color-edge2: #3f3f46;
--color-ink: #f4f4f5;
--color-muted: #a1a1aa;
--color-accent: oklch(0.72 0.17 152); --color-accent: oklch(0.72 0.17 152);
--color-accent-strong: oklch(0.64 0.19 152); --color-accent-strong: oklch(0.64 0.19 152);
--color-accent-soft: oklch(0.95 0.05 152); --color-accent-ink: #09090b;
/* 状态色(语义,克制使用) */
--color-ok: oklch(0.72 0.17 152); --color-ok: oklch(0.72 0.17 152);
--color-warn: oklch(0.80 0.15 75); --color-warn: #fbbf24;
--color-err: oklch(0.63 0.21 25); --color-err: #f87171;
/* 软色(color-mix 运行时解析,随主题自适应) */
--color-accent-soft: color-mix(in oklab, var(--color-accent) 15%, transparent);
--color-ok-soft: color-mix(in oklab, var(--color-ok) 15%, transparent);
--color-warn-soft: color-mix(in oklab, var(--color-warn) 15%, transparent);
--color-err-soft: color-mix(in oklab, var(--color-err) 12%, transparent);
} }
html { /* 默认深色 */
:root {
color-scheme: dark; color-scheme: dark;
} }
/* 浅色:覆盖同一批语义令牌 */
[data-theme='light'] {
color-scheme: light;
--color-bg: #fafafa;
--color-surface: #ffffff;
--color-surface2: #f4f4f5;
--color-edge: #e4e4e7;
--color-edge2: #d4d4d8;
--color-ink: #18181b;
--color-muted: #52525b;
--color-accent: oklch(0.52 0.14 152);
--color-accent-strong: oklch(0.47 0.14 152);
--color-accent-ink: #ffffff;
--color-ok: oklch(0.52 0.14 152);
--color-warn: #d97706;
--color-err: #dc2626;
}
body { body {
background-color: #09090b; background-color: var(--color-bg);
color: #f4f4f5; color: var(--color-ink);
font-family: var(--font-sans); font-family: var(--font-sans);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
@@ -41,6 +73,21 @@ body {
border-radius: 4px; border-radius: 4px;
} }
/* 通用组件视觉基元 */
@layer components {
.card {
@apply rounded-lg border border-edge bg-surface;
}
.mono-num {
@apply font-mono tabular-nums;
}
.table-row {
@apply border-b border-edge last:border-0 hover:bg-surface2;
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,
@@ -49,18 +96,3 @@ body {
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
} }
} }
/* 通用组件视觉基元 */
@layer components {
.card {
@apply rounded-lg border border-zinc-800 bg-zinc-900/60;
}
.mono-num {
@apply font-mono tabular-nums;
}
.table-row {
@apply border-b border-zinc-800/70 last:border-0 hover:bg-zinc-800/30;
}
}
+27 -1
View File
@@ -5,6 +5,8 @@ export interface User {
role: 'user' | 'admin' role: 'user' | 'admin'
balance: number balance: number
status: string status: string
allowed_models?: string[] | null
denied_models?: string[] | null
created_at: string created_at: string
} }
@@ -25,7 +27,9 @@ export interface Channel {
id: number id: number
name: string name: string
provider: 'openai' | 'anthropic' | 'compatible' provider: 'openai' | 'anthropic' | 'compatible'
formats: string[] // chat | responses | messages
base_url: string base_url: string
base_urls?: Record<string, string> | null // 分协议 base_url 覆盖
api_key_masked: string api_key_masked: string
weight: number weight: number
priority: number priority: number
@@ -36,6 +40,14 @@ export interface Channel {
created_at: string created_at: string
} }
export interface ChannelModelMapping {
id: number
model_id: number
model_name: string
upstream_model: string
weight: number
}
export interface ModelBinding { export interface ModelBinding {
id: number id: number
channel_id: number channel_id: number
@@ -47,13 +59,22 @@ export interface ModelBinding {
export interface Model { export interface Model {
id: number id: number
name: string name: string
display_name: string
input_price: number input_price: number
output_price: number output_price: number
cache_read_price: number cache_read_price: number
enabled: boolean enabled: boolean
sort: number sort: number
channels: ModelBinding[] channels: ModelBinding[]
used?: boolean
needs_pricing?: boolean
denied?: boolean
}
export interface ModelSummary {
total: number
unpriced: number
missing: { channel: string; model_id: number; upstream_model: string }[]
denied_count: number
} }
export interface UsageLog { export interface UsageLog {
@@ -61,6 +82,8 @@ export interface UsageLog {
request_id: string request_id: string
model: string model: string
protocol: string protocol: string
channel_id?: number
channel?: string
input_tokens: number input_tokens: number
output_tokens: number output_tokens: number
cache_read_tokens: number cache_read_tokens: number
@@ -68,8 +91,11 @@ export interface UsageLog {
latency_ms: number latency_ms: number
status: string status: string
error_code: string | null error_code: string | null
raw_request?: string
raw_response?: string
created_at: string created_at: string
user?: string user?: string
user_id?: number
} }
export interface Paged<T> { export interface Paged<T> {
+51 -45
View File
@@ -1,32 +1,38 @@
<script setup lang="ts"> <script setup lang="ts">
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
const auth = useAuthStore() const auth = useAuthStore()
// 调用演示用浏览器当前 host(兼容端点),避免写死域名
const host = window.location.host
const baseURL = `${window.location.origin}/v1`
</script> </script>
<template> <template>
<div class="min-h-[100dvh] bg-zinc-950 text-zinc-100"> <div class="min-h-[100dvh] bg-bg text-ink">
<!-- 导航 --> <!-- 导航 -->
<header class="sticky top-0 z-40 border-b border-zinc-800/60 bg-zinc-950/80 backdrop-blur"> <header class="sticky top-0 z-40 border-b border-edge bg-bg backdrop-blur">
<div class="mx-auto flex h-14 max-w-6xl items-center justify-between px-4"> <div class="mx-auto flex h-14 max-w-6xl items-center justify-between px-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<img src="/favicon.svg" alt="" class="size-5" /> <img src="/favicon.svg" alt="" class="size-5" />
<span class="text-sm font-semibold tracking-tight">openteam</span> <span class="text-sm font-semibold tracking-tight">openteam</span>
</div> </div>
<nav class="flex items-center gap-2"> <nav class="flex items-center gap-1.5 sm:gap-2">
<ThemeToggle />
<router-link <router-link
v-if="!auth.isAuthed" v-if="!auth.isAuthed"
to="/login" to="/login"
class="rounded-md px-3 py-1.5 text-sm text-zinc-400 transition hover:text-zinc-100" class="rounded-md px-2.5 py-1.5 text-sm text-muted transition hover:text-ink sm:px-3"
> >
登录 登录
</router-link> </router-link>
<router-link v-else to="/console/dashboard" class="rounded-md px-3 py-1.5 text-sm text-zinc-400 transition hover:text-zinc-100"> <router-link v-else to="/console/dashboard" class="rounded-md px-2.5 py-1.5 text-sm text-muted transition hover:text-ink sm:px-3">
控制台 控制台
</router-link> </router-link>
<router-link <router-link
v-if="!auth.isAuthed" v-if="!auth.isAuthed"
to="/register" to="/register"
class="rounded-md bg-accent px-3.5 py-1.5 text-sm font-medium text-zinc-950 transition hover:bg-accent-strong" class="rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-ink transition hover:bg-accent-strong sm:px-3.5"
> >
免费注册 免费注册
</router-link> </router-link>
@@ -39,49 +45,49 @@ const auth = useAuthStore()
<div class="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-emerald-500/40 to-transparent" /> <div class="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-emerald-500/40 to-transparent" />
<div class="mx-auto grid max-w-6xl items-center gap-10 px-4 pt-20 pb-16 lg:grid-cols-2 lg:pt-24"> <div class="mx-auto grid max-w-6xl items-center gap-10 px-4 pt-20 pb-16 lg:grid-cols-2 lg:pt-24">
<div class="max-w-xl"> <div class="max-w-xl">
<p class="mb-3 font-mono text-xs tracking-wide text-emerald-400/80">LLM API 中转网关</p> <p class="mb-3 font-mono text-xs tracking-wide text-accent">LLM API 中转网关</p>
<h1 class="text-4xl leading-none font-semibold tracking-tight md:text-5xl"> <h1 class="text-3xl leading-tight font-semibold tracking-tight sm:text-4xl md:text-5xl">
一个 Key,调用所有主流模型 一个 Key,调用所有主流模型
</h1> </h1>
<p class="mt-5 max-w-md text-base leading-relaxed text-zinc-400"> <p class="mt-5 max-w-md text-base leading-relaxed text-muted">
统一 OpenAI 与 Anthropic 协议入口,三套 API 自动互转,用量、计费与 API Key 管理开箱即用。 统一 OpenAI 与 Anthropic 协议入口,三套 API 自动互转,用量、计费与 API Key 管理开箱即用。
</p> </p>
<div class="mt-8 flex items-center gap-3"> <div class="mt-8 flex items-center gap-3">
<router-link <router-link
to="/register" to="/register"
class="inline-flex h-10 items-center rounded-md bg-accent px-5 text-sm font-medium text-zinc-950 transition hover:bg-accent-strong active:scale-[0.98]" class="inline-flex h-10 items-center rounded-md bg-accent px-5 text-sm font-medium text-accent-ink transition hover:bg-accent-strong active:scale-[0.98]"
> >
免费注册 免费注册
</router-link> </router-link>
<router-link <router-link
to="/login" to="/login"
class="inline-flex h-10 items-center rounded-md border border-zinc-700 px-5 text-sm text-zinc-200 transition hover:border-zinc-500" class="inline-flex h-10 items-center rounded-md border border-edge2 px-5 text-sm text-ink transition hover:border-edge2"
> >
登录 登录
</router-link> </router-link>
</div> </div>
<p class="mt-5 font-mono text-xs text-zinc-600">不用换 SDK,改一行 base_url 即可接入</p> <p class="mt-5 font-mono text-xs text-muted">不用换 SDK,改一行 base_url 即可接入</p>
</div> </div>
<!-- 调用演示(真实格式,非伪截图) --> <!-- 调用演示(真实格式,非伪截图) -->
<div class="card overflow-hidden font-mono text-xs"> <div class="card overflow-hidden font-mono text-xs">
<div class="flex items-center gap-1.5 border-b border-zinc-800 px-4 py-2.5"> <div class="flex items-center gap-1.5 border-b border-edge px-4 py-2.5">
<span class="size-2.5 rounded-full bg-zinc-700" /> <span class="size-2.5 rounded-full bg-surface2" />
<span class="size-2.5 rounded-full bg-zinc-700" /> <span class="size-2.5 rounded-full bg-surface2" />
<span class="ml-2 text-zinc-500">curl api.openteam.dev</span> <span class="ml-2 text-muted">curl {{ host }}</span>
</div> </div>
<div class="space-y-3 p-4 leading-relaxed"> <div class="space-y-3 p-4 leading-relaxed">
<div> <div>
<p class="text-zinc-400"><span class="text-emerald-400">$</span> curl https://api.openteam.dev/v1/chat/completions</p> <p class="text-muted"><span class="text-accent">$</span> curl {{ baseURL }}/chat/completions</p>
<p class="text-zinc-400"> -H <span class="text-emerald-300">"Authorization: Bearer sk-..."</span> \</p> <p class="text-muted"> -H <span class="text-accent">"Authorization: Bearer sk-ot-..."</span> \</p>
<p class="text-zinc-400"> -d <span class="text-zinc-300">'{"model": "claude-sonnet-5", "messages": [{"role": "user", "content": "你好"}]}'</span></p> <p class="text-muted"> -d <span class="text-ink">'{"model": "claude-sonnet-5", "messages": [{"role": "user", "content": "你好"}]}'</span></p>
</div> </div>
<div class="border-t border-zinc-800 pt-3 text-zinc-500"> <div class="border-t border-edge pt-3 text-muted">
<p class="text-zinc-600"># OpenAI 格式请求,网关自动转 Anthropic 协议</p> <p class="text-muted"># OpenAI 格式请求,网关自动转 Anthropic 协议</p>
<p class="text-zinc-300">data: {"id":"resp_1","model":"claude-sonnet-5","choices":[{</p> <p class="text-ink">data: {"id":"resp_1","model":"claude-sonnet-5","choices":[{</p>
<p class="text-zinc-300">&nbsp;&nbsp;"delta":{"content":"你好,这是流式回复"}</p> <p class="text-ink">&nbsp;&nbsp;"delta":{"content":"你好,这是流式回复"}</p>
<p class="text-zinc-300">}]}</p> <p class="text-ink">}]}</p>
<p class="text-zinc-300">data: [DONE]</p> <p class="text-ink">data: [DONE]</p>
</div> </div>
</div> </div>
</div> </div>
@@ -89,57 +95,57 @@ const auth = useAuthStore()
</section> </section>
<!-- 协议入口 --> <!-- 协议入口 -->
<section class="border-t border-zinc-800/60"> <section class="border-t border-edge">
<div class="mx-auto max-w-6xl px-4 py-16"> <div class="mx-auto max-w-6xl px-4 py-16">
<h2 class="text-2xl font-semibold tracking-tight">三套协议,一个入口</h2> <h2 class="text-2xl font-semibold tracking-tight">三套协议,一个入口</h2>
<p class="mt-2 max-w-xl text-sm text-zinc-500"> <p class="mt-2 max-w-xl text-sm text-muted">
对客户端暴露统一的 OpenAI 兼容入口;客户端协议与上游渠道不匹配时自动转换,无需关心背后接的是哪家。 对客户端暴露统一的 OpenAI 兼容入口;客户端协议与上游渠道不匹配时自动转换,无需关心背后接的是哪家。
</p> </p>
<div class="card mt-8 divide-y divide-zinc-800/70"> <div class="card mt-8 divide-y divide-edge">
<div v-for="p in [ <div v-for="p in [
{ path: 'POST /v1/chat/completions', desc: 'OpenAI Chat,兼容面最广,SDK 与工具链最全', tag: 'OpenAI' }, { path: 'POST /v1/chat/completions', desc: 'OpenAI Chat,兼容面最广,SDK 与工具链最全', tag: 'OpenAI' },
{ path: 'POST /v1/responses', desc: 'OpenAI Responses,新一代 SDK 与 Agents 首选', tag: 'OpenAI' }, { path: 'POST /v1/responses', desc: 'OpenAI Responses,新一代 SDK 与 Agents 首选', tag: 'OpenAI' },
{ path: 'POST /v1/messages', desc: 'Anthropic Messages,Claude 生态原生格式', tag: 'Anthropic' }, { path: 'POST /v1/messages', desc: 'Anthropic Messages,Claude 生态原生格式', tag: 'Anthropic' },
{ path: 'GET /v1/models', desc: 'OpenAI 风格模型列表', tag: 'List' }, { path: 'GET /v1/models', desc: 'OpenAI 风格模型列表', tag: 'List' },
]" :key="p.path" class="grid gap-1 px-5 py-3.5 sm:grid-cols-3 sm:items-center"> ]" :key="p.path" class="grid gap-1 px-5 py-3.5 sm:grid-cols-3 sm:items-center">
<code class="font-mono text-sm text-emerald-300">{{ p.path }}</code> <code class="font-mono text-sm text-accent">{{ p.path }}</code>
<p class="text-sm text-zinc-400 sm:col-span-1">{{ p.desc }}</p> <p class="text-sm text-muted sm:col-span-1">{{ p.desc }}</p>
<span class="hidden justify-self-end font-mono text-[11px] text-zinc-600 sm:block">{{ p.tag }}</span> <span class="hidden justify-self-end font-mono text-[11px] text-muted sm:block">{{ p.tag }}</span>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<!-- 网关能力 --> <!-- 网关能力 -->
<section class="border-t border-zinc-800/60"> <section class="border-t border-edge">
<div class="mx-auto max-w-6xl px-4 py-16"> <div class="mx-auto max-w-6xl px-4 py-16">
<h2 class="text-2xl font-semibold tracking-tight">网关替你处理的事</h2> <h2 class="text-2xl font-semibold tracking-tight">网关替你处理的事</h2>
<div class="mt-8 grid gap-4 lg:grid-cols-3"> <div class="mt-8 grid gap-4 lg:grid-cols-3">
<div class="card p-5 lg:col-span-2"> <div class="card p-5 lg:col-span-2">
<p class="font-mono text-xs text-emerald-400/80">responses → claude-sonnet-5</p> <p class="font-mono text-xs text-accent">responses → claude-sonnet-5</p>
<h3 class="mt-2 text-lg font-semibold">协议自动转换</h3> <h3 class="mt-2 text-lg font-semibold">协议自动转换</h3>
<p class="mt-2 text-sm leading-relaxed text-zinc-500"> <p class="mt-2 text-sm leading-relaxed text-muted">
客户端按 Responses 调用 Claude 模型,网关转成 Anthropic 协议打给上游,再以 Responses 事件流式返回。直通优先,能力无损时零转换。 客户端按 Responses 调用 Claude 模型,网关转成 Anthropic 协议打给上游,再以 Responses 事件流式返回。直通优先,能力无损时零转换。
</p> </p>
</div> </div>
<div class="card p-5"> <div class="card p-5">
<p class="font-mono text-xs text-zinc-600">usage · daily</p> <p class="font-mono text-xs text-muted">usage · daily</p>
<h3 class="mt-2 text-lg font-semibold">用量与计费</h3> <h3 class="mt-2 text-lg font-semibold">用量与计费</h3>
<p class="mt-2 text-sm leading-relaxed text-zinc-500"> <p class="mt-2 text-sm leading-relaxed text-muted">
请求级 token 统计,按模型价格自动扣费,日粒度报表与余额流水可追溯。 请求级 token 统计,按模型价格自动扣费,日粒度报表与余额流水可追溯。
</p> </p>
</div> </div>
<div class="card p-5"> <div class="card p-5">
<p class="font-mono text-xs text-zinc-600">sk-…</p> <p class="font-mono text-xs text-muted">sk-ot-…</p>
<h3 class="mt-2 text-lg font-semibold">API Key 管理</h3> <h3 class="mt-2 text-lg font-semibold">API Key 管理</h3>
<p class="mt-2 text-sm leading-relaxed text-zinc-500"> <p class="mt-2 text-sm leading-relaxed text-muted">
密钥仅存哈希,支持配额、过期与模型白名单,创建时一次性展示。 密钥仅存哈希,支持配额、过期与模型白名单,创建时一次性展示。
</p> </p>
</div> </div>
<div class="card p-5 lg:col-span-2"> <div class="card p-5 lg:col-span-2">
<p class="font-mono text-xs text-zinc-600">channels · lb · health</p> <p class="font-mono text-xs text-muted">channels · lb · health</p>
<h3 class="mt-2 text-lg font-semibold">多渠道接入</h3> <h3 class="mt-2 text-lg font-semibold">多渠道接入</h3>
<p class="mt-2 text-sm leading-relaxed text-zinc-500"> <p class="mt-2 text-sm leading-relaxed text-muted">
一个模型绑定多个上游渠道,按权重与健康状态选择,故障自动转移。管理员在后台一键接入新渠道、导入模型并定价。 一个模型绑定多个上游渠道,按权重与健康状态选择,故障自动转移。管理员在后台一键接入新渠道、导入模型并定价。
</p> </p>
</div> </div>
@@ -148,21 +154,21 @@ const auth = useAuthStore()
</section> </section>
<!-- CTA --> <!-- CTA -->
<section class="border-t border-zinc-800/60"> <section class="border-t border-edge">
<div class="mx-auto max-w-6xl px-4 py-20 text-center"> <div class="mx-auto max-w-6xl px-4 py-20 text-center">
<h2 class="text-2xl font-semibold tracking-tight">自托管,密钥在自己手里</h2> <h2 class="text-2xl font-semibold tracking-tight">自托管,密钥在自己手里</h2>
<p class="mx-auto mt-3 max-w-md text-sm text-zinc-500">Docker Compose 一键部署,PostgreSQL 存账,渠道密钥加密存储。</p> <p class="mx-auto mt-3 max-w-md text-sm text-muted">Docker Compose 一键部署,PostgreSQL 存账,渠道密钥加密存储。</p>
<router-link <router-link
to="/register" to="/register"
class="mt-8 inline-flex h-10 items-center rounded-md bg-accent px-6 text-sm font-medium text-zinc-950 transition hover:bg-accent-strong active:scale-[0.98]" class="mt-8 inline-flex h-10 items-center rounded-md bg-accent px-6 text-sm font-medium text-accent-ink transition hover:bg-accent-strong active:scale-[0.98]"
> >
开始使用 开始使用
</router-link> </router-link>
</div> </div>
</section> </section>
<footer class="border-t border-zinc-800/60"> <footer class="border-t border-edge">
<div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-6 text-xs text-zinc-600"> <div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-6 text-xs text-muted">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<img src="/favicon.svg" alt="" class="size-4" /> <img src="/favicon.svg" alt="" class="size-4" />
<span>openteam · LLM API 中转站</span> <span>openteam · LLM API 中转站</span>
+47 -5
View File
@@ -1,11 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { PhFingerprint } from '@phosphor-icons/vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import { errMsg } from '@/api/client' import { http, errMsg } from '@/api/client'
import { loginPasskey, isWebAuthnSupported } from '@/lib/webauthn'
import Button from '@/components/ui/Button.vue' import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue' import Input from '@/components/ui/Input.vue'
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -15,6 +18,7 @@ const toast = useToastStore()
const username = ref('') const username = ref('')
const password = ref('') const password = ref('')
const loading = ref(false) const loading = ref(false)
const pkLoading = ref(false)
const error = ref('') const error = ref('')
async function submit() { async function submit() {
@@ -32,27 +36,65 @@ async function submit() {
loading.value = false loading.value = false
} }
} }
async function passkeyLogin() {
if (!isWebAuthnSupported()) {
toast.err('当前环境不支持 Passkey(需 HTTPS 或 localhost)')
return
}
pkLoading.value = true
error.value = ''
try {
const { data } = await http.post('/webauthn/login/begin', {
username: username.value || undefined,
})
const credential = await loginPasskey(data.data.assertion)
const resp = await http.post('/webauthn/login/complete', {
challenge: data.data.challenge,
credential,
user_id: data.data.user_id ?? 0,
})
auth.setSession(resp.data.data)
toast.ok('登录成功')
const redirect = (route.query.redirect as string) || '/console/dashboard'
router.push(redirect)
} catch (e) {
error.value = errMsg(e)
} finally {
pkLoading.value = false
}
}
</script> </script>
<template> <template>
<div class="flex min-h-[100dvh] items-center justify-center bg-zinc-950 px-4"> <div class="flex min-h-[100dvh] items-center justify-center bg-bg px-4">
<ThemeToggle class="fixed right-4 top-4" />
<div class="w-full max-w-sm"> <div class="w-full max-w-sm">
<div class="mb-8 text-center"> <div class="mb-8 text-center">
<div class="mb-3 inline-flex items-center gap-2"> <div class="mb-3 inline-flex items-center gap-2">
<img src="/favicon.svg" alt="" class="size-7" /> <img src="/favicon.svg" alt="" class="size-7" />
<span class="text-lg font-semibold tracking-tight">openteam</span> <span class="text-lg font-semibold tracking-tight">openteam</span>
</div> </div>
<p class="text-sm text-zinc-500">登录到控制台</p> <p class="text-sm text-muted">登录到控制台</p>
</div> </div>
<form class="card space-y-4 p-6" @submit.prevent="submit"> <form class="card space-y-4 p-6" @submit.prevent="submit">
<Input v-model="username" label="用户名或邮箱" autocomplete="username" placeholder="alice" /> <Input v-model="username" label="用户名或邮箱" autocomplete="username" placeholder="alice" />
<Input v-model="password" label="密码" type="password" autocomplete="current-password" /> <Input v-model="password" label="密码" type="password" autocomplete="current-password" />
<p v-if="error" class="text-xs text-red-400">{{ error }}</p> <p v-if="error" class="text-xs text-err">{{ error }}</p>
<Button class="w-full" :loading="loading" type="submit">登录</Button> <Button class="w-full" :loading="loading" type="submit">登录</Button>
<div class="flex items-center gap-3 py-1">
<div class="h-px flex-1 bg-edge" />
<span class="text-xs text-muted">或</span>
<div class="h-px flex-1 bg-edge" />
</div>
<Button variant="ghost" class="w-full" :loading="pkLoading" type="button" @click="passkeyLogin">
<PhFingerprint :size="15" />
使用 Passkey 登录
</Button>
</form> </form>
<p class="mt-5 text-center text-sm text-zinc-500"> <p class="mt-5 text-center text-sm text-muted">
还没有账号? 还没有账号?
<router-link to="/register" class="text-accent hover:text-accent-strong">注册</router-link> <router-link to="/register" class="text-accent hover:text-accent-strong">注册</router-link>
</p> </p>
+6 -4
View File
@@ -6,6 +6,7 @@ import { useToastStore } from '@/stores/toast'
import { errMsg } from '@/api/client' import { errMsg } from '@/api/client'
import Button from '@/components/ui/Button.vue' import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue' import Input from '@/components/ui/Input.vue'
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
@@ -42,14 +43,15 @@ async function submit() {
</script> </script>
<template> <template>
<div class="flex min-h-[100dvh] items-center justify-center bg-zinc-950 px-4"> <div class="flex min-h-[100dvh] items-center justify-center bg-bg px-4">
<ThemeToggle class="fixed right-4 top-4" />
<div class="w-full max-w-sm"> <div class="w-full max-w-sm">
<div class="mb-8 text-center"> <div class="mb-8 text-center">
<div class="mb-3 inline-flex items-center justify-center gap-2"> <div class="mb-3 inline-flex items-center justify-center gap-2">
<img src="/favicon.svg" alt="" class="size-7" /> <img src="/favicon.svg" alt="" class="size-7" />
<span class="text-lg font-semibold tracking-tight">openteam</span> <span class="text-lg font-semibold tracking-tight">openteam</span>
</div> </div>
<p class="text-sm text-zinc-500">一个 Key 访问多家模型</p> <p class="text-sm text-muted">一个 Key 访问多家模型</p>
</div> </div>
<form class="card space-y-4 p-6" @submit.prevent="submit"> <form class="card space-y-4 p-6" @submit.prevent="submit">
@@ -62,11 +64,11 @@ async function submit() {
autocomplete="new-password" autocomplete="new-password"
hint="至少 8 位" hint="至少 8 位"
/> />
<p v-if="error" class="text-xs text-red-400">{{ error }}</p> <p v-if="error" class="text-xs text-err">{{ error }}</p>
<Button class="w-full" :loading="loading" type="submit">注册</Button> <Button class="w-full" :loading="loading" type="submit">注册</Button>
</form> </form>
<p class="mt-5 text-center text-sm text-zinc-500"> <p class="mt-5 text-center text-sm text-muted">
已有账号? 已有账号?
<router-link to="/login" class="text-accent hover:text-accent-strong">登录</router-link> <router-link to="/login" class="text-accent hover:text-accent-strong">登录</router-link>
</p> </p>
-32
View File
@@ -1,32 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import ShellLayout from '@/components/layout/ShellLayout.vue'
const auth = useAuthStore()
const sections = computed(() => {
const s: { title: string; items: { to: string; label: string }[] }[] = [
{ title: '管理', items: [
{ to: '/admin/overview', label: '运营总览' },
{ to: '/admin/channels', label: '渠道' },
{ to: '/admin/models', label: '模型与定价' },
{ to: '/admin/users', label: '用户' },
{ to: '/admin/config', label: '系统配置' },
] },
]
if (auth.user) {
s.push({ title: '控制台', items: [
{ to: '/console/dashboard', label: '仪表盘' },
{ to: '/console/keys', label: 'API Keys' },
{ to: '/console/usage', label: '用量' },
] })
}
return s
})
</script>
<template>
<ShellLayout :sections="sections">
<router-view />
</ShellLayout>
</template>
+178
View File
@@ -0,0 +1,178 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { PhArrowsClockwise, PhPlus, PhX } from '@phosphor-icons/vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
import Button from '@/components/ui/Button.vue'
import type { Channel, ChannelModelMapping } from '@/types'
const props = defineProps<{ channel: Channel }>()
const toast = useToastStore()
const mappings = ref<ChannelModelMapping[]>([])
const remote = ref<string[]>([])
const selected = ref<string[]>([])
const loading = ref(false)
const fetched = ref(false)
const addForm = reactive({ custom_name: '', upstream_model: '' })
async function load() {
try {
const { data } = await http.get(`/admin/channels/${props.channel.id}/models`)
mappings.value = data.data.items
} catch (e) {
toast.err(errMsg(e))
}
}
async function fetchRemote() {
loading.value = true
try {
const { data } = await http.get(`/admin/channels/${props.channel.id}/models/remote`)
remote.value = data.data.items
selected.value = []
fetched.value = true
} catch (e) {
toast.err(errMsg(e))
} finally {
loading.value = false
}
}
async function addSelected() {
let added = 0
for (const name of selected.value) {
try {
await http.post(`/admin/channels/${props.channel.id}/models`, { upstream_model: name })
added++
} catch {
/* 单个失败不中断 */
}
}
selected.value = []
toast.ok(added ? `已添加 ${added} 个模型` : '所选均已添加')
await load()
// 已添加的模型已被渠道允许,从拉取候选中移除
await fetchRemote()
}
async function addManual() {
if (!addForm.upstream_model.trim()) return
try {
await http.post(`/admin/channels/${props.channel.id}/models`, {
upstream_model: addForm.upstream_model.trim(),
custom_name: addForm.custom_name.trim(),
})
toast.ok('已添加')
addForm.custom_name = ''
addForm.upstream_model = ''
await load()
} catch (e) {
toast.err(errMsg(e))
}
}
async function saveUpstream(b: ChannelModelMapping) {
try {
await http.patch(`/admin/channels/${props.channel.id}/models/${b.id}`, {
upstream_model: b.upstream_model,
})
toast.ok('已更新')
await load()
} catch (e) {
toast.err(errMsg(e))
}
}
async function remove(b: ChannelModelMapping) {
if (!confirm(`解除模型 ${b.model_name} 的绑定?`)) return
try {
await http.delete(`/admin/channels/${props.channel.id}/models/${b.id}`)
toast.ok('已解除')
await load()
} catch (e) {
toast.err(errMsg(e))
}
}
onMounted(load)
</script>
<template>
<div class="space-y-3">
<!-- 已允许的模型 -->
<div>
<p class="mb-1.5 text-xs font-medium text-muted">已允许的模型({{ mappings.length }})</p>
<div v-if="mappings.length" class="flex flex-wrap gap-2">
<div
v-for="b in mappings"
:key="b.id"
class="inline-flex items-center gap-1.5 rounded-md border border-edge bg-surface px-2 py-1 font-mono text-[11px] text-muted"
>
<span class="text-ink">{{ b.model_name }}</span>
<span class="opacity-60">→</span>
<input
v-model="b.upstream_model"
class="w-28 rounded border border-transparent bg-transparent px-1 text-[11px] text-accent outline-none transition focus:border-accent/50 focus:bg-surface2"
@change="saveUpstream(b)"
/>
<button class="text-muted hover:text-err" aria-label="解除" @click="remove(b)">
<PhX :size="12" />
</button>
</div>
</div>
<p v-else class="text-xs text-muted">尚未允许任何模型</p>
</div>
<!-- 从接口拉取 + 勾选 -->
<div class="border-t border-edge pt-3">
<div class="mb-1.5 flex items-center justify-between">
<p class="text-xs font-medium text-muted">从接口拉取模型</p>
<Button size="sm" variant="ghost" :loading="loading" @click="fetchRemote">
<PhArrowsClockwise :size="13" />
拉取
</Button>
</div>
<div v-if="remote.length" class="flex max-h-36 flex-wrap gap-2 overflow-y-auto">
<label
v-for="m in remote"
:key="m"
class="flex cursor-pointer items-center gap-1.5 rounded-md border px-2 py-1 font-mono text-[11px] text-muted transition select-none"
:class="selected.includes(m) ? 'border-accent bg-accent-soft text-ink' : 'border-edge2 hover:border-edge'"
>
<input v-model="selected" type="checkbox" :value="m" class="size-3.5 accent-[var(--color-accent)]" />
{{ m }}
</label>
</div>
<div v-if="remote.length" class="mt-2">
<Button size="sm" @click="addSelected">
<PhPlus :size="13" />
添加所选({{ selected.length }})
</Button>
</div>
<p v-else-if="!loading" class="text-xs text-muted">
{{ remote.length === 0 && fetched ? '接口返回的模型均已允许,无新增候选' : '点「拉取」获取渠道接口返回的新模型,勾选需要的加入' }}
</p>
</div>
<!-- 手动添加 -->
<div class="flex items-center gap-2 border-t border-edge pt-3">
<input
v-model="addForm.custom_name"
placeholder="自定义名称(可选)"
class="h-8 min-w-0 flex-1 rounded-md border border-edge2 bg-surface px-2 font-mono text-xs outline-none focus:border-accent"
@keyup.enter="addManual"
/>
<input
v-model="addForm.upstream_model"
placeholder="上游模型名"
class="h-8 min-w-0 flex-1 rounded-md border border-edge2 bg-surface px-2 font-mono text-xs outline-none focus:border-accent"
@keyup.enter="addManual"
/>
<Button size="sm" class="shrink-0" @click="addManual">
<PhPlus :size="13" />
添加
</Button>
</div>
</div>
</template>
+173 -51
View File
@@ -1,7 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { PhCaretDown, PhPulse, PhNotePencil, PhTrash, PhStack } from '@phosphor-icons/vue'
import { http, errMsg } from '@/api/client' import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import { PROTOCOL_OPTIONS, protocolShort } from '@/lib/protocol'
import ChannelModelsDrawer from '@/views/admin/ChannelModelsDrawer.vue'
import Button from '@/components/ui/Button.vue' import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue' import Input from '@/components/ui/Input.vue'
import Modal from '@/components/ui/Modal.vue' import Modal from '@/components/ui/Modal.vue'
@@ -14,25 +17,25 @@ const editOpen = ref(false)
const editing = ref<Channel | null>(null) const editing = ref<Channel | null>(null)
const saving = ref(false) const saving = ref(false)
const busyId = ref<number | null>(null) const busyId = ref<number | null>(null)
const expandedId = ref<number | null>(null)
function toggleDrawer(ch: Channel) {
expandedId.value = expandedId.value === ch.id ? null : ch.id
}
const form = reactive({ const form = reactive({
name: '', name: '',
provider: 'openai' as 'openai' | 'anthropic' | 'compatible', formats: ['chat'] as string[],
base_url: '', base_url: '',
base_urls: { chat: '', responses: '', messages: '' } as Record<string, string>,
api_key: '', api_key: '',
weight: 1, weight: 1,
priority: 0, priority: 0,
timeout_ms: 120000, timeout_ms: 300000,
max_concurrency: 16, max_concurrency: 16,
enabled: true, enabled: true,
}) })
const providerMap: Record<string, string> = {
openai: 'OpenAI',
anthropic: 'Anthropic',
compatible: '兼容',
}
async function load() { async function load() {
try { try {
const { data } = await http.get('/admin/channels') const { data } = await http.get('/admin/channels')
@@ -45,8 +48,10 @@ async function load() {
function openCreate() { function openCreate() {
editing.value = null editing.value = null
Object.assign(form, { Object.assign(form, {
name: '', provider: 'openai', base_url: '', api_key: '', name: '', formats: ['chat'], base_url: '',
weight: 1, priority: 0, timeout_ms: 120000, max_concurrency: 16, enabled: true, base_urls: { chat: '', responses: '', messages: '' },
api_key: '',
weight: 1, priority: 0, timeout_ms: 300000, max_concurrency: 16, enabled: true,
}) })
editOpen.value = true editOpen.value = true
} }
@@ -54,7 +59,14 @@ function openCreate() {
function openEdit(ch: Channel) { function openEdit(ch: Channel) {
editing.value = ch editing.value = ch
Object.assign(form, { Object.assign(form, {
name: ch.name, provider: ch.provider, base_url: ch.base_url, api_key: '', name: ch.name, formats: [...(ch.formats?.length ? ch.formats : ['chat'])],
base_url: ch.base_url,
base_urls: {
chat: ch.base_urls?.chat ?? '',
responses: ch.base_urls?.responses ?? '',
messages: ch.base_urls?.messages ?? '',
},
api_key: '',
weight: ch.weight, priority: ch.priority, timeout_ms: ch.timeout_ms, weight: ch.weight, priority: ch.priority, timeout_ms: ch.timeout_ms,
max_concurrency: ch.max_concurrency, enabled: ch.enabled, max_concurrency: ch.max_concurrency, enabled: ch.enabled,
}) })
@@ -62,6 +74,10 @@ function openEdit(ch: Channel) {
} }
async function save() { async function save() {
if (form.formats.length === 0) {
toast.err('请至少选择一种 API 格式')
return
}
saving.value = true saving.value = true
const payload = { const payload = {
...form, ...form,
@@ -111,38 +127,76 @@ async function testChannel(ch: Channel) {
} }
} }
async function importModels(ch: Channel) {
busyId.value = ch.id
try {
const { data } = await http.post(`/admin/channels/${ch.id}/models/import`)
toast.ok(`已导入 ${data.data.imported} 个模型`)
} catch (e) {
toast.err(errMsg(e))
} finally {
busyId.value = null
}
}
onMounted(load) onMounted(load)
</script> </script>
<template> <template>
<div class="mx-auto max-w-6xl"> <div class="mx-auto max-w-6xl">
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<div> <div>
<h1 class="text-lg font-semibold">渠道</h1> <h1 class="text-lg font-semibold">渠道</h1>
<p class="text-sm text-zinc-500">接入上游服务,API Key 加密存储</p> <p class="text-sm text-muted">接入上游服务,API Key 加密存储</p>
</div> </div>
<Button @click="openCreate">添加渠道</Button> <Button class="shrink-0" @click="openCreate">添加渠道</Button>
</div> </div>
<div class="card"> <!-- 移动端:卡片列表 -->
<div class="space-y-3 md:hidden">
<div v-for="ch in channels" :key="ch.id" class="card p-4" :class="ch.enabled ? 'bg-accent-soft' : ''">
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="min-w-0">
<p class="text-sm font-medium text-ink">{{ ch.name }}</p>
<div class="mt-1.5 flex flex-wrap gap-1">
<code
v-for="f in ch.formats || []"
:key="f"
class="rounded bg-surface2 px-1.5 py-0.5 font-mono text-[10px] text-muted"
>{{ protocolShort(f) }}</code>
</div>
</div>
<div class="flex shrink-0 gap-1.5">
<Badge :variant="ch.health_status === 'healthy' ? 'ok' : ch.health_status === 'cooldown' ? 'err' : 'warn'">
{{ ch.health_status }}
</Badge>
<Badge :variant="ch.enabled ? 'ok' : 'neutral'">{{ ch.enabled ? '启用' : '停用' }}</Badge>
</div>
</div>
<p class="mt-2 truncate font-mono text-[11px] text-muted">{{ ch.base_url }}</p>
<div class="mt-3 flex flex-wrap gap-x-3 gap-y-1.5 border-t border-edge pt-3">
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-accent" :disabled="busyId === ch.id" @click="testChannel(ch)">
<PhPulse :size="13" />
{{ busyId === ch.id ? '测试中…' : '测试' }}
</button>
<button class="inline-flex items-center gap-1 text-xs text-accent hover:text-accent-strong" @click="toggleDrawer(ch)">
<PhStack :size="13" />
支持的模型 {{ expandedId === ch.id ? '▴' : '▾' }}
</button>
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-ink" @click="openEdit(ch)">
<PhNotePencil :size="13" />
编辑
</button>
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-err" @click="remove(ch)">
<PhTrash :size="13" />
删除
</button>
</div>
<div v-if="expandedId === ch.id" class="mt-3 border-t border-edge pt-3">
<ChannelModelsDrawer :channel="ch" />
</div>
</div>
<p v-if="channels.length === 0" class="card px-4 py-10 text-center text-sm text-muted">
还没有渠道,点击「添加渠道」
</p>
</div>
<!-- 桌面端:表格 -->
<div class="card hidden md:block">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm min-w-[820px]">
<thead> <thead>
<tr class="border-b border-zinc-800 text-left text-xs text-zinc-500"> <tr class="border-b border-edge text-left text-xs text-muted">
<th scope="col" class="px-4 py-2.5 font-medium">名称</th> <th scope="col" class="px-4 py-2.5 font-medium">名称</th>
<th scope="col" class="px-4 py-2.5 font-medium">类型</th> <th scope="col" class="px-4 py-2.5 font-medium">API 格式</th>
<th scope="col" class="px-4 py-2.5 font-medium">Base URL</th> <th scope="col" class="px-4 py-2.5 font-medium">Base URL</th>
<th scope="col" class="px-4 py-2.5 font-medium">Key</th> <th scope="col" class="px-4 py-2.5 font-medium">Key</th>
<th scope="col" class="px-4 py-2.5 font-medium">健康</th> <th scope="col" class="px-4 py-2.5 font-medium">健康</th>
@@ -151,30 +205,56 @@ onMounted(load)
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="ch in channels" :key="ch.id" class="table-row"> <template v-for="ch in channels" :key="ch.id">
<td class="px-4 py-2.5 text-zinc-200">{{ ch.name }}</td> <tr class="table-row" :style="ch.enabled ? { borderLeft: '2px solid var(--color-accent)' } : {}">
<td class="px-4 py-2.5 font-mono text-xs text-zinc-400">{{ providerMap[ch.provider] }}</td> <td class="px-4 py-2.5">
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ ch.base_url }}</td> <button class="inline-flex items-center gap-1.5 text-ink transition hover:text-accent" @click="toggleDrawer(ch)">
<td class="px-4 py-2.5 font-mono text-xs text-zinc-600">{{ ch.api_key_masked || '****' }}</td> <span class="truncate">{{ ch.name }}</span>
<PhCaretDown :size="12" class="shrink-0 text-muted transition-transform" :class="expandedId === ch.id ? 'rotate-180' : ''" />
</button>
</td>
<td class="px-4 py-2.5">
<div class="flex flex-col gap-0.5">
<code
v-for="f in ch.formats || []"
:key="f"
class="font-mono text-[11px] leading-4 text-muted"
>{{ protocolShort(f) }}</code>
</div>
</td>
<td class="max-w-[220px] truncate px-4 py-2.5 font-mono text-xs text-muted">{{ ch.base_url }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ ch.api_key_masked || '****' }}</td>
<td class="px-4 py-2.5"> <td class="px-4 py-2.5">
<Badge :variant="ch.health_status === 'healthy' ? 'ok' : ch.health_status === 'cooldown' ? 'err' : 'warn'"> <Badge :variant="ch.health_status === 'healthy' ? 'ok' : ch.health_status === 'cooldown' ? 'err' : 'warn'">
{{ ch.health_status }} {{ ch.health_status }}
</Badge> </Badge>
</td> </td>
<td class="px-4 py-2.5 text-xs text-zinc-400">{{ ch.enabled ? '是' : '否' }}</td> <td class="px-4 py-2.5 text-xs text-muted">{{ ch.enabled ? '是' : '否' }}</td>
<td class="px-4 py-2.5 text-right"> <td class="px-4 py-2.5 text-right">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button class="text-xs text-zinc-500 hover:text-accent" :disabled="busyId === ch.id" @click="testChannel(ch)"> <button class="inline-flex items-center gap-1 text-xs text-muted hover:text-accent" :disabled="busyId === ch.id" @click="testChannel(ch)">
<PhPulse :size="13" />
{{ busyId === ch.id ? '测试中…' : '测试' }} {{ busyId === ch.id ? '测试中…' : '测试' }}
</button> </button>
<button class="text-xs text-zinc-500 hover:text-accent" @click="importModels(ch)">导入模型</button> <button class="inline-flex items-center gap-1 text-xs text-muted hover:text-ink" @click="openEdit(ch)">
<button class="text-xs text-zinc-500 hover:text-zinc-200" @click="openEdit(ch)">编辑</button> <PhNotePencil :size="13" />
<button class="text-xs text-zinc-500 hover:text-red-400" @click="remove(ch)">删除</button> 编辑
</button>
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-err" @click="remove(ch)">
<PhTrash :size="13" />
删除
</button>
</div> </div>
</td> </td>
</tr> </tr>
<tr v-if="expandedId === ch.id" class="bg-surface/40">
<td colspan="7" class="px-4 py-3">
<ChannelModelsDrawer :channel="ch" />
</td>
</tr>
</template>
<tr v-if="channels.length === 0"> <tr v-if="channels.length === 0">
<td colspan="7" class="px-4 py-10 text-center text-sm text-zinc-600">还没有渠道,点击「添加渠道」</td> <td colspan="7" class="px-4 py-10 text-center text-sm text-muted">还没有渠道,点击「添加渠道」</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -183,29 +263,71 @@ onMounted(load)
<Modal :open="editOpen" :title="editing ? '编辑渠道' : '添加渠道'" @close="editOpen = false"> <Modal :open="editOpen" :title="editing ? '编辑渠道' : '添加渠道'" @close="editOpen = false">
<div class="space-y-4"> <div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<Input v-model="form.name" label="名称" placeholder="openai" /> <Input v-model="form.name" label="名称" placeholder="openai" />
<label class="block"> <div>
<span class="mb-1.5 block text-xs font-medium text-zinc-400">API 类型</span> <span class="mb-1.5 block text-xs font-medium text-muted">支持的 API 格式</span>
<select v-model="form.provider" class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm text-zinc-100 outline-none focus:border-accent"> <div class="flex flex-wrap gap-2">
<option value="openai">OpenAI</option> <label
<option value="anthropic">Anthropic</option> v-for="opt in PROTOCOL_OPTIONS"
<option value="compatible">兼容</option> :key="opt.value"
</select> class="flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition select-none"
:class="form.formats.includes(opt.value) ? 'border-accent bg-accent-soft text-ink' : 'border-edge2 text-muted hover:border-edge'"
>
<input
v-model="form.formats"
type="checkbox"
:value="opt.value"
class="size-3.5 rounded accent-[var(--color-accent)]"
/>
{{ opt.label }}
</label> </label>
</div> </div>
<Input v-model="form.base_url" label="Base URL" placeholder="https://api.openai.com" /> <p class="mt-1.5 text-xs text-muted">客户端协议不在其中时,网关自动转换为其支持的格式</p>
</div>
<Input
v-model="form.base_url"
label="Base URL(可选)"
placeholder="https://api.openai.com/v1"
:maxlength="255"
hint="支持前缀或完整端点,如 https://api.openai.com/v1 或 https://api.openai.com/v1/chat/completions;留空按供应商默认"
/>
<div class="space-y-3 rounded-md border border-edge p-3">
<p class="text-xs font-medium text-muted">分协议 Base URL(可选,如智谱三种格式不同)</p>
<Input v-model="form.base_urls.chat" label="OpenAI Chat Completions" placeholder="留空用主 Base URL" :maxlength="255" />
<Input v-model="form.base_urls.responses" label="OpenAI Responses" placeholder="留空用主 Base URL" :maxlength="255" />
<Input v-model="form.base_urls.messages" label="Anthropic Messages" placeholder="留空用主 Base URL" :maxlength="255" />
<p class="text-xs text-muted">网关按协议选对应 base_url 直通,无需为每种格式建多个渠道</p>
</div>
<Input <Input
v-model="form.api_key" v-model="form.api_key"
label="上游 API Key" label="上游 API Key"
:placeholder="editing ? '留空则不修改' : 'sk-...'" :placeholder="editing ? '留空则不修改' : 'sk-...'"
/> />
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input v-model="form.weight" label="权重" type="number" /> <Input v-model="form.weight" label="权重" type="number" />
<Input v-model="form.priority" label="优先级" type="number" /> <Input v-model="form.priority" label="优先级" type="number" />
<Input v-model="form.timeout_ms" label="超时 (ms)" type="number" /> <Input v-model="form.timeout_ms" label="超时 (ms)" type="number" />
<Input v-model="form.max_concurrency" label="最大并发" type="number" /> <Input v-model="form.max_concurrency" label="最大并发" type="number" />
</div> </div>
<div class="flex items-center justify-between rounded-md border border-edge p-3">
<div>
<p class="text-sm font-medium text-ink">启用渠道</p>
<p class="text-xs text-muted">禁用后该渠道不会被用于请求转发</p>
</div>
<button
type="button"
role="switch"
:aria-checked="form.enabled"
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
:class="form.enabled ? 'bg-accent' : 'bg-surface2'"
@click="form.enabled = !form.enabled"
>
<span
class="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-0 transition-transform"
:class="form.enabled ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
</div> </div>
<template #footer> <template #footer>
<Button variant="ghost" @click="editOpen = false">取消</Button> <Button variant="ghost" @click="editOpen = false">取消</Button>
+92 -24
View File
@@ -9,12 +9,31 @@ const config = reactive<Record<string, string>>({})
const loading = ref(false) const loading = ref(false)
const saving = ref(false) const saving = ref(false)
const availableModels = ref<string[]>([])
const allowList = ref<string[]>([])
const denyList = ref<string[]>([])
function parseConfigList(v: unknown): string[] {
if (!v) return []
if (Array.isArray(v)) return v.map(String)
try {
const a = JSON.parse(String(v))
return Array.isArray(a) ? a.map(String) : []
} catch {
return []
}
}
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
const { data } = await http.get('/admin/config') const { data } = await http.get('/admin/config')
Object.keys(config).forEach((k) => delete config[k]) Object.keys(config).forEach((k) => delete config[k])
Object.assign(config, data.data.config) Object.assign(config, data.data.config)
allowList.value = parseConfigList(config.model_allowlist)
denyList.value = parseConfigList(config.model_denylist)
delete config.model_allowlist
delete config.model_denylist
} catch (e) { } catch (e) {
toast.err(errMsg(e)) toast.err(errMsg(e))
} finally { } finally {
@@ -22,10 +41,22 @@ async function load() {
} }
} }
async function loadModels() {
try {
const { data } = await http.get('/admin/models')
availableModels.value = (data.data.items as { name: string }[]).map((m) => m.name)
} catch {
/* 忽略 */
}
}
async function save() { async function save() {
saving.value = true saving.value = true
try { try {
await http.put('/admin/config', config) const payload: Record<string, unknown> = { ...config }
payload.model_allowlist = allowList.value
payload.model_denylist = denyList.value
await http.put('/admin/config', payload)
toast.ok('配置已保存') toast.ok('配置已保存')
} catch (e) { } catch (e) {
toast.err(errMsg(e)) toast.err(errMsg(e))
@@ -34,63 +65,100 @@ async function save() {
} }
} }
onMounted(load) onMounted(() => {
load()
loadModels()
})
</script> </script>
<template> <template>
<div class="mx-auto max-w-2xl"> <div class="mx-auto max-w-2xl space-y-6">
<div class="mb-6"> <div class="mb-2">
<h1 class="text-lg font-semibold">系统配置</h1> <h1 class="text-lg font-semibold">系统配置</h1>
<p class="text-sm text-zinc-500">注册策略等平台级配置</p> <p class="text-sm text-muted">注册策略与模型访问限制</p>
</div>
<div class="card p-6">
<div class="mb-5">
<h2 class="text-sm font-semibold">模型限制</h2>
<p class="mt-1 text-xs text-muted">
针对全部用户开放/禁止模型;用户级限制优先级更高(用户管理里可单独配置)。
</p>
</div>
<div class="space-y-5">
<div>
<p class="mb-2 text-xs font-medium text-muted">允许的模型(留空 = 全部开放)</p>
<div class="flex max-h-40 flex-wrap gap-2 overflow-y-auto">
<label
v-for="m in availableModels"
:key="m"
class="flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition select-none"
:class="allowList.includes(m) ? 'border-accent bg-accent-soft text-ink' : 'border-edge2 text-muted hover:border-edge'"
>
<input v-model="allowList" type="checkbox" :value="m" class="size-3.5 rounded accent-[var(--color-accent)]" />
{{ m }}
</label>
<p v-if="availableModels.length === 0" class="text-xs text-muted">暂无模型,请先在模型定价中添加</p>
</div>
</div>
<div>
<p class="mb-2 text-xs font-medium text-muted">禁止的模型(黑名单优先)</p>
<div class="flex max-h-40 flex-wrap gap-2 overflow-y-auto">
<label
v-for="m in availableModels"
:key="m"
class="flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition select-none"
:class="denyList.includes(m) ? 'border-err bg-err-soft text-err' : 'border-edge2 text-muted hover:border-edge'"
>
<input v-model="denyList" type="checkbox" :value="m" class="size-3.5 rounded accent-[var(--color-err)]" />
{{ m }}
</label>
</div>
</div>
</div>
</div> </div>
<div class="card space-y-5 p-6"> <div class="card space-y-5 p-6">
<div v-if="loading" class="py-8 text-center text-sm text-zinc-600">加载中…</div> <div v-if="loading" class="py-8 text-center text-sm text-muted">加载中…</div>
<template v-else> <template v-else>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<p class="mb-1.5 text-xs font-medium text-zinc-400">注册模式</p> <p class="mb-1.5 text-xs font-medium text-muted">注册模式</p>
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
class="flex-1 rounded-md border px-3 py-2 text-sm transition" class="flex-1 rounded-md border px-3 py-2 text-sm transition"
:class="config.registration_mode === 'open' ? 'border-accent bg-accent/10 text-emerald-300' : 'border-zinc-700 text-zinc-400'" :class="config.registration_mode === 'open' ? 'border-accent bg-accent-soft text-accent' : 'border-edge2 text-muted'"
@click="config.registration_mode = 'open'" @click="config.registration_mode = 'open'"
> >
开放注册 开放注册
</button> </button>
<button <button
class="flex-1 rounded-md border px-3 py-2 text-sm transition" class="flex-1 rounded-md border px-3 py-2 text-sm transition"
:class="config.registration_mode === 'invite' ? 'border-accent bg-accent/10 text-emerald-300' : 'border-zinc-700 text-zinc-400'" :class="config.registration_mode === 'invite' ? 'border-accent bg-accent-soft text-accent' : 'border-edge2 text-muted'"
@click="config.registration_mode = 'invite'" @click="config.registration_mode = 'invite'"
> >
邀请码 邀请码
</button> </button>
</div> </div>
<p class="mt-1.5 text-xs text-zinc-600">邀请模式下注册需填写有效邀请码(invite_codes 配置)</p> <p class="mt-1.5 text-xs text-muted">邀请模式下注册需填写有效邀请码(invite_codes 配置)</p>
</div> </div>
<div> <div>
<p class="mb-1.5 text-xs font-medium text-zinc-400">邀请码(逗号分隔)</p> <p class="mb-1.5 text-xs font-medium text-muted">邀请码(逗号分隔)</p>
<input <input
v-model="config.invite_codes" v-model="config.invite_codes"
placeholder="code1,code2" placeholder="code1,code2"
class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 font-mono text-xs outline-none focus:border-accent" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 font-mono text-xs outline-none focus:border-accent"
/> />
</div> </div>
<div class="flex items-center justify-between rounded-md border border-zinc-800 bg-zinc-900/60 px-4 py-3">
<div>
<p class="text-sm text-zinc-300">其他配置项</p>
<p class="text-xs text-zinc-600">汇率、限流阈值、维护开关在后续里程碑开放</p>
</div>
<span class="font-mono text-xs text-zinc-600">M3+</span>
</div>
</div>
<div class="flex justify-end">
<Button :loading="saving" @click="save">保存</Button>
</div> </div>
</template> </template>
</div> </div>
<div class="flex justify-end">
<Button :loading="saving" @click="save">保存配置</Button>
</div>
</div> </div>
</template> </template>
+90 -85
View File
@@ -1,27 +1,51 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { http, errMsg } from '@/api/client' import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import Button from '@/components/ui/Button.vue' import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue' import Input from '@/components/ui/Input.vue'
import Modal from '@/components/ui/Modal.vue' import Modal from '@/components/ui/Modal.vue'
import Badge from '@/components/ui/Badge.vue' import Badge from '@/components/ui/Badge.vue'
import type { Channel, Model } from '@/types' import type { Model, ModelSummary } from '@/types'
const toast = useToastStore() const toast = useToastStore()
const models = ref<Model[]>([]) const models = ref<Model[]>([])
const channels = ref<Channel[]>([]) const summary = ref<ModelSummary>({ total: 0, unpriced: 0, missing: [], denied_count: 0 })
const editOpen = ref(false) const editOpen = ref(false)
const editing = ref<Model | null>(null) const editing = ref<Model | null>(null)
const saving = ref(false) const saving = ref(false)
const quickName = ref('')
const clearing = ref(false)
const bindOpen = ref(false) // 未绑定任何渠道的模型:渠道选中的后来被取消,客户端无法调用
const bindModel = ref<Model | null>(null) const unused = computed(() => models.value.filter((m) => m.channels.length === 0))
const binding = reactive({ channel_id: 0, upstream_model: '', weight: 1 })
async function clearUnused() {
if (!unused.value.length) {
toast.push('没有未绑定渠道的模型')
return
}
const names = unused.value.map((m) => m.name)
if (!confirm(`确定删除 ${names.length} 个未绑定渠道的模型?\n\n${names.join('\n')}`)) return
clearing.value = true
try {
const { data } = await http.delete('/admin/models/unused')
toast.ok(`已清除 ${data.data.count} 个模型`)
await load()
} catch (e) {
toast.err(errMsg(e))
} finally {
clearing.value = false
}
}
function quickAdd() {
openCreate()
if (quickName.value) form.name = quickName.value.trim()
}
const form = reactive({ const form = reactive({
name: '', name: '',
display_name: '',
input_price: 0, input_price: 0,
output_price: 0, output_price: 0,
cache_read_price: 0, cache_read_price: 0,
@@ -30,9 +54,9 @@ const form = reactive({
async function load() { async function load() {
try { try {
const [m, c] = await Promise.all([http.get('/admin/models'), http.get('/admin/channels')]) const { data } = await http.get('/admin/models')
models.value = m.data.data.items models.value = data.data.items
channels.value = c.data.data.items summary.value = data.data.summary
} catch (e) { } catch (e) {
toast.err(errMsg(e)) toast.err(errMsg(e))
} }
@@ -40,14 +64,14 @@ async function load() {
function openCreate() { function openCreate() {
editing.value = null editing.value = null
Object.assign(form, { name: '', display_name: '', input_price: 0, output_price: 0, cache_read_price: 0, enabled: true }) Object.assign(form, { name: '', input_price: 0, output_price: 0, cache_read_price: 0, enabled: true })
editOpen.value = true editOpen.value = true
} }
function openEdit(m: Model) { function openEdit(m: Model) {
editing.value = m editing.value = m
Object.assign(form, { Object.assign(form, {
name: m.name, display_name: m.display_name, name: m.name,
input_price: m.input_price, output_price: m.output_price, cache_read_price: m.cache_read_price, input_price: m.input_price, output_price: m.output_price, cache_read_price: m.cache_read_price,
enabled: m.enabled, enabled: m.enabled,
}) })
@@ -57,7 +81,6 @@ function openEdit(m: Model) {
async function save() { async function save() {
saving.value = true saving.value = true
const payload = { const payload = {
display_name: form.display_name || form.name,
input_price: Number(form.input_price), input_price: Number(form.input_price),
output_price: Number(form.output_price), output_price: Number(form.output_price),
cache_read_price: Number(form.cache_read_price), cache_read_price: Number(form.cache_read_price),
@@ -91,87 +114,89 @@ async function removeModel(m: Model) {
} }
} }
function openBind(m: Model) {
bindModel.value = m
Object.assign(binding, { channel_id: channels.value[0]?.id ?? 0, upstream_model: m.name, weight: 1 })
bindOpen.value = true
}
async function saveBinding() {
if (!bindModel.value) return
try {
await http.post(`/admin/models/${bindModel.value.id}/bindings`, {
...binding,
weight: Number(binding.weight),
})
toast.ok('绑定已添加')
bindOpen.value = false
await load()
} catch (e) {
toast.err(errMsg(e))
}
}
async function removeBinding(m: Model, bid: number) {
try {
await http.delete(`/admin/models/${m.id}/bindings/${bid}`)
toast.ok('绑定已移除')
await load()
} catch (e) {
toast.err(errMsg(e))
}
}
onMounted(load) onMounted(load)
</script> </script>
<template> <template>
<div class="mx-auto max-w-6xl"> <div class="mx-auto max-w-6xl">
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<div> <div>
<h1 class="text-lg font-semibold">模型与定价</h1> <h1 class="text-lg font-semibold">模型与定价</h1>
<p class="text-sm text-zinc-500">价格按每百万 token (USD),历史用量按当时价格入账</p> <p class="text-sm text-muted">接口导入不全时可直接输入模型名添加,如 glm-4.7-flash</p>
</div> </div>
<Button @click="openCreate">添加模型</Button> <div class="flex w-full flex-wrap gap-2 sm:w-auto sm:flex-nowrap">
<input
v-model="quickName"
placeholder="模型名,如 glm-4.7-flash"
class="h-10 min-w-0 flex-1 rounded-md border border-edge2 bg-surface px-3 font-mono text-xs outline-none focus:border-accent sm:w-52 sm:flex-none"
@keyup.enter="quickAdd"
/>
<Button class="shrink-0" @click="quickAdd">添加模型</Button>
<Button
size="md"
variant="danger"
class="shrink-0 px-1!"
:loading="clearing"
:disabled="!unused.length"
@click="clearUnused"
>
清除悬空{{ unused.length ? `(${unused.length})` : '' }}
</Button>
</div> </div>
</div>
<!-- 提示:定价目录 = 渠道选中的模型 + 手动添加的模型 -->
<div v-if="summary.missing.length" class="card border-err/50 p-4">
<p class="text-sm font-medium text-err">以下渠道选中的模型不在定价目录</p>
<p v-for="(x, i) in summary.missing" :key="i" class="mt-1 font-mono text-xs text-muted">
{{ x.channel }} → {{ x.upstream_model || '模型 #' + x.model_id }}(请到渠道抽屉重新选中,或手动添加)
</p>
</div>
<p v-else-if="summary.unpriced > 0" class="text-xs text-muted">
有 <span class="mono-num text-warn">{{ summary.unpriced }}</span> 个渠道允许的模型未定价,网关将按示例价计费
</p>
<p v-else class="text-xs text-muted">定价目录中渠道允许的模型均已定价</p>
<div class="space-y-3"> <div class="space-y-3">
<div v-for="m in models" :key="m.id" class="card"> <div v-for="m in models" :key="m.id" :class="m.channels.length ? 'card' : 'card border-warn/60 bg-warn-soft'">
<div class="flex items-center justify-between px-4 py-3"> <div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3">
<div class="flex items-center gap-3"> <div class="flex flex-wrap items-center gap-2">
<span class="font-mono text-sm text-zinc-100">{{ m.name }}</span> <span class="font-mono text-sm text-ink">{{ m.name }}</span>
<Badge v-if="m.channels.length" variant="neutral">渠道允许</Badge>
<Badge v-else variant="warn">悬空</Badge>
<Badge :variant="m.enabled ? 'ok' : 'neutral'">{{ m.enabled ? '启用' : '停用' }}</Badge> <Badge :variant="m.enabled ? 'ok' : 'neutral'">{{ m.enabled ? '启用' : '停用' }}</Badge>
<Badge v-if="m.denied" variant="err">已禁止</Badge>
<Badge v-if="m.needs_pricing" variant="warn">未定价</Badge>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<span class="mono-num text-xs text-zinc-400">入 {{ m.input_price }}</span> <span class="mono-num text-xs text-muted">入 {{ m.input_price }}</span>
<span class="mono-num text-xs text-zinc-400">出 {{ m.output_price }}</span> <span class="mono-num text-xs text-muted">出 {{ m.output_price }}</span>
<span class="mono-num text-xs text-zinc-500">缓存读 {{ m.cache_read_price }}</span> <span class="mono-num text-xs text-muted">缓存读 {{ m.cache_read_price }}</span>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button class="text-xs text-zinc-500 hover:text-accent" @click="openBind(m)">绑定渠道</button> <button class="text-xs text-muted hover:text-ink" @click="openEdit(m)">编辑</button>
<button class="text-xs text-zinc-500 hover:text-zinc-200" @click="openEdit(m)">编辑</button> <button class="text-xs text-muted hover:text-err" @click="removeModel(m)">删除</button>
<button class="text-xs text-zinc-500 hover:text-red-400" @click="removeModel(m)">删除</button>
</div> </div>
</div> </div>
<div v-if="m.channels.length" class="border-t border-zinc-800/70 px-4 py-2"> <div v-if="m.channels.length" class="border-t border-edge px-4 py-2">
<p class="mb-1.5 text-[11px] font-medium text-muted">允许渠道(渠道抽屉中管理)</p>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<span <span
v-for="b in m.channels" v-for="b in m.channels"
:key="b.id" :key="b.id"
class="inline-flex items-center gap-1.5 rounded-md border border-zinc-800 bg-zinc-900 px-2 py-0.5 font-mono text-[11px] text-zinc-400" class="inline-flex items-center rounded-md border border-edge bg-surface px-2 py-0.5 font-mono text-[11px] text-muted"
> >
{{ b.channel_name }} → {{ b.upstream_model }} {{ b.channel_name }} → {{ b.upstream_model }}
<button class="text-zinc-400 hover:text-red-400" aria-label="移除绑定" @click="removeBinding(m, b.id)">×</button>
</span> </span>
</div> </div>
</div> </div>
<p v-else class="border-t border-zinc-800/70 px-4 py-2 text-xs text-zinc-600"> <p v-else class="border-t border-edge px-4 py-2 text-xs text-warn">
未绑定渠道,客户端无法调用该模型 悬空模型:无任何渠道提供,客户端无法调用
</p> </p>
</div> </div>
<p v-if="models.length === 0" class="card px-4 py-10 text-center text-sm text-zinc-600"> <p v-if="models.length === 0" class="card px-4 py-10 text-center text-sm text-muted">
还没有模型,点击「添加模型」或到渠道页「导入模型」 还没有模型,点击「添加模型」或到渠道页「导入模型」
</p> </p>
</div> </div>
@@ -180,8 +205,7 @@ onMounted(load)
<Modal :open="editOpen" :title="editing ? '编辑模型' : '添加模型'" @close="editOpen = false"> <Modal :open="editOpen" :title="editing ? '编辑模型' : '添加模型'" @close="editOpen = false">
<div class="space-y-4"> <div class="space-y-4">
<Input v-model="form.name" label="模型名" placeholder="claude-sonnet-5" :disabled="!!editing" /> <Input v-model="form.name" label="模型名" placeholder="claude-sonnet-5" :disabled="!!editing" />
<Input v-model="form.display_name" label="展示名" /> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="grid grid-cols-2 gap-4">
<Input v-model="form.input_price" label="输入价格 /1M" type="number" /> <Input v-model="form.input_price" label="输入价格 /1M" type="number" />
<Input v-model="form.output_price" label="输出价格 /1M" type="number" /> <Input v-model="form.output_price" label="输出价格 /1M" type="number" />
<Input v-model="form.cache_read_price" label="缓存读价格 /1M" type="number" /> <Input v-model="form.cache_read_price" label="缓存读价格 /1M" type="number" />
@@ -192,24 +216,5 @@ onMounted(load)
<Button :loading="saving" @click="save">{{ editing ? '保存' : '创建' }}</Button> <Button :loading="saving" @click="save">{{ editing ? '保存' : '创建' }}</Button>
</template> </template>
</Modal> </Modal>
<!-- 绑定渠道 -->
<Modal :open="bindOpen" title="绑定渠道" @close="bindOpen = false">
<div class="space-y-4">
<p class="text-xs text-zinc-500">模型 <span class="font-mono text-emerald-300">{{ bindModel?.name }}</span> 通过以下渠道提供</p>
<label class="block">
<span class="mb-1.5 block text-xs font-medium text-zinc-400">渠道</span>
<select v-model="binding.channel_id" class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm text-zinc-100 outline-none focus:border-accent">
<option v-for="ch in channels" :key="ch.id" :value="ch.id">{{ ch.name }}</option>
</select>
</label>
<Input v-model="binding.upstream_model" label="上游模型名" placeholder="与渠道侧一致" />
<Input v-model="binding.weight" label="权重" type="number" />
</div>
<template #footer>
<Button variant="ghost" @click="bindOpen = false">取消</Button>
<Button @click="saveBinding">绑定</Button>
</template>
</Modal>
</div> </div>
</template> </template>
+28 -18
View File
@@ -4,10 +4,12 @@ import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import { fmtNum, fmtCost, fmtTime } from '@/lib/format' import { fmtNum, fmtCost, fmtTime } from '@/lib/format'
import Badge from '@/components/ui/Badge.vue' import Badge from '@/components/ui/Badge.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import TrendChart from '@/components/ui/TrendChart.vue' import TrendChart from '@/components/ui/TrendChart.vue'
import type { UsageLog } from '@/types' import type { UsageLog } from '@/types'
const toast = useToastStore() const toast = useToastStore()
const loading = ref(true)
const data = ref({ const data = ref({
total_users: 0, total_keys: 0, total_channels: 0, total_models: 0, total_users: 0, total_keys: 0, total_channels: 0, total_models: 0,
today: { requests: 0, cost: 0, tokens: 0 }, today: { requests: 0, cost: 0, tokens: 0 },
@@ -23,6 +25,8 @@ async function load() {
logs.value = u.data.data.items logs.value = u.data.data.items
} catch (e) { } catch (e) {
toast.err(errMsg(e)) toast.err(errMsg(e))
} finally {
loading.value = false
} }
} }
@@ -33,25 +37,29 @@ onMounted(load)
<div class="mx-auto max-w-6xl space-y-6"> <div class="mx-auto max-w-6xl space-y-6">
<div class="mb-2"> <div class="mb-2">
<h1 class="text-lg font-semibold">运营总览</h1> <h1 class="text-lg font-semibold">运营总览</h1>
<p class="text-sm text-zinc-500">全局用户、渠道与营收</p> <p class="text-sm text-muted">全局用户、渠道与营收</p>
</div> </div>
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4"> <div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div class="card p-4"> <div class="card p-4">
<p class="text-xs text-zinc-500">用户 / 密钥</p> <p class="text-xs text-muted">用户 / 密钥</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(data.total_users) }} / {{ fmtNum(data.total_keys) }}</p> <Skeleton v-if="loading" class="mt-2" height="1.5rem" width="55%" />
<p v-else class="mono-num mt-1 text-xl text-ink">{{ fmtNum(data.total_users) }} / {{ fmtNum(data.total_keys) }}</p>
</div> </div>
<div class="card p-4"> <div class="card p-4">
<p class="text-xs text-zinc-500">渠道 / 模型</p> <p class="text-xs text-muted">渠道 / 模型</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(data.total_channels) }} / {{ fmtNum(data.total_models) }}</p> <Skeleton v-if="loading" class="mt-2" height="1.5rem" width="45%" />
<p v-else class="mono-num mt-1 text-xl text-ink">{{ fmtNum(data.total_channels) }} / {{ fmtNum(data.total_models) }}</p>
</div> </div>
<div class="card p-4"> <div class="card p-4">
<p class="text-xs text-zinc-500">今日请求</p> <p class="text-xs text-muted">今日请求</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(data.today.requests) }}</p> <Skeleton v-if="loading" class="mt-2" height="1.5rem" width="40%" />
<p v-else class="mono-num mt-1 text-xl text-ink">{{ fmtNum(data.today.requests) }}</p>
</div> </div>
<div class="card p-4"> <div class="card p-4">
<p class="text-xs text-zinc-500">本月营收</p> <p class="text-xs text-muted">本月营收</p>
<p class="mono-num mt-1 text-xl text-emerald-300">{{ fmtCost(data.month.cost) }}</p> <Skeleton v-if="loading" class="mt-2" height="1.5rem" width="45%" />
<p v-else class="mono-num mt-1 text-xl text-accent">{{ fmtCost(data.month.cost) }}</p>
</div> </div>
</div> </div>
@@ -59,9 +67,11 @@ onMounted(load)
<div class="card p-5 lg:col-span-3"> <div class="card p-5 lg:col-span-3">
<div class="mb-4 flex items-baseline justify-between"> <div class="mb-4 flex items-baseline justify-between">
<h2 class="text-sm font-semibold">近 14 天全局成本</h2> <h2 class="text-sm font-semibold">近 14 天全局成本</h2>
<span class="font-mono text-[11px] text-zinc-600">{{ fmtCost(data.month.cost) }} / 本月</span> <span class="font-mono text-[11px] text-muted">{{ fmtCost(data.month.cost) }} / 本月</span>
</div> </div>
<Skeleton v-if="loading" height="160px" />
<TrendChart <TrendChart
v-else
:points="data.trend_14d.map((t) => ({ label: t.date.slice(5), value: t.cost }))" :points="data.trend_14d.map((t) => ({ label: t.date.slice(5), value: t.cost }))"
:format="(v) => '$' + v.toExponential(2)" :format="(v) => '$' + v.toExponential(2)"
/> />
@@ -70,22 +80,22 @@ onMounted(load)
<div class="card p-5 lg:col-span-2"> <div class="card p-5 lg:col-span-2">
<div class="mb-3 flex items-baseline justify-between"> <div class="mb-3 flex items-baseline justify-between">
<h2 class="text-sm font-semibold">全局最近请求</h2> <h2 class="text-sm font-semibold">全局最近请求</h2>
<span class="font-mono text-[11px] text-zinc-600">共 {{ data.month.requests }} / 月</span> <span class="font-mono text-[11px] text-muted">共 {{ data.month.requests }} / 月</span>
</div> </div>
<ul class="divide-y divide-zinc-800/70"> <ul class="divide-y divide-edge">
<li v-for="l in logs" :key="l.id" class="flex items-center justify-between py-2"> <li v-for="l in logs" :key="l.id" class="flex items-center justify-between py-2">
<div class="min-w-0"> <div class="min-w-0">
<p class="truncate text-xs text-zinc-300"> <p class="truncate text-xs text-ink">
<span class="font-mono text-emerald-400/80">{{ l.user }}</span> · {{ l.model }} <span class="font-mono text-accent">{{ l.user }}</span> · {{ l.model }}
</p> </p>
<p class="font-mono text-[11px] text-zinc-600">{{ fmtTime(l.created_at) }}</p> <p class="font-mono text-[11px] text-muted">{{ fmtTime(l.created_at) }}</p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="mono-num text-xs text-zinc-500">{{ fmtCost(l.cost) }}</span> <span class="mono-num text-xs text-muted">{{ fmtCost(l.cost) }}</span>
<Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge> <Badge :variant="l.status === 'success' ? 'ok' : l.status === 'canceled' ? 'neutral' : 'err'">{{ l.status }}</Badge>
</div> </div>
</li> </li>
<li v-if="logs.length === 0" class="py-6 text-center text-xs text-zinc-600">暂无请求</li> <li v-if="logs.length === 0" class="py-6 text-center text-xs text-muted">暂无请求</li>
</ul> </ul>
</div> </div>
</div> </div>
+250
View File
@@ -0,0 +1,250 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
import { protocolShort } from '@/lib/protocol'
import { fmtCost, fmtTime } from '@/lib/format'
import Badge from '@/components/ui/Badge.vue'
import Button from '@/components/ui/Button.vue'
import Modal from '@/components/ui/Modal.vue'
import type { UsageLog, User } from '@/types'
const toast = useToastStore()
const logs = ref<UsageLog[]>([])
const total = ref(0)
const page = ref(1)
const modelFilter = ref('')
const userFilter = ref('')
const userCandidates = ref<User[]>([])
let userSearchTimer: ReturnType<typeof setTimeout> | null = null
const pageSize = 15
const viewing = ref<UsageLog | null>(null)
const viewTab = ref<'request' | 'response'>('request')
function openRaw(l: UsageLog) {
viewing.value = l
viewTab.value = 'request'
}
// 用户自动补全候选(防抖 300ms)
function onUserInput() {
if (userSearchTimer) clearTimeout(userSearchTimer)
const kw = userFilter.value.trim()
if (!kw) {
userCandidates.value = []
return
}
userSearchTimer = setTimeout(async () => {
try {
const { data } = await http.get(`/admin/users?q=${encodeURIComponent(kw)}&page_size=10`)
userCandidates.value = data.data.items ?? []
} catch {
userCandidates.value = []
}
}, 300)
}
// 用户名 → user_id(精确匹配),查不到返回 null
async function resolveUserId(username: string): Promise<number | null> {
const { data } = await http.get(`/admin/users?q=${encodeURIComponent(username)}&page_size=10`)
const items: User[] = data.data.items ?? []
const hit = items.find((u) => u.username === username)
return hit ? hit.id : null
}
async function load() {
try {
const params = new URLSearchParams()
params.set('page', String(page.value))
params.set('page_size', String(pageSize))
if (modelFilter.value.trim()) params.set('model', modelFilter.value.trim())
if (userFilter.value.trim()) {
const uid = await resolveUserId(userFilter.value.trim())
if (uid === null) {
toast.err(`未找到用户:${userFilter.value.trim()}`)
return
}
params.set('user_id', String(uid))
}
const { data } = await http.get(`/admin/usage?${params.toString()}`)
logs.value = data.data.items
total.value = data.data.total
} catch (e) {
toast.err(errMsg(e))
}
}
function search() {
page.value = 1
load()
}
function clearUser() {
userFilter.value = ''
userCandidates.value = []
search()
}
function goPage(p: number) {
page.value = p
load()
}
onMounted(load)
</script>
<template>
<div class="mx-auto max-w-6xl">
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-lg font-semibold">用量统计</h1>
<p class="text-sm text-muted">全局请求明细与成本</p>
</div>
<div class="flex flex-wrap gap-2">
<div class="relative">
<input
v-model="userFilter"
list="user-options"
placeholder="按用户过滤"
class="h-10 w-44 rounded-md border border-edge2 bg-surface px-3 font-mono text-xs outline-none focus:border-accent"
@input="onUserInput"
@keyup.enter="search"
/>
<button
v-if="userFilter"
class="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-muted hover:text-ink"
title="清除用户筛选"
@click="clearUser"
>
✕
</button>
</div>
<datalist id="user-options">
<option v-for="u in userCandidates" :key="u.id" :value="u.username" />
</datalist>
<input
v-model="modelFilter"
placeholder="按模型过滤"
class="h-10 min-w-0 flex-1 rounded-md border border-edge2 bg-surface px-3 font-mono text-xs outline-none focus:border-accent sm:w-48 sm:flex-none"
@keyup.enter="search"
/>
<Button variant="ghost" class="shrink-0" @click="search">搜索</Button>
</div>
</div>
<div class="card">
<!-- 移动端:卡片列表 -->
<div class="space-y-3 p-3 md:hidden">
<div v-for="l in logs" :key="l.id" class="card p-3">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate font-mono text-sm text-ink">{{ l.model }}</p>
<p class="mt-0.5 truncate text-xs text-muted">{{ l.channel }} · {{ l.user }}</p>
</div>
<div class="flex shrink-0 flex-col items-end gap-1">
<Badge :variant="l.status === 'success' ? 'ok' : l.status === 'canceled' ? 'neutral' : 'err'">{{ l.status }}</Badge>
<span class="mono-num text-sm font-medium text-ink">{{ fmtCost(l.cost) }}</span>
</div>
</div>
<div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-edge pt-2 text-xs text-muted">
<span class="font-mono">{{ protocolShort(l.protocol) }}</span>
<span class="mono-num">{{ l.input_tokens }}/{{ l.output_tokens }} tok</span>
<span class="mono-num">{{ l.latency_ms }}ms</span>
<span class="mono-num w-full">{{ fmtTime(l.created_at) }}</span>
<Button v-if="l.raw_request" size="sm" variant="ghost" class="ml-auto" @click="openRaw(l)">查看原始</Button>
</div>
</div>
<p v-if="logs.length === 0" class="card px-4 py-8 text-center text-sm text-muted">暂无请求记录</p>
</div>
<!-- 桌面端:表格 -->
<div class="hidden overflow-x-auto md:block">
<table class="w-full text-sm min-w-[760px]">
<thead>
<tr class="border-b border-edge text-left text-xs text-muted">
<th scope="col" class="px-4 py-2.5 font-medium">用户</th>
<th scope="col" class="px-4 py-2.5 font-medium">模型</th>
<th scope="col" class="px-4 py-2.5 font-medium">渠道</th>
<th scope="col" class="px-4 py-2.5 font-medium">API 格式</th>
<th scope="col" class="px-4 py-2.5 font-medium">Token 入/出</th>
<th scope="col" class="px-4 py-2.5 font-medium">成本</th>
<th scope="col" class="px-4 py-2.5 font-medium">耗时</th>
<th scope="col" class="px-4 py-2.5 font-medium">状态</th>
<th scope="col" class="px-4 py-2.5 font-medium">时间</th>
<th scope="col" class="px-4 py-2.5 font-medium">原始</th>
</tr>
</thead>
<tbody>
<tr v-for="l in logs" :key="l.id" class="table-row">
<td class="px-4 py-2.5 text-xs text-ink">{{ l.user }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-ink">{{ l.model }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ l.channel || '-' }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ protocolShort(l.protocol) }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.input_tokens }}/{{ l.output_tokens }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-ink">{{ fmtCost(l.cost) }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.latency_ms }}ms</td>
<td class="px-4 py-2.5">
<Badge :variant="l.status === 'success' ? 'ok' : l.status === 'canceled' ? 'neutral' : 'err'">{{ l.status }}</Badge>
</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(l.created_at) }}</td>
<td class="px-4 py-2.5">
<Button v-if="l.raw_request" size="sm" variant="ghost" @click="openRaw(l)">查看</Button>
<span v-else class="text-xs text-muted">-</span>
</td>
</tr>
<tr v-if="logs.length === 0">
<td colspan="10" class="px-4 py-10 text-center text-sm text-muted">暂无请求记录</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between border-t border-edge px-4 py-3">
<span class="font-mono text-xs text-muted">共 {{ total }} 条</span>
<div class="flex gap-2">
<Button size="sm" variant="ghost" :disabled="page <= 1" @click="goPage(page - 1)">上一页</Button>
<Button size="sm" variant="ghost" :disabled="page * pageSize >= total" @click="goPage(page + 1)">下一页</Button>
</div>
</div>
</div>
<Modal :open="!!viewing" title="原始请求与响应" width="max-w-3xl" @close="viewing = null">
<div v-if="viewing" class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="text-xs text-muted">
<span class="font-mono text-ink">{{ viewing.model }}</span>
<span class="mx-1.5">·</span>
<span class="font-mono">{{ viewing.channel || '-' }}</span>
<span class="mx-1.5">·</span>
<span class="font-mono">{{ viewing.user || '-' }}</span>
<span class="mx-1.5">·</span>
<span class="mono-num">{{ fmtTime(viewing.created_at) }}</span>
</div>
<div class="flex gap-1 rounded-md border border-edge p-0.5">
<button
class="rounded px-2.5 py-1 text-xs transition"
:class="viewTab === 'request' ? 'bg-surface2 text-ink' : 'text-muted'"
@click="viewTab = 'request'"
>
请求
</button>
<button
class="rounded px-2.5 py-1 text-xs transition"
:class="viewTab === 'response' ? 'bg-surface2 text-ink' : 'text-muted'"
@click="viewTab = 'response'"
>
响应
</button>
</div>
</div>
<pre
v-if="viewTab === 'request' ? viewing.raw_request : viewing.raw_response"
class="max-h-[60vh] overflow-auto rounded-md border border-edge bg-surface p-3 font-mono text-xs leading-relaxed text-ink whitespace-pre-wrap break-all"
>{{ viewTab === 'request' ? viewing.raw_request : viewing.raw_response }}</pre>
<p v-else class="rounded-md border border-edge bg-surface p-4 text-center text-xs text-muted">
该请求未记录{{ viewTab === 'request' ? '原始请求' : '原始响应' }}
</p>
</div>
</Modal>
</div>
</template>
+92 -27
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { PhNotePencil, PhCoins } from '@phosphor-icons/vue'
import { http, errMsg } from '@/api/client' import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
@@ -20,7 +21,20 @@ const pageSize = 15
const editOpen = ref(false) const editOpen = ref(false)
const editing = ref<User | null>(null) const editing = ref<User | null>(null)
const editForm = reactive({ role: 'user', status: 'active' }) const editForm = reactive({
username: '',
email: '',
password: '',
role: 'user',
status: 'active',
allowed_models: '',
denied_models: '',
})
function parseModelList(s: string): string[] | undefined {
const arr = s.split(/[,,\s]+/).map((x) => x.trim()).filter(Boolean)
return arr.length ? arr : undefined
}
const balanceOpen = ref(false) const balanceOpen = ref(false)
const balanceUser = ref<User | null>(null) const balanceUser = ref<User | null>(null)
@@ -43,14 +57,33 @@ function search() {
function openEdit(u: User) { function openEdit(u: User) {
editing.value = u editing.value = u
Object.assign(editForm, { role: u.role, status: u.status }) Object.assign(editForm, {
username: u.username,
email: u.email,
password: '',
role: u.role,
status: u.status,
allowed_models: (u.allowed_models || []).join(', '),
denied_models: (u.denied_models || []).join(', '),
})
editOpen.value = true editOpen.value = true
} }
async function saveEdit() { async function saveEdit() {
if (!editing.value) return if (!editing.value) return
try { try {
await http.patch(`/admin/users/${editing.value.id}`, editForm) const payload: Record<string, unknown> = {
username: editForm.username,
email: editForm.email,
role: editForm.role,
status: editForm.status,
}
if (editForm.password) payload.password = editForm.password
const allow = parseModelList(editForm.allowed_models)
const deny = parseModelList(editForm.denied_models)
payload.allowed_models = allow ?? []
payload.denied_models = deny ?? []
await http.patch(`/admin/users/${editing.value.id}`, payload)
toast.ok('已更新') toast.ok('已更新')
editOpen.value = false editOpen.value = false
await load() await load()
@@ -90,27 +123,27 @@ onMounted(load)
<template> <template>
<div class="mx-auto max-w-6xl"> <div class="mx-auto max-w-6xl">
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<div> <div>
<h1 class="text-lg font-semibold">用户</h1> <h1 class="text-lg font-semibold">用户</h1>
<p class="text-sm text-zinc-500">管理角色、状态与余额</p> <p class="text-sm text-muted">管理角色、状态与余额</p>
</div> </div>
<div class="flex gap-2"> <div class="flex w-full gap-2 sm:w-auto">
<input <input
v-model="q" v-model="q"
placeholder="搜索用户名 / 邮箱" placeholder="搜索用户名 / 邮箱"
class="h-10 w-56 rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm outline-none focus:border-accent" class="h-10 min-w-0 flex-1 rounded-md border border-edge2 bg-surface px-3 text-sm outline-none focus:border-accent sm:w-56 sm:flex-none"
@keyup.enter="search" @keyup.enter="search"
/> />
<Button variant="ghost" @click="search">搜索</Button> <Button variant="ghost" class="shrink-0" @click="search">搜索</Button>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm min-w-[720px]">
<thead> <thead>
<tr class="border-b border-zinc-800 text-left text-xs text-zinc-500"> <tr class="border-b border-edge text-left text-xs text-muted">
<th scope="col" class="px-4 py-2.5 font-medium">ID</th> <th scope="col" class="px-4 py-2.5 font-medium">ID</th>
<th scope="col" class="px-4 py-2.5 font-medium">用户名</th> <th scope="col" class="px-4 py-2.5 font-medium">用户名</th>
<th scope="col" class="px-4 py-2.5 font-medium">邮箱</th> <th scope="col" class="px-4 py-2.5 font-medium">邮箱</th>
@@ -123,35 +156,44 @@ onMounted(load)
</thead> </thead>
<tbody> <tbody>
<tr v-for="u in users" :key="u.id" class="table-row"> <tr v-for="u in users" :key="u.id" class="table-row">
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ u.id }}</td> <td class="px-4 py-2.5 font-mono text-xs text-muted">{{ u.id }}</td>
<td class="px-4 py-2.5 text-zinc-200"> <td class="px-4 py-2.5 text-ink">
{{ u.username }} {{ u.username }}
<span v-if="u.id === auth.user?.id" class="text-[11px] text-zinc-600">(我)</span> <span v-if="u.id === auth.user?.id" class="text-[11px] text-muted">(我)</span>
</td> </td>
<td class="px-4 py-2.5 text-xs text-zinc-400">{{ u.email }}</td> <td class="px-4 py-2.5 text-xs text-muted">{{ u.email }}</td>
<td class="px-4 py-2.5"> <td class="px-4 py-2.5">
<Badge :variant="u.role === 'admin' ? 'accent' : 'neutral'">{{ u.role }}</Badge> <Badge :variant="u.role === 'admin' ? 'accent' : 'neutral'">{{ u.role }}</Badge>
</td> </td>
<td class="px-4 py-2.5 mono-num text-xs text-emerald-300/90">{{ fmtMoney(u.balance) }}</td> <td class="px-4 py-2.5 mono-num text-xs text-accent">{{ fmtMoney(u.balance) }}</td>
<td class="px-4 py-2.5"> <td class="px-4 py-2.5">
<Badge :variant="u.status === 'active' ? 'ok' : 'warn'">{{ u.status }}</Badge> <Badge :variant="u.status === 'active' ? 'ok' : 'warn'">{{ u.status }}</Badge>
</td> </td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ fmtTime(u.created_at) }}</td> <td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(u.created_at) }}</td>
<td class="px-4 py-2.5 text-right"> <td class="px-4 py-2.5 text-right">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button class="text-xs text-zinc-500 hover:text-zinc-200" @click="openEdit(u)">编辑</button> <button
<button class="text-xs text-zinc-500 hover:text-accent" @click="openBalance(u)">调余额</button> class="inline-flex items-center gap-1 rounded-md border border-accent/40 bg-accent-soft px-2 py-1 text-xs font-medium text-accent transition hover:border-accent hover:bg-accent/15"
@click="openEdit(u)"
>
<PhNotePencil :size="13" weight="bold" />
编辑
</button>
<button class="inline-flex items-center gap-1 text-xs text-muted transition hover:text-accent" @click="openBalance(u)">
<PhCoins :size="13" />
调余额
</button>
</div> </div>
</td> </td>
</tr> </tr>
<tr v-if="users.length === 0"> <tr v-if="users.length === 0">
<td colspan="8" class="px-4 py-10 text-center text-sm text-zinc-600">无用户</td> <td colspan="8" class="px-4 py-10 text-center text-sm text-muted">无用户</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="flex items-center justify-between border-t border-zinc-800 px-4 py-3"> <div class="flex items-center justify-between border-t border-edge px-4 py-3">
<span class="font-mono text-xs text-zinc-600">共 {{ total }} 人</span> <span class="font-mono text-xs text-muted">共 {{ total }} 人</span>
<div class="flex gap-2"> <div class="flex gap-2">
<Button size="sm" variant="ghost" :disabled="page <= 1" @click="goPage(page - 1)">上一页</Button> <Button size="sm" variant="ghost" :disabled="page <= 1" @click="goPage(page - 1)">上一页</Button>
<Button size="sm" variant="ghost" :disabled="page * pageSize >= total" @click="goPage(page + 1)">下一页</Button> <Button size="sm" variant="ghost" :disabled="page * pageSize >= total" @click="goPage(page + 1)">下一页</Button>
@@ -160,23 +202,46 @@ onMounted(load)
</div> </div>
<!-- 编辑用户 --> <!-- 编辑用户 -->
<Modal :open="editOpen" title="编辑用户" @close="editOpen = false"> <Modal :open="editOpen" :title="`编辑用户 · ${editing?.username}`" @close="editOpen = false">
<div class="space-y-4"> <div class="space-y-4">
<Input v-model="editForm.username" label="用户名" />
<Input v-model="editForm.email" label="邮箱" type="email" />
<Input
v-model="editForm.password"
label="重置密码"
type="password"
autocomplete="new-password"
hint="留空则不修改"
/>
<Input
v-model="editForm.allowed_models"
label="允许的模型"
placeholder="逗号分隔,如 gpt-4o, claude-sonnet-5"
hint="留空不限制;用户级白名单优先于全局"
/>
<Input
v-model="editForm.denied_models"
label="禁止的模型"
placeholder="逗号分隔"
hint="黑名单优先"
/>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<label class="block"> <label class="block">
<span class="mb-1.5 block text-xs font-medium text-zinc-400">角色</span> <span class="mb-1.5 block text-xs font-medium text-muted">角色</span>
<select v-model="editForm.role" class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm outline-none focus:border-accent"> <select v-model="editForm.role" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink outline-none focus:border-accent">
<option value="user">user</option> <option value="user">user</option>
<option value="admin">admin</option> <option value="admin">admin</option>
</select> </select>
</label> </label>
<label class="block"> <label class="block">
<span class="mb-1.5 block text-xs font-medium text-zinc-400">状态</span> <span class="mb-1.5 block text-xs font-medium text-muted">状态</span>
<select v-model="editForm.status" class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm outline-none focus:border-accent"> <select v-model="editForm.status" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink outline-none focus:border-accent">
<option value="active">active</option> <option value="active">active</option>
<option value="disabled">disabled</option> <option value="disabled">disabled</option>
</select> </select>
</label> </label>
</div> </div>
</div>
<template #footer> <template #footer>
<Button variant="ghost" @click="editOpen = false">取消</Button> <Button variant="ghost" @click="editOpen = false">取消</Button>
<Button @click="saveEdit">保存</Button> <Button @click="saveEdit">保存</Button>
@@ -186,7 +251,7 @@ onMounted(load)
<!-- 调整余额 --> <!-- 调整余额 -->
<Modal :open="balanceOpen" :title="`调整余额 · ${balanceUser?.username}`" @close="balanceOpen = false"> <Modal :open="balanceOpen" :title="`调整余额 · ${balanceUser?.username}`" @close="balanceOpen = false">
<div class="space-y-4"> <div class="space-y-4">
<p class="text-xs text-zinc-500">当前余额 {{ fmtMoney(balanceUser?.balance ?? 0) }}</p> <p class="text-xs text-muted">当前余额 {{ fmtMoney(balanceUser?.balance ?? 0) }}</p>
<Input v-model="balanceForm.amount" label="调整金额" type="number" hint="正数增加,负数扣减" /> <Input v-model="balanceForm.amount" label="调整金额" type="number" hint="正数增加,负数扣减" />
<Input v-model="balanceForm.remark" label="备注" placeholder="可选" /> <Input v-model="balanceForm.remark" label="备注" placeholder="可选" />
</div> </div>
-38
View File
@@ -1,38 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import ShellLayout from '@/components/layout/ShellLayout.vue'
const auth = useAuthStore()
const sections = computed(() => {
const s: { title: string; items: { to: string; label: string }[] }[] = [
{
title: '控制台',
items: [
{ to: '/console/dashboard', label: '仪表盘' },
{ to: '/console/keys', label: 'API Keys' },
{ to: '/console/usage', label: '用量' },
],
},
]
if (auth.isAdmin) {
s.push({
title: '管理',
items: [
{ to: '/admin/overview', label: '运营总览' },
{ to: '/admin/channels', label: '渠道' },
{ to: '/admin/models', label: '模型与定价' },
{ to: '/admin/users', label: '用户' },
{ to: '/admin/config', label: '系统配置' },
],
})
}
return s
})
</script>
<template>
<ShellLayout :sections="sections">
<router-view />
</ShellLayout>
</template>
+99 -23
View File
@@ -3,12 +3,16 @@ import { onMounted, ref } from 'vue'
import { http, errMsg } from '@/api/client' import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import { fmtMoney, fmtNum, fmtCost, fmtTime } from '@/lib/format' import { fmtMoney, fmtNum, fmtCost, fmtTime } from '@/lib/format'
import { copyText } from '@/lib/clipboard'
import Badge from '@/components/ui/Badge.vue' import Badge from '@/components/ui/Badge.vue'
import Button from '@/components/ui/Button.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import TrendChart from '@/components/ui/TrendChart.vue' import TrendChart from '@/components/ui/TrendChart.vue'
import type { UsageLog } from '@/types' import type { UsageLog } from '@/types'
const toast = useToastStore() const toast = useToastStore()
const loading = ref(true)
const balance = ref(0) const balance = ref(0)
const today = ref({ requests: 0, tokens: 0, cost: 0 }) const today = ref({ requests: 0, tokens: 0, cost: 0 })
const monthCost = ref(0) const monthCost = ref(0)
@@ -32,6 +36,8 @@ async function load() {
logs.value = l.data.data.items logs.value = l.data.data.items
} catch (e) { } catch (e) {
toast.err(errMsg(e)) toast.err(errMsg(e))
} finally {
loading.value = false
} }
} }
@@ -40,6 +46,24 @@ function daysAgo(n: number): string {
return d.toISOString().slice(0, 10) return d.toISOString().slice(0, 10)
} }
// 快速开始:Base URL 用浏览器当前 host(兼容端点),curl 示例
const baseURL = `${window.location.origin}/v1`
const curlCmd = `curl ${baseURL}/chat/completions \\
-H "Authorization: Bearer sk-ot-xxxx" \\
-H "Content-Type: application/json" \\
-d '{"model":"gpt-test","stream":true,"messages":[{"role":"user","content":"hi"}]}'`
const copied = ref('')
async function copy(text: string, key: string) {
const ok = await copyText(text)
if (ok) {
copied.value = key
setTimeout(() => (copied.value = ''), 2000)
} else {
toast.err('复制失败,请手动复制')
}
}
onMounted(load) onMounted(load)
</script> </script>
@@ -47,26 +71,30 @@ onMounted(load)
<div class="mx-auto max-w-5xl space-y-6"> <div class="mx-auto max-w-5xl space-y-6">
<div class="mb-2"> <div class="mb-2">
<h1 class="text-lg font-semibold">仪表盘</h1> <h1 class="text-lg font-semibold">仪表盘</h1>
<p class="text-sm text-zinc-500">余额、用量与最近请求</p> <p class="text-sm text-muted">余额、用量与最近请求</p>
</div> </div>
<!-- 指标条 --> <!-- 指标条 -->
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4"> <div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div class="card p-4"> <div class="card p-4">
<p class="text-xs text-zinc-500">余额</p> <p class="text-xs text-muted">余额</p>
<p class="mono-num mt-1 text-xl text-emerald-300">{{ fmtMoney(balance) }}</p> <Skeleton v-if="loading" class="mt-2" height="1.5rem" width="55%" />
<p v-else class="mono-num mt-1 text-xl text-accent">{{ fmtMoney(balance) }}</p>
</div> </div>
<div class="card p-4"> <div class="card p-4">
<p class="text-xs text-zinc-500">今日请求</p> <p class="text-xs text-muted">今日请求</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(today.requests) }}</p> <Skeleton v-if="loading" class="mt-2" height="1.5rem" width="40%" />
<p v-else class="mono-num mt-1 text-xl text-ink">{{ fmtNum(today.requests) }}</p>
</div> </div>
<div class="card p-4"> <div class="card p-4">
<p class="text-xs text-zinc-500">今日 Token</p> <p class="text-xs text-muted">今日 Token</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(today.tokens) }}</p> <Skeleton v-if="loading" class="mt-2" height="1.5rem" width="40%" />
<p v-else class="mono-num mt-1 text-xl text-ink">{{ fmtNum(today.tokens) }}</p>
</div> </div>
<div class="card p-4"> <div class="card p-4">
<p class="text-xs text-zinc-500">近 30 日消耗</p> <p class="text-xs text-muted">近 30 日消耗</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtCost(monthCost) }}</p> <Skeleton v-if="loading" class="mt-2" height="1.5rem" width="45%" />
<p v-else class="mono-num mt-1 text-xl text-ink">{{ fmtCost(monthCost) }}</p>
</div> </div>
</div> </div>
@@ -75,9 +103,10 @@ onMounted(load)
<div class="card p-5 lg:col-span-3"> <div class="card p-5 lg:col-span-3">
<div class="mb-4 flex items-baseline justify-between"> <div class="mb-4 flex items-baseline justify-between">
<h2 class="text-sm font-semibold">近 14 天成本</h2> <h2 class="text-sm font-semibold">近 14 天成本</h2>
<span class="font-mono text-[11px] text-zinc-600">{{ models }} 个可用模型</span> <span class="font-mono text-[11px] text-muted">{{ models }} 个可用模型</span>
</div> </div>
<TrendChart :points="trend" :format="(v) => '$' + v.toExponential(2)" /> <Skeleton v-if="loading" height="160px" />
<TrendChart v-else :points="trend" :format="(v) => '$' + v.toExponential(2)" />
</div> </div>
<!-- 最近请求 --> <!-- 最近请求 -->
@@ -86,19 +115,27 @@ onMounted(load)
<h2 class="text-sm font-semibold">最近请求</h2> <h2 class="text-sm font-semibold">最近请求</h2>
<router-link to="/console/usage" class="text-xs text-accent hover:text-accent-strong">查看全部</router-link> <router-link to="/console/usage" class="text-xs text-accent hover:text-accent-strong">查看全部</router-link>
</div> </div>
<ul class="divide-y divide-zinc-800/70"> <ul v-if="loading" class="divide-y divide-edge">
<li v-for="l in logs" :key="l.id" class="flex items-center justify-between py-2"> <li v-for="i in 4" :key="i" class="flex items-center justify-between py-2">
<div class="min-w-0"> <Skeleton height="0.75rem" width="40%" />
<p class="truncate font-mono text-xs text-zinc-300">{{ l.model }}</p> <Skeleton height="0.75rem" width="30%" />
<p class="text-[11px] text-zinc-600">{{ fmtTime(l.created_at) }}</p>
</div>
<div class="flex items-center gap-2">
<span class="mono-num text-xs text-zinc-400">{{ l.input_tokens }}/{{ l.output_tokens }}</span>
<span class="mono-num w-16 text-right text-xs text-zinc-500">{{ fmtCost(l.cost) }}</span>
<Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge>
</div>
</li> </li>
<li v-if="logs.length === 0" class="py-6 text-center text-xs text-zinc-600"> </ul>
<ul v-else class="divide-y divide-edge">
<li v-for="l in logs" :key="l.id" class="py-2">
<div class="flex items-center justify-between gap-2">
<p class="min-w-0 truncate font-mono text-xs text-ink">{{ l.model }}</p>
<Badge class="shrink-0" :variant="l.status === 'success' ? 'ok' : l.status === 'canceled' ? 'neutral' : 'err'">{{ l.status }}</Badge>
</div>
<p class="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-muted">
<span class="mono-num">{{ l.input_tokens }}/{{ l.output_tokens }}</span>
<span class="opacity-40">·</span>
<span class="mono-num">{{ fmtCost(l.cost) }}</span>
<span class="opacity-40">·</span>
<span>{{ fmtTime(l.created_at) }}</span>
</p>
</li>
<li v-if="logs.length === 0" class="py-6 text-center text-xs text-muted">
还没有请求记录,去 还没有请求记录,去
<router-link to="/console/keys" class="text-accent">API Keys</router-link> <router-link to="/console/keys" class="text-accent">API Keys</router-link>
创建密钥开始调用 创建密钥开始调用
@@ -106,5 +143,44 @@ onMounted(load)
</ul> </ul>
</div> </div>
</div> </div>
<!-- 快速开始 -->
<div class="card min-w-0 p-5">
<div class="mb-4">
<h2 class="text-sm font-semibold">快速开始</h2>
<p class="text-xs text-muted">通过兼容端点接入,OpenAI / Anthropic 协议自动转换</p>
</div>
<div class="space-y-4">
<div class="min-w-0">
<p class="mb-1.5 text-[11px] font-medium text-muted">Base URL</p>
<div class="flex items-center gap-2">
<code class="min-w-0 flex-1 overflow-x-auto rounded-md border border-edge bg-surface px-3 py-2 font-mono text-xs whitespace-nowrap text-ink">{{ baseURL }}</code>
<Button size="sm" variant="ghost" class="shrink-0" @click="copy(baseURL, 'base')">
{{ copied === 'base' ? '已复制' : '复制' }}
</Button>
</div>
</div>
<div class="min-w-0">
<p class="mb-1.5 text-[11px] font-medium text-muted">兼容</p>
<div class="flex flex-wrap gap-1.5">
<Badge variant="neutral">messages</Badge>
<Badge variant="neutral">chat/completions</Badge>
<Badge variant="neutral">responses</Badge>
</div>
</div>
<div class="min-w-0">
<p class="mb-1.5 text-[11px] font-medium text-muted">curl 示例</p>
<div class="relative min-w-0 overflow-hidden rounded-md border border-edge bg-surface">
<pre class="overflow-x-auto px-3 py-2.5 pr-20 font-mono text-[11px] leading-relaxed text-ink">{{ curlCmd }}</pre>
<Button size="sm" variant="ghost" class="absolute top-2 right-2" @click="copy(curlCmd, 'curl')">
{{ copied === 'curl' ? '已复制' : '复制' }}
</Button>
</div>
</div>
</div>
</div>
</div> </div>
</template> </template>
+197 -27
View File
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { PhCopy, PhCheck } from '@phosphor-icons/vue' import { PhCopy, PhCheck, PhNotePencil } from '@phosphor-icons/vue'
import { http, errMsg } from '@/api/client' import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import { fmtTime } from '@/lib/format' import { fmtNum, fmtTime } from '@/lib/format'
import { copyText } from '@/lib/clipboard'
import Button from '@/components/ui/Button.vue' import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue' import Input from '@/components/ui/Input.vue'
import Modal from '@/components/ui/Modal.vue' import Modal from '@/components/ui/Modal.vue'
@@ -15,11 +16,42 @@ const keys = ref<ApiKey[]>([])
const createOpen = ref(false) const createOpen = ref(false)
const keyName = ref('') const keyName = ref('')
const createAdv = reactive({ quota_tokens_per_day: '', quota_requests_per_day: '', allowed_models: '' })
const creating = ref(false) const creating = ref(false)
const created = ref<{ name: string; key: string; key_prefix: string } | null>(null) const created = ref<{ name: string; key: string; key_prefix: string } | null>(null)
const copied = ref(false) const copied = ref(false)
const editOpen = ref(false)
const editing = ref<ApiKey | null>(null)
const savingEdit = ref(false)
const editForm = reactive({
name: '',
status: 'active',
quota_tokens_per_day: '',
quota_requests_per_day: '',
allowed_models: '',
})
function parseOptionalInt(s: string): number | undefined {
const t = s.trim()
if (t === '') return undefined
const n = Number(t)
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : undefined
}
function parseModels(s: string): string[] | undefined {
const arr = s.split(/[,,\s]+/).map((x) => x.trim()).filter(Boolean)
return arr.length ? arr : undefined
}
function quotaText(k: ApiKey): string {
const parts: string[] = []
if (k.quota_tokens_per_day) parts.push(`${fmtNum(k.quota_tokens_per_day)} tok/日`)
if (k.quota_requests_per_day) parts.push(`${fmtNum(k.quota_requests_per_day)} req/日`)
return parts.length ? parts.join(' · ') : '不限'
}
async function load() { async function load() {
try { try {
const { data } = await http.get('/keys') const { data } = await http.get('/keys')
@@ -33,10 +65,18 @@ async function createKey() {
if (!keyName.value) return if (!keyName.value) return
creating.value = true creating.value = true
try { try {
const { data } = await http.post('/keys', { name: keyName.value }) const payload: Record<string, unknown> = { name: keyName.value }
const t = parseOptionalInt(createAdv.quota_tokens_per_day)
const r = parseOptionalInt(createAdv.quota_requests_per_day)
const m = parseModels(createAdv.allowed_models)
if (t !== undefined) payload.quota_tokens_per_day = t
if (r !== undefined) payload.quota_requests_per_day = r
if (m !== undefined) payload.allowed_models = m
const { data } = await http.post('/keys', payload)
created.value = data.data created.value = data.data
createOpen.value = false createOpen.value = false
keyName.value = '' keyName.value = ''
Object.assign(createAdv, { quota_tokens_per_day: '', quota_requests_per_day: '', allowed_models: '' })
await load() await load()
} catch (e) { } catch (e) {
toast.err(errMsg(e)) toast.err(errMsg(e))
@@ -45,11 +85,45 @@ async function createKey() {
} }
} }
async function revoke(k: ApiKey) { function openEdit(k: ApiKey) {
if (!confirm(`吊销密钥 ${k.name}?吊销后立即失效。`)) return editing.value = k
Object.assign(editForm, {
name: k.name,
status: k.status,
quota_tokens_per_day: k.quota_tokens_per_day != null ? String(k.quota_tokens_per_day) : '',
quota_requests_per_day: k.quota_requests_per_day != null ? String(k.quota_requests_per_day) : '',
allowed_models: (k.allowed_models || []).join(', '),
})
editOpen.value = true
}
async function saveEdit() {
if (!editing.value || !editForm.name) return
savingEdit.value = true
try {
const payload: Record<string, unknown> = { name: editForm.name, status: editForm.status }
const t = parseOptionalInt(editForm.quota_tokens_per_day)
const r = parseOptionalInt(editForm.quota_requests_per_day)
const m = parseModels(editForm.allowed_models)
if (t !== undefined) payload.quota_tokens_per_day = t
if (r !== undefined) payload.quota_requests_per_day = r
if (m !== undefined) payload.allowed_models = m
await http.patch(`/keys/${editing.value.id}`, payload)
toast.ok('密钥已更新')
editOpen.value = false
await load()
} catch (e) {
toast.err(errMsg(e))
} finally {
savingEdit.value = false
}
}
async function remove(k: ApiKey) {
if (!confirm(`删除密钥 ${k.name}?删除后立即失效且不可恢复。`)) return
try { try {
await http.delete(`/keys/${k.id}`) await http.delete(`/keys/${k.id}`)
toast.ok('密钥已吊销') toast.ok('密钥已删除')
await load() await load()
} catch (e) { } catch (e) {
toast.err(errMsg(e)) toast.err(errMsg(e))
@@ -58,11 +132,11 @@ async function revoke(k: ApiKey) {
async function copyKey() { async function copyKey() {
if (!created.value) return if (!created.value) return
try { const ok = await copyText(created.value.key)
await navigator.clipboard.writeText(created.value.key) if (ok) {
copied.value = true copied.value = true
setTimeout(() => (copied.value = false), 1500) setTimeout(() => (copied.value = false), 1500)
} catch { } else {
toast.err('复制失败,请手动复制') toast.err('复制失败,请手动复制')
} }
} }
@@ -75,45 +149,103 @@ onMounted(load)
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex items-center justify-between">
<div> <div>
<h1 class="text-lg font-semibold">API Keys</h1> <h1 class="text-lg font-semibold">API Keys</h1>
<p class="text-sm text-zinc-500">密钥明文仅在创建时展示一次,请立即保存</p> <p class="text-sm text-muted">密钥明文仅在创建时展示一次,请立即保存</p>
</div> </div>
<Button @click="createOpen = true">新建密钥</Button> <Button @click="createOpen = true">新建密钥</Button>
</div> </div>
<div class="card"> <div class="card">
<div class="overflow-x-auto"> <!-- 移动端:卡片列表 -->
<table class="w-full text-sm"> <div class="space-y-3 p-3 md:hidden">
<div v-for="k in keys" :key="k.id" class="card p-3">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-ink">{{ k.name }}</p>
<p class="truncate font-mono text-xs text-muted">{{ k.key_prefix }}…</p>
</div>
<Badge :variant="k.status === 'active' ? 'ok' : 'neutral'">{{ k.status }}</Badge>
</div>
<div class="mt-2.5 space-y-1.5 text-xs">
<p class="flex items-baseline justify-between gap-3">
<span class="shrink-0 text-muted">配额</span>
<span class="mono-num truncate text-ink">{{ quotaText(k) }}</span>
</p>
<p class="flex items-baseline justify-between gap-3">
<span class="shrink-0 text-muted">过期</span>
<span class="mono-num truncate text-ink">{{ fmtTime(k.expires_at) }}</span>
</p>
<p class="flex items-baseline justify-between gap-3">
<span class="shrink-0 text-muted">最近使用</span>
<span class="mono-num truncate text-ink">{{ fmtTime(k.last_used_at) }}</span>
</p>
</div>
<div class="mt-2.5 flex gap-2 border-t border-edge pt-2.5">
<Button size="sm" variant="ghost" class="flex-1" @click="openEdit(k)">
<PhNotePencil :size="13" weight="bold" />
编辑
</Button>
<Button v-if="k.status === 'active'" size="sm" variant="danger" class="flex-1" @click="remove(k)">
删除
</Button>
</div>
</div>
<p v-if="keys.length === 0" class="card px-4 py-8 text-center text-sm text-muted">
还没有密钥,点击右上角「新建密钥」
</p>
</div>
<!-- 桌面端:表格 -->
<div class="hidden overflow-x-auto md:block">
<table class="w-full text-sm min-w-[680px]">
<thead> <thead>
<tr class="border-b border-zinc-800 text-left text-xs text-zinc-500"> <tr class="border-b border-edge text-left text-xs text-muted">
<th scope="col" class="px-4 py-2.5 font-medium">名称</th> <th scope="col" class="px-4 py-2.5 font-medium">名称</th>
<th scope="col" class="px-4 py-2.5 font-medium">前缀</th> <th scope="col" class="px-4 py-2.5 font-medium">前缀</th>
<th scope="col" class="px-4 py-2.5 font-medium">配额</th>
<th scope="col" class="px-4 py-2.5 font-medium">过期</th>
<th scope="col" class="px-4 py-2.5 font-medium">状态</th> <th scope="col" class="px-4 py-2.5 font-medium">状态</th>
<th scope="col" class="px-4 py-2.5 font-medium">最近使用</th> <th scope="col" class="px-4 py-2.5 font-medium">最近使用</th>
<th scope="col" class="px-4 py-2.5 font-medium">创建时间</th>
<th scope="col" class="px-4 py-2.5" /> <th scope="col" class="px-4 py-2.5" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="k in keys" :key="k.id" class="table-row"> <tr v-for="k in keys" :key="k.id" class="table-row">
<td class="px-4 py-2.5 text-zinc-200">{{ k.name }}</td> <td class="px-4 py-2.5 text-ink">{{ k.name }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-400">{{ k.key_prefix }}…</td> <td class="px-4 py-2.5 font-mono text-xs text-muted">{{ k.key_prefix }}…</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">
<template v-if="k.quota_tokens_per_day || k.quota_requests_per_day">
<span v-if="k.quota_tokens_per_day">{{ fmtNum(k.quota_tokens_per_day) }} tok/日</span>
<span v-if="k.quota_tokens_per_day && k.quota_requests_per_day"> · </span>
<span v-if="k.quota_requests_per_day">{{ fmtNum(k.quota_requests_per_day) }} req/日</span>
</template>
<span v-else class="text-muted">不限</span>
</td>
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(k.expires_at) }}</td>
<td class="px-4 py-2.5"> <td class="px-4 py-2.5">
<Badge :variant="k.status === 'active' ? 'ok' : 'neutral'">{{ k.status }}</Badge> <Badge :variant="k.status === 'active' ? 'ok' : 'neutral'">{{ k.status }}</Badge>
</td> </td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ fmtTime(k.last_used_at) }}</td> <td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(k.last_used_at) }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ fmtTime(k.created_at) }}</td>
<td class="px-4 py-2.5 text-right"> <td class="px-4 py-2.5 text-right">
<div class="flex justify-end gap-2">
<button
class="inline-flex items-center gap-1 rounded-md border border-accent/40 bg-accent-soft px-2 py-1 text-xs font-medium text-accent transition hover:border-accent"
@click="openEdit(k)"
>
<PhNotePencil :size="13" weight="bold" />
编辑
</button>
<button <button
v-if="k.status === 'active'" v-if="k.status === 'active'"
class="text-xs text-zinc-500 hover:text-red-400" class="text-xs text-muted transition hover:text-err"
@click="revoke(k)" @click="remove(k)"
> >
吊销 删除
</button> </button>
</div>
</td> </td>
</tr> </tr>
<tr v-if="keys.length === 0"> <tr v-if="keys.length === 0">
<td colspan="6" class="px-4 py-10 text-center text-sm text-zinc-600"> <td colspan="7" class="px-4 py-10 text-center text-sm text-muted">
还没有密钥,点击右上角「新建密钥」 还没有密钥,点击右上角「新建密钥」
</td> </td>
</tr> </tr>
@@ -124,22 +256,60 @@ onMounted(load)
<!-- 新建密钥 --> <!-- 新建密钥 -->
<Modal :open="createOpen" title="新建密钥" @close="createOpen = false"> <Modal :open="createOpen" title="新建密钥" @close="createOpen = false">
<div class="space-y-4">
<Input v-model="keyName" label="密钥名称" placeholder="例如 dev / prod" @keyup.enter="createKey" /> <Input v-model="keyName" label="密钥名称" placeholder="例如 dev / prod" @keyup.enter="createKey" />
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Input v-model="createAdv.quota_tokens_per_day" label="每日 Token 上限(可选)" placeholder="如 100000" />
<Input v-model="createAdv.quota_requests_per_day" label="每日请求上限(可选)" placeholder="如 1000" />
</div>
<Input
v-model="createAdv.allowed_models"
label="模型白名单(可选)"
placeholder="逗号分隔,如 gpt-4o, claude-sonnet-5"
hint="留空则不限模型"
/>
</div>
<template #footer> <template #footer>
<Button variant="ghost" @click="createOpen = false">取消</Button> <Button variant="ghost" @click="createOpen = false">取消</Button>
<Button :loading="creating" @click="createKey">创建</Button> <Button :loading="creating" @click="createKey">创建</Button>
</template> </template>
</Modal> </Modal>
<!-- 编辑密钥 -->
<Modal :open="editOpen" :title="`编辑密钥 · ${editing?.name}`" @close="editOpen = false">
<div class="space-y-4">
<Input v-model="editForm.name" label="名称" />
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Input v-model="editForm.quota_tokens_per_day" label="每日 Token 上限" placeholder="留空则不修改" />
<Input v-model="editForm.quota_requests_per_day" label="每日请求上限" placeholder="留空则不修改" />
</div>
<Input v-model="editForm.allowed_models" label="模型白名单" placeholder="逗号分隔" hint="留空则不修改" />
<label class="block">
<span class="mb-1.5 block text-xs font-medium text-muted">状态</span>
<select
v-model="editForm.status"
class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink outline-none focus:border-accent"
>
<option value="active">active(启用)</option>
<option value="revoked">revoked(停用)</option>
</select>
</label>
</div>
<template #footer>
<Button variant="ghost" @click="editOpen = false">取消</Button>
<Button :loading="savingEdit" @click="saveEdit">保存</Button>
</template>
</Modal>
<!-- 一次性展示密钥 --> <!-- 一次性展示密钥 -->
<Modal :open="!!created" title="密钥已创建" @close="created = null"> <Modal :open="!!created" title="密钥已创建" @close="created = null">
<div class="space-y-4"> <div class="space-y-4">
<p class="text-xs text-zinc-500">请复制并妥善保存,关闭后不再显示。</p> <p class="text-xs text-muted">请复制并妥善保存,关闭后不再显示。</p>
<div class="flex items-center gap-2"> <div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<code class="mono-num flex-1 truncate rounded-md border border-accent/40 bg-zinc-900 px-3 py-2 text-xs text-emerald-300"> <code class="mono-num flex-1 truncate rounded-md border border-accent bg-surface px-3 py-2 text-xs text-accent">
{{ created?.key }} {{ created?.key }}
</code> </code>
<Button size="sm" @click="copyKey"> <Button size="sm" class="shrink-0" @click="copyKey">
<PhCheck v-if="copied" :size="14" /> <PhCheck v-if="copied" :size="14" />
<PhCopy v-else :size="14" /> <PhCopy v-else :size="14" />
{{ copied ? '已复制' : '复制' }} {{ copied ? '已复制' : '复制' }}
+180
View File
@@ -0,0 +1,180 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { PhFingerprint } from '@phosphor-icons/vue'
import { http, errMsg } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
import { fmtMoney, fmtTime } from '@/lib/format'
import { registerPasskey, isWebAuthnSupported } from '@/lib/webauthn'
import Badge from '@/components/ui/Badge.vue'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
const auth = useAuthStore()
const toast = useToastStore()
const passkeys = ref<{ id: number; name: string; created_at: string }[]>([])
const binding = ref(false)
const passkeyName = ref('')
// 按 UA 推断设备名,方便多设备区分
function detectPlatform(): string {
const ua = navigator.userAgent || ''
if (/iPhone|iPad|iPod/.test(ua)) return 'iPhone'
if (/Android/.test(ua)) return 'Android'
if (/Windows/.test(ua)) return 'Windows'
if (/Macintosh|Mac OS/.test(ua)) return 'Mac'
if (/Linux/.test(ua)) return 'Linux'
return 'Passkey'
}
function usePasskeyName(): string {
return passkeyName.value.trim() || detectPlatform()
}
async function loadPasskeys() {
try {
const { data } = await http.get('/webauthn/passkeys')
passkeys.value = data.data.items
} catch {
/* 忽略 */
}
}
async function bindPasskey() {
if (!isWebAuthnSupported()) {
toast.err('当前环境不支持 Passkey(需 HTTPS 或 localhost)')
return
}
binding.value = true
try {
const { data } = await http.post('/webauthn/register/begin')
const credential = await registerPasskey(data.data.creation)
await http.post('/webauthn/register/complete', {
challenge: data.data.challenge,
name: usePasskeyName(),
credential,
})
toast.ok('Passkey 已绑定')
passkeyName.value = ''
await loadPasskeys()
} catch (e) {
toast.err(errMsg(e))
} finally {
binding.value = false
}
}
async function removePasskey(id: number) {
if (!confirm('解除该 Passkey?解除后需重新绑定才能免密登录。')) return
try {
await http.delete(`/webauthn/passkeys/${id}`)
toast.ok('已解除')
await loadPasskeys()
} catch (e) {
toast.err(errMsg(e))
}
}
onMounted(loadPasskeys)
const oldPwd = ref('')
const newPwd = ref('')
const confirmPwd = ref('')
const saving = ref(false)
async function changePassword() {
if (newPwd.value.length < 8) {
toast.err('新密码至少 8 位')
return
}
if (newPwd.value !== confirmPwd.value) {
toast.err('两次输入的新密码不一致')
return
}
saving.value = true
try {
await http.post('/auth/password', { old_password: oldPwd.value, new_password: newPwd.value })
toast.ok('密码已更新')
oldPwd.value = newPwd.value = confirmPwd.value = ''
} catch (e) {
toast.err(errMsg(e))
} finally {
saving.value = false
}
}
</script>
<template>
<div class="mx-auto max-w-2xl space-y-6">
<div class="mb-2">
<h1 class="text-lg font-semibold">账户设置</h1>
<p class="text-sm text-muted">个人资料与安全</p>
</div>
<div class="card p-5">
<h2 class="mb-4 text-sm font-semibold">个人资料</h2>
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4 text-sm">
<div>
<dt class="text-xs text-muted">用户名</dt>
<dd class="mt-1 text-ink">{{ auth.user?.username }}</dd>
</div>
<div>
<dt class="text-xs text-muted">邮箱</dt>
<dd class="mt-1 font-mono text-xs text-ink">{{ auth.user?.email }}</dd>
</div>
<div>
<dt class="text-xs text-muted">角色</dt>
<dd class="mt-1"><Badge :variant="auth.isAdmin ? 'accent' : 'neutral'">{{ auth.user?.role }}</Badge></dd>
</div>
<div>
<dt class="text-xs text-muted">余额</dt>
<dd class="mono-num mt-1 text-accent">{{ fmtMoney(auth.user?.balance ?? 0) }}</dd>
</div>
<div>
<dt class="text-xs text-muted">注册时间</dt>
<dd class="mono-num mt-1 text-xs text-muted">{{ fmtTime(auth.user?.created_at) }}</dd>
</div>
</dl>
</div>
<div class="card p-5">
<h2 class="mb-4 text-sm font-semibold">修改密码</h2>
<div class="max-w-sm space-y-4">
<Input v-model="oldPwd" label="当前密码" type="password" autocomplete="current-password" />
<Input v-model="newPwd" label="新密码" type="password" autocomplete="new-password" hint="至少 8 位" />
<Input v-model="confirmPwd" label="确认新密码" type="password" autocomplete="new-password" />
<Button :loading="saving" @click="changePassword">更新密码</Button>
</div>
</div>
<div class="card p-5">
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 class="text-sm font-semibold">Passkey 登录</h2>
<div class="flex items-center gap-2">
<input
v-model="passkeyName"
placeholder="设备名,如 iPhone"
class="h-8 w-32 rounded-md border border-edge2 bg-surface px-2 text-xs outline-none focus:border-accent"
@keyup.enter="bindPasskey"
/>
<Button size="sm" :loading="binding" @click="bindPasskey">
<PhFingerprint :size="14" />
绑定
</Button>
</div>
</div>
<p class="mb-3 text-xs text-muted">用生物识别或系统 PIN 免密登录。需要 HTTPS 或 localhost 环境。</p>
<ul v-if="passkeys.length" class="divide-y divide-edge">
<li v-for="pk in passkeys" :key="pk.id" class="flex items-center justify-between py-2">
<div>
<p class="text-sm text-ink">{{ pk.name }}</p>
<p class="font-mono text-xs text-muted">{{ fmtTime(pk.created_at) }}</p>
</div>
<button class="text-xs text-muted transition hover:text-err" @click="removePasskey(pk.id)">解除</button>
</li>
</ul>
<p v-else class="text-xs text-muted">尚未绑定 Passkey</p>
</div>
</div>
</template>
+52 -27
View File
@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue'
import { http, errMsg } from '@/api/client' import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import { fmtNum, fmtCost, fmtTime } from '@/lib/format' import { fmtNum, fmtCost, fmtTime } from '@/lib/format'
import { protocolShort } from '@/lib/protocol'
import Badge from '@/components/ui/Badge.vue' import Badge from '@/components/ui/Badge.vue'
import TrendChart from '@/components/ui/TrendChart.vue' import TrendChart from '@/components/ui/TrendChart.vue'
import type { UsageLog } from '@/types' import type { UsageLog } from '@/types'
@@ -51,42 +52,42 @@ onMounted(load)
<div class="mx-auto max-w-5xl space-y-6"> <div class="mx-auto max-w-5xl space-y-6">
<div class="mb-2"> <div class="mb-2">
<h1 class="text-lg font-semibold">用量</h1> <h1 class="text-lg font-semibold">用量</h1>
<p class="text-sm text-zinc-500">汇总、分布与请求明细</p> <p class="text-sm text-muted">汇总、分布与请求明细</p>
</div> </div>
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4"> <div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div class="card p-4"> <div class="card p-4">
<p class="text-xs text-zinc-500">今日请求</p> <p class="text-xs text-muted">今日请求</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(summary.today.requests) }}</p> <p class="mono-num mt-1 text-xl text-ink">{{ fmtNum(summary.today.requests) }}</p>
</div> </div>
<div class="card p-4"> <div class="card p-4">
<p class="text-xs text-zinc-500">今日 Token</p> <p class="text-xs text-muted">今日 Token</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(summary.today.tokens) }}</p> <p class="mono-num mt-1 text-xl text-ink">{{ fmtNum(summary.today.tokens) }}</p>
</div> </div>
<div class="card p-4"> <div class="card p-4">
<p class="text-xs text-zinc-500">本月请求</p> <p class="text-xs text-muted">本月请求</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(summary.month.requests) }}</p> <p class="mono-num mt-1 text-xl text-ink">{{ fmtNum(summary.month.requests) }}</p>
</div> </div>
<div class="card p-4"> <div class="card p-4">
<p class="text-xs text-zinc-500">本月成本</p> <p class="text-xs text-muted">本月成本</p>
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtCost(summary.month.cost) }}</p> <p class="mono-num mt-1 text-xl text-ink">{{ fmtCost(summary.month.cost) }}</p>
</div> </div>
</div> </div>
<div class="card p-5"> <div class="card p-5">
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<h2 class="text-sm font-semibold">成本分布</h2> <h2 class="text-sm font-semibold">成本分布</h2>
<div class="flex gap-1 rounded-md border border-zinc-800 p-0.5"> <div class="flex gap-1 rounded-md border border-edge p-0.5">
<button <button
class="rounded px-2.5 py-1 text-xs transition" class="rounded px-2.5 py-1 text-xs transition"
:class="group === 'day' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-500'" :class="group === 'day' ? 'bg-surface2 text-ink' : 'text-muted'"
@click="switchGroup('day')" @click="switchGroup('day')"
> >
按天 按天
</button> </button>
<button <button
class="rounded px-2.5 py-1 text-xs transition" class="rounded px-2.5 py-1 text-xs transition"
:class="group === 'model' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-500'" :class="group === 'model' ? 'bg-surface2 text-ink' : 'text-muted'"
@click="switchGroup('model')" @click="switchGroup('model')"
> >
按模型 按模型
@@ -97,19 +98,43 @@ onMounted(load)
</div> </div>
<div class="card"> <div class="card">
<div class="flex items-center justify-between border-b border-zinc-800 px-4 py-3"> <div class="flex flex-wrap items-center justify-between gap-2 border-b border-edge px-4 py-3">
<h2 class="text-sm font-semibold">请求明细</h2> <h2 class="text-sm font-semibold">请求明细</h2>
<input <input
v-model="modelFilter" v-model="modelFilter"
placeholder="按模型过滤" placeholder="按模型过滤"
class="h-8 w-48 rounded-md border border-zinc-700 bg-zinc-900 px-2.5 font-mono text-xs outline-none focus:border-accent" class="h-8 w-full rounded-md border border-edge2 bg-surface px-2.5 font-mono text-xs outline-none focus:border-accent sm:w-48"
@keyup.enter="page = 1; load()" @keyup.enter="page = 1; load()"
/> />
</div> </div>
<div class="overflow-x-auto">
<table class="w-full text-sm"> <!-- 移动端:卡片列表 -->
<div class="space-y-3 p-3 md:hidden">
<div v-for="l in logs" :key="l.id" class="card p-3">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate font-mono text-sm text-ink">{{ l.model }}</p>
<p class="mt-0.5 font-mono text-xs text-muted">{{ protocolShort(l.protocol) }}</p>
</div>
<div class="flex shrink-0 flex-col items-end gap-1">
<Badge :variant="l.status === 'success' ? 'ok' : l.status === 'canceled' ? 'neutral' : 'err'">{{ l.status }}</Badge>
<span class="mono-num text-sm font-medium text-ink">{{ fmtCost(l.cost) }}</span>
</div>
</div>
<div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-edge pt-2 text-xs text-muted">
<span class="mono-num">{{ l.input_tokens }}/{{ l.output_tokens }} tok</span>
<span class="mono-num">{{ l.latency_ms }}ms</span>
<span class="mono-num w-full">{{ fmtTime(l.created_at) }}</span>
</div>
</div>
<p v-if="logs.length === 0" class="card px-4 py-8 text-center text-sm text-muted">暂无请求记录</p>
</div>
<!-- 桌面端:表格 -->
<div class="hidden overflow-x-auto md:block">
<table class="w-full text-sm min-w-[640px]">
<thead> <thead>
<tr class="border-b border-zinc-800 text-left text-xs text-zinc-500"> <tr class="border-b border-edge text-left text-xs text-muted">
<th scope="col" class="px-4 py-2.5 font-medium">模型</th> <th scope="col" class="px-4 py-2.5 font-medium">模型</th>
<th scope="col" class="px-4 py-2.5 font-medium">协议</th> <th scope="col" class="px-4 py-2.5 font-medium">协议</th>
<th scope="col" class="px-4 py-2.5 font-medium">Token 入/出</th> <th scope="col" class="px-4 py-2.5 font-medium">Token 入/出</th>
@@ -121,22 +146,22 @@ onMounted(load)
</thead> </thead>
<tbody> <tbody>
<tr v-for="l in logs" :key="l.id" class="table-row"> <tr v-for="l in logs" :key="l.id" class="table-row">
<td class="px-4 py-2.5 font-mono text-xs text-zinc-200">{{ l.model }}</td> <td class="px-4 py-2.5 font-mono text-xs text-ink">{{ l.model }}</td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ l.protocol }}</td> <td class="px-4 py-2.5 font-mono text-xs text-muted">{{ protocolShort(l.protocol) }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-zinc-400">{{ l.input_tokens }}/{{ l.output_tokens }}</td> <td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.input_tokens }}/{{ l.output_tokens }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-zinc-300">{{ fmtCost(l.cost) }}</td> <td class="px-4 py-2.5 mono-num text-xs text-ink">{{ fmtCost(l.cost) }}</td>
<td class="px-4 py-2.5 mono-num text-xs text-zinc-500">{{ l.latency_ms }}ms</td> <td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.latency_ms }}ms</td>
<td class="px-4 py-2.5"><Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge></td> <td class="px-4 py-2.5"><Badge :variant="l.status === 'success' ? 'ok' : l.status === 'canceled' ? 'neutral' : 'err'">{{ l.status }}</Badge></td>
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ fmtTime(l.created_at) }}</td> <td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(l.created_at) }}</td>
</tr> </tr>
<tr v-if="logs.length === 0"> <tr v-if="logs.length === 0">
<td colspan="7" class="px-4 py-10 text-center text-sm text-zinc-600">暂无请求记录</td> <td colspan="7" class="px-4 py-10 text-center text-sm text-muted">暂无请求记录</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="flex items-center justify-between border-t border-zinc-800 px-4 py-3"> <div class="flex items-center justify-between border-t border-edge px-4 py-3">
<span class="font-mono text-xs text-zinc-600">共 {{ total }} 条</span> <span class="font-mono text-xs text-muted">共 {{ total }} 条</span>
<div class="flex gap-2"> <div class="flex gap-2">
<Button size="sm" variant="ghost" :disabled="page <= 1" @click="goPage(page - 1)">上一页</Button> <Button size="sm" variant="ghost" :disabled="page <= 1" @click="goPage(page - 1)">上一页</Button>
<Button size="sm" variant="ghost" :disabled="page * pageSize >= total" @click="goPage(page + 1)">下一页</Button> <Button size="sm" variant="ghost" :disabled="page * pageSize >= total" @click="goPage(page + 1)">下一页</Button>