mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge branch 'Wei-Shaw:main' into main
This commit is contained in:
@@ -1 +1 @@
|
||||
0.1.139
|
||||
0.1.141
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
|
||||
@@ -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 最小账号信息(避免泄露敏感字段)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 加固
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 加固
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 })})`,
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user