فهرست منبع

Merge branch 'alpha' into feature/claude-code

# Conflicts:
#	web/src/components/table/channels/modals/EditChannelModal.jsx
Seefs 5 ماه پیش
والد
کامیت
345fbdf3d2
52فایلهای تغییر یافته به همراه1400 افزوده شده و 312 حذف شده
  1. 5 0
      common/custom-event.go
  2. 95 0
      common/str.go
  3. 10 7
      controller/channel-test.go
  4. 5 5
      controller/playground.go
  5. 5 6
      controller/relay.go
  6. 1 1
      middleware/distributor.go
  7. 8 12
      model/channel.go
  8. 14 0
      model/channel_cache.go
  9. 5 5
      relay/audio_handler.go
  10. 2 2
      relay/channel/ali/image.go
  11. 2 2
      relay/channel/ali/rerank.go
  12. 3 3
      relay/channel/ali/text.go
  13. 1 1
      relay/channel/api_request.go
  14. 1 56
      relay/channel/gemini/adaptor.go
  15. 3 3
      relay/channel/gemini/relay-gemini-native.go
  16. 60 6
      relay/channel/gemini/relay-gemini.go
  17. 2 2
      relay/channel/jimeng/image.go
  18. 6 6
      relay/channel/openai/relay-openai.go
  19. 2 2
      relay/channel/openai/relay_responses.go
  20. 2 2
      relay/channel/palm/relay-palm.go
  21. 2 2
      relay/channel/siliconflow/relay-siliconflow.go
  22. 2 2
      relay/channel/tencent/relay-tencent.go
  23. 11 2
      relay/channel/vertex/adaptor.go
  24. 2 2
      relay/channel/zhipu/relay-zhipu.go
  25. 9 9
      relay/claude_handler.go
  26. 1 0
      relay/common/relay_info.go
  27. 3 3
      relay/common_handler/rerank.go
  28. 7 8
      relay/embedding_handler.go
  29. 11 12
      relay/gemini_handler.go
  30. 1 1
      relay/helper/stream_scanner.go
  31. 10 10
      relay/image_handler.go
  32. 18 17
      relay/relay-text.go
  33. 10 10
      relay/rerank_handler.go
  34. 10 10
      relay/responses_handler.go
  35. 3 3
      relay/websocket.go
  36. 1 1
      service/channel.go
  37. 1 2
      service/error.go
  38. 4 0
      setting/rate_limit.go
  39. 63 20
      types/error.go
  40. 609 0
      web/src/components/common/JSONEditor.js
  41. 2 2
      web/src/components/playground/SettingsPanel.js
  42. 59 46
      web/src/components/table/channels/modals/EditChannelModal.jsx
  43. 2 2
      web/src/components/table/channels/modals/EditTagModal.jsx
  44. 272 0
      web/src/components/table/channels/modals/ModelSelectModal.jsx
  45. 2 2
      web/src/components/table/tokens/modals/EditTokenModal.jsx
  46. 11 1
      web/src/helpers/render.js
  47. 9 5
      web/src/helpers/utils.js
  48. 8 5
      web/src/hooks/mj-logs/useMjLogsData.js
  49. 8 5
      web/src/hooks/task-logs/useTaskLogsData.js
  50. 8 8
      web/src/hooks/usage-logs/useUsageLogsData.js
  51. 6 1
      web/src/i18n/locales/en.json
  52. 3 0
      web/src/pages/Setting/RateLimit/SettingsRequestRateLimit.js

+ 5 - 0
common/custom-event.go

