Merge branch 'test' into dev

This commit is contained in:
yangjianbo
2026-02-12 14:40:22 +08:00
24 changed files with 1672 additions and 144 deletions

View File

@@ -280,6 +280,9 @@ type GatewayConfig struct {
// ForceCodexCLI: 强制将 OpenAI `/v1/responses` 请求按 Codex CLI 处理。 // ForceCodexCLI: 强制将 OpenAI `/v1/responses` 请求按 Codex CLI 处理。
// 用于网关未透传/改写 User-Agent 时的兼容兜底(默认关闭,避免影响其他客户端)。 // 用于网关未透传/改写 User-Agent 时的兼容兜底(默认关闭,避免影响其他客户端)。
ForceCodexCLI bool `mapstructure:"force_codex_cli"` ForceCodexCLI bool `mapstructure:"force_codex_cli"`
// OpenAIPassthroughAllowTimeoutHeaders: OpenAI 透传模式是否放行客户端超时头
// 关闭(默认)可避免 x-stainless-timeout 等头导致上游提前断流。
OpenAIPassthroughAllowTimeoutHeaders bool `mapstructure:"openai_passthrough_allow_timeout_headers"`
// HTTP 上游连接池配置(性能优化:支持高并发场景调优) // HTTP 上游连接池配置(性能优化:支持高并发场景调优)
// MaxIdleConns: 所有主机的最大空闲连接总数 // MaxIdleConns: 所有主机的最大空闲连接总数
@@ -995,6 +998,7 @@ func setDefaults() {
viper.SetDefault("gateway.max_account_switches", 10) viper.SetDefault("gateway.max_account_switches", 10)
viper.SetDefault("gateway.max_account_switches_gemini", 3) viper.SetDefault("gateway.max_account_switches_gemini", 3)
viper.SetDefault("gateway.force_codex_cli", false) viper.SetDefault("gateway.force_codex_cli", false)
viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false)
viper.SetDefault("gateway.antigravity_fallback_cooldown_minutes", 1) viper.SetDefault("gateway.antigravity_fallback_cooldown_minutes", 1)
viper.SetDefault("gateway.max_body_size", int64(100*1024*1024)) viper.SetDefault("gateway.max_body_size", int64(100*1024*1024))
viper.SetDefault("gateway.sora_max_body_size", int64(256*1024*1024)) viper.SetDefault("gateway.sora_max_body_size", int64(256*1024*1024))

View File

@@ -58,6 +58,96 @@ func TestParseOpsDuration(t *testing.T) {
require.False(t, ok) require.False(t, ok)
} }
func TestParseOpsOpenAITokenStatsDuration(t *testing.T) {
tests := []struct {
input string
want time.Duration
ok bool
}{
{input: "30m", want: 30 * time.Minute, ok: true},
{input: "1h", want: time.Hour, ok: true},
{input: "1d", want: 24 * time.Hour, ok: true},
{input: "15d", want: 15 * 24 * time.Hour, ok: true},
{input: "30d", want: 30 * 24 * time.Hour, ok: true},
{input: "7d", want: 0, ok: false},
}
for _, tt := range tests {
got, ok := parseOpsOpenAITokenStatsDuration(tt.input)
require.Equal(t, tt.ok, ok, "input=%s", tt.input)
require.Equal(t, tt.want, got, "input=%s", tt.input)
}
}
func TestParseOpsOpenAITokenStatsFilter_Defaults(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
before := time.Now().UTC()
filter, err := parseOpsOpenAITokenStatsFilter(c)
after := time.Now().UTC()
require.NoError(t, err)
require.NotNil(t, filter)
require.Equal(t, "30d", filter.TimeRange)
require.Equal(t, 1, filter.Page)
require.Equal(t, 20, filter.PageSize)
require.Equal(t, 0, filter.TopN)
require.Nil(t, filter.GroupID)
require.Equal(t, "", filter.Platform)
require.True(t, filter.StartTime.Before(filter.EndTime))
require.WithinDuration(t, before.Add(-30*24*time.Hour), filter.StartTime, 2*time.Second)
require.WithinDuration(t, after, filter.EndTime, 2*time.Second)
}
func TestParseOpsOpenAITokenStatsFilter_WithTopN(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(
http.MethodGet,
"/?time_range=1h&platform=openai&group_id=12&top_n=50",
nil,
)
filter, err := parseOpsOpenAITokenStatsFilter(c)
require.NoError(t, err)
require.Equal(t, "1h", filter.TimeRange)
require.Equal(t, "openai", filter.Platform)
require.NotNil(t, filter.GroupID)
require.Equal(t, int64(12), *filter.GroupID)
require.Equal(t, 50, filter.TopN)
require.Equal(t, 0, filter.Page)
require.Equal(t, 0, filter.PageSize)
}
func TestParseOpsOpenAITokenStatsFilter_InvalidParams(t *testing.T) {
tests := []string{
"/?time_range=7d",
"/?group_id=0",
"/?group_id=abc",
"/?top_n=0",
"/?top_n=101",
"/?top_n=10&page=1",
"/?top_n=10&page_size=20",
"/?page=0",
"/?page_size=0",
"/?page_size=101",
}
gin.SetMode(gin.TestMode)
for _, rawURL := range tests {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, rawURL, nil)
_, err := parseOpsOpenAITokenStatsFilter(c)
require.Error(t, err, "url=%s", rawURL)
}
}
func TestParseOpsTimeRange(t *testing.T) { func TestParseOpsTimeRange(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
w := httptest.NewRecorder() w := httptest.NewRecorder()

View File

@@ -1,6 +1,7 @@
package admin package admin
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -218,6 +219,115 @@ func (h *OpsHandler) GetDashboardErrorDistribution(c *gin.Context) {
response.Success(c, data) response.Success(c, data)
} }
// GetDashboardOpenAITokenStats returns OpenAI token efficiency stats grouped by model.
// GET /api/v1/admin/ops/dashboard/openai-token-stats
func (h *OpsHandler) GetDashboardOpenAITokenStats(c *gin.Context) {
if h.opsService == nil {
response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
return
}
if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
response.ErrorFrom(c, err)
return
}
filter, err := parseOpsOpenAITokenStatsFilter(c)
if err != nil {
response.BadRequest(c, err.Error())
return
}
data, err := h.opsService.GetOpenAITokenStats(c.Request.Context(), filter)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, data)
}
func parseOpsOpenAITokenStatsFilter(c *gin.Context) (*service.OpsOpenAITokenStatsFilter, error) {
if c == nil {
return nil, fmt.Errorf("invalid request")
}
timeRange := strings.TrimSpace(c.Query("time_range"))
if timeRange == "" {
timeRange = "30d"
}
dur, ok := parseOpsOpenAITokenStatsDuration(timeRange)
if !ok {
return nil, fmt.Errorf("invalid time_range")
}
end := time.Now().UTC()
start := end.Add(-dur)
filter := &service.OpsOpenAITokenStatsFilter{
TimeRange: timeRange,
StartTime: start,
EndTime: end,
Platform: strings.TrimSpace(c.Query("platform")),
}
if v := strings.TrimSpace(c.Query("group_id")); v != "" {
id, err := strconv.ParseInt(v, 10, 64)
if err != nil || id <= 0 {
return nil, fmt.Errorf("invalid group_id")
}
filter.GroupID = &id
}
topNRaw := strings.TrimSpace(c.Query("top_n"))
pageRaw := strings.TrimSpace(c.Query("page"))
pageSizeRaw := strings.TrimSpace(c.Query("page_size"))
if topNRaw != "" && (pageRaw != "" || pageSizeRaw != "") {
return nil, fmt.Errorf("invalid query: top_n cannot be used with page/page_size")
}
if topNRaw != "" {
topN, err := strconv.Atoi(topNRaw)
if err != nil || topN < 1 || topN > 100 {
return nil, fmt.Errorf("invalid top_n")
}
filter.TopN = topN
return filter, nil
}
filter.Page = 1
filter.PageSize = 20
if pageRaw != "" {
page, err := strconv.Atoi(pageRaw)
if err != nil || page < 1 {
return nil, fmt.Errorf("invalid page")
}
filter.Page = page
}
if pageSizeRaw != "" {
pageSize, err := strconv.Atoi(pageSizeRaw)
if err != nil || pageSize < 1 || pageSize > 100 {
return nil, fmt.Errorf("invalid page_size")
}
filter.PageSize = pageSize
}
return filter, nil
}
func parseOpsOpenAITokenStatsDuration(v string) (time.Duration, bool) {
switch strings.TrimSpace(v) {
case "30m":
return 30 * time.Minute, true
case "1h":
return time.Hour, true
case "1d":
return 24 * time.Hour, true
case "15d":
return 15 * 24 * time.Hour, true
case "30d":
return 30 * 24 * time.Hour, true
default:
return 0, false
}
}
func pickThroughputBucketSeconds(window time.Duration) int { func pickThroughputBucketSeconds(window time.Duration) int {
// Keep buckets predictable and avoid huge responses. // Keep buckets predictable and avoid huge responses.
switch { switch {

View File

@@ -28,7 +28,6 @@ type OpenAIGatewayHandler struct {
errorPassthroughService *service.ErrorPassthroughService errorPassthroughService *service.ErrorPassthroughService
concurrencyHelper *ConcurrencyHelper concurrencyHelper *ConcurrencyHelper
maxAccountSwitches int maxAccountSwitches int
cfg *config.Config
} }
// NewOpenAIGatewayHandler creates a new OpenAIGatewayHandler // NewOpenAIGatewayHandler creates a new OpenAIGatewayHandler
@@ -55,7 +54,6 @@ func NewOpenAIGatewayHandler(
errorPassthroughService: errorPassthroughService, errorPassthroughService: errorPassthroughService,
concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatComment, pingInterval), concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatComment, pingInterval),
maxAccountSwitches: maxAccountSwitches, maxAccountSwitches: maxAccountSwitches,
cfg: cfg,
} }
} }

