Compare commits
32
Commits
3e7efb3c88
..
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 |
+7
-1
@@ -27,12 +27,15 @@ OT_MASTER_KEY=change-me-master-key
|
|||||||
OT_PROXY_UPSTREAM_KEY=
|
OT_PROXY_UPSTREAM_KEY=
|
||||||
OT_PROXY_UPSTREAM_BASE_URL=https://api.openai.com
|
OT_PROXY_UPSTREAM_BASE_URL=https://api.openai.com
|
||||||
OT_PROXY_DEFAULT_MODEL=gpt-4o-mini
|
OT_PROXY_DEFAULT_MODEL=gpt-4o-mini
|
||||||
OT_PROXY_TIMEOUT=120s
|
OT_PROXY_TIMEOUT=300s
|
||||||
|
|
||||||
# 渠道健康检查
|
# 渠道健康检查
|
||||||
OT_PROXY_HEALTH_INTERVAL=60s
|
OT_PROXY_HEALTH_INTERVAL=60s
|
||||||
OT_PROXY_HEALTH_FAIL_THRESHOLD=2
|
OT_PROXY_HEALTH_FAIL_THRESHOLD=2
|
||||||
|
|
||||||
|
# 调试:记录管理员的原始请求与响应到请求明细(默认关闭;流式记录全部事件)
|
||||||
|
OT_PROXY_LOG_RAW=false
|
||||||
|
|
||||||
# 限流(内存计数,Redis 后置):用户级每秒请求数上限(0=不限制)
|
# 限流(内存计数,Redis 后置):用户级每秒请求数上限(0=不限制)
|
||||||
OT_RATELIMIT_USER_RPS=20
|
OT_RATELIMIT_USER_RPS=20
|
||||||
|
|
||||||
@@ -45,3 +48,6 @@ OT_WEBAUTHN_RP_NAME=openteam
|
|||||||
OT_ADMIN_USERNAME=admin
|
OT_ADMIN_USERNAME=admin
|
||||||
OT_ADMIN_EMAIL=admin@localhost
|
OT_ADMIN_EMAIL=admin@localhost
|
||||||
OT_ADMIN_PASSWORD=admin123
|
OT_ADMIN_PASSWORD=admin123
|
||||||
|
|
||||||
|
# 记录原始请求日志
|
||||||
|
OT_PROXY_LOG_RAW=false
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ dist/
|
|||||||
# Go
|
# Go
|
||||||
server/bin/
|
server/bin/
|
||||||
server/data/
|
server/data/
|
||||||
|
# 本地静态托管软链(指向 ../web/dist),不入库
|
||||||
|
server/web/
|
||||||
scripts/mockupstream/bin/
|
scripts/mockupstream/bin/
|
||||||
|
|
||||||
# TypeScript 增量构建产物
|
# TypeScript 增量构建产物
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
.PHONY: run build test tidy mock-upstream web-dev web-build
|
.PHONY: run build test tidy mock-upstream web-dev web-build reset-admin-password reset-password
|
||||||
|
|
||||||
run:
|
run:
|
||||||
cd server && go run ./cmd/server
|
cd server && go run ./cmd/server
|
||||||
|
|
||||||
|
reset-admin-password:
|
||||||
|
cd server && go run ./cmd/server reset-admin-password
|
||||||
|
|
||||||
|
reset-password:
|
||||||
|
cd server && go run ./cmd/server reset-password
|
||||||
|
|
||||||
build:
|
build:
|
||||||
cd server && go build -o bin/openteam ./cmd/server
|
cd server && go build -o bin/openteam ./cmd/server
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -275,8 +275,8 @@ Anthropic /v1/messages ──┘
|
|||||||
|
|
||||||
#### 5.3.3 API Key
|
#### 5.3.3 API Key
|
||||||
|
|
||||||
- 格式:`sk-` + 48 位随机 base62,**创建时仅展示一次**。
|
- 格式:`sk-ot-` + 48 位随机 base62,**创建时仅展示一次**。
|
||||||
- 存储:仅 SHA-256 哈希 + 展示前缀(如 `sk-aB3c…`);请求时哈希后查表。
|
- 存储:仅 SHA-256 哈希 + 展示前缀(如 `sk-ot-aB3c…`);请求时哈希后查表。
|
||||||
- 附加能力:密钥级配额(每日 token / 请求数)、模型白名单、过期时间、启停。
|
- 附加能力:密钥级配额(每日 token / 请求数)、模型白名单、过期时间、启停。
|
||||||
- 限额检查用 Redis 计数,与用户级限流叠加。
|
- 限额检查用 Redis 计数,与用户级限流叠加。
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
- **限流/配额**(内存计数):密钥级每日请求数 / Token 数配额、用户级每秒速率(`OT_RATELIMIT_USER_RPS`);超限返回 429
|
- **限流/配额**(内存计数):密钥级每日请求数 / Token 数配额、用户级每秒速率(`OT_RATELIMIT_USER_RPS`);超限返回 429
|
||||||
- **前端**:Dashboard / 管理总览骨架屏加载态
|
- **前端**:Dashboard / 管理总览骨架屏加载态
|
||||||
- **用户体系**:注册(开放/邀请码可切换,管理后台可改)、登录(JWT access + HttpOnly refresh cookie)、argon2id 密码
|
- **用户体系**:注册(开放/邀请码可切换,管理后台可改)、登录(JWT access + HttpOnly refresh cookie)、argon2id 密码
|
||||||
- **API Key**:`sk-` 48 位 base62,仅存 SHA-256 哈希,明文一次性展示;支持限额/过期/白名单字段
|
- **API Key**:`sk-ot-` 48 位 base62,仅存 SHA-256 哈希,明文一次性展示;支持限额/过期/白名单字段
|
||||||
- **用量计费**:请求级 `usage_logs` 异步批量落库,按模型价格扣减余额,日粒度预聚合(`usage_daily`)
|
- **用量计费**:请求级 `usage_logs` 异步批量落库,按模型价格扣减余额,日粒度预聚合(`usage_daily`)
|
||||||
- **管理 API**:渠道 CRUD + 连通测试 + 模型导入、模型管理 + 定价 + 渠道绑定、用户管理、全局用量/统计、系统配置
|
- **管理 API**:渠道 CRUD + 连通测试 + 模型导入、模型管理 + 定价 + 渠道绑定、用户管理、全局用量/统计、系统配置
|
||||||
- **前端**(Vue3 + Tailwind,taste-skill 设计,深色优先)
|
- **前端**(Vue3 + Tailwind,taste-skill 设计,深色优先)
|
||||||
|
|||||||
@@ -2,60 +2,8 @@
|
|||||||
// 启动:OT_PROXY_UPSTREAM_KEY=sk-xxx go run ./cmd/server
|
// 启动:OT_PROXY_UPSTREAM_KEY=sk-xxx go run ./cmd/server
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import "github.com/openteam/server/internal/cli"
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"strconv"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/openteam/server/internal/api"
|
|
||||||
"github.com/openteam/server/internal/app"
|
|
||||||
"github.com/openteam/server/internal/config"
|
|
||||||
"github.com/openteam/server/internal/proxy"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg, err := config.Load()
|
cli.Execute()
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
a, err := app.New(cfg)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("app init: %v", err)
|
|
||||||
}
|
|
||||||
defer a.Shutdown(context.Background())
|
|
||||||
|
|
||||||
gw := proxy.NewGateway(a.DB, a.Enc, a.Usage, 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()
|
|
||||||
}
|
}
|
||||||
@@ -7,9 +7,11 @@ require (
|
|||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/go-webauthn/webauthn v0.17.4
|
github.com/go-webauthn/webauthn v0.17.4
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
github.com/tiktoken-go/tokenizer v0.8.1
|
github.com/tiktoken-go/tokenizer v0.8.1
|
||||||
golang.org/x/crypto v0.55.0
|
golang.org/x/crypto v0.55.0
|
||||||
|
golang.org/x/term v0.45.0
|
||||||
gorm.io/driver/postgres v1.6.2
|
gorm.io/driver/postgres v1.6.2
|
||||||
gorm.io/gorm v1.31.2
|
gorm.io/gorm v1.31.2
|
||||||
)
|
)
|
||||||
@@ -35,6 +37,7 @@ require (
|
|||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/google/go-tpm v0.9.8 // indirect
|
github.com/google/go-tpm v0.9.8 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.10.0 // indirect
|
github.com/jackc/pgx/v5 v5.10.0 // indirect
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiD
|
|||||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -60,6 +61,8 @@ github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbu
|
|||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
@@ -106,6 +109,7 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
|
|||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||||
@@ -114,6 +118,9 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
|||||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
@@ -159,6 +166,8 @@ golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
|||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
|
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ func (h *Handler) AdminChannelAddModel(c *gin.Context) {
|
|||||||
// 解析或创建全局模型(客户端名)
|
// 解析或创建全局模型(客户端名)
|
||||||
var m store.Model
|
var m store.Model
|
||||||
if err := h.a.DB.Where("name = ?", globalName).First(&m).Error; err != nil {
|
if err := h.a.DB.Where("name = ?", globalName).First(&m).Error; err != nil {
|
||||||
m = store.Model{Name: globalName, DisplayName: globalName, Enabled: true}
|
m = store.Model{Name: globalName, Enabled: true}
|
||||||
if err := h.a.DB.Create(&m).Error; err != nil {
|
if err := h.a.DB.Create(&m).Error; err != nil {
|
||||||
resp.Fail(c, http.StatusInternalServerError, "failed to create model")
|
resp.Fail(c, http.StatusInternalServerError, "failed to create model")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ func (h *Handler) AdminCreateChannel(c *gin.Context) {
|
|||||||
Name: req.Name, Provider: req.Provider, Formats: formats, BaseURL: baseURL,
|
Name: req.Name, Provider: req.Provider, Formats: formats, BaseURL: baseURL,
|
||||||
BaseURLs: normalizeBaseURLs(req.BaseURLs),
|
BaseURLs: normalizeBaseURLs(req.BaseURLs),
|
||||||
APIKeyEnc: enc, Weight: intOr(req.Weight, 1), Priority: intOr(req.Priority, 0),
|
APIKeyEnc: enc, Weight: intOr(req.Weight, 1), Priority: intOr(req.Priority, 0),
|
||||||
TimeoutMS: intOr(req.TimeoutMS, 120000), MaxConcurrency: intOr(req.MaxConcurrency, 16),
|
TimeoutMS: intOr(req.TimeoutMS, 300000), MaxConcurrency: intOr(req.MaxConcurrency, 16),
|
||||||
HealthStatus: store.ChannelHealthHealthy, Enabled: boolOr(req.Enabled, true),
|
HealthStatus: store.ChannelHealthHealthy, Enabled: boolOr(req.Enabled, true),
|
||||||
}
|
}
|
||||||
if err := h.a.DB.Create(&ch).Error; err != nil {
|
if err := h.a.DB.Create(&ch).Error; err != nil {
|
||||||
|
|||||||
@@ -29,16 +29,19 @@ func (h *Handler) AdminModels(c *gin.Context) {
|
|||||||
h.a.DB.Preload("Channel").Where("model_id = ?", m.ID).Find(&bindings)
|
h.a.DB.Preload("Channel").Where("model_id = ?", m.ID).Find(&bindings)
|
||||||
chs := make([]gin.H, 0, len(bindings))
|
chs := make([]gin.H, 0, len(bindings))
|
||||||
for _, b := range bindings {
|
for _, b := range bindings {
|
||||||
|
if !b.Channel.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
chs = append(chs, gin.H{
|
chs = append(chs, gin.H{
|
||||||
"id": b.ID, "channel_id": b.ChannelID, "channel_name": b.Channel.Name,
|
"id": b.ID, "channel_id": b.ChannelID, "channel_name": b.Channel.Name,
|
||||||
"upstream_model": b.UpstreamModel, "weight": b.Weight,
|
"upstream_model": b.UpstreamModel, "weight": b.Weight,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
used := len(bindings) > 0
|
used := len(chs) > 0
|
||||||
needsPricing := used && m.InputPrice == 0 && m.OutputPrice == 0 && m.CacheReadPrice == 0
|
needsPricing := used && m.InputPrice == 0 && m.OutputPrice == 0 && m.CacheReadPrice == 0
|
||||||
denied := containsStr(deny, m.Name) || (len(allow) > 0 && !containsStr(allow, m.Name))
|
denied := containsStr(deny, m.Name) || (len(allow) > 0 && !containsStr(allow, m.Name))
|
||||||
out = append(out, gin.H{
|
out = append(out, gin.H{
|
||||||
"id": m.ID, "name": m.Name, "display_name": m.DisplayName,
|
"id": m.ID, "name": m.Name,
|
||||||
"input_price": m.InputPrice, "output_price": m.OutputPrice, "cache_read_price": m.CacheReadPrice,
|
"input_price": m.InputPrice, "output_price": m.OutputPrice, "cache_read_price": m.CacheReadPrice,
|
||||||
"enabled": m.Enabled, "sort": m.Sort, "channels": chs,
|
"enabled": m.Enabled, "sort": m.Sort, "channels": chs,
|
||||||
"used": used, "needs_pricing": needsPricing, "denied": denied,
|
"used": used, "needs_pricing": needsPricing, "denied": denied,
|
||||||
@@ -116,7 +119,6 @@ func containsStr(list []string, s string) bool {
|
|||||||
func (h *Handler) AdminCreateModel(c *gin.Context) {
|
func (h *Handler) AdminCreateModel(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name" binding:"required,min=1,max=128"`
|
Name string `json:"name" binding:"required,min=1,max=128"`
|
||||||
DisplayName string `json:"display_name"`
|
|
||||||
InputPrice float64 `json:"input_price"`
|
InputPrice float64 `json:"input_price"`
|
||||||
OutputPrice float64 `json:"output_price"`
|
OutputPrice float64 `json:"output_price"`
|
||||||
CacheReadPrice float64 `json:"cache_read_price"`
|
CacheReadPrice float64 `json:"cache_read_price"`
|
||||||
@@ -127,13 +129,10 @@ func (h *Handler) AdminCreateModel(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
m := store.Model{
|
m := store.Model{
|
||||||
Name: req.Name, DisplayName: req.DisplayName,
|
Name: req.Name,
|
||||||
InputPrice: req.InputPrice, OutputPrice: req.OutputPrice, CacheReadPrice: req.CacheReadPrice,
|
InputPrice: req.InputPrice, OutputPrice: req.OutputPrice, CacheReadPrice: req.CacheReadPrice,
|
||||||
Enabled: boolOr(req.Enabled, true),
|
Enabled: boolOr(req.Enabled, true),
|
||||||
}
|
}
|
||||||
if m.DisplayName == "" {
|
|
||||||
m.DisplayName = m.Name
|
|
||||||
}
|
|
||||||
if err := h.a.DB.Create(&m).Error; err != nil {
|
if err := h.a.DB.Create(&m).Error; err != nil {
|
||||||
resp.Fail(c, http.StatusConflict, "failed to create model (name may already exist)")
|
resp.Fail(c, http.StatusConflict, "failed to create model (name may already exist)")
|
||||||
return
|
return
|
||||||
@@ -141,7 +140,7 @@ func (h *Handler) AdminCreateModel(c *gin.Context) {
|
|||||||
resp.Created(c, gin.H{"id": m.ID, "name": m.Name})
|
resp.Created(c, gin.H{"id": m.ID, "name": m.Name})
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminUpdateModel PUT /api/v1/admin/models/:id — 价格/展示名/启停/排序。
|
// AdminUpdateModel PUT /api/v1/admin/models/:id — 价格/启停/排序。
|
||||||
func (h *Handler) AdminUpdateModel(c *gin.Context) {
|
func (h *Handler) AdminUpdateModel(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -149,7 +148,6 @@ func (h *Handler) AdminUpdateModel(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
DisplayName *string `json:"display_name"`
|
|
||||||
InputPrice *float64 `json:"input_price"`
|
InputPrice *float64 `json:"input_price"`
|
||||||
OutputPrice *float64 `json:"output_price"`
|
OutputPrice *float64 `json:"output_price"`
|
||||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||||
@@ -166,9 +164,6 @@ func (h *Handler) AdminUpdateModel(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
updates := map[string]any{}
|
updates := map[string]any{}
|
||||||
if req.DisplayName != nil {
|
|
||||||
updates["display_name"] = *req.DisplayName
|
|
||||||
}
|
|
||||||
if req.InputPrice != nil {
|
if req.InputPrice != nil {
|
||||||
updates["input_price"] = *req.InputPrice
|
updates["input_price"] = *req.InputPrice
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,11 +90,17 @@ func (h *Handler) AdminUsage(c *gin.Context) {
|
|||||||
for _, l := range logs {
|
for _, l := range logs {
|
||||||
var uname string
|
var uname string
|
||||||
h.a.DB.Model(&store.User{}).Where("id = ?", l.UserID).Pluck("username", &uname)
|
h.a.DB.Model(&store.User{}).Where("id = ?", l.UserID).Pluck("username", &uname)
|
||||||
|
var chName string
|
||||||
|
if l.ChannelID > 0 {
|
||||||
|
h.a.DB.Model(&store.Channel{}).Where("id = ?", l.ChannelID).Pluck("name", &chName)
|
||||||
|
}
|
||||||
out = append(out, gin.H{
|
out = append(out, gin.H{
|
||||||
"id": l.ID, "user": uname, "user_id": l.UserID, "model": l.ModelName, "protocol": l.Protocol,
|
"id": l.ID, "user": uname, "user_id": l.UserID, "model": l.ModelName, "protocol": l.Protocol,
|
||||||
|
"channel_id": l.ChannelID, "channel": chName,
|
||||||
"input_tokens": l.InputTokens, "output_tokens": l.OutputTokens,
|
"input_tokens": l.InputTokens, "output_tokens": l.OutputTokens,
|
||||||
"cache_read_tokens": l.CacheReadTokens, "cost": l.Cost,
|
"cache_read_tokens": l.CacheReadTokens, "cost": l.Cost,
|
||||||
"latency_ms": l.LatencyMS, "status": l.Status, "error_code": l.ErrorCode,
|
"latency_ms": l.LatencyMS, "status": l.Status, "error_code": l.ErrorCode,
|
||||||
|
"raw_request": l.RawRequest, "raw_response": l.RawResponse,
|
||||||
"created_at": l.CreatedAt,
|
"created_at": l.CreatedAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,15 +49,22 @@ func (h *Handler) todayUsage(c *gin.Context, userID uint64) gin.H {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UserModels GET /api/v1/user/models — 控制台可用模型列表(无需 API Key)。
|
// UserModels GET /api/v1/user/models — 控制台可用模型列表(无需 API Key)。
|
||||||
|
// 仅返回启用的模型且至少绑定到一个启用且健康的渠道,与 /v1/models 口径一致。
|
||||||
func (h *Handler) UserModels(c *gin.Context) {
|
func (h *Handler) UserModels(c *gin.Context) {
|
||||||
var ms []store.Model
|
var names []string
|
||||||
if err := h.a.DB.Where("enabled = ?", true).Order("sort ASC, id ASC").Find(&ms).Error; err != nil {
|
if err := h.a.DB.Table("models").
|
||||||
|
Joins("JOIN channel_model_bindings ON channel_model_bindings.model_id = models.id").
|
||||||
|
Joins("JOIN channels ON channels.id = channel_model_bindings.channel_id").
|
||||||
|
Where("models.enabled = ? AND channels.enabled = ? AND channels.health_status = ?",
|
||||||
|
true, true, store.ChannelHealthHealthy).
|
||||||
|
Distinct("models.name").
|
||||||
|
Order("models.sort ASC, models.id ASC").
|
||||||
|
Pluck("models.name", &names).Error; err != nil {
|
||||||
resp.Fail(c, http.StatusInternalServerError, "failed to load models")
|
resp.Fail(c, http.StatusInternalServerError, "failed to load models")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := make([]string, 0, len(ms))
|
if names == nil {
|
||||||
for _, m := range ms {
|
names = []string{}
|
||||||
out = append(out, m.Name)
|
|
||||||
}
|
}
|
||||||
resp.OK(c, gin.H{"items": out})
|
resp.OK(c, gin.H{"items": names})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,6 @@ func (a *App) Seed() error {
|
|||||||
// 默认模型 + 绑定
|
// 默认模型 + 绑定
|
||||||
m := store.Model{
|
m := store.Model{
|
||||||
Name: a.Cfg.Proxy.DefaultModel,
|
Name: a.Cfg.Proxy.DefaultModel,
|
||||||
DisplayName: a.Cfg.Proxy.DefaultModel,
|
|
||||||
InputPrice: 0.15, // 每百万 token,示例价
|
InputPrice: 0.15, // 每百万 token,示例价
|
||||||
OutputPrice: 0.60,
|
OutputPrice: 0.60,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
|
|||||||
@@ -87,6 +87,20 @@ func (s *Service) loadBound(bindings []store.ChannelModelBinding) []Candidate {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 按权重加权随机选一个候选渠道(负载均衡)。
|
// Pick 按权重加权随机选一个候选渠道(负载均衡)。
|
||||||
func (s *Service) Pick(cands []Candidate) *store.Channel {
|
func (s *Service) Pick(cands []Candidate) *store.Channel {
|
||||||
if len(cands) == 0 {
|
if len(cands) == 0 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -66,6 +66,7 @@ type ProxyConfig struct {
|
|||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
HealthInterval time.Duration // 渠道健康检查周期
|
HealthInterval time.Duration // 渠道健康检查周期
|
||||||
HealthFailThreshold int // 连续失败 N 次进 cooldown
|
HealthFailThreshold int // 连续失败 N 次进 cooldown
|
||||||
|
LogRaw bool // 记录管理员原始请求体+响应到 usage_logs(调试用,默认关)
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadDotEnv 读取 .env 并把 KEY=VALUE 注入环境变量(AutomaticEnv 自动映射 OT_ 前缀)。
|
// loadDotEnv 读取 .env 并把 KEY=VALUE 注入环境变量(AutomaticEnv 自动映射 OT_ 前缀)。
|
||||||
@@ -126,9 +127,10 @@ func Load() (*Config, error) {
|
|||||||
v.SetDefault("proxy.upstream_base_url", "https://api.openai.com")
|
v.SetDefault("proxy.upstream_base_url", "https://api.openai.com")
|
||||||
v.SetDefault("proxy.upstream_key", "")
|
v.SetDefault("proxy.upstream_key", "")
|
||||||
v.SetDefault("proxy.default_model", "gpt-4o-mini")
|
v.SetDefault("proxy.default_model", "gpt-4o-mini")
|
||||||
v.SetDefault("proxy.timeout", "120s")
|
v.SetDefault("proxy.timeout", "300s")
|
||||||
v.SetDefault("proxy.health_interval", "60s")
|
v.SetDefault("proxy.health_interval", "60s")
|
||||||
v.SetDefault("proxy.health_fail_threshold", 2)
|
v.SetDefault("proxy.health_fail_threshold", 2)
|
||||||
|
v.SetDefault("proxy.log_raw", false)
|
||||||
|
|
||||||
v.SetDefault("ratelimit.user_rps", 20)
|
v.SetDefault("ratelimit.user_rps", 20)
|
||||||
|
|
||||||
@@ -168,6 +170,7 @@ func Load() (*Config, error) {
|
|||||||
Timeout: v.GetDuration("proxy.timeout"),
|
Timeout: v.GetDuration("proxy.timeout"),
|
||||||
HealthInterval: v.GetDuration("proxy.health_interval"),
|
HealthInterval: v.GetDuration("proxy.health_interval"),
|
||||||
HealthFailThreshold: v.GetInt("proxy.health_fail_threshold"),
|
HealthFailThreshold: v.GetInt("proxy.health_fail_threshold"),
|
||||||
|
LogRaw: v.GetBool("proxy.log_raw"),
|
||||||
},
|
},
|
||||||
RateLimit: RateLimitConfig{
|
RateLimit: RateLimitConfig{
|
||||||
UserRPS: v.GetInt("ratelimit.user_rps"),
|
UserRPS: v.GetInt("ratelimit.user_rps"),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Package apikey 生成与管理 API Key:sk- + 48 位 base62 随机串。
|
// Package apikey 生成与管理 API Key:sk-ot- + 48 位 base62 随机串。
|
||||||
// 库中仅存 SHA-256 哈希与展示前缀(PLANNING §4.3.3)。
|
// 库中仅存 SHA-256 哈希与展示前缀(PLANNING §4.3.3)。
|
||||||
package apikey
|
package apikey
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||||
keyLen = 48
|
keyLen = 48
|
||||||
prefix = "sk-"
|
prefix = "sk-ot-"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Generate 生成明文 key(仅创建时展示一次)与哈希、前缀。
|
// Generate 生成明文 key(仅创建时展示一次)与哈希、前缀。
|
||||||
@@ -34,7 +34,7 @@ func Hash(key string) string {
|
|||||||
return hex.EncodeToString(sum[:])
|
return hex.EncodeToString(sum[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefix 展示前缀:sk-aB3cD5…(前 12 字符)
|
// Prefix 展示前缀:sk-ot-aB3c…(前 12 字符)
|
||||||
func Prefix(key string) string {
|
func Prefix(key string) string {
|
||||||
if len(key) <= 12 {
|
if len(key) <= 12 {
|
||||||
return key
|
return key
|
||||||
|
|||||||
@@ -3,32 +3,35 @@ package apikey
|
|||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestGenerateValid(t *testing.T) {
|
func TestGenerateValid(t *testing.T) {
|
||||||
plain, hash, prefix, err := Generate()
|
plain, hash, keyPrefix, err := Generate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Generate: %v", err)
|
t.Fatalf("Generate: %v", err)
|
||||||
}
|
}
|
||||||
if !Valid(plain) {
|
if !Valid(plain) {
|
||||||
t.Fatalf("generated key invalid: %q", plain)
|
t.Fatalf("generated key invalid: %q", plain)
|
||||||
}
|
}
|
||||||
if len(plain) != len("sk-")+48 {
|
if len(plain) != len(prefix)+keyLen {
|
||||||
t.Fatalf("unexpected key length: %d", len(plain))
|
t.Fatalf("unexpected key length: %d", len(plain))
|
||||||
}
|
}
|
||||||
|
if keyPrefix != plain[:12] {
|
||||||
|
t.Fatalf("prefix mismatch: %s vs %s", keyPrefix, plain[:12])
|
||||||
|
}
|
||||||
if Hash(plain) != hash {
|
if Hash(plain) != hash {
|
||||||
t.Fatal("hash mismatch")
|
t.Fatal("hash mismatch")
|
||||||
}
|
}
|
||||||
if prefix != plain[:12] {
|
|
||||||
t.Fatalf("prefix mismatch: %s vs %s", prefix, plain[:12])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHashStable(t *testing.T) {
|
func TestHashStable(t *testing.T) {
|
||||||
if Hash("sk-test") != Hash("sk-test") {
|
if Hash("sk-ot-test") != Hash("sk-ot-test") {
|
||||||
t.Fatal("hash not stable")
|
t.Fatal("hash not stable")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidRejects(t *testing.T) {
|
func TestValidRejects(t *testing.T) {
|
||||||
cases := []string{"", "sk-abc", "abc-123456789012345678901234567890123456789012345678", "sk-1234567890123456789012345678901234567890123456789"}
|
tooShort := prefix + "abc"
|
||||||
|
tooLong := prefix + "1234567890123456789012345678901234567890123456789"
|
||||||
|
wrongPrefix := "abc-ot123456789012345678901234567890123456789012345678"
|
||||||
|
cases := []string{"", tooShort, wrongPrefix, tooLong}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
if Valid(c) {
|
if Valid(c) {
|
||||||
t.Fatalf("expected invalid: %q", c)
|
t.Fatalf("expected invalid: %q", c)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 流式转换
|
// 流式转换
|
||||||
|
|
||||||
@@ -549,3 +594,431 @@ func TestMessagesToResponsesReqImage(t *testing.T) {
|
|||||||
t.Fatalf("input_image = %v", img)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ func anthropicMsgToChat(role string, content json.RawMessage) []any {
|
|||||||
var blocks []map[string]any
|
var blocks []map[string]any
|
||||||
if json.Unmarshal(content, &blocks) == nil && blocks != nil {
|
if json.Unmarshal(content, &blocks) == nil && blocks != nil {
|
||||||
var out []any
|
var out []any
|
||||||
|
var toolMsgs []any // tool_result 单独收集,保证排在 assistant(tool_calls) 之后
|
||||||
var textParts []string
|
var textParts []string
|
||||||
var contentBlocks []any // text / image_url 块,保留原始顺序
|
var contentBlocks []any // text / image_url 块,保留原始顺序
|
||||||
var toolCalls []any
|
var toolCalls []any
|
||||||
@@ -259,7 +260,7 @@ func anthropicMsgToChat(role string, content json.RawMessage) []any {
|
|||||||
case "tool_result":
|
case "tool_result":
|
||||||
callID, _ := b["tool_use_id"].(string)
|
callID, _ := b["tool_use_id"].(string)
|
||||||
res := strField(b["content"])
|
res := strField(b["content"])
|
||||||
out = append(out, map[string]any{"role": "tool", "tool_call_id": callID, "content": res})
|
toolMsgs = append(toolMsgs, map[string]any{"role": "tool", "tool_call_id": callID, "content": res})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
hasImage := false
|
hasImage := false
|
||||||
@@ -282,6 +283,7 @@ func anthropicMsgToChat(role string, content json.RawMessage) []any {
|
|||||||
}
|
}
|
||||||
out = append(out, msg)
|
out = append(out, msg)
|
||||||
}
|
}
|
||||||
|
out = append(out, toolMsgs...)
|
||||||
if len(out) > 0 {
|
if len(out) > 0 {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,11 +240,9 @@ func chatToResponsesReq(body []byte) ([]byte, error) {
|
|||||||
if len(system) > 0 {
|
if len(system) > 0 {
|
||||||
out["instructions"] = strings.Join(system, "\n")
|
out["instructions"] = strings.Join(system, "\n")
|
||||||
}
|
}
|
||||||
if len(input) == 1 {
|
// input 必须是数组:部分上游(如火山方舟)只接受 []*InputItem,
|
||||||
out["input"] = input[0] // 单条消息项
|
// 单对象会被拒(400 Mismatch type)。
|
||||||
} else {
|
|
||||||
out["input"] = input
|
out["input"] = input
|
||||||
}
|
|
||||||
|
|
||||||
if len(req.Tools) > 0 {
|
if len(req.Tools) > 0 {
|
||||||
tools := make([]any, 0, len(req.Tools))
|
tools := make([]any, 0, len(req.Tools))
|
||||||
@@ -305,8 +303,11 @@ func responsesToChatResp(body []byte) ([]byte, error) {
|
|||||||
msg["tool_calls"] = toolCalls
|
msg["tool_calls"] = toolCalls
|
||||||
}
|
}
|
||||||
finish := "stop"
|
finish := "stop"
|
||||||
if string(rawJSON(m, "status")) == `"incomplete"` {
|
switch {
|
||||||
finish = "length"
|
case string(rawJSON(m, "status")) == `"incomplete"`:
|
||||||
|
finish = "length" // 截断优先,客户端可据此区分
|
||||||
|
case len(toolCalls) > 0:
|
||||||
|
finish = "tool_calls" // 有工具调用时映射 tool_calls,否则中转后 stop_reason 成 end_turn
|
||||||
}
|
}
|
||||||
var prompt, completion int64
|
var prompt, completion int64
|
||||||
if u := rawJSON(m, "usage"); u != nil {
|
if u := rawJSON(m, "usage"); u != nil {
|
||||||
|
|||||||
@@ -11,16 +11,17 @@ type sseState struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseLine 解析一行 SSE;返回是否 data 行及其内容、是否 [DONE]。
|
// parseLine 解析一行 SSE;返回是否 data 行及其内容、是否 [DONE]。
|
||||||
|
// data: 后可跟空格(标准)或紧贴 JSON(上游如火山方舟会省略空格)。
|
||||||
func (s *sseState) parseLine(line []byte) (isData bool, data string, done bool) {
|
func (s *sseState) parseLine(line []byte) (isData bool, data string, done bool) {
|
||||||
str := strings.TrimRight(string(line), "\r\n")
|
str := strings.TrimRight(string(line), "\r\n")
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(str, "event: "):
|
case strings.HasPrefix(str, "event: "):
|
||||||
s.event = strings.TrimSpace(strings.TrimPrefix(str, "event: "))
|
s.event = strings.TrimSpace(strings.TrimPrefix(str, "event: "))
|
||||||
return false, "", false
|
return false, "", false
|
||||||
case str == "data: [DONE]":
|
case str == "data: [DONE]" || str == "data:[DONE]":
|
||||||
return true, "[DONE]", true
|
return true, "[DONE]", true
|
||||||
case strings.HasPrefix(str, "data:"):
|
case strings.HasPrefix(str, "data:"):
|
||||||
return true, strings.TrimPrefix(str, "data: "), false
|
return true, strings.TrimLeft(strings.TrimPrefix(str, "data:"), " "), false
|
||||||
default:
|
default:
|
||||||
return false, "", false
|
return false, "", false
|
||||||
}
|
}
|
||||||
@@ -49,9 +50,11 @@ func eventLine(name string, obj any) []byte {
|
|||||||
type messagesToChat struct {
|
type messagesToChat struct {
|
||||||
sseState
|
sseState
|
||||||
id, model string
|
id, model string
|
||||||
|
toolIdx map[int]int // messages content block index → chat tool_calls index(顺序编号,避开文本块)
|
||||||
|
nextTool int
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMessagesToChat() *messagesToChat { return &messagesToChat{} }
|
func newMessagesToChat() *messagesToChat { return &messagesToChat{toolIdx: map[int]int{}} }
|
||||||
|
|
||||||
func (t *messagesToChat) line(line []byte) []byte {
|
func (t *messagesToChat) line(line []byte) []byte {
|
||||||
isData, data, done := t.parseLine(line)
|
isData, data, done := t.parseLine(line)
|
||||||
@@ -72,8 +75,43 @@ func (t *messagesToChat) line(line []byte) []byte {
|
|||||||
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
|
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
|
||||||
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"role": "assistant"}, "finish_reason": nil}},
|
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"role": "assistant"}, "finish_reason": nil}},
|
||||||
})
|
})
|
||||||
|
case "content_block_start":
|
||||||
|
cb, _ := m["content_block"].(map[string]any)
|
||||||
|
if cb == nil || cb["type"] != "tool_use" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
blockIdx, _ := m["index"].(float64)
|
||||||
|
tool := t.nextTool
|
||||||
|
t.nextTool++
|
||||||
|
t.toolIdx[int(blockIdx)] = tool
|
||||||
|
toolID, _ := cb["id"].(string)
|
||||||
|
name, _ := cb["name"].(string)
|
||||||
|
return dataLine(map[string]any{
|
||||||
|
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
|
||||||
|
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{
|
||||||
|
"tool_calls": []any{map[string]any{"index": tool, "id": toolID, "type": "function", "function": map[string]any{"name": name, "arguments": ""}}},
|
||||||
|
}, "finish_reason": nil}},
|
||||||
|
})
|
||||||
case "content_block_delta":
|
case "content_block_delta":
|
||||||
delta, _ := m["delta"].(map[string]any)
|
delta, _ := m["delta"].(map[string]any)
|
||||||
|
deltaType, _ := delta["type"].(string)
|
||||||
|
if deltaType == "input_json_delta" {
|
||||||
|
blockIdx, _ := m["index"].(float64)
|
||||||
|
tool, ok := t.toolIdx[int(blockIdx)]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
partial, _ := delta["partial_json"].(string)
|
||||||
|
if partial == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return dataLine(map[string]any{
|
||||||
|
"id": "chatcmpl-" + strings.TrimPrefix(t.id, "msg_"), "object": "chat.completion.chunk", "model": t.model,
|
||||||
|
"choices": []any{map[string]any{"index": 0, "delta": map[string]any{
|
||||||
|
"tool_calls": []any{map[string]any{"index": tool, "function": map[string]any{"arguments": partial}}},
|
||||||
|
}, "finish_reason": nil}},
|
||||||
|
})
|
||||||
|
}
|
||||||
text, _ := delta["text"].(string)
|
text, _ := delta["text"].(string)
|
||||||
if text == "" {
|
if text == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -118,14 +156,19 @@ func joinLines(lines [][]byte) []byte {
|
|||||||
|
|
||||||
type chatToMessages struct {
|
type chatToMessages struct {
|
||||||
sseState
|
sseState
|
||||||
started bool
|
started bool // message_start 已发出
|
||||||
blockStarted bool
|
nextIndex int // 下一个 content block index(顺序分配)
|
||||||
|
textIndex int // 文本块 index;-1 = 未开始
|
||||||
|
toolIdx map[int]int // chat delta.tool_calls[].index → messages block index
|
||||||
|
openBlocks []int // 已开始未停止的 block index,按开始顺序
|
||||||
model string
|
model string
|
||||||
stopReason string
|
stopReason string
|
||||||
usage any
|
usage any
|
||||||
}
|
}
|
||||||
|
|
||||||
func newChatToMessages() *chatToMessages { return &chatToMessages{} }
|
func newChatToMessages() *chatToMessages {
|
||||||
|
return &chatToMessages{textIndex: -1, toolIdx: map[int]int{}}
|
||||||
|
}
|
||||||
|
|
||||||
func (t *chatToMessages) line(line []byte) []byte {
|
func (t *chatToMessages) line(line []byte) []byte {
|
||||||
isData, data, done := t.parseLine(line)
|
isData, data, done := t.parseLine(line)
|
||||||
@@ -133,18 +176,18 @@ func (t *chatToMessages) line(line []byte) []byte {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if done {
|
if done {
|
||||||
// 汇聚最终 message_delta + content_block_stop + message_stop
|
// 汇聚最终:先对每个已开始未停止的块发 content_block_stop,再 message_delta + message_stop
|
||||||
|
var out [][]byte
|
||||||
|
for _, idx := range t.openBlocks {
|
||||||
|
out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": idx}))
|
||||||
|
}
|
||||||
md := map[string]any{"type": "message_delta", "delta": map[string]any{
|
md := map[string]any{"type": "message_delta", "delta": map[string]any{
|
||||||
"stop_reason": stopReasonOrEnd(t.stopReason), "stop_sequence": nil,
|
"stop_reason": stopReasonOrEnd(t.stopReason), "stop_sequence": nil,
|
||||||
}}
|
}}
|
||||||
if t.usage != nil {
|
if t.usage != nil {
|
||||||
md["usage"] = t.usage
|
md["usage"] = t.usage
|
||||||
}
|
}
|
||||||
var out [][]byte
|
|
||||||
out = append(out, eventLine("message_delta", md))
|
out = append(out, eventLine("message_delta", md))
|
||||||
if t.blockStarted {
|
|
||||||
out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": 0}))
|
|
||||||
}
|
|
||||||
out = append(out, eventLine("message_stop", map[string]any{"type": "message_stop"}))
|
out = append(out, eventLine("message_stop", map[string]any{"type": "message_stop"}))
|
||||||
return joinLines(out)
|
return joinLines(out)
|
||||||
}
|
}
|
||||||
@@ -167,11 +210,12 @@ func (t *chatToMessages) line(line []byte) []byte {
|
|||||||
id, _ := m["id"].(string)
|
id, _ := m["id"].(string)
|
||||||
|
|
||||||
var out [][]byte
|
var out [][]byte
|
||||||
// 首个包含内容或角色的块前,先发 message_start + content_block_start
|
// message_start 只在实际有内容(文本或工具)时发出,避免 GLM 等模型的
|
||||||
if !t.started {
|
// reasoning_content 块(带 role 无 content)提前开出一个空文本块。
|
||||||
role, _ := delta["role"].(string)
|
ensureStarted := func() {
|
||||||
content, _ := delta["content"].(string)
|
if t.started {
|
||||||
if role == "assistant" || content != "" {
|
return
|
||||||
|
}
|
||||||
t.started = true
|
t.started = true
|
||||||
out = append(out, eventLine("message_start", map[string]any{
|
out = append(out, eventLine("message_start", map[string]any{
|
||||||
"type": "message_start",
|
"type": "message_start",
|
||||||
@@ -180,34 +224,83 @@ func (t *chatToMessages) line(line []byte) []byte {
|
|||||||
"model": t.model, "content": []any{}, "usage": map[string]any{"input_tokens": 0, "output_tokens": 0},
|
"model": t.model, "content": []any{}, "usage": map[string]any{"input_tokens": 0, "output_tokens": 0},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
out = append(out, eventLine("content_block_start", map[string]any{
|
|
||||||
"type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""},
|
|
||||||
}))
|
|
||||||
t.blockStarted = true
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if content, _ := delta["content"].(string); content != "" {
|
// 文本:delta.content(string;兼容 {type:text,text} 数组)
|
||||||
if !t.started {
|
if content := deltaText(delta); content != "" {
|
||||||
t.started = true
|
if t.textIndex < 0 {
|
||||||
t.blockStarted = true
|
t.textIndex = t.nextIndex
|
||||||
out = append(out, eventLine("message_start", map[string]any{
|
t.nextIndex++
|
||||||
"type": "message_start",
|
ensureStarted()
|
||||||
"message": map[string]any{"id": "msg_" + strings.TrimPrefix(id, "chatcmpl-"), "type": "message", "role": "assistant", "model": t.model, "content": []any{}},
|
|
||||||
}))
|
|
||||||
out = append(out, eventLine("content_block_start", map[string]any{
|
out = append(out, eventLine("content_block_start", map[string]any{
|
||||||
"type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""},
|
"type": "content_block_start", "index": t.textIndex, "content_block": map[string]any{"type": "text", "text": ""},
|
||||||
}))
|
}))
|
||||||
|
t.openBlocks = append(t.openBlocks, t.textIndex)
|
||||||
}
|
}
|
||||||
out = append(out, eventLine("content_block_delta", map[string]any{
|
out = append(out, eventLine("content_block_delta", map[string]any{
|
||||||
"type": "content_block_delta", "index": 0, "delta": map[string]any{"type": "text_delta", "text": content},
|
"type": "content_block_delta", "index": t.textIndex, "delta": map[string]any{"type": "text_delta", "text": content},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 工具调用:delta.tool_calls(并行调用各 index 独立成块;arguments 支持整段/分段两种流式)
|
||||||
|
if tcs, ok := delta["tool_calls"].([]any); ok {
|
||||||
|
for _, tc := range tcs {
|
||||||
|
call, ok := tc.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx, _ := call["index"].(float64)
|
||||||
|
tcIdx := int(idx)
|
||||||
|
fn, _ := call["function"].(map[string]any)
|
||||||
|
name, _ := fn["name"].(string)
|
||||||
|
args, _ := fn["arguments"].(string)
|
||||||
|
blockIdx, seen := t.toolIdx[tcIdx]
|
||||||
|
if !seen {
|
||||||
|
blockIdx = t.nextIndex
|
||||||
|
t.nextIndex++
|
||||||
|
t.toolIdx[tcIdx] = blockIdx
|
||||||
|
toolID, _ := call["id"].(string)
|
||||||
|
ensureStarted()
|
||||||
|
out = append(out, eventLine("content_block_start", map[string]any{
|
||||||
|
"type": "content_block_start", "index": blockIdx, "content_block": map[string]any{
|
||||||
|
"type": "tool_use", "id": toolID, "name": name, "input": map[string]any{},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
t.openBlocks = append(t.openBlocks, blockIdx)
|
||||||
|
}
|
||||||
|
if args != "" {
|
||||||
|
out = append(out, eventLine("content_block_delta", map[string]any{
|
||||||
|
"type": "content_block_delta", "index": blockIdx, "delta": map[string]any{"type": "input_json_delta", "partial_json": args},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if u, ok := m["usage"]; ok {
|
if u, ok := m["usage"]; ok {
|
||||||
t.usage = u
|
t.usage = u
|
||||||
}
|
}
|
||||||
return joinLines(out)
|
return joinLines(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deltaText 取 chat delta.content 文本(string 或 [{type:text,text}] 数组拼接)。
|
||||||
|
func deltaText(delta map[string]any) string {
|
||||||
|
if s, ok := delta["content"].(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if arr, ok := delta["content"].([]any); ok {
|
||||||
|
var parts []string
|
||||||
|
for _, b := range arr {
|
||||||
|
if bm, ok := b.(map[string]any); ok {
|
||||||
|
if t, _ := bm["text"].(string); t != "" {
|
||||||
|
parts = append(parts, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func stopReasonOrEnd(s string) string {
|
func stopReasonOrEnd(s string) string {
|
||||||
if s == "" {
|
if s == "" {
|
||||||
return "end_turn"
|
return "end_turn"
|
||||||
@@ -223,9 +316,16 @@ type responsesToMessages struct {
|
|||||||
started bool
|
started bool
|
||||||
model string
|
model string
|
||||||
usage any
|
usage any
|
||||||
|
nextIndex int // 下一个 content block index(顺序分配)
|
||||||
|
textIndex int // 文本块 index;-1 = 未开始
|
||||||
|
toolIdx map[string]int // function_call item_id → messages block index
|
||||||
|
openBlocks []int // 已开始未停止的 block index,按开始顺序
|
||||||
|
anyTool bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newResponsesToMessages() *responsesToMessages { return &responsesToMessages{} }
|
func newResponsesToMessages() *responsesToMessages {
|
||||||
|
return &responsesToMessages{textIndex: -1, toolIdx: map[string]int{}}
|
||||||
|
}
|
||||||
|
|
||||||
func (t *responsesToMessages) line(line []byte) []byte {
|
func (t *responsesToMessages) line(line []byte) []byte {
|
||||||
isData, data, done := t.parseLine(line)
|
isData, data, done := t.parseLine(line)
|
||||||
@@ -243,14 +343,16 @@ func (t *responsesToMessages) line(line []byte) []byte {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
var out [][]byte
|
var out [][]byte
|
||||||
switch evt {
|
// message_start 只在 response.created 时发出;文本/工具块在对应事件到达时再开,
|
||||||
case "response.created":
|
// 避免纯函数调用响应提前开出一个空文本块。
|
||||||
if !t.started {
|
ensureStarted := func() {
|
||||||
|
if t.started {
|
||||||
|
return
|
||||||
|
}
|
||||||
t.started = true
|
t.started = true
|
||||||
id, _ := m["response"].(map[string]any)
|
|
||||||
rid := ""
|
rid := ""
|
||||||
if id != nil {
|
if resp, ok := m["response"].(map[string]any); ok {
|
||||||
rid, _ = id["id"].(string)
|
rid, _ = resp["id"].(string)
|
||||||
}
|
}
|
||||||
out = append(out, eventLine("message_start", map[string]any{
|
out = append(out, eventLine("message_start", map[string]any{
|
||||||
"type": "message_start",
|
"type": "message_start",
|
||||||
@@ -259,22 +361,75 @@ func (t *responsesToMessages) line(line []byte) []byte {
|
|||||||
"model": t.model, "content": []any{},
|
"model": t.model, "content": []any{},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
out = append(out, eventLine("content_block_start", map[string]any{
|
|
||||||
"type": "content_block_start", "index": 0, "content_block": map[string]any{"type": "text", "text": ""},
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
switch evt {
|
||||||
|
case "response.created":
|
||||||
|
ensureStarted()
|
||||||
case "response.output_text.delta":
|
case "response.output_text.delta":
|
||||||
delta, _ := m["delta"].(string)
|
delta, _ := m["delta"].(string)
|
||||||
if delta != "" {
|
if delta == "" {
|
||||||
out = append(out, eventLine("content_block_delta", map[string]any{
|
return nil
|
||||||
"type": "content_block_delta", "index": 0, "delta": map[string]any{"type": "text_delta", "text": delta},
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
case "response.completed":
|
if t.textIndex < 0 {
|
||||||
out = append(out, eventLine("message_delta", map[string]any{
|
t.textIndex = t.nextIndex
|
||||||
"type": "message_delta", "delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil},
|
t.nextIndex++
|
||||||
|
ensureStarted()
|
||||||
|
out = append(out, eventLine("content_block_start", map[string]any{
|
||||||
|
"type": "content_block_start", "index": t.textIndex, "content_block": map[string]any{"type": "text", "text": ""},
|
||||||
}))
|
}))
|
||||||
out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": 0}))
|
t.openBlocks = append(t.openBlocks, t.textIndex)
|
||||||
|
}
|
||||||
|
out = append(out, eventLine("content_block_delta", map[string]any{
|
||||||
|
"type": "content_block_delta", "index": t.textIndex, "delta": map[string]any{"type": "text_delta", "text": delta},
|
||||||
|
}))
|
||||||
|
case "response.output_item.added":
|
||||||
|
item, _ := m["item"].(map[string]any)
|
||||||
|
if item == nil || item["type"] != "function_call" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
blockIdx := t.nextIndex
|
||||||
|
t.nextIndex++
|
||||||
|
t.anyTool = true
|
||||||
|
itemID, _ := item["id"].(string)
|
||||||
|
t.toolIdx[itemID] = blockIdx
|
||||||
|
toolUseID, _ := item["call_id"].(string)
|
||||||
|
if toolUseID == "" {
|
||||||
|
toolUseID = itemID
|
||||||
|
}
|
||||||
|
name, _ := item["name"].(string)
|
||||||
|
ensureStarted()
|
||||||
|
out = append(out, eventLine("content_block_start", map[string]any{
|
||||||
|
"type": "content_block_start", "index": blockIdx, "content_block": map[string]any{
|
||||||
|
"type": "tool_use", "id": toolUseID, "name": name, "input": map[string]any{},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
t.openBlocks = append(t.openBlocks, blockIdx)
|
||||||
|
case "response.function_call_arguments.delta":
|
||||||
|
itemID, _ := m["item_id"].(string)
|
||||||
|
blockIdx, ok := t.toolIdx[itemID]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
delta, _ := m["delta"].(string)
|
||||||
|
if delta == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out = append(out, eventLine("content_block_delta", map[string]any{
|
||||||
|
"type": "content_block_delta", "index": blockIdx, "delta": map[string]any{"type": "input_json_delta", "partial_json": delta},
|
||||||
|
}))
|
||||||
|
case "response.completed":
|
||||||
|
for _, idx := range t.openBlocks {
|
||||||
|
out = append(out, eventLine("content_block_stop", map[string]any{"type": "content_block_stop", "index": idx}))
|
||||||
|
}
|
||||||
|
stop := "end_turn"
|
||||||
|
if t.anyTool {
|
||||||
|
stop = "tool_use"
|
||||||
|
}
|
||||||
|
md := map[string]any{"type": "message_delta", "delta": map[string]any{"stop_reason": stop, "stop_sequence": nil}}
|
||||||
|
if t.usage != nil {
|
||||||
|
md["usage"] = t.usage
|
||||||
|
}
|
||||||
|
out = append(out, eventLine("message_delta", md))
|
||||||
out = append(out, eventLine("message_stop", map[string]any{"type": "message_stop"}))
|
out = append(out, eventLine("message_stop", map[string]any{"type": "message_stop"}))
|
||||||
}
|
}
|
||||||
return joinLines(out)
|
return joinLines(out)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type Gateway struct {
|
|||||||
enc *crypto.Encryptor
|
enc *crypto.Encryptor
|
||||||
lim *ratelimit.Limiter
|
lim *ratelimit.Limiter
|
||||||
userRPS int
|
userRPS int
|
||||||
|
logRaw bool
|
||||||
hc *http.Client
|
hc *http.Client
|
||||||
|
|
||||||
policyMu sync.Mutex
|
policyMu sync.Mutex
|
||||||
@@ -108,7 +109,7 @@ func contains(list []string, s string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ratelimit.Limiter, userRPS int) *Gateway {
|
func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ratelimit.Limiter, userRPS int, logRaw bool, gw_timeout time.Duration) *Gateway {
|
||||||
return &Gateway{
|
return &Gateway{
|
||||||
db: db,
|
db: db,
|
||||||
ch: channel.NewService(db, enc),
|
ch: channel.NewService(db, enc),
|
||||||
@@ -116,7 +117,8 @@ func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ra
|
|||||||
enc: enc,
|
enc: enc,
|
||||||
lim: lim,
|
lim: lim,
|
||||||
userRPS: userRPS,
|
userRPS: userRPS,
|
||||||
hc: &http.Client{Timeout: 120 * time.Second},
|
logRaw: logRaw,
|
||||||
|
hc: &http.Client{Timeout: gw_timeout * time.Second},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +142,7 @@ func (g *Gateway) Auth(c *gin.Context) {
|
|||||||
key = strings.TrimSpace(c.GetHeader("x-api-key"))
|
key = strings.TrimSpace(c.GetHeader("x-api-key"))
|
||||||
}
|
}
|
||||||
if !apikey.Valid(key) {
|
if !apikey.Valid(key) {
|
||||||
apiError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key format. Expected: Authorization: Bearer sk-... or x-api-key: sk-...")
|
apiError(c, http.StatusUnauthorized, "invalid_api_key", "Invalid API key format. Expected: Authorization: Bearer sk-ot-... or x-api-key: sk-ot-...")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -205,10 +207,15 @@ func (g *Gateway) Handle(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// models GET /v1/models:返回启用的全局模型(OpenAI 风格)。
|
// models GET /v1/models:返回对外可见的模型(启用且至少绑定到一个启用且健康的渠道;OpenAI 风格)。
|
||||||
func (g *Gateway) models(c *gin.Context) {
|
func (g *Gateway) models(c *gin.Context) {
|
||||||
|
ids := g.ch.AvailableModelIDs()
|
||||||
|
if len(ids) == 0 {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"object": "list", "data": []gin.H{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
var ms []store.Model
|
var ms []store.Model
|
||||||
if err := g.db.Where("enabled = ?", true).Order("sort ASC, id ASC").Find(&ms).Error; err != nil {
|
if err := g.db.Where("id IN ? AND enabled = ?", ids, true).Order("sort ASC, id ASC").Find(&ms).Error; err != nil {
|
||||||
apiError(c, http.StatusInternalServerError, "internal_error", "failed to load models")
|
apiError(c, http.StatusInternalServerError, "internal_error", "failed to load models")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/openteam/server/internal/proxy/convert"
|
"github.com/openteam/server/internal/proxy/convert"
|
||||||
|
"github.com/openteam/server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// chatCompletions POST /v1/chat/completions
|
// chatCompletions POST /v1/chat/completions
|
||||||
@@ -23,6 +24,7 @@ func (g *Gateway) chatCompletions(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.Set("protocol", convert.ProtoChat)
|
c.Set("protocol", convert.ProtoChat)
|
||||||
c.Set("model_name", br.Model)
|
c.Set("model_name", br.Model)
|
||||||
|
g.recordRawRequest(c, u, body)
|
||||||
if !g.checkModelAllowed(u, br.Model) {
|
if !g.checkModelAllowed(u, br.Model) {
|
||||||
apiError(c, http.StatusForbidden, "model_not_allowed", "模型未对你开放,请联系管理员")
|
apiError(c, http.StatusForbidden, "model_not_allowed", "模型未对你开放,请联系管理员")
|
||||||
return
|
return
|
||||||
@@ -55,6 +57,7 @@ func (g *Gateway) responses(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.Set("protocol", convert.ProtoResponses)
|
c.Set("protocol", convert.ProtoResponses)
|
||||||
c.Set("model_name", br.Model)
|
c.Set("model_name", br.Model)
|
||||||
|
g.recordRawRequest(c, u, body)
|
||||||
if !g.checkModelAllowed(u, br.Model) {
|
if !g.checkModelAllowed(u, br.Model) {
|
||||||
apiError(c, http.StatusForbidden, "model_not_allowed", "模型未对你开放,请联系管理员")
|
apiError(c, http.StatusForbidden, "model_not_allowed", "模型未对你开放,请联系管理员")
|
||||||
return
|
return
|
||||||
@@ -87,6 +90,7 @@ func (g *Gateway) messages(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.Set("protocol", convert.ProtoMessages)
|
c.Set("protocol", convert.ProtoMessages)
|
||||||
c.Set("model_name", br.Model)
|
c.Set("model_name", br.Model)
|
||||||
|
g.recordRawRequest(c, u, body)
|
||||||
if !g.checkModelAllowed(u, br.Model) {
|
if !g.checkModelAllowed(u, br.Model) {
|
||||||
apiError(c, http.StatusForbidden, "model_not_allowed", "模型未对你开放,请联系管理员")
|
apiError(c, http.StatusForbidden, "model_not_allowed", "模型未对你开放,请联系管理员")
|
||||||
return
|
return
|
||||||
@@ -108,6 +112,14 @@ type sinkHolder struct {
|
|||||||
sink *usageSink
|
sink *usageSink
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordRawRequest 记录管理员原始请求体到 context(供 finishUsage 落库)。
|
||||||
|
// 仅当开关开启且用户为管理员时记录;响应侧以 c.Get("raw_request") 是否非空判断是否需要捕获响应。
|
||||||
|
func (g *Gateway) recordRawRequest(c *gin.Context, u *store.User, body []byte) {
|
||||||
|
if g.logRaw && u.Role == store.RoleAdmin {
|
||||||
|
c.Set("raw_request", string(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// apiError 按客户端协议返回错误体(PLANNING §5.1.4)。
|
// apiError 按客户端协议返回错误体(PLANNING §5.1.4)。
|
||||||
func apiError(c *gin.Context, status int, code, message string) {
|
func apiError(c *gin.Context, status int, code, message string) {
|
||||||
if p, _ := c.Get("protocol"); p == convert.ProtoMessages {
|
if p, _ := c.Get("protocol"); p == convert.ProtoMessages {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package proxy
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -101,14 +100,32 @@ func requestText(body []byte) string {
|
|||||||
return strings.Join(parts, "\n")
|
return strings.Join(parts, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// sseContentText 提取一条 SSE data 行中的内容文本(chat delta.content / responses delta / messages delta.text)。
|
// sseDataPayload 提取一条 SSE 的 JSON 载荷(去掉 data: 前缀与空白)。
|
||||||
func sseContentText(line []byte) string {
|
// 兼容三种写法:
|
||||||
|
// - 单 data: 行:data: {...} 或 data:{...}(上游如火山方舟会省略 data: 后的空格)
|
||||||
|
// - event:+data: 多行块:转换器 eventLine 产出的块(event: xxx\ndata: {...} 拼在一个 []byte)
|
||||||
|
func sseDataPayload(line []byte) (string, bool) {
|
||||||
s := string(line)
|
s := string(line)
|
||||||
if strings.HasPrefix(s, "data: ") {
|
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:")
|
s = strings.TrimPrefix(s, "data:")
|
||||||
|
} else {
|
||||||
|
return "", false
|
||||||
}
|
}
|
||||||
|
s = strings.TrimLeft(s, " ") // data: 后的可选空格
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
if s == "" || s == "[DONE]" {
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
var m map[string]any
|
var m map[string]any
|
||||||
@@ -146,11 +163,29 @@ func upstreamURL(ch *store.Channel, proto, path string) string {
|
|||||||
return ch.UpstreamURL(proto, path)
|
return ch.UpstreamURL(proto, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// doProxy 通用代理(M5):遍历候选渠道,按需转换;可安全重试的失败自动故障转移。
|
// doProxy 通用代理(M5):加权随机选一个候选渠道;失败自动故障转移。
|
||||||
func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto string, body []byte, stream bool, sink *usageSink) {
|
func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto string, body []byte, stream bool, sink *usageSink) {
|
||||||
|
if len(cands) == 0 {
|
||||||
|
apiError(c, http.StatusBadGateway, "upstream_error", "no available channel")
|
||||||
|
g.recordError(c, nil, nil, now(), "no_available_channel")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 加权随机选择起始渠道
|
||||||
|
picked := g.ch.Pick(cands)
|
||||||
|
startIdx := 0
|
||||||
|
for i, cand := range cands {
|
||||||
|
if cand.Channel.ID == picked.ID {
|
||||||
|
startIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var lastStatus = http.StatusBadGateway
|
var lastStatus = http.StatusBadGateway
|
||||||
var lastBody = []byte("all upstream channels failed")
|
var lastBody = []byte("all upstream channels failed")
|
||||||
for _, cand := range cands {
|
// 从选中的渠道开始遍历,到末尾后再从头遍历到选中渠道之前
|
||||||
|
for offset := 0; offset < len(cands); offset++ {
|
||||||
|
idx := (startIdx + offset) % len(cands)
|
||||||
|
cand := cands[idx]
|
||||||
ch := cand.Channel
|
ch := cand.Channel
|
||||||
plan, err := prepareUpstream(ch, clientProto, body, cand.UpstreamModel)
|
plan, err := prepareUpstream(ch, clientProto, body, cand.UpstreamModel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -200,9 +235,7 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(ch.TimeoutMS)*time.Millisecond)
|
req, err := http.NewRequest(http.MethodPost, upstreamURL(ch, plan.proto, plan.path), bytes.NewReader(upBody))
|
||||||
defer cancel()
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL(ch, plan.proto, plan.path), bytes.NewReader(upBody))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, false, http.StatusInternalServerError, []byte("failed to build upstream request")
|
return false, false, http.StatusInternalServerError, []byte("failed to build upstream request")
|
||||||
}
|
}
|
||||||
@@ -224,9 +257,6 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
|
|||||||
start := time.Now()
|
start := time.Now()
|
||||||
resp, err := g.hc.Do(req)
|
resp, err := g.hc.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.Err() == context.DeadlineExceeded {
|
|
||||||
return false, true, http.StatusGatewayTimeout, []byte("upstream request timed out")
|
|
||||||
}
|
|
||||||
return false, true, http.StatusBadGateway, []byte("upstream request failed: " + err.Error())
|
return false, true, http.StatusBadGateway, []byte("upstream request failed: " + err.Error())
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -268,6 +298,9 @@ func (g *Gateway) copyAndCapture(c *gin.Context, ch *store.Channel, r io.Reader,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_, _ = c.Writer.Write(out)
|
_, _ = c.Writer.Write(out)
|
||||||
|
if _, ok := c.Get("raw_request"); ok {
|
||||||
|
c.Set("raw_response", string(data)) // 上游原始响应(未转换)
|
||||||
|
}
|
||||||
g.finishUsage(c, ch, start, store.UsageStatusSuccess, "")
|
g.finishUsage(c, ch, start, store.UsageStatusSuccess, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,10 +312,24 @@ func (g *Gateway) streamCopy(c *gin.Context, ch *store.Channel, r io.Reader, sta
|
|||||||
flusher = nopFlusher{}
|
flusher = nopFlusher{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 原始响应捕获:仅管理员且开关开启(raw_request 已 set)时累积上游原始行
|
||||||
|
_, capture := c.Get("raw_request")
|
||||||
|
var rawResp strings.Builder
|
||||||
|
|
||||||
|
// commitRaw 在记账前把已累积的原始响应写入 context
|
||||||
|
commitRaw := func() {
|
||||||
|
if capture {
|
||||||
|
c.Set("raw_response", rawResp.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
scanner := newSSEScanner(r)
|
scanner := newSSEScanner(r)
|
||||||
for {
|
for {
|
||||||
line, err := scanner.Next()
|
line, err := scanner.Next()
|
||||||
if line != nil {
|
if line != nil {
|
||||||
|
if capture {
|
||||||
|
rawResp.Write(line)
|
||||||
|
}
|
||||||
out := line
|
out := line
|
||||||
if lineConv != nil {
|
if lineConv != nil {
|
||||||
out = lineConv(line)
|
out = lineConv(line)
|
||||||
@@ -290,6 +337,7 @@ func (g *Gateway) streamCopy(c *gin.Context, ch *store.Channel, r io.Reader, sta
|
|||||||
if out != nil {
|
if out != nil {
|
||||||
if _, werr := w.Write(out); werr != nil {
|
if _, werr := w.Write(out); werr != nil {
|
||||||
// 客户端意外断开:按已生成部分收费(canceled)
|
// 客户端意外断开:按已生成部分收费(canceled)
|
||||||
|
commitRaw()
|
||||||
g.finishUsage(c, ch, start, store.UsageStatusCanceled, "client_disconnect")
|
g.finishUsage(c, ch, start, store.UsageStatusCanceled, "client_disconnect")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -303,6 +351,7 @@ func (g *Gateway) streamCopy(c *gin.Context, ch *store.Channel, r io.Reader, sta
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
commitRaw()
|
||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
g.finishUsage(c, ch, start, store.UsageStatusSuccess, "")
|
g.finishUsage(c, ch, start, store.UsageStatusSuccess, "")
|
||||||
} else if c.Request.Context().Err() != nil {
|
} else if c.Request.Context().Err() != nil {
|
||||||
@@ -371,15 +420,11 @@ func extractUsage(data []byte) json.RawMessage {
|
|||||||
|
|
||||||
// scanUsage 从 SSE 一行中提取 usage(OpenAI 末块 / responses completed / messages message_delta 等)。
|
// scanUsage 从 SSE 一行中提取 usage(OpenAI 末块 / responses completed / messages message_delta 等)。
|
||||||
func scanUsage(line []byte) json.RawMessage {
|
func scanUsage(line []byte) json.RawMessage {
|
||||||
s := string(line)
|
if !bytes.Contains(line, []byte(`"usage"`)) {
|
||||||
if !strings.Contains(s, `"usage"`) {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(s, "data: ") {
|
s, ok := sseDataPayload(line)
|
||||||
s = strings.TrimPrefix(s, "data: ")
|
if !ok {
|
||||||
}
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
if s == "[DONE]" || s == "" {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var m map[string]json.RawMessage
|
var m map[string]json.RawMessage
|
||||||
@@ -400,7 +445,11 @@ func scanUsage(line []byte) json.RawMessage {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// usageFromMap 从 map 顶层或 message 子对象中取 usage。
|
// usageFromMap 从 map 顶层、message 或 delta 子对象中取 usage。
|
||||||
|
// 覆盖三种形态:
|
||||||
|
// - OpenAI 流式末块顶层 usage
|
||||||
|
// - Anthropic 非流式 / message_start 的 message.usage
|
||||||
|
// - Anthropic 流式 message_delta 的 delta.usage(真实 token 计数所在)
|
||||||
func usageFromMap(m map[string]json.RawMessage) json.RawMessage {
|
func usageFromMap(m map[string]json.RawMessage) json.RawMessage {
|
||||||
if u, ok := m["usage"]; ok && string(u) != "null" {
|
if u, ok := m["usage"]; ok && string(u) != "null" {
|
||||||
return u
|
return u
|
||||||
@@ -413,6 +462,14 @@ func usageFromMap(m map[string]json.RawMessage) json.RawMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if deltaRaw, ok := m["delta"]; ok {
|
||||||
|
var delta map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(deltaRaw, &delta) == nil {
|
||||||
|
if u, ok := delta["usage"]; ok && string(u) != "null" {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,6 +509,14 @@ func (u *usageSink) push(raw json.RawMessage) {
|
|||||||
if json.Unmarshal(raw, &t) != nil {
|
if json.Unmarshal(raw, &t) != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// messages 流式最终事件(message_delta 的 usage)带 cache_* 字段,是上游的最终计费口径,
|
||||||
|
// 其中 input_tokens 仅指"非缓存输入"(与 message_start 的"总输入"语义不同)。
|
||||||
|
// 整体替换而非字段合并,避免 delta 的非缓存 input 覆盖 start 的总 input 后语义错乱
|
||||||
|
// (实际消耗由 finishUsage 按 input + cache_read + cache_creation 汇总)。
|
||||||
|
if t.CacheReadInputTokens > 0 || t.CacheCreationInputTokens > 0 {
|
||||||
|
u.us = t
|
||||||
|
return
|
||||||
|
}
|
||||||
// 零值不覆盖:不同事件携带不同字段
|
// 零值不覆盖:不同事件携带不同字段
|
||||||
if t.PromptTokens > 0 {
|
if t.PromptTokens > 0 {
|
||||||
u.us.PromptTokens = t.PromptTokens
|
u.us.PromptTokens = t.PromptTokens
|
||||||
@@ -499,11 +564,15 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
|
|||||||
modelName, _ := c.Get("model_name")
|
modelName, _ := c.Get("model_name")
|
||||||
mn, _ := modelName.(string)
|
mn, _ := modelName.(string)
|
||||||
|
|
||||||
// 流式中断(canceled):上游最终 usage 可能未返回,按已生成内容用 tiktoken 估算
|
// 上游未返回真实 usage 时估算(tiktoken):
|
||||||
if status == store.UsageStatusCanceled {
|
// - 流式中断(canceled):上游最终 usage 可能未返回
|
||||||
if in == 0 {
|
// - 流式正常结束(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 est, ok := c.Get("est_input_text"); ok {
|
||||||
if v, ok2 := est.(string); ok2 {
|
if v, ok2 := est.(string); ok2 && v != "" {
|
||||||
in = int64(tokenizer.Count(v, mn))
|
in = int64(tokenizer.Count(v, mn))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -519,12 +588,18 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
|
|||||||
_ = g.db.Where("name = ?", mn).First(&model).Error
|
_ = g.db.Where("name = ?", mn).First(&model).Error
|
||||||
if model.ID > 0 {
|
if model.ID > 0 {
|
||||||
modelID = model.ID
|
modelID = model.ID
|
||||||
|
// 计价口径:in=非缓存输入、cacheRead=缓存读、cacheCreate=缓存写(Anthropic 语义,
|
||||||
|
// messages 流式 message_delta 的 input_tokens 即非缓存部分)。
|
||||||
|
// 缓存写按 1.25× 输入价(Anthropic 5m 口径)。
|
||||||
cost = float64(in)/1e6*model.InputPrice +
|
cost = float64(in)/1e6*model.InputPrice +
|
||||||
float64(out)/1e6*model.OutputPrice +
|
float64(out)/1e6*model.OutputPrice +
|
||||||
float64(cacheRead)/1e6*model.CacheReadPrice
|
float64(cacheRead)/1e6*model.CacheReadPrice +
|
||||||
|
float64(cacheCreate)/1e6*model.InputPrice*1.25
|
||||||
} else {
|
} else {
|
||||||
cost = float64(in)/1e6*0.15 + float64(out)/1e6*0.60 // 无定价模型时按示例价
|
cost = float64(in+cacheRead+cacheCreate)/1e6*0.15 + float64(out)/1e6*0.60 // 无定价模型时按示例价
|
||||||
}
|
}
|
||||||
|
// 落库的 input_tokens 存输入总量(含缓存读/写),与上游 message_start 口径一致,便于对账展示。
|
||||||
|
in += cacheRead + cacheCreate
|
||||||
|
|
||||||
proto, _ := c.Get("protocol")
|
proto, _ := c.Get("protocol")
|
||||||
p, _ := proto.(string)
|
p, _ := proto.(string)
|
||||||
@@ -555,6 +630,14 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
|
|||||||
chID = ch.ID
|
chID = ch.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var rawReq, rawResp string
|
||||||
|
if v, ok := c.Get("raw_request"); ok {
|
||||||
|
rawReq, _ = v.(string)
|
||||||
|
}
|
||||||
|
if v, ok := c.Get("raw_response"); ok {
|
||||||
|
rawResp, _ = v.(string)
|
||||||
|
}
|
||||||
|
|
||||||
// 密钥今日 token 用量累计(配额检查用)
|
// 密钥今日 token 用量累计(配额检查用)
|
||||||
if g.lim != nil && kidVal > 0 {
|
if g.lim != nil && kidVal > 0 {
|
||||||
g.lim.AddTokens(kidVal, in+out)
|
g.lim.AddTokens(kidVal, in+out)
|
||||||
@@ -580,6 +663,8 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
|
|||||||
LatencyMS: latency,
|
LatencyMS: latency,
|
||||||
Status: status,
|
Status: status,
|
||||||
ErrorCode: errCodePtr,
|
ErrorCode: errCodePtr,
|
||||||
|
RawRequest: rawReq,
|
||||||
|
RawResponse: rawResp,
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -11,6 +12,12 @@ import (
|
|||||||
gormlogger "gorm.io/gorm/logger"
|
gormlogger "gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// schemaVersion 当前 schema 版本。struct 变更(加列/改列/删列)时递增,
|
||||||
|
// 触发一次 AutoMigrate 并把新版本写入库(SQLite 用 PRAGMA user_version)。
|
||||||
|
// AutoMigrate 对已有表的列判定不收敛(每次都重建表:CREATE __temp + INSERT SELECT + DROP),
|
||||||
|
// 大表上一次重建数十秒且每次重启重演,所以之后版本未变就直接跳过。
|
||||||
|
const schemaVersion = 2
|
||||||
|
|
||||||
// Open 打开数据库连接并自动迁移。
|
// Open 打开数据库连接并自动迁移。
|
||||||
// 开发默认 SQLite(dsn 支持 file:...?_journal_mode=WAL),生产可切 postgres。
|
// 开发默认 SQLite(dsn 支持 file:...?_journal_mode=WAL),生产可切 postgres。
|
||||||
func Open(driver, dsn string) (*gorm.DB, error) {
|
func Open(driver, dsn string) (*gorm.DB, error) {
|
||||||
@@ -33,13 +40,35 @@ func Open(driver, dsn string) (*gorm.DB, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if driver != "postgres" && currentSQLiteVersion(db) >= schemaVersion {
|
||||||
|
log.Printf("store: connected driver=%s (schema up-to-date v%d, skip migrate)", driver, schemaVersion)
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
if err := db.AutoMigrate(AllModels()...); err != nil {
|
if err := db.AutoMigrate(AllModels()...); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
log.Printf("store: connected driver=%s (migrated)", driver)
|
if driver != "postgres" {
|
||||||
|
setSQLiteVersion(db, schemaVersion)
|
||||||
|
}
|
||||||
|
log.Printf("store: connected driver=%s (migrated, schema v%d)", driver, schemaVersion)
|
||||||
|
// 将已有渠道的超时时间从 120000ms 更新为 300000ms(幂等操作)
|
||||||
|
db.Model(&Channel{}).Where("timeout_ms = ?", 120000).Update("timeout_ms", 300000)
|
||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// currentSQLiteVersion 读取 PRAGMA user_version。
|
||||||
|
func currentSQLiteVersion(db *gorm.DB) int {
|
||||||
|
var v int
|
||||||
|
db.Raw("PRAGMA user_version").Scan(&v)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSQLiteVersion 写入 PRAGMA user_version。
|
||||||
|
func setSQLiteVersion(db *gorm.DB, v int) {
|
||||||
|
db.Exec(fmt.Sprintf("PRAGMA user_version = %d", v))
|
||||||
|
}
|
||||||
|
|
||||||
// sqliteDir 提取 SQLite DSN 中的目录部分(忽略 file: 前缀与查询参数)。
|
// sqliteDir 提取 SQLite DSN 中的目录部分(忽略 file: 前缀与查询参数)。
|
||||||
func sqliteDir(dsn string) string {
|
func sqliteDir(dsn string) string {
|
||||||
d := dsn
|
d := dsn
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ type Channel struct {
|
|||||||
APIKeyEnc string `gorm:"size:1024;not null" json:"-"` // AES-GCM 密文
|
APIKeyEnc string `gorm:"size:1024;not null" json:"-"` // AES-GCM 密文
|
||||||
Weight int `gorm:"not null;default:1" json:"weight"`
|
Weight int `gorm:"not null;default:1" json:"weight"`
|
||||||
Priority int `gorm:"not null;default:0" json:"priority"` // 数值小优先
|
Priority int `gorm:"not null;default:0" json:"priority"` // 数值小优先
|
||||||
TimeoutMS int `gorm:"not null;default:120000" json:"timeout_ms"`
|
TimeoutMS int `gorm:"not null;default:300000" json:"timeout_ms"`
|
||||||
MaxConcurrency int `gorm:"not null;default:16" json:"max_concurrency"`
|
MaxConcurrency int `gorm:"not null;default:16" json:"max_concurrency"`
|
||||||
HealthStatus string `gorm:"size:16;not null;default:healthy" json:"health_status"`
|
HealthStatus string `gorm:"size:16;not null;default:healthy" json:"health_status"`
|
||||||
Enabled bool `gorm:"not null;default:true" json:"enabled"`
|
Enabled bool `gorm:"not null;default:true" json:"enabled"`
|
||||||
@@ -145,7 +145,6 @@ func (c *Channel) UpstreamURL(proto, path string) string {
|
|||||||
type Model struct {
|
type Model struct {
|
||||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
Name string `gorm:"uniqueIndex;size:128;not null" json:"name"`
|
Name string `gorm:"uniqueIndex;size:128;not null" json:"name"`
|
||||||
DisplayName string `gorm:"size:128" json:"display_name"`
|
|
||||||
InputPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"input_price"`
|
InputPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"input_price"`
|
||||||
OutputPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"output_price"`
|
OutputPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"output_price"`
|
||||||
CacheReadPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"cache_read_price"`
|
CacheReadPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"cache_read_price"`
|
||||||
@@ -188,6 +187,8 @@ type UsageLog struct {
|
|||||||
LatencyMS int `json:"latency_ms"`
|
LatencyMS int `json:"latency_ms"`
|
||||||
Status string `gorm:"size:16;not null" json:"status"`
|
Status string `gorm:"size:16;not null" json:"status"`
|
||||||
ErrorCode *string `json:"error_code,omitempty"`
|
ErrorCode *string `json:"error_code,omitempty"`
|
||||||
|
RawRequest string `gorm:"type:text" json:"raw_request"` // 客户端原始请求体(未转换)
|
||||||
|
RawResponse string `gorm:"type:text" json:"raw_response"` // 上游原始响应(未转换;流式为全部 SSE 事件)
|
||||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ async function logout() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="flex min-h-[100dvh] flex-1 flex-col md:ml-56">
|
<div class="flex min-h-[100dvh] min-w-0 flex-1 flex-col md:ml-56">
|
||||||
<header class="flex h-14 items-center justify-between gap-2 border-b border-edge px-4 md:px-6">
|
<header class="flex h-14 items-center justify-between gap-2 border-b border-edge px-4 md:px-6">
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -142,7 +142,7 @@ async function logout() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="flex-1 px-4 py-6 md:px-6">
|
<main class="min-w-0 flex-1 px-4 py-6 md:px-6">
|
||||||
<router-view />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+5
-1
@@ -59,7 +59,6 @@ export interface ModelBinding {
|
|||||||
export interface Model {
|
export interface Model {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
display_name: string
|
|
||||||
input_price: number
|
input_price: number
|
||||||
output_price: number
|
output_price: number
|
||||||
cache_read_price: number
|
cache_read_price: number
|
||||||
@@ -83,6 +82,8 @@ export interface UsageLog {
|
|||||||
request_id: string
|
request_id: string
|
||||||
model: string
|
model: string
|
||||||
protocol: string
|
protocol: string
|
||||||
|
channel_id?: number
|
||||||
|
channel?: string
|
||||||
input_tokens: number
|
input_tokens: number
|
||||||
output_tokens: number
|
output_tokens: number
|
||||||
cache_read_tokens: number
|
cache_read_tokens: number
|
||||||
@@ -90,8 +91,11 @@ export interface UsageLog {
|
|||||||
latency_ms: number
|
latency_ms: number
|
||||||
status: string
|
status: string
|
||||||
error_code: string | null
|
error_code: string | null
|
||||||
|
raw_request?: string
|
||||||
|
raw_response?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
user?: string
|
user?: string
|
||||||
|
user_id?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Paged<T> {
|
export interface Paged<T> {
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ const baseURL = `${window.location.origin}/v1`
|
|||||||
<div class="space-y-3 p-4 leading-relaxed">
|
<div class="space-y-3 p-4 leading-relaxed">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-muted"><span class="text-accent">$</span> curl {{ baseURL }}/chat/completions</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-..."</span> \</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>
|
<p class="text-muted"> -d <span class="text-ink">'{"model": "claude-sonnet-5", "messages": [{"role": "user", "content": "你好"}]}'</span></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-t border-edge pt-3 text-muted">
|
<div class="border-t border-edge pt-3 text-muted">
|
||||||
@@ -136,7 +136,7 @@ const baseURL = `${window.location.origin}/v1`
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card p-5">
|
<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>
|
<h3 class="mt-2 text-lg font-semibold">API Key 管理</h3>
|
||||||
<p class="mt-2 text-sm leading-relaxed text-muted">
|
<p class="mt-2 text-sm leading-relaxed text-muted">
|
||||||
密钥仅存哈希,支持配额、过期与模型白名单,创建时一次性展示。
|
密钥仅存哈希,支持配额、过期与模型白名单,创建时一次性展示。
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const form = reactive({
|
|||||||
api_key: '',
|
api_key: '',
|
||||||
weight: 1,
|
weight: 1,
|
||||||
priority: 0,
|
priority: 0,
|
||||||
timeout_ms: 120000,
|
timeout_ms: 300000,
|
||||||
max_concurrency: 16,
|
max_concurrency: 16,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
})
|
})
|
||||||
@@ -51,7 +51,7 @@ function openCreate() {
|
|||||||
name: '', formats: ['chat'], base_url: '',
|
name: '', formats: ['chat'], base_url: '',
|
||||||
base_urls: { chat: '', responses: '', messages: '' },
|
base_urls: { chat: '', responses: '', messages: '' },
|
||||||
api_key: '',
|
api_key: '',
|
||||||
weight: 1, priority: 0, timeout_ms: 120000, max_concurrency: 16, enabled: true,
|
weight: 1, priority: 0, timeout_ms: 300000, max_concurrency: 16, enabled: true,
|
||||||
})
|
})
|
||||||
editOpen.value = true
|
editOpen.value = true
|
||||||
}
|
}
|
||||||
@@ -142,7 +142,7 @@ onMounted(load)
|
|||||||
|
|
||||||
<!-- 移动端:卡片列表 -->
|
<!-- 移动端:卡片列表 -->
|
||||||
<div class="space-y-3 md:hidden">
|
<div class="space-y-3 md:hidden">
|
||||||
<div v-for="ch in channels" :key="ch.id" class="card p-4">
|
<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="flex flex-wrap items-start justify-between gap-2">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="text-sm font-medium text-ink">{{ ch.name }}</p>
|
<p class="text-sm font-medium text-ink">{{ ch.name }}</p>
|
||||||
@@ -206,7 +206,7 @@ onMounted(load)
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<template v-for="ch in channels" :key="ch.id">
|
<template v-for="ch in channels" :key="ch.id">
|
||||||
<tr class="table-row">
|
<tr class="table-row" :style="ch.enabled ? { borderLeft: '2px solid var(--color-accent)' } : {}">
|
||||||
<td class="px-4 py-2.5">
|
<td class="px-4 py-2.5">
|
||||||
<button class="inline-flex items-center gap-1.5 text-ink transition hover:text-accent" @click="toggleDrawer(ch)">
|
<button class="inline-flex items-center gap-1.5 text-ink transition hover:text-accent" @click="toggleDrawer(ch)">
|
||||||
<span class="truncate">{{ ch.name }}</span>
|
<span class="truncate">{{ ch.name }}</span>
|
||||||
@@ -309,6 +309,25 @@ onMounted(load)
|
|||||||
<Input v-model="form.timeout_ms" label="超时 (ms)" type="number" />
|
<Input v-model="form.timeout_ms" label="超时 (ms)" type="number" />
|
||||||
<Input v-model="form.max_concurrency" label="最大并发" type="number" />
|
<Input v-model="form.max_concurrency" label="最大并发" type="number" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center justify-between rounded-md border border-edge p-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-ink">启用渠道</p>
|
||||||
|
<p class="text-xs text-muted">禁用后该渠道不会被用于请求转发</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="form.enabled"
|
||||||
|
class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||||
|
:class="form.enabled ? 'bg-accent' : 'bg-surface2'"
|
||||||
|
@click="form.enabled = !form.enabled"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-0 transition-transform"
|
||||||
|
:class="form.enabled ? 'translate-x-6' : 'translate-x-1'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button variant="ghost" @click="editOpen = false">取消</Button>
|
<Button variant="ghost" @click="editOpen = false">取消</Button>
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ function quickAdd() {
|
|||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
display_name: '',
|
|
||||||
input_price: 0,
|
input_price: 0,
|
||||||
output_price: 0,
|
output_price: 0,
|
||||||
cache_read_price: 0,
|
cache_read_price: 0,
|
||||||
@@ -65,14 +64,14 @@ async function load() {
|
|||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editing.value = null
|
editing.value = null
|
||||||
Object.assign(form, { name: '', display_name: '', input_price: 0, output_price: 0, cache_read_price: 0, enabled: true })
|
Object.assign(form, { name: '', input_price: 0, output_price: 0, cache_read_price: 0, enabled: true })
|
||||||
editOpen.value = true
|
editOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(m: Model) {
|
function openEdit(m: Model) {
|
||||||
editing.value = m
|
editing.value = m
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
name: m.name, display_name: m.display_name,
|
name: m.name,
|
||||||
input_price: m.input_price, output_price: m.output_price, cache_read_price: m.cache_read_price,
|
input_price: m.input_price, output_price: m.output_price, cache_read_price: m.cache_read_price,
|
||||||
enabled: m.enabled,
|
enabled: m.enabled,
|
||||||
})
|
})
|
||||||
@@ -82,7 +81,6 @@ function openEdit(m: Model) {
|
|||||||
async function save() {
|
async function save() {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
const payload = {
|
const payload = {
|
||||||
display_name: form.display_name || form.name,
|
|
||||||
input_price: Number(form.input_price),
|
input_price: Number(form.input_price),
|
||||||
output_price: Number(form.output_price),
|
output_price: Number(form.output_price),
|
||||||
cache_read_price: Number(form.cache_read_price),
|
cache_read_price: Number(form.cache_read_price),
|
||||||
@@ -207,7 +205,6 @@ onMounted(load)
|
|||||||
<Modal :open="editOpen" :title="editing ? '编辑模型' : '添加模型'" @close="editOpen = false">
|
<Modal :open="editOpen" :title="editing ? '编辑模型' : '添加模型'" @close="editOpen = false">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<Input v-model="form.name" label="模型名" placeholder="claude-sonnet-5" :disabled="!!editing" />
|
<Input v-model="form.name" label="模型名" placeholder="claude-sonnet-5" :disabled="!!editing" />
|
||||||
<Input v-model="form.display_name" label="展示名" />
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<Input v-model="form.input_price" label="输入价格 /1M" type="number" />
|
<Input v-model="form.input_price" label="输入价格 /1M" type="number" />
|
||||||
<Input v-model="form.output_price" label="输出价格 /1M" type="number" />
|
<Input v-model="form.output_price" label="输出价格 /1M" type="number" />
|
||||||
|
|||||||
@@ -6,20 +6,68 @@ import { protocolShort } from '@/lib/protocol'
|
|||||||
import { fmtCost, fmtTime } from '@/lib/format'
|
import { fmtCost, fmtTime } from '@/lib/format'
|
||||||
import Badge from '@/components/ui/Badge.vue'
|
import Badge from '@/components/ui/Badge.vue'
|
||||||
import Button from '@/components/ui/Button.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 toast = useToastStore()
|
||||||
const logs = ref<UsageLog[]>([])
|
const logs = ref<UsageLog[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const modelFilter = ref('')
|
const modelFilter = ref('')
|
||||||
|
const userFilter = ref('')
|
||||||
|
const userCandidates = ref<User[]>([])
|
||||||
|
let userSearchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
const pageSize = 15
|
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() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const { data } = await http.get(
|
const params = new URLSearchParams()
|
||||||
`/admin/usage?page=${page.value}&page_size=${pageSize}${modelFilter.value ? '&model=' + modelFilter.value : ''}`,
|
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
|
logs.value = data.data.items
|
||||||
total.value = data.data.total
|
total.value = data.data.total
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -32,6 +80,12 @@ function search() {
|
|||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearUser() {
|
||||||
|
userFilter.value = ''
|
||||||
|
userCandidates.value = []
|
||||||
|
search()
|
||||||
|
}
|
||||||
|
|
||||||
function goPage(p: number) {
|
function goPage(p: number) {
|
||||||
page.value = p
|
page.value = p
|
||||||
load()
|
load()
|
||||||
@@ -47,7 +101,28 @@ onMounted(load)
|
|||||||
<h1 class="text-lg font-semibold">用量统计</h1>
|
<h1 class="text-lg font-semibold">用量统计</h1>
|
||||||
<p class="text-sm text-muted">全局请求明细与成本</p>
|
<p class="text-sm text-muted">全局请求明细与成本</p>
|
||||||
</div>
|
</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
|
<input
|
||||||
v-model="modelFilter"
|
v-model="modelFilter"
|
||||||
placeholder="按模型过滤"
|
placeholder="按模型过滤"
|
||||||
@@ -65,7 +140,7 @@ onMounted(load)
|
|||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="truncate font-mono text-sm text-ink">{{ l.model }}</p>
|
<p class="truncate font-mono text-sm text-ink">{{ l.model }}</p>
|
||||||
<p class="mt-0.5 truncate text-xs text-muted">{{ l.user }}</p>
|
<p class="mt-0.5 truncate text-xs text-muted">{{ l.channel }} · {{ l.user }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex shrink-0 flex-col items-end gap-1">
|
<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>
|
<Badge :variant="l.status === 'success' ? 'ok' : l.status === 'canceled' ? 'neutral' : 'err'">{{ l.status }}</Badge>
|
||||||
@@ -77,6 +152,7 @@ onMounted(load)
|
|||||||
<span class="mono-num">{{ l.input_tokens }}/{{ l.output_tokens }} tok</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">{{ l.latency_ms }}ms</span>
|
||||||
<span class="mono-num w-full">{{ fmtTime(l.created_at) }}</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>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="logs.length === 0" class="card px-4 py-8 text-center text-sm text-muted">暂无请求记录</p>
|
<p v-if="logs.length === 0" class="card px-4 py-8 text-center text-sm text-muted">暂无请求记录</p>
|
||||||
@@ -89,18 +165,21 @@ onMounted(load)
|
|||||||
<tr class="border-b border-edge text-left text-xs text-muted">
|
<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">API 格式</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">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>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="l in logs" :key="l.id" class="table-row">
|
<tr v-for="l in logs" :key="l.id" class="table-row">
|
||||||
<td class="px-4 py-2.5 text-xs text-ink">{{ l.user }}</td>
|
<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-ink">{{ l.model }}</td>
|
||||||
|
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ l.channel || '-' }}</td>
|
||||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ protocolShort(l.protocol) }}</td>
|
<td class="px-4 py-2.5 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-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-ink">{{ fmtCost(l.cost) }}</td>
|
||||||
@@ -109,9 +188,13 @@ onMounted(load)
|
|||||||
<Badge :variant="l.status === 'success' ? 'ok' : l.status === 'canceled' ? 'neutral' : 'err'">{{ l.status }}</Badge>
|
<Badge :variant="l.status === 'success' ? 'ok' : l.status === 'canceled' ? 'neutral' : 'err'">{{ l.status }}</Badge>
|
||||||
</td>
|
</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 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>
|
||||||
<tr v-if="logs.length === 0">
|
<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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -124,5 +207,44 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ function daysAgo(n: number): string {
|
|||||||
// 快速开始:Base URL 用浏览器当前 host(兼容端点),curl 示例
|
// 快速开始:Base URL 用浏览器当前 host(兼容端点),curl 示例
|
||||||
const baseURL = `${window.location.origin}/v1`
|
const baseURL = `${window.location.origin}/v1`
|
||||||
const curlCmd = `curl ${baseURL}/chat/completions \\
|
const curlCmd = `curl ${baseURL}/chat/completions \\
|
||||||
-H "Authorization: Bearer sk-xxxx" \\
|
-H "Authorization: Bearer sk-ot-xxxx" \\
|
||||||
-H "Content-Type: application/json" \\
|
-H "Content-Type: application/json" \\
|
||||||
-d '{"model":"gpt-test","stream":true,"messages":[{"role":"user","content":"hi"}]}'`
|
-d '{"model":"gpt-test","stream":true,"messages":[{"role":"user","content":"hi"}]}'`
|
||||||
const copied = ref('')
|
const copied = ref('')
|
||||||
@@ -145,14 +145,14 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 快速开始 -->
|
<!-- 快速开始 -->
|
||||||
<div class="card p-5">
|
<div class="card min-w-0 p-5">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h2 class="text-sm font-semibold">快速开始</h2>
|
<h2 class="text-sm font-semibold">快速开始</h2>
|
||||||
<p class="text-xs text-muted">通过兼容端点接入,OpenAI / Anthropic 协议自动转换</p>
|
<p class="text-xs text-muted">通过兼容端点接入,OpenAI / Anthropic 协议自动转换</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<p class="mb-1.5 text-[11px] font-medium text-muted">Base URL</p>
|
<p class="mb-1.5 text-[11px] font-medium text-muted">Base URL</p>
|
||||||
<div class="flex items-center gap-2">
|
<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>
|
<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>
|
||||||
@@ -162,7 +162,7 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<p class="mb-1.5 text-[11px] font-medium text-muted">兼容</p>
|
<p class="mb-1.5 text-[11px] font-medium text-muted">兼容</p>
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
<Badge variant="neutral">messages</Badge>
|
<Badge variant="neutral">messages</Badge>
|
||||||
@@ -171,10 +171,10 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<p class="mb-1.5 text-[11px] font-medium text-muted">curl 示例</p>
|
<p class="mb-1.5 text-[11px] font-medium text-muted">curl 示例</p>
|
||||||
<div class="relative">
|
<div class="relative min-w-0 overflow-hidden rounded-md border border-edge bg-surface">
|
||||||
<pre class="overflow-x-auto rounded-md border border-edge bg-surface px-3 py-2.5 pr-20 font-mono text-[11px] leading-relaxed text-ink">{{ curlCmd }}</pre>
|
<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')">
|
<Button size="sm" variant="ghost" class="absolute top-2 right-2" @click="copy(curlCmd, 'curl')">
|
||||||
{{ copied === 'curl' ? '已复制' : '复制' }}
|
{{ copied === 'curl' ? '已复制' : '复制' }}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user