mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 08:35:33 +08:00
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:
13 files changed
+173
-15
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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"}]};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -21,4 +21,8 @@ type BillingSettler interface {
|
||||
|
||||
// Reserve 将预扣额度补到目标值;若目标值不高于当前预扣额度则不做任何事。
|
||||
Reserve(targetQuota int) error
|
||||
|
||||
// ReserveBeforeSubmit 与 Reserve 相同,用于发起上游请求前价格已经确定的补扣
|
||||
// (例如任务重试换到更贵的渠道):钱包余额不足时返回额度不足,不记为欠费。
|
||||
ReserveBeforeSubmit(targetQuota int) error
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -50,7 +50,8 @@ func (w *WalletFunding) PreConsume(amount int) error {
|
||||
if !reserved {
|
||||
return ErrInsufficientWalletQuota
|
||||
}
|
||||
w.consumed = amount
|
||||
// 发送前补扣会再次预扣,累加后退款才能退回全部预扣。
|
||||
w.consumed += amount
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user