Przeglądaj źródła

Merge branch 'alpha' into refactor/model-pricing

t0ng7u 7 miesięcy temu
rodzic
commit
9110611489

+ 2 - 1
controller/redemption.go

@@ -6,6 +6,7 @@ import (
 	"one-api/common"
 	"one-api/model"
 	"strconv"
+	"unicode/utf8"
 
 	"github.com/gin-gonic/gin"
 )
@@ -63,7 +64,7 @@ func AddRedemption(c *gin.Context) {
 		common.ApiError(c, err)
 		return
 	}
-	if len(redemption.Name) == 0 || len(redemption.Name) > 20 {
+	if utf8.RuneCountInString(redemption.Name) == 0 || utf8.RuneCountInString(redemption.Name) > 20 {
 		c.JSON(http.StatusOK, gin.H{
 			"success": false,
 			"message": "兑换码名称长度必须在1-20之间",

+ 5 - 3
dto/channel_settings.go

@@ -1,7 +1,9 @@
 package dto
 
 type ChannelSettings struct {
-	ForceFormat       bool   `json:"force_format,omitempty"`
-	ThinkingToContent bool   `json:"thinking_to_content,omitempty"`
-	Proxy             string `json:"proxy"`
+	ForceFormat            bool   `json:"force_format,omitempty"`
+	ThinkingToContent      bool   `json:"thinking_to_content,omitempty"`
+	Proxy                  string `json:"proxy"`
+	PassThroughBodyEnabled bool   `json:"pass_through_body_enabled,omitempty"`
+	SystemPrompt           string `json:"system_prompt,omitempty"`
 }

+ 15 - 6
dto/openai_request.go

@@ -7,15 +7,15 @@ import (
 )
 
 type ResponseFormat struct {
-	Type       string            `json:"type,omitempty"`
-	JsonSchema *FormatJsonSchema `json:"json_schema,omitempty"`
+	Type       string          `json:"type,omitempty"`
+	JsonSchema json.RawMessage `json:"json_schema,omitempty"`
 }
 
 type FormatJsonSchema struct {
-	Description string `json:"description,omitempty"`
-	Name        string `json:"name"`
-	Schema      any    `json:"schema,omitempty"`
-	Strict      any    `json:"strict,omitempty"`
+	Description string          `json:"description,omitempty"`
+	Name        string          `json:"name"`
+	Schema      any             `json:"schema,omitempty"`
+	Strict      json.RawMessage `json:"strict,omitempty"`
 }
 
 type GeneralOpenAIRequest struct {
@@ -73,6 +73,15 @@ func (r *GeneralOpenAIRequest) ToMap() map[string]any {
 	return result
 }
 
+func (r *GeneralOpenAIRequest) GetSystemRoleName() string {
+	if strings.HasPrefix(r.Model, "o") {
+		if !strings.HasPrefix(r.Model, "o1-mini") && !strings.HasPrefix(r.Model, "o1-preview") {
+			return "developer"
+		}
+	}
+	return "system"
+}
+
 type ToolCallRequest struct {
 	ID       string          `json:"id,omitempty"`
 	Type     string          `json:"type"`

+ 13 - 0
i18n/zh-cn.json

@@ -585,6 +585,19 @@
   "渠道权重": "渠道权重",
   "渠道额外设置": "渠道额外设置",
   "此项可选,用于配置渠道特定设置,为一个 JSON 字符串,例如:": "此项可选,用于配置渠道特定设置,为一个 JSON 字符串,例如:",
+  "强制格式化": "强制格式化",
+  "强制将响应格式化为 OpenAI 标准格式(只适用于OpenAI渠道类型)": "强制将响应格式化为 OpenAI 标准格式(只适用于OpenAI渠道类型)",
+  "思考内容转换": "思考内容转换",
+  "将 reasoning_content 转换为 <think> 标签拼接到内容中": "将 reasoning_content 转换为 <think> 标签拼接到内容中",
+  "透传请求体": "透传请求体",
+  "启用请求体透传功能": "启用请求体透传功能",
+  "代理地址": "代理地址",
+  "例如: socks5://user:pass@host:port": "例如: socks5://user:pass@host:port",
+  "用于配置网络代理": "用于配置网络代理",
+  "用于配置网络代理,支持 socks5 协议": "用于配置网络代理,支持 socks5 协议",
+  "系统提示词": "系统提示词",
+  "输入系统提示词,用户的系统提示词将优先于此设置": "输入系统提示词,用户的系统提示词将优先于此设置",
+  "用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置": "用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置",
   "参数覆盖": "参数覆盖",
   "此项可选,用于覆盖请求参数。不支持覆盖 stream 参数。为一个 JSON 字符串,例如:": "此项可选,用于覆盖请求参数。不支持覆盖 stream 参数。为一个 JSON 字符串,例如:",
   "请输入组织org-xxx": "请输入组织org-xxx",

+ 6 - 7
middleware/distributor.go

@@ -111,18 +111,17 @@ func Distribute() func(c *gin.Context) {
 					if userGroup == "auto" {
 						showGroup = fmt.Sprintf("auto(%s)", selectGroup)
 					}
-					message := fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败(distributor): %s", showGroup, modelRequest.Model, err.Error())
+					message := fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败(数据库一致性已被破坏,distributor): %s", showGroup, modelRequest.Model, err.Error())
 					// 如果错误,但是渠道不为空,说明是数据库一致性问题
-					if channel != nil {
-						common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id))
-						message = "数据库一致性已被破坏,请联系管理员"
-					}
-					// 如果错误,而且渠道为空,说明是没有可用渠道
+					//if channel != nil {
+					//	common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id))
+					//	message = "数据库一致性已被破坏,请联系管理员"
+					//}
 					abortWithOpenAiMessage(c, http.StatusServiceUnavailable, message)
 					return
 				}
 				if channel == nil {
-					abortWithOpenAiMessage(c, http.StatusServiceUnavailable, fmt.Sprintf("分组 %s 下模型 %s 的可用渠道不存在(数据库一致性已被破坏,distributor)", userGroup, modelRequest.Model))
+					abortWithOpenAiMessage(c, http.StatusServiceUnavailable, fmt.Sprintf("分组 %s 下模型 %s 无可用渠道(distributor)", userGroup, modelRequest.Model))
 					return
 				}
 			}

+ 1 - 1
model/ability.go

@@ -136,7 +136,7 @@ func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
 			}
 		}
 	} else {
-		return nil, errors.New("channel not found")
+		return nil, nil
 	}
 	err = DB.First(&channel, "id = ?", channel.Id).Error
 	return &channel, err