View File

@@ -147,108 +147,6 @@ var (
return 1 return 1
`) `)
// WARNING: Redis Cluster 不兼容 — 脚本内部拼接 keyCluster 模式下可能路由到错误节点。
// 调用时传递空 KEYS 数组,所有 key 在 Lua 内通过 ARGV 动态拼接,
// 无法被 Redis Cluster 正确路由到对应 slot仅适用于单节点或 Sentinel 模式。
//
// getAccountsLoadBatchScript - batch load query with expired slot cleanup
// ARGV[1] = slot TTL (seconds)
// ARGV[2..n] = accountID1, maxConcurrency1, accountID2, maxConcurrency2, ...
getAccountsLoadBatchScript = redis.NewScript(`
local result = {}
local slotTTL = tonumber(ARGV[1])
-- Get current server time
local timeResult = redis.call('TIME')
local nowSeconds = tonumber(timeResult[1])
local cutoffTime = nowSeconds - slotTTL
local i = 2
while i <= #ARGV do
local accountID = ARGV[i]
local maxConcurrency = tonumber(ARGV[i + 1])
local slotKey = 'concurrency:account:' .. accountID
-- Clean up expired slots before counting
redis.call('ZREMRANGEBYSCORE', slotKey, '-inf', cutoffTime)
local currentConcurrency = redis.call('ZCARD', slotKey)
local waitKey = 'wait:account:' .. accountID
local waitingCount = redis.call('GET', waitKey)
if waitingCount == false then
waitingCount = 0
else
waitingCount = tonumber(waitingCount)
end
local loadRate = 0
if maxConcurrency > 0 then
loadRate = math.floor((currentConcurrency + waitingCount) * 100 / maxConcurrency)
end
table.insert(result, accountID)
table.insert(result, currentConcurrency)
table.insert(result, waitingCount)
table.insert(result, loadRate)
i = i + 2
end
return result
`)
// WARNING: Redis Cluster 不兼容 — 脚本内部拼接 keyCluster 模式下可能路由到错误节点。
// 调用时传递空 KEYS 数组,所有 key 在 Lua 内通过 ARGV 动态拼接,
// 无法被 Redis Cluster 正确路由到对应 slot仅适用于单节点或 Sentinel 模式。
//
// getUsersLoadBatchScript - batch load query for users with expired slot cleanup
// ARGV[1] = slot TTL (seconds)
// ARGV[2..n] = userID1, maxConcurrency1, userID2, maxConcurrency2, ...
getUsersLoadBatchScript = redis.NewScript(`
local result = {}
local slotTTL = tonumber(ARGV[1])
-- Get current server time
local timeResult = redis.call('TIME')
local nowSeconds = tonumber(timeResult[1])
local cutoffTime = nowSeconds - slotTTL
local i = 2
while i <= #ARGV do
local userID = ARGV[i]
local maxConcurrency = tonumber(ARGV[i + 1])
local slotKey = 'concurrency:user:' .. userID
-- Clean up expired slots before counting
redis.call('ZREMRANGEBYSCORE', slotKey, '-inf', cutoffTime)
local currentConcurrency = redis.call('ZCARD', slotKey)
local waitKey = 'concurrency:wait:' .. userID
local waitingCount = redis.call('GET', waitKey)
if waitingCount == false then
waitingCount = 0
else
waitingCount = tonumber(waitingCount)
end
local loadRate = 0
if maxConcurrency > 0 then
loadRate = math.floor((currentConcurrency + waitingCount) * 100 / maxConcurrency)
end
table.insert(result, userID)
table.insert(result, currentConcurrency)
table.insert(result, waitingCount)
table.insert(result, loadRate)
i = i + 2
end
return result
`)
// cleanupExpiredSlotsScript - remove expired slots // cleanupExpiredSlotsScript - remove expired slots
// KEYS[1] = concurrency:account:{accountID} // KEYS[1] = concurrency:account:{accountID}
// ARGV[1] = TTL (seconds) // ARGV[1] = TTL (seconds)

View File

