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] =?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),