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

451 lines
15 KiB
Go

package control
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/url"
"regexp"
"strconv"
"time"
"github.com/gin-gonic/gin"
app_errors "gpt-load/internal/platform/errors"
"gpt-load/internal/platform/response"
"gpt-load/internal/pricing"
"gpt-load/internal/requestlog"
"gpt-load/internal/telemetry"
"gpt-load/internal/usage"
)
const (
defaultRequestLogLimit = 50
maxRequestLogLimit = 200
requestLogCursorV1 = 1
)
var canonicalLowercaseUUIDv4 = regexp.MustCompile(
`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`,
)
type RequestLogReader interface {
List(context.Context, requestlog.ListQuery) (requestlog.Page, error)
}
type requestLogCursorPayload struct {
Version int `json:"v"`
CompletedAt string `json:"completed_at"`
RequestID string `json:"request_id"`
}
type requestLogAccessKeyResponse struct {
ID uint `json:"id"`
Name *string `json:"name"`
Deleted bool `json:"deleted"`
}
type requestLogAttemptResponse struct {
Sequence int `json:"sequence"`
GroupID uint `json:"group_id"`
GroupName string `json:"group_name"`
KeyID uint `json:"key_id"`
UpstreamModel string `json:"upstream_model"`
StatusCode int `json:"status_code"`
DurationMs int64 `json:"duration_ms"`
FailureCategory telemetry.FailureCategory `json:"failure_category"`
Action telemetry.Action `json:"action"`
WillRetry bool `json:"will_retry"`
ErrorCode string `json:"error_code"`
ErrorSummary string `json:"error_summary"`
Committed bool `json:"committed"`
}
type requestLogItemResponse struct {
RequestID string `json:"request_id"`
CompletedAt string `json:"completed_at"`
AccessKey requestLogAccessKeyResponse `json:"access_key"`
Protocol string `json:"protocol"`
ClientModel string `json:"client_model"`
UpstreamModel string `json:"upstream_model"`
Status telemetry.RequestStatus `json:"status"`
StatusCode int `json:"status_code"`
DurationMs int64 `json:"duration_ms"`
ErrorCode string `json:"error_code"`
ErrorSummary string `json:"error_summary"`
AffinityHit bool `json:"affinity_hit"`
Attempts []requestLogAttemptResponse `json:"attempts"`
GroupID *uint `json:"group_id"`
UsageState usage.State `json:"usage_state"`
CostState pricing.CostState `json:"cost_state"`
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"`
OutputTokens int64 `json:"output_tokens"`
EstimatedCostUSD json.Number `json:"estimated_cost_usd"`
}
type requestLogListResponse struct {
Items []requestLogItemResponse `json:"items"`
NextCursor *string `json:"next_cursor"`
}
func (service *Service) ListRequestLogs(
ctx context.Context,
query requestlog.ListQuery,
) (requestlog.Page, error) {
if service.requestLogs == nil {
return requestlog.Page{}, app_errors.ErrInternalServer
}
page, err := service.requestLogs.List(ctx, query)
if err != nil {
return requestlog.Page{}, app_errors.ParseDBError(err)
}
return page, nil
}
func (s *Server) handleListRequestLogs(c *gin.Context) {
query, apiErr := parseRequestLogQuery(c.Request.URL.RawQuery)
if apiErr != nil {
writeServiceError(c, "list_request_logs", apiErr)
return
}
page, err := s.service.ListRequestLogs(c.Request.Context(), query)
if err != nil {
writeServiceError(c, "list_request_logs", err)
return
}
result, err := mapRequestLogListResponse(page)
if err != nil {
writeServiceError(c, "list_request_logs", err)
return
}
response.SuccessI18n(c, "common.success", result)
}
func parseRequestLogQuery(rawQuery string) (requestlog.ListQuery, *app_errors.APIError) {
values, err := url.ParseQuery(rawQuery)
if err != nil {
return requestlog.ListQuery{}, app_errors.ErrBadRequest
}
allowed := map[string]struct{}{
"from": {}, "to": {}, "group_id": {}, "model": {}, "access_key_id": {},
"status": {}, "request_id": {}, "limit": {}, "cursor": {},
}
for key, value := range values {
if _, ok := allowed[key]; !ok || len(value) != 1 {
return requestlog.ListQuery{}, app_errors.ErrBadRequest
}
}
query := requestlog.ListQuery{Limit: defaultRequestLogLimit}
if value, ok := singleQueryValue(values, "from"); ok {
parsed, err := time.Parse(time.RFC3339Nano, value)
if err != nil {
return requestlog.ListQuery{}, app_errors.ErrBadRequest
}
parsed = parsed.UTC()
query.From = &parsed
}
if value, ok := singleQueryValue(values, "to"); ok {
parsed, err := time.Parse(time.RFC3339Nano, value)
if err != nil {
return requestlog.ListQuery{}, app_errors.ErrBadRequest
}
parsed = parsed.UTC()
query.To = &parsed
}
if query.From != nil && query.To != nil && !query.From.Before(*query.To) {
return requestlog.ListQuery{}, app_errors.ErrValidation
}
if value, ok := singleQueryValue(values, "group_id"); ok {
parsed, apiErr := parseRequestLogID(value)
if apiErr != nil {
return requestlog.ListQuery{}, apiErr
}
query.GroupID = &parsed
}
if value, ok := singleQueryValue(values, "model"); ok {
if value == "" {
return requestlog.ListQuery{}, app_errors.ErrValidation
}
query.ClientModel = value
}
if value, ok := singleQueryValue(values, "access_key_id"); ok {
parsed, apiErr := parseRequestLogID(value)
if apiErr != nil {
return requestlog.ListQuery{}, apiErr
}
query.AccessKeyID = &parsed
}
if value, ok := singleQueryValue(values, "status"); ok {
status := telemetry.RequestStatus(value)
switch status {
case telemetry.RequestStatusSuccess,
telemetry.RequestStatusError,
telemetry.RequestStatusIncomplete,
telemetry.RequestStatusCanceled:
query.Status = status
default:
return requestlog.ListQuery{}, app_errors.ErrValidation
}
}
if value, ok := singleQueryValue(values, "request_id"); ok {
if !canonicalLowercaseUUIDv4.MatchString(value) {
return requestlog.ListQuery{}, app_errors.ErrBadRequest
}
query.RequestID = value
}
if value, ok := singleQueryValue(values, "limit"); ok {
parsed, err := strconv.Atoi(value)
if err != nil {
return requestlog.ListQuery{}, app_errors.ErrBadRequest
}
if parsed < 1 || parsed > maxRequestLogLimit {
return requestlog.ListQuery{}, app_errors.ErrValidation
}
query.Limit = parsed
}
if value, ok := singleQueryValue(values, "cursor"); ok {
cursor, err := decodeRequestLogCursor(value)
if err != nil {
return requestlog.ListQuery{}, app_errors.ErrBadRequest
}
query.Cursor = cursor
}
return query, nil
}
func singleQueryValue(values url.Values, key string) (string, bool) {
value, ok := values[key]
if !ok {
return "", false
}
return value[0], true
}
func parseRequestLogID(value string) (uint, *app_errors.APIError) {
parsed, err := strconv.ParseUint(value, 10, strconv.IntSize)
if err != nil {
return 0, app_errors.ErrBadRequest
}
if parsed == 0 {
return 0, app_errors.ErrValidation
}
return uint(parsed), nil
}
func decodeRequestLogCursor(encoded string) (*requestlog.Cursor, error) {
raw, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("decode request log cursor base64: %w", err)
}
if encoded != base64.RawURLEncoding.EncodeToString(raw) {
return nil, fmt.Errorf("decode request log cursor base64: non-canonical encoding")
}
payload, err := decodeRequestLogCursorPayload(raw)
if err != nil {
return nil, err
}
if payload.Version != requestLogCursorV1 {
return nil, fmt.Errorf("unsupported request log cursor version")
}
completedAt, err := time.Parse(time.RFC3339Nano, payload.CompletedAt)
if err != nil || payload.CompletedAt != completedAt.UTC().Format(time.RFC3339Nano) {
return nil, fmt.Errorf("invalid request log cursor completed_at")
}
if !canonicalLowercaseUUIDv4.MatchString(payload.RequestID) {
return nil, fmt.Errorf("invalid request log cursor request_id")
}
return &requestlog.Cursor{
CompletedAt: completedAt.UTC(),
RequestID: payload.RequestID,
}, nil
}
func decodeRequestLogCursorPayload(raw []byte) (requestLogCursorPayload, error) {
if err := rejectDuplicateRequestLogCursorFields(raw); err != nil {
return requestLogCursorPayload{}, err
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
var payload requestLogCursorPayload
if err := decoder.Decode(&payload); err != nil {
return requestLogCursorPayload{}, fmt.Errorf("decode request log cursor JSON: %w", err)
}
var trailing json.RawMessage
if err := decoder.Decode(&trailing); !errorsIsEOF(err) {
if err == nil {
return requestLogCursorPayload{}, fmt.Errorf("decode request log cursor JSON: multiple values")
}
return requestLogCursorPayload{}, fmt.Errorf("decode request log cursor JSON: %w", err)
}
return payload, nil
}
func rejectDuplicateRequestLogCursorFields(raw []byte) error {
decoder := json.NewDecoder(bytes.NewReader(raw))
token, err := decoder.Token()
if err != nil {
return fmt.Errorf("decode request log cursor JSON: %w", err)
}
if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' {
return fmt.Errorf("request log cursor must be a JSON object")
}
seen := make(map[string]struct{}, 3)
for decoder.More() {
token, err := decoder.Token()
if err != nil {
return fmt.Errorf("decode request log cursor field: %w", err)
}
key, ok := token.(string)
if !ok {
return fmt.Errorf("request log cursor field name is invalid")
}
if _, exists := seen[key]; exists {
return fmt.Errorf("duplicate request log cursor field %q", key)
}
seen[key] = struct{}{}
var value json.RawMessage
if err := decoder.Decode(&value); err != nil {
return fmt.Errorf("decode request log cursor field %q: %w", key, err)
}
}
if _, err := decoder.Token(); err != nil {
return fmt.Errorf("decode request log cursor JSON: %w", err)
}
return nil
}
func errorsIsEOF(err error) bool {
return err == io.EOF
}
func encodeRequestLogCursor(cursor requestlog.Cursor) (string, error) {
completedAt := cursor.CompletedAt.UTC()
if !canonicalLowercaseUUIDv4.MatchString(cursor.RequestID) {
return "", fmt.Errorf("encode request log cursor: invalid request ID")
}
payload := requestLogCursorPayload{
Version: requestLogCursorV1,
CompletedAt: completedAt.Format(time.RFC3339Nano),
RequestID: cursor.RequestID,
}
raw, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("encode request log cursor JSON: %w", err)
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func mapRequestLogListResponse(page requestlog.Page) (requestLogListResponse, error) {
result := requestLogListResponse{
Items: make([]requestLogItemResponse, 0, len(page.Items)),
}
for _, record := range page.Items {
usageCost, err := mapRequestLogUsageCost(record)
if err != nil {
return requestLogListResponse{}, err
}
attempts := make([]requestLogAttemptResponse, 0, len(record.Attempts))
for _, attempt := range record.Attempts {
attempts = append(attempts, requestLogAttemptResponse{
Sequence: attempt.Sequence,
GroupID: attempt.GroupID,
GroupName: attempt.GroupName,
KeyID: attempt.KeyID,
UpstreamModel: attempt.UpstreamModel,
StatusCode: attempt.StatusCode,
DurationMs: attempt.DurationMs,
FailureCategory: attempt.FailureCategory,
Action: attempt.Action,
WillRetry: attempt.WillRetry,
ErrorCode: attempt.ErrorCode,
ErrorSummary: attempt.ErrorSummary,
Committed: attempt.Committed,
})
}
result.Items = append(result.Items, requestLogItemResponse{
RequestID: record.RequestID,
CompletedAt: record.CompletedAt.UTC().Format(time.RFC3339Nano),
AccessKey: requestLogAccessKeyResponse{
ID: record.AccessKey.ID,
Name: record.AccessKey.Name,
Deleted: record.AccessKey.Deleted,
},
Protocol: string(record.Protocol),
ClientModel: record.ClientModel,
UpstreamModel: record.UpstreamModel,
Status: record.Status,
StatusCode: record.StatusCode,
DurationMs: record.DurationMs,
ErrorCode: record.ErrorCode,
ErrorSummary: record.ErrorSummary,
AffinityHit: record.AffinityHit,
Attempts: attempts,
GroupID: usageCost.groupID,
UsageState: record.UsageState,
CostState: record.CostState,
UncachedInputTokens: record.UncachedInputTokens,
CacheReadTokens: record.CacheReadTokens,
CacheWrite5MTokens: record.CacheWrite5MTokens,
CacheWrite1HTokens: record.CacheWrite1HTokens,
OutputTokens: record.OutputTokens,
EstimatedCostUSD: usageCost.estimatedCostUSD,
})
}
if page.NextCursor != nil {
encoded, err := encodeRequestLogCursor(*page.NextCursor)
if err != nil {
return requestLogListResponse{}, err
}
result.NextCursor = &encoded
}
return result, nil
}
type requestLogUsageCostResponse struct {
groupID *uint
estimatedCostUSD json.Number
}
func mapRequestLogUsageCost(record requestlog.Record) (requestLogUsageCostResponse, error) {
if err := requestlog.ValidateUsageCostState(
record.UsageState,
record.CostState,
record.EstimatedCostUSD,
); err != nil {
return requestLogUsageCostResponse{}, fmt.Errorf("map request log usage/cost: %w", err)
}
for _, value := range []int64{
record.UncachedInputTokens,
record.CacheReadTokens,
record.CacheWrite5MTokens,
record.CacheWrite1HTokens,
record.OutputTokens,
} {
if value < 0 || value > maxSafeInteger {
return requestLogUsageCostResponse{}, fmt.Errorf("map request log usage tokens: unsafe value")
}
}
if uint64(record.GroupID) > uint64(maxSafeInteger) {
return requestLogUsageCostResponse{}, fmt.Errorf("map request log final Group ID: unsafe value")
}
cost, err := mapEstimatedCostUSD(record.EstimatedCostUSD)
if err != nil {
return requestLogUsageCostResponse{}, err
}
result := requestLogUsageCostResponse{
estimatedCostUSD: cost,
}
if record.GroupID != 0 {
groupID := record.GroupID
result.groupID = &groupID
}
return result, nil
}