Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,4 @@ token_estimator_test.go
skills-lock.json
.playwright-mcp
Operation.md
home
/home
19 changes: 12 additions & 7 deletions controller/relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -582,20 +582,25 @@ func RelayTask(c *gin.Context) {
}
service.LogTaskConsumption(c, relayInfo)

deltaSettlementDisabled := false
if relayInfo.ChannelMeta != nil && relayInfo.ChannelType == constant.ChannelTypeDoubaoVideo {
deltaSettlementDisabled = relayInfo.ChannelOtherSettings.DisableTaskDeltaSettlement
}
task := model.InitTask(result.Platform, relayInfo)
task.PrivateData.UpstreamTaskID = result.UpstreamTaskID
task.PrivateData.BillingSource = relayInfo.BillingSource
task.PrivateData.SubscriptionId = relayInfo.SubscriptionId
task.PrivateData.TokenId = relayInfo.TokenId
task.PrivateData.NodeName = common.NodeName
task.PrivateData.BillingContext = &model.TaskBillingContext{
ModelPrice: relayInfo.PriceData.ModelPrice,
GroupRatio: relayInfo.PriceData.GroupRatioInfo.GroupRatio,
ModelRatio: relayInfo.PriceData.ModelRatio,
OtherRatios: relayInfo.PriceData.OtherRatios,
TaskBilling: relayInfo.TaskBilling,
OriginModelName: relayInfo.OriginModelName,
PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName) || relayInfo.PriceData.UsePrice || relayInfo.TaskBilling != nil,
ModelPrice: relayInfo.PriceData.ModelPrice,
GroupRatio: relayInfo.PriceData.GroupRatioInfo.GroupRatio,
ModelRatio: relayInfo.PriceData.ModelRatio,
OtherRatios: relayInfo.PriceData.OtherRatios,
TaskBilling: relayInfo.TaskBilling,
OriginModelName: relayInfo.OriginModelName,
PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName) || relayInfo.PriceData.UsePrice || relayInfo.TaskBilling != nil,
DeltaSettlementDisabled: common.GetPointer(deltaSettlementDisabled),
}
task.Quota = result.Quota
task.Data = result.TaskData
Expand Down
4 changes: 3 additions & 1 deletion controller/task_video.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,9 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
}

