diff --git a/controller/relay.go b/controller/relay.go index 55fd96f..48734d2 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -607,7 +607,11 @@ func buildTaskBillingContext(info *relaycommon.RelayInfo) *model.TaskBillingCont bc.BillingUnit = decision.BillingUnit bc.TokenUnitPriceUSD = decision.TokenUnitPriceUSD bc.PerCallBilling = decision.PerCallBilling - bc.ModelPrice = decision.PriceUSD + if decision.BillingUnit == types.BillingUnitPer1MTokens { + bc.ModelPrice = decision.TokenUnitPriceUSD + } else { + bc.ModelPrice = decision.PriceUSD + } bc.GroupRatio = decision.GroupRatioInfo.GroupRatio bc.OtherRatios = types.CloneRatios(decision.OtherRatios) } diff --git a/model/log.go b/model/log.go index 416f26f..3dd6d1b 100644 --- a/model/log.go +++ b/model/log.go @@ -211,15 +211,16 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) } type RecordTaskBillingLogParams struct { - UserId int - LogType int - Content string - ChannelId int - ModelName string - Quota int - TokenId int - Group string - Other map[string]interface{} + UserId int + LogType int + Content string + ChannelId int + ModelName string + Quota int + TokenId int + Group string + CompletionTokens int + Other map[string]interface{} } func RecordTaskBillingLog(params RecordTaskBillingLogParams) { @@ -234,18 +235,19 @@ func RecordTaskBillingLog(params RecordTaskBillingLogParams) { } } log := &Log{ - UserId: params.UserId, - Username: username, - CreatedAt: common.GetTimestamp(), - Type: params.LogType, - Content: params.Content, - TokenName: tokenName, - ModelName: params.ModelName, - Quota: params.Quota, - ChannelId: params.ChannelId, - TokenId: params.TokenId, - Group: params.Group, - Other: common.MapToJsonStr(params.Other), + UserId: params.UserId, + Username: username, + CreatedAt: common.GetTimestamp(), + Type: params.LogType, + Content: params.Content, + TokenName: tokenName, + ModelName: params.ModelName, + Quota: params.Quota, + ChannelId: params.ChannelId, + TokenId: params.TokenId, + Group: params.Group, + CompletionTokens: params.CompletionTokens, + Other: common.MapToJsonStr(params.Other), } err := LOG_DB.Create(log).Error if err != nil { diff --git a/relay/relay_task.go b/relay/relay_task.go index 4d7c41e..dded3e3 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -521,6 +521,26 @@ func TaskModel2Dto(task *model.Task) *dto.TaskDto { Progress: task.Progress, Properties: task.Properties, Username: task.Username, - Data: task.Data, + Data: sanitizeTaskDtoData(task), } } + +func sanitizeTaskDtoData(task *model.Task) []byte { + if task == nil || len(task.Data) == 0 { + return nil + } + payload := map[string]any{} + if err := common.Unmarshal(task.Data, &payload); err != nil { + return task.Data + } + if _, ok := payload["aiping_id"]; !ok { + return task.Data + } + delete(payload, "aiping_id") + payload["id"] = task.TaskID + data, err := common.Marshal(payload) + if err != nil { + return task.Data + } + return data +} diff --git a/relay/relay_task_test.go b/relay/relay_task_test.go index c62bf4c..8fb84b0 100644 --- a/relay/relay_task_test.go +++ b/relay/relay_task_test.go @@ -200,6 +200,25 @@ func TestTaskModel2DtoDoesNotExposeUpstreamRequestSnapshot(t *testing.T) { require.NotContains(t, string(data), "hidden") } +func TestTaskModel2DtoSanitizesAipingInternalIDFromData(t *testing.T) { + task := &model.Task{ + TaskID: "task_public", + Data: []byte(`{ + "id":"cgt-upstream", + "aiping_id":"b7ee658f-acac-433d-897d-5c9ed55ef402", + "status":"succeeded" + }`), + } + + dtoTask := TaskModel2Dto(task) + data, err := common.Marshal(dtoTask) + + require.NoError(t, err) + require.NotContains(t, string(data), "aiping_id") + require.NotContains(t, string(data), "b7ee658f-acac-433d-897d-5c9ed55ef402") + require.Contains(t, string(data), `"id":"task_public"`) +} + func newRelayTaskRemixContext(t *testing.T, originTaskID string) *gin.Context { t.Helper() gin.SetMode(gin.TestMode) diff --git a/service/task_billing.go b/service/task_billing.go index 87eef0b..18fc066 100644 --- a/service/task_billing.go +++ b/service/task_billing.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "fmt" "strings" @@ -11,6 +12,7 @@ import ( "github.com/QuantumNous/new-api/model" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" ) @@ -39,6 +41,21 @@ func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) { other["request_path"] = c.Request.URL.Path other["model_price"] = info.PriceData.ModelPrice other["group_ratio"] = info.PriceData.GroupRatioInfo.GroupRatio + other["billing_phase"] = "preconsume" + other["pre_consumed_quota"] = info.PriceData.Quota + if info.TaskRelayInfo != nil && info.TaskRelayInfo.PublicTaskID != "" { + other["task_id"] = info.TaskRelayInfo.PublicTaskID + } + if decision := info.PricingDecisionFrozen; decision != nil && decision.BillingMode == types.BillingModeMatrix { + other["billing_mode"] = decision.BillingMode + other["billing_unit"] = decision.BillingUnit + if decision.TokenUnitPriceUSD > 0 { + other["token_unit_price_usd"] = decision.TokenUnitPriceUSD + } + if decision.Snapshot != nil { + other["pricing_snapshot"] = types.CloneMapAny(decision.Snapshot) + } + } if info.PriceData.GroupRatioInfo.HasSpecialRatio { other["user_group_ratio"] = info.PriceData.GroupRatioInfo.GroupSpecialRatio } @@ -146,6 +163,35 @@ func taskModelName(task *model.Task) string { return task.Properties.OriginModelName } +func taskCompletionTokens(task *model.Task) int { + if bc := task.PrivateData.BillingContext; bc != nil && bc.PricingSnapshot != nil { + if tt, ok := bc.PricingSnapshot["total_tokens"]; ok { + switch v := tt.(type) { + case float64: + return int(v) + case int: + return v + case json.Number: + if n, err := v.Int64(); err == nil { + return int(n) + } + } + } + } + return 0 +} + +func taskSettlementOther(task *model.Task, reason string, preConsumedQuota, actualQuota, quotaDelta int) map[string]interface{} { + other := taskBillingOther(task) + other["task_id"] = task.TaskID + other["billing_phase"] = "settlement" + other["reason"] = reason + other["pre_consumed_quota"] = preConsumedQuota + other["actual_quota"] = actualQuota + other["quota_delta"] = quotaDelta + return other +} + // RefundTaskQuota 统一的任务失败退款逻辑。 // 当异步任务失败时,将预扣的 quota 退还给用户(支持钱包和订阅),并退还令牌额度。 func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) { @@ -166,7 +212,9 @@ func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) { // 3. 记录日志 other := taskBillingOther(task) other["task_id"] = task.TaskID + other["billing_phase"] = "refund" other["reason"] = reason + other["pre_consumed_quota"] = quota model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{ UserId: task.UserId, LogType: model.LogTypeRefund, @@ -193,6 +241,18 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int if quotaDelta == 0 { logger.LogInfo(ctx, fmt.Sprintf("任务 %s 预扣费准确(%s,%s)", task.TaskID, logger.LogQuota(actualQuota), reason)) + model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{ + UserId: task.UserId, + LogType: model.LogTypeConsume, + Content: "", + ChannelId: task.ChannelId, + ModelName: taskModelName(task), + Quota: 0, + TokenId: task.PrivateData.TokenId, + Group: task.Group, + CompletionTokens: taskCompletionTokens(task), + Other: taskSettlementOther(task, reason, preConsumedQuota, actualQuota, quotaDelta), + }) return } @@ -226,21 +286,17 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int logType = model.LogTypeRefund logQuota = -quotaDelta } - other := taskBillingOther(task) - other["task_id"] = task.TaskID - other["reason"] = reason - other["pre_consumed_quota"] = preConsumedQuota - other["actual_quota"] = actualQuota model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{ - UserId: task.UserId, - LogType: logType, - Content: "", - ChannelId: task.ChannelId, - ModelName: taskModelName(task), - Quota: logQuota, - TokenId: task.PrivateData.TokenId, - Group: task.Group, - Other: other, + UserId: task.UserId, + LogType: logType, + Content: "", + ChannelId: task.ChannelId, + ModelName: taskModelName(task), + Quota: logQuota, + TokenId: task.PrivateData.TokenId, + Group: task.Group, + CompletionTokens: taskCompletionTokens(task), + Other: taskSettlementOther(task, reason, preConsumedQuota, actualQuota, quotaDelta), }) } diff --git a/service/task_billing_test.go b/service/task_billing_test.go index 1145bba..7cb48d4 100644 --- a/service/task_billing_test.go +++ b/service/task_billing_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "net/http" + "net/http/httptest" "os" "testing" "time" @@ -11,6 +12,8 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -125,8 +128,8 @@ func makeTask(userId, channelId, quota, tokenId int, billingSource string, subsc SubscriptionId: subscriptionId, TokenId: tokenId, BillingContext: &model.TaskBillingContext{ - ModelPrice: 0.02, - GroupRatio: 1.0, + ModelPrice: 0.02, + GroupRatio: 1.0, OriginModelName: "test-model", }, }, @@ -175,6 +178,14 @@ func getLastLog(t *testing.T) *model.Log { return &log } +func getLogOtherMap(t *testing.T, log *model.Log) map[string]any { + t.Helper() + require.NotNil(t, log) + other := map[string]any{} + require.NoError(t, common.UnmarshalJsonStr(log.Other, &other)) + return other +} + func countLogs(t *testing.T) int64 { t.Helper() var count int64 @@ -217,6 +228,61 @@ func TestRefundTaskQuota_Wallet(t *testing.T) { assert.Equal(t, "test-model", log.ModelName) } +func TestLogTaskConsumption_MatrixPreconsumeIncludesTaskBillingMetadata(t *testing.T) { + truncate(t) + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(httptest.NewRecorder()) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil) + ctx.Set("token_name", "task-token") + + const userID, channelID = 40, 40 + seedUser(t, userID, 10000) + seedChannel(t, channelID) + + info := &relaycommon.RelayInfo{ + UserId: userID, + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelId: channelID, + }, + TokenId: 400, + UsingGroup: "default", + OriginModelName: "matrix-model", + TaskRelayInfo: &relaycommon.TaskRelayInfo{Action: "submit", PublicTaskID: "task_public_123"}, + PriceData: types.PriceData{ + ModelPrice: 46, + Quota: 1, + GroupRatioInfo: types.GroupRatioInfo{ + GroupRatio: 1, + }, + }, + PricingDecisionFrozen: &types.PricingDecision{ + BillingMode: types.BillingModeMatrix, + BillingUnit: types.BillingUnitPer1MTokens, + TokenUnitPriceUSD: 46, + Snapshot: map[string]any{ + "billing_unit": types.BillingUnitPer1MTokens, + "resolution": "1080p", + }, + }, + } + + LogTaskConsumption(ctx, info) + + log := getLastLog(t) + require.NotNil(t, log) + assert.Equal(t, model.LogTypeConsume, log.Type) + assert.Equal(t, 1, log.Quota) + + other := getLogOtherMap(t, log) + assert.Equal(t, "preconsume", other["billing_phase"]) + assert.Equal(t, "task_public_123", other["task_id"]) + assert.Equal(t, float64(1), other["pre_consumed_quota"]) + assert.Equal(t, types.BillingModeMatrix, other["billing_mode"]) + assert.Equal(t, types.BillingUnitPer1MTokens, other["billing_unit"]) + assert.Equal(t, float64(46), other["token_unit_price_usd"]) + require.IsType(t, map[string]any{}, other["pricing_snapshot"]) +} + func TestRefundTaskQuota_Subscription(t *testing.T) { truncate(t) ctx := context.Background() @@ -244,6 +310,11 @@ func TestRefundTaskQuota_Subscription(t *testing.T) { log := getLastLog(t) require.NotNil(t, log) assert.Equal(t, model.LogTypeRefund, log.Type) + + other := getLogOtherMap(t, log) + assert.Equal(t, "refund", other["billing_phase"]) + assert.Equal(t, task.TaskID, other["task_id"]) + assert.Equal(t, float64(preConsumed), other["pre_consumed_quota"]) } func TestRefundTaskQuota_ZeroQuota(t *testing.T) { @@ -322,6 +393,13 @@ func TestRecalculate_PositiveDelta(t *testing.T) { require.NotNil(t, log) assert.Equal(t, model.LogTypeConsume, log.Type) assert.Equal(t, actualQuota-preConsumed, log.Quota) + + other := getLogOtherMap(t, log) + assert.Equal(t, "settlement", other["billing_phase"]) + assert.Equal(t, task.TaskID, other["task_id"]) + assert.Equal(t, float64(preConsumed), other["pre_consumed_quota"]) + assert.Equal(t, float64(actualQuota), other["actual_quota"]) + assert.Equal(t, float64(actualQuota-preConsumed), other["quota_delta"]) } func TestRecalculate_NegativeDelta(t *testing.T) { @@ -363,18 +441,29 @@ func TestRecalculate_ZeroDelta(t *testing.T) { const userID = 12 const initQuota, preConsumed = 10000, 3000 + const totalTokens = 48400 seedUser(t, userID, initQuota) task := makeTask(userID, 0, preConsumed, 0, BillingSourceWallet, 0) + task.PrivateData.BillingContext.PricingSnapshot = map[string]any{ + "total_tokens": float64(totalTokens), + } RecalculateTaskQuota(ctx, task, preConsumed, "exact match") // No change to user quota assert.Equal(t, initQuota, getUserQuota(t, userID)) - // No log created (delta is zero) - assert.Equal(t, int64(0), countLogs(t)) + log := getLastLog(t) + require.NotNil(t, log) + assert.Equal(t, model.LogTypeConsume, log.Type) + assert.Equal(t, 0, log.Quota) + assert.Equal(t, totalTokens, log.CompletionTokens) + + other := getLogOtherMap(t, log) + assert.Equal(t, "settlement", other["billing_phase"]) + assert.Equal(t, float64(0), other["quota_delta"]) } func TestRecalculate_ActualQuotaZero(t *testing.T) { @@ -615,9 +704,11 @@ type mockAdaptor struct { adjustReturn int } -func (m *mockAdaptor) Init(_ *relaycommon.RelayInfo) {} -func (m *mockAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) { return nil, nil } -func (m *mockAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) { return nil, nil } +func (m *mockAdaptor) Init(_ *relaycommon.RelayInfo) {} +func (m *mockAdaptor) FetchTask(string, string, map[string]any, string) (*http.Response, error) { + return nil, nil +} +func (m *mockAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) { return nil, nil } func (m *mockAdaptor) AdjustBillingOnComplete(_ *model.Task, _ *relaycommon.TaskInfo) int { return m.adjustReturn } diff --git a/web/src/components/table/task-logs/modals/ContentModal.jsx b/web/src/components/table/task-logs/modals/ContentModal.jsx index 3527fd9..01c3731 100644 --- a/web/src/components/table/task-logs/modals/ContentModal.jsx +++ b/web/src/components/table/task-logs/modals/ContentModal.jsx @@ -162,7 +162,7 @@ const ContentModal = ({ height: isVideo ? '70vh' : '400px', maxHeight: '80vh', overflow: 'auto', - padding: isVideo && videoError ? '0' : '24px', + padding: isVideo && videoError ? '0' : '18px 20px 20px', }} width={isVideo ? '90vw' : 800} style={isVideo ? { maxWidth: 960 } : undefined} @@ -170,7 +170,29 @@ const ContentModal = ({ {isVideo ? ( renderVideoContent() ) : ( -
{modalContent}
+
+ {modalContent}
+
+
+ {typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
+
+ );
+
+ const buildSectionedExpandData = (flatItems, log, other) => {
+ const sections = {};
+ const knownKeys = new Set();
+ const addKnown = (sectionKey, key, value) => {
+ knownKeys.add(key);
+ pushDetail(sections, sectionKey, { key, value });
+ };
+
+ addKnown('basic', t('时间'), log.timestamp2string);
+ addKnown('basic', t('模型'), log.model_name);
+ addKnown('basic', t('令牌'), log.token_name);
+ addKnown('basic', t('分组'), log.group);
+ if (isAdminUser) {
+ addKnown('basic', t('用户'), log.username);
+ }
+ if (other?.task_id) {
+ addKnown('basic', t('任务ID'), other.task_id);
+ }
+ if (other?.request_path) {
+ addKnown('basic', t('请求路径'), other.request_path);
+ }
+
+ addKnown('billing', t('花费'), renderQuota(log.quota, 6));
+ addKnown('billing', t('Prompt Tokens'), log.prompt_tokens);
+ addKnown('billing', t('Completion Tokens'), log.completion_tokens);
+ if (other?.model_price !== undefined) {
+ const quotaPerUnit = parseFloat(
+ localStorage.getItem('quota_per_unit') || '500000',
+ );
+ addKnown(
+ 'billing',
+ t('模型价格'),
+ renderQuota(other.model_price * quotaPerUnit, 6),
+ );
+ }
+ if (other?.token_unit_price_usd !== undefined) {
+ const quotaPerUnit = parseFloat(
+ localStorage.getItem('quota_per_unit') || '500000',
+ );
+ addKnown(
+ 'billing',
+ t('Token 单价'),
+ renderQuota(other.token_unit_price_usd * quotaPerUnit, 6),
+ );
+ }
+ if (other?.group_ratio !== undefined) {
+ addKnown('billing', t('分组倍率'), other.group_ratio);
+ }
+ if (other?.user_group_ratio !== undefined) {
+ addKnown('billing', t('用户分组倍率'), other.user_group_ratio);
+ }
+ if (other?.billing_mode) {
+ addKnown('billing', t('计费模式'), other.billing_mode);
+ }
+ if (other?.billing_unit) {
+ addKnown('billing', t('计费单位'), other.billing_unit);
+ }
+ if (other?.billing_phase) {
+ addKnown('billing', t('计费阶段'), other.billing_phase);
+ }
+ if (other?.pre_consumed_quota !== undefined) {
+ addKnown(
+ 'billing',
+ t('预扣金额'),
+ renderQuota(other.pre_consumed_quota, 6),
+ );
+ }
+ if (other?.actual_quota !== undefined) {
+ addKnown('billing', t('实际消费'), renderQuota(other.actual_quota, 6));
+ }
+ if (other?.quota_delta !== undefined) {
+ const delta = Number(other.quota_delta);
+ addKnown(
+ 'billing',
+ t('结算差额'),
+ `${delta > 0 ? '+' : ''}${renderQuota(delta, 6)}`,
+ );
+ }
+ if (other?.refund_quota !== undefined) {
+ addKnown('billing', t('退还金额'), renderQuota(other.refund_quota, 6));
+ }
+ if (other?.total_tokens !== undefined) {
+ addKnown('billing', t('总 Tokens'), other.total_tokens);
+ }
+
+ if (other?.pricing_snapshot) {
+ const snapshot = other.pricing_snapshot || {};
+ const excluded = new Set([
+ 'price',
+ 'billing_unit',
+ 'pricing_mode',
+ 'fallback_strategy',
+ 'total_tokens',
+ ]);
+ Object.entries(snapshot).forEach(([key, value]) => {
+ if (!excluded.has(key)) {
+ addKnown('matrix', key, String(value));
+ }
+ });
+ addKnown('matrix', t('命中价格 USD'), snapshot.price);
+ addKnown('matrix', t('命中模式'), snapshot.pricing_mode);
+ addKnown('matrix', 'Fallback', snapshot.fallback_strategy);
+ }
+
+ if (log.request_id) {
+ addKnown('context', 'Request ID', log.request_id);
+ }
+ if (isAdminUser && log.chat_id) {
+ addKnown('context', 'Chat ID', log.chat_id);
+ }
+ if (isAdminUser && log.upstream_id) {
+ addKnown('context', 'Upstream ID', log.upstream_id);
+ }
+ if (other?.request_conversion) {
+ addKnown(
+ 'context',
+ t('请求转换'),
+ requestConversionDisplayValue(other.request_conversion),
+ );
+ }
+ if (other?.is_model_mapped) {
+ addKnown('context', t('请求并计费模型'), log.model_name);
+ addKnown('context', t('实际模型'), other.upstream_model_name);
+ }
+
+ if (other?.reason) {
+ addKnown(
+ 'state',
+ t('原因'),
+