feat(monitor): 完成监控页面请求日志实现
This commit is contained in:
48 files changed
+4664
-1545
No files matched your search
@@ -170,12 +170,12 @@ func TestAccessKeyCollectionHTTPReturnsLatestRequestTimeAndOmitsCollectionScope(
|
||||
{
|
||||
ID: "00000000-0000-4000-8000-000000000001", CompletedAtMS: 1_700_000_000_100,
|
||||
AccessKeyID: used.ID, Protocol: "openai-completions", ClientModel: "gpt-4.1",
|
||||
UpstreamModel: "gpt-4.1", Status: "success", Attempts: models.JSON(`[]`),
|
||||
UpstreamModel: "gpt-4.1", Status: "success",
|
||||
},
|
||||
{
|
||||
ID: "00000000-0000-4000-8000-000000000002", CompletedAtMS: 1_700_000_000_900,
|
||||
AccessKeyID: used.ID, Protocol: "anthropic", ClientModel: "claude-sonnet",
|
||||
UpstreamModel: "claude-sonnet", Status: "error", Attempts: models.JSON(`[]`),
|
||||
UpstreamModel: "claude-sonnet", Status: "error",
|
||||
},
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create request logs: %v", err)
|
||||
|
||||
@@ -114,6 +114,7 @@ func (s *Server) HTTPModule() httproute.Module {
|
||||
),
|
||||
controlRoute("control.health", http.MethodGet, "/health", s.handleRuntimeHealth),
|
||||
controlRoute("control.logs.list", http.MethodGet, "/logs", s.handleListRequestLogs),
|
||||
controlRoute("control.logs.get", http.MethodGet, "/logs/:request_id", s.handleGetRequestLog),
|
||||
controlRoute("control.usage", http.MethodGet, "/usage", s.handleUsage),
|
||||
controlRoute("control.route.inspect", http.MethodPost, "/route/inspect", s.handleRouteInspect),
|
||||
controlRoute("control.settings.get", http.MethodGet, "/settings", s.handleGetSettings),
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
app_errors "gpt-load/internal/platform/errors"
|
||||
"gpt-load/internal/platform/response"
|
||||
"gpt-load/internal/pricing"
|
||||
"gpt-load/internal/protocol"
|
||||
"gpt-load/internal/requestlog"
|
||||
"gpt-load/internal/telemetry"
|
||||
"gpt-load/internal/usage"
|
||||
@@ -34,6 +35,7 @@ var canonicalLowercaseUUIDv4 = regexp.MustCompile(
|
||||
|
||||
type RequestLogReader interface {
|
||||
List(context.Context, requestlog.ListQuery) (requestlog.Page, error)
|
||||
Get(context.Context, string) (requestlog.Record, error)
|
||||
}
|
||||
|
||||
type requestLogCursorPayload struct {
|
||||
@@ -49,19 +51,49 @@ type requestLogAccessKeyResponse struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
PricingReceipt *requestLogPricingReceiptResponse `json:"pricing_receipt"`
|
||||
}
|
||||
|
||||
type requestLogPricingIdentityResponse struct {
|
||||
ScopeKey string `json:"scope_key"`
|
||||
ModelID string `json:"model_id"`
|
||||
}
|
||||
|
||||
type requestLogPricingMultiplierResponse struct {
|
||||
Numerator string `json:"numerator"`
|
||||
Denominator string `json:"denominator"`
|
||||
}
|
||||
|
||||
type requestLogPricingLineResponse struct {
|
||||
Code string `json:"code"`
|
||||
Quantity string `json:"quantity"`
|
||||
RateNanoUSDPerMillion *string `json:"rate_nano_usd_per_million"`
|
||||
Multiplier requestLogPricingMultiplierResponse `json:"multiplier"`
|
||||
State pricing.ReceiptLineState `json:"state"`
|
||||
AmountNanoUSD *string `json:"amount_nano_usd"`
|
||||
}
|
||||
|
||||
type requestLogPricingReceiptResponse struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Method string `json:"method"`
|
||||
MethodVersion int `json:"method_version"`
|
||||
Currency string `json:"currency"`
|
||||
Rule requestLogPricingIdentityResponse `json:"rule"`
|
||||
ContextThresholdTokens *string `json:"context_threshold_tokens"`
|
||||
LineItems []requestLogPricingLineResponse `json:"line_items"`
|
||||
TotalNanoUSD string `json:"total_nano_usd"`
|
||||
}
|
||||
|
||||
type requestLogItemResponse struct {
|
||||
@@ -73,24 +105,31 @@ type requestLogItemResponse struct {
|
||||
UpstreamModel *string `json:"upstream_model"`
|
||||
Status telemetry.RequestStatus `json:"status"`
|
||||
StatusCode int `json:"status_code"`
|
||||
Stream bool `json:"stream"`
|
||||
FirstResponseMs *int64 `json:"first_response_ms"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
AttemptCount int `json:"attempt_count"`
|
||||
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"`
|
||||
PricingCompleteness pricing.Completeness `json:"pricing_completeness"`
|
||||
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"`
|
||||
InputTokens string `json:"input_tokens"`
|
||||
CacheReadTokens string `json:"cache_read_tokens"`
|
||||
CacheWrite5MTokens string `json:"cache_write_5m_tokens"`
|
||||
CacheWrite1HTokens string `json:"cache_write_1h_tokens"`
|
||||
CacheWriteUnknownTokens string `json:"cache_write_unknown_tokens"`
|
||||
OutputTokens string `json:"output_tokens"`
|
||||
EstimatedCostNanoUSD string `json:"estimated_cost_nano_usd"`
|
||||
}
|
||||
|
||||
type requestLogDetailResponse struct {
|
||||
requestLogItemResponse
|
||||
Attempts []requestLogAttemptResponse `json:"attempts"`
|
||||
}
|
||||
|
||||
type requestLogListResponse struct {
|
||||
Items []requestLogItemResponse `json:"items"`
|
||||
NextCursor *string `json:"next_cursor"`
|
||||
@@ -110,6 +149,17 @@ func (service *Service) ListRequestLogs(
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (service *Service) GetRequestLog(ctx context.Context, requestID string) (requestlog.Record, error) {
|
||||
if service.requestLogs == nil {
|
||||
return requestlog.Record{}, app_errors.ErrInternalServer
|
||||
}
|
||||
record, err := service.requestLogs.Get(ctx, requestID)
|
||||
if err != nil {
|
||||
return requestlog.Record{}, app_errors.ParseDBError(err)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleListRequestLogs(c *gin.Context) {
|
||||
query, apiErr := parseRequestLogQuery(c.Request.URL.RawQuery)
|
||||
if apiErr != nil {
|
||||
@@ -129,6 +179,25 @@ func (s *Server) handleListRequestLogs(c *gin.Context) {
|
||||
response.SuccessI18n(c, "common.success", result)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetRequestLog(c *gin.Context) {
|
||||
requestID := c.Param("request_id")
|
||||
if !canonicalLowercaseUUIDv4.MatchString(requestID) {
|
||||
writeServiceError(c, "get_request_log", app_errors.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
record, err := s.service.GetRequestLog(c.Request.Context(), requestID)
|
||||
if err != nil {
|
||||
writeServiceError(c, "get_request_log", err)
|
||||
return
|
||||
}
|
||||
result, err := mapRequestLogDetailResponse(record)
|
||||
if err != nil {
|
||||
writeServiceError(c, "get_request_log", 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 {
|
||||
@@ -136,7 +205,16 @@ func parseRequestLogQuery(rawQuery string) (requestlog.ListQuery, *app_errors.AP
|
||||
}
|
||||
allowed := map[string]struct{}{
|
||||
"from_ms": {}, "to_ms": {}, "group_id": {}, "client_model": {}, "upstream_model": {}, "access_key_id": {},
|
||||
"status": {}, "request_id": {}, "limit": {}, "cursor": {},
|
||||
"status": {}, "request_id": {}, "protocol": {}, "stream": {}, "final_status_code": {},
|
||||
"usage_state": {}, "cost_state": {}, "pricing_completeness": {}, "cache_present": {},
|
||||
"upstream_key_id": {}, "attempt_status_code": {}, "failure_category": {}, "error_code": {},
|
||||
"retry_state": {}, "retry_count_min": {}, "retry_count_max": {},
|
||||
"first_response_min_ms": {}, "first_response_max_ms": {},
|
||||
"duration_min_ms": {}, "duration_max_ms": {},
|
||||
"input_tokens_min": {}, "input_tokens_max": {},
|
||||
"output_tokens_min": {}, "output_tokens_max": {},
|
||||
"cost_min_nano_usd": {}, "cost_max_nano_usd": {},
|
||||
"limit": {}, "cursor": {},
|
||||
}
|
||||
for key, value := range values {
|
||||
if _, ok := allowed[key]; !ok || len(value) != 1 {
|
||||
@@ -170,13 +248,13 @@ func parseRequestLogQuery(rawQuery string) (requestlog.ListQuery, *app_errors.AP
|
||||
query.GroupID = &parsed
|
||||
}
|
||||
if value, ok := singleQueryValue(values, "client_model"); ok {
|
||||
if value == "" {
|
||||
if !validUsageModel(value) {
|
||||
return requestlog.ListQuery{}, app_errors.ErrValidation
|
||||
}
|
||||
query.ClientModel = value
|
||||
}
|
||||
if value, ok := singleQueryValue(values, "upstream_model"); ok {
|
||||
if value == "" {
|
||||
if !validUsageModel(value) {
|
||||
return requestlog.ListQuery{}, app_errors.ErrValidation
|
||||
}
|
||||
query.UpstreamModel = value
|
||||
@@ -188,6 +266,27 @@ func parseRequestLogQuery(rawQuery string) (requestlog.ListQuery, *app_errors.AP
|
||||
}
|
||||
query.AccessKeyID = &parsed
|
||||
}
|
||||
if value, ok := singleQueryValue(values, "protocol"); ok {
|
||||
parsed := protocol.Protocol(value)
|
||||
if !parsed.Valid() {
|
||||
return requestlog.ListQuery{}, app_errors.ErrValidation
|
||||
}
|
||||
query.Protocol = parsed
|
||||
}
|
||||
if value, ok := singleQueryValue(values, "stream"); ok {
|
||||
parsed, valid := parseRequestLogBool(value)
|
||||
if !valid {
|
||||
return requestlog.ListQuery{}, app_errors.ErrValidation
|
||||
}
|
||||
query.Stream = &parsed
|
||||
}
|
||||
if value, ok := singleQueryValue(values, "final_status_code"); ok {
|
||||
parsed, apiErr := parseRequestLogStatusCode(value)
|
||||
if apiErr != nil {
|
||||
return requestlog.ListQuery{}, apiErr
|
||||
}
|
||||
query.FinalStatusCode = &parsed
|
||||
}
|
||||
if value, ok := singleQueryValue(values, "status"); ok {
|
||||
status := telemetry.RequestStatus(value)
|
||||
switch status {
|
||||
@@ -200,6 +299,119 @@ func parseRequestLogQuery(rawQuery string) (requestlog.ListQuery, *app_errors.AP
|
||||
return requestlog.ListQuery{}, app_errors.ErrValidation
|
||||
}
|
||||
}
|
||||
if value, ok := singleQueryValue(values, "usage_state"); ok {
|
||||
state := usage.State(value)
|
||||
switch state {
|
||||
case usage.StateComplete, usage.StatePartial, usage.StateMissing, usage.StateNotApplicable:
|
||||
query.UsageState = state
|
||||
default:
|
||||
return requestlog.ListQuery{}, app_errors.ErrValidation
|
||||
}
|
||||
}
|
||||
if value, ok := singleQueryValue(values, "cost_state"); ok {
|
||||
state := pricing.CostState(value)
|
||||
switch state {
|
||||
case pricing.CostStatePriced, pricing.CostStateUnpriced, pricing.CostStateNotApplicable:
|
||||
query.CostState = state
|
||||
default:
|
||||
return requestlog.ListQuery{}, app_errors.ErrValidation
|
||||
}
|
||||
}
|
||||
if value, ok := singleQueryValue(values, "pricing_completeness"); ok {
|
||||
completeness := pricing.Completeness(value)
|
||||
switch completeness {
|
||||
case pricing.CompletenessComplete,
|
||||
pricing.CompletenessPartial,
|
||||
pricing.CompletenessUnavailable,
|
||||
pricing.CompletenessNotApplicable:
|
||||
query.PricingCompleteness = completeness
|
||||
default:
|
||||
return requestlog.ListQuery{}, app_errors.ErrValidation
|
||||
}
|
||||
}
|
||||
if value, ok := singleQueryValue(values, "cache_present"); ok {
|
||||
parsed, valid := parseRequestLogBool(value)
|
||||
if !valid {
|
||||
return requestlog.ListQuery{}, app_errors.ErrValidation
|
||||
}
|
||||
query.CachePresent = &parsed
|
||||
}
|
||||
if value, ok := singleQueryValue(values, "upstream_key_id"); ok {
|
||||
parsed, apiErr := parseRequestLogID(value)
|
||||
if apiErr != nil {
|
||||
return requestlog.ListQuery{}, apiErr
|
||||
}
|
||||
query.UpstreamKeyID = &parsed
|
||||
}
|
||||
if value, ok := singleQueryValue(values, "attempt_status_code"); ok {
|
||||
parsed, apiErr := parseRequestLogStatusCode(value)
|
||||
if apiErr != nil {
|
||||
return requestlog.ListQuery{}, apiErr
|
||||
}
|
||||
query.AttemptStatusCode = &parsed
|
||||
}
|
||||
if value, ok := singleQueryValue(values, "failure_category"); ok {
|
||||
category := telemetry.FailureCategory(value)
|
||||
switch category {
|
||||
case telemetry.FailureCategoryOK,
|
||||
telemetry.FailureCategoryRateLimited,
|
||||
telemetry.FailureCategoryModelUnavailable,
|
||||
telemetry.FailureCategoryInvalidKey,
|
||||
telemetry.FailureCategoryUpstreamHost,
|
||||
telemetry.FailureCategoryClientError,
|
||||
telemetry.FailureCategoryDownstreamCancel,
|
||||
telemetry.FailureCategoryAmbiguous:
|
||||
query.FailureCategory = category
|
||||
default:
|
||||
return requestlog.ListQuery{}, app_errors.ErrValidation
|
||||
}
|
||||
}
|
||||
if value, ok := singleQueryValue(values, "error_code"); ok {
|
||||
if !validUsageModel(value) {
|
||||
return requestlog.ListQuery{}, app_errors.ErrValidation
|
||||
}
|
||||
query.AttemptErrorCode = value
|
||||
}
|
||||
if value, ok := singleQueryValue(values, "retry_state"); ok {
|
||||
state := requestlog.RetryState(value)
|
||||
switch state {
|
||||
case requestlog.RetryStateRetried, requestlog.RetryStateNotRetried:
|
||||
query.RetryState = state
|
||||
default:
|
||||
return requestlog.ListQuery{}, app_errors.ErrValidation
|
||||
}
|
||||
}
|
||||
if apiErr := parseRequestLogIntRange(
|
||||
values,
|
||||
"retry_count_min",
|
||||
"retry_count_max",
|
||||
&query.RetryCountMin,
|
||||
&query.RetryCountMax,
|
||||
); apiErr != nil {
|
||||
return requestlog.ListQuery{}, apiErr
|
||||
}
|
||||
for _, target := range []struct {
|
||||
minimumKey string
|
||||
maximumKey string
|
||||
minimum **int64
|
||||
maximum **int64
|
||||
}{
|
||||
{"first_response_min_ms", "first_response_max_ms", &query.FirstResponseMinMS, &query.FirstResponseMaxMS},
|
||||
{"duration_min_ms", "duration_max_ms", &query.DurationMinMS, &query.DurationMaxMS},
|
||||
{"input_tokens_min", "input_tokens_max", &query.InputTokensMin, &query.InputTokensMax},
|
||||
{"output_tokens_min", "output_tokens_max", &query.OutputTokensMin, &query.OutputTokensMax},
|
||||
{"cost_min_nano_usd", "cost_max_nano_usd", &query.CostMinNanoUSD, &query.CostMaxNanoUSD},
|
||||
} {
|
||||
if apiErr := parseRequestLogInt64Range(
|
||||
values,
|
||||
target.minimumKey,
|
||||
target.maximumKey,
|
||||
target.minimum,
|
||||
target.maximum,
|
||||
); apiErr != nil {
|
||||
return requestlog.ListQuery{}, apiErr
|
||||
}
|
||||
}
|
||||
if value, ok := singleQueryValue(values, "request_id"); ok {
|
||||
if !canonicalLowercaseUUIDv4.MatchString(value) {
|
||||
return requestlog.ListQuery{}, app_errors.ErrBadRequest
|
||||
@@ -251,6 +463,98 @@ func parseRequestLogID(value string) (uint, *app_errors.APIError) {
|
||||
return uint(parsed), nil
|
||||
}
|
||||
|
||||
func parseRequestLogBool(value string) (bool, bool) {
|
||||
switch value {
|
||||
case "true":
|
||||
return true, true
|
||||
case "false":
|
||||
return false, true
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
func parseRequestLogStatusCode(value string) (int, *app_errors.APIError) {
|
||||
parsed, err := parseCanonicalNonNegativeInt64(value)
|
||||
if err != nil {
|
||||
return 0, app_errors.ErrBadRequest
|
||||
}
|
||||
if parsed > 999 {
|
||||
return 0, app_errors.ErrValidation
|
||||
}
|
||||
return int(parsed), nil
|
||||
}
|
||||
|
||||
func parseRequestLogIntRange(
|
||||
values url.Values,
|
||||
minimumKey string,
|
||||
maximumKey string,
|
||||
minimum **int,
|
||||
maximum **int,
|
||||
) *app_errors.APIError {
|
||||
var parsedMinimum *int64
|
||||
var parsedMaximum *int64
|
||||
if apiErr := parseRequestLogInt64Range(
|
||||
values,
|
||||
minimumKey,
|
||||
maximumKey,
|
||||
&parsedMinimum,
|
||||
&parsedMaximum,
|
||||
); apiErr != nil {
|
||||
return apiErr
|
||||
}
|
||||
if parsedMinimum != nil {
|
||||
value := int(*parsedMinimum)
|
||||
if int64(value) != *parsedMinimum {
|
||||
return app_errors.ErrValidation
|
||||
}
|
||||
*minimum = &value
|
||||
}
|
||||
if parsedMaximum != nil {
|
||||
value := int(*parsedMaximum)
|
||||
if int64(value) != *parsedMaximum {
|
||||
return app_errors.ErrValidation
|
||||
}
|
||||
*maximum = &value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseRequestLogInt64Range(
|
||||
values url.Values,
|
||||
minimumKey string,
|
||||
maximumKey string,
|
||||
minimum **int64,
|
||||
maximum **int64,
|
||||
) *app_errors.APIError {
|
||||
if value, ok := singleQueryValue(values, minimumKey); ok {
|
||||
parsed, err := parseCanonicalNonNegativeInt64(value)
|
||||
if err != nil {
|
||||
return app_errors.ErrBadRequest
|
||||
}
|
||||
*minimum = &parsed
|
||||
}
|
||||
if value, ok := singleQueryValue(values, maximumKey); ok {
|
||||
parsed, err := parseCanonicalNonNegativeInt64(value)
|
||||
if err != nil {
|
||||
return app_errors.ErrBadRequest
|
||||
}
|
||||
*maximum = &parsed
|
||||
}
|
||||
if *minimum != nil && *maximum != nil && **minimum > **maximum {
|
||||
return app_errors.ErrValidation
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseCanonicalNonNegativeInt64(value string) (int64, error) {
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || parsed < 0 || strconv.FormatInt(parsed, 10) != value {
|
||||
return 0, fmt.Errorf("value must be a canonical non-negative integer")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func decodeRequestLogCursor(encoded string) (*requestlog.Cursor, error) {
|
||||
raw, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
@@ -370,54 +674,11 @@ func mapRequestLogListResponse(page requestlog.Page) (requestLogListResponse, er
|
||||
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: nullableRequestLogModel(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,
|
||||
})
|
||||
item, err := mapRequestLogItemResponse(record, usageCost)
|
||||
if err != nil {
|
||||
return requestLogListResponse{}, err
|
||||
}
|
||||
result.Items = append(result.Items, requestLogItemResponse{
|
||||
RequestID: record.RequestID,
|
||||
CompletedAtMS: record.CompletedAtMS,
|
||||
AccessKey: requestLogAccessKeyResponse{
|
||||
ID: record.AccessKey.ID,
|
||||
Name: record.AccessKey.Name,
|
||||
Deleted: record.AccessKey.Deleted,
|
||||
},
|
||||
Protocol: string(record.Protocol),
|
||||
ClientModel: nullableRequestLogModel(record.ClientModel),
|
||||
UpstreamModel: nullableRequestLogModel(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,
|
||||
PricingCompleteness: record.PricingCompleteness,
|
||||
UncachedInputTokens: record.UncachedInputTokens,
|
||||
CacheReadTokens: record.CacheReadTokens,
|
||||
CacheWrite5MTokens: record.CacheWrite5MTokens,
|
||||
CacheWrite1HTokens: record.CacheWrite1HTokens,
|
||||
CacheWriteUnknownTokens: record.CacheWriteUnknownTokens,
|
||||
OutputTokens: record.OutputTokens,
|
||||
EstimatedCostNanoUSD: usageCost.estimatedCostNanoUSD,
|
||||
})
|
||||
result.Items = append(result.Items, item)
|
||||
}
|
||||
if page.NextCursor != nil {
|
||||
encoded, err := encodeRequestLogCursor(*page.NextCursor)
|
||||
@@ -429,6 +690,148 @@ func mapRequestLogListResponse(page requestlog.Page) (requestLogListResponse, er
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func mapRequestLogItemResponse(
|
||||
record requestlog.Record,
|
||||
usageCost requestLogUsageCostResponse,
|
||||
) (requestLogItemResponse, error) {
|
||||
inputTokens, ok := checkedRequestLogInputTokens(record)
|
||||
if !ok {
|
||||
return requestLogItemResponse{}, fmt.Errorf("map request log input tokens: overflow")
|
||||
}
|
||||
return requestLogItemResponse{
|
||||
RequestID: record.RequestID,
|
||||
CompletedAtMS: record.CompletedAtMS,
|
||||
AccessKey: requestLogAccessKeyResponse{
|
||||
ID: record.AccessKey.ID,
|
||||
Name: record.AccessKey.Name,
|
||||
Deleted: record.AccessKey.Deleted,
|
||||
},
|
||||
Protocol: string(record.Protocol),
|
||||
ClientModel: nullableRequestLogModel(record.ClientModel),
|
||||
UpstreamModel: nullableRequestLogModel(record.UpstreamModel),
|
||||
Status: record.Status,
|
||||
StatusCode: record.StatusCode,
|
||||
Stream: record.Stream,
|
||||
FirstResponseMs: record.FirstResponseMs,
|
||||
DurationMs: record.DurationMs,
|
||||
AttemptCount: record.AttemptCount,
|
||||
ErrorCode: record.ErrorCode,
|
||||
ErrorSummary: record.ErrorSummary,
|
||||
AffinityHit: record.AffinityHit,
|
||||
GroupID: usageCost.groupID,
|
||||
UsageState: record.UsageState,
|
||||
CostState: record.CostState,
|
||||
PricingCompleteness: record.PricingCompleteness,
|
||||
InputTokens: strconv.FormatInt(inputTokens, 10),
|
||||
CacheReadTokens: strconv.FormatInt(record.CacheReadTokens, 10),
|
||||
CacheWrite5MTokens: strconv.FormatInt(record.CacheWrite5MTokens, 10),
|
||||
CacheWrite1HTokens: strconv.FormatInt(record.CacheWrite1HTokens, 10),
|
||||
CacheWriteUnknownTokens: strconv.FormatInt(record.CacheWriteUnknownTokens, 10),
|
||||
OutputTokens: strconv.FormatInt(record.OutputTokens, 10),
|
||||
EstimatedCostNanoUSD: usageCost.estimatedCostNanoUSD,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func checkedRequestLogInputTokens(record requestlog.Record) (int64, bool) {
|
||||
total := int64(0)
|
||||
for _, value := range []int64{
|
||||
record.UncachedInputTokens,
|
||||
record.CacheReadTokens,
|
||||
record.CacheWrite5MTokens,
|
||||
record.CacheWrite1HTokens,
|
||||
record.CacheWriteUnknownTokens,
|
||||
} {
|
||||
var ok bool
|
||||
total, ok = usage.CheckedAdd(total, value)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
return total, true
|
||||
}
|
||||
|
||||
func mapRequestLogDetailResponse(record requestlog.Record) (requestLogDetailResponse, error) {
|
||||
usageCost, err := mapRequestLogUsageCost(record)
|
||||
if err != nil {
|
||||
return requestLogDetailResponse{}, err
|
||||
}
|
||||
item, err := mapRequestLogItemResponse(record, usageCost)
|
||||
if err != nil {
|
||||
return requestLogDetailResponse{}, err
|
||||
}
|
||||
attempts := make([]requestLogAttemptResponse, 0, len(record.Attempts))
|
||||
for _, attempt := range record.Attempts {
|
||||
receipt, err := mapRequestLogPricingReceipt(attempt.PricingReceipt)
|
||||
if err != nil {
|
||||
return requestLogDetailResponse{}, err
|
||||
}
|
||||
attempts = append(attempts, requestLogAttemptResponse{
|
||||
Sequence: attempt.Sequence,
|
||||
GroupID: attempt.GroupID,
|
||||
GroupName: attempt.GroupName,
|
||||
KeyID: attempt.KeyID,
|
||||
UpstreamModel: nullableRequestLogModel(attempt.UpstreamModel),
|
||||
StatusCode: attempt.StatusCode,
|
||||
DurationMs: attempt.DurationMs,
|
||||
FailureCategory: attempt.FailureCategory,
|
||||
Action: attempt.Action,
|
||||
WillRetry: attempt.WillRetry,
|
||||
ErrorCode: attempt.ErrorCode,
|
||||
ErrorSummary: attempt.ErrorSummary,
|
||||
PricingReceipt: receipt,
|
||||
})
|
||||
}
|
||||
return requestLogDetailResponse{requestLogItemResponse: item, Attempts: attempts}, nil
|
||||
}
|
||||
|
||||
func mapRequestLogPricingReceipt(
|
||||
receipt *pricing.Receipt,
|
||||
) (*requestLogPricingReceiptResponse, error) {
|
||||
if receipt == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if err := pricing.ValidateReceipt(*receipt); err != nil {
|
||||
return nil, fmt.Errorf("map request log pricing receipt: %w", err)
|
||||
}
|
||||
result := &requestLogPricingReceiptResponse{
|
||||
SchemaVersion: receipt.SchemaVersion,
|
||||
Method: receipt.Method,
|
||||
MethodVersion: receipt.MethodVersion,
|
||||
Currency: receipt.Currency,
|
||||
Rule: requestLogPricingIdentityResponse{
|
||||
ScopeKey: receipt.Rule.ScopeKey,
|
||||
ModelID: receipt.Rule.ModelID,
|
||||
},
|
||||
LineItems: make([]requestLogPricingLineResponse, 0, len(receipt.LineItems)),
|
||||
TotalNanoUSD: strconv.FormatInt(receipt.TotalNanoUSD, 10),
|
||||
}
|
||||
if receipt.ContextThresholdTokens != nil {
|
||||
value := strconv.FormatInt(*receipt.ContextThresholdTokens, 10)
|
||||
result.ContextThresholdTokens = &value
|
||||
}
|
||||
for _, line := range receipt.LineItems {
|
||||
mapped := requestLogPricingLineResponse{
|
||||
Code: line.Code,
|
||||
Quantity: strconv.FormatInt(line.Quantity, 10),
|
||||
Multiplier: requestLogPricingMultiplierResponse{
|
||||
Numerator: strconv.FormatInt(line.Multiplier.Numerator, 10),
|
||||
Denominator: strconv.FormatInt(line.Multiplier.Denominator, 10),
|
||||
},
|
||||
State: line.State,
|
||||
}
|
||||
if line.RateNanoUSDPerMillion != nil {
|
||||
value := strconv.FormatInt(*line.RateNanoUSDPerMillion, 10)
|
||||
mapped.RateNanoUSDPerMillion = &value
|
||||
}
|
||||
if line.AmountNanoUSD != nil {
|
||||
value := strconv.FormatInt(*line.AmountNanoUSD, 10)
|
||||
mapped.AmountNanoUSD = &value
|
||||
}
|
||||
result.LineItems = append(result.LineItems, mapped)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func nullableRequestLogModel(value string) *string {
|
||||
if value == "" {
|
||||
return nil
|
||||
@@ -458,8 +861,8 @@ func mapRequestLogUsageCost(record requestlog.Record) (requestLogUsageCostRespon
|
||||
record.CacheWriteUnknownTokens,
|
||||
record.OutputTokens,
|
||||
} {
|
||||
if value < 0 || value > maxSafeInteger {
|
||||
return requestLogUsageCostResponse{}, fmt.Errorf("map request log usage tokens: unsafe value")
|
||||
if value < 0 {
|
||||
return requestLogUsageCostResponse{}, fmt.Errorf("map request log usage tokens: negative value")
|
||||
}
|
||||
}
|
||||
if _, ok := usage.CheckedTotal(usage.Tokens{
|
||||
|
||||
@@ -141,6 +141,107 @@ func TestRequestLogEndpointAcceptsCanonicalNumericBoundaries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestLogEndpointParsesAdvancedFilters(t *testing.T) {
|
||||
reader := &recordingRequestLogReader{}
|
||||
engine := newRequestLogTestEngine(t, reader)
|
||||
query := strings.Join([]string{
|
||||
"protocol=anthropic",
|
||||
"stream=true",
|
||||
"final_status_code=529",
|
||||
"usage_state=partial",
|
||||
"cost_state=priced",
|
||||
"pricing_completeness=partial",
|
||||
"cache_present=true",
|
||||
"upstream_key_id=9",
|
||||
"attempt_status_code=429",
|
||||
"failure_category=rate_limited",
|
||||
"error_code=provider_rate_limit",
|
||||
"retry_state=retried",
|
||||
"retry_count_min=1",
|
||||
"retry_count_max=3",
|
||||
"first_response_min_ms=10",
|
||||
"first_response_max_ms=900",
|
||||
"duration_min_ms=20",
|
||||
"duration_max_ms=2000",
|
||||
"input_tokens_min=100",
|
||||
"input_tokens_max=2000",
|
||||
"output_tokens_min=10",
|
||||
"output_tokens_max=500",
|
||||
"cost_min_nano_usd=1000",
|
||||
"cost_max_nano_usd=9000000",
|
||||
}, "&")
|
||||
recorder := performRequestLogRequest(engine, "test-auth-key", query)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("response = %d %s, want 200", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if len(reader.queries) != 1 {
|
||||
t.Fatalf("Reader calls = %d, want one", len(reader.queries))
|
||||
}
|
||||
got := reader.queries[0]
|
||||
if got.Protocol != protocol.Anthropic || got.Stream == nil || !*got.Stream ||
|
||||
got.FinalStatusCode == nil || *got.FinalStatusCode != 529 ||
|
||||
got.UsageState != usage.StatePartial || got.CostState != pricing.CostStatePriced ||
|
||||
got.PricingCompleteness != pricing.CompletenessPartial ||
|
||||
got.CachePresent == nil || !*got.CachePresent ||
|
||||
got.UpstreamKeyID == nil || *got.UpstreamKeyID != 9 ||
|
||||
got.AttemptStatusCode == nil || *got.AttemptStatusCode != 429 ||
|
||||
got.FailureCategory != telemetry.FailureCategoryRateLimited ||
|
||||
got.AttemptErrorCode != "provider_rate_limit" || got.RetryState != requestlog.RetryStateRetried ||
|
||||
got.RetryCountMin == nil || *got.RetryCountMin != 1 ||
|
||||
got.RetryCountMax == nil || *got.RetryCountMax != 3 ||
|
||||
got.FirstResponseMinMS == nil || *got.FirstResponseMinMS != 10 ||
|
||||
got.FirstResponseMaxMS == nil || *got.FirstResponseMaxMS != 900 ||
|
||||
got.DurationMinMS == nil || *got.DurationMinMS != 20 ||
|
||||
got.DurationMaxMS == nil || *got.DurationMaxMS != 2000 ||
|
||||
got.InputTokensMin == nil || *got.InputTokensMin != 100 ||
|
||||
got.InputTokensMax == nil || *got.InputTokensMax != 2000 ||
|
||||
got.OutputTokensMin == nil || *got.OutputTokensMin != 10 ||
|
||||
got.OutputTokensMax == nil || *got.OutputTokensMax != 500 ||
|
||||
got.CostMinNanoUSD == nil || *got.CostMinNanoUSD != 1000 ||
|
||||
got.CostMaxNanoUSD == nil || *got.CostMaxNanoUSD != 9000000 {
|
||||
t.Fatalf("parsed advanced query = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestLogEndpointRejectsInvalidAdvancedFilters(t *testing.T) {
|
||||
tests := []string{
|
||||
"protocol=openai",
|
||||
"stream=1",
|
||||
"final_status_code=1000",
|
||||
"usage_state=unknown",
|
||||
"cost_state=unknown",
|
||||
"pricing_completeness=unknown",
|
||||
"cache_present=yes",
|
||||
"upstream_key_id=0",
|
||||
"attempt_status_code=-1",
|
||||
"failure_category=unknown",
|
||||
"error_code=",
|
||||
"retry_state=unknown",
|
||||
"retry_count_min=4&retry_count_max=3",
|
||||
"first_response_min_ms=20&first_response_max_ms=10",
|
||||
"duration_min_ms=20&duration_max_ms=10",
|
||||
"input_tokens_min=20&input_tokens_max=10",
|
||||
"output_tokens_min=20&output_tokens_max=10",
|
||||
"cost_min_nano_usd=20&cost_max_nano_usd=10",
|
||||
}
|
||||
for _, query := range tests {
|
||||
t.Run(query, func(t *testing.T) {
|
||||
reader := &recordingRequestLogReader{}
|
||||
recorder := performRequestLogRequest(
|
||||
newRequestLogTestEngine(t, reader),
|
||||
"test-auth-key",
|
||||
query,
|
||||
)
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("response = %d %s, want 400", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if len(reader.queries) != 0 {
|
||||
t.Fatalf("Reader calls = %d, want zero", len(reader.queries))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestLogEndpointReturnsOpaqueCursorAndSafeDTO(t *testing.T) {
|
||||
completedAt := time.Date(2026, time.July, 24, 12, 0, 0, 123456789, time.UTC)
|
||||
completedAtMS := completedAt.UnixMilli()
|
||||
@@ -168,18 +269,7 @@ func TestRequestLogEndpointReturnsOpaqueCursorAndSafeDTO(t *testing.T) {
|
||||
UsageState: usage.StateNotApplicable,
|
||||
CostState: pricing.CostStateNotApplicable,
|
||||
PricingCompleteness: pricing.CompletenessNotApplicable,
|
||||
Attempts: []requestlog.Attempt{{
|
||||
Sequence: 1,
|
||||
GroupID: 12,
|
||||
GroupName: "Primary",
|
||||
KeyID: 11,
|
||||
UpstreamModel: "upstream-model",
|
||||
StatusCode: 200,
|
||||
DurationMs: 1234,
|
||||
FailureCategory: telemetry.FailureCategoryOK,
|
||||
Action: telemetry.ActionTerminate,
|
||||
Committed: true,
|
||||
}},
|
||||
AttemptCount: 1,
|
||||
}},
|
||||
NextCursor: nextCursor,
|
||||
},
|
||||
@@ -232,16 +322,8 @@ func TestRequestLogEndpointReturnsOpaqueCursorAndSafeDTO(t *testing.T) {
|
||||
envelope.Data.NextCursor == nil || *envelope.Data.NextCursor == "" {
|
||||
t.Fatalf("first response envelope = %#v", envelope)
|
||||
}
|
||||
attempts, ok := envelope.Data.Items[0]["attempts"].([]any)
|
||||
if !ok || len(attempts) != 1 {
|
||||
t.Fatalf("attempts = %#v, want one", envelope.Data.Items[0]["attempts"])
|
||||
}
|
||||
attempt, ok := attempts[0].(map[string]any)
|
||||
if !ok || attempt["key_id"] != float64(11) {
|
||||
t.Fatalf("attempt = %#v, want key_id 11", attempts[0])
|
||||
}
|
||||
if _, exists := attempt["key_mask"]; exists {
|
||||
t.Fatalf("attempt exposes key_mask: %#v", attempt)
|
||||
if _, exists := envelope.Data.Items[0]["attempts"]; exists {
|
||||
t.Fatalf("list item unexpectedly exposes attempts: %#v", envelope.Data.Items[0])
|
||||
}
|
||||
for _, forbidden := range []string{"headers", "body", "url"} {
|
||||
if strings.Contains(strings.ToLower(recorder.Body.String()), forbidden) {
|
||||
@@ -291,6 +373,94 @@ func TestRequestLogEndpointReturnsOpaqueCursorAndSafeDTO(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestLogDetailEndpointReturnsAttemptsAndFrozenPricingReceipt(t *testing.T) {
|
||||
requestID := "00000000-0000-4000-8000-000000000611"
|
||||
rate := int64(1_000_000_000)
|
||||
amount := int64(1_000_000)
|
||||
receipt := &pricing.Receipt{
|
||||
SchemaVersion: 1,
|
||||
Method: pricing.ReceiptMethodUnitRateSum,
|
||||
MethodVersion: 1,
|
||||
Currency: "USD",
|
||||
Rule: pricing.Identity{ScopeKey: "group:12", ModelID: "gpt-4.1"},
|
||||
LineItems: []pricing.ReceiptLine{{
|
||||
Code: "input",
|
||||
Quantity: 1000,
|
||||
RateNanoUSDPerMillion: &rate,
|
||||
Multiplier: pricing.Multiplier{Numerator: 1, Denominator: 1},
|
||||
State: pricing.ReceiptLinePriced,
|
||||
AmountNanoUSD: &amount,
|
||||
}},
|
||||
TotalNanoUSD: amount,
|
||||
}
|
||||
reader := &recordingRequestLogReader{details: map[string]requestlog.Record{
|
||||
requestID: {
|
||||
RequestID: requestID,
|
||||
CompletedAtMS: 1_784_894_400_000,
|
||||
Protocol: protocol.OpenAICompletions,
|
||||
Status: telemetry.RequestStatusSuccess,
|
||||
StatusCode: http.StatusOK,
|
||||
AttemptCount: 1,
|
||||
UsageState: usage.StateComplete,
|
||||
CostState: pricing.CostStatePriced,
|
||||
PricingCompleteness: pricing.CompletenessComplete,
|
||||
UncachedInputTokens: 1000,
|
||||
EstimatedCostNanoUSD: amount,
|
||||
Attempts: []requestlog.Attempt{{
|
||||
Sequence: 1,
|
||||
GroupID: 12,
|
||||
GroupName: "Primary",
|
||||
KeyID: 99,
|
||||
UpstreamModel: "gpt-4.1",
|
||||
StatusCode: http.StatusOK,
|
||||
DurationMs: 1200,
|
||||
FailureCategory: telemetry.FailureCategoryOK,
|
||||
Action: telemetry.ActionTerminate,
|
||||
PricingReceipt: receipt,
|
||||
}},
|
||||
},
|
||||
}}
|
||||
engine := newRequestLogTestEngine(t, reader)
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/logs/"+requestID, nil)
|
||||
request.Header.Set("Authorization", "Bearer test-auth-key")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("response = %d %s, want 200", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
RequestID string `json:"request_id"`
|
||||
Attempts []struct {
|
||||
KeyID uint `json:"key_id"`
|
||||
PricingReceipt struct {
|
||||
Method string `json:"method"`
|
||||
TotalNanoUSD string `json:"total_nano_usd"`
|
||||
LineItems []struct {
|
||||
Quantity string `json:"quantity"`
|
||||
AmountNanoUSD string `json:"amount_nano_usd"`
|
||||
} `json:"line_items"`
|
||||
} `json:"pricing_receipt"`
|
||||
} `json:"attempts"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if envelope.Data.RequestID != requestID || len(envelope.Data.Attempts) != 1 ||
|
||||
envelope.Data.Attempts[0].KeyID != 99 ||
|
||||
envelope.Data.Attempts[0].PricingReceipt.Method != pricing.ReceiptMethodUnitRateSum ||
|
||||
envelope.Data.Attempts[0].PricingReceipt.TotalNanoUSD != "1000000" ||
|
||||
len(envelope.Data.Attempts[0].PricingReceipt.LineItems) != 1 ||
|
||||
envelope.Data.Attempts[0].PricingReceipt.LineItems[0].Quantity != "1000" ||
|
||||
envelope.Data.Attempts[0].PricingReceipt.LineItems[0].AmountNanoUSD != "1000000" {
|
||||
t.Fatalf("detail projection = %#v", envelope.Data)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(recorder.Body.String()), "committed") {
|
||||
t.Fatalf("detail exposes internal committed flag: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestLogEndpointProjectsUsageCostAndNullGroupZero(t *testing.T) {
|
||||
reader := &recordingRequestLogReader{pages: []requestlog.Page{{Items: []requestlog.Record{
|
||||
{
|
||||
@@ -307,9 +477,8 @@ func TestRequestLogEndpointProjectsUsageCostAndNullGroupZero(t *testing.T) {
|
||||
CacheWrite5MTokens: 3,
|
||||
CacheWrite1HTokens: 4,
|
||||
CacheWriteUnknownTokens: 6,
|
||||
OutputTokens: 5,
|
||||
OutputTokens: maxSafeInteger + 1,
|
||||
EstimatedCostNanoUSD: 123_456_789_012,
|
||||
Attempts: []requestlog.Attempt{},
|
||||
},
|
||||
}}}}
|
||||
recorder := performRequestLogRequest(newRequestLogTestEngine(t, reader), "test-auth-key", "")
|
||||
@@ -323,12 +492,12 @@ func TestRequestLogEndpointProjectsUsageCostAndNullGroupZero(t *testing.T) {
|
||||
UsageState string `json:"usage_state"`
|
||||
CostState string `json:"cost_state"`
|
||||
PricingCompleteness string `json:"pricing_completeness"`
|
||||
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"`
|
||||
InputTokens string `json:"input_tokens"`
|
||||
CacheReadTokens string `json:"cache_read_tokens"`
|
||||
CacheWrite5MTokens string `json:"cache_write_5m_tokens"`
|
||||
CacheWrite1HTokens string `json:"cache_write_1h_tokens"`
|
||||
CacheWriteUnknownTokens string `json:"cache_write_unknown_tokens"`
|
||||
OutputTokens string `json:"output_tokens"`
|
||||
EstimatedCostNanoUSD string `json:"estimated_cost_nano_usd"`
|
||||
} `json:"items"`
|
||||
} `json:"data"`
|
||||
@@ -342,10 +511,10 @@ func TestRequestLogEndpointProjectsUsageCostAndNullGroupZero(t *testing.T) {
|
||||
item := envelope.Data.Items[0]
|
||||
if item.GroupID != nil || item.UsageState != "complete" || item.CostState != "priced" ||
|
||||
item.PricingCompleteness != "complete" ||
|
||||
item.UncachedInputTokens != 1 || item.CacheReadTokens != 2 ||
|
||||
item.CacheWrite5MTokens != 3 || item.CacheWrite1HTokens != 4 ||
|
||||
item.CacheWriteUnknownTokens != 6 ||
|
||||
item.OutputTokens != 5 || item.EstimatedCostNanoUSD != "123456789012" {
|
||||
item.InputTokens != "16" || item.CacheReadTokens != "2" ||
|
||||
item.CacheWrite5MTokens != "3" || item.CacheWrite1HTokens != "4" ||
|
||||
item.CacheWriteUnknownTokens != "6" ||
|
||||
item.OutputTokens != "9007199254740992" || item.EstimatedCostNanoUSD != "123456789012" {
|
||||
t.Fatalf("usage/cost projection = %#v", item)
|
||||
}
|
||||
}
|
||||
@@ -360,17 +529,6 @@ func TestRequestLogResponseUsesNullModelsForProtocolOnlyResponsesResources(t *te
|
||||
UsageState: usage.StateNotApplicable,
|
||||
CostState: pricing.CostStateNotApplicable,
|
||||
PricingCompleteness: pricing.CompletenessNotApplicable,
|
||||
Attempts: []requestlog.Attempt{{
|
||||
Sequence: 1,
|
||||
GroupID: 12,
|
||||
GroupName: "Primary",
|
||||
KeyID: 11,
|
||||
UpstreamModel: "",
|
||||
StatusCode: http.StatusOK,
|
||||
FailureCategory: telemetry.FailureCategoryOK,
|
||||
Action: telemetry.ActionTerminate,
|
||||
Committed: true,
|
||||
}},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -399,7 +557,6 @@ func TestRequestLogUsageCostProjectionRejectsUnsafeValues(t *testing.T) {
|
||||
{name: "usage state", mutate: func(record *requestlog.Record) { record.UsageState = "invalid" }},
|
||||
{name: "cost state", mutate: func(record *requestlog.Record) { record.CostState = "invalid" }},
|
||||
{name: "negative token", mutate: func(record *requestlog.Record) { record.OutputTokens = -1 }},
|
||||
{name: "unsafe token", mutate: func(record *requestlog.Record) { record.OutputTokens = maxSafeInteger + 1 }},
|
||||
{name: "negative cost", mutate: func(record *requestlog.Record) { record.EstimatedCostNanoUSD = -1 }},
|
||||
{
|
||||
name: "missing usage cannot be priced",
|
||||
@@ -503,7 +660,6 @@ func TestRequestLogEndpointFailsClosedOnCorruptReaderUsageCost(t *testing.T) {
|
||||
CompletedAtMS: time.Date(2026, time.July, 27, 0, 0, 0, 0, time.UTC).UnixMilli(),
|
||||
UsageState: usage.StateComplete,
|
||||
CostState: pricing.CostStatePriced,
|
||||
Attempts: []requestlog.Attempt{},
|
||||
}
|
||||
test.mutate(&record)
|
||||
reader := &recordingRequestLogReader{
|
||||
@@ -540,6 +696,8 @@ type recordingRequestLogReader struct {
|
||||
pages []requestlog.Page
|
||||
queries []requestlog.ListQuery
|
||||
err error
|
||||
details map[string]requestlog.Record
|
||||
getErr error
|
||||
}
|
||||
|
||||
func (reader *recordingRequestLogReader) List(
|
||||
@@ -557,6 +715,19 @@ func (reader *recordingRequestLogReader) List(
|
||||
return reader.pages[index], nil
|
||||
}
|
||||
|
||||
func (reader *recordingRequestLogReader) Get(
|
||||
_ context.Context,
|
||||
requestID string,
|
||||
) (requestlog.Record, error) {
|
||||
if reader.getErr != nil {
|
||||
return requestlog.Record{}, reader.getErr
|
||||
}
|
||||
if record, ok := reader.details[requestID]; ok {
|
||||
return record, nil
|
||||
}
|
||||
return requestlog.Record{}, fmt.Errorf("request log %s not found", requestID)
|
||||
}
|
||||
|
||||
func newRequestLogTestEngine(t *testing.T, reader RequestLogReader) *gin.Engine {
|
||||
t.Helper()
|
||||
initControlI18n(t)
|
||||
|
||||
@@ -37,6 +37,7 @@ type ForwardInput struct {
|
||||
ExternalModel string
|
||||
UpstreamModelID string
|
||||
OnStreamReady func()
|
||||
OnFirstResponse func()
|
||||
}
|
||||
|
||||
type UpstreamResult struct {
|
||||
@@ -425,6 +426,9 @@ func (forwarder *Forwarder) ForwardStream(
|
||||
result.Stream = observeStreamTermination(ctx, err, streamEvents)
|
||||
return result
|
||||
}
|
||||
if input.OnFirstResponse != nil {
|
||||
input.OnFirstResponse()
|
||||
}
|
||||
if rewrittenStream.consumeReadTerminalBoundary() {
|
||||
streamEvents.markTerminalForwarded()
|
||||
}
|
||||
|
||||
@@ -378,6 +378,7 @@ func (handler *Handler) Handle(ginContext *gin.Context) {
|
||||
allowedKeyRefs[ref.ID] = ref
|
||||
}
|
||||
recorder.setClientModel(model)
|
||||
recorder.setStream(metadata.Stream)
|
||||
recorder.setUsageApplicable(metadata.ObserveUsage)
|
||||
recorder.setUsageDiagnostics(metadata.UsageDiagnostics)
|
||||
|
||||
@@ -551,6 +552,9 @@ func (handler *Handler) executeAttempts(
|
||||
OnStreamReady: func() {
|
||||
handler.recordSuccess(selectedKeyID, handler.now())
|
||||
},
|
||||
OnFirstResponse: func() {
|
||||
recorder.recordFirstResponse()
|
||||
},
|
||||
}
|
||||
if recorder != nil {
|
||||
recorder.freezeNextAttemptPricing(
|
||||
|
||||
@@ -50,6 +50,8 @@ type requestRecorder struct {
|
||||
accessKeyID uint
|
||||
protocol protocol.Protocol
|
||||
clientModel string
|
||||
stream bool
|
||||
firstResponseMs *int64
|
||||
usageApplicable bool
|
||||
usageDiagnostics usage.Diagnostics
|
||||
attempts []telemetry.Attempt
|
||||
@@ -109,20 +111,22 @@ func (recorder *requestRecorder) emit() {
|
||||
duration = 0
|
||||
}
|
||||
recorder.sink.Emit(telemetry.RequestEvent{
|
||||
RequestID: recorder.requestID,
|
||||
CompletedAt: completedAt.UTC(),
|
||||
AccessKeyID: recorder.accessKeyID,
|
||||
Protocol: recorder.protocol,
|
||||
ClientModel: recorder.clientModel,
|
||||
UpstreamModel: recorder.outcome.upstreamModel,
|
||||
Status: recorder.outcome.status,
|
||||
StatusCode: recorder.outcome.statusCode,
|
||||
ErrorCode: recorder.outcome.errorCode,
|
||||
ErrorSummary: recorder.outcome.errorSummary,
|
||||
DurationMs: duration.Milliseconds(),
|
||||
AffinityHit: false,
|
||||
Attempts: append([]telemetry.Attempt(nil), recorder.attempts...),
|
||||
Usage: recorder.usage,
|
||||
RequestID: recorder.requestID,
|
||||
CompletedAt: completedAt.UTC(),
|
||||
AccessKeyID: recorder.accessKeyID,
|
||||
Protocol: recorder.protocol,
|
||||
ClientModel: recorder.clientModel,
|
||||
UpstreamModel: recorder.outcome.upstreamModel,
|
||||
Status: recorder.outcome.status,
|
||||
StatusCode: recorder.outcome.statusCode,
|
||||
ErrorCode: recorder.outcome.errorCode,
|
||||
ErrorSummary: recorder.outcome.errorSummary,
|
||||
Stream: recorder.stream,
|
||||
FirstResponseMs: recorder.firstResponseMs,
|
||||
DurationMs: duration.Milliseconds(),
|
||||
AffinityHit: false,
|
||||
Attempts: append([]telemetry.Attempt(nil), recorder.attempts...),
|
||||
Usage: recorder.usage,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -132,6 +136,24 @@ func (recorder *requestRecorder) setClientModel(model string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (recorder *requestRecorder) setStream(stream bool) {
|
||||
if recorder != nil {
|
||||
recorder.stream = stream
|
||||
}
|
||||
}
|
||||
|
||||
func (recorder *requestRecorder) recordFirstResponse() {
|
||||
if recorder == nil || !recorder.stream || recorder.firstResponseMs != nil || recorder.now == nil {
|
||||
return
|
||||
}
|
||||
duration := recorder.now().Sub(recorder.startedAt)
|
||||
if duration < 0 {
|
||||
duration = 0
|
||||
}
|
||||
value := duration.Milliseconds()
|
||||
recorder.firstResponseMs = &value
|
||||
}
|
||||
|
||||
func (recorder *requestRecorder) setUsageApplicable(applicable bool) {
|
||||
if recorder != nil {
|
||||
recorder.usageApplicable = applicable
|
||||
@@ -426,13 +448,18 @@ func quoteFrozenAttempt(
|
||||
if frozen.table == nil || frozen.scopeKey == "" || frozen.upstreamModel == "" {
|
||||
return observation
|
||||
}
|
||||
quote := frozen.table.Quote(pricing.Identity{
|
||||
quote, receipt := frozen.table.QuoteWithReceipt(pricing.Identity{
|
||||
ScopeKey: frozen.scopeKey,
|
||||
ModelID: frozen.upstreamModel,
|
||||
}, result)
|
||||
observation.CostState = string(quote.State)
|
||||
observation.PricingCompleteness = string(quote.Completeness)
|
||||
observation.EstimatedCostNanoUSD = int64(quote.EstimatedCostNanoUSD)
|
||||
if receipt != nil {
|
||||
if encoded, err := json.Marshal(receipt); err == nil {
|
||||
observation.ReceiptJSON = string(encoded)
|
||||
}
|
||||
}
|
||||
return observation
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,40 @@ import (
|
||||
"gpt-load/internal/usage"
|
||||
)
|
||||
|
||||
func TestRequestRecorderCapturesFirstResponseAfterDownstreamCommit(t *testing.T) {
|
||||
startedAt := time.Date(2026, time.August, 5, 8, 0, 0, 0, time.UTC)
|
||||
sink := &recordingRequestLogSink{}
|
||||
times := []time.Time{startedAt.Add(340 * time.Millisecond), startedAt.Add(2 * time.Second)}
|
||||
now := func() time.Time {
|
||||
value := times[0]
|
||||
times = times[1:]
|
||||
return value
|
||||
}
|
||||
recorder := newRequestRecorder(
|
||||
sink,
|
||||
"00000000-0000-4000-8000-000000000001",
|
||||
startedAt,
|
||||
1,
|
||||
protocol.OpenAICompletions,
|
||||
now,
|
||||
)
|
||||
recorder.setStream(true)
|
||||
recorder.recordFirstResponse()
|
||||
recorder.recordFirstResponse()
|
||||
recorder.emit()
|
||||
|
||||
events := sink.snapshot()
|
||||
if len(events) != 1 || !events[0].Stream || events[0].FirstResponseMs == nil ||
|
||||
*events[0].FirstResponseMs != 340 || events[0].DurationMs != 2_000 {
|
||||
t.Fatalf("captured timing = %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func withoutPricingReceipt(value telemetry.PricingObservation) telemetry.PricingObservation {
|
||||
value.ReceiptJSON = ""
|
||||
return value
|
||||
}
|
||||
|
||||
const fixedRequestID = "11111111-2222-4333-8444-555555555555"
|
||||
|
||||
type rpmLimiterCall struct {
|
||||
@@ -223,7 +257,7 @@ func TestHandlerRecordsProtocolOnlyResponsesResourceWithoutFabricatingModels(t *
|
||||
len(event.Attempts) != 1 ||
|
||||
event.Attempts[0].UpstreamModel != "" ||
|
||||
event.Usage.Result.State != usage.StateNotApplicable ||
|
||||
event.Usage.Pricing != (telemetry.PricingObservation{
|
||||
withoutPricingReceipt(event.Usage.Pricing) != (telemetry.PricingObservation{
|
||||
CostState: "not_applicable", PricingCompleteness: "not_applicable",
|
||||
}) {
|
||||
t.Fatalf("event = %#v", event)
|
||||
@@ -265,7 +299,7 @@ func TestHandlerBillableResponsesUsageWithoutModelIsUnpriced(t *testing.T) {
|
||||
engine.ServeHTTP(httptest.NewRecorder(), request)
|
||||
|
||||
events := sink.snapshot()
|
||||
if len(events) != 1 || events[0].Usage.Pricing != (telemetry.PricingObservation{
|
||||
if len(events) != 1 || withoutPricingReceipt(events[0].Usage.Pricing) != (telemetry.PricingObservation{
|
||||
CostState: "unpriced", PricingCompleteness: "unavailable",
|
||||
}) {
|
||||
t.Fatalf("events = %#v", events)
|
||||
@@ -410,7 +444,7 @@ func TestHandlerFreezesAttemptPriceBeforeForward(t *testing.T) {
|
||||
PricingCompleteness: string(pricing.CompletenessComplete),
|
||||
EstimatedCostNanoUSD: 2_000_000_000,
|
||||
}
|
||||
if events[0].Usage.Pricing != want {
|
||||
if withoutPricingReceipt(events[0].Usage.Pricing) != want {
|
||||
t.Fatalf("frozen pricing = %#v, want %#v", events[0].Usage.Pricing, want)
|
||||
}
|
||||
if provider.LoadCount() != 1 {
|
||||
@@ -445,7 +479,7 @@ func TestRequestRecorderPreservesKnownCostWhenUsedOutputPriceIsUnavailable(t *te
|
||||
},
|
||||
}, health.Result{}, model, index)
|
||||
|
||||
if got := recorder.usage.Pricing; got != (telemetry.PricingObservation{
|
||||
if got := withoutPricingReceipt(recorder.usage.Pricing); got != (telemetry.PricingObservation{
|
||||
PriceScopeKey: "group:1", UpstreamModel: model,
|
||||
CostState: "priced", PricingCompleteness: "partial",
|
||||
EstimatedCostNanoUSD: 2_000_000_000,
|
||||
@@ -487,7 +521,7 @@ func TestRequestRecorderMergesRequestPricingDiagnosticsIntoKnownCost(t *testing.
|
||||
if !recorder.usage.Result.Diagnostics.Has(usage.DiagnosticUnsupportedBillableDetail) {
|
||||
t.Fatalf("usage diagnostics = %#v, want unsupported billable detail", recorder.usage.Result.Diagnostics)
|
||||
}
|
||||
if got := recorder.usage.Pricing; got != (telemetry.PricingObservation{
|
||||
if got := withoutPricingReceipt(recorder.usage.Pricing); got != (telemetry.PricingObservation{
|
||||
PriceScopeKey: "group:1", UpstreamModel: model,
|
||||
CostState: "priced", PricingCompleteness: "partial",
|
||||
EstimatedCostNanoUSD: 4_000_000_000,
|
||||
@@ -541,7 +575,7 @@ func TestHandlerUsesSelectedProviderModelForPricingInsteadOfAliasOrBodyModel(t *
|
||||
|
||||
events := sink.snapshot()
|
||||
if len(events) != 1 || events[0].ClientModel != "client-alias" ||
|
||||
events[0].UpstreamModel != model || events[0].Usage.Pricing != (telemetry.PricingObservation{
|
||||
events[0].UpstreamModel != model || withoutPricingReceipt(events[0].Usage.Pricing) != (telemetry.PricingObservation{
|
||||
PriceScopeKey: "provider:openai", UpstreamModel: model,
|
||||
CostState: "priced", PricingCompleteness: "complete",
|
||||
EstimatedCostNanoUSD: 3_000_000_000,
|
||||
@@ -580,7 +614,7 @@ func TestHandlerMarksExplicitUnsupportedPricingModePartial(t *testing.T) {
|
||||
t.Fatalf("events = %#v, want one", events)
|
||||
}
|
||||
if !events[0].Usage.Result.Diagnostics.Has(usage.DiagnosticUnsupportedBillableDetail) ||
|
||||
events[0].Usage.Pricing != (telemetry.PricingObservation{
|
||||
withoutPricingReceipt(events[0].Usage.Pricing) != (telemetry.PricingObservation{
|
||||
PriceScopeKey: "group:1", UpstreamModel: "gpt-4o",
|
||||
CostState: "priced", PricingCompleteness: "partial",
|
||||
EstimatedCostNanoUSD: 4_000_000_000,
|
||||
@@ -785,7 +819,7 @@ func TestHandlerRetryExhaustionUsesProviderErrorAttemptAndItsFrozenPrice(t *test
|
||||
}
|
||||
event := events[0]
|
||||
if event.Usage.GroupID != 1 || event.Usage.KeyID != 1 ||
|
||||
event.Usage.AttemptSequence != 1 || event.Usage.Pricing != (telemetry.PricingObservation{
|
||||
event.Usage.AttemptSequence != 1 || withoutPricingReceipt(event.Usage.Pricing) != (telemetry.PricingObservation{
|
||||
PriceScopeKey: "group:1",
|
||||
UpstreamModel: "gpt-4o",
|
||||
CostState: string(pricing.CostStatePriced),
|
||||
|
||||
@@ -15,8 +15,8 @@ type NanoUSD int64
|
||||
|
||||
// Multiplier applies an exact positive ratio to a price component.
|
||||
type Multiplier struct {
|
||||
Numerator int64
|
||||
Denominator int64
|
||||
Numerator int64 `json:"numerator"`
|
||||
Denominator int64 `json:"denominator"`
|
||||
}
|
||||
|
||||
// ParseUSD parses a non-negative decimal USD amount with at most nine decimal places.
|
||||
|
||||
+60
-15
@@ -11,46 +11,69 @@ var (
|
||||
|
||||
// Quote prices a finalized usage result without modifying it.
|
||||
func (table *Table) Quote(identity Identity, result usage.Result) Quote {
|
||||
quote, _ := table.QuoteWithReceipt(identity, result)
|
||||
return quote
|
||||
}
|
||||
|
||||
// QuoteWithReceipt prices a finalized usage result and freezes the exact rule,
|
||||
// tier, rates, multipliers, and component amounts used for the calculation.
|
||||
func (table *Table) QuoteWithReceipt(
|
||||
identity Identity,
|
||||
result usage.Result,
|
||||
) (Quote, *Receipt) {
|
||||
switch result.State {
|
||||
case usage.StateNotApplicable:
|
||||
return Quote{State: CostStateNotApplicable, Completeness: CompletenessNotApplicable}
|
||||
return Quote{State: CostStateNotApplicable, Completeness: CompletenessNotApplicable}, nil
|
||||
case usage.StateMissing:
|
||||
return unavailableQuote()
|
||||
return unavailableQuote(), nil
|
||||
case usage.StateComplete, usage.StatePartial:
|
||||
default:
|
||||
return unavailableQuote()
|
||||
return unavailableQuote(), nil
|
||||
}
|
||||
|
||||
rule, ok := table.Lookup(identity)
|
||||
if !ok {
|
||||
return unavailableQuote()
|
||||
return unavailableQuote(), nil
|
||||
}
|
||||
if _, ok := usage.CheckedTotal(result.Tokens); !ok {
|
||||
return unavailableQuote()
|
||||
return unavailableQuote(), nil
|
||||
}
|
||||
inputTokens, ok := checkedInputTokens(result.Tokens)
|
||||
if !ok {
|
||||
return unavailableQuote()
|
||||
return unavailableQuote(), nil
|
||||
}
|
||||
|
||||
prices := rule.Prices
|
||||
var selectedThreshold *int64
|
||||
for _, tier := range rule.ContextTiers {
|
||||
if inputTokens < tier.InputThresholdTokens {
|
||||
break
|
||||
}
|
||||
prices = tier.Prices
|
||||
threshold := tier.InputThresholdTokens
|
||||
selectedThreshold = &threshold
|
||||
}
|
||||
|
||||
components := [...]struct {
|
||||
code string
|
||||
tokens int64
|
||||
price Price
|
||||
multiplier Multiplier
|
||||
}{
|
||||
{tokens: result.Tokens.UncachedInput, price: prices.Input, multiplier: directPriceMultiplier},
|
||||
{tokens: result.Tokens.CacheRead, price: prices.CacheRead, multiplier: directPriceMultiplier},
|
||||
{tokens: result.Tokens.CacheWrite5M, price: prices.CacheWrite, multiplier: directPriceMultiplier},
|
||||
{tokens: result.Tokens.CacheWrite1H, price: prices.CacheWrite, multiplier: cacheWriteOneHourMultiplier},
|
||||
{tokens: result.Tokens.Output, price: prices.Output, multiplier: directPriceMultiplier},
|
||||
{code: "input", tokens: result.Tokens.UncachedInput, price: prices.Input, multiplier: directPriceMultiplier},
|
||||
{code: "cache_read", tokens: result.Tokens.CacheRead, price: prices.CacheRead, multiplier: directPriceMultiplier},
|
||||
{code: "cache_write_5m", tokens: result.Tokens.CacheWrite5M, price: prices.CacheWrite, multiplier: directPriceMultiplier},
|
||||
{code: "cache_write_1h", tokens: result.Tokens.CacheWrite1H, price: prices.CacheWrite, multiplier: cacheWriteOneHourMultiplier},
|
||||
{code: "output", tokens: result.Tokens.Output, price: prices.Output, multiplier: directPriceMultiplier},
|
||||
}
|
||||
receipt := &Receipt{
|
||||
SchemaVersion: 1,
|
||||
Method: ReceiptMethodUnitRateSum,
|
||||
MethodVersion: 1,
|
||||
Currency: "USD",
|
||||
Rule: identity,
|
||||
ContextThresholdTokens: selectedThreshold,
|
||||
LineItems: make([]ReceiptLine, 0, len(components)+1),
|
||||
}
|
||||
|
||||
positiveBillable := result.Tokens.CacheWriteUnknown > 0
|
||||
@@ -64,8 +87,15 @@ func (table *Table) Quote(identity Identity, result usage.Result) Quote {
|
||||
continue
|
||||
}
|
||||
positiveBillable = true
|
||||
line := ReceiptLine{
|
||||
Code: component.code,
|
||||
Quantity: component.tokens,
|
||||
Multiplier: component.multiplier,
|
||||
State: ReceiptLineUnpriced,
|
||||
}
|
||||
if !component.price.Set {
|
||||
partial = true
|
||||
receipt.LineItems = append(receipt.LineItems, line)
|
||||
continue
|
||||
}
|
||||
componentCost, ok := QuoteComponent(
|
||||
@@ -74,17 +104,32 @@ func (table *Table) Quote(identity Identity, result usage.Result) Quote {
|
||||
component.multiplier,
|
||||
)
|
||||
if !ok {
|
||||
return unavailableQuote()
|
||||
return unavailableQuote(), nil
|
||||
}
|
||||
cost, ok = CheckedAddNanoUSD(cost, componentCost)
|
||||
if !ok {
|
||||
return unavailableQuote()
|
||||
return unavailableQuote(), nil
|
||||
}
|
||||
rate := int64(component.price.NanoUSDPerMillion)
|
||||
amount := int64(componentCost)
|
||||
line.RateNanoUSDPerMillion = &rate
|
||||
line.AmountNanoUSD = &amount
|
||||
line.State = ReceiptLinePriced
|
||||
receipt.LineItems = append(receipt.LineItems, line)
|
||||
pricedPositive = true
|
||||
}
|
||||
if result.Tokens.CacheWriteUnknown > 0 {
|
||||
receipt.LineItems = append(receipt.LineItems, ReceiptLine{
|
||||
Code: "cache_write",
|
||||
Quantity: result.Tokens.CacheWriteUnknown,
|
||||
Multiplier: directPriceMultiplier,
|
||||
State: ReceiptLineUnpriced,
|
||||
})
|
||||
}
|
||||
receipt.TotalNanoUSD = int64(cost)
|
||||
|
||||
if positiveBillable && !pricedPositive {
|
||||
return unavailableQuote()
|
||||
return unavailableQuote(), receipt
|
||||
}
|
||||
completeness := CompletenessComplete
|
||||
if partial {
|
||||
@@ -94,7 +139,7 @@ func (table *Table) Quote(identity Identity, result usage.Result) Quote {
|
||||
State: CostStatePriced,
|
||||
Completeness: completeness,
|
||||
EstimatedCostNanoUSD: cost,
|
||||
}
|
||||
}, receipt
|
||||
}
|
||||
|
||||
func checkedInputTokens(tokens usage.Tokens) (int64, bool) {
|
||||
|
||||
@@ -228,3 +228,71 @@ func TestQuotePartialUsageKeepsKnownCostAndPartialCompleteness(t *testing.T) {
|
||||
t.Fatalf("Quote() = %#v, want partial known cost %#v", quote, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteWithReceiptFreezesExactTieredCalculation(t *testing.T) {
|
||||
identity := Identity{ScopeKey: "provider:anthropic", ModelID: "claude-sonnet"}
|
||||
threshold := int64(1_000)
|
||||
table := mustTable(t, Rule{
|
||||
Identity: identity,
|
||||
Prices: Prices{
|
||||
Input: fixedPrice(2), Output: fixedPrice(8),
|
||||
CacheRead: fixedPrice(1), CacheWrite: fixedPrice(5),
|
||||
},
|
||||
ContextTiers: []ContextTier{{
|
||||
InputThresholdTokens: threshold,
|
||||
Prices: Prices{
|
||||
Input: fixedPrice(3), Output: fixedPrice(9),
|
||||
CacheRead: fixedPrice(1), CacheWrite: fixedPrice(5),
|
||||
},
|
||||
}},
|
||||
})
|
||||
|
||||
quote, receipt := table.QuoteWithReceipt(identity, usage.Result{
|
||||
Tokens: usage.Tokens{
|
||||
UncachedInput: 1_000_000,
|
||||
CacheRead: 2_000_000,
|
||||
CacheWrite5M: 1_000_000,
|
||||
CacheWrite1H: 1_000_000,
|
||||
Output: 1_000_000,
|
||||
},
|
||||
State: usage.StateComplete,
|
||||
})
|
||||
if quote != (Quote{
|
||||
State: CostStatePriced, Completeness: CompletenessComplete,
|
||||
EstimatedCostNanoUSD: 27,
|
||||
}) {
|
||||
t.Fatalf("QuoteWithReceipt() quote = %#v", quote)
|
||||
}
|
||||
if receipt == nil || receipt.SchemaVersion != 1 || receipt.Method != "unit_rate_sum" ||
|
||||
receipt.MethodVersion != 1 || receipt.Currency != "USD" ||
|
||||
receipt.Rule != identity || receipt.ContextThresholdTokens == nil ||
|
||||
*receipt.ContextThresholdTokens != threshold || receipt.TotalNanoUSD != 27 {
|
||||
t.Fatalf("QuoteWithReceipt() receipt = %#v", receipt)
|
||||
}
|
||||
if got, want := len(receipt.LineItems), 5; got != want {
|
||||
t.Fatalf("receipt line items = %d, want %d: %#v", got, want, receipt.LineItems)
|
||||
}
|
||||
if receipt.LineItems[3].Code != "cache_write_1h" ||
|
||||
receipt.LineItems[3].Multiplier != (Multiplier{Numerator: 8, Denominator: 5}) ||
|
||||
receipt.LineItems[3].AmountNanoUSD == nil || *receipt.LineItems[3].AmountNanoUSD != 8 {
|
||||
t.Fatalf("one-hour cache receipt line = %#v", receipt.LineItems[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteWithReceiptPreservesUnpricedPositiveComponents(t *testing.T) {
|
||||
identity := Identity{ScopeKey: "provider:openai", ModelID: "gpt-4.1"}
|
||||
table := mustTable(t, Rule{Identity: identity, Prices: Prices{Input: fixedPrice(7)}})
|
||||
|
||||
quote, receipt := table.QuoteWithReceipt(identity, usage.Result{
|
||||
Tokens: usage.Tokens{UncachedInput: 1_000_000, Output: 10},
|
||||
State: usage.StateComplete,
|
||||
})
|
||||
if quote.Completeness != CompletenessPartial || receipt == nil || len(receipt.LineItems) != 2 {
|
||||
t.Fatalf("QuoteWithReceipt() = %#v, %#v", quote, receipt)
|
||||
}
|
||||
output := receipt.LineItems[1]
|
||||
if output.Code != "output" || output.State != ReceiptLineUnpriced ||
|
||||
output.RateNanoUSDPerMillion != nil || output.AmountNanoUSD != nil {
|
||||
t.Fatalf("unpriced output receipt line = %#v", output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package pricing
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ValidateReceipt verifies a persisted request-time receipt without consulting
|
||||
// the mutable current pricing table.
|
||||
func ValidateReceipt(receipt Receipt) error {
|
||||
if receipt.SchemaVersion != 1 || receipt.Method != ReceiptMethodUnitRateSum ||
|
||||
receipt.MethodVersion != 1 || receipt.Currency != "USD" {
|
||||
return fmt.Errorf("unsupported pricing receipt contract")
|
||||
}
|
||||
if err := validateIdentity(receipt.Rule); err != nil {
|
||||
return fmt.Errorf("invalid pricing receipt rule: %w", err)
|
||||
}
|
||||
if receipt.ContextThresholdTokens != nil && *receipt.ContextThresholdTokens < 0 {
|
||||
return fmt.Errorf("invalid pricing receipt context threshold")
|
||||
}
|
||||
if receipt.TotalNanoUSD < 0 {
|
||||
return fmt.Errorf("invalid pricing receipt total")
|
||||
}
|
||||
|
||||
allowed := map[string]struct{}{
|
||||
"input": {}, "cache_read": {}, "cache_write_5m": {},
|
||||
"cache_write_1h": {}, "cache_write": {}, "output": {},
|
||||
}
|
||||
seen := make(map[string]struct{}, len(receipt.LineItems))
|
||||
total := NanoUSD(0)
|
||||
for _, line := range receipt.LineItems {
|
||||
if _, ok := allowed[line.Code]; !ok {
|
||||
return fmt.Errorf("invalid pricing receipt line code %q", line.Code)
|
||||
}
|
||||
if _, exists := seen[line.Code]; exists {
|
||||
return fmt.Errorf("duplicate pricing receipt line code %q", line.Code)
|
||||
}
|
||||
seen[line.Code] = struct{}{}
|
||||
if line.Quantity <= 0 || line.Multiplier.Numerator <= 0 ||
|
||||
line.Multiplier.Denominator <= 0 {
|
||||
return fmt.Errorf("invalid pricing receipt line quantity or multiplier")
|
||||
}
|
||||
switch line.State {
|
||||
case ReceiptLinePriced:
|
||||
if line.RateNanoUSDPerMillion == nil || line.AmountNanoUSD == nil ||
|
||||
*line.RateNanoUSDPerMillion < 0 || *line.AmountNanoUSD < 0 {
|
||||
return fmt.Errorf("invalid priced receipt line")
|
||||
}
|
||||
amount, ok := QuoteComponent(
|
||||
line.Quantity,
|
||||
NanoUSD(*line.RateNanoUSDPerMillion),
|
||||
line.Multiplier,
|
||||
)
|
||||
if !ok || int64(amount) != *line.AmountNanoUSD {
|
||||
return fmt.Errorf("pricing receipt line amount mismatch")
|
||||
}
|
||||
total, ok = CheckedAddNanoUSD(total, amount)
|
||||
if !ok {
|
||||
return fmt.Errorf("pricing receipt total overflows")
|
||||
}
|
||||
case ReceiptLineUnpriced:
|
||||
if line.RateNanoUSDPerMillion != nil || line.AmountNanoUSD != nil {
|
||||
return fmt.Errorf("invalid unpriced receipt line")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("invalid pricing receipt line state")
|
||||
}
|
||||
}
|
||||
if int64(total) != receipt.TotalNanoUSD {
|
||||
return fmt.Errorf("pricing receipt total mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -18,8 +18,8 @@ type Prices struct {
|
||||
// Identity is the exact pricing identity of a real upstream model in one
|
||||
// canonical provider or group scope.
|
||||
type Identity struct {
|
||||
ScopeKey string
|
||||
ModelID string
|
||||
ScopeKey string `json:"scope_key"`
|
||||
ModelID string `json:"model_id"`
|
||||
}
|
||||
|
||||
// ContextTier replaces all base prices once its inclusive threshold is met.
|
||||
@@ -62,6 +62,42 @@ type Quote struct {
|
||||
EstimatedCostNanoUSD NanoUSD
|
||||
}
|
||||
|
||||
// ReceiptMethodUnitRateSum identifies the stable line-item calculation used by
|
||||
// the first persisted pricing receipt schema.
|
||||
const ReceiptMethodUnitRateSum = "unit_rate_sum"
|
||||
|
||||
// ReceiptLineState distinguishes a priced component from a positive component
|
||||
// whose exact rate was unavailable at request time.
|
||||
type ReceiptLineState string
|
||||
|
||||
const (
|
||||
ReceiptLinePriced ReceiptLineState = "priced"
|
||||
ReceiptLineUnpriced ReceiptLineState = "unpriced"
|
||||
)
|
||||
|
||||
// ReceiptLine is one frozen term in the request-time cost calculation.
|
||||
type ReceiptLine struct {
|
||||
Code string `json:"code"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
RateNanoUSDPerMillion *int64 `json:"rate_nano_usd_per_million,omitempty"`
|
||||
Multiplier Multiplier `json:"multiplier"`
|
||||
State ReceiptLineState `json:"state"`
|
||||
AmountNanoUSD *int64 `json:"amount_nano_usd,omitempty"`
|
||||
}
|
||||
|
||||
// Receipt is the immutable, versioned explanation of one request-time quote.
|
||||
// It deliberately stores calculation inputs instead of a presentation string.
|
||||
type Receipt struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Method string `json:"method"`
|
||||
MethodVersion int `json:"method_version"`
|
||||
Currency string `json:"currency"`
|
||||
Rule Identity `json:"rule"`
|
||||
ContextThresholdTokens *int64 `json:"context_threshold_tokens,omitempty"`
|
||||
LineItems []ReceiptLine `json:"line_items"`
|
||||
TotalNanoUSD int64 `json:"total_nano_usd"`
|
||||
}
|
||||
|
||||
// Table is an immutable exact pricing snapshot.
|
||||
type Table struct {
|
||||
rules map[Identity]Rule
|
||||
|
||||
@@ -33,28 +33,34 @@ func mapEvent(
|
||||
return models.RequestLog{}, fmt.Errorf("map request event usage/pricing: %w", err)
|
||||
}
|
||||
|
||||
attempts := make([]Attempt, 0, len(event.Attempts))
|
||||
receipt, err := canonicalPricingReceipt(event.Usage.Pricing)
|
||||
if err != nil {
|
||||
return models.RequestLog{}, fmt.Errorf("map request event pricing receipt: %w", err)
|
||||
}
|
||||
attempts := make([]models.RequestLogAttempt, 0, len(event.Attempts))
|
||||
for _, attempt := range event.Attempts {
|
||||
attempts = append(attempts, Attempt{
|
||||
attemptReceipt := models.JSON(nil)
|
||||
if attempt.Sequence == event.Usage.AttemptSequence {
|
||||
attemptReceipt = receipt
|
||||
}
|
||||
attempts = append(attempts, models.RequestLogAttempt{
|
||||
RequestID: event.RequestID,
|
||||
Sequence: attempt.Sequence,
|
||||
CompletedAtMS: completedAtMS,
|
||||
GroupID: attempt.GroupID,
|
||||
GroupName: redactIdentityValue(redactor, attempt.GroupName),
|
||||
KeyID: attempt.KeyID,
|
||||
UpstreamModel: redactIdentityValue(redactor, projectModel(attempt.UpstreamModel)),
|
||||
StatusCode: attempt.StatusCode,
|
||||
DurationMs: attempt.DurationMs,
|
||||
FailureCategory: attempt.FailureCategory,
|
||||
Action: attempt.Action,
|
||||
FailureCategory: string(attempt.FailureCategory),
|
||||
Action: string(attempt.Action),
|
||||
WillRetry: attempt.WillRetry,
|
||||
ErrorCode: attempt.ErrorCode,
|
||||
ErrorSummary: sanitizeSummary(redactor, attempt.ErrorSummary),
|
||||
Committed: attempt.Committed,
|
||||
PricingReceipt: attemptReceipt,
|
||||
})
|
||||
}
|
||||
encodedAttempts, err := json.Marshal(attempts)
|
||||
if err != nil {
|
||||
encodedAttempts = []byte("[]")
|
||||
}
|
||||
|
||||
result := event.Usage.Result
|
||||
pricingObservation := event.Usage.Pricing
|
||||
@@ -69,10 +75,13 @@ func mapEvent(
|
||||
UpstreamModel: redactIdentityValue(redactor, projectModel(event.UpstreamModel)),
|
||||
Status: string(event.Status),
|
||||
StatusCode: event.StatusCode,
|
||||
Stream: event.Stream,
|
||||
FirstResponseMs: event.FirstResponseMs,
|
||||
DurationMs: event.DurationMs,
|
||||
AttemptCount: len(attempts),
|
||||
ErrorCode: event.ErrorCode,
|
||||
ErrorSummary: sanitizeSummary(redactor, event.ErrorSummary),
|
||||
AffinityHit: false,
|
||||
AffinityHit: event.AffinityHit,
|
||||
UncachedInputTokens: result.Tokens.UncachedInput,
|
||||
OutputTokens: result.Tokens.Output,
|
||||
CacheReadTokens: result.Tokens.CacheRead,
|
||||
@@ -83,11 +92,43 @@ func mapEvent(
|
||||
UsageState: string(result.State),
|
||||
CostState: pricingObservation.CostState,
|
||||
PricingCompleteness: pricingObservation.PricingCompleteness,
|
||||
Attempts: models.JSON(encodedAttempts),
|
||||
AttemptRows: attempts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func canonicalPricingReceipt(observation telemetry.PricingObservation) (models.JSON, error) {
|
||||
if observation.ReceiptJSON == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var receipt pricing.Receipt
|
||||
if err := json.Unmarshal([]byte(observation.ReceiptJSON), &receipt); err != nil {
|
||||
return nil, fmt.Errorf("decode receipt: %w", err)
|
||||
}
|
||||
if err := pricing.ValidateReceipt(receipt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if receipt.Rule != (pricing.Identity{
|
||||
ScopeKey: observation.PriceScopeKey,
|
||||
ModelID: observation.UpstreamModel,
|
||||
}) || receipt.TotalNanoUSD != observation.EstimatedCostNanoUSD {
|
||||
return nil, fmt.Errorf("receipt does not match frozen pricing observation")
|
||||
}
|
||||
encoded, err := json.Marshal(receipt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode receipt: %w", err)
|
||||
}
|
||||
return models.JSON(encoded), nil
|
||||
}
|
||||
|
||||
func validateFrozenObservation(event telemetry.RequestEvent) error {
|
||||
if event.DurationMs < 0 {
|
||||
return fmt.Errorf("negative request duration")
|
||||
}
|
||||
if event.FirstResponseMs != nil {
|
||||
if !event.Stream || *event.FirstResponseMs < 0 || *event.FirstResponseMs > event.DurationMs {
|
||||
return fmt.Errorf("invalid first response duration")
|
||||
}
|
||||
}
|
||||
result := event.Usage.Result
|
||||
for _, value := range [...]int64{
|
||||
result.Tokens.UncachedInput,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package requestlog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -57,12 +56,9 @@ func TestMapEventPersistsFrozenUsagePricingAndAttribution(t *testing.T) {
|
||||
t.Fatalf("persisted frozen pricing = %+v", row)
|
||||
}
|
||||
|
||||
var attempts []Attempt
|
||||
if err := json.Unmarshal(row.Attempts, &attempts); err != nil {
|
||||
t.Fatalf("unmarshal attempts: %v", err)
|
||||
}
|
||||
if len(attempts) != 1 || attempts[0].GroupID != 7 || attempts[0].KeyID != 8 {
|
||||
t.Fatalf("attempts = %+v", attempts)
|
||||
if len(row.AttemptRows) != 1 || row.AttemptRows[0].GroupID != 7 ||
|
||||
row.AttemptRows[0].KeyID != 8 {
|
||||
t.Fatalf("attempts = %+v", row.AttemptRows)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,13 +197,9 @@ func TestMapEventDefensivelyRedactsAndBoundsSummaries(t *testing.T) {
|
||||
strings.Contains(row.ErrorSummary, secret) || !strings.HasSuffix(row.ErrorSummary, truncatedMarker) {
|
||||
t.Fatalf("request summary was not sanitized: %q", row.ErrorSummary)
|
||||
}
|
||||
var attempts []Attempt
|
||||
if err := json.Unmarshal(row.Attempts, &attempts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(attempts) != 1 || strings.Contains(attempts[0].ErrorSummary, secret) ||
|
||||
!strings.HasSuffix(attempts[0].ErrorSummary, truncatedMarker) {
|
||||
t.Fatalf("attempt summary was not sanitized: %+v", attempts)
|
||||
if len(row.AttemptRows) != 1 || strings.Contains(row.AttemptRows[0].ErrorSummary, secret) ||
|
||||
!strings.HasSuffix(row.AttemptRows[0].ErrorSummary, truncatedMarker) {
|
||||
t.Fatalf("attempt summary was not sanitized: %+v", row.AttemptRows)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,13 +207,10 @@ func TestMapEventBoundsUnattributedAttemptModelsButRejectsOversizedBoundModel(t
|
||||
event := testEvent("model-bounds")
|
||||
event.Attempts = append(event.Attempts, telemetry.Attempt{Sequence: 2, UpstreamModel: strings.Repeat("界", 100)})
|
||||
row := mustMapEvent(t, redact.New(), event)
|
||||
var attempts []Attempt
|
||||
if err := json.Unmarshal(row.Attempts, &attempts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(attempts[1].UpstreamModel) > maxModelBytes || !utf8.ValidString(attempts[1].UpstreamModel) ||
|
||||
!strings.HasSuffix(attempts[1].UpstreamModel, truncatedMarker) {
|
||||
t.Fatalf("unattributed attempt model = %q", attempts[1].UpstreamModel)
|
||||
if len(row.AttemptRows[1].UpstreamModel) > maxModelBytes ||
|
||||
!utf8.ValidString(row.AttemptRows[1].UpstreamModel) ||
|
||||
!strings.HasSuffix(row.AttemptRows[1].UpstreamModel, truncatedMarker) {
|
||||
t.Fatalf("unattributed attempt model = %q", row.AttemptRows[1].UpstreamModel)
|
||||
}
|
||||
|
||||
overlong := strings.Repeat("x", maxModelBytes+1)
|
||||
|
||||
+182
-24
@@ -4,6 +4,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"gpt-load/internal/pricing"
|
||||
"gpt-load/internal/protocol"
|
||||
@@ -31,22 +34,9 @@ func (service *Service) List(ctx context.Context, input ListQuery) (Page, error)
|
||||
if input.ToMS != nil {
|
||||
query = query.Where("completed_at_ms < ?", *input.ToMS)
|
||||
}
|
||||
if input.GroupID != nil {
|
||||
query = query.Where(`
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM json_each(COALESCE(request_logs.attempts, '[]')) AS attempt
|
||||
WHERE json_type(attempt.value, '$.group_id') = 'integer'
|
||||
AND json_extract(attempt.value, '$.group_id') = ?
|
||||
)
|
||||
`, *input.GroupID)
|
||||
}
|
||||
if input.ClientModel != "" {
|
||||
query = query.Where("client_model = ?", input.ClientModel)
|
||||
}
|
||||
if input.UpstreamModel != "" {
|
||||
query = query.Where("upstream_model = ?", input.UpstreamModel)
|
||||
}
|
||||
if input.AccessKeyID != nil {
|
||||
query = query.Where("access_key_id = ?", *input.AccessKeyID)
|
||||
}
|
||||
@@ -56,6 +46,54 @@ func (service *Service) List(ctx context.Context, input ListQuery) (Page, error)
|
||||
if input.RequestID != "" {
|
||||
query = query.Where("id = ?", input.RequestID)
|
||||
}
|
||||
if input.Protocol != "" {
|
||||
query = query.Where("protocol = ?", input.Protocol)
|
||||
}
|
||||
if input.Stream != nil {
|
||||
query = query.Where("stream = ?", *input.Stream)
|
||||
}
|
||||
if input.FinalStatusCode != nil {
|
||||
query = query.Where("status_code = ?", *input.FinalStatusCode)
|
||||
}
|
||||
if input.UsageState != "" {
|
||||
query = query.Where("usage_state = ?", input.UsageState)
|
||||
}
|
||||
if input.CostState != "" {
|
||||
query = query.Where("cost_state = ?", input.CostState)
|
||||
}
|
||||
if input.PricingCompleteness != "" {
|
||||
query = query.Where("pricing_completeness = ?", input.PricingCompleteness)
|
||||
}
|
||||
if input.CachePresent != nil {
|
||||
expression := `(cache_read_tokens + cache_write_5m_tokens + cache_write_1h_tokens + cache_write_unknown_tokens) > 0`
|
||||
if !*input.CachePresent {
|
||||
expression = `(cache_read_tokens + cache_write_5m_tokens + cache_write_1h_tokens + cache_write_unknown_tokens) = 0`
|
||||
}
|
||||
query = query.Where(expression)
|
||||
}
|
||||
if input.RetryState == RetryStateRetried {
|
||||
query = query.Where("attempt_count > 1")
|
||||
} else if input.RetryState == RetryStateNotRetried {
|
||||
query = query.Where("attempt_count <= 1")
|
||||
}
|
||||
retryCountExpression := "CASE WHEN attempt_count > 1 THEN attempt_count - 1 ELSE 0 END"
|
||||
if input.RetryCountMin != nil {
|
||||
query = query.Where(retryCountExpression+" >= ?", *input.RetryCountMin)
|
||||
}
|
||||
if input.RetryCountMax != nil {
|
||||
query = query.Where(retryCountExpression+" <= ?", *input.RetryCountMax)
|
||||
}
|
||||
query = applyNullableRange(query, "first_response_ms", input.FirstResponseMinMS, input.FirstResponseMaxMS)
|
||||
query = applyNullableRange(query, "duration_ms", input.DurationMinMS, input.DurationMaxMS)
|
||||
query = applyNullableRange(
|
||||
query,
|
||||
"(uncached_input_tokens + cache_read_tokens + cache_write_5m_tokens + cache_write_1h_tokens + cache_write_unknown_tokens)",
|
||||
input.InputTokensMin,
|
||||
input.InputTokensMax,
|
||||
)
|
||||
query = applyNullableRange(query, "output_tokens", input.OutputTokensMin, input.OutputTokensMax)
|
||||
query = applyNullableRange(query, "estimated_cost_nano_usd", input.CostMinNanoUSD, input.CostMaxNanoUSD)
|
||||
query = applyAttemptFilters(query, input)
|
||||
if input.Cursor != nil {
|
||||
query = query.Where(
|
||||
"completed_at_ms < ? OR (completed_at_ms = ? AND id < ?)",
|
||||
@@ -93,18 +131,131 @@ func (service *Service) List(ctx context.Context, input ListQuery) (Page, error)
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func applyNullableRange[T int | int64](
|
||||
query *gorm.DB,
|
||||
expression string,
|
||||
minimum *T,
|
||||
maximum *T,
|
||||
) *gorm.DB {
|
||||
if minimum != nil {
|
||||
query = query.Where(expression+" >= ?", *minimum)
|
||||
}
|
||||
if maximum != nil {
|
||||
query = query.Where(expression+" <= ?", *maximum)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func applyAttemptFilters(query *gorm.DB, input ListQuery) *gorm.DB {
|
||||
conditions := make([]string, 0, 6)
|
||||
arguments := make([]any, 0, 6)
|
||||
if input.GroupID != nil {
|
||||
conditions = append(conditions, "attempt.group_id = ?")
|
||||
arguments = append(arguments, *input.GroupID)
|
||||
}
|
||||
if input.UpstreamKeyID != nil {
|
||||
conditions = append(conditions, "attempt.key_id = ?")
|
||||
arguments = append(arguments, *input.UpstreamKeyID)
|
||||
}
|
||||
if input.UpstreamModel != "" {
|
||||
conditions = append(conditions, "attempt.upstream_model = ?")
|
||||
arguments = append(arguments, input.UpstreamModel)
|
||||
}
|
||||
if input.AttemptStatusCode != nil {
|
||||
conditions = append(conditions, "attempt.status_code = ?")
|
||||
arguments = append(arguments, *input.AttemptStatusCode)
|
||||
}
|
||||
if input.FailureCategory != "" {
|
||||
conditions = append(conditions, "attempt.failure_category = ?")
|
||||
arguments = append(arguments, input.FailureCategory)
|
||||
}
|
||||
if input.AttemptErrorCode != "" {
|
||||
conditions = append(conditions, "attempt.error_code = ?")
|
||||
arguments = append(arguments, input.AttemptErrorCode)
|
||||
}
|
||||
if len(conditions) == 0 {
|
||||
return query
|
||||
}
|
||||
statement := `EXISTS (
|
||||
SELECT 1 FROM request_log_attempts AS attempt
|
||||
WHERE attempt.request_id = request_logs.id AND ` +
|
||||
strings.Join(conditions, " AND ") + `
|
||||
)`
|
||||
return query.Where(statement, arguments...)
|
||||
}
|
||||
|
||||
// Get returns one request with all retry attempts and its frozen pricing receipt.
|
||||
func (service *Service) Get(ctx context.Context, requestID string) (Record, error) {
|
||||
var record Record
|
||||
err := service.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var row models.RequestLog
|
||||
if err := tx.Where("id = ?", requestID).First(&row).Error; err != nil {
|
||||
return fmt.Errorf("query request log detail: %w", err)
|
||||
}
|
||||
records, err := decodeRequestLogRows([]models.RequestLog{row})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := loadAccessKeyRefs(ctx, tx, records); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var attemptRows []models.RequestLogAttempt
|
||||
if err := tx.Where("request_id = ?", requestID).
|
||||
Order("sequence ASC").
|
||||
Find(&attemptRows).Error; err != nil {
|
||||
return fmt.Errorf("query request log attempts: %w", err)
|
||||
}
|
||||
attempts, err := decodeAttemptRows(attemptRows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
records[0].Attempts = attempts
|
||||
record = records[0]
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func decodeAttemptRows(rows []models.RequestLogAttempt) ([]Attempt, error) {
|
||||
attempts := make([]Attempt, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
var receipt *pricing.Receipt
|
||||
if len(row.PricingReceipt) > 0 && string(row.PricingReceipt) != "null" {
|
||||
var decoded pricing.Receipt
|
||||
if err := json.Unmarshal(row.PricingReceipt, &decoded); err != nil {
|
||||
return nil, fmt.Errorf("decode request log pricing receipt: %w", err)
|
||||
}
|
||||
if err := pricing.ValidateReceipt(decoded); err != nil {
|
||||
return nil, fmt.Errorf("decode request log pricing receipt: %w", err)
|
||||
}
|
||||
receipt = &decoded
|
||||
}
|
||||
attempts = append(attempts, Attempt{
|
||||
Sequence: row.Sequence,
|
||||
GroupID: row.GroupID,
|
||||
GroupName: row.GroupName,
|
||||
KeyID: row.KeyID,
|
||||
UpstreamModel: row.UpstreamModel,
|
||||
StatusCode: row.StatusCode,
|
||||
DurationMs: row.DurationMs,
|
||||
FailureCategory: telemetry.FailureCategory(row.FailureCategory),
|
||||
Action: telemetry.Action(row.Action),
|
||||
WillRetry: row.WillRetry,
|
||||
ErrorCode: row.ErrorCode,
|
||||
ErrorSummary: row.ErrorSummary,
|
||||
PricingReceipt: receipt,
|
||||
})
|
||||
}
|
||||
return attempts, nil
|
||||
}
|
||||
|
||||
func decodeRequestLogRows(rows []models.RequestLog) ([]Record, error) {
|
||||
records := make([]Record, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
attempts := make([]Attempt, 0)
|
||||
if len(row.Attempts) > 0 && string(row.Attempts) != "null" {
|
||||
if err := json.Unmarshal(row.Attempts, &attempts); err != nil {
|
||||
return nil, fmt.Errorf("decode request log attempts: %w", err)
|
||||
}
|
||||
if attempts == nil {
|
||||
attempts = make([]Attempt, 0)
|
||||
}
|
||||
}
|
||||
if err := validateRequestLogUsageCost(row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -117,11 +268,14 @@ func decodeRequestLogRows(rows []models.RequestLog) ([]Record, error) {
|
||||
UpstreamModel: row.UpstreamModel,
|
||||
Status: telemetry.RequestStatus(row.Status),
|
||||
StatusCode: row.StatusCode,
|
||||
Stream: row.Stream,
|
||||
FirstResponseMs: row.FirstResponseMs,
|
||||
DurationMs: row.DurationMs,
|
||||
AttemptCount: row.AttemptCount,
|
||||
ErrorCode: row.ErrorCode,
|
||||
ErrorSummary: row.ErrorSummary,
|
||||
AffinityHit: row.AffinityHit,
|
||||
Attempts: attempts,
|
||||
Attempts: nil,
|
||||
GroupID: row.GroupID,
|
||||
UsageState: usage.State(row.UsageState),
|
||||
CostState: pricing.CostState(row.CostState),
|
||||
@@ -173,6 +327,10 @@ func validateRequestLogUsageCost(row models.RequestLog) error {
|
||||
}
|
||||
|
||||
func (service *Service) loadAccessKeyRefs(ctx context.Context, records []Record) error {
|
||||
return loadAccessKeyRefs(ctx, service.db, records)
|
||||
}
|
||||
|
||||
func loadAccessKeyRefs(ctx context.Context, db *gorm.DB, records []Record) error {
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -191,7 +349,7 @@ func (service *Service) loadAccessKeyRefs(ctx context.Context, records []Record)
|
||||
ID uint
|
||||
Name string
|
||||
}
|
||||
if err := service.db.WithContext(ctx).
|
||||
if err := db.WithContext(ctx).
|
||||
Model(&models.AccessKey{}).
|
||||
Select("id", "name").
|
||||
Where("id IN ?", ids).
|
||||
|
||||
@@ -40,7 +40,6 @@ func TestDecodeRequestLogRowsPreservesUsageCostAttribution(t *testing.T) {
|
||||
UsageState: string(usage.StateComplete),
|
||||
CostState: string(pricing.CostStatePriced),
|
||||
PricingCompleteness: string(pricing.CompletenessComplete),
|
||||
Attempts: models.JSON(`[]`),
|
||||
}}
|
||||
|
||||
records, err := decodeRequestLogRows(rows)
|
||||
@@ -61,28 +60,26 @@ func TestDecodeRequestLogRowsPreservesUsageCostAttribution(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeRequestLogRowsIgnoresHistoricalKeyMask(t *testing.T) {
|
||||
rows := []models.RequestLog{{
|
||||
ID: "00000000-0000-4000-8000-000000000605",
|
||||
CompletedAtMS: 1_785_110_400_000,
|
||||
Protocol: string(protocol.OpenAICompletions),
|
||||
Status: string(telemetry.RequestStatusSuccess),
|
||||
UsageState: string(usage.StateNotApplicable),
|
||||
CostState: string(pricing.CostStateNotApplicable),
|
||||
PricingCompleteness: string(pricing.CompletenessNotApplicable),
|
||||
Attempts: models.JSON(
|
||||
`[{"sequence":1,"group_id":7,"group_name":"Primary","key_id":11,"key_mask":"prov****safe","upstream_model":"model","status_code":200,"duration_ms":10,"failure_category":"ok","action":"terminate","will_retry":false,"error_code":"","error_summary":"","committed":true}]`,
|
||||
),
|
||||
}}
|
||||
|
||||
records, err := decodeRequestLogRows(rows)
|
||||
func TestDecodeAttemptRowsExposesOnlyNormalizedSafeFields(t *testing.T) {
|
||||
attempts, err := decodeAttemptRows([]models.RequestLogAttempt{{
|
||||
RequestID: "00000000-0000-4000-8000-000000000605",
|
||||
Sequence: 1,
|
||||
GroupID: 7,
|
||||
GroupName: "Primary",
|
||||
KeyID: 11,
|
||||
UpstreamModel: "model",
|
||||
StatusCode: 200,
|
||||
DurationMs: 10,
|
||||
FailureCategory: string(telemetry.FailureCategoryOK),
|
||||
Action: string(telemetry.ActionTerminate),
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("decodeRequestLogRows() error = %v", err)
|
||||
t.Fatalf("decodeAttemptRows() error = %v", err)
|
||||
}
|
||||
if len(records) != 1 || len(records[0].Attempts) != 1 {
|
||||
t.Fatalf("records = %#v, want one historical attempt", records)
|
||||
if len(attempts) != 1 {
|
||||
t.Fatalf("attempts = %#v, want one attempt", attempts)
|
||||
}
|
||||
encoded, err := json.Marshal(records[0].Attempts[0])
|
||||
encoded, err := json.Marshal(attempts[0])
|
||||
if err != nil {
|
||||
t.Fatalf("marshal decoded attempt: %v", err)
|
||||
}
|
||||
@@ -102,7 +99,6 @@ func TestDecodeRequestLogRowsRejectsInvalidUsageCostValues(t *testing.T) {
|
||||
UsageState: string(usage.StateComplete),
|
||||
CostState: string(pricing.CostStatePriced),
|
||||
PricingCompleteness: string(pricing.CompletenessComplete),
|
||||
Attempts: models.JSON(`[]`),
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -190,7 +186,6 @@ func TestDecodeRequestLogRowsAcceptsApprovedUsageCostMatrix(t *testing.T) {
|
||||
CostState: string(test.costState),
|
||||
PricingCompleteness: string(test.completeness),
|
||||
EstimatedCostNanoUSD: test.cost,
|
||||
Attempts: models.JSON(`[]`),
|
||||
}
|
||||
records, err := decodeRequestLogRows([]models.RequestLog{row})
|
||||
if err != nil {
|
||||
@@ -263,8 +258,8 @@ func TestServiceListAppliesAllFiltersAndGroupJSON(t *testing.T) {
|
||||
to := from.Add(time.Hour)
|
||||
targetID := "00000000-0000-4000-8000-000000000201"
|
||||
integerAttempts := []Attempt{
|
||||
{Sequence: 1, GroupID: 12, GroupName: "retry-first", WillRetry: true},
|
||||
{Sequence: 2, GroupID: 13, GroupName: "retry-second"},
|
||||
{Sequence: 1, GroupID: 12, GroupName: "retry-first", UpstreamModel: "different-upstream-model", WillRetry: true},
|
||||
{Sequence: 2, GroupID: 13, GroupName: "retry-second", UpstreamModel: "different-upstream-model"},
|
||||
}
|
||||
target := requestLogQueryRow(targetID, from, 71, "client-model", integerAttempts)
|
||||
target.UpstreamModel = "different-upstream-model"
|
||||
@@ -299,7 +294,6 @@ func TestServiceListAppliesAllFiltersAndGroupJSON(t *testing.T) {
|
||||
nil,
|
||||
)
|
||||
stringGroup.Status = string(telemetry.RequestStatusError)
|
||||
stringGroup.Attempts = models.JSON(`[{"group_id":"12"}]`)
|
||||
createRequestLogQueryRow(t, db, stringGroup)
|
||||
|
||||
realGroup := requestLogQueryRow(
|
||||
@@ -310,7 +304,6 @@ func TestServiceListAppliesAllFiltersAndGroupJSON(t *testing.T) {
|
||||
nil,
|
||||
)
|
||||
realGroup.Status = string(telemetry.RequestStatusError)
|
||||
realGroup.Attempts = models.JSON(`[{"group_id":12.0}]`)
|
||||
createRequestLogQueryRow(t, db, realGroup)
|
||||
|
||||
upstreamOnly := requestLogQueryRow(
|
||||
@@ -351,7 +344,6 @@ func TestServiceListAppliesAllFiltersAndGroupJSON(t *testing.T) {
|
||||
nil,
|
||||
)
|
||||
zeroAttempts.Status = string(telemetry.RequestStatusError)
|
||||
zeroAttempts.Attempts = nil
|
||||
createRequestLogQueryRow(t, db, zeroAttempts)
|
||||
|
||||
groupID := uint(12)
|
||||
@@ -404,8 +396,8 @@ func TestServiceListGroupFilterUsesAnyAttemptWhileAttributionUsesFinalGroup(t *t
|
||||
db := openRequestLogQueryDB(t)
|
||||
event := testEvent("00000000-0000-4000-8000-000000000210")
|
||||
event.Attempts = []telemetry.Attempt{
|
||||
{Sequence: 1, GroupID: 12, KeyID: 1, UpstreamModel: "retry-model", WillRetry: true},
|
||||
{Sequence: 2, GroupID: 13, KeyID: 2, UpstreamModel: "final-model"},
|
||||
{Sequence: 1, GroupID: 12, GroupName: "retry", KeyID: 1, UpstreamModel: "retry-model", FailureCategory: telemetry.FailureCategoryRateLimited, Action: telemetry.ActionRetry, WillRetry: true},
|
||||
{Sequence: 2, GroupID: 13, GroupName: "final", KeyID: 2, UpstreamModel: "final-model", FailureCategory: telemetry.FailureCategoryOK, Action: telemetry.ActionTerminate},
|
||||
}
|
||||
event.UpstreamModel = "final-model"
|
||||
event.Usage = telemetry.UsageObservation{
|
||||
@@ -422,9 +414,7 @@ func TestServiceListGroupFilterUsesAnyAttemptWhileAttributionUsesFinalGroup(t *t
|
||||
if row.GroupID != 13 {
|
||||
t.Fatalf("top-level GroupID = %d, want final attribution 13", row.GroupID)
|
||||
}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("create attributed RequestLog: %v", err)
|
||||
}
|
||||
createRequestLogQueryRow(t, db, row)
|
||||
|
||||
service := newRequestLogTestService(db)
|
||||
for _, groupID := range []uint{12, 13} {
|
||||
@@ -441,6 +431,86 @@ func TestServiceListGroupFilterUsesAnyAttemptWhileAttributionUsesFinalGroup(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceListAttemptFiltersMustMatchTheSameAttempt(t *testing.T) {
|
||||
db := openRequestLogQueryDB(t)
|
||||
row := requestLogQueryRow(
|
||||
"00000000-0000-4000-8000-000000000211",
|
||||
time.Date(2026, time.July, 24, 12, 0, 0, 0, time.UTC),
|
||||
71,
|
||||
"client-model",
|
||||
[]Attempt{
|
||||
{
|
||||
Sequence: 1, GroupID: 12, GroupName: "retry", KeyID: 1,
|
||||
StatusCode: 429, FailureCategory: telemetry.FailureCategoryRateLimited,
|
||||
Action: telemetry.ActionRetry, WillRetry: true,
|
||||
},
|
||||
{
|
||||
Sequence: 2, GroupID: 13, GroupName: "final", KeyID: 2,
|
||||
StatusCode: 200, FailureCategory: telemetry.FailureCategoryOK,
|
||||
Action: telemetry.ActionTerminate,
|
||||
},
|
||||
},
|
||||
)
|
||||
createRequestLogQueryRow(t, db, row)
|
||||
service := newRequestLogTestService(db)
|
||||
groupID := uint(12)
|
||||
statusOK := 200
|
||||
|
||||
page, err := service.List(context.Background(), ListQuery{
|
||||
GroupID: &groupID, AttemptStatusCode: &statusOK, Limit: 50,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("List() mismatched attempt filters error = %v", err)
|
||||
}
|
||||
if len(page.Items) != 0 {
|
||||
t.Fatalf("mismatched attempt filters returned %#v, want empty", page.Items)
|
||||
}
|
||||
|
||||
statusRateLimited := 429
|
||||
page, err = service.List(context.Background(), ListQuery{
|
||||
GroupID: &groupID, AttemptStatusCode: &statusRateLimited,
|
||||
FailureCategory: telemetry.FailureCategoryRateLimited, Limit: 50,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("List() matching attempt filters error = %v", err)
|
||||
}
|
||||
if got, want := requestIDs(page.Items), []string{row.ID}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("matching attempt filters IDs = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceListZeroRetryRangeIncludesRequestsWithoutUpstreamAttempts(t *testing.T) {
|
||||
db := openRequestLogQueryDB(t)
|
||||
base := time.Date(2026, time.July, 24, 12, 0, 0, 0, time.UTC)
|
||||
zero := requestLogQueryRow(
|
||||
"00000000-0000-4000-8000-000000000212", base, 71, "zero", nil,
|
||||
)
|
||||
single := requestLogQueryRow(
|
||||
"00000000-0000-4000-8000-000000000213", base.Add(time.Second), 71, "single",
|
||||
[]Attempt{{Sequence: 1, GroupID: 12, GroupName: "single", KeyID: 1}},
|
||||
)
|
||||
retried := requestLogQueryRow(
|
||||
"00000000-0000-4000-8000-000000000214", base.Add(2*time.Second), 71, "retried",
|
||||
[]Attempt{
|
||||
{Sequence: 1, GroupID: 12, GroupName: "retry", KeyID: 1, WillRetry: true},
|
||||
{Sequence: 2, GroupID: 12, GroupName: "final", KeyID: 2},
|
||||
},
|
||||
)
|
||||
for _, row := range []models.RequestLog{zero, single, retried} {
|
||||
createRequestLogQueryRow(t, db, row)
|
||||
}
|
||||
minimum, maximum := 0, 0
|
||||
page, err := newRequestLogTestService(db).List(context.Background(), ListQuery{
|
||||
RetryCountMin: &minimum, RetryCountMax: &maximum, Limit: 50,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("List() zero retry range error = %v", err)
|
||||
}
|
||||
if got, want := requestIDs(page.Items), []string{single.ID, zero.ID}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("zero retry range IDs = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceListBatchLoadsCurrentAccessKeyNames(t *testing.T) {
|
||||
db := openRequestLogQueryDB(t)
|
||||
current := models.AccessKey{
|
||||
@@ -515,7 +585,7 @@ func TestServiceListBatchLoadsCurrentAccessKeyNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceListNormalizesNullAttemptsToEmptyArray(t *testing.T) {
|
||||
func TestServiceListOmitsAttemptsAndDetailLoadsThem(t *testing.T) {
|
||||
db := openRequestLogQueryDB(t)
|
||||
row := requestLogQueryRow(
|
||||
"00000000-0000-4000-8000-000000000401",
|
||||
@@ -524,23 +594,34 @@ func TestServiceListNormalizesNullAttemptsToEmptyArray(t *testing.T) {
|
||||
"zero-attempt",
|
||||
nil,
|
||||
)
|
||||
row.Attempts = nil
|
||||
row.AttemptRows = []models.RequestLogAttempt{{
|
||||
RequestID: row.ID,
|
||||
Sequence: 1,
|
||||
CompletedAtMS: row.CompletedAtMS,
|
||||
GroupID: 7,
|
||||
GroupName: "Primary",
|
||||
KeyID: 9,
|
||||
StatusCode: 200,
|
||||
DurationMs: 10,
|
||||
FailureCategory: string(telemetry.FailureCategoryOK),
|
||||
Action: string(telemetry.ActionTerminate),
|
||||
}}
|
||||
row.AttemptCount = 1
|
||||
createRequestLogQueryRow(t, db, row)
|
||||
|
||||
page, err := newRequestLogTestService(db).List(context.Background(), ListQuery{Limit: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("List() error = %v", err)
|
||||
}
|
||||
if len(page.Items) != 1 || page.Items[0].Attempts == nil ||
|
||||
len(page.Items[0].Attempts) != 0 {
|
||||
t.Fatalf("Attempts = %#v, want non-nil empty slice", page.Items)
|
||||
if len(page.Items) != 1 || page.Items[0].Attempts != nil || page.Items[0].AttemptCount != 1 {
|
||||
t.Fatalf("list item = %#v, want lightweight item without attempts", page.Items)
|
||||
}
|
||||
encoded, err := json.Marshal(page.Items[0])
|
||||
detail, err := newRequestLogTestService(db).Get(context.Background(), row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal() error = %v", err)
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
if string(encoded) == "" || !containsJSONFragment(encoded, `"Attempts":[]`) {
|
||||
t.Fatalf("encoded Record = %s, want empty attempts array", encoded)
|
||||
if len(detail.Attempts) != 1 || detail.Attempts[0].GroupName != "Primary" {
|
||||
t.Fatalf("detail attempts = %#v", detail.Attempts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,9 +653,44 @@ func requestLogQueryRow(
|
||||
clientModel string,
|
||||
attempts []Attempt,
|
||||
) models.RequestLog {
|
||||
encodedAttempts, err := json.Marshal(attempts)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
attemptRows := make([]models.RequestLogAttempt, 0, len(attempts))
|
||||
for index, attempt := range attempts {
|
||||
groupID := attempt.GroupID
|
||||
if groupID == 0 {
|
||||
groupID = 1
|
||||
}
|
||||
keyID := attempt.KeyID
|
||||
if keyID == 0 {
|
||||
keyID = 1
|
||||
}
|
||||
sequence := attempt.Sequence
|
||||
if sequence == 0 {
|
||||
sequence = index + 1
|
||||
}
|
||||
failureCategory := attempt.FailureCategory
|
||||
if failureCategory == "" {
|
||||
failureCategory = telemetry.FailureCategoryOK
|
||||
}
|
||||
action := attempt.Action
|
||||
if action == "" {
|
||||
action = telemetry.ActionTerminate
|
||||
}
|
||||
attemptRows = append(attemptRows, models.RequestLogAttempt{
|
||||
RequestID: id,
|
||||
Sequence: sequence,
|
||||
CompletedAtMS: completedAt.UTC().UnixMilli(),
|
||||
GroupID: groupID,
|
||||
GroupName: attempt.GroupName,
|
||||
KeyID: keyID,
|
||||
UpstreamModel: attempt.UpstreamModel,
|
||||
StatusCode: attempt.StatusCode,
|
||||
DurationMs: attempt.DurationMs,
|
||||
FailureCategory: string(failureCategory),
|
||||
Action: string(action),
|
||||
WillRetry: attempt.WillRetry,
|
||||
ErrorCode: attempt.ErrorCode,
|
||||
ErrorSummary: attempt.ErrorSummary,
|
||||
})
|
||||
}
|
||||
return models.RequestLog{
|
||||
ID: id,
|
||||
@@ -586,9 +702,10 @@ func requestLogQueryRow(
|
||||
Status: string(telemetry.RequestStatusSuccess),
|
||||
StatusCode: 200,
|
||||
DurationMs: 25,
|
||||
AttemptCount: len(attemptRows),
|
||||
UsageState: string(usage.StateNotApplicable),
|
||||
CostState: string(pricing.CostStateNotApplicable),
|
||||
Attempts: models.JSON(encodedAttempts),
|
||||
AttemptRows: attemptRows,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,6 +714,11 @@ func createRequestLogQueryRow(t *testing.T, db *gorm.DB, row models.RequestLog)
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatalf("create RequestLog %s: %v", row.ID, err)
|
||||
}
|
||||
if len(row.AttemptRows) > 0 {
|
||||
if err := db.Create(&row.AttemptRows).Error; err != nil {
|
||||
t.Fatalf("create RequestLog attempts %s: %v", row.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requestIDs(records []Record) []string {
|
||||
|
||||
@@ -197,12 +197,8 @@ func TestServiceEmitLifecycleAndDeepCopy(t *testing.T) {
|
||||
if len(rows) != 1 || rows[0].ID != "deep-copy" {
|
||||
t.Fatalf("written rows = %+v, want original request", rows)
|
||||
}
|
||||
var attempts []Attempt
|
||||
if err := json.Unmarshal(rows[0].Attempts, &attempts); err != nil {
|
||||
t.Fatalf("unmarshal attempts: %v", err)
|
||||
}
|
||||
if len(attempts) != 1 || attempts[0].GroupName != "primary" {
|
||||
t.Fatalf("written attempts = %+v, want original deep copy", attempts)
|
||||
if len(rows[0].AttemptRows) != 1 || rows[0].AttemptRows[0].GroupName != "primary" {
|
||||
t.Fatalf("written attempts = %+v, want original deep copy", rows[0].AttemptRows)
|
||||
}
|
||||
|
||||
if err := service.Stop(context.Background()); err != nil {
|
||||
|
||||
@@ -202,7 +202,6 @@ func aggregationRow(
|
||||
UsageState: string(usage.StateComplete),
|
||||
CostState: string(pricing.CostStatePriced),
|
||||
PricingCompleteness: string(pricing.CompletenessComplete),
|
||||
Attempts: models.JSON(`[]`),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,25 +37,56 @@ type Attempt struct {
|
||||
WillRetry bool `json:"will_retry"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
ErrorSummary string `json:"error_summary"`
|
||||
Committed bool `json:"committed"`
|
||||
PricingReceipt *pricing.Receipt `json:"pricing_receipt,omitempty"`
|
||||
}
|
||||
|
||||
type RetryState string
|
||||
|
||||
const (
|
||||
RetryStateRetried RetryState = "retried"
|
||||
RetryStateNotRetried RetryState = "not_retried"
|
||||
)
|
||||
|
||||
type Cursor struct {
|
||||
CompletedAtMS int64
|
||||
RequestID string
|
||||
}
|
||||
|
||||
type ListQuery struct {
|
||||
FromMS *int64
|
||||
ToMS *int64
|
||||
GroupID *uint
|
||||
ClientModel string
|
||||
UpstreamModel string
|
||||
AccessKeyID *uint
|
||||
Status telemetry.RequestStatus
|
||||
RequestID string
|
||||
Limit int
|
||||
Cursor *Cursor
|
||||
FromMS *int64
|
||||
ToMS *int64
|
||||
GroupID *uint
|
||||
ClientModel string
|
||||
UpstreamModel string
|
||||
AccessKeyID *uint
|
||||
Status telemetry.RequestStatus
|
||||
RequestID string
|
||||
Protocol protocol.Protocol
|
||||
Stream *bool
|
||||
FinalStatusCode *int
|
||||
UsageState usage.State
|
||||
CostState pricing.CostState
|
||||
PricingCompleteness pricing.Completeness
|
||||
CachePresent *bool
|
||||
UpstreamKeyID *uint
|
||||
AttemptStatusCode *int
|
||||
FailureCategory telemetry.FailureCategory
|
||||
AttemptErrorCode string
|
||||
RetryState RetryState
|
||||
RetryCountMin *int
|
||||
RetryCountMax *int
|
||||
FirstResponseMinMS *int64
|
||||
FirstResponseMaxMS *int64
|
||||
DurationMinMS *int64
|
||||
DurationMaxMS *int64
|
||||
InputTokensMin *int64
|
||||
InputTokensMax *int64
|
||||
OutputTokensMin *int64
|
||||
OutputTokensMax *int64
|
||||
CostMinNanoUSD *int64
|
||||
CostMaxNanoUSD *int64
|
||||
Limit int
|
||||
Cursor *Cursor
|
||||
}
|
||||
|
||||
type AccessKeyRef struct {
|
||||
@@ -73,7 +104,10 @@ type Record struct {
|
||||
UpstreamModel string
|
||||
Status telemetry.RequestStatus
|
||||
StatusCode int
|
||||
Stream bool
|
||||
FirstResponseMs *int64
|
||||
DurationMs int64
|
||||
AttemptCount int
|
||||
ErrorCode string
|
||||
ErrorSummary string
|
||||
AffinityHit bool
|
||||
|
||||
@@ -178,6 +178,15 @@ func writeRequestLogBatch(tx *gorm.DB, rows []models.RequestLog) error {
|
||||
if err := tx.CreateInBatches(newRows, batchSize).Error; err != nil {
|
||||
return fmt.Errorf("insert request logs: %w", err)
|
||||
}
|
||||
attemptRows := make([]models.RequestLogAttempt, 0)
|
||||
for _, row := range newRows {
|
||||
attemptRows = append(attemptRows, row.AttemptRows...)
|
||||
}
|
||||
if len(attemptRows) > 0 {
|
||||
if err := tx.CreateInBatches(attemptRows, batchSize).Error; err != nil {
|
||||
return fmt.Errorf("insert request log attempts: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
deltas, err := buildUsageStatDeltas(newRows)
|
||||
if err != nil {
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
)
|
||||
|
||||
// CurrentSchemaVersion identifies the SQLite schema supported by this binary.
|
||||
const CurrentSchemaVersion uint = 4
|
||||
const CurrentSchemaVersion uint = 5
|
||||
|
||||
const sqliteBusyTimeoutMS = 5000
|
||||
|
||||
@@ -121,19 +121,19 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
}
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := createSchemaV4Tables(tx); err != nil {
|
||||
if err := createSchemaV5Tables(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createSchemaV4InfoTable(tx); err != nil {
|
||||
if err := createSchemaV5InfoTable(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := createSchemaV4Indexes(tx); err != nil {
|
||||
if err := createSchemaV5Indexes(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&schemaInfo{Version: CurrentSchemaVersion}).Error; err != nil {
|
||||
return fmt.Errorf("initialize schema_info: %w", err)
|
||||
}
|
||||
return validateSchemaV4(tx)
|
||||
return validateSchemaV5(tx)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
CurrentSchemaVersion,
|
||||
)
|
||||
}
|
||||
return validateSchemaV4(db)
|
||||
return validateSchemaV5(db)
|
||||
}
|
||||
|
||||
func readSchemaVersion(db *gorm.DB) (uint, error) {
|
||||
|
||||
@@ -669,8 +669,8 @@ func TestAutoMigrateCreatesRequestLogFieldsAndCompositeIndexes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if storage.CurrentSchemaVersion != 4 {
|
||||
t.Fatalf("CurrentSchemaVersion = %d, want 4", storage.CurrentSchemaVersion)
|
||||
if storage.CurrentSchemaVersion != 5 {
|
||||
t.Fatalf("CurrentSchemaVersion = %d, want 5", storage.CurrentSchemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,30 +2,54 @@ package models
|
||||
|
||||
// RequestLog is the durable request-level audit and usage record.
|
||||
type RequestLog struct {
|
||||
ID string `gorm:"type:varchar(36);primaryKey;not null;index:idx_request_logs_completed_id,priority:2,sort:desc;index:idx_request_logs_access_completed_id,priority:3,sort:desc;index:idx_request_logs_status_completed_id,priority:3,sort:desc;index:idx_request_logs_model_completed_id,priority:3,sort:desc;index:idx_request_logs_upstream_model_completed_id,priority:3,sort:desc"`
|
||||
CompletedAtMS int64 `gorm:"column:completed_at_ms;not null;index:idx_request_logs_completed_id,priority:1,sort:desc;index:idx_request_logs_access_completed_id,priority:2,sort:desc;index:idx_request_logs_status_completed_id,priority:2,sort:desc;index:idx_request_logs_model_completed_id,priority:2,sort:desc;index:idx_request_logs_upstream_model_completed_id,priority:2,sort:desc"`
|
||||
AccessKeyID uint `gorm:"not null;index:idx_request_logs_access_completed_id,priority:1"`
|
||||
GroupID uint `gorm:"not null;default:0"`
|
||||
Protocol string `gorm:"type:varchar(32);not null"`
|
||||
ClientModel string `gorm:"type:varchar(255);not null;index:idx_request_logs_model_completed_id,priority:1"`
|
||||
UpstreamModel string `gorm:"type:varchar(255);not null;index:idx_request_logs_upstream_model_completed_id,priority:1"`
|
||||
Status string `gorm:"type:varchar(32);not null;index:idx_request_logs_status_completed_id,priority:1"`
|
||||
StatusCode int `gorm:"not null"`
|
||||
DurationMs int64 `gorm:"not null"`
|
||||
ErrorCode string `gorm:"type:varchar(64);not null;default:''"`
|
||||
ErrorSummary string `gorm:"type:text;not null;default:''"`
|
||||
AffinityHit bool `gorm:"not null;default:false"`
|
||||
UncachedInputTokens int64 `gorm:"column:uncached_input_tokens;not null;default:0"`
|
||||
OutputTokens int64 `gorm:"not null;default:0"`
|
||||
CacheReadTokens int64 `gorm:"not null;default:0"`
|
||||
CacheWrite5MTokens int64 `gorm:"column:cache_write_5m_tokens;not null;default:0"`
|
||||
CacheWrite1HTokens int64 `gorm:"column:cache_write_1h_tokens;not null;default:0"`
|
||||
CacheWriteUnknownTokens int64 `gorm:"column:cache_write_unknown_tokens;not null;default:0"`
|
||||
EstimatedCostNanoUSD int64 `gorm:"column:estimated_cost_nano_usd;not null;default:0;check:chk_request_log_cost_nano,estimated_cost_nano_usd >= 0"`
|
||||
UsageState string `gorm:"type:varchar(32);not null;default:'not_applicable';check:chk_request_log_usage_state,usage_state IN ('complete','partial','missing','not_applicable')"`
|
||||
CostState string `gorm:"type:varchar(32);not null;default:'not_applicable';check:chk_request_log_cost_state,cost_state IN ('priced','unpriced','not_applicable')"`
|
||||
PricingCompleteness string `gorm:"type:varchar(32);not null;default:'not_applicable';check:chk_request_log_pricing_completeness,pricing_completeness IN ('complete','partial','unavailable','not_applicable')"`
|
||||
Attempts JSON `gorm:"type:json"`
|
||||
ID string `gorm:"type:varchar(36);primaryKey;not null;index:idx_request_logs_completed_id,priority:2,sort:desc;index:idx_request_logs_access_completed_id,priority:3,sort:desc;index:idx_request_logs_status_completed_id,priority:3,sort:desc;index:idx_request_logs_model_completed_id,priority:3,sort:desc;index:idx_request_logs_upstream_model_completed_id,priority:3,sort:desc"`
|
||||
CompletedAtMS int64 `gorm:"column:completed_at_ms;not null;index:idx_request_logs_completed_id,priority:1,sort:desc;index:idx_request_logs_access_completed_id,priority:2,sort:desc;index:idx_request_logs_status_completed_id,priority:2,sort:desc;index:idx_request_logs_model_completed_id,priority:2,sort:desc;index:idx_request_logs_upstream_model_completed_id,priority:2,sort:desc"`
|
||||
AccessKeyID uint `gorm:"not null;index:idx_request_logs_access_completed_id,priority:1"`
|
||||
GroupID uint `gorm:"not null;default:0"`
|
||||
Protocol string `gorm:"type:varchar(32);not null"`
|
||||
ClientModel string `gorm:"type:varchar(255);not null;index:idx_request_logs_model_completed_id,priority:1"`
|
||||
UpstreamModel string `gorm:"type:varchar(255);not null;index:idx_request_logs_upstream_model_completed_id,priority:1"`
|
||||
Status string `gorm:"type:varchar(32);not null;index:idx_request_logs_status_completed_id,priority:1"`
|
||||
StatusCode int `gorm:"not null"`
|
||||
Stream bool `gorm:"not null;default:false"`
|
||||
FirstResponseMs *int64 `gorm:"column:first_response_ms"`
|
||||
DurationMs int64 `gorm:"not null"`
|
||||
AttemptCount int `gorm:"not null;default:0"`
|
||||
ErrorCode string `gorm:"type:varchar(64);not null;default:''"`
|
||||
ErrorSummary string `gorm:"type:text;not null;default:''"`
|
||||
AffinityHit bool `gorm:"not null;default:false"`
|
||||
UncachedInputTokens int64 `gorm:"column:uncached_input_tokens;not null;default:0"`
|
||||
OutputTokens int64 `gorm:"not null;default:0"`
|
||||
CacheReadTokens int64 `gorm:"not null;default:0"`
|
||||
CacheWrite5MTokens int64 `gorm:"column:cache_write_5m_tokens;not null;default:0"`
|
||||
CacheWrite1HTokens int64 `gorm:"column:cache_write_1h_tokens;not null;default:0"`
|
||||
CacheWriteUnknownTokens int64 `gorm:"column:cache_write_unknown_tokens;not null;default:0"`
|
||||
EstimatedCostNanoUSD int64 `gorm:"column:estimated_cost_nano_usd;not null;default:0;check:chk_request_log_cost_nano,estimated_cost_nano_usd >= 0"`
|
||||
UsageState string `gorm:"type:varchar(32);not null;default:'not_applicable';check:chk_request_log_usage_state,usage_state IN ('complete','partial','missing','not_applicable')"`
|
||||
CostState string `gorm:"type:varchar(32);not null;default:'not_applicable';check:chk_request_log_cost_state,cost_state IN ('priced','unpriced','not_applicable')"`
|
||||
PricingCompleteness string `gorm:"type:varchar(32);not null;default:'not_applicable';check:chk_request_log_pricing_completeness,pricing_completeness IN ('complete','partial','unavailable','not_applicable')"`
|
||||
AttemptRows []RequestLogAttempt `gorm:"-"`
|
||||
}
|
||||
|
||||
// RequestLogAttempt is one durable upstream attempt belonging to a client
|
||||
// request. Identity fields are snapshots and intentionally do not reference
|
||||
// mutable Group or Key catalog rows.
|
||||
type RequestLogAttempt struct {
|
||||
RequestID string `gorm:"type:varchar(36);primaryKey;not null"`
|
||||
Sequence int `gorm:"primaryKey;not null"`
|
||||
CompletedAtMS int64 `gorm:"column:completed_at_ms;not null"`
|
||||
GroupID uint `gorm:"not null"`
|
||||
GroupName string `gorm:"type:varchar(255);not null"`
|
||||
KeyID uint `gorm:"not null"`
|
||||
UpstreamModel string `gorm:"type:varchar(255);not null;default:''"`
|
||||
StatusCode int `gorm:"not null"`
|
||||
DurationMs int64 `gorm:"not null"`
|
||||
FailureCategory string `gorm:"type:varchar(32);not null"`
|
||||
Action string `gorm:"type:varchar(32);not null"`
|
||||
WillRetry bool `gorm:"not null;default:false"`
|
||||
ErrorCode string `gorm:"type:varchar(64);not null;default:''"`
|
||||
ErrorSummary string `gorm:"type:text;not null;default:''"`
|
||||
PricingReceipt JSON `gorm:"type:json"`
|
||||
}
|
||||
|
||||
// UsageStat is an hourly aggregate by access key, upstream group, and upstream model.
|
||||
|
||||
@@ -6,16 +6,16 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func createSchemaV4Tables(db *gorm.DB) error {
|
||||
for _, statement := range schemaV4TableStatements() {
|
||||
func createSchemaV5Tables(db *gorm.DB) error {
|
||||
for _, statement := range schemaV5TableStatements() {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
return fmt.Errorf("create schema v4 table: %w", err)
|
||||
return fmt.Errorf("create schema v5 table: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func schemaV4TableStatements() []string {
|
||||
func schemaV5TableStatements() []string {
|
||||
return []string{
|
||||
`CREATE TABLE groups (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -78,7 +78,11 @@ func schemaV4TableStatements() []string {
|
||||
upstream_model varchar(255) NOT NULL,
|
||||
status varchar(32) NOT NULL,
|
||||
status_code integer NOT NULL,
|
||||
stream numeric NOT NULL DEFAULT false,
|
||||
first_response_ms integer
|
||||
CHECK (first_response_ms IS NULL OR first_response_ms >= 0),
|
||||
duration_ms integer NOT NULL CHECK (duration_ms >= 0),
|
||||
attempt_count integer NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
||||
error_code varchar(64) NOT NULL DEFAULT '',
|
||||
error_summary text NOT NULL DEFAULT '',
|
||||
affinity_hit numeric NOT NULL DEFAULT false,
|
||||
@@ -96,7 +100,6 @@ func schemaV4TableStatements() []string {
|
||||
usage_state varchar(32) NOT NULL DEFAULT 'not_applicable',
|
||||
cost_state varchar(32) NOT NULL DEFAULT 'not_applicable',
|
||||
pricing_completeness varchar(32) NOT NULL DEFAULT 'not_applicable',
|
||||
attempts json,
|
||||
CONSTRAINT chk_request_log_cost_nano
|
||||
CHECK (estimated_cost_nano_usd >= 0),
|
||||
CONSTRAINT chk_request_log_status
|
||||
@@ -126,6 +129,35 @@ func schemaV4TableStatements() []string {
|
||||
))
|
||||
)
|
||||
)`,
|
||||
`CREATE TABLE request_log_attempts (
|
||||
request_id varchar(36) NOT NULL,
|
||||
sequence integer NOT NULL CHECK (sequence > 0),
|
||||
completed_at_ms integer NOT NULL CHECK (completed_at_ms >= 0),
|
||||
group_id integer NOT NULL CHECK (group_id > 0),
|
||||
group_name varchar(255) NOT NULL,
|
||||
key_id integer NOT NULL CHECK (key_id > 0),
|
||||
upstream_model varchar(255) NOT NULL DEFAULT '',
|
||||
status_code integer NOT NULL,
|
||||
duration_ms integer NOT NULL CHECK (duration_ms >= 0),
|
||||
failure_category varchar(32) NOT NULL,
|
||||
action varchar(32) NOT NULL,
|
||||
will_retry numeric NOT NULL DEFAULT false,
|
||||
error_code varchar(64) NOT NULL DEFAULT '',
|
||||
error_summary text NOT NULL DEFAULT '',
|
||||
pricing_receipt json,
|
||||
PRIMARY KEY (request_id, sequence),
|
||||
CONSTRAINT chk_request_log_attempt_failure_category CHECK (
|
||||
failure_category IN (
|
||||
'ok','rate_limited','model_unavailable','invalid_key',
|
||||
'upstream_host_error','client_error','downstream_cancel','ambiguous'
|
||||
)
|
||||
),
|
||||
CONSTRAINT chk_request_log_attempt_action CHECK (
|
||||
action IN ('terminate','retry','cooldown_key','fail_key','skip_group')
|
||||
),
|
||||
FOREIGN KEY (request_id) REFERENCES request_logs(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE usage_stats (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
bucket_start_ms integer NOT NULL CHECK (bucket_start_ms >= 0),
|
||||
@@ -229,16 +261,16 @@ func schemaV4TableStatements() []string {
|
||||
}
|
||||
}
|
||||
|
||||
func createSchemaV4Indexes(db *gorm.DB) error {
|
||||
for _, statement := range schemaV4IndexStatements() {
|
||||
func createSchemaV5Indexes(db *gorm.DB) error {
|
||||
for _, statement := range schemaV5IndexStatements() {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
return fmt.Errorf("create schema v4 index: %w", err)
|
||||
return fmt.Errorf("create schema v5 index: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func schemaV4IndexStatements() []string {
|
||||
func schemaV5IndexStatements() []string {
|
||||
return []string{
|
||||
`CREATE UNIQUE INDEX idx_groups_name ON groups(name)`,
|
||||
`CREATE UNIQUE INDEX idx_upstream_keys_group_hash
|
||||
@@ -254,6 +286,18 @@ func schemaV4IndexStatements() []string {
|
||||
ON request_logs(client_model, completed_at_ms DESC, id DESC)`,
|
||||
`CREATE INDEX idx_request_logs_upstream_model_completed_id
|
||||
ON request_logs(upstream_model, completed_at_ms DESC, id DESC)`,
|
||||
`CREATE INDEX idx_request_log_attempts_group_completed_request
|
||||
ON request_log_attempts(group_id, completed_at_ms DESC, request_id)`,
|
||||
`CREATE INDEX idx_request_log_attempts_key_completed_request
|
||||
ON request_log_attempts(key_id, completed_at_ms DESC, request_id)`,
|
||||
`CREATE INDEX idx_request_log_attempts_model_completed_request
|
||||
ON request_log_attempts(upstream_model, completed_at_ms DESC, request_id)`,
|
||||
`CREATE INDEX idx_request_log_attempts_status_completed_request
|
||||
ON request_log_attempts(status_code, completed_at_ms DESC, request_id)`,
|
||||
`CREATE INDEX idx_request_log_attempts_failure_completed_request
|
||||
ON request_log_attempts(failure_category, completed_at_ms DESC, request_id)`,
|
||||
`CREATE INDEX idx_request_log_attempts_error_completed_request
|
||||
ON request_log_attempts(error_code, completed_at_ms DESC, request_id)`,
|
||||
`CREATE UNIQUE INDEX idx_usage_stats_bucket_access_group_model
|
||||
ON usage_stats(bucket_start_ms, access_key_id, group_id, model)`,
|
||||
`CREATE UNIQUE INDEX idx_model_prices_scope_model
|
||||
@@ -270,16 +314,16 @@ func schemaV4IndexStatements() []string {
|
||||
}
|
||||
}
|
||||
|
||||
func validateSchemaV4ForeignKeys(db *gorm.DB) error {
|
||||
func validateSchemaV5ForeignKeys(db *gorm.DB) error {
|
||||
var violations []struct {
|
||||
Table string
|
||||
RowID int64 `gorm:"column:rowid"`
|
||||
}
|
||||
if err := db.Raw("PRAGMA foreign_key_check").Scan(&violations).Error; err != nil {
|
||||
return fmt.Errorf("validate schema v4 foreign keys: %w", err)
|
||||
return fmt.Errorf("validate schema v5 foreign keys: %w", err)
|
||||
}
|
||||
if len(violations) != 0 {
|
||||
return fmt.Errorf("validate schema v4 foreign keys: %d violation(s)", len(violations))
|
||||
return fmt.Errorf("validate schema v5 foreign keys: %d violation(s)", len(violations))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"gpt-load/internal/storage/models"
|
||||
)
|
||||
|
||||
type schemaV4Column struct {
|
||||
type schemaV5Column struct {
|
||||
Name string
|
||||
Type string
|
||||
NotNull int `gorm:"column:notnull"`
|
||||
@@ -21,12 +21,12 @@ type schemaV4Column struct {
|
||||
}
|
||||
|
||||
func TestAutoMigrateCreatesFinalPricingSchema(t *testing.T) {
|
||||
db := openSchemaV4TestDatabase(t)
|
||||
db := openSchemaV5TestDatabase(t)
|
||||
if err := storage.AutoMigrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if storage.CurrentSchemaVersion != 4 {
|
||||
t.Fatalf("CurrentSchemaVersion = %d, want 4", storage.CurrentSchemaVersion)
|
||||
if storage.CurrentSchemaVersion != 5 {
|
||||
t.Fatalf("CurrentSchemaVersion = %d, want 5", storage.CurrentSchemaVersion)
|
||||
}
|
||||
assertColumns(t, db, "model_prices", []string{
|
||||
"price_scope_key", "model_id",
|
||||
@@ -39,7 +39,7 @@ func TestAutoMigrateCreatesFinalPricingSchema(t *testing.T) {
|
||||
assertUniqueIndex(t, db, "model_prices", "idx_model_prices_scope_model",
|
||||
[]string{"price_scope_key", "model_id"})
|
||||
|
||||
columns := schemaV4Columns(t, db, "model_prices")
|
||||
columns := schemaV5Columns(t, db, "model_prices")
|
||||
for _, name := range []string{
|
||||
"input_price_nano_usd_per_million_tokens",
|
||||
"output_price_nano_usd_per_million_tokens",
|
||||
@@ -62,10 +62,50 @@ func TestAutoMigrateCreatesFinalPricingSchema(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMigrateCreatesNormalizedRequestLogSchemaV5(t *testing.T) {
|
||||
db := openSchemaV5TestDatabase(t)
|
||||
if err := storage.AutoMigrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if storage.CurrentSchemaVersion != 5 {
|
||||
t.Fatalf("CurrentSchemaVersion = %d, want 5", storage.CurrentSchemaVersion)
|
||||
}
|
||||
requestColumns := schemaV5Columns(t, db, "request_logs")
|
||||
for _, name := range []string{"stream", "first_response_ms", "attempt_count"} {
|
||||
if _, ok := requestColumns[name]; !ok {
|
||||
t.Errorf("request_logs.%s is missing", name)
|
||||
}
|
||||
}
|
||||
if _, ok := requestColumns["attempts"]; ok {
|
||||
t.Error("request_logs.attempts JSON column still exists")
|
||||
}
|
||||
assertColumns(t, db, "request_log_attempts", []string{
|
||||
"request_id", "sequence", "completed_at_ms", "group_id", "group_name",
|
||||
"key_id", "upstream_model", "status_code", "duration_ms",
|
||||
"failure_category", "action", "will_retry", "error_code", "error_summary",
|
||||
"pricing_receipt",
|
||||
})
|
||||
|
||||
var foreignKeys []struct {
|
||||
Table string
|
||||
From string
|
||||
To string
|
||||
OnDelete string `gorm:"column:on_delete"`
|
||||
}
|
||||
if err := db.Raw("PRAGMA foreign_key_list('request_log_attempts')").Scan(&foreignKeys).Error; err != nil {
|
||||
t.Fatalf("inspect request_log_attempts foreign keys: %v", err)
|
||||
}
|
||||
if len(foreignKeys) != 1 || foreignKeys[0].Table != "request_logs" ||
|
||||
foreignKeys[0].From != "request_id" || foreignKeys[0].To != "id" ||
|
||||
foreignKeys[0].OnDelete != "CASCADE" {
|
||||
t.Fatalf("request_log_attempts foreign keys = %#v", foreignKeys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMigrateRejectsUnreleasedLegacySchema(t *testing.T) {
|
||||
for _, version := range []uint{1, 2, 3} {
|
||||
for _, version := range []uint{1, 2, 3, 4} {
|
||||
t.Run(fmt.Sprintf("version_%d", version), func(t *testing.T) {
|
||||
db := openSchemaV4TestDatabase(t)
|
||||
db := openSchemaV5TestDatabase(t)
|
||||
if err := db.Exec("CREATE TABLE schema_info (version integer PRIMARY KEY)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -81,12 +121,12 @@ func TestAutoMigrateRejectsUnreleasedLegacySchema(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAutoMigrateCreatesFinalCatalogAndUsageColumns(t *testing.T) {
|
||||
db := openSchemaV4TestDatabase(t)
|
||||
db := openSchemaV5TestDatabase(t)
|
||||
if err := storage.AutoMigrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
groups := schemaV4Columns(t, db, "groups")
|
||||
groups := schemaV5Columns(t, db, "groups")
|
||||
providerID, ok := groups["provider_id"]
|
||||
if !ok {
|
||||
t.Fatal("groups.provider_id is missing")
|
||||
@@ -100,7 +140,7 @@ func TestAutoMigrateCreatesFinalCatalogAndUsageColumns(t *testing.T) {
|
||||
"request_logs": {"cache_write_unknown_tokens", "pricing_completeness"},
|
||||
"usage_stats": {"cache_write_unknown_tokens", "pricing_partial_count"},
|
||||
} {
|
||||
got := schemaV4Columns(t, db, table)
|
||||
got := schemaV5Columns(t, db, table)
|
||||
for _, name := range columns {
|
||||
column, found := got[name]
|
||||
if !found {
|
||||
@@ -113,7 +153,7 @@ func TestAutoMigrateCreatesFinalCatalogAndUsageColumns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
pricingCompleteness := schemaV4Columns(t, db, "request_logs")["pricing_completeness"]
|
||||
pricingCompleteness := schemaV5Columns(t, db, "request_logs")["pricing_completeness"]
|
||||
if pricingCompleteness.DefaultValue == nil ||
|
||||
strings.Trim(*pricingCompleteness.DefaultValue, "'\"") != "not_applicable" {
|
||||
t.Errorf("request_logs.pricing_completeness default = %v, want not_applicable",
|
||||
@@ -122,7 +162,7 @@ func TestAutoMigrateCreatesFinalCatalogAndUsageColumns(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFinalModelPriceRejectsNegativeScalarPrices(t *testing.T) {
|
||||
db := openMigratedSchemaV4TestDatabase(t)
|
||||
db := openMigratedSchemaV5TestDatabase(t)
|
||||
priceColumns := []string{
|
||||
"input_price_nano_usd_per_million_tokens",
|
||||
"output_price_nano_usd_per_million_tokens",
|
||||
@@ -142,7 +182,7 @@ func TestFinalModelPriceRejectsNegativeScalarPrices(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestModelPriceValidatesAndNormalizesContextTiersBeforePersistence(t *testing.T) {
|
||||
db := openMigratedSchemaV4TestDatabase(t)
|
||||
db := openMigratedSchemaV5TestDatabase(t)
|
||||
|
||||
empty := models.ModelPrice{
|
||||
PriceScopeKey: "provider:openai",
|
||||
@@ -270,7 +310,7 @@ func TestModelPriceValidatesContextTiersAcrossGORMWritePaths(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFinalRequestLogEnforcesStateContract(t *testing.T) {
|
||||
db := openMigratedSchemaV4TestDatabase(t)
|
||||
db := openMigratedSchemaV5TestDatabase(t)
|
||||
valid := []requestStateFixture{
|
||||
{id: "valid-priced-complete", status: "success", usage: "complete", cost: "priced", pricing: "complete", estimatedCost: 1},
|
||||
{id: "valid-priced-partial", status: "incomplete", usage: "partial", cost: "priced", pricing: "partial", estimatedCost: 0},
|
||||
@@ -302,7 +342,7 @@ func TestFinalRequestLogEnforcesStateContract(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFinalSchemaUsesMillisecondIntegersAndEnforcesCounters(t *testing.T) {
|
||||
db := openMigratedSchemaV4TestDatabase(t)
|
||||
db := openMigratedSchemaV5TestDatabase(t)
|
||||
|
||||
for table, names := range map[string][]string{
|
||||
"groups": {"created_at_ms", "updated_at_ms"},
|
||||
@@ -315,7 +355,7 @@ func TestFinalSchemaUsesMillisecondIntegersAndEnforcesCounters(t *testing.T) {
|
||||
"jobs": {"created_at_ms", "started_at_ms", "finished_at_ms"},
|
||||
"control_operations": {"completed_at_ms", "compacted_at_ms", "created_at_ms", "updated_at_ms"},
|
||||
} {
|
||||
columns := schemaV4Columns(t, db, table)
|
||||
columns := schemaV5Columns(t, db, table)
|
||||
for _, name := range names {
|
||||
column, ok := columns[name]
|
||||
if !ok {
|
||||
@@ -356,10 +396,10 @@ func TestFinalSchemaUsesMillisecondIntegersAndEnforcesCounters(t *testing.T) {
|
||||
id, completed_at_ms, access_key_id, group_id, protocol, client_model,
|
||||
upstream_model, status, status_code, duration_ms, error_code, error_summary,
|
||||
affinity_hit, estimated_cost_nano_usd, usage_state, cost_state,
|
||||
pricing_completeness, attempts
|
||||
pricing_completeness
|
||||
) VALUES ('negative-request-duration', 1, 0, 0, 'openai-completions', '', '',
|
||||
'success', 200, -1, '', '', false, 0,
|
||||
'complete', 'priced', 'complete', '[]')`,
|
||||
'complete', 'priced', 'complete')`,
|
||||
},
|
||||
{
|
||||
name: "negative request token",
|
||||
@@ -368,10 +408,10 @@ func TestFinalSchemaUsesMillisecondIntegersAndEnforcesCounters(t *testing.T) {
|
||||
upstream_model, status, status_code, duration_ms, error_code, error_summary,
|
||||
affinity_hit, uncached_input_tokens, output_tokens, cache_read_tokens,
|
||||
cache_write_5m_tokens, cache_write_1h_tokens, cache_write_unknown_tokens,
|
||||
estimated_cost_nano_usd, usage_state, cost_state, pricing_completeness, attempts
|
||||
estimated_cost_nano_usd, usage_state, cost_state, pricing_completeness
|
||||
) VALUES ('negative-request-token', 1, 0, 0, 'openai-completions', '', '',
|
||||
'success', 200, 0, '', '', false, 0, 0, 0, 0, 0, -1, 0,
|
||||
'complete', 'priced', 'complete', '[]')`,
|
||||
'complete', 'priced', 'complete')`,
|
||||
},
|
||||
{
|
||||
name: "negative request cost",
|
||||
@@ -379,10 +419,10 @@ func TestFinalSchemaUsesMillisecondIntegersAndEnforcesCounters(t *testing.T) {
|
||||
id, completed_at_ms, access_key_id, group_id, protocol, client_model,
|
||||
upstream_model, status, status_code, duration_ms, error_code, error_summary,
|
||||
affinity_hit, estimated_cost_nano_usd, usage_state, cost_state,
|
||||
pricing_completeness, attempts
|
||||
pricing_completeness
|
||||
) VALUES ('negative-request-cost', 1, 0, 0, 'openai-completions', '', '',
|
||||
'success', 200, 0, '', '', false, -1,
|
||||
'complete', 'priced', 'complete', '[]')`,
|
||||
'complete', 'priced', 'complete')`,
|
||||
},
|
||||
{
|
||||
name: "negative usage unknown cache write",
|
||||
@@ -423,7 +463,7 @@ func TestFinalSchemaUsesMillisecondIntegersAndEnforcesCounters(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAutoMigrateRejectsCorruptCanonicalTable(t *testing.T) {
|
||||
db := openMigratedSchemaV4TestDatabase(t)
|
||||
db := openMigratedSchemaV5TestDatabase(t)
|
||||
if err := db.Exec(`ALTER TABLE usage_stats RENAME TO usage_stats_checked`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -435,17 +475,17 @@ func TestAutoMigrateRejectsCorruptCanonicalTable(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := storage.AutoMigrate(db); err == nil || !strings.Contains(err.Error(), "table usage_stats differs") {
|
||||
t.Fatalf("AutoMigrate(corrupt v4 table) error = %v", err)
|
||||
t.Fatalf("AutoMigrate(corrupt v5 table) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMigrateValidatesExistingVersionFourSchema(t *testing.T) {
|
||||
db := openMigratedSchemaV4TestDatabase(t)
|
||||
func TestAutoMigrateValidatesExistingVersionFiveSchema(t *testing.T) {
|
||||
db := openMigratedSchemaV5TestDatabase(t)
|
||||
if err := db.Exec("DROP INDEX idx_model_prices_scope_model").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := storage.AutoMigrate(db); err == nil || !strings.Contains(err.Error(), "idx_model_prices_scope_model") {
|
||||
t.Fatalf("AutoMigrate(corrupt v4) error = %v, want missing-index validation", err)
|
||||
t.Fatalf("AutoMigrate(corrupt v5) error = %v, want missing-index validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,9 +504,9 @@ func insertRequestStateFixture(db *gorm.DB, fixture requestStateFixture) error {
|
||||
upstream_model, status, status_code, duration_ms, error_code, error_summary,
|
||||
affinity_hit, uncached_input_tokens, output_tokens, cache_read_tokens,
|
||||
cache_write_5m_tokens, cache_write_1h_tokens, cache_write_unknown_tokens,
|
||||
estimated_cost_nano_usd, usage_state, cost_state, pricing_completeness, attempts
|
||||
estimated_cost_nano_usd, usage_state, cost_state, pricing_completeness
|
||||
) VALUES (?, 1, 0, 0, 'openai-completions', '', '', ?, 200, 0, '', '',
|
||||
false, 0, 0, 0, 0, 0, 0, ?, ?, ?, ?, '[]')`,
|
||||
false, 0, 0, 0, 0, 0, 0, ?, ?, ?, ?)`,
|
||||
fixture.id, fixture.status, fixture.estimatedCost,
|
||||
fixture.usage, fixture.cost, fixture.pricing,
|
||||
).Error
|
||||
@@ -474,7 +514,7 @@ func insertRequestStateFixture(db *gorm.DB, fixture requestStateFixture) error {
|
||||
|
||||
func createModelPriceForWritePath(t *testing.T, modelID string) (*gorm.DB, models.ModelPrice) {
|
||||
t.Helper()
|
||||
db := openMigratedSchemaV4TestDatabase(t)
|
||||
db := openMigratedSchemaV5TestDatabase(t)
|
||||
row := models.ModelPrice{
|
||||
PriceScopeKey: "provider:openai",
|
||||
ModelID: modelID,
|
||||
@@ -506,7 +546,7 @@ func assertModelPriceTiersNull(
|
||||
}
|
||||
}
|
||||
|
||||
func openSchemaV4TestDatabase(t *testing.T) *gorm.DB {
|
||||
func openSchemaV5TestDatabase(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := storage.Open(":memory:")
|
||||
if err != nil {
|
||||
@@ -524,22 +564,22 @@ func openSchemaV4TestDatabase(t *testing.T) *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
func openMigratedSchemaV4TestDatabase(t *testing.T) *gorm.DB {
|
||||
func openMigratedSchemaV5TestDatabase(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db := openSchemaV4TestDatabase(t)
|
||||
db := openSchemaV5TestDatabase(t)
|
||||
if err := storage.AutoMigrate(db); err != nil {
|
||||
t.Fatalf("AutoMigrate() error = %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func schemaV4Columns(t *testing.T, db *gorm.DB, table string) map[string]schemaV4Column {
|
||||
func schemaV5Columns(t *testing.T, db *gorm.DB, table string) map[string]schemaV5Column {
|
||||
t.Helper()
|
||||
var columns []schemaV4Column
|
||||
var columns []schemaV5Column
|
||||
if err := db.Raw("PRAGMA table_info('" + table + "')").Scan(&columns).Error; err != nil {
|
||||
t.Fatalf("inspect %s columns: %v", table, err)
|
||||
}
|
||||
result := make(map[string]schemaV4Column, len(columns))
|
||||
result := make(map[string]schemaV5Column, len(columns))
|
||||
for _, column := range columns {
|
||||
result[column.Name] = column
|
||||
}
|
||||
@@ -548,7 +588,7 @@ func schemaV4Columns(t *testing.T, db *gorm.DB, table string) map[string]schemaV
|
||||
|
||||
func assertColumns(t *testing.T, db *gorm.DB, table string, want []string) {
|
||||
t.Helper()
|
||||
got := schemaV4Columns(t, db, table)
|
||||
got := schemaV5Columns(t, db, table)
|
||||
for _, name := range want {
|
||||
if _, ok := got[name]; !ok {
|
||||
t.Errorf("%s.%s is missing", table, name)
|
||||
+15
-15
@@ -7,23 +7,23 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const schemaV4InfoTableSQL = `CREATE TABLE schema_info (
|
||||
const schemaV5InfoTableSQL = `CREATE TABLE schema_info (
|
||||
version integer PRIMARY KEY NOT NULL,
|
||||
CONSTRAINT chk_schema_info_version CHECK (version = 4)
|
||||
CONSTRAINT chk_schema_info_version CHECK (version = 5)
|
||||
)`
|
||||
|
||||
func createSchemaV4InfoTable(db *gorm.DB) error {
|
||||
if err := db.Exec(schemaV4InfoTableSQL).Error; err != nil {
|
||||
return fmt.Errorf("create schema v4 info table: %w", err)
|
||||
func createSchemaV5InfoTable(db *gorm.DB) error {
|
||||
if err := db.Exec(schemaV5InfoTableSQL).Error; err != nil {
|
||||
return fmt.Errorf("create schema v5 info table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSchemaV4(db *gorm.DB) error {
|
||||
for _, expected := range append(schemaV4TableStatements(), schemaV4InfoTableSQL) {
|
||||
func validateSchemaV5(db *gorm.DB) error {
|
||||
for _, expected := range append(schemaV5TableStatements(), schemaV5InfoTableSQL) {
|
||||
fields := strings.Fields(expected)
|
||||
if len(fields) < 3 {
|
||||
return fmt.Errorf("validate SQLite schema version 4: invalid canonical table DDL")
|
||||
return fmt.Errorf("validate SQLite schema version 5: invalid canonical table DDL")
|
||||
}
|
||||
name := fields[2]
|
||||
actual, err := sqliteSchemaSQL(db, "table", name)
|
||||
@@ -31,14 +31,14 @@ func validateSchemaV4(db *gorm.DB) error {
|
||||
return err
|
||||
}
|
||||
if normalizeSQLiteDDL(actual) != normalizeSQLiteDDL(expected) {
|
||||
return fmt.Errorf("validate SQLite schema version 4: table %s differs", name)
|
||||
return fmt.Errorf("validate SQLite schema version 5: table %s differs", name)
|
||||
}
|
||||
}
|
||||
|
||||
for _, expected := range schemaV4IndexStatements() {
|
||||
for _, expected := range schemaV5IndexStatements() {
|
||||
fields := strings.Fields(expected)
|
||||
if len(fields) < 4 {
|
||||
return fmt.Errorf("validate SQLite schema version 4: invalid canonical index DDL")
|
||||
return fmt.Errorf("validate SQLite schema version 5: invalid canonical index DDL")
|
||||
}
|
||||
nameIndex := 2
|
||||
if strings.EqualFold(fields[1], "unique") {
|
||||
@@ -50,10 +50,10 @@ func validateSchemaV4(db *gorm.DB) error {
|
||||
return err
|
||||
}
|
||||
if normalizeSQLiteDDL(actual) != normalizeSQLiteDDL(expected) {
|
||||
return fmt.Errorf("validate SQLite schema version 4: critical index %s differs", name)
|
||||
return fmt.Errorf("validate SQLite schema version 5: critical index %s differs", name)
|
||||
}
|
||||
}
|
||||
return validateSchemaV4ForeignKeys(db)
|
||||
return validateSchemaV5ForeignKeys(db)
|
||||
}
|
||||
|
||||
func sqliteSchemaSQL(db *gorm.DB, objectType, name string) (string, error) {
|
||||
@@ -65,7 +65,7 @@ func sqliteSchemaSQL(db *gorm.DB, objectType, name string) (string, error) {
|
||||
).Scan(&statement)
|
||||
if result.Error != nil {
|
||||
return "", fmt.Errorf(
|
||||
"validate SQLite schema version 4: read %s %s: %w",
|
||||
"validate SQLite schema version 5: read %s %s: %w",
|
||||
objectType,
|
||||
name,
|
||||
result.Error,
|
||||
@@ -73,7 +73,7 @@ func sqliteSchemaSQL(db *gorm.DB, objectType, name string) (string, error) {
|
||||
}
|
||||
if result.RowsAffected != 1 || strings.TrimSpace(statement) == "" {
|
||||
return "", fmt.Errorf(
|
||||
"validate SQLite schema version 4: %s %s is missing",
|
||||
"validate SQLite schema version 5: %s %s is missing",
|
||||
objectType,
|
||||
name,
|
||||
)
|
||||
@@ -63,6 +63,7 @@ type PricingObservation struct {
|
||||
CostState string
|
||||
PricingCompleteness string
|
||||
EstimatedCostNanoUSD int64
|
||||
ReceiptJSON string
|
||||
}
|
||||
|
||||
type UsageObservation struct {
|
||||
@@ -74,20 +75,22 @@ type UsageObservation struct {
|
||||
}
|
||||
|
||||
type RequestEvent struct {
|
||||
RequestID string
|
||||
CompletedAt time.Time
|
||||
AccessKeyID uint
|
||||
Protocol protocol.Protocol
|
||||
ClientModel string
|
||||
UpstreamModel string
|
||||
Status RequestStatus
|
||||
StatusCode int
|
||||
ErrorCode string
|
||||
ErrorSummary string
|
||||
DurationMs int64
|
||||
AffinityHit bool
|
||||
Attempts []Attempt
|
||||
Usage UsageObservation
|
||||
RequestID string
|
||||
CompletedAt time.Time
|
||||
AccessKeyID uint
|
||||
Protocol protocol.Protocol
|
||||
ClientModel string
|
||||
UpstreamModel string
|
||||
Status RequestStatus
|
||||
StatusCode int
|
||||
ErrorCode string
|
||||
ErrorSummary string
|
||||
Stream bool
|
||||
FirstResponseMs *int64
|
||||
DurationMs int64
|
||||
AffinityHit bool
|
||||
Attempts []Attempt
|
||||
Usage UsageObservation
|
||||
}
|
||||
|
||||
type RequestLogSink interface {
|
||||
|
||||
@@ -27,6 +27,8 @@ func TestRequestTelemetryContractUsesExactFieldAllowlist(t *testing.T) {
|
||||
"StatusCode",
|
||||
"ErrorCode",
|
||||
"ErrorSummary",
|
||||
"Stream",
|
||||
"FirstResponseMs",
|
||||
"DurationMs",
|
||||
"AffinityHit",
|
||||
"Attempts",
|
||||
@@ -53,6 +55,7 @@ func TestRequestTelemetryContractUsesExactFieldAllowlist(t *testing.T) {
|
||||
"CostState",
|
||||
"PricingCompleteness",
|
||||
"EstimatedCostNanoUSD",
|
||||
"ReceiptJSON",
|
||||
},
|
||||
reflect.TypeOf(UsageObservation{}): {
|
||||
"Result",
|
||||
|
||||
@@ -56,10 +56,35 @@ function normalizeLogFilters(filters: RequestLogFilters): RequestLogFilters {
|
||||
'from_ms',
|
||||
'to_ms',
|
||||
'group_id',
|
||||
'model',
|
||||
'client_model',
|
||||
'upstream_model',
|
||||
'access_key_id',
|
||||
'status',
|
||||
'request_id',
|
||||
'protocol',
|
||||
'stream',
|
||||
'final_status_code',
|
||||
'usage_state',
|
||||
'cost_state',
|
||||
'pricing_completeness',
|
||||
'cache_present',
|
||||
'upstream_key_id',
|
||||
'attempt_status_code',
|
||||
'failure_category',
|
||||
'error_code',
|
||||
'retry_state',
|
||||
'retry_count_min',
|
||||
'retry_count_max',
|
||||
'first_response_min_ms',
|
||||
'first_response_max_ms',
|
||||
'duration_min_ms',
|
||||
'duration_max_ms',
|
||||
'input_tokens_min',
|
||||
'input_tokens_max',
|
||||
'output_tokens_min',
|
||||
'output_tokens_max',
|
||||
'cost_min_nano_usd',
|
||||
'cost_max_nano_usd',
|
||||
] as const) {
|
||||
const value = filters[field]
|
||||
if (value !== undefined) Object.assign(result, { [field]: value })
|
||||
@@ -107,6 +132,8 @@ export const controlQueryKeys = {
|
||||
all: ['control', 'logs'] as const,
|
||||
list: (filters: RequestLogFilters) =>
|
||||
['control', 'logs', 'list', normalizeLogFilters(filters)] as const,
|
||||
details: () => ['control', 'logs', 'detail'] as const,
|
||||
detail: (requestID: string) => ['control', 'logs', 'detail', requestID] as const,
|
||||
},
|
||||
usage: {
|
||||
all: ['control', 'usage'] as const,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { keepPreviousData, queryOptions } from '@tanstack/vue-query'
|
||||
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import type { ApiClient } from '@/api/client'
|
||||
@@ -19,23 +20,71 @@ import {
|
||||
} from './projector'
|
||||
|
||||
export type RequestLogStatus = 'success' | 'error' | 'incomplete' | 'canceled'
|
||||
|
||||
export type { FailureCategory } from '@/api/control/types'
|
||||
|
||||
export type RequestLogAction = 'terminate' | 'retry' | 'cooldown_key' | 'fail_key' | 'skip_group'
|
||||
export type RequestLogUsageState = 'complete' | 'partial' | 'missing' | 'not_applicable'
|
||||
export type RequestLogCostState = 'priced' | 'unpriced' | 'not_applicable'
|
||||
export type RequestLogPricingCompleteness =
|
||||
'complete' | 'partial' | 'unavailable' | 'not_applicable'
|
||||
export type RequestLogRetryState = 'retried' | 'not_retried'
|
||||
export type RequestLogReceiptLineState = 'priced' | 'unpriced'
|
||||
export type RequestLogPageSize = 20 | 50 | 100
|
||||
|
||||
export type { FailureCategory } from '@/api/control/types'
|
||||
|
||||
export interface RequestLogFilters {
|
||||
from_ms?: number
|
||||
to_ms?: number
|
||||
limit?: RequestLogPageSize
|
||||
group_id?: number
|
||||
model?: string
|
||||
client_model?: string
|
||||
upstream_model?: string
|
||||
access_key_id?: number
|
||||
status?: RequestLogStatus
|
||||
request_id?: string
|
||||
protocol?: AccessProtocol
|
||||
stream?: boolean
|
||||
final_status_code?: number
|
||||
usage_state?: RequestLogUsageState
|
||||
cost_state?: RequestLogCostState
|
||||
pricing_completeness?: RequestLogPricingCompleteness
|
||||
cache_present?: boolean
|
||||
upstream_key_id?: number
|
||||
attempt_status_code?: number
|
||||
failure_category?: FailureCategory
|
||||
error_code?: string
|
||||
retry_state?: RequestLogRetryState
|
||||
retry_count_min?: number
|
||||
retry_count_max?: number
|
||||
first_response_min_ms?: number
|
||||
first_response_max_ms?: number
|
||||
duration_min_ms?: number
|
||||
duration_max_ms?: number
|
||||
input_tokens_min?: number
|
||||
input_tokens_max?: number
|
||||
output_tokens_min?: number
|
||||
output_tokens_max?: number
|
||||
cost_min_nano_usd?: string
|
||||
cost_max_nano_usd?: string
|
||||
}
|
||||
|
||||
export interface RequestLogPricingLineDto {
|
||||
code: 'input' | 'cache_read' | 'cache_write_5m' | 'cache_write_1h' | 'cache_write' | 'output'
|
||||
quantity: string
|
||||
rate_nano_usd_per_million: string | null
|
||||
multiplier: { numerator: string; denominator: string }
|
||||
state: RequestLogReceiptLineState
|
||||
amount_nano_usd: string | null
|
||||
}
|
||||
|
||||
export interface RequestLogPricingReceiptDto {
|
||||
schema_version: 1
|
||||
method: 'unit_rate_sum'
|
||||
method_version: 1
|
||||
currency: 'USD'
|
||||
rule: { scope_key: string; model_id: string }
|
||||
context_threshold_tokens: string | null
|
||||
line_items: RequestLogPricingLineDto[]
|
||||
total_nano_usd: string
|
||||
}
|
||||
|
||||
export interface RequestLogAttemptDto {
|
||||
@@ -51,40 +100,42 @@ export interface RequestLogAttemptDto {
|
||||
will_retry: boolean
|
||||
error_code: string
|
||||
error_summary: string
|
||||
committed: boolean
|
||||
pricing_receipt: RequestLogPricingReceiptDto | null
|
||||
}
|
||||
|
||||
export interface RequestLogItemDto {
|
||||
request_id: string
|
||||
completed_at_ms: number
|
||||
access_key: {
|
||||
id: number
|
||||
name: string | null
|
||||
deleted: boolean
|
||||
}
|
||||
access_key: { id: number; name: string | null; deleted: boolean }
|
||||
protocol: AccessProtocol
|
||||
client_model: string | null
|
||||
upstream_model: string | null
|
||||
status: RequestLogStatus
|
||||
status_code: number
|
||||
stream: boolean
|
||||
first_response_ms: number | null
|
||||
duration_ms: number
|
||||
attempt_count: number
|
||||
error_code: string
|
||||
error_summary: string
|
||||
affinity_hit: boolean
|
||||
attempts: RequestLogAttemptDto[]
|
||||
group_id: number | null
|
||||
usage_state: RequestLogUsageState
|
||||
cost_state: RequestLogCostState
|
||||
pricing_completeness: RequestLogPricingCompleteness
|
||||
uncached_input_tokens: number
|
||||
cache_read_tokens: number
|
||||
cache_write_5m_tokens: number
|
||||
cache_write_1h_tokens: number
|
||||
cache_write_unknown_tokens: number
|
||||
output_tokens: number
|
||||
input_tokens: string
|
||||
cache_read_tokens: string
|
||||
cache_write_5m_tokens: string
|
||||
cache_write_1h_tokens: string
|
||||
cache_write_unknown_tokens: string
|
||||
output_tokens: string
|
||||
estimated_cost_nano_usd: string
|
||||
}
|
||||
|
||||
export interface RequestLogDetailDto extends RequestLogItemDto {
|
||||
attempts: RequestLogAttemptDto[]
|
||||
}
|
||||
|
||||
export interface RequestLogPageDto {
|
||||
items: RequestLogItemDto[]
|
||||
next_cursor: string | null
|
||||
@@ -106,21 +157,15 @@ const actions = ['terminate', 'retry', 'cooldown_key', 'fail_key', 'skip_group']
|
||||
const usageStates = ['complete', 'partial', 'missing', 'not_applicable'] as const
|
||||
const costStates = ['priced', 'unpriced', 'not_applicable'] as const
|
||||
const pricingCompletenessValues = ['complete', 'partial', 'unavailable', 'not_applicable'] as const
|
||||
const attemptFields = [
|
||||
'sequence',
|
||||
'group_id',
|
||||
'group_name',
|
||||
'key_id',
|
||||
'upstream_model',
|
||||
'status_code',
|
||||
'duration_ms',
|
||||
'failure_category',
|
||||
'action',
|
||||
'will_retry',
|
||||
'error_code',
|
||||
'error_summary',
|
||||
'committed',
|
||||
const receiptCodes = [
|
||||
'input',
|
||||
'cache_read',
|
||||
'cache_write_5m',
|
||||
'cache_write_1h',
|
||||
'cache_write',
|
||||
'output',
|
||||
] as const
|
||||
const receiptLineStates = ['priced', 'unpriced'] as const
|
||||
const itemFields = [
|
||||
'request_id',
|
||||
'completed_at_ms',
|
||||
@@ -130,16 +175,18 @@ const itemFields = [
|
||||
'upstream_model',
|
||||
'status',
|
||||
'status_code',
|
||||
'stream',
|
||||
'first_response_ms',
|
||||
'duration_ms',
|
||||
'attempt_count',
|
||||
'error_code',
|
||||
'error_summary',
|
||||
'affinity_hit',
|
||||
'attempts',
|
||||
'group_id',
|
||||
'usage_state',
|
||||
'cost_state',
|
||||
'pricing_completeness',
|
||||
'uncached_input_tokens',
|
||||
'input_tokens',
|
||||
'cache_read_tokens',
|
||||
'cache_write_5m_tokens',
|
||||
'cache_write_1h_tokens',
|
||||
@@ -182,9 +229,87 @@ function projectAccessKey(value: unknown): RequestLogItemDto['access_key'] {
|
||||
}
|
||||
}
|
||||
|
||||
function projectPricingReceipt(value: unknown): RequestLogPricingReceiptDto | null {
|
||||
if (value === null) return null
|
||||
const record = projectRecord(value)
|
||||
assertNoSecretLikeFields(record, [
|
||||
'schema_version',
|
||||
'method',
|
||||
'method_version',
|
||||
'currency',
|
||||
'rule',
|
||||
'context_threshold_tokens',
|
||||
'line_items',
|
||||
'total_nano_usd',
|
||||
])
|
||||
const rule = projectRecord(record.rule)
|
||||
assertNoSecretLikeFields(rule, ['scope_key', 'model_id'])
|
||||
const lines = projectArray(record.line_items, (lineValue): RequestLogPricingLineDto => {
|
||||
const line = projectRecord(lineValue)
|
||||
assertNoSecretLikeFields(line, [
|
||||
'code',
|
||||
'quantity',
|
||||
'rate_nano_usd_per_million',
|
||||
'multiplier',
|
||||
'state',
|
||||
'amount_nano_usd',
|
||||
])
|
||||
const multiplier = projectRecord(line.multiplier)
|
||||
assertNoSecretLikeFields(multiplier, ['numerator', 'denominator'])
|
||||
const numerator = projectNonNegativeInt64String(multiplier.numerator)
|
||||
const denominator = projectNonNegativeInt64String(multiplier.denominator)
|
||||
if (numerator === '0' || denominator === '0') invalidResponse()
|
||||
return {
|
||||
code: projectEnum(line.code, receiptCodes),
|
||||
quantity: projectNonNegativeInt64String(line.quantity),
|
||||
rate_nano_usd_per_million:
|
||||
line.rate_nano_usd_per_million === null
|
||||
? null
|
||||
: projectNonNegativeInt64String(line.rate_nano_usd_per_million),
|
||||
multiplier: {
|
||||
numerator,
|
||||
denominator,
|
||||
},
|
||||
state: projectEnum(line.state, receiptLineStates),
|
||||
amount_nano_usd:
|
||||
line.amount_nano_usd === null ? null : projectNonNegativeInt64String(line.amount_nano_usd),
|
||||
}
|
||||
})
|
||||
return {
|
||||
schema_version: projectSafeInteger(record.schema_version, { minimum: 1, maximum: 1 }) as 1,
|
||||
method: projectEnum(record.method, ['unit_rate_sum'] as const),
|
||||
method_version: projectSafeInteger(record.method_version, { minimum: 1, maximum: 1 }) as 1,
|
||||
currency: projectEnum(record.currency, ['USD'] as const),
|
||||
rule: {
|
||||
scope_key: projectNonBlankString(rule.scope_key),
|
||||
model_id: projectNonBlankString(rule.model_id),
|
||||
},
|
||||
context_threshold_tokens:
|
||||
record.context_threshold_tokens === null
|
||||
? null
|
||||
: projectNonNegativeInt64String(record.context_threshold_tokens),
|
||||
line_items: lines,
|
||||
total_nano_usd: projectNonNegativeInt64String(record.total_nano_usd),
|
||||
}
|
||||
}
|
||||
|
||||
function projectAttempt(value: unknown): RequestLogAttemptDto {
|
||||
const record = projectRecord(value)
|
||||
assertNoSecretLikeFields(record, attemptFields)
|
||||
assertNoSecretLikeFields(record, [
|
||||
'sequence',
|
||||
'group_id',
|
||||
'group_name',
|
||||
'key_id',
|
||||
'upstream_model',
|
||||
'status_code',
|
||||
'duration_ms',
|
||||
'failure_category',
|
||||
'action',
|
||||
'will_retry',
|
||||
'error_code',
|
||||
'error_summary',
|
||||
'pricing_receipt',
|
||||
])
|
||||
return {
|
||||
sequence: projectSafeInteger(record.sequence, { minimum: 1 }),
|
||||
group_id: projectSafeInteger(record.group_id, { minimum: 1 }),
|
||||
@@ -198,7 +323,7 @@ function projectAttempt(value: unknown): RequestLogAttemptDto {
|
||||
will_retry: projectBoolean(record.will_retry),
|
||||
error_code: projectString(record.error_code, { allowEmpty: true }),
|
||||
error_summary: projectString(record.error_summary, { allowEmpty: true }),
|
||||
committed: projectBoolean(record.committed),
|
||||
pricing_receipt: projectPricingReceipt(record.pricing_receipt),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,30 +344,23 @@ function projectUsageCost(record: Record<string, unknown>) {
|
||||
pricingCompleteness === 'not_applicable')
|
||||
if (!validCombination) invalidResponse()
|
||||
|
||||
const tokens = {
|
||||
uncached_input_tokens: projectSafeInteger(record.uncached_input_tokens, { minimum: 0 }),
|
||||
cache_read_tokens: projectSafeInteger(record.cache_read_tokens, { minimum: 0 }),
|
||||
cache_write_5m_tokens: projectSafeInteger(record.cache_write_5m_tokens, { minimum: 0 }),
|
||||
cache_write_1h_tokens: projectSafeInteger(record.cache_write_1h_tokens, { minimum: 0 }),
|
||||
cache_write_unknown_tokens: projectSafeInteger(record.cache_write_unknown_tokens, {
|
||||
minimum: 0,
|
||||
}),
|
||||
output_tokens: projectSafeInteger(record.output_tokens, { minimum: 0 }),
|
||||
}
|
||||
const estimatedCostNanoUSD = projectNonNegativeInt64String(record.estimated_cost_nano_usd)
|
||||
if (costState !== 'priced' && estimatedCostNanoUSD !== '0') invalidResponse()
|
||||
return {
|
||||
usage_state: usageState,
|
||||
cost_state: costState,
|
||||
pricing_completeness: pricingCompleteness,
|
||||
...tokens,
|
||||
input_tokens: projectNonNegativeInt64String(record.input_tokens),
|
||||
cache_read_tokens: projectNonNegativeInt64String(record.cache_read_tokens),
|
||||
cache_write_5m_tokens: projectNonNegativeInt64String(record.cache_write_5m_tokens),
|
||||
cache_write_1h_tokens: projectNonNegativeInt64String(record.cache_write_1h_tokens),
|
||||
cache_write_unknown_tokens: projectNonNegativeInt64String(record.cache_write_unknown_tokens),
|
||||
output_tokens: projectNonNegativeInt64String(record.output_tokens),
|
||||
estimated_cost_nano_usd: estimatedCostNanoUSD,
|
||||
}
|
||||
}
|
||||
|
||||
export function projectRequestLogItem(value: unknown): RequestLogItemDto {
|
||||
const record = projectRecord(value)
|
||||
assertNoSecretLikeFields(record, itemFields)
|
||||
function projectItemRecord(record: Record<string, unknown>): RequestLogItemDto {
|
||||
return {
|
||||
request_id: projectRequestID(record.request_id),
|
||||
completed_at_ms: projectEpochMilliseconds(record.completed_at_ms),
|
||||
@@ -252,41 +370,83 @@ export function projectRequestLogItem(value: unknown): RequestLogItemDto {
|
||||
upstream_model: projectNullableModel(record.upstream_model),
|
||||
status: projectEnum(record.status, statuses),
|
||||
status_code: projectStatusCode(record.status_code),
|
||||
stream: projectBoolean(record.stream),
|
||||
first_response_ms:
|
||||
record.first_response_ms === null
|
||||
? null
|
||||
: projectSafeInteger(record.first_response_ms, { minimum: 0 }),
|
||||
duration_ms: projectSafeInteger(record.duration_ms, { minimum: 0 }),
|
||||
attempt_count: projectSafeInteger(record.attempt_count, { minimum: 0 }),
|
||||
error_code: projectString(record.error_code, { allowEmpty: true }),
|
||||
error_summary: projectString(record.error_summary, { allowEmpty: true }),
|
||||
affinity_hit: projectBoolean(record.affinity_hit),
|
||||
attempts: projectArray(record.attempts, projectAttempt),
|
||||
group_id: record.group_id === null ? null : projectSafeInteger(record.group_id, { minimum: 1 }),
|
||||
...projectUsageCost(record),
|
||||
}
|
||||
}
|
||||
|
||||
export function projectRequestLogItem(value: unknown): RequestLogItemDto {
|
||||
const record = projectRecord(value)
|
||||
assertNoSecretLikeFields(record, itemFields)
|
||||
return projectItemRecord(record)
|
||||
}
|
||||
|
||||
export function projectRequestLogDetail(value: unknown): RequestLogDetailDto {
|
||||
const record = projectRecord(value)
|
||||
assertNoSecretLikeFields(record, [...itemFields, 'attempts'])
|
||||
return { ...projectItemRecord(record), attempts: projectArray(record.attempts, projectAttempt) }
|
||||
}
|
||||
|
||||
export function projectRequestLogPage(value: unknown): RequestLogPageDto {
|
||||
const record = projectRecord(value)
|
||||
assertNoSecretLikeFields(record, ['items', 'next_cursor'])
|
||||
const nextCursor = record.next_cursor === null ? null : projectNonBlankString(record.next_cursor)
|
||||
return {
|
||||
items: projectArray(record.items, projectRequestLogItem),
|
||||
next_cursor: nextCursor,
|
||||
next_cursor: record.next_cursor === null ? null : projectNonBlankString(record.next_cursor),
|
||||
}
|
||||
}
|
||||
|
||||
const filterFields = [
|
||||
'from_ms',
|
||||
'to_ms',
|
||||
'limit',
|
||||
'group_id',
|
||||
'client_model',
|
||||
'upstream_model',
|
||||
'access_key_id',
|
||||
'status',
|
||||
'request_id',
|
||||
'protocol',
|
||||
'stream',
|
||||
'final_status_code',
|
||||
'usage_state',
|
||||
'cost_state',
|
||||
'pricing_completeness',
|
||||
'cache_present',
|
||||
'upstream_key_id',
|
||||
'attempt_status_code',
|
||||
'failure_category',
|
||||
'error_code',
|
||||
'retry_state',
|
||||
'retry_count_min',
|
||||
'retry_count_max',
|
||||
'first_response_min_ms',
|
||||
'first_response_max_ms',
|
||||
'duration_min_ms',
|
||||
'duration_max_ms',
|
||||
'input_tokens_min',
|
||||
'input_tokens_max',
|
||||
'output_tokens_min',
|
||||
'output_tokens_max',
|
||||
'cost_min_nano_usd',
|
||||
'cost_max_nano_usd',
|
||||
] as const satisfies readonly (keyof RequestLogFilters)[]
|
||||
|
||||
export function normalizeRequestLogFilters(filters: RequestLogFilters): RequestLogFilters {
|
||||
const result: RequestLogFilters = {}
|
||||
for (const field of [
|
||||
'from_ms',
|
||||
'to_ms',
|
||||
'group_id',
|
||||
'model',
|
||||
'access_key_id',
|
||||
'status',
|
||||
'request_id',
|
||||
] as const) {
|
||||
for (const field of filterFields) {
|
||||
const value = filters[field]
|
||||
if (value !== undefined) {
|
||||
Object.assign(result, { [field]: value })
|
||||
}
|
||||
if (value !== undefined) Object.assign(result, { [field]: value })
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -301,36 +461,60 @@ export async function listRequestLogs(
|
||||
cursor?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RequestLogPageDto> {
|
||||
const normalized = normalizeRequestLogFilters(filters)
|
||||
const params = new URLSearchParams()
|
||||
const values: Array<[string, string | number | undefined]> = [
|
||||
['from_ms', normalized.from_ms],
|
||||
['to_ms', normalized.to_ms],
|
||||
['group_id', normalized.group_id],
|
||||
['client_model', normalized.model],
|
||||
['access_key_id', normalized.access_key_id],
|
||||
['status', normalized.status],
|
||||
['request_id', normalized.request_id],
|
||||
['cursor', cursor],
|
||||
]
|
||||
for (const [key, value] of values) {
|
||||
if (value !== undefined) params.append(key, String(value))
|
||||
for (const field of filterFields) {
|
||||
const value = filters[field]
|
||||
if (value !== undefined) params.append(field, String(value))
|
||||
}
|
||||
if (cursor !== undefined) params.append('cursor', cursor)
|
||||
const query = params.toString()
|
||||
const path: `/api/${string}` = query === '' ? '/api/logs' : `/api/logs?${query}`
|
||||
return projectRequestLogPage(await client.request(path, { method: 'GET', signal }))
|
||||
}
|
||||
|
||||
export function requestLogInfiniteQueryOptions(
|
||||
export async function getRequestLog(
|
||||
client: ApiClient,
|
||||
requestID: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RequestLogDetailDto> {
|
||||
if (!requestIDPattern.test(requestID)) throw new InvalidResponseError()
|
||||
return projectRequestLogDetail(
|
||||
await client.request(`/api/logs/${encodeURIComponent(requestID)}`, { method: 'GET', signal }),
|
||||
)
|
||||
}
|
||||
|
||||
export function requestLogQueryOptions(
|
||||
client: ApiClient,
|
||||
filters: MaybeRefOrGetter<RequestLogFilters>,
|
||||
cursor: MaybeRefOrGetter<string | undefined>,
|
||||
) {
|
||||
return {
|
||||
queryKey: computed(() => requestLogQueryIdentity(toValue(filters))),
|
||||
initialPageParam: null as string | null,
|
||||
queryFn: ({ pageParam, signal }: { pageParam: string | null; signal: AbortSignal }) =>
|
||||
listRequestLogs(client, toValue(filters), pageParam ?? undefined, signal),
|
||||
getNextPageParam: (lastPage: RequestLogPageDto) => lastPage.next_cursor ?? undefined,
|
||||
return queryOptions({
|
||||
queryKey: computed(() => [
|
||||
...requestLogQueryIdentity(toValue(filters)),
|
||||
'cursor',
|
||||
toValue(cursor) ?? null,
|
||||
]),
|
||||
queryFn: ({ signal }) => listRequestLogs(client, toValue(filters), toValue(cursor), signal),
|
||||
placeholderData: keepPreviousData,
|
||||
gcTime: 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function requestLogDetailQueryOptions(
|
||||
client: ApiClient,
|
||||
requestID: MaybeRefOrGetter<string | undefined>,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: computed(() => {
|
||||
const id = toValue(requestID)
|
||||
return id === undefined ? controlQueryKeys.logs.details() : controlQueryKeys.logs.detail(id)
|
||||
}),
|
||||
queryFn: ({ signal }) => {
|
||||
const id = toValue(requestID)
|
||||
if (id === undefined) throw new InvalidResponseError()
|
||||
return getRequestLog(client, id, signal)
|
||||
},
|
||||
enabled: computed(() => toValue(requestID) !== undefined),
|
||||
gcTime: 0,
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { formatISOInstant, formatLocalInstant } from '@/lib/format'
|
||||
import { formatISOInstant, formatLocalInstant, formatLocalInstantWithSeconds } from '@/lib/format'
|
||||
import { currentTimeZone } from '@/lib/time'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -9,9 +9,11 @@ const props = withDefaults(
|
||||
instant: number
|
||||
locale: string
|
||||
timeZone?: string
|
||||
precision?: 'minute' | 'second'
|
||||
}>(),
|
||||
{
|
||||
timeZone: currentTimeZone(),
|
||||
precision: 'minute',
|
||||
},
|
||||
)
|
||||
|
||||
@@ -20,7 +22,9 @@ const instantMs = computed(() => (dateTime.value === undefined ? null : props.in
|
||||
|
||||
const absolute = computed(() => {
|
||||
if (instantMs.value === null) return String(props.instant)
|
||||
return formatLocalInstant(instantMs.value, props.locale, { timeZone: props.timeZone })
|
||||
return props.precision === 'second'
|
||||
? formatLocalInstantWithSeconds(instantMs.value, props.timeZone)
|
||||
: formatLocalInstant(instantMs.value, props.locale, { timeZone: props.timeZone })
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
<script setup lang="ts">
|
||||
import { CalendarClock } from '@lucide/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { currentTimeZone } from '@/lib/time'
|
||||
|
||||
import AppButton from './AppButton.vue'
|
||||
import AppPopover from './AppPopover.vue'
|
||||
import FormField from './FormField.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
from: string
|
||||
to: string
|
||||
label: string
|
||||
fromLabel: string
|
||||
toLabel: string
|
||||
timezoneLabel: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'update:from': [value: string]
|
||||
'update:to': [value: string]
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
const open = ref(false)
|
||||
const timezone = currentTimeZone()
|
||||
const shortcuts = [
|
||||
{ key: '15m', milliseconds: 15 * 60 * 1000 },
|
||||
{ key: '1h', milliseconds: 60 * 60 * 1000 },
|
||||
{ key: '24h', milliseconds: 24 * 60 * 60 * 1000 },
|
||||
{ key: '3d', milliseconds: 3 * 24 * 60 * 60 * 1000 },
|
||||
{ key: '7d', milliseconds: 7 * 24 * 60 * 60 * 1000 },
|
||||
{ key: '15d', milliseconds: 15 * 24 * 60 * 60 * 1000 },
|
||||
] as const
|
||||
|
||||
const display = computed(() => `${displayValue(props.from)} → ${displayValue(props.to)}`)
|
||||
|
||||
function displayValue(value: string): string {
|
||||
const normalized = normalizeLocalInputValue(value)
|
||||
return normalized ? normalized.replace('T', ' ') : '—'
|
||||
}
|
||||
|
||||
function normalizeLocalInputValue(value: string): string {
|
||||
if (!value) return ''
|
||||
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/u.test(value) ? `${value}:00` : value
|
||||
}
|
||||
|
||||
function updateLocalInput(field: 'from' | 'to', value: string): void {
|
||||
const normalized = normalizeLocalInputValue(value)
|
||||
if (field === 'from') {
|
||||
emit('update:from', normalized)
|
||||
} else {
|
||||
emit('update:to', normalized)
|
||||
}
|
||||
}
|
||||
|
||||
function localInputValue(milliseconds: number): string {
|
||||
const date = new Date(milliseconds)
|
||||
const pad = (part: number) => String(part).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
|
||||
date.getHours(),
|
||||
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
function selectShortcut(milliseconds: number): void {
|
||||
const now = Math.floor(Date.now() / 1000) * 1000
|
||||
const to = now + 24 * 60 * 60 * 1000
|
||||
emit('update:from', localInputValue(Math.max(0, now - milliseconds)))
|
||||
emit('update:to', localInputValue(to))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPopover v-model:open="open" align="start" content-class="app-date-range-popover">
|
||||
<template #trigger>
|
||||
<AppButton
|
||||
class="app-date-range__trigger"
|
||||
variant="secondary"
|
||||
size="compact"
|
||||
:aria-label="label"
|
||||
>
|
||||
<CalendarClock :size="14" aria-hidden="true" />
|
||||
<span>{{ display }}</span>
|
||||
</AppButton>
|
||||
</template>
|
||||
|
||||
<div class="app-date-range__shortcuts" :aria-label="t('monitor.logs.filters.quickRanges')">
|
||||
<AppButton
|
||||
v-for="shortcut in shortcuts"
|
||||
:key="shortcut.key"
|
||||
variant="ghost"
|
||||
size="compact"
|
||||
@click="selectShortcut(shortcut.milliseconds)"
|
||||
>
|
||||
{{ t(`monitor.logs.filters.quick.${shortcut.key}`) }}
|
||||
</AppButton>
|
||||
</div>
|
||||
<div class="app-date-range__fields">
|
||||
<FormField id="logs-range-from" :label="fromLabel" size="compact">
|
||||
<span class="app-date-range__input-shell">
|
||||
<input
|
||||
id="logs-range-from"
|
||||
class="app-date-range__native-input"
|
||||
:value="from"
|
||||
type="datetime-local"
|
||||
step="1"
|
||||
:aria-label="fromLabel"
|
||||
@input="updateLocalInput('from', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</span>
|
||||
</FormField>
|
||||
<FormField id="logs-range-to" :label="toLabel" size="compact">
|
||||
<span class="app-date-range__input-shell">
|
||||
<input
|
||||
id="logs-range-to"
|
||||
class="app-date-range__native-input"
|
||||
:value="to"
|
||||
type="datetime-local"
|
||||
step="1"
|
||||
:aria-label="toLabel"
|
||||
@input="updateLocalInput('to', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</span>
|
||||
</FormField>
|
||||
</div>
|
||||
<p class="app-date-range__timezone">{{ timezoneLabel }} · {{ timezone }}</p>
|
||||
</AppPopover>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.app-date-range__trigger {
|
||||
max-width: 332px;
|
||||
}
|
||||
|
||||
.app-date-range__trigger > span {
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-date-range-popover {
|
||||
width: min(420px, var(--reka-popover-content-available-width));
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.app-date-range__shortcuts {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.app-date-range__shortcuts .app-button {
|
||||
flex: 1 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-date-range__fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.app-date-range__timezone {
|
||||
margin: 10px 0 0;
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--text-label-xs);
|
||||
}
|
||||
|
||||
.app-date-range__input-shell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: var(--control-xs);
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border-control);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.app-date-range__input-value {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 0 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-meta);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-date-range__native-input {
|
||||
position: static !important;
|
||||
width: 100% !important;
|
||||
height: var(--control-xs) !important;
|
||||
min-height: var(--control-xs) !important;
|
||||
border: 0 !important;
|
||||
border-radius: inherit !important;
|
||||
background: transparent !important;
|
||||
color: var(--color-text) !important;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: var(--text-meta) !important;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 1;
|
||||
padding: 0 10px !important;
|
||||
}
|
||||
|
||||
.app-date-range__input-shell:focus-within {
|
||||
border-color: var(--color-action);
|
||||
box-shadow: 0 0 0 2px var(--color-action-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.app-date-range__trigger {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
min-height: var(--touch-target);
|
||||
}
|
||||
|
||||
.app-date-range__fields {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -13,11 +13,13 @@ withDefaults(
|
||||
placeholder: string
|
||||
clearLabel: string
|
||||
id?: string
|
||||
list?: string
|
||||
disabled?: boolean
|
||||
describedBy?: string
|
||||
}>(),
|
||||
{
|
||||
id: undefined,
|
||||
list: undefined,
|
||||
disabled: false,
|
||||
describedBy: undefined,
|
||||
},
|
||||
@@ -51,6 +53,7 @@ defineExpose({ focus })
|
||||
ref="input"
|
||||
class="app-search-input__control"
|
||||
type="search"
|
||||
:list="list"
|
||||
:value="modelValue"
|
||||
:aria-label="label"
|
||||
:aria-describedby="describedBy"
|
||||
@@ -58,6 +61,8 @@ defineExpose({ focus })
|
||||
:disabled="disabled"
|
||||
autocomplete="off"
|
||||
:spellcheck="false"
|
||||
data-1p-ignore="true"
|
||||
data-lpignore="true"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<IconButton
|
||||
|
||||
@@ -17,6 +17,11 @@ interface SelectOption {
|
||||
label: string
|
||||
}
|
||||
|
||||
// Reka Select reserves an empty string for clearing the selection. Keep the
|
||||
// existing AppSelect contract for "any" options while using a safe internal
|
||||
// value for the primitive component.
|
||||
const emptyOptionValue = '__app_select_empty__'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -37,6 +42,14 @@ const props = withDefaults(
|
||||
)
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
const attrs = useAttrs()
|
||||
const normalizedOptions = computed(() =>
|
||||
props.options.map((option) =>
|
||||
option.value === '' ? { ...option, value: emptyOptionValue } : option,
|
||||
),
|
||||
)
|
||||
const normalizedModelValue = computed(() =>
|
||||
props.modelValue === '' ? emptyOptionValue : props.modelValue,
|
||||
)
|
||||
const selectedLabel = computed(
|
||||
() => props.options.find((option) => option.value === props.modelValue)?.label ?? '',
|
||||
)
|
||||
@@ -44,9 +57,13 @@ const selectedLabel = computed(
|
||||
|
||||
<template>
|
||||
<SelectRoot
|
||||
:model-value="modelValue"
|
||||
:model-value="normalizedModelValue"
|
||||
:disabled="props.disabled"
|
||||
@update:model-value="(value) => typeof value === 'string' && emit('update:modelValue', value)"
|
||||
@update:model-value="
|
||||
(value) =>
|
||||
typeof value === 'string' &&
|
||||
emit('update:modelValue', value === emptyOptionValue ? '' : value)
|
||||
"
|
||||
>
|
||||
<SelectTrigger
|
||||
v-bind="attrs"
|
||||
@@ -61,11 +78,11 @@ const selectedLabel = computed(
|
||||
<SelectPortal>
|
||||
<SelectContent class="app-select__content" position="popper" :side-offset="6">
|
||||
<SelectItem
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
v-for="(option, index) in normalizedOptions"
|
||||
:key="`${props.options[index]?.value ?? option.value}:${index}`"
|
||||
class="app-select__item"
|
||||
:value="option.value"
|
||||
:data-value="option.value"
|
||||
:data-value="props.options[index]?.value ?? option.value"
|
||||
>
|
||||
<SelectItemIndicator class="app-select__indicator">
|
||||
<Check :size="15" aria-hidden="true" />
|
||||
|
||||
@@ -4,15 +4,30 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import AppButton from '@/components/ui/AppButton.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
page: number
|
||||
pageSize: number
|
||||
totalItems: number
|
||||
totalPages: number
|
||||
pageSizes?: readonly (20 | 50 | 100)[]
|
||||
showPageSize?: boolean
|
||||
appearance?: 'default' | 'detail'
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
page: number
|
||||
pageSize?: number
|
||||
totalItems?: number
|
||||
totalPages?: number
|
||||
pageSizes?: readonly (20 | 50 | 100)[]
|
||||
showPageSize?: boolean
|
||||
appearance?: 'default' | 'detail'
|
||||
cursor?: boolean
|
||||
hasPrevious?: boolean
|
||||
hasNext?: boolean
|
||||
}>(),
|
||||
{
|
||||
pageSize: 20,
|
||||
totalItems: 0,
|
||||
totalPages: 0,
|
||||
pageSizes: () => [20, 50, 100] as const,
|
||||
appearance: 'default',
|
||||
cursor: false,
|
||||
hasPrevious: false,
|
||||
hasNext: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
previous: []
|
||||
@@ -29,7 +44,7 @@ const from = computed(() => (hasCurrentPage.value ? (props.page - 1) * props.pag
|
||||
const to = computed(() =>
|
||||
hasCurrentPage.value ? Math.min(props.page * props.pageSize, props.totalItems) : 0,
|
||||
)
|
||||
const pageSizes = computed(() => props.pageSizes ?? ([20, 50, 100] as const))
|
||||
const pageSizes = computed(() => props.pageSizes)
|
||||
|
||||
function updatePageSize(event: Event): void {
|
||||
const value = Number((event.target as HTMLSelectElement).value)
|
||||
@@ -40,13 +55,13 @@ function updatePageSize(event: Event): void {
|
||||
<template>
|
||||
<nav
|
||||
class="pagination-bar"
|
||||
:class="`pagination-bar--${appearance ?? 'default'}`"
|
||||
:class="[`pagination-bar--${appearance}`, { 'pagination-bar--cursor': cursor }]"
|
||||
:aria-label="t('common.pagination.label')"
|
||||
>
|
||||
<span class="pagination-bar__range" aria-hidden="true">
|
||||
<span v-if="!cursor" class="pagination-bar__range" aria-hidden="true">
|
||||
{{ t('common.pagination.total', { total: totalItems }) }}
|
||||
</span>
|
||||
<span class="sr-only" aria-live="polite">
|
||||
<span v-if="!cursor" class="sr-only" aria-live="polite">
|
||||
{{ t('common.pagination.range', { from, to, total: totalItems }) }}
|
||||
</span>
|
||||
<span
|
||||
@@ -65,17 +80,19 @@ function updatePageSize(event: Event): void {
|
||||
variant="secondary"
|
||||
size="compact"
|
||||
:aria-label="t('common.pagination.previous')"
|
||||
:disabled="page <= 1"
|
||||
:disabled="cursor ? !hasPrevious : page <= 1"
|
||||
@click="$emit('previous')"
|
||||
>
|
||||
←
|
||||
</AppButton>
|
||||
<span class="pagination-bar__page">{{ page }} / {{ totalPages }}</span>
|
||||
<span class="pagination-bar__page">
|
||||
{{ cursor ? page : `${page} / ${totalPages}` }}
|
||||
</span>
|
||||
<AppButton
|
||||
variant="secondary"
|
||||
size="compact"
|
||||
:aria-label="t('common.pagination.next')"
|
||||
:disabled="totalPages === 0 || page >= totalPages"
|
||||
:disabled="cursor ? !hasNext : totalPages === 0 || page >= totalPages"
|
||||
@click="$emit('next')"
|
||||
>
|
||||
→
|
||||
@@ -108,6 +125,10 @@ function updatePageSize(event: Event): void {
|
||||
padding-top: 13px;
|
||||
}
|
||||
|
||||
.pagination-bar--cursor {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.pagination-bar__page {
|
||||
min-width: 44px;
|
||||
color: var(--color-text-muted);
|
||||
|
||||
@@ -1,328 +1,266 @@
|
||||
<script setup lang="ts">
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
FailureCategory,
|
||||
RequestLogAction,
|
||||
RequestLogItemDto,
|
||||
RequestLogStatus,
|
||||
import { useApiClient } from '@/api/client-context'
|
||||
import {
|
||||
requestLogDetailQueryOptions,
|
||||
type RequestLogAttemptDto,
|
||||
type RequestLogPricingLineDto,
|
||||
} from '@/app/resources/request-logs'
|
||||
import { modelPricesLocation, monitorLocation } from '@/app/route-locations'
|
||||
import AppDateTime from '@/components/ui/AppDateTime.vue'
|
||||
import AppDrawer from '@/components/ui/AppDrawer.vue'
|
||||
import CopyButton from '@/components/ui/CopyButton.vue'
|
||||
import InlineFeedback from '@/components/ui/InlineFeedback.vue'
|
||||
import QueryFeedback from '@/components/ui/QueryFeedback.vue'
|
||||
import SkeletonBlock from '@/components/ui/SkeletonBlock.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import { formatEstimatedCost } from '@/lib/format'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
log: RequestLogItemDto | null
|
||||
}>()
|
||||
import { formatLogDuration, formatLogOutputRate, formatLogTokenCount } from './log-format'
|
||||
|
||||
const props = defineProps<{ open: boolean; requestId: string | undefined }>()
|
||||
defineEmits<{ 'update:open': [open: boolean] }>()
|
||||
const client = useApiClient()
|
||||
const { locale, t } = useI18n()
|
||||
|
||||
const failureCategories: readonly FailureCategory[] = [
|
||||
'ok',
|
||||
'rate_limited',
|
||||
'model_unavailable',
|
||||
'invalid_key',
|
||||
'upstream_host_error',
|
||||
'client_error',
|
||||
'downstream_cancel',
|
||||
'ambiguous',
|
||||
]
|
||||
const actions: readonly RequestLogAction[] = [
|
||||
'terminate',
|
||||
'retry',
|
||||
'cooldown_key',
|
||||
'fail_key',
|
||||
'skip_group',
|
||||
]
|
||||
const orderedAttempts = computed(() =>
|
||||
[...(props.log?.attempts ?? [])].sort((left, right) => left.sequence - right.sequence),
|
||||
const query = useQuery(requestLogDetailQueryOptions(client, () => props.requestId))
|
||||
const log = computed(() => query.data.value)
|
||||
const receipt = computed(
|
||||
() => log.value?.attempts.find((attempt) => attempt.pricing_receipt)?.pricing_receipt,
|
||||
)
|
||||
const inspectorTarget = computed(() => {
|
||||
if (
|
||||
!props.log?.protocol ||
|
||||
!Number.isSafeInteger(props.log.access_key.id) ||
|
||||
props.log.access_key.id <= 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const query: Record<string, string | number> = {
|
||||
tab: 'inspector',
|
||||
protocol: props.log.protocol,
|
||||
}
|
||||
if (props.log.client_model !== null) query.external_model = props.log.client_model
|
||||
query.access_key_id = props.log.access_key.id
|
||||
return monitorLocation(query)
|
||||
const cacheRows = computed(() => {
|
||||
if (!log.value) return []
|
||||
return [
|
||||
{ label: t('monitor.logs.tokens.cacheRead'), value: log.value.cache_read_tokens },
|
||||
{ label: t('monitor.logs.tokens.cacheWrite5m'), value: log.value.cache_write_5m_tokens },
|
||||
{ label: t('monitor.logs.tokens.cacheWrite1h'), value: log.value.cache_write_1h_tokens },
|
||||
{ label: t('monitor.logs.tokens.cacheWrite'), value: log.value.cache_write_unknown_tokens },
|
||||
].filter(({ value }) => value !== '0')
|
||||
})
|
||||
const usageTone = computed(() => {
|
||||
if (props.log?.usage_state === 'complete') return 'success'
|
||||
if (props.log?.usage_state === 'partial') return 'warning'
|
||||
if (props.log?.usage_state === 'missing') return 'danger'
|
||||
return 'neutral'
|
||||
})
|
||||
const costTone = computed(() => {
|
||||
if (props.log?.cost_state === 'priced') return 'success'
|
||||
if (props.log?.cost_state === 'unpriced') return 'warning'
|
||||
return 'neutral'
|
||||
const formula = computed(() => {
|
||||
const value = receipt.value
|
||||
if (!value) return '—'
|
||||
const parts = value.line_items.map(formatFormulaLine)
|
||||
const prefix = value.context_threshold_tokens
|
||||
? `${t('monitor.logs.receipt.contextTier', {
|
||||
value: formatLogTokenCount(value.context_threshold_tokens, locale.value),
|
||||
})}: `
|
||||
: ''
|
||||
const expression = parts.length > 0 ? parts.join(' + ') : '0'
|
||||
return `${prefix}${expression} = ${formatEstimatedCost(value.total_nano_usd, locale.value)}`
|
||||
})
|
||||
|
||||
function statusTone(status: RequestLogStatus): 'success' | 'danger' | 'warning' | 'neutral' {
|
||||
function statusTone(status: string): 'success' | 'danger' | 'warning' | 'neutral' {
|
||||
if (status === 'success') return 'success'
|
||||
if (status === 'error') return 'danger'
|
||||
if (status === 'incomplete') return 'warning'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
function failureLabel(value: string): string {
|
||||
return failureCategories.includes(value as FailureCategory)
|
||||
? t(`monitor.logs.failureCategory.${value}`)
|
||||
: t('monitor.logs.failureCategory.unknown')
|
||||
function attemptTone(attempt: RequestLogAttemptDto): 'success' | 'danger' | 'warning' {
|
||||
if (attempt.failure_category === 'ok') return 'success'
|
||||
return attempt.will_retry ? 'warning' : 'danger'
|
||||
}
|
||||
|
||||
function actionLabel(value: string): string {
|
||||
return actions.includes(value as RequestLogAction)
|
||||
? t(`monitor.logs.action.${value}`)
|
||||
: t('monitor.logs.action.unknown')
|
||||
}
|
||||
|
||||
function accessKeyLabel(log: RequestLogItemDto): string {
|
||||
if (log.access_key.deleted) {
|
||||
return t('monitor.logs.accessKey.deleted', { id: log.access_key.id })
|
||||
function formatFormulaLine(line: RequestLogPricingLineDto): string {
|
||||
const label = t(`monitor.logs.receipt.lines.${line.code}`)
|
||||
const quantity = formatLogTokenCount(line.quantity, locale.value)
|
||||
if (line.state === 'unpriced' || line.rate_nano_usd_per_million === null) {
|
||||
return `(${label} ${quantity} × — = —)`
|
||||
}
|
||||
if (log.access_key.name) {
|
||||
return t('monitor.logs.accessKey.named', {
|
||||
id: log.access_key.id,
|
||||
name: log.access_key.name,
|
||||
})
|
||||
}
|
||||
return `#${log.access_key.id}`
|
||||
const multiplier =
|
||||
line.multiplier.numerator === line.multiplier.denominator
|
||||
? ''
|
||||
: ` × ${line.multiplier.numerator}/${line.multiplier.denominator}`
|
||||
const amount =
|
||||
line.amount_nano_usd === null ? '—' : formatEstimatedCost(line.amount_nano_usd, locale.value)
|
||||
return `(${label} ${quantity} × ${formatEstimatedCost(
|
||||
line.rate_nano_usd_per_million,
|
||||
locale.value,
|
||||
)}/1M${multiplier} = ${amount})`
|
||||
}
|
||||
|
||||
function protocolLabel(log: RequestLogItemDto): string {
|
||||
return log.protocol
|
||||
? t(`common.protocols.${log.protocol}`)
|
||||
: t('monitor.logs.drawer.usage.unknown')
|
||||
function accessKeyLabel(): string {
|
||||
const key = log.value?.access_key
|
||||
if (!key) return '—'
|
||||
if (key.deleted) return t('monitor.logs.accessKey.deleted', { id: key.id })
|
||||
return key.name ? `${key.name} · #${key.id}` : `#${key.id}`
|
||||
}
|
||||
|
||||
function modelLabel(model: string | null): string {
|
||||
return model ?? t('monitor.logs.drawer.modelNotSpecified')
|
||||
}
|
||||
|
||||
function usageStateLabel(log: RequestLogItemDto): string {
|
||||
return t(`monitor.logs.drawer.usage.state.${log.usage_state}`)
|
||||
}
|
||||
|
||||
function costStateLabel(log: RequestLogItemDto): string {
|
||||
if (log.cost_state === 'priced' && log.usage_state === 'partial') {
|
||||
return t('monitor.logs.drawer.usage.costState.partialPriced')
|
||||
}
|
||||
return t(`monitor.logs.drawer.usage.costState.${log.cost_state}`)
|
||||
}
|
||||
|
||||
function aggregationLabel(log: RequestLogItemDto): string {
|
||||
if (log.usage_state === 'not_applicable') {
|
||||
return t('monitor.logs.drawer.usage.aggregation.notApplicable')
|
||||
}
|
||||
if (log.usage_state === 'missing') {
|
||||
return t('monitor.logs.drawer.usage.aggregation.missing')
|
||||
}
|
||||
if (log.usage_state === 'partial' && log.cost_state === 'priced') {
|
||||
return t('monitor.logs.drawer.usage.aggregation.partialPriced')
|
||||
}
|
||||
if (log.usage_state === 'partial') {
|
||||
return t('monitor.logs.drawer.usage.aggregation.partialUnpriced')
|
||||
}
|
||||
if (log.cost_state === 'unpriced') {
|
||||
return t('monitor.logs.drawer.usage.aggregation.completeUnpriced')
|
||||
}
|
||||
return t('monitor.logs.drawer.usage.aggregation.completePriced')
|
||||
}
|
||||
|
||||
function tokenValue(log: RequestLogItemDto, value: number): string {
|
||||
if (log.usage_state === 'not_applicable') return t('monitor.logs.drawer.usage.notApplicable')
|
||||
if (log.usage_state === 'missing') return t('monitor.logs.drawer.usage.unknown')
|
||||
return new Intl.NumberFormat(locale.value).format(value)
|
||||
}
|
||||
|
||||
function estimatedCost(log: RequestLogItemDto): string {
|
||||
if (log.cost_state === 'not_applicable') return t('monitor.logs.drawer.usage.notApplicable')
|
||||
if (log.cost_state === 'unpriced') return t('monitor.logs.drawer.usage.unknown')
|
||||
return formatEstimatedCost(log.estimated_cost_nano_usd, locale.value)
|
||||
function groupLabel(): string {
|
||||
const groupID = log.value?.group_id
|
||||
if (groupID === null || groupID === undefined) return '—'
|
||||
const attempt = [...(log.value?.attempts ?? [])]
|
||||
.reverse()
|
||||
.find(({ group_id }) => group_id === groupID)
|
||||
return attempt ? `${attempt.group_name} · #${groupID}` : `#${groupID}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDrawer
|
||||
:open="open"
|
||||
appearance="ledger"
|
||||
:title="t('monitor.logs.drawer.title')"
|
||||
:description="t('monitor.logs.drawer.description')"
|
||||
:close-label="t('monitor.logs.drawer.close')"
|
||||
@update:open="$emit('update:open', $event)"
|
||||
>
|
||||
<template #trigger><slot name="trigger" /></template>
|
||||
<div v-if="query.isPending.value" class="log-detail__skeleton" role="status">
|
||||
<span class="sr-only">{{ t('monitor.logs.drawer.loading') }}</span>
|
||||
<SkeletonBlock height="72px" />
|
||||
<SkeletonBlock v-for="index in 4" :key="index" height="132px" />
|
||||
</div>
|
||||
<QueryFeedback
|
||||
v-else-if="query.isError.value || !log"
|
||||
state="error"
|
||||
:message="t('monitor.logs.drawer.loadFailed')"
|
||||
:retry-label="t('common.retry')"
|
||||
@retry="query.refetch()"
|
||||
/>
|
||||
<div v-else class="log-detail">
|
||||
<header class="log-detail__summary">
|
||||
<StatusBadge :tone="statusTone(log.status)" size="compact">
|
||||
{{ t(`monitor.logs.status.${log.status}`)
|
||||
}}<template v-if="log.status !== 'success' && log.status_code">
|
||||
· {{ log.status_code }}</template
|
||||
>
|
||||
</StatusBadge>
|
||||
<span class="log-detail__time">
|
||||
<AppDateTime :instant="log.completed_at_ms" :locale="locale" precision="second" />
|
||||
</span>
|
||||
<span class="log-detail__request-id">
|
||||
<code>{{ log.request_id }}</code>
|
||||
<CopyButton
|
||||
:value="log.request_id"
|
||||
:label="t('monitor.logs.drawer.copyRequestId')"
|
||||
:success-label="t('common.copied')"
|
||||
:failure-label="t('common.copyFailed')"
|
||||
/>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div v-if="log" class="log-detail">
|
||||
<section class="log-detail__section" aria-labelledby="log-detail-summary-heading">
|
||||
<h2 id="log-detail-summary-heading">{{ t('monitor.logs.drawer.summary') }}</h2>
|
||||
<dl class="log-detail__facts">
|
||||
<div class="log-detail__wide">
|
||||
<dt>{{ t('monitor.logs.drawer.requestId') }}</dt>
|
||||
<dd class="log-detail__copy">
|
||||
<code>{{ log.request_id }}</code>
|
||||
<CopyButton
|
||||
:value="log.request_id"
|
||||
:label="t('monitor.logs.drawer.copyRequestId')"
|
||||
:success-label="t('common.copied')"
|
||||
:failure-label="t('common.copyFailed')"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.completedAt') }}</dt>
|
||||
<dd>
|
||||
<AppDateTime :instant="log.completed_at_ms" :locale="locale" />
|
||||
</dd>
|
||||
</div>
|
||||
<section class="log-detail__section">
|
||||
<h3>{{ t('monitor.logs.drawer.summary') }}</h3>
|
||||
<dl class="log-detail__grid">
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.status') }}</dt>
|
||||
<dd>
|
||||
<StatusBadge :tone="statusTone(log.status)">
|
||||
{{ t(`monitor.logs.status.${log.status}`) }}
|
||||
</StatusBadge>
|
||||
{{ t(`monitor.logs.status.${log.status}`)
|
||||
}}<template v-if="log.status_code"> · {{ log.status_code }}</template>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.protocol') }}</dt>
|
||||
<dd>{{ protocolLabel(log) }}</dd>
|
||||
<dt>{{ t('monitor.logs.drawer.attemptCount') }}</dt>
|
||||
<dd>{{ log.attempt_count }}</dd>
|
||||
</div>
|
||||
<div v-if="log.stream">
|
||||
<dt>{{ t('monitor.logs.drawer.firstResponse') }}</dt>
|
||||
<dd>
|
||||
{{ log.first_response_ms === null ? '—' : formatLogDuration(log.first_response_ms) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.duration') }}</dt>
|
||||
<dd>{{ formatLogDuration(log.duration_ms) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.outputRate') }}</dt>
|
||||
<dd>{{ formatLogOutputRate(log, locale) }}</dd>
|
||||
</div>
|
||||
<div v-if="log.error_code">
|
||||
<dt>{{ t('monitor.logs.drawer.errorCode') }}</dt>
|
||||
<dd>
|
||||
<code>{{ log.error_code }}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="log.error_summary" class="log-detail__wide">
|
||||
<dt>{{ t('monitor.logs.drawer.errorSummary') }}</dt>
|
||||
<dd>{{ log.error_summary }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="log-detail__section">
|
||||
<h3>{{ t('monitor.logs.drawer.route') }}</h3>
|
||||
<dl class="log-detail__grid">
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.accessKey') }}</dt>
|
||||
<dd>{{ accessKeyLabel(log) }}</dd>
|
||||
<dd>{{ accessKeyLabel() }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.protocol') }}</dt>
|
||||
<dd>{{ log.protocol }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.clientModel') }}</dt>
|
||||
<dd>
|
||||
<code>{{ modelLabel(log.client_model) }}</code>
|
||||
<code>{{ log.client_model ?? '—' }}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.upstreamModel') }}</dt>
|
||||
<dd>
|
||||
<code>{{ modelLabel(log.upstream_model) }}</code>
|
||||
<code>{{ log.upstream_model ?? '—' }}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.statusCode') }}</dt>
|
||||
<dd>{{ log.status_code }}</dd>
|
||||
<dt>{{ t('monitor.logs.drawer.group') }}</dt>
|
||||
<dd>{{ groupLabel() }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.duration') }}</dt>
|
||||
<dd>{{ log.duration_ms }} ms</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.errorCode') }}</dt>
|
||||
<dd>
|
||||
<code>{{ log.error_code || t('monitor.logs.none') }}</code>
|
||||
</dd>
|
||||
<dt>{{ t('monitor.logs.drawer.affinity') }}</dt>
|
||||
<dd>{{ log.affinity_hit ? t('monitor.logs.yes') : t('monitor.logs.no') }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="log-detail__summary">
|
||||
<h3>{{ t('monitor.logs.drawer.errorSummary') }}</h3>
|
||||
<p>{{ log.error_summary || t('monitor.logs.none') }}</p>
|
||||
</div>
|
||||
<RouterLink v-if="inspectorTarget" class="log-detail__inspector" :to="inspectorTarget">
|
||||
{{ t('monitor.logs.drawer.openInspector') }}
|
||||
</RouterLink>
|
||||
</section>
|
||||
|
||||
<section class="log-detail__section" aria-labelledby="log-detail-usage-heading">
|
||||
<h2 id="log-detail-usage-heading">{{ t('monitor.logs.drawer.usage.title') }}</h2>
|
||||
<p class="log-detail__section-description">
|
||||
{{ t('monitor.logs.drawer.usage.description') }}
|
||||
</p>
|
||||
<div class="log-detail__usage-status">
|
||||
<StatusBadge :tone="usageTone">{{ usageStateLabel(log) }}</StatusBadge>
|
||||
<StatusBadge :tone="costTone">{{ costStateLabel(log) }}</StatusBadge>
|
||||
</div>
|
||||
<dl class="log-detail__facts">
|
||||
<section class="log-detail__section">
|
||||
<h3>{{ t('monitor.logs.drawer.usage.title') }}</h3>
|
||||
<dl class="log-detail__grid">
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.usage.finalGroup') }}</dt>
|
||||
<dd>
|
||||
{{
|
||||
log.group_id === null
|
||||
? t('monitor.logs.drawer.usage.unknown')
|
||||
: t('monitor.logs.drawer.usage.groupId', { id: log.group_id })
|
||||
}}
|
||||
</dd>
|
||||
<dt>{{ t('monitor.logs.tokens.input') }}</dt>
|
||||
<dd>{{ formatLogTokenCount(log.input_tokens, locale) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.tokens.output') }}</dt>
|
||||
<dd>{{ formatLogTokenCount(log.output_tokens, locale) }}</dd>
|
||||
</div>
|
||||
<div v-for="row in cacheRows" :key="row.label">
|
||||
<dt>{{ row.label }}</dt>
|
||||
<dd>{{ formatLogTokenCount(row.value, locale) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.usage.estimatedCost') }}</dt>
|
||||
<dd>{{ estimatedCost(log) }}</dd>
|
||||
<dd>
|
||||
{{
|
||||
log.cost_state === 'priced'
|
||||
? formatEstimatedCost(log.estimated_cost_nano_usd, locale)
|
||||
: '—'
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.usage.tokens.uncachedInput') }}</dt>
|
||||
<dd>{{ tokenValue(log, log.uncached_input_tokens) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.usage.tokens.cacheRead') }}</dt>
|
||||
<dd>{{ tokenValue(log, log.cache_read_tokens) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.usage.tokens.cacheWrite5m') }}</dt>
|
||||
<dd>{{ tokenValue(log, log.cache_write_5m_tokens) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.usage.tokens.cacheWrite1h') }}</dt>
|
||||
<dd>{{ tokenValue(log, log.cache_write_1h_tokens) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.usage.tokens.output') }}</dt>
|
||||
<dd>{{ tokenValue(log, log.output_tokens) }}</dd>
|
||||
<div class="log-detail__wide">
|
||||
<dt>{{ t('monitor.logs.receipt.formula') }}</dt>
|
||||
<dd class="log-detail__formula">{{ formula }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<InlineFeedback
|
||||
:tone="
|
||||
log.usage_state === 'missing' || log.cost_state === 'unpriced' ? 'warning' : 'info'
|
||||
"
|
||||
>
|
||||
{{ aggregationLabel(log) }}
|
||||
</InlineFeedback>
|
||||
<RouterLink
|
||||
v-if="log.cost_state === 'unpriced'"
|
||||
class="log-detail__prices"
|
||||
:to="modelPricesLocation()"
|
||||
>
|
||||
{{ t('monitor.logs.drawer.usage.openPrices') }}
|
||||
</RouterLink>
|
||||
</section>
|
||||
|
||||
<section class="log-detail__section" aria-labelledby="log-detail-attempts-heading">
|
||||
<h2 id="log-detail-attempts-heading">{{ t('monitor.logs.drawer.attempts') }}</h2>
|
||||
<p v-if="orderedAttempts.length === 0" class="log-detail__empty">
|
||||
<section class="log-detail__section">
|
||||
<h3>{{ t('monitor.logs.drawer.attempts') }}</h3>
|
||||
<p v-if="log.attempts.length === 0" class="log-detail__empty">
|
||||
{{ t('monitor.logs.drawer.noAttempts') }}
|
||||
</p>
|
||||
<article
|
||||
v-for="attempt in orderedAttempts"
|
||||
v-else
|
||||
:key="attempt.sequence"
|
||||
class="log-attempt"
|
||||
>
|
||||
<article v-for="attempt in log.attempts" :key="attempt.sequence" class="log-attempt">
|
||||
<header>
|
||||
<h3>{{ t('monitor.logs.drawer.attempt', { sequence: attempt.sequence }) }}</h3>
|
||||
<StatusBadge :tone="attempt.committed ? 'success' : 'neutral'">
|
||||
{{
|
||||
attempt.committed
|
||||
? t('monitor.logs.drawer.committed')
|
||||
: t('monitor.logs.drawer.notCommitted')
|
||||
}}
|
||||
<span>{{ t('monitor.logs.drawer.attempt', { sequence: attempt.sequence }) }}</span>
|
||||
<StatusBadge :tone="attemptTone(attempt)" size="compact">
|
||||
{{ t(`monitor.logs.failureCategory.${attempt.failure_category}`)
|
||||
}}<template v-if="attempt.status_code"> · {{ attempt.status_code }}</template>
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<dl class="log-detail__facts">
|
||||
<dl class="log-detail__grid">
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.group') }}</dt>
|
||||
<dd>{{ attempt.group_name }} · #{{ attempt.group_id }}</dd>
|
||||
@@ -334,40 +272,32 @@ function estimatedCost(log: RequestLogItemDto): string {
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.upstreamModel') }}</dt>
|
||||
<dd>
|
||||
<code>{{ modelLabel(attempt.upstream_model) }}</code>
|
||||
<code>{{ attempt.upstream_model ?? '—' }}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.statusCode') }}</dt>
|
||||
<dd>{{ attempt.status_code }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.duration') }}</dt>
|
||||
<dd>{{ attempt.duration_ms }} ms</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.failureCategory') }}</dt>
|
||||
<dd>{{ failureLabel(attempt.failure_category) }}</dd>
|
||||
<dd>{{ formatLogDuration(attempt.duration_ms) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.action') }}</dt>
|
||||
<dd>{{ actionLabel(attempt.action) }}</dd>
|
||||
<dd>{{ t(`monitor.logs.action.${attempt.action}`) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('monitor.logs.drawer.willRetry') }}</dt>
|
||||
<dd>{{ attempt.will_retry ? t('monitor.logs.yes') : t('monitor.logs.no') }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="attempt.error_code">
|
||||
<dt>{{ t('monitor.logs.drawer.errorCode') }}</dt>
|
||||
<dd>
|
||||
<code>{{ attempt.error_code || t('monitor.logs.none') }}</code>
|
||||
<code>{{ attempt.error_code }}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="attempt.error_summary" class="log-detail__wide">
|
||||
<dt>{{ t('monitor.logs.drawer.errorSummary') }}</dt>
|
||||
<dd>{{ attempt.error_summary }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="log-detail__summary">
|
||||
<h4>{{ t('monitor.logs.drawer.errorSummary') }}</h4>
|
||||
<p>{{ attempt.error_summary || t('monitor.logs.none') }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
@@ -375,51 +305,86 @@ function estimatedCost(log: RequestLogItemDto): string {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.log-detail {
|
||||
.log-detail,
|
||||
.log-detail__skeleton {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.log-detail__skeleton {
|
||||
gap: 12px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.log-detail__summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.log-detail__time {
|
||||
margin-left: auto;
|
||||
color: var(--color-text-faint);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-label-xs);
|
||||
}
|
||||
|
||||
.log-detail__request-id {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.log-detail__request-id code {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-label-xs);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-detail__request-id :deep(.copy-control button) {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.log-detail__section {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-3);
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.log-detail__section h2,
|
||||
.log-detail__section h3,
|
||||
.log-detail__section h4,
|
||||
.log-detail__section p {
|
||||
margin: 0;
|
||||
.log-detail__section h3 {
|
||||
margin: 0 0 12px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.log-detail__section-description {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.log-detail__section h2 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.log-detail__facts {
|
||||
.log-detail__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
gap: 12px 18px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.log-detail__facts > div {
|
||||
.log-detail__grid > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.log-detail__facts dt {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
.log-detail__grid dt {
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--text-label-xs);
|
||||
}
|
||||
|
||||
.log-detail__facts dd {
|
||||
margin: var(--space-1) 0 0;
|
||||
.log-detail__grid dd {
|
||||
margin: 3px 0 0;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -427,82 +392,46 @@ function estimatedCost(log: RequestLogItemDto): string {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.log-detail__copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.log-detail code,
|
||||
.log-detail time {
|
||||
.log-detail__formula {
|
||||
font-family: var(--font-mono);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.log-detail__summary,
|
||||
.log-attempt {
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-surface-sunken);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.log-detail__summary {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.log-detail__summary p {
|
||||
color: var(--color-text-muted);
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.log-detail__inspector {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
color: var(--color-action);
|
||||
font-weight: 650;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.log-detail__usage-status {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.log-detail__prices {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
color: var(--color-action);
|
||||
font-weight: 650;
|
||||
.log-attempt + .log-attempt {
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.log-attempt {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding: 13px 0;
|
||||
}
|
||||
|
||||
.log-attempt > header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.log-detail__empty {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.log-detail :deep(.status-badge) {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.log-detail__grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.log-detail__wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,444 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { AccessKeyOptionDto } from '@/api/control/types'
|
||||
import { enabledDataProtocols } from '@/api/control/protocols'
|
||||
import AppButton from '@/components/ui/AppButton.vue'
|
||||
import AppDrawer from '@/components/ui/AppDrawer.vue'
|
||||
import AppSelect from '@/components/ui/AppSelect.vue'
|
||||
import FormField from '@/components/ui/FormField.vue'
|
||||
|
||||
import {
|
||||
requestLogCostStates,
|
||||
requestLogFailureCategories,
|
||||
requestLogPricingCompleteness,
|
||||
requestLogRetryStates,
|
||||
requestLogUsageStates,
|
||||
type LogFilterDraft,
|
||||
type LogFilterErrors,
|
||||
} from './log-filters'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
draft: LogFilterDraft
|
||||
errors: LogFilterErrors
|
||||
accessKeys: AccessKeyOptionDto[]
|
||||
accessKeysFailed: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'update:open': [open: boolean]
|
||||
updateField: [field: keyof LogFilterDraft, value: string]
|
||||
apply: []
|
||||
reset: []
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const option = (value: string, label: string) => ({ value, label })
|
||||
const booleanOptions = () => [
|
||||
option('', t('monitor.logs.filters.any')),
|
||||
option('true', t('monitor.logs.yes')),
|
||||
option('false', t('monitor.logs.no')),
|
||||
]
|
||||
const protocolOptions = () => [
|
||||
option('', t('monitor.logs.filters.anyProtocol')),
|
||||
...enabledDataProtocols.map((value) => option(value, value)),
|
||||
]
|
||||
const usageOptions = () => [
|
||||
option('', t('monitor.logs.filters.any')),
|
||||
...requestLogUsageStates.map((value) =>
|
||||
option(value, t(`monitor.logs.filters.usageState.${value}`)),
|
||||
),
|
||||
]
|
||||
const costOptions = () => [
|
||||
option('', t('monitor.logs.filters.any')),
|
||||
...requestLogCostStates.map((value) =>
|
||||
option(value, t(`monitor.logs.filters.costState.${value}`)),
|
||||
),
|
||||
]
|
||||
const completenessOptions = () => [
|
||||
option('', t('monitor.logs.filters.any')),
|
||||
...requestLogPricingCompleteness.map((value) =>
|
||||
option(value, t(`monitor.logs.filters.completeness.${value}`)),
|
||||
),
|
||||
]
|
||||
const failureOptions = () => [
|
||||
option('', t('monitor.logs.filters.any')),
|
||||
...requestLogFailureCategories.map((value) =>
|
||||
option(value, t(`monitor.logs.failureCategory.${value}`)),
|
||||
),
|
||||
]
|
||||
const retryOptions = () => [
|
||||
option('', t('monitor.logs.filters.any')),
|
||||
...requestLogRetryStates.map((value) =>
|
||||
option(value, t(`monitor.logs.filters.retryState.${value}`)),
|
||||
),
|
||||
]
|
||||
const accessKeyOptions = () => [
|
||||
option('', t('monitor.logs.filters.anyAccessKey')),
|
||||
...props.accessKeys.map((key) => option(String(key.id), `${key.name} · #${key.id}`)),
|
||||
]
|
||||
|
||||
function error(field: keyof LogFilterDraft): string | undefined {
|
||||
const key = props.errors[field]
|
||||
return key ? t(key) : undefined
|
||||
}
|
||||
|
||||
function update(field: keyof LogFilterDraft, value: string): void {
|
||||
emit('updateField', field, value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDrawer
|
||||
:open="open"
|
||||
appearance="ledger"
|
||||
:title="t('monitor.logs.filters.advancedTitle')"
|
||||
:description="t('monitor.logs.filters.advancedDescription')"
|
||||
:close-label="t('monitor.logs.filters.closeAdvanced')"
|
||||
@update:open="emit('update:open', $event)"
|
||||
>
|
||||
<form class="logs-advanced" @submit.prevent="emit('apply')">
|
||||
<section class="logs-advanced__section">
|
||||
<h3>{{ t('monitor.logs.filters.sections.request') }}</h3>
|
||||
<div class="logs-advanced__grid">
|
||||
<FormField
|
||||
id="logs-access-key"
|
||||
:label="t('monitor.logs.filters.accessKey')"
|
||||
size="compact"
|
||||
:error="error('access_key_id')"
|
||||
>
|
||||
<AppSelect
|
||||
id="logs-access-key"
|
||||
:model-value="draft.access_key_id"
|
||||
:label="t('monitor.logs.filters.accessKey')"
|
||||
:options="accessKeyOptions()"
|
||||
size="compact"
|
||||
:disabled="accessKeysFailed"
|
||||
@update:model-value="update('access_key_id', $event)"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField id="logs-protocol" :label="t('monitor.logs.filters.protocol')" size="compact">
|
||||
<AppSelect
|
||||
id="logs-protocol"
|
||||
:model-value="draft.protocol"
|
||||
:label="t('monitor.logs.filters.protocol')"
|
||||
:options="protocolOptions()"
|
||||
size="compact"
|
||||
@update:model-value="update('protocol', $event)"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="logs-request-id"
|
||||
:label="t('monitor.logs.filters.requestId')"
|
||||
size="compact"
|
||||
:error="error('request_id')"
|
||||
>
|
||||
<input
|
||||
id="logs-request-id"
|
||||
:value="draft.request_id"
|
||||
class="logs-advanced__mono"
|
||||
autocomplete="off"
|
||||
@input="update('request_id', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField id="logs-stream" :label="t('monitor.logs.filters.stream')" size="compact">
|
||||
<AppSelect
|
||||
id="logs-stream"
|
||||
:model-value="draft.stream"
|
||||
:label="t('monitor.logs.filters.stream')"
|
||||
:options="booleanOptions()"
|
||||
size="compact"
|
||||
@update:model-value="update('stream', $event)"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="logs-advanced__section">
|
||||
<h3>{{ t('monitor.logs.filters.sections.attempt') }}</h3>
|
||||
<div class="logs-advanced__grid">
|
||||
<FormField
|
||||
id="logs-upstream-key"
|
||||
:label="t('monitor.logs.filters.upstreamKey')"
|
||||
size="compact"
|
||||
:error="error('upstream_key_id')"
|
||||
>
|
||||
<input
|
||||
id="logs-upstream-key"
|
||||
:value="draft.upstream_key_id"
|
||||
inputmode="numeric"
|
||||
@input="update('upstream_key_id', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="logs-upstream-model"
|
||||
:label="t('monitor.logs.filters.upstreamModel')"
|
||||
size="compact"
|
||||
:error="error('upstream_model')"
|
||||
>
|
||||
<input
|
||||
id="logs-upstream-model"
|
||||
:value="draft.upstream_model"
|
||||
autocomplete="off"
|
||||
@input="update('upstream_model', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="logs-retry-state"
|
||||
:label="t('monitor.logs.filters.retryStateLabel')"
|
||||
size="compact"
|
||||
>
|
||||
<AppSelect
|
||||
id="logs-retry-state"
|
||||
:model-value="draft.retry_state"
|
||||
:label="t('monitor.logs.filters.retryStateLabel')"
|
||||
:options="retryOptions()"
|
||||
size="compact"
|
||||
@update:model-value="update('retry_state', $event)"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="logs-retry-min"
|
||||
:label="t('monitor.logs.filters.retryMin')"
|
||||
size="compact"
|
||||
:error="error('retry_count_min')"
|
||||
>
|
||||
<input
|
||||
id="logs-retry-min"
|
||||
:value="draft.retry_count_min"
|
||||
inputmode="numeric"
|
||||
@input="update('retry_count_min', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="logs-retry-max"
|
||||
:label="t('monitor.logs.filters.retryMax')"
|
||||
size="compact"
|
||||
:error="error('retry_count_max')"
|
||||
>
|
||||
<input
|
||||
id="logs-retry-max"
|
||||
:value="draft.retry_count_max"
|
||||
inputmode="numeric"
|
||||
@input="update('retry_count_max', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="logs-attempt-code"
|
||||
:label="t('monitor.logs.filters.attemptStatusCode')"
|
||||
size="compact"
|
||||
:error="error('attempt_status_code')"
|
||||
>
|
||||
<input
|
||||
id="logs-attempt-code"
|
||||
:value="draft.attempt_status_code"
|
||||
inputmode="numeric"
|
||||
@input="update('attempt_status_code', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="logs-failure"
|
||||
:label="t('monitor.logs.filters.failureCategory')"
|
||||
size="compact"
|
||||
>
|
||||
<AppSelect
|
||||
id="logs-failure"
|
||||
:model-value="draft.failure_category"
|
||||
:label="t('monitor.logs.filters.failureCategory')"
|
||||
:options="failureOptions()"
|
||||
size="compact"
|
||||
@update:model-value="update('failure_category', $event)"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="logs-error-code"
|
||||
:label="t('monitor.logs.filters.errorCode')"
|
||||
size="compact"
|
||||
:error="error('error_code')"
|
||||
>
|
||||
<input
|
||||
id="logs-error-code"
|
||||
:value="draft.error_code"
|
||||
autocomplete="off"
|
||||
@input="update('error_code', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="logs-advanced__section">
|
||||
<h3>{{ t('monitor.logs.filters.sections.result') }}</h3>
|
||||
<div class="logs-advanced__grid">
|
||||
<FormField
|
||||
id="logs-final-code"
|
||||
:label="t('monitor.logs.filters.finalStatusCode')"
|
||||
size="compact"
|
||||
:error="error('final_status_code')"
|
||||
>
|
||||
<input
|
||||
id="logs-final-code"
|
||||
:value="draft.final_status_code"
|
||||
inputmode="numeric"
|
||||
@input="update('final_status_code', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="logs-usage-state"
|
||||
:label="t('monitor.logs.filters.usageStateLabel')"
|
||||
size="compact"
|
||||
>
|
||||
<AppSelect
|
||||
id="logs-usage-state"
|
||||
:model-value="draft.usage_state"
|
||||
:label="t('monitor.logs.filters.usageStateLabel')"
|
||||
:options="usageOptions()"
|
||||
size="compact"
|
||||
@update:model-value="update('usage_state', $event)"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="logs-cost-state"
|
||||
:label="t('monitor.logs.filters.costStateLabel')"
|
||||
size="compact"
|
||||
>
|
||||
<AppSelect
|
||||
id="logs-cost-state"
|
||||
:model-value="draft.cost_state"
|
||||
:label="t('monitor.logs.filters.costStateLabel')"
|
||||
:options="costOptions()"
|
||||
size="compact"
|
||||
@update:model-value="update('cost_state', $event)"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="logs-completeness"
|
||||
:label="t('monitor.logs.filters.completenessLabel')"
|
||||
size="compact"
|
||||
>
|
||||
<AppSelect
|
||||
id="logs-completeness"
|
||||
:model-value="draft.pricing_completeness"
|
||||
:label="t('monitor.logs.filters.completenessLabel')"
|
||||
:options="completenessOptions()"
|
||||
size="compact"
|
||||
@update:model-value="update('pricing_completeness', $event)"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField id="logs-cache" :label="t('monitor.logs.filters.cachePresent')" size="compact">
|
||||
<AppSelect
|
||||
id="logs-cache"
|
||||
:model-value="draft.cache_present"
|
||||
:label="t('monitor.logs.filters.cachePresent')"
|
||||
:options="booleanOptions()"
|
||||
size="compact"
|
||||
@update:model-value="update('cache_present', $event)"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="logs-advanced__section">
|
||||
<h3>{{ t('monitor.logs.filters.sections.ranges') }}</h3>
|
||||
<div class="logs-advanced__grid">
|
||||
<FormField
|
||||
v-for="field in [
|
||||
'first_response_min_ms',
|
||||
'first_response_max_ms',
|
||||
'duration_min_ms',
|
||||
'duration_max_ms',
|
||||
'input_tokens_min',
|
||||
'input_tokens_max',
|
||||
'output_tokens_min',
|
||||
'output_tokens_max',
|
||||
'cost_min_usd',
|
||||
'cost_max_usd',
|
||||
] as const"
|
||||
:id="`logs-${field}`"
|
||||
:key="field"
|
||||
:label="t(`monitor.logs.filters.rangeFields.${field}`)"
|
||||
size="compact"
|
||||
:error="error(field)"
|
||||
>
|
||||
<input
|
||||
:id="`logs-${field}`"
|
||||
:value="draft[field]"
|
||||
inputmode="decimal"
|
||||
@input="update(field, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<AppButton variant="ghost" @click="emit('reset')">{{
|
||||
t('monitor.logs.filters.reset')
|
||||
}}</AppButton>
|
||||
<span class="logs-advanced__footer-actions">
|
||||
<AppButton variant="secondary" @click="emit('update:open', false)">{{
|
||||
t('common.cancel')
|
||||
}}</AppButton>
|
||||
<AppButton @click="emit('apply')">{{ t('monitor.logs.filters.apply') }}</AppButton>
|
||||
</span>
|
||||
</template>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.logs-advanced,
|
||||
.logs-advanced__section {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logs-advanced {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.logs-advanced__section {
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.logs-advanced__section:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.logs-advanced__section h3 {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.logs-advanced__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px 10px;
|
||||
}
|
||||
|
||||
.logs-advanced__grid :deep(.app-select__trigger) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logs-advanced__mono {
|
||||
font-family: var(--font-mono) !important;
|
||||
}
|
||||
|
||||
.logs-advanced__footer-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.logs-advanced__grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.logs-advanced__footer-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { ListFilter } from '@lucide/vue'
|
||||
import { ListFilter, X } from '@lucide/vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { AccessKeyOptionDto, GroupOptionDto } from '@/api/control/types'
|
||||
import AppDateTimeRangePicker from '@/components/ui/AppDateTimeRangePicker.vue'
|
||||
import AppButton from '@/components/ui/AppButton.vue'
|
||||
import FormField from '@/components/ui/FormField.vue'
|
||||
import AppSelect from '@/components/ui/AppSelect.vue'
|
||||
import AppTextInput from '@/components/ui/AppTextInput.vue'
|
||||
|
||||
import type { LogFilterDraft, LogFilterErrors } from './log-filters'
|
||||
import LogsAdvancedFilterDrawer from './LogsAdvancedFilterDrawer.vue'
|
||||
|
||||
interface AppliedChip {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
draft: LogFilterDraft
|
||||
@@ -15,233 +24,273 @@ const props = defineProps<{
|
||||
accessKeys: AccessKeyOptionDto[]
|
||||
groupsFailed: boolean
|
||||
accessKeysFailed: boolean
|
||||
dirty: boolean
|
||||
refreshPending: boolean
|
||||
appliedChips: AppliedChip[]
|
||||
advancedCount: number
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
updateField: [field: keyof LogFilterDraft, value: string]
|
||||
removeFilter: [key: string]
|
||||
apply: []
|
||||
reset: []
|
||||
refresh: []
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
const advancedOpen = ref(false)
|
||||
|
||||
function error(field: keyof LogFilterDraft): string | undefined {
|
||||
const key = props.errors[field]
|
||||
return key ? t(key) : undefined
|
||||
const groupOptions = computed(() => [
|
||||
{ value: '', label: t('monitor.logs.filters.anyGroup') },
|
||||
...props.groups.map((group) => ({
|
||||
value: String(group.id),
|
||||
label: `${group.name} · #${group.id}`,
|
||||
})),
|
||||
])
|
||||
const statusOptions = computed(() => [
|
||||
{ value: '', label: t('monitor.logs.filters.anyStatus') },
|
||||
...(['success', 'error', 'incomplete', 'canceled'] as const).map((value) => ({
|
||||
value,
|
||||
label: t(`monitor.logs.status.${value}`),
|
||||
})),
|
||||
])
|
||||
const firstError = computed(() => {
|
||||
const key = Object.values(props.errors)[0]
|
||||
return key ? t(key) : ''
|
||||
})
|
||||
|
||||
function update(field: keyof LogFilterDraft, value: string): void {
|
||||
emit('updateField', field, value)
|
||||
}
|
||||
|
||||
function submit(): void {
|
||||
emit('apply')
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
advancedOpen.value = false
|
||||
emit('reset')
|
||||
}
|
||||
|
||||
async function applyAdvanced(): Promise<void> {
|
||||
emit('apply')
|
||||
await nextTick()
|
||||
if (Object.keys(props.errors).length === 0) advancedOpen.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="logs-filter-form"
|
||||
:aria-label="t('monitor.logs.filters.label')"
|
||||
@submit.prevent="emit('apply')"
|
||||
>
|
||||
<div class="logs-filter-grid">
|
||||
<FormField id="logs-from" :label="t('monitor.logs.filters.from')" :error="error('from')">
|
||||
<template #default="{ describedBy }">
|
||||
<input
|
||||
id="logs-from"
|
||||
:value="draft.from"
|
||||
type="datetime-local"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-invalid="error('from') ? 'true' : undefined"
|
||||
@input="emit('updateField', 'from', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</template>
|
||||
</FormField>
|
||||
<FormField id="logs-to" :label="t('monitor.logs.filters.to')" :error="error('to')">
|
||||
<template #default="{ describedBy }">
|
||||
<input
|
||||
id="logs-to"
|
||||
:value="draft.to"
|
||||
type="datetime-local"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-invalid="error('to') ? 'true' : undefined"
|
||||
@input="emit('updateField', 'to', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</template>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="logs-group"
|
||||
:label="t('monitor.logs.filters.group')"
|
||||
:description="t('monitor.logs.filters.groupHelp')"
|
||||
:error="error('group_id')"
|
||||
>
|
||||
<template #default="{ describedBy }">
|
||||
<select
|
||||
id="logs-group"
|
||||
:value="draft.group_id"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-invalid="error('group_id') ? 'true' : undefined"
|
||||
:disabled="groupsFailed"
|
||||
@change="emit('updateField', 'group_id', ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">{{ t('monitor.logs.filters.anyGroup') }}</option>
|
||||
<option
|
||||
v-if="draft.group_id && !groups.some((group) => String(group.id) === draft.group_id)"
|
||||
:value="draft.group_id"
|
||||
>
|
||||
#{{ draft.group_id }}
|
||||
</option>
|
||||
<option v-for="group in groups" :key="group.id" :value="String(group.id)">
|
||||
{{ group.name }} · #{{ group.id }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
</FormField>
|
||||
<FormField id="logs-model" :label="t('monitor.logs.filters.model')" :error="error('model')">
|
||||
<template #default="{ describedBy }">
|
||||
<input
|
||||
id="logs-model"
|
||||
:value="draft.model"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-invalid="error('model') ? 'true' : undefined"
|
||||
@input="emit('updateField', 'model', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</template>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="logs-access-key"
|
||||
:label="t('monitor.logs.filters.accessKey')"
|
||||
:error="error('access_key_id')"
|
||||
>
|
||||
<template #default="{ describedBy }">
|
||||
<select
|
||||
id="logs-access-key"
|
||||
:value="draft.access_key_id"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-invalid="error('access_key_id') ? 'true' : undefined"
|
||||
:disabled="accessKeysFailed"
|
||||
@change="
|
||||
emit('updateField', 'access_key_id', ($event.target as HTMLSelectElement).value)
|
||||
"
|
||||
>
|
||||
<option value="">{{ t('monitor.logs.filters.anyAccessKey') }}</option>
|
||||
<option
|
||||
v-if="
|
||||
draft.access_key_id &&
|
||||
!accessKeys.some((key) => String(key.id) === draft.access_key_id)
|
||||
"
|
||||
:value="draft.access_key_id"
|
||||
>
|
||||
#{{ draft.access_key_id }}
|
||||
</option>
|
||||
<option v-for="key in accessKeys" :key="key.id" :value="String(key.id)">
|
||||
{{ key.name }} · #{{ key.id }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="logs-status"
|
||||
:label="t('monitor.logs.filters.status')"
|
||||
:error="error('status')"
|
||||
>
|
||||
<template #default="{ describedBy }">
|
||||
<select
|
||||
id="logs-status"
|
||||
:value="draft.status"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-invalid="error('status') ? 'true' : undefined"
|
||||
@change="emit('updateField', 'status', ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">{{ t('monitor.logs.filters.anyStatus') }}</option>
|
||||
<option value="success">{{ t('monitor.logs.status.success') }}</option>
|
||||
<option value="error">{{ t('monitor.logs.status.error') }}</option>
|
||||
<option value="incomplete">{{ t('monitor.logs.status.incomplete') }}</option>
|
||||
<option value="canceled">{{ t('monitor.logs.status.canceled') }}</option>
|
||||
</select>
|
||||
</template>
|
||||
</FormField>
|
||||
<FormField
|
||||
id="logs-request-id"
|
||||
:label="t('monitor.logs.filters.requestId')"
|
||||
:error="error('request_id')"
|
||||
>
|
||||
<template #default="{ describedBy }">
|
||||
<input
|
||||
id="logs-request-id"
|
||||
:value="draft.request_id"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-invalid="error('request_id') ? 'true' : undefined"
|
||||
@input="emit('updateField', 'request_id', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</template>
|
||||
</FormField>
|
||||
<form class="logs-filter" :aria-label="t('monitor.logs.filters.label')" @submit.prevent="submit">
|
||||
<div v-if="appliedChips.length" class="logs-filter__chips">
|
||||
<span class="logs-filter__chips-label">{{ t('monitor.logs.filters.applied') }}</span>
|
||||
<template v-for="chip in appliedChips" :key="chip.key">
|
||||
<span v-if="chip.key === 'time'" class="logs-filter__chip logs-filter__chip--fixed">
|
||||
{{ chip.label }}
|
||||
</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="logs-filter__chip"
|
||||
:aria-label="t('monitor.logs.filters.remove', { value: chip.label })"
|
||||
@click="emit('removeFilter', chip.key)"
|
||||
>
|
||||
<span>{{ chip.label }}</span>
|
||||
<X :size="12" aria-hidden="true" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<p v-if="dirty" class="logs-filter-dirty" role="status">
|
||||
<ListFilter :size="16" aria-hidden="true" />
|
||||
<span>{{ t('monitor.logs.filters.dirty') }}</span>
|
||||
</p>
|
||||
<div class="logs-filter-actions">
|
||||
<AppButton type="submit">{{ t('monitor.logs.filters.apply') }}</AppButton>
|
||||
<AppButton variant="ghost" @click="emit('reset')">
|
||||
|
||||
<div class="logs-filter__row">
|
||||
<AppDateTimeRangePicker
|
||||
:from="draft.from"
|
||||
:to="draft.to"
|
||||
:label="t('monitor.logs.filters.timeRange')"
|
||||
:from-label="t('monitor.logs.filters.from')"
|
||||
:to-label="t('monitor.logs.filters.to')"
|
||||
:timezone-label="t('monitor.logs.filters.timezone')"
|
||||
@update:from="update('from', $event)"
|
||||
@update:to="update('to', $event)"
|
||||
/>
|
||||
|
||||
<AppSelect
|
||||
class="logs-filter__group"
|
||||
:model-value="draft.group_id"
|
||||
:label="t('monitor.logs.filters.group')"
|
||||
:options="groupOptions"
|
||||
size="compact"
|
||||
:disabled="groupsFailed"
|
||||
@update:model-value="update('group_id', $event)"
|
||||
/>
|
||||
<span class="logs-filter__model">
|
||||
<AppTextInput
|
||||
:model-value="draft.client_model"
|
||||
:label="t('monitor.logs.filters.clientModel')"
|
||||
:placeholder="t('monitor.logs.filters.clientModel')"
|
||||
:invalid="Boolean(errors.client_model)"
|
||||
size="compact"
|
||||
data-1p-ignore="true"
|
||||
data-lpignore="true"
|
||||
@update:model-value="update('client_model', $event)"
|
||||
/>
|
||||
</span>
|
||||
<AppSelect
|
||||
class="logs-filter__status"
|
||||
:model-value="draft.status"
|
||||
:label="t('monitor.logs.filters.status')"
|
||||
:options="statusOptions"
|
||||
size="compact"
|
||||
@update:model-value="update('status', $event)"
|
||||
/>
|
||||
<AppButton variant="secondary" size="compact" @click="advancedOpen = true">
|
||||
<ListFilter :size="14" aria-hidden="true" />
|
||||
{{ t('monitor.logs.filters.more') }}
|
||||
<span v-if="advancedCount" class="logs-filter__count">{{ advancedCount }}</span>
|
||||
</AppButton>
|
||||
<AppButton type="submit" size="compact">{{ t('monitor.logs.filters.apply') }}</AppButton>
|
||||
<AppButton variant="ghost" size="compact" @click="reset">
|
||||
{{ t('monitor.logs.filters.reset') }}
|
||||
</AppButton>
|
||||
<AppButton variant="secondary" :busy="refreshPending" @click="emit('refresh')">
|
||||
{{ t('monitor.logs.refresh') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
|
||||
<p v-if="firstError" class="logs-filter__error" role="alert">{{ firstError }}</p>
|
||||
</form>
|
||||
|
||||
<LogsAdvancedFilterDrawer
|
||||
v-model:open="advancedOpen"
|
||||
:draft="draft"
|
||||
:errors="errors"
|
||||
:access-keys="accessKeys"
|
||||
:access-keys-failed="accessKeysFailed"
|
||||
@update-field="update"
|
||||
@apply="applyAdvanced"
|
||||
@reset="reset"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.logs-filter-form {
|
||||
.logs-filter {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-4);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--color-surface);
|
||||
padding: var(--space-4);
|
||||
box-shadow: var(--shadow-card);
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
.logs-filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(150px, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.logs-filter-form input,
|
||||
.logs-filter-form select {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--color-border-control);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
padding: 8px 10px;
|
||||
font: inherit;
|
||||
}
|
||||
.logs-filter-actions,
|
||||
.logs-filter-dirty {
|
||||
|
||||
.logs-filter__chips,
|
||||
.logs-filter__row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.logs-filter__chips {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.logs-filter-dirty {
|
||||
margin: 0;
|
||||
|
||||
.logs-filter__chips-label {
|
||||
color: var(--color-text-faint);
|
||||
font-size: var(--text-label-xs);
|
||||
}
|
||||
|
||||
.logs-filter__chip {
|
||||
display: inline-flex;
|
||||
min-height: 24px;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border: 1px solid var(--color-border-control);
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-muted);
|
||||
padding: 2px 8px;
|
||||
font: inherit;
|
||||
font-size: var(--text-label-xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.logs-filter__chip:hover {
|
||||
border-color: var(--color-text-faint);
|
||||
color: var(--color-text);
|
||||
font-weight: 650;
|
||||
}
|
||||
.logs-filter-dirty svg {
|
||||
color: var(--color-warning);
|
||||
|
||||
.logs-filter__chip--fixed {
|
||||
cursor: default;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.logs-filter-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
.logs-filter__chip--fixed:hover {
|
||||
border-color: var(--color-border-control);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.logs-filter__row {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.logs-filter__group {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.logs-filter__model {
|
||||
display: block;
|
||||
width: auto;
|
||||
min-width: 130px;
|
||||
flex: 1 1 180px;
|
||||
}
|
||||
|
||||
.logs-filter__model :deep(.app-text-input) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logs-filter__status {
|
||||
width: 108px;
|
||||
}
|
||||
|
||||
.logs-filter__group :deep(.app-select__trigger),
|
||||
.logs-filter__status :deep(.app-select__trigger) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.logs-filter__count {
|
||||
display: inline-flex;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: var(--color-action-soft);
|
||||
color: var(--color-action);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.logs-filter__error {
|
||||
margin: -2px 10px 8px;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-label-xs);
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.logs-filter__row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.logs-filter__row > :deep(.app-button),
|
||||
.logs-filter__row > :deep(.app-select__trigger) {
|
||||
min-height: var(--touch-target);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.logs-filter-grid {
|
||||
grid-template-columns: 1fr;
|
||||
.logs-filter__row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.logs-filter__row > :deep(.app-popover),
|
||||
.logs-filter__group,
|
||||
.logs-filter__model,
|
||||
.logs-filter__status {
|
||||
width: 100%;
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -29,8 +29,8 @@ const canonicalQuery = computed(() => normalizeMonitorQuery(route.query))
|
||||
const isCanonicalQuery = computed(() => sameMonitorQuery(route.query, canonicalQuery.value))
|
||||
const items = computed<AppTabItem[]>(() => [
|
||||
{ value: 'health', label: t('monitor.tabs.health') },
|
||||
{ value: 'logs', label: t('monitor.tabs.logs') },
|
||||
{ value: 'usage', label: t('monitor.tabs.usage') },
|
||||
{ value: 'logs', label: t('monitor.tabs.logs') },
|
||||
{ value: 'inspector', label: t('monitor.tabs.inspector') },
|
||||
])
|
||||
|
||||
|
||||
@@ -1,61 +1,198 @@
|
||||
import type { LocationQueryRaw } from 'vue-router'
|
||||
|
||||
import type { RequestLogFilters, RequestLogStatus } from '@/app/resources/request-logs'
|
||||
|
||||
import { requestLogStatuses } from './monitor-route'
|
||||
import { enabledDataProtocols } from '@/api/control/protocols'
|
||||
import type {
|
||||
RequestLogCostState,
|
||||
RequestLogFilters,
|
||||
RequestLogPageSize,
|
||||
RequestLogPricingCompleteness,
|
||||
RequestLogRetryState,
|
||||
RequestLogStatus,
|
||||
RequestLogUsageState,
|
||||
} from '@/app/resources/request-logs'
|
||||
|
||||
export interface LogFilterDraft {
|
||||
from: string
|
||||
to: string
|
||||
group_id: string
|
||||
model: string
|
||||
access_key_id: string
|
||||
status: string
|
||||
client_model: string
|
||||
upstream_model: string
|
||||
access_key_id: string
|
||||
request_id: string
|
||||
protocol: string
|
||||
stream: string
|
||||
final_status_code: string
|
||||
usage_state: string
|
||||
cost_state: string
|
||||
pricing_completeness: string
|
||||
cache_present: string
|
||||
upstream_key_id: string
|
||||
attempt_status_code: string
|
||||
failure_category: string
|
||||
error_code: string
|
||||
retry_state: string
|
||||
retry_count_min: string
|
||||
retry_count_max: string
|
||||
first_response_min_ms: string
|
||||
first_response_max_ms: string
|
||||
duration_min_ms: string
|
||||
duration_max_ms: string
|
||||
input_tokens_min: string
|
||||
input_tokens_max: string
|
||||
output_tokens_min: string
|
||||
output_tokens_max: string
|
||||
cost_min_usd: string
|
||||
cost_max_usd: string
|
||||
}
|
||||
|
||||
export type LogFilterErrors = Partial<Record<keyof LogFilterDraft, string>>
|
||||
|
||||
const requestIDPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
export const requestLogStatuses = ['success', 'error', 'incomplete', 'canceled'] as const
|
||||
export const requestLogUsageStates = ['complete', 'partial', 'missing', 'not_applicable'] as const
|
||||
export const requestLogCostStates = ['priced', 'unpriced', 'not_applicable'] as const
|
||||
export const requestLogPricingCompleteness = [
|
||||
'complete',
|
||||
'partial',
|
||||
'unavailable',
|
||||
'not_applicable',
|
||||
] as const
|
||||
export const requestLogFailureCategories = [
|
||||
'ok',
|
||||
'rate_limited',
|
||||
'model_unavailable',
|
||||
'invalid_key',
|
||||
'upstream_host_error',
|
||||
'client_error',
|
||||
'downstream_cancel',
|
||||
'ambiguous',
|
||||
] as const
|
||||
export const requestLogRetryStates = ['retried', 'not_retried'] as const
|
||||
|
||||
const emptyDraft = (): LogFilterDraft => ({
|
||||
from: '',
|
||||
to: '',
|
||||
group_id: '',
|
||||
model: '',
|
||||
access_key_id: '',
|
||||
status: '',
|
||||
request_id: '',
|
||||
})
|
||||
const requestIDPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
const canonicalNonNegativeInteger = /^(?:0|[1-9]\d*)$/
|
||||
const safeText = (value: string): boolean =>
|
||||
value === value.trim() && value.length <= 255 && !/[\u0000-\u001f\u007f]/u.test(value)
|
||||
|
||||
function defaultRange(): Pick<RequestLogFilters, 'from_ms' | 'to_ms'> {
|
||||
const now = Math.floor(Date.now() / 1000) * 1000
|
||||
return {
|
||||
from_ms: Math.max(0, now - 60 * 60 * 1000),
|
||||
to_ms: now + 24 * 60 * 60 * 1000,
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultRequestLogFilters(): RequestLogFilters {
|
||||
return { ...defaultRange(), limit: 20 }
|
||||
}
|
||||
|
||||
function emptyDraft(): LogFilterDraft {
|
||||
return {
|
||||
from: '',
|
||||
to: '',
|
||||
group_id: '',
|
||||
status: '',
|
||||
client_model: '',
|
||||
upstream_model: '',
|
||||
access_key_id: '',
|
||||
request_id: '',
|
||||
protocol: '',
|
||||
stream: '',
|
||||
final_status_code: '',
|
||||
usage_state: '',
|
||||
cost_state: '',
|
||||
pricing_completeness: '',
|
||||
cache_present: '',
|
||||
upstream_key_id: '',
|
||||
attempt_status_code: '',
|
||||
failure_category: '',
|
||||
error_code: '',
|
||||
retry_state: '',
|
||||
retry_count_min: '',
|
||||
retry_count_max: '',
|
||||
first_response_min_ms: '',
|
||||
first_response_max_ms: '',
|
||||
duration_min_ms: '',
|
||||
duration_max_ms: '',
|
||||
input_tokens_min: '',
|
||||
input_tokens_max: '',
|
||||
output_tokens_min: '',
|
||||
output_tokens_max: '',
|
||||
cost_min_usd: '',
|
||||
cost_max_usd: '',
|
||||
}
|
||||
}
|
||||
|
||||
function toLocalDateTime(value: number | undefined): string {
|
||||
if (value === undefined || !Number.isSafeInteger(value) || value < 0) return ''
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
const pad = (part: number) => String(part).padStart(2, '0')
|
||||
return [
|
||||
date.getFullYear(),
|
||||
'-',
|
||||
pad(date.getMonth() + 1),
|
||||
'-',
|
||||
pad(date.getDate()),
|
||||
'T',
|
||||
pad(date.getHours()),
|
||||
':',
|
||||
pad(date.getMinutes()),
|
||||
].join('')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(
|
||||
date.getHours(),
|
||||
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
|
||||
export function createLogFilterDraft(filters: RequestLogFilters = {}): LogFilterDraft {
|
||||
function nanoUSDToUSD(value: string | undefined): string {
|
||||
if (value === undefined || !canonicalNonNegativeInteger.test(value)) return ''
|
||||
const padded = value.padStart(10, '0')
|
||||
const whole = padded.slice(0, -9)
|
||||
const fraction = padded.slice(-9).replace(/0+$/u, '')
|
||||
return fraction ? `${whole}.${fraction}` : whole
|
||||
}
|
||||
|
||||
function usdToNanoUSD(value: string): string | undefined {
|
||||
const match = value.match(/^(0|[1-9]\d*)(?:\.(\d{1,9}))?$/u)
|
||||
if (!match) return undefined
|
||||
const whole = match[1]
|
||||
const fraction = (match[2] ?? '').padEnd(9, '0')
|
||||
if (whole === undefined) return undefined
|
||||
return (BigInt(whole) * 1_000_000_000n + BigInt(fraction || '0')).toString()
|
||||
}
|
||||
|
||||
export function createLogFilterDraft(filters: RequestLogFilters = defaultRange()): LogFilterDraft {
|
||||
return {
|
||||
...emptyDraft(),
|
||||
from: toLocalDateTime(filters.from_ms),
|
||||
to: toLocalDateTime(filters.to_ms),
|
||||
group_id: filters.group_id === undefined ? '' : String(filters.group_id),
|
||||
model: filters.model ?? '',
|
||||
access_key_id: filters.access_key_id === undefined ? '' : String(filters.access_key_id),
|
||||
status: filters.status ?? '',
|
||||
client_model: filters.client_model ?? '',
|
||||
upstream_model: filters.upstream_model ?? '',
|
||||
access_key_id: filters.access_key_id === undefined ? '' : String(filters.access_key_id),
|
||||
request_id: filters.request_id ?? '',
|
||||
protocol: filters.protocol ?? '',
|
||||
stream: filters.stream === undefined ? '' : String(filters.stream),
|
||||
final_status_code:
|
||||
filters.final_status_code === undefined ? '' : String(filters.final_status_code),
|
||||
usage_state: filters.usage_state ?? '',
|
||||
cost_state: filters.cost_state ?? '',
|
||||
pricing_completeness: filters.pricing_completeness ?? '',
|
||||
cache_present: filters.cache_present === undefined ? '' : String(filters.cache_present),
|
||||
upstream_key_id: filters.upstream_key_id === undefined ? '' : String(filters.upstream_key_id),
|
||||
attempt_status_code:
|
||||
filters.attempt_status_code === undefined ? '' : String(filters.attempt_status_code),
|
||||
failure_category: filters.failure_category ?? '',
|
||||
error_code: filters.error_code ?? '',
|
||||
retry_state: filters.retry_state ?? '',
|
||||
retry_count_min: filters.retry_count_min === undefined ? '' : String(filters.retry_count_min),
|
||||
retry_count_max: filters.retry_count_max === undefined ? '' : String(filters.retry_count_max),
|
||||
first_response_min_ms:
|
||||
filters.first_response_min_ms === undefined ? '' : String(filters.first_response_min_ms),
|
||||
first_response_max_ms:
|
||||
filters.first_response_max_ms === undefined ? '' : String(filters.first_response_max_ms),
|
||||
duration_min_ms: filters.duration_min_ms === undefined ? '' : String(filters.duration_min_ms),
|
||||
duration_max_ms: filters.duration_max_ms === undefined ? '' : String(filters.duration_max_ms),
|
||||
input_tokens_min:
|
||||
filters.input_tokens_min === undefined ? '' : String(filters.input_tokens_min),
|
||||
input_tokens_max:
|
||||
filters.input_tokens_max === undefined ? '' : String(filters.input_tokens_max),
|
||||
output_tokens_min:
|
||||
filters.output_tokens_min === undefined ? '' : String(filters.output_tokens_min),
|
||||
output_tokens_max:
|
||||
filters.output_tokens_max === undefined ? '' : String(filters.output_tokens_max),
|
||||
cost_min_usd: nanoUSDToUSD(filters.cost_min_nano_usd),
|
||||
cost_max_usd: nanoUSDToUSD(filters.cost_max_nano_usd),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,119 +201,250 @@ export function applyLogFilterDraft(draft: LogFilterDraft): RequestLogFilters {
|
||||
if (draft.from) filters.from_ms = new Date(draft.from).getTime()
|
||||
if (draft.to) filters.to_ms = new Date(draft.to).getTime()
|
||||
if (draft.group_id) filters.group_id = Number(draft.group_id)
|
||||
if (draft.model) filters.model = draft.model
|
||||
if (draft.access_key_id) filters.access_key_id = Number(draft.access_key_id)
|
||||
if (draft.status) filters.status = draft.status as RequestLogStatus
|
||||
if (draft.client_model) filters.client_model = draft.client_model
|
||||
if (draft.upstream_model) filters.upstream_model = draft.upstream_model
|
||||
if (draft.access_key_id) filters.access_key_id = Number(draft.access_key_id)
|
||||
if (draft.request_id) filters.request_id = draft.request_id
|
||||
if (draft.protocol) filters.protocol = draft.protocol as RequestLogFilters['protocol']
|
||||
if (draft.stream) filters.stream = draft.stream === 'true'
|
||||
if (draft.final_status_code) filters.final_status_code = Number(draft.final_status_code)
|
||||
if (draft.usage_state) filters.usage_state = draft.usage_state as RequestLogUsageState
|
||||
if (draft.cost_state) filters.cost_state = draft.cost_state as RequestLogCostState
|
||||
if (draft.pricing_completeness) {
|
||||
filters.pricing_completeness = draft.pricing_completeness as RequestLogPricingCompleteness
|
||||
}
|
||||
if (draft.cache_present) filters.cache_present = draft.cache_present === 'true'
|
||||
if (draft.upstream_key_id) filters.upstream_key_id = Number(draft.upstream_key_id)
|
||||
if (draft.attempt_status_code) filters.attempt_status_code = Number(draft.attempt_status_code)
|
||||
if (draft.failure_category) {
|
||||
filters.failure_category = draft.failure_category as RequestLogFilters['failure_category']
|
||||
}
|
||||
if (draft.error_code) filters.error_code = draft.error_code
|
||||
if (draft.retry_state) filters.retry_state = draft.retry_state as RequestLogRetryState
|
||||
for (const field of [
|
||||
'retry_count_min',
|
||||
'retry_count_max',
|
||||
'first_response_min_ms',
|
||||
'first_response_max_ms',
|
||||
'duration_min_ms',
|
||||
'duration_max_ms',
|
||||
'input_tokens_min',
|
||||
'input_tokens_max',
|
||||
'output_tokens_min',
|
||||
'output_tokens_max',
|
||||
] as const) {
|
||||
if (draft[field]) Object.assign(filters, { [field]: Number(draft[field]) })
|
||||
}
|
||||
const costMin = usdToNanoUSD(draft.cost_min_usd)
|
||||
const costMax = usdToNanoUSD(draft.cost_max_usd)
|
||||
if (costMin !== undefined) filters.cost_min_nano_usd = costMin
|
||||
if (costMax !== undefined) filters.cost_max_nano_usd = costMax
|
||||
return filters
|
||||
}
|
||||
|
||||
function scalar(raw: unknown): string | undefined {
|
||||
return typeof raw === 'string' ? raw : undefined
|
||||
}
|
||||
|
||||
function parseSafeInteger(raw: unknown, minimum = 0, maximum = Number.MAX_SAFE_INTEGER) {
|
||||
const value = scalar(raw)
|
||||
if (value === undefined || !canonicalNonNegativeInteger.test(value)) return undefined
|
||||
const number = Number(value)
|
||||
return Number.isSafeInteger(number) && number >= minimum && number <= maximum ? number : undefined
|
||||
}
|
||||
|
||||
function parseBoolean(raw: unknown): boolean | undefined {
|
||||
return raw === 'true' ? true : raw === 'false' ? false : undefined
|
||||
}
|
||||
|
||||
function parseText(raw: unknown): string | undefined {
|
||||
const value = scalar(raw)
|
||||
return value && safeText(value) ? value : undefined
|
||||
}
|
||||
|
||||
function parseEnum<T extends string>(raw: unknown, values: readonly T[]): T | undefined {
|
||||
return typeof raw === 'string' && values.includes(raw as T) ? (raw as T) : undefined
|
||||
}
|
||||
|
||||
function parseNanoUSD(raw: unknown): string | undefined {
|
||||
const value = scalar(raw)
|
||||
if (value === undefined || !canonicalNonNegativeInteger.test(value)) return undefined
|
||||
try {
|
||||
return BigInt(value) <= 9_223_372_036_854_775_807n ? value : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAppliedLogFilters(query: Record<string, unknown>): RequestLogFilters {
|
||||
const filters: RequestLogFilters = {}
|
||||
const fromMS = parseEpochMilliseconds(query.from_ms)
|
||||
const toMS = parseEpochMilliseconds(query.to_ms)
|
||||
if (fromMS !== undefined) filters.from_ms = fromMS
|
||||
if (toMS !== undefined) filters.to_ms = toMS
|
||||
if (typeof query.group_id === 'string') filters.group_id = Number(query.group_id)
|
||||
if (typeof query.model === 'string') filters.model = query.model
|
||||
if (typeof query.access_key_id === 'string') filters.access_key_id = Number(query.access_key_id)
|
||||
if (typeof query.status === 'string') filters.status = query.status as RequestLogStatus
|
||||
if (typeof query.request_id === 'string') filters.request_id = query.request_id
|
||||
const from = parseSafeInteger(query.from_ms)
|
||||
const to = parseSafeInteger(query.to_ms)
|
||||
if (from !== undefined && to !== undefined && from < to) {
|
||||
filters.from_ms = from
|
||||
filters.to_ms = to
|
||||
} else {
|
||||
Object.assign(filters, defaultRange())
|
||||
}
|
||||
const limit = parseSafeInteger(query.limit)
|
||||
filters.limit = limit === 20 || limit === 50 || limit === 100 ? (limit as RequestLogPageSize) : 20
|
||||
const ids = ['group_id', 'access_key_id', 'upstream_key_id'] as const
|
||||
for (const field of ids) {
|
||||
const value = parseSafeInteger(query[field], 1)
|
||||
if (value !== undefined) Object.assign(filters, { [field]: value })
|
||||
}
|
||||
const numbers = [
|
||||
'retry_count_min',
|
||||
'retry_count_max',
|
||||
'first_response_min_ms',
|
||||
'first_response_max_ms',
|
||||
'duration_min_ms',
|
||||
'duration_max_ms',
|
||||
'input_tokens_min',
|
||||
'input_tokens_max',
|
||||
'output_tokens_min',
|
||||
'output_tokens_max',
|
||||
] as const
|
||||
for (const field of numbers) {
|
||||
const value = parseSafeInteger(query[field])
|
||||
if (value !== undefined) Object.assign(filters, { [field]: value })
|
||||
}
|
||||
for (const field of ['final_status_code', 'attempt_status_code'] as const) {
|
||||
const value = parseSafeInteger(query[field], 0, 999)
|
||||
if (value !== undefined) Object.assign(filters, { [field]: value })
|
||||
}
|
||||
for (const field of ['client_model', 'upstream_model', 'error_code'] as const) {
|
||||
const value = parseText(query[field])
|
||||
if (value !== undefined) Object.assign(filters, { [field]: value })
|
||||
}
|
||||
const requestID = scalar(query.request_id)
|
||||
if (requestID && requestIDPattern.test(requestID)) filters.request_id = requestID
|
||||
const status = parseEnum(query.status, requestLogStatuses)
|
||||
if (status) filters.status = status
|
||||
const protocol = parseEnum(query.protocol, enabledDataProtocols)
|
||||
if (protocol) filters.protocol = protocol
|
||||
const usageState = parseEnum(query.usage_state, requestLogUsageStates)
|
||||
if (usageState) filters.usage_state = usageState
|
||||
const costState = parseEnum(query.cost_state, requestLogCostStates)
|
||||
if (costState) filters.cost_state = costState
|
||||
const completeness = parseEnum(query.pricing_completeness, requestLogPricingCompleteness)
|
||||
if (completeness) filters.pricing_completeness = completeness
|
||||
const failure = parseEnum(query.failure_category, requestLogFailureCategories)
|
||||
if (failure) filters.failure_category = failure
|
||||
const retryState = parseEnum(query.retry_state, requestLogRetryStates)
|
||||
if (retryState) filters.retry_state = retryState
|
||||
for (const field of ['stream', 'cache_present'] as const) {
|
||||
const value = parseBoolean(query[field])
|
||||
if (value !== undefined) Object.assign(filters, { [field]: value })
|
||||
}
|
||||
const minCost = parseNanoUSD(query.cost_min_nano_usd)
|
||||
const maxCost = parseNanoUSD(query.cost_max_nano_usd)
|
||||
if (minCost !== undefined) filters.cost_min_nano_usd = minCost
|
||||
if (maxCost !== undefined) filters.cost_max_nano_usd = maxCost
|
||||
return filters
|
||||
}
|
||||
|
||||
export function serializeAppliedLogFilters(filters: RequestLogFilters): LocationQueryRaw {
|
||||
const query: LocationQueryRaw = { tab: 'logs' }
|
||||
if (filters.from_ms !== undefined) query.from_ms = String(filters.from_ms)
|
||||
if (filters.to_ms !== undefined) query.to_ms = String(filters.to_ms)
|
||||
if (filters.group_id !== undefined) query.group_id = String(filters.group_id)
|
||||
if (filters.model !== undefined) query.model = filters.model
|
||||
if (filters.access_key_id !== undefined) query.access_key_id = String(filters.access_key_id)
|
||||
if (filters.status !== undefined) query.status = filters.status
|
||||
if (filters.request_id !== undefined) query.request_id = filters.request_id
|
||||
for (const [field, value] of Object.entries(filters)) {
|
||||
if (value !== undefined) query[field] = String(value)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
function isPositiveID(value: string): boolean {
|
||||
if (!/^\d+$/.test(value)) return false
|
||||
const parsed = Number(value)
|
||||
return Number.isSafeInteger(parsed) && parsed > 0
|
||||
}
|
||||
|
||||
function parseEpochMilliseconds(value: unknown): number | undefined {
|
||||
if (typeof value !== 'string' || !/^(?:0|[1-9]\d*)$/.test(value)) return undefined
|
||||
const parsed = Number(value)
|
||||
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined
|
||||
}
|
||||
|
||||
function parseLocalDateTime(value: string): Date | undefined {
|
||||
const match = value.match(
|
||||
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/,
|
||||
)
|
||||
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/u)
|
||||
if (!match) return undefined
|
||||
const [, year, month, day, hour, minute, second = '0', milliseconds = '0'] = match
|
||||
const parts = [year, month, day, hour, minute, second, milliseconds.padEnd(3, '0')].map(Number)
|
||||
const [yearValue, monthValue, dayValue, hourValue, minuteValue, secondValue, msValue] = parts
|
||||
const values = match.slice(1).map(Number)
|
||||
const [year, month, day, hour, minute, second] = values
|
||||
if ([year, month, day, hour, minute, second].some((part) => part === undefined)) return undefined
|
||||
const date = new Date(year!, month! - 1, day!, hour!, minute!, second!)
|
||||
return date.getFullYear() === year &&
|
||||
date.getMonth() === month! - 1 &&
|
||||
date.getDate() === day &&
|
||||
date.getHours() === hour &&
|
||||
date.getMinutes() === minute &&
|
||||
date.getSeconds() === second
|
||||
? date
|
||||
: undefined
|
||||
}
|
||||
|
||||
function validateIntegerField(
|
||||
errors: LogFilterErrors,
|
||||
draft: LogFilterDraft,
|
||||
field: keyof LogFilterDraft,
|
||||
maximum = Number.MAX_SAFE_INTEGER,
|
||||
): void {
|
||||
const value = draft[field]
|
||||
if (!value) return
|
||||
const parsed = Number(value)
|
||||
if (
|
||||
yearValue === undefined ||
|
||||
monthValue === undefined ||
|
||||
dayValue === undefined ||
|
||||
hourValue === undefined ||
|
||||
minuteValue === undefined ||
|
||||
secondValue === undefined ||
|
||||
msValue === undefined
|
||||
!canonicalNonNegativeInteger.test(value) ||
|
||||
!Number.isSafeInteger(parsed) ||
|
||||
parsed > maximum
|
||||
) {
|
||||
return undefined
|
||||
errors[field] = 'monitor.logs.errors.nonNegativeInteger'
|
||||
}
|
||||
const date = new Date(
|
||||
yearValue,
|
||||
monthValue - 1,
|
||||
dayValue,
|
||||
hourValue,
|
||||
minuteValue,
|
||||
secondValue,
|
||||
msValue,
|
||||
)
|
||||
if (
|
||||
date.getFullYear() !== yearValue ||
|
||||
date.getMonth() !== monthValue - 1 ||
|
||||
date.getDate() !== dayValue ||
|
||||
date.getHours() !== hourValue ||
|
||||
date.getMinutes() !== minuteValue ||
|
||||
date.getSeconds() !== secondValue ||
|
||||
date.getMilliseconds() !== msValue
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
export function validateLogFilterDraft(draft: LogFilterDraft): LogFilterErrors {
|
||||
const errors: LogFilterErrors = {}
|
||||
const from = draft.from ? parseLocalDateTime(draft.from) : undefined
|
||||
const to = draft.to ? parseLocalDateTime(draft.to) : undefined
|
||||
if (draft.from && !from) errors.from = 'monitor.logs.errors.dateTime'
|
||||
if (draft.to && !to) errors.to = 'monitor.logs.errors.dateTime'
|
||||
if (from && to && from.getTime() >= to.getTime()) {
|
||||
errors.to = 'monitor.logs.errors.range'
|
||||
const from = parseLocalDateTime(draft.from)
|
||||
const to = parseLocalDateTime(draft.to)
|
||||
if (!from) errors.from = 'monitor.logs.errors.dateTime'
|
||||
if (!to) errors.to = 'monitor.logs.errors.dateTime'
|
||||
if (from && to && from.getTime() >= to.getTime()) errors.to = 'monitor.logs.errors.range'
|
||||
for (const field of ['group_id', 'access_key_id', 'upstream_key_id'] as const) {
|
||||
validateIntegerField(errors, draft, field)
|
||||
if (draft[field] === '0') errors[field] = 'monitor.logs.errors.positiveId'
|
||||
}
|
||||
if (draft.group_id && !isPositiveID(draft.group_id)) {
|
||||
errors.group_id = 'monitor.logs.errors.positiveId'
|
||||
for (const field of [
|
||||
'retry_count_min',
|
||||
'retry_count_max',
|
||||
'first_response_min_ms',
|
||||
'first_response_max_ms',
|
||||
'duration_min_ms',
|
||||
'duration_max_ms',
|
||||
'input_tokens_min',
|
||||
'input_tokens_max',
|
||||
'output_tokens_min',
|
||||
'output_tokens_max',
|
||||
] as const) {
|
||||
validateIntegerField(errors, draft, field)
|
||||
}
|
||||
if (draft.access_key_id && !isPositiveID(draft.access_key_id)) {
|
||||
errors.access_key_id = 'monitor.logs.errors.positiveId'
|
||||
for (const field of ['final_status_code', 'attempt_status_code'] as const) {
|
||||
validateIntegerField(errors, draft, field, 999)
|
||||
}
|
||||
if (
|
||||
draft.model &&
|
||||
(draft.model.trim() !== draft.model || /[\u0000-\u001f\u007f]/.test(draft.model))
|
||||
) {
|
||||
errors.model = 'monitor.logs.errors.model'
|
||||
}
|
||||
if (draft.status && !requestLogStatuses.includes(draft.status as RequestLogStatus)) {
|
||||
errors.status = 'monitor.logs.errors.status'
|
||||
for (const field of ['client_model', 'upstream_model', 'error_code'] as const) {
|
||||
if (draft[field] && !safeText(draft[field])) errors[field] = 'monitor.logs.errors.text'
|
||||
}
|
||||
if (draft.request_id && !requestIDPattern.test(draft.request_id)) {
|
||||
errors.request_id = 'monitor.logs.errors.requestId'
|
||||
}
|
||||
if (draft.cost_min_usd && usdToNanoUSD(draft.cost_min_usd) === undefined) {
|
||||
errors.cost_min_usd = 'monitor.logs.errors.usd'
|
||||
}
|
||||
if (draft.cost_max_usd && usdToNanoUSD(draft.cost_max_usd) === undefined) {
|
||||
errors.cost_max_usd = 'monitor.logs.errors.usd'
|
||||
}
|
||||
for (const [minimum, maximum] of [
|
||||
['retry_count_min', 'retry_count_max'],
|
||||
['first_response_min_ms', 'first_response_max_ms'],
|
||||
['duration_min_ms', 'duration_max_ms'],
|
||||
['input_tokens_min', 'input_tokens_max'],
|
||||
['output_tokens_min', 'output_tokens_max'],
|
||||
] as const) {
|
||||
if (!errors[minimum] && !errors[maximum] && draft[minimum] && draft[maximum]) {
|
||||
if (Number(draft[minimum]) > Number(draft[maximum])) {
|
||||
errors[maximum] = 'monitor.logs.errors.numericRange'
|
||||
}
|
||||
}
|
||||
}
|
||||
const minCost = usdToNanoUSD(draft.cost_min_usd)
|
||||
const maxCost = usdToNanoUSD(draft.cost_max_usd)
|
||||
if (minCost !== undefined && maxCost !== undefined && BigInt(minCost) > BigInt(maxCost)) {
|
||||
errors.cost_max_usd = 'monitor.logs.errors.numericRange'
|
||||
}
|
||||
return errors
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { RequestLogItemDto } from '@/app/resources/request-logs'
|
||||
|
||||
export function formatLogDuration(milliseconds: number): string {
|
||||
if (!Number.isSafeInteger(milliseconds) || milliseconds < 0) return '—'
|
||||
if (milliseconds < 1_000) return `${milliseconds}ms`
|
||||
if (milliseconds < 60_000) {
|
||||
const seconds = milliseconds / 1_000
|
||||
return `${seconds.toFixed(seconds < 10 ? 2 : 1).replace(/\.0+$/u, '')}s`
|
||||
}
|
||||
const totalSeconds = Math.round(milliseconds / 1_000)
|
||||
const hours = Math.floor(totalSeconds / 3_600)
|
||||
const minutes = Math.floor((totalSeconds % 3_600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
if (hours > 0) {
|
||||
return `${hours}h${String(minutes).padStart(2, '0')}m${String(seconds).padStart(2, '0')}s`
|
||||
}
|
||||
return `${minutes}m${String(seconds).padStart(2, '0')}s`
|
||||
}
|
||||
|
||||
export function formatLogTokenCount(value: string, locale: string): string {
|
||||
if (!/^(?:0|[1-9]\d*)$/u.test(value)) return '—'
|
||||
try {
|
||||
return new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(BigInt(value))
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export function formatLogOutputRate(log: RequestLogItemDto, locale: string): string {
|
||||
if (!log.stream || log.first_response_ms === null || log.duration_ms <= log.first_response_ms) {
|
||||
return '—'
|
||||
}
|
||||
const output = Number(log.output_tokens)
|
||||
if (!Number.isSafeInteger(output) || output <= 0) return '—'
|
||||
const rate = output / ((log.duration_ms - log.first_response_ms) / 1_000)
|
||||
if (!Number.isFinite(rate)) return '—'
|
||||
return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(rate)} t/s`
|
||||
}
|
||||
|
||||
export function hasRequestLogCache(log: RequestLogItemDto): boolean {
|
||||
return [
|
||||
log.cache_read_tokens,
|
||||
log.cache_write_5m_tokens,
|
||||
log.cache_write_1h_tokens,
|
||||
log.cache_write_unknown_tokens,
|
||||
].some((value) => value !== '0')
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { LocationQueryRaw } from 'vue-router'
|
||||
|
||||
import { enabledDataProtocols } from '@/api/control/protocols'
|
||||
import type { RequestLogStatus } from '@/app/resources/request-logs'
|
||||
import type { UsageFilters } from '@/app/resources/usage'
|
||||
|
||||
import { parseAppliedLogFilters, serializeAppliedLogFilters } from './log-filters'
|
||||
import {
|
||||
normalizeUsageGroupID,
|
||||
normalizeUsageModel,
|
||||
@@ -11,13 +11,6 @@ import {
|
||||
} from './usage-filters'
|
||||
|
||||
export type MonitorTab = 'health' | 'logs' | 'inspector' | 'usage'
|
||||
|
||||
export const requestLogStatuses = [
|
||||
'success',
|
||||
'error',
|
||||
'incomplete',
|
||||
'canceled',
|
||||
] as const satisfies readonly RequestLogStatus[]
|
||||
const requestIDPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
|
||||
export function normalizeMonitorTab(raw: unknown): MonitorTab {
|
||||
@@ -75,27 +68,9 @@ function normalizeInspectorQuery(
|
||||
}
|
||||
|
||||
function normalizeLogsQuery(query: Record<string, unknown>, tab: MonitorTab): LocationQueryRaw {
|
||||
const normalized: LocationQueryRaw = { tab }
|
||||
const fromMS = scalarEpochMilliseconds(query.from_ms)
|
||||
const toMS = scalarEpochMilliseconds(query.to_ms)
|
||||
|
||||
if (fromMS === undefined || toMS === undefined || fromMS < toMS) {
|
||||
if (fromMS !== undefined) normalized.from_ms = fromMS
|
||||
if (toMS !== undefined) normalized.to_ms = toMS
|
||||
}
|
||||
|
||||
const groupID = scalarPositiveID(query.group_id)
|
||||
const model = scalarText(query.model)
|
||||
const accessKeyID = scalarPositiveID(query.access_key_id)
|
||||
const status = scalarEnum(query.status, requestLogStatuses)
|
||||
const requestID = scalarUUIDv4(query.request_id)
|
||||
const normalized = serializeAppliedLogFilters(parseAppliedLogFilters(query))
|
||||
normalized.tab = tab
|
||||
const selectedRequestID = parseSelectedRequestID(query)
|
||||
|
||||
if (groupID !== undefined) normalized.group_id = groupID
|
||||
if (model !== undefined) normalized.model = model
|
||||
if (accessKeyID !== undefined) normalized.access_key_id = accessKeyID
|
||||
if (status !== undefined) normalized.status = status
|
||||
if (requestID !== undefined) normalized.request_id = requestID
|
||||
if (selectedRequestID !== undefined) normalized.selected_request_id = selectedRequestID
|
||||
return normalized
|
||||
}
|
||||
@@ -122,9 +97,3 @@ function scalarEnum<T extends string>(raw: unknown, values: readonly T[]): T | u
|
||||
function scalarUUIDv4(raw: unknown): string | undefined {
|
||||
return typeof raw === 'string' && requestIDPattern.test(raw) ? raw : undefined
|
||||
}
|
||||
|
||||
function scalarEpochMilliseconds(raw: unknown): string | undefined {
|
||||
if (typeof raw !== 'string' || !/^(?:0|[1-9]\d*)$/.test(raw)) return undefined
|
||||
const value = Number(raw)
|
||||
return Number.isSafeInteger(value) && value >= 0 ? String(value) : undefined
|
||||
}
|
||||
@@ -423,20 +423,50 @@ export default {
|
||||
options: {
|
||||
groupsFailed: 'Unable to load Group filter options. The log list is unaffected.',
|
||||
accessKeysFailed: 'Unable to load AccessKey filter options. The log list is unaffected.',
|
||||
partialFailed: 'Some filter options are unavailable. Log queries remain available.',
|
||||
},
|
||||
filters: {
|
||||
label: 'Request log filters',
|
||||
timeRange: 'Time range',
|
||||
from: 'From',
|
||||
to: 'To',
|
||||
quickRanges: 'Quick time ranges',
|
||||
quick: {
|
||||
'15m': '15m',
|
||||
'1h': '1h',
|
||||
'24h': '24h',
|
||||
'3d': '3d',
|
||||
'7d': '7d',
|
||||
'15d': '15d',
|
||||
},
|
||||
any: 'Any',
|
||||
group: 'Group',
|
||||
groupHelp: 'Matches any Group used by an attempt.',
|
||||
anyGroup: 'Any Group',
|
||||
model: 'Client model',
|
||||
clientModel: 'Client model',
|
||||
upstreamModel: 'Upstream model',
|
||||
accessKey: 'AccessKey',
|
||||
anyAccessKey: 'Any AccessKey',
|
||||
protocol: 'Protocol',
|
||||
anyProtocol: 'Any protocol',
|
||||
stream: 'Streaming request',
|
||||
status: 'Status',
|
||||
anyStatus: 'Any status',
|
||||
requestId: 'Request ID',
|
||||
finalStatusCode: 'Final HTTP status',
|
||||
upstreamKey: 'Upstream key ID',
|
||||
attemptStatusCode: 'Any-attempt status',
|
||||
failureCategory: 'Any-attempt failure',
|
||||
errorCode: 'Any-attempt error code',
|
||||
retryStateLabel: 'Retry state',
|
||||
retryMin: 'Minimum retries',
|
||||
retryMax: 'Maximum retries',
|
||||
usageStateLabel: 'Usage state',
|
||||
costStateLabel: 'Cost state',
|
||||
completenessLabel: 'Pricing completeness',
|
||||
cachePresent: 'Has cache tokens',
|
||||
more: 'More filters',
|
||||
apply: 'Apply',
|
||||
reset: 'Reset',
|
||||
dirty: 'Filter draft has not been applied',
|
||||
@@ -446,11 +476,58 @@ export default {
|
||||
appliedTo: 'To {value}',
|
||||
appliedGroup: 'Group {value}',
|
||||
appliedModel: 'Client model {value}',
|
||||
appliedClientModel: 'Client model {value}',
|
||||
appliedUpstreamModel: 'Upstream model {value}',
|
||||
appliedAccessKey: 'AccessKey {value}',
|
||||
appliedStatus: 'Status {value}',
|
||||
appliedUsageState: 'Usage {value}',
|
||||
appliedCostState: 'Cost {value}',
|
||||
appliedCompleteness: 'Pricing {value}',
|
||||
appliedRequestId: 'Request ID {value}',
|
||||
timezone: 'Local timezone',
|
||||
lastRefreshed: 'Last successful refresh',
|
||||
remove: 'Remove filter {value}',
|
||||
advancedTitle: 'More filters',
|
||||
advancedDescription: 'Filter by request, retry attempts, final result, and numeric ranges.',
|
||||
closeAdvanced: 'Close more filters',
|
||||
sections: {
|
||||
request: 'Request',
|
||||
attempt: 'Retry attempts',
|
||||
result: 'Final result',
|
||||
ranges: 'Numeric ranges',
|
||||
},
|
||||
retryState: { retried: 'Retried', not_retried: 'Not retried' },
|
||||
usageState: {
|
||||
complete: 'Complete',
|
||||
partial: 'Partial',
|
||||
missing: 'Missing',
|
||||
not_applicable: 'Not applicable',
|
||||
},
|
||||
costState: {
|
||||
priced: 'Priced',
|
||||
unpriced: 'Unpriced',
|
||||
not_applicable: 'Not applicable',
|
||||
},
|
||||
completeness: {
|
||||
complete: 'Complete',
|
||||
partial: 'Partial',
|
||||
unavailable: 'Unavailable',
|
||||
not_applicable: 'Not applicable',
|
||||
},
|
||||
rangeFields: {
|
||||
retry_count_min: 'Minimum retries',
|
||||
retry_count_max: 'Maximum retries',
|
||||
first_response_min_ms: 'Minimum first response (ms)',
|
||||
first_response_max_ms: 'Maximum first response (ms)',
|
||||
duration_min_ms: 'Minimum duration (ms)',
|
||||
duration_max_ms: 'Maximum duration (ms)',
|
||||
input_tokens_min: 'Minimum input tokens',
|
||||
input_tokens_max: 'Maximum input tokens',
|
||||
output_tokens_min: 'Minimum output tokens',
|
||||
output_tokens_max: 'Maximum output tokens',
|
||||
cost_min_usd: 'Minimum cost (USD)',
|
||||
cost_max_usd: 'Maximum cost (USD)',
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
dateTime: 'Enter a valid local date and time.',
|
||||
@@ -459,6 +536,10 @@ export default {
|
||||
model: 'Client model cannot contain surrounding whitespace or control characters.',
|
||||
status: 'Select a valid status.',
|
||||
requestId: 'Enter a canonical lowercase UUIDv4.',
|
||||
nonNegativeInteger: 'Enter a non-negative integer.',
|
||||
numericRange: 'The maximum cannot be less than the minimum.',
|
||||
text: 'Text cannot contain surrounding whitespace or control characters.',
|
||||
usd: 'Enter a non-negative USD amount with up to 9 decimal places.',
|
||||
},
|
||||
accessKey: {
|
||||
named: '{name} · #{id}',
|
||||
@@ -492,6 +573,8 @@ export default {
|
||||
title: 'Request log details',
|
||||
description: 'Review the final result and redacted upstream attempt chain.',
|
||||
close: 'Close request log details',
|
||||
loading: 'Loading request log details…',
|
||||
loadFailed: 'Unable to load request log details.',
|
||||
summary: 'Final result',
|
||||
requestId: 'Request ID',
|
||||
copyRequestId: 'Copy request ID',
|
||||
@@ -504,6 +587,11 @@ export default {
|
||||
modelNotSpecified: 'Not specified',
|
||||
statusCode: 'Status code',
|
||||
duration: 'Duration',
|
||||
firstResponse: 'First response',
|
||||
outputRate: 'Output rate',
|
||||
attemptCount: 'Attempts',
|
||||
route: 'Request and final route',
|
||||
affinity: 'Affinity hit',
|
||||
errorCode: 'Error code',
|
||||
errorSummary: 'Redacted error summary',
|
||||
openInspector: 'Inspect with current state (not a historical replay)',
|
||||
@@ -562,12 +650,17 @@ export default {
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
time: 'Time',
|
||||
modelProtocol: 'Model / protocol',
|
||||
response: 'Response',
|
||||
cost: 'Cost',
|
||||
tokens: 'Tokens',
|
||||
completedAt: 'Completed',
|
||||
requestId: 'Request ID',
|
||||
route: 'Protocol and models',
|
||||
route: 'Route',
|
||||
accessKey: 'AccessKey',
|
||||
result: 'Final status',
|
||||
timing: 'Duration and attempts',
|
||||
timing: 'First / total',
|
||||
actions: 'Actions',
|
||||
},
|
||||
status: {
|
||||
@@ -576,6 +669,35 @@ export default {
|
||||
incomplete: 'Incomplete',
|
||||
canceled: 'Canceled',
|
||||
},
|
||||
response: {
|
||||
normal: 'Normal',
|
||||
streamError: 'Stream error',
|
||||
errorWithCode: 'Error {code}',
|
||||
},
|
||||
modelMapping: 'Client model {client} → upstream model {upstream}',
|
||||
modelMappingLabel: 'View model mapping',
|
||||
tokens: {
|
||||
input: 'Input',
|
||||
output: 'Output',
|
||||
cacheRead: 'Cache read',
|
||||
cacheWrite5m: 'Cache write 5m',
|
||||
cacheWrite1h: 'Cache write 1h',
|
||||
cacheWrite: 'Cache write',
|
||||
cacheDetails: 'View cache token details',
|
||||
partial: 'Token usage is partial',
|
||||
},
|
||||
receipt: {
|
||||
formula: 'Cost calculation',
|
||||
contextTier: 'Context ≥ {value}',
|
||||
lines: {
|
||||
input: 'Standard input',
|
||||
cache_read: 'Cache read',
|
||||
cache_write_5m: 'Cache write 5m',
|
||||
cache_write_1h: 'Cache write 1h',
|
||||
cache_write: 'Cache write',
|
||||
output: 'Output',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const
|
||||
@@ -421,20 +421,50 @@ export default {
|
||||
groupsFailed: 'Group フィルターの選択肢を読み込めません。ログ一覧には影響しません。',
|
||||
accessKeysFailed:
|
||||
'AccessKey フィルターの選択肢を読み込めません。ログ一覧には影響しません。',
|
||||
partialFailed: '一部のフィルター選択肢を読み込めませんが、ログ検索は使用できます。',
|
||||
},
|
||||
filters: {
|
||||
label: 'リクエストログのフィルター',
|
||||
timeRange: '時間範囲',
|
||||
from: '開始時刻',
|
||||
to: '終了時刻',
|
||||
quickRanges: 'クイック時間範囲',
|
||||
quick: {
|
||||
'15m': '15m',
|
||||
'1h': '1h',
|
||||
'24h': '24h',
|
||||
'3d': '3d',
|
||||
'7d': '7d',
|
||||
'15d': '15d',
|
||||
},
|
||||
any: 'すべて',
|
||||
group: 'Group',
|
||||
groupHelp: 'いずれかの試行で使用された Group に一致します。',
|
||||
anyGroup: 'すべての Group',
|
||||
model: 'クライアントモデル',
|
||||
clientModel: 'クライアントモデル',
|
||||
upstreamModel: 'アップストリームモデル',
|
||||
accessKey: 'AccessKey',
|
||||
anyAccessKey: 'すべての AccessKey',
|
||||
protocol: 'プロトコル',
|
||||
anyProtocol: 'すべてのプロトコル',
|
||||
stream: 'ストリーミング',
|
||||
status: '状態',
|
||||
anyStatus: 'すべての状態',
|
||||
requestId: 'リクエスト ID',
|
||||
finalStatusCode: '最終 HTTP ステータス',
|
||||
upstreamKey: 'アップストリーム Key ID',
|
||||
attemptStatusCode: '試行のステータス',
|
||||
failureCategory: '試行の失敗分類',
|
||||
errorCode: '試行のエラーコード',
|
||||
retryStateLabel: '再試行状態',
|
||||
retryMin: '最小再試行回数',
|
||||
retryMax: '最大再試行回数',
|
||||
usageStateLabel: '使用量状態',
|
||||
costStateLabel: 'コスト状態',
|
||||
completenessLabel: '価格計算の完全性',
|
||||
cachePresent: 'キャッシュ Token あり',
|
||||
more: 'その他のフィルター',
|
||||
apply: '適用',
|
||||
reset: 'リセット',
|
||||
dirty: 'フィルターの下書きはまだ適用されていません',
|
||||
@@ -444,11 +474,58 @@ export default {
|
||||
appliedTo: '終了 {value}',
|
||||
appliedGroup: 'Group {value}',
|
||||
appliedModel: 'クライアントモデル {value}',
|
||||
appliedClientModel: 'クライアントモデル {value}',
|
||||
appliedUpstreamModel: 'アップストリームモデル {value}',
|
||||
appliedAccessKey: 'AccessKey {value}',
|
||||
appliedStatus: '状態 {value}',
|
||||
appliedUsageState: '使用量 {value}',
|
||||
appliedCostState: 'コスト {value}',
|
||||
appliedCompleteness: '料金 {value}',
|
||||
appliedRequestId: 'リクエスト ID {value}',
|
||||
timezone: 'ローカルタイムゾーン',
|
||||
lastRefreshed: '最終成功更新',
|
||||
remove: 'フィルター {value} を削除',
|
||||
advancedTitle: 'その他のフィルター',
|
||||
advancedDescription: 'リクエスト、再試行、最終結果、数値範囲で絞り込みます。',
|
||||
closeAdvanced: 'その他のフィルターを閉じる',
|
||||
sections: {
|
||||
request: 'リクエスト',
|
||||
attempt: '再試行',
|
||||
result: '最終結果',
|
||||
ranges: '数値範囲',
|
||||
},
|
||||
retryState: { retried: '再試行あり', not_retried: '再試行なし' },
|
||||
usageState: {
|
||||
complete: '完全',
|
||||
partial: '部分的',
|
||||
missing: '欠落',
|
||||
not_applicable: '該当なし',
|
||||
},
|
||||
costState: {
|
||||
priced: '計算済み',
|
||||
unpriced: '未計算',
|
||||
not_applicable: '該当なし',
|
||||
},
|
||||
completeness: {
|
||||
complete: '完全',
|
||||
partial: '部分的',
|
||||
unavailable: '利用不可',
|
||||
not_applicable: '該当なし',
|
||||
},
|
||||
rangeFields: {
|
||||
retry_count_min: '最小再試行回数',
|
||||
retry_count_max: '最大再試行回数',
|
||||
first_response_min_ms: '最短初回応答(ms)',
|
||||
first_response_max_ms: '最長初回応答(ms)',
|
||||
duration_min_ms: '最短所要時間(ms)',
|
||||
duration_max_ms: '最長所要時間(ms)',
|
||||
input_tokens_min: '最小入力 Token',
|
||||
input_tokens_max: '最大入力 Token',
|
||||
output_tokens_min: '最小出力 Token',
|
||||
output_tokens_max: '最大出力 Token',
|
||||
cost_min_usd: '最小コスト(USD)',
|
||||
cost_max_usd: '最大コスト(USD)',
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
dateTime: '有効なローカル日時を入力してください。',
|
||||
@@ -457,6 +534,10 @@ export default {
|
||||
model: 'クライアントモデルの前後に空白や制御文字は使用できません。',
|
||||
status: '有効な状態を選択してください。',
|
||||
requestId: '正規の小文字 UUIDv4 を入力してください。',
|
||||
nonNegativeInteger: '0 以上の整数を入力してください。',
|
||||
numericRange: '最大値は最小値以上である必要があります。',
|
||||
text: '前後の空白や制御文字は使用できません。',
|
||||
usd: '小数点以下 9 桁までの 0 以上の USD 金額を入力してください。',
|
||||
},
|
||||
accessKey: {
|
||||
named: '{name} · #{id}',
|
||||
@@ -490,6 +571,8 @@ export default {
|
||||
title: 'リクエストログ詳細',
|
||||
description: '最終結果と秘匿化されたアップストリーム試行チェーンを確認します。',
|
||||
close: 'リクエストログ詳細を閉じる',
|
||||
loading: 'リクエストログ詳細を読み込み中…',
|
||||
loadFailed: 'リクエストログ詳細を読み込めません。',
|
||||
summary: '最終結果',
|
||||
requestId: 'リクエスト ID',
|
||||
copyRequestId: 'リクエスト ID をコピー',
|
||||
@@ -502,6 +585,11 @@ export default {
|
||||
modelNotSpecified: '未指定',
|
||||
statusCode: 'ステータスコード',
|
||||
duration: '所要時間',
|
||||
firstResponse: '初回応答',
|
||||
outputRate: '出力速度',
|
||||
attemptCount: '試行回数',
|
||||
route: 'リクエストと最終ルート',
|
||||
affinity: 'アフィニティ一致',
|
||||
errorCode: 'エラーコード',
|
||||
errorSummary: '秘匿化されたエラー概要',
|
||||
openInspector: '現在の状態で確認(履歴の再現ではありません)',
|
||||
@@ -558,12 +646,17 @@ export default {
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
time: '時刻',
|
||||
modelProtocol: 'モデル / プロトコル',
|
||||
response: '応答',
|
||||
cost: 'コスト',
|
||||
tokens: 'Tokens',
|
||||
completedAt: '完了時刻',
|
||||
requestId: 'リクエスト ID',
|
||||
route: 'プロトコルとモデル',
|
||||
route: 'ルート',
|
||||
accessKey: 'AccessKey',
|
||||
result: '最終状態',
|
||||
timing: '所要時間と試行',
|
||||
timing: '初回 / 合計',
|
||||
actions: '操作',
|
||||
},
|
||||
status: {
|
||||
@@ -572,6 +665,35 @@ export default {
|
||||
incomplete: '未完了',
|
||||
canceled: 'キャンセル済み',
|
||||
},
|
||||
response: {
|
||||
normal: '正常',
|
||||
streamError: 'ストリームエラー',
|
||||
errorWithCode: 'エラー {code}',
|
||||
},
|
||||
modelMapping: 'クライアントモデル {client} → アップストリームモデル {upstream}',
|
||||
modelMappingLabel: 'モデルマッピングを表示',
|
||||
tokens: {
|
||||
input: '入力',
|
||||
output: '出力',
|
||||
cacheRead: 'キャッシュ読み取り',
|
||||
cacheWrite5m: 'キャッシュ書き込み 5m',
|
||||
cacheWrite1h: 'キャッシュ書き込み 1h',
|
||||
cacheWrite: 'キャッシュ書き込み',
|
||||
cacheDetails: 'キャッシュ Token の詳細を表示',
|
||||
partial: 'Token 使用量が部分的です',
|
||||
},
|
||||
receipt: {
|
||||
formula: 'コスト計算',
|
||||
contextTier: 'コンテキスト ≥ {value}',
|
||||
lines: {
|
||||
input: '標準入力',
|
||||
cache_read: 'キャッシュ読み取り',
|
||||
cache_write_5m: 'キャッシュ書き込み 5m',
|
||||
cache_write_1h: 'キャッシュ書き込み 1h',
|
||||
cache_write: 'キャッシュ書き込み',
|
||||
output: '出力',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const
|
||||
@@ -406,20 +406,50 @@ export default {
|
||||
options: {
|
||||
groupsFailed: '无法加载 Group 筛选选项;日志列表不受影响。',
|
||||
accessKeysFailed: '无法加载 AccessKey 筛选选项;日志列表不受影响。',
|
||||
partialFailed: '部分筛选选项暂时无法加载,日志查询仍可正常使用。',
|
||||
},
|
||||
filters: {
|
||||
label: '请求日志筛选',
|
||||
timeRange: '时间范围',
|
||||
from: '开始时间',
|
||||
to: '结束时间',
|
||||
quickRanges: '快捷时间范围',
|
||||
quick: {
|
||||
'15m': '15m',
|
||||
'1h': '1h',
|
||||
'24h': '24h',
|
||||
'3d': '3d',
|
||||
'7d': '7d',
|
||||
'15d': '15d',
|
||||
},
|
||||
any: '全部',
|
||||
group: 'Group',
|
||||
groupHelp: '匹配任一尝试使用过的 Group。',
|
||||
anyGroup: '全部 Group',
|
||||
model: '客户端模型',
|
||||
clientModel: '客户端模型',
|
||||
upstreamModel: '上游模型',
|
||||
accessKey: 'AccessKey',
|
||||
anyAccessKey: '全部 AccessKey',
|
||||
protocol: '协议',
|
||||
anyProtocol: '全部协议',
|
||||
stream: '流式请求',
|
||||
status: '状态',
|
||||
anyStatus: '全部状态',
|
||||
requestId: '请求 ID',
|
||||
finalStatusCode: '最终 HTTP 状态码',
|
||||
upstreamKey: '上游 Key ID',
|
||||
attemptStatusCode: '任一尝试状态码',
|
||||
failureCategory: '任一尝试失败分类',
|
||||
errorCode: '任一尝试错误码',
|
||||
retryStateLabel: '重试状态',
|
||||
retryMin: '最少重试次数',
|
||||
retryMax: '最多重试次数',
|
||||
usageStateLabel: '用量状态',
|
||||
costStateLabel: '成本状态',
|
||||
completenessLabel: '计价完整性',
|
||||
cachePresent: '包含缓存 Token',
|
||||
more: '更多筛选',
|
||||
apply: '应用',
|
||||
reset: '重置',
|
||||
dirty: '筛选草稿尚未应用',
|
||||
@@ -429,11 +459,61 @@ export default {
|
||||
appliedTo: '结束 {value}',
|
||||
appliedGroup: 'Group {value}',
|
||||
appliedModel: '客户端模型 {value}',
|
||||
appliedClientModel: '客户端模型 {value}',
|
||||
appliedUpstreamModel: '上游模型 {value}',
|
||||
appliedAccessKey: 'AccessKey {value}',
|
||||
appliedStatus: '状态 {value}',
|
||||
appliedUsageState: '用量 {value}',
|
||||
appliedCostState: '成本 {value}',
|
||||
appliedCompleteness: '计价 {value}',
|
||||
appliedRequestId: '请求 ID {value}',
|
||||
timezone: '本地时区',
|
||||
lastRefreshed: '最近一次成功刷新',
|
||||
remove: '移除筛选条件 {value}',
|
||||
advancedTitle: '更多筛选',
|
||||
advancedDescription: '按请求、重试过程、最终结果和数值范围精确筛选。',
|
||||
closeAdvanced: '关闭更多筛选',
|
||||
sections: {
|
||||
request: '请求',
|
||||
attempt: '重试过程',
|
||||
result: '最终结果',
|
||||
ranges: '数值范围',
|
||||
},
|
||||
retryState: {
|
||||
retried: '发生过重试',
|
||||
not_retried: '未发生重试',
|
||||
},
|
||||
usageState: {
|
||||
complete: '完整',
|
||||
partial: '部分缺失',
|
||||
missing: '缺失',
|
||||
not_applicable: '不适用',
|
||||
},
|
||||
costState: {
|
||||
priced: '已计价',
|
||||
unpriced: '未计价',
|
||||
not_applicable: '不适用',
|
||||
},
|
||||
completeness: {
|
||||
complete: '完整',
|
||||
partial: '部分计价',
|
||||
unavailable: '不可用',
|
||||
not_applicable: '不适用',
|
||||
},
|
||||
rangeFields: {
|
||||
retry_count_min: '最少重试次数',
|
||||
retry_count_max: '最多重试次数',
|
||||
first_response_min_ms: '最短首响(ms)',
|
||||
first_response_max_ms: '最长首响(ms)',
|
||||
duration_min_ms: '最短耗时(ms)',
|
||||
duration_max_ms: '最长耗时(ms)',
|
||||
input_tokens_min: '最少输入 Token',
|
||||
input_tokens_max: '最多输入 Token',
|
||||
output_tokens_min: '最少输出 Token',
|
||||
output_tokens_max: '最多输出 Token',
|
||||
cost_min_usd: '最低成本(USD)',
|
||||
cost_max_usd: '最高成本(USD)',
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
dateTime: '请输入有效的本地日期时间。',
|
||||
@@ -442,6 +522,10 @@ export default {
|
||||
model: '客户端模型不能包含首尾空白或控制字符。',
|
||||
status: '请选择有效状态。',
|
||||
requestId: '请输入规范的小写 UUIDv4。',
|
||||
nonNegativeInteger: '请输入非负整数。',
|
||||
numericRange: '最大值不能小于最小值。',
|
||||
text: '内容不能包含首尾空白或控制字符。',
|
||||
usd: '请输入最多 9 位小数的非负 USD 金额。',
|
||||
},
|
||||
accessKey: {
|
||||
named: '{name} · #{id}',
|
||||
@@ -475,6 +559,8 @@ export default {
|
||||
title: '请求日志详情',
|
||||
description: '查看最终结果和脱敏的上游尝试链。',
|
||||
close: '关闭请求日志详情',
|
||||
loading: '正在加载请求日志详情…',
|
||||
loadFailed: '无法加载请求日志详情。',
|
||||
summary: '最终结果',
|
||||
requestId: '请求 ID',
|
||||
copyRequestId: '复制请求 ID',
|
||||
@@ -487,6 +573,11 @@ export default {
|
||||
modelNotSpecified: '未指定',
|
||||
statusCode: '状态码',
|
||||
duration: '耗时',
|
||||
firstResponse: '首响',
|
||||
outputRate: '输出速率',
|
||||
attemptCount: '尝试次数',
|
||||
route: '请求与最终路由',
|
||||
affinity: '亲和命中',
|
||||
errorCode: '错误码',
|
||||
errorSummary: '脱敏错误摘要',
|
||||
openInspector: '按当前状态检查(非历史重放)',
|
||||
@@ -539,12 +630,17 @@ export default {
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
time: '时间',
|
||||
modelProtocol: '模型/协议',
|
||||
response: '响应',
|
||||
cost: '成本',
|
||||
tokens: 'Tokens',
|
||||
completedAt: '完成时间',
|
||||
requestId: '请求 ID',
|
||||
route: '协议与模型',
|
||||
route: '路由',
|
||||
accessKey: 'AccessKey',
|
||||
result: '最终状态',
|
||||
timing: '耗时与尝试',
|
||||
timing: '首响/耗时',
|
||||
actions: '操作',
|
||||
},
|
||||
status: {
|
||||
@@ -553,6 +649,35 @@ export default {
|
||||
incomplete: '未完成',
|
||||
canceled: '已取消',
|
||||
},
|
||||
response: {
|
||||
normal: '正常',
|
||||
streamError: '流错误',
|
||||
errorWithCode: '错误 {code}',
|
||||
},
|
||||
modelMapping: '客户端模型 {client} → 上游模型 {upstream}',
|
||||
modelMappingLabel: '查看模型映射',
|
||||
tokens: {
|
||||
input: '输入',
|
||||
output: '输出',
|
||||
cacheRead: '缓存读取',
|
||||
cacheWrite5m: '缓存写入 5m',
|
||||
cacheWrite1h: '缓存写入 1h',
|
||||
cacheWrite: '缓存写入',
|
||||
cacheDetails: '查看缓存 Token 明细',
|
||||
partial: '当前 Token 用量不完整',
|
||||
},
|
||||
receipt: {
|
||||
formula: '成本计算',
|
||||
contextTier: '上下文 ≥ {value}',
|
||||
lines: {
|
||||
input: '标准输入',
|
||||
cache_read: '缓存读取',
|
||||
cache_write_5m: '缓存写入 5m',
|
||||
cache_write_1h: '缓存写入 1h',
|
||||
cache_write: '缓存写入',
|
||||
output: '输出',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const
|
||||
@@ -47,6 +47,11 @@ export function formatLocalTime(ms: number, locale: string): string {
|
||||
return formatZonedDateTime(date, currentTimeZone(), true).slice(11)
|
||||
}
|
||||
|
||||
export function formatLocalInstantWithSeconds(ms: number, timeZone = currentTimeZone()): string {
|
||||
const date = validDate(ms)
|
||||
return date ? formatZonedDateTime(date, timeZone, true) : '—'
|
||||
}
|
||||
|
||||
export function formatLocalTimeRange(startMs: number, endMs: number, locale: string): string {
|
||||
void locale
|
||||
const start = validDate(startMs)
|
||||
|
||||
Reference in new issue
Block a user