// 如果返回了 total_tokens 并且配置了模型倍率(非固定价格),则重新计费
if taskResult.TotalTokens > 0 {
if task.DeltaSettlementDisabledForChannel(channel.Type, channel.GetOtherSettings()) {
logger.LogInfo(ctx, fmt.Sprintf("视频任务 %s 已按渠道设置跳过完成态差额结算", task.TaskID))
} else if taskResult.TotalTokens > 0 {
// 获取模型名称
var taskData map[string]interface{}
if err := common.Unmarshal(task.Data, &taskData); err == nil {
Expand Down
1 change: 1 addition & 0 deletions dto/channel_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ type ChannelOtherSettings struct {
UpstreamModelUpdateIgnoredModels []string `json:"upstream_model_update_ignored_models,omitempty"`
TaskProtocol string `json:"task_protocol,omitempty"`
TaskProtocolConfig *TaskProtocolConfig `json:"task_protocol_config,omitempty"`
DisableTaskDeltaSettlement bool `json:"disable_task_delta_settlement,omitempty"`
AdvancedCustom *AdvancedCustomConfig `json:"advanced_custom,omitempty"`
}

Expand Down
32 changes: 25 additions & 7 deletions model/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,31 @@ type TaskPrivateData struct {

// TaskBillingContext 记录任务提交时的计费参数,以便轮询阶段可以重新计算额度。
type TaskBillingContext struct {
ModelPrice float64 `json:"model_price,omitempty"` // 模型单价
GroupRatio float64 `json:"group_ratio,omitempty"` // 分组倍率
ModelRatio float64 `json:"model_ratio,omitempty"` // 模型倍率
OtherRatios map[string]float64 `json:"other_ratios,omitempty"` // 附加倍率(时长、分辨率等)
TaskBilling *types.TaskBillingResult `json:"task_billing,omitempty"`
OriginModelName string `json:"origin_model_name,omitempty"` // 模型名称,必须为OriginModelName
PerCallBilling bool `json:"per_call_billing,omitempty"` // 按次计费:跳过轮询阶段的差额结算
ModelPrice float64 `json:"model_price,omitempty"` // 模型单价
GroupRatio float64 `json:"group_ratio,omitempty"` // 分组倍率
ModelRatio float64 `json:"model_ratio,omitempty"` // 模型倍率
OtherRatios map[string]float64 `json:"other_ratios,omitempty"` // 附加倍率(时长、分辨率等)
TaskBilling *types.TaskBillingResult `json:"task_billing,omitempty"`
OriginModelName string `json:"origin_model_name,omitempty"` // 模型名称,必须为OriginModelName
PerCallBilling bool `json:"per_call_billing,omitempty"` // 按次计费:跳过轮询阶段的差额结算
DeltaSettlementDisabled *bool `json:"delta_settlement_disabled,omitempty"` // 渠道关闭完成态差额结算时按提交快照跳过
}

func (t *Task) DeltaSettlementDisabledForChannel(channelType int, channelSettings ...dto.ChannelOtherSettings) bool {
if channelType != constant.ChannelTypeDoubaoVideo {
return false
}
if t != nil {
if bc := t.PrivateData.BillingContext; bc != nil && bc.DeltaSettlementDisabled != nil {
return *bc.DeltaSettlementDisabled
}
}
for _, settings := range channelSettings {
if settings.DisableTaskDeltaSettlement {
return true
}
}
return false
}

// GetUpstreamTaskID 获取上游真实 task ID(用于与 provider 通信)
Expand Down
22 changes: 22 additions & 0 deletions relay/channel/task/taskcommon/path_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,28 @@ func TestBuildTaskQueryURLKeepsFallbackWithoutConfig(t *testing.T) {
assert.Equal(t, "https://fallback.example.com/task_abc", got)
}

func TestParseConfiguredTaskResultReadsUsageTokens(t *testing.T) {
taskInfo, ok, err := ParseConfiguredTaskResult([]byte(`{
"object": "media.task",
"task_id": "task_123",
"status": "succeeded",
"progress": 100,
"result": {
"url": "https://example.com/result.mp4"
},
"usage": {
"completion_tokens": 40594,
"total_tokens": 40594
}
}`), dto.ChannelOtherSettings{TaskProtocol: TaskProtocolGenericVideo})

require.NoError(t, err)
require.True(t, ok)
require.NotNil(t, taskInfo)
assert.Equal(t, 40594, taskInfo.CompletionTokens)
assert.Equal(t, 40594, taskInfo.TotalTokens)
Comment on lines +90 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use distinct token values.

Identical fixtures let an incorrect shared mapping pass; use different completion_tokens and total_tokens values.

Proposed fix
-    "completion_tokens": 40594,
-    "total_tokens": 40594
+    "completion_tokens": 12345,
+    "total_tokens": 40594
...
- assert.Equal(t, 40594, taskInfo.CompletionTokens)
+ assert.Equal(t, 12345, taskInfo.CompletionTokens)
  assert.Equal(t, 40594, taskInfo.TotalTokens)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"usage": {
"completion_tokens": 40594,
"total_tokens": 40594
}
}`), dto.ChannelOtherSettings{TaskProtocol: TaskProtocolGenericVideo})
require.NoError(t, err)
require.True(t, ok)
require.NotNil(t, taskInfo)
assert.Equal(t, 40594, taskInfo.CompletionTokens)
assert.Equal(t, 40594, taskInfo.TotalTokens)
"usage": {
"completion_tokens": 12345,
"total_tokens": 40594
}
}`), dto.ChannelOtherSettings{TaskProtocol: TaskProtocolGenericVideo})
require.NoError(t, err)
require.True(t, ok)
require.NotNil(t, taskInfo)
assert.Equal(t, 12345, taskInfo.CompletionTokens)
assert.Equal(t, 40594, taskInfo.TotalTokens)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@relay/channel/task/taskcommon/path_config_test.go` around lines 90 - 100,
Update the test fixture and assertions around taskInfo token parsing to use
distinct values for completion_tokens and total_tokens, ensuring the assertions
independently verify CompletionTokens and TotalTokens mapping.

}

func TestBuildConfiguredTaskPassThroughBodyUsesChannelPassThrough(t *testing.T) {
c := newJSONTaskContext(`{
"model": "local-model",
Expand Down
16 changes: 10 additions & 6 deletions relay/channel/task/taskcommon/protocol_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,14 +203,18 @@ func ParseConfiguredTaskResult(respBody []byte, settings dto.ChannelOtherSetting
status = string(model.TaskStatusSuccess)
}
progress := NormalizeConfiguredProgress(progressRaw, status)
completionTokens := int(gjson.GetBytes(respBody, "usage.completion_tokens").Int())
totalTokens := int(gjson.GetBytes(respBody, "usage.total_tokens").Int())

return &relaycommon.TaskInfo{
Code: 0,
TaskID: taskID,
Status: status,
Progress: progress,
Url: resultURL,
Reason: reason,
Code: 0,
TaskID: taskID,
Status: status,
Progress: progress,
Url: resultURL,
Reason: reason,
CompletionTokens: completionTokens,
TotalTokens: totalTokens,
}, true, nil
}

Expand Down
120 changes: 117 additions & 3 deletions service/task_billing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"time"

"github.com/MAX-API-Next/MAX-API/common"
"github.com/MAX-API-Next/MAX-API/constant"
"github.com/MAX-API-Next/MAX-API/dto"
"github.com/MAX-API-Next/MAX-API/model"
relaycommon "github.com/MAX-API-Next/MAX-API/relay/common"
"github.com/glebarez/sqlite"
Expand Down Expand Up @@ -716,7 +718,7 @@ func TestSettle_PerCallBilling_SkipsAdaptorAdjust(t *testing.T) {
adaptor := &mockAdaptor{adjustReturn: 2000}
taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess}

settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
settleTaskBillingOnComplete(ctx, adaptor, task, taskResult, 0)

// Per-call: no adjustment despite adaptor returning 2000
assert.EqualValues(t, initQuota, getUserQuota(t, userID))
Expand All @@ -743,7 +745,7 @@ func TestSettle_PerCallBilling_SkipsTotalTokens(t *testing.T) {
adaptor := &mockAdaptor{adjustReturn: 0}
taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess, TotalTokens: 9999}

settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
settleTaskBillingOnComplete(ctx, adaptor, task, taskResult, 0)

// Per-call: no recalculation by tokens
assert.EqualValues(t, initQuota, getUserQuota(t, userID))
Expand All @@ -752,6 +754,118 @@ func TestSettle_PerCallBilling_SkipsTotalTokens(t *testing.T) {
assert.Equal(t, int64(0), countLogs(t))
}

func TestSettle_DeltaSettlementDisabledSnapshotSkipsAdjustments(t *testing.T) {
truncate(t)
ctx := context.Background()

const userID, tokenID, channelID = 34, 34, 34
const initQuota, preConsumed = 10000, 5000
const tokenRemain = 8000

seedUser(t, userID, initQuota)
seedToken(t, tokenID, userID, "sk-disabled-snapshot", tokenRemain)
seedChannel(t, channelID)

task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
task.PrivateData.BillingContext.DeltaSettlementDisabled = common.GetPointer(true)

adaptor := &mockAdaptor{adjustReturn: 3000}
taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess, TotalTokens: 9999}

settleTaskBillingOnComplete(ctx, adaptor, task, taskResult, constant.ChannelTypeDoubaoVideo, dto.ChannelOtherSettings{})

assert.EqualValues(t, initQuota, getUserQuota(t, userID))
assert.Equal(t, tokenRemain, getTokenRemainQuota(t, tokenID))
assert.Equal(t, preConsumed, task.Quota)
assert.Equal(t, int64(0), countLogs(t))
}

func TestSettle_DeltaSettlementDisabledChannelFallbackForLegacyTask(t *testing.T) {
truncate(t)
ctx := context.Background()

const userID, tokenID, channelID = 35, 35, 35
const initQuota, preConsumed = 10000, 5000
const tokenRemain = 8000

seedUser(t, userID, initQuota)
seedToken(t, tokenID, userID, "sk-disabled-channel", tokenRemain)
seedChannel(t, channelID)

task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
task.PrivateData.BillingContext.DeltaSettlementDisabled = nil

adaptor := &mockAdaptor{adjustReturn: 3000}
taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess, TotalTokens: 9999}

settleTaskBillingOnComplete(ctx, adaptor, task, taskResult, constant.ChannelTypeDoubaoVideo, dto.ChannelOtherSettings{
DisableTaskDeltaSettlement: true,
})

assert.EqualValues(t, initQuota, getUserQuota(t, userID))
assert.Equal(t, tokenRemain, getTokenRemainQuota(t, tokenID))
assert.Equal(t, preConsumed, task.Quota)
assert.Equal(t, int64(0), countLogs(t))
}

