Compare commits

..

21 Commits

Author SHA1 Message Date
CaIon
18aa0de323 fix: add support for gpt-5.4 model in model_ratio.go 2026-03-06 11:43:05 +08:00
Seefs
f0e938a513 Merge pull request #3120 from nekohy/main
feats: repair the thinking of claude to openrouter convert
2026-03-05 18:10:46 +08:00
Seefs
db8243bb36 Merge pull request #3130 from feitianbubu/pr/ce221d98d71ab7aec3eb60f27ca33dbb4dc9610a
fix: fetch model add header passthrough rule key check
2026-03-05 18:09:44 +08:00
feitianbubu
1b85b183e6 fix: fetch model add header passthrough rule key check 2026-03-05 17:49:36 +08:00
Calcium-Ion
56c971691b Merge pull request #3129 from seefs001/feature/param-override-wildcard-path
Feature/param override wildcard path
2026-03-05 16:53:39 +08:00
Seefs
9d4ea49984 chore: remove top-right field guide entry in param override editor 2026-03-05 16:43:15 +08:00
Seefs
16e4ce52e3 feat: add wildcard path support and improve param override templates/editor 2026-03-05 16:39:34 +08:00
Nekohy
de12d6df05 delete some if 2026-03-05 06:24:22 +08:00
Nekohy
5b264f3a57 feats: repair the thinking of claude to openrouter convert 2026-03-05 06:12:48 +08:00
CaIon
887a929d65 fix: add multilingual support for meta description in index.html 2026-03-04 18:19:19 +08:00
Calcium-Ion
34262dc8c3 Merge pull request #3093 from feitianbubu/pr/92ad4854fcb501216dd9f2155c19f0556e4655bc
fix: update task billing log content to include reason
2026-03-04 18:13:59 +08:00
CaIon
ddffccc499 fix: update meta description for improved clarity and accuracy 2026-03-04 18:07:17 +08:00
CaIon
c31f9db61e feat: enhance PricingTags and SelectableButtonGroup with new badge styles and color variants 2026-03-04 00:36:04 +08:00
CaIon
3b65c32573 fix: improve error message for unsupported image generation models 2026-03-04 00:36:03 +08:00
Calcium-Ion
196f534c41 Merge pull request #3096 from seefs001/fix/auto-fetch-upstream-model-tips
Fix/auto fetch upstream model tips
2026-03-03 14:47:43 +08:00
Seefs
40c36b1a30 fix: count ignored models from unselected items in upstream update toast 2026-03-03 14:29:43 +08:00
Calcium-Ion
ae1c8e4173 fix: use default model price for radio price model (#3090) 2026-03-03 14:29:03 +08:00
Seefs
429b7428f4 fix: remove extra spaces 2026-03-03 14:08:43 +08:00
Seefs
0a804f0e70 fix: refine upstream update ignore UX and detect behavior 2026-03-03 14:00:48 +08:00
feitianbubu
5f3c5f14d4 fix: update task billing log content to include reason 2026-03-03 12:37:43 +08:00
feitianbubu
d12cc3a8da fix: use default model price for radio price model 2026-03-03 11:22:04 +08:00
28 changed files with 749 additions and 141 deletions

View File

@@ -13,6 +13,7 @@ import (
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
relaychannel "github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/relay/channel/gemini"
"github.com/QuantumNous/new-api/relay/channel/ollama"
"github.com/QuantumNous/new-api/service"
@@ -183,6 +184,9 @@ func buildFetchModelsHeaders(channel *model.Channel, key string) (http.Header, e
headerOverride := channel.GetHeaderOverride()
for k, v := range headerOverride {
if relaychannel.IsHeaderPassthroughRuleKey(k) {
continue
}
str, ok := v.(string)
if !ok {
return nil, fmt.Errorf("invalid header override for key %s", k)

View File

@@ -730,14 +730,6 @@ func DetectChannelUpstreamModelUpdates(c *gin.Context) {
}
settings := channel.GetOtherSettings()
if !settings.UpstreamModelUpdateCheckEnabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该渠道未开启上游模型更新检测",
})
return
}
modelsChanged, autoAdded, err := checkAndPersistChannelUpstreamModelUpdates(channel, &settings, true, false)
if err != nil {
common.ApiError(c, err)

View File

@@ -27,12 +27,12 @@ type ChannelOtherSettings struct {
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key"
OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"`
ClaudeBetaQuery bool `json:"claude_beta_query,omitempty"` // Claude 渠道是否强制追加 ?beta=true
AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费)
AllowInferenceGeo bool `json:"allow_inference_geo,omitempty"` // 是否允许 inference_geo 透传(仅 Claude默认过滤以满足数据驻留合规
AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私)
DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用)
AllowIncludeObfuscation bool `json:"allow_include_obfuscation, omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护)
ClaudeBetaQuery bool `json:"claude_beta_query,omitempty"` // Claude 渠道是否强制追加 ?beta=true
AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费)
AllowInferenceGeo bool `json:"allow_inference_geo,omitempty"` // 是否允许 inference_geo 透传(仅 Claude默认过滤以满足数据驻留合规
AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私)
DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用)
AllowIncludeObfuscation bool `json:"allow_include_obfuscation,omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护)
AwsKeyType AwsKeyType `json:"aws_key_type,omitempty"`
UpstreamModelUpdateCheckEnabled bool `json:"upstream_model_update_check_enabled,omitempty"` // 是否检测上游模型更新
UpstreamModelUpdateAutoSyncEnabled bool `json:"upstream_model_update_auto_sync_enabled,omitempty"` // 是否自动同步上游模型更新

View File

@@ -218,6 +218,11 @@ type ClaudeRequest struct {
ServiceTier string `json:"service_tier,omitempty"`
}
// OutputConfigForEffort just for extract effort
type OutputConfigForEffort struct {
Effort string `json:"effort,omitempty"`
}
// createClaudeFileSource 根据数据内容创建正确类型的 FileSource
func createClaudeFileSource(data string) *types.FileSource {
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
@@ -409,6 +414,15 @@ func (c *ClaudeRequest) GetTools() []any {
}
}
func (c *ClaudeRequest) GetEfforts() string {
var OutputConfig OutputConfigForEffort
if err := json.Unmarshal(c.OutputConfig, &OutputConfig); err == nil {
effort := OutputConfig.Effort
return effort
}
return ""
}
// ProcessTools 处理工具列表,支持类型断言
func ProcessTools(tools []any) ([]*Tool, []*ClaudeWebSearchTool) {
var normalTools []*Tool

View File

@@ -100,6 +100,9 @@ func getHeaderPassthroughRegex(pattern string) (*regexp.Regexp, error) {
return compiled, nil
}
func IsHeaderPassthroughRuleKey(key string) bool {
return isHeaderPassthroughRuleKey(key)
}
func isHeaderPassthroughRuleKey(key string) bool {
key = strings.TrimSpace(key)
if key == "" {

View File

@@ -59,7 +59,7 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
if !strings.HasPrefix(info.UpstreamModelName, "imagen") {
return nil, errors.New("not supported model for image generation")
return nil, errors.New("not supported model for image generation, only imagen models are supported")
}
// convert size to aspect ratio but allow user to specify aspect ratio

View File

@@ -298,6 +298,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
}
reasoning := openrouter.RequestReasoning{
Enabled: true,
MaxTokens: *thinking.BudgetTokens,
}

View File

@@ -3,6 +3,7 @@ package openrouter
import "encoding/json"
type RequestReasoning struct {
Enabled bool `json:"enabled"`
// One of the following (not both):
Effort string `json:"effort,omitempty"` // Can be "high", "medium", or "low" (OpenAI-style)
MaxTokens int `json:"max_tokens,omitempty"` // Specific token limit (Anthropic-style)

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
@@ -487,15 +488,35 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
}
// 处理路径中的负数索引
opPath := processNegativeIndex(result, op.Path)
var opPaths []string
if isPathBasedOperation(op.Mode) {
opPaths, err = resolveOperationPaths(result, opPath)
if err != nil {
return "", err
}
if len(opPaths) == 0 {
continue
}
}
switch op.Mode {
case "delete":
result, err = sjson.Delete(result, opPath)
case "set":
if op.KeepOrigin && gjson.Get(result, opPath).Exists() {
continue
for _, path := range opPaths {
result, err = deleteValue(result, path)
if err != nil {
break
}
}
case "set":
for _, path := range opPaths {
if op.KeepOrigin && gjson.Get(result, path).Exists() {
continue
}
result, err = sjson.Set(result, path, op.Value)
if err != nil {
break
}
}
result, err = sjson.Set(result, opPath, op.Value)
case "move":
opFrom := processNegativeIndex(result, op.From)
opTo := processNegativeIndex(result, op.To)
@@ -508,27 +529,82 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
opTo := processNegativeIndex(result, op.To)
result, err = copyValue(result, opFrom, opTo)
case "prepend":
result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, true)
for _, path := range opPaths {
result, err = modifyValue(result, path, op.Value, op.KeepOrigin, true)
if err != nil {
break
}
}
case "append":
result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, false)
for _, path := range opPaths {
result, err = modifyValue(result, path, op.Value, op.KeepOrigin, false)
if err != nil {
break
}
}
case "trim_prefix":
result, err = trimStringValue(result, opPath, op.Value, true)
for _, path := range opPaths {
result, err = trimStringValue(result, path, op.Value, true)
if err != nil {
break
}
}
case "trim_suffix":
result, err = trimStringValue(result, opPath, op.Value, false)
for _, path := range opPaths {
result, err = trimStringValue(result, path, op.Value, false)
if err != nil {
break
}
}
case "ensure_prefix":
result, err = ensureStringAffix(result, opPath, op.Value, true)
for _, path := range opPaths {
result, err = ensureStringAffix(result, path, op.Value, true)
if err != nil {
break
}
}
case "ensure_suffix":
result, err = ensureStringAffix(result, opPath, op.Value, false)
for _, path := range opPaths {
result, err = ensureStringAffix(result, path, op.Value, false)
if err != nil {
break
}
}
case "trim_space":
result, err = transformStringValue(result, opPath, strings.TrimSpace)
for _, path := range opPaths {
result, err = transformStringValue(result, path, strings.TrimSpace)
if err != nil {
break
}
}
case "to_lower":
result, err = transformStringValue(result, opPath, strings.ToLower)
for _, path := range opPaths {
result, err = transformStringValue(result, path, strings.ToLower)
if err != nil {
break
}
}
case "to_upper":
result, err = transformStringValue(result, opPath, strings.ToUpper)
for _, path := range opPaths {
result, err = transformStringValue(result, path, strings.ToUpper)
if err != nil {
break
}
}
case "replace":
result, err = replaceStringValue(result, opPath, op.From, op.To)
for _, path := range opPaths {
result, err = replaceStringValue(result, path, op.From, op.To)
if err != nil {
break
}
}
case "regex_replace":
result, err = regexReplaceStringValue(result, opPath, op.From, op.To)
for _, path := range opPaths {
result, err = regexReplaceStringValue(result, path, op.From, op.To)
if err != nil {
break
}
}
case "return_error":
returnErr, parseErr := parseParamOverrideReturnError(op.Value)
if parseErr != nil {
@@ -536,7 +612,12 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
}
return "", returnErr
case "prune_objects":
result, err = pruneObjects(result, opPath, contextJSON, op.Value)
for _, path := range opPaths {
result, err = pruneObjects(result, path, contextJSON, op.Value)
if err != nil {
break
}
}
case "set_header":
err = setHeaderOverrideInContext(context, op.Path, op.Value, op.KeepOrigin)
if err == nil {
@@ -1174,6 +1255,92 @@ func copyValue(jsonStr, fromPath, toPath string) (string, error) {
return sjson.Set(jsonStr, toPath, sourceValue.Value())
}
func isPathBasedOperation(mode string) bool {
switch mode {
case "delete", "set", "prepend", "append", "trim_prefix", "trim_suffix", "ensure_prefix", "ensure_suffix", "trim_space", "to_lower", "to_upper", "replace", "regex_replace", "prune_objects":
return true
default:
return false
}
}
func resolveOperationPaths(jsonStr, path string) ([]string, error) {
if !strings.Contains(path, "*") {
return []string{path}, nil
}
return expandWildcardPaths(jsonStr, path)
}
func expandWildcardPaths(jsonStr, path string) ([]string, error) {
var root interface{}
if err := common.Unmarshal([]byte(jsonStr), &root); err != nil {
return nil, err
}
segments := strings.Split(path, ".")
paths := collectWildcardPaths(root, segments, nil)
return lo.Uniq(paths), nil
}
func collectWildcardPaths(node interface{}, segments []string, prefix []string) []string {
if len(segments) == 0 {
return []string{strings.Join(prefix, ".")}
}
segment := strings.TrimSpace(segments[0])
if segment == "" {
return nil
}
isLast := len(segments) == 1
if segment == "*" {
switch typed := node.(type) {
case map[string]interface{}:
keys := lo.Keys(typed)
sort.Strings(keys)
return lo.FlatMap(keys, func(key string, _ int) []string {
return collectWildcardPaths(typed[key], segments[1:], append(prefix, key))
})
case []interface{}:
return lo.FlatMap(lo.Range(len(typed)), func(index int, _ int) []string {
return collectWildcardPaths(typed[index], segments[1:], append(prefix, strconv.Itoa(index)))
})
default:
return nil
}
}
switch typed := node.(type) {
case map[string]interface{}:
if isLast {
return []string{strings.Join(append(prefix, segment), ".")}
}
next, exists := typed[segment]
if !exists {
return nil
}
return collectWildcardPaths(next, segments[1:], append(prefix, segment))
case []interface{}:
index, err := strconv.Atoi(segment)
if err != nil || index < 0 || index >= len(typed) {
return nil
}
if isLast {
return []string{strings.Join(append(prefix, segment), ".")}
}
return collectWildcardPaths(typed[index], segments[1:], append(prefix, segment))
default:
return nil
}
}
func deleteValue(jsonStr, path string) (string, error) {
if strings.TrimSpace(path) == "" {
return jsonStr, nil
}
return sjson.Delete(jsonStr, path)
}
func modifyValue(jsonStr, path string, value interface{}, keepOrigin, isPrepend bool) (string, error) {
current := gjson.Get(jsonStr, path)
switch {

View File

@@ -2,6 +2,7 @@ package common
import (
"encoding/json"
"fmt"
"reflect"
"testing"
@@ -9,6 +10,7 @@ import (
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/samber/lo"
)
func TestApplyParamOverrideTrimPrefix(t *testing.T) {
@@ -242,6 +244,224 @@ func TestApplyParamOverrideDelete(t *testing.T) {
}
}
func TestApplyParamOverrideDeleteWildcardPath(t *testing.T) {
input := []byte(`{"tools":[{"type":"bash","custom":{"input_examples":["a"],"other":1}},{"type":"code","custom":{"input_examples":["b"]}},{"type":"noop","custom":{"other":2}}]}`)
override := map[string]interface{}{
"operations": []interface{}{
map[string]interface{}{
"path": "tools.*.custom.input_examples",
"mode": "delete",
},
},
}
out, err := ApplyParamOverride(input, override, nil)
if err != nil {
t.Fatalf("ApplyParamOverride returned error: %v", err)
}
assertJSONEqual(t, `{"tools":[{"type":"bash","custom":{"other":1}},{"type":"code","custom":{}},{"type":"noop","custom":{"other":2}}]}`, string(out))
}
func TestApplyParamOverrideSetWildcardPath(t *testing.T) {
input := []byte(`{"tools":[{"custom":{"tag":"A"}},{"custom":{"tag":"B"}},{"custom":{"tag":"C"}}]}`)
override := map[string]interface{}{
"operations": []interface{}{
map[string]interface{}{
"path": "tools.*.custom.enabled",
"mode": "set",
"value": true,
},
},
}
out, err := ApplyParamOverride(input, override, nil)
if err != nil {
t.Fatalf("ApplyParamOverride returned error: %v", err)
}
var got struct {
Tools []struct {
Custom struct {
Enabled bool `json:"enabled"`
} `json:"custom"`
} `json:"tools"`
}
if err := json.Unmarshal(out, &got); err != nil {
t.Fatalf("failed to unmarshal output JSON: %v", err)
}
if !lo.EveryBy(got.Tools, func(item struct {
Custom struct {
Enabled bool `json:"enabled"`
} `json:"custom"`
}) bool {
return item.Custom.Enabled
}) {
t.Fatalf("expected wildcard set to enable all tools, got: %s", string(out))
}
}
func TestApplyParamOverrideTrimSpaceWildcardPath(t *testing.T) {
input := []byte(`{"tools":[{"custom":{"name":" alpha "}},{"custom":{"name":" beta"}},{"custom":{"name":"gamma "}}]}`)
override := map[string]interface{}{
"operations": []interface{}{
map[string]interface{}{
"path": "tools.*.custom.name",
"mode": "trim_space",
},
},
}
out, err := ApplyParamOverride(input, override, nil)
if err != nil {
t.Fatalf("ApplyParamOverride returned error: %v", err)
}
var got struct {
Tools []struct {
Custom struct {
Name string `json:"name"`
} `json:"custom"`
} `json:"tools"`
}
if err := json.Unmarshal(out, &got); err != nil {
t.Fatalf("failed to unmarshal output JSON: %v", err)
}
names := lo.Map(got.Tools, func(item struct {
Custom struct {
Name string `json:"name"`
} `json:"custom"`
}, _ int) string {
return item.Custom.Name
})
if !reflect.DeepEqual(names, []string{"alpha", "beta", "gamma"}) {
t.Fatalf("unexpected names after wildcard trim_space: %v", names)
}
}
func TestApplyParamOverrideDeleteWildcardEqualsIndexedPaths(t *testing.T) {
input := []byte(`{"tools":[{"custom":{"input_examples":["a"],"other":1}},{"custom":{"input_examples":["b"],"other":2}},{"custom":{"input_examples":["c"],"other":3}}]}`)
wildcardOverride := map[string]interface{}{
"operations": []interface{}{
map[string]interface{}{
"path": "tools.*.custom.input_examples",
"mode": "delete",
},
},
}
indexedOverride := map[string]interface{}{
"operations": lo.Map(lo.Range(3), func(index int, _ int) interface{} {
return map[string]interface{}{
"path": fmt.Sprintf("tools.%d.custom.input_examples", index),
"mode": "delete",
}
}),
}
wildcardOut, err := ApplyParamOverride(input, wildcardOverride, nil)
if err != nil {
t.Fatalf("wildcard ApplyParamOverride returned error: %v", err)
}
indexedOut, err := ApplyParamOverride(input, indexedOverride, nil)
if err != nil {
t.Fatalf("indexed ApplyParamOverride returned error: %v", err)
}
assertJSONEqual(t, string(indexedOut), string(wildcardOut))
}
func TestApplyParamOverrideSetWildcardKeepOrigin(t *testing.T) {
input := []byte(`{"tools":[{"custom":{"tag":"A"}},{"custom":{"tag":"B","enabled":false}},{"custom":{"tag":"C"}}]}`)
override := map[string]interface{}{
"operations": []interface{}{
map[string]interface{}{
"path": "tools.*.custom.enabled",
"mode": "set",
"value": true,
"keep_origin": true,
},
},
}
out, err := ApplyParamOverride(input, override, nil)
if err != nil {
t.Fatalf("ApplyParamOverride returned error: %v", err)
}
var got struct {
Tools []struct {
Custom struct {
Enabled bool `json:"enabled"`
} `json:"custom"`
} `json:"tools"`
}
if err := json.Unmarshal(out, &got); err != nil {
t.Fatalf("failed to unmarshal output JSON: %v", err)
}
enabledValues := lo.Map(got.Tools, func(item struct {
Custom struct {
Enabled bool `json:"enabled"`
} `json:"custom"`
}, _ int) bool {
return item.Custom.Enabled
})
if !reflect.DeepEqual(enabledValues, []bool{true, false, true}) {
t.Fatalf("unexpected enabled values after wildcard keep_origin set: %v", enabledValues)
}
}
func TestApplyParamOverrideTrimSpaceMultiWildcardPath(t *testing.T) {
input := []byte(`{"tools":[{"custom":{"items":[{"name":" alpha "},{"name":" beta "}]}},{"custom":{"items":[{"name":" gamma"}]}}]}`)
override := map[string]interface{}{
"operations": []interface{}{
map[string]interface{}{
"path": "tools.*.custom.items.*.name",
"mode": "trim_space",
},
},
}
out, err := ApplyParamOverride(input, override, nil)
if err != nil {
t.Fatalf("ApplyParamOverride returned error: %v", err)
}
var got struct {
Tools []struct {
Custom struct {
Items []struct {
Name string `json:"name"`
} `json:"items"`
} `json:"custom"`
} `json:"tools"`
}
if err := json.Unmarshal(out, &got); err != nil {
t.Fatalf("failed to unmarshal output JSON: %v", err)
}
names := lo.FlatMap(got.Tools, func(tool struct {
Custom struct {
Items []struct {
Name string `json:"name"`
} `json:"items"`
} `json:"custom"`
}, _ int) []string {
return lo.Map(tool.Custom.Items, func(item struct {
Name string `json:"name"`
}, _ int) string {
return item.Name
})
})
if !reflect.DeepEqual(names, []string{"alpha", "beta", "gamma"}) {
t.Fatalf("unexpected names after multi wildcard trim_space: %v", names)
}
}
func TestApplyParamOverrideSet(t *testing.T) {
input := []byte(`{"model":"gpt-4","temperature":0.7}`)
override := map[string]interface{}{
@@ -261,6 +481,42 @@ func TestApplyParamOverrideSet(t *testing.T) {
assertJSONEqual(t, `{"model":"gpt-4","temperature":0.1}`, string(out))
}
func TestApplyParamOverrideSetWithDescriptionKeepsCompatibility(t *testing.T) {
input := []byte(`{"model":"gpt-4","temperature":0.7}`)
overrideWithoutDesc := map[string]interface{}{
"operations": []interface{}{
map[string]interface{}{
"path": "temperature",
"mode": "set",
"value": 0.1,
},
},
}
overrideWithDesc := map[string]interface{}{
"operations": []interface{}{
map[string]interface{}{
"description": "set temperature for deterministic output",
"path": "temperature",
"mode": "set",
"value": 0.1,
},
},
}
outWithoutDesc, err := ApplyParamOverride(input, overrideWithoutDesc, nil)
if err != nil {
t.Fatalf("ApplyParamOverride without description returned error: %v", err)
}
outWithDesc, err := ApplyParamOverride(input, overrideWithDesc, nil)
if err != nil {
t.Fatalf("ApplyParamOverride with description returned error: %v", err)
}
assertJSONEqual(t, string(outWithoutDesc), string(outWithDesc))
assertJSONEqual(t, `{"model":"gpt-4","temperature":0.1}`, string(outWithDesc))
}
func TestApplyParamOverrideSetKeepOrigin(t *testing.T) {
input := []byte(`{"model":"gpt-4","temperature":0.7}`)
override := map[string]interface{}{

View File

@@ -147,24 +147,22 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types
// 如果没有配置价格,检查模型倍率配置
if !success {
// 没有配置费用,返回错误
// 没有配置费用,也要使用默认费用,否则按费率计费模型无法使用
defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName]
if !ok {
// 不再使用默认价格,而是返回错误
return types.PriceData{}, fmt.Errorf("模型 %s 价格未配置,请联系管理员设置", info.OriginModelName)
} else {
if ok {
modelPrice = defaultPrice
}
// 没有配置倍率也不接受没配置,那就返回错误
_, ratioSuccess, matchName := ratio_setting.GetModelRatio(info.OriginModelName)
if !ratioSuccess {
} else {
// 没有配置倍率也不接受没配置,那就返回错误
_, ratioSuccess, matchName := ratio_setting.GetModelRatio(info.OriginModelName)
acceptUnsetRatio := false
if info.UserSetting.AcceptUnsetRatioModel {
acceptUnsetRatio = true
}
if !acceptUnsetRatio {
if !ratioSuccess && !acceptUnsetRatio {
return types.PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置请联系管理员设置或开始自用模式Model %s ratio or price not set, please set or start self-use mode", matchName, matchName)
}
// 未配置价格但配置了倍率,使用默认预扣价格
modelPrice = float64(common.PreConsumedQuota) / common.QuotaPerUnit
}
}

View File

@@ -34,22 +34,34 @@ func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.Re
isOpenRouter := info.ChannelType == constant.ChannelTypeOpenRouter
if claudeRequest.Thinking != nil && claudeRequest.Thinking.Type == "enabled" {
if isOpenRouter {
reasoning := openrouter.RequestReasoning{
MaxTokens: claudeRequest.Thinking.GetBudgetTokens(),
if isOpenRouter {
if effort := claudeRequest.GetEfforts(); effort != "" {
effortBytes, _ := json.Marshal(effort)
openAIRequest.Verbosity = effortBytes
}
if claudeRequest.Thinking != nil {
var reasoning openrouter.RequestReasoning
if claudeRequest.Thinking.Type == "enabled" {
reasoning = openrouter.RequestReasoning{
Enabled: true,
MaxTokens: claudeRequest.Thinking.GetBudgetTokens(),
}
} else if claudeRequest.Thinking.Type == "adaptive" {
reasoning = openrouter.RequestReasoning{
Enabled: true,
}
}
reasoningJSON, err := json.Marshal(reasoning)
if err != nil {
return nil, fmt.Errorf("failed to marshal reasoning: %w", err)
}
openAIRequest.Reasoning = reasoningJSON
} else {
thinkingSuffix := "-thinking"
if strings.HasSuffix(info.OriginModelName, thinkingSuffix) &&
!strings.HasSuffix(openAIRequest.Model, thinkingSuffix) {
openAIRequest.Model = openAIRequest.Model + thinkingSuffix
}
}
} else {
thinkingSuffix := "-thinking"
if strings.HasSuffix(info.OriginModelName, thinkingSuffix) &&
!strings.HasSuffix(openAIRequest.Model, thinkingSuffix) {
openAIRequest.Model = openAIRequest.Model + thinkingSuffix
}
}

View File

@@ -222,13 +222,13 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int
}
other := taskBillingOther(task)
other["task_id"] = task.TaskID
other["reason"] = reason
//other["reason"] = reason
other["pre_consumed_quota"] = preConsumedQuota
other["actual_quota"] = actualQuota
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
UserId: task.UserId,
LogType: logType,
Content: "",
Content: reason,
ChannelId: task.ChannelId,
ModelName: taskModelName(task),
Quota: logQuota,

View File

@@ -471,6 +471,9 @@ func getHardcodedCompletionModelRatio(name string) (float64, bool) {
}
// gpt-5 匹配
if strings.HasPrefix(name, "gpt-5") {
if strings.HasPrefix(name, "gpt-5.4") {
return 6, true
}
return 8, true
}
// gpt-4.5-preview匹配

8
web/index.html vendored
View File

@@ -7,7 +7,13 @@
<meta name="theme-color" content="#ffffff" />
<meta
name="description"
content="OpenAI 接口聚合管理,支持多种渠道包括 Azure可用于二次分发管理 key仅单可执行文件已打包好 Docker 镜像,一键部署,开箱即用"
lang="zh"
content="统一的 AI 模型聚合与分发网关,支持将各类大语言模型跨格式转换为 OpenAI、Claude、Gemini 兼容接口,为个人与企业提供集中式模型管理与网关服务。"
/>
<meta
name="description"
lang="en"
content="A unified AI model hub for aggregation & distribution. It supports cross-converting various LLMs into OpenAI-compatible, Claude-compatible, or Gemini-compatible formats. A centralized gateway for personal and enterprise model management."
/>
<meta name="generator" content="new-api" />
<title>New API</title>

View File

@@ -23,7 +23,6 @@ import { useContainerWidth } from '../../../hooks/common/useContainerWidth';
import {
Divider,
Button,
Tag,
Row,
Col,
Collapsible,
@@ -46,6 +45,7 @@ import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
* @param {number} collapseHeight 折叠时的高度默认200
* @param {boolean} withCheckbox 是否启用前缀 Checkbox 来控制激活状态
* @param {boolean} loading 是否处于加载状态
* @param {string} variant 颜色变体: 'violet' | 'teal' | 'amber' | 'rose' | 'green',不传则使用默认蓝色
*/
const SelectableButtonGroup = ({
title,
@@ -58,6 +58,7 @@ const SelectableButtonGroup = ({
collapseHeight = 200,
withCheckbox = false,
loading = false,
variant,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [skeletonCount] = useState(12);
@@ -178,9 +179,6 @@ const SelectableButtonGroup = ({
) : (
<Row gutter={gutterSize} style={{ lineHeight: '32px', ...style }}>
{items.map((item) => {
const isDisabled =
item.disabled ||
(typeof item.tagCount === 'number' && item.tagCount === 0);
const isActive = Array.isArray(activeValue)
? activeValue.includes(item.value)
: activeValue === item.value;
@@ -194,13 +192,11 @@ const SelectableButtonGroup = ({
}}
theme={isActive ? 'light' : 'outline'}
type={isActive ? 'primary' : 'tertiary'}
disabled={isDisabled}
className='sbg-button'
icon={
<Checkbox
checked={isActive}
onChange={() => onChange(item.value)}
disabled={isDisabled}
style={{ pointerEvents: 'auto' }}
/>
}
@@ -210,14 +206,9 @@ const SelectableButtonGroup = ({
{item.icon && <span className='sbg-icon'>{item.icon}</span>}
<ConditionalTooltipText text={item.label} />
{item.tagCount !== undefined && shouldShowTags && (
<Tag
className='sbg-tag'
color='white'
shape='circle'
size='small'
>
<span className={`sbg-badge ${isActive ? 'sbg-badge-active' : ''}`}>
{item.tagCount}
</Tag>
</span>
)}
</div>
</Button>
@@ -231,22 +222,16 @@ const SelectableButtonGroup = ({
onClick={() => onChange(item.value)}
theme={isActive ? 'light' : 'outline'}
type={isActive ? 'primary' : 'tertiary'}
disabled={isDisabled}
className='sbg-button'
style={{ width: '100%' }}
>
<div className='sbg-content'>
{item.icon && <span className='sbg-icon'>{item.icon}</span>}
<ConditionalTooltipText text={item.label} />
{item.tagCount !== undefined && shouldShowTags && (
<Tag
className='sbg-tag'
color='white'
shape='circle'
size='small'
>
{item.tagCount !== undefined && shouldShowTags && item.tagCount !== '' && (
<span className={`sbg-badge ${isActive ? 'sbg-badge-active' : ''}`}>
{item.tagCount}
</Tag>
</span>
)}
</div>
</Button>
@@ -258,7 +243,7 @@ const SelectableButtonGroup = ({
return (
<div
className={`mb-8 ${containerWidth <= 400 ? 'sbg-compact' : ''}`}
className={`mb-8 ${containerWidth <= 400 ? 'sbg-compact' : ''}${variant ? ` sbg-variant-${variant}` : ''}`}
ref={containerRef}
>
{title && (

View File

@@ -723,10 +723,6 @@ export const getChannelsColumns = ({
name: t('仅检测上游模型更新'),
type: 'tertiary',
onClick: () => {
if (!upstreamUpdateMeta.enabled) {
showInfo(t('该渠道未开启上游模型更新检测'));
return;
}
detectChannelUpstreamUpdates(record);
},
});

View File

@@ -3291,6 +3291,18 @@ const EditChannelModal = (props) => {
inputs.upstream_model_update_last_check_time,
)}
</div>
<Form.Input
field='upstream_model_update_ignored_models'
label={t('已忽略模型')}
placeholder={t('例如gpt-4.1-nano,gpt-4o-mini')}
onChange={(value) =>
handleInputChange(
'upstream_model_update_ignored_models',
value,
)
}
showClear
/>
</>
)}
@@ -3460,19 +3472,6 @@ const EditChannelModal = (props) => {
)}
/>
<Form.Input
field='upstream_model_update_ignored_models'
label={t('手动忽略模型(逗号分隔)')}
placeholder={t('例如gpt-4.1-nano,gpt-4o-mini')}
onChange={(value) =>
handleInputChange(
'upstream_model_update_ignored_models',
value,
)
}
showClear
/>
<div className='text-xs text-gray-500 mb-3'>
{t('上次检测到可加入模型')}:&nbsp;
{upstreamDetectedModels.length === 0 ? (

View File

@@ -276,6 +276,7 @@ const LEGACY_TEMPLATE = {
const OPERATION_TEMPLATE = {
operations: [
{
description: 'Set default temperature for openai/* models.',
path: 'temperature',
mode: 'set',
value: 0.7,
@@ -294,8 +295,9 @@ const OPERATION_TEMPLATE = {
const HEADER_PASSTHROUGH_TEMPLATE = {
operations: [
{
description: 'Pass through X-Request-Id header to upstream.',
mode: 'pass_headers',
value: ['Authorization'],
value: ['X-Request-Id'],
keep_origin: true,
},
],
@@ -304,6 +306,8 @@ const HEADER_PASSTHROUGH_TEMPLATE = {
const GEMINI_IMAGE_4K_TEMPLATE = {
operations: [
{
description:
'Set imageSize to 4K when model contains gemini/image and ends with 4k.',
mode: 'set',
path: 'generationConfig.imageConfig.imageSize',
value: '4K',
@@ -311,7 +315,17 @@ const GEMINI_IMAGE_4K_TEMPLATE = {
{
path: 'original_model',
mode: 'contains',
value: 'gemini-3-pro-image-preview',
value: 'gemini',
},
{
path: 'original_model',
mode: 'contains',
value: 'image',
},
{
path: 'original_model',
mode: 'suffix',
value: '4k',
},
],
logic: 'AND',
@@ -319,11 +333,13 @@ const GEMINI_IMAGE_4K_TEMPLATE = {
],
};
const AWS_BEDROCK_ANTHROPIC_BETA_OVERRIDE_TEMPLATE = {
const AWS_BEDROCK_ANTHROPIC_COMPAT_TEMPLATE = {
operations: [
{
description: 'Normalize anthropic-beta header tokens for Bedrock compatibility.',
mode: 'set_header',
path: 'anthropic-beta',
// https://github.com/BerriAI/litellm/blob/main/litellm/anthropic_beta_headers_config.json
value: {
'advanced-tool-use-2025-11-20': 'tool-search-tool-2025-10-19',
bash_20241022: null,
@@ -355,6 +371,11 @@ const AWS_BEDROCK_ANTHROPIC_BETA_OVERRIDE_TEMPLATE = {
'web-search-2025-03-05': null,
},
},
{
description: 'Remove all tools[*].custom.input_examples before upstream relay.',
mode: 'delete',
path: 'tools.*.custom.input_examples',
},
],
};
@@ -378,7 +399,7 @@ const TEMPLATE_PRESET_CONFIG = {
},
pass_headers_auth: {
group: 'scenario',
label: '请求头透传(Authorization',
label: '请求头透传(X-Request-Id',
kind: 'operations',
payload: HEADER_PASSTHROUGH_TEMPLATE,
},
@@ -402,9 +423,9 @@ const TEMPLATE_PRESET_CONFIG = {
},
aws_bedrock_anthropic_beta_override: {
group: 'scenario',
label: 'AWS Bedrock anthropic-beta覆盖',
label: 'AWS Bedrock Claude 兼容模板',
kind: 'operations',
payload: AWS_BEDROCK_ANTHROPIC_BETA_OVERRIDE_TEMPLATE,
payload: AWS_BEDROCK_ANTHROPIC_COMPAT_TEMPLATE,
},
};
@@ -764,6 +785,7 @@ const createDefaultCondition = () => normalizeCondition({});
const normalizeOperation = (operation = {}) => ({
id: nextLocalId(),
description: typeof operation.description === 'string' ? operation.description : '',
path: typeof operation.path === 'string' ? operation.path : '',
mode: OPERATION_MODE_VALUES.has(operation.mode) ? operation.mode : 'set',
value_text: toValueText(operation.value),
@@ -1086,6 +1108,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
if (!keyword) return operations;
return operations.filter((operation) => {
const searchableText = [
operation.description,
operation.mode,
operation.path,
operation.from,
@@ -1151,10 +1174,14 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
const payloadOps = filteredOps.map((operation) => {
const mode = operation.mode || 'set';
const meta = MODE_META[mode] || MODE_META.set;
const descriptionValue = String(operation.description || '').trim();
const pathValue = operation.path.trim();
const fromValue = operation.from.trim();
const toValue = operation.to.trim();
const payload = { mode };
if (descriptionValue) {
payload.description = descriptionValue;
}
if (meta.path) {
payload.path = pathValue;
}
@@ -1563,6 +1590,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
if (index < 0) return prev;
const source = prev[index];
const cloned = normalizeOperation({
description: source.description,
path: source.path,
mode: source.mode,
value: parseLooseValue(source.value_text),
@@ -1812,14 +1840,6 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
{t('重置')}
</Button>
</Space>
<Text
type='tertiary'
size='small'
className='cursor-pointer select-none mt-1 whitespace-nowrap'
onClick={() => openFieldGuide('path')}
>
{t('字段速查')}
</Text>
</div>
</Card>
@@ -1891,7 +1911,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
<Input
value={operationSearch}
placeholder={t('搜索规则(类型 / 路径 / 来源 / 目标)')}
placeholder={t('搜索规则(描述 / 类型 / 路径 / 来源 / 目标)')}
onChange={(nextValue) =>
setOperationSearch(nextValue || '')
}
@@ -1958,6 +1978,23 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
>
{getOperationSummary(operation, index)}
</Text>
{String(operation.description || '').trim() ? (
<Text
type='tertiary'
size='small'
className='block mt-1'
style={{
lineHeight: 1.5,
wordBreak: 'break-word',
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{operation.description}
</Text>
) : null}
</div>
<Tag size='small' color='grey'>
{(operation.conditions || []).length}
@@ -2035,6 +2072,7 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
type='danger'
theme='borderless'
icon={<IconDelete />}
aria-label={t('删除规则')}
onClick={() =>
removeOperation(selectedOperation.id)
}
@@ -2085,6 +2123,25 @@ const ParamOverrideEditorModal = ({ visible, value, onSave, onCancel }) => {
>
{MODE_DESCRIPTIONS[mode] || ''}
</Text>
<div className='mt-2'>
<Text type='tertiary' size='small'>
{t('规则描述(可选)')}
</Text>
<Input
value={selectedOperation.description || ''}
placeholder={t('例如:清理工具参数,避免上游校验错误')}
onChange={(nextValue) =>
updateOperation(selectedOperation.id, {
description: nextValue || '',
})
}
maxLength={180}
showClear
/>
<Text type='tertiary' size='small' className='mt-1 block'>
{`${String(selectedOperation.description || '').length}/180`}
</Text>
</div>
{meta.value ? (
mode === 'return_error' && returnErrorDraft ? (

View File

@@ -76,7 +76,6 @@ const PricingEndpointTypes = ({
value: 'all',
label: t('全部端点'),
tagCount: getEndpointTypeCount('all'),
disabled: models.length === 0,
},
...availableEndpointTypes.map((endpointType) => {
const count = getEndpointTypeCount(endpointType);
@@ -84,7 +83,6 @@ const PricingEndpointTypes = ({
value: endpointType,
label: getEndpointTypeLabel(endpointType),
tagCount: count,
disabled: count === 0,
};
}),
];
@@ -96,6 +94,7 @@ const PricingEndpointTypes = ({
activeValue={filterEndpointType}
onChange={setFilterEndpointType}
loading={loading}
variant='green'
t={t}
/>
);

View File

@@ -52,20 +52,19 @@ const PricingGroups = ({
.length;
let ratioDisplay = '';
if (g === 'all') {
ratioDisplay = t('全部');
// ratioDisplay = t('全部');
} else {
const ratio = groupRatio[g];
if (ratio !== undefined && ratio !== null) {
ratioDisplay = `x${ratio}`;
ratioDisplay = `${ratio}x`;
} else {
ratioDisplay = 'x1';
ratioDisplay = '1x';
}
}
return {
value: g,
label: g === 'all' ? t('全部分组') : g,
tagCount: ratioDisplay,
disabled: modelCount === 0,
};
});
@@ -76,6 +75,7 @@ const PricingGroups = ({
activeValue={filterGroup}
onChange={setFilterGroup}
loading={loading}
variant='teal'
t={t}
/>
);

View File

@@ -52,6 +52,7 @@ const PricingQuotaTypes = ({
activeValue={filterQuotaType}
onChange={setFilterQuotaType}
loading={loading}
variant='amber'
t={t}
/>
);

View File

@@ -78,7 +78,6 @@ const PricingTags = ({
value: 'all',
label: t('全部标签'),
tagCount: getTagCount('all'),
disabled: models.length === 0,
},
];
@@ -88,7 +87,6 @@ const PricingTags = ({
value: tag,
label: tag,
tagCount: count,
disabled: count === 0,
});
});
@@ -102,6 +100,7 @@ const PricingTags = ({
activeValue={filterTag}
onChange={setFilterTag}
loading={loading}
variant='rose'
t={t}
/>
);

View File

@@ -83,7 +83,6 @@ const PricingVendors = ({
value: 'all',
label: t('全部供应商'),
tagCount: getVendorCount('all'),
disabled: models.length === 0,
},
];
@@ -96,7 +95,6 @@ const PricingVendors = ({
label: vendor,
icon: icon ? getLobeHubIcon(icon, 16) : null,
tagCount: count,
disabled: count === 0,
});
});
@@ -107,7 +105,6 @@ const PricingVendors = ({
value: 'unknown',
label: t('未知供应商'),
tagCount: count,
disabled: count === 0,
});
}
@@ -121,6 +118,7 @@ const PricingVendors = ({
activeValue={filterVendor}
onChange={setFilterVendor}
loading={loading}
variant='violet'
t={t}
/>
);

View File

@@ -113,15 +113,6 @@ const PricingSidebar = ({
t={t}
/>
<PricingTags
filterTag={filterTag}
setFilterTag={setFilterTag}
models={tagModels}
allModels={categoryProps.models}
loading={loading}
t={t}
/>
<PricingGroups
filterGroup={filterGroup}
setFilterGroup={handleGroupClick}
@@ -140,6 +131,15 @@ const PricingSidebar = ({
t={t}
/>
<PricingTags
filterTag={filterTag}
setFilterTag={setFilterTag}
models={tagModels}
allModels={categoryProps.models}
loading={loading}
t={t}
/>
<PricingEndpointTypes
filterEndpointType={filterEndpointType}
setFilterEndpointType={setFilterEndpointType}

View File

@@ -96,15 +96,6 @@ const FilterModalContent = ({ sidebarProps, t }) => {
t={t}
/>
<PricingTags
filterTag={filterTag}
setFilterTag={setFilterTag}
models={tagModels}
allModels={categoryProps.models}
loading={loading}
t={t}
/>
<PricingGroups
filterGroup={filterGroup}
setFilterGroup={setFilterGroup}
@@ -123,6 +114,15 @@ const FilterModalContent = ({ sidebarProps, t }) => {
t={t}
/>
<PricingTags
filterTag={filterTag}
setFilterTag={setFilterTag}
models={tagModels}
allModels={categoryProps.models}
loading={loading}
t={t}
/>
<PricingEndpointTypes
filterEndpointType={filterEndpointType}
setFilterEndpointType={setFilterEndpointType}

View File

@@ -21,6 +21,23 @@ import { useRef, useState } from 'react';
import { API, showError, showInfo, showSuccess } from '../../helpers';
import { normalizeModelList } from './upstreamUpdateUtils';
const getManualIgnoredModelCountFromSettings = (settings) => {
let parsed = null;
if (settings && typeof settings === 'object') {
parsed = settings;
} else if (typeof settings === 'string') {
try {
parsed = JSON.parse(settings);
} catch (error) {
parsed = null;
}
}
if (!parsed || typeof parsed !== 'object') {
return 0;
}
return normalizeModelList(parsed.upstream_model_update_ignored_models).length;
};
export const useChannelUpstreamUpdates = ({ t, refresh }) => {
const [showUpstreamUpdateModal, setShowUpstreamUpdateModal] = useState(false);
const [upstreamUpdateChannel, setUpstreamUpdateChannel] = useState(null);
@@ -114,14 +131,18 @@ export const useChannelUpstreamUpdates = ({ t, refresh }) => {
const addedCount = data?.added_models?.length || 0;
const removedCount = data?.removed_models?.length || 0;
const ignoredCount = data?.ignored_models?.length || 0;
const totalIgnoredCount = getManualIgnoredModelCountFromSettings(
data?.settings,
);
const ignoredCount = normalizeModelList(ignoreModels).length;
showSuccess(
t(
'已处理上游模型更新:加入 {{added}} 个,删除 {{removed}} 个,忽略 {{ignored}} 个',
'已处理上游模型更新:加入 {{added}} 个,删除 {{removed}} 个,本次忽略 {{ignored}} 个,当前已忽略模型 {{totalIgnored}} 个',
{
added: addedCount,
removed: removedCount,
ignored: ignoredCount,
totalIgnored: totalIgnoredCount,
},
),
);

96
web/src/index.css vendored
View File

@@ -344,6 +344,102 @@ code {
text-overflow: ellipsis;
}
/* Badge for count/multiplier in filter buttons */
.sbg-badge {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-width: 18px;
height: 18px;
padding: 0 6px;
border-radius: 9px;
font-size: 11px;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 1;
background-color: var(--semi-color-fill-0);
color: var(--semi-color-text-2);
transition: background-color 0.15s ease, color 0.15s ease;
}
.sbg-badge-active {
background-color: var(--semi-color-primary-light-active);
color: var(--semi-color-primary);
}
/* ---- SelectableButtonGroup color variants ---- */
.sbg-variant-violet {
--semi-color-primary: #6d28d9;
--semi-color-primary-light-default: rgba(124, 58, 237, 0.08);
--semi-color-primary-light-hover: rgba(124, 58, 237, 0.15);
--semi-color-primary-light-active: rgba(124, 58, 237, 0.22);
}
.sbg-variant-teal {
--semi-color-primary: #0f766e;
--semi-color-primary-light-default: rgba(20, 184, 166, 0.08);
--semi-color-primary-light-hover: rgba(20, 184, 166, 0.15);
--semi-color-primary-light-active: rgba(20, 184, 166, 0.22);
}
.sbg-variant-amber {
--semi-color-primary: #b45309;
--semi-color-primary-light-default: rgba(245, 158, 11, 0.08);
--semi-color-primary-light-hover: rgba(245, 158, 11, 0.15);
--semi-color-primary-light-active: rgba(245, 158, 11, 0.22);
}
.sbg-variant-rose {
--semi-color-primary: #be123c;
--semi-color-primary-light-default: rgba(244, 63, 94, 0.08);
--semi-color-primary-light-hover: rgba(244, 63, 94, 0.15);
--semi-color-primary-light-active: rgba(244, 63, 94, 0.22);
}
.sbg-variant-green {
--semi-color-primary: #047857;
--semi-color-primary-light-default: rgba(16, 185, 129, 0.08);
--semi-color-primary-light-hover: rgba(16, 185, 129, 0.15);
--semi-color-primary-light-active: rgba(16, 185, 129, 0.22);
}
/* Dark mode: lighter text, slightly stronger backgrounds */
html.dark .sbg-variant-violet {
--semi-color-primary: #a78bfa;
--semi-color-primary-light-default: rgba(139, 92, 246, 0.14);
--semi-color-primary-light-hover: rgba(139, 92, 246, 0.22);
--semi-color-primary-light-active: rgba(139, 92, 246, 0.3);
}
html.dark .sbg-variant-teal {
--semi-color-primary: #2dd4bf;
--semi-color-primary-light-default: rgba(45, 212, 191, 0.14);
--semi-color-primary-light-hover: rgba(45, 212, 191, 0.22);
--semi-color-primary-light-active: rgba(45, 212, 191, 0.3);
}
html.dark .sbg-variant-amber {
--semi-color-primary: #fbbf24;
--semi-color-primary-light-default: rgba(251, 191, 36, 0.14);
--semi-color-primary-light-hover: rgba(251, 191, 36, 0.22);
--semi-color-primary-light-active: rgba(251, 191, 36, 0.3);
}
html.dark .sbg-variant-rose {
--semi-color-primary: #fb7185;
--semi-color-primary-light-default: rgba(251, 113, 133, 0.14);
--semi-color-primary-light-hover: rgba(251, 113, 133, 0.22);
--semi-color-primary-light-active: rgba(251, 113, 133, 0.3);
}
html.dark .sbg-variant-green {
--semi-color-primary: #34d399;
--semi-color-primary-light-default: rgba(52, 211, 153, 0.14);
--semi-color-primary-light-hover: rgba(52, 211, 153, 0.22);
--semi-color-primary-light-active: rgba(52, 211, 153, 0.3);
}
/* Tabs组件样式 */
.semi-tabs-content {
padding: 0 !important;