mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-11 14:41:21 +00:00
feat(task): resolve channel-mapped aliases and case variants for plugin models
Channel model_mapping keys exposed in a channel's model list now act as first-class aliases for task-plugin models across the whole line: - Derived alias view (model/task_model_alias.go): built from enabled channels' model_mapping, chain-following with cycle detection, declared names always win, cross-plugin conflicts dropped. Rebuilt on channel cache refresh, registry generation change, and a 60s TTL. - Request path: PinTaskPluginEndpoint resolves declared-name case folds and mapping aliases before endpoint lookup (never rewriting the body until the endpoint is claimed), pins with MappedModel, and the decode contract accepts alias echoes without loosening model ownership for normal pins. Legacy /v1/tasks submit folds case variants the same way. Fixes aliases on POST /v1/responses silently falling through to the main relay against task channels. - Mapping order: ModelMappedHelper now runs before the plugin submit hook builds and caches the upstream body, so channel model_mapping actually reaches the upstream request. Plugins receive the mapped name as ctx.upstreamModel in both decode and submit contexts. - Billing: identity stays the origin name; when the alias has no tiered expression, the selected channel's mapping tail expression applies. Pricing page and billing-expr smoke tests resolve aliases to the owning plugin's usage schema. - Case folding: ASCII-only fold with exact-match priority; same-plugin and cross-plugin fold collisions rejected at registration. - Plugins: model-keyed rate tables, req_key derivation, and combo validation in doubao/kling/jimeng/hailuo/vidu/sunoapi now key on ctx.upstreamModel || ctx.model; render/echo paths keep ctx.model.
This commit is contained in:
@@ -19,6 +19,7 @@ import (
|
||||
pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
|
||||
"github.com/QuantumNous/new-api/relay/channel"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/helper"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -1154,3 +1155,56 @@ func TestTaskAdaptorBatchBridge(t *testing.T) {
|
||||
assert.Equal(t, "40%", pending.TaskInfo.Progress)
|
||||
assert.Empty(t, pending.TaskInfo.Url)
|
||||
}
|
||||
|
||||
const mappingOrderAdaptorPlugin = `
|
||||
export const meta = {apiVersion:1,key:"map-order-adaptor",name:"Map Order Adaptor",version:"1.0.0",author:{name:"Test"},models:["declared-model"],fetchMode:"per_task"};
|
||||
export function buildSubmitRequest(ctx) {
|
||||
return {url: ctx.baseUrl+"/submit", method:"POST", body:{upstreamModel: ctx.upstreamModel, model: ctx.model}};
|
||||
}
|
||||
export function parseSubmitResponse(){return {taskId:"1"};}
|
||||
export function buildQueryRequest(){return {url:"https://provider.example"};}
|
||||
export function parseTaskResult(){return {status:"SUCCESS"};}
|
||||
`
|
||||
|
||||
func mappingOrderSubmitBody(t *testing.T, origin, mapping string) []byte {
|
||||
t.Helper()
|
||||
plugin, err := pluginruntime.NewRegistry().Register(mappingOrderAdaptorPlugin, pluginruntime.Options{})
|
||||
require.NoError(t, err)
|
||||
adaptor := New(plugin)
|
||||
info := &relaycommon.RelayInfo{
|
||||
ChannelMeta: &relaycommon.ChannelMeta{ChannelBaseUrl: "https://provider.example"},
|
||||
TaskRelayInfo: &relaycommon.TaskRelayInfo{},
|
||||
OriginModelName: origin,
|
||||
}
|
||||
adaptor.Init(info)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
|
||||
if mapping != "" {
|
||||
c.Set("model_mapping", mapping)
|
||||
}
|
||||
c.Set("task_request", map[string]any{"prompt": "p"})
|
||||
info.UpstreamModelName = info.OriginModelName
|
||||
require.NoError(t, helper.ModelMappedHelper(c, info, nil))
|
||||
require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))
|
||||
body, err := adaptor.BuildRequestBody(c, info)
|
||||
require.NoError(t, err)
|
||||
raw, err := io.ReadAll(body)
|
||||
require.NoError(t, err)
|
||||
return raw
|
||||
}
|
||||
|
||||
func TestTaskAdaptorBuildSubmitReceivesMappedUpstreamModel(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
mapped := mappingOrderSubmitBody(t, "alias-model", `{"alias-model":"mid-model","mid-model":"declared-model"}`)
|
||||
var decoded map[string]any
|
||||
require.NoError(t, common.Unmarshal(mapped, &decoded))
|
||||
assert.Equal(t, "declared-model", decoded["upstreamModel"])
|
||||
assert.Equal(t, "alias-model", decoded["model"])
|
||||
|
||||
withoutMapping := mappingOrderSubmitBody(t, "declared-model", "")
|
||||
emptyMapping := mappingOrderSubmitBody(t, "declared-model", "{}")
|
||||
assert.Equal(t, withoutMapping, emptyMapping)
|
||||
require.NoError(t, common.Unmarshal(withoutMapping, &decoded))
|
||||
assert.Equal(t, "declared-model", decoded["upstreamModel"])
|
||||
assert.Equal(t, "declared-model", decoded["model"])
|
||||
}
|
||||
|
||||
+34
-7
@@ -212,6 +212,19 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe
|
||||
info.PublicTaskID = model.GenerateTaskID()
|
||||
}
|
||||
adaptor.Init(info)
|
||||
// Plugin submit hooks run during ValidateRequestAndSetAction and cache the
|
||||
// upstream body. OriginModelName is already seeded on that line (protocol
|
||||
// resolved_task_model, legacy submit, or GenRelayInfo original_model), so
|
||||
// map before validation. The empty-name CoverTaskActionToModelName
|
||||
// synthesis happens after validate and cannot move; skip the late block
|
||||
// when early mapping ran so a chain is never applied twice.
|
||||
mappedBeforeValidate := info.OriginModelName != ""
|
||||
if mappedBeforeValidate {
|
||||
info.UpstreamModelName = info.OriginModelName
|
||||
if err := helper.ModelMappedHelper(c, info, nil); err != nil {
|
||||
return nil, service.TaskErrorWrapperLocal(err, "model_mapping_failed", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
if taskErr := adaptor.ValidateRequestAndSetAction(c, info); taskErr != nil {
|
||||
return nil, taskErr
|
||||
}
|
||||
@@ -222,19 +235,33 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe
|
||||
modelName = service.CoverTaskActionToModelName(platform, info.Action)
|
||||
}
|
||||
|
||||
// 2.5 应用渠道的模型映射(与同步任务对齐)
|
||||
info.OriginModelName = modelName
|
||||
info.UpstreamModelName = modelName
|
||||
if err := helper.ModelMappedHelper(c, info, nil); err != nil {
|
||||
return nil, service.TaskErrorWrapperLocal(err, "model_mapping_failed", http.StatusBadRequest)
|
||||
if !mappedBeforeValidate {
|
||||
info.OriginModelName = modelName
|
||||
info.UpstreamModelName = modelName
|
||||
if err := helper.ModelMappedHelper(c, info, nil); err != nil {
|
||||
return nil, service.TaskErrorWrapperLocal(err, "model_mapping_failed", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 价格计算:基础模型价格
|
||||
info.OriginModelName = modelName
|
||||
var priceData types.PriceData
|
||||
var err error
|
||||
if billing_setting.GetBillingMode(modelName) == billing_setting.BillingModeTieredExpr {
|
||||
exprStr, exists := billing_setting.GetBillingExpr(modelName)
|
||||
useTiered := billing_setting.GetBillingMode(modelName) == billing_setting.BillingModeTieredExpr
|
||||
var exprStr string
|
||||
var exists bool
|
||||
if useTiered {
|
||||
exprStr, exists = billing_setting.GetBillingExpr(modelName)
|
||||
} else if info.IsModelMapped {
|
||||
if billing_setting.GetBillingMode(info.UpstreamModelName) == billing_setting.BillingModeTieredExpr {
|
||||
if tailExpr, tailOK := billing_setting.GetBillingExpr(info.UpstreamModelName); tailOK && strings.TrimSpace(tailExpr) != "" {
|
||||
exprStr = tailExpr
|
||||
exists = true
|
||||
useTiered = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if useTiered {
|
||||
provider, supported := adaptor.(channel.TaskUsageFactsProvider)
|
||||
if !exists || !supported {
|
||||
return nil, service.TaskErrorWrapper(fmt.Errorf("task model %s has no usage expression or meter", modelName), "model_price_error", http.StatusBadRequest)
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||||
pluginruntime "github.com/QuantumNous/new-api/pkg/jsplugin"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/billing_setting"
|
||||
"github.com/QuantumNous/new-api/setting/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTaskModel2DtoNormalizesLegacyAction(t *testing.T) {
|
||||
@@ -16,3 +27,206 @@ func TestTaskModel2DtoNormalizesLegacyAction(t *testing.T) {
|
||||
assert.Equal(t, constant.TaskActionFirstTailToVideo, dtoTask.Action)
|
||||
assert.Equal(t, "firstTailGenerate", task.Action)
|
||||
}
|
||||
|
||||
const mappingOrderSubmitPlugin = `
|
||||
export const meta = {apiVersion:1,key:"maporder",name:"Map Order",version:"1.0.0",author:{name:"Test"},models:["declared-model"],fetchMode:"per_task"};
|
||||
export function buildSubmitRequest(ctx) {
|
||||
return {url: ctx.baseUrl+"/submit", method:"POST", body:{upstreamModel: ctx.upstreamModel, model: ctx.model}, action:"text_to_video"};
|
||||
}
|
||||
export function parseSubmitResponse(){return {taskId:"1"};}
|
||||
export function buildQueryRequest(){return {url:"https://provider.example"};}
|
||||
export function parseTaskResult(){return {status:"SUCCESS"};}
|
||||
`
|
||||
|
||||
const mappingOrderRewritePlugin = `
|
||||
export const meta = {apiVersion:1,key:"maporder-rw",name:"Map Order RW",version:"1.0.0",author:{name:"Test"},models:["declared-model"],fetchMode:"per_task"};
|
||||
export function buildSubmitRequest(ctx) {
|
||||
return {url: ctx.baseUrl+"/submit", method:"POST", body:{upstreamModel: ctx.upstreamModel}, rewriteModel:"rewritten"};
|
||||
}
|
||||
export function parseSubmitResponse(){return {taskId:"1"};}
|
||||
export function buildQueryRequest(){return {url:"https://provider.example"};}
|
||||
export function parseTaskResult(){return {status:"SUCCESS"};}
|
||||
`
|
||||
|
||||
func pinMappingOrderPlugin(t *testing.T, c *gin.Context, source string) {
|
||||
t.Helper()
|
||||
plugin, err := pluginruntime.NewRegistry().Register(source, pluginruntime.Options{})
|
||||
require.NoError(t, err)
|
||||
c.Set(pluginruntime.ContextKeyPinnedPlugin, pluginruntime.PinnedPlugin{Plugin: plugin})
|
||||
}
|
||||
|
||||
func newTaskSubmitContext(t *testing.T, originalModel, mapping string) (*gin.Context, *relaycommon.RelayInfo) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
|
||||
common.SetContextKey(c, constant.ContextKeyOriginalModel, originalModel)
|
||||
common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, "https://provider.example")
|
||||
if mapping != "" {
|
||||
c.Set("model_mapping", mapping)
|
||||
}
|
||||
c.Set("task_request", map[string]any{"prompt": "p"})
|
||||
return c, &relaycommon.RelayInfo{TaskRelayInfo: &relaycommon.TaskRelayInfo{}}
|
||||
}
|
||||
|
||||
func TestRelayTaskSubmitMapsBeforeValidateWhenOriginSet(t *testing.T) {
|
||||
const mapping = `{"alias-model":"mid-model","mid-model":"declared-model"}`
|
||||
|
||||
c, info := newTaskSubmitContext(t, "alias-model", mapping)
|
||||
pinMappingOrderPlugin(t, c, mappingOrderSubmitPlugin)
|
||||
info.OriginModelName = "alias-model"
|
||||
|
||||
_, taskErr := RelayTaskSubmit(c, info)
|
||||
require.NotNil(t, taskErr)
|
||||
assert.Equal(t, "model_price_error", taskErr.Code)
|
||||
assert.Equal(t, "alias-model", info.OriginModelName)
|
||||
assert.Equal(t, "declared-model", info.UpstreamModelName)
|
||||
assert.True(t, info.IsModelMapped)
|
||||
}
|
||||
|
||||
func TestRelayTaskSubmitDeclaredNameWithoutMappingIsUnchanged(t *testing.T) {
|
||||
c, info := newTaskSubmitContext(t, "declared-model", "")
|
||||
pinMappingOrderPlugin(t, c, mappingOrderSubmitPlugin)
|
||||
info.OriginModelName = "declared-model"
|
||||
|
||||
_, taskErr := RelayTaskSubmit(c, info)
|
||||
require.NotNil(t, taskErr)
|
||||
assert.Equal(t, "model_price_error", taskErr.Code)
|
||||
assert.Equal(t, "declared-model", info.OriginModelName)
|
||||
assert.Equal(t, "declared-model", info.UpstreamModelName)
|
||||
assert.False(t, info.IsModelMapped)
|
||||
}
|
||||
|
||||
func TestRelayTaskSubmitDoesNotApplyMappingTwice(t *testing.T) {
|
||||
c, info := newTaskSubmitContext(t, "alias-model", `{"alias-model":"declared-model"}`)
|
||||
pinMappingOrderPlugin(t, c, mappingOrderRewritePlugin)
|
||||
info.OriginModelName = "alias-model"
|
||||
|
||||
_, taskErr := RelayTaskSubmit(c, info)
|
||||
require.NotNil(t, taskErr)
|
||||
assert.Equal(t, "model_price_error", taskErr.Code)
|
||||
assert.Equal(t, "rewritten", info.UpstreamModelName, "late mapping would overwrite rewriteModel with the chain tail")
|
||||
assert.Equal(t, "alias-model", info.OriginModelName)
|
||||
}
|
||||
|
||||
func TestRelayTaskSubmitEmptyOriginKeepsLateMapping(t *testing.T) {
|
||||
plugin, err := pluginruntime.NewRegistry().Register(mappingOrderSubmitPlugin, pluginruntime.Options{})
|
||||
require.NoError(t, err)
|
||||
synthesized := service.CoverTaskActionToModelName(constant.TaskPlatform(plugin.Meta.Key), "text_to_video")
|
||||
c, info := newTaskSubmitContext(t, "pre-validate-upstream",
|
||||
`{"pre-validate-upstream":"should-not-apply-early","`+synthesized+`":"legacy-tail"}`)
|
||||
c.Set(pluginruntime.ContextKeyPinnedPlugin, pluginruntime.PinnedPlugin{Plugin: plugin})
|
||||
info.OriginModelName = ""
|
||||
|
||||
_, taskErr := RelayTaskSubmit(c, info)
|
||||
require.NotNil(t, taskErr)
|
||||
assert.Equal(t, "model_price_error", taskErr.Code)
|
||||
assert.Equal(t, synthesized, info.OriginModelName)
|
||||
assert.Equal(t, "legacy-tail", info.UpstreamModelName)
|
||||
assert.True(t, info.IsModelMapped)
|
||||
}
|
||||
|
||||
const billingFallbackPlugin = `
|
||||
export const meta = {apiVersion:1,key:"bill-fallback",name:"Bill Fallback",version:"1.0.0",author:{name:"Test"},models:["declared-model"],fetchMode:"per_task"};
|
||||
export function buildSubmitRequest(ctx) {
|
||||
return {url: ctx.baseUrl+"/submit", method:"POST", body:{upstreamModel: ctx.upstreamModel, model: ctx.model}, action:"text_to_video"};
|
||||
}
|
||||
export function parseSubmitResponse(){return {taskId:"1"};}
|
||||
export function buildQueryRequest(){return {url:"https://provider.example"};}
|
||||
export function parseTaskResult(){return {status:"SUCCESS"};}
|
||||
`
|
||||
|
||||
func saveBillingConfig(t *testing.T) {
|
||||
t.Helper()
|
||||
saved := map[string]string{}
|
||||
require.NoError(t, config.GlobalConfig.SaveToDB(func(key, value string) error {
|
||||
saved[key] = value
|
||||
return nil
|
||||
}))
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, config.GlobalConfig.LoadFromDB(saved))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRelayTaskSubmitAliasBillingIdentityAndExprFallback(t *testing.T) {
|
||||
const mapping = `{"alias-model":"declared-model"}`
|
||||
const aliasExpr = `tier("alias", 2)`
|
||||
const tailExpr = `tier("tail", 3)`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
modes map[string]string
|
||||
exprs map[string]string
|
||||
wantTiered bool
|
||||
wantExpr string
|
||||
}{
|
||||
{
|
||||
name: "alias own tiered wins",
|
||||
modes: map[string]string{"alias-model": "tiered_expr", "declared-model": "tiered_expr"},
|
||||
exprs: map[string]string{"alias-model": aliasExpr, "declared-model": tailExpr},
|
||||
wantTiered: true,
|
||||
wantExpr: aliasExpr,
|
||||
},
|
||||
{
|
||||
name: "fallback uses tail expr",
|
||||
modes: map[string]string{"declared-model": "tiered_expr"},
|
||||
exprs: map[string]string{"declared-model": tailExpr},
|
||||
wantTiered: true,
|
||||
wantExpr: tailExpr,
|
||||
},
|
||||
{
|
||||
name: "neither tiered uses ordinary pricing",
|
||||
wantTiered: false,
|
||||
},
|
||||
}
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
saveBillingConfig(t)
|
||||
if len(testCase.modes) > 0 {
|
||||
modeJSON, marshalErr := common.Marshal(testCase.modes)
|
||||
require.NoError(t, marshalErr)
|
||||
exprJSON, marshalErr := common.Marshal(testCase.exprs)
|
||||
require.NoError(t, marshalErr)
|
||||
require.NoError(t, config.GlobalConfig.LoadFromDB(map[string]string{
|
||||
"billing_setting.billing_mode": string(modeJSON),
|
||||
"billing_setting.billing_expr": string(exprJSON),
|
||||
}))
|
||||
if testCase.wantExpr == aliasExpr {
|
||||
require.Equal(t, billing_setting.BillingModeTieredExpr, billing_setting.GetBillingMode("alias-model"))
|
||||
} else {
|
||||
require.Equal(t, billing_setting.BillingModeRatio, billing_setting.GetBillingMode("alias-model"))
|
||||
require.Equal(t, billing_setting.BillingModeTieredExpr, billing_setting.GetBillingMode("declared-model"))
|
||||
}
|
||||
}
|
||||
|
||||
c, info := newTaskSubmitContext(t, "alias-model", mapping)
|
||||
c.Set("group", "default")
|
||||
info.UserGroup = "default"
|
||||
info.UsingGroup = "default"
|
||||
pinMappingOrderPlugin(t, c, billingFallbackPlugin)
|
||||
info.OriginModelName = "alias-model"
|
||||
|
||||
_, taskErr := RelayTaskSubmit(c, info)
|
||||
require.NotNil(t, taskErr)
|
||||
assert.Equal(t, "alias-model", info.OriginModelName)
|
||||
assert.Equal(t, "declared-model", info.UpstreamModelName)
|
||||
assert.True(t, info.IsModelMapped)
|
||||
|
||||
task := model.InitTask(constant.TaskPlatform("bill-fallback"), info)
|
||||
assert.Equal(t, "alias-model", task.Properties.OriginModelName)
|
||||
assert.Equal(t, "declared-model", task.Properties.UpstreamModelName)
|
||||
|
||||
if testCase.wantTiered {
|
||||
require.NotNil(t, info.TieredBillingSnapshot)
|
||||
assert.Equal(t, "alias-model", info.TieredBillingSnapshot.ModelName)
|
||||
assert.Equal(t, testCase.wantExpr, info.TieredBillingSnapshot.ExprString)
|
||||
assert.Equal(t, billingexpr.ExprHashString(testCase.wantExpr), info.TieredBillingSnapshot.ExprHash)
|
||||
assert.NotEqual(t, "model_price_error", taskErr.Code)
|
||||
} else {
|
||||
assert.Nil(t, info.TieredBillingSnapshot)
|
||||
assert.Equal(t, "model_price_error", taskErr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user