Compare commits
74
Commits
0637ce0a51
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d911881499 | ||
|
|
2e3bb22f81 | ||
|
|
923ac79039 | ||
|
|
6d951a0d61 | ||
|
|
1f5f60e905 | ||
|
|
c6a4fcfbbc | ||
|
|
5cb428bba9 | ||
|
|
658a9cb580 | ||
|
|
3a836bf0e9 | ||
|
|
faf38e2fd3 | ||
|
|
d8257df100 | ||
|
|
cd015d370c | ||
|
|
939a98d389 | ||
|
|
0b31cc209e | ||
|
|
305b3ed731 | ||
|
|
79545aa48e | ||
|
|
d818cf3b4c | ||
|
|
98f296101e | ||
|
|
2983c89b42 | ||
|
|
a45a6e16bd | ||
|
|
c78a473e59 | ||
|
|
f7a5741b33 | ||
|
|
daf6b8c66b | ||
|
|
8430ecc6c6 | ||
|
|
4aa8b79156 | ||
|
|
94fd7fd40a | ||
|
|
048de4c9a8 | ||
|
|
781f1b9f83 | ||
|
|
42214d6f5e | ||
|
|
10f51cbdae | ||
|
|
0939f98fb5 | ||
|
|
30ab9e842c | ||
|
|
3e7efb3c88 | ||
|
|
8495694671 | ||
|
|
5aa0be13dc | ||
|
|
b5fb947a5b | ||
|
|
ea7f023228 | ||
|
|
967c54ae32 | ||
|
|
eac6938baa | ||
|
|
fcaafdc807 | ||
|
|
74424d980c | ||
|
|
c84fea9ef0 | ||
|
|
c3dc28fb5b | ||
|
|
4137b6fe20 | ||
|
|
d0c879a2ed | ||
|
|
8194edb63d | ||
|
|
6f895acf63 | ||
|
|
fb7db00817 | ||
|
|
0ee92d78e7 | ||
|
|
4b507d8064 | ||
|
|
7f28c8b83b | ||
|
|
47f6a8daaa | ||
|
|
e96f194a59 | ||
|
|
db83972b6f | ||
|
|
7c4e80afac | ||
|
|
9324a782d5 | ||
|
|
057b1b2c0b | ||
|
|
40c1ae43e9 | ||
|
|
b365779188 | ||
|
|
866690b7ce | ||
|
|
298bf89c90 | ||
|
|
79f3f4395c | ||
|
|
4d4d09ba58 | ||
|
|
8d9940ad5a | ||
|
|
512e46664c | ||
|
|
a8e2cd214c | ||
|
|
eb57a09c5d | ||
|
|
d0bc28a4fe | ||
|
|
4f92d7e0a4 | ||
|
|
d9dcbc4fb3 | ||
|
|
5eff32afd8 | ||
|
|
6adadebaf0 | ||
|
|
a422034b2b | ||
|
|
4846db9293 |
@@ -0,0 +1,20 @@
|
||||
# VCS
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# 依赖与构建产物(镜像内重新安装/构建)
|
||||
**/node_modules
|
||||
**/dist
|
||||
server/bin
|
||||
|
||||
# 本地数据与密钥
|
||||
data/
|
||||
*.db
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# 编辑器/系统
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
+15
-1
@@ -27,13 +27,27 @@ OT_MASTER_KEY=change-me-master-key
|
||||
OT_PROXY_UPSTREAM_KEY=
|
||||
OT_PROXY_UPSTREAM_BASE_URL=https://api.openai.com
|
||||
OT_PROXY_DEFAULT_MODEL=gpt-4o-mini
|
||||
OT_PROXY_TIMEOUT=120s
|
||||
OT_PROXY_TIMEOUT=300s
|
||||
|
||||
# 渠道健康检查
|
||||
OT_PROXY_HEALTH_INTERVAL=60s
|
||||
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_EMAIL=admin@localhost
|
||||
OT_ADMIN_PASSWORD=admin123
|
||||
|
||||
# 记录原始请求日志
|
||||
OT_PROXY_LOG_RAW=false
|
||||
|
||||
@@ -7,6 +7,8 @@ dist/
|
||||
# Go
|
||||
server/bin/
|
||||
server/data/
|
||||
# 本地静态托管软链(指向 ../web/dist),不入库
|
||||
server/web/
|
||||
scripts/mockupstream/bin/
|
||||
|
||||
# TypeScript 增量构建产物
|
||||
@@ -34,3 +36,6 @@ nohup.out
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# 测试结果记录(参考用, 不入库)
|
||||
docs/*.txt
|
||||
|
||||
+53
@@ -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"]
|
||||
@@ -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:
|
||||
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:
|
||||
cd server && go build -o bin/openteam ./cmd/server
|
||||
|
||||
|
||||
+4
-4
@@ -275,8 +275,8 @@ Anthropic /v1/messages ──┘
|
||||
|
||||
#### 5.3.3 API Key
|
||||
|
||||
- 格式:`sk-` + 48 位随机 base62,**创建时仅展示一次**。
|
||||
- 存储:仅 SHA-256 哈希 + 展示前缀(如 `sk-aB3c…`);请求时哈希后查表。
|
||||
- 格式:`sk-ot-` + 48 位随机 base62,**创建时仅展示一次**。
|
||||
- 存储:仅 SHA-256 哈希 + 展示前缀(如 `sk-ot-aB3c…`);请求时哈希后查表。
|
||||
- 附加能力:密钥级配额(每日 token / 请求数)、模型白名单、过期时间、启停。
|
||||
- 限额检查用 Redis 计数,与用户级限流叠加。
|
||||
|
||||
@@ -461,8 +461,8 @@ pending(待审核) ──approve──▶ credited(已入账)
|
||||
- 已过 web-design-guidelines 复查并修复(移动端侧栏、表格横向滚动、模态框焦点/滚动锁、focus-visible、aria 等)。
|
||||
**验收**:用户在控制台建 key、发请求、看用量;管理员能加渠道、调价、看统计。
|
||||
|
||||
### M3 管理后台前端 + 计费完善(◻ 部分完成)
|
||||
渠道/模型/用户/总览/配置页面已完成;`usage_daily` 趋势图已内建(自建 SVG)。待做:限流接入、加载骨架屏、按模型聚合报表增强。
|
||||
### M3 管理后台前端 + 计费完善(✅ 完成收尾)
|
||||
渠道/模型/用户/总览/配置页面已完成;`usage_daily` 趋势图已内建(自建 SVG);限流/配额接入(内存计数:密钥每日请求/token 配额、用户级速率,超限 429);Dashboard/总览骨架屏加载态。待做:Redis 化限流、按模型聚合报表增强。
|
||||
|
||||
### M4 协议转换(✅ 已完成)
|
||||
- `convert` 包:Chat↔Messages↔Responses 请求/响应 JSON 转换 + 流式 SSE 逐行状态机转换器(含单测)。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
自托管的 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),流式逐事件转换;协议匹配时直通
|
||||
- 错误按客户端协议返回(OpenAI 格式 / Anthropic 格式)
|
||||
- **渠道体系**:按模型绑定选渠道 + 加权负载均衡;每渠道并发信号量(满载溢出);后台健康检查(连续失败进 cooldown、恢复放回);可安全重试的失败自动故障转移(网络错误/429/5xx/超时且未写出响应头)
|
||||
- **限流/配额**(内存计数):密钥级每日请求数 / Token 数配额、用户级每秒速率(`OT_RATELIMIT_USER_RPS`);超限返回 429
|
||||
- **前端**:Dashboard / 管理总览骨架屏加载态
|
||||
- **用户体系**:注册(开放/邀请码可切换,管理后台可改)、登录(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`)
|
||||
- **管理 API**:渠道 CRUD + 连通测试 + 模型导入、模型管理 + 定价 + 渠道绑定、用户管理、全局用量/统计、系统配置
|
||||
- **前端**(Vue3 + Tailwind,taste-skill 设计,深色优先)
|
||||
|
||||
@@ -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
|
||||
@@ -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}" "$@"
|
||||
Executable
+72
@@ -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,60 +2,8 @@
|
||||
// 启动:OT_PROXY_UPSTREAM_KEY=sk-xxx go run ./cmd/server
|
||||
package main
|
||||
|
||||
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"
|
||||
)
|
||||
import "github.com/openteam/server/internal/cli"
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
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()
|
||||
cli.Execute()
|
||||
}
|
||||
+15
-3
@@ -1,13 +1,17 @@
|
||||
module github.com/openteam/server
|
||||
|
||||
go 1.25.0
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.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/spf13/cobra v1.10.2
|
||||
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/term v0.45.0
|
||||
gorm.io/driver/postgres v1.6.2
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
@@ -17,18 +21,23 @@ require (
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // 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/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/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.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-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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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/reflect2 v1.0.2 // 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/quic-go v0.59.0 // 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/pflag v1.0.10 // 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/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.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
|
||||
+33
-4
@@ -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/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
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/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
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/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
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/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-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
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/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
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/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-tpm v0.9.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/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/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-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/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/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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
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/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/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/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
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/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
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/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
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/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/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/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
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/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
|
||||
@@ -3,7 +3,9 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -46,14 +48,59 @@ func (h *Handler) AdminPatchUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Username *string `json:"username"`
|
||||
Email *string `json:"email"`
|
||||
Password *string `json:"password"`
|
||||
Role *string `json:"role"`
|
||||
Status *string `json:"status"`
|
||||
AllowedModels *[]string `json:"allowed_models"`
|
||||
DeniedModels *[]string `json:"denied_models"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||||
return
|
||||
}
|
||||
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 != store.RoleUser && *req.Role != store.RoleAdmin {
|
||||
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
|
||||
}
|
||||
// 模型限制(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 {
|
||||
resp.OK(c, gin.H{"ok": true})
|
||||
return
|
||||
@@ -166,5 +222,9 @@ func (h *Handler) AdminPutConfig(c *gin.Context) {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to save config")
|
||||
return
|
||||
}
|
||||
// 模型限制等策略可能变化,立即失效缓存
|
||||
if h.gw != nil {
|
||||
h.gw.ResetModelPolicy()
|
||||
}
|
||||
resp.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -13,8 +13,6 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/openteam/server/internal/pkg/resp"
|
||||
"github.com/openteam/server/internal/store"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// AdminChannels GET /api/v1/admin/channels — 渠道列表(不返回加密 key,返回掩码)。
|
||||
@@ -28,12 +26,13 @@ func (h *Handler) AdminChannels(c *gin.Context) {
|
||||
for _, ch := range chs {
|
||||
masked := ""
|
||||
if key, err := h.a.Enc.Decrypt(ch.APIKeyEnc); err == nil && len(key) > 8 {
|
||||
masked = strings.Repeat("*", len(key)-4) + key[len(key)-4:]
|
||||
masked = maskAPIKey(key)
|
||||
} else if err == nil {
|
||||
masked = "****"
|
||||
}
|
||||
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,
|
||||
"timeout_ms": ch.TimeoutMS, "max_concurrency": ch.MaxConcurrency,
|
||||
"health_status": ch.HealthStatus, "enabled": ch.Enabled,
|
||||
@@ -45,8 +44,10 @@ func (h *Handler) AdminChannels(c *gin.Context) {
|
||||
|
||||
type channelBody struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=64"`
|
||||
Provider string `json:"provider" binding:"required"`
|
||||
BaseURL string `json:"base_url" binding:"required"`
|
||||
Provider string `json:"provider"` // 可选:为空时按 formats 推断(兼容旧数据)
|
||||
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"`
|
||||
Weight *int `json:"weight"`
|
||||
Priority *int `json:"priority"`
|
||||
@@ -55,10 +56,97 @@ type channelBody struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// normalizeBaseURLs 校验并清理分协议 base_url。
|
||||
func normalizeBaseURLs(m map[string]string) map[string]string {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
for k, v := range m {
|
||||
if validFormats[k] && strings.TrimSpace(v) != "" {
|
||||
out[k] = strings.TrimRight(strings.TrimSpace(v), "/")
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveBaseURL 渠道 base_url:留空按供应商默认;网关按内容智能识别前缀/完整端点。
|
||||
func resolveBaseURL(provider, raw string) (string, error) {
|
||||
base := strings.TrimRight(raw, "/")
|
||||
if base == "" {
|
||||
switch provider {
|
||||
case store.ChannelProviderOpenAI:
|
||||
base = "https://api.openai.com"
|
||||
case store.ChannelProviderAnthropic:
|
||||
base = "https://api.anthropic.com"
|
||||
}
|
||||
}
|
||||
if base == "" {
|
||||
return "", errors.New("base_url required for compatible channels")
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func validateProvider(p string) bool {
|
||||
return p == store.ChannelProviderOpenAI || p == store.ChannelProviderAnthropic || p == store.ChannelProviderCompatible
|
||||
}
|
||||
|
||||
var validFormats = map[string]bool{
|
||||
store.FormatChat: true, store.FormatResponses: true, store.FormatMessages: true,
|
||||
}
|
||||
|
||||
// deriveProvider 按格式推断供应商(仅作内部字段/兼容用途,不参与路由)。
|
||||
func deriveProvider(formats []string) string {
|
||||
if len(formats) == 0 {
|
||||
return store.ChannelProviderCompatible
|
||||
}
|
||||
messagesOnly, hasResponses := true, false
|
||||
for _, f := range formats {
|
||||
if f != store.FormatMessages {
|
||||
messagesOnly = false
|
||||
}
|
||||
if f == store.FormatResponses {
|
||||
hasResponses = true
|
||||
}
|
||||
}
|
||||
if messagesOnly {
|
||||
return store.ChannelProviderAnthropic
|
||||
}
|
||||
if hasResponses {
|
||||
return store.ChannelProviderOpenAI
|
||||
}
|
||||
return store.ChannelProviderCompatible
|
||||
}
|
||||
|
||||
// resolveFormats 渠道协议格式:显式给出则校验去重;空则按 provider 推断默认。
|
||||
func resolveFormats(provider string, formats []string) ([]string, error) {
|
||||
if len(formats) == 0 {
|
||||
switch provider {
|
||||
case store.ChannelProviderAnthropic:
|
||||
return []string{store.FormatMessages}, nil
|
||||
case store.ChannelProviderOpenAI:
|
||||
return []string{store.FormatChat, store.FormatResponses}, nil
|
||||
default:
|
||||
return []string{store.FormatChat}, nil
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(formats))
|
||||
for _, f := range formats {
|
||||
if !validFormats[f] {
|
||||
return nil, fmt.Errorf("unsupported format %q", f)
|
||||
}
|
||||
if !seen[f] {
|
||||
seen[f] = true
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AdminCreateChannel POST /api/v1/admin/channels
|
||||
func (h *Handler) AdminCreateChannel(c *gin.Context) {
|
||||
var req channelBody
|
||||
@@ -66,6 +154,9 @@ func (h *Handler) AdminCreateChannel(c *gin.Context) {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Provider == "" {
|
||||
req.Provider = deriveProvider(req.Formats)
|
||||
}
|
||||
if !validateProvider(req.Provider) {
|
||||
resp.Fail(c, http.StatusBadRequest, "provider must be openai, anthropic or compatible")
|
||||
return
|
||||
@@ -74,15 +165,26 @@ func (h *Handler) AdminCreateChannel(c *gin.Context) {
|
||||
resp.Fail(c, http.StatusBadRequest, "api_key required")
|
||||
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)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to encrypt api key")
|
||||
return
|
||||
}
|
||||
ch := store.Channel{
|
||||
Name: req.Name, Provider: req.Provider, BaseURL: strings.TrimRight(req.BaseURL, "/"),
|
||||
Name: req.Name, Provider: req.Provider, Formats: formats, BaseURL: baseURL,
|
||||
BaseURLs: normalizeBaseURLs(req.BaseURLs),
|
||||
APIKeyEnc: enc, Weight: intOr(req.Weight, 1), Priority: intOr(req.Priority, 0),
|
||||
TimeoutMS: intOr(req.TimeoutMS, 120000), MaxConcurrency: intOr(req.MaxConcurrency, 16),
|
||||
TimeoutMS: intOr(req.TimeoutMS, 300000), MaxConcurrency: intOr(req.MaxConcurrency, 16),
|
||||
HealthStatus: store.ChannelHealthHealthy, Enabled: boolOr(req.Enabled, true),
|
||||
}
|
||||
if err := h.a.DB.Create(&ch).Error; err != nil {
|
||||
@@ -102,7 +204,9 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Provider *string `json:"provider"`
|
||||
Formats *[]string `json:"formats"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
BaseURLs *map[string]string `json:"base_urls"`
|
||||
APIKey *string `json:"api_key"`
|
||||
Weight *int `json:"weight"`
|
||||
Priority *int `json:"priority"`
|
||||
@@ -132,7 +236,21 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
|
||||
updates["provider"] = *body.Provider
|
||||
}
|
||||
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 != "" {
|
||||
enc, err := h.a.Enc.Encrypt(*body.APIKey)
|
||||
@@ -160,6 +278,20 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
|
||||
if body.Enabled != nil {
|
||||
updates["enabled"] = *body.Enabled
|
||||
}
|
||||
if body.Formats != nil {
|
||||
prov := ch.Provider
|
||||
if body.Provider != nil {
|
||||
prov = *body.Provider
|
||||
}
|
||||
formats, ferr := resolveFormats(prov, *body.Formats)
|
||||
if ferr != nil {
|
||||
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 err := h.a.DB.Model(&ch).Updates(updates).Error; err != nil {
|
||||
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")
|
||||
return
|
||||
}
|
||||
url := strings.TrimRight(ch.BaseURL, "/") + "/v1/models"
|
||||
url := ch.UpstreamURL("", "/models")
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
@@ -237,86 +369,14 @@ func (h *Handler) AdminTestChannel(c *gin.Context) {
|
||||
resp.OK(c, gin.H{"ok": true, "latency_ms": latency, "message": msg})
|
||||
}
|
||||
|
||||
// AdminImportChannelModels POST /api/v1/admin/channels/:id/models/import
|
||||
// 拉取渠道 GET /v1/models,导入模型库并绑定。
|
||||
func (h *Handler) AdminImportChannelModels(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid channel id")
|
||||
return
|
||||
// maskAPIKey 掩码渠道密钥:保留前 7 位与后 4 位,中间固定 ****** 遮蔽。
|
||||
// 示例:xxxxxxx******Mq4Y;密钥较短时退化为仅保留后 4 位。
|
||||
func maskAPIKey(key string) string {
|
||||
if len(key) <= 11 {
|
||||
return strings.Repeat("*", len(key)-4) + key[len(key)-4:]
|
||||
}
|
||||
var ch store.Channel
|
||||
if err := h.a.DB.First(&ch, id).Error; err != nil {
|
||||
resp.Fail(c, http.StatusNotFound, "channel not found")
|
||||
return
|
||||
return key[:7] + "******" + key[len(key)-4:]
|
||||
}
|
||||
key, err := h.a.Enc.Decrypt(ch.APIKeyEnc)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key")
|
||||
return
|
||||
}
|
||||
url := strings.TrimRight(ch.BaseURL, "/") + "/v1/models"
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
resp2, err := client.Do(req)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadGateway, "failed to reach channel: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
resp.Fail(c, http.StatusBadGateway, "channel returned http "+strconv.Itoa(resp2.StatusCode))
|
||||
return
|
||||
}
|
||||
var list struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp2.Body).Decode(&list); err != nil {
|
||||
resp.Fail(c, http.StatusBadGateway, "failed to parse model list")
|
||||
return
|
||||
}
|
||||
if len(list.Data) == 0 {
|
||||
resp.Fail(c, http.StatusNotFound, "channel returned no models")
|
||||
return
|
||||
}
|
||||
|
||||
imported := 0
|
||||
err = h.a.DB.Transaction(func(tx *gorm.DB) error {
|
||||
for _, item := range list.Data {
|
||||
name := strings.TrimSpace(item.ID)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
var m store.Model
|
||||
if err := tx.Where("name = ?", name).FirstOrCreate(&m, store.Model{
|
||||
Name: name, DisplayName: name, Enabled: true,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// upsert 绑定(upstream_model 默认同名)
|
||||
var binding store.ChannelModelBinding
|
||||
err := tx.Where("channel_id = ? AND model_id = ?", ch.ID, m.ID).First(&binding).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
binding = store.ChannelModelBinding{ChannelID: ch.ID, ModelID: m.ID, UpstreamModel: name, Weight: 1}
|
||||
if err := tx.Create(&binding).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
imported++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to import models")
|
||||
return
|
||||
}
|
||||
resp.OK(c, gin.H{"imported": imported})
|
||||
}
|
||||
|
||||
var _ = clause.Assignments // 保留 gorm/clause 引用(后续定价批处理用)
|
||||
|
||||
func intOr(p *int, def int) int {
|
||||
if p == nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -11,38 +12,113 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AdminModels GET /api/v1/admin/models — 模型列表(含价格与渠道绑定)。
|
||||
// AdminModels GET /api/v1/admin/models — 模型列表(含价格、渠道绑定、定价/禁止状态)。
|
||||
func (h *Handler) AdminModels(c *gin.Context) {
|
||||
var ms []store.Model
|
||||
if err := h.a.DB.Order("sort ASC, id ASC").Find(&ms).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to load models")
|
||||
return
|
||||
}
|
||||
|
||||
// 全局模型限制策略
|
||||
allow, deny := h.modelPolicyConfig()
|
||||
|
||||
out := make([]gin.H, 0, len(ms))
|
||||
for _, m := range ms {
|
||||
var bindings []store.ChannelModelBinding
|
||||
h.a.DB.Preload("Channel").Where("model_id = ?", m.ID).Find(&bindings)
|
||||
chs := make([]gin.H, 0, len(bindings))
|
||||
for _, b := range bindings {
|
||||
if !b.Channel.Enabled {
|
||||
continue
|
||||
}
|
||||
chs = append(chs, gin.H{
|
||||
"id": b.ID, "channel_id": b.ChannelID, "channel_name": b.Channel.Name,
|
||||
"upstream_model": b.UpstreamModel, "weight": b.Weight,
|
||||
})
|
||||
}
|
||||
used := len(chs) > 0
|
||||
needsPricing := used && m.InputPrice == 0 && m.OutputPrice == 0 && m.CacheReadPrice == 0
|
||||
denied := containsStr(deny, m.Name) || (len(allow) > 0 && !containsStr(allow, m.Name))
|
||||
out = append(out, gin.H{
|
||||
"id": m.ID, "name": m.Name, "display_name": m.DisplayName,
|
||||
"id": m.ID, "name": m.Name,
|
||||
"input_price": m.InputPrice, "output_price": m.OutputPrice, "cache_read_price": m.CacheReadPrice,
|
||||
"enabled": m.Enabled, "sort": m.Sort, "channels": chs,
|
||||
"used": used, "needs_pricing": needsPricing, "denied": denied,
|
||||
})
|
||||
}
|
||||
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
|
||||
func (h *Handler) AdminCreateModel(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=128"`
|
||||
DisplayName string `json:"display_name"`
|
||||
InputPrice float64 `json:"input_price"`
|
||||
OutputPrice float64 `json:"output_price"`
|
||||
CacheReadPrice float64 `json:"cache_read_price"`
|
||||
@@ -53,13 +129,10 @@ func (h *Handler) AdminCreateModel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
m := store.Model{
|
||||
Name: req.Name, DisplayName: req.DisplayName,
|
||||
Name: req.Name,
|
||||
InputPrice: req.InputPrice, OutputPrice: req.OutputPrice, CacheReadPrice: req.CacheReadPrice,
|
||||
Enabled: boolOr(req.Enabled, true),
|
||||
}
|
||||
if m.DisplayName == "" {
|
||||
m.DisplayName = m.Name
|
||||
}
|
||||
if err := h.a.DB.Create(&m).Error; err != nil {
|
||||
resp.Fail(c, http.StatusConflict, "failed to create model (name may already exist)")
|
||||
return
|
||||
@@ -67,7 +140,7 @@ func (h *Handler) AdminCreateModel(c *gin.Context) {
|
||||
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) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -75,7 +148,6 @@ func (h *Handler) AdminUpdateModel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DisplayName *string `json:"display_name"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
@@ -92,9 +164,6 @@ func (h *Handler) AdminUpdateModel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
updates := map[string]any{}
|
||||
if req.DisplayName != nil {
|
||||
updates["display_name"] = *req.DisplayName
|
||||
}
|
||||
if req.InputPrice != nil {
|
||||
updates["input_price"] = *req.InputPrice
|
||||
}
|
||||
@@ -139,6 +208,29 @@ func (h *Handler) AdminDeleteModel(c *gin.Context) {
|
||||
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
|
||||
func (h *Handler) AdminCreateModelBinding(c *gin.Context) {
|
||||
modelID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
|
||||
@@ -90,11 +90,17 @@ func (h *Handler) AdminUsage(c *gin.Context) {
|
||||
for _, l := range logs {
|
||||
var uname string
|
||||
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{
|
||||
"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,
|
||||
"cache_read_tokens": l.CacheReadTokens, "cost": l.Cost,
|
||||
"latency_ms": l.LatencyMS, "status": l.Status, "error_code": l.ErrorCode,
|
||||
"raw_request": l.RawRequest, "raw_response": l.RawResponse,
|
||||
"created_at": l.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,18 +8,24 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/openteam/server/internal/app"
|
||||
"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/proxy"
|
||||
"github.com/openteam/server/internal/store"
|
||||
)
|
||||
|
||||
// Handler 聚合所有管理 API。
|
||||
type Handler struct {
|
||||
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})
|
||||
}
|
||||
|
||||
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
|
||||
func (h *Handler) Me(c *gin.Context) {
|
||||
u := sessionUser(c)
|
||||
@@ -189,6 +225,8 @@ func (h *Handler) publicUser(u *store.User) gin.H {
|
||||
"role": u.Role,
|
||||
"balance": u.Balance,
|
||||
"status": u.Status,
|
||||
"allowed_models": u.AllowedModels,
|
||||
"denied_models": u.DeniedModels,
|
||||
"created_at": u.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -135,9 +136,6 @@ func (h *Handler) PatchKey(c *gin.Context) {
|
||||
if req.QuotaRequestsPerDay != nil {
|
||||
updates["quota_requests_per_day"] = *req.QuotaRequestsPerDay
|
||||
}
|
||||
if req.AllowedModels != nil {
|
||||
updates["allowed_models"] = *req.AllowedModels
|
||||
}
|
||||
if req.Status != nil {
|
||||
if *req.Status != store.KeyStatusActive && *req.Status != store.KeyStatusRevoked {
|
||||
resp.Fail(c, http.StatusBadRequest, "status must be active or revoked")
|
||||
@@ -151,10 +149,18 @@ func (h *Handler) PatchKey(c *gin.Context) {
|
||||
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})
|
||||
}
|
||||
|
||||
// DeleteKey DELETE /api/v1/keys/:id — 吊销。
|
||||
// DeleteKey DELETE /api/v1/keys/:id — 删除(硬删,立即失效不可恢复)。
|
||||
func (h *Handler) DeleteKey(c *gin.Context) {
|
||||
u, ok := userFromContext(c)
|
||||
if !ok {
|
||||
@@ -166,11 +172,9 @@ func (h *Handler) DeleteKey(c *gin.Context) {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid key id")
|
||||
return
|
||||
}
|
||||
res := h.a.DB.Model(&store.APIKey{}).
|
||||
Where("id = ? AND user_id = ?", id, u.ID).
|
||||
Update("status", store.KeyStatusRevoked)
|
||||
res := h.a.DB.Where("id = ? AND user_id = ?", id, u.ID).Delete(&store.APIKey{})
|
||||
if res.Error != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to revoke key")
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to delete key")
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"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.Use(gin.Logger(), gin.Recovery(), middleware.CORS())
|
||||
|
||||
h := NewHandler(a)
|
||||
h := NewHandler(a, gw, a.Passkeys)
|
||||
|
||||
// --- 代理端点(对外)---
|
||||
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("/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) {
|
||||
if len(c.Request.URL.Path) >= 3 && c.Request.URL.Path[:3] == "/v1" {
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/v1") {
|
||||
gw.Auth(c)
|
||||
if !c.IsAborted() {
|
||||
gw.Handle(c)
|
||||
}
|
||||
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"})
|
||||
})
|
||||
|
||||
@@ -50,9 +71,20 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
|
||||
auth.POST("/login", h.Login)
|
||||
auth.POST("/refresh", h.Refresh)
|
||||
auth.POST("/logout", h.Logout)
|
||||
auth.POST("/password", middleware.SessionAuth(a), h.ChangePassword)
|
||||
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.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.DELETE("/channels/:id", h.AdminDeleteChannel)
|
||||
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.DELETE("/models/unused", h.AdminDeleteUnusedModels)
|
||||
admin.POST("/models", h.AdminCreateModel)
|
||||
admin.PUT("/models/:id", h.AdminUpdateModel)
|
||||
admin.DELETE("/models/:id", h.AdminDeleteModel)
|
||||
|
||||
@@ -49,15 +49,22 @@ func (h *Handler) todayUsage(c *gin.Context, userID uint64) gin.H {
|
||||
}
|
||||
|
||||
// UserModels GET /api/v1/user/models — 控制台可用模型列表(无需 API Key)。
|
||||
// 仅返回启用的模型且至少绑定到一个启用且健康的渠道,与 /v1/models 口径一致。
|
||||
func (h *Handler) UserModels(c *gin.Context) {
|
||||
var ms []store.Model
|
||||
if err := h.a.DB.Where("enabled = ?", true).Order("sort ASC, id ASC").Find(&ms).Error; err != nil {
|
||||
var names []string
|
||||
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")
|
||||
return
|
||||
}
|
||||
out := make([]string, 0, len(ms))
|
||||
for _, m := range ms {
|
||||
out = append(out, m.Name)
|
||||
if names == nil {
|
||||
names = []string{}
|
||||
}
|
||||
resp.OK(c, gin.H{"items": out})
|
||||
resp.OK(c, gin.H{"items": names})
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ import (
|
||||
|
||||
"github.com/openteam/server/internal/channel"
|
||||
"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/jwt"
|
||||
"github.com/openteam/server/internal/pkg/ratelimit"
|
||||
"github.com/openteam/server/internal/store"
|
||||
"github.com/openteam/server/internal/usage"
|
||||
"gorm.io/gorm"
|
||||
@@ -23,6 +25,8 @@ type App struct {
|
||||
JWT *jwt.Manager
|
||||
Usage *usage.Recorder
|
||||
Health *channel.HealthMonitor
|
||||
Limit *ratelimit.Limiter
|
||||
Passkeys *passkey.Service
|
||||
startedAt time.Time
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
@@ -44,6 +48,7 @@ func New(cfg *config.Config) (*App, error) {
|
||||
}
|
||||
a.ctx, a.cancel = context.WithCancel(context.Background())
|
||||
a.Usage = usage.NewRecorder(db)
|
||||
a.Limit = ratelimit.New()
|
||||
|
||||
if err := a.Seed(); err != nil {
|
||||
return nil, err
|
||||
@@ -54,6 +59,13 @@ func New(cfg *config.Config) (*App, error) {
|
||||
FailThreshold: cfg.Proxy.HealthFailThreshold,
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
@@ -97,6 +109,7 @@ func (a *App) Seed() error {
|
||||
ch := store.Channel{
|
||||
Name: a.Cfg.Proxy.DefaultChannelName,
|
||||
Provider: store.ChannelProviderOpenAI,
|
||||
Formats: []string{store.FormatChat, store.FormatResponses},
|
||||
BaseURL: a.Cfg.Proxy.UpstreamBaseURL,
|
||||
APIKeyEnc: enc,
|
||||
Weight: 1,
|
||||
@@ -112,7 +125,6 @@ func (a *App) Seed() error {
|
||||
// 默认模型 + 绑定
|
||||
m := store.Model{
|
||||
Name: a.Cfg.Proxy.DefaultModel,
|
||||
DisplayName: a.Cfg.Proxy.DefaultModel,
|
||||
InputPrice: 0.15, // 每百万 token,示例价
|
||||
OutputPrice: 0.60,
|
||||
Enabled: true,
|
||||
|
||||
@@ -27,69 +27,88 @@ func NewService(db *gorm.DB, enc *crypto.Encryptor) *Service {
|
||||
return &Service{db: db, enc: enc, sems: map[uint64]chan struct{}{}}
|
||||
}
|
||||
|
||||
// Candidate 一个候选渠道 + 该模型的映射关系。
|
||||
type Candidate struct {
|
||||
Channel *store.Channel
|
||||
UpstreamModel string // 全局模型在此渠道的映射名(无绑定则为空,用客户端模型名)
|
||||
}
|
||||
|
||||
// Candidates 返回可用渠道候选:健康 + 启用,按优先级、权重降序、id 升序排列。
|
||||
// model 非空时优先取绑定该模型的渠道;无绑定则退回全局。
|
||||
func (s *Service) Candidates(model string) []*store.Channel {
|
||||
// model 非空时优先取绑定该模型的渠道(携带 upstream_model 映射);无绑定则退回全局。
|
||||
func (s *Service) Candidates(model string) []Candidate {
|
||||
if model != "" {
|
||||
var b []store.ChannelModelBinding
|
||||
var modelIDs []uint64
|
||||
s.db.Model(&store.Model{}).Where("name = ? AND enabled = ?", model, true).Pluck("id", &modelIDs)
|
||||
if len(modelIDs) > 0 {
|
||||
s.db.Where("model_id IN ?", modelIDs).Find(&b)
|
||||
chs := s.loadBound(b)
|
||||
if len(chs) > 0 {
|
||||
return chs
|
||||
if cands := s.loadBound(b); len(cands) > 0 {
|
||||
return cands
|
||||
}
|
||||
}
|
||||
}
|
||||
var chs []store.Channel
|
||||
s.db.Where("enabled = ? AND health_status = ?", true, store.ChannelHealthHealthy).
|
||||
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 {
|
||||
out = append(out, &chs[i])
|
||||
out = append(out, Candidate{Channel: &chs[i]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// loadBound 按绑定顺序加载渠道,过滤健康/启用。
|
||||
func (s *Service) loadBound(bindings []store.ChannelModelBinding) []*store.Channel {
|
||||
// loadBound 按绑定顺序加载渠道候选,过滤健康/启用,携带 upstream_model 映射。
|
||||
func (s *Service) loadBound(bindings []store.ChannelModelBinding) []Candidate {
|
||||
if len(bindings) == 0 {
|
||||
return nil
|
||||
}
|
||||
// channel_id -> 绑定(取该渠道对该模型的映射)
|
||||
byChannel := map[uint64]store.ChannelModelBinding{}
|
||||
ids := make([]uint64, 0, len(bindings))
|
||||
seen := map[uint64]bool{}
|
||||
for _, b := range bindings {
|
||||
if !seen[b.ChannelID] {
|
||||
seen[b.ChannelID] = true
|
||||
if _, ok := byChannel[b.ChannelID]; !ok {
|
||||
ids = append(ids, b.ChannelID)
|
||||
}
|
||||
byChannel[b.ChannelID] = b
|
||||
}
|
||||
var chs []store.Channel
|
||||
s.db.Where("id IN ? AND enabled = ? AND health_status = ?", ids, true, store.ChannelHealthHealthy).
|
||||
Order("priority ASC, weight DESC, id ASC").Find(&chs)
|
||||
// 保持绑定顺序
|
||||
byID := map[uint64]*store.Channel{}
|
||||
for i := range chs {
|
||||
byID[chs[i].ID] = &chs[i]
|
||||
}
|
||||
out := make([]*store.Channel, 0, len(ids))
|
||||
out := make([]Candidate, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if ch, ok := byID[id]; ok {
|
||||
out = append(out, ch)
|
||||
out = append(out, Candidate{Channel: ch, UpstreamModel: byChannel[id].UpstreamModel})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Pick 按权重加权随机选一个候选(负载均衡)。
|
||||
func (s *Service) Pick(cands []*store.Channel) *store.Channel {
|
||||
// AvailableModelIDs 返回对外可见的模型 ID:启用的模型且至少绑定到一个启用且健康的渠道。
|
||||
// 与 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 {
|
||||
return nil
|
||||
}
|
||||
total := 0
|
||||
for _, c := range cands {
|
||||
w := c.Weight
|
||||
w := c.Channel.Weight
|
||||
if w <= 0 {
|
||||
w = 1
|
||||
}
|
||||
@@ -98,16 +117,16 @@ func (s *Service) Pick(cands []*store.Channel) *store.Channel {
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(int64(total)))
|
||||
acc := 0
|
||||
for _, c := range cands {
|
||||
w := c.Weight
|
||||
w := c.Channel.Weight
|
||||
if w <= 0 {
|
||||
w = 1
|
||||
}
|
||||
acc += w
|
||||
if int(n.Int64()) < acc {
|
||||
return c
|
||||
return c.Channel
|
||||
}
|
||||
}
|
||||
return cands[len(cands)-1]
|
||||
return cands[len(cands)-1].Channel
|
||||
}
|
||||
|
||||
// TryAcquire 尝试获取渠道并发槽;渠道满载返回 false(调用方可溢出到其他渠道)。
|
||||
|
||||
@@ -54,8 +54,8 @@ func TestCandidatesFiltersUnhealthy(t *testing.T) {
|
||||
if len(cands) != 2 {
|
||||
t.Fatalf("candidates = %d, want 2", len(cands))
|
||||
}
|
||||
if cands[0].Name != "a" {
|
||||
t.Fatalf("first by priority should be a, got %s", cands[0].Name)
|
||||
if cands[0].Channel.Name != "a" {
|
||||
t.Fatalf("first by priority should be a, got %s", cands[0].Channel.Name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -83,7 +82,7 @@ func (h *HealthMonitor) probe(ch *store.Channel) {
|
||||
h.record(ch, false)
|
||||
return
|
||||
}
|
||||
url := strings.TrimRight(ch.BaseURL, "/") + "/v1/models"
|
||||
url := ch.UpstreamURL("", "/models")
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -17,9 +17,23 @@ type Config struct {
|
||||
JWT JWTConfig
|
||||
Auth AuthConfig
|
||||
Proxy ProxyConfig
|
||||
RateLimit RateLimitConfig
|
||||
WebAuthn WebAuthnConfig
|
||||
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 {
|
||||
Driver string // sqlite | postgres
|
||||
DSN string
|
||||
@@ -52,6 +66,7 @@ type ProxyConfig struct {
|
||||
Timeout time.Duration
|
||||
HealthInterval time.Duration // 渠道健康检查周期
|
||||
HealthFailThreshold int // 连续失败 N 次进 cooldown
|
||||
LogRaw bool // 记录管理员原始请求体+响应到 usage_logs(调试用,默认关)
|
||||
}
|
||||
|
||||
// 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_key", "")
|
||||
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_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{
|
||||
Env: v.GetString("env"),
|
||||
@@ -148,6 +170,15 @@ func Load() (*Config, error) {
|
||||
Timeout: v.GetDuration("proxy.timeout"),
|
||||
HealthInterval: v.GetDuration("proxy.health_interval"),
|
||||
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"),
|
||||
}, nil
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,二次取应失败")
|
||||
}
|
||||
}
|
||||
@@ -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)。
|
||||
package apikey
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
const (
|
||||
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
keyLen = 48
|
||||
prefix = "sk-"
|
||||
prefix = "sk-ot-"
|
||||
)
|
||||
|
||||
// Generate 生成明文 key(仅创建时展示一次)与哈希、前缀。
|
||||
@@ -34,7 +34,7 @@ func Hash(key string) string {
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// Prefix 展示前缀:sk-aB3cD5…(前 12 字符)
|
||||
// Prefix 展示前缀:sk-ot-aB3c…(前 12 字符)
|
||||
func Prefix(key string) string {
|
||||
if len(key) <= 12 {
|
||||
return key
|
||||
|
||||
@@ -3,32 +3,35 @@ package apikey
|
||||
import "testing"
|
||||
|
||||
func TestGenerateValid(t *testing.T) {
|
||||
plain, hash, prefix, err := Generate()
|
||||
plain, hash, keyPrefix, err := Generate()
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
if !Valid(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))
|
||||
}
|
||||
if keyPrefix != plain[:12] {
|
||||
t.Fatalf("prefix mismatch: %s vs %s", keyPrefix, plain[:12])
|
||||
}
|
||||
if Hash(plain) != hash {
|
||||
t.Fatal("hash mismatch")
|
||||
}
|
||||
if prefix != plain[:12] {
|
||||
t.Fatalf("prefix mismatch: %s vs %s", prefix, plain[:12])
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if Valid(c) {
|
||||
t.Fatalf("expected invalid: %q", c)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 图片内容块转换(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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,9 +137,11 @@ func chatMsgToAnthropic(m chatMsg) any {
|
||||
var url string
|
||||
if iu, ok := b["image_url"].(map[string]any); ok {
|
||||
url, _ = iu["url"].(string)
|
||||
} else if s, ok := b["image_url"].(string); ok {
|
||||
url = s
|
||||
}
|
||||
if url != "" {
|
||||
blocks = append(blocks, 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
|
||||
if json.Unmarshal(content, &blocks) == nil && blocks != nil {
|
||||
var out []any
|
||||
var toolMsgs []any // tool_result 单独收集,保证排在 assistant(tool_calls) 之后
|
||||
var textParts []string
|
||||
var contentBlocks []any // text / image_url 块,保留原始顺序
|
||||
var toolCalls []any
|
||||
for _, b := range blocks {
|
||||
switch b["type"] {
|
||||
case "text":
|
||||
if t, _ := b["text"].(string); t != "" {
|
||||
textParts = append(textParts, t)
|
||||
contentBlocks = append(contentBlocks, map[string]any{"type": "text", "text": t})
|
||||
}
|
||||
case "image":
|
||||
if cb := chatImageBlock(b); cb != nil {
|
||||
contentBlocks = append(contentBlocks, cb)
|
||||
}
|
||||
case "tool_use":
|
||||
id, _ := b["id"].(string)
|
||||
@@ -251,12 +260,22 @@ func anthropicMsgToChat(role string, content json.RawMessage) []any {
|
||||
case "tool_result":
|
||||
callID, _ := b["tool_use_id"].(string)
|
||||
res := strField(b["content"])
|
||||
out = append(out, map[string]any{"role": "tool", "tool_call_id": callID, "content": res})
|
||||
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}
|
||||
if len(textParts) > 0 {
|
||||
switch {
|
||||
case hasImage:
|
||||
msg["content"] = contentBlocks
|
||||
case len(textParts) > 0:
|
||||
msg["content"] = strings.Join(textParts, "")
|
||||
}
|
||||
if len(toolCalls) > 0 {
|
||||
@@ -264,6 +283,7 @@ func anthropicMsgToChat(role string, content json.RawMessage) []any {
|
||||
}
|
||||
out = append(out, msg)
|
||||
}
|
||||
out = append(out, toolMsgs...)
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
@@ -416,6 +436,58 @@ func intOrNil(p *int, def int) any {
|
||||
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 {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return map[string]any{}
|
||||
|
||||
@@ -101,20 +101,82 @@ func responsesInputToChat(raw json.RawMessage) []any {
|
||||
out = append(out, map[string]any{"role": role, "content": content})
|
||||
} else if blocks, ok := item["content"].([]any); ok {
|
||||
var text []string
|
||||
var contentBlocks []any
|
||||
for _, b := range blocks {
|
||||
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 != "" {
|
||||
text = append(text, t)
|
||||
contentBlocks = append(contentBlocks, map[string]any{"type": "text", "text": t})
|
||||
}
|
||||
case "input_image":
|
||||
var url string
|
||||
if s, ok := bm["image_url"].(string); ok {
|
||||
url = s
|
||||
} else if m, ok := bm["image_url"].(map[string]any); ok {
|
||||
url, _ = m["url"].(string)
|
||||
}
|
||||
if url != "" {
|
||||
contentBlocks = append(contentBlocks, map[string]any{"type": "image_url", "image_url": map[string]any{"url": url}})
|
||||
}
|
||||
}
|
||||
}
|
||||
hasImage := false
|
||||
for _, cb := range contentBlocks {
|
||||
if m, _ := cb.(map[string]any); m["type"] == "image_url" {
|
||||
hasImage = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasImage {
|
||||
out = append(out, map[string]any{"role": role, "content": contentBlocks})
|
||||
} else {
|
||||
out = append(out, map[string]any{"role": role, "content": strings.Join(text, "")})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// chatContentToResponsesBlocks 把 Chat 用户消息 content 转 Responses input 块数组(input_text / input_image)。
|
||||
func chatContentToResponsesBlocks(content json.RawMessage) []any {
|
||||
// 纯字符串 → 单个 input_text
|
||||
var s string
|
||||
if json.Unmarshal(content, &s) == nil && s != "" {
|
||||
return []any{map[string]any{"type": "input_text", "text": s}}
|
||||
}
|
||||
// 数组 → 按块转换(text / image_url)
|
||||
var arr []map[string]any
|
||||
if json.Unmarshal(content, &arr) == nil && arr != nil {
|
||||
var out []any
|
||||
for _, b := range arr {
|
||||
switch b["type"] {
|
||||
case "text", "input_text":
|
||||
if t, _ := b["text"].(string); t != "" {
|
||||
out = append(out, map[string]any{"type": "input_text", "text": t})
|
||||
}
|
||||
case "image_url":
|
||||
var url string
|
||||
if iu, ok := b["image_url"].(map[string]any); ok {
|
||||
url, _ = iu["url"].(string)
|
||||
} else if s, ok := b["image_url"].(string); ok {
|
||||
url = s
|
||||
}
|
||||
if url != "" {
|
||||
out = append(out, map[string]any{"type": "input_image", "image_url": url})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 请求:Chat → Responses
|
||||
|
||||
@@ -170,21 +232,17 @@ func chatToResponsesReq(body []byte) ([]byte, error) {
|
||||
}})
|
||||
}
|
||||
default:
|
||||
if s := str(m.Content); s != "" {
|
||||
input = append(input, map[string]any{"type": "message", "role": "user", "content": []any{
|
||||
map[string]any{"type": "input_text", "text": s},
|
||||
}})
|
||||
if blocks := chatContentToResponsesBlocks(m.Content); len(blocks) > 0 {
|
||||
input = append(input, map[string]any{"type": "message", "role": "user", "content": blocks})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(system) > 0 {
|
||||
out["instructions"] = strings.Join(system, "\n")
|
||||
}
|
||||
if len(input) == 1 {
|
||||
out["input"] = input[0] // 单条消息项
|
||||
} else {
|
||||
// input 必须是数组:部分上游(如火山方舟)只接受 []*InputItem,
|
||||
// 单对象会被拒(400 Mismatch type)。
|
||||
out["input"] = input
|
||||
}
|
||||
|
||||
if len(req.Tools) > 0 {
|
||||
tools := make([]any, 0, len(req.Tools))
|
||||
@@ -245,8 +303,11 @@ func responsesToChatResp(body []byte) ([]byte, error) {
|
||||
msg["tool_calls"] = toolCalls
|
||||
}
|
||||
finish := "stop"
|
||||
if string(rawJSON(m, "status")) == `"incomplete"` {
|
||||
finish = "length"
|
||||
switch {
|
||||
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
|
||||
if u := rawJSON(m, "usage"); u != nil {
|
||||
|
||||
@@ -11,16 +11,17 @@ type sseState struct {
|
||||
}
|
||||
|
||||
// parseLine 解析一行 SSE;返回是否 data 行及其内容、是否 [DONE]。
|
||||
// data: 后可跟空格(标准)或紧贴 JSON(上游如火山方舟会省略空格)。
|
||||
func (s *sseState) parseLine(line []byte) (isData bool, data string, done bool) {
|
||||
str := strings.TrimRight(string(line), "\r\n")
|
||||
switch {
|
||||
case strings.HasPrefix(str, "event: "):
|
||||
s.event = strings.TrimSpace(strings.TrimPrefix(str, "event: "))
|
||||
return false, "", false
|
||||
case str == "data: [DONE]":
|
||||
case str == "data: [DONE]" || str == "data:[DONE]":
|
||||
return true, "[DONE]", true
|
||||
case strings.HasPrefix(str, "data:"):
|
||||
return true, strings.TrimPrefix(str, "data: "), false
|
||||
return true, strings.TrimLeft(strings.TrimPrefix(str, "data:"), " "), false
|
||||
default:
|
||||
return false, "", false
|
||||
}
|
||||
@@ -49,9 +50,11 @@ func eventLine(name string, obj any) []byte {
|
||||
type messagesToChat struct {
|
||||
sseState
|
||||
id, model string
|
||||
toolIdx map[int]int // messages content block index → chat tool_calls index(顺序编号,避开文本块)
|
||||
nextTool int
|
||||
}
|
||||
|
||||
func newMessagesToChat() *messagesToChat { return &messagesToChat{} }
|
||||
func newMessagesToChat() *messagesToChat { return &messagesToChat{toolIdx: map[int]int{}} }
|
||||
|
||||
func (t *messagesToChat) line(line []byte) []byte {
|
||||
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,
|
||||
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"role": "assistant"}, "finish_reason": nil}},
|
||||
})
|
||||
case "content_block_start":
|
||||
cb, _ := m["content_block"].(map[string]any)
|
||||
if cb == nil || cb["type"] != "tool_use" {
|
||||
return nil
|
||||
}
|
||||
blockIdx, _ := m["index"].(float64)
|
||||
tool := t.nextTool
|
||||
t.nextTool++
|
||||
t.toolIdx[int(blockIdx)] = tool
|
||||
toolID, _ := cb["id"].(string)
|
||||
name, _ := cb["name"].(string)
|
||||
return dataLine(map[string]any{
|
||||
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
|
||||
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{
|
||||
"tool_calls": []any{map[string]any{"index": tool, "id": toolID, "type": "function", "function": map[string]any{"name": name, "arguments": ""}}},
|
||||
}, "finish_reason": nil}},
|
||||
})
|
||||
case "content_block_delta":
|
||||
delta, _ := m["delta"].(map[string]any)
|
||||
deltaType, _ := delta["type"].(string)
|
||||
if deltaType == "input_json_delta" {
|
||||
blockIdx, _ := m["index"].(float64)
|
||||
tool, ok := t.toolIdx[int(blockIdx)]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
partial, _ := delta["partial_json"].(string)
|
||||
if partial == "" {
|
||||
return nil
|
||||
}
|
||||
return dataLine(map[string]any{
|
||||
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
|
||||
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{
|
||||
"tool_calls": []any{map[string]any{"index": tool, "function": map[string]any{"arguments": partial}}},
|
||||
}, "finish_reason": nil}},
|
||||
})
|
||||
}
|
||||
text, _ := delta["text"].(string)
|
||||
if text == "" {
|
||||
return nil
|
||||
@@ -118,14 +156,19 @@ func joinLines(lines [][]byte) []byte {
|
||||
|
||||
type chatToMessages struct {
|
||||
sseState
|
||||
started bool
|
||||
blockStarted bool
|
||||
started bool // message_start 已发出
|
||||
nextIndex int // 下一个 content block index(顺序分配)
|
||||
textIndex int // 文本块 index;-1 = 未开始
|
||||
toolIdx map[int]int // chat delta.tool_calls[].index → messages block index
|
||||
openBlocks []int // 已开始未停止的 block index,按开始顺序
|
||||
model string
|
||||
stopReason string
|
||||
usage any
|
||||
}
|
||||
|
||||
func newChatToMessages() *chatToMessages { return &chatToMessages{} }
|
||||
func newChatToMessages() *chatToMessages {
|
||||
return &chatToMessages{textIndex: -1, toolIdx: map[int]int{}}
|
||||
}
|
||||
|
||||
func (t *chatToMessages) line(line []byte) []byte {
|
||||
isData, data, done := t.parseLine(line)
|
||||
@@ -133,18 +176,18 @@ func (t *chatToMessages) line(line []byte) []byte {
|
||||
return nil
|
||||
}
|
||||
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{
|
||||
"stop_reason": stopReasonOrEnd(t.stopReason), "stop_sequence": nil,
|
||||
}}
|
||||
if t.usage != nil {
|
||||
md["usage"] = t.usage
|
||||
}
|
||||
var out [][]byte
|
||||
out = append(out, eventLine("message_delta", md))
|
||||
if t.blockStarted {
|
||||
out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": 0}))
|
||||
}
|
||||
out = append(out, eventLine("message_stop", map[string]any{"type": "message_stop"}))
|
||||
return joinLines(out)
|
||||
}
|
||||
@@ -167,11 +210,12 @@ func (t *chatToMessages) line(line []byte) []byte {
|
||||
id, _ := m["id"].(string)
|
||||
|
||||
var out [][]byte
|
||||
// 首个包含内容或角色的块前,先发 message_start + content_block_start
|
||||
if !t.started {
|
||||
role, _ := delta["role"].(string)
|
||||
content, _ := delta["content"].(string)
|
||||
if role == "assistant" || content != "" {
|
||||
// message_start 只在实际有内容(文本或工具)时发出,避免 GLM 等模型的
|
||||
// reasoning_content 块(带 role 无 content)提前开出一个空文本块。
|
||||
ensureStarted := func() {
|
||||
if t.started {
|
||||
return
|
||||
}
|
||||
t.started = true
|
||||
out = append(out, eventLine("message_start", map[string]any{
|
||||
"type": "message_start",
|
||||
@@ -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},
|
||||
},
|
||||
}))
|
||||
out = append(out, eventLine("content_block_start", map[string]any{
|
||||
"type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""},
|
||||
}))
|
||||
t.blockStarted = true
|
||||
}
|
||||
}
|
||||
if content, _ := delta["content"].(string); content != "" {
|
||||
if !t.started {
|
||||
t.started = true
|
||||
t.blockStarted = true
|
||||
out = append(out, eventLine("message_start", map[string]any{
|
||||
"type": "message_start",
|
||||
"message": map[string]any{"id": "msg_" + strings.TrimPrefix(id, "chatcmpl-"), "type": "message", "role": "assistant", "model": t.model, "content": []any{}},
|
||||
}))
|
||||
|
||||
// 文本:delta.content(string;兼容 {type:text,text} 数组)
|
||||
if content := deltaText(delta); content != "" {
|
||||
if t.textIndex < 0 {
|
||||
t.textIndex = t.nextIndex
|
||||
t.nextIndex++
|
||||
ensureStarted()
|
||||
out = append(out, eventLine("content_block_start", map[string]any{
|
||||
"type": "content_block_start", "index": 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{
|
||||
"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 {
|
||||
t.usage = u
|
||||
}
|
||||
return joinLines(out)
|
||||
}
|
||||
|
||||
// deltaText 取 chat delta.content 文本(string 或 [{type:text,text}] 数组拼接)。
|
||||
func deltaText(delta map[string]any) string {
|
||||
if s, ok := delta["content"].(string); ok {
|
||||
return s
|
||||
}
|
||||
if arr, ok := delta["content"].([]any); ok {
|
||||
var parts []string
|
||||
for _, b := range arr {
|
||||
if bm, ok := b.(map[string]any); ok {
|
||||
if t, _ := bm["text"].(string); t != "" {
|
||||
parts = append(parts, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func stopReasonOrEnd(s string) string {
|
||||
if s == "" {
|
||||
return "end_turn"
|
||||
@@ -223,9 +316,16 @@ type responsesToMessages struct {
|
||||
started bool
|
||||
model string
|
||||
usage any
|
||||
nextIndex int // 下一个 content block index(顺序分配)
|
||||
textIndex int // 文本块 index;-1 = 未开始
|
||||
toolIdx map[string]int // function_call item_id → messages block index
|
||||
openBlocks []int // 已开始未停止的 block index,按开始顺序
|
||||
anyTool bool
|
||||
}
|
||||
|
||||
func newResponsesToMessages() *responsesToMessages { return &responsesToMessages{} }
|
||||
func newResponsesToMessages() *responsesToMessages {
|
||||
return &responsesToMessages{textIndex: -1, toolIdx: map[string]int{}}
|
||||
}
|
||||
|
||||
func (t *responsesToMessages) line(line []byte) []byte {
|
||||
isData, data, done := t.parseLine(line)
|
||||
@@ -243,14 +343,16 @@ func (t *responsesToMessages) line(line []byte) []byte {
|
||||
}
|
||||
}
|
||||
var out [][]byte
|
||||
switch evt {
|
||||
case "response.created":
|
||||
if !t.started {
|
||||
// message_start 只在 response.created 时发出;文本/工具块在对应事件到达时再开,
|
||||
// 避免纯函数调用响应提前开出一个空文本块。
|
||||
ensureStarted := func() {
|
||||
if t.started {
|
||||
return
|
||||
}
|
||||
t.started = true
|
||||
id, _ := m["response"].(map[string]any)
|
||||
rid := ""
|
||||
if id != nil {
|
||||
rid, _ = id["id"].(string)
|
||||
if resp, ok := m["response"].(map[string]any); ok {
|
||||
rid, _ = resp["id"].(string)
|
||||
}
|
||||
out = append(out, eventLine("message_start", map[string]any{
|
||||
"type": "message_start",
|
||||
@@ -259,22 +361,75 @@ func (t *responsesToMessages) line(line []byte) []byte {
|
||||
"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":
|
||||
delta, _ := m["delta"].(string)
|
||||
if delta != "" {
|
||||
out = append(out, eventLine("content_block_delta", map[string]any{
|
||||
"type": "content_block_delta", "index": 0, "delta": map[string]any{"type": "text_delta", "text": delta},
|
||||
}))
|
||||
if delta == "" {
|
||||
return nil
|
||||
}
|
||||
case "response.completed":
|
||||
out = append(out, eventLine("message_delta", map[string]any{
|
||||
"type": "message_delta", "delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil},
|
||||
if t.textIndex < 0 {
|
||||
t.textIndex = t.nextIndex
|
||||
t.nextIndex++
|
||||
ensureStarted()
|
||||
out = append(out, eventLine("content_block_start", map[string]any{
|
||||
"type": "content_block_start", "index": t.textIndex, "content_block": map[string]any{"type": "text", "text": ""},
|
||||
}))
|
||||
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"}))
|
||||
}
|
||||
return joinLines(out)
|
||||
@@ -414,6 +569,7 @@ type chatToResponses struct {
|
||||
sseState
|
||||
model string
|
||||
usage any
|
||||
finishSeen bool
|
||||
done bool
|
||||
}
|
||||
|
||||
@@ -425,6 +581,7 @@ func (t *chatToResponses) line(line []byte) []byte {
|
||||
return nil
|
||||
}
|
||||
if done {
|
||||
// 流结束兜底:finish 后 usage 未随块到达时在此补发 completed
|
||||
if !t.done {
|
||||
t.done = true
|
||||
return eventLine("response.completed", map[string]any{
|
||||
@@ -453,6 +610,9 @@ func (t *chatToResponses) line(line []byte) []byte {
|
||||
finish, _ = c0["finish_reason"].(string)
|
||||
}
|
||||
}
|
||||
if finish != "" {
|
||||
t.finishSeen = true
|
||||
}
|
||||
var out [][]byte
|
||||
if role, _ := delta["role"].(string); role == "assistant" {
|
||||
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,
|
||||
}))
|
||||
}
|
||||
if finish != "" && !t.done {
|
||||
// 上游 usage 块(choices 为空)通常晚于 finish_reason:此时再发 completed,携带 usage
|
||||
if _, hasUsage := m["usage"]; hasUsage && t.finishSeen && !t.done {
|
||||
t.done = true
|
||||
out = append(out, eventLine("response.completed", map[string]any{
|
||||
"type": "response.completed",
|
||||
|
||||
@@ -3,15 +3,19 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/openteam/server/internal/channel"
|
||||
"github.com/openteam/server/internal/pkg/apikey"
|
||||
"github.com/openteam/server/internal/pkg/crypto"
|
||||
"github.com/openteam/server/internal/pkg/ratelimit"
|
||||
"github.com/openteam/server/internal/proxy/convert"
|
||||
"github.com/openteam/server/internal/store"
|
||||
"github.com/openteam/server/internal/usage"
|
||||
@@ -29,16 +33,92 @@ type Gateway struct {
|
||||
ch *channel.Service
|
||||
rec *usage.Recorder
|
||||
enc *crypto.Encryptor
|
||||
lim *ratelimit.Limiter
|
||||
userRPS int
|
||||
logRaw bool
|
||||
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{
|
||||
db: db,
|
||||
ch: channel.NewService(db, enc),
|
||||
rec: rec,
|
||||
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")
|
||||
key := strings.TrimPrefix(auth, "Bearer ")
|
||||
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) {
|
||||
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()
|
||||
return
|
||||
}
|
||||
@@ -81,6 +165,25 @@ func (g *Gateway) Auth(c *gin.Context) {
|
||||
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(CtxKeyID, k.ID)
|
||||
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) {
|
||||
ids := g.ch.AvailableModelIDs()
|
||||
if len(ids) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"object": "list", "data": []gin.H{}})
|
||||
return
|
||||
}
|
||||
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")
|
||||
return
|
||||
}
|
||||
@@ -123,8 +231,8 @@ func (g *Gateway) models(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"object": "list", "data": data})
|
||||
}
|
||||
|
||||
// candidateChannels 返回可用渠道候选(按模型绑定优先,退化全局)。
|
||||
func (g *Gateway) candidateChannels(model string) []*store.Channel {
|
||||
// candidateChannels 返回可用渠道候选(按模型绑定优先,退化全局;携带模型映射)。
|
||||
func (g *Gateway) candidateChannels(model string) []channel.Candidate {
|
||||
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 {
|
||||
switch proto {
|
||||
case convert.ProtoMessages:
|
||||
return "/v1/messages"
|
||||
return "/messages"
|
||||
case convert.ProtoResponses:
|
||||
return "/v1/responses"
|
||||
return "/responses"
|
||||
default:
|
||||
return "/v1/chat/completions"
|
||||
return "/chat/completions"
|
||||
}
|
||||
}
|
||||
|
||||
// upstreamPlan 描述一次代理请求的上游访问方式。
|
||||
type upstreamPlan struct {
|
||||
proto string // 上游协议(用于分协议 base_url)
|
||||
path string // 上游路径
|
||||
body []byte // 已转换的请求体
|
||||
lineConv func([]byte) []byte // 流式逐行转换(nil=直通)
|
||||
bodyConv func([]byte) ([]byte, error) // 非流式响应体转换(nil=直通)
|
||||
}
|
||||
|
||||
// prepareUpstream 计算上游访问计划:协议匹配直通,否则转换。
|
||||
func prepareUpstream(provider, clientProto string, body []byte) (*upstreamPlan, error) {
|
||||
up := upstreamProtoFor(provider, clientProto)
|
||||
plan := &upstreamPlan{path: upstreamPath(up), body: body}
|
||||
if up != clientProto {
|
||||
converted, err := convert.ConvertRequest(body, clientProto, up)
|
||||
// conversionTarget 决定客户端协议在渠道上的处理方式:
|
||||
// 渠道声明支持该协议则直通(返回原协议);否则转为其首选支持协议(chat > messages > responses)。
|
||||
func conversionTarget(formats []string, clientProto string) string {
|
||||
for _, f := range formats {
|
||||
if f == clientProto {
|
||||
return clientProto
|
||||
}
|
||||
}
|
||||
for _, p := range []string{convert.ProtoChat, convert.ProtoMessages, convert.ProtoResponses} {
|
||||
for _, f := range formats {
|
||||
if f == p {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, err
|
||||
}
|
||||
plan.body = converted
|
||||
plan.lineConv = convert.NewStreamTransformer(up, clientProto)
|
||||
plan.bodyConv = func(b []byte) ([]byte, error) { return convert.ConvertResponse(b, up, clientProto) }
|
||||
plan.lineConv = convert.NewStreamTransformer(target, 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
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/openteam/server/internal/proxy/convert"
|
||||
"github.com/openteam/server/internal/store"
|
||||
)
|
||||
|
||||
// chatCompletions POST /v1/chat/completions
|
||||
@@ -23,6 +24,11 @@ func (g *Gateway) chatCompletions(c *gin.Context) {
|
||||
}
|
||||
c.Set("protocol", convert.ProtoChat)
|
||||
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)
|
||||
if len(cands) == 0 {
|
||||
@@ -51,6 +57,11 @@ func (g *Gateway) responses(c *gin.Context) {
|
||||
}
|
||||
c.Set("protocol", convert.ProtoResponses)
|
||||
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)
|
||||
if len(cands) == 0 {
|
||||
@@ -79,6 +90,11 @@ func (g *Gateway) messages(c *gin.Context) {
|
||||
}
|
||||
c.Set("protocol", convert.ProtoMessages)
|
||||
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)
|
||||
if len(cands) == 0 {
|
||||
@@ -96,6 +112,14 @@ type sinkHolder struct {
|
||||
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)。
|
||||
func apiError(c *gin.Context, status int, code, message string) {
|
||||
if p, _ := c.Get("protocol"); p == convert.ProtoMessages {
|
||||
|
||||
@@ -3,7 +3,6 @@ package proxy
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -15,6 +14,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/openteam/server/internal/channel"
|
||||
"github.com/openteam/server/internal/pkg/tokenizer"
|
||||
"github.com/openteam/server/internal/store"
|
||||
)
|
||||
|
||||
@@ -38,22 +39,155 @@ func parseBody(c *gin.Context) (*bodyReq, []byte, error) {
|
||||
return nil, nil, err
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(body))
|
||||
// 中断请求输入文本(token 估算用,仅存文本不立即分词)
|
||||
c.Set("est_input_text", requestText(body))
|
||||
br := &bodyReq{}
|
||||
_ = json.Unmarshal(body, br) // 解析失败按空处理,直通仍可转发
|
||||
return br, body, nil
|
||||
}
|
||||
|
||||
// upstreamURL 组装上游地址:base_url + 路径。
|
||||
func upstreamURL(ch *store.Channel, path string) string {
|
||||
return strings.TrimRight(ch.BaseURL, "/") + path
|
||||
// requestText 提取请求体中的用户输入文本(chat/messages 的 content、responses 的 input/instructions),
|
||||
// 用于中断时估算输入 token。
|
||||
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")
|
||||
}
|
||||
|
||||
// sseDataPayload 提取一条 SSE 的 JSON 载荷(去掉 data: 前缀与空白)。
|
||||
// 兼容三种写法:
|
||||
// - 单 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
|
||||
}
|
||||
}
|
||||
|
||||
// doProxy 通用代理(M5):遍历候选渠道,按需转换;可安全重试的失败自动故障转移。
|
||||
func (g *Gateway) doProxy(c *gin.Context, cands []*store.Channel, clientProto string, body []byte, stream bool, sink *usageSink) {
|
||||
var lastStatus = http.StatusBadGateway
|
||||
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 {
|
||||
lastStatus, lastBody = http.StatusInternalServerError, []byte("conversion error: "+err.Error())
|
||||
continue
|
||||
@@ -91,7 +225,7 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
|
||||
|
||||
upBody := plan.body
|
||||
// 直通 chat 流式:注入 stream_options.include_usage,保证末块带 usage(OpenAI 行为)
|
||||
if stream && plan.path == "/v1/chat/completions" && plan.lineConv == nil && !bytes.Contains(upBody, []byte(`"include_usage"`)) {
|
||||
if stream && plan.path == "/chat/completions" && plan.lineConv == nil && !bytes.Contains(upBody, []byte(`"include_usage"`)) {
|
||||
var m map[string]any
|
||||
if json.Unmarshal(upBody, &m) == nil {
|
||||
m["stream_options"] = map[string]any{"include_usage": true}
|
||||
@@ -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)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL(ch, plan.path), bytes.NewReader(upBody))
|
||||
req, err := http.NewRequest(http.MethodPost, upstreamURL(ch, plan.proto, plan.path), bytes.NewReader(upBody))
|
||||
if err != nil {
|
||||
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 != "" {
|
||||
req.Header.Set("User-Agent", ua)
|
||||
}
|
||||
if plan.path == "/v1/messages" {
|
||||
if plan.path == "/messages" {
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
}
|
||||
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()
|
||||
resp, err := g.hc.Do(req)
|
||||
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())
|
||||
}
|
||||
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)
|
||||
if _, ok := c.Get("raw_request"); ok {
|
||||
c.Set("raw_response", string(data)) // 上游原始响应(未转换)
|
||||
}
|
||||
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{}
|
||||
}
|
||||
|
||||
// 原始响应捕获:仅管理员且开关开启(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)
|
||||
for {
|
||||
line, err := scanner.Next()
|
||||
if line != nil {
|
||||
if capture {
|
||||
rawResp.Write(line)
|
||||
}
|
||||
out := line
|
||||
if lineConv != nil {
|
||||
out = lineConv(line)
|
||||
}
|
||||
if out != nil {
|
||||
if _, werr := w.Write(out); werr != nil {
|
||||
g.recordError(c, ch, nil, start, "client_disconnect")
|
||||
// 客户端意外断开:按已生成部分收费(canceled)
|
||||
commitRaw()
|
||||
g.finishUsage(c, ch, start, store.UsageStatusCanceled, "client_disconnect")
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
if sink != nil {
|
||||
sink.outputText += sseContentText(out)
|
||||
}
|
||||
}
|
||||
if usageRaw := scanUsage(line); usageRaw != nil && sink != nil {
|
||||
sink.push(usageRaw)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
commitRaw()
|
||||
if err == io.EOF {
|
||||
g.finishUsage(c, ch, start, store.UsageStatusSuccess, "")
|
||||
} else if c.Request.Context().Err() != nil {
|
||||
g.recordError(c, ch, nil, start, "client_disconnect")
|
||||
// 客户端意外断开:按已生成部分收费(canceled)
|
||||
g.finishUsage(c, ch, start, store.UsageStatusCanceled, "client_disconnect")
|
||||
} else {
|
||||
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 等)。
|
||||
func scanUsage(line []byte) json.RawMessage {
|
||||
s := string(line)
|
||||
if !strings.Contains(s, `"usage"`) {
|
||||
if !bytes.Contains(line, []byte(`"usage"`)) {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(s, "data: ") {
|
||||
s = strings.TrimPrefix(s, "data: ")
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "[DONE]" || s == "" {
|
||||
s, ok := sseDataPayload(line)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var m map[string]json.RawMessage
|
||||
@@ -296,7 +445,11 @@ func scanUsage(line []byte) json.RawMessage {
|
||||
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 {
|
||||
if u, ok := m["usage"]; ok && string(u) != "null" {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -334,8 +495,10 @@ func (s *sseScanner) Next() ([]byte, error) {
|
||||
// 记账
|
||||
|
||||
// usageSink 累积多次 usage:合并各事件字段(message_start 给 input,message_delta 给 output)。
|
||||
// outputText 累积已转发内容文本,用于流式中断时按 tiktoken 估算输出 token。
|
||||
type usageSink struct {
|
||||
us usageShape
|
||||
outputText string
|
||||
}
|
||||
|
||||
func (u *usageSink) push(raw json.RawMessage) {
|
||||
@@ -346,6 +509,14 @@ func (u *usageSink) push(raw json.RawMessage) {
|
||||
if json.Unmarshal(raw, &t) != nil {
|
||||
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 {
|
||||
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)
|
||||
|
||||
var us usageShape
|
||||
var sink *usageSink
|
||||
if h, ok := c.Get("usage_raw"); ok {
|
||||
if holder, ok := h.(*sinkHolder); ok && holder.sink != nil {
|
||||
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")
|
||||
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 cost float64
|
||||
var modelID uint64
|
||||
_ = g.db.Where("name = ?", mn).First(&model).Error
|
||||
if model.ID > 0 {
|
||||
modelID = model.ID
|
||||
// 计价口径:in=非缓存输入、cacheRead=缓存读、cacheCreate=缓存写(Anthropic 语义,
|
||||
// messages 流式 message_delta 的 input_tokens 即非缓存部分)。
|
||||
// 缓存写按 1.25× 输入价(Anthropic 5m 口径)。
|
||||
cost = float64(in)/1e6*model.InputPrice +
|
||||
float64(out)/1e6*model.OutputPrice +
|
||||
float64(cacheRead)/1e6*model.CacheReadPrice
|
||||
float64(cacheRead)/1e6*model.CacheReadPrice +
|
||||
float64(cacheCreate)/1e6*model.InputPrice*1.25
|
||||
} 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")
|
||||
p, _ := proto.(string)
|
||||
@@ -433,6 +630,19 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
|
||||
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{
|
||||
RequestID: fmt.Sprintf("trace-%s", traceStr),
|
||||
TraceID: traceStr,
|
||||
@@ -453,6 +663,8 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
|
||||
LatencyMS: latency,
|
||||
Status: status,
|
||||
ErrorCode: errCodePtr,
|
||||
RawRequest: rawReq,
|
||||
RawResponse: rawResp,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -11,6 +12,12 @@ import (
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// schemaVersion 当前 schema 版本。struct 变更(加列/改列/删列)时递增,
|
||||
// 触发一次 AutoMigrate 并把新版本写入库(SQLite 用 PRAGMA user_version)。
|
||||
// AutoMigrate 对已有表的列判定不收敛(每次都重建表:CREATE __temp + INSERT SELECT + DROP),
|
||||
// 大表上一次重建数十秒且每次重启重演,所以之后版本未变就直接跳过。
|
||||
const schemaVersion = 2
|
||||
|
||||
// Open 打开数据库连接并自动迁移。
|
||||
// 开发默认 SQLite(dsn 支持 file:...?_journal_mode=WAL),生产可切 postgres。
|
||||
func Open(driver, dsn string) (*gorm.DB, error) {
|
||||
@@ -33,13 +40,35 @@ func Open(driver, dsn string) (*gorm.DB, error) {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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: 前缀与查询参数)。
|
||||
func sqliteDir(dsn string) string {
|
||||
d := dsn
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
// 字段设计对应 PLANNING.md §6:金额/价格 numeric(20,8),token bigint,时间 UTC。
|
||||
package store
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 角色 / 状态枚举(字符串存库,便于阅读与迁移)
|
||||
const (
|
||||
@@ -22,6 +26,11 @@ const (
|
||||
ChannelHealthDegraded = "degraded"
|
||||
ChannelHealthCooldown = "cooldown"
|
||||
|
||||
// 渠道原生支持的协议格式
|
||||
FormatChat = "chat" // OpenAI Chat Completions
|
||||
FormatResponses = "responses" // OpenAI Responses API
|
||||
FormatMessages = "messages" // Anthropic Messages
|
||||
|
||||
UsageStatusSuccess = "success"
|
||||
UsageStatusError = "error"
|
||||
UsageStatusCanceled = "canceled"
|
||||
@@ -47,6 +56,8 @@ type User struct {
|
||||
Role string `gorm:"size:16;not null;default:user" json:"role"`
|
||||
Balance float64 `gorm:"type:numeric(20,8);not null;default:0" json:"balance"`
|
||||
Status string `gorm:"size:16;not null;default:active" json:"status"`
|
||||
AllowedModels []string `gorm:"type:jsonb;serializer:json" json:"allowed_models,omitempty"` // 用户级模型白名单(空=不限制)
|
||||
DeniedModels []string `gorm:"type:jsonb;serializer:json" json:"denied_models,omitempty"` // 用户级模型黑名单
|
||||
InviteCode *string `json:"invite_code,omitempty"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -74,12 +85,14 @@ type APIKey struct {
|
||||
type Channel struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Name string `gorm:"uniqueIndex;size:64;not null" json:"name"`
|
||||
Provider string `gorm:"size:16;not null" json:"provider"` // 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"`
|
||||
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 密文
|
||||
Weight int `gorm:"not null;default:1" json:"weight"`
|
||||
Priority int `gorm:"not null;default:0" json:"priority"` // 数值小优先
|
||||
TimeoutMS int `gorm:"not null;default:120000" json:"timeout_ms"`
|
||||
TimeoutMS int `gorm:"not null;default:300000" json:"timeout_ms"`
|
||||
MaxConcurrency int `gorm:"not null;default:16" json:"max_concurrency"`
|
||||
HealthStatus string `gorm:"size:16;not null;default:healthy" json:"health_status"`
|
||||
Enabled bool `gorm:"not null;default:true" json:"enabled"`
|
||||
@@ -87,11 +100,51 @@ type Channel struct {
|
||||
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)
|
||||
type Model struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Name string `gorm:"uniqueIndex;size:128;not null" json:"name"`
|
||||
DisplayName string `gorm:"size:128" json:"display_name"`
|
||||
InputPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"input_price"`
|
||||
OutputPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"output_price"`
|
||||
CacheReadPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"cache_read_price"`
|
||||
@@ -134,6 +187,8 @@ type UsageLog struct {
|
||||
LatencyMS int `json:"latency_ms"`
|
||||
Status string `gorm:"size:16;not null" json:"status"`
|
||||
ErrorCode *string `json:"error_code,omitempty"`
|
||||
RawRequest string `gorm:"type:text" json:"raw_request"` // 客户端原始请求体(未转换)
|
||||
RawResponse string `gorm:"type:text" json:"raw_response"` // 上游原始响应(未转换;流式为全部 SSE 事件)
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -177,6 +232,16 @@ type BalanceLog struct {
|
||||
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)
|
||||
type SystemConfig struct {
|
||||
Key string `gorm:"primaryKey;size:64" json:"key"`
|
||||
@@ -195,6 +260,7 @@ func AllModels() []any {
|
||||
&UsageDaily{},
|
||||
&RechargeOrder{},
|
||||
&BalanceLog{},
|
||||
&Passkey{},
|
||||
&SystemConfig{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ type Recorder struct {
|
||||
ch chan *store.UsageLog
|
||||
wg sync.WaitGroup
|
||||
closed chan struct{}
|
||||
once sync.Once // 保证 Close 只执行一次(main 的 defer Shutdown 与显式 Close 双调用)
|
||||
}
|
||||
|
||||
const batchSize = 32
|
||||
@@ -45,9 +46,11 @@ func (r *Recorder) Record(l *store.UsageLog) {
|
||||
}
|
||||
|
||||
func (r *Recorder) Close() {
|
||||
r.once.Do(func() {
|
||||
close(r.closed)
|
||||
r.wg.Wait()
|
||||
close(r.ch)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Recorder) run() {
|
||||
@@ -97,7 +100,8 @@ func (r *Recorder) flush(logs []*store.UsageLog) error {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
// 扣余额(余额可为负:流式请求不中断;后续请求被拒)
|
||||
|
||||
+13
-2
@@ -1,10 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" class="dark">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,21 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { PhList } from '@phosphor-icons/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
PhList, PhGauge, PhSquaresFour, PhKey, PhChartLine, PhChartBar, PhGear,
|
||||
PhShieldCheck, PhArrowLeft, PhGlobe, PhTag, PhUsers, PhSliders,
|
||||
} from '@phosphor-icons/vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { fmtMoney } from '@/lib/format'
|
||||
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
|
||||
|
||||
interface NavItem {
|
||||
to: string
|
||||
label: string
|
||||
icon: unknown
|
||||
jump?: boolean // 跨区跳转(如 进入管理 / 返回控制台),不做激活态
|
||||
}
|
||||
defineProps<{ sections: { title: string; items: NavItem[] }[] }>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const balance = computed(() => fmtMoney(auth.user?.balance ?? 0))
|
||||
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() {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
@@ -27,7 +70,7 @@ async function logout() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-[100dvh] bg-zinc-950">
|
||||
<div class="flex min-h-[100dvh] bg-bg">
|
||||
<!-- 移动端遮罩 -->
|
||||
<div
|
||||
v-if="sidebarOpen"
|
||||
@@ -37,51 +80,61 @@ async function logout() {
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<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'"
|
||||
>
|
||||
<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" />
|
||||
<span class="text-sm font-semibold tracking-tight">openteam</span>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 overflow-y-auto px-3 py-4">
|
||||
<template v-for="sec in sections" :key="sec.title">
|
||||
<p class="mt-4 mb-1.5 px-2 text-[10px] font-medium tracking-[0.14em] text-zinc-600 uppercase first:mt-0">
|
||||
<p class="mt-4 mb-1.5 px-2 text-[10px] font-medium tracking-[0.14em] text-muted uppercase first:mt-0">
|
||||
{{ sec.title }}
|
||||
</p>
|
||||
<router-link
|
||||
v-for="n in sec.items"
|
||||
:key="n.to"
|
||||
:to="n.to"
|
||||
class="mb-0.5 flex items-center rounded-md px-2 py-1.5 text-sm text-zinc-400 transition hover:bg-zinc-800/50 hover:text-zinc-100"
|
||||
active-class="bg-zinc-800/70 text-zinc-50"
|
||||
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"
|
||||
:class="n.jump ? 'text-xs' : ''"
|
||||
:active-class="n.jump ? '' : 'bg-surface2 text-ink'"
|
||||
@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>
|
||||
</template>
|
||||
</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>
|
||||
|
||||
<div class="flex min-h-[100dvh] flex-1 flex-col md:ml-56">
|
||||
<header class="flex h-14 items-center justify-between border-b border-zinc-800/80 px-4 md:px-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<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 gap-2 border-b border-edge px-4 md:px-6">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<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="打开菜单"
|
||||
@click="sidebarOpen = true"
|
||||
>
|
||||
<PhList :size="20" />
|
||||
</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 class="flex items-center gap-3">
|
||||
<span class="mono-num rounded-md border border-zinc-800 bg-zinc-900 px-2.5 py-1 text-xs text-emerald-300">
|
||||
余额 {{ balance }}
|
||||
<div class="flex shrink-0 items-center gap-1.5 md:gap-3">
|
||||
<span class="mono-num rounded-md border border-edge bg-surface px-2 py-1 text-xs text-accent sm:px-2.5">
|
||||
<span class="hidden sm:inline">余额 </span>{{ balance }}
|
||||
</span>
|
||||
<ThemeToggle />
|
||||
<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"
|
||||
>
|
||||
退出
|
||||
@@ -89,7 +142,7 @@ async function logout() {
|
||||
</div>
|
||||
</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 />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -8,11 +8,11 @@ withDefaults(defineProps<{ variant?: 'neutral' | 'ok' | 'warn' | 'err' | 'accent
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-mono text-[11px] leading-5"
|
||||
:class="{
|
||||
neutral: 'bg-zinc-800/80 text-zinc-300',
|
||||
ok: 'bg-ok/15 text-emerald-300',
|
||||
warn: 'bg-warn/15 text-amber-300',
|
||||
err: 'bg-err/15 text-red-300',
|
||||
accent: 'bg-accent/15 text-emerald-300',
|
||||
neutral: 'bg-surface2 text-ink',
|
||||
ok: 'bg-ok-soft text-accent',
|
||||
warn: 'bg-warn-soft text-warn',
|
||||
err: 'bg-err-soft text-err',
|
||||
accent: 'bg-accent-soft text-accent',
|
||||
}[variant]"
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -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="[
|
||||
size === 'sm' ? 'h-8 px-3 text-xs' : 'h-10 px-4 text-sm',
|
||||
variant === 'primary' && 'bg-accent text-zinc-950 hover:bg-accent-strong',
|
||||
variant === 'ghost' && 'border border-zinc-700 text-zinc-200 hover:border-zinc-500 hover:bg-zinc-800/60',
|
||||
variant === 'danger' && 'border border-err/50 text-red-300 hover:border-err hover:bg-err/10',
|
||||
variant === 'primary' && 'bg-accent text-accent-ink hover:bg-accent-strong',
|
||||
variant === 'ghost' && 'border border-edge2 text-ink hover:border-edge2 hover:bg-surface2',
|
||||
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" />
|
||||
|
||||
@@ -9,6 +9,7 @@ withDefaults(
|
||||
error?: string
|
||||
autocomplete?: string
|
||||
disabled?: boolean
|
||||
maxlength?: number
|
||||
}>(),
|
||||
{ type: 'text', modelValue: '', disabled: false },
|
||||
)
|
||||
@@ -17,18 +18,19 @@ const emit = defineEmits<{ 'update:modelValue': [string | number] }>()
|
||||
|
||||
<template>
|
||||
<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
|
||||
:type="type"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:autocomplete="autocomplete"
|
||||
:disabled="disabled"
|
||||
class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm text-zinc-100 placeholder-zinc-500 outline-none transition focus:border-accent focus:ring-2 focus:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:class="error && 'border-err focus:border-err focus:ring-err/30'"
|
||||
:maxlength="maxlength"
|
||||
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)"
|
||||
/>
|
||||
<span v-if="hint && !error" class="mt-1.5 block text-xs text-zinc-500">{{ hint }}</span>
|
||||
<span v-if="error" class="mt-1.5 block text-xs text-red-400">{{ error }}</span>
|
||||
<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-err">{{ error }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
@@ -65,16 +65,16 @@ onUnmounted(() => {
|
||||
class="card w-full shadow-xl outline-none"
|
||||
:class="width"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-zinc-800 px-5 py-3.5">
|
||||
<h3 class="text-sm font-semibold text-zinc-100">{{ title }}</h3>
|
||||
<button class="rounded-md p-1 text-zinc-500 hover:bg-zinc-800 hover:text-zinc-200" aria-label="关闭" @click="emit('close')">
|
||||
<div class="flex items-center justify-between border-b border-edge px-5 py-3.5">
|
||||
<h3 class="text-sm font-semibold text-ink">{{ title }}</h3>
|
||||
<button class="rounded-md p-1 text-muted hover:bg-surface2 hover:text-ink" aria-label="关闭" @click="emit('close')">
|
||||
<PhX :size="16" weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-5 py-4">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="$slots.footer" class="flex justify-end gap-2 border-t border-zinc-800 px-5 py-3.5">
|
||||
<div v-if="$slots.footer" class="flex justify-end gap-2 border-t border-edge px-5 py-3.5">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
@@ -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,7 +4,7 @@ const toast = useToastStore()
|
||||
</script>
|
||||
|
||||
<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
|
||||
enter-active-class="transition-all duration-200"
|
||||
enter-from-class="translate-y-1 opacity-0"
|
||||
@@ -15,13 +15,13 @@ const toast = useToastStore()
|
||||
v-for="t in toast.items"
|
||||
:key="t.id"
|
||||
class="card flex items-start gap-2.5 px-4 py-3 shadow-lg"
|
||||
:class="t.type === 'err' && 'border-err/40'"
|
||||
:class="t.type === 'err' && 'border-err'"
|
||||
>
|
||||
<span
|
||||
class="mt-0.5 size-1.5 shrink-0 rounded-full"
|
||||
:class="t.type === 'err' ? 'bg-err' : t.type === 'ok' ? 'bg-ok' : 'bg-zinc-500'"
|
||||
: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>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@ const chart = computed(() => {
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="mb-1 flex items-baseline justify-between">
|
||||
<span class="mono-num text-xs text-zinc-500">max {{ chart.maxLabel }}</span>
|
||||
<span class="mono-num text-xs text-muted">max {{ chart.maxLabel }}</span>
|
||||
</div>
|
||||
<svg
|
||||
:viewBox="`0 0 100 ${height}`"
|
||||
@@ -38,7 +38,7 @@ const chart = computed(() => {
|
||||
: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">
|
||||
<rect
|
||||
:x="b.x"
|
||||
@@ -50,7 +50,7 @@ const chart = computed(() => {
|
||||
/>
|
||||
</g>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -2,12 +2,12 @@ import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
import { useThemeStore } from './stores/theme'
|
||||
import './style.css'
|
||||
|
||||
// 深色优先(MVP 固定深色,后续加亮色切换)
|
||||
document.documentElement.classList.add('dark')
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
useThemeStore(pinia).init()
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const ShellLayout = () => import('@/components/layout/ShellLayout.vue')
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
@@ -9,18 +11,19 @@ export const router = createRouter({
|
||||
{ path: '/register', name: 'register', component: () => import('@/views/RegisterView.vue'), meta: { guest: true } },
|
||||
{
|
||||
path: '/console',
|
||||
component: () => import('@/views/console/ConsoleLayout.vue'),
|
||||
component: ShellLayout,
|
||||
meta: { auth: true },
|
||||
children: [
|
||||
{ path: '', redirect: '/console/dashboard' },
|
||||
{ path: 'dashboard', name: 'dashboard', component: () => import('@/views/console/DashboardView.vue') },
|
||||
{ path: 'keys', name: 'keys', component: () => import('@/views/console/KeysView.vue') },
|
||||
{ path: 'usage', name: 'usage', component: () => import('@/views/console/UsageView.vue') },
|
||||
{ path: 'settings', name: 'settings', component: () => import('@/views/console/SettingsView.vue') },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('@/views/admin/AdminLayout.vue'),
|
||||
component: ShellLayout,
|
||||
meta: { auth: true, admin: true },
|
||||
children: [
|
||||
{ path: '', redirect: '/admin/overview' },
|
||||
@@ -28,6 +31,7 @@ export const router = createRouter({
|
||||
{ path: 'channels', name: 'admin-channels', component: () => import('@/views/admin/ChannelsView.vue') },
|
||||
{ path: 'models', name: 'admin-models', component: () => import('@/views/admin/ModelsView.vue') },
|
||||
{ path: 'users', name: 'admin-users', component: () => import('@/views/admin/UsersView.vue') },
|
||||
{ path: 'usage', name: 'admin-usage', component: () => import('@/views/admin/UsageView.vue') },
|
||||
{ path: 'config', name: 'admin-config', component: () => import('@/views/admin/ConfigView.vue') },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -29,7 +29,9 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
async login(username: string, password: string) {
|
||||
const { data } = await http.post('/auth/login', { username, password })
|
||||
const d = data.data as { access_token: string; user: User }
|
||||
this.setSession(data.data)
|
||||
},
|
||||
setSession(d: { access_token: string; user: User }) {
|
||||
this.accessToken = d.access_token
|
||||
this.user = d.user
|
||||
localStorage.setItem('ot_access', d.access_token)
|
||||
|
||||
@@ -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
@@ -4,32 +4,64 @@
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 设计 tokens(taste-skill 产出) */
|
||||
/* 深色优先 · 单一强调色 emerald · 圆角体系:卡片 8 / 控件 6 / 徽章 pill */
|
||||
/* 密度 7:mono 数字、紧凑表格、细线分隔 */
|
||||
/* 三主题 dark / light / system(data-theme 切换) */
|
||||
/* 令牌值在 @theme 内写实色字面量 → 透明度修饰符(如 bg-accent-soft)可用; */
|
||||
/* [data-theme=light] 覆盖同一批 --color-* 变量实现主题切换。 */
|
||||
/* 圆角:卡片 8 / 控件 6 / 徽章 pill */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
--font-mono: 'Geist Mono Variable', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
|
||||
/* 强调色(单一一处定义,全局一致) */
|
||||
/* 深色(默认)语义令牌 */
|
||||
--color-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-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-warn: oklch(0.80 0.15 75);
|
||||
--color-err: oklch(0.63 0.21 25);
|
||||
--color-warn: #fbbf24;
|
||||
--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;
|
||||
}
|
||||
|
||||
/* 浅色:覆盖同一批语义令牌 */
|
||||
[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 {
|
||||
background-color: #09090b;
|
||||
color: #f4f4f5;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
@@ -41,6 +73,21 @@ body {
|
||||
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) {
|
||||
*,
|
||||
*::before,
|
||||
@@ -49,18 +96,3 @@ body {
|
||||
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
@@ -5,6 +5,8 @@ export interface User {
|
||||
role: 'user' | 'admin'
|
||||
balance: number
|
||||
status: string
|
||||
allowed_models?: string[] | null
|
||||
denied_models?: string[] | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -25,7 +27,9 @@ export interface Channel {
|
||||
id: number
|
||||
name: string
|
||||
provider: 'openai' | 'anthropic' | 'compatible'
|
||||
formats: string[] // chat | responses | messages
|
||||
base_url: string
|
||||
base_urls?: Record<string, string> | null // 分协议 base_url 覆盖
|
||||
api_key_masked: string
|
||||
weight: number
|
||||
priority: number
|
||||
@@ -36,6 +40,14 @@ export interface Channel {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ChannelModelMapping {
|
||||
id: number
|
||||
model_id: number
|
||||
model_name: string
|
||||
upstream_model: string
|
||||
weight: number
|
||||
}
|
||||
|
||||
export interface ModelBinding {
|
||||
id: number
|
||||
channel_id: number
|
||||
@@ -47,13 +59,22 @@ export interface ModelBinding {
|
||||
export interface Model {
|
||||
id: number
|
||||
name: string
|
||||
display_name: string
|
||||
input_price: number
|
||||
output_price: number
|
||||
cache_read_price: number
|
||||
enabled: boolean
|
||||
sort: number
|
||||
channels: ModelBinding[]
|
||||
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 {
|
||||
@@ -61,6 +82,8 @@ export interface UsageLog {
|
||||
request_id: string
|
||||
model: string
|
||||
protocol: string
|
||||
channel_id?: number
|
||||
channel?: string
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_read_tokens: number
|
||||
@@ -68,8 +91,11 @@ export interface UsageLog {
|
||||
latency_ms: number
|
||||
status: string
|
||||
error_code: string | null
|
||||
raw_request?: string
|
||||
raw_response?: string
|
||||
created_at: string
|
||||
user?: string
|
||||
user_id?: number
|
||||
}
|
||||
|
||||
export interface Paged<T> {
|
||||
|
||||
@@ -1,32 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
|
||||
const auth = useAuthStore()
|
||||
|
||||
// 调用演示用浏览器当前 host(兼容端点),避免写死域名
|
||||
const host = window.location.host
|
||||
const baseURL = `${window.location.origin}/v1`
|
||||
</script>
|
||||
|
||||
<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="flex items-center gap-2">
|
||||
<img src="/favicon.svg" alt="" class="size-5" />
|
||||
<span class="text-sm font-semibold tracking-tight">openteam</span>
|
||||
</div>
|
||||
<nav class="flex items-center gap-2">
|
||||
<nav class="flex items-center gap-1.5 sm:gap-2">
|
||||
<ThemeToggle />
|
||||
<router-link
|
||||
v-if="!auth.isAuthed"
|
||||
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 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
|
||||
v-if="!auth.isAuthed"
|
||||
to="/register"
|
||||
class="rounded-md bg-accent px-3.5 py-1.5 text-sm font-medium text-zinc-950 transition hover:bg-accent-strong"
|
||||
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>
|
||||
@@ -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="mx-auto grid max-w-6xl items-center gap-10 px-4 pt-20 pb-16 lg:grid-cols-2 lg:pt-24">
|
||||
<div class="max-w-xl">
|
||||
<p class="mb-3 font-mono text-xs tracking-wide text-emerald-400/80">LLM API 中转网关</p>
|
||||
<h1 class="text-4xl leading-none font-semibold tracking-tight md:text-5xl">
|
||||
<p class="mb-3 font-mono text-xs tracking-wide text-accent">LLM API 中转网关</p>
|
||||
<h1 class="text-3xl leading-tight font-semibold tracking-tight sm:text-4xl md:text-5xl">
|
||||
一个 Key,调用所有主流模型
|
||||
</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 管理开箱即用。
|
||||
</p>
|
||||
<div class="mt-8 flex items-center gap-3">
|
||||
<router-link
|
||||
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
|
||||
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>
|
||||
</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 class="card overflow-hidden font-mono text-xs">
|
||||
<div class="flex items-center gap-1.5 border-b border-zinc-800 px-4 py-2.5">
|
||||
<span class="size-2.5 rounded-full bg-zinc-700" />
|
||||
<span class="size-2.5 rounded-full bg-zinc-700" />
|
||||
<span class="ml-2 text-zinc-500">curl api.openteam.dev</span>
|
||||
<div class="flex items-center gap-1.5 border-b border-edge px-4 py-2.5">
|
||||
<span class="size-2.5 rounded-full bg-surface2" />
|
||||
<span class="size-2.5 rounded-full bg-surface2" />
|
||||
<span class="ml-2 text-muted">curl {{ host }}</span>
|
||||
</div>
|
||||
<div class="space-y-3 p-4 leading-relaxed">
|
||||
<div>
|
||||
<p class="text-zinc-400"><span class="text-emerald-400">$</span> curl https://api.openteam.dev/v1/chat/completions</p>
|
||||
<p class="text-zinc-400"> -H <span class="text-emerald-300">"Authorization: Bearer sk-..."</span> \</p>
|
||||
<p class="text-zinc-400"> -d <span class="text-zinc-300">'{"model": "claude-sonnet-5", "messages": [{"role": "user", "content": "你好"}]}'</span></p>
|
||||
<p class="text-muted"><span class="text-accent">$</span> curl {{ baseURL }}/chat/completions</p>
|
||||
<p class="text-muted"> -H <span class="text-accent">"Authorization: Bearer sk-ot-..."</span> \</p>
|
||||
<p class="text-muted"> -d <span class="text-ink">'{"model": "claude-sonnet-5", "messages": [{"role": "user", "content": "你好"}]}'</span></p>
|
||||
</div>
|
||||
<div class="border-t border-zinc-800 pt-3 text-zinc-500">
|
||||
<p class="text-zinc-600"># OpenAI 格式请求,网关自动转 Anthropic 协议</p>
|
||||
<p class="text-zinc-300">data: {"id":"resp_1","model":"claude-sonnet-5","choices":[{</p>
|
||||
<p class="text-zinc-300"> "delta":{"content":"你好,这是流式回复"}</p>
|
||||
<p class="text-zinc-300">}]}</p>
|
||||
<p class="text-zinc-300">data: [DONE]</p>
|
||||
<div class="border-t border-edge pt-3 text-muted">
|
||||
<p class="text-muted"># OpenAI 格式请求,网关自动转 Anthropic 协议</p>
|
||||
<p class="text-ink">data: {"id":"resp_1","model":"claude-sonnet-5","choices":[{</p>
|
||||
<p class="text-ink"> "delta":{"content":"你好,这是流式回复"}</p>
|
||||
<p class="text-ink">}]}</p>
|
||||
<p class="text-ink">data: [DONE]</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,57 +95,57 @@ const auth = useAuthStore()
|
||||
</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">
|
||||
<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 兼容入口;客户端协议与上游渠道不匹配时自动转换,无需关心背后接的是哪家。
|
||||
</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 [
|
||||
{ path: 'POST /v1/chat/completions', desc: 'OpenAI Chat,兼容面最广,SDK 与工具链最全', tag: 'OpenAI' },
|
||||
{ path: 'POST /v1/responses', desc: 'OpenAI Responses,新一代 SDK 与 Agents 首选', tag: 'OpenAI' },
|
||||
{ path: 'POST /v1/messages', desc: 'Anthropic Messages,Claude 生态原生格式', tag: 'Anthropic' },
|
||||
{ path: 'GET /v1/models', desc: 'OpenAI 风格模型列表', tag: 'List' },
|
||||
]" :key="p.path" class="grid gap-1 px-5 py-3.5 sm:grid-cols-3 sm:items-center">
|
||||
<code class="font-mono text-sm text-emerald-300">{{ p.path }}</code>
|
||||
<p class="text-sm text-zinc-400 sm:col-span-1">{{ p.desc }}</p>
|
||||
<span class="hidden justify-self-end font-mono text-[11px] text-zinc-600 sm:block">{{ p.tag }}</span>
|
||||
<code class="font-mono text-sm text-accent">{{ p.path }}</code>
|
||||
<p class="text-sm text-muted sm:col-span-1">{{ p.desc }}</p>
|
||||
<span class="hidden justify-self-end font-mono text-[11px] text-muted sm:block">{{ p.tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<h2 class="text-2xl font-semibold tracking-tight">网关替你处理的事</h2>
|
||||
<div class="mt-8 grid gap-4 lg:grid-cols-3">
|
||||
<div class="card p-5 lg:col-span-2">
|
||||
<p class="font-mono text-xs text-emerald-400/80">responses → claude-sonnet-5</p>
|
||||
<p class="font-mono text-xs text-accent">responses → claude-sonnet-5</p>
|
||||
<h3 class="mt-2 text-lg font-semibold">协议自动转换</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-zinc-500">
|
||||
<p class="mt-2 text-sm leading-relaxed text-muted">
|
||||
客户端按 Responses 调用 Claude 模型,网关转成 Anthropic 协议打给上游,再以 Responses 事件流式返回。直通优先,能力无损时零转换。
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<p class="mt-2 text-sm leading-relaxed text-zinc-500">
|
||||
<p class="mt-2 text-sm leading-relaxed text-muted">
|
||||
请求级 token 统计,按模型价格自动扣费,日粒度报表与余额流水可追溯。
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<p class="mt-2 text-sm leading-relaxed text-zinc-500">
|
||||
<p class="mt-2 text-sm leading-relaxed text-muted">
|
||||
密钥仅存哈希,支持配额、过期与模型白名单,创建时一次性展示。
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<p class="mt-2 text-sm leading-relaxed text-zinc-500">
|
||||
<p class="mt-2 text-sm leading-relaxed text-muted">
|
||||
一个模型绑定多个上游渠道,按权重与健康状态选择,故障自动转移。管理员在后台一键接入新渠道、导入模型并定价。
|
||||
</p>
|
||||
</div>
|
||||
@@ -148,21 +154,21 @@ const auth = useAuthStore()
|
||||
</section>
|
||||
|
||||
<!-- 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">
|
||||
<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
|
||||
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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="border-t border-zinc-800/60">
|
||||
<div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-6 text-xs text-zinc-600">
|
||||
<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-muted">
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="/favicon.svg" alt="" class="size-4" />
|
||||
<span>openteam · LLM API 中转站</span>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { PhFingerprint } from '@phosphor-icons/vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
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 Input from '@/components/ui/Input.vue'
|
||||
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -15,6 +18,7 @@ const toast = useToastStore()
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const pkLoading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function submit() {
|
||||
@@ -32,27 +36,65 @@ async function submit() {
|
||||
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>
|
||||
|
||||
<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="mb-8 text-center">
|
||||
<div class="mb-3 inline-flex items-center gap-2">
|
||||
<img src="/favicon.svg" alt="" class="size-7" />
|
||||
<span class="text-lg font-semibold tracking-tight">openteam</span>
|
||||
</div>
|
||||
<p class="text-sm text-zinc-500">登录到控制台</p>
|
||||
<p class="text-sm text-muted">登录到控制台</p>
|
||||
</div>
|
||||
|
||||
<form class="card space-y-4 p-6" @submit.prevent="submit">
|
||||
<Input v-model="username" label="用户名或邮箱" autocomplete="username" placeholder="alice" />
|
||||
<Input v-model="password" label="密码" type="password" autocomplete="current-password" />
|
||||
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
|
||||
<p v-if="error" class="text-xs text-err">{{ error }}</p>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</p>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useToastStore } from '@/stores/toast'
|
||||
import { errMsg } from '@/api/client'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
@@ -42,14 +43,15 @@ async function submit() {
|
||||
</script>
|
||||
|
||||
<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="mb-8 text-center">
|
||||
<div class="mb-3 inline-flex items-center justify-center gap-2">
|
||||
<img src="/favicon.svg" alt="" class="size-7" />
|
||||
<span class="text-lg font-semibold tracking-tight">openteam</span>
|
||||
</div>
|
||||
<p class="text-sm text-zinc-500">一个 Key 访问多家模型</p>
|
||||
<p class="text-sm text-muted">一个 Key 访问多家模型</p>
|
||||
</div>
|
||||
|
||||
<form class="card space-y-4 p-6" @submit.prevent="submit">
|
||||
@@ -62,11 +64,11 @@ async function submit() {
|
||||
autocomplete="new-password"
|
||||
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>
|
||||
</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>
|
||||
</p>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { PhCaretDown, PhPulse, PhNotePencil, PhTrash, PhStack } from '@phosphor-icons/vue'
|
||||
import { http, errMsg } from '@/api/client'
|
||||
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 Input from '@/components/ui/Input.vue'
|
||||
import Modal from '@/components/ui/Modal.vue'
|
||||
@@ -14,25 +17,25 @@ const editOpen = ref(false)
|
||||
const editing = ref<Channel | null>(null)
|
||||
const saving = ref(false)
|
||||
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({
|
||||
name: '',
|
||||
provider: 'openai' as 'openai' | 'anthropic' | 'compatible',
|
||||
formats: ['chat'] as string[],
|
||||
base_url: '',
|
||||
base_urls: { chat: '', responses: '', messages: '' } as Record<string, string>,
|
||||
api_key: '',
|
||||
weight: 1,
|
||||
priority: 0,
|
||||
timeout_ms: 120000,
|
||||
timeout_ms: 300000,
|
||||
max_concurrency: 16,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const providerMap: Record<string, string> = {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
compatible: '兼容',
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const { data } = await http.get('/admin/channels')
|
||||
@@ -45,8 +48,10 @@ async function load() {
|
||||
function openCreate() {
|
||||
editing.value = null
|
||||
Object.assign(form, {
|
||||
name: '', provider: 'openai', base_url: '', api_key: '',
|
||||
weight: 1, priority: 0, timeout_ms: 120000, max_concurrency: 16, enabled: true,
|
||||
name: '', formats: ['chat'], base_url: '',
|
||||
base_urls: { chat: '', responses: '', messages: '' },
|
||||
api_key: '',
|
||||
weight: 1, priority: 0, timeout_ms: 300000, max_concurrency: 16, enabled: true,
|
||||
})
|
||||
editOpen.value = true
|
||||
}
|
||||
@@ -54,7 +59,14 @@ function openCreate() {
|
||||
function openEdit(ch: Channel) {
|
||||
editing.value = ch
|
||||
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,
|
||||
max_concurrency: ch.max_concurrency, enabled: ch.enabled,
|
||||
})
|
||||
@@ -62,6 +74,10 @@ function openEdit(ch: Channel) {
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (form.formats.length === 0) {
|
||||
toast.err('请至少选择一种 API 格式')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
const payload = {
|
||||
...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)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<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>
|
||||
<Button @click="openCreate">添加渠道</Button>
|
||||
<Button class="shrink-0" @click="openCreate">添加渠道</Button>
|
||||
</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">
|
||||
<table class="w-full text-sm">
|
||||
<table class="w-full text-sm min-w-[820px]">
|
||||
<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">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">Key</th>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">健康</th>
|
||||
@@ -151,30 +205,56 @@ onMounted(load)
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="ch in channels" :key="ch.id" class="table-row">
|
||||
<td class="px-4 py-2.5 text-zinc-200">{{ ch.name }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-zinc-400">{{ providerMap[ch.provider] }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ ch.base_url }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-zinc-600">{{ ch.api_key_masked || '****' }}</td>
|
||||
<template v-for="ch in channels" :key="ch.id">
|
||||
<tr class="table-row" :style="ch.enabled ? { borderLeft: '2px solid var(--color-accent)' } : {}">
|
||||
<td class="px-4 py-2.5">
|
||||
<button class="inline-flex items-center gap-1.5 text-ink transition hover:text-accent" @click="toggleDrawer(ch)">
|
||||
<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">
|
||||
<Badge :variant="ch.health_status === 'healthy' ? 'ok' : ch.health_status === 'cooldown' ? 'err' : 'warn'">
|
||||
{{ ch.health_status }}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-xs text-zinc-400">{{ ch.enabled ? '是' : '否' }}</td>
|
||||
<td class="px-4 py-2.5 text-xs text-muted">{{ ch.enabled ? '是' : '否' }}</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="text-xs text-zinc-500 hover:text-accent" :disabled="busyId === ch.id" @click="testChannel(ch)">
|
||||
<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="text-xs text-zinc-500 hover:text-accent" @click="importModels(ch)">导入模型</button>
|
||||
<button class="text-xs text-zinc-500 hover:text-zinc-200" @click="openEdit(ch)">编辑</button>
|
||||
<button class="text-xs text-zinc-500 hover:text-red-400" @click="remove(ch)">删除</button>
|
||||
<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>
|
||||
</td>
|
||||
</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">
|
||||
<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>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -183,29 +263,71 @@ onMounted(load)
|
||||
|
||||
<Modal :open="editOpen" :title="editing ? '编辑渠道' : '添加渠道'" @close="editOpen = false">
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<Input v-model="form.name" label="名称" placeholder="openai" />
|
||||
<label class="block">
|
||||
<span class="mb-1.5 block text-xs font-medium text-zinc-400">API 类型</span>
|
||||
<select v-model="form.provider" class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm text-zinc-100 outline-none focus:border-accent">
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="compatible">兼容</option>
|
||||
</select>
|
||||
<div>
|
||||
<span class="mb-1.5 block text-xs font-medium text-muted">支持的 API 格式</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
v-for="opt in PROTOCOL_OPTIONS"
|
||||
:key="opt.value"
|
||||
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>
|
||||
</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
|
||||
v-model="form.api_key"
|
||||
label="上游 API Key"
|
||||
: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.priority" label="优先级" type="number" />
|
||||
<Input v-model="form.timeout_ms" label="超时 (ms)" type="number" />
|
||||
<Input v-model="form.max_concurrency" label="最大并发" type="number" />
|
||||
</div>
|
||||
<div 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>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="editOpen = false">取消</Button>
|
||||
|
||||
@@ -9,12 +9,31 @@ const config = reactive<Record<string, string>>({})
|
||||
const loading = 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() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await http.get('/admin/config')
|
||||
Object.keys(config).forEach((k) => delete config[k])
|
||||
Object.assign(config, data.data.config)
|
||||
allowList.value = parseConfigList(config.model_allowlist)
|
||||
denyList.value = parseConfigList(config.model_denylist)
|
||||
delete config.model_allowlist
|
||||
delete config.model_denylist
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
} 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() {
|
||||
saving.value = true
|
||||
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('配置已保存')
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
@@ -34,63 +65,100 @@ async function save() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadModels()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<div class="mb-6">
|
||||
<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-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 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>
|
||||
<div class="space-y-4">
|
||||
<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">
|
||||
<button
|
||||
class="flex-1 rounded-md border px-3 py-2 text-sm transition"
|
||||
:class="config.registration_mode === 'open' ? 'border-accent bg-accent/10 text-emerald-300' : 'border-zinc-700 text-zinc-400'"
|
||||
:class="config.registration_mode === 'open' ? 'border-accent bg-accent-soft text-accent' : 'border-edge2 text-muted'"
|
||||
@click="config.registration_mode = 'open'"
|
||||
>
|
||||
开放注册
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 rounded-md border px-3 py-2 text-sm transition"
|
||||
:class="config.registration_mode === 'invite' ? 'border-accent bg-accent/10 text-emerald-300' : 'border-zinc-700 text-zinc-400'"
|
||||
:class="config.registration_mode === 'invite' ? 'border-accent bg-accent-soft text-accent' : 'border-edge2 text-muted'"
|
||||
@click="config.registration_mode = 'invite'"
|
||||
>
|
||||
邀请码
|
||||
</button>
|
||||
</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>
|
||||
<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
|
||||
v-model="config.invite_codes"
|
||||
placeholder="code1,code2"
|
||||
class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 font-mono text-xs outline-none focus:border-accent"
|
||||
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 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>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button :loading="saving" @click="save">保存</Button>
|
||||
</div>
|
||||
</template>
|
||||
<Button :loading="saving" @click="save">保存配置</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,27 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { http, errMsg } from '@/api/client'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import Modal from '@/components/ui/Modal.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import type { Channel, Model } from '@/types'
|
||||
import type { Model, ModelSummary } from '@/types'
|
||||
|
||||
const toast = useToastStore()
|
||||
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 editing = ref<Model | null>(null)
|
||||
const saving = ref(false)
|
||||
const quickName = ref('')
|
||||
const clearing = ref(false)
|
||||
|
||||
const bindOpen = ref(false)
|
||||
const bindModel = ref<Model | null>(null)
|
||||
const binding = reactive({ channel_id: 0, upstream_model: '', weight: 1 })
|
||||
// 未绑定任何渠道的模型:渠道选中的后来被取消,客户端无法调用
|
||||
const unused = computed(() => models.value.filter((m) => m.channels.length === 0))
|
||||
|
||||
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({
|
||||
name: '',
|
||||
display_name: '',
|
||||
input_price: 0,
|
||||
output_price: 0,
|
||||
cache_read_price: 0,
|
||||
@@ -30,9 +54,9 @@ const form = reactive({
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [m, c] = await Promise.all([http.get('/admin/models'), http.get('/admin/channels')])
|
||||
models.value = m.data.data.items
|
||||
channels.value = c.data.data.items
|
||||
const { data } = await http.get('/admin/models')
|
||||
models.value = data.data.items
|
||||
summary.value = data.data.summary
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
@@ -40,14 +64,14 @@ async function load() {
|
||||
|
||||
function openCreate() {
|
||||
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
|
||||
}
|
||||
|
||||
function openEdit(m: Model) {
|
||||
editing.value = m
|
||||
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,
|
||||
enabled: m.enabled,
|
||||
})
|
||||
@@ -57,7 +81,6 @@ function openEdit(m: Model) {
|
||||
async function save() {
|
||||
saving.value = true
|
||||
const payload = {
|
||||
display_name: form.display_name || form.name,
|
||||
input_price: Number(form.input_price),
|
||||
output_price: Number(form.output_price),
|
||||
cache_read_price: Number(form.cache_read_price),
|
||||
@@ -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)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<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>
|
||||
<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 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 v-for="m in models" :key="m.id" class="card">
|
||||
<div class="flex items-center justify-between px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-mono text-sm text-zinc-100">{{ m.name }}</span>
|
||||
<div v-for="m in models" :key="m.id" :class="m.channels.length ? 'card' : 'card border-warn/60 bg-warn-soft'">
|
||||
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<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 v-if="m.denied" variant="err">已禁止</Badge>
|
||||
<Badge v-if="m.needs_pricing" variant="warn">未定价</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="mono-num text-xs text-zinc-400">入 {{ m.input_price }}</span>
|
||||
<span class="mono-num text-xs text-zinc-400">出 {{ m.output_price }}</span>
|
||||
<span class="mono-num text-xs text-zinc-500">缓存读 {{ m.cache_read_price }}</span>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="mono-num text-xs text-muted">入 {{ m.input_price }}</span>
|
||||
<span class="mono-num text-xs text-muted">出 {{ m.output_price }}</span>
|
||||
<span class="mono-num text-xs text-muted">缓存读 {{ m.cache_read_price }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="text-xs text-zinc-500 hover:text-accent" @click="openBind(m)">绑定渠道</button>
|
||||
<button class="text-xs text-zinc-500 hover:text-zinc-200" @click="openEdit(m)">编辑</button>
|
||||
<button class="text-xs text-zinc-500 hover:text-red-400" @click="removeModel(m)">删除</button>
|
||||
<button class="text-xs text-muted hover:text-ink" @click="openEdit(m)">编辑</button>
|
||||
<button class="text-xs text-muted hover:text-err" @click="removeModel(m)">删除</button>
|
||||
</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">
|
||||
<span
|
||||
v-for="b in m.channels"
|
||||
:key="b.id"
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-zinc-800 bg-zinc-900 px-2 py-0.5 font-mono text-[11px] text-zinc-400"
|
||||
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 }}
|
||||
<button class="text-zinc-400 hover:text-red-400" aria-label="移除绑定" @click="removeBinding(m, b.id)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="border-t border-zinc-800/70 px-4 py-2 text-xs text-zinc-600">
|
||||
未绑定渠道,客户端无法调用该模型
|
||||
<p v-else class="border-t border-edge px-4 py-2 text-xs text-warn">
|
||||
悬空模型:无任何渠道提供,客户端无法调用
|
||||
</p>
|
||||
</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>
|
||||
</div>
|
||||
@@ -180,8 +205,7 @@ onMounted(load)
|
||||
<Modal :open="editOpen" :title="editing ? '编辑模型' : '添加模型'" @close="editOpen = false">
|
||||
<div class="space-y-4">
|
||||
<Input v-model="form.name" label="模型名" placeholder="claude-sonnet-5" :disabled="!!editing" />
|
||||
<Input v-model="form.display_name" label="展示名" />
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input v-model="form.input_price" label="输入价格 /1M" type="number" />
|
||||
<Input v-model="form.output_price" label="输出价格 /1M" type="number" />
|
||||
<Input v-model="form.cache_read_price" label="缓存读价格 /1M" type="number" />
|
||||
@@ -192,24 +216,5 @@ onMounted(load)
|
||||
<Button :loading="saving" @click="save">{{ editing ? '保存' : '创建' }}</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 绑定渠道 -->
|
||||
<Modal :open="bindOpen" title="绑定渠道" @close="bindOpen = false">
|
||||
<div class="space-y-4">
|
||||
<p class="text-xs text-zinc-500">模型 <span class="font-mono text-emerald-300">{{ bindModel?.name }}</span> 通过以下渠道提供</p>
|
||||
<label class="block">
|
||||
<span class="mb-1.5 block text-xs font-medium text-zinc-400">渠道</span>
|
||||
<select v-model="binding.channel_id" class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm text-zinc-100 outline-none focus:border-accent">
|
||||
<option v-for="ch in channels" :key="ch.id" :value="ch.id">{{ ch.name }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<Input v-model="binding.upstream_model" label="上游模型名" placeholder="与渠道侧一致" />
|
||||
<Input v-model="binding.weight" label="权重" type="number" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="bindOpen = false">取消</Button>
|
||||
<Button @click="saveBinding">绑定</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,10 +4,12 @@ import { http, errMsg } from '@/api/client'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { fmtNum, fmtCost, fmtTime } from '@/lib/format'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import Skeleton from '@/components/ui/Skeleton.vue'
|
||||
import TrendChart from '@/components/ui/TrendChart.vue'
|
||||
import type { UsageLog } from '@/types'
|
||||
|
||||
const toast = useToastStore()
|
||||
const loading = ref(true)
|
||||
const data = ref({
|
||||
total_users: 0, total_keys: 0, total_channels: 0, total_models: 0,
|
||||
today: { requests: 0, cost: 0, tokens: 0 },
|
||||
@@ -23,6 +25,8 @@ async function load() {
|
||||
logs.value = u.data.data.items
|
||||
} catch (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="mb-2">
|
||||
<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="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<div class="card p-4">
|
||||
<p class="text-xs text-zinc-500">用户 / 密钥</p>
|
||||
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(data.total_users) }} / {{ fmtNum(data.total_keys) }}</p>
|
||||
<p class="text-xs text-muted">用户 / 密钥</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 class="card p-4">
|
||||
<p class="text-xs text-zinc-500">渠道 / 模型</p>
|
||||
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(data.total_channels) }} / {{ fmtNum(data.total_models) }}</p>
|
||||
<p class="text-xs text-muted">渠道 / 模型</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 class="card p-4">
|
||||
<p class="text-xs text-zinc-500">今日请求</p>
|
||||
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(data.today.requests) }}</p>
|
||||
<p class="text-xs text-muted">今日请求</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 class="card p-4">
|
||||
<p class="text-xs text-zinc-500">本月营收</p>
|
||||
<p class="mono-num mt-1 text-xl text-emerald-300">{{ fmtCost(data.month.cost) }}</p>
|
||||
<p class="text-xs text-muted">本月营收</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>
|
||||
|
||||
@@ -59,9 +67,11 @@ onMounted(load)
|
||||
<div class="card p-5 lg:col-span-3">
|
||||
<div class="mb-4 flex items-baseline justify-between">
|
||||
<h2 class="text-sm font-semibold">近 14 天全局成本</h2>
|
||||
<span class="font-mono text-[11px] text-zinc-600">{{ fmtCost(data.month.cost) }} / 本月</span>
|
||||
<span class="font-mono text-[11px] text-muted">{{ fmtCost(data.month.cost) }} / 本月</span>
|
||||
</div>
|
||||
<Skeleton v-if="loading" height="160px" />
|
||||
<TrendChart
|
||||
v-else
|
||||
:points="data.trend_14d.map((t) => ({ label: t.date.slice(5), value: t.cost }))"
|
||||
:format="(v) => '$' + v.toExponential(2)"
|
||||
/>
|
||||
@@ -70,22 +80,22 @@ onMounted(load)
|
||||
<div class="card p-5 lg:col-span-2">
|
||||
<div class="mb-3 flex items-baseline justify-between">
|
||||
<h2 class="text-sm font-semibold">全局最近请求</h2>
|
||||
<span class="font-mono text-[11px] text-zinc-600">共 {{ data.month.requests }} / 月</span>
|
||||
<span class="font-mono text-[11px] text-muted">共 {{ data.month.requests }} / 月</span>
|
||||
</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">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-xs text-zinc-300">
|
||||
<span class="font-mono text-emerald-400/80">{{ l.user }}</span> · {{ l.model }}
|
||||
<p class="truncate text-xs text-ink">
|
||||
<span class="font-mono text-accent">{{ l.user }}</span> · {{ l.model }}
|
||||
</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 class="flex items-center gap-2">
|
||||
<span class="mono-num text-xs text-zinc-500">{{ fmtCost(l.cost) }}</span>
|
||||
<Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge>
|
||||
<span class="mono-num text-xs text-muted">{{ fmtCost(l.cost) }}</span>
|
||||
<Badge :variant="l.status === 'success' ? 'ok' : l.status === 'canceled' ? 'neutral' : 'err'">{{ l.status }}</Badge>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { PhNotePencil, PhCoins } from '@phosphor-icons/vue'
|
||||
import { http, errMsg } from '@/api/client'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -20,7 +21,20 @@ const pageSize = 15
|
||||
|
||||
const editOpen = ref(false)
|
||||
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 balanceUser = ref<User | null>(null)
|
||||
@@ -43,14 +57,33 @@ function search() {
|
||||
|
||||
function openEdit(u: User) {
|
||||
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
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editing.value) return
|
||||
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('已更新')
|
||||
editOpen.value = false
|
||||
await load()
|
||||
@@ -90,27 +123,27 @@ onMounted(load)
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<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="flex gap-2">
|
||||
<div class="flex w-full gap-2 sm:w-auto">
|
||||
<input
|
||||
v-model="q"
|
||||
placeholder="搜索用户名 / 邮箱"
|
||||
class="h-10 w-56 rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm outline-none focus:border-accent"
|
||||
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"
|
||||
/>
|
||||
<Button variant="ghost" @click="search">搜索</Button>
|
||||
<Button variant="ghost" class="shrink-0" @click="search">搜索</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<table class="w-full text-sm min-w-[720px]">
|
||||
<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">用户名</th>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">邮箱</th>
|
||||
@@ -123,35 +156,44 @@ onMounted(load)
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in users" :key="u.id" class="table-row">
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ u.id }}</td>
|
||||
<td class="px-4 py-2.5 text-zinc-200">
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ u.id }}</td>
|
||||
<td class="px-4 py-2.5 text-ink">
|
||||
{{ 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 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">
|
||||
<Badge :variant="u.role === 'admin' ? 'accent' : 'neutral'">{{ u.role }}</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 mono-num text-xs text-emerald-300/90">{{ fmtMoney(u.balance) }}</td>
|
||||
<td class="px-4 py-2.5 mono-num text-xs text-accent">{{ fmtMoney(u.balance) }}</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<Badge :variant="u.status === 'active' ? 'ok' : 'warn'">{{ u.status }}</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ fmtTime(u.created_at) }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(u.created_at) }}</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="text-xs text-zinc-500 hover:text-zinc-200" @click="openEdit(u)">编辑</button>
|
||||
<button class="text-xs text-zinc-500 hover:text-accent" @click="openBalance(u)">调余额</button>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
<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>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex items-center justify-between border-t border-zinc-800 px-4 py-3">
|
||||
<span class="font-mono text-xs text-zinc-600">共 {{ total }} 人</span>
|
||||
<div class="flex 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>
|
||||
@@ -160,23 +202,46 @@ onMounted(load)
|
||||
</div>
|
||||
|
||||
<!-- 编辑用户 -->
|
||||
<Modal :open="editOpen" title="编辑用户" @close="editOpen = false">
|
||||
<Modal :open="editOpen" :title="`编辑用户 · ${editing?.username}`" @close="editOpen = false">
|
||||
<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">
|
||||
<span class="mb-1.5 block text-xs font-medium text-zinc-400">角色</span>
|
||||
<select v-model="editForm.role" class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm outline-none focus:border-accent">
|
||||
<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-edge2 bg-surface px-3 text-sm text-ink outline-none focus:border-accent">
|
||||
<option value="user">user</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="mb-1.5 block text-xs font-medium text-zinc-400">状态</span>
|
||||
<select v-model="editForm.status" class="h-10 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 text-sm outline-none focus:border-accent">
|
||||
<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="disabled">disabled</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button variant="ghost" @click="editOpen = false">取消</Button>
|
||||
<Button @click="saveEdit">保存</Button>
|
||||
@@ -186,7 +251,7 @@ onMounted(load)
|
||||
<!-- 调整余额 -->
|
||||
<Modal :open="balanceOpen" :title="`调整余额 · ${balanceUser?.username}`" @close="balanceOpen = false">
|
||||
<div class="space-y-4">
|
||||
<p class="text-xs text-zinc-500">当前余额 {{ fmtMoney(balanceUser?.balance ?? 0) }}</p>
|
||||
<p class="text-xs text-muted">当前余额 {{ fmtMoney(balanceUser?.balance ?? 0) }}</p>
|
||||
<Input v-model="balanceForm.amount" label="调整金额" type="number" hint="正数增加,负数扣减" />
|
||||
<Input v-model="balanceForm.remark" label="备注" placeholder="可选" />
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
@@ -3,12 +3,16 @@ import { onMounted, ref } from 'vue'
|
||||
import { http, errMsg } from '@/api/client'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { fmtMoney, fmtNum, fmtCost, fmtTime } from '@/lib/format'
|
||||
import { copyText } from '@/lib/clipboard'
|
||||
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 type { UsageLog } from '@/types'
|
||||
|
||||
const toast = useToastStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const balance = ref(0)
|
||||
const today = ref({ requests: 0, tokens: 0, cost: 0 })
|
||||
const monthCost = ref(0)
|
||||
@@ -32,6 +36,8 @@ async function load() {
|
||||
logs.value = l.data.data.items
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +46,24 @@ function daysAgo(n: number): string {
|
||||
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)
|
||||
</script>
|
||||
|
||||
@@ -47,26 +71,30 @@ onMounted(load)
|
||||
<div class="mx-auto max-w-5xl space-y-6">
|
||||
<div class="mb-2">
|
||||
<h1 class="text-lg font-semibold">仪表盘</h1>
|
||||
<p class="text-sm text-zinc-500">余额、用量与最近请求</p>
|
||||
<p class="text-sm text-muted">余额、用量与最近请求</p>
|
||||
</div>
|
||||
|
||||
<!-- 指标条 -->
|
||||
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<div class="card p-4">
|
||||
<p class="text-xs text-zinc-500">余额</p>
|
||||
<p class="mono-num mt-1 text-xl text-emerald-300">{{ fmtMoney(balance) }}</p>
|
||||
<p class="text-xs text-muted">余额</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 class="card p-4">
|
||||
<p class="text-xs text-zinc-500">今日请求</p>
|
||||
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(today.requests) }}</p>
|
||||
<p class="text-xs text-muted">今日请求</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 class="card p-4">
|
||||
<p class="text-xs text-zinc-500">今日 Token</p>
|
||||
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(today.tokens) }}</p>
|
||||
<p class="text-xs text-muted">今日 Token</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 class="card p-4">
|
||||
<p class="text-xs text-zinc-500">近 30 日消耗</p>
|
||||
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtCost(monthCost) }}</p>
|
||||
<p class="text-xs text-muted">近 30 日消耗</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>
|
||||
|
||||
@@ -75,9 +103,10 @@ onMounted(load)
|
||||
<div class="card p-5 lg:col-span-3">
|
||||
<div class="mb-4 flex items-baseline justify-between">
|
||||
<h2 class="text-sm font-semibold">近 14 天成本</h2>
|
||||
<span class="font-mono text-[11px] text-zinc-600">{{ models }} 个可用模型</span>
|
||||
<span class="font-mono text-[11px] text-muted">{{ models }} 个可用模型</span>
|
||||
</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>
|
||||
|
||||
<!-- 最近请求 -->
|
||||
@@ -86,19 +115,27 @@ onMounted(load)
|
||||
<h2 class="text-sm font-semibold">最近请求</h2>
|
||||
<router-link to="/console/usage" class="text-xs text-accent hover:text-accent-strong">查看全部</router-link>
|
||||
</div>
|
||||
<ul class="divide-y divide-zinc-800/70">
|
||||
<li v-for="l in logs" :key="l.id" class="flex items-center justify-between py-2">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-mono text-xs text-zinc-300">{{ l.model }}</p>
|
||||
<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>
|
||||
<ul v-if="loading" class="divide-y divide-edge">
|
||||
<li v-for="i in 4" :key="i" class="flex items-center justify-between py-2">
|
||||
<Skeleton height="0.75rem" width="40%" />
|
||||
<Skeleton height="0.75rem" width="30%" />
|
||||
</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>
|
||||
创建密钥开始调用
|
||||
@@ -106,5 +143,44 @@ onMounted(load)
|
||||
</ul>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { PhCopy, PhCheck } from '@phosphor-icons/vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { PhCopy, PhCheck, PhNotePencil } from '@phosphor-icons/vue'
|
||||
import { http, errMsg } from '@/api/client'
|
||||
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 Input from '@/components/ui/Input.vue'
|
||||
import Modal from '@/components/ui/Modal.vue'
|
||||
@@ -15,11 +16,42 @@ const keys = ref<ApiKey[]>([])
|
||||
|
||||
const createOpen = ref(false)
|
||||
const keyName = ref('')
|
||||
const createAdv = reactive({ quota_tokens_per_day: '', quota_requests_per_day: '', allowed_models: '' })
|
||||
const creating = ref(false)
|
||||
|
||||
const created = ref<{ name: string; key: string; key_prefix: string } | null>(null)
|
||||
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() {
|
||||
try {
|
||||
const { data } = await http.get('/keys')
|
||||
@@ -33,10 +65,18 @@ async function createKey() {
|
||||
if (!keyName.value) return
|
||||
creating.value = true
|
||||
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
|
||||
createOpen.value = false
|
||||
keyName.value = ''
|
||||
Object.assign(createAdv, { quota_tokens_per_day: '', quota_requests_per_day: '', allowed_models: '' })
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
@@ -45,11 +85,45 @@ async function createKey() {
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(k: ApiKey) {
|
||||
if (!confirm(`吊销密钥 ${k.name}?吊销后立即失效。`)) return
|
||||
function openEdit(k: ApiKey) {
|
||||
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 {
|
||||
await http.delete(`/keys/${k.id}`)
|
||||
toast.ok('密钥已吊销')
|
||||
toast.ok('密钥已删除')
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
@@ -58,11 +132,11 @@ async function revoke(k: ApiKey) {
|
||||
|
||||
async function copyKey() {
|
||||
if (!created.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(created.value.key)
|
||||
const ok = await copyText(created.value.key)
|
||||
if (ok) {
|
||||
copied.value = true
|
||||
setTimeout(() => (copied.value = false), 1500)
|
||||
} catch {
|
||||
} else {
|
||||
toast.err('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
@@ -75,45 +149,103 @@ onMounted(load)
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<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>
|
||||
<Button @click="createOpen = true">新建密钥</Button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="k in keys" :key="k.id" class="table-row">
|
||||
<td class="px-4 py-2.5 text-zinc-200">{{ k.name }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-zinc-400">{{ k.key_prefix }}…</td>
|
||||
<td class="px-4 py-2.5 text-ink">{{ k.name }}</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">
|
||||
<Badge :variant="k.status === 'active' ? 'ok' : 'neutral'">{{ k.status }}</Badge>
|
||||
</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-zinc-500">{{ fmtTime(k.created_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 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
|
||||
v-if="k.status === 'active'"
|
||||
class="text-xs text-zinc-500 hover:text-red-400"
|
||||
@click="revoke(k)"
|
||||
class="text-xs text-muted transition hover:text-err"
|
||||
@click="remove(k)"
|
||||
>
|
||||
吊销
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<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>
|
||||
</tr>
|
||||
@@ -124,22 +256,60 @@ onMounted(load)
|
||||
|
||||
<!-- 新建密钥 -->
|
||||
<Modal :open="createOpen" title="新建密钥" @close="createOpen = false">
|
||||
<div class="space-y-4">
|
||||
<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>
|
||||
<Button variant="ghost" @click="createOpen = false">取消</Button>
|
||||
<Button :loading="creating" @click="createKey">创建</Button>
|
||||
</template>
|
||||
</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">
|
||||
<div class="space-y-4">
|
||||
<p class="text-xs text-zinc-500">请复制并妥善保存,关闭后不再显示。</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="mono-num flex-1 truncate rounded-md border border-accent/40 bg-zinc-900 px-3 py-2 text-xs text-emerald-300">
|
||||
<p class="text-xs text-muted">请复制并妥善保存,关闭后不再显示。</p>
|
||||
<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 bg-surface px-3 py-2 text-xs text-accent">
|
||||
{{ created?.key }}
|
||||
</code>
|
||||
<Button size="sm" @click="copyKey">
|
||||
<Button size="sm" class="shrink-0" @click="copyKey">
|
||||
<PhCheck v-if="copied" :size="14" />
|
||||
<PhCopy v-else :size="14" />
|
||||
{{ copied ? '已复制' : '复制' }}
|
||||
|
||||
@@ -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>
|
||||
@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue'
|
||||
import { http, errMsg } from '@/api/client'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { fmtNum, fmtCost, fmtTime } from '@/lib/format'
|
||||
import { protocolShort } from '@/lib/protocol'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import TrendChart from '@/components/ui/TrendChart.vue'
|
||||
import type { UsageLog } from '@/types'
|
||||
@@ -51,42 +52,42 @@ onMounted(load)
|
||||
<div class="mx-auto max-w-5xl space-y-6">
|
||||
<div class="mb-2">
|
||||
<h1 class="text-lg font-semibold">用量</h1>
|
||||
<p class="text-sm text-zinc-500">汇总、分布与请求明细</p>
|
||||
<p class="text-sm text-muted">汇总、分布与请求明细</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<div class="card p-4">
|
||||
<p class="text-xs text-zinc-500">今日请求</p>
|
||||
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(summary.today.requests) }}</p>
|
||||
<p class="text-xs text-muted">今日请求</p>
|
||||
<p class="mono-num mt-1 text-xl text-ink">{{ fmtNum(summary.today.requests) }}</p>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<p class="text-xs text-zinc-500">今日 Token</p>
|
||||
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(summary.today.tokens) }}</p>
|
||||
<p class="text-xs text-muted">今日 Token</p>
|
||||
<p class="mono-num mt-1 text-xl text-ink">{{ fmtNum(summary.today.tokens) }}</p>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<p class="text-xs text-zinc-500">本月请求</p>
|
||||
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtNum(summary.month.requests) }}</p>
|
||||
<p class="text-xs text-muted">本月请求</p>
|
||||
<p class="mono-num mt-1 text-xl text-ink">{{ fmtNum(summary.month.requests) }}</p>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<p class="text-xs text-zinc-500">本月成本</p>
|
||||
<p class="mono-num mt-1 text-xl text-zinc-100">{{ fmtCost(summary.month.cost) }}</p>
|
||||
<p class="text-xs text-muted">本月成本</p>
|
||||
<p class="mono-num mt-1 text-xl text-ink">{{ fmtCost(summary.month.cost) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-5">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-sm font-semibold">成本分布</h2>
|
||||
<div class="flex gap-1 rounded-md border border-zinc-800 p-0.5">
|
||||
<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="group === 'day' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-500'"
|
||||
:class="group === 'day' ? 'bg-surface2 text-ink' : 'text-muted'"
|
||||
@click="switchGroup('day')"
|
||||
>
|
||||
按天
|
||||
</button>
|
||||
<button
|
||||
class="rounded px-2.5 py-1 text-xs transition"
|
||||
:class="group === 'model' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-500'"
|
||||
:class="group === 'model' ? 'bg-surface2 text-ink' : 'text-muted'"
|
||||
@click="switchGroup('model')"
|
||||
>
|
||||
按模型
|
||||
@@ -97,19 +98,43 @@ onMounted(load)
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<input
|
||||
v-model="modelFilter"
|
||||
placeholder="按模型过滤"
|
||||
class="h-8 w-48 rounded-md border border-zinc-700 bg-zinc-900 px-2.5 font-mono text-xs outline-none focus:border-accent"
|
||||
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()"
|
||||
/>
|
||||
</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>
|
||||
<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">Token 入/出</th>
|
||||
@@ -121,22 +146,22 @@ onMounted(load)
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="l in logs" :key="l.id" class="table-row">
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-zinc-200">{{ l.model }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ l.protocol }}</td>
|
||||
<td class="px-4 py-2.5 mono-num text-xs text-zinc-400">{{ l.input_tokens }}/{{ l.output_tokens }}</td>
|
||||
<td class="px-4 py-2.5 mono-num text-xs text-zinc-300">{{ fmtCost(l.cost) }}</td>
|
||||
<td class="px-4 py-2.5 mono-num text-xs text-zinc-500">{{ l.latency_ms }}ms</td>
|
||||
<td class="px-4 py-2.5"><Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge></td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500">{{ fmtTime(l.created_at) }}</td>
|
||||
<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">{{ 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>
|
||||
</tr>
|
||||
<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>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex items-center justify-between border-t border-zinc-800 px-4 py-3">
|
||||
<span class="font-mono text-xs text-zinc-600">共 {{ total }} 条</span>
|
||||
<div class="flex 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>
|
||||
|
||||
Reference in New Issue
Block a user