فهرست منبع

feat: summary span minute support (#256)

zijiren 6 ماه پیش
والد
کامیت
7ddec8667c

+ 0 - 8
core/common/consume/consume.go

@@ -35,8 +35,6 @@ func AsyncConsume(
 	downstreamResult bool,
 	user string,
 	metadata map[string]string,
-	channelRate model.RequestRate,
-	groupRate model.RequestRate,
 ) {
 	if !checkNeedRecordConsume(code, meta) {
 		return
@@ -65,8 +63,6 @@ func AsyncConsume(
 		downstreamResult,
 		user,
 		metadata,
-		channelRate,
-		groupRate,
 	)
 }
 
@@ -85,8 +81,6 @@ func Consume(
 	downstreamResult bool,
 	user string,
 	metadata map[string]string,
-	channelRate model.RequestRate,
-	groupRate model.RequestRate,
 ) {
 	if !checkNeedRecordConsume(code, meta) {
 		return
@@ -109,8 +103,6 @@ func Consume(
 		downstreamResult,
 		user,
 		metadata,
-		channelRate,
-		groupRate,
 	)
 	if err != nil {
 		log.Error("error batch record consume: " + err.Error())

+ 0 - 4
core/common/consume/record.go

@@ -21,8 +21,6 @@ func recordConsume(
 	downstreamResult bool,
 	user string,
 	metadata map[string]string,
-	channelModelRate model.RequestRate,
-	groupModelTokenRate model.RequestRate,
 ) error {
 	return model.BatchRecordLogs(
 		meta.RequestID,
@@ -47,7 +45,5 @@ func recordConsume(
 		amount,
 		user,
 		metadata,
-		channelModelRate,
-		groupModelTokenRate,
 	)
 }

+ 2 - 2
core/controller/dashboard.go

@@ -344,7 +344,7 @@ func GetTimeSeriesModelData(c *gin.Context) {
 	channelID, _ := strconv.Atoi(c.Query("channel"))
 	startTime, endTime := parseTimeRange(c)
 	timezoneLocation, _ := time.LoadLocation(c.DefaultQuery("timezone", "Local"))
-	models, err := model.GetTimeSeriesModelData(
+	models, err := model.GetTimeSeriesModelDataMinute(
 		channelID,
 		startTime,
 		endTime,
@@ -382,7 +382,7 @@ func GetGroupTimeSeriesModelData(c *gin.Context) {
 	tokenName := c.Query("token_name")
 	startTime, endTime := parseTimeRange(c)
 	timezoneLocation, _ := time.LoadLocation(c.DefaultQuery("timezone", "Local"))
-	models, err := model.GetGroupTimeSeriesModelData(
+	models, err := model.GetGroupTimeSeriesModelDataMinute(
 		group,
 		tokenName,
 		startTime,

+ 3 - 3
core/controller/group.go

@@ -51,7 +51,7 @@ func GetGroups(c *gin.Context) {
 	}
 	groupResponses := make([]*GroupResponse, len(groups))
 	for i, group := range groups {
-		lastRequestAt, _ := model.GetGroupLastRequestTime(group.ID)
+		lastRequestAt, _ := model.GetGroupLastRequestTimeMinute(group.ID)
 		groupResponses[i] = &GroupResponse{
 			Group:      group,
 			AccessedAt: lastRequestAt,
@@ -89,7 +89,7 @@ func SearchGroups(c *gin.Context) {
 	}
 	groupResponses := make([]*GroupResponse, len(groups))
 	for i, group := range groups {
-		lastRequestAt, _ := model.GetGroupLastRequestTime(group.ID)
+		lastRequestAt, _ := model.GetGroupLastRequestTimeMinute(group.ID)
 		groupResponses[i] = &GroupResponse{
 			Group:      group,
 			AccessedAt: lastRequestAt,
@@ -122,7 +122,7 @@ func GetGroup(c *gin.Context) {
 		middleware.ErrorResponse(c, http.StatusInternalServerError, err.Error())
 		return
 	}
-	lastRequestAt, _ := model.GetGroupLastRequestTime(group)
+	lastRequestAt, _ := model.GetGroupLastRequestTimeMinute(group)
 	groupResponse := &GroupResponse{
 		Group:      _group,
 		AccessedAt: lastRequestAt,

+ 0 - 2
core/controller/relay-controller.go

@@ -313,8 +313,6 @@ func recordResult(
 		downstreamResult,
 		user,
 		metadata,
-		monitorplugin.GetChannelModelRequestRate(c, meta),
-		middleware.GetGroupModelTokenRequestRate(c),
 	)
 }
 

+ 1 - 1
core/controller/token.go

@@ -89,7 +89,7 @@ func validateTokenUpdate(token AddTokenRequest) error {
 }
 
 func buildTokenResponse(token *model.Token) *TokenResponse {
-	lastRequestAt, _ := model.GetGroupTokenLastRequestTime(token.GroupID, string(token.Name))
+	lastRequestAt, _ := model.GetGroupTokenLastRequestTimeMinute(token.GroupID, string(token.Name))
 	return &TokenResponse{
 		Token:      token,
 		AccessedAt: lastRequestAt,

+ 72 - 24
core/docs/docs.go

@@ -8221,6 +8221,15 @@ const docTemplate = `{
                 "readme": {
                     "type": "string"
                 },
+                "readme_cn": {
+                    "type": "string"
+                },
+                "readme_cn_url": {
+                    "type": "string"
+                },
+                "readme_url": {
+                    "type": "string"
+                },
                 "tags": {
                     "type": "array",
                     "items": {
@@ -8318,9 +8327,18 @@ const docTemplate = `{
                 "created_at": {
                     "type": "string"
                 },
+                "description": {
+                    "type": "string"
+                },
+                "description_cn": {
+                    "type": "string"
+                },
                 "embed_config": {
                     "$ref": "#/definitions/model.MCPEmbeddingConfig"
                 },
+                "endpoints": {
+                    "$ref": "#/definitions/controller.MCPEndpoint"
+                },
                 "github_url": {
                     "type": "string"
                 },
@@ -8351,6 +8369,12 @@ const docTemplate = `{
                 "readme": {
                     "type": "string"
                 },
+                "readme_cn": {
+                    "type": "string"
+                },
+                "readme_cn_url": {
+                    "type": "string"
+                },
                 "readme_url": {
                     "type": "string"
                 },
@@ -8516,6 +8540,12 @@ const docTemplate = `{
                 "created_at": {
                     "type": "string"
                 },
+                "description": {
+                    "type": "string"
+                },
+                "description_cn": {
+                    "type": "string"
+                },
                 "embed_config": {
                     "$ref": "#/definitions/model.MCPEmbeddingConfig"
                 },
@@ -8546,6 +8576,12 @@ const docTemplate = `{
                 "readme": {
                     "type": "string"
                 },
+                "readme_cn": {
+                    "type": "string"
+                },
+                "readme_cn_url": {
+                    "type": "string"
+                },
                 "readme_url": {
                     "type": "string"
                 },
@@ -9188,15 +9224,9 @@ const docTemplate = `{
                 "max_rpm": {
                     "type": "integer"
                 },
-                "max_rps": {
-                    "type": "integer"
-                },
                 "max_tpm": {
                     "type": "integer"
                 },
-                "max_tps": {
-                    "type": "integer"
-                },
                 "output_tokens": {
                     "type": "integer"
                 },
@@ -9206,9 +9236,15 @@ const docTemplate = `{
                 "timestamp": {
                     "type": "integer"
                 },
+                "total_time_milliseconds": {
+                    "type": "integer"
+                },
                 "total_tokens": {
                     "type": "integer"
                 },
+                "total_ttfb_milliseconds": {
+                    "type": "integer"
+                },
                 "used_amount": {
                     "type": "number"
                 },
@@ -9264,15 +9300,9 @@ const docTemplate = `{
                 "max_rpm": {
                     "type": "integer"
                 },
-                "max_rps": {
-                    "type": "integer"
-                },
                 "max_tpm": {
                     "type": "integer"
                 },
-                "max_tps": {
-                    "type": "integer"
-                },
                 "models": {
                     "type": "array",
                     "items": {
@@ -9288,9 +9318,15 @@ const docTemplate = `{
                 "total_count": {
                     "type": "integer"
                 },
+                "total_time_milliseconds": {
+                    "type": "integer"
+                },
                 "total_tokens": {
                     "type": "integer"
                 },
+                "total_ttfb_milliseconds": {
+                    "type": "integer"
+                },
                 "tpm": {
                     "type": "integer"
                 },
@@ -9592,15 +9628,9 @@ const docTemplate = `{
                 "max_rpm": {
                     "type": "integer"
                 },
-                "max_rps": {
-                    "type": "integer"
-                },
                 "max_tpm": {
                     "type": "integer"
                 },
-                "max_tps": {
-                    "type": "integer"
-                },
                 "models": {
                     "type": "array",
                     "items": {
@@ -9622,9 +9652,15 @@ const docTemplate = `{
                 "total_count": {
                     "type": "integer"
                 },
+                "total_time_milliseconds": {
+                    "type": "integer"
+                },
                 "total_tokens": {
                     "type": "integer"
                 },
+                "total_ttfb_milliseconds": {
+                    "type": "integer"
+                },
                 "tpm": {
                     "type": "integer"
                 },
@@ -10139,15 +10175,9 @@ const docTemplate = `{
                 "max_rpm": {
                     "type": "integer"
                 },
-                "max_rps": {
-                    "type": "integer"
-                },
                 "max_tpm": {
                     "type": "integer"
                 },
-                "max_tps": {
-                    "type": "integer"
-                },
                 "model": {
                     "type": "string"
                 },
@@ -10160,9 +10190,15 @@ const docTemplate = `{
                 "timestamp": {
                     "type": "integer"
                 },
+                "total_time_milliseconds": {
+                    "type": "integer"
+                },
                 "total_tokens": {
                     "type": "integer"
                 },
+                "total_ttfb_milliseconds": {
+                    "type": "integer"
+                },
                 "used_amount": {
                     "type": "number"
                 },
@@ -10346,6 +10382,12 @@ const docTemplate = `{
                 "created_at": {
                     "type": "string"
                 },
+                "description": {
+                    "type": "string"
+                },
+                "description_cn": {
+                    "type": "string"
+                },
                 "embed_config": {
                     "$ref": "#/definitions/model.MCPEmbeddingConfig"
                 },
@@ -10373,6 +10415,12 @@ const docTemplate = `{
                 "readme": {
                     "type": "string"
                 },
+                "readme_cn": {
+                    "type": "string"
+                },
+                "readme_cn_url": {
+                    "type": "string"
+                },
                 "readme_url": {
                     "type": "string"
                 },

+ 72 - 24
core/docs/swagger.json

@@ -8212,6 +8212,15 @@
                 "readme": {
                     "type": "string"
                 },
+                "readme_cn": {
+                    "type": "string"
+                },
+                "readme_cn_url": {
+                    "type": "string"
+                },
+                "readme_url": {
+                    "type": "string"
+                },
                 "tags": {
                     "type": "array",
                     "items": {
@@ -8309,9 +8318,18 @@
                 "created_at": {
                     "type": "string"
                 },
+                "description": {
+                    "type": "string"
+                },
+                "description_cn": {
+                    "type": "string"
+                },
                 "embed_config": {
                     "$ref": "#/definitions/model.MCPEmbeddingConfig"
                 },
+                "endpoints": {
+                    "$ref": "#/definitions/controller.MCPEndpoint"
+                },
                 "github_url": {
                     "type": "string"
                 },
@@ -8342,6 +8360,12 @@
                 "readme": {
                     "type": "string"
                 },
+                "readme_cn": {
+                    "type": "string"
+                },
+                "readme_cn_url": {
+                    "type": "string"
+                },
                 "readme_url": {
                     "type": "string"
                 },
@@ -8507,6 +8531,12 @@
                 "created_at": {
                     "type": "string"
                 },
+                "description": {
+                    "type": "string"
+                },
+                "description_cn": {
+                    "type": "string"
+                },
                 "embed_config": {
                     "$ref": "#/definitions/model.MCPEmbeddingConfig"
                 },
@@ -8537,6 +8567,12 @@
                 "readme": {
                     "type": "string"
                 },
+                "readme_cn": {
+                    "type": "string"
+                },
+                "readme_cn_url": {
+                    "type": "string"
+                },
                 "readme_url": {
                     "type": "string"
                 },
@@ -9179,15 +9215,9 @@
                 "max_rpm": {
                     "type": "integer"
                 },
-                "max_rps": {
-                    "type": "integer"
-                },
                 "max_tpm": {
                     "type": "integer"
                 },
-                "max_tps": {
-                    "type": "integer"
-                },
                 "output_tokens": {
                     "type": "integer"
                 },
@@ -9197,9 +9227,15 @@
                 "timestamp": {
                     "type": "integer"
                 },
+                "total_time_milliseconds": {
+                    "type": "integer"
+                },
                 "total_tokens": {
                     "type": "integer"
                 },
+                "total_ttfb_milliseconds": {
+                    "type": "integer"
+                },
                 "used_amount": {
                     "type": "number"
                 },
@@ -9255,15 +9291,9 @@
                 "max_rpm": {
                     "type": "integer"
                 },
-                "max_rps": {
-                    "type": "integer"
-                },
                 "max_tpm": {
                     "type": "integer"
                 },
-                "max_tps": {
-                    "type": "integer"
-                },
                 "models": {
                     "type": "array",
                     "items": {
@@ -9279,9 +9309,15 @@
                 "total_count": {
                     "type": "integer"
                 },
+                "total_time_milliseconds": {
+                    "type": "integer"
+                },
                 "total_tokens": {
                     "type": "integer"
                 },
+                "total_ttfb_milliseconds": {
+                    "type": "integer"
+                },
                 "tpm": {
                     "type": "integer"
                 },
@@ -9583,15 +9619,9 @@
                 "max_rpm": {
                     "type": "integer"
                 },
-                "max_rps": {
-                    "type": "integer"
-                },
                 "max_tpm": {
                     "type": "integer"
                 },
-                "max_tps": {
-                    "type": "integer"
-                },
                 "models": {
                     "type": "array",
                     "items": {
@@ -9613,9 +9643,15 @@
                 "total_count": {
                     "type": "integer"
                 },
+                "total_time_milliseconds": {
+                    "type": "integer"
+                },
                 "total_tokens": {
                     "type": "integer"
                 },
+                "total_ttfb_milliseconds": {
+                    "type": "integer"
+                },
                 "tpm": {
                     "type": "integer"
                 },
@@ -10130,15 +10166,9 @@
                 "max_rpm": {
                     "type": "integer"
                 },
-                "max_rps": {
-                    "type": "integer"
-                },
                 "max_tpm": {
                     "type": "integer"
                 },
-                "max_tps": {
-                    "type": "integer"
-                },
                 "model": {
                     "type": "string"
                 },
@@ -10151,9 +10181,15 @@
                 "timestamp": {
                     "type": "integer"
                 },
+                "total_time_milliseconds": {
+                    "type": "integer"
+                },
                 "total_tokens": {
                     "type": "integer"
                 },
+                "total_ttfb_milliseconds": {
+                    "type": "integer"
+                },
                 "used_amount": {
                     "type": "number"
                 },
@@ -10337,6 +10373,12 @@
                 "created_at": {
                     "type": "string"
                 },
+                "description": {
+                    "type": "string"
+                },
+                "description_cn": {
+                    "type": "string"
+                },
                 "embed_config": {
                     "$ref": "#/definitions/model.MCPEmbeddingConfig"
                 },
@@ -10364,6 +10406,12 @@
                 "readme": {
                     "type": "string"
                 },
+                "readme_cn": {
+                    "type": "string"
+                },
+                "readme_cn_url": {
+                    "type": "string"
+                },
                 "readme_url": {
                     "type": "string"
                 },

+ 48 - 16
core/docs/swagger.yaml

@@ -169,6 +169,12 @@ definitions:
         type: string
       readme:
         type: string
+      readme_cn:
+        type: string
+      readme_cn_url:
+        type: string
+      readme_url:
+        type: string
       tags:
         items:
           type: string
@@ -232,8 +238,14 @@ definitions:
     properties:
       created_at:
         type: string
+      description:
+        type: string
+      description_cn:
+        type: string
       embed_config:
         $ref: '#/definitions/model.MCPEmbeddingConfig'
+      endpoints:
+        $ref: '#/definitions/controller.MCPEndpoint'
       github_url:
         type: string
       id:
@@ -254,6 +266,10 @@ definitions:
         $ref: '#/definitions/model.PublicMCPProxyConfig'
       readme:
         type: string
+      readme_cn:
+        type: string
+      readme_cn_url:
+        type: string
       readme_url:
         type: string
       reusing:
@@ -362,6 +378,10 @@ definitions:
     properties:
       created_at:
         type: string
+      description:
+        type: string
+      description_cn:
+        type: string
       embed_config:
         $ref: '#/definitions/model.MCPEmbeddingConfig'
       endpoints:
@@ -382,6 +402,10 @@ definitions:
         $ref: '#/definitions/model.PublicMCPProxyConfig'
       readme:
         type: string
+      readme_cn:
+        type: string
+      readme_cn_url:
+        type: string
       readme_url:
         type: string
       status:
@@ -837,20 +861,20 @@ definitions:
         type: integer
       max_rpm:
         type: integer
-      max_rps:
-        type: integer
       max_tpm:
         type: integer
-      max_tps:
-        type: integer
       output_tokens:
         type: integer
       request_count:
         type: integer
       timestamp:
         type: integer
+      total_time_milliseconds:
+        type: integer
       total_tokens:
         type: integer
+      total_ttfb_milliseconds:
+        type: integer
       used_amount:
         type: number
       web_search_count:
@@ -887,12 +911,8 @@ definitions:
         type: integer
       max_rpm:
         type: integer
-      max_rps:
-        type: integer
       max_tpm:
         type: integer
-      max_tps:
-        type: integer
       models:
         items:
           type: string
@@ -903,8 +923,12 @@ definitions:
         type: integer
       total_count:
         type: integer
+      total_time_milliseconds:
+        type: integer
       total_tokens:
         type: integer
+      total_ttfb_milliseconds:
+        type: integer
       tpm:
         type: integer
       used_amount:
@@ -1108,12 +1132,8 @@ definitions:
         type: integer
       max_rpm:
         type: integer
-      max_rps:
-        type: integer
       max_tpm:
         type: integer
-      max_tps:
-        type: integer
       models:
         items:
           type: string
@@ -1128,8 +1148,12 @@ definitions:
         type: array
       total_count:
         type: integer
+      total_time_milliseconds:
+        type: integer
       total_tokens:
         type: integer
+      total_ttfb_milliseconds:
+        type: integer
       tpm:
         type: integer
       used_amount:
@@ -1475,12 +1499,8 @@ definitions:
         type: integer
       max_rpm:
         type: integer
-      max_rps:
-        type: integer
       max_tpm:
         type: integer
-      max_tps:
-        type: integer
       model:
         type: string
       output_tokens:
@@ -1489,8 +1509,12 @@ definitions:
         type: integer
       timestamp:
         type: integer
+      total_time_milliseconds:
+        type: integer
       total_tokens:
         type: integer
+      total_ttfb_milliseconds:
+        type: integer
       used_amount:
         type: number
       web_search_count:
@@ -1635,6 +1659,10 @@ definitions:
     properties:
       created_at:
         type: string
+      description:
+        type: string
+      description_cn:
+        type: string
       embed_config:
         $ref: '#/definitions/model.MCPEmbeddingConfig'
       github_url:
@@ -1653,6 +1681,10 @@ definitions:
         $ref: '#/definitions/model.PublicMCPProxyConfig'
       readme:
         type: string
+      readme_cn:
+        type: string
+      readme_cn_url:
+        type: string
       readme_url:
         type: string
       status:

+ 0 - 11
core/middleware/distributor.go

@@ -421,8 +421,6 @@ func distribute(c *gin.Context, mode mode.Mode) {
 			true,
 			user,
 			metadata,
-			model.RequestRate{},
-			GetGroupModelTokenRequestRate(c),
 		)
 		AbortLogWithMessage(c, http.StatusTooManyRequests, errMsg, "request_rate_limit_exceeded")
 		return
@@ -431,15 +429,6 @@ func distribute(c *gin.Context, mode mode.Mode) {
 	c.Next()
 }
 
-func GetGroupModelTokenRequestRate(c *gin.Context) model.RequestRate {
-	return model.RequestRate{
-		RPM: monitorplugin.GetGroupModelTokenRPM(c),
-		RPS: monitorplugin.GetGroupModelTokenRPS(c),
-		TPM: monitorplugin.GetGroupModelTokenTPM(c),
-		TPS: monitorplugin.GetGroupModelTokenTPS(c),
-	}
-}
-
 func GetRequestModel(c *gin.Context) string {
 	return c.GetString(RequestModel)
 }

+ 245 - 49
core/model/batch.go

@@ -13,11 +13,13 @@ import (
 )
 
 type batchUpdateData struct {
-	Groups         map[string]*GroupUpdate
-	Tokens         map[int]*TokenUpdate
-	Channels       map[int]*ChannelUpdate
-	Summaries      map[string]*SummaryUpdate
-	GroupSummaries map[string]*GroupSummaryUpdate
+	Groups               map[string]*GroupUpdate
+	Tokens               map[int]*TokenUpdate
+	Channels             map[int]*ChannelUpdate
+	Summaries            map[string]*SummaryUpdate
+	GroupSummaries       map[string]*GroupSummaryUpdate
+	SummariesMinute      map[string]*SummaryMinuteUpdate
+	GroupSummariesMinute map[string]*GroupSummaryMinuteUpdate
 	sync.Mutex
 }
 
@@ -33,7 +35,9 @@ func (b *batchUpdateData) isCleanLocked() bool {
 		len(b.Tokens) == 0 &&
 		len(b.Channels) == 0 &&
 		len(b.Summaries) == 0 &&
-		len(b.GroupSummaries) == 0
+		len(b.GroupSummaries) == 0 &&
+		len(b.SummariesMinute) == 0 &&
+		len(b.GroupSummariesMinute) == 0
 }
 
 type GroupUpdate struct {
@@ -56,15 +60,29 @@ type SummaryUpdate struct {
 	SummaryData
 }
 
+type SummaryMinuteUpdate struct {
+	SummaryMinuteUnique
+	SummaryData
+}
+
 func summaryUniqueKey(unique SummaryUnique) string {
 	return fmt.Sprintf("%d:%s:%d", unique.ChannelID, unique.Model, unique.HourTimestamp)
 }
 
+func summaryMinuteUniqueKey(unique SummaryMinuteUnique) string {
+	return fmt.Sprintf("%d:%s:%d", unique.ChannelID, unique.Model, unique.MinuteTimestamp)
+}
+
 type GroupSummaryUpdate struct {
 	GroupSummaryUnique
 	SummaryData
 }
 
+type GroupSummaryMinuteUpdate struct {
+	GroupSummaryMinuteUnique
+	SummaryData
+}
+
 func groupSummaryUniqueKey(unique GroupSummaryUnique) string {
 	return fmt.Sprintf(
 		"%s:%s:%s:%d",
@@ -75,15 +93,27 @@ func groupSummaryUniqueKey(unique GroupSummaryUnique) string {
 	)
 }
 
+func groupSummaryMinuteUniqueKey(unique GroupSummaryMinuteUnique) string {
+	return fmt.Sprintf(
+		"%s:%s:%s:%d",
+		unique.GroupID,
+		unique.TokenName,
+		unique.Model,
+		unique.MinuteTimestamp,
+	)
+}
+
 var batchData batchUpdateData
 
 func init() {
 	batchData = batchUpdateData{
-		Groups:         make(map[string]*GroupUpdate),
-		Tokens:         make(map[int]*TokenUpdate),
-		Channels:       make(map[int]*ChannelUpdate),
-		Summaries:      make(map[string]*SummaryUpdate),
-		GroupSummaries: make(map[string]*GroupSummaryUpdate),
+		Groups:               make(map[string]*GroupUpdate),
+		Tokens:               make(map[int]*TokenUpdate),
+		Channels:             make(map[int]*ChannelUpdate),
+		Summaries:            make(map[string]*SummaryUpdate),
+		GroupSummaries:       make(map[string]*GroupSummaryUpdate),
+		SummariesMinute:      make(map[string]*SummaryMinuteUpdate),
+		GroupSummariesMinute: make(map[string]*GroupSummaryMinuteUpdate),
 	}
 }
 
@@ -141,6 +171,12 @@ func ProcessBatchUpdatesSummary() {
 	wg.Add(1)
 	go processSummaryUpdates(&wg)
 
+	wg.Add(1)
+	go processSummaryMinuteUpdates(&wg)
+
+	wg.Add(1)
+	go processGroupSummaryMinuteUpdates(&wg)
+
 	wg.Wait()
 }
 
@@ -216,6 +252,23 @@ func processGroupSummaryUpdates(wg *sync.WaitGroup) {
 	}
 }
 
+func processGroupSummaryMinuteUpdates(wg *sync.WaitGroup) {
+	defer wg.Done()
+	for key, data := range batchData.GroupSummariesMinute {
+		err := UpsertGroupSummaryMinute(data.GroupSummaryMinuteUnique, data.SummaryData)
+		if err != nil {
+			notify.ErrorThrottle(
+				"batchUpdateGroupSummary",
+				time.Minute,
+				"failed to batch update group summary",
+				err.Error(),
+			)
+		} else {
+			delete(batchData.GroupSummariesMinute, key)
+		}
+	}
+}
+
 func processSummaryUpdates(wg *sync.WaitGroup) {
 	defer wg.Done()
 	for key, data := range batchData.Summaries {
@@ -233,11 +286,21 @@ func processSummaryUpdates(wg *sync.WaitGroup) {
 	}
 }
 
-type RequestRate struct {
-	RPM int64
-	RPS int64
-	TPM int64
-	TPS int64
+func processSummaryMinuteUpdates(wg *sync.WaitGroup) {
+	defer wg.Done()
+	for key, data := range batchData.SummariesMinute {
+		err := UpsertSummaryMinute(data.SummaryMinuteUnique, data.SummaryData)
+		if err != nil {
+			notify.ErrorThrottle(
+				"batchUpdateSummaryMinute",
+				time.Minute,
+				"failed to batch update summary minute",
+				err.Error(),
+			)
+		} else {
+			delete(batchData.SummariesMinute, key)
+		}
+	}
 }
 
 func BatchRecordLogs(
@@ -263,8 +326,6 @@ func BatchRecordLogs(
 	amount float64,
 	user string,
 	metadata map[string]string,
-	channelModelRate RequestRate,
-	groupModelTokenRate RequestRate,
 ) (err error) {
 	now := time.Now()
 
@@ -329,19 +390,54 @@ func BatchRecordLogs(
 	updateTokenData(tokenID, amount, amountDecimal)
 
 	if channelID != 0 {
-		updateSummaryData(channelID, modelName, now, code, amountDecimal, usage, channelModelRate)
-	}
-
-	updateGroupSummaryData(
-		group,
-		tokenName,
-		modelName,
-		now,
-		code,
-		amountDecimal,
-		usage,
-		groupModelTokenRate,
-	)
+		updateSummaryData(
+			channelID,
+			modelName,
+			now,
+			requestAt,
+			firstByteAt,
+			code,
+			amountDecimal,
+			usage,
+		)
+
+		updateSummaryDataMinute(
+			channelID,
+			modelName,
+			now,
+			requestAt,
+			firstByteAt,
+			code,
+			amountDecimal,
+			usage,
+		)
+	}
+
+	if group != "" {
+		updateGroupSummaryData(
+			group,
+			tokenName,
+			modelName,
+			now,
+			requestAt,
+			firstByteAt,
+			code,
+			amountDecimal,
+			usage,
+		)
+
+		updateGroupSummaryDataMinute(
+			group,
+			tokenName,
+			modelName,
+			now,
+			requestAt,
+			firstByteAt,
+			code,
+			amountDecimal,
+			usage,
+		)
+	}
 
 	return err
 }
@@ -391,11 +487,22 @@ func updateTokenData(tokenID int, amount float64, amountDecimal decimal.Decimal)
 func updateGroupSummaryData(
 	group, tokenName, modelName string,
 	createAt time.Time,
+	requestAt time.Time,
+	firstByteAt time.Time,
 	code int,
 	amountDecimal decimal.Decimal,
 	usage Usage,
-	groupModelTokenRate RequestRate,
 ) {
+	if createAt.IsZero() {
+		createAt = time.Now()
+	}
+	if requestAt.IsZero() {
+		requestAt = createAt
+	}
+	if firstByteAt.IsZero() || firstByteAt.Before(requestAt) {
+		firstByteAt = requestAt
+	}
+
 	groupUnique := GroupSummaryUnique{
 		GroupID:       group,
 		TokenName:     tokenName,
@@ -417,19 +524,58 @@ func updateGroupSummaryData(
 		Add(decimal.NewFromFloat(groupSummary.UsedAmount)).
 		InexactFloat64()
 
-	if groupModelTokenRate.RPM > groupSummary.MaxRPM {
-		groupSummary.MaxRPM = groupModelTokenRate.RPM
+	groupSummary.TotalTimeMilliseconds += createAt.Sub(requestAt).Milliseconds()
+	groupSummary.TotalTTFBMilliseconds += firstByteAt.Sub(requestAt).Milliseconds()
+
+	groupSummary.Usage.Add(usage)
+	if code != http.StatusOK {
+		groupSummary.ExceptionCount++
+	}
+}
+
+func updateGroupSummaryDataMinute(
+	group, tokenName, modelName string,
+	createAt time.Time,
+	requestAt time.Time,
+	firstByteAt time.Time,
+	code int,
+	amountDecimal decimal.Decimal,
+	usage Usage,
+) {
+	if createAt.IsZero() {
+		createAt = time.Now()
 	}
-	if groupModelTokenRate.RPS > groupSummary.MaxRPS {
-		groupSummary.MaxRPS = groupModelTokenRate.RPS
+	if requestAt.IsZero() {
+		requestAt = createAt
 	}
-	if groupModelTokenRate.TPM > groupSummary.MaxTPM {
-		groupSummary.MaxTPM = groupModelTokenRate.TPM
+	if firstByteAt.IsZero() || firstByteAt.Before(requestAt) {
+		firstByteAt = requestAt
 	}
-	if groupModelTokenRate.TPS > groupSummary.MaxTPS {
-		groupSummary.MaxTPS = groupModelTokenRate.TPS
+
+	groupUnique := GroupSummaryMinuteUnique{
+		GroupID:         group,
+		TokenName:       tokenName,
+		Model:           modelName,
+		MinuteTimestamp: createAt.Truncate(time.Minute).Unix(),
+	}
+
+	groupSummaryKey := groupSummaryMinuteUniqueKey(groupUnique)
+	groupSummary, ok := batchData.GroupSummariesMinute[groupSummaryKey]
+	if !ok {
+		groupSummary = &GroupSummaryMinuteUpdate{
+			GroupSummaryMinuteUnique: groupUnique,
+		}
+		batchData.GroupSummariesMinute[groupSummaryKey] = groupSummary
 	}
 
+	groupSummary.RequestCount++
+	groupSummary.UsedAmount = amountDecimal.
+		Add(decimal.NewFromFloat(groupSummary.UsedAmount)).
+		InexactFloat64()
+
+	groupSummary.TotalTimeMilliseconds += createAt.Sub(requestAt).Milliseconds()
+	groupSummary.TotalTTFBMilliseconds += firstByteAt.Sub(requestAt).Milliseconds()
+
 	groupSummary.Usage.Add(usage)
 	if code != http.StatusOK {
 		groupSummary.ExceptionCount++
@@ -440,11 +586,22 @@ func updateSummaryData(
 	channelID int,
 	modelName string,
 	createAt time.Time,
+	requestAt time.Time,
+	firstByteAt time.Time,
 	code int,
 	amountDecimal decimal.Decimal,
 	usage Usage,
-	channelModelRate RequestRate,
 ) {
+	if createAt.IsZero() {
+		createAt = time.Now()
+	}
+	if requestAt.IsZero() {
+		requestAt = createAt
+	}
+	if firstByteAt.IsZero() || firstByteAt.Before(requestAt) {
+		firstByteAt = requestAt
+	}
+
 	summaryUnique := SummaryUnique{
 		ChannelID:     channelID,
 		Model:         modelName,
@@ -465,19 +622,58 @@ func updateSummaryData(
 		Add(decimal.NewFromFloat(summary.UsedAmount)).
 		InexactFloat64()
 
-	if channelModelRate.RPM > summary.MaxRPM {
-		summary.MaxRPM = channelModelRate.RPM
+	summary.TotalTimeMilliseconds += createAt.Sub(requestAt).Milliseconds()
+	summary.TotalTTFBMilliseconds += firstByteAt.Sub(requestAt).Milliseconds()
+
+	summary.Usage.Add(usage)
+	if code != http.StatusOK {
+		summary.ExceptionCount++
 	}
-	if channelModelRate.RPS > summary.MaxRPS {
-		summary.MaxRPS = channelModelRate.RPS
+}
+
+func updateSummaryDataMinute(
+	channelID int,
+	modelName string,
+	createAt time.Time,
+	requestAt time.Time,
+	firstByteAt time.Time,
+	code int,
+	amountDecimal decimal.Decimal,
+	usage Usage,
+) {
+	if createAt.IsZero() {
+		createAt = time.Now()
 	}
-	if channelModelRate.TPM > summary.MaxTPM {
-		summary.MaxTPM = channelModelRate.TPM
+	if requestAt.IsZero() {
+		requestAt = createAt
 	}
-	if channelModelRate.TPS > summary.MaxTPS {
-		summary.MaxTPS = channelModelRate.TPS
+	if firstByteAt.IsZero() || firstByteAt.Before(requestAt) {
+		firstByteAt = requestAt
+	}
+
+	summaryUnique := SummaryMinuteUnique{
+		ChannelID:       channelID,
+		Model:           modelName,
+		MinuteTimestamp: createAt.Truncate(time.Minute).Unix(),
+	}
+
+	summaryKey := summaryMinuteUniqueKey(summaryUnique)
+	summary, ok := batchData.SummariesMinute[summaryKey]
+	if !ok {
+		summary = &SummaryMinuteUpdate{
+			SummaryMinuteUnique: summaryUnique,
+		}
+		batchData.SummariesMinute[summaryKey] = summary
 	}
 
+	summary.RequestCount++
+	summary.UsedAmount = amountDecimal.
+		Add(decimal.NewFromFloat(summary.UsedAmount)).
+		InexactFloat64()
+
+	summary.TotalTimeMilliseconds += createAt.Sub(requestAt).Milliseconds()
+	summary.TotalTTFBMilliseconds += firstByteAt.Sub(requestAt).Milliseconds()
+
 	summary.Usage.Add(usage)
 	if code != http.StatusOK {
 		summary.ExceptionCount++

+ 135 - 0
core/model/groupsummary-minute.go

@@ -0,0 +1,135 @@
+package model
+
+import (
+	"errors"
+	"time"
+
+	"gorm.io/gorm"
+	"gorm.io/gorm/clause"
+)
+
+// only summary result only requests
+type GroupSummaryMinute struct {
+	ID     int                      `gorm:"primaryKey"`
+	Unique GroupSummaryMinuteUnique `gorm:"embedded"`
+	Data   SummaryData              `gorm:"embedded"`
+}
+
+type GroupSummaryMinuteUnique struct {
+	GroupID         string `gorm:"not null;uniqueIndex:idx_groupsummary_minute_unique,priority:1"`
+	TokenName       string `gorm:"not null;uniqueIndex:idx_groupsummary_minute_unique,priority:2"`
+	Model           string `gorm:"not null;uniqueIndex:idx_groupsummary_minute_unique,priority:3"`
+	MinuteTimestamp int64  `gorm:"not null;uniqueIndex:idx_groupsummary_minute_unique,priority:4,sort:desc"`
+}
+
+func (l *GroupSummaryMinute) BeforeCreate(_ *gorm.DB) (err error) {
+	if l.Unique.Model == "" {
+		return errors.New("model is required")
+	}
+	if l.Unique.MinuteTimestamp == 0 {
+		return errors.New("minute timestamp is required")
+	}
+	if err := validateMinuteTimestamp(l.Unique.MinuteTimestamp); err != nil {
+		return err
+	}
+	return
+}
+
+func CreateGroupSummaryMinuteIndexs(db *gorm.DB) error {
+	indexes := []string{
+		"CREATE INDEX IF NOT EXISTS idx_groupsummary_minute_group_minute ON group_summary_minutes (group_id, minute_timestamp DESC)",
+		"CREATE INDEX IF NOT EXISTS idx_groupsummary_minute_group_token_minute ON group_summary_minutes (group_id, token_name, minute_timestamp DESC)",
+		"CREATE INDEX IF NOT EXISTS idx_groupsummary_minute_group_model_minute ON group_summary_minutes (group_id, model, minute_timestamp DESC)",
+	}
+
+	for _, index := range indexes {
+		if err := db.Exec(index).Error; err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func UpsertGroupSummaryMinute(unique GroupSummaryMinuteUnique, data SummaryData) error {
+	err := validateMinuteTimestamp(unique.MinuteTimestamp)
+	if err != nil {
+		return err
+	}
+
+	for range 3 {
+		result := LogDB.
+			Model(&GroupSummaryMinute{}).
+			Where(
+				"group_id = ? AND token_name = ? AND model = ? AND minute_timestamp = ?",
+				unique.GroupID,
+				unique.TokenName,
+				unique.Model,
+				unique.MinuteTimestamp,
+			).
+			Updates(data.buildUpdateData("group_summary_minutes"))
+		err = result.Error
+		if err != nil {
+			return err
+		}
+		if result.RowsAffected > 0 {
+			return nil
+		}
+
+		err = createGroupSummaryMinute(unique, data)
+		if err == nil {
+			return nil
+		}
+		if !errors.Is(err, gorm.ErrDuplicatedKey) {
+			return err
+		}
+	}
+
+	return err
+}
+
+func createGroupSummaryMinute(unique GroupSummaryMinuteUnique, data SummaryData) error {
+	return LogDB.
+		Clauses(clause.OnConflict{
+			Columns: []clause.Column{
+				{Name: "group_id"},
+				{Name: "token_name"},
+				{Name: "model"},
+				{Name: "minute_timestamp"},
+			},
+			DoUpdates: clause.Assignments(data.buildUpdateData("group_summary_minutes")),
+		}).
+		Create(&GroupSummaryMinute{
+			Unique: unique,
+			Data:   data,
+		}).Error
+}
+
+func GetGroupLastRequestTimeMinute(group string) (time.Time, error) {
+	if group == "" {
+		return time.Time{}, errors.New("group is required")
+	}
+	var summary GroupSummaryMinute
+	err := LogDB.
+		Model(&GroupSummaryMinute{}).
+		Where("group_id = ?", group).
+		Order("minute_timestamp desc").
+		First(&summary).Error
+	if summary.Unique.MinuteTimestamp == 0 {
+		return time.Time{}, nil
+	}
+	return time.Unix(summary.Unique.MinuteTimestamp, 0), err
+}
+
+func GetGroupTokenLastRequestTimeMinute(group, token string) (time.Time, error) {
+	var summary GroupSummaryMinute
+	err := LogDB.
+		Model(&GroupSummaryMinute{}).
+		Where("group_id = ? AND token_name = ?", group, token).
+		Order("minute_timestamp desc").
+		First(&summary).Error
+	if summary.Unique.MinuteTimestamp == 0 {
+		return time.Time{}, nil
+	}
+	return time.Unix(summary.Unique.MinuteTimestamp, 0), err
+}

+ 0 - 30
core/model/groupsummary.go

@@ -2,7 +2,6 @@ package model
 
 import (
 	"errors"
-	"time"
 
 	"gorm.io/gorm"
 	"gorm.io/gorm/clause"
@@ -104,32 +103,3 @@ func createGroupSummary(unique GroupSummaryUnique, data SummaryData) error {
 			Data:   data,
 		}).Error
 }
-
-func GetGroupLastRequestTime(group string) (time.Time, error) {
-	if group == "" {
-		return time.Time{}, errors.New("group is required")
-	}
-	var summary GroupSummary
-	err := LogDB.
-		Model(&GroupSummary{}).
-		Where("group_id = ?", group).
-		Order("hour_timestamp desc").
-		First(&summary).Error
-	if summary.Unique.HourTimestamp == 0 {
-		return time.Time{}, nil
-	}
-	return time.Unix(summary.Unique.HourTimestamp, 0), err
-}
-
-func GetGroupTokenLastRequestTime(group, token string) (time.Time, error) {
-	var summary GroupSummary
-	err := LogDB.
-		Model(&GroupSummary{}).
-		Where("group_id = ? AND token_name = ?", group, token).
-		Order("hour_timestamp desc").
-		First(&summary).Error
-	if summary.Unique.HourTimestamp == 0 {
-		return time.Time{}, nil
-	}
-	return time.Unix(summary.Unique.HourTimestamp, 0), err
-}

+ 20 - 0
core/model/main.go

@@ -198,6 +198,8 @@ func migrateLOGDB() error {
 		&Summary{},
 		&ConsumeError{},
 		&Store{},
+		&SummaryMinute{},
+		&GroupSummaryMinute{},
 	)
 	if err != nil {
 		return err
@@ -231,6 +233,24 @@ func migrateLOGDB() error {
 				err.Error(),
 			)
 		}
+		err = CreateSummaryMinuteIndexs(LogDB)
+		if err != nil {
+			notify.ErrorThrottle(
+				"createSummaryMinuteIndexs",
+				time.Minute,
+				"failed to create summary minute indexs",
+				err.Error(),
+			)
+		}
+		err = CreateGroupSummaryMinuteIndexs(LogDB)
+		if err != nil {
+			notify.ErrorThrottle(
+				"createSummaryMinuteIndexs",
+				time.Minute,
+				"failed to create group summary minute indexs",
+				err.Error(),
+			)
+		}
 	}()
 	return nil
 }

+ 707 - 0
core/model/summary-minute.go

@@ -0,0 +1,707 @@
+package model
+
+import (
+	"cmp"
+	"errors"
+	"slices"
+	"time"
+
+	"github.com/shopspring/decimal"
+	"golang.org/x/sync/errgroup"
+	"gorm.io/gorm"
+	"gorm.io/gorm/clause"
+)
+
+type SummaryMinute struct {
+	ID     int                 `gorm:"primaryKey"`
+	Unique SummaryMinuteUnique `gorm:"embedded"`
+	Data   SummaryData         `gorm:"embedded"`
+}
+
+type SummaryMinuteUnique struct {
+	ChannelID       int    `gorm:"not null;uniqueIndex:idx_summary_minute_unique,priority:1"`
+	Model           string `gorm:"not null;uniqueIndex:idx_summary_minute_unique,priority:2"`
+	MinuteTimestamp int64  `gorm:"not null;uniqueIndex:idx_summary_minute_unique,priority:3,sort:desc"`
+}
+
+func (l *SummaryMinute) BeforeCreate(_ *gorm.DB) (err error) {
+	if l.Unique.ChannelID == 0 {
+		return errors.New("channel id is required")
+	}
+	if l.Unique.Model == "" {
+		return errors.New("model is required")
+	}
+	if l.Unique.MinuteTimestamp == 0 {
+		return errors.New("minute timestamp is required")
+	}
+	if err := validateMinuteTimestamp(l.Unique.MinuteTimestamp); err != nil {
+		return err
+	}
+	return
+}
+
+var minuteTimestampDivisor = int64(time.Minute.Seconds())
+
+func validateMinuteTimestamp(minuteTimestamp int64) error {
+	if minuteTimestamp%minuteTimestampDivisor != 0 {
+		return errors.New("minute timestamp must be an exact minute")
+	}
+	return nil
+}
+
+func CreateSummaryMinuteIndexs(db *gorm.DB) error {
+	indexes := []string{
+		"CREATE INDEX IF NOT EXISTS idx_summary_minute_channel_minute ON summary_minutes (channel_id, minute_timestamp DESC)",
+		"CREATE INDEX IF NOT EXISTS idx_summary_minute_model_minute ON summary_minutes (model, minute_timestamp DESC)",
+	}
+
+	for _, index := range indexes {
+		if err := db.Exec(index).Error; err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func UpsertSummaryMinute(unique SummaryMinuteUnique, data SummaryData) error {
+	err := validateMinuteTimestamp(unique.MinuteTimestamp)
+	if err != nil {
+		return err
+	}
+
+	for range 3 {
+		result := LogDB.
+			Model(&SummaryMinute{}).
+			Where(
+				"channel_id = ? AND model = ? AND minute_timestamp = ?",
+				unique.ChannelID,
+				unique.Model,
+				unique.MinuteTimestamp,
+			).
+			Updates(data.buildUpdateData("summary_minutes"))
+		err = result.Error
+		if err != nil {
+			return err
+		}
+		if result.RowsAffected > 0 {
+			return nil
+		}
+
+		err = createSummaryMinute(unique, data)
+		if err == nil {
+			return nil
+		}
+		if !errors.Is(err, gorm.ErrDuplicatedKey) {
+			return err
+		}
+	}
+
+	return err
+}
+
+func createSummaryMinute(unique SummaryMinuteUnique, data SummaryData) error {
+	return LogDB.
+		Clauses(clause.OnConflict{
+			Columns: []clause.Column{
+				{Name: "channel_id"},
+				{Name: "model"},
+				{Name: "minute_timestamp"},
+			},
+			DoUpdates: clause.Assignments(data.buildUpdateData("summary_minutes")),
+		}).
+		Create(&SummaryMinute{
+			Unique: unique,
+			Data:   data,
+		}).Error
+}
+
+func getChartDataMinute(
+	start, end time.Time,
+	channelID int,
+	modelName string,
+	timeSpan TimeSpanType,
+	timezone *time.Location,
+) ([]*ChartData, error) {
+	query := LogDB.Model(&SummaryMinute{})
+
+	if channelID != 0 {
+		query = query.Where("channel_id = ?", channelID)
+	}
+
+	if modelName != "" {
+		query = query.Where("model = ?", modelName)
+	}
+
+	switch {
+	case !start.IsZero() && !end.IsZero():
+		query = query.Where("minute_timestamp BETWEEN ? AND ?", start.Unix(), end.Unix())
+	case !start.IsZero():
+		query = query.Where("minute_timestamp >= ?", start.Unix())
+	case !end.IsZero():
+		query = query.Where("minute_timestamp <= ?", end.Unix())
+	}
+
+	// Only include max metrics when we have specific channel and model
+	selectFields := "minute_timestamp as timestamp, sum(request_count) as request_count, sum(used_amount) as used_amount, " +
+		"sum(exception_count) as exception_count, sum(total_time_milliseconds) as total_time_milliseconds, sum(total_ttfb_milliseconds) as total_ttfb_milliseconds, " +
+		"sum(input_tokens) as input_tokens, sum(output_tokens) as output_tokens, " +
+		"sum(cached_tokens) as cached_tokens, sum(cache_creation_tokens) as cache_creation_tokens, " +
+		"sum(total_tokens) as total_tokens, sum(web_search_count) as web_search_count, " +
+		"sum(request_count) as max_rpm, sum(total_tokens) as max_tpm"
+
+	query = query.
+		Select(selectFields).
+		Group("timestamp").
+		Order("timestamp ASC")
+
+	var chartData []*ChartData
+	err := query.Scan(&chartData).Error
+	if err != nil {
+		return nil, err
+	}
+
+	if len(chartData) > 0 {
+		return aggregateDataToSpan(chartData, timeSpan, timezone), nil
+	}
+
+	return chartData, nil
+}
+
+func getGroupChartDataMinute(
+	group string,
+	start, end time.Time,
+	tokenName, modelName string,
+	timeSpan TimeSpanType,
+	timezone *time.Location,
+) ([]*ChartData, error) {
+	query := LogDB.Model(&GroupSummaryMinute{})
+	if group != "" {
+		query = query.Where("group_id = ?", group)
+	}
+	if tokenName != "" {
+		query = query.Where("token_name = ?", tokenName)
+	}
+
+	if modelName != "" {
+		query = query.Where("model = ?", modelName)
+	}
+
+	switch {
+	case !start.IsZero() && !end.IsZero():
+		query = query.Where("minute_timestamp BETWEEN ? AND ?", start.Unix(), end.Unix())
+	case !start.IsZero():
+		query = query.Where("minute_timestamp >= ?", start.Unix())
+	case !end.IsZero():
+		query = query.Where("minute_timestamp <= ?", end.Unix())
+	}
+
+	// Only include max metrics when we have specific channel and model
+	selectFields := "minute_timestamp as timestamp, sum(request_count) as request_count, sum(used_amount) as used_amount, " +
+		"sum(exception_count) as exception_count, sum(total_time_milliseconds) as total_time_milliseconds, sum(total_ttfb_milliseconds) as total_ttfb_milliseconds, " +
+		"sum(input_tokens) as input_tokens, sum(output_tokens) as output_tokens, " +
+		"sum(cached_tokens) as cached_tokens, sum(cache_creation_tokens) as cache_creation_tokens, " +
+		"sum(total_tokens) as total_tokens, sum(web_search_count) as web_search_count, " +
+		"sum(request_count) as max_rpm, sum(total_tokens) as max_tpm"
+
+	query = query.
+		Select(selectFields).
+		Group("timestamp").
+		Order("timestamp ASC")
+
+	var chartData []*ChartData
+	err := query.Scan(&chartData).Error
+	if err != nil {
+		return nil, err
+	}
+
+	// If timeSpan is day, aggregate hour data into day data
+	if timeSpan == TimeSpanDay && len(chartData) > 0 {
+		return aggregateDataToSpan(chartData, timeSpan, timezone), nil
+	}
+
+	return chartData, nil
+}
+
+func GetUsedChannelsMinute(start, end time.Time) ([]int, error) {
+	return getLogGroupByValuesMinute[int]("channel_id", start, end)
+}
+
+func GetUsedModelsMinute(start, end time.Time) ([]string, error) {
+	return getLogGroupByValuesMinute[string]("model", start, end)
+}
+
+func GetGroupUsedModelsMinute(group, tokenName string, start, end time.Time) ([]string, error) {
+	return getGroupLogGroupByValuesMinute[string]("model", group, tokenName, start, end)
+}
+
+func GetGroupUsedTokenNamesMinute(group string, start, end time.Time) ([]string, error) {
+	return getGroupLogGroupByValuesMinute[string]("token_name", group, "", start, end)
+}
+
+func getLogGroupByValuesMinute[T cmp.Ordered](
+	field string,
+	start, end time.Time,
+) ([]T, error) {
+	type Result struct {
+		Value        T
+		UsedAmount   float64
+		RequestCount int64
+	}
+	var results []Result
+
+	var query *gorm.DB
+
+	query = LogDB.
+		Model(&SummaryMinute{})
+
+	switch {
+	case !start.IsZero() && !end.IsZero():
+		query = query.Where("minute_timestamp BETWEEN ? AND ?", start.Unix(), end.Unix())
+	case !start.IsZero():
+		query = query.Where("minute_timestamp >= ?", start.Unix())
+	case !end.IsZero():
+		query = query.Where("minute_timestamp <= ?", end.Unix())
+	}
+
+	err := query.
+		Select(
+			field + " as value, SUM(request_count) as request_count, SUM(used_amount) as used_amount",
+		).
+		Group(field).
+		Scan(&results).Error
+	if err != nil {
+		return nil, err
+	}
+
+	slices.SortFunc(results, func(a, b Result) int {
+		if a.UsedAmount != b.UsedAmount {
+			return cmp.Compare(b.UsedAmount, a.UsedAmount)
+		}
+		if a.RequestCount != b.RequestCount {
+			return cmp.Compare(b.RequestCount, a.RequestCount)
+		}
+		return cmp.Compare(a.Value, b.Value)
+	})
+
+	values := make([]T, len(results))
+	for i, result := range results {
+		values[i] = result.Value
+	}
+
+	return values, nil
+}
+
+func getGroupLogGroupByValuesMinute[T cmp.Ordered](
+	field, group, tokenName string,
+	start, end time.Time,
+) ([]T, error) {
+	type Result struct {
+		Value        T
+		UsedAmount   float64
+		RequestCount int64
+	}
+	var results []Result
+
+	query := LogDB.
+		Model(&GroupSummaryMinute{})
+	if group != "" {
+		query = query.Where("group_id = ?", group)
+	}
+	if tokenName != "" {
+		query = query.Where("token_name = ?", tokenName)
+	}
+
+	switch {
+	case !start.IsZero() && !end.IsZero():
+		query = query.Where("minute_timestamp BETWEEN ? AND ?", start.Unix(), end.Unix())
+	case !start.IsZero():
+		query = query.Where("minute_timestamp >= ?", start.Unix())
+	case !end.IsZero():
+		query = query.Where("minute_timestamp <= ?", end.Unix())
+	}
+
+	err := query.
+		Select(
+			field + " as value, SUM(request_count) as request_count, SUM(used_amount) as used_amount",
+		).
+		Group(field).
+		Scan(&results).Error
+	if err != nil {
+		return nil, err
+	}
+
+	slices.SortFunc(results, func(a, b Result) int {
+		if a.UsedAmount != b.UsedAmount {
+			return cmp.Compare(b.UsedAmount, a.UsedAmount)
+		}
+		if a.RequestCount != b.RequestCount {
+			return cmp.Compare(b.RequestCount, a.RequestCount)
+		}
+		return cmp.Compare(a.Value, b.Value)
+	})
+
+	values := make([]T, len(results))
+	for i, result := range results {
+		values[i] = result.Value
+	}
+
+	return values, nil
+}
+
+func GetDashboardDataMinute(
+	start,
+	end time.Time,
+	modelName string,
+	channelID int,
+	timeSpan TimeSpanType,
+	timezone *time.Location,
+) (*DashboardResponse, error) {
+	if end.IsZero() {
+		end = time.Now()
+	} else if end.Before(start) {
+		return nil, errors.New("end time is before start time")
+	}
+
+	var (
+		chartData []*ChartData
+		channels  []int
+		models    []string
+	)
+
+	g := new(errgroup.Group)
+
+	g.Go(func() error {
+		var err error
+		chartData, err = getChartDataMinute(start, end, channelID, modelName, timeSpan, timezone)
+		return err
+	})
+
+	g.Go(func() error {
+		var err error
+		channels, err = GetUsedChannelsMinute(start, end)
+		return err
+	})
+
+	g.Go(func() error {
+		var err error
+		models, err = GetUsedModelsMinute(start, end)
+		return err
+	})
+
+	if err := g.Wait(); err != nil {
+		return nil, err
+	}
+
+	dashboardResponse := sumDashboardResponse(chartData)
+	dashboardResponse.Channels = channels
+	dashboardResponse.Models = models
+
+	return &dashboardResponse, nil
+}
+
+func GetGroupDashboardDataMinute(
+	group string,
+	start, end time.Time,
+	tokenName string,
+	modelName string,
+	timeSpan TimeSpanType,
+	timezone *time.Location,
+) (*GroupDashboardResponse, error) {
+	if group == "" {
+		return nil, errors.New("group is required")
+	}
+
+	if end.IsZero() {
+		end = time.Now()
+	} else if end.Before(start) {
+		return nil, errors.New("end time is before start time")
+	}
+
+	var (
+		chartData  []*ChartData
+		tokenNames []string
+		models     []string
+	)
+
+	g := new(errgroup.Group)
+
+	g.Go(func() error {
+		var err error
+		chartData, err = getGroupChartDataMinute(
+			group,
+			start,
+			end,
+			tokenName,
+			modelName,
+			timeSpan,
+			timezone,
+		)
+		return err
+	})
+
+	g.Go(func() error {
+		var err error
+		tokenNames, err = GetGroupUsedTokenNamesMinute(group, start, end)
+		return err
+	})
+
+	g.Go(func() error {
+		var err error
+		models, err = GetGroupUsedModelsMinute(group, tokenName, start, end)
+		return err
+	})
+
+	if err := g.Wait(); err != nil {
+		return nil, err
+	}
+
+	dashboardResponse := sumDashboardResponse(chartData)
+	dashboardResponse.Models = models
+
+	return &GroupDashboardResponse{
+		DashboardResponse: dashboardResponse,
+		TokenNames:        tokenNames,
+	}, nil
+}
+
+func GetTimeSeriesModelDataMinute(
+	channelID int,
+	start, end time.Time,
+	timeSpan TimeSpanType,
+	timezone *time.Location,
+) ([]*TimeModelData, error) {
+	if end.IsZero() {
+		end = time.Now()
+	} else if end.Before(start) {
+		return nil, errors.New("end time is before start time")
+	}
+
+	query := LogDB.Model(&SummaryMinute{})
+
+	if channelID != 0 {
+		query = query.Where("channel_id = ?", channelID)
+	}
+
+	switch {
+	case !start.IsZero() && !end.IsZero():
+		query = query.Where("minute_timestamp BETWEEN ? AND ?", start.Unix(), end.Unix())
+	case !start.IsZero():
+		query = query.Where("minute_timestamp >= ?", start.Unix())
+	case !end.IsZero():
+		query = query.Where("minute_timestamp <= ?", end.Unix())
+	}
+
+	selectFields := "minute_timestamp as timestamp, model, " +
+		"sum(request_count) as request_count, sum(used_amount) as used_amount, " +
+		"sum(exception_count) as exception_count, sum(total_time_milliseconds) as total_time_milliseconds, sum(total_ttfb_milliseconds) as total_ttfb_milliseconds, " +
+		"sum(input_tokens) as input_tokens, " +
+		"sum(output_tokens) as output_tokens, sum(cached_tokens) as cached_tokens, " +
+		"sum(cache_creation_tokens) as cache_creation_tokens, sum(total_tokens) as total_tokens, " +
+		"sum(web_search_count) as web_search_count, sum(request_count) as max_rpm, sum(total_tokens) as max_tpm"
+
+	var rawData []ModelData
+	err := query.
+		Select(selectFields).
+		Group("timestamp, model").
+		Order("timestamp ASC").
+		Scan(&rawData).Error
+	if err != nil {
+		return nil, err
+	}
+
+	if len(rawData) > 0 {
+		rawData = aggregatToSpan(rawData, timeSpan, timezone)
+	}
+
+	return convertToTimeModelData(rawData), nil
+}
+
+func GetGroupTimeSeriesModelDataMinute(
+	group string,
+	tokenName string,
+	start, end time.Time,
+	timeSpan TimeSpanType,
+	timezone *time.Location,
+) ([]*TimeModelData, error) {
+	if end.IsZero() {
+		end = time.Now()
+	} else if end.Before(start) {
+		return nil, errors.New("end time is before start time")
+	}
+
+	query := LogDB.Model(&GroupSummaryMinute{}).
+		Where("group_id = ?", group)
+	if tokenName != "" {
+		query = query.Where("token_name = ?", tokenName)
+	}
+
+	switch {
+	case !start.IsZero() && !end.IsZero():
+		query = query.Where("minute_timestamp BETWEEN ? AND ?", start.Unix(), end.Unix())
+	case !start.IsZero():
+		query = query.Where("minute_timestamp >= ?", start.Unix())
+	case !end.IsZero():
+		query = query.Where("minute_timestamp <= ?", end.Unix())
+	}
+
+	selectFields := "minute_timestamp as timestamp, model, " +
+		"sum(request_count) as request_count, sum(used_amount) as used_amount, " +
+		"sum(exception_count) as exception_count, sum(total_time_milliseconds) as total_time_milliseconds, sum(total_ttfb_milliseconds) as total_ttfb_milliseconds, " +
+		"sum(input_tokens) as input_tokens, " +
+		"sum(output_tokens) as output_tokens, sum(cached_tokens) as cached_tokens, " +
+		"sum(cache_creation_tokens) as cache_creation_tokens, sum(total_tokens) as total_tokens, " +
+		"sum(web_search_count) as web_search_count, sum(request_count) as max_rpm, sum(total_tokens) as max_tpm"
+
+	var rawData []ModelData
+	err := query.
+		Select(selectFields).
+		Group("timestamp, model").
+		Order("timestamp ASC").
+		Scan(&rawData).Error
+	if err != nil {
+		return nil, err
+	}
+
+	if len(rawData) > 0 {
+		rawData = aggregatToSpan(rawData, timeSpan, timezone)
+	}
+
+	return convertToTimeModelData(rawData), nil
+}
+
+func aggregatToSpan(
+	minuteData []ModelData,
+	timeSpan TimeSpanType,
+	timezone *time.Location,
+) []ModelData {
+	if timezone == nil {
+		timezone = time.Local
+	}
+
+	type AggKey struct {
+		Timestamp int64
+		Model     string
+	}
+	dataMap := make(map[AggKey]*ModelData)
+
+	for _, data := range minuteData {
+		t := time.Unix(data.Timestamp, 0).In(timezone)
+
+		key := AggKey{
+			Model: data.Model,
+		}
+
+		switch timeSpan {
+		case TimeSpanDay:
+			startOfDay := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, timezone)
+			key.Timestamp = startOfDay.Unix()
+		case TimeSpanHour:
+			startOfHour := time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, timezone)
+			key.Timestamp = startOfHour.Unix()
+		case TimeSpanMinute:
+			fallthrough
+		default:
+			startOfMinute := time.Date(
+				t.Year(),
+				t.Month(),
+				t.Day(),
+				t.Hour(),
+				t.Minute(),
+				0,
+				0,
+				timezone,
+			)
+			key.Timestamp = startOfMinute.Unix()
+		}
+
+		if _, exists := dataMap[key]; !exists {
+			dataMap[key] = &ModelData{
+				Timestamp: key.Timestamp,
+				Model:     data.Model,
+			}
+		}
+
+		currentData := dataMap[key]
+		currentData.RequestCount += data.RequestCount
+		currentData.UsedAmount = decimal.
+			NewFromFloat(currentData.UsedAmount).
+			Add(decimal.NewFromFloat(data.UsedAmount)).
+			InexactFloat64()
+		currentData.ExceptionCount += data.ExceptionCount
+		currentData.TotalTimeMilliseconds += data.TotalTimeMilliseconds
+		currentData.TotalTTFBMilliseconds += data.TotalTTFBMilliseconds
+		currentData.InputTokens += data.InputTokens
+		currentData.OutputTokens += data.OutputTokens
+		currentData.CachedTokens += data.CachedTokens
+		currentData.CacheCreationTokens += data.CacheCreationTokens
+		currentData.TotalTokens += data.TotalTokens
+		currentData.WebSearchCount += data.WebSearchCount
+
+		if data.MaxRPM > currentData.MaxRPM {
+			currentData.MaxRPM = data.MaxRPM
+		}
+		if data.MaxTPM > currentData.MaxTPM {
+			currentData.MaxTPM = data.MaxTPM
+		}
+	}
+
+	result := make([]ModelData, 0, len(dataMap))
+	for _, data := range dataMap {
+		result = append(result, *data)
+	}
+
+	return result
+}
+
+func convertToTimeModelData(rawData []ModelData) []*TimeModelData {
+	timeMap := make(map[int64][]*ModelData)
+
+	for _, data := range rawData {
+		modelData := &ModelData{
+			Model:                 data.Model,
+			RequestCount:          data.RequestCount,
+			UsedAmount:            data.UsedAmount,
+			ExceptionCount:        data.ExceptionCount,
+			TotalTimeMilliseconds: data.TotalTimeMilliseconds,
+			TotalTTFBMilliseconds: data.TotalTTFBMilliseconds,
+			InputTokens:           data.InputTokens,
+			OutputTokens:          data.OutputTokens,
+			CachedTokens:          data.CachedTokens,
+			CacheCreationTokens:   data.CacheCreationTokens,
+			TotalTokens:           data.TotalTokens,
+			WebSearchCount:        data.WebSearchCount,
+			MaxRPM:                data.MaxRPM,
+			MaxTPM:                data.MaxTPM,
+		}
+
+		timeMap[data.Timestamp] = append(timeMap[data.Timestamp], modelData)
+	}
+
+	result := make([]*TimeModelData, 0, len(timeMap))
+	for timestamp, models := range timeMap {
+		slices.SortFunc(models, func(a, b *ModelData) int {
+			if a.UsedAmount != b.UsedAmount {
+				return cmp.Compare(b.UsedAmount, a.UsedAmount)
+			}
+			if a.TotalTokens != b.TotalTokens {
+				return cmp.Compare(b.TotalTokens, a.TotalTokens)
+			}
+			if a.RequestCount != b.RequestCount {
+				return cmp.Compare(b.RequestCount, a.RequestCount)
+			}
+			return cmp.Compare(a.Model, b.Model)
+		})
+
+		result = append(result, &TimeModelData{
+			Timestamp: timestamp,
+			Models:    models,
+		})
+	}
+
+	slices.SortFunc(result, func(a, b *TimeModelData) int {
+		return cmp.Compare(a.Timestamp, b.Timestamp)
+	})
+
+	return result
+}

+ 108 - 376
core/model/summary.go

@@ -27,14 +27,12 @@ type SummaryUnique struct {
 }
 
 type SummaryData struct {
-	RequestCount   int64   `json:"request_count"`
-	UsedAmount     float64 `json:"used_amount"`
-	ExceptionCount int64   `json:"exception_count"`
-	MaxRPM         int64   `json:"max_rpm,omitempty"`
-	MaxRPS         int64   `json:"max_rps,omitempty"`
-	MaxTPM         int64   `json:"max_tpm,omitempty"`
-	MaxTPS         int64   `json:"max_tps,omitempty"`
-	Usage          Usage   `json:"usage,omitempty"   gorm:"embedded"`
+	RequestCount          int64   `json:"request_count"`
+	UsedAmount            float64 `json:"used_amount"`
+	ExceptionCount        int64   `json:"exception_count"`
+	TotalTimeMilliseconds int64   `json:"total_time_milliseconds,omitempty"`
+	TotalTTFBMilliseconds int64   `json:"total_ttfb_milliseconds,omitempty"`
+	Usage                 Usage   `json:"usage,omitempty"                   gorm:"embedded"`
 }
 
 func (d *SummaryData) buildUpdateData(tableName string) map[string]any {
@@ -48,50 +46,16 @@ func (d *SummaryData) buildUpdateData(tableName string) map[string]any {
 	if d.ExceptionCount > 0 {
 		data["exception_count"] = gorm.Expr(tableName+".exception_count + ?", d.ExceptionCount)
 	}
-
-	// max rpm tpm update
-	if d.MaxRPM > 0 {
-		data["max_rpm"] = gorm.Expr(
-			fmt.Sprintf(
-				"CASE WHEN %s.max_rpm < ? THEN ? ELSE %s.max_rpm END",
-				tableName,
-				tableName,
-			),
-			d.MaxRPM,
-			d.MaxRPM,
-		)
-	}
-	if d.MaxRPS > 0 {
-		data["max_rps"] = gorm.Expr(
-			fmt.Sprintf(
-				"CASE WHEN %s.max_rps < ? THEN ? ELSE %s.max_rps END",
-				tableName,
-				tableName,
-			),
-			d.MaxRPS,
-			d.MaxRPS,
+	if d.TotalTimeMilliseconds > 0 {
+		data["total_time_milliseconds"] = gorm.Expr(
+			tableName+".total_time_milliseconds + ?",
+			d.TotalTimeMilliseconds,
 		)
 	}
-	if d.MaxTPM > 0 {
-		data["max_tpm"] = gorm.Expr(
-			fmt.Sprintf(
-				"CASE WHEN %s.max_tpm < ? THEN ? ELSE %s.max_tpm END",
-				tableName,
-				tableName,
-			),
-			d.MaxTPM,
-			d.MaxTPM,
-		)
-	}
-	if d.MaxTPS > 0 {
-		data["max_tps"] = gorm.Expr(
-			fmt.Sprintf(
-				"CASE WHEN %s.max_tps < ? THEN ? ELSE %s.max_tps END",
-				tableName,
-				tableName,
-			),
-			d.MaxTPS,
-			d.MaxTPS,
+	if d.TotalTTFBMilliseconds > 0 {
+		data["total_ttfb_milliseconds"] = gorm.Expr(
+			tableName+".total_ttfb_milliseconds + ?",
+			d.TotalTTFBMilliseconds,
 		)
 	}
 
@@ -261,18 +225,11 @@ func getChartData(
 
 	// Only include max metrics when we have specific channel and model
 	selectFields := "hour_timestamp as timestamp, sum(request_count) as request_count, sum(used_amount) as used_amount, " +
-		"sum(exception_count) as exception_count, sum(input_tokens) as input_tokens, sum(output_tokens) as output_tokens, " +
+		"sum(exception_count) as exception_count, sum(total_time_milliseconds) as total_time_milliseconds, sum(total_ttfb_milliseconds) as total_ttfb_milliseconds, " +
+		"sum(input_tokens) as input_tokens, sum(output_tokens) as output_tokens, " +
 		"sum(cached_tokens) as cached_tokens, sum(cache_creation_tokens) as cache_creation_tokens, " +
 		"sum(total_tokens) as total_tokens, sum(web_search_count) as web_search_count"
 
-	// Only include max metrics when querying for a specific channel and model
-	if channelID != 0 && modelName != "" {
-		selectFields += ", max(max_rpm) as max_rpm, max(max_rps) as max_rps, max(max_tpm) as max_tpm, max(max_tps) as max_tps"
-	} else {
-		// Set max metrics to 0 when not querying for specific channel and model
-		selectFields += ", 0 as max_rpm, 0 as max_rps, 0 as max_tpm, 0 as max_tps"
-	}
-
 	query = query.
 		Select(selectFields).
 		Group("timestamp").
@@ -286,7 +243,7 @@ func getChartData(
 
 	// If timeSpan is day, aggregate hour data into day data
 	if timeSpan == TimeSpanDay && len(chartData) > 0 {
-		return aggregateHourDataToDay(chartData, timezone), nil
+		return aggregateDataToSpan(chartData, timeSpan, timezone), nil
 	}
 
 	return chartData, nil
@@ -322,18 +279,11 @@ func getGroupChartData(
 
 	// Only include max metrics when we have specific channel and model
 	selectFields := "hour_timestamp as timestamp, sum(request_count) as request_count, sum(used_amount) as used_amount, " +
-		"sum(exception_count) as exception_count, sum(input_tokens) as input_tokens, sum(output_tokens) as output_tokens, " +
+		"sum(exception_count) as exception_count, sum(total_time_milliseconds) as total_time_milliseconds, sum(total_ttfb_milliseconds) as total_ttfb_milliseconds, " +
+		"sum(input_tokens) as input_tokens, sum(output_tokens) as output_tokens, " +
 		"sum(cached_tokens) as cached_tokens, sum(cache_creation_tokens) as cache_creation_tokens, " +
 		"sum(total_tokens) as total_tokens, sum(web_search_count) as web_search_count"
 
-	// Only include max metrics when querying for a specific channel and model
-	if group != "" && tokenName != "" && modelName != "" {
-		selectFields += ", max(max_rpm) as max_rpm, max(max_rps) as max_rps, max(max_tpm) as max_tpm, max(max_tps) as max_tps"
-	} else {
-		// Set max metrics to 0 when not querying for specific channel and model
-		selectFields += ", 0 as max_rpm, 0 as max_rps, 0 as max_tpm, 0 as max_tps"
-	}
-
 	query = query.
 		Select(selectFields).
 		Group("timestamp").
@@ -347,7 +297,7 @@ func getGroupChartData(
 
 	// If timeSpan is day, aggregate hour data into day data
 	if timeSpan == TimeSpanDay && len(chartData) > 0 {
-		return aggregateHourDataToDay(chartData, timezone), nil
+		return aggregateDataToSpan(chartData, timeSpan, timezone), nil
 	}
 
 	return chartData, nil
@@ -480,35 +430,37 @@ func getGroupLogGroupByValues[T cmp.Ordered](
 }
 
 type ChartData struct {
-	Timestamp           int64   `json:"timestamp"`
-	RequestCount        int64   `json:"request_count"`
-	UsedAmount          float64 `json:"used_amount"`
-	InputTokens         int64   `json:"input_tokens,omitempty"`
-	OutputTokens        int64   `json:"output_tokens,omitempty"`
-	CachedTokens        int64   `json:"cached_tokens,omitempty"`
-	CacheCreationTokens int64   `json:"cache_creation_tokens,omitempty"`
-	TotalTokens         int64   `json:"total_tokens,omitempty"`
-	ExceptionCount      int64   `json:"exception_count"`
-	WebSearchCount      int64   `json:"web_search_count,omitempty"`
+	Timestamp    int64   `json:"timestamp"`
+	RequestCount int64   `json:"request_count"`
+	UsedAmount   float64 `json:"used_amount"`
+
+	TotalTimeMilliseconds int64 `json:"total_time_milliseconds,omitempty"`
+	TotalTTFBMilliseconds int64 `json:"total_ttfb_milliseconds,omitempty"`
+
+	InputTokens         int64 `json:"input_tokens,omitempty"`
+	OutputTokens        int64 `json:"output_tokens,omitempty"`
+	CachedTokens        int64 `json:"cached_tokens,omitempty"`
+	CacheCreationTokens int64 `json:"cache_creation_tokens,omitempty"`
+	TotalTokens         int64 `json:"total_tokens,omitempty"`
+	ExceptionCount      int64 `json:"exception_count"`
+	WebSearchCount      int64 `json:"web_search_count,omitempty"`
 
 	MaxRPM int64 `json:"max_rpm,omitempty"`
 	MaxTPM int64 `json:"max_tpm,omitempty"`
-	MaxRPS int64 `json:"max_rps,omitempty"`
-	MaxTPS int64 `json:"max_tps,omitempty"`
 }
 
 type DashboardResponse struct {
-	ChartData      []*ChartData `json:"chart_data"`
-	TotalCount     int64        `json:"total_count"`
-	ExceptionCount int64        `json:"exception_count"`
+	ChartData             []*ChartData `json:"chart_data"`
+	TotalCount            int64        `json:"total_count"`
+	ExceptionCount        int64        `json:"exception_count"`
+	TotalTimeMilliseconds int64        `json:"total_time_milliseconds,omitempty"`
+	TotalTTFBMilliseconds int64        `json:"total_ttfb_milliseconds,omitempty"`
 
 	RPM int64 `json:"rpm"`
 	TPM int64 `json:"tpm"`
 
 	MaxRPM int64 `json:"max_rpm,omitempty"`
 	MaxTPM int64 `json:"max_tpm,omitempty"`
-	MaxRPS int64 `json:"max_rps,omitempty"`
-	MaxTPS int64 `json:"max_tps,omitempty"`
 
 	UsedAmount          float64 `json:"used_amount"`
 	InputTokens         int64   `json:"input_tokens,omitempty"`
@@ -530,60 +482,69 @@ type GroupDashboardResponse struct {
 type TimeSpanType string
 
 const (
-	TimeSpanDay  TimeSpanType = "day"
-	TimeSpanHour TimeSpanType = "hour"
+	TimeSpanMinute TimeSpanType = "minute"
+	TimeSpanDay    TimeSpanType = "day"
+	TimeSpanHour   TimeSpanType = "hour"
 )
 
-// aggregateHourDataToDay converts hourly chart data into daily aggregated data
-func aggregateHourDataToDay(hourlyData []*ChartData, timezone *time.Location) []*ChartData {
-	dayData := make(map[int64]*ChartData)
+func aggregateDataToSpan(
+	data []*ChartData,
+	timeSpan TimeSpanType,
+	timezone *time.Location,
+) []*ChartData {
+	dataMap := make(map[int64]*ChartData)
 	if timezone == nil {
 		timezone = time.Local
 	}
 
-	for _, data := range hourlyData {
+	for _, data := range data {
 		// Convert timestamp to time in the specified timezone
 		t := time.Unix(data.Timestamp, 0).In(timezone)
 		// Get the start of the day in the specified timezone
-		startOfDay := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, timezone)
-		dayTimestamp := startOfDay.Unix()
+		var timestamp int64
+		switch timeSpan {
+		case TimeSpanDay:
+			startOfDay := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, timezone)
+			timestamp = startOfDay.Unix()
+		case TimeSpanHour:
+			startOfHour := time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, timezone)
+			timestamp = startOfHour.Unix()
+		case TimeSpanMinute:
+			timestamp = t.Unix()
+		}
 
-		if _, exists := dayData[dayTimestamp]; !exists {
-			dayData[dayTimestamp] = &ChartData{
-				Timestamp: dayTimestamp,
+		if _, exists := dataMap[timestamp]; !exists {
+			dataMap[timestamp] = &ChartData{
+				Timestamp: timestamp,
 			}
 		}
 
-		day := dayData[dayTimestamp]
-		day.RequestCount += data.RequestCount
-		day.UsedAmount = decimal.
-			NewFromFloat(data.UsedAmount).
-			Add(decimal.NewFromFloat(day.UsedAmount)).
+		currentData := dataMap[timestamp]
+		currentData.RequestCount += data.RequestCount
+		currentData.TotalTimeMilliseconds += data.TotalTimeMilliseconds
+		currentData.TotalTTFBMilliseconds += data.TotalTTFBMilliseconds
+		currentData.UsedAmount = decimal.
+			NewFromFloat(currentData.UsedAmount).
+			Add(decimal.NewFromFloat(data.UsedAmount)).
 			InexactFloat64()
-		day.ExceptionCount += data.ExceptionCount
-		day.InputTokens += data.InputTokens
-		day.OutputTokens += data.OutputTokens
-		day.CachedTokens += data.CachedTokens
-		day.CacheCreationTokens += data.CacheCreationTokens
-		day.TotalTokens += data.TotalTokens
-		day.WebSearchCount += data.WebSearchCount
-
-		if data.MaxRPM > day.MaxRPM {
-			day.MaxRPM = data.MaxRPM
-		}
-		if data.MaxTPM > day.MaxTPM {
-			day.MaxTPM = data.MaxTPM
-		}
-		if data.MaxRPS > day.MaxRPS {
-			day.MaxRPS = data.MaxRPS
+		currentData.ExceptionCount += data.ExceptionCount
+		currentData.InputTokens += data.InputTokens
+		currentData.OutputTokens += data.OutputTokens
+		currentData.CachedTokens += data.CachedTokens
+		currentData.CacheCreationTokens += data.CacheCreationTokens
+		currentData.TotalTokens += data.TotalTokens
+		currentData.WebSearchCount += data.WebSearchCount
+
+		if data.MaxRPM > currentData.MaxRPM {
+			currentData.MaxRPM = data.MaxRPM
 		}
-		if data.MaxTPS > day.MaxTPS {
-			day.MaxTPS = data.MaxTPS
+		if data.MaxTPM > currentData.MaxTPM {
+			currentData.MaxTPM = data.MaxTPM
 		}
 	}
 
-	result := make([]*ChartData, 0, len(dayData))
-	for _, data := range dayData {
+	result := make([]*ChartData, 0, len(dataMap))
+	for _, data := range dataMap {
 		result = append(result, data)
 	}
 
@@ -602,8 +563,13 @@ func sumDashboardResponse(chartData []*ChartData) DashboardResponse {
 	for _, data := range chartData {
 		dashboardResponse.TotalCount += data.RequestCount
 		dashboardResponse.ExceptionCount += data.ExceptionCount
-
+		dashboardResponse.TotalTimeMilliseconds += data.TotalTimeMilliseconds
+		dashboardResponse.TotalTTFBMilliseconds += data.TotalTTFBMilliseconds
 		usedAmount = usedAmount.Add(decimal.NewFromFloat(data.UsedAmount))
+		dashboardResponse.UsedAmount = decimal.
+			NewFromFloat(dashboardResponse.UsedAmount).
+			Add(decimal.NewFromFloat(data.UsedAmount)).
+			InexactFloat64()
 		dashboardResponse.InputTokens += data.InputTokens
 		dashboardResponse.OutputTokens += data.OutputTokens
 		dashboardResponse.TotalTokens += data.TotalTokens
@@ -617,12 +583,6 @@ func sumDashboardResponse(chartData []*ChartData) DashboardResponse {
 		if data.MaxTPM > dashboardResponse.MaxTPM {
 			dashboardResponse.MaxTPM = data.MaxTPM
 		}
-		if data.MaxRPS > dashboardResponse.MaxRPS {
-			dashboardResponse.MaxRPS = data.MaxRPS
-		}
-		if data.MaxTPS > dashboardResponse.MaxTPS {
-			dashboardResponse.MaxTPS = data.MaxTPS
-		}
 	}
 	dashboardResponse.UsedAmount = usedAmount.InexactFloat64()
 	return dashboardResponse
@@ -746,255 +706,27 @@ func GetGroupDashboardData(
 
 //nolint:revive
 type ModelData struct {
-	Timestamp           int64   `json:"timestamp,omitempty"`
-	Model               string  `json:"model"`
-	RequestCount        int64   `json:"request_count"`
-	UsedAmount          float64 `json:"used_amount"`
-	ExceptionCount      int64   `json:"exception_count"`
-	InputTokens         int64   `json:"input_tokens,omitempty"`
-	OutputTokens        int64   `json:"output_tokens,omitempty"`
-	CachedTokens        int64   `json:"cached_tokens,omitempty"`
-	CacheCreationTokens int64   `json:"cache_creation_tokens,omitempty"`
-	TotalTokens         int64   `json:"total_tokens,omitempty"`
-	WebSearchCount      int64   `json:"web_search_count,omitempty"`
-	MaxRPM              int64   `json:"max_rpm,omitempty"`
-	MaxRPS              int64   `json:"max_rps,omitempty"`
-	MaxTPM              int64   `json:"max_tpm,omitempty"`
-	MaxTPS              int64   `json:"max_tps,omitempty"`
-}
-
-type TimeModelData struct {
-	Timestamp int64        `json:"timestamp"`
-	Models    []*ModelData `json:"models"`
-}
-
-func GetTimeSeriesModelData(
-	channelID int,
-	start, end time.Time,
-	timeSpan TimeSpanType,
-	timezone *time.Location,
-) ([]*TimeModelData, error) {
-	if end.IsZero() {
-		end = time.Now()
-	} else if end.Before(start) {
-		return nil, errors.New("end time is before start time")
-	}
-
-	query := LogDB.Model(&Summary{})
-
-	if channelID != 0 {
-		query = query.Where("channel_id = ?", channelID)
-	}
-
-	switch {
-	case !start.IsZero() && !end.IsZero():
-		query = query.Where("hour_timestamp BETWEEN ? AND ?", start.Unix(), end.Unix())
-	case !start.IsZero():
-		query = query.Where("hour_timestamp >= ?", start.Unix())
-	case !end.IsZero():
-		query = query.Where("hour_timestamp <= ?", end.Unix())
-	}
-
-	selectFields := "hour_timestamp as timestamp, model, " +
-		"sum(request_count) as request_count, sum(used_amount) as used_amount, " +
-		"sum(exception_count) as exception_count, sum(input_tokens) as input_tokens, " +
-		"sum(output_tokens) as output_tokens, sum(cached_tokens) as cached_tokens, " +
-		"sum(cache_creation_tokens) as cache_creation_tokens, sum(total_tokens) as total_tokens, " +
-		"sum(web_search_count) as web_search_count"
-
-	if channelID != 0 {
-		selectFields += ", max(max_rpm) as max_rpm, max(max_rps) as max_rps, max(max_tpm) as max_tpm, max(max_tps) as max_tps"
-	} else {
-		selectFields += ", 0 as max_rpm, 0 as max_rps, 0 as max_tpm, 0 as max_tps"
-	}
-
-	var rawData []ModelData
-	err := query.
-		Select(selectFields).
-		Group("timestamp, model").
-		Order("timestamp ASC").
-		Scan(&rawData).Error
-	if err != nil {
-		return nil, err
-	}
-
-	if timeSpan == TimeSpanDay && len(rawData) > 0 {
-		rawData = aggregateHourlyToDaily(rawData, timezone)
-	}
-
-	return convertToTimeModelData(rawData), nil
-}
-
-func GetGroupTimeSeriesModelData(
-	group string,
-	tokenName string,
-	start, end time.Time,
-	timeSpan TimeSpanType,
-	timezone *time.Location,
-) ([]*TimeModelData, error) {
-	if end.IsZero() {
-		end = time.Now()
-	} else if end.Before(start) {
-		return nil, errors.New("end time is before start time")
-	}
-
-	query := LogDB.Model(&GroupSummary{}).
-		Where("group_id = ?", group)
-	if tokenName != "" {
-		query = query.Where("token_name = ?", tokenName)
-	}
-
-	switch {
-	case !start.IsZero() && !end.IsZero():
-		query = query.Where("hour_timestamp BETWEEN ? AND ?", start.Unix(), end.Unix())
-	case !start.IsZero():
-		query = query.Where("hour_timestamp >= ?", start.Unix())
-	case !end.IsZero():
-		query = query.Where("hour_timestamp <= ?", end.Unix())
-	}
-
-	selectFields := "hour_timestamp as timestamp, model, " +
-		"sum(request_count) as request_count, sum(used_amount) as used_amount, " +
-		"sum(exception_count) as exception_count, sum(input_tokens) as input_tokens, " +
-		"sum(output_tokens) as output_tokens, sum(cached_tokens) as cached_tokens, " +
-		"sum(cache_creation_tokens) as cache_creation_tokens, sum(total_tokens) as total_tokens, " +
-		"sum(web_search_count) as web_search_count"
-
-	if tokenName != "" {
-		selectFields += ", max(max_rpm) as max_rpm, max(max_rps) as max_rps, max(max_tpm) as max_tpm, max(max_tps) as max_tps"
-	} else {
-		selectFields += ", 0 as max_rpm, 0 as max_rps, 0 as max_tpm, 0 as max_tps"
-	}
-
-	var rawData []ModelData
-	err := query.
-		Select(selectFields).
-		Group("timestamp, model").
-		Order("timestamp ASC").
-		Scan(&rawData).Error
-	if err != nil {
-		return nil, err
-	}
-
-	if timeSpan == TimeSpanDay && len(rawData) > 0 {
-		rawData = aggregateHourlyToDaily(rawData, timezone)
-	}
-
-	return convertToTimeModelData(rawData), nil
-}
-
-func aggregateHourlyToDaily(hourlyData []ModelData, timezone *time.Location) []ModelData {
-	if timezone == nil {
-		timezone = time.Local
-	}
-
-	type AggKey struct {
-		DayTimestamp int64
-		Model        string
-	}
-	dayData := make(map[AggKey]*ModelData)
-
-	for _, data := range hourlyData {
-		t := time.Unix(data.Timestamp, 0).In(timezone)
-		startOfDay := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, timezone)
-		dayTimestamp := startOfDay.Unix()
-
-		key := AggKey{
-			DayTimestamp: dayTimestamp,
-			Model:        data.Model,
-		}
-
-		if _, exists := dayData[key]; !exists {
-			dayData[key] = &ModelData{
-				Timestamp: dayTimestamp,
-				Model:     data.Model,
-			}
-		}
+	Timestamp      int64   `json:"timestamp,omitempty"`
+	Model          string  `json:"model"`
+	RequestCount   int64   `json:"request_count"`
+	UsedAmount     float64 `json:"used_amount"`
+	ExceptionCount int64   `json:"exception_count"`
 
-		day := dayData[key]
-		day.RequestCount += data.RequestCount
-		day.UsedAmount = decimal.
-			NewFromFloat(data.UsedAmount).
-			Add(decimal.NewFromFloat(day.UsedAmount)).
-			InexactFloat64()
-		day.ExceptionCount += data.ExceptionCount
-		day.InputTokens += data.InputTokens
-		day.OutputTokens += data.OutputTokens
-		day.CachedTokens += data.CachedTokens
-		day.CacheCreationTokens += data.CacheCreationTokens
-		day.TotalTokens += data.TotalTokens
-		day.WebSearchCount += data.WebSearchCount
-
-		if data.MaxRPM > day.MaxRPM {
-			day.MaxRPM = data.MaxRPM
-		}
-		if data.MaxTPM > day.MaxTPM {
-			day.MaxTPM = data.MaxTPM
-		}
-		if data.MaxRPS > day.MaxRPS {
-			day.MaxRPS = data.MaxRPS
-		}
-		if data.MaxTPS > day.MaxTPS {
-			day.MaxTPS = data.MaxTPS
-		}
-	}
+	TotalTimeMilliseconds int64 `json:"total_time_milliseconds,omitempty"`
+	TotalTTFBMilliseconds int64 `json:"total_ttfb_milliseconds,omitempty"`
 
-	result := make([]ModelData, 0, len(dayData))
-	for _, data := range dayData {
-		result = append(result, *data)
-	}
+	InputTokens         int64 `json:"input_tokens,omitempty"`
+	OutputTokens        int64 `json:"output_tokens,omitempty"`
+	CachedTokens        int64 `json:"cached_tokens,omitempty"`
+	CacheCreationTokens int64 `json:"cache_creation_tokens,omitempty"`
+	TotalTokens         int64 `json:"total_tokens,omitempty"`
+	WebSearchCount      int64 `json:"web_search_count,omitempty"`
 
-	return result
+	MaxRPM int64 `json:"max_rpm,omitempty"`
+	MaxTPM int64 `json:"max_tpm,omitempty"`
 }
 
-func convertToTimeModelData(rawData []ModelData) []*TimeModelData {
-	timeMap := make(map[int64][]*ModelData)
-
-	for _, data := range rawData {
-		modelData := &ModelData{
-			Model:               data.Model,
-			RequestCount:        data.RequestCount,
-			UsedAmount:          data.UsedAmount,
-			ExceptionCount:      data.ExceptionCount,
-			InputTokens:         data.InputTokens,
-			OutputTokens:        data.OutputTokens,
-			CachedTokens:        data.CachedTokens,
-			CacheCreationTokens: data.CacheCreationTokens,
-			TotalTokens:         data.TotalTokens,
-			WebSearchCount:      data.WebSearchCount,
-			MaxRPM:              data.MaxRPM,
-			MaxRPS:              data.MaxRPS,
-			MaxTPM:              data.MaxTPM,
-			MaxTPS:              data.MaxTPS,
-		}
-
-		timeMap[data.Timestamp] = append(timeMap[data.Timestamp], modelData)
-	}
-
-	result := make([]*TimeModelData, 0, len(timeMap))
-	for timestamp, models := range timeMap {
-		slices.SortFunc(models, func(a, b *ModelData) int {
-			if a.UsedAmount != b.UsedAmount {
-				return cmp.Compare(b.UsedAmount, a.UsedAmount)
-			}
-			if a.TotalTokens != b.TotalTokens {
-				return cmp.Compare(b.TotalTokens, a.TotalTokens)
-			}
-			if a.RequestCount != b.RequestCount {
-				return cmp.Compare(b.RequestCount, a.RequestCount)
-			}
-			return cmp.Compare(a.Model, b.Model)
-		})
-
-		result = append(result, &TimeModelData{
-			Timestamp: timestamp,
-			Models:    models,
-		})
-	}
-
-	slices.SortFunc(result, func(a, b *TimeModelData) int {
-		return cmp.Compare(a.Timestamp, b.Timestamp)
-	})
-
-	return result
+type TimeModelData struct {
+	Timestamp int64        `json:"timestamp"`
+	Models    []*ModelData `json:"models"`
 }

+ 9 - 2
core/relay/plugin/monitor/monitor.go

@@ -209,8 +209,15 @@ const (
 	MetaChannelModelKeyTPS = "channel_model_tps"
 )
 
-func GetChannelModelRequestRate(c *gin.Context, meta *meta.Meta) model.RequestRate {
-	rate := model.RequestRate{}
+type RequestRate struct {
+	RPM int64
+	RPS int64
+	TPM int64
+	TPS int64
+}
+
+func GetChannelModelRequestRate(c *gin.Context, meta *meta.Meta) RequestRate {
+	rate := RequestRate{}
 
 	if rpm, ok := meta.Get(MetaChannelModelKeyRPM); ok {
 		rate.RPM, _ = rpm.(int64)