Compare commits
64
Commits
512e46664c
...
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 |
@@ -0,0 +1,20 @@
|
||||
# VCS
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# 依赖与构建产物(镜像内重新安装/构建)
|
||||
**/node_modules
|
||||
**/dist
|
||||
server/bin
|
||||
|
||||
# 本地数据与密钥
|
||||
data/
|
||||
*.db
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# 编辑器/系统
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
+12
-1
@@ -27,16 +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
|
||||
|
||||
|
||||
+2
-2
@@ -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 计数,与用户级限流叠加。
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
- **限流/配额**(内存计数):密钥级每日请求数 / 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, a.Limit, cfg.RateLimit.UserRPS)
|
||||
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 {
|
||||
Role *string `json:"role"`
|
||||
Status *string `json:"status"`
|
||||
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, "formats": ch.FormatsEffective(), "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,
|
||||
@@ -44,19 +43,37 @@ func (h *Handler) AdminChannels(c *gin.Context) {
|
||||
}
|
||||
|
||||
type channelBody struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=64"`
|
||||
Provider string `json:"provider"` // 可选:为空时按 formats 推断(兼容旧数据)
|
||||
Formats []string `json:"formats"` // 原生支持的协议 chat|responses|messages(主配置)
|
||||
BaseURL string `json:"base_url"` // 可选:为空时按供应商默认(openai/anthropic)
|
||||
APIKey string `json:"api_key"`
|
||||
Weight *int `json:"weight"`
|
||||
Priority *int `json:"priority"`
|
||||
TimeoutMS *int `json:"timeout_ms"`
|
||||
MaxConcurrency *int `json:"max_concurrency"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Name string `json:"name" binding:"required,min=1,max=64"`
|
||||
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"`
|
||||
TimeoutMS *int `json:"timeout_ms"`
|
||||
MaxConcurrency *int `json:"max_concurrency"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// resolveBaseURL 渠道 base_url:留空按供应商默认;兼容用户填完整地址(含 /v1)。
|
||||
// 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 == "" {
|
||||
@@ -70,7 +87,7 @@ func resolveBaseURL(provider, raw string) (string, error) {
|
||||
if base == "" {
|
||||
return "", errors.New("base_url required for compatible channels")
|
||||
}
|
||||
return strings.TrimSuffix(base, "/v1"), nil
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func validateProvider(p string) bool {
|
||||
@@ -165,8 +182,9 @@ func (h *Handler) AdminCreateChannel(c *gin.Context) {
|
||||
}
|
||||
ch := store.Channel{
|
||||
Name: req.Name, Provider: req.Provider, Formats: formats, BaseURL: baseURL,
|
||||
BaseURLs: normalizeBaseURLs(req.BaseURLs),
|
||||
APIKeyEnc: enc, Weight: intOr(req.Weight, 1), Priority: intOr(req.Priority, 0),
|
||||
TimeoutMS: intOr(req.TimeoutMS, 120000), MaxConcurrency: intOr(req.MaxConcurrency, 16),
|
||||
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 {
|
||||
@@ -184,17 +202,18 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Provider *string `json:"provider"`
|
||||
Formats *[]string `json:"formats"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
APIKey *string `json:"api_key"`
|
||||
Weight *int `json:"weight"`
|
||||
Priority *int `json:"priority"`
|
||||
TimeoutMS *int `json:"timeout_ms"`
|
||||
MaxConcurrency *int `json:"max_concurrency"`
|
||||
HealthStatus *string `json:"health_status"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Name *string `json:"name"`
|
||||
Provider *string `json:"provider"`
|
||||
Formats *[]string `json:"formats"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
BaseURLs *map[string]string `json:"base_urls"`
|
||||
APIKey *string `json:"api_key"`
|
||||
Weight *int `json:"weight"`
|
||||
Priority *int `json:"priority"`
|
||||
TimeoutMS *int `json:"timeout_ms"`
|
||||
MaxConcurrency *int `json:"max_concurrency"`
|
||||
HealthStatus *string `json:"health_status"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||||
@@ -228,6 +247,11 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -264,20 +288,15 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
|
||||
resp.Fail(c, http.StatusBadRequest, ferr.Error())
|
||||
return
|
||||
}
|
||||
updates["formats"] = formats
|
||||
// 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")
|
||||
return
|
||||
}
|
||||
// jsonb 序列化走模型字段更新
|
||||
if f, ok := updates["formats"]; ok {
|
||||
if err := h.a.DB.Model(&ch).Update("formats", f).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to update formats")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
resp.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
@@ -320,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)
|
||||
@@ -350,87 +369,15 @@ 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
|
||||
}
|
||||
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})
|
||||
return key[:7] + "******" + key[len(key)-4:]
|
||||
}
|
||||
|
||||
var _ = clause.Assignments // 保留 gorm/clause 引用(后续定价批处理用)
|
||||
|
||||
func intOr(p *int, def int) int {
|
||||
if p == nil {
|
||||
return def
|
||||
|
||||
@@ -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)
|
||||
@@ -196,4 +288,4 @@ func (h *Handler) AdminDeleteModelBinding(c *gin.Context) {
|
||||
}
|
||||
|
||||
var _ = errors.Is
|
||||
var _ = gorm.ErrRecordNotFound
|
||||
var _ = gorm.ErrRecordNotFound
|
||||
@@ -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
|
||||
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}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 认证
|
||||
@@ -219,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ func (h *Handler) PatchKey(c *gin.Context) {
|
||||
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 {
|
||||
@@ -172,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})
|
||||
}
|
||||
@@ -21,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")
|
||||
@@ -34,7 +34,13 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
|
||||
// 静态资源(前端构建产物,存在时托管)
|
||||
const dist = "web/dist"
|
||||
if _, err := os.Stat(dist); err == nil {
|
||||
r.Static("/assets", dist+"/assets")
|
||||
// /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")
|
||||
}
|
||||
|
||||
@@ -48,6 +54,8 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(dist); err == nil {
|
||||
// SPA 入口:必须每次回源校验,否则浏览器启发式缓存会保留引用已删除 chunk 的旧 HTML → 黑屏
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.File(dist + "/index.html")
|
||||
return
|
||||
}
|
||||
@@ -67,6 +75,16 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
|
||||
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)
|
||||
@@ -93,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,6 +8,7 @@ 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"
|
||||
@@ -25,6 +26,7 @@ type App struct {
|
||||
Usage *usage.Recorder
|
||||
Health *channel.HealthMonitor
|
||||
Limit *ratelimit.Limiter
|
||||
Passkeys *passkey.Service
|
||||
startedAt time.Time
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
@@ -57,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
|
||||
}
|
||||
|
||||
@@ -115,11 +124,10 @@ func (a *App) Seed() error {
|
||||
}
|
||||
// 默认模型 + 绑定
|
||||
m := store.Model{
|
||||
Name: a.Cfg.Proxy.DefaultModel,
|
||||
DisplayName: a.Cfg.Proxy.DefaultModel,
|
||||
InputPrice: 0.15, // 每百万 token,示例价
|
||||
Name: a.Cfg.Proxy.DefaultModel,
|
||||
InputPrice: 0.15, // 每百万 token,示例价
|
||||
OutputPrice: 0.60,
|
||||
Enabled: true,
|
||||
Enabled: true,
|
||||
}
|
||||
if err := a.DB.Create(&m).Error; err == nil {
|
||||
a.DB.Create(&store.ChannelModelBinding{ChannelID: ch.ID, ModelID: m.ID, UpstreamModel: m.Name})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -18,9 +18,17 @@ type Config struct {
|
||||
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=不限制)
|
||||
@@ -51,13 +59,14 @@ type AuthConfig struct {
|
||||
}
|
||||
|
||||
type ProxyConfig struct {
|
||||
DefaultChannelName string // 首次启动自动创建的渠道名(如 openai)
|
||||
UpstreamBaseURL string // 渠道 base_url 默认值
|
||||
UpstreamKey string // 渠道上游 key 默认值
|
||||
DefaultModel string // 渠道模型导入时使用的模型名
|
||||
Timeout time.Duration
|
||||
HealthInterval time.Duration // 渠道健康检查周期
|
||||
HealthFailThreshold int // 连续失败 N 次进 cooldown
|
||||
DefaultChannelName string // 首次启动自动创建的渠道名(如 openai)
|
||||
UpstreamBaseURL string // 渠道 base_url 默认值
|
||||
UpstreamKey string // 渠道上游 key 默认值
|
||||
DefaultModel string // 渠道模型导入时使用的模型名
|
||||
Timeout time.Duration
|
||||
HealthInterval time.Duration // 渠道健康检查周期
|
||||
HealthFailThreshold int // 连续失败 N 次进 cooldown
|
||||
LogRaw bool // 记录管理员原始请求体+响应到 usage_logs(调试用,默认关)
|
||||
}
|
||||
|
||||
// loadDotEnv 读取 .env 并把 KEY=VALUE 注入环境变量(AutomaticEnv 自动映射 OT_ 前缀)。
|
||||
@@ -118,12 +127,17 @@ 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"),
|
||||
Port: v.GetInt("port"),
|
||||
@@ -156,10 +170,16 @@ 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,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}})
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, map[string]any{"role": role, "content": strings.Join(text, "")})
|
||||
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 {
|
||||
out["input"] = input
|
||||
}
|
||||
// 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
|
||||
case strings.HasPrefix(str, "data:"):
|
||||
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
|
||||
model string
|
||||
stopReason string
|
||||
usage any
|
||||
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,47 +210,97 @@ 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 != "" {
|
||||
t.started = true
|
||||
out = append(out, eventLine("message_start", map[string]any{
|
||||
"type": "message_start",
|
||||
"message": map[string]any{
|
||||
"id": "msg_" + strings.TrimPrefix(id, "chatcmpl-"), "type": "message", "role": "assistant",
|
||||
"model": t.model, "content": []any{}, "usage": map[string]any{"input_tokens": 0, "output_tokens": 0},
|
||||
},
|
||||
}))
|
||||
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
|
||||
// message_start 只在实际有内容(文本或工具)时发出,避免 GLM 等模型的
|
||||
// reasoning_content 块(带 role 无 content)提前开出一个空文本块。
|
||||
ensureStarted := func() {
|
||||
if t.started {
|
||||
return
|
||||
}
|
||||
}
|
||||
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{}},
|
||||
}))
|
||||
out = append(out, eventLine("content_block_start", map[string]any{
|
||||
"type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""},
|
||||
}))
|
||||
}
|
||||
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},
|
||||
t.started = true
|
||||
out = append(out, eventLine("message_start", map[string]any{
|
||||
"type": "message_start",
|
||||
"message": map[string]any{
|
||||
"id": "msg_" + strings.TrimPrefix(id, "chatcmpl-"), "type": "message", "role": "assistant",
|
||||
"model": t.model, "content": []any{}, "usage": map[string]any{"input_tokens": 0, "output_tokens": 0},
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
// 文本:delta.content(string;兼容 {type:text,text} 数组)
|
||||
if content := deltaText(delta); content != "" {
|
||||
if t.textIndex < 0 {
|
||||
t.textIndex = t.nextIndex
|
||||
t.nextIndex++
|
||||
ensureStarted()
|
||||
out = append(out, eventLine("content_block_start", map[string]any{
|
||||
"type": "content_block_start", "index": t.textIndex, "content_block": map[string]any{"type": "text", "text": ""},
|
||||
}))
|
||||
t.openBlocks = append(t.openBlocks, t.textIndex)
|
||||
}
|
||||
out = append(out, eventLine("content_block_delta", map[string]any{
|
||||
"type": "content_block_delta", "index": t.textIndex, "delta": map[string]any{"type": "text_delta", "text": content},
|
||||
}))
|
||||
}
|
||||
|
||||
// 工具调用:delta.tool_calls(并行调用各 index 独立成块;arguments 支持整段/分段两种流式)
|
||||
if tcs, ok := delta["tool_calls"].([]any); ok {
|
||||
for _, tc := range tcs {
|
||||
call, ok := tc.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
idx, _ := call["index"].(float64)
|
||||
tcIdx := int(idx)
|
||||
fn, _ := call["function"].(map[string]any)
|
||||
name, _ := fn["name"].(string)
|
||||
args, _ := fn["arguments"].(string)
|
||||
blockIdx, seen := t.toolIdx[tcIdx]
|
||||
if !seen {
|
||||
blockIdx = t.nextIndex
|
||||
t.nextIndex++
|
||||
t.toolIdx[tcIdx] = blockIdx
|
||||
toolID, _ := call["id"].(string)
|
||||
ensureStarted()
|
||||
out = append(out, eventLine("content_block_start", map[string]any{
|
||||
"type": "content_block_start", "index": blockIdx, "content_block": map[string]any{
|
||||
"type": "tool_use", "id": toolID, "name": name, "input": map[string]any{},
|
||||
},
|
||||
}))
|
||||
t.openBlocks = append(t.openBlocks, blockIdx)
|
||||
}
|
||||
if args != "" {
|
||||
out = append(out, eventLine("content_block_delta", map[string]any{
|
||||
"type": "content_block_delta", "index": blockIdx, "delta": map[string]any{"type": "input_json_delta", "partial_json": args},
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if u, ok := m["usage"]; ok {
|
||||
t.usage = u
|
||||
}
|
||||
return joinLines(out)
|
||||
}
|
||||
|
||||
// deltaText 取 chat delta.content 文本(string 或 [{type:text,text}] 数组拼接)。
|
||||
func deltaText(delta map[string]any) string {
|
||||
if s, ok := delta["content"].(string); ok {
|
||||
return s
|
||||
}
|
||||
if arr, ok := delta["content"].([]any); ok {
|
||||
var parts []string
|
||||
for _, b := range arr {
|
||||
if bm, ok := b.(map[string]any); ok {
|
||||
if t, _ := bm["text"].(string); t != "" {
|
||||
parts = append(parts, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func stopReasonOrEnd(s string) string {
|
||||
if s == "" {
|
||||
return "end_turn"
|
||||
@@ -220,12 +313,19 @@ func stopReasonOrEnd(s string) string {
|
||||
|
||||
type responsesToMessages struct {
|
||||
sseState
|
||||
started bool
|
||||
model string
|
||||
usage any
|
||||
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,38 +343,93 @@ func (t *responsesToMessages) line(line []byte) []byte {
|
||||
}
|
||||
}
|
||||
var out [][]byte
|
||||
// message_start 只在 response.created 时发出;文本/工具块在对应事件到达时再开,
|
||||
// 避免纯函数调用响应提前开出一个空文本块。
|
||||
ensureStarted := func() {
|
||||
if t.started {
|
||||
return
|
||||
}
|
||||
t.started = true
|
||||
rid := ""
|
||||
if resp, ok := m["response"].(map[string]any); ok {
|
||||
rid, _ = resp["id"].(string)
|
||||
}
|
||||
out = append(out, eventLine("message_start", map[string]any{
|
||||
"type": "message_start",
|
||||
"message": map[string]any{
|
||||
"id": "msg_" + strings.TrimPrefix(rid, "resp_"), "type": "message", "role": "assistant",
|
||||
"model": t.model, "content": []any{},
|
||||
},
|
||||
}))
|
||||
}
|
||||
switch evt {
|
||||
case "response.created":
|
||||
if !t.started {
|
||||
t.started = true
|
||||
id, _ := m["response"].(map[string]any)
|
||||
rid := ""
|
||||
if id != nil {
|
||||
rid, _ = id["id"].(string)
|
||||
}
|
||||
out = append(out, eventLine("message_start", map[string]any{
|
||||
"type": "message_start",
|
||||
"message": map[string]any{
|
||||
"id": "msg_" + strings.TrimPrefix(rid, "resp_"), "type": "message", "role": "assistant",
|
||||
"model": t.model, "content": []any{},
|
||||
},
|
||||
}))
|
||||
out = append(out, eventLine("content_block_start", map[string]any{
|
||||
"type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""},
|
||||
}))
|
||||
}
|
||||
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": ""},
|
||||
}))
|
||||
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},
|
||||
}))
|
||||
out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": 0}))
|
||||
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)
|
||||
@@ -412,9 +567,10 @@ func (t *responsesToChat) line(line []byte) []byte {
|
||||
|
||||
type chatToResponses struct {
|
||||
sseState
|
||||
model string
|
||||
usage any
|
||||
done bool
|
||||
model string
|
||||
usage any
|
||||
finishSeen bool
|
||||
done bool
|
||||
}
|
||||
|
||||
func newChatToResponses() *chatToResponses { return &chatToResponses{} }
|
||||
@@ -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,10 +3,12 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -33,10 +35,81 @@ type Gateway struct {
|
||||
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, lim *ratelimit.Limiter, userRPS int) *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),
|
||||
@@ -44,7 +117,8 @@ func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ra
|
||||
enc: enc,
|
||||
lim: lim,
|
||||
userRPS: userRPS,
|
||||
hc: &http.Client{Timeout: 120 * time.Second},
|
||||
logRaw: logRaw,
|
||||
hc: &http.Client{Timeout: gw_timeout * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,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
|
||||
}
|
||||
@@ -129,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
|
||||
}
|
||||
@@ -148,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)
|
||||
}
|
||||
|
||||
@@ -179,19 +262,20 @@ func (g *Gateway) checkBalance(c *gin.Context, u *store.User) bool {
|
||||
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 {
|
||||
path string // 上游路径
|
||||
body []byte // 已转换的请求体
|
||||
lineConv func([]byte) []byte // 流式逐行转换(nil=直通)
|
||||
proto string // 上游协议(用于分协议 base_url)
|
||||
path string // 上游路径
|
||||
body []byte // 已转换的请求体
|
||||
lineConv func([]byte) []byte // 流式逐行转换(nil=直通)
|
||||
bodyConv func([]byte) ([]byte, error) // 非流式响应体转换(nil=直通)
|
||||
}
|
||||
|
||||
@@ -213,13 +297,14 @@ func conversionTarget(formats []string, clientProto string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// prepareUpstream 计算上游访问计划:渠道声明支持客户端协议则直通,否则转换。
|
||||
func prepareUpstream(ch *store.Channel, clientProto string, body []byte) (*upstreamPlan, error) {
|
||||
// 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{path: upstreamPath(target), body: body}
|
||||
plan := &upstreamPlan{proto: target, path: upstreamPath(target), body: body}
|
||||
if target != clientProto {
|
||||
converted, err := convert.ConvertRequest(body, clientProto, target)
|
||||
if err != nil {
|
||||
@@ -229,7 +314,26 @@ func prepareUpstream(ch *store.Channel, clientProto string, body []byte) (*upstr
|
||||
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,24 +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 + 路径。
|
||||
// 兼容用户填完整 base(含 /v1):去掉尾部 /v1,避免与请求路径重复。
|
||||
func upstreamURL(ch *store.Channel, path string) string {
|
||||
base := strings.TrimRight(ch.BaseURL, "/")
|
||||
return strings.TrimSuffix(base, "/v1") + 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")
|
||||
}
|
||||
|
||||
// doProxy 通用代理(M5):遍历候选渠道,按需转换;可安全重试的失败自动故障转移。
|
||||
func (g *Gateway) doProxy(c *gin.Context, cands []*store.Channel, clientProto string, body []byte, stream bool, sink *usageSink) {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
var lastStatus = http.StatusBadGateway
|
||||
var lastBody = []byte("all upstream channels failed")
|
||||
for _, ch := range cands {
|
||||
plan, err := prepareUpstream(ch, 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
|
||||
@@ -93,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}
|
||||
@@ -103,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")
|
||||
}
|
||||
@@ -115,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"} {
|
||||
@@ -127,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()
|
||||
@@ -171,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, "")
|
||||
}
|
||||
|
||||
@@ -182,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")
|
||||
}
|
||||
@@ -269,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
|
||||
@@ -298,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
|
||||
@@ -311,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
|
||||
}
|
||||
|
||||
@@ -336,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
|
||||
us usageShape
|
||||
outputText string
|
||||
}
|
||||
|
||||
func (u *usageSink) push(raw json.RawMessage) {
|
||||
@@ -348,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
|
||||
@@ -379,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,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)
|
||||
@@ -435,6 +630,14 @@ 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)
|
||||
@@ -460,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 (
|
||||
@@ -52,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"`
|
||||
@@ -80,12 +86,13 @@ 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(供应商/默认格式)
|
||||
Formats []string `gorm:"type:jsonb;serializer:json" json:"formats,omitempty"` // 原生支持的协议格式 chat|responses|messages
|
||||
BaseURL string `gorm:"size:255;not null" json:"base_url"`
|
||||
APIKeyEnc string `gorm:"size:1024;not null" json:"-"` // AES-GCM 密文
|
||||
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"`
|
||||
@@ -108,11 +115,36 @@ func (c *Channel) FormatsEffective() []string {
|
||||
}
|
||||
}
|
||||
|
||||
// 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"`
|
||||
@@ -155,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"`
|
||||
}
|
||||
|
||||
@@ -198,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"`
|
||||
@@ -216,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() {
|
||||
close(r.closed)
|
||||
r.wg.Wait()
|
||||
close(r.ch)
|
||||
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
|
||||
}
|
||||
// 扣余额(余额可为负:流式请求不中断;后续请求被拒)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { PhList } from '@phosphor-icons/vue'
|
||||
import {
|
||||
PhList, PhGauge, PhSquaresFour, PhKey, PhChartLine, PhChartBar, PhGear,
|
||||
PhShieldCheck, PhArrowLeft, PhGlobe, PhTag, PhUsers, PhSliders,
|
||||
} from '@phosphor-icons/vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { fmtMoney } from '@/lib/format'
|
||||
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
|
||||
@@ -9,6 +12,7 @@ import ThemeToggle from '@/components/ui/ThemeToggle.vue'
|
||||
interface NavItem {
|
||||
to: string
|
||||
label: string
|
||||
icon: unknown
|
||||
jump?: boolean // 跨区跳转(如 进入管理 / 返回控制台),不做激活态
|
||||
}
|
||||
|
||||
@@ -27,30 +31,30 @@ const sections = computed(() => {
|
||||
{
|
||||
title: '管理后台',
|
||||
items: [
|
||||
{ to: '/admin/overview', label: '总览' },
|
||||
{ to: '/admin/channels', label: '渠道管理' },
|
||||
{ to: '/admin/models', label: '模型定价' },
|
||||
{ to: '/admin/users', label: '用户管理' },
|
||||
{ to: '/admin/usage', label: '用量统计' },
|
||||
{ to: '/admin/config', label: '系统配置' },
|
||||
{ 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: '返回控制台', jump: true }] },
|
||||
{ title: '用户', items: [{ to: '/console/dashboard', label: '返回控制台', icon: PhArrowLeft, jump: true }] },
|
||||
]
|
||||
}
|
||||
const user: { title: string; items: NavItem[] }[] = [
|
||||
{
|
||||
title: '管理',
|
||||
items: [
|
||||
{ to: '/console/dashboard', label: '仪表盘' },
|
||||
{ to: '/console/keys', label: 'API 密钥' },
|
||||
{ to: '/console/usage', label: '用量明细' },
|
||||
{ to: '/console/settings', label: '账户设置' },
|
||||
{ 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: '管理', jump: true })
|
||||
user[0].items.push({ to: '/admin/overview', label: '管理', icon: PhShieldCheck, jump: true })
|
||||
}
|
||||
return user
|
||||
})
|
||||
@@ -98,7 +102,10 @@ async function logout() {
|
||||
:active-class="n.jump ? '' : 'bg-surface2 text-ink'"
|
||||
@click="navTo"
|
||||
>
|
||||
<span>{{ n.label }}</span>
|
||||
<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>
|
||||
@@ -109,25 +116,25 @@ async function logout() {
|
||||
</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-edge 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-muted hover:bg-surface2 hover:text-ink 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-muted">{{ 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-edge bg-surface px-2.5 py-1 text-xs text-accent">
|
||||
余额 {{ 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-muted transition hover:bg-surface2 hover:text-ink"
|
||||
class="rounded-md px-1.5 py-1 text-xs text-muted transition hover:bg-surface2 hover:text-ink md:px-2"
|
||||
@click="logout"
|
||||
>
|
||||
退出
|
||||
@@ -135,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>
|
||||
|
||||
@@ -9,6 +9,7 @@ withDefaults(
|
||||
error?: string
|
||||
autocomplete?: string
|
||||
disabled?: boolean
|
||||
maxlength?: number
|
||||
}>(),
|
||||
{ type: 'text', modelValue: '', disabled: false },
|
||||
)
|
||||
@@ -24,6 +25,7 @@ const emit = defineEmits<{ 'update:modelValue': [string | number] }>()
|
||||
:placeholder="placeholder"
|
||||
:autocomplete="autocomplete"
|
||||
:disabled="disabled"
|
||||
: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)"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,17 @@ export const PROTOCOL_NAMES: Record<string, string> = {
|
||||
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' },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+26
-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
|
||||
}
|
||||
|
||||
@@ -27,6 +29,7 @@ export interface Channel {
|
||||
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
|
||||
@@ -37,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
|
||||
@@ -48,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 {
|
||||
@@ -62,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
|
||||
@@ -69,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> {
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
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>
|
||||
@@ -13,22 +17,22 @@ const auth = useAuthStore()
|
||||
<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-muted transition hover:text-ink"
|
||||
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-muted transition hover:text-ink">
|
||||
<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-accent-ink 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>
|
||||
@@ -42,7 +46,7 @@ const auth = useAuthStore()
|
||||
<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-accent">LLM API 中转网关</p>
|
||||
<h1 class="text-4xl leading-none font-semibold tracking-tight md:text-5xl">
|
||||
<h1 class="text-3xl leading-tight font-semibold tracking-tight sm:text-4xl md:text-5xl">
|
||||
一个 Key,调用所有主流模型
|
||||
</h1>
|
||||
<p class="mt-5 max-w-md text-base leading-relaxed text-muted">
|
||||
@@ -70,12 +74,12 @@ const auth = useAuthStore()
|
||||
<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 api.openteam.dev</span>
|
||||
<span class="ml-2 text-muted">curl {{ host }}</span>
|
||||
</div>
|
||||
<div class="space-y-3 p-4 leading-relaxed">
|
||||
<div>
|
||||
<p class="text-muted"><span class="text-accent">$</span> curl https://api.openteam.dev/v1/chat/completions</p>
|
||||
<p class="text-muted"> -H <span class="text-accent">"Authorization: Bearer sk-..."</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-edge pt-3 text-muted">
|
||||
@@ -132,7 +136,7 @@ const auth = useAuthStore()
|
||||
</p>
|
||||
</div>
|
||||
<div class="card p-5">
|
||||
<p class="font-mono text-xs text-muted">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-muted">
|
||||
密钥仅存哈希,支持配额、过期与模型白名单,创建时一次性展示。
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<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'
|
||||
@@ -16,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() {
|
||||
@@ -33,6 +36,34 @@ 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>
|
||||
@@ -52,6 +83,15 @@ async function submit() {
|
||||
<Input v-model="password" label="密码" type="password" autocomplete="current-password" />
|
||||
<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-muted">
|
||||
|
||||
@@ -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,8 +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, protocolName } from '@/lib/protocol'
|
||||
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'
|
||||
@@ -15,15 +17,21 @@ 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: '',
|
||||
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,
|
||||
})
|
||||
@@ -40,8 +48,10 @@ async function load() {
|
||||
function openCreate() {
|
||||
editing.value = null
|
||||
Object.assign(form, {
|
||||
name: '', formats: ['chat'], 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
|
||||
}
|
||||
@@ -50,7 +60,13 @@ function openEdit(ch: Channel) {
|
||||
editing.value = ch
|
||||
Object.assign(form, {
|
||||
name: ch.name, formats: [...(ch.formats?.length ? ch.formats : ['chat'])],
|
||||
base_url: ch.base_url, api_key: '',
|
||||
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,
|
||||
})
|
||||
@@ -111,34 +127,72 @@ 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-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-edge text-left text-xs text-muted">
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">名称</th>
|
||||
@@ -151,18 +205,24 @@ 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-ink">{{ ch.name }}</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">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
<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="rounded-full bg-surface2 px-2 py-0.5 text-[10px] leading-4 text-muted"
|
||||
>{{ protocolName(f) }}</span>
|
||||
class="font-mono text-[11px] leading-4 text-muted"
|
||||
>{{ protocolShort(f) }}</code>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ ch.base_url }}</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'">
|
||||
@@ -172,15 +232,27 @@ onMounted(load)
|
||||
<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-muted 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-muted hover:text-accent" @click="importModels(ch)">导入模型</button>
|
||||
<button class="text-xs text-muted hover:text-ink" @click="openEdit(ch)">编辑</button>
|
||||
<button class="text-xs text-muted hover:text-err" @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-muted">还没有渠道,点击「添加渠道」</td>
|
||||
</tr>
|
||||
@@ -215,20 +287,47 @@ onMounted(load)
|
||||
<Input
|
||||
v-model="form.base_url"
|
||||
label="Base URL(可选)"
|
||||
placeholder="https://api.openai.com"
|
||||
hint="留空按供应商默认;可填完整地址,如 https://api.openai.com/v1"
|
||||
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,14 +65,59 @@ 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-muted">注册策略等平台级配置</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">
|
||||
@@ -77,20 +153,12 @@ onMounted(load)
|
||||
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-edge bg-surface px-4 py-3">
|
||||
<div>
|
||||
<p class="text-sm text-ink">其他配置项</p>
|
||||
<p class="text-xs text-muted">汇率、限流阈值、维护开关在后续里程碑开放</p>
|
||||
</div>
|
||||
<span class="font-mono text-xs text-muted">M3+</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button :loading="saving" @click="save">保存</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<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,83 +114,85 @@ 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-muted">价格按每百万 token (USD),历史用量按当时价格入账</p>
|
||||
<p class="text-sm text-muted">接口导入不全时可直接输入模型名添加,如 glm-4.7-flash</p>
|
||||
</div>
|
||||
<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>
|
||||
<Button @click="openCreate">添加模型</Button>
|
||||
</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">
|
||||
<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">
|
||||
<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-muted hover:text-accent" @click="openBind(m)">绑定渠道</button>
|
||||
<button class="text-xs text-muted hover:text-ink" @click="openEdit(m)">编辑</button>
|
||||
<button class="text-xs text-muted hover:text-err" @click="removeModel(m)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-edge bg-surface px-2 py-0.5 font-mono text-[11px] text-muted"
|
||||
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-muted hover:text-err" aria-label="移除绑定" @click="removeBinding(m, b.id)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="border-t border-edge px-4 py-2 text-xs text-muted">
|
||||
未绑定渠道,客户端无法调用该模型
|
||||
<p v-else class="border-t border-edge px-4 py-2 text-xs text-warn">
|
||||
悬空模型:无任何渠道提供,客户端无法调用
|
||||
</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-muted">模型 <span class="font-mono text-accent">{{ bindModel?.name }}</span> 通过以下渠道提供</p>
|
||||
<label class="block">
|
||||
<span class="mb-1.5 block text-xs font-medium text-muted">渠道</span>
|
||||
<select v-model="binding.channel_id" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm text-ink 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>
|
||||
|
||||
@@ -92,7 +92,7 @@ onMounted(load)
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="mono-num text-xs text-muted">{{ fmtCost(l.cost) }}</span>
|
||||
<Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge>
|
||||
<Badge :variant="l.status === 'success' ? 'ok' : l.status === 'canceled' ? 'neutral' : 'err'">{{ l.status }}</Badge>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="logs.length === 0" class="py-6 text-center text-xs text-muted">暂无请求</li>
|
||||
|
||||
@@ -2,24 +2,72 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { http, errMsg } from '@/api/client'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { protocolName } from '@/lib/protocol'
|
||||
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 type { UsageLog } from '@/types'
|
||||
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 { data } = await http.get(
|
||||
`/admin/usage?page=${page.value}&page_size=${pageSize}${modelFilter.value ? '&model=' + modelFilter.value : ''}`,
|
||||
)
|
||||
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) {
|
||||
@@ -32,6 +80,12 @@ function search() {
|
||||
load()
|
||||
}
|
||||
|
||||
function clearUser() {
|
||||
userFilter.value = ''
|
||||
userCandidates.value = []
|
||||
search()
|
||||
}
|
||||
|
||||
function goPage(p: number) {
|
||||
page.value = p
|
||||
load()
|
||||
@@ -42,52 +96,105 @@ 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-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 gap-2">
|
||||
<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 w-48 rounded-md border border-edge2 bg-surface px-3 font-mono text-xs outline-none focus:border-accent"
|
||||
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" @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">
|
||||
<!-- 移动端:卡片列表 -->
|
||||
<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">{{ protocolName(l.protocol) }}</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' : 'err'">{{ l.status }}</Badge>
|
||||
<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="8" class="px-4 py-10 text-center text-sm text-muted">暂无请求记录</td>
|
||||
<td colspan="10" class="px-4 py-10 text-center text-sm text-muted">暂无请求记录</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -100,5 +207,44 @@ onMounted(load)
|
||||
</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,25 +123,25 @@ 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-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-edge2 bg-surface 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-edge text-left text-xs text-muted">
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">ID</th>
|
||||
@@ -139,8 +172,17 @@ onMounted(load)
|
||||
<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-muted hover:text-ink" @click="openEdit(u)">编辑</button>
|
||||
<button class="text-xs text-muted 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>
|
||||
@@ -160,22 +202,45 @@ onMounted(load)
|
||||
</div>
|
||||
|
||||
<!-- 编辑用户 -->
|
||||
<Modal :open="editOpen" title="编辑用户" @close="editOpen = false">
|
||||
<Modal :open="editOpen" :title="`编辑用户 · ${editing?.username}`" @close="editOpen = false">
|
||||
<div class="space-y-4">
|
||||
<label class="block">
|
||||
<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 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-muted">状态</span>
|
||||
<select v-model="editForm.status" class="h-10 w-full rounded-md border border-edge2 bg-surface px-3 text-sm outline-none focus:border-accent">
|
||||
<option value="active">active</option>
|
||||
<option value="disabled">disabled</option>
|
||||
</select>
|
||||
</label>
|
||||
<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-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-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>
|
||||
|
||||
@@ -3,7 +3,9 @@ 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'
|
||||
@@ -44,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>
|
||||
|
||||
@@ -102,16 +122,18 @@ onMounted(load)
|
||||
</li>
|
||||
</ul>
|
||||
<ul v-else 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 font-mono text-xs text-ink">{{ l.model }}</p>
|
||||
<p class="text-[11px] text-muted">{{ fmtTime(l.created_at) }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="mono-num text-xs text-muted">{{ l.input_tokens }}/{{ l.output_tokens }}</span>
|
||||
<span class="mono-num w-16 text-right text-xs text-muted">{{ fmtCost(l.cost) }}</span>
|
||||
<Badge :variant="l.status === 'success' ? 'ok' : 'err'">{{ l.status }}</Badge>
|
||||
<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">
|
||||
还没有请求记录,去
|
||||
@@ -121,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, reactive, ref } from 'vue'
|
||||
import { PhCopy, PhCheck } from '@phosphor-icons/vue'
|
||||
import { PhCopy, PhCheck, PhNotePencil } from '@phosphor-icons/vue'
|
||||
import { http, errMsg } from '@/api/client'
|
||||
import { 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,7 +16,6 @@ const keys = ref<ApiKey[]>([])
|
||||
|
||||
const createOpen = ref(false)
|
||||
const keyName = ref('')
|
||||
const advOpen = ref(false)
|
||||
const createAdv = reactive({ quota_tokens_per_day: '', quota_requests_per_day: '', allowed_models: '' })
|
||||
const creating = ref(false)
|
||||
|
||||
@@ -45,6 +45,13 @@ function parseModels(s: string): string[] | undefined {
|
||||
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')
|
||||
@@ -70,7 +77,6 @@ async function createKey() {
|
||||
createOpen.value = false
|
||||
keyName.value = ''
|
||||
Object.assign(createAdv, { quota_tokens_per_day: '', quota_requests_per_day: '', allowed_models: '' })
|
||||
advOpen.value = false
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
@@ -113,11 +119,11 @@ async function saveEdit() {
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(k: ApiKey) {
|
||||
if (!confirm(`吊销密钥 ${k.name}?吊销后立即失效。`)) return
|
||||
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))
|
||||
@@ -126,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('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
@@ -149,15 +155,56 @@ onMounted(load)
|
||||
</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-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>
|
||||
@@ -165,26 +212,40 @@ onMounted(load)
|
||||
<tr v-for="k in keys" :key="k.id" class="table-row">
|
||||
<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-muted">{{ fmtTime(k.last_used_at) }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(k.created_at) }}</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<div class="flex justify-end gap-2.5">
|
||||
<button class="text-xs text-muted hover:text-ink" @click="openEdit(k)">编辑</button>
|
||||
<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-muted hover:text-err"
|
||||
@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-muted">
|
||||
<td colspan="7" class="px-4 py-10 text-center text-sm text-muted">
|
||||
还没有密钥,点击右上角「新建密钥」
|
||||
</td>
|
||||
</tr>
|
||||
@@ -197,18 +258,16 @@ 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>
|
||||
<button class="text-xs text-muted transition hover:text-ink" @click="advOpen = !advOpen">
|
||||
{{ advOpen ? '收起' : '展开' }}高级选项(配额 / 白名单)
|
||||
</button>
|
||||
<div v-if="advOpen" class="mt-3 space-y-4">
|
||||
<div class="grid 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" />
|
||||
</div>
|
||||
<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>
|
||||
@@ -220,7 +279,7 @@ onMounted(load)
|
||||
<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-2 gap-3">
|
||||
<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>
|
||||
@@ -232,7 +291,7 @@ onMounted(load)
|
||||
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>
|
||||
<option value="revoked">revoked(停用)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
@@ -246,11 +305,11 @@ onMounted(load)
|
||||
<Modal :open="!!created" title="密钥已创建" @close="created = null">
|
||||
<div class="space-y-4">
|
||||
<p class="text-xs text-muted">请复制并妥善保存,关闭后不再显示。</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<code class="mono-num flex-1 truncate rounded-md border border-accent 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 ? '已复制' : '复制' }}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
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'
|
||||
@@ -11,6 +13,71 @@ 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('')
|
||||
@@ -47,7 +114,7 @@ async function changePassword() {
|
||||
|
||||
<div class="card p-5">
|
||||
<h2 class="mb-4 text-sm font-semibold">个人资料</h2>
|
||||
<dl class="grid grid-cols-2 gap-x-6 gap-y-4 text-sm">
|
||||
<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>
|
||||
@@ -80,5 +147,34 @@ async function changePassword() {
|
||||
<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,7 +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 { protocolName } from '@/lib/protocol'
|
||||
import { protocolShort } from '@/lib/protocol'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import TrendChart from '@/components/ui/TrendChart.vue'
|
||||
import type { UsageLog } from '@/types'
|
||||
@@ -98,17 +98,41 @@ onMounted(load)
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between border-b border-edge 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-edge2 bg-surface 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-edge text-left text-xs text-muted">
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">模型</th>
|
||||
@@ -123,11 +147,11 @@ onMounted(load)
|
||||
<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-ink">{{ l.model }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ protocolName(l.protocol) }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ protocolShort(l.protocol) }}</td>
|
||||
<td class="px-4 py-2.5 mono-num text-xs text-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' : 'err'">{{ l.status }}</Badge></td>
|
||||
<td class="px-4 py-2.5"><Badge :variant="l.status === 'success' ? 'ok' : l.status === 'canceled' ? 'neutral' : 'err'">{{ l.status }}</Badge></td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ fmtTime(l.created_at) }}</td>
|
||||
</tr>
|
||||
<tr v-if="logs.length === 0">
|
||||
|
||||
Reference in New Issue
Block a user