diff --git a/.env.example b/.env.example index ea246427..72645404 100644 --- a/.env.example +++ b/.env.example @@ -47,7 +47,7 @@ # 所有请求超时时间,单位秒,默认为0,表示不限制 # RELAY_TIMEOUT=0 # 流模式无响应超时时间,单位秒,如果出现空补全可以尝试改为更大值 -# STREAMING_TIMEOUT=120 +# STREAMING_TIMEOUT=300 # Gemini 识别图片 最大图片数量 # GEMINI_VISION_MAX_IMAGE_NUM=16 diff --git a/README.en.md b/README.en.md index df7f1cbc..a88b9a40 100644 --- a/README.en.md +++ b/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` diff --git a/README.md b/README.md index 4060715c..1b337eae 100644 --- a/README.md +++ b/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` diff --git a/common/init.go b/common/init.go index d70a09dd..c4626f9a 100644 --- a/common/init.go +++ b/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信息 diff --git a/controller/relay.go b/controller/relay.go index c97eca20..d235f550 100644 --- a/controller/relay.go +++ b/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 { diff --git a/docker-compose.yml b/docker-compose.yml index 57ad0b30..d98fd706 100644 --- a/docker-compose.yml +++ b/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 diff --git a/dto/dalle.go b/dto/dalle.go index ce2f6361..d1e66de9 100644 --- a/dto/dalle.go +++ b/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 { diff --git a/middleware/auth.go b/middleware/auth.go index ee8d9241..5f682eb6 100644 --- a/middleware/auth.go +++ b/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") || diff --git a/middleware/distributor.go b/middleware/distributor.go index 751f58ef..286a4d1f 100644 --- a/middleware/distributor.go +++ b/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 diff --git a/middleware/jimeng_adapter.go b/middleware/jimeng_adapter.go new file mode 100644 index 00000000..ce5e1467 --- /dev/null +++ b/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() + } +} diff --git a/model/main.go b/model/main.go index d6cd38aa..dbf27152 100644 --- a/model/main.go +++ b/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 } diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index 4c7ba60e..fc1749a0 100644 --- a/relay/channel/openai/adaptor.go +++ b/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 + // 使用已解析的 multipart 表单,避免重复解析 + mf := c.Request.MultipartForm + if mf == nil { + if _, err := c.MultipartForm(); err != nil { + return nil, errors.New("failed to parse multipart form") } - for _, value := range values { - writer.WriteField(key, value) + mf = c.Request.MultipartForm + } + + // 写入所有非文件字段 + if mf != nil { + for key, values := range mf.Value { + if key == "model" { + continue + } + for _, value := range values { + writer.WriteField(key, value) + } } } - // 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 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 diff --git a/relay/helper/stream_scanner.go b/relay/helper/stream_scanner.go index df8c5072..a5706f95 100644 --- a/relay/helper/stream_scanner.go +++ b/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) // 增加缓冲区避免阻塞 diff --git a/relay/relay_task.go b/relay/relay_task.go index ce00527b..0ccc3b33 100644 --- a/relay/relay_task.go +++ b/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) diff --git a/router/video-router.go b/router/video-router.go index 0bd8cd83..bcc05eae 100644 --- a/router/video-router.go +++ b/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) + } }