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.