feat: publish sanitized custom.97 source
This commit is contained in:
1022 files changed
+69903
-19207
No files matched your search
@@ -1,5 +1,32 @@
|
||||
package billing_setting
|
||||
|
||||
import "github.com/QuantumNous/new-api/constant"
|
||||
|
||||
// deepSeekPeak is the legacy built-in schedule used by the original DeepSeek
|
||||
// model expressions. Provider-specific pricing below uses deepSeekPeakAllDays.
|
||||
const deepSeekPeak = `weekday("Asia/Shanghai") >= 1 && weekday("Asia/Shanghai") <= 5 && ((hour("Asia/Shanghai") >= 9 && hour("Asia/Shanghai") < 12) || (hour("Asia/Shanghai") >= 14 && hour("Asia/Shanghai") < 18))`
|
||||
|
||||
// deepSeekPeakAllDays follows the actual upstream schedule configured for the
|
||||
// official token group: the two peak windows apply every day of the week.
|
||||
const deepSeekPeakAllDays = `((hour("Asia/Shanghai") >= 9 && hour("Asia/Shanghai") < 12) || (hour("Asia/Shanghai") >= 14 && hour("Asia/Shanghai") < 18))`
|
||||
|
||||
const deepSeekFlashExpr = deepSeekPeak + ` ? tier("peak", p * 0.3 + cr * 0.006 + c * 1.2) : tier("off_peak", p * 0.15 + cr * 0.003 + c * 0.6)`
|
||||
|
||||
const (
|
||||
// DeepSeekOfficialGroup is the user-token group whose official pricing is
|
||||
// selected for compatible/custom channels as well as native DeepSeek ones.
|
||||
DeepSeekOfficialGroup = "Deepseek官Key"
|
||||
DeepSeekOfficialGroupRatio = 0.45
|
||||
)
|
||||
|
||||
// deepSeekFlashOfficialExpr uses the official Flash base prices. The cache
|
||||
// prices are raised slightly so that the shared 0.45 group ratio still covers
|
||||
// the upstream cache-read prices shown for the official key.
|
||||
const deepSeekFlashOfficialExpr = deepSeekPeakAllDays + ` ? tier("peak", p * 2 + cr * 0.066667 + c * 8) : tier("off_peak", p * 1 + cr * 0.033334 + c * 4)`
|
||||
|
||||
// deepSeekV4ProOfficialExpr follows the official DeepSeek V4 Pro base prices.
|
||||
const deepSeekV4ProOfficialExpr = deepSeekPeakAllDays + ` ? tier("peak", p * 9 + cr * 0.30 + c * 27) : tier("off_peak", p * 4.5 + cr * 0.15 + c * 13.5)`
|
||||
|
||||
// Built-in token prices use actual USD per million tokens. Keep new model
|
||||
// defaults here instead of splitting them across the legacy ratio tables.
|
||||
var builtinBillingExpr = map[string]string{
|
||||
@@ -13,4 +40,33 @@ var builtinBillingExpr = map[string]string{
|
||||
// Do not infer service-tier discounts from incoming request parameters:
|
||||
// channels filter service_tier by default, so it may not reach the upstream.
|
||||
"gpt-6-astra": `len <= 272000 ? tier("standard", p * 10 + c * 50 + cr * 1 + cc * 12.5) : tier("long_context", p * 20 + c * 75 + cr * 2 + cc * 25)`,
|
||||
// https://api-docs.deepseek.com/quick_start/pricing (2026-10-17). Off-peak
|
||||
// is half the peak price. deepseek-v4-flash and deepseek-v4-flash-vision-exp
|
||||
// run on V4.1 Flash and bill at the Flash price. Images bill as input
|
||||
// tokens, and there is no cache-write price.
|
||||
"deepseek-flash": deepSeekFlashExpr,
|
||||
"deepseek-v4-flash": deepSeekFlashExpr,
|
||||
"deepseek-v4-flash-vision-exp": deepSeekFlashExpr,
|
||||
"deepseek-v4-pro": deepSeekPeak + ` ? tier("peak", p * 1.32 + cr * 0.044 + c * 3.96) : tier("off_peak", p * 0.66 + cr * 0.022 + c * 1.98)`,
|
||||
// The Search and Fetch API routing names: $0.75 per 1,000 successful
|
||||
// requests each, the same pricing page. The calls carry no tokens.
|
||||
constant.DeepSeekSearchModel: `tier("request", fixed(0.00075))`,
|
||||
constant.DeepSeekFetchModel: `tier("request", fixed(0.00075))`,
|
||||
}
|
||||
|
||||
// GetChannelBillingExpr returns provider-specific built-in pricing that must
|
||||
// not replace the model's regular or group pricing for other channels.
|
||||
func GetChannelBillingExpr(channelType int, model string) (string, bool) {
|
||||
if channelType != constant.ChannelTypeDeepSeek {
|
||||
return "", false
|
||||
}
|
||||
|
||||
switch model {
|
||||
case "deepseek-flash", "deepseek-v4.1-flash", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp":
|
||||
return deepSeekFlashOfficialExpr, true
|
||||
case "deepseek-v4-pro":
|
||||
return deepSeekV4ProOfficialExpr, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,10 @@ package billing_setting_test
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/controller"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||||
@@ -18,6 +20,71 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDeepSeekOfficialChannelBillingExprIsChannelScoped(t *testing.T) {
|
||||
for _, model := range []string{"deepseek-flash", "deepseek-v4.1-flash", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp"} {
|
||||
expression, ok := billing_setting.GetChannelBillingExpr(constant.ChannelTypeDeepSeek, model)
|
||||
require.True(t, ok, model)
|
||||
_, err := billingexpr.CompileFromCache(expression)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, expression, `p * 2`, model)
|
||||
assert.Contains(t, expression, `p * 1`, model)
|
||||
assert.Contains(t, expression, `cr * 0.066667`, model)
|
||||
assert.Contains(t, expression, `cr * 0.033334`, model)
|
||||
assert.NotContains(t, expression, `weekday("Asia/Shanghai")`, model)
|
||||
}
|
||||
|
||||
proExpression, ok := billing_setting.GetChannelBillingExpr(constant.ChannelTypeDeepSeek, "deepseek-v4-pro")
|
||||
require.True(t, ok)
|
||||
_, err := billingexpr.CompileFromCache(proExpression)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, proExpression, `p * 9`)
|
||||
assert.Contains(t, proExpression, `p * 4.5`)
|
||||
assert.Contains(t, proExpression, `cr * 0.30`)
|
||||
assert.Contains(t, proExpression, `cr * 0.15`)
|
||||
assert.Contains(t, proExpression, `c * 27`)
|
||||
assert.Contains(t, proExpression, `c * 13.5`)
|
||||
assert.NotContains(t, proExpression, `weekday("Asia/Shanghai")`)
|
||||
|
||||
_, ok = billing_setting.GetChannelBillingExpr(constant.ChannelTypeOpenAI, "deepseek-v4.1-flash")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestDeepSeekOfficialChannelPricingUsesAllDayWindows(t *testing.T) {
|
||||
expression, ok := billing_setting.GetChannelBillingExpr(constant.ChannelTypeDeepSeek, "deepseek-v4.1-flash")
|
||||
require.True(t, ok)
|
||||
params := billingexpr.TokenParams{P: 1_000_000, CR: 1_000_000, C: 1_000_000, Len: 1_000_000}
|
||||
snapshot := &billingexpr.BillingSnapshot{ExprString: expression, ExprHash: billingexpr.ExprHashString(expression), GroupRatio: 0.45, QuotaPerUnit: 1_000_000}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
at time.Time
|
||||
tier string
|
||||
want int
|
||||
}{
|
||||
{name: "monday peak", at: time.Date(2026, 10, 26, 2, 0, 0, 0, time.UTC), tier: "peak", want: 4_530_000},
|
||||
{name: "monday off peak", at: time.Date(2026, 10, 26, 5, 0, 0, 0, time.UTC), tier: "off_peak", want: 2_265_000},
|
||||
{name: "tuesday peak", at: time.Date(2026, 10, 27, 2, 0, 0, 0, time.UTC), tier: "peak", want: 4_530_000},
|
||||
{name: "tuesday off peak", at: time.Date(2026, 10, 27, 5, 0, 0, 0, time.UTC), tier: "off_peak", want: 2_265_000},
|
||||
{name: "wednesday peak", at: time.Date(2026, 10, 28, 2, 0, 0, 0, time.UTC), tier: "peak", want: 4_530_000},
|
||||
{name: "wednesday off peak", at: time.Date(2026, 10, 28, 5, 0, 0, 0, time.UTC), tier: "off_peak", want: 2_265_000},
|
||||
{name: "thursday peak", at: time.Date(2026, 10, 29, 2, 0, 0, 0, time.UTC), tier: "peak", want: 4_530_000},
|
||||
{name: "thursday off peak", at: time.Date(2026, 10, 29, 5, 0, 0, 0, time.UTC), tier: "off_peak", want: 2_265_000},
|
||||
{name: "friday peak", at: time.Date(2026, 10, 30, 2, 0, 0, 0, time.UTC), tier: "peak", want: 4_530_000},
|
||||
{name: "friday off peak", at: time.Date(2026, 10, 30, 5, 0, 0, 0, time.UTC), tier: "off_peak", want: 2_265_000},
|
||||
{name: "saturday peak", at: time.Date(2026, 10, 31, 2, 0, 0, 0, time.UTC), tier: "peak", want: 4_530_000},
|
||||
{name: "saturday off peak", at: time.Date(2026, 10, 31, 5, 0, 0, 0, time.UTC), tier: "off_peak", want: 2_265_000},
|
||||
{name: "sunday peak", at: time.Date(2026, 11, 1, 2, 0, 0, 0, time.UTC), tier: "peak", want: 4_530_000},
|
||||
{name: "sunday off peak", at: time.Date(2026, 11, 1, 5, 0, 0, 0, time.UTC), tier: "off_peak", want: 2_265_000},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result, err := billingexpr.ComputeTieredQuotaWithRequest(snapshot, params, billingexpr.RequestInput{At: &tc.at})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.tier, result.MatchedTier)
|
||||
assert.Equal(t, tc.want, result.ActualQuotaAfterGroup)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGPT6AstraBuiltinBilling(t *testing.T) {
|
||||
settings := config.GlobalConfig.Get("billing_setting").(*billing_setting.BillingSetting)
|
||||
saved := *settings
|
||||
@@ -148,3 +215,61 @@ func TestImageModelBuiltinPricesAndOverrides(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// DeepSeek prices are peak/off-peak expressions in USD per 1M tokens; peak is
|
||||
// twice off-peak. The same usage settles to the same quota from a Chat,
|
||||
// Responses or Anthropic response, and the Search/Fetch routing names cost
|
||||
// $0.75 per 1,000 requests with no token price. Which tier runs depends on the clock; both are pinned here.
|
||||
func TestDeepSeekBuiltinBilling(t *testing.T) {
|
||||
settings := config.GlobalConfig.Get("billing_setting").(*billing_setting.BillingSetting)
|
||||
saved := *settings
|
||||
savedRatios, savedPrices := ratio_setting.ModelRatio2JSONString(), ratio_setting.ModelPrice2JSONString()
|
||||
t.Cleanup(func() {
|
||||
*settings = saved
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedRatios))
|
||||
require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(savedPrices))
|
||||
})
|
||||
*settings = billing_setting.BillingSetting{BillingMode: map[string]string{}, BillingExpr: map[string]string{}}
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{}`))
|
||||
require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{}`))
|
||||
|
||||
// 1000 cache-miss + 2000 cache-hit input tokens and 500 output tokens.
|
||||
chat := &dto.Usage{PromptTokens: 3000, CompletionTokens: 500, PromptTokensDetails: dto.InputTokenDetails{CachedTokens: 2000}}
|
||||
responses := &dto.Usage{}
|
||||
service.ApplyResponsesUsage(responses, &dto.Usage{InputTokens: 3000, OutputTokens: 500, InputTokensDetails: &dto.InputTokenDetails{CachedTokens: 2000}})
|
||||
anthropic := &dto.Usage{PromptTokens: 1000, CompletionTokens: 500, PromptTokensDetails: dto.InputTokenDetails{CachedTokens: 2000}, UsageSemantic: "anthropic"}
|
||||
quota := func(expression string, usage *dto.Usage) billingexpr.TieredResult {
|
||||
params := service.BuildTieredTokenParams(usage, usage.UsageSemantic == "anthropic", billingexpr.UsedVars(expression))
|
||||
result, err := billingexpr.ComputeTieredQuota(&billingexpr.BillingSnapshot{
|
||||
ExprString: expression, ExprHash: billingexpr.ExprHashString(expression), GroupRatio: 1, QuotaPerUnit: 500000,
|
||||
}, params)
|
||||
require.NoError(t, err)
|
||||
return result
|
||||
}
|
||||
for model, want := range map[string]map[string]int{
|
||||
// flash: 1000*0.15 + 2000*0.003 + 500*0.6 = $0.000456 off-peak.
|
||||
"deepseek-flash": {"off_peak": 228, "peak": 456},
|
||||
"deepseek-v4-flash": {"off_peak": 228, "peak": 456},
|
||||
"deepseek-v4-flash-vision-exp": {"off_peak": 228, "peak": 456},
|
||||
// pro: 1000*0.66 + 2000*0.022 + 500*1.98 = $0.001694 off-peak.
|
||||
"deepseek-v4-pro": {"off_peak": 847, "peak": 1694},
|
||||
} {
|
||||
t.Run(model, func(t *testing.T) {
|
||||
assert.Equal(t, billing_setting.BillingModeTieredExpr, billing_setting.GetBillingMode(model))
|
||||
expression, ok := billing_setting.GetBillingExpr(model)
|
||||
require.True(t, ok)
|
||||
for name, usage := range map[string]*dto.Usage{"chat": chat, "responses": responses, "anthropic": anthropic} {
|
||||
result := quota(expression, usage)
|
||||
require.Contains(t, want, result.MatchedTier, name)
|
||||
assert.Equal(t, want[result.MatchedTier], result.ActualQuotaAfterGroup, name)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, model := range []string{"deepseek-search", "deepseek-fetch"} {
|
||||
expression, ok := billing_setting.GetBillingExpr(model)
|
||||
require.True(t, ok, model)
|
||||
result := quota(expression, &dto.Usage{})
|
||||
assert.Equal(t, billingexpr.BillingUnitRequest, result.BillingUnit, model)
|
||||
assert.Equal(t, 375, result.ActualQuotaAfterGroup, model)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||||
"github.com/QuantumNous/new-api/pkg/jsplugin"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
@@ -30,14 +31,14 @@ const (
|
||||
// DB keys: billing_setting.billing_mode, billing_setting.billing_expr,
|
||||
// billing_setting.plugin_billing_expr
|
||||
type BillingSetting struct {
|
||||
GroupBillingExpr map[string]map[string]string `json:"group_billing_expr"`
|
||||
BillingMode map[string]string `json:"billing_mode"`
|
||||
BillingExpr map[string]string `json:"billing_expr"`
|
||||
PluginBillingExpr map[string]string `json:"plugin_billing_expr"`
|
||||
GroupBillingExpr map[string]map[string]string `json:"group_billing_expr"`
|
||||
BillingMode map[string]string `json:"billing_mode"`
|
||||
BillingExpr map[string]string `json:"billing_expr"`
|
||||
PluginBillingExpr map[string]string `json:"plugin_billing_expr"`
|
||||
}
|
||||
|
||||
var billingSetting = BillingSetting{
|
||||
GroupBillingExpr: make(map[string]map[string]string),
|
||||
GroupBillingExpr: make(map[string]map[string]string),
|
||||
BillingMode: make(map[string]string),
|
||||
BillingExpr: make(map[string]string),
|
||||
PluginBillingExpr: make(map[string]string),
|
||||
@@ -152,6 +153,9 @@ func GetBuiltinBillingExprCopy() map[string]string {
|
||||
|
||||
func GetGroupBillingExpr(group, model string) (string, bool) {
|
||||
expr, ok := billingSetting.GroupBillingExpr[group][model]
|
||||
if !ok && group == DeepSeekOfficialGroup {
|
||||
return GetChannelBillingExpr(constant.ChannelTypeDeepSeek, model)
|
||||
}
|
||||
return expr, ok
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ func UpdateChatsByJsonString(jsonString string) error {
|
||||
func Chats2JsonString() string {
|
||||
jsonBytes, err := json.Marshal(Chats)
|
||||
if err != nil {
|
||||
common.SysLog("error marshalling chats: " + err.Error())
|
||||
common.SysLog(common.LogText("error marshalling chats: %s", err.Error()))
|
||||
return "[]"
|
||||
}
|
||||
return string(jsonBytes)
|
||||
|
||||
@@ -58,7 +58,7 @@ func (cm *ConfigManager) LoadFromDB(options map[string]string) error {
|
||||
// 如果找到配置项,则更新配置
|
||||
if len(configMap) > 0 {
|
||||
if err := updateConfigFromMap(config, configMap); err != nil {
|
||||
common.SysError("failed to update config " + name + ": " + err.Error())
|
||||
common.SysError(common.LogText("failed to update config %s: %s", name, err.Error()))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package console_setting
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
@@ -24,10 +23,10 @@ var (
|
||||
slugRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
||||
)
|
||||
|
||||
func parseJSONArray(jsonStr string, typeName string) ([]map[string]interface{}, error) {
|
||||
var list []map[string]interface{}
|
||||
func parseJSONArray(jsonStr string, formatErrorKey string) ([]map[string]any, error) {
|
||||
var list []map[string]any
|
||||
if err := common.UnmarshalJsonStr(jsonStr, &list); err != nil {
|
||||
return nil, fmt.Errorf("%s格式错误:%s", typeName, err.Error())
|
||||
return nil, common.NewMessage(formatErrorKey, map[string]any{"error": err.Error()})
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
@@ -36,31 +35,52 @@ func exceedsMaxCharacters(s string, max int) bool {
|
||||
return len(utf16.Encode([]rune(s))) > max
|
||||
}
|
||||
|
||||
func validateURL(urlStr string, index int, itemType string) error {
|
||||
// itemMessages are the keys of the URL and content checks shared by the
|
||||
// API info and Uptime Kuma group lists.
|
||||
type itemMessages struct {
|
||||
invalidURL string
|
||||
unparsableURL string
|
||||
unsafeContent string
|
||||
}
|
||||
|
||||
var (
|
||||
apiInfoMessages = itemMessages{
|
||||
invalidURL: "API info #{{index}} has an invalid URL format",
|
||||
unparsableURL: "API info #{{index}} has a URL that cannot be parsed: {{error}}",
|
||||
unsafeContent: "API info #{{index}} contains disallowed content",
|
||||
}
|
||||
groupMessages = itemMessages{
|
||||
invalidURL: "Group #{{index}} has an invalid URL format",
|
||||
unparsableURL: "Group #{{index}} has a URL that cannot be parsed: {{error}}",
|
||||
unsafeContent: "Group #{{index}} contains disallowed content",
|
||||
}
|
||||
)
|
||||
|
||||
func validateURL(urlStr string, index int, messages itemMessages) error {
|
||||
if !urlRegex.MatchString(urlStr) {
|
||||
return fmt.Errorf("第%d个%s的URL格式不正确", index, itemType)
|
||||
return common.NewMessage(messages.invalidURL, map[string]any{"index": index})
|
||||
}
|
||||
if _, err := url.Parse(urlStr); err != nil {
|
||||
return fmt.Errorf("第%d个%s的URL无法解析:%s", index, itemType, err.Error())
|
||||
return common.NewMessage(messages.unparsableURL, map[string]any{"index": index, "error": err.Error()})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkDangerousContent(content string, index int, itemType string) error {
|
||||
func checkDangerousContent(content string, index int, messages itemMessages) error {
|
||||
lower := strings.ToLower(content)
|
||||
for _, d := range dangerousChars {
|
||||
if strings.Contains(lower, d) {
|
||||
return fmt.Errorf("第%d个%s包含不允许的内容", index, itemType)
|
||||
return common.NewMessage(messages.unsafeContent, map[string]any{"index": index})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getJSONList(jsonStr string) []map[string]interface{} {
|
||||
func getJSONList(jsonStr string) []map[string]any {
|
||||
if jsonStr == "" {
|
||||
return []map[string]interface{}{}
|
||||
return []map[string]any{}
|
||||
}
|
||||
var list []map[string]interface{}
|
||||
var list []map[string]any
|
||||
_ = common.UnmarshalJsonStr(jsonStr, &list)
|
||||
return list
|
||||
}
|
||||
@@ -80,77 +100,77 @@ func ValidateConsoleSettings(settingsStr string, settingType string) error {
|
||||
case "UptimeKumaGroups":
|
||||
return validateUptimeKumaGroups(settingsStr)
|
||||
default:
|
||||
return fmt.Errorf("未知的设置类型:%s", settingType)
|
||||
return common.NewMessage("Unknown setting type: {{type}}", map[string]any{"type": settingType})
|
||||
}
|
||||
}
|
||||
|
||||
func validateApiInfo(apiInfoStr string) error {
|
||||
apiInfoList, err := parseJSONArray(apiInfoStr, "API信息")
|
||||
apiInfoList, err := parseJSONArray(apiInfoStr, "Invalid API info format: {{error}}")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(apiInfoList) > 50 {
|
||||
return fmt.Errorf("API信息数量不能超过50个")
|
||||
return common.NewMessage("API info cannot exceed 50 entries")
|
||||
}
|
||||
|
||||
for i, apiInfo := range apiInfoList {
|
||||
urlStr, ok := apiInfo["url"].(string)
|
||||
if !ok || urlStr == "" {
|
||||
return fmt.Errorf("第%d个API信息缺少URL字段", i+1)
|
||||
return common.NewMessage("API info #{{index}} is missing the URL field", map[string]any{"index": i + 1})
|
||||
}
|
||||
route, ok := apiInfo["route"].(string)
|
||||
if !ok || route == "" {
|
||||
return fmt.Errorf("第%d个API信息缺少线路描述字段", i+1)
|
||||
return common.NewMessage("API info #{{index}} is missing the route description field", map[string]any{"index": i + 1})
|
||||
}
|
||||
description, ok := apiInfo["description"].(string)
|
||||
if !ok || description == "" {
|
||||
return fmt.Errorf("第%d个API信息缺少说明字段", i+1)
|
||||
return common.NewMessage("API info #{{index}} is missing the description field", map[string]any{"index": i + 1})
|
||||
}
|
||||
color, ok := apiInfo["color"].(string)
|
||||
if !ok || color == "" {
|
||||
return fmt.Errorf("第%d个API信息缺少颜色字段", i+1)
|
||||
return common.NewMessage("API info #{{index}} is missing the color field", map[string]any{"index": i + 1})
|
||||
}
|
||||
|
||||
if err := validateURL(urlStr, i+1, "API信息"); err != nil {
|
||||
if err := validateURL(urlStr, i+1, apiInfoMessages); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if exceedsMaxCharacters(urlStr, 500) {
|
||||
return fmt.Errorf("第%d个API信息的URL长度不能超过500字符", i+1)
|
||||
return common.NewMessage("API info #{{index}} URL cannot exceed 500 characters", map[string]any{"index": i + 1})
|
||||
}
|
||||
if exceedsMaxCharacters(route, 100) {
|
||||
return fmt.Errorf("第%d个API信息的线路描述长度不能超过100字符", i+1)
|
||||
return common.NewMessage("API info #{{index}} route description cannot exceed 100 characters", map[string]any{"index": i + 1})
|
||||
}
|
||||
if exceedsMaxCharacters(description, 200) {
|
||||
return fmt.Errorf("第%d个API信息的说明长度不能超过200字符", i+1)
|
||||
return common.NewMessage("API info #{{index}} description cannot exceed 200 characters", map[string]any{"index": i + 1})
|
||||
}
|
||||
|
||||
if !validColors[color] {
|
||||
return fmt.Errorf("第%d个API信息的颜色值不合法", i+1)
|
||||
return common.NewMessage("API info #{{index}} has an invalid color value", map[string]any{"index": i + 1})
|
||||
}
|
||||
|
||||
if err := checkDangerousContent(description, i+1, "API信息"); err != nil {
|
||||
if err := checkDangerousContent(description, i+1, apiInfoMessages); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkDangerousContent(route, i+1, "API信息"); err != nil {
|
||||
if err := checkDangerousContent(route, i+1, apiInfoMessages); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetApiInfo() []map[string]interface{} {
|
||||
func GetApiInfo() []map[string]any {
|
||||
return getJSONList(GetConsoleSetting().ApiInfo)
|
||||
}
|
||||
|
||||
func validateAnnouncements(announcementsStr string) error {
|
||||
list, err := parseJSONArray(announcementsStr, "系统公告")
|
||||
list, err := parseJSONArray(announcementsStr, "Invalid announcements format: {{error}}")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(list) > 100 {
|
||||
return fmt.Errorf("系统公告数量不能超过100个")
|
||||
return common.NewMessage("Announcements cannot exceed 100 entries")
|
||||
}
|
||||
validTypes := map[string]bool{
|
||||
"default": true, "ongoing": true, "success": true, "warning": true, "error": true,
|
||||
@@ -158,32 +178,32 @@ func validateAnnouncements(announcementsStr string) error {
|
||||
for i, ann := range list {
|
||||
content, ok := ann["content"].(string)
|
||||
if !ok || content == "" {
|
||||
return fmt.Errorf("第%d个公告缺少内容字段", i+1)
|
||||
return common.NewMessage("Announcement #{{index}} is missing the content field", map[string]any{"index": i + 1})
|
||||
}
|
||||
publishDateAny, exists := ann["publishDate"]
|
||||
if !exists {
|
||||
return fmt.Errorf("第%d个公告缺少发布日期字段", i+1)
|
||||
return common.NewMessage("Announcement #{{index}} is missing the publish date field", map[string]any{"index": i + 1})
|
||||
}
|
||||
publishDateStr, ok := publishDateAny.(string)
|
||||
if !ok || publishDateStr == "" {
|
||||
return fmt.Errorf("第%d个公告的发布日期不能为空", i+1)
|
||||
return common.NewMessage("Announcement #{{index}} publish date cannot be empty", map[string]any{"index": i + 1})
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, publishDateStr); err != nil {
|
||||
return fmt.Errorf("第%d个公告的发布日期格式错误", i+1)
|
||||
return common.NewMessage("Announcement #{{index}} has an invalid publish date format", map[string]any{"index": i + 1})
|
||||
}
|
||||
if t, exists := ann["type"]; exists {
|
||||
if typeStr, ok := t.(string); ok {
|
||||
if !validTypes[typeStr] {
|
||||
return fmt.Errorf("第%d个公告的类型值不合法", i+1)
|
||||
return common.NewMessage("Announcement #{{index}} has an invalid type value", map[string]any{"index": i + 1})
|
||||
}
|
||||
}
|
||||
}
|
||||
if exceedsMaxCharacters(content, 500) {
|
||||
return fmt.Errorf("第%d个公告的内容长度不能超过500字符", i+1)
|
||||
return common.NewMessage("Announcement #{{index}} content cannot exceed 500 characters", map[string]any{"index": i + 1})
|
||||
}
|
||||
if extra, exists := ann["extra"]; exists {
|
||||
if extraStr, ok := extra.(string); ok && exceedsMaxCharacters(extraStr, 100) {
|
||||
return fmt.Errorf("第%d个公告的说明长度不能超过100字符", i+1)
|
||||
return common.NewMessage("Announcement #{{index}} note cannot exceed 100 characters", map[string]any{"index": i + 1})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -191,33 +211,33 @@ func validateAnnouncements(announcementsStr string) error {
|
||||
}
|
||||
|
||||
func validateFAQ(faqStr string) error {
|
||||
list, err := parseJSONArray(faqStr, "FAQ信息")
|
||||
list, err := parseJSONArray(faqStr, "Invalid FAQ format: {{error}}")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(list) > 100 {
|
||||
return fmt.Errorf("FAQ数量不能超过100个")
|
||||
return common.NewMessage("FAQ cannot exceed 100 entries")
|
||||
}
|
||||
for i, faq := range list {
|
||||
question, ok := faq["question"].(string)
|
||||
if !ok || question == "" {
|
||||
return fmt.Errorf("第%d个FAQ缺少问题字段", i+1)
|
||||
return common.NewMessage("FAQ #{{index}} is missing the question field", map[string]any{"index": i + 1})
|
||||
}
|
||||
answer, ok := faq["answer"].(string)
|
||||
if !ok || answer == "" {
|
||||
return fmt.Errorf("第%d个FAQ缺少答案字段", i+1)
|
||||
return common.NewMessage("FAQ #{{index}} is missing the answer field", map[string]any{"index": i + 1})
|
||||
}
|
||||
if exceedsMaxCharacters(question, 200) {
|
||||
return fmt.Errorf("第%d个FAQ的问题长度不能超过200字符", i+1)
|
||||
return common.NewMessage("FAQ #{{index}} question cannot exceed 200 characters", map[string]any{"index": i + 1})
|
||||
}
|
||||
if exceedsMaxCharacters(answer, 1000) {
|
||||
return fmt.Errorf("第%d个FAQ的答案长度不能超过1000字符", i+1)
|
||||
return common.NewMessage("FAQ #{{index}} answer cannot exceed 1000 characters", map[string]any{"index": i + 1})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getPublishTime(item map[string]interface{}) time.Time {
|
||||
func getPublishTime(item map[string]any) time.Time {
|
||||
if v, ok := item["publishDate"]; ok {
|
||||
if s, ok2 := v.(string); ok2 {
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
@@ -228,7 +248,7 @@ func getPublishTime(item map[string]interface{}) time.Time {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func GetAnnouncements() []map[string]interface{} {
|
||||
func GetAnnouncements() []map[string]any {
|
||||
list := getJSONList(GetConsoleSetting().Announcements)
|
||||
sort.SliceStable(list, func(i, j int) bool {
|
||||
return getPublishTime(list[i]).After(getPublishTime(list[j]))
|
||||
@@ -236,18 +256,18 @@ func GetAnnouncements() []map[string]interface{} {
|
||||
return list
|
||||
}
|
||||
|
||||
func GetFAQ() []map[string]interface{} {
|
||||
func GetFAQ() []map[string]any {
|
||||
return getJSONList(GetConsoleSetting().FAQ)
|
||||
}
|
||||
|
||||
func validateUptimeKumaGroups(groupsStr string) error {
|
||||
groups, err := parseJSONArray(groupsStr, "Uptime Kuma分组配置")
|
||||
groups, err := parseJSONArray(groupsStr, "Invalid Uptime Kuma group settings format: {{error}}")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(groups) > 20 {
|
||||
return fmt.Errorf("Uptime Kuma分组数量不能超过20个")
|
||||
return common.NewMessage("Uptime Kuma groups cannot exceed 20 entries")
|
||||
}
|
||||
|
||||
nameSet := make(map[string]bool)
|
||||
@@ -255,56 +275,56 @@ func validateUptimeKumaGroups(groupsStr string) error {
|
||||
for i, group := range groups {
|
||||
categoryName, ok := group["categoryName"].(string)
|
||||
if !ok || categoryName == "" {
|
||||
return fmt.Errorf("第%d个分组缺少分类名称字段", i+1)
|
||||
return common.NewMessage("Group #{{index}} is missing the category name field", map[string]any{"index": i + 1})
|
||||
}
|
||||
if nameSet[categoryName] {
|
||||
return fmt.Errorf("第%d个分组的分类名称与其他分组重复", i+1)
|
||||
return common.NewMessage("Group #{{index}} category name duplicates another group", map[string]any{"index": i + 1})
|
||||
}
|
||||
nameSet[categoryName] = true
|
||||
urlStr, ok := group["url"].(string)
|
||||
if !ok || urlStr == "" {
|
||||
return fmt.Errorf("第%d个分组缺少URL字段", i+1)
|
||||
return common.NewMessage("Group #{{index}} is missing the URL field", map[string]any{"index": i + 1})
|
||||
}
|
||||
slug, ok := group["slug"].(string)
|
||||
if !ok || slug == "" {
|
||||
return fmt.Errorf("第%d个分组缺少Slug字段", i+1)
|
||||
return common.NewMessage("Group #{{index}} is missing the slug field", map[string]any{"index": i + 1})
|
||||
}
|
||||
description, ok := group["description"].(string)
|
||||
if !ok {
|
||||
description = ""
|
||||
}
|
||||
|
||||
if err := validateURL(urlStr, i+1, "分组"); err != nil {
|
||||
if err := validateURL(urlStr, i+1, groupMessages); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if exceedsMaxCharacters(categoryName, 50) {
|
||||
return fmt.Errorf("第%d个分组的分类名称长度不能超过50字符", i+1)
|
||||
return common.NewMessage("Group #{{index}} category name cannot exceed 50 characters", map[string]any{"index": i + 1})
|
||||
}
|
||||
if exceedsMaxCharacters(urlStr, 500) {
|
||||
return fmt.Errorf("第%d个分组的URL长度不能超过500字符", i+1)
|
||||
return common.NewMessage("Group #{{index}} URL cannot exceed 500 characters", map[string]any{"index": i + 1})
|
||||
}
|
||||
if exceedsMaxCharacters(slug, 100) {
|
||||
return fmt.Errorf("第%d个分组的Slug长度不能超过100字符", i+1)
|
||||
return common.NewMessage("Group #{{index}} slug cannot exceed 100 characters", map[string]any{"index": i + 1})
|
||||
}
|
||||
if exceedsMaxCharacters(description, 200) {
|
||||
return fmt.Errorf("第%d个分组的描述长度不能超过200字符", i+1)
|
||||
return common.NewMessage("Group #{{index}} description cannot exceed 200 characters", map[string]any{"index": i + 1})
|
||||
}
|
||||
|
||||
if !slugRegex.MatchString(slug) {
|
||||
return fmt.Errorf("第%d个分组的Slug只能包含字母、数字、下划线和连字符", i+1)
|
||||
return common.NewMessage("Group #{{index}} slug can only contain letters, numbers, underscores and hyphens", map[string]any{"index": i + 1})
|
||||
}
|
||||
|
||||
if err := checkDangerousContent(description, i+1, "分组"); err != nil {
|
||||
if err := checkDangerousContent(description, i+1, groupMessages); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkDangerousContent(categoryName, i+1, "分组"); err != nil {
|
||||
if err := checkDangerousContent(categoryName, i+1, groupMessages); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetUptimeKumaGroups() []map[string]interface{} {
|
||||
func GetUptimeKumaGroups() []map[string]any {
|
||||
return getJSONList(GetConsoleSetting().UptimeKumaGroups)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package model_setting
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -124,12 +123,12 @@ func compiledThinkingBlacklist() ([]string, []*regexp.Regexp) {
|
||||
if after, ok := strings.CutPrefix(entry, thinkingBlacklistRegexPrefix); ok {
|
||||
pattern := after
|
||||
if pattern == "" {
|
||||
common.SysError(fmt.Sprintf("invalid thinking_model_blacklist regex %q: pattern is empty", entry))
|
||||
common.SysError(common.LogText("invalid thinking_model_blacklist regex %q: pattern is empty", entry))
|
||||
continue
|
||||
}
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
common.SysError(fmt.Sprintf("invalid thinking_model_blacklist regex %q: %v", entry, err))
|
||||
common.SysError(common.LogText("invalid thinking_model_blacklist regex %q: %v", entry, err))
|
||||
continue
|
||||
}
|
||||
regexes = append(regexes, re)
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
package model_setting
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/setting/config"
|
||||
)
|
||||
|
||||
// QwenSettings defines Qwen model configuration. 注意bool要以enabled结尾才可以生效编辑
|
||||
type QwenSettings struct {
|
||||
SyncImageModels []string `json:"sync_image_models"`
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
var defaultQwenSettings = QwenSettings{
|
||||
SyncImageModels: []string{
|
||||
"z-image",
|
||||
"qwen-image",
|
||||
"wan2.6",
|
||||
"wan2.7",
|
||||
"qwen-image-edit",
|
||||
"qwen-image-edit-max",
|
||||
"qwen-image-edit-max-2026-01-16",
|
||||
"qwen-image-edit-plus",
|
||||
"qwen-image-edit-plus-2025-12-15",
|
||||
"qwen-image-edit-plus-2025-10-30",
|
||||
},
|
||||
}
|
||||
|
||||
// 全局实例
|
||||
var qwenSettings = defaultQwenSettings
|
||||
|
||||
func init() {
|
||||
// 注册到全局配置管理器
|
||||
config.GlobalConfig.Register("qwen", &qwenSettings)
|
||||
}
|
||||
|
||||
// GetQwenSettings
|
||||
func GetQwenSettings() *QwenSettings {
|
||||
return &qwenSettings
|
||||
}
|
||||
|
||||
// IsSyncImageModel
|
||||
func IsSyncImageModel(model string) bool {
|
||||
for _, m := range qwenSettings.SyncImageModels {
|
||||
if strings.Contains(model, m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package operation_setting
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/setting/config"
|
||||
)
|
||||
|
||||
const LogMaxRangeDaysOptionKey = "log_setting.max_range_days"
|
||||
|
||||
// LogSetting limits how far regular users can reach into usage logs.
|
||||
type LogSetting struct {
|
||||
// UserExportEnabled lets regular users export their own usage logs.
|
||||
// Administrators can always export.
|
||||
UserExportEnabled bool `json:"user_export_enabled"`
|
||||
// MaxRangeDays is the longest time range a user can search in their logs
|
||||
// and dashboard, and the longest range any export can cover.
|
||||
MaxRangeDays int `json:"max_range_days"`
|
||||
}
|
||||
|
||||
var logSetting = LogSetting{
|
||||
UserExportEnabled: true,
|
||||
MaxRangeDays: 365,
|
||||
}
|
||||
|
||||
func init() {
|
||||
config.GlobalConfig.Register("log_setting", &logSetting)
|
||||
}
|
||||
|
||||
func GetLogSetting() *LogSetting {
|
||||
return &logSetting
|
||||
}
|
||||
|
||||
func (s *LogSetting) MaxRangeSeconds() int64 {
|
||||
return int64(max(s.MaxRangeDays, 1)) * 24 * 60 * 60
|
||||
}
|
||||
|
||||
func ValidateLogMaxRangeDays(value string) error {
|
||||
days, err := strconv.Atoi(value)
|
||||
if err != nil || days < 1 {
|
||||
return common.NewMessage("The longest log time range must be at least 1 day")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,14 +1,24 @@
|
||||
package operation_setting
|
||||
|
||||
import "github.com/QuantumNous/new-api/setting/config"
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/QuantumNous/new-api/setting/config"
|
||||
)
|
||||
|
||||
type QuotaSetting struct {
|
||||
EnableFreeModelPreConsume bool `json:"enable_free_model_pre_consume"` // 是否对免费模型启用预消耗
|
||||
EnableFreeModelPreConsume bool `json:"enable_free_model_pre_consume"` // 是否对免费模型启用预消耗
|
||||
TrustQuotaUSD float64 `json:"trust_quota_usd"` // 钱包免预扣门槛,0 表示禁用
|
||||
PreConsumeMultiplier float64 `json:"pre_consume_multiplier"` // 预计输入费用的预扣倍率,仅影响预留
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
var quotaSetting = QuotaSetting{
|
||||
EnableFreeModelPreConsume: true,
|
||||
TrustQuotaUSD: 10,
|
||||
PreConsumeMultiplier: 1,
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -19,3 +29,27 @@ func init() {
|
||||
func GetQuotaSetting() *QuotaSetting {
|
||||
return "aSetting
|
||||
}
|
||||
|
||||
// ValidateQuotaOption validates reservation settings before they are persisted.
|
||||
func ValidateQuotaOption(key, value string) error {
|
||||
if key != "quota_setting.trust_quota_usd" && key != "quota_setting.pre_consume_multiplier" {
|
||||
return nil
|
||||
}
|
||||
number, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil || math.IsNaN(number) || math.IsInf(number, 0) || number < 0 {
|
||||
return fmt.Errorf("%s must be a finite non-negative number", key)
|
||||
}
|
||||
if key == "quota_setting.pre_consume_multiplier" && number == 0 {
|
||||
return fmt.Errorf("%s must be greater than zero", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InputPreConsumeMultiplier rejects invalid runtime settings as well as invalid saves.
|
||||
func InputPreConsumeMultiplier() (float64, error) {
|
||||
multiplier := quotaSetting.PreConsumeMultiplier
|
||||
if multiplier <= 0 || math.IsNaN(multiplier) || math.IsInf(multiplier, 0) {
|
||||
return 0, fmt.Errorf("pre-consume multiplier must be a finite number greater than zero")
|
||||
}
|
||||
return multiplier, nil
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package operation_setting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -23,6 +22,8 @@ import (
|
||||
// Effective index: hardcoded defaults → hardcoded model overrides → valid
|
||||
// operator values. Lookup uses the longest model prefix before the tool
|
||||
// default, and a matched numeric zero is terminal.
|
||||
//
|
||||
// Built-in keys are written in the vendor's list currency.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ToolPriceOptionKey = "tool_price_setting.prices"
|
||||
@@ -49,6 +50,49 @@ func seedHardcodedToolPrices(prices map[string]float64) {
|
||||
prices["web_search_preview:gpt-4.1*"] = defaultSearchPreviewModelPrice
|
||||
prices["web_search_preview:gpt-4o-mini*"] = defaultSearchPreviewModelPrice
|
||||
prices["web_search_preview:gpt-4.1-mini*"] = defaultSearchPreviewModelPrice
|
||||
|
||||
// Google Search grounding (USD per 1K, ai.google.dev/gemini-api/docs/pricing
|
||||
// and cloud.google.com/vertex-ai/generative-ai/pricing): Gemini 3 bills each
|
||||
// search query at $14 (the google_search default); Gemini 2.5 and older bill
|
||||
// $35 per grounded prompt, so their query count is free and the grounded
|
||||
// prompt carries the price.
|
||||
prices["google_search:gemini-2.5*"] = 0
|
||||
prices["google_search:gemini-2.0*"] = 0
|
||||
prices["google_search:gemini-1.5*"] = 0
|
||||
prices["google_search_grounded_prompt:gemini-2.5*"] = 35
|
||||
prices["google_search_grounded_prompt:gemini-2.0*"] = 35
|
||||
prices["google_search_grounded_prompt:gemini-1.5*"] = 35
|
||||
|
||||
// Vendor search tiers, verbatim in the vendor's list currency per 1K
|
||||
// calls. CNY: Zhipu search engines, 0.01/0.03/0.05 CNY per call
|
||||
// (https://docs.bigmodel.cn/cn/guide/tools/web-search), and Alibaba Model
|
||||
// Studio search strategies at Beijing prices
|
||||
// (https://help.aliyun.com/zh/model-studio/web-search); the Singapore
|
||||
// agent price (73.392381 CNY) is set by the operator. USD: Azure
|
||||
// Responses web search, billed as Bing transactions at $14 per 1,000
|
||||
// (https://www.microsoft.com/en-us/bing/apis), and xAI web search $5 per
|
||||
// 1K calls, x_search $5 per 1K posts and $10 per 1K profiles
|
||||
// (https://docs.x.ai/developers/pricing).
|
||||
prices["search_std"] = 10
|
||||
prices["search_pro"] = 30
|
||||
prices["search_pro_sogou"] = 50
|
||||
prices["search_pro_quark"] = 50
|
||||
prices["search_strategy_turbo"] = 3
|
||||
prices["search_strategy_max"] = 4
|
||||
prices["search_strategy_agent"] = 4
|
||||
prices["search_strategy_agent_max"] = 4
|
||||
prices["bing_web_search"] = 14
|
||||
prices["web_search:grok*"] = 5
|
||||
prices["x_search_posts"] = 5
|
||||
prices["x_search_profiles"] = 10
|
||||
|
||||
// DeepSeek Search and Fetch APIs, $0.75 per 1,000 successful requests
|
||||
// each (https://api-docs.deepseek.com/quick_start/pricing), for the
|
||||
// searches and page fetches DeepSeek runs server-side for Claude Code and
|
||||
// Codex traffic. /v1/tools/search and /v1/tools/fetch bill through the
|
||||
// deepseek-search and deepseek-fetch model prices instead.
|
||||
prices["deepseek_search"] = 0.75
|
||||
prices["deepseek_fetch"] = 0.75
|
||||
}
|
||||
|
||||
// ToolPriceSetting is managed by config.GlobalConfig.Register.
|
||||
@@ -61,11 +105,29 @@ var toolPriceSetting = ToolPriceSetting{
|
||||
Prices: make(map[string]float64),
|
||||
}
|
||||
|
||||
// builtInToolNames are the tool names (the part before ":") that
|
||||
// seedHardcodedToolPrices prices, built once from the constant seed and never
|
||||
// from operator configuration.
|
||||
var builtInToolNames = make(map[string]struct{})
|
||||
|
||||
func init() {
|
||||
config.GlobalConfig.Register("tool_price_setting", &toolPriceSetting)
|
||||
seed := make(map[string]float64)
|
||||
seedHardcodedToolPrices(seed)
|
||||
for key := range seed {
|
||||
name, _, _ := strings.Cut(key, ":")
|
||||
builtInToolNames[name] = struct{}{}
|
||||
}
|
||||
RebuildToolPriceIndex()
|
||||
}
|
||||
|
||||
// IsBuiltInToolPriceKey reports whether the built-in price seed prices the
|
||||
// tool name, as a default or for a model prefix. Operator prices never count.
|
||||
func IsBuiltInToolPriceKey(name string) bool {
|
||||
_, ok := builtInToolNames[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Precomputed price index (atomic, lock-free on read path)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -89,25 +151,25 @@ func isValidToolPrice(price float64) bool {
|
||||
func decodeToolPricesJSON(value string, ignoreInvalidEntries bool) (map[string]float64, error) {
|
||||
rawValue := json.RawMessage(strings.TrimSpace(value))
|
||||
if common.GetJsonType(rawValue) != "object" {
|
||||
return nil, fmt.Errorf("工具价格必须是 JSON 对象")
|
||||
return nil, common.NewMessage("Tool prices must be a JSON object")
|
||||
}
|
||||
|
||||
var rawPrices map[string]json.RawMessage
|
||||
if err := common.Unmarshal(rawValue, &rawPrices); err != nil {
|
||||
return nil, fmt.Errorf("解析工具价格失败: %w", err)
|
||||
return nil, common.NewMessage("Failed to parse tool prices: {{error}}", map[string]any{"error": err.Error()})
|
||||
}
|
||||
|
||||
prices := make(map[string]float64, len(rawPrices))
|
||||
for name, rawPrice := range rawPrices {
|
||||
var entryErr error
|
||||
if common.GetJsonType(rawPrice) != "number" {
|
||||
entryErr = fmt.Errorf("工具价格 %q 必须是非负数字", name)
|
||||
entryErr = common.NewMessage(`Tool price "{{name}}" must be a non-negative number`, map[string]any{"name": name})
|
||||
} else {
|
||||
var price float64
|
||||
if err := common.Unmarshal(rawPrice, &price); err != nil {
|
||||
entryErr = fmt.Errorf("解析工具价格 %q 失败: %w", name, err)
|
||||
entryErr = common.NewMessage(`Failed to parse tool price "{{name}}": {{error}}`, map[string]any{"name": name, "error": err.Error()})
|
||||
} else if !isValidToolPrice(price) {
|
||||
entryErr = fmt.Errorf("工具价格 %q 必须是有限的非负数字", name)
|
||||
entryErr = common.NewMessage(`Tool price "{{name}}" must be a finite non-negative number`, map[string]any{"name": name})
|
||||
} else {
|
||||
prices[name] = price
|
||||
}
|
||||
@@ -137,7 +199,7 @@ func ValidateToolPricesJSON(value string) error {
|
||||
func LoadToolPricesFromJSONString(value string) {
|
||||
prices, err := decodeToolPricesJSON(value, true)
|
||||
if err != nil {
|
||||
common.SysError("加载工具价格失败,将使用硬编码兜底: " + err.Error())
|
||||
common.SysError(common.LogText("failed to load tool prices, using hardcoded fallbacks: %s", err.Error()))
|
||||
prices = make(map[string]float64)
|
||||
}
|
||||
toolPriceSetting.Prices = prices
|
||||
|
||||
@@ -24,18 +24,46 @@ func TestToolPriceHardcodedFallbacksSurviveMissingOperatorConfig(t *testing.T) {
|
||||
toolPriceSetting.Prices = map[string]float64{}
|
||||
RebuildToolPriceIndex()
|
||||
|
||||
expectedDefaults := map[string]float64{
|
||||
"web_search": 10,
|
||||
"web_search_preview": 10,
|
||||
"file_search": 2.5,
|
||||
"google_search": 14,
|
||||
"image_generation": 150,
|
||||
tests := []struct {
|
||||
tool string
|
||||
model string
|
||||
want float64
|
||||
}{
|
||||
{"web_search", "", 10},
|
||||
{"web_search_preview", "", 10},
|
||||
{"file_search", "", 2.5},
|
||||
{"google_search", "", 14},
|
||||
{"image_generation", "", 150},
|
||||
{"web_search_preview", "gpt-4o-2024-11-20", 25},
|
||||
{"web_search_preview", "gpt-4.1-mini", 25},
|
||||
// Gemini 3 bills each search query; 2.5 and older bill the grounded prompt.
|
||||
{"google_search", "gemini-3.7-flash", 14},
|
||||
{"google_search_grounded_prompt", "gemini-3.7-flash", 0},
|
||||
{"google_search", "gemini-2.5-flash", 0},
|
||||
{"google_search_grounded_prompt", "gemini-2.5-flash", 35},
|
||||
// Vendor search tiers in the vendor's list currency.
|
||||
{"search_std", "", 10},
|
||||
{"search_pro", "", 30},
|
||||
{"search_pro_sogou", "", 50},
|
||||
{"search_pro_quark", "", 50},
|
||||
{"search_strategy_turbo", "", 3},
|
||||
{"search_strategy_max", "", 4},
|
||||
{"search_strategy_agent", "", 4},
|
||||
{"search_strategy_agent_max", "", 4},
|
||||
{"bing_web_search", "", 14},
|
||||
{"web_search", "grok-4", 5},
|
||||
{"x_search_posts", "", 5},
|
||||
{"x_search_profiles", "", 10},
|
||||
{"deepseek_search", "", 0.75},
|
||||
{"deepseek_fetch", "", 0.75},
|
||||
{"deepseek_search", "claude-sonnet-4-5", 0.75},
|
||||
}
|
||||
for name, expected := range expectedDefaults {
|
||||
assert.Equal(t, expected, GetToolPrice(name), name)
|
||||
for _, tt := range tests {
|
||||
assert.Equal(t, tt.want, GetToolPriceForModel(tt.tool, tt.model), "%s for %q", tt.tool, tt.model)
|
||||
}
|
||||
assert.Equal(t, 25.0, GetToolPriceForModel("web_search_preview", "gpt-4o-2024-11-20"))
|
||||
assert.Equal(t, 25.0, GetToolPriceForModel("web_search_preview", "gpt-4.1-mini"))
|
||||
assert.True(t, IsBuiltInToolPriceKey("bing_web_search"))
|
||||
assert.True(t, IsBuiltInToolPriceKey("deepseek_fetch"))
|
||||
assert.True(t, IsBuiltInToolPriceKey("google_search_grounded_prompt"), "seeded only for model prefixes")
|
||||
}
|
||||
|
||||
func TestToolPriceOperatorOverridePrecedenceAndExplicitZero(t *testing.T) {
|
||||
@@ -77,6 +105,7 @@ func TestToolPriceCustomFunctionHasNoHardcodedFallback(t *testing.T) {
|
||||
toolPriceSetting.Prices["lookup_customer"] = 5
|
||||
RebuildToolPriceIndex()
|
||||
assert.Equal(t, 5.0, GetToolPrice("lookup_customer"))
|
||||
assert.False(t, IsBuiltInToolPriceKey("lookup_customer"), "an operator price is not a built-in key")
|
||||
|
||||
toolPriceSetting.Prices["lookup_customer"] = 0
|
||||
RebuildToolPriceIndex()
|
||||
|
||||
@@ -31,7 +31,7 @@ func ModelRequestRateLimitGroup2JSONString() string {
|
||||
|
||||
jsonBytes, err := common.Marshal(ModelRequestRateLimitGroup)
|
||||
if err != nil {
|
||||
common.SysLog("error marshalling model ratio: " + err.Error())
|
||||
common.SysLog(common.LogText("error marshalling model ratio: %s", err.Error()))
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ func UpdateGroupRatioByJSONString(jsonStr string) error {
|
||||
func GetGroupRatio(name string) float64 {
|
||||
ratio, ok := groupRatioMap.Get(name)
|
||||
if !ok {
|
||||
common.SysLog("group ratio not found: " + name)
|
||||
common.SysLog(common.LogText("group ratio not found: %s", name))
|
||||
return 1
|
||||
}
|
||||
return ratio
|
||||
|
||||
@@ -365,7 +365,7 @@ func GetModelPrice(name string, printErr bool) (float64, bool) {
|
||||
}
|
||||
|
||||
if printErr {
|
||||
common.SysError("model price not found: " + name)
|
||||
common.SysError(common.LogText("model price not found: %s", name))
|
||||
}
|
||||
return -1, false
|
||||
}
|
||||
@@ -395,7 +395,7 @@ func GetModelRatio(name string) (float64, bool, string) {
|
||||
func DefaultModelRatio2JSONString() string {
|
||||
jsonBytes, err := common.Marshal(defaultModelRatio)
|
||||
if err != nil {
|
||||
common.SysError("error marshalling model ratio: " + err.Error())
|
||||
common.SysError(common.LogText("error marshalling model ratio: %s", err.Error()))
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ package setting
|
||||
|
||||
import "strings"
|
||||
|
||||
var CheckSensitiveEnabled = true
|
||||
var CheckSensitiveOnPromptEnabled = true
|
||||
var CheckSensitiveEnabled = false
|
||||
var CheckSensitiveOnPromptEnabled = false
|
||||
|
||||
//var CheckSensitiveOnCompletionEnabled = true
|
||||
|
||||
@@ -14,10 +14,7 @@ var StopOnSensitiveEnabled = true
|
||||
var StreamCacheQueueLength = 0
|
||||
|
||||
// SensitiveWords 敏感词
|
||||
// var SensitiveWords []string
|
||||
var SensitiveWords = []string{
|
||||
"test_sensitive",
|
||||
}
|
||||
var SensitiveWords []string
|
||||
|
||||
func SensitiveWordsToString() string {
|
||||
return strings.Join(SensitiveWords, "\n")
|
||||
|
||||
@@ -26,7 +26,7 @@ type TaskArtifactAccessLimits struct {
|
||||
func positiveTaskArtifactLimit(env string, defaultValue int) int {
|
||||
value := common.GetEnvOrDefault(env, defaultValue)
|
||||
if value <= 0 {
|
||||
common.SysError(env + " must be positive; using default")
|
||||
common.SysError(common.LogText("%s must be positive; using default", env))
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
|
||||
@@ -63,12 +63,12 @@ func LoadTaskArtifactStoreConfig() TaskArtifactStoreConfig {
|
||||
S3PresignTTLSeconds: common.GetEnvOrDefault(TaskArtifactStoreS3PresignTTLEnv, DefaultTaskArtifactStorePresignTTLSeconds),
|
||||
}
|
||||
if err := ValidateTaskArtifactStoreConfig(config); err != nil {
|
||||
common.SysError("invalid task artifact store configuration: " + err.Error() + "; using upstream mode")
|
||||
common.SysError(common.LogText("invalid task artifact store configuration: %s; using upstream mode", err.Error()))
|
||||
config.Mode = TaskArtifactStoreModeUpstream
|
||||
return config
|
||||
}
|
||||
if config.Mode == TaskArtifactStoreModeS3 {
|
||||
common.SysError("task artifact S3 storage is not implemented; using upstream mode")
|
||||
common.SysError(common.LogText("task artifact S3 storage is not implemented; using upstream mode"))
|
||||
config.Mode = TaskArtifactStoreModeUpstream
|
||||
}
|
||||
return config
|
||||
|
||||
@@ -8,9 +8,11 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
)
|
||||
|
||||
// The default descriptions are English source keys that the web console
|
||||
// translates; descriptions an administrator saves are shown as written.
|
||||
var userUsableGroups = map[string]string{
|
||||
"default": "默认分组",
|
||||
"vip": "vip分组",
|
||||
"default": "Default group",
|
||||
"vip": "VIP group",
|
||||
}
|
||||
var userUsableGroupsMutex sync.RWMutex
|
||||
|
||||
@@ -29,7 +31,7 @@ func UserUsableGroups2JSONString() string {
|
||||
|
||||
jsonBytes, err := json.Marshal(userUsableGroups)
|
||||
if err != nil {
|
||||
common.SysLog("error marshalling user groups: " + err.Error())
|
||||
common.SysLog(common.LogText("error marshalling user groups: %s", err.Error()))
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user