Files
new-api/controller/plugin_native_e2e_test.go
CaIon 55e2ae54d1 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.
2026-10-20 22:43:11 +08:00

249 lines
9.3 KiB
Go

package controller
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
"github.com/QuantumNous/new-api/relay"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
type nativeRouteBilling struct {
events []string
preConsumed int
userID int
settled bool
}
func (b *nativeRouteBilling) Settle(int) error {
b.events = append(b.events, "settle")
b.settled = true
return nil
}
func (b *nativeRouteBilling) Refund(*gin.Context) {
b.events = append(b.events, "refund")
if !b.settled && b.preConsumed > 0 {
_ = model.IncreaseUserQuota(b.userID, b.preConsumed, true)
b.preConsumed = 0
}
}
func (b *nativeRouteBilling) NeedsRefund() bool {
return !b.settled && b.preConsumed > 0
}
func (b *nativeRouteBilling) GetPreConsumedQuota() int {
return b.preConsumed
}
func (b *nativeRouteBilling) Reserve(quota int) error {
b.events = append(b.events, "reserve")
if err := model.DecreaseUserQuota(b.userID, quota, true); err != nil {
return err
}
b.preConsumed = quota
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()
previousDB := model.DB
previousLogDB := model.LOG_DB
previousMemoryCache := common.MemoryCacheEnabled
previousBatchUpdate := common.BatchUpdateEnabled
previousLogConsume := common.LogConsumeEnabled
previousRedisEnabled := common.RedisEnabled
database, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, database.AutoMigrate(&model.User{}, &model.Channel{}, &model.Task{}, &model.Log{}))
model.DB = database
model.LOG_DB = database
common.MemoryCacheEnabled = false
common.BatchUpdateEnabled = false
common.LogConsumeEnabled = false
common.RedisEnabled = false
previousModelRatios := ratio_setting.ModelRatio2JSONString()
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"kling-v1":1}`))
t.Cleanup(func() {
model.DB = previousDB
model.LOG_DB = previousLogDB
common.MemoryCacheEnabled = previousMemoryCache
common.BatchUpdateEnabled = previousBatchUpdate
common.LogConsumeEnabled = previousLogConsume
common.RedisEnabled = previousRedisEnabled
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(previousModelRatios))
})
require.NoError(t, database.Create(&model.User{
Id: 7,
Username: "native-route-user",
Group: "default",
Quota: 1_000_000,
}).Error)
var submitCalls atomic.Int32
var queryCalls atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodPost && r.URL.Path == "/kling/v1/videos/text2video":
submitCalls.Add(1)
body, readErr := io.ReadAll(r.Body)
if !assert.NoError(t, readErr) {
http.Error(w, "read request", http.StatusInternalServerError)
return
}
assert.Contains(t, string(body), `"model_name":"kling-v1"`)
_, _ = io.WriteString(w, `{"code":0,"message":"","data":{"task_id":"kling-private-1","task_status":"submitted"}}`)
case r.Method == http.MethodGet && r.URL.Path == "/kling/v1/videos/text2video/kling-private-1":
queryCalls.Add(1)
_, _ = io.WriteString(w, `{"code":0,"message":"","data":{"task_id":"kling-private-1","task_status":"succeed","task_status_msg":"","task_result":{"videos":[{"id":"video-private","url":"https://cdn.example/video.mp4","duration":"5"}]},"final_unit_deduction":"1"}}`)
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
channel := model.Channel{
Type: constant.ChannelTypeKling,
Name: "kling-native-e2e",
Key: "sk-test",
BaseURL: &upstream.URL,
Status: common.ChannelStatusEnabled,
Models: "kling-v1",
Group: "default",
}
require.NoError(t, database.Create(&channel).Error)
generation := pluginruntime.DefaultRegistry.Generation()
require.NotNil(t, generation)
submitBinding, found := generation.LookupDeclaredRoute(http.MethodPost, "/kling/v1/videos/text2video")
require.True(t, found)
require.Equal(t, "kling", submitBinding.Plugin.Meta.Key)
submitRecorder := httptest.NewRecorder()
submitContext, _ := gin.CreateTestContext(submitRecorder)
submitContext.Request = httptest.NewRequest(
http.MethodPost,
"/kling/v1/videos/text2video",
bytes.NewBufferString(`{"model_name":"kling-v1","prompt":"a lighthouse"}`),
)
submitContext.Request.Header.Set("Content-Type", "application/json")
submitContext.Set(pluginruntime.ContextKeyPinnedRoute, pluginruntime.PinnedRoute{
Generation: generation,
Plugin: submitBinding.Plugin,
Route: submitBinding.Route,
})
common.SetContextKey(submitContext, constant.ContextKeyUserId, 7)
common.SetContextKey(submitContext, constant.ContextKeyUserGroup, "default")
common.SetContextKey(submitContext, constant.ContextKeyUsingGroup, "default")
common.SetContextKey(submitContext, constant.ContextKeyTokenGroup, "default")
common.SetContextKey(submitContext, constant.ContextKeyUserQuota, 1_000_000)
middleware.PrepareTaskPluginRoute()(submitContext)
require.False(t, submitContext.IsAborted(), submitRecorder.Body.String())
require.Equal(t, "kling-v1", submitContext.GetString("resolved_task_model"))
require.Equal(t, "text_to_video", submitContext.GetString("task_action"))
require.Nil(t, middleware.SetupContextForSelectedChannel(submitContext, &channel, "kling-v1"))
billing := &nativeRouteBilling{userID: 7}
relayInfo := &relaycommon.RelayInfo{
UserId: 7,
UserGroup: "default",
UsingGroup: "default",
UserQuota: 1_000_000,
TokenGroup: "default",
OriginModelName: "kling-v1",
Billing: billing,
TaskRelayInfo: &relaycommon.TaskRelayInfo{
Action: submitContext.GetString("task_action"),
PublicTaskID: "task_kling_public",
LockedChannel: &channel,
},
}
outcome, taskErr := executeTaskSubmissionWith(submitContext, relayInfo, relay.RelayTaskSubmit)
require.Nil(t, taskErr)
require.NotNil(t, outcome)
require.Equal(t, []string{"reserve", "settle"}, billing.events)
require.False(t, submitContext.Writer.Written())
presentTaskSubmission(submitContext, outcome)
require.Equal(t, http.StatusOK, submitRecorder.Code)
assert.Contains(t, submitRecorder.Body.String(), `"task_id":"task_kling_public"`)
assert.NotContains(t, submitRecorder.Body.String(), "kling-private-1")
assert.Equal(t, int32(1), submitCalls.Load())
var persisted model.Task
require.NoError(t, database.Where("task_id = ?", "task_kling_public").First(&persisted).Error)
assert.Equal(t, constant.TaskPlatform("kling"), persisted.Platform)
assert.Equal(t, "kling-private-1", persisted.PrivateData.UpstreamTaskID)
assert.Equal(t, model.TaskStatus(model.TaskStatusNotStart), persisted.Status)
previousAdaptorFactory := service.GetTaskAdaptorFunc
service.GetTaskAdaptorFunc = func(platform constant.TaskPlatform) service.TaskPollingAdaptor {
return relay.GetTaskAdaptor(platform)
}
t.Cleanup(func() { service.GetTaskAdaptorFunc = previousAdaptorFactory })
service.DispatchPlatformUpdate(
context.Background(),
persisted.Platform,
map[int][]string{channel.Id: {"kling-private-1"}},
map[string]*model.Task{"kling-private-1": &persisted},
)
require.NoError(t, database.Where("task_id = ?", "task_kling_public").First(&persisted).Error)
assert.Equal(t, model.TaskStatus(model.TaskStatusSuccess), persisted.Status)
assert.Equal(t, "100%", persisted.Progress)
assert.Equal(t, 1, persisted.Quota)
assert.Equal(t, int32(1), queryCalls.Load())
var settledUser model.User
require.NoError(t, database.First(&settledUser, 7).Error)
assert.Equal(t, 999_999, settledUser.Quota)
queryBinding, found := generation.LookupDeclaredRoute(http.MethodGet, "/kling/v1/videos/text2video/:task_id")
require.True(t, found)
queryRecorder := httptest.NewRecorder()
queryContext, _ := gin.CreateTestContext(queryRecorder)
queryContext.Request = httptest.NewRequest(http.MethodGet, "/kling/v1/videos/text2video/task_kling_public", nil)
queryContext.Params = gin.Params{{Key: "task_id", Value: "task_kling_public"}}
queryContext.Set(pluginruntime.ContextKeyPinnedRoute, pluginruntime.PinnedRoute{
Generation: generation,
Plugin: queryBinding.Plugin,
Route: queryBinding.Route,
})
common.SetContextKey(queryContext, constant.ContextKeyUserId, 7)
middleware.PrepareTaskPluginRoute()(queryContext)
require.True(t, queryContext.IsAborted())
require.Equal(t, http.StatusOK, queryRecorder.Code)
assert.Contains(t, queryRecorder.Body.String(), `"task_id":"task_kling_public"`)
assert.Contains(t, queryRecorder.Body.String(), `"task_status":"succeed"`)
assert.NotContains(t, queryRecorder.Body.String(), "kling-private-1")
assert.NotContains(t, queryRecorder.Body.String(), upstream.URL)
}