feat: publish v1.0.0-rc.36-custom.1

This commit is contained in:
Codex committed 2026-09-11 05:07:48 +08:00
commit 3a7e8f3b54
2381 files changed
+551494

No files matched your search

@@ -0,0 +1,158 @@
package operation_setting
import "github.com/QuantumNous/new-api/setting/config"
type ChannelAffinityKeySource struct {
Type string `json:"type"` // context_int, context_string, request_header, gjson
Key string `json:"key,omitempty"`
Path string `json:"path,omitempty"`
}
type ChannelAffinityRule struct {
Name string `json:"name"`
ModelRegex []string `json:"model_regex"`
PathRegex []string `json:"path_regex"`
UserAgentInclude []string `json:"user_agent_include,omitempty"`
KeySources []ChannelAffinityKeySource `json:"key_sources"`
ValueRegex string `json:"value_regex"`
TTLSeconds int `json:"ttl_seconds"`
ParamOverrideTemplate map[string]any `json:"param_override_template,omitempty"`
SkipRetryOnFailure bool `json:"skip_retry_on_failure"`
IncludeUsingGroup bool `json:"include_using_group"`
IncludeModelName bool `json:"include_model_name"`
IncludeRuleName bool `json:"include_rule_name"`
}
type ChannelAffinitySetting struct {
Enabled bool `json:"enabled"`
SwitchOnSuccess bool `json:"switch_on_success"`
KeepOnChannelDisabled bool `json:"keep_on_channel_disabled"`
MaxEntries int `json:"max_entries"`
DefaultTTLSeconds int `json:"default_ttl_seconds"`
Rules []ChannelAffinityRule `json:"rules"`
}
// Keep Codex CLI passthrough aligned with upstream. Codex uses lower-case
// header names, while HTTP matching here is case-insensitive.
// Request session/thread headers:
// https://github.com/openai/codex/commit/7c7b4861d88960f7e3bd5b7f30f8351be666dd84
// Responses metadata headers/client_metadata:
// https://github.com/openai/codex/commit/14df0e8833aad0d6d78287954b61ffac67af936c
// x-codex-turn-state response/request round trip:
// https://github.com/openai/codex/commit/ebdd8795e924a8149b616e46ca2ed7848c207a4b
var codexCliPassThroughHeaders = []string{
"Originator",
"Session_id",
"Thread_id",
"Session-Id",
"Thread-Id",
"X-Client-Request-Id",
"User-Agent",
"X-Codex-Beta-Features",
"X-Codex-Turn-State",
"X-Codex-Turn-Metadata",
"X-Codex-Window-Id",
"X-Codex-Parent-Thread-Id",
//"X-Codex-Installation-Id",
"X-OpenAI-Subagent",
"X-OpenAI-Memgen-Request",
//"X-OAI-Attestation",
"X-ResponsesAPI-Include-Timing-Metrics",
"X-OpenAI-Internal-Codex-Responses-Lite",
}
var claudeCliPassThroughHeaders = []string{
"X-Stainless-Arch",
"X-Stainless-Lang",
"X-Stainless-Os",
"X-Stainless-Package-Version",
"X-Stainless-Retry-Count",
"X-Stainless-Runtime",
"X-Stainless-Runtime-Version",
"X-Stainless-Timeout",
"User-Agent",
"X-App",
"Anthropic-Beta",
"Anthropic-Dangerous-Direct-Browser-Access",
"Anthropic-Version",
}
func buildPassHeaderTemplate(headers []string) map[string]any {
clonedHeaders := make([]string, 0, len(headers))
clonedHeaders = append(clonedHeaders, headers...)
return map[string]any{
"operations": []map[string]any{
{
"mode": "pass_headers",
"value": clonedHeaders,
"keep_origin": true,
},
},
}
}
func buildCodexPassHeaderTemplate() map[string]any {
requestHeaders := make([]string, 0, len(codexCliPassThroughHeaders))
requestHeaders = append(requestHeaders, codexCliPassThroughHeaders...)
return map[string]any{
"operations": []map[string]any{
{
"mode": "pass_headers",
"value": requestHeaders,
"keep_origin": true,
},
},
}
}
var channelAffinitySetting = ChannelAffinitySetting{
Enabled: true,
SwitchOnSuccess: true,
KeepOnChannelDisabled: false,
MaxEntries: 100_000,
DefaultTTLSeconds: 3600,
Rules: []ChannelAffinityRule{
{
Name: "codex cli trace",
ModelRegex: []string{"^gpt-.*$"},
PathRegex: []string{"/v1/responses"},
KeySources: []ChannelAffinityKeySource{
{Type: "gjson", Path: "prompt_cache_key"},
},
ValueRegex: "",
TTLSeconds: 0,
ParamOverrideTemplate: buildCodexPassHeaderTemplate(),
SkipRetryOnFailure: true,
IncludeUsingGroup: true,
IncludeRuleName: true,
UserAgentInclude: nil,
},
{
Name: "claude cli trace",
ModelRegex: []string{"^claude-.*$"},
PathRegex: []string{"/v1/messages"},
KeySources: []ChannelAffinityKeySource{
{Type: "gjson", Path: "metadata.user_id"},
},
ValueRegex: "",
TTLSeconds: 0,
ParamOverrideTemplate: buildPassHeaderTemplate(claudeCliPassThroughHeaders),
SkipRetryOnFailure: true,
IncludeUsingGroup: true,
IncludeRuleName: true,
UserAgentInclude: nil,
},
},
}
func init() {
config.GlobalConfig.Register("channel_affinity_setting", &channelAffinitySetting)
}
func GetChannelAffinitySetting() *ChannelAffinitySetting {
return &channelAffinitySetting
}
@@ -0,0 +1,37 @@
package operation_setting
import "github.com/QuantumNous/new-api/setting/config"
// CheckinSetting 签到功能配置
type CheckinSetting struct {
Enabled bool `json:"enabled"` // 是否启用签到功能
MinQuota int `json:"min_quota"` // 签到最小额度奖励
MaxQuota int `json:"max_quota"` // 签到最大额度奖励
}
// 默认配置
var checkinSetting = CheckinSetting{
Enabled: false, // 默认关闭
MinQuota: 1000, // 默认最小额度 1000 (约 0.002 USD)
MaxQuota: 10000, // 默认最大额度 10000 (约 0.02 USD)
}
func init() {
// 注册到全局配置管理器
config.GlobalConfig.Register("checkin_setting", &checkinSetting)
}
// GetCheckinSetting 获取签到配置
func GetCheckinSetting() *CheckinSetting {
return &checkinSetting
}
// IsCheckinEnabled 是否启用签到功能
func IsCheckinEnabled() bool {
return checkinSetting.Enabled
}
// GetCheckinQuotaRange 获取签到额度范围
func GetCheckinQuotaRange() (min, max int) {
return checkinSetting.MinQuota, checkinSetting.MaxQuota
}
@@ -0,0 +1,91 @@
package operation_setting
import "github.com/QuantumNous/new-api/setting/config"
// 额度展示类型
const (
QuotaDisplayTypeUSD = "USD"
QuotaDisplayTypeCNY = "CNY"
QuotaDisplayTypeTokens = "TOKENS"
QuotaDisplayTypeCustom = "CUSTOM"
)
type GeneralSetting struct {
DocsLink string `json:"docs_link"`
PingIntervalEnabled bool `json:"ping_interval_enabled"`
PingIntervalSeconds int `json:"ping_interval_seconds"`
// 当前站点额度展示类型:USD / CNY / TOKENS
QuotaDisplayType string `json:"quota_display_type"`
// 自定义货币符号,用于 CUSTOM 展示类型
CustomCurrencySymbol string `json:"custom_currency_symbol"`
// 自定义货币与美元汇率(1 USD = X Custom)
CustomCurrencyExchangeRate float64 `json:"custom_currency_exchange_rate"`
}
// 默认配置
var generalSetting = GeneralSetting{
DocsLink: "https://docs.newapi.pro",
PingIntervalEnabled: false,
PingIntervalSeconds: 60,
QuotaDisplayType: QuotaDisplayTypeUSD,
CustomCurrencySymbol: "¤",
CustomCurrencyExchangeRate: 1.0,
}
func init() {
// 注册到全局配置管理器
config.GlobalConfig.Register("general_setting", &generalSetting)
}
func GetGeneralSetting() *GeneralSetting {
return &generalSetting
}
// IsCurrencyDisplay 是否以货币形式展示(美元或人民币)
func IsCurrencyDisplay() bool {
return generalSetting.QuotaDisplayType != QuotaDisplayTypeTokens
}
// IsCNYDisplay 是否以人民币展示
func IsCNYDisplay() bool {
return generalSetting.QuotaDisplayType == QuotaDisplayTypeCNY
}
// GetQuotaDisplayType 返回额度展示类型
func GetQuotaDisplayType() string {
return generalSetting.QuotaDisplayType
}
// GetCurrencySymbol 返回当前展示类型对应符号
func GetCurrencySymbol() string {
switch generalSetting.QuotaDisplayType {
case QuotaDisplayTypeUSD:
return "$"
case QuotaDisplayTypeCNY:
return "¥"
case QuotaDisplayTypeCustom:
if generalSetting.CustomCurrencySymbol != "" {
return generalSetting.CustomCurrencySymbol
}
return "¤"
default:
return ""
}
}
// GetUsdToCurrencyRate 返回 1 USD = X <currency> 的 X(TOKENS 不适用)
func GetUsdToCurrencyRate(usdToCny float64) float64 {
switch generalSetting.QuotaDisplayType {
case QuotaDisplayTypeUSD:
return 1
case QuotaDisplayTypeCNY:
return usdToCny
case QuotaDisplayTypeCustom:
if generalSetting.CustomCurrencyExchangeRate > 0 {
return generalSetting.CustomCurrencyExchangeRate
}
return 1
default:
return 1
}
}
@@ -0,0 +1,81 @@
package operation_setting
import (
"fmt"
"os"
"strconv"
"github.com/QuantumNous/new-api/setting/config"
)
type MonitorSetting struct {
AutoTestChannelEnabled bool `json:"auto_test_channel_enabled"`
AutoTestChannelMinutes float64 `json:"auto_test_channel_minutes"`
ChannelTestMode string `json:"channel_test_mode"`
ChannelTestConcurrency int `json:"channel_test_concurrency"`
}
const (
ChannelTestModeScheduledAll = "scheduled_all"
ChannelTestModeAutoBanOnly = "auto_ban_only"
ChannelTestModePassiveRecovery = "passive_recovery"
ChannelTestConcurrencyOptionKey = "monitor_setting.channel_test_concurrency"
DefaultChannelTestConcurrency = 1
MaxChannelTestConcurrency = 32
)
// 默认配置
var monitorSetting = MonitorSetting{
AutoTestChannelEnabled: false,
AutoTestChannelMinutes: 10,
ChannelTestMode: ChannelTestModeScheduledAll,
ChannelTestConcurrency: DefaultChannelTestConcurrency,
}
func init() {
// 注册到全局配置管理器
config.GlobalConfig.Register("monitor_setting", &monitorSetting)
}
func GetMonitorSetting() *MonitorSetting {
if os.Getenv("CHANNEL_TEST_FREQUENCY") != "" {
frequency, err := strconv.Atoi(os.Getenv("CHANNEL_TEST_FREQUENCY"))
if err == nil && frequency > 0 {
monitorSetting.AutoTestChannelEnabled = true
monitorSetting.AutoTestChannelMinutes = float64(frequency)
monitorSetting.ChannelTestMode = ChannelTestModeScheduledAll
}
}
if enabled, ok := os.LookupEnv("CHANNEL_TEST_ENABLED"); ok {
parsed, err := strconv.ParseBool(enabled)
if err == nil {
monitorSetting.AutoTestChannelEnabled = parsed
}
}
switch monitorSetting.ChannelTestMode {
case ChannelTestModeAutoBanOnly, ChannelTestModePassiveRecovery:
default:
monitorSetting.ChannelTestMode = ChannelTestModeScheduledAll
}
monitorSetting.ChannelTestConcurrency = NormalizeChannelTestConcurrency(monitorSetting.ChannelTestConcurrency)
return &monitorSetting
}
func NormalizeChannelTestConcurrency(concurrency int) int {
if concurrency < 1 {
return DefaultChannelTestConcurrency
}
if concurrency > MaxChannelTestConcurrency {
return MaxChannelTestConcurrency
}
return concurrency
}
func ValidateChannelTestConcurrency(value string) error {
concurrency, err := strconv.Atoi(value)
if err != nil || concurrency < 1 || concurrency > MaxChannelTestConcurrency {
return fmt.Errorf("channel test concurrency must be between 1 and %d", MaxChannelTestConcurrency)
}
return nil
}
@@ -0,0 +1,91 @@
package operation_setting
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetMonitorSetting_ChannelTestEnabledEnvOverridesEnabledConfig(t *testing.T) {
orig := monitorSetting
t.Cleanup(func() { monitorSetting = orig })
t.Setenv("CHANNEL_TEST_ENABLED", "false")
t.Setenv("CHANNEL_TEST_FREQUENCY", "5")
monitorSetting = MonitorSetting{
AutoTestChannelEnabled: true,
AutoTestChannelMinutes: 20,
}
setting := GetMonitorSetting()
require.NotNil(t, setting)
assert.False(t, setting.AutoTestChannelEnabled)
assert.Equal(t, float64(5), setting.AutoTestChannelMinutes)
}
func TestGetMonitorSetting_ChannelTestEnabledEnvCanEnableDisabledConfig(t *testing.T) {
orig := monitorSetting
t.Cleanup(func() { monitorSetting = orig })
t.Setenv("CHANNEL_TEST_ENABLED", "true")
monitorSetting = MonitorSetting{
AutoTestChannelEnabled: false,
AutoTestChannelMinutes: 12,
}
setting := GetMonitorSetting()
require.NotNil(t, setting)
assert.True(t, setting.AutoTestChannelEnabled)
assert.Equal(t, float64(12), setting.AutoTestChannelMinutes)
}
func TestGetMonitorSettingPreservesAutoBanOnlyMode(t *testing.T) {
orig := monitorSetting
t.Cleanup(func() { monitorSetting = orig })
t.Setenv("CHANNEL_TEST_ENABLED", "")
t.Setenv("CHANNEL_TEST_FREQUENCY", "")
monitorSetting = MonitorSetting{ChannelTestMode: ChannelTestModeAutoBanOnly}
setting := GetMonitorSetting()
require.NotNil(t, setting)
assert.Equal(t, ChannelTestModeAutoBanOnly, setting.ChannelTestMode)
}
func TestGetMonitorSettingNormalizesChannelTestConcurrency(t *testing.T) {
orig := monitorSetting
t.Cleanup(func() { monitorSetting = orig })
tests := []struct {
name string
concurrency int
want int
}{
{name: "missing uses safe default", concurrency: 0, want: DefaultChannelTestConcurrency},
{name: "configured value is preserved", concurrency: 8, want: 8},
{name: "oversized value is capped", concurrency: MaxChannelTestConcurrency + 1, want: MaxChannelTestConcurrency},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
monitorSetting = MonitorSetting{ChannelTestConcurrency: test.concurrency}
setting := GetMonitorSetting()
require.NotNil(t, setting)
assert.Equal(t, test.want, setting.ChannelTestConcurrency)
})
}
}
func TestValidateChannelTestConcurrency(t *testing.T) {
require.NoError(t, ValidateChannelTestConcurrency("1"))
require.NoError(t, ValidateChannelTestConcurrency("32"))
assert.Error(t, ValidateChannelTestConcurrency("0"))
assert.Error(t, ValidateChannelTestConcurrency("33"))
assert.Error(t, ValidateChannelTestConcurrency("1.5"))
}
@@ -0,0 +1,32 @@
package operation_setting
import "strings"
var DemoSiteEnabled = false
var SelfUseModeEnabled = false
var AutomaticDisableKeywords = []string{
"Your credit balance is too low",
"This organization has been disabled.",
"You exceeded your current quota",
"Permission denied",
"The security token included in the request is invalid",
"Operation not allowed",
"Your account is not authorized",
}
func AutomaticDisableKeywordsToString() string {
return strings.Join(AutomaticDisableKeywords, "\n")
}
func AutomaticDisableKeywordsFromString(s string) {
AutomaticDisableKeywords = []string{}
ak := strings.SplitSeq(s, "\n")
for k := range ak {
k = strings.TrimSpace(k)
k = strings.ToLower(k)
if k != "" {
AutomaticDisableKeywords = append(AutomaticDisableKeywords, k)
}
}
}
@@ -0,0 +1,36 @@
package operation_setting
import "github.com/QuantumNous/new-api/setting/config"
type PaymentSetting struct {
AmountOptions []int `json:"amount_options"`
AmountDiscount map[int]float64 `json:"amount_discount"` // 充值金额对应的折扣,例如 100 元 0.9 表示 100 元充值享受 9 折优惠
ComplianceConfirmed bool `json:"compliance_confirmed"`
ComplianceTermsVersion string `json:"compliance_terms_version"`
ComplianceConfirmedAt int64 `json:"compliance_confirmed_at"`
ComplianceConfirmedBy int `json:"compliance_confirmed_by"`
ComplianceConfirmedIP string `json:"compliance_confirmed_ip"`
}
const CurrentComplianceTermsVersion = "v1"
// 默认配置
var paymentSetting = PaymentSetting{
AmountOptions: []int{10, 20, 50, 100, 200, 500},
AmountDiscount: map[int]float64{},
}
func init() {
// 注册到全局配置管理器
config.GlobalConfig.Register("payment_setting", &paymentSetting)
}
func GetPaymentSetting() *PaymentSetting {
return &paymentSetting
}
func IsPaymentComplianceConfirmed() bool {
return paymentSetting.ComplianceConfirmed &&
paymentSetting.ComplianceTermsVersion == CurrentComplianceTermsVersion
}
@@ -0,0 +1,59 @@
/**
此文件为旧版支付设置文件,如需增加新的参数、变量等,请在 payment_setting.go 中添加
This file is the old version of the payment settings file. If you need to add new parameters, variables, etc., please add them in payment_setting.go
*/
package operation_setting
import (
"github.com/QuantumNous/new-api/common"
)
var PayAddress = ""
var CustomCallbackAddress = ""
var EpayId = ""
var EpayKey = ""
var Price = 7.3
var MinTopUp = 1
var USDExchangeRate = 7.3
var PayMethods = []map[string]string{
{
"name": "支付宝",
"icon": "SiAlipay",
"type": "alipay",
},
{
"name": "微信",
"icon": "SiWechat",
"type": "wxpay",
},
{
"name": "自定义1",
"icon": "LuCreditCard",
"type": "custom1",
"min_topup": "50",
},
}
func UpdatePayMethodsByJsonString(jsonString string) error {
PayMethods = make([]map[string]string, 0)
return common.Unmarshal([]byte(jsonString), &PayMethods)
}
func PayMethods2JsonString() string {
jsonBytes, err := common.Marshal(PayMethods)
if err != nil {
return "[]"
}
return string(jsonBytes)
}
func ContainsPayMethod(method string) bool {
for _, payMethod := range PayMethods {
if payMethod["type"] == method {
return true
}
}
return false
}
@@ -0,0 +1,21 @@
package operation_setting
import "github.com/QuantumNous/new-api/setting/config"
type QuotaSetting struct {
EnableFreeModelPreConsume bool `json:"enable_free_model_pre_consume"` // 是否对免费模型启用预消耗
}
// 默认配置
var quotaSetting = QuotaSetting{
EnableFreeModelPreConsume: true,
}
func init() {
// 注册到全局配置管理器
config.GlobalConfig.Register("quota_setting", &quotaSetting)
}
func GetQuotaSetting() *QuotaSetting {
return &quotaSetting
}
@@ -0,0 +1,208 @@
package operation_setting
import (
"fmt"
"sort"
"strconv"
"strings"
"github.com/QuantumNous/new-api/relaykit/types"
)
type StatusCodeRange struct {
Start int
End int
}
var AutomaticDisableStatusCodeRanges = []StatusCodeRange{{Start: 401, End: 401}}
// Default behavior matches legacy hardcoded retry rules in controller/relay.go shouldRetry:
// retry for 1xx, 3xx, 4xx(except 400/408), 5xx(except 504/524), and no retry for 2xx.
var AutomaticRetryStatusCodeRanges = []StatusCodeRange{
{Start: 100, End: 199},
{Start: 300, End: 399},
{Start: 401, End: 407},
{Start: 409, End: 499},
{Start: 500, End: 503},
{Start: 505, End: 523},
{Start: 525, End: 599},
}
var alwaysSkipRetryStatusCodes = map[int]struct{}{
504: {},
524: {},
}
var alwaysSkipRetryCodes = map[types.ErrorCode]struct{}{
types.ErrorCodeBadResponseBody: {},
}
func AutomaticDisableStatusCodesToString() string {
return statusCodeRangesToString(AutomaticDisableStatusCodeRanges)
}
func AutomaticDisableStatusCodesFromString(s string) error {
ranges, err := ParseHTTPStatusCodeRanges(s)
if err != nil {
return err
}
AutomaticDisableStatusCodeRanges = ranges
return nil
}
func ShouldDisableByStatusCode(code int) bool {
return shouldMatchStatusCodeRanges(AutomaticDisableStatusCodeRanges, code)
}
func AutomaticRetryStatusCodesToString() string {
return statusCodeRangesToString(AutomaticRetryStatusCodeRanges)
}
func AutomaticRetryStatusCodesFromString(s string) error {
ranges, err := ParseHTTPStatusCodeRanges(s)
if err != nil {
return err
}
AutomaticRetryStatusCodeRanges = ranges
return nil
}
func IsAlwaysSkipRetryStatusCode(code int) bool {
_, exists := alwaysSkipRetryStatusCodes[code]
return exists
}
func IsAlwaysSkipRetryCode(errorCode types.ErrorCode) bool {
_, exists := alwaysSkipRetryCodes[errorCode]
return exists
}
func ShouldRetryByStatusCode(code int) bool {
if IsAlwaysSkipRetryStatusCode(code) {
return false
}
return shouldMatchStatusCodeRanges(AutomaticRetryStatusCodeRanges, code)
}
func statusCodeRangesToString(ranges []StatusCodeRange) string {
if len(ranges) == 0 {
return ""
}
parts := make([]string, 0, len(ranges))
for _, r := range ranges {
if r.Start == r.End {
parts = append(parts, strconv.Itoa(r.Start))
continue
}
parts = append(parts, fmt.Sprintf("%d-%d", r.Start, r.End))
}
return strings.Join(parts, ",")
}
func shouldMatchStatusCodeRanges(ranges []StatusCodeRange, code int) bool {
if code < 100 || code > 599 {
return false
}
for _, r := range ranges {
if code < r.Start {
return false
}
if code <= r.End {
return true
}
}
return false
}
func ParseHTTPStatusCodeRanges(input string) ([]StatusCodeRange, error) {
input = strings.TrimSpace(input)
if input == "" {
return nil, nil
}
input = strings.NewReplacer(",", ",").Replace(input)
segments := strings.Split(input, ",")
var ranges []StatusCodeRange
var invalid []string
for _, seg := range segments {
seg = strings.TrimSpace(seg)
if seg == "" {
continue
}
r, err := parseHTTPStatusCodeToken(seg)
if err != nil {
invalid = append(invalid, seg)
continue
}
ranges = append(ranges, r)
}
if len(invalid) > 0 {
return nil, fmt.Errorf("invalid http status code rules: %s", strings.Join(invalid, ", "))
}
if len(ranges) == 0 {
return nil, nil
}
sort.Slice(ranges, func(i, j int) bool {
if ranges[i].Start == ranges[j].Start {
return ranges[i].End < ranges[j].End
}
return ranges[i].Start < ranges[j].Start
})
merged := []StatusCodeRange{ranges[0]}
for _, r := range ranges[1:] {
last := &merged[len(merged)-1]
if r.Start <= last.End+1 {
if r.End > last.End {
last.End = r.End
}
continue
}
merged = append(merged, r)
}
return merged, nil
}
func parseHTTPStatusCodeToken(token string) (StatusCodeRange, error) {
token = strings.TrimSpace(token)
token = strings.ReplaceAll(token, " ", "")
if token == "" {
return StatusCodeRange{}, fmt.Errorf("empty token")
}
if strings.Contains(token, "-") {
parts := strings.Split(token, "-")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return StatusCodeRange{}, fmt.Errorf("invalid range token: %s", token)
}
start, err := strconv.Atoi(parts[0])
if err != nil {
return StatusCodeRange{}, fmt.Errorf("invalid range start: %s", token)
}
end, err := strconv.Atoi(parts[1])
if err != nil {
return StatusCodeRange{}, fmt.Errorf("invalid range end: %s", token)
}
if start > end {
return StatusCodeRange{}, fmt.Errorf("range start > end: %s", token)
}
if start < 100 || end > 599 {
return StatusCodeRange{}, fmt.Errorf("range out of bounds: %s", token)
}
return StatusCodeRange{Start: start, End: end}, nil
}
code, err := strconv.Atoi(token)
if err != nil {
return StatusCodeRange{}, fmt.Errorf("invalid status code: %s", token)
}
if code < 100 || code > 599 {
return StatusCodeRange{}, fmt.Errorf("status code out of bounds: %s", token)
}
return StatusCodeRange{Start: code, End: code}, nil
}
@@ -0,0 +1,87 @@
package operation_setting
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestParseHTTPStatusCodeRanges_CommaSeparated(t *testing.T) {
ranges, err := ParseHTTPStatusCodeRanges("401,403,500-599")
require.NoError(t, err)
require.Equal(t, []StatusCodeRange{
{Start: 401, End: 401},
{Start: 403, End: 403},
{Start: 500, End: 599},
}, ranges)
}
func TestParseHTTPStatusCodeRanges_MergeAndNormalize(t *testing.T) {
ranges, err := ParseHTTPStatusCodeRanges("500-505,504,401,403,402")
require.NoError(t, err)
require.Equal(t, []StatusCodeRange{
{Start: 401, End: 403},
{Start: 500, End: 505},
}, ranges)
}
func TestParseHTTPStatusCodeRanges_Invalid(t *testing.T) {
_, err := ParseHTTPStatusCodeRanges("99,600,foo,500-400,500-")
require.Error(t, err)
}
func TestParseHTTPStatusCodeRanges_NoComma_IsInvalid(t *testing.T) {
_, err := ParseHTTPStatusCodeRanges("401 403")
require.Error(t, err)
}
func TestShouldDisableByStatusCode(t *testing.T) {
orig := AutomaticDisableStatusCodeRanges
t.Cleanup(func() { AutomaticDisableStatusCodeRanges = orig })
AutomaticDisableStatusCodeRanges = []StatusCodeRange{
{Start: 401, End: 403},
{Start: 500, End: 599},
}
require.True(t, ShouldDisableByStatusCode(401))
require.True(t, ShouldDisableByStatusCode(403))
require.False(t, ShouldDisableByStatusCode(404))
require.True(t, ShouldDisableByStatusCode(500))
require.False(t, ShouldDisableByStatusCode(200))
}
func TestShouldRetryByStatusCode(t *testing.T) {
orig := AutomaticRetryStatusCodeRanges
t.Cleanup(func() { AutomaticRetryStatusCodeRanges = orig })
AutomaticRetryStatusCodeRanges = []StatusCodeRange{
{Start: 429, End: 429},
{Start: 500, End: 599},
}
require.True(t, ShouldRetryByStatusCode(429))
require.True(t, ShouldRetryByStatusCode(500))
require.False(t, ShouldRetryByStatusCode(504))
require.False(t, ShouldRetryByStatusCode(524))
require.False(t, ShouldRetryByStatusCode(400))
require.False(t, ShouldRetryByStatusCode(200))
}
func TestShouldRetryByStatusCode_DefaultMatchesLegacyBehavior(t *testing.T) {
require.False(t, ShouldRetryByStatusCode(200))
require.False(t, ShouldRetryByStatusCode(400))
require.True(t, ShouldRetryByStatusCode(401))
require.False(t, ShouldRetryByStatusCode(408))
require.True(t, ShouldRetryByStatusCode(429))
require.True(t, ShouldRetryByStatusCode(500))
require.False(t, ShouldRetryByStatusCode(504))
require.False(t, ShouldRetryByStatusCode(524))
require.True(t, ShouldRetryByStatusCode(599))
}
func TestIsAlwaysSkipRetryStatusCode(t *testing.T) {
require.True(t, IsAlwaysSkipRetryStatusCode(504))
require.True(t, IsAlwaysSkipRetryStatusCode(524))
require.False(t, IsAlwaysSkipRetryStatusCode(500))
}
@@ -0,0 +1,28 @@
package operation_setting
import "github.com/QuantumNous/new-api/setting/config"
// TokenSetting 令牌相关配置
type TokenSetting struct {
MaxUserTokens int `json:"max_user_tokens"` // 每用户最大令牌数量
}
// 默认配置
var tokenSetting = TokenSetting{
MaxUserTokens: 1000, // 默认每用户最多 1000 个令牌
}
func init() {
// 注册到全局配置管理器
config.GlobalConfig.Register("token_setting", &tokenSetting)
}
// GetTokenSetting 获取令牌配置
func GetTokenSetting() *TokenSetting {
return &tokenSetting
}
// GetMaxUserTokens 获取每用户最大令牌数量
func GetMaxUserTokens() int {
return GetTokenSetting().MaxUserTokens
}
+269
View File
@@ -0,0 +1,269 @@
package operation_setting
import (
"encoding/json"
"fmt"
"math"
"sort"
"strings"
"sync/atomic"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/config"
)
// ---------------------------------------------------------------------------
// Tool call prices ($/1K calls, admin-configurable)
// DB key: tool_price_setting.prices
//
// Key format:
// - "tool_name" → default price for all models
// - "tool_name:model_prefix*" → override for models matching the prefix
//
// 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.
// ---------------------------------------------------------------------------
const ToolPriceOptionKey = "tool_price_setting.prices"
const (
defaultWebSearchToolPrice = 10.0
defaultWebSearchPreviewToolPrice = 10.0
defaultFileSearchToolPrice = 2.5
defaultGoogleSearchToolPrice = 14.0
defaultImageGenerationToolPrice = 150.0
defaultSearchPreviewModelPrice = 25.0
)
// seedHardcodedToolPrices injects compile-time built-in fallbacks (tool
// defaults and model-prefix overrides) into the destination. The source is
// constants, not a mutable package map or operator configuration.
func seedHardcodedToolPrices(prices map[string]float64) {
prices["web_search"] = defaultWebSearchToolPrice
prices["web_search_preview"] = defaultWebSearchPreviewToolPrice
prices["file_search"] = defaultFileSearchToolPrice
prices["google_search"] = defaultGoogleSearchToolPrice
prices["image_generation"] = defaultImageGenerationToolPrice
prices["web_search_preview:gpt-4o*"] = defaultSearchPreviewModelPrice
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
}
// ToolPriceSetting is managed by config.GlobalConfig.Register.
// Prices holds operator overrides only; hardcoded fallbacks live in the index.
type ToolPriceSetting struct {
Prices map[string]float64 `json:"prices"`
}
var toolPriceSetting = ToolPriceSetting{
Prices: make(map[string]float64),
}
func init() {
config.GlobalConfig.Register("tool_price_setting", &toolPriceSetting)
RebuildToolPriceIndex()
}
// ---------------------------------------------------------------------------
// Precomputed price index (atomic, lock-free on read path)
// ---------------------------------------------------------------------------
type prefixEntry struct {
prefix string
price float64
}
type toolPriceIndex struct {
defaults map[string]float64
prefixes map[string][]prefixEntry
}
var currentIndex atomic.Pointer[toolPriceIndex]
func isValidToolPrice(price float64) bool {
return price >= 0 && !math.IsNaN(price) && !math.IsInf(price, 0)
}
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 对象")
}
var rawPrices map[string]json.RawMessage
if err := common.Unmarshal(rawValue, &rawPrices); err != nil {
return nil, fmt.Errorf("解析工具价格失败: %w", err)
}
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)
} else {
var price float64
if err := common.Unmarshal(rawPrice, &price); err != nil {
entryErr = fmt.Errorf("解析工具价格 %q 失败: %w", name, err)
} else if !isValidToolPrice(price) {
entryErr = fmt.Errorf("工具价格 %q 必须是有限的非负数字", name)
} else {
prices[name] = price
}
}
if entryErr == nil {
continue
}
if !ignoreInvalidEntries {
return nil, entryErr
}
common.SysError(entryErr.Error())
}
return prices, nil
}
// ValidateToolPricesJSON validates an operator-supplied complete price map.
// A numeric zero is valid and intentionally disables the matching rule.
func ValidateToolPricesJSON(value string) error {
_, err := decodeToolPricesJSON(value, false)
return err
}
// LoadToolPricesFromJSONString replaces the complete operator price map.
// Invalid legacy entries are ignored individually so valid sibling overrides
// survive, while missing built-in keys continue to use hardcoded fallbacks.
func LoadToolPricesFromJSONString(value string) {
prices, err := decodeToolPricesJSON(value, true)
if err != nil {
common.SysError("加载工具价格失败,将使用硬编码兜底: " + err.Error())
prices = make(map[string]float64)
}
toolPriceSetting.Prices = prices
RebuildToolPriceIndex()
}
// RebuildToolPriceIndex rebuilds the lookup index from the current config.
// Called on init and after config updates. Not on the billing hot path.
func RebuildToolPriceIndex() {
merged := make(map[string]float64, 9+len(toolPriceSetting.Prices))
seedHardcodedToolPrices(merged)
for k, v := range toolPriceSetting.Prices {
if !isValidToolPrice(v) {
continue
}
merged[k] = v
}
idx := &toolPriceIndex{
defaults: make(map[string]float64),
prefixes: make(map[string][]prefixEntry),
}
for key, price := range merged {
before, after, ok := strings.Cut(key, ":")
if !ok {
idx.defaults[key] = price
continue
}
toolName := before
modelPart := after
prefix := strings.TrimSuffix(modelPart, "*")
idx.prefixes[toolName] = append(idx.prefixes[toolName], prefixEntry{prefix: prefix, price: price})
}
for tool := range idx.prefixes {
entries := idx.prefixes[tool]
sort.Slice(entries, func(i, j int) bool {
if len(entries[i].prefix) == len(entries[j].prefix) {
return entries[i].prefix < entries[j].prefix
}
return len(entries[i].prefix) > len(entries[j].prefix)
})
idx.prefixes[tool] = entries
}
currentIndex.Store(idx)
}
// GetToolPriceForModel returns the price ($/1K calls) for a tool given a model name.
// Lookup: longest prefix match → tool default → 0.
func GetToolPriceForModel(toolName, modelName string) float64 {
idx := currentIndex.Load()
if idx == nil {
RebuildToolPriceIndex()
idx = currentIndex.Load()
if idx == nil {
return 0
}
}
if entries, ok := idx.prefixes[toolName]; ok && modelName != "" {
for _, e := range entries {
if strings.HasPrefix(modelName, e.prefix) {
return e.price
}
}
}
if p, ok := idx.defaults[toolName]; ok {
return p
}
return 0
}
// GetToolPrice is a convenience wrapper when no model name is needed.
func GetToolPrice(toolName string) float64 {
return GetToolPriceForModel(toolName, "")
}
// SetToolPriceForTest injects a tool price and rebuilds the lookup index. Tests only.
func SetToolPriceForTest(name string, price float64) {
if toolPriceSetting.Prices == nil {
toolPriceSetting.Prices = make(map[string]float64)
}
toolPriceSetting.Prices[name] = price
RebuildToolPriceIndex()
}
// DeleteToolPriceForTest removes an injected tool price and rebuilds the index. Tests only.
func DeleteToolPriceForTest(name string) {
delete(toolPriceSetting.Prices, name)
RebuildToolPriceIndex()
}
// ---------------------------------------------------------------------------
// Gemini audio input pricing (per-million tokens, model-specific)
// ---------------------------------------------------------------------------
const (
Gemini25FlashPreviewInputAudioPrice = 1.00
Gemini25FlashProductionInputAudioPrice = 1.00
Gemini25FlashLitePreviewInputAudioPrice = 0.50
Gemini25FlashNativeAudioInputAudioPrice = 3.00
Gemini20FlashInputAudioPrice = 0.70
GeminiRoboticsER15InputAudioPrice = 1.00
)
func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-native-audio") {
return Gemini25FlashNativeAudioInputAudioPrice
}
if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-lite") {
return Gemini25FlashLitePreviewInputAudioPrice
}
if strings.HasPrefix(modelName, "gemini-2.5-flash-preview") {
return Gemini25FlashPreviewInputAudioPrice
}
if strings.HasPrefix(modelName, "gemini-2.5-flash") {
return Gemini25FlashProductionInputAudioPrice
}
if strings.HasPrefix(modelName, "gemini-2.0-flash") {
return Gemini20FlashInputAudioPrice
}
if strings.HasPrefix(modelName, "gemini-robotics-er-1.5") {
return GeminiRoboticsER15InputAudioPrice
}
return 0
}
@@ -0,0 +1,154 @@
package operation_setting
import (
"maps"
"math"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func preserveToolPrices(t *testing.T) {
t.Helper()
original := make(map[string]float64, len(toolPriceSetting.Prices))
maps.Copy(original, toolPriceSetting.Prices)
t.Cleanup(func() {
toolPriceSetting.Prices = original
RebuildToolPriceIndex()
})
}
func TestToolPriceHardcodedFallbacksSurviveMissingOperatorConfig(t *testing.T) {
preserveToolPrices(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,
}
for name, expected := range expectedDefaults {
assert.Equal(t, expected, GetToolPrice(name), name)
}
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"))
}
func TestToolPriceOperatorOverridePrecedenceAndExplicitZero(t *testing.T) {
preserveToolPrices(t)
toolPriceSetting.Prices = map[string]float64{
"image_generation": 0,
"web_search": 12,
"web_search_preview": 0,
"web_search_preview:gpt-4o*": 30,
"web_search_preview:gpt-4o-mini*": 0,
"web_search_preview:custom-model*": 7,
}
RebuildToolPriceIndex()
assert.Equal(t, 0.0, GetToolPrice("image_generation"))
assert.Equal(t, 12.0, GetToolPrice("web_search"))
assert.Equal(t, 0.0, GetToolPriceForModel("web_search_preview", "o1"))
assert.Equal(t, 30.0, GetToolPriceForModel("web_search_preview", "gpt-4o"))
assert.Equal(t, 0.0, GetToolPriceForModel("web_search_preview", "gpt-4o-mini"))
assert.Equal(t, 25.0, GetToolPriceForModel("web_search_preview", "gpt-4.1"))
assert.Equal(t, 7.0, GetToolPriceForModel("web_search_preview", "custom-model-v2"))
delete(toolPriceSetting.Prices, "web_search_preview:gpt-4o*")
RebuildToolPriceIndex()
assert.Equal(t, 25.0, GetToolPriceForModel("web_search_preview", "gpt-4o"))
delete(toolPriceSetting.Prices, "web_search")
RebuildToolPriceIndex()
assert.Equal(t, 10.0, GetToolPrice("web_search"))
}
func TestToolPriceCustomFunctionHasNoHardcodedFallback(t *testing.T) {
preserveToolPrices(t)
toolPriceSetting.Prices = map[string]float64{}
RebuildToolPriceIndex()
assert.Equal(t, 0.0, GetToolPrice("lookup_customer"))
toolPriceSetting.Prices["lookup_customer"] = 5
RebuildToolPriceIndex()
assert.Equal(t, 5.0, GetToolPrice("lookup_customer"))
toolPriceSetting.Prices["lookup_customer"] = 0
RebuildToolPriceIndex()
assert.Equal(t, 0.0, GetToolPrice("lookup_customer"))
}
func TestValidateToolPricesJSON(t *testing.T) {
valid := []string{
`{}`,
`{"web_search":0}`,
`{"web_search":10,"custom_fn":2.5}`,
}
for _, value := range valid {
assert.NoError(t, ValidateToolPricesJSON(value), value)
}
invalid := []string{
`null`,
`[]`,
`{"web_search":null}`,
`{"web_search":true}`,
`{"web_search":"0"}`,
`{"web_search":-1}`,
`{"web_search":1e999}`,
`{"web_search":`,
}
for _, value := range invalid {
assert.Error(t, ValidateToolPricesJSON(value), value)
}
}
func TestLoadToolPricesFromJSONStringReplacesMapAndKeepsValidSiblings(t *testing.T) {
preserveToolPrices(t)
LoadToolPricesFromJSONString(`{
"web_search": 0,
"custom_fn": 3,
"file_search": null,
"google_search": -1,
"image_generation": "0"
}`)
require.Len(t, toolPriceSetting.Prices, 2)
assert.Equal(t, 0.0, toolPriceSetting.Prices["web_search"])
assert.Equal(t, 3.0, toolPriceSetting.Prices["custom_fn"])
assert.Equal(t, 0.0, GetToolPrice("web_search"))
assert.Equal(t, 3.0, GetToolPrice("custom_fn"))
assert.Equal(t, 2.5, GetToolPrice("file_search"))
assert.Equal(t, 14.0, GetToolPrice("google_search"))
assert.Equal(t, 150.0, GetToolPrice("image_generation"))
LoadToolPricesFromJSONString(`{"image_generation":0}`)
require.Len(t, toolPriceSetting.Prices, 1)
assert.NotContains(t, toolPriceSetting.Prices, "web_search")
assert.NotContains(t, toolPriceSetting.Prices, "custom_fn")
assert.Equal(t, 10.0, GetToolPrice("web_search"))
assert.Equal(t, 0.0, GetToolPrice("custom_fn"))
assert.Equal(t, 0.0, GetToolPrice("image_generation"))
}
func TestRebuildToolPriceIndexIgnoresInvalidDirectValues(t *testing.T) {
preserveToolPrices(t)
toolPriceSetting.Prices = map[string]float64{
"web_search": -1,
"file_search": math.Inf(1),
"image_generation": math.NaN(),
"custom_fn": math.NaN(),
}
RebuildToolPriceIndex()
assert.Equal(t, 10.0, GetToolPrice("web_search"))
assert.Equal(t, 2.5, GetToolPrice("file_search"))
assert.Equal(t, 150.0, GetToolPrice("image_generation"))
assert.Equal(t, 0.0, GetToolPrice("custom_fn"))
}