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.
This commit is contained in:
刘佳林
2026-08-25 15:36:22 +08:00
parent 11ada80d58
commit 5705f4a4aa
8 changed files with 125 additions and 35 deletions
+46 -13
View File
@@ -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
@@ -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) {
+5 -3
View File
@@ -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.
@@ -93,14 +93,14 @@
<template #cell-reasoning_effort="{ row }">
<div v-if="hasReasoningEffortMapping(row)" class="space-y-0.5 text-xs">
<div class="font-medium text-gray-900 dark:text-white">
{{ formatReasoningEffort(row.requested_reasoning_effort) }}
{{ formatReasoningEffort(row.reasoning_effort) }}
</div>
<div class="text-gray-500 dark:text-gray-400">
<span class="mr-0.5">↳</span>{{ formatReasoningEffort(row.reasoning_effort) }}
<span class="mr-0.5">↳</span>{{ formatReasoningEffort(row.upstream_reasoning_effort) }}
</div>
</div>
<span v-else class="text-sm text-gray-900 dark:text-white">
{{ formatReasoningEffort(row.requested_reasoning_effort || row.reasoning_effort) }}
{{ formatReasoningEffort(row.reasoning_effort) }}
</span>
</template>
@@ -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)
}
@@ -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',
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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',
+2 -2
View File
@@ -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),