mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-01 19:41:57 +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.
420 lines
12 KiB
Go
420 lines
12 KiB
Go
package controller
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/QuantumNous/new-api/common"
|
||
"github.com/QuantumNous/new-api/i18n"
|
||
"github.com/QuantumNous/new-api/model"
|
||
"github.com/QuantumNous/new-api/pkg/jsplugin"
|
||
"github.com/QuantumNous/new-api/service"
|
||
"github.com/QuantumNous/new-api/setting"
|
||
"github.com/QuantumNous/new-api/setting/billing_setting"
|
||
"github.com/QuantumNous/new-api/setting/console_setting"
|
||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
var completionRatioMetaOptionKeys = []string{
|
||
"ModelPrice",
|
||
"ModelRatio",
|
||
"CompletionRatio",
|
||
"CacheRatio",
|
||
"CreateCacheRatio",
|
||
"ImageRatio",
|
||
"AudioRatio",
|
||
"AudioCompletionRatio",
|
||
}
|
||
|
||
func isPaymentComplianceOptionKey(key string) bool {
|
||
return strings.HasPrefix(key, "payment_setting.compliance_")
|
||
}
|
||
|
||
func isPositiveOptionValue(value string) bool {
|
||
intValue, err := strconv.Atoi(strings.TrimSpace(value))
|
||
if err == nil {
|
||
return intValue > 0
|
||
}
|
||
floatValue, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||
return err == nil && floatValue > 0
|
||
}
|
||
|
||
func collectModelNamesFromOptionValue(raw string, modelNames map[string]struct{}) {
|
||
if strings.TrimSpace(raw) == "" {
|
||
return
|
||
}
|
||
|
||
var parsed map[string]any
|
||
if err := common.UnmarshalJsonStr(raw, &parsed); err != nil {
|
||
return
|
||
}
|
||
|
||
for modelName := range parsed {
|
||
modelNames[modelName] = struct{}{}
|
||
}
|
||
}
|
||
|
||
func buildCompletionRatioMetaValue(optionValues map[string]string) string {
|
||
modelNames := make(map[string]struct{})
|
||
for _, key := range completionRatioMetaOptionKeys {
|
||
collectModelNamesFromOptionValue(optionValues[key], modelNames)
|
||
}
|
||
|
||
meta := make(map[string]ratio_setting.CompletionRatioInfo, len(modelNames))
|
||
for modelName := range modelNames {
|
||
meta[modelName] = ratio_setting.GetCompletionRatioInfo(modelName)
|
||
}
|
||
|
||
jsonBytes, err := common.Marshal(meta)
|
||
if err != nil {
|
||
return "{}"
|
||
}
|
||
return string(jsonBytes)
|
||
}
|
||
|
||
func GetOptions(c *gin.Context) {
|
||
var options []*model.Option
|
||
optionValues := make(map[string]string)
|
||
common.OptionMapRWMutex.Lock()
|
||
for k, v := range common.OptionMap {
|
||
if k == "theme.frontend" {
|
||
continue
|
||
}
|
||
value := common.Interface2String(v)
|
||
isSensitiveKey := strings.HasSuffix(k, "Token") ||
|
||
strings.HasSuffix(k, "Secret") ||
|
||
strings.HasSuffix(k, "Key") ||
|
||
strings.HasSuffix(k, "secret") ||
|
||
strings.HasSuffix(k, "api_key")
|
||
if isSensitiveKey {
|
||
continue
|
||
}
|
||
options = append(options, &model.Option{
|
||
Key: k,
|
||
Value: value,
|
||
})
|
||
for _, optionKey := range completionRatioMetaOptionKeys {
|
||
if optionKey == k {
|
||
optionValues[k] = value
|
||
break
|
||
}
|
||
}
|
||
}
|
||
common.OptionMapRWMutex.Unlock()
|
||
options = append(options, &model.Option{
|
||
Key: "CompletionRatioMeta",
|
||
Value: buildCompletionRatioMetaValue(optionValues),
|
||
})
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"message": "",
|
||
"data": options,
|
||
})
|
||
}
|
||
|
||
type OptionUpdateRequest struct {
|
||
Key string `json:"key"`
|
||
Value any `json:"value"`
|
||
}
|
||
|
||
func UpdateOption(c *gin.Context) {
|
||
var option OptionUpdateRequest
|
||
err := common.DecodeJson(c.Request.Body, &option)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{
|
||
"success": false,
|
||
"message": "无效的参数",
|
||
})
|
||
return
|
||
}
|
||
switch option.Value.(type) {
|
||
case bool:
|
||
option.Value = common.Interface2String(option.Value.(bool))
|
||
case float64:
|
||
option.Value = common.Interface2String(option.Value.(float64))
|
||
case int:
|
||
option.Value = common.Interface2String(option.Value.(int))
|
||
default:
|
||
option.Value = fmt.Sprintf("%v", option.Value)
|
||
}
|
||
switch option.Key {
|
||
case "QuotaForInviter", "QuotaForInvitee":
|
||
if isPositiveOptionValue(option.Value.(string)) && !operation_setting.IsPaymentComplianceConfirmed() {
|
||
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
|
||
return
|
||
}
|
||
default:
|
||
if isPaymentComplianceOptionKey(option.Key) {
|
||
common.ApiErrorMsg(c, "合规确认字段不允许通过通用设置接口修改")
|
||
return
|
||
}
|
||
}
|
||
if option.Key == "TaskPublicAddress" && option.Value.(string) != "" {
|
||
if err := service.ValidateTaskArtifactBaseURL(option.Value.(string)); err != nil {
|
||
common.ApiErrorMsg(c, err.Error())
|
||
return
|
||
}
|
||
}
|
||
switch option.Key {
|
||
case "GitHubOAuthEnabled":
|
||
if option.Value == "true" && common.GitHubClientId == "" {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "无法启用 GitHub OAuth,请先填入 GitHub Client Id 以及 GitHub Client Secret!",
|
||
})
|
||
return
|
||
}
|
||
case "discord.enabled":
|
||
if option.Value == "true" && system_setting.GetDiscordSettings().ClientId == "" {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "无法启用 Discord OAuth,请先填入 Discord Client Id 以及 Discord Client Secret!",
|
||
})
|
||
return
|
||
}
|
||
case "oidc.enabled":
|
||
if option.Value == "true" && system_setting.GetOIDCSettings().ClientId == "" {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "无法启用 OIDC 登录,请先填入 OIDC Client Id 以及 OIDC Client Secret!",
|
||
})
|
||
return
|
||
}
|
||
case "LinuxDOOAuthEnabled":
|
||
if option.Value == "true" && common.LinuxDOClientId == "" {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "无法启用 LinuxDO OAuth,请先填入 LinuxDO Client Id 以及 LinuxDO Client Secret!",
|
||
})
|
||
return
|
||
}
|
||
case "EmailDomainRestrictionEnabled":
|
||
if option.Value == "true" && len(common.EmailDomainWhitelist) == 0 {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "无法启用邮箱域名限制,请先填入限制的邮箱域名!",
|
||
})
|
||
return
|
||
}
|
||
case "WeChatAuthEnabled":
|
||
if option.Value == "true" && common.WeChatServerAddress == "" {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "无法启用微信登录,请先填入微信登录相关配置信息!",
|
||
})
|
||
return
|
||
}
|
||
case "TurnstileCheckEnabled":
|
||
if option.Value == "true" && common.TurnstileSiteKey == "" {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "无法启用 Turnstile 校验,请先填入 Turnstile 校验相关配置信息!",
|
||
})
|
||
|
||
return
|
||
}
|
||
case "TelegramOAuthEnabled":
|
||
if option.Value == "true" && common.TelegramBotToken == "" {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "无法启用 Telegram OAuth,请先填入 Telegram Bot Token!",
|
||
})
|
||
return
|
||
}
|
||
case "theme.frontend":
|
||
if option.Value != "default" {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "Classic 前端已移除,主题只能设置为 default",
|
||
})
|
||
return
|
||
}
|
||
case "GroupRatio":
|
||
err = ratio_setting.CheckGroupRatio(option.Value.(string))
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "gemini.safety_settings":
|
||
err = model_setting.ValidateGeminiSafetySettings(option.Value.(string))
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "claude.default_max_tokens":
|
||
err = model_setting.ValidateClaudeDefaultMaxTokens(option.Value.(string))
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case operation_setting.ToolPriceOptionKey:
|
||
err = operation_setting.ValidateToolPricesJSON(option.Value.(string))
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "ImageRatio":
|
||
err = ratio_setting.UpdateImageRatioByJSONString(option.Value.(string))
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "图片倍率设置失败: " + err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "AudioRatio":
|
||
err = ratio_setting.UpdateAudioRatioByJSONString(option.Value.(string))
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "音频倍率设置失败: " + err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "AudioCompletionRatio":
|
||
err = ratio_setting.UpdateAudioCompletionRatioByJSONString(option.Value.(string))
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "音频补全倍率设置失败: " + err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "CreateCacheRatio":
|
||
err = ratio_setting.UpdateCreateCacheRatioByJSONString(option.Value.(string))
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": "缓存创建倍率设置失败: " + err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "ModelRequestRateLimitGroup":
|
||
err = setting.CheckModelRequestRateLimitGroup(option.Value.(string))
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "AutomaticDisableStatusCodes":
|
||
_, err = operation_setting.ParseHTTPStatusCodeRanges(option.Value.(string))
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "AutomaticRetryStatusCodes":
|
||
_, err = operation_setting.ParseHTTPStatusCodeRanges(option.Value.(string))
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "billing_setting.billing_expr":
|
||
expressions := make(map[string]string)
|
||
if err = common.UnmarshalJsonStr(option.Value.(string), &expressions); err != nil {
|
||
common.ApiErrorMsg(c, "计费表达式配置必须是模型到表达式的 JSON 对象: "+err.Error())
|
||
return
|
||
}
|
||
models := make([]string, 0, len(expressions))
|
||
for modelName := range expressions {
|
||
models = append(models, modelName)
|
||
}
|
||
sort.Strings(models)
|
||
generation := jsplugin.DefaultRegistry.Generation()
|
||
for _, modelName := range models {
|
||
expression := expressions[modelName]
|
||
if plugin, ok := generation.GetByModel(modelName); ok {
|
||
err = billing_setting.SmokeTestTaskExpr(expression, plugin.Meta.UsageSchema)
|
||
} else if target, resolved := model.ResolveTaskModelAlias(generation, modelName); resolved {
|
||
if plugin, ok := generation.Get(target.PluginKey); ok {
|
||
err = billing_setting.SmokeTestTaskExpr(expression, plugin.Meta.UsageSchema)
|
||
} else {
|
||
err = billing_setting.SmokeTestExpr(expression)
|
||
}
|
||
} else {
|
||
err = billing_setting.SmokeTestExpr(expression)
|
||
}
|
||
if err != nil {
|
||
common.ApiErrorMsg(c, fmt.Sprintf("模型 %s 的计费表达式无效: %v", modelName, err))
|
||
return
|
||
}
|
||
}
|
||
case "console_setting.api_info":
|
||
err = console_setting.ValidateConsoleSettings(option.Value.(string), "ApiInfo")
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "console_setting.announcements":
|
||
err = console_setting.ValidateConsoleSettings(option.Value.(string), "Announcements")
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "console_setting.faq":
|
||
err = console_setting.ValidateConsoleSettings(option.Value.(string), "FAQ")
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
case "console_setting.uptime_kuma_groups":
|
||
err = console_setting.ValidateConsoleSettings(option.Value.(string), "UptimeKumaGroups")
|
||
if err != nil {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": false,
|
||
"message": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
}
|
||
err = model.UpdateOption(option.Key, option.Value.(string))
|
||
if err != nil {
|
||
common.ApiError(c, err)
|
||
return
|
||
}
|
||
// 出于安全考虑只记录被修改的配置项名称,不记录配置值(可能含密钥等敏感信息)。
|
||
recordManageAudit(c, "option.update", map[string]interface{}{
|
||
"key": option.Key,
|
||
})
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"success": true,
|
||
"message": "",
|
||
})
|
||
}
|