diff --git a/backend/internal/handler/admin/dashboard_handler.go b/backend/internal/handler/admin/dashboard_handler.go index c94ed033fa16..42d0538c9bb8 100644 --- a/backend/internal/handler/admin/dashboard_handler.go +++ b/backend/internal/handler/admin/dashboard_handler.go @@ -262,13 +262,18 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) { return } } - upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch") + nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2") + if err != nil { + response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false") + return + } + upstreamModelMismatch, err = parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch") if err != nil { response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false") return } - trend, hit, err := h.getUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, upstreamModelMismatch) + trend, hit, err := h.getUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, nativeCompactionV2, billingType, upstreamModelMismatch) if err != nil { response.Error(c, 500, "Failed to get usage trend") return @@ -349,13 +354,18 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) { return } } - upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch") + nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2") + if err != nil { + response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false") + return + } + upstreamModelMismatch, err = parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch") if err != nil { response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false") return } - stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, modelSource, requestType, stream, billingType, upstreamModelMismatch) + stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, modelSource, requestType, stream, nativeCompactionV2, billingType, upstreamModelMismatch) if err != nil { response.Error(c, 500, "Failed to get model statistics") return @@ -426,13 +436,18 @@ func (h *DashboardHandler) GetGroupStats(c *gin.Context) { return } } - upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch") + nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2") + if err != nil { + response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false") + return + } + upstreamModelMismatch, err = parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch") if err != nil { response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false") return } - stats, hit, err := h.getGroupStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, upstreamModelMismatch) + stats, hit, err := h.getGroupStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, nativeCompactionV2, billingType, upstreamModelMismatch) if err != nil { response.Error(c, 500, "Failed to get group statistics") return @@ -701,6 +716,14 @@ func (h *DashboardHandler) GetUserBreakdown(c *gin.Context) { dim.Stream = &s } } + if v := strings.TrimSpace(c.Query("native_compaction_v2")); v != "" { + value, err := strconv.ParseBool(v) + if err != nil { + response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false") + return + } + dim.NativeCompactionV2 = &value + } if v := c.Query("billing_type"); v != "" { if bt, err := strconv.ParseInt(v, 10, 8); err == nil { btVal := int8(bt) diff --git a/backend/internal/handler/admin/dashboard_handler_request_type_test.go b/backend/internal/handler/admin/dashboard_handler_request_type_test.go index 3316557dbae6..dd77a5d01ff8 100644 --- a/backend/internal/handler/admin/dashboard_handler_request_type_test.go +++ b/backend/internal/handler/admin/dashboard_handler_request_type_test.go @@ -15,16 +15,19 @@ import ( type dashboardUsageRepoCapture struct { service.UsageLogRepository - trendRequestType *int16 - trendStream *bool - modelRequestType *int16 - modelStream *bool - trendMismatch *bool - modelMismatch *bool - groupMismatch *bool - rankingLimit int - ranking []usagestats.UserSpendingRankingItem - rankingTotal float64 + trendRequestType *int16 + trendStream *bool + trendNativeCompaction *bool + modelRequestType *int16 + modelStream *bool + modelNativeCompaction *bool + groupNativeCompaction *bool + trendMismatch *bool + modelMismatch *bool + groupMismatch *bool + rankingLimit int + ranking []usagestats.UserSpendingRankingItem + rankingTotal float64 } func (s *dashboardUsageRepoCapture) GetUsageTrendWithUsageFilters( @@ -35,6 +38,7 @@ func (s *dashboardUsageRepoCapture) GetUsageTrendWithUsageFilters( ) ([]usagestats.TrendDataPoint, error) { s.trendRequestType = filters.RequestType s.trendStream = filters.Stream + s.trendNativeCompaction = filters.NativeCompactionV2 s.trendMismatch = filters.UpstreamModelMismatch return []usagestats.TrendDataPoint{}, nil } @@ -62,6 +66,7 @@ func (s *dashboardUsageRepoCapture) GetModelStatsWithUsageFiltersBySource( ) ([]usagestats.ModelStat, error) { s.modelRequestType = filters.RequestType s.modelStream = filters.Stream + s.modelNativeCompaction = filters.NativeCompactionV2 s.modelMismatch = filters.UpstreamModelMismatch return []usagestats.ModelStat{}, nil } @@ -71,6 +76,7 @@ func (s *dashboardUsageRepoCapture) GetGroupStatsWithUsageFilters( startTime, endTime time.Time, filters usagestats.UsageLogFilters, ) ([]usagestats.GroupStat, error) { + s.groupNativeCompaction = filters.NativeCompactionV2 s.groupMismatch = filters.UpstreamModelMismatch return []usagestats.GroupStat{}, nil } @@ -208,6 +214,48 @@ func TestDashboardModelStatsValidModelSource(t *testing.T) { require.Equal(t, http.StatusOK, rec.Code) } +func TestDashboardNativeCompactionFilterPropagatesAlongsideTransport(t *testing.T) { + resetDashboardReadCachesForTest() + repo := &dashboardUsageRepoCapture{} + router := newDashboardRequestTypeTestRouter(repo) + + for _, path := range []string{ + "/admin/dashboard/trend?request_type=stream&native_compaction_v2=true", + "/admin/dashboard/models?request_type=stream&native_compaction_v2=true", + "/admin/dashboard/groups?request_type=stream&native_compaction_v2=true", + } { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code, path) + } + + require.NotNil(t, repo.trendNativeCompaction) + require.True(t, *repo.trendNativeCompaction) + require.NotNil(t, repo.modelNativeCompaction) + require.True(t, *repo.modelNativeCompaction) + require.NotNil(t, repo.groupNativeCompaction) + require.True(t, *repo.groupNativeCompaction) + require.NotNil(t, repo.trendRequestType) + require.Equal(t, int16(service.RequestTypeStream), *repo.trendRequestType) +} + +func TestDashboardNativeCompactionFilterRejectsInvalidBoolean(t *testing.T) { + repo := &dashboardUsageRepoCapture{} + router := newDashboardRequestTypeTestRouter(repo) + + for _, path := range []string{ + "/admin/dashboard/trend?native_compaction_v2=invalid", + "/admin/dashboard/models?native_compaction_v2=invalid", + "/admin/dashboard/groups?native_compaction_v2=invalid", + } { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code, path) + } +} + func TestDashboardModelAuditFilterPropagatesToTrendModelAndGroupQueries(t *testing.T) { resetDashboardReadCachesForTest() repo := &dashboardUsageRepoCapture{} diff --git a/backend/internal/handler/admin/dashboard_query_cache.go b/backend/internal/handler/admin/dashboard_query_cache.go index 4f79fb4500c3..61d38a66e99e 100644 --- a/backend/internal/handler/admin/dashboard_query_cache.go +++ b/backend/internal/handler/admin/dashboard_query_cache.go @@ -28,6 +28,7 @@ type dashboardTrendCacheKey struct { Model string `json:"model"` RequestType *int16 `json:"request_type"` Stream *bool `json:"stream"` + NativeCompactionV2 *bool `json:"native_compaction_v2"` BillingType *int8 `json:"billing_type"` UpstreamModelMismatch *bool `json:"upstream_model_mismatch"` } @@ -42,6 +43,7 @@ type dashboardModelGroupCacheKey struct { ModelSource string `json:"model_source,omitempty"` RequestType *int16 `json:"request_type"` Stream *bool `json:"stream"` + NativeCompactionV2 *bool `json:"native_compaction_v2"` BillingType *int8 `json:"billing_type"` UpstreamModelMismatch *bool `json:"upstream_model_mismatch"` } @@ -85,6 +87,7 @@ func (h *DashboardHandler) getUsageTrendCached( model string, requestType *int16, stream *bool, + nativeCompactionV2 *bool, billingType *int8, upstreamModelMismatch *bool, ) ([]usagestats.TrendDataPoint, bool, error) { @@ -99,13 +102,14 @@ func (h *DashboardHandler) getUsageTrendCached( Model: model, RequestType: requestType, Stream: stream, + NativeCompactionV2: nativeCompactionV2, BillingType: billingType, UpstreamModelMismatch: upstreamModelMismatch, }) entry, hit, err := dashboardTrendCache.GetOrLoad(key, func() (any, error) { return h.dashboardService.GetUsageTrendWithUsageFilters(ctx, startTime, endTime, granularity, usagestats.UsageLogFilters{ UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID, - Model: model, RequestType: requestType, Stream: stream, BillingType: billingType, + Model: model, RequestType: requestType, Stream: stream, NativeCompactionV2: nativeCompactionV2, BillingType: billingType, UpstreamModelMismatch: upstreamModelMismatch, }) }) @@ -123,6 +127,7 @@ func (h *DashboardHandler) getModelStatsCached( modelSource string, requestType *int16, stream *bool, + nativeCompactionV2 *bool, billingType *int8, upstreamModelMismatch *bool, ) ([]usagestats.ModelStat, bool, error) { @@ -136,13 +141,14 @@ func (h *DashboardHandler) getModelStatsCached( ModelSource: usagestats.NormalizeModelSource(modelSource), RequestType: requestType, Stream: stream, + NativeCompactionV2: nativeCompactionV2, BillingType: billingType, UpstreamModelMismatch: upstreamModelMismatch, }) entry, hit, err := dashboardModelStatsCache.GetOrLoad(key, func() (any, error) { return h.dashboardService.GetModelStatsWithUsageFiltersBySource(ctx, startTime, endTime, usagestats.UsageLogFilters{ UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID, - RequestType: requestType, Stream: stream, BillingType: billingType, + RequestType: requestType, Stream: stream, NativeCompactionV2: nativeCompactionV2, BillingType: billingType, UpstreamModelMismatch: upstreamModelMismatch, }, modelSource) }) @@ -159,6 +165,7 @@ func (h *DashboardHandler) getGroupStatsCached( userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, + nativeCompactionV2 *bool, billingType *int8, upstreamModelMismatch *bool, ) ([]usagestats.GroupStat, bool, error) { @@ -171,13 +178,14 @@ func (h *DashboardHandler) getGroupStatsCached( GroupID: groupID, RequestType: requestType, Stream: stream, + NativeCompactionV2: nativeCompactionV2, BillingType: billingType, UpstreamModelMismatch: upstreamModelMismatch, }) entry, hit, err := dashboardGroupStatsCache.GetOrLoad(key, func() (any, error) { return h.dashboardService.GetGroupStatsWithUsageFilters(ctx, startTime, endTime, usagestats.UsageLogFilters{ UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID, - RequestType: requestType, Stream: stream, BillingType: billingType, + RequestType: requestType, Stream: stream, NativeCompactionV2: nativeCompactionV2, BillingType: billingType, UpstreamModelMismatch: upstreamModelMismatch, }) }) diff --git a/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go b/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go index b19c9360aa98..1900205eaf6b 100644 --- a/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go +++ b/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go @@ -44,6 +44,7 @@ type dashboardSnapshotV2Filters struct { Model string RequestType *int16 Stream *bool + NativeCompactionV2 *bool BillingType *int8 UpstreamModelMismatch *bool } @@ -59,6 +60,7 @@ type dashboardSnapshotV2CacheKey struct { Model string `json:"model"` RequestType *int16 `json:"request_type"` Stream *bool `json:"stream"` + NativeCompactionV2 *bool `json:"native_compaction_v2"` BillingType *int8 `json:"billing_type"` UpstreamModelMismatch *bool `json:"upstream_model_mismatch"` IncludeStats bool `json:"include_stats"` @@ -105,6 +107,7 @@ func (h *DashboardHandler) GetSnapshotV2(c *gin.Context) { Model: filters.Model, RequestType: filters.RequestType, Stream: filters.Stream, + NativeCompactionV2: filters.NativeCompactionV2, BillingType: filters.BillingType, UpstreamModelMismatch: filters.UpstreamModelMismatch, IncludeStats: includeStats, @@ -186,6 +189,7 @@ func (h *DashboardHandler) buildSnapshotV2Response( filters.Model, filters.RequestType, filters.Stream, + filters.NativeCompactionV2, filters.BillingType, filters.UpstreamModelMismatch, ) @@ -207,6 +211,7 @@ func (h *DashboardHandler) buildSnapshotV2Response( usagestats.ModelSourceRequested, filters.RequestType, filters.Stream, + filters.NativeCompactionV2, filters.BillingType, filters.UpstreamModelMismatch, ) @@ -227,6 +232,7 @@ func (h *DashboardHandler) buildSnapshotV2Response( filters.GroupID, filters.RequestType, filters.Stream, + filters.NativeCompactionV2, filters.BillingType, filters.UpstreamModelMismatch, ) @@ -296,6 +302,14 @@ func parseDashboardSnapshotV2Filters(c *gin.Context) (*dashboardSnapshotV2Filter filters.Stream = &streamVal } + if nativeCompactionV2Str := strings.TrimSpace(c.Query("native_compaction_v2")); nativeCompactionV2Str != "" { + value, err := strconv.ParseBool(nativeCompactionV2Str) + if err != nil { + return nil, err + } + filters.NativeCompactionV2 = &value + } + if billingTypeStr := strings.TrimSpace(c.Query("billing_type")); billingTypeStr != "" { v, err := strconv.ParseInt(billingTypeStr, 10, 8) if err != nil { diff --git a/backend/internal/handler/admin/usage_handler.go b/backend/internal/handler/admin/usage_handler.go index 829730c647f5..4fe37f087f44 100644 --- a/backend/internal/handler/admin/usage_handler.go +++ b/backend/internal/handler/admin/usage_handler.go @@ -132,6 +132,12 @@ func (h *UsageHandler) List(c *gin.Context) { stream = &val } + nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2") + if err != nil { + response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false") + return + } + var billingType *int8 if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" { val, err := strconv.ParseInt(billingTypeStr, 10, 8) @@ -192,6 +198,7 @@ func (h *UsageHandler) List(c *gin.Context) { ModelFilterSource: usagestats.ModelSourceRequested, RequestType: requestType, Stream: stream, + NativeCompactionV2: nativeCompactionV2, BillingType: billingType, BillingMode: billingMode, UpstreamModelMismatch: upstreamModelMismatch, @@ -276,6 +283,12 @@ func (h *UsageHandler) Stats(c *gin.Context) { stream = &val } + nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2") + if err != nil { + response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false") + return + } + var billingType *int8 if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" { val, err := strconv.ParseInt(billingTypeStr, 10, 8) @@ -344,6 +357,7 @@ func (h *UsageHandler) Stats(c *gin.Context) { ModelFilterSource: usagestats.ModelSourceRequested, RequestType: requestType, Stream: stream, + NativeCompactionV2: nativeCompactionV2, BillingType: billingType, BillingMode: billingMode, UpstreamModelMismatch: upstreamModelMismatch, diff --git a/backend/internal/handler/admin/usage_handler_request_type_test.go b/backend/internal/handler/admin/usage_handler_request_type_test.go index 7ea657749c58..7a045be10b9a 100644 --- a/backend/internal/handler/admin/usage_handler_request_type_test.go +++ b/backend/internal/handler/admin/usage_handler_request_type_test.go @@ -95,6 +95,32 @@ func TestAdminUsageListInvalidStream(t *testing.T) { require.Equal(t, http.StatusBadRequest, rec.Code) } +func TestAdminUsageListNativeCompactionFilter(t *testing.T) { + repo := &adminUsageRepoCapture{} + router := newAdminUsageRequestTypeTestRouter(repo) + + req := httptest.NewRequest(http.MethodGet, "/admin/usage?request_type=stream&native_compaction_v2=true", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.NotNil(t, repo.listFilters.RequestType) + require.Equal(t, int16(service.RequestTypeStream), *repo.listFilters.RequestType) + require.NotNil(t, repo.listFilters.NativeCompactionV2) + require.True(t, *repo.listFilters.NativeCompactionV2) +} + +func TestAdminUsageListInvalidNativeCompactionFilter(t *testing.T) { + repo := &adminUsageRepoCapture{} + router := newAdminUsageRequestTypeTestRouter(repo) + + req := httptest.NewRequest(http.MethodGet, "/admin/usage?native_compaction_v2=oops", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusBadRequest, rec.Code) +} + func TestAdminUsageListExactTotalTrue(t *testing.T) { repo := &adminUsageRepoCapture{} router := newAdminUsageRequestTypeTestRouter(repo) @@ -144,6 +170,19 @@ func TestAdminUsageStatsRequestTypePriority(t *testing.T) { require.Nil(t, repo.statsFilters.Stream) } +func TestAdminUsageStatsNativeCompactionFilter(t *testing.T) { + repo := &adminUsageRepoCapture{} + router := newAdminUsageRequestTypeTestRouter(repo) + + req := httptest.NewRequest(http.MethodGet, "/admin/usage/stats?native_compaction_v2=true", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.NotNil(t, repo.statsFilters.NativeCompactionV2) + require.True(t, *repo.statsFilters.NativeCompactionV2) +} + func TestAdminUsageStatsUsesRequestedModelForDisplayModelFilter(t *testing.T) { repo := &adminUsageRepoCapture{} router := newAdminUsageRequestTypeTestRouter(repo) diff --git a/backend/internal/handler/admin/usage_query_cache.go b/backend/internal/handler/admin/usage_query_cache.go index f6b7746e1bcf..fdf9e2b4f9db 100644 --- a/backend/internal/handler/admin/usage_query_cache.go +++ b/backend/internal/handler/admin/usage_query_cache.go @@ -21,6 +21,7 @@ type usageStatsCacheKeyData struct { BillingMode string `json:"billing_mode"` RequestType *int16 `json:"request_type"` Stream *bool `json:"stream"` + NativeCompactionV2 *bool `json:"native_compaction_v2"` BillingType *int8 `json:"billing_type"` UpstreamModelMismatch *bool `json:"upstream_model_mismatch"` } @@ -45,6 +46,7 @@ func usageStatsCacheKey(filters usagestats.UsageLogFilters) string { BillingMode: filters.BillingMode, RequestType: filters.RequestType, Stream: filters.Stream, + NativeCompactionV2: filters.NativeCompactionV2, BillingType: filters.BillingType, UpstreamModelMismatch: filters.UpstreamModelMismatch, }) diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 7ca18282d283..1c02cd69a3ed 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -667,6 +667,7 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog { RequestType: requestType.String(), Stream: stream, OpenAIWSMode: openAIWSMode, + NativeCompactionV2: l.NativeCompactionV2, DurationMs: l.DurationMs, FirstTokenMs: l.FirstTokenMs, ImageCount: l.ImageCount, diff --git a/backend/internal/handler/dto/mappers_usage_test.go b/backend/internal/handler/dto/mappers_usage_test.go index 3923d9cd2003..bd4e382cb3a8 100644 --- a/backend/internal/handler/dto/mappers_usage_test.go +++ b/backend/internal/handler/dto/mappers_usage_test.go @@ -28,6 +28,27 @@ func TestUsageLogFromService_IncludesOpenAIWSMode(t *testing.T) { require.False(t, UsageLogFromServiceAdmin(httpLog).OpenAIWSMode) } +func TestUsageLogFromService_PreservesNativeCompactionAndStream(t *testing.T) { + t.Parallel() + + log := &service.UsageLog{ + RequestID: "resp_compaction", + Model: "gpt-5.6-sol", + RequestType: service.RequestTypeStream, + Stream: true, + NativeCompactionV2: true, + } + + userDTO := UsageLogFromService(log) + adminDTO := UsageLogFromServiceAdmin(log) + require.Equal(t, "stream", userDTO.RequestType) + require.True(t, userDTO.Stream) + require.True(t, userDTO.NativeCompactionV2) + require.Equal(t, "stream", adminDTO.RequestType) + require.True(t, adminDTO.Stream) + require.True(t, adminDTO.NativeCompactionV2) +} + func TestUsageLogFromService_PrefersRequestTypeForLegacyFields(t *testing.T) { t.Parallel() diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 5081735ac88a..5ac667e171d4 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -522,8 +522,11 @@ type UsageLog struct { RequestType string `json:"request_type"` Stream bool `json:"stream"` OpenAIWSMode bool `json:"openai_ws_mode"` - DurationMs *int `json:"duration_ms"` - FirstTokenMs *int `json:"first_token_ms"` + // NativeCompactionV2 is true only for requests positively identified at + // runtime as the native OpenAI remote compaction v2 wire. + NativeCompactionV2 bool `json:"native_compaction_v2"` + DurationMs *int `json:"duration_ms"` + FirstTokenMs *int `json:"first_token_ms"` // 图片生成字段 ImageCount int `json:"image_count"` diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index fd269a6a2cdd..180da53f9bf1 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -778,6 +778,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, res.UpstreamModel), PricingAt: pricingAt, CyberBlocked: cyberBlocked, + NativeCompactionV2: nativeV2, }); err != nil { logger.L().With( zap.String("component", "handler.openai_gateway.responses"), @@ -3675,6 +3676,7 @@ func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey } // 提前拍成标量,避免在下方 goroutine 内访问 gin.Context。 sessionID := service.ExtractClientSessionID(c) + nativeCompactionV2 := service.IsOpenAINativeCompactionV2(c) apiKeyPrefix := "" if apiKey != nil { apiKeyPrefix = keyPrefix(apiKey.Key, 8) @@ -3742,6 +3744,7 @@ func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey SessionID: sessionID, RequestPayloadHash: requestPayloadHash, APIKeyService: apiKeySvc, + NativeCompactionV2: nativeCompactionV2, ChannelUsageFields: channelFields, }) } diff --git a/backend/internal/handler/usage_handler.go b/backend/internal/handler/usage_handler.go index 45f72ae3066e..9a4284450bc4 100644 --- a/backend/internal/handler/usage_handler.go +++ b/backend/internal/handler/usage_handler.go @@ -126,6 +126,16 @@ func (h *UsageHandler) parseUserUsageFilters(c *gin.Context, requireRange bool) stream = &val } + var nativeCompactionV2 *bool + if raw := strings.TrimSpace(c.Query("native_compaction_v2")); raw != "" { + value, err := strconv.ParseBool(raw) + if err != nil { + response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false") + return nil, false + } + nativeCompactionV2 = &value + } + var billingType *int8 if billingTypeStr := strings.TrimSpace(c.Query("billing_type")); billingTypeStr != "" { val, err := strconv.ParseInt(billingTypeStr, 10, 8) @@ -195,17 +205,18 @@ func (h *UsageHandler) parseUserUsageFilters(c *gin.Context, requireRange bool) return &userUsageFilters{ Filters: usagestats.UsageLogFilters{ - UserID: subject.UserID, - APIKeyID: apiKeyID, - GroupID: groupID, - Model: strings.TrimSpace(c.Query("model")), - ModelFilterSource: usagestats.ModelSourceRequested, - RequestType: requestType, - Stream: stream, - BillingType: billingType, - BillingMode: billingMode, - StartTime: startPtr, - EndTime: endPtr, + UserID: subject.UserID, + APIKeyID: apiKeyID, + GroupID: groupID, + Model: strings.TrimSpace(c.Query("model")), + ModelFilterSource: usagestats.ModelSourceRequested, + RequestType: requestType, + Stream: stream, + NativeCompactionV2: nativeCompactionV2, + BillingType: billingType, + BillingMode: billingMode, + StartTime: startPtr, + EndTime: endPtr, }, StartTime: derefTime(startPtr), EndTime: derefTime(endPtr), diff --git a/backend/internal/handler/usage_handler_request_type_test.go b/backend/internal/handler/usage_handler_request_type_test.go index 1e480d2e3488..b651e2f13183 100644 --- a/backend/internal/handler/usage_handler_request_type_test.go +++ b/backend/internal/handler/usage_handler_request_type_test.go @@ -131,6 +131,32 @@ func TestUserUsageListInvalidStream(t *testing.T) { require.Equal(t, http.StatusBadRequest, rec.Code) } +func TestUserUsageListNativeCompactionFilter(t *testing.T) { + repo := &userUsageRepoCapture{} + router := newUserUsageRequestTypeTestRouter(repo) + + req := httptest.NewRequest(http.MethodGet, "/usage?request_type=stream&native_compaction_v2=true", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.NotNil(t, repo.listFilters.RequestType) + require.Equal(t, int16(service.RequestTypeStream), *repo.listFilters.RequestType) + require.NotNil(t, repo.listFilters.NativeCompactionV2) + require.True(t, *repo.listFilters.NativeCompactionV2) +} + +func TestUserUsageListInvalidNativeCompactionFilter(t *testing.T) { + repo := &userUsageRepoCapture{} + router := newUserUsageRequestTypeTestRouter(repo) + + req := httptest.NewRequest(http.MethodGet, "/usage?native_compaction_v2=unknown", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusBadRequest, rec.Code) +} + func TestUserUsageListAdvancedFilters(t *testing.T) { repo := &userUsageRepoCapture{} router := newUserUsageRequestTypeTestRouter(repo) @@ -189,6 +215,7 @@ func TestUserUsageListKeepsUserBillingAndIPWithoutAdminCostFields(t *testing.T) AccountID: 5, RequestID: "req_user_billing", Model: "gpt-5", + NativeCompactionV2: true, InputCost: 0.01, OutputCost: 0.02, CacheCreationCost: 0.03, @@ -219,6 +246,7 @@ func TestUserUsageListKeepsUserBillingAndIPWithoutAdminCostFields(t *testing.T) require.Contains(t, body, `"total_cost":0.1`) require.Contains(t, body, `"actual_cost":0.08`) require.Contains(t, body, `"rate_multiplier":0.8`) + require.Contains(t, body, `"native_compaction_v2":true`) require.Contains(t, body, `"ip_address":"203.0.113.10"`) require.NotContains(t, body, "upstream_endpoint") require.NotContains(t, body, "account_rate_multiplier") diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go index ad97026e2bfc..9d651d85a06e 100644 --- a/backend/internal/pkg/usagestats/usage_log_types.go +++ b/backend/internal/pkg/usagestats/usage_log_types.go @@ -186,12 +186,13 @@ type UserBreakdownDimension struct { Endpoint string // filter by endpoint value (non-empty to enable) EndpointType string // "inbound", "upstream", or "path" // Additional filter conditions - UserID int64 // filter by user_id (>0 to enable) - APIKeyID int64 // filter by api_key_id (>0 to enable) - AccountID int64 // filter by account_id (>0 to enable) - RequestType *int16 // filter by request_type (non-nil to enable) - Stream *bool // filter by stream flag (non-nil to enable) - BillingType *int8 // filter by billing_type (non-nil to enable) + UserID int64 // filter by user_id (>0 to enable) + APIKeyID int64 // filter by api_key_id (>0 to enable) + AccountID int64 // filter by account_id (>0 to enable) + RequestType *int16 // filter by request_type (non-nil to enable) + Stream *bool // filter by stream flag (non-nil to enable) + NativeCompactionV2 *bool // filter by native compaction v2 flag (non-nil to enable) + BillingType *int8 // filter by billing_type (non-nil to enable) // SortBy 指定排序列(空 = 默认按 actual_cost)。合法值由 repo 层 allowlist 校验。 SortBy string } @@ -278,6 +279,7 @@ type UsageLogFilters struct { ModelFilterSource string RequestType *int16 Stream *bool + NativeCompactionV2 *bool BillingType *int8 BillingMode string UpstreamModelMismatch *bool diff --git a/backend/internal/repository/migrations_schema_integration_test.go b/backend/internal/repository/migrations_schema_integration_test.go index e4af36ae253a..d90cc4f4b580 100644 --- a/backend/internal/repository/migrations_schema_integration_test.go +++ b/backend/internal/repository/migrations_schema_integration_test.go @@ -69,6 +69,8 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) { requireColumn(t, tx, "usage_logs", "billing_type", "smallint", 0, false) requireColumn(t, tx, "usage_logs", "request_type", "smallint", 0, false) requireColumn(t, tx, "usage_logs", "openai_ws_mode", "boolean", 0, false) + requireColumn(t, tx, "usage_logs", "native_compaction_v2", "boolean", 0, false) + requireColumnDefaultContains(t, tx, "usage_logs", "native_compaction_v2", "false") requireColumn(t, tx, "usage_logs", "image_input_size", "character varying", 32, true) requireColumn(t, tx, "usage_logs", "image_output_size", "character varying", 32, true) requireColumn(t, tx, "usage_logs", "image_size_source", "character varying", 16, true) diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index 94b605693daf..fe77d14a8fd3 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -199,6 +199,27 @@ func appendRequestTypeOrStreamQueryFilter(query string, args []any, requestType return query, args } +func appendNativeCompactionV2WhereCondition(conditions []string, args []any, nativeCompactionV2 *bool, alias string) ([]string, []any) { + if nativeCompactionV2 == nil { + return conditions, args + } + column := "native_compaction_v2" + if alias != "" { + column = alias + "." + column + } + conditions = append(conditions, fmt.Sprintf("%s = $%d", column, len(args)+1)) + args = append(args, *nativeCompactionV2) + return conditions, args +} + +func appendNativeCompactionV2QueryFilter(query string, args []any, nativeCompactionV2 *bool, alias string) (string, []any) { + conditions, args := appendNativeCompactionV2WhereCondition(nil, args, nativeCompactionV2, alias) + if len(conditions) == 0 { + return query, args + } + return query + " AND " + conditions[0], args +} + // buildRequestTypeFilterCondition 在 request_type 过滤时兼容 legacy 字段,避免历史数据漏查。 func buildRequestTypeFilterCondition(startArgIndex int, requestType int16) (string, []any) { return buildRequestTypeFilterConditionWithAlias(startArgIndex, requestType, "") diff --git a/backend/internal/repository/usage_log_repo_breakdown_test.go b/backend/internal/repository/usage_log_repo_breakdown_test.go index 69f50bbada7e..33a45e5c36ba 100644 --- a/backend/internal/repository/usage_log_repo_breakdown_test.go +++ b/backend/internal/repository/usage_log_repo_breakdown_test.go @@ -77,3 +77,26 @@ func TestGetUserBreakdownStatsRequestTypeIncludesLegacyFallback(t *testing.T) { require.Empty(t, rows) require.NoError(t, mock.ExpectationsWereMet()) } + +func TestGetUserBreakdownStatsFiltersNativeCompactionV2(t *testing.T) { + db, mock := newSQLMock(t) + repo := &usageLogRepository{sql: db} + start := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + end := start.Add(24 * time.Hour) + nativeCompactionV2 := true + + mock.ExpectQuery(regexp.QuoteMeta("AND ul.native_compaction_v2 = $3")). + WithArgs(start, end, true). + WillReturnRows(sqlmock.NewRows([]string{ + "user_id", "email", "requests", "input_tokens", "output_tokens", + "cache_tokens", "total_tokens", "cost", "actual_cost", "account_cost", + })) + + rows, err := repo.GetUserBreakdownStats(context.Background(), start, end, usagestats.UserBreakdownDimension{ + NativeCompactionV2: &nativeCompactionV2, + }, 0) + + require.NoError(t, err) + require.Empty(t, rows) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/backend/internal/repository/usage_log_repo_insert.go b/backend/internal/repository/usage_log_repo_insert.go index 67bf2937aec7..f70706d4819f 100644 --- a/backend/internal/repository/usage_log_repo_insert.go +++ b/backend/internal/repository/usage_log_repo_insert.go @@ -83,6 +83,7 @@ var usageLogInsertArgTypes = [...]string{ "text", // billing_mode "numeric", // account_stats_cost "text", // session_id + "boolean", // native_compaction_v2 "timestamptz", // created_at } @@ -282,6 +283,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, billing_mode, account_stats_cost, session_id, + native_compaction_v2, created_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, @@ -289,7 +291,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, - $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60 + $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61 ) ON CONFLICT (request_id, api_key_id) DO NOTHING RETURNING id, created_at @@ -740,6 +742,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage billing_mode, account_stats_cost, session_id, + native_compaction_v2, created_at ) AS (VALUES `) @@ -833,6 +836,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage billing_mode, account_stats_cost, session_id, + native_compaction_v2, created_at ) SELECT @@ -895,6 +899,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage billing_mode, account_stats_cost, session_id, + native_compaction_v2, created_at FROM input ON CONFLICT (request_id, api_key_id) DO NOTHING @@ -997,6 +1002,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( billing_mode, account_stats_cost, session_id, + native_compaction_v2, created_at ) AS (VALUES `) @@ -1085,6 +1091,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( billing_mode, account_stats_cost, session_id, + native_compaction_v2, created_at ) SELECT @@ -1147,6 +1154,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( billing_mode, account_stats_cost, session_id, + native_compaction_v2, created_at FROM input ON CONFLICT (request_id, api_key_id) DO NOTHING @@ -1217,6 +1225,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared billing_mode, account_stats_cost, session_id, + native_compaction_v2, created_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, @@ -1224,7 +1233,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, - $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60 + $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61 ) ON CONFLICT (request_id, api_key_id) DO NOTHING `, prepared.args...) @@ -1345,6 +1354,7 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { billingMode, log.AccountStatsCost, // account_stats_cost sessionID, // session_id + log.NativeCompactionV2, createdAt, }, } diff --git a/backend/internal/repository/usage_log_repo_query.go b/backend/internal/repository/usage_log_repo_query.go index 6ca471999ee4..556dd3ef8831 100644 --- a/backend/internal/repository/usage_log_repo_query.go +++ b/backend/internal/repository/usage_log_repo_query.go @@ -19,7 +19,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/service" ) -const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, upstream_response_model, upstream_model_mismatch, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, requested_reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, session_id, created_at" +const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, upstream_response_model, upstream_model_mismatch, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, requested_reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, session_id, native_compaction_v2, created_at" func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *service.UsageLog, err error) { query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1" @@ -122,6 +122,7 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat } conditions, args = appendUsageLogModelWhereCondition(conditions, args, filters.Model, filters.ModelFilterSource) conditions, args = appendRequestTypeOrStreamWhereCondition(conditions, args, filters.RequestType, filters.Stream) + conditions, args = appendNativeCompactionV2WhereCondition(conditions, args, filters.NativeCompactionV2, "") if filters.BillingType != nil { conditions = append(conditions, fmt.Sprintf("billing_type = $%d", len(args)+1)) args = append(args, int16(*filters.BillingType)) @@ -499,6 +500,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e billingMode sql.NullString accountStatsCost sql.NullFloat64 sessionID sql.NullString + nativeCompactionV2 bool createdAt time.Time ) @@ -563,6 +565,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e &billingMode, &accountStatsCost, &sessionID, + &nativeCompactionV2, &createdAt, ); err != nil { return nil, err @@ -595,6 +598,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e AccountRateMultiplier: nullFloat64Ptr(accountRateMultiplier), BillingType: int8(billingType), RequestType: service.RequestTypeFromInt16(requestTypeRaw), + NativeCompactionV2: nativeCompactionV2, ImageCount: imageCount, VideoCount: videoCount, CacheTTLOverridden: cacheTTLOverridden, diff --git a/backend/internal/repository/usage_log_repo_request_type_test.go b/backend/internal/repository/usage_log_repo_request_type_test.go index 997f20d0ab93..434bb7163f60 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -100,6 +100,7 @@ func TestUsageLogRepositoryCreateSyncRequestTypeAndLegacyFields(t *testing.T) { sqlmock.AnyArg(), // billing_mode sqlmock.AnyArg(), // account_stats_cost sqlmock.AnyArg(), // session_id + log.NativeCompactionV2, createdAt, ). WillReturnRows(sqlmock.NewRows([]string{"id", "created_at"}).AddRow(int64(99), createdAt)) @@ -193,6 +194,7 @@ func TestUsageLogRepositoryCreate_PersistsServiceTier(t *testing.T) { sqlmock.AnyArg(), // billing_mode sqlmock.AnyArg(), // account_stats_cost sqlmock.AnyArg(), // session_id + log.NativeCompactionV2, createdAt, ). WillReturnRows(sqlmock.NewRows([]string{"id", "created_at"}).AddRow(int64(100), createdAt)) @@ -258,6 +260,30 @@ func TestPrepareUsageLogInsert_ArgCountMatchesTypes(t *testing.T) { require.Len(t, prepared.args, len(usageLogInsertArgTypes)) } +func TestPrepareUsageLogInsert_PersistsNativeCompactionV2WithoutChangingRequestType(t *testing.T) { + log := &service.UsageLog{ + UserID: 1, + APIKeyID: 2, + AccountID: 3, + RequestID: "req-native-compaction-v2", + Model: "gpt-5", + RequestedModel: "gpt-5", + RequestType: service.RequestTypeStream, + NativeCompactionV2: true, + CreatedAt: time.Date(2025, 1, 5, 13, 0, 0, 0, time.UTC), + } + + prepared := prepareUsageLogInsert(log) + + require.Len(t, prepared.args, len(usageLogInsertArgTypes)) + require.Equal(t, "boolean", usageLogInsertArgTypes[len(usageLogInsertArgTypes)-2]) + require.Equal(t, true, prepared.args[len(prepared.args)-2]) + require.Equal(t, int16(service.RequestTypeStream), prepared.args[30]) + require.Equal(t, service.RequestTypeStream, log.RequestType) + require.True(t, log.Stream) + require.False(t, log.OpenAIWSMode) +} + func TestPrepareUsageLogInsert_PersistsImageSizeMetadata(t *testing.T) { imageSize := "4K" inputSize := "1024x1024" @@ -380,6 +406,26 @@ func TestUsageLogRepositoryListWithFiltersRequestTypePriority(t *testing.T) { require.NoError(t, mock.ExpectationsWereMet()) } +func TestUsageLogRepositoryListWithFiltersNativeCompactionV2(t *testing.T) { + db, mock := newSQLMock(t) + repo := &usageLogRepository{sql: db} + nativeCompactionV2 := true + filters := usagestats.UsageLogFilters{NativeCompactionV2: &nativeCompactionV2, ExactTotal: true} + + mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM usage_logs WHERE native_compaction_v2 = \\$1"). + WithArgs(true). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(0))) + mock.ExpectQuery("SELECT .* FROM usage_logs WHERE native_compaction_v2 = \\$1 ORDER BY id DESC LIMIT \\$2 OFFSET \\$3"). + WithArgs(true, 20, 0). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + + logs, page, err := repo.ListWithFilters(context.Background(), pagination.PaginationParams{Page: 1, PageSize: 20}, filters) + require.NoError(t, err) + require.Empty(t, logs) + require.NotNil(t, page) + require.NoError(t, mock.ExpectationsWereMet()) +} + func TestUsageLogRepositoryListWithFiltersRequestID(t *testing.T) { db, mock := newSQLMock(t) repo := &usageLogRepository{sql: db} @@ -457,6 +503,74 @@ func TestUsageLogRepositoryGetUsageTrendWithUsageFiltersRequestedModelSource(t * require.NoError(t, mock.ExpectationsWereMet()) } +func TestUsageLogRepositoryUsageAggregatesFilterNativeCompactionV2(t *testing.T) { + start := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + end := start.Add(24 * time.Hour) + nativeCompactionV2 := true + filters := usagestats.UsageLogFilters{NativeCompactionV2: &nativeCompactionV2} + + t.Run("stats", func(t *testing.T) { + db, mock := newSQLMock(t) + repo := &usageLogRepository{sql: db} + mock.ExpectQuery("(?s)FROM usage_logs\\s+WHERE native_compaction_v2 = \\$1.*GROUP BY GROUPING SETS"). + WithArgs(true). + WillReturnRows(sqlmock.NewRows([]string{ + "inbound_grouped", "upstream_grouped", "inbound_endpoint", "upstream_endpoint", + "requests", "input_tokens", "output_tokens", "cache_creation_tokens", "cache_read_tokens", + "cost", "actual_cost", "account_cost", "avg_duration_ms", + })) + + _, err := repo.GetStatsWithFilters(context.Background(), filters) + require.NoError(t, err) + require.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("trend bypasses preaggregate", func(t *testing.T) { + db, mock := newSQLMock(t) + repo := &usageLogRepository{sql: db} + mock.ExpectQuery("(?s)FROM usage_logs.*AND native_compaction_v2 = \\$3"). + WithArgs(start, end, true). + WillReturnRows(sqlmock.NewRows([]string{"date", "requests", "input_tokens", "output_tokens", "cache_creation_tokens", "cache_read_tokens", "total_tokens", "cost", "actual_cost"})) + + _, err := repo.GetUsageTrendWithUsageFilters(context.Background(), start, end, "day", filters) + require.NoError(t, err) + require.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("model", func(t *testing.T) { + db, mock := newSQLMock(t) + repo := &usageLogRepository{sql: db} + mock.ExpectQuery("(?s)FROM usage_logs.*AND native_compaction_v2 = \\$3"). + WithArgs(start, end, true). + WillReturnRows(sqlmock.NewRows([]string{ + "model", "requests", "input_tokens", "output_tokens", "cache_creation_tokens", + "cache_read_tokens", "total_tokens", "cost", "actual_cost", "account_cost", + })) + + _, err := repo.GetModelStatsWithUsageFiltersBySource(context.Background(), start, end, filters, usagestats.ModelSourceRequested) + require.NoError(t, err) + require.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("group", func(t *testing.T) { + db, mock := newSQLMock(t) + repo := &usageLogRepository{sql: db} + mock.ExpectQuery("(?s)FROM usage_logs ul.*AND ul.native_compaction_v2 = \\$3"). + WithArgs(start, end, true). + WillReturnRows(sqlmock.NewRows([]string{"group_id", "group_name", "requests", "total_tokens", "cost", "actual_cost", "account_cost"})) + + _, err := repo.GetGroupStatsWithUsageFilters(context.Background(), start, end, filters) + require.NoError(t, err) + require.NoError(t, mock.ExpectationsWereMet()) + }) +} + +func TestShouldUsePreaggregatedTrendRejectsNativeCompactionV2Filter(t *testing.T) { + nativeCompactionV2 := true + require.True(t, shouldUsePreaggregatedTrend("day", 0, 0, 0, 0, "", nil, nil, nil, "", nil, nil)) + require.False(t, shouldUsePreaggregatedTrend("day", 0, 0, 0, 0, "", nil, nil, nil, "", nil, &nativeCompactionV2)) +} + func TestUsageLogRepositoryGetModelStatsWithFiltersRequestTypePriority(t *testing.T) { db, mock := newSQLMock(t) repo := &usageLogRepository{sql: db} @@ -842,6 +956,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, sql.NullFloat64{}, sql.NullString{}, + false, // native_compaction_v2 now, }}) require.NoError(t, err) @@ -920,6 +1035,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, // billing_mode sql.NullFloat64{}, // account_stats_cost sql.NullString{}, // session_id + false, // native_compaction_v2 now, }}) require.NoError(t, err) @@ -981,6 +1097,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, // billing_mode sql.NullFloat64{}, // account_stats_cost sql.NullString{}, // session_id + true, // native_compaction_v2 now, }}) require.NoError(t, err) @@ -989,6 +1106,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { require.Equal(t, service.RequestTypeStream, log.RequestType) require.True(t, log.Stream) require.False(t, log.OpenAIWSMode) + require.True(t, log.NativeCompactionV2) }) t.Run("service_tier_is_scanned", func(t *testing.T) { @@ -1042,6 +1160,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, // billing_mode sql.NullFloat64{}, // account_stats_cost sql.NullString{}, // session_id + false, // native_compaction_v2 now, }}) require.NoError(t, err) diff --git a/backend/internal/repository/usage_log_repo_stats.go b/backend/internal/repository/usage_log_repo_stats.go index 9385900a7125..91e0eaf77bdc 100644 --- a/backend/internal/repository/usage_log_repo_stats.go +++ b/backend/internal/repository/usage_log_repo_stats.go @@ -677,6 +677,7 @@ func (r *usageLogRepository) GetStatsWithFilters(ctx context.Context, filters Us } conditions, args = appendUsageLogModelWhereCondition(conditions, args, filters.Model, filters.ModelFilterSource) conditions, args = appendRequestTypeOrStreamWhereCondition(conditions, args, filters.RequestType, filters.Stream) + conditions, args = appendNativeCompactionV2WhereCondition(conditions, args, filters.NativeCompactionV2, "") if filters.BillingType != nil { conditions = append(conditions, fmt.Sprintf("billing_type = $%d", len(args)+1)) args = append(args, int16(*filters.BillingType)) diff --git a/backend/internal/repository/usage_log_repo_trend.go b/backend/internal/repository/usage_log_repo_trend.go index 3ed3aa51b1b9..0b1bbe7d912d 100644 --- a/backend/internal/repository/usage_log_repo_trend.go +++ b/backend/internal/repository/usage_log_repo_trend.go @@ -265,20 +265,20 @@ func (r *usageLogRepository) GetUserUsageTrendByUserID(ctx context.Context, user // GetUserModelStats 获取指定用户的模型统计 func (r *usageLogRepository) GetUserModelStats(ctx context.Context, userID int64, startTime, endTime time.Time) (results []ModelStat, err error) { - return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, 0, 0, 0, "", nil, nil, nil, usagestats.ModelSourceRequested, "", nil) + return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, 0, 0, 0, "", nil, nil, nil, usagestats.ModelSourceRequested, "", nil, nil) } // GetUsageTrendWithFilters returns usage trend data with optional filters func (r *usageLogRepository) GetUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8) (results []TrendDataPoint, err error) { - return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "", nil) + return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, "", requestType, stream, billingType, "", nil, nil) } func (r *usageLogRepository) GetUsageTrendWithUsageFilters(ctx context.Context, startTime, endTime time.Time, granularity string, filters UsageLogFilters) (results []TrendDataPoint, err error) { - return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode, filters.UpstreamModelMismatch) + return r.getUsageTrendWithFilters(ctx, startTime, endTime, granularity, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.ModelFilterSource, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode, filters.UpstreamModelMismatch, filters.NativeCompactionV2) } -func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string, upstreamModelMismatch *bool) (results []TrendDataPoint, err error) { - if shouldUsePreaggregatedTrend(granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, billingMode, upstreamModelMismatch) { +func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, startTime, endTime time.Time, granularity string, userID, apiKeyID, accountID, groupID int64, model string, modelSource string, requestType *int16, stream *bool, billingType *int8, billingMode string, upstreamModelMismatch *bool, nativeCompactionV2 *bool) (results []TrendDataPoint, err error) { + if shouldUsePreaggregatedTrend(granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, billingMode, upstreamModelMismatch, nativeCompactionV2) { aggregated, aggregatedErr := r.getUsageTrendFromAggregates(ctx, startTime, endTime, granularity) if aggregatedErr == nil && len(aggregated) > 0 { return aggregated, nil @@ -321,6 +321,7 @@ func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, start } query, args = appendUsageLogModelQueryFilter(query, args, model, modelSource) query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream) + query, args = appendNativeCompactionV2QueryFilter(query, args, nativeCompactionV2, "") if billingType != nil { query += fmt.Sprintf(" AND billing_type = $%d", len(args)+1) args = append(args, int16(*billingType)) @@ -351,7 +352,7 @@ func (r *usageLogRepository) getUsageTrendWithFilters(ctx context.Context, start return results, nil } -func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string, upstreamModelMismatch *bool) bool { +func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string, upstreamModelMismatch *bool, nativeCompactionV2 *bool) bool { if granularity != "day" && granularity != "hour" { return false } @@ -364,7 +365,8 @@ func shouldUsePreaggregatedTrend(granularity string, userID, apiKeyID, accountID stream == nil && billingType == nil && billingMode == "" && - upstreamModelMismatch == nil + upstreamModelMismatch == nil && + nativeCompactionV2 == nil } func (r *usageLogRepository) getUsageTrendFromAggregates(ctx context.Context, startTime, endTime time.Time, granularity string) (results []TrendDataPoint, err error) { @@ -429,20 +431,20 @@ func (r *usageLogRepository) getUsageTrendFromAggregates(ctx context.Context, st // GetModelStatsWithFilters returns model statistics with optional filters func (r *usageLogRepository) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []ModelStat, err error) { - return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, usagestats.ModelSourceRequested, "", nil) + return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, usagestats.ModelSourceRequested, "", nil, nil) } // GetModelStatsWithFiltersBySource returns model statistics with optional filters and model source dimension. // source: requested | upstream | mapping. func (r *usageLogRepository) GetModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8, source string) (results []ModelStat, err error) { - return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, source, "", nil) + return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, source, "", nil, nil) } func (r *usageLogRepository) GetModelStatsWithUsageFiltersBySource(ctx context.Context, startTime, endTime time.Time, filters UsageLogFilters, source string) (results []ModelStat, err error) { - return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, source, filters.BillingMode, filters.UpstreamModelMismatch) + return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, source, filters.BillingMode, filters.UpstreamModelMismatch, filters.NativeCompactionV2) } -func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, source string, billingMode string, upstreamModelMismatch *bool) (results []ModelStat, err error) { +func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, source string, billingMode string, upstreamModelMismatch *bool, nativeCompactionV2 *bool) (results []ModelStat, err error) { actualCostExpr := "COALESCE(SUM(actual_cost), 0) as actual_cost" // 当仅按 account_id 聚合时,实际费用使用账号倍率(total_cost * account_rate_multiplier)。 if accountID > 0 && userID == 0 && apiKeyID == 0 { @@ -489,6 +491,7 @@ func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Contex args = append(args, model) } query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream) + query, args = appendNativeCompactionV2QueryFilter(query, args, nativeCompactionV2, "") if billingType != nil { query += fmt.Sprintf(" AND billing_type = $%d", len(args)+1) args = append(args, int16(*billingType)) @@ -521,14 +524,14 @@ func (r *usageLogRepository) getModelStatsWithFiltersBySource(ctx context.Contex // GetGroupStatsWithFilters returns group usage statistics with optional filters func (r *usageLogRepository) GetGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []usagestats.GroupStat, err error) { - return r.getGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, "", nil) + return r.getGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, "", requestType, stream, billingType, "", nil, nil) } func (r *usageLogRepository) GetGroupStatsWithUsageFilters(ctx context.Context, startTime, endTime time.Time, filters UsageLogFilters) (results []usagestats.GroupStat, err error) { - return r.getGroupStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode, filters.UpstreamModelMismatch) + return r.getGroupStatsWithFilters(ctx, startTime, endTime, filters.UserID, filters.APIKeyID, filters.AccountID, filters.GroupID, filters.Model, filters.RequestType, filters.Stream, filters.BillingType, filters.BillingMode, filters.UpstreamModelMismatch, filters.NativeCompactionV2) } -func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string, upstreamModelMismatch *bool) (results []usagestats.GroupStat, err error) { +func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, model string, requestType *int16, stream *bool, billingType *int8, billingMode string, upstreamModelMismatch *bool, nativeCompactionV2 *bool) (results []usagestats.GroupStat, err error) { query := ` SELECT COALESCE(ul.group_id, 0) as group_id, @@ -566,6 +569,7 @@ func (r *usageLogRepository) getGroupStatsWithFilters(ctx context.Context, start args = append(args, model) } query, args = appendRequestTypeOrStreamQueryFilter(query, args, requestType, stream) + query, args = appendNativeCompactionV2QueryFilter(query, args, nativeCompactionV2, "ul") if billingType != nil { query += fmt.Sprintf(" AND ul.billing_type = $%d", len(args)+1) args = append(args, int16(*billingType)) @@ -663,6 +667,7 @@ func (r *usageLogRepository) GetUserBreakdownStats(ctx context.Context, startTim query += fmt.Sprintf(" AND ul.stream = $%d", len(args)+1) args = append(args, *dim.Stream) } + query, args = appendNativeCompactionV2QueryFilter(query, args, dim.NativeCompactionV2, "ul") if dim.BillingType != nil { query += fmt.Sprintf(" AND ul.billing_type = $%d", len(args)+1) args = append(args, *dim.BillingType) diff --git a/backend/internal/repository/usage_log_session_id_unit_test.go b/backend/internal/repository/usage_log_session_id_unit_test.go index e4ff0ebe534c..296775bfe3bc 100644 --- a/backend/internal/repository/usage_log_session_id_unit_test.go +++ b/backend/internal/repository/usage_log_session_id_unit_test.go @@ -30,39 +30,39 @@ func newSessionIDUsageLog(sessionID *string) *service.UsageLog { // TestPrepareUsageLogInsert_SessionIDArgWiring pins the session_id column to the // arg slice / arg-type table so the five INSERT column lists stay in sync. session_id -// is the penultimate arg (created_at is always last). +// is immediately before native_compaction_v2; created_at is always last. func TestPrepareUsageLogInsert_SessionIDArgWiring(t *testing.T) { - require.Len(t, usageLogInsertArgTypes, 60, "arg-type table must include session_id") - sessionID := "sess-persisted-123" prepared := prepareUsageLogInsert(newSessionIDUsageLog(&sessionID)) require.Len(t, prepared.args, len(usageLogInsertArgTypes), "prepared args must match the arg-type table length") - // created_at is last; session_id is the arg immediately before it. - sessionArg := prepared.args[len(prepared.args)-2] + // created_at is last; native_compaction_v2 is penultimate; session_id precedes it. + sessionArg := prepared.args[len(prepared.args)-3] ns, ok := sessionArg.(sql.NullString) require.True(t, ok, "session_id arg should be a sql.NullString, got %T", sessionArg) require.True(t, ns.Valid) require.Equal(t, sessionID, ns.String) - require.Equal(t, "text", usageLogInsertArgTypes[len(usageLogInsertArgTypes)-2], + require.Equal(t, "text", usageLogInsertArgTypes[len(usageLogInsertArgTypes)-3], "session_id arg type must be text") + require.Equal(t, "boolean", usageLogInsertArgTypes[len(usageLogInsertArgTypes)-2], + "native_compaction_v2 arg type must be boolean") } // TestPrepareUsageLogInsert_SessionIDNullWhenAbsent proves an absent session id is // persisted as SQL NULL rather than an empty string. func TestPrepareUsageLogInsert_SessionIDNullWhenAbsent(t *testing.T) { prepared := prepareUsageLogInsert(newSessionIDUsageLog(nil)) - sessionArg := prepared.args[len(prepared.args)-2] + sessionArg := prepared.args[len(prepared.args)-3] ns, ok := sessionArg.(sql.NullString) require.True(t, ok, "session_id arg should be a sql.NullString, got %T", sessionArg) require.False(t, ns.Valid, "absent session id must be NULL, not empty string") empty := "" preparedEmpty := prepareUsageLogInsert(newSessionIDUsageLog(&empty)) - nsEmpty := preparedEmpty.args[len(preparedEmpty.args)-2].(sql.NullString) + nsEmpty := preparedEmpty.args[len(preparedEmpty.args)-3].(sql.NullString) require.False(t, nsEmpty.Valid, "empty session id must also be NULL") } diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index d1641490c4ee..007d5d65bb2a 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -590,6 +590,7 @@ func TestAPIContracts(t *testing.T) { "request_id": "req_123", "model": "claude-3", "request_type": "stream", + "native_compaction_v2": false, "openai_ws_mode": false, "group_id": null, "subscription_id": null, diff --git a/backend/internal/service/openai_compact_body_signal.go b/backend/internal/service/openai_compact_body_signal.go index c2aaf870f92a..e0ea88b62e97 100644 --- a/backend/internal/service/openai_compact_body_signal.go +++ b/backend/internal/service/openai_compact_body_signal.go @@ -70,6 +70,13 @@ func isOpenAINativeCompactionV2(c *gin.Context) bool { return c.GetBool(openAINativeCompactionV2Key) } +// IsOpenAINativeCompactionV2 reports whether the handler identified this +// request as the native remote compaction v2 wire. It exposes only the +// request-scoped boolean marker; no request payload is retained. +func IsOpenAINativeCompactionV2(c *gin.Context) bool { + return isOpenAINativeCompactionV2(c) +} + // ensureOpenAIRemoteCompactionV2BetaFeature 确保出站 x-codex-beta-features // 头包含 remote_compaction_v2。真实 Codex 发送 compaction_trigger 时总会同时 // 携带该协商头(codex-rs build_model_client_beta_features_header 对该 feature diff --git a/backend/internal/service/openai_compact_body_signal_test.go b/backend/internal/service/openai_compact_body_signal_test.go index 16587df0fdd6..b0e0e4e24c20 100644 --- a/backend/internal/service/openai_compact_body_signal_test.go +++ b/backend/internal/service/openai_compact_body_signal_test.go @@ -5,10 +5,20 @@ package service import ( "testing" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) +func TestIsOpenAINativeCompactionV2ReadsOnlyRequestMarker(t *testing.T) { + c, _ := gin.CreateTestContext(nil) + require.False(t, IsOpenAINativeCompactionV2(c)) + + MarkOpenAINativeCompactionV2(c) + require.True(t, IsOpenAINativeCompactionV2(c)) + require.False(t, IsOpenAINativeCompactionV2(nil)) +} + func TestHasCompactionTriggerInInput_DetectsCompactSignal(t *testing.T) { body := []byte(`{ "model":"gpt-5.5", diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index c3bca27176b5..5aeaea39dd79 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -2866,6 +2866,30 @@ func TestGatewayServiceCalculateRecordUsageCost_GroupImagePriceOverridesChannelI require.InDelta(t, 0.042, cost.ActualCost, 1e-12) } +func TestRecordUsageKeepsCompactionSemanticFlagOrthogonalToTransport(t *testing.T) { + logStub := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(logStub, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + + apiKey := &APIKey{ID: 20, UserID: 21, Group: &Group{RateMultiplier: 1}} + require.NoError(t, svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + NativeCompactionV2: true, + Result: &OpenAIForwardResult{ + Model: "gpt-5", + Stream: true, + Duration: time.Second, + Usage: OpenAIUsage{InputTokens: 100, OutputTokens: 10}, + }, + APIKey: apiKey, + User: &User{ID: 21}, + Account: &Account{ID: 22}, + })) + + require.NotNil(t, logStub.lastLog) + require.True(t, logStub.lastLog.Stream) + require.True(t, logStub.lastLog.NativeCompactionV2) + require.Equal(t, RequestTypeStream, RequestTypeFromLegacy(logStub.lastLog.Stream, logStub.lastLog.OpenAIWSMode)) +} + func TestRecordUsageMarksCyberRequestType(t *testing.T) { logStub := &openAIRecordUsageLogRepoStub{inserted: true} userStub := &openAIRecordUsageUserRepoStub{} @@ -2874,7 +2898,8 @@ func TestRecordUsageMarksCyberRequestType(t *testing.T) { svc := newOpenAIRecordUsageServiceForTest(logStub, userStub, subStub, rateStub) in := &OpenAIRecordUsageInput{ - CyberBlocked: true, + CyberBlocked: true, + NativeCompactionV2: true, Result: &OpenAIForwardResult{ Model: "gpt-5", Duration: time.Second, @@ -2887,6 +2912,7 @@ func TestRecordUsageMarksCyberRequestType(t *testing.T) { require.NoError(t, svc.RecordUsage(context.Background(), in)) require.NotNil(t, logStub.lastLog) require.Equal(t, RequestTypeCyberBlocked, logStub.lastLog.RequestType) + require.True(t, logStub.lastLog.NativeCompactionV2) require.Equal(t, 100, logStub.lastLog.InputTokens, "计费 token 不变(正常计费)") } diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index d6579b206855..5055c51a747d 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -39,6 +39,10 @@ type OpenAIRecordUsageInput struct { PricingAt time.Time // CyberBlocked 为 true 时把该用量行标记为 cyber(request_type=cyber),计费逻辑不变。 CyberBlocked bool + // NativeCompactionV2 is an orthogonal semantic flag captured by the + // Responses handler from stream=true + compaction_trigger. It never stores + // the request payload and does not replace the transport request type. + NativeCompactionV2 bool ChannelUsageFields } @@ -63,6 +67,7 @@ type CyberPolicyUsageInput struct { SessionID string RequestPayloadHash string APIKeyService APIKeyQuotaUpdater + NativeCompactionV2 bool ChannelUsageFields } @@ -100,6 +105,7 @@ func (s *OpenAIGatewayService) RecordCyberPolicyUsageLog(ctx context.Context, in APIKeyService: in.APIKeyService, ChannelUsageFields: in.ChannelUsageFields, CyberBlocked: true, + NativeCompactionV2: in.NativeCompactionV2, }); err != nil { logger.LegacyPrintf("service.openai_gateway", "cyber usage record failed: request_id=%s err=%v", in.RequestID, err) } @@ -347,6 +353,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec ImageOutputSize: optionalTrimmedStringPtr(result.ImageOutputSize), ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource), ImageSizeBreakdown: result.ImageSizeBreakdown, + NativeCompactionV2: input.NativeCompactionV2, } isVideoUsage := isGrokVideoUsageResult(result, billingModels) if isVideoUsage { diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go index 3808d1f467ab..e20e9d56ca6e 100644 --- a/backend/internal/service/usage_log.go +++ b/backend/internal/service/usage_log.go @@ -173,14 +173,15 @@ type UsageLog struct { // AccountStatsCost 账号统计定价预计算费用(nil = 使用默认公式 total_cost × account_rate_multiplier) AccountStatsCost *float64 - BillingType int8 - RequestType RequestType - Stream bool - OpenAIWSMode bool - DurationMs *int - FirstTokenMs *int - UserAgent *string - IPAddress *string + BillingType int8 + RequestType RequestType + Stream bool + OpenAIWSMode bool + NativeCompactionV2 bool + DurationMs *int + FirstTokenMs *int + UserAgent *string + IPAddress *string // SessionID is the explicit client-provided request correlation identifier // (e.g. the session_id / X-Session-Id headers). Nil when the client sent no // valid session header. It is never derived from prompt_cache_key or content. diff --git a/backend/migrations/231_add_usage_log_native_compaction_v2.sql b/backend/migrations/231_add_usage_log_native_compaction_v2.sql new file mode 100644 index 000000000000..1814c22e2d3b --- /dev/null +++ b/backend/migrations/231_add_usage_log_native_compaction_v2.sql @@ -0,0 +1,5 @@ +ALTER TABLE usage_logs + ADD COLUMN IF NOT EXISTS native_compaction_v2 BOOLEAN NOT NULL DEFAULT FALSE; + +COMMENT ON COLUMN usage_logs.native_compaction_v2 IS + 'True only when the request was identified at runtime as native OpenAI remote compaction v2'; diff --git a/frontend/src/api/admin/dashboard.ts b/frontend/src/api/admin/dashboard.ts index 5117039f6143..92d9861d4f9d 100644 --- a/frontend/src/api/admin/dashboard.ts +++ b/frontend/src/api/admin/dashboard.ts @@ -55,6 +55,7 @@ export interface TrendParams { group_id?: number request_type?: UsageRequestType stream?: boolean + native_compaction_v2?: boolean | null billing_type?: number | null upstream_model_mismatch?: boolean } @@ -87,6 +88,7 @@ export interface ModelStatsParams { group_id?: number request_type?: UsageRequestType stream?: boolean + native_compaction_v2?: boolean | null billing_type?: number | null upstream_model_mismatch?: boolean } @@ -116,6 +118,7 @@ export interface GroupStatsParams { group_id?: number request_type?: UsageRequestType stream?: boolean + native_compaction_v2?: boolean | null billing_type?: number | null upstream_model_mismatch?: boolean } @@ -178,6 +181,7 @@ export interface UserBreakdownParams { account_id?: number request_type?: UsageRequestType stream?: boolean + native_compaction_v2?: boolean | null billing_type?: number | null } diff --git a/frontend/src/api/admin/usage.ts b/frontend/src/api/admin/usage.ts index 1c740caabea1..f26e690fcb91 100644 --- a/frontend/src/api/admin/usage.ts +++ b/frontend/src/api/admin/usage.ts @@ -124,6 +124,7 @@ export async function getStats(params: { model?: string request_type?: UsageRequestType stream?: boolean + native_compaction_v2?: boolean | null upstream_model_mismatch?: boolean period?: string start_date?: string diff --git a/frontend/src/api/usage.ts b/frontend/src/api/usage.ts index 5e43157c65a2..8ac7a6ab3cc0 100644 --- a/frontend/src/api/usage.ts +++ b/frontend/src/api/usage.ts @@ -64,6 +64,7 @@ export interface TrendParams { group_id?: number request_type?: UsageRequestType stream?: boolean + native_compaction_v2?: boolean | null billing_type?: number | null billing_mode?: string | null timezone?: string @@ -285,6 +286,7 @@ export async function getDashboardModels(params?: { group_id?: number request_type?: UsageRequestType stream?: boolean + native_compaction_v2?: boolean | null billing_type?: number | null billing_mode?: string | null timezone?: string diff --git a/frontend/src/components/admin/usage/UsageFilters.vue b/frontend/src/components/admin/usage/UsageFilters.vue index 1d52eb947772..b772ee5f977b 100644 --- a/frontend/src/components/admin/usage/UsageFilters.vue +++ b/frontend/src/components/admin/usage/UsageFilters.vue @@ -127,6 +127,12 @@ + +