Merge branch 'Wei-Shaw:main' into main

This commit is contained in:
xueshiji
2026-07-01 14:00:51 +08:00
committed by GitHub
71 changed files with 3620 additions and 1931 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.139
0.1.141
+1
View File
@@ -136,6 +136,7 @@ var DefaultBedrockModelMapping = map[string]string{
"claude-opus-4-1": "us.anthropic.claude-opus-4-1-20250805-v1:0",
"claude-opus-4-20250514": "us.anthropic.claude-opus-4-20250514-v1:0",
// Claude Sonnet
"claude-sonnet-5": "us.anthropic.claude-sonnet-5-v1",
"claude-sonnet-4-6-thinking": "us.anthropic.claude-sonnet-4-6",
"claude-sonnet-4-6": "us.anthropic.claude-sonnet-4-6",
"claude-sonnet-4-5": "us.anthropic.claude-sonnet-4-5-20250929-v1:0",
@@ -259,6 +259,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
ClaudeOAuthSystemPromptBlocks: settings.ClaudeOAuthSystemPromptBlocks,
EnableAnthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection,
RewriteMessageCacheControl: settings.RewriteMessageCacheControl,
EnableClientDatelineNormalization: settings.EnableClientDatelineNormalization,
AntigravityUserAgentVersion: settings.AntigravityUserAgentVersion,
OpenAICodexUserAgent: settings.OpenAICodexUserAgent,
MinCodexVersion: settings.MinCodexVersion,
@@ -598,6 +599,7 @@ type UpdateSettingsRequest struct {
ClaudeOAuthSystemPromptBlocks *string `json:"claude_oauth_system_prompt_blocks"`
EnableAnthropicCacheTTL1hInjection *bool `json:"enable_anthropic_cache_ttl_1h_injection"`
RewriteMessageCacheControl *bool `json:"rewrite_message_cache_control"`
EnableClientDatelineNormalization *bool `json:"enable_client_dateline_normalization"`
AntigravityUserAgentVersion *string `json:"antigravity_user_agent_version"`
OpenAICodexUserAgent *string `json:"openai_codex_user_agent"`
@@ -1731,6 +1733,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.RewriteMessageCacheControl
}(),
EnableClientDatelineNormalization: func() bool {
if req.EnableClientDatelineNormalization != nil {
return *req.EnableClientDatelineNormalization
}
return previousSettings.EnableClientDatelineNormalization
}(),
AntigravityUserAgentVersion: func() string {
if req.AntigravityUserAgentVersion != nil {
return *req.AntigravityUserAgentVersion
@@ -2143,6 +2151,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
ClaudeOAuthSystemPromptBlocks: updatedSettings.ClaudeOAuthSystemPromptBlocks,
EnableAnthropicCacheTTL1hInjection: updatedSettings.EnableAnthropicCacheTTL1hInjection,
RewriteMessageCacheControl: updatedSettings.RewriteMessageCacheControl,
EnableClientDatelineNormalization: updatedSettings.EnableClientDatelineNormalization,
AntigravityUserAgentVersion: updatedSettings.AntigravityUserAgentVersion,
OpenAICodexUserAgent: updatedSettings.OpenAICodexUserAgent,
MinCodexVersion: updatedSettings.MinCodexVersion,
@@ -2644,6 +2653,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.RewriteMessageCacheControl != after.RewriteMessageCacheControl {
changed = append(changed, "rewrite_message_cache_control")
}
if before.EnableClientDatelineNormalization != after.EnableClientDatelineNormalization {
changed = append(changed, "enable_client_dateline_normalization")
}
if before.AntigravityUserAgentVersion != after.AntigravityUserAgentVersion {
changed = append(changed, "antigravity_user_agent_version")
}
+6 -4
View File
@@ -577,7 +577,7 @@ func AccountSummaryFromService(a *service.Account) *AccountSummary {
}
func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
// 普通用户 DTO:严禁包含管理员字段(例如 account_rate_multiplier、ip_address、account)。
// 普通用户 DTO:严禁包含管理员字段(例如 account_rate_multiplier、account、upstream_model)。
requestType := l.EffectiveRequestType()
stream, openAIWSMode := service.ApplyLegacyRequestFields(requestType, l.Stream, l.OpenAIWSMode)
requestedModel := l.RequestedModel
@@ -594,7 +594,6 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
ServiceTier: l.ServiceTier,
ReasoningEffort: l.ReasoningEffort,
InboundEndpoint: l.InboundEndpoint,
UpstreamEndpoint: l.UpstreamEndpoint,
GroupID: l.GroupID,
SubscriptionID: l.SubscriptionID,
InputTokens: l.InputTokens,
@@ -626,6 +625,7 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
ImageSizeBreakdown: l.ImageSizeBreakdown,
MediaType: l.MediaType,
UserAgent: l.UserAgent,
IPAddress: l.IPAddress,
CacheTTLOverridden: l.CacheTTLOverridden,
BillingMode: l.BillingMode,
CreatedAt: l.CreatedAt,
@@ -637,7 +637,7 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
}
// UsageLogFromService converts a service UsageLog to DTO for regular users.
// It excludes Account details and IP address - users should not see these.
// It excludes admin-only account/upstream internals while keeping user billing and request metadata.
func UsageLogFromService(l *service.UsageLog) *UsageLog {
if l == nil {
return nil
@@ -652,8 +652,10 @@ func UsageLogFromServiceAdmin(l *service.UsageLog) *AdminUsageLog {
if l == nil {
return nil
}
usageLog := usageLogFromServiceUser(l)
usageLog.UpstreamEndpoint = l.UpstreamEndpoint
return &AdminUsageLog{
UsageLog: usageLogFromServiceUser(l),
UsageLog: usageLog,
UpstreamModel: l.UpstreamModel,
ChannelID: l.ChannelID,
ModelMappingChain: l.ModelMappingChain,
@@ -95,8 +95,7 @@ func TestUsageLogFromService_IncludesServiceTierForUserAndAdmin(t *testing.T) {
require.Equal(t, serviceTier, *userDTO.ServiceTier)
require.NotNil(t, userDTO.InboundEndpoint)
require.Equal(t, inboundEndpoint, *userDTO.InboundEndpoint)
require.NotNil(t, userDTO.UpstreamEndpoint)
require.Equal(t, upstreamEndpoint, *userDTO.UpstreamEndpoint)
require.Nil(t, userDTO.UpstreamEndpoint)
require.NotNil(t, adminDTO.ServiceTier)
require.Equal(t, serviceTier, *adminDTO.ServiceTier)
require.NotNil(t, adminDTO.InboundEndpoint)
@@ -133,6 +132,45 @@ func TestUsageLogFromService_UsesRequestedModelAndKeepsUpstreamAdminOnly(t *test
require.Contains(t, string(adminJSON), `"upstream_model":"claude-sonnet-4-20250514"`)
}
func TestUsageLogFromService_KeepsUserBillingAndIPWithoutAdminCostFields(t *testing.T) {
t.Parallel()
ipAddress := "203.0.113.10"
accountRateMultiplier := 1.5
accountStatsCost := 0.21
log := &service.UsageLog{
RequestID: "req_user_visible_billing",
Model: "gpt-5.4",
InputCost: 0.01,
OutputCost: 0.02,
CacheCreationCost: 0.03,
CacheReadCost: 0.04,
TotalCost: 0.10,
ActualCost: 0.08,
RateMultiplier: 0.8,
IPAddress: &ipAddress,
AccountRateMultiplier: &accountRateMultiplier,
AccountStatsCost: &accountStatsCost,
}
userDTO := UsageLogFromService(log)
require.Equal(t, 0.01, userDTO.InputCost)
require.Equal(t, 0.02, userDTO.OutputCost)
require.Equal(t, 0.03, userDTO.CacheCreationCost)
require.Equal(t, 0.04, userDTO.CacheReadCost)
require.Equal(t, 0.10, userDTO.TotalCost)
require.Equal(t, 0.08, userDTO.ActualCost)
require.Equal(t, 0.8, userDTO.RateMultiplier)
require.NotNil(t, userDTO.IPAddress)
require.Equal(t, ipAddress, *userDTO.IPAddress)
userJSON, err := json.Marshal(userDTO)
require.NoError(t, err)
require.NotContains(t, string(userJSON), "account_rate_multiplier")
require.NotContains(t, string(userJSON), "account_stats_cost")
require.NotContains(t, string(userJSON), "account_cost")
}
func TestUsageLogFromService_FallsBackToLegacyModelWhenRequestedModelMissing(t *testing.T) {
t.Parallel()
+1
View File
@@ -186,6 +186,7 @@ type SystemSettings struct {
ClaudeOAuthSystemPromptBlocks string `json:"claude_oauth_system_prompt_blocks"`
EnableAnthropicCacheTTL1hInjection bool `json:"enable_anthropic_cache_ttl_1h_injection"`
RewriteMessageCacheControl bool `json:"rewrite_message_cache_control"`
EnableClientDatelineNormalization bool `json:"enable_client_dateline_normalization"`
AntigravityUserAgentVersion string `json:"antigravity_user_agent_version"`
OpenAICodexUserAgent string `json:"openai_codex_user_agent"`
+3 -1
View File
@@ -485,6 +485,8 @@ type UsageLog struct {
// User-Agent
UserAgent *string `json:"user_agent"`
// IPAddress is visible to the owner of the usage record.
IPAddress *string `json:"ip_address,omitempty"`
// Cache TTL Override 标记
CacheTTLOverridden bool `json:"cache_ttl_overridden"`
@@ -520,7 +522,7 @@ type AdminUsageLog struct {
// AccountStatsCost 自定义定价规则计算的账号统计费用(nil 表示使用默认公式)
AccountStatsCost *float64 `json:"account_stats_cost,omitempty"`
// IPAddress 用户请求 IP(仅管理员可见)
// IPAddress 用户请求 IP
IPAddress *string `json:"ip_address,omitempty"`
// Account 最小账号信息(避免泄露敏感字段)
+257 -161
View File
@@ -17,6 +17,33 @@ import (
"github.com/gin-gonic/gin"
)
type userUsageFilters struct {
Filters usagestats.UsageLogFilters
StartTime time.Time
EndTime time.Time
}
type userModelStat struct {
Model string `json:"model"`
Requests int64 `json:"requests"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheCreationTokens int64 `json:"cache_creation_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens"`
TotalTokens int64 `json:"total_tokens"`
Cost float64 `json:"cost"`
ActualCost float64 `json:"actual_cost"`
}
type userGroupStat struct {
GroupID int64 `json:"group_id"`
GroupName string `json:"group_name"`
Requests int64 `json:"requests"`
TotalTokens int64 `json:"total_tokens"`
Cost float64 `json:"cost"`
ActualCost float64 `json:"actual_cost"`
}
// UsageHandler handles usage-related requests
type UsageHandler struct {
usageService *service.UsageService
@@ -40,41 +67,45 @@ func NewUsageHandler(
}
}
// List handles listing usage records with pagination
// GET /api/v1/usage
func (h *UsageHandler) List(c *gin.Context) {
func (h *UsageHandler) parseUserUsageFilters(c *gin.Context, requireRange bool) (*userUsageFilters, bool) {
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
response.Unauthorized(c, "User not authenticated")
return
return nil, false
}
page, pageSize := response.ParsePagination(c)
var apiKeyID int64
if apiKeyIDStr := c.Query("api_key_id"); apiKeyIDStr != "" {
if apiKeyIDStr := strings.TrimSpace(c.Query("api_key_id")); apiKeyIDStr != "" {
id, err := strconv.ParseInt(apiKeyIDStr, 10, 64)
if err != nil {
response.BadRequest(c, "Invalid api_key_id")
return
return nil, false
}
if h.apiKeyService == nil {
response.InternalError(c, "API key service not available")
return nil, false
}
// [Security Fix] Verify API Key ownership to prevent horizontal privilege escalation
apiKey, err := h.apiKeyService.GetByID(c.Request.Context(), id)
if err != nil {
response.ErrorFrom(c, err)
return
return nil, false
}
if apiKey.UserID != subject.UserID {
response.Forbidden(c, "Not authorized to access this API key's usage records")
return
return nil, false
}
apiKeyID = id
}
// Parse additional filters
model := c.Query("model")
var groupID int64
if groupIDStr := strings.TrimSpace(c.Query("group_id")); groupIDStr != "" {
id, err := strconv.ParseInt(groupIDStr, 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group_id")
return nil, false
}
groupID = id
}
var requestType *int16
var stream *bool
@@ -82,51 +113,119 @@ func (h *UsageHandler) List(c *gin.Context) {
parsed, err := service.ParseUsageRequestType(requestTypeStr)
if err != nil {
response.BadRequest(c, err.Error())
return
return nil, false
}
value := int16(parsed)
requestType = &value
} else if streamStr := c.Query("stream"); streamStr != "" {
} else if streamStr := strings.TrimSpace(c.Query("stream")); streamStr != "" {
val, err := strconv.ParseBool(streamStr)
if err != nil {
response.BadRequest(c, "Invalid stream value, use true or false")
return
return nil, false
}
stream = &val
}
var billingType *int8
if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
if billingTypeStr := strings.TrimSpace(c.Query("billing_type")); billingTypeStr != "" {
val, err := strconv.ParseInt(billingTypeStr, 10, 8)
if err != nil {
response.BadRequest(c, "Invalid billing_type")
return
return nil, false
}
bt := int8(val)
billingType = &bt
}
// Parse date range
var startTime, endTime *time.Time
userTZ := c.Query("timezone") // Get user's timezone from request
if startDateStr := c.Query("start_date"); startDateStr != "" {
billingMode := strings.TrimSpace(c.Query("billing_mode"))
if billingMode != "" && !service.BillingMode(billingMode).IsValid() {
response.BadRequest(c, "Invalid billing_mode")
return nil, false
}
userTZ := c.Query("timezone")
now := timezone.NowInUserLocation(userTZ)
var startTime, endTime time.Time
var startPtr, endPtr *time.Time
startDateStr := strings.TrimSpace(c.Query("start_date"))
endDateStr := strings.TrimSpace(c.Query("end_date"))
if startDateStr != "" {
t, err := timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ)
if err != nil {
response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
return
return nil, false
}
startTime = &t
startTime = t
startPtr = &startTime
}
if endDateStr := c.Query("end_date"); endDateStr != "" {
if endDateStr != "" {
t, err := timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ)
if err != nil {
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
return
return nil, false
}
// Use half-open range [start, end), move to next calendar day start (DST-safe).
t = t.AddDate(0, 0, 1)
endTime = &t
endTime = t.AddDate(0, 0, 1)
endPtr = &endTime
}
if requireRange {
if startPtr == nil {
switch c.DefaultQuery("period", "") {
case "today":
startTime = timezone.StartOfDayInUserLocation(now, userTZ)
case "week":
startTime = now.AddDate(0, 0, -7)
case "month":
startTime = now.AddDate(0, -1, 0)
default:
startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ)
}
startPtr = &startTime
}
if endPtr == nil {
if strings.TrimSpace(c.Query("period")) != "" {
endTime = now
} else {
endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ)
}
endPtr = &endTime
}
}
return &userUsageFilters{
Filters: usagestats.UsageLogFilters{
UserID: subject.UserID,
APIKeyID: apiKeyID,
GroupID: groupID,
Model: strings.TrimSpace(c.Query("model")),
ModelFilterSource: usagestats.ModelSourceRequested,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
BillingMode: billingMode,
StartTime: startPtr,
EndTime: endPtr,
},
StartTime: derefTime(startPtr),
EndTime: derefTime(endPtr),
}, true
}
func derefTime(value *time.Time) time.Time {
if value == nil {
return time.Time{}
}
return *value
}
// List handles listing usage records with pagination
// GET /api/v1/usage
func (h *UsageHandler) List(c *gin.Context) {
page, pageSize := response.ParsePagination(c)
parsed, ok := h.parseUserUsageFilters(c, false)
if !ok {
return
}
params := pagination.PaginationParams{
@@ -135,18 +234,8 @@ func (h *UsageHandler) List(c *gin.Context) {
SortBy: c.DefaultQuery("sort_by", "created_at"),
SortOrder: c.DefaultQuery("sort_order", "desc"),
}
filters := usagestats.UsageLogFilters{
UserID: subject.UserID, // Always filter by current user for security
APIKeyID: apiKeyID,
Model: model,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
StartTime: startTime,
EndTime: endTime,
}
records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters)
records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, parsed.Filters)
if err != nil {
response.ErrorFrom(c, err)
return
@@ -303,122 +392,23 @@ func (h *UsageHandler) GetByID(c *gin.Context) {
// Stats handles getting usage statistics
// GET /api/v1/usage/stats
func (h *UsageHandler) Stats(c *gin.Context) {
subject, ok := middleware2.GetAuthSubjectFromContext(c)
parsed, ok := h.parseUserUsageFilters(c, true)
if !ok {
response.Unauthorized(c, "User not authenticated")
return
}
var apiKeyID int64
if apiKeyIDStr := c.Query("api_key_id"); apiKeyIDStr != "" {
id, err := strconv.ParseInt(apiKeyIDStr, 10, 64)
if err != nil {
response.BadRequest(c, "Invalid api_key_id")
return
}
// [Security Fix] Verify API Key ownership to prevent horizontal privilege escalation
apiKey, err := h.apiKeyService.GetByID(c.Request.Context(), id)
if err != nil {
response.NotFound(c, "API key not found")
return
}
if apiKey.UserID != subject.UserID {
response.Forbidden(c, "Not authorized to access this API key's statistics")
return
}
apiKeyID = id
}
// 获取时间范围参数
userTZ := c.Query("timezone") // Get user's timezone from request
now := timezone.NowInUserLocation(userTZ)
var startTime, endTime time.Time
// 优先使用 start_date 和 end_date 参数
startDateStr := c.Query("start_date")
endDateStr := c.Query("end_date")
if startDateStr != "" && endDateStr != "" {
// 使用自定义日期范围
var err error
startTime, err = timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ)
if err != nil {
response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
return
}
endTime, err = timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ)
if err != nil {
response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
return
}
// 与 SQL 条件 created_at < end 对齐,使用次日 00:00 作为上边界(DST-safe)。
endTime = endTime.AddDate(0, 0, 1)
} else {
// 使用 period 参数
period := c.DefaultQuery("period", "today")
switch period {
case "today":
startTime = timezone.StartOfDayInUserLocation(now, userTZ)
case "week":
startTime = now.AddDate(0, 0, -7)
case "month":
startTime = now.AddDate(0, -1, 0)
default:
startTime = timezone.StartOfDayInUserLocation(now, userTZ)
}
endTime = now
}
var stats *service.UsageStats
var err error
if apiKeyID > 0 {
stats, err = h.usageService.GetStatsByAPIKey(c.Request.Context(), apiKeyID, startTime, endTime)
} else {
stats, err = h.usageService.GetStatsByUser(c.Request.Context(), subject.UserID, startTime, endTime)
}
stats, err := h.usageService.GetStatsWithFilters(c.Request.Context(), parsed.Filters)
if err != nil {
response.ErrorFrom(c, err)
return
}
stats.TotalAccountCost = nil
stats.UpstreamEndpoints = nil
stats.EndpointPaths = nil
response.Success(c, stats)
}
// parseUserTimeRange parses start_date, end_date query parameters for user dashboard
// Uses user's timezone if provided, otherwise falls back to server timezone
func parseUserTimeRange(c *gin.Context) (time.Time, time.Time) {
userTZ := c.Query("timezone") // Get user's timezone from request
now := timezone.NowInUserLocation(userTZ)
startDate := c.Query("start_date")
endDate := c.Query("end_date")
var startTime, endTime time.Time
if startDate != "" {
if t, err := timezone.ParseInUserLocation("2006-01-02", startDate, userTZ); err == nil {
startTime = t
} else {
startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ)
}
} else {
startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ)
}
if endDate != "" {
if t, err := timezone.ParseInUserLocation("2006-01-02", endDate, userTZ); err == nil {
endTime = t.Add(24 * time.Hour) // Include the end date
} else {
endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ)
}
} else {
endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ)
}
return startTime, endTime
}
const (
defaultAPIKeyDailyUsageDays = 30
maxAPIKeyDailyUsageDays = 90
@@ -463,16 +453,13 @@ func (h *UsageHandler) DashboardStats(c *gin.Context) {
// DashboardTrend handles getting user usage trend data
// GET /api/v1/usage/dashboard/trend
func (h *UsageHandler) DashboardTrend(c *gin.Context) {
subject, ok := middleware2.GetAuthSubjectFromContext(c)
parsed, ok := h.parseUserUsageFilters(c, true)
if !ok {
response.Unauthorized(c, "User not authenticated")
return
}
startTime, endTime := parseUserTimeRange(c)
granularity := c.DefaultQuery("granularity", "day")
trend, err := h.usageService.GetUserUsageTrendByUserID(c.Request.Context(), subject.UserID, startTime, endTime, granularity)
trend, err := h.usageService.GetUsageTrendWithFilters(c.Request.Context(), parsed.StartTime, parsed.EndTime, granularity, parsed.Filters)
if err != nil {
response.ErrorFrom(c, err)
return
@@ -480,8 +467,8 @@ func (h *UsageHandler) DashboardTrend(c *gin.Context) {
response.Success(c, gin.H{
"trend": trend,
"start_date": startTime.Format("2006-01-02"),
"end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
"start_date": parsed.StartTime.Format("2006-01-02"),
"end_date": parsed.EndTime.Add(-24 * time.Hour).Format("2006-01-02"),
"granularity": granularity,
})
}
@@ -489,27 +476,136 @@ func (h *UsageHandler) DashboardTrend(c *gin.Context) {
// DashboardModels handles getting user model usage statistics
// GET /api/v1/usage/dashboard/models
func (h *UsageHandler) DashboardModels(c *gin.Context) {
subject, ok := middleware2.GetAuthSubjectFromContext(c)
parsed, ok := h.parseUserUsageFilters(c, true)
if !ok {
response.Unauthorized(c, "User not authenticated")
return
}
startTime, endTime := parseUserTimeRange(c)
modelSource := strings.TrimSpace(c.Query("model_source"))
if modelSource != "" && modelSource != usagestats.ModelSourceRequested {
response.BadRequest(c, "Invalid model_source, user usage only supports requested")
return
}
stats, err := h.usageService.GetUserModelStats(c.Request.Context(), subject.UserID, startTime, endTime)
stats, err := h.usageService.GetModelStatsWithFiltersBySource(c.Request.Context(), parsed.StartTime, parsed.EndTime, parsed.Filters, usagestats.ModelSourceRequested)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{
"models": stats,
"start_date": startTime.Format("2006-01-02"),
"end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
"models": userModelStatsFromUsageStats(stats),
"start_date": parsed.StartTime.Format("2006-01-02"),
"end_date": parsed.EndTime.Add(-24 * time.Hour).Format("2006-01-02"),
})
}
// DashboardSnapshotV2 returns usage-page chart data scoped to the current user.
// GET /api/v1/usage/dashboard/snapshot-v2
func (h *UsageHandler) DashboardSnapshotV2(c *gin.Context) {
parsed, ok := h.parseUserUsageFilters(c, true)
if !ok {
return
}
granularity := strings.TrimSpace(c.DefaultQuery("granularity", "day"))
if granularity != "hour" {
granularity = "day"
}
includeTrend, ok := parseBoolQueryWithDefault(c, "include_trend", true)
if !ok {
return
}
includeModels, ok := parseBoolQueryWithDefault(c, "include_model_stats", true)
if !ok {
return
}
includeGroups, ok := parseBoolQueryWithDefault(c, "include_group_stats", false)
if !ok {
return
}
resp := gin.H{
"generated_at": time.Now().UTC().Format(time.RFC3339),
"start_date": parsed.StartTime.Format("2006-01-02"),
"end_date": parsed.EndTime.Add(-24 * time.Hour).Format("2006-01-02"),
"granularity": granularity,
}
if includeTrend {
trend, err := h.usageService.GetUsageTrendWithFilters(c.Request.Context(), parsed.StartTime, parsed.EndTime, granularity, parsed.Filters)
if err != nil {
response.ErrorFrom(c, err)
return
}
resp["trend"] = trend
}
if includeModels {
models, err := h.usageService.GetModelStatsWithFiltersBySource(c.Request.Context(), parsed.StartTime, parsed.EndTime, parsed.Filters, usagestats.ModelSourceRequested)
if err != nil {
response.ErrorFrom(c, err)
return
}
resp["models"] = userModelStatsFromUsageStats(models)
}
if includeGroups {
groups, err := h.usageService.GetGroupStatsWithFilters(c.Request.Context(), parsed.StartTime, parsed.EndTime, parsed.Filters)
if err != nil {
response.ErrorFrom(c, err)
return
}
resp["groups"] = userGroupStatsFromUsageStats(groups)
}
response.Success(c, resp)
}
func userModelStatsFromUsageStats(stats []usagestats.ModelStat) []userModelStat {
out := make([]userModelStat, 0, len(stats))
for _, stat := range stats {
out = append(out, userModelStat{
Model: stat.Model,
Requests: stat.Requests,
InputTokens: stat.InputTokens,
OutputTokens: stat.OutputTokens,
CacheCreationTokens: stat.CacheCreationTokens,
CacheReadTokens: stat.CacheReadTokens,
TotalTokens: stat.TotalTokens,
Cost: stat.Cost,
ActualCost: stat.ActualCost,
})
}
return out
}
func userGroupStatsFromUsageStats(stats []usagestats.GroupStat) []userGroupStat {
out := make([]userGroupStat, 0, len(stats))
for _, stat := range stats {
out = append(out, userGroupStat{
GroupID: stat.GroupID,
GroupName: stat.GroupName,
Requests: stat.Requests,
TotalTokens: stat.TotalTokens,
Cost: stat.Cost,
ActualCost: stat.ActualCost,
})
}
return out
}
func parseBoolQueryWithDefault(c *gin.Context, key string, fallback bool) (bool, bool) {
raw := c.Query(key)
if strings.TrimSpace(raw) == "" {
return fallback, true
}
parsed, err := strconv.ParseBool(raw)
if err != nil {
response.BadRequest(c, "Invalid "+key+" value, use true or false")
return false, false
}
return parsed, true
}
// BatchAPIKeysUsageRequest represents the request for batch API keys usage
type BatchAPIKeysUsageRequest struct {
APIKeyIDs []int64 `json:"api_key_ids" binding:"required"`
@@ -5,6 +5,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
@@ -16,21 +17,67 @@ import (
type userUsageRepoCapture struct {
service.UsageLogRepository
listParams pagination.PaginationParams
listFilters usagestats.UsageLogFilters
listParams pagination.PaginationParams
listFilters usagestats.UsageLogFilters
statsFilters usagestats.UsageLogFilters
trendFilters usagestats.UsageLogFilters
groupFilters usagestats.UsageLogFilters
listRows []service.UsageLog
stats *usagestats.UsageStats
modelStats []usagestats.ModelStat
groupStats []usagestats.GroupStat
}
func (s *userUsageRepoCapture) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]service.UsageLog, *pagination.PaginationResult, error) {
s.listParams = params
s.listFilters = filters
return []service.UsageLog{}, &pagination.PaginationResult{
Total: 0,
return s.listRows, &pagination.PaginationResult{
Total: int64(len(s.listRows)),
Page: params.Page,
PageSize: params.PageSize,
Pages: 0,
}, nil
}
func (s *userUsageRepoCapture) GetStatsWithFilters(ctx context.Context, filters usagestats.UsageLogFilters) (*usagestats.UsageStats, error) {
s.statsFilters = filters
if s.stats != nil {
return s.stats, nil
}
return &usagestats.UsageStats{}, nil
}
func (s *userUsageRepoCapture) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]usagestats.TrendDataPoint, error) {
s.trendFilters = usagestats.UsageLogFilters{
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
Model: model,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
}
return []usagestats.TrendDataPoint{}, nil
}
func (s *userUsageRepoCapture) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.ModelStat, error) {
return s.modelStats, nil
}
func (s *userUsageRepoCapture) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) ([]usagestats.GroupStat, error) {
s.groupFilters = usagestats.UsageLogFilters{
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
}
return s.groupStats, nil
}
func newUserUsageRequestTypeTestRouter(repo *userUsageRepoCapture) *gin.Engine {
gin.SetMode(gin.TestMode)
usageSvc := service.NewUsageService(repo, nil, nil, nil)
@@ -41,6 +88,9 @@ func newUserUsageRequestTypeTestRouter(repo *userUsageRepoCapture) *gin.Engine {
c.Next()
})
router.GET("/usage", handler.List)
router.GET("/usage/stats", handler.Stats)
router.GET("/usage/dashboard/models", handler.DashboardModels)
router.GET("/usage/dashboard/snapshot-v2", handler.DashboardSnapshotV2)
return router
}
@@ -80,3 +130,197 @@ func TestUserUsageListInvalidStream(t *testing.T) {
require.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestUserUsageListAdvancedFilters(t *testing.T) {
repo := &userUsageRepoCapture{}
router := newUserUsageRequestTypeTestRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/usage?group_id=7&model=gpt-5&billing_type=1&billing_mode=image&start_date=2026-03-01&end_date=2026-03-02", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, int64(42), repo.listFilters.UserID)
require.Equal(t, int64(7), repo.listFilters.GroupID)
require.Equal(t, "gpt-5", repo.listFilters.Model)
require.Equal(t, usagestats.ModelSourceRequested, repo.listFilters.ModelFilterSource)
require.NotNil(t, repo.listFilters.BillingType)
require.Equal(t, int8(1), *repo.listFilters.BillingType)
require.Equal(t, "image", repo.listFilters.BillingMode)
require.NotNil(t, repo.listFilters.StartTime)
require.NotNil(t, repo.listFilters.EndTime)
}
func TestUserUsageListInvalidBillingMode(t *testing.T) {
repo := &userUsageRepoCapture{}
router := newUserUsageRequestTypeTestRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/usage?billing_mode=bad", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestUserUsageListKeepsUserBillingAndIPWithoutAdminCostFields(t *testing.T) {
ipAddress := "203.0.113.10"
upstreamModel := "upstream-private-model"
billingTier := "internal-tier"
channelID := int64(99)
accountRateMultiplier := 1.7
accountStatsCost := 0.12
repo := &userUsageRepoCapture{
listRows: []service.UsageLog{{
ID: 1,
UserID: 42,
APIKeyID: 7,
AccountID: 5,
RequestID: "req_user_billing",
Model: "gpt-5",
InputCost: 0.01,
OutputCost: 0.02,
CacheCreationCost: 0.03,
CacheReadCost: 0.04,
TotalCost: 0.10,
ActualCost: 0.08,
RateMultiplier: 0.8,
IPAddress: &ipAddress,
UpstreamModel: &upstreamModel,
BillingTier: &billingTier,
ChannelID: &channelID,
AccountRateMultiplier: &accountRateMultiplier,
AccountStatsCost: &accountStatsCost,
}},
}
router := newUserUsageRequestTypeTestRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/usage", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
body := rec.Body.String()
require.Contains(t, body, `"input_cost":0.01`)
require.Contains(t, body, `"output_cost":0.02`)
require.Contains(t, body, `"cache_creation_cost":0.03`)
require.Contains(t, body, `"cache_read_cost":0.04`)
require.Contains(t, body, `"total_cost":0.1`)
require.Contains(t, body, `"actual_cost":0.08`)
require.Contains(t, body, `"rate_multiplier":0.8`)
require.Contains(t, body, `"ip_address":"203.0.113.10"`)
require.NotContains(t, body, "upstream_endpoint")
require.NotContains(t, body, "account_rate_multiplier")
require.NotContains(t, body, "account_stats_cost")
require.NotContains(t, body, "upstream_model")
require.NotContains(t, body, "billing_tier")
require.NotContains(t, body, "channel_id")
require.NotContains(t, body, `"account":`)
}
func TestUserUsageStatsUsesScopedFilters(t *testing.T) {
accountCost := 0.12
repo := &userUsageRepoCapture{
stats: &usagestats.UsageStats{
TotalCost: 0.10,
TotalActualCost: 0.08,
TotalAccountCost: &accountCost,
UpstreamEndpoints: []usagestats.EndpointStat{{
Endpoint: "/v1/responses",
}},
EndpointPaths: []usagestats.EndpointStat{{
Endpoint: "/v1/chat/completions -> /v1/responses",
}},
},
}
router := newUserUsageRequestTypeTestRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/usage/stats?group_id=9&request_type=sync&billing_mode=token&start_date=2026-03-01&end_date=2026-03-02", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, int64(42), repo.statsFilters.UserID)
require.Equal(t, int64(9), repo.statsFilters.GroupID)
require.Equal(t, usagestats.ModelSourceRequested, repo.statsFilters.ModelFilterSource)
require.NotNil(t, repo.statsFilters.RequestType)
require.Equal(t, int16(service.RequestTypeSync), *repo.statsFilters.RequestType)
require.Equal(t, "token", repo.statsFilters.BillingMode)
require.Contains(t, rec.Body.String(), `"total_cost":0.1`)
require.Contains(t, rec.Body.String(), `"total_actual_cost":0.08`)
require.NotContains(t, rec.Body.String(), "total_account_cost")
require.NotContains(t, rec.Body.String(), "upstream_endpoints")
require.NotContains(t, rec.Body.String(), "endpoint_paths")
}
func TestUserUsageDashboardModelsOmitsAccountCost(t *testing.T) {
repo := &userUsageRepoCapture{
modelStats: []usagestats.ModelStat{{
Model: "gpt-5",
Requests: 2,
TotalTokens: 30,
Cost: 0.10,
ActualCost: 0.08,
AccountCost: 0.07,
}},
}
router := newUserUsageRequestTypeTestRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/usage/dashboard/models?start_date=2026-03-01&end_date=2026-03-02", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
body := rec.Body.String()
require.Contains(t, body, `"cost":0.1`)
require.Contains(t, body, `"actual_cost":0.08`)
require.NotContains(t, body, "account_cost")
}
func TestUserUsageDashboardModelsRejectsAdminModelSources(t *testing.T) {
repo := &userUsageRepoCapture{}
router := newUserUsageRequestTypeTestRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/usage/dashboard/models?model_source=upstream&start_date=2026-03-01&end_date=2026-03-02", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestUserUsageSnapshotUsesScopedFilters(t *testing.T) {
repo := &userUsageRepoCapture{
modelStats: []usagestats.ModelStat{{Model: "gpt-5", AccountCost: 0.07}},
groupStats: []usagestats.GroupStat{{GroupID: 1, GroupName: "default", AccountCost: 0.06}},
}
router := newUserUsageRequestTypeTestRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/usage/dashboard/snapshot-v2?include_trend=true&include_model_stats=true&include_group_stats=true&group_id=11&request_type=stream&start_date=2026-03-01&end_date=2026-03-02", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, int64(42), repo.trendFilters.UserID)
require.Equal(t, int64(11), repo.trendFilters.GroupID)
require.NotNil(t, repo.trendFilters.RequestType)
require.Equal(t, int16(service.RequestTypeStream), *repo.trendFilters.RequestType)
require.Equal(t, int64(42), repo.groupFilters.UserID)
require.Equal(t, int64(11), repo.groupFilters.GroupID)
require.NotContains(t, rec.Body.String(), "account_cost")
}
func TestUserUsageSnapshotRejectsInvalidIncludeFlags(t *testing.T) {
repo := &userUsageRepoCapture{}
router := newUserUsageRequestTypeTestRouter(repo)
for _, query := range []string{
"include_trend=bad",
"include_model_stats=bad",
"include_group_stats=bad",
} {
req := httptest.NewRequest(http.MethodGet, "/usage/dashboard/snapshot-v2?start_date=2026-03-01&end_date=2026-03-02&"+query, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code, query)
}
}
@@ -0,0 +1,288 @@
// Package anthropicfp provides pure helpers for suppressing client-side
// fingerprints that would otherwise be visible to upstream Anthropic when a
// forwarding gateway sits between the client and api.anthropic.com.
//
// Currently exposes NormalizeDateline: it rewrites the "Today's date is
// YYYY-MM-DD." sentence inside a request body back to a canonical ASCII form,
// erasing three bits of steganographic signal (four apostrophe code points and
// a date-separator variant) that some clients embed in that sentence when
// they detect a non-official base URL.
package anthropicfp
import (
"fmt"
"regexp"
"sort"
"strings"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// datelineRegexes matches the fingerprinted sentence with any of the four
// apostrophe code points seen in the wild and either separator. Two regexes
// are used because Go's RE2-based regexp package does not support
// backreferences: matching `-` and `/` in two passes keeps the two separators
// inside YYYY?MM?DD forced to agree, so mixed-separator strings like
// "Today's date is 2026-07/01." never match. This is what filters out
// user-authored prose like "Today is foo." or "His date is 2026-06-30." from
// being touched.
var (
datelineRegexHyphen = regexp.MustCompile(`Today(['’ʼʹ])s date is (\d{4})-(\d{2})-(\d{2})\.`)
datelineRegexSlash = regexp.MustCompile(`Today(['’ʼʹ])s date is (\d{4})/(\d{2})/(\d{2})\.`)
)
// systemReminderRegex matches a <system-reminder> block. The dateline lives in
// this block once the conversation has advanced past the first turn (system
// prompt caching hides the top-level system block for subsequent turns), so
// the messages[].content[] scan is confined to what lives inside these tags.
var systemReminderRegex = regexp.MustCompile(`(?s)<system-reminder>.*?</system-reminder>`)
// DatelineHit records what a single rewrite normalized, for observability.
type DatelineHit struct {
// ApostropheVariant is one of "ascii" (U+0027), "u2019", "u02bc", "u02b9".
ApostropheVariant string
// DateSeparator is either "-" or "/" as seen before normalization.
DateSeparator string
}
// canonicalize returns the canonical form of a matched dateline sentence.
// The output always uses ASCII apostrophe and hyphen separators.
func canonicalize(year, month, day string) string {
return fmt.Sprintf("Today's date is %s-%s-%s.", year, month, day)
}
func apostropheVariant(r rune) string {
switch r {
case '’':
return "u2019"
case 'ʼ':
return "u02bc"
case 'ʹ':
return "u02b9"
default:
return "ascii"
}
}
type datelineMatch struct {
start, end int
apoRune rune
sep string
year, month, day string
}
func collectMatches(text string, re *regexp.Regexp, sep string) []datelineMatch {
locs := re.FindAllStringSubmatchIndex(text, -1)
if len(locs) == 0 {
return nil
}
out := make([]datelineMatch, 0, len(locs))
for _, m := range locs {
var apoRune rune
for _, r := range text[m[2]:m[3]] {
apoRune = r
break
}
out = append(out, datelineMatch{
start: m[0],
end: m[1],
apoRune: apoRune,
sep: sep,
year: text[m[4]:m[5]],
month: text[m[6]:m[7]],
day: text[m[8]:m[9]],
})
}
return out
}
// NormalizeText replaces every fingerprinted dateline sentence in text with
// its canonical form. It returns the possibly-rewritten text and the list of
// hits observed. When no match is found the original string is returned
// verbatim (byte-identical), and the returned hit slice is nil.
func NormalizeText(text string) (string, []DatelineHit) {
if !strings.Contains(text, "date is ") {
return text, nil
}
matches := collectMatches(text, datelineRegexHyphen, "-")
matches = append(matches, collectMatches(text, datelineRegexSlash, "/")...)
if len(matches) == 0 {
return text, nil
}
sort.Slice(matches, func(i, j int) bool { return matches[i].start < matches[j].start })
var b strings.Builder
b.Grow(len(text))
prev := 0
hits := make([]DatelineHit, 0, len(matches))
changed := false
for _, m := range matches {
full := text[m.start:m.end]
canonical := canonicalize(m.year, m.month, m.day)
if canonical == full {
// Already canonical: no rewrite, no hit.
continue
}
_, _ = b.WriteString(text[prev:m.start])
_, _ = b.WriteString(canonical)
prev = m.end
changed = true
hits = append(hits, DatelineHit{
ApostropheVariant: apostropheVariant(m.apoRune),
DateSeparator: m.sep,
})
}
if !changed {
return text, nil
}
_, _ = b.WriteString(text[prev:])
return b.String(), hits
}
// normalizeSystemReminderScopedText scans only the <system-reminder> blocks
// inside text and normalizes datelines inside them. Text outside the blocks is
// preserved byte-for-byte, so user prose, tool_result content, code blocks,
// or shell commands that happen to contain an apostrophe or a slash date are
// never touched.
func normalizeSystemReminderScopedText(text string) (string, []DatelineHit) {
if !strings.Contains(text, "<system-reminder>") {
return text, nil
}
locs := systemReminderRegex.FindAllStringIndex(text, -1)
if len(locs) == 0 {
return text, nil
}
var b strings.Builder
b.Grow(len(text))
prev := 0
var hits []DatelineHit
changed := false
for _, loc := range locs {
_, _ = b.WriteString(text[prev:loc[0]])
block := text[loc[0]:loc[1]]
normalized, blockHits := NormalizeText(block)
if normalized != block {
changed = true
}
_, _ = b.WriteString(normalized)
hits = append(hits, blockHits...)
prev = loc[1]
}
if !changed {
return text, nil
}
_, _ = b.WriteString(text[prev:])
return b.String(), hits
}
// NormalizeDateline scans an Anthropic /v1/messages request body and rewrites
// every fingerprinted dateline sentence back to its canonical ASCII form.
//
// Scope (mirroring where genuine clients place the sentence):
// - `system` string, or `.text` field of each text-typed block in `system`.
// - Text bodies inside `messages[i].content` — but ONLY the substrings that
// appear inside `<system-reminder>...</system-reminder>` tags. Free user
// prose, tool_use.input, tool_result.content, and other block types are
// never scanned, guaranteeing that legitimate text like a code block, a
// shell command, or a chat message that mentions today's date is never
// accidentally rewritten.
//
// The function is a pure transform: it never modifies the input slice, and if
// no rewrite is needed it returns the original slice (identity), a nil hit
// slice, and changed=false.
func NormalizeDateline(body []byte) ([]byte, []DatelineHit, bool) {
if len(body) == 0 {
return body, nil, false
}
out := body
var hits []DatelineHit
changed := false
sys := gjson.GetBytes(out, "system")
if sys.Exists() {
switch {
case sys.Type == gjson.String:
normalized, sysHits := NormalizeText(sys.String())
if normalized != sys.String() {
if next, err := sjson.SetBytes(out, "system", normalized); err == nil {
out = next
changed = true
hits = append(hits, sysHits...)
}
}
case sys.IsArray():
idx := 0
sys.ForEach(func(_, item gjson.Result) bool {
if item.Get("type").String() == "text" {
t := item.Get("text")
if t.Exists() && t.Type == gjson.String {
normalized, textHits := NormalizeText(t.String())
if normalized != t.String() {
path := fmt.Sprintf("system.%d.text", idx)
if next, err := sjson.SetBytes(out, path, normalized); err == nil {
out = next
changed = true
hits = append(hits, textHits...)
}
}
}
}
idx++
return true
})
}
}
messages := gjson.GetBytes(out, "messages")
if messages.IsArray() {
msgIdx := -1
messages.ForEach(func(_, msg gjson.Result) bool {
msgIdx++
content := msg.Get("content")
if !content.Exists() {
return true
}
switch {
case content.Type == gjson.String:
normalized, contentHits := normalizeSystemReminderScopedText(content.String())
if normalized != content.String() {
path := fmt.Sprintf("messages.%d.content", msgIdx)
if next, err := sjson.SetBytes(out, path, normalized); err == nil {
out = next
changed = true
hits = append(hits, contentHits...)
}
}
case content.IsArray():
contentIdx := -1
content.ForEach(func(_, block gjson.Result) bool {
contentIdx++
if block.Get("type").String() != "text" {
return true
}
t := block.Get("text")
if !t.Exists() || t.Type != gjson.String {
return true
}
normalized, textHits := normalizeSystemReminderScopedText(t.String())
if normalized != t.String() {
path := fmt.Sprintf("messages.%d.content.%d.text", msgIdx, contentIdx)
if next, err := sjson.SetBytes(out, path, normalized); err == nil {
out = next
changed = true
hits = append(hits, textHits...)
}
}
return true
})
}
return true
})
}
if !changed {
return body, nil, false
}
return out, hits, true
}
@@ -0,0 +1,248 @@
package anthropicfp
import (
"bytes"
"strings"
"testing"
)
func TestNormalizeText_ASCIIHyphenIsIdentity(t *testing.T) {
in := "Today's date is 2026-07-01."
out, hits := NormalizeText(in)
if out != in {
t.Fatalf("canonical form should be returned identity, got %q", out)
}
if len(hits) != 0 {
t.Fatalf("no hits expected on canonical input, got %d", len(hits))
}
}
func TestNormalizeText_SlashSeparatorASCIIApostrophe(t *testing.T) {
in := "Today's date is 2026/07/01."
out, hits := NormalizeText(in)
want := "Today's date is 2026-07-01."
if out != want {
t.Fatalf("want %q, got %q", want, out)
}
if len(hits) != 1 || hits[0].ApostropheVariant != "ascii" || hits[0].DateSeparator != "/" {
t.Fatalf("unexpected hit: %+v", hits)
}
}
func TestNormalizeText_U2019Apostrophe(t *testing.T) {
in := "Today’s date is 2026-07-01."
out, hits := NormalizeText(in)
want := "Today's date is 2026-07-01."
if out != want {
t.Fatalf("want %q, got %q", want, out)
}
if len(hits) != 1 || hits[0].ApostropheVariant != "u2019" || hits[0].DateSeparator != "-" {
t.Fatalf("unexpected hit: %+v", hits)
}
}
func TestNormalizeText_U02BCApostropheWithSlash(t *testing.T) {
in := "Todayʼs date is 2026/07/01."
out, hits := NormalizeText(in)
want := "Today's date is 2026-07-01."
if out != want {
t.Fatalf("want %q, got %q", want, out)
}
if len(hits) != 1 || hits[0].ApostropheVariant != "u02bc" || hits[0].DateSeparator != "/" {
t.Fatalf("unexpected hit: %+v", hits)
}
}
func TestNormalizeText_U02B9Apostrophe(t *testing.T) {
in := "Todayʹs date is 2026/07/01."
out, hits := NormalizeText(in)
want := "Today's date is 2026-07-01."
if out != want {
t.Fatalf("want %q, got %q", want, out)
}
if len(hits) != 1 || hits[0].ApostropheVariant != "u02b9" || hits[0].DateSeparator != "/" {
t.Fatalf("unexpected hit: %+v", hits)
}
}
func TestNormalizeText_MixedSeparatorsNoMatch(t *testing.T) {
// backreference \3 forces the two separators to agree
in := "Today's date is 2026-07/01."
out, hits := NormalizeText(in)
if out != in {
t.Fatalf("mixed-separator input should not be matched, got %q", out)
}
if len(hits) != 0 {
t.Fatalf("expected no hits, got %d", len(hits))
}
}
func TestNormalizeText_NegativeLookalike(t *testing.T) {
cases := []string{
"Today is a great day.",
"His date is 2026-07-01.",
"Yesterday's date was 2026-06-30.",
"'s date is 2026-07-01.",
}
for _, c := range cases {
out, hits := NormalizeText(c)
if out != c {
t.Fatalf("input %q should not be modified, got %q", c, out)
}
if len(hits) != 0 {
t.Fatalf("input %q should produce no hits", c)
}
}
}
func TestNormalizeText_Idempotent(t *testing.T) {
in := "Today’s date is 2026/07/01."
out1, _ := NormalizeText(in)
out2, hits2 := NormalizeText(out1)
if out1 != out2 {
t.Fatalf("normalization not idempotent: %q vs %q", out1, out2)
}
if len(hits2) != 0 {
t.Fatalf("second pass should produce no hits, got %d", len(hits2))
}
}
func TestNormalizeText_MultipleOccurrences(t *testing.T) {
in := "First line.\nToday’s date is 2026/07/01.\nMore text.\nTodayʼs date is 2026-07-01.\nEnd."
out, hits := NormalizeText(in)
if strings.Count(out, "Today's date is") != 2 {
t.Fatalf("expected two canonicalized sentences, got: %q", out)
}
if strings.Contains(out, "’") || strings.Contains(out, "ʼ") || strings.Contains(out, "2026/07/01") {
t.Fatalf("fingerprint characters must be gone, got: %q", out)
}
if len(hits) != 2 {
t.Fatalf("expected 2 hits, got %d", len(hits))
}
}
func TestNormalizeDateline_SystemString(t *testing.T) {
body := []byte(`{"system":"You are helpful.\nToday’s date is 2026/07/01.\nBe brief.","messages":[]}`)
out, hits, changed := NormalizeDateline(body)
if !changed {
t.Fatalf("expected changed=true")
}
if len(hits) != 1 {
t.Fatalf("expected 1 hit, got %d", len(hits))
}
if !bytes.Contains(out, []byte("Today's date is 2026-07-01.")) {
t.Fatalf("output missing canonical dateline: %s", string(out))
}
if bytes.Contains(out, []byte("2026/07/01")) {
t.Fatalf("output should not contain slash date: %s", string(out))
}
}
func TestNormalizeDateline_SystemBlocksArray(t *testing.T) {
body := []byte(`{"system":[{"type":"text","text":"You are helpful."},{"type":"text","text":"Todayʼs date is 2026/07/01."}],"messages":[]}`)
out, hits, changed := NormalizeDateline(body)
if !changed {
t.Fatalf("expected changed=true")
}
if len(hits) != 1 || hits[0].ApostropheVariant != "u02bc" {
t.Fatalf("unexpected hits: %+v", hits)
}
if !bytes.Contains(out, []byte("Today's date is 2026-07-01.")) {
t.Fatalf("output missing canonical dateline: %s", string(out))
}
}
func TestNormalizeDateline_MessagesContentStringInSystemReminder(t *testing.T) {
body := []byte(`{"messages":[{"role":"user","content":"<system-reminder>\n# currentDate\nToday’s date is 2026/07/01.\n</system-reminder>\nHello, please help."}]}`)
out, hits, changed := NormalizeDateline(body)
if !changed {
t.Fatalf("expected changed=true")
}
if len(hits) != 1 {
t.Fatalf("expected 1 hit, got %d", len(hits))
}
if !bytes.Contains(out, []byte("Today's date is 2026-07-01.")) {
t.Fatalf("canonical dateline missing: %s", string(out))
}
}
func TestNormalizeDateline_MessagesContentBlocksInSystemReminder(t *testing.T) {
body := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"<system-reminder>\nToday’s date is 2026/07/01.\n</system-reminder>"},{"type":"text","text":"do X"}]}]}`)
out, hits, changed := NormalizeDateline(body)
if !changed {
t.Fatalf("expected changed=true")
}
if len(hits) != 1 {
t.Fatalf("expected 1 hit, got %d", len(hits))
}
if !bytes.Contains(out, []byte("Today's date is 2026-07-01.")) {
t.Fatalf("canonical dateline missing: %s", string(out))
}
}
func TestNormalizeDateline_LeavesOutOfScopeUntouched(t *testing.T) {
// User prose outside <system-reminder> that mentions today's date must
// not be modified. tool_use.input / tool_result.content are never scanned.
body := []byte(`{"messages":[` +
`{"role":"user","content":"Today’s date is 2026/07/01. Please help."},` +
`{"role":"assistant","content":[{"type":"tool_use","id":"x","name":"y","input":{"note":"Today’s date is 2026/07/01."}}]},` +
`{"role":"user","content":[{"type":"tool_result","tool_use_id":"x","content":"log: Today’s date is 2026/07/01."}]}` +
`]}`)
out, hits, changed := NormalizeDateline(body)
if changed {
t.Fatalf("expected changed=false, hits=%v out=%s", hits, string(out))
}
if !bytes.Equal(out, body) {
t.Fatalf("output should equal input byte-for-byte")
}
}
func TestNormalizeDateline_Idempotent(t *testing.T) {
body := []byte(`{"messages":[{"role":"user","content":"<system-reminder>\nToday’s date is 2026/07/01.\n</system-reminder>"}]}`)
first, _, changed1 := NormalizeDateline(body)
if !changed1 {
t.Fatalf("expected first pass to change body")
}
second, _, changed2 := NormalizeDateline(first)
if changed2 {
t.Fatalf("second pass should not report changes")
}
if !bytes.Equal(first, second) {
t.Fatalf("second pass diverged: %s vs %s", string(first), string(second))
}
}
func TestNormalizeDateline_EmptyBody(t *testing.T) {
out, hits, changed := NormalizeDateline(nil)
if changed || out != nil || hits != nil {
t.Fatalf("empty body should be no-op")
}
}
func TestNormalizeDateline_NoDateline(t *testing.T) {
body := []byte(`{"messages":[{"role":"user","content":"hello"}],"system":"just a system prompt"}`)
out, hits, changed := NormalizeDateline(body)
if changed || len(hits) != 0 {
t.Fatalf("expected no changes; changed=%v hits=%v", changed, hits)
}
if &out[0] != &body[0] {
// Identity is a bonus but not strict; verify content equality at minimum
if !bytes.Equal(out, body) {
t.Fatalf("output must byte-match input when no changes needed")
}
}
}
func TestNormalizeDateline_MultipleSystemReminderBlocksInSameText(t *testing.T) {
body := []byte(`{"messages":[{"role":"user","content":"<system-reminder>\nToday’s date is 2026/07/01.\n</system-reminder>\nsome prose\n<system-reminder>\nAlso Todayʼs date is 2026/07/01.\n</system-reminder>"}]}`)
out, hits, changed := NormalizeDateline(body)
if !changed {
t.Fatalf("expected changed=true")
}
if len(hits) != 2 {
t.Fatalf("expected 2 hits, got %d", len(hits))
}
if bytes.Contains(out, []byte("2026/07/01")) {
t.Fatalf("slash separator must be gone: %s", string(out))
}
}
+6
View File
@@ -146,6 +146,12 @@ var DefaultModels = []Model{
DisplayName: "Claude Opus 4.8",
CreatedAt: "2026-05-29T00:00:00Z",
},
{
ID: "claude-sonnet-5",
Type: "model",
DisplayName: "Claude Sonnet 5",
CreatedAt: "2026-07-01T00:00:00Z",
},
{
ID: "claude-sonnet-4-6",
Type: "model",
@@ -261,17 +261,19 @@ type PlatformDashboardStats struct {
// UsageLogFilters represents filters for usage log queries
type UsageLogFilters struct {
UserID int64
APIKeyID int64
AccountID int64
GroupID int64
Model string
RequestType *int16
Stream *bool
BillingType *int8
BillingMode string
StartTime *time.Time
EndTime *time.Time
UserID int64
APIKeyID int64
AccountID int64
GroupID int64
Model string
// ModelFilterSource controls how Model is matched. Empty preserves raw usage_logs.model semantics.
ModelFilterSource string
RequestType *int16
Stream *bool
BillingType *int8
BillingMode string
StartTime *time.Time
EndTime *time.Time
// ExactTotal requests exact COUNT(*) for pagination. Default false for fast large-table paging.
ExactTotal bool
}
+111 -24
View File
@@ -143,23 +143,53 @@ func appendRawUsageLogModelWhereCondition(conditions []string, args []any, model
}
func appendUsageLogBillingModeWhereCondition(conditions []string, args []any, billingMode string) ([]string, []any) {
return appendUsageLogBillingModeWhereConditionWithAlias(conditions, args, billingMode, "")
}
func appendUsageLogBillingModeWhereConditionWithAlias(conditions []string, args []any, billingMode string, alias string) ([]string, []any) {
mode := strings.TrimSpace(billingMode)
if mode == "" {
return conditions, args
}
column := func(name string) string {
if alias == "" {
return name
}
return alias + "." + name
}
placeholder := fmt.Sprintf("$%d", len(args)+1)
switch service.BillingMode(mode) {
case service.BillingModeImage:
conditions = append(conditions, fmt.Sprintf("(billing_mode = %s OR COALESCE(image_count, 0) > 0)", placeholder))
conditions = append(conditions, fmt.Sprintf("(%s = %s OR COALESCE(%s, 0) > 0)", column("billing_mode"), placeholder, column("image_count")))
case service.BillingModeToken:
conditions = append(conditions, fmt.Sprintf("(billing_mode = %s OR ((billing_mode IS NULL OR billing_mode = '') AND COALESCE(image_count, 0) <= 0))", placeholder))
conditions = append(conditions, fmt.Sprintf("(%s = %s OR ((%s IS NULL OR %s = '') AND COALESCE(%s, 0) <= 0))", column("billing_mode"), placeholder, column("billing_mode"), column("billing_mode"), column("image_count")))
default:
conditions = append(conditions, fmt.Sprintf("billing_mode = %s", placeholder))
conditions = append(conditions, fmt.Sprintf("%s = %s", column("billing_mode"), placeholder))
}
args = append(args, mode)
return conditions, args
}
func appendUsageLogBillingModeQueryFilter(query string, args []any, billingMode string, alias string) (string, []any) {
conditions, args := appendUsageLogBillingModeWhereConditionWithAlias(nil, args, billingMode, alias)
if len(conditions) == 0 {
return query, args
}
return query + " AND " + conditions[0], args
}
func appendUsageLogModelWhereCondition(conditions []string, args []any, model string, source string) ([]string, []any) {
if strings.TrimSpace(source) == "" {
return appendRawUsageLogModelWhereCondition(conditions, args, model)
}
if strings.TrimSpace(model) == "" {
return conditions, args
}
conditions = append(conditions, fmt.Sprintf("%s = $%d", resolveModelDimensionExpression(source), len(args)+1))
args = append(args, model)
return conditions, args
}
// appendRawUsageLogModelQueryFilter keeps direct model filters on the raw model column for backward
// compatibility with historical rows. Requested/upstream analytics must use
// resolveModelDimensionExpression instead.
@@ -172,6 +202,18 @@ func appendRawUsageLogModelQueryFilter(query string, args []any, model string) (
return query, args
}
func appendUsageLogModelQueryFilter(query string, args []any, model string, source string) (string, []any) {
if strings.TrimSpace(source) == "" {
return appendRawUsageLogModelQueryFilter(query, args, model)
}
if strings.TrimSpace(model) == "" {
return query, args
}
query += fmt.Sprintf(" AND %s = $%d", resolveModelDimensionExpression(source), len(args)+1)
args = append(args, model)
return query, args
}
type usageLogRepository struct {
client *dbent.Client
sql sqlExecutor
@@ -2806,7 +2848,7 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat
conditions = append(conditions, fmt.Sprintf("group_id = $%d", len(args)+1))
args = append(args, filters.GroupID)
}
conditions, args = appendRawUsageLogModelWhereCondition(conditions, args, filters.Model)
conditions, args = appendUsageLogModelWhereCondition(conditions, args, filters.Model, filters.ModelFilterSource)
conditions, args = appendRequestTypeOrStreamWhereCondition(conditions, args, filters.RequestType, filters.Stream)
if filters.BillingType != nil {
conditions = append(conditions, fmt.Sprintf("billing_type = $%d", len(args)+1))
@@ -3018,7 +3060,15 @@ func (r *usageLogRepository) GetBatchAPIKeyUsageStats(ctx context.Context, apiKe
// GetUsageTrendWithFilters returns usage trend data with optional filters
func (r *usageLogRepository) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) (results []TrendDataPoint, err error) {
if shouldUsePreaggregatedTrend(granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType) {
return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "")
}
func (r *usageLogRepository) GetUsageTrendWithUsageFilters(ctx context.Context, startTime, endTime time.Time, granularity string, filters UsageLogFilters) (results []TrendDataPoint, err error) {
return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode)
}
func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []TrendDataPoint, err error) {
if shouldUsePreaggregatedTrend(granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, billingMode) {
aggregated, aggregatedErr := r.getUsageTrendFromAggregates(ctx, startTime, endTime, granularity)
if aggregatedErr == nil && len(aggregated) > 0 {
return aggregated, nil
@@ -3059,12 +3109,13 @@ func (r *usageLogRepository) GetUsageTrendWithFilters(ctx context.Context, start
query += fmt.Sprintf(" AND group_id = $%d", len(args)+1)
args = append(args, groupID)
}
query, args = appendRawUsageLogModelQueryFilter(query, args, model)
query, args = appendUsageLogModelQueryFilter(query, args, model, modelSource)
query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream)
if billingType != nil {
query += fmt.Sprintf(" AND billing_type = $%d", len(args)+1)
args = append(args, int16(*billingType))
}
query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "")
query += " GROUP BY date ORDER BY date ASC"
rows, err := r.sql.QueryContext(ctx, query, args...)
@@ -3087,7 +3138,7 @@ func (r *usageLogRepository) GetUsageTrendWithFilters(ctx context.Context, start
return results, nil
}
func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) bool {
func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string) bool {
if granularity != "day" && granularity != "hour" {
return false
}
@@ -3098,7 +3149,8 @@ func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID
model == "" &&
requestType == nil &&
stream == nil &&
billingType == nil
billingType == nil &&
billingMode == ""
}
func (r *usageLogRepository) getUsageTrendFromAggregates(ctx context.Context, startTime, endTime time.Time, granularity string) (results []TrendDataPoint, err error) {
@@ -3163,16 +3215,20 @@ func (r *usageLogRepository) getUsageTrendFromAggregates(ctx context.Context, st
// GetModelStatsWithFilters returns model statistics with optional filters
func (r *usageLogRepository) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []ModelStat, err error) {
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, usagestats.ModelSourceRequested)
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, usagestats.ModelSourceRequested, "")
}
// GetModelStatsWithFiltersBySource returns model statistics with optional filters and model source dimension.
// source: requested | upstream | mapping.
func (r *usageLogRepository) GetModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8, source string) (results []ModelStat, err error) {
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, source)
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, source, "")
}
func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8, source string) (results []ModelStat, err error) {
func (r *usageLogRepository) GetModelStatsWithUsageFiltersBySource(ctx context.Context, startTime, endTime time.Time, filters UsageLogFilters, source string) (results []ModelStat, err error) {
return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, source, filters.BillingMode)
}
func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, source string, billingMode string) (results []ModelStat, err error) {
actualCostExpr := "COALESCE(SUM(actual_cost), 0) as actual_cost"
// 当仅按 account_id 聚合时,实际费用使用账号倍率(total_cost * account_rate_multiplier)。
if accountID > 0 && userID == 0 && apiKeyID == 0 {
@@ -3214,11 +3270,16 @@ func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Contex
query += fmt.Sprintf(" AND group_id = $%d", len(args)+1)
args = append(args, groupID)
}
if strings.TrimSpace(model) != "" {
query += fmt.Sprintf(" AND %s = $%d", modelExpr, len(args)+1)
args = append(args, model)
}
query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream)
if billingType != nil {
query += fmt.Sprintf(" AND billing_type = $%d", len(args)+1)
args = append(args, int16(*billingType))
}
query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "")
query += fmt.Sprintf(" GROUP BY %s ORDER BY total_tokens DESC", modelExpr)
rows, err := r.sql.QueryContext(ctx, query, args...)
@@ -3243,6 +3304,14 @@ func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Contex
// GetGroupStatsWithFilters returns group usage statistics with optional filters
func (r *usageLogRepository) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []usagestats.GroupStat, err error) {
return r.getGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, "")
}
func (r *usageLogRepository) GetGroupStatsWithUsageFilters(ctx context.Context, startTime, endTime time.Time, filters UsageLogFilters) (results []usagestats.GroupStat, err error) {
return r.getGroupStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode)
}
func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []usagestats.GroupStat, err error) {
query := `
SELECT
COALESCE(ul.group_id, 0) as group_id,
@@ -3274,11 +3343,17 @@ func (r *usageLogRepository) GetGroupStatsWithFilters(ctx context.Context, start
query += fmt.Sprintf(" AND ul.group_id = $%d", len(args)+1)
args = append(args, groupID)
}
if strings.TrimSpace(model) != "" {
modelExpr := resolveModelDimensionExpressionWithAlias(usagestats.ModelSourceRequested, "ul")
query += fmt.Sprintf(" AND %s = $%d", modelExpr, len(args)+1)
args = append(args, model)
}
query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream)
if billingType != nil {
query += fmt.Sprintf(" AND ul.billing_type = $%d", len(args)+1)
args = append(args, int16(*billingType))
}
query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "ul")
query += " GROUP BY ul.group_id, g.name ORDER BY total_tokens DESC"
rows, err := r.sql.QueryContext(ctx, query, args...)
@@ -3444,12 +3519,22 @@ func (r *usageLogRepository) GetAllGroupUsageSummary(ctx context.Context, todayS
// resolveModelDimensionExpression maps model source type to a safe SQL expression.
func resolveModelDimensionExpression(modelType string) string {
requestedExpr := "COALESCE(NULLIF(TRIM(requested_model), ''), model)"
return resolveModelDimensionExpressionWithAlias(modelType, "")
}
func resolveModelDimensionExpressionWithAlias(modelType, alias string) string {
column := func(name string) string {
if alias == "" {
return name
}
return alias + "." + name
}
requestedExpr := fmt.Sprintf("COALESCE(NULLIF(TRIM(%s), ''), %s)", column("requested_model"), column("model"))
switch usagestats.NormalizeModelSource(modelType) {
case usagestats.ModelSourceUpstream:
return fmt.Sprintf("COALESCE(NULLIF(TRIM(upstream_model), ''), %s)", requestedExpr)
return fmt.Sprintf("COALESCE(NULLIF(TRIM(%s), ''), %s)", column("upstream_model"), requestedExpr)
case usagestats.ModelSourceMapping:
return fmt.Sprintf("(%s || ' -> ' || COALESCE(NULLIF(TRIM(upstream_model), ''), %s))", requestedExpr, requestedExpr)
return fmt.Sprintf("(%s || ' -> ' || COALESCE(NULLIF(TRIM(%s), ''), %s))", requestedExpr, column("upstream_model"), requestedExpr)
default:
return requestedExpr
}
@@ -3523,7 +3608,7 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us
conditions = append(conditions, fmt.Sprintf("group_id = $%d", len(args)+1))
args = append(args, filters.GroupID)
}
conditions, args = appendRawUsageLogModelWhereCondition(conditions, args, filters.Model)
conditions, args = appendUsageLogModelWhereCondition(conditions, args, filters.Model, filters.ModelFilterSource)
conditions, args = appendRequestTypeOrStreamWhereCondition(conditions, args, filters.RequestType, filters.Stream)
if filters.BillingType != nil {
conditions = append(conditions, fmt.Sprintf("billing_type = $%d", len(args)+1))
@@ -3587,7 +3672,7 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us
}
// endpoint 明细:best-effort(失败 log + 返空),不致命。
runEndpoints := func(c context.Context) {
res, err := r.GetEndpointStatsWithFilters(c, start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType)
res, err := r.getEndpointStatsByColumnWithFilters(c, "inbound_endpoint", start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode)
if err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
logger.LegacyPrintf("repository.usage_log", "GetEndpointStatsWithFilters failed in GetStatsWithFilters: %v", err)
@@ -3597,7 +3682,7 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us
endpoints = res
}
runUpstream := func(c context.Context) {
res, err := r.GetUpstreamEndpointStatsWithFilters(c, start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType)
res, err := r.getEndpointStatsByColumnWithFilters(c, "upstream_endpoint", start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode)
if err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
logger.LegacyPrintf("repository.usage_log", "GetUpstreamEndpointStatsWithFilters failed in GetStatsWithFilters: %v", err)
@@ -3607,7 +3692,7 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us
upstreamEndpoints = res
}
runPaths := func(c context.Context) {
res, err := r.getEndpointPathStatsWithFilters(c, start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType)
res, err := r.getEndpointPathStatsWithFilters(c, start, end, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode)
if err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
logger.LegacyPrintf("repository.usage_log", "getEndpointPathStatsWithFilters failed in GetStatsWithFilters: %v", err)
@@ -3658,7 +3743,7 @@ type AccountUsageStatsResponse = usagestats.AccountUsageStatsResponse
// EndpointStat represents endpoint usage statistics row.
type EndpointStat = usagestats.EndpointStat
func (r *usageLogRepository) getEndpointStatsByColumnWithFilters(ctx context.Context, endpointColumn string, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) (results []EndpointStat, err error) {
func (r *usageLogRepository) getEndpointStatsByColumnWithFilters(ctx context.Context, endpointColumn string, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []EndpointStat, err error) {
actualCostExpr := "COALESCE(SUM(actual_cost), 0) as actual_cost"
if accountID > 0 && userID == 0 && apiKeyID == 0 {
actualCostExpr = "COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as actual_cost"
@@ -3692,12 +3777,13 @@ func (r *usageLogRepository) getEndpointStatsByColumnWithFilters(ctx context.Con
query += fmt.Sprintf(" AND group_id = $%d", len(args)+1)
args = append(args, groupID)
}
query, args = appendRawUsageLogModelQueryFilter(query, args, model)
query, args = appendUsageLogModelQueryFilter(query, args, model, modelSource)
query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream)
if billingType != nil {
query += fmt.Sprintf(" AND billing_type = $%d", len(args)+1)
args = append(args, int16(*billingType))
}
query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "")
query += " GROUP BY endpoint ORDER BY requests DESC"
rows, err := r.sql.QueryContext(ctx, query, args...)
@@ -3725,7 +3811,7 @@ func (r *usageLogRepository) getEndpointStatsByColumnWithFilters(ctx context.Con
return results, nil
}
func (r *usageLogRepository) getEndpointPathStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) (results []EndpointStat, err error) {
func (r *usageLogRepository) getEndpointPathStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string) (results []EndpointStat, err error) {
actualCostExpr := "COALESCE(SUM(actual_cost), 0) as actual_cost"
if accountID > 0 && userID == 0 && apiKeyID == 0 {
actualCostExpr = "COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as actual_cost"
@@ -3763,12 +3849,13 @@ func (r *usageLogRepository) getEndpointPathStatsWithFilters(ctx context.Context
query += fmt.Sprintf(" AND group_id = $%d", len(args)+1)
args = append(args, groupID)
}
query, args = appendRawUsageLogModelQueryFilter(query, args, model)
query, args = appendUsageLogModelQueryFilter(query, args, model, modelSource)
query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream)
if billingType != nil {
query += fmt.Sprintf(" AND billing_type = $%d", len(args)+1)
args = append(args, int16(*billingType))
}
query, args = appendUsageLogBillingModeQueryFilter(query, args, billingMode, "")
query += " GROUP BY endpoint ORDER BY requests DESC"
rows, err := r.sql.QueryContext(ctx, query, args...)
@@ -3798,12 +3885,12 @@ func (r *usageLogRepository) getEndpointPathStatsWithFilters(ctx context.Context
// GetEndpointStatsWithFilters returns inbound endpoint statistics with optional filters.
func (r *usageLogRepository) GetEndpointStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]EndpointStat, error) {
return r.getEndpointStatsByColumnWithFilters(ctx, "inbound_endpoint", startTime, endTime, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType)
return r.getEndpointStatsByColumnWithFilters(ctx, "inbound_endpoint", startTime, endTime, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "")
}
// GetUpstreamEndpointStatsWithFilters returns upstream endpoint statistics with optional filters.
func (r *usageLogRepository) GetUpstreamEndpointStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) ([]EndpointStat, error) {
return r.getEndpointStatsByColumnWithFilters(ctx, "upstream_endpoint", startTime, endTime, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType)
return r.getEndpointStatsByColumnWithFilters(ctx, "upstream_endpoint", startTime, endTime, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "")
}
// GetAccountUsageStats returns comprehensive usage statistics for an account over a time range
@@ -306,6 +306,20 @@ func TestAppendUsageLogBillingModeWhereCondition(t *testing.T) {
}
}
func TestAppendUsageLogBillingModeWhereConditionWithAlias(t *testing.T) {
conditions, args := appendUsageLogBillingModeWhereConditionWithAlias(nil, nil, string(service.BillingModeImage), "ul")
require.Equal(t, []string{"(ul.billing_mode = $1 OR COALESCE(ul.image_count, 0) > 0)"}, conditions)
require.Equal(t, []any{string(service.BillingModeImage)}, args)
}
func TestAppendUsageLogBillingModeQueryFilter(t *testing.T) {
query, args := appendUsageLogBillingModeQueryFilter("SELECT * FROM usage_logs WHERE user_id = $1", []any{int64(42)}, string(service.BillingModeToken), "")
require.Equal(t, "SELECT * FROM usage_logs WHERE user_id = $1 AND (billing_mode = $2 OR ((billing_mode IS NULL OR billing_mode = '') AND COALESCE(image_count, 0) <= 0))", query)
require.Equal(t, []any{int64(42), string(service.BillingModeToken)}, args)
}
func anySliceToDriverValues(values []any) []driver.Value {
out := make([]driver.Value, 0, len(values))
for _, value := range values {
@@ -341,6 +355,26 @@ func TestUsageLogRepositoryListWithFiltersRequestTypePriority(t *testing.T) {
require.NoError(t, mock.ExpectationsWereMet())
}
func TestUsageLogRepositoryListWithFiltersRequestedModelSource(t *testing.T) {
db, mock := newSQLMock(t)
repo := &usageLogRepository{sql: db}
filters := usagestats.UsageLogFilters{
Model: "gpt-5",
ModelFilterSource: usagestats.ModelSourceRequested,
}
mock.ExpectQuery("SELECT .* FROM usage_logs WHERE COALESCE\\(NULLIF\\(TRIM\\(requested_model\\), ''\\), model\\) = \\$1 ORDER BY id DESC LIMIT \\$2 OFFSET \\$3").
WithArgs("gpt-5", 21, 0).
WillReturnRows(sqlmock.NewRows([]string{"id"}))
logs, page, err := repo.ListWithFilters(context.Background(), pagination.PaginationParams{Page: 1, PageSize: 20}, filters)
require.NoError(t, err)
require.Empty(t, logs)
require.NotNil(t, page)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestUsageLogRepositoryGetUsageTrendWithFiltersRequestTypePriority(t *testing.T) {
db, mock := newSQLMock(t)
repo := &usageLogRepository{sql: db}
@@ -360,6 +394,27 @@ func TestUsageLogRepositoryGetUsageTrendWithFiltersRequestTypePriority(t *testin
require.NoError(t, mock.ExpectationsWereMet())
}
func TestUsageLogRepositoryGetUsageTrendWithUsageFiltersRequestedModelSource(t *testing.T) {
db, mock := newSQLMock(t)
repo := &usageLogRepository{sql: db}
start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
end := start.Add(24 * time.Hour)
filters := usagestats.UsageLogFilters{
Model: "gpt-5",
ModelFilterSource: usagestats.ModelSourceRequested,
}
mock.ExpectQuery("AND COALESCE\\(NULLIF\\(TRIM\\(requested_model\\), ''\\), model\\) = \\$3").
WithArgs(start, end, "gpt-5").
WillReturnRows(sqlmock.NewRows([]string{"date", "requests", "input_tokens", "output_tokens", "cache_creation_tokens", "cache_read_tokens", "total_tokens", "cost", "actual_cost"}))
trend, err := repo.GetUsageTrendWithUsageFilters(context.Background(), start, end, "day", filters)
require.NoError(t, err)
require.Empty(t, trend)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestUsageLogRepositoryGetModelStatsWithFiltersRequestTypePriority(t *testing.T) {
db, mock := newSQLMock(t)
repo := &usageLogRepository{sql: db}
@@ -379,6 +434,45 @@ func TestUsageLogRepositoryGetModelStatsWithFiltersRequestTypePriority(t *testin
require.NoError(t, mock.ExpectationsWereMet())
}
func TestUsageLogRepositoryGetStatsWithFiltersRequestedModelSource(t *testing.T) {
db, mock := newSQLMock(t)
repo := &usageLogRepository{sql: db}
filters := usagestats.UsageLogFilters{
Model: "gpt-5",
ModelFilterSource: usagestats.ModelSourceRequested,
}
mock.ExpectQuery("FROM usage_logs\\s+WHERE COALESCE\\(NULLIF\\(TRIM\\(requested_model\\), ''\\), model\\) = \\$1").
WithArgs("gpt-5").
WillReturnRows(sqlmock.NewRows([]string{
"total_requests",
"total_input_tokens",
"total_output_tokens",
"total_cache_tokens",
"total_cache_creation_tokens",
"total_cache_read_tokens",
"total_cost",
"total_actual_cost",
"total_account_cost",
"avg_duration_ms",
}).AddRow(int64(1), int64(2), int64(3), int64(4), int64(1), int64(3), 1.2, 1.0, 1.2, 20.0))
mock.ExpectQuery("SELECT COALESCE\\(NULLIF\\(TRIM\\(inbound_endpoint\\), ''\\), 'unknown'\\) AS endpoint").
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "gpt-5").
WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"}))
mock.ExpectQuery("SELECT COALESCE\\(NULLIF\\(TRIM\\(upstream_endpoint\\), ''\\), 'unknown'\\) AS endpoint").
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "gpt-5").
WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"}))
mock.ExpectQuery("SELECT CONCAT\\(").
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), "gpt-5").
WillReturnRows(sqlmock.NewRows([]string{"endpoint", "requests", "total_tokens", "cost", "actual_cost"}))
stats, err := repo.GetStatsWithFilters(context.Background(), filters)
require.NoError(t, err)
require.Equal(t, int64(1), stats.TotalRequests)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestUsageLogRepositoryGetStatsWithFiltersRequestTypePriority(t *testing.T) {
db, mock := newSQLMock(t)
repo := &usageLogRepository{sql: db}
@@ -452,6 +546,29 @@ func TestUsageLogRepositoryGetModelStatsAccountCostColumn(t *testing.T) {
require.NoError(t, mock.ExpectationsWereMet())
}
func TestUsageLogRepositoryGetModelStatsWithUsageFiltersAppliesRequestedModelFilter(t *testing.T) {
db, mock := newSQLMock(t)
repo := &usageLogRepository{sql: db}
start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
end := start.Add(24 * time.Hour)
filters := usagestats.UsageLogFilters{Model: "gpt-5"}
mock.ExpectQuery("AND COALESCE\\(NULLIF\\(TRIM\\(requested_model\\), ''\\), model\\) = \\$3").
WithArgs(start, end, "gpt-5").
WillReturnRows(sqlmock.NewRows([]string{
"model", "requests", "input_tokens", "output_tokens",
"cache_creation_tokens", "cache_read_tokens", "total_tokens",
"cost", "actual_cost", "account_cost",
}).AddRow("gpt-5", int64(1), int64(10), int64(20), int64(0), int64(0), int64(30), 0.1, 0.08, 0.07))
results, err := repo.GetModelStatsWithUsageFiltersBySource(context.Background(), start, end, filters, usagestats.ModelSourceRequested)
require.NoError(t, err)
require.Len(t, results, 1)
require.Equal(t, "gpt-5", results[0].Model)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestUsageLogRepositoryGetGroupStatsAccountCostColumn(t *testing.T) {
db, mock := newSQLMock(t)
repo := &usageLogRepository{sql: db}
@@ -481,6 +598,28 @@ func TestUsageLogRepositoryGetGroupStatsAccountCostColumn(t *testing.T) {
require.NoError(t, mock.ExpectationsWereMet())
}
func TestUsageLogRepositoryGetGroupStatsWithUsageFiltersAppliesRequestedModelFilter(t *testing.T) {
db, mock := newSQLMock(t)
repo := &usageLogRepository{sql: db}
start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
end := start.Add(24 * time.Hour)
filters := usagestats.UsageLogFilters{Model: "gpt-5"}
mock.ExpectQuery("AND COALESCE\\(NULLIF\\(TRIM\\(ul.requested_model\\), ''\\), ul.model\\) = \\$3").
WithArgs(start, end, "gpt-5").
WillReturnRows(sqlmock.NewRows([]string{
"group_id", "group_name", "requests", "total_tokens",
"cost", "actual_cost", "account_cost",
}).AddRow(int64(1), "default", int64(1), int64(30), 0.1, 0.08, 0.07))
results, err := repo.GetGroupStatsWithUsageFilters(context.Background(), start, end, filters)
require.NoError(t, err)
require.Len(t, results, 1)
require.Equal(t, int64(1), results[0].GroupID)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestUsageLogRepositoryGetStatsWithFiltersAlwaysReturnsAccountCost(t *testing.T) {
db, mock := newSQLMock(t)
repo := &usageLogRepository{sql: db}
+59 -2
View File
@@ -846,6 +846,7 @@ func TestAPIContracts(t *testing.T) {
"claude_oauth_system_prompt_blocks": "",
"enable_anthropic_cache_ttl_1h_injection": false,
"rewrite_message_cache_control": false,
"enable_client_dateline_normalization": true,
"antigravity_user_agent_version": "",
"enable_fingerprint_unification": true,
"enable_metadata_passthrough": false,
@@ -1090,6 +1091,7 @@ func TestAPIContracts(t *testing.T) {
"claude_oauth_system_prompt_blocks": "",
"enable_anthropic_cache_ttl_1h_injection": false,
"rewrite_message_cache_control": false,
"enable_client_dateline_normalization": true,
"antigravity_user_agent_version": "",
"min_codex_version": "",
"max_codex_version": "",
@@ -2506,7 +2508,7 @@ func (r *stubUsageLogRepo) ListWithFilters(ctx context.Context, params paginatio
continue
}
// Apply Model filter
if filters.Model != "" && log.Model != filters.Model {
if filters.Model != "" && stubUsageLogFilterModel(log, filters.ModelFilterSource) != filters.Model {
continue
}
// Apply Stream filter
@@ -2532,6 +2534,13 @@ func (r *stubUsageLogRepo) ListWithFilters(ctx context.Context, params paginatio
return out, paginationResult(total, params), nil
}
func stubUsageLogFilterModel(log service.UsageLog, source string) string {
if source == usagestats.ModelSourceRequested && log.RequestedModel != "" {
return log.RequestedModel
}
return log.Model
}
func (r *stubUsageLogRepo) GetGlobalStats(ctx context.Context, startTime, endTime time.Time) (*usagestats.UsageStats, error) {
return nil, errors.New("not implemented")
}
@@ -2541,7 +2550,55 @@ func (r *stubUsageLogRepo) GetAccountUsageStats(ctx context.Context, accountID i
}
func (r *stubUsageLogRepo) GetStatsWithFilters(ctx context.Context, filters usagestats.UsageLogFilters) (*usagestats.UsageStats, error) {
return nil, errors.New("not implemented")
logs, _, err := r.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 100000}, filters)
if err != nil {
return nil, err
}
var totalRequests int64
var totalInputTokens int64
var totalOutputTokens int64
var totalCacheTokens int64
var totalCacheCreationTokens int64
var totalCacheReadTokens int64
var totalCost float64
var totalActualCost float64
var totalDuration int64
var durationCount int64
for _, log := range logs {
totalRequests++
totalInputTokens += int64(log.InputTokens)
totalOutputTokens += int64(log.OutputTokens)
totalCacheTokens += int64(log.CacheCreationTokens + log.CacheReadTokens)
totalCacheCreationTokens += int64(log.CacheCreationTokens)
totalCacheReadTokens += int64(log.CacheReadTokens)
totalCost += log.TotalCost
totalActualCost += log.ActualCost
if log.DurationMs != nil {
totalDuration += int64(*log.DurationMs)
durationCount++
}
}
var avgDuration float64
if durationCount > 0 {
avgDuration = float64(totalDuration) / float64(durationCount)
}
return &usagestats.UsageStats{
TotalRequests: totalRequests,
TotalInputTokens: totalInputTokens,
TotalOutputTokens: totalOutputTokens,
TotalCacheTokens: totalCacheTokens,
TotalCacheCreationTokens: totalCacheCreationTokens,
TotalCacheReadTokens: totalCacheReadTokens,
TotalTokens: totalInputTokens + totalOutputTokens + totalCacheTokens,
TotalCost: totalCost,
TotalActualCost: totalActualCost,
AverageDurationMs: avgDuration,
Endpoints: []usagestats.EndpointStat{},
}, nil
}
func (r *stubUsageLogRepo) GetAllGroupUsageSummary(ctx context.Context, todayStart time.Time) ([]usagestats.GroupUsageSummary, error) {
return nil, errors.New("not implemented")
+1
View File
@@ -90,6 +90,7 @@ func RegisterUserRoutes(
usage.GET("/dashboard/stats", h.Usage.DashboardStats)
usage.GET("/dashboard/trend", h.Usage.DashboardTrend)
usage.GET("/dashboard/models", h.Usage.DashboardModels)
usage.GET("/dashboard/snapshot-v2", h.Usage.DashboardSnapshotV2)
usage.POST("/dashboard/api-keys-usage", h.Usage.DashboardAPIKeysUsage)
}
+5 -2
View File
@@ -276,8 +276,9 @@ func (s *BillingService) initFallbackPricing() {
LongContextInputMultiplier: openAIGPT54LongContextInputMultiplier,
LongContextOutputMultiplier: openAIGPT54LongContextOutputMultiplier,
}
// GPT-5.5 暂无独立定价,回退到 GPT-5.4
// GPT-5.5 / GPT-5.5 Pro 暂无独立定价,回退到 GPT-5.4。
s.fallbackPrices["gpt-5.5"] = s.fallbackPrices["gpt-5.4"]
s.fallbackPrices["gpt-5.5-pro"] = s.fallbackPrices["gpt-5.4"]
s.fallbackPrices["gpt-5.4-mini"] = &ModelPricing{
InputPricePerToken: 7.5e-7,
@@ -666,6 +667,8 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
// OpenAI(GPT-5 / Codex 族):仅匹配已知型号,避免未知 OpenAI 型号误计价。
if normalized := normalizeKnownOpenAICodexModel(modelLower); normalized != "" {
switch normalized {
case "gpt-5.5-pro":
return s.fallbackPrices["gpt-5.5-pro"]
case "gpt-5.5":
return s.fallbackPrices["gpt-5.5"]
case "gpt-5.4-mini":
@@ -1057,7 +1060,7 @@ func isOpenAIGPT54Model(model string) bool {
// normalizeCodexModel 的默认兜底把非 OpenAI 模型(claude-*、gemini-*、gpt-4o)
// 误识别为 gpt-5.4。
normalized := normalizeKnownOpenAICodexModel(model)
return normalized == "gpt-5.4" || normalized == "gpt-5.5"
return normalized == "gpt-5.4" || normalized == "gpt-5.5" || normalized == "gpt-5.5-pro"
}
// CalculateCostWithConfig 使用配置中的默认倍率计算费用
@@ -263,6 +263,25 @@ func TestCalculateCost_OpenAIGPT54LongContextAppliesWholeSessionMultipliers(t *t
require.InDelta(t, expectedInput+expectedOutput, cost.ActualCost, 1e-10)
}
func TestCalculateCost_OpenAIGPT55ProUsesGPT55PricingPolicy(t *testing.T) {
svc := newTestBillingService()
tokens := UsageTokens{
InputTokens: 300000,
OutputTokens: 4000,
}
cost, err := svc.CalculateCost("gpt-5.5-pro", tokens, 1.0)
require.NoError(t, err)
expectedInput := float64(tokens.InputTokens) * 2.5e-6 * 2.0
expectedOutput := float64(tokens.OutputTokens) * 15e-6 * 1.5
require.InDelta(t, expectedInput, cost.InputCost, 1e-10)
require.InDelta(t, expectedOutput, cost.OutputCost, 1e-10)
require.InDelta(t, expectedInput+expectedOutput, cost.TotalCost, 1e-10)
require.InDelta(t, expectedInput+expectedOutput, cost.ActualCost, 1e-10)
}
// 回归测试 #2293:长上下文计费触发时,cache_read_tokens 也应应用 LongContextInputMultiplier。
// 修复前:CacheReadCost = tokens * 0.25e-6 (漏乘倍率,少计费用)。
// 修复后:CacheReadCost = tokens * 0.25e-6 * LongContextInputMultiplier(=2.0)。
@@ -450,6 +450,13 @@ const (
SettingKeyClaudeOAuthSystemPromptBlocks = "claude_oauth_system_prompt_blocks"
// SettingKeyEnableAnthropicCacheTTL1hInjection 是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl(默认 false)
SettingKeyEnableAnthropicCacheTTL1hInjection = "enable_anthropic_cache_ttl_1h_injection"
// SettingKeyEnableClientDatelineNormalization 是否对 Anthropic OAuth/SetupToken 账号
// 的 /v1/messages 请求体做客户端 dateline 归一化(默认 true)。
// 归一化把 system prompt / <system-reminder> 块中 "Today's date is …" 语句里的
// 非 ASCII 撇号与 "/" 日期分隔符还原为 ASCII 撇号 + "-" 分隔符,抹除某些客户端
// 在检测到非官方 base URL 时注入的 3 bit 隐写指纹。仅适用于 Anthropic OAuth/SetupToken
// 账号;API Key 账号不受影响。
SettingKeyEnableClientDatelineNormalization = "enable_client_dateline_normalization"
// SettingKeyRewriteMessageCacheControl 是否改写 messages[*].content[*].cache_control(默认 false)
SettingKeyRewriteMessageCacheControl = "rewrite_message_cache_control"
// SettingKeyAntigravityUserAgentVersion Antigravity 上游 User-Agent 版本号(空值使用环境变量/默认值)
@@ -242,3 +242,72 @@ func TestIsCountTokensUnsupported404(t *testing.T) {
})
}
}
// TestDefaultBetaPolicy_Context1M_Sonnet5Whitelist 验证默认策略下 context-1m-2025-08-07 的分模型行为:
// - claude-sonnet-5 及后续版本:pass(放行),保留 1M 上下文能力
// - 其他 sonnet 版本(4.x 及以下)、opus、haiku:filter(过滤),因为上游不支持
func TestDefaultBetaPolicy_Context1M_Sonnet5Whitelist(t *testing.T) {
settings := DefaultBetaPolicySettings()
// 找到 context-1m-2025-08-07 规则
var rule *BetaPolicyRule
for i := range settings.Rules {
if settings.Rules[i].BetaToken == "context-1m-2025-08-07" {
rule = &settings.Rules[i]
break
}
}
require.NotNil(t, rule, "default policy must include context-1m-2025-08-07 rule")
require.Equal(t, BetaPolicyActionPass, rule.Action, "primary action for whitelisted models is pass")
require.Equal(t, BetaPolicyActionFilter, rule.FallbackAction, "non-whitelisted models must be filtered")
require.NotEmpty(t, rule.ModelWhitelist, "context-1m must be scoped to sonnet-5+ via whitelist")
// 表驱动:模型 → 期望 action
// 覆盖每种上游路径下的模型 ID 变形:直连 Anthropic API、Vertex AI("@YYYYMMDD" 后缀)、
// AWS Bedrock 跨区域推理(us./eu./apac./jp./au./us-gov./global./anthropic. 前缀)。
cases := []struct {
model string
wantAction string
desc string
}{
// —— 直连 Anthropic API —— sonnet-5 系列应放行
{"claude-sonnet-5", BetaPolicyActionPass, "sonnet-5 canonical"},
{"claude-sonnet-5-20260701", BetaPolicyActionPass, "sonnet-5 dated variant matches wildcard"},
{"claude-sonnet-5-thinking", BetaPolicyActionPass, "sonnet-5 thinking variant matches wildcard"},
// —— Vertex AI 归一化后的 sonnet-5 —— 也应放行
{"claude-sonnet-5@20260701", BetaPolicyActionPass, "sonnet-5 Vertex-normalized dated form"},
// —— AWS Bedrock 各跨区域前缀 sonnet-5 —— 也应放行
{"us.anthropic.claude-sonnet-5-v1", BetaPolicyActionPass, "bedrock us. sonnet-5"},
{"eu.anthropic.claude-sonnet-5-20260701-v1:0", BetaPolicyActionPass, "bedrock eu. sonnet-5 dated"},
{"apac.anthropic.claude-sonnet-5-v1", BetaPolicyActionPass, "bedrock apac. sonnet-5"},
{"jp.anthropic.claude-sonnet-5-v1", BetaPolicyActionPass, "bedrock jp. sonnet-5"},
{"au.anthropic.claude-sonnet-5-v1", BetaPolicyActionPass, "bedrock au. sonnet-5"},
{"us-gov.anthropic.claude-sonnet-5-v1", BetaPolicyActionPass, "bedrock us-gov. sonnet-5"},
{"global.anthropic.claude-sonnet-5-v1", BetaPolicyActionPass, "bedrock global. sonnet-5"},
{"anthropic.claude-sonnet-5-v1", BetaPolicyActionPass, "bedrock no-region sonnet-5"},
// —— sonnet-4.x 及以下必须过滤 ——
{"claude-sonnet-4-6", BetaPolicyActionFilter, "sonnet-4.6 must be filtered"},
{"claude-sonnet-4-5-20250929", BetaPolicyActionFilter, "sonnet-4.5 dated must be filtered"},
{"claude-sonnet-4", BetaPolicyActionFilter, "sonnet-4 must be filtered"},
{"claude-sonnet-4-5@20250929", BetaPolicyActionFilter, "sonnet-4.5 Vertex format must be filtered"},
{"us.anthropic.claude-sonnet-4-6", BetaPolicyActionFilter, "bedrock us. sonnet-4.6 must be filtered"},
{"us.anthropic.claude-sonnet-4-5-20250929-v1:0", BetaPolicyActionFilter, "bedrock us. sonnet-4.5 must be filtered"},
// —— Opus / Haiku 必须过滤(无 1M) ——
{"claude-opus-4-8", BetaPolicyActionFilter, "opus must be filtered"},
{"claude-opus-4-7", BetaPolicyActionFilter, "opus 4.7 must be filtered"},
{"us.anthropic.claude-opus-4-8-v1", BetaPolicyActionFilter, "bedrock opus 4.8 must be filtered"},
{"claude-haiku-4-5", BetaPolicyActionFilter, "haiku must be filtered"},
{"us.anthropic.claude-haiku-4-5-20251001-v1:0", BetaPolicyActionFilter, "bedrock haiku must be filtered"},
{"claude-3-5-sonnet-20241022", BetaPolicyActionFilter, "legacy sonnet 3.5 must be filtered"},
// —— 特殊边界:不应把 "claude-sonnet-50" / "claude-sonnet-5.1" 之类意外命名误放行 ——
{"claude-sonnet-50", BetaPolicyActionFilter, "must not over-match a hypothetical sonnet-50"},
}
for _, tc := range cases {
t.Run(tc.model, func(t *testing.T) {
action, _ := resolveRuleAction(*rule, tc.model)
require.Equal(t, tc.wantAction, action, tc.desc)
})
}
}
@@ -0,0 +1,123 @@
package service
import (
"context"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/anthropicfp"
"github.com/stretchr/testify/require"
)
// TestGatewayClientDatelineNormalization_Scope covers the account/switch matrix
// for the shouldNormalizeClientDateline gate: Anthropic OAuth/SetupToken pass
// only when the switch is on; API-Key and non-Anthropic platforms are excluded
// unconditionally.
func TestGatewayClientDatelineNormalization_Scope(t *testing.T) {
repo := &gatewayTTLSettingRepo{data: map[string]string{}}
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{})
svc := &GatewayService{
settingService: NewSettingService(repo, &config.Config{}),
}
ctx := context.Background()
// Default (missing key): fallback in parseSettings/cache loader is true.
require.True(t, svc.shouldNormalizeClientDateline(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}))
require.True(t, svc.shouldNormalizeClientDateline(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeSetupToken}))
require.False(t, svc.shouldNormalizeClientDateline(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey}))
require.False(t, svc.shouldNormalizeClientDateline(ctx, &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}))
// Switch off: no account qualifies.
repo.data[SettingKeyEnableClientDatelineNormalization] = "false"
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{})
require.False(t, svc.shouldNormalizeClientDateline(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}))
require.False(t, svc.shouldNormalizeClientDateline(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeSetupToken}))
// Switch back on: OAuth qualifies again.
repo.data[SettingKeyEnableClientDatelineNormalization] = "true"
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{})
require.True(t, svc.shouldNormalizeClientDateline(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}))
}
// TestGatewayClientDatelineNormalization_HelperNoRewrite exercises the code
// path used by Forward: the helper must return ok=false when the switch is
// off, when the account is API-Key, when the account is nil, and when the
// body carries no fingerprinted dateline. It must return ok=true and a
// rewritten body when both the switch is on and the account is Anthropic
// OAuth/SetupToken and a rewrite actually happened.
func TestGatewayClientDatelineNormalization_HelperNoRewrite(t *testing.T) {
repo := &gatewayTTLSettingRepo{data: map[string]string{
SettingKeyEnableClientDatelineNormalization: "true",
}}
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{})
svc := &GatewayService{
settingService: NewSettingService(repo, &config.Config{}),
}
ctx := context.Background()
dirty := []byte(`{"messages":[{"role":"user","content":"<system-reminder>\nToday’s date is 2026/07/01.\n</system-reminder>"}]}`)
clean := []byte(`{"messages":[{"role":"user","content":"just hello"}]}`)
// API-Key account: never rewrites, even with dirty payload.
next, ok := svc.normalizeClientDatelineIfEnabled(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey}, dirty)
require.False(t, ok)
require.Nil(t, next)
// Nil account: safe no-op.
next, ok = svc.normalizeClientDatelineIfEnabled(ctx, nil, dirty)
require.False(t, ok)
require.Nil(t, next)
// OAuth account + clean body: no changes, ok=false.
next, ok = svc.normalizeClientDatelineIfEnabled(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}, clean)
require.False(t, ok)
require.Nil(t, next)
// OAuth account + dirty body: rewritten, ok=true.
next, ok = svc.normalizeClientDatelineIfEnabled(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}, dirty)
require.True(t, ok)
require.NotNil(t, next)
require.Contains(t, string(next), "Today's date is 2026-07-01.")
require.NotContains(t, string(next), "2026/07/01")
require.NotContains(t, string(next), "Today’s date is")
// SetupToken account + dirty body: rewritten, ok=true.
next, ok = svc.normalizeClientDatelineIfEnabled(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeSetupToken}, dirty)
require.True(t, ok)
require.Contains(t, string(next), "Today's date is 2026-07-01.")
// Switch off: even OAuth account is not rewritten.
repo.data[SettingKeyEnableClientDatelineNormalization] = "false"
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{})
next, ok = svc.normalizeClientDatelineIfEnabled(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}, dirty)
require.False(t, ok)
require.Nil(t, next)
}
// TestGatewayClientDatelineNormalization_LeavesUserProseUntouched double-checks
// that the pure normalizer never touches content outside <system-reminder>
// blocks. This is an integration guard between the switch-gated helper and
// the pkg/anthropicfp scope contract, tripped by anyone who broadens scope.
func TestGatewayClientDatelineNormalization_LeavesUserProseUntouched(t *testing.T) {
repo := &gatewayTTLSettingRepo{data: map[string]string{
SettingKeyEnableClientDatelineNormalization: "true",
}}
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{})
svc := &GatewayService{
settingService: NewSettingService(repo, &config.Config{}),
}
ctx := context.Background()
// User prose that happens to include a fingerprint-looking sentence
// (outside <system-reminder>) must be preserved byte-for-byte.
body := []byte(`{"messages":[{"role":"user","content":"I wrote: Today’s date is 2026/07/01. What do you think?"}]}`)
next, ok := svc.normalizeClientDatelineIfEnabled(ctx, &Account{Platform: PlatformAnthropic, Type: AccountTypeOAuth}, body)
require.False(t, ok, "must not rewrite user prose outside <system-reminder>")
require.Nil(t, next)
// Direct pure-fn check for redundancy.
out, hits, changed := anthropicfp.NormalizeDateline(body)
require.False(t, changed)
require.Empty(t, hits)
require.Equal(t, body, out)
}
@@ -26,6 +26,7 @@ import (
"unsafe"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/anthropicfp"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
@@ -4782,6 +4783,32 @@ func (s *GatewayService) shouldInjectAnthropicCacheTTL1h(ctx context.Context, ac
return s.settingService.IsAnthropicCacheTTL1hInjectionEnabled(ctx)
}
// shouldNormalizeClientDateline reports whether the request body's client
// dateline should be normalized before forwarding to Anthropic. The switch is
// scoped to Anthropic OAuth/SetupToken accounts only; API-Key accounts and
// non-Anthropic platforms bypass this step entirely.
func (s *GatewayService) shouldNormalizeClientDateline(ctx context.Context, account *Account) bool {
if account == nil || !account.IsAnthropicOAuthOrSetupToken() || s == nil || s.settingService == nil {
return false
}
return s.settingService.IsClientDatelineNormalizationEnabled(ctx)
}
// normalizeClientDatelineIfEnabled applies dateline normalization to body when
// the switch is on and the account qualifies. Returns (nextBody, true) only
// when the body actually changed; otherwise returns (nil, false) so callers
// can skip the writeback.
func (s *GatewayService) normalizeClientDatelineIfEnabled(ctx context.Context, account *Account, body []byte) ([]byte, bool) {
if !s.shouldNormalizeClientDateline(ctx, account) {
return nil, false
}
next, _, changed := anthropicfp.NormalizeDateline(body)
if !changed {
return nil, false
}
return next, true
}
func (s *GatewayService) claudeOAuthSystemPromptInjectionSettings(ctx context.Context) (bool, string, string) {
if s == nil || s.settingService == nil {
return true, "", ""
@@ -4931,6 +4958,16 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
}
}
// 客户端 dateline 归一化:仅对 Anthropic OAuth/SetupToken 账号生效。
// 抹除 "Today's date is …" 语句里可能被注入的隐写指纹(4 种撇号 × 2 种日期
// 分隔符),还原为 ASCII 撇号 + "-" 分隔符。运行在 mimicry 分支之外,
// 保证真实 Claude Code 客户端注入的指纹同样被清洗。
if next, ok := s.normalizeClientDatelineIfEnabled(ctx, account, body); ok {
if err := replaceBody(next); err != nil {
return nil, err
}
}
// 强制执行 cache_control 块数量限制(最多 4 个)
if err := replaceBody(enforceCacheControlLimit(body)); err != nil {
return nil, err
@@ -10,6 +10,7 @@ import (
var codexModelMap = map[string]string{
"gpt-5.5": "gpt-5.5",
"gpt-5.5-pro": "gpt-5.5-pro",
"codex-auto-review": "codex-auto-review",
"gpt-5.4": "gpt-5.4",
"gpt-5.4-mini": "gpt-5.4-mini",
@@ -61,6 +62,7 @@ var codexVersionModelPrefixes = []struct {
{prefix: "gpt-5.3-codex", target: "gpt-5.3-codex"},
{prefix: "gpt-5.4-mini", target: "gpt-5.4-mini"},
{prefix: "gpt-5.4-nano", target: "gpt-5.4-nano"},
{prefix: "gpt-5.5-pro", target: "gpt-5.5-pro"},
{prefix: "gpt-5.5", target: "gpt-5.5"},
{prefix: "gpt-5.4", target: "gpt-5.4"},
{prefix: "gpt-5.2", target: "gpt-5.2"},
@@ -1157,11 +1159,41 @@ func filterCodexInputWithOptions(input []any, opts codexInputFilterOptions) []an
}
typ, _ := m["type"].(string)
// chatgpt.com codex backend (OAuth path) does not persist reasoning
// items because applyCodexOAuthTransform forces store=false. Any rs_*
// reference replayed in input is guaranteed to 404 upstream
// ("Item with id 'rs_...' not found"). Drop reasoning items entirely.
// chatgpt.com codex (OAuth path) runs with store=false (forced by
// applyCodexOAuthTransform). Replaying a reasoning item with its rs_*
// id but no encrypted_content 404s upstream ("Item with id 'rs_...'
// not found") — the 404 is triggered by the id lookup, not by the
// reasoning item itself. So strip the id (always, independent of
// PreserveReferences) yet keep the item: under store=false
// encrypted_content is the official channel for carrying reasoning
// context across turns, and dropping the whole item silently degrades
// multi-turn agent reasoning. Preserve encrypted_content/content/
// summary and every other field verbatim. Upstream additionally
// requires a summary field — a missing one is rejected with 400
// "Missing required parameter 'input[N].summary'" — so backfill an
// empty array when it is absent. Contracts verified end-to-end against
// chatgpt.com codex (gpt-5.5); see issue #1957.
// compaction_summary items (cmp_*) are the other encrypted_content
// carrier. Verified against the live backend: they require
// encrypted_content (a missing one is rejected with 400), and with it
// present the cmp_* id does not 404 whether kept or stripped. Being
// neither reasoning nor tool calls, they flow through the generic path
// below (id stripped when !PreserveReferences, encrypted_content
// preserved either way), which is safe and needs no special-casing.
if typ == "reasoning" {
newItem := make(map[string]any, len(m))
for key, value := range m {
if key == "id" {
// rs_* id replayed under store=false 404s; strip it.
continue
}
newItem[key] = value
}
if summary, ok := newItem["summary"]; !ok || summary == nil {
// Upstream requires a summary field; an empty array satisfies it.
newItem["summary"] = []any{}
}
filtered = append(filtered, newItem)
continue
}
@@ -898,6 +898,10 @@ func TestNormalizeCodexModel_Gpt53(t *testing.T) {
"gpt-5.4": "gpt-5.4",
"gpt5.5": "gpt-5.5",
"openai/gpt5.5": "gpt-5.5",
"gpt-5.5-pro": "gpt-5.5-pro",
"gpt5.5-pro": "gpt-5.5-pro",
"openai/gpt5.5-pro": "gpt-5.5-pro",
"gpt-5.5-pro-high": "gpt-5.5-pro",
"codex-auto-review": "codex-auto-review",
"gpt5.4": "gpt-5.4",
"gpt-5.4-high": "gpt-5.4",
@@ -1343,23 +1347,24 @@ func TestIsInstructionsEmpty(t *testing.T) {
}
}
func TestFilterCodexInput_DropsReasoningItemsRegardlessOfPreserveReferences(t *testing.T) {
// Reasoning items in input[] reference rs_* IDs that were emitted by
// chatgpt.com under store=false (forced by applyCodexOAuthTransform).
// They are never persisted upstream, so forwarding them produces a
// guaranteed 404 ("Item with id 'rs_...' not found"). Drop them
// regardless of preserveReferences. See: Wei-Shaw/sub2api issue #1957.
// TestFilterCodexInput_PreservesReasoningStripsID covers the core OAuth-path
// reasoning contract (replaces the earlier "drops reasoning" test, whose
// premise was wrong). A reasoning item carrying encrypted_content is the
// official channel for replaying reasoning context across turns under
// store=false, so it must survive the filter with encrypted_content intact;
// only its rs_* id is stripped (always, independent of PreserveReferences)
// because a bare rs_* id replayed under store=false 404s upstream. Contracts
// 1/2/3, verified end-to-end against chatgpt.com codex (gpt-5.5). See issue
// #1957.
func TestFilterCodexInput_PreservesReasoningStripsID(t *testing.T) {
build := func() []any {
return []any{
map[string]any{"type": "message", "id": "msg_0", "role": "user", "content": "hi"},
map[string]any{
"type": "reasoning",
"id": "rs_0672f12450da0b9c0169f07220a6c08198b68c2455ced99344",
"summary": []any{},
"type": "reasoning",
"id": "rs_0672f12450da0b9c0169f07220a6c08198b68c2455ced99344",
"encrypted_content": "gAAAAAB-enc-payload",
"summary": []any{},
},
map[string]any{"type": "function_call", "id": "fc_1", "call_id": "call_1", "name": "tool"},
map[string]any{"type": "function_call_output", "call_id": "call_1", "output": "{}"},
}
}
@@ -1367,31 +1372,175 @@ func TestFilterCodexInput_DropsReasoningItemsRegardlessOfPreserveReferences(t *t
preserve := preserve
t.Run(fmt.Sprintf("preserveReferences=%v", preserve), func(t *testing.T) {
filtered := filterCodexInput(build(), preserve)
require.Len(t, filtered, 1)
item, ok := filtered[0].(map[string]any)
require.True(t, ok)
// Contract 2: the reasoning item survives the filter.
require.Equal(t, "reasoning", item["type"])
// Contract 2: encrypted_content (cross-turn channel) preserved verbatim.
require.Equal(t, "gAAAAAB-enc-payload", item["encrypted_content"])
// Contract 1/3: rs_* id stripped unconditionally, even when
// PreserveReferences=true (id lookup, not the item, triggers the 404).
_, hasID := item["id"]
require.False(t, hasID)
// summary passed through untouched.
summary, ok := item["summary"].([]any)
require.True(t, ok)
require.Len(t, summary, 0)
})
}
}
// TestFilterCodexInput_BareReasoningStripsIDBackfillsSummary covers contract 1
// plus 5: a reasoning item carrying only an rs_* id (no encrypted_content) is
// kept as an empty shell with the id stripped, and a missing summary is
// backfilled to [] so upstream does not reject it with 400 "Missing required
// parameter 'input[N].summary'". Verified against chatgpt.com codex (gpt-5.5).
func TestFilterCodexInput_BareReasoningStripsIDBackfillsSummary(t *testing.T) {
input := []any{
map[string]any{
"type": "reasoning",
"id": "rs_0672f12450da0b9c0169f07220a6c08198b68c2455ced99344",
},
}
filtered := filterCodexInput(input, false)
require.Len(t, filtered, 1)
item, ok := filtered[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "reasoning", item["type"])
// Contract 1: id stripped.
_, hasID := item["id"]
require.False(t, hasID)
// Contract 5: summary backfilled to an empty array.
summary, ok := item["summary"].([]any)
require.True(t, ok)
require.Len(t, summary, 0)
}
// TestFilterCodexInput_ReasoningBackfillsMissingSummary isolates contract 5:
// even when a reasoning item carries other content (here encrypted_content),
// a missing summary field is always added as [] before forwarding upstream.
func TestFilterCodexInput_ReasoningBackfillsMissingSummary(t *testing.T) {
input := []any{
map[string]any{
"type": "reasoning",
"id": "rs_abc",
"encrypted_content": "gAAAAAB-enc",
},
}
filtered := filterCodexInput(input, false)
require.Len(t, filtered, 1)
item, ok := filtered[0].(map[string]any)
require.True(t, ok)
summary, ok := item["summary"].([]any)
require.True(t, ok)
require.Len(t, summary, 0)
// encrypted_content still preserved alongside the backfilled summary.
require.Equal(t, "gAAAAAB-enc", item["encrypted_content"])
}
// TestFilterCodexInput_PreservesReasoningSummaryAndContent verifies that a
// non-empty summary is not overwritten and that arbitrary reasoning fields
// (e.g. content) survive verbatim — only the id is removed.
func TestFilterCodexInput_PreservesReasoningSummaryAndContent(t *testing.T) {
summary := []any{
map[string]any{"type": "summary_text", "text": "Considered the options."},
}
content := []any{
map[string]any{"type": "reasoning_text", "text": "internal chain"},
}
input := []any{
map[string]any{
"type": "reasoning",
"id": "rs_abc",
"summary": summary,
"content": content,
"encrypted_content": "gAAAAAB-enc",
},
}
filtered := filterCodexInput(input, false)
require.Len(t, filtered, 1)
item, ok := filtered[0].(map[string]any)
require.True(t, ok)
// Non-empty summary preserved verbatim (not replaced with []).
require.Equal(t, summary, item["summary"])
// content preserved verbatim.
require.Equal(t, content, item["content"])
require.Equal(t, "gAAAAAB-enc", item["encrypted_content"])
_, hasID := item["id"]
require.False(t, hasID)
}
// TestFilterCodexInput_PreservesReasoningInMixedInput exercises contract 7:
// reasoning items are stripped of their rs_* ids but kept (with
// encrypted_content) while message / function_call / function_call_output
// items flow through unchanged, with tool-call pairing (call_id) intact.
func TestFilterCodexInput_PreservesReasoningInMixedInput(t *testing.T) {
build := func() []any {
return []any{
map[string]any{"type": "message", "id": "msg_0", "role": "user", "content": "hi"},
map[string]any{
"type": "reasoning",
"id": "rs_1",
"encrypted_content": "gAAAAAB-enc-1",
"summary": []any{},
},
map[string]any{
"type": "reasoning",
"id": "rs_2",
"summary": []any{},
},
// call_id already in fc_ form so the unrelated call_->fc_
// normalization does not obscure the pairing assertion.
map[string]any{"type": "function_call", "id": "fc_1", "call_id": "fc_1", "name": "tool", "arguments": "{}"},
map[string]any{"type": "function_call_output", "call_id": "fc_1", "output": "{}"},
}
}
for _, preserve := range []bool{true, false} {
preserve := preserve
t.Run(fmt.Sprintf("preserveReferences=%v", preserve), func(t *testing.T) {
filtered := filterCodexInput(build(), preserve)
// Nothing is dropped: both reasoning items are now preserved.
require.Len(t, filtered, 5)
byType := make(map[string][]map[string]any)
for _, raw := range filtered {
item, ok := raw.(map[string]any)
require.True(t, ok)
require.NotEqual(t, "reasoning", item["type"],
"reasoning items must be dropped from input on the OAuth path")
typ, _ := item["type"].(string)
byType[typ] = append(byType[typ], item)
// No surviving item may carry an rs_* id.
if id, ok := item["id"].(string); ok {
require.False(t, strings.HasPrefix(id, "rs_"),
"no item carrying an rs_* id should survive the filter")
}
}
// Sanity check: the non-reasoning items should still be present.
gotTypes := make(map[string]int)
for _, raw := range filtered {
item, ok := raw.(map[string]any)
require.True(t, ok)
typ, ok := item["type"].(string)
require.True(t, ok)
gotTypes[typ]++
// Both reasoning items kept, ids stripped, summary present.
require.Len(t, byType["reasoning"], 2)
for _, r := range byType["reasoning"] {
_, hasID := r["id"]
require.False(t, hasID)
_, hasSummary := r["summary"]
require.True(t, hasSummary)
}
require.Equal(t, 1, gotTypes["message"])
require.Equal(t, 1, gotTypes["function_call"])
require.Equal(t, 1, gotTypes["function_call_output"])
require.Equal(t, 0, gotTypes["reasoning"])
require.Equal(t, "gAAAAAB-enc-1", byType["reasoning"][0]["encrypted_content"])
// message / function_call(+output) untouched by reasoning handling.
require.Len(t, byType["message"], 1)
// Contract 7: tool-call pairing by call_id is unaffected.
require.Len(t, byType["function_call"], 1)
require.Equal(t, "fc_1", byType["function_call"][0]["call_id"])
require.Len(t, byType["function_call_output"], 1)
require.Equal(t, "fc_1", byType["function_call_output"][0]["call_id"])
})
}
}
@@ -16,6 +16,7 @@ func mustRawJSON(t *testing.T, s string) json.RawMessage {
func TestShouldAutoInjectPromptCacheKeyForCompat(t *testing.T) {
require.True(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-5.5"))
require.True(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-5.5-pro"))
require.True(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-5.4"))
require.True(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-5.4-mini"))
require.True(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-5.2"))
@@ -65,6 +65,8 @@ func normalizeKnownOpenAICodexModel(model string) string {
}
switch {
case strings.Contains(normalized, "gpt-5.5-pro"):
return "gpt-5.5-pro"
case strings.Contains(normalized, "gpt-5.5"):
return "gpt-5.5"
case strings.Contains(normalized, "gpt-5.4-mini"):
@@ -94,6 +94,15 @@ func TestResolveOpenAIForwardModel(t *testing.T) {
defaultMappedModel: "gpt-5.4",
expectedModel: "gpt-5.5",
},
{
name: "preserves gpt-5.5-pro instead of group default",
account: &Account{
Credentials: map[string]any{},
},
requestedModel: "gpt-5.5-pro",
defaultMappedModel: "gpt-5.5",
expectedModel: "gpt-5.5-pro",
},
{
name: "preserves compact-spelled gpt5.5 instead of group default",
account: &Account{
@@ -261,6 +270,12 @@ func TestNormalizeOpenAIModelForUpstream(t *testing.T) {
model: "gpt-5.4-high",
want: "gpt-5.4",
},
{
name: "oauth preserves GPT-5.5 Pro model",
account: &Account{Type: AccountTypeOAuth},
model: "openai/gpt-5.5-pro",
want: "gpt-5.5-pro",
},
{
name: "oauth preserves codex auto review model",
account: &Account{Type: AccountTypeOAuth},
@@ -303,3 +318,17 @@ func TestUsageBillingModelCandidatesPreserveCodexAutoReviewModel(t *testing.T) {
}
}
}
func TestUsageBillingModelCandidatesPreserveGPT55ProModel(t *testing.T) {
candidates := usageBillingModelCandidates("openai/gpt-5.5-pro")
expected := []string{"openai/gpt-5.5-pro", "gpt-5.5-pro"}
if len(candidates) != len(expected) {
t.Fatalf("usageBillingModelCandidates(openai/gpt-5.5-pro) = %#v, want %#v", candidates, expected)
}
for i := range expected {
if candidates[i] != expected[i] {
t.Fatalf("usageBillingModelCandidates(openai/gpt-5.5-pro) = %#v, want %#v", candidates, expected)
}
}
}
+28 -2
View File
@@ -112,6 +112,7 @@ type cachedGatewayForwardingSettings struct {
claudeOAuthSystemPromptBlocks string
anthropicCacheTTL1hInjection bool
rewriteMessageCacheControl bool
clientDatelineNormalization bool
expiresAt int64 // unix nano
}
@@ -2207,6 +2208,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
updates[SettingKeyClaudeOAuthSystemPromptBlocks] = settings.ClaudeOAuthSystemPromptBlocks
updates[SettingKeyEnableAnthropicCacheTTL1hInjection] = strconv.FormatBool(settings.EnableAnthropicCacheTTL1hInjection)
updates[SettingKeyRewriteMessageCacheControl] = strconv.FormatBool(settings.RewriteMessageCacheControl)
updates[SettingKeyEnableClientDatelineNormalization] = strconv.FormatBool(settings.EnableClientDatelineNormalization)
updates[SettingKeyAntigravityUserAgentVersion] = antigravity.NormalizeUserAgentVersion(settings.AntigravityUserAgentVersion)
updates[SettingKeyOpenAICodexUserAgent] = strings.TrimSpace(settings.OpenAICodexUserAgent)
// codex_cli_only 加固
@@ -2345,6 +2347,7 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) {
claudeOAuthSystemPromptBlocks: settings.ClaudeOAuthSystemPromptBlocks,
anthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection,
rewriteMessageCacheControl: settings.RewriteMessageCacheControl,
clientDatelineNormalization: settings.EnableClientDatelineNormalization,
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
})
s.antigravityUAVersionSF.Forget("antigravity_user_agent_version")
@@ -2548,6 +2551,7 @@ func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool {
type gatewayForwardingSettingsResult struct {
fp, mp, cch, claudeOAuthSystemPromptInjection, cacheTTL1h, rewriteMessageCacheControl bool
clientDatelineNormalization bool
claudeOAuthSystemPrompt, claudeOAuthSystemPromptBlocks string
}
@@ -2563,6 +2567,7 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
claudeOAuthSystemPromptBlocks: cached.claudeOAuthSystemPromptBlocks,
cacheTTL1h: cached.anthropicCacheTTL1hInjection,
rewriteMessageCacheControl: cached.rewriteMessageCacheControl,
clientDatelineNormalization: cached.clientDatelineNormalization,
}
}
}
@@ -2578,6 +2583,7 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
claudeOAuthSystemPromptBlocks: cached.claudeOAuthSystemPromptBlocks,
cacheTTL1h: cached.anthropicCacheTTL1hInjection,
rewriteMessageCacheControl: cached.rewriteMessageCacheControl,
clientDatelineNormalization: cached.clientDatelineNormalization,
}, nil
}
}
@@ -2592,6 +2598,7 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
SettingKeyClaudeOAuthSystemPromptBlocks,
SettingKeyEnableAnthropicCacheTTL1hInjection,
SettingKeyRewriteMessageCacheControl,
SettingKeyEnableClientDatelineNormalization,
})
if err != nil {
slog.Warn("failed to get gateway forwarding settings", "error", err)
@@ -2602,9 +2609,10 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
claudeOAuthSystemPromptInjection: true,
anthropicCacheTTL1hInjection: false,
rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(),
clientDatelineNormalization: true,
expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(),
})
return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl()}, nil
return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(), clientDatelineNormalization: true}, nil
}
fp := true
if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" {
@@ -2623,6 +2631,10 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
if v, ok := values[SettingKeyRewriteMessageCacheControl]; ok && v != "" {
rewriteMessageCacheControl = v == "true"
}
clientDatelineNormalization := true
if v, ok := values[SettingKeyEnableClientDatelineNormalization]; ok && v != "" {
clientDatelineNormalization = v == "true"
}
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
fingerprintUnification: fp,
metadataPassthrough: mp,
@@ -2632,6 +2644,7 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
claudeOAuthSystemPromptBlocks: systemPromptBlocks,
anthropicCacheTTL1hInjection: cacheTTL1h,
rewriteMessageCacheControl: rewriteMessageCacheControl,
clientDatelineNormalization: clientDatelineNormalization,
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
})
return gatewayForwardingSettingsResult{
@@ -2643,12 +2656,13 @@ func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context)
claudeOAuthSystemPromptBlocks: systemPromptBlocks,
cacheTTL1h: cacheTTL1h,
rewriteMessageCacheControl: rewriteMessageCacheControl,
clientDatelineNormalization: clientDatelineNormalization,
}, nil
})
if r, ok := val.(gatewayForwardingSettingsResult); ok {
return r
}
return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true}
return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, clientDatelineNormalization: true}
}
// GetGatewayForwardingSettings returns cached gateway forwarding settings.
@@ -2669,6 +2683,12 @@ func (s *SettingService) IsRewriteMessageCacheControlEnabled(ctx context.Context
return s.getGatewayForwardingSettingsCached(ctx).rewriteMessageCacheControl
}
// IsClientDatelineNormalizationEnabled 检查是否启用 Anthropic OAuth/SetupToken 请求体
// 的客户端 dateline 归一化。默认开启。
func (s *SettingService) IsClientDatelineNormalizationEnabled(ctx context.Context) bool {
return s.getGatewayForwardingSettingsCached(ctx).clientDatelineNormalization
}
// GetClaudeOAuthSystemPromptInjectionSettings returns the Claude OAuth mimic
// system block switch, legacy custom expansion prompt, and configurable blocks JSON.
// Empty values mean use the built-in Claude Code default blocks.
@@ -3173,6 +3193,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyAllowUngroupedKeyScheduling: "false",
SettingKeyEnableAnthropicCacheTTL1hInjection: "false",
SettingKeyRewriteMessageCacheControl: strconv.FormatBool(s.defaultRewriteMessageCacheControl()),
SettingKeyEnableClientDatelineNormalization: "true",
SettingKeyAntigravityUserAgentVersion: "",
SettingKeyOpenAICodexUserAgent: "",
SettingPaymentVisibleMethodAlipaySource: "",
@@ -3711,6 +3732,11 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
} else {
result.RewriteMessageCacheControl = s.defaultRewriteMessageCacheControl()
}
if v, ok := settings[SettingKeyEnableClientDatelineNormalization]; ok && v != "" {
result.EnableClientDatelineNormalization = v == "true"
} else {
result.EnableClientDatelineNormalization = true
}
result.AntigravityUserAgentVersion = antigravity.NormalizeUserAgentVersion(settings[SettingKeyAntigravityUserAgentVersion])
result.OpenAICodexUserAgent = strings.TrimSpace(settings[SettingKeyOpenAICodexUserAgent])
// codex_cli_only 加固
+37 -1
View File
@@ -197,6 +197,7 @@ type SystemSettings struct {
ClaudeOAuthSystemPrompt string // Claude OAuth mimic 路径注入的通用扩展 system prompt;空值使用内置默认
ClaudeOAuthSystemPromptBlocks string // Claude OAuth mimic 路径注入的 system blocks JSON 配置;空值使用内置默认
EnableAnthropicCacheTTL1hInjection bool // 是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl(默认 false)
EnableClientDatelineNormalization bool // 是否对 Anthropic OAuth/SetupToken 请求体做客户端 dateline 归一化(默认 true)
RewriteMessageCacheControl bool // 是否改写 messages[*].content[*].cache_control(默认 false)
AntigravityUserAgentVersion string // Antigravity 上游 User-Agent 版本号;空值使用配置/默认值
OpenAICodexUserAgent string // OpenAI Codex 上游完整 User-Agent;空值使用内置默认
@@ -488,6 +489,23 @@ func DefaultRateLimit429CooldownSettings() *RateLimit429CooldownSettings {
}
// DefaultBetaPolicySettings 返回默认的 Beta 策略配置
//
// context-1m-2025-08-07 的默认策略:
// - 仅 claude-sonnet-5 及后续版本(如 claude-sonnet-5-*)在上游默认支持 1M 上下文。
// - Sonnet 4.x 及以下、Opus、Haiku 上游都不支持该 beta,透传上去会被上游 400 或降级。
// - 因此默认对 sonnet-5* 放行、其余全部过滤,与上游能力保持一致。
//
// 白名单需要覆盖每个上游路径的模型 ID 变形:
// - 直连 Anthropic API(OAuth mimic / API Key / SetupToken):模型保持客户端原样
// (如 "claude-sonnet-5"、"claude-sonnet-5-YYYYMMDD"、"claude-sonnet-5-thinking")。
// - Vertex AI:normalizeVertexAnthropicModelID 会把 "-YYYYMMDD" 后缀转成 "@YYYYMMDD"
// (如 "claude-sonnet-5@YYYYMMDD")。
// - AWS Bedrock:ResolveBedrockModelID 会输出带跨区域前缀的模型 ID
// (us./eu./apac./jp./au./us-gov./global. 或无前缀的 "anthropic." 形式)。
//
// 白名单只用后缀通配符(matchModelPattern 语义),因此每个路径都需要显式列出前缀。
// 精确匹配 "claude-sonnet-5" + 后缀 "-*" 与 "@*",可覆盖直连/Vertex 场景,同时避免误伤
// 未来可能出现的 "claude-sonnet-50" 或 "claude-sonnet-5.x" 之类的意外命名。
func DefaultBetaPolicySettings() *BetaPolicySettings {
return &BetaPolicySettings{
Rules: []BetaPolicyRule{
@@ -498,8 +516,26 @@ func DefaultBetaPolicySettings() *BetaPolicySettings {
},
{
BetaToken: "context-1m-2025-08-07",
Action: BetaPolicyActionFilter,
Action: BetaPolicyActionPass,
Scope: BetaPolicyScopeAll,
ModelWhitelist: []string{
// 直连 Anthropic API(客户端请求 model 原样)
"claude-sonnet-5",
"claude-sonnet-5-*",
// Vertex AI 走 normalizeVertexAnthropicModelID 后 "@YYYYMMDD" 格式
"claude-sonnet-5@*",
// AWS Bedrock cross-region inference profile
"us.anthropic.claude-sonnet-5*",
"eu.anthropic.claude-sonnet-5*",
"apac.anthropic.claude-sonnet-5*",
"jp.anthropic.claude-sonnet-5*",
"au.anthropic.claude-sonnet-5*",
"us-gov.anthropic.claude-sonnet-5*",
"global.anthropic.claude-sonnet-5*",
// AWS Bedrock 无 cross-region 前缀
"anthropic.claude-sonnet-5*",
},
FallbackAction: BetaPolicyActionFilter,
},
},
}
+68
View File
@@ -316,6 +316,25 @@ func (s *UsageService) GetUserUsageTrendByUserID(ctx context.Context, userID int
return trend, nil
}
// GetUsageTrendWithFilters returns trend data using the shared usage filter shape.
func (s *UsageService) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, filters usagestats.UsageLogFilters) ([]usagestats.TrendDataPoint, error) {
type usageTrendWithFiltersRepo interface {
GetUsageTrendWithUsageFilters(ctx context.Context, startTime, endTime time.Time, granularity string, filters usagestats.UsageLogFilters) ([]usagestats.TrendDataPoint, error)
}
if filterRepo, ok := s.usageRepo.(usageTrendWithFiltersRepo); ok {
trend, err := filterRepo.GetUsageTrendWithUsageFilters(ctx, startTime, endTime, granularity, filters)
if err != nil {
return nil, fmt.Errorf("get usage trend with filters: %w", err)
}
return trend, nil
}
trend, err := s.usageRepo.GetUsageTrendWithFilters(ctx, startTime, endTime, granularity, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType)
if err != nil {
return nil, fmt.Errorf("get usage trend with filters: %w", err)
}
return trend, nil
}
// GetUserModelStats returns per-user model usage stats.
func (s *UsageService) GetUserModelStats(ctx context.Context, userID int64, startTime, endTime time.Time) ([]usagestats.ModelStat, error) {
stats, err := s.usageRepo.GetUserModelStats(ctx, userID, startTime, endTime)
@@ -325,6 +344,55 @@ func (s *UsageService) GetUserModelStats(ctx context.Context, userID int64, star
return stats, nil
}
// GetModelStatsWithFiltersBySource returns model stats using the shared usage filter shape.
func (s *UsageService) GetModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, filters usagestats.UsageLogFilters, modelSource string) ([]usagestats.ModelStat, error) {
normalizedSource := usagestats.NormalizeModelSource(modelSource)
type modelStatsWithUsageFiltersRepo interface {
GetModelStatsWithUsageFiltersBySource(ctx context.Context, startTime, endTime time.Time, filters usagestats.UsageLogFilters, source string) ([]usagestats.ModelStat, error)
}
if filterRepo, ok := s.usageRepo.(modelStatsWithUsageFiltersRepo); ok {
stats, err := filterRepo.GetModelStatsWithUsageFiltersBySource(ctx, startTime, endTime, filters, normalizedSource)
if err != nil {
return nil, fmt.Errorf("get model stats with filters by source: %w", err)
}
return stats, nil
}
type modelStatsBySourceRepo interface {
GetModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8, source string) ([]usagestats.ModelStat, error)
}
if sourceRepo, ok := s.usageRepo.(modelStatsBySourceRepo); ok {
stats, err := sourceRepo.GetModelStatsWithFiltersBySource(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.RequestType, filters.Stream, filters.BillingType, normalizedSource)
if err != nil {
return nil, fmt.Errorf("get model stats with filters by source: %w", err)
}
return stats, nil
}
stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.RequestType, filters.Stream, filters.BillingType)
if err != nil {
return nil, fmt.Errorf("get model stats with filters: %w", err)
}
return stats, nil
}
// GetGroupStatsWithFilters returns group stats using the shared usage filter shape.
func (s *UsageService) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, filters usagestats.UsageLogFilters) ([]usagestats.GroupStat, error) {
type groupStatsWithUsageFiltersRepo interface {
GetGroupStatsWithUsageFilters(ctx context.Context, startTime, endTime time.Time, filters usagestats.UsageLogFilters) ([]usagestats.GroupStat, error)
}
if filterRepo, ok := s.usageRepo.(groupStatsWithUsageFiltersRepo); ok {
stats, err := filterRepo.GetGroupStatsWithUsageFilters(ctx, startTime, endTime, filters)
if err != nil {
return nil, fmt.Errorf("get group stats with filters: %w", err)
}
return stats, nil
}
stats, err := s.usageRepo.GetGroupStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.RequestType, filters.Stream, filters.BillingType)
if err != nil {
return nil, fmt.Errorf("get group stats with filters: %w", err)
}
return stats, nil
}
// GetAPIKeyModelStats returns per-model usage stats for a specific API Key.
func (s *UsageService) GetAPIKeyModelStats(ctx context.Context, apiKeyID int64, startTime, endTime time.Time) ([]usagestats.ModelStat, error) {
stats, err := s.usageRepo.GetModelStatsWithFilters(ctx, startTime, endTime, 0, apiKeyID, 0, 0, nil, nil, nil)
+2
View File
@@ -561,6 +561,7 @@ export interface SystemSettings {
claude_oauth_system_prompt_blocks: string;
enable_anthropic_cache_ttl_1h_injection: boolean;
rewrite_message_cache_control: boolean;
enable_client_dateline_normalization: boolean;
antigravity_user_agent_version: string;
openai_codex_user_agent: string;
// codex_cli_only 加固
@@ -811,6 +812,7 @@ export interface UpdateSettingsRequest {
claude_oauth_system_prompt_blocks?: string;
enable_anthropic_cache_ttl_1h_injection?: boolean;
rewrite_message_cache_control?: boolean;
enable_client_dateline_normalization?: boolean;
antigravity_user_agent_version?: string;
openai_codex_user_agent?: string;
// codex_cli_only 加固
+52 -6
View File
@@ -11,6 +11,8 @@ import type {
PaginatedResponse,
TrendDataPoint,
ModelStat,
GroupStat,
UsageRequestType,
UserErrorRequest,
UserErrorRequestDetail,
UserErrorListParams
@@ -57,6 +59,14 @@ export interface TrendParams {
start_date?: string
end_date?: string
granularity?: 'day' | 'hour'
api_key_id?: number
model?: string
group_id?: number
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
billing_mode?: string | null
timezone?: string
}
export interface TrendResponse {
@@ -91,6 +101,22 @@ export interface ApiKeyDailyUsageResponse {
end_date: string
}
export interface UsageDashboardSnapshotV2Params extends TrendParams {
include_trend?: boolean
include_model_stats?: boolean
include_group_stats?: boolean
}
export interface UsageDashboardSnapshotV2Response {
generated_at: string
start_date: string
end_date: string
granularity: string
trend?: TrendDataPoint[]
models?: ModelStat[]
groups?: GroupStat[]
}
/**
* List usage logs with optional filters
* @param page - Page number (default: 1)
@@ -141,10 +167,12 @@ export async function query(
* @returns Usage statistics
*/
export async function getStats(
period: string = 'today',
paramsOrPeriod: (UsageQueryParams & { period?: string; timezone?: string }) | string = 'today',
apiKeyId?: number
): Promise<UsageStatsResponse> {
const params: Record<string, unknown> = { period }
const params: Record<string, unknown> = typeof paramsOrPeriod === 'string'
? { period: paramsOrPeriod }
: { ...paramsOrPeriod }
if (apiKeyId !== undefined) {
params.api_key_id = apiKeyId
@@ -251,6 +279,15 @@ export async function getDashboardTrend(params?: TrendParams): Promise<TrendResp
export async function getDashboardModels(params?: {
start_date?: string
end_date?: string
api_key_id?: number
model?: string
model_source?: 'requested'
group_id?: number
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
billing_mode?: string | null
timezone?: string
}): Promise<ModelStatsResponse> {
const { data } = await apiClient.get<ModelStatsResponse>('/usage/dashboard/models', { params })
return data
@@ -273,6 +310,16 @@ export async function getMyApiKeyDailyUsage(
return data
}
export async function getDashboardSnapshotV2(
params?: UsageDashboardSnapshotV2Params
): Promise<UsageDashboardSnapshotV2Response> {
const { data } = await apiClient.get<UsageDashboardSnapshotV2Response>(
'/usage/dashboard/snapshot-v2',
{ params }
)
return data
}
export interface BatchApiKeyUsageStats {
api_key_id: number
today_actual_cost: number
@@ -308,11 +355,9 @@ export async function getDashboardApiKeysUsage(
}
export async function listMyErrorRequests(
params: UserErrorListParams,
config: { signal?: AbortSignal } = {}
params: UserErrorListParams
): Promise<PaginatedResponse<UserErrorRequest>> {
const { data } = await apiClient.get<PaginatedResponse<UserErrorRequest>>('/usage/errors', {
...config,
params
})
return data
@@ -335,10 +380,11 @@ export const usageAPI = {
getDashboardTrend,
getDashboardModels,
getMyApiKeyDailyUsage,
getDashboardSnapshotV2,
getDashboardApiKeysUsage,
// Error requests
listMyErrorRequests,
getMyErrorDetail,
getMyErrorDetail
}
export default usageAPI
@@ -560,18 +560,18 @@
Google One
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
个人账号,享受 Google One 订阅配额
{{ t('admin.accounts.gemini.oauthType.googleOneDesc') }}
</span>
<div class="mt-2 flex flex-wrap gap-1">
<span
class="rounded bg-purple-100 px-2 py-0.5 text-[10px] font-semibold text-purple-700 dark:bg-purple-900/40 dark:text-purple-300"
>
推荐个人用户
{{ t('admin.accounts.gemini.oauthType.badges.individuals') }}
</span>
<span
class="rounded bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
>
无需 GCP
{{ t('admin.accounts.gemini.oauthType.badges.noGcp') }}
</span>
</div>
</div>
@@ -603,10 +603,10 @@
GCP Code Assist
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
企业级,需要 GCP 项目
{{ t('admin.accounts.gemini.oauthType.codeAssistDesc') }}
</span>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
需要激活 GCP 项目并绑定信用卡
{{ t('admin.accounts.gemini.oauthType.codeAssistRequirement') }}
<a
:href="geminiHelpLinks.gcpProject"
class="ml-1 text-blue-600 hover:underline dark:text-blue-400"
@@ -620,12 +620,12 @@
<span
class="rounded bg-blue-100 px-2 py-0.5 text-[10px] font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"
>
企业用户
{{ t('admin.accounts.gemini.oauthType.badges.enterprise') }}
</span>
<span
class="rounded bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
>
高并发
{{ t('admin.accounts.gemini.oauthType.badges.highConcurrency') }}
</span>
</div>
</div>
@@ -648,7 +648,13 @@
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
<span>{{ showAdvancedOAuth ? '隐藏' : '显示' }}高级选项(自建 OAuth Client)</span>
<span>
{{
showAdvancedOAuth
? t('admin.accounts.gemini.oauthType.hideAdvanced')
: t('admin.accounts.gemini.oauthType.showAdvanced')
}}
</span>
</button>
</div>
@@ -3072,7 +3078,7 @@
rel="noreferrer"
class="text-sm text-blue-600 hover:underline dark:text-blue-400"
>
修改归属地
{{ t('admin.accounts.gemini.setupGuide.links.countryChange') }}
</a>
<span class="text-gray-400">·</span>
<a
@@ -56,7 +56,7 @@
class="text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">{{
t('admin.accounts.oauth.openai.mobileRefreshTokenAuth', '手动输入 Mobile RT')
t('admin.accounts.oauth.openai.mobileRefreshTokenAuth')
}}</span>
</label>
<label v-if="showSessionTokenOption" class="flex cursor-pointer items-center gap-2">
@@ -78,7 +78,7 @@
class="text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">{{
t('admin.accounts.oauth.openai.accessTokenAuth', '手动输入 AT')
t('admin.accounts.oauth.openai.accessTokenAuth')
}}</span>
</label>
<label v-if="showCodexSessionImportOption" class="flex cursor-pointer items-center gap-2">
@@ -106,7 +106,7 @@
<span class="text-xs text-gray-500 dark:text-gray-400">
{{
geminiOAuthType === 'google_one'
? '个人账号'
? t('admin.accounts.gemini.oauthType.googleOneDesc')
: geminiOAuthType === 'code_assist'
? t('admin.accounts.gemini.oauthType.builtInDesc')
: t('admin.accounts.gemini.oauthType.customDesc')
@@ -110,7 +110,7 @@
<span class="text-xs text-gray-500 dark:text-gray-400">
{{
geminiOAuthType === 'google_one'
? '个人账号'
? t('admin.accounts.gemini.oauthType.googleOneDesc')
: geminiOAuthType === 'code_assist'
? t('admin.accounts.gemini.oauthType.builtInDesc')
: t('admin.accounts.gemini.oauthType.customDesc')
@@ -4,32 +4,32 @@
<!-- Token mode: context range + prices ($/MTok) -->
<template v-if="mode === 'token'">
<div class="w-20">
<label class="text-xs text-gray-400">Min</label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.minTokens') }}</label>
<input :value="interval.min_tokens" @input="emitField('min_tokens', toInt(($event.target as HTMLInputElement).value))"
type="number" min="0" class="input mt-0.5 text-xs" />
</div>
<div class="w-20">
<label class="text-xs text-gray-400">Max <span class="text-gray-300">(含)</span></label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.maxTokens') }} <span class="text-gray-300">{{ t('admin.channels.form.inclusive') }}</span></label>
<input :value="interval.max_tokens ?? ''" @input="emitField('max_tokens', toIntOrNull(($event.target as HTMLInputElement).value))"
type="number" min="0" class="input mt-0.5 text-xs" :placeholder="'∞'" />
</div>
<div class="flex-1">
<label class="text-xs text-gray-400">{{ t('admin.channels.form.inputPrice', '输入') }} <span v-if="isEmpty" class="text-red-500">*</span> <span class="text-gray-300">$/M</span></label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.inputPrice') }} <span v-if="isEmpty" class="text-red-500">*</span> <span class="text-gray-300">$/M</span></label>
<input :value="interval.input_price" @input="emitField('input_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
</div>
<div class="flex-1">
<label class="text-xs text-gray-400">{{ t('admin.channels.form.outputPrice', '输出') }} <span v-if="isEmpty" class="text-red-500">*</span> <span class="text-gray-300">$/M</span></label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.outputPrice') }} <span v-if="isEmpty" class="text-red-500">*</span> <span class="text-gray-300">$/M</span></label>
<input :value="interval.output_price" @input="emitField('output_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
</div>
<div class="flex-1">
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheWritePrice', '缓存W') }} <span class="text-gray-300">$/M</span></label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheWritePriceShort') }} <span class="text-gray-300">$/M</span></label>
<input :value="interval.cache_write_price" @input="emitField('cache_write_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
</div>
<div class="flex-1">
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheReadPrice', '缓存R') }} <span class="text-gray-300">$/M</span></label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheReadPriceShort') }} <span class="text-gray-300">$/M</span></label>
<input :value="interval.cache_read_price" @input="emitField('cache_read_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
</div>
@@ -39,23 +39,23 @@
<template v-else>
<div class="w-24">
<label class="text-xs text-gray-400">
{{ mode === 'image' ? t('admin.channels.form.resolution', '分辨率') : t('admin.channels.form.tierLabel', '层级') }}
{{ mode === 'image' ? t('admin.channels.form.resolution') : t('admin.channels.form.tierLabel') }}
</label>
<input :value="interval.tier_label" @input="emitField('tier_label', ($event.target as HTMLInputElement).value)"
type="text" class="input mt-0.5 text-xs" :placeholder="mode === 'image' ? '1K / 2K / 4K' : ''" />
</div>
<div class="w-20">
<label class="text-xs text-gray-400">Min</label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.minTokens') }}</label>
<input :value="interval.min_tokens" @input="emitField('min_tokens', toInt(($event.target as HTMLInputElement).value))"
type="number" min="0" class="input mt-0.5 text-xs" />
</div>
<div class="w-20">
<label class="text-xs text-gray-400">Max <span class="text-gray-300">(含)</span></label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.maxTokens') }} <span class="text-gray-300">{{ t('admin.channels.form.inclusive') }}</span></label>
<input :value="interval.max_tokens ?? ''" @input="emitField('max_tokens', toIntOrNull(($event.target as HTMLInputElement).value))"
type="number" min="0" class="input mt-0.5 text-xs" :placeholder="'∞'" />
</div>
<div class="flex-1">
<label class="text-xs text-gray-400">{{ t('admin.channels.form.perRequestPrice', '单次价格') }} <span v-if="isEmpty" class="text-red-500">*</span> <span class="text-gray-300">$</span></label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.perRequestPrice') }} <span v-if="isEmpty" class="text-red-500">*</span> <span class="text-gray-300">$</span></label>
<input :value="interval.per_request_price" @input="emitField('per_request_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-xs" />
</div>
@@ -34,7 +34,7 @@
v-if="entry.models.length === 0"
class="text-xs italic text-gray-400"
>
{{ t('admin.channels.form.noModels', '未添加模型') }}
{{ t('admin.channels.form.noModels') }}
</span>
</div>
@@ -48,7 +48,7 @@
<!-- Expanded: show the label "Pricing Entry" or similar -->
<div v-else class="flex-1 text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.pricingEntry', '定价配置') }}
{{ t('admin.channels.form.pricingEntry') }}
</div>
<!-- Remove button (always visible, stop propagation) -->
@@ -71,19 +71,19 @@
<div class="mt-3 flex items-start gap-2">
<div class="flex-1">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.models', '模型列表') }} <span class="text-red-500">*</span>
{{ t('admin.channels.form.models') }} <span class="text-red-500">*</span>
</label>
<ModelTagInput
:models="entry.models"
:platform="props.platform"
@update:models="onModelsUpdate($event)"
:placeholder="t('admin.channels.form.modelsPlaceholder', '输入模型名后按回车添加,支持通配符 *')"
:placeholder="t('admin.channels.form.modelsPlaceholder')"
class="mt-1"
/>
</div>
<div class="w-40">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.billingMode', '计费模式') }}
{{ t('admin.channels.form.billingMode') }}
</label>
<Select
:modelValue="entry.billing_mode"
@@ -98,34 +98,34 @@
<div v-if="entry.billing_mode === 'token'">
<!-- Default prices (fallback when no interval matches) -->
<label class="mt-3 block text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.defaultPrices', '默认价格(未命中区间时使用)') }}
{{ t('admin.channels.form.defaultPrices') }}
<span class="ml-1 font-normal text-gray-400">$/MTok</span>
</label>
<div class="mt-1 grid grid-cols-2 gap-2 sm:grid-cols-5">
<div>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.inputPrice', '输入') }}</label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.inputPrice') }}</label>
<input :value="entry.input_price" @input="emitField('input_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder')" />
</div>
<div>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.outputPrice', '输出') }}</label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.outputPrice') }}</label>
<input :value="entry.output_price" @input="emitField('output_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder')" />
</div>
<div>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheWritePrice', '缓存写入') }}</label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheWritePrice') }}</label>
<input :value="entry.cache_write_price" @input="emitField('cache_write_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder')" />
</div>
<div>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheReadPrice', '缓存读取') }}</label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.cacheReadPrice') }}</label>
<input :value="entry.cache_read_price" @input="emitField('cache_read_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder')" />
</div>
<div>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.imageTokenPrice', '图片输出') }}</label>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.imageTokenPrice') }}</label>
<input :value="entry.image_output_price" @input="emitField('image_output_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder')" />
</div>
</div>
@@ -133,11 +133,11 @@
<div class="mt-3">
<div class="flex items-center justify-between">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.intervals', '上下文区间定价(可选)') }}
{{ t('admin.channels.form.intervals') }}
<span class="ml-1 font-normal text-gray-400">(min, max]</span>
</label>
<button type="button" @click="addInterval" class="text-xs text-primary-600 hover:text-primary-700">
+ {{ t('admin.channels.form.addInterval', '添加区间') }}
+ {{ t('admin.channels.form.addInterval') }}
</button>
</div>
<div v-if="entry.intervals && entry.intervals.length > 0" class="mt-2 space-y-2">
@@ -157,21 +157,21 @@
<div v-else-if="entry.billing_mode === 'per_request'">
<!-- Default per-request price -->
<label class="mt-3 block text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.defaultPerRequestPrice', '默认单次价格(未命中层级时使用)') }}
{{ t('admin.channels.form.defaultPerRequestPrice') }}
<span class="ml-1 font-normal text-gray-400">$</span>
</label>
<div class="mt-1 w-48">
<input :value="entry.per_request_price" @input="emitField('per_request_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
type="number" step="any" min="0" class="input text-sm" :placeholder="t('admin.channels.form.pricePlaceholder')" />
</div>
<!-- Tiers -->
<div class="mt-3 flex items-center justify-between">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.requestTiers', '按次计费层级') }}
{{ t('admin.channels.form.requestTiers') }}
</label>
<button type="button" @click="addInterval" class="text-xs text-primary-600 hover:text-primary-700">
+ {{ t('admin.channels.form.addTier', '添加层级') }}
+ {{ t('admin.channels.form.addTier') }}
</button>
</div>
<div v-if="entry.intervals && entry.intervals.length > 0" class="mt-2 space-y-2">
@@ -185,7 +185,7 @@
/>
</div>
<div v-else class="mt-2 rounded border border-dashed border-gray-300 p-3 text-center text-xs text-gray-400 dark:border-dark-500">
{{ t('admin.channels.form.noTiersYet', '暂无层级,点击添加配置按次计费价格') }}
{{ t('admin.channels.form.noTiersYet') }}
</div>
</div>
@@ -193,21 +193,21 @@
<div v-else-if="entry.billing_mode === 'image'">
<!-- Default image price (per-request, same as per_request mode) -->
<label class="mt-3 block text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.defaultImagePrice', '默认图片价格(未命中层级时使用)') }}
{{ t('admin.channels.form.defaultImagePrice') }}
<span class="ml-1 font-normal text-gray-400">$</span>
</label>
<div class="mt-1 w-48">
<input :value="entry.per_request_price" @input="emitField('per_request_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input text-sm" :placeholder="t('admin.channels.form.pricePlaceholder', '默认')" />
type="number" step="any" min="0" class="input text-sm" :placeholder="t('admin.channels.form.pricePlaceholder')" />
</div>
<!-- Image tiers -->
<div class="mt-3 flex items-center justify-between">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.imageTiers', '图片计费层级(按次)') }}
{{ t('admin.channels.form.imageTiers') }}
</label>
<button type="button" @click="addImageTier" class="text-xs text-primary-600 hover:text-primary-700">
+ {{ t('admin.channels.form.addTier', '添加层级') }}
+ {{ t('admin.channels.form.addTier') }}
</button>
</div>
<div v-if="entry.intervals && entry.intervals.length > 0" class="mt-2 space-y-2">
@@ -254,9 +254,9 @@ const emit = defineEmits<{
const collapsed = ref(props.entry.models.length > 0)
const billingModeOptions = computed(() => [
{ value: 'token', label: 'Token' },
{ value: 'per_request', label: t('admin.channels.billingMode.perRequest', '按次') },
{ value: 'image', label: t('admin.channels.billingMode.image', '图片(按次)') }
{ value: 'token', label: t('admin.channels.billingMode.token') },
{ value: 'per_request', label: t('admin.channels.billingMode.perRequest') },
{ value: 'image', label: t('admin.channels.billingMode.image') }
])
const billingModeLabel = computed(() => {
@@ -16,6 +16,10 @@ function makeInterval(over: Partial<IntervalFormEntry>): IntervalFormEntry {
}
}
function t(key: string, params?: Record<string, unknown>): string {
return `${key}${params ? ` ${JSON.stringify(params)}` : ''}`
}
describe('validateIntervals', () => {
describe('token mode', () => {
it('rejects unbounded interval that is not last', () => {
@@ -23,7 +27,7 @@ describe('validateIntervals', () => {
makeInterval({ min_tokens: 0, max_tokens: null, input_price: 1, output_price: 1 }),
makeInterval({ min_tokens: 200000, max_tokens: 500000, input_price: 2, output_price: 2 }),
]
expect(validateIntervals(intervals, 'token')).toMatch(/无上限/)
expect(validateIntervals(intervals, 'token', t)).toContain('unboundedLast')
})
it('accepts unbounded interval at the end', () => {
@@ -31,7 +35,7 @@ describe('validateIntervals', () => {
makeInterval({ min_tokens: 0, max_tokens: 200000, input_price: 1, output_price: 1 }),
makeInterval({ min_tokens: 200000, max_tokens: null, input_price: 2, output_price: 2 }),
]
expect(validateIntervals(intervals, 'token')).toBeNull()
expect(validateIntervals(intervals, 'token', t)).toBeNull()
})
it('rejects overlapping intervals', () => {
@@ -39,15 +43,15 @@ describe('validateIntervals', () => {
makeInterval({ min_tokens: 0, max_tokens: 250000, input_price: 1, output_price: 1 }),
makeInterval({ min_tokens: 200000, max_tokens: 500000, input_price: 2, output_price: 2 }),
]
expect(validateIntervals(intervals, 'token')).toMatch(/重叠/)
expect(validateIntervals(intervals, 'token', t)).toContain('overlap')
})
it('defaults mode to token when omitted', () => {
it('rejects unbounded interval in token mode', () => {
const intervals: IntervalFormEntry[] = [
makeInterval({ min_tokens: 0, max_tokens: null, input_price: 1, output_price: 1 }),
makeInterval({ min_tokens: 100, max_tokens: 200, input_price: 2, output_price: 2 }),
]
expect(validateIntervals(intervals)).toMatch(/无上限/)
expect(validateIntervals(intervals, 'token', t)).toContain('unboundedLast')
})
})
@@ -58,22 +62,22 @@ describe('validateIntervals', () => {
makeInterval({ tier_label: '2K', per_request_price: 0.06 }),
makeInterval({ tier_label: '4K', per_request_price: 0.08 }),
]
expect(validateIntervals(intervals, 'image')).toBeNull()
expect(validateIntervals(intervals, 'per_request')).toBeNull()
expect(validateIntervals(intervals, 'image', t)).toBeNull()
expect(validateIntervals(intervals, 'per_request', t)).toBeNull()
})
it('still rejects negative prices', () => {
const intervals: IntervalFormEntry[] = [
makeInterval({ tier_label: '1K', per_request_price: -1 }),
]
expect(validateIntervals(intervals, 'image')).toMatch(/不能为负数/)
expect(validateIntervals(intervals, 'image', t)).toContain('negativePrice')
})
it('still rejects max <= min on a single tier', () => {
const intervals: IntervalFormEntry[] = [
makeInterval({ tier_label: '1K', min_tokens: 100, max_tokens: 50, per_request_price: 0.04 }),
]
expect(validateIntervals(intervals, 'image')).toMatch(/必须大于/)
expect(validateIntervals(intervals, 'image', t)).toContain('maxGreaterThanMin')
})
})
})
+61 -19
View File
@@ -1,5 +1,7 @@
import type { BillingMode, PricingInterval } from '@/api/admin/channels'
type TranslateFn = (key: string, params?: Record<string, unknown>) => string
export interface IntervalFormEntry {
min_tokens: number
max_tokens: number | null
@@ -124,7 +126,8 @@ export function findModelConflict(models: string[]): [string, string] | null {
*/
export function validateIntervals(
intervals: IntervalFormEntry[],
mode: BillingMode = 'token',
mode: BillingMode,
t: TranslateFn,
): string | null {
if (!intervals || intervals.length === 0) return null
@@ -132,58 +135,97 @@ export function validateIntervals(
const sorted = [...intervals].sort((a, b) => a.min_tokens - b.min_tokens)
for (let i = 0; i < sorted.length; i++) {
const err = validateSingleInterval(sorted[i], i)
const err = validateSingleInterval(sorted[i], i, t)
if (err) return err
}
// per_request / image 模式按 tier_label 匹配,不做 token 区间重叠校验
if (mode !== 'token') return null
return checkIntervalOverlap(sorted)
return checkIntervalOverlap(sorted, t)
}
function validateSingleInterval(iv: IntervalFormEntry, idx: number): string | null {
function intervalValidationMessage(
t: TranslateFn,
key: string,
params: Record<string, unknown>,
): string {
return t(`admin.channels.intervalValidation.${key}`, params)
}
function intervalPriceLabel(t: TranslateFn, key: string): string {
return t(`admin.channels.intervalValidation.price.${key}`)
}
function validateSingleInterval(iv: IntervalFormEntry, idx: number, t: TranslateFn): string | null {
const index = idx + 1
if (iv.min_tokens < 0) {
return `区间 #${idx + 1}: 最小 token 数 (${iv.min_tokens}) 不能为负数`
return intervalValidationMessage(
t,
'negativeMin',
{ index, value: iv.min_tokens },
)
}
if (iv.max_tokens != null) {
if (iv.max_tokens <= 0) {
return `区间 #${idx + 1}: 最大 token 数 (${iv.max_tokens}) 必须大于 0`
return intervalValidationMessage(
t,
'maxPositive',
{ index, value: iv.max_tokens },
)
}
if (iv.max_tokens <= iv.min_tokens) {
return `区间 #${idx + 1}: 最大 token 数 (${iv.max_tokens}) 必须大于最小 token 数 (${iv.min_tokens})`
return intervalValidationMessage(
t,
'maxGreaterThanMin',
{ index, max: iv.max_tokens, min: iv.min_tokens },
)
}
}
return validateIntervalPrices(iv, idx)
return validateIntervalPrices(iv, idx, t)
}
function validateIntervalPrices(iv: IntervalFormEntry, idx: number): string | null {
function validateIntervalPrices(iv: IntervalFormEntry, idx: number, t: TranslateFn): string | null {
const index = idx + 1
const prices: [string, number | string | null][] = [
['输入价格', iv.input_price],
['输出价格', iv.output_price],
['缓存写入价格', iv.cache_write_price],
['缓存读取价格', iv.cache_read_price],
['单次价格', iv.per_request_price],
['inputPrice', iv.input_price],
['outputPrice', iv.output_price],
['cacheWritePrice', iv.cache_write_price],
['cacheReadPrice', iv.cache_read_price],
['perRequestPrice', iv.per_request_price],
]
for (const [name, val] of prices) {
for (const [key, val] of prices) {
if (val != null && val !== '' && Number(val) < 0) {
return `区间 #${idx + 1}: ${name}不能为负数`
const field = intervalPriceLabel(t, key)
return intervalValidationMessage(
t,
'negativePrice',
{ index, field },
)
}
}
return null
}
function checkIntervalOverlap(sorted: IntervalFormEntry[]): string | null {
function checkIntervalOverlap(sorted: IntervalFormEntry[], t: TranslateFn): string | null {
for (let i = 0; i < sorted.length; i++) {
// 无上限区间必须是最后一个
if (sorted[i].max_tokens == null && i < sorted.length - 1) {
return `区间 #${i + 1}: 无上限区间(最大 token 数为空)只能是最后一个`
return intervalValidationMessage(
t,
'unboundedLast',
{ index: i + 1 },
)
}
if (i === 0) continue
const prev = sorted[i - 1]
// (min, max] 语义:前一个区间上界 > 当前区间下界则重叠
if (prev.max_tokens == null || prev.max_tokens > sorted[i].min_tokens) {
const prevMax = prev.max_tokens == null ? '∞' : String(prev.max_tokens)
return `区间 #${i} 和 #${i + 1} 重叠:前一个区间上界 (${prevMax}) 大于当前区间下界 (${sorted[i].min_tokens})`
return intervalValidationMessage(
t,
'overlap',
{ previousIndex: i, currentIndex: i + 1, previousMax: prevMax, currentMin: sorted[i].min_tokens },
)
}
}
return null
@@ -68,9 +68,14 @@
${{ (stats?.total_actual_cost || 0).toFixed(4) }}
</p>
<p class="text-xs text-gray-400">
<span class="text-orange-500">{{ t('usage.accountCost') }} ${{ (stats?.total_account_cost || 0).toFixed(4) }}</span>
<span> · </span>
<span>{{ t('usage.standardCost') }} ${{ (stats?.total_cost || 0).toFixed(4) }}</span>
<template v-if="showAccountCost && totalAccountCost != null">
<span class="text-orange-500">{{ t('usage.accountCost') }} ${{ totalAccountCost.toFixed(4) }}</span>
<span> · </span>
</template>
<span>
{{ t('usage.standardCost') }}
<span :class="{ 'line-through': strikeStandardCost }">${{ (stats?.total_cost || 0).toFixed(4) }}</span>
</span>
</p>
</div>
</div>
@@ -84,14 +89,30 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { AdminUsageStatsResponse } from '@/api/admin/usage'
import type { UsageStatsResponse } from '@/types'
import Icon from '@/components/icons/Icon.vue'
defineProps<{ stats: AdminUsageStatsResponse | null }>()
const props = withDefaults(defineProps<{
stats: (AdminUsageStatsResponse | UsageStatsResponse) | null
showAccountCost?: boolean
strikeStandardCost?: boolean
}>(), {
showAccountCost: true,
strikeStandardCost: false,
})
const { t } = useI18n()
const totalAccountCost = computed(() => {
const stats = props.stats as (AdminUsageStatsResponse & { total_account_cost?: number }) | null
return stats?.total_account_cost ?? null
})
const showAccountCost = computed(() => props.showAccountCost)
const strikeStandardCost = computed(() => props.strikeStandardCost)
const formatDuration = (ms: number) =>
ms < 1000 ? `${ms.toFixed(0)}ms` : `${(ms / 1000).toFixed(2)}s`
@@ -68,7 +68,7 @@
<span class="font-medium text-gray-500 dark:text-gray-400">{{ t('usage.inbound') }}:</span>
<span class="ml-1">{{ row.inbound_endpoint?.trim() || '-' }}</span>
</div>
<div class="break-all text-gray-700 dark:text-gray-300">
<div v-if="showUpstreamEndpoint" class="break-all text-gray-700 dark:text-gray-300">
<span class="font-medium text-gray-500 dark:text-gray-400">{{ t('usage.upstream') }}:</span>
<span class="ml-1">{{ row.upstream_endpoint?.trim() || '-' }}</span>
</div>
@@ -163,7 +163,7 @@
</div>
</div>
</div>
<div v-if="row.account_rate_multiplier != null" class="mt-0.5 text-[11px] text-orange-500 dark:text-orange-400">
<div v-if="showAccountBilling && row.account_rate_multiplier != null" class="mt-0.5 text-[11px] text-orange-500 dark:text-orange-400">
A ${{ accountBilled(row).toFixed(6) }}
</div>
</div>
@@ -380,20 +380,22 @@
<span class="font-semibold text-green-400">${{ tooltipData?.actual_cost?.toFixed(6) || '0.000000' }}</span>
</div>
<!-- Account billing (separated from user billing) -->
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span>
<span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.account_rate_multiplier ?? 1) }}x</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="font-semibold text-green-400">
${{ accountBilled({
total_cost: tooltipData?.total_cost,
account_stats_cost: tooltipData?.account_stats_cost,
account_rate_multiplier: tooltipData?.account_rate_multiplier,
}).toFixed(6) }}
</span>
</div>
<template v-if="showAccountBilling">
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.accountMultiplier') }}</span>
<span class="font-semibold text-blue-400">{{ formatMultiplier(tooltipData?.account_rate_multiplier ?? 1) }}x</span>
</div>
<div class="flex items-center justify-between gap-6">
<span class="text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="font-semibold text-green-400">
${{ accountBilled({
total_cost: tooltipData?.total_cost,
account_stats_cost: tooltipData?.account_stats_cost,
account_rate_multiplier: tooltipData?.account_rate_multiplier,
}).toFixed(6) }}
</span>
</div>
</template>
</div>
<div class="absolute right-full top-1/2 h-0 w-0 -translate-y-1/2 border-b-[6px] border-r-[6px] border-t-[6px] border-b-transparent border-r-gray-900 border-t-transparent dark:border-r-gray-800"></div>
</div>
@@ -449,19 +451,25 @@ interface Props {
serverSideSort?: boolean
defaultSortKey?: string
defaultSortOrder?: 'asc' | 'desc'
showAccountBilling?: boolean
showUpstreamEndpoint?: boolean
}
withDefaults(defineProps<Props>(), {
const props = withDefaults(defineProps<Props>(), {
loading: false,
serverSideSort: false,
defaultSortKey: '',
defaultSortOrder: 'asc'
defaultSortOrder: 'asc',
showAccountBilling: true,
showUpstreamEndpoint: true
})
defineEmits<{
userClick: [userID: number, email?: string]
sort: [key: string, order: 'asc' | 'desc']
}>()
const { t } = useI18n()
const showAccountBilling = props.showAccountBilling
const showUpstreamEndpoint = props.showUpstreamEndpoint
// Tooltip state - cost
const tooltipVisible = ref(false)
@@ -19,7 +19,7 @@
font-weight="bold"
fill="white"
text-anchor="middle"
>钉</text>
>D</text>
</svg>
{{ t('auth.dingtalk.signIn') }}
</button>
@@ -17,7 +17,7 @@
for="login-agreement-consent"
class="cursor-pointer text-gray-700 dark:text-dark-200"
>
我已阅读并同意
{{ t('legal.loginAgreementPrompt.checkboxPrefix') }}
</label>
<template v-for="(doc, index) in documents" :key="doc.id || doc.title">
<RouterLink
@@ -28,7 +28,7 @@
>
{{ doc.title }}
</RouterLink>
<span v-if="index < documents.length - 1">、</span>
<span v-if="index < documents.length - 1">{{ t('legal.loginAgreementPrompt.documentSeparator') }}</span>
</template>
</p>
</div>
@@ -42,9 +42,9 @@
<div class="flex items-start gap-3">
<Icon name="shield" size="sm" class="mt-0.5 flex-shrink-0 text-primary-600 dark:text-primary-300" />
<div class="min-w-0 flex-1">
<p class="font-medium">继续登录前需要先同意最新条款。</p>
<p class="font-medium">{{ t('legal.loginAgreementPrompt.noticeTitle') }}</p>
<p class="mt-1 text-primary-700 dark:text-primary-200/80">
未同意前,账号密码输入和快捷登录会保持禁用。
{{ t('legal.loginAgreementPrompt.noticeDescription') }}
</p>
</div>
<button
@@ -52,7 +52,7 @@
class="flex-shrink-0 rounded-md bg-primary-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-primary-700"
@click="emit('open')"
>
查看条款
{{ t('legal.loginAgreementPrompt.viewTerms') }}
</button>
</div>
</div>
@@ -72,7 +72,7 @@
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-xl font-bold tracking-normal text-gray-950 dark:text-white">
条款更新通知
{{ t('legal.loginAgreementPrompt.dialogTitle') }}
</h2>
<span
v-if="updatedAt"
@@ -82,7 +82,11 @@
</span>
</div>
<p class="mt-2 text-sm leading-6 text-gray-600 dark:text-dark-300">
我们的服务条款已于 {{ updatedAt || '近期' }} 更新。在继续使用服务之前,请仔细阅读并同意以下条款。
{{
t('legal.loginAgreementPrompt.dialogDescription', {
date: updatedAt || t('legal.loginAgreementPrompt.recently'),
})
}}
</p>
</div>
</div>
@@ -90,7 +94,7 @@
<div class="max-h-[58vh] overflow-y-auto px-6 py-5">
<div class="mb-3 flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-gray-900 dark:text-white">相关文档</p>
<p class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('legal.loginAgreementPrompt.relatedDocuments') }}</p>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<RouterLink
@@ -121,14 +125,14 @@
class="rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-semibold text-gray-700 transition hover:bg-gray-100 dark:border-dark-700 dark:bg-dark-800 dark:text-dark-200 dark:hover:bg-dark-700"
@click="emit('reject')"
>
拒绝
{{ t('legal.loginAgreementPrompt.reject') }}
</button>
<button
type="button"
class="rounded-xl bg-primary-600 px-4 py-3 text-sm font-semibold text-white shadow-sm shadow-primary-600/20 transition hover:bg-primary-700"
@click="emit('accept')"
>
同意并继续
{{ t('legal.loginAgreementPrompt.accept') }}
</button>
</div>
</div>
@@ -140,9 +144,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
import type { LoginAgreementDocument } from '@/types'
const { t } = useI18n()
const props = withDefaults(defineProps<{
accepted: boolean
documents: LoginAgreementDocument[]
@@ -184,10 +191,21 @@ function handleCheckboxChange(event: Event): void {
}
function documentIcon(index: number, title: string): 'document' | 'shield' | 'globe' | 'cog' {
if (title.includes('政策') || title.includes('隐私')) {
const normalizedTitle = title.toLowerCase()
if (
normalizedTitle.includes('policy') ||
normalizedTitle.includes('privacy') ||
title.includes('政策') ||
title.includes('隐私')
) {
return 'shield'
}
if (title.includes('国家') || title.includes('地区')) {
if (
normalizedTitle.includes('country') ||
normalizedTitle.includes('region') ||
title.includes('国家') ||
title.includes('地区')
) {
return 'globe'
}
if (index === 3) {
@@ -89,13 +89,14 @@
<tbody>
<template v-for="item in displayEndpointStats" :key="item.endpoint">
<tr
class="border-t border-gray-100 cursor-pointer transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-dark-700/40"
@click="toggleBreakdown(item.endpoint)"
class="border-t border-gray-100 transition-colors dark:border-gray-700"
:class="enableBreakdown ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-700/40' : ''"
@click="enableBreakdown && toggleBreakdown(item.endpoint)"
>
<td class="max-w-[180px] truncate py-1.5 font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" :title="item.endpoint">
<td class="max-w-[180px] truncate py-1.5 font-medium" :class="enableBreakdown ? 'text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300' : 'text-gray-900 dark:text-white'" :title="item.endpoint">
<span class="inline-flex items-center gap-1">
<svg v-if="expandedKey === item.endpoint" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
<svg v-else class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
<svg v-if="enableBreakdown && expandedKey === item.endpoint" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
<svg v-else-if="enableBreakdown" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
{{ item.endpoint }}
</span>
</td>
@@ -159,6 +160,7 @@ const props = withDefaults(
source?: EndpointSource
showMetricToggle?: boolean
showSourceToggle?: boolean
enableBreakdown?: boolean
startDate?: string
endDate?: string
filters?: Record<string, any>
@@ -171,7 +173,8 @@ const props = withDefaults(
metric: 'tokens',
source: 'inbound',
showMetricToggle: false,
showSourceToggle: false
showSourceToggle: false,
enableBreakdown: true
}
)
@@ -45,7 +45,7 @@
<th class="pb-2 text-right">{{ t('admin.dashboard.requests') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.tokens') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.actual') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.accountCost') }}</th>
<th v-if="showAccountCost" class="pb-2 text-right">{{ t('admin.dashboard.accountCost') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.standard') }}</th>
</tr>
</thead>
@@ -53,17 +53,17 @@
<template v-for="group in displayGroupStats" :key="group.group_id">
<tr
class="border-t border-gray-100 transition-colors dark:border-gray-700"
:class="group.group_id > 0 ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-700/40' : ''"
@click="group.group_id > 0 && toggleBreakdown('group', group.group_id)"
:class="enableBreakdown && group.group_id > 0 ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-700/40' : ''"
@click="enableBreakdown && group.group_id > 0 && toggleBreakdown('group', group.group_id)"
>
<td
class="max-w-[100px] truncate py-1.5 font-medium"
:class="group.group_id > 0 ? 'text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300' : 'text-gray-900 dark:text-white'"
:class="enableBreakdown && group.group_id > 0 ? 'text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300' : 'text-gray-900 dark:text-white'"
:title="group.group_name || String(group.group_id)"
>
<span class="inline-flex items-center gap-1">
<svg v-if="group.group_id > 0 && expandedKey === `group-${group.group_id}`" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
<svg v-else-if="group.group_id > 0" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
<svg v-if="enableBreakdown && group.group_id > 0 && expandedKey === `group-${group.group_id}`" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
<svg v-else-if="enableBreakdown && group.group_id > 0" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
{{ group.group_name || t('admin.dashboard.noGroup') }}
</span>
</td>
@@ -76,7 +76,7 @@
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
${{ formatCost(group.actual_cost) }}
</td>
<td class="py-1.5 text-right text-orange-500 dark:text-orange-400">
<td v-if="showAccountCost" class="py-1.5 text-right text-orange-500 dark:text-orange-400">
${{ formatCost(group.account_cost) }}
</td>
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
@@ -85,10 +85,11 @@
</tr>
<!-- User breakdown sub-rows -->
<tr v-if="expandedKey === `group-${group.group_id}`">
<td colspan="6" class="p-0">
<td :colspan="distributionColspan" class="p-0">
<UserBreakdownSubTable
:items="breakdownItems"
:loading="breakdownLoading"
:show-account-cost="showAccountCost"
/>
</td>
</tr>
@@ -127,6 +128,8 @@ const props = withDefaults(defineProps<{
loading?: boolean
metric?: DistributionMetric
showMetricToggle?: boolean
enableBreakdown?: boolean
showAccountCost?: boolean
startDate?: string
endDate?: string
filters?: Record<string, any>
@@ -134,6 +137,8 @@ const props = withDefaults(defineProps<{
loading: false,
metric: 'tokens',
showMetricToggle: false,
enableBreakdown: true,
showAccountCost: true,
})
const emit = defineEmits<{
@@ -143,6 +148,8 @@ const emit = defineEmits<{
const expandedKey = ref<string | null>(null)
const breakdownItems = ref<UserBreakdownItem[]>([])
const breakdownLoading = ref(false)
const showAccountCost = computed(() => props.showAccountCost)
const distributionColspan = computed(() => showAccountCost.value ? 6 : 5)
const toggleBreakdown = async (type: string, id: number | string) => {
const key = `${type}-${id}`
@@ -114,23 +114,25 @@
<th class="pb-2 text-right">{{ t('admin.dashboard.requests') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.tokens') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.actual') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.accountCost') }}</th>
<th v-if="showAccountCost" class="pb-2 text-right">{{ t('admin.dashboard.accountCost') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.standard') }}</th>
</tr>
</thead>
<tbody>
<template v-for="model in displayModelStats" :key="model.model">
<tr
class="border-t border-gray-100 cursor-pointer transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-dark-700/40"
@click="toggleBreakdown('model', model.model)"
class="border-t border-gray-100 transition-colors dark:border-gray-700"
:class="enableBreakdown ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-dark-700/40' : ''"
@click="enableBreakdown && toggleBreakdown('model', model.model)"
>
<td
class="max-w-[100px] truncate py-1.5 font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
class="max-w-[100px] truncate py-1.5 font-medium"
:class="enableBreakdown ? 'text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300' : 'text-gray-900 dark:text-white'"
:title="model.model"
>
<span class="inline-flex items-center gap-1">
<svg v-if="expandedKey === `model-${model.model}`" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
<svg v-else class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
<svg v-if="enableBreakdown && expandedKey === `model-${model.model}`" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
<svg v-else-if="enableBreakdown" class="h-3 w-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
{{ model.model }}
</span>
</td>
@@ -143,7 +145,7 @@
<td class="py-1.5 text-right text-green-600 dark:text-green-400">
${{ formatCost(model.actual_cost) }}
</td>
<td class="py-1.5 text-right text-orange-500 dark:text-orange-400">
<td v-if="showAccountCost" class="py-1.5 text-right text-orange-500 dark:text-orange-400">
${{ formatCost(model.account_cost) }}
</td>
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">
@@ -151,10 +153,11 @@
</td>
</tr>
<tr v-if="expandedKey === `model-${model.model}`">
<td colspan="6" class="p-0">
<td :colspan="distributionColspan" class="p-0">
<UserBreakdownSubTable
:items="breakdownItems"
:loading="breakdownLoading"
:show-account-cost="showAccountCost"
/>
</td>
</tr>
@@ -270,6 +273,8 @@ const props = withDefaults(defineProps<{
metric?: DistributionMetric
showSourceToggle?: boolean
showMetricToggle?: boolean
enableBreakdown?: boolean
showAccountCost?: boolean
rankingLoading?: boolean
rankingError?: boolean
startDate?: string
@@ -288,6 +293,8 @@ const props = withDefaults(defineProps<{
metric: 'tokens',
showSourceToggle: false,
showMetricToggle: false,
enableBreakdown: true,
showAccountCost: true,
rankingLoading: false,
rankingError: false
})
@@ -328,6 +335,8 @@ const emit = defineEmits<{
}>()
const enableRankingView = computed(() => props.enableRankingView)
const showAccountCost = computed(() => props.showAccountCost)
const distributionColspan = computed(() => showAccountCost.value ? 6 : 5)
const activeView = ref<'model_distribution' | 'spending_ranking'>('model_distribution')
const chartColors = [
@@ -25,7 +25,7 @@
<td class="py-1 text-right text-green-600 dark:text-green-400">
${{ formatCost(user.actual_cost) }}
</td>
<td class="py-1 text-right text-orange-500 dark:text-orange-400">
<td v-if="showAccountCost" class="py-1 text-right text-orange-500 dark:text-orange-400">
${{ formatCost(user.account_cost) }}
</td>
<td class="py-1 pr-1 text-right text-gray-400 dark:text-gray-500">
@@ -38,16 +38,23 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import type { UserBreakdownItem } from '@/types'
const { t } = useI18n()
defineProps<{
const props = withDefaults(defineProps<{
items: UserBreakdownItem[]
loading?: boolean
}>()
showAccountCost?: boolean
}>(), {
loading: false,
showAccountCost: true,
})
const showAccountCost = computed(() => props.showAccountCost)
const formatTokens = (value: number): string => {
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(2)}B`
@@ -56,7 +63,8 @@ const formatTokens = (value: number): string => {
return value.toLocaleString()
}
const formatCost = (value: number): string => {
const formatCost = (value: number | undefined | null): string => {
if (value == null) return '0.0000'
if (value >= 1000) return (value / 1000).toFixed(2) + 'K'
if (value >= 1) return value.toFixed(2)
if (value >= 0.01) return value.toFixed(3)
@@ -10,6 +10,7 @@ const messages: Record<string, string> = {
'admin.dashboard.requests': 'Requests',
'admin.dashboard.tokens': 'Tokens',
'admin.dashboard.actual': 'Actual',
'admin.dashboard.accountCost': 'Account Cost',
'admin.dashboard.standard': 'Standard',
'admin.dashboard.metricTokens': 'By Tokens',
'admin.dashboard.metricActualCost': 'By Actual Cost',
@@ -111,4 +112,22 @@ describe('GroupDistributionChart', () => {
})
expect(label).toBe('group-b: $0.900 (90.0%)')
})
it('can hide account cost for user usage stats without account_cost', () => {
const wrapper = mount(GroupDistributionChart, {
props: {
groupStats,
showAccountCost: false,
},
global: {
stubs: {
LoadingSpinner: true,
},
},
})
expect(wrapper.text()).not.toContain('Account Cost')
expect(wrapper.findAll('thead th')).toHaveLength(5)
expect(wrapper.findAll('tbody tr')[0].findAll('td')).toHaveLength(5)
})
})
@@ -17,6 +17,7 @@ const messages: Record<string, string> = {
'admin.dashboard.requests': 'Requests',
'admin.dashboard.tokens': 'Tokens',
'admin.dashboard.actual': 'Actual',
'admin.dashboard.accountCost': 'Account Cost',
'admin.dashboard.standard': 'Standard',
'admin.dashboard.metricTokens': 'By Tokens',
'admin.dashboard.metricActualCost': 'By Actual Cost',
@@ -126,6 +127,24 @@ describe('ModelDistributionChart', () => {
expect(label).toBe('model-b: $1.40 (87.5%)')
})
it('can hide account cost for user usage stats without account_cost', () => {
const wrapper = mount(ModelDistributionChart, {
props: {
modelStats,
showAccountCost: false,
},
global: {
stubs: {
LoadingSpinner: true,
},
},
})
expect(wrapper.text()).not.toContain('Account Cost')
expect(wrapper.findAll('thead th')).toHaveLength(5)
expect(wrapper.findAll('tbody tr')[0].findAll('td')).toHaveLength(5)
})
it('renders Others in the spending ranking table and uses a dedicated chart color', async () => {
const wrapper = mount(ModelDistributionChart, {
props: {
@@ -31,7 +31,7 @@
<span class="font-bold">{{ userRateMultiplier }}x</span>
</template>
<template v-else>
{{ rateMultiplier }}x 倍率
{{ rateMultiplier }}x {{ t('admin.groups.rateLabel') }}
</template>
</span>
<!-- Checkmark -->
@@ -51,9 +51,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import GroupBadge from './GroupBadge.vue'
import type { SubscriptionType, GroupPlatform } from '@/types'
const { t } = useI18n()
interface Props {
name: string
platform: GroupPlatform
+11 -8
View File
@@ -31,6 +31,7 @@ export const claudeModels = [
'claude-opus-4-7',
'claude-opus-4-8',
'claude-sonnet-4-6',
'claude-sonnet-5',
'claude-fable-5'
]
@@ -243,6 +244,7 @@ export const allModels = allModelsList.map(m => ({ value: m, label: m }))
const anthropicPresetMappings = [
{ label: 'Fable 5', from: 'claude-fable-5', to: 'claude-fable-5', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' },
{ label: 'Sonnet 5', from: 'claude-sonnet-5', to: 'claude-sonnet-5', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: 'Sonnet 4', from: 'claude-sonnet-4-20250514', to: 'claude-sonnet-4-20250514', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5-20250929', to: 'claude-sonnet-4-5-20250929', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: 'Sonnet 4.6', from: 'claude-sonnet-4-6', to: 'claude-sonnet-4-6', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
@@ -303,16 +305,16 @@ const antigravityPresetMappings = [
{ label: '3-Pro-Preview→3.1-Pro-High', from: 'gemini-3-pro-preview', to: 'gemini-3.1-pro-high', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' },
{ label: '3-Pro-High→3.1-Pro-High', from: 'gemini-3-pro-high', to: 'gemini-3.1-pro-high', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
{ label: '3-Pro-Low→3.1-Pro-Low', from: 'gemini-3-pro-low', to: 'gemini-3.1-pro-low', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' },
{ label: '3.1-Pro-High透传', from: 'gemini-3.1-pro-high', to: 'gemini-3.1-pro-high', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
{ label: '3.1-Pro-Low透传', from: 'gemini-3.1-pro-low', to: 'gemini-3.1-pro-low', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' },
{ label: '3.1-Pro-High passthrough', from: 'gemini-3.1-pro-high', to: 'gemini-3.1-pro-high', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
{ label: '3.1-Pro-Low passthrough', from: 'gemini-3.1-pro-low', to: 'gemini-3.1-pro-low', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' },
// Gemini 通配符映射
{ label: 'Gemini 3→Flash', from: 'gemini-3*', to: 'gemini-3-flash', color: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400' },
{ label: 'Gemini 2.5→Flash', from: 'gemini-2.5*', to: 'gemini-2.5-flash', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
{ label: '2.5-Flash-Image透传', from: 'gemini-2.5-flash-image', to: 'gemini-2.5-flash-image', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
{ label: '3.1-Flash-Image透传', from: 'gemini-3.1-flash-image', to: 'gemini-3.1-flash-image', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
{ label: '2.5-Flash-Image passthrough', from: 'gemini-2.5-flash-image', to: 'gemini-2.5-flash-image', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
{ label: '3.1-Flash-Image passthrough', from: 'gemini-3.1-flash-image', to: 'gemini-3.1-flash-image', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
{ label: '3-Pro-Image→3.1', from: 'gemini-3-pro-image', to: 'gemini-3.1-flash-image', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
{ label: '3-Flash透传', from: 'gemini-3-flash', to: 'gemini-3-flash', color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400' },
{ label: '2.5-Flash-Lite透传', from: 'gemini-2.5-flash-lite', to: 'gemini-2.5-flash-lite', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
{ label: '3-Flash passthrough', from: 'gemini-3-flash', to: 'gemini-3-flash', color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400' },
{ label: '2.5-Flash-Lite passthrough', from: 'gemini-2.5-flash-lite', to: 'gemini-2.5-flash-lite', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
// 精确映射
{ label: 'Sonnet 4.6', from: 'claude-sonnet-4-6', to: 'claude-sonnet-4-6', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5', to: 'claude-sonnet-4-5', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
@@ -328,6 +330,7 @@ const bedrockPresetMappings = [
{ label: 'Opus 4.6', from: 'claude-opus-4-6', to: 'us.anthropic.claude-opus-4-6-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
{ label: 'Opus 4.7', from: 'claude-opus-4-7', to: 'us.anthropic.claude-opus-4-7-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
{ label: 'Opus 4.8', from: 'claude-opus-4-8', to: 'us.anthropic.claude-opus-4-8-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
{ label: 'Sonnet 5', from: 'claude-sonnet-5', to: 'us.anthropic.claude-sonnet-5-v1', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: 'Sonnet 4.6', from: 'claude-sonnet-4-6', to: 'us.anthropic.claude-sonnet-4-6', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
{ label: 'Opus 4.5', from: 'claude-opus-4-5-thinking', to: 'us.anthropic.claude-opus-4-5-20251101-v1:0', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5', to: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
@@ -483,12 +486,12 @@ export function buildModelMappingObject(
if (!from || !to) continue
// 校验通配符格式:* 只能放在末尾
if (!isValidWildcardPattern(from)) {
console.warn(`[buildModelMappingObject] 无效的通配符格式,跳过: ${from}`)
console.warn(`[buildModelMappingObject] Invalid wildcard pattern, skipped: ${from}`)
continue
}
// to 不允许包含通配符
if (to.includes('*')) {
console.warn(`[buildModelMappingObject] 目标模型不能包含通配符,跳过: ${from} -> ${to}`)
console.warn(`[buildModelMappingObject] Target model cannot contain a wildcard, skipped: ${from} -> ${to}`)
continue
}
mapping[from] = to
+114 -2
View File
@@ -369,7 +369,24 @@ export default {
updatedAt: 'Updated: {date}',
empty: 'No content',
loginAgreement: 'Login Agreement',
adminCompliance: 'Deployment and Operation Compliance Commitment'
adminCompliance: 'Deployment and Operation Compliance Commitment',
loginAgreementPrompt: {
checkboxPrefix: 'I have read and agree to ',
documentSeparator: ', ',
noticeTitle: 'Accept the latest terms before continuing.',
noticeDescription: 'Account/password login and quick sign-in stay disabled until you accept.',
viewTerms: 'View terms',
dialogTitle: 'Terms Update Notice',
dialogDescription: 'Our service terms were updated on {date}. Please read and accept the following terms before continuing.',
recently: 'recently',
relatedDocuments: 'Related documents',
reject: 'Reject',
accept: 'Accept and continue',
loginRejectedWarning: 'Account/password login and quick sign-in are disabled until you accept the latest terms.',
loginRequiredWarning: 'Please read and accept the latest terms before logging in.',
registerRejectedWarning: 'Registration and quick sign-in are disabled until you accept the latest terms.',
registerRequiredWarning: 'Please read and accept the latest terms before registering.'
}
},
// Navigation
@@ -587,6 +604,7 @@ export default {
},
linuxdoCallbackPageTitle: 'LinuxDo Sign-In Callback',
dingtalkCallbackPageTitle: 'DingTalk Sign-In Callback',
dingtalkProviderName: 'DingTalk',
oidcCallbackPageTitle: 'OIDC Sign-In Callback',
oauthCallbackPageTitle: 'OAuth Callback',
wechatProviderName: 'WeChat',
@@ -2151,6 +2169,15 @@ export default {
accountsUnit: '',
rateAndAccounts: '{rate}x rate · {count} accounts',
accountsCount: '{count} accounts',
rateLabel: 'rate',
accountFilters: {
title: 'Account Filter Controls',
oauthOnly: 'Only allow OAuth accounts',
oauthOnlyEnabled: 'Enabled — API Key accounts will be excluded',
privacySetOnly: 'Only allow accounts with privacy protection set',
privacySetOnlyEnabled: 'Enabled — accounts with unset Privacy will be excluded',
disabled: 'Disabled'
},
form: {
name: 'Name',
description: 'Description',
@@ -2256,7 +2283,10 @@ export default {
title: 'Custom /v1/models Model List',
hint: 'Only changes the /v1/models response. Whitelist model calls and account routing are unchanged.',
loading: 'Loading model list...',
empty: 'No displayable models'
empty: 'No displayable models',
selectedSummary: 'Selected {selected} / {total}',
selectAll: 'Select all',
invertSelection: 'Invert'
},
claudeCode: {
title: 'Claude Code Client Restriction',
@@ -2412,6 +2442,21 @@ export default {
duplicateModels: 'Model "{0}" appears in multiple pricing entries',
modelConflict: "Model patterns '{model1}' and '{model2}' conflict: overlapping match range. Model names are matched case-insensitively, so an existing entry already covers all case variants — no need to add the variant separately.",
mappingConflict: "Mapping source patterns '{model1}' and '{model2}' conflict: overlapping match range. Source patterns are matched case-insensitively, so an existing entry already covers all case variants.",
intervalValidation: {
negativeMin: 'Interval #{index}: minimum token count ({value}) cannot be negative',
maxPositive: 'Interval #{index}: maximum token count ({value}) must be greater than 0',
maxGreaterThanMin: 'Interval #{index}: maximum token count ({max}) must be greater than minimum token count ({min})',
negativePrice: 'Interval #{index}: {field} cannot be negative',
unboundedLast: 'Interval #{index}: an unbounded interval (empty maximum token count) must be last',
overlap: 'Intervals #{previousIndex} and #{currentIndex} overlap: previous upper bound ({previousMax}) is greater than current lower bound ({currentMin})',
price: {
inputPrice: 'input price',
outputPrice: 'output price',
cacheWritePrice: 'cache write price',
cacheReadPrice: 'cache read price',
perRequestPrice: 'per-request price'
}
},
deleteConfirm: 'Are you sure you want to delete channel "{name}"? This cannot be undone.',
columns: {
name: 'Name',
@@ -2446,10 +2491,15 @@ export default {
outputPrice: 'Output',
cacheWritePrice: 'Cache Write',
cacheReadPrice: 'Cache Read',
cacheWritePriceShort: 'Cache W',
cacheReadPriceShort: 'Cache R',
imageTokenPrice: 'Image Output',
imageOutputPrice: 'Image Output Price',
pricePlaceholder: 'Default',
intervals: 'Context Intervals (optional)',
minTokens: 'Min',
maxTokens: 'Max',
inclusive: '(inclusive)',
addInterval: 'Add Interval',
requestTiers: 'Request Tiers',
imageTiers: 'Image Tiers (Per Request)',
@@ -2631,6 +2681,7 @@ export default {
queueSize: 'Async Queue Size',
blockStatus: 'Block HTTP Status',
blockMessage: 'Custom Block Message',
defaultBlockMessage: 'Content audit matched a risk rule. Please adjust your input and try again.',
emailOnHit: 'Email on Hit',
emailOnHitHint: 'When enabled, send a risk-control email on every hit; auto-ban notices are always sent.',
autoBan: 'Auto Ban User',
@@ -3988,6 +4039,11 @@ export default {
builtInTitle: 'Built-in OAuth (Gemini CLI / Code Assist)',
builtInDesc: 'Uses Google built-in client ID. No admin configuration required.',
builtInRequirement: 'Requires a GCP project and Project ID.',
googleOneDesc: 'Personal account with Google One subscription quota',
codeAssistDesc: 'Enterprise-grade, requires a GCP project',
codeAssistRequirement: 'Requires an active GCP project with billing enabled',
showAdvanced: 'Show advanced options (custom OAuth Client)',
hideAdvanced: 'Hide advanced options (custom OAuth Client)',
gcpProjectLink: 'Create project',
customTitle: 'Custom OAuth (AI Studio OAuth)',
customDesc: 'Uses admin-configured OAuth client for org management.',
@@ -3995,6 +4051,9 @@ export default {
badges: {
recommended: 'Recommended',
highConcurrency: 'High concurrency',
individuals: 'Recommended for individuals',
noGcp: 'No GCP required',
enterprise: 'Enterprise users',
noAdmin: 'No admin setup',
orgManaged: 'Org managed',
adminRequired: 'Admin required'
@@ -4014,6 +4073,7 @@ export default {
},
links: {
countryCheck: 'Check country association',
countryChange: 'Change country association',
geminiWebActivation: 'Activate Gemini Web',
gcpProject: 'Open GCP Console'
}
@@ -4758,6 +4818,52 @@ export default {
noData: 'No data.',
loadingText: 'loading',
ready: 'ready',
autoRefreshRemaining: 'Remaining {seconds}s',
systemLogs: {
title: 'System Logs',
description: 'Newest logs are shown first. Filter, search, and clean up by condition.',
queue: 'Queue',
written: 'Written',
dropped: 'Dropped',
failed: 'Failed',
runtimeConfig: 'Runtime Log Configuration (applies immediately)',
all: 'All',
level: 'Level',
stacktraceThreshold: 'Stacktrace threshold',
samplingInitial: 'Sampling initial',
samplingThereafter: 'Sampling thereafter',
retentionDays: 'Retention days',
caller: 'caller',
sampling: 'sampling',
saveAndApply: 'Save and apply',
resetDefaults: 'Reset defaults',
latestWriteError: 'Latest write error:',
timeRange: 'Time range',
startTime: 'Start time (optional)',
endTime: 'End time (optional)',
component: 'Component',
componentPlaceholder: 'e.g. http.access',
keyId: 'KEY ID',
platform: 'Platform',
model: 'Model',
keyword: 'Keyword',
keywordPlaceholder: 'message/request_id',
search: 'Search',
cleanCurrentFilters: 'Clean current filters',
refreshHealth: 'Refresh health',
empty: 'No system logs',
time: 'Time',
logDetails: 'Log Details',
loadFailed: 'Failed to load system logs',
runtimeConfigActive: 'Runtime log configuration is active',
runtimeConfigSaveFailed: 'Failed to save log configuration',
resetRuntimeConfigConfirm: 'Reset to startup configuration (env/yaml) and apply immediately?',
runtimeConfigReset: 'Reset to startup log configuration',
runtimeConfigResetFailed: 'Failed to reset log configuration',
cleanupConfirm: 'Clean up system logs matching the current filters? This cannot be undone.',
cleanupSuccess: 'Cleanup complete. Deleted {count} log entries.',
cleanupFailed: 'Failed to clean up system logs'
},
requestsTotal: 'Requests (total)',
slaScope: 'SLA scope:',
tokens: 'Tokens',
@@ -5827,6 +5933,8 @@ export default {
anthropicCacheTTL1hInjectionHint: 'When enabled, existing ephemeral cache_control blocks in Anthropic OAuth/Setup Token request bodies are forced to 1h; response usage is billed back as 5m by default, with account-level TTL billing override taking priority.',
rewriteMessageCacheControl: 'Rewrite Message Cache Breakpoints',
rewriteMessageCacheControlHint: 'Default off: preserve client cache_control on message content blocks. When enabled, client breakpoints are stripped and proxy breakpoints are injected for clients that do not manage caching themselves.',
clientDatelineNormalization: 'Client Dateline Normalization',
clientDatelineNormalizationHint: 'Default on. Rewrites the "Today\'s date is …" sentence in Anthropic OAuth/Setup Token requests back to a canonical ASCII apostrophe and hyphen date format, erasing steganographic fingerprint bits some clients inject when they detect a non-official base URL. Applies to system prompts and <system-reminder> blocks only; API-Key accounts are unaffected.',
antigravityUserAgentVersion: 'Antigravity UA Version',
antigravityUserAgentVersionPlaceholder: '1.23.2',
antigravityUserAgentVersionHint: 'Leave empty to use ANTIGRAVITY_USER_AGENT_VERSION or the built-in default 1.23.2; when set, the admin setting takes precedence.',
@@ -6779,6 +6887,10 @@ export default {
notFoundDesc: 'This custom page does not exist or has been removed.',
notConfiguredTitle: 'Page URL not configured',
notConfiguredDesc: 'The URL for this custom page has not been properly configured.',
tableOfContents: 'Contents',
copyCode: 'Copy',
copiedCode: 'Copied',
copyCodeFailed: 'Failed'
},
// Announcements Page
+114 -2
View File
@@ -369,7 +369,24 @@ export default {
updatedAt: '更新日期:{date}',
empty: '暂无正文内容',
loginAgreement: '登录条款',
adminCompliance: '部署与运营合规承诺'
adminCompliance: '部署与运营合规承诺',
loginAgreementPrompt: {
checkboxPrefix: '我已阅读并同意',
documentSeparator: '、',
noticeTitle: '继续登录前需要先同意最新条款。',
noticeDescription: '未同意前,账号密码输入和快捷登录会保持禁用。',
viewTerms: '查看条款',
dialogTitle: '条款更新通知',
dialogDescription: '我们的服务条款已于 {date} 更新。在继续使用服务之前,请仔细阅读并同意以下条款。',
recently: '近期',
relatedDocuments: '相关文档',
reject: '拒绝',
accept: '同意并继续',
loginRejectedWarning: '未同意最新条款前,无法输入账号密码或使用快捷登录。',
loginRequiredWarning: '请先阅读并同意最新条款后再登录。',
registerRejectedWarning: '未同意最新条款前,无法注册或使用快捷登录。',
registerRequiredWarning: '请先阅读并同意最新条款后再注册。'
}
},
// Navigation
@@ -585,6 +602,7 @@ export default {
},
linuxdoCallbackPageTitle: 'LinuxDo 登录回调',
dingtalkCallbackPageTitle: '钉钉登录回调',
dingtalkProviderName: '钉钉',
oidcCallbackPageTitle: 'OIDC 登录回调',
oauthCallbackPageTitle: 'OAuth 回调',
wechatProviderName: '微信',
@@ -2267,6 +2285,15 @@ export default {
public: '公开',
rateAndAccounts: '{rate}x 费率 · {count} 个账号',
accountsCount: '{count} 个账号',
rateLabel: '倍率',
accountFilters: {
title: '账号过滤控制',
oauthOnly: '仅允许 OAuth 账号',
oauthOnlyEnabled: '已启用 — 排除 API Key 类型账号',
privacySetOnly: '仅允许隐私保护已设置的账号',
privacySetOnlyEnabled: '已启用 — Privacy 未设置的账号将被排除',
disabled: '未启用'
},
enterGroupName: '请输入分组名称',
optionalDescription: '可选描述',
platformHint: '选择此分组关联的平台',
@@ -2338,7 +2365,10 @@ export default {
title: '自定义 /v1/models 模型列表',
hint: '仅影响 /v1/models 展示结果,不影响白名单模型调用和账号调度。',
loading: '正在加载模型列表...',
empty: '暂无可展示模型'
empty: '暂无可展示模型',
selectedSummary: '已选 {selected} / {total}',
selectAll: '全选',
invertSelection: '反选'
},
claudeCode: {
title: 'Claude Code 客户端限制',
@@ -2488,6 +2518,21 @@ export default {
duplicateModels: '模型「{0}」在多个定价条目中重复',
modelConflict: "模型模式 '{model1}' 和 '{model2}' 冲突:匹配范围重叠。模型名称按大小写不敏感匹配,已有条目已覆盖其所有大小写变体,无需重复添加。",
mappingConflict: "模型映射源 '{model1}' 和 '{model2}' 冲突:匹配范围重叠。源模式按大小写不敏感匹配,已有条目已覆盖其所有大小写变体。",
intervalValidation: {
negativeMin: '区间 #{index}:最小 token 数({value})不能为负数',
maxPositive: '区间 #{index}:最大 token 数({value})必须大于 0',
maxGreaterThanMin: '区间 #{index}:最大 token 数({max})必须大于最小 token 数({min})',
negativePrice: '区间 #{index}:{field}不能为负数',
unboundedLast: '区间 #{index}:无上限区间(最大 token 数为空)必须放在最后',
overlap: '区间 #{previousIndex} 和 #{currentIndex} 重叠:前一个上界({previousMax})大于当前下界({currentMin})',
price: {
inputPrice: '输入价格',
outputPrice: '输出价格',
cacheWritePrice: '缓存写入价格',
cacheReadPrice: '缓存读取价格',
perRequestPrice: '单次价格'
}
},
deleteConfirm: '确定要删除渠道「{name}」吗?此操作不可撤销。',
columns: {
name: '名称',
@@ -2522,10 +2567,15 @@ export default {
outputPrice: '输出',
cacheWritePrice: '缓存写入',
cacheReadPrice: '缓存读取',
cacheWritePriceShort: '缓存写',
cacheReadPriceShort: '缓存读',
imageTokenPrice: '图片输出',
imageOutputPrice: '图片输出价格',
pricePlaceholder: '默认',
intervals: '上下文区间定价(可选)',
minTokens: '最小',
maxTokens: '最大',
inclusive: '(含)',
addInterval: '添加区间',
requestTiers: '按次计费层级',
imageTiers: '图片计费层级(按次)',
@@ -2707,6 +2757,7 @@ export default {
queueSize: '异步队列大小',
blockStatus: '拦截 HTTP 状态码',
blockMessage: '自定义拦截提示',
defaultBlockMessage: '内容审计命中风险规则,请调整输入后重试',
emailOnHit: '命中后发送邮件',
emailOnHitHint: '开启后每次达到阈值都会向用户发送风控提醒邮件;自动封禁通知始终发送。',
autoBan: '自动封禁用户',
@@ -4140,6 +4191,11 @@ export default {
builtInTitle: '内置授权(Gemini CLI / Code Assist)',
builtInDesc: '使用 Google 内置客户端 ID,无需管理员配置。',
builtInRequirement: '需要 GCP 项目并填写 Project ID。',
googleOneDesc: '个人账号,享受 Google One 订阅配额',
codeAssistDesc: '企业级,需要 GCP 项目',
codeAssistRequirement: '需要激活 GCP 项目并绑定信用卡',
showAdvanced: '显示高级选项(自建 OAuth Client)',
hideAdvanced: '隐藏高级选项(自建 OAuth Client)',
gcpProjectLink: '创建项目',
customTitle: '自定义授权(AI Studio OAuth)',
customDesc: '使用管理员预设的 OAuth 客户端,适合组织管理。',
@@ -4147,6 +4203,9 @@ export default {
badges: {
recommended: '推荐',
highConcurrency: '高并发',
individuals: '推荐个人用户',
noGcp: '无需 GCP',
enterprise: '企业用户',
noAdmin: '无需管理员配置',
orgManaged: '组织管理',
adminRequired: '需要管理员'
@@ -4166,6 +4225,7 @@ export default {
},
links: {
countryCheck: '检查归属地',
countryChange: '修改归属地',
geminiWebActivation: '激活 Gemini Web',
gcpProject: '打开 GCP 控制台'
}
@@ -4912,6 +4972,52 @@ export default {
noData: '暂无数据',
loadingText: '加载中...',
ready: '就绪',
autoRefreshRemaining: '剩余 {seconds}s',
systemLogs: {
title: '系统日志',
description: '优先显示最新日志,可按条件筛选、搜索和清理。',
queue: '队列',
written: '已写入',
dropped: '已丢弃',
failed: '写入失败',
runtimeConfig: '运行时日志配置(立即生效)',
all: '全部',
level: '级别',
stacktraceThreshold: '堆栈阈值',
samplingInitial: '采样初始条数',
samplingThereafter: '后续采样间隔',
retentionDays: '保留天数',
caller: '调用方',
sampling: '采样',
saveAndApply: '保存并应用',
resetDefaults: '重置默认值',
latestWriteError: '最近写入错误:',
timeRange: '时间范围',
startTime: '开始时间(可选)',
endTime: '结束时间(可选)',
component: '组件',
componentPlaceholder: '例如 http.access',
keyId: 'KEY ID',
platform: '平台',
model: '模型',
keyword: '关键词',
keywordPlaceholder: 'message/request_id',
search: '搜索',
cleanCurrentFilters: '清理当前筛选结果',
refreshHealth: '刷新健康状态',
empty: '暂无系统日志',
time: '时间',
logDetails: '日志详情',
loadFailed: '加载系统日志失败',
runtimeConfigActive: '运行时日志配置已生效',
runtimeConfigSaveFailed: '保存日志配置失败',
resetRuntimeConfigConfirm: '确定要重置为启动配置(env/yaml)并立即应用吗?',
runtimeConfigReset: '已重置为启动日志配置',
runtimeConfigResetFailed: '重置日志配置失败',
cleanupConfirm: '确定要清理匹配当前筛选条件的系统日志吗?此操作不可撤销。',
cleanupSuccess: '清理完成,已删除 {count} 条日志。',
cleanupFailed: '清理系统日志失败'
},
requestsTotal: '请求(总计)',
slaScope: 'SLA 范围:',
tokens: 'Token数',
@@ -5981,6 +6087,8 @@ export default {
anthropicCacheTTL1hInjectionHint: '开启后,对 Anthropic OAuth/Setup Token 请求体中已有的 ephemeral 缓存块强制写入 1h;响应 usage 默认按 5m 回写计费,账号级 TTL 计费设置优先。',
rewriteMessageCacheControl: '改写消息缓存断点',
rewriteMessageCacheControlHint: '默认关闭,保留客户端在 messages 内容块中的 cache_control。开启后会清除客户端断点并注入代理断点,适合不自行管理缓存策略的客户端。',
clientDatelineNormalization: '客户端 dateline 归一化',
clientDatelineNormalizationHint: '默认开启。将 Anthropic OAuth/Setup Token 请求体中 "Today\'s date is …" 语句里的撇号与日期分隔符还原为 ASCII 撇号 + 短横线 (2026-07-01) 的规范形态,抹除某些客户端在检测到非官方 base URL 时注入的隐写指纹位。仅作用于 system prompt 与 <system-reminder> 块内,API Key 账号不受影响。',
antigravityUserAgentVersion: 'Antigravity UA 版本',
antigravityUserAgentVersionPlaceholder: '1.23.2',
antigravityUserAgentVersionHint: '留空时使用 ANTIGRAVITY_USER_AGENT_VERSION 或内置默认值 1.23.2;填写后后台设置优先。',
@@ -6933,6 +7041,10 @@ export default {
notFoundDesc: '该自定义页面不存在或已被删除。',
notConfiguredTitle: '页面链接未配置',
notConfiguredDesc: '该自定义页面的 URL 未正确配置。',
tableOfContents: '目录',
copyCode: '复制',
copiedCode: '已复制',
copyCodeFailed: '失败'
},
// Announcements Page
+8 -5
View File
@@ -1303,6 +1303,7 @@ export interface UsageLog {
// User-Agent
user_agent: string | null
ip_address?: string | null
// Cache TTL Override
cache_ttl_overridden: boolean
@@ -1336,9 +1337,6 @@ export interface AdminUsageLog extends UsageLog {
channel_id?: number | null
billing_tier?: string | null
// 用户请求 IP(仅管理员可见)
ip_address?: string | null
// 最小账号信息(仅管理员接口返回)
account?: UsageLogAccountSummary
}
@@ -1481,6 +1479,9 @@ export interface UsageStatsResponse {
total_actual_cost: number // 实际扣除
average_duration_ms: number
models?: Record<string, number>
endpoints?: EndpointStat[]
upstream_endpoints?: EndpointStat[]
endpoint_paths?: EndpointStat[]
}
// ==================== Trend & Chart Types ====================
@@ -1507,7 +1508,7 @@ export interface ModelStat {
total_tokens: number
cost: number // 标准计费
actual_cost: number // 实际扣除
account_cost: number // 账号成本
account_cost?: number // 账号成本(仅管理员接口返回)
}
export interface EndpointStat {
@@ -1525,7 +1526,7 @@ export interface GroupStat {
total_tokens: number
cost: number // 标准计费
actual_cost: number // 实际扣除
account_cost: number // 账号成本
account_cost?: number // 账号成本(仅管理员接口返回)
}
export interface UserBreakdownItem {
@@ -1700,8 +1701,10 @@ export interface UsageQueryParams {
request_type?: UsageRequestType
stream?: boolean
billing_type?: number | null
billing_mode?: string | null
start_date?: string
end_date?: string
timezone?: string
sort_by?: string
sort_order?: 'asc' | 'desc'
}
+9 -11
View File
@@ -155,7 +155,7 @@
class="channel-tab"
:class="activeTab === 'basic' ? 'channel-tab-active' : 'channel-tab-inactive'"
>
{{ t('admin.channels.form.basicSettings', '基础设置') }}
{{ t('admin.channels.form.basicSettings') }}
</button>
<!-- Platform Tabs (only enabled) -->
<button
@@ -230,7 +230,7 @@
<!-- Platform Management -->
<div class="space-y-3">
<label class="input-label mb-0">{{ t('admin.channels.form.platformConfig', '平台配置') }}</label>
<label class="input-label mb-0">{{ t('admin.channels.form.platformConfig') }}</label>
<div class="flex flex-wrap gap-2">
<label
v-for="p in platformOrder"
@@ -283,7 +283,7 @@
<label class="input-label text-xs">
{{ t('admin.channels.form.groups', 'Associated Groups') }} <span class="text-red-500">*</span>
<span v-if="section.group_ids.length > 0" class="ml-1 font-normal text-gray-400">
({{ t('admin.channels.form.selectedCount', { count: section.group_ids.length }, `已选 ${section.group_ids.length} 个`) }})
({{ t('admin.channels.form.selectedCount', { count: section.group_ids.length }) }})
</span>
</label>
<div class="max-h-40 overflow-auto rounded-lg border border-gray-200 bg-gray-50 p-2 dark:border-dark-600 dark:bg-dark-900">
@@ -1434,14 +1434,14 @@ async function handleSubmit() {
for (const section of form.platforms.filter(s => s.enabled)) {
if (section.group_ids.length === 0) {
const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform)
appStore.showError(t('admin.channels.noGroupsSelected', { platform: platformLabel }, `${platformLabel} 平台未选择分组,请至少选择一个分组或禁用该平台`))
appStore.showError(t('admin.channels.noGroupsSelected', { platform: platformLabel }))
activeTab.value = section.platform
return
}
for (const entry of section.model_pricing) {
if (entry.models.length === 0) {
const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform)
appStore.showError(t('admin.channels.emptyModelsInPricing', { platform: platformLabel }, `${platformLabel} 平台下有定价条目未添加模型,请添加模型或删除该条目`))
appStore.showError(t('admin.channels.emptyModelsInPricing', { platform: platformLabel }))
activeTab.value = section.platform
return
}
@@ -1459,8 +1459,7 @@ async function handleSubmit() {
if (pricingConflict) {
appStore.showError(
t('admin.channels.modelConflict',
{ model1: pricingConflict[0], model2: pricingConflict[1] },
`模型模式 '${pricingConflict[0]}' 和 '${pricingConflict[1]}' 冲突:匹配范围重叠`)
{ model1: pricingConflict[0], model2: pricingConflict[1] })
)
activeTab.value = section.platform
return
@@ -1472,8 +1471,7 @@ async function handleSubmit() {
if (mappingConflict) {
appStore.showError(
t('admin.channels.mappingConflict',
{ model1: mappingConflict[0], model2: mappingConflict[1] },
`模型映射源 '${mappingConflict[0]}' 和 '${mappingConflict[1]}' 冲突:匹配范围重叠`)
{ model1: mappingConflict[0], model2: mappingConflict[1] })
)
activeTab.value = section.platform
return
@@ -1488,7 +1486,7 @@ async function handleSubmit() {
if ((entry.billing_mode === 'per_request' || entry.billing_mode === 'image') &&
(entry.per_request_price == null || entry.per_request_price === '') &&
(!entry.intervals || entry.intervals.length === 0)) {
appStore.showError(t('admin.channels.form.perRequestPriceRequired', '按次/图片计费模式必须设置默认价格或至少一个计费层级'))
appStore.showError(t('admin.channels.form.perRequestPriceRequired'))
return
}
}
@@ -1498,7 +1496,7 @@ async function handleSubmit() {
for (const section of form.platforms.filter(s => s.enabled)) {
for (const entry of section.model_pricing) {
if (!entry.intervals || entry.intervals.length === 0) continue
const intervalErr = validateIntervals(entry.intervals, entry.billing_mode)
const intervalErr = validateIntervals(entry.intervals, entry.billing_mode, t)
if (intervalErr) {
const platformLabel = t('admin.groups.platforms.' + section.platform, section.platform)
const modelLabel = entry.models.join(', ') || t('admin.channels.form.unnamed')
+32 -24
View File
@@ -683,8 +683,12 @@
class="flex items-center justify-between gap-2 border-b border-gray-200 bg-gray-50 px-3 py-2 text-xs dark:border-dark-600 dark:bg-dark-800"
>
<span class="text-gray-500 dark:text-gray-400">
已选 {{ createModelsListSelectedCount }} /
{{ createModelsListState.items.length }}
{{
t("admin.groups.modelsList.selectedSummary", {
selected: createModelsListSelectedCount,
total: createModelsListState.items.length,
})
}}
</span>
<div class="flex items-center gap-1.5">
<button
@@ -692,14 +696,14 @@
class="rounded px-2 py-1 font-medium text-primary-600 transition-colors hover:bg-primary-50 dark:text-primary-400 dark:hover:bg-primary-900/20"
@click="selectAllModelsListItems(createModelsListState)"
>
全选
{{ t("admin.groups.modelsList.selectAll") }}
</button>
<button
type="button"
class="rounded px-2 py-1 font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
@click="invertModelsListSelection(createModelsListState)"
>
反选
{{ t("admin.groups.modelsList.invertSelection") }}
</button>
</div>
</div>
@@ -1343,20 +1347,20 @@
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4 space-y-4"
>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
账号过滤控制
{{ t("admin.groups.accountFilters.title") }}
</h4>
<!-- require_oauth_only toggle -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400"
>仅允许 OAuth 账号</label
>{{ t("admin.groups.accountFilters.oauthOnly") }}</label
>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{
createForm.require_oauth_only
? "已启用 — 排除 API Key 类型账号"
: "未启用"
? t("admin.groups.accountFilters.oauthOnlyEnabled")
: t("admin.groups.accountFilters.disabled")
}}
</p>
</div>
@@ -1387,13 +1391,13 @@
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400"
>仅允许隐私保护已设置的账号</label
>{{ t("admin.groups.accountFilters.privacySetOnly") }}</label
>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{
createForm.require_privacy_set
? "已启用 — Privacy 未设置的账号将被排除"
: "未启用"
? t("admin.groups.accountFilters.privacySetOnlyEnabled")
: t("admin.groups.accountFilters.disabled")
}}
</p>
</div>
@@ -2017,8 +2021,12 @@
class="flex items-center justify-between gap-2 border-b border-gray-200 bg-gray-50 px-3 py-2 text-xs dark:border-dark-600 dark:bg-dark-800"
>
<span class="text-gray-500 dark:text-gray-400">
已选 {{ editModelsListSelectedCount }} /
{{ editModelsListState.items.length }}
{{
t("admin.groups.modelsList.selectedSummary", {
selected: editModelsListSelectedCount,
total: editModelsListState.items.length,
})
}}
</span>
<div class="flex items-center gap-1.5">
<button
@@ -2026,14 +2034,14 @@
class="rounded px-2 py-1 font-medium text-primary-600 transition-colors hover:bg-primary-50 dark:text-primary-400 dark:hover:bg-primary-900/20"
@click="selectAllModelsListItems(editModelsListState)"
>
全选
{{ t("admin.groups.modelsList.selectAll") }}
</button>
<button
type="button"
class="rounded px-2 py-1 font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
@click="invertModelsListSelection(editModelsListState)"
>
反选
{{ t("admin.groups.modelsList.invertSelection") }}
</button>
</div>
</div>
@@ -2673,20 +2681,20 @@
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4 space-y-4"
>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
账号过滤控制
{{ t("admin.groups.accountFilters.title") }}
</h4>
<!-- require_oauth_only toggle -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400"
>仅允许 OAuth 账号</label
>{{ t("admin.groups.accountFilters.oauthOnly") }}</label
>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{
editForm.require_oauth_only
? "已启用 — 排除 API Key 类型账号"
: "未启用"
? t("admin.groups.accountFilters.oauthOnlyEnabled")
: t("admin.groups.accountFilters.disabled")
}}
</p>
</div>
@@ -2717,13 +2725,13 @@
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-gray-600 dark:text-gray-400"
>仅允许隐私保护已设置的账号</label
>{{ t("admin.groups.accountFilters.privacySetOnly") }}</label
>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{
editForm.require_privacy_set
? "已启用 — Privacy 未设置的账号将被排除"
: "未启用"
? t("admin.groups.accountFilters.privacySetOnlyEnabled")
: t("admin.groups.accountFilters.disabled")
}}
</p>
</div>
@@ -3333,7 +3341,7 @@ const copyAccountsGroupOptions = computed(() => {
);
return eligibleGroups.map((g) => ({
value: g.id,
label: `${g.name} (${g.account_count || 0} 个账号)`,
label: `${g.name} (${t("admin.groups.accountsCount", { count: g.account_count || 0 })})`,
}));
});
@@ -3348,7 +3356,7 @@ const copyAccountsGroupOptionsForEdit = computed(() => {
);
return eligibleGroups.map((g) => ({
value: g.id,
label: `${g.name} (${g.account_count || 0} 个账号)`,
label: `${g.name} (${t("admin.groups.accountsCount", { count: g.account_count || 0 })})`,
}));
});
+4 -3
View File
@@ -1191,6 +1191,7 @@ const riskThresholdCategories = Object.keys(riskThresholdDefaults)
const { t } = useI18n()
const appStore = useAppStore()
const defaultBlockMessage = () => t('admin.riskControl.defaultBlockMessage')
const loading = ref(true)
const saving = ref(false)
@@ -1237,7 +1238,7 @@ const configForm = reactive({
worker_count: 4,
queue_size: 32768,
block_status: 403,
block_message: '内容审计命中风险规则,请调整输入后重试',
block_message: defaultBlockMessage(),
email_on_hit: true,
auto_ban_enabled: true,
cyber_policy_exclude_from_ban_count: false,
@@ -1714,7 +1715,7 @@ function applyConfig(config: ContentModerationConfig) {
configForm.worker_count = config.worker_count || 4
configForm.queue_size = config.queue_size || 32768
configForm.block_status = config.block_status || 403
configForm.block_message = config.block_message || '内容审计命中风险规则,请调整输入后重试'
configForm.block_message = config.block_message || defaultBlockMessage()
configForm.email_on_hit = config.email_on_hit ?? true
configForm.auto_ban_enabled = config.auto_ban_enabled ?? true
configForm.cyber_policy_exclude_from_ban_count = config.cyber_policy_exclude_from_ban_count ?? false
@@ -1795,7 +1796,7 @@ async function saveConfig() {
worker_count: Number(configForm.worker_count) || 4,
queue_size: Number(configForm.queue_size) || 32768,
block_status: Number(configForm.block_status) || 403,
block_message: configForm.block_message || '内容审计命中风险规则,请调整输入后重试',
block_message: configForm.block_message || defaultBlockMessage(),
email_on_hit: configForm.email_on_hit,
auto_ban_enabled: configForm.auto_ban_enabled,
cyber_policy_exclude_from_ban_count: configForm.cyber_policy_exclude_from_ban_count,
+39 -11
View File
@@ -2537,7 +2537,7 @@
<input
v-model="form.dingtalk_connect_sync_display_name_attr_name"
type="text"
placeholder="钉钉姓名"
:placeholder="localText('钉钉姓名', 'DingTalk Name')"
class="input text-sm flex-1 max-w-xs"
/>
</div>
@@ -2583,7 +2583,7 @@
<input
v-model="form.dingtalk_connect_sync_corp_email_attr_name"
type="text"
placeholder="钉钉企业邮箱"
:placeholder="localText('钉钉企业邮箱', 'DingTalk Corporate Email')"
class="input text-sm flex-1 max-w-xs"
/>
</div>
@@ -2629,7 +2629,7 @@
<input
v-model="form.dingtalk_connect_sync_dept_attr_name"
type="text"
placeholder="钉钉部门"
:placeholder="localText('钉钉部门', 'DingTalk Department')"
class="input text-sm flex-1 max-w-xs"
/>
</div>
@@ -4368,6 +4368,31 @@
<Toggle v-model="form.rewrite_message_cache_control" />
</div>
<!-- 客户端 dateline 归一化(仅 Anthropic OAuth/SetupToken) -->
<div class="flex items-center justify-between">
<div>
<label
class="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{
t(
"admin.settings.gatewayForwarding.clientDatelineNormalization",
)
}}
</label>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{
t(
"admin.settings.gatewayForwarding.clientDatelineNormalizationHint",
)
}}
</p>
</div>
<Toggle
v-model="form.enable_client_dateline_normalization"
/>
</div>
<!-- Antigravity UA 版本 -->
<div>
<label
@@ -7435,22 +7460,22 @@ function defaultLoginAgreementDocuments(): LoginAgreementDocument[] {
return [
{
id: "terms",
title: "服务条款",
title: localText("服务条款", "Terms of Service"),
content_md: "",
},
{
id: "usage-policy",
title: "使用政策",
title: localText("使用政策", "Usage Policy"),
content_md: "",
},
{
id: "supported-regions",
title: "支持的国家和地区",
title: localText("支持的国家和地区", "Supported Countries and Regions"),
content_md: "",
},
{
id: "service-specific-terms",
title: "服务特定条款",
title: localText("服务特定条款", "Service-Specific Terms"),
content_md: "",
},
];
@@ -7989,9 +8014,9 @@ const form = reactive<SettingsForm>({
dingtalk_connect_sync_corp_email_attr_key: "dingtalk_email",
dingtalk_connect_sync_display_name_attr_key: "dingtalk_name",
dingtalk_connect_sync_dept_attr_key: "dingtalk_department",
dingtalk_connect_sync_corp_email_attr_name: "钉钉企业邮箱",
dingtalk_connect_sync_display_name_attr_name: "钉钉姓名",
dingtalk_connect_sync_dept_attr_name: "钉钉部门",
dingtalk_connect_sync_corp_email_attr_name: localText("钉钉企业邮箱", "DingTalk Corporate Email"),
dingtalk_connect_sync_display_name_attr_name: localText("钉钉姓名", "DingTalk Name"),
dingtalk_connect_sync_dept_attr_name: localText("钉钉部门", "DingTalk Department"),
wechat_connect_enabled: false,
wechat_connect_app_id: "",
wechat_connect_app_secret: "",
@@ -8078,6 +8103,7 @@ const form = reactive<SettingsForm>({
claude_oauth_system_prompt_blocks: defaultClaudeOAuthSystemPromptBlocks,
enable_anthropic_cache_ttl_1h_injection: false,
rewrite_message_cache_control: false,
enable_client_dateline_normalization: true,
antigravity_user_agent_version: "",
openai_codex_user_agent: "",
// codex_cli_only 加固
@@ -8148,7 +8174,7 @@ const authSourceDefaultsMeta = computed(() => [
},
{
source: "dingtalk" as AuthSourceType,
title: "钉钉",
title: t("auth.dingtalkProviderName"),
description: localText(
"通过钉钉首次注册或首次绑定时应用。",
"Applied on first signup or first bind through DingTalk.",
@@ -9282,6 +9308,8 @@ async function saveSettings() {
enable_anthropic_cache_ttl_1h_injection:
form.enable_anthropic_cache_ttl_1h_injection,
rewrite_message_cache_control: form.rewrite_message_cache_control,
enable_client_dateline_normalization:
form.enable_client_dateline_normalization,
antigravity_user_agent_version:
form.antigravity_user_agent_version?.trim() || "",
openai_codex_user_agent:
@@ -383,6 +383,7 @@ const baseSettingsResponse = {
claude_oauth_system_prompt_blocks: "",
enable_anthropic_cache_ttl_1h_injection: false,
rewrite_message_cache_control: false,
enable_client_dateline_normalization: true,
antigravity_user_agent_version: "",
openai_codex_user_agent: "",
payment_enabled: true,
@@ -889,7 +889,7 @@ function handleToolbarRefresh() {
<template v-if="props.autoRefreshEnabled && props.autoRefreshCountdown !== undefined">
<span>·</span>
<span>剩余 {{ props.autoRefreshCountdown }}s</span>
<span>{{ t('admin.ops.autoRefreshRemaining', { seconds: props.autoRefreshCountdown }) }}</span>
</template>
</div>
</div>
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { opsAPI, type OpsRuntimeLogConfig, type OpsSystemLog, type OpsSystemLogSinkHealth } from '@/api/admin/ops'
import Pagination from '@/components/common/Pagination.vue'
import Select from '@/components/common/Select.vue'
import { useAppStore } from '@/stores'
const appStore = useAppStore()
const { t } = useI18n()
const props = withDefaults(defineProps<{
platformFilter?: string
@@ -81,13 +83,13 @@ const timeRangeOptions = [
{ value: '30d', label: '30d' }
]
const filterLevelOptions = [
{ value: '', label: '全部' },
const filterLevelOptions = computed(() => [
{ value: '', label: t('admin.ops.systemLogs.all') },
{ value: 'debug', label: 'debug' },
{ value: 'info', label: 'info' },
{ value: 'warn', label: 'warn' },
{ value: 'error', label: 'error' }
]
])
const levelBadgeClass = (level: string) => {
const v = String(level || '').toLowerCase()
@@ -203,7 +205,7 @@ const fetchLogs = async () => {
total.value = res.total || 0
} catch (err: any) {
console.error('[OpsSystemLogTable] Failed to fetch logs', err)
appStore.showError(err?.response?.data?.detail || '系统日志加载失败')
appStore.showError(err?.response?.data?.detail || t('admin.ops.systemLogs.loadFailed'))
} finally {
loading.value = false
}
@@ -246,17 +248,17 @@ const saveRuntimeConfig = async () => {
runtimeConfig.caller = saved.caller
runtimeConfig.stacktrace_level = saved.stacktrace_level
runtimeConfig.retention_days = saved.retention_days
appStore.showSuccess('日志运行时配置已生效')
appStore.showSuccess(t('admin.ops.systemLogs.runtimeConfigActive'))
} catch (err: any) {
console.error('[OpsSystemLogTable] Failed to save runtime log config', err)
appStore.showError(err?.response?.data?.detail || '保存日志配置失败')
appStore.showError(err?.response?.data?.detail || t('admin.ops.systemLogs.runtimeConfigSaveFailed'))
} finally {
runtimeSaving.value = false
}
}
const resetRuntimeConfig = async () => {
const ok = window.confirm('确认回滚为启动配置(env/yaml)并立即生效?')
const ok = window.confirm(t('admin.ops.systemLogs.resetRuntimeConfigConfirm'))
if (!ok) return
runtimeSaving.value = true
@@ -269,18 +271,18 @@ const resetRuntimeConfig = async () => {
runtimeConfig.caller = saved.caller
runtimeConfig.stacktrace_level = saved.stacktrace_level
runtimeConfig.retention_days = saved.retention_days
appStore.showSuccess('已回滚到启动日志配置')
appStore.showSuccess(t('admin.ops.systemLogs.runtimeConfigReset'))
await fetchHealth()
} catch (err: any) {
console.error('[OpsSystemLogTable] Failed to reset runtime log config', err)
appStore.showError(err?.response?.data?.detail || '回滚日志配置失败')
appStore.showError(err?.response?.data?.detail || t('admin.ops.systemLogs.runtimeConfigResetFailed'))
} finally {
runtimeSaving.value = false
}
}
const cleanupCurrentFilter = async () => {
const ok = window.confirm('确认按当前筛选条件清理系统日志?该操作不可撤销。')
const ok = window.confirm(t('admin.ops.systemLogs.cleanupConfirm'))
if (!ok) return
try {
const payload = {
@@ -298,12 +300,12 @@ const cleanupCurrentFilter = async () => {
q: filters.q.trim() || undefined
}
const res = await opsAPI.cleanupSystemLogs(payload)
appStore.showSuccess(`清理完成,删除 ${res.deleted || 0} 条日志`)
appStore.showSuccess(t('admin.ops.systemLogs.cleanupSuccess', { count: res.deleted || 0 }))
page.value = 1
await Promise.all([fetchLogs(), fetchHealth()])
} catch (err: any) {
console.error('[OpsSystemLogTable] Failed to cleanup logs', err)
appStore.showError(err?.response?.data?.detail || '清理系统日志失败')
appStore.showError(err?.response?.data?.detail || t('admin.ops.systemLogs.cleanupFailed'))
}
}
@@ -368,41 +370,41 @@ onMounted(async () => {
<section class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-dark-700 dark:bg-dark-900/60">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-sm font-bold text-gray-900 dark:text-white">系统日志</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">默认按最新时间倒序,支持筛选搜索与按条件清理。</p>
<h3 class="text-sm font-bold text-gray-900 dark:text-white">{{ t('admin.ops.systemLogs.title') }}</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.systemLogs.description') }}</p>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs">
<span class="rounded-md bg-gray-100 px-2 py-1 text-gray-700 dark:bg-dark-700 dark:text-gray-200">队列 {{ health.queue_depth }}/{{ health.queue_capacity }}</span>
<span class="rounded-md bg-gray-100 px-2 py-1 text-gray-700 dark:bg-dark-700 dark:text-gray-200">写入 {{ health.written_count }}</span>
<span class="rounded-md bg-amber-100 px-2 py-1 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">丢弃 {{ health.dropped_count }}</span>
<span class="rounded-md bg-red-100 px-2 py-1 text-red-700 dark:bg-red-900/30 dark:text-red-300">失败 {{ health.write_failed_count }}</span>
<span class="rounded-md bg-gray-100 px-2 py-1 text-gray-700 dark:bg-dark-700 dark:text-gray-200">{{ t('admin.ops.systemLogs.queue') }} {{ health.queue_depth }}/{{ health.queue_capacity }}</span>
<span class="rounded-md bg-gray-100 px-2 py-1 text-gray-700 dark:bg-dark-700 dark:text-gray-200">{{ t('admin.ops.systemLogs.written') }} {{ health.written_count }}</span>
<span class="rounded-md bg-amber-100 px-2 py-1 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">{{ t('admin.ops.systemLogs.dropped') }} {{ health.dropped_count }}</span>
<span class="rounded-md bg-red-100 px-2 py-1 text-red-700 dark:bg-red-900/30 dark:text-red-300">{{ t('admin.ops.systemLogs.failed') }} {{ health.write_failed_count }}</span>
</div>
</div>
<div class="mb-4 rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-dark-700 dark:bg-dark-800/70">
<div class="mb-2 flex items-center justify-between">
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">运行时日志配置(实时生效)</div>
<span v-if="runtimeLoading" class="text-xs text-gray-500">加载中...</span>
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">{{ t('admin.ops.systemLogs.runtimeConfig') }}</div>
<span v-if="runtimeLoading" class="text-xs text-gray-500">{{ t('common.loading') }}</span>
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-6">
<label class="text-xs text-gray-600 dark:text-gray-300">
级别
{{ t('admin.ops.systemLogs.level') }}
<Select v-model="runtimeConfig.level" class="mt-1" :options="runtimeLevelOptions" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
堆栈阈值
{{ t('admin.ops.systemLogs.stacktraceThreshold') }}
<Select v-model="runtimeConfig.stacktrace_level" class="mt-1" :options="stacktraceLevelOptions" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
采样初始
{{ t('admin.ops.systemLogs.samplingInitial') }}
<input v-model.number="runtimeConfig.sampling_initial" type="number" min="1" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
采样后续
{{ t('admin.ops.systemLogs.samplingThereafter') }}
<input v-model.number="runtimeConfig.sampling_thereafter" type="number" min="1" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
保留天数
{{ t('admin.ops.systemLogs.retentionDays') }}
<input v-model.number="runtimeConfig.retention_days" type="number" min="1" max="3650" class="input mt-1" />
</label>
<div class="md:col-span-2 xl:col-span-6">
@@ -410,47 +412,47 @@ onMounted(async () => {
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
<label class="inline-flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
<input v-model="runtimeConfig.caller" type="checkbox" />
caller
{{ t('admin.ops.systemLogs.caller') }}
</label>
<label class="inline-flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
<input v-model="runtimeConfig.enable_sampling" type="checkbox" />
sampling
{{ t('admin.ops.systemLogs.sampling') }}
</label>
</div>
<div class="flex flex-wrap items-center gap-2 lg:justify-end">
<button type="button" class="btn btn-primary btn-sm" :disabled="runtimeSaving" @click="saveRuntimeConfig">
{{ runtimeSaving ? '保存中...' : '保存并生效' }}
{{ runtimeSaving ? t('common.saving') : t('admin.ops.systemLogs.saveAndApply') }}
</button>
<button type="button" class="btn btn-secondary btn-sm" :disabled="runtimeSaving" @click="resetRuntimeConfig">
回滚默认值
{{ t('admin.ops.systemLogs.resetDefaults') }}
</button>
</div>
</div>
</div>
</div>
<p v-if="health.last_error" class="mt-2 text-xs text-red-600 dark:text-red-400">最近写入错误:{{ health.last_error }}</p>
<p v-if="health.last_error" class="mt-2 text-xs text-red-600 dark:text-red-400">{{ t('admin.ops.systemLogs.latestWriteError') }} {{ health.last_error }}</p>
</div>
<div class="mb-4 grid grid-cols-1 gap-3 md:grid-cols-5">
<label class="text-xs text-gray-600 dark:text-gray-300">
时间范围
{{ t('admin.ops.systemLogs.timeRange') }}
<Select v-model="filters.time_range" class="mt-1" :options="timeRangeOptions" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
开始时间(可选)
{{ t('admin.ops.systemLogs.startTime') }}
<input v-model="filters.start_time" type="datetime-local" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
结束时间(可选)
{{ t('admin.ops.systemLogs.endTime') }}
<input v-model="filters.end_time" type="datetime-local" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
级别
{{ t('admin.ops.systemLogs.level') }}
<Select v-model="filters.level" class="mt-1" :options="filterLevelOptions" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
组件
<input v-model="filters.component" type="text" class="input mt-1" placeholder="如 http.access" />
{{ t('admin.ops.systemLogs.component') }}
<input v-model="filters.component" type="text" class="input mt-1" :placeholder="t('admin.ops.systemLogs.componentPlaceholder')" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
request_id
@@ -465,7 +467,7 @@ onMounted(async () => {
<input v-model="filters.user_id" type="text" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
KEY ID
{{ t('admin.ops.systemLogs.keyId') }}
<input v-model="filters.api_key_id" type="text" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
@@ -473,36 +475,36 @@ onMounted(async () => {
<input v-model="filters.account_id" type="text" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
平台
{{ t('admin.ops.systemLogs.platform') }}
<input v-model="filters.platform" type="text" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
模型
{{ t('admin.ops.systemLogs.model') }}
<input v-model="filters.model" type="text" class="input mt-1" />
</label>
<label class="text-xs text-gray-600 dark:text-gray-300">
关键词
<input v-model="filters.q" type="text" class="input mt-1" placeholder="消息/request_id" />
{{ t('admin.ops.systemLogs.keyword') }}
<input v-model="filters.q" type="text" class="input mt-1" :placeholder="t('admin.ops.systemLogs.keywordPlaceholder')" />
</label>
</div>
<div class="mb-3 flex flex-wrap gap-2">
<button type="button" class="btn btn-primary btn-sm" @click="applyFilters">查询</button>
<button type="button" class="btn btn-secondary btn-sm" @click="resetFilters">重置</button>
<button type="button" class="btn btn-danger btn-sm" @click="cleanupCurrentFilter">按当前筛选清理</button>
<button type="button" class="btn btn-secondary btn-sm" @click="fetchHealth">刷新健康指标</button>
<button type="button" class="btn btn-primary btn-sm" @click="applyFilters">{{ t('admin.ops.systemLogs.search') }}</button>
<button type="button" class="btn btn-secondary btn-sm" @click="resetFilters">{{ t('common.reset') }}</button>
<button type="button" class="btn btn-danger btn-sm" @click="cleanupCurrentFilter">{{ t('admin.ops.systemLogs.cleanCurrentFilters') }}</button>
<button type="button" class="btn btn-secondary btn-sm" @click="fetchHealth">{{ t('admin.ops.systemLogs.refreshHealth') }}</button>
</div>
<div class="overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
<div v-if="loading" class="px-4 py-8 text-center text-sm text-gray-500">加载中...</div>
<div v-else-if="!hasData" class="px-4 py-8 text-center text-sm text-gray-500">暂无系统日志</div>
<div v-if="loading" class="px-4 py-8 text-center text-sm text-gray-500">{{ t('common.loading') }}</div>
<div v-else-if="!hasData" class="px-4 py-8 text-center text-sm text-gray-500">{{ t('admin.ops.systemLogs.empty') }}</div>
<div v-else class="overflow-auto">
<table class="min-w-full table-fixed divide-y divide-gray-200 dark:divide-dark-700">
<thead class="bg-gray-50 dark:bg-dark-900">
<tr>
<th class="w-[170px] px-3 py-2 text-left text-[11px] font-semibold text-gray-500">时间</th>
<th class="w-[80px] px-3 py-2 text-left text-[11px] font-semibold text-gray-500">级别</th>
<th class="px-3 py-2 text-left text-[11px] font-semibold text-gray-500">日志详细信息</th>
<th class="w-[170px] px-3 py-2 text-left text-[11px] font-semibold text-gray-500">{{ t('admin.ops.systemLogs.time') }}</th>
<th class="w-[80px] px-3 py-2 text-left text-[11px] font-semibold text-gray-500">{{ t('admin.ops.systemLogs.level') }}</th>
<th class="px-3 py-2 text-left text-[11px] font-semibold text-gray-500">{{ t('admin.ops.systemLogs.logDetails') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-dark-800">
@@ -295,7 +295,7 @@ const totpTempToken = ref('')
const totpCode = ref('')
const totpError = ref('')
const totpUserEmailMasked = ref('')
const providerName = '钉钉'
const providerName = t('auth.dingtalkProviderName')
const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account')
const needsChooser = computed(() => pendingAccountAction.value === 'choose_account_action')
+2 -2
View File
@@ -397,7 +397,7 @@ function rejectLoginAgreement(): void {
localStorage.removeItem(LOGIN_AGREEMENT_STORAGE_KEY)
agreementAccepted.value = false
showAgreementModal.value = false
appStore.showWarning('未同意最新条款前,无法输入账号密码或使用快捷登录。')
appStore.showWarning(t('legal.loginAgreementPrompt.loginRejectedWarning'))
}
// ==================== Turnstile Handlers ====================
@@ -428,7 +428,7 @@ function validateForm(): boolean {
let isValid = true
if (agreementGateActive.value) {
appStore.showWarning('请先阅读并同意最新条款后再登录。')
appStore.showWarning(t('legal.loginAgreementPrompt.loginRequiredWarning'))
if (loginAgreementMode.value !== 'checkbox') {
showAgreementModal.value = true
}
+2 -2
View File
@@ -567,7 +567,7 @@ function rejectLoginAgreement(): void {
localStorage.removeItem(LOGIN_AGREEMENT_STORAGE_KEY)
agreementAccepted.value = false
showAgreementModal.value = false
appStore.showWarning('未同意最新条款前,无法注册或使用快捷登录。')
appStore.showWarning(t('legal.loginAgreementPrompt.registerRejectedWarning'))
}
// ==================== Promo Code Validation ====================
@@ -757,7 +757,7 @@ function validateForm(): boolean {
let isValid = true
if (agreementGateActive.value) {
appStore.showWarning('请先阅读并同意最新条款后再注册。')
appStore.showWarning(t('legal.loginAgreementPrompt.registerRequiredWarning'))
if (loginAgreementMode.value !== 'checkbox') {
showAgreementModal.value = true
}
+7 -7
View File
@@ -35,7 +35,7 @@
class="toc-sidebar"
>
<div class="toc-header">
<span class="toc-title">目录</span>
<span class="toc-title">{{ t('customPage.tableOfContents') }}</span>
<button class="toc-close-btn" @click="tocVisible = false">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
</button>
@@ -64,7 +64,7 @@
@click="tocVisible = true"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h18M3 6h18M3 18h18"/></svg>
<span class="ml-1 text-xs">目录</span>
<span class="ml-1 text-xs">{{ t('customPage.tableOfContents') }}</span>
</button>
<!-- Content -->
@@ -317,16 +317,16 @@ function injectCopyButtons() {
if (pre.querySelector('.copy-btn')) return
const btn = document.createElement('button')
btn.className = 'copy-btn'
btn.textContent = '复制'
btn.textContent = t('customPage.copyCode')
btn.addEventListener('click', async () => {
const code = pre.querySelector('code')?.textContent ?? pre.textContent ?? ''
try {
await navigator.clipboard.writeText(code)
btn.textContent = '已复制 ✓'
setTimeout(() => { btn.textContent = '复制' }, 2000)
btn.textContent = t('customPage.copiedCode')
setTimeout(() => { btn.textContent = t('customPage.copyCode') }, 2000)
} catch {
btn.textContent = '失败'
setTimeout(() => { btn.textContent = '复制' }, 2000)
btn.textContent = t('customPage.copyCodeFailed')
setTimeout(() => { btn.textContent = t('customPage.copyCode') }, 2000)
}
})
pre.style.position = 'relative'
+6 -20
View File
@@ -275,7 +275,7 @@ import { platformAccentBarClass, platformBadgeLightClass, platformBadgeClass, pl
import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
import Icon from '@/components/icons/Icon.vue'
import { DEFAULT_PAYMENT_CURRENCY, formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
import { formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
import { buildPaymentErrorToastMessage, describePaymentScenarioError } from './paymentUx'
import { hasWechatResumeQuery, parseWechatResumeRoute, stripWechatResumeQuery } from './paymentWechatResume'
@@ -540,10 +540,6 @@ const localeCode = computed(() => {
return undefined
})
interface PaymentAmountFormatOptions {
subscription?: boolean
}
function currencyFractionDigits(currency: string): number {
try {
return new Intl.NumberFormat(undefined, {
@@ -567,22 +563,12 @@ function ceilPaymentAmount(value: number, currency: string): number {
return Math.ceil(value * factor) / factor
}
function subscriptionPaymentAmountForCurrency(value: number, currency: string): number {
if (currency !== DEFAULT_PAYMENT_CURRENCY) return value
return roundPaymentAmount(value / balanceRechargeMultiplier.value, currency)
}
function subscriptionPaymentAmount(value: number): number {
return subscriptionPaymentAmountForCurrency(value, selectedCurrency.value)
}
function formatSelectedPaymentAmount(value: number, options: PaymentAmountFormatOptions = {}): string {
const amount = options.subscription ? subscriptionPaymentAmount(value) : value
return formatPaymentAmount(amount, selectedCurrency.value, localeCode.value)
function formatSelectedPaymentAmount(value: number): string {
return formatPaymentAmount(value, selectedCurrency.value, localeCode.value)
}
function formatSelectedSubscriptionPaymentAmount(value: number): string {
return formatSelectedPaymentAmount(value, { subscription: true })
return formatSelectedPaymentAmount(roundPaymentAmount(value, selectedCurrency.value))
}
const methodOptions = computed<PaymentMethodOption[]>(() =>
@@ -631,7 +617,7 @@ const canSubmit = computed(() =>
const subPaymentAmount = computed(() => {
const price = selectedPlan.value?.price ?? 0
return subscriptionPaymentAmount(price)
return roundPaymentAmount(price, selectedCurrency.value)
})
const subFeeAmount = computed(() => {
@@ -645,7 +631,7 @@ const subTotalAmount = computed(() => {
})
function subscriptionTotalAmountForCurrency(value: number, currency: string): number {
const paymentAmount = subscriptionPaymentAmountForCurrency(value, currency)
const paymentAmount = roundPaymentAmount(value, currency)
if (feeRate.value <= 0 || paymentAmount <= 0) return paymentAmount
const fee = ceilPaymentAmount((paymentAmount * feeRate.value) / 100, currency)
return roundPaymentAmount(paymentAmount + fee, currency)
File diff suppressed because it is too large Load Diff
@@ -236,28 +236,29 @@ async function mountSubscriptionConfirm(options: Parameters<typeof checkoutInfoW
}
describe('PaymentView subscription confirmation amounts', () => {
it('shows converted CNY pay amount for plan price, original price, and create button', async () => {
it('keeps subscription plan price independent from balance recharge multiplier', async () => {
const wrapper = await mountSubscriptionConfirm({
checkout: {
balance_recharge_multiplier: 0.14,
balance_recharge_multiplier: 4,
},
method: {
currency: 'CNY',
},
plan: {
price: 7.99,
original_price: 9.99,
price: 200,
original_price: 300,
},
})
const text = wrapper.text()
const convertedPrice = formatPaymentAmount(57.07, 'CNY')
const convertedOriginalPrice = formatPaymentAmount(71.36, 'CNY')
const planPrice = formatPaymentAmount(200, 'CNY')
const originalPrice = formatPaymentAmount(300, 'CNY')
const convertedByRechargeMultiplier = formatPaymentAmount(50, 'CNY')
expect(text).toContain(convertedPrice)
expect(text).toContain(convertedOriginalPrice)
expect(text).not.toContain(formatPaymentAmount(7.99, 'CNY'))
expect(wrapper.findAll('button').some(button => button.text().includes(convertedPrice))).toBe(true)
expect(text).toContain(planPrice)
expect(text).toContain(originalPrice)
expect(text).not.toContain(convertedByRechargeMultiplier)
expect(wrapper.findAll('button').some(button => button.text().includes(planPrice))).toBe(true)
})
it('keeps plan price when multiplier is not configured or payment currency is not CNY', async () => {
@@ -293,10 +294,10 @@ describe('PaymentView subscription confirmation amounts', () => {
expect(usdWrapper.text()).toContain(formatPaymentAmount(9.99, 'USD'))
})
it('adds fee rate after CNY multiplier conversion to match backend pay_amount', async () => {
it('adds fee rate to the direct subscription plan price to match backend pay_amount', async () => {
const wrapper = await mountSubscriptionConfirm({
checkout: {
balance_recharge_multiplier: 0.14,
balance_recharge_multiplier: 4,
recharge_fee_rate: 2.5,
},
method: {
@@ -308,11 +309,11 @@ describe('PaymentView subscription confirmation amounts', () => {
})
const text = wrapper.text()
const convertedPrice = formatPaymentAmount(57.07, 'CNY')
const fee = formatPaymentAmount(1.43, 'CNY')
const total = formatPaymentAmount(58.5, 'CNY')
const price = formatPaymentAmount(7.99, 'CNY')
const fee = formatPaymentAmount(0.20, 'CNY')
const total = formatPaymentAmount(8.19, 'CNY')
expect(text).toContain(convertedPrice)
expect(text).toContain(price)
expect(text).toContain(fee)
expect(text).toContain(total)
expect(wrapper.findAll('button').some(button => button.text().includes(total))).toBe(true)
@@ -1,13 +1,26 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import UsageView from '../UsageView.vue'
const { query, getStatsByDateRange, list, showError, showWarning, showSuccess, showInfo } = vi.hoisted(() => ({
const {
query,
getStats,
getDashboardModels,
getDashboardSnapshotV2,
list,
getAvailable,
showError,
showWarning,
showSuccess,
showInfo,
} = vi.hoisted(() => ({
query: vi.fn(),
getStatsByDateRange: vi.fn(),
getStats: vi.fn(),
getDashboardModels: vi.fn(),
getDashboardSnapshotV2: vi.fn(),
list: vi.fn(),
getAvailable: vi.fn(),
showError: vi.fn(),
showWarning: vi.fn(),
showSuccess: vi.fn(),
@@ -15,62 +28,55 @@ const { query, getStatsByDateRange, list, showError, showWarning, showSuccess, s
}))
const messages: Record<string, string> = {
'usage.costDetails': 'Cost Breakdown',
'admin.usage.inputCost': 'Input Cost',
'admin.usage.outputCost': 'Output Cost',
'admin.usage.cacheCreationCost': 'Cache Creation Cost',
'admin.usage.cacheReadCost': 'Cache Read Cost',
'usage.inputTokenPrice': 'Input price',
'usage.outputTokenPrice': 'Output price',
'usage.perMillionTokens': '/ 1M tokens',
'usage.serviceTier': 'Service tier',
'usage.serviceTierPriority': 'Fast',
'usage.serviceTierFlex': 'Flex',
'usage.serviceTierStandard': 'Standard',
'usage.rate': 'Rate',
'usage.original': 'Original',
'usage.billed': 'Billed',
'usage.allApiKeys': 'All API Keys',
'usage.apiKeyFilter': 'API Key',
'usage.model': 'Model',
'usage.reasoningEffort': 'Reasoning Effort',
'usage.type': 'Type',
'usage.tokens': 'Tokens',
'usage.cost': 'Cost',
'usage.firstToken': 'First Token',
'usage.duration': 'Duration',
'usage.time': 'Time',
'usage.userAgent': 'User Agent',
'usage.imageUnit': ' images',
'usage.imageCount': 'Image count',
'usage.imageBillingSize': 'Billing size',
'usage.imageInputSize': 'Input size',
'usage.imageOutputSize': 'Output size',
'usage.imageSizeSource': 'Size source',
'usage.imageSizeBreakdown': 'Size breakdown',
'usage.imageSizeSourceOutput': 'Upstream output',
'usage.imageSizeSourceInput': 'Request input',
'usage.imageSizeSourceDefault': 'Default billing tier',
'usage.imageSizeSourceLegacy': 'Legacy record',
'usage.imageSizeSourceMissing': 'Not recorded',
'usage.imageSizeNotRecorded': 'not recorded',
'usage.imageSizeLegacyUnstandardized': 'legacy unstandardized',
'usage.imageSizeUnknown': 'unknown',
'usage.imageUnitPrice': 'Per-image price',
'usage.imageTotalPrice': 'Image total price',
'admin.dashboard.timeRange': 'Time range',
'admin.dashboard.granularity': 'Granularity',
'admin.dashboard.day': 'Day',
'admin.dashboard.hour': 'Hour',
'admin.users.columnSettings': 'Columns',
'admin.usage.group': 'Group',
'admin.usage.billingType': 'Billing type',
'admin.usage.billingMode': 'Billing mode',
'admin.usage.allTypes': 'All types',
'admin.usage.allBillingTypes': 'All billing types',
'admin.usage.billingTypeBalance': 'Balance',
'admin.usage.billingTypeSubscription': 'Subscription',
'admin.usage.allBillingModes': 'All billing modes',
'admin.usage.billingModeToken': 'Token',
'admin.usage.billingModePerRequest': 'Per request',
'admin.usage.billingModeImage': 'Image',
'admin.usage.allGroups': 'All groups',
'admin.usage.allModels': 'All models',
'usage.allApiKeys': 'All API Keys',
'usage.apiKeyFilter': 'API Key',
'usage.model': 'Model',
'usage.type': 'Type',
'usage.ws': 'WS',
'usage.stream': 'Stream',
'usage.sync': 'Sync',
'usage.exporting': 'Exporting',
'usage.exportCsv': 'Export CSV',
'usage.failedToLoad': 'Failed to load',
'usage.noDataToExport': 'No data',
'usage.preparingExport': 'Preparing export',
'usage.exportSuccess': 'Export success',
'usage.exportFailed': 'Export failed',
'common.refresh': 'Refresh',
'common.reset': 'Reset',
}
vi.mock('@/api', () => ({
usageAPI: {
query,
getStatsByDateRange,
getStats,
getDashboardModels,
getDashboardSnapshotV2,
},
keysAPI: {
list,
},
userGroupsAPI: {
getAvailable,
},
}))
vi.mock('@/stores/app', () => ({
@@ -87,178 +93,131 @@ vi.mock('vue-i18n', async () => {
}
})
const AppLayoutStub = { template: '<div><slot /></div>' }
const TablePageLayoutStub = {
template: '<div><slot name="actions" /><slot name="filters" /><slot name="table" /><slot /></div>',
}
const DataTableStub = {
props: ['data'],
template: `
<div>
<div v-for="row in data" :key="row.request_id">
<slot name="cell-billing_mode" :row="row" />
<slot name="cell-tokens" :row="row" />
<slot name="cell-cost" :row="row" />
</div>
</div>
`,
const simpleStub = { template: '<div><slot /></div>' }
const chartStub = { template: '<div />' }
const usageLog = {
id: 1,
request_id: 'req-user-export',
actual_cost: 0.092883,
total_cost: 0.092883,
rate_multiplier: 1,
service_tier: 'priority',
input_cost: 0.020285,
output_cost: 0.00303,
cache_creation_cost: 0.000001,
cache_read_cost: 0.069568,
input_tokens: 4057,
output_tokens: 101,
cache_creation_tokens: 4,
cache_read_tokens: 278272,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
image_count: 0,
image_size: null,
first_token_ms: 12,
duration_ms: 345,
created_at: '2026-03-08T00:00:00Z',
model: 'gpt-5.4',
reasoning_effort: null,
ip_address: '203.0.113.10',
api_key: { name: 'demo-key' },
billing_mode: 'token',
request_type: 'sync',
stream: false,
}
describe('user UsageView tooltip', () => {
function mountUsageView() {
return mount(UsageView, {
global: {
stubs: {
AppLayout: simpleStub,
Pagination: true,
Select: true,
DateRangePicker: true,
Icon: true,
UsageStatsCards: chartStub,
UsageTable: chartStub,
ModelDistributionChart: chartStub,
GroupDistributionChart: chartStub,
EndpointDistributionChart: chartStub,
TokenUsageTrend: chartStub,
},
},
})
}
describe('user UsageView', () => {
beforeEach(() => {
query.mockReset()
getStatsByDateRange.mockReset()
getStats.mockReset()
getDashboardModels.mockReset()
getDashboardSnapshotV2.mockReset()
list.mockReset()
getAvailable.mockReset()
showError.mockReset()
showWarning.mockReset()
showSuccess.mockReset()
showInfo.mockReset()
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
top: 20,
left: 20,
right: 120,
bottom: 40,
width: 100,
height: 20,
toJSON: () => ({}),
} as DOMRect)
;(globalThis as any).ResizeObserver = class {
observe() {}
disconnect() {}
}
query.mockResolvedValue({ items: [usageLog], total: 1, pages: 1 })
getStats.mockResolvedValue({
total_requests: 1,
total_input_tokens: 10,
total_output_tokens: 20,
total_cache_tokens: 0,
total_tokens: 30,
total_cost: 0.1,
total_actual_cost: 0.08,
average_duration_ms: 12,
endpoints: [],
upstream_endpoints: [],
endpoint_paths: [],
})
getDashboardModels.mockResolvedValue({
models: [{ model: 'gpt-5.4', requests: 1, input_tokens: 10, output_tokens: 20, cache_creation_tokens: 0, cache_read_tokens: 0, total_tokens: 30, cost: 0.1, actual_cost: 0.08 }],
start_date: '2026-03-08',
end_date: '2026-03-08',
})
getDashboardSnapshotV2.mockResolvedValue({
generated_at: '2026-03-08T00:00:00Z',
start_date: '2026-03-08',
end_date: '2026-03-08',
granularity: 'hour',
trend: [],
groups: [],
})
list.mockResolvedValue({ items: [{ id: 1, name: 'demo-key' }] })
getAvailable.mockResolvedValue([{ id: 1, name: 'default' }])
})
it('shows fast service tier and unit prices in user tooltip', async () => {
query.mockResolvedValue({
items: [
{
request_id: 'req-user-1',
actual_cost: 0.092883,
total_cost: 0.092883,
rate_multiplier: 1,
service_tier: 'priority',
input_cost: 0.020285,
output_cost: 0.00303,
cache_creation_cost: 0,
cache_read_cost: 0.069568,
input_tokens: 4057,
output_tokens: 101,
cache_creation_tokens: 0,
cache_read_tokens: 278272,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
image_count: 0,
image_size: null,
first_token_ms: null,
duration_ms: 1,
created_at: '2026-03-08T00:00:00Z',
},
],
total: 1,
pages: 1,
})
getStatsByDateRange.mockResolvedValue({
total_requests: 1,
total_tokens: 100,
total_cost: 0.1,
avg_duration_ms: 1,
})
list.mockResolvedValue({ items: [] })
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
Pagination: true,
EmptyState: true,
Select: true,
DateRangePicker: true,
DataTable: DataTableStub,
Icon: true,
Teleport: true,
},
},
})
it('loads logs, stats, model stats, and snapshot on first render', async () => {
mountUsageView()
await flushPromises()
await nextTick()
const setupState = (wrapper.vm as any).$?.setupState
setupState.tooltipData = {
request_id: 'req-user-1',
actual_cost: 0.092883,
total_cost: 0.092883,
rate_multiplier: 1,
service_tier: 'priority',
input_cost: 0.020285,
output_cost: 0.00303,
cache_creation_cost: 0,
cache_read_cost: 0.069568,
input_tokens: 4057,
output_tokens: 101,
}
setupState.tooltipVisible = true
await nextTick()
const text = wrapper.text()
expect(text).toContain('Service tier')
expect(text).toContain('Fast')
expect(text).toContain('Rate')
expect(text).toContain('1.00x')
expect(text).toContain('Billed')
expect(text).toContain('$0.092883')
expect(text).toContain('$5.0000 / 1M tokens')
expect(text).toContain('$30.0000 / 1M tokens')
expect(query).toHaveBeenCalled()
expect(getStats).toHaveBeenCalled()
expect(getDashboardModels).toHaveBeenCalled()
expect(getDashboardSnapshotV2).toHaveBeenCalledWith(expect.objectContaining({
include_trend: true,
include_model_stats: false,
include_group_stats: true,
}))
expect(list).toHaveBeenCalledWith(1, 100)
expect(getAvailable).toHaveBeenCalled()
})
it('exports csv with input and output unit price columns', async () => {
const exportedLogs = [
{
request_id: 'req-user-export',
actual_cost: 0.092883,
total_cost: 0.092883,
rate_multiplier: 1,
service_tier: 'priority',
input_cost: 0.020285,
output_cost: 0.00303,
cache_creation_cost: 0.000001,
cache_read_cost: 0.069568,
input_tokens: 4057,
output_tokens: 101,
cache_creation_tokens: 4,
cache_read_tokens: 278272,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
image_count: 0,
image_size: null,
first_token_ms: 12,
duration_ms: 345,
created_at: '2026-03-08T00:00:00Z',
model: 'gpt-5.4',
reasoning_effort: null,
api_key: { name: 'demo-key' },
},
]
query.mockResolvedValue({
items: exportedLogs,
total: 1,
pages: 1,
})
getStatsByDateRange.mockResolvedValue({
total_requests: 1,
total_tokens: 100,
total_cost: 0.1,
avg_duration_ms: 1,
})
list.mockResolvedValue({ items: [] })
it('exports csv with current filters and without admin-only fields', async () => {
const wrapper = mountUsageView()
await flushPromises()
let exportedBlob: Blob | null = null
let csvContent = ''
const OriginalBlob = globalThis.Blob
vi.stubGlobal('Blob', vi.fn((parts: BlobPart[], options?: BlobPropertyBag) => {
csvContent = parts.map((part) => String(part)).join('')
return new OriginalBlob(parts, options)
}))
const originalCreateObjectURL = window.URL.createObjectURL
const originalRevokeObjectURL = window.URL.revokeObjectURL
window.URL.createObjectURL = vi.fn((blob: Blob | MediaSource) => {
@@ -268,146 +227,38 @@ describe('user UsageView tooltip', () => {
window.URL.revokeObjectURL = vi.fn(() => {}) as typeof window.URL.revokeObjectURL
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
Pagination: true,
EmptyState: true,
Select: true,
DateRangePicker: true,
DataTable: DataTableStub,
Icon: true,
Teleport: true,
},
},
})
await flushPromises()
const setupState = (wrapper.vm as any).$?.setupState
await setupState.exportToCSV()
await (wrapper.vm as any).exportToCSV()
expect(exportedBlob).not.toBeNull()
const hasSortedExportQuery = query.mock.calls.some((call) => {
const params = call[0] as Record<string, unknown> | undefined
const config = call[1]
return (
params?.page_size === 100 &&
params?.sort_by === 'created_at' &&
params?.sort_order === 'desc' &&
config === undefined
)
})
expect(hasSortedExportQuery).toBe(true)
expect(query).toHaveBeenCalledWith(expect.objectContaining({
page_size: 100,
sort_by: 'created_at',
sort_order: 'desc',
}))
expect(clickSpy).toHaveBeenCalled()
expect(showSuccess).toHaveBeenCalled()
expect(csvContent).toContain('IP Address')
expect(csvContent).toContain('203.0.113.10')
expect(csvContent).toContain('Billed Cost')
expect(csvContent).toContain('Original Cost')
expect(csvContent).not.toContain('Upstream Endpoint')
expect(csvContent).not.toContain('account_cost')
expect(csvContent).not.toContain('account_rate_multiplier')
window.URL.createObjectURL = originalCreateObjectURL
window.URL.revokeObjectURL = originalRevokeObjectURL
vi.unstubAllGlobals()
clickSpy.mockRestore()
})
it('exports historical image rows with image billing mode derived from image_count', async () => {
const exportedLogs = [
{
request_id: 'req-user-export-legacy-image',
actual_cost: 0.2,
total_cost: 0.2,
rate_multiplier: 1,
service_tier: null,
input_cost: 0,
output_cost: 0,
cache_creation_cost: 0,
cache_read_cost: 0,
input_tokens: 0,
output_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
image_count: 1,
image_size: null,
billing_mode: null,
first_token_ms: null,
duration_ms: 345,
created_at: '2026-03-08T00:00:00Z',
model: 'gpt-image-2',
reasoning_effort: null,
api_key: { name: 'demo-key' },
},
]
query.mockResolvedValue({
items: exportedLogs,
total: 1,
pages: 1,
})
getStatsByDateRange.mockResolvedValue({
total_requests: 1,
total_tokens: 0,
total_cost: 0.2,
avg_duration_ms: 1,
})
list.mockResolvedValue({ items: [] })
let exportedBlob: Blob | null = null
const originalCreateObjectURL = window.URL.createObjectURL
const originalRevokeObjectURL = window.URL.revokeObjectURL
window.URL.createObjectURL = vi.fn((blob: Blob | MediaSource) => {
exportedBlob = blob as Blob
return 'blob:usage-export'
}) as typeof window.URL.createObjectURL
window.URL.revokeObjectURL = vi.fn(() => {}) as typeof window.URL.revokeObjectURL
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
Pagination: true,
EmptyState: true,
Select: true,
DateRangePicker: true,
DataTable: DataTableStub,
Icon: true,
Teleport: true,
},
},
})
await flushPromises()
const setupState = (wrapper.vm as any).$?.setupState
await setupState.exportToCSV()
expect(exportedBlob).not.toBeNull()
const csv = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(reader.error)
reader.readAsText(exportedBlob as Blob)
})
expect(csv).toContain('Billing Mode')
expect(csv).toContain('Image')
expect(csv).not.toContain(',Token,0,0,0,0,')
window.URL.createObjectURL = originalCreateObjectURL
window.URL.revokeObjectURL = originalRevokeObjectURL
clickSpy.mockRestore()
})
it('does not display a 2K fallback for historical image rows with missing size', async () => {
query.mockResolvedValue({
items: [
{
request_id: 'req-user-legacy-missing-image',
...usageLog,
request_id: 'req-user-export-legacy-image',
actual_cost: 0.2,
total_cost: 0.2,
rate_multiplier: 1,
service_tier: null,
input_cost: 0,
output_cost: 0,
cache_creation_cost: 0,
@@ -416,125 +267,40 @@ describe('user UsageView tooltip', () => {
output_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
image_count: 1,
image_size: null,
image_input_size: null,
image_output_size: null,
image_size_source: null,
image_size_breakdown: null,
billing_mode: null,
first_token_ms: null,
duration_ms: 1,
created_at: '2026-03-08T00:00:00Z',
model: 'gpt-image-2',
billing_mode: null,
ip_address: null,
},
],
total: 1,
pages: 1,
})
getStatsByDateRange.mockResolvedValue({
total_requests: 1,
total_tokens: 0,
total_cost: 0.2,
avg_duration_ms: 1,
})
list.mockResolvedValue({ items: [] })
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
Pagination: true,
EmptyState: true,
Select: true,
DateRangePicker: true,
DataTable: DataTableStub,
Icon: true,
Teleport: true,
},
},
})
await flushPromises()
await nextTick()
const text = wrapper.text()
expect(text).toContain('Image')
expect(text).toContain('not recorded')
expect(text).not.toContain('(2K)')
})
it('shows image billing metadata in the user cost tooltip', async () => {
query.mockResolvedValue({
items: [],
total: 0,
pages: 0,
})
getStatsByDateRange.mockResolvedValue({
total_requests: 0,
total_tokens: 0,
total_cost: 0,
avg_duration_ms: 0,
})
list.mockResolvedValue({ items: [] })
const wrapper = mount(UsageView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
Pagination: true,
EmptyState: true,
Select: true,
DateRangePicker: true,
DataTable: DataTableStub,
Icon: true,
Teleport: true,
},
},
})
const wrapper = mountUsageView()
await flushPromises()
const setupState = (wrapper.vm as any).$?.setupState
setupState.tooltipData = {
request_id: 'req-user-output-image',
actual_cost: 0.8,
total_cost: 0.8,
rate_multiplier: 1,
service_tier: null,
input_cost: 0,
output_cost: 0,
cache_creation_cost: 0,
cache_read_cost: 0,
input_tokens: 0,
output_tokens: 0,
cache_creation_tokens: 0,
cache_read_tokens: 0,
billing_mode: null,
image_count: 2,
image_size: '4K',
image_input_size: '1024x1024',
image_output_size: '3840x2160',
image_size_source: 'output',
image_size_breakdown: { '4K': 2 },
}
setupState.tooltipVisible = true
await nextTick()
let csvContent = ''
const OriginalBlob = globalThis.Blob
vi.stubGlobal('Blob', vi.fn((parts: BlobPart[], options?: BlobPropertyBag) => {
csvContent = parts.map((part) => String(part)).join('')
return new OriginalBlob(parts, options)
}))
const originalCreateObjectURL = window.URL.createObjectURL
const originalRevokeObjectURL = window.URL.revokeObjectURL
window.URL.createObjectURL = vi.fn(() => 'blob:usage-export') as typeof window.URL.createObjectURL
window.URL.revokeObjectURL = vi.fn(() => {}) as typeof window.URL.revokeObjectURL
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const text = wrapper.text()
expect(text).toContain('Image count')
expect(text).toContain('Billing size')
expect(text).toContain('4K')
expect(text).toContain('Size source')
expect(text).toContain('Upstream output')
expect(text).toContain('Input size')
expect(text).toContain('1024x1024')
expect(text).toContain('Output size')
expect(text).toContain('3840x2160')
expect(text).toContain('4K x 2')
await (wrapper.vm as any).exportToCSV()
expect(csvContent).toContain('Billing Mode')
expect(csvContent).toContain('Image')
expect(csvContent).not.toContain(',Token,0,0,0,0,')
window.URL.createObjectURL = originalCreateObjectURL
window.URL.revokeObjectURL = originalRevokeObjectURL
vi.unstubAllGlobals()
clickSpy.mockRestore()
})
})