Files
new-api-custom/controller/option.go
T

434 lines
14 KiB
Go

package controller
import (
"errors"
"fmt"
"net/http"
"slices"
"sort"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"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" || k == "billing_setting.billing_mode" || k == "billing_setting.billing_expr" {
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,
})
if slices.Contains(completionRatioMetaOptionKeys, k) {
optionValues[k] = value
}
}
common.OptionMapRWMutex.Unlock()
// Display the same effective expressions used by pricing and settlement,
// including built-in defaults absent from persisted administrator options.
for key, values := range map[string]map[string]string{
"billing_setting.billing_mode": billing_setting.GetBillingModeCopy(),
"billing_setting.billing_expr": billing_setting.GetBillingExprCopy(),
} {
encoded, err := common.Marshal(values)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
return
}
options = append(options, &model.Option{Key: key, Value: string(encoded)})
}
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 UpdatePasskeyDomains(c *gin.Context) {
var request struct {
RPID *string `json:"rp_id"`
LegacyRPIDs *string `json:"legacy_rp_ids"`
Origins *string `json:"origins"`
Preview bool `json:"preview"`
RemovalConfirmation string `json:"removal_confirmation"`
}
if err := common.DecodeJson(c.Request.Body, &request); err != nil || request.RPID == nil || request.LegacyRPIDs == nil || request.Origins == nil {
common.ApiErrorT(c, "Invalid parameters")
return
}
change, err := model.UpdatePasskeyDomainOptions(map[string]string{
"passkey.rp_id": *request.RPID, "passkey.legacy_rp_ids": *request.LegacyRPIDs, "passkey.origins": *request.Origins,
}, request.Preview, request.RemovalConfirmation)
if err != nil {
writePasskeyDomainSettingsError(c, err)
if !request.Preview {
recordPasskeyDomainAudit(c, change, request.RemovalConfirmation != "", err)
}
return
}
if !request.Preview {
recordPasskeyDomainAudit(c, change, request.RemovalConfirmation != "", nil)
}
common.ApiSuccess(c, change)
}
func writePasskeyDomainSettingsError(c *gin.Context, err error) {
var removal *model.PasskeyDomainRemovalError
if errors.As(err, &removal) {
common.ApiErrorStatus(c, http.StatusConflict, common.NewMessage("Review the affected Passkeys and confirm the domain removal. If the settings or impact have changed, confirmation is required again."), gin.H{
"code": "PASSKEY_RP_ID_REMOVAL_CONFIRMATION_REQUIRED",
"data": removal.Change,
})
return
}
if errors.Is(err, system_setting.ErrPasskeyRPIDInvalid) {
writeSecurityOperationError(c, err)
return
}
common.ApiError(c, err)
}
func UpdateOption(c *gin.Context) {
var option OptionUpdateRequest
err := common.DecodeJson(c.Request.Body, &option)
if err != nil {
common.ApiErrorStatus(c, http.StatusBadRequest, common.NewMessage("Invalid parameters"))
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.ApiErrorT(c, "Payment, redemption, subscription, and invitation reward features are disabled. The administrator must confirm compliance terms before enabling them.")
return
}
default:
if isPaymentComplianceOptionKey(option.Key) {
common.ApiErrorT(c, "Compliance confirmation fields cannot be changed through the general settings API")
return
}
}
if option.Key == "TaskPublicAddress" && option.Value.(string) != "" {
if err := service.ValidateTaskArtifactBaseURL(option.Value.(string)); err != nil {
common.ApiError(c, err)
return
}
}
switch option.Key {
case "GitHubOAuthEnabled":
if option.Value == "true" && common.GitHubClientId == "" {
common.ApiErrorT(c, "Cannot enable {{provider}}. Enter the {{provider}} Client ID and Client Secret first.", map[string]any{"provider": "GitHub OAuth"})
return
}
case "discord.enabled":
if option.Value == "true" && system_setting.GetDiscordSettings().ClientId == "" {
common.ApiErrorT(c, "Cannot enable {{provider}}. Enter the {{provider}} Client ID and Client Secret first.", map[string]any{"provider": "Discord OAuth"})
return
}
case "oidc.enabled":
if option.Value == "true" && system_setting.GetOIDCSettings().ClientId == "" {
common.ApiErrorT(c, "Cannot enable {{provider}}. Enter the {{provider}} Client ID and Client Secret first.", map[string]any{"provider": "OIDC"})
return
}
case "LinuxDOOAuthEnabled":
if option.Value == "true" && common.LinuxDOClientId == "" {
common.ApiErrorT(c, "Cannot enable {{provider}}. Enter the {{provider}} Client ID and Client Secret first.", map[string]any{"provider": "LinuxDO OAuth"})
return
}
case "EmailDomainRestrictionEnabled":
if option.Value == "true" && len(common.EmailDomainWhitelist) == 0 {
common.ApiErrorT(c, "Cannot enable the email domain restriction. Enter the allowed email domains first.")
return
}
case "WeChatAuthEnabled":
if option.Value == "true" && common.WeChatServerAddress == "" {
common.ApiErrorT(c, "Cannot enable WeChat login. Enter the WeChat login settings first.")
return
}
case "TurnstileCheckEnabled":
if option.Value == "true" && common.TurnstileSiteKey == "" {
common.ApiErrorT(c, "Cannot enable Turnstile verification. Enter the Turnstile settings first.")
return
}
case "TelegramOAuthEnabled":
if option.Value == "true" && !system_setting.GetTelegramSettings().IsConfigured() {
c.JSON(http.StatusOK, gin.H{
"success": false,
"code": "TELEGRAM_OAUTH_NOT_CONFIGURED",
"message": "Telegram OAuth is not configured or enabled. Please contact your administrator.",
})
return
}
case "theme.frontend":
if option.Value != "default" {
common.ApiErrorT(c, "The Classic frontend has been removed. The theme can only be set to default")
return
}
case "GroupRatio":
err = ratio_setting.CheckGroupRatio(option.Value.(string))
if err != nil {
common.ApiError(c, err)
return
}
case "gemini.safety_settings":
err = model_setting.ValidateGeminiSafetySettings(option.Value.(string))
if err != nil {
common.ApiError(c, err)
return
}
case "claude.default_max_tokens":
err = model_setting.ValidateClaudeDefaultMaxTokens(option.Value.(string))
if err != nil {
common.ApiError(c, err)
return
}
case operation_setting.ToolPriceOptionKey:
err = operation_setting.ValidateToolPricesJSON(option.Value.(string))
if err != nil {
common.ApiError(c, err)
return
}
case "ImageRatio":
err = ratio_setting.UpdateImageRatioByJSONString(option.Value.(string))
if err != nil {
common.ApiErrorT(c, "Failed to set the image ratio: {{error}}", map[string]any{"error": err.Error()})
return
}
case "AudioRatio":
err = ratio_setting.UpdateAudioRatioByJSONString(option.Value.(string))
if err != nil {
common.ApiErrorT(c, "Failed to set the audio ratio: {{error}}", map[string]any{"error": err.Error()})
return
}
case "AudioCompletionRatio":
err = ratio_setting.UpdateAudioCompletionRatioByJSONString(option.Value.(string))
if err != nil {
common.ApiErrorT(c, "Failed to set the audio completion ratio: {{error}}", map[string]any{"error": err.Error()})
return
}
case "CreateCacheRatio":
err = ratio_setting.UpdateCreateCacheRatioByJSONString(option.Value.(string))
if err != nil {
common.ApiErrorT(c, "Failed to set the cache creation ratio: {{error}}", map[string]any{"error": err.Error()})
return
}
case "ModelRequestRateLimitGroup":
err = setting.CheckModelRequestRateLimitGroup(option.Value.(string))
if err != nil {
common.ApiError(c, err)
return
}
case "AutomaticDisableStatusCodes":
_, err = operation_setting.ParseHTTPStatusCodeRanges(option.Value.(string))
if err != nil {
common.ApiError(c, err)
return
}
case "AutomaticRetryStatusCodes":
_, err = operation_setting.ParseHTTPStatusCodeRanges(option.Value.(string))
if err != nil {
common.ApiError(c, err)
return
}
case "billing_setting.billing_expr":
expressions := make(map[string]string)
if err = common.UnmarshalJsonStr(option.Value.(string), &expressions); err != nil {
common.ApiErrorT(c, "Billing expressions must be a JSON object mapping models to expressions: {{error}}", map[string]any{"error": err.Error()})
return
}
models := make([]string, 0, len(expressions))
for modelName := range expressions {
models = append(models, modelName)
}
sort.Strings(models)
storedVariants := billing_setting.GetPluginBillingExprCopy()
for _, modelName := range models {
variants := make(map[string]any)
for key, expression := range storedVariants {
if plugin, name, ok := billing_setting.SplitPluginBillingExprKey(key); ok && name == modelName {
variants[plugin] = expression
}
}
err = model.ValidateModelPricing(modelName, model.PricingValues{
"billing_setting.billing_expr": expressions[modelName],
billing_setting.PluginBillingExprOption: variants,
})
if err != nil {
common.ApiErrorT(c, "Invalid billing expression for model {{model}}: {{error}}", map[string]any{"model": modelName, "error": err.Error()})
return
}
}
case billing_setting.PluginBillingExprOption:
var expressions map[string]string
if err = common.UnmarshalJsonStr(option.Value.(string), &expressions); err != nil || expressions == nil {
common.ApiErrorMsg(c, "plugin billing expressions must be a JSON object")
return
}
for key, expression := range expressions {
plugin, name, valid := billing_setting.SplitPluginBillingExprKey(key)
if !valid {
common.ApiErrorMsg(c, "invalid plugin billing expression key: "+key)
return
}
if err = model.ValidateModelPricing(name, model.PricingValues{
billing_setting.PluginBillingExprOption: map[string]any{plugin: expression},
}); err != nil {
common.ApiError(c, err)
return
}
}
case "console_setting.api_info":
err = console_setting.ValidateConsoleSettings(option.Value.(string), "ApiInfo")
if err != nil {
common.ApiError(c, err)
return
}
case "console_setting.announcements":
err = console_setting.ValidateConsoleSettings(option.Value.(string), "Announcements")
if err != nil {
common.ApiError(c, err)
return
}
case "console_setting.faq":
err = console_setting.ValidateConsoleSettings(option.Value.(string), "FAQ")
if err != nil {
common.ApiError(c, err)
return
}
case "console_setting.uptime_kuma_groups":
err = console_setting.ValidateConsoleSettings(option.Value.(string), "UptimeKumaGroups")
if err != nil {
common.ApiError(c, err)
return
}
}
if model.IsPasskeyDomainOption(option.Key) {
change, updateErr := model.UpdatePasskeyDomainOptions(map[string]string{option.Key: option.Value.(string)}, false, "")
if updateErr != nil {
writePasskeyDomainSettingsError(c, updateErr)
recordPasskeyDomainAudit(c, change, false, updateErr)
return
}
recordPasskeyDomainAudit(c, change, false, nil)
common.ApiSuccess(c, change)
return
}
err = model.UpdateOption(option.Key, option.Value.(string))
if err != nil {
if errors.Is(err, system_setting.ErrPasskeyRPIDInvalid) {
writeSecurityOperationError(c, err)
} else {
common.ApiError(c, err)
}
return
}
// 出于安全考虑只记录被修改的配置项名称,不记录配置值(可能含密钥等敏感信息)。
recordManageAudit(c, "option.update", map[string]any{
"key": option.Key,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
}