func TestSettle_DeltaSettlementSnapshotOverridesCurrentChannelSetting(t *testing.T) {
truncate(t)
ctx := context.Background()

const userID, tokenID, channelID = 36, 36, 36
const initQuota, preConsumed = 10000, 5000
const adaptorQuota = 3000
const tokenRemain = 8000

seedUser(t, userID, initQuota)
seedToken(t, tokenID, userID, "sk-enabled-snapshot", tokenRemain)
seedChannel(t, channelID)

task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
task.PrivateData.BillingContext.DeltaSettlementDisabled = common.GetPointer(false)

adaptor := &mockAdaptor{adjustReturn: adaptorQuota}
taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess}

settleTaskBillingOnComplete(ctx, adaptor, task, taskResult, constant.ChannelTypeDoubaoVideo, dto.ChannelOtherSettings{
DisableTaskDeltaSettlement: true,
})

assert.EqualValues(t, initQuota+(preConsumed-adaptorQuota), getUserQuota(t, userID))
assert.Equal(t, tokenRemain+(preConsumed-adaptorQuota), getTokenRemainQuota(t, tokenID))
assert.Equal(t, adaptorQuota, task.Quota)
assert.Equal(t, int64(1), countLogs(t))
}

func TestSettle_DeltaSettlementDisabledIgnoredForNonDoubaoVideo(t *testing.T) {
truncate(t)
ctx := context.Background()

const userID, tokenID, channelID = 37, 37, 37
const initQuota, preConsumed = 10000, 5000
const adaptorQuota = 3000
const tokenRemain = 8000

seedUser(t, userID, initQuota)
seedToken(t, tokenID, userID, "sk-disabled-non-doubao", tokenRemain)
seedChannel(t, channelID)

task := makeTask(userID, channelID, preConsumed, tokenID, BillingSourceWallet, 0)
task.PrivateData.BillingContext.DeltaSettlementDisabled = common.GetPointer(true)

adaptor := &mockAdaptor{adjustReturn: adaptorQuota}
taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess}

