mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-07 18:18:00 +00:00
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.
213 lines
5.6 KiB
Go
213 lines
5.6 KiB
Go
package model
|
|
|
|
import (
|
|
"fmt"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/pkg/jsplugin"
|
|
)
|
|
|
|
// TaskAliasTarget is one mapping-derived alias after cross-channel aggregation.
|
|
// Declared is empty when the same plugin resolves the alias to more than one
|
|
// declared tail (display-only; pin is disabled).
|
|
type TaskAliasTarget struct {
|
|
Alias string
|
|
Declared string
|
|
PluginKey string
|
|
}
|
|
|
|
type taskAliasView struct {
|
|
generation uint64
|
|
expiresAt time.Time
|
|
byFold map[string]TaskAliasTarget
|
|
}
|
|
|
|
const taskAliasViewTTL = 60 * time.Second
|
|
|
|
var (
|
|
taskAliasViewPtr atomic.Pointer[taskAliasView]
|
|
taskAliasRebuildMu sync.Mutex
|
|
)
|
|
|
|
// ResolveTaskModelAlias returns the mapping-derived alias target for name
|
|
// (exact or ASCII-folded). g is the caller's routing generation: a Number
|
|
// mismatch or TTL expiry rebuilds the view against that generation.
|
|
func ResolveTaskModelAlias(g *jsplugin.RoutingGeneration, name string) (TaskAliasTarget, bool) {
|
|
if g == nil || name == "" {
|
|
return TaskAliasTarget{}, false
|
|
}
|
|
view := loadFreshTaskAliasView(g)
|
|
if view == nil {
|
|
return TaskAliasTarget{}, false
|
|
}
|
|
target, ok := view.byFold[jsplugin.ASCIIFold(name)]
|
|
return target, ok
|
|
}
|
|
|
|
func loadFreshTaskAliasView(g *jsplugin.RoutingGeneration) *taskAliasView {
|
|
view := taskAliasViewPtr.Load()
|
|
if taskAliasViewFresh(view, g.Number) {
|
|
return view
|
|
}
|
|
taskAliasRebuildMu.Lock()
|
|
defer taskAliasRebuildMu.Unlock()
|
|
view = taskAliasViewPtr.Load()
|
|
if taskAliasViewFresh(view, g.Number) {
|
|
return view
|
|
}
|
|
rebuilt := buildTaskAliasView(g)
|
|
taskAliasViewPtr.Store(rebuilt)
|
|
return rebuilt
|
|
}
|
|
|
|
func taskAliasViewFresh(view *taskAliasView, generation uint64) bool {
|
|
return view != nil && view.generation == generation && time.Now().Before(view.expiresAt)
|
|
}
|
|
|
|
func rebuildTaskAliasView() {
|
|
taskAliasRebuildMu.Lock()
|
|
defer taskAliasRebuildMu.Unlock()
|
|
taskAliasViewPtr.Store(buildTaskAliasView(jsplugin.DefaultRegistry.Generation()))
|
|
}
|
|
|
|
type taskAliasDraft struct {
|
|
spellings []string
|
|
byPlugin map[string]map[string]struct{}
|
|
}
|
|
|
|
func buildTaskAliasView(generation *jsplugin.RoutingGeneration) *taskAliasView {
|
|
genNum := uint64(0)
|
|
if generation != nil {
|
|
genNum = generation.Number
|
|
}
|
|
view := &taskAliasView{
|
|
generation: genNum,
|
|
expiresAt: time.Now().Add(taskAliasViewTTL),
|
|
byFold: make(map[string]TaskAliasTarget),
|
|
}
|
|
if DB == nil {
|
|
return view
|
|
}
|
|
|
|
var channels []Channel
|
|
err := DB.Select("id", "type", "models", "model_mapping").
|
|
Where("status = ?", common.ChannelStatusEnabled).
|
|
Find(&channels).Error
|
|
if err != nil {
|
|
common.SysError(fmt.Sprintf("rebuild task alias view: %s", err.Error()))
|
|
return view
|
|
}
|
|
|
|
drafts := make(map[string]*taskAliasDraft)
|
|
for i := range channels {
|
|
channel := &channels[i]
|
|
mappingJSON := channel.GetModelMapping()
|
|
if mappingJSON == "" || mappingJSON == "{}" {
|
|
continue
|
|
}
|
|
modelMap := make(map[string]string)
|
|
if err := common.UnmarshalJsonStr(mappingJSON, &modelMap); err != nil {
|
|
common.SysError(fmt.Sprintf("task alias view: channel %d model_mapping: %s", channel.Id, err.Error()))
|
|
continue
|
|
}
|
|
inModels := make(map[string]struct{})
|
|
for _, modelName := range channel.GetModels() {
|
|
inModels[modelName] = struct{}{}
|
|
}
|
|
for alias, mapped := range modelMap {
|
|
if mapped == "" {
|
|
continue
|
|
}
|
|
if _, exposed := inModels[alias]; !exposed {
|
|
continue
|
|
}
|
|
if _, declared := generation.CanonicalModel(alias); declared {
|
|
continue
|
|
}
|
|
tail, cyclic := followChannelModelMapping(modelMap, alias)
|
|
if cyclic {
|
|
common.SysError(fmt.Sprintf("task alias mapping cycle dropped: channel=%d key=%q", channel.Id, alias))
|
|
continue
|
|
}
|
|
declared, ok := generation.CanonicalModel(tail)
|
|
if !ok {
|
|
continue
|
|
}
|
|
plugin, ok := generation.GetByModel(declared)
|
|
if !ok {
|
|
continue
|
|
}
|
|
fold := jsplugin.ASCIIFold(alias)
|
|
draft := drafts[fold]
|
|
if draft == nil {
|
|
draft = &taskAliasDraft{byPlugin: make(map[string]map[string]struct{})}
|
|
drafts[fold] = draft
|
|
}
|
|
draft.spellings = append(draft.spellings, alias)
|
|
declareds := draft.byPlugin[plugin.Meta.Key]
|
|
if declareds == nil {
|
|
declareds = make(map[string]struct{})
|
|
draft.byPlugin[plugin.Meta.Key] = declareds
|
|
}
|
|
declareds[declared] = struct{}{}
|
|
}
|
|
}
|
|
|
|
for _, draft := range drafts {
|
|
alias := draft.spellings[0]
|
|
for _, spelling := range draft.spellings[1:] {
|
|
if spelling < alias {
|
|
alias = spelling
|
|
}
|
|
}
|
|
if len(draft.byPlugin) != 1 {
|
|
pluginKeys := make([]string, 0, len(draft.byPlugin))
|
|
for key := range draft.byPlugin {
|
|
pluginKeys = append(pluginKeys, key)
|
|
}
|
|
common.SysLog(fmt.Sprintf("task model alias %q dropped: maps to multiple plugins %v", alias, pluginKeys))
|
|
continue
|
|
}
|
|
var pluginKey, declared string
|
|
for key, declareds := range draft.byPlugin {
|
|
pluginKey = key
|
|
if len(declareds) == 1 {
|
|
for name := range declareds {
|
|
declared = name
|
|
}
|
|
}
|
|
}
|
|
view.byFold[jsplugin.ASCIIFold(alias)] = TaskAliasTarget{
|
|
Alias: alias,
|
|
Declared: declared,
|
|
PluginKey: pluginKey,
|
|
}
|
|
}
|
|
return view
|
|
}
|
|
|
|
// followChannelModelMapping walks one channel's mapping the same way
|
|
// ModelMappedHelper does: visited-set cycle detection, self-map stops at
|
|
// the current hop, a non-self cycle is reported to the caller.
|
|
func followChannelModelMapping(modelMap map[string]string, start string) (string, bool) {
|
|
current := start
|
|
visited := map[string]bool{current: true}
|
|
for {
|
|
mapped, exists := modelMap[current]
|
|
if !exists || mapped == "" {
|
|
return current, false
|
|
}
|
|
if visited[mapped] {
|
|
if mapped == current {
|
|
return current, false
|
|
}
|
|
return "", true
|
|
}
|
|
visited[mapped] = true
|
|
current = mapped
|
|
}
|
|
}
|