+ 1 - 1
model/channel_cache.go

@@ -130,7 +130,7 @@ func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel,
 	channels := group2model2channels[group][model]
 
 	if len(channels) == 0 {
-		return nil, errors.New("channel not found")
+		return nil, nil
 	}
 
 	if len(channels) == 1 {

+ 8 - 4
relay/channel/gemini/adaptor.go

@@ -9,6 +9,7 @@ import (
 	"one-api/common"
 	"one-api/dto"
 	"one-api/relay/channel"
+	"one-api/relay/channel/openai"
 	relaycommon "one-api/relay/common"
 	"one-api/relay/constant"
 	"one-api/setting/model_setting"
@@ -21,10 +22,13 @@ import (
 type Adaptor struct {
 }
 
-func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
-	//TODO implement me
-	panic("implement me")
-	return nil, nil
+func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
+	adaptor := openai.Adaptor{}
+	oaiReq, err := adaptor.ConvertClaudeRequest(c, info, req)
+	if err != nil {
+		return nil, err
+	}
+	return a.ConvertOpenAIRequest(c, info, oaiReq.(*dto.GeneralOpenAIRequest))
 }
 
 func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {

+ 56 - 15
relay/channel/gemini/relay-gemini.go

@@ -9,6 +9,7 @@ import (
 	"one-api/common"
 	"one-api/constant"
 	"one-api/dto"
+	"one-api/relay/channel/openai"
 	relaycommon "one-api/relay/common"
 	"one-api/relay/helper"
 	"one-api/service"
@@ -219,9 +220,13 @@ func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon
 	if textRequest.ResponseFormat != nil && (textRequest.ResponseFormat.Type == "json_schema" || textRequest.ResponseFormat.Type == "json_object") {
 		geminiRequest.GenerationConfig.ResponseMimeType = "application/json"
 
-		if textRequest.ResponseFormat.JsonSchema != nil && textRequest.ResponseFormat.JsonSchema.Schema != nil {
-			cleanedSchema := removeAdditionalPropertiesWithDepth(textRequest.ResponseFormat.JsonSchema.Schema, 0)
-			geminiRequest.GenerationConfig.ResponseSchema = cleanedSchema
+		if len(textRequest.ResponseFormat.JsonSchema) > 0 {
+			// 先将json.RawMessage解析
+			var jsonSchema dto.FormatJsonSchema
+			if err := common.Unmarshal(textRequest.ResponseFormat.JsonSchema, &jsonSchema); err == nil {
+				cleanedSchema := removeAdditionalPropertiesWithDepth(jsonSchema.Schema, 0)
+				geminiRequest.GenerationConfig.ResponseSchema = cleanedSchema
+			}
 		}
 	}
 	tool_call_ids := make(map[string]string)
@@ -732,7 +737,7 @@ func streamResponseGeminiChat2OpenAI(geminiResponse *GeminiChatResponse) (*dto.C
 		choice := dto.ChatCompletionsStreamResponseChoice{
 			Index: int(candidate.Index),
 			Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
-				Role: "assistant",
+				//Role: "assistant",
 			},
 		}
 		var texts []string
@@ -794,6 +799,27 @@ func streamResponseGeminiChat2OpenAI(geminiResponse *GeminiChatResponse) (*dto.C
 	return &response, isStop, hasImage
 }
 
+func handleStream(c *gin.Context, info *relaycommon.RelayInfo, resp *dto.ChatCompletionsStreamResponse) error {
+	streamData, err := common.Marshal(resp)
+	if err != nil {
+		return fmt.Errorf("failed to marshal stream response: %w", err)
+	}
+	err = openai.HandleStreamFormat(c, info, string(streamData), info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent)
+	if err != nil {
+		return fmt.Errorf("failed to handle stream format: %w", err)
+	}
+	return nil
+}
+
+func handleFinalStream(c *gin.Context, info *relaycommon.RelayInfo, resp *dto.ChatCompletionsStreamResponse) error {
+	streamData, err := common.Marshal(resp)
+	if err != nil {
+		return fmt.Errorf("failed to marshal stream response: %w", err)
+	}
+	openai.HandleFinalResponse(c, info, string(streamData), resp.Id, resp.Created, resp.Model, resp.GetSystemFingerprint(), resp.Usage, info.ShouldIncludeUsage)
+	return nil
+}
+
 func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
 	// responseText := ""
 	id := helper.GetResponseID(c)
@@ -801,6 +827,8 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
 	var usage = &dto.Usage{}
 	var imageCount int
 
+	respCount := 0
+
 	helper.StreamScannerHandler(c, resp, info, func(data string) bool {
 		var geminiResponse GeminiChatResponse
 		err := common.UnmarshalJsonStr(data, &geminiResponse)
@@ -829,18 +857,31 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
 				}
 			}
 		}
-		err = helper.ObjectData(c, response)
+
+		if respCount == 0 {
+			// send first response
+			err = handleStream(c, info, helper.GenerateStartEmptyResponse(id, createAt, info.UpstreamModelName, nil))
+			if err != nil {
+				common.LogError(c, err.Error())
+			}
+		}
+
+		err = handleStream(c, info, response)
 		if err != nil {
 			common.LogError(c, err.Error())
 		}
 		if isStop {
-			response := helper.GenerateStopResponse(id, createAt, info.UpstreamModelName, constant.FinishReasonStop)
-			helper.ObjectData(c, response)
+			_ = handleStream(c, info, helper.GenerateStopResponse(id, createAt, info.UpstreamModelName, constant.FinishReasonStop))
 		}
+		respCount++
 		return true
 	})
 
-	var response *dto.ChatCompletionsStreamResponse
+	if respCount == 0 {
+		// 空补全,报错不计费
+		// empty response, throw an error
+		return nil, types.NewOpenAIError(errors.New("no response received from Gemini API"), types.ErrorCodeEmptyResponse, http.StatusInternalServerError)
+	}
 
 	if imageCount != 0 {
 		if usage.CompletionTokens == 0 {
@@ -851,14 +892,14 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
 	usage.PromptTokensDetails.TextTokens = usage.PromptTokens
 	usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
 
-	if info.ShouldIncludeUsage {
-		response = helper.GenerateFinalUsageResponse(id, createAt, info.UpstreamModelName, *usage)
-		err := helper.ObjectData(c, response)
-		if err != nil {
-			common.SysError("send final response failed: " + err.Error())
-		}
+	response := helper.GenerateFinalUsageResponse(id, createAt, info.UpstreamModelName, *usage)
+	err := handleFinalStream(c, info, response)
+	if err != nil {
+		common.SysError("send final response failed: " + err.Error())
 	}
-	helper.Done(c)
+	//if info.RelayFormat == relaycommon.RelayFormatOpenAI {
+	//	helper.Done(c)
+	//}
 	//resp.Body.Close()
 	return usage, nil
 }

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

@@ -14,7 +14,7 @@ import (
 )
 
 // 辅助函数
-func handleStreamFormat(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error {
+func HandleStreamFormat(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error {
 	info.SendResponseCount++
 	switch info.RelayFormat {
 	case relaycommon.RelayFormatOpenAI:
@@ -158,7 +158,7 @@ func handleLastResponse(lastStreamData string, responseId *string, createAt *int
 	return nil
 }
 
-func handleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStreamData string,
+func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStreamData string,
 	responseId string, createAt int64, model string, systemFingerprint string,
 	usage *dto.Usage, containStreamUsage bool) {
 

+ 4 - 17
relay/channel/openai/relay-openai.go

@@ -123,24 +123,11 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
 	var toolCount int
 	var usage = &dto.Usage{}
 	var streamItems []string // store stream items
-	var forceFormat bool
-	var thinkToContent bool
-
-	if info.ChannelSetting.ForceFormat {
-		forceFormat = true
-	}
-
-	if info.ChannelSetting.ThinkingToContent {
-		thinkToContent = true
-	}
-
-	var (
-		lastStreamData string
-	)
+	var lastStreamData string
 
 	helper.StreamScannerHandler(c, resp, info, func(data string) bool {
 		if lastStreamData != "" {
-			err := handleStreamFormat(c, info, lastStreamData, forceFormat, thinkToContent)
+			err := HandleStreamFormat(c, info, lastStreamData, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent)
 			if err != nil {
 				common.SysError("error handling stream format: " + err.Error())
 			}
@@ -161,7 +148,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
 
 	if info.RelayFormat == relaycommon.RelayFormatOpenAI {
 		if shouldSendLastResp {
-			_ = sendStreamData(c, info, lastStreamData, forceFormat, thinkToContent)
+			_ = sendStreamData(c, info, lastStreamData, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent)
 		}
 	}
 
@@ -180,7 +167,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
 			}
 		}
 	}
-	handleFinalResponse(c, info, lastStreamData, responseId, createAt, model, systemFingerprint, usage, containStreamUsage)
+	HandleFinalResponse(c, info, lastStreamData, responseId, createAt, model, systemFingerprint, usage, containStreamUsage)
 
 	return usage, nil
 }

+ 34 - 12
relay/claude_handler.go

@@ -80,7 +80,6 @@ func ClaudeHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 		return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType)
 	}
 	adaptor.Init(relayInfo)
-	var requestBody io.Reader
 
 	if textRequest.MaxTokens == 0 {
 		textRequest.MaxTokens = uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(textRequest.Model))
@@ -108,18 +107,41 @@ func ClaudeHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 		relayInfo.UpstreamModelName = textRequest.Model
 	}
 
-	convertedRequest, err := adaptor.ConvertClaudeRequest(c, relayInfo, textRequest)
-	if err != nil {
-		return types.NewError(err, types.ErrorCodeConvertRequestFailed)
-	}
-	jsonData, err := common.Marshal(convertedRequest)
-	if common.DebugEnabled {
-		println("requestBody: ", string(jsonData))
-	}
-	if err != nil {
-		return types.NewError(err, types.ErrorCodeConvertRequestFailed)
+	var requestBody io.Reader
+	if model_setting.GetGlobalSettings().PassThroughRequestEnabled || relayInfo.ChannelSetting.PassThroughBodyEnabled {
+		body, err := common.GetRequestBody(c)
+		if err != nil {
+			return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest)
+		}
+		requestBody = bytes.NewBuffer(body)
+	} else {
+		convertedRequest, err := adaptor.ConvertClaudeRequest(c, relayInfo, textRequest)
+		if err != nil {
+			return types.NewError(err, types.ErrorCodeConvertRequestFailed)
+		}
+		jsonData, err := common.Marshal(convertedRequest)
+		if err != nil {
+			return types.NewError(err, types.ErrorCodeConvertRequestFailed)
+		}
+
+		// apply param override
+		if len(relayInfo.ParamOverride) > 0 {
+			reqMap := make(map[string]interface{})
+			_ = common.Unmarshal(jsonData, &reqMap)
+			for key, value := range relayInfo.ParamOverride {
+				reqMap[key] = value
+			}
+			jsonData, err = common.Marshal(reqMap)
+			if err != nil {
+				return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid)
+			}
+		}
+
+		if common.DebugEnabled {
+			println("requestBody: ", string(jsonData))
+		}
+		requestBody = bytes.NewBuffer(jsonData)
 	}
-	requestBody = bytes.NewBuffer(jsonData)
 
 	statusCodeMappingStr := c.GetString("status_code_mapping")
 	var httpResp *http.Response

+ 31 - 7
relay/gemini_handler.go

@@ -5,6 +5,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"io"
 	"net/http"
 	"one-api/common"
 	"one-api/dto"
@@ -194,16 +195,39 @@ func GeminiHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 		}
 	}
 
-	requestBody, err := json.Marshal(req)
-	if err != nil {
-		return types.NewError(err, types.ErrorCodeConvertRequestFailed)
-	}
+	var requestBody io.Reader
+	if model_setting.GetGlobalSettings().PassThroughRequestEnabled || relayInfo.ChannelSetting.PassThroughBodyEnabled {
+		body, err := common.GetRequestBody(c)
+		if err != nil {
+			return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest)
+		}
+		requestBody = bytes.NewReader(body)
+	} else {
+		jsonData, err := json.Marshal(req)
+		if err != nil {
+			return types.NewError(err, types.ErrorCodeConvertRequestFailed)
+		}
+
+		// apply param override
+		if len(relayInfo.ParamOverride) > 0 {
+			reqMap := make(map[string]interface{})
+			_ = common.Unmarshal(jsonData, &reqMap)
+			for key, value := range relayInfo.ParamOverride {
+				reqMap[key] = value
+			}
+			jsonData, err = common.Marshal(reqMap)
+			if err != nil {
+				return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid)
+			}
+		}
 
-	if common.DebugEnabled {
-		println("Gemini request body: %s", string(requestBody))
+		if common.DebugEnabled {
+			println("Gemini request body: %s", string(jsonData))
+		}
+		requestBody = bytes.NewReader(jsonData)
 	}
 
-	resp, err := adaptor.DoRequest(c, relayInfo, bytes.NewReader(requestBody))
+	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)

+ 18 - 0
relay/helper/common.go

@@ -139,6 +139,24 @@ func GetLocalRealtimeID(c *gin.Context) string {
 	return fmt.Sprintf("evt_%s", logID)
 }
 
+func GenerateStartEmptyResponse(id string, createAt int64, model string, systemFingerprint *string) *dto.ChatCompletionsStreamResponse {
+	return &dto.ChatCompletionsStreamResponse{
+		Id:                id,
+		Object:            "chat.completion.chunk",
+		Created:           createAt,
+		Model:             model,
+		SystemFingerprint: systemFingerprint,
+		Choices: []dto.ChatCompletionsStreamResponseChoice{
+			{
+				Delta: dto.ChatCompletionsStreamResponseChoiceDelta{
+					Role:    "assistant",
+					Content: common.GetPointer(""),
+				},
+			},
+		},
+	}
+}
+
 func GenerateStopResponse(id string, createAt int64, model string, finishReason string) *dto.ChatCompletionsStreamResponse {
 	return &dto.ChatCompletionsStreamResponse{
 		Id:                id,

+ 33 - 11
relay/image_handler.go

@@ -16,6 +16,7 @@ import (
 	"one-api/relay/helper"
 	"one-api/service"
 	"one-api/setting"
+	"one-api/setting/model_setting"
 	"one-api/types"
 	"strings"
 
@@ -187,22 +188,43 @@ func ImageHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 
 	var requestBody io.Reader
 
-	convertedRequest, err := adaptor.ConvertImageRequest(c, relayInfo, *imageRequest)
-	if err != nil {
-		return types.NewError(err, types.ErrorCodeConvertRequestFailed)
-	}
-	if relayInfo.RelayMode == relayconstant.RelayModeImagesEdits {
-		requestBody = convertedRequest.(io.Reader)
+	if model_setting.GetGlobalSettings().PassThroughRequestEnabled || relayInfo.ChannelSetting.PassThroughBodyEnabled {
+		body, err := common.GetRequestBody(c)
+		if err != nil {
+			return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest)
+		}
+		requestBody = bytes.NewBuffer(body)
 	} else {
-		jsonData, err := json.Marshal(convertedRequest)
+		convertedRequest, err := adaptor.ConvertImageRequest(c, relayInfo, *imageRequest)
 		if err != nil {
 			return types.NewError(err, types.ErrorCodeConvertRequestFailed)
 		}
-		requestBody = bytes.NewBuffer(jsonData)
-	}
+		if relayInfo.RelayMode == relayconstant.RelayModeImagesEdits {
+			requestBody = convertedRequest.(io.Reader)
+		} else {
+			jsonData, err := json.Marshal(convertedRequest)
+			if err != nil {
+				return types.NewError(err, types.ErrorCodeConvertRequestFailed)
+			}
 
-	if common.DebugEnabled {
-		println(fmt.Sprintf("image request body: %s", requestBody))
+			// apply param override
+			if len(relayInfo.ParamOverride) > 0 {
+				reqMap := make(map[string]interface{})
+				_ = common.Unmarshal(jsonData, &reqMap)
+				for key, value := range relayInfo.ParamOverride {
+					reqMap[key] = value
+				}
+				jsonData, err = common.Marshal(reqMap)
+				if err != nil {
+					return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid)
+				}
+			}
+
+			if common.DebugEnabled {
+				println(fmt.Sprintf("image request body: %s", string(jsonData)))
+			}
+			requestBody = bytes.NewBuffer(jsonData)
+		}
 	}
 
 	statusCodeMappingStr := c.GetString("status_code_mapping")

+ 26 - 3
relay/relay-text.go

@@ -2,7 +2,6 @@ package relay
 
 import (
 	"bytes"
-	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
@@ -171,18 +170,42 @@ func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) {
 	adaptor.Init(relayInfo)
 	var requestBody io.Reader
 
-	if model_setting.GetGlobalSettings().PassThroughRequestEnabled {
+	if model_setting.GetGlobalSettings().PassThroughRequestEnabled || relayInfo.ChannelSetting.PassThroughBodyEnabled {
 		body, err := common.GetRequestBody(c)
 		if err != nil {
 			return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest)
 		}
+		if common.DebugEnabled {
+			println("requestBody: ", string(body))
+		}
 		requestBody = bytes.NewBuffer(body)
 	} else {
 		convertedRequest, err := adaptor.ConvertOpenAIRequest(c, relayInfo, textRequest)
 		if err != nil {
 			return types.NewError(err, types.ErrorCodeConvertRequestFailed)
 		}
-		jsonData, err := json.Marshal(convertedRequest)
+
+		if relayInfo.ChannelSetting.SystemPrompt != "" {
+			// 如果有系统提示,则将其添加到请求中
+			request := convertedRequest.(*dto.GeneralOpenAIRequest)
+			containSystemPrompt := false
+			for _, message := range request.Messages {
+				if message.Role == request.GetSystemRoleName() {
+					containSystemPrompt = true
+					break
+				}
+			}
+			if !containSystemPrompt {
+				// 如果没有系统提示,则添加系统提示
+				systemMessage := dto.Message{
+					Role:    request.GetSystemRoleName(),
+					Content: relayInfo.ChannelSetting.SystemPrompt,
+				}
+				request.Messages = append([]dto.Message{systemMessage}, request.Messages...)
+			}
+		}
+
+		jsonData, err := common.Marshal(convertedRequest)
 		if err != nil {
 			return types.NewError(err, types.ErrorCodeConvertRequestFailed)
 		}

+ 37 - 11
relay/rerank_handler.go

@@ -3,12 +3,14 @@ package relay
 import (
 	"bytes"
 	"fmt"
+	"io"
 	"net/http"
 	"one-api/common"
 	"one-api/dto"
 	relaycommon "one-api/relay/common"
 	"one-api/relay/helper"
 	"one-api/service"
+	"one-api/setting/model_setting"
 	"one-api/types"
 
 	"github.com/gin-gonic/gin"
@@ -70,18 +72,42 @@ func RerankHelper(c *gin.Context, relayMode int) (newAPIError *types.NewAPIError
 	}
 	adaptor.Init(relayInfo)
 
-	convertedRequest, err := adaptor.ConvertRerankRequest(c, relayInfo.RelayMode, *rerankRequest)
-	if err != nil {
-		return types.NewError(err, types.ErrorCodeConvertRequestFailed)
-	}
-	jsonData, err := common.Marshal(convertedRequest)
-	if err != nil {
-		return types.NewError(err, types.ErrorCodeConvertRequestFailed)
-	}
-	requestBody := bytes.NewBuffer(jsonData)
-	if common.DebugEnabled {
-		println(fmt.Sprintf("Rerank request body: %s", requestBody.String()))
+	var requestBody io.Reader
+	if model_setting.GetGlobalSettings().PassThroughRequestEnabled || relayInfo.ChannelSetting.PassThroughBodyEnabled {
+		body, err := common.GetRequestBody(c)
+		if err != nil {
+			return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest)
+		}
+		requestBody = bytes.NewBuffer(body)
+	} else {
+		convertedRequest, err := adaptor.ConvertRerankRequest(c, relayInfo.RelayMode, *rerankRequest)
+		if err != nil {
+			return types.NewError(err, types.ErrorCodeConvertRequestFailed)
+		}
+		jsonData, err := common.Marshal(convertedRequest)
+		if err != nil {
+			return types.NewError(err, types.ErrorCodeConvertRequestFailed)
+		}
+
+		// apply param override
+		if len(relayInfo.ParamOverride) > 0 {
+			reqMap := make(map[string]interface{})
+			_ = common.Unmarshal(jsonData, &reqMap)
+			for key, value := range relayInfo.ParamOverride {
+				reqMap[key] = value
+			}
+			jsonData, err = common.Marshal(reqMap)
+			if err != nil {
+				return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid)
+			}
+		}
+
+		if common.DebugEnabled {
+			println(fmt.Sprintf("Rerank request body: %s", string(jsonData)))
+		}
+		requestBody = bytes.NewBuffer(jsonData)
 	}
+
 	resp, err := adaptor.DoRequest(c, relayInfo, requestBody)
 	if err != nil {
 		return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError)

+ 8 - 0
types/error.go

@@ -28,6 +28,7 @@ const (
 	ErrorTypeMidjourneyError ErrorType = "midjourney_error"
 	ErrorTypeGeminiError     ErrorType = "gemini_error"
 	ErrorTypeRerankError     ErrorType = "rerank_error"
+	ErrorTypeUpstreamError   ErrorType = "upstream_error"
 )
 
 type ErrorCode string
@@ -62,6 +63,7 @@ const (
 	ErrorCodeBadResponseStatusCode  ErrorCode = "bad_response_status_code"
 	ErrorCodeBadResponse            ErrorCode = "bad_response"
 	ErrorCodeBadResponseBody        ErrorCode = "bad_response_body"
+	ErrorCodeEmptyResponse          ErrorCode = "empty_response"
 
 	// sql error
 	ErrorCodeQueryDataError  ErrorCode = "query_data_error"
@@ -194,6 +196,9 @@ func WithOpenAIError(openAIError OpenAIError, statusCode int) *NewAPIError {
 	if !ok {
 		code = fmt.Sprintf("%v", openAIError.Code)
 	}
+	if openAIError.Type == "" {
+		openAIError.Type = "upstream_error"
+	}
 	return &NewAPIError{
 		RelayError: openAIError,
 		errorType:  ErrorTypeOpenAIError,
@@ -204,6 +209,9 @@ func WithOpenAIError(openAIError OpenAIError, statusCode int) *NewAPIError {
 }
 
 func WithClaudeError(claudeError ClaudeError, statusCode int) *NewAPIError {
+	if claudeError.Type == "" {
+		claudeError.Type = "upstream_error"
+	}
 	return &NewAPIError{
 		RelayError: claudeError,
 		errorType:  ErrorTypeClaudeError,

+ 186 - 20
web/src/components/table/channels/modals/EditChannelModal.jsx

@@ -121,6 +121,12 @@ const EditChannelModal = (props) => {
     weight: 0,
     tag: '',
     multi_key_mode: 'random',
+    // 渠道额外设置的默认值
+    force_format: false,
+    thinking_to_content: false,
+    proxy: '',
+    pass_through_body_enabled: false,
+    system_prompt: '',
   };
   const [batch, setBatch] = useState(false);
   const [multiToSingle, setMultiToSingle] = useState(false);
@@ -142,8 +148,69 @@ const EditChannelModal = (props) => {
   const [isMultiKeyChannel, setIsMultiKeyChannel] = useState(false);
   const [channelSearchValue, setChannelSearchValue] = useState('');
   const [useManualInput, setUseManualInput] = useState(false); // 是否使用手动输入模式
+  // 渠道额外设置状态
+  const [channelSettings, setChannelSettings] = useState({
+    force_format: false,
+    thinking_to_content: false,
+    proxy: '',
+    pass_through_body_enabled: false,
+    system_prompt: '',
+  });
   const showApiConfigCard = inputs.type !== 45;  // 控制是否显示 API 配置卡片(仅当渠道类型不是 豆包 时显示)
   const getInitValues = () => ({ ...originInputs });
+
+  // 处理渠道额外设置的更新
+  const handleChannelSettingsChange = (key, value) => {
+    // 更新内部状态
+    setChannelSettings(prev => ({ ...prev, [key]: value }));
+
+    // 同步更新到表单字段
+    if (formApiRef.current) {
+      formApiRef.current.setValue(key, value);
+    }
+
+    // 同步更新inputs状态
+    setInputs(prev => ({ ...prev, [key]: value }));
+
+    // 生成setting JSON并更新
+    const newSettings = { ...channelSettings, [key]: value };
+    const settingsJson = JSON.stringify(newSettings);
+    handleInputChange('setting', settingsJson);
+  };
+
+  // 解析渠道设置JSON为单独的状态
+  const parseChannelSettings = (settingJson) => {
+    try {
+      if (settingJson && settingJson.trim()) {
+        const parsed = JSON.parse(settingJson);
+        setChannelSettings({
+          force_format: parsed.force_format || false,
+          thinking_to_content: parsed.thinking_to_content || false,
+          proxy: parsed.proxy || '',
+          pass_through_body_enabled: parsed.pass_through_body_enabled || false,
+          system_prompt: parsed.system_prompt || '',
+        });
+      } else {
+        setChannelSettings({
+          force_format: false,
+          thinking_to_content: false,
+          proxy: '',
+          pass_through_body_enabled: false,
+          system_prompt: '',
+        });
+      }
+    } catch (error) {
+      console.error('解析渠道设置失败:', error);
+      setChannelSettings({
+        force_format: false,
+        thinking_to_content: false,
+        proxy: '',
+        pass_through_body_enabled: false,
+        system_prompt: '',
+      });
+    }
+  };
+
   const handleInputChange = (name, value) => {
     if (formApiRef.current) {
       formApiRef.current.setValue(name, value);
@@ -256,6 +323,30 @@ const EditChannelModal = (props) => {
         setBatch(false);
         setMultiToSingle(false);
       }
+      // 解析渠道额外设置并合并到data中
+      if (data.setting) {
+        try {
+          const parsedSettings = JSON.parse(data.setting);
+          data.force_format = parsedSettings.force_format || false;
+          data.thinking_to_content = parsedSettings.thinking_to_content || false;
+          data.proxy = parsedSettings.proxy || '';
+          data.pass_through_body_enabled = parsedSettings.pass_through_body_enabled || false;
+          data.system_prompt = parsedSettings.system_prompt || '';
+        } catch (error) {
+          console.error('解析渠道设置失败:', error);
+          data.force_format = false;
+          data.thinking_to_content = false;
+          data.proxy = '';
+          data.pass_through_body_enabled = false;
+        }
+      } else {
+        data.force_format = false;
+        data.thinking_to_content = false;
+        data.proxy = '';
+        data.pass_through_body_enabled = false;
+        data.system_prompt = '';
+      }
+
       setInputs(data);
       if (formApiRef.current) {
         formApiRef.current.setValues(data);
@@ -266,6 +357,14 @@ const EditChannelModal = (props) => {
         setAutoBan(true);
       }
       setBasicModels(getChannelModels(data.type));
+      // 同步更新channelSettings状态显示
+      setChannelSettings({
+        force_format: data.force_format,
+        thinking_to_content: data.thinking_to_content,
+        proxy: data.proxy,
+        pass_through_body_enabled: data.pass_through_body_enabled,
+        system_prompt: data.system_prompt,
+      });
       // console.log(data);
     } else {
       showError(message);
@@ -446,6 +545,14 @@ const EditChannelModal = (props) => {
       setUseManualInput(false);
     } else {
       formApiRef.current?.reset();
+      // 重置渠道设置状态
+      setChannelSettings({
+        force_format: false,
+        thinking_to_content: false,
+        proxy: '',
+        pass_through_body_enabled: false,
+        system_prompt: '',
+      });
     }
   }, [props.visible, channelId]);
 
@@ -579,6 +686,24 @@ const EditChannelModal = (props) => {
     if (localInputs.type === 18 && localInputs.other === '') {
       localInputs.other = 'v2.1';
     }
+
+    // 生成渠道额外设置JSON
+    const channelExtraSettings = {
+      force_format: localInputs.force_format || false,
+      thinking_to_content: localInputs.thinking_to_content || false,
+      proxy: localInputs.proxy || '',
+      pass_through_body_enabled: localInputs.pass_through_body_enabled || false,
+      system_prompt: localInputs.system_prompt || '',
+    };
+    localInputs.setting = JSON.stringify(channelExtraSettings);
+
+    // 清理不需要发送到后端的字段
+    delete localInputs.force_format;
+    delete localInputs.thinking_to_content;
+    delete localInputs.proxy;
+    delete localInputs.pass_through_body_enabled;
+    delete localInputs.system_prompt;
+
     let res;
     localInputs.auto_ban = localInputs.auto_ban ? 1 : 0;
     localInputs.models = localInputs.models.join(',');
@@ -1400,7 +1525,7 @@ const EditChannelModal = (props) => {
                     label={t('是否自动禁用')}
                     checkedText={t('开')}
                     uncheckedText={t('关')}
-                    onChange={(val) => setAutoBan(val)}
+                    onChange={(value) => setAutoBan(value)}
                     extraText={t('仅当自动禁用开启时有效,关闭后不会自动禁用该渠道')}
                     initValue={autoBan}
                   />
@@ -1445,33 +1570,74 @@ const EditChannelModal = (props) => {
                     }
                     showClear
                   />
+                </Card>
 
-                  <Form.TextArea
-                    field='setting'
-                    label={t('渠道额外设置')}
-                    placeholder={
-                      t('此项可选,用于配置渠道特定设置,为一个 JSON 字符串,例如:') +
-                      '\n{\n  "force_format": true\n}'
-                    }
-                    autosize
-                    onChange={(value) => handleInputChange('setting', value)}
-                    extraText={(
-                      <Space wrap>
-                        <Text
-                          className="!text-semi-color-primary cursor-pointer"
-                          onClick={() => handleInputChange('setting', JSON.stringify({ force_format: true }, null, 2))}
-                        >
-                          {t('填入模板')}
-                        </Text>
+                {/* Channel Extra Settings Card */}
+                <Card className="!rounded-2xl shadow-sm border-0 mb-6">
+                  {/* Header: Channel Extra Settings */}
+                  <div className="flex items-center mb-2">
+                    <Avatar size="small" color="violet" className="mr-2 shadow-md">
+                      <IconBolt size={16} />
+                    </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>
-                      </Space>
-                    )}
+                      </div>
+                    </div>
+                  </div>
+
+                  {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'
+                    label={t('思考内容转换')}
+                    checkedText={t('开')}
+                    uncheckedText={t('关')}
+                    onChange={(value) => handleChannelSettingsChange('thinking_to_content', value)}
+                    extraText={t('将 reasoning_content 转换为 <think> 标签拼接到内容中')}
+                  />
+
+                  <Form.Switch
+                    field='pass_through_body_enabled'
+                    label={t('透传请求体')}
+                    checkedText={t('开')}
+                    uncheckedText={t('关')}
+                    onChange={(value) => handleChannelSettingsChange('pass_through_body_enabled', value)}
+                    extraText={t('启用请求体透传功能')}
+                  />
+
+                  <Form.Input
+                    field='proxy'
+                    label={t('代理地址')}
+                    placeholder={t('例如: socks5://user:pass@host:port')}
+                    onChange={(value) => handleChannelSettingsChange('proxy', value)}
+                    showClear
+                    extraText={t('用于配置网络代理,支持 socks5 协议')}
+                  />
+
+                  <Form.TextArea
+                    field='system_prompt'
+                    label={t('系统提示词')}
+                    placeholder={t('输入系统提示词,用户的系统提示词将优先于此设置')}
+                    onChange={(value) => handleChannelSettingsChange('system_prompt', value)}
+                    autosize
                     showClear
+                    extraText={t('用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置')}
                   />
                 </Card>
               </div>

+ 12 - 0
web/src/i18n/locales/en.json

@@ -1330,6 +1330,18 @@
   "API地址": "Base URL",
   "对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "For official channels, the new-api has a built-in address. Unless it is a third-party proxy site or a special Azure access address, there is no need to fill it in",
   "渠道额外设置": "Channel extra settings",
+  "强制格式化": "Force format",
+  "强制将响应格式化为 OpenAI 标准格式(只适用于OpenAI渠道类型)": "Force format responses to OpenAI standard format (Only for OpenAI channel types)",
+  "思考内容转换": "Thinking content conversion",
+  "将 reasoning_content 转换为 <think> 标签拼接到内容中": "Convert reasoning_content to <think> tags and append to content",
+  "透传请求体": "Pass through body",
+  "启用请求体透传功能": "Enable request body pass-through functionality",
+  "代理地址": "Proxy address",
+  "例如: socks5://user:pass@host:port": "e.g.: socks5://user:pass@host:port",
+  "用于配置网络代理,支持 socks5 协议": "Used to configure network proxy, supports socks5 protocol",
+  "系统提示词": "System Prompt",
+  "输入系统提示词,用户的系统提示词将优先于此设置": "Enter system prompt, user's system prompt will take priority over this setting",
+  "用户优先:如果用户在请求中指定了系统提示词,将优先使用用户的设置": "User priority: If the user specifies a system prompt in the request, the user's setting will be used first",
   "参数覆盖": "Parameters override",
   "模型请求速率限制": "Model request rate limit",
   "启用用户模型请求速率限制(可能会影响高并发性能)": "Enable user model request rate limit (may affect high concurrency performance)",