Merge remote-tracking branch 'origin/main' into nightly

# Conflicts:
#	web/src/helpers/render.jsx
#	web/src/hooks/usage-logs/useUsageLogsData.jsx
#	web/src/i18n/locales/en.json
This commit is contained in:
CaIon
2026-04-09 17:12:21 +08:00
100 changed files with 6875 additions and 2699 deletions
@@ -85,7 +85,7 @@ func TestBuildMessageDeltaPatchUsage(t *testing.T) {
require.EqualValues(t, 50, usage.CacheCreationInputTokens)
require.EqualValues(t, 53, usage.OutputTokens)
require.NotNil(t, usage.CacheCreation)
require.EqualValues(t, 10, usage.CacheCreation.Ephemeral5mInputTokens)
require.EqualValues(t, 30, usage.CacheCreation.Ephemeral5mInputTokens)
require.EqualValues(t, 20, usage.CacheCreation.Ephemeral1hInputTokens)
})
@@ -108,4 +108,22 @@ func TestBuildMessageDeltaPatchUsage(t *testing.T) {
require.EqualValues(t, 7, usage.CacheReadInputTokens)
require.EqualValues(t, 6, usage.CacheCreationInputTokens)
})
t.Run("default aggregate cache creation to 5m when split missing", func(t *testing.T) {
claudeResponse := &dto.ClaudeResponse{Usage: &dto.ClaudeUsage{
OutputTokens: 53,
CacheCreationInputTokens: 50,
}}
claudeInfo := &ClaudeResponseInfo{Usage: &dto.Usage{
PromptTokensDetails: dto.InputTokenDetails{
CachedCreationTokens: 50,
},
}}
usage := buildMessageDeltaPatchUsage(claudeResponse, claudeInfo)
require.NotNil(t, usage)
require.NotNil(t, usage.CacheCreation)
require.EqualValues(t, 50, usage.CacheCreation.Ephemeral5mInputTokens)
require.EqualValues(t, 0, usage.CacheCreation.Ephemeral1hInputTokens)
})
}
+61 -26
View File
@@ -85,7 +85,7 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
// 解析 UserLocation JSON
var userLocationMap map[string]interface{}
if err := json.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil {
if err := common.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil {
// 检查是否有 approximate 字段
if approximateData, ok := userLocationMap["approximate"].(map[string]interface{}); ok {
if timezone, ok := approximateData["timezone"].(string); ok && timezone != "" {
@@ -177,7 +177,7 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
}
// TODO: 临时处理
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
claudeRequest.TopP = common.GetPointer[float64](0)
claudeRequest.TopP = nil
claudeRequest.Temperature = common.GetPointer[float64](1.0)
if !model_setting.ShouldPreserveThinkingSuffix(textRequest.Model) {
claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking")
@@ -343,33 +343,39 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
} else {
claudeMediaMessages := make([]dto.ClaudeMediaMessage, 0)
for _, mediaMessage := range message.ParseContent() {
claudeMediaMessage := dto.ClaudeMediaMessage{
Type: mediaMessage.Type,
}
if mediaMessage.Type == "text" {
claudeMediaMessage.Text = common.GetPointer[string](mediaMessage.Text)
} else {
imageUrl := mediaMessage.GetImageMedia()
claudeMediaMessage.Type = "image"
claudeMediaMessage.Source = &dto.ClaudeMessageSource{
Type: "base64",
}
// 使用统一的文件服务获取图片数据
var source *types.FileSource
if strings.HasPrefix(imageUrl.Url, "http") {
source = types.NewURLFileSource(imageUrl.Url)
} else {
source = types.NewBase64FileSource(imageUrl.Url, "")
switch mediaMessage.Type {
case "text":
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
Type: "text",
Text: common.GetPointer[string](mediaMessage.Text),
})
default:
source := mediaMessage.ToFileSource()
if source == nil {
continue
}
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Claude")
if err != nil {
return nil, fmt.Errorf("get file data failed: %s", err.Error())
}
claudeMediaMessage := dto.ClaudeMediaMessage{
Source: &dto.ClaudeMessageSource{
Type: "base64",
},
}
if strings.HasPrefix(mimeType, "application/pdf") {
claudeMediaMessage.Type = "document"
} else {
claudeMediaMessage.Type = "image"
}
claudeMediaMessage.Source.MediaType = mimeType
claudeMediaMessage.Source.Data = base64Data
claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage)
continue
}
claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage)
}
if message.ToolCalls != nil {
for _, toolCall := range message.ParseToolCalls() {
inputObj := make(map[string]any)
@@ -574,6 +580,11 @@ func buildOpenAIStyleUsageFromClaudeUsage(usage *dto.Usage) dto.Usage {
return dto.Usage{}
}
clone := *usage
clone.ClaudeCacheCreation5mTokens, clone.ClaudeCacheCreation1hTokens = service.NormalizeCacheCreationSplit(
usage.PromptTokensDetails.CachedCreationTokens,
usage.ClaudeCacheCreation5mTokens,
usage.ClaudeCacheCreation1hTokens,
)
cacheCreationTokens := cacheCreationTokensForOpenAIUsage(usage)
totalInputTokens := usage.PromptTokens + usage.PromptTokensDetails.CachedTokens + cacheCreationTokens
clone.PromptTokens = totalInputTokens
@@ -603,11 +614,26 @@ func buildMessageDeltaPatchUsage(claudeResponse *dto.ClaudeResponse, claudeInfo
if usage.CacheCreationInputTokens == 0 && claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens > 0 {
usage.CacheCreationInputTokens = claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens
}
if usage.CacheCreation == nil && (claudeInfo.Usage.ClaudeCacheCreation5mTokens > 0 || claudeInfo.Usage.ClaudeCacheCreation1hTokens > 0) {
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
Ephemeral5mInputTokens: claudeInfo.Usage.ClaudeCacheCreation5mTokens,
Ephemeral1hInputTokens: claudeInfo.Usage.ClaudeCacheCreation1hTokens,
}
cacheCreation5m := 0
cacheCreation1h := 0
if usage.CacheCreation != nil {
cacheCreation5m = usage.CacheCreation.Ephemeral5mInputTokens
cacheCreation1h = usage.CacheCreation.Ephemeral1hInputTokens
} else {
cacheCreation5m = claudeInfo.Usage.ClaudeCacheCreation5mTokens
cacheCreation1h = claudeInfo.Usage.ClaudeCacheCreation1hTokens
}
cacheCreation5m, cacheCreation1h = service.NormalizeCacheCreationSplit(
usage.CacheCreationInputTokens,
cacheCreation5m,
cacheCreation1h,
)
if usage.CacheCreation == nil && (cacheCreation5m > 0 || cacheCreation1h > 0) {
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{}
}
if usage.CacheCreation != nil {
usage.CacheCreation.Ephemeral5mInputTokens = cacheCreation5m
usage.CacheCreation.Ephemeral1hInputTokens = cacheCreation1h
}
return usage
}
@@ -783,7 +809,16 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
if common.DebugEnabled {
common.SysLog("claude response usage is not complete, maybe upstream error")
}
claudeInfo.Usage = service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens)
// 只补缺失字段,不整份覆盖——保留 message_start 已拿到的 cache 字段
fallback := service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
if claudeInfo.Usage.CompletionTokens == 0 ||
(!claudeInfo.Done && fallback.CompletionTokens > claudeInfo.Usage.CompletionTokens) {
claudeInfo.Usage.CompletionTokens = fallback.CompletionTokens
}
if claudeInfo.Usage.PromptTokens == 0 {
claudeInfo.Usage.PromptTokens = fallback.PromptTokens
}
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
}
if claudeInfo.Usage != nil {
claudeInfo.Usage.UsageSemantic = "anthropic"
+125
View File
@@ -1,10 +1,12 @@
package claude
import (
"encoding/base64"
"strings"
"testing"
"github.com/QuantumNous/new-api/dto"
"github.com/stretchr/testify/require"
)
func TestFormatClaudeResponseInfo_MessageStart(t *testing.T) {
@@ -255,3 +257,126 @@ func TestBuildOpenAIStyleUsageFromClaudeUsagePreservesCacheCreationRemainder(t *
})
}
}
func TestBuildOpenAIStyleUsageFromClaudeUsageDefaultsAggregateCacheCreationTo5m(t *testing.T) {
usage := &dto.Usage{
PromptTokens: 100,
CompletionTokens: 20,
PromptTokensDetails: dto.InputTokenDetails{
CachedTokens: 30,
CachedCreationTokens: 50,
},
UsageSemantic: "anthropic",
}
openAIUsage := buildOpenAIStyleUsageFromClaudeUsage(usage)
require.Equal(t, 50, openAIUsage.ClaudeCacheCreation5mTokens)
require.Equal(t, 0, openAIUsage.ClaudeCacheCreation1hTokens)
}
func TestRequestOpenAI2ClaudeMessage_IgnoresUnsupportedFileContent(t *testing.T) {
request := dto.GeneralOpenAIRequest{
Model: "claude-3-5-sonnet",
Messages: []dto.Message{
{
Role: "user",
Content: []any{
dto.MediaContent{
Type: dto.ContentTypeText,
Text: "see attachment",
},
dto.MediaContent{
Type: dto.ContentTypeFile,
File: &dto.MessageFile{
FileName: "blob.bin",
FileData: "JVBERi0xLjQK",
},
},
},
},
},
}
claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
require.NoError(t, err)
require.Len(t, claudeRequest.Messages, 1)
content, ok := claudeRequest.Messages[0].Content.([]dto.ClaudeMediaMessage)
require.True(t, ok)
require.Len(t, content, 1)
require.Equal(t, "text", content[0].Type)
require.NotNil(t, content[0].Text)
require.Equal(t, "see attachment", *content[0].Text)
}
func TestRequestOpenAI2ClaudeMessage_SupportsPDFFileContent(t *testing.T) {
request := dto.GeneralOpenAIRequest{
Model: "claude-3-5-sonnet",
Messages: []dto.Message{
{
Role: "user",
Content: []any{
dto.MediaContent{
Type: dto.ContentTypeFile,
File: &dto.MessageFile{
FileName: "spec.pdf",
FileData: "JVBERi0xLjQK",
},
},
dto.MediaContent{
Type: dto.ContentTypeText,
Text: "summarize it",
},
},
},
},
}
claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
require.NoError(t, err)
require.Len(t, claudeRequest.Messages, 1)
content, ok := claudeRequest.Messages[0].Content.([]dto.ClaudeMediaMessage)
require.True(t, ok)
require.Len(t, content, 2)
require.Equal(t, "document", content[0].Type)
require.NotNil(t, content[0].Source)
require.Equal(t, "base64", content[0].Source.Type)
require.Equal(t, "application/pdf", content[0].Source.MediaType)
require.Equal(t, "JVBERi0xLjQK", content[0].Source.Data)
require.Equal(t, "text", content[1].Type)
require.NotNil(t, content[1].Text)
require.Equal(t, "summarize it", *content[1].Text)
}
func TestRequestOpenAI2ClaudeMessage_ConvertsTextFileContentToText(t *testing.T) {
request := dto.GeneralOpenAIRequest{
Model: "claude-3-5-sonnet",
Messages: []dto.Message{
{
Role: "user",
Content: []any{
dto.MediaContent{
Type: dto.ContentTypeFile,
File: &dto.MessageFile{
FileName: "notes.txt",
FileData: base64.StdEncoding.EncodeToString([]byte("alpha\nbeta")),
},
},
},
},
},
}
claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
require.NoError(t, err)
require.Len(t, claudeRequest.Messages, 1)
content, ok := claudeRequest.Messages[0].Content.([]dto.ClaudeMediaMessage)
require.True(t, ok)
require.Len(t, content, 1)
require.Equal(t, "text", content[0].Type)
require.NotNil(t, content[0].Text)
require.Equal(t, "alpha\nbeta", *content[0].Text)
}
+6 -38
View File
@@ -37,6 +37,8 @@ var geminiSupportedMimeTypes = map[string]bool{
"image/jpeg": true,
"image/jpg": true, // support old image/jpeg
"image/webp": true,
"image/heic": true,
"image/heif": true,
"text/plain": true,
"video/mov": true,
"video/mpeg": true,
@@ -583,14 +585,10 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
Text: part.Text,
})
}
} else if part.Type == dto.ContentTypeImageURL {
// 使用统一的文件服务获取图片数据
var source *types.FileSource
imageUrl := part.GetImageMedia().Url
if strings.HasPrefix(imageUrl, "http") {
source = types.NewURLFileSource(imageUrl)
} else {
source = types.NewBase64FileSource(imageUrl, "")
} else {
source := part.ToFileSource()
if source == nil {
continue
}
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Gemini")
if err != nil {
@@ -602,36 +600,6 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", mimeType, source.GetIdentifier(), getSupportedMimeTypesList())
}
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: mimeType,
Data: base64Data,
},
})
} else if part.Type == dto.ContentTypeFile {
if part.GetFile().FileId != "" {
return nil, fmt.Errorf("only base64 file is supported in gemini")
}
fileSource := types.NewBase64FileSource(part.GetFile().FileData, "")
base64Data, mimeType, err := service.GetBase64Data(c, fileSource, "formatting file for Gemini")
if err != nil {
return nil, fmt.Errorf("decode base64 file data failed: %s", err.Error())
}
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: mimeType,
Data: base64Data,
},
})
} else if part.Type == dto.ContentTypeInputAudio {
if part.GetInputAudio().Data == "" {
return nil, fmt.Errorf("only base64 audio is supported in gemini")
}
audioSource := types.NewBase64FileSource(part.GetInputAudio().Data, "audio/"+part.GetInputAudio().Format)
base64Data, mimeType, err := service.GetBase64Data(c, audioSource, "formatting audio for Gemini")
if err != nil {
return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error())
}
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: mimeType,
+7 -1
View File
@@ -78,7 +78,10 @@ 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) {
return request, nil
if info.RelayMode != constant.RelayModeImagesGenerations {
return nil, fmt.Errorf("unsupported image relay mode: %d", info.RelayMode)
}
return oaiImage2MiniMaxImageRequest(request), nil
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
@@ -121,6 +124,9 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
if info.RelayMode == constant.RelayModeAudioSpeech {
return handleTTSResponse(c, resp, info)
}
if info.RelayMode == constant.RelayModeImagesGenerations {
return miniMaxImageHandler(c, resp, info)
}
switch info.RelayFormat {
case types.RelayFormatClaude:
+137
View File
@@ -0,0 +1,137 @@
package minimax
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/gin-gonic/gin"
)
func TestGetRequestURLForImageGeneration(t *testing.T) {
t.Parallel()
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesGenerations,
ChannelMeta: &relaycommon.ChannelMeta{
ChannelBaseUrl: "https://api.minimax.chat",
},
}
got, err := GetRequestURL(info)
if err != nil {
t.Fatalf("GetRequestURL returned error: %v", err)
}
want := "https://api.minimax.chat/v1/image_generation"
if got != want {
t.Fatalf("GetRequestURL() = %q, want %q", got, want)
}
}
func TestConvertImageRequest(t *testing.T) {
t.Parallel()
adaptor := &Adaptor{}
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesGenerations,
OriginModelName: "image-01",
}
request := dto.ImageRequest{
Model: "image-01",
Prompt: "a red fox in snowfall",
Size: "1536x1024",
ResponseFormat: "url",
N: uintPtr(2),
}
got, err := adaptor.ConvertImageRequest(gin.CreateTestContextOnly(httptest.NewRecorder(), gin.New()), info, request)
if err != nil {
t.Fatalf("ConvertImageRequest returned error: %v", err)
}
body, err := json.Marshal(got)
if err != nil {
t.Fatalf("json.Marshal returned error: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("json.Unmarshal returned error: %v", err)
}
if payload["model"] != "image-01" {
t.Fatalf("model = %#v, want %q", payload["model"], "image-01")
}
if payload["prompt"] != request.Prompt {
t.Fatalf("prompt = %#v, want %q", payload["prompt"], request.Prompt)
}
if payload["n"] != float64(2) {
t.Fatalf("n = %#v, want 2", payload["n"])
}
if payload["aspect_ratio"] != "3:2" {
t.Fatalf("aspect_ratio = %#v, want %q", payload["aspect_ratio"], "3:2")
}
if payload["response_format"] != "url" {
t.Fatalf("response_format = %#v, want %q", payload["response_format"], "url")
}
}
func TestDoResponseForImageGeneration(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesGenerations,
StartTime: time.Unix(1700000000, 0),
}
resp := &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: httptest.NewRecorder().Result().Body,
}
resp.Body = ioNopCloser(`{"data":{"image_urls":["https://example.com/minimax.png"]}}`)
adaptor := &Adaptor{}
usage, err := adaptor.DoResponse(c, resp, info)
if err != nil {
t.Fatalf("DoResponse returned error: %v", err)
}
if usage == nil {
t.Fatalf("DoResponse returned nil usage")
}
body := recorder.Body.String()
if !strings.Contains(body, `"url":"https://example.com/minimax.png"`) {
t.Fatalf("response body = %s, want OpenAI image response with image URL", body)
}
if strings.Contains(body, `"image_urls"`) {
t.Fatalf("response body = %s, should not expose raw MiniMax image_urls payload", body)
}
}
type nopReadCloser struct {
*strings.Reader
}
func (n nopReadCloser) Close() error {
return nil
}
func ioNopCloser(body string) nopReadCloser {
return nopReadCloser{Reader: strings.NewReader(body)}
}
func uintPtr(v uint) *uint {
return &v
}
+4
View File
@@ -8,6 +8,8 @@ var ModelList = []string{
"abab6-chat",
"abab5.5-chat",
"abab5.5s-chat",
"MiniMax-M2.7",
"MiniMax-M2.7-highspeed",
"speech-2.5-hd-preview",
"speech-2.5-turbo-preview",
"speech-02-hd",
@@ -19,6 +21,8 @@ var ModelList = []string{
"MiniMax-M2",
"MiniMax-M2.5",
"MiniMax-M2.5-highspeed",
"image-01",
"image-01-live",
}
var ChannelName = "minimax"
+213
View File
@@ -0,0 +1,213 @@
package minimax
import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
)
type MiniMaxImageRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
AspectRatio string `json:"aspect_ratio,omitempty"`
ResponseFormat string `json:"response_format,omitempty"`
N int `json:"n,omitempty"`
PromptOptimizer *bool `json:"prompt_optimizer,omitempty"`
AigcWatermark *bool `json:"aigc_watermark,omitempty"`
}
type MiniMaxImageResponse struct {
ID string `json:"id"`
Data struct {
ImageURLs []string `json:"image_urls"`
ImageBase64 []string `json:"image_base64"`
} `json:"data"`
Metadata map[string]any `json:"metadata"`
BaseResp struct {
StatusCode int `json:"status_code"`
StatusMsg string `json:"status_msg"`
} `json:"base_resp"`
}
func oaiImage2MiniMaxImageRequest(request dto.ImageRequest) MiniMaxImageRequest {
responseFormat := normalizeMiniMaxResponseFormat(request.ResponseFormat)
minimaxRequest := MiniMaxImageRequest{
Model: request.Model,
Prompt: request.Prompt,
ResponseFormat: responseFormat,
N: 1,
AigcWatermark: request.Watermark,
}
if request.Model == "" {
minimaxRequest.Model = "image-01"
}
if request.N != nil && *request.N > 0 {
minimaxRequest.N = int(*request.N)
}
if aspectRatio := aspectRatioFromImageRequest(request); aspectRatio != "" {
minimaxRequest.AspectRatio = aspectRatio
}
if raw, ok := request.Extra["prompt_optimizer"]; ok {
var promptOptimizer bool
if err := common.Unmarshal(raw, &promptOptimizer); err == nil {
minimaxRequest.PromptOptimizer = &promptOptimizer
}
}
return minimaxRequest
}
func aspectRatioFromImageRequest(request dto.ImageRequest) string {
if raw, ok := request.Extra["aspect_ratio"]; ok {
var aspectRatio string
if err := common.Unmarshal(raw, &aspectRatio); err == nil && aspectRatio != "" {
return aspectRatio
}
}
switch request.Size {
case "1024x1024":
return "1:1"
case "1792x1024":
return "16:9"
case "1024x1792":
return "9:16"
case "1536x1024", "1248x832":
return "3:2"
case "1024x1536", "832x1248":
return "2:3"
case "1152x864":
return "4:3"
case "864x1152":
return "3:4"
case "1344x576":
return "21:9"
}
width, height, ok := parseImageSize(request.Size)
if !ok {
return ""
}
ratio := reduceAspectRatio(width, height)
switch ratio {
case "1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9":
return ratio
default:
return ""
}
}
func parseImageSize(size string) (int, int, bool) {
parts := strings.Split(size, "x")
if len(parts) != 2 {
return 0, 0, false
}
width, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, false
}
height, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, false
}
if width <= 0 || height <= 0 {
return 0, 0, false
}
return width, height, true
}
func reduceAspectRatio(width, height int) string {
divisor := gcd(width, height)
return fmt.Sprintf("%d:%d", width/divisor, height/divisor)
}
func gcd(a, b int) int {
for b != 0 {
a, b = b, a%b
}
if a == 0 {
return 1
}
return a
}
func normalizeMiniMaxResponseFormat(responseFormat string) string {
switch strings.ToLower(responseFormat) {
case "", "url":
return "url"
case "b64_json", "base64":
return "base64"
default:
return responseFormat
}
}
func responseMiniMax2OpenAIImage(response *MiniMaxImageResponse, info *relaycommon.RelayInfo) (*dto.ImageResponse, error) {
imageResponse := &dto.ImageResponse{
Created: info.StartTime.Unix(),
}
for _, imageURL := range response.Data.ImageURLs {
imageResponse.Data = append(imageResponse.Data, dto.ImageData{Url: imageURL})
}
for _, imageBase64 := range response.Data.ImageBase64 {
imageResponse.Data = append(imageResponse.Data, dto.ImageData{B64Json: imageBase64})
}
if len(response.Metadata) > 0 {
metadata, err := common.Marshal(response.Metadata)
if err != nil {
return nil, err
}
imageResponse.Metadata = metadata
}
return imageResponse, nil
}
func miniMaxImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
}
service.CloseResponseBodyGracefully(resp)
var minimaxResponse MiniMaxImageResponse
if err := common.Unmarshal(responseBody, &minimaxResponse); err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if minimaxResponse.BaseResp.StatusCode != 0 {
return nil, types.WithOpenAIError(types.OpenAIError{
Message: minimaxResponse.BaseResp.StatusMsg,
Type: "minimax_image_error",
Code: fmt.Sprintf("%d", minimaxResponse.BaseResp.StatusCode),
}, resp.StatusCode)
}
openAIResponse, err := responseMiniMax2OpenAIImage(&minimaxResponse, info)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
jsonResponse, err := common.Marshal(openAIResponse)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
c.Writer.Header().Set("Content-Type", "application/json")
c.Writer.WriteHeader(resp.StatusCode)
if _, err := c.Writer.Write(jsonResponse); err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
return &dto.Usage{}, nil
}
+2
View File
@@ -21,6 +21,8 @@ func GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
switch info.RelayMode {
case constant.RelayModeChatCompletions:
return fmt.Sprintf("%s/v1/text/chatcompletion_v2", baseUrl), nil
case constant.RelayModeImagesGenerations:
return fmt.Sprintf("%s/v1/image_generation", baseUrl), nil
case constant.RelayModeAudioSpeech:
return fmt.Sprintf("%s/v1/t2a_v2", baseUrl), nil
default:
+2 -9
View File
@@ -98,15 +98,8 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
parts := m.ParseContent()
for _, part := range parts {
if part.Type == dto.ContentTypeImageURL {
img := part.GetImageMedia()
if img != nil && img.Url != "" {
// 使用统一的文件服务获取图片数据
var source *types.FileSource
if strings.HasPrefix(img.Url, "http") {
source = types.NewURLFileSource(img.Url)
} else {
source = types.NewBase64FileSource(img.Url, "")
}
source := part.ToFileSource()
if source != nil {
base64Data, _, err := service.GetBase64Data(c, source, "fetch image for ollama chat")
if err != nil {
return nil, err
+1 -1
View File
@@ -369,7 +369,7 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
a.ResponseFormat = request.ResponseFormat
if info.RelayMode == relayconstant.RelayModeAudioSpeech {
jsonData, err := json.Marshal(request)
jsonData, err := common.Marshal(request)
if err != nil {
return nil, fmt.Errorf("error marshalling object: %w", err)
}
+3 -3
View File
@@ -80,9 +80,9 @@ type AliVideoOutput struct {
// AliUsage 使用统计
type AliUsage struct {
Duration int `json:"duration,omitempty"`
VideoCount int `json:"video_count,omitempty"`
SR int `json:"SR,omitempty"`
Duration dto.IntValue `json:"duration,omitempty"`
VideoCount dto.IntValue `json:"video_count,omitempty"`
SR dto.IntValue `json:"SR,omitempty"`
}
type AliMetadata struct {
+93 -36
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"time"
"github.com/QuantumNous/new-api/common"
@@ -13,12 +14,13 @@ import (
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay/channel"
taskcommon "github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
"github.com/QuantumNous/new-api/relay/channel/task/taskcommon"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/pkg/errors"
"github.com/samber/lo"
)
// ============================
@@ -26,37 +28,37 @@ import (
// ============================
type ContentItem struct {
Type string `json:"type"` // "text", "image_url" or "video"
Text string `json:"text,omitempty"` // for text type
ImageURL *ImageURL `json:"image_url,omitempty"` // for image_url type
Video *VideoReference `json:"video,omitempty"` // for video (sample) type
Role string `json:"role,omitempty"` // reference_image / first_frame / last_frame
Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
ImageURL *MediaURL `json:"image_url,omitempty"`
VideoURL *MediaURL `json:"video_url,omitempty"`
AudioURL *MediaURL `json:"audio_url,omitempty"`
Role string `json:"role,omitempty"`
}
type ImageURL struct {
URL string `json:"url"`
}
type VideoReference struct {
URL string `json:"url"` // Draft video URL
type MediaURL struct {
URL string `json:"url,omitempty"`
}
type requestPayload struct {
Model string `json:"model"`
Content []ContentItem `json:"content"`
Content []ContentItem `json:"content,omitempty"`
CallbackURL string `json:"callback_url,omitempty"`
ReturnLastFrame *dto.BoolValue `json:"return_last_frame,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
ExecutionExpiresAfter dto.IntValue `json:"execution_expires_after,omitempty"`
ExecutionExpiresAfter *dto.IntValue `json:"execution_expires_after,omitempty"`
GenerateAudio *dto.BoolValue `json:"generate_audio,omitempty"`
Draft *dto.BoolValue `json:"draft,omitempty"`
Resolution string `json:"resolution,omitempty"`
Ratio string `json:"ratio,omitempty"`
Duration dto.IntValue `json:"duration,omitempty"`
Frames dto.IntValue `json:"frames,omitempty"`
Seed dto.IntValue `json:"seed,omitempty"`
CameraFixed *dto.BoolValue `json:"camera_fixed,omitempty"`
Watermark *dto.BoolValue `json:"watermark,omitempty"`
Tools []struct {
Type string `json:"type,omitempty"`
} `json:"tools,omitempty"`
Resolution string `json:"resolution,omitempty"`
Ratio string `json:"ratio,omitempty"`
Duration *dto.IntValue `json:"duration,omitempty"`
Frames *dto.IntValue `json:"frames,omitempty"`
Seed *dto.IntValue `json:"seed,omitempty"`
CameraFixed *dto.BoolValue `json:"camera_fixed,omitempty"`
Watermark *dto.BoolValue `json:"watermark,omitempty"`
}
type responsePayload struct {
@@ -76,10 +78,20 @@ type responseTask struct {
Ratio string `json:"ratio"`
FramesPerSecond int `json:"framespersecond"`
ServiceTier string `json:"service_tier"`
Usage struct {
Tools []struct {
Type string `json:"type"`
} `json:"tools"`
Usage struct {
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
ToolUsage struct {
WebSearch int `json:"web_search"`
} `json:"tool_usage"`
} `json:"usage"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
@@ -108,18 +120,61 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom
}
// BuildRequestURL constructs the upstream URL.
func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) {
func (a *TaskAdaptor) BuildRequestURL(_ *relaycommon.RelayInfo) (string, error) {
return fmt.Sprintf("%s/api/v3/contents/generations/tasks", a.baseURL), nil
}
// BuildRequestHeader sets required headers.
func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error {
func (a *TaskAdaptor) BuildRequestHeader(_ *gin.Context, req *http.Request, _ *relaycommon.RelayInfo) error {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+a.apiKey)
return nil
}
// EstimateBilling 检测请求 metadata 中是否包含视频输入,返回视频折扣 OtherRatio。
func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 {
req, err := relaycommon.GetTaskRequest(c)
if err != nil {
return nil
}
if hasVideoInMetadata(req.Metadata) {
if ratio, ok := GetVideoInputRatio(info.OriginModelName); ok {
return map[string]float64{"video_input": ratio}
}
}
return nil
}
// hasVideoInMetadata 直接检查 metadata 的 content 数组是否包含 video_url 条目,
// 避免构建完整的上游 requestPayload。
func hasVideoInMetadata(metadata map[string]interface{}) bool {
if metadata == nil {
return false
}
contentRaw, ok := metadata["content"]
if !ok {
return false
}
contentSlice, ok := contentRaw.([]interface{})
if !ok {
return false
}
for _, item := range contentSlice {
itemMap, ok := item.(map[string]interface{})
if !ok {
continue
}
if itemMap["type"] == "video_url" {
return true
}
if _, has := itemMap["video_url"]; has {
return true
}
}
return false
}
// BuildRequestBody converts request into Doubao specific format.
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
req, err := relaycommon.GetTaskRequest(c)
@@ -218,20 +273,12 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
Content: []ContentItem{},
}
// Add text prompt
if req.Prompt != "" {
r.Content = append(r.Content, ContentItem{
Type: "text",
Text: req.Prompt,
})
}
// Add images if present
if req.HasImage() {
for _, imgURL := range req.Images {
r.Content = append(r.Content, ContentItem{
Type: "image_url",
ImageURL: &ImageURL{
ImageURL: &MediaURL{
URL: imgURL,
},
})
@@ -243,6 +290,16 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
return nil, errors.Wrap(err, "unmarshal metadata failed")
}
if sec, _ := strconv.Atoi(req.Seconds); sec > 0 {
r.Duration = lo.ToPtr(dto.IntValue(sec))
}
r.Content = lo.Reject(r.Content, func(c ContentItem, _ int) bool { return c.Type == "text" })
r.Content = append(r.Content, ContentItem{
Type: "text",
Text: req.Prompt,
})
return &r, nil
}
@@ -274,7 +331,7 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e
case "failed":
taskResult.Status = model.TaskStatusFailure
taskResult.Progress = "100%"
taskResult.Reason = "task failed"
taskResult.Reason = resTask.Error.Message
default:
// Unknown status, treat as processing
taskResult.Status = model.TaskStatusInProgress
@@ -302,8 +359,8 @@ func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, erro
if dResp.Status == "failed" {
openAIVideo.Error = &dto.OpenAIVideoError{
Message: "task failed",
Code: "failed",
Message: dResp.Error.Message,
Code: dResp.Error.Code,
}
}
+15
View File
@@ -5,6 +5,21 @@ var ModelList = []string{
"doubao-seedance-1-0-lite-t2v",
"doubao-seedance-1-0-lite-i2v",
"doubao-seedance-1-5-pro-251215",
"doubao-seedance-2-0-260128",
"doubao-seedance-2-0-fast-260128",
}
var ChannelName = "doubao-video"
// videoInputRatioMap 视频输入折扣比率(含视频单价 / 不含视频单价)。
// 管理员应将 ModelRatio 设置为"不含视频"的较高费率,
// 系统在检测到视频输入时自动乘以此折扣。
var videoInputRatioMap = map[string]float64{
"doubao-seedance-2-0-260128": 28.0 / 46.0, // ~0.6087
"doubao-seedance-2-0-fast-260128": 22.0 / 37.0, // ~0.5946
}
func GetVideoInputRatio(modelName string) (float64, bool) {
r, ok := videoInputRatioMap[modelName]
return r, ok
}
+3
View File
@@ -64,6 +64,9 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
}
return fmt.Sprintf("%s/api/paas/v4/embeddings", baseURL), nil
case relayconstant.RelayModeImagesGenerations:
if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" {
return fmt.Sprintf("%s/images/generations", specialPlan.OpenAIBaseURL), nil
}
return fmt.Sprintf("%s/api/paas/v4/images/generations", baseURL), nil
default:
if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" {
+16
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
@@ -696,6 +697,7 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
type Alias TaskSubmitReq
aux := &struct {
Metadata json.RawMessage `json:"metadata,omitempty"`
Duration json.RawMessage `json:"duration,omitempty"`
*Alias
}{
Alias: (*Alias)(t),
@@ -705,6 +707,20 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
return err
}
if len(aux.Duration) > 0 {
var durationInt int
if err := common.Unmarshal(aux.Duration, &durationInt); err == nil {
t.Duration = durationInt
} else {
var durationStr string
if err := common.Unmarshal(aux.Duration, &durationStr); err == nil && durationStr != "" {
if v, err := strconv.Atoi(durationStr); err == nil {
t.Duration = v
}
}
}
}
if len(aux.Metadata) > 0 {
var metadataStr string
if err := common.Unmarshal(aux.Metadata, &metadataStr); err == nil && metadataStr != "" {
+3 -1
View File
@@ -204,7 +204,9 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d
if err != nil {
return createTaskError(err, "invalid_multipart_form", http.StatusBadRequest, true)
}
} else if err := common.UnmarshalBodyReusable(c, &req); err != nil {
}
// 为了metadata字段的兼容性,统一UnmarshalBodyReusable
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
return createTaskError(err, "invalid_request", http.StatusBadRequest, true)
}
+29 -15
View File
@@ -146,21 +146,23 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
return priceData, nil
}
// ModelPriceHelperPerCall 按次计费的 PriceHelper (MJ、Task)
// ModelPriceHelperPerCall 按次/按量计费的 PriceHelper (MJ、Task)
func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types.PriceData, error) {
groupRatioInfo := HandleGroupRatio(c, info)
modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true)
// 如果没有配置价格,检查模型倍率配置
if !success {
usePrice := success
var modelRatio float64
// 没有配置费用,也要使用默认费用,否则按费率计费模型无法使用
if !success {
defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName]
if ok {
modelPrice = defaultPrice
usePrice = true
} else {
// 没有配置倍率也不接受没配置,那就返回错误
_, ratioSuccess, matchName := ratio_setting.GetModelRatio(info.OriginModelName)
var ratioSuccess bool
var matchName string
modelRatio, ratioSuccess, matchName = ratio_setting.GetModelRatio(info.OriginModelName)
acceptUnsetRatio := false
if info.UserSetting.AcceptUnsetRatioModel {
acceptUnsetRatio = true
@@ -168,25 +170,37 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types
if !ratioSuccess && !acceptUnsetRatio {
return types.PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开始自用模式;Model %s ratio or price not set, please set or start self-use mode", matchName, matchName)
}
// 未配置价格但配置了倍率,使用默认预扣价格
modelPrice = float64(common.PreConsumedQuota) / common.QuotaPerUnit
}
}
quota := int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
// 免费模型检测(与 ModelPriceHelper 对齐)
var quota int
freeModel := false
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 {
quota = 0
freeModel = true
if usePrice {
quota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 {
quota = 0
freeModel = true
}
}
} else {
// 按量计费:以模型倍率的一半作为预扣额度
quota = int(modelRatio / 2 * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
modelPrice = -1
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
if groupRatioInfo.GroupRatio == 0 || modelRatio == 0 {
quota = 0
freeModel = true
}
}
}
priceData := types.PriceData{
FreeModel: freeModel,
ModelPrice: modelPrice,
ModelRatio: modelRatio,
UsePrice: usePrice,
Quota: quota,
GroupRatioInfo: groupRatioInfo,
}