@@ -9,6 +9,7 @@ import (
 	"io"
 	"net/http"
 	"strings"
+	"sync"
 )
 
 type stringWriter interface {
@@ -52,6 +53,8 @@ type CustomEvent struct {
 	Id    string
 	Retry uint
 	Data  interface{}
+
+	Mutex sync.Mutex
 }
 
 func encode(writer io.Writer, event CustomEvent) error {
@@ -73,6 +76,8 @@ func (r CustomEvent) Render(w http.ResponseWriter) error {
 }
 
 func (r CustomEvent) WriteContentType(w http.ResponseWriter) {
+	r.Mutex.Lock()
+	defer r.Mutex.Unlock()
 	header := w.Header()
 	header["Content-Type"] = contentType
 

+ 95 - 0
common/str.go

@@ -4,7 +4,10 @@ import (
 	"encoding/base64"
 	"encoding/json"
 	"math/rand"
+	"net/url"
+	"regexp"
 	"strconv"
+	"strings"
 	"unsafe"
 )
 
@@ -95,3 +98,95 @@ func GetJsonString(data any) string {
 	b, _ := json.Marshal(data)
 	return string(b)
 }
+
+// MaskSensitiveInfo masks sensitive information like URLs, IPs in a string
+// Example:
+// http://example.com -> http://***.com
+// https://api.test.org/v1/users/123?key=secret -> https://***.org/***/***/?key=***
+// https://sub.domain.co.uk/path/to/resource -> https://***.co.uk/***/***
+// 192.168.1.1 -> ***.***.***.***
+func MaskSensitiveInfo(str string) string {
+	// Mask URLs
+	urlPattern := regexp.MustCompile(`(http|https)://[^\s/$.?#].[^\s]*`)
+	str = urlPattern.ReplaceAllStringFunc(str, func(urlStr string) string {
+		u, err := url.Parse(urlStr)
+		if err != nil {
+			return urlStr
+		}
+
+		host := u.Host
+		if host == "" {
+			return urlStr
+		}
+
+		// Split host by dots
+		parts := strings.Split(host, ".")
+		if len(parts) < 2 {
+			// If less than 2 parts, just mask the whole host
+			return u.Scheme + "://***" + u.Path
+		}
+
+		// Keep the TLD (Top Level Domain) and mask the rest
+		var maskedHost string
+		if len(parts) == 2 {
+			// example.com -> ***.com
+			maskedHost = "***." + parts[len(parts)-1]
+		} else {
+			// Handle cases like sub.domain.co.uk or api.example.com
+			// Keep last 2 parts if they look like country code TLD (co.uk, com.cn, etc.)
+			lastPart := parts[len(parts)-1]
+			secondLastPart := parts[len(parts)-2]
+
+			if len(lastPart) == 2 && len(secondLastPart) <= 3 {
+				// Likely country code TLD like co.uk, com.cn
+				maskedHost = "***." + secondLastPart + "." + lastPart
+			} else {
+				// Regular TLD like .com, .org
+				maskedHost = "***." + lastPart
+			}
+		}
+
+		result := u.Scheme + "://" + maskedHost
+
+		// Mask path
+		if u.Path != "" && u.Path != "/" {
+			pathParts := strings.Split(strings.Trim(u.Path, "/"), "/")
+			maskedPathParts := make([]string, len(pathParts))
+			for i := range pathParts {
+				if pathParts[i] != "" {
+					maskedPathParts[i] = "***"
+				}
+			}
+			if len(maskedPathParts) > 0 {
+				result += "/" + strings.Join(maskedPathParts, "/")
+			}
+		} else if u.Path == "/" {
+			result += "/"
+		}
+
+		// Mask query parameters
+		if u.RawQuery != "" {
+			values, err := url.ParseQuery(u.RawQuery)
+			if err != nil {
+				// If can't parse query, just mask the whole query string
+				result += "?***"
+			} else {
+				maskedParams := make([]string, 0, len(values))
+				for key := range values {
+					maskedParams = append(maskedParams, key+"=***")
+				}
+				if len(maskedParams) > 0 {
+					result += "?" + strings.Join(maskedParams, "&")
+				}
+			}
+		}
+
+		return result
+	})
+
+	// Mask IP addresses
+	ipPattern := regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`)
+	str = ipPattern.ReplaceAllString(str, "***.***.***.***")
+
+	return str
+}

+ 10 - 7
controller/channel-test.go

@@ -209,7 +209,7 @@ func testChannel(channel *model.Channel, testModel string) testResult {
 		return testResult{
 			context:     c,
 			localErr:    err,
-			newAPIError: types.NewError(err, types.ErrorCodeDoRequestFailed),
+			newAPIError: types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError),
 		}
 	}
 	var httpResp *http.Response
@@ -220,7 +220,7 @@ func testChannel(channel *model.Channel, testModel string) testResult {
 			return testResult{
 				context:     c,
 				localErr:    err,
-				newAPIError: types.NewError(err, types.ErrorCodeBadResponse),
+				newAPIError: types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError),
 			}
 		}
 	}
@@ -236,7 +236,7 @@ func testChannel(channel *model.Channel, testModel string) testResult {
 		return testResult{
 			context:     c,
 			localErr:    errors.New("usage is nil"),
-			newAPIError: types.NewError(errors.New("usage is nil"), types.ErrorCodeBadResponseBody),
+			newAPIError: types.NewOpenAIError(errors.New("usage is nil"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError),
 		}
 	}
 	usage := usageA.(*dto.Usage)
@@ -246,7 +246,7 @@ func testChannel(channel *model.Channel, testModel string) testResult {
 		return testResult{
 			context:     c,
 			localErr:    err,
-			newAPIError: types.NewError(err, types.ErrorCodeReadResponseBodyFailed),
+			newAPIError: types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError),
 		}
 	}
 	info.PromptTokens = usage.PromptTokens
@@ -332,8 +332,11 @@ func TestChannel(c *gin.Context) {
 	}
 	channel, err := model.CacheGetChannel(channelId)
 	if err != nil {
-		common.ApiError(c, err)
-		return
+		channel, err = model.GetChannelById(channelId, true)
+		if err != nil {
+			common.ApiError(c, err)
+			return
+		}
 	}
 	//defer func() {
 	//	if channel.ChannelInfo.IsMultiKey {
@@ -417,7 +420,7 @@ func testAllChannels(notify bool) error {
 			if common.AutomaticDisableChannelEnabled && !shouldBanChannel {
 				if milliseconds > disableThreshold {
 					err := errors.New(fmt.Sprintf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0))
-					newAPIError = types.NewError(err, types.ErrorCodeChannelResponseTimeExceeded)
+					newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout)
 					shouldBanChannel = true
 				}
 			}

+ 5 - 5
controller/playground.go

@@ -28,19 +28,19 @@ func Playground(c *gin.Context) {
 
 	useAccessToken := c.GetBool("use_access_token")
 	if useAccessToken {
-		newAPIError = types.NewError(errors.New("暂不支持使用 access token"), types.ErrorCodeAccessDenied)
+		newAPIError = types.NewError(errors.New("暂不支持使用 access token"), types.ErrorCodeAccessDenied, types.ErrOptionWithSkipRetry())
 		return
 	}
 
 	playgroundRequest := &dto.PlayGroundRequest{}
 	err := common.UnmarshalBodyReusable(c, playgroundRequest)
 	if err != nil {
-		newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest)
+		newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
 		return
 	}
 
 	if playgroundRequest.Model == "" {
-		newAPIError = types.NewError(errors.New("请选择模型"), types.ErrorCodeInvalidRequest)
+		newAPIError = types.NewError(errors.New("请选择模型"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
 		return
 	}
 	c.Set("original_model", playgroundRequest.Model)
@@ -51,7 +51,7 @@ func Playground(c *gin.Context) {
 		group = userGroup
 	} else {
 		if !setting.GroupInUserUsableGroups(group) && group != userGroup {
-			newAPIError = types.NewError(errors.New("无权访问该分组"), types.ErrorCodeAccessDenied)
+			newAPIError = types.NewError(errors.New("无权访问该分组"), types.ErrorCodeAccessDenied, types.ErrOptionWithSkipRetry())
 			return
 		}
 		c.Set("group", group)
@@ -62,7 +62,7 @@ func Playground(c *gin.Context) {
 	// Write user context to ensure acceptUnsetRatio is available
 	userCache, err := model.GetUserCache(userId)
 	if err != nil {
-		newAPIError = types.NewError(err, types.ErrorCodeQueryDataError)
+		newAPIError = types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
 		return
 	}
 	userCache.WriteContext(c)

+ 5 - 6
controller/relay.go

@@ -62,8 +62,7 @@ func relayHandler(c *gin.Context, relayMode int) *types.NewAPIError {
 		other["channel_id"] = channelId
 		other["channel_name"] = c.GetString("channel_name")
 		other["channel_type"] = c.GetInt("channel_type")
-
-		model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.Error(), tokenId, 0, false, userGroup, other)
+		model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveError(), tokenId, 0, false, userGroup, other)
 	}
 
 	return err
@@ -128,7 +127,7 @@ func WssRelay(c *gin.Context) {
 	defer ws.Close()
 
 	if err != nil {
-		helper.WssError(c, ws, types.NewError(err, types.ErrorCodeGetChannelFailed).ToOpenAIError())
+		helper.WssError(c, ws, types.NewError(err, types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()).ToOpenAIError())
 		return
 	}
 
@@ -259,10 +258,10 @@ func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*m
 	}
 	channel, selectGroup, err := model.CacheGetRandomSatisfiedChannel(c, group, originalModel, retryCount)
 	if err != nil {
-		return nil, types.NewError(errors.New(fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败(retry): %s", selectGroup, originalModel, err.Error())), types.ErrorCodeGetChannelFailed)
+		return nil, types.NewError(errors.New(fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败(retry): %s", selectGroup, originalModel, err.Error())), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
 	}
 	if channel == nil {
-		return nil, types.NewError(errors.New(fmt.Sprintf("分组 %s 下模型 %s 的可用渠道不存在(数据库一致性已被破坏,retry)", selectGroup, originalModel)), types.ErrorCodeGetChannelFailed)
+		return nil, types.NewError(errors.New(fmt.Sprintf("分组 %s 下模型 %s 的可用渠道不存在(数据库一致性已被破坏,retry)", selectGroup, originalModel)), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
 	}
 	newAPIError := middleware.SetupContextForSelectedChannel(c, channel, originalModel)
 	if newAPIError != nil {
@@ -278,7 +277,7 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
 	if types.IsChannelError(openaiErr) {
 		return true
 	}
-	if types.IsLocalError(openaiErr) {
+	if types.IsSkipRetryError(openaiErr) {
 		return false
 	}
 	if retryTimes <= 0 {

+ 1 - 1
middleware/distributor.go

@@ -247,7 +247,7 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
 func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, modelName string) *types.NewAPIError {
 	c.Set("original_model", modelName) // for retry
 	if channel == nil {
-		return types.NewError(errors.New("channel is nil"), types.ErrorCodeGetChannelFailed)
+		return types.NewError(errors.New("channel is nil"), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
 	}
 	common.SetContextKey(c, constant.ContextKeyChannelId, channel.Id)
 	common.SetContextKey(c, constant.ContextKeyChannelName, channel.Name)

+ 8 - 12
model/channel.go

@@ -75,7 +75,7 @@ func (channel *Channel) getKeys() []string {
 	// If the key starts with '[', try to parse it as a JSON array (e.g., for Vertex AI scenarios)
 	if strings.HasPrefix(trimmed, "[") {
 		var arr []json.RawMessage
-		if err := json.Unmarshal([]byte(trimmed), &arr); err == nil {
+		if err := common.Unmarshal([]byte(trimmed), &arr); err == nil {
 			res := make([]string, len(arr))
 			for i, v := range arr {
 				res[i] = string(v)
@@ -138,7 +138,7 @@ func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) {
 
 		channelInfo, err := CacheGetChannelInfo(channel.Id)
 		if err != nil {
-			return "", 0, types.NewError(err, types.ErrorCodeGetChannelFailed)
+			return "", 0, types.NewError(err, types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
 		}
 		//println("before polling index:", channel.ChannelInfo.MultiKeyPollingIndex)
 		defer func() {
@@ -197,7 +197,7 @@ func (channel *Channel) GetGroups() []string {
 func (channel *Channel) GetOtherInfo() map[string]interface{} {
 	otherInfo := make(map[string]interface{})
 	if channel.OtherInfo != "" {
-		err := json.Unmarshal([]byte(channel.OtherInfo), &otherInfo)
+		err := common.Unmarshal([]byte(channel.OtherInfo), &otherInfo)
 		if err != nil {
 			common.SysError("failed to unmarshal other info: " + err.Error())
 		}
@@ -425,7 +425,7 @@ func (channel *Channel) Update() error {
 			trimmed := strings.TrimSpace(keyStr)
 			if strings.HasPrefix(trimmed, "[") {
 				var arr []json.RawMessage
-				if err := json.Unmarshal([]byte(trimmed), &arr); err == nil {
+				if err := common.Unmarshal([]byte(trimmed), &arr); err == nil {
 					keys = make([]string, len(arr))
 					for i, v := range arr {
 						keys[i] = string(v)
@@ -571,10 +571,6 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
 			if channelCache.Status == status {
 				return false
 			}
-			// 如果缓存渠道不存在(说明已经被禁用),且要设置的状态不为启用,直接返回
-			if status != common.ChannelStatusEnabled {
-				return false
-			}
 			CacheUpdateChannelStatus(channelId, status)
 		}
 	}
@@ -778,7 +774,7 @@ func SearchTags(keyword string, group string, model string, idSort bool) ([]*str
 func (channel *Channel) ValidateSettings() error {
 	channelParams := &dto.ChannelSettings{}
 	if channel.Setting != nil && *channel.Setting != "" {
-		err := json.Unmarshal([]byte(*channel.Setting), channelParams)
+		err := common.Unmarshal([]byte(*channel.Setting), channelParams)
 		if err != nil {
 			return err
 		}
@@ -789,7 +785,7 @@ func (channel *Channel) ValidateSettings() error {
 func (channel *Channel) GetSetting() dto.ChannelSettings {
 	setting := dto.ChannelSettings{}
 	if channel.Setting != nil && *channel.Setting != "" {
-		err := json.Unmarshal([]byte(*channel.Setting), &setting)
+		err := common.Unmarshal([]byte(*channel.Setting), &setting)
 		if err != nil {
 			common.SysError("failed to unmarshal setting: " + err.Error())
 			channel.Setting = nil // 清空设置以避免后续错误
@@ -800,7 +796,7 @@ func (channel *Channel) GetSetting() dto.ChannelSettings {
 }
 
 func (channel *Channel) SetSetting(setting dto.ChannelSettings) {
-	settingBytes, err := json.Marshal(setting)
+	settingBytes, err := common.Marshal(setting)
 	if err != nil {
 		common.SysError("failed to marshal setting: " + err.Error())
 		return
@@ -811,7 +807,7 @@ func (channel *Channel) SetSetting(setting dto.ChannelSettings) {
 func (channel *Channel) GetParamOverride() map[string]interface{} {
 	paramOverride := make(map[string]interface{})
 	if channel.ParamOverride != nil && *channel.ParamOverride != "" {
-		err := json.Unmarshal([]byte(*channel.ParamOverride), &paramOverride)
+		err := common.Unmarshal([]byte(*channel.ParamOverride), &paramOverride)
 		if err != nil {
 			common.SysError("failed to unmarshal param override: " + err.Error())
 		}

+ 14 - 0
model/channel_cache.go

@@ -239,6 +239,20 @@ func CacheUpdateChannelStatus(id int, status int) {
 	if channel, ok := channelsIDM[id]; ok {
 		channel.Status = status
 	}
+	if status != common.ChannelStatusEnabled {
+		// delete the channel from group2model2channels
+		for group, model2channels := range group2model2channels {
+			for model, channels := range model2channels {
+				for i, channelId := range channels {
+					if channelId == id {
+						// remove the channel from the slice
+						group2model2channels[group][model] = append(channels[:i], channels[i+1:]...)
+						break
+					}
+				}
+			}
+		}
+	}
 }
 
 func CacheUpdateChannel(channel *Channel) {

+ 5 - 5
relay/audio_handler.go

@@ -62,7 +62,7 @@ func AudioHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 
 	if err != nil {
 		common.LogError(c, fmt.Sprintf("getAndValidAudioRequest failed: %s", err.Error()))
-		return types.NewError(err, types.ErrorCodeInvalidRequest)
+		return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
 	}
 
 	promptTokens := 0
@@ -75,7 +75,7 @@ func AudioHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 
 	priceData, err := helper.ModelPriceHelper(c, relayInfo, preConsumedTokens, 0)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeModelPriceError)
+		return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry())
 	}
 
 	preConsumedQuota, userQuota, openaiErr := preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo)
@@ -90,18 +90,18 @@ func AudioHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 
 	err = helper.ModelMappedHelper(c, relayInfo, audioRequest)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeChannelModelMappedError)
+		return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
 	}
 
 	adaptor := GetAdaptor(relayInfo.ApiType)
 	if adaptor == nil {
-		return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType)
+		return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry())
 	}
 	adaptor.Init(relayInfo)
 
 	ioReader, err := adaptor.ConvertAudioRequest(c, relayInfo, *audioRequest)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeConvertRequestFailed)
+		return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
 	}
 
 	resp, err := adaptor.DoRequest(c, relayInfo, ioReader)

+ 2 - 2
relay/channel/ali/image.go

@@ -132,12 +132,12 @@ func aliImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rela
 	var aliTaskResponse AliResponse
 	responseBody, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeReadResponseBodyFailed), nil
+		return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil
 	}
 	common.CloseResponseBodyGracefully(resp)
 	err = json.Unmarshal(responseBody, &aliTaskResponse)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeBadResponseBody), nil
+		return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil
 	}
 
 	if aliTaskResponse.Message != "" {

+ 2 - 2
relay/channel/ali/rerank.go

@@ -34,14 +34,14 @@ func ConvertRerankRequest(request dto.RerankRequest) *AliRerankRequest {
 func RerankHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) {
 	responseBody, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeReadResponseBodyFailed), nil
+		return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil
 	}
 	common.CloseResponseBodyGracefully(resp)
 
 	var aliResponse AliRerankResponse
 	err = json.Unmarshal(responseBody, &aliResponse)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeBadResponseBody), nil
+		return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil
 	}
 
 	if aliResponse.Code != "" {

+ 3 - 3
relay/channel/ali/text.go

@@ -43,7 +43,7 @@ func aliEmbeddingHandler(c *gin.Context, resp *http.Response) (*types.NewAPIErro
 	var fullTextResponse dto.FlexibleEmbeddingResponse
 	err := json.NewDecoder(resp.Body).Decode(&fullTextResponse)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeBadResponseBody), nil
+		return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil
 	}
 
 	common.CloseResponseBodyGracefully(resp)
@@ -179,12 +179,12 @@ func aliHandler(c *gin.Context, resp *http.Response) (*types.NewAPIError, *dto.U
 	var aliResponse AliResponse
 	responseBody, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeReadResponseBodyFailed), nil
+		return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil
 	}
 	common.CloseResponseBodyGracefully(resp)
 	err = json.Unmarshal(responseBody, &aliResponse)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeBadResponseBody), nil
+		return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil
 	}
 	if aliResponse.Code != "" {
 		return types.WithOpenAIError(types.OpenAIError{

+ 1 - 1
relay/channel/api_request.go

@@ -223,7 +223,7 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
 		helper.SetEventStreamHeaders(c)
 		// 处理流式请求的 ping 保活
 		generalSettings := operation_setting.GetGeneralSetting()
-		if generalSettings.PingIntervalEnabled {
+		if generalSettings.PingIntervalEnabled && !info.DisablePing {
 			pingInterval := time.Duration(generalSettings.PingIntervalSeconds) * time.Second
 			stopPinger = startPingKeepAlive(c, pingInterval)
 			// 使用defer确保在任何情况下都能停止ping goroutine

+ 1 - 56
relay/channel/gemini/adaptor.go

@@ -1,12 +1,10 @@
 package gemini
 
 import (
-	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
 	"net/http"
-	"one-api/common"
 	"one-api/dto"
 	"one-api/relay/channel"
 	"one-api/relay/channel/openai"
@@ -175,6 +173,7 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
 func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
 	if info.RelayMode == constant.RelayModeGemini {
 		if info.IsStream {
+			info.DisablePing = true
 			return GeminiTextGenerationStreamHandler(c, info, resp)
 		} else {
 			return GeminiTextGenerationHandler(c, info, resp)
@@ -212,60 +211,6 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
 	return nil, types.NewError(errors.New("not implemented"), types.ErrorCodeBadResponseBody)
 }
 
-func GeminiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
-	responseBody, readErr := io.ReadAll(resp.Body)
-	if readErr != nil {
-		return nil, types.NewError(readErr, types.ErrorCodeBadResponseBody)
-	}
-	_ = resp.Body.Close()
-
-	var geminiResponse GeminiImageResponse
-	if jsonErr := json.Unmarshal(responseBody, &geminiResponse); jsonErr != nil {
-		return nil, types.NewError(jsonErr, types.ErrorCodeBadResponseBody)
-	}
-
-	if len(geminiResponse.Predictions) == 0 {
-		return nil, types.NewError(errors.New("no images generated"), types.ErrorCodeBadResponseBody)
-	}
-
-	// convert to openai format response
-	openAIResponse := dto.ImageResponse{
-		Created: common.GetTimestamp(),
-		Data:    make([]dto.ImageData, 0, len(geminiResponse.Predictions)),
-	}
-
-	for _, prediction := range geminiResponse.Predictions {
-		if prediction.RaiFilteredReason != "" {
-			continue // skip filtered image
-		}
-		openAIResponse.Data = append(openAIResponse.Data, dto.ImageData{
-			B64Json: prediction.BytesBase64Encoded,
-		})
-	}
-
-	jsonResponse, jsonErr := json.Marshal(openAIResponse)
-	if jsonErr != nil {
-		return nil, types.NewError(jsonErr, types.ErrorCodeBadResponseBody)
-	}
-
-	c.Writer.Header().Set("Content-Type", "application/json")
-	c.Writer.WriteHeader(resp.StatusCode)
-	_, _ = c.Writer.Write(jsonResponse)
-
-	// https://github.com/google-gemini/cookbook/blob/719a27d752aac33f39de18a8d3cb42a70874917e/quickstarts/Counting_Tokens.ipynb
-	// each image has fixed 258 tokens
-	const imageTokens = 258
-	generatedImages := len(openAIResponse.Data)
-
-	usage := &dto.Usage{
-		PromptTokens:     imageTokens * generatedImages, // each generated image has fixed 258 tokens
-		CompletionTokens: 0,                             // image generation does not calculate completion tokens
-		TotalTokens:      imageTokens * generatedImages,
-	}
-
-	return usage, nil
-}
-
 func (a *Adaptor) GetModelList() []string {
 	return ModelList
 }

+ 3 - 3
relay/channel/gemini/relay-gemini-native.go

@@ -20,7 +20,7 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re
 	// 读取响应体
 	responseBody, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
+		return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
 	}
 
 	if common.DebugEnabled {
@@ -31,7 +31,7 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re
 	var geminiResponse GeminiChatResponse
 	err = common.Unmarshal(responseBody, &geminiResponse)
 	if err != nil {
-		return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
+		return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
 	}
 
 	// 计算使用量(基于 UsageMetadata)
@@ -54,7 +54,7 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re
 	// 直接返回 Gemini 原生格式的 JSON 响应
 	jsonResponse, err := common.Marshal(geminiResponse)
 	if err != nil {
-		return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
+		return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
 	}
 
 	common.IOCopyBytesGracefully(c, resp, jsonResponse)

+ 60 - 6
relay/channel/gemini/relay-gemini.go

@@ -907,7 +907,7 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
 func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
 	responseBody, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
+		return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
 	}
 	common.CloseResponseBodyGracefully(resp)
 	if common.DebugEnabled {
@@ -916,10 +916,10 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
 	var geminiResponse GeminiChatResponse
 	err = common.Unmarshal(responseBody, &geminiResponse)
 	if err != nil {
-		return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
+		return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
 	}
 	if len(geminiResponse.Candidates) == 0 {
-		return nil, types.NewError(errors.New("no candidates returned"), types.ErrorCodeBadResponseBody)
+		return nil, types.NewOpenAIError(errors.New("no candidates returned"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
 	}
 	fullTextResponse := responseGeminiChat2OpenAI(c, &geminiResponse)
 	fullTextResponse.Model = info.UpstreamModelName
@@ -956,12 +956,12 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
 
 	responseBody, readErr := io.ReadAll(resp.Body)
 	if readErr != nil {
-		return nil, types.NewError(readErr, types.ErrorCodeBadResponseBody)
+		return nil, types.NewOpenAIError(readErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
 	}
 
 	var geminiResponse GeminiEmbeddingResponse
 	if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil {
-		return nil, types.NewError(jsonErr, types.ErrorCodeBadResponseBody)
+		return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
 	}
 
 	// convert to openai format response
@@ -991,9 +991,63 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
 
 	jsonResponse, jsonErr := common.Marshal(openAIResponse)
 	if jsonErr != nil {
-		return nil, types.NewError(jsonErr, types.ErrorCodeBadResponseBody)
+		return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
 	}
 
 	common.IOCopyBytesGracefully(c, resp, jsonResponse)
 	return usage, nil
 }
+
+func GeminiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
+	responseBody, readErr := io.ReadAll(resp.Body)
+	if readErr != nil {
+		return nil, types.NewOpenAIError(readErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
+	}
+	_ = resp.Body.Close()
+
+	var geminiResponse GeminiImageResponse
+	if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil {
+		return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
+	}
+
+	if len(geminiResponse.Predictions) == 0 {
+		return nil, types.NewOpenAIError(errors.New("no images generated"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
+	}
+
+	// convert to openai format response
+	openAIResponse := dto.ImageResponse{
+		Created: common.GetTimestamp(),
+		Data:    make([]dto.ImageData, 0, len(geminiResponse.Predictions)),
+	}
+
+	for _, prediction := range geminiResponse.Predictions {
+		if prediction.RaiFilteredReason != "" {
+			continue // skip filtered image
+		}
+		openAIResponse.Data = append(openAIResponse.Data, dto.ImageData{
+			B64Json: prediction.BytesBase64Encoded,
+		})
+	}
+
+	jsonResponse, jsonErr := json.Marshal(openAIResponse)
+	if jsonErr != nil {
+		return nil, types.NewError(jsonErr, types.ErrorCodeBadResponseBody)
+	}
+
+	c.Writer.Header().Set("Content-Type", "application/json")
+	c.Writer.WriteHeader(resp.StatusCode)
+	_, _ = c.Writer.Write(jsonResponse)
+
+	// https://github.com/google-gemini/cookbook/blob/719a27d752aac33f39de18a8d3cb42a70874917e/quickstarts/Counting_Tokens.ipynb
+	// each image has fixed 258 tokens
+	const imageTokens = 258
+	generatedImages := len(openAIResponse.Data)
+
+	usage := &dto.Usage{
+		PromptTokens:     imageTokens * generatedImages, // each generated image has fixed 258 tokens
+		CompletionTokens: 0,                             // image generation does not calculate completion tokens
+		TotalTokens:      imageTokens * generatedImages,
+	}
+
+	return usage, nil
+}

+ 2 - 2
relay/channel/jimeng/image.go

@@ -52,13 +52,13 @@ func jimengImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.R
 	var jimengResponse ImageResponse
 	responseBody, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed)
+		return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
 	}
 	common.CloseResponseBodyGracefully(resp)
 
 	err = json.Unmarshal(responseBody, &jimengResponse)
 	if err != nil {
-		return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
+		return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
 	}
 
 	// Check if the response indicates an error

+ 6 - 6
relay/channel/openai/relay-openai.go

@@ -109,7 +109,7 @@ func sendStreamData(c *gin.Context, info *relaycommon.RelayInfo, data string, fo
 func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
 	if resp == nil || resp.Body == nil {
 		common.LogError(c, "invalid response or response body")
-		return nil, types.NewError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse)
+		return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError)
 	}
 
 	defer common.CloseResponseBodyGracefully(resp)
@@ -178,11 +178,11 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
 	var simpleResponse dto.OpenAITextResponse
 	responseBody, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed)
+		return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
 	}
 	err = common.Unmarshal(responseBody, &simpleResponse)
 	if err != nil {
-		return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
+		return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
 	}
 	if simpleResponse.Error != nil && simpleResponse.Error.Type != "" {
 		return nil, types.WithOpenAIError(*simpleResponse.Error, resp.StatusCode)
@@ -263,7 +263,7 @@ func OpenaiSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel
 	}
 	responseBody, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeReadResponseBodyFailed), nil
+		return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil
 	}
 	// 写入新的 response body
 	common.IOCopyBytesGracefully(c, resp, responseBody)
@@ -547,13 +547,13 @@ func OpenaiHandlerWithUsage(c *gin.Context, info *relaycommon.RelayInfo, resp *h
 
 	responseBody, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed)
+		return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
 	}
 
 	var usageResp dto.SimpleResponse
 	err = common.Unmarshal(responseBody, &usageResp)
 	if err != nil {
-		return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
+		return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
 	}
 
 	// 写入新的 response body

+ 2 - 2
relay/channel/openai/relay_responses.go

@@ -22,11 +22,11 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
 	var responsesResponse dto.OpenAIResponsesResponse
 	responseBody, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed)
+		return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
 	}
 	err = common.Unmarshal(responseBody, &responsesResponse)
 	if err != nil {
-		return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
+		return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
 	}
 	if responsesResponse.Error != nil {
 		return nil, types.WithOpenAIError(*responsesResponse.Error, resp.StatusCode)

+ 2 - 2
relay/channel/palm/relay-palm.go

@@ -127,13 +127,13 @@ func palmStreamHandler(c *gin.Context, resp *http.Response) (*types.NewAPIError,
 func palmHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
 	responseBody, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed)
+		return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
 	}
 	common.CloseResponseBodyGracefully(resp)
 	var palmResponse PaLMChatResponse
 	err = json.Unmarshal(responseBody, &palmResponse)
 	if err != nil {
-		return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
+		return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
 	}
 	if palmResponse.Error.Code != 0 || len(palmResponse.Candidates) == 0 {
 		return nil, types.WithOpenAIError(types.OpenAIError{

+ 2 - 2
relay/channel/siliconflow/relay-siliconflow.go

@@ -15,13 +15,13 @@ import (
 func siliconflowRerankHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
 	responseBody, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed)
+		return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
 	}
 	common.CloseResponseBodyGracefully(resp)
 	var siliconflowResp SFRerankResponse
 	err = json.Unmarshal(responseBody, &siliconflowResp)
 	if err != nil {
-		return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
+		return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
 	}
 	usage := &dto.Usage{
 		PromptTokens:     siliconflowResp.Meta.Tokens.InputTokens,

+ 2 - 2
relay/channel/tencent/relay-tencent.go

@@ -136,12 +136,12 @@ func tencentHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Resp
 	var tencentSb TencentChatResponseSB
 	responseBody, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed)
+		return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
 	}
 	common.CloseResponseBodyGracefully(resp)
 	err = json.Unmarshal(responseBody, &tencentSb)
 	if err != nil {
-		return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
+		return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
 	}
 	if tencentSb.Response.Error.Code != 0 {
 		return nil, types.WithOpenAIError(types.OpenAIError{

+ 11 - 2
relay/channel/vertex/adaptor.go

@@ -67,10 +67,10 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
 func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
 	if strings.HasPrefix(info.UpstreamModelName, "claude") {
 		a.RequestMode = RequestModeClaude
-	} else if strings.HasPrefix(info.UpstreamModelName, "gemini") {
-		a.RequestMode = RequestModeGemini
 	} else if strings.Contains(info.UpstreamModelName, "llama") {
 		a.RequestMode = RequestModeLlama
+	} else {
+		a.RequestMode = RequestModeGemini
 	}
 }
 
@@ -83,6 +83,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
 	a.AccountCredentials = *adc
 	suffix := ""
 	if a.RequestMode == RequestModeGemini {
+
 		if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
 			// 新增逻辑:处理 -thinking-<budget> 格式
 			if strings.Contains(info.UpstreamModelName, "-thinking-") {
@@ -100,6 +101,11 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
 		} else {
 			suffix = "generateContent"
 		}
+
+		if strings.HasPrefix(info.UpstreamModelName, "imagen") {
+			suffix = "predict"
+		}
+
 		if region == "global" {
 			return fmt.Sprintf(
 				"https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s",
@@ -231,6 +237,9 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
 			if info.RelayMode == constant.RelayModeGemini {
 				usage, err = gemini.GeminiTextGenerationHandler(c, info, resp)
 			} else {
+				if strings.HasPrefix(info.UpstreamModelName, "imagen") {
+					return gemini.GeminiImageHandler(c, info, resp)
+				}
 				usage, err = gemini.GeminiChatHandler(c, info, resp)
 			}
 		case RequestModeLlama:

+ 2 - 2
relay/channel/zhipu/relay-zhipu.go

@@ -220,12 +220,12 @@ func zhipuHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respon
 	var zhipuResponse ZhipuResponse
 	responseBody, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed)
+		return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
 	}
 	common.CloseResponseBodyGracefully(resp)
 	err = json.Unmarshal(responseBody, &zhipuResponse)
 	if err != nil {
-		return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
+		return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
 	}
 	if !zhipuResponse.Success {
 		return nil, types.WithOpenAIError(types.OpenAIError{

+ 9 - 9
relay/claude_handler.go

@@ -40,7 +40,7 @@ func ClaudeHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 	// get & validate textRequest 获取并验证文本请求
 	textRequest, err := getAndValidateClaudeRequest(c)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeInvalidRequest)
+		return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
 	}
 
 	if textRequest.Stream {
@@ -49,18 +49,18 @@ func ClaudeHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 
 	err = helper.ModelMappedHelper(c, relayInfo, textRequest)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeChannelModelMappedError)
+		return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
 	}
 
 	promptTokens, err := getClaudePromptTokens(textRequest, relayInfo)
 	// count messages token error 计算promptTokens错误
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeCountTokenFailed)
+		return types.NewError(err, types.ErrorCodeCountTokenFailed, types.ErrOptionWithSkipRetry())
 	}
 
 	priceData, err := helper.ModelPriceHelper(c, relayInfo, promptTokens, int(textRequest.MaxTokens))
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeModelPriceError)
+		return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry())
 	}
 
 	// pre-consume quota 预消耗配额
@@ -77,7 +77,7 @@ func ClaudeHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 
 	adaptor := GetAdaptor(relayInfo.ApiType)
 	if adaptor == nil {
-		return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType)
+		return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry())
 	}
 	adaptor.Init(relayInfo)
 
@@ -111,17 +111,17 @@ func ClaudeHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 	if model_setting.GetGlobalSettings().PassThroughRequestEnabled || relayInfo.ChannelSetting.PassThroughBodyEnabled {
 		body, err := common.GetRequestBody(c)
 		if err != nil {
-			return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest)
+			return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
 		}
 		requestBody = bytes.NewBuffer(body)
 	} else {
 		convertedRequest, err := adaptor.ConvertClaudeRequest(c, relayInfo, textRequest)
 		if err != nil {
-			return types.NewError(err, types.ErrorCodeConvertRequestFailed)
+			return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
 		}
 		jsonData, err := common.Marshal(convertedRequest)
 		if err != nil {
-			return types.NewError(err, types.ErrorCodeConvertRequestFailed)
+			return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
 		}
 
 		// apply param override
@@ -133,7 +133,7 @@ func ClaudeHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 			}
 			jsonData, err = common.Marshal(reqMap)
 			if err != nil {
-				return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid)
+				return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
 			}
 		}
 

+ 1 - 0
relay/common/relay_info.go

@@ -88,6 +88,7 @@ type RelayInfo struct {
 	BaseUrl              string
 	SupportStreamOptions bool
 	ShouldIncludeUsage   bool
+	DisablePing          bool // 是否禁止向下游发送自定义 Ping
 	IsModelMapped        bool
 	ClientWs             *websocket.Conn
 	TargetWs             *websocket.Conn

+ 3 - 3
relay/common_handler/rerank.go

@@ -16,7 +16,7 @@ import (
 func RerankHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
 	responseBody, err := io.ReadAll(resp.Body)
 	if err != nil {
-		return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed)
+		return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
 	}
 	common.CloseResponseBodyGracefully(resp)
 	if common.DebugEnabled {
@@ -27,7 +27,7 @@ func RerankHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
 		var xinRerankResponse xinference.XinRerankResponse
 		err = common.Unmarshal(responseBody, &xinRerankResponse)
 		if err != nil {
-			return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
+			return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
 		}
 		jinaRespResults := make([]dto.RerankResponseResult, len(xinRerankResponse.Results))
 		for i, result := range xinRerankResponse.Results {
@@ -62,7 +62,7 @@ func RerankHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
 	} else {
 		err = common.Unmarshal(responseBody, &jinaResp)
 		if err != nil {
-			return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
+			return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
 		}
 		jinaResp.Usage.PromptTokens = jinaResp.Usage.TotalTokens
 	}

+ 7 - 8
relay/embedding_handler.go

@@ -41,17 +41,17 @@ func EmbeddingHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 	err := common.UnmarshalBodyReusable(c, &embeddingRequest)
 	if err != nil {
 		common.LogError(c, fmt.Sprintf("getAndValidateTextRequest failed: %s", err.Error()))
-		return types.NewError(err, types.ErrorCodeInvalidRequest)
+		return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
 	}
 
 	err = validateEmbeddingRequest(c, relayInfo, *embeddingRequest)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeInvalidRequest)
+		return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
 	}
 
 	err = helper.ModelMappedHelper(c, relayInfo, embeddingRequest)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeChannelModelMappedError)
+		return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
 	}
 
 	promptToken := getEmbeddingPromptToken(*embeddingRequest)
@@ -59,7 +59,7 @@ func EmbeddingHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 
 	priceData, err := helper.ModelPriceHelper(c, relayInfo, promptToken, 0)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeModelPriceError)
+		return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry())
 	}
 	// pre-consume quota 预消耗配额
 	preConsumedQuota, userQuota, newAPIError := preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo)
@@ -74,18 +74,17 @@ func EmbeddingHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 
 	adaptor := GetAdaptor(relayInfo.ApiType)
 	if adaptor == nil {
-		return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType)
+		return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry())
 	}
 	adaptor.Init(relayInfo)
 
 	convertedRequest, err := adaptor.ConvertEmbeddingRequest(c, relayInfo, *embeddingRequest)
-
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeConvertRequestFailed)
+		return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
 	}
 	jsonData, err := json.Marshal(convertedRequest)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeConvertRequestFailed)
+		return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
 	}
 	requestBody := bytes.NewBuffer(jsonData)
 	statusCodeMappingStr := c.GetString("status_code_mapping")

+ 11 - 12
relay/gemini_handler.go

@@ -2,7 +2,6 @@ package relay
 
 import (
 	"bytes"
-	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
@@ -81,7 +80,7 @@ func getGeminiInputTokens(req *gemini.GeminiChatRequest, info *relaycommon.Relay
 
 func isNoThinkingRequest(req *gemini.GeminiChatRequest) bool {
 	if req.GenerationConfig.ThinkingConfig != nil && req.GenerationConfig.ThinkingConfig.ThinkingBudget != nil {
-		return *req.GenerationConfig.ThinkingConfig.ThinkingBudget <= 0
+		return *req.GenerationConfig.ThinkingConfig.ThinkingBudget == 0
 	}
 	return false
 }
@@ -110,7 +109,7 @@ func GeminiHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 	req, err := getAndValidateGeminiRequest(c)
 	if err != nil {
 		common.LogError(c, fmt.Sprintf("getAndValidateGeminiRequest error: %s", err.Error()))
-		return types.NewError(err, types.ErrorCodeInvalidRequest)
+		return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
 	}
 
 	relayInfo := relaycommon.GenRelayInfoGemini(c)
@@ -122,14 +121,14 @@ func GeminiHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 		sensitiveWords, err := checkGeminiInputSensitive(req)
 		if err != nil {
 			common.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(sensitiveWords, ", ")))
-			return types.NewError(err, types.ErrorCodeSensitiveWordsDetected)
+			return types.NewError(err, types.ErrorCodeSensitiveWordsDetected, types.ErrOptionWithSkipRetry())
 		}
 	}
 
 	// model mapped 模型映射
 	err = helper.ModelMappedHelper(c, relayInfo, req)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeChannelModelMappedError)
+		return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
 	}
 
 	if value, exists := c.Get("prompt_tokens"); exists {
@@ -160,7 +159,7 @@ func GeminiHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 
 	priceData, err := helper.ModelPriceHelper(c, relayInfo, relayInfo.PromptTokens, int(req.GenerationConfig.MaxOutputTokens))
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeModelPriceError)
+		return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry())
 	}
 
 	// pre consume quota
@@ -176,7 +175,7 @@ func GeminiHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 
 	adaptor := GetAdaptor(relayInfo.ApiType)
 	if adaptor == nil {
-		return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType)
+		return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry())
 	}
 
 	adaptor.Init(relayInfo)
@@ -199,13 +198,13 @@ func GeminiHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 	if model_setting.GetGlobalSettings().PassThroughRequestEnabled || relayInfo.ChannelSetting.PassThroughBodyEnabled {
 		body, err := common.GetRequestBody(c)
 		if err != nil {
-			return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest)
+			return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
 		}
 		requestBody = bytes.NewReader(body)
 	} else {
-		jsonData, err := json.Marshal(req)
+		jsonData, err := common.Marshal(req)
 		if err != nil {
-			return types.NewError(err, types.ErrorCodeConvertRequestFailed)
+			return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
 		}
 
 		// apply param override
@@ -217,7 +216,7 @@ func GeminiHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 			}
 			jsonData, err = common.Marshal(reqMap)
 			if err != nil {
-				return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid)
+				return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
 			}
 		}
 
@@ -230,7 +229,7 @@ func GeminiHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 	resp, err := adaptor.DoRequest(c, relayInfo, requestBody)
 	if err != nil {
 		common.LogError(c, "Do gemini request failed: "+err.Error())
-		return types.NewError(err, types.ErrorCodeDoRequestFailed)
+		return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError)
 	}
 
 	statusCodeMappingStr := c.GetString("status_code_mapping")

+ 1 - 1
relay/helper/stream_scanner.go

@@ -54,7 +54,7 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
 	)
 
 	generalSettings := operation_setting.GetGeneralSetting()
-	pingEnabled := generalSettings.PingIntervalEnabled
+	pingEnabled := generalSettings.PingIntervalEnabled && !info.DisablePing
 	pingInterval := time.Duration(generalSettings.PingIntervalSeconds) * time.Second
 	if pingInterval <= 0 {
 		pingInterval = DefaultPingInterval

+ 10 - 10
relay/image_handler.go

@@ -115,17 +115,17 @@ func ImageHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 	imageRequest, err := getAndValidImageRequest(c, relayInfo)
 	if err != nil {
 		common.LogError(c, fmt.Sprintf("getAndValidImageRequest failed: %s", err.Error()))
-		return types.NewError(err, types.ErrorCodeInvalidRequest)
+		return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
 	}
 
 	err = helper.ModelMappedHelper(c, relayInfo, imageRequest)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeChannelModelMappedError)
+		return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
 	}
 
 	priceData, err := helper.ModelPriceHelper(c, relayInfo, len(imageRequest.Prompt), 0)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeModelPriceError)
+		return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry())
 	}
 	var preConsumedQuota int
 	var quota int
@@ -173,16 +173,16 @@ func ImageHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 		quota = int(priceData.ModelPrice * priceData.GroupRatioInfo.GroupRatio * common.QuotaPerUnit)
 		userQuota, err = model.GetUserQuota(relayInfo.UserId, false)
 		if err != nil {
-			return types.NewError(err, types.ErrorCodeQueryDataError)
+			return types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
 		}
 		if userQuota-quota < 0 {
-			return types.NewError(fmt.Errorf("image pre-consumed quota failed, user quota: %s, need quota: %s", common.FormatQuota(userQuota), common.FormatQuota(quota)), types.ErrorCodeInsufficientUserQuota)
+			return types.NewError(fmt.Errorf("image pre-consumed quota failed, user quota: %s, need quota: %s", common.FormatQuota(userQuota), common.FormatQuota(quota)), types.ErrorCodeInsufficientUserQuota, types.ErrOptionWithSkipRetry())
 		}
 	}
 
 	adaptor := GetAdaptor(relayInfo.ApiType)
 	if adaptor == nil {
-		return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType)
+		return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry())
 	}
 	adaptor.Init(relayInfo)
 
@@ -191,20 +191,20 @@ func ImageHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 	if model_setting.GetGlobalSettings().PassThroughRequestEnabled || relayInfo.ChannelSetting.PassThroughBodyEnabled {
 		body, err := common.GetRequestBody(c)
 		if err != nil {
-			return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest)
+			return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
 		}
 		requestBody = bytes.NewBuffer(body)
 	} else {
 		convertedRequest, err := adaptor.ConvertImageRequest(c, relayInfo, *imageRequest)
 		if err != nil {
-			return types.NewError(err, types.ErrorCodeConvertRequestFailed)
+			return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
 		}
 		if relayInfo.RelayMode == relayconstant.RelayModeImagesEdits {
 			requestBody = convertedRequest.(io.Reader)
 		} else {
 			jsonData, err := json.Marshal(convertedRequest)
 			if err != nil {
-				return types.NewError(err, types.ErrorCodeConvertRequestFailed)
+				return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
 			}
 
 			// apply param override
@@ -216,7 +216,7 @@ func ImageHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 				}
 				jsonData, err = common.Marshal(reqMap)
 				if err != nil {
-					return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid)
+					return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
 				}
 			}
 

+ 18 - 17
relay/relay-text.go

@@ -90,9 +90,8 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 
 	// get & validate textRequest 获取并验证文本请求
 	textRequest, err := getAndValidateTextRequest(c, relayInfo)
-
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeInvalidRequest)
+		return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
 	}
 
 	if textRequest.WebSearchOptions != nil {
@@ -103,13 +102,13 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 		words, err := checkRequestSensitive(textRequest, relayInfo)
 		if err != nil {
 			common.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(words, ", ")))
-			return types.NewError(err, types.ErrorCodeSensitiveWordsDetected)
+			return types.NewError(err, types.ErrorCodeSensitiveWordsDetected, types.ErrOptionWithSkipRetry())
 		}
 	}
 
 	err = helper.ModelMappedHelper(c, relayInfo, textRequest)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeChannelModelMappedError)
+		return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
 	}
 
 	// 获取 promptTokens,如果上下文中已经存在,则直接使用
@@ -121,14 +120,14 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 		promptTokens, err = getPromptTokens(textRequest, relayInfo)
 		// count messages token error 计算promptTokens错误
 		if err != nil {
-			return types.NewError(err, types.ErrorCodeCountTokenFailed)
+			return types.NewError(err, types.ErrorCodeCountTokenFailed, types.ErrOptionWithSkipRetry())
 		}
 		c.Set("prompt_tokens", promptTokens)
 	}
 
 	priceData, err := helper.ModelPriceHelper(c, relayInfo, promptTokens, int(math.Max(float64(textRequest.MaxTokens), float64(textRequest.MaxCompletionTokens))))
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeModelPriceError)
+		return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry())
 	}
 
 	// pre-consume quota 预消耗配额
@@ -165,7 +164,7 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 
 	adaptor := GetAdaptor(relayInfo.ApiType)
 	if adaptor == nil {
-		return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType)
+		return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry())
 	}
 	adaptor.Init(relayInfo)
 	var requestBody io.Reader
@@ -173,7 +172,7 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 	if model_setting.GetGlobalSettings().PassThroughRequestEnabled || relayInfo.ChannelSetting.PassThroughBodyEnabled {
 		body, err := common.GetRequestBody(c)
 		if err != nil {
-			return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest)
+			return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
 		}
 		if common.DebugEnabled {
 			println("requestBody: ", string(body))
@@ -182,7 +181,7 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 	} else {
 		convertedRequest, err := adaptor.ConvertOpenAIRequest(c, relayInfo, textRequest)
 		if err != nil {
-			return types.NewError(err, types.ErrorCodeConvertRequestFailed)
+			return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
 		}
 
 		if relayInfo.ChannelSetting.SystemPrompt != "" {
@@ -207,7 +206,7 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 
 		jsonData, err := common.Marshal(convertedRequest)
 		if err != nil {
-			return types.NewError(err, types.ErrorCodeConvertRequestFailed)
+			return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
 		}
 
 		// apply param override
@@ -219,7 +218,7 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 			}
 			jsonData, err = common.Marshal(reqMap)
 			if err != nil {
-				return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid)
+				return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
 			}
 		}
 
@@ -231,7 +230,6 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 
 	var httpResp *http.Response
 	resp, err := adaptor.DoRequest(c, relayInfo, requestBody)
-
 	if err != nil {
 		return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError)
 	}
@@ -304,13 +302,13 @@ func checkRequestSensitive(textRequest *dto.GeneralOpenAIRequest, info *relaycom
 func preConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) (int, int, *types.NewAPIError) {
 	userQuota, err := model.GetUserQuota(relayInfo.UserId, false)
 	if err != nil {
-		return 0, 0, types.NewError(err, types.ErrorCodeQueryDataError)
+		return 0, 0, types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry())
 	}
 	if userQuota <= 0 {
-		return 0, 0, types.NewErrorWithStatusCode(errors.New("user quota is not enough"), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden)
+		return 0, 0, types.NewErrorWithStatusCode(errors.New("user quota is not enough"), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry())
 	}
 	if userQuota-preConsumedQuota < 0 {
-		return 0, 0, types.NewErrorWithStatusCode(fmt.Errorf("pre-consume quota failed, user quota: %s, need quota: %s", common.FormatQuota(userQuota), common.FormatQuota(preConsumedQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden)
+		return 0, 0, types.NewErrorWithStatusCode(fmt.Errorf("pre-consume quota failed, user quota: %s, need quota: %s", common.FormatQuota(userQuota), common.FormatQuota(preConsumedQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry())
 	}
 	relayInfo.UserQuota = userQuota
 	if userQuota > 100*preConsumedQuota {
@@ -334,11 +332,11 @@ func preConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommo
 	if preConsumedQuota > 0 {
 		err := service.PreConsumeTokenQuota(relayInfo, preConsumedQuota)
 		if err != nil {
-			return 0, 0, types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden)
+			return 0, 0, types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry())
 		}
 		err = model.DecreaseUserQuota(relayInfo.UserId, preConsumedQuota)
 		if err != nil {
-			return 0, 0, types.NewError(err, types.ErrorCodeUpdateDataError)
+			return 0, 0, types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry())
 		}
 	}
 	return preConsumedQuota, userQuota, nil
@@ -517,6 +515,9 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
 		common.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, "+
 			"tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, modelName, preConsumedQuota))
 	} else {
+		if !ratio.IsZero() && quota == 0 {
+			quota = 1
+		}
 		model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota)
 		model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota)
 	}

+ 10 - 10
relay/rerank_handler.go

@@ -31,21 +31,21 @@ func RerankHelper(c *gin.Context, relayMode int) (newAPIError *types.NewAPIError
 	err := common.UnmarshalBodyReusable(c, &rerankRequest)
 	if err != nil {
 		common.LogError(c, fmt.Sprintf("getAndValidateTextRequest failed: %s", err.Error()))
-		return types.NewError(err, types.ErrorCodeInvalidRequest)
+		return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
 	}
 
 	relayInfo := relaycommon.GenRelayInfoRerank(c, rerankRequest)
 
 	if rerankRequest.Query == "" {
-		return types.NewError(fmt.Errorf("query is empty"), types.ErrorCodeInvalidRequest)
+		return types.NewError(fmt.Errorf("query is empty"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
 	}
 	if len(rerankRequest.Documents) == 0 {
-		return types.NewError(fmt.Errorf("documents is empty"), types.ErrorCodeInvalidRequest)
+		return types.NewError(fmt.Errorf("documents is empty"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
 	}
 
 	err = helper.ModelMappedHelper(c, relayInfo, rerankRequest)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeChannelModelMappedError)
+		return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
 	}
 
 	promptToken := getRerankPromptToken(*rerankRequest)
@@ -53,7 +53,7 @@ func RerankHelper(c *gin.Context, relayMode int) (newAPIError *types.NewAPIError
 
 	priceData, err := helper.ModelPriceHelper(c, relayInfo, promptToken, 0)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeModelPriceError)
+		return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry())
 	}
 	// pre-consume quota 预消耗配额
 	preConsumedQuota, userQuota, newAPIError := preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo)
@@ -68,7 +68,7 @@ func RerankHelper(c *gin.Context, relayMode int) (newAPIError *types.NewAPIError
 
 	adaptor := GetAdaptor(relayInfo.ApiType)
 	if adaptor == nil {
-		return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType)
+		return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry())
 	}
 	adaptor.Init(relayInfo)
 
@@ -76,17 +76,17 @@ func RerankHelper(c *gin.Context, relayMode int) (newAPIError *types.NewAPIError
 	if model_setting.GetGlobalSettings().PassThroughRequestEnabled || relayInfo.ChannelSetting.PassThroughBodyEnabled {
 		body, err := common.GetRequestBody(c)
 		if err != nil {
-			return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest)
+			return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
 		}
 		requestBody = bytes.NewBuffer(body)
 	} else {
 		convertedRequest, err := adaptor.ConvertRerankRequest(c, relayInfo.RelayMode, *rerankRequest)
 		if err != nil {
-			return types.NewError(err, types.ErrorCodeConvertRequestFailed)
+			return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
 		}
 		jsonData, err := common.Marshal(convertedRequest)
 		if err != nil {
-			return types.NewError(err, types.ErrorCodeConvertRequestFailed)
+			return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
 		}
 
 		// apply param override
@@ -98,7 +98,7 @@ func RerankHelper(c *gin.Context, relayMode int) (newAPIError *types.NewAPIError
 			}
 			jsonData, err = common.Marshal(reqMap)
 			if err != nil {
-				return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid)
+				return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
 			}
 		}
 

+ 10 - 10
relay/responses_handler.go

@@ -51,7 +51,7 @@ func ResponsesHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 	req, err := getAndValidateResponsesRequest(c)
 	if err != nil {
 		common.LogError(c, fmt.Sprintf("getAndValidateResponsesRequest error: %s", err.Error()))
-		return types.NewError(err, types.ErrorCodeInvalidRequest)
+		return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
 	}
 
 	relayInfo := relaycommon.GenRelayInfoResponses(c, req)
@@ -60,13 +60,13 @@ func ResponsesHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 		sensitiveWords, err := checkInputSensitive(req, relayInfo)
 		if err != nil {
 			common.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(sensitiveWords, ", ")))
-			return types.NewError(err, types.ErrorCodeSensitiveWordsDetected)
+			return types.NewError(err, types.ErrorCodeSensitiveWordsDetected, types.ErrOptionWithSkipRetry())
 		}
 	}
 
 	err = helper.ModelMappedHelper(c, relayInfo, req)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeChannelModelMappedError)
+		return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
 	}
 
 	if value, exists := c.Get("prompt_tokens"); exists {
@@ -79,7 +79,7 @@ func ResponsesHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 
 	priceData, err := helper.ModelPriceHelper(c, relayInfo, relayInfo.PromptTokens, int(req.MaxOutputTokens))
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeModelPriceError)
+		return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry())
 	}
 	// pre consume quota
 	preConsumedQuota, userQuota, newAPIError := preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo)
@@ -93,38 +93,38 @@ func ResponsesHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 	}()
 	adaptor := GetAdaptor(relayInfo.ApiType)
 	if adaptor == nil {
-		return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType)
+		return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry())
 	}
 	adaptor.Init(relayInfo)
 	var requestBody io.Reader
 	if model_setting.GetGlobalSettings().PassThroughRequestEnabled {
 		body, err := common.GetRequestBody(c)
 		if err != nil {
-			return types.NewError(err, types.ErrorCodeReadRequestBodyFailed)
+			return types.NewError(err, types.ErrorCodeReadRequestBodyFailed, types.ErrOptionWithSkipRetry())
 		}
 		requestBody = bytes.NewBuffer(body)
 	} else {
 		convertedRequest, err := adaptor.ConvertOpenAIResponsesRequest(c, relayInfo, *req)
 		if err != nil {
-			return types.NewError(err, types.ErrorCodeConvertRequestFailed)
+			return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
 		}
 		jsonData, err := json.Marshal(convertedRequest)
 		if err != nil {
-			return types.NewError(err, types.ErrorCodeConvertRequestFailed)
+			return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
 		}
 		// apply param override
 		if len(relayInfo.ParamOverride) > 0 {
 			reqMap := make(map[string]interface{})
 			err = json.Unmarshal(jsonData, &reqMap)
 			if err != nil {
-				return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid)
+				return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
 			}
 			for key, value := range relayInfo.ParamOverride {
 				reqMap[key] = value
 			}
 			jsonData, err = json.Marshal(reqMap)
 			if err != nil {
-				return types.NewError(err, types.ErrorCodeConvertRequestFailed)
+				return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
 			}
 		}
 

+ 3 - 3
relay/websocket.go

@@ -24,12 +24,12 @@ func WssHelper(c *gin.Context, ws *websocket.Conn) (newAPIError *types.NewAPIErr
 
 	err := helper.ModelMappedHelper(c, relayInfo, nil)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeChannelModelMappedError)
+		return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
 	}
 
 	priceData, err := helper.ModelPriceHelper(c, relayInfo, 0, 0)
 	if err != nil {
-		return types.NewError(err, types.ErrorCodeModelPriceError)
+		return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry())
 	}
 
 	// pre-consume quota 预消耗配额
@@ -46,7 +46,7 @@ func WssHelper(c *gin.Context, ws *websocket.Conn) (newAPIError *types.NewAPIErr
 
 	adaptor := GetAdaptor(relayInfo.ApiType)
 	if adaptor == nil {
-		return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType)
+		return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry())
 	}
 	adaptor.Init(relayInfo)
 	//var requestBody io.Reader

+ 1 - 1
service/channel.go

@@ -45,7 +45,7 @@ func ShouldDisableChannel(channelType int, err *types.NewAPIError) bool {
 	if types.IsChannelError(err) {
 		return true
 	}
-	if types.IsLocalError(err) {
+	if types.IsSkipRetryError(err) {
 		return false
 	}
 	if err.StatusCode == http.StatusUnauthorized {

+ 1 - 2
service/error.go

@@ -1,7 +1,6 @@
 package service
 
 import (
-	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
@@ -112,7 +111,7 @@ func ResetStatusCode(newApiErr *types.NewAPIError, statusCodeMappingStr string)
 		return
 	}
 	statusCodeMapping := make(map[string]string)
-	err := json.Unmarshal([]byte(statusCodeMappingStr), &statusCodeMapping)
+	err := common.Unmarshal([]byte(statusCodeMappingStr), &statusCodeMapping)
 	if err != nil {
 		return
 	}

+ 4 - 0
setting/rate_limit.go

@@ -3,6 +3,7 @@ package setting
 import (
 	"encoding/json"
 	"fmt"
+	"math"
 	"one-api/common"
 	"sync"
 )
@@ -58,6 +59,9 @@ func CheckModelRequestRateLimitGroup(jsonStr string) error {
 		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 limits[0] > math.MaxInt32 || limits[1] > math.MaxInt32 {
+			return fmt.Errorf("group %s [%d, %d] has max rate limits value 2147483647", group, limits[0], limits[1])
+		}
 	}
 
 	return nil

+ 63 - 20
types/error.go

@@ -4,6 +4,7 @@ import (
 	"errors"
 	"fmt"
 	"net/http"
+	"one-api/common"
 	"strings"
 )
 
@@ -77,6 +78,7 @@ const (
 type NewAPIError struct {
 	Err        error
 	RelayError any
+	skipRetry  bool
 	errorType  ErrorType
 	errorCode  ErrorCode
 	StatusCode int
@@ -107,19 +109,30 @@ func (e *NewAPIError) Error() string {
 	return e.Err.Error()
 }
 
+func (e *NewAPIError) MaskSensitiveError() string {
+	if e == nil {
+		return ""
+	}
+	if e.Err == nil {
+		return string(e.errorCode)
+	}
+	return common.MaskSensitiveInfo(e.Err.Error())
+}
+
 func (e *NewAPIError) SetMessage(message string) {
 	e.Err = errors.New(message)
 }
 
 func (e *NewAPIError) ToOpenAIError() OpenAIError {
+	var result OpenAIError
 	switch e.errorType {
 	case ErrorTypeOpenAIError:
 		if openAIError, ok := e.RelayError.(OpenAIError); ok {
-			return openAIError
+			result = openAIError
 		}
 	case ErrorTypeClaudeError:
 		if claudeError, ok := e.RelayError.(ClaudeError); ok {
-			return OpenAIError{
+			result = OpenAIError{
 				Message: e.Error(),
 				Type:    claudeError.Type,
 				Param:   "",
@@ -127,59 +140,70 @@ func (e *NewAPIError) ToOpenAIError() OpenAIError {
 			}
 		}
 	}
-	return OpenAIError{
+	result = OpenAIError{
 		Message: e.Error(),
 		Type:    string(e.errorType),
 		Param:   "",
 		Code:    e.errorCode,
 	}
+	result.Message = common.MaskSensitiveInfo(result.Message)
+	return result
 }
 
 func (e *NewAPIError) ToClaudeError() ClaudeError {
+	var result ClaudeError
 	switch e.errorType {
 	case ErrorTypeOpenAIError:
 		openAIError := e.RelayError.(OpenAIError)
-		return ClaudeError{
+		result = ClaudeError{
 			Message: e.Error(),
 			Type:    fmt.Sprintf("%v", openAIError.Code),
 		}
 	case ErrorTypeClaudeError:
-		return e.RelayError.(ClaudeError)
+		result = e.RelayError.(ClaudeError)
 	default:
-		return ClaudeError{
+		result = ClaudeError{
 			Message: e.Error(),
 			Type:    string(e.errorType),
 		}
 	}
+	result.Message = common.MaskSensitiveInfo(result.Message)
+	return result
 }
 
-func NewError(err error, errorCode ErrorCode) *NewAPIError {
-	return &NewAPIError{
+type NewAPIErrorOptions func(*NewAPIError)
+
+func NewError(err error, errorCode ErrorCode, ops ...NewAPIErrorOptions) *NewAPIError {
+	e := &NewAPIError{
 		Err:        err,
 		RelayError: nil,
 		errorType:  ErrorTypeNewAPIError,
 		StatusCode: http.StatusInternalServerError,
 		errorCode:  errorCode,
 	}
+	for _, op := range ops {
+		op(e)
+	}
+	return e
 }
 
-func NewOpenAIError(err error, errorCode ErrorCode, statusCode int) *NewAPIError {
+func NewOpenAIError(err error, errorCode ErrorCode, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError {
 	openaiError := OpenAIError{
 		Message: err.Error(),
 		Type:    string(errorCode),
 	}
-	return WithOpenAIError(openaiError, statusCode)
+	return WithOpenAIError(openaiError, statusCode, ops...)
 }
 
-func InitOpenAIError(errorCode ErrorCode, statusCode int) *NewAPIError {
+func InitOpenAIError(errorCode ErrorCode, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError {
 	openaiError := OpenAIError{
 		Type: string(errorCode),
 	}
-	return WithOpenAIError(openaiError, statusCode)
+	return WithOpenAIError(openaiError, statusCode, ops...)
 }
 
-func NewErrorWithStatusCode(err error, errorCode ErrorCode, statusCode int) *NewAPIError {
-	return &NewAPIError{
+func NewErrorWithStatusCode(err error, errorCode ErrorCode, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError {
+	e := &NewAPIError{
 		Err: err,
 		RelayError: OpenAIError{
 			Message: err.Error(),
@@ -189,9 +213,14 @@ func NewErrorWithStatusCode(err error, errorCode ErrorCode, statusCode int) *New
 		StatusCode: statusCode,
 		errorCode:  errorCode,
 	}
+	for _, op := range ops {
+		op(e)
+	}
+
+	return e
 }
 
-func WithOpenAIError(openAIError OpenAIError, statusCode int) *NewAPIError {
+func WithOpenAIError(openAIError OpenAIError, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError {
 	code, ok := openAIError.Code.(string)
 	if !ok {
 		code = fmt.Sprintf("%v", openAIError.Code)
@@ -199,26 +228,34 @@ func WithOpenAIError(openAIError OpenAIError, statusCode int) *NewAPIError {
 	if openAIError.Type == "" {
 		openAIError.Type = "upstream_error"
 	}
-	return &NewAPIError{
+	e := &NewAPIError{
 		RelayError: openAIError,
 		errorType:  ErrorTypeOpenAIError,
 		StatusCode: statusCode,
 		Err:        errors.New(openAIError.Message),
 		errorCode:  ErrorCode(code),
 	}
+	for _, op := range ops {
+		op(e)
+	}
+	return e
 }
 
-func WithClaudeError(claudeError ClaudeError, statusCode int) *NewAPIError {
+func WithClaudeError(claudeError ClaudeError, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError {
 	if claudeError.Type == "" {
 		claudeError.Type = "upstream_error"
 	}
-	return &NewAPIError{
+	e := &NewAPIError{
 		RelayError: claudeError,
 		errorType:  ErrorTypeClaudeError,
 		StatusCode: statusCode,
 		Err:        errors.New(claudeError.Message),
 		errorCode:  ErrorCode(claudeError.Type),
 	}
+	for _, op := range ops {
+		op(e)
+	}
+	return e
 }
 
 func IsChannelError(err *NewAPIError) bool {
@@ -228,10 +265,16 @@ func IsChannelError(err *NewAPIError) bool {
 	return strings.HasPrefix(string(err.errorCode), "channel:")
 }
 
-func IsLocalError(err *NewAPIError) bool {
+func IsSkipRetryError(err *NewAPIError) bool {
 	if err == nil {
 		return false
 	}
 
-	return err.errorType == ErrorTypeNewAPIError
+	return err.skipRetry
+}
+
+func ErrOptionWithSkipRetry() NewAPIErrorOptions {
+	return func(e *NewAPIError) {
+		e.skipRetry = true
+	}
 }

+ 609 - 0
web/src/components/common/JSONEditor.js

@@ -0,0 +1,609 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+  Space,
+  Button,
+  Form,
+  Card,
+  Typography,
+  Banner,
+  Row,
+  Col,
+  InputNumber,
+  Switch,
+  Select,
+  Input,
+} from '@douyinfe/semi-ui';
+import {
+  IconCode,
+  IconEdit,
+  IconPlus,
+  IconDelete,
+  IconSetting,
+} from '@douyinfe/semi-icons';
+
+const { Text } = Typography;
+
+const JSONEditor = ({
+  value = '',
+  onChange,
+  field,
+  label,
+  placeholder,
+  extraText,
+  showClear = true,
+  template,
+  templateLabel,
+  editorType = 'keyValue', // keyValue, object, region
+  autosize = true,
+  rules = [],
+  formApi = null,
+  ...props
+}) => {
+  const { t } = useTranslation();
+  
+  // 初始化JSON数据
+  const [jsonData, setJsonData] = useState(() => {
+    // 初始化时解析JSON数据
+    if (value && value.trim()) {
+      try {
+        const parsed = JSON.parse(value);
+        return parsed;
+      } catch (error) {
+        return {};
+      }
+    }
+    return {};
+  });
+  
+  // 根据键数量决定默认编辑模式
+  const [editMode, setEditMode] = useState(() => {
+    // 如果初始JSON数据的键数量大于10个,则默认使用手动模式
+    if (value && value.trim()) {
+      try {
+        const parsed = JSON.parse(value);
+        const keyCount = Object.keys(parsed).length;
+        return keyCount > 10 ? 'manual' : 'visual';
+      } catch (error) {
+        return 'visual';
+      }
+    }
+    return 'visual';
+  });
+  const [jsonError, setJsonError] = useState('');
+
+  // 数据同步 - 当value变化时总是更新jsonData(如果JSON有效)
+  useEffect(() => {
+    try {
+      const parsed = value && value.trim() ? JSON.parse(value) : {};
+      setJsonData(parsed);
+      setJsonError('');
+    } catch (error) {
+      console.log('JSON解析失败:', error.message);
+      setJsonError(error.message);
+      // JSON格式错误时不更新jsonData
+    }
+  }, [value]);
+
+
+  // 处理可视化编辑的数据变化
+  const handleVisualChange = useCallback((newData) => {
+    setJsonData(newData);
+    setJsonError('');
+    const jsonString = Object.keys(newData).length === 0 ? '' : JSON.stringify(newData, null, 2);
+    
+    // 通过formApi设置值(如果提供的话)
+    if (formApi && field) {
+      formApi.setValue(field, jsonString);
+    }
+    
+    onChange?.(jsonString);
+  }, [onChange, formApi, field]);
+
+  // 处理手动编辑的数据变化
+  const handleManualChange = useCallback((newValue) => {
+    onChange?.(newValue);
+    // 验证JSON格式
+    if (newValue && newValue.trim()) {
+      try {
+        const parsed = JSON.parse(newValue);
+        setJsonError('');
+        // 预先准备可视化数据,但不立即应用
+        // 这样切换到可视化模式时数据已经准备好了
+      } catch (error) {
+        setJsonError(error.message);
+      }
+    } else {
+      setJsonError('');
+    }
+  }, [onChange]);
+
+  // 切换编辑模式
+  const toggleEditMode = useCallback(() => {
+    if (editMode === 'visual') {
+      // 从可视化模式切换到手动模式
+      setEditMode('manual');
+    } else {
+      // 从手动模式切换到可视化模式,需要验证JSON
+      try {
+        const parsed = value && value.trim() ? JSON.parse(value) : {};
+        setJsonData(parsed);
+        setJsonError('');
+        setEditMode('visual');
+      } catch (error) {
+        setJsonError(error.message);
+        // JSON格式错误时不切换模式
+        return;
+      }
+    }
+  }, [editMode, value]);
+
+  // 添加键值对
+  const addKeyValue = useCallback(() => {
+    const newData = { ...jsonData };
+    const keys = Object.keys(newData);
+    let newKey = 'key';
+    let counter = 1;
+    while (newData.hasOwnProperty(newKey)) {
+      newKey = `key${counter}`;
+      counter++;
+    }
+    newData[newKey] = '';
+    handleVisualChange(newData);
+  }, [jsonData, handleVisualChange]);
+
+  // 删除键值对
+  const removeKeyValue = useCallback((keyToRemove) => {
+    const newData = { ...jsonData };
+    delete newData[keyToRemove];
+    handleVisualChange(newData);
+  }, [jsonData, handleVisualChange]);
+
+  // 更新键名
+  const updateKey = useCallback((oldKey, newKey) => {
+    if (oldKey === newKey) return;
+    const newData = { ...jsonData };
+    const value = newData[oldKey];
+    delete newData[oldKey];
+    newData[newKey] = value;
+    handleVisualChange(newData);
+  }, [jsonData, handleVisualChange]);
+
+  // 更新值
+  const updateValue = useCallback((key, newValue) => {
+    const newData = { ...jsonData };
+    newData[key] = newValue;
+    handleVisualChange(newData);
+  }, [jsonData, handleVisualChange]);
+
+  // 填入模板
+  const fillTemplate = useCallback(() => {
+    if (template) {
+      const templateString = JSON.stringify(template, null, 2);
+      
+      // 通过formApi设置值(如果提供的话)
+      if (formApi && field) {
+        formApi.setValue(field, templateString);
+      }
+      
+      // 无论哪种模式都要更新值
+      onChange?.(templateString);
+      
+      // 如果是可视化模式,同时更新jsonData
+      if (editMode === 'visual') {
+        setJsonData(template);
+      }
+      
+      // 清除错误状态
+      setJsonError('');
+    }
+  }, [template, onChange, editMode, formApi, field]);
+
+  // 渲染键值对编辑器
+  const renderKeyValueEditor = () => {
+    const entries = Object.entries(jsonData);
+    
+    return (
+      <div className="space-y-1">
+        {entries.length === 0 && (
+          <div className="text-center py-6 px-4">
+            <div className="text-gray-400 mb-2">
+              <IconCode size={32} />
+            </div>
+            <Text type="tertiary" className="text-gray-500 text-sm">
+              {t('暂无数据,点击下方按钮添加键值对')}
+            </Text>
+          </div>
+        )}
+        
+        {entries.map(([key, value], index) => (
+          <Card key={index} className="!p-3 !border-gray-200 !rounded-md hover:shadow-sm transition-shadow duration-200">
+            <Row gutter={12} align="middle">
+              <Col span={10}>
+                <div className="space-y-1">
+                  <Text type="tertiary" size="small">{t('键名')}</Text>
+                  <Input
+                    placeholder={t('键名')}
+                    value={key}
+                    onChange={(newKey) => updateKey(key, newKey)}
+                    size="small"
+                  />
+                </div>
+              </Col>
+              <Col span={11}>
+                <div className="space-y-1">
+                  <Text type="tertiary" size="small">{t('值')}</Text>
+                  <Input
+                    placeholder={t('值')}
+                    value={value}
+                    onChange={(newValue) => updateValue(key, newValue)}
+                    size="small"
+                  />
+                </div>
+              </Col>
+              <Col span={3}>
+                <div className="flex justify-center pt-4">
+                  <Button
+                    icon={<IconDelete />}
+                    type="danger"
+                    theme="borderless"
+                    size="small"
+                    onClick={() => removeKeyValue(key)}
+                    className="hover:bg-red-50"
+                  />
+                </div>
+              </Col>
+            </Row>
+          </Card>
+        ))}
+        
+        <div className="flex justify-center pt-1">
+          <Button
+            icon={<IconPlus />}
+            onClick={addKeyValue}
+            size="small"
+            theme="solid"
+            type="primary"
+            className="shadow-sm hover:shadow-md transition-shadow px-4"
+          >
+            {t('添加键值对')}
+          </Button>
+        </div>
+      </div>
+    );
+  };
+
+  // 渲染对象编辑器(用于复杂JSON)
+  const renderObjectEditor = () => {
+    const entries = Object.entries(jsonData);
+    
+    return (
+      <div className="space-y-1">
+        {entries.length === 0 && (
+          <div className="text-center py-6 px-4">
+            <div className="text-gray-400 mb-2">
+              <IconSetting size={32} />
+            </div>
+            <Text type="tertiary" className="text-gray-500 text-sm">
+              {t('暂无参数,点击下方按钮添加请求参数')}
+            </Text>
+          </div>
+        )}
+        
+        {entries.map(([key, value], index) => (
+          <Card key={index} className="!p-3 !border-gray-200 !rounded-md hover:shadow-sm transition-shadow duration-200">
+            <Row gutter={12} align="middle">
+              <Col span={8}>
+                <div className="space-y-1">
+                  <Text type="tertiary" size="small">{t('参数名')}</Text>
+                  <Input
+                    placeholder={t('参数名')}
+                    value={key}
+                    onChange={(newKey) => updateKey(key, newKey)}
+                    size="small"
+                  />
+                </div>
+              </Col>
+              <Col span={13}>
+                <div className="space-y-1">
+                  <Text type="tertiary" size="small">{t('参数值')} ({typeof value})</Text>
+                  {renderValueInput(key, value)}
+                </div>
+              </Col>
+              <Col span={3}>
+                <div className="flex justify-center pt-4">
+                  <Button
+                    icon={<IconDelete />}
+                    type="danger"
+                    theme="borderless"
+                    size="small"
+                    onClick={() => removeKeyValue(key)}
+                    className="hover:bg-red-50"
+                  />
+                </div>
+              </Col>
+            </Row>
+          </Card>
+        ))}
+        
+        <div className="flex justify-center pt-1">
+          <Button
+            icon={<IconPlus />}
+            onClick={addKeyValue}
+            size="small"
+            theme="solid"
+            type="primary"
+            className="shadow-sm hover:shadow-md transition-shadow px-4"
+          >
+            {t('添加参数')}
+          </Button>
+        </div>
+      </div>
+    );
+  };
+
+  // 渲染参数值输入控件
+  const renderValueInput = (key, value) => {
+    const valueType = typeof value;
+    
+    if (valueType === 'boolean') {
+      return (
+        <div className="flex items-center">
+          <Switch
+            checked={value}
+            onChange={(newValue) => updateValue(key, newValue)}
+            size="small"
+          />
+          <Text type="tertiary" size="small" className="ml-2">
+            {value ? t('true') : t('false')}
+          </Text>
+        </div>
+      );
+    }
+    
+    if (valueType === 'number') {
+      return (
+        <InputNumber
+          value={value}
+          onChange={(newValue) => updateValue(key, newValue)}
+          size="small"
+          style={{ width: '100%' }}
+          step={key === 'temperature' ? 0.1 : 1}
+          precision={key === 'temperature' ? 2 : 0}
+          placeholder={t('输入数字')}
+        />
+      );
+    }
+    
+    // 字符串类型或其他类型
+    return (
+      <Input
+        placeholder={t('参数值')}
+        value={String(value)}
+        onChange={(newValue) => {
+          // 尝试转换为适当的类型
+          let convertedValue = newValue;
+          if (newValue === 'true') convertedValue = true;
+          else if (newValue === 'false') convertedValue = false;
+          else if (!isNaN(newValue) && newValue !== '' && newValue !== '0') {
+            convertedValue = Number(newValue);
+          }
+          
+          updateValue(key, convertedValue);
+        }}
+        size="small"
+      />
+    );
+  };
+
+  // 渲染区域编辑器(特殊格式)
+  const renderRegionEditor = () => {
+    const entries = Object.entries(jsonData);
+    const defaultEntry = entries.find(([key]) => key === 'default');
+    const modelEntries = entries.filter(([key]) => key !== 'default');
+    
+    return (
+      <div className="space-y-1">
+        {/* 默认区域 */}
+        <Card className="!p-2 !border-blue-200 !bg-blue-50">
+          <div className="flex items-center mb-1">
+            <Text strong size="small" className="text-blue-700">{t('默认区域')}</Text>
+          </div>
+          <Input
+            placeholder={t('默认区域,如: us-central1')}
+            value={defaultEntry ? defaultEntry[1] : ''}
+            onChange={(value) => updateValue('default', value)}
+            size="small"
+          />
+        </Card>
+        
+        {/* 模型专用区域 */}
+        <div className="space-y-1">
+          <Text strong size="small">{t('模型专用区域')}</Text>
+          {modelEntries.map(([modelName, region], index) => (
+            <Card key={index} className="!p-3 !border-gray-200 !rounded-md hover:shadow-sm transition-shadow duration-200">
+              <Row gutter={12} align="middle">
+                <Col span={10}>
+                  <div className="space-y-1">
+                    <Text type="tertiary" size="small">{t('模型名称')}</Text>
+                    <Input
+                      placeholder={t('模型名称')}
+                      value={modelName}
+                      onChange={(newKey) => updateKey(modelName, newKey)}
+                      size="small"
+                    />
+                  </div>
+                </Col>
+                <Col span={11}>
+                  <div className="space-y-1">
+                    <Text type="tertiary" size="small">{t('区域')}</Text>
+                    <Input
+                      placeholder={t('区域')}
+                      value={region}
+                      onChange={(newValue) => updateValue(modelName, newValue)}
+                      size="small"
+                    />
+                  </div>
+                </Col>
+                <Col span={3}>
+                  <div className="flex justify-center pt-4">
+                    <Button
+                      icon={<IconDelete />}
+                      type="danger"
+                      theme="borderless"
+                      size="small"
+                      onClick={() => removeKeyValue(modelName)}
+                      className="hover:bg-red-50"
+                    />
+                  </div>
+                </Col>
+              </Row>
+            </Card>
+          ))}
+          
+          <div className="flex justify-center pt-1">
+            <Button
+              icon={<IconPlus />}
+              onClick={addKeyValue}
+              size="small"
+              theme="solid"
+              type="primary"
+              className="shadow-sm hover:shadow-md transition-shadow px-4"
+            >
+              {t('添加模型区域')}
+            </Button>
+          </div>
+        </div>
+      </div>
+    );
+  };
+
+  // 渲染可视化编辑器
+  const renderVisualEditor = () => {
+    switch (editorType) {
+      case 'region':
+        return renderRegionEditor();
+      case 'object':
+        return renderObjectEditor();
+      case 'keyValue':
+      default:
+        return renderKeyValueEditor();
+    }
+  };
+
+  const hasJsonError = jsonError && jsonError.trim() !== '';
+
+  return (
+    <div className="space-y-1">
+      {/* Label统一显示在上方 */}
+      {label && (
+        <div className="flex items-center">
+          <Text className="text-sm font-medium text-gray-900">{label}</Text>
+        </div>
+      )}
+      
+      {/* 编辑模式切换 */}
+      <div className="flex items-center justify-between p-2 bg-gray-50 rounded-md">
+        <div className="flex items-center gap-2">
+          {editMode === 'visual' && (
+            <Text type="tertiary" size="small" className="bg-blue-100 text-blue-700 px-2 py-0.5 rounded text-xs">
+              {t('可视化模式')}
+            </Text>
+          )}
+          {editMode === 'manual' && (
+            <Text type="tertiary" size="small" className="bg-green-100 text-green-700 px-2 py-0.5 rounded text-xs">
+              {t('手动编辑模式')}
+            </Text>
+          )}
+        </div>
+        <div className="flex items-center gap-2">
+          {template && templateLabel && (
+            <Button
+              size="small"
+              type="tertiary"
+              onClick={fillTemplate}
+              className="!text-semi-color-primary hover:bg-blue-50 text-xs"
+            >
+              {templateLabel}
+            </Button>
+          )}
+          <Space size="tight">
+            <Button
+              size="small"
+              type={editMode === 'visual' ? 'primary' : 'tertiary'}
+              icon={<IconEdit />}
+              onClick={toggleEditMode}
+              disabled={editMode === 'manual' && hasJsonError}
+              className={editMode === 'visual' ? 'shadow-sm' : ''}
+            >
+              {t('可视化')}
+            </Button>
+            <Button
+              size="small"
+              type={editMode === 'manual' ? 'primary' : 'tertiary'}
+              icon={<IconCode />}
+              onClick={toggleEditMode}
+              className={editMode === 'manual' ? 'shadow-sm' : ''}
+            >
+              {t('手动编辑')}
+            </Button>
+          </Space>
+        </div>
+      </div>
+
+      {/* JSON错误提示 */}
+      {hasJsonError && (
+        <Banner
+          type="danger"
+          description={`JSON 格式错误: ${jsonError}`}
+          className="!rounded-md text-sm"
+        />
+      )}
+
+      {/* 编辑器内容 */}
+      {editMode === 'visual' ? (
+        <div>
+          <Card className="!p-3 !border-gray-200 !shadow-sm !rounded-md bg-white">
+            {renderVisualEditor()}
+          </Card>
+          {/* 可视化模式下的额外文本显示在下方 */}
+          {extraText && (
+            <div className="text-xs text-gray-600 mt-0.5">
+              {extraText}
+            </div>
+          )}
+          {/* 隐藏的Form字段用于验证和数据绑定 */}
+          <Form.Input
+            field={field}
+            value={value}
+            rules={rules}
+            style={{ display: 'none' }}
+            noLabel={true}
+            {...props}
+          />
+        </div>
+      ) : (
+        <Form.TextArea
+          field={field}
+          placeholder={placeholder}
+          value={value}
+          onChange={handleManualChange}
+          showClear={showClear}
+          rows={Math.max(8, value ? value.split('\n').length : 8)}
+          rules={rules}
+          noLabel={true}
+          {...props}
+        />
+      )}
+
+      {/* 额外文本在手动编辑模式下显示 */}
+      {extraText && editMode === 'manual' && (
+        <div className="text-xs text-gray-600">
+          {extraText}
+        </div>
+      )}
+    </div>
+  );
+};
+
+export default JSONEditor; 

+ 2 - 2
web/src/components/playground/SettingsPanel.js

@@ -33,7 +33,7 @@ import {
   Settings,
 } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
-import { renderGroupOption, modelSelectFilter } from '../../helpers';
+import { renderGroupOption, selectFilter } from '../../helpers';
 import ParameterControl from './ParameterControl';
 import ImageUrlInput from './ImageUrlInput';
 import ConfigManager from './ConfigManager';
@@ -173,7 +173,7 @@ const SettingsPanel = ({
             name='model'
             required
             selection
-            filter={modelSelectFilter}
+            filter={selectFilter}
             autoClearSearchValue={false}
             onChange={(value) => onInputChange('model', value)}
             value={inputs.model}

+ 59 - 46
web/src/components/table/channels/modals/EditChannelModal.jsx

@@ -43,6 +43,9 @@ import {
   Tag,
   Typography,
 } from '@douyinfe/semi-ui';
+import { getChannelModels, copy, getChannelIcon, getModelCategories, selectFilter } from '../../../../helpers';
+import ModelSelectModal from './ModelSelectModal';
+import JSONEditor from '../../../common/JSONEditor';
 import { CHANNEL_OPTIONS, CLAUDE_CODE_DEFAULT_SYSTEM_PROMPT } from '../../../../constants';
 import {
   IconBolt,
@@ -70,7 +73,9 @@ const STATUS_CODE_MAPPING_EXAMPLE = {
 };
 
 const REGION_EXAMPLE = {
-  default: 'us-central1',
+  "default": 'global',
+  "gemini-1.5-pro-002": "europe-west2",
+  "gemini-1.5-flash-002": "europe-west2",
   'claude-3-5-sonnet-20240620': 'europe-west1',
 };
 
@@ -149,6 +154,8 @@ const EditChannelModal = (props) => {
   const [authorizationCode, setAuthorizationCode] = useState('');
   const [oauthParams, setOauthParams] = useState(null);
   const [isExchangingCode, setIsExchangingCode] = useState(false);
+  const [modelModalVisible, setModelModalVisible] = useState(false);
+  const [fetchedModels, setFetchedModels] = useState([]);
   const formApiRef = useRef(null);
   const [vertexKeys, setVertexKeys] = useState([]);
   const [vertexFileList, setVertexFileList] = useState([]);
@@ -404,7 +411,7 @@ const EditChannelModal = (props) => {
     //   return;
     // }
     setLoading(true);
-    const models = inputs['models'] || [];
+    const models = [];
     let err = false;
 
     if (isEdit) {
@@ -445,8 +452,9 @@ const EditChannelModal = (props) => {
     }
 
     if (!err) {
-      handleInputChange(name, Array.from(new Set(models)));
-      showSuccess(t('获取模型列表成功'));
+      const uniqueModels = Array.from(new Set(models));
+      setFetchedModels(uniqueModels);
+      setModelModalVisible(true);
     } else {
       showError(t('获取模型列表失败'));
     }
@@ -1071,7 +1079,7 @@ const EditChannelModal = (props) => {
                     rules={[{ required: true, message: t('请选择渠道类型') }]}
                     optionList={channelOptionList}
                     style={{ width: '100%' }}
-                    filter={modelSelectFilter}
+                    filter={selectFilter}
                     autoClearSearchValue={false}
                     searchPosition='dropdown'
                     onSearch={(value) => setChannelSearchValue(value)}
@@ -1305,24 +1313,24 @@ const EditChannelModal = (props) => {
                   )}
 
                   {inputs.type === 41 && (
-                    <Form.TextArea
+                    <JSONEditor
                       field='other'
                       label={t('部署地区')}
                       placeholder={t(
                         '请输入部署地区,例如:us-central1\n支持使用模型映射格式\n{\n    "default": "us-central1",\n    "claude-3-5-sonnet-20240620": "europe-west1"\n}'
                       )}
-                      autosize
+                      value={inputs.other || ''}
                       onChange={(value) => handleInputChange('other', value)}
                       rules={[{ required: true, message: t('请填写部署地区') }]}
+                      template={REGION_EXAMPLE}
+                      templateLabel={t('填入模板')}
+                      editorType="region"
+                      formApi={formApiRef.current}
                       extraText={
-                        <Text
-                          className="!text-semi-color-primary cursor-pointer"
-                          onClick={() => handleInputChange('other', JSON.stringify(REGION_EXAMPLE, null, 2))}
-                        >
-                          {t('填入模板')}
+                        <Text type="tertiary" size="small">
+                          {t('设置默认地区和特定模型的专用地区')}
                         </Text>
                       }
-                      showClear
                     />
                   )}
 
@@ -1515,7 +1523,7 @@ const EditChannelModal = (props) => {
                     placeholder={t('请选择该渠道所支持的模型')}
                     rules={[{ required: true, message: t('请选择模型') }]}
                     multiple
-                    filter={modelSelectFilter}
+                    filter={selectFilter}
                     autoClearSearchValue={false}
                     searchPosition='dropdown'
                     optionList={modelOptions}
@@ -1578,24 +1586,24 @@ const EditChannelModal = (props) => {
                     showClear
                   />
 
-                  <Form.TextArea
+                  <JSONEditor
                     field='model_mapping'
                     label={t('模型重定向')}
                     placeholder={
                       t('此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,例如:') +
                       `\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`
                     }
-                    autosize
+                    value={inputs.model_mapping || ''}
                     onChange={(value) => handleInputChange('model_mapping', value)}
+                    template={MODEL_MAPPING_EXAMPLE}
+                    templateLabel={t('填入模板')}
+                    editorType="keyValue"
+                    formApi={formApiRef.current}
                     extraText={
-                      <Text
-                        className="!text-semi-color-primary cursor-pointer"
-                        onClick={() => handleInputChange('model_mapping', JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2))}
-                      >
-                        {t('填入模板')}
+                      <Text type="tertiary" size="small">
+                        {t('键为请求中的模型名称,值为要替换的模型名称')}
                       </Text>
                     }
-                    showClear
                   />
                 </Card>
 
@@ -1685,7 +1693,7 @@ const EditChannelModal = (props) => {
                     showClear
                   />
 
-                  <Form.TextArea
+                  <JSONEditor
                     field='status_code_mapping'
                     label={t('状态码复写')}
                     placeholder={
@@ -1693,17 +1701,17 @@ const EditChannelModal = (props) => {
                       '\n' +
                       JSON.stringify(STATUS_CODE_MAPPING_EXAMPLE, null, 2)
                     }
-                    autosize
+                    value={inputs.status_code_mapping || ''}
                     onChange={(value) => handleInputChange('status_code_mapping', value)}
+                    template={STATUS_CODE_MAPPING_EXAMPLE}
+                    templateLabel={t('填入模板')}
+                    editorType="keyValue"
+                    formApi={formApiRef.current}
                     extraText={
-                      <Text
-                        className="!text-semi-color-primary cursor-pointer"
-                        onClick={() => handleInputChange('status_code_mapping', JSON.stringify(STATUS_CODE_MAPPING_EXAMPLE, null, 2))}
-                      >
-                        {t('填入模板')}
+                      <Text type="tertiary" size="small">
+                        {t('键为原状态码,值为要复写的状态码,仅影响本地判断')}
                       </Text>
                     }
-                    showClear
                   />
                 </Card>
 
@@ -1716,25 +1724,19 @@ const EditChannelModal = (props) => {
                     </Avatar>
                     <div>
                       <Text className="text-lg font-medium">{t('渠道额外设置')}</Text>
-                      <div className="text-xs text-gray-600">
-                        <Text
-                          className="!text-semi-color-primary cursor-pointer"
-                          onClick={() => window.open('https://github.com/QuantumNous/new-api/blob/main/docs/channel/other_setting.md')}
-                        >
-                          {t('设置说明')}
-                        </Text>
-                      </div>
                     </div>
                   </div>
 
-                  <Form.Switch
-                    field='force_format'
-                    label={t('强制格式化')}
-                    checkedText={t('开')}
-                    uncheckedText={t('关')}
-                    onChange={(value) => handleChannelSettingsChange('force_format', value)}
-                    extraText={t('强制将响应格式化为 OpenAI 标准格式(只适用于OpenAI渠道类型)')}
-                  />
+                  {inputs.type === 1 && (
+                    <Form.Switch
+                      field='force_format'
+                      label={t('强制格式化')}
+                      checkedText={t('开')}
+                      uncheckedText={t('关')}
+                      onChange={(value) => handleChannelSettingsChange('force_format', value)}
+                      extraText={t('强制将响应格式化为 OpenAI 标准格式(只适用于OpenAI渠道类型)')}
+                    />
+                  )}
 
                   <Form.Switch
                     field='thinking_to_content'
@@ -1791,6 +1793,17 @@ const EditChannelModal = (props) => {
           onVisibleChange={(visible) => setIsModalOpenurl(visible)}
         />
       </SideSheet>
+      <ModelSelectModal
+        visible={modelModalVisible}
+        models={fetchedModels}
+        selected={inputs.models}
+        onConfirm={(selectedModels) => {
+          handleInputChange('models', selectedModels);
+          showSuccess(t('模型列表已更新'));
+          setModelModalVisible(false);
+        }}
+        onCancel={() => setModelModalVisible(false)}
+      />
 
       {/* OAuth Authorization Modal */}
       <Modal

+ 2 - 2
web/src/components/table/channels/modals/EditTagModal.jsx

@@ -25,7 +25,7 @@ import {
   showSuccess,
   showWarning,
   verifyJSON,
-  modelSelectFilter,
+  selectFilter,
 } from '../../../../helpers';
 import {
   SideSheet,
@@ -395,7 +395,7 @@ const EditTagModal = (props) => {
                     label={t('模型')}
                     placeholder={t('请选择该渠道所支持的模型,留空则不更改')}
                     multiple
-                    filter={modelSelectFilter}
+                    filter={selectFilter}
                     autoClearSearchValue={false}
                     searchPosition='dropdown'
                     optionList={modelOptions}

+ 272 - 0
web/src/components/table/channels/modals/ModelSelectModal.jsx

@@ -0,0 +1,272 @@
+import React, { useState, useEffect } from 'react';
+import { useIsMobile } from '../../../../hooks/common/useIsMobile.js';
+import { Modal, Checkbox, Spin, Input, Typography, Empty, Tabs, Collapse } from '@douyinfe/semi-ui';
+import {
+  IllustrationNoResult,
+  IllustrationNoResultDark
+} from '@douyinfe/semi-illustrations';
+import { IconSearch } from '@douyinfe/semi-icons';
+import { useTranslation } from 'react-i18next';
+import { getModelCategories } from '../../../../helpers/render';
+
+const ModelSelectModal = ({ visible, models = [], selected = [], onConfirm, onCancel }) => {
+  const { t } = useTranslation();
+  const [checkedList, setCheckedList] = useState(selected);
+  const [keyword, setKeyword] = useState('');
+  const [activeTab, setActiveTab] = useState('new');
+
+  const isMobile = useIsMobile();
+
+  const filteredModels = models.filter((m) => m.toLowerCase().includes(keyword.toLowerCase()));
+
+  // 分类模型:新获取的模型和已有模型
+  const newModels = filteredModels.filter(model => !selected.includes(model));
+  const existingModels = filteredModels.filter(model => selected.includes(model));
+
+  // 同步外部选中值
+  useEffect(() => {
+    if (visible) {
+      setCheckedList(selected);
+    }
+  }, [visible, selected]);
+
+  // 当模型列表变化时,设置默认tab
+  useEffect(() => {
+    if (visible) {
+      // 默认显示新获取模型tab,如果没有新模型则显示已有模型
+      const hasNewModels = newModels.length > 0;
+      setActiveTab(hasNewModels ? 'new' : 'existing');
+    }
+  }, [visible, newModels.length, selected]);
+
+  const handleOk = () => {
+    onConfirm && onConfirm(checkedList);
+  };
+
+  // 按厂商分类模型
+  const categorizeModels = (models) => {
+    const categories = getModelCategories(t);
+    const categorizedModels = {};
+    const uncategorizedModels = [];
+
+    models.forEach(model => {
+      let foundCategory = false;
+      for (const [key, category] of Object.entries(categories)) {
+        if (key !== 'all' && category.filter({ model_name: model })) {
+          if (!categorizedModels[key]) {
+            categorizedModels[key] = {
+              label: category.label,
+              icon: category.icon,
+              models: []
+            };
+          }
+          categorizedModels[key].models.push(model);
+          foundCategory = true;
+          break;
+        }
+      }
+      if (!foundCategory) {
+        uncategorizedModels.push(model);
+      }
+    });
+
+    // 如果有未分类模型,添加到"其他"分类
+    if (uncategorizedModels.length > 0) {
+      categorizedModels['other'] = {
+        label: t('其他'),
+        icon: null,
+        models: uncategorizedModels
+      };
+    }
+
+    return categorizedModels;
+  };
+
+  const newModelsByCategory = categorizeModels(newModels);
+  const existingModelsByCategory = categorizeModels(existingModels);
+
+  // Tab列表配置
+  const tabList = [
+    ...(newModels.length > 0 ? [{
+      tab: `${t('新获取的模型')} (${newModels.length})`,
+      itemKey: 'new'
+    }] : []),
+    ...(existingModels.length > 0 ? [{
+      tab: `${t('已有的模型')} (${existingModels.length})`,
+      itemKey: 'existing'
+    }] : [])
+  ];
+
+  // 处理分类全选/取消全选
+  const handleCategorySelectAll = (categoryModels, isChecked) => {
+    let newCheckedList = [...checkedList];
+
+    if (isChecked) {
+      // 全选:添加该分类下所有未选中的模型
+      categoryModels.forEach(model => {
+        if (!newCheckedList.includes(model)) {
+          newCheckedList.push(model);
+        }
+      });
+    } else {
+      // 取消全选:移除该分类下所有已选中的模型
+      newCheckedList = newCheckedList.filter(model => !categoryModels.includes(model));
+    }
+
+    setCheckedList(newCheckedList);
+  };
+
+  // 检查分类是否全选
+  const isCategoryAllSelected = (categoryModels) => {
+    return categoryModels.length > 0 && categoryModels.every(model => checkedList.includes(model));
+  };
+
+  // 检查分类是否部分选中
+  const isCategoryIndeterminate = (categoryModels) => {
+    const selectedCount = categoryModels.filter(model => checkedList.includes(model)).length;
+    return selectedCount > 0 && selectedCount < categoryModels.length;
+  };
+
+  const renderModelsByCategory = (modelsByCategory, categoryKeyPrefix) => {
+    const categoryEntries = Object.entries(modelsByCategory);
+    if (categoryEntries.length === 0) return null;
+
+    // 生成所有面板的key,确保都展开
+    const allActiveKeys = categoryEntries.map((_, index) => `${categoryKeyPrefix}_${index}`);
+
+    return (
+      <Collapse activeKey={allActiveKeys}>
+        {categoryEntries.map(([key, categoryData], index) => (
+          <Collapse.Panel
+            key={`${categoryKeyPrefix}_${index}`}
+            itemKey={`${categoryKeyPrefix}_${index}`}
+            header={`${categoryData.label} (${categoryData.models.length})`}
+            extra={
+              <Checkbox
+                checked={isCategoryAllSelected(categoryData.models)}
+                indeterminate={isCategoryIndeterminate(categoryData.models)}
+                onChange={(e) => {
+                  e.stopPropagation(); // 防止触发面板折叠
+                  handleCategorySelectAll(categoryData.models, e.target.checked);
+                }}
+                onClick={(e) => e.stopPropagation()} // 防止点击checkbox时折叠面板
+              />
+            }
+          >
+            <div className="flex items-center gap-2 mb-3">
+              {categoryData.icon}
+              <Typography.Text type="secondary" size="small">
+                {t('已选择 {{selected}} / {{total}}', {
+                  selected: categoryData.models.filter(model => checkedList.includes(model)).length,
+                  total: categoryData.models.length
+                })}
+              </Typography.Text>
+            </div>
+            <div className="grid grid-cols-2 gap-x-4">
+              {categoryData.models.map((model) => (
+                <Checkbox key={model} value={model} className="my-1">
+                  {model}
+                </Checkbox>
+              ))}
+            </div>
+          </Collapse.Panel>
+        ))}
+      </Collapse>
+    );
+  };
+
+  return (
+    <Modal
+      header={
+        <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 py-4">
+          <Typography.Title heading={5} className="m-0">
+            {t('选择模型')}
+          </Typography.Title>
+          <div className="flex-shrink-0">
+            <Tabs
+              type="slash"
+              size="small"
+              tabList={tabList}
+              activeKey={activeTab}
+              onChange={(key) => setActiveTab(key)}
+            />
+          </div>
+        </div>
+      }
+      visible={visible}
+      onOk={handleOk}
+      onCancel={onCancel}
+      okText={t('确定')}
+      cancelText={t('取消')}
+      size={isMobile ? 'full-width' : 'large'}
+      closeOnEsc
+      maskClosable
+      centered
+    >
+      <Input
+        prefix={<IconSearch size={14} />}
+        placeholder={t('搜索模型')}
+        value={keyword}
+        onChange={(v) => setKeyword(v)}
+        showClear
+      />
+
+      <Spin spinning={!models || models.length === 0}>
+        <div style={{ maxHeight: 400, overflowY: 'auto', paddingRight: 8 }}>
+          {filteredModels.length === 0 ? (
+            <Empty
+              image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
+              darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
+              description={t('暂无匹配模型')}
+              style={{ padding: 30 }}
+            />
+          ) : (
+            <Checkbox.Group value={checkedList} onChange={(vals) => setCheckedList(vals)}>
+              {activeTab === 'new' && newModels.length > 0 && (
+                <div>
+                  {renderModelsByCategory(newModelsByCategory, 'new')}
+                </div>
+              )}
+              {activeTab === 'existing' && existingModels.length > 0 && (
+                <div>
+                  {renderModelsByCategory(existingModelsByCategory, 'existing')}
+                </div>
+              )}
+            </Checkbox.Group>
+          )}
+        </div>
+      </Spin>
+
+      <Typography.Text type="secondary" size="small" className="block text-right mt-4">
+        <div className="flex items-center justify-end gap-2">
+          {(() => {
+            const currentModels = activeTab === 'new' ? newModels : existingModels;
+            const currentSelected = currentModels.filter(model => checkedList.includes(model)).length;
+            const isAllSelected = currentModels.length > 0 && currentSelected === currentModels.length;
+            const isIndeterminate = currentSelected > 0 && currentSelected < currentModels.length;
+
+            return (
+              <>
+                <span>
+                  {t('已选择 {{selected}} / {{total}}', {
+                    selected: currentSelected,
+                    total: currentModels.length
+                  })}
+                </span>
+                <Checkbox
+                  checked={isAllSelected}
+                  indeterminate={isIndeterminate}
+                  onChange={(e) => {
+                    handleCategorySelectAll(currentModels, e.target.checked);
+                  }}
+                />
+              </>
+            );
+          })()}
+        </div>
+      </Typography.Text>
+    </Modal>
+  );
+};
+
+export default ModelSelectModal; 

+ 2 - 2
web/src/components/table/tokens/modals/EditTokenModal.jsx

@@ -26,7 +26,7 @@ import {
   renderGroupOption,
   renderQuotaWithPrompt,
   getModelCategories,
-  modelSelectFilter,
+  selectFilter,
 } from '../../../../helpers';
 import { useIsMobile } from '../../../../hooks/common/useIsMobile.js';
 import {
@@ -514,7 +514,7 @@ const EditTokenModal = (props) => {
                       multiple
                       optionList={models}
                       extraText={t('非必要,不建议启用模型限制')}
-                      filter={modelSelectFilter}
+                      filter={selectFilter}
                       autoClearSearchValue={false}
                       searchPosition='dropdown'
                       showClear

+ 11 - 1
web/src/helpers/render.js

@@ -884,12 +884,22 @@ export function renderQuotaWithAmount(amount) {
 }
 
 export function renderQuota(quota, digits = 2) {
+
   let quotaPerUnit = localStorage.getItem('quota_per_unit');
   let displayInCurrency = localStorage.getItem('display_in_currency');
   quotaPerUnit = parseFloat(quotaPerUnit);
   displayInCurrency = displayInCurrency === 'true';
   if (displayInCurrency) {
-    return '$' + (quota / quotaPerUnit).toFixed(digits);
+    const result = quota / quotaPerUnit;
+    const fixedResult = result.toFixed(digits);
+    
+    // 如果 toFixed 后结果为 0 但原始值不为 0,显示最小值
+    if (parseFloat(fixedResult) === 0 && quota > 0 && result > 0) {
+      const minValue = Math.pow(10, -digits);
+      return '$' + minValue.toFixed(digits);
+    }
+    
+    return '$' + fixedResult;
   }
   return renderNumber(quota);
 }

+ 9 - 5
web/src/helpers/utils.js

@@ -560,12 +560,16 @@ export function setTableCompactMode(compact, tableKey = 'global') {
 
 // -------------------------------
 // Select 组件统一过滤逻辑
-// 解决 label 为 ReactNode(带图标等)时无法用内置 filter 搜索的问题。
-// 使用方式: <Select filter={modelSelectFilter} ... />
-export const modelSelectFilter = (input, option) => {
+// 使用方式: <Select filter={selectFilter} ... />
+// 统一的 Select 搜索过滤逻辑 -- 支持同时匹配 option.value 与 option.label
+export const selectFilter = (input, option) => {
   if (!input) return true;
-  const val = (option?.value || '').toString().toLowerCase();
-  return val.includes(input.trim().toLowerCase());
+
+  const keyword = input.trim().toLowerCase();
+  const valueText = (option?.value ?? '').toString().toLowerCase();
+  const labelText = (option?.label ?? '').toString().toLowerCase();
+
+  return valueText.includes(keyword) || labelText.includes(keyword);
 };
 
 // -------------------------------

+ 8 - 5
web/src/hooks/mj-logs/useMjLogsData.js

@@ -60,6 +60,8 @@ export const useMjLogsData = () => {
 
   // User and admin
   const isAdminUser = isAdmin();
+  // Role-specific storage key to prevent different roles from overwriting each other
+  const STORAGE_KEY = isAdminUser ? 'mj-logs-table-columns-admin' : 'mj-logs-table-columns-user';
 
   // Modal states
   const [isModalOpen, setIsModalOpen] = useState(false);
@@ -88,13 +90,14 @@ export const useMjLogsData = () => {
 
   // Load saved column preferences from localStorage
   useEffect(() => {
-    const savedColumns = localStorage.getItem('mj-logs-table-columns');
+    const savedColumns = localStorage.getItem(STORAGE_KEY);
     if (savedColumns) {
       try {
         const parsed = JSON.parse(savedColumns);
         const defaults = getDefaultColumnVisibility();
         const merged = { ...defaults, ...parsed };
-        // If not admin, force hide columns only visible to admins
+
+        // For non-admin users, force-hide admin-only columns (does not touch admin settings)
         if (!isAdminUser) {
           merged[COLUMN_KEYS.CHANNEL] = false;
           merged[COLUMN_KEYS.SUBMIT_RESULT] = false;
@@ -139,7 +142,7 @@ export const useMjLogsData = () => {
   const initDefaultColumns = () => {
     const defaults = getDefaultColumnVisibility();
     setVisibleColumns(defaults);
-    localStorage.setItem('mj-logs-table-columns', JSON.stringify(defaults));
+    localStorage.setItem(STORAGE_KEY, JSON.stringify(defaults));
   };
 
   // Handle column visibility change
@@ -167,10 +170,10 @@ export const useMjLogsData = () => {
     setVisibleColumns(updatedColumns);
   };
 
-  // Update table when column visibility changes
+  // Persist column settings to the role-specific STORAGE_KEY
   useEffect(() => {
     if (Object.keys(visibleColumns).length > 0) {
-      localStorage.setItem('mj-logs-table-columns', JSON.stringify(visibleColumns));
+      localStorage.setItem(STORAGE_KEY, JSON.stringify(visibleColumns));
     }
   }, [visibleColumns]);
 

+ 8 - 5
web/src/hooks/task-logs/useTaskLogsData.js

@@ -58,6 +58,8 @@ export const useTaskLogsData = () => {
 
   // User and admin
   const isAdminUser = isAdmin();
+  // Role-specific storage key to prevent different roles from overwriting each other
+  const STORAGE_KEY = isAdminUser ? 'task-logs-table-columns-admin' : 'task-logs-table-columns-user';
 
   // Modal state
   const [isModalOpen, setIsModalOpen] = useState(false);
@@ -86,13 +88,14 @@ export const useTaskLogsData = () => {
 
   // Load saved column preferences from localStorage
   useEffect(() => {
-    const savedColumns = localStorage.getItem('task-logs-table-columns');
+    const savedColumns = localStorage.getItem(STORAGE_KEY);
     if (savedColumns) {
       try {
         const parsed = JSON.parse(savedColumns);
         const defaults = getDefaultColumnVisibility();
         const merged = { ...defaults, ...parsed };
-        // If not admin, force hide columns only visible to admins
+
+        // For non-admin users, force-hide admin-only columns (does not touch admin settings)
         if (!isAdminUser) {
           merged[COLUMN_KEYS.CHANNEL] = false;
         }
@@ -127,7 +130,7 @@ export const useTaskLogsData = () => {
   const initDefaultColumns = () => {
     const defaults = getDefaultColumnVisibility();
     setVisibleColumns(defaults);
-    localStorage.setItem('task-logs-table-columns', JSON.stringify(defaults));
+    localStorage.setItem(STORAGE_KEY, JSON.stringify(defaults));
   };
 
   // Handle column visibility change
@@ -152,10 +155,10 @@ export const useTaskLogsData = () => {
     setVisibleColumns(updatedColumns);
   };
 
-  // Update table when column visibility changes
+  // Persist column settings to the role-specific STORAGE_KEY
   useEffect(() => {
     if (Object.keys(visibleColumns).length > 0) {
-      localStorage.setItem('task-logs-table-columns', JSON.stringify(visibleColumns));
+      localStorage.setItem(STORAGE_KEY, JSON.stringify(visibleColumns));
     }
   }, [visibleColumns]);
 

+ 8 - 8
web/src/hooks/usage-logs/useUsageLogsData.js

@@ -74,6 +74,8 @@ export const useLogsData = () => {
 
   // User and admin
   const isAdminUser = isAdmin();
+  // Role-specific storage key to prevent different roles from overwriting each other
+  const STORAGE_KEY = isAdminUser ? 'logs-table-columns-admin' : 'logs-table-columns-user';
 
   // Statistics state
   const [stat, setStat] = useState({
@@ -110,13 +112,14 @@ export const useLogsData = () => {
 
   // Load saved column preferences from localStorage
   useEffect(() => {
-    const savedColumns = localStorage.getItem('logs-table-columns');
+    const savedColumns = localStorage.getItem(STORAGE_KEY);
     if (savedColumns) {
       try {
         const parsed = JSON.parse(savedColumns);
         const defaults = getDefaultColumnVisibility();
         const merged = { ...defaults, ...parsed };
-        // If not admin, force hide columns only visible to admins
+
+        // For non-admin users, force-hide admin-only columns (does not touch admin settings)
         if (!isAdminUser) {
           merged[COLUMN_KEYS.CHANNEL] = false;
           merged[COLUMN_KEYS.USERNAME] = false;
@@ -156,7 +159,7 @@ export const useLogsData = () => {
   const initDefaultColumns = () => {
     const defaults = getDefaultColumnVisibility();
     setVisibleColumns(defaults);
-    localStorage.setItem('logs-table-columns', JSON.stringify(defaults));
+    localStorage.setItem(STORAGE_KEY, JSON.stringify(defaults));
   };
 
   // Handle column visibility change
@@ -186,13 +189,10 @@ export const useLogsData = () => {
     setVisibleColumns(updatedColumns);
   };
 
-  // Update table when column visibility changes
+  // Persist column settings to the role-specific STORAGE_KEY
   useEffect(() => {
     if (Object.keys(visibleColumns).length > 0) {
-      localStorage.setItem(
-        'logs-table-columns',
-        JSON.stringify(visibleColumns),
-      );
+      localStorage.setItem(STORAGE_KEY, JSON.stringify(visibleColumns));
     }
   }, [visibleColumns]);
 

+ 6 - 1
web/src/i18n/locales/en.json

@@ -1799,5 +1799,10 @@
   "显示第": "Showing",
   "条 - 第": "to",
   "条,共": "of",
-  "条": "items"
+  "条": "items",
+  "选择模型": "Select model",
+  "已选择 {{selected}} / {{total}}": "Selected {{selected}} / {{total}}",
+  "新获取的模型": "New models",
+  "已有的模型": "Existing models",
+  "搜索模型": "Search models"
 }

+ 3 - 0
web/src/pages/Setting/RateLimit/SettingsRequestRateLimit.js

@@ -147,6 +147,7 @@ export default function RequestRateLimit(props) {
                   label={t('用户每周期最多请求次数')}
                   step={1}
                   min={0}
+                  max={100000000}
                   suffix={t('次')}
                   extraText={t('包括失败请求的次数,0代表不限制')}
                   field={'ModelRequestRateLimitCount'}
@@ -163,6 +164,7 @@ export default function RequestRateLimit(props) {
                   label={t('用户每周期最多请求完成次数')}
                   step={1}
                   min={1}
+                  max={100000000}
                   suffix={t('次')}
                   extraText={t('只包括请求成功的次数')}
                   field={'ModelRequestRateLimitSuccessCount'}
@@ -199,6 +201,7 @@ export default function RequestRateLimit(props) {
                         <li>{t('使用 JSON 对象格式,格式为:{"组名": [最多请求次数, 最多请求完成次数]}')}</li>
                       <li>{t('示例:{"default": [200, 100], "vip": [0, 1000]}。')}</li>
                       <li>{t('[最多请求次数]必须大于等于0,[最多请求完成次数]必须大于等于1。')}</li>
+                        <li>{t('[最多请求次数]和[最多请求完成次数]的最大值为2147483647。')}</li>
                         <li>{t('分组速率配置优先级高于全局速率限制。')}</li>
                         <li>{t('限制周期统一使用上方配置的“限制周期”值。')}</li>
                       </ul>