diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 886a5535..22d3f1f0 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -366,6 +366,7 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog { AccountID: l.AccountID, RequestID: l.RequestID, Model: l.Model, + ReasoningEffort: l.ReasoningEffort, GroupID: l.GroupID, SubscriptionID: l.SubscriptionID, InputTokens: l.InputTokens, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 4cfaef5f..64979189 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -222,6 +222,9 @@ type UsageLog struct { AccountID int64 `json:"account_id"` RequestID string `json:"request_id"` Model string `json:"model"` + // ReasoningEffort is the request's reasoning effort level (OpenAI Responses API). + // nil means not provided / not applicable. + ReasoningEffort *string `json:"reasoning_effort,omitempty"` GroupID *int64 `json:"group_id"` SubscriptionID *int64 `json:"subscription_id"` diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index 963db7ba..dc8f1460 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -22,7 +22,7 @@ import ( "github.com/lib/pq" ) -const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, stream, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, created_at" +const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, stream, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, reasoning_effort, created_at" type usageLogRepository struct { client *dbent.Client @@ -111,21 +111,22 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog) duration_ms, first_token_ms, user_agent, - ip_address, - image_count, - image_size, - created_at - ) VALUES ( - $1, $2, $3, $4, $5, - $6, $7, - $8, $9, $10, $11, - $12, $13, - $14, $15, $16, $17, $18, $19, - $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30 - ) - ON CONFLICT (request_id, api_key_id) DO NOTHING - RETURNING id, created_at - ` + ip_address, + image_count, + image_size, + reasoning_effort, + created_at + ) VALUES ( + $1, $2, $3, $4, $5, + $6, $7, + $8, $9, $10, $11, + $12, $13, + $14, $15, $16, $17, $18, $19, + $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31 + ) + ON CONFLICT (request_id, api_key_id) DO NOTHING + RETURNING id, created_at + ` groupID := nullInt64(log.GroupID) subscriptionID := nullInt64(log.SubscriptionID) @@ -134,6 +135,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog) userAgent := nullString(log.UserAgent) ipAddress := nullString(log.IPAddress) imageSize := nullString(log.ImageSize) + reasoningEffort := nullString(log.ReasoningEffort) var requestIDArg any if requestID != "" { @@ -170,6 +172,7 @@ func (r *usageLogRepository) Create(ctx context.Context, log *service.UsageLog) ipAddress, log.ImageCount, imageSize, + reasoningEffort, createdAt, } if err := scanSingleRow(ctx, sqlq, query, args, &log.ID, &log.CreatedAt); err != nil { @@ -2090,6 +2093,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e ipAddress sql.NullString imageCount int imageSize sql.NullString + reasoningEffort sql.NullString createdAt time.Time ) @@ -2124,6 +2128,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e &ipAddress, &imageCount, &imageSize, + &reasoningEffort, &createdAt, ); err != nil { return nil, err @@ -2183,6 +2188,9 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e if imageSize.Valid { log.ImageSize = &imageSize.String } + if reasoningEffort.Valid { + log.ReasoningEffort = &reasoningEffort.String + } return log, nil } diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index b1866dee..04ea2930 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -156,12 +156,15 @@ type OpenAIUsage struct { // OpenAIForwardResult represents the result of forwarding type OpenAIForwardResult struct { - RequestID string - Usage OpenAIUsage - Model string - Stream bool - Duration time.Duration - FirstTokenMs *int + RequestID string + Usage OpenAIUsage + Model string + // ReasoningEffort is extracted from request body (reasoning.effort) or derived from model suffix. + // Stored for usage records display; nil means not provided / not applicable. + ReasoningEffort *string + Stream bool + Duration time.Duration + FirstTokenMs *int } // OpenAIGatewayService handles OpenAI API gateway operations @@ -958,13 +961,16 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } } + reasoningEffort := extractOpenAIReasoningEffort(reqBody, originalModel) + return &OpenAIForwardResult{ - RequestID: resp.Header.Get("x-request-id"), - Usage: *usage, - Model: originalModel, - Stream: reqStream, - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, + RequestID: resp.Header.Get("x-request-id"), + Usage: *usage, + Model: originalModel, + ReasoningEffort: reasoningEffort, + Stream: reqStream, + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, }, nil } @@ -1728,6 +1734,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec AccountID: account.ID, RequestID: result.RequestID, Model: result.Model, + ReasoningEffort: result.ReasoningEffort, InputTokens: actualInputTokens, OutputTokens: result.Usage.OutputTokens, CacheCreationTokens: result.Usage.CacheCreationInputTokens, @@ -1922,3 +1929,86 @@ func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, acc _ = s.accountRepo.UpdateExtra(updateCtx, accountID, updates) }() } + +func getOpenAIReasoningEffortFromReqBody(reqBody map[string]any) (value string, present bool) { + if reqBody == nil { + return "", false + } + + // Primary: reasoning.effort + if reasoning, ok := reqBody["reasoning"].(map[string]any); ok { + if effort, ok := reasoning["effort"].(string); ok { + return normalizeOpenAIReasoningEffort(effort), true + } + } + + // Fallback: some clients may use a flat field. + if effort, ok := reqBody["reasoning_effort"].(string); ok { + return normalizeOpenAIReasoningEffort(effort), true + } + + return "", false +} + +func deriveOpenAIReasoningEffortFromModel(model string) string { + if strings.TrimSpace(model) == "" { + return "" + } + + modelID := strings.TrimSpace(model) + if strings.Contains(modelID, "/") { + parts := strings.Split(modelID, "/") + modelID = parts[len(parts)-1] + } + + parts := strings.FieldsFunc(strings.ToLower(modelID), func(r rune) bool { + switch r { + case '-', '_', ' ': + return true + default: + return false + } + }) + if len(parts) == 0 { + return "" + } + + return normalizeOpenAIReasoningEffort(parts[len(parts)-1]) +} + +func extractOpenAIReasoningEffort(reqBody map[string]any, requestedModel string) *string { + if value, present := getOpenAIReasoningEffortFromReqBody(reqBody); present { + if value == "" { + return nil + } + return &value + } + + value := deriveOpenAIReasoningEffortFromModel(requestedModel) + if value == "" { + return nil + } + return &value +} + +func normalizeOpenAIReasoningEffort(raw string) string { + value := strings.ToLower(strings.TrimSpace(raw)) + if value == "" { + return "" + } + + // Normalize separators for "x-high"/"x_high" variants. + value = strings.NewReplacer("-", "", "_", "", " ", "").Replace(value) + + switch value { + case "none", "minimal": + return "" + case "low", "medium", "high": + return value + case "xhigh", "extrahigh": + return "xhigh" + default: + // Only store known effort levels for now to keep UI consistent. + return "" + } +} diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go index 3b0e934f..a9721d7f 100644 --- a/backend/internal/service/usage_log.go +++ b/backend/internal/service/usage_log.go @@ -14,6 +14,9 @@ type UsageLog struct { AccountID int64 RequestID string Model string + // ReasoningEffort is the request's reasoning effort level (OpenAI Responses API), + // e.g. "low" / "medium" / "high" / "xhigh". Nil means not provided / not applicable. + ReasoningEffort *string GroupID *int64 SubscriptionID *int64 diff --git a/backend/migrations/046_add_usage_log_reasoning_effort.sql b/backend/migrations/046_add_usage_log_reasoning_effort.sql new file mode 100644 index 00000000..f6572d1d --- /dev/null +++ b/backend/migrations/046_add_usage_log_reasoning_effort.sql @@ -0,0 +1,4 @@ +-- Add reasoning_effort field to usage_logs for OpenAI/Codex requests. +-- This stores the request's reasoning effort level (e.g. low/medium/high/xhigh). +ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS reasoning_effort VARCHAR(20); + diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index f6d1b1be..5edbd3b6 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -21,6 +21,12 @@ {{ value }} + + -