Merge pull request #6188 from JialinLiu-codedance/feat/usage-requested-reasoning-effort

feat(usage): 使用记录展示映射前的推理强度
This commit is contained in:
Wesley Liddick
2026-08-28 16:18:50 +08:00
committed by GitHub
41 changed files with 1020 additions and 106 deletions
@@ -72,7 +72,36 @@ func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKe
return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, true
}
func bindRequestedReasoningEffort(c *gin.Context, body []byte, model string) {
if c == nil || c.Request == nil {
return
}
effort := service.CanonicalRequestedReasoningEffort(body, model)
if effort == nil {
return
}
c.Request = c.Request.WithContext(service.WithRequestedReasoningEffort(c.Request.Context(), *effort))
}
func stampOpenAIRequestedReasoningEffort(result *service.OpenAIForwardResult, c *gin.Context) {
if result == nil || result.RequestedReasoningEffort != nil {
return
}
if c == nil || c.Request == nil {
return
}
result.RequestedReasoningEffort = service.RequestedReasoningEffortFromContext(c.Request.Context())
}
func stampForwardRequestedReasoningEffort(result *service.ForwardResult, requested *string) {
if result == nil || result.RequestedReasoningEffort != nil {
return
}
result.RequestedReasoningEffort = requested
}
func applyOpenAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool) {
bindRequestedReasoningEffort(c, body, strings.TrimSpace(gjson.GetBytes(body, "model").String()))
maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
if !ok {
return body, false
@@ -84,6 +113,7 @@ func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *s
if c == nil || c.Request == nil {
return
}
bindRequestedReasoningEffort(c, body, strings.TrimSpace(gjson.GetBytes(body, "model").String()))
// The Messages bridge synthesizes a default OpenAI effort when
// output_config.effort is omitted. Bind the group policy only for an
// explicit client value so the ceiling does not alter that default.
@@ -120,6 +120,9 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
got, changed := applyOpenAIReasoningEffortPolicyForRequest(openAICtx, apiKey, body)
require.True(t, changed)
require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(got))
requested := service.RequestedReasoningEffortFromContext(openAICtx.Request.Context())
require.NotNil(t, requested)
require.Equal(t, "max", *requested)
bindOpenAIReasoningEffortPolicyForMessagesRequest(openAICtx, apiKey, []byte(`{"output_config":{"effort":"max"}}`))
bound, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(openAICtx.Request.Context(), body)
+46 -12
View File
@@ -3,6 +3,7 @@ package dto
import (
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -644,7 +645,7 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
RequestID: l.RequestID,
Model: requestedModel,
ServiceTier: l.ServiceTier,
ReasoningEffort: l.ReasoningEffort,
ReasoningEffort: userFacingReasoningEffort(l),
InboundEndpoint: l.InboundEndpoint,
GroupID: l.GroupID,
SubscriptionID: l.SubscriptionID,
@@ -711,20 +712,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,6 +179,61 @@ func TestUsageLogFromService_KeepsUserBillingAndIPWithoutAdminCostFields(t *test
require.NotContains(t, string(userJSON), "account_cost")
}
func TestUsageLogFromService_UsersSeeRequestedReasoningEffortOnly(t *testing.T) {
t.Parallel()
requested := "max"
forwarded := "xhigh"
log := &service.UsageLog{
RequestID: "req_effort",
Model: "gpt-5.4",
ReasoningEffort: &forwarded,
RequestedReasoningEffort: &requested,
}
userDTO := UsageLogFromService(log)
adminDTO := UsageLogFromServiceAdmin(log)
require.NotNil(t, userDTO.ReasoningEffort)
require.Equal(t, requested, *userDTO.ReasoningEffort)
require.NotNil(t, adminDTO.ReasoningEffort)
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) {
t.Parallel()
+5 -1
View File
@@ -489,8 +489,9 @@ 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 request's reasoning effort level.
// 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"`
// InboundEndpoint is the client-facing API endpoint path, e.g. /v1/chat/completions.
InboundEndpoint *string `json:"inbound_endpoint,omitempty"`
@@ -566,6 +567,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.
@@ -169,6 +169,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
body = parsedReq.Body.Bytes()
reqModel := parsedReq.Model
reqStream := parsedReq.Stream
bindRequestedReasoningEffort(c, body, reqModel)
ensureCompositeTargetPlatform(c, apiKey, reqModel)
reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream))
@@ -541,6 +542,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
stampForwardRequestedReasoningEffort(result, service.NormalizeClaudeOutputEffort(parsedReq.OutputEffort))
if result.ReasoningEffort == nil {
result.ReasoningEffort = service.NormalizeClaudeOutputEffort(parsedReq.OutputEffort)
}
@@ -882,6 +884,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
stampForwardRequestedReasoningEffort(result, service.NormalizeClaudeOutputEffort(attemptParsedReq.OutputEffort))
if result.ReasoningEffort == nil {
result.ReasoningEffort = service.NormalizeClaudeOutputEffort(attemptParsedReq.OutputEffort)
}
@@ -76,6 +76,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
return
}
reqModel := modelResult.String()
bindRequestedReasoningEffort(c, body, reqModel)
ensureCompositeTargetPlatform(c, apiKey, reqModel)
if !compositeTargetPlatformResolved(c, apiKey, reqModel) {
h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups")
@@ -335,6 +336,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
stampForwardRequestedReasoningEffort(result, service.RequestedReasoningEffortFromContext(c.Request.Context()))
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
@@ -76,6 +76,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
return
}
reqModel := modelResult.String()
bindRequestedReasoningEffort(c, body, reqModel)
ensureCompositeTargetPlatform(c, apiKey, reqModel)
if !compositeTargetPlatformResolved(c, apiKey, reqModel) {
h.responsesErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups")
@@ -323,6 +324,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
stampForwardRequestedReasoningEffort(result, service.RequestedReasoningEffortFromContext(c.Request.Context()))
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
@@ -267,6 +267,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
if res == nil {
return
}
stampOpenAIRequestedReasoningEffort(res, c)
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
inboundEndpoint := GetInboundEndpoint(c)
@@ -751,6 +751,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
if res == nil {
return
}
stampOpenAIRequestedReasoningEffort(res, c)
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
requestPayloadHash := service.HashUsageRequestPayload(body)
@@ -1315,6 +1316,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
if res == nil {
return
}
stampOpenAIRequestedReasoningEffort(res, c)
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
requestPayloadHash := service.HashUsageRequestPayload(body)
@@ -72,6 +72,7 @@ var usageLogInsertArgTypes = [...]string{
"integer", // video_duration_seconds
"text", // service_tier
"text", // reasoning_effort
"text", // requested_reasoning_effort
"text", // inbound_endpoint
"text", // upstream_endpoint
"boolean", // cache_ttl_overridden
@@ -270,6 +271,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor,
video_duration_seconds,
service_tier,
reasoning_effort,
requested_reasoning_effort,
inbound_endpoint,
upstream_endpoint,
cache_ttl_overridden,
@@ -287,7 +289,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor,
$12, $13, $14, $15,
$16, $17, $18, $19,
$20, $21, $22, $23, $24, $25,
$26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59
$26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60
)
ON CONFLICT (request_id, api_key_id) DO NOTHING
RETURNING id, created_at
@@ -727,6 +729,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
video_duration_seconds,
service_tier,
reasoning_effort,
requested_reasoning_effort,
inbound_endpoint,
upstream_endpoint,
cache_ttl_overridden,
@@ -740,9 +743,9 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
created_at
) AS (VALUES `)
// Each batch row prepends the synthetic input_index before the 59
// Each batch row prepends the synthetic input_index before the 60
// usage-log column values.
args := make([]any, 0, len(keys)*60)
args := make([]any, 0, len(keys)*61)
argPos := 1
for idx, key := range keys {
if idx > 0 {
@@ -819,6 +822,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
video_duration_seconds,
service_tier,
reasoning_effort,
requested_reasoning_effort,
inbound_endpoint,
upstream_endpoint,
cache_ttl_overridden,
@@ -880,6 +884,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
video_duration_seconds,
service_tier,
reasoning_effort,
requested_reasoning_effort,
inbound_endpoint,
upstream_endpoint,
cache_ttl_overridden,
@@ -981,6 +986,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
video_duration_seconds,
service_tier,
reasoning_effort,
requested_reasoning_effort,
inbound_endpoint,
upstream_endpoint,
cache_ttl_overridden,
@@ -994,7 +1000,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
created_at
) AS (VALUES `)
args := make([]any, 0, len(preparedList)*59)
args := make([]any, 0, len(preparedList)*60)
argPos := 1
for idx, prepared := range preparedList {
if idx > 0 {
@@ -1068,6 +1074,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
video_duration_seconds,
service_tier,
reasoning_effort,
requested_reasoning_effort,
inbound_endpoint,
upstream_endpoint,
cache_ttl_overridden,
@@ -1129,6 +1136,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
video_duration_seconds,
service_tier,
reasoning_effort,
requested_reasoning_effort,
inbound_endpoint,
upstream_endpoint,
cache_ttl_overridden,
@@ -1198,6 +1206,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared
video_duration_seconds,
service_tier,
reasoning_effort,
requested_reasoning_effort,
inbound_endpoint,
upstream_endpoint,
cache_ttl_overridden,
@@ -1215,7 +1224,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared
$12, $13, $14, $15,
$16, $17, $18, $19,
$20, $21, $22, $23, $24, $25,
$26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59
$26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60
)
ON CONFLICT (request_id, api_key_id) DO NOTHING
`, prepared.args...)
@@ -1250,6 +1259,7 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared {
videoDurationSeconds := nullInt(log.VideoDurationSeconds)
serviceTier := nullString(log.ServiceTier)
reasoningEffort := nullString(log.ReasoningEffort)
requestedReasoningEffort := nullString(log.RequestedReasoningEffort)
inboundEndpoint := nullString(log.InboundEndpoint)
upstreamEndpoint := nullString(log.UpstreamEndpoint)
channelID := nullInt64(log.ChannelID)
@@ -1324,6 +1334,7 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared {
videoDurationSeconds,
serviceTier,
reasoningEffort,
requestedReasoningEffort,
inboundEndpoint,
upstreamEndpoint,
log.CacheTTLOverridden,
@@ -19,7 +19,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/service"
)
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, upstream_response_model, upstream_model_mismatch, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, session_id, created_at"
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, upstream_response_model, upstream_model_mismatch, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, requested_reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, session_id, created_at"
func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *service.UsageLog, err error) {
query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1"
@@ -488,6 +488,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
videoDurationSeconds sql.NullInt64
serviceTier sql.NullString
reasoningEffort sql.NullString
requestedReasoningEffort sql.NullString
inboundEndpoint sql.NullString
upstreamEndpoint sql.NullString
cacheTTLOverridden bool
@@ -551,6 +552,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
&videoDurationSeconds,
&serviceTier,
&reasoningEffort,
&requestedReasoningEffort,
&inboundEndpoint,
&upstreamEndpoint,
&cacheTTLOverridden,
@@ -656,6 +658,9 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
if reasoningEffort.Valid {
log.ReasoningEffort = &reasoningEffort.String
}
if requestedReasoningEffort.Valid {
log.RequestedReasoningEffort = &requestedReasoningEffort.String
}
if inboundEndpoint.Valid {
log.InboundEndpoint = &inboundEndpoint.String
}
@@ -89,6 +89,7 @@ func TestUsageLogRepositoryCreateSyncRequestTypeAndLegacyFields(t *testing.T) {
sqlmock.AnyArg(), // video_duration_seconds
sqlmock.AnyArg(), // service_tier
sqlmock.AnyArg(), // reasoning_effort
sqlmock.AnyArg(), // requested_reasoning_effort
sqlmock.AnyArg(), // inbound_endpoint
sqlmock.AnyArg(), // upstream_endpoint
log.CacheTTLOverridden,
@@ -180,9 +181,10 @@ func TestUsageLogRepositoryCreate_PersistsServiceTier(t *testing.T) {
sqlmock.AnyArg(), // video_resolution
sqlmock.AnyArg(), // video_duration_seconds
serviceTier,
sqlmock.AnyArg(),
sqlmock.AnyArg(),
sqlmock.AnyArg(),
sqlmock.AnyArg(), // reasoning_effort
sqlmock.AnyArg(), // requested_reasoning_effort
sqlmock.AnyArg(), // inbound_endpoint
sqlmock.AnyArg(), // upstream_endpoint
log.CacheTTLOverridden,
log.LongContextBillingApplied,
sqlmock.AnyArg(), // channel_id
@@ -831,6 +833,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
sql.NullString{},
sql.NullString{},
sql.NullString{},
sql.NullString{},
false,
false,
sql.NullInt64{},
@@ -908,6 +911,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
sql.NullString{},
sql.NullString{},
sql.NullString{},
sql.NullString{},
false,
false,
sql.NullInt64{}, // channel_id
@@ -968,6 +972,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
sql.NullString{},
sql.NullString{},
sql.NullString{},
sql.NullString{},
false,
false,
sql.NullInt64{}, // channel_id
@@ -1028,6 +1033,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
sql.NullString{},
sql.NullString{},
sql.NullString{},
sql.NullString{},
false,
false,
sql.NullInt64{}, // channel_id
@@ -32,7 +32,7 @@ func newSessionIDUsageLog(sessionID *string) *service.UsageLog {
// arg slice / arg-type table so the five INSERT column lists stay in sync. session_id
// is the penultimate arg (created_at is always last).
func TestPrepareUsageLogInsert_SessionIDArgWiring(t *testing.T) {
require.Len(t, usageLogInsertArgTypes, 59, "arg-type table must include session_id")
require.Len(t, usageLogInsertArgTypes, 60, "arg-type table must include session_id")
sessionID := "sess-persisted-123"
prepared := prepareUsageLogInsert(newSessionIDUsageLog(&sessionID))
@@ -66,9 +66,40 @@ func TestPrepareUsageLogInsert_SessionIDNullWhenAbsent(t *testing.T) {
require.False(t, nsEmpty.Valid, "empty session id must also be NULL")
}
func TestPrepareUsageLogInsert_RequestedReasoningEffortArgWiring(t *testing.T) {
requested := "max"
forwarded := "xhigh"
prepared := prepareUsageLogInsert(&service.UsageLog{
UserID: 1,
APIKeyID: 2,
AccountID: 3,
RequestID: "req-requested-effort",
Model: "gpt-5.4",
ReasoningEffort: &forwarded,
RequestedReasoningEffort: &requested,
CreatedAt: time.Now().UTC(),
})
require.Len(t, prepared.args, len(usageLogInsertArgTypes))
require.Equal(t, "text", usageLogInsertArgTypes[48], "requested_reasoning_effort must follow reasoning_effort")
require.Equal(t, "text", usageLogInsertArgTypes[47], "reasoning_effort arg type must stay text")
forwardedArg, ok := prepared.args[47].(sql.NullString)
require.True(t, ok)
require.True(t, forwardedArg.Valid)
require.Equal(t, forwarded, forwardedArg.String)
requestedArg, ok := prepared.args[48].(sql.NullString)
require.True(t, ok)
require.True(t, requestedArg.Valid)
require.Equal(t, requested, requestedArg.String)
}
// TestUsageLogInsertQueries_IncludeSessionID guards that every generated INSERT path
// and the SELECT column list reference session_id.
func TestUsageLogInsertQueries_IncludeSessionID(t *testing.T) {
require.Contains(t, usageLogSelectColumns, "requested_reasoning_effort",
"SELECT column list must include requested_reasoning_effort")
require.Contains(t, usageLogSelectColumns, "session_id",
"SELECT column list must include session_id")
@@ -80,6 +111,7 @@ func TestUsageLogInsertQueries_IncludeSessionID(t *testing.T) {
batchQuery, batchArgs := buildUsageLogBatchInsertQuery([]string{key},
map[string]usageLogInsertPrepared{key: prepared})
require.Contains(t, batchQuery, "session_id")
require.Contains(t, batchQuery, "requested_reasoning_effort")
// Two column references (INSERT column list + SELECT ... FROM input) plus the CTE def.
require.GreaterOrEqual(t, strings.Count(batchQuery, "session_id"), 3)
require.Len(t, batchArgs, len(prepared.args)+1,
@@ -627,6 +627,8 @@ type ForwardResult struct {
FirstTokenMs *int // 首字时间(流式请求)
ClientDisconnect bool // 客户端是否在流式传输过程中断开
ReasoningEffort *string
// RequestedReasoningEffort is the client-requested effort before mapping.
RequestedReasoningEffort *string
// ServiceTier records the tier requested by the client. OpenAI uses
// service_tier; Anthropic speed=fast is normalized to "fast". Usage recording
// lowers it to UpstreamResponseServiceTier when the upstream reports a
@@ -1211,48 +1211,49 @@ func (s *GatewayService) buildRecordUsageLog(
)
}
usageLog := &UsageLog{
UserID: user.ID,
APIKeyID: apiKey.ID,
AccountID: account.ID,
RequestID: requestID,
Model: result.Model,
RequestedModel: requestedModel,
UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel),
UpstreamResponseModel: optionalTrimmedStringPtr(result.UpstreamResponseModel),
UpstreamModelMismatch: upstreamModelMismatch(sentModel, result.UpstreamResponseModel),
ServiceTier: result.ServiceTier,
ReasoningEffort: result.ReasoningEffort,
InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint),
UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint),
InputTokens: result.Usage.InputTokens,
OutputTokens: result.Usage.OutputTokens,
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
CacheReadTokens: result.Usage.CacheReadInputTokens,
CacheCreation5mTokens: result.Usage.CacheCreation5mTokens,
CacheCreation1hTokens: result.Usage.CacheCreation1hTokens,
ImageOutputTokens: result.Usage.ImageOutputTokens,
RateMultiplier: multiplier,
AccountRateMultiplier: &accountRateMultiplier,
BillingType: billingType,
BillingMode: resolveBillingMode(result, cost),
Stream: result.Stream,
DurationMs: &durationMs,
FirstTokenMs: result.FirstTokenMs,
ImageCount: result.ImageCount,
ImageSize: optionalTrimmedStringPtr(result.ImageSize),
ImageInputSize: optionalTrimmedStringPtr(result.ImageInputSize),
ImageOutputSize: optionalTrimmedStringPtr(result.ImageOutputSize),
ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource),
ImageSizeBreakdown: result.ImageSizeBreakdown,
CacheTTLOverridden: cacheTTLOverridden,
ChannelID: optionalInt64Ptr(input.ChannelID),
ModelMappingChain: optionalTrimmedStringPtr(input.ModelMappingChain),
UserAgent: optionalTrimmedStringPtr(input.UserAgent),
IPAddress: optionalTrimmedStringPtr(input.IPAddress),
SessionID: optionalTrimmedStringPtr(input.SessionID),
GroupID: apiKey.GroupID,
SubscriptionID: optionalSubscriptionID(subscription),
CreatedAt: time.Now(),
UserID: user.ID,
APIKeyID: apiKey.ID,
AccountID: account.ID,
RequestID: requestID,
Model: result.Model,
RequestedModel: requestedModel,
UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel),
UpstreamResponseModel: optionalTrimmedStringPtr(result.UpstreamResponseModel),
UpstreamModelMismatch: upstreamModelMismatch(sentModel, result.UpstreamResponseModel),
ServiceTier: result.ServiceTier,
ReasoningEffort: result.ReasoningEffort,
RequestedReasoningEffort: coalesceRequestedReasoningEffort(result.RequestedReasoningEffort, result.ReasoningEffort),
InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint),
UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint),
InputTokens: result.Usage.InputTokens,
OutputTokens: result.Usage.OutputTokens,
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
CacheReadTokens: result.Usage.CacheReadInputTokens,
CacheCreation5mTokens: result.Usage.CacheCreation5mTokens,
CacheCreation1hTokens: result.Usage.CacheCreation1hTokens,
ImageOutputTokens: result.Usage.ImageOutputTokens,
RateMultiplier: multiplier,
AccountRateMultiplier: &accountRateMultiplier,
BillingType: billingType,
BillingMode: resolveBillingMode(result, cost),
Stream: result.Stream,
DurationMs: &durationMs,
FirstTokenMs: result.FirstTokenMs,
ImageCount: result.ImageCount,
ImageSize: optionalTrimmedStringPtr(result.ImageSize),
ImageInputSize: optionalTrimmedStringPtr(result.ImageInputSize),
ImageOutputSize: optionalTrimmedStringPtr(result.ImageOutputSize),
ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource),
ImageSizeBreakdown: result.ImageSizeBreakdown,
CacheTTLOverridden: cacheTTLOverridden,
ChannelID: optionalInt64Ptr(input.ChannelID),
ModelMappingChain: optionalTrimmedStringPtr(input.ModelMappingChain),
UserAgent: optionalTrimmedStringPtr(input.UserAgent),
IPAddress: optionalTrimmedStringPtr(input.IPAddress),
SessionID: optionalTrimmedStringPtr(input.SessionID),
GroupID: apiKey.GroupID,
SubscriptionID: optionalSubscriptionID(subscription),
CreatedAt: time.Now(),
}
if result.ImageCount > 0 && (cost == nil || cost.BillingMode != string(BillingModeToken)) {
usageLog.RateMultiplier = imageMultiplier
@@ -61,8 +61,9 @@ func (s *OpenAIGatewayService) forwardAnthropicViaNativeAnthropicEndpoint(
// 记录客户端请求的推理强度:优先 Claude 协议的 output_config.effort;
// 缺失且 thinking 已启用时,按国产 passback-required 模型兜底为 high
// (对齐 Anthropic 网关 gateway_handler 的记录语义,避免该路径长期落 NULL)。
requestedReasoningEffort := NormalizeClaudeOutputEffort(gjson.GetBytes(body, "output_config.effort").String())
reasoningEffort := ApplyThinkingEnabledFallback(
NormalizeClaudeOutputEffort(gjson.GetBytes(body, "output_config.effort").String()),
requestedReasoningEffort,
body,
billingModel,
)
@@ -1527,6 +1527,8 @@ func TestOpenAIGatewayServiceRecordUsage_UsesRequestedModelAndUpstreamModelMetad
require.Equal(t, serviceTier, *usageRepo.lastLog.ServiceTier)
require.NotNil(t, usageRepo.lastLog.ReasoningEffort)
require.Equal(t, reasoning, *usageRepo.lastLog.ReasoningEffort)
require.NotNil(t, usageRepo.lastLog.RequestedReasoningEffort)
require.Equal(t, reasoning, *usageRepo.lastLog.RequestedReasoningEffort)
require.NotNil(t, usageRepo.lastLog.UserAgent)
require.Equal(t, "codex-cli/1.0", *usageRepo.lastLog.UserAgent)
require.NotNil(t, usageRepo.lastLog.IPAddress)
@@ -1536,6 +1538,39 @@ func TestOpenAIGatewayServiceRecordUsage_UsesRequestedModelAndUpstreamModelMetad
require.Equal(t, 1, userRepo.deductCalls)
}
func TestOpenAIGatewayServiceRecordUsage_PersistsRequestedReasoningEffort(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
userRepo := &openAIRecordUsageUserRepoStub{}
subRepo := &openAIRecordUsageSubRepoStub{}
svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil)
requested := "max"
forwarded := "xhigh"
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
Result: &OpenAIForwardResult{
RequestID: "resp_requested_effort",
Model: "gpt-5.4",
ReasoningEffort: &forwarded,
RequestedReasoningEffort: &requested,
Usage: OpenAIUsage{
InputTokens: 20,
OutputTokens: 10,
},
Duration: time.Second,
},
APIKey: &APIKey{ID: 10},
User: &User{ID: 20},
Account: &Account{ID: 30},
})
require.NoError(t, err)
require.NotNil(t, usageRepo.lastLog)
require.NotNil(t, usageRepo.lastLog.ReasoningEffort)
require.Equal(t, forwarded, *usageRepo.lastLog.ReasoningEffort)
require.NotNil(t, usageRepo.lastLog.RequestedReasoningEffort)
require.Equal(t, requested, *usageRepo.lastLog.RequestedReasoningEffort)
}
func TestOpenAIGatewayServiceRecordUsage_PreservesChannelMappedUpstreamModel(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
userRepo := &openAIRecordUsageUserRepoStub{}
@@ -1350,6 +1350,65 @@ func extractOpenAIReasoningEffortFromBody(body []byte, modelCandidates ...string
return &value
}
func explicitRequestedReasoningEffortFromBody(body []byte) string {
raw := strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String())
if raw == "" {
raw = strings.TrimSpace(gjson.GetBytes(body, "reasoning_effort").String())
}
if raw == "" {
raw = strings.TrimSpace(gjson.GetBytes(body, "output_config.effort").String())
}
return raw
}
// CanonicalRequestedReasoningEffort extracts the client-requested effort before
// group policy rewriting and before model-family remapping (max -> xhigh).
// Empty or unknown values return nil. "max" is preserved even for models that
// later persist "xhigh".
func CanonicalRequestedReasoningEffort(body []byte, modelCandidates ...string) *string {
if raw := explicitRequestedReasoningEffortFromBody(body); raw != "" {
canonical := NormalizeMaxReasoningEffort(raw)
if canonical == "" {
return nil
}
return &canonical
}
for _, model := range modelCandidates {
if value := canonicalReasoningEffortFromModelSuffix(model); value != "" {
return &value
}
}
if model := strings.TrimSpace(gjson.GetBytes(body, "model").String()); model != "" {
if value := canonicalReasoningEffortFromModelSuffix(model); value != "" {
return &value
}
}
return nil
}
func canonicalReasoningEffortFromModelSuffix(model string) string {
if strings.TrimSpace(model) == "" {
return ""
}
modelID := strings.TrimSpace(model)
if strings.Contains(modelID, "/") {
parts := strings.Split(modelID, "/")
modelID = parts[len(parts)-1]
}
parts := strings.FieldsFunc(strings.ToLower(modelID), func(r rune) bool {
switch r {
case '-', '_', ' ':
return true
default:
return false
}
})
if len(parts) == 0 {
return ""
}
return NormalizeMaxReasoningEffort(parts[len(parts)-1])
}
func extractOpenAIServiceTier(reqBody map[string]any) *string {
if reqBody == nil {
return nil
@@ -2019,6 +2078,31 @@ func extractOpenAIReasoningEffort(reqBody map[string]any, modelCandidates ...str
return &value
}
func CanonicalRequestedReasoningEffortFromReqBody(reqBody map[string]any, modelCandidates ...string) *string {
if reqBody == nil {
return CanonicalRequestedReasoningEffort(nil, modelCandidates...)
}
raw := ""
if reasoning, ok := reqBody["reasoning"].(map[string]any); ok {
if effort, ok := reasoning["effort"].(string); ok {
raw = strings.TrimSpace(effort)
}
}
if raw == "" {
if effort, ok := reqBody["reasoning_effort"].(string); ok {
raw = strings.TrimSpace(effort)
}
}
if raw != "" {
canonical := NormalizeMaxReasoningEffort(raw)
if canonical == "" {
return nil
}
return &canonical
}
return CanonicalRequestedReasoningEffort(nil, modelCandidates...)
}
func normalizeOpenAIReasoningEffort(raw string) string {
value := strings.ToLower(strings.TrimSpace(raw))
if value == "" {
@@ -256,11 +256,15 @@ type OpenAIForwardResult struct {
// ServiceTier 优先取上游实际响应回显的 tier;缺失时回退到最终出站 body 的
// tier。nil 表示两者都无识别 tier。
ServiceTier *string
// ReasoningEffort is extracted from request body (reasoning.effort) or derived from model suffix.
// ReasoningEffort is extracted from request body (reasoning.effort) or derived from model suffix
// after group policy rewriting and model-family remapping.
// Stored for usage records display; nil means not provided / not applicable.
ReasoningEffort *string
Stream bool
OpenAIWSMode bool
// RequestedReasoningEffort is the client-requested effort before mapping.
// Empty/nil means it should fall back to ReasoningEffort at persistence.
RequestedReasoningEffort *string
Stream bool
OpenAIWSMode bool
// UpstreamTerminalEvent is the normalized terminal event observed on an
// upstream Responses WebSocket turn. Empty preserves legacy/non-WS success.
UpstreamTerminalEvent string
@@ -321,31 +321,32 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
}
usageLog := &UsageLog{
UserID: user.ID,
APIKeyID: apiKey.ID,
AccountID: account.ID,
RequestID: requestID,
Model: result.Model,
RequestedModel: requestedModel,
UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel),
UpstreamResponseModel: optionalTrimmedStringPtr(result.UpstreamResponseModel),
UpstreamModelMismatch: upstreamModelMismatch(sentModel, result.UpstreamResponseModel),
ServiceTier: result.ServiceTier,
ReasoningEffort: result.ReasoningEffort,
InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint),
UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint),
InputTokens: actualInputTokens,
OutputTokens: result.Usage.OutputTokens,
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
CacheReadTokens: result.Usage.CacheReadInputTokens,
ImageInputTokens: result.Usage.ImageInputTokens,
ImageOutputTokens: result.Usage.ImageOutputTokens,
ImageCount: result.ImageCount,
ImageSize: optionalTrimmedStringPtr(result.ImageSize),
ImageInputSize: optionalTrimmedStringPtr(result.ImageInputSize),
ImageOutputSize: optionalTrimmedStringPtr(result.ImageOutputSize),
ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource),
ImageSizeBreakdown: result.ImageSizeBreakdown,
UserID: user.ID,
APIKeyID: apiKey.ID,
AccountID: account.ID,
RequestID: requestID,
Model: result.Model,
RequestedModel: requestedModel,
UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel),
UpstreamResponseModel: optionalTrimmedStringPtr(result.UpstreamResponseModel),
UpstreamModelMismatch: upstreamModelMismatch(sentModel, result.UpstreamResponseModel),
ServiceTier: result.ServiceTier,
ReasoningEffort: result.ReasoningEffort,
RequestedReasoningEffort: coalesceRequestedReasoningEffort(result.RequestedReasoningEffort, result.ReasoningEffort),
InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint),
UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint),
InputTokens: actualInputTokens,
OutputTokens: result.Usage.OutputTokens,
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
CacheReadTokens: result.Usage.CacheReadInputTokens,
ImageInputTokens: result.Usage.ImageInputTokens,
ImageOutputTokens: result.Usage.ImageOutputTokens,
ImageCount: result.ImageCount,
ImageSize: optionalTrimmedStringPtr(result.ImageSize),
ImageInputSize: optionalTrimmedStringPtr(result.ImageInputSize),
ImageOutputSize: optionalTrimmedStringPtr(result.ImageOutputSize),
ImageSizeSource: optionalTrimmedStringPtr(result.ImageSizeSource),
ImageSizeBreakdown: result.ImageSizeBreakdown,
}
isVideoUsage := isGrokVideoUsageResult(result, billingModels)
if isVideoUsage {
@@ -17,6 +17,7 @@ const (
var openAIReasoningEffortValues = []string{"minimal", "low", "medium", "high", "xhigh", "max"}
type openAIReasoningEffortPolicyContextKey struct{}
type requestedReasoningEffortContextKey struct{}
type openAIReasoningEffortPolicy struct {
maxEffort string
@@ -136,6 +137,36 @@ func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapp
return normalized, nil
}
// WithRequestedReasoningEffort stores the client-requested effort captured from
// the inbound body before group policy or model-family remapping.
func WithRequestedReasoningEffort(ctx context.Context, effort string) context.Context {
if ctx == nil {
ctx = context.Background()
}
effort = strings.TrimSpace(effort)
if effort == "" {
return ctx
}
return context.WithValue(ctx, requestedReasoningEffortContextKey{}, effort)
}
// RequestedReasoningEffortFromContext returns the inbound requested effort bound
// to ctx, or nil when none was captured.
func RequestedReasoningEffortFromContext(ctx context.Context) *string {
if ctx == nil {
return nil
}
value, ok := ctx.Value(requestedReasoningEffortContextKey{}).(string)
if !ok {
return nil
}
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return &value
}
// WithOpenAIReasoningEffortPolicy binds a group policy to a request after its
// concrete target platform has been resolved to OpenAI. The policy is copied so
// retries and asynchronous forwarding cannot observe later slice mutations.
@@ -8,6 +8,34 @@ import (
"github.com/tidwall/gjson"
)
func TestCanonicalRequestedReasoningEffort(t *testing.T) {
t.Parallel()
max := CanonicalRequestedReasoningEffort([]byte(`{"model":"gpt-5.4","reasoning":{"effort":"MAX"}}`), "gpt-5.4")
require.NotNil(t, max)
require.Equal(t, "max", *max)
fromSuffix := CanonicalRequestedReasoningEffort([]byte(`{"model":"gpt-5.4-max"}`), "gpt-5.4-max")
require.NotNil(t, fromSuffix)
require.Equal(t, "max", *fromSuffix)
claude := CanonicalRequestedReasoningEffort([]byte(`{"model":"claude-sonnet-4","output_config":{"effort":"high"}}`))
require.NotNil(t, claude)
require.Equal(t, "high", *claude)
require.Nil(t, CanonicalRequestedReasoningEffort([]byte(`{"model":"gpt-5.4"}`), "gpt-5.4"))
}
func TestRequestedReasoningEffortContext(t *testing.T) {
t.Parallel()
require.Nil(t, RequestedReasoningEffortFromContext(context.Background()))
ctx := WithRequestedReasoningEffort(context.Background(), " max ")
got := RequestedReasoningEffortFromContext(ctx)
require.NotNil(t, got)
require.Equal(t, "max", *got)
}
func TestNormalizeMaxReasoningEffort(t *testing.T) {
tests := []struct {
name string
@@ -194,6 +194,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
imageSizeTier string
imageInputSize string
payloadBytes int
requestedReasoningEffort *string
}
ingressSessionOriginalModel := ""
@@ -257,6 +258,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
nil,
)
}
requestedReasoningEffort := CanonicalRequestedReasoningEffort(normalized, strings.TrimSpace(values[1].String()))
if hooks != nil && (hooks.MaxReasoningEffort != "" || len(hooks.ReasoningEffortMappings) > 0) {
if capped, changed := ApplyOpenAIReasoningEffortPolicy(normalized, hooks.MaxReasoningEffort, hooks.ReasoningEffortMappings); changed {
normalized = capped
@@ -473,6 +475,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
imageSizeTier: imageSizeTier,
imageInputSize: imageInputSize,
payloadBytes: len(normalized),
requestedReasoningEffort: requestedReasoningEffort,
}, nil
}
@@ -909,7 +912,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
}
var rejectedFieldRetryState *openAIResponsesRejectedFieldRetryState
sendAndRelay := func(turn int, lease *openAIWSConnLease, payload []byte, payloadBytes int, originalModel string, imageBillingModel string, imageSizeTier string, imageInputSize string) (*OpenAIForwardResult, error) {
sendAndRelay := func(turn int, lease *openAIWSConnLease, payload []byte, payloadBytes int, originalModel string, imageBillingModel string, imageSizeTier string, imageInputSize string, requestedReasoningEffort *string) (*OpenAIForwardResult, error) {
responseModelObserver := &upstreamResponseModelObserver{}
if lease == nil {
return nil, errors.New("upstream websocket lease is nil")
@@ -1180,6 +1183,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
UpstreamResponseServiceTier: responseModelObserver.ServiceTier(),
ServiceTier: resolvedOpenAIUpstreamServiceTierFromObserver(responseModelObserver, extractOpenAIServiceTierFromBody(payload)),
ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(payload, mappedModel, originalModel), payload, mappedModel),
RequestedReasoningEffort: requestedReasoningEffort,
Stream: reqStream,
OpenAIWSMode: true,
UpstreamTerminalEvent: terminalEvent,
@@ -1209,6 +1213,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
currentImageSizeTier := firstPayload.imageSizeTier
currentImageInputSize := firstPayload.imageInputSize
currentPayloadBytes := firstPayload.payloadBytes
currentRequestedReasoningEffort := firstPayload.requestedReasoningEffort
isStrictAffinityTurn := func(payload []byte) bool {
if !storeDisabled {
return false
@@ -1697,7 +1702,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
)
}
result, relayErr := sendAndRelay(turn, sessionLease, currentPayload, currentPayloadBytes, currentOriginalModel, currentImageBillingModel, currentImageSizeTier, currentImageInputSize)
result, relayErr := sendAndRelay(turn, sessionLease, currentPayload, currentPayloadBytes, currentOriginalModel, currentImageBillingModel, currentImageSizeTier, currentImageInputSize, currentRequestedReasoningEffort)
if relayErr != nil {
lastTurnClean = false
if isOpenAIWSSessionPreempted(ctx) {
@@ -1859,6 +1864,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
currentImageSizeTier = nextPayload.imageSizeTier
currentImageInputSize = nextPayload.imageInputSize
currentPayloadBytes = nextPayload.payloadBytes
currentRequestedReasoningEffort = nextPayload.requestedReasoningEffort
rejectedFieldRetryState = newOpenAIResponsesRejectedFieldRetryState(currentPayload)
storeDisabled = s.isOpenAIWSStoreDisabledInRequestRaw(currentPayload, account)
if !storeDisabled {
@@ -784,6 +784,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
ImageOutputSizes: imageCounter.Sizes(),
ServiceTier: resolvedOpenAIUpstreamServiceTierFromObserver(responseModelObserver, extractOpenAIServiceTier(reqBody)),
ReasoningEffort: extractOpenAIReasoningEffort(reqBody, mappedModel, originalModel),
RequestedReasoningEffort: CanonicalRequestedReasoningEffortFromReqBody(reqBody, originalModel, mappedModel),
Stream: reqStream,
OpenAIWSMode: true,
UpstreamTerminalEvent: upstreamTerminalEvent,
@@ -526,6 +526,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
UpstreamResponseServiceTier: responseModelObserver.ServiceTier(),
ServiceTier: resolvedOpenAIUpstreamServiceTierFromObserver(responseModelObserver, extractOpenAIServiceTierFromBody(body)),
ReasoningEffort: ApplyThinkingEnabledFallback(extractOpenAIReasoningEffortFromBody(body, mappedModel, originalModel), body, mappedModel),
RequestedReasoningEffort: CanonicalRequestedReasoningEffort(body, originalModel, mappedModel),
Stream: reqStream,
OpenAIWSMode: true,
UpstreamTerminalEvent: upstreamTerminalEvent,
@@ -133,10 +133,11 @@ func openAIWSPassthroughPolicyModelFromSessionFrame(account *Account, payload []
}
type openAIWSPassthroughUsageMeta struct {
serviceTier atomic.Pointer[string]
reasoningEffort atomic.Pointer[string]
requestModel atomic.Pointer[string]
upstreamModel atomic.Pointer[string]
serviceTier atomic.Pointer[string]
reasoningEffort atomic.Pointer[string]
requestedReasoningEffort atomic.Pointer[string]
requestModel atomic.Pointer[string]
upstreamModel atomic.Pointer[string]
// 仅在 client->upstream filter goroutine 中读写;Load 侧通过上方原子指针同步。
sessionRequestModel string
@@ -161,6 +162,14 @@ func (m *openAIWSPassthroughUsageMeta) initFromFirstFrame(policyOutput []byte, m
m.storeTurnModels(m.sessionRequestModel, policyOutput)
}
func (m *openAIWSPassthroughUsageMeta) captureRequestedReasoningEffort(originalBody []byte, modelCandidates ...string) {
if m == nil {
return
}
candidates := append([]string{m.sessionRequestModel}, modelCandidates...)
m.requestedReasoningEffort.Store(CanonicalRequestedReasoningEffort(originalBody, candidates...))
}
func (m *openAIWSPassthroughUsageMeta) updateSessionRequestModel(payload []byte) {
if m == nil {
return
@@ -686,6 +695,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
}
firstClientMessage = liteFirstMessage
}
originalFirstClientMessage := firstClientMessage
if hooks != nil && (hooks.MaxReasoningEffort != "" || len(hooks.ReasoningEffortMappings) > 0) {
if capped, changed := ApplyOpenAIReasoningEffortPolicy(firstClientMessage, hooks.MaxReasoningEffort, hooks.ReasoningEffortMappings); changed {
firstClientMessage = capped
@@ -796,6 +806,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
// goroutine)和 OnTurnComplete / final result(runUpstreamToClient
// goroutine)之间同步当前 turn 的 usage metadata。
usageMeta.initFromFirstFrame(firstClientMessage, capturedSessionModel)
usageMeta.captureRequestedReasoningEffort(originalFirstClientMessage, capturedSessionModel)
_, initialUpstreamModel := usageMeta.turnModels(initialRequestModel)
SetOpsUpstreamModel(c, initialUpstreamModel)
wsURL, err := s.buildOpenAIResponsesWSURL(account)
@@ -1020,11 +1031,13 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
}
payload = litePayload
}
originalResponseCreate := payload
if hooks != nil && (hooks.MaxReasoningEffort != "" || len(hooks.ReasoningEffortMappings) > 0) {
if capped, changed := ApplyOpenAIReasoningEffortPolicy(payload, hooks.MaxReasoningEffort, hooks.ReasoningEffortMappings); changed {
payload = capped
}
}
usageMeta.captureRequestedReasoningEffort(originalResponseCreate)
}
turnNo := int(completedTurns.Load()) + 1
if turnNo < 2 {
@@ -1201,6 +1214,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
UpstreamResponseServiceTier: normalizeObservedOpenAIServiceTier(turn.ResponseServiceTier),
ServiceTier: usageMeta.serviceTier.Load(),
ReasoningEffort: usageMeta.reasoningEffort.Load(),
RequestedReasoningEffort: usageMeta.requestedReasoningEffort.Load(),
Stream: true,
OpenAIWSMode: true,
UpstreamTerminalEvent: normalizeOpenAIWSTerminalEvent(turn.TerminalEventType),
@@ -1334,6 +1348,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
UpstreamResponseServiceTier: normalizeObservedOpenAIServiceTier(relayResult.ResponseServiceTier),
ServiceTier: usageMeta.serviceTier.Load(),
ReasoningEffort: usageMeta.reasoningEffort.Load(),
RequestedReasoningEffort: usageMeta.requestedReasoningEffort.Load(),
Stream: true,
OpenAIWSMode: true,
UpstreamTerminalEvent: normalizeOpenAIWSTerminalEvent(relayResult.TerminalEventType),
@@ -24,10 +24,14 @@ func TestWSPassthroughUsageMeta_InitFromFirstFrame_NonGPT56FallsBackToXHigh(t *t
meta := newOpenAIWSPassthroughUsageMeta("gpt-5.4", body)
meta.initFromFirstFrame(body, "gpt-5.4")
meta.captureRequestedReasoningEffort(body, "gpt-5.4")
got := meta.reasoningEffort.Load()
require.NotNil(t, got)
require.Equal(t, "xhigh", *got, "non-5.6 model should normalize max to xhigh")
requested := meta.requestedReasoningEffort.Load()
require.NotNil(t, requested)
require.Equal(t, "max", *requested, "usage should keep the pre-mapping requested effort")
}
func TestWSPassthroughUsageMeta_UpdateFromResponseCreate_MappedModelCandidate(t *testing.T) {
+5 -1
View File
@@ -131,10 +131,14 @@ type UsageLog struct {
// ServiceTier records the billable request tier, e.g. OpenAI "priority" / "flex"
// or Anthropic "fast".
ServiceTier *string
// ReasoningEffort is the request's reasoning effort level.
// ReasoningEffort is the effective effort recorded for this request after
// group policy rewriting and model-family remapping (e.g. max -> xhigh).
// OpenAI: "low" / "medium" / "high" / "xhigh"; Claude: "low" / "medium" / "high" / "max".
// Nil means not provided / not applicable.
ReasoningEffort *string
// RequestedReasoningEffort is the client-requested effort before mapping.
// Nil means historical rows, or that no explicit/suffix-derived effort was observed.
RequestedReasoningEffort *string
// InboundEndpoint is the client-facing API endpoint path, e.g. /v1/chat/completions.
InboundEndpoint *string
// UpstreamEndpoint is the normalized upstream endpoint path, e.g. /v1/responses.
@@ -17,6 +17,18 @@ func optionalStringValue(value *string) string {
return strings.TrimSpace(*value)
}
// coalesceRequestedReasoningEffort prefers the client-requested value and falls
// back to the effective/forwarded effort for historical or unmapped rows.
func coalesceRequestedReasoningEffort(requested, forwarded *string) *string {
if trimmed := optionalStringValue(requested); trimmed != "" {
return &trimmed
}
if trimmed := optionalStringValue(forwarded); trimmed != "" {
return &trimmed
}
return nil
}
func forwardResultBillingModel(requestedModel, upstreamModel string) string {
if trimmed := strings.TrimSpace(requestedModel); trimmed != "" {
return trimmed
@@ -0,0 +1,7 @@
-- Persist the client-requested reasoning effort before group policy rewriting
-- and model-family remapping (e.g. max -> xhigh). NULL means historical rows
-- written before this dual-write, or requests that never declared an effort.
--
-- Nullable with no default: on PostgreSQL 11+ this is a metadata-only change
-- and does not rewrite the (potentially large, partitioned) usage_logs table.
ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS requested_reasoning_effort VARCHAR(20);
@@ -0,0 +1,322 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import UserUsageView from '@/views/user/UsageView.vue'
import AdminUsageView from '@/views/admin/UsageView.vue'
const {
userQuery,
userGetStats,
userGetDashboardModels,
userGetDashboardSnapshotV2,
userKeysList,
userGroupsGetAvailable,
adminList,
adminGetStats,
adminGetSnapshotV2,
adminGetModelStats,
adminGetById,
listErrorLogs,
} = vi.hoisted(() => ({
userQuery: vi.fn(),
userGetStats: vi.fn(),
userGetDashboardModels: vi.fn(),
userGetDashboardSnapshotV2: vi.fn(),
userKeysList: vi.fn(),
userGroupsGetAvailable: vi.fn(),
adminList: vi.fn(),
adminGetStats: vi.fn(),
adminGetSnapshotV2: vi.fn(),
adminGetModelStats: vi.fn(),
adminGetById: vi.fn(),
listErrorLogs: vi.fn(),
}))
const messages: Record<string, string> = {
'admin.dashboard.timeRange': 'Time range',
'admin.dashboard.granularity': 'Granularity',
'admin.dashboard.day': 'Day',
'admin.dashboard.hour': 'Hour',
'admin.users.columnSettings': 'Columns',
'admin.usage.group': 'Group',
'admin.usage.user': 'User',
'admin.usage.account': 'Account',
'admin.usage.billingType': 'Billing type',
'admin.usage.billingMode': 'Billing mode',
'admin.usage.allTypes': 'All types',
'admin.usage.allBillingTypes': 'All billing types',
'admin.usage.billingTypeBalance': 'Balance',
'admin.usage.billingTypeSubscription': 'Subscription',
'admin.usage.allBillingModes': 'All billing modes',
'admin.usage.billingModeToken': 'Token',
'admin.usage.billingModePerRequest': 'Per request',
'admin.usage.billingModeImage': 'Image',
'admin.usage.allGroups': 'All groups',
'admin.usage.allModels': 'All models',
'admin.usage.ipAddress': 'IP',
'usage.allApiKeys': 'All API Keys',
'usage.apiKeyFilter': 'API Key',
'usage.model': 'Model',
'usage.type': 'Type',
'usage.reasoningEffort': 'Reasoning Effort',
'usage.endpoint': 'Endpoint',
'usage.tokens': 'Tokens',
'usage.cost': 'Cost',
'usage.latency': 'Latency',
'usage.time': 'Time',
'usage.userAgent': 'User Agent',
'usage.ws': 'WS',
'usage.stream': 'Stream',
'usage.sync': 'Sync',
'usage.exporting': 'Exporting',
'usage.exportCsv': 'Export CSV',
'usage.failedToLoad': 'Failed to load',
'usage.noRecords': 'No records',
'empty.noData': 'No data',
'common.refresh': 'Refresh',
'common.reset': 'Reset',
}
vi.mock('@/api', () => ({
usageAPI: {
query: userQuery,
getStats: userGetStats,
getDashboardModels: userGetDashboardModels,
getDashboardSnapshotV2: userGetDashboardSnapshotV2,
},
keysAPI: {
list: userKeysList,
},
userGroupsAPI: {
getAvailable: userGroupsGetAvailable,
},
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
usage: {
list: adminList,
getStats: adminGetStats,
},
dashboard: {
getSnapshotV2: adminGetSnapshotV2,
getModelStats: adminGetModelStats,
},
users: {
getById: adminGetById,
},
},
}))
vi.mock('@/api/admin/usage', () => ({
adminUsageAPI: {
list: adminList,
},
}))
vi.mock('@/api/admin/ops', () => ({
listErrorLogs,
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError: vi.fn(),
showWarning: vi.fn(),
showSuccess: vi.fn(),
showInfo: vi.fn(),
}),
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => messages[key] ?? key,
}),
}
})
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
}))
const layoutStub = { template: '<div><slot /></div>' }
const chartStub = { template: '<div />' }
const iconStub = { template: '<span />' }
const adminFiltersStub = defineComponent({
template: '<div><slot name="after-reset" /></div>',
})
const emptyStats = {
total_requests: 1,
total_input_tokens: 10,
total_output_tokens: 20,
total_cache_tokens: 0,
total_tokens: 30,
total_cost: 0.1,
total_actual_cost: 0.08,
average_duration_ms: 12,
endpoints: [],
upstream_endpoints: [],
endpoint_paths: [],
}
const userMappedLog = {
id: 11,
request_id: 'req-user-mapped',
created_at: '2026-08-25T00:00:00Z',
model: 'gpt-5.4',
reasoning_effort: 'max',
actual_cost: 0.01,
total_cost: 0.01,
rate_multiplier: 1,
input_cost: 0.01,
output_cost: 0,
cache_creation_cost: 0,
cache_read_cost: 0,
input_tokens: 8,
output_tokens: 2,
cache_creation_tokens: 0,
cache_read_tokens: 0,
cache_creation_5m_tokens: 0,
cache_creation_1h_tokens: 0,
image_count: 0,
billing_mode: 'token',
request_type: 'sync',
stream: false,
api_key: { name: 'user-key' },
}
const adminMappedLog = {
...userMappedLog,
request_id: 'req-admin-mapped',
user_id: 7,
user: { email: 'liu.jialin@code-dance.com' },
account: { name: 'codex-wang' },
upstream_reasoning_effort: 'xhigh',
}
const sharedPageStubs = {
AppLayout: layoutStub,
Pagination: true,
Select: true,
DateRangePicker: true,
Icon: iconStub,
UsageStatsCards: chartStub,
ModelDistributionChart: chartStub,
GroupDistributionChart: chartStub,
EndpointDistributionChart: chartStub,
TokenUsageTrend: chartStub,
IpGeoCell: true,
EmptyState: true,
}
function reasoningCellText(wrapper: ReturnType<typeof mount>): string {
return wrapper.get('[data-testid="reasoning-effort-cell"]').text()
}
describe('usage reasoning effort page display', () => {
beforeEach(() => {
localStorage.clear()
userQuery.mockReset().mockResolvedValue({ items: [userMappedLog], total: 1, pages: 1 })
userGetStats.mockReset().mockResolvedValue(emptyStats)
userGetDashboardModels.mockReset().mockResolvedValue({ models: [], start_date: '2026-08-25', end_date: '2026-08-25' })
userGetDashboardSnapshotV2.mockReset().mockResolvedValue({
generated_at: '2026-08-25T00:00:00Z',
start_date: '2026-08-25',
end_date: '2026-08-25',
granularity: 'hour',
trend: [],
groups: [],
})
userKeysList.mockReset().mockResolvedValue({ items: [{ id: 1, name: 'user-key' }] })
userGroupsGetAvailable.mockReset().mockResolvedValue([{ id: 1, name: 'default' }])
adminList.mockReset().mockResolvedValue({ items: [adminMappedLog], total: 1, pages: 1 })
adminGetStats.mockReset().mockResolvedValue(emptyStats)
adminGetSnapshotV2.mockReset().mockResolvedValue({ trend: [], models: [], groups: [] })
adminGetModelStats.mockReset().mockResolvedValue({ models: [] })
adminGetById.mockReset().mockResolvedValue({ id: 7, email: 'liu.jialin@code-dance.com' })
listErrorLogs.mockReset().mockResolvedValue({ items: [], total: 0 })
})
afterEach(() => {
localStorage.clear()
})
it('user usage page only shows the requested reasoning effort', async () => {
const wrapper = mount(UserUsageView, {
global: { stubs: sharedPageStubs },
})
await flushPromises()
const cell = reasoningCellText(wrapper)
expect(cell).toContain('Max')
expect(cell).not.toContain('XHigh')
expect(cell).not.toContain('↳')
expect(wrapper.text()).not.toContain('XHigh')
})
it('admin usage page shows requested and mapped effort after the column is enabled', async () => {
const wrapper = mount(AdminUsageView, {
global: {
stubs: {
...sharedPageStubs,
UsageFilters: adminFiltersStub,
UsageExportProgress: true,
UsageCleanupDialog: true,
UserBalanceHistoryModal: true,
UserTokenRanking: true,
OpsErrorLogTable: true,
OpsErrorDetailModal: true,
},
},
})
await flushPromises()
expect(wrapper.find('[data-testid="reasoning-effort-cell"]').exists()).toBe(false)
await wrapper.get('[data-testid="usage-column-settings"]').trigger('click')
await wrapper.get('[data-testid="usage-column-toggle-reasoning_effort"]').trigger('click')
await flushPromises()
const cell = reasoningCellText(wrapper)
expect(cell).toContain('Max')
expect(cell).toContain('XHigh')
expect(cell).toContain('↳')
})
it('admin usage page shows a single value when reasoning effort was not mapped', async () => {
adminList.mockResolvedValue({
items: [{ ...adminMappedLog, request_id: 'req-admin-plain', reasoning_effort: 'high', upstream_reasoning_effort: null }],
total: 1,
pages: 1,
})
const wrapper = mount(AdminUsageView, {
global: {
stubs: {
...sharedPageStubs,
UsageFilters: adminFiltersStub,
UsageExportProgress: true,
UsageCleanupDialog: true,
UserBalanceHistoryModal: true,
UserTokenRanking: true,
OpsErrorLogTable: true,
OpsErrorDetailModal: true,
},
},
})
await flushPromises()
await wrapper.get('[data-testid="usage-column-settings"]').trigger('click')
await wrapper.get('[data-testid="usage-column-toggle-reasoning_effort"]').trigger('click')
await flushPromises()
const cell = reasoningCellText(wrapper)
expect(cell).toContain('High')
expect(cell).not.toContain('↳')
})
})
@@ -91,7 +91,15 @@
</template>
<template #cell-reasoning_effort="{ row }">
<span class="text-sm text-gray-900 dark:text-white">
<div v-if="hasReasoningEffortMapping(row)" data-testid="reasoning-effort-cell" class="space-y-0.5 text-xs">
<div class="font-medium text-gray-900 dark:text-white">
{{ formatReasoningEffort(row.reasoning_effort) }}
</div>
<div class="text-gray-500 dark:text-gray-400">
<span class="mr-0.5">↳</span>{{ formatReasoningEffort(row.upstream_reasoning_effort) }}
</div>
</div>
<span v-else data-testid="reasoning-effort-cell" class="text-sm text-gray-900 dark:text-white">
{{ formatReasoningEffort(row.reasoning_effort) }}
</span>
</template>
@@ -500,7 +508,7 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { formatDateTime, formatReasoningEffort } from '@/utils/format'
import { formatDateTime, formatReasoningEffort, reasoningEffortValuesEqual } from '@/utils/format'
import { formatCacheTokens, formatMultiplier } from '@/utils/formatters'
import { formatTokenPricePerMillion } from '@/utils/usagePricing'
import { getUsageServiceTierLabel } from '@/utils/usageServiceTier'
@@ -587,6 +595,12 @@ const ipGeoBatchLoading = ref(false)
const showIpGeoToolbar = computed(() => props.columns.some((col) => col.key === 'ip_address'))
const hasReasoningEffortMapping = (row: AdminUsageLog): boolean => {
const requested = row.reasoning_effort?.trim() || ''
const forwarded = row.upstream_reasoning_effort?.trim() || ''
return requested !== '' && forwarded !== '' && !reasoningEffortValuesEqual(requested, forwarded)
}
const sentUpstreamModel = (row: AdminUsageLog): string => row.upstream_model?.trim() || row.model?.trim() || ''
const normalizeModelVariant = (model: string): string => model
@@ -84,6 +84,7 @@ const DataTableStub = {
<div>
<div v-for="row in data" :key="row.request_id">
<slot name="cell-model" :row="row" :value="row.model" />
<slot name="cell-reasoning_effort" :row="row" :value="row.reasoning_effort" />
<slot name="cell-billing_mode" :row="row" />
<slot name="cell-tokens" :row="row" />
<slot name="cell-cost" :row="row" />
@@ -256,6 +257,86 @@ describe('admin UsageTable tooltip', () => {
expect(text).toContain('claude-sonnet-4-20250514')
})
it('shows requested and forwarded reasoning effort separately when they differ', () => {
const wrapper = mount(UsageTable, {
props: {
data: [{
request_id: 'req-admin-effort-1',
model: 'gpt-5.4',
reasoning_effort: 'max',
upstream_reasoning_effort: 'xhigh',
}],
loading: false,
columns: [],
},
global: {
stubs: {
DataTable: DataTableStub,
EmptyState: true,
Icon: true,
Teleport: true,
},
},
})
const text = wrapper.text()
expect(text).toContain('Max')
expect(text).toContain('XHigh')
expect(text).toContain('↳')
})
it('shows a single reasoning effort when requested matches forwarded', () => {
const wrapper = mount(UsageTable, {
props: {
data: [{
request_id: 'req-admin-effort-2',
model: 'gpt-5.6-sol',
reasoning_effort: 'max',
}],
loading: false,
columns: [],
},
global: {
stubs: {
DataTable: DataTableStub,
EmptyState: true,
Icon: true,
Teleport: true,
},
},
})
const text = wrapper.text()
expect(text).toContain('Max')
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',
@@ -561,6 +642,7 @@ const DataTableStubWithUser = {
<div v-for="row in data" :key="row.request_id">
<slot name="cell-user" :row="row" />
<slot name="cell-model" :row="row" :value="row.model" />
<slot name="cell-reasoning_effort" :row="row" :value="row.reasoning_effort" />
<slot name="cell-billing_mode" :row="row" />
<slot name="cell-tokens" :row="row" />
<slot name="cell-cost" :row="row" />
@@ -335,6 +335,7 @@ export default {
modelVariant: 'Possible version variant',
modelMismatch: 'Different model',
reasoningEffort: 'Reasoning Effort',
requestedReasoningEffort: 'Requested reasoning effort',
endpoint: 'Endpoint',
endpointDistribution: 'Endpoint Distribution',
inbound: 'Inbound',
@@ -340,6 +340,7 @@ export default {
modelVariant: '疑似版本变体',
modelMismatch: '模型不一致',
reasoningEffort: '推理强度',
requestedReasoningEffort: '请求推理强度',
endpoint: '端点',
endpointDistribution: '端点分布',
inbound: '入站',
+1
View File
@@ -1708,6 +1708,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
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import {
formatReasoningEffort,
formatReasoningEffortMapping,
reasoningEffortValuesEqual,
} from '@/utils/format'
describe('formatReasoningEffort', () => {
it('title-cases known effort values', () => {
expect(formatReasoningEffort('max')).toBe('Max')
expect(formatReasoningEffort('x-high')).toBe('XHigh')
expect(formatReasoningEffort(null)).toBe('-')
})
})
describe('formatReasoningEffortMapping', () => {
it('shows a single value when requested and forwarded match', () => {
expect(formatReasoningEffortMapping('max', 'max')).toBe('Max')
expect(formatReasoningEffortMapping(null, 'high')).toBe('High')
})
it('shows requested then forwarded when mapping changed the value', () => {
expect(formatReasoningEffortMapping('max', 'xhigh')).toBe('Max → XHigh')
expect(formatReasoningEffortMapping('high', 'medium')).toBe('High → Medium')
})
})
describe('reasoningEffortValuesEqual', () => {
it('treats x-high aliases as equal', () => {
expect(reasoningEffortValuesEqual('x-high', 'xhigh')).toBe(true)
expect(reasoningEffortValuesEqual('max', 'xhigh')).toBe(false)
})
})
+30 -1
View File
@@ -211,11 +211,15 @@ export function parseDateTimeLocalInput(value: string): number | null {
* @param effort 原始 effort(如 "low" / "medium" / "high" / "xhigh")
* @returns 格式化后的字符串(Low / Medium / High / Xhigh),无值返回 "-"
*/
function normalizeReasoningEffortKey(effort: string | null | undefined): string {
return (effort ?? '').toString().trim().toLowerCase().replace(/[-_\s]/g, '')
}
export function formatReasoningEffort(effort: string | null | undefined): string {
const raw = (effort ?? '').toString().trim()
if (!raw) return '-'
const normalized = raw.toLowerCase().replace(/[-_\s]/g, '')
const normalized = normalizeReasoningEffortKey(raw)
switch (normalized) {
case 'low':
return 'Low'
@@ -237,6 +241,31 @@ export function formatReasoningEffort(effort: string | null | undefined): string
}
}
export function reasoningEffortValuesEqual(
left: string | null | undefined,
right: string | null | undefined,
): boolean {
const a = normalizeReasoningEffortKey(left)
const b = normalizeReasoningEffortKey(right)
if (!a && !b) return true
return a !== '' && a === b
}
/** Requested vs forwarded effort for usage export; one value when they match. */
export function formatReasoningEffortMapping(
requested: string | null | undefined,
forwarded: string | null | undefined,
): string {
const requestedLabel = formatReasoningEffort(requested)
const forwardedLabel = formatReasoningEffort(forwarded)
if (requestedLabel === '-' && forwardedLabel === '-') return '-'
if (requestedLabel === '-' || reasoningEffortValuesEqual(requested, forwarded)) {
return forwardedLabel === '-' ? requestedLabel : forwardedLabel
}
if (forwardedLabel === '-') return requestedLabel
return `${requestedLabel} → ${forwardedLabel}`
}
/**
* 格式化时间(显示时分秒)
* @param date 日期字符串或 Date 对象
+4 -2
View File
@@ -87,6 +87,7 @@
<template #after-reset>
<div v-if="activeTab !== 'ranking'" class="relative" ref="columnDropdownRef">
<button
data-testid="usage-column-settings"
@click="showColumnDropdown = !showColumnDropdown"
class="btn btn-secondary px-2 md:px-3"
:title="t('admin.users.columnSettings')"
@@ -103,6 +104,7 @@
<button
v-for="col in currentToggleableColumns"
:key="col.key"
:data-testid="`usage-column-toggle-${col.key}`"
@click="toggleCurrentColumn(col.key)"
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>
@@ -575,7 +577,7 @@ const exportToExcel = async () => {
const XLSX = await import('xlsx')
const headers = [
t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'),
t('admin.usage.account'), t('usage.requestedModel'), t('usage.sentUpstreamModel'), t('usage.upstreamResponseModel'), t('usage.upstreamModelMismatch'), t('usage.reasoningEffort'), t('admin.usage.group'),
t('admin.usage.account'), t('usage.requestedModel'), t('usage.sentUpstreamModel'), t('usage.upstreamResponseModel'), t('usage.upstreamModelMismatch'), t('usage.requestedReasoningEffort'), t('usage.reasoningEffort'), t('admin.usage.group'),
t('usage.inboundEndpoint'), t('usage.upstreamEndpoint'),
t('usage.type'),
t('admin.usage.inputTokens'), t('admin.usage.outputTokens'),
@@ -595,7 +597,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.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
View File
@@ -131,6 +131,7 @@
<div class="relative" ref="columnDropdownRef">
<button
type="button"
data-testid="usage-column-settings"
@click="showColumnDropdown = !showColumnDropdown"
class="btn btn-secondary px-2 md:px-3"
:title="t('admin.users.columnSettings')"
@@ -146,6 +147,7 @@
v-for="col in currentToggleableColumns"
:key="col.key"
type="button"
:data-testid="`usage-column-toggle-${col.key}`"
@click="toggleCurrentColumn(col.key)"
class="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
>