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