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:
CaIon
2026-08-30 19:13:51 +08:00
parent 918427d8ab
commit 6c22550ea3
23 changed files with 968 additions and 59 deletions
@@ -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
View File
@@ -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)
+214
View File
@@ -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)
}
})
}
}