settleTaskBillingOnComplete(ctx, adaptor, task, taskResult, constant.ChannelTypeKling, dto.ChannelOtherSettings{
DisableTaskDeltaSettlement: true,
})

assert.EqualValues(t, initQuota+(preConsumed-adaptorQuota), getUserQuota(t, userID))
assert.Equal(t, tokenRemain+(preConsumed-adaptorQuota), getTokenRemainQuota(t, tokenID))
assert.Equal(t, adaptorQuota, task.Quota)
assert.Equal(t, int64(1), countLogs(t))
}

func TestSettle_NonPerCall_AdaptorAdjustWorks(t *testing.T) {
truncate(t)
ctx := context.Background()
Expand All @@ -771,7 +885,7 @@ func TestSettle_NonPerCall_AdaptorAdjustWorks(t *testing.T) {
adaptor := &mockAdaptor{adjustReturn: adaptorQuota}
taskResult := &relaycommon.TaskInfo{Status: model.TaskStatusSuccess}

settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
settleTaskBillingOnComplete(ctx, adaptor, task, taskResult, 0)

// Non-per-call: adaptor adjustment applies (refund 2000)
assert.EqualValues(t, initQuota+(preConsumed-adaptorQuota), getUserQuota(t, userID))
Expand Down
8 changes: 6 additions & 2 deletions service/task_polling.go
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor TaskPollingAdaptor, ch *
}

if shouldSettle {
settleTaskBillingOnComplete(ctx, adaptor, task, taskResult)
settleTaskBillingOnComplete(ctx, adaptor, task, taskResult, ch.Type, ch.GetOtherSettings())
}
if shouldRefund {
RefundTaskQuota(ctx, task, task.FailReason)
Expand Down Expand Up @@ -563,12 +563,16 @@ func truncateBase64(s string) string {
//
// 2. taskResult.TotalTokens > 0 → 按 token 重算
// 3. 都不满足 → 保持预扣额度不变
func settleTaskBillingOnComplete(ctx context.Context, adaptor TaskPollingAdaptor, task *model.Task, taskResult *relaycommon.TaskInfo) {
func settleTaskBillingOnComplete(ctx context.Context, adaptor TaskPollingAdaptor, task *model.Task, taskResult *relaycommon.TaskInfo, channelType int, channelSettings ...dto.ChannelOtherSettings) {
// 0. 按次计费的任务不做差额结算
if bc := task.PrivateData.BillingContext; bc != nil && bc.PerCallBilling {
logger.LogInfo(ctx, fmt.Sprintf("任务 %s 按次计费,跳过差额结算", task.TaskID))
return
}
if task.DeltaSettlementDisabledForChannel(channelType, channelSettings...) {
logger.LogInfo(ctx, fmt.Sprintf("任务 %s 已按渠道设置跳过完成态差额结算", task.TaskID))
return
}
// 1. 优先让 adaptor 决定最终额度
if actualQuota := adaptor.AdjustBillingOnComplete(task, taskResult); actualQuota > 0 {
RecalculateTaskQuota(ctx, task, actualQuota, "adaptor计费调整")
Expand Down
4 changes: 2 additions & 2 deletions web/default/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
"type": "module",
"scripts": {
"dev": "rsbuild dev",
"build": "rsbuild build",
"build:check": "tsc -b && rsbuild build",
"build": "rsbuild build && node scripts/verify-tailwind-build.mjs",
"build:check": "tsc -b && rsbuild build && node scripts/verify-tailwind-build.mjs",
"typecheck": "tsc -b",
"lint": "eslint .",
"preview": "rsbuild preview",
Expand Down
Loading
Loading