mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:28:39 +08:00
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:
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user