diff --git a/backend/internal/handler/admin/ops_realtime_handler.go b/backend/internal/handler/admin/ops_realtime_handler.go index 0c23c13b..4f15ec57 100644 --- a/backend/internal/handler/admin/ops_realtime_handler.go +++ b/backend/internal/handler/admin/ops_realtime_handler.go @@ -118,3 +118,96 @@ func (h *OpsHandler) GetAccountAvailability(c *gin.Context) { } response.Success(c, payload) } + +func parseOpsRealtimeWindow(v string) (time.Duration, string, bool) { + switch strings.ToLower(strings.TrimSpace(v)) { + case "", "1min", "1m": + return 1 * time.Minute, "1min", true + case "5min", "5m": + return 5 * time.Minute, "5min", true + case "30min", "30m": + return 30 * time.Minute, "30min", true + case "1h", "60m", "60min": + return 1 * time.Hour, "1h", true + default: + return 0, "", false + } +} + +// GetRealtimeTrafficSummary returns QPS/TPS current/peak/avg for the selected window. +// GET /api/v1/admin/ops/realtime-traffic +// +// Query params: +// - window: 1min|5min|30min|1h (default: 1min) +// - platform: optional +// - group_id: optional +func (h *OpsHandler) GetRealtimeTrafficSummary(c *gin.Context) { + if h.opsService == nil { + response.Error(c, http.StatusServiceUnavailable, "Ops service not available") + return + } + if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil { + response.ErrorFrom(c, err) + return + } + + windowDur, windowLabel, ok := parseOpsRealtimeWindow(c.Query("window")) + if !ok { + response.BadRequest(c, "Invalid window") + return + } + + platform := strings.TrimSpace(c.Query("platform")) + var groupID *int64 + if v := strings.TrimSpace(c.Query("group_id")); v != "" { + id, err := strconv.ParseInt(v, 10, 64) + if err != nil || id <= 0 { + response.BadRequest(c, "Invalid group_id") + return + } + groupID = &id + } + + endTime := time.Now().UTC() + startTime := endTime.Add(-windowDur) + + if !h.opsService.IsRealtimeMonitoringEnabled(c.Request.Context()) { + disabledSummary := &service.OpsRealtimeTrafficSummary{ + Window: windowLabel, + StartTime: startTime, + EndTime: endTime, + Platform: platform, + GroupID: groupID, + QPS: service.OpsRateSummary{}, + TPS: service.OpsRateSummary{}, + } + response.Success(c, gin.H{ + "enabled": false, + "summary": disabledSummary, + "timestamp": endTime, + }) + return + } + + filter := &service.OpsDashboardFilter{ + StartTime: startTime, + EndTime: endTime, + Platform: platform, + GroupID: groupID, + QueryMode: service.OpsQueryModeRaw, + } + + summary, err := h.opsService.GetRealtimeTrafficSummary(c.Request.Context(), filter) + if err != nil { + response.ErrorFrom(c, err) + return + } + if summary != nil { + summary.Window = windowLabel + } + response.Success(c, gin.H{ + "enabled": true, + "summary": summary, + "timestamp": endTime, + }) +} diff --git a/backend/internal/handler/admin/ops_settings_handler.go b/backend/internal/handler/admin/ops_settings_handler.go index 0e0ecb72..ebc8bf49 100644 --- a/backend/internal/handler/admin/ops_settings_handler.go +++ b/backend/internal/handler/admin/ops_settings_handler.go @@ -146,3 +146,49 @@ func (h *OpsHandler) UpdateAdvancedSettings(c *gin.Context) { } response.Success(c, updated) } + +// GetMetricThresholds returns Ops metric thresholds (DB-backed). +// GET /api/v1/admin/ops/settings/metric-thresholds +func (h *OpsHandler) GetMetricThresholds(c *gin.Context) { + if h.opsService == nil { + response.Error(c, http.StatusServiceUnavailable, "Ops service not available") + return + } + if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil { + response.ErrorFrom(c, err) + return + } + + cfg, err := h.opsService.GetMetricThresholds(c.Request.Context()) + if err != nil { + response.Error(c, http.StatusInternalServerError, "Failed to get metric thresholds") + return + } + response.Success(c, cfg) +} + +// UpdateMetricThresholds updates Ops metric thresholds (DB-backed). +// PUT /api/v1/admin/ops/settings/metric-thresholds +func (h *OpsHandler) UpdateMetricThresholds(c *gin.Context) { + if h.opsService == nil { + response.Error(c, http.StatusServiceUnavailable, "Ops service not available") + return + } + if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil { + response.ErrorFrom(c, err) + return + } + + var req service.OpsMetricThresholds + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request body") + return + } + + updated, err := h.opsService.UpdateMetricThresholds(c.Request.Context(), &req) + if err != nil { + response.Error(c, http.StatusBadRequest, err.Error()) + return + } + response.Success(c, updated) +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index c5cce937..882e4cf2 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -3,6 +3,7 @@ package handler import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/handler/dto" + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/pkg/response" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -76,7 +77,7 @@ func (h *AuthHandler) Register(c *gin.Context) { // Turnstile 验证(当提供了邮箱验证码时跳过,因为发送验证码时已验证过) if req.VerifyCode == "" { - if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, c.ClientIP()); err != nil { + if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil { response.ErrorFrom(c, err) return } @@ -105,7 +106,7 @@ func (h *AuthHandler) SendVerifyCode(c *gin.Context) { } // Turnstile 验证 - if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, c.ClientIP()); err != nil { + if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil { response.ErrorFrom(c, err) return } @@ -132,7 +133,7 @@ func (h *AuthHandler) Login(c *gin.Context) { } // Turnstile 验证 - if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, c.ClientIP()); err != nil { + if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil { response.ErrorFrom(c, err) return } diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 284a4f8f..b60618a8 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -15,6 +15,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" pkgerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -88,6 +89,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) { return } + // 检查是否为 Claude Code 客户端,设置到 context 中 + SetClaudeCodeClientContext(c, body) + setOpsRequestContext(c, "", false, body) parsedReq, err := service.ParseGatewayRequest(body) @@ -271,12 +275,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) { var failoverErr *service.UpstreamFailoverError if errors.As(err, &failoverErr) { failedAccountIDs[account.ID] = struct{}{} + lastFailoverStatus = failoverErr.StatusCode if switchCount >= maxAccountSwitches { - lastFailoverStatus = failoverErr.StatusCode h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted) return } - lastFailoverStatus = failoverErr.StatusCode switchCount++ log.Printf("Account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches) continue @@ -286,8 +289,12 @@ func (h *GatewayHandler) Messages(c *gin.Context) { return } + // 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context) + userAgent := c.GetHeader("User-Agent") + clientIP := ip.GetClientIP(c) + // 异步记录使用量(subscription已在函数开头获取) - go func(result *service.ForwardResult, usedAccount *service.Account) { + go func(result *service.ForwardResult, usedAccount *service.Account, ua, clientIP string) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ @@ -296,10 +303,12 @@ func (h *GatewayHandler) Messages(c *gin.Context) { User: apiKey.User, Account: usedAccount, Subscription: subscription, + UserAgent: ua, + IPAddress: clientIP, }); err != nil { log.Printf("Record usage failed: %v", err) } - }(result, account) + }(result, account, userAgent, clientIP) return } } @@ -399,12 +408,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) { var failoverErr *service.UpstreamFailoverError if errors.As(err, &failoverErr) { failedAccountIDs[account.ID] = struct{}{} + lastFailoverStatus = failoverErr.StatusCode if switchCount >= maxAccountSwitches { - lastFailoverStatus = failoverErr.StatusCode h.handleFailoverExhausted(c, lastFailoverStatus, streamStarted) return } - lastFailoverStatus = failoverErr.StatusCode switchCount++ log.Printf("Account %d: upstream error %d, switching account %d/%d", account.ID, failoverErr.StatusCode, switchCount, maxAccountSwitches) continue @@ -414,8 +422,12 @@ func (h *GatewayHandler) Messages(c *gin.Context) { return } + // 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context) + userAgent := c.GetHeader("User-Agent") + clientIP := ip.GetClientIP(c) + // 异步记录使用量(subscription已在函数开头获取) - go func(result *service.ForwardResult, usedAccount *service.Account) { + go func(result *service.ForwardResult, usedAccount *service.Account, ua, clientIP string) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ @@ -424,10 +436,12 @@ func (h *GatewayHandler) Messages(c *gin.Context) { User: apiKey.User, Account: usedAccount, Subscription: subscription, + UserAgent: ua, + IPAddress: clientIP, }); err != nil { log.Printf("Record usage failed: %v", err) } - }(result, account) + }(result, account, userAgent, clientIP) return } } diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index d639beb3..2dddb856 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -12,6 +12,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "github.com/Wei-Shaw/sub2api/internal/pkg/gemini" "github.com/Wei-Shaw/sub2api/internal/pkg/googleapi" + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -314,8 +315,12 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { return } + // 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context) + userAgent := c.GetHeader("User-Agent") + clientIP := ip.GetClientIP(c) + // 6) record usage async - go func(result *service.ForwardResult, usedAccount *service.Account) { + go func(result *service.ForwardResult, usedAccount *service.Account, ua, ip string) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ @@ -324,10 +329,12 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { User: apiKey.User, Account: usedAccount, Subscription: subscription, + UserAgent: ua, + IPAddress: ip, }); err != nil { log.Printf("Record usage failed: %v", err) } - }(result, account) + }(result, account, userAgent, clientIP) return } } diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 5f3474b0..3011b97d 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -12,6 +12,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -263,8 +264,12 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { return } + // 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context) + userAgent := c.GetHeader("User-Agent") + clientIP := ip.GetClientIP(c) + // Async record usage - go func(result *service.OpenAIForwardResult, usedAccount *service.Account) { + go func(result *service.OpenAIForwardResult, usedAccount *service.Account, ua, ip string) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{ @@ -273,10 +278,12 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { User: apiKey.User, Account: usedAccount, Subscription: subscription, + UserAgent: ua, + IPAddress: ip, }); err != nil { log.Printf("Record usage failed: %v", err) } - }(result, account) + }(result, account, userAgent, clientIP) return } } diff --git a/backend/internal/handler/ops_error_logger.go b/backend/internal/handler/ops_error_logger.go index 7115059a..13bd9d94 100644 --- a/backend/internal/handler/ops_error_logger.go +++ b/backend/internal/handler/ops_error_logger.go @@ -15,6 +15,7 @@ import ( "unicode/utf8" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -489,6 +490,7 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { Severity: classifyOpsSeverity("upstream_error", effectiveUpstreamStatus), StatusCode: status, IsBusinessLimited: false, + IsCountTokens: isCountTokensRequest(c), ErrorMessage: recoveredMsg, ErrorBody: "", @@ -521,7 +523,7 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { } var clientIP string - if ip := strings.TrimSpace(c.ClientIP()); ip != "" { + if ip := strings.TrimSpace(ip.GetClientIP(c)); ip != "" { clientIP = ip entry.ClientIP = &clientIP } @@ -598,6 +600,7 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { Severity: classifyOpsSeverity(parsed.ErrorType, status), StatusCode: status, IsBusinessLimited: isBusinessLimited, + IsCountTokens: isCountTokensRequest(c), ErrorMessage: parsed.Message, // Keep the full captured error body (capture is already capped at 64KB) so the @@ -680,7 +683,7 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc { } var clientIP string - if ip := strings.TrimSpace(c.ClientIP()); ip != "" { + if ip := strings.TrimSpace(ip.GetClientIP(c)); ip != "" { clientIP = ip entry.ClientIP = &clientIP } @@ -704,6 +707,14 @@ var opsRetryRequestHeaderAllowlist = []string{ "anthropic-version", } +// isCountTokensRequest checks if the request is a count_tokens request +func isCountTokensRequest(c *gin.Context) bool { + if c == nil || c.Request == nil || c.Request.URL == nil { + return false + } + return strings.Contains(c.Request.URL.Path, "/count_tokens") +} + func extractOpsRetryRequestHeaders(c *gin.Context) *string { if c == nil || c.Request == nil { return nil diff --git a/backend/internal/repository/ops_repo.go b/backend/internal/repository/ops_repo.go index 8e157dbf..f9cb6b4d 100644 --- a/backend/internal/repository/ops_repo.go +++ b/backend/internal/repository/ops_repo.go @@ -46,6 +46,7 @@ INSERT INTO ops_error_logs ( severity, status_code, is_business_limited, + is_count_tokens, error_message, error_body, error_source, @@ -64,7 +65,7 @@ INSERT INTO ops_error_logs ( retry_count, created_at ) VALUES ( - $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34 + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35 ) RETURNING id` var id int64 @@ -88,6 +89,7 @@ INSERT INTO ops_error_logs ( opsNullString(input.Severity), opsNullInt(input.StatusCode), input.IsBusinessLimited, + input.IsCountTokens, opsNullString(input.ErrorMessage), opsNullString(input.ErrorBody), opsNullString(input.ErrorSource), diff --git a/backend/internal/repository/ops_repo_dashboard.go b/backend/internal/repository/ops_repo_dashboard.go index 194020bb..85791a9a 100644 --- a/backend/internal/repository/ops_repo_dashboard.go +++ b/backend/internal/repository/ops_repo_dashboard.go @@ -964,8 +964,8 @@ func buildErrorWhere(filter *service.OpsDashboardFilter, start, end time.Time, s } idx := startIndex - clauses := make([]string, 0, 4) - args = make([]any, 0, 4) + clauses := make([]string, 0, 5) + args = make([]any, 0, 5) args = append(args, start) clauses = append(clauses, fmt.Sprintf("created_at >= $%d", idx)) @@ -974,6 +974,8 @@ func buildErrorWhere(filter *service.OpsDashboardFilter, start, end time.Time, s clauses = append(clauses, fmt.Sprintf("created_at < $%d", idx)) idx++ + clauses = append(clauses, "is_count_tokens = FALSE") + if groupID != nil && *groupID > 0 { args = append(args, *groupID) clauses = append(clauses, fmt.Sprintf("group_id = $%d", idx)) diff --git a/backend/internal/repository/ops_repo_preagg.go b/backend/internal/repository/ops_repo_preagg.go index fc74e4f6..60f6da0f 100644 --- a/backend/internal/repository/ops_repo_preagg.go +++ b/backend/internal/repository/ops_repo_preagg.go @@ -78,7 +78,9 @@ error_base AS ( status_code AS client_status_code, COALESCE(upstream_status_code, status_code, 0) AS effective_status_code FROM ops_error_logs + -- Exclude count_tokens requests from error metrics as they are informational probes WHERE created_at >= $1 AND created_at < $2 + AND is_count_tokens = FALSE ), error_agg AS ( SELECT diff --git a/backend/internal/repository/ops_repo_realtime_traffic.go b/backend/internal/repository/ops_repo_realtime_traffic.go new file mode 100644 index 00000000..a9b0b929 --- /dev/null +++ b/backend/internal/repository/ops_repo_realtime_traffic.go @@ -0,0 +1,129 @@ +package repository + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +func (r *opsRepository) GetRealtimeTrafficSummary(ctx context.Context, filter *service.OpsDashboardFilter) (*service.OpsRealtimeTrafficSummary, error) { + if r == nil || r.db == nil { + return nil, fmt.Errorf("nil ops repository") + } + if filter == nil { + return nil, fmt.Errorf("nil filter") + } + if filter.StartTime.IsZero() || filter.EndTime.IsZero() { + return nil, fmt.Errorf("start_time/end_time required") + } + + start := filter.StartTime.UTC() + end := filter.EndTime.UTC() + if start.After(end) { + return nil, fmt.Errorf("start_time must be <= end_time") + } + + window := end.Sub(start) + if window <= 0 { + return nil, fmt.Errorf("invalid time window") + } + if window > time.Hour { + return nil, fmt.Errorf("window too large") + } + + usageJoin, usageWhere, usageArgs, next := buildUsageWhere(filter, start, end, 1) + errorWhere, errorArgs, _ := buildErrorWhere(filter, start, end, next) + + q := ` +WITH usage_buckets AS ( + SELECT + date_trunc('minute', ul.created_at) AS bucket, + COALESCE(COUNT(*), 0) AS success_count, + COALESCE(SUM(input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens), 0) AS token_sum + FROM usage_logs ul + ` + usageJoin + ` + ` + usageWhere + ` + GROUP BY 1 +), +error_buckets AS ( + SELECT + date_trunc('minute', created_at) AS bucket, + COALESCE(COUNT(*), 0) AS error_count + FROM ops_error_logs + ` + errorWhere + ` + AND COALESCE(status_code, 0) >= 400 + GROUP BY 1 +), +combined AS ( + SELECT + COALESCE(u.bucket, e.bucket) AS bucket, + COALESCE(u.success_count, 0) AS success_count, + COALESCE(u.token_sum, 0) AS token_sum, + COALESCE(e.error_count, 0) AS error_count, + COALESCE(u.success_count, 0) + COALESCE(e.error_count, 0) AS request_total + FROM usage_buckets u + FULL OUTER JOIN error_buckets e ON u.bucket = e.bucket +) +SELECT + COALESCE(SUM(success_count), 0) AS success_total, + COALESCE(SUM(error_count), 0) AS error_total, + COALESCE(SUM(token_sum), 0) AS token_total, + COALESCE(MAX(request_total), 0) AS peak_requests_per_min, + COALESCE(MAX(token_sum), 0) AS peak_tokens_per_min +FROM combined` + + args := append(usageArgs, errorArgs...) + var successCount int64 + var errorTotal int64 + var tokenConsumed int64 + var peakRequestsPerMin int64 + var peakTokensPerMin int64 + if err := r.db.QueryRowContext(ctx, q, args...).Scan( + &successCount, + &errorTotal, + &tokenConsumed, + &peakRequestsPerMin, + &peakTokensPerMin, + ); err != nil { + return nil, err + } + + windowSeconds := window.Seconds() + if windowSeconds <= 0 { + windowSeconds = 1 + } + + requestCountTotal := successCount + errorTotal + qpsAvg := roundTo1DP(float64(requestCountTotal) / windowSeconds) + tpsAvg := roundTo1DP(float64(tokenConsumed) / windowSeconds) + + // Keep "current" consistent with the dashboard overview semantics: last 1 minute. + // This remains "within the selected window" since end=start+window. + qpsCurrent, tpsCurrent, err := r.queryCurrentRates(ctx, filter, end) + if err != nil { + return nil, err + } + + qpsPeak := roundTo1DP(float64(peakRequestsPerMin) / 60.0) + tpsPeak := roundTo1DP(float64(peakTokensPerMin) / 60.0) + + return &service.OpsRealtimeTrafficSummary{ + StartTime: start, + EndTime: end, + Platform: strings.TrimSpace(filter.Platform), + GroupID: filter.GroupID, + QPS: service.OpsRateSummary{ + Current: qpsCurrent, + Peak: qpsPeak, + Avg: qpsAvg, + }, + TPS: service.OpsRateSummary{ + Current: tpsCurrent, + Peak: tpsPeak, + Avg: tpsAvg, + }, + }, nil +} diff --git a/backend/internal/repository/ops_repo_trends.go b/backend/internal/repository/ops_repo_trends.go index e4ac96d3..022d1187 100644 --- a/backend/internal/repository/ops_repo_trends.go +++ b/backend/internal/repository/ops_repo_trends.go @@ -170,6 +170,7 @@ error_totals AS ( FROM ops_error_logs WHERE created_at >= $1 AND created_at < $2 AND COALESCE(status_code, 0) >= 400 + AND is_count_tokens = FALSE -- 排除 count_tokens 请求的错误 GROUP BY 1 ), combined AS ( @@ -243,6 +244,7 @@ error_totals AS ( AND platform = $3 AND group_id IS NOT NULL AND COALESCE(status_code, 0) >= 400 + AND is_count_tokens = FALSE -- 排除 count_tokens 请求的错误 GROUP BY 1 ), combined AS ( diff --git a/backend/internal/repository/scheduler_outbox_repo.go b/backend/internal/repository/scheduler_outbox_repo.go index 1a09c0d1..d7bc97da 100644 --- a/backend/internal/repository/scheduler_outbox_repo.go +++ b/backend/internal/repository/scheduler_outbox_repo.go @@ -80,17 +80,17 @@ func enqueueSchedulerOutbox(ctx context.Context, exec sqlExecutor, eventType str if exec == nil { return nil } - var payloadJSON []byte + var payloadArg any if payload != nil { encoded, err := json.Marshal(payload) if err != nil { return err } - payloadJSON = encoded + payloadArg = encoded } _, err := exec.ExecContext(ctx, ` INSERT INTO scheduler_outbox (event_type, account_id, group_id, payload) VALUES ($1, $2, $3, $4) - `, eventType, accountID, groupID, payloadJSON) + `, eventType, accountID, groupID, payloadArg) return err } diff --git a/backend/internal/repository/scheduler_snapshot_outbox_integration_test.go b/backend/internal/repository/scheduler_snapshot_outbox_integration_test.go index e82d663f..dede6014 100644 --- a/backend/internal/repository/scheduler_snapshot_outbox_integration_test.go +++ b/backend/internal/repository/scheduler_snapshot_outbox_integration_test.go @@ -46,25 +46,12 @@ func TestSchedulerSnapshotOutboxReplay(t *testing.T) { Extra: map[string]any{}, } require.NoError(t, accountRepo.Create(ctx, account)) + require.NoError(t, cache.SetAccount(ctx, account)) svc := service.NewSchedulerSnapshotService(cache, outboxRepo, accountRepo, nil, cfg) svc.Start() t.Cleanup(svc.Stop) - bucket := service.SchedulerBucket{GroupID: 0, Platform: service.PlatformOpenAI, Mode: service.SchedulerModeSingle} - require.Eventually(t, func() bool { - accounts, hit, err := cache.GetSnapshot(ctx, bucket) - if err != nil || !hit { - return false - } - for _, acc := range accounts { - if acc.ID == account.ID { - return true - } - } - return false - }, 5*time.Second, 100*time.Millisecond) - require.NoError(t, accountRepo.UpdateLastUsed(ctx, account.ID)) updated, err := accountRepo.GetByID(ctx, account.ID) require.NoError(t, err) diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 111e4578..9bb019bb 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -73,6 +73,7 @@ func registerOpsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { // Realtime ops signals ops.GET("/concurrency", h.Admin.Ops.GetConcurrencyStats) ops.GET("/account-availability", h.Admin.Ops.GetAccountAvailability) + ops.GET("/realtime-traffic", h.Admin.Ops.GetRealtimeTrafficSummary) // Alerts (rules + events) ops.GET("/alert-rules", h.Admin.Ops.ListAlertRules) @@ -96,6 +97,13 @@ func registerOpsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { ops.GET("/advanced-settings", h.Admin.Ops.GetAdvancedSettings) ops.PUT("/advanced-settings", h.Admin.Ops.UpdateAdvancedSettings) + // Settings group (DB-backed) + settings := ops.Group("/settings") + { + settings.GET("/metric-thresholds", h.Admin.Ops.GetMetricThresholds) + settings.PUT("/metric-thresholds", h.Admin.Ops.UpdateMetricThresholds) + } + // WebSocket realtime (QPS/TPS) ws := ops.Group("/ws") { diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index 3dab7b7f..60567434 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -523,6 +523,9 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, proxyURL = account.Proxy.URL() } + // Sanitize thinking blocks (clean cache_control and flatten history thinking) + sanitizeThinkingBlocks(&claudeReq) + // 获取转换选项 // Antigravity 上游要求必须包含身份提示词,否则会返回 429 transformOpts := s.getClaudeTransformOptions(ctx) @@ -534,6 +537,9 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, return nil, fmt.Errorf("transform request: %w", err) } + // Safety net: ensure no cache_control leaked into Gemini request + geminiBody = cleanCacheControlFromGeminiJSON(geminiBody) + // Antigravity 上游只支持流式请求,统一使用 streamGenerateContent // 如果客户端请求非流式,在响应处理阶段会收集完整流式响应后转换返回 action := "streamGenerateContent" @@ -903,6 +909,143 @@ func extractAntigravityErrorMessage(body []byte) string { return "" } +// cleanCacheControlFromGeminiJSON removes cache_control from Gemini JSON (emergency fix) +// This should not be needed if transformation is correct, but serves as a safety net +func cleanCacheControlFromGeminiJSON(body []byte) []byte { + // Try a more robust approach: parse and clean + var data map[string]any + if err := json.Unmarshal(body, &data); err != nil { + log.Printf("[Antigravity] Failed to parse Gemini JSON for cache_control cleaning: %v", err) + return body + } + + cleaned := removeCacheControlFromAny(data) + if !cleaned { + return body + } + + if result, err := json.Marshal(data); err == nil { + log.Printf("[Antigravity] Successfully cleaned cache_control from Gemini JSON") + return result + } + + return body +} + +// removeCacheControlFromAny recursively removes cache_control fields +func removeCacheControlFromAny(v any) bool { + cleaned := false + + switch val := v.(type) { + case map[string]any: + for k, child := range val { + if k == "cache_control" { + delete(val, k) + cleaned = true + } else if removeCacheControlFromAny(child) { + cleaned = true + } + } + case []any: + for _, item := range val { + if removeCacheControlFromAny(item) { + cleaned = true + } + } + } + + return cleaned +} + +// sanitizeThinkingBlocks cleans cache_control and flattens history thinking blocks +// Thinking blocks do NOT support cache_control field (Anthropic API/Vertex AI requirement) +// Additionally, history thinking blocks are flattened to text to avoid upstream validation errors +func sanitizeThinkingBlocks(req *antigravity.ClaudeRequest) { + if req == nil { + return + } + + log.Printf("[Antigravity] sanitizeThinkingBlocks: processing request with %d messages", len(req.Messages)) + + // Clean system blocks + if len(req.System) > 0 { + var systemBlocks []map[string]any + if err := json.Unmarshal(req.System, &systemBlocks); err == nil { + for i := range systemBlocks { + if blockType, _ := systemBlocks[i]["type"].(string); blockType == "thinking" || systemBlocks[i]["thinking"] != nil { + if removeCacheControlFromAny(systemBlocks[i]) { + log.Printf("[Antigravity] Deep cleaned cache_control from thinking block in system[%d]", i) + } + } + } + // Marshal back + if cleaned, err := json.Marshal(systemBlocks); err == nil { + req.System = cleaned + } + } + } + + // Clean message content blocks and flatten history + lastMsgIdx := len(req.Messages) - 1 + for msgIdx := range req.Messages { + raw := req.Messages[msgIdx].Content + if len(raw) == 0 { + continue + } + + // Try to parse as blocks array + var blocks []map[string]any + if err := json.Unmarshal(raw, &blocks); err != nil { + continue + } + + cleaned := false + for blockIdx := range blocks { + blockType, _ := blocks[blockIdx]["type"].(string) + + // Check for thinking blocks (typed or untyped) + if blockType == "thinking" || blocks[blockIdx]["thinking"] != nil { + // 1. Clean cache_control + if removeCacheControlFromAny(blocks[blockIdx]) { + log.Printf("[Antigravity] Deep cleaned cache_control from thinking block in messages[%d].content[%d]", msgIdx, blockIdx) + cleaned = true + } + + // 2. Flatten to text if it's a history message (not the last one) + if msgIdx < lastMsgIdx { + log.Printf("[Antigravity] Flattening history thinking block to text at messages[%d].content[%d]", msgIdx, blockIdx) + + // Extract thinking content + var textContent string + if t, ok := blocks[blockIdx]["thinking"].(string); ok { + textContent = t + } else { + // Fallback for non-string content (marshal it) + if b, err := json.Marshal(blocks[blockIdx]["thinking"]); err == nil { + textContent = string(b) + } + } + + // Convert to text block + blocks[blockIdx]["type"] = "text" + blocks[blockIdx]["text"] = textContent + delete(blocks[blockIdx], "thinking") + delete(blocks[blockIdx], "signature") + delete(blocks[blockIdx], "cache_control") // Ensure it's gone + cleaned = true + } + } + } + + // Marshal back if modified + if cleaned { + if marshaled, err := json.Marshal(blocks); err == nil { + req.Messages[msgIdx].Content = marshaled + } + } + } +} + // stripThinkingFromClaudeRequest converts thinking blocks to text blocks in a Claude Messages request. // This preserves the thinking content while avoiding signature validation errors. // Note: redacted_thinking blocks are removed because they cannot be converted to text. diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index e2c70e45..2a5c44c6 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -1227,6 +1227,9 @@ func enforceCacheControlLimit(body []byte) []byte { return body } + // 清理 thinking 块中的非法 cache_control(thinking 块不支持该字段) + removeCacheControlFromThinkingBlocks(data) + // 计算当前 cache_control 块数量 count := countCacheControlBlocks(data) if count <= maxCacheControlBlocks { @@ -1254,6 +1257,7 @@ func enforceCacheControlLimit(body []byte) []byte { } // countCacheControlBlocks 统计 system 和 messages 中的 cache_control 块数量 +// 注意:thinking 块不支持 cache_control,统计时跳过 func countCacheControlBlocks(data map[string]any) int { count := 0 @@ -1261,6 +1265,10 @@ func countCacheControlBlocks(data map[string]any) int { if system, ok := data["system"].([]any); ok { for _, item := range system { if m, ok := item.(map[string]any); ok { + // thinking 块不支持 cache_control,跳过 + if blockType, _ := m["type"].(string); blockType == "thinking" { + continue + } if _, has := m["cache_control"]; has { count++ } @@ -1275,6 +1283,10 @@ func countCacheControlBlocks(data map[string]any) int { if content, ok := msgMap["content"].([]any); ok { for _, item := range content { if m, ok := item.(map[string]any); ok { + // thinking 块不支持 cache_control,跳过 + if blockType, _ := m["type"].(string); blockType == "thinking" { + continue + } if _, has := m["cache_control"]; has { count++ } @@ -1290,6 +1302,7 @@ func countCacheControlBlocks(data map[string]any) int { // removeCacheControlFromMessages 从 messages 中移除一个 cache_control(从头开始) // 返回 true 表示成功移除,false 表示没有可移除的 +// 注意:跳过 thinking 块(它不支持 cache_control) func removeCacheControlFromMessages(data map[string]any) bool { messages, ok := data["messages"].([]any) if !ok { @@ -1307,6 +1320,10 @@ func removeCacheControlFromMessages(data map[string]any) bool { } for _, item := range content { if m, ok := item.(map[string]any); ok { + // thinking 块不支持 cache_control,跳过 + if blockType, _ := m["type"].(string); blockType == "thinking" { + continue + } if _, has := m["cache_control"]; has { delete(m, "cache_control") return true @@ -1319,6 +1336,7 @@ func removeCacheControlFromMessages(data map[string]any) bool { // removeCacheControlFromSystem 从 system 中移除一个 cache_control(从尾部开始,保护注入的 prompt) // 返回 true 表示成功移除,false 表示没有可移除的 +// 注意:跳过 thinking 块(它不支持 cache_control) func removeCacheControlFromSystem(data map[string]any) bool { system, ok := data["system"].([]any) if !ok { @@ -1328,6 +1346,10 @@ func removeCacheControlFromSystem(data map[string]any) bool { // 从尾部开始移除,保护开头注入的 Claude Code prompt for i := len(system) - 1; i >= 0; i-- { if m, ok := system[i].(map[string]any); ok { + // thinking 块不支持 cache_control,跳过 + if blockType, _ := m["type"].(string); blockType == "thinking" { + continue + } if _, has := m["cache_control"]; has { delete(m, "cache_control") return true @@ -1337,6 +1359,44 @@ func removeCacheControlFromSystem(data map[string]any) bool { return false } +// removeCacheControlFromThinkingBlocks 强制清理所有 thinking 块中的非法 cache_control +// thinking 块不支持 cache_control 字段,这个函数确保所有 thinking 块都不含该字段 +func removeCacheControlFromThinkingBlocks(data map[string]any) { + // 清理 system 中的 thinking 块 + if system, ok := data["system"].([]any); ok { + for _, item := range system { + if m, ok := item.(map[string]any); ok { + if blockType, _ := m["type"].(string); blockType == "thinking" { + if _, has := m["cache_control"]; has { + delete(m, "cache_control") + log.Printf("[Warning] Removed illegal cache_control from thinking block in system") + } + } + } + } + } + + // 清理 messages 中的 thinking 块 + if messages, ok := data["messages"].([]any); ok { + for msgIdx, msg := range messages { + if msgMap, ok := msg.(map[string]any); ok { + if content, ok := msgMap["content"].([]any); ok { + for contentIdx, item := range content { + if m, ok := item.(map[string]any); ok { + if blockType, _ := m["type"].(string); blockType == "thinking" { + if _, has := m["cache_control"]; has { + delete(m, "cache_control") + log.Printf("[Warning] Removed illegal cache_control from thinking block in messages[%d].content[%d]", msgIdx, contentIdx) + } + } + } + } + } + } + } + } +} + // Forward 转发请求到Claude API func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest) (*ForwardResult, error) { startTime := time.Now() diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index f9078959..e86aa2d3 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -545,14 +545,12 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco isCodexCLI := openai.IsCodexCLIRequest(c.GetHeader("User-Agent")) - // Apply model mapping (skip for Codex CLI for transparent forwarding) - mappedModel := reqModel - if !isCodexCLI { - mappedModel = account.GetMappedModel(reqModel) - if mappedModel != reqModel { - reqBody["model"] = mappedModel - bodyModified = true - } + // Apply model mapping for all requests (including Codex CLI) + mappedModel := account.GetMappedModel(reqModel) + if mappedModel != reqModel { + log.Printf("[OpenAI] Model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", reqModel, mappedModel, account.Name, isCodexCLI) + reqBody["model"] = mappedModel + bodyModified = true } if account.Type == AccountTypeOAuth && !isCodexCLI { @@ -568,6 +566,44 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } } + // Handle max_output_tokens based on platform and account type + if !isCodexCLI { + if maxOutputTokens, hasMaxOutputTokens := reqBody["max_output_tokens"]; hasMaxOutputTokens { + switch account.Platform { + case PlatformOpenAI: + // For OpenAI API Key, remove max_output_tokens (not supported) + // For OpenAI OAuth (Responses API), keep it (supported) + if account.Type == AccountTypeAPIKey { + delete(reqBody, "max_output_tokens") + bodyModified = true + } + case PlatformAnthropic: + // For Anthropic (Claude), convert to max_tokens + delete(reqBody, "max_output_tokens") + if _, hasMaxTokens := reqBody["max_tokens"]; !hasMaxTokens { + reqBody["max_tokens"] = maxOutputTokens + } + bodyModified = true + case PlatformGemini: + // For Gemini, remove (will be handled by Gemini-specific transform) + delete(reqBody, "max_output_tokens") + bodyModified = true + default: + // For unknown platforms, remove to be safe + delete(reqBody, "max_output_tokens") + bodyModified = true + } + } + + // Also handle max_completion_tokens (similar logic) + if _, hasMaxCompletionTokens := reqBody["max_completion_tokens"]; hasMaxCompletionTokens { + if account.Type == AccountTypeAPIKey || account.Platform != PlatformOpenAI { + delete(reqBody, "max_completion_tokens") + bodyModified = true + } + } + } + // Re-serialize body only if modified if bodyModified { var err error diff --git a/backend/internal/service/ops_port.go b/backend/internal/service/ops_port.go index 39f3aaf2..4df21c37 100644 --- a/backend/internal/service/ops_port.go +++ b/backend/internal/service/ops_port.go @@ -17,6 +17,8 @@ type OpsRepository interface { // Lightweight window stats (for realtime WS / quick sampling). GetWindowStats(ctx context.Context, filter *OpsDashboardFilter) (*OpsWindowStats, error) + // Lightweight realtime traffic summary (for the Ops dashboard header card). + GetRealtimeTrafficSummary(ctx context.Context, filter *OpsDashboardFilter) (*OpsRealtimeTrafficSummary, error) GetDashboardOverview(ctx context.Context, filter *OpsDashboardFilter) (*OpsDashboardOverview, error) GetThroughputTrend(ctx context.Context, filter *OpsDashboardFilter, bucketSeconds int) (*OpsThroughputTrendResponse, error) @@ -71,6 +73,7 @@ type OpsInsertErrorLogInput struct { Severity string StatusCode int IsBusinessLimited bool + IsCountTokens bool // 是否为 count_tokens 请求 ErrorMessage string ErrorBody string diff --git a/backend/internal/service/ops_realtime_traffic.go b/backend/internal/service/ops_realtime_traffic.go new file mode 100644 index 00000000..458905c5 --- /dev/null +++ b/backend/internal/service/ops_realtime_traffic.go @@ -0,0 +1,36 @@ +package service + +import ( + "context" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +// GetRealtimeTrafficSummary returns QPS/TPS current/peak/avg for the provided window. +// This is used by the Ops dashboard "Realtime Traffic" card and is intentionally lightweight. +func (s *OpsService) GetRealtimeTrafficSummary(ctx context.Context, filter *OpsDashboardFilter) (*OpsRealtimeTrafficSummary, error) { + if err := s.RequireMonitoringEnabled(ctx); err != nil { + return nil, err + } + if s.opsRepo == nil { + return nil, infraerrors.ServiceUnavailable("OPS_REPO_UNAVAILABLE", "Ops repository not available") + } + if filter == nil { + return nil, infraerrors.BadRequest("OPS_FILTER_REQUIRED", "filter is required") + } + if filter.StartTime.IsZero() || filter.EndTime.IsZero() { + return nil, infraerrors.BadRequest("OPS_TIME_RANGE_REQUIRED", "start_time/end_time are required") + } + if filter.StartTime.After(filter.EndTime) { + return nil, infraerrors.BadRequest("OPS_TIME_RANGE_INVALID", "start_time must be <= end_time") + } + if filter.EndTime.Sub(filter.StartTime) > time.Hour { + return nil, infraerrors.BadRequest("OPS_TIME_RANGE_TOO_LARGE", "invalid time range: max window is 1 hour") + } + + // Realtime traffic summary always uses raw logs (minute granularity peaks). + filter.QueryMode = OpsQueryModeRaw + + return s.opsRepo.GetRealtimeTrafficSummary(ctx, filter) +} diff --git a/backend/internal/service/ops_realtime_traffic_models.go b/backend/internal/service/ops_realtime_traffic_models.go new file mode 100644 index 00000000..e88a890b --- /dev/null +++ b/backend/internal/service/ops_realtime_traffic_models.go @@ -0,0 +1,19 @@ +package service + +import "time" + +// OpsRealtimeTrafficSummary is a lightweight summary used by the Ops dashboard "Realtime Traffic" card. +// It reports QPS/TPS current/peak/avg for the requested time window. +type OpsRealtimeTrafficSummary struct { + // Window is a normalized label (e.g. "1min", "5min", "30min", "1h"). + Window string `json:"window"` + + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + + Platform string `json:"platform"` + GroupID *int64 `json:"group_id"` + + QPS OpsRateSummary `json:"qps"` + TPS OpsRateSummary `json:"tps"` +} diff --git a/backend/internal/service/ops_settings.go b/backend/internal/service/ops_settings.go index fbf8f069..53c78fed 100644 --- a/backend/internal/service/ops_settings.go +++ b/backend/internal/service/ops_settings.go @@ -368,6 +368,9 @@ func defaultOpsAdvancedSettings() *OpsAdvancedSettings { Aggregation: OpsAggregationSettings{ AggregationEnabled: false, }, + IgnoreCountTokensErrors: false, + AutoRefreshEnabled: false, + AutoRefreshIntervalSec: 30, } } @@ -388,6 +391,10 @@ func normalizeOpsAdvancedSettings(cfg *OpsAdvancedSettings) { if cfg.DataRetention.HourlyMetricsRetentionDays <= 0 { cfg.DataRetention.HourlyMetricsRetentionDays = 30 } + // Normalize auto refresh interval (default 30 seconds) + if cfg.AutoRefreshIntervalSec <= 0 { + cfg.AutoRefreshIntervalSec = 30 + } } func validateOpsAdvancedSettings(cfg *OpsAdvancedSettings) error { @@ -403,6 +410,9 @@ func validateOpsAdvancedSettings(cfg *OpsAdvancedSettings) error { if cfg.DataRetention.HourlyMetricsRetentionDays < 1 || cfg.DataRetention.HourlyMetricsRetentionDays > 365 { return errors.New("hourly_metrics_retention_days must be between 1 and 365") } + if cfg.AutoRefreshIntervalSec < 15 || cfg.AutoRefreshIntervalSec > 300 { + return errors.New("auto_refresh_interval_seconds must be between 15 and 300") + } return nil } @@ -463,3 +473,93 @@ func (s *OpsService) UpdateOpsAdvancedSettings(ctx context.Context, cfg *OpsAdva _ = json.Unmarshal(raw, updated) return updated, nil } + +// ========================= +// Metric thresholds +// ========================= + +const SettingKeyOpsMetricThresholds = "ops_metric_thresholds" + +func defaultOpsMetricThresholds() *OpsMetricThresholds { + slaMin := 99.5 + latencyMax := 2000.0 + ttftMax := 500.0 + reqErrMax := 5.0 + upstreamErrMax := 5.0 + return &OpsMetricThresholds{ + SLAPercentMin: &slaMin, + LatencyP99MsMax: &latencyMax, + TTFTp99MsMax: &ttftMax, + RequestErrorRatePercentMax: &reqErrMax, + UpstreamErrorRatePercentMax: &upstreamErrMax, + } +} + +func (s *OpsService) GetMetricThresholds(ctx context.Context) (*OpsMetricThresholds, error) { + defaultCfg := defaultOpsMetricThresholds() + if s == nil || s.settingRepo == nil { + return defaultCfg, nil + } + if ctx == nil { + ctx = context.Background() + } + + raw, err := s.settingRepo.GetValue(ctx, SettingKeyOpsMetricThresholds) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + if b, mErr := json.Marshal(defaultCfg); mErr == nil { + _ = s.settingRepo.Set(ctx, SettingKeyOpsMetricThresholds, string(b)) + } + return defaultCfg, nil + } + return nil, err + } + + cfg := &OpsMetricThresholds{} + if err := json.Unmarshal([]byte(raw), cfg); err != nil { + return defaultCfg, nil + } + + return cfg, nil +} + +func (s *OpsService) UpdateMetricThresholds(ctx context.Context, cfg *OpsMetricThresholds) (*OpsMetricThresholds, error) { + if s == nil || s.settingRepo == nil { + return nil, errors.New("setting repository not initialized") + } + if ctx == nil { + ctx = context.Background() + } + if cfg == nil { + return nil, errors.New("invalid config") + } + + // Validate thresholds + if cfg.SLAPercentMin != nil && (*cfg.SLAPercentMin < 0 || *cfg.SLAPercentMin > 100) { + return nil, errors.New("sla_percent_min must be between 0 and 100") + } + if cfg.LatencyP99MsMax != nil && *cfg.LatencyP99MsMax < 0 { + return nil, errors.New("latency_p99_ms_max must be >= 0") + } + if cfg.TTFTp99MsMax != nil && *cfg.TTFTp99MsMax < 0 { + return nil, errors.New("ttft_p99_ms_max must be >= 0") + } + if cfg.RequestErrorRatePercentMax != nil && (*cfg.RequestErrorRatePercentMax < 0 || *cfg.RequestErrorRatePercentMax > 100) { + return nil, errors.New("request_error_rate_percent_max must be between 0 and 100") + } + if cfg.UpstreamErrorRatePercentMax != nil && (*cfg.UpstreamErrorRatePercentMax < 0 || *cfg.UpstreamErrorRatePercentMax > 100) { + return nil, errors.New("upstream_error_rate_percent_max must be between 0 and 100") + } + + raw, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + if err := s.settingRepo.Set(ctx, SettingKeyOpsMetricThresholds, string(raw)); err != nil { + return nil, err + } + + updated := &OpsMetricThresholds{} + _ = json.Unmarshal(raw, updated) + return updated, nil +} diff --git a/backend/internal/service/ops_settings_models.go b/backend/internal/service/ops_settings_models.go index 7d9a823c..229488a1 100644 --- a/backend/internal/service/ops_settings_models.go +++ b/backend/internal/service/ops_settings_models.go @@ -61,17 +61,29 @@ type OpsAlertSilencingSettings struct { Entries []OpsAlertSilenceEntry `json:"entries,omitempty"` } +type OpsMetricThresholds struct { + SLAPercentMin *float64 `json:"sla_percent_min,omitempty"` // SLA低于此值变红 + LatencyP99MsMax *float64 `json:"latency_p99_ms_max,omitempty"` // 延迟P99高于此值变红 + TTFTp99MsMax *float64 `json:"ttft_p99_ms_max,omitempty"` // TTFT P99高于此值变红 + RequestErrorRatePercentMax *float64 `json:"request_error_rate_percent_max,omitempty"` // 请求错误率高于此值变红 + UpstreamErrorRatePercentMax *float64 `json:"upstream_error_rate_percent_max,omitempty"` // 上游错误率高于此值变红 +} + type OpsAlertRuntimeSettings struct { EvaluationIntervalSeconds int `json:"evaluation_interval_seconds"` DistributedLock OpsDistributedLockSettings `json:"distributed_lock"` Silencing OpsAlertSilencingSettings `json:"silencing"` + Thresholds OpsMetricThresholds `json:"thresholds"` // 指标阈值配置 } // OpsAdvancedSettings stores advanced ops configuration (data retention, aggregation). type OpsAdvancedSettings struct { - DataRetention OpsDataRetentionSettings `json:"data_retention"` - Aggregation OpsAggregationSettings `json:"aggregation"` + DataRetention OpsDataRetentionSettings `json:"data_retention"` + Aggregation OpsAggregationSettings `json:"aggregation"` + IgnoreCountTokensErrors bool `json:"ignore_count_tokens_errors"` + AutoRefreshEnabled bool `json:"auto_refresh_enabled"` + AutoRefreshIntervalSec int `json:"auto_refresh_interval_seconds"` } type OpsDataRetentionSettings struct { diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index e557d479..62b0c6b8 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -61,6 +61,7 @@ func (s *RateLimitService) SetSettingService(settingService *SettingService) { func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Account, statusCode int, headers http.Header, responseBody []byte) (shouldDisable bool) { // apikey 类型账号:检查自定义错误码配置 // 如果启用且错误码不在列表中,则不处理(不停止调度、不标记限流/过载) + customErrorCodesEnabled := account.IsCustomErrorCodesEnabled() if !account.ShouldHandleErrorCode(statusCode) { log.Printf("Account %d: error %d skipped (not in custom error codes)", account.ID, statusCode) return false @@ -105,11 +106,19 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc s.handle529(ctx, account) shouldDisable = false default: - // 其他5xx错误:记录但不停止调度 - if statusCode >= 500 { + // 自定义错误码启用时:在列表中的错误码都应该停止调度 + if customErrorCodesEnabled { + msg := "Custom error code triggered" + if upstreamMsg != "" { + msg = upstreamMsg + } + s.handleCustomErrorCode(ctx, account, statusCode, msg) + shouldDisable = true + } else if statusCode >= 500 { + // 未启用自定义错误码时:仅记录5xx错误 log.Printf("Account %d received upstream error %d", account.ID, statusCode) + shouldDisable = false } - shouldDisable = false } if tempMatched { @@ -285,6 +294,16 @@ func (s *RateLimitService) handleAuthError(ctx context.Context, account *Account log.Printf("Account %d disabled due to auth error: %s", account.ID, errorMsg) } +// handleCustomErrorCode 处理自定义错误码,停止账号调度 +func (s *RateLimitService) handleCustomErrorCode(ctx context.Context, account *Account, statusCode int, errorMsg string) { + msg := "Custom error code " + strconv.Itoa(statusCode) + ": " + errorMsg + if err := s.accountRepo.SetError(ctx, account.ID, msg); err != nil { + log.Printf("SetError failed for account %d: %v", account.ID, err) + return + } + log.Printf("Account %d disabled due to custom error code %d: %s", account.ID, statusCode, errorMsg) +} + // handle429 处理429限流错误 // 解析响应头获取重置时间,标记账号为限流状态 func (s *RateLimitService) handle429(ctx context.Context, account *Account, headers http.Header) { diff --git a/backend/migrations/036_ops_error_logs_add_is_count_tokens.sql b/backend/migrations/036_ops_error_logs_add_is_count_tokens.sql new file mode 100644 index 00000000..dedb1154 --- /dev/null +++ b/backend/migrations/036_ops_error_logs_add_is_count_tokens.sql @@ -0,0 +1,16 @@ +-- Migration: 添加 is_count_tokens 字段到 ops_error_logs 表 +-- Purpose: 标记 count_tokens 请求的错误,以便在统计和告警中根据配置动态过滤 +-- Author: System +-- Date: 2026-01-12 + +-- Add is_count_tokens column to ops_error_logs table +ALTER TABLE ops_error_logs +ADD COLUMN is_count_tokens BOOLEAN NOT NULL DEFAULT FALSE; + +-- Add comment +COMMENT ON COLUMN ops_error_logs.is_count_tokens IS '是否为 count_tokens 请求的错误(用于统计过滤)'; + +-- Create index for filtering (optional, improves query performance) +CREATE INDEX IF NOT EXISTS idx_ops_error_logs_is_count_tokens +ON ops_error_logs(is_count_tokens) +WHERE is_count_tokens = TRUE; diff --git a/frontend/.eslintignore b/frontend/.eslintignore new file mode 100644 index 00000000..d8682246 --- /dev/null +++ b/frontend/.eslintignore @@ -0,0 +1,14 @@ +# 忽略编译后的文件 +vite.config.js +vite.config.d.ts + +# 忽略依赖 +node_modules/ + +# 忽略构建输出 +dist/ +../backend/internal/web/dist/ + +# 忽略缓存 +.cache/ +.vite/ diff --git a/frontend/src/api/admin/ops.ts b/frontend/src/api/admin/ops.ts index 1d1453f5..ce0ab58d 100644 --- a/frontend/src/api/admin/ops.ts +++ b/frontend/src/api/admin/ops.ts @@ -362,6 +362,45 @@ export async function getAccountAvailabilityStats(platform?: string, groupId?: n return data } +export interface OpsRateSummary { + current: number + peak: number + avg: number +} + +export interface OpsRealtimeTrafficSummary { + window: string + start_time: string + end_time: string + platform: string + group_id?: number | null + qps: OpsRateSummary + tps: OpsRateSummary +} + +export interface OpsRealtimeTrafficSummaryResponse { + enabled: boolean + summary: OpsRealtimeTrafficSummary | null + timestamp?: string +} + +export async function getRealtimeTrafficSummary( + window: string, + platform?: string, + groupId?: number | null +): Promise { + const params: Record = { window } + if (platform) { + params.platform = platform + } + if (typeof groupId === 'number' && groupId > 0) { + params.group_id = groupId + } + + const { data } = await apiClient.get('/admin/ops/realtime-traffic', { params }) + return data +} + /** * Subscribe to realtime QPS updates via WebSocket. * @@ -661,6 +700,14 @@ export interface EmailNotificationConfig { } } +export interface OpsMetricThresholds { + sla_percent_min?: number | null // SLA低于此值变红 + latency_p99_ms_max?: number | null // 延迟P99高于此值变红 + ttft_p99_ms_max?: number | null // TTFT P99高于此值变红 + request_error_rate_percent_max?: number | null // 请求错误率高于此值变红 + upstream_error_rate_percent_max?: number | null // 上游错误率高于此值变红 +} + export interface OpsDistributedLockSettings { enabled: boolean key: string @@ -681,11 +728,15 @@ export interface OpsAlertRuntimeSettings { reason: string }> } + thresholds: OpsMetricThresholds // 指标阈值配置 } export interface OpsAdvancedSettings { data_retention: OpsDataRetentionSettings aggregation: OpsAggregationSettings + ignore_count_tokens_errors: boolean + auto_refresh_enabled: boolean + auto_refresh_interval_seconds: number } export interface OpsDataRetentionSettings { @@ -929,6 +980,17 @@ export async function updateAdvancedSettings(config: OpsAdvancedSettings): Promi return data } +// ==================== Metric Thresholds ==================== + +async function getMetricThresholds(): Promise { + const { data } = await apiClient.get('/admin/ops/settings/metric-thresholds') + return data +} + +async function updateMetricThresholds(thresholds: OpsMetricThresholds): Promise { + await apiClient.put('/admin/ops/settings/metric-thresholds', thresholds) +} + export const opsAPI = { getDashboardOverview, getThroughputTrend, @@ -937,6 +999,7 @@ export const opsAPI = { getErrorDistribution, getConcurrencyStats, getAccountAvailabilityStats, + getRealtimeTrafficSummary, subscribeQPS, listErrorLogs, getErrorLogDetail, @@ -952,7 +1015,9 @@ export const opsAPI = { getAlertRuntimeSettings, updateAlertRuntimeSettings, getAdvancedSettings, - updateAdvancedSettings + updateAdvancedSettings, + getMetricThresholds, + updateMetricThresholds } export default opsAPI diff --git a/frontend/src/components/account/AccountGroupsCell.vue b/frontend/src/components/account/AccountGroupsCell.vue new file mode 100644 index 00000000..512383a5 --- /dev/null +++ b/frontend/src/components/account/AccountGroupsCell.vue @@ -0,0 +1,158 @@ + + + diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index 51ad32d1..9ccf6130 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -778,6 +778,16 @@ const addPresetMapping = (from: string, to: string) => { const toggleErrorCode = (code: number) => { const index = selectedErrorCodes.value.indexOf(code) if (index === -1) { + // Adding code - check for 429/529 warning + if (code === 429) { + if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) { + return + } + } else if (code === 529) { + if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) { + return + } + } selectedErrorCodes.value.push(code) } else { selectedErrorCodes.value.splice(index, 1) @@ -794,6 +804,16 @@ const addCustomErrorCode = () => { appStore.showInfo(t('admin.accounts.errorCodeExists')) return } + // Check for 429/529 warning + if (code === 429) { + if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) { + return + } + } else if (code === 529) { + if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) { + return + } + } selectedErrorCodes.value.push(code) customErrorCodeInput.value = null } diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 5833632b..a56a987f 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -1976,6 +1976,16 @@ const addPresetMapping = (from: string, to: string) => { const toggleErrorCode = (code: number) => { const index = selectedErrorCodes.value.indexOf(code) if (index === -1) { + // Adding code - check for 429/529 warning + if (code === 429) { + if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) { + return + } + } else if (code === 529) { + if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) { + return + } + } selectedErrorCodes.value.push(code) } else { selectedErrorCodes.value.splice(index, 1) @@ -1993,6 +2003,16 @@ const addCustomErrorCode = () => { appStore.showInfo(t('admin.accounts.errorCodeExists')) return } + // Check for 429/529 warning + if (code === 429) { + if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) { + return + } + } else if (code === 529) { + if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) { + return + } + } selectedErrorCodes.value.push(code) customErrorCodeInput.value = null } @@ -2462,6 +2482,7 @@ const handleCookieAuth = async (sessionKey: string) => { await adminAPI.accounts.create({ name: accountName, + notes: form.notes, platform: form.platform, type: addMethod.value, // Use addMethod as type: 'oauth' or 'setup-token' credentials, @@ -2469,6 +2490,8 @@ const handleCookieAuth = async (sessionKey: string) => { proxy_id: form.proxy_id, concurrency: form.concurrency, priority: form.priority, + group_ids: form.group_ids, + expires_at: form.expires_at, auto_pause_on_expired: autoPauseOnExpired.value }) diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 3b36cfbf..7cb740bd 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -936,6 +936,16 @@ const addPresetMapping = (from: string, to: string) => { const toggleErrorCode = (code: number) => { const index = selectedErrorCodes.value.indexOf(code) if (index === -1) { + // Adding code - check for 429/529 warning + if (code === 429) { + if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) { + return + } + } else if (code === 529) { + if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) { + return + } + } selectedErrorCodes.value.push(code) } else { selectedErrorCodes.value.splice(index, 1) @@ -953,6 +963,16 @@ const addCustomErrorCode = () => { appStore.showInfo(t('admin.accounts.errorCodeExists')) return } + // Check for 429/529 warning + if (code === 429) { + if (!confirm(t('admin.accounts.customErrorCodes429Warning'))) { + return + } + } else if (code === 529) { + if (!confirm(t('admin.accounts.customErrorCodes529Warning'))) { + return + } + } selectedErrorCodes.value.push(code) customErrorCodeInput.value = null } diff --git a/frontend/src/components/icons/Icon.vue b/frontend/src/components/icons/Icon.vue index ec3c9a1b..c8ab8aed 100644 --- a/frontend/src/components/icons/Icon.vue +++ b/frontend/src/components/icons/Icon.vue @@ -124,7 +124,8 @@ const icons = { chatBubble: 'M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z', calculator: 'M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z', fire: 'M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z', - badge: 'M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z' + badge: 'M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z', + brain: 'M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m0 0l-2.69 2.689c-1.232 1.232-.65 3.318 1.067 3.611A48.309 48.309 0 0012 21c2.773 0 5.491-.235 8.135-.687 1.718-.293 2.3-2.379 1.067-3.61L19.8 15.3M12 8.25a1.5 1.5 0 100-3 1.5 1.5 0 000 3zm0 0v3m-3-1.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3zm0 0h6m-3 4.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3z' } as const const iconPath = computed(() => icons[props.name]) diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index 58f42ae6..8075ba70 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -376,6 +376,10 @@ const currentFiles = computed((): FileConfig[] => { const trimmed = `${baseRoot}/antigravity`.replace(/\/+$/, '') return trimmed.endsWith('/v1beta') ? trimmed : `${trimmed}/v1beta` })() + const geminiBase = (() => { + const trimmed = baseRoot.replace(/\/+$/, '') + return trimmed.endsWith('/v1beta') ? trimmed : `${trimmed}/v1beta` + })() if (activeClientTab.value === 'opencode') { switch (props.platform) { @@ -384,7 +388,7 @@ const currentFiles = computed((): FileConfig[] => { case 'openai': return [generateOpenCodeConfig('openai', apiBase, apiKey)] case 'gemini': - return [generateOpenCodeConfig('gemini', apiBase, apiKey)] + return [generateOpenCodeConfig('gemini', geminiBase, apiKey)] case 'antigravity': return [ generateOpenCodeConfig('antigravity-claude', antigravityBase, apiKey, 'opencode.json (Claude)'), @@ -525,14 +529,16 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin [platform]: { options: { baseURL: baseUrl, - apiKey, - ...(platform === 'openai' ? { store: false } : {}) + apiKey } } } const openaiModels = { 'gpt-5.2-codex': { name: 'GPT-5.2 Codex', + options: { + store: false + }, variants: { low: {}, medium: {}, @@ -574,9 +580,26 @@ function generateOpenCodeConfig(platform: string, baseUrl: string, apiKey: strin provider[platform].models = openaiModels } + const agent = + platform === 'openai' + ? { + build: { + options: { + store: false + } + }, + plan: { + options: { + store: false + } + } + } + : undefined + const content = JSON.stringify( { provider, + ...(agent ? { agent } : {}), $schema: 'https://opencode.ai/config.json' }, null, diff --git a/frontend/src/composables/useModelWhitelist.ts b/frontend/src/composables/useModelWhitelist.ts index d18bdc5f..79900c6e 100644 --- a/frontend/src/composables/useModelWhitelist.ts +++ b/frontend/src/composables/useModelWhitelist.ts @@ -13,7 +13,17 @@ const openaiModels = [ 'o1', 'o1-preview', 'o1-mini', 'o1-pro', 'o3', 'o3-mini', 'o3-pro', 'o4-mini', - 'gpt-5', 'gpt-5-mini', 'gpt-5-nano', + // GPT-5 系列(同步后端定价文件) + 'gpt-5', 'gpt-5-2025-08-07', 'gpt-5-chat', 'gpt-5-chat-latest', + 'gpt-5-codex', 'gpt-5-pro', 'gpt-5-pro-2025-10-06', + 'gpt-5-mini', 'gpt-5-mini-2025-08-07', + 'gpt-5-nano', 'gpt-5-nano-2025-08-07', + // GPT-5.1 系列 + 'gpt-5.1', 'gpt-5.1-2025-11-13', 'gpt-5.1-chat-latest', + 'gpt-5.1-codex', 'gpt-5.1-codex-max', 'gpt-5.1-codex-mini', + // GPT-5.2 系列 + 'gpt-5.2', 'gpt-5.2-2025-12-11', 'gpt-5.2-chat-latest', + 'gpt-5.2-codex', 'gpt-5.2-pro', 'gpt-5.2-pro-2025-12-11', 'chatgpt-4o-latest', 'gpt-4o-audio-preview', 'gpt-4o-realtime-preview' ] @@ -211,7 +221,10 @@ const openaiPresetMappings = [ { label: 'GPT-4.1', from: 'gpt-4.1', to: 'gpt-4.1', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' }, { label: 'o1', from: 'o1', to: 'o1', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' }, { label: 'o3', from: 'o3', to: 'o3', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' }, - { label: 'GPT-5', from: 'gpt-5', to: 'gpt-5', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' } + { label: 'GPT-5', from: 'gpt-5', to: 'gpt-5', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }, + { label: 'GPT-5.1', from: 'gpt-5.1', to: 'gpt-5.1', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' }, + { label: 'GPT-5.2', from: 'gpt-5.2', to: 'gpt-5.2', color: 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400' }, + { label: 'GPT-5.1 Codex', from: 'gpt-5.1-codex', to: 'gpt-5.1-codex', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' } ] const geminiPresetMappings = [ diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 23e9a1fb..bd17a7f1 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -156,6 +156,7 @@ export default { unknownError: 'Unknown error occurred', saving: 'Saving...', selectedCount: '({count} selected)', refresh: 'Refresh', + settings: 'Settings', notAvailable: 'N/A', now: 'Now', unknown: 'Unknown', @@ -389,7 +390,7 @@ export default { opencode: { title: 'OpenCode Example', subtitle: 'opencode.json', - hint: 'This is a group configuration example. Adjust model and options as needed.', + hint: 'Config path: ~/.config/opencode/opencode.json (or opencode.jsonc), create if not exists. Use default providers (openai/anthropic/google) or custom provider_id. API Key can be configured directly or via /connect command. This is an example, adjust models and options as needed.', }, }, customKeyLabel: 'Custom Key', @@ -1021,6 +1022,7 @@ export default { schedulableEnabled: 'Scheduling enabled', schedulableDisabled: 'Scheduling disabled', failedToToggleSchedulable: 'Failed to toggle scheduling status', + allGroups: '{count} groups total', platforms: { anthropic: 'Anthropic', claude: 'Claude', @@ -1203,6 +1205,10 @@ export default { customErrorCodesHint: 'Only stop scheduling for selected error codes', customErrorCodesWarning: 'Only selected error codes will stop scheduling. Other errors will return 500.', + customErrorCodes429Warning: + '429 already has built-in rate limit handling. Adding it to custom error codes will disable the account instead of temporary rate limiting. Are you sure?', + customErrorCodes529Warning: + '529 already has built-in overload handling. Adding it to custom error codes will disable the account instead of temporary overload marking. Are you sure?', selectedErrorCodes: 'Selected', noneSelectedUsesDefault: 'None selected (uses default policy)', enterErrorCode: 'Enter error code (100-599)', @@ -1902,6 +1908,7 @@ export default { max: 'max:', qps: 'QPS', requests: 'Requests', + requestsTitle: 'Requests', upstream: 'Upstream', client: 'Client', system: 'System', @@ -1936,6 +1943,9 @@ export default { '6h': 'Last 6 hours', '24h': 'Last 24 hours' }, + fullscreen: { + enter: 'Enter Fullscreen' + }, diagnosis: { title: 'Smart Diagnosis', footer: 'Automated diagnostic suggestions based on current metrics', @@ -2114,7 +2124,10 @@ export default { empty: 'No alert rules', loadFailed: 'Failed to load alert rules', saveFailed: 'Failed to save alert rule', + saveSuccess: 'Alert rule saved successfully', deleteFailed: 'Failed to delete alert rule', + deleteSuccess: 'Alert rule deleted successfully', + manage: 'Manage Alert Rules', create: 'Create Rule', createTitle: 'Create Alert Rule', editTitle: 'Edit Alert Rule', @@ -2297,6 +2310,54 @@ export default { accountHealthThresholdRange: 'Account health threshold must be between 0 and 100' } }, + settings: { + title: 'Ops Monitoring Settings', + loadFailed: 'Failed to load settings', + saveSuccess: 'Ops monitoring settings saved successfully', + saveFailed: 'Failed to save settings', + dataCollection: 'Data Collection', + evaluationInterval: 'Evaluation Interval (seconds)', + evaluationIntervalHint: 'Frequency of detection tasks, recommended to keep default', + alertConfig: 'Alert Configuration', + enableAlert: 'Enable Alerts', + alertRecipients: 'Alert Recipient Emails', + emailPlaceholder: 'Enter email address', + recipientsHint: 'If empty, the system will use the first admin email as default recipient', + minSeverity: 'Minimum Severity', + reportConfig: 'Report Configuration', + enableReport: 'Enable Reports', + reportRecipients: 'Report Recipient Emails', + dailySummary: 'Daily Summary', + weeklySummary: 'Weekly Summary', + metricThresholds: 'Metric Thresholds', + metricThresholdsHint: 'Configure alert thresholds for metrics, values exceeding thresholds will be displayed in red', + slaMinPercent: 'SLA Minimum Percentage', + slaMinPercentHint: 'SLA below this value will be displayed in red (default: 99.5%)', + latencyP99MaxMs: 'Latency P99 Maximum (ms)', + latencyP99MaxMsHint: 'Latency P99 above this value will be displayed in red (default: 2000ms)', + ttftP99MaxMs: 'TTFT P99 Maximum (ms)', + ttftP99MaxMsHint: 'TTFT P99 above this value will be displayed in red (default: 500ms)', + requestErrorRateMaxPercent: 'Request Error Rate Maximum (%)', + requestErrorRateMaxPercentHint: 'Request error rate above this value will be displayed in red (default: 5%)', + upstreamErrorRateMaxPercent: 'Upstream Error Rate Maximum (%)', + upstreamErrorRateMaxPercentHint: 'Upstream error rate above this value will be displayed in red (default: 5%)', + advancedSettings: 'Advanced Settings', + dataRetention: 'Data Retention Policy', + enableCleanup: 'Enable Data Cleanup', + cleanupSchedule: 'Cleanup Schedule (Cron)', + cleanupScheduleHint: 'Example: 0 2 * * * means 2 AM daily', + errorLogRetentionDays: 'Error Log Retention Days', + minuteMetricsRetentionDays: 'Minute Metrics Retention Days', + hourlyMetricsRetentionDays: 'Hourly Metrics Retention Days', + retentionDaysHint: 'Recommended 7-90 days, longer periods will consume more storage', + aggregation: 'Pre-aggregation Tasks', + enableAggregation: 'Enable Pre-aggregation', + aggregationHint: 'Pre-aggregation improves query performance for long time windows', + validation: { + title: 'Please fix the following issues', + retentionDaysRange: 'Retention days must be between 1-365 days' + } + }, concurrency: { title: 'Concurrency / Queue', byPlatform: 'By Platform', @@ -2330,12 +2391,13 @@ export default { accountError: 'Error' }, tooltips: { + totalRequests: 'Total number of requests (including both successful and failed requests) in the selected time window.', throughputTrend: 'Requests/QPS + Tokens/TPS in the selected window.', latencyHistogram: 'Latency distribution (duration_ms) for successful requests.', errorTrend: 'Error counts over time (SLA scope excludes business limits; upstream excludes 429/529).', errorDistribution: 'Error distribution by status code.', goroutines: - 'Number of Go runtime goroutines (lightweight threads). There is no absolute “safe” number—use your historical baseline. Heuristic: <2k is common; 2k–8k watch; >8k plus rising queue/latency often suggests blocking/leaks.', + 'Number of Go runtime goroutines (lightweight threads). There is no absolute "safe" number—use your historical baseline. Heuristic: <2k is common; 2k–8k watch; >8k plus rising queue/latency often suggests blocking/leaks.', cpu: 'CPU usage percentage, showing system processor load.', memory: 'Memory usage, including used and total available memory.', db: 'Database connection pool status, including active, idle, and waiting connections.', @@ -2345,6 +2407,7 @@ export default { tokens: 'Total number of tokens processed in the current time window.', sla: 'Service Level Agreement success rate, excluding business limits (e.g., insufficient balance, quota exceeded).', errors: 'Error statistics, including total errors, error rate, and upstream error rate.', + upstreamErrors: 'Upstream error statistics, excluding rate limit errors (429/529).', latency: 'Request latency statistics, including p50, p90, p95, p99 percentiles.', ttft: 'Time To First Token, measuring the speed of first byte return in streaming responses.', health: 'System health score (0-100), considering SLA, error rate, and resource usage.' diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index fb77e834..9724a55c 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -387,7 +387,7 @@ export default { opencode: { title: 'OpenCode 配置示例', subtitle: 'opencode.json', - hint: '示例仅用于演示分组配置,模型与选项可按需调整。', + hint: '配置文件路径:~/.config/opencode/opencode.json(或 opencode.jsonc),不存在需手动创建。可使用默认 provider(openai/anthropic/google)或自定义 provider_id。API Key 支持直接配置或通过客户端 /connect 命令配置。示例仅供参考,模型与选项可按需调整。', }, }, customKeyLabel: '自定义密钥', @@ -1099,6 +1099,7 @@ export default { schedulableEnabled: '调度已开启', schedulableDisabled: '调度已关闭', failedToToggleSchedulable: '切换调度状态失败', + allGroups: '共 {count} 个分组', columns: { name: '名称', platformType: '平台/类型', @@ -1339,6 +1340,10 @@ export default { customErrorCodes: '自定义错误码', customErrorCodesHint: '仅对选中的错误码停止调度', customErrorCodesWarning: '仅选中的错误码会停止调度,其他错误将返回 500。', + customErrorCodes429Warning: + '429 已有内置的限流处理机制。添加到自定义错误码后,将直接停止调度而非临时限流。确定要添加吗?', + customErrorCodes529Warning: + '529 已有内置的过载处理机制。添加到自定义错误码后,将直接停止调度而非临时标记过载。确定要添加吗?', selectedErrorCodes: '已选择', noneSelectedUsesDefault: '未选择(使用默认策略)', enterErrorCode: '输入错误码 (100-599)', @@ -2018,7 +2023,7 @@ export default { ready: '就绪', requestsTotal: '请求(总计)', slaScope: 'SLA 范围:', - tokens: 'Token', + tokens: 'Token数', tps: 'TPS', current: '当前', peak: '峰值', @@ -2047,7 +2052,8 @@ export default { avg: 'avg', max: 'max', qps: 'QPS', - requests: '请求', + requests: '请求数', + requestsTitle: '请求', upstream: '上游', client: '客户端', system: '系统', @@ -2082,6 +2088,9 @@ export default { '6h': '近6小时', '24h': '近24小时' }, + fullscreen: { + enter: '进入全屏' + }, diagnosis: { title: '智能诊断', footer: '基于当前指标的自动诊断建议', @@ -2465,6 +2474,18 @@ export default { reportRecipients: '评估报告接收邮箱', dailySummary: '每日摘要', weeklySummary: '每周摘要', + metricThresholds: '指标阈值配置', + metricThresholdsHint: '配置各项指标的告警阈值,超出阈值时将以红色显示', + slaMinPercent: 'SLA最低百分比', + slaMinPercentHint: 'SLA低于此值时显示为红色(默认:99.5%)', + latencyP99MaxMs: '延迟P99最大值(毫秒)', + latencyP99MaxMsHint: '延迟P99高于此值时显示为红色(默认:2000ms)', + ttftP99MaxMs: 'TTFT P99最大值(毫秒)', + ttftP99MaxMsHint: 'TTFT P99高于此值时显示为红色(默认:500ms)', + requestErrorRateMaxPercent: '请求错误率最大值(%)', + requestErrorRateMaxPercentHint: '请求错误率高于此值时显示为红色(默认:5%)', + upstreamErrorRateMaxPercent: '上游错误率最大值(%)', + upstreamErrorRateMaxPercentHint: '上游错误率高于此值时显示为红色(默认:5%)', advancedSettings: '高级设置', dataRetention: '数据保留策略', enableCleanup: '启用数据清理', diff --git a/frontend/src/style.css b/frontend/src/style.css index bd86ab6d..b3b492b9 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -19,7 +19,22 @@ @apply min-h-screen; } - /* 自定义滚动条 */ + /* 自定义滚动条 - 默认隐藏,悬停或滚动时显示 */ + * { + scrollbar-width: thin; + scrollbar-color: transparent transparent; + } + + *:hover, + *:focus-within { + scrollbar-color: rgba(156, 163, 175, 0.5) transparent; + } + + .dark *:hover, + .dark *:focus-within { + scrollbar-color: rgba(75, 85, 99, 0.5) transparent; + } + ::-webkit-scrollbar { @apply h-2 w-2; } @@ -29,10 +44,15 @@ } ::-webkit-scrollbar-thumb { - @apply rounded-full bg-gray-300 dark:bg-dark-600; + @apply rounded-full bg-transparent; + transition: background-color 0.2s ease; } - ::-webkit-scrollbar-thumb:hover { + *:hover::-webkit-scrollbar-thumb { + @apply bg-gray-300/50 dark:bg-dark-600/50; + } + + *:hover::-webkit-scrollbar-thumb:hover { @apply bg-gray-400 dark:bg-dark-500; } diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 0480ef39..8a5268ca 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -56,10 +56,7 @@