Merge branch 'main' into ssrf

# Conflicts:
#	service/cf_worker.go
This commit is contained in:
CaIon
2025-09-18 13:29:11 +08:00
12 changed files with 221 additions and 28 deletions

View File

@@ -90,6 +90,11 @@ func testChannel(channel *model.Channel, testModel string) testResult {
requestPath = "/v1/embeddings" // 修改请求路径 requestPath = "/v1/embeddings" // 修改请求路径
} }
// VolcEngine 图像生成模型
if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
requestPath = "/v1/images/generations"
}
c.Request = &http.Request{ c.Request = &http.Request{
Method: "POST", Method: "POST",
URL: &url.URL{Path: requestPath}, // 使用动态路径 URL: &url.URL{Path: requestPath}, // 使用动态路径
@@ -109,6 +114,21 @@ func testChannel(channel *model.Channel, testModel string) testResult {
} }
} }
// 重新检查模型类型并更新请求路径
if strings.Contains(strings.ToLower(testModel), "embedding") ||
strings.HasPrefix(testModel, "m3e") ||
strings.Contains(testModel, "bge-") ||
strings.Contains(testModel, "embed") ||
channel.Type == constant.ChannelTypeMokaAI {
requestPath = "/v1/embeddings"
c.Request.URL.Path = requestPath
}
if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
requestPath = "/v1/images/generations"
c.Request.URL.Path = requestPath
}
cache, err := model.GetUserCache(1) cache, err := model.GetUserCache(1)
if err != nil { if err != nil {
return testResult{ return testResult{
@@ -140,6 +160,9 @@ func testChannel(channel *model.Channel, testModel string) testResult {
if c.Request.URL.Path == "/v1/embeddings" { if c.Request.URL.Path == "/v1/embeddings" {
relayFormat = types.RelayFormatEmbedding relayFormat = types.RelayFormatEmbedding
} }
if c.Request.URL.Path == "/v1/images/generations" {
relayFormat = types.RelayFormatOpenAIImage
}
info, err := relaycommon.GenRelayInfo(c, relayFormat, request, nil) info, err := relaycommon.GenRelayInfo(c, relayFormat, request, nil)
@@ -201,6 +224,22 @@ func testChannel(channel *model.Channel, testModel string) testResult {
} }
// 调用专门用于 Embedding 的转换函数 // 调用专门用于 Embedding 的转换函数
convertedRequest, err = adaptor.ConvertEmbeddingRequest(c, info, embeddingRequest) convertedRequest, err = adaptor.ConvertEmbeddingRequest(c, info, embeddingRequest)
} else if info.RelayMode == relayconstant.RelayModeImagesGenerations {
// 创建一个 ImageRequest
prompt := "cat"
if request.Prompt != nil {
if promptStr, ok := request.Prompt.(string); ok && promptStr != "" {
prompt = promptStr
}
}
imageRequest := dto.ImageRequest{
Prompt: prompt,
Model: request.Model,
N: uint(request.N),
Size: request.Size,
}
// 调用专门用于图像生成的转换函数
convertedRequest, err = adaptor.ConvertImageRequest(c, info, imageRequest)
} else { } else {
// 对其他所有请求类型(如 Chat保持原有逻辑 // 对其他所有请求类型(如 Chat保持原有逻辑
convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, request) convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, request)

View File

@@ -53,7 +53,7 @@ func GetSetup(c *gin.Context) {
func PostSetup(c *gin.Context) { func PostSetup(c *gin.Context) {
// Check if setup is already completed // Check if setup is already completed
if constant.Setup { if constant.Setup {
c.JSON(400, gin.H{ c.JSON(200, gin.H{
"success": false, "success": false,
"message": "系统已经初始化完成", "message": "系统已经初始化完成",
}) })
@@ -66,7 +66,7 @@ func PostSetup(c *gin.Context) {
var req SetupRequest var req SetupRequest
err := c.ShouldBindJSON(&req) err := c.ShouldBindJSON(&req)
if err != nil { if err != nil {
c.JSON(400, gin.H{ c.JSON(200, gin.H{
"success": false, "success": false,
"message": "请求参数有误", "message": "请求参数有误",
}) })
@@ -77,7 +77,7 @@ func PostSetup(c *gin.Context) {
if !rootExists { if !rootExists {
// Validate username length: max 12 characters to align with model.User validation // Validate username length: max 12 characters to align with model.User validation
if len(req.Username) > 12 { if len(req.Username) > 12 {
c.JSON(400, gin.H{ c.JSON(200, gin.H{
"success": false, "success": false,
"message": "用户名长度不能超过12个字符", "message": "用户名长度不能超过12个字符",
}) })
@@ -85,7 +85,7 @@ func PostSetup(c *gin.Context) {
} }
// Validate password // Validate password
if req.Password != req.ConfirmPassword { if req.Password != req.ConfirmPassword {
c.JSON(400, gin.H{ c.JSON(200, gin.H{
"success": false, "success": false,
"message": "两次输入的密码不一致", "message": "两次输入的密码不一致",
}) })
@@ -93,7 +93,7 @@ func PostSetup(c *gin.Context) {
} }
if len(req.Password) < 8 { if len(req.Password) < 8 {
c.JSON(400, gin.H{ c.JSON(200, gin.H{
"success": false, "success": false,
"message": "密码长度至少为8个字符", "message": "密码长度至少为8个字符",
}) })
@@ -103,7 +103,7 @@ func PostSetup(c *gin.Context) {
// Create root user // Create root user
hashedPassword, err := common.Password2Hash(req.Password) hashedPassword, err := common.Password2Hash(req.Password)
if err != nil { if err != nil {
c.JSON(500, gin.H{ c.JSON(200, gin.H{
"success": false, "success": false,
"message": "系统错误: " + err.Error(), "message": "系统错误: " + err.Error(),
}) })
@@ -120,7 +120,7 @@ func PostSetup(c *gin.Context) {
} }
err = model.DB.Create(&rootUser).Error err = model.DB.Create(&rootUser).Error
if err != nil { if err != nil {
c.JSON(500, gin.H{ c.JSON(200, gin.H{
"success": false, "success": false,
"message": "创建管理员账号失败: " + err.Error(), "message": "创建管理员账号失败: " + err.Error(),
}) })
@@ -135,7 +135,7 @@ func PostSetup(c *gin.Context) {
// Save operation modes to database for persistence // Save operation modes to database for persistence
err = model.UpdateOption("SelfUseModeEnabled", boolToString(req.SelfUseModeEnabled)) err = model.UpdateOption("SelfUseModeEnabled", boolToString(req.SelfUseModeEnabled))
if err != nil { if err != nil {
c.JSON(500, gin.H{ c.JSON(200, gin.H{
"success": false, "success": false,
"message": "保存自用模式设置失败: " + err.Error(), "message": "保存自用模式设置失败: " + err.Error(),
}) })
@@ -144,7 +144,7 @@ func PostSetup(c *gin.Context) {
err = model.UpdateOption("DemoSiteEnabled", boolToString(req.DemoSiteEnabled)) err = model.UpdateOption("DemoSiteEnabled", boolToString(req.DemoSiteEnabled))
if err != nil { if err != nil {
c.JSON(500, gin.H{ c.JSON(200, gin.H{
"success": false, "success": false,
"message": "保存演示站点模式设置失败: " + err.Error(), "message": "保存演示站点模式设置失败: " + err.Error(),
}) })
@@ -160,7 +160,7 @@ func PostSetup(c *gin.Context) {
} }
err = model.DB.Create(&setup).Error err = model.DB.Create(&setup).Error
if err != nil { if err != nil {
c.JSON(500, gin.H{ c.JSON(200, gin.H{
"success": false, "success": false,
"message": "系统初始化失败: " + err.Error(), "message": "系统初始化失败: " + err.Error(),
}) })

View File

@@ -217,7 +217,7 @@ func genStripeLink(referenceId string, customerId string, email string, amount i
params := &stripe.CheckoutSessionParams{ params := &stripe.CheckoutSessionParams{
ClientReferenceID: stripe.String(referenceId), ClientReferenceID: stripe.String(referenceId),
SuccessURL: stripe.String(system_setting.ServerAddress + "/log"), SuccessURL: stripe.String(system_setting.ServerAddress + "/console/log"),
CancelURL: stripe.String(system_setting.ServerAddress + "/topup"), CancelURL: stripe.String(system_setting.ServerAddress + "/topup"),
LineItems: []*stripe.CheckoutSessionLineItemParams{ LineItems: []*stripe.CheckoutSessionLineItemParams{
{ {

View File

@@ -6,6 +6,10 @@ import (
"one-api/types" "one-api/types"
) )
const (
ResponsesOutputTypeImageGenerationCall = "image_generation_call"
)
type SimpleResponse struct { type SimpleResponse struct {
Usage `json:"usage"` Usage `json:"usage"`
Error any `json:"error"` Error any `json:"error"`
@@ -273,6 +277,42 @@ func (o *OpenAIResponsesResponse) GetOpenAIError() *types.OpenAIError {
return GetOpenAIError(o.Error) return GetOpenAIError(o.Error)
} }
func (o *OpenAIResponsesResponse) HasImageGenerationCall() bool {
if len(o.Output) == 0 {
return false
}
for _, output := range o.Output {
if output.Type == ResponsesOutputTypeImageGenerationCall {
return true
}
}
return false
}
func (o *OpenAIResponsesResponse) GetQuality() string {
if len(o.Output) == 0 {
return ""
}
for _, output := range o.Output {
if output.Type == ResponsesOutputTypeImageGenerationCall {
return output.Quality
}
}
return ""
}
func (o *OpenAIResponsesResponse) GetSize() string {
if len(o.Output) == 0 {
return ""
}
for _, output := range o.Output {
if output.Type == ResponsesOutputTypeImageGenerationCall {
return output.Size
}
}
return ""
}
type IncompleteDetails struct { type IncompleteDetails struct {
Reasoning string `json:"reasoning"` Reasoning string `json:"reasoning"`
} }
@@ -283,6 +323,8 @@ type ResponsesOutput struct {
Status string `json:"status"` Status string `json:"status"`
Role string `json:"role"` Role string `json:"role"`
Content []ResponsesOutputContent `json:"content"` Content []ResponsesOutputContent `json:"content"`
Quality string `json:"quality"`
Size string `json:"size"`
} }
type ResponsesOutputContent struct { type ResponsesOutputContent struct {

View File

@@ -33,6 +33,12 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
} }
if responsesResponse.HasImageGenerationCall() {
c.Set("image_generation_call", true)
c.Set("image_generation_call_quality", responsesResponse.GetQuality())
c.Set("image_generation_call_size", responsesResponse.GetSize())
}
// 写入新的 response body // 写入新的 response body
service.IOCopyBytesGracefully(c, resp, responseBody) service.IOCopyBytesGracefully(c, resp, responseBody)
@@ -80,7 +86,8 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
sendResponsesStreamData(c, streamResponse, data) sendResponsesStreamData(c, streamResponse, data)
switch streamResponse.Type { switch streamResponse.Type {
case "response.completed": case "response.completed":
if streamResponse.Response != nil && streamResponse.Response.Usage != nil { if streamResponse.Response != nil {
if streamResponse.Response.Usage != nil {
if streamResponse.Response.Usage.InputTokens != 0 { if streamResponse.Response.Usage.InputTokens != 0 {
usage.PromptTokens = streamResponse.Response.Usage.InputTokens usage.PromptTokens = streamResponse.Response.Usage.InputTokens
} }
@@ -94,6 +101,12 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
} }
} }
if streamResponse.Response.HasImageGenerationCall() {
c.Set("image_generation_call", true)
c.Set("image_generation_call_quality", streamResponse.Response.GetQuality())
c.Set("image_generation_call_size", streamResponse.Response.GetSize())
}
}
case "response.output_text.delta": case "response.output_text.delta":
// 处理输出文本 // 处理输出文本
responseTextBuilder.WriteString(streamResponse.Delta) responseTextBuilder.WriteString(streamResponse.Delta)

View File

@@ -36,6 +36,7 @@ type requestPayload struct {
Prompt string `json:"prompt,omitempty"` Prompt string `json:"prompt,omitempty"`
Seed int64 `json:"seed"` Seed int64 `json:"seed"`
AspectRatio string `json:"aspect_ratio"` AspectRatio string `json:"aspect_ratio"`
Frames int `json:"frames,omitempty"`
} }
type responsePayload struct { type responsePayload struct {
@@ -311,10 +312,15 @@ func hmacSHA256(key []byte, data []byte) []byte {
func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) { func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) {
r := requestPayload{ r := requestPayload{
ReqKey: "jimeng_vgfm_i2v_l20", ReqKey: req.Model,
Prompt: req.Prompt, Prompt: req.Prompt,
AspectRatio: "16:9", // Default aspect ratio }
Seed: -1, // Default to random
switch req.Duration {
case 10:
r.Frames = 241 // 24*10+1 = 241
default:
r.Frames = 121 // 24*5+1 = 121
} }
// Handle one-of image_urls or binary_data_base64 // Handle one-of image_urls or binary_data_base64
@@ -334,6 +340,22 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*
if err != nil { if err != nil {
return nil, errors.Wrap(err, "unmarshal metadata failed") return nil, errors.Wrap(err, "unmarshal metadata failed")
} }
// 即梦视频3.0 ReqKey转换
// https://www.volcengine.com/docs/85621/1792707
if strings.Contains(r.ReqKey, "jimeng_v30") {
if len(r.ImageUrls) > 1 {
// 多张图片:首尾帧生成
r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_tail_v30", 1)
} else if len(r.ImageUrls) == 1 {
// 单张图片:图生视频
r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_v30", 1)
} else {
// 无图片:文生视频
r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_t2v_v30", 1)
}
}
return &r, nil return &r, nil
} }

View File

@@ -41,6 +41,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) { func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
switch info.RelayMode { switch info.RelayMode {
case constant.RelayModeImagesGenerations:
return request, nil
case constant.RelayModeImagesEdits: case constant.RelayModeImagesEdits:
var requestBody bytes.Buffer var requestBody bytes.Buffer

View File

@@ -8,6 +8,7 @@ var ModelList = []string{
"Doubao-lite-32k", "Doubao-lite-32k",
"Doubao-lite-4k", "Doubao-lite-4k",
"Doubao-embedding", "Doubao-embedding",
"doubao-seedream-4-0-250828",
} }
var ChannelName = "volcengine" var ChannelName = "volcengine"

View File

@@ -276,6 +276,13 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
fileSearchTool.CallCount, dFileSearchQuota.String()) fileSearchTool.CallCount, dFileSearchQuota.String())
} }
} }
var dImageGenerationCallQuota decimal.Decimal
var imageGenerationCallPrice float64
if ctx.GetBool("image_generation_call") {
imageGenerationCallPrice = operation_setting.GetGPTImage1PriceOnceCall(ctx.GetString("image_generation_call_quality"), ctx.GetString("image_generation_call_size"))
dImageGenerationCallQuota = decimal.NewFromFloat(imageGenerationCallPrice).Mul(dGroupRatio).Mul(dQuotaPerUnit)
extraContent += fmt.Sprintf("Image Generation Call 花费 %s", dImageGenerationCallQuota.String())
}
var quotaCalculateDecimal decimal.Decimal var quotaCalculateDecimal decimal.Decimal
@@ -331,6 +338,8 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota) quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota)
// 添加 audio input 独立计费 // 添加 audio input 独立计费
quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota) quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota)
// 添加 image generation call 计费
quotaCalculateDecimal = quotaCalculateDecimal.Add(dImageGenerationCallQuota)
quota := int(quotaCalculateDecimal.Round(0).IntPart()) quota := int(quotaCalculateDecimal.Round(0).IntPart())
totalTokens := promptTokens + completionTokens totalTokens := promptTokens + completionTokens
@@ -429,6 +438,10 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
other["audio_input_token_count"] = audioTokens other["audio_input_token_count"] = audioTokens
other["audio_input_price"] = audioInputPrice other["audio_input_price"] = audioInputPrice
} }
if !dImageGenerationCallQuota.IsZero() {
other["image_generation_call"] = true
other["image_generation_call_price"] = imageGenerationCallPrice
}
model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{ model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{
ChannelId: relayInfo.ChannelId, ChannelId: relayInfo.ChannelId,
PromptTokens: promptTokens, PromptTokens: promptTokens,

View File

@@ -10,6 +10,18 @@ const (
FileSearchPrice = 2.5 FileSearchPrice = 2.5
) )
const (
GPTImage1Low1024x1024 = 0.011
GPTImage1Low1024x1536 = 0.016
GPTImage1Low1536x1024 = 0.016
GPTImage1Medium1024x1024 = 0.042
GPTImage1Medium1024x1536 = 0.063
GPTImage1Medium1536x1024 = 0.063
GPTImage1High1024x1024 = 0.167
GPTImage1High1024x1536 = 0.25
GPTImage1High1536x1024 = 0.25
)
const ( const (
// Gemini Audio Input Price // Gemini Audio Input Price
Gemini25FlashPreviewInputAudioPrice = 1.00 Gemini25FlashPreviewInputAudioPrice = 1.00
@@ -65,3 +77,31 @@ func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 {
} }
return 0 return 0
} }
func GetGPTImage1PriceOnceCall(quality string, size string) float64 {
prices := map[string]map[string]float64{
"low": {
"1024x1024": GPTImage1Low1024x1024,
"1024x1536": GPTImage1Low1024x1536,
"1536x1024": GPTImage1Low1536x1024,
},
"medium": {
"1024x1024": GPTImage1Medium1024x1024,
"1024x1536": GPTImage1Medium1024x1536,
"1536x1024": GPTImage1Medium1536x1024,
},
"high": {
"1024x1024": GPTImage1High1024x1024,
"1024x1536": GPTImage1High1024x1536,
"1536x1024": GPTImage1High1536x1024,
},
}
if qualityMap, exists := prices[quality]; exists {
if price, exists := qualityMap[size]; exists {
return price
}
}
return GPTImage1High1024x1024
}

View File

@@ -1027,6 +1027,8 @@ export function renderModelPrice(
audioInputSeperatePrice = false, audioInputSeperatePrice = false,
audioInputTokens = 0, audioInputTokens = 0,
audioInputPrice = 0, audioInputPrice = 0,
imageGenerationCall = false,
imageGenerationCallPrice = 0,
) { ) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio( const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
groupRatio, groupRatio,
@@ -1069,7 +1071,8 @@ export function renderModelPrice(
(audioInputTokens / 1000000) * audioInputPrice * groupRatio + (audioInputTokens / 1000000) * audioInputPrice * groupRatio +
(completionTokens / 1000000) * completionRatioPrice * groupRatio + (completionTokens / 1000000) * completionRatioPrice * groupRatio +
(webSearchCallCount / 1000) * webSearchPrice * groupRatio + (webSearchCallCount / 1000) * webSearchPrice * groupRatio +
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio; (fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
(imageGenerationCallPrice * groupRatio);
return ( return (
<> <>
@@ -1131,7 +1134,13 @@ export function renderModelPrice(
})} })}
</p> </p>
)} )}
<p></p> {imageGenerationCall && imageGenerationCallPrice > 0 && (
<p>
{i18next.t('图片生成调用:${{price}} / 1次', {
price: imageGenerationCallPrice,
})}
</p>
)}
<p> <p>
{(() => { {(() => {
// 构建输入部分描述 // 构建输入部分描述
@@ -1211,6 +1220,16 @@ export function renderModelPrice(
}, },
) )
: '', : '',
imageGenerationCall && imageGenerationCallPrice > 0
? i18next.t(
' + 图片生成调用 ${{price}} / 1次 * {{ratioType}} {{ratio}}',
{
price: imageGenerationCallPrice,
ratio: groupRatio,
ratioType: ratioLabel,
},
)
: '',
].join(''); ].join('');
return i18next.t( return i18next.t(

View File

@@ -447,6 +447,8 @@ export const useLogsData = () => {
other?.audio_input_seperate_price || false, other?.audio_input_seperate_price || false,
other?.audio_input_token_count || 0, other?.audio_input_token_count || 0,
other?.audio_input_price || 0, other?.audio_input_price || 0,
other?.image_generation_call || false,
other?.image_generation_call_price || 0,
); );
} }
expandDataLocal.push({ expandDataLocal.push({