@@ -0,0 +1,145 @@
package repository
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/Wei-Shaw/sub2api/internal/service"
)
func (r *opsRepository) GetOpenAITokenStats(ctx context.Context, filter *service.OpsOpenAITokenStatsFilter) (*service.OpsOpenAITokenStatsResponse, error) {
if r == nil || r.db == nil {
return nil, fmt.Errorf("nil ops repository")
}
if filter == nil {
return nil, fmt.Errorf("nil filter")
}
if filter.StartTime.IsZero() || filter.EndTime.IsZero() {
return nil, fmt.Errorf("start_time/end_time required")
}
// 允许 start_time == end_time结果为空与 service 层校验口径保持一致。
if filter.StartTime.After(filter.EndTime) {
return nil, fmt.Errorf("start_time must be <= end_time")
}
dashboardFilter := &service.OpsDashboardFilter{
StartTime: filter.StartTime.UTC(),
EndTime: filter.EndTime.UTC(),
Platform: strings.TrimSpace(strings.ToLower(filter.Platform)),
GroupID: filter.GroupID,
}
join, where, baseArgs, next := buildUsageWhere(dashboardFilter, dashboardFilter.StartTime, dashboardFilter.EndTime, 1)
where += " AND ul.model LIKE 'gpt%'"
baseCTE := `
WITH stats AS (
SELECT
ul.model AS model,
COUNT(*)::bigint AS request_count,
ROUND(
AVG(
CASE
WHEN ul.duration_ms > 0 AND ul.output_tokens > 0
THEN ul.output_tokens * 1000.0 / ul.duration_ms
END
)::numeric,
2
)::float8 AS avg_tokens_per_sec,
ROUND(AVG(ul.first_token_ms)::numeric, 2)::float8 AS avg_first_token_ms,
COALESCE(SUM(ul.output_tokens), 0)::bigint AS total_output_tokens,
COALESCE(ROUND(AVG(ul.duration_ms)::numeric, 0), 0)::bigint AS avg_duration_ms,
COUNT(CASE WHEN ul.first_token_ms IS NOT NULL THEN 1 END)::bigint AS requests_with_first_token
FROM usage_logs ul
` + join + `
` + where + `
GROUP BY ul.model
)
`
countSQL := baseCTE + `SELECT COUNT(*) FROM stats`
var total int64
if err := r.db.QueryRowContext(ctx, countSQL, baseArgs...).Scan(&total); err != nil {
return nil, err
}
querySQL := baseCTE + `
SELECT
model,
request_count,
avg_tokens_per_sec,
avg_first_token_ms,
total_output_tokens,
avg_duration_ms,
requests_with_first_token
FROM stats
ORDER BY request_count DESC, model ASC`
args := make([]any, 0, len(baseArgs)+2)
args = append(args, baseArgs...)
if filter.IsTopNMode() {
querySQL += fmt.Sprintf("\nLIMIT $%d", next)
args = append(args, filter.TopN)
} else {
offset := (filter.Page - 1) * filter.PageSize
querySQL += fmt.Sprintf("\nLIMIT $%d OFFSET $%d", next, next+1)
args = append(args, filter.PageSize, offset)
}
rows, err := r.db.QueryContext(ctx, querySQL, args...)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
items := make([]*service.OpsOpenAITokenStatsItem, 0, 32)
for rows.Next() {
item := &service.OpsOpenAITokenStatsItem{}
var avgTPS sql.NullFloat64
var avgFirstToken sql.NullFloat64
if err := rows.Scan(
&item.Model,
&item.RequestCount,
&avgTPS,
&avgFirstToken,
&item.TotalOutputTokens,
&item.AvgDurationMs,
&item.RequestsWithFirstToken,
); err != nil {
return nil, err
}
if avgTPS.Valid {
v := avgTPS.Float64
item.AvgTokensPerSec = &v
}
if avgFirstToken.Valid {
v := avgFirstToken.Float64
item.AvgFirstTokenMs = &v
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
resp := &service.OpsOpenAITokenStatsResponse{
TimeRange: strings.TrimSpace(filter.TimeRange),
StartTime: dashboardFilter.StartTime,
EndTime: dashboardFilter.EndTime,
Platform: dashboardFilter.Platform,
GroupID: dashboardFilter.GroupID,
Items: items,
Total: total,
}
if filter.IsTopNMode() {
topN := filter.TopN
resp.TopN = &topN
} else {
resp.Page = filter.Page
resp.PageSize = filter.PageSize
}
return resp, nil
}

View File

@@ -0,0 +1,156 @@
package repository
import (
"context"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestOpsRepositoryGetOpenAITokenStats_PaginationMode(t *testing.T) {
db, mock := newSQLMock(t)
repo := &opsRepository{db: db}
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
end := start.Add(24 * time.Hour)
groupID := int64(9)
filter := &service.OpsOpenAITokenStatsFilter{
TimeRange: "1d",
StartTime: start,
EndTime: end,
Platform: " OpenAI ",
GroupID: &groupID,
Page: 2,
PageSize: 10,
}
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM stats`).
WithArgs(start, end, groupID, "openai").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(3)))
rows := sqlmock.NewRows([]string{
"model",
"request_count",
"avg_tokens_per_sec",
"avg_first_token_ms",
"total_output_tokens",
"avg_duration_ms",
"requests_with_first_token",
}).
AddRow("gpt-4o-mini", int64(20), 21.56, 120.34, int64(3000), int64(850), int64(18)).
AddRow("gpt-4.1", int64(20), 10.2, 240.0, int64(2500), int64(900), int64(20))
mock.ExpectQuery(`ORDER BY request_count DESC, model ASC\s+LIMIT \$5 OFFSET \$6`).
WithArgs(start, end, groupID, "openai", 10, 10).
WillReturnRows(rows)
resp, err := repo.GetOpenAITokenStats(context.Background(), filter)
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, int64(3), resp.Total)
require.Equal(t, 2, resp.Page)
require.Equal(t, 10, resp.PageSize)
require.Nil(t, resp.TopN)
require.Equal(t, "openai", resp.Platform)
require.NotNil(t, resp.GroupID)
require.Equal(t, groupID, *resp.GroupID)
require.Len(t, resp.Items, 2)
require.Equal(t, "gpt-4o-mini", resp.Items[0].Model)
require.NotNil(t, resp.Items[0].AvgTokensPerSec)
require.InDelta(t, 21.56, *resp.Items[0].AvgTokensPerSec, 0.0001)
require.NotNil(t, resp.Items[0].AvgFirstTokenMs)
require.InDelta(t, 120.34, *resp.Items[0].AvgFirstTokenMs, 0.0001)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestOpsRepositoryGetOpenAITokenStats_TopNMode(t *testing.T) {
db, mock := newSQLMock(t)
repo := &opsRepository{db: db}
start := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)
end := start.Add(time.Hour)
filter := &service.OpsOpenAITokenStatsFilter{
TimeRange: "1h",
StartTime: start,
EndTime: end,
TopN: 5,
}
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM stats`).
WithArgs(start, end).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(1)))
rows := sqlmock.NewRows([]string{
"model",
"request_count",
"avg_tokens_per_sec",
"avg_first_token_ms",
"total_output_tokens",
"avg_duration_ms",
"requests_with_first_token",
}).
AddRow("gpt-4o", int64(5), nil, nil, int64(0), int64(0), int64(0))
mock.ExpectQuery(`ORDER BY request_count DESC, model ASC\s+LIMIT \$3`).
WithArgs(start, end, 5).
WillReturnRows(rows)
resp, err := repo.GetOpenAITokenStats(context.Background(), filter)
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, resp.TopN)
require.Equal(t, 5, *resp.TopN)
require.Equal(t, 0, resp.Page)
require.Equal(t, 0, resp.PageSize)
require.Len(t, resp.Items, 1)
require.Nil(t, resp.Items[0].AvgTokensPerSec)
require.Nil(t, resp.Items[0].AvgFirstTokenMs)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestOpsRepositoryGetOpenAITokenStats_EmptyResult(t *testing.T) {
db, mock := newSQLMock(t)
repo := &opsRepository{db: db}
start := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)
end := start.Add(30 * time.Minute)
filter := &service.OpsOpenAITokenStatsFilter{
TimeRange: "30m",
StartTime: start,
EndTime: end,
Page: 1,
PageSize: 20,
}
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM stats`).
WithArgs(start, end).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(0)))
mock.ExpectQuery(`ORDER BY request_count DESC, model ASC\s+LIMIT \$3 OFFSET \$4`).
WithArgs(start, end, 20, 0).
WillReturnRows(sqlmock.NewRows([]string{
"model",
"request_count",
"avg_tokens_per_sec",
"avg_first_token_ms",
"total_output_tokens",
"avg_duration_ms",
"requests_with_first_token",
}))
resp, err := repo.GetOpenAITokenStats(context.Background(), filter)
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, int64(0), resp.Total)
require.Len(t, resp.Items, 0)
require.Equal(t, 1, resp.Page)
require.Equal(t, 20, resp.PageSize)
require.NoError(t, mock.ExpectationsWereMet())
}

View File

@@ -150,6 +150,7 @@ func registerOpsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
ops.GET("/dashboard/latency-histogram", h.Admin.Ops.GetDashboardLatencyHistogram) ops.GET("/dashboard/latency-histogram", h.Admin.Ops.GetDashboardLatencyHistogram)
ops.GET("/dashboard/error-trend", h.Admin.Ops.GetDashboardErrorTrend) ops.GET("/dashboard/error-trend", h.Admin.Ops.GetDashboardErrorTrend)
ops.GET("/dashboard/error-distribution", h.Admin.Ops.GetDashboardErrorDistribution) ops.GET("/dashboard/error-distribution", h.Admin.Ops.GetDashboardErrorDistribution)
ops.GET("/dashboard/openai-token-stats", h.Admin.Ops.GetDashboardOpenAITokenStats)
} }
} }

View File

@@ -189,45 +189,6 @@ func sliceRawFromBody(body []byte, r gjson.Result) []byte {
return []byte(r.Raw) return []byte(r.Raw)
} }
// parseIntegralNumber 将 JSON 解码后的数字安全转换为 int。
// 仅接受“整数值”的输入,小数/NaN/Inf/越界值都会返回 false。
func parseIntegralNumber(raw any) (int, bool) {
switch v := raw.(type) {
case float64:
if math.IsNaN(v) || math.IsInf(v, 0) || v != math.Trunc(v) {
return 0, false
}
if v > float64(math.MaxInt) || v < float64(math.MinInt) {
return 0, false
}
return int(v), true
case int:
return v, true
case int8:
return int(v), true
case int16:
return int(v), true
case int32:
return int(v), true
case int64:
if v > int64(math.MaxInt) || v < int64(math.MinInt) {
return 0, false
}
return int(v), true
case json.Number:
i64, err := v.Int64()
if err != nil {
return 0, false
}
if i64 > int64(math.MaxInt) || i64 < int64(math.MinInt) {
return 0, false
}
return int(i64), true
default:
return 0, false
}
}
// FilterThinkingBlocks removes thinking blocks from request body // FilterThinkingBlocks removes thinking blocks from request body
// Returns filtered body or original body if filtering fails (fail-safe) // Returns filtered body or original body if filtering fails (fail-safe)
// This prevents 400 errors from invalid thinking block signatures // This prevents 400 errors from invalid thinking block signatures

View File

@@ -1020,6 +1020,23 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
reqModel, reqModel,
reqStream, reqStream,
) )
if reqStream && c != nil && c.Request != nil {
if timeoutHeaders := collectOpenAIPassthroughTimeoutHeaders(c.Request.Header); len(timeoutHeaders) > 0 {
if s.isOpenAIPassthroughTimeoutHeadersAllowed() {
log.Printf(
"[WARN] [OpenAI passthrough] 透传请求包含超时相关请求头,且当前配置为放行,可能导致上游提前断流: account=%d headers=%s",
account.ID,
strings.Join(timeoutHeaders, ", "),
)
} else {
log.Printf(
"[WARN] [OpenAI passthrough] 检测到超时相关请求头,将按配置过滤以降低断流风险: account=%d headers=%s",
account.ID,
strings.Join(timeoutHeaders, ", "),
)
}
}
}
// Get access token // Get access token
token, _, err := s.GetAccessToken(ctx, account) token, _, err := s.GetAccessToken(ctx, account)
@@ -1135,12 +1152,16 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
} }
// 透传客户端请求头(尽可能原样),并做安全剔除。 // 透传客户端请求头(尽可能原样),并做安全剔除。
allowTimeoutHeaders := s.isOpenAIPassthroughTimeoutHeadersAllowed()
if c != nil && c.Request != nil { if c != nil && c.Request != nil {
for key, values := range c.Request.Header { for key, values := range c.Request.Header {
lower := strings.ToLower(key) lower := strings.ToLower(key)
if isOpenAIPassthroughBlockedRequestHeader(lower) { if isOpenAIPassthroughBlockedRequestHeader(lower) {
continue continue
} }
if !allowTimeoutHeaders && isOpenAIPassthroughTimeoutHeader(lower) {
continue
}
for _, v := range values { for _, v := range values {
req.Header.Add(key, v) req.Header.Add(key, v)
} }
@@ -1233,6 +1254,38 @@ func isOpenAIPassthroughBlockedRequestHeader(lowerKey string) bool {
} }
} }
func isOpenAIPassthroughTimeoutHeader(lowerKey string) bool {
switch lowerKey {
case "x-stainless-timeout", "x-stainless-read-timeout", "x-stainless-connect-timeout", "x-request-timeout", "request-timeout", "grpc-timeout":
return true
default:
return false
}
}
func (s *OpenAIGatewayService) isOpenAIPassthroughTimeoutHeadersAllowed() bool {
return s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIPassthroughAllowTimeoutHeaders
}
func collectOpenAIPassthroughTimeoutHeaders(h http.Header) []string {
if h == nil {
return nil
}
var matched []string
for key, values := range h {
lowerKey := strings.ToLower(strings.TrimSpace(key))
if isOpenAIPassthroughTimeoutHeader(lowerKey) {
entry := lowerKey
if len(values) > 0 {
entry = fmt.Sprintf("%s=%s", lowerKey, strings.Join(values, "|"))
}
matched = append(matched, entry)
}
}
sort.Strings(matched)
return matched
}
type openaiStreamingResultPassthrough struct { type openaiStreamingResultPassthrough struct {
usage *OpenAIUsage usage *OpenAIUsage
firstTokenMs *int firstTokenMs *int
@@ -1265,6 +1318,8 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
usage := &OpenAIUsage{} usage := &OpenAIUsage{}
var firstTokenMs *int var firstTokenMs *int
clientDisconnected := false clientDisconnected := false
sawDone := false
upstreamRequestID := strings.TrimSpace(resp.Header.Get("x-request-id"))
scanner := bufio.NewScanner(resp.Body) scanner := bufio.NewScanner(resp.Body)
maxLineSize := defaultMaxLineSize maxLineSize := defaultMaxLineSize
@@ -1278,7 +1333,11 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
if data, ok := extractOpenAISSEDataLine(line); ok { if data, ok := extractOpenAISSEDataLine(line); ok {
if firstTokenMs == nil && strings.TrimSpace(data) != "" { trimmedData := strings.TrimSpace(data)
if trimmedData == "[DONE]" {
sawDone = true
}
if firstTokenMs == nil && trimmedData != "" && trimmedData != "[DONE]" {
ms := int(time.Since(startTime).Milliseconds()) ms := int(time.Since(startTime).Milliseconds())
firstTokenMs = &ms firstTokenMs = &ms
} }
@@ -1300,14 +1359,34 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, nil return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, nil
} }
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
log.Printf(
"[WARN] [OpenAI passthrough] 流读取被取消,可能发生断流: account=%d request_id=%s err=%v ctx_err=%v",
account.ID,
upstreamRequestID,
err,
ctx.Err(),
)
return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, nil return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, nil
} }
if errors.Is(err, bufio.ErrTooLong) { if errors.Is(err, bufio.ErrTooLong) {
log.Printf("[OpenAI passthrough] SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, err) log.Printf("[OpenAI passthrough] SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, err)
return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, err return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, err
} }
log.Printf(
"[WARN] [OpenAI passthrough] 流读取异常中断: account=%d request_id=%s err=%v",
account.ID,
upstreamRequestID,
err,
)
return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream read error: %w", err) return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream read error: %w", err)
} }
if !clientDisconnected && !sawDone && ctx.Err() == nil {
log.Printf(
"[WARN] [OpenAI passthrough] 上游流在未收到 [DONE] 时结束,疑似断流: account=%d request_id=%s",
account.ID,
upstreamRequestID,
)
}
return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, nil return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, nil
} }

View File

@@ -4,9 +4,12 @@ import (
"bytes" "bytes"
"context" "context"
"io" "io"
"log"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
@@ -43,6 +46,27 @@ func (u *httpUpstreamRecorder) DoWithTLS(req *http.Request, proxyURL string, acc
return u.Do(req, proxyURL, accountID, accountConcurrency) return u.Do(req, proxyURL, accountID, accountConcurrency)
} }
var stdLogCaptureMu sync.Mutex
func captureStdLog(t *testing.T) (*bytes.Buffer, func()) {
t.Helper()
stdLogCaptureMu.Lock()
buf := &bytes.Buffer{}
prevWriter := log.Writer()
prevFlags := log.Flags()
log.SetFlags(0)
log.SetOutput(buf)
return buf, func() {
log.SetOutput(prevWriter)
log.SetFlags(prevFlags)
// 防御性恢复,避免其他测试改动了底层 writer。
if prevWriter == nil {
log.SetOutput(os.Stderr)
}
stdLogCaptureMu.Unlock()
}
}
func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyUnchanged(t *testing.T) { func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyUnchanged(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
@@ -459,3 +483,170 @@ func TestOpenAIGatewayService_APIKeyPassthrough_PreservesBodyAndUsesResponsesEnd
require.Equal(t, "curl/8.0", upstream.lastReq.Header.Get("User-Agent")) require.Equal(t, "curl/8.0", upstream.lastReq.Header.Get("User-Agent"))
require.Equal(t, "keep", upstream.lastReq.Header.Get("X-Test")) require.Equal(t, "keep", upstream.lastReq.Header.Get("X-Test"))
} }
func TestOpenAIGatewayService_OAuthPassthrough_WarnOnTimeoutHeadersForStream(t *testing.T) {
gin.SetMode(gin.TestMode)
logBuf, restore := captureStdLog(t)
defer restore()
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.1.0")
c.Request.Header.Set("x-stainless-timeout", "10000")
originalBody := []byte(`{"model":"gpt-5.2","stream":true,"input":[{"type":"text","text":"hi"}]}`)
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}, "X-Request-Id": []string{"rid-timeout"}},
Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")),
}
upstream := &httpUpstreamRecorder{resp: resp}
svc := &OpenAIGatewayService{
cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}},
httpUpstream: upstream,
}
account := &Account{
ID: 321,
Name: "acc",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Concurrency: 1,
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
Extra: map[string]any{"openai_passthrough": true},
Status: StatusActive,
Schedulable: true,
RateMultiplier: f64p(1),
}
_, err := svc.Forward(context.Background(), c, account, originalBody)
require.NoError(t, err)
require.Contains(t, logBuf.String(), "检测到超时相关请求头,将按配置过滤以降低断流风险")
require.Contains(t, logBuf.String(), "x-stainless-timeout=10000")
}
func TestOpenAIGatewayService_OAuthPassthrough_WarnWhenStreamEndsWithoutDone(t *testing.T) {
gin.SetMode(gin.TestMode)
logBuf, restore := captureStdLog(t)
defer restore()
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.1.0")
originalBody := []byte(`{"model":"gpt-5.2","stream":true,"input":[{"type":"text","text":"hi"}]}`)
// 注意:刻意不发送 [DONE],模拟上游中途断流。
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}, "X-Request-Id": []string{"rid-truncate"}},
Body: io.NopCloser(strings.NewReader("data: {\"type\":\"response.output_text.delta\",\"delta\":\"h\"}\n\n")),
}
upstream := &httpUpstreamRecorder{resp: resp}
svc := &OpenAIGatewayService{
cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}},
httpUpstream: upstream,
}
account := &Account{
ID: 654,
Name: "acc",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Concurrency: 1,
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
Extra: map[string]any{"openai_passthrough": true},
Status: StatusActive,
Schedulable: true,
RateMultiplier: f64p(1),
}
_, err := svc.Forward(context.Background(), c, account, originalBody)
require.NoError(t, err)
require.Contains(t, logBuf.String(), "上游流在未收到 [DONE] 时结束,疑似断流")
require.Contains(t, logBuf.String(), "rid-truncate")
}
func TestOpenAIGatewayService_OAuthPassthrough_DefaultFiltersTimeoutHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.1.0")
c.Request.Header.Set("x-stainless-timeout", "120000")
c.Request.Header.Set("X-Test", "keep")
originalBody := []byte(`{"model":"gpt-5.2","stream":false,"input":[{"type":"text","text":"hi"}]}`)
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}, "X-Request-Id": []string{"rid-filter-default"}},
Body: io.NopCloser(strings.NewReader(`{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`)),
}
upstream := &httpUpstreamRecorder{resp: resp}
svc := &OpenAIGatewayService{
cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}},
httpUpstream: upstream,
}
account := &Account{
ID: 111,
Name: "acc",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Concurrency: 1,
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
Extra: map[string]any{"openai_passthrough": true},
Status: StatusActive,
Schedulable: true,
RateMultiplier: f64p(1),
}
_, err := svc.Forward(context.Background(), c, account, originalBody)
require.NoError(t, err)
require.NotNil(t, upstream.lastReq)
require.Empty(t, upstream.lastReq.Header.Get("x-stainless-timeout"))
require.Equal(t, "keep", upstream.lastReq.Header.Get("X-Test"))
}
func TestOpenAIGatewayService_OAuthPassthrough_AllowTimeoutHeadersWhenConfigured(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.1.0")
c.Request.Header.Set("x-stainless-timeout", "120000")
c.Request.Header.Set("X-Test", "keep")
originalBody := []byte(`{"model":"gpt-5.2","stream":false,"input":[{"type":"text","text":"hi"}]}`)
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}, "X-Request-Id": []string{"rid-filter-allow"}},
Body: io.NopCloser(strings.NewReader(`{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`)),
}
upstream := &httpUpstreamRecorder{resp: resp}
svc := &OpenAIGatewayService{
cfg: &config.Config{Gateway: config.GatewayConfig{
ForceCodexCLI: false,
OpenAIPassthroughAllowTimeoutHeaders: true,
}},
httpUpstream: upstream,
}
account := &Account{
ID: 222,
Name: "acc",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Concurrency: 1,
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
Extra: map[string]any{"openai_passthrough": true},
Status: StatusActive,
Schedulable: true,
RateMultiplier: f64p(1),
}
_, err := svc.Forward(context.Background(), c, account, originalBody)
require.NoError(t, err)
require.NotNil(t, upstream.lastReq)
require.Equal(t, "120000", upstream.lastReq.Header.Get("x-stainless-timeout"))
require.Equal(t, "keep", upstream.lastReq.Header.Get("X-Test"))
}

View File

@@ -0,0 +1,55 @@
package service
import (
"context"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
func (s *OpsService) GetOpenAITokenStats(ctx context.Context, filter *OpsOpenAITokenStatsFilter) (*OpsOpenAITokenStatsResponse, error) {
if err := s.RequireMonitoringEnabled(ctx); err != nil {
return nil, err
}
if s.opsRepo == nil {
return nil, infraerrors.ServiceUnavailable("OPS_REPO_UNAVAILABLE", "Ops repository not available")
}
if filter == nil {
return nil, infraerrors.BadRequest("OPS_FILTER_REQUIRED", "filter is required")
}
if filter.StartTime.IsZero() || filter.EndTime.IsZero() {
return nil, infraerrors.BadRequest("OPS_TIME_RANGE_REQUIRED", "start_time/end_time are required")
}
if filter.StartTime.After(filter.EndTime) {
return nil, infraerrors.BadRequest("OPS_TIME_RANGE_INVALID", "start_time must be <= end_time")
}
if filter.GroupID != nil && *filter.GroupID <= 0 {
return nil, infraerrors.BadRequest("OPS_GROUP_ID_INVALID", "group_id must be > 0")
}
// top_n cannot be mixed with page/page_size params.
if filter.TopN > 0 && (filter.Page > 0 || filter.PageSize > 0) {
return nil, infraerrors.BadRequest("OPS_PAGINATION_CONFLICT", "top_n cannot be used with page/page_size")
}
if filter.TopN > 0 {
if filter.TopN < 1 || filter.TopN > 100 {
return nil, infraerrors.BadRequest("OPS_TOPN_INVALID", "top_n must be between 1 and 100")
}
} else {
if filter.Page <= 0 {
filter.Page = 1
}
if filter.PageSize <= 0 {
filter.PageSize = 20
}
if filter.Page < 1 {
return nil, infraerrors.BadRequest("OPS_PAGE_INVALID", "page must be >= 1")
}
if filter.PageSize < 1 || filter.PageSize > 100 {
return nil, infraerrors.BadRequest("OPS_PAGE_SIZE_INVALID", "page_size must be between 1 and 100")
}
}
return s.opsRepo.GetOpenAITokenStats(ctx, filter)
}

View File

@@ -0,0 +1,54 @@
package service
import "time"
type OpsOpenAITokenStatsFilter struct {
TimeRange string
StartTime time.Time
EndTime time.Time
Platform string
GroupID *int64
// Pagination mode (default): page/page_size
Page int
PageSize int
// TopN mode: top_n
TopN int
}
func (f *OpsOpenAITokenStatsFilter) IsTopNMode() bool {
return f != nil && f.TopN > 0
}
type OpsOpenAITokenStatsItem struct {
Model string `json:"model"`
RequestCount int64 `json:"request_count"`
AvgTokensPerSec *float64 `json:"avg_tokens_per_sec"`
AvgFirstTokenMs *float64 `json:"avg_first_token_ms"`
TotalOutputTokens int64 `json:"total_output_tokens"`
AvgDurationMs int64 `json:"avg_duration_ms"`
RequestsWithFirstToken int64 `json:"requests_with_first_token"`
}
type OpsOpenAITokenStatsResponse struct {
TimeRange string `json:"time_range"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
Platform string `json:"platform,omitempty"`
GroupID *int64 `json:"group_id,omitempty"`
Items []*OpsOpenAITokenStatsItem `json:"items"`
// Total model rows before pagination/topN trimming.
Total int64 `json:"total"`
// Pagination mode metadata.
Page int `json:"page,omitempty"`
PageSize int `json:"page_size,omitempty"`
// TopN mode metadata.
TopN *int `json:"top_n,omitempty"`
}

View File

@@ -0,0 +1,162 @@
package service
import (
"context"
"testing"
"time"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/stretchr/testify/require"
)
type openAITokenStatsRepoStub struct {
OpsRepository
resp *OpsOpenAITokenStatsResponse
err error
captured *OpsOpenAITokenStatsFilter
}
func (s *openAITokenStatsRepoStub) GetOpenAITokenStats(ctx context.Context, filter *OpsOpenAITokenStatsFilter) (*OpsOpenAITokenStatsResponse, error) {
s.captured = filter
if s.err != nil {
return nil, s.err
}
if s.resp != nil {
return s.resp, nil
}
return &OpsOpenAITokenStatsResponse{}, nil
}
func TestOpsServiceGetOpenAITokenStats_Validation(t *testing.T) {
now := time.Now().UTC()
tests := []struct {
name string
filter *OpsOpenAITokenStatsFilter
wantCode int
wantReason string
}{
{
name: "filter 不能为空",
filter: nil,
wantCode: 400,
wantReason: "OPS_FILTER_REQUIRED",
},
{
name: "start_time/end_time 必填",
filter: &OpsOpenAITokenStatsFilter{
StartTime: time.Time{},
EndTime: now,
},
wantCode: 400,
wantReason: "OPS_TIME_RANGE_REQUIRED",
},
{
name: "start_time 不能晚于 end_time",
filter: &OpsOpenAITokenStatsFilter{
StartTime: now,
EndTime: now.Add(-1 * time.Minute),
},
wantCode: 400,
wantReason: "OPS_TIME_RANGE_INVALID",
},
{
name: "group_id 必须大于 0",
filter: &OpsOpenAITokenStatsFilter{
StartTime: now.Add(-time.Hour),
EndTime: now,
GroupID: int64Ptr(0),
},
wantCode: 400,
wantReason: "OPS_GROUP_ID_INVALID",
},
{
name: "top_n 与分页参数互斥",
filter: &OpsOpenAITokenStatsFilter{
StartTime: now.Add(-time.Hour),
EndTime: now,
TopN: 10,
Page: 1,
},
wantCode: 400,
wantReason: "OPS_PAGINATION_CONFLICT",
},
{
name: "top_n 参数越界",
filter: &OpsOpenAITokenStatsFilter{
StartTime: now.Add(-time.Hour),
EndTime: now,
TopN: 101,
},
wantCode: 400,
wantReason: "OPS_TOPN_INVALID",
},
{
name: "page_size 参数越界",
filter: &OpsOpenAITokenStatsFilter{
StartTime: now.Add(-time.Hour),
EndTime: now,
Page: 1,
PageSize: 101,
},
wantCode: 400,
wantReason: "OPS_PAGE_SIZE_INVALID",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := &OpsService{
opsRepo: &openAITokenStatsRepoStub{},
}
_, err := svc.GetOpenAITokenStats(context.Background(), tt.filter)
require.Error(t, err)
require.Equal(t, tt.wantCode, infraerrors.Code(err))
require.Equal(t, tt.wantReason, infraerrors.Reason(err))
})
}
}
func TestOpsServiceGetOpenAITokenStats_DefaultPagination(t *testing.T) {
now := time.Now().UTC()
repo := &openAITokenStatsRepoStub{
resp: &OpsOpenAITokenStatsResponse{
Items: []*OpsOpenAITokenStatsItem{
{Model: "gpt-4o-mini", RequestCount: 10},
},
Total: 1,
},
}
svc := &OpsService{opsRepo: repo}
filter := &OpsOpenAITokenStatsFilter{
TimeRange: "30d",
StartTime: now.Add(-30 * 24 * time.Hour),
EndTime: now,
}
resp, err := svc.GetOpenAITokenStats(context.Background(), filter)
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, repo.captured)
require.Equal(t, 1, repo.captured.Page)
require.Equal(t, 20, repo.captured.PageSize)
require.Equal(t, 0, repo.captured.TopN)
}
func TestOpsServiceGetOpenAITokenStats_RepoUnavailable(t *testing.T) {
now := time.Now().UTC()
svc := &OpsService{}
_, err := svc.GetOpenAITokenStats(context.Background(), &OpsOpenAITokenStatsFilter{
TimeRange: "1h",
StartTime: now.Add(-time.Hour),
EndTime: now,
TopN: 10,
})
require.Error(t, err)
require.Equal(t, 503, infraerrors.Code(err))
require.Equal(t, "OPS_REPO_UNAVAILABLE", infraerrors.Reason(err))
}
func int64Ptr(v int64) *int64 { return &v }

