feat(usage): 使用记录展示映射前的推理强度

用量行新增 requested_reasoning_effort,记录分组策略与模型族改写前的客户端请求值;推理强度列与模型列一致,主行显示请求值,有映射时用 ↳ 展示实际转发值。
This commit is contained in:
刘佳林
2026-08-25 15:10:52 +08:00
parent aa2c4e8d13
commit 11ada80d58
40 changed files with 595 additions and 97 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)
+1
View File
@@ -644,6 +644,7 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
Model: requestedModel,
ServiceTier: l.ServiceTier,
ReasoningEffort: l.ReasoningEffort,
RequestedReasoningEffort: l.RequestedReasoningEffort,
InboundEndpoint: l.InboundEndpoint,
GroupID: l.GroupID,
SubscriptionID: l.SubscriptionID,
@@ -179,6 +179,31 @@ func TestUsageLogFromService_KeepsUserBillingAndIPWithoutAdminCostFields(t *test
require.NotContains(t, string(userJSON), "account_cost")
}
func TestUsageLogFromService_IncludesRequestedReasoningEffort(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, forwarded, *userDTO.ReasoningEffort)
require.NotNil(t, userDTO.RequestedReasoningEffort)
require.Equal(t, requested, *userDTO.RequestedReasoningEffort)
require.NotNil(t, adminDTO.ReasoningEffort)
require.Equal(t, forwarded, *adminDTO.ReasoningEffort)
require.NotNil(t, adminDTO.RequestedReasoningEffort)
require.Equal(t, requested, *adminDTO.RequestedReasoningEffort)
}
func TestUsageLogFromService_FallsBackToLegacyModelWhenRequestedModelMissing(t *testing.T) {
t.Parallel()
+3 -1
View File
@@ -486,9 +486,11 @@ 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 effective effort after group policy / model-family remapping.
// OpenAI: "low"/"medium"/"high"/"xhigh"; Claude: "low"/"medium"/"high"/"max".
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.
@@ -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)
@@ -702,6 +702,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)
@@ -1266,6 +1267,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,
@@ -622,6 +622,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{}
@@ -1220,6 +1220,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
@@ -1889,6 +1948,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,
@@ -525,6 +525,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),
@@ -1327,6 +1341,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);
@@ -91,8 +91,16 @@
</template>
<template #cell-reasoning_effort="{ row }">
<span class="text-sm text-gray-900 dark:text-white">
{{ formatReasoningEffort(row.reasoning_effort) }}
<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) }}
</div>
<div class="text-gray-500 dark:text-gray-400">
<span class="mr-0.5">↳</span>{{ formatReasoningEffort(row.reasoning_effort) }}
</div>
</div>
<span v-else class="text-sm text-gray-900 dark:text-white">
{{ formatReasoningEffort(row.requested_reasoning_effort || 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.requested_reasoning_effort?.trim() || ''
const forwarded = row.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,61 @@ 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',
requested_reasoning_effort: 'max',
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',
requested_reasoning_effort: 'max',
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.each([
{
name: 'possible version variant',
@@ -561,6 +617,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" />
@@ -309,6 +309,7 @@ export default {
modelVariant: 'Possible version variant',
modelMismatch: 'Different model',
reasoningEffort: 'Reasoning Effort',
requestedReasoningEffort: 'Requested reasoning effort',
endpoint: 'Endpoint',
endpointDistribution: 'Endpoint Distribution',
inbound: 'Inbound',
@@ -314,6 +314,7 @@ export default {
modelVariant: '疑似版本变体',
modelMismatch: '模型不一致',
reasoningEffort: '推理强度',
requestedReasoningEffort: '请求推理强度',
endpoint: '端点',
endpointDistribution: '端点分布',
inbound: '入站',
+1
View File
@@ -1639,6 +1639,7 @@ 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
@@ -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 对象
+2 -2
View File
@@ -575,7 +575,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 +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.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.requested_reasoning_effort || log.reasoning_effort), formatReasoningEffort(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 { formatReasoningEffort } from '@/utils/format'
import { formatReasoningEffortMapping } 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,
formatReasoningEffort(log.reasoning_effort),
formatReasoningEffortMapping(log.requested_reasoning_effort, log.reasoning_effort),
log.inbound_endpoint || '',
log.ip_address || '',
getRequestTypeExportText(log),