fix(task): reprice each retry and reserve it before submission

A task retry that moved to another plugin or channel kept the previous
attempt's usage expression. A retry from an expression-priced plugin to
one priced per request charged, and later settled, at the first plugin's
price. Each attempt now prices itself from scratch.

A retry to a more expensive channel was topped up only after the
upstream had accepted the task. A user who could not cover the
difference went into arrears, or the task was dropped while the upstream
was already running it. The retry now raises the reservation before the
request is sent, with the same balance check as the first pre-consume,
and a channel the balance cannot cover is never called.

A wallet top-up taken before submission replaced the amount held by the
first pre-consume instead of adding to it, so a request that then failed
refunded only the top-up. Image requests whose quantity was raised
before submission were affected too. The full amount is now refunded.
This commit is contained in:
CaIon committed 2026-10-20 22:43:11 +08:00
1 parent fc8f7d6fef
commit 55e2ae54d1
13 files changed
+173 -15

No files matched your search

+4
View File
@@ -63,6 +63,10 @@ func (b *nativeRouteBilling) Reserve(quota int) error {
return nil
}
// ReserveBeforeSubmit holds nothing: this fixture charges in Reserve once the
// upstream has accepted.
func (*nativeRouteBilling) ReserveBeforeSubmit(int) error { return nil }
func TestKlingNativeRouteSubmitPollSettleAndQuery(t *testing.T) {
gin.SetMode(gin.TestMode)
service.InitHttpClient()
+2
View File
@@ -58,6 +58,8 @@ func (b *taskSubmissionTestBilling) Reserve(int) error {
return b.reserveErr
}
func (b *taskSubmissionTestBilling) ReserveBeforeSubmit(quota int) error { return b.Reserve(quota) }
func TestPresentTaskSubmissionUsesNativePresenterAfterPersistence(t *testing.T) {
plugin, err := pluginruntime.CompilePlugin(`
export const meta = {apiVersion:1,key:"presenter-test",name:"Presenter",version:"1.0.0",author:{name:"Test"},models:["model"],fetchMode:"per_task",routes:[{method:"POST",path:"/vendor/jobs",type:"submit",decode:"decode",render:"created"}]};
+2
View File
@@ -71,6 +71,8 @@ func (*recordedSettlement) NeedsRefund() bool { return false }
func (*recordedSettlement) GetPreConsumedQuota() int { return 0 }
func (*recordedSettlement) Reserve(int) error { return nil }
func (*recordedSettlement) ReserveBeforeSubmit(int) error { return nil }
// /v1/tools/search and /v1/tools/fetch follow /v1/alpha/search: the raw body
// reaches /v1/tools/* unchanged and the response returns unchanged. With only
// the built-in prices, a response DeepSeek bills (results, page content)
+4
View File
@@ -21,4 +21,8 @@ type BillingSettler interface {
// Reserve 将预扣额度补到目标值;若目标值不高于当前预扣额度则不做任何事。
Reserve(targetQuota int) error
// ReserveBeforeSubmit 与 Reserve 相同,用于发起上游请求前价格已经确定的补扣
// (例如任务重试换到更贵的渠道):钱包余额不足时返回额度不足,不记为欠费。
ReserveBeforeSubmit(targetQuota int) error
}
+2
View File
@@ -42,6 +42,8 @@ func (*imageReservation) Settle(int) error { return nil }
func (*imageReservation) Refund(*gin.Context) {}
func (*imageReservation) NeedsRefund() bool { return false }
func (s *imageReservation) ReserveBeforeSubmit(quota int) error { return s.Reserve(quota) }
func TestImageRequestReservesFinalQuantityBeforeUpstream(t *testing.T) {
service.InitHttpClient()
for _, tc := range []struct {
+2
View File
@@ -800,3 +800,5 @@ func (s *priceTestReservation) Refund(*gin.Context) {}
func (s *priceTestReservation) NeedsRefund() bool { return false }
func (s *priceTestReservation) GetPreConsumedQuota() int { return s.held }
func (s *priceTestReservation) Reserve(quota int) error { s.held = max(s.held, quota); return nil }
func (s *priceTestReservation) ReserveBeforeSubmit(quota int) error { return s.Reserve(quota) }
+7 -4
View File
@@ -194,7 +194,7 @@ func ApplyOriginTaskAffinity(c *gin.Context, info *relaycommon.RelayInfo) *dto.T
// RelayTaskSubmit 完成 task 提交的全部流程(每次尝试调用一次):
// 刷新渠道元数据 → 确定 platform/adaptor → 验证请求 →
// 估算计费(EstimateBilling) → 计算价格 → 预扣费(仅首次)→
// 估算计费(EstimateBilling) → 计算价格 → 预扣费(重试时补足差额)→
// 构建/发送/解析上游请求 → 提交后计费调整(AdjustBillingOnSubmit)。
// 共享控制器编排负责未落库退款、最终额度预留、落库和结算。
func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitResult, *dto.TaskError) {
@@ -247,8 +247,10 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe
}
}
// 4. 价格计算:基础模型价格
// 4. 价格计算:基础模型价格。重试可能换了插件或渠道,每次尝试都重新定价,
// 不沿用上一次尝试的表达式快照。
info.OriginModelName = modelName
info.TieredBillingSnapshot = nil
var priceData types.PriceData
var err error
pluginKey := c.GetString("task_plugin_key")
@@ -331,8 +333,9 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe
noteTaskQuotaClamp(info, clamp)
}
// 7. 预扣费(仅首次 — 重试时 info.Billing 已存在,跳过)
if info.Billing == nil && !info.PriceData.FreeModel {
// 7. 预扣费:首次尝试创建计费会话;重试时会话已存在,换到更贵的渠道要在
// 发起上游请求前补足预扣,余额不足就不发请求。
if info.Billing != nil || !info.PriceData.FreeModel {
info.ForcePreConsume = true
if apiErr := service.PreConsumeBilling(c, info.PriceData.Quota, info); apiErr != nil {
return nil, service.TaskErrorFromAPIError(apiErr)
+101 -1
View File
@@ -19,6 +19,7 @@ import (
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/billing_setting"
"github.com/QuantumNous/new-api/setting/config"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
@@ -531,7 +532,7 @@ export function parseTaskResult() { return {status:"SUCCESS"}; }
c.Set("group", "default")
info.UserGroup, info.UsingGroup = "default", "default"
info.OriginModelName = "declared-model"
// An existing reservation skips pre-consume so the fixture reaches the upstream call.
// An existing reservation stands in for the wallet so the fixture reaches the upstream call.
info.Billing = &imageReservation{limit: 1 << 30}
pinMappingOrderPlugin(t, c, source)
@@ -551,3 +552,102 @@ export function parseTaskResult() { return {status:"SUCCESS"}; }
})
}
}
// A retry prices the plugin it lands on from scratch and holds that price
// before the upstream sees the request. Plugin A fails upstream on the first
// attempt; the retry runs on plugin B, pinned the way
// SetupContextForSelectedChannel pins another candidate plugin's channel.
func TestRelayTaskSubmitRetryRepricesAndReservesBeforeUpstream(t *testing.T) {
service.InitHttpClient()
const source = `
export const meta = {apiVersion:1,key:"KEY",name:"KEY",version:"1.0.0",author:{name:"Test"},models:["declared-model"],fetchMode:"per_task",usageSchema:{seconds:{type:"number",unit:"second",description:"Video generation unit price"}}};
export function buildSubmitRequest(ctx) { return {url: ctx.baseUrl+"/submit", method:"POST", body:{model: ctx.model}, action:"text_to_video"}; }
export function parseSubmitResponse(ctx, response) { return {taskId: response.body.id}; }
export function buildQueryRequest(ctx) { return {url: ctx.baseUrl+"/query"}; }
export function parseTaskResult() { return {status:"SUCCESS"}; }
export function extractUsage() { return {seconds:2}; }
`
pricier := map[string]string{"retry-a::declared-model": `tier("a", u("seconds") * 0.05)`, "retry-b::declared-model": `tier("b", u("seconds") * 0.4)`}
for _, tc := range []struct {
name, perCallPrice, wantCode string
expressions map[string]string
wallet, wantQuota int
wantSubmitted []string
}{
{name: "per-call plugin drops the previous expression", expressions: map[string]string{"retry-a::declared-model": `tier("a", u("seconds") * 0.02)`}, perCallPrice: `{"declared-model":0.04}`, wallet: 1 << 30, wantQuota: 40000, wantSubmitted: []string{"a", "b"}},
{name: "pricier plugin is held before submission", expressions: pricier, perCallPrice: `{}`, wallet: 1 << 30, wantQuota: 400000, wantSubmitted: []string{"a", "b"}},
{name: "unaffordable plugin is never submitted", expressions: pricier, perCallPrice: `{}`, wallet: 100000, wantCode: "insufficient_user_quota", wantSubmitted: []string{"a"}},
} {
t.Run(tc.name, func(t *testing.T) {
saveBillingConfig(t)
savedPrices := ratio_setting.ModelPrice2JSONString()
t.Cleanup(func() { require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(savedPrices)) })
require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(tc.perCallPrice))
expressions, err := common.Marshal(tc.expressions)
require.NoError(t, err)
require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{
billing_setting.PluginBillingExprOption: string(expressions),
"billing_setting.billing_mode": `{}`,
"billing_setting.billing_expr": `{}`,
"group_ratio_setting.group_ratio": `{"default":1}`,
}))
registry := pluginruntime.NewRegistry()
for _, key := range []string{"retry-a", "retry-b"} {
_, err := registry.Register(strings.ReplaceAll(source, "KEY", key), pluginruntime.Options{})
require.NoError(t, err)
}
// The fake stands in for the session the first attempt created; its
// limit is the most the wallet can hold.
billing := &imageReservation{limit: tc.wallet}
var submitted []string
var heldAtSubmit []int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempt, _, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")
submitted = append(submitted, attempt)
heldAtSubmit = append(heldAtSubmit, billing.held)
w.Header().Set("Content-Type", "application/json")
if attempt == "a" {
w.WriteHeader(http.StatusInternalServerError)
}
_, _ = w.Write([]byte(`{"id":"job-` + attempt + `"}`))
}))
defer server.Close()
c, info := newTaskSubmitContext(t, "declared-model", "")
c.Set("group", "default")
info.UserGroup, info.UsingGroup, info.OriginModelName = "default", "default", "declared-model"
info.Billing = billing
var result *TaskSubmitResult
var taskErr *dto.TaskError
for _, attempt := range []string{"a", "b"} {
plugin, ok := registry.Generation().Get("retry-" + attempt)
require.True(t, ok)
c.Set(pluginruntime.ContextKeyPinnedPlugin, pluginruntime.PinnedPlugin{Generation: registry.Generation(), Plugin: plugin})
c.Set("task_plugin_key", plugin.Meta.Key)
common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, server.URL+"/"+attempt)
result, taskErr = RelayTaskSubmit(c, info)
if attempt == "a" {
require.NotNil(t, taskErr)
require.Equal(t, http.StatusInternalServerError, taskErr.StatusCode, "the first attempt fails upstream and is retried")
}
}
require.Equal(t, tc.wantSubmitted, submitted)
if tc.wantCode != "" {
require.NotNil(t, taskErr)
assert.Equal(t, tc.wantCode, taskErr.Code)
assert.Equal(t, http.StatusForbidden, taskErr.StatusCode)
return
}
require.Nil(t, taskErr, "submission error: %+v", taskErr)
assert.Equal(t, tc.wantQuota, result.Quota)
assert.Equal(t, tc.wantQuota, heldAtSubmit[1], "plugin B must not see the request before its price is held")
if tc.perCallPrice != `{}` {
assert.Nil(t, info.TieredBillingSnapshot, "a per-call plugin must not settle with the previous plugin's expression")
return
}
require.NotNil(t, info.TieredBillingSnapshot)
assert.Equal(t, tc.expressions["retry-b::declared-model"], info.TieredBillingSnapshot.ExprString)
})
}
}
+13
View File
@@ -1,6 +1,7 @@
package service
import (
"errors"
"fmt"
"net/http"
@@ -18,6 +19,8 @@ const (
// PreConsumeBilling 根据用户计费偏好创建 BillingSession 并执行预扣费。
// 会话存储在 relayInfo.Billing 上,供后续 Settle / Refund 使用。
// 会话已存在时(任务重试换到另一个渠道)把预扣补到 preConsumedQuota,
// 和首次预扣一样必须由余额覆盖。
func PreConsumeBilling(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError {
if relayInfo != nil && relayInfo.QuotaClamp != nil {
return types.NewErrorWithStatusCode(
@@ -35,6 +38,16 @@ func PreConsumeBilling(c *gin.Context, preConsumedQuota int, relayInfo *relaycom
types.ErrOptionWithSkipRetry(),
)
}
if relayInfo != nil && relayInfo.Billing != nil {
if err := relayInfo.Billing.ReserveBeforeSubmit(preConsumedQuota); err != nil {
var apiErr *types.NewAPIError
if errors.As(err, &apiErr) {
return apiErr
}
return types.NewErrorWithStatusCode(err, types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry())
}
return nil
}
session, apiErr := NewBillingSession(c, relayInfo, preConsumedQuota)
if apiErr != nil {
return apiErr
+16 -8
View File
@@ -156,15 +156,23 @@ func (s *BillingSession) GetPreConsumedQuota() int {
}
func (s *BillingSession) Reserve(targetQuota int) error {
return s.reserve(targetQuota, false)
}
// ReserveBeforeSubmit 在发起上游请求前补扣,钱包余额必须覆盖差额。
func (s *BillingSession) ReserveBeforeSubmit(targetQuota int) error {
return s.reserve(targetQuota, true)
}
func (s *BillingSession) reserve(targetQuota int, requireAvailableQuota bool) error {
s.mu.Lock()
defer s.mu.Unlock()
imageRequest := false
if s.relayInfo != nil {
_, imageRequest = s.relayInfo.Request.(*dto.ImageRequest)
imageRequest = imageRequest || s.relayInfo.ImageRequestCount > 0
_, imageRequest := s.relayInfo.Request.(*dto.ImageRequest)
requireAvailableQuota = requireAvailableQuota || imageRequest || s.relayInfo.ImageRequestCount > 0
}
if s.settled || s.refunded || s.trusted && !imageRequest || targetQuota <= s.preConsumedQuota {
if s.settled || s.refunded || s.trusted && !requireAvailableQuota || targetQuota <= s.preConsumedQuota {
return nil
}
@@ -173,7 +181,7 @@ func (s *BillingSession) Reserve(targetQuota int) error {
return nil
}
if err := s.reserveFunding(delta, imageRequest); err != nil {
if err := s.reserveFunding(delta, requireAvailableQuota); err != nil {
return err
}
if err := s.reserveToken(delta); err != nil {
@@ -184,7 +192,7 @@ func (s *BillingSession) Reserve(targetQuota int) error {
s.preConsumedQuota += delta
s.tokenConsumed += delta
s.extraReserved += delta
if imageRequest {
if requireAvailableQuota {
s.trusted = false
}
s.syncRelayInfo()
@@ -257,8 +265,8 @@ func (s *BillingSession) reserveFunding(delta int, requireAvailableQuota bool) e
switch funding := s.funding.(type) {
case *WalletFunding:
if requireAvailableQuota {
// Image quantity is known before submission, including retries and
// overrides. Reserve atomically instead of admitting wallet debt.
// Image quantities and task retry prices are known before
// submission. Reserve atomically instead of admitting wallet debt.
if err := funding.PreConsume(delta); err != nil {
if errors.Is(err, ErrInsufficientWalletQuota) {
return types.NewErrorWithStatusCode(err, types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
+2 -1
View File
@@ -50,7 +50,8 @@ func (w *WalletFunding) PreConsume(amount int) error {
if !reserved {
return ErrInsufficientWalletQuota
}
w.consumed = amount
// 发送前补扣会再次预扣,累加后退款才能退回全部预扣。
w.consumed += amount
return nil
}
+14 -1
View File
@@ -110,7 +110,7 @@ func runFixedPriceAccountingCases(t *testing.T, db, logDB *gorm.DB) {
usage *dto.Usage
audio, stream, refund, insufficient, tool bool
realtime, reserveInsufficient bool
wallet, outboundImages int
wallet, outboundImages, retryReservation int
groupRatio float64
want int
unit billingexpr.BillingUnit
@@ -140,6 +140,8 @@ func runFixedPriceAccountingCases(t *testing.T, db, logDB *gorm.DB) {
{name: "image quantity exceeds one-image wallet before submission", expression: `tier("image", fixed(0.04)) * image_count`, requestedImages: 4, wallet: 20000, insufficient: true},
{name: "image override reserves extra quantity", expression: `tier("image", fixed(0.04)) * image_count`, requestedImages: 1, outboundImages: 4, want: 80000, unit: billingexpr.BillingUnitRequest},
{name: "image override cannot exceed remaining wallet", expression: `tier("image", fixed(0.04)) * image_count`, requestedImages: 1, outboundImages: 4, wallet: 40000, reserveInsufficient: true, refund: true},
{name: "task retry holds a pricier channel and refunds all of it", expression: flat, retryReservation: 40000, refund: true},
{name: "task retry cannot exceed remaining wallet", expression: flat, retryReservation: 40000, wallet: 20000, reserveInsufficient: true, refund: true},
} {
t.Run(tc.name, func(t *testing.T) {
quota := startingQuota
@@ -203,6 +205,17 @@ func runFixedPriceAccountingCases(t *testing.T, db, logDB *gorm.DB) {
assert.Equal(t, tc.want, info.Billing.GetPreConsumedQuota())
}
}
if tc.retryReservation > 0 {
retryErr := PreConsumeBilling(ctx, tc.retryReservation, info)
if tc.reserveInsufficient {
require.NotNil(t, retryErr)
assert.Equal(t, types.ErrorCodeInsufficientUserQuota, retryErr.GetErrorCode())
assert.Equal(t, reservation, info.Billing.GetPreConsumedQuota())
} else {
require.Nil(t, retryErr)
assert.Equal(t, tc.retryReservation, info.Billing.GetPreConsumedQuota())
}
}
if tc.refund {
refunded := make(chan struct{}, 1)
const callback = "fixed_billing_refund_observed"
+4
View File
@@ -340,6 +340,10 @@ func (s *recordingBillingSettler) Reserve(targetQuota int) error {
return nil
}
func (s *recordingBillingSettler) ReserveBeforeSubmit(targetQuota int) error {
return s.Reserve(targetQuota)
}
func TestPrepareTieredBillingForSelectedGroupUpdatesReservation(t *testing.T) {
const expr = `tier("base", p)`
billing := &recordingBillingSettler{preConsumedQuota: 50_000}