Compare commits
18
Commits
2983c89b42
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d911881499 | ||
|
|
2e3bb22f81 | ||
|
|
923ac79039 | ||
|
|
6d951a0d61 | ||
|
|
1f5f60e905 | ||
|
|
c6a4fcfbbc | ||
|
|
5cb428bba9 | ||
|
|
658a9cb580 | ||
|
|
3a836bf0e9 | ||
|
|
faf38e2fd3 | ||
|
|
d8257df100 | ||
|
|
cd015d370c | ||
|
|
939a98d389 | ||
|
|
0b31cc209e | ||
|
|
305b3ed731 | ||
|
|
79545aa48e | ||
|
|
d818cf3b4c | ||
|
|
98f296101e |
+2
-2
@@ -27,7 +27,7 @@ OT_MASTER_KEY=change-me-master-key
|
||||
OT_PROXY_UPSTREAM_KEY=
|
||||
OT_PROXY_UPSTREAM_BASE_URL=https://api.openai.com
|
||||
OT_PROXY_DEFAULT_MODEL=gpt-4o-mini
|
||||
OT_PROXY_TIMEOUT=120s
|
||||
OT_PROXY_TIMEOUT=300s
|
||||
|
||||
# 渠道健康检查
|
||||
OT_PROXY_HEALTH_INTERVAL=60s
|
||||
@@ -50,4 +50,4 @@ OT_ADMIN_EMAIL=admin@localhost
|
||||
OT_ADMIN_PASSWORD=admin123
|
||||
|
||||
# 记录原始请求日志
|
||||
OT_PROXY_LOG_RAW=false
|
||||
OT_PROXY_LOG_RAW=false
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: run build test tidy mock-upstream web-dev web-build reset-admin-password
|
||||
.PHONY: run build test tidy mock-upstream web-dev web-build reset-admin-password reset-password
|
||||
|
||||
run:
|
||||
cd server && go run ./cmd/server
|
||||
@@ -6,6 +6,9 @@ run:
|
||||
reset-admin-password:
|
||||
cd server && go run ./cmd/server reset-admin-password
|
||||
|
||||
reset-password:
|
||||
cd server && go run ./cmd/server reset-password
|
||||
|
||||
build:
|
||||
cd server && go build -o bin/openteam ./cmd/server
|
||||
|
||||
|
||||
@@ -2,66 +2,8 @@
|
||||
// 启动:OT_PROXY_UPSTREAM_KEY=sk-xxx go run ./cmd/server
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/openteam/server/internal/api"
|
||||
"github.com/openteam/server/internal/app"
|
||||
"github.com/openteam/server/internal/config"
|
||||
"github.com/openteam/server/internal/proxy"
|
||||
)
|
||||
import "github.com/openteam/server/internal/cli"
|
||||
|
||||
func main() {
|
||||
// 子命令分发:`openteam reset-admin-password ...`(无子命令时启动服务)
|
||||
if len(os.Args) > 1 && os.Args[1] == "reset-admin-password" {
|
||||
runResetAdminPassword(os.Args[2:])
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
|
||||
a, err := app.New(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("app init: %v", err)
|
||||
}
|
||||
defer a.Shutdown(context.Background())
|
||||
|
||||
gw := proxy.NewGateway(a.DB, a.Enc, a.Usage, a.Limit, cfg.RateLimit.UserRPS, cfg.Proxy.LogRaw)
|
||||
router := api.NewRouter(a, gw)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + strconv.Itoa(cfg.Port),
|
||||
Handler: router,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("openteam listening on %s (env=%s)", srv.Addr, cfg.Env)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
log.Println("shutting down...")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(ctx); err != nil {
|
||||
log.Printf("server shutdown: %v", err)
|
||||
}
|
||||
a.Usage.Close()
|
||||
}
|
||||
cli.Execute()
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
// 子命令 reset-admin-password:重置管理员密码(忘记密码时用,需在 server/ 目录或配置可加载处执行)。
|
||||
// 用法:go run ./cmd/server reset-admin-password [-user admin] [-password 新密码]
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
|
||||
"github.com/openteam/server/internal/config"
|
||||
"github.com/openteam/server/internal/pkg/crypto"
|
||||
"github.com/openteam/server/internal/store"
|
||||
)
|
||||
|
||||
// pwAlphabet 密码随机串字符集(去掉 0/O/1/l/I 等易混淆字符)。
|
||||
const pwAlphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz"
|
||||
|
||||
func runResetAdminPassword(args []string) {
|
||||
fs := flag.NewFlagSet("reset-admin-password", flag.ExitOnError)
|
||||
user := fs.String("user", "admin", "目标管理员用户名")
|
||||
password := fs.String("password", "", "新密码(至少 8 位;留空则随机生成并打印一次)")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("config: %v", err)
|
||||
}
|
||||
db, err := store.Open(cfg.DB.Driver, cfg.DB.DSN)
|
||||
if err != nil {
|
||||
log.Fatalf("db: %v", err)
|
||||
}
|
||||
|
||||
var u store.User
|
||||
if err := db.Where("username = ? AND role = ?", *user, store.RoleAdmin).First(&u).Error; err != nil {
|
||||
log.Fatalf("admin user %q not found", *user)
|
||||
}
|
||||
|
||||
pw := *password
|
||||
generated := false
|
||||
if pw == "" {
|
||||
pw, err = randomPassword(16)
|
||||
if err != nil {
|
||||
log.Fatalf("generate password: %v", err)
|
||||
}
|
||||
generated = true
|
||||
}
|
||||
if len(pw) < 8 || len(pw) > 72 {
|
||||
log.Fatal("password length must be between 8 and 72")
|
||||
}
|
||||
|
||||
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("hash password: %v", err)
|
||||
}
|
||||
if err := db.Model(&store.User{}).Where("id = ?", u.ID).Update("password_hash", hash).Error; err != nil {
|
||||
log.Fatalf("update password: %v", err)
|
||||
}
|
||||
|
||||
if generated {
|
||||
fmt.Printf("已重置 %q 的密码:%s\n", u.Username, pw)
|
||||
fmt.Println("(请立即保存,不会再次显示)")
|
||||
} else {
|
||||
fmt.Printf("已重置 %q 的密码。\n", u.Username)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -7,9 +7,11 @@ require (
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-webauthn/webauthn v0.17.4
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/tiktoken-go/tokenizer v0.8.1
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/term v0.45.0
|
||||
gorm.io/driver/postgres v1.6.2
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
@@ -35,6 +37,7 @@ require (
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.10.0 // indirect
|
||||
|
||||
@@ -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/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -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/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
@@ -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/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
@@ -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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
|
||||
@@ -118,7 +118,7 @@ func (h *Handler) AdminChannelAddModel(c *gin.Context) {
|
||||
// 解析或创建全局模型(客户端名)
|
||||
var m store.Model
|
||||
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 {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to create model")
|
||||
return
|
||||
|
||||
@@ -184,7 +184,7 @@ func (h *Handler) AdminCreateChannel(c *gin.Context) {
|
||||
Name: req.Name, Provider: req.Provider, Formats: formats, BaseURL: baseURL,
|
||||
BaseURLs: normalizeBaseURLs(req.BaseURLs),
|
||||
APIKeyEnc: enc, Weight: intOr(req.Weight, 1), Priority: intOr(req.Priority, 0),
|
||||
TimeoutMS: intOr(req.TimeoutMS, 120000), MaxConcurrency: intOr(req.MaxConcurrency, 16),
|
||||
TimeoutMS: intOr(req.TimeoutMS, 300000), MaxConcurrency: intOr(req.MaxConcurrency, 16),
|
||||
HealthStatus: store.ChannelHealthHealthy, Enabled: boolOr(req.Enabled, true),
|
||||
}
|
||||
if err := h.a.DB.Create(&ch).Error; err != nil {
|
||||
|
||||
@@ -41,7 +41,7 @@ func (h *Handler) AdminModels(c *gin.Context) {
|
||||
needsPricing := used && m.InputPrice == 0 && m.OutputPrice == 0 && m.CacheReadPrice == 0
|
||||
denied := containsStr(deny, m.Name) || (len(allow) > 0 && !containsStr(allow, m.Name))
|
||||
out = append(out, gin.H{
|
||||
"id": m.ID, "name": m.Name, "display_name": m.DisplayName,
|
||||
"id": m.ID, "name": m.Name,
|
||||
"input_price": m.InputPrice, "output_price": m.OutputPrice, "cache_read_price": m.CacheReadPrice,
|
||||
"enabled": m.Enabled, "sort": m.Sort, "channels": chs,
|
||||
"used": used, "needs_pricing": needsPricing, "denied": denied,
|
||||
@@ -119,7 +119,6 @@ func containsStr(list []string, s string) bool {
|
||||
func (h *Handler) AdminCreateModel(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=128"`
|
||||
DisplayName string `json:"display_name"`
|
||||
InputPrice float64 `json:"input_price"`
|
||||
OutputPrice float64 `json:"output_price"`
|
||||
CacheReadPrice float64 `json:"cache_read_price"`
|
||||
@@ -130,13 +129,10 @@ func (h *Handler) AdminCreateModel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
m := store.Model{
|
||||
Name: req.Name, DisplayName: req.DisplayName,
|
||||
Name: req.Name,
|
||||
InputPrice: req.InputPrice, OutputPrice: req.OutputPrice, CacheReadPrice: req.CacheReadPrice,
|
||||
Enabled: boolOr(req.Enabled, true),
|
||||
}
|
||||
if m.DisplayName == "" {
|
||||
m.DisplayName = m.Name
|
||||
}
|
||||
if err := h.a.DB.Create(&m).Error; err != nil {
|
||||
resp.Fail(c, http.StatusConflict, "failed to create model (name may already exist)")
|
||||
return
|
||||
@@ -144,7 +140,7 @@ func (h *Handler) AdminCreateModel(c *gin.Context) {
|
||||
resp.Created(c, gin.H{"id": m.ID, "name": m.Name})
|
||||
}
|
||||
|
||||
// AdminUpdateModel PUT /api/v1/admin/models/:id — 价格/展示名/启停/排序。
|
||||
// AdminUpdateModel PUT /api/v1/admin/models/:id — 价格/启停/排序。
|
||||
func (h *Handler) AdminUpdateModel(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -152,7 +148,6 @@ func (h *Handler) AdminUpdateModel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DisplayName *string `json:"display_name"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
@@ -169,9 +164,6 @@ func (h *Handler) AdminUpdateModel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
updates := map[string]any{}
|
||||
if req.DisplayName != nil {
|
||||
updates["display_name"] = *req.DisplayName
|
||||
}
|
||||
if req.InputPrice != nil {
|
||||
updates["input_price"] = *req.InputPrice
|
||||
}
|
||||
|
||||
@@ -90,8 +90,13 @@ func (h *Handler) AdminUsage(c *gin.Context) {
|
||||
for _, l := range logs {
|
||||
var uname string
|
||||
h.a.DB.Model(&store.User{}).Where("id = ?", l.UserID).Pluck("username", &uname)
|
||||
var chName string
|
||||
if l.ChannelID > 0 {
|
||||
h.a.DB.Model(&store.Channel{}).Where("id = ?", l.ChannelID).Pluck("name", &chName)
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"id": l.ID, "user": uname, "user_id": l.UserID, "model": l.ModelName, "protocol": l.Protocol,
|
||||
"channel_id": l.ChannelID, "channel": chName,
|
||||
"input_tokens": l.InputTokens, "output_tokens": l.OutputTokens,
|
||||
"cache_read_tokens": l.CacheReadTokens, "cost": l.Cost,
|
||||
"latency_ms": l.LatencyMS, "status": l.Status, "error_code": l.ErrorCode,
|
||||
|
||||
@@ -124,11 +124,10 @@ func (a *App) Seed() error {
|
||||
}
|
||||
// 默认模型 + 绑定
|
||||
m := store.Model{
|
||||
Name: a.Cfg.Proxy.DefaultModel,
|
||||
DisplayName: a.Cfg.Proxy.DefaultModel,
|
||||
InputPrice: 0.15, // 每百万 token,示例价
|
||||
Name: a.Cfg.Proxy.DefaultModel,
|
||||
InputPrice: 0.15, // 每百万 token,示例价
|
||||
OutputPrice: 0.60,
|
||||
Enabled: true,
|
||||
Enabled: true,
|
||||
}
|
||||
if err := a.DB.Create(&m).Error; err == nil {
|
||||
a.DB.Create(&store.ChannelModelBinding{ChannelID: ch.ID, ModelID: m.ID, UpstreamModel: m.Name})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -59,14 +59,14 @@ type AuthConfig struct {
|
||||
}
|
||||
|
||||
type ProxyConfig struct {
|
||||
DefaultChannelName string // 首次启动自动创建的渠道名(如 openai)
|
||||
UpstreamBaseURL string // 渠道 base_url 默认值
|
||||
UpstreamKey string // 渠道上游 key 默认值
|
||||
DefaultModel string // 渠道模型导入时使用的模型名
|
||||
Timeout time.Duration
|
||||
HealthInterval time.Duration // 渠道健康检查周期
|
||||
HealthFailThreshold int // 连续失败 N 次进 cooldown
|
||||
LogRaw bool // 记录管理员原始请求体+响应到 usage_logs(调试用,默认关)
|
||||
DefaultChannelName string // 首次启动自动创建的渠道名(如 openai)
|
||||
UpstreamBaseURL string // 渠道 base_url 默认值
|
||||
UpstreamKey string // 渠道上游 key 默认值
|
||||
DefaultModel string // 渠道模型导入时使用的模型名
|
||||
Timeout time.Duration
|
||||
HealthInterval time.Duration // 渠道健康检查周期
|
||||
HealthFailThreshold int // 连续失败 N 次进 cooldown
|
||||
LogRaw bool // 记录管理员原始请求体+响应到 usage_logs(调试用,默认关)
|
||||
}
|
||||
|
||||
// loadDotEnv 读取 .env 并把 KEY=VALUE 注入环境变量(AutomaticEnv 自动映射 OT_ 前缀)。
|
||||
@@ -127,7 +127,7 @@ func Load() (*Config, error) {
|
||||
v.SetDefault("proxy.upstream_base_url", "https://api.openai.com")
|
||||
v.SetDefault("proxy.upstream_key", "")
|
||||
v.SetDefault("proxy.default_model", "gpt-4o-mini")
|
||||
v.SetDefault("proxy.timeout", "120s")
|
||||
v.SetDefault("proxy.timeout", "300s")
|
||||
v.SetDefault("proxy.health_interval", "60s")
|
||||
v.SetDefault("proxy.health_fail_threshold", 2)
|
||||
v.SetDefault("proxy.log_raw", false)
|
||||
|
||||
@@ -240,11 +240,9 @@ func chatToResponsesReq(body []byte) ([]byte, error) {
|
||||
if len(system) > 0 {
|
||||
out["instructions"] = strings.Join(system, "\n")
|
||||
}
|
||||
if len(input) == 1 {
|
||||
out["input"] = input[0] // 单条消息项
|
||||
} else {
|
||||
out["input"] = input
|
||||
}
|
||||
// input 必须是数组:部分上游(如火山方舟)只接受 []*InputItem,
|
||||
// 单对象会被拒(400 Mismatch type)。
|
||||
out["input"] = input
|
||||
|
||||
if len(req.Tools) > 0 {
|
||||
tools := make([]any, 0, len(req.Tools))
|
||||
|
||||
@@ -11,16 +11,17 @@ type sseState struct {
|
||||
}
|
||||
|
||||
// parseLine 解析一行 SSE;返回是否 data 行及其内容、是否 [DONE]。
|
||||
// data: 后可跟空格(标准)或紧贴 JSON(上游如火山方舟会省略空格)。
|
||||
func (s *sseState) parseLine(line []byte) (isData bool, data string, done bool) {
|
||||
str := strings.TrimRight(string(line), "\r\n")
|
||||
switch {
|
||||
case strings.HasPrefix(str, "event: "):
|
||||
s.event = strings.TrimSpace(strings.TrimPrefix(str, "event: "))
|
||||
return false, "", false
|
||||
case str == "data: [DONE]":
|
||||
case str == "data: [DONE]" || str == "data:[DONE]":
|
||||
return true, "[DONE]", true
|
||||
case strings.HasPrefix(str, "data: "):
|
||||
return true, strings.TrimPrefix(str, "data: "), false
|
||||
case strings.HasPrefix(str, "data:"):
|
||||
return true, strings.TrimLeft(strings.TrimPrefix(str, "data:"), " "), false
|
||||
default:
|
||||
return false, "", false
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ func contains(list []string, s string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ratelimit.Limiter, userRPS int, logRaw bool) *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{
|
||||
db: db,
|
||||
ch: channel.NewService(db, enc),
|
||||
@@ -118,7 +118,7 @@ func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ra
|
||||
lim: lim,
|
||||
userRPS: userRPS,
|
||||
logRaw: logRaw,
|
||||
hc: &http.Client{Timeout: 120 * time.Second},
|
||||
hc: &http.Client{Timeout: gw_timeout * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,10 +272,10 @@ func upstreamPath(proto string) string {
|
||||
|
||||
// upstreamPlan 描述一次代理请求的上游访问方式。
|
||||
type upstreamPlan struct {
|
||||
proto string // 上游协议(用于分协议 base_url)
|
||||
path string // 上游路径
|
||||
body []byte // 已转换的请求体
|
||||
lineConv func([]byte) []byte // 流式逐行转换(nil=直通)
|
||||
proto string // 上游协议(用于分协议 base_url)
|
||||
path string // 上游路径
|
||||
body []byte // 已转换的请求体
|
||||
lineConv func([]byte) []byte // 流式逐行转换(nil=直通)
|
||||
bodyConv func([]byte) ([]byte, error) // 非流式响应体转换(nil=直通)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package proxy
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -101,18 +100,32 @@ func requestText(body []byte) string {
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
// sseContentText 提取一条 SSE 中的内容文本(chat delta.content / responses delta / messages delta.text)。
|
||||
// 兼容单 data: 行与 event:+data: 多行块(转换器 eventLine 产出的块)。
|
||||
func sseContentText(line []byte) string {
|
||||
// sseDataPayload 提取一条 SSE 的 JSON 载荷(去掉 data: 前缀与空白)。
|
||||
// 兼容三种写法:
|
||||
// - 单 data: 行:data: {...} 或 data:{...}(上游如火山方舟会省略 data: 后的空格)
|
||||
// - event:+data: 多行块:转换器 eventLine 产出的块(event: xxx\ndata: {...} 拼在一个 []byte)
|
||||
func sseDataPayload(line []byte) (string, bool) {
|
||||
s := string(line)
|
||||
// 多行块:取最后一个 data: 行(event: 头 + data: 载荷拼在一个 []byte 里)
|
||||
if idx := strings.LastIndex(s, "\ndata: "); idx >= 0 {
|
||||
s = s[idx+len("\ndata: "):]
|
||||
} else if strings.HasPrefix(s, "data: ") {
|
||||
s = strings.TrimPrefix(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:")
|
||||
} else {
|
||||
return "", false
|
||||
}
|
||||
s = strings.TrimLeft(s, " ") // data: 后的可选空格
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || s == "[DONE]" {
|
||||
return "", false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
|
||||
// sseContentText 提取一条 SSE 中的内容文本(chat delta.content / responses delta / messages delta.text)。
|
||||
func sseContentText(line []byte) string {
|
||||
s, ok := sseDataPayload(line)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
var m map[string]any
|
||||
@@ -150,11 +163,29 @@ func upstreamURL(ch *store.Channel, proto, path string) string {
|
||||
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) {
|
||||
if len(cands) == 0 {
|
||||
apiError(c, http.StatusBadGateway, "upstream_error", "no available channel")
|
||||
g.recordError(c, nil, nil, now(), "no_available_channel")
|
||||
return
|
||||
}
|
||||
// 加权随机选择起始渠道
|
||||
picked := g.ch.Pick(cands)
|
||||
startIdx := 0
|
||||
for i, cand := range cands {
|
||||
if cand.Channel.ID == picked.ID {
|
||||
startIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var lastStatus = http.StatusBadGateway
|
||||
var lastBody = []byte("all upstream channels failed")
|
||||
for _, cand := range cands {
|
||||
// 从选中的渠道开始遍历,到末尾后再从头遍历到选中渠道之前
|
||||
for offset := 0; offset < len(cands); offset++ {
|
||||
idx := (startIdx + offset) % len(cands)
|
||||
cand := cands[idx]
|
||||
ch := cand.Channel
|
||||
plan, err := prepareUpstream(ch, clientProto, body, cand.UpstreamModel)
|
||||
if err != nil {
|
||||
@@ -204,9 +235,7 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(ch.TimeoutMS)*time.Millisecond)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL(ch, plan.proto, plan.path), bytes.NewReader(upBody))
|
||||
req, err := http.NewRequest(http.MethodPost, upstreamURL(ch, plan.proto, plan.path), bytes.NewReader(upBody))
|
||||
if err != nil {
|
||||
return false, false, http.StatusInternalServerError, []byte("failed to build upstream request")
|
||||
}
|
||||
@@ -228,9 +257,6 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
|
||||
start := time.Now()
|
||||
resp, err := g.hc.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return false, true, http.StatusGatewayTimeout, []byte("upstream request timed out")
|
||||
}
|
||||
return false, true, http.StatusBadGateway, []byte("upstream request failed: " + err.Error())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -394,18 +420,11 @@ func extractUsage(data []byte) json.RawMessage {
|
||||
|
||||
// scanUsage 从 SSE 一行中提取 usage(OpenAI 末块 / responses completed / messages message_delta 等)。
|
||||
func scanUsage(line []byte) json.RawMessage {
|
||||
s := string(line)
|
||||
if !strings.Contains(s, `"usage"`) {
|
||||
if !bytes.Contains(line, []byte(`"usage"`)) {
|
||||
return nil
|
||||
}
|
||||
// 兼容单 data: 行与 event:+data: 多行块(转换器 eventLine 产出的块)
|
||||
if idx := strings.LastIndex(s, "\ndata: "); idx >= 0 {
|
||||
s = s[idx+len("\ndata: "):]
|
||||
} else if strings.HasPrefix(s, "data: ") {
|
||||
s = strings.TrimPrefix(s, "data: ")
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "[DONE]" || s == "" {
|
||||
s, ok := sseDataPayload(line)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var m map[string]json.RawMessage
|
||||
@@ -490,6 +509,14 @@ func (u *usageSink) push(raw json.RawMessage) {
|
||||
if json.Unmarshal(raw, &t) != nil {
|
||||
return
|
||||
}
|
||||
// messages 流式最终事件(message_delta 的 usage)带 cache_* 字段,是上游的最终计费口径,
|
||||
// 其中 input_tokens 仅指"非缓存输入"(与 message_start 的"总输入"语义不同)。
|
||||
// 整体替换而非字段合并,避免 delta 的非缓存 input 覆盖 start 的总 input 后语义错乱
|
||||
// (实际消耗由 finishUsage 按 input + cache_read + cache_creation 汇总)。
|
||||
if t.CacheReadInputTokens > 0 || t.CacheCreationInputTokens > 0 {
|
||||
u.us = t
|
||||
return
|
||||
}
|
||||
// 零值不覆盖:不同事件携带不同字段
|
||||
if t.PromptTokens > 0 {
|
||||
u.us.PromptTokens = t.PromptTokens
|
||||
@@ -543,7 +570,7 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
|
||||
// message_delta 不带 usage,只能按已收发内容估算,否则记账为 0 消耗
|
||||
// 非流式上游必返回 usage,此处 in/out 非 0 不受影响。
|
||||
if status == store.UsageStatusCanceled || status == store.UsageStatusSuccess {
|
||||
if in == 0 {
|
||||
if in == 0 && cacheRead == 0 && cacheCreate == 0 {
|
||||
if est, ok := c.Get("est_input_text"); ok {
|
||||
if v, ok2 := est.(string); ok2 && v != "" {
|
||||
in = int64(tokenizer.Count(v, mn))
|
||||
@@ -561,12 +588,18 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
|
||||
_ = g.db.Where("name = ?", mn).First(&model).Error
|
||||
if model.ID > 0 {
|
||||
modelID = model.ID
|
||||
// 计价口径:in=非缓存输入、cacheRead=缓存读、cacheCreate=缓存写(Anthropic 语义,
|
||||
// messages 流式 message_delta 的 input_tokens 即非缓存部分)。
|
||||
// 缓存写按 1.25× 输入价(Anthropic 5m 口径)。
|
||||
cost = float64(in)/1e6*model.InputPrice +
|
||||
float64(out)/1e6*model.OutputPrice +
|
||||
float64(cacheRead)/1e6*model.CacheReadPrice
|
||||
float64(cacheRead)/1e6*model.CacheReadPrice +
|
||||
float64(cacheCreate)/1e6*model.InputPrice*1.25
|
||||
} else {
|
||||
cost = float64(in)/1e6*0.15 + float64(out)/1e6*0.60 // 无定价模型时按示例价
|
||||
cost = float64(in+cacheRead+cacheCreate)/1e6*0.15 + float64(out)/1e6*0.60 // 无定价模型时按示例价
|
||||
}
|
||||
// 落库的 input_tokens 存输入总量(含缓存读/写),与上游 message_start 口径一致,便于对账展示。
|
||||
in += cacheRead + cacheCreate
|
||||
|
||||
proto, _ := c.Get("protocol")
|
||||
p, _ := proto.(string)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 复现线上火山方舟 qwen 流:data:{...} 无空格(省略 data: 后的空格)。
|
||||
func TestSSEDataPayloadNoSpace(t *testing.T) {
|
||||
lines := []string{
|
||||
`data:{"message":{"model":"qwen3.8-flash","id":"msg_1","role":"assistant","type":"message","content":[],"usage":{"input_tokens":31626,"output_tokens":0}},"type":"message_start"}`,
|
||||
`data:{"delta":{"type":"text_delta","text":"你好"},"type":"content_block_delta","index":0}`,
|
||||
`data:{"delta":{"type":"text_delta","text":"!"},"type":"content_block_delta","index":0}`,
|
||||
`data:{"delta":{"stop_reason":"end_turn"},"type":"message_delta","usage":{"cache_creation":{"ephemeral_5m_input_tokens":33065},"output_tokens":8,"cache_creation_input_tokens":33065,"input_tokens":8,"cache_read_input_tokens":0}}`,
|
||||
}
|
||||
var out string
|
||||
for _, l := range lines {
|
||||
out += sseContentText([]byte(l))
|
||||
}
|
||||
if out != "你好!" {
|
||||
t.Fatalf("outputText=%q, want %q", out, "你好!")
|
||||
}
|
||||
// message_delta 的 delta.usage 应能提取(output_tokens=8)
|
||||
u := scanUsage([]byte(lines[3]))
|
||||
if u == nil {
|
||||
t.Fatal("scanUsage returned nil for message_delta with usage")
|
||||
}
|
||||
var sh usageShape
|
||||
if err := json.Unmarshal(u, &sh); err != nil {
|
||||
t.Fatalf("unmarshal usage: %v", err)
|
||||
}
|
||||
if sh.OutputTokens != 8 {
|
||||
t.Fatalf("output_tokens=%d, want 8", sh.OutputTokens)
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容带空格的单 data: 行(标准 SSE)与 event:+data: 多行块。
|
||||
func TestSSEDataPayloadSpacedAndMultiLine(t *testing.T) {
|
||||
// 标准:data: {...}
|
||||
if got := sseContentText([]byte(`data: {"delta":{"type":"text_delta","text":"hi"},"type":"content_block_delta","index":0}`)); got != "hi" {
|
||||
t.Fatalf("spaced single line: got %q, want hi", got)
|
||||
}
|
||||
// 多行块:event: message_delta\ndata: {...}
|
||||
block := []byte("event: message_delta\ndata: {\"delta\":{\"type\":\"text_delta\",\"text\":\"yo\"},\"type\":\"content_block_delta\",\"index\":0}\n")
|
||||
if got := sseContentText(block); got != "yo" {
|
||||
t.Fatalf("multiline block: got %q, want yo", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 复现线上 qwen(dashscope)messages 流式缓存场景:
|
||||
// message_start.usage.input_tokens 是总输入,message_delta.usage.input_tokens 是非缓存输入
|
||||
// 且带 cache_read/cache_creation,是最终计费口径。合并后:
|
||||
// in(落库)=input+cache_read+cache_creation,计价 in 只算非缓存部分。
|
||||
// 此前 delta 的 input 覆盖 start 的 input 导致总输入丢失(31790 → 8)。
|
||||
func TestUsageSinkMessageDeltaAuthoritative(t *testing.T) {
|
||||
sink := &usageSink{}
|
||||
// message_start:总输入 31790
|
||||
start := json.RawMessage(`{"input_tokens":31790,"output_tokens":0}`)
|
||||
sink.push(start)
|
||||
if got := sink.us.InputTokens; got != 31790 {
|
||||
t.Fatalf("after start: input=%d, want 31790", got)
|
||||
}
|
||||
// message_delta:非缓存输入 8 + 缓存写 33229(最终口径,整体替换)
|
||||
delta := json.RawMessage(`{"output_tokens":8,"cache_creation_input_tokens":33229,"input_tokens":8,"cache_read_input_tokens":0}`)
|
||||
sink.push(delta)
|
||||
s := sink.Shape()
|
||||
if s.InputTokens != 8 || s.CacheCreationInputTokens != 33229 || s.OutputTokens != 8 {
|
||||
t.Fatalf("after delta: %+v, want input=8 cache_create=33229 output=8", s)
|
||||
}
|
||||
// finishUsage 口径:落库 input = 8 + 0 + 33229 = 33237(总量),计价 in=8、cacheCreate=33229
|
||||
in := s.InputTokens + s.CacheReadInputTokens + s.CacheCreationInputTokens
|
||||
if in != 33237 {
|
||||
t.Fatalf("total input=%d, want 33237", in)
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存命中场景(id=55):delta input=76 非缓存 + cache_read=33229 + cache_creation=17。
|
||||
func TestUsageSinkCacheHitMerge(t *testing.T) {
|
||||
sink := &usageSink{}
|
||||
sink.push(json.RawMessage(`{"input_tokens":31862,"output_tokens":0}`))
|
||||
sink.push(json.RawMessage(`{"output_tokens":32,"cache_creation_input_tokens":17,"input_tokens":76,"cache_read_input_tokens":33229}`))
|
||||
s := sink.Shape()
|
||||
total := s.InputTokens + s.CacheReadInputTokens + s.CacheCreationInputTokens
|
||||
if total != 33322 {
|
||||
t.Fatalf("total input=%d, want 33322 (76+33229+17)", total)
|
||||
}
|
||||
if s.OutputTokens != 32 {
|
||||
t.Fatalf("output=%d, want 32", s.OutputTokens)
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI chat 末块(无 cache 字段)仍走零值不覆盖合并,不受整体替换影响。
|
||||
func TestUsageSinkChatLastChunkStillMerges(t *testing.T) {
|
||||
sink := &usageSink{}
|
||||
sink.push(json.RawMessage(`{"prompt_tokens":65,"completion_tokens":0}`))
|
||||
sink.push(json.RawMessage(`{"prompt_tokens":65,"completion_tokens":82}`))
|
||||
s := sink.Shape()
|
||||
if s.PromptTokens != 65 || s.CompletionTokens != 82 {
|
||||
t.Fatalf("chat merge broken: %+v", s)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -11,6 +12,12 @@ import (
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// schemaVersion 当前 schema 版本。struct 变更(加列/改列/删列)时递增,
|
||||
// 触发一次 AutoMigrate 并把新版本写入库(SQLite 用 PRAGMA user_version)。
|
||||
// AutoMigrate 对已有表的列判定不收敛(每次都重建表:CREATE __temp + INSERT SELECT + DROP),
|
||||
// 大表上一次重建数十秒且每次重启重演,所以之后版本未变就直接跳过。
|
||||
const schemaVersion = 2
|
||||
|
||||
// Open 打开数据库连接并自动迁移。
|
||||
// 开发默认 SQLite(dsn 支持 file:...?_journal_mode=WAL),生产可切 postgres。
|
||||
func Open(driver, dsn string) (*gorm.DB, error) {
|
||||
@@ -33,13 +40,35 @@ func Open(driver, dsn string) (*gorm.DB, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if driver != "postgres" && currentSQLiteVersion(db) >= schemaVersion {
|
||||
log.Printf("store: connected driver=%s (schema up-to-date v%d, skip migrate)", driver, schemaVersion)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(AllModels()...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("store: connected driver=%s (migrated)", driver)
|
||||
if driver != "postgres" {
|
||||
setSQLiteVersion(db, schemaVersion)
|
||||
}
|
||||
log.Printf("store: connected driver=%s (migrated, schema v%d)", driver, schemaVersion)
|
||||
// 将已有渠道的超时时间从 120000ms 更新为 300000ms(幂等操作)
|
||||
db.Model(&Channel{}).Where("timeout_ms = ?", 120000).Update("timeout_ms", 300000)
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// currentSQLiteVersion 读取 PRAGMA user_version。
|
||||
func currentSQLiteVersion(db *gorm.DB) int {
|
||||
var v int
|
||||
db.Raw("PRAGMA user_version").Scan(&v)
|
||||
return v
|
||||
}
|
||||
|
||||
// setSQLiteVersion 写入 PRAGMA user_version。
|
||||
func setSQLiteVersion(db *gorm.DB, v int) {
|
||||
db.Exec(fmt.Sprintf("PRAGMA user_version = %d", v))
|
||||
}
|
||||
|
||||
// sqliteDir 提取 SQLite DSN 中的目录部分(忽略 file: 前缀与查询参数)。
|
||||
func sqliteDir(dsn string) string {
|
||||
d := dsn
|
||||
|
||||
@@ -92,7 +92,7 @@ type Channel struct {
|
||||
APIKeyEnc string `gorm:"size:1024;not null" json:"-"` // AES-GCM 密文
|
||||
Weight int `gorm:"not null;default:1" json:"weight"`
|
||||
Priority int `gorm:"not null;default:0" json:"priority"` // 数值小优先
|
||||
TimeoutMS int `gorm:"not null;default:120000" json:"timeout_ms"`
|
||||
TimeoutMS int `gorm:"not null;default:300000" json:"timeout_ms"`
|
||||
MaxConcurrency int `gorm:"not null;default:16" json:"max_concurrency"`
|
||||
HealthStatus string `gorm:"size:16;not null;default:healthy" json:"health_status"`
|
||||
Enabled bool `gorm:"not null;default:true" json:"enabled"`
|
||||
@@ -145,7 +145,6 @@ func (c *Channel) UpstreamURL(proto, path string) string {
|
||||
type Model struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Name string `gorm:"uniqueIndex;size:128;not null" json:"name"`
|
||||
DisplayName string `gorm:"size:128" json:"display_name"`
|
||||
InputPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"input_price"`
|
||||
OutputPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"output_price"`
|
||||
CacheReadPrice float64 `gorm:"type:numeric(20,8);not null;default:0" json:"cache_read_price"`
|
||||
|
||||
@@ -116,7 +116,7 @@ async function logout() {
|
||||
</div>
|
||||
</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">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<button
|
||||
@@ -142,7 +142,7 @@ async function logout() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 px-4 py-6 md:px-6">
|
||||
<main class="min-w-0 flex-1 px-4 py-6 md:px-6">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
+2
-1
@@ -59,7 +59,6 @@ export interface ModelBinding {
|
||||
export interface Model {
|
||||
id: number
|
||||
name: string
|
||||
display_name: string
|
||||
input_price: number
|
||||
output_price: number
|
||||
cache_read_price: number
|
||||
@@ -83,6 +82,8 @@ export interface UsageLog {
|
||||
request_id: string
|
||||
model: string
|
||||
protocol: string
|
||||
channel_id?: number
|
||||
channel?: string
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_read_tokens: number
|
||||
|
||||
@@ -31,7 +31,7 @@ const form = reactive({
|
||||
api_key: '',
|
||||
weight: 1,
|
||||
priority: 0,
|
||||
timeout_ms: 120000,
|
||||
timeout_ms: 300000,
|
||||
max_concurrency: 16,
|
||||
enabled: true,
|
||||
})
|
||||
@@ -51,7 +51,7 @@ function openCreate() {
|
||||
name: '', formats: ['chat'], base_url: '',
|
||||
base_urls: { chat: '', responses: '', messages: '' },
|
||||
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
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@ function quickAdd() {
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
display_name: '',
|
||||
input_price: 0,
|
||||
output_price: 0,
|
||||
cache_read_price: 0,
|
||||
@@ -65,14 +64,14 @@ async function load() {
|
||||
|
||||
function openCreate() {
|
||||
editing.value = null
|
||||
Object.assign(form, { name: '', display_name: '', input_price: 0, output_price: 0, cache_read_price: 0, enabled: true })
|
||||
Object.assign(form, { name: '', input_price: 0, output_price: 0, cache_read_price: 0, enabled: true })
|
||||
editOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(m: Model) {
|
||||
editing.value = m
|
||||
Object.assign(form, {
|
||||
name: m.name, display_name: m.display_name,
|
||||
name: m.name,
|
||||
input_price: m.input_price, output_price: m.output_price, cache_read_price: m.cache_read_price,
|
||||
enabled: m.enabled,
|
||||
})
|
||||
@@ -82,7 +81,6 @@ function openEdit(m: Model) {
|
||||
async function save() {
|
||||
saving.value = true
|
||||
const payload = {
|
||||
display_name: form.display_name || form.name,
|
||||
input_price: Number(form.input_price),
|
||||
output_price: Number(form.output_price),
|
||||
cache_read_price: Number(form.cache_read_price),
|
||||
@@ -207,7 +205,6 @@ onMounted(load)
|
||||
<Modal :open="editOpen" :title="editing ? '编辑模型' : '添加模型'" @close="editOpen = false">
|
||||
<div class="space-y-4">
|
||||
<Input v-model="form.name" label="模型名" placeholder="claude-sonnet-5" :disabled="!!editing" />
|
||||
<Input v-model="form.display_name" label="展示名" />
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input v-model="form.input_price" label="输入价格 /1M" type="number" />
|
||||
<Input v-model="form.output_price" label="输出价格 /1M" type="number" />
|
||||
|
||||
@@ -140,7 +140,7 @@ onMounted(load)
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-mono text-sm text-ink">{{ l.model }}</p>
|
||||
<p class="mt-0.5 truncate text-xs text-muted">{{ l.user }}</p>
|
||||
<p class="mt-0.5 truncate text-xs text-muted">{{ l.channel }} · {{ l.user }}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-col items-end gap-1">
|
||||
<Badge :variant="l.status === 'success' ? 'ok' : l.status === 'canceled' ? 'neutral' : 'err'">{{ l.status }}</Badge>
|
||||
@@ -165,6 +165,7 @@ onMounted(load)
|
||||
<tr class="border-b border-edge text-left text-xs text-muted">
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">用户</th>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">模型</th>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">渠道</th>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">API 格式</th>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">Token 入/出</th>
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">成本</th>
|
||||
@@ -178,6 +179,7 @@ onMounted(load)
|
||||
<tr v-for="l in logs" :key="l.id" class="table-row">
|
||||
<td class="px-4 py-2.5 text-xs text-ink">{{ l.user }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-ink">{{ l.model }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ l.channel || '-' }}</td>
|
||||
<td class="px-4 py-2.5 font-mono text-xs text-muted">{{ protocolShort(l.protocol) }}</td>
|
||||
<td class="px-4 py-2.5 mono-num text-xs text-muted">{{ l.input_tokens }}/{{ l.output_tokens }}</td>
|
||||
<td class="px-4 py-2.5 mono-num text-xs text-ink">{{ fmtCost(l.cost) }}</td>
|
||||
@@ -192,7 +194,7 @@ onMounted(load)
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="logs.length === 0">
|
||||
<td colspan="9" class="px-4 py-10 text-center text-sm text-muted">暂无请求记录</td>
|
||||
<td colspan="10" class="px-4 py-10 text-center text-sm text-muted">暂无请求记录</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -212,6 +214,8 @@ onMounted(load)
|
||||
<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>
|
||||
|
||||
@@ -145,14 +145,14 @@ onMounted(load)
|
||||
</div>
|
||||
|
||||
<!-- 快速开始 -->
|
||||
<div class="card p-5">
|
||||
<div class="card min-w-0 p-5">
|
||||
<div class="mb-4">
|
||||
<h2 class="text-sm font-semibold">快速开始</h2>
|
||||
<p class="text-xs text-muted">通过兼容端点接入,OpenAI / Anthropic 协议自动转换</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<p class="mb-1.5 text-[11px] font-medium text-muted">Base URL</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="min-w-0 flex-1 overflow-x-auto rounded-md border border-edge bg-surface px-3 py-2 font-mono text-xs whitespace-nowrap text-ink">{{ baseURL }}</code>
|
||||
@@ -162,7 +162,7 @@ onMounted(load)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<p class="mb-1.5 text-[11px] font-medium text-muted">兼容</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<Badge variant="neutral">messages</Badge>
|
||||
@@ -171,10 +171,10 @@ onMounted(load)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="min-w-0">
|
||||
<p class="mb-1.5 text-[11px] font-medium text-muted">curl 示例</p>
|
||||
<div class="relative">
|
||||
<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>
|
||||
<div class="relative min-w-0 overflow-hidden rounded-md border border-edge bg-surface">
|
||||
<pre class="overflow-x-auto px-3 py-2.5 pr-20 font-mono text-[11px] leading-relaxed text-ink">{{ curlCmd }}</pre>
|
||||
<Button size="sm" variant="ghost" class="absolute top-2 right-2" @click="copy(curlCmd, 'curl')">
|
||||
{{ copied === 'curl' ? '已复制' : '复制' }}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user