package sql_test import ( "context" "database/sql" "encoding/json" "testing" "time" "ccLoad/internal/model" sqlstore "ccLoad/internal/storage/sql" ) func TestMetrics_BasicQueriesAndFilters(t *testing.T) { store := newTestStore(t, "metrics_basic.db") ctx := context.Background() // 两个渠道:用于覆盖 type/name 过滤与交集逻辑 openaiCfg, err := store.CreateConfig(ctx, &model.Config{ Name: "openai-main", URL: "https://example.com", Priority: 10, Enabled: true, ChannelType: "openai", CostMultiplier: 0.85, ModelEntries: []model.ModelEntry{ {Model: "gpt-4o"}, }, }) if err != nil { t.Fatalf("CreateConfig openai failed: %v", err) } anthCfg, err := store.CreateConfig(ctx, &model.Config{ Name: "anthropic-1", URL: "https://example.com", Priority: 20, Enabled: true, ChannelType: "anthropic", ModelEntries: []model.ModelEntry{ {Model: "claude-3-5-sonnet-latest"}, }, }) if err != nil { t.Fatalf("CreateConfig anthropic failed: %v", err) } now := time.Now() start := now.Add(-2 * time.Minute) end := now.Add(1 * time.Minute) // openai: success + error + cancelled(499) // anthropic: success if err := store.BatchAddLogs(ctx, []*model.LogEntry{ {Time: model.JSONTime{Time: now}, ChannelID: openaiCfg.ID, Model: "gpt-4o", StatusCode: 200, Duration: 0.1, IsStreaming: true, FirstByteTime: 0.01, InputTokens: 10, OutputTokens: 20, Cost: 0.01, LogSource: model.LogSourceProxy}, {Time: model.JSONTime{Time: now}, ChannelID: openaiCfg.ID, Model: "gpt-4o", StatusCode: 500, Duration: 0.2, IsStreaming: false, InputTokens: 1, OutputTokens: 2, Cost: 0.02, LogSource: model.LogSourceProxy}, {Time: model.JSONTime{Time: now}, ChannelID: openaiCfg.ID, Model: "gpt-4o", StatusCode: 499, Duration: 0.3, IsStreaming: true, FirstByteTime: 0.02, InputTokens: 999, OutputTokens: 999, Cost: 9.99, LogSource: model.LogSourceProxy}, {Time: model.JSONTime{Time: now}, ChannelID: anthCfg.ID, Model: "claude-3-5-sonnet-latest", StatusCode: 200, Duration: 0.4, IsStreaming: false, InputTokens: 3, OutputTokens: 4, Cost: 0.03, LogSource: model.LogSourceProxy}, {Time: model.JSONTime{Time: now}, ChannelID: openaiCfg.ID, Model: "gpt-4o", StatusCode: 200, Duration: 0.05, IsStreaming: false, InputTokens: 100, OutputTokens: 200, Cost: 1.23, LogSource: model.LogSourceManualTest}, }); err != nil { t.Fatalf("BatchAddLogs failed: %v", err) } // GetDistinctModels:无过滤 + 按渠道类型过滤(覆盖 fetchChannelIDsByType) modelsAll, err := store.GetDistinctModels(ctx, start, end, "", nil) if err != nil { t.Fatalf("GetDistinctModels(all) failed: %v", err) } if len(modelsAll) < 2 { t.Fatalf("GetDistinctModels(all) got %v, want >=2", modelsAll) } modelsOpenAI, err := store.GetDistinctModels(ctx, start, end, "openai", nil) if err != nil { t.Fatalf("GetDistinctModels(openai) failed: %v", err) } if len(modelsOpenAI) != 1 || modelsOpenAI[0] != "gpt-4o" { t.Fatalf("GetDistinctModels(openai) got %v, want [gpt-4o]", modelsOpenAI) } // GetChannelSuccessRates:openai 成功率 1/2(499 不纳入口径) rates, err := store.GetChannelSuccessRates(ctx, start) if err != nil { t.Fatalf("GetChannelSuccessRates failed: %v", err) } r := rates[openaiCfg.ID] if r.SampleCount != 2 || r.SuccessRate < 0.49 || r.SuccessRate > 0.51 { t.Fatalf("openai success rate=%v sample=%d, want ~0.5 and 2", r.SuccessRate, r.SampleCount) } // GetStats:覆盖 applyChannelFilter(nil) + 渠道信息批量填充 + RPM 降级路径 stats, err := store.GetStats(ctx, start, end, nil, false) if err != nil { t.Fatalf("GetStats failed: %v", err) } if len(stats) < 2 { t.Fatalf("GetStats len=%d, want >=2", len(stats)) } foundOpenAI := false for _, e := range stats { if e.ChannelID != nil && int64(*e.ChannelID) == openaiCfg.ID && e.Model == "gpt-4o" { foundOpenAI = true if e.Total != 2 || e.Success != 1 || e.Error != 1 { t.Fatalf("openai stats=%+v, want total=2 success=1 error=1 (exclude 499)", e) } if e.ChannelName == "" || e.ChannelPriority == nil { t.Fatalf("expected channel info filled, got %+v", e) } if e.CostMultiplier == nil || *e.CostMultiplier != 0.85 { t.Fatalf("expected cost_multiplier=0.85 in stats entry, got %+v", e) } encoded, err := json.Marshal(e) if err != nil { t.Fatalf("marshal stats entry failed: %v", err) } var payload map[string]any if err := json.Unmarshal(encoded, &payload); err != nil { t.Fatalf("unmarshal stats entry failed: %v", err) } if got, ok := payload["channel_type"].(string); !ok || got != "openai" { t.Fatalf("expected channel_type=openai in stats payload, got %+v", payload) } if got, ok := payload["cost_multiplier"].(float64); !ok || got != 0.85 { t.Fatalf("expected cost_multiplier=0.85 in stats payload, got %+v", payload) } } } if !foundOpenAI { t.Fatalf("missing openai stats entry") } // GetStatsLite:轻量版也应可用 if _, err := store.GetStatsLite(ctx, start, end, nil); err != nil { t.Fatalf("GetStatsLite failed: %v", err) } // GetRPMStats:全局峰值/平均统计 rpm, err := store.GetRPMStats(ctx, start, end, nil, false) if err != nil { t.Fatalf("GetRPMStats failed: %v", err) } if rpm.PeakRPM <= 0 || rpm.AvgRPM <= 0 { t.Fatalf("unexpected rpm stats: %+v", rpm) } // AggregateRangeWithFilter:覆盖 resolveChannelFilter(type+nameLike 交集) pts, err := store.AggregateRangeWithFilter(ctx, start, end, time.Minute, &model.LogFilter{ ChannelType: "openai", ChannelNameLike: "openai", }) if err != nil { t.Fatalf("AggregateRangeWithFilter failed: %v", err) } if len(pts) == 0 { t.Fatalf("AggregateRangeWithFilter returned empty points") } nonEmpty := false for _, p := range pts { if p.Success > 0 || p.Error > 0 { nonEmpty = true break } } if !nonEmpty { t.Fatalf("expected at least one non-empty metric point") } // 空结果:触发 buildEmptyMetricPoints 路径 emptyPts, err := store.AggregateRangeWithFilter(ctx, start, end, time.Minute, &model.LogFilter{ ChannelType: "does-not-exist", }) if err != nil { t.Fatalf("AggregateRangeWithFilter(empty) failed: %v", err) } if len(emptyPts) == 0 { t.Fatalf("expected empty metric points series, got len=0") } // 触发 QueryBuilder.WhereIn:GetStats 带 type+name 过滤走 applyChannelFilter filteredStats, err := store.GetStats(ctx, start, end, &model.LogFilter{ ChannelType: "openai", ChannelNameLike: "openai", }, false) if err != nil { t.Fatalf("GetStats(filtered) failed: %v", err) } if len(filteredStats) != 1 { t.Fatalf("GetStats(filtered) len=%d, want 1", len(filteredStats)) } manualStats, err := store.GetStats(ctx, start, end, &model.LogFilter{LogSource: model.LogSourceManualTest}, false) if err != nil { t.Fatalf("GetStats(manual_test) failed: %v", err) } if len(manualStats) != 1 || manualStats[0].Total != 1 || manualStats[0].Success != 1 { t.Fatalf("manual test stats=%+v, want one success record", manualStats) } // GetTodayChannelCosts:覆盖今日成本聚合 costs, err := store.GetTodayChannelCosts(ctx, now.Add(-24*time.Hour)) if err != nil { t.Fatalf("GetTodayChannelCosts failed: %v", err) } if _, ok := costs[openaiCfg.ID]; !ok { t.Fatalf("expected openai cost entry in map") } // 覆盖 SQLStore 的底层 DB wrapper:Ping/Query/Exec/BeginTx/GetHealthTimeline ss := store.(*sqlstore.SQLStore) if err := ss.Ping(ctx); err != nil { t.Fatalf("Ping failed: %v", err) } row := ss.QueryRowContext(ctx, "SELECT 1") var one int if err := row.Scan(&one); err != nil || one != 1 { t.Fatalf("QueryRowContext got (%d,%v), want (1,nil)", one, err) } rows, err := ss.QueryContext(ctx, "SELECT 1") if err != nil { t.Fatalf("QueryContext failed: %v", err) } _ = rows.Close() if _, err := ss.ExecContext(ctx, "SELECT 1"); err != nil { t.Fatalf("ExecContext failed: %v", err) } tx, err := ss.BeginTx(ctx, &sql.TxOptions{}) if err != nil { t.Fatalf("BeginTx failed: %v", err) } _ = tx.Rollback() hlRows, err := ss.GetHealthTimeline(ctx, model.HealthTimelineParams{ SinceMs: 0, UntilMs: time.Now().UnixMilli(), BucketMs: 60000, Filter: &model.LogFilter{LogSource: model.LogSourceAll}, }) if err != nil { t.Fatalf("GetHealthTimeline failed: %v", err) } _ = hlRows // CleanupLogsBefore:删除所有日志 if err := store.CleanupLogsBefore(ctx, time.Now().Add(time.Hour)); err != nil { t.Fatalf("CleanupLogsBefore failed: %v", err) } } func TestGetHealthTimeline_AppliesFullStatsFilter(t *testing.T) { store := newTestStore(t, "health_timeline_full_filter.db") ctx := context.Background() openaiCfg, err := store.CreateConfig(ctx, &model.Config{ Name: "openai-main", URL: "https://example.com", Priority: 10, Enabled: true, ChannelType: "openai", ModelEntries: []model.ModelEntry{ {Model: "gpt-4o"}, {Model: "gpt-4.1"}, }, }) if err != nil { t.Fatalf("CreateConfig openai failed: %v", err) } anthCfg, err := store.CreateConfig(ctx, &model.Config{ Name: "anthropic-main", URL: "https://example.com", Priority: 20, Enabled: true, ChannelType: "anthropic", ModelEntries: []model.ModelEntry{ {Model: "claude-3-5-sonnet"}, }, }) if err != nil { t.Fatalf("CreateConfig anthropic failed: %v", err) } now := time.Now().Truncate(time.Millisecond) authTokenID := int64(77) otherAuthTokenID := int64(88) if err := store.BatchAddLogs(ctx, []*model.LogEntry{ {Time: model.JSONTime{Time: now}, ChannelID: openaiCfg.ID, Model: "gpt-4o", StatusCode: 200, AuthTokenID: authTokenID, LogSource: model.LogSourceProxy}, {Time: model.JSONTime{Time: now}, ChannelID: openaiCfg.ID, Model: "gpt-4.1", StatusCode: 200, AuthTokenID: authTokenID, LogSource: model.LogSourceProxy}, {Time: model.JSONTime{Time: now}, ChannelID: openaiCfg.ID, Model: "gpt-4o", StatusCode: 200, AuthTokenID: otherAuthTokenID, LogSource: model.LogSourceProxy}, {Time: model.JSONTime{Time: now}, ChannelID: anthCfg.ID, Model: "gpt-4o", StatusCode: 200, AuthTokenID: authTokenID, LogSource: model.LogSourceProxy}, {Time: model.JSONTime{Time: now}, ChannelID: openaiCfg.ID, Model: "gpt-4o", StatusCode: 200, AuthTokenID: authTokenID, LogSource: model.LogSourceManualTest}, }); err != nil { t.Fatalf("BatchAddLogs failed: %v", err) } ss := store.(*sqlstore.SQLStore) rows, err := ss.GetHealthTimeline(ctx, model.HealthTimelineParams{ SinceMs: now.Add(-time.Minute).UnixMilli(), UntilMs: now.Add(time.Minute).UnixMilli(), BucketMs: 60_000, Filter: &model.LogFilter{ ChannelType: "openai", ChannelNameLike: "main", ModelLike: "gpt-4", AuthTokenID: &authTokenID, LogSource: model.LogSourceProxy, }, }) if err != nil { t.Fatalf("GetHealthTimeline failed: %v", err) } if len(rows) != 2 { t.Fatalf("GetHealthTimeline rows=%+v, want exactly two filtered openai/gpt-4 proxy rows for token %d", rows, authTokenID) } for _, row := range rows { if int64(row.ChannelID) != openaiCfg.ID { t.Fatalf("row channel_id=%d, want %d", row.ChannelID, openaiCfg.ID) } if row.Model != "gpt-4o" && row.Model != "gpt-4.1" { t.Fatalf("row model=%q, want gpt-4o or gpt-4.1", row.Model) } if row.Success != 1 || row.ErrorCount != 0 { t.Fatalf("row counts=%+v, want one success and no errors", row) } } } func TestMetrics_LastSuccessAndLastFailedRequest(t *testing.T) { store := newTestStore(t, "metrics_last_success.db") ctx := context.Background() cfg, err := store.CreateConfig(ctx, &model.Config{ Name: "last-success-channel", URL: "https://example.com", Priority: 10, Enabled: true, ChannelType: "openai", ModelEntries: []model.ModelEntry{ {Model: "gpt-4o"}, }, }) if err != nil { t.Fatalf("CreateConfig failed: %v", err) } now := time.Now().Truncate(time.Millisecond) successAt := now.Add(-30 * time.Second) failedAt := now.Add(-10 * time.Second) cancelledAt := now.Add(-5 * time.Second) if err := store.BatchAddLogs(ctx, []*model.LogEntry{ {Time: model.JSONTime{Time: successAt}, ChannelID: cfg.ID, Model: "gpt-4o", StatusCode: 200, Message: "ok", LogSource: model.LogSourceProxy}, {Time: model.JSONTime{Time: failedAt}, ChannelID: cfg.ID, Model: "gpt-4o", StatusCode: 429, Message: "rate limit exceeded", LogSource: model.LogSourceProxy}, {Time: model.JSONTime{Time: failedAt}, ChannelID: cfg.ID, Model: "gpt-4o", StatusCode: 500, Message: "upstream failed later", LogSource: model.LogSourceProxy}, {Time: model.JSONTime{Time: cancelledAt}, ChannelID: cfg.ID, Model: "gpt-4o", StatusCode: 499, Message: "client cancelled", LogSource: model.LogSourceProxy}, }); err != nil { t.Fatalf("BatchAddLogs failed: %v", err) } stats, err := store.GetStats(ctx, now.Add(-time.Minute), now.Add(time.Minute), nil, false) if err != nil { t.Fatalf("GetStats failed: %v", err) } for _, e := range stats { if e.ChannelID == nil || int64(*e.ChannelID) != cfg.ID || e.Model != "gpt-4o" { continue } if e.LastSuccessAt == nil || *e.LastSuccessAt != successAt.UnixMilli() { t.Fatalf("LastSuccessAt=%v, want %d", e.LastSuccessAt, successAt.UnixMilli()) } if e.LastSuccessID == nil || *e.LastSuccessID <= 0 { t.Fatalf("LastSuccessID=%v, want positive id", e.LastSuccessID) } wantLastRequestAt := failedAt.UnixMilli() if e.LastRequestAt == nil || *e.LastRequestAt != wantLastRequestAt { t.Fatalf("LastRequestAt=%v, want %d", e.LastRequestAt, wantLastRequestAt) } if e.LastRequestID == nil || *e.LastRequestID <= 0 { t.Fatalf("LastRequestID=%v, want positive id", e.LastRequestID) } if e.LastRequestStatus == nil || *e.LastRequestStatus != 500 { t.Fatalf("LastRequestStatus=%v, want 500", e.LastRequestStatus) } if e.LastRequestMessage != "upstream failed later" { t.Fatalf("LastRequestMessage=%q, want upstream failed later", e.LastRequestMessage) } return } t.Fatalf("stats missing channel %d model gpt-4o: %+v", cfg.ID, stats) } func TestMetrics_ChannelLevelLastRequestIDsExposeTieBreakForFrontEndAggregation(t *testing.T) { store := newTestStore(t, "metrics_last_request_ids.db") ctx := context.Background() cfg, err := store.CreateConfig(ctx, &model.Config{ Name: "multi-model-channel", URL: "https://example.com", Priority: 10, Enabled: true, ChannelType: "openai", ModelEntries: []model.ModelEntry{ {Model: "gpt-4o"}, {Model: "gpt-4.1"}, }, }) if err != nil { t.Fatalf("CreateConfig failed: %v", err) } now := time.Now().Truncate(time.Millisecond) if err := store.BatchAddLogs(ctx, []*model.LogEntry{ {Time: model.JSONTime{Time: now}, ChannelID: cfg.ID, Model: "gpt-4o", StatusCode: 200, Message: "ok-a", LogSource: model.LogSourceProxy}, {Time: model.JSONTime{Time: now}, ChannelID: cfg.ID, Model: "gpt-4.1", StatusCode: 500, Message: "fail-b", LogSource: model.LogSourceProxy}, }); err != nil { t.Fatalf("BatchAddLogs failed: %v", err) } stats, err := store.GetStats(ctx, now.Add(-time.Minute), now.Add(time.Minute), nil, false) if err != nil { t.Fatalf("GetStats failed: %v", err) } idsByModel := make(map[string]int64, 2) for _, e := range stats { if e.ChannelID == nil || int64(*e.ChannelID) != cfg.ID { continue } if e.LastRequestID == nil || *e.LastRequestID <= 0 { t.Fatalf("model %s LastRequestID=%v, want positive id", e.Model, e.LastRequestID) } if e.LastRequestStatus == nil || *e.LastRequestStatus != 500 { t.Fatalf("model %s LastRequestStatus=%v, want channel latest status 500", e.Model, e.LastRequestStatus) } if e.LastRequestMessage != "fail-b" { t.Fatalf("model %s LastRequestMessage=%q, want channel latest message fail-b", e.Model, e.LastRequestMessage) } idsByModel[e.Model] = *e.LastRequestID } if len(idsByModel) != 2 { t.Fatalf("idsByModel=%v, want 2 models", idsByModel) } if idsByModel["gpt-4.1"] != idsByModel["gpt-4o"] { t.Fatalf("want channel-level latest request id shared across entries, got %+v", idsByModel) } } func TestMetrics_LastSuccessAtIgnoresCurrentRange(t *testing.T) { store := newTestStore(t, "metrics_last_success_all_time.db") ctx := context.Background() cfg, err := store.CreateConfig(ctx, &model.Config{ Name: "history-success-channel", URL: "https://example.com", Priority: 10, Enabled: true, ChannelType: "openai", ModelEntries: []model.ModelEntry{ {Model: "gpt-4o"}, }, }) if err != nil { t.Fatalf("CreateConfig failed: %v", err) } now := time.Now().Truncate(time.Millisecond) oldSuccessAt := now.Add(-48 * time.Hour) recentFailedAt := now.Add(-10 * time.Second) if err := store.BatchAddLogs(ctx, []*model.LogEntry{ {Time: model.JSONTime{Time: oldSuccessAt}, ChannelID: cfg.ID, Model: "gpt-4o", StatusCode: 200, Message: "historic ok", LogSource: model.LogSourceProxy}, {Time: model.JSONTime{Time: recentFailedAt}, ChannelID: cfg.ID, Model: "gpt-4o", StatusCode: 500, Message: "recent failed", LogSource: model.LogSourceProxy}, }); err != nil { t.Fatalf("BatchAddLogs failed: %v", err) } stats, err := store.GetStats(ctx, now.Add(-time.Minute), now.Add(time.Minute), nil, false) if err != nil { t.Fatalf("GetStats failed: %v", err) } for _, e := range stats { if e.ChannelID == nil || int64(*e.ChannelID) != cfg.ID || e.Model != "gpt-4o" { continue } if e.LastSuccessAt == nil || *e.LastSuccessAt != oldSuccessAt.UnixMilli() { t.Fatalf("LastSuccessAt=%v, want %d", e.LastSuccessAt, oldSuccessAt.UnixMilli()) } if e.LastRequestAt == nil || *e.LastRequestAt != recentFailedAt.UnixMilli() { t.Fatalf("LastRequestAt=%v, want %d", e.LastRequestAt, recentFailedAt.UnixMilli()) } return } t.Fatalf("stats missing channel %d model gpt-4o: %+v", cfg.ID, stats) } func TestMetrics_LastRequestAtIgnoresCurrentRange(t *testing.T) { store := newTestStore(t, "metrics_last_request_all_time.db") ctx := context.Background() cfg, err := store.CreateConfig(ctx, &model.Config{ Name: "history-request-channel", URL: "https://example.com", Priority: 10, Enabled: true, ChannelType: "openai", ModelEntries: []model.ModelEntry{ {Model: "gpt-4o"}, }, }) if err != nil { t.Fatalf("CreateConfig failed: %v", err) } now := time.Now().Truncate(time.Millisecond) inRangeSuccessAt := now.Add(-30 * time.Second) latestFailedAt := now.Add(2 * time.Hour) if err := store.BatchAddLogs(ctx, []*model.LogEntry{ {Time: model.JSONTime{Time: inRangeSuccessAt}, ChannelID: cfg.ID, Model: "gpt-4o", StatusCode: 200, Message: "in-range ok", LogSource: model.LogSourceProxy}, {Time: model.JSONTime{Time: latestFailedAt}, ChannelID: cfg.ID, Model: "gpt-4o", StatusCode: 502, Message: "latest failed outside range", LogSource: model.LogSourceProxy}, }); err != nil { t.Fatalf("BatchAddLogs failed: %v", err) } stats, err := store.GetStats(ctx, now.Add(-time.Minute), now.Add(time.Minute), nil, false) if err != nil { t.Fatalf("GetStats failed: %v", err) } for _, e := range stats { if e.ChannelID == nil || int64(*e.ChannelID) != cfg.ID || e.Model != "gpt-4o" { continue } if e.LastSuccessAt == nil || *e.LastSuccessAt != inRangeSuccessAt.UnixMilli() { t.Fatalf("LastSuccessAt=%v, want %d", e.LastSuccessAt, inRangeSuccessAt.UnixMilli()) } if e.LastRequestAt == nil || *e.LastRequestAt != latestFailedAt.UnixMilli() { t.Fatalf("LastRequestAt=%v, want %d", e.LastRequestAt, latestFailedAt.UnixMilli()) } if e.LastRequestStatus == nil || *e.LastRequestStatus != 502 { t.Fatalf("LastRequestStatus=%v, want 502", e.LastRequestStatus) } if e.LastRequestMessage != "latest failed outside range" { t.Fatalf("LastRequestMessage=%q, want latest failed outside range", e.LastRequestMessage) } return } t.Fatalf("stats missing channel %d model gpt-4o: %+v", cfg.ID, stats) } func TestMetrics_LastStateIsChannelLevelWithoutModelFilter(t *testing.T) { store := newTestStore(t, "metrics_last_state_channel_level.db") ctx := context.Background() cfg, err := store.CreateConfig(ctx, &model.Config{ Name: "channel-level-state", URL: "https://example.com", Priority: 10, Enabled: true, ChannelType: "openai", ModelEntries: []model.ModelEntry{ {Model: "model-a"}, {Model: "model-b"}, }, }) if err != nil { t.Fatalf("CreateConfig failed: %v", err) } now := time.Now().Truncate(time.Millisecond) inRangeFailedAt := now.Add(-3 * time.Hour) latestSuccessAt := now.Add(-1 * time.Hour) if err := store.BatchAddLogs(ctx, []*model.LogEntry{ {Time: model.JSONTime{Time: inRangeFailedAt}, ChannelID: cfg.ID, Model: "model-b", StatusCode: 500, Message: "model b failed in range", LogSource: model.LogSourceProxy}, {Time: model.JSONTime{Time: latestSuccessAt}, ChannelID: cfg.ID, Model: "model-a", StatusCode: 200, Message: "model a recovered after range", LogSource: model.LogSourceProxy}, }); err != nil { t.Fatalf("BatchAddLogs failed: %v", err) } stats, err := store.GetStats(ctx, now.Add(-4*time.Hour), now.Add(-2*time.Hour), nil, false) if err != nil { t.Fatalf("GetStats failed: %v", err) } for _, e := range stats { if e.ChannelID == nil || int64(*e.ChannelID) != cfg.ID || e.Model != "model-b" { continue } if e.LastSuccessAt == nil || *e.LastSuccessAt != latestSuccessAt.UnixMilli() { t.Fatalf("LastSuccessAt=%v, want channel latest success %d", e.LastSuccessAt, latestSuccessAt.UnixMilli()) } if e.LastRequestAt == nil || *e.LastRequestAt != latestSuccessAt.UnixMilli() { t.Fatalf("LastRequestAt=%v, want channel latest request %d", e.LastRequestAt, latestSuccessAt.UnixMilli()) } if e.LastRequestStatus == nil || *e.LastRequestStatus != 200 { t.Fatalf("LastRequestStatus=%v, want 200", e.LastRequestStatus) } if e.LastRequestMessage != "model a recovered after range" { t.Fatalf("LastRequestMessage=%q, want model a recovered after range", e.LastRequestMessage) } return } t.Fatalf("stats missing channel %d model model-b: %+v", cfg.ID, stats) } func TestMetrics_LastStateRespectsModelFilter(t *testing.T) { store := newTestStore(t, "metrics_last_state_model_filter.db") ctx := context.Background() cfg, err := store.CreateConfig(ctx, &model.Config{ Name: "model-filter-state", URL: "https://example.com", Priority: 10, Enabled: true, ChannelType: "openai", ModelEntries: []model.ModelEntry{ {Model: "model-a"}, {Model: "model-b"}, }, }) if err != nil { t.Fatalf("CreateConfig failed: %v", err) } now := time.Now().Truncate(time.Millisecond) inRangeFailedAt := now.Add(-3 * time.Hour) latestSuccessAt := now.Add(-1 * time.Hour) if err := store.BatchAddLogs(ctx, []*model.LogEntry{ {Time: model.JSONTime{Time: inRangeFailedAt}, ChannelID: cfg.ID, Model: "model-b", StatusCode: 500, Message: "model b failed in range", LogSource: model.LogSourceProxy}, {Time: model.JSONTime{Time: latestSuccessAt}, ChannelID: cfg.ID, Model: "model-a", StatusCode: 200, Message: "model a recovered after range", LogSource: model.LogSourceProxy}, }); err != nil { t.Fatalf("BatchAddLogs failed: %v", err) } stats, err := store.GetStats(ctx, now.Add(-4*time.Hour), now.Add(-2*time.Hour), &model.LogFilter{ Model: "model-b", LogSource: model.LogSourceProxy, }, false) if err != nil { t.Fatalf("GetStats failed: %v", err) } for _, e := range stats { if e.ChannelID == nil || int64(*e.ChannelID) != cfg.ID || e.Model != "model-b" { continue } if e.LastSuccessAt != nil { t.Fatalf("LastSuccessAt=%v, want nil for model-b", e.LastSuccessAt) } if e.LastRequestAt == nil || *e.LastRequestAt != inRangeFailedAt.UnixMilli() { t.Fatalf("LastRequestAt=%v, want model latest request %d", e.LastRequestAt, inRangeFailedAt.UnixMilli()) } if e.LastRequestStatus == nil || *e.LastRequestStatus != 500 { t.Fatalf("LastRequestStatus=%v, want 500", e.LastRequestStatus) } return } t.Fatalf("stats missing channel %d model model-b: %+v", cfg.ID, stats) } func TestMetrics_LastStateIgnoresStatusCodeFilter(t *testing.T) { store := newTestStore(t, "metrics_last_state_status_filter.db") ctx := context.Background() cfg, err := store.CreateConfig(ctx, &model.Config{ Name: "status-filter-channel", URL: "https://example.com", Priority: 10, Enabled: true, ChannelType: "openai", ModelEntries: []model.ModelEntry{ {Model: "gpt-4o"}, }, }) if err != nil { t.Fatalf("CreateConfig failed: %v", err) } now := time.Now().Truncate(time.Millisecond) successAt := now.Add(-30 * time.Second) failed500At := now.Add(-20 * time.Second) failed429At := now.Add(-10 * time.Second) if err := store.BatchAddLogs(ctx, []*model.LogEntry{ {Time: model.JSONTime{Time: successAt}, ChannelID: cfg.ID, Model: "gpt-4o", StatusCode: 200, Message: "historic ok", LogSource: model.LogSourceProxy}, {Time: model.JSONTime{Time: failed500At}, ChannelID: cfg.ID, Model: "gpt-4o", StatusCode: 500, Message: "old failed", LogSource: model.LogSourceProxy}, {Time: model.JSONTime{Time: failed429At}, ChannelID: cfg.ID, Model: "gpt-4o", StatusCode: 429, Message: "latest failed", LogSource: model.LogSourceProxy}, }); err != nil { t.Fatalf("BatchAddLogs failed: %v", err) } statusCode := 500 stats, err := store.GetStats(ctx, now.Add(-time.Minute), now.Add(time.Minute), &model.LogFilter{ StatusCode: &statusCode, LogSource: model.LogSourceProxy, }, false) if err != nil { t.Fatalf("GetStats failed: %v", err) } for _, e := range stats { if e.ChannelID == nil || int64(*e.ChannelID) != cfg.ID || e.Model != "gpt-4o" { continue } if e.Success != 0 || e.Error != 1 || e.Total != 1 { t.Fatalf("filtered stats mismatch: success=%d error=%d total=%d", e.Success, e.Error, e.Total) } if e.LastSuccessAt == nil || *e.LastSuccessAt != successAt.UnixMilli() { t.Fatalf("LastSuccessAt=%v, want %d", e.LastSuccessAt, successAt.UnixMilli()) } if e.LastRequestAt == nil || *e.LastRequestAt != failed429At.UnixMilli() { t.Fatalf("LastRequestAt=%v, want %d", e.LastRequestAt, failed429At.UnixMilli()) } if e.LastRequestStatus == nil || *e.LastRequestStatus != 429 { t.Fatalf("LastRequestStatus=%v, want 429", e.LastRequestStatus) } if e.LastRequestMessage != "latest failed" { t.Fatalf("LastRequestMessage=%q, want latest failed", e.LastRequestMessage) } return } t.Fatalf("stats missing channel %d model gpt-4o: %+v", cfg.ID, stats) } func TestGetStats_PreservesZeroCostMultiplierForFreeChannels(t *testing.T) { store := newTestStore(t, "metrics_zero_multiplier.db") ctx := context.Background() cfg, err := store.CreateConfig(ctx, &model.Config{ Name: "free-channel", URL: "https://example.com", Priority: 1, Enabled: true, ChannelType: "openai", CostMultiplier: 0, ModelEntries: []model.ModelEntry{ {Model: "gpt-5.4"}, }, }) if err != nil { t.Fatalf("CreateConfig failed: %v", err) } now := time.Now() if err := store.BatchAddLogs(ctx, []*model.LogEntry{ { Time: model.JSONTime{Time: now}, ChannelID: cfg.ID, Model: "gpt-5.4", StatusCode: 200, Duration: 0.1, Cost: 0.02, LogSource: model.LogSourceProxy, }, }); err != nil { t.Fatalf("BatchAddLogs failed: %v", err) } stats, err := store.GetStats(ctx, now.Add(-time.Minute), now.Add(time.Minute), nil, false) if err != nil { t.Fatalf("GetStats failed: %v", err) } if len(stats) != 1 { t.Fatalf("GetStats len=%d, want 1", len(stats)) } if stats[0].CostMultiplier == nil { t.Fatalf("expected cost_multiplier=0, got nil") } if *stats[0].CostMultiplier != 0 { t.Fatalf("expected cost_multiplier=0, got %v", *stats[0].CostMultiplier) } if stats[0].EffectiveCost == nil { t.Fatalf("expected effective_cost=0, got nil") } if *stats[0].EffectiveCost != 0 { t.Fatalf("expected effective_cost=0, got %v", *stats[0].EffectiveCost) } }