View File

@@ -27,6 +27,7 @@ type OpsRepository interface {
GetLatencyHistogram(ctx context.Context, filter *OpsDashboardFilter) (*OpsLatencyHistogramResponse, error) GetLatencyHistogram(ctx context.Context, filter *OpsDashboardFilter) (*OpsLatencyHistogramResponse, error)
GetErrorTrend(ctx context.Context, filter *OpsDashboardFilter, bucketSeconds int) (*OpsErrorTrendResponse, error) GetErrorTrend(ctx context.Context, filter *OpsDashboardFilter, bucketSeconds int) (*OpsErrorTrendResponse, error)
GetErrorDistribution(ctx context.Context, filter *OpsDashboardFilter) (*OpsErrorDistributionResponse, error) GetErrorDistribution(ctx context.Context, filter *OpsDashboardFilter) (*OpsErrorDistributionResponse, error)
GetOpenAITokenStats(ctx context.Context, filter *OpsOpenAITokenStatsFilter) (*OpsOpenAITokenStatsResponse, error)
InsertSystemMetrics(ctx context.Context, input *OpsInsertSystemMetricsInput) error InsertSystemMetrics(ctx context.Context, input *OpsInsertSystemMetricsInput) error
GetLatestSystemMetrics(ctx context.Context, windowMinutes int) (*OpsSystemMetricsSnapshot, error) GetLatestSystemMetrics(ctx context.Context, windowMinutes int) (*OpsSystemMetricsSnapshot, error)

View File

@@ -0,0 +1,51 @@
//go:build unit
package service
import (
"encoding/json"
"math"
)
// parseIntegralNumber 将 JSON 解码后的数字安全转换为 int。
// 仅接受“整数值”的输入,小数/NaN/Inf/越界值都会返回 false。
//
// 说明:
// - 该函数当前仅用于 unit 测试中的 map-based 解析逻辑验证,因此放在 unit build tag 下,
// 避免在默认构建中触发 unused lint。
func parseIntegralNumber(raw any) (int, bool) {
switch v := raw.(type) {
case float64:
if math.IsNaN(v) || math.IsInf(v, 0) || v != math.Trunc(v) {
return 0, false
}
if v > float64(math.MaxInt) || v < float64(math.MinInt) {
return 0, false
}
return int(v), true
case int:
return v, true
case int8:
return int(v), true
case int16:
return int(v), true
case int32:
return int(v), true
case int64:
if v > int64(math.MaxInt) || v < int64(math.MinInt) {
return 0, false
}
return int(v), true
case json.Number:
i64, err := v.Int64()
if err != nil {
return 0, false
}
if i64 > int64(math.MaxInt) || i64 < int64(math.MinInt) {
return 0, false
}
return int(i64), true
default:
return 0, false
}
}

8
build_image_aicodex.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/bin/bash
# 本地构建镜像的快速脚本,避免在命令行反复输入构建参数。
docker build -t yangjianbo/aicodex2api:latest \
--build-arg GOPROXY=https://goproxy.cn,direct \
--build-arg GOSUMDB=sum.golang.google.cn \
-f Dockerfile \
.

View File

@@ -187,6 +187,9 @@ gateway:
# #
# 注意:开启后会影响所有客户端的行为(不仅限于 VS Code / Codex CLI请谨慎开启。 # 注意:开启后会影响所有客户端的行为(不仅限于 VS Code / Codex CLI请谨慎开启。
force_codex_cli: false force_codex_cli: false
# OpenAI 透传模式是否放行客户端超时头(如 x-stainless-timeout
# 默认 false过滤超时头降低上游提前断流风险。
openai_passthrough_allow_timeout_headers: false
# HTTP upstream connection pool settings (HTTP/2 + multi-proxy scenario defaults) # HTTP upstream connection pool settings (HTTP/2 + multi-proxy scenario defaults)
# HTTP 上游连接池配置HTTP/2 + 多代理场景默认值) # HTTP 上游连接池配置HTTP/2 + 多代理场景默认值)
# Max idle connections across all hosts # Max idle connections across all hosts

View File

@@ -259,6 +259,40 @@ export interface OpsErrorDistributionResponse {
items: OpsErrorDistributionItem[] items: OpsErrorDistributionItem[]
} }
export type OpsOpenAITokenStatsTimeRange = '30m' | '1h' | '1d' | '15d' | '30d'
export interface OpsOpenAITokenStatsItem {
model: string
request_count: number
avg_tokens_per_sec?: number | null
avg_first_token_ms?: number | null
total_output_tokens: number
avg_duration_ms: number
requests_with_first_token: number
}
export interface OpsOpenAITokenStatsResponse {
time_range: OpsOpenAITokenStatsTimeRange
start_time: string
end_time: string
platform?: string
group_id?: number | null
items: OpsOpenAITokenStatsItem[]
total: number
page?: number
page_size?: number
top_n?: number | null
}
export interface OpsOpenAITokenStatsParams {
time_range?: OpsOpenAITokenStatsTimeRange
platform?: string
group_id?: number | null
page?: number
page_size?: number
top_n?: number
}
export interface OpsSystemMetricsSnapshot { export interface OpsSystemMetricsSnapshot {
id: number id: number
created_at: string created_at: string
@@ -971,6 +1005,17 @@ export async function getErrorDistribution(
return data return data
} }
export async function getOpenAITokenStats(
params: OpsOpenAITokenStatsParams,
options: OpsRequestOptions = {}
): Promise<OpsOpenAITokenStatsResponse> {
const { data } = await apiClient.get<OpsOpenAITokenStatsResponse>('/admin/ops/dashboard/openai-token-stats', {
params,
signal: options.signal
})
return data
}
export type OpsErrorListView = 'errors' | 'excluded' | 'all' export type OpsErrorListView = 'errors' | 'excluded' | 'all'
export type OpsErrorListQueryParams = { export type OpsErrorListQueryParams = {
@@ -1188,6 +1233,7 @@ export const opsAPI = {
getLatencyHistogram, getLatencyHistogram,
getErrorTrend, getErrorTrend,
getErrorDistribution, getErrorDistribution,
getOpenAITokenStats,
getConcurrencyStats, getConcurrencyStats,
getUserConcurrencyStats, getUserConcurrencyStats,
getAccountAvailabilityStats, getAccountAvailabilityStats,

View File

@@ -2508,11 +2508,33 @@ export default {
'5m': 'Last 5 minutes', '5m': 'Last 5 minutes',
'30m': 'Last 30 minutes', '30m': 'Last 30 minutes',
'1h': 'Last 1 hour', '1h': 'Last 1 hour',
'1d': 'Last 1 day',
'15d': 'Last 15 days',
'6h': 'Last 6 hours', '6h': 'Last 6 hours',
'24h': 'Last 24 hours', '24h': 'Last 24 hours',
'7d': 'Last 7 days', '7d': 'Last 7 days',
'30d': 'Last 30 days' '30d': 'Last 30 days'
}, },
openaiTokenStats: {
title: 'OpenAI Token Request Stats',
viewModeTopN: 'TopN',
viewModePagination: 'Pagination',
prevPage: 'Previous',
nextPage: 'Next',
pageInfo: 'Page {page}/{total}',
totalModels: 'Total models: {total}',
failedToLoad: 'Failed to load OpenAI token stats',
empty: 'No OpenAI token stats for the current filters',
table: {
model: 'Model',
requestCount: 'Requests',
avgTokensPerSec: 'Avg Tokens/sec',
avgFirstTokenMs: 'Avg First Token Latency (ms)',
totalOutputTokens: 'Total Output Tokens',
avgDurationMs: 'Avg Duration (ms)',
requestsWithFirstToken: 'Requests With First Token'
}
},
fullscreen: { fullscreen: {
enter: 'Enter Fullscreen' enter: 'Enter Fullscreen'
}, },

View File

@@ -2675,12 +2675,34 @@ export default {
'5m': '近5分钟', '5m': '近5分钟',
'30m': '近30分钟', '30m': '近30分钟',
'1h': '近1小时', '1h': '近1小时',
'1d': '近1天',
'15d': '近15天',
'6h': '近6小时', '6h': '近6小时',
'24h': '近24小时', '24h': '近24小时',
'7d': '近7天', '7d': '近7天',
'30d': '近30天', '30d': '近30天',
custom: '自定义' custom: '自定义'
}, },
openaiTokenStats: {
title: 'OpenAI Token 请求统计',
viewModeTopN: 'TopN',
viewModePagination: '分页',
prevPage: '上一页',
nextPage: '下一页',
pageInfo: '第 {page}/{total} 页',
totalModels: '模型总数:{total}',
failedToLoad: '加载 OpenAI Token 统计失败',
empty: '当前筛选条件下暂无 OpenAI Token 请求统计数据',
table: {
model: '模型',
requestCount: '请求数',
avgTokensPerSec: '平均 Tokens/秒',
avgFirstTokenMs: '平均首 Token 延迟(ms)',
totalOutputTokens: '输出 Token 总数',
avgDurationMs: '平均时长(ms)',
requestsWithFirstToken: '首 Token 样本数'
}
},
customTimeRange: { customTimeRange: {
startTime: '开始时间', startTime: '开始时间',
endTime: '结束时间' endTime: '结束时间'

View File

@@ -84,6 +84,15 @@
/> />
</div> </div>
<!-- Row: OpenAI Token Stats -->
<div v-if="opsEnabled && !(loading && !hasLoadedOnce)" class="grid grid-cols-1 gap-6">
<OpsOpenAITokenStatsCard
:platform-filter="platform"
:group-id-filter="groupId"
:refresh-token="dashboardRefreshToken"
/>
</div>
<!-- Alert Events --> <!-- Alert Events -->
<OpsAlertEventsCard v-if="opsEnabled && !(loading && !hasLoadedOnce)" /> <OpsAlertEventsCard v-if="opsEnabled && !(loading && !hasLoadedOnce)" />
@@ -148,6 +157,7 @@ import OpsLatencyChart from './components/OpsLatencyChart.vue'
import OpsThroughputTrendChart from './components/OpsThroughputTrendChart.vue' import OpsThroughputTrendChart from './components/OpsThroughputTrendChart.vue'
import OpsSwitchRateTrendChart from './components/OpsSwitchRateTrendChart.vue' import OpsSwitchRateTrendChart from './components/OpsSwitchRateTrendChart.vue'
import OpsAlertEventsCard from './components/OpsAlertEventsCard.vue' import OpsAlertEventsCard from './components/OpsAlertEventsCard.vue'
import OpsOpenAITokenStatsCard from './components/OpsOpenAITokenStatsCard.vue'
import OpsRequestDetailsModal, { type OpsRequestDetailsPreset } from './components/OpsRequestDetailsModal.vue' import OpsRequestDetailsModal, { type OpsRequestDetailsPreset } from './components/OpsRequestDetailsModal.vue'
import OpsSettingsDialog from './components/OpsSettingsDialog.vue' import OpsSettingsDialog from './components/OpsSettingsDialog.vue'
import OpsAlertRulesCard from './components/OpsAlertRulesCard.vue' import OpsAlertRulesCard from './components/OpsAlertRulesCard.vue'

View File

@@ -0,0 +1,246 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Select from '@/components/common/Select.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import { opsAPI, type OpsOpenAITokenStatsResponse, type OpsOpenAITokenStatsTimeRange } from '@/api/admin/ops'
import { formatNumber } from '@/utils/format'
interface Props {
platformFilter?: string
groupIdFilter?: number | null
refreshToken: number
}
type ViewMode = 'topn' | 'pagination'
const props = withDefaults(defineProps<Props>(), {
platformFilter: '',
groupIdFilter: null
})
const { t } = useI18n()
const loading = ref(false)
const errorMessage = ref('')
const response = ref<OpsOpenAITokenStatsResponse | null>(null)
const timeRange = ref<OpsOpenAITokenStatsTimeRange>('30d')
const viewMode = ref<ViewMode>('topn')
const topN = ref<number>(20)
const page = ref<number>(1)
const pageSize = ref<number>(20)
const items = computed(() => response.value?.items ?? [])
const total = computed(() => response.value?.total ?? 0)
const totalPages = computed(() => {
if (viewMode.value !== 'pagination') return 1
const size = pageSize.value > 0 ? pageSize.value : 20
return Math.max(1, Math.ceil(total.value / size))
})
const timeRangeOptions = computed(() => [
{ value: '30m', label: t('admin.ops.timeRange.30m') },
{ value: '1h', label: t('admin.ops.timeRange.1h') },
{ value: '1d', label: t('admin.ops.timeRange.1d') },
{ value: '15d', label: t('admin.ops.timeRange.15d') },
{ value: '30d', label: t('admin.ops.timeRange.30d') }
])
const viewModeOptions = computed(() => [
{ value: 'topn', label: t('admin.ops.openaiTokenStats.viewModeTopN') },
{ value: 'pagination', label: t('admin.ops.openaiTokenStats.viewModePagination') }
])
const topNOptions = computed(() => [
{ value: 10, label: 'Top 10' },
{ value: 20, label: 'Top 20' },
{ value: 50, label: 'Top 50' },
{ value: 100, label: 'Top 100' }
])
const pageSizeOptions = computed(() => [
{ value: 10, label: '10' },
{ value: 20, label: '20' },
{ value: 50, label: '50' },
{ value: 100, label: '100' }
])
function formatRate(v?: number | null): string {
if (typeof v !== 'number' || !Number.isFinite(v)) return '-'
return v.toFixed(2)
}
function formatInt(v?: number | null): string {
if (typeof v !== 'number' || !Number.isFinite(v)) return '-'
return formatNumber(Math.round(v))
}
function buildParams() {
const params: Record<string, any> = {
time_range: timeRange.value,
platform: props.platformFilter || undefined,
group_id: typeof props.groupIdFilter === 'number' && props.groupIdFilter > 0 ? props.groupIdFilter : undefined
}
if (viewMode.value === 'topn') {
params.top_n = topN.value
} else {
params.page = page.value
params.page_size = pageSize.value
}
return params
}
async function loadData() {
loading.value = true
errorMessage.value = ''
try {
response.value = await opsAPI.getOpenAITokenStats(buildParams())
// 防御:若 total 变化导致当前页超出最大页,则回退到末页并重新拉取一次。
if (viewMode.value === 'pagination' && page.value > totalPages.value) {
page.value = totalPages.value
response.value = await opsAPI.getOpenAITokenStats(buildParams())
}
} catch (err: any) {
console.error('[OpsOpenAITokenStatsCard] Failed to load data', err)
response.value = null
errorMessage.value = err?.message || t('admin.ops.openaiTokenStats.failedToLoad')
} finally {
loading.value = false
}
}
watch(
() => ({
timeRange: timeRange.value,
viewMode: viewMode.value,
topN: topN.value,
page: page.value,
pageSize: pageSize.value,
platform: props.platformFilter,
groupId: props.groupIdFilter,
refreshToken: props.refreshToken
}),
(next, prev) => {
// 避免“筛选变化 -> 重置页码 -> 触发两次请求”:
// 先只重置页码,等待下一次 watch仅 page 变化)再发起请求。
const filtersChanged = !prev ||
next.timeRange !== prev.timeRange ||
next.viewMode !== prev.viewMode ||
next.pageSize !== prev.pageSize ||
next.platform !== prev.platform ||
next.groupId !== prev.groupId ||
next.refreshToken !== prev.refreshToken
if (next.viewMode === 'pagination' && filtersChanged && next.page !== 1) {
page.value = 1
return
}
void loadData()
},
{ immediate: true }
)
function onPrevPage() {
if (viewMode.value !== 'pagination') return
if (page.value > 1) page.value -= 1
}
function onNextPage() {
if (viewMode.value !== 'pagination') return
if (page.value < totalPages.value) page.value += 1
}
</script>
<template>
<section class="card p-4 md:p-5">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<h3 class="text-sm font-bold text-gray-900 dark:text-white">
{{ t('admin.ops.openaiTokenStats.title') }}
</h3>
<div class="flex flex-wrap items-center gap-2">
<div class="w-36">
<Select v-model="timeRange" :options="timeRangeOptions" />
</div>
<div class="w-36">
<Select v-model="viewMode" :options="viewModeOptions" />
</div>
<div v-if="viewMode === 'topn'" class="w-28">
<Select v-model="topN" :options="topNOptions" />
</div>
<template v-else>
<div class="w-24">
<Select v-model="pageSize" :options="pageSizeOptions" />
</div>
<button
class="btn btn-secondary btn-sm"
:disabled="loading || page <= 1"
@click="onPrevPage"
>
{{ t('admin.ops.openaiTokenStats.prevPage') }}
</button>
<button
class="btn btn-secondary btn-sm"
:disabled="loading || page >= totalPages"
@click="onNextPage"
>
{{ t('admin.ops.openaiTokenStats.nextPage') }}
</button>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.ops.openaiTokenStats.pageInfo', { page, total: totalPages }) }}
</span>
</template>
</div>
</div>
<div v-if="errorMessage" class="mb-4 rounded-lg bg-red-50 px-3 py-2 text-xs text-red-600 dark:bg-red-900/20 dark:text-red-400">
{{ errorMessage }}
</div>
<div v-if="loading" class="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.ops.loadingText') }}
</div>
<EmptyState
v-else-if="items.length === 0"
:title="t('common.noData')"
:description="t('admin.ops.openaiTokenStats.empty')"
/>
<div v-else class="overflow-x-auto">
<table class="min-w-full text-left text-xs md:text-sm">
<thead>
<tr class="border-b border-gray-200 text-gray-500 dark:border-dark-700 dark:text-gray-400">
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.model') }}</th>
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.requestCount') }}</th>
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.avgTokensPerSec') }}</th>
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.avgFirstTokenMs') }}</th>
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.totalOutputTokens') }}</th>
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.avgDurationMs') }}</th>
<th class="px-2 py-2 font-semibold">{{ t('admin.ops.openaiTokenStats.table.requestsWithFirstToken') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in items"
:key="row.model"
class="border-b border-gray-100 text-gray-700 dark:border-dark-800 dark:text-gray-200"
>
<td class="px-2 py-2 font-medium">{{ row.model }}</td>
<td class="px-2 py-2">{{ formatInt(row.request_count) }}</td>
<td class="px-2 py-2">{{ formatRate(row.avg_tokens_per_sec) }}</td>
<td class="px-2 py-2">{{ formatRate(row.avg_first_token_ms) }}</td>
<td class="px-2 py-2">{{ formatInt(row.total_output_tokens) }}</td>
<td class="px-2 py-2">{{ formatInt(row.avg_duration_ms) }}</td>
<td class="px-2 py-2">{{ formatInt(row.requests_with_first_token) }}</td>
</tr>
</tbody>
</table>
<div v-if="viewMode === 'topn'" class="mt-3 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.ops.openaiTokenStats.totalModels', { total }) }}
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,215 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import OpsOpenAITokenStatsCard from '../OpsOpenAITokenStatsCard.vue'
const mockGetOpenAITokenStats = vi.fn()
vi.mock('@/api/admin/ops', () => ({
opsAPI: {
getOpenAITokenStats: (...args: any[]) => mockGetOpenAITokenStats(...args),
},
}))
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-i18n')>()
return {
...actual,
useI18n: () => ({
t: (key: string, params?: Record<string, any>) => {
if (key === 'admin.ops.openaiTokenStats.pageInfo' && params) {
return `${params.page}/${params.total}`
}
return key
},
}),
}
})
const SelectStub = defineComponent({
name: 'SelectControlStub',
props: {
modelValue: {
type: [String, Number],
default: '',
},
},
emits: ['update:modelValue'],
template: '<div class="select-stub" />',
})
const EmptyStateStub = defineComponent({
name: 'EmptyState',
props: {
title: { type: String, default: '' },
description: { type: String, default: '' },
},
template: '<div class="empty-state">{{ title }}|{{ description }}</div>',
})
const sampleResponse = {
time_range: '30d' as const,
start_time: '2026-01-01T00:00:00Z',
end_time: '2026-01-31T00:00:00Z',
platform: 'openai',
group_id: 7,
items: [
{
model: 'gpt-4o-mini',
request_count: 12,
avg_tokens_per_sec: 22.5,
avg_first_token_ms: 123.45,
total_output_tokens: 1234,
avg_duration_ms: 321,
requests_with_first_token: 10,
},
],
total: 40,
page: 1,
page_size: 20,
top_n: null,
}
describe('OpsOpenAITokenStatsCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('默认加载并透传 platform/group 过滤,支持时间窗口切换', async () => {
mockGetOpenAITokenStats.mockResolvedValue(sampleResponse)
const wrapper = mount(OpsOpenAITokenStatsCard, {
props: {
platformFilter: 'openai',
groupIdFilter: 7,
refreshToken: 0,
},
global: {
stubs: {
Select: SelectStub,
EmptyState: EmptyStateStub,
},
},
})
await flushPromises()
expect(mockGetOpenAITokenStats).toHaveBeenCalledWith(
expect.objectContaining({
time_range: '30d',
platform: 'openai',
group_id: 7,
top_n: 20,
})
)
const selects = wrapper.findAllComponents(SelectStub)
await selects[0].vm.$emit('update:modelValue', '1h')
await flushPromises()
expect(mockGetOpenAITokenStats).toHaveBeenCalledWith(
expect.objectContaining({
time_range: '1h',
platform: 'openai',
group_id: 7,
})
)
})
it('支持分页与 TopN 模式切换并按参数请求', async () => {
mockGetOpenAITokenStats.mockImplementation(async (params: Record<string, any>) => ({
...sampleResponse,
time_range: params.time_range ?? '30d',
page: params.page ?? 1,
page_size: params.page_size ?? 20,
top_n: params.top_n ?? null,
total: 40,
}))
const wrapper = mount(OpsOpenAITokenStatsCard, {
props: {
refreshToken: 0,
},
global: {
stubs: {
Select: SelectStub,
EmptyState: EmptyStateStub,
},
},
})
await flushPromises()
let selects = wrapper.findAllComponents(SelectStub)
await selects[1].vm.$emit('update:modelValue', 'pagination')
await flushPromises()
expect(mockGetOpenAITokenStats).toHaveBeenCalledWith(
expect.objectContaining({
page: 1,
page_size: 20,
})
)
const buttons = wrapper.findAll('button')
expect(buttons.length).toBeGreaterThanOrEqual(2)
await buttons[1].trigger('click')
await flushPromises()
expect(mockGetOpenAITokenStats).toHaveBeenCalledWith(
expect.objectContaining({
page: 2,
page_size: 20,
})
)
selects = wrapper.findAllComponents(SelectStub)
await selects[1].vm.$emit('update:modelValue', 'topn')
await flushPromises()
selects = wrapper.findAllComponents(SelectStub)
await selects[2].vm.$emit('update:modelValue', 50)
await flushPromises()
expect(mockGetOpenAITokenStats).toHaveBeenCalledWith(
expect.objectContaining({
top_n: 50,
})
)
})
it('接口返回空数据时显示空态', async () => {
mockGetOpenAITokenStats.mockResolvedValue({
...sampleResponse,
items: [],
total: 0,
})
const wrapper = mount(OpsOpenAITokenStatsCard, {
props: { refreshToken: 0 },
global: {
stubs: {
Select: SelectStub,
EmptyState: EmptyStateStub,
},
},
})
await flushPromises()
expect(wrapper.find('.empty-state').exists()).toBe(true)
})
it('接口异常时显示错误提示', async () => {
mockGetOpenAITokenStats.mockRejectedValue(new Error('加载失败'))
const wrapper = mount(OpsOpenAITokenStatsCard, {
props: { refreshToken: 0 },
global: {
stubs: {
Select: SelectStub,
EmptyState: EmptyStateStub,
},
},
})
await flushPromises()
expect(wrapper.text()).toContain('加载失败')
})
})