Commit 057f71c2 by CaIon

fix(logs): isolate privileged metadata

parent 73afad58
...@@ -8,6 +8,8 @@ upload ...@@ -8,6 +8,8 @@ upload
*.db *.db
build build
*.db-journal *.db-journal
*.db-shm
*.db-wal
logs logs
web/dist web/dist
web/node_modules web/node_modules
......
...@@ -559,7 +559,7 @@ func settleTestQuota(info *relaycommon.RelayInfo, priceData hosttypes.PriceData, ...@@ -559,7 +559,7 @@ func settleTestQuota(info *relaycommon.RelayInfo, priceData hosttypes.PriceData,
return common.QuotaFromFloat(priceData.ModelPrice * common.QuotaPerUnit), nil return common.QuotaFromFloat(priceData.ModelPrice * common.QuotaPerUnit), nil
} }
func buildTestLogOther(c *gin.Context, info *relaycommon.RelayInfo, priceData hosttypes.PriceData, usage *dto.Usage, tieredResult *billingexpr.TieredResult) map[string]interface{} { func buildTestLogOther(c *gin.Context, info *relaycommon.RelayInfo, priceData hosttypes.PriceData, usage *dto.Usage, tieredResult *billingexpr.TieredResult) *model.LogOther {
other := service.GenerateTextOtherInfo(c, info, priceData.ModelRatio, priceData.GroupRatioInfo.GroupRatio, priceData.CompletionRatio, other := service.GenerateTextOtherInfo(c, info, priceData.ModelRatio, priceData.GroupRatioInfo.GroupRatio, priceData.CompletionRatio,
usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice, priceData.GroupRatioInfo.GroupSpecialRatio) usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice, priceData.GroupRatioInfo.GroupSpecialRatio)
if tieredResult != nil { if tieredResult != nil {
......
...@@ -279,10 +279,11 @@ func TestBuildTestLogOtherInjectsTieredInfo(t *testing.T) { ...@@ -279,10 +279,11 @@ func TestBuildTestLogOtherInjectsTieredInfo(t *testing.T) {
RequestRules: requestRules, RequestRules: requestRules,
}) })
require.Equal(t, "tiered_expr", other["billing_mode"]) fields := other.Snapshot()
require.Equal(t, "base", other["matched_tier"]) require.Equal(t, "tiered_expr", fields["billing_mode"])
require.Equal(t, requestRules, other["request_rules"]) require.Equal(t, "base", fields["matched_tier"])
require.NotEmpty(t, other["expr_b64"]) require.Equal(t, requestRules, fields["request_rules"])
require.NotEmpty(t, fields["expr_b64"])
} }
func TestResolveChannelTestUserIDUsesRequestUser(t *testing.T) { func TestResolveChannelTestUserIDUsesRequestUser(t *testing.T) {
......
...@@ -29,6 +29,8 @@ func GetAllLogs(c *gin.Context) { ...@@ -29,6 +29,8 @@ func GetAllLogs(c *gin.Context) {
} }
if c.GetInt("role") < common.RoleRootUser { if c.GetInt("role") < common.RoleRootUser {
model.FormatAdminLogs(logs) model.FormatAdminLogs(logs)
} else {
model.FormatRootLogs(logs)
} }
pageInfo.SetTotal(int(total)) pageInfo.SetTotal(int(total))
pageInfo.SetItems(logs) pageInfo.SetItems(logs)
......
...@@ -411,41 +411,21 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t ...@@ -411,41 +411,21 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
modelName := c.GetString("original_model") modelName := c.GetString("original_model")
tokenId := c.GetInt("token_id") tokenId := c.GetInt("token_id")
userGroup := c.GetString("group") userGroup := c.GetString("group")
channelId := c.GetInt("channel_id") other := model.NewLogOther()
other := make(map[string]interface{})
if c.Request != nil && c.Request.URL != nil { if c.Request != nil && c.Request.URL != nil {
other["request_path"] = c.Request.URL.Path other.SetPublic("request_path", c.Request.URL.Path)
} }
other["error_type"] = err.GetErrorType() other.SetPublic("error_type", err.GetErrorType())
other["error_code"] = err.GetErrorCode() other.SetPublic("error_code", err.GetErrorCode())
other["status_code"] = err.StatusCode other.SetPublic("status_code", err.StatusCode)
other["channel_id"] = channelId service.AppendRelayLogAdminInfo(c, relayInfo, other)
other["channel_name"] = c.GetString("channel_name")
other["channel_type"] = c.GetInt("channel_type")
adminInfo := make(map[string]interface{})
adminInfo["use_channel"] = c.GetStringSlice("use_channel")
if relayInfo != nil {
if diagnostics := relayInfo.ConversionDiagnostics(); len(diagnostics) > 0 {
adminInfo["conversion_diagnostics"] = diagnostics
}
if relayInfo.ConversionDiagnosticsTruncated() {
adminInfo["conversion_diagnostics_truncated"] = true
}
}
isMultiKey := common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey)
if isMultiKey {
adminInfo["is_multi_key"] = true
adminInfo["multi_key_index"] = common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex)
}
service.AppendChannelAffinityAdminInfo(c, adminInfo)
other["admin_info"] = adminInfo
service.AppendTaskPluginContextAuditInfo(c, other) service.AppendTaskPluginContextAuditInfo(c, other)
startTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime) startTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime)
if startTime.IsZero() { if startTime.IsZero() {
startTime = time.Now() startTime = time.Now()
} }
useTimeSeconds := int(time.Since(startTime).Seconds()) useTimeSeconds := int(time.Since(startTime).Seconds())
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, useTimeSeconds, common.GetContextKeyBool(c, constant.ContextKeyIsStream), userGroup, other) model.RecordErrorLog(c, userId, channelError.ChannelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, useTimeSeconds, common.GetContextKeyBool(c, constant.ContextKeyIsStream), userGroup, other)
} }
} }
......
package controller
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestProcessChannelErrorUsesSnapshotWithoutLeakingChannelMetadata(t *testing.T) {
gin.SetMode(gin.TestMode)
previousDB, previousLogDB := model.DB, model.LOG_DB
previousRedisEnabled := common.RedisEnabled
previousMainDatabaseType := common.MainDatabaseType()
previousLogDatabaseType := common.LogDatabaseType()
previousErrorLogEnabled := constant.ErrorLogEnabled
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := database.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(1)
require.NoError(t, database.AutoMigrate(&model.User{}, &model.Log{}))
model.DB, model.LOG_DB = database, database
common.RedisEnabled = false
common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
constant.ErrorLogEnabled = true
t.Cleanup(func() {
model.DB, model.LOG_DB = previousDB, previousLogDB
common.RedisEnabled = previousRedisEnabled
common.SetDatabaseTypes(previousMainDatabaseType, previousLogDatabaseType)
constant.ErrorLogEnabled = previousErrorLogEnabled
require.NoError(t, sqlDB.Close())
})
require.NoError(t, database.Create(&model.User{Id: 7, Username: "log-owner", Group: "default"}).Error)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
ctx.Set("id", 7)
ctx.Set("username", "log-owner")
ctx.Set("token_name", "test-token")
ctx.Set("token_id", 11)
ctx.Set("original_model", "gpt-test")
ctx.Set("group", "default")
ctx.Set("channel_id", 202)
ctx.Set("channel_name", "mutable-context-channel")
ctx.Set("channel_type", 9)
ctx.Set("use_channel", []string{"101"})
common.SetContextKey(ctx, constant.ContextKeyRequestStartTime, time.Now().Add(-time.Second))
channelSnapshot := types.ChannelError{
ChannelId: 101,
ChannelType: 1,
ChannelName: "snapshot-channel",
AutoBan: false,
}
apiErr := types.NewOpenAIError(errors.New("upstream failed"), types.ErrorCodeBadResponseStatusCode, http.StatusBadGateway)
processChannelError(ctx, channelSnapshot, apiErr, nil)
var stored model.Log
require.NoError(t, database.First(&stored).Error)
assert.Equal(t, channelSnapshot.ChannelId, stored.ChannelId)
storedOther, err := common.StrToMap(stored.Other)
require.NoError(t, err)
assert.Equal(t, float64(http.StatusBadGateway), storedOther["status_code"])
for _, key := range []string{"channel_id", "channel_name", "channel_type"} {
assert.NotContains(t, storedOther, key)
}
adminInfo, ok := storedOther["admin_info"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, []interface{}{"101"}, adminInfo["use_channel"])
logs, total, err := model.GetUserLogs(7, model.LogTypeError, 0, 0, "", "", 0, 10, "", "", "")
require.NoError(t, err)
require.Equal(t, int64(1), total)
require.Len(t, logs, 1)
assert.Equal(t, channelSnapshot.ChannelId, logs[0].ChannelId)
assert.Empty(t, logs[0].ChannelName)
userOther, err := common.StrToMap(logs[0].Other)
require.NoError(t, err)
assert.NotContains(t, userOther, "admin_info")
for _, key := range []string{"channel_id", "channel_name", "channel_type"} {
assert.NotContains(t, userOther, key)
}
}
...@@ -116,19 +116,7 @@ func assignDisplayLogIds(logs []*Log, startIdx int) { ...@@ -116,19 +116,7 @@ func assignDisplayLogIds(logs []*Log, startIdx int) {
func formatUserLogs(logs []*Log, startIdx int) { func formatUserLogs(logs []*Log, startIdx int) {
for i := range logs { for i := range logs {
logs[i].ChannelName = "" logs[i].ChannelName = ""
var otherMap map[string]interface{} logs[i].Other = formatLogOtherJSON(logs[i].Other, logOtherVisibilityUser)
otherMap, _ = common.StrToMap(logs[i].Other)
if otherMap != nil {
// Remove admin-only debug fields.
delete(otherMap, "admin_info")
// Remove diagnostics reserved for root.
delete(otherMap, "root_info")
// Remove operation-audit details (operator/route info), admin-only.
delete(otherMap, "audit_info")
// delete(otherMap, "reject_reason")
// delete(otherMap, "stream_status")
}
logs[i].Other = common.MapToJsonStr(otherMap)
} }
assignDisplayLogIds(logs, startIdx) assignDisplayLogIds(logs, startIdx)
} }
...@@ -137,12 +125,15 @@ func formatUserLogs(logs []*Log, startIdx int) { ...@@ -137,12 +125,15 @@ func formatUserLogs(logs []*Log, startIdx int) {
// admin_info. Root callers must not pass their results through this formatter. // admin_info. Root callers must not pass their results through this formatter.
func FormatAdminLogs(logs []*Log) { func FormatAdminLogs(logs []*Log) {
for i := range logs { for i := range logs {
otherMap, _ := common.StrToMap(logs[i].Other) logs[i].Other = formatLogOtherJSON(logs[i].Other, logOtherVisibilityAdmin)
if otherMap == nil {
continue
} }
delete(otherMap, "root_info") }
logs[i].Other = common.MapToJsonStr(otherMap)
// FormatRootLogs normalizes legacy metadata into the current scoped shape
// without removing root-only diagnostics.
func FormatRootLogs(logs []*Log) {
for i := range logs {
logs[i].Other = formatLogOtherJSON(logs[i].Other, logOtherVisibilityRoot)
} }
} }
...@@ -188,10 +179,9 @@ func RecordLogWithAdminInfo(userId int, logType int, content string, adminInfo m ...@@ -188,10 +179,9 @@ func RecordLogWithAdminInfo(userId int, logType int, content string, adminInfo m
Content: content, Content: content,
} }
if len(adminInfo) > 0 { if len(adminInfo) > 0 {
other := map[string]interface{}{ other := NewLogOther()
"admin_info": adminInfo, other.MergeAdmin(adminInfo)
} log.Other = other.JSONString()
log.Other = common.MapToJsonStr(other)
} }
if err := createLog(log); err != nil { if err := createLog(log); err != nil {
common.SysLog("failed to record log: " + err.Error()) common.SysLog("failed to record log: " + err.Error())
...@@ -216,11 +206,9 @@ func buildOpField(action string, params map[string]interface{}) map[string]inter ...@@ -216,11 +206,9 @@ func buildOpField(action string, params map[string]interface{}) map[string]inter
// content 为英文兜底文本(用于导出);action+params 供前端本地化渲染。 // content 为英文兜底文本(用于导出);action+params 供前端本地化渲染。
// extra 可携带 login_method、user_agent 等附加信息(普通用户可见)。 // extra 可携带 login_method、user_agent 等附加信息(普通用户可见)。
func RecordLoginLog(userId int, username string, content string, ip string, action string, params map[string]interface{}, extra map[string]interface{}) { func RecordLoginLog(userId int, username string, content string, ip string, action string, params map[string]interface{}, extra map[string]interface{}) {
other := map[string]interface{}{} other := NewLogOther()
for k, v := range extra { other.MergePublic(extra)
other[k] = v other.SetPublic("op", buildOpField(action, params))
}
other["op"] = buildOpField(action, params)
log := &Log{ log := &Log{
UserId: userId, UserId: userId,
Username: username, Username: username,
...@@ -228,7 +216,7 @@ func RecordLoginLog(userId int, username string, content string, ip string, acti ...@@ -228,7 +216,7 @@ func RecordLoginLog(userId int, username string, content string, ip string, acti
Type: LogTypeLogin, Type: LogTypeLogin,
Content: content, Content: content,
Ip: ip, Ip: ip,
Other: common.MapToJsonStr(other), Other: other.JSONString(),
} }
if err := createLog(log); err != nil { if err := createLog(log); err != nil {
common.SysLog("failed to record login log: " + err.Error()) common.SysLog("failed to record login log: " + err.Error())
...@@ -243,15 +231,10 @@ func RecordLoginLog(userId int, username string, content string, ip string, acti ...@@ -243,15 +231,10 @@ func RecordLoginLog(userId int, username string, content string, ip string, acti
// auditInfo 存放路由/方法/结果等中间件兜底信息(写入 Other.audit_info,普通用户查询时剥离)。 // auditInfo 存放路由/方法/结果等中间件兜底信息(写入 Other.audit_info,普通用户查询时剥离)。
func RecordOperationAuditLog(logUserId int, content string, ip string, action string, params map[string]interface{}, adminInfo map[string]interface{}, auditInfo map[string]interface{}) { func RecordOperationAuditLog(logUserId int, content string, ip string, action string, params map[string]interface{}, adminInfo map[string]interface{}, auditInfo map[string]interface{}) {
username, _ := GetUsernameById(logUserId, false) username, _ := GetUsernameById(logUserId, false)
other := map[string]interface{}{ other := NewLogOther()
"op": buildOpField(action, params), other.SetPublic("op", buildOpField(action, params))
} other.MergeAdmin(adminInfo)
if len(adminInfo) > 0 { other.MergeAudit(auditInfo)
other["admin_info"] = adminInfo
}
if len(auditInfo) > 0 {
other["audit_info"] = auditInfo
}
log := &Log{ log := &Log{
UserId: logUserId, UserId: logUserId,
Username: username, Username: username,
...@@ -259,7 +242,7 @@ func RecordOperationAuditLog(logUserId int, content string, ip string, action st ...@@ -259,7 +242,7 @@ func RecordOperationAuditLog(logUserId int, content string, ip string, action st
Type: LogTypeManage, Type: LogTypeManage,
Content: content, Content: content,
Ip: ip, Ip: ip,
Other: common.MapToJsonStr(other), Other: other.JSONString(),
} }
if err := createLog(log); err != nil { if err := createLog(log); err != nil {
common.SysLog("failed to record operation audit log: " + err.Error()) common.SysLog("failed to record operation audit log: " + err.Error())
...@@ -268,17 +251,15 @@ func RecordOperationAuditLog(logUserId int, content string, ip string, action st ...@@ -268,17 +251,15 @@ func RecordOperationAuditLog(logUserId int, content string, ip string, action st
func RecordTopupLog(userId int, content string, callerIp string, paymentMethod string, callbackPaymentMethod string) { func RecordTopupLog(userId int, content string, callerIp string, paymentMethod string, callbackPaymentMethod string) {
username, _ := GetUsernameById(userId, false) username, _ := GetUsernameById(userId, false)
adminInfo := map[string]interface{}{ other := NewLogOther()
other.MergeAdmin(map[string]interface{}{
"server_ip": common.GetIp(), "server_ip": common.GetIp(),
"node_name": common.NodeName, "node_name": common.NodeName,
"caller_ip": callerIp, "caller_ip": callerIp,
"payment_method": paymentMethod, "payment_method": paymentMethod,
"callback_payment_method": callbackPaymentMethod, "callback_payment_method": callbackPaymentMethod,
"version": common.Version, "version": common.Version,
} })
other := map[string]interface{}{
"admin_info": adminInfo,
}
log := &Log{ log := &Log{
UserId: userId, UserId: userId,
Username: username, Username: username,
...@@ -286,7 +267,7 @@ func RecordTopupLog(userId int, content string, callerIp string, paymentMethod s ...@@ -286,7 +267,7 @@ func RecordTopupLog(userId int, content string, callerIp string, paymentMethod s
Type: LogTypeTopup, Type: LogTypeTopup,
Content: content, Content: content,
Ip: callerIp, Ip: callerIp,
Other: common.MapToJsonStr(other), Other: other.JSONString(),
} }
err := createLog(log) err := createLog(log)
if err != nil { if err != nil {
...@@ -295,12 +276,12 @@ func RecordTopupLog(userId int, content string, callerIp string, paymentMethod s ...@@ -295,12 +276,12 @@ func RecordTopupLog(userId int, content string, callerIp string, paymentMethod s
} }
func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string, tokenName string, content string, tokenId int, useTimeSeconds int, func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string, tokenName string, content string, tokenId int, useTimeSeconds int,
isStream bool, group string, other map[string]interface{}) { isStream bool, group string, other *LogOther) {
logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, common.LocalLogPreview(content))) logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, common.LocalLogPreview(content)))
username := c.GetString("username") username := c.GetString("username")
requestId := c.GetString(common.RequestIdKey) requestId := c.GetString(common.RequestIdKey)
upstreamRequestId := c.GetString(common.UpstreamRequestIdKey) upstreamRequestId := c.GetString(common.UpstreamRequestIdKey)
otherStr := common.MapToJsonStr(other) otherStr := other.JSONString()
// 判断是否需要记录 IP // 判断是否需要记录 IP
needRecordIp := false needRecordIp := false
if settingMap, err := GetUserSetting(userId, false); err == nil { if settingMap, err := GetUserSetting(userId, false); err == nil {
...@@ -352,7 +333,7 @@ type RecordConsumeLogParams struct { ...@@ -352,7 +333,7 @@ type RecordConsumeLogParams struct {
UseTimeSeconds int `json:"use_time_seconds"` UseTimeSeconds int `json:"use_time_seconds"`
IsStream bool `json:"is_stream"` IsStream bool `json:"is_stream"`
Group string `json:"group"` Group string `json:"group"`
Other map[string]interface{} `json:"other"` Other *LogOther `json:"other"`
} }
func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) { func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) {
...@@ -364,7 +345,7 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) ...@@ -364,7 +345,7 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
requestId := c.GetString(common.RequestIdKey) requestId := c.GetString(common.RequestIdKey)
upstreamRequestId := c.GetString(common.UpstreamRequestIdKey) upstreamRequestId := c.GetString(common.UpstreamRequestIdKey)
createdAt := common.GetTimestamp() createdAt := common.GetTimestamp()
otherStr := common.MapToJsonStr(params.Other) otherStr := params.Other.JSONString()
// 判断是否需要记录 IP // 判断是否需要记录 IP
needRecordIp := false needRecordIp := false
if settingMap, err := GetUserSetting(userId, false); err == nil { if settingMap, err := GetUserSetting(userId, false); err == nil {
...@@ -427,7 +408,7 @@ type RecordTaskBillingLogParams struct { ...@@ -427,7 +408,7 @@ type RecordTaskBillingLogParams struct {
Quota int Quota int
TokenId int TokenId int
Group string Group string
Other map[string]interface{} Other *LogOther
NodeName string // 任务发起节点;为空时回退当前节点 NodeName string // 任务发起节点;为空时回退当前节点
} }
...@@ -455,7 +436,7 @@ func RecordTaskBillingLog(params RecordTaskBillingLogParams) { ...@@ -455,7 +436,7 @@ func RecordTaskBillingLog(params RecordTaskBillingLogParams) {
ChannelId: params.ChannelId, ChannelId: params.ChannelId,
TokenId: params.TokenId, TokenId: params.TokenId,
Group: params.Group, Group: params.Group,
Other: common.MapToJsonStr(params.Other), Other: params.Other.JSONString(),
} }
err := createLog(log) err := createLog(log)
if err != nil { if err != nil {
......
...@@ -75,9 +75,158 @@ func TestTaskPluginLogVisibilityIsRoleSeparated(t *testing.T) { ...@@ -75,9 +75,158 @@ func TestTaskPluginLogVisibilityIsRoleSeparated(t *testing.T) {
}) })
t.Run("root", func(t *testing.T) { t.Run("root", func(t *testing.T) {
parsed, err := common.StrToMap(other) logs := []*Log{{Other: other}}
FormatRootLogs(logs)
parsed, err := common.StrToMap(logs[0].Other)
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, parsed, "admin_info") assert.Contains(t, parsed, "admin_info")
assert.Contains(t, parsed, "root_info") assert.Contains(t, parsed, "root_info")
}) })
} }
func TestLegacyLogOtherVisibilityIsRoleSeparated(t *testing.T) {
other := common.MapToJsonStr(map[string]interface{}{
"request_path": "/v1/chat/completions",
"channel_id": 202,
"channel_name": "legacy-secret-channel",
"channel_type": 1,
"reject_reason": "legacy-policy-rejection",
"admin_info": map[string]interface{}{
"existing_admin_field": "preserved",
},
"root_info": map[string]interface{}{
"upstream_request_id": "upstream-private",
},
"audit_info": map[string]interface{}{
"method": "POST",
},
})
t.Run("user", func(t *testing.T) {
logs := []*Log{{
Id: 99,
ChannelId: 77,
ChannelName: "resolved-secret-channel",
Other: other,
}}
formatUserLogs(logs, 10)
assert.Equal(t, 11, logs[0].Id)
assert.Equal(t, 77, logs[0].ChannelId)
assert.Empty(t, logs[0].ChannelName)
parsed, err := common.StrToMap(logs[0].Other)
require.NoError(t, err)
assert.Equal(t, "/v1/chat/completions", parsed["request_path"])
for _, key := range []string{
"channel_id",
"channel_name",
"channel_type",
"reject_reason",
"admin_info",
"root_info",
"audit_info",
} {
assert.NotContains(t, parsed, key)
}
})
t.Run("admin", func(t *testing.T) {
logs := []*Log{{Other: other}}
FormatAdminLogs(logs)
parsed, err := common.StrToMap(logs[0].Other)
require.NoError(t, err)
assert.Equal(t, "legacy-secret-channel", parsed["channel_name"])
assert.NotContains(t, parsed, "reject_reason")
assert.NotContains(t, parsed, "root_info")
assert.Contains(t, parsed, "audit_info")
adminInfo, ok := parsed["admin_info"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "preserved", adminInfo["existing_admin_field"])
assert.Equal(t, "legacy-policy-rejection", adminInfo["reject_reason"])
})
t.Run("root", func(t *testing.T) {
logs := []*Log{{Other: other}}
FormatRootLogs(logs)
parsed, err := common.StrToMap(logs[0].Other)
require.NoError(t, err)
assert.Equal(t, "legacy-secret-channel", parsed["channel_name"])
assert.NotContains(t, parsed, "reject_reason")
assert.Contains(t, parsed, "root_info")
assert.Contains(t, parsed, "audit_info")
adminInfo, ok := parsed["admin_info"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "preserved", adminInfo["existing_admin_field"])
assert.Equal(t, "legacy-policy-rejection", adminInfo["reject_reason"])
})
}
func TestLegacyRejectReasonDoesNotOverrideScopedValue(t *testing.T) {
other := common.MapToJsonStr(map[string]interface{}{
"reject_reason": "legacy-value",
"admin_info": map[string]interface{}{
"reject_reason": "scoped-value",
},
})
logs := []*Log{{Other: other}}
FormatRootLogs(logs)
parsed, err := common.StrToMap(logs[0].Other)
require.NoError(t, err)
assert.NotContains(t, parsed, "reject_reason")
adminInfo, ok := parsed["admin_info"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "scoped-value", adminInfo["reject_reason"])
}
func TestLegacyRejectReasonHandlesNullAdminInfo(t *testing.T) {
logs := []*Log{{Other: `{"reject_reason":"legacy-value","admin_info":null}`}}
FormatAdminLogs(logs)
parsed, err := common.StrToMap(logs[0].Other)
require.NoError(t, err)
assert.NotContains(t, parsed, "reject_reason")
adminInfo, ok := parsed["admin_info"].(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "legacy-value", adminInfo["reject_reason"])
}
func TestLogFormattingPreservesLargeIntegerLexemes(t *testing.T) {
const other = `{"public_id":9007199254740993,"admin_info":{"admin_id":9007199254740995},"root_info":{"generation":18446744073709551615}}`
t.Run("user", func(t *testing.T) {
logs := []*Log{{Other: other}}
formatUserLogs(logs, 0)
assert.Contains(t, logs[0].Other, `"public_id":9007199254740993`)
assert.NotContains(t, logs[0].Other, "admin_id")
assert.NotContains(t, logs[0].Other, "generation")
})
t.Run("admin", func(t *testing.T) {
logs := []*Log{{Other: other}}
FormatAdminLogs(logs)
assert.Contains(t, logs[0].Other, `"public_id":9007199254740993`)
assert.Contains(t, logs[0].Other, `"admin_id":9007199254740995`)
assert.NotContains(t, logs[0].Other, "generation")
})
t.Run("root", func(t *testing.T) {
logs := []*Log{{Other: other}}
FormatRootLogs(logs)
assert.Equal(t, other, logs[0].Other)
})
}
package model
import (
"encoding/json"
"maps"
"github.com/QuantumNous/new-api/common"
)
const (
logOtherAdminInfoKey = "admin_info"
logOtherRootInfoKey = "root_info"
logOtherAuditInfoKey = "audit_info"
)
type logOtherVisibility int
const (
logOtherVisibilityUser logOtherVisibility = iota
logOtherVisibilityAdmin
logOtherVisibilityRoot
)
// LogOther separates usage-log metadata by the audience allowed to see it.
// Its maps stay private so callers cannot accidentally place privileged fields
// in the user-visible top level.
type LogOther struct {
public map[string]any
adminInfo map[string]any
rootInfo map[string]any
auditInfo map[string]any
}
func NewLogOther() *LogOther {
return &LogOther{}
}
func isReservedLogOtherKey(key string) bool {
switch key {
case logOtherAdminInfoKey, logOtherRootInfoKey, logOtherAuditInfoKey,
"channel_id", "channel_name", "channel_type", "reject_reason":
return true
default:
return false
}
}
// SetPublic records metadata that log owners may receive from self/token log APIs.
// It rejects role-scoped and legacy-sensitive keys so new writers cannot recreate
// the historical channel/reject-reason leak.
func (o *LogOther) SetPublic(key string, value any) bool {
if o == nil || key == "" || isReservedLogOtherKey(key) {
return false
}
if o.public == nil {
o.public = make(map[string]any)
}
o.public[key] = value
return true
}
func (o *LogOther) MergePublic(values map[string]any) {
for key, value := range values {
o.SetPublic(key, value)
}
}
func (o *LogOther) SetAdmin(key string, value any) bool {
if o == nil || key == "" {
return false
}
if o.adminInfo == nil {
o.adminInfo = make(map[string]any)
}
o.adminInfo[key] = value
return true
}
func (o *LogOther) MergeAdmin(values map[string]any) {
for key, value := range values {
o.SetAdmin(key, value)
}
}
func (o *LogOther) SetRoot(key string, value any) bool {
if o == nil || key == "" {
return false
}
if o.rootInfo == nil {
o.rootInfo = make(map[string]any)
}
o.rootInfo[key] = value
return true
}
func (o *LogOther) MergeRoot(values map[string]any) {
for key, value := range values {
o.SetRoot(key, value)
}
}
func (o *LogOther) SetAudit(key string, value any) bool {
if o == nil || key == "" {
return false
}
if o.auditInfo == nil {
o.auditInfo = make(map[string]any)
}
o.auditInfo[key] = value
return true
}
func (o *LogOther) MergeAudit(values map[string]any) {
for key, value := range values {
o.SetAudit(key, value)
}
}
func copyLogOtherMap(values map[string]any) map[string]any {
if len(values) == 0 {
return nil
}
copyValues := make(map[string]any, len(values))
maps.Copy(copyValues, values)
return copyValues
}
func (o *LogOther) normalizeLegacyAdminFields() {
if o == nil || o.public == nil {
return
}
if rejectReason, ok := o.public["reject_reason"]; ok {
if _, exists := o.adminInfo["reject_reason"]; !exists {
o.SetAdmin("reject_reason", rejectReason)
}
delete(o.public, "reject_reason")
}
}
func (o *LogOther) toMap(visibility logOtherVisibility) map[string]any {
result := make(map[string]any)
if o == nil {
return result
}
for key, value := range o.public {
if visibility == logOtherVisibilityUser {
switch key {
case "channel_id", "channel_name", "channel_type", "reject_reason":
continue
}
}
result[key] = value
}
if visibility >= logOtherVisibilityAdmin {
if adminInfo := copyLogOtherMap(o.adminInfo); len(adminInfo) > 0 {
result[logOtherAdminInfoKey] = adminInfo
}
if auditInfo := copyLogOtherMap(o.auditInfo); len(auditInfo) > 0 {
result[logOtherAuditInfoKey] = auditInfo
}
}
if visibility == logOtherVisibilityRoot {
if rootInfo := copyLogOtherMap(o.rootInfo); len(rootInfo) > 0 {
result[logOtherRootInfoKey] = rootInfo
}
}
return result
}
func (o *LogOther) jsonString(visibility logOtherVisibility) string {
if o == nil {
return ""
}
o.normalizeLegacyAdminFields()
data, err := common.Marshal(o.toMap(visibility))
if err != nil {
common.SysError("failed to marshal log other: " + err.Error())
return ""
}
return string(data)
}
// JSONString returns the complete stored representation, including all
// privileged scopes. API projections must use the role-specific formatter.
func (o *LogOther) JSONString() string {
return o.jsonString(logOtherVisibilityRoot)
}
// Snapshot returns a detached top-level view for tests and read-only
// inspection. Mutating it cannot add or replace fields in LogOther.
func (o *LogOther) Snapshot() map[string]any {
if o == nil {
return nil
}
return o.toMap(logOtherVisibilityRoot)
}
func (o *LogOther) MarshalJSON() ([]byte, error) {
return common.Marshal(o.toMap(logOtherVisibilityRoot))
}
func normalizeLegacyRejectReason(values map[string]json.RawMessage) bool {
rejectReason, ok := values["reject_reason"]
if !ok {
return false
}
adminInfo := make(map[string]json.RawMessage)
if rawAdminInfo, exists := values[logOtherAdminInfoKey]; exists {
_ = common.Unmarshal(rawAdminInfo, &adminInfo)
}
if adminInfo == nil {
adminInfo = make(map[string]json.RawMessage)
}
if _, exists := adminInfo["reject_reason"]; !exists {
adminInfo["reject_reason"] = rejectReason
}
encodedAdminInfo, err := common.Marshal(adminInfo)
if err != nil {
return false
}
values[logOtherAdminInfoKey] = encodedAdminInfo
delete(values, "reject_reason")
return true
}
// formatLogOtherJSON applies the role projection while keeping untouched JSON
// values as RawMessage. This preserves integers larger than JavaScript's safe
// range instead of round-tripping them through float64.
func formatLogOtherJSON(value string, visibility logOtherVisibility) string {
if value == "" {
return ""
}
var values map[string]json.RawMessage
if err := common.UnmarshalJsonStr(value, &values); err != nil {
if visibility == logOtherVisibilityRoot {
return value
}
return "{}"
}
changed := false
if visibility == logOtherVisibilityUser {
for _, key := range []string{
logOtherAdminInfoKey,
logOtherRootInfoKey,
logOtherAuditInfoKey,
"channel_id",
"channel_name",
"channel_type",
"reject_reason",
} {
if _, exists := values[key]; exists {
delete(values, key)
changed = true
}
}
} else {
changed = normalizeLegacyRejectReason(values)
if visibility == logOtherVisibilityAdmin {
if _, exists := values[logOtherRootInfoKey]; exists {
delete(values, logOtherRootInfoKey)
changed = true
}
}
}
if visibility == logOtherVisibilityRoot && !changed {
return value
}
formatted, err := common.Marshal(values)
if err != nil {
if visibility == logOtherVisibilityRoot {
return value
}
return "{}"
}
return string(formatted)
}
package model
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLogOtherScopesAndMerges(t *testing.T) {
var other LogOther
assert.True(t, other.SetPublic("request_path", "/v1/chat/completions"))
other.MergePublic(map[string]interface{}{
"zero": 0,
})
assert.True(t, other.SetAdmin("use_channel", []string{"channel-a"}))
other.MergeAdmin(map[string]interface{}{
"rejected": false,
})
assert.True(t, other.SetRoot("upstream_request_id", "upstream-private"))
other.MergeRoot(map[string]interface{}{
"generation": 0,
})
assert.True(t, other.SetAudit("method", "POST"))
other.MergeAudit(map[string]interface{}{
"success": false,
})
require.JSONEq(t, `{
"request_path": "/v1/chat/completions",
"zero": 0,
"admin_info": {
"use_channel": ["channel-a"],
"rejected": false
},
"root_info": {
"upstream_request_id": "upstream-private",
"generation": 0
},
"audit_info": {
"method": "POST",
"success": false
}
}`, other.JSONString())
}
func TestLogOtherRejectsSensitivePublicFields(t *testing.T) {
other := NewLogOther()
for _, key := range []string{
"admin_info",
"root_info",
"audit_info",
"channel_id",
"channel_name",
"channel_type",
"reject_reason",
} {
assert.False(t, other.SetPublic(key, "must-not-leak"), key)
}
other.MergePublic(map[string]interface{}{
"request_path": "/v1/responses",
"channel_name": "still-must-not-leak",
"admin_info": map[string]interface{}{"secret": true},
})
require.JSONEq(t, `{"request_path":"/v1/responses"}`, other.JSONString())
require.JSONEq(t, `{}`, NewLogOther().JSONString())
}
...@@ -57,7 +57,7 @@ func TestOptInSafeToolLossRejectedAsBadRequestWithAdminDiagnostics(t *testing.T) ...@@ -57,7 +57,7 @@ func TestOptInSafeToolLossRejectedAsBadRequestWithAdminDiagnostics(t *testing.T)
assert.True(t, hasHostDiagnosticCode(diagnostics, "unsupported_hosted_tool")) assert.True(t, hasHostDiagnosticCode(diagnostics, "unsupported_hosted_tool"))
other := service.GenerateTextOtherInfo(c, info, 1, 1, 1, 0, 0, 0, 1) other := service.GenerateTextOtherInfo(c, info, 1, 1, 1, 0, 0, 0, 1)
adminInfo, ok := other["admin_info"].(map[string]interface{}) adminInfo, ok := other.Snapshot()["admin_info"].(map[string]interface{})
require.True(t, ok) require.True(t, ok)
require.Contains(t, adminInfo, "conversion_diagnostics") require.Contains(t, adminInfo, "conversion_diagnostics")
} }
......
package service package service
import "github.com/QuantumNous/new-api/relaykit/dto" import (
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relaykit/dto"
)
const ( const (
usageBillingPathLocal = "local" usageBillingPathLocal = "local"
...@@ -50,16 +53,11 @@ func usageBillingPathForLog(isLocalCountTokens bool, usage *dto.Usage) string { ...@@ -50,16 +53,11 @@ func usageBillingPathForLog(isLocalCountTokens bool, usage *dto.Usage) string {
return usageBillingPathUpstream return usageBillingPathUpstream
} }
func appendUsageBillingPathForLog(other map[string]interface{}, isLocalCountTokens bool, usage *dto.Usage) { func appendUsageBillingPathForLog(other *model.LogOther, isLocalCountTokens bool, usage *dto.Usage) {
if other == nil { if other == nil {
return return
} }
adminInfo, ok := other["admin_info"].(map[string]interface{}) other.SetAdmin("usage_billing_path", usageBillingPathForLog(isLocalCountTokens, usage))
if !ok || adminInfo == nil {
adminInfo = make(map[string]interface{})
other["admin_info"] = adminInfo
}
adminInfo["usage_billing_path"] = usageBillingPathForLog(isLocalCountTokens, usage)
} }
func usageFromBillingUsage(usage *dto.Usage) (*dto.Usage, bool) { func usageFromBillingUsage(usage *dto.Usage) (*dto.Usage, bool) {
......
...@@ -10,6 +10,7 @@ import ( ...@@ -10,6 +10,7 @@ import (
"time" "time"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/cachex" "github.com/QuantumNous/new-api/pkg/cachex"
"github.com/QuantumNous/new-api/relaykit/dto" "github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types" "github.com/QuantumNous/new-api/relaykit/types"
...@@ -699,15 +700,15 @@ func MarkChannelAffinityUsed(c *gin.Context, selectedGroup string, channelID int ...@@ -699,15 +700,15 @@ func MarkChannelAffinityUsed(c *gin.Context, selectedGroup string, channelID int
c.Set(ginKeyChannelAffinityLogInfo, info) c.Set(ginKeyChannelAffinityLogInfo, info)
} }
func AppendChannelAffinityAdminInfo(c *gin.Context, adminInfo map[string]interface{}) { func AppendChannelAffinityAdminInfo(c *gin.Context, other *model.LogOther) {
if c == nil || adminInfo == nil { if c == nil || other == nil {
return return
} }
anyInfo, ok := c.Get(ginKeyChannelAffinityLogInfo) anyInfo, ok := c.Get(ginKeyChannelAffinityLogInfo)
if !ok || anyInfo == nil { if !ok || anyInfo == nil {
return return
} }
adminInfo["channel_affinity"] = anyInfo other.SetAdmin("channel_affinity", anyInfo)
} }
func RecordChannelAffinity(c *gin.Context, channelID int) { func RecordChannelAffinity(c *gin.Context, channelID int) {
......
...@@ -117,6 +117,9 @@ func RefundMidjourneyQuota(ctx context.Context, task *model.Midjourney, reason s ...@@ -117,6 +117,9 @@ func RefundMidjourneyQuota(ctx context.Context, task *model.Midjourney, reason s
billingChannelId := task.GetBillingChannelId() billingChannelId := task.GetBillingChannelId()
model.UpdateUserUsedQuota(task.UserId, -quota) model.UpdateUserUsedQuota(task.UserId, -quota)
model.UpdateChannelUsedQuota(billingChannelId, -quota) model.UpdateChannelUsedQuota(billingChannelId, -quota)
other := model.NewLogOther()
other.SetPublic("task_id", task.MjId)
other.SetPublic("reason", reason)
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{ model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
UserId: task.UserId, UserId: task.UserId,
LogType: model.LogTypeRefund, LogType: model.LogTypeRefund,
...@@ -125,10 +128,7 @@ func RefundMidjourneyQuota(ctx context.Context, task *model.Midjourney, reason s ...@@ -125,10 +128,7 @@ func RefundMidjourneyQuota(ctx context.Context, task *model.Midjourney, reason s
ModelName: CovertMjpActionToModelName(task.Action), ModelName: CovertMjpActionToModelName(task.Action),
Quota: quota, Quota: quota,
TokenId: task.TokenId, TokenId: task.TokenId,
Other: map[string]interface{}{ Other: other,
"task_id": task.MjId,
"reason": reason,
},
}) })
task.Quota = 0 task.Quota = 0
......
...@@ -6,6 +6,7 @@ import ( ...@@ -6,6 +6,7 @@ import (
"testing" "testing"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common" relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto" "github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types" "github.com/QuantumNous/new-api/relaykit/types"
...@@ -33,10 +34,11 @@ func TestAttachQuotaSaturationNestsUnderAdminInfo(t *testing.T) { ...@@ -33,10 +34,11 @@ func TestAttachQuotaSaturationNestsUnderAdminInfo(t *testing.T) {
}, },
} }
other := map[string]interface{}{"model_price": 0.004} other := model.NewLogOther()
other.SetPublic("model_price", 0.004)
attachQuotaSaturation(ctx, relayInfo, other) attachQuotaSaturation(ctx, relayInfo, other)
adminInfo, ok := other["admin_info"].(map[string]interface{}) adminInfo, ok := other.Snapshot()["admin_info"].(map[string]interface{})
require.True(t, ok, "admin_info should be created") require.True(t, ok, "admin_info should be created")
sat, ok := adminInfo["quota_saturation"].(map[string]interface{}) sat, ok := adminInfo["quota_saturation"].(map[string]interface{})
require.True(t, ok, "quota_saturation should be nested under admin_info") require.True(t, ok, "quota_saturation should be nested under admin_info")
...@@ -76,12 +78,11 @@ func TestAttachQuotaSaturationPreservesExistingAdminInfo(t *testing.T) { ...@@ -76,12 +78,11 @@ func TestAttachQuotaSaturationPreservesExistingAdminInfo(t *testing.T) {
relayInfo := &relaycommon.RelayInfo{ relayInfo := &relaycommon.RelayInfo{
QuotaClamp: &common.QuotaClamp{Op: "QuotaFromFloat", Kind: common.QuotaClampUnderflow, Clamped: common.MinQuota}, QuotaClamp: &common.QuotaClamp{Op: "QuotaFromFloat", Kind: common.QuotaClampUnderflow, Clamped: common.MinQuota},
} }
other := map[string]interface{}{ other := model.NewLogOther()
"admin_info": map[string]interface{}{"admin_username": "root"}, other.SetAdmin("admin_username", "root")
}
attachQuotaSaturation(ctx, relayInfo, other) attachQuotaSaturation(ctx, relayInfo, other)
adminInfo := other["admin_info"].(map[string]interface{}) adminInfo := other.Snapshot()["admin_info"].(map[string]interface{})
require.Equal(t, "root", adminInfo["admin_username"], "existing admin_info fields preserved") require.Equal(t, "root", adminInfo["admin_username"], "existing admin_info fields preserved")
require.NotNil(t, adminInfo["quota_saturation"]) require.NotNil(t, adminInfo["quota_saturation"])
} }
...@@ -93,10 +94,11 @@ func TestAttachQuotaSaturationNoClampNoMarker(t *testing.T) { ...@@ -93,10 +94,11 @@ func TestAttachQuotaSaturationNoClampNoMarker(t *testing.T) {
ctx, _ := gin.CreateTestContext(nil) ctx, _ := gin.CreateTestContext(nil)
relayInfo := &relaycommon.RelayInfo{QuotaClamp: nil} relayInfo := &relaycommon.RelayInfo{QuotaClamp: nil}
other := map[string]interface{}{"model_price": 0.004} other := model.NewLogOther()
other.SetPublic("model_price", 0.004)
attachQuotaSaturation(ctx, relayInfo, other) attachQuotaSaturation(ctx, relayInfo, other)
_, hasAdmin := other["admin_info"] _, hasAdmin := other.Snapshot()["admin_info"]
require.False(t, hasAdmin, "no admin_info should be added when there is no clamp") require.False(t, hasAdmin, "no admin_info should be added when there is no clamp")
} }
......
...@@ -42,27 +42,27 @@ func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo, task *model ...@@ -42,27 +42,27 @@ func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo, task *model
logContent = fmt.Sprintf("%s, 计算参数:%s", logContent, strings.Join(contents, ", ")) logContent = fmt.Sprintf("%s, 计算参数:%s", logContent, strings.Join(contents, ", "))
} }
} }
other := make(map[string]interface{}) other := model.NewLogOther()
other["is_task"] = true other.SetPublic("is_task", true)
other["request_path"] = c.Request.URL.Path other.SetPublic("request_path", c.Request.URL.Path)
other["model_price"] = info.PriceData.ModelPrice other.SetPublic("model_price", info.PriceData.ModelPrice)
if info.PriceData.ModelRatio > 0 { if info.PriceData.ModelRatio > 0 {
other["model_ratio"] = info.PriceData.ModelRatio other.SetPublic("model_ratio", info.PriceData.ModelRatio)
} }
other["group_ratio"] = info.PriceData.GroupRatioInfo.GroupRatio other.SetPublic("group_ratio", info.PriceData.GroupRatioInfo.GroupRatio)
if info.PriceData.GroupRatioInfo.HasSpecialRatio { if info.PriceData.GroupRatioInfo.HasSpecialRatio {
other["user_group_ratio"] = info.PriceData.GroupRatioInfo.GroupSpecialRatio other.SetPublic("user_group_ratio", info.PriceData.GroupRatioInfo.GroupSpecialRatio)
} }
if info.IsModelMapped { if info.IsModelMapped {
other["is_model_mapped"] = true other.SetPublic("is_model_mapped", true)
other["upstream_model_name"] = info.UpstreamModelName other.SetPublic("upstream_model_name", info.UpstreamModelName)
} }
if snap := info.TieredBillingSnapshot; snap != nil { if snap := info.TieredBillingSnapshot; snap != nil {
other["billing_mode"] = "tiered_expr" other.SetPublic("billing_mode", "tiered_expr")
other["expr_b64"] = base64.StdEncoding.EncodeToString([]byte(snap.ExprString)) other.SetPublic("expr_b64", base64.StdEncoding.EncodeToString([]byte(snap.ExprString)))
other["matched_tier"] = snap.EstimatedTier other.SetPublic("matched_tier", snap.EstimatedTier)
if len(snap.UsageFacts) > 0 { if len(snap.UsageFacts) > 0 {
other["usage_facts"] = snap.UsageFacts other.SetPublic("usage_facts", snap.UsageFacts)
} }
} }
appendTaskLogInfo(task, other) appendTaskLogInfo(task, other)
...@@ -134,43 +134,43 @@ func taskAdjustTokenQuota(ctx context.Context, task *model.Task, delta int) { ...@@ -134,43 +134,43 @@ func taskAdjustTokenQuota(ctx context.Context, task *model.Task, delta int) {
} }
// taskBillingOther 从 task 的 BillingContext 构建日志 Other 字段。 // taskBillingOther 从 task 的 BillingContext 构建日志 Other 字段。
func taskBillingOther(task *model.Task) map[string]interface{} { func taskBillingOther(task *model.Task) *model.LogOther {
other := make(map[string]interface{}) other := model.NewLogOther()
if bc := task.PrivateData.BillingContext; bc != nil { if bc := task.PrivateData.BillingContext; bc != nil {
other["model_price"] = bc.ModelPrice other.SetPublic("model_price", bc.ModelPrice)
if bc.ModelRatio > 0 { if bc.ModelRatio > 0 {
other["model_ratio"] = bc.ModelRatio other.SetPublic("model_ratio", bc.ModelRatio)
} }
other["group_ratio"] = bc.GroupRatio other.SetPublic("group_ratio", bc.GroupRatio)
if priceData := taskBillingContextPriceData(bc); priceData != nil { if priceData := taskBillingContextPriceData(bc); priceData != nil {
for k, v := range priceData.OtherRatios() { for k, v := range priceData.OtherRatios() {
other[k] = v other.SetPublic(k, v)
} }
} }
if snap := bc.TieredSnapshot; snap != nil { if snap := bc.TieredSnapshot; snap != nil {
other["billing_mode"] = "tiered_expr" other.SetPublic("billing_mode", "tiered_expr")
other["expr_b64"] = base64.StdEncoding.EncodeToString([]byte(snap.ExprString)) other.SetPublic("expr_b64", base64.StdEncoding.EncodeToString([]byte(snap.ExprString)))
other["matched_tier"] = snap.EstimatedTier other.SetPublic("matched_tier", snap.EstimatedTier)
if len(snap.UsageFacts) > 0 { if len(snap.UsageFacts) > 0 {
other["usage_facts"] = snap.UsageFacts other.SetPublic("usage_facts", snap.UsageFacts)
} }
} }
} }
props := task.Properties props := task.Properties
if props.UpstreamModelName != "" && props.UpstreamModelName != props.OriginModelName { if props.UpstreamModelName != "" && props.UpstreamModelName != props.OriginModelName {
other["is_model_mapped"] = true other.SetPublic("is_model_mapped", true)
other["upstream_model_name"] = props.UpstreamModelName other.SetPublic("upstream_model_name", props.UpstreamModelName)
} }
appendTaskLogInfo(task, other) appendTaskLogInfo(task, other)
return other return other
} }
func appendTaskLogInfo(task *model.Task, other map[string]interface{}) { func appendTaskLogInfo(task *model.Task, other *model.LogOther) {
if task == nil || other == nil { if task == nil || other == nil {
return return
} }
if task.TaskID != "" { if task.TaskID != "" {
other["task_id"] = task.TaskID other.SetPublic("task_id", task.TaskID)
} }
if task.PrivateData.Execution != nil { if task.PrivateData.Execution != nil {
AppendTaskPluginAuditInfo(other, task.PrivateData.Execution.TaskPlugin) AppendTaskPluginAuditInfo(other, task.PrivateData.Execution.TaskPlugin)
...@@ -178,16 +178,11 @@ func appendTaskLogInfo(task *model.Task, other map[string]interface{}) { ...@@ -178,16 +178,11 @@ func appendTaskLogInfo(task *model.Task, other map[string]interface{}) {
if task.PrivateData.UpstreamTaskID == "" && task.PrivateData.NodeName == "" { if task.PrivateData.UpstreamTaskID == "" && task.PrivateData.NodeName == "" {
return return
} }
rootInfo, ok := other["root_info"].(map[string]interface{})
if !ok || rootInfo == nil {
rootInfo = map[string]interface{}{}
other["root_info"] = rootInfo
}
if task.PrivateData.UpstreamTaskID != "" { if task.PrivateData.UpstreamTaskID != "" {
rootInfo["upstream_task_id"] = task.PrivateData.UpstreamTaskID other.SetRoot("upstream_task_id", task.PrivateData.UpstreamTaskID)
} }
if task.PrivateData.NodeName != "" { if task.PrivateData.NodeName != "" {
rootInfo["node_name"] = task.PrivateData.NodeName other.SetRoot("node_name", task.PrivateData.NodeName)
} }
} }
...@@ -234,8 +229,8 @@ func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) bool ...@@ -234,8 +229,8 @@ func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) bool
// 4. 记录日志 // 4. 记录日志
other := taskBillingOther(task) other := taskBillingOther(task)
other["task_id"] = task.TaskID other.SetPublic("task_id", task.TaskID)
other["reason"] = reason other.SetPublic("reason", reason)
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{ model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
UserId: task.UserId, UserId: task.UserId,
LogType: model.LogTypeRefund, LogType: model.LogTypeRefund,
...@@ -310,9 +305,9 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int ...@@ -310,9 +305,9 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int
logQuota = -quotaDelta logQuota = -quotaDelta
} }
other := taskBillingOther(task) other := taskBillingOther(task)
other["task_id"] = task.TaskID other.SetPublic("task_id", task.TaskID)
other["pre_consumed_quota"] = preConsumedQuota other.SetPublic("pre_consumed_quota", preConsumedQuota)
other["actual_quota"] = actualQuota other.SetPublic("actual_quota", actualQuota)
for _, clamp := range clamps { for _, clamp := range clamps {
attachQuotaSaturationToOther(other, clamp) attachQuotaSaturationToOther(other, clamp)
} }
......
...@@ -163,6 +163,13 @@ func makeTask(userId, channelId, quota, tokenId int, billingSource string, subsc ...@@ -163,6 +163,13 @@ func makeTask(userId, channelId, quota, tokenId int, billingSource string, subsc
} }
} }
func taskBillingOtherMap(t *testing.T, other *model.LogOther) map[string]interface{} {
t.Helper()
var values map[string]interface{}
require.NoError(t, common.UnmarshalJsonStr(other.JSONString(), &values))
return values
}
func TestPriceDataOtherRatiosFilterAndSnapshot(t *testing.T) { func TestPriceDataOtherRatiosFilterAndSnapshot(t *testing.T) {
priceData := types.PriceData{} priceData := types.PriceData{}
...@@ -227,7 +234,7 @@ func TestTaskBillingOtherFiltersHistoricalOtherRatios(t *testing.T) { ...@@ -227,7 +234,7 @@ func TestTaskBillingOtherFiltersHistoricalOtherRatios(t *testing.T) {
"inf": math.Inf(1), "inf": math.Inf(1),
} }
other := taskBillingOther(task) other := taskBillingOtherMap(t, taskBillingOther(task))
assert.Equal(t, 2.0, other["seconds"]) assert.Equal(t, 2.0, other["seconds"])
assert.Equal(t, 1.0, other["identity"]) assert.Equal(t, 1.0, other["identity"])
...@@ -253,7 +260,7 @@ func TestTaskBillingOtherIncludesTieredSnapshotAndKeepsUsageFactsNested(t *testi ...@@ -253,7 +260,7 @@ func TestTaskBillingOtherIncludesTieredSnapshotAndKeepsUsageFactsNested(t *testi
}, },
} }
other := taskBillingOther(task) other := taskBillingOtherMap(t, taskBillingOther(task))
assert.Equal(t, "tiered_expr", other["billing_mode"]) assert.Equal(t, "tiered_expr", other["billing_mode"])
assert.Equal(t, base64.StdEncoding.EncodeToString([]byte(expression)), other["expr_b64"]) assert.Equal(t, base64.StdEncoding.EncodeToString([]byte(expression)), other["expr_b64"])
...@@ -262,7 +269,7 @@ func TestTaskBillingOtherIncludesTieredSnapshotAndKeepsUsageFactsNested(t *testi ...@@ -262,7 +269,7 @@ func TestTaskBillingOtherIncludesTieredSnapshotAndKeepsUsageFactsNested(t *testi
require.True(t, ok) require.True(t, ok)
assert.Equal(t, map[string]any{ assert.Equal(t, map[string]any{
"resolution": "720P", "resolution": "720P",
"seconds": 5, "seconds": float64(5),
}, facts) }, facts)
assert.NotContains(t, other, "resolution") assert.NotContains(t, other, "resolution")
assert.NotContains(t, other, "seconds") assert.NotContains(t, other, "seconds")
...@@ -277,7 +284,7 @@ func TestTaskBillingOtherOmitsEmptyUsageFacts(t *testing.T) { ...@@ -277,7 +284,7 @@ func TestTaskBillingOtherOmitsEmptyUsageFacts(t *testing.T) {
UsageFacts: map[string]any{}, UsageFacts: map[string]any{},
} }
other := taskBillingOther(task) other := taskBillingOtherMap(t, taskBillingOther(task))
assert.Equal(t, "tiered_expr", other["billing_mode"]) assert.Equal(t, "tiered_expr", other["billing_mode"])
assert.Equal(t, base64.StdEncoding.EncodeToString([]byte(expression)), other["expr_b64"]) assert.Equal(t, base64.StdEncoding.EncodeToString([]byte(expression)), other["expr_b64"])
...@@ -401,7 +408,7 @@ func TestTaskBillingOtherSeparatesPluginAndRootDiagnostics(t *testing.T) { ...@@ -401,7 +408,7 @@ func TestTaskBillingOtherSeparatesPluginAndRootDiagnostics(t *testing.T) {
}, },
} }
other := taskBillingOther(task) other := taskBillingOtherMap(t, taskBillingOther(task))
assert.Equal(t, "task_public", other["task_id"]) assert.Equal(t, "task_public", other["task_id"])
adminInfo, ok := other["admin_info"].(map[string]interface{}) adminInfo, ok := other["admin_info"].(map[string]interface{})
...@@ -421,7 +428,7 @@ func TestTaskBillingOtherSeparatesPluginAndRootDiagnostics(t *testing.T) { ...@@ -421,7 +428,7 @@ func TestTaskBillingOtherSeparatesPluginAndRootDiagnostics(t *testing.T) {
assert.Equal(t, "node-a", rootInfo["node_name"]) assert.Equal(t, "node-a", rootInfo["node_name"])
runtimeInfo, ok := rootInfo["task_plugin"].(map[string]interface{}) runtimeInfo, ok := rootInfo["task_plugin"].(map[string]interface{})
require.True(t, ok) require.True(t, ok)
assert.Equal(t, uint64(42), runtimeInfo["generation"]) assert.Equal(t, float64(42), runtimeInfo["generation"])
assert.NotContains(t, runtimeInfo, "author") assert.NotContains(t, runtimeInfo, "author")
} }
......
...@@ -51,15 +51,10 @@ func TaskExecutionSnapshotFromContext(ctx *gin.Context) *model.TaskExecutionSnap ...@@ -51,15 +51,10 @@ func TaskExecutionSnapshotFromContext(ctx *gin.Context) *model.TaskExecutionSnap
// AppendTaskPluginAuditInfo writes role-separated, credential-free plugin // AppendTaskPluginAuditInfo writes role-separated, credential-free plugin
// provenance into a usage log. // provenance into a usage log.
func AppendTaskPluginAuditInfo(other map[string]interface{}, snapshot *model.TaskPluginSnapshot) { func AppendTaskPluginAuditInfo(other *model.LogOther, snapshot *model.TaskPluginSnapshot) {
if other == nil || snapshot == nil || snapshot.Key == "" { if other == nil || snapshot == nil || snapshot.Key == "" {
return return
} }
adminInfo, ok := other["admin_info"].(map[string]interface{})
if !ok || adminInfo == nil {
adminInfo = map[string]interface{}{}
other["admin_info"] = adminInfo
}
taskPlugin := map[string]interface{}{ taskPlugin := map[string]interface{}{
"key": snapshot.Key, "key": snapshot.Key,
"name": snapshot.Name, "name": snapshot.Name,
...@@ -72,24 +67,18 @@ func AppendTaskPluginAuditInfo(other map[string]interface{}, snapshot *model.Tas ...@@ -72,24 +67,18 @@ func AppendTaskPluginAuditInfo(other map[string]interface{}, snapshot *model.Tas
} }
taskPlugin["author"] = author taskPlugin["author"] = author
} }
adminInfo["task_plugin"] = taskPlugin other.SetAdmin("task_plugin", taskPlugin)
other.SetRoot("task_plugin", map[string]interface{}{
rootInfo, ok := other["root_info"].(map[string]interface{})
if !ok || rootInfo == nil {
rootInfo = map[string]interface{}{}
other["root_info"] = rootInfo
}
rootInfo["task_plugin"] = map[string]interface{}{
"key": snapshot.Key, "key": snapshot.Key,
"version": snapshot.Version, "version": snapshot.Version,
"api_version": snapshot.APIVersion, "api_version": snapshot.APIVersion,
"generation": snapshot.Generation, "generation": snapshot.Generation,
} })
} }
// AppendTaskPluginContextAuditInfo is used before a task row exists, such as // AppendTaskPluginContextAuditInfo is used before a task row exists, such as
// an upstream submission error log. // an upstream submission error log.
func AppendTaskPluginContextAuditInfo(ctx *gin.Context, other map[string]interface{}) { func AppendTaskPluginContextAuditInfo(ctx *gin.Context, other *model.LogOther) {
execution := TaskExecutionSnapshotFromContext(ctx) execution := TaskExecutionSnapshotFromContext(ctx)
if execution == nil { if execution == nil {
return return
......
...@@ -31,11 +31,11 @@ type ToolSurchargeItem struct { ...@@ -31,11 +31,11 @@ type ToolSurchargeItem struct {
Price float64 `json:"price"` Price float64 `json:"price"`
} }
func appendToolSurchargeLogInfo(other map[string]interface{}, items []ToolSurchargeItem) { func appendToolSurchargeLogInfo(other *model.LogOther, items []ToolSurchargeItem) {
if len(items) == 0 { if len(items) == 0 {
return return
} }
other["tool_surcharges"] = items other.SetPublic("tool_surcharges", items)
} }
type textQuotaSummary struct { type textQuotaSummary struct {
...@@ -463,7 +463,7 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us ...@@ -463,7 +463,7 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
} }
logContent := strings.Join(extraContent, ", ") logContent := strings.Join(extraContent, ", ")
var other map[string]interface{} var other *model.LogOther
if summary.IsClaudeUsageSemantic { if summary.IsClaudeUsageSemantic {
other = GenerateClaudeOtherInfo(ctx, relayInfo, other = GenerateClaudeOtherInfo(ctx, relayInfo,
summary.ModelRatio, summary.GroupRatio, summary.CompletionRatio, summary.ModelRatio, summary.GroupRatio, summary.CompletionRatio,
...@@ -472,50 +472,50 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us ...@@ -472,50 +472,50 @@ func PostTextConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, us
summary.CacheCreationTokens5m, summary.CacheCreationRatio5m, summary.CacheCreationTokens5m, summary.CacheCreationRatio5m,
summary.CacheCreationTokens1h, summary.CacheCreationRatio1h, summary.CacheCreationTokens1h, summary.CacheCreationRatio1h,
summary.ModelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio) summary.ModelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
other["usage_semantic"] = "anthropic" other.SetPublic("usage_semantic", "anthropic")
} else { } else {
other = GenerateTextOtherInfo(ctx, relayInfo, summary.ModelRatio, summary.GroupRatio, summary.CompletionRatio, summary.CacheTokens, summary.CacheRatio, summary.ModelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio) other = GenerateTextOtherInfo(ctx, relayInfo, summary.ModelRatio, summary.GroupRatio, summary.CompletionRatio, summary.CacheTokens, summary.CacheRatio, summary.ModelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio)
} }
appendUsageBillingPathForLog(other, common.GetContextKeyBool(ctx, constant.ContextKeyLocalCountTokens), originUsage) appendUsageBillingPathForLog(other, common.GetContextKeyBool(ctx, constant.ContextKeyLocalCountTokens), originUsage)
if adminRejectReason != "" { if adminRejectReason != "" {
other["reject_reason"] = adminRejectReason other.SetAdmin("reject_reason", adminRejectReason)
} }
if summary.ImageTokens != 0 { if summary.ImageTokens != 0 {
other["image"] = true other.SetPublic("image", true)
other["image_ratio"] = summary.ImageRatio other.SetPublic("image_ratio", summary.ImageRatio)
other["image_output"] = summary.ImageTokens other.SetPublic("image_output", summary.ImageTokens)
} }
appendToolSurchargeLogInfo(other, summary.ToolSurchargeItems) appendToolSurchargeLogInfo(other, summary.ToolSurchargeItems)
if summary.AudioInputPrice > 0 && summary.AudioTokens > 0 { if summary.AudioInputPrice > 0 && summary.AudioTokens > 0 {
other["audio_input_seperate_price"] = true other.SetPublic("audio_input_seperate_price", true)
other["audio_input_token_count"] = summary.AudioTokens other.SetPublic("audio_input_token_count", summary.AudioTokens)
other["audio_input_price"] = summary.AudioInputPrice other.SetPublic("audio_input_price", summary.AudioInputPrice)
} }
if summary.CacheCreationTokens > 0 { if summary.CacheCreationTokens > 0 {
other["cache_creation_tokens"] = summary.CacheCreationTokens other.SetPublic("cache_creation_tokens", summary.CacheCreationTokens)
other["cache_creation_ratio"] = summary.CacheCreationRatio other.SetPublic("cache_creation_ratio", summary.CacheCreationRatio)
} }
if summary.CacheCreationTokens5m > 0 { if summary.CacheCreationTokens5m > 0 {
other["cache_creation_tokens_5m"] = summary.CacheCreationTokens5m other.SetPublic("cache_creation_tokens_5m", summary.CacheCreationTokens5m)
other["cache_creation_ratio_5m"] = summary.CacheCreationRatio5m other.SetPublic("cache_creation_ratio_5m", summary.CacheCreationRatio5m)
} }
if summary.CacheCreationTokens1h > 0 { if summary.CacheCreationTokens1h > 0 {
other["cache_creation_tokens_1h"] = summary.CacheCreationTokens1h other.SetPublic("cache_creation_tokens_1h", summary.CacheCreationTokens1h)
other["cache_creation_ratio_1h"] = summary.CacheCreationRatio1h other.SetPublic("cache_creation_ratio_1h", summary.CacheCreationRatio1h)
} }
cacheWriteTokens := cacheWriteTokensTotal(summary) cacheWriteTokens := cacheWriteTokensTotal(summary)
if cacheWriteTokens > 0 { if cacheWriteTokens > 0 {
// cache_write_tokens: normalized cache creation total for UI display. // cache_write_tokens: normalized cache creation total for UI display.
// If split 5m/1h values are present, this is their sum; otherwise it falls back // If split 5m/1h values are present, this is their sum; otherwise it falls back
// to cache_creation_tokens. // to cache_creation_tokens.
other["cache_write_tokens"] = cacheWriteTokens other.SetPublic("cache_write_tokens", cacheWriteTokens)
} }
if relayInfo.GetFinalRequestRelayFormat() != types.RelayFormatClaude && billingUsage != nil && billingUsage.UsageSource != "" && billingUsage.InputTokens > 0 { if relayInfo.GetFinalRequestRelayFormat() != types.RelayFormatClaude && billingUsage != nil && billingUsage.UsageSource != "" && billingUsage.InputTokens > 0 {
// input_tokens_total: explicit normalized total input used by the usage log UI. // input_tokens_total: explicit normalized total input used by the usage log UI.
// Only write this field when upstream/current conversion has already provided a // Only write this field when upstream/current conversion has already provided a
// reliable total input value and tagged the usage source. Do not infer it from // reliable total input value and tagged the usage source. Do not infer it from
// prompt/cache fields here, otherwise old upstream payloads may be double-counted. // prompt/cache fields here, otherwise old upstream payloads may be double-counted.
other["input_tokens_total"] = billingUsage.InputTokens other.SetPublic("input_tokens_total", billingUsage.InputTokens)
} }
if tieredBillingApplied { if tieredBillingApplied {
InjectTieredBillingInfo(other, relayInfo, tieredResult) InjectTieredBillingInfo(other, relayInfo, tieredResult)
......
...@@ -8,6 +8,7 @@ import ( ...@@ -8,6 +8,7 @@ import (
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr" "github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common" relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant" relayconstant "github.com/QuantumNous/new-api/relay/constant"
...@@ -445,20 +446,21 @@ func TestUsageBillingPathForLog(t *testing.T) { ...@@ -445,20 +446,21 @@ func TestUsageBillingPathForLog(t *testing.T) {
} }
func TestAppendUsageBillingPathForLogWritesAdminInfo(t *testing.T) { func TestAppendUsageBillingPathForLogWritesAdminInfo(t *testing.T) {
other := map[string]interface{}{ other := model.NewLogOther()
"admin_info": map[string]interface{}{},
}
appendUsageBillingPathForLog(other, true, &dto.Usage{ appendUsageBillingPathForLog(other, true, &dto.Usage{
BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{InputTokens: 1}), BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{InputTokens: 1}),
}) })
adminInfo, ok := other["admin_info"].(map[string]interface{}) var values map[string]interface{}
require.NoError(t, common.UnmarshalJsonStr(other.JSONString(), &values))
adminInfo, ok := values["admin_info"].(map[string]interface{})
require.True(t, ok) require.True(t, ok)
require.Equal(t, usageBillingPathAnthropic, adminInfo["usage_billing_path"]) require.Equal(t, usageBillingPathAnthropic, adminInfo["usage_billing_path"])
other = map[string]interface{}{} other = model.NewLogOther()
appendUsageBillingPathForLog(other, true, nil) appendUsageBillingPathForLog(other, true, nil)
adminInfo, ok = other["admin_info"].(map[string]interface{}) require.NoError(t, common.UnmarshalJsonStr(other.JSONString(), &values))
adminInfo, ok = values["admin_info"].(map[string]interface{})
require.True(t, ok) require.True(t, ok)
require.Equal(t, usageBillingPathLocal, adminInfo["usage_billing_path"]) require.Equal(t, usageBillingPathLocal, adminInfo["usage_billing_path"])
} }
...@@ -1118,9 +1120,9 @@ func TestCalculateTextToolCallSurchargeGeminiFunctionCall(t *testing.T) { ...@@ -1118,9 +1120,9 @@ func TestCalculateTextToolCallSurchargeGeminiFunctionCall(t *testing.T) {
assert.Equal(t, 2, summary.ToolSurchargeItems[0].Count) assert.Equal(t, 2, summary.ToolSurchargeItems[0].Count)
assert.Equal(t, 5.0, summary.ToolSurchargeItems[0].Price) assert.Equal(t, 5.0, summary.ToolSurchargeItems[0].Price)
other := map[string]interface{}{} other := model.NewLogOther()
appendToolSurchargeLogInfo(other, summary.ToolSurchargeItems) appendToolSurchargeLogInfo(other, summary.ToolSurchargeItems)
assert.Equal(t, summary.ToolSurchargeItems, other["tool_surcharges"]) assert.Equal(t, summary.ToolSurchargeItems, other.Snapshot()["tool_surcharges"])
} }
func TestCalculateTextToolCallSurchargeImageGenerationDefaultPrice(t *testing.T) { func TestCalculateTextToolCallSurchargeImageGenerationDefaultPrice(t *testing.T) {
...@@ -1214,15 +1216,16 @@ func TestAppendToolSurchargeLogInfoWritesOnlyStructuredFields(t *testing.T) { ...@@ -1214,15 +1216,16 @@ func TestAppendToolSurchargeLogInfoWritesOnlyStructuredFields(t *testing.T) {
{Name: dto.BuildInToolWebSearch, Count: 2, Price: 10}, {Name: dto.BuildInToolWebSearch, Count: 2, Price: 10},
{Name: dto.BuildInToolImageGeneration, Count: 1, Price: 150}, {Name: dto.BuildInToolImageGeneration, Count: 1, Price: 150},
} }
other := map[string]interface{}{} other := model.NewLogOther()
appendToolSurchargeLogInfo(other, items) appendToolSurchargeLogInfo(other, items)
assert.Equal(t, items, other["tool_surcharges"]) fields := other.Snapshot()
assert.NotContains(t, other, "web_search") assert.Equal(t, items, fields["tool_surcharges"])
assert.NotContains(t, other, "web_search_call_count") assert.NotContains(t, fields, "web_search")
assert.NotContains(t, other, "web_search_price") assert.NotContains(t, fields, "web_search_call_count")
assert.NotContains(t, other, "file_search") assert.NotContains(t, fields, "web_search_price")
assert.NotContains(t, other, "image_generation_call") assert.NotContains(t, fields, "file_search")
assert.NotContains(t, other, "image_generation_call_price") assert.NotContains(t, fields, "image_generation_call")
assert.NotContains(t, fields, "image_generation_call_price")
} }
...@@ -134,7 +134,8 @@ func ChargeViolationFeeIfNeeded(ctx *gin.Context, relayInfo *relaycommon.RelayIn ...@@ -134,7 +134,8 @@ func ChargeViolationFeeIfNeeded(ctx *gin.Context, relayInfo *relaycommon.RelayIn
tokenName := ctx.GetString("token_name") tokenName := ctx.GetString("token_name")
oai := apiErr.ToOpenAIError() oai := apiErr.ToOpenAIError()
other := map[string]any{ other := model.NewLogOther()
other.MergePublic(map[string]interface{}{
"violation_fee": true, "violation_fee": true,
"violation_fee_code": string(types.ErrorCodeViolationFeeGrokCSAM), "violation_fee_code": string(types.ErrorCodeViolationFeeGrokCSAM),
"fee_quota": feeQuota, "fee_quota": feeQuota,
...@@ -144,7 +145,7 @@ func ChargeViolationFeeIfNeeded(ctx *gin.Context, relayInfo *relaycommon.RelayIn ...@@ -144,7 +145,7 @@ func ChargeViolationFeeIfNeeded(ctx *gin.Context, relayInfo *relaycommon.RelayIn
"upstream_error_type": oai.Type, "upstream_error_type": oai.Type,
"upstream_error_code": fmt.Sprintf("%v", oai.Code), "upstream_error_code": fmt.Sprintf("%v", oai.Code),
"violation_fee_marker": CSAMViolationMarker, "violation_fee_marker": CSAMViolationMarker,
} })
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{ model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId, ChannelId: relayInfo.ChannelId,
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import { afterEach, describe, expect, test } from 'vitest'
import type { UsageLog } from '../../data/schema'
import type { LogOtherData } from '../../types'
import { DetailsDialog } from '../dialogs/details-dialog'
const queryClients: QueryClient[] = []
function makeLog(other: LogOtherData): UsageLog {
return {
id: 1,
user_id: 1,
created_at: 1,
type: 5,
content: 'request rejected',
username: 'user',
token_name: 'token',
model_name: 'gpt-test',
quota: 0,
prompt_tokens: 0,
completion_tokens: 0,
use_time: 0,
is_stream: false,
channel: 1,
channel_name: 'channel',
token_id: 1,
group: 'default',
ip: '',
other: JSON.stringify(other),
request_id: 'req-1',
upstream_request_id: '',
}
}
function renderDetails(isAdmin: boolean): void {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
const freshAt = Date.now() + 60_000
queryClient.setQueryData(['status'], {}, { updatedAt: freshAt })
queryClients.push(queryClient)
render(
<QueryClientProvider client={queryClient}>
<DetailsDialog
log={makeLog({
admin_info: {
reject_reason: 'blocked by channel policy',
},
})}
isAdmin={isAdmin}
isRoot={false}
open
onOpenChange={() => undefined}
/>
</QueryClientProvider>
)
}
afterEach(() => {
for (const queryClient of queryClients) {
queryClient.clear()
}
queryClients.length = 0
})
describe('usage log reject reason', () => {
test('shows the nested admin reject reason to admins', () => {
renderDetails(true)
expect(screen.getByText('Reject Reason')).toBeInTheDocument()
expect(screen.getByText('blocked by channel policy')).toBeInTheDocument()
})
test('hides the nested admin reject reason from non-admin users', () => {
renderDetails(false)
expect(screen.queryByText('Reject Reason')).toBeNull()
expect(screen.queryByText('blocked by channel policy')).toBeNull()
})
})
...@@ -841,13 +841,13 @@ export function DetailsDialog(props: DetailsDialogProps) { ...@@ -841,13 +841,13 @@ export function DetailsDialog(props: DetailsDialogProps) {
)} )}
{/* Reject reason (admin only) */} {/* Reject reason (admin only) */}
{props.isAdmin && other?.reject_reason && ( {props.isAdmin && adminInfo?.reject_reason && (
<DetailSection <DetailSection
icon={<AlertTriangle className='size-3.5' aria-hidden='true' />} icon={<AlertTriangle className='size-3.5' aria-hidden='true' />}
label={t('Reject Reason')} label={t('Reject Reason')}
variant='danger' variant='danger'
> >
<p className='text-xs wrap-break-word'>{other.reject_reason}</p> <p className='text-xs wrap-break-word'>{adminInfo.reject_reason}</p>
</DetailSection> </DetailSection>
)} )}
......
...@@ -142,6 +142,8 @@ export interface LogOtherData { ...@@ -142,6 +142,8 @@ export interface LogOtherData {
original: number original: number
clamped: number clamped: number
} }
// Reject / intercept reason (admin only)
reject_reason?: string
task_plugin?: TaskPluginInfo task_plugin?: TaskPluginInfo
} }
root_info?: { root_info?: {
...@@ -236,8 +238,6 @@ export interface LogOtherData { ...@@ -236,8 +238,6 @@ export interface LogOtherData {
violation_fee_code?: string violation_fee_code?: string
violation_fee_marker?: string violation_fee_marker?: string
fee_quota?: number fee_quota?: number
// Reject / intercept reason (admin)
reject_reason?: string
// Task-related fields (for refund logs, type=6) // Task-related fields (for refund logs, type=6)
is_task?: boolean is_task?: boolean
task_id?: string task_id?: string
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment