feat: publish v1.0.0-rc.36-custom.1
This commit is contained in:
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", "aSetting)
|
||||
}
|
||||
|
||||
func GetQuotaSetting() *QuotaSetting {
|
||||
return "aSetting
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
Reference in new issue
Block a user