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