Files
gpt-load/internal/control/usage.go
T

544 lines
18 KiB
Go

package control
import (
"context"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"unicode"
"unicode/utf8"
"github.com/gin-gonic/gin"
"gpt-load/internal/channel"
"gpt-load/internal/platform/epochms"
app_errors "gpt-load/internal/platform/errors"
"gpt-load/internal/platform/response"
"gpt-load/internal/requestlog"
)
const (
usageRange1Hour = "1h"
usageRange24Hours = "24h"
usageRange3Days = "3d"
usageRange7Days = "7d"
usageRange15Days = "15d"
usageRange30Days = "30d"
usageRangeCustom = "custom"
usageBreakdownMax = 100
usageCustomMaxMS = 30 * epochms.MillisecondsPerDay
)
type UsageStatReader interface {
QueryUsage(context.Context, requestlog.UsageQuery) (requestlog.UsageReport, error)
}
type usageAggregateResponse struct {
RequestCount int64 `json:"request_count"`
SuccessCount int64 `json:"success_count"`
FailureCount int64 `json:"failure_count"`
UncachedInputTokens int64 `json:"uncached_input_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens"`
CacheWrite5MTokens int64 `json:"cache_write_5m_tokens"`
CacheWrite1HTokens int64 `json:"cache_write_1h_tokens"`
CacheWriteUnknownTokens int64 `json:"cache_write_unknown_tokens"`
OutputTokens int64 `json:"output_tokens"`
TotalTokens int64 `json:"total_tokens"`
EstimatedCostNanoUSD string `json:"estimated_cost_nano_usd"`
UsageMissingCount int64 `json:"usage_missing_count"`
PartialCount int64 `json:"partial_count"`
UnpricedRequestCount int64 `json:"unpriced_request_count"`
PricingPartialCount int64 `json:"pricing_partial_count"`
}
type usageSeriesResponse struct {
BucketStartMS int64 `json:"bucket_start_ms"`
BucketEndMS int64 `json:"bucket_end_ms"`
usageAggregateResponse
}
type usageBreakdownResponse struct {
GroupID uint `json:"group_id"`
ChannelID *channel.ID `json:"channel_id"`
CredentialID *uint `json:"credential_id"`
Model string `json:"model"`
usageAggregateResponse
}
type usageCollectionHealthResponse struct {
Scope string `json:"scope"`
DroppedTotal uint64 `json:"dropped_total"`
WriteFailureTotal uint64 `json:"write_failure_total"`
LastWriteFailureAtMS *int64 `json:"last_write_failure_at_ms"`
}
type usageResponse struct {
Range string `json:"range"`
Granularity requestlog.UsageGranularity `json:"granularity"`
BucketWidthMS int64 `json:"bucket_width_ms"`
FromMS int64 `json:"from_ms"`
ToMS int64 `json:"to_ms"`
ObservedAtMS int64 `json:"observed_at_ms"`
Summary usageAggregateResponse `json:"summary"`
Series []usageSeriesResponse `json:"series"`
Breakdown []usageBreakdownResponse `json:"breakdown"`
BreakdownTruncated bool `json:"breakdown_truncated"`
BreakdownOrder requestlog.UsageBreakdownOrder `json:"breakdown_order"`
BreakdownCount int64 `json:"breakdown_count"`
CollectionHealth usageCollectionHealthResponse `json:"collection_health"`
}
func (service *Service) QueryUsage(
ctx context.Context,
query requestlog.UsageQuery,
) (requestlog.UsageReport, error) {
if service.usageStats == nil {
return requestlog.UsageReport{}, app_errors.ErrInternalServer
}
report, err := service.usageStats.QueryUsage(ctx, query)
if err != nil {
return requestlog.UsageReport{}, app_errors.ParseDBError(err)
}
return report, nil
}
func (server *Server) handleUsage(c *gin.Context) {
observedAtMS, err := safeEpochMilliseconds(server.service.now())
if err != nil {
writeServiceError(c, "usage", err)
return
}
query, apiErr := parseUsageQuery(c.Request.URL.RawQuery, observedAtMS)
if apiErr != nil {
writeServiceError(c, "usage", apiErr)
return
}
if accessKeyID, scoped := currentAccessKeyID(c); scoped {
if query.GroupID != nil || query.ChannelID != "" || query.CredentialID != nil {
writeServiceError(c, "usage", app_errors.ErrBadRequest)
return
}
query.AccessKeyID = &accessKeyID
}
report, err := server.service.QueryUsage(c.Request.Context(), query)
if err != nil {
writeServiceError(c, "usage", err)
return
}
result, err := server.service.mapUsageResponse(observedAtMS, query, report)
if err != nil {
writeServiceError(c, "usage", err)
return
}
response.SuccessI18n(c, "common.success", result)
}
func parseUsageQuery(rawQuery string, observedAtMS int64) (requestlog.UsageQuery, *app_errors.APIError) {
values, err := url.ParseQuery(rawQuery)
if err != nil {
return requestlog.UsageQuery{}, app_errors.ErrBadRequest
}
allowed := map[string]struct{}{
"range": {},
"from_ms": {},
"to_ms": {},
"group_id": {},
"channel_id": {},
"credential_id": {},
"upstream_model": {},
"breakdown_order": {},
}
for key, value := range values {
if _, ok := allowed[key]; !ok || len(value) != 1 {
return requestlog.UsageQuery{}, app_errors.ErrBadRequest
}
}
if err := validateSafeMilliseconds(observedAtMS); err != nil {
return requestlog.UsageQuery{}, app_errors.ErrInternalServer
}
query := requestlog.UsageQuery{
Limit: usageBreakdownMax,
BreakdownOrder: requestlog.UsageBreakdownOrderRequests,
}
rangeValue := usageRange24Hours
if value, ok := singleQueryValue(values, "range"); ok {
rangeValue = value
}
fromValue, hasFrom := singleQueryValue(values, "from_ms")
toValue, hasTo := singleQueryValue(values, "to_ms")
if hasFrom != hasTo {
return requestlog.UsageQuery{}, app_errors.ErrValidation
}
if hasFrom {
if _, hasRange := singleQueryValue(values, "range"); hasRange {
return requestlog.UsageQuery{}, app_errors.ErrValidation
}
fromMS, err := parseCanonicalSafeMilliseconds(fromValue)
if err != nil {
return requestlog.UsageQuery{}, app_errors.ErrBadRequest
}
toMS, err := parseCanonicalSafeMilliseconds(toValue)
if err != nil {
return requestlog.UsageQuery{}, app_errors.ErrBadRequest
}
if fromMS >= toMS {
return requestlog.UsageQuery{}, app_errors.ErrValidation
}
spanMS := toMS - fromMS
if spanMS > usageCustomMaxMS {
return requestlog.UsageQuery{}, app_errors.ErrValidation
}
query.FromMS = fromMS
query.ToMS = toMS
bucketWidth := epochms.MillisecondsPerDay
if spanMS <= epochms.MillisecondsPerDay {
query.Granularity = requestlog.UsageGranularityHour
bucketWidth = epochms.MillisecondsPerHour
} else {
query.Granularity = requestlog.UsageGranularityDay
}
query.BucketWidthMS = bucketWidth
if fromMS%bucketWidth != 0 || toMS%bucketWidth != 0 {
return requestlog.UsageQuery{}, app_errors.ErrValidation
}
rangeValue = ""
}
if !hasFrom {
preset, ok := usageRangePreset(rangeValue)
if !ok {
return requestlog.UsageQuery{}, app_errors.ErrValidation
}
fromMS, toMS, err := epochms.WindowEndingAt(
observedAtMS,
preset.bucketWidthMS,
preset.bucketCount,
)
if err != nil {
return requestlog.UsageQuery{}, app_errors.ErrInternalServer
}
query.FromMS = fromMS
query.ToMS = toMS
query.Granularity = preset.granularity
query.BucketWidthMS = preset.bucketWidthMS
}
if value, ok := singleQueryValue(values, "group_id"); ok {
groupID, apiErr := parseUsageGroupID(value)
if apiErr != nil {
return requestlog.UsageQuery{}, apiErr
}
query.GroupID = &groupID
}
if value, ok := singleQueryValue(values, "channel_id"); ok {
channelID, apiErr := parseRequestLogChannelID(value)
if apiErr != nil {
return requestlog.UsageQuery{}, apiErr
}
query.ChannelID = channelID
}
if value, ok := singleQueryValue(values, "credential_id"); ok {
credentialID, apiErr := parseUsageGroupID(value)
if apiErr != nil {
return requestlog.UsageQuery{}, apiErr
}
query.CredentialID = &credentialID
}
if value, ok := singleQueryValue(values, "upstream_model"); ok {
if !validUsageModel(value) {
return requestlog.UsageQuery{}, app_errors.ErrValidation
}
query.UpstreamModel = value
}
if value, ok := singleQueryValue(values, "breakdown_order"); ok {
switch requestlog.UsageBreakdownOrder(value) {
case requestlog.UsageBreakdownOrderRequests:
query.BreakdownOrder = requestlog.UsageBreakdownOrderRequests
case requestlog.UsageBreakdownOrderCost:
query.BreakdownOrder = requestlog.UsageBreakdownOrderCost
default:
return requestlog.UsageQuery{}, app_errors.ErrValidation
}
}
return query, nil
}
type usagePreset struct {
bucketWidthMS int64
bucketCount int
granularity requestlog.UsageGranularity
}
func usageRangePreset(value string) (usagePreset, bool) {
switch value {
case usageRange1Hour:
return usagePreset{epochms.MillisecondsPerHour, 1, requestlog.UsageGranularityHour}, true
case usageRange24Hours:
return usagePreset{epochms.MillisecondsPerHour, 24, requestlog.UsageGranularityHour}, true
case usageRange3Days:
return usagePreset{3 * epochms.MillisecondsPerHour, 24, requestlog.UsageGranularityHour}, true
case usageRange7Days:
return usagePreset{6 * epochms.MillisecondsPerHour, 28, requestlog.UsageGranularityHour}, true
case usageRange15Days:
return usagePreset{12 * epochms.MillisecondsPerHour, 30, requestlog.UsageGranularityHour}, true
case usageRange30Days:
return usagePreset{epochms.MillisecondsPerDay, 30, requestlog.UsageGranularityDay}, true
default:
return usagePreset{}, false
}
}
func validUsageModel(value string) bool {
if !utf8.ValidString(value) || len(value) < 1 || len(value) > 255 ||
strings.TrimSpace(value) != value {
return false
}
for _, character := range value {
if unicode.IsControl(character) {
return false
}
}
return true
}
func parseUsageGroupID(value string) (uint, *app_errors.APIError) {
parsed, err := parseCanonicalSafeUint(value)
if err != nil {
if errors.Is(err, errUnsafeCanonicalUint) {
return 0, app_errors.ErrValidation
}
return 0, app_errors.ErrBadRequest
}
if parsed == 0 || uint64(uint(parsed)) != parsed {
return 0, app_errors.ErrValidation
}
return uint(parsed), nil
}
func (service *Service) mapUsageResponse(
observedAtMS int64,
query requestlog.UsageQuery,
report requestlog.UsageReport,
) (usageResponse, error) {
accessKeyScoped := query.AccessKeyID != nil
if !accessKeyScoped && service.requestLogStats == nil {
return usageResponse{}, app_errors.ErrInternalServer
}
switch query.BreakdownOrder {
case requestlog.UsageBreakdownOrderRequests, requestlog.UsageBreakdownOrderCost:
default:
return usageResponse{}, fmt.Errorf("map usage response: invalid query breakdown order")
}
if report.BreakdownOrder != query.BreakdownOrder {
return usageResponse{}, fmt.Errorf("map usage response: breakdown order mismatch")
}
if report.BreakdownCount < 0 || report.BreakdownCount > maxSafeInteger {
return usageResponse{}, fmt.Errorf("map usage response: unsafe breakdown group count")
}
summary, err := mapUsageAggregate(report.Summary)
if err != nil {
return usageResponse{}, err
}
stats := requestlog.Stats{}
if !accessKeyScoped {
stats = service.requestLogStats.Stats()
}
if stats.DroppedTotal > uint64(maxSafeInteger) ||
stats.WriteFailureTotal > uint64(maxSafeInteger) {
return usageResponse{}, fmt.Errorf("map usage collection health: unsafe counter")
}
bucketWidthMS, err := usageResponseBucketWidth(query)
if err != nil {
return usageResponse{}, err
}
rangeValue, err := usageResponseRange(query, bucketWidthMS)
if err != nil {
return usageResponse{}, err
}
lastWriteFailureAtMS, err := optionalSafeEpochMilliseconds(stats.LastWriteFailureAt)
if err != nil {
return usageResponse{}, fmt.Errorf("map usage collection health: %w", err)
}
if err := validateSafeMilliseconds(query.FromMS); err != nil {
return usageResponse{}, fmt.Errorf("map usage from_ms: %w", err)
}
if err := validateSafeMilliseconds(query.ToMS); err != nil {
return usageResponse{}, fmt.Errorf("map usage to_ms: %w", err)
}
if err := validateSafeMilliseconds(observedAtMS); err != nil {
return usageResponse{}, fmt.Errorf("map usage observed_at_ms: %w", err)
}
result := usageResponse{
Range: rangeValue,
Granularity: query.Granularity,
BucketWidthMS: bucketWidthMS,
FromMS: query.FromMS,
ToMS: query.ToMS,
ObservedAtMS: observedAtMS,
CollectionHealth: usageCollectionHealthResponse{
Scope: "current_process",
DroppedTotal: stats.DroppedTotal,
WriteFailureTotal: stats.WriteFailureTotal,
LastWriteFailureAtMS: lastWriteFailureAtMS,
},
Summary: summary,
Series: make([]usageSeriesResponse, 0, len(report.Series)),
Breakdown: make([]usageBreakdownResponse, 0, len(report.Breakdown)),
BreakdownTruncated: report.BreakdownTruncated,
BreakdownOrder: report.BreakdownOrder,
BreakdownCount: report.BreakdownCount,
}
if accessKeyScoped {
result.CollectionHealth = usageCollectionHealthResponse{Scope: "access_key"}
}
for _, point := range report.Series {
if err := validateSafeMilliseconds(point.BucketStartMS); err != nil {
return usageResponse{}, fmt.Errorf("map usage bucket_start_ms: %w", err)
}
if err := validateSafeMilliseconds(point.BucketEndMS); err != nil {
return usageResponse{}, fmt.Errorf("map usage bucket_end_ms: %w", err)
}
aggregate, err := mapUsageAggregate(point.UsageAggregate)
if err != nil {
return usageResponse{}, err
}
result.Series = append(result.Series, usageSeriesResponse{
BucketStartMS: point.BucketStartMS,
BucketEndMS: point.BucketEndMS,
usageAggregateResponse: aggregate,
})
}
for _, row := range report.Breakdown {
if uint64(row.GroupID) > uint64(maxSafeInteger) {
return usageResponse{}, fmt.Errorf("map usage breakdown: unsafe group")
}
channelID, err := nullableRequestLogChannelID(row.ChannelID)
if err != nil {
return usageResponse{}, fmt.Errorf("map usage breakdown: %w", err)
}
credentialID, err := nullableRequestLogID(row.CredentialID, "credential")
if err != nil {
return usageResponse{}, fmt.Errorf("map usage breakdown: %w", err)
}
aggregate, err := mapUsageAggregate(row.UsageAggregate)
if err != nil {
return usageResponse{}, err
}
result.Breakdown = append(result.Breakdown, usageBreakdownResponse{
GroupID: row.GroupID, ChannelID: channelID, CredentialID: credentialID,
Model: row.Model, usageAggregateResponse: aggregate,
})
if accessKeyScoped {
redacted := &result.Breakdown[len(result.Breakdown)-1]
redacted.GroupID = 0
redacted.ChannelID = nil
redacted.CredentialID = nil
}
}
return result, nil
}
func usageResponseBucketWidth(query requestlog.UsageQuery) (int64, error) {
bucketWidthMS := query.BucketWidthMS
if bucketWidthMS == 0 {
switch query.Granularity {
case requestlog.UsageGranularityHour:
bucketWidthMS = epochms.MillisecondsPerHour
case requestlog.UsageGranularityDay:
bucketWidthMS = epochms.MillisecondsPerDay
default:
return 0, fmt.Errorf("map usage response: invalid granularity")
}
}
if bucketWidthMS < epochms.MillisecondsPerHour ||
bucketWidthMS > epochms.MillisecondsPerDay ||
bucketWidthMS%epochms.MillisecondsPerHour != 0 {
return 0, fmt.Errorf("map usage response: invalid bucket width")
}
if query.Granularity == requestlog.UsageGranularityHour &&
bucketWidthMS >= epochms.MillisecondsPerDay {
return 0, fmt.Errorf("map usage response: invalid hourly bucket width")
}
if query.Granularity == requestlog.UsageGranularityDay &&
bucketWidthMS != epochms.MillisecondsPerDay {
return 0, fmt.Errorf("map usage response: invalid daily bucket width")
}
return bucketWidthMS, nil
}
func usageResponseRange(query requestlog.UsageQuery, bucketWidthMS int64) (string, error) {
duration := query.ToMS - query.FromMS
if query.Granularity != requestlog.UsageGranularityHour &&
query.Granularity != requestlog.UsageGranularityDay {
return "", fmt.Errorf("map usage response: invalid granularity")
}
for _, rangeValue := range []string{
usageRange1Hour,
usageRange24Hours,
usageRange3Days,
usageRange7Days,
usageRange15Days,
usageRange30Days,
} {
preset, _ := usageRangePreset(rangeValue)
if query.Granularity == preset.granularity &&
bucketWidthMS == preset.bucketWidthMS &&
duration == int64(preset.bucketCount)*preset.bucketWidthMS {
return rangeValue, nil
}
}
return usageRangeCustom, nil
}
func mapUsageAggregate(source requestlog.UsageAggregate) (usageAggregateResponse, error) {
values := []int64{
source.RequestCount, source.SuccessCount, source.FailureCount,
source.UncachedInputTokens, source.CacheReadTokens, source.CacheWrite5MTokens,
source.CacheWrite1HTokens, source.OutputTokens, source.UsageMissingCount,
source.CacheWriteUnknownTokens, source.PartialCount, source.UnpricedRequestCount,
source.PricingPartialCount,
}
for _, value := range values {
if value < 0 || value > maxSafeInteger {
return usageAggregateResponse{}, fmt.Errorf("map usage aggregate: unsafe integer")
}
}
totalTokens, err := checkedUsageTokenTotal(
source.UncachedInputTokens,
source.CacheReadTokens,
source.CacheWrite5MTokens,
source.CacheWrite1HTokens,
source.CacheWriteUnknownTokens,
source.OutputTokens,
)
if err != nil {
return usageAggregateResponse{}, err
}
if source.EstimatedCostNanoUSD < 0 {
return usageAggregateResponse{}, fmt.Errorf("map usage cost: negative value")
}
return usageAggregateResponse{
RequestCount: source.RequestCount, SuccessCount: source.SuccessCount, FailureCount: source.FailureCount,
UncachedInputTokens: source.UncachedInputTokens, CacheReadTokens: source.CacheReadTokens,
CacheWrite5MTokens: source.CacheWrite5MTokens, CacheWrite1HTokens: source.CacheWrite1HTokens,
CacheWriteUnknownTokens: source.CacheWriteUnknownTokens,
OutputTokens: source.OutputTokens, TotalTokens: totalTokens,
EstimatedCostNanoUSD: strconv.FormatInt(source.EstimatedCostNanoUSD, 10),
UsageMissingCount: source.UsageMissingCount, PartialCount: source.PartialCount,
UnpricedRequestCount: source.UnpricedRequestCount,
PricingPartialCount: source.PricingPartialCount,
}, nil
}
func checkedUsageTokenTotal(tokens ...int64) (int64, error) {
var total int64
for _, value := range tokens {
if value < 0 || value > maxSafeInteger-total {
return 0, fmt.Errorf("map usage aggregate: unsafe total tokens")
}
total += value
}
return total, nil
}