Compare commits

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

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

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

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

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

- push:带 cache_* 字段的 usage 视为最终口径,整体替换 sink
- finishUsage:缓存写按 1.25× 输入价计费(Anthropic 5m 口径);
  落库 input_tokens 存总量(含缓存读/写)便于对账
- 估算兜底条件排除已有缓存计数的请求
- 回归测试:缓存写/缓存命中/chat 末块合并不回归
2026-08-28 18:11:24 +08:00
Sakurasan cd015d370c fix: handle zero channel_id in admin usage logs 2026-08-28 16:55:08 +08:00
Sakurasan 939a98d389 feat: show channel name in admin usage logs 2026-08-28 16:40:23 +08:00
Sakurasan 0b31cc209e fix: chat→responses 转换 input 始终输出数组
单条消息时原逻辑把 input 塌缩成单个对象,火山方舟等上游只接受
[]*InputItem 数组,直接 400(Mismatch type)。chat/messages 经
vole 渠道转换因此全挂。改为恒为数组,三种协议全矩阵实测恢复。
2026-08-27 09:57:48 +08:00
Sakurasan 305b3ed731 refactor: 删除无用的模型展示名(display_name)字段
该字段仅存取、从不展示:管理列表只渲染 name,/v1/models 与用户侧
均不返回它。与渠道绑定 upstream_model 的名字映射职责重叠且悬空。
- store.Model 结构体、admin CRUD、seed 数据移除 display_name
- 前端编辑弹窗输入框、表单状态、types.ts 同步移除
- 本地库已 ALTER TABLE DROP COLUMN;生产残留列无害
2026-08-27 09:57:30 +08:00
23 changed files with 359 additions and 76 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ 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
+4 -1
View File
@@ -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: run:
cd server && go run ./cmd/server cd server && go run ./cmd/server
@@ -6,6 +6,9 @@ run:
reset-admin-password: reset-admin-password:
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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 {
+3 -11
View File
@@ -41,7 +41,7 @@ func (h *Handler) AdminModels(c *gin.Context) {
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,
@@ -119,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"`
@@ -130,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
@@ -144,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 {
@@ -152,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"`
@@ -169,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
} }
+5
View File
@@ -90,8 +90,13 @@ 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,
-1
View File
@@ -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,
+176
View File
@@ -0,0 +1,176 @@
// Package cli 命令行入口。
package cli
import (
"fmt"
"log"
"os"
"strconv"
"strings"
"github.com/openteam/server/internal/config"
"github.com/openteam/server/internal/pkg/crypto"
"github.com/openteam/server/internal/store"
"github.com/spf13/cobra"
"golang.org/x/term"
"gorm.io/gorm"
)
var (
resetPwUser string
resetPwPassword string
resetPwUserID string
)
var resetPasswordCmd = &cobra.Command{
Use: "reset-password",
Short: "重置用户密码",
Long: `重置任意用户密码(管理员可重置任何用户,需在可加载配置的目录下执行)。
交互式引导(默认):
go run ./cmd/server reset-password
非交互式(用于脚本):
go run ./cmd/server reset-password --user admin --password "新密码"
go run ./cmd/server reset-password --user-id 123 --password "新密码"`,
Run: func(cmd *cobra.Command, args []string) {
runResetPassword()
},
}
func init() {
rootCmd.AddCommand(resetPasswordCmd)
resetPasswordCmd.Flags().StringVar(&resetPwUser, "user", "", "目标用户名(留空则交互选择)")
resetPasswordCmd.Flags().StringVar(&resetPwUserID, "user-id", "", "目标用户 ID(优先于 --user)")
resetPasswordCmd.Flags().StringVar(&resetPwPassword, "password", "", "新密码,至少 8 位(留空则交互输入)")
}
func runResetPassword() {
// --- 阶段 1:加载配置与数据库 ---
cfg, err := config.Load()
if err != nil {
log.Fatalf("加载配置失败: %v", err)
}
db, err := store.Open(cfg.DB.Driver, cfg.DB.DSN)
if err != nil {
log.Fatalf("连接数据库失败: %v", err)
}
// --- 阶段 2:选择目标用户 ---
target := targetUser(db)
// --- 阶段 3:获取新密码 ---
var pw string
if resetPwPassword != "" {
pw = resetPwPassword
} else {
pw = promptNewPassword()
}
if pw == "" {
log.Fatalf("密码不能为空")
}
if len(pw) < 8 || len(pw) > 72 {
log.Fatalf("密码长度必须在 8 到 72 位之间")
}
// --- 阶段 4:加密并更新 ---
hasher := crypto.NewPasswordHasher(cfg.Auth.Argon2Time, cfg.Auth.Argon2Memory, cfg.Auth.Argon2Threads, cfg.Auth.Argon2KeyLen, cfg.Auth.SaltLen)
hash, err := hasher.HashPassword(pw)
if err != nil {
log.Fatalf("加密密码失败: %v", err)
}
if err := db.Model(&store.User{}).Where("id = ?", target.ID).Update("password_hash", hash).Error; err != nil {
log.Fatalf("更新密码失败: %v", err)
}
fmt.Printf("✅ 已重置用户 %q 的密码!\n", target.Username)
}
// targetUser 根据 --user-id 或 --user 解析目标用户,否则交互选择。
func targetUser(db *gorm.DB) *store.User {
// 优先使用 --user-id
if resetPwUserID != "" {
id, err := strconv.ParseUint(resetPwUserID, 10, 64)
if err != nil {
log.Fatalf("无效的用户 ID: %v", err)
}
var u store.User
if err := db.First(&u, id).Error; err != nil {
log.Fatalf("用户 ID %d 不存在", id)
}
return &u
}
// 使用 --user
if resetPwUser != "" {
var u store.User
if err := db.Where("username = ?", resetPwUser).First(&u).Error; err != nil {
log.Fatalf("用户 %q 不存在", resetPwUser)
}
return &u
}
// 交互选择
var users []struct {
ID uint64
Username string
Role string
}
db.Model(&store.User{}).Select("id, username, role").Order("id ASC").Find(&users)
if len(users) == 0 {
log.Fatalf("系统中没有用户")
}
if len(users) == 1 {
return &store.User{ID: users[0].ID, Username: users[0].Username}
}
return selectUser(users)
}
// selectUser 交互式选择用户。
func selectUser(users []struct {
ID uint64
Username string
Role string
}) *store.User {
fmt.Println("请选择要重置密码的用户:")
for i, u := range users {
fmt.Printf(" [%d] %s (%s)\n", i+1, u.Username, u.Role)
}
fmt.Printf("请输入编号 (1-%d): ", len(users))
var n int
if _, err := fmt.Scanf("%d", &n); err != nil || n < 1 || n > len(users) {
log.Fatalf("无效输入")
}
return &store.User{ID: users[n-1].ID, Username: users[n-1].Username}
}
// promptNewPassword 交互式输入密码。
func promptNewPassword() string {
fmt.Println("请选择密码设置方式:")
fmt.Println(" [1] 手动输入密码")
fmt.Println(" [2] 自动生成随机密码(16 位,不含易混淆字符)")
fmt.Printf("请输入编号 (1-2): ")
var n int
if _, err := fmt.Scanf("%d", &n); err != nil || n < 1 || n > 2 {
log.Fatalf("无效输入,请输入 1 或 2")
}
if n == 2 {
pw, err := randomPassword(16)
if err != nil {
log.Fatalf("生成随机密码失败: %v", err)
}
fmt.Printf("生成的随机密码:%s\n", pw)
fmt.Println("(请立即保存,不会再次显示)")
return pw
}
// 手动输入:静默输入(不回显)
fmt.Print("请输入新密码(至少 8 位):")
raw, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
log.Fatalf("读取密码失败: %v", err)
}
return strings.TrimSpace(string(raw))
}
+2 -1
View File
@@ -19,7 +19,8 @@ var rootCmd = &cobra.Command{
go run ./cmd/server go run ./cmd/server
子命令: 子命令:
reset-admin-password 重置管理员密码(交互式,无参数时自动提示)`, reset-admin-password 重置管理员密码(交互式,无参数时自动提示)
reset-password 重置任意用户密码`,
RunE: func(c *cobra.Command, args []string) error { RunE: func(c *cobra.Command, args []string) error {
return serve() return serve()
}, },
+1 -1
View File
@@ -31,7 +31,7 @@ func serve() error {
} }
defer a.Shutdown(context.Background()) defer a.Shutdown(context.Background())
gw := proxy.NewGateway(a.DB, a.Enc, a.Usage, a.Limit, cfg.RateLimit.UserRPS, cfg.Proxy.LogRaw) 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) router := api.NewRouter(a, gw)
srv := &http.Server{ srv := &http.Server{
+1 -1
View File
@@ -127,7 +127,7 @@ 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("proxy.log_raw", false)
@@ -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))
+2 -2
View File
@@ -109,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, 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{ return &Gateway{
db: db, db: db,
ch: channel.NewService(db, enc), ch: channel.NewService(db, enc),
@@ -118,7 +118,7 @@ func NewGateway(db *gorm.DB, enc *crypto.Encryptor, rec *usage.Recorder, lim *ra
lim: lim, lim: lim,
userRPS: userRPS, userRPS: userRPS,
logRaw: logRaw, logRaw: logRaw,
hc: &http.Client{Timeout: 120 * time.Second}, hc: &http.Client{Timeout: gw_timeout * time.Second},
} }
} }
+38 -12
View File
@@ -3,7 +3,6 @@ package proxy
import ( import (
"bufio" "bufio"
"bytes" "bytes"
"context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
@@ -164,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 {
@@ -218,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")
} }
@@ -242,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()
@@ -497,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
@@ -550,7 +570,7 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
// message_delta 不带 usage,只能按已收发内容估算,否则记账为 0 消耗 // message_delta 不带 usage,只能按已收发内容估算,否则记账为 0 消耗
// 非流式上游必返回 usage,此处 in/out 非 0 不受影响。 // 非流式上游必返回 usage,此处 in/out 非 0 不受影响。
if status == store.UsageStatusCanceled || status == store.UsageStatusSuccess { 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 est, ok := c.Get("est_input_text"); ok {
if v, ok2 := est.(string); ok2 && v != "" { if v, ok2 := est.(string); ok2 && v != "" {
in = int64(tokenizer.Count(v, mn)) in = int64(tokenizer.Count(v, mn))
@@ -568,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)
+53
View File
@@ -46,3 +46,56 @@ func TestSSEDataPayloadSpacedAndMultiLine(t *testing.T) {
t.Fatalf("multiline block: got %q, want yo", got) t.Fatalf("multiline block: got %q, want yo", got)
} }
} }
// 复现线上 qwen(dashscope)messages 流式缓存场景:
// message_start.usage.input_tokens 是总输入,message_delta.usage.input_tokens 是非缓存输入
// 且带 cache_read/cache_creation,是最终计费口径。合并后:
// in(落库)=input+cache_read+cache_creation,计价 in 只算非缓存部分。
// 此前 delta 的 input 覆盖 start 的 input 导致总输入丢失(31790 → 8)。
func TestUsageSinkMessageDeltaAuthoritative(t *testing.T) {
sink := &usageSink{}
// message_start:总输入 31790
start := json.RawMessage(`{"input_tokens":31790,"output_tokens":0}`)
sink.push(start)
if got := sink.us.InputTokens; got != 31790 {
t.Fatalf("after start: input=%d, want 31790", got)
}
// message_delta:非缓存输入 8 + 缓存写 33229(最终口径,整体替换)
delta := json.RawMessage(`{"output_tokens":8,"cache_creation_input_tokens":33229,"input_tokens":8,"cache_read_input_tokens":0}`)
sink.push(delta)
s := sink.Shape()
if s.InputTokens != 8 || s.CacheCreationInputTokens != 33229 || s.OutputTokens != 8 {
t.Fatalf("after delta: %+v, want input=8 cache_create=33229 output=8", s)
}
// finishUsage 口径:落库 input = 8 + 0 + 33229 = 33237(总量),计价 in=8、cacheCreate=33229
in := s.InputTokens + s.CacheReadInputTokens + s.CacheCreationInputTokens
if in != 33237 {
t.Fatalf("total input=%d, want 33237", in)
}
}
// 缓存命中场景(id=55):delta input=76 非缓存 + cache_read=33229 + cache_creation=17。
func TestUsageSinkCacheHitMerge(t *testing.T) {
sink := &usageSink{}
sink.push(json.RawMessage(`{"input_tokens":31862,"output_tokens":0}`))
sink.push(json.RawMessage(`{"output_tokens":32,"cache_creation_input_tokens":17,"input_tokens":76,"cache_read_input_tokens":33229}`))
s := sink.Shape()
total := s.InputTokens + s.CacheReadInputTokens + s.CacheCreationInputTokens
if total != 33322 {
t.Fatalf("total input=%d, want 33322 (76+33229+17)", total)
}
if s.OutputTokens != 32 {
t.Fatalf("output=%d, want 32", s.OutputTokens)
}
}
// OpenAI chat 末块(无 cache 字段)仍走零值不覆盖合并,不受整体替换影响。
func TestUsageSinkChatLastChunkStillMerges(t *testing.T) {
sink := &usageSink{}
sink.push(json.RawMessage(`{"prompt_tokens":65,"completion_tokens":0}`))
sink.push(json.RawMessage(`{"prompt_tokens":65,"completion_tokens":82}`))
s := sink.Shape()
if s.PromptTokens != 65 || s.CompletionTokens != 82 {
t.Fatalf("chat merge broken: %+v", s)
}
}
+30 -1
View File
@@ -1,6 +1,7 @@
package store package store
import ( import (
"fmt"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
@@ -11,6 +12,12 @@ import (
gormlogger "gorm.io/gorm/logger" gormlogger "gorm.io/gorm/logger"
) )
// schemaVersion 当前 schema 版本。struct 变更(加列/改列/删列)时递增,
// 触发一次 AutoMigrate 并把新版本写入库(SQLite 用 PRAGMA user_version)。
// AutoMigrate 对已有表的列判定不收敛(每次都重建表:CREATE __temp + INSERT SELECT + DROP),
// 大表上一次重建数十秒且每次重启重演,所以之后版本未变就直接跳过。
const schemaVersion = 2
// Open 打开数据库连接并自动迁移。 // Open 打开数据库连接并自动迁移。
// 开发默认 SQLite(dsn 支持 file:...?_journal_mode=WAL),生产可切 postgres。 // 开发默认 SQLite(dsn 支持 file:...?_journal_mode=WAL),生产可切 postgres。
func Open(driver, dsn string) (*gorm.DB, error) { func Open(driver, dsn string) (*gorm.DB, error) {
@@ -33,13 +40,35 @@ func Open(driver, dsn string) (*gorm.DB, error) {
return nil, err return nil, err
} }
if driver != "postgres" && currentSQLiteVersion(db) >= schemaVersion {
log.Printf("store: connected driver=%s (schema up-to-date v%d, skip migrate)", driver, schemaVersion)
return db, nil
}
if err := db.AutoMigrate(AllModels()...); err != nil { if err := db.AutoMigrate(AllModels()...); err != nil {
return nil, err return nil, err
} }
log.Printf("store: connected driver=%s (migrated)", driver) if driver != "postgres" {
setSQLiteVersion(db, schemaVersion)
}
log.Printf("store: connected driver=%s (migrated, schema v%d)", driver, schemaVersion)
// 将已有渠道的超时时间从 120000ms 更新为 300000ms(幂等操作)
db.Model(&Channel{}).Where("timeout_ms = ?", 120000).Update("timeout_ms", 300000)
return db, nil return db, nil
} }
// currentSQLiteVersion 读取 PRAGMA user_version。
func currentSQLiteVersion(db *gorm.DB) int {
var v int
db.Raw("PRAGMA user_version").Scan(&v)
return v
}
// setSQLiteVersion 写入 PRAGMA user_version。
func setSQLiteVersion(db *gorm.DB, v int) {
db.Exec(fmt.Sprintf("PRAGMA user_version = %d", v))
}
// sqliteDir 提取 SQLite DSN 中的目录部分(忽略 file: 前缀与查询参数)。 // sqliteDir 提取 SQLite DSN 中的目录部分(忽略 file: 前缀与查询参数)。
func sqliteDir(dsn string) string { func sqliteDir(dsn string) string {
d := dsn d := dsn
+1 -2
View File
@@ -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"`
+2 -2
View File
@@ -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>
+2 -1
View File
@@ -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
+2 -2
View File
@@ -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
} }
+2 -5
View File
@@ -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 -2
View File
@@ -140,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>
@@ -165,6 +165,7 @@ 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>
@@ -178,6 +179,7 @@ onMounted(load)
<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>
@@ -192,7 +194,7 @@ onMounted(load)
</td> </td>
</tr> </tr>
<tr v-if="logs.length === 0"> <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> </tr>
</tbody> </tbody>
</table> </table>
@@ -212,6 +214,8 @@ onMounted(load)
<div class="text-xs text-muted"> <div class="text-xs text-muted">
<span class="font-mono text-ink">{{ viewing.model }}</span> <span class="font-mono text-ink">{{ viewing.model }}</span>
<span class="mx-1.5">·</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="font-mono">{{ viewing.user || '-' }}</span>
<span class="mx-1.5">·</span> <span class="mx-1.5">·</span>
<span class="mono-num">{{ fmtTime(viewing.created_at) }}</span> <span class="mono-num">{{ fmtTime(viewing.created_at) }}</span>
+6 -6
View File
@@ -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>