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