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

feat: support image edit model mapping

(cherry picked from commit 1a869d8ad77f262ee27675ec2deaf451b1743eb7)
CaIon 10 месяцев назад
Родитель
Сommit
487ef35c58

+ 112 - 22
relay/channel/openai/adaptor.go

@@ -22,9 +22,11 @@ import (
 	"one-api/relay/common_handler"
 	"one-api/relay/constant"
 	"one-api/service"
+	"path/filepath"
 	"strings"
 
 	"github.com/gin-gonic/gin"
+	"net/textproto"
 )
 
 type Adaptor struct {
@@ -238,13 +240,8 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
 func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
 	switch info.RelayMode {
 	case constant.RelayModeImagesEdits:
-		body, err := common.GetRequestBody(c)
-		if err != nil {
-			return nil, errors.New("get request body fail")
-		}
-		return bytes.NewReader(body), nil
 
-		/*var requestBody bytes.Buffer
+		var requestBody bytes.Buffer
 		writer := multipart.NewWriter(&requestBody)
 
 		writer.WriteField("model", request.Model)
@@ -260,36 +257,129 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
 			}
 		}
 
-		// 添加文件字段
-		imageFiles := c.Request.MultipartForm.File["image[]"]
-		for _, file := range imageFiles {
-			part, err := writer.CreateFormFile("image[]", file.Filename)
-			if err != nil {
-				return nil, errors.New("create form file failed")
+		// 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 {
+			// 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 not found, check for "image[]" field
+				if imageFiles, exists = c.Request.MultipartForm.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 {
+						if strings.HasPrefix(fieldName, "image[") && len(files) > 0 {
+							foundArrayImages = true
+							for _, file := range files {
+								imageFiles = append(imageFiles, file)
+							}
+						}
+					}
+
+					// If no image fields found at all
+					if !foundArrayImages && (len(imageFiles) == 0) {
+						return nil, errors.New("image is required")
+					}
+				}
 			}
-			// 打开文件
-			src, err := file.Open()
-			if err != nil {
-				return nil, errors.New("open file failed")
+
+			// Process all image files
+			for i, fileHeader := range imageFiles {
+				file, err := fileHeader.Open()
+				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"
+				if len(imageFiles) > 1 {
+					fieldName = "image[]"
+				}
+
+				// Determine MIME type based on file extension
+				mimeType := detectImageMimeType(fileHeader.Filename)
+
+				// Create a form file with the appropriate content type
+				h := make(textproto.MIMEHeader)
+				h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, fileHeader.Filename))
+				h.Set("Content-Type", mimeType)
+
+				part, err := writer.CreatePart(h)
+				if err != nil {
+					return nil, fmt.Errorf("create form part failed for image %d: %w", i, err)
+				}
+
+				if _, err := io.Copy(part, file); err != nil {
+					return nil, fmt.Errorf("copy file failed for image %d: %w", i, err)
+				}
 			}
-			// 将文件数据写入 form part
-			_, err = io.Copy(part, src)
-			if err != nil {
-				return nil, errors.New("copy file failed")
+
+			// Handle mask file if present
+			if maskFiles, exists := c.Request.MultipartForm.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()
+
+				// Determine MIME type for mask file
+				mimeType := detectImageMimeType(maskFiles[0].Filename)
+
+				// Create a form file with the appropriate content type
+				h := make(textproto.MIMEHeader)
+				h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="mask"; filename="%s"`, maskFiles[0].Filename))
+				h.Set("Content-Type", mimeType)
+
+				maskPart, err := writer.CreatePart(h)
+				if err != nil {
+					return nil, errors.New("create form file failed for mask")
+				}
+
+				if _, err := io.Copy(maskPart, maskFile); err != nil {
+					return nil, errors.New("copy mask file failed")
+				}
 			}
-			src.Close()
+		} else {
+			return nil, errors.New("no multipart form data found")
 		}
 
 		// 关闭 multipart 编写器以设置分界线
 		writer.Close()
 		c.Request.Header.Set("Content-Type", writer.FormDataContentType())
-		return bytes.NewReader(requestBody.Bytes()), nil*/
+		return bytes.NewReader(requestBody.Bytes()), nil
 
 	default:
 		return request, nil
 	}
 }
 
+// detectImageMimeType determines the MIME type based on the file extension
+func detectImageMimeType(filename string) string {
+	ext := strings.ToLower(filepath.Ext(filename))
+	switch ext {
+	case ".jpg", ".jpeg":
+		return "image/jpeg"
+	case ".png":
+		return "image/png"
+	case ".webp":
+		return "image/webp"
+	default:
+		// Try to detect from extension if possible
+		if strings.HasPrefix(ext, ".jp") {
+			return "image/jpeg"
+		}
+		// Default to png as a fallback
+		return "image/png"
+	}
+}
+
 func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
 	if info.RelayMode == constant.RelayModeAudioTranscription ||
 		info.RelayMode == constant.RelayModeAudioTranslation ||

+ 5 - 0
relay/relay-text.go

@@ -425,6 +425,11 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
 		logContent += ", " + extraContent
 	}
 	other := service.GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice)
+	if imageTokens != 0 {
+		other["image"] = true
+		other["image_ratio"] = imageRatio
+		other["image_output"] = imageTokens
+	}
 	model.RecordConsumeLog(ctx, relayInfo.UserId, relayInfo.ChannelId, promptTokens, completionTokens, logModel,
 		tokenName, quota, logContent, relayInfo.TokenId, userQuota, int(useTimeSeconds), relayInfo.IsStream, relayInfo.Group, other)
 }

+ 3 - 1
web/src/components/LogsTable.js

@@ -987,7 +987,9 @@ const LogsTable = () => {
             other?.group_ratio,
             other?.cache_tokens || 0,
             other?.cache_ratio || 1.0,
-          );
+          other?.image || false,
+              other?.image_ratio || 0,
+              other?.image_output || 0,);
         }
         expandDataLocal.push({
           key: t('计费过程'),

+ 105 - 35
web/src/helpers/render.js

@@ -314,6 +314,9 @@ export function renderModelPrice(
   groupRatio,
   cacheTokens = 0,
   cacheRatio = 1.0,
+  image = false,
+  imageRatio = 1.0,
+  imageOutputTokens = 0,
 ) {
   if (modelPrice !== -1) {
     return i18next.t(
@@ -331,10 +334,15 @@ export function renderModelPrice(
     let inputRatioPrice = modelRatio * 2.0;
     let completionRatioPrice = modelRatio * 2.0 * completionRatio;
     let cacheRatioPrice = modelRatio * 2.0 * cacheRatio;
+    let imageRatioPrice = modelRatio * 2.0 * imageRatio;
 
     // Calculate effective input tokens (non-cached + cached with ratio applied)
-    const effectiveInputTokens =
+    let effectiveInputTokens =
       inputTokens - cacheTokens + cacheTokens * cacheRatio;
+// Handle image tokens if present
+    if (image && imageOutputTokens > 0) {
+      effectiveInputTokens = inputTokens - imageOutputTokens + imageOutputTokens * imageRatio;
+    }
 
     let price =
       (effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
@@ -344,13 +352,13 @@ export function renderModelPrice(
       <>
         <article>
           <p>
-            {i18next.t('提示价格:${{price}} / 1M tokens', {
+            {i18next.t('输入价格:${{price}} / 1M tokens', {
               price: inputRatioPrice,
             })}
           </p>
           <p>
             {i18next.t(
-              '补全价格:${{price}} * {{completionRatio}} = ${{total}} / 1M tokens (补全倍率: {{completionRatio}})',
+              '输出价格:${{price}} * {{completionRatio}} = ${{total}} / 1M tokens (补全倍率: {{completionRatio}})',
               {
                 price: inputRatioPrice,
                 total: completionRatioPrice,
@@ -370,11 +378,24 @@ export function renderModelPrice(
               )}
             </p>
           )}
+          {image && imageOutputTokens > 0 && (
+            <p>
+              {i18next.t(
+                '图片输入价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (图片倍率: {{imageRatio}})',
+                {
+                  price: imageRatioPrice,
+                  ratio: groupRatio,
+                  total: imageRatioPrice * groupRatio,
+                  imageRatio: imageRatio,
+                },
+              )}
+            </p>
+          )}
           <p></p>
           <p>
-            {cacheTokens > 0
+            {cacheTokens > 0 && !image
               ? i18next.t(
-                  '提示 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
+                  '输入 {{nonCacheInput}} tokens / 1M tokens * ${{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * ${{cachePrice}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
                   {
                     nonCacheInput: inputTokens - cacheTokens,
                     cacheInput: cacheTokens,
@@ -386,8 +407,22 @@ export function renderModelPrice(
                     total: price.toFixed(6),
                   },
                 )
+              : image && imageOutputTokens > 0
+              ? i18next.t(
+                  '输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
+                  {
+                    nonImageInput: inputTokens - imageOutputTokens,
+                    imageInput: imageOutputTokens,
+                    imageRatio: imageRatio,
+                    price: inputRatioPrice,
+                    completion: completionTokens,
+                    compPrice: completionRatioPrice,
+                    ratio: groupRatio,
+                    total: price.toFixed(6),
+                  },
+                )
               : i18next.t(
-                  '提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
+                  '输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}',
                   {
                     input: inputTokens,
                     price: inputRatioPrice,
@@ -405,12 +440,53 @@ export function renderModelPrice(
   }
 }
 
+export function renderLogContent(
+  modelRatio,
+  completionRatio,
+  modelPrice = -1,
+  groupRatio,
+  user_group_ratio,
+  image = false,
+  imageRatio = 1.0,
+) {
+  const useUserGroupRatio = isValidGroupRatio(user_group_ratio);
+  const ratioLabel = useUserGroupRatio ? i18next.t('专属倍率') : i18next.t('分组倍率');
+  const ratio = useUserGroupRatio ? user_group_ratio : groupRatio;
+
+  if (modelPrice !== -1) {
+    return i18next.t('模型价格 ${{price}},{{ratioType}} {{ratio}}', {
+      price: modelPrice,
+      ratioType: ratioLabel,
+      ratio
+    });
+  } else {
+    if (image) {
+      return i18next.t('模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},图片输入倍率 {{imageRatio}},{{ratioType}} {{ratio}}', {
+        modelRatio: modelRatio,
+        completionRatio: completionRatio,
+        imageRatio: imageRatio,
+        ratioType: ratioLabel,
+        ratio
+      });
+    } else {
+      return i18next.t('模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},{{ratioType}} {{ratio}}', {
+        modelRatio: modelRatio,
+        completionRatio: completionRatio,
+        ratioType: ratioLabel,
+        ratio
+      });
+    }
+  }
+}
+
 export function renderModelPriceSimple(
   modelRatio,
   modelPrice = -1,
   groupRatio,
   cacheTokens = 0,
   cacheRatio = 1.0,
+  image = false,
+  imageRatio = 1.0,
 ) {
   if (modelPrice !== -1) {
     return i18next.t('价格:${{price}} * 分组:{{ratio}}', {
@@ -418,7 +494,28 @@ export function renderModelPriceSimple(
       ratio: groupRatio,
     });
   } else {
-    if (cacheTokens !== 0) {
+    if (image && cacheTokens !== 0) {
+      return i18next.t(
+        '模型: {{ratio}} * {{ratioType}}: {{groupRatio}} * 缓存倍率: {{cacheRatio}} * 图片输入倍率: {{imageRatio}}',
+        {
+          ratio: modelRatio,
+          ratioType: ratioLabel,
+          groupRatio: groupRatio,
+          cacheRatio: cacheRatio,
+          imageRatio: imageRatio,
+        },
+      );
+    } else if (image) {
+      return i18next.t(
+        '模型: {{ratio}} * {{ratioType}}: {{groupRatio}} * 图片输入倍率: {{imageRatio}}',
+        {
+          ratio: modelRatio,
+          ratioType: ratioLabel,
+          groupRatio: groupRatio,
+          imageRatio: imageRatio,
+        },
+      );
+    } else if (cacheTokens !== 0) {
       return i18next.t(
         '模型: {{ratio}} * 分组: {{groupRatio}} * 缓存: {{cacheRatio}}',
         {
@@ -882,7 +979,7 @@ export function renderClaudeLogContent(
     });
   } else {
     return i18next.t(
-      '模型倍率 {{modelRatio}},补全倍率 {{completionRatio}},缓存倍率 {{cacheRatio}},缓存创建倍率 {{cacheCreationRatio}},{{ratioType}} {{ratio}}',
+      '模型倍率 {{modelRatio}},输出倍率 {{completionRatio}},缓存倍率 {{cacheRatio}},缓存创建倍率 {{cacheCreationRatio}},{{ratioType}} {{ratio}}',
       {
         modelRatio: modelRatio,
         completionRatio: completionRatio,
@@ -933,30 +1030,3 @@ export function renderClaudeModelPriceSimple(
     }
   }
 }
-
-export function renderLogContent(
-  modelRatio,
-  completionRatio,
-  modelPrice = -1,
-  groupRatio,
-) {
-  const ratioLabel = false ? i18next.t('专属倍率') : i18next.t('分组倍率');
-
-  if (modelPrice !== -1) {
-    return i18next.t('模型价格 ${{price}},{{ratioType}} {{ratio}}', {
-      price: modelPrice,
-      ratioType: ratioLabel,
-      ratio: groupRatio,
-    });
-  } else {
-    return i18next.t(
-      '模型倍率 {{modelRatio}},补全倍率 {{completionRatio}},{{ratioType}} {{ratio}}',
-      {
-        modelRatio: modelRatio,
-        completionRatio: completionRatio,
-        ratioType: ratioLabel,
-        ratio: groupRatio,
-      },
-    );
-  }
-}

+ 10 - 5
web/src/i18n/locales/en.json

@@ -679,7 +679,10 @@
   "当前分组可用": "Available in current group",
   "当前分组不可用": "The current group is unavailable",
   "提示:": "input:",
+  "输入:": "input:",
   "补全:": "output:",
+  "输出:": "output:",
+  "图片输出:": "Image output:",
   "模型价格:": "Model price:",
   "模型:": "Model:",
   "分组:": "Grouping:",
@@ -1054,14 +1057,16 @@
   "等级": "grade",
   "钉钉": "DingTalk",
   "模型价格:${{price}} * 分组倍率:{{ratio}} = ${{total}}": "Model price: ${{price}} * Group ratio: {{ratio}} = ${{total}}",
-  "提示:${{price}} * {{ratio}} = ${{total}} / 1M tokens": "Prompt: ${{price}} * {{ratio}} = ${{total}} / 1M tokens",
-  "补全:${{price}} * {{ratio}} = ${{total}} / 1M tokens": "Completion: ${{price}} * {{ratio}} = ${{total}} / 1M tokens",
-  "音频提示:${{price}} * {{ratio}} * {{audioRatio}} = ${{total}} / 1M tokens": "Audio prompt: ${{price}} * {{ratio}} * {{audioRatio}} = ${{total}} / 1M tokens",
+  "输入:${{price}} * {{ratio}} = ${{total}} / 1M tokens": "Prompt: ${{price}} * {{ratio}} = ${{total}} / 1M tokens",
+  "输出:${{price}} * {{ratio}} = ${{total}} / 1M tokens": "Completion: ${{price}} * {{ratio}} = ${{total}} / 1M tokens",
+  "图片输入:${{price}} * {{ratio}} = ${{total}} / 1M tokens (图片倍率: {{imageRatio}})": "Image input: ${{price}} * {{ratio}} = ${{total}} / 1M tokens (Image ratio: {{imageRatio}})",
+  "音频输入:${{price}} * {{ratio}} * {{audioRatio}} = ${{total}} / 1M tokens": "Audio prompt: ${{price}} * {{ratio}} * {{audioRatio}} = ${{total}} / 1M tokens",
   "音频提示 {{input}} tokens / 1M tokens * ${{price}} * {{audioRatio}} + 音频补全 {{completion}} tokens / 1M tokens * ${{price}} * {{audioRatio}} * {{audioCompRatio}}": "Audio prompt {{input}} tokens / 1M tokens * ${{price}} * {{audioRatio}} + Audio completion {{completion}} tokens / 1M tokens * ${{price}} * {{audioRatio}} * {{audioCompRatio}}",
-  "音频补全:${{price}} * {{ratio}} * {{audioRatio}} * {{audioCompRatio}} = ${{total}} / 1M tokens": "Audio completion: ${{price}} * {{ratio}} * {{audioRatio}} * {{audioCompRatio}} = ${{total}} / 1M tokens",
+  "音频输出:${{price}} * {{ratio}} * {{audioRatio}} * {{audioCompRatio}} = ${{total}} / 1M tokens": "Audio completion: ${{price}} * {{ratio}} * {{audioRatio}} * {{audioCompRatio}} = ${{total}} / 1M tokens",
+  "输入 {{nonImageInput}} tokens + 图片输入 {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}": "Input {{nonImageInput}} tokens + Image input {{imageInput}} tokens * {{imageRatio}} / 1M tokens * ${{price}} + Output {{completion}} tokens / 1M tokens * ${{compPrice}} * Group {{ratio}} = ${{total}}",
   "(文字 + 音频)* 分组倍率 {{ratio}} = ${{total}}": "(Text + Audio) * Group ratio {{ratio}} = ${{total}}",
   "文字提示 {{input}} tokens / 1M tokens * ${{price}} + 文字补全 {{completion}} tokens / 1M tokens * ${{compPrice}} +": "Text prompt {{input}} tokens / 1M tokens * ${{price}} + Text completion {{completion}} tokens / 1M tokens * ${{compPrice}} +",
-  "提示 {{input}} tokens / 1M tokens * ${{price}} + 补全 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}": "Prompt {{input}} tokens / 1M tokens * ${{price}} + Completion {{completion}} tokens / 1M tokens * ${{compPrice}} * Group {{ratio}} = ${{total}}",
+  "输入 {{input}} tokens / 1M tokens * ${{price}} + 输出 {{completion}} tokens / 1M tokens * ${{compPrice}} * 分组 {{ratio}} = ${{total}}": "Prompt {{input}} tokens / 1M tokens * ${{price}} + Completion {{completion}} tokens / 1M tokens * ${{compPrice}} * Group {{ratio}} = ${{total}}",
   "价格:${{price}} * 分组:{{ratio}}": "Price: ${{price}} * Group: {{ratio}}",
   "模型: {{ratio}} * 分组: {{groupRatio}}": "Model: {{ratio}} * Group: {{groupRatio}}",
   "统计额度": "Statistical quota",