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

This commit is contained in:
t0ng7u
2025-08-12 23:11:55 +08:00
15 changed files with 189 additions and 65 deletions

View File

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

View File

@@ -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): 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` - `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` - `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` - `FORCE_STREAM_OPTION`: Whether to override client stream_options parameter, default is `true`
- `GET_MEDIA_TOKEN`: Whether to count image tokens, default is `true` - `GET_MEDIA_TOKEN`: Whether to count image tokens, default is `true`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -174,7 +174,9 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
relayMode = relayconstant.RelayModeVideoFetchByID relayMode = relayconstant.RelayModeVideoFetchByID
shouldSelectChannel = false 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/") { } 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 // Gemini API 路径处理: /v1beta/models/gemini-2.0-flash:generateContent
relayMode := relayconstant.RelayModeGemini relayMode := relayconstant.RelayModeGemini

View File

@@ -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()
}
}

View File

@@ -389,12 +389,7 @@ func CloseDB() error {
// default charset/collation can store Chinese characters. It allows common // default charset/collation can store Chinese characters. It allows common
// Chinese-capable charsets (utf8mb4, utf8, gbk, big5, gb18030) and panics otherwise. // Chinese-capable charsets (utf8mb4, utf8, gbk, big5, gb18030) and panics otherwise.
func checkMySQLChineseSupport(db *gorm.DB) error { 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 // Read current schema defaults
var schemaCharset, schemaCollation string var schemaCharset, schemaCollation string
@@ -416,20 +411,68 @@ func checkMySQLChineseSupport(db *gorm.DB) error {
csLower := toLower(cs) csLower := toLower(cs)
clLower := toLower(cl) clLower := toLower(cl)
if prefix, ok := allowedCharsets[csLower]; ok { if prefix, ok := allowedCharsets[csLower]; ok {
// collation should correspond to the charset when available
if clLower == "" { if clLower == "" {
return true return true
} }
return strings.HasPrefix(clLower, prefix) return strings.HasPrefix(clLower, prefix)
} }
// 如果仅提供了排序规则,尝试按排序规则前缀判断
for _, prefix := range allowedCharsets {
if strings.HasPrefix(clLower, prefix) {
return true
}
}
return false return false
} }
// We strictly require the CONNECTION and SCHEMA defaults to be Chinese-capable. // 1) 当前库默认值必须支持中文
// We also check database/server variables and include them in the error for visibility. if !isChineseCapable(schemaCharset, schemaCollation) {
if !isChineseCapable(charsetConn, collationConn) || !isChineseCapable(schemaCharset, schemaCollation) || !isChineseCapable(charsetDBVar, collationDBVar) { 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",
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)", schemaCharset, schemaCollation, schemaCharset, schemaCollation)
charsetServer, collationServer, charsetDBVar, collationDBVar, charsetConn, collationConn, 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 return nil
} }

View File

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

View File

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

View File

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

View File

@@ -23,4 +23,12 @@ func SetVideoRouter(router *gin.Engine) {
klingV1Router.GET("/videos/text2video/:task_id", controller.RelayTask) klingV1Router.GET("/videos/text2video/:task_id", controller.RelayTask)
klingV1Router.GET("/videos/image2video/: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)
}
} }