diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index 6614d116..502cee69 100644 --- a/relay/channel/openai/adaptor.go +++ b/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 || diff --git a/relay/relay-text.go b/relay/relay-text.go index d5625409..4fdd435d 100644 --- a/relay/relay-text.go +++ b/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) } diff --git a/web/src/components/LogsTable.js b/web/src/components/LogsTable.js index 7834d51a..551551da 100644 --- a/web/src/components/LogsTable.js +++ b/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('计费过程'), diff --git a/web/src/helpers/render.js b/web/src/helpers/render.js index 4d1a3113..6ea8e6de 100644 --- a/web/src/helpers/render.js +++ b/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( <>

- {i18next.t('提示价格:${{price}} / 1M tokens', { + {i18next.t('输入价格:${{price}} / 1M tokens', { price: inputRatioPrice, })}

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

)} + {image && imageOutputTokens > 0 && ( +

+ {i18next.t( + '图片输入价格:${{price}} * {{ratio}} = ${{total}} / 1M tokens (图片倍率: {{imageRatio}})', + { + price: imageRatioPrice, + ratio: groupRatio, + total: imageRatioPrice * groupRatio, + imageRatio: imageRatio, + }, + )} +

+ )}

- {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, - }, - ); - } -} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 8aaaab77..8ba836d9 100644 --- a/web/src/i18n/locales/en.json +++ b/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",