mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:28:39 +08:00
feat(usage): 使用记录展示映射前的推理强度
用量行新增 requested_reasoning_effort,记录分组策略与模型族改写前的客户端请求值;推理强度列与模型列一致,主行显示请求值,有映射时用 ↳ 展示实际转发值。
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: '入站',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 对象
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user