前端新增 UsageLogExpandedDetail 点击展开面板和 BillingDetailSummary hover 计费详情,后端修复 per_1m_tokens 任务结算日志的 completion_tokens 从零值改为写入 total_tokens 实际值。新增 taskBillingLogMerge 合并 任务预扣/结算日志。 Co-Authored-By: Claude <noreply@anthropic.com>master
| @@ -607,7 +607,11 @@ func buildTaskBillingContext(info *relaycommon.RelayInfo) *model.TaskBillingCont | |||||
| bc.BillingUnit = decision.BillingUnit | bc.BillingUnit = decision.BillingUnit | ||||
| bc.TokenUnitPriceUSD = decision.TokenUnitPriceUSD | bc.TokenUnitPriceUSD = decision.TokenUnitPriceUSD | ||||
| bc.PerCallBilling = decision.PerCallBilling | 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.GroupRatio = decision.GroupRatioInfo.GroupRatio | ||||
| bc.OtherRatios = types.CloneRatios(decision.OtherRatios) | bc.OtherRatios = types.CloneRatios(decision.OtherRatios) | ||||
| } | } | ||||
| @@ -211,15 +211,16 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) | |||||
| } | } | ||||
| type RecordTaskBillingLogParams struct { | 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) { | func RecordTaskBillingLog(params RecordTaskBillingLogParams) { | ||||
| @@ -234,18 +235,19 @@ func RecordTaskBillingLog(params RecordTaskBillingLogParams) { | |||||
| } | } | ||||
| } | } | ||||
| log := &Log{ | 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 | err := LOG_DB.Create(log).Error | ||||
| if err != nil { | if err != nil { | ||||
| @@ -521,6 +521,26 @@ func TaskModel2Dto(task *model.Task) *dto.TaskDto { | |||||
| Progress: task.Progress, | Progress: task.Progress, | ||||
| Properties: task.Properties, | Properties: task.Properties, | ||||
| Username: task.Username, | 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 | |||||
| } | |||||
| @@ -200,6 +200,25 @@ func TestTaskModel2DtoDoesNotExposeUpstreamRequestSnapshot(t *testing.T) { | |||||
| require.NotContains(t, string(data), "hidden") | 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 { | func newRelayTaskRemixContext(t *testing.T, originTaskID string) *gin.Context { | ||||
| t.Helper() | t.Helper() | ||||
| gin.SetMode(gin.TestMode) | gin.SetMode(gin.TestMode) | ||||
| @@ -2,6 +2,7 @@ package service | |||||
| import ( | import ( | ||||
| "context" | "context" | ||||
| "encoding/json" | |||||
| "fmt" | "fmt" | ||||
| "strings" | "strings" | ||||
| @@ -11,6 +12,7 @@ import ( | |||||
| "github.com/QuantumNous/new-api/model" | "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/setting/ratio_setting" | "github.com/QuantumNous/new-api/setting/ratio_setting" | ||||
| "github.com/QuantumNous/new-api/types" | |||||
| "github.com/gin-gonic/gin" | "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["request_path"] = c.Request.URL.Path | ||||
| other["model_price"] = info.PriceData.ModelPrice | other["model_price"] = info.PriceData.ModelPrice | ||||
| other["group_ratio"] = info.PriceData.GroupRatioInfo.GroupRatio | 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 { | if info.PriceData.GroupRatioInfo.HasSpecialRatio { | ||||
| other["user_group_ratio"] = info.PriceData.GroupRatioInfo.GroupSpecialRatio | other["user_group_ratio"] = info.PriceData.GroupRatioInfo.GroupSpecialRatio | ||||
| } | } | ||||
| @@ -146,6 +163,35 @@ func taskModelName(task *model.Task) string { | |||||
| return task.Properties.OriginModelName | 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 统一的任务失败退款逻辑。 | // RefundTaskQuota 统一的任务失败退款逻辑。 | ||||
| // 当异步任务失败时,将预扣的 quota 退还给用户(支持钱包和订阅),并退还令牌额度。 | // 当异步任务失败时,将预扣的 quota 退还给用户(支持钱包和订阅),并退还令牌额度。 | ||||
| func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) { | 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. 记录日志 | // 3. 记录日志 | ||||
| other := taskBillingOther(task) | other := taskBillingOther(task) | ||||
| other["task_id"] = task.TaskID | other["task_id"] = task.TaskID | ||||
| other["billing_phase"] = "refund" | |||||
| other["reason"] = reason | other["reason"] = reason | ||||
| other["pre_consumed_quota"] = quota | |||||
| model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{ | model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{ | ||||
| UserId: task.UserId, | UserId: task.UserId, | ||||
| LogType: model.LogTypeRefund, | LogType: model.LogTypeRefund, | ||||
| @@ -193,6 +241,18 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int | |||||
| if quotaDelta == 0 { | if quotaDelta == 0 { | ||||
| logger.LogInfo(ctx, fmt.Sprintf("任务 %s 预扣费准确(%s,%s)", | logger.LogInfo(ctx, fmt.Sprintf("任务 %s 预扣费准确(%s,%s)", | ||||
| task.TaskID, logger.LogQuota(actualQuota), reason)) | 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 | return | ||||
| } | } | ||||
| @@ -226,21 +286,17 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int | |||||
| logType = model.LogTypeRefund | logType = model.LogTypeRefund | ||||
| logQuota = -quotaDelta | 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{ | 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), | |||||
| }) | }) | ||||
| } | } | ||||
| @@ -4,6 +4,7 @@ import ( | |||||
| "context" | "context" | ||||
| "encoding/json" | "encoding/json" | ||||
| "net/http" | "net/http" | ||||
| "net/http/httptest" | |||||
| "os" | "os" | ||||
| "testing" | "testing" | ||||
| "time" | "time" | ||||
| @@ -11,6 +12,8 @@ import ( | |||||
| "github.com/QuantumNous/new-api/common" | "github.com/QuantumNous/new-api/common" | ||||
| "github.com/QuantumNous/new-api/model" | "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/types" | |||||
| "github.com/gin-gonic/gin" | |||||
| "github.com/glebarez/sqlite" | "github.com/glebarez/sqlite" | ||||
| "github.com/stretchr/testify/assert" | "github.com/stretchr/testify/assert" | ||||
| "github.com/stretchr/testify/require" | "github.com/stretchr/testify/require" | ||||
| @@ -125,8 +128,8 @@ func makeTask(userId, channelId, quota, tokenId int, billingSource string, subsc | |||||
| SubscriptionId: subscriptionId, | SubscriptionId: subscriptionId, | ||||
| TokenId: tokenId, | TokenId: tokenId, | ||||
| BillingContext: &model.TaskBillingContext{ | BillingContext: &model.TaskBillingContext{ | ||||
| ModelPrice: 0.02, | |||||
| GroupRatio: 1.0, | |||||
| ModelPrice: 0.02, | |||||
| GroupRatio: 1.0, | |||||
| OriginModelName: "test-model", | OriginModelName: "test-model", | ||||
| }, | }, | ||||
| }, | }, | ||||
| @@ -175,6 +178,14 @@ func getLastLog(t *testing.T) *model.Log { | |||||
| return &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 { | func countLogs(t *testing.T) int64 { | ||||
| t.Helper() | t.Helper() | ||||
| var count int64 | var count int64 | ||||
| @@ -217,6 +228,61 @@ func TestRefundTaskQuota_Wallet(t *testing.T) { | |||||
| assert.Equal(t, "test-model", log.ModelName) | 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) { | func TestRefundTaskQuota_Subscription(t *testing.T) { | ||||
| truncate(t) | truncate(t) | ||||
| ctx := context.Background() | ctx := context.Background() | ||||
| @@ -244,6 +310,11 @@ func TestRefundTaskQuota_Subscription(t *testing.T) { | |||||
| log := getLastLog(t) | log := getLastLog(t) | ||||
| require.NotNil(t, log) | require.NotNil(t, log) | ||||
| assert.Equal(t, model.LogTypeRefund, log.Type) | 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) { | func TestRefundTaskQuota_ZeroQuota(t *testing.T) { | ||||
| @@ -322,6 +393,13 @@ func TestRecalculate_PositiveDelta(t *testing.T) { | |||||
| require.NotNil(t, log) | require.NotNil(t, log) | ||||
| assert.Equal(t, model.LogTypeConsume, log.Type) | assert.Equal(t, model.LogTypeConsume, log.Type) | ||||
| assert.Equal(t, actualQuota-preConsumed, log.Quota) | 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) { | func TestRecalculate_NegativeDelta(t *testing.T) { | ||||
| @@ -363,18 +441,29 @@ func TestRecalculate_ZeroDelta(t *testing.T) { | |||||
| const userID = 12 | const userID = 12 | ||||
| const initQuota, preConsumed = 10000, 3000 | const initQuota, preConsumed = 10000, 3000 | ||||
| const totalTokens = 48400 | |||||
| seedUser(t, userID, initQuota) | seedUser(t, userID, initQuota) | ||||
| task := makeTask(userID, 0, preConsumed, 0, BillingSourceWallet, 0) | 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") | RecalculateTaskQuota(ctx, task, preConsumed, "exact match") | ||||
| // No change to user quota | // No change to user quota | ||||
| assert.Equal(t, initQuota, getUserQuota(t, userID)) | 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) { | func TestRecalculate_ActualQuotaZero(t *testing.T) { | ||||
| @@ -615,9 +704,11 @@ type mockAdaptor struct { | |||||
| adjustReturn int | 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 { | func (m *mockAdaptor) AdjustBillingOnComplete(_ *model.Task, _ *relaycommon.TaskInfo) int { | ||||
| return m.adjustReturn | return m.adjustReturn | ||||
| } | } | ||||
| @@ -162,7 +162,7 @@ const ContentModal = ({ | |||||
| height: isVideo ? '70vh' : '400px', | height: isVideo ? '70vh' : '400px', | ||||
| maxHeight: '80vh', | maxHeight: '80vh', | ||||
| overflow: 'auto', | overflow: 'auto', | ||||
| padding: isVideo && videoError ? '0' : '24px', | |||||
| padding: isVideo && videoError ? '0' : '18px 20px 20px', | |||||
| }} | }} | ||||
| width={isVideo ? '90vw' : 800} | width={isVideo ? '90vw' : 800} | ||||
| style={isVideo ? { maxWidth: 960 } : undefined} | style={isVideo ? { maxWidth: 960 } : undefined} | ||||
| @@ -170,7 +170,29 @@ const ContentModal = ({ | |||||
| {isVideo ? ( | {isVideo ? ( | ||||
| renderVideoContent() | renderVideoContent() | ||||
| ) : ( | ) : ( | ||||
| <p style={{ whiteSpace: 'pre-line' }}>{modalContent}</p> | |||||
| <div | |||||
| style={{ | |||||
| minHeight: '100%', | |||||
| padding: '16px 18px', | |||||
| border: '1px solid var(--semi-color-border)', | |||||
| borderRadius: 6, | |||||
| background: 'var(--semi-color-fill-0)', | |||||
| }} | |||||
| > | |||||
| <pre | |||||
| style={{ | |||||
| margin: 0, | |||||
| whiteSpace: 'pre-wrap', | |||||
| wordBreak: 'break-word', | |||||
| lineHeight: 1.7, | |||||
| fontSize: 13, | |||||
| fontFamily: | |||||
| 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace', | |||||
| }} | |||||
| > | |||||
| {modalContent} | |||||
| </pre> | |||||
| </div> | |||||
| )} | )} | ||||
| </Modal> | </Modal> | ||||
| ); | ); | ||||
| @@ -0,0 +1,207 @@ | |||||
| /* | |||||
| Copyright (C) 2025 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 React from 'react'; | |||||
| import { Popover, Space, Tag, Typography } from '@douyinfe/semi-ui'; | |||||
| import { getCurrencyConfig, renderQuota } from '../../../helpers'; | |||||
| import { | |||||
| buildMatrixBillingDetail, | |||||
| formatBillingRatio, | |||||
| } from './BillingDetailSummaryData'; | |||||
| const DetailRow = ({ label, value }) => { | |||||
| if (value === undefined || value === null || value === '') return null; | |||||
| return ( | |||||
| <div | |||||
| style={{ | |||||
| display: 'flex', | |||||
| justifyContent: 'space-between', | |||||
| gap: 14, | |||||
| padding: '3px 0', | |||||
| }} | |||||
| > | |||||
| <Typography.Text type='tertiary'>{label}</Typography.Text> | |||||
| <Typography.Text style={{ textAlign: 'right', wordBreak: 'break-word' }}> | |||||
| {value} | |||||
| </Typography.Text> | |||||
| </div> | |||||
| ); | |||||
| }; | |||||
| const PopoverSection = ({ title, children }) => ( | |||||
| <div | |||||
| style={{ | |||||
| padding: '10px 12px', | |||||
| border: '1px solid var(--semi-color-border)', | |||||
| borderRadius: 6, | |||||
| background: 'var(--semi-color-bg-1)', | |||||
| }} | |||||
| > | |||||
| <Typography.Text strong>{title}</Typography.Text> | |||||
| <div style={{ marginTop: 6 }}>{children}</div> | |||||
| </div> | |||||
| ); | |||||
| const BillingDetailSummary = ({ other, record, t }) => { | |||||
| const detail = buildMatrixBillingDetail({ | |||||
| other, | |||||
| t, | |||||
| currencyConfig: getCurrencyConfig(), | |||||
| }); | |||||
| if (!detail) return null; | |||||
| const actualQuotaText = | |||||
| record?.quota !== undefined ? renderQuota(record.quota, 6) : ''; | |||||
| const preConsumedQuotaText = | |||||
| other?.pre_consumed_quota !== undefined | |||||
| ? renderQuota(other.pre_consumed_quota, 6) | |||||
| : ''; | |||||
| const settledQuotaText = | |||||
| other?.actual_quota !== undefined ? renderQuota(other.actual_quota, 6) : ''; | |||||
| const popoverContent = ( | |||||
| <div | |||||
| style={{ | |||||
| width: 420, | |||||
| maxWidth: 'min(420px, calc(100vw - 48px))', | |||||
| padding: 4, | |||||
| lineHeight: 1.65, | |||||
| }} | |||||
| > | |||||
| <Space wrap size={4} style={{ marginBottom: 8 }}> | |||||
| <Tag color='blue' shape='circle'> | |||||
| {detail.title} | |||||
| </Tag> | |||||
| {detail.isPerTokens ? ( | |||||
| <Tag color='orange' shape='circle'> | |||||
| {t('按完成 tokens 重算')} | |||||
| </Tag> | |||||
| ) : ( | |||||
| <Tag color='green' shape='circle'> | |||||
| {t('固定按次')} | |||||
| </Tag> | |||||
| )} | |||||
| {detail.pricingMode !== '-' && ( | |||||
| <Tag | |||||
| color={detail.pricingMode === 'matched' ? 'green' : 'amber'} | |||||
| shape='circle' | |||||
| > | |||||
| {detail.pricingMode} | |||||
| </Tag> | |||||
| )} | |||||
| {detail.usesLegacyPriceField && ( | |||||
| <Tag color='grey' shape='circle'> | |||||
| {t('旧日志字段')} | |||||
| </Tag> | |||||
| )} | |||||
| </Space> | |||||
| <div | |||||
| style={{ | |||||
| marginBottom: 10, | |||||
| padding: '10px 12px', | |||||
| border: '1px solid var(--semi-color-border)', | |||||
| borderRadius: 6, | |||||
| background: 'var(--semi-color-bg-1)', | |||||
| }} | |||||
| > | |||||
| <Typography.Text strong>{t('价格计算')}</Typography.Text> | |||||
| <DetailRow label={t('基础单价')} value={detail.unitPriceText} /> | |||||
| <DetailRow | |||||
| label={t('分组倍率')} | |||||
| value={formatBillingRatio(detail.groupRatio)} | |||||
| /> | |||||
| <DetailRow label={t('实际单价')} value={detail.finalUnitPriceText} /> | |||||
| <DetailRow label={t('实际用量')} value={detail.usageText} /> | |||||
| <DetailRow label={t('计算扣费')} value={detail.costText} /> | |||||
| </div> | |||||
| {detail.dimensions.length > 0 && ( | |||||
| <div | |||||
| style={{ | |||||
| marginBottom: 10, | |||||
| padding: '10px 12px', | |||||
| border: '1px solid var(--semi-color-border)', | |||||
| borderRadius: 6, | |||||
| background: 'var(--semi-color-bg-1)', | |||||
| }} | |||||
| > | |||||
| <Typography.Text strong>{t('命中维度')}</Typography.Text> | |||||
| {detail.dimensions.map((item) => ( | |||||
| <DetailRow key={item.key} label={item.key} value={item.value} /> | |||||
| ))} | |||||
| </div> | |||||
| )} | |||||
| <div | |||||
| style={{ | |||||
| padding: '10px 12px', | |||||
| border: '1px solid var(--semi-color-border)', | |||||
| borderRadius: 6, | |||||
| background: 'var(--semi-color-bg-1)', | |||||
| }} | |||||
| > | |||||
| <Typography.Text strong>{t('结算信息')}</Typography.Text> | |||||
| <DetailRow label={t('日志扣费')} value={actualQuotaText} /> | |||||
| <DetailRow label={t('预扣')} value={preConsumedQuotaText} /> | |||||
| <DetailRow label={t('实际')} value={settledQuotaText} /> | |||||
| <DetailRow label={t('命中模式')} value={detail.pricingMode} /> | |||||
| <DetailRow label='Fallback' value={detail.fallbackStrategy} /> | |||||
| </div> | |||||
| </div> | |||||
| ); | |||||
| return ( | |||||
| <Popover | |||||
| content={popoverContent} | |||||
| trigger='hover' | |||||
| position='leftTop' | |||||
| spacing={10} | |||||
| > | |||||
| <div style={{ maxWidth: 280, cursor: 'default' }}> | |||||
| <Space wrap size={4} style={{ marginBottom: 4 }}> | |||||
| <Tag color='blue' shape='circle'> | |||||
| {detail.isPerTokens ? t('每 1M tokens') : t('按次')} | |||||
| </Tag> | |||||
| {detail.pricingMode !== '-' && ( | |||||
| <Tag | |||||
| color={detail.pricingMode === 'matched' ? 'green' : 'amber'} | |||||
| shape='circle' | |||||
| > | |||||
| {detail.pricingMode} | |||||
| </Tag> | |||||
| )} | |||||
| </Space> | |||||
| <div className='text-xs font-medium text-gray-900'> | |||||
| {detail.unitPriceText} × {formatBillingRatio(detail.groupRatio)} | |||||
| </div> | |||||
| <div className='text-xs text-gray-600'> | |||||
| {detail.usageText} → {detail.costText} | |||||
| </div> | |||||
| {detail.dimensionText && ( | |||||
| <div className='text-xs text-gray-500 truncate'> | |||||
| {detail.dimensionText} | |||||
| </div> | |||||
| )} | |||||
| </div> | |||||
| </Popover> | |||||
| ); | |||||
| }; | |||||
| export default BillingDetailSummary; | |||||
| @@ -0,0 +1,109 @@ | |||||
| import { describe, expect, it } from 'vitest'; | |||||
| import { buildMatrixBillingDetail } from './BillingDetailSummaryData.js'; | |||||
| const t = (key) => | |||||
| ({ | |||||
| 矩阵计费: '矩阵计费', | |||||
| '每 1M tokens': '每 1M tokens', | |||||
| 按次: '按次', | |||||
| 次: '次', | |||||
| 单次计费: '单次计费', | |||||
| '等待实际 tokens': '等待实际 tokens', | |||||
| 等待完成后重算: '等待完成后重算', | |||||
| 任意: '任意', | |||||
| })[key] || key; | |||||
| const cny = { symbol: '¥', rate: 7, type: 'CNY' }; | |||||
| describe('BillingDetailSummary matrix billing detail', () => { | |||||
| it('summarizes per_1m_tokens matrix usage with dimensions and token cost', () => { | |||||
| const detail = buildMatrixBillingDetail({ | |||||
| t, | |||||
| currencyConfig: cny, | |||||
| other: { | |||||
| billing_mode: 'matrix', | |||||
| billing_unit: 'per_1m_tokens', | |||||
| token_unit_price_usd: 37 / 7, | |||||
| group_ratio: 1, | |||||
| pricing_snapshot: { | |||||
| billing_unit: 'per_1m_tokens', | |||||
| pricing_mode: 'matched', | |||||
| price: 37 / 7, | |||||
| resolution: '1080P', | |||||
| duration: 10, | |||||
| total_tokens: 2_000_000, | |||||
| }, | |||||
| }, | |||||
| }); | |||||
| expect(detail).toMatchObject({ | |||||
| isPerTokens: true, | |||||
| title: '矩阵计费 · 每 1M tokens', | |||||
| unitPriceText: '¥37.000000 / 1M tokens', | |||||
| finalUnitPriceText: '¥37.000000 / 1M tokens', | |||||
| usageText: '2,000,000 tokens', | |||||
| costText: '¥74.000000', | |||||
| dimensionText: 'resolution=1080P · duration=10', | |||||
| pricingMode: 'matched', | |||||
| }); | |||||
| }); | |||||
| it('summarizes per_call matrix billing as fixed per-call cost', () => { | |||||
| const detail = buildMatrixBillingDetail({ | |||||
| t, | |||||
| currencyConfig: cny, | |||||
| other: { | |||||
| billing_mode: 'matrix', | |||||
| billing_unit: 'per_call', | |||||
| model_price: 2, | |||||
| group_ratio: 1.5, | |||||
| pricing_snapshot: { | |||||
| billing_unit: 'per_call', | |||||
| pricing_mode: 'matched', | |||||
| price: 2, | |||||
| size: 'large', | |||||
| }, | |||||
| }, | |||||
| }); | |||||
| expect(detail).toMatchObject({ | |||||
| isPerTokens: false, | |||||
| title: '矩阵计费 · 按次', | |||||
| unitPriceText: '¥14.000000 / 次', | |||||
| finalUnitPriceText: '¥21.000000 / 次', | |||||
| usageText: '单次计费', | |||||
| costText: '¥21.000000', | |||||
| dimensionText: 'size=large', | |||||
| }); | |||||
| }); | |||||
| it('marks old per-token logs that only have model_price', () => { | |||||
| const detail = buildMatrixBillingDetail({ | |||||
| t, | |||||
| currencyConfig: cny, | |||||
| other: { | |||||
| billing_mode: 'matrix', | |||||
| billing_unit: 'per_1m_tokens', | |||||
| model_price: 46, | |||||
| group_ratio: 1, | |||||
| pricing_snapshot: { | |||||
| billing_unit: 'per_1m_tokens', | |||||
| pricing_mode: 'fallback_max', | |||||
| fallback_strategy: 'max', | |||||
| price: 46, | |||||
| resolution: '*', | |||||
| }, | |||||
| }, | |||||
| }); | |||||
| expect(detail).toMatchObject({ | |||||
| usesLegacyPriceField: true, | |||||
| unitPriceText: '¥322.000000 / 1M tokens', | |||||
| usageText: '等待实际 tokens', | |||||
| costText: '等待完成后重算', | |||||
| dimensionText: 'resolution=任意', | |||||
| pricingMode: 'fallback_max', | |||||
| fallbackStrategy: 'max', | |||||
| }); | |||||
| }); | |||||
| }); | |||||
| @@ -0,0 +1,142 @@ | |||||
| /* | |||||
| Copyright (C) 2025 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 | |||||
| */ | |||||
| const SNAPSHOT_META_KEYS = new Set([ | |||||
| 'price', | |||||
| 'billing_unit', | |||||
| 'pricing_mode', | |||||
| 'fallback_strategy', | |||||
| 'total_tokens', | |||||
| ]); | |||||
| export const formatBillingRatio = (ratio) => { | |||||
| if (ratio === undefined || ratio === null) return '-'; | |||||
| const value = Number(ratio); | |||||
| if (!Number.isFinite(value)) return String(ratio); | |||||
| return value.toFixed(4); | |||||
| }; | |||||
| const formatCurrency = (usdAmount, currencyConfig, digits = 6) => { | |||||
| const value = Number(usdAmount || 0); | |||||
| const rate = Number(currencyConfig?.rate || 1); | |||||
| const symbol = currencyConfig?.symbol || '$'; | |||||
| const displayValue = value * (Number.isFinite(rate) && rate > 0 ? rate : 1); | |||||
| return `${symbol}${displayValue.toFixed(digits)}`; | |||||
| }; | |||||
| const formatTokenCount = (value) => { | |||||
| const tokens = Number(value || 0); | |||||
| if (!Number.isFinite(tokens) || tokens <= 0) return ''; | |||||
| return Math.round(tokens).toLocaleString(); | |||||
| }; | |||||
| const getMatrixDimensions = (snapshot, t = (key) => key) => | |||||
| Object.entries(snapshot || {}) | |||||
| .filter(([key]) => !SNAPSHOT_META_KEYS.has(key)) | |||||
| .map(([key, value]) => ({ | |||||
| key, | |||||
| value: value === '*' ? t('任意') : String(value), | |||||
| })); | |||||
| const getBillingUnitLabel = (billingUnit, t = (key) => key) => { | |||||
| if (billingUnit === 'per_1m_tokens') return '1M tokens'; | |||||
| if (billingUnit === 'per_call') return t('次'); | |||||
| return billingUnit || '-'; | |||||
| }; | |||||
| const getMatrixUnitPrice = (other, billingUnit) => { | |||||
| if (billingUnit === 'per_1m_tokens') { | |||||
| if (other?.token_unit_price_usd !== undefined) { | |||||
| return { | |||||
| value: Number(other.token_unit_price_usd), | |||||
| source: 'token_unit_price_usd', | |||||
| }; | |||||
| } | |||||
| return { value: Number(other?.model_price), source: 'model_price' }; | |||||
| } | |||||
| return { value: Number(other?.model_price), source: 'model_price' }; | |||||
| }; | |||||
| export const buildMatrixBillingDetail = ({ | |||||
| other, | |||||
| t = (key) => key, | |||||
| currencyConfig = { symbol: '$', rate: 1 }, | |||||
| }) => { | |||||
| if (!other || other.billing_mode !== 'matrix') return null; | |||||
| const snapshot = other.pricing_snapshot || {}; | |||||
| const billingUnit = other.billing_unit || snapshot.billing_unit || 'per_call'; | |||||
| const { value: unitPriceUSD, source } = getMatrixUnitPrice( | |||||
| other, | |||||
| billingUnit, | |||||
| ); | |||||
| if (!Number.isFinite(unitPriceUSD) || unitPriceUSD <= 0) return null; | |||||
| const groupRatio = Number(other.group_ratio ?? 1); | |||||
| const effectiveGroupRatio = Number.isFinite(groupRatio) ? groupRatio : 1; | |||||
| const finalUnitPriceUSD = unitPriceUSD * effectiveGroupRatio; | |||||
| const unitLabel = getBillingUnitLabel(billingUnit, t); | |||||
| const dimensions = getMatrixDimensions(snapshot, t); | |||||
| const dimensionText = dimensions | |||||
| .map((item) => `${item.key}=${item.value}`) | |||||
| .join(' · '); | |||||
| const totalTokens = snapshot.total_tokens ?? other.total_tokens; | |||||
| const tokenText = formatTokenCount(totalTokens); | |||||
| const pricingMode = snapshot.pricing_mode || '-'; | |||||
| const fallbackStrategy = snapshot.fallback_strategy || ''; | |||||
| const isPerTokens = billingUnit === 'per_1m_tokens'; | |||||
| const estimatedCostUSD = | |||||
| isPerTokens && tokenText | |||||
| ? (finalUnitPriceUSD * Number(totalTokens || 0)) / 1_000_000 | |||||
| : finalUnitPriceUSD; | |||||
| const title = isPerTokens | |||||
| ? `${t('矩阵计费')} · ${t('每 1M tokens')}` | |||||
| : `${t('矩阵计费')} · ${t('按次')}`; | |||||
| const unitPriceText = `${formatCurrency(unitPriceUSD, currencyConfig)} / ${unitLabel}`; | |||||
| const finalUnitPriceText = `${formatCurrency(finalUnitPriceUSD, currencyConfig)} / ${unitLabel}`; | |||||
| const usageText = isPerTokens | |||||
| ? tokenText | |||||
| ? `${tokenText} tokens` | |||||
| : t('等待实际 tokens') | |||||
| : t('单次计费'); | |||||
| const costText = | |||||
| isPerTokens && !tokenText | |||||
| ? t('等待完成后重算') | |||||
| : formatCurrency(estimatedCostUSD, currencyConfig); | |||||
| return { | |||||
| billingUnit, | |||||
| isPerTokens, | |||||
| title, | |||||
| unitLabel, | |||||
| source, | |||||
| usesLegacyPriceField: isPerTokens && source === 'model_price', | |||||
| groupRatio: effectiveGroupRatio, | |||||
| unitPriceText, | |||||
| finalUnitPriceText, | |||||
| usageText, | |||||
| costText, | |||||
| tokenText, | |||||
| dimensionText, | |||||
| dimensions, | |||||
| pricingMode, | |||||
| fallbackStrategy, | |||||
| }; | |||||
| }; | |||||
| @@ -0,0 +1,80 @@ | |||||
| import React from 'react'; | |||||
| import { Descriptions, Typography } from '@douyinfe/semi-ui'; | |||||
| const sectionStyle = { | |||||
| border: '1px solid var(--semi-color-border)', | |||||
| borderRadius: 6, | |||||
| background: 'var(--semi-color-bg-1)', | |||||
| overflow: 'hidden', | |||||
| }; | |||||
| const headerStyle = { | |||||
| padding: '9px 12px', | |||||
| borderBottom: '1px solid var(--semi-color-border)', | |||||
| background: 'var(--semi-color-fill-0)', | |||||
| }; | |||||
| const bodyStyle = { | |||||
| padding: '10px 12px 12px', | |||||
| }; | |||||
| const gridStyle = { | |||||
| display: 'grid', | |||||
| gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', | |||||
| gap: '10px 16px', | |||||
| }; | |||||
| const itemStyle = { | |||||
| minWidth: 0, | |||||
| }; | |||||
| const valueStyle = { | |||||
| marginTop: 3, | |||||
| lineHeight: 1.55, | |||||
| wordBreak: 'break-word', | |||||
| }; | |||||
| const DetailItem = ({ item }) => ( | |||||
| <div style={itemStyle}> | |||||
| <Typography.Text type='tertiary' size='small'> | |||||
| {item.key} | |||||
| </Typography.Text> | |||||
| <div style={valueStyle}>{item.value}</div> | |||||
| </div> | |||||
| ); | |||||
| const DetailSection = ({ section }) => { | |||||
| if (!section?.items?.length) return null; | |||||
| return ( | |||||
| <div style={sectionStyle}> | |||||
| <div style={headerStyle}> | |||||
| <Typography.Text strong>{section.title}</Typography.Text> | |||||
| </div> | |||||
| <div style={bodyStyle}> | |||||
| <div style={gridStyle}> | |||||
| {section.items.map((item, index) => ( | |||||
| <DetailItem key={`${item.key}-${index}`} item={item} /> | |||||
| ))} | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| ); | |||||
| }; | |||||
| const UsageLogExpandedDetail = ({ detail }) => { | |||||
| if (detail?.sections?.length) { | |||||
| return ( | |||||
| <div style={{ padding: '12px 16px' }}> | |||||
| <div style={{ display: 'grid', gap: 12 }}> | |||||
| {detail.sections.map((section) => ( | |||||
| <DetailSection key={section.title} section={section} /> | |||||
| ))} | |||||
| </div> | |||||
| </div> | |||||
| ); | |||||
| } | |||||
| return <Descriptions data={detail?.items || detail || []} />; | |||||
| }; | |||||
| export default UsageLogExpandedDetail; | |||||
| @@ -25,7 +25,7 @@ import { | |||||
| Tooltip, | Tooltip, | ||||
| Popover, | Popover, | ||||
| Typography, | Typography, | ||||
| Button | |||||
| Button, | |||||
| } from '@douyinfe/semi-ui'; | } from '@douyinfe/semi-ui'; | ||||
| import { | import { | ||||
| timestamp2string, | timestamp2string, | ||||
| @@ -43,6 +43,7 @@ import { | |||||
| } from '../../../helpers'; | } from '../../../helpers'; | ||||
| import { IconHelpCircle } from '@douyinfe/semi-icons'; | import { IconHelpCircle } from '@douyinfe/semi-icons'; | ||||
| import { Route, Sparkles } from 'lucide-react'; | import { Route, Sparkles } from 'lucide-react'; | ||||
| import BillingDetailSummary from './BillingDetailSummary'; | |||||
| const colors = [ | const colors = [ | ||||
| 'amber', | 'amber', | ||||
| @@ -101,7 +102,53 @@ function buildChannelAffinityTooltip(affinity, t) { | |||||
| } | } | ||||
| // Render functions | // Render functions | ||||
| function renderType(type, t) { | |||||
| function renderType(type, t, record = null) { | |||||
| const other = record ? getLogOther(record.other) : null; | |||||
| if (other?.billing_mode === 'matrix') { | |||||
| if (other?.task_billing_status === 'pending') { | |||||
| return ( | |||||
| <Tag color='amber' shape='circle'> | |||||
| {t('待结算')} | |||||
| </Tag> | |||||
| ); | |||||
| } | |||||
| if (other?.task_billing_status === 'settled') { | |||||
| return ( | |||||
| <Tag color='green' shape='circle'> | |||||
| {t('已结算')} | |||||
| </Tag> | |||||
| ); | |||||
| } | |||||
| if (other?.task_billing_status === 'refunded') { | |||||
| return ( | |||||
| <Tag color='teal' shape='circle'> | |||||
| {t('已退还')} | |||||
| </Tag> | |||||
| ); | |||||
| } | |||||
| if (other?.billing_phase === 'preconsume') { | |||||
| return ( | |||||
| <Tag color='amber' shape='circle'> | |||||
| {t('预扣')} | |||||
| </Tag> | |||||
| ); | |||||
| } | |||||
| if (other?.billing_phase === 'settlement') { | |||||
| return ( | |||||
| <Tag color={type === 6 ? 'teal' : 'green'} shape='circle'> | |||||
| {type === 6 ? t('退还') : t('补扣')} | |||||
| </Tag> | |||||
| ); | |||||
| } | |||||
| if (other?.billing_phase === 'refund') { | |||||
| return ( | |||||
| <Tag color='teal' shape='circle'> | |||||
| {t('退还')} | |||||
| </Tag> | |||||
| ); | |||||
| } | |||||
| } | |||||
| switch (type) { | switch (type) { | ||||
| case 1: | case 1: | ||||
| return ( | return ( | ||||
| @@ -374,7 +421,10 @@ export const getLogsColumns = ({ | |||||
| } | } | ||||
| return isAdminUser && | return isAdminUser && | ||||
| (record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6) ? ( | |||||
| (record.type === 0 || | |||||
| record.type === 2 || | |||||
| record.type === 5 || | |||||
| record.type === 6) ? ( | |||||
| <Space> | <Space> | ||||
| <span style={{ position: 'relative', display: 'inline-block' }}> | <span style={{ position: 'relative', display: 'inline-block' }}> | ||||
| <Tooltip content={record.channel_name || t('未知渠道')}> | <Tooltip content={record.channel_name || t('未知渠道')}> | ||||
| @@ -465,7 +515,10 @@ export const getLogsColumns = ({ | |||||
| title: t('令牌'), | title: t('令牌'), | ||||
| dataIndex: 'token_name', | dataIndex: 'token_name', | ||||
| render: (text, record, index) => { | render: (text, record, index) => { | ||||
| return record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6 ? ( | |||||
| return record.type === 0 || | |||||
| record.type === 2 || | |||||
| record.type === 5 || | |||||
| record.type === 6 ? ( | |||||
| <div> | <div> | ||||
| <Tag | <Tag | ||||
| color='grey' | color='grey' | ||||
| @@ -522,7 +575,7 @@ export const getLogsColumns = ({ | |||||
| title: t('类型'), | title: t('类型'), | ||||
| dataIndex: 'type', | dataIndex: 'type', | ||||
| render: (text, record, index) => { | render: (text, record, index) => { | ||||
| return <>{renderType(text, t)}</>; | |||||
| return <>{renderType(text, t, record)}</>; | |||||
| }, | }, | ||||
| }, | }, | ||||
| { | { | ||||
| @@ -530,7 +583,10 @@ export const getLogsColumns = ({ | |||||
| title: t('模型'), | title: t('模型'), | ||||
| dataIndex: 'model_name', | dataIndex: 'model_name', | ||||
| render: (text, record, index) => { | render: (text, record, index) => { | ||||
| return record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6 ? ( | |||||
| return record.type === 0 || | |||||
| record.type === 2 || | |||||
| record.type === 5 || | |||||
| record.type === 6 ? ( | |||||
| <>{renderModelName(record, copyText, t)}</> | <>{renderModelName(record, copyText, t)}</> | ||||
| ) : ( | ) : ( | ||||
| <></> | <></> | ||||
| @@ -597,7 +653,10 @@ export const getLogsColumns = ({ | |||||
| cacheText = `${t('缓存写')} ${formatTokenCount(cacheSummary.cacheWriteTokens)}`; | cacheText = `${t('缓存写')} ${formatTokenCount(cacheSummary.cacheWriteTokens)}`; | ||||
| } | } | ||||
| return record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6 ? ( | |||||
| return record.type === 0 || | |||||
| record.type === 2 || | |||||
| record.type === 5 || | |||||
| record.type === 6 ? ( | |||||
| <div | <div | ||||
| style={{ | style={{ | ||||
| display: 'inline-flex', | display: 'inline-flex', | ||||
| @@ -631,7 +690,10 @@ export const getLogsColumns = ({ | |||||
| dataIndex: 'completion_tokens', | dataIndex: 'completion_tokens', | ||||
| render: (text, record, index) => { | render: (text, record, index) => { | ||||
| return parseInt(text) > 0 && | return parseInt(text) > 0 && | ||||
| (record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6) ? ( | |||||
| (record.type === 0 || | |||||
| record.type === 2 || | |||||
| record.type === 5 || | |||||
| record.type === 6) ? ( | |||||
| <>{<span> {text} </span>}</> | <>{<span> {text} </span>}</> | ||||
| ) : ( | ) : ( | ||||
| <></> | <></> | ||||
| @@ -643,7 +705,14 @@ export const getLogsColumns = ({ | |||||
| title: t('花费'), | title: t('花费'), | ||||
| dataIndex: 'quota', | dataIndex: 'quota', | ||||
| render: (text, record, index) => { | render: (text, record, index) => { | ||||
| if (!(record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6)) { | |||||
| if ( | |||||
| !( | |||||
| record.type === 0 || | |||||
| record.type === 2 || | |||||
| record.type === 5 || | |||||
| record.type === 6 | |||||
| ) | |||||
| ) { | |||||
| return <></>; | return <></>; | ||||
| } | } | ||||
| const other = getLogOther(record.other); | const other = getLogOther(record.other); | ||||
| @@ -710,9 +779,9 @@ export const getLogsColumns = ({ | |||||
| } | } | ||||
| if (other.admin_info !== undefined) { | if (other.admin_info !== undefined) { | ||||
| if ( | if ( | ||||
| other.admin_info.use_channel !== null && | |||||
| other.admin_info.use_channel !== undefined && | |||||
| other.admin_info.use_channel !== '' | |||||
| other.admin_info.use_channel !== null && | |||||
| other.admin_info.use_channel !== undefined && | |||||
| other.admin_info.use_channel !== '' | |||||
| ) { | ) { | ||||
| let useChannel = other.admin_info.use_channel; | let useChannel = other.admin_info.use_channel; | ||||
| let useChannelStr = useChannel.join('->'); | let useChannelStr = useChannel.join('->'); | ||||
| @@ -788,6 +857,13 @@ export const getLogsColumns = ({ | |||||
| ); | ); | ||||
| } | } | ||||
| const matrixPrice = Number( | |||||
| other?.token_unit_price_usd ?? other?.model_price ?? 0, | |||||
| ); | |||||
| if (other?.billing_mode === 'matrix' && matrixPrice > 0) { | |||||
| return <BillingDetailSummary other={other} record={record} t={t} />; | |||||
| } | |||||
| let content = other?.claude | let content = other?.claude | ||||
| ? renderModelPriceSimple( | ? renderModelPriceSimple( | ||||
| other.model_ratio, | other.model_ratio, | ||||
| @@ -830,14 +906,14 @@ export const getLogsColumns = ({ | |||||
| 'openai', | 'openai', | ||||
| ); | ); | ||||
| return ( | return ( | ||||
| <Typography.Paragraph | |||||
| ellipsis={{ | |||||
| rows: 3, | |||||
| }} | |||||
| style={{ maxWidth: 240, whiteSpace: 'pre-line' }} | |||||
| > | |||||
| {content} | |||||
| </Typography.Paragraph> | |||||
| <Typography.Paragraph | |||||
| ellipsis={{ | |||||
| rows: 3, | |||||
| }} | |||||
| style={{ maxWidth: 240, whiteSpace: 'pre-line' }} | |||||
| > | |||||
| {content} | |||||
| </Typography.Paragraph> | |||||
| ); | ); | ||||
| }, | }, | ||||
| }, | }, | ||||
| @@ -18,13 +18,14 @@ For commercial licensing, please contact support@quantumnous.com | |||||
| */ | */ | ||||
| import React, { useMemo } from 'react'; | import React, { useMemo } from 'react'; | ||||
| import { Empty, Descriptions } from '@douyinfe/semi-ui'; | |||||
| import { Empty } from '@douyinfe/semi-ui'; | |||||
| import CardTable from '../../common/ui/CardTable'; | import CardTable from '../../common/ui/CardTable'; | ||||
| import { | import { | ||||
| IllustrationNoResult, | IllustrationNoResult, | ||||
| IllustrationNoResultDark, | IllustrationNoResultDark, | ||||
| } from '@douyinfe/semi-illustrations'; | } from '@douyinfe/semi-illustrations'; | ||||
| import { getLogsColumns } from './UsageLogsColumnDefs'; | import { getLogsColumns } from './UsageLogsColumnDefs'; | ||||
| import UsageLogExpandedDetail from './UsageLogExpandedDetail'; | |||||
| const LogsTable = (logsData) => { | const LogsTable = (logsData) => { | ||||
| const { | const { | ||||
| @@ -82,7 +83,7 @@ const LogsTable = (logsData) => { | |||||
| }, [compactMode, visibleColumnsList]); | }, [compactMode, visibleColumnsList]); | ||||
| const expandRowRender = (record, index) => { | const expandRowRender = (record, index) => { | ||||
| return <Descriptions data={expandData[record.key]} />; | |||||
| return <UsageLogExpandedDetail detail={expandData[record.key]} />; | |||||
| }; | }; | ||||
| return ( | return ( | ||||
| @@ -92,7 +93,9 @@ const LogsTable = (logsData) => { | |||||
| expandedRowRender: expandRowRender, | expandedRowRender: expandRowRender, | ||||
| expandRowByClick: true, | expandRowByClick: true, | ||||
| rowExpandable: (record) => | rowExpandable: (record) => | ||||
| expandData[record.key] && expandData[record.key].length > 0, | |||||
| expandData[record.key]?.sections?.length > 0 || | |||||
| expandData[record.key]?.items?.length > 0 || | |||||
| expandData[record.key]?.length > 0, | |||||
| })} | })} | ||||
| dataSource={logs} | dataSource={logs} | ||||
| rowKey='key' | rowKey='key' | ||||
| @@ -693,6 +693,26 @@ export const summarizeMatrixPricing = ({ | |||||
| return { | return { | ||||
| price: min === max ? minText : `${minText} - ${maxText}`, | price: min === max ? minText : `${minText} - ${maxText}`, | ||||
| unit: getMatrixPricingUnit(cfg, tokenUnit), | unit: getMatrixPricingUnit(cfg, tokenUnit), | ||||
| details: (cfg.table || []).slice(0, 3).map((row) => { | |||||
| const dimensionText = Object.entries(row) | |||||
| .filter(([key]) => key !== 'price' && key !== 'source') | |||||
| .map( | |||||
| ([key, value]) => `${key}=${value === '*' ? '任意' : String(value)}`, | |||||
| ) | |||||
| .join(' · '); | |||||
| return { | |||||
| dimensionText, | |||||
| formattedPrice: formatMatrixPrice({ | |||||
| price: Number(row.price || 0) * usedGroupRatio, | |||||
| pricingConfig: cfg, | |||||
| tokenUnit, | |||||
| displayPrice, | |||||
| currency, | |||||
| precision, | |||||
| }), | |||||
| }; | |||||
| }), | |||||
| hiddenDetailCount: Math.max(0, (cfg.table || []).length - 3), | |||||
| }; | }; | ||||
| }; | }; | ||||
| @@ -706,8 +726,9 @@ export const calculateModelPrice = ({ | |||||
| precision = 4, | precision = 4, | ||||
| }) => { | }) => { | ||||
| // 1. 选择实际使用的分组 | // 1. 选择实际使用的分组 | ||||
| const ratios = groupRatio || {}; | |||||
| let usedGroup = selectedGroup; | let usedGroup = selectedGroup; | ||||
| let usedGroupRatio = groupRatio[selectedGroup]; | |||||
| let usedGroupRatio = ratios[selectedGroup]; | |||||
| if (selectedGroup === 'all' || usedGroupRatio === undefined) { | if (selectedGroup === 'all' || usedGroupRatio === undefined) { | ||||
| // 在模型可用分组中选择倍率最小的分组,若无则使用 1 | // 在模型可用分组中选择倍率最小的分组,若无则使用 1 | ||||
| @@ -717,7 +738,7 @@ export const calculateModelPrice = ({ | |||||
| record.enable_groups.length > 0 | record.enable_groups.length > 0 | ||||
| ) { | ) { | ||||
| record.enable_groups.forEach((g) => { | record.enable_groups.forEach((g) => { | ||||
| const r = groupRatio[g]; | |||||
| const r = ratios[g]; | |||||
| if (r !== undefined && r < minRatio) { | if (r !== undefined && r < minRatio) { | ||||
| minRatio = r; | minRatio = r; | ||||
| usedGroup = g; | usedGroup = g; | ||||
| @@ -839,7 +860,8 @@ export const formatPriceInfo = (priceData, t) => { | |||||
| if (priceData.isPerToken) { | if (priceData.isPerToken) { | ||||
| return ( | return ( | ||||
| <span style={{ color: 'var(--semi-color-text-1)' }}> | <span style={{ color: 'var(--semi-color-text-1)' }}> | ||||
| {t('输入')} {priceData.inputPrice} / {t('输出')} {priceData.completionPrice} / {priceData.unitLabel} tokens | |||||
| {t('输入')} {priceData.inputPrice} / {t('输出')}{' '} | |||||
| {priceData.completionPrice} / {priceData.unitLabel} tokens | |||||
| </span> | </span> | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -15,8 +15,12 @@ globalThis.localStorage = { | |||||
| getItem: () => null, | getItem: () => null, | ||||
| }; | }; | ||||
| const { calculateModelPrice, formatMatrixPrice, hasMatrixPricing } = | |||||
| await import('./utils.jsx'); | |||||
| const { | |||||
| calculateModelPrice, | |||||
| formatMatrixPrice, | |||||
| hasMatrixPricing, | |||||
| summarizeMatrixPricing, | |||||
| } = await import('./utils.jsx'); | |||||
| const displayPrice = (value) => `$${Number(value).toFixed(3)}`; | const displayPrice = (value) => `$${Number(value).toFixed(3)}`; | ||||
| @@ -27,8 +31,18 @@ describe('model pricing helpers', () => { | |||||
| pricing_config: { | pricing_config: { | ||||
| billing_unit: 'per_1m_tokens', | billing_unit: 'per_1m_tokens', | ||||
| table: [ | table: [ | ||||
| { resolution: '480p', video_input: true, price: 28, source: 'manual' }, | |||||
| { resolution: '480p', video_input: false, price: 46, source: 'manual' }, | |||||
| { | |||||
| resolution: '480p', | |||||
| video_input: true, | |||||
| price: 28, | |||||
| source: 'manual', | |||||
| }, | |||||
| { | |||||
| resolution: '480p', | |||||
| video_input: false, | |||||
| price: 46, | |||||
| source: 'manual', | |||||
| }, | |||||
| ], | ], | ||||
| }, | }, | ||||
| }; | }; | ||||
| @@ -50,6 +64,24 @@ describe('model pricing helpers', () => { | |||||
| unit: '1M tokens', | unit: '1M tokens', | ||||
| usedGroupRatio: 0.5, | usedGroupRatio: 0.5, | ||||
| }); | }); | ||||
| expect( | |||||
| summarizeMatrixPricing({ | |||||
| record, | |||||
| usedGroupRatio: 0.5, | |||||
| tokenUnit: 'M', | |||||
| displayPrice, | |||||
| currency: 'USD', | |||||
| }).details, | |||||
| ).toEqual([ | |||||
| { | |||||
| dimensionText: 'resolution=480p · video_input=true', | |||||
| formattedPrice: '$14.0000', | |||||
| }, | |||||
| { | |||||
| dimensionText: 'resolution=480p · video_input=false', | |||||
| formattedPrice: '$23.0000', | |||||
| }, | |||||
| ]); | |||||
| }); | }); | ||||
| it('formats per 1K matrix token price from per 1M price', () => { | it('formats per 1K matrix token price from per 1M price', () => { | ||||
| @@ -63,4 +95,28 @@ describe('model pricing helpers', () => { | |||||
| }), | }), | ||||
| ).toBe('$0.0280'); | ).toBe('$0.0280'); | ||||
| }); | }); | ||||
| it('falls back to group ratio 1 when group ratios are not provided', () => { | |||||
| const record = { | |||||
| enable_groups: ['default'], | |||||
| pricing_config: { | |||||
| billing_unit: 'per_1m_tokens', | |||||
| table: [{ resolution: '720p', price: 46, source: 'manual' }], | |||||
| }, | |||||
| }; | |||||
| expect( | |||||
| calculateModelPrice({ | |||||
| record, | |||||
| selectedGroup: 'default', | |||||
| tokenUnit: 'M', | |||||
| displayPrice, | |||||
| currency: 'USD', | |||||
| }), | |||||
| ).toMatchObject({ | |||||
| isMatrix: true, | |||||
| price: '$46.0000', | |||||
| usedGroupRatio: 1, | |||||
| }); | |||||
| }); | |||||
| }); | }); | ||||
| @@ -0,0 +1,168 @@ | |||||
| const MATRIX_BILLING_MODE = 'matrix'; | |||||
| const PHASE_PRECONSUME = 'preconsume'; | |||||
| const PHASE_SETTLEMENT = 'settlement'; | |||||
| const PHASE_REFUND = 'refund'; | |||||
| const parseOther = (other) => { | |||||
| if (!other) return {}; | |||||
| if (typeof other === 'object') return other; | |||||
| try { | |||||
| const parsed = JSON.parse(other); | |||||
| return parsed && typeof parsed === 'object' ? parsed : {}; | |||||
| } catch { | |||||
| return {}; | |||||
| } | |||||
| }; | |||||
| const numberOrFallback = (value, fallback) => { | |||||
| const numberValue = Number(value); | |||||
| return Number.isFinite(numberValue) ? numberValue : fallback; | |||||
| }; | |||||
| const getMergeKey = (log) => { | |||||
| const other = parseOther(log?.other); | |||||
| if (other.billing_mode !== MATRIX_BILLING_MODE) return ''; | |||||
| if (!other.task_id || !other.billing_phase) return ''; | |||||
| if ( | |||||
| ![PHASE_PRECONSUME, PHASE_SETTLEMENT, PHASE_REFUND].includes( | |||||
| other.billing_phase, | |||||
| ) | |||||
| ) { | |||||
| return ''; | |||||
| } | |||||
| return other.task_id; | |||||
| }; | |||||
| const summarizeBillingLog = (log) => { | |||||
| const other = parseOther(log.other); | |||||
| return { | |||||
| id: log.id, | |||||
| type: log.type, | |||||
| quota: log.quota, | |||||
| created_at: log.created_at, | |||||
| billing_phase: other.billing_phase, | |||||
| pre_consumed_quota: other.pre_consumed_quota, | |||||
| actual_quota: other.actual_quota, | |||||
| quota_delta: other.quota_delta, | |||||
| }; | |||||
| }; | |||||
| const pickMergedRecord = (group) => { | |||||
| const refund = group.find( | |||||
| (item) => item.other.billing_phase === PHASE_REFUND, | |||||
| ); | |||||
| if (refund) { | |||||
| return { | |||||
| item: refund, | |||||
| status: 'refunded', | |||||
| type: 6, | |||||
| quota: refund.log.quota, | |||||
| }; | |||||
| } | |||||
| const settlement = group.find( | |||||
| (item) => item.other.billing_phase === PHASE_SETTLEMENT, | |||||
| ); | |||||
| if (settlement) { | |||||
| const preConsumed = numberOrFallback( | |||||
| settlement.other.pre_consumed_quota, | |||||
| 0, | |||||
| ); | |||||
| const actualQuota = numberOrFallback( | |||||
| settlement.other.actual_quota, | |||||
| preConsumed + numberOrFallback(settlement.other.quota_delta, 0), | |||||
| ); | |||||
| return { | |||||
| item: settlement, | |||||
| status: 'settled', | |||||
| type: 2, | |||||
| quota: actualQuota, | |||||
| }; | |||||
| } | |||||
| const preconsume = | |||||
| group.find((item) => item.other.billing_phase === PHASE_PRECONSUME) || | |||||
| group[0]; | |||||
| return { | |||||
| item: preconsume, | |||||
| status: 'pending', | |||||
| type: 2, | |||||
| quota: preconsume.log.quota, | |||||
| }; | |||||
| }; | |||||
| const buildMergedRecord = (taskId, group) => { | |||||
| const picked = pickMergedRecord(group); | |||||
| const preconsume = group.find( | |||||
| (item) => item.other.billing_phase === PHASE_PRECONSUME, | |||||
| ); | |||||
| const settlement = group.find( | |||||
| (item) => item.other.billing_phase === PHASE_SETTLEMENT, | |||||
| ); | |||||
| const refund = group.find( | |||||
| (item) => item.other.billing_phase === PHASE_REFUND, | |||||
| ); | |||||
| const other = { | |||||
| ...picked.item.other, | |||||
| task_id: taskId, | |||||
| task_billing_merged: true, | |||||
| task_billing_status: picked.status, | |||||
| task_billing_logs: group.map((item) => summarizeBillingLog(item.log)), | |||||
| }; | |||||
| if (preconsume) { | |||||
| other.pre_consumed_quota = numberOrFallback( | |||||
| preconsume.other.pre_consumed_quota, | |||||
| preconsume.log.quota, | |||||
| ); | |||||
| } | |||||
| if (settlement) { | |||||
| other.actual_quota = numberOrFallback( | |||||
| settlement.other.actual_quota, | |||||
| picked.quota, | |||||
| ); | |||||
| other.quota_delta = numberOrFallback(settlement.other.quota_delta, 0); | |||||
| } | |||||
| if (refund) { | |||||
| other.refund_quota = refund.log.quota; | |||||
| } | |||||
| return { | |||||
| ...picked.item.log, | |||||
| key: `task-billing-${taskId}`, | |||||
| type: picked.type, | |||||
| quota: picked.quota, | |||||
| other: JSON.stringify(other), | |||||
| }; | |||||
| }; | |||||
| export const mergeTaskBillingLogs = (logs = []) => { | |||||
| const groups = new Map(); | |||||
| const passthrough = []; | |||||
| logs.forEach((log, index) => { | |||||
| const taskId = getMergeKey(log); | |||||
| if (!taskId) { | |||||
| passthrough.push({ index, log }); | |||||
| return; | |||||
| } | |||||
| if (!groups.has(taskId)) { | |||||
| groups.set(taskId, { firstIndex: index, items: [] }); | |||||
| } | |||||
| groups.get(taskId).items.push({ | |||||
| log, | |||||
| other: parseOther(log.other), | |||||
| index, | |||||
| }); | |||||
| }); | |||||
| const merged = Array.from(groups.entries()).map(([taskId, group]) => ({ | |||||
| index: group.firstIndex, | |||||
| log: buildMergedRecord(taskId, group.items), | |||||
| })); | |||||
| return [...passthrough, ...merged] | |||||
| .sort((a, b) => a.index - b.index) | |||||
| .map((item) => item.log); | |||||
| }; | |||||
| @@ -0,0 +1,143 @@ | |||||
| import { describe, expect, it } from 'vitest'; | |||||
| import { mergeTaskBillingLogs } from './taskBillingLogMerge.js'; | |||||
| const withOther = (log, other) => ({ | |||||
| ...log, | |||||
| other: JSON.stringify(other), | |||||
| }); | |||||
| const readOther = (log) => JSON.parse(log.other); | |||||
| describe('mergeTaskBillingLogs', () => { | |||||
| it('merges matrix preconsume and settlement logs into one final consumption row', () => { | |||||
| const logs = [ | |||||
| withOther( | |||||
| { id: 11, type: 2, quota: 1, created_at: 100, model_name: 'seedance' }, | |||||
| { | |||||
| billing_mode: 'matrix', | |||||
| billing_phase: 'preconsume', | |||||
| task_id: 'task_1', | |||||
| pre_consumed_quota: 1, | |||||
| }, | |||||
| ), | |||||
| withOther( | |||||
| { id: 12, type: 2, quota: 36, created_at: 200, model_name: 'seedance' }, | |||||
| { | |||||
| billing_mode: 'matrix', | |||||
| billing_phase: 'settlement', | |||||
| task_id: 'task_1', | |||||
| pre_consumed_quota: 1, | |||||
| actual_quota: 37, | |||||
| quota_delta: 36, | |||||
| }, | |||||
| ), | |||||
| ]; | |||||
| const merged = mergeTaskBillingLogs(logs); | |||||
| expect(merged).toHaveLength(1); | |||||
| expect(merged[0]).toMatchObject({ | |||||
| id: 12, | |||||
| key: 'task-billing-task_1', | |||||
| type: 2, | |||||
| quota: 37, | |||||
| }); | |||||
| expect(readOther(merged[0])).toMatchObject({ | |||||
| billing_mode: 'matrix', | |||||
| billing_phase: 'settlement', | |||||
| task_id: 'task_1', | |||||
| pre_consumed_quota: 1, | |||||
| actual_quota: 37, | |||||
| quota_delta: 36, | |||||
| task_billing_merged: true, | |||||
| task_billing_status: 'settled', | |||||
| }); | |||||
| expect(readOther(merged[0]).task_billing_logs).toHaveLength(2); | |||||
| }); | |||||
| it('keeps pending preconsume rows when no settlement is present', () => { | |||||
| const logs = [ | |||||
| withOther( | |||||
| { id: 21, type: 2, quota: 1, created_at: 100, model_name: 'seedance' }, | |||||
| { | |||||
| billing_mode: 'matrix', | |||||
| billing_phase: 'preconsume', | |||||
| task_id: 'task_2', | |||||
| pre_consumed_quota: 1, | |||||
| }, | |||||
| ), | |||||
| ]; | |||||
| const merged = mergeTaskBillingLogs(logs); | |||||
| expect(merged).toHaveLength(1); | |||||
| expect(merged[0]).toMatchObject({ | |||||
| id: 21, | |||||
| key: 'task-billing-task_2', | |||||
| type: 2, | |||||
| quota: 1, | |||||
| }); | |||||
| expect(readOther(merged[0])).toMatchObject({ | |||||
| task_billing_merged: true, | |||||
| task_billing_status: 'pending', | |||||
| }); | |||||
| }); | |||||
| it('does not merge old matrix logs that do not have billing phase metadata', () => { | |||||
| const logs = [ | |||||
| withOther( | |||||
| { id: 31, type: 2, quota: 1, created_at: 100, model_name: 'seedance' }, | |||||
| { | |||||
| billing_mode: 'matrix', | |||||
| task_id: 'task_3', | |||||
| }, | |||||
| ), | |||||
| withOther( | |||||
| { id: 32, type: 2, quota: 9, created_at: 200, model_name: 'seedance' }, | |||||
| { | |||||
| billing_mode: 'matrix', | |||||
| task_id: 'task_3', | |||||
| }, | |||||
| ), | |||||
| ]; | |||||
| expect(mergeTaskBillingLogs(logs)).toHaveLength(2); | |||||
| }); | |||||
| it('merges preconsume and refund logs into one refund row', () => { | |||||
| const logs = [ | |||||
| withOther( | |||||
| { id: 41, type: 2, quota: 20, created_at: 100, model_name: 'seedance' }, | |||||
| { | |||||
| billing_mode: 'matrix', | |||||
| billing_phase: 'preconsume', | |||||
| task_id: 'task_4', | |||||
| pre_consumed_quota: 20, | |||||
| }, | |||||
| ), | |||||
| withOther( | |||||
| { id: 42, type: 6, quota: 20, created_at: 200, model_name: 'seedance' }, | |||||
| { | |||||
| billing_mode: 'matrix', | |||||
| billing_phase: 'refund', | |||||
| task_id: 'task_4', | |||||
| pre_consumed_quota: 20, | |||||
| }, | |||||
| ), | |||||
| ]; | |||||
| const merged = mergeTaskBillingLogs(logs); | |||||
| expect(merged).toHaveLength(1); | |||||
| expect(merged[0]).toMatchObject({ | |||||
| id: 42, | |||||
| key: 'task-billing-task_4', | |||||
| type: 6, | |||||
| quota: 20, | |||||
| }); | |||||
| expect(readOther(merged[0])).toMatchObject({ | |||||
| task_billing_merged: true, | |||||
| task_billing_status: 'refunded', | |||||
| }); | |||||
| }); | |||||
| }); | |||||
| @@ -38,6 +38,7 @@ import { | |||||
| } from '../../helpers'; | } from '../../helpers'; | ||||
| import { ITEMS_PER_PAGE } from '../../constants'; | import { ITEMS_PER_PAGE } from '../../constants'; | ||||
| import { useTableCompactMode } from '../common/useTableCompactMode'; | import { useTableCompactMode } from '../common/useTableCompactMode'; | ||||
| import { mergeTaskBillingLogs } from './taskBillingLogMerge'; | |||||
| export const useLogsData = () => { | export const useLogsData = () => { | ||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| @@ -248,8 +249,12 @@ export const useLogsData = () => { | |||||
| logType: formLogType, | logType: formLogType, | ||||
| } = getFormValues(); | } = getFormValues(); | ||||
| const currentLogType = formLogType !== undefined ? formLogType : logType; | const currentLogType = formLogType !== undefined ? formLogType : logType; | ||||
| let localStartTimestamp = start_timestamp ? Date.parse(start_timestamp) / 1000 : 0; | |||||
| let localEndTimestamp = end_timestamp ? Date.parse(end_timestamp) / 1000 : 0; | |||||
| let localStartTimestamp = start_timestamp | |||||
| ? Date.parse(start_timestamp) / 1000 | |||||
| : 0; | |||||
| let localEndTimestamp = end_timestamp | |||||
| ? Date.parse(end_timestamp) / 1000 | |||||
| : 0; | |||||
| const params = new URLSearchParams({ type: currentLogType }); | const params = new URLSearchParams({ type: currentLogType }); | ||||
| if (localStartTimestamp) params.set('start_timestamp', localStartTimestamp); | if (localStartTimestamp) params.set('start_timestamp', localStartTimestamp); | ||||
| if (localEndTimestamp) params.set('end_timestamp', localEndTimestamp); | if (localEndTimestamp) params.set('end_timestamp', localEndTimestamp); | ||||
| @@ -278,8 +283,12 @@ export const useLogsData = () => { | |||||
| logType: formLogType, | logType: formLogType, | ||||
| } = getFormValues(); | } = getFormValues(); | ||||
| const currentLogType = formLogType !== undefined ? formLogType : logType; | const currentLogType = formLogType !== undefined ? formLogType : logType; | ||||
| let localStartTimestamp = start_timestamp ? Date.parse(start_timestamp) / 1000 : 0; | |||||
| let localEndTimestamp = end_timestamp ? Date.parse(end_timestamp) / 1000 : 0; | |||||
| let localStartTimestamp = start_timestamp | |||||
| ? Date.parse(start_timestamp) / 1000 | |||||
| : 0; | |||||
| let localEndTimestamp = end_timestamp | |||||
| ? Date.parse(end_timestamp) / 1000 | |||||
| : 0; | |||||
| const params = new URLSearchParams({ type: currentLogType }); | const params = new URLSearchParams({ type: currentLogType }); | ||||
| if (localStartTimestamp) params.set('start_timestamp', localStartTimestamp); | if (localStartTimestamp) params.set('start_timestamp', localStartTimestamp); | ||||
| if (localEndTimestamp) params.set('end_timestamp', localEndTimestamp); | if (localEndTimestamp) params.set('end_timestamp', localEndTimestamp); | ||||
| @@ -340,6 +349,208 @@ export const useLogsData = () => { | |||||
| // Format logs data | // Format logs data | ||||
| const setLogsFormat = (logs) => { | const setLogsFormat = (logs) => { | ||||
| logs = mergeTaskBillingLogs(logs || []); | |||||
| const pushDetail = (sections, sectionKey, item) => { | |||||
| if ( | |||||
| item?.value === undefined || | |||||
| item?.value === null || | |||||
| item?.value === '' | |||||
| ) { | |||||
| return; | |||||
| } | |||||
| if (!sections[sectionKey]) { | |||||
| sections[sectionKey] = []; | |||||
| } | |||||
| sections[sectionKey].push(item); | |||||
| }; | |||||
| const compactJsonBlock = (value) => ( | |||||
| <pre | |||||
| style={{ | |||||
| margin: 0, | |||||
| maxHeight: 220, | |||||
| overflow: 'auto', | |||||
| whiteSpace: 'pre-wrap', | |||||
| wordBreak: 'break-word', | |||||
| lineHeight: 1.6, | |||||
| fontSize: 12, | |||||
| padding: '8px 10px', | |||||
| border: '1px solid var(--semi-color-border)', | |||||
| borderRadius: 6, | |||||
| background: 'var(--semi-color-fill-0)', | |||||
| }} | |||||
| > | |||||
| {typeof value === 'string' ? value : JSON.stringify(value, null, 2)} | |||||
| </pre> | |||||
| ); | |||||
| 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('原因'), | |||||
| <div style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}> | |||||
| {other.reason} | |||||
| </div>, | |||||
| ); | |||||
| } | |||||
| if (other?.reject_reason) { | |||||
| addKnown('state', t('拦截原因'), other.reject_reason); | |||||
| } | |||||
| if (other?.task_billing_status) { | |||||
| const statusMap = { | |||||
| pending: t('待结算'), | |||||
| settled: t('已结算'), | |||||
| refunded: t('已退还'), | |||||
| }; | |||||
| addKnown( | |||||
| 'state', | |||||
| t('任务账单状态'), | |||||
| statusMap[other.task_billing_status] || other.task_billing_status, | |||||
| ); | |||||
| } | |||||
| const sectionOrder = [ | |||||
| ['basic', t('基础信息')], | |||||
| ['billing', t('计费信息')], | |||||
| ['matrix', t('矩阵计费')], | |||||
| ['context', t('请求上下文')], | |||||
| ['state', t('状态与原因')], | |||||
| ]; | |||||
| return { | |||||
| items: flatItems, | |||||
| sections: sectionOrder | |||||
| .filter(([key]) => sections[key]?.length) | |||||
| .map(([key, title]) => ({ title, items: sections[key] })), | |||||
| }; | |||||
| }; | |||||
| const requestConversionDisplayValue = (conversionChain) => { | const requestConversionDisplayValue = (conversionChain) => { | ||||
| const chain = Array.isArray(conversionChain) | const chain = Array.isArray(conversionChain) | ||||
| ? conversionChain.filter(Boolean) | ? conversionChain.filter(Boolean) | ||||
| @@ -357,7 +568,10 @@ export const useLogsData = () => { | |||||
| let other = getLogOther(logs[i].other); | let other = getLogOther(logs[i].other); | ||||
| let expandDataLocal = []; | let expandDataLocal = []; | ||||
| if (isAdminUser && (logs[i].type === 0 || logs[i].type === 2 || logs[i].type === 6)) { | |||||
| if ( | |||||
| isAdminUser && | |||||
| (logs[i].type === 0 || logs[i].type === 2 || logs[i].type === 6) | |||||
| ) { | |||||
| expandDataLocal.push({ | expandDataLocal.push({ | ||||
| key: t('渠道信息'), | key: t('渠道信息'), | ||||
| value: `${logs[i].channel} - ${logs[i].channel_name || '[未知]'}`, | value: `${logs[i].channel} - ${logs[i].channel_name || '[未知]'}`, | ||||
| @@ -571,13 +785,63 @@ export const useLogsData = () => { | |||||
| expandDataLocal.push({ | expandDataLocal.push({ | ||||
| key: t('失败原因'), | key: t('失败原因'), | ||||
| value: ( | value: ( | ||||
| <div style={{ maxWidth: 600, whiteSpace: 'normal', wordBreak: 'break-word', lineHeight: 1.6 }}> | |||||
| <div | |||||
| style={{ | |||||
| maxWidth: 600, | |||||
| whiteSpace: 'normal', | |||||
| wordBreak: 'break-word', | |||||
| lineHeight: 1.6, | |||||
| }} | |||||
| > | |||||
| {other.reason} | {other.reason} | ||||
| </div> | </div> | ||||
| ), | ), | ||||
| }); | }); | ||||
| } | } | ||||
| } | } | ||||
| if (other?.task_billing_merged) { | |||||
| const statusMap = { | |||||
| pending: t('待结算'), | |||||
| settled: t('已结算'), | |||||
| refunded: t('已退还'), | |||||
| }; | |||||
| expandDataLocal.push({ | |||||
| key: t('任务账单状态'), | |||||
| value: | |||||
| statusMap[other.task_billing_status] || other.task_billing_status, | |||||
| }); | |||||
| if (other?.task_id && logs[i].type !== 6) { | |||||
| expandDataLocal.push({ | |||||
| key: t('任务ID'), | |||||
| value: other.task_id, | |||||
| }); | |||||
| } | |||||
| if (other?.pre_consumed_quota !== undefined) { | |||||
| expandDataLocal.push({ | |||||
| key: t('预扣金额'), | |||||
| value: renderQuota(other.pre_consumed_quota, 6), | |||||
| }); | |||||
| } | |||||
| if (other?.actual_quota !== undefined) { | |||||
| expandDataLocal.push({ | |||||
| key: t('实际消费'), | |||||
| value: renderQuota(other.actual_quota, 6), | |||||
| }); | |||||
| } | |||||
| if (other?.quota_delta !== undefined) { | |||||
| const delta = Number(other.quota_delta); | |||||
| expandDataLocal.push({ | |||||
| key: t('结算差额'), | |||||
| value: `${delta > 0 ? '+' : ''}${renderQuota(delta, 6)}`, | |||||
| }); | |||||
| } | |||||
| if (other?.refund_quota !== undefined) { | |||||
| expandDataLocal.push({ | |||||
| key: t('退还金额'), | |||||
| value: renderQuota(other.refund_quota, 6), | |||||
| }); | |||||
| } | |||||
| } | |||||
| if (other?.request_path) { | if (other?.request_path) { | ||||
| expandDataLocal.push({ | expandDataLocal.push({ | ||||
| key: t('请求路径'), | key: t('请求路径'), | ||||
| @@ -651,7 +915,11 @@ export const useLogsData = () => { | |||||
| value: localCountMode, | value: localCountMode, | ||||
| }); | }); | ||||
| } | } | ||||
| expandDatesLocal[logs[i].key] = expandDataLocal; | |||||
| expandDatesLocal[logs[i].key] = buildSectionedExpandData( | |||||
| expandDataLocal, | |||||
| logs[i], | |||||
| other, | |||||
| ); | |||||
| } | } | ||||
| setExpandData(expandDatesLocal); | setExpandData(expandDatesLocal); | ||||
| @@ -684,8 +952,12 @@ export const useLogsData = () => { | |||||
| ? formLogType | ? formLogType | ||||
| : logType; | : logType; | ||||
| let localStartTimestamp = start_timestamp ? Date.parse(start_timestamp) / 1000 : 0; | |||||
| let localEndTimestamp = end_timestamp ? Date.parse(end_timestamp) / 1000 : 0; | |||||
| let localStartTimestamp = start_timestamp | |||||
| ? Date.parse(start_timestamp) / 1000 | |||||
| : 0; | |||||
| let localEndTimestamp = end_timestamp | |||||
| ? Date.parse(end_timestamp) / 1000 | |||||
| : 0; | |||||
| const params = new URLSearchParams({ | const params = new URLSearchParams({ | ||||
| p: startIdx, | p: startIdx, | ||||
| @@ -778,9 +1050,14 @@ export const useLogsData = () => { | |||||
| // Check if any record has expandable content | // Check if any record has expandable content | ||||
| const hasExpandableRows = () => { | const hasExpandableRows = () => { | ||||
| return logs.some( | |||||
| (log) => expandData[log.key] && expandData[log.key].length > 0, | |||||
| ); | |||||
| return logs.some((log) => { | |||||
| const detail = expandData[log.key]; | |||||
| return ( | |||||
| detail?.sections?.length > 0 || | |||||
| detail?.items?.length > 0 || | |||||
| detail?.length > 0 | |||||
| ); | |||||
| }); | |||||
| }; | }; | ||||
| return { | return { | ||||