From 11ada80d58addc53fe70d567f4c06eea10463c2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=BD=B3=E6=9E=97?= Date: Tue, 25 Aug 2026 15:10:52 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(usage):=20=E4=BD=BF=E7=94=A8=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=B1=95=E7=A4=BA=E6=98=A0=E5=B0=84=E5=89=8D=E7=9A=84?= =?UTF-8?q?=E6=8E=A8=E7=90=86=E5=BC=BA=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用量行新增 requested_reasoning_effort,记录分组策略与模型族改写前的客户端请求值;推理强度列与模型列一致,主行显示请求值,有映射时用 ↳ 展示实际转发值。 --- .../internal/handler/composite_platform.go | 30 +++++++ .../handler/composite_platform_test.go | 3 + backend/internal/handler/dto/mappers.go | 1 + .../handler/dto/mappers_usage_test.go | 25 ++++++ backend/internal/handler/dto/types.go | 4 +- backend/internal/handler/gateway_handler.go | 3 + .../gateway_handler_chat_completions.go | 2 + .../handler/gateway_handler_responses.go | 2 + .../handler/openai_chat_completions.go | 1 + .../handler/openai_gateway_handler.go | 2 + .../repository/usage_log_repo_insert.go | 21 +++-- .../repository/usage_log_repo_query.go | 7 +- .../usage_log_repo_request_type_test.go | 12 ++- .../usage_log_session_id_unit_test.go | 34 +++++++- backend/internal/service/gateway_service.go | 2 + .../internal/service/gateway_usage_billing.go | 85 ++++++++++--------- ...penai_gateway_messages_anthropic_native.go | 3 +- .../openai_gateway_record_usage_test.go | 35 ++++++++ .../service/openai_gateway_request_body.go | 84 ++++++++++++++++++ .../service/openai_gateway_service.go | 10 ++- .../internal/service/openai_gateway_usage.go | 51 +++++------ .../service/openai_reasoning_effort_policy.go | 31 +++++++ .../openai_reasoning_effort_policy_test.go | 28 ++++++ .../service/openai_ws_forwarder_ingress.go | 10 ++- .../service/openai_ws_forwarder_v2.go | 1 + .../internal/service/openai_ws_http_bridge.go | 1 + .../openai_ws_v2_passthrough_adapter.go | 23 ++++- ...i_ws_v2_passthrough_adapter_effort_test.go | 4 + backend/internal/service/usage_log.go | 6 +- backend/internal/service/usage_log_helpers.go | 12 +++ ...d_usage_log_requested_reasoning_effort.sql | 7 ++ .../src/components/admin/usage/UsageTable.vue | 20 ++++- .../admin/usage/__tests__/UsageTable.spec.ts | 57 +++++++++++++ frontend/src/i18n/locales/en/dashboard.ts | 1 + frontend/src/i18n/locales/zh/dashboard.ts | 1 + frontend/src/types/index.ts | 1 + .../__tests__/formatReasoningEffort.spec.ts | 33 +++++++ frontend/src/utils/format.ts | 31 ++++++- frontend/src/views/admin/UsageView.vue | 4 +- frontend/src/views/user/UsageView.vue | 4 +- 40 files changed, 595 insertions(+), 97 deletions(-) create mode 100644 backend/migrations/231_add_usage_log_requested_reasoning_effort.sql create mode 100644 frontend/src/utils/__tests__/formatReasoningEffort.spec.ts diff --git a/backend/internal/handler/composite_platform.go b/backend/internal/handler/composite_platform.go index 0d57c709c6..fad5e12b56 100644 --- a/backend/internal/handler/composite_platform.go +++ b/backend/internal/handler/composite_platform.go @@ -72,7 +72,36 @@ func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKe return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, true } +func bindRequestedReasoningEffort(c *gin.Context, body []byte, model string) { + if c == nil || c.Request == nil { + return + } + effort := service.CanonicalRequestedReasoningEffort(body, model) + if effort == nil { + return + } + c.Request = c.Request.WithContext(service.WithRequestedReasoningEffort(c.Request.Context(), *effort)) +} + +func stampOpenAIRequestedReasoningEffort(result *service.OpenAIForwardResult, c *gin.Context) { + if result == nil || result.RequestedReasoningEffort != nil { + return + } + if c == nil || c.Request == nil { + return + } + result.RequestedReasoningEffort = service.RequestedReasoningEffortFromContext(c.Request.Context()) +} + +func stampForwardRequestedReasoningEffort(result *service.ForwardResult, requested *string) { + if result == nil || result.RequestedReasoningEffort != nil { + return + } + result.RequestedReasoningEffort = requested +} + func applyOpenAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool) { + bindRequestedReasoningEffort(c, body, strings.TrimSpace(gjson.GetBytes(body, "model").String())) maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey) if !ok { return body, false @@ -84,6 +113,7 @@ func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *s if c == nil || c.Request == nil { return } + bindRequestedReasoningEffort(c, body, strings.TrimSpace(gjson.GetBytes(body, "model").String())) // The Messages bridge synthesizes a default OpenAI effort when // output_config.effort is omitted. Bind the group policy only for an // explicit client value so the ceiling does not alter that default. diff --git a/backend/internal/handler/composite_platform_test.go b/backend/internal/handler/composite_platform_test.go index 6e068a4614..4d2c0f583f 100644 --- a/backend/internal/handler/composite_platform_test.go +++ b/backend/internal/handler/composite_platform_test.go @@ -120,6 +120,9 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) { got, changed := applyOpenAIReasoningEffortPolicyForRequest(openAICtx, apiKey, body) require.True(t, changed) require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(got)) + requested := service.RequestedReasoningEffortFromContext(openAICtx.Request.Context()) + require.NotNil(t, requested) + require.Equal(t, "max", *requested) bindOpenAIReasoningEffortPolicyForMessagesRequest(openAICtx, apiKey, []byte(`{"output_config":{"effort":"max"}}`)) bound, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(openAICtx.Request.Context(), body) diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index d5a9403a23..81aa8f8865 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -644,6 +644,7 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog { Model: requestedModel, ServiceTier: l.ServiceTier, ReasoningEffort: l.ReasoningEffort, + RequestedReasoningEffort: l.RequestedReasoningEffort, InboundEndpoint: l.InboundEndpoint, GroupID: l.GroupID, SubscriptionID: l.SubscriptionID, diff --git a/backend/internal/handler/dto/mappers_usage_test.go b/backend/internal/handler/dto/mappers_usage_test.go index d396bbfa2f..43299000e9 100644 --- a/backend/internal/handler/dto/mappers_usage_test.go +++ b/backend/internal/handler/dto/mappers_usage_test.go @@ -179,6 +179,31 @@ func TestUsageLogFromService_KeepsUserBillingAndIPWithoutAdminCostFields(t *test require.NotContains(t, string(userJSON), "account_cost") } +func TestUsageLogFromService_IncludesRequestedReasoningEffort(t *testing.T) { + t.Parallel() + + requested := "max" + forwarded := "xhigh" + log := &service.UsageLog{ + RequestID: "req_effort", + Model: "gpt-5.4", + ReasoningEffort: &forwarded, + RequestedReasoningEffort: &requested, + } + + userDTO := UsageLogFromService(log) + adminDTO := UsageLogFromServiceAdmin(log) + + require.NotNil(t, userDTO.ReasoningEffort) + require.Equal(t, forwarded, *userDTO.ReasoningEffort) + require.NotNil(t, userDTO.RequestedReasoningEffort) + require.Equal(t, requested, *userDTO.RequestedReasoningEffort) + require.NotNil(t, adminDTO.ReasoningEffort) + require.Equal(t, forwarded, *adminDTO.ReasoningEffort) + require.NotNil(t, adminDTO.RequestedReasoningEffort) + require.Equal(t, requested, *adminDTO.RequestedReasoningEffort) +} + func TestUsageLogFromService_FallsBackToLegacyModelWhenRequestedModelMissing(t *testing.T) { t.Parallel() diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 2cd623f7d6..72e89a0985 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -486,9 +486,11 @@ type UsageLog struct { Model string `json:"model"` // ServiceTier records the OpenAI service tier used for billing, e.g. "priority" / "flex". ServiceTier *string `json:"service_tier,omitempty"` - // ReasoningEffort is the request's reasoning effort level. + // ReasoningEffort is the effective effort after group policy / model-family remapping. // OpenAI: "low"/"medium"/"high"/"xhigh"; Claude: "low"/"medium"/"high"/"max". ReasoningEffort *string `json:"reasoning_effort,omitempty"` + // RequestedReasoningEffort is the client-requested effort before mapping. + RequestedReasoningEffort *string `json:"requested_reasoning_effort,omitempty"` // InboundEndpoint is the client-facing API endpoint path, e.g. /v1/chat/completions. InboundEndpoint *string `json:"inbound_endpoint,omitempty"` // UpstreamEndpoint is the normalized upstream endpoint path, e.g. /v1/responses. diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 1128329be8..2e4987c54f 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -169,6 +169,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { body = parsedReq.Body.Bytes() reqModel := parsedReq.Model reqStream := parsedReq.Stream + bindRequestedReasoningEffort(c, body, reqModel) ensureCompositeTargetPlatform(c, apiKey, reqModel) reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream)) @@ -541,6 +542,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { inboundEndpoint := GetInboundEndpoint(c) upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) + stampForwardRequestedReasoningEffort(result, service.NormalizeClaudeOutputEffort(parsedReq.OutputEffort)) if result.ReasoningEffort == nil { result.ReasoningEffort = service.NormalizeClaudeOutputEffort(parsedReq.OutputEffort) } @@ -882,6 +884,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { inboundEndpoint := GetInboundEndpoint(c) upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) + stampForwardRequestedReasoningEffort(result, service.NormalizeClaudeOutputEffort(attemptParsedReq.OutputEffort)) if result.ReasoningEffort == nil { result.ReasoningEffort = service.NormalizeClaudeOutputEffort(attemptParsedReq.OutputEffort) } diff --git a/backend/internal/handler/gateway_handler_chat_completions.go b/backend/internal/handler/gateway_handler_chat_completions.go index 951a93a6b3..7a98504596 100644 --- a/backend/internal/handler/gateway_handler_chat_completions.go +++ b/backend/internal/handler/gateway_handler_chat_completions.go @@ -76,6 +76,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) { return } reqModel := modelResult.String() + bindRequestedReasoningEffort(c, body, reqModel) ensureCompositeTargetPlatform(c, apiKey, reqModel) if !compositeTargetPlatformResolved(c, apiKey, reqModel) { h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups") @@ -335,6 +336,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) { quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) sessionID := service.ExtractClientSessionID(c) + stampForwardRequestedReasoningEffort(result, service.RequestedReasoningEffortFromContext(c.Request.Context())) h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) { if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ Result: result, diff --git a/backend/internal/handler/gateway_handler_responses.go b/backend/internal/handler/gateway_handler_responses.go index ac5a3d23fc..04a61cb16e 100644 --- a/backend/internal/handler/gateway_handler_responses.go +++ b/backend/internal/handler/gateway_handler_responses.go @@ -76,6 +76,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) { return } reqModel := modelResult.String() + bindRequestedReasoningEffort(c, body, reqModel) ensureCompositeTargetPlatform(c, apiKey, reqModel) if !compositeTargetPlatformResolved(c, apiKey, reqModel) { h.responsesErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups") @@ -323,6 +324,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) { quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey) sessionID := service.ExtractClientSessionID(c) + stampForwardRequestedReasoningEffort(result, service.RequestedReasoningEffortFromContext(c.Request.Context())) h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) { if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{ Result: result, diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index aa2b610d70..be75ab8dea 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -267,6 +267,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { if res == nil { return } + stampOpenAIRequestedReasoningEffort(res, c) userAgent := c.GetHeader("User-Agent") clientIP := ip.GetClientIP(c) inboundEndpoint := GetInboundEndpoint(c) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 32cea2f073..7ec0ca5bb3 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -702,6 +702,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { if res == nil { return } + stampOpenAIRequestedReasoningEffort(res, c) userAgent := c.GetHeader("User-Agent") clientIP := ip.GetClientIP(c) requestPayloadHash := service.HashUsageRequestPayload(body) @@ -1266,6 +1267,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { if res == nil { return } + stampOpenAIRequestedReasoningEffort(res, c) userAgent := c.GetHeader("User-Agent") clientIP := ip.GetClientIP(c) requestPayloadHash := service.HashUsageRequestPayload(body) diff --git a/backend/internal/repository/usage_log_repo_insert.go b/backend/internal/repository/usage_log_repo_insert.go index 6d932c0d43..67bf2937ae 100644 --- a/backend/internal/repository/usage_log_repo_insert.go +++ b/backend/internal/repository/usage_log_repo_insert.go @@ -72,6 +72,7 @@ var usageLogInsertArgTypes = [...]string{ "integer", // video_duration_seconds "text", // service_tier "text", // reasoning_effort + "text", // requested_reasoning_effort "text", // inbound_endpoint "text", // upstream_endpoint "boolean", // cache_ttl_overridden @@ -270,6 +271,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, video_duration_seconds, service_tier, reasoning_effort, + requested_reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, @@ -287,7 +289,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, - $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59 + $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60 ) ON CONFLICT (request_id, api_key_id) DO NOTHING RETURNING id, created_at @@ -727,6 +729,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage video_duration_seconds, service_tier, reasoning_effort, + requested_reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, @@ -740,9 +743,9 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage created_at ) AS (VALUES `) - // Each batch row prepends the synthetic input_index before the 59 + // Each batch row prepends the synthetic input_index before the 60 // usage-log column values. - args := make([]any, 0, len(keys)*60) + args := make([]any, 0, len(keys)*61) argPos := 1 for idx, key := range keys { if idx > 0 { @@ -819,6 +822,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage video_duration_seconds, service_tier, reasoning_effort, + requested_reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, @@ -880,6 +884,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage video_duration_seconds, service_tier, reasoning_effort, + requested_reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, @@ -981,6 +986,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( video_duration_seconds, service_tier, reasoning_effort, + requested_reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, @@ -994,7 +1000,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( created_at ) AS (VALUES `) - args := make([]any, 0, len(preparedList)*59) + args := make([]any, 0, len(preparedList)*60) argPos := 1 for idx, prepared := range preparedList { if idx > 0 { @@ -1068,6 +1074,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( video_duration_seconds, service_tier, reasoning_effort, + requested_reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, @@ -1129,6 +1136,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( video_duration_seconds, service_tier, reasoning_effort, + requested_reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, @@ -1198,6 +1206,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared video_duration_seconds, service_tier, reasoning_effort, + requested_reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, @@ -1215,7 +1224,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, - $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59 + $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60 ) ON CONFLICT (request_id, api_key_id) DO NOTHING `, prepared.args...) @@ -1250,6 +1259,7 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { videoDurationSeconds := nullInt(log.VideoDurationSeconds) serviceTier := nullString(log.ServiceTier) reasoningEffort := nullString(log.ReasoningEffort) + requestedReasoningEffort := nullString(log.RequestedReasoningEffort) inboundEndpoint := nullString(log.InboundEndpoint) upstreamEndpoint := nullString(log.UpstreamEndpoint) channelID := nullInt64(log.ChannelID) @@ -1324,6 +1334,7 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { videoDurationSeconds, serviceTier, reasoningEffort, + requestedReasoningEffort, inboundEndpoint, upstreamEndpoint, log.CacheTTLOverridden, diff --git a/backend/internal/repository/usage_log_repo_query.go b/backend/internal/repository/usage_log_repo_query.go index f2fa6e30b0..6ca471999e 100644 --- a/backend/internal/repository/usage_log_repo_query.go +++ b/backend/internal/repository/usage_log_repo_query.go @@ -19,7 +19,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/service" ) -const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, upstream_response_model, upstream_model_mismatch, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, session_id, created_at" +const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, upstream_response_model, upstream_model_mismatch, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, requested_reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, session_id, created_at" func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *service.UsageLog, err error) { query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1" @@ -488,6 +488,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e videoDurationSeconds sql.NullInt64 serviceTier sql.NullString reasoningEffort sql.NullString + requestedReasoningEffort sql.NullString inboundEndpoint sql.NullString upstreamEndpoint sql.NullString cacheTTLOverridden bool @@ -551,6 +552,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e &videoDurationSeconds, &serviceTier, &reasoningEffort, + &requestedReasoningEffort, &inboundEndpoint, &upstreamEndpoint, &cacheTTLOverridden, @@ -656,6 +658,9 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e if reasoningEffort.Valid { log.ReasoningEffort = &reasoningEffort.String } + if requestedReasoningEffort.Valid { + log.RequestedReasoningEffort = &requestedReasoningEffort.String + } if inboundEndpoint.Valid { log.InboundEndpoint = &inboundEndpoint.String } diff --git a/backend/internal/repository/usage_log_repo_request_type_test.go b/backend/internal/repository/usage_log_repo_request_type_test.go index a67c93c345..997f20d0ab 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -89,6 +89,7 @@ func TestUsageLogRepositoryCreateSyncRequestTypeAndLegacyFields(t *testing.T) { sqlmock.AnyArg(), // video_duration_seconds sqlmock.AnyArg(), // service_tier sqlmock.AnyArg(), // reasoning_effort + sqlmock.AnyArg(), // requested_reasoning_effort sqlmock.AnyArg(), // inbound_endpoint sqlmock.AnyArg(), // upstream_endpoint log.CacheTTLOverridden, @@ -180,9 +181,10 @@ func TestUsageLogRepositoryCreate_PersistsServiceTier(t *testing.T) { sqlmock.AnyArg(), // video_resolution sqlmock.AnyArg(), // video_duration_seconds serviceTier, - sqlmock.AnyArg(), - sqlmock.AnyArg(), - sqlmock.AnyArg(), + sqlmock.AnyArg(), // reasoning_effort + sqlmock.AnyArg(), // requested_reasoning_effort + sqlmock.AnyArg(), // inbound_endpoint + sqlmock.AnyArg(), // upstream_endpoint log.CacheTTLOverridden, log.LongContextBillingApplied, sqlmock.AnyArg(), // channel_id @@ -831,6 +833,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, sql.NullString{}, sql.NullString{}, + sql.NullString{}, false, false, sql.NullInt64{}, @@ -908,6 +911,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, sql.NullString{}, sql.NullString{}, + sql.NullString{}, false, false, sql.NullInt64{}, // channel_id @@ -968,6 +972,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, sql.NullString{}, sql.NullString{}, + sql.NullString{}, false, false, sql.NullInt64{}, // channel_id @@ -1028,6 +1033,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, sql.NullString{}, sql.NullString{}, + sql.NullString{}, false, false, sql.NullInt64{}, // channel_id diff --git a/backend/internal/repository/usage_log_session_id_unit_test.go b/backend/internal/repository/usage_log_session_id_unit_test.go index 64c1c1bab2..e4ff0ebe53 100644 --- a/backend/internal/repository/usage_log_session_id_unit_test.go +++ b/backend/internal/repository/usage_log_session_id_unit_test.go @@ -32,7 +32,7 @@ func newSessionIDUsageLog(sessionID *string) *service.UsageLog { // arg slice / arg-type table so the five INSERT column lists stay in sync. session_id // is the penultimate arg (created_at is always last). func TestPrepareUsageLogInsert_SessionIDArgWiring(t *testing.T) { - require.Len(t, usageLogInsertArgTypes, 59, "arg-type table must include session_id") + require.Len(t, usageLogInsertArgTypes, 60, "arg-type table must include session_id") sessionID := "sess-persisted-123" prepared := prepareUsageLogInsert(newSessionIDUsageLog(&sessionID)) @@ -66,9 +66,40 @@ func TestPrepareUsageLogInsert_SessionIDNullWhenAbsent(t *testing.T) { require.False(t, nsEmpty.Valid, "empty session id must also be NULL") } +func TestPrepareUsageLogInsert_RequestedReasoningEffortArgWiring(t *testing.T) { + requested := "max" + forwarded := "xhigh" + prepared := prepareUsageLogInsert(&service.UsageLog{ + UserID: 1, + APIKeyID: 2, + AccountID: 3, + RequestID: "req-requested-effort", + Model: "gpt-5.4", + ReasoningEffort: &forwarded, + RequestedReasoningEffort: &requested, + CreatedAt: time.Now().UTC(), + }) + + require.Len(t, prepared.args, len(usageLogInsertArgTypes)) + require.Equal(t, "text", usageLogInsertArgTypes[48], "requested_reasoning_effort must follow reasoning_effort") + require.Equal(t, "text", usageLogInsertArgTypes[47], "reasoning_effort arg type must stay text") + + forwardedArg, ok := prepared.args[47].(sql.NullString) + require.True(t, ok) + require.True(t, forwardedArg.Valid) + require.Equal(t, forwarded, forwardedArg.String) + + requestedArg, ok := prepared.args[48].(sql.NullString) + require.True(t, ok) + require.True(t, requestedArg.Valid) + require.Equal(t, requested, requestedArg.String) +} + // TestUsageLogInsertQueries_IncludeSessionID guards that every generated INSERT path // and the SELECT column list reference session_id. func TestUsageLogInsertQueries_IncludeSessionID(t *testing.T) { + require.Contains(t, usageLogSelectColumns, "requested_reasoning_effort", + "SELECT column list must include requested_reasoning_effort") require.Contains(t, usageLogSelectColumns, "session_id", "SELECT column list must include session_id") @@ -80,6 +111,7 @@ func TestUsageLogInsertQueries_IncludeSessionID(t *testing.T) { batchQuery, batchArgs := buildUsageLogBatchInsertQuery([]string{key}, map[string]usageLogInsertPrepared{key: prepared}) require.Contains(t, batchQuery, "session_id") + require.Contains(t, batchQuery, "requested_reasoning_effort") // Two column references (INSERT column list + SELECT ... FROM input) plus the CTE def. require.GreaterOrEqual(t, strings.Count(batchQuery, "session_id"), 3) require.Len(t, batchArgs, len(prepared.args)+1, diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 37bab2f97d..d934eaa0dd 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -622,6 +622,8 @@ type ForwardResult struct { FirstTokenMs *int // 首字时间(流式请求) ClientDisconnect bool // 客户端是否在流式传输过程中断开 ReasoningEffort *string + // RequestedReasoningEffort is the client-requested effort before mapping. + RequestedReasoningEffort *string // ServiceTier records the tier requested by the client. OpenAI uses // service_tier; Anthropic speed=fast is normalized to "fast". Usage recording // lowers it to UpstreamResponseServiceTier when the upstream reports a diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 1832dd6d32..4d47720f05 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -1211,48 +1211,49 @@ func (s *GatewayService) buildRecordUsageLog( ) } usageLog := &UsageLog{ - UserID: user.ID, - APIKeyID: apiKey.ID, - AccountID: account.ID, - RequestID: requestID, - Model: result.Model, - RequestedModel: requestedModel, - UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel), - UpstreamResponseModel: optionalTrimmedStringPtr(result.UpstreamResponseModel), - UpstreamModelMismatch: upstreamModelMismatch(sentModel, result.UpstreamResponseModel), - ServiceTier: result.ServiceTier, - ReasoningEffort: result.ReasoningEffort, - InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), - UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint), - InputTokens: result.Usage.InputTokens, - OutputTokens: result.Usage.OutputTokens, - CacheCreationTokens: result.Usage.CacheCreationInputTokens, - CacheReadTokens: result.Usage.CacheReadInputTokens, - CacheCreation5mTokens: result.Usage.CacheCreation5mTokens, - CacheCreation1hTokens: result.Usage.CacheCreation1hTokens, - ImageOutputTokens: result.Usage.ImageOutputTokens, - RateMultiplier: multiplier, - AccountRateMultiplier: &accountRateMultiplier, - BillingType: billingType, - BillingMode: resolveBillingMode(result, cost), - Stream: result.Stream, - DurationMs: &durationMs, - FirstTokenMs: result.FirstTokenMs, - ImageCount: result.ImageCount, - ImageSize: optionalTrimmedStringPtr(result.ImageSize), - ImageInputSize: optionalTrimmedStringPtr(result.ImageInputSize), - ImageOutputSize: optionalTrimmedStringPtr(result.ImageOutputSize), - ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource), - ImageSizeBreakdown: result.ImageSizeBreakdown, - CacheTTLOverridden: cacheTTLOverridden, - ChannelID: optionalInt64Ptr(input.ChannelID), - ModelMappingChain: optionalTrimmedStringPtr(input.ModelMappingChain), - UserAgent: optionalTrimmedStringPtr(input.UserAgent), - IPAddress: optionalTrimmedStringPtr(input.IPAddress), - SessionID: optionalTrimmedStringPtr(input.SessionID), - GroupID: apiKey.GroupID, - SubscriptionID: optionalSubscriptionID(subscription), - CreatedAt: time.Now(), + UserID: user.ID, + APIKeyID: apiKey.ID, + AccountID: account.ID, + RequestID: requestID, + Model: result.Model, + RequestedModel: requestedModel, + UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel), + UpstreamResponseModel: optionalTrimmedStringPtr(result.UpstreamResponseModel), + UpstreamModelMismatch: upstreamModelMismatch(sentModel, result.UpstreamResponseModel), + ServiceTier: result.ServiceTier, + ReasoningEffort: result.ReasoningEffort, + RequestedReasoningEffort: coalesceRequestedReasoningEffort(result.RequestedReasoningEffort, result.ReasoningEffort), + InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), + UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint), + InputTokens: result.Usage.InputTokens, + OutputTokens: result.Usage.OutputTokens, + CacheCreationTokens: result.Usage.CacheCreationInputTokens, + CacheReadTokens: result.Usage.CacheReadInputTokens, + CacheCreation5mTokens: result.Usage.CacheCreation5mTokens, + CacheCreation1hTokens: result.Usage.CacheCreation1hTokens, + ImageOutputTokens: result.Usage.ImageOutputTokens, + RateMultiplier: multiplier, + AccountRateMultiplier: &accountRateMultiplier, + BillingType: billingType, + BillingMode: resolveBillingMode(result, cost), + Stream: result.Stream, + DurationMs: &durationMs, + FirstTokenMs: result.FirstTokenMs, + ImageCount: result.ImageCount, + ImageSize: optionalTrimmedStringPtr(result.ImageSize), + ImageInputSize: optionalTrimmedStringPtr(result.ImageInputSize), + ImageOutputSize: optionalTrimmedStringPtr(result.ImageOutputSize), + ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource), + ImageSizeBreakdown: result.ImageSizeBreakdown, + CacheTTLOverridden: cacheTTLOverridden, + ChannelID: optionalInt64Ptr(input.ChannelID), + ModelMappingChain: optionalTrimmedStringPtr(input.ModelMappingChain), + UserAgent: optionalTrimmedStringPtr(input.UserAgent), + IPAddress: optionalTrimmedStringPtr(input.IPAddress), + SessionID: optionalTrimmedStringPtr(input.SessionID), + GroupID: apiKey.GroupID, + SubscriptionID: optionalSubscriptionID(subscription), + CreatedAt: time.Now(), } if result.ImageCount > 0 && (cost == nil || cost.BillingMode != string(BillingModeToken)) { usageLog.RateMultiplier = imageMultiplier diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index a4bc7b9f3a..4fb5c2ec5a 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -61,8 +61,9 @@ func (s *OpenAIGatewayService) forwardAnthropicViaNativeAnthropicEndpoint( // 记录客户端请求的推理强度:优先 Claude 协议的 output_config.effort; // 缺失且 thinking 已启用时,按国产 passback-required 模型兜底为 high // (对齐 Anthropic 网关 gateway_handler 的记录语义,避免该路径长期落 NULL)。 + requestedReasoningEffort := NormalizeClaudeOutputEffort(gjson.GetBytes(body, "output_config.effort").String()) reasoningEffort := ApplyThinkingEnabledFallback( - NormalizeClaudeOutputEffort(gjson.GetBytes(body, "output_config.effort").String()), + requestedReasoningEffort, body, billingModel, ) diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index 9f9c7b4c6a..c3bca27176 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -1527,6 +1527,8 @@ func TestOpenAIGatewayServiceRecordUsage_UsesRequestedModelAndUpstreamModelMetad require.Equal(t, serviceTier, *usageRepo.lastLog.ServiceTier) require.NotNil(t, usageRepo.lastLog.ReasoningEffort) require.Equal(t, reasoning, *usageRepo.lastLog.ReasoningEffort) + require.NotNil(t, usageRepo.lastLog.RequestedReasoningEffort) + require.Equal(t, reasoning, *usageRepo.lastLog.RequestedReasoningEffort) require.NotNil(t, usageRepo.lastLog.UserAgent) require.Equal(t, "codex-cli/1.0", *usageRepo.lastLog.UserAgent) require.NotNil(t, usageRepo.lastLog.IPAddress) @@ -1536,6 +1538,39 @@ func TestOpenAIGatewayServiceRecordUsage_UsesRequestedModelAndUpstreamModelMetad require.Equal(t, 1, userRepo.deductCalls) } +func TestOpenAIGatewayServiceRecordUsage_PersistsRequestedReasoningEffort(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + subRepo := &openAIRecordUsageSubRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil) + requested := "max" + forwarded := "xhigh" + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_requested_effort", + Model: "gpt-5.4", + ReasoningEffort: &forwarded, + RequestedReasoningEffort: &requested, + Usage: OpenAIUsage{ + InputTokens: 20, + OutputTokens: 10, + }, + Duration: time.Second, + }, + APIKey: &APIKey{ID: 10}, + User: &User{ID: 20}, + Account: &Account{ID: 30}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.NotNil(t, usageRepo.lastLog.ReasoningEffort) + require.Equal(t, forwarded, *usageRepo.lastLog.ReasoningEffort) + require.NotNil(t, usageRepo.lastLog.RequestedReasoningEffort) + require.Equal(t, requested, *usageRepo.lastLog.RequestedReasoningEffort) +} + func TestOpenAIGatewayServiceRecordUsage_PreservesChannelMappedUpstreamModel(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 6272875cc5..0523937d17 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -1220,6 +1220,65 @@ func extractOpenAIReasoningEffortFromBody(body []byte, modelCandidates ...string return &value } +func explicitRequestedReasoningEffortFromBody(body []byte) string { + raw := strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()) + if raw == "" { + raw = strings.TrimSpace(gjson.GetBytes(body, "reasoning_effort").String()) + } + if raw == "" { + raw = strings.TrimSpace(gjson.GetBytes(body, "output_config.effort").String()) + } + return raw +} + +// CanonicalRequestedReasoningEffort extracts the client-requested effort before +// group policy rewriting and before model-family remapping (max -> xhigh). +// Empty or unknown values return nil. "max" is preserved even for models that +// later persist "xhigh". +func CanonicalRequestedReasoningEffort(body []byte, modelCandidates ...string) *string { + if raw := explicitRequestedReasoningEffortFromBody(body); raw != "" { + canonical := NormalizeMaxReasoningEffort(raw) + if canonical == "" { + return nil + } + return &canonical + } + for _, model := range modelCandidates { + if value := canonicalReasoningEffortFromModelSuffix(model); value != "" { + return &value + } + } + if model := strings.TrimSpace(gjson.GetBytes(body, "model").String()); model != "" { + if value := canonicalReasoningEffortFromModelSuffix(model); value != "" { + return &value + } + } + return nil +} + +func canonicalReasoningEffortFromModelSuffix(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 NormalizeMaxReasoningEffort(parts[len(parts)-1]) +} + func extractOpenAIServiceTier(reqBody map[string]any) *string { if reqBody == nil { return nil @@ -1889,6 +1948,31 @@ func extractOpenAIReasoningEffort(reqBody map[string]any, modelCandidates ...str return &value } +func CanonicalRequestedReasoningEffortFromReqBody(reqBody map[string]any, modelCandidates ...string) *string { + if reqBody == nil { + return CanonicalRequestedReasoningEffort(nil, modelCandidates...) + } + raw := "" + if reasoning, ok := reqBody["reasoning"].(map[string]any); ok { + if effort, ok := reasoning["effort"].(string); ok { + raw = strings.TrimSpace(effort) + } + } + if raw == "" { + if effort, ok := reqBody["reasoning_effort"].(string); ok { + raw = strings.TrimSpace(effort) + } + } + if raw != "" { + canonical := NormalizeMaxReasoningEffort(raw) + if canonical == "" { + return nil + } + return &canonical + } + return CanonicalRequestedReasoningEffort(nil, modelCandidates...) +} + func normalizeOpenAIReasoningEffort(raw string) string { value := strings.ToLower(strings.TrimSpace(raw)) if value == "" { diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index c4d085b024..621de5c257 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -256,11 +256,15 @@ type OpenAIForwardResult struct { // ServiceTier 优先取上游实际响应回显的 tier;缺失时回退到最终出站 body 的 // tier。nil 表示两者都无识别 tier。 ServiceTier *string - // ReasoningEffort is extracted from request body (reasoning.effort) or derived from model suffix. + // ReasoningEffort is extracted from request body (reasoning.effort) or derived from model suffix + // after group policy rewriting and model-family remapping. // Stored for usage records display; nil means not provided / not applicable. ReasoningEffort *string - Stream bool - OpenAIWSMode bool + // RequestedReasoningEffort is the client-requested effort before mapping. + // Empty/nil means it should fall back to ReasoningEffort at persistence. + RequestedReasoningEffort *string + Stream bool + OpenAIWSMode bool // UpstreamTerminalEvent is the normalized terminal event observed on an // upstream Responses WebSocket turn. Empty preserves legacy/non-WS success. UpstreamTerminalEvent string diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 7489456bb8..d6579b2068 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -321,31 +321,32 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec } usageLog := &UsageLog{ - UserID: user.ID, - APIKeyID: apiKey.ID, - AccountID: account.ID, - RequestID: requestID, - Model: result.Model, - RequestedModel: requestedModel, - UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel), - UpstreamResponseModel: optionalTrimmedStringPtr(result.UpstreamResponseModel), - UpstreamModelMismatch: upstreamModelMismatch(sentModel, result.UpstreamResponseModel), - ServiceTier: result.ServiceTier, - ReasoningEffort: result.ReasoningEffort, - InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), - UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint), - InputTokens: actualInputTokens, - OutputTokens: result.Usage.OutputTokens, - CacheCreationTokens: result.Usage.CacheCreationInputTokens, - CacheReadTokens: result.Usage.CacheReadInputTokens, - ImageInputTokens: result.Usage.ImageInputTokens, - ImageOutputTokens: result.Usage.ImageOutputTokens, - ImageCount: result.ImageCount, - ImageSize: optionalTrimmedStringPtr(result.ImageSize), - ImageInputSize: optionalTrimmedStringPtr(result.ImageInputSize), - ImageOutputSize: optionalTrimmedStringPtr(result.ImageOutputSize), - ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource), - ImageSizeBreakdown: result.ImageSizeBreakdown, + UserID: user.ID, + APIKeyID: apiKey.ID, + AccountID: account.ID, + RequestID: requestID, + Model: result.Model, + RequestedModel: requestedModel, + UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel), + UpstreamResponseModel: optionalTrimmedStringPtr(result.UpstreamResponseModel), + UpstreamModelMismatch: upstreamModelMismatch(sentModel, result.UpstreamResponseModel), + ServiceTier: result.ServiceTier, + ReasoningEffort: result.ReasoningEffort, + RequestedReasoningEffort: coalesceRequestedReasoningEffort(result.RequestedReasoningEffort, result.ReasoningEffort), + InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint), + UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint), + InputTokens: actualInputTokens, + OutputTokens: result.Usage.OutputTokens, + CacheCreationTokens: result.Usage.CacheCreationInputTokens, + CacheReadTokens: result.Usage.CacheReadInputTokens, + ImageInputTokens: result.Usage.ImageInputTokens, + ImageOutputTokens: result.Usage.ImageOutputTokens, + ImageCount: result.ImageCount, + ImageSize: optionalTrimmedStringPtr(result.ImageSize), + ImageInputSize: optionalTrimmedStringPtr(result.ImageInputSize), + ImageOutputSize: optionalTrimmedStringPtr(result.ImageOutputSize), + ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource), + ImageSizeBreakdown: result.ImageSizeBreakdown, } isVideoUsage := isGrokVideoUsageResult(result, billingModels) if isVideoUsage { diff --git a/backend/internal/service/openai_reasoning_effort_policy.go b/backend/internal/service/openai_reasoning_effort_policy.go index 73453c2260..17432e49c7 100644 --- a/backend/internal/service/openai_reasoning_effort_policy.go +++ b/backend/internal/service/openai_reasoning_effort_policy.go @@ -17,6 +17,7 @@ const ( var openAIReasoningEffortValues = []string{"minimal", "low", "medium", "high", "xhigh", "max"} type openAIReasoningEffortPolicyContextKey struct{} +type requestedReasoningEffortContextKey struct{} type openAIReasoningEffortPolicy struct { maxEffort string @@ -136,6 +137,36 @@ func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapp return normalized, nil } +// WithRequestedReasoningEffort stores the client-requested effort captured from +// the inbound body before group policy or model-family remapping. +func WithRequestedReasoningEffort(ctx context.Context, effort string) context.Context { + if ctx == nil { + ctx = context.Background() + } + effort = strings.TrimSpace(effort) + if effort == "" { + return ctx + } + return context.WithValue(ctx, requestedReasoningEffortContextKey{}, effort) +} + +// RequestedReasoningEffortFromContext returns the inbound requested effort bound +// to ctx, or nil when none was captured. +func RequestedReasoningEffortFromContext(ctx context.Context) *string { + if ctx == nil { + return nil + } + value, ok := ctx.Value(requestedReasoningEffortContextKey{}).(string) + if !ok { + return nil + } + value = strings.TrimSpace(value) + if value == "" { + return nil + } + return &value +} + // WithOpenAIReasoningEffortPolicy binds a group policy to a request after its // concrete target platform has been resolved to OpenAI. The policy is copied so // retries and asynchronous forwarding cannot observe later slice mutations. diff --git a/backend/internal/service/openai_reasoning_effort_policy_test.go b/backend/internal/service/openai_reasoning_effort_policy_test.go index 4b501f48db..2ef836cc31 100644 --- a/backend/internal/service/openai_reasoning_effort_policy_test.go +++ b/backend/internal/service/openai_reasoning_effort_policy_test.go @@ -8,6 +8,34 @@ import ( "github.com/tidwall/gjson" ) +func TestCanonicalRequestedReasoningEffort(t *testing.T) { + t.Parallel() + + max := CanonicalRequestedReasoningEffort([]byte(`{"model":"gpt-5.4","reasoning":{"effort":"MAX"}}`), "gpt-5.4") + require.NotNil(t, max) + require.Equal(t, "max", *max) + + fromSuffix := CanonicalRequestedReasoningEffort([]byte(`{"model":"gpt-5.4-max"}`), "gpt-5.4-max") + require.NotNil(t, fromSuffix) + require.Equal(t, "max", *fromSuffix) + + claude := CanonicalRequestedReasoningEffort([]byte(`{"model":"claude-sonnet-4","output_config":{"effort":"high"}}`)) + require.NotNil(t, claude) + require.Equal(t, "high", *claude) + + require.Nil(t, CanonicalRequestedReasoningEffort([]byte(`{"model":"gpt-5.4"}`), "gpt-5.4")) +} + +func TestRequestedReasoningEffortContext(t *testing.T) { + t.Parallel() + + require.Nil(t, RequestedReasoningEffortFromContext(context.Background())) + ctx := WithRequestedReasoningEffort(context.Background(), " max ") + got := RequestedReasoningEffortFromContext(ctx) + require.NotNil(t, got) + require.Equal(t, "max", *got) +} + func TestNormalizeMaxReasoningEffort(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 8dbb5b1abb..aff0a5d067 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -194,6 +194,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( imageSizeTier string imageInputSize string payloadBytes int + requestedReasoningEffort *string } ingressSessionOriginalModel := "" @@ -257,6 +258,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( nil, ) } + requestedReasoningEffort := CanonicalRequestedReasoningEffort(normalized, strings.TrimSpace(values[1].String())) if hooks != nil && (hooks.MaxReasoningEffort != "" || len(hooks.ReasoningEffortMappings) > 0) { if capped, changed := ApplyOpenAIReasoningEffortPolicy(normalized, hooks.MaxReasoningEffort, hooks.ReasoningEffortMappings); changed { normalized = capped @@ -473,6 +475,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( imageSizeTier: imageSizeTier, imageInputSize: imageInputSize, payloadBytes: len(normalized), + requestedReasoningEffort: requestedReasoningEffort, }, nil } @@ -909,7 +912,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } var rejectedFieldRetryState *openAIResponsesRejectedFieldRetryState - sendAndRelay := func(turn int, lease *openAIWSConnLease, payload []byte, payloadBytes int, originalModel string, imageBillingModel string, imageSizeTier string, imageInputSize string) (*OpenAIForwardResult, error) { + sendAndRelay := func(turn int, lease *openAIWSConnLease, payload []byte, payloadBytes int, originalModel string, imageBillingModel string, imageSizeTier string, imageInputSize string, requestedReasoningEffort *string) (*OpenAIForwardResult, error) { responseModelObserver := &upstreamResponseModelObserver{} if lease == nil { return nil, errors.New("upstream websocket lease is nil") @@ -1180,6 +1183,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( UpstreamResponseServiceTier: responseModelObserver.ServiceTier(), ServiceTier: resolvedOpenAIUpstreamServiceTierFromObserver(responseModelObserver, extractOpenAIServiceTierFromBody(payload)), ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(payload, mappedModel, originalModel), payload, mappedModel), + RequestedReasoningEffort: requestedReasoningEffort, Stream: reqStream, OpenAIWSMode: true, UpstreamTerminalEvent: terminalEvent, @@ -1209,6 +1213,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( currentImageSizeTier := firstPayload.imageSizeTier currentImageInputSize := firstPayload.imageInputSize currentPayloadBytes := firstPayload.payloadBytes + currentRequestedReasoningEffort := firstPayload.requestedReasoningEffort isStrictAffinityTurn := func(payload []byte) bool { if !storeDisabled { return false @@ -1697,7 +1702,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( ) } - result, relayErr := sendAndRelay(turn, sessionLease, currentPayload, currentPayloadBytes, currentOriginalModel, currentImageBillingModel, currentImageSizeTier, currentImageInputSize) + result, relayErr := sendAndRelay(turn, sessionLease, currentPayload, currentPayloadBytes, currentOriginalModel, currentImageBillingModel, currentImageSizeTier, currentImageInputSize, currentRequestedReasoningEffort) if relayErr != nil { lastTurnClean = false if isOpenAIWSSessionPreempted(ctx) { @@ -1859,6 +1864,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( currentImageSizeTier = nextPayload.imageSizeTier currentImageInputSize = nextPayload.imageInputSize currentPayloadBytes = nextPayload.payloadBytes + currentRequestedReasoningEffort = nextPayload.requestedReasoningEffort rejectedFieldRetryState = newOpenAIResponsesRejectedFieldRetryState(currentPayload) storeDisabled = s.isOpenAIWSStoreDisabledInRequestRaw(currentPayload, account) if !storeDisabled { diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index 7702ebe797..f977edc558 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -784,6 +784,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( ImageOutputSizes: imageCounter.Sizes(), ServiceTier: resolvedOpenAIUpstreamServiceTierFromObserver(responseModelObserver, extractOpenAIServiceTier(reqBody)), ReasoningEffort: extractOpenAIReasoningEffort(reqBody, mappedModel, originalModel), + RequestedReasoningEffort: CanonicalRequestedReasoningEffortFromReqBody(reqBody, originalModel, mappedModel), Stream: reqStream, OpenAIWSMode: true, UpstreamTerminalEvent: upstreamTerminalEvent, diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index 2bb0c200b5..8993e89735 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -525,6 +525,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( UpstreamResponseServiceTier: responseModelObserver.ServiceTier(), ServiceTier: resolvedOpenAIUpstreamServiceTierFromObserver(responseModelObserver, extractOpenAIServiceTierFromBody(body)), ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(body, mappedModel, originalModel), body, mappedModel), + RequestedReasoningEffort: CanonicalRequestedReasoningEffort(body, originalModel, mappedModel), Stream: reqStream, OpenAIWSMode: true, UpstreamTerminalEvent: upstreamTerminalEvent, diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 60e7b41416..6fd9dfb50d 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -133,10 +133,11 @@ func openAIWSPassthroughPolicyModelFromSessionFrame(account *Account, payload [] } type openAIWSPassthroughUsageMeta struct { - serviceTier atomic.Pointer[string] - reasoningEffort atomic.Pointer[string] - requestModel atomic.Pointer[string] - upstreamModel atomic.Pointer[string] + serviceTier atomic.Pointer[string] + reasoningEffort atomic.Pointer[string] + requestedReasoningEffort atomic.Pointer[string] + requestModel atomic.Pointer[string] + upstreamModel atomic.Pointer[string] // 仅在 client->upstream filter goroutine 中读写;Load 侧通过上方原子指针同步。 sessionRequestModel string @@ -161,6 +162,14 @@ func (m *openAIWSPassthroughUsageMeta) initFromFirstFrame(policyOutput []byte, m m.storeTurnModels(m.sessionRequestModel, policyOutput) } +func (m *openAIWSPassthroughUsageMeta) captureRequestedReasoningEffort(originalBody []byte, modelCandidates ...string) { + if m == nil { + return + } + candidates := append([]string{m.sessionRequestModel}, modelCandidates...) + m.requestedReasoningEffort.Store(CanonicalRequestedReasoningEffort(originalBody, candidates...)) +} + func (m *openAIWSPassthroughUsageMeta) updateSessionRequestModel(payload []byte) { if m == nil { return @@ -686,6 +695,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } firstClientMessage = liteFirstMessage } + originalFirstClientMessage := firstClientMessage if hooks != nil && (hooks.MaxReasoningEffort != "" || len(hooks.ReasoningEffortMappings) > 0) { if capped, changed := ApplyOpenAIReasoningEffortPolicy(firstClientMessage, hooks.MaxReasoningEffort, hooks.ReasoningEffortMappings); changed { firstClientMessage = capped @@ -796,6 +806,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( // goroutine)和 OnTurnComplete / final result(runUpstreamToClient // goroutine)之间同步当前 turn 的 usage metadata。 usageMeta.initFromFirstFrame(firstClientMessage, capturedSessionModel) + usageMeta.captureRequestedReasoningEffort(originalFirstClientMessage, capturedSessionModel) _, initialUpstreamModel := usageMeta.turnModels(initialRequestModel) SetOpsUpstreamModel(c, initialUpstreamModel) wsURL, err := s.buildOpenAIResponsesWSURL(account) @@ -1020,11 +1031,13 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } payload = litePayload } + originalResponseCreate := payload if hooks != nil && (hooks.MaxReasoningEffort != "" || len(hooks.ReasoningEffortMappings) > 0) { if capped, changed := ApplyOpenAIReasoningEffortPolicy(payload, hooks.MaxReasoningEffort, hooks.ReasoningEffortMappings); changed { payload = capped } } + usageMeta.captureRequestedReasoningEffort(originalResponseCreate) } turnNo := int(completedTurns.Load()) + 1 if turnNo < 2 { @@ -1201,6 +1214,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( UpstreamResponseServiceTier: normalizeObservedOpenAIServiceTier(turn.ResponseServiceTier), ServiceTier: usageMeta.serviceTier.Load(), ReasoningEffort: usageMeta.reasoningEffort.Load(), + RequestedReasoningEffort: usageMeta.requestedReasoningEffort.Load(), Stream: true, OpenAIWSMode: true, UpstreamTerminalEvent: normalizeOpenAIWSTerminalEvent(turn.TerminalEventType), @@ -1327,6 +1341,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( UpstreamResponseServiceTier: normalizeObservedOpenAIServiceTier(relayResult.ResponseServiceTier), ServiceTier: usageMeta.serviceTier.Load(), ReasoningEffort: usageMeta.reasoningEffort.Load(), + RequestedReasoningEffort: usageMeta.requestedReasoningEffort.Load(), Stream: true, OpenAIWSMode: true, UpstreamTerminalEvent: normalizeOpenAIWSTerminalEvent(relayResult.TerminalEventType), diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter_effort_test.go b/backend/internal/service/openai_ws_v2_passthrough_adapter_effort_test.go index 82c9eaac85..5d337257e0 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter_effort_test.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter_effort_test.go @@ -24,10 +24,14 @@ func TestWSPassthroughUsageMeta_InitFromFirstFrame_NonGPT56FallsBackToXHigh(t *t meta := newOpenAIWSPassthroughUsageMeta("gpt-5.4", body) meta.initFromFirstFrame(body, "gpt-5.4") + meta.captureRequestedReasoningEffort(body, "gpt-5.4") got := meta.reasoningEffort.Load() require.NotNil(t, got) require.Equal(t, "xhigh", *got, "non-5.6 model should normalize max to xhigh") + requested := meta.requestedReasoningEffort.Load() + require.NotNil(t, requested) + require.Equal(t, "max", *requested, "usage should keep the pre-mapping requested effort") } func TestWSPassthroughUsageMeta_UpdateFromResponseCreate_MappedModelCandidate(t *testing.T) { diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go index b6dc2a9d0e..3808d1f467 100644 --- a/backend/internal/service/usage_log.go +++ b/backend/internal/service/usage_log.go @@ -131,10 +131,14 @@ type UsageLog struct { // ServiceTier records the billable request tier, e.g. OpenAI "priority" / "flex" // or Anthropic "fast". ServiceTier *string - // ReasoningEffort is the request's reasoning effort level. + // ReasoningEffort is the effective effort recorded for this request after + // group policy rewriting and model-family remapping (e.g. max -> xhigh). // OpenAI: "low" / "medium" / "high" / "xhigh"; Claude: "low" / "medium" / "high" / "max". // Nil means not provided / not applicable. ReasoningEffort *string + // RequestedReasoningEffort is the client-requested effort before mapping. + // Nil means historical rows, or that no explicit/suffix-derived effort was observed. + RequestedReasoningEffort *string // InboundEndpoint is the client-facing API endpoint path, e.g. /v1/chat/completions. InboundEndpoint *string // UpstreamEndpoint is the normalized upstream endpoint path, e.g. /v1/responses. diff --git a/backend/internal/service/usage_log_helpers.go b/backend/internal/service/usage_log_helpers.go index deb102e736..dda3099070 100644 --- a/backend/internal/service/usage_log_helpers.go +++ b/backend/internal/service/usage_log_helpers.go @@ -17,6 +17,18 @@ func optionalStringValue(value *string) string { return strings.TrimSpace(*value) } +// coalesceRequestedReasoningEffort prefers the client-requested value and falls +// back to the effective/forwarded effort for historical or unmapped rows. +func coalesceRequestedReasoningEffort(requested, forwarded *string) *string { + if trimmed := optionalStringValue(requested); trimmed != "" { + return &trimmed + } + if trimmed := optionalStringValue(forwarded); trimmed != "" { + return &trimmed + } + return nil +} + func forwardResultBillingModel(requestedModel, upstreamModel string) string { if trimmed := strings.TrimSpace(requestedModel); trimmed != "" { return trimmed diff --git a/backend/migrations/231_add_usage_log_requested_reasoning_effort.sql b/backend/migrations/231_add_usage_log_requested_reasoning_effort.sql new file mode 100644 index 0000000000..8f35a714cf --- /dev/null +++ b/backend/migrations/231_add_usage_log_requested_reasoning_effort.sql @@ -0,0 +1,7 @@ +-- Persist the client-requested reasoning effort before group policy rewriting +-- and model-family remapping (e.g. max -> xhigh). NULL means historical rows +-- written before this dual-write, or requests that never declared an effort. +-- +-- Nullable with no default: on PostgreSQL 11+ this is a metadata-only change +-- and does not rewrite the (potentially large, partitioned) usage_logs table. +ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS requested_reasoning_effort VARCHAR(20); diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index f7f619dbb4..dac6098e57 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -91,8 +91,16 @@ @@ -500,7 +508,7 @@ import { computed, ref } from 'vue' import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' -import { formatDateTime, formatReasoningEffort } from '@/utils/format' +import { formatDateTime, formatReasoningEffort, reasoningEffortValuesEqual } from '@/utils/format' import { formatCacheTokens, formatMultiplier } from '@/utils/formatters' import { formatTokenPricePerMillion } from '@/utils/usagePricing' import { getUsageServiceTierLabel } from '@/utils/usageServiceTier' @@ -587,6 +595,12 @@ const ipGeoBatchLoading = ref(false) const showIpGeoToolbar = computed(() => props.columns.some((col) => col.key === 'ip_address')) +const hasReasoningEffortMapping = (row: AdminUsageLog): boolean => { + const requested = row.requested_reasoning_effort?.trim() || '' + const forwarded = row.reasoning_effort?.trim() || '' + return requested !== '' && forwarded !== '' && !reasoningEffortValuesEqual(requested, forwarded) +} + const sentUpstreamModel = (row: AdminUsageLog): string => row.upstream_model?.trim() || row.model?.trim() || '' const normalizeModelVariant = (model: string): string => model diff --git a/frontend/src/components/admin/usage/__tests__/UsageTable.spec.ts b/frontend/src/components/admin/usage/__tests__/UsageTable.spec.ts index 56c0932108..a8bf2df1db 100644 --- a/frontend/src/components/admin/usage/__tests__/UsageTable.spec.ts +++ b/frontend/src/components/admin/usage/__tests__/UsageTable.spec.ts @@ -84,6 +84,7 @@ const DataTableStub = {
+ @@ -256,6 +257,61 @@ describe('admin UsageTable tooltip', () => { expect(text).toContain('claude-sonnet-4-20250514') }) + it('shows requested and forwarded reasoning effort separately when they differ', () => { + const wrapper = mount(UsageTable, { + props: { + data: [{ + request_id: 'req-admin-effort-1', + model: 'gpt-5.4', + requested_reasoning_effort: 'max', + reasoning_effort: 'xhigh', + }], + loading: false, + columns: [], + }, + global: { + stubs: { + DataTable: DataTableStub, + EmptyState: true, + Icon: true, + Teleport: true, + }, + }, + }) + + const text = wrapper.text() + expect(text).toContain('Max') + expect(text).toContain('XHigh') + expect(text).toContain('↳') + }) + + it('shows a single reasoning effort when requested matches forwarded', () => { + const wrapper = mount(UsageTable, { + props: { + data: [{ + request_id: 'req-admin-effort-2', + model: 'gpt-5.6-sol', + requested_reasoning_effort: 'max', + reasoning_effort: 'max', + }], + loading: false, + columns: [], + }, + global: { + stubs: { + DataTable: DataTableStub, + EmptyState: true, + Icon: true, + Teleport: true, + }, + }, + }) + + const text = wrapper.text() + expect(text).toContain('Max') + expect(text).not.toContain('↳') + }) + it.each([ { name: 'possible version variant', @@ -561,6 +617,7 @@ const DataTableStubWithUser = {
+ diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts index 65fa9f480c..30e9e80595 100644 --- a/frontend/src/i18n/locales/en/dashboard.ts +++ b/frontend/src/i18n/locales/en/dashboard.ts @@ -309,6 +309,7 @@ export default { modelVariant: 'Possible version variant', modelMismatch: 'Different model', reasoningEffort: 'Reasoning Effort', + requestedReasoningEffort: 'Requested reasoning effort', endpoint: 'Endpoint', endpointDistribution: 'Endpoint Distribution', inbound: 'Inbound', diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts index 044c2e1bf0..d0fb902c71 100644 --- a/frontend/src/i18n/locales/zh/dashboard.ts +++ b/frontend/src/i18n/locales/zh/dashboard.ts @@ -314,6 +314,7 @@ export default { modelVariant: '疑似版本变体', modelMismatch: '模型不一致', reasoningEffort: '推理强度', + requestedReasoningEffort: '请求推理强度', endpoint: '端点', endpointDistribution: '端点分布', inbound: '入站', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 10a72a0a17..21b2f31729 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1639,6 +1639,7 @@ export interface UsageLog { model: string service_tier?: string | null reasoning_effort?: string | null + requested_reasoning_effort?: string | null inbound_endpoint?: string | null upstream_endpoint?: string | null diff --git a/frontend/src/utils/__tests__/formatReasoningEffort.spec.ts b/frontend/src/utils/__tests__/formatReasoningEffort.spec.ts new file mode 100644 index 0000000000..aaac5d156c --- /dev/null +++ b/frontend/src/utils/__tests__/formatReasoningEffort.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { + formatReasoningEffort, + formatReasoningEffortMapping, + reasoningEffortValuesEqual, +} from '@/utils/format' + +describe('formatReasoningEffort', () => { + it('title-cases known effort values', () => { + expect(formatReasoningEffort('max')).toBe('Max') + expect(formatReasoningEffort('x-high')).toBe('XHigh') + expect(formatReasoningEffort(null)).toBe('-') + }) +}) + +describe('formatReasoningEffortMapping', () => { + it('shows a single value when requested and forwarded match', () => { + expect(formatReasoningEffortMapping('max', 'max')).toBe('Max') + expect(formatReasoningEffortMapping(null, 'high')).toBe('High') + }) + + it('shows requested then forwarded when mapping changed the value', () => { + expect(formatReasoningEffortMapping('max', 'xhigh')).toBe('Max → XHigh') + expect(formatReasoningEffortMapping('high', 'medium')).toBe('High → Medium') + }) +}) + +describe('reasoningEffortValuesEqual', () => { + it('treats x-high aliases as equal', () => { + expect(reasoningEffortValuesEqual('x-high', 'xhigh')).toBe(true) + expect(reasoningEffortValuesEqual('max', 'xhigh')).toBe(false) + }) +}) diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts index 787bd4b545..17ba82e098 100644 --- a/frontend/src/utils/format.ts +++ b/frontend/src/utils/format.ts @@ -211,11 +211,15 @@ export function parseDateTimeLocalInput(value: string): number | null { * @param effort 原始 effort(如 "low" / "medium" / "high" / "xhigh") * @returns 格式化后的字符串(Low / Medium / High / Xhigh),无值返回 "-" */ +function normalizeReasoningEffortKey(effort: string | null | undefined): string { + return (effort ?? '').toString().trim().toLowerCase().replace(/[-_\s]/g, '') +} + export function formatReasoningEffort(effort: string | null | undefined): string { const raw = (effort ?? '').toString().trim() if (!raw) return '-' - const normalized = raw.toLowerCase().replace(/[-_\s]/g, '') + const normalized = normalizeReasoningEffortKey(raw) switch (normalized) { case 'low': return 'Low' @@ -237,6 +241,31 @@ export function formatReasoningEffort(effort: string | null | undefined): string } } +export function reasoningEffortValuesEqual( + left: string | null | undefined, + right: string | null | undefined, +): boolean { + const a = normalizeReasoningEffortKey(left) + const b = normalizeReasoningEffortKey(right) + if (!a && !b) return true + return a !== '' && a === b +} + +/** Requested vs forwarded effort for usage export; one value when they match. */ +export function formatReasoningEffortMapping( + requested: string | null | undefined, + forwarded: string | null | undefined, +): string { + const requestedLabel = formatReasoningEffort(requested) + const forwardedLabel = formatReasoningEffort(forwarded) + if (requestedLabel === '-' && forwardedLabel === '-') return '-' + if (requestedLabel === '-' || reasoningEffortValuesEqual(requested, forwarded)) { + return forwardedLabel === '-' ? requestedLabel : forwardedLabel + } + if (forwardedLabel === '-') return requestedLabel + return `${requestedLabel} → ${forwardedLabel}` +} + /** * 格式化时间(显示时分秒) * @param date 日期字符串或 Date 对象 diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue index b598f610ab..1e5967dafb 100644 --- a/frontend/src/views/admin/UsageView.vue +++ b/frontend/src/views/admin/UsageView.vue @@ -575,7 +575,7 @@ const exportToExcel = async () => { const XLSX = await import('xlsx') const headers = [ t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'), - t('admin.usage.account'), t('usage.requestedModel'), t('usage.sentUpstreamModel'), t('usage.upstreamResponseModel'), t('usage.upstreamModelMismatch'), t('usage.reasoningEffort'), t('admin.usage.group'), + t('admin.usage.account'), t('usage.requestedModel'), t('usage.sentUpstreamModel'), t('usage.upstreamResponseModel'), t('usage.upstreamModelMismatch'), t('usage.requestedReasoningEffort'), t('usage.reasoningEffort'), t('admin.usage.group'), t('usage.inboundEndpoint'), t('usage.upstreamEndpoint'), t('usage.type'), t('admin.usage.inputTokens'), t('admin.usage.outputTokens'), @@ -595,7 +595,7 @@ const exportToExcel = async () => { if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total } const rows = (res.items || []).map((log: AdminUsageLog) => [ log.created_at, log.user?.email || '', log.api_key?.name || '', log.account?.name || '', log.model, - log.upstream_model || log.model, log.upstream_response_model || '', log.upstream_model_mismatch == null ? '' : t(log.upstream_model_mismatch ? 'common.yes' : 'common.no'), formatReasoningEffort(log.reasoning_effort), log.group?.name || '', + log.upstream_model || log.model, log.upstream_response_model || '', log.upstream_model_mismatch == null ? '' : t(log.upstream_model_mismatch ? 'common.yes' : 'common.no'), formatReasoningEffort(log.requested_reasoning_effort || log.reasoning_effort), formatReasoningEffort(log.reasoning_effort), log.group?.name || '', log.inbound_endpoint || '', log.upstream_endpoint || '', getRequestTypeLabel(log), log.input_tokens, log.output_tokens, log.cache_read_tokens, log.cache_creation_tokens, log.input_cost?.toFixed(6) || '0.000000', log.output_cost?.toFixed(6) || '0.000000', diff --git a/frontend/src/views/user/UsageView.vue b/frontend/src/views/user/UsageView.vue index d836936a5a..bf536821f7 100644 --- a/frontend/src/views/user/UsageView.vue +++ b/frontend/src/views/user/UsageView.vue @@ -230,7 +230,7 @@ import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue' import Icon from '@/components/icons/Icon.vue' import UserErrorRequestsTable from '@/components/user/UserErrorRequestsTable.vue' import { getPersistedPageSize } from '@/composables/usePersistedPageSize' -import { formatReasoningEffort } from '@/utils/format' +import { formatReasoningEffortMapping } from '@/utils/format' import { getBillingModeLabel, getDisplayBillingMode as resolveDisplayBillingMode } from '@/utils/billingMode' import { resolveUsageRequestType, requestTypeToLegacyStream } from '@/utils/usageRequestType' import type { @@ -658,7 +658,7 @@ const exportToCSV = async () => { log.created_at, log.api_key?.name || '', log.model, - formatReasoningEffort(log.reasoning_effort), + formatReasoningEffortMapping(log.requested_reasoning_effort, log.reasoning_effort), log.inbound_endpoint || '', log.ip_address || '', getRequestTypeExportText(log), From 5705f4a4aab54a4d12f140cf30cd595ee9c7bcd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=BD=B3=E6=9E=97?= Date: Tue, 25 Aug 2026 15:36:22 +0800 Subject: [PATCH 2/3] fix(usage): hide mapped reasoning effort from users Users only see the reasoning effort they requested. Admins still see the requested value plus the forwarded mapped value, matching the model column's requested vs upstream split. --- backend/internal/handler/dto/mappers.go | 59 +++++++++++++++---- .../handler/dto/mappers_usage_test.go | 44 +++++++++++--- backend/internal/handler/dto/types.go | 8 ++- .../src/components/admin/usage/UsageTable.vue | 10 ++-- .../admin/usage/__tests__/UsageTable.spec.ts | 31 +++++++++- frontend/src/types/index.ts | 2 +- frontend/src/views/admin/UsageView.vue | 2 +- frontend/src/views/user/UsageView.vue | 4 +- 8 files changed, 125 insertions(+), 35 deletions(-) diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 81aa8f8865..e59aa39730 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -3,6 +3,7 @@ package dto import ( "strconv" + "strings" "time" "github.com/Wei-Shaw/sub2api/internal/service" @@ -643,8 +644,7 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog { RequestID: l.RequestID, Model: requestedModel, ServiceTier: l.ServiceTier, - ReasoningEffort: l.ReasoningEffort, - RequestedReasoningEffort: l.RequestedReasoningEffort, + ReasoningEffort: userFacingReasoningEffort(l), InboundEndpoint: l.InboundEndpoint, GroupID: l.GroupID, SubscriptionID: l.SubscriptionID, @@ -711,20 +711,53 @@ func UsageLogFromServiceAdmin(l *service.UsageLog) *AdminUsageLog { usageLog := usageLogFromServiceUser(l) usageLog.UpstreamEndpoint = l.UpstreamEndpoint return &AdminUsageLog{ - UsageLog: usageLog, - UpstreamModel: l.UpstreamModel, - UpstreamResponseModel: l.UpstreamResponseModel, - UpstreamModelMismatch: l.UpstreamModelMismatch, - ChannelID: l.ChannelID, - ModelMappingChain: l.ModelMappingChain, - BillingTier: l.BillingTier, - AccountRateMultiplier: l.AccountRateMultiplier, - AccountStatsCost: l.AccountStatsCost, - IPAddress: l.IPAddress, - Account: AccountSummaryFromService(l.Account), + UsageLog: usageLog, + UpstreamModel: l.UpstreamModel, + UpstreamReasoningEffort: adminUpstreamReasoningEffort(l), + UpstreamResponseModel: l.UpstreamResponseModel, + UpstreamModelMismatch: l.UpstreamModelMismatch, + ChannelID: l.ChannelID, + ModelMappingChain: l.ModelMappingChain, + BillingTier: l.BillingTier, + AccountRateMultiplier: l.AccountRateMultiplier, + AccountStatsCost: l.AccountStatsCost, + IPAddress: l.IPAddress, + Account: AccountSummaryFromService(l.Account), } } +func userFacingReasoningEffort(l *service.UsageLog) *string { + if l == nil { + return nil + } + if requested := strings.TrimSpace(derefString(l.RequestedReasoningEffort)); requested != "" { + return &requested + } + return l.ReasoningEffort +} + +func adminUpstreamReasoningEffort(l *service.UsageLog) *string { + if l == nil { + return nil + } + forwarded := strings.TrimSpace(derefString(l.ReasoningEffort)) + if forwarded == "" { + return nil + } + requested := userFacingReasoningEffort(l) + if requested != nil && service.NormalizeMaxReasoningEffort(*requested) == service.NormalizeMaxReasoningEffort(forwarded) { + return nil + } + return &forwarded +} + +func derefString(value *string) string { + if value == nil { + return "" + } + return *value +} + func UsageCleanupTaskFromService(task *service.UsageCleanupTask) *UsageCleanupTask { if task == nil { return nil diff --git a/backend/internal/handler/dto/mappers_usage_test.go b/backend/internal/handler/dto/mappers_usage_test.go index 43299000e9..3923d9cd20 100644 --- a/backend/internal/handler/dto/mappers_usage_test.go +++ b/backend/internal/handler/dto/mappers_usage_test.go @@ -179,7 +179,7 @@ func TestUsageLogFromService_KeepsUserBillingAndIPWithoutAdminCostFields(t *test require.NotContains(t, string(userJSON), "account_cost") } -func TestUsageLogFromService_IncludesRequestedReasoningEffort(t *testing.T) { +func TestUsageLogFromService_UsersSeeRequestedReasoningEffortOnly(t *testing.T) { t.Parallel() requested := "max" @@ -195,13 +195,43 @@ func TestUsageLogFromService_IncludesRequestedReasoningEffort(t *testing.T) { adminDTO := UsageLogFromServiceAdmin(log) require.NotNil(t, userDTO.ReasoningEffort) - require.Equal(t, forwarded, *userDTO.ReasoningEffort) - require.NotNil(t, userDTO.RequestedReasoningEffort) - require.Equal(t, requested, *userDTO.RequestedReasoningEffort) + require.Equal(t, requested, *userDTO.ReasoningEffort) require.NotNil(t, adminDTO.ReasoningEffort) - require.Equal(t, forwarded, *adminDTO.ReasoningEffort) - require.NotNil(t, adminDTO.RequestedReasoningEffort) - require.Equal(t, requested, *adminDTO.RequestedReasoningEffort) + require.Equal(t, requested, *adminDTO.ReasoningEffort) + require.NotNil(t, adminDTO.UpstreamReasoningEffort) + require.Equal(t, forwarded, *adminDTO.UpstreamReasoningEffort) + + userJSON, err := json.Marshal(userDTO) + require.NoError(t, err) + require.Contains(t, string(userJSON), `"reasoning_effort":"max"`) + require.NotContains(t, string(userJSON), "upstream_reasoning_effort") + require.NotContains(t, string(userJSON), "requested_reasoning_effort") + + adminJSON, err := json.Marshal(adminDTO) + require.NoError(t, err) + require.Contains(t, string(adminJSON), `"reasoning_effort":"max"`) + require.Contains(t, string(adminJSON), `"upstream_reasoning_effort":"xhigh"`) +} + +func TestUsageLogFromService_OmitsUpstreamReasoningEffortWhenUnmapped(t *testing.T) { + t.Parallel() + + effort := "high" + log := &service.UsageLog{ + RequestID: "req_effort_same", + Model: "gpt-5.4", + ReasoningEffort: &effort, + RequestedReasoningEffort: &effort, + } + + adminDTO := UsageLogFromServiceAdmin(log) + require.NotNil(t, adminDTO.ReasoningEffort) + require.Equal(t, effort, *adminDTO.ReasoningEffort) + require.Nil(t, adminDTO.UpstreamReasoningEffort) + + adminJSON, err := json.Marshal(adminDTO) + require.NoError(t, err) + require.NotContains(t, string(adminJSON), "upstream_reasoning_effort") } func TestUsageLogFromService_FallsBackToLegacyModelWhenRequestedModelMissing(t *testing.T) { diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 72e89a0985..ba989ce86c 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -486,11 +486,10 @@ type UsageLog struct { Model string `json:"model"` // ServiceTier records the OpenAI service tier used for billing, e.g. "priority" / "flex". ServiceTier *string `json:"service_tier,omitempty"` - // ReasoningEffort is the effective effort after group policy / model-family remapping. + // ReasoningEffort is the client-requested effort (mapping-hidden, like Model). // OpenAI: "low"/"medium"/"high"/"xhigh"; Claude: "low"/"medium"/"high"/"max". + // Historical rows without requested_reasoning_effort fall back to the stored effective value. ReasoningEffort *string `json:"reasoning_effort,omitempty"` - // RequestedReasoningEffort is the client-requested effort before mapping. - RequestedReasoningEffort *string `json:"requested_reasoning_effort,omitempty"` // InboundEndpoint is the client-facing API endpoint path, e.g. /v1/chat/completions. InboundEndpoint *string `json:"inbound_endpoint,omitempty"` // UpstreamEndpoint is the normalized upstream endpoint path, e.g. /v1/responses. @@ -565,6 +564,9 @@ type AdminUsageLog struct { // UpstreamModel is the actual model sent to the upstream provider after mapping. // Omitted when no mapping was applied (requested model was used as-is). UpstreamModel *string `json:"upstream_model,omitempty"` + // UpstreamReasoningEffort is the effort actually forwarded after group policy / + // model-family remapping. Omitted when it matches the client-requested value. + UpstreamReasoningEffort *string `json:"upstream_reasoning_effort,omitempty"` // UpstreamResponseModel is the raw model declared by the upstream response. UpstreamResponseModel *string `json:"upstream_response_model,omitempty"` // UpstreamModelMismatch is nil when the upstream did not declare a model. diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index dac6098e57..bdba892581 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -93,14 +93,14 @@ @@ -596,8 +596,8 @@ const ipGeoBatchLoading = ref(false) const showIpGeoToolbar = computed(() => props.columns.some((col) => col.key === 'ip_address')) const hasReasoningEffortMapping = (row: AdminUsageLog): boolean => { - const requested = row.requested_reasoning_effort?.trim() || '' - const forwarded = row.reasoning_effort?.trim() || '' + const requested = row.reasoning_effort?.trim() || '' + const forwarded = row.upstream_reasoning_effort?.trim() || '' return requested !== '' && forwarded !== '' && !reasoningEffortValuesEqual(requested, forwarded) } diff --git a/frontend/src/components/admin/usage/__tests__/UsageTable.spec.ts b/frontend/src/components/admin/usage/__tests__/UsageTable.spec.ts index a8bf2df1db..e0ee74154c 100644 --- a/frontend/src/components/admin/usage/__tests__/UsageTable.spec.ts +++ b/frontend/src/components/admin/usage/__tests__/UsageTable.spec.ts @@ -263,8 +263,8 @@ describe('admin UsageTable tooltip', () => { data: [{ request_id: 'req-admin-effort-1', model: 'gpt-5.4', - requested_reasoning_effort: 'max', - reasoning_effort: 'xhigh', + reasoning_effort: 'max', + upstream_reasoning_effort: 'xhigh', }], loading: false, columns: [], @@ -291,7 +291,6 @@ describe('admin UsageTable tooltip', () => { data: [{ request_id: 'req-admin-effort-2', model: 'gpt-5.6-sol', - requested_reasoning_effort: 'max', reasoning_effort: 'max', }], loading: false, @@ -312,6 +311,32 @@ describe('admin UsageTable tooltip', () => { expect(text).not.toContain('↳') }) + it('hides mapped reasoning effort for user rows that only have the requested value', () => { + const wrapper = mount(UsageTable, { + props: { + data: [{ + request_id: 'req-user-effort-1', + model: 'gpt-5.4', + reasoning_effort: 'max', + }], + loading: false, + columns: [], + }, + global: { + stubs: { + DataTable: DataTableStub, + EmptyState: true, + Icon: true, + Teleport: true, + }, + }, + }) + + expect(wrapper.text()).toContain('Max') + expect(wrapper.text()).not.toContain('XHigh') + expect(wrapper.text()).not.toContain('↳') + }) + it.each([ { name: 'possible version variant', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 21b2f31729..5c605d971b 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1639,7 +1639,6 @@ export interface UsageLog { model: string service_tier?: string | null reasoning_effort?: string | null - requested_reasoning_effort?: string | null inbound_endpoint?: string | null upstream_endpoint?: string | null @@ -1706,6 +1705,7 @@ export interface UsageLogAccountSummary { export interface AdminUsageLog extends UsageLog { upstream_model?: string | null + upstream_reasoning_effort?: string | null upstream_response_model?: string | null upstream_model_mismatch?: boolean | null model_mapping_chain?: string | null diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue index 1e5967dafb..3f8215e134 100644 --- a/frontend/src/views/admin/UsageView.vue +++ b/frontend/src/views/admin/UsageView.vue @@ -595,7 +595,7 @@ const exportToExcel = async () => { if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total } const rows = (res.items || []).map((log: AdminUsageLog) => [ log.created_at, log.user?.email || '', log.api_key?.name || '', log.account?.name || '', log.model, - log.upstream_model || log.model, log.upstream_response_model || '', log.upstream_model_mismatch == null ? '' : t(log.upstream_model_mismatch ? 'common.yes' : 'common.no'), formatReasoningEffort(log.requested_reasoning_effort || log.reasoning_effort), formatReasoningEffort(log.reasoning_effort), log.group?.name || '', + log.upstream_model || log.model, log.upstream_response_model || '', log.upstream_model_mismatch == null ? '' : t(log.upstream_model_mismatch ? 'common.yes' : 'common.no'), formatReasoningEffort(log.reasoning_effort), formatReasoningEffort(log.upstream_reasoning_effort || log.reasoning_effort), log.group?.name || '', log.inbound_endpoint || '', log.upstream_endpoint || '', getRequestTypeLabel(log), log.input_tokens, log.output_tokens, log.cache_read_tokens, log.cache_creation_tokens, log.input_cost?.toFixed(6) || '0.000000', log.output_cost?.toFixed(6) || '0.000000', diff --git a/frontend/src/views/user/UsageView.vue b/frontend/src/views/user/UsageView.vue index bf536821f7..d836936a5a 100644 --- a/frontend/src/views/user/UsageView.vue +++ b/frontend/src/views/user/UsageView.vue @@ -230,7 +230,7 @@ import TokenUsageTrend from '@/components/charts/TokenUsageTrend.vue' import Icon from '@/components/icons/Icon.vue' import UserErrorRequestsTable from '@/components/user/UserErrorRequestsTable.vue' import { getPersistedPageSize } from '@/composables/usePersistedPageSize' -import { formatReasoningEffortMapping } from '@/utils/format' +import { formatReasoningEffort } from '@/utils/format' import { getBillingModeLabel, getDisplayBillingMode as resolveDisplayBillingMode } from '@/utils/billingMode' import { resolveUsageRequestType, requestTypeToLegacyStream } from '@/utils/usageRequestType' import type { @@ -658,7 +658,7 @@ const exportToCSV = async () => { log.created_at, log.api_key?.name || '', log.model, - formatReasoningEffortMapping(log.requested_reasoning_effort, log.reasoning_effort), + formatReasoningEffort(log.reasoning_effort), log.inbound_endpoint || '', log.ip_address || '', getRequestTypeExportText(log), From a8cfe746b3d1cdef800c9d3038236e862af2356e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=BD=B3=E6=9E=97?= Date: Tue, 25 Aug 2026 15:49:04 +0800 Subject: [PATCH 3/3] test(usage): cover user and admin reasoning effort page display Mount the real usage tables and assert users only see the requested effort, while admins can reveal mapped effort via column settings. --- .../usage-reasoning-effort.spec.ts | 322 ++++++++++++++++++ .../src/components/admin/usage/UsageTable.vue | 4 +- frontend/src/views/admin/UsageView.vue | 2 + frontend/src/views/user/UsageView.vue | 2 + 4 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 frontend/src/__tests__/integration/usage-reasoning-effort.spec.ts diff --git a/frontend/src/__tests__/integration/usage-reasoning-effort.spec.ts b/frontend/src/__tests__/integration/usage-reasoning-effort.spec.ts new file mode 100644 index 0000000000..8d6edf2c00 --- /dev/null +++ b/frontend/src/__tests__/integration/usage-reasoning-effort.spec.ts @@ -0,0 +1,322 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { defineComponent } from 'vue' + +import UserUsageView from '@/views/user/UsageView.vue' +import AdminUsageView from '@/views/admin/UsageView.vue' + +const { + userQuery, + userGetStats, + userGetDashboardModels, + userGetDashboardSnapshotV2, + userKeysList, + userGroupsGetAvailable, + adminList, + adminGetStats, + adminGetSnapshotV2, + adminGetModelStats, + adminGetById, + listErrorLogs, +} = vi.hoisted(() => ({ + userQuery: vi.fn(), + userGetStats: vi.fn(), + userGetDashboardModels: vi.fn(), + userGetDashboardSnapshotV2: vi.fn(), + userKeysList: vi.fn(), + userGroupsGetAvailable: vi.fn(), + adminList: vi.fn(), + adminGetStats: vi.fn(), + adminGetSnapshotV2: vi.fn(), + adminGetModelStats: vi.fn(), + adminGetById: vi.fn(), + listErrorLogs: vi.fn(), +})) + +const messages: Record = { + 'admin.dashboard.timeRange': 'Time range', + 'admin.dashboard.granularity': 'Granularity', + 'admin.dashboard.day': 'Day', + 'admin.dashboard.hour': 'Hour', + 'admin.users.columnSettings': 'Columns', + 'admin.usage.group': 'Group', + 'admin.usage.user': 'User', + 'admin.usage.account': 'Account', + 'admin.usage.billingType': 'Billing type', + 'admin.usage.billingMode': 'Billing mode', + 'admin.usage.allTypes': 'All types', + 'admin.usage.allBillingTypes': 'All billing types', + 'admin.usage.billingTypeBalance': 'Balance', + 'admin.usage.billingTypeSubscription': 'Subscription', + 'admin.usage.allBillingModes': 'All billing modes', + 'admin.usage.billingModeToken': 'Token', + 'admin.usage.billingModePerRequest': 'Per request', + 'admin.usage.billingModeImage': 'Image', + 'admin.usage.allGroups': 'All groups', + 'admin.usage.allModels': 'All models', + 'admin.usage.ipAddress': 'IP', + 'usage.allApiKeys': 'All API Keys', + 'usage.apiKeyFilter': 'API Key', + 'usage.model': 'Model', + 'usage.type': 'Type', + 'usage.reasoningEffort': 'Reasoning Effort', + 'usage.endpoint': 'Endpoint', + 'usage.tokens': 'Tokens', + 'usage.cost': 'Cost', + 'usage.latency': 'Latency', + 'usage.time': 'Time', + 'usage.userAgent': 'User Agent', + 'usage.ws': 'WS', + 'usage.stream': 'Stream', + 'usage.sync': 'Sync', + 'usage.exporting': 'Exporting', + 'usage.exportCsv': 'Export CSV', + 'usage.failedToLoad': 'Failed to load', + 'usage.noRecords': 'No records', + 'empty.noData': 'No data', + 'common.refresh': 'Refresh', + 'common.reset': 'Reset', +} + +vi.mock('@/api', () => ({ + usageAPI: { + query: userQuery, + getStats: userGetStats, + getDashboardModels: userGetDashboardModels, + getDashboardSnapshotV2: userGetDashboardSnapshotV2, + }, + keysAPI: { + list: userKeysList, + }, + userGroupsAPI: { + getAvailable: userGroupsGetAvailable, + }, +})) + +vi.mock('@/api/admin', () => ({ + adminAPI: { + usage: { + list: adminList, + getStats: adminGetStats, + }, + dashboard: { + getSnapshotV2: adminGetSnapshotV2, + getModelStats: adminGetModelStats, + }, + users: { + getById: adminGetById, + }, + }, +})) + +vi.mock('@/api/admin/usage', () => ({ + adminUsageAPI: { + list: adminList, + }, +})) + +vi.mock('@/api/admin/ops', () => ({ + listErrorLogs, +})) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ + showError: vi.fn(), + showWarning: vi.fn(), + showSuccess: vi.fn(), + showInfo: vi.fn(), + }), +})) + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string) => messages[key] ?? key, + }), + } +}) + +vi.mock('vue-router', () => ({ + useRoute: () => ({ query: {} }), +})) + +const layoutStub = { template: '
' } +const chartStub = { template: '
' } +const iconStub = { template: '' } +const adminFiltersStub = defineComponent({ + template: '
', +}) + +const emptyStats = { + total_requests: 1, + total_input_tokens: 10, + total_output_tokens: 20, + total_cache_tokens: 0, + total_tokens: 30, + total_cost: 0.1, + total_actual_cost: 0.08, + average_duration_ms: 12, + endpoints: [], + upstream_endpoints: [], + endpoint_paths: [], +} + +const userMappedLog = { + id: 11, + request_id: 'req-user-mapped', + created_at: '2026-08-25T00:00:00Z', + model: 'gpt-5.4', + reasoning_effort: 'max', + actual_cost: 0.01, + total_cost: 0.01, + rate_multiplier: 1, + input_cost: 0.01, + output_cost: 0, + cache_creation_cost: 0, + cache_read_cost: 0, + input_tokens: 8, + output_tokens: 2, + cache_creation_tokens: 0, + cache_read_tokens: 0, + cache_creation_5m_tokens: 0, + cache_creation_1h_tokens: 0, + image_count: 0, + billing_mode: 'token', + request_type: 'sync', + stream: false, + api_key: { name: 'user-key' }, +} + +const adminMappedLog = { + ...userMappedLog, + request_id: 'req-admin-mapped', + user_id: 7, + user: { email: 'liu.jialin@code-dance.com' }, + account: { name: 'codex-wang' }, + upstream_reasoning_effort: 'xhigh', +} + +const sharedPageStubs = { + AppLayout: layoutStub, + Pagination: true, + Select: true, + DateRangePicker: true, + Icon: iconStub, + UsageStatsCards: chartStub, + ModelDistributionChart: chartStub, + GroupDistributionChart: chartStub, + EndpointDistributionChart: chartStub, + TokenUsageTrend: chartStub, + IpGeoCell: true, + EmptyState: true, +} + +function reasoningCellText(wrapper: ReturnType): string { + return wrapper.get('[data-testid="reasoning-effort-cell"]').text() +} + +describe('usage reasoning effort page display', () => { + beforeEach(() => { + localStorage.clear() + userQuery.mockReset().mockResolvedValue({ items: [userMappedLog], total: 1, pages: 1 }) + userGetStats.mockReset().mockResolvedValue(emptyStats) + userGetDashboardModels.mockReset().mockResolvedValue({ models: [], start_date: '2026-08-25', end_date: '2026-08-25' }) + userGetDashboardSnapshotV2.mockReset().mockResolvedValue({ + generated_at: '2026-08-25T00:00:00Z', + start_date: '2026-08-25', + end_date: '2026-08-25', + granularity: 'hour', + trend: [], + groups: [], + }) + userKeysList.mockReset().mockResolvedValue({ items: [{ id: 1, name: 'user-key' }] }) + userGroupsGetAvailable.mockReset().mockResolvedValue([{ id: 1, name: 'default' }]) + + adminList.mockReset().mockResolvedValue({ items: [adminMappedLog], total: 1, pages: 1 }) + adminGetStats.mockReset().mockResolvedValue(emptyStats) + adminGetSnapshotV2.mockReset().mockResolvedValue({ trend: [], models: [], groups: [] }) + adminGetModelStats.mockReset().mockResolvedValue({ models: [] }) + adminGetById.mockReset().mockResolvedValue({ id: 7, email: 'liu.jialin@code-dance.com' }) + listErrorLogs.mockReset().mockResolvedValue({ items: [], total: 0 }) + }) + + afterEach(() => { + localStorage.clear() + }) + + it('user usage page only shows the requested reasoning effort', async () => { + const wrapper = mount(UserUsageView, { + global: { stubs: sharedPageStubs }, + }) + await flushPromises() + + const cell = reasoningCellText(wrapper) + expect(cell).toContain('Max') + expect(cell).not.toContain('XHigh') + expect(cell).not.toContain('↳') + expect(wrapper.text()).not.toContain('XHigh') + }) + + it('admin usage page shows requested and mapped effort after the column is enabled', async () => { + const wrapper = mount(AdminUsageView, { + global: { + stubs: { + ...sharedPageStubs, + UsageFilters: adminFiltersStub, + UsageExportProgress: true, + UsageCleanupDialog: true, + UserBalanceHistoryModal: true, + UserTokenRanking: true, + OpsErrorLogTable: true, + OpsErrorDetailModal: true, + }, + }, + }) + await flushPromises() + + expect(wrapper.find('[data-testid="reasoning-effort-cell"]').exists()).toBe(false) + + await wrapper.get('[data-testid="usage-column-settings"]').trigger('click') + await wrapper.get('[data-testid="usage-column-toggle-reasoning_effort"]').trigger('click') + await flushPromises() + + const cell = reasoningCellText(wrapper) + expect(cell).toContain('Max') + expect(cell).toContain('XHigh') + expect(cell).toContain('↳') + }) + + it('admin usage page shows a single value when reasoning effort was not mapped', async () => { + adminList.mockResolvedValue({ + items: [{ ...adminMappedLog, request_id: 'req-admin-plain', reasoning_effort: 'high', upstream_reasoning_effort: null }], + total: 1, + pages: 1, + }) + + const wrapper = mount(AdminUsageView, { + global: { + stubs: { + ...sharedPageStubs, + UsageFilters: adminFiltersStub, + UsageExportProgress: true, + UsageCleanupDialog: true, + UserBalanceHistoryModal: true, + UserTokenRanking: true, + OpsErrorLogTable: true, + OpsErrorDetailModal: true, + }, + }, + }) + await flushPromises() + + await wrapper.get('[data-testid="usage-column-settings"]').trigger('click') + await wrapper.get('[data-testid="usage-column-toggle-reasoning_effort"]').trigger('click') + await flushPromises() + + const cell = reasoningCellText(wrapper) + expect(cell).toContain('High') + expect(cell).not.toContain('↳') + }) +}) diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index bdba892581..3a613c3b03 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -91,7 +91,7 @@ diff --git a/frontend/src/views/admin/UsageView.vue b/frontend/src/views/admin/UsageView.vue index 3f8215e134..1fa950e75b 100644 --- a/frontend/src/views/admin/UsageView.vue +++ b/frontend/src/views/admin/UsageView.vue @@ -87,6 +87,7 @@