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,66 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
)
|
||||
|
||||
const DefaultMaxTokenAutoGroups = 5
|
||||
|
||||
var autoGroups = []string{
|
||||
"default",
|
||||
}
|
||||
|
||||
var DefaultUseAutoGroup = false
|
||||
|
||||
var maxTokenAutoGroups atomic.Int64
|
||||
|
||||
func init() {
|
||||
maxTokenAutoGroups.Store(DefaultMaxTokenAutoGroups)
|
||||
}
|
||||
|
||||
func ContainsAutoGroup(group string) bool {
|
||||
return slices.Contains(autoGroups, group)
|
||||
}
|
||||
|
||||
func UpdateAutoGroupsByJsonString(jsonString string) error {
|
||||
autoGroups = make([]string, 0)
|
||||
return common.Unmarshal([]byte(jsonString), &autoGroups)
|
||||
}
|
||||
|
||||
func AutoGroups2JsonString() string {
|
||||
jsonBytes, err := common.Marshal(autoGroups)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
func GetAutoGroups() []string {
|
||||
return autoGroups
|
||||
}
|
||||
|
||||
func GetMaxTokenAutoGroups() int {
|
||||
return int(maxTokenAutoGroups.Load())
|
||||
}
|
||||
|
||||
func ValidateMaxTokenAutoGroups(value string) error {
|
||||
maxCount, err := strconv.Atoi(value)
|
||||
if err != nil || maxCount <= 0 {
|
||||
return fmt.Errorf("MaxTokenAutoGroups must be a positive integer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func UpdateMaxTokenAutoGroups(value string) error {
|
||||
if err := ValidateMaxTokenAutoGroups(value); err != nil {
|
||||
return err
|
||||
}
|
||||
maxCount, _ := strconv.Atoi(value)
|
||||
maxTokenAutoGroups.Store(int64(maxCount))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUpdateMaxTokenAutoGroupsAcceptsAnyPositiveInteger(t *testing.T) {
|
||||
original := GetMaxTokenAutoGroups()
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, UpdateMaxTokenAutoGroups(fmt.Sprintf("%d", original)))
|
||||
})
|
||||
|
||||
require.NoError(t, UpdateMaxTokenAutoGroups("123456"))
|
||||
assert.Equal(t, 123456, GetMaxTokenAutoGroups())
|
||||
}
|
||||
|
||||
func TestUpdateMaxTokenAutoGroupsRejectsInvalidValuesWithoutChangingState(t *testing.T) {
|
||||
original := GetMaxTokenAutoGroups()
|
||||
for _, value := range []string{"", "0", "-1", "1.5", "not-a-number"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
assert.Error(t, UpdateMaxTokenAutoGroups(value))
|
||||
assert.Equal(t, original, GetMaxTokenAutoGroups())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package billing_setting
|
||||
|
||||
// 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{
|
||||
// https://developers.openai.com/api/docs/models/gpt-6-astra
|
||||
// Standard pricing; the long-context rates apply to the whole request.
|
||||
// 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)`,
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package billing_setting_test
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/controller"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/billing_setting"
|
||||
"github.com/QuantumNous/new-api/setting/config"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGPT6AstraBuiltinBilling(t *testing.T) {
|
||||
settings := config.GlobalConfig.Get("billing_setting").(*billing_setting.BillingSetting)
|
||||
saved := *settings
|
||||
savedRatios, savedPrices := ratio_setting.ModelRatio2JSONString(), ratio_setting.ModelPrice2JSONString()
|
||||
savedOptions := common.OptionMap
|
||||
t.Cleanup(func() {
|
||||
*settings, common.OptionMap = saved, savedOptions
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedRatios))
|
||||
require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(savedPrices))
|
||||
})
|
||||
common.OptionMap = map[string]string{"billing_setting.billing_mode": `{}`, "billing_setting.billing_expr": `{}`}
|
||||
require.NoError(t, config.GlobalConfig.LoadFromDB(common.OptionMap))
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{}`))
|
||||
require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{}`))
|
||||
assert.Equal(t, billing_setting.BillingModeTieredExpr, billing_setting.GetBillingMode("gpt-6-astra"))
|
||||
expression, ok := billing_setting.GetBillingExpr("gpt-6-astra")
|
||||
require.True(t, ok)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
input, output, cached, written int
|
||||
request string
|
||||
quota int
|
||||
}{
|
||||
{"standard", 1000, 100, 0, 0, `{}`, 7500},
|
||||
{"client flex cannot discount standard pricing", 1000, 100, 0, 0, `{"service_tier":"flex"}`, 7500},
|
||||
{"cache at context boundary", 272000, 1000, 200000, 20000, `{}`, 510000},
|
||||
{"whole request above boundary", 272001, 1000, 200000, 20000, `{}`, 1007510},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
usage := &dto.Usage{
|
||||
PromptTokens: tc.input, CompletionTokens: tc.output,
|
||||
PromptTokensDetails: dto.InputTokenDetails{CachedTokens: tc.cached, CacheWriteTokens: tc.written},
|
||||
}
|
||||
params := service.BuildTieredTokenParams(usage, false, billingexpr.UsedVars(expression))
|
||||
result, err := billingexpr.ComputeTieredQuotaWithRequest(&billingexpr.BillingSnapshot{
|
||||
ExprString: expression, GroupRatio: 1, QuotaPerUnit: 500000,
|
||||
}, params, billingexpr.RequestInput{Body: []byte(tc.request)})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.quota, result.ActualQuotaAfterGroup)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("admin options expose defaults without persisting them", func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
controller.GetOptions(ctx)
|
||||
var response struct {
|
||||
Success bool
|
||||
Data []model.Option
|
||||
}
|
||||
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
|
||||
require.True(t, response.Success)
|
||||
found := map[string]string{}
|
||||
for _, option := range response.Data {
|
||||
if _, ok := common.OptionMap[option.Key]; ok {
|
||||
assert.NotContains(t, found, option.Key)
|
||||
var values map[string]string
|
||||
require.NoError(t, common.UnmarshalJsonStr(option.Value, &values))
|
||||
found[option.Key] = values["gpt-6-astra"]
|
||||
assert.Equal(t, `{}`, common.OptionMap[option.Key])
|
||||
}
|
||||
}
|
||||
assert.Equal(t, map[string]string{"billing_setting.billing_mode": "tiered_expr", "billing_setting.billing_expr": expression}, found)
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, mode, expr, ratios, prices, wantMode string
|
||||
}{
|
||||
{"custom expression overrides legacy price", "tiered_expr", "p * 7", `{"gpt-6-astra":8}`, `{}`, "tiered_expr"},
|
||||
{"explicit ratio mode", "ratio", "", `{}`, `{}`, "ratio"},
|
||||
{"existing free token price", "", "", `{"gpt-6-astra":0}`, `{}`, "ratio"},
|
||||
{"existing per-call price", "", "", `{}`, `{"gpt-6-astra":0.1}`, "ratio"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
*settings = billing_setting.BillingSetting{BillingMode: map[string]string{}, BillingExpr: map[string]string{}}
|
||||
if tc.mode != "" {
|
||||
settings.BillingMode["gpt-6-astra"] = tc.mode
|
||||
}
|
||||
if tc.expr != "" {
|
||||
settings.BillingExpr["gpt-6-astra"] = tc.expr
|
||||
}
|
||||
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(tc.ratios))
|
||||
require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(tc.prices))
|
||||
assert.Equal(t, tc.wantMode, billing_setting.GetBillingMode("gpt-6-astra"))
|
||||
actual, ok := billing_setting.GetBillingExpr("gpt-6-astra")
|
||||
assert.Equal(t, tc.expr, actual)
|
||||
assert.Equal(t, tc.expr != "", ok)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package billing_setting
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
||||
"github.com/QuantumNous/new-api/pkg/jsplugin"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/setting/config"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
const (
|
||||
BillingModeRatio = "ratio"
|
||||
BillingModeTieredExpr = "tiered_expr"
|
||||
BillingModeField = "billing_mode"
|
||||
BillingExprField = "billing_expr"
|
||||
maxTaskExprSmokeTests = 64
|
||||
)
|
||||
|
||||
// BillingSetting is managed by config.GlobalConfig.Register.
|
||||
// DB keys: billing_setting.billing_mode, billing_setting.billing_expr
|
||||
type BillingSetting struct {
|
||||
BillingMode map[string]string `json:"billing_mode"`
|
||||
BillingExpr map[string]string `json:"billing_expr"`
|
||||
GroupBillingExpr map[string]map[string]string `json:"group_billing_expr"`
|
||||
}
|
||||
|
||||
var billingSetting = BillingSetting{
|
||||
BillingMode: make(map[string]string),
|
||||
BillingExpr: make(map[string]string),
|
||||
GroupBillingExpr: make(map[string]map[string]string),
|
||||
}
|
||||
|
||||
func init() {
|
||||
config.GlobalConfig.Register("billing_setting", &billingSetting)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read accessors (hot path, must be fast)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func GetBillingMode(model string) string {
|
||||
if mode, ok := billingSetting.BillingMode[model]; ok {
|
||||
return mode
|
||||
}
|
||||
if _, ok := builtinBillingExpr[model]; ok {
|
||||
// Existing administrator-configured legacy prices take precedence over
|
||||
// a newly introduced built-in expression unless a mode was explicit.
|
||||
if ratio_setting.HasConfiguredModelRatio(model) {
|
||||
return BillingModeRatio
|
||||
}
|
||||
if _, configured := ratio_setting.GetModelPrice(model, false); configured {
|
||||
return BillingModeRatio
|
||||
}
|
||||
return BillingModeTieredExpr
|
||||
}
|
||||
return BillingModeRatio
|
||||
}
|
||||
|
||||
func GetBillingExpr(model string) (string, bool) {
|
||||
if expr, ok := billingSetting.BillingExpr[model]; ok {
|
||||
return expr, true
|
||||
}
|
||||
if GetBillingMode(model) == BillingModeTieredExpr {
|
||||
expr, ok := builtinBillingExpr[model]
|
||||
return expr, ok
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func GetBuiltinBillingExpr(model string) (string, bool) {
|
||||
expression, ok := builtinBillingExpr[model]
|
||||
return expression, ok
|
||||
}
|
||||
|
||||
func GetBuiltinBillingExprCopy() map[string]string {
|
||||
return lo.Assign(builtinBillingExpr)
|
||||
}
|
||||
|
||||
func GetGroupBillingExpr(group, model string) (string, bool) {
|
||||
expr, ok := billingSetting.GroupBillingExpr[group][model]
|
||||
return expr, ok
|
||||
}
|
||||
|
||||
func GetBillingExprForGroup(group, model string) (string, bool) {
|
||||
if expr, ok := GetGroupBillingExpr(group, model); ok {
|
||||
return expr, true
|
||||
}
|
||||
return GetBillingExpr(model)
|
||||
}
|
||||
|
||||
func GetBillingModeCopy() map[string]string {
|
||||
modes := lo.Assign(billingSetting.BillingMode)
|
||||
for model := range builtinBillingExpr {
|
||||
if _, configured := modes[model]; !configured && GetBillingMode(model) == BillingModeTieredExpr {
|
||||
modes[model] = BillingModeTieredExpr
|
||||
}
|
||||
}
|
||||
return modes
|
||||
}
|
||||
|
||||
func GetBillingExprCopy() map[string]string {
|
||||
expressions := lo.Assign(billingSetting.BillingExpr)
|
||||
for model := range builtinBillingExpr {
|
||||
if _, configured := expressions[model]; configured {
|
||||
continue
|
||||
}
|
||||
if expression, ok := GetBillingExpr(model); ok {
|
||||
expressions[model] = expression
|
||||
}
|
||||
}
|
||||
return expressions
|
||||
}
|
||||
|
||||
func GetPricingSyncData(base map[string]any) map[string]any {
|
||||
extra := make(map[string]any, 2)
|
||||
if modes := GetBillingModeCopy(); len(modes) > 0 {
|
||||
extra[BillingModeField] = modes
|
||||
}
|
||||
if exprs := GetBillingExprCopy(); len(exprs) > 0 {
|
||||
extra[BillingExprField] = exprs
|
||||
}
|
||||
return lo.Assign(base, extra)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Smoke test (called externally for validation before save)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func SmokeTestExpr(exprStr string) error {
|
||||
return smokeTestExpr(exprStr)
|
||||
}
|
||||
|
||||
func smokeTestExpr(exprStr string) error {
|
||||
if _, err := billingexpr.CompileFromCache(exprStr); err != nil {
|
||||
return err
|
||||
}
|
||||
usageKeys := billingexpr.UsedUsageKeys(exprStr)
|
||||
if len(usageKeys) > 0 {
|
||||
sortedKeys := make([]string, 0, len(usageKeys))
|
||||
for key := range usageKeys {
|
||||
sortedKeys = append(sortedKeys, key)
|
||||
}
|
||||
sort.Strings(sortedKeys)
|
||||
return fmt.Errorf("expression references usage keys %v but the model has no task plugin usage schema", sortedKeys)
|
||||
}
|
||||
|
||||
vectors := []billingexpr.TokenParams{
|
||||
{P: 0, C: 0, Len: 0},
|
||||
{P: 1000, C: 1000, Len: 1000},
|
||||
{P: 100000, C: 100000, Len: 100000},
|
||||
{P: 1000000, C: 1000000, Len: 1000000},
|
||||
}
|
||||
|
||||
for _, v := range vectors {
|
||||
for _, request := range billingExprSmokeRequests() {
|
||||
result, _, err := billingexpr.RunExprWithRequest(exprStr, v, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("vector {p=%g, c=%g}: run failed: %w", v.P, v.C, err)
|
||||
}
|
||||
if math.IsNaN(result) || math.IsInf(result, 0) || result < 0 {
|
||||
return fmt.Errorf("vector {p=%g, c=%g}: result must be finite and non-negative, got %f", v.P, v.C, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SmokeTestTaskExpr validates a task usage expression against the usage facts
|
||||
// declared by its plugin. Literal u() keys must be declared; dynamic calls are
|
||||
// still exercised by the generated runtime vectors when possible.
|
||||
func SmokeTestTaskExpr(exprStr string, schema map[string]jsplugin.UsageFieldSchema) error {
|
||||
if _, err := billingexpr.CompileFromCache(exprStr); err != nil {
|
||||
return err
|
||||
}
|
||||
for key := range billingexpr.UsedUsageKeys(exprStr) {
|
||||
if _, declared := schema[key]; !declared {
|
||||
return fmt.Errorf("usage key %q is not declared by the task plugin", key)
|
||||
}
|
||||
}
|
||||
|
||||
for _, usage := range taskUsageSmokeVectors(schema) {
|
||||
for _, request := range billingExprSmokeRequests() {
|
||||
request.Usage = usage
|
||||
result, _, err := billingexpr.RunExprWithRequest(exprStr, billingexpr.TokenParams{}, request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("usage vector %v: run failed: %w", usage, err)
|
||||
}
|
||||
if math.IsNaN(result) || math.IsInf(result, 0) || result < 0 {
|
||||
return fmt.Errorf("usage vector %v: result must be finite and non-negative, got %f", usage, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type usageSmokeDimension struct {
|
||||
name string
|
||||
values []any
|
||||
}
|
||||
|
||||
func taskUsageSmokeVectors(schema map[string]jsplugin.UsageFieldSchema) []map[string]any {
|
||||
names := make([]string, 0, len(schema))
|
||||
for name := range schema {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
dimensions := make([]usageSmokeDimension, 0, len(names))
|
||||
for _, name := range names {
|
||||
field := schema[name]
|
||||
if len(field.Enum) > 0 {
|
||||
values := make([]any, len(field.Enum))
|
||||
for index, value := range field.Enum {
|
||||
values[index] = value
|
||||
}
|
||||
dimensions = append(dimensions, usageSmokeDimension{name: name, values: values})
|
||||
continue
|
||||
}
|
||||
if field.Type == "boolean" {
|
||||
dimensions = append(dimensions, usageSmokeDimension{name: name, values: []any{false, true}})
|
||||
continue
|
||||
}
|
||||
limit := relaycommon.MaxTaskDurationSeconds
|
||||
if field.Unit == "count" {
|
||||
limit = dto.MaxImageN
|
||||
}
|
||||
if field.Unit == "token" || field.Unit == "credit" {
|
||||
limit = common.MaxQuota
|
||||
}
|
||||
dimensions = append(dimensions, usageSmokeDimension{
|
||||
name: name,
|
||||
values: []any{float64(0), float64(1), float64(limit)},
|
||||
})
|
||||
}
|
||||
|
||||
if usageSmokeCombinationCount(dimensions, maxTaskExprSmokeTests) > maxTaskExprSmokeTests {
|
||||
for index := range dimensions {
|
||||
field := schema[dimensions[index].name]
|
||||
if len(field.Enum) <= 2 {
|
||||
continue
|
||||
}
|
||||
dimensions[index].values = []any{field.Enum[0], field.Enum[len(field.Enum)-1]}
|
||||
}
|
||||
}
|
||||
|
||||
vectors := make([]map[string]any, 0, maxTaskExprSmokeTests)
|
||||
var appendVectors func(int, map[string]any)
|
||||
appendVectors = func(index int, current map[string]any) {
|
||||
if len(vectors) >= maxTaskExprSmokeTests {
|
||||
return
|
||||
}
|
||||
if index == len(dimensions) {
|
||||
vector := make(map[string]any, len(current))
|
||||
maps.Copy(vector, current)
|
||||
vectors = append(vectors, vector)
|
||||
return
|
||||
}
|
||||
for _, value := range dimensions[index].values {
|
||||
current[dimensions[index].name] = value
|
||||
appendVectors(index+1, current)
|
||||
}
|
||||
delete(current, dimensions[index].name)
|
||||
}
|
||||
appendVectors(0, make(map[string]any, len(dimensions)))
|
||||
|
||||
combinationCount := usageSmokeCombinationCount(dimensions, maxTaskExprSmokeTests)
|
||||
if combinationCount > maxTaskExprSmokeTests && len(vectors) > 0 {
|
||||
last := make(map[string]any, len(dimensions))
|
||||
for _, dimension := range dimensions {
|
||||
last[dimension.name] = dimension.values[len(dimension.values)-1]
|
||||
}
|
||||
vectors[len(vectors)-1] = last
|
||||
}
|
||||
return vectors
|
||||
}
|
||||
|
||||
func usageSmokeCombinationCount(dimensions []usageSmokeDimension, stopAfter int) int {
|
||||
count := 1
|
||||
for _, dimension := range dimensions {
|
||||
if len(dimension.values) == 0 {
|
||||
return 0
|
||||
}
|
||||
if count > stopAfter/len(dimension.values) {
|
||||
return stopAfter + 1
|
||||
}
|
||||
count *= len(dimension.values)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func billingExprSmokeRequests() []billingexpr.RequestInput {
|
||||
return []billingexpr.RequestInput{
|
||||
{},
|
||||
{
|
||||
Headers: map[string]string{
|
||||
"anthropic-beta": "fast-mode-2026-02-01",
|
||||
},
|
||||
Body: []byte(`{"service_tier":"fast","stream_options":{"include_usage":true},"messages":[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21]}`),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package billing_setting
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/pkg/jsplugin"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSmokeTestTaskExprValidatesDeclaredUsageVectors(t *testing.T) {
|
||||
videoSchema := map[string]jsplugin.UsageFieldSchema{
|
||||
"seconds": {Type: "number", Unit: "second"},
|
||||
"mode": {Enum: []string{"std", "pro"}},
|
||||
"quality": {Enum: []string{"sd", "hd"}},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
schema map[string]jsplugin.UsageFieldSchema
|
||||
expression string
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "declared numeric and enum facts",
|
||||
schema: videoSchema,
|
||||
expression: `u("mode") == "pro" ? tier("pro", u("seconds") * 0.8) : tier("std", u("seconds") * 0.4)`,
|
||||
},
|
||||
{
|
||||
name: "undeclared literal key",
|
||||
schema: videoSchema,
|
||||
expression: `tier("base", u("clips") * 0.1)`,
|
||||
expectedError: `usage key "clips" is not declared`,
|
||||
},
|
||||
{
|
||||
name: "negative duration boundary",
|
||||
schema: videoSchema,
|
||||
expression: fmt.Sprintf(`u("seconds") == %d ? -1 : 0`, relaycommon.MaxTaskDurationSeconds),
|
||||
expectedError: "result must be finite and non-negative",
|
||||
},
|
||||
{
|
||||
name: "negative count boundary",
|
||||
schema: map[string]jsplugin.UsageFieldSchema{"clips": {Type: "number", Unit: "count"}},
|
||||
expression: fmt.Sprintf(`u("clips") == %d ? -1 : 0`, dto.MaxImageN),
|
||||
expectedError: "result must be finite and non-negative",
|
||||
},
|
||||
{
|
||||
name: "negative token boundary",
|
||||
schema: map[string]jsplugin.UsageFieldSchema{"tokens": {Type: "number", Unit: "token"}},
|
||||
expression: fmt.Sprintf(`u("tokens") == %d ? -1 : 0`, common.MaxQuota),
|
||||
expectedError: "result must be finite and non-negative",
|
||||
},
|
||||
{
|
||||
name: "negative credit boundary",
|
||||
schema: map[string]jsplugin.UsageFieldSchema{"units": {Type: "number", Unit: "credit"}},
|
||||
expression: fmt.Sprintf(`u("units") == %d ? -1 : 0`, common.MaxQuota),
|
||||
expectedError: "result must be finite and non-negative",
|
||||
},
|
||||
{
|
||||
name: "negative enum combination",
|
||||
schema: videoSchema,
|
||||
expression: `u("mode") == "pro" && u("quality") == "hd" ? -1 : 0`,
|
||||
expectedError: "result must be finite and non-negative",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
err := SmokeTestTaskExpr(testCase.expression, testCase.schema)
|
||||
if testCase.expectedError == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.ErrorContains(t, err, testCase.expectedError)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSmokeTestTaskExprCapsOversizedEnumProductsAtLastCombination(t *testing.T) {
|
||||
schema := make(map[string]jsplugin.UsageFieldSchema, 7)
|
||||
condition := ""
|
||||
for index := range 7 {
|
||||
schema[fmt.Sprintf("enum_%d", index)] = jsplugin.UsageFieldSchema{Enum: []string{"first", "middle", "last"}}
|
||||
if condition != "" {
|
||||
condition += " && "
|
||||
}
|
||||
condition += fmt.Sprintf(`u("enum_%d") == "last"`, index)
|
||||
}
|
||||
|
||||
err := SmokeTestTaskExpr(condition+" ? -1 : 0", schema)
|
||||
require.ErrorContains(t, err, "result must be finite and non-negative")
|
||||
}
|
||||
|
||||
func TestSmokeTestExprRejectsTaskUsageWithoutSchema(t *testing.T) {
|
||||
err := SmokeTestExpr(`u("mode") == "std" ? 1 : 2`)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "mode")
|
||||
assert.ErrorContains(t, err, "no task plugin usage schema")
|
||||
|
||||
require.NoError(t, SmokeTestExpr(`tier("base", p * 2 + c * 8)`))
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
)
|
||||
|
||||
var Chats = []map[string]string{
|
||||
//{
|
||||
// "ChatGPT Next Web 官方示例": "https://app.nextchat.dev/#/?settings={\"key\":\"{key}\",\"url\":\"{address}\"}",
|
||||
//},
|
||||
{
|
||||
"Cherry Studio": "cherrystudio://providers/api-keys?v=1&data={cherryConfig}",
|
||||
},
|
||||
{
|
||||
"AionUI": "aionui://provider/add?v=1&data={aionuiConfig}",
|
||||
},
|
||||
{
|
||||
"流畅阅读": "fluentread",
|
||||
},
|
||||
{
|
||||
"CC Switch": "ccswitch",
|
||||
},
|
||||
{
|
||||
"DeepChat": "deepchat://provider/install?v=1&data={deepchatConfig}",
|
||||
},
|
||||
{
|
||||
"AQBot": "aqbot://providers?{aqbotConfig}",
|
||||
},
|
||||
{
|
||||
"Lobe Chat 官方示例": "https://chat-preview.lobehub.com/?settings={\"keyVaults\":{\"openai\":{\"apiKey\":\"{key}\",\"baseURL\":\"{address}/v1\"}}}",
|
||||
},
|
||||
{
|
||||
"AI as Workspace": "https://aiaw.app/set-provider?provider={\"type\":\"openai\",\"settings\":{\"apiKey\":\"{key}\",\"baseURL\":\"{address}/v1\",\"compatibility\":\"strict\"}}",
|
||||
},
|
||||
{
|
||||
"AMA 问天": "ama://set-api-key?server={address}&key={key}",
|
||||
},
|
||||
{
|
||||
"OpenCat": "opencat://team/join?domain={address}&token={key}",
|
||||
},
|
||||
}
|
||||
|
||||
func UpdateChatsByJsonString(jsonString string) error {
|
||||
Chats = make([]map[string]string, 0)
|
||||
return json.Unmarshal([]byte(jsonString), &Chats)
|
||||
}
|
||||
|
||||
func Chats2JsonString() string {
|
||||
jsonBytes, err := json.Marshal(Chats)
|
||||
if err != nil {
|
||||
common.SysLog("error marshalling chats: " + err.Error())
|
||||
return "[]"
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
)
|
||||
|
||||
// ConfigManager 统一管理所有配置
|
||||
type ConfigManager struct {
|
||||
configs map[string]any
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
var GlobalConfig = NewConfigManager()
|
||||
|
||||
func NewConfigManager() *ConfigManager {
|
||||
return &ConfigManager{
|
||||
configs: make(map[string]any),
|
||||
}
|
||||
}
|
||||
|
||||
// Register 注册一个配置模块
|
||||
func (cm *ConfigManager) Register(name string, config any) {
|
||||
cm.mutex.Lock()
|
||||
defer cm.mutex.Unlock()
|
||||
cm.configs[name] = config
|
||||
}
|
||||
|
||||
// Get 获取指定配置模块
|
||||
func (cm *ConfigManager) Get(name string) any {
|
||||
cm.mutex.RLock()
|
||||
defer cm.mutex.RUnlock()
|
||||
return cm.configs[name]
|
||||
}
|
||||
|
||||
// LoadFromDB 从数据库加载配置
|
||||
func (cm *ConfigManager) LoadFromDB(options map[string]string) error {
|
||||
cm.mutex.Lock()
|
||||
defer cm.mutex.Unlock()
|
||||
|
||||
for name, config := range cm.configs {
|
||||
prefix := name + "."
|
||||
configMap := make(map[string]string)
|
||||
|
||||
// 收集属于此配置的所有选项
|
||||
for key, value := range options {
|
||||
if after, ok := strings.CutPrefix(key, prefix); ok {
|
||||
configKey := after
|
||||
configMap[configKey] = value
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找到配置项,则更新配置
|
||||
if len(configMap) > 0 {
|
||||
if err := updateConfigFromMap(config, configMap); err != nil {
|
||||
common.SysError("failed to update config " + name + ": " + err.Error())
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveToDB 将配置保存到数据库
|
||||
func (cm *ConfigManager) SaveToDB(updateFunc func(key, value string) error) error {
|
||||
cm.mutex.RLock()
|
||||
defer cm.mutex.RUnlock()
|
||||
|
||||
for name, config := range cm.configs {
|
||||
configMap, err := configToMap(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for key, value := range configMap {
|
||||
dbKey := name + "." + key
|
||||
if err := updateFunc(dbKey, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 辅助函数:将配置对象转换为map
|
||||
func configToMap(config any) (map[string]string, error) {
|
||||
result := make(map[string]string)
|
||||
|
||||
val := reflect.ValueOf(config)
|
||||
if val.Kind() == reflect.Pointer {
|
||||
val = val.Elem()
|
||||
}
|
||||
|
||||
if val.Kind() != reflect.Struct {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
typ := val.Type()
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := val.Field(i)
|
||||
fieldType := typ.Field(i)
|
||||
|
||||
// 跳过未导出字段
|
||||
if !fieldType.IsExported() {
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取json标签作为键名
|
||||
key := fieldType.Tag.Get("json")
|
||||
if key == "" || key == "-" {
|
||||
key = fieldType.Name
|
||||
}
|
||||
|
||||
// 处理不同类型的字段
|
||||
var strValue string
|
||||
switch field.Kind() {
|
||||
case reflect.String:
|
||||
strValue = field.String()
|
||||
case reflect.Bool:
|
||||
strValue = strconv.FormatBool(field.Bool())
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
strValue = strconv.FormatInt(field.Int(), 10)
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
strValue = strconv.FormatUint(field.Uint(), 10)
|
||||
case reflect.Float32, reflect.Float64:
|
||||
strValue = strconv.FormatFloat(field.Float(), 'f', -1, 64)
|
||||
case reflect.Pointer:
|
||||
// 处理指针类型:如果非 nil,序列化指向的值
|
||||
if !field.IsNil() {
|
||||
bytes, err := json.Marshal(field.Interface())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
strValue = string(bytes)
|
||||
} else {
|
||||
// nil 指针序列化为 "null"
|
||||
strValue = "null"
|
||||
}
|
||||
case reflect.Map, reflect.Slice, reflect.Struct:
|
||||
// 复杂类型使用JSON序列化
|
||||
bytes, err := json.Marshal(field.Interface())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
strValue = string(bytes)
|
||||
default:
|
||||
// 跳过不支持的类型
|
||||
continue
|
||||
}
|
||||
|
||||
result[key] = strValue
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 辅助函数:从map更新配置对象
|
||||
func updateConfigFromMap(config any, configMap map[string]string) error {
|
||||
val := reflect.ValueOf(config)
|
||||
if val.Kind() != reflect.Pointer {
|
||||
return nil
|
||||
}
|
||||
val = val.Elem()
|
||||
|
||||
if val.Kind() != reflect.Struct {
|
||||
return nil
|
||||
}
|
||||
|
||||
typ := val.Type()
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := val.Field(i)
|
||||
fieldType := typ.Field(i)
|
||||
|
||||
// 跳过未导出字段
|
||||
if !fieldType.IsExported() {
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取json标签作为键名
|
||||
key := fieldType.Tag.Get("json")
|
||||
if key == "" || key == "-" {
|
||||
key = fieldType.Name
|
||||
}
|
||||
|
||||
// 检查map中是否有对应的值
|
||||
strValue, ok := configMap[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// 根据字段类型设置值
|
||||
if !field.CanSet() {
|
||||
continue
|
||||
}
|
||||
|
||||
switch field.Kind() {
|
||||
case reflect.String:
|
||||
field.SetString(strValue)
|
||||
case reflect.Bool:
|
||||
boolValue, err := strconv.ParseBool(strValue)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
field.SetBool(boolValue)
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
intValue, err := strconv.ParseInt(strValue, 10, 64)
|
||||
if err != nil {
|
||||
// 兼容 float 格式的字符串(如 "2.000000")
|
||||
floatValue, fErr := strconv.ParseFloat(strValue, 64)
|
||||
if fErr != nil {
|
||||
continue
|
||||
}
|
||||
intValue = int64(floatValue)
|
||||
}
|
||||
field.SetInt(intValue)
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
uintValue, err := strconv.ParseUint(strValue, 10, 64)
|
||||
if err != nil {
|
||||
// 兼容 float 格式的字符串
|
||||
floatValue, fErr := strconv.ParseFloat(strValue, 64)
|
||||
if fErr != nil || floatValue < 0 {
|
||||
continue
|
||||
}
|
||||
uintValue = uint64(floatValue)
|
||||
}
|
||||
field.SetUint(uintValue)
|
||||
case reflect.Float32, reflect.Float64:
|
||||
floatValue, err := strconv.ParseFloat(strValue, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
field.SetFloat(floatValue)
|
||||
case reflect.Pointer:
|
||||
// 处理指针类型
|
||||
if strValue == "null" {
|
||||
field.Set(reflect.Zero(field.Type()))
|
||||
} else {
|
||||
// 如果指针是 nil,需要先初始化
|
||||
if field.IsNil() {
|
||||
field.Set(reflect.New(field.Type().Elem()))
|
||||
}
|
||||
// 反序列化到指针指向的值
|
||||
err := json.Unmarshal([]byte(strValue), field.Interface())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
case reflect.Map:
|
||||
// json.Unmarshal merges into existing maps (keeps old keys that are
|
||||
// absent from the new JSON). Allocate a fresh map so removed keys
|
||||
// are properly cleared.
|
||||
fresh := reflect.New(field.Type())
|
||||
if err := json.Unmarshal([]byte(strValue), fresh.Interface()); err != nil {
|
||||
continue
|
||||
}
|
||||
field.Set(fresh.Elem())
|
||||
case reflect.Slice, reflect.Struct:
|
||||
err := json.Unmarshal([]byte(strValue), field.Addr().Interface())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfigToMap 将配置对象转换为map(导出函数)
|
||||
func ConfigToMap(config any) (map[string]string, error) {
|
||||
return configToMap(config)
|
||||
}
|
||||
|
||||
// UpdateConfigFromMap 从map更新配置对象(导出函数)
|
||||
func UpdateConfigFromMap(config any, configMap map[string]string) error {
|
||||
return updateConfigFromMap(config, configMap)
|
||||
}
|
||||
|
||||
// ExportAllConfigs 导出所有已注册的配置为扁平结构
|
||||
func (cm *ConfigManager) ExportAllConfigs() map[string]string {
|
||||
cm.mutex.RLock()
|
||||
defer cm.mutex.RUnlock()
|
||||
|
||||
result := make(map[string]string)
|
||||
|
||||
for name, cfg := range cm.configs {
|
||||
configMap, err := ConfigToMap(cfg)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 使用 "模块名.配置项" 的格式添加到结果中
|
||||
for key, value := range configMap {
|
||||
result[name+"."+key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
type testConfigWithMap struct {
|
||||
Modes map[string]string `json:"modes"`
|
||||
Exprs map[string]string `json:"exprs"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func TestUpdateConfigFromMap_MapReplacement(t *testing.T) {
|
||||
cfg := &testConfigWithMap{
|
||||
Modes: map[string]string{
|
||||
"model-a": "tiered_expr",
|
||||
"model-b": "tiered_expr",
|
||||
},
|
||||
Exprs: map[string]string{
|
||||
"model-a": "p * 5 + c * 25",
|
||||
"model-b": "p * 10 + c * 50",
|
||||
},
|
||||
Name: "billing",
|
||||
}
|
||||
|
||||
// Simulate removing model-a: new value only has model-b
|
||||
err := UpdateConfigFromMap(cfg, map[string]string{
|
||||
"modes": `{"model-b": "tiered_expr"}`,
|
||||
"exprs": `{"model-b": "p * 10 + c * 50"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateConfigFromMap failed: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := cfg.Modes["model-a"]; ok {
|
||||
t.Errorf("Modes still contains model-a after it was removed from the update; got %v", cfg.Modes)
|
||||
}
|
||||
if _, ok := cfg.Exprs["model-a"]; ok {
|
||||
t.Errorf("Exprs still contains model-a after it was removed from the update; got %v", cfg.Exprs)
|
||||
}
|
||||
|
||||
if cfg.Modes["model-b"] != "tiered_expr" {
|
||||
t.Errorf("Modes[model-b] = %q, want %q", cfg.Modes["model-b"], "tiered_expr")
|
||||
}
|
||||
if cfg.Exprs["model-b"] != "p * 10 + c * 50" {
|
||||
t.Errorf("Exprs[model-b] = %q, want %q", cfg.Exprs["model-b"], "p * 10 + c * 50")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateConfigFromMap_EmptyMapClearsAll(t *testing.T) {
|
||||
cfg := &testConfigWithMap{
|
||||
Modes: map[string]string{
|
||||
"model-a": "tiered_expr",
|
||||
},
|
||||
Exprs: map[string]string{
|
||||
"model-a": "p * 5 + c * 25",
|
||||
},
|
||||
}
|
||||
|
||||
err := UpdateConfigFromMap(cfg, map[string]string{
|
||||
"modes": `{}`,
|
||||
"exprs": `{}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateConfigFromMap failed: %v", err)
|
||||
}
|
||||
|
||||
if len(cfg.Modes) != 0 {
|
||||
t.Errorf("Modes should be empty after updating with {}, got %v", cfg.Modes)
|
||||
}
|
||||
if len(cfg.Exprs) != 0 {
|
||||
t.Errorf("Exprs should be empty after updating with {}, got %v", cfg.Exprs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateConfigFromMap_ScalarFieldsUnchanged(t *testing.T) {
|
||||
cfg := &testConfigWithMap{
|
||||
Modes: map[string]string{"m": "v"},
|
||||
Name: "old",
|
||||
}
|
||||
|
||||
err := UpdateConfigFromMap(cfg, map[string]string{
|
||||
"name": "new",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateConfigFromMap failed: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Name != "new" {
|
||||
t.Errorf("Name = %q, want %q", cfg.Name, "new")
|
||||
}
|
||||
// modes was not in configMap, should remain unchanged
|
||||
if cfg.Modes["m"] != "v" {
|
||||
t.Errorf("Modes should be unchanged, got %v", cfg.Modes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package console_setting
|
||||
|
||||
import "github.com/QuantumNous/new-api/setting/config"
|
||||
|
||||
type ConsoleSetting struct {
|
||||
ApiInfo string `json:"api_info"` // 控制台 API 信息 (JSON 数组字符串)
|
||||
UptimeKumaGroups string `json:"uptime_kuma_groups"` // Uptime Kuma 分组配置 (JSON 数组字符串)
|
||||
Announcements string `json:"announcements"` // 系统公告 (JSON 数组字符串)
|
||||
FAQ string `json:"faq"` // 常见问题 (JSON 数组字符串)
|
||||
ApiInfoEnabled bool `json:"api_info_enabled"` // 是否启用 API 信息面板
|
||||
UptimeKumaEnabled bool `json:"uptime_kuma_enabled"` // 是否启用 Uptime Kuma 面板
|
||||
AnnouncementsEnabled bool `json:"announcements_enabled"` // 是否启用系统公告面板
|
||||
FAQEnabled bool `json:"faq_enabled"` // 是否启用常见问答面板
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
var defaultConsoleSetting = ConsoleSetting{
|
||||
ApiInfo: "",
|
||||
UptimeKumaGroups: "",
|
||||
Announcements: "",
|
||||
FAQ: "",
|
||||
ApiInfoEnabled: true,
|
||||
UptimeKumaEnabled: true,
|
||||
AnnouncementsEnabled: true,
|
||||
FAQEnabled: true,
|
||||
}
|
||||
|
||||
// 全局实例
|
||||
var consoleSetting = defaultConsoleSetting
|
||||
|
||||
func init() {
|
||||
// 注册到全局配置管理器,键名为 console_setting
|
||||
config.GlobalConfig.Register("console_setting", &consoleSetting)
|
||||
}
|
||||
|
||||
// GetConsoleSetting 获取 ConsoleSetting 配置实例
|
||||
func GetConsoleSetting() *ConsoleSetting {
|
||||
return &consoleSetting
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package console_setting
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
)
|
||||
|
||||
var (
|
||||
urlRegex = regexp.MustCompile(`^https?://(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?|(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))(?:\:[0-9]{1,5})?(?:/.*)?$`)
|
||||
dangerousChars = []string{"<script", "<iframe", "javascript:", "onload=", "onerror=", "onclick="}
|
||||
validColors = map[string]bool{
|
||||
"blue": true, "green": true, "cyan": true, "purple": true, "pink": true,
|
||||
"red": true, "orange": true, "amber": true, "yellow": true, "lime": true,
|
||||
"light-green": true, "teal": true, "light-blue": true, "indigo": true,
|
||||
"violet": true, "grey": true, "slate": true,
|
||||
}
|
||||
slugRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
||||
)
|
||||
|
||||
func parseJSONArray(jsonStr string, typeName string) ([]map[string]interface{}, error) {
|
||||
var list []map[string]interface{}
|
||||
if err := common.UnmarshalJsonStr(jsonStr, &list); err != nil {
|
||||
return nil, fmt.Errorf("%s格式错误:%s", typeName, err.Error())
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func exceedsMaxCharacters(s string, max int) bool {
|
||||
return len(utf16.Encode([]rune(s))) > max
|
||||
}
|
||||
|
||||
func validateURL(urlStr string, index int, itemType string) error {
|
||||
if !urlRegex.MatchString(urlStr) {
|
||||
return fmt.Errorf("第%d个%s的URL格式不正确", index, itemType)
|
||||
}
|
||||
if _, err := url.Parse(urlStr); err != nil {
|
||||
return fmt.Errorf("第%d个%s的URL无法解析:%s", index, itemType, err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkDangerousContent(content string, index int, itemType string) error {
|
||||
lower := strings.ToLower(content)
|
||||
for _, d := range dangerousChars {
|
||||
if strings.Contains(lower, d) {
|
||||
return fmt.Errorf("第%d个%s包含不允许的内容", index, itemType)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getJSONList(jsonStr string) []map[string]interface{} {
|
||||
if jsonStr == "" {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
var list []map[string]interface{}
|
||||
_ = common.UnmarshalJsonStr(jsonStr, &list)
|
||||
return list
|
||||
}
|
||||
|
||||
func ValidateConsoleSettings(settingsStr string, settingType string) error {
|
||||
if settingsStr == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch settingType {
|
||||
case "ApiInfo":
|
||||
return validateApiInfo(settingsStr)
|
||||
case "Announcements":
|
||||
return validateAnnouncements(settingsStr)
|
||||
case "FAQ":
|
||||
return validateFAQ(settingsStr)
|
||||
case "UptimeKumaGroups":
|
||||
return validateUptimeKumaGroups(settingsStr)
|
||||
default:
|
||||
return fmt.Errorf("未知的设置类型:%s", settingType)
|
||||
}
|
||||
}
|
||||
|
||||
func validateApiInfo(apiInfoStr string) error {
|
||||
apiInfoList, err := parseJSONArray(apiInfoStr, "API信息")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(apiInfoList) > 50 {
|
||||
return fmt.Errorf("API信息数量不能超过50个")
|
||||
}
|
||||
|
||||
for i, apiInfo := range apiInfoList {
|
||||
urlStr, ok := apiInfo["url"].(string)
|
||||
if !ok || urlStr == "" {
|
||||
return fmt.Errorf("第%d个API信息缺少URL字段", i+1)
|
||||
}
|
||||
route, ok := apiInfo["route"].(string)
|
||||
if !ok || route == "" {
|
||||
return fmt.Errorf("第%d个API信息缺少线路描述字段", i+1)
|
||||
}
|
||||
description, ok := apiInfo["description"].(string)
|
||||
if !ok || description == "" {
|
||||
return fmt.Errorf("第%d个API信息缺少说明字段", i+1)
|
||||
}
|
||||
color, ok := apiInfo["color"].(string)
|
||||
if !ok || color == "" {
|
||||
return fmt.Errorf("第%d个API信息缺少颜色字段", i+1)
|
||||
}
|
||||
|
||||
if err := validateURL(urlStr, i+1, "API信息"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if exceedsMaxCharacters(urlStr, 500) {
|
||||
return fmt.Errorf("第%d个API信息的URL长度不能超过500字符", i+1)
|
||||
}
|
||||
if exceedsMaxCharacters(route, 100) {
|
||||
return fmt.Errorf("第%d个API信息的线路描述长度不能超过100字符", i+1)
|
||||
}
|
||||
if exceedsMaxCharacters(description, 200) {
|
||||
return fmt.Errorf("第%d个API信息的说明长度不能超过200字符", i+1)
|
||||
}
|
||||
|
||||
if !validColors[color] {
|
||||
return fmt.Errorf("第%d个API信息的颜色值不合法", i+1)
|
||||
}
|
||||
|
||||
if err := checkDangerousContent(description, i+1, "API信息"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkDangerousContent(route, i+1, "API信息"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetApiInfo() []map[string]interface{} {
|
||||
return getJSONList(GetConsoleSetting().ApiInfo)
|
||||
}
|
||||
|
||||
func validateAnnouncements(announcementsStr string) error {
|
||||
list, err := parseJSONArray(announcementsStr, "系统公告")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(list) > 100 {
|
||||
return fmt.Errorf("系统公告数量不能超过100个")
|
||||
}
|
||||
validTypes := map[string]bool{
|
||||
"default": true, "ongoing": true, "success": true, "warning": true, "error": true,
|
||||
}
|
||||
for i, ann := range list {
|
||||
content, ok := ann["content"].(string)
|
||||
if !ok || content == "" {
|
||||
return fmt.Errorf("第%d个公告缺少内容字段", i+1)
|
||||
}
|
||||
publishDateAny, exists := ann["publishDate"]
|
||||
if !exists {
|
||||
return fmt.Errorf("第%d个公告缺少发布日期字段", i+1)
|
||||
}
|
||||
publishDateStr, ok := publishDateAny.(string)
|
||||
if !ok || publishDateStr == "" {
|
||||
return fmt.Errorf("第%d个公告的发布日期不能为空", i+1)
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339, publishDateStr); err != nil {
|
||||
return fmt.Errorf("第%d个公告的发布日期格式错误", i+1)
|
||||
}
|
||||
if t, exists := ann["type"]; exists {
|
||||
if typeStr, ok := t.(string); ok {
|
||||
if !validTypes[typeStr] {
|
||||
return fmt.Errorf("第%d个公告的类型值不合法", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
if exceedsMaxCharacters(content, 500) {
|
||||
return fmt.Errorf("第%d个公告的内容长度不能超过500字符", 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 nil
|
||||
}
|
||||
|
||||
func validateFAQ(faqStr string) error {
|
||||
list, err := parseJSONArray(faqStr, "FAQ信息")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(list) > 100 {
|
||||
return fmt.Errorf("FAQ数量不能超过100个")
|
||||
}
|
||||
for i, faq := range list {
|
||||
question, ok := faq["question"].(string)
|
||||
if !ok || question == "" {
|
||||
return fmt.Errorf("第%d个FAQ缺少问题字段", i+1)
|
||||
}
|
||||
answer, ok := faq["answer"].(string)
|
||||
if !ok || answer == "" {
|
||||
return fmt.Errorf("第%d个FAQ缺少答案字段", i+1)
|
||||
}
|
||||
if exceedsMaxCharacters(question, 200) {
|
||||
return fmt.Errorf("第%d个FAQ的问题长度不能超过200字符", i+1)
|
||||
}
|
||||
if exceedsMaxCharacters(answer, 1000) {
|
||||
return fmt.Errorf("第%d个FAQ的答案长度不能超过1000字符", i+1)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getPublishTime(item map[string]interface{}) time.Time {
|
||||
if v, ok := item["publishDate"]; ok {
|
||||
if s, ok2 := v.(string); ok2 {
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func GetAnnouncements() []map[string]interface{} {
|
||||
list := getJSONList(GetConsoleSetting().Announcements)
|
||||
sort.SliceStable(list, func(i, j int) bool {
|
||||
return getPublishTime(list[i]).After(getPublishTime(list[j]))
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
func GetFAQ() []map[string]interface{} {
|
||||
return getJSONList(GetConsoleSetting().FAQ)
|
||||
}
|
||||
|
||||
func validateUptimeKumaGroups(groupsStr string) error {
|
||||
groups, err := parseJSONArray(groupsStr, "Uptime Kuma分组配置")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(groups) > 20 {
|
||||
return fmt.Errorf("Uptime Kuma分组数量不能超过20个")
|
||||
}
|
||||
|
||||
nameSet := make(map[string]bool)
|
||||
|
||||
for i, group := range groups {
|
||||
categoryName, ok := group["categoryName"].(string)
|
||||
if !ok || categoryName == "" {
|
||||
return fmt.Errorf("第%d个分组缺少分类名称字段", i+1)
|
||||
}
|
||||
if nameSet[categoryName] {
|
||||
return fmt.Errorf("第%d个分组的分类名称与其他分组重复", i+1)
|
||||
}
|
||||
nameSet[categoryName] = true
|
||||
urlStr, ok := group["url"].(string)
|
||||
if !ok || urlStr == "" {
|
||||
return fmt.Errorf("第%d个分组缺少URL字段", i+1)
|
||||
}
|
||||
slug, ok := group["slug"].(string)
|
||||
if !ok || slug == "" {
|
||||
return fmt.Errorf("第%d个分组缺少Slug字段", i+1)
|
||||
}
|
||||
description, ok := group["description"].(string)
|
||||
if !ok {
|
||||
description = ""
|
||||
}
|
||||
|
||||
if err := validateURL(urlStr, i+1, "分组"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if exceedsMaxCharacters(categoryName, 50) {
|
||||
return fmt.Errorf("第%d个分组的分类名称长度不能超过50字符", i+1)
|
||||
}
|
||||
if exceedsMaxCharacters(urlStr, 500) {
|
||||
return fmt.Errorf("第%d个分组的URL长度不能超过500字符", i+1)
|
||||
}
|
||||
if exceedsMaxCharacters(slug, 100) {
|
||||
return fmt.Errorf("第%d个分组的Slug长度不能超过100字符", i+1)
|
||||
}
|
||||
if exceedsMaxCharacters(description, 200) {
|
||||
return fmt.Errorf("第%d个分组的描述长度不能超过200字符", i+1)
|
||||
}
|
||||
|
||||
if !slugRegex.MatchString(slug) {
|
||||
return fmt.Errorf("第%d个分组的Slug只能包含字母、数字、下划线和连字符", i+1)
|
||||
}
|
||||
|
||||
if err := checkDangerousContent(description, i+1, "分组"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkDangerousContent(categoryName, i+1, "分组"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetUptimeKumaGroups() []map[string]interface{} {
|
||||
return getJSONList(GetConsoleSetting().UptimeKumaGroups)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package setting
|
||||
|
||||
var MjNotifyEnabled = false
|
||||
var MjAccountFilterEnabled = false
|
||||
var MjModeClearEnabled = false
|
||||
var MjForwardUrlEnabled = true
|
||||
var MjActionCheckSuccessEnabled = true
|
||||
@@ -0,0 +1,111 @@
|
||||
package model_setting
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/setting/config"
|
||||
)
|
||||
|
||||
//var claudeHeadersSettings = map[string][]string{}
|
||||
//
|
||||
//var ClaudeThinkingAdapterEnabled = true
|
||||
//var ClaudeThinkingAdapterMaxTokens = 8192
|
||||
//var ClaudeThinkingAdapterBudgetTokensPercentage = 0.8
|
||||
|
||||
// ClaudeSettings 定义Claude模型的配置
|
||||
type ClaudeSettings struct {
|
||||
HeadersSettings map[string]map[string][]string `json:"model_headers_settings"`
|
||||
DefaultMaxTokens map[string]int `json:"default_max_tokens"`
|
||||
ThinkingAdapterEnabled bool `json:"thinking_adapter_enabled"`
|
||||
ThinkingAdapterBudgetTokensPercentage float64 `json:"thinking_adapter_budget_tokens_percentage"`
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
var defaultClaudeSettings = ClaudeSettings{
|
||||
HeadersSettings: map[string]map[string][]string{},
|
||||
ThinkingAdapterEnabled: true,
|
||||
DefaultMaxTokens: map[string]int{
|
||||
"default": 8192,
|
||||
},
|
||||
ThinkingAdapterBudgetTokensPercentage: 0.8,
|
||||
}
|
||||
|
||||
// 全局实例
|
||||
var claudeSettings = defaultClaudeSettings
|
||||
|
||||
func init() {
|
||||
// 注册到全局配置管理器
|
||||
config.GlobalConfig.Register("claude", &claudeSettings)
|
||||
}
|
||||
|
||||
// GetClaudeSettings 获取Claude配置
|
||||
func GetClaudeSettings() *ClaudeSettings {
|
||||
// check default max tokens must have default key
|
||||
if _, ok := claudeSettings.DefaultMaxTokens["default"]; !ok {
|
||||
claudeSettings.DefaultMaxTokens["default"] = 8192
|
||||
}
|
||||
return &claudeSettings
|
||||
}
|
||||
|
||||
func (c *ClaudeSettings) WriteHeaders(originModel string, httpHeader *http.Header) {
|
||||
if headers, ok := c.HeadersSettings[originModel]; ok {
|
||||
for headerKey, headerValues := range headers {
|
||||
mergedValues := normalizeHeaderListValues(
|
||||
append(append([]string(nil), httpHeader.Values(headerKey)...), headerValues...),
|
||||
)
|
||||
if len(mergedValues) == 0 {
|
||||
continue
|
||||
}
|
||||
httpHeader.Set(headerKey, strings.Join(mergedValues, ","))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeHeaderListValues(values []string) []string {
|
||||
normalizedValues := make([]string, 0, len(values))
|
||||
seenValues := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
for item := range strings.SplitSeq(value, ",") {
|
||||
normalizedItem := strings.TrimSpace(item)
|
||||
if normalizedItem == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seenValues[normalizedItem]; exists {
|
||||
continue
|
||||
}
|
||||
seenValues[normalizedItem] = struct{}{}
|
||||
normalizedValues = append(normalizedValues, normalizedItem)
|
||||
}
|
||||
}
|
||||
return normalizedValues
|
||||
}
|
||||
|
||||
func (c *ClaudeSettings) GetDefaultMaxTokens(model string) int {
|
||||
if maxTokens, ok := c.DefaultMaxTokens[model]; ok {
|
||||
return maxTokens
|
||||
}
|
||||
return c.DefaultMaxTokens["default"]
|
||||
}
|
||||
|
||||
// ValidateClaudeDefaultMaxTokens validates the JSON persisted by the option
|
||||
// API. Zero stays allowed — the current Messages API accepts max_tokens: 0 as
|
||||
// cache pre-warming — but negative values are rejected because they would
|
||||
// wrap into huge unsigned values during request conversion.
|
||||
func ValidateClaudeDefaultMaxTokens(value string) error {
|
||||
var settings map[string]int
|
||||
if err := common.UnmarshalJsonStr(value, &settings); err != nil {
|
||||
return fmt.Errorf("Claude default max tokens must be a JSON map of model to integer: %w", err)
|
||||
}
|
||||
if settings == nil {
|
||||
return fmt.Errorf("Claude default max tokens must be a JSON map of model to integer")
|
||||
}
|
||||
for model, maxTokens := range settings {
|
||||
if maxTokens < 0 {
|
||||
return fmt.Errorf("negative Claude default max_tokens %d for %q", maxTokens, model)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package model_setting
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestClaudeSettingsWriteHeadersMergesConfiguredValuesIntoSingleHeader(t *testing.T) {
|
||||
settings := &ClaudeSettings{
|
||||
HeadersSettings: map[string]map[string][]string{
|
||||
"claude-3-7-sonnet-20250219-thinking": {
|
||||
"anthropic-beta": {
|
||||
"token-efficient-tools-2025-02-19",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Set("anthropic-beta", "output-128k-2025-02-19")
|
||||
|
||||
settings.WriteHeaders("claude-3-7-sonnet-20250219-thinking", &headers)
|
||||
|
||||
got := headers.Values("anthropic-beta")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected a single merged header value, got %v", got)
|
||||
}
|
||||
expected := "output-128k-2025-02-19,token-efficient-tools-2025-02-19"
|
||||
if got[0] != expected {
|
||||
t.Fatalf("expected merged header %q, got %q", expected, got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeSettingsWriteHeadersDeduplicatesAcrossCommaSeparatedAndRepeatedValues(t *testing.T) {
|
||||
settings := &ClaudeSettings{
|
||||
HeadersSettings: map[string]map[string][]string{
|
||||
"claude-3-7-sonnet-20250219-thinking": {
|
||||
"anthropic-beta": {
|
||||
"token-efficient-tools-2025-02-19",
|
||||
"computer-use-2025-01-24",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Add("anthropic-beta", "output-128k-2025-02-19, token-efficient-tools-2025-02-19")
|
||||
headers.Add("anthropic-beta", "token-efficient-tools-2025-02-19")
|
||||
|
||||
settings.WriteHeaders("claude-3-7-sonnet-20250219-thinking", &headers)
|
||||
|
||||
got := headers.Values("anthropic-beta")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected duplicate values to collapse into one header, got %v", got)
|
||||
}
|
||||
expected := "output-128k-2025-02-19,token-efficient-tools-2025-02-19,computer-use-2025-01-24"
|
||||
if got[0] != expected {
|
||||
t.Fatalf("expected deduplicated merged header %q, got %q", expected, got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateClaudeDefaultMaxTokens(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "positive default", value: `{"default": 8192}`},
|
||||
{name: "zero allowed", value: `{"default": 0}`},
|
||||
{name: "zero model override allowed", value: `{"default": 8192, "claude-test": 0}`},
|
||||
{name: "empty map allowed", value: `{}`},
|
||||
{name: "negative default rejected", value: `{"default": -1}`, wantErr: `negative Claude default max_tokens -1 for "default"`},
|
||||
{name: "negative model override rejected", value: `{"default": 8192, "claude-test": -5}`, wantErr: `negative Claude default max_tokens -5 for "claude-test"`},
|
||||
{name: "non-integer rejected", value: `{"default": "high"}`, wantErr: "JSON map of model to integer"},
|
||||
{name: "null rejected", value: `null`, wantErr: "JSON map of model to integer"},
|
||||
{name: "malformed rejected", value: `{`, wantErr: "JSON map of model to integer"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateClaudeDefaultMaxTokens(tt.value)
|
||||
if tt.wantErr == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package model_setting
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/setting/config"
|
||||
)
|
||||
|
||||
const defaultGeminiSafetySetting = "OFF"
|
||||
|
||||
var validGeminiSafetySettings = map[string]struct{}{
|
||||
"OFF": {},
|
||||
"BLOCK_NONE": {},
|
||||
"BLOCK_ONLY_HIGH": {},
|
||||
"BLOCK_MEDIUM_AND_ABOVE": {},
|
||||
"BLOCK_LOW_AND_ABOVE": {},
|
||||
"HARM_BLOCK_THRESHOLD_UNSPECIFIED": {},
|
||||
}
|
||||
|
||||
// GeminiSettings defines Gemini model configuration. 注意bool要以enabled结尾才可以生效编辑
|
||||
type GeminiSettings struct {
|
||||
SafetySettings map[string]string `json:"safety_settings"`
|
||||
VersionSettings map[string]string `json:"version_settings"`
|
||||
SupportedImagineModels []string `json:"supported_imagine_models"`
|
||||
ThinkingAdapterEnabled bool `json:"thinking_adapter_enabled"`
|
||||
ThinkingAdapterBudgetTokensPercentage float64 `json:"thinking_adapter_budget_tokens_percentage"`
|
||||
FunctionCallThoughtSignatureEnabled bool `json:"function_call_thought_signature_enabled"`
|
||||
RemoveFunctionResponseIdEnabled bool `json:"remove_function_response_id_enabled"`
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
var defaultGeminiSettings = GeminiSettings{
|
||||
SafetySettings: map[string]string{
|
||||
"default": defaultGeminiSafetySetting,
|
||||
},
|
||||
VersionSettings: map[string]string{
|
||||
"default": "v1beta",
|
||||
"gemini-1.0-pro": "v1",
|
||||
},
|
||||
SupportedImagineModels: []string{
|
||||
"gemini-2.0-flash-exp-image-generation",
|
||||
"gemini-2.0-flash-exp",
|
||||
"gemini-3-pro-image-preview",
|
||||
"gemini-3-pro-image",
|
||||
"gemini-2.5-flash-image",
|
||||
"gemini-3.1-flash-image",
|
||||
"gemini-3.1-flash-image-preview",
|
||||
},
|
||||
ThinkingAdapterEnabled: false,
|
||||
ThinkingAdapterBudgetTokensPercentage: 0.6,
|
||||
FunctionCallThoughtSignatureEnabled: true,
|
||||
RemoveFunctionResponseIdEnabled: true,
|
||||
}
|
||||
|
||||
// 全局实例
|
||||
var geminiSettings = defaultGeminiSettings
|
||||
|
||||
func init() {
|
||||
// 注册到全局配置管理器
|
||||
config.GlobalConfig.Register("gemini", &geminiSettings)
|
||||
}
|
||||
|
||||
// GetGeminiSettings 获取Gemini配置
|
||||
func GetGeminiSettings() *GeminiSettings {
|
||||
return &geminiSettings
|
||||
}
|
||||
|
||||
// GetGeminiSafetySetting 获取安全设置
|
||||
func GetGeminiSafetySetting(key string) string {
|
||||
settings := geminiSettings.SafetySettings
|
||||
if value := settings[key]; value != "" {
|
||||
return value
|
||||
}
|
||||
if value := settings["default"]; value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultGeminiSafetySetting
|
||||
}
|
||||
|
||||
// ValidateGeminiSafetySettings validates the JSON persisted by the option API.
|
||||
// Empty values remain valid because read-time fallback returns the default.
|
||||
func ValidateGeminiSafetySettings(value string) error {
|
||||
var settings map[string]string
|
||||
if err := common.UnmarshalJsonStr(value, &settings); err != nil {
|
||||
return fmt.Errorf("Gemini safety settings must be a JSON string map: %w", err)
|
||||
}
|
||||
if settings == nil {
|
||||
return fmt.Errorf("Gemini safety settings must be a JSON string map")
|
||||
}
|
||||
for category, threshold := range settings {
|
||||
if threshold == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := validGeminiSafetySettings[threshold]; !ok {
|
||||
return fmt.Errorf("invalid Gemini safety threshold %q for %q", threshold, category)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetGeminiVersionSetting 获取版本设置
|
||||
func GetGeminiVersionSetting(key string) string {
|
||||
if value, ok := geminiSettings.VersionSettings[key]; ok {
|
||||
return value
|
||||
}
|
||||
return geminiSettings.VersionSettings["default"]
|
||||
}
|
||||
|
||||
func IsGeminiModelSupportImagine(model string) bool {
|
||||
return slices.Contains(geminiSettings.SupportedImagineModels, model)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package model_setting
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGeminiSafetySettingsReadNormalization(t *testing.T) {
|
||||
original := geminiSettings.SafetySettings
|
||||
t.Cleanup(func() {
|
||||
geminiSettings.SafetySettings = original
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
settings map[string]string
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "nil map gets OFF default",
|
||||
settings: nil,
|
||||
key: "HARM_CATEGORY_HATE_SPEECH",
|
||||
want: "OFF",
|
||||
},
|
||||
{
|
||||
name: "missing default gets OFF without replacing existing values",
|
||||
settings: map[string]string{
|
||||
"HARM_CATEGORY_HATE_SPEECH": "BLOCK_SOME",
|
||||
},
|
||||
key: "HARM_CATEGORY_HATE_SPEECH",
|
||||
want: "BLOCK_SOME",
|
||||
},
|
||||
{
|
||||
name: "empty default gets OFF",
|
||||
settings: map[string]string{
|
||||
"default": "",
|
||||
},
|
||||
key: "HARM_CATEGORY_HATE_SPEECH",
|
||||
want: "OFF",
|
||||
},
|
||||
{
|
||||
name: "empty override falls back to configured default",
|
||||
settings: map[string]string{
|
||||
"default": "BLOCK_ONLY_HIGH",
|
||||
"HARM_CATEGORY_HATE_SPEECH": "",
|
||||
},
|
||||
key: "HARM_CATEGORY_HATE_SPEECH",
|
||||
want: "BLOCK_ONLY_HIGH",
|
||||
},
|
||||
{
|
||||
name: "historical invalid nonempty default is preserved",
|
||||
settings: map[string]string{
|
||||
"default": "BLOCK_SOME",
|
||||
},
|
||||
key: "HARM_CATEGORY_HATE_SPEECH",
|
||||
want: "BLOCK_SOME",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
geminiSettings.SafetySettings = test.settings
|
||||
|
||||
assert.Equal(t, test.want, GetGeminiSafetySetting(test.key))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGeminiSafetySettings(t *testing.T) {
|
||||
valid := []string{
|
||||
`{}`,
|
||||
`{"default":""}`,
|
||||
`{"HARM_CATEGORY_HATE_SPEECH":""}`,
|
||||
`{"default":"OFF"}`,
|
||||
`{"default":"BLOCK_NONE"}`,
|
||||
`{"default":"BLOCK_ONLY_HIGH"}`,
|
||||
`{"default":"BLOCK_MEDIUM_AND_ABOVE"}`,
|
||||
`{"default":"BLOCK_LOW_AND_ABOVE"}`,
|
||||
`{"default":"HARM_BLOCK_THRESHOLD_UNSPECIFIED"}`,
|
||||
}
|
||||
for _, value := range valid {
|
||||
require.NoError(t, ValidateGeminiSafetySettings(value), value)
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
`null`,
|
||||
`[]`,
|
||||
`{"default":1}`,
|
||||
`{"default":"BLOCK_SOME"}`,
|
||||
`{"default":" off "}`,
|
||||
`{"default":`,
|
||||
}
|
||||
for _, value := range invalid {
|
||||
assert.Error(t, ValidateGeminiSafetySettings(value), value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package model_setting
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/setting/config"
|
||||
)
|
||||
|
||||
type ChatCompletionsToResponsesPolicy struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AllChannels bool `json:"all_channels"`
|
||||
ChannelIDs []int `json:"channel_ids,omitempty"`
|
||||
ChannelTypes []int `json:"channel_types,omitempty"`
|
||||
ModelPatterns []string `json:"model_patterns,omitempty"`
|
||||
}
|
||||
|
||||
func (p ChatCompletionsToResponsesPolicy) IsChannelEnabled(channelID int, channelType int) bool {
|
||||
if !p.Enabled {
|
||||
return false
|
||||
}
|
||||
if p.AllChannels {
|
||||
return true
|
||||
}
|
||||
|
||||
if channelID > 0 && len(p.ChannelIDs) > 0 && slices.Contains(p.ChannelIDs, channelID) {
|
||||
return true
|
||||
}
|
||||
if channelType > 0 && len(p.ChannelTypes) > 0 && slices.Contains(p.ChannelTypes, channelType) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type GlobalSettings struct {
|
||||
PassThroughRequestEnabled bool `json:"pass_through_request_enabled"`
|
||||
ThinkingModelBlacklist []string `json:"thinking_model_blacklist"`
|
||||
// EffortTailModelIDs lists real model IDs that sit inside the GPT/o-series
|
||||
// family whitelist but whose names already end in an effort word.
|
||||
EffortTailModelIDs []string `json:"effort_tail_model_ids"`
|
||||
ChatCompletionsToResponsesPolicy ChatCompletionsToResponsesPolicy `json:"chat_completions_to_responses_policy"`
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
var defaultOpenaiSettings = GlobalSettings{
|
||||
PassThroughRequestEnabled: false,
|
||||
ThinkingModelBlacklist: []string{
|
||||
"moonshotai/kimi-k2-thinking",
|
||||
"kimi-k2-thinking",
|
||||
},
|
||||
EffortTailModelIDs: []string{
|
||||
"gpt-5.1-codex-max",
|
||||
"qwen-image-edit-max",
|
||||
"qwen-max",
|
||||
"stable-diffusion-3-medium",
|
||||
"yi-medium",
|
||||
},
|
||||
ChatCompletionsToResponsesPolicy: ChatCompletionsToResponsesPolicy{
|
||||
Enabled: false,
|
||||
AllChannels: true,
|
||||
},
|
||||
}
|
||||
|
||||
// 全局实例
|
||||
var globalSettings = defaultOpenaiSettings
|
||||
|
||||
func init() {
|
||||
// 注册到全局配置管理器
|
||||
config.GlobalConfig.Register("global", &globalSettings)
|
||||
}
|
||||
|
||||
func GetGlobalSettings() *GlobalSettings {
|
||||
return &globalSettings
|
||||
}
|
||||
|
||||
const thinkingBlacklistRegexPrefix = "re:"
|
||||
|
||||
type thinkingBlacklistCompiled struct {
|
||||
source string
|
||||
exact []string
|
||||
regexes []*regexp.Regexp
|
||||
}
|
||||
|
||||
var (
|
||||
thinkingBlacklistMu sync.RWMutex
|
||||
thinkingBlacklistCache thinkingBlacklistCompiled
|
||||
)
|
||||
|
||||
func thinkingBlacklistSourceKey(entries []string) string {
|
||||
return strings.Join(entries, "\x00")
|
||||
}
|
||||
|
||||
func compiledThinkingBlacklist() ([]string, []*regexp.Regexp) {
|
||||
entries := globalSettings.ThinkingModelBlacklist
|
||||
key := thinkingBlacklistSourceKey(entries)
|
||||
|
||||
thinkingBlacklistMu.RLock()
|
||||
if thinkingBlacklistCache.source == key {
|
||||
exact, regexes := thinkingBlacklistCache.exact, thinkingBlacklistCache.regexes
|
||||
thinkingBlacklistMu.RUnlock()
|
||||
return exact, regexes
|
||||
}
|
||||
thinkingBlacklistMu.RUnlock()
|
||||
|
||||
thinkingBlacklistMu.Lock()
|
||||
defer thinkingBlacklistMu.Unlock()
|
||||
if thinkingBlacklistCache.source == key {
|
||||
return thinkingBlacklistCache.exact, thinkingBlacklistCache.regexes
|
||||
}
|
||||
|
||||
exact := make([]string, 0, len(entries))
|
||||
var regexes []*regexp.Regexp
|
||||
for _, entry := range entries {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
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))
|
||||
continue
|
||||
}
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
common.SysError(fmt.Sprintf("invalid thinking_model_blacklist regex %q: %v", entry, err))
|
||||
continue
|
||||
}
|
||||
regexes = append(regexes, re)
|
||||
continue
|
||||
}
|
||||
exact = append(exact, entry)
|
||||
}
|
||||
thinkingBlacklistCache = thinkingBlacklistCompiled{source: key, exact: exact, regexes: regexes}
|
||||
return exact, regexes
|
||||
}
|
||||
|
||||
// ShouldPreserveThinkingSuffix reports whether the full model name is exempt
|
||||
// from host thinking-suffix and @-modifier parsing. Exact blacklist entries
|
||||
// match the complete name; entries prefixed with re: are Go regular expressions
|
||||
// matched with MatchString against the same full name.
|
||||
func ShouldPreserveThinkingSuffix(modelName string) bool {
|
||||
target := strings.TrimSpace(modelName)
|
||||
if target == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
exact, regexes := compiledThinkingBlacklist()
|
||||
if slices.Contains(exact, target) {
|
||||
return true
|
||||
}
|
||||
for _, re := range regexes {
|
||||
if re.MatchString(target) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ShouldPreserveEffortTail reports whether modelName is a real model ID whose
|
||||
// name already ends in an effort word. Entries match the complete name and the
|
||||
// de-namespaced bare name.
|
||||
func ShouldPreserveEffortTail(modelName string) bool {
|
||||
target := strings.TrimSpace(modelName)
|
||||
if target == "" {
|
||||
return false
|
||||
}
|
||||
bare := target
|
||||
if slash := strings.LastIndex(target, "/"); slash >= 0 {
|
||||
bare = target[slash+1:]
|
||||
}
|
||||
|
||||
for _, entry := range globalSettings.EffortTailModelIDs {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
if entry == target || entry == bare {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package model_setting
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestShouldPreserveThinkingSuffixExactAndRegex(t *testing.T) {
|
||||
settings := GetGlobalSettings()
|
||||
original := append([]string(nil), settings.ThinkingModelBlacklist...)
|
||||
t.Cleanup(func() { settings.ThinkingModelBlacklist = original })
|
||||
|
||||
assert.True(t, ShouldPreserveThinkingSuffix("kimi-k2-thinking"))
|
||||
assert.True(t, ShouldPreserveThinkingSuffix("moonshotai/kimi-k2-thinking"))
|
||||
assert.False(t, ShouldPreserveThinkingSuffix("m@sha256:abc"))
|
||||
|
||||
settings.ThinkingModelBlacklist = []string{
|
||||
"kimi-k2-thinking",
|
||||
"re:[",
|
||||
"re:",
|
||||
"re:.*@sha256:.*",
|
||||
}
|
||||
|
||||
var logged bytes.Buffer
|
||||
previous := gin.DefaultErrorWriter
|
||||
gin.DefaultErrorWriter = &logged
|
||||
t.Cleanup(func() { gin.DefaultErrorWriter = previous })
|
||||
|
||||
assert.True(t, ShouldPreserveThinkingSuffix("kimi-k2-thinking"))
|
||||
assert.True(t, ShouldPreserveThinkingSuffix("m@sha256:abc"))
|
||||
assert.False(t, ShouldPreserveThinkingSuffix("m@sha256"))
|
||||
assert.False(t, ShouldPreserveThinkingSuffix("qwen3-max@thinking:on"))
|
||||
require.Contains(t, logged.String(), `invalid thinking_model_blacklist regex "re:["`)
|
||||
require.Contains(t, logged.String(), `invalid thinking_model_blacklist regex "re:"`)
|
||||
|
||||
settings.ThinkingModelBlacklist = []string{"re:^beta@"}
|
||||
assert.False(t, ShouldPreserveThinkingSuffix("m@sha256:abc"))
|
||||
assert.True(t, ShouldPreserveThinkingSuffix("beta@sha256:abc"))
|
||||
assert.False(t, ShouldPreserveThinkingSuffix("alpha@sha256:abc"))
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package model_setting
|
||||
|
||||
import "github.com/QuantumNous/new-api/setting/config"
|
||||
|
||||
// GrokSettings defines Grok model configuration.
|
||||
type GrokSettings struct {
|
||||
ViolationDeductionEnabled bool `json:"violation_deduction_enabled"`
|
||||
ViolationDeductionAmount float64 `json:"violation_deduction_amount"`
|
||||
}
|
||||
|
||||
var defaultGrokSettings = GrokSettings{
|
||||
ViolationDeductionEnabled: true,
|
||||
ViolationDeductionAmount: 0.05,
|
||||
}
|
||||
|
||||
var grokSettings = defaultGrokSettings
|
||||
|
||||
func init() {
|
||||
config.GlobalConfig.Register("grok", &grokSettings)
|
||||
}
|
||||
|
||||
func GetGrokSettings() *GrokSettings {
|
||||
return &grokSettings
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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,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"))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package setting
|
||||
|
||||
var CreemApiKey = ""
|
||||
var CreemProducts = "[]"
|
||||
var CreemTestMode = false
|
||||
var CreemWebhookSecret = ""
|
||||
@@ -0,0 +1,8 @@
|
||||
package setting
|
||||
|
||||
var StripeApiSecret = ""
|
||||
var StripeWebhookSecret = ""
|
||||
var StripePriceId = ""
|
||||
var StripeUnitPrice = 8.0
|
||||
var StripeMinTopUp = 1
|
||||
var StripePromotionCodesEnabled = false
|
||||
@@ -0,0 +1,67 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
)
|
||||
|
||||
var (
|
||||
WaffoEnabled bool
|
||||
WaffoApiKey string
|
||||
WaffoPrivateKey string
|
||||
WaffoPublicCert string
|
||||
WaffoSandboxPublicCert string
|
||||
WaffoSandboxApiKey string
|
||||
WaffoSandboxPrivateKey string
|
||||
WaffoSandbox bool
|
||||
WaffoMerchantId string
|
||||
WaffoNotifyUrl string
|
||||
WaffoReturnUrl string
|
||||
WaffoSubscriptionReturnUrl string
|
||||
WaffoCurrency string
|
||||
WaffoUnitPrice float64 = 1.0
|
||||
WaffoMinTopUp int = 1
|
||||
)
|
||||
|
||||
// GetWaffoPayMethods 从 options 读取 Waffo 支付方式配置
|
||||
func GetWaffoPayMethods() []constant.WaffoPayMethod {
|
||||
common.OptionMapRWMutex.RLock()
|
||||
jsonStr := common.OptionMap["WaffoPayMethods"]
|
||||
common.OptionMapRWMutex.RUnlock()
|
||||
|
||||
if jsonStr == "" {
|
||||
return copyDefaultWaffoPayMethods()
|
||||
}
|
||||
var methods []constant.WaffoPayMethod
|
||||
if err := common.UnmarshalJsonStr(jsonStr, &methods); err != nil {
|
||||
return copyDefaultWaffoPayMethods()
|
||||
}
|
||||
return methods
|
||||
}
|
||||
|
||||
// SetWaffoPayMethods 序列化 Waffo 支付方式配置并更新 OptionMap
|
||||
func SetWaffoPayMethods(methods []constant.WaffoPayMethod) error {
|
||||
jsonBytes, err := common.Marshal(methods)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
common.OptionMapRWMutex.Lock()
|
||||
common.OptionMap["WaffoPayMethods"] = string(jsonBytes)
|
||||
common.OptionMapRWMutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyDefaultWaffoPayMethods() []constant.WaffoPayMethod {
|
||||
cp := make([]constant.WaffoPayMethod, len(constant.DefaultWaffoPayMethods))
|
||||
copy(cp, constant.DefaultWaffoPayMethods)
|
||||
return cp
|
||||
}
|
||||
|
||||
// WaffoPayMethods2JsonString 将默认 WaffoPayMethods 序列化为 JSON 字符串(供 InitOptionMap 使用)
|
||||
func WaffoPayMethods2JsonString() string {
|
||||
jsonBytes, err := common.Marshal(constant.DefaultWaffoPayMethods)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package setting
|
||||
|
||||
// Waffo Pancake hosted checkout configuration. Gateway is enabled once
|
||||
// MerchantID + PrivateKey + ProductID are populated (no separate Enabled
|
||||
// flag, matching Stripe / Creem). StoreID + ProductID are operator-bound
|
||||
// via SaveWaffoPancakeConfig.
|
||||
var (
|
||||
WaffoPancakeMerchantID string
|
||||
WaffoPancakePrivateKey string
|
||||
WaffoPancakeReturnURL string
|
||||
WaffoPancakeUnitPrice float64 = 1.0
|
||||
WaffoPancakeMinTopUp int = 1
|
||||
WaffoPancakeStoreID string
|
||||
WaffoPancakeProductID string
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
package perf_metrics_setting
|
||||
|
||||
import "github.com/QuantumNous/new-api/setting/config"
|
||||
|
||||
type PerfMetricsSetting struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
FlushInterval int `json:"flush_interval"`
|
||||
BucketTime string `json:"bucket_time"`
|
||||
RetentionDays int `json:"retention_days"`
|
||||
}
|
||||
|
||||
var perfMetricsSetting = PerfMetricsSetting{
|
||||
Enabled: true,
|
||||
FlushInterval: 5,
|
||||
BucketTime: "hour",
|
||||
RetentionDays: 0,
|
||||
}
|
||||
|
||||
func init() {
|
||||
config.GlobalConfig.Register("perf_metrics_setting", &perfMetricsSetting)
|
||||
}
|
||||
|
||||
func GetSetting() PerfMetricsSetting {
|
||||
return perfMetricsSetting
|
||||
}
|
||||
|
||||
func GetBucketSeconds() int64 {
|
||||
switch perfMetricsSetting.BucketTime {
|
||||
case "minute":
|
||||
return 60
|
||||
case "5min":
|
||||
return 300
|
||||
case "hour":
|
||||
return 3600
|
||||
default:
|
||||
return 3600
|
||||
}
|
||||
}
|
||||
|
||||
func GetFlushIntervalMinutes() int {
|
||||
if perfMetricsSetting.FlushInterval < 1 {
|
||||
return 1
|
||||
}
|
||||
return perfMetricsSetting.FlushInterval
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package performance_setting
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/setting/config"
|
||||
)
|
||||
|
||||
// PerformanceSetting 性能设置配置
|
||||
type PerformanceSetting struct {
|
||||
// DiskCacheEnabled 是否启用磁盘缓存(磁盘换内存)
|
||||
DiskCacheEnabled bool `json:"disk_cache_enabled"`
|
||||
// DiskCacheThresholdMB 触发磁盘缓存的请求体大小阈值(MB)
|
||||
DiskCacheThresholdMB int `json:"disk_cache_threshold_mb"`
|
||||
// DiskCacheMaxSizeMB 磁盘缓存最大总大小(MB)
|
||||
DiskCacheMaxSizeMB int `json:"disk_cache_max_size_mb"`
|
||||
// DiskCachePath 磁盘缓存目录
|
||||
DiskCachePath string `json:"disk_cache_path"`
|
||||
|
||||
// MonitorEnabled 是否启用性能监控
|
||||
MonitorEnabled bool `json:"monitor_enabled"`
|
||||
// MonitorCPUThreshold CPU 使用率阈值(%)
|
||||
MonitorCPUThreshold int `json:"monitor_cpu_threshold"`
|
||||
// MonitorMemoryThreshold 内存使用率阈值(%)
|
||||
MonitorMemoryThreshold int `json:"monitor_memory_threshold"`
|
||||
// MonitorDiskThreshold 磁盘使用率阈值(%)
|
||||
MonitorDiskThreshold int `json:"monitor_disk_threshold"`
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
var performanceSetting = PerformanceSetting{
|
||||
DiskCacheEnabled: false,
|
||||
DiskCacheThresholdMB: 10, // 超过 10MB 使用磁盘缓存
|
||||
DiskCacheMaxSizeMB: 1024, // 最大 1GB 磁盘缓存
|
||||
DiskCachePath: "", // 空表示使用系统临时目录
|
||||
|
||||
MonitorEnabled: true,
|
||||
MonitorCPUThreshold: 90,
|
||||
MonitorMemoryThreshold: 90,
|
||||
MonitorDiskThreshold: 95,
|
||||
}
|
||||
|
||||
func init() {
|
||||
// 注册到全局配置管理器
|
||||
config.GlobalConfig.Register("performance_setting", &performanceSetting)
|
||||
// 同步初始配置到 common 包
|
||||
syncToCommon()
|
||||
}
|
||||
|
||||
// syncToCommon 将配置同步到 common 包
|
||||
func syncToCommon() {
|
||||
common.SetDiskCacheConfig(common.DiskCacheConfig{
|
||||
Enabled: performanceSetting.DiskCacheEnabled,
|
||||
ThresholdMB: performanceSetting.DiskCacheThresholdMB,
|
||||
MaxSizeMB: performanceSetting.DiskCacheMaxSizeMB,
|
||||
Path: performanceSetting.DiskCachePath,
|
||||
})
|
||||
|
||||
common.SetPerformanceMonitorConfig(common.PerformanceMonitorConfig{
|
||||
Enabled: performanceSetting.MonitorEnabled,
|
||||
CPUThreshold: performanceSetting.MonitorCPUThreshold,
|
||||
MemoryThreshold: performanceSetting.MonitorMemoryThreshold,
|
||||
DiskThreshold: performanceSetting.MonitorDiskThreshold,
|
||||
})
|
||||
}
|
||||
|
||||
// GetPerformanceSetting 获取性能设置
|
||||
func GetPerformanceSetting() *PerformanceSetting {
|
||||
return &performanceSetting
|
||||
}
|
||||
|
||||
// UpdateAndSync 更新配置并同步到 common 包
|
||||
// 当配置从数据库加载后,需要调用此函数同步
|
||||
func UpdateAndSync() {
|
||||
syncToCommon()
|
||||
}
|
||||
|
||||
// GetCacheStats 获取缓存统计信息(代理到 common 包)
|
||||
func GetCacheStats() common.DiskCacheStats {
|
||||
return common.GetDiskCacheStats()
|
||||
}
|
||||
|
||||
// ResetStats 重置统计信息
|
||||
func ResetStats() {
|
||||
common.ResetDiskCacheStats()
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
)
|
||||
|
||||
// maxRateLimitDurationSeconds is the largest window the count cap is computed
|
||||
// against (24h). Token-bucket capacity is count*duration; this keeps that
|
||||
// product inside int64 when the window is at most a day.
|
||||
const maxRateLimitDurationSeconds = 24 * 60 * 60
|
||||
|
||||
// maxModelRequestRateLimitCount is math.MaxInt64 / maxRateLimitDurationSeconds.
|
||||
// It is the largest count that cannot overflow int64(count)*duration for a
|
||||
// window of at most 24 hours.
|
||||
const maxModelRequestRateLimitCount int64 = math.MaxInt64 / maxRateLimitDurationSeconds
|
||||
|
||||
var ModelRequestRateLimitEnabled = false
|
||||
var ModelRequestRateLimitDurationMinutes = 1
|
||||
var ModelRequestRateLimitCount = 0
|
||||
var ModelRequestRateLimitSuccessCount = 1000
|
||||
var ModelRequestRateLimitGroup = map[string][2]int{}
|
||||
var ModelRequestRateLimitMutex sync.RWMutex
|
||||
|
||||
func ModelRequestRateLimitGroup2JSONString() string {
|
||||
ModelRequestRateLimitMutex.RLock()
|
||||
defer ModelRequestRateLimitMutex.RUnlock()
|
||||
|
||||
jsonBytes, err := common.Marshal(ModelRequestRateLimitGroup)
|
||||
if err != nil {
|
||||
common.SysLog("error marshalling model ratio: " + err.Error())
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
func UpdateModelRequestRateLimitGroupByJSONString(jsonStr string) error {
|
||||
ModelRequestRateLimitMutex.RLock()
|
||||
defer ModelRequestRateLimitMutex.RUnlock()
|
||||
|
||||
ModelRequestRateLimitGroup = make(map[string][2]int)
|
||||
return common.Unmarshal([]byte(jsonStr), &ModelRequestRateLimitGroup)
|
||||
}
|
||||
|
||||
func GetGroupRateLimit(group string) (totalCount, successCount int, found bool) {
|
||||
ModelRequestRateLimitMutex.RLock()
|
||||
defer ModelRequestRateLimitMutex.RUnlock()
|
||||
|
||||
if ModelRequestRateLimitGroup == nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
limits, found := ModelRequestRateLimitGroup[group]
|
||||
if !found {
|
||||
return 0, 0, false
|
||||
}
|
||||
return limits[0], limits[1], true
|
||||
}
|
||||
|
||||
func CheckModelRequestRateLimitGroup(jsonStr string) error {
|
||||
checkModelRequestRateLimitGroup := make(map[string][2]int)
|
||||
err := common.Unmarshal([]byte(jsonStr), &checkModelRequestRateLimitGroup)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for group, limits := range checkModelRequestRateLimitGroup {
|
||||
if limits[0] < 0 || limits[1] < 1 {
|
||||
return fmt.Errorf("group %s has negative rate limit values: [%d, %d]", group, limits[0], limits[1])
|
||||
}
|
||||
if int64(limits[0]) > maxModelRequestRateLimitCount || int64(limits[1]) > maxModelRequestRateLimitCount {
|
||||
return fmt.Errorf("group %s [%d, %d] exceeds max rate limit %d", group, limits[0], limits[1], maxModelRequestRateLimitCount)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package ratio_setting
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
)
|
||||
|
||||
var defaultCacheRatio = map[string]float64{
|
||||
"gemini-3-flash-preview": 0.1,
|
||||
"gemini-3-pro-preview": 0.1,
|
||||
"gemini-3.1-pro-preview": 0.1,
|
||||
"gpt-4": 0.5,
|
||||
"o1": 0.5,
|
||||
"o1-2024-12-17": 0.5,
|
||||
"o1-preview-2024-09-12": 0.5,
|
||||
"o1-preview": 0.5,
|
||||
"o1-mini-2024-09-12": 0.5,
|
||||
"o1-mini": 0.5,
|
||||
"o3-mini": 0.5,
|
||||
"o3-mini-2025-01-31": 0.5,
|
||||
"gpt-4o-2024-11-20": 0.5,
|
||||
"gpt-4o-2024-08-06": 0.5,
|
||||
"gpt-4o": 0.5,
|
||||
"gpt-4o-mini-2024-07-18": 0.5,
|
||||
"gpt-4o-mini": 0.5,
|
||||
"gpt-4o-realtime-preview": 0.5,
|
||||
"gpt-4o-mini-realtime-preview": 0.5,
|
||||
"gpt-4.5-preview": 0.5,
|
||||
"gpt-4.5-preview-2025-02-27": 0.5,
|
||||
"gpt-4.1": 0.25,
|
||||
"gpt-4.1-mini": 0.25,
|
||||
"gpt-4.1-nano": 0.25,
|
||||
"gpt-5": 0.1,
|
||||
"gpt-5-2025-08-07": 0.1,
|
||||
"gpt-5-chat-latest": 0.1,
|
||||
"gpt-5-mini": 0.1,
|
||||
"gpt-5-mini-2025-08-07": 0.1,
|
||||
"gpt-5-nano": 0.1,
|
||||
"gpt-5-nano-2025-08-07": 0.1,
|
||||
"deepseek-chat": 0.25,
|
||||
"deepseek-reasoner": 0.25,
|
||||
"deepseek-coder": 0.25,
|
||||
"claude-3-sonnet-20240229": 0.1,
|
||||
"claude-3-opus-20240229": 0.1,
|
||||
"claude-3-haiku-20240307": 0.1,
|
||||
"claude-3-5-haiku-20241022": 0.1,
|
||||
"claude-haiku-4-5-20251001": 0.1,
|
||||
"claude-3-5-sonnet-20240620": 0.1,
|
||||
"claude-3-5-sonnet-20241022": 0.1,
|
||||
"claude-3-7-sonnet-20250219": 0.1,
|
||||
"claude-3-7-sonnet-20250219-thinking": 0.1,
|
||||
"claude-sonnet-4-20250514": 0.1,
|
||||
"claude-sonnet-4-20250514-thinking": 0.1,
|
||||
"claude-opus-4-20250514": 0.1,
|
||||
"claude-opus-4-20250514-thinking": 0.1,
|
||||
"claude-opus-4-1-20250805": 0.1,
|
||||
"claude-opus-4-1-20250805-thinking": 0.1,
|
||||
"claude-sonnet-4-5-20250929": 0.1,
|
||||
"claude-sonnet-4-5-20250929-thinking": 0.1,
|
||||
"claude-opus-4-5-20251101": 0.1,
|
||||
"claude-opus-4-5-20251101-thinking": 0.1,
|
||||
"claude-opus-4-6": 0.1,
|
||||
"claude-opus-4-6-thinking": 0.1,
|
||||
"claude-opus-4-6-max": 0.1,
|
||||
"claude-opus-4-6-high": 0.1,
|
||||
"claude-opus-4-6-medium": 0.1,
|
||||
"claude-opus-4-6-low": 0.1,
|
||||
"claude-opus-4-7": 0.1,
|
||||
"claude-opus-4-7-thinking": 0.1,
|
||||
"claude-opus-4-7-max": 0.1,
|
||||
"claude-opus-4-7-xhigh": 0.1,
|
||||
"claude-opus-4-7-high": 0.1,
|
||||
"claude-opus-4-7-medium": 0.1,
|
||||
"claude-opus-4-7-low": 0.1,
|
||||
"claude-opus-4-8": 0.1,
|
||||
"claude-opus-4-8-thinking": 0.1,
|
||||
"claude-opus-4-8-max": 0.1,
|
||||
"claude-opus-4-8-xhigh": 0.1,
|
||||
"claude-opus-4-8-high": 0.1,
|
||||
"claude-opus-4-8-medium": 0.1,
|
||||
"claude-opus-4-8-low": 0.1,
|
||||
}
|
||||
|
||||
var defaultCreateCacheRatio = map[string]float64{
|
||||
"gpt-5.6-sol": 1.25,
|
||||
"gpt-5.6-terra": 1.25,
|
||||
"gpt-5.6-luna": 1.25,
|
||||
"claude-3-sonnet-20240229": 1.25,
|
||||
"claude-3-opus-20240229": 1.25,
|
||||
"claude-3-haiku-20240307": 1.25,
|
||||
"claude-3-5-haiku-20241022": 1.25,
|
||||
"claude-haiku-4-5-20251001": 1.25,
|
||||
"claude-3-5-sonnet-20240620": 1.25,
|
||||
"claude-3-5-sonnet-20241022": 1.25,
|
||||
"claude-3-7-sonnet-20250219": 1.25,
|
||||
"claude-3-7-sonnet-20250219-thinking": 1.25,
|
||||
"claude-sonnet-4-20250514": 1.25,
|
||||
"claude-sonnet-4-20250514-thinking": 1.25,
|
||||
"claude-opus-4-20250514": 1.25,
|
||||
"claude-opus-4-20250514-thinking": 1.25,
|
||||
"claude-opus-4-1-20250805": 1.25,
|
||||
"claude-opus-4-1-20250805-thinking": 1.25,
|
||||
"claude-sonnet-4-5-20250929": 1.25,
|
||||
"claude-sonnet-4-5-20250929-thinking": 1.25,
|
||||
"claude-opus-4-5-20251101": 1.25,
|
||||
"claude-opus-4-5-20251101-thinking": 1.25,
|
||||
"claude-opus-4-6": 1.25,
|
||||
"claude-opus-4-6-thinking": 1.25,
|
||||
"claude-opus-4-6-max": 1.25,
|
||||
"claude-opus-4-6-high": 1.25,
|
||||
"claude-opus-4-6-medium": 1.25,
|
||||
"claude-opus-4-6-low": 1.25,
|
||||
"claude-opus-4-7": 1.25,
|
||||
"claude-opus-4-7-thinking": 1.25,
|
||||
"claude-opus-4-7-max": 1.25,
|
||||
"claude-opus-4-7-xhigh": 1.25,
|
||||
"claude-opus-4-7-high": 1.25,
|
||||
"claude-opus-4-7-medium": 1.25,
|
||||
"claude-opus-4-7-low": 1.25,
|
||||
"claude-opus-4-8": 1.25,
|
||||
"claude-opus-4-8-thinking": 1.25,
|
||||
"claude-opus-4-8-max": 1.25,
|
||||
"claude-opus-4-8-xhigh": 1.25,
|
||||
"claude-opus-4-8-high": 1.25,
|
||||
"claude-opus-4-8-medium": 1.25,
|
||||
"claude-opus-4-8-low": 1.25,
|
||||
}
|
||||
|
||||
//var defaultCreateCacheRatio = map[string]float64{}
|
||||
|
||||
var cacheRatioMap = types.NewRWMap[string, float64]()
|
||||
var createCacheRatioMap = types.NewRWMap[string, float64]()
|
||||
|
||||
// GetCacheRatioMap returns a copy of the cache ratio map
|
||||
func GetCacheRatioMap() map[string]float64 {
|
||||
return cacheRatioMap.ReadAll()
|
||||
}
|
||||
|
||||
// CacheRatio2JSONString converts the cache ratio map to a JSON string
|
||||
func CacheRatio2JSONString() string {
|
||||
return cacheRatioMap.MarshalJSONString()
|
||||
}
|
||||
|
||||
// CreateCacheRatio2JSONString converts the create cache ratio map to a JSON string
|
||||
func CreateCacheRatio2JSONString() string {
|
||||
return createCacheRatioMap.MarshalJSONString()
|
||||
}
|
||||
|
||||
// UpdateCacheRatioByJSONString updates the cache ratio map from a JSON string
|
||||
func UpdateCacheRatioByJSONString(jsonStr string) error {
|
||||
return types.LoadFromJsonStringWithCallback(cacheRatioMap, jsonStr, InvalidateExposedDataCache)
|
||||
}
|
||||
|
||||
// UpdateCreateCacheRatioByJSONString updates the create cache ratio map from a JSON string
|
||||
func UpdateCreateCacheRatioByJSONString(jsonStr string) error {
|
||||
return types.LoadFromJsonStringWithCallback(createCacheRatioMap, jsonStr, InvalidateExposedDataCache)
|
||||
}
|
||||
|
||||
// GetCacheRatio returns the cache ratio for a model
|
||||
func GetCacheRatio(name string) (float64, bool) {
|
||||
ratio, ok := cacheRatioMap.Get(name)
|
||||
if !ok {
|
||||
return 1, false // Default to 1 if not found
|
||||
}
|
||||
return ratio, true
|
||||
}
|
||||
|
||||
func GetCreateCacheRatio(name string) (float64, bool) {
|
||||
ratio, ok := createCacheRatioMap.Get(name)
|
||||
if !ok {
|
||||
return 1.25, false // Default to 1.25 if not found
|
||||
}
|
||||
return ratio, true
|
||||
}
|
||||
|
||||
func GetCacheRatioCopy() map[string]float64 {
|
||||
return cacheRatioMap.ReadAll()
|
||||
}
|
||||
|
||||
func GetCreateCacheRatioCopy() map[string]float64 {
|
||||
return createCacheRatioMap.ReadAll()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package ratio_setting
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
var exposeRatioEnabled atomic.Bool
|
||||
|
||||
func init() {
|
||||
exposeRatioEnabled.Store(false)
|
||||
}
|
||||
|
||||
func SetExposeRatioEnabled(enabled bool) {
|
||||
exposeRatioEnabled.Store(enabled)
|
||||
}
|
||||
|
||||
func IsExposeRatioEnabled() bool {
|
||||
return exposeRatioEnabled.Load()
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package ratio_setting
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const exposedDataTTL = 30 * time.Second
|
||||
|
||||
type exposedCache struct {
|
||||
data gin.H
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
exposedData atomic.Value
|
||||
rebuildMu sync.Mutex
|
||||
)
|
||||
|
||||
func InvalidateExposedDataCache() {
|
||||
exposedData.Store((*exposedCache)(nil))
|
||||
}
|
||||
|
||||
func cloneGinH(src gin.H) gin.H {
|
||||
dst := make(gin.H, len(src))
|
||||
maps.Copy(dst, src)
|
||||
return dst
|
||||
}
|
||||
|
||||
func GetExposedData() gin.H {
|
||||
if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) {
|
||||
return cloneGinH(c.data)
|
||||
}
|
||||
rebuildMu.Lock()
|
||||
defer rebuildMu.Unlock()
|
||||
if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) {
|
||||
return cloneGinH(c.data)
|
||||
}
|
||||
newData := gin.H{
|
||||
"model_ratio": GetModelRatioCopy(),
|
||||
"completion_ratio": GetCompletionRatioCopy(),
|
||||
"cache_ratio": GetCacheRatioCopy(),
|
||||
"create_cache_ratio": GetCreateCacheRatioCopy(),
|
||||
"model_price": GetModelPriceCopy(),
|
||||
}
|
||||
exposedData.Store(&exposedCache{
|
||||
data: newData,
|
||||
expiresAt: time.Now().Add(exposedDataTTL),
|
||||
})
|
||||
return cloneGinH(newData)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package ratio_setting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/setting/config"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
)
|
||||
|
||||
var defaultGroupRatio = map[string]float64{
|
||||
"default": 1,
|
||||
"vip": 1,
|
||||
"svip": 1,
|
||||
}
|
||||
|
||||
var groupRatioMap = types.NewRWMap[string, float64]()
|
||||
|
||||
var defaultGroupGroupRatio = map[string]map[string]float64{
|
||||
"vip": {
|
||||
"edit_this": 0.9,
|
||||
},
|
||||
}
|
||||
|
||||
var groupGroupRatioMap = types.NewRWMap[string, map[string]float64]()
|
||||
|
||||
var defaultGroupSpecialUsableGroup = map[string]map[string]string{}
|
||||
|
||||
type GroupRatioSetting struct {
|
||||
GroupRatio *types.RWMap[string, float64] `json:"group_ratio"`
|
||||
GroupGroupRatio *types.RWMap[string, map[string]float64] `json:"group_group_ratio"`
|
||||
GroupSpecialUsableGroup *types.RWMap[string, map[string]string] `json:"group_special_usable_group"`
|
||||
}
|
||||
|
||||
var groupRatioSetting GroupRatioSetting
|
||||
|
||||
func init() {
|
||||
groupSpecialUsableGroup := types.NewRWMap[string, map[string]string]()
|
||||
groupSpecialUsableGroup.AddAll(defaultGroupSpecialUsableGroup)
|
||||
|
||||
groupRatioMap.AddAll(defaultGroupRatio)
|
||||
groupGroupRatioMap.AddAll(defaultGroupGroupRatio)
|
||||
|
||||
groupRatioSetting = GroupRatioSetting{
|
||||
GroupSpecialUsableGroup: groupSpecialUsableGroup,
|
||||
GroupRatio: groupRatioMap,
|
||||
GroupGroupRatio: groupGroupRatioMap,
|
||||
}
|
||||
|
||||
config.GlobalConfig.Register("group_ratio_setting", &groupRatioSetting)
|
||||
}
|
||||
|
||||
func GetGroupRatioSetting() *GroupRatioSetting {
|
||||
if groupRatioSetting.GroupSpecialUsableGroup == nil {
|
||||
groupRatioSetting.GroupSpecialUsableGroup = types.NewRWMap[string, map[string]string]()
|
||||
groupRatioSetting.GroupSpecialUsableGroup.AddAll(defaultGroupSpecialUsableGroup)
|
||||
}
|
||||
return &groupRatioSetting
|
||||
}
|
||||
|
||||
func GetGroupRatioCopy() map[string]float64 {
|
||||
return groupRatioMap.ReadAll()
|
||||
}
|
||||
|
||||
func ContainsGroupRatio(name string) bool {
|
||||
_, ok := groupRatioMap.Get(name)
|
||||
return ok
|
||||
}
|
||||
|
||||
func GroupRatio2JSONString() string {
|
||||
return groupRatioMap.MarshalJSONString()
|
||||
}
|
||||
|
||||
func UpdateGroupRatioByJSONString(jsonStr string) error {
|
||||
return types.LoadFromJsonString(groupRatioMap, jsonStr)
|
||||
}
|
||||
|
||||
func GetGroupRatio(name string) float64 {
|
||||
ratio, ok := groupRatioMap.Get(name)
|
||||
if !ok {
|
||||
common.SysLog("group ratio not found: " + name)
|
||||
return 1
|
||||
}
|
||||
return ratio
|
||||
}
|
||||
|
||||
func GetGroupGroupRatio(userGroup, usingGroup string) (float64, bool) {
|
||||
gp, ok := groupGroupRatioMap.Get(userGroup)
|
||||
if !ok {
|
||||
return -1, false
|
||||
}
|
||||
ratio, ok := gp[usingGroup]
|
||||
if !ok {
|
||||
return -1, false
|
||||
}
|
||||
return ratio, true
|
||||
}
|
||||
|
||||
func GroupGroupRatio2JSONString() string {
|
||||
return groupGroupRatioMap.MarshalJSONString()
|
||||
}
|
||||
|
||||
func UpdateGroupGroupRatioByJSONString(jsonStr string) error {
|
||||
return types.LoadFromJsonString(groupGroupRatioMap, jsonStr)
|
||||
}
|
||||
|
||||
func CheckGroupRatio(jsonStr string) error {
|
||||
checkGroupRatio := make(map[string]float64)
|
||||
err := json.Unmarshal([]byte(jsonStr), &checkGroupRatio)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for name, ratio := range checkGroupRatio {
|
||||
if ratio < 0 {
|
||||
return errors.New("group ratio must be not less than 0: " + name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package ratio_setting
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFormatMatchingModelNameDoesNotStripBase(t *testing.T) {
|
||||
assert.Equal(t, "qwen3-max@thinking:on", FormatMatchingModelName("qwen3-max@thinking:on"))
|
||||
assert.Equal(t, "claude-3-7-sonnet-thinking", FormatMatchingModelName("claude-3-7-sonnet-thinking"))
|
||||
assert.Equal(t, "gemini-2.5-flash-thinking-*", FormatMatchingModelName("gemini-2.5-flash-thinking-8192"))
|
||||
assert.Equal(t, "gpt-4-gizmo-*", FormatMatchingModelName("gpt-4-gizmo-abc"))
|
||||
}
|
||||
|
||||
func TestRoutingMatchModelNameStripsThenWildcards(t *testing.T) {
|
||||
assert.Equal(t, "qwen3-max", RoutingMatchModelName("qwen3-max@thinking:on@temperature:0.2"))
|
||||
assert.Equal(t, "claude-3-7-sonnet", RoutingMatchModelName("claude-3-7-sonnet-thinking"))
|
||||
assert.Equal(t, "gemini-2.5-flash-thinking-*", RoutingMatchModelName("gemini-2.5-flash-thinking-8192"))
|
||||
assert.Equal(t, "gpt-5.1-codex-max", RoutingMatchModelName("gpt-5.1-codex-max"))
|
||||
|
||||
geminiSettings := model_setting.GetGeminiSettings()
|
||||
old := geminiSettings.ThinkingAdapterEnabled
|
||||
geminiSettings.ThinkingAdapterEnabled = true
|
||||
t.Cleanup(func() { geminiSettings.ThinkingAdapterEnabled = old })
|
||||
assert.Equal(t, "gemini-2.5-flash", RoutingMatchModelName("gemini-2.5-flash-thinking-8192"))
|
||||
}
|
||||
|
||||
func TestRoutingMatchModelNamePreservesExemptAtName(t *testing.T) {
|
||||
settings := model_setting.GetGlobalSettings()
|
||||
original := append([]string(nil), settings.ThinkingModelBlacklist...)
|
||||
t.Cleanup(func() { settings.ThinkingModelBlacklist = original })
|
||||
settings.ThinkingModelBlacklist = append(original, "re:.*@sha256:.*")
|
||||
|
||||
assert.Equal(t, "opaque@sha256:deadbeef", RoutingMatchModelName("opaque@sha256:deadbeef"))
|
||||
assert.Equal(t, "kimi-k2-thinking", RoutingMatchModelName("kimi-k2-thinking"))
|
||||
}
|
||||
@@ -0,0 +1,760 @@
|
||||
package ratio_setting
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
hostreasoning "github.com/QuantumNous/new-api/setting/reasoning"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
)
|
||||
|
||||
// from songquanpeng/one-api
|
||||
const (
|
||||
USD2RMB = 7.3 // 暂定 1 USD = 7.3 RMB
|
||||
USD = 500 // $0.002 = 1 -> $1 = 500
|
||||
RMB = USD / USD2RMB
|
||||
)
|
||||
|
||||
// modelRatio
|
||||
// https://platform.openai.com/docs/models/model-endpoint-compatibility
|
||||
// https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Blfmc9dlf
|
||||
// https://openai.com/pricing
|
||||
// TODO: when a new api is enabled, check the pricing here
|
||||
// 1 === $0.002 / 1K tokens
|
||||
// 1 === ¥0.014 / 1k tokens
|
||||
|
||||
var defaultModelRatio = map[string]float64{
|
||||
//"midjourney": 50,
|
||||
"gpt-4-gizmo-*": 15,
|
||||
"gpt-4o-gizmo-*": 2.5,
|
||||
"gpt-4-all": 15,
|
||||
"gpt-4o-all": 15,
|
||||
"gpt-4": 15,
|
||||
"gpt-4-0613": 15,
|
||||
"gpt-4-32k": 30,
|
||||
"gpt-4-32k-0613": 30,
|
||||
"gpt-4-1106-preview": 5, // $10 / 1M tokens
|
||||
"gpt-4-0125-preview": 5, // $10 / 1M tokens
|
||||
"gpt-4-turbo-preview": 5, // $10 / 1M tokens
|
||||
"gpt-4-vision-preview": 5, // $10 / 1M tokens
|
||||
"gpt-4-1106-vision-preview": 5, // $10 / 1M tokens
|
||||
"chatgpt-4o-latest": 2.5, // $5 / 1M tokens
|
||||
"gpt-4o": 1.25, // $2.5 / 1M tokens
|
||||
"gpt-4o-audio-preview": 1.25, // $2.5 / 1M tokens
|
||||
"gpt-4o-audio-preview-2024-10-01": 1.25, // $2.5 / 1M tokens
|
||||
"gpt-4o-2024-05-13": 2.5, // $5 / 1M tokens
|
||||
"gpt-4o-2024-08-06": 1.25, // $2.5 / 1M tokens
|
||||
"gpt-4o-2024-11-20": 1.25, // $2.5 / 1M tokens
|
||||
"gpt-4o-realtime-preview": 2.5,
|
||||
"gpt-4o-realtime-preview-2024-10-01": 2.5,
|
||||
"gpt-4o-realtime-preview-2024-12-17": 2.5,
|
||||
"gpt-4o-mini-realtime-preview": 0.3,
|
||||
"gpt-4o-mini-realtime-preview-2024-12-17": 0.3,
|
||||
"gpt-4.1": 1.0, // $2 / 1M tokens
|
||||
"gpt-4.1-2025-04-14": 1.0, // $2 / 1M tokens
|
||||
"gpt-4.1-mini": 0.2, // $0.4 / 1M tokens
|
||||
"gpt-4.1-mini-2025-04-14": 0.2, // $0.4 / 1M tokens
|
||||
"gpt-4.1-nano": 0.05, // $0.1 / 1M tokens
|
||||
"gpt-4.1-nano-2025-04-14": 0.05, // $0.1 / 1M tokens
|
||||
"gpt-image-1": 2.5, // $5 / 1M tokens
|
||||
"o1": 7.5, // $15 / 1M tokens
|
||||
"o1-2024-12-17": 7.5, // $15 / 1M tokens
|
||||
"o1-preview": 7.5, // $15 / 1M tokens
|
||||
"o1-preview-2024-09-12": 7.5, // $15 / 1M tokens
|
||||
"o1-mini": 0.55, // $1.1 / 1M tokens
|
||||
"o1-mini-2024-09-12": 0.55, // $1.1 / 1M tokens
|
||||
"o1-pro": 75.0, // $150 / 1M tokens
|
||||
"o1-pro-2025-03-19": 75.0, // $150 / 1M tokens
|
||||
"o3-mini": 0.55,
|
||||
"o3-mini-2025-01-31": 0.55,
|
||||
"o3-mini-high": 0.55,
|
||||
"o3-mini-2025-01-31-high": 0.55,
|
||||
"o3-mini-low": 0.55,
|
||||
"o3-mini-2025-01-31-low": 0.55,
|
||||
"o3-mini-medium": 0.55,
|
||||
"o3-mini-2025-01-31-medium": 0.55,
|
||||
"o3": 1.0, // $2 / 1M tokens
|
||||
"o3-2025-04-16": 1.0, // $2 / 1M tokens
|
||||
"o3-pro": 10.0, // $20 / 1M tokens
|
||||
"o3-pro-2025-06-10": 10.0, // $20 / 1M tokens
|
||||
"o3-deep-research": 5.0, // $10 / 1M tokens
|
||||
"o3-deep-research-2025-06-26": 5.0, // $10 / 1M tokens
|
||||
"o4-mini": 0.55, // $1.1 / 1M tokens
|
||||
"o4-mini-2025-04-16": 0.55, // $1.1 / 1M tokens
|
||||
"o4-mini-deep-research": 1.0, // $2 / 1M tokens
|
||||
"o4-mini-deep-research-2025-06-26": 1.0, // $2 / 1M tokens
|
||||
"gpt-4o-mini": 0.075,
|
||||
"gpt-4o-mini-2024-07-18": 0.075,
|
||||
"gpt-4-turbo": 5, // $0.01 / 1K tokens
|
||||
"gpt-4-turbo-2024-04-09": 5, // $0.01 / 1K tokens
|
||||
"gpt-4.5-preview": 37.5,
|
||||
"gpt-4.5-preview-2025-02-27": 37.5,
|
||||
"gpt-5": 0.625,
|
||||
"gpt-5-2025-08-07": 0.625,
|
||||
"gpt-5-chat-latest": 0.625,
|
||||
"gpt-5-mini": 0.125,
|
||||
"gpt-5-mini-2025-08-07": 0.125,
|
||||
"gpt-5-nano": 0.025,
|
||||
"gpt-5-nano-2025-08-07": 0.025,
|
||||
"gpt-5.5": 2.5, // $5 / 1M tokens
|
||||
"gpt-5.6-sol": 2.5,
|
||||
"gpt-5.6-terra": 1.25,
|
||||
"gpt-5.6-luna": 0.5,
|
||||
"gpt-3.5-turbo": 0.25,
|
||||
"gpt-3.5-turbo-0613": 0.75,
|
||||
"gpt-3.5-turbo-16k": 1.5, // $0.003 / 1K tokens
|
||||
"gpt-3.5-turbo-16k-0613": 1.5,
|
||||
"gpt-3.5-turbo-instruct": 0.75, // $0.0015 / 1K tokens
|
||||
"gpt-3.5-turbo-1106": 0.5, // $0.001 / 1K tokens
|
||||
"gpt-3.5-turbo-0125": 0.25,
|
||||
"text-ada-001": 0.2,
|
||||
"text-babbage-001": 0.25,
|
||||
"text-curie-001": 1,
|
||||
"text-davinci-edit-001": 10,
|
||||
"code-davinci-edit-001": 10,
|
||||
"whisper-1": 15, // $0.006 / minute -> $0.006 / 150 words -> $0.006 / 200 tokens -> $0.03 / 1k tokens
|
||||
"tts-1": 7.5, // 1k characters -> $0.015
|
||||
"tts-1-1106": 7.5, // 1k characters -> $0.015
|
||||
"tts-1-hd": 15, // 1k characters -> $0.03
|
||||
"tts-1-hd-1106": 15, // 1k characters -> $0.03
|
||||
"davinci": 10,
|
||||
"curie": 10,
|
||||
"text-embedding-3-small": 0.01,
|
||||
"text-embedding-3-large": 0.065,
|
||||
"text-embedding-ada-002": 0.05,
|
||||
"text-search-ada-doc-001": 10,
|
||||
"text-moderation-stable": 0.1,
|
||||
"text-moderation-latest": 0.1,
|
||||
"claude-3-haiku-20240307": 0.125, // $0.25 / 1M tokens
|
||||
"claude-3-5-haiku-20241022": 0.5, // $1 / 1M tokens
|
||||
"claude-haiku-4-5-20251001": 0.5, // $1 / 1M tokens
|
||||
"claude-3-sonnet-20240229": 1.5, // $3 / 1M tokens
|
||||
"claude-3-5-sonnet-20240620": 1.5,
|
||||
"claude-3-5-sonnet-20241022": 1.5,
|
||||
"claude-3-7-sonnet-20250219": 1.5,
|
||||
"claude-3-7-sonnet-20250219-thinking": 1.5,
|
||||
"claude-sonnet-4-20250514": 1.5,
|
||||
"claude-sonnet-4-5-20250929": 1.5,
|
||||
"claude-opus-4-5-20251101": 2.5,
|
||||
"claude-opus-4-6": 2.5,
|
||||
"claude-opus-4-6-max": 2.5,
|
||||
"claude-opus-4-6-high": 2.5,
|
||||
"claude-opus-4-6-medium": 2.5,
|
||||
"claude-opus-4-6-low": 2.5,
|
||||
"claude-opus-4-7": 2.5,
|
||||
"claude-opus-4-7-max": 2.5,
|
||||
"claude-opus-4-7-xhigh": 2.5,
|
||||
"claude-opus-4-7-high": 2.5,
|
||||
"claude-opus-4-7-medium": 2.5,
|
||||
"claude-opus-4-7-low": 2.5,
|
||||
"claude-opus-4-8": 2.5,
|
||||
"claude-opus-4-8-max": 2.5,
|
||||
"claude-opus-4-8-xhigh": 2.5,
|
||||
"claude-opus-4-8-high": 2.5,
|
||||
"claude-opus-4-8-medium": 2.5,
|
||||
"claude-opus-4-8-low": 2.5,
|
||||
"claude-3-opus-20240229": 7.5, // $15 / 1M tokens
|
||||
"claude-opus-4-20250514": 7.5,
|
||||
"claude-opus-4-1-20250805": 7.5,
|
||||
"ERNIE-4.0-8K": 0.120 * RMB,
|
||||
"ERNIE-3.5-8K": 0.012 * RMB,
|
||||
"ERNIE-3.5-8K-0205": 0.024 * RMB,
|
||||
"ERNIE-3.5-8K-1222": 0.012 * RMB,
|
||||
"ERNIE-Bot-8K": 0.024 * RMB,
|
||||
"ERNIE-3.5-4K-0205": 0.012 * RMB,
|
||||
"ERNIE-Speed-8K": 0.004 * RMB,
|
||||
"ERNIE-Speed-128K": 0.004 * RMB,
|
||||
"ERNIE-Lite-8K-0922": 0.008 * RMB,
|
||||
"ERNIE-Lite-8K-0308": 0.003 * RMB,
|
||||
"ERNIE-Tiny-8K": 0.001 * RMB,
|
||||
"BLOOMZ-7B": 0.004 * RMB,
|
||||
"Embedding-V1": 0.002 * RMB,
|
||||
"bge-large-zh": 0.002 * RMB,
|
||||
"bge-large-en": 0.002 * RMB,
|
||||
"tao-8k": 0.002 * RMB,
|
||||
"PaLM-2": 1,
|
||||
"gemini-1.5-pro-latest": 1.25, // $3.5 / 1M tokens
|
||||
"gemini-1.5-flash-latest": 0.075,
|
||||
"gemini-2.0-flash": 0.05,
|
||||
"gemini-2.5-pro-exp-03-25": 0.625,
|
||||
"gemini-2.5-pro-preview-03-25": 0.625,
|
||||
"gemini-2.5-pro": 0.625,
|
||||
"gemini-2.5-flash-preview-04-17": 0.075,
|
||||
"gemini-2.5-flash-preview-04-17-thinking": 0.075,
|
||||
"gemini-2.5-flash-preview-05-20": 0.075,
|
||||
"gemini-2.5-flash-preview-05-20-thinking": 0.075,
|
||||
"gemini-2.5-flash-thinking-*": 0.075, // 用于为后续所有2.5 flash thinking budget 模型设置默认倍率
|
||||
"gemini-2.5-pro-thinking-*": 0.625, // 用于为后续所有2.5 pro thinking budget 模型设置默认倍率
|
||||
"gemini-2.5-flash-lite-preview-thinking-*": 0.05,
|
||||
"gemini-2.5-flash-lite-preview-06-17": 0.05,
|
||||
"gemini-2.5-flash": 0.15,
|
||||
"gemini-robotics-er-1.5-preview": 0.15,
|
||||
"gemini-embedding-001": 0.075,
|
||||
"text-embedding-004": 0.001,
|
||||
"chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens
|
||||
"chatglm_pro": 0.7143, // ¥0.01 / 1k tokens
|
||||
"chatglm_std": 0.3572, // ¥0.005 / 1k tokens
|
||||
"chatglm_lite": 0.1429, // ¥0.002 / 1k tokens
|
||||
"glm-4": 7.143, // ¥0.1 / 1k tokens
|
||||
"glm-4v": 0.05 * RMB, // ¥0.05 / 1k tokens
|
||||
"glm-4-alltools": 0.1 * RMB, // ¥0.1 / 1k tokens
|
||||
"glm-3-turbo": 0.3572,
|
||||
"glm-4-plus": 0.05 * RMB,
|
||||
"glm-4-0520": 0.1 * RMB,
|
||||
"glm-4-air": 0.001 * RMB,
|
||||
"glm-4-airx": 0.01 * RMB,
|
||||
"glm-4-long": 0.001 * RMB,
|
||||
"glm-4-flash": 0,
|
||||
"glm-4v-plus": 0.01 * RMB,
|
||||
"qwen-turbo": 0.8572, // ¥0.012 / 1k tokens
|
||||
"qwen-plus": 10, // ¥0.14 / 1k tokens
|
||||
"text-embedding-v1": 0.05, // ¥0.0007 / 1k tokens
|
||||
"SparkDesk-v1.1": 1.2858, // ¥0.018 / 1k tokens
|
||||
"SparkDesk-v2.1": 1.2858, // ¥0.018 / 1k tokens
|
||||
"SparkDesk-v3.1": 1.2858, // ¥0.018 / 1k tokens
|
||||
"SparkDesk-v3.5": 1.2858, // ¥0.018 / 1k tokens
|
||||
"SparkDesk-v4.0": 1.2858,
|
||||
"hunyuan": 7.143, // ¥0.1 / 1k tokens // https://cloud.tencent.com/document/product/1729/97731#e0e6be58-60c8-469f-bdeb-6c264ce3b4d0
|
||||
// https://platform.lingyiwanwu.com/docs#-计费单元
|
||||
// 已经按照 7.2 来换算美元价格
|
||||
"yi-34b-chat-0205": 0.18,
|
||||
"yi-34b-chat-200k": 0.864,
|
||||
"yi-vl-plus": 0.432,
|
||||
"yi-large": 20.0 / 1000 * RMB,
|
||||
"yi-medium": 2.5 / 1000 * RMB,
|
||||
"yi-vision": 6.0 / 1000 * RMB,
|
||||
"yi-medium-200k": 12.0 / 1000 * RMB,
|
||||
"yi-spark": 1.0 / 1000 * RMB,
|
||||
"yi-large-rag": 25.0 / 1000 * RMB,
|
||||
"yi-large-turbo": 12.0 / 1000 * RMB,
|
||||
"yi-large-preview": 20.0 / 1000 * RMB,
|
||||
"yi-large-rag-preview": 25.0 / 1000 * RMB,
|
||||
"command": 0.5,
|
||||
"command-nightly": 0.5,
|
||||
"command-light": 0.5,
|
||||
"command-light-nightly": 0.5,
|
||||
"command-r": 0.25,
|
||||
"command-r-plus": 1.5,
|
||||
"command-r-08-2024": 0.075,
|
||||
"command-r-plus-08-2024": 1.25,
|
||||
"deepseek-chat": 0.27 / 2,
|
||||
"deepseek-coder": 0.27 / 2,
|
||||
"deepseek-reasoner": 0.55 / 2, // 0.55 / 1k tokens
|
||||
// Perplexity online 模型对搜索额外收费,有需要应自行调整,此处不计入搜索费用
|
||||
"llama-3-sonar-small-32k-chat": 0.2 / 1000 * USD,
|
||||
"llama-3-sonar-small-32k-online": 0.2 / 1000 * USD,
|
||||
"llama-3-sonar-large-32k-chat": 1 / 1000 * USD,
|
||||
"llama-3-sonar-large-32k-online": 1 / 1000 * USD,
|
||||
// grok
|
||||
"grok-3-beta": 1.5,
|
||||
"grok-3-mini-beta": 0.15,
|
||||
"grok-2": 1,
|
||||
"grok-2-vision": 1,
|
||||
"grok-beta": 2.5,
|
||||
"grok-vision-beta": 2.5,
|
||||
"grok-3-fast-beta": 2.5,
|
||||
"grok-3-mini-fast-beta": 0.3,
|
||||
// submodel
|
||||
"NousResearch/Hermes-4-405B-FP8": 0.8,
|
||||
"Qwen/Qwen3-235B-A22B-Thinking-2507": 0.6,
|
||||
"Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": 0.8,
|
||||
"Qwen/Qwen3-235B-A22B-Instruct-2507": 0.3,
|
||||
"zai-org/GLM-4.5-FP8": 0.8,
|
||||
"openai/gpt-oss-120b": 0.5,
|
||||
"deepseek-ai/DeepSeek-R1-0528": 0.8,
|
||||
"deepseek-ai/DeepSeek-R1": 0.8,
|
||||
"deepseek-ai/DeepSeek-V3-0324": 0.8,
|
||||
"deepseek-ai/DeepSeek-V3.1": 0.8,
|
||||
}
|
||||
|
||||
var defaultModelPrice = map[string]float64{
|
||||
"suno_music": 0.1,
|
||||
"suno_lyrics": 0.01,
|
||||
"dall-e-3": 0.04,
|
||||
"imagen-3.0-generate-002": 0.03,
|
||||
"black-forest-labs/flux-1.1-pro": 0.04,
|
||||
"gpt-4-gizmo-*": 0.1,
|
||||
"mj_video": 0.8,
|
||||
"mj_imagine": 0.1,
|
||||
"mj_edits": 0.1,
|
||||
"mj_variation": 0.1,
|
||||
"mj_reroll": 0.1,
|
||||
"mj_blend": 0.1,
|
||||
"mj_modal": 0.1,
|
||||
"mj_zoom": 0.1,
|
||||
"mj_shorten": 0.1,
|
||||
"mj_high_variation": 0.1,
|
||||
"mj_low_variation": 0.1,
|
||||
"mj_pan": 0.1,
|
||||
"mj_inpaint": 0,
|
||||
"mj_custom_zoom": 0,
|
||||
"mj_describe": 0.05,
|
||||
"mj_upscale": 0.05,
|
||||
"swap_face": 0.05,
|
||||
"mj_upload": 0.05,
|
||||
"sora-2": 0.3,
|
||||
"sora-2-pro": 0.5,
|
||||
"gpt-4o-mini-tts": 0.3,
|
||||
"veo-3.0-generate-001": 0.4,
|
||||
"veo-3.0-fast-generate-001": 0.15,
|
||||
"veo-3.1-generate-preview": 0.4,
|
||||
"veo-3.1-fast-generate-preview": 0.15,
|
||||
}
|
||||
|
||||
var defaultAudioRatio = map[string]float64{
|
||||
"gpt-4o-audio-preview": 16,
|
||||
"gpt-4o-mini-audio-preview": 66.67,
|
||||
"gpt-4o-realtime-preview": 8,
|
||||
"gpt-4o-mini-realtime-preview": 16.67,
|
||||
"gpt-4o-mini-tts": 25,
|
||||
}
|
||||
|
||||
var defaultAudioCompletionRatio = map[string]float64{
|
||||
"gpt-4o-realtime": 2,
|
||||
"gpt-4o-mini-realtime": 2,
|
||||
"gpt-4o-mini-tts": 1,
|
||||
"tts-1": 0,
|
||||
"tts-1-hd": 0,
|
||||
"tts-1-1106": 0,
|
||||
"tts-1-hd-1106": 0,
|
||||
}
|
||||
|
||||
var modelPriceMap = types.NewRWMap[string, float64]()
|
||||
var modelRatioMap = types.NewRWMap[string, float64]()
|
||||
var completionRatioMap = types.NewRWMap[string, float64]()
|
||||
|
||||
var defaultCompletionRatio = map[string]float64{
|
||||
"gpt-4-gizmo-*": 2,
|
||||
"gpt-4o-gizmo-*": 3,
|
||||
"gpt-4-all": 2,
|
||||
"gpt-image-1": 8,
|
||||
}
|
||||
|
||||
// InitRatioSettings initializes all model related settings maps
|
||||
func InitRatioSettings() {
|
||||
modelPriceMap.AddAll(defaultModelPrice)
|
||||
modelRatioMap.AddAll(defaultModelRatio)
|
||||
completionRatioMap.AddAll(defaultCompletionRatio)
|
||||
cacheRatioMap.AddAll(defaultCacheRatio)
|
||||
createCacheRatioMap.AddAll(defaultCreateCacheRatio)
|
||||
imageRatioMap.AddAll(defaultImageRatio)
|
||||
audioRatioMap.AddAll(defaultAudioRatio)
|
||||
audioCompletionRatioMap.AddAll(defaultAudioCompletionRatio)
|
||||
}
|
||||
|
||||
func GetModelPriceMap() map[string]float64 {
|
||||
return modelPriceMap.ReadAll()
|
||||
}
|
||||
|
||||
func ModelPrice2JSONString() string {
|
||||
return modelPriceMap.MarshalJSONString()
|
||||
}
|
||||
|
||||
func UpdateModelPriceByJSONString(jsonStr string) error {
|
||||
return types.LoadFromJsonStringWithCallback(modelPriceMap, jsonStr, InvalidateExposedDataCache)
|
||||
}
|
||||
|
||||
// GetModelPrice 返回模型的价格,如果模型不存在则返回-1,false
|
||||
func GetModelPrice(name string, printErr bool) (float64, bool) {
|
||||
name = FormatMatchingModelName(name)
|
||||
|
||||
if price, ok := modelPriceMap.Get(name); ok {
|
||||
return price, true
|
||||
}
|
||||
|
||||
if printErr {
|
||||
common.SysError("model price not found: " + name)
|
||||
}
|
||||
return -1, false
|
||||
}
|
||||
|
||||
func UpdateModelRatioByJSONString(jsonStr string) error {
|
||||
return types.LoadFromJsonStringWithCallback(modelRatioMap, jsonStr, InvalidateExposedDataCache)
|
||||
}
|
||||
|
||||
// 处理带有思考预算的模型名称,方便统一定价
|
||||
func handleThinkingBudgetModel(name, prefix, wildcard string) string {
|
||||
if strings.HasPrefix(name, prefix) && strings.Contains(name, "-thinking-") {
|
||||
return wildcard
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func GetModelRatio(name string) (float64, bool, string) {
|
||||
name = FormatMatchingModelName(name)
|
||||
|
||||
ratio, ok := modelRatioMap.Get(name)
|
||||
if !ok {
|
||||
return 37.5, operation_setting.SelfUseModeEnabled, name
|
||||
}
|
||||
return ratio, true, name
|
||||
}
|
||||
|
||||
func DefaultModelRatio2JSONString() string {
|
||||
jsonBytes, err := common.Marshal(defaultModelRatio)
|
||||
if err != nil {
|
||||
common.SysError("error marshalling model ratio: " + err.Error())
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
func GetDefaultModelRatioMap() map[string]float64 {
|
||||
return defaultModelRatio
|
||||
}
|
||||
|
||||
func GetDefaultModelPriceMap() map[string]float64 {
|
||||
return defaultModelPrice
|
||||
}
|
||||
|
||||
// GetDefaultPricingMaps returns independent copies for model-level reset and
|
||||
// first-write initialization; callers cannot mutate the built-in defaults.
|
||||
func GetDefaultPricingMaps() map[string]map[string]float64 {
|
||||
defaults := map[string]map[string]float64{
|
||||
"ModelPrice": defaultModelPrice, "ModelRatio": defaultModelRatio,
|
||||
"CompletionRatio": defaultCompletionRatio, "CacheRatio": defaultCacheRatio,
|
||||
"CreateCacheRatio": defaultCreateCacheRatio, "ImageRatio": defaultImageRatio,
|
||||
"AudioRatio": defaultAudioRatio, "AudioCompletionRatio": defaultAudioCompletionRatio,
|
||||
}
|
||||
result := make(map[string]map[string]float64, len(defaults))
|
||||
for key, values := range defaults {
|
||||
result[key] = make(map[string]float64, len(values))
|
||||
maps.Copy(result[key], values)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func CompletionRatio2JSONString() string {
|
||||
return completionRatioMap.MarshalJSONString()
|
||||
}
|
||||
|
||||
func UpdateCompletionRatioByJSONString(jsonStr string) error {
|
||||
return types.LoadFromJsonStringWithCallback(completionRatioMap, jsonStr, InvalidateExposedDataCache)
|
||||
}
|
||||
|
||||
func GetCompletionRatio(name string) float64 {
|
||||
name = FormatMatchingModelName(name)
|
||||
|
||||
if strings.Contains(name, "/") {
|
||||
if ratio, ok := completionRatioMap.Get(name); ok {
|
||||
return ratio
|
||||
}
|
||||
}
|
||||
hardCodedRatio, contain := getHardcodedCompletionModelRatio(name)
|
||||
if contain {
|
||||
return hardCodedRatio
|
||||
}
|
||||
if ratio, ok := completionRatioMap.Get(name); ok {
|
||||
return ratio
|
||||
}
|
||||
return hardCodedRatio
|
||||
}
|
||||
|
||||
type CompletionRatioInfo struct {
|
||||
Ratio float64 `json:"ratio"`
|
||||
Locked bool `json:"locked"`
|
||||
}
|
||||
|
||||
func GetCompletionRatioInfo(name string) CompletionRatioInfo {
|
||||
name = FormatMatchingModelName(name)
|
||||
|
||||
if strings.Contains(name, "/") {
|
||||
if ratio, ok := completionRatioMap.Get(name); ok {
|
||||
return CompletionRatioInfo{
|
||||
Ratio: ratio,
|
||||
Locked: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hardCodedRatio, locked := getHardcodedCompletionModelRatio(name)
|
||||
if locked {
|
||||
return CompletionRatioInfo{
|
||||
Ratio: hardCodedRatio,
|
||||
Locked: true,
|
||||
}
|
||||
}
|
||||
|
||||
if ratio, ok := completionRatioMap.Get(name); ok {
|
||||
return CompletionRatioInfo{
|
||||
Ratio: ratio,
|
||||
Locked: false,
|
||||
}
|
||||
}
|
||||
|
||||
return CompletionRatioInfo{
|
||||
Ratio: hardCodedRatio,
|
||||
Locked: false,
|
||||
}
|
||||
}
|
||||
|
||||
func getHardcodedCompletionModelRatio(name string) (float64, bool) {
|
||||
|
||||
isReservedModel := strings.HasSuffix(name, "-all") || strings.HasSuffix(name, "-gizmo-*")
|
||||
if isReservedModel {
|
||||
return 2, false
|
||||
}
|
||||
|
||||
if strings.HasPrefix(name, "gpt-") {
|
||||
if strings.HasPrefix(name, "gpt-4o") {
|
||||
if name == "gpt-4o-2024-05-13" {
|
||||
return 3, true
|
||||
}
|
||||
if strings.HasPrefix(name, "gpt-4o-mini-tts") {
|
||||
return 20, false
|
||||
}
|
||||
return 4, false
|
||||
}
|
||||
// gpt-5 匹配
|
||||
if strings.HasPrefix(name, "gpt-5") {
|
||||
if !strings.Contains(name, ".") {
|
||||
return 8, true
|
||||
}
|
||||
if strings.HasPrefix(name, "gpt-5.4") {
|
||||
if strings.HasPrefix(name, "gpt-5.4-nano") {
|
||||
return 6.25, true
|
||||
}
|
||||
return 6, true
|
||||
}
|
||||
// gpt-5.5 and later models are unlocked
|
||||
return 6, false
|
||||
}
|
||||
// gpt-4.5-preview匹配
|
||||
if strings.HasPrefix(name, "gpt-4.5-preview") {
|
||||
return 2, true
|
||||
}
|
||||
if strings.HasPrefix(name, "gpt-4-turbo") || strings.HasSuffix(name, "gpt-4-1106") || strings.HasSuffix(name, "gpt-4-1105") {
|
||||
return 3, true
|
||||
}
|
||||
// 没有特殊标记的 gpt-4 模型默认倍率为 2
|
||||
return 2, false
|
||||
}
|
||||
if strings.HasPrefix(name, "o1") || strings.HasPrefix(name, "o3") {
|
||||
return 4, true
|
||||
}
|
||||
if name == "chatgpt-4o-latest" {
|
||||
return 3, true
|
||||
}
|
||||
|
||||
if strings.Contains(name, "claude-3") {
|
||||
return 5, true
|
||||
} else if strings.Contains(name, "claude-sonnet-4") || strings.Contains(name, "claude-opus-4") || strings.Contains(name, "claude-haiku-4") {
|
||||
return 5, true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(name, "gpt-3.5") {
|
||||
if name == "gpt-3.5-turbo" || strings.HasSuffix(name, "0125") {
|
||||
// https://openai.com/blog/new-embedding-models-and-api-updates
|
||||
// Updated GPT-3.5 Turbo model and lower pricing
|
||||
return 3, true
|
||||
}
|
||||
if strings.HasSuffix(name, "1106") {
|
||||
return 2, true
|
||||
}
|
||||
return 4.0 / 3.0, true
|
||||
}
|
||||
if strings.HasPrefix(name, "mistral-") {
|
||||
return 3, true
|
||||
}
|
||||
if strings.HasPrefix(name, "gemini-") {
|
||||
if strings.HasPrefix(name, "gemini-1.5") {
|
||||
return 4, true
|
||||
} else if strings.HasPrefix(name, "gemini-2.0") {
|
||||
return 4, true
|
||||
} else if strings.HasPrefix(name, "gemini-2.5-pro") { // 移除preview来增加兼容性,这里假设正式版的倍率和preview一致
|
||||
return 8, false
|
||||
} else if strings.HasPrefix(name, "gemini-2.5-flash") { // 处理不同的flash模型倍率
|
||||
if strings.HasPrefix(name, "gemini-2.5-flash-preview") {
|
||||
return 3.5 / 0.15, false
|
||||
}
|
||||
if strings.HasPrefix(name, "gemini-2.5-flash-lite") {
|
||||
return 4, false
|
||||
}
|
||||
return 2.5 / 0.3, false
|
||||
} else if strings.HasPrefix(name, "gemini-robotics-er-1.5") {
|
||||
return 2.5 / 0.3, false
|
||||
} else if strings.HasPrefix(name, "gemini-3-pro") {
|
||||
if strings.HasPrefix(name, "gemini-3-pro-image") {
|
||||
return 60, false
|
||||
}
|
||||
return 6, false
|
||||
}
|
||||
return 4, false
|
||||
}
|
||||
if strings.HasPrefix(name, "command") {
|
||||
switch name {
|
||||
case "command-r":
|
||||
return 3, true
|
||||
case "command-r-plus":
|
||||
return 5, true
|
||||
case "command-r-08-2024":
|
||||
return 4, true
|
||||
case "command-r-plus-08-2024":
|
||||
return 4, true
|
||||
default:
|
||||
return 4, false
|
||||
}
|
||||
}
|
||||
// hint 只给官方上4倍率,由于开源模型供应商自行定价,不对其进行补全倍率进行强制对齐
|
||||
if strings.HasPrefix(name, "ERNIE-Speed-") {
|
||||
return 2, true
|
||||
} else if strings.HasPrefix(name, "ERNIE-Lite-") {
|
||||
return 2, true
|
||||
} else if strings.HasPrefix(name, "ERNIE-Character") {
|
||||
return 2, true
|
||||
} else if strings.HasPrefix(name, "ERNIE-Functions") {
|
||||
return 2, true
|
||||
}
|
||||
switch name {
|
||||
case "llama2-70b-4096":
|
||||
return 0.8 / 0.64, true
|
||||
case "llama3-8b-8192":
|
||||
return 2, true
|
||||
case "llama3-70b-8192":
|
||||
return 0.79 / 0.59, true
|
||||
}
|
||||
return 1, false
|
||||
}
|
||||
|
||||
func GetAudioRatio(name string) float64 {
|
||||
name = FormatMatchingModelName(name)
|
||||
if ratio, ok := audioRatioMap.Get(name); ok {
|
||||
return ratio
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func GetAudioCompletionRatio(name string) float64 {
|
||||
name = FormatMatchingModelName(name)
|
||||
if ratio, ok := audioCompletionRatioMap.Get(name); ok {
|
||||
return ratio
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func ContainsAudioRatio(name string) bool {
|
||||
name = FormatMatchingModelName(name)
|
||||
_, ok := audioRatioMap.Get(name)
|
||||
return ok
|
||||
}
|
||||
|
||||
func ContainsAudioCompletionRatio(name string) bool {
|
||||
name = FormatMatchingModelName(name)
|
||||
_, ok := audioCompletionRatioMap.Get(name)
|
||||
return ok
|
||||
}
|
||||
|
||||
func ModelRatio2JSONString() string {
|
||||
return modelRatioMap.MarshalJSONString()
|
||||
}
|
||||
|
||||
var defaultImageRatio = map[string]float64{
|
||||
"gpt-image-1": 2,
|
||||
}
|
||||
var imageRatioMap = types.NewRWMap[string, float64]()
|
||||
var audioRatioMap = types.NewRWMap[string, float64]()
|
||||
var audioCompletionRatioMap = types.NewRWMap[string, float64]()
|
||||
|
||||
func ImageRatio2JSONString() string {
|
||||
return imageRatioMap.MarshalJSONString()
|
||||
}
|
||||
|
||||
func UpdateImageRatioByJSONString(jsonStr string) error {
|
||||
return types.LoadFromJsonString(imageRatioMap, jsonStr)
|
||||
}
|
||||
|
||||
func GetImageRatio(name string) (float64, bool) {
|
||||
ratio, ok := imageRatioMap.Get(name)
|
||||
if !ok {
|
||||
return 1, false // Default to 1 if not found
|
||||
}
|
||||
return ratio, true
|
||||
}
|
||||
|
||||
func AudioRatio2JSONString() string {
|
||||
return audioRatioMap.MarshalJSONString()
|
||||
}
|
||||
|
||||
func UpdateAudioRatioByJSONString(jsonStr string) error {
|
||||
return types.LoadFromJsonStringWithCallback(audioRatioMap, jsonStr, InvalidateExposedDataCache)
|
||||
}
|
||||
|
||||
func AudioCompletionRatio2JSONString() string {
|
||||
return audioCompletionRatioMap.MarshalJSONString()
|
||||
}
|
||||
|
||||
func UpdateAudioCompletionRatioByJSONString(jsonStr string) error {
|
||||
return types.LoadFromJsonStringWithCallback(audioCompletionRatioMap, jsonStr, InvalidateExposedDataCache)
|
||||
}
|
||||
|
||||
func GetModelRatioCopy() map[string]float64 {
|
||||
return modelRatioMap.ReadAll()
|
||||
}
|
||||
|
||||
func GetModelPriceCopy() map[string]float64 {
|
||||
return modelPriceMap.ReadAll()
|
||||
}
|
||||
|
||||
func GetCompletionRatioCopy() map[string]float64 {
|
||||
return completionRatioMap.ReadAll()
|
||||
}
|
||||
|
||||
func GetImageRatioCopy() map[string]float64 {
|
||||
return imageRatioMap.ReadAll()
|
||||
}
|
||||
|
||||
func GetAudioRatioCopy() map[string]float64 {
|
||||
return audioRatioMap.ReadAll()
|
||||
}
|
||||
|
||||
func GetAudioCompletionRatioCopy() map[string]float64 {
|
||||
return audioCompletionRatioMap.ReadAll()
|
||||
}
|
||||
|
||||
// RoutingMatchModelName returns the name used for channel-ability and token-limit
|
||||
// fallback matching: strip @ modifiers and legacy aliases first, then apply
|
||||
// wildcard normalization.
|
||||
func RoutingMatchModelName(name string) string {
|
||||
return FormatMatchingModelName(hostreasoning.BaseModelName(name))
|
||||
}
|
||||
|
||||
// HasConfiguredModelRatio reports whether name has an explicit ratio entry
|
||||
// after wildcard normalization. Self-use fallback does not count.
|
||||
func HasConfiguredModelRatio(name string) bool {
|
||||
name = FormatMatchingModelName(name)
|
||||
_, ok := modelRatioMap.Get(name)
|
||||
return ok
|
||||
}
|
||||
|
||||
// 转换模型名,减少渠道必须配置各种带参数模型
|
||||
func FormatMatchingModelName(name string) string {
|
||||
if strings.HasPrefix(name, "gemini-2.5-flash-lite") {
|
||||
name = handleThinkingBudgetModel(name, "gemini-2.5-flash-lite", "gemini-2.5-flash-lite-thinking-*")
|
||||
} else if strings.HasPrefix(name, "gemini-2.5-flash") {
|
||||
name = handleThinkingBudgetModel(name, "gemini-2.5-flash", "gemini-2.5-flash-thinking-*")
|
||||
} else if strings.HasPrefix(name, "gemini-2.5-pro") {
|
||||
name = handleThinkingBudgetModel(name, "gemini-2.5-pro", "gemini-2.5-pro-thinking-*")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(name, "gpt-4-gizmo") {
|
||||
name = "gpt-4-gizmo-*"
|
||||
}
|
||||
if strings.HasPrefix(name, "gpt-4o-gizmo") {
|
||||
name = "gpt-4o-gizmo-*"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// result: 倍率or价格, usePrice, exist
|
||||
func GetModelRatioOrPrice(model string) (float64, bool, bool) { // price or ratio
|
||||
price, usePrice := GetModelPrice(model, false)
|
||||
if usePrice {
|
||||
return price, true, true
|
||||
}
|
||||
modelRatio, success, _ := GetModelRatio(model)
|
||||
if success {
|
||||
return modelRatio, false, true
|
||||
}
|
||||
return 37.5, false, false
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// Package reasoning re-exports the pure model-name effort-suffix helpers,
|
||||
// which moved to the conversion kit (relaykit/relayconvert/reasoning) as part
|
||||
// of the relaykit extraction. Host code keeps importing this path unchanged.
|
||||
package reasoning
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
)
|
||||
|
||||
var (
|
||||
EffortSuffixes = kitreasoning.EffortSuffixes
|
||||
OpenAIEffortSuffixes = kitreasoning.OpenAIEffortSuffixes
|
||||
DeepSeekV4EffortSuffixes = kitreasoning.DeepSeekV4EffortSuffixes
|
||||
)
|
||||
|
||||
var (
|
||||
TrimEffortSuffixWithSuffixes = kitreasoning.TrimEffortSuffixWithSuffixes
|
||||
ParseDeepSeekV4ThinkingSuffix = kitreasoning.ParseDeepSeekV4ThinkingSuffix
|
||||
TrimGeminiThinkingSuffix = kitreasoning.TrimGeminiThinkingSuffix
|
||||
)
|
||||
|
||||
// ParseOpenAIReasoningEffortFromModelSuffix applies RelayKit's positive family
|
||||
// whitelist and the host EffortTailModelIDs escape hatch so real model IDs
|
||||
// such as gpt-5.1-codex-max remain opaque.
|
||||
func ParseOpenAIReasoningEffortFromModelSuffix(modelName string) (string, string) {
|
||||
return kitreasoning.ParseOpenAIReasoningEffortFromModelSuffix(modelName, model_setting.ShouldPreserveEffortTail)
|
||||
}
|
||||
|
||||
// ParseLegacyModelSuffix parses the old naked aliases only for positively
|
||||
// matched GPT/o-series, Claude, and Gemini model families. The provider prefix
|
||||
// before the final path segment is kept opaque.
|
||||
func ParseLegacyModelSuffix(modelName string, allowClaudeThinkingAlias bool, allowGeminiThinkingAlias bool) (string, kitreasoning.Intent, bool, error) {
|
||||
prefix, bare := splitModelNamespace(modelName)
|
||||
|
||||
var (
|
||||
base string
|
||||
intent kitreasoning.Intent
|
||||
found bool
|
||||
err error
|
||||
)
|
||||
switch {
|
||||
case strings.HasPrefix(bare, "claude-"):
|
||||
base, intent, found, err = kitreasoning.ParseClaudeModelSuffix(bare, allowClaudeThinkingAlias)
|
||||
case strings.HasPrefix(bare, "gemini-"):
|
||||
base, intent, found, err = kitreasoning.ParseGeminiModelSuffix(bare, allowGeminiThinkingAlias)
|
||||
default:
|
||||
effort, openAIBase := ParseOpenAIReasoningEffortFromModelSuffix(bare)
|
||||
if effort == "" {
|
||||
return modelName, kitreasoning.Intent{}, false, nil
|
||||
}
|
||||
parsedEffort, parseErr := kitreasoning.ParseEffort(effort)
|
||||
if parseErr != nil {
|
||||
return modelName, kitreasoning.Intent{}, false, parseErr
|
||||
}
|
||||
mode := kitreasoning.ModeEnabled
|
||||
if parsedEffort == kitreasoning.EffortNone {
|
||||
mode = kitreasoning.ModeDisabled
|
||||
}
|
||||
base = openAIBase
|
||||
intent = kitreasoning.Intent{Mode: mode, Effort: parsedEffort, Source: kitreasoning.SourceSuffix}
|
||||
found = true
|
||||
}
|
||||
if err != nil || !found {
|
||||
return modelName, kitreasoning.Intent{}, false, err
|
||||
}
|
||||
return prefix + base, intent, true, nil
|
||||
}
|
||||
|
||||
// BaseModelName strips explicit model modifiers and any enabled legacy alias.
|
||||
// Names on the thinking-suffix blacklist stay verbatim, including @ tails.
|
||||
// Malformed legacy aliases stay intact so request conversion can report the
|
||||
// precise validation error later.
|
||||
func BaseModelName(modelName string) string {
|
||||
if model_setting.ShouldPreserveThinkingSuffix(modelName) {
|
||||
return modelName
|
||||
}
|
||||
base := kitreasoning.ParseModelModifiers(modelName).Base
|
||||
if model_setting.ShouldPreserveThinkingSuffix(base) {
|
||||
return base
|
||||
}
|
||||
legacyBase, _, found, err := ParseLegacyModelSuffix(
|
||||
base,
|
||||
model_setting.GetClaudeSettings().ThinkingAdapterEnabled,
|
||||
model_setting.GetGeminiSettings().ThinkingAdapterEnabled,
|
||||
)
|
||||
if err != nil {
|
||||
return base
|
||||
}
|
||||
if found {
|
||||
return legacyBase
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func splitModelNamespace(modelName string) (string, string) {
|
||||
if slash := strings.LastIndex(modelName, "/"); slash >= 0 {
|
||||
return modelName[:slash+1], modelName[slash+1:]
|
||||
}
|
||||
return "", modelName
|
||||
}
|
||||
|
||||
// CanonicalBillingModelNames returns specificity-descending canonical billing
|
||||
// name candidates (without the raw request name or the bare base). Explicit
|
||||
// @ modifiers and legacy aliases normalize through the same Intent, so order,
|
||||
// duplicates, and case do not matter. Temperature and topp never appear.
|
||||
func CanonicalBillingModelNames(modelName string) []string {
|
||||
if model_setting.ShouldPreserveThinkingSuffix(modelName) {
|
||||
return nil
|
||||
}
|
||||
spec := kitreasoning.ParseModelModifiers(modelName)
|
||||
base := spec.Base
|
||||
intent, hasThinking := billingIntentFromModifiers(spec)
|
||||
|
||||
if !model_setting.ShouldPreserveThinkingSuffix(base) {
|
||||
legacyBase, legacyIntent, found, err := ParseLegacyModelSuffix(
|
||||
base,
|
||||
model_setting.GetClaudeSettings().ThinkingAdapterEnabled,
|
||||
model_setting.GetGeminiSettings().ThinkingAdapterEnabled,
|
||||
)
|
||||
if err == nil && found {
|
||||
base = legacyBase
|
||||
if !hasThinking {
|
||||
intent = legacyIntent
|
||||
hasThinking = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasThinking {
|
||||
return nil
|
||||
}
|
||||
return canonicalNamesFromIntent(base, intent)
|
||||
}
|
||||
|
||||
func billingIntentFromModifiers(spec kitreasoning.ModelModifierSpec) (kitreasoning.Intent, bool) {
|
||||
last := make(map[string]int, len(spec.Modifiers))
|
||||
for index, modifier := range spec.Modifiers {
|
||||
last[modifier.Key] = index
|
||||
}
|
||||
|
||||
var (
|
||||
intent kitreasoning.Intent
|
||||
hasThinking bool
|
||||
)
|
||||
for index, modifier := range spec.Modifiers {
|
||||
if last[modifier.Key] != index {
|
||||
continue
|
||||
}
|
||||
switch modifier.Key {
|
||||
case "thinking":
|
||||
parsed, ok := kitreasoning.ParseThinkingModifier(modifier.Value)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if hasThinking && parsed.Mode != kitreasoning.ModeDisabled && parsed.BudgetTokens == nil {
|
||||
intent.Mode = parsed.Mode
|
||||
intent.Source = kitreasoning.SourceSuffix
|
||||
} else if hasThinking && parsed.Mode != kitreasoning.ModeDisabled {
|
||||
intent.Mode = parsed.Mode
|
||||
intent.BudgetTokens = parsed.BudgetTokens
|
||||
intent.BudgetSource = parsed.BudgetSource
|
||||
intent.Source = kitreasoning.SourceSuffix
|
||||
} else {
|
||||
intent = parsed
|
||||
}
|
||||
hasThinking = true
|
||||
case "effort":
|
||||
effort, err := kitreasoning.ParseEffort(modifier.Value)
|
||||
if err != nil || effort == "" {
|
||||
continue
|
||||
}
|
||||
if effort == kitreasoning.EffortNone {
|
||||
intent = kitreasoning.Intent{Mode: kitreasoning.ModeDisabled, Effort: kitreasoning.EffortNone, Source: kitreasoning.SourceSuffix}
|
||||
} else {
|
||||
if intent.Mode == kitreasoning.ModeUnset || intent.Mode == kitreasoning.ModeDisabled {
|
||||
intent.Mode = kitreasoning.ModeEnabled
|
||||
}
|
||||
intent.Effort = effort
|
||||
intent.Source = kitreasoning.SourceSuffix
|
||||
}
|
||||
hasThinking = true
|
||||
}
|
||||
}
|
||||
return intent, hasThinking
|
||||
}
|
||||
|
||||
func canonicalNamesFromIntent(base string, intent kitreasoning.Intent) []string {
|
||||
thinking, effort, ok := normalizeBillingThinking(intent)
|
||||
if !ok || base == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var names []string
|
||||
if effort != "" {
|
||||
names = append(names, base+"@effort:"+effort+"@thinking:"+thinking)
|
||||
}
|
||||
thinkingForm := base + "@thinking:" + thinking
|
||||
if thinkingForm != base {
|
||||
names = append(names, thinkingForm)
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(names))
|
||||
out := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
if name == "" || name == base {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[name]; exists {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeBillingThinking(intent kitreasoning.Intent) (thinking string, effort string, ok bool) {
|
||||
if intent.Effort == kitreasoning.EffortNone || intent.Mode == kitreasoning.ModeDisabled {
|
||||
return "off", "", true
|
||||
}
|
||||
if intent.BudgetTokens != nil && *intent.BudgetTokens == 0 {
|
||||
return "off", "", true
|
||||
}
|
||||
if intent.Effort != "" && intent.Effort != kitreasoning.EffortNone {
|
||||
return "on", strings.ToLower(string(intent.Effort)), true
|
||||
}
|
||||
if intent.Mode == kitreasoning.ModeEnabled || intent.Mode == kitreasoning.ModeAdaptive || intent.BudgetTokens != nil {
|
||||
return "on", "", true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package reasoning
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCanonicalBillingModelNames(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "thinking on",
|
||||
in: "qwen3-max@thinking:on",
|
||||
want: []string{"qwen3-max@thinking:on"},
|
||||
},
|
||||
{
|
||||
name: "shuffled temperature and thinking",
|
||||
in: "qwen3-max@temperature:0.2@thinking:on",
|
||||
want: []string{"qwen3-max@thinking:on"},
|
||||
},
|
||||
{
|
||||
name: "thinking first then temperature",
|
||||
in: "qwen3-max@thinking:on@temperature:0.2",
|
||||
want: []string{"qwen3-max@thinking:on"},
|
||||
},
|
||||
{
|
||||
name: "budget normalizes to on",
|
||||
in: "qwen3-max@thinking:8192",
|
||||
want: []string{"qwen3-max@thinking:on"},
|
||||
},
|
||||
{
|
||||
name: "minus one normalizes to on",
|
||||
in: "qwen3-max@thinking:-1",
|
||||
want: []string{"qwen3-max@thinking:on"},
|
||||
},
|
||||
{
|
||||
name: "adaptive normalizes to on",
|
||||
in: "qwen3-max@thinking:adaptive",
|
||||
want: []string{"qwen3-max@thinking:on"},
|
||||
},
|
||||
{
|
||||
name: "thinking off",
|
||||
in: "qwen3-max@thinking:off",
|
||||
want: []string{"qwen3-max@thinking:off"},
|
||||
},
|
||||
{
|
||||
name: "effort none becomes thinking off",
|
||||
in: "qwen3-max@effort:none",
|
||||
want: []string{"qwen3-max@thinking:off"},
|
||||
},
|
||||
{
|
||||
name: "effort high implies thinking on",
|
||||
in: "qwen3-max@effort:high",
|
||||
want: []string{"qwen3-max@effort:high@thinking:on", "qwen3-max@thinking:on"},
|
||||
},
|
||||
{
|
||||
name: "effort and thinking keys sorted",
|
||||
in: "qwen3-max@thinking:on@effort:high@temperature:0.2",
|
||||
want: []string{"qwen3-max@effort:high@thinking:on", "qwen3-max@thinking:on"},
|
||||
},
|
||||
{
|
||||
name: "duplicate last wins then normalize",
|
||||
in: "qwen3-max@thinking:off@thinking:on@effort:low@effort:high",
|
||||
want: []string{"qwen3-max@effort:high@thinking:on", "qwen3-max@thinking:on"},
|
||||
},
|
||||
{
|
||||
name: "legacy thinking alias",
|
||||
in: "claude-3-7-sonnet-thinking",
|
||||
want: []string{"claude-3-7-sonnet@thinking:on"},
|
||||
},
|
||||
{
|
||||
name: "legacy thinking budget matches explicit budget",
|
||||
in: "gemini-2.5-flash-thinking-8192",
|
||||
want: []string{"gemini-2.5-flash@thinking:on"},
|
||||
},
|
||||
{
|
||||
name: "legacy nothinking",
|
||||
in: "claude-3-7-sonnet-nothinking",
|
||||
want: []string{"claude-3-7-sonnet@thinking:off"},
|
||||
},
|
||||
{
|
||||
name: "temperature only has no reasoning state",
|
||||
in: "qwen3-max@temperature:0.7",
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
geminiSettings := model_setting.GetGeminiSettings()
|
||||
oldGemini := geminiSettings.ThinkingAdapterEnabled
|
||||
geminiSettings.ThinkingAdapterEnabled = true
|
||||
t.Cleanup(func() { geminiSettings.ThinkingAdapterEnabled = oldGemini })
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, CanonicalBillingModelNames(tt.in))
|
||||
})
|
||||
}
|
||||
|
||||
assert.Equal(t,
|
||||
CanonicalBillingModelNames("gemini-2.5-flash@thinking:8192"),
|
||||
CanonicalBillingModelNames("gemini-2.5-flash-thinking-8192"),
|
||||
)
|
||||
assert.Equal(t, "gpt-5.1-codex-max", BaseModelName("gpt-5.1-codex-max"))
|
||||
assert.Empty(t, CanonicalBillingModelNames("gpt-5.1-codex-max"))
|
||||
}
|
||||
|
||||
func TestParseOpenAIReasoningEffortPreservesCodexMax(t *testing.T) {
|
||||
effort, base := ParseOpenAIReasoningEffortFromModelSuffix("gpt-5.1-codex-max")
|
||||
assert.Empty(t, effort)
|
||||
assert.Equal(t, "gpt-5.1-codex-max", base)
|
||||
}
|
||||
|
||||
func TestBaseModelNameStripsModifiers(t *testing.T) {
|
||||
require.Equal(t, "qwen3-max", BaseModelName("qwen3-max@thinking:on@temperature:0.2"))
|
||||
}
|
||||
|
||||
func TestExemptAtNameIsOpaqueForBillingIdentity(t *testing.T) {
|
||||
settings := model_setting.GetGlobalSettings()
|
||||
original := append([]string(nil), settings.ThinkingModelBlacklist...)
|
||||
t.Cleanup(func() { settings.ThinkingModelBlacklist = original })
|
||||
settings.ThinkingModelBlacklist = append(original, "re:.*@sha256:.*")
|
||||
|
||||
const model = "opaque@sha256:deadbeef"
|
||||
assert.Equal(t, model, BaseModelName(model))
|
||||
assert.Empty(t, CanonicalBillingModelNames(model))
|
||||
assert.Equal(t, "kimi-k2-thinking", BaseModelName("kimi-k2-thinking"))
|
||||
assert.Empty(t, CanonicalBillingModelNames("kimi-k2-thinking"))
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package setting
|
||||
|
||||
import "strings"
|
||||
|
||||
var CheckSensitiveEnabled = true
|
||||
var CheckSensitiveOnPromptEnabled = true
|
||||
|
||||
//var CheckSensitiveOnCompletionEnabled = true
|
||||
|
||||
// StopOnSensitiveEnabled 如果检测到敏感词,是否立刻停止生成,否则替换敏感词
|
||||
var StopOnSensitiveEnabled = true
|
||||
|
||||
// StreamCacheQueueLength 流模式缓存队列长度,0表示无缓存
|
||||
var StreamCacheQueueLength = 0
|
||||
|
||||
// SensitiveWords 敏感词
|
||||
// var SensitiveWords []string
|
||||
var SensitiveWords = []string{
|
||||
"test_sensitive",
|
||||
}
|
||||
|
||||
func SensitiveWordsToString() string {
|
||||
return strings.Join(SensitiveWords, "\n")
|
||||
}
|
||||
|
||||
func SensitiveWordsFromString(s string) {
|
||||
SensitiveWords = []string{}
|
||||
sw := strings.SplitSeq(s, "\n")
|
||||
for w := range sw {
|
||||
w = strings.TrimSpace(w)
|
||||
if w != "" {
|
||||
SensitiveWords = append(SensitiveWords, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ShouldCheckPromptSensitive() bool {
|
||||
return CheckSensitiveEnabled && CheckSensitiveOnPromptEnabled
|
||||
}
|
||||
|
||||
//func ShouldCheckCompletionSensitive() bool {
|
||||
// return CheckSensitiveEnabled && CheckSensitiveOnCompletionEnabled
|
||||
//}
|
||||
@@ -0,0 +1,21 @@
|
||||
package system_setting
|
||||
|
||||
import "github.com/QuantumNous/new-api/setting/config"
|
||||
|
||||
type DiscordSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ClientId string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
var defaultDiscordSettings = DiscordSettings{}
|
||||
|
||||
func init() {
|
||||
// 注册到全局配置管理器
|
||||
config.GlobalConfig.Register("discord", &defaultDiscordSettings)
|
||||
}
|
||||
|
||||
func GetDiscordSettings() *DiscordSettings {
|
||||
return &defaultDiscordSettings
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package system_setting
|
||||
|
||||
import "github.com/QuantumNous/new-api/setting/config"
|
||||
|
||||
type FetchSetting struct {
|
||||
EnableSSRFProtection bool `json:"enable_ssrf_protection"` // 是否启用SSRF防护
|
||||
AllowPrivateIp bool `json:"allow_private_ip"`
|
||||
DomainFilterMode bool `json:"domain_filter_mode"` // 域名过滤模式,true: 白名单模式,false: 黑名单模式
|
||||
IpFilterMode bool `json:"ip_filter_mode"` // IP过滤模式,true: 白名单模式,false: 黑名单模式
|
||||
DomainList []string `json:"domain_list"` // domain format, e.g. example.com, *.example.com
|
||||
IpList []string `json:"ip_list"` // CIDR format
|
||||
AllowedPorts []string `json:"allowed_ports"` // port range format, e.g. 80, 443, 8000-9000
|
||||
ApplyIPFilterForDomain bool `json:"apply_ip_filter_for_domain"` // 对域名启用IP过滤(实验性)
|
||||
}
|
||||
|
||||
var defaultFetchSetting = FetchSetting{
|
||||
EnableSSRFProtection: true, // 默认开启SSRF防护
|
||||
AllowPrivateIp: false,
|
||||
DomainFilterMode: false,
|
||||
IpFilterMode: false,
|
||||
DomainList: []string{},
|
||||
IpList: []string{},
|
||||
AllowedPorts: []string{"80", "443", "8080", "8443"},
|
||||
ApplyIPFilterForDomain: true,
|
||||
}
|
||||
|
||||
func init() {
|
||||
// 注册到全局配置管理器
|
||||
config.GlobalConfig.Register("fetch_setting", &defaultFetchSetting)
|
||||
}
|
||||
|
||||
func GetFetchSetting() *FetchSetting {
|
||||
return &defaultFetchSetting
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package system_setting
|
||||
|
||||
import "github.com/QuantumNous/new-api/setting/config"
|
||||
|
||||
type LegalSettings struct {
|
||||
UserAgreement string `json:"user_agreement"`
|
||||
PrivacyPolicy string `json:"privacy_policy"`
|
||||
}
|
||||
|
||||
var defaultLegalSettings = LegalSettings{
|
||||
UserAgreement: "",
|
||||
PrivacyPolicy: "",
|
||||
}
|
||||
|
||||
func init() {
|
||||
config.GlobalConfig.Register("legal", &defaultLegalSettings)
|
||||
}
|
||||
|
||||
func GetLegalSettings() *LegalSettings {
|
||||
return &defaultLegalSettings
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package system_setting
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/setting/config"
|
||||
)
|
||||
|
||||
type OIDCSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ClientId string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
WellKnown string `json:"well_known"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
UserInfoEndpoint string `json:"user_info_endpoint"`
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
var defaultOIDCSettings = OIDCSettings{}
|
||||
|
||||
func init() {
|
||||
// 注册到全局配置管理器
|
||||
config.GlobalConfig.Register("oidc", &defaultOIDCSettings)
|
||||
}
|
||||
|
||||
func GetOIDCSettings() *OIDCSettings {
|
||||
return &defaultOIDCSettings
|
||||
}
|
||||
|
||||
// GetEffectiveDisplayName returns the admin-configured display name, or the
|
||||
// literal "OIDC" when none has been set. Centralizing this fallback keeps the
|
||||
// default in one place for both the OAuth provider name and the public
|
||||
// status payload.
|
||||
func (s *OIDCSettings) GetEffectiveDisplayName() string {
|
||||
if trimmed := strings.TrimSpace(s.DisplayName); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
return "OIDC"
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package system_setting
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/setting/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOIDCSettings_GetEffectiveDisplayName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
displayName string
|
||||
want string
|
||||
}{
|
||||
{name: "blank falls back to OIDC", displayName: "", want: "OIDC"},
|
||||
{name: "custom name is returned verbatim", displayName: "Acme SSO", want: "Acme SSO"},
|
||||
{name: "whitespace-only falls back to OIDC", displayName: " ", want: "OIDC"},
|
||||
{name: "surrounding whitespace is trimmed", displayName: " Acme SSO ", want: "Acme SSO"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := &OIDCSettings{DisplayName: tt.displayName}
|
||||
assert.Equal(t, tt.want, s.GetEffectiveDisplayName())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCSettings_DisplayNamePersistenceRoundTrip(t *testing.T) {
|
||||
settings := &OIDCSettings{DisplayName: " Acme SSO "}
|
||||
manager := config.NewConfigManager()
|
||||
manager.Register("oidc", settings)
|
||||
|
||||
saved := make(map[string]string)
|
||||
require.NoError(t, manager.SaveToDB(func(key, value string) error {
|
||||
saved[key] = value
|
||||
return nil
|
||||
}))
|
||||
require.Equal(t, " Acme SSO ", saved["oidc.display_name"])
|
||||
|
||||
settings.DisplayName = ""
|
||||
require.NoError(t, manager.LoadFromDB(saved))
|
||||
assert.Equal(t, " Acme SSO ", settings.DisplayName)
|
||||
assert.Equal(t, "Acme SSO", settings.GetEffectiveDisplayName())
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package system_setting
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/setting/config"
|
||||
)
|
||||
|
||||
type PasskeySettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
RPDisplayName string `json:"rp_display_name"`
|
||||
RPID string `json:"rp_id"`
|
||||
Origins string `json:"origins"`
|
||||
AllowInsecureOrigin bool `json:"allow_insecure_origin"`
|
||||
UserVerification string `json:"user_verification"`
|
||||
AttachmentPreference string `json:"attachment_preference"`
|
||||
}
|
||||
|
||||
var defaultPasskeySettings = PasskeySettings{
|
||||
Enabled: false,
|
||||
RPDisplayName: common.SystemName,
|
||||
RPID: "",
|
||||
Origins: "",
|
||||
AllowInsecureOrigin: false,
|
||||
UserVerification: "preferred",
|
||||
AttachmentPreference: "",
|
||||
}
|
||||
|
||||
func init() {
|
||||
config.GlobalConfig.Register("passkey", &defaultPasskeySettings)
|
||||
}
|
||||
|
||||
func GetPasskeySettings() *PasskeySettings {
|
||||
if defaultPasskeySettings.RPID == "" && ServerAddress != "" {
|
||||
// 从ServerAddress提取域名作为RPID
|
||||
// ServerAddress可能是 "https://newapi.pro" 这种格式
|
||||
serverAddr := strings.TrimSpace(ServerAddress)
|
||||
if parsed, err := url.Parse(serverAddr); err == nil && parsed.Host != "" {
|
||||
defaultPasskeySettings.RPID = parsed.Host
|
||||
} else {
|
||||
defaultPasskeySettings.RPID = serverAddr
|
||||
}
|
||||
}
|
||||
if defaultPasskeySettings.Origins == "" || defaultPasskeySettings.Origins == "[]" {
|
||||
defaultPasskeySettings.Origins = ServerAddress
|
||||
}
|
||||
return &defaultPasskeySettings
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package system_setting
|
||||
|
||||
var ServerAddress = "http://localhost:3000"
|
||||
var TaskPublicAddress = ""
|
||||
var WorkerUrl = ""
|
||||
var WorkerValidKey = ""
|
||||
var WorkerAllowHttpImageRequestEnabled = false
|
||||
|
||||
func EnableWorker() bool {
|
||||
return WorkerUrl != ""
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package system_setting
|
||||
|
||||
import "github.com/QuantumNous/new-api/common"
|
||||
|
||||
const (
|
||||
DefaultTaskArtifactInvalidRateLimitPerMinute = 60
|
||||
DefaultTaskArtifactGlobalConcurrency = 128
|
||||
DefaultTaskArtifactIPConcurrency = 64
|
||||
DefaultTaskArtifactObjectConcurrency = 16
|
||||
)
|
||||
|
||||
const (
|
||||
TaskArtifactInvalidRateLimitEnv = "TASK_ARTIFACT_INVALID_RATE_LIMIT_PER_MINUTE"
|
||||
TaskArtifactGlobalLimitEnv = "TASK_ARTIFACT_GLOBAL_CONCURRENCY"
|
||||
TaskArtifactIPLimitEnv = "TASK_ARTIFACT_IP_CONCURRENCY"
|
||||
TaskArtifactObjectLimitEnv = "TASK_ARTIFACT_OBJECT_CONCURRENCY"
|
||||
)
|
||||
|
||||
type TaskArtifactAccessLimits struct {
|
||||
InvalidRatePerMinute int
|
||||
GlobalConcurrency int
|
||||
IPConcurrency int
|
||||
ObjectConcurrency int
|
||||
}
|
||||
|
||||
func positiveTaskArtifactLimit(env string, defaultValue int) int {
|
||||
value := common.GetEnvOrDefault(env, defaultValue)
|
||||
if value <= 0 {
|
||||
common.SysError(env + " must be positive; using default")
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// LoadTaskArtifactAccessLimits reads startup configuration. Invalid and
|
||||
// non-positive values safely fall back to the documented defaults.
|
||||
func LoadTaskArtifactAccessLimits() TaskArtifactAccessLimits {
|
||||
return TaskArtifactAccessLimits{
|
||||
InvalidRatePerMinute: positiveTaskArtifactLimit(
|
||||
TaskArtifactInvalidRateLimitEnv,
|
||||
DefaultTaskArtifactInvalidRateLimitPerMinute,
|
||||
),
|
||||
GlobalConcurrency: positiveTaskArtifactLimit(
|
||||
TaskArtifactGlobalLimitEnv,
|
||||
DefaultTaskArtifactGlobalConcurrency,
|
||||
),
|
||||
IPConcurrency: positiveTaskArtifactLimit(
|
||||
TaskArtifactIPLimitEnv,
|
||||
DefaultTaskArtifactIPConcurrency,
|
||||
),
|
||||
ObjectConcurrency: positiveTaskArtifactLimit(
|
||||
TaskArtifactObjectLimitEnv,
|
||||
DefaultTaskArtifactObjectConcurrency,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package system_setting
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
)
|
||||
|
||||
const (
|
||||
TaskArtifactStoreModeUpstream = "upstream"
|
||||
TaskArtifactStoreModeS3 = "s3"
|
||||
|
||||
DefaultTaskArtifactStorePresignTTLSeconds = 900
|
||||
MaxTaskArtifactStorePresignTTLSeconds = 7 * 24 * 60 * 60
|
||||
)
|
||||
|
||||
const (
|
||||
TaskArtifactStoreModeEnv = "TASK_ARTIFACT_STORE_MODE"
|
||||
TaskArtifactStoreS3EndpointEnv = "TASK_ARTIFACT_STORE_S3_ENDPOINT"
|
||||
TaskArtifactStoreS3BucketEnv = "TASK_ARTIFACT_STORE_S3_BUCKET"
|
||||
TaskArtifactStoreS3RegionEnv = "TASK_ARTIFACT_STORE_S3_REGION"
|
||||
TaskArtifactStoreS3AccessKeyEnv = "TASK_ARTIFACT_STORE_S3_ACCESS_KEY"
|
||||
TaskArtifactStoreS3SecretKeyEnv = "TASK_ARTIFACT_STORE_S3_SECRET_KEY"
|
||||
TaskArtifactStoreS3PrefixEnv = "TASK_ARTIFACT_STORE_S3_PREFIX"
|
||||
TaskArtifactStoreS3PresignTTLEnv = "TASK_ARTIFACT_STORE_S3_PRESIGN_TTL"
|
||||
)
|
||||
|
||||
var (
|
||||
taskArtifactStoreBucketPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$`)
|
||||
taskArtifactStoreRegionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
||||
)
|
||||
|
||||
// TaskArtifactStoreConfig reserves the configuration contract for a future S3
|
||||
// implementation. The current release always falls back to upstream proxying.
|
||||
type TaskArtifactStoreConfig struct {
|
||||
Mode string
|
||||
S3Endpoint string
|
||||
S3Bucket string
|
||||
S3Region string
|
||||
S3AccessKey string
|
||||
S3SecretKey string
|
||||
S3Prefix string
|
||||
S3PresignTTLSeconds int
|
||||
}
|
||||
|
||||
// LoadTaskArtifactStoreConfig reads and validates startup-only configuration.
|
||||
// S3 mode is deliberately disabled until a storage implementation is shipped.
|
||||
func LoadTaskArtifactStoreConfig() TaskArtifactStoreConfig {
|
||||
config := TaskArtifactStoreConfig{
|
||||
Mode: common.GetEnvOrDefaultString(TaskArtifactStoreModeEnv, TaskArtifactStoreModeUpstream),
|
||||
S3Endpoint: common.GetEnvOrDefaultString(TaskArtifactStoreS3EndpointEnv, ""),
|
||||
S3Bucket: common.GetEnvOrDefaultString(TaskArtifactStoreS3BucketEnv, ""),
|
||||
S3Region: common.GetEnvOrDefaultString(TaskArtifactStoreS3RegionEnv, ""),
|
||||
S3AccessKey: common.GetEnvOrDefaultString(TaskArtifactStoreS3AccessKeyEnv, ""),
|
||||
S3SecretKey: common.GetEnvOrDefaultString(TaskArtifactStoreS3SecretKeyEnv, ""),
|
||||
S3Prefix: common.GetEnvOrDefaultString(TaskArtifactStoreS3PrefixEnv, ""),
|
||||
S3PresignTTLSeconds: common.GetEnvOrDefault(TaskArtifactStoreS3PresignTTLEnv, DefaultTaskArtifactStorePresignTTLSeconds),
|
||||
}
|
||||
if err := ValidateTaskArtifactStoreConfig(config); err != nil {
|
||||
common.SysError("invalid task artifact store configuration: " + err.Error() + "; using upstream mode")
|
||||
config.Mode = TaskArtifactStoreModeUpstream
|
||||
return config
|
||||
}
|
||||
if config.Mode == TaskArtifactStoreModeS3 {
|
||||
common.SysError("task artifact S3 storage is not implemented; using upstream mode")
|
||||
config.Mode = TaskArtifactStoreModeUpstream
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// ValidateTaskArtifactStoreConfig performs syntax checks only. It never
|
||||
// resolves hosts, contacts an endpoint, or verifies credentials.
|
||||
func ValidateTaskArtifactStoreConfig(config TaskArtifactStoreConfig) error {
|
||||
if config.Mode != TaskArtifactStoreModeUpstream && config.Mode != TaskArtifactStoreModeS3 {
|
||||
return fmt.Errorf("unsupported mode %q", config.Mode)
|
||||
}
|
||||
if config.S3PresignTTLSeconds <= 0 || config.S3PresignTTLSeconds > MaxTaskArtifactStorePresignTTLSeconds {
|
||||
return fmt.Errorf("S3 presign TTL must be between 1 and %d seconds", MaxTaskArtifactStorePresignTTLSeconds)
|
||||
}
|
||||
|
||||
requireS3Fields := config.Mode == TaskArtifactStoreModeS3
|
||||
if requireS3Fields && config.S3Endpoint == "" {
|
||||
return errors.New("S3 endpoint is required")
|
||||
}
|
||||
if config.S3Endpoint != "" {
|
||||
if config.S3Endpoint != strings.TrimSpace(config.S3Endpoint) {
|
||||
return errors.New("S3 endpoint must not contain surrounding whitespace")
|
||||
}
|
||||
endpoint, err := url.Parse(config.S3Endpoint)
|
||||
if err != nil || endpoint == nil || endpoint.Host == "" || endpoint.User != nil || endpoint.Opaque != "" {
|
||||
return errors.New("S3 endpoint must be an absolute URL without userinfo")
|
||||
}
|
||||
if endpoint.Scheme != "http" && endpoint.Scheme != "https" {
|
||||
return errors.New("S3 endpoint must use http or https")
|
||||
}
|
||||
if endpoint.RawQuery != "" || endpoint.ForceQuery || endpoint.Fragment != "" {
|
||||
return errors.New("S3 endpoint must not contain a query or fragment")
|
||||
}
|
||||
}
|
||||
|
||||
if requireS3Fields && config.S3Bucket == "" {
|
||||
return errors.New("S3 bucket is required")
|
||||
}
|
||||
if config.S3Bucket != "" {
|
||||
if !taskArtifactStoreBucketPattern.MatchString(config.S3Bucket) ||
|
||||
strings.Contains(config.S3Bucket, "..") || net.ParseIP(config.S3Bucket) != nil {
|
||||
return errors.New("S3 bucket syntax is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
if requireS3Fields && config.S3Region == "" {
|
||||
return errors.New("S3 region is required")
|
||||
}
|
||||
if config.S3Region != "" && !taskArtifactStoreRegionPattern.MatchString(config.S3Region) {
|
||||
return errors.New("S3 region syntax is invalid")
|
||||
}
|
||||
if err := validateTaskArtifactStoreCredential("access key", config.S3AccessKey, 256, requireS3Fields); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateTaskArtifactStoreCredential("secret key", config.S3SecretKey, 1024, requireS3Fields); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if config.S3Prefix != "" {
|
||||
if config.S3Prefix != strings.TrimSpace(config.S3Prefix) || len(config.S3Prefix) > 512 ||
|
||||
strings.HasPrefix(config.S3Prefix, "/") || strings.Contains(config.S3Prefix, "\\") {
|
||||
return errors.New("S3 prefix syntax is invalid")
|
||||
}
|
||||
for part := range strings.SplitSeq(config.S3Prefix, "/") {
|
||||
if part == "." || part == ".." {
|
||||
return errors.New("S3 prefix must not contain dot segments")
|
||||
}
|
||||
}
|
||||
for _, character := range config.S3Prefix {
|
||||
if unicode.IsControl(character) {
|
||||
return errors.New("S3 prefix must not contain control characters")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTaskArtifactStoreCredential(name, value string, maxLength int, required bool) error {
|
||||
if required && value == "" {
|
||||
return fmt.Errorf("S3 %s is required", name)
|
||||
}
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
if value != strings.TrimSpace(value) || len(value) > maxLength {
|
||||
return fmt.Errorf("S3 %s syntax is invalid", name)
|
||||
}
|
||||
for _, character := range value {
|
||||
if unicode.IsControl(character) {
|
||||
return fmt.Errorf("S3 %s syntax is invalid", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package system_setting
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidateTaskArtifactStoreConfig(t *testing.T) {
|
||||
valid := TaskArtifactStoreConfig{
|
||||
Mode: TaskArtifactStoreModeS3,
|
||||
S3Endpoint: "https://objects.example.com/storage",
|
||||
S3Bucket: "task-artifacts",
|
||||
S3Region: "us-east-1",
|
||||
S3AccessKey: "access-key",
|
||||
S3SecretKey: "secret-key",
|
||||
S3Prefix: "tasks/v1/",
|
||||
S3PresignTTLSeconds: 900,
|
||||
}
|
||||
require.NoError(t, ValidateTaskArtifactStoreConfig(valid))
|
||||
require.NoError(t, ValidateTaskArtifactStoreConfig(TaskArtifactStoreConfig{
|
||||
Mode: TaskArtifactStoreModeUpstream,
|
||||
S3PresignTTLSeconds: DefaultTaskArtifactStorePresignTTLSeconds,
|
||||
}))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*TaskArtifactStoreConfig)
|
||||
match string
|
||||
}{
|
||||
{name: "mode", mutate: func(config *TaskArtifactStoreConfig) { config.Mode = "filesystem" }, match: "unsupported mode"},
|
||||
{name: "endpoint scheme", mutate: func(config *TaskArtifactStoreConfig) { config.S3Endpoint = "ftp://objects.example.com" }, match: "http or https"},
|
||||
{name: "endpoint credentials", mutate: func(config *TaskArtifactStoreConfig) { config.S3Endpoint = "https://user:pass@objects.example.com" }, match: "without userinfo"},
|
||||
{name: "endpoint query", mutate: func(config *TaskArtifactStoreConfig) { config.S3Endpoint = "https://objects.example.com?token=secret" }, match: "query or fragment"},
|
||||
{name: "bucket", mutate: func(config *TaskArtifactStoreConfig) { config.S3Bucket = "Invalid_Bucket" }, match: "bucket syntax"},
|
||||
{name: "IP bucket", mutate: func(config *TaskArtifactStoreConfig) { config.S3Bucket = "192.168.1.1" }, match: "bucket syntax"},
|
||||
{name: "region", mutate: func(config *TaskArtifactStoreConfig) { config.S3Region = "bad region" }, match: "region syntax"},
|
||||
{name: "access key", mutate: func(config *TaskArtifactStoreConfig) { config.S3AccessKey = " access-key" }, match: "access key syntax"},
|
||||
{name: "secret key", mutate: func(config *TaskArtifactStoreConfig) { config.S3SecretKey = "secret\nkey" }, match: "secret key syntax"},
|
||||
{name: "prefix root", mutate: func(config *TaskArtifactStoreConfig) { config.S3Prefix = "/tasks" }, match: "prefix syntax"},
|
||||
{name: "prefix traversal", mutate: func(config *TaskArtifactStoreConfig) { config.S3Prefix = "tasks/../private" }, match: "dot segments"},
|
||||
{name: "TTL zero", mutate: func(config *TaskArtifactStoreConfig) { config.S3PresignTTLSeconds = 0 }, match: "presign TTL"},
|
||||
{name: "TTL too long", mutate: func(config *TaskArtifactStoreConfig) {
|
||||
config.S3PresignTTLSeconds = MaxTaskArtifactStorePresignTTLSeconds + 1
|
||||
}, match: "presign TTL"},
|
||||
}
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
config := valid
|
||||
testCase.mutate(&config)
|
||||
assert.ErrorContains(t, ValidateTaskArtifactStoreConfig(config), testCase.match)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTaskArtifactStoreConfigFallsBackToUpstream(t *testing.T) {
|
||||
t.Setenv(TaskArtifactStoreModeEnv, "filesystem")
|
||||
t.Setenv(TaskArtifactStoreS3PresignTTLEnv, "900")
|
||||
config := LoadTaskArtifactStoreConfig()
|
||||
assert.Equal(t, TaskArtifactStoreModeUpstream, config.Mode)
|
||||
|
||||
t.Setenv(TaskArtifactStoreModeEnv, TaskArtifactStoreModeS3)
|
||||
t.Setenv(TaskArtifactStoreS3EndpointEnv, "https://objects.example.com")
|
||||
t.Setenv(TaskArtifactStoreS3BucketEnv, "task-artifacts")
|
||||
t.Setenv(TaskArtifactStoreS3RegionEnv, "us-east-1")
|
||||
t.Setenv(TaskArtifactStoreS3AccessKeyEnv, "access-key")
|
||||
t.Setenv(TaskArtifactStoreS3SecretKeyEnv, "secret-key")
|
||||
t.Setenv(TaskArtifactStoreS3PrefixEnv, "tasks/v1")
|
||||
t.Setenv(TaskArtifactStoreS3PresignTTLEnv, "600")
|
||||
config = LoadTaskArtifactStoreConfig()
|
||||
|
||||
assert.Equal(t, TaskArtifactStoreModeUpstream, config.Mode)
|
||||
assert.Equal(t, "https://objects.example.com", config.S3Endpoint)
|
||||
assert.Equal(t, 600, config.S3PresignTTLSeconds)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package system_setting
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLoadTaskArtifactAccessLimitsUsesPositiveEnvironmentValues(t *testing.T) {
|
||||
t.Setenv(TaskArtifactInvalidRateLimitEnv, "17")
|
||||
t.Setenv(TaskArtifactGlobalLimitEnv, "23")
|
||||
t.Setenv(TaskArtifactIPLimitEnv, "11")
|
||||
t.Setenv(TaskArtifactObjectLimitEnv, "7")
|
||||
|
||||
limits := LoadTaskArtifactAccessLimits()
|
||||
assert.Equal(t, 17, limits.InvalidRatePerMinute)
|
||||
assert.Equal(t, 23, limits.GlobalConcurrency)
|
||||
assert.Equal(t, 11, limits.IPConcurrency)
|
||||
assert.Equal(t, 7, limits.ObjectConcurrency)
|
||||
}
|
||||
|
||||
func TestLoadTaskArtifactAccessLimitsFallsBackForInvalidValues(t *testing.T) {
|
||||
t.Setenv(TaskArtifactInvalidRateLimitEnv, "0")
|
||||
t.Setenv(TaskArtifactGlobalLimitEnv, "-1")
|
||||
t.Setenv(TaskArtifactIPLimitEnv, "invalid")
|
||||
t.Setenv(TaskArtifactObjectLimitEnv, "")
|
||||
|
||||
limits := LoadTaskArtifactAccessLimits()
|
||||
assert.Equal(t, DefaultTaskArtifactInvalidRateLimitPerMinute, limits.InvalidRatePerMinute)
|
||||
assert.Equal(t, DefaultTaskArtifactGlobalConcurrency, limits.GlobalConcurrency)
|
||||
assert.Equal(t, DefaultTaskArtifactIPConcurrency, limits.IPConcurrency)
|
||||
assert.Equal(t, DefaultTaskArtifactObjectConcurrency, limits.ObjectConcurrency)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package system_setting
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/setting/config"
|
||||
)
|
||||
|
||||
type TelegramSettings struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
}
|
||||
|
||||
var telegramSettings TelegramSettings
|
||||
|
||||
func init() {
|
||||
config.GlobalConfig.Register("telegram", &telegramSettings)
|
||||
}
|
||||
|
||||
func GetTelegramSettings() *TelegramSettings {
|
||||
return &telegramSettings
|
||||
}
|
||||
|
||||
func (s *TelegramSettings) IsConfigured() bool {
|
||||
return strings.TrimSpace(s.ClientID) != "" && strings.TrimSpace(s.ClientSecret) != ""
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
)
|
||||
|
||||
const (
|
||||
TaskPluginMarketplaceSourcesKey = "TaskPluginMarketplaceSources"
|
||||
TaskPluginDisabledFactoryKeysKey = "TaskPluginDisabledFactoryKeys"
|
||||
|
||||
officialTaskPluginMarketplaceIndexURL = "https://www.newapi.ai/api/v1/plugins/index.json"
|
||||
githubTaskPluginMarketplaceIndexURL = "https://raw.githubusercontent.com/QuantumNous/new-api-plugins/main/index.json"
|
||||
)
|
||||
|
||||
type TaskPluginMarketplaceSource struct {
|
||||
Name string `json:"name"`
|
||||
IndexURL string `json:"index_url"`
|
||||
}
|
||||
|
||||
func defaultTaskPluginMarketplaceSources() []TaskPluginMarketplaceSource {
|
||||
return []TaskPluginMarketplaceSource{
|
||||
{Name: "Official", IndexURL: officialTaskPluginMarketplaceIndexURL},
|
||||
{Name: "GitHub", IndexURL: githubTaskPluginMarketplaceIndexURL},
|
||||
}
|
||||
}
|
||||
|
||||
func GetTaskPluginMarketplaceSources() []TaskPluginMarketplaceSource {
|
||||
common.OptionMapRWMutex.RLock()
|
||||
raw := ""
|
||||
if common.OptionMap != nil {
|
||||
raw = common.OptionMap[TaskPluginMarketplaceSourcesKey]
|
||||
}
|
||||
common.OptionMapRWMutex.RUnlock()
|
||||
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return defaultTaskPluginMarketplaceSources()
|
||||
}
|
||||
var sources []TaskPluginMarketplaceSource
|
||||
if err := common.UnmarshalJsonStr(raw, &sources); err != nil {
|
||||
return defaultTaskPluginMarketplaceSources()
|
||||
}
|
||||
if sources == nil {
|
||||
return []TaskPluginMarketplaceSource{}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func TaskPluginMarketplaceSources2JsonString() string {
|
||||
encoded, err := common.Marshal(defaultTaskPluginMarketplaceSources())
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func ParseTaskPluginDisabledFactoryKeys(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return []string{}
|
||||
}
|
||||
var keys []string
|
||||
if err := common.Unmarshal([]byte(raw), &keys); err != nil {
|
||||
return []string{}
|
||||
}
|
||||
if keys == nil {
|
||||
return []string{}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func GetTaskPluginDisabledFactoryKeys() []string {
|
||||
common.OptionMapRWMutex.RLock()
|
||||
raw := ""
|
||||
if common.OptionMap != nil {
|
||||
raw = common.OptionMap[TaskPluginDisabledFactoryKeysKey]
|
||||
}
|
||||
common.OptionMapRWMutex.RUnlock()
|
||||
return ParseTaskPluginDisabledFactoryKeys(raw)
|
||||
}
|
||||
|
||||
func SetTaskPluginDisabledFactoryKeysOption(keys []string) error {
|
||||
normalized := make([]string, 0, len(keys))
|
||||
seen := make(map[string]struct{}, len(keys))
|
||||
for _, key := range keys {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
normalized = append(normalized, key)
|
||||
}
|
||||
sort.Strings(normalized)
|
||||
encoded, err := common.Marshal(normalized)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
common.OptionMapRWMutex.Lock()
|
||||
if common.OptionMap == nil {
|
||||
common.OptionMap = make(map[string]string)
|
||||
}
|
||||
common.OptionMap[TaskPluginDisabledFactoryKeysKey] = string(encoded)
|
||||
common.OptionMapRWMutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func IsTaskPluginFactoryDisabled(key string) bool {
|
||||
return slices.Contains(GetTaskPluginDisabledFactoryKeys(), key)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setupTaskPluginDisabledFactoryKeysTest(t *testing.T) {
|
||||
t.Helper()
|
||||
originalMap := common.OptionMap
|
||||
common.OptionMapRWMutex.Lock()
|
||||
common.OptionMap = map[string]string{}
|
||||
common.OptionMapRWMutex.Unlock()
|
||||
t.Cleanup(func() {
|
||||
common.OptionMapRWMutex.Lock()
|
||||
common.OptionMap = originalMap
|
||||
common.OptionMapRWMutex.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func TestTaskPluginDisabledFactoryKeysRoundTripAndDedupe(t *testing.T) {
|
||||
setupTaskPluginDisabledFactoryKeysTest(t)
|
||||
|
||||
assert.Empty(t, GetTaskPluginDisabledFactoryKeys())
|
||||
assert.False(t, IsTaskPluginFactoryDisabled("kling"))
|
||||
|
||||
require.NoError(t, SetTaskPluginDisabledFactoryKeysOption([]string{"kling", "sora", "kling", " hailuo "}))
|
||||
assert.Equal(t, []string{"hailuo", "kling", "sora"}, GetTaskPluginDisabledFactoryKeys())
|
||||
assert.Equal(t, `["hailuo","kling","sora"]`, common.OptionMap[TaskPluginDisabledFactoryKeysKey])
|
||||
assert.True(t, IsTaskPluginFactoryDisabled("kling"))
|
||||
assert.True(t, IsTaskPluginFactoryDisabled("hailuo"))
|
||||
assert.False(t, IsTaskPluginFactoryDisabled("google"))
|
||||
}
|
||||
|
||||
func TestTaskPluginDisabledFactoryKeysBadJSONReturnsEmpty(t *testing.T) {
|
||||
setupTaskPluginDisabledFactoryKeysTest(t)
|
||||
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{name: "absent", raw: ""},
|
||||
{name: "null", raw: "null"},
|
||||
{name: "object", raw: "{}"},
|
||||
{name: "number", raw: "1"},
|
||||
{name: "truncated", raw: `["kling"`},
|
||||
{name: "not json", raw: "kling"},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
common.OptionMapRWMutex.Lock()
|
||||
if testCase.raw == "" {
|
||||
delete(common.OptionMap, TaskPluginDisabledFactoryKeysKey)
|
||||
} else {
|
||||
common.OptionMap[TaskPluginDisabledFactoryKeysKey] = testCase.raw
|
||||
}
|
||||
common.OptionMapRWMutex.Unlock()
|
||||
|
||||
assert.Empty(t, GetTaskPluginDisabledFactoryKeys())
|
||||
assert.False(t, IsTaskPluginFactoryDisabled("kling"))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package task_pricing_setting
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/setting/config"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type TaskPricingSetting struct {
|
||||
SoraSizeRatio map[string]float64 `json:"sora_size_ratio"`
|
||||
VertexResolution4K map[string]float64 `json:"vertex_resolution_4k_ratio"`
|
||||
}
|
||||
|
||||
var taskPricingSetting = TaskPricingSetting{
|
||||
SoraSizeRatio: map[string]float64{
|
||||
"1792x1024": 1.666667,
|
||||
"1024x1792": 1.666667,
|
||||
},
|
||||
VertexResolution4K: map[string]float64{
|
||||
"veo-3.1-fast-generate": 2.333333,
|
||||
"veo-3.1-generate": 1.5,
|
||||
"veo-3.1": 1.5,
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
config.GlobalConfig.Register("task_pricing_setting", &taskPricingSetting)
|
||||
}
|
||||
|
||||
func SoraSizeRatio(size string) float64 {
|
||||
if ratio, ok := taskPricingSetting.SoraSizeRatio[size]; ok && ratio > 0 {
|
||||
return ratio
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func VertexResolutionRatio(model, resolution string) float64 {
|
||||
if !strings.EqualFold(resolution, "4k") {
|
||||
return 1
|
||||
}
|
||||
matchedPattern := ""
|
||||
matchedRatio := 1.0
|
||||
for pattern, ratio := range taskPricingSetting.VertexResolution4K {
|
||||
if !strings.Contains(model, pattern) || ratio <= 0 {
|
||||
continue
|
||||
}
|
||||
if len(pattern) > len(matchedPattern) || (len(pattern) == len(matchedPattern) && pattern < matchedPattern) {
|
||||
matchedPattern = pattern
|
||||
matchedRatio = ratio
|
||||
}
|
||||
}
|
||||
return matchedRatio
|
||||
}
|
||||
|
||||
func GetCopy() TaskPricingSetting {
|
||||
return TaskPricingSetting{
|
||||
SoraSizeRatio: lo.Assign(taskPricingSetting.SoraSizeRatio),
|
||||
VertexResolution4K: lo.Assign(taskPricingSetting.VertexResolution4K),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package task_pricing_setting
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/setting/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTaskPricingDefaultsAndOptionUpdate(t *testing.T) {
|
||||
original := GetCopy()
|
||||
t.Cleanup(func() { taskPricingSetting = original })
|
||||
assert.InDelta(t, 1.666667, SoraSizeRatio("1792x1024"), 0.000001)
|
||||
assert.InDelta(t, 2.333333, VertexResolutionRatio("veo-3.1-fast-generate-preview", "4K"), 0.000001)
|
||||
require.NoError(t, config.UpdateConfigFromMap(&taskPricingSetting, map[string]string{"sora_size_ratio": `{"1792x1024":2}`}))
|
||||
assert.Equal(t, 2.0, SoraSizeRatio("1792x1024"))
|
||||
assert.Equal(t, 1.0, SoraSizeRatio("720x1280"))
|
||||
}
|
||||
|
||||
func TestVertexResolutionRatioPrefersMostSpecificModelPattern(t *testing.T) {
|
||||
original := GetCopy()
|
||||
t.Cleanup(func() { taskPricingSetting = original })
|
||||
taskPricingSetting.VertexResolution4K = map[string]float64{
|
||||
"veo-3.1": 1.5,
|
||||
"veo-3.1-fast-generate": 2.333333,
|
||||
"veo-3.1-fast-generate-preview": 3,
|
||||
}
|
||||
|
||||
assert.Equal(t, 3.0, VertexResolutionRatio("veo-3.1-fast-generate-preview", "4K"))
|
||||
assert.Equal(t, 2.333333, VertexResolutionRatio("veo-3.1-fast-generate", "4k"))
|
||||
assert.Equal(t, 1.5, VertexResolutionRatio("veo-3.1-generate", "4K"))
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package setting
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"sync"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
)
|
||||
|
||||
var userUsableGroups = map[string]string{
|
||||
"default": "默认分组",
|
||||
"vip": "vip分组",
|
||||
}
|
||||
var userUsableGroupsMutex sync.RWMutex
|
||||
|
||||
func GetUserUsableGroupsCopy() map[string]string {
|
||||
userUsableGroupsMutex.RLock()
|
||||
defer userUsableGroupsMutex.RUnlock()
|
||||
|
||||
copyUserUsableGroups := make(map[string]string)
|
||||
maps.Copy(copyUserUsableGroups, userUsableGroups)
|
||||
return copyUserUsableGroups
|
||||
}
|
||||
|
||||
func UserUsableGroups2JSONString() string {
|
||||
userUsableGroupsMutex.RLock()
|
||||
defer userUsableGroupsMutex.RUnlock()
|
||||
|
||||
jsonBytes, err := json.Marshal(userUsableGroups)
|
||||
if err != nil {
|
||||
common.SysLog("error marshalling user groups: " + err.Error())
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
func UpdateUserUsableGroupsByJSONString(jsonStr string) error {
|
||||
userUsableGroupsMutex.Lock()
|
||||
defer userUsableGroupsMutex.Unlock()
|
||||
|
||||
userUsableGroups = make(map[string]string)
|
||||
return json.Unmarshal([]byte(jsonStr), &userUsableGroups)
|
||||
}
|
||||
|
||||
func GetUsableGroupDescription(groupName string) string {
|
||||
userUsableGroupsMutex.RLock()
|
||||
defer userUsableGroupsMutex.RUnlock()
|
||||
|
||||
if desc, ok := userUsableGroups[groupName]; ok {
|
||||
return desc
|
||||
}
|
||||
return groupName
|
||||
}
|
||||
Reference in new issue
Block a user