Просмотр исходного кода

Merge remote-tracking branch 'origin/alpha' into alpha

t0ng7u 6 месяцев назад
Родитель
Сommit
0082b87f61

+ 1 - 1
.env.example

@@ -47,7 +47,7 @@
 # 所有请求超时时间,单位秒,默认为0,表示不限制
 # RELAY_TIMEOUT=0
 # 流模式无响应超时时间,单位秒,如果出现空补全可以尝试改为更大值
-# STREAMING_TIMEOUT=120
+# STREAMING_TIMEOUT=300
 
 # Gemini 识别图片 最大图片数量
 # GEMINI_VISION_MAX_IMAGE_NUM=16

+ 1 - 1
README.en.md

@@ -100,7 +100,7 @@ This version supports multiple models, please refer to [API Documentation-Relay
 For detailed configuration instructions, please refer to [Installation Guide-Environment Variables Configuration](https://docs.newapi.pro/installation/environment-variables):
 
 - `GENERATE_DEFAULT_TOKEN`: Whether to generate initial tokens for newly registered users, default is `false`
-- `STREAMING_TIMEOUT`: Streaming response timeout, default is 120 seconds
+- `STREAMING_TIMEOUT`: Streaming response timeout, default is 300 seconds
 - `DIFY_DEBUG`: Whether to output workflow and node information for Dify channels, default is `true`
 - `FORCE_STREAM_OPTION`: Whether to override client stream_options parameter, default is `true`
 - `GET_MEDIA_TOKEN`: Whether to count image tokens, default is `true`

+ 1 - 1
README.md

@@ -100,7 +100,7 @@ New API提供了丰富的功能,详细特性请参考[特性说明](https://do
 详细配置说明请参考[安装指南-环境变量配置](https://docs.newapi.pro/installation/environment-variables):
 
 - `GENERATE_DEFAULT_TOKEN`:是否为新注册用户生成初始令牌,默认为 `false`
-- `STREAMING_TIMEOUT`:流式回复超时时间,默认120秒
+- `STREAMING_TIMEOUT`:流式回复超时时间,默认300秒
 - `DIFY_DEBUG`:Dify渠道是否输出工作流和节点信息,默认 `true`
 - `FORCE_STREAM_OPTION`:是否覆盖客户端stream_options参数,默认 `true`
 - `GET_MEDIA_TOKEN`:是否统计图片token,默认 `true`

+ 1 - 1
common/init.go

@@ -101,7 +101,7 @@ func InitEnv() {
 }
 
 func initConstantEnv() {
-	constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 120)
+	constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 300)
 	constant.DifyDebug = GetEnvOrDefaultBool("DIFY_DEBUG", true)
 	constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 20)
 	// ForceStreamOption 覆盖请求参数,强制返回usage信息

+ 0 - 4
controller/relay.go

@@ -312,10 +312,6 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
 		return true
 	}
 	if openaiErr.StatusCode == http.StatusBadRequest {
-		channelType := c.GetInt("channel_type")
-		if channelType == constant.ChannelTypeAnthropic {
-			return true
-		}
 		return false
 	}
 	if openaiErr.StatusCode == 408 {

+ 1 - 1
docker-compose.yml

@@ -16,7 +16,7 @@ services:
       - REDIS_CONN_STRING=redis://redis
       - TZ=Asia/Shanghai
       - ERROR_LOG_ENABLED=true # 是否启用错误日志记录
-    #      - STREAMING_TIMEOUT=120  # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值
+    #      - STREAMING_TIMEOUT=300  # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值
     #      - SESSION_SECRET=random_string  # 多机部署时设置,必须修改这个随机字符串!!!!!!!
     #      - NODE_TYPE=slave  # Uncomment for slave node in multi-node deployment
     #      - SYNC_FREQUENCY=60  # Uncomment if regular database syncing is needed

+ 16 - 13
dto/dalle.go

@@ -3,19 +3,22 @@ package dto
 import "encoding/json"
 
 type ImageRequest struct {
-	Model          string          `json:"model"`
-	Prompt         string          `json:"prompt" binding:"required"`
-	N              int             `json:"n,omitempty"`
-	Size           string          `json:"size,omitempty"`
-	Quality        string          `json:"quality,omitempty"`
-	ResponseFormat string          `json:"response_format,omitempty"`
-	Style          string          `json:"style,omitempty"`
-	User           string          `json:"user,omitempty"`
-	ExtraFields    json.RawMessage `json:"extra_fields,omitempty"`
-	Background     string          `json:"background,omitempty"`
-	Moderation     string          `json:"moderation,omitempty"`
-	OutputFormat   string          `json:"output_format,omitempty"`
-	Watermark      *bool           `json:"watermark,omitempty"`
+	Model             string          `json:"model"`
+	Prompt            string          `json:"prompt" binding:"required"`
+	N                 int             `json:"n,omitempty"`
+	Size              string          `json:"size,omitempty"`
+	Quality           string          `json:"quality,omitempty"`
+	ResponseFormat    string          `json:"response_format,omitempty"`
+	Style             json.RawMessage `json:"style,omitempty"`
+	User              json.RawMessage `json:"user,omitempty"`
+	ExtraFields       json.RawMessage `json:"extra_fields,omitempty"`
+	Background        json.RawMessage `json:"background,omitempty"`
+	Moderation        json.RawMessage `json:"moderation,omitempty"`
+	OutputFormat      json.RawMessage `json:"output_format,omitempty"`
+	OutputCompression json.RawMessage `json:"output_compression,omitempty"`
+	PartialImages     json.RawMessage `json:"partial_images,omitempty"`
+	// Stream            bool            `json:"stream,omitempty"`
+	Watermark *bool `json:"watermark,omitempty"`
 }
 
 type ImageResponse struct {

+ 4 - 2
middleware/auth.go

@@ -197,8 +197,10 @@ func TokenAuth() func(c *gin.Context) {
 		// 或者是否 x-api-key 不为空且存在anthropic-version
 		// 谁知道有多少不符合规范没写anthropic-version的
 		// 所以就这样随它去吧(
-		if strings.Contains(c.Request.URL.Path, "/v1/messages") || (anthropicKey != "" && c.Request.Header.Get("anthropic-version") != "") {
-			c.Request.Header.Set("Authorization", "Bearer "+anthropicKey)
+		if strings.Contains(c.Request.URL.Path, "/v1/messages") {
+			if anthropicKey != "" {
+				c.Request.Header.Set("Authorization", "Bearer "+anthropicKey)
+			}
 		}
 		// gemini api 从query中获取key
 		if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models") ||

+ 3 - 1
middleware/distributor.go

@@ -174,7 +174,9 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
 			relayMode = relayconstant.RelayModeVideoFetchByID
 			shouldSelectChannel = false
 		}
-		c.Set("relay_mode", relayMode)
+		if _, ok := c.Get("relay_mode"); !ok {
+			c.Set("relay_mode", relayMode)
+		}
 	} else if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") || strings.HasPrefix(c.Request.URL.Path, "/v1/models/") {
 		// Gemini API 路径处理: /v1beta/models/gemini-2.0-flash:generateContent
 		relayMode := relayconstant.RelayModeGemini

+ 66 - 0
middleware/jimeng_adapter.go

@@ -0,0 +1,66 @@
+package middleware
+
+import (
+	"bytes"
+	"encoding/json"
+	"github.com/gin-gonic/gin"
+	"io"
+	"net/http"
+	"one-api/common"
+	"one-api/constant"
+	relayconstant "one-api/relay/constant"
+)
+
+func JimengRequestConvert() func(c *gin.Context) {
+	return func(c *gin.Context) {
+		action := c.Query("Action")
+		if action == "" {
+			abortWithOpenAiMessage(c, http.StatusBadRequest, "Action query parameter is required")
+			return
+		}
+
+		// Handle Jimeng official API request
+		var originalReq map[string]interface{}
+		if err := common.UnmarshalBodyReusable(c, &originalReq); err != nil {
+			abortWithOpenAiMessage(c, http.StatusBadRequest, "Invalid request body")
+			return
+		}
+		model, _ := originalReq["req_key"].(string)
+		prompt, _ := originalReq["prompt"].(string)
+
+		unifiedReq := map[string]interface{}{
+			"model":    model,
+			"prompt":   prompt,
+			"metadata": originalReq,
+		}
+
+		jsonData, err := json.Marshal(unifiedReq)
+		if err != nil {
+			abortWithOpenAiMessage(c, http.StatusInternalServerError, "Failed to marshal request body")
+			return
+		}
+
+		// Update request body
+		c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonData))
+		c.Set(common.KeyRequestBody, jsonData)
+
+		if image, ok := originalReq["image"]; !ok || image == "" {
+			c.Set("action", constant.TaskActionTextGenerate)
+		}
+
+		c.Request.URL.Path = "/v1/video/generations"
+
+		if action == "CVSync2AsyncGetResult" {
+			taskId, ok := originalReq["task_id"].(string)
+			if !ok || taskId == "" {
+				abortWithOpenAiMessage(c, http.StatusBadRequest, "task_id is required for CVSync2AsyncGetResult")
+				return
+			}
+			c.Request.URL.Path = "/v1/video/generations/" + taskId
+			c.Request.Method = http.MethodGet
+			c.Set("task_id", taskId)
+			c.Set("relay_mode", relayconstant.RelayModeVideoFetchByID)
+		}
+		c.Next()
+	}
+}

+ 55 - 12
model/main.go

@@ -389,12 +389,7 @@ func CloseDB() error {
 // default charset/collation can store Chinese characters. It allows common
 // Chinese-capable charsets (utf8mb4, utf8, gbk, big5, gb18030) and panics otherwise.
 func checkMySQLChineseSupport(db *gorm.DB) error {
-	// Read session/server variables
-	var charsetServer, collationServer, charsetDBVar, collationDBVar, charsetConn, collationConn string
-	row := db.Raw("SELECT @@character_set_server, @@collation_server, @@character_set_database, @@collation_database, @@character_set_connection, @@collation_connection").Row()
-	if err := row.Scan(&charsetServer, &collationServer, &charsetDBVar, &collationDBVar, &charsetConn, &collationConn); err != nil {
-		return fmt.Errorf("读取 MySQL 字符集变量失败 / Failed to read MySQL charset variables: %v", err)
-	}
+	// 仅检测:当前库默认字符集/排序规则 + 各表的排序规则(隐含字符集)
 
 	// Read current schema defaults
 	var schemaCharset, schemaCollation string
@@ -416,20 +411,68 @@ func checkMySQLChineseSupport(db *gorm.DB) error {
 		csLower := toLower(cs)
 		clLower := toLower(cl)
 		if prefix, ok := allowedCharsets[csLower]; ok {
-			// collation should correspond to the charset when available
 			if clLower == "" {
 				return true
 			}
 			return strings.HasPrefix(clLower, prefix)
 		}
+		// 如果仅提供了排序规则,尝试按排序规则前缀判断
+		for _, prefix := range allowedCharsets {
+			if strings.HasPrefix(clLower, prefix) {
+				return true
+			}
+		}
 		return false
 	}
 
-	// We strictly require the CONNECTION and SCHEMA defaults to be Chinese-capable.
-	// We also check database/server variables and include them in the error for visibility.
-	if !isChineseCapable(charsetConn, collationConn) || !isChineseCapable(schemaCharset, schemaCollation) || !isChineseCapable(charsetDBVar, collationDBVar) {
-		return fmt.Errorf("MySQL 字符集/排序规则必须支持中文(允许 utf8mb4/utf8/gbk/big5/gb18030),请调整服务器、数据库或连接设置。/ MySQL charset/collation must be Chinese-capable (one of utf8mb4/utf8/gbk/big5/gb18030). Details: server(%s/%s), database_var(%s/%s), connection(%s/%s), schema(%s/%s)",
-			charsetServer, collationServer, charsetDBVar, collationDBVar, charsetConn, collationConn, schemaCharset, schemaCollation)
+	// 1) 当前库默认值必须支持中文
+	if !isChineseCapable(schemaCharset, schemaCollation) {
+		return fmt.Errorf("当前库默认字符集/排序规则不支持中文:schema(%s/%s)。请将库设置为 utf8mb4/utf8/gbk/big5/gb18030 / Schema default charset/collation is not Chinese-capable: schema(%s/%s). Please set to utf8mb4/utf8/gbk/big5/gb18030",
+			schemaCharset, schemaCollation, schemaCharset, schemaCollation)
+	}
+
+	// 2) 所有物理表的排序规则(隐含字符集)必须支持中文
+	type tableInfo struct {
+		Name      string
+		Collation *string
+	}
+	var tables []tableInfo
+	if err := db.Raw("SELECT TABLE_NAME, TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'").Scan(&tables).Error; err != nil {
+		return fmt.Errorf("读取表排序规则失败 / Failed to read table collations: %v", err)
+	}
+
+	var badTables []string
+	for _, t := range tables {
+		// NULL 或空表示继承库默认设置,已在上面校验库默认,视为通过
+		if t.Collation == nil || *t.Collation == "" {
+			continue
+		}
+		cl := *t.Collation
+		// 仅凭排序规则判断是否中文可用
+		ok := false
+		lower := strings.ToLower(cl)
+		for _, prefix := range allowedCharsets {
+			if strings.HasPrefix(lower, prefix) {
+				ok = true
+				break
+			}
+		}
+		if !ok {
+			badTables = append(badTables, fmt.Sprintf("%s(%s)", t.Name, cl))
+		}
+	}
+
+	if len(badTables) > 0 {
+		// 限制输出数量以避免日志过长
+		maxShow := 20
+		shown := badTables
+		if len(shown) > maxShow {
+			shown = shown[:maxShow]
+		}
+		return fmt.Errorf(
+			"存在不支持中文的表,请修复其排序规则/字符集。示例(最多展示 %d 项):%v / Found tables not Chinese-capable. Please fix their collation/charset. Examples (showing up to %d): %v",
+			maxShow, shown, maxShow, shown,
+		)
 	}
 	return nil
 }

+ 28 - 23
relay/channel/openai/adaptor.go

@@ -359,40 +359,42 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
 		writer := multipart.NewWriter(&requestBody)
 
 		writer.WriteField("model", request.Model)
-		// 获取所有表单字段
-		formData := c.Request.PostForm
-		// 遍历表单字段并打印输出
-		for key, values := range formData {
-			if key == "model" {
-				continue
-			}
-			for _, value := range values {
-				writer.WriteField(key, value)
+		// 使用已解析的 multipart 表单,避免重复解析
+		mf := c.Request.MultipartForm
+		if mf == nil {
+			if _, err := c.MultipartForm(); err != nil {
+				return nil, errors.New("failed to parse multipart form")
 			}
+			mf = c.Request.MultipartForm
 		}
 
-		// Parse the multipart form to handle both single image and multiple images
-		if err := c.Request.ParseMultipartForm(32 << 20); err != nil { // 32MB max memory
-			return nil, errors.New("failed to parse multipart form")
+		// 写入所有非文件字段
+		if mf != nil {
+			for key, values := range mf.Value {
+				if key == "model" {
+					continue
+				}
+				for _, value := range values {
+					writer.WriteField(key, value)
+				}
+			}
 		}
 
-		if c.Request.MultipartForm != nil && c.Request.MultipartForm.File != nil {
+		if mf != nil && mf.File != nil {
 			// Check if "image" field exists in any form, including array notation
 			var imageFiles []*multipart.FileHeader
 			var exists bool
 
 			// First check for standard "image" field
-			if imageFiles, exists = c.Request.MultipartForm.File["image"]; !exists || len(imageFiles) == 0 {
+			if imageFiles, exists = mf.File["image"]; !exists || len(imageFiles) == 0 {
 				// If not found, check for "image[]" field
-				if imageFiles, exists = c.Request.MultipartForm.File["image[]"]; !exists || len(imageFiles) == 0 {
+				if imageFiles, exists = mf.File["image[]"]; !exists || len(imageFiles) == 0 {
 					// If still not found, iterate through all fields to find any that start with "image["
 					foundArrayImages := false
-					for fieldName, files := range c.Request.MultipartForm.File {
+					for fieldName, files := range mf.File {
 						if strings.HasPrefix(fieldName, "image[") && len(files) > 0 {
 							foundArrayImages = true
-							for _, file := range files {
-								imageFiles = append(imageFiles, file)
-							}
+							imageFiles = append(imageFiles, files...)
 						}
 					}
 
@@ -409,7 +411,6 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
 				if err != nil {
 					return nil, fmt.Errorf("failed to open image file %d: %w", i, err)
 				}
-				defer file.Close()
 
 				// If multiple images, use image[] as the field name
 				fieldName := "image"
@@ -433,15 +434,18 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
 				if _, err := io.Copy(part, file); err != nil {
 					return nil, fmt.Errorf("copy file failed for image %d: %w", i, err)
 				}
+
+				// 复制完立即关闭,避免在循环内使用 defer 占用资源
+				_ = file.Close()
 			}
 
 			// Handle mask file if present
-			if maskFiles, exists := c.Request.MultipartForm.File["mask"]; exists && len(maskFiles) > 0 {
+			if maskFiles, exists := mf.File["mask"]; exists && len(maskFiles) > 0 {
 				maskFile, err := maskFiles[0].Open()
 				if err != nil {
 					return nil, errors.New("failed to open mask file")
 				}
-				defer maskFile.Close()
+				// 复制完立即关闭,避免在循环内使用 defer 占用资源
 
 				// Determine MIME type for mask file
 				mimeType := detectImageMimeType(maskFiles[0].Filename)
@@ -459,6 +463,7 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
 				if _, err := io.Copy(maskPart, maskFile); err != nil {
 					return nil, errors.New("copy mask file failed")
 				}
+				_ = maskFile.Close()
 			}
 		} else {
 			return nil, errors.New("no multipart form data found")
@@ -467,7 +472,7 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
 		// 关闭 multipart 编写器以设置分界线
 		writer.Close()
 		c.Request.Header.Set("Content-Type", writer.FormDataContentType())
-		return bytes.NewReader(requestBody.Bytes()), nil
+		return &requestBody, nil
 
 	default:
 		return request, nil

+ 0 - 4
relay/helper/stream_scanner.go

@@ -39,10 +39,6 @@ func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon
 	}()
 
 	streamingTimeout := time.Duration(constant.StreamingTimeout) * time.Second
-	if strings.HasPrefix(info.UpstreamModelName, "o") {
-		// twice timeout for thinking model
-		streamingTimeout *= 2
-	}
 
 	var (
 		stopChan   = make(chan bool, 3) // 增加缓冲区避免阻塞

+ 3 - 0
relay/relay_task.go

@@ -258,6 +258,9 @@ func sunoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dt
 
 func videoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dto.TaskError) {
 	taskId := c.Param("task_id")
+	if taskId == "" {
+		taskId = c.GetString("task_id")
+	}
 	userId := c.GetInt("id")
 
 	originTask, exist, err := model.GetByTaskId(userId, taskId)

+ 8 - 0
router/video-router.go

@@ -23,4 +23,12 @@ func SetVideoRouter(router *gin.Engine) {
 		klingV1Router.GET("/videos/text2video/:task_id", controller.RelayTask)
 		klingV1Router.GET("/videos/image2video/:task_id", controller.RelayTask)
 	}
+
+	// Jimeng official API routes - direct mapping to official API format
+	jimengOfficialGroup := router.Group("jimeng")
+	jimengOfficialGroup.Use(middleware.JimengRequestConvert(), middleware.TokenAuth(), middleware.Distribute())
+	{
+		// Maps to: /?Action=CVSync2AsyncSubmitTask&Version=2022-08-31 and /?Action=CVSync2AsyncGetResult&Version=2022-08-31
+		jimengOfficialGroup.POST("/", controller.RelayTask)
+	}
 }