diff --git a/.github/workflows/docker-build-branch.yml b/.github/workflows/docker-build-branch.yml new file mode 100644 index 000000000000..32218e4258b3 --- /dev/null +++ b/.github/workflows/docker-build-branch.yml @@ -0,0 +1,66 @@ +name: Docker Build & Push + +on: + push: + branches: + - 'feat/**' + - 'fix/**' + - 'test/**' + - 'main' + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + name: Build & push + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Write VERSION + run: echo "${GITHUB_REF##*/}-${GITHUB_SHA::7}" > VERSION + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=sha,prefix= + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + + - name: Build & push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Image digest + run: | + echo "### Docker Image" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY diff --git a/controller/option.go b/controller/option.go index b5fdfdc1515b..6dc70c9f1b70 100644 --- a/controller/option.go +++ b/controller/option.go @@ -259,6 +259,24 @@ func UpdateOption(c *gin.Context) { }) return } + case "VoiceCloneUnlockRatio": + err = ratio_setting.UpdateVoiceCloneUnlockRatioByJSONString(option.Value.(string)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "音色解锁倍率设置失败: " + err.Error(), + }) + return + } + case "VideoResolutionRatio": + err = ratio_setting.UpdateVideoResolutionRatioByJSONString(option.Value.(string)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "视频分辨率倍率设置失败: " + err.Error(), + }) + return + } case "CreateCacheRatio": err = ratio_setting.UpdateCreateCacheRatioByJSONString(option.Value.(string)) if err != nil { diff --git a/controller/ratio_sync.go b/controller/ratio_sync.go index 1f57bcc245f3..a6af673af579 100644 --- a/controller/ratio_sync.go +++ b/controller/ratio_sync.go @@ -69,20 +69,24 @@ var pricingSyncFields = []string{ "image_ratio", "audio_ratio", "audio_completion_ratio", + "voice_clone_unlock_ratio", + "video_resolution_ratio", "model_price", billing_setting.BillingModeField, billing_setting.BillingExprField, } var numericPricingSyncFields = map[string]bool{ - "model_ratio": true, - "completion_ratio": true, - "cache_ratio": true, - "create_cache_ratio": true, - "image_ratio": true, - "audio_ratio": true, - "audio_completion_ratio": true, - "model_price": true, + "model_ratio": true, + "completion_ratio": true, + "cache_ratio": true, + "create_cache_ratio": true, + "image_ratio": true, + "audio_ratio": true, + "audio_completion_ratio": true, + "voice_clone_unlock_ratio": true, + "video_resolution_ratio": true, + "model_price": true, } type upstreamResult struct { @@ -136,6 +140,8 @@ func getLocalPricingSyncData() map[string]any { data["image_ratio"] = ratio_setting.GetImageRatioCopy() data["audio_ratio"] = ratio_setting.GetAudioRatioCopy() data["audio_completion_ratio"] = ratio_setting.GetAudioCompletionRatioCopy() + data["voice_clone_unlock_ratio"] = ratio_setting.GetVoiceCloneUnlockRatioCopy() + data["video_resolution_ratio"] = ratio_setting.GetVideoResolutionRatioCopy() return data } diff --git a/controller/relay.go b/controller/relay.go index 1d14dcc6f880..c12eae2e2e0c 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -39,6 +39,8 @@ func relayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIErro err = relay.ImageHelper(c, info) case relayconstant.RelayModeAudioSpeech: fallthrough + case relayconstant.RelayModeAudioVoiceClone: + fallthrough case relayconstant.RelayModeAudioTranslation: fallthrough case relayconstant.RelayModeAudioTranscription: diff --git a/dto/audio.go b/dto/audio.go index e0d4f9d07c7e..7abf7b75fdca 100644 --- a/dto/audio.go +++ b/dto/audio.go @@ -30,6 +30,17 @@ type AudioRequest struct { //Stream json.RawMessage `json:"stream,omitempty"` } +type AudioVoiceCloneRequest struct { + Model string `json:"model"` + Input json.RawMessage `json:"input,omitempty"` + Text string `json:"text,omitempty"` + VoiceID string `json:"voice_id,omitempty"` + FileID int64 `json:"file_id,omitempty"` + AudioURL string `json:"audio_url,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + rawText string +} + func (r *AudioRequest) GetTokenCountMeta() *types.TokenCountMeta { meta := &types.TokenCountMeta{ CombineText: r.Input, @@ -41,6 +52,32 @@ func (r *AudioRequest) GetTokenCountMeta() *types.TokenCountMeta { return meta } +func (r *AudioVoiceCloneRequest) GetTokenCountMeta() *types.TokenCountMeta { + text := r.Text + if text == "" && len(r.Input) > 0 { + var input struct { + Text string `json:"text"` + } + if json.Unmarshal(r.Input, &input) == nil { + text = input.Text + } + } + return &types.TokenCountMeta{ + CombineText: text, + TokenType: types.TokenTypeTextNumber, + } +} + +func (r *AudioVoiceCloneRequest) IsStream(c *gin.Context) bool { + return false +} + +func (r *AudioVoiceCloneRequest) SetModelName(modelName string) { + if modelName != "" { + r.Model = modelName + } +} + func (r *AudioRequest) IsStream(c *gin.Context) bool { return r.StreamFormat == "sse" } diff --git a/model/option.go b/model/option.go index ed1af72ebb12..cb1369ed137f 100644 --- a/model/option.go +++ b/model/option.go @@ -148,6 +148,8 @@ func InitOptionMap() { common.OptionMap["ImageRatio"] = ratio_setting.ImageRatio2JSONString() common.OptionMap["AudioRatio"] = ratio_setting.AudioRatio2JSONString() common.OptionMap["AudioCompletionRatio"] = ratio_setting.AudioCompletionRatio2JSONString() + common.OptionMap["VoiceCloneUnlockRatio"] = ratio_setting.VoiceCloneUnlockRatio2JSONString() + common.OptionMap["VideoResolutionRatio"] = ratio_setting.VideoResolutionRatio2JSONString() common.OptionMap["TopUpLink"] = common.TopUpLink //common.OptionMap["ChatLink"] = common.ChatLink //common.OptionMap["ChatLink2"] = common.ChatLink2 @@ -540,6 +542,10 @@ func updateOptionMap(key string, value string) (err error) { err = ratio_setting.UpdateAudioRatioByJSONString(value) case "AudioCompletionRatio": err = ratio_setting.UpdateAudioCompletionRatioByJSONString(value) + case "VoiceCloneUnlockRatio": + err = ratio_setting.UpdateVoiceCloneUnlockRatioByJSONString(value) + case "VideoResolutionRatio": + err = ratio_setting.UpdateVideoResolutionRatioByJSONString(value) case "TopUpLink": common.TopUpLink = value //case "ChatLink": diff --git a/model/pricing.go b/model/pricing.go index b9574a388587..f63685734046 100644 --- a/model/pricing.go +++ b/model/pricing.go @@ -31,6 +31,7 @@ type Pricing struct { ImageRatio *float64 `json:"image_ratio,omitempty"` AudioRatio *float64 `json:"audio_ratio,omitempty"` AudioCompletionRatio *float64 `json:"audio_completion_ratio,omitempty"` + VoiceCloneUnlockRatio *float64 `json:"voice_clone_unlock_ratio,omitempty"` EnableGroup []string `json:"enable_groups"` SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` BillingMode string `json:"billing_mode,omitempty"` @@ -331,6 +332,9 @@ func updatePricing() { audioCompletionRatio := ratio_setting.GetAudioCompletionRatio(model) pricing.AudioCompletionRatio = &audioCompletionRatio } + if voiceCloneUnlockRatio, ok := ratio_setting.GetVoiceCloneUnlockRatio(model); ok { + pricing.VoiceCloneUnlockRatio = &voiceCloneUnlockRatio + } if billingMode := billing_setting.GetBillingMode(model); billingMode == "tiered_expr" { if expr, ok := billing_setting.GetBillingExpr(model); ok && strings.TrimSpace(expr) != "" { pricing.BillingMode = billingMode diff --git a/relay/audio_handler.go b/relay/audio_handler.go index 7e9f6c481444..b71243a0ddec 100644 --- a/relay/audio_handler.go +++ b/relay/audio_handler.go @@ -19,16 +19,12 @@ func AudioHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type info.InitChannelMeta(c) audioReq, ok := info.Request.(*dto.AudioRequest) - if !ok { + voiceCloneReq, cloneOK := info.Request.(*dto.AudioVoiceCloneRequest) + if !ok && !cloneOK { return types.NewError(errors.New("invalid request type"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) } - request, err := common.DeepCopy(audioReq) - if err != nil { - return types.NewError(fmt.Errorf("failed to copy request to AudioRequest: %w", err), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) - } - - err = helper.ModelMappedHelper(c, info, request) + err := helper.ModelMappedHelper(c, info, info.Request) if err != nil { return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) } @@ -39,7 +35,19 @@ func AudioHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type } adaptor.Init(info) - ioReader, err := adaptor.ConvertAudioRequest(c, info, *request) + request := dto.AudioRequest{} + if ok { + copied, copyErr := common.DeepCopy(audioReq) + if copyErr != nil { + return types.NewError(fmt.Errorf("failed to copy request to AudioRequest: %w", copyErr), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + request = *copied + } else if cloneOK { + request.Model = voiceCloneReq.Model + request.Input = voiceCloneReq.Text + } + + ioReader, err := adaptor.ConvertAudioRequest(c, info, request) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go index cb3070ff367e..f12beb30ebf9 100644 --- a/relay/channel/ali/adaptor.go +++ b/relay/channel/ali/adaptor.go @@ -119,6 +119,14 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { } else { fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl) } + case constant.RelayModeAudioSpeech: + fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl) + case constant.RelayModeAudioVoiceClone: + if isAliMiniMaxSpeechModel(info.UpstreamModelName) || isAliMiniMaxSpeechModel(info.OriginModelName) { + fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl) + } else { + fullRequestURL = fmt.Sprintf("%s/api/v1/services/audio/tts/customization", info.ChannelBaseUrl) + } case constant.RelayModeCompletions: fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/completions", info.ChannelBaseUrl) default: @@ -151,6 +159,9 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel } req.Set("Content-Type", "application/json") } + if info.RelayMode == constant.RelayModeAudioSpeech || info.RelayMode == constant.RelayModeAudioVoiceClone { + req.Set("Content-Type", "application/json") + } return nil } @@ -226,8 +237,14 @@ 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) { - //TODO implement me - return nil, errors.New("not implemented") + switch info.RelayMode { + case constant.RelayModeAudioSpeech: + return convertOpenAIToAliTTS(c, info, request) + case constant.RelayModeAudioVoiceClone: + return convertAliVoiceClone(c, info, request) + default: + return nil, errors.New("unsupported audio relay mode") + } } func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { @@ -256,6 +273,10 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom err, usage = aliImageHandler(a, c, resp, info) case constant.RelayModeRerank: err, usage = RerankHandler(c, resp, info) + case constant.RelayModeAudioSpeech: + err, usage = aliTTSHandler(c, resp, info) + case constant.RelayModeAudioVoiceClone: + err, usage = aliVoiceCloneHandler(c, resp, info) default: adaptor := openai.Adaptor{} usage, err = adaptor.DoResponse(c, resp, info) diff --git a/relay/channel/ali/constants.go b/relay/channel/ali/constants.go index df64439bc174..78ed9cc1adfa 100644 --- a/relay/channel/ali/constants.go +++ b/relay/channel/ali/constants.go @@ -1,12 +1,25 @@ package ali +// https://help.aliyun.com/zh/model-studio/voice-clone-design-http-api +// https://help.aliyun.com/zh/model-studio/voice-cloning-user-guide + var ModelList = []string{ + // Chat models "qwen-turbo", "qwen-plus", "qwen-max", "qwen-max-longcontext", "qwq-32b", "qwen3-235b-a22b", + // TTS models - 语音合成 + "qwen-tts", + "qwen-tts-latest", + "qwen3-tts-flash", + "qwen3-tts-vc-realtime-2026-01-15", + // Voice clone models - 语音克隆 + "qwen-voice-clone", + "qwen-voice-enrollment", + // Embedding and rerank models "text-embedding-v1", "gte-rerank-v2", } diff --git a/relay/channel/ali/dto.go b/relay/channel/ali/dto.go index ec564f08ea6a..75182e8ae8bb 100644 --- a/relay/channel/ali/dto.go +++ b/relay/channel/ali/dto.go @@ -73,6 +73,7 @@ type AliUsage struct { OutputTokens int `json:"output_tokens"` TotalTokens int `json:"total_tokens"` ImageCount int `json:"image_count,omitempty"` + Count int `json:"count,omitempty"` } type TaskResult struct { diff --git a/relay/channel/ali/tts.go b/relay/channel/ali/tts.go new file mode 100644 index 000000000000..5abd51c65924 --- /dev/null +++ b/relay/channel/ali/tts.go @@ -0,0 +1,340 @@ +package ali + +import ( + "bytes" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "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/setting/ratio_setting" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" +) + +type AliTTSRequest struct { + Model string `json:"model"` + Input AliTTSInput `json:"input"` + Parameters map[string]interface{} `json:"parameters,omitempty"` +} + +type AliTTSInput struct { + Text string `json:"text"` + Voice string `json:"voice,omitempty"` +} + +type AliTTSResponse struct { + Output struct { + Audio struct { + Url string `json:"url,omitempty"` + Data string `json:"data,omitempty"` + } `json:"audio,omitempty"` + Data struct { + Audio string `json:"audio,omitempty"` + Status int `json:"status,omitempty"` + } `json:"data,omitempty"` + ExtraInfo struct { + AudioFormat string `json:"audio_format,omitempty"` + UsageCharacters int `json:"usage_characters,omitempty"` + } `json:"extra_info,omitempty"` + BaseResp struct { + StatusCode int `json:"status_code,omitempty"` + StatusMsg string `json:"status_msg,omitempty"` + } `json:"base_resp,omitempty"` + } `json:"output"` + Usage AliUsage `json:"usage"` + AliError +} + +func convertOpenAIToAliTTS(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + if isAliMiniMaxSpeechModel(request.Model) { + return convertOpenAIToAliMiniMaxTTS(c, info, request) + } + + parameters := map[string]interface{}{} + if request.ResponseFormat != "" { + parameters["format"] = request.ResponseFormat + } + if request.Speed != nil { + parameters["speed"] = *request.Speed + } + if len(request.Metadata) > 0 { + var metadata map[string]interface{} + if err := json.Unmarshal(request.Metadata, &metadata); err != nil { + return nil, fmt.Errorf("error unmarshalling metadata to ali tts parameters: %w", err) + } + for key, value := range metadata { + parameters[key] = value + } + } + + aliReq := AliTTSRequest{ + Model: request.Model, + Input: AliTTSInput{ + Text: request.Input, + Voice: request.Voice, + }, + Parameters: parameters, + } + if len(parameters) == 0 { + aliReq.Parameters = nil + } + + jsonData, err := common.Marshal(aliReq) + if err != nil { + return nil, fmt.Errorf("error marshalling ali tts request: %w", err) + } + return bytes.NewReader(jsonData), nil +} + +func convertOpenAIToAliMiniMaxTTS(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + input := map[string]interface{}{ + "text": request.Input, + } + voiceSetting := map[string]interface{}{} + if request.Voice != "" { + voiceSetting["voice_id"] = request.Voice + } + if request.Speed != nil { + voiceSetting["speed"] = *request.Speed + } + if len(voiceSetting) > 0 { + input["voice_setting"] = voiceSetting + } + if request.ResponseFormat != "" { + input["audio_setting"] = map[string]interface{}{ + "format": request.ResponseFormat, + } + } + if len(request.Metadata) > 0 { + var metadata map[string]interface{} + if err := json.Unmarshal(request.Metadata, &metadata); err != nil { + return nil, fmt.Errorf("error unmarshalling metadata to ali minimax tts input: %w", err) + } + for key, value := range metadata { + input[key] = value + } + } + aliReq := map[string]interface{}{ + "model": request.Model, + "input": input, + } + jsonData, err := common.Marshal(aliReq) + if err != nil { + return nil, fmt.Errorf("error marshalling ali minimax tts request: %w", err) + } + return bytes.NewReader(jsonData), nil +} + +func convertAliVoiceClone(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + storage, err := common.GetBodyStorage(c) + if err != nil { + return nil, err + } + body, err := storage.Bytes() + if err != nil { + return nil, err + } + var payload map[string]interface{} + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + if request.Model != "" { + payload["model"] = request.Model + } + jsonData, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return bytes.NewReader(jsonData), nil +} + +func isAliMiniMaxSpeechModel(model string) bool { + model = strings.TrimSpace(model) + return strings.HasPrefix(model, "MiniMax/") || strings.HasPrefix(model, "speech-") +} + +func aliTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return types.NewErrorWithStatusCode( + fmt.Errorf("failed to read ali tts response: %w", err), + types.ErrorCodeReadResponseBodyFailed, + http.StatusInternalServerError, + ), nil + } + defer service.CloseResponseBodyGracefully(resp) + + var aliResp AliTTSResponse + if err := common.Unmarshal(body, &aliResp); err != nil { + return types.NewErrorWithStatusCode( + fmt.Errorf("failed to unmarshal ali tts response: %w", err), + types.ErrorCodeBadResponseBody, + http.StatusInternalServerError, + ), nil + } + if aliResp.Code != "" { + return types.NewErrorWithStatusCode( + fmt.Errorf("ali tts error: %s - %s", aliResp.Code, aliResp.Message), + types.ErrorCodeBadResponse, + http.StatusBadRequest, + ), nil + } + if aliResp.Output.BaseResp.StatusCode != 0 && aliResp.Output.BaseResp.StatusMsg != "" { + return types.NewErrorWithStatusCode( + fmt.Errorf("ali minimax tts error: %d - %s", aliResp.Output.BaseResp.StatusCode, aliResp.Output.BaseResp.StatusMsg), + types.ErrorCodeBadResponse, + http.StatusBadRequest, + ), nil + } + + if aliResp.Output.Audio.Url != "" { + c.Redirect(http.StatusFound, aliResp.Output.Audio.Url) + } else if aliResp.Output.Audio.Data != "" { + audioData := aliResp.Output.Audio.Data + if comma := strings.Index(audioData, ","); comma >= 0 { + audioData = audioData[comma+1:] + } + decoded, decodeErr := base64.StdEncoding.DecodeString(audioData) + if decodeErr != nil { + return types.NewErrorWithStatusCode( + fmt.Errorf("failed to decode ali tts audio data: %w", decodeErr), + types.ErrorCodeBadResponse, + http.StatusInternalServerError, + ), nil + } + c.Data(http.StatusOK, "audio/mpeg", decoded) + } else if aliResp.Output.Data.Audio != "" { + if strings.HasPrefix(aliResp.Output.Data.Audio, "http") { + c.Redirect(http.StatusFound, aliResp.Output.Data.Audio) + } else { + decoded, decodeErr := hex.DecodeString(aliResp.Output.Data.Audio) + if decodeErr != nil { + return types.NewErrorWithStatusCode( + fmt.Errorf("failed to decode ali minimax audio data: %w", decodeErr), + types.ErrorCodeBadResponse, + http.StatusInternalServerError, + ), nil + } + contentType := "audio/mpeg" + switch strings.ToLower(aliResp.Output.ExtraInfo.AudioFormat) { + case "wav": + contentType = "audio/wav" + case "flac": + contentType = "audio/flac" + case "aac": + contentType = "audio/aac" + case "pcm": + contentType = "audio/pcm" + } + c.Data(http.StatusOK, contentType, decoded) + } + } else { + c.Data(resp.StatusCode, "application/json", body) + } + + promptTokens := info.GetEstimatePromptTokens() + if aliResp.Usage.Count > 0 { + promptTokens = aliResp.Usage.Count + } else if aliResp.Output.ExtraInfo.UsageCharacters > 0 { + promptTokens = aliResp.Output.ExtraInfo.UsageCharacters + } + totalTokens := aliResp.Usage.TotalTokens + if totalTokens == 0 { + totalTokens = promptTokens + } + if aliResp.Usage.InputTokens > 0 { + promptTokens = aliResp.Usage.InputTokens + } + audioTokens := common.Max(totalTokens-promptTokens, 0) + return nil, &dto.Usage{ + PromptTokens: promptTokens, + CompletionTokens: audioTokens, + TotalTokens: totalTokens, + PromptTokensDetails: dto.InputTokenDetails{ + TextTokens: promptTokens, + }, + CompletionTokenDetails: dto.OutputTokenDetails{ + AudioTokens: audioTokens, + }, + } +} + +// AliVoiceCloneResponse 阿里语音克隆响应结构 +type AliVoiceCloneResponse struct { + Output struct { + Voice string `json:"voice,omitempty"` + VoiceID string `json:"voice_id,omitempty"` + } `json:"output"` + Usage struct { + Count int `json:"count,omitempty"` // CosyVoice: 按次计费 + Characters int `json:"characters,omitempty"` // Qwen: 按字符计费(当传入text时) + } `json:"usage"` + RequestID string `json:"request_id"` + AliError +} + +func aliVoiceCloneHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return types.NewErrorWithStatusCode( + fmt.Errorf("failed to read ali voice clone response: %w", err), + types.ErrorCodeReadResponseBodyFailed, + http.StatusInternalServerError, + ), nil + } + defer service.CloseResponseBodyGracefully(resp) + + var cloneResp AliVoiceCloneResponse + if err := common.Unmarshal(body, &cloneResp); err != nil { + return types.NewErrorWithStatusCode( + fmt.Errorf("failed to unmarshal ali voice clone response: %w", err), + types.ErrorCodeBadResponseBody, + http.StatusInternalServerError, + ), nil + } + if cloneResp.Code != "" { + return types.NewErrorWithStatusCode( + fmt.Errorf("ali voice clone error: %s - %s", cloneResp.Code, cloneResp.Message), + types.ErrorCodeBadResponse, + http.StatusBadRequest, + ), nil + } + + c.Data(resp.StatusCode, "application/json", body) + + // 计算使用量 + // 1. 基础创建费用(按次)- 通过 model_price 配置 + // 2. 样例音频字符数(当传入 text 时)- 通过 usage.characters 计费 + promptTokens := cloneResp.Usage.Count + if isAliMiniMaxSpeechModel(info.OriginModelName) || isAliMiniMaxSpeechModel(info.UpstreamModelName) { + if unlockPrice, ok := ratio_setting.GetVoiceCloneUnlockRatio(info.OriginModelName); ok { + c.Set(service.ContextKeyVoiceCloneFixedPrice, unlockPrice) + } + } + + // 如果有样例音频字符数(Qwen传入text时),计入completion tokens + completionTokens := 0 + if cloneResp.Usage.Characters > 0 { + completionTokens = cloneResp.Usage.Characters + } + + return nil, &dto.Usage{ + PromptTokens: promptTokens, + CompletionTokens: completionTokens, + TotalTokens: promptTokens + completionTokens, + PromptTokensDetails: dto.InputTokenDetails{ + TextTokens: promptTokens, + }, + CompletionTokenDetails: dto.OutputTokenDetails{ + AudioTokens: completionTokens, + }, + } +} diff --git a/relay/channel/ali/tts_test.go b/relay/channel/ali/tts_test.go new file mode 100644 index 000000000000..4d13dabd8d53 --- /dev/null +++ b/relay/channel/ali/tts_test.go @@ -0,0 +1,154 @@ +package ali + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/gin-gonic/gin" +) + +func TestAliMiniMaxVoiceCloneResponseSetsUnlockPrice(t *testing.T) { + gin.SetMode(gin.TestMode) + ratio_setting.InitRatioSettings() + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + body := `{ + "output": { + "base_resp": {"status_code": 0, "status_msg": "success"}, + "demo_audio": "https://example.com/demo.mp3" + }, + "usage": {"characters": 15}, + "request_id": "test-request" + }` + + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + } + info := &relaycommon.RelayInfo{ + OriginModelName: "MiniMax/speech-02-turbo", + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "MiniMax/speech-02-turbo", + }, + } + + err, usage := aliVoiceCloneHandler(ctx, resp, info) + if err != nil { + t.Fatalf("aliVoiceCloneHandler returned error: %v", err) + } + + if usage.CompletionTokens != 15 { + t.Fatalf("CompletionTokens = %d, want 15", usage.CompletionTokens) + } + if usage.CompletionTokenDetails.AudioTokens != 15 { + t.Fatalf("audio completion tokens = %d, want 15", usage.CompletionTokenDetails.AudioTokens) + } + if got := ctx.GetFloat64(service.ContextKeyVoiceCloneFixedPrice); got != 9.9 { + t.Fatalf("voice clone fixed price = %v, want 9.9", got) + } +} + +func TestAliQwenVoiceCloneListDoesNotSetUnlockPrice(t *testing.T) { + gin.SetMode(gin.TestMode) + ratio_setting.InitRatioSettings() + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + // Qwen list returns usage.count=0 + body := `{ + "output": { + "base_resp": {"status_code": 0, "status_msg": "success"}, + "results": [] + }, + "usage": {"count": 0}, + "request_id": "test-qwen-list" + }` + + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + } + info := &relaycommon.RelayInfo{ + OriginModelName: "qwen-voice-enrollment", + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "qwen-voice-enrollment", + }, + } + + err, usage := aliVoiceCloneHandler(ctx, resp, info) + if err != nil { + t.Fatalf("aliVoiceCloneHandler returned error: %v", err) + } + + // Qwen is not MiniMax, should NOT set unlock price + if got := ctx.GetFloat64(service.ContextKeyVoiceCloneFixedPrice); got != 0 { + t.Fatalf("voice clone fixed price = %v, want 0 (Qwen should not set unlock price)", got) + } + // usage.count=0 -> prompt tokens should be 0 (not fallback to estimate) + if usage.PromptTokens != 0 { + t.Fatalf("PromptTokens = %d, want 0 (list has count=0)", usage.PromptTokens) + } + if usage.CompletionTokens != 0 { + t.Fatalf("CompletionTokens = %d, want 0", usage.CompletionTokens) + } +} + +func TestAliVoiceCloneUnmarshalError(t *testing.T) { + gin.SetMode(gin.TestMode) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + // Invalid JSON + body := `not json` + + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + } + info := &relaycommon.RelayInfo{ + OriginModelName: "MiniMax/speech-02-turbo", + } + + err, _ := aliVoiceCloneHandler(ctx, resp, info) + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } + if err.StatusCode != http.StatusInternalServerError { + t.Fatalf("StatusCode = %d, want %d", err.StatusCode, http.StatusInternalServerError) + } +} + +func TestAliVoiceCloneUpstreamError(t *testing.T) { + gin.SetMode(gin.TestMode) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + body := `{ + "code": "InvalidParameter", + "message": "voice not found", + "request_id": "test-error" + }` + + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + } + info := &relaycommon.RelayInfo{ + OriginModelName: "MiniMax/speech-02-turbo", + } + + err, _ := aliVoiceCloneHandler(ctx, resp, info) + if err == nil { + t.Fatal("expected error for upstream error, got nil") + } + if err.StatusCode != http.StatusBadRequest { + t.Fatalf("StatusCode = %d, want %d", err.StatusCode, http.StatusBadRequest) + } +} diff --git a/relay/channel/minimax/adaptor.go b/relay/channel/minimax/adaptor.go index 56d3a1ec7dca..4b85d89a1b34 100644 --- a/relay/channel/minimax/adaptor.go +++ b/relay/channel/minimax/adaptor.go @@ -8,6 +8,7 @@ import ( "io" "net/http" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/relay/channel" "github.com/QuantumNous/new-api/relay/channel/claude" @@ -33,6 +34,26 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn } func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + if info.RelayMode == constant.RelayModeAudioVoiceClone { + storage, err := common.GetBodyStorage(c) + if err != nil { + return nil, err + } + body, err := storage.Bytes() + if err != nil { + return nil, err + } + var payload map[string]interface{} + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + delete(payload, "model") + jsonData, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return bytes.NewReader(jsonData), nil + } if info.RelayMode != constant.RelayModeAudioSpeech { return nil, errors.New("unsupported audio relay mode") } @@ -42,7 +63,7 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf outputFormat := request.ResponseFormat minimaxRequest := MiniMaxTTSRequest{ - Model: info.OriginModelName, + Model: request.Model, Text: request.Input, VoiceSetting: VoiceSetting{ VoiceID: voiceID, @@ -124,6 +145,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.RelayModeAudioVoiceClone { + return handleVoiceCloneResponse(c, resp, info) + } if info.RelayMode == constant.RelayModeImagesGenerations { return miniMaxImageHandler(c, resp, info) } diff --git a/relay/channel/minimax/constants.go b/relay/channel/minimax/constants.go index efdab04c34c6..0feec2caf0ee 100644 --- a/relay/channel/minimax/constants.go +++ b/relay/channel/minimax/constants.go @@ -16,6 +16,8 @@ var ModelList = []string{ "speech-02-turbo", "speech-01-hd", "speech-01-turbo", + "minimax-tts", + "minimax-voice-clone", "MiniMax-M2.1", "MiniMax-M2.1-highspeed", "MiniMax-M2", diff --git a/relay/channel/minimax/relay-minimax.go b/relay/channel/minimax/relay-minimax.go index a1a05150c00f..1886db52e13e 100644 --- a/relay/channel/minimax/relay-minimax.go +++ b/relay/channel/minimax/relay-minimax.go @@ -25,6 +25,8 @@ func GetRequestURL(info *relaycommon.RelayInfo) (string, error) { return fmt.Sprintf("%s/v1/image_generation", baseUrl), nil case constant.RelayModeAudioSpeech: return fmt.Sprintf("%s/v1/t2a_v2", baseUrl), nil + case constant.RelayModeAudioVoiceClone: + return fmt.Sprintf("%s/v1/voice_clone", baseUrl), nil default: return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode) } diff --git a/relay/channel/minimax/tts.go b/relay/channel/minimax/tts.go index 61ecabf83106..ed10469e6d28 100644 --- a/relay/channel/minimax/tts.go +++ b/relay/channel/minimax/tts.go @@ -91,6 +91,21 @@ type MiniMaxBaseResp struct { StatusMsg string `json:"status_msg"` } +type MiniMaxVoiceCloneResponse struct { + InputSensitive bool `json:"input_sensitive"` + InputSensitiveType int `json:"input_sensitive_type"` + DemoAudio string `json:"demo_audio"` + ExtraInfo struct { + AudioLength int `json:"audio_length"` + AudioSampleRate int `json:"audio_sample_rate"` + AudioSize int `json:"audio_size"` + Bitrate int `json:"bitrate"` + WordCount int `json:"word_count"` + UsageCharacters int `json:"usage_characters"` // 试听音频字符数 + } `json:"extra_info"` + BaseResp MiniMaxBaseResp `json:"base_resp"` +} + func getContentTypeByFormat(format string) string { contentTypeMap := map[string]string{ "mp3": "audio/mpeg", @@ -163,15 +178,72 @@ func handleTTSResponse(c *gin.Context, resp *http.Response, info *relaycommon.Re c.Data(http.StatusOK, contentType, audioData) } + promptTokens := info.GetEstimatePromptTokens() + audioTokens := int(minimaxResp.ExtraInfo.UsageCharacters) usage = &dto.Usage{ - PromptTokens: info.GetEstimatePromptTokens(), - CompletionTokens: 0, - TotalTokens: int(minimaxResp.ExtraInfo.UsageCharacters), + PromptTokens: promptTokens, + CompletionTokens: audioTokens, + TotalTokens: promptTokens + audioTokens, + PromptTokensDetails: dto.InputTokenDetails{ + TextTokens: promptTokens, + }, + CompletionTokenDetails: dto.OutputTokenDetails{ + AudioTokens: audioTokens, + }, } return usage, nil } +func handleVoiceCloneResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("failed to read minimax voice clone response: %w", readErr), + types.ErrorCodeReadResponseBodyFailed, + http.StatusInternalServerError, + ) + } + defer resp.Body.Close() + + var cloneResp MiniMaxVoiceCloneResponse + if unmarshalErr := json.Unmarshal(body, &cloneResp); unmarshalErr == nil && cloneResp.BaseResp.StatusCode != 0 { + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("minimax voice clone error: %d - %s", cloneResp.BaseResp.StatusCode, cloneResp.BaseResp.StatusMsg), + types.ErrorCodeBadResponse, + http.StatusBadRequest, + ) + } + + c.Data(resp.StatusCode, "application/json", body) + + // 计算使用量 + // 1. 基础创建费用(按次)- 通过 model_price 配置 + // 2. 试听音频字符数(当传入 text 时)- 通过 extra_info.usage_characters 计费 + promptTokens := info.GetEstimatePromptTokens() + if promptTokens == 0 { + promptTokens = 1 // 至少计费1个token(创建操作) + } + + // 如果有试听音频字符数(传入text时),计入completion tokens + completionTokens := 0 + if cloneResp.ExtraInfo.UsageCharacters > 0 { + completionTokens = cloneResp.ExtraInfo.UsageCharacters + } + + return &dto.Usage{ + PromptTokens: promptTokens, + CompletionTokens: completionTokens, + TotalTokens: promptTokens + completionTokens, + PromptTokensDetails: dto.InputTokenDetails{ + TextTokens: promptTokens, + }, + CompletionTokenDetails: dto.OutputTokenDetails{ + AudioTokens: completionTokens, + }, + }, nil +} + func handleChatCompletionResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { body, readErr := io.ReadAll(resp.Body) if readErr != nil { diff --git a/relay/channel/task/ali/adaptor.go b/relay/channel/task/ali/adaptor.go index 5b6b01d939e6..30e937a0ed98 100644 --- a/relay/channel/task/ali/adaptor.go +++ b/relay/channel/task/ali/adaptor.go @@ -2,6 +2,7 @@ package ali import ( "bytes" + "context" "fmt" "io" "net/http" @@ -16,6 +17,7 @@ import ( "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/QuantumNous/new-api/setting/ratio_setting" "github.com/samber/lo" "github.com/gin-gonic/gin" @@ -244,7 +246,12 @@ func ProcessAliOtherRatios(aliReq *AliVideoRequest) (map[string]float64, error) resolution = resolution + "P" } } - if otherRatio, ok := aliRatios[aliReq.Model]; ok { + // 优先使用管理员配置的分辨率倍率,未配置时回退到内置硬编码 + if configRatios, ok := ratio_setting.GetVideoResolutionRatio(aliReq.Model); ok { + if ratio, ok := configRatios[resolution]; ok { + otherRatios[fmt.Sprintf("resolution-%s", resolution)] = ratio + } + } else if otherRatio, ok := aliRatios[aliReq.Model]; ok { if ratio, ok := otherRatio[resolution]; ok { otherRatios[fmt.Sprintf("resolution-%s", resolution)] = ratio } @@ -533,3 +540,68 @@ func convertAliStatus(aliStatus string) string { return dto.VideoStatusUnknown } } + +// AdjustBillingOnComplete 根据阿里返回的 usage 调整计费 +// 阿里视频计费公式:费用 = 分辨率价格 × 视频时长(秒) × 分组倍率 +// 实际生成的视频时长可能与请求时长不同,需要根据 usage.duration 调整 +func (a *TaskAdaptor) AdjustBillingOnComplete(task *model.Task, taskResult *relaycommon.TaskInfo) int { + // 只处理成功状态的任务 + if taskResult.Status != model.TaskStatusSuccess { + return 0 + } + + // 解析阿里响应获取 usage + var aliResp AliVideoResponse + if err := common.Unmarshal(task.Data, &aliResp); err != nil { + logger.LogError(context.TODO(), fmt.Sprintf("解析阿里视频响应失败: %v", err)) + return 0 + } + + // 如果没有 usage 信息,保持预扣费不变 + if aliResp.Usage == nil { + return 0 + } + + actualDuration := int(aliResp.Usage.Duration) + if actualDuration <= 0 { + return 0 + } + + // 获取预扣费时的请求时长 + requestedDuration := 5 // 默认5秒 + if task.PrivateData.BillingContext != nil && task.PrivateData.BillingContext.OtherRatios != nil { + if seconds, ok := task.PrivateData.BillingContext.OtherRatios["seconds"]; ok && seconds > 0 { + requestedDuration = int(seconds) + } + } + + // 如果实际时长与请求时长相同,无需调整 + if actualDuration == requestedDuration { + logger.LogInfo(context.TODO(), fmt.Sprintf( + "任务 %s 视频时长与预估一致: %d秒,无需调整计费", + task.TaskID, actualDuration, + )) + return 0 + } + + // 计算实际应扣额度 + // 公式:实际额度 = 预扣额度 × (实际时长 / 请求时长) + preConsumedQuota := task.Quota + actualQuota := int(float64(preConsumedQuota) * float64(actualDuration) / float64(requestedDuration)) + + if actualDuration < requestedDuration { + logger.LogInfo(context.TODO(), fmt.Sprintf( + "任务 %s 视频时长缩短: %d秒 → %d秒,应退还: %s", + task.TaskID, requestedDuration, actualDuration, + logger.FormatQuota(preConsumedQuota-actualQuota), + )) + } else { + logger.LogInfo(context.TODO(), fmt.Sprintf( + "任务 %s 视频时长增加: %d秒 → %d秒,应补扣: %s", + task.TaskID, requestedDuration, actualDuration, + logger.FormatQuota(actualQuota-preConsumedQuota), + )) + } + + return actualQuota +} diff --git a/relay/channel/task/ali/billing_test.go b/relay/channel/task/ali/billing_test.go new file mode 100644 index 000000000000..30ee6876a8e9 --- /dev/null +++ b/relay/channel/task/ali/billing_test.go @@ -0,0 +1,114 @@ +package ali + +import ( + "testing" + + "github.com/QuantumNous/new-api/setting/ratio_setting" +) + +func TestProcessAliOtherRatios_UsesHardcodedFallback(t *testing.T) { + ratio_setting.InitRatioSettings() + + // Test with no config override — should use hardcoded values + req := &AliVideoRequest{ + Model: "wan2.5-t2v-preview", + Parameters: &AliVideoParameters{ + Resolution: "720P", + }, + } + + ratios, err := ProcessAliOtherRatios(req) + if err != nil { + t.Fatalf("ProcessAliOtherRatios returned error: %v", err) + } + + if got := ratios["resolution-720P"]; got != 2 { + t.Fatalf("resolution-720P ratio = %v, want 2", got) + } +} + +func TestProcessAliOtherRatios_InvalidSize(t *testing.T) { + ratio_setting.InitRatioSettings() + + req := &AliVideoRequest{ + Model: "wan2.5-t2v-preview", + Parameters: &AliVideoParameters{ + Size: "9999*9999", // invalid size + }, + } + + _, err := ProcessAliOtherRatios(req) + if err == nil { + t.Fatal("expected error for invalid size, got nil") + } +} + +func TestProcessAliOtherRatios_UnknownModel(t *testing.T) { + ratio_setting.InitRatioSettings() + + req := &AliVideoRequest{ + Model: "unknown-model-v1", + Parameters: &AliVideoParameters{ + Resolution: "720P", + }, + } + + ratios, err := ProcessAliOtherRatios(req) + if err != nil { + t.Fatalf("ProcessAliOtherRatios returned error: %v", err) + } + + // Unknown model — no resolution entry + if got := ratios["resolution-720P"]; got != 0 { + t.Fatalf("resolution-720P ratio = %v, want 0 (unknown model)", got) + } +} + +func TestProcessAliOtherRatios_UsesConfigOverride(t *testing.T) { + ratio_setting.InitRatioSettings() + + // Set a config override for wan2.5-t2v-preview + jsonStr := `{"wan2.5-t2v-preview": {"480P": 1, "720P": 5, "1080P": 10}}` + if err := ratio_setting.UpdateVideoResolutionRatioByJSONString(jsonStr); err != nil { + t.Fatalf("failed to set config: %v", err) + } + + req := &AliVideoRequest{ + Model: "wan2.5-t2v-preview", + Parameters: &AliVideoParameters{ + Resolution: "720P", + }, + } + + ratios, err := ProcessAliOtherRatios(req) + if err != nil { + t.Fatalf("ProcessAliOtherRatios returned error: %v", err) + } + + // Config override should be used instead of hardcoded value (2) + if got := ratios["resolution-720P"]; got != 5 { + t.Fatalf("resolution-720P ratio = %v, want 5 (config override)", got) + } +} + +func TestVideoResolutionRatio_DefaultValues(t *testing.T) { + ratio_setting.InitRatioSettings() + + // Verify default values exist + ratios, ok := ratio_setting.GetVideoResolutionRatio("wan2.5-t2v-preview") + if !ok { + t.Fatal("wan2.5-t2v-preview not found in default VideoResolutionRatio") + } + if ratios["480P"] != 1 { + t.Fatalf("480P default = %v, want 1", ratios["480P"]) + } + if ratios["720P"] != 2 { + t.Fatalf("720P default = %v, want 2", ratios["720P"]) + } + + // Verify unknown model returns false + _, ok = ratio_setting.GetVideoResolutionRatio("nonexistent-model") + if ok { + t.Fatal("nonexistent-model should not be found") + } +} diff --git a/relay/constant/relay_mode.go b/relay/constant/relay_mode.go index 256715679213..be4e63240d53 100644 --- a/relay/constant/relay_mode.go +++ b/relay/constant/relay_mode.go @@ -35,6 +35,7 @@ const ( RelayModeAudioSpeech // tts RelayModeAudioTranscription // whisper RelayModeAudioTranslation // whisper + RelayModeAudioVoiceClone // voice clone RelayModeSunoFetch RelayModeSunoFetchByID @@ -78,6 +79,8 @@ func Path2RelayMode(path string) int { relayMode = RelayModeResponses } else if strings.HasPrefix(path, "/v1/audio/speech") { relayMode = RelayModeAudioSpeech + } else if strings.HasPrefix(path, "/v1/audio/voice-clone") || strings.HasPrefix(path, "/v1/audio/voice_clone") { + relayMode = RelayModeAudioVoiceClone } else if strings.HasPrefix(path, "/v1/audio/transcriptions") { relayMode = RelayModeAudioTranscription } else if strings.HasPrefix(path, "/v1/audio/translations") { diff --git a/relay/helper/valid_request.go b/relay/helper/valid_request.go index 2581b2812c94..648272256e65 100644 --- a/relay/helper/valid_request.go +++ b/relay/helper/valid_request.go @@ -44,7 +44,11 @@ func GetAndValidateRequest(c *gin.Context, format types.RelayFormat) (request dt case types.RelayFormatRerank: request, err = GetAndValidateRerankRequest(c) case types.RelayFormatOpenAIAudio: - request, err = GetAndValidAudioRequest(c, relayMode) + if relayMode == relayconstant.RelayModeAudioVoiceClone { + request, err = GetAndValidAudioVoiceCloneRequest(c) + } else { + request, err = GetAndValidAudioRequest(c, relayMode) + } case types.RelayFormatOpenAIRealtime: request = &dto.BaseRequest{} default: @@ -53,6 +57,17 @@ func GetAndValidateRequest(c *gin.Context, format types.RelayFormat) (request dt return request, err } +func GetAndValidAudioVoiceCloneRequest(c *gin.Context) (*dto.AudioVoiceCloneRequest, error) { + request := &dto.AudioVoiceCloneRequest{} + if err := common.UnmarshalBodyReusable(c, request); err != nil { + return nil, err + } + if request.Model == "" { + return nil, errors.New("model is required") + } + return request, nil +} + func GetAndValidAudioRequest(c *gin.Context, relayMode int) (*dto.AudioRequest, error) { audioRequest := &dto.AudioRequest{} err := common.UnmarshalBodyReusable(c, audioRequest) diff --git a/router/relay-router.go b/router/relay-router.go index 17a13cad7fd6..1f2a630a4b47 100644 --- a/router/relay-router.go +++ b/router/relay-router.go @@ -131,6 +131,12 @@ func SetRelayRouter(router *gin.Engine) { httpRouter.POST("/audio/speech", func(c *gin.Context) { controller.Relay(c, types.RelayFormatOpenAIAudio) }) + httpRouter.POST("/audio/voice-clone", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatOpenAIAudio) + }) + httpRouter.POST("/audio/voice_clone", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatOpenAIAudio) + }) // rerank related routes httpRouter.POST("/rerank", func(c *gin.Context) { diff --git a/service/quota.go b/service/quota.go index 862805d7cdb0..fcda04ec52f2 100644 --- a/service/quota.go +++ b/service/quota.go @@ -39,6 +39,8 @@ type QuotaInfo struct { GroupRatio float64 } +const ContextKeyVoiceCloneFixedPrice = "voice_clone_fixed_price" + func hasCustomModelRatio(modelName string, currentRatio float64) bool { defaultRatio, exists := ratio_setting.GetDefaultModelRatioMap()[modelName] if !exists { @@ -321,6 +323,13 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u } quota := calculateAudioQuota(quotaInfo) + voiceCloneFixedPrice := ctx.GetFloat64(ContextKeyVoiceCloneFixedPrice) + if voiceCloneFixedPrice > 0 { + voiceCloneFixedQuota := decimal.NewFromFloat(voiceCloneFixedPrice). + Mul(decimal.NewFromFloat(common.QuotaPerUnit)). + Mul(decimal.NewFromFloat(groupRatio)) + quota += int(voiceCloneFixedQuota.Round(0).IntPart()) + } if tieredOk { quota = tieredQuota } @@ -333,6 +342,9 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u } else { logContent = fmt.Sprintf("模型价格 %.2f,分组倍率 %.2f", modelPrice, groupRatio) } + if voiceCloneFixedPrice > 0 { + logContent += fmt.Sprintf(",音色克隆解锁价格 %.2f", voiceCloneFixedPrice) + } // record all the consume log even if quota is 0 if totalTokens == 0 { @@ -357,6 +369,9 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u } other := GenerateAudioOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio, completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio) + if voiceCloneFixedPrice > 0 { + other["voice_clone_unlock_price"] = voiceCloneFixedPrice + } if tieredResult != nil { InjectTieredBillingInfo(other, relayInfo, tieredResult) } diff --git a/setting/ratio_setting/exposed_cache.go b/setting/ratio_setting/exposed_cache.go index c88216fcb015..67b1a022ae4e 100644 --- a/setting/ratio_setting/exposed_cache.go +++ b/setting/ratio_setting/exposed_cache.go @@ -42,11 +42,16 @@ func GetExposedData() gin.H { return cloneGinH(c.data) } newData := gin.H{ - "model_ratio": GetModelRatioCopy(), - "completion_ratio": GetCompletionRatioCopy(), - "cache_ratio": GetCacheRatioCopy(), - "create_cache_ratio": GetCreateCacheRatioCopy(), - "model_price": GetModelPriceCopy(), + "model_ratio": GetModelRatioCopy(), + "completion_ratio": GetCompletionRatioCopy(), + "cache_ratio": GetCacheRatioCopy(), + "create_cache_ratio": GetCreateCacheRatioCopy(), + "image_ratio": GetImageRatioCopy(), + "audio_ratio": GetAudioRatioCopy(), + "audio_completion_ratio": GetAudioCompletionRatioCopy(), + "voice_clone_unlock_ratio": GetVoiceCloneUnlockRatioCopy(), + "video_resolution_ratio": GetVideoResolutionRatioCopy(), + "model_price": GetModelPriceCopy(), } exposedData.Store(&exposedCache{ data: newData, diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 80702ee42ad2..a5c7972f0f0a 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -209,6 +209,23 @@ var defaultModelRatio = map[string]float64{ "glm-4v-plus": 0.01 * RMB, "qwen-turbo": 0.8572, // ¥0.012 / 1k tokens "qwen-plus": 10, // ¥0.14 / 1k tokens + "qwen-tts": 0.05, // audio synthesis, per 1k characters + "qwen-tts-latest": 0.05, // audio synthesis, per 1k characters + "qwen3-tts-flash": 0.05, // audio synthesis, per 1k characters + "qwen3-tts-vc-realtime-2026-01-15": 0.05, // audio synthesis, per 1k characters + "minimax-tts": 0.5, // audio synthesis, per 1k characters + "MiniMax/speech-2.5-hd-preview": 0.5, + "MiniMax/speech-2.5-turbo-preview": 0.5, + "MiniMax/speech-02-hd": 0.5, + "MiniMax/speech-02-turbo": 0.5, + "MiniMax/speech-01-hd": 0.5, + "MiniMax/speech-01-turbo": 0.5, + "speech-2.5-hd-preview": 0.5, + "speech-2.5-turbo-preview": 0.5, + "speech-02-hd": 0.5, + "speech-02-turbo": 0.5, + "speech-01-hd": 0.5, + "speech-01-turbo": 0.5, "text-embedding-v1": 0.05, // ¥0.0007 / 1k tokens "SparkDesk-v1.1": 1.2858, // ¥0.018 / 1k tokens "SparkDesk-v2.1": 1.2858, // ¥0.018 / 1k tokens @@ -304,6 +321,9 @@ var defaultModelPrice = map[string]float64{ "sora-2": 0.3, "sora-2-pro": 0.5, "gpt-4o-mini-tts": 0.3, + "qwen-voice-clone": 0.1, + "qwen-voice-enrollment": 0.1, + "minimax-voice-clone": 9.9, // MiniMax 第一次创建自定义音频收费 9.9元/次 "veo-3.0-generate-001": 0.4, "veo-3.0-fast-generate-001": 0.15, "veo-3.1-generate-preview": 0.4, @@ -311,21 +331,63 @@ var defaultModelPrice = map[string]float64{ } var defaultAudioRatio = map[string]float64{ - "gpt-4o-audio-preview": 16, - "gpt-4o-mini-audio-preview": 66.67, - "gpt-4o-realtime-preview": 8, - "gpt-4o-mini-realtime-preview": 16.67, - "gpt-4o-mini-tts": 25, + "gpt-4o-audio-preview": 16, + "gpt-4o-mini-audio-preview": 66.67, + "gpt-4o-realtime-preview": 8, + "gpt-4o-mini-realtime-preview": 16.67, + "gpt-4o-mini-tts": 25, + "qwen-tts": 1, + "qwen-tts-latest": 1, + "qwen3-tts-flash": 1, + "qwen3-tts-vc-realtime-2026-01-15": 1, + "minimax-tts": 1, + "MiniMax/speech-2.5-hd-preview": 1, + "MiniMax/speech-2.5-turbo-preview": 1, + "MiniMax/speech-02-hd": 1, + "MiniMax/speech-02-turbo": 1, + "MiniMax/speech-01-hd": 1, + "MiniMax/speech-01-turbo": 1, + "speech-2.5-hd-preview": 1, + "speech-2.5-turbo-preview": 1, + "speech-02-hd": 1, + "speech-02-turbo": 1, + "speech-01-hd": 1, + "speech-01-turbo": 1, + // Voice clone - 创建操作按次计费,样例音频按字符计费 + "qwen-voice-clone": 1, // 创建操作基础计费 + "qwen-voice-enrollment": 1, // 创建操作基础计费 + "minimax-voice-clone": 1, // MiniMax创建操作基础计费 } var defaultAudioCompletionRatio = map[string]float64{ - "gpt-4o-realtime": 2, - "gpt-4o-mini-realtime": 2, - "gpt-4o-mini-tts": 1, - "tts-1": 0, - "tts-1-hd": 0, - "tts-1-1106": 0, - "tts-1-hd-1106": 0, + "gpt-4o-realtime": 2, + "gpt-4o-mini-realtime": 2, + "gpt-4o-mini-tts": 1, + "tts-1": 0, + "tts-1-hd": 0, + "tts-1-1106": 0, + "tts-1-hd-1106": 0, + "qwen-tts": 0, + "qwen-tts-latest": 0, + "qwen3-tts-flash": 0, + "qwen3-tts-vc-realtime-2026-01-15": 0, + "minimax-tts": 0, + "MiniMax/speech-2.5-hd-preview": 0, + "MiniMax/speech-2.5-turbo-preview": 0, + "MiniMax/speech-02-hd": 0, + "MiniMax/speech-02-turbo": 0, + "MiniMax/speech-01-hd": 0, + "MiniMax/speech-01-turbo": 0, + "speech-2.5-hd-preview": 0, + "speech-2.5-turbo-preview": 0, + "speech-02-hd": 0, + "speech-02-turbo": 0, + "speech-01-hd": 0, + "speech-01-turbo": 0, + // Voice clone - 样例音频字符数计费倍率 + "qwen-voice-clone": 0, // 样例音频按字符计费 + "qwen-voice-enrollment": 0, // 样例音频按字符计费 + "minimax-voice-clone": 0, // MiniMax样例音频按字符计费 } var modelPriceMap = types.NewRWMap[string, float64]() @@ -349,6 +411,8 @@ func InitRatioSettings() { imageRatioMap.AddAll(defaultImageRatio) audioRatioMap.AddAll(defaultAudioRatio) audioCompletionRatioMap.AddAll(defaultAudioCompletionRatio) + voiceCloneUnlockRatioMap.AddAll(defaultVoiceCloneUnlockRatio) + videoResolutionRatioMap.AddAll(defaultVideoResolutionRatio) } func GetModelPriceMap() map[string]float64 { @@ -661,9 +725,64 @@ func ModelRatio2JSONString() string { var defaultImageRatio = map[string]float64{ "gpt-image-1": 2, } + +// VoiceCloneUnlockRatio 音色解锁费(首次使用复刻音色时收取) +// 例如:MiniMax 首次使用复刻音色进行语音合成时收取 9.9 元 +var defaultVoiceCloneUnlockRatio = map[string]float64{ + "minimax-tts": 9.9, // MiniMax 首次使用复刻音色解锁费 9.9元 + "MiniMax/speech-2.5-hd-preview": 9.9, + "MiniMax/speech-2.5-turbo-preview": 9.9, + "MiniMax/speech-02-hd": 9.9, + "MiniMax/speech-02-turbo": 9.9, + "MiniMax/speech-01-hd": 9.9, + "MiniMax/speech-01-turbo": 9.9, +} + var imageRatioMap = types.NewRWMap[string, float64]() var audioRatioMap = types.NewRWMap[string, float64]() var audioCompletionRatioMap = types.NewRWMap[string, float64]() +var voiceCloneUnlockRatioMap = types.NewRWMap[string, float64]() + +// VideoResolutionRatio 视频分辨率倍率(阿里百炼视频生成模型按分辨率计费) +// 格式:model_name -> resolution -> ratio +var defaultVideoResolutionRatio = map[string]map[string]float64{ + "wan2.6-i2v": { + "720P": 1, + "1080P": 1 / 0.6, + }, + "wan2.5-t2v-preview": { + "480P": 1, + "720P": 2, + "1080P": 1 / 0.3, + }, + "wan2.2-t2v-plus": { + "480P": 1, + "1080P": 0.7 / 0.14, + }, + "wan2.5-i2v-preview": { + "480P": 1, + "720P": 2, + "1080P": 1 / 0.3, + }, + "wan2.2-i2v-plus": { + "480P": 1, + "1080P": 0.7 / 0.14, + }, + "wan2.2-kf2v-flash": { + "480P": 1, + "720P": 2, + "1080P": 4.8, + }, + "wan2.2-i2v-flash": { + "480P": 1, + "720P": 2, + }, + "wan2.2-s2v": { + "480P": 1, + "720P": 0.9 / 0.5, + }, +} +var videoResolutionRatioMap = types.NewRWMap[string, map[string]float64]() func ImageRatio2JSONString() string { return imageRatioMap.MarshalJSONString() @@ -721,6 +840,46 @@ func GetAudioCompletionRatioCopy() map[string]float64 { return audioCompletionRatioMap.ReadAll() } +func VoiceCloneUnlockRatio2JSONString() string { + return voiceCloneUnlockRatioMap.MarshalJSONString() +} + +func UpdateVoiceCloneUnlockRatioByJSONString(jsonStr string) error { + return types.LoadFromJsonStringWithCallback(voiceCloneUnlockRatioMap, jsonStr, InvalidateExposedDataCache) +} + +func GetVoiceCloneUnlockRatio(name string) (float64, bool) { + ratio, ok := voiceCloneUnlockRatioMap.Get(name) + if !ok { + return 0, false + } + return ratio, true +} + +func GetVoiceCloneUnlockRatioCopy() map[string]float64 { + return voiceCloneUnlockRatioMap.ReadAll() +} + +func VideoResolutionRatio2JSONString() string { + return videoResolutionRatioMap.MarshalJSONString() +} + +func UpdateVideoResolutionRatioByJSONString(jsonStr string) error { + return types.LoadFromJsonStringWithCallback(videoResolutionRatioMap, jsonStr, InvalidateExposedDataCache) +} + +func GetVideoResolutionRatio(modelName string) (map[string]float64, bool) { + ratio, ok := videoResolutionRatioMap.Get(modelName) + if !ok { + return nil, false + } + return ratio, true +} + +func GetVideoResolutionRatioCopy() map[string]map[string]float64 { + return videoResolutionRatioMap.ReadAll() +} + // 转换模型名,减少渠道必须配置各种带参数模型 func FormatMatchingModelName(name string) string { diff --git a/types/price_data.go b/types/price_data.go index 93bc6ae8d168..04400ae110e2 100644 --- a/types/price_data.go +++ b/types/price_data.go @@ -9,22 +9,23 @@ type GroupRatioInfo struct { } type PriceData struct { - FreeModel bool - ModelPrice float64 - ModelRatio float64 - CompletionRatio float64 - CacheRatio float64 - CacheCreationRatio float64 - CacheCreation5mRatio float64 - CacheCreation1hRatio float64 - ImageRatio float64 - AudioRatio float64 - AudioCompletionRatio float64 - OtherRatios map[string]float64 - UsePrice bool - Quota int // 按次计费的最终额度(MJ / Task) - QuotaToPreConsume int // 按量计费的预消耗额度 - GroupRatioInfo GroupRatioInfo + FreeModel bool + ModelPrice float64 + ModelRatio float64 + CompletionRatio float64 + CacheRatio float64 + CacheCreationRatio float64 + CacheCreation5mRatio float64 + CacheCreation1hRatio float64 + ImageRatio float64 + AudioRatio float64 + AudioCompletionRatio float64 + VoiceCloneUnlockRatio float64 // 音色解锁费(首次使用复刻音色时收取) + OtherRatios map[string]float64 + UsePrice bool + Quota int // 按次计费的最终额度(MJ / Task) + QuotaToPreConsume int // 按量计费的预消耗额度 + GroupRatioInfo GroupRatioInfo } func (p *PriceData) AddOtherRatio(key string, ratio float64) { diff --git a/web/classic/package.json b/web/classic/package.json index 84b2e0edf0e8..032a7c0cc0f8 100644 --- a/web/classic/package.json +++ b/web/classic/package.json @@ -4,9 +4,10 @@ "private": true, "type": "module", "dependencies": { - "@douyinfe/semi-icons": "^2.63.1", - "@douyinfe/semi-ui": "^2.69.1", - "@lobehub/icons": "^2.0.0", + "@douyinfe/semi-icons": "2.63.1", + "@douyinfe/semi-theme-default": "2.69.1", + "@douyinfe/semi-ui": "2.69.1", + "@lobehub/icons": "1.20.0", "@visactor/react-vchart": "~1.8.8", "@visactor/vchart": "~1.8.8", "@visactor/vchart-semi-theme": "~1.8.8", @@ -73,9 +74,9 @@ ] }, "devDependencies": { - "@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6", + "@douyinfe/vite-plugin-semi": "2.74.0-alpha.6", "@so1ve/prettier-config": "^3.1.0", - "@vitejs/plugin-react": "^4.2.1", + "@vitejs/plugin-react": "4.2.1", "autoprefixer": "^10.4.21", "code-inspector-plugin": "^1.3.3", "eslint": "8.57.0", @@ -86,7 +87,7 @@ "prettier": "^3.0.0", "tailwindcss": "^3", "typescript": "4.4.2", - "vite": "^5.2.0" + "vite": "5.1.0" }, "prettier": { "singleQuote": true, diff --git a/web/classic/src/components/settings/RatioSetting.jsx b/web/classic/src/components/settings/RatioSetting.jsx index 6f96e325208f..cdb4ac8de22c 100644 --- a/web/classic/src/components/settings/RatioSetting.jsx +++ b/web/classic/src/components/settings/RatioSetting.jsx @@ -43,6 +43,8 @@ const RatioSetting = () => { ImageRatio: '', AudioRatio: '', AudioCompletionRatio: '', + VoiceCloneUnlockRatio: '', + VideoResolutionRatio: '', AutoGroups: '', DefaultUseAutoGroup: false, ExposeRatioEnabled: false, diff --git a/web/classic/src/helpers/render.jsx b/web/classic/src/helpers/render.jsx index 46c95b236831..20ca84af46af 100644 --- a/web/classic/src/helpers/render.jsx +++ b/web/classic/src/helpers/render.jsx @@ -34,7 +34,7 @@ import { Gemini, Moonshot, Zhipu, - Qwen, + Tongyi, DeepSeek, Minimax, Wenxin, @@ -43,22 +43,11 @@ import { Hunyuan, Cohere, Cloudflare, - Ai360, Yi, - Jina, Mistral, - XAI, Ollama, - Doubao, Suno, - Xinference, OpenRouter, - Dify, - Coze, - SiliconCloud, - FastGPT, - Kling, - Jimeng, Perplexity, Replicate, } from '@lobehub/icons'; @@ -94,7 +83,6 @@ import { SiGitlab, SiGoogle, SiKeycloak, - SiLinkedin, SiNextcloud, SiNotion, SiOkta, @@ -107,6 +95,32 @@ import { SiX, } from 'react-icons/si'; +function createFallbackLobeIcon(label) { + const FallbackIcon = ({ size = 14 }) => ( + + {label} + + ); + FallbackIcon.Color = FallbackIcon; + return FallbackIcon; +} + +const Ai360 = createFallbackLobeIcon('360'); +const Jina = createFallbackLobeIcon('J'); +const XAI = LobeIcons.Grok || createFallbackLobeIcon('X'); +const Doubao = createFallbackLobeIcon('D'); +const Xinference = createFallbackLobeIcon('X'); +const Dify = createFallbackLobeIcon('D'); +const Coze = createFallbackLobeIcon('C'); +const SiliconCloud = createFallbackLobeIcon('S'); +const FastGPT = createFallbackLobeIcon('F'); +const Kling = createFallbackLobeIcon('K'); +const Jimeng = createFallbackLobeIcon('J'); +const SiLinkedin = SiOpenid; + // 获取侧边栏Lucide图标组件 export function getLucideIcon(key, selected = false) { const size = 16; @@ -228,7 +242,7 @@ export const getModelCategories = (() => { }, qwen: { label: t('通义千问'), - icon: , + icon: , filter: (model) => model.model_name.toLowerCase().includes('qwen'), }, deepseek: { @@ -358,7 +372,7 @@ export function getChannelIcon(channelType) { case 46: // 百度文心千帆V2 return ; case 17: // 阿里通义千问 - return ; + return ; case 18: // 讯飞星火认知 return ; case 16: // 智谱 ChatGLM diff --git a/web/classic/src/helpers/utils.jsx b/web/classic/src/helpers/utils.jsx index 7c7e63c73740..fe132ea33dcb 100644 --- a/web/classic/src/helpers/utils.jsx +++ b/web/classic/src/helpers/utils.jsx @@ -680,6 +680,7 @@ export const calculateModelPrice = ({ imageRatio: formatRatio(record.image_ratio), audioInputRatio: formatRatio(record.audio_ratio), audioOutputRatio: formatRatio(record.audio_completion_ratio), + voiceCloneUnlockRatio: formatRatio(record.voice_clone_unlock_ratio), isPerToken: true, isTokensDisplay: true, usedGroup, @@ -739,6 +740,9 @@ export const calculateModelPrice = ({ Number(record.audio_completion_ratio), ) : null, + voiceCloneUnlockPrice: hasRatioValue(record.voice_clone_unlock_ratio) + ? displayPrice(Number(record.voice_clone_unlock_ratio) * usedGroupRatio) + : null, unitLabel, isPerToken: true, isTokensDisplay: false, @@ -833,6 +837,12 @@ export const getModelPriceItems = ( value: priceData.audioOutputRatio, suffix: 'x', }, + { + key: 'voice-clone-unlock', + label: t('音色克隆解锁'), + value: priceData.voiceCloneUnlockRatio, + suffix: '', + }, ].filter( (item) => item.value !== null && item.value !== undefined && item.value !== '', @@ -883,6 +893,12 @@ export const getModelPriceItems = ( value: priceData.audioOutputPrice, suffix: unitSuffix, }, + { + key: 'voice-clone-unlock', + label: t('音色克隆解锁'), + value: priceData.voiceCloneUnlockPrice, + suffix: '', + }, ].filter((item) => item.value !== null && item.value !== undefined && item.value !== ''); } diff --git a/web/classic/src/i18n/locales/en.json b/web/classic/src/i18n/locales/en.json index 17511d2a5552..f3d85deca08d 100644 --- a/web/classic/src/i18n/locales/en.json +++ b/web/classic/src/i18n/locales/en.json @@ -2564,6 +2564,9 @@ "示例:{\"default\": [200, 100], \"vip\": [0, 1000]}。": "Example: {\"default\": [200, 100], \"vip\": [0, 1000]}.", "视频": "Video", "视频Remix": "Video remix", + "视频分辨率倍率": "Video Resolution Ratio", + "视频分辨率倍率(仅部分模型支持该计费)": "Video resolution ratio (only supported by some models)", + "音色克隆解锁": "Voice Clone Unlock", "视频无法在当前浏览器中播放,这可能是由于:": "The video cannot be played in this browser, possibly because:", "禁用": "Disable", "禁用 store 透传": "Disable store Pass-through", diff --git a/web/classic/src/i18n/locales/fr.json b/web/classic/src/i18n/locales/fr.json index a24d32bad00c..ac756f634e8b 100644 --- a/web/classic/src/i18n/locales/fr.json +++ b/web/classic/src/i18n/locales/fr.json @@ -2551,6 +2551,8 @@ "示例:{\"default\": [200, 100], \"vip\": [0, 1000]}。": "Exemple : {\"default\": [200, 100], \"vip\": [0, 1000]}.", "视频": "Vidéo", "视频Remix": "Remix vidéo", + "视频分辨率倍率": "Ratio de résolution vidéo", + "视频分辨率倍率(仅部分模型支持该计费)": "Ratio de résolution vidéo (uniquement pris en charge par certains modèles)", "视频无法在当前浏览器中播放,这可能是由于:": "La vidéo ne peut pas être lue dans ce navigateur, cela peut être dû à :", "禁用": "Désactiver", "禁用 store 透传": "Désactiver le passage de store", diff --git a/web/classic/src/i18n/locales/ja.json b/web/classic/src/i18n/locales/ja.json index dde2a1a578e2..35c1456fb61e 100644 --- a/web/classic/src/i18n/locales/ja.json +++ b/web/classic/src/i18n/locales/ja.json @@ -2520,6 +2520,8 @@ "示例:{\"default\": [200, 100], \"vip\": [0, 1000]}。": "例:{\"default\": [200, 100], \"vip\": [0, 1000]}。", "视频": "動画", "视频Remix": "動画リミックス", + "视频分辨率倍率": "動画解像度倍率", + "视频分辨率倍率(仅部分模型支持该计费)": "動画解像度倍率(一部のモデルのみサポート)", "视频无法在当前浏览器中播放,这可能是由于:": "The video cannot be played in this browser, possibly because:", "禁用": "無効にする", "禁用 store 透传": "ストアパススルーを無効にする", diff --git a/web/classic/src/i18n/locales/ru.json b/web/classic/src/i18n/locales/ru.json index b934dfe1bc5c..6a740dba372b 100644 --- a/web/classic/src/i18n/locales/ru.json +++ b/web/classic/src/i18n/locales/ru.json @@ -2571,6 +2571,8 @@ "示例:{\"default\": [200, 100], \"vip\": [0, 1000]}。": "Пример: {\"default\": [200, 100], \"vip\": [0, 1000]}.", "视频": "Видео", "视频Remix": "Видео ремикс", + "视频分辨率倍率": "Коэффициент разрешения видео", + "视频分辨率倍率(仅部分模型支持该计费)": "Коэффициент разрешения видео (поддерживается только некоторыми моделями)", "视频无法在当前浏览器中播放,这可能是由于:": "Видео нельзя воспроизвести в этом браузере, возможные причины:", "禁用": "Отключить", "禁用 store 透传": "Отключить сквозную передачу store", diff --git a/web/classic/src/i18n/locales/vi.json b/web/classic/src/i18n/locales/vi.json index 771a25fcf201..9d87a06f0582 100644 --- a/web/classic/src/i18n/locales/vi.json +++ b/web/classic/src/i18n/locales/vi.json @@ -2755,6 +2755,8 @@ "社群": "Cộng đồng", "视频": "Video", "视频Remix": "Remix video", + "视频分辨率倍率": "Tỷ lệ độ phân giải video", + "视频分辨率倍率(仅部分模型支持该计费)": "Tỷ lệ độ phân giải video (chỉ được hỗ trợ bởi một số mô hình)", "视频无法在当前浏览器中播放,这可能是由于:": "The video cannot be played in this browser, possibly because:", "禁用": "Vô hiệu hóa", "禁用 Passkey": "Vô hiệu hóa Passkey", diff --git a/web/classic/src/i18n/locales/zh.json b/web/classic/src/i18n/locales/zh.json index b70e8ffb955c..834dafc352d4 100644 --- a/web/classic/src/i18n/locales/zh.json +++ b/web/classic/src/i18n/locales/zh.json @@ -1754,6 +1754,8 @@ "示例:{\"default\": [200, 100], \"vip\": [0, 1000]}。": "示例:{\"default\": [200, 100], \"vip\": [0, 1000]}。", "视频": "视频", "视频Remix": "视频 Remix", + "视频分辨率倍率": "视频分辨率倍率", + "视频分辨率倍率(仅部分模型支持该计费)": "视频分辨率倍率(仅部分模型支持该计费)", "视频无法在当前浏览器中播放,这可能是由于:": "视频无法在当前浏览器中播放,这可能是由于:", "禁用": "禁用", "禁用 store 透传": "禁用 store 透传", diff --git a/web/classic/src/pages/Home/index.jsx b/web/classic/src/pages/Home/index.jsx index c242e08634f9..8de88d9e99e5 100644 --- a/web/classic/src/pages/Home/index.jsx +++ b/web/classic/src/pages/Home/index.jsx @@ -40,12 +40,11 @@ import { } from '@douyinfe/semi-icons'; import { Link } from 'react-router-dom'; import NoticeModal from '../../components/layout/NoticeModal'; +import * as LobeIcons from '@lobehub/icons'; import { Moonshot, OpenAI, - XAI, Zhipu, - Volcengine, Cohere, Claude, Gemini, @@ -55,14 +54,41 @@ import { Spark, Qingyan, DeepSeek, - Qwen, Midjourney, Grok, - AzureAI, Hunyuan, - Xinference, + Tongyi, } from '@lobehub/icons'; +const createFallbackLobeIcon = (label) => { + const FallbackIcon = ({ size = 40 }) => ( + + {label} + + ); + FallbackIcon.Color = FallbackIcon; + return FallbackIcon; +}; + +const XAI = LobeIcons.Grok || createFallbackLobeIcon('X'); +const Volcengine = LobeIcons.ByteDance || createFallbackLobeIcon('V'); +const AzureAI = LobeIcons.Azure || createFallbackLobeIcon('A'); +const Xinference = createFallbackLobeIcon('X'); +const Qwen = Tongyi; + const { Text } = Typography; const Home = () => { diff --git a/web/classic/src/pages/Setting/Ratio/ModelRatioSettings.jsx b/web/classic/src/pages/Setting/Ratio/ModelRatioSettings.jsx index e9be19785547..a07996b52c9d 100644 --- a/web/classic/src/pages/Setting/Ratio/ModelRatioSettings.jsx +++ b/web/classic/src/pages/Setting/Ratio/ModelRatioSettings.jsx @@ -48,6 +48,8 @@ export default function ModelRatioSettings(props) { ImageRatio: '', AudioRatio: '', AudioCompletionRatio: '', + VoiceCloneUnlockRatio: '', + VideoResolutionRatio: '', ExposeRatioEnabled: false, }); const refForm = useRef(); @@ -319,6 +321,58 @@ export default function ModelRatioSettings(props) { /> + + + verifyJSON(value), + message: '不是合法的 JSON 字符串', + }, + ]} + onChange={(value) => + setInputs({ ...inputs, VoiceCloneUnlockRatio: value }) + } + /> + + + + + verifyJSON(value), + message: '不是合法的 JSON 字符串', + }, + ]} + onChange={(value) => + setInputs({ ...inputs, VideoResolutionRatio: value }) + } + /> + + {t('音频补全倍率')} + + {t('音色克隆解锁价格')} + + + {t('视频分辨率倍率')} + {t('固定价格')} {t('表达式计费')} @@ -1094,6 +1116,12 @@ export default function UpstreamRatioSync(props) { AudioCompletionRatio: JSON.parse( props.options.AudioCompletionRatio || '{}', ), + VoiceCloneUnlockRatio: JSON.parse( + props.options.VoiceCloneUnlockRatio || '{}', + ), + VideoResolutionRatio: JSON.parse( + props.options.VideoResolutionRatio || '{}', + ), ModelPrice: JSON.parse(props.options.ModelPrice || '{}'), 'billing_setting.billing_mode': JSON.parse( props.options['billing_setting.billing_mode'] || '{}', diff --git a/web/classic/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx b/web/classic/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx index 2beafe01d938..6864e57d698c 100644 --- a/web/classic/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx +++ b/web/classic/src/pages/Setting/Ratio/components/ModelPricingEditor.jsx @@ -123,6 +123,7 @@ export default function ModelPricingEditor({ isOptionalFieldEnabled, handleOptionalFieldToggle, handleNumericFieldChange, + handleJsonFieldChange, handleBillingModeChange, handleBillingExprChange, handleRequestRuleExprChange, @@ -694,6 +695,86 @@ export default function ModelPricingEditor({ : '' } /> + + handleNumericFieldChange('voiceCloneUnlockPrice', value) + } + headerAction={ + + handleOptionalFieldToggle( + 'voiceCloneUnlockPrice', + checked, + ) + } + /> + } + hidden={ + !isOptionalFieldEnabled( + selectedModel, + 'voiceCloneUnlockPrice', + ) + } + extraText={ + !isOptionalFieldEnabled( + selectedModel, + 'voiceCloneUnlockPrice', + ) + ? t('当前未启用,需要时再打开即可。') + : t('复刻音色首次使用或解锁时收取的固定费用。') + } + /> +
+
+ {t('视频分辨率倍率')} + + handleOptionalFieldToggle( + 'videoResolutionRatio', + checked, + ) + } + /> +
+ {isOptionalFieldEnabled( + selectedModel, + 'videoResolutionRatio', + ) ? ( + + handleJsonFieldChange('videoResolutionRatio', value) + } + /> + ) : null} +
+ {!isOptionalFieldEnabled( + selectedModel, + 'videoResolutionRatio', + ) + ? t('当前未启用,需要时再打开即可。') + : t( + '不同视频分辨率的倍率设置。格式如 {"480P": 1, "720P": 2}。', + )} +
+
)} diff --git a/web/classic/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js b/web/classic/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js index ba9efdfcebc0..1814704f5aa6 100644 --- a/web/classic/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js +++ b/web/classic/src/pages/Setting/Ratio/hooks/useModelPricingEditorState.js @@ -40,6 +40,8 @@ const EMPTY_MODEL = { imagePrice: '', audioInputPrice: '', audioOutputPrice: '', + voiceCloneUnlockPrice: '', + videoResolutionRatio: '', billingExpr: '', requestRuleExpr: '', rawRatios: { @@ -50,6 +52,8 @@ const EMPTY_MODEL = { imageRatio: '', audioRatio: '', audioCompletionRatio: '', + voiceCloneUnlockRatio: '', + videoResolutionRatio: '', }, hasConflict: false, }; @@ -150,6 +154,15 @@ const buildModelState = (name, sourceMaps) => { const audioCompletionRatio = toNumericString( sourceMaps.AudioCompletionRatio[name], ); + const voiceCloneUnlockRatio = toNumericString( + sourceMaps.VoiceCloneUnlockRatio[name], + ); + const videoResolutionRatioRaw = sourceMaps.VideoResolutionRatio[name]; + const videoResolutionRatio = videoResolutionRatioRaw !== undefined + ? (typeof videoResolutionRatioRaw === 'object' + ? JSON.stringify(videoResolutionRatioRaw, null, 2) + : String(videoResolutionRatioRaw)) + : ''; const fixedPrice = toNumericString(sourceMaps.ModelPrice[name]); const inputPrice = ratioToBasePrice(modelRatio); const inputPriceNumber = toNumberOrNull(inputPrice); @@ -199,6 +212,8 @@ const buildModelState = (name, sourceMaps) => { toNumberOrNull(audioInputPrice) !== null && hasValue(audioCompletionRatio) ? formatNumber(Number(audioInputPrice) * Number(audioCompletionRatio)) : '', + voiceCloneUnlockPrice: voiceCloneUnlockRatio, + videoResolutionRatio, requestRuleExpr: '', rawRatios: { modelRatio, @@ -208,6 +223,8 @@ const buildModelState = (name, sourceMaps) => { imageRatio, audioRatio, audioCompletionRatio, + voiceCloneUnlockRatio, + videoResolutionRatio, }, hasConflict: hasValue(fixedPrice) && @@ -219,6 +236,8 @@ const buildModelState = (name, sourceMaps) => { imageRatio, audioRatio, audioCompletionRatio, + voiceCloneUnlockRatio, + videoResolutionRatio, ].some(hasValue), }; }; @@ -243,6 +262,8 @@ export const getModelWarnings = (model, t) => { model.imagePrice, model.audioInputPrice, model.audioOutputPrice, + model.voiceCloneUnlockPrice, + model.videoResolutionRatio, ].some(hasValue); if (model.hasConflict) { @@ -260,6 +281,8 @@ export const getModelWarnings = (model, t) => { model.rawRatios.imageRatio, model.rawRatios.audioRatio, model.rawRatios.audioCompletionRatio, + model.rawRatios.voiceCloneUnlockRatio, + model.rawRatios.videoResolutionRatio, ].some(hasValue) ) { warnings.push( @@ -315,6 +338,8 @@ export const buildSummaryText = (model, t) => { model.imagePrice, model.audioInputPrice, model.audioOutputPrice, + model.voiceCloneUnlockPrice, + model.videoResolutionRatio, ].filter(hasValue).length; const extraLabel = extraCount > 0 ? `,${t('额外价格项')} ${extraCount}` : ''; @@ -332,6 +357,8 @@ export const buildOptionalFieldToggles = (model) => ({ imagePrice: hasValue(model.imagePrice), audioInputPrice: hasValue(model.audioInputPrice), audioOutputPrice: hasValue(model.audioOutputPrice), + voiceCloneUnlockPrice: hasValue(model.voiceCloneUnlockPrice), + videoResolutionRatio: hasValue(model.videoResolutionRatio), }); const serializeModel = (model, t) => { @@ -344,6 +371,8 @@ const serializeModel = (model, t) => { ImageRatio: null, AudioRatio: null, AudioCompletionRatio: null, + VoiceCloneUnlockRatio: null, + VideoResolutionRatio: null, }; if (model.billingMode === 'per-request') { @@ -360,6 +389,7 @@ const serializeModel = (model, t) => { const imagePrice = toNumberOrNull(model.imagePrice); const audioInputPrice = toNumberOrNull(model.audioInputPrice); const audioOutputPrice = toNumberOrNull(model.audioOutputPrice); + const voiceCloneUnlockPrice = toNumberOrNull(model.voiceCloneUnlockPrice); const hasDependentPrice = [ completionPrice, @@ -409,6 +439,28 @@ const serializeModel = (model, t) => { model.rawRatios.audioCompletionRatio, ); } + if (hasValue(model.rawRatios.voiceCloneUnlockRatio)) { + result.VoiceCloneUnlockRatio = toNormalizedNumber( + model.rawRatios.voiceCloneUnlockRatio, + ); + } else if (voiceCloneUnlockPrice !== null) { + result.VoiceCloneUnlockRatio = toNormalizedNumber(voiceCloneUnlockPrice); + } + if (hasValue(model.rawRatios.videoResolutionRatio)) { + try { + result.VideoResolutionRatio = JSON.parse( + model.rawRatios.videoResolutionRatio, + ); + } catch (e) { + // skip invalid JSON for video resolution ratio + } + } else if (hasValue(model.videoResolutionRatio)) { + try { + result.VideoResolutionRatio = JSON.parse(model.videoResolutionRatio); + } catch (e) { + // skip invalid JSON for video resolution ratio + } + } return result; } @@ -448,6 +500,16 @@ const serializeModel = (model, t) => { audioOutputPrice / audioInputPrice, ); } + if (voiceCloneUnlockPrice !== null) { + result.VoiceCloneUnlockRatio = toNormalizedNumber(voiceCloneUnlockPrice); + } + if (hasValue(model.videoResolutionRatio)) { + try { + result.VideoResolutionRatio = JSON.parse(model.videoResolutionRatio); + } catch (e) { + // skip invalid JSON for video resolution ratio + } + } return result; }; @@ -550,6 +612,20 @@ export const buildPreviewRows = (model, t) => { ? model.rawRatios.audioCompletionRatio : t('空'), }, + { + key: 'VoiceCloneUnlockRatio', + label: 'VoiceCloneUnlockRatio', + value: hasValue(model.rawRatios.voiceCloneUnlockRatio) + ? model.rawRatios.voiceCloneUnlockRatio + : t('空'), + }, + { + key: 'VideoResolutionRatio', + label: 'VideoResolutionRatio', + value: hasValue(model.rawRatios.videoResolutionRatio) + ? model.rawRatios.videoResolutionRatio + : t('空'), + }, ]; return rows; } @@ -560,6 +636,7 @@ export const buildPreviewRows = (model, t) => { const imagePrice = toNumberOrNull(model.imagePrice); const audioInputPrice = toNumberOrNull(model.audioInputPrice); const audioOutputPrice = toNumberOrNull(model.audioOutputPrice); + const voiceCloneUnlockPrice = toNumberOrNull(model.voiceCloneUnlockPrice); const rows = [ { @@ -614,6 +691,21 @@ export const buildPreviewRows = (model, t) => { ? formatNumber(audioOutputPrice / audioInputPrice) : t('空'), }, + { + key: 'VoiceCloneUnlockRatio', + label: 'VoiceCloneUnlockRatio', + value: + voiceCloneUnlockPrice !== null + ? formatNumber(voiceCloneUnlockPrice) + : t('空'), + }, + { + key: 'VideoResolutionRatio', + label: 'VideoResolutionRatio', + value: hasValue(model.videoResolutionRatio) + ? model.videoResolutionRatio + : t('空'), + }, ]; return rows; }; @@ -646,6 +738,8 @@ export function useModelPricingEditorState({ ImageRatio: parseOptionJSON(options.ImageRatio), AudioRatio: parseOptionJSON(options.AudioRatio), AudioCompletionRatio: parseOptionJSON(options.AudioCompletionRatio), + VoiceCloneUnlockRatio: parseOptionJSON(options.VoiceCloneUnlockRatio), + VideoResolutionRatio: parseOptionJSON(options.VideoResolutionRatio), ModelBillingMode: parseOptionJSON(options['billing_setting.billing_mode']), ModelBillingExpr: parseOptionJSON(options['billing_setting.billing_expr']), }; @@ -661,6 +755,8 @@ export function useModelPricingEditorState({ ...Object.keys(sourceMaps.ImageRatio), ...Object.keys(sourceMaps.AudioRatio), ...Object.keys(sourceMaps.AudioCompletionRatio), + ...Object.keys(sourceMaps.VoiceCloneUnlockRatio), + ...Object.keys(sourceMaps.VideoResolutionRatio), ...Object.keys(sourceMaps.ModelBillingMode), ...Object.keys(sourceMaps.ModelBillingExpr), ]); @@ -872,6 +968,14 @@ export function useModelPricingEditorState({ }); }; + const handleJsonFieldChange = (field, value) => { + if (!selectedModel) return; + upsertModel(selectedModel.name, (model) => ({ + ...model, + [field]: value, + })); + }; + const handleBillingModeChange = (value) => { if (!selectedModel) return; upsertModel(selectedModel.name, (model) => { @@ -971,6 +1075,8 @@ export function useModelPricingEditorState({ imagePrice: selectedModel.imagePrice, audioInputPrice: selectedModel.audioInputPrice, audioOutputPrice: selectedModel.audioOutputPrice, + voiceCloneUnlockPrice: selectedModel.voiceCloneUnlockPrice, + videoResolutionRatio: selectedModel.videoResolutionRatio, billingExpr: selectedModel.billingExpr || '', requestRuleExpr: selectedModel.requestRuleExpr || '', }; @@ -1006,6 +1112,8 @@ export function useModelPricingEditorState({ audioOutputPrice: Boolean(sourceToggles.audioInputPrice) && Boolean(sourceToggles.audioOutputPrice), + voiceCloneUnlockPrice: Boolean(sourceToggles.voiceCloneUnlockPrice), + videoResolutionRatio: Boolean(sourceToggles.videoResolutionRatio), }; }); return next; @@ -1032,6 +1140,8 @@ export function useModelPricingEditorState({ ImageRatio: {}, AudioRatio: {}, AudioCompletionRatio: {}, + VoiceCloneUnlockRatio: {}, + VideoResolutionRatio: {}, }; const tieredOutput = { @@ -1122,6 +1232,7 @@ export function useModelPricingEditorState({ isOptionalFieldEnabled, handleOptionalFieldToggle, handleNumericFieldChange, + handleJsonFieldChange, handleBillingModeChange, handleBillingExprChange, handleRequestRuleExprChange, diff --git a/web/classic/vite.config.js b/web/classic/vite.config.js index 73e46212a587..ed8ac649ef65 100644 --- a/web/classic/vite.config.js +++ b/web/classic/vite.config.js @@ -19,10 +19,8 @@ For commercial licensing, please contact support@quantumnous.com import react from '@vitejs/plugin-react'; import { defineConfig, transformWithEsbuild } from 'vite'; -import pkg from '@douyinfe/vite-plugin-semi'; import path from 'path'; import { codeInspectorPlugin } from 'code-inspector-plugin'; -const { vitePluginSemi } = pkg; // https://vitejs.dev/config/ export default defineConfig({ @@ -51,9 +49,6 @@ export default defineConfig({ }, }, react(), - vitePluginSemi({ - cssLayer: true, - }), ], optimizeDeps: { force: true, diff --git a/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx b/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx index 5e9cee02b800..99f48b9b85b6 100644 --- a/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx +++ b/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx @@ -180,6 +180,8 @@ export function ModelMutateDrawer({ ImageRatio: '', AudioRatio: '', AudioCompletionRatio: '', + VoiceCloneUnlockRatio: '', + VideoResolutionRatio: '', ExposeRatioEnabled: false, 'billing_setting.billing_mode': '{}', 'billing_setting.billing_expr': '{}', diff --git a/web/default/src/features/system-settings/billing/index.tsx b/web/default/src/features/system-settings/billing/index.tsx index daad50668a92..2c0788b12142 100644 --- a/web/default/src/features/system-settings/billing/index.tsx +++ b/web/default/src/features/system-settings/billing/index.tsx @@ -47,6 +47,8 @@ const defaultBillingSettings: BillingSettings = { ImageRatio: '', AudioRatio: '', AudioCompletionRatio: '', + VoiceCloneUnlockRatio: '', + VideoResolutionRatio: '', ExposeRatioEnabled: false, 'billing_setting.billing_mode': '{}', 'billing_setting.billing_expr': '{}', diff --git a/web/default/src/features/system-settings/billing/section-registry.tsx b/web/default/src/features/system-settings/billing/section-registry.tsx index 1a1dc8a2f6c7..77c29587ad87 100644 --- a/web/default/src/features/system-settings/billing/section-registry.tsx +++ b/web/default/src/features/system-settings/billing/section-registry.tsx @@ -34,6 +34,8 @@ const getModelDefaults = (settings: BillingSettings) => ({ ImageRatio: settings.ImageRatio, AudioRatio: settings.AudioRatio, AudioCompletionRatio: settings.AudioCompletionRatio, + VoiceCloneUnlockRatio: settings.VoiceCloneUnlockRatio, + VideoResolutionRatio: settings.VideoResolutionRatio, ExposeRatioEnabled: settings.ExposeRatioEnabled, BillingMode: settings['billing_setting.billing_mode'], BillingExpr: settings['billing_setting.billing_expr'], diff --git a/web/default/src/features/system-settings/models/constants.ts b/web/default/src/features/system-settings/models/constants.ts index 54dae556b2f7..e14e24a14e2c 100644 --- a/web/default/src/features/system-settings/models/constants.ts +++ b/web/default/src/features/system-settings/models/constants.ts @@ -66,6 +66,8 @@ export const RATIO_TYPE_OPTIONS = [ { label: 'Image ratio', value: 'image_ratio' }, { label: 'Audio ratio', value: 'audio_ratio' }, { label: 'Audio completion ratio', value: 'audio_completion_ratio' }, + { label: 'Voice clone unlock ratio', value: 'voice_clone_unlock_ratio' }, + { label: 'Video resolution ratio', value: 'video_resolution_ratio' }, { label: 'Fixed price', value: 'model_price' }, { label: 'Expression billing', value: 'billing_expr' }, ] as const diff --git a/web/default/src/features/system-settings/models/index.tsx b/web/default/src/features/system-settings/models/index.tsx index 050235ac7333..0cbcefbcd8e8 100644 --- a/web/default/src/features/system-settings/models/index.tsx +++ b/web/default/src/features/system-settings/models/index.tsx @@ -51,6 +51,8 @@ const defaultModelSettings: ModelSettings = { ImageRatio: '', AudioRatio: '', AudioCompletionRatio: '', + VoiceCloneUnlockRatio: '', + VideoResolutionRatio: '', ExposeRatioEnabled: false, 'billing_setting.billing_mode': '{}', 'billing_setting.billing_expr': '{}', diff --git a/web/default/src/features/system-settings/models/model-pricing-sheet.tsx b/web/default/src/features/system-settings/models/model-pricing-sheet.tsx index b9e5c548e67e..b31cc1255e90 100644 --- a/web/default/src/features/system-settings/models/model-pricing-sheet.tsx +++ b/web/default/src/features/system-settings/models/model-pricing-sheet.tsx @@ -84,6 +84,8 @@ const createModelPricingSchema = (t: (key: string) => string) => imageRatio: z.string().optional(), audioRatio: z.string().optional(), audioCompletionRatio: z.string().optional(), + voiceCloneUnlockRatio: z.string().optional(), + videoResolutionRatio: z.string().optional(), }) type ModelPricingFormValues = z.infer< @@ -98,6 +100,8 @@ type LaneKey = | 'image' | 'audioInput' | 'audioOutput' + | 'voiceCloneUnlock' + | 'videoResolution' export type ModelRatioData = { name: string @@ -109,6 +113,8 @@ export type ModelRatioData = { imageRatio?: string audioRatio?: string audioCompletionRatio?: string + voiceCloneUnlockRatio?: string + videoResolutionRatio?: string billingMode?: PricingMode billingExpr?: string requestRuleExpr?: string @@ -146,6 +152,8 @@ const EMPTY_LANE_PRICES: Record = { image: '', audioInput: '', audioOutput: '', + voiceCloneUnlock: '', + videoResolution: '', } const EMPTY_LANE_ENABLED: Record = { @@ -155,6 +163,8 @@ const EMPTY_LANE_ENABLED: Record = { image: false, audioInput: false, audioOutput: false, + voiceCloneUnlock: false, + videoResolution: false, } const ratioFieldByLane: Record = { @@ -164,6 +174,8 @@ const ratioFieldByLane: Record = { image: 'imageRatio', audioInput: 'audioRatio', audioOutput: 'audioCompletionRatio', + voiceCloneUnlock: 'voiceCloneUnlockRatio', + videoResolution: 'videoResolutionRatio', } const laneConfigs: Array<{ @@ -208,6 +220,18 @@ const laneConfigs: Array<{ descriptionKey: 'Token price for audio output.', placeholder: '15.11', }, + { + key: 'voiceCloneUnlock', + titleKey: 'Voice clone unlock price', + descriptionKey: 'One-time fee for first use of cloned voice (e.g., MiniMax charges 9.9 yuan).', + placeholder: '9.9', + }, + { + key: 'videoResolution', + titleKey: 'Video resolution ratio', + descriptionKey: 'Model-specific resolution multipliers (e.g., 720P: 2, 1080P: 3.33).', + placeholder: '{}', + }, ] function hasValue(value: unknown): boolean { @@ -257,6 +281,8 @@ function createInitialLaneState(data?: ModelRatioData | null) { image: deriveLanePrice(data.imageRatio, promptPrice), audioInput: audioInputPrice, audioOutput: deriveLanePrice(data.audioCompletionRatio, audioInputPrice), + voiceCloneUnlock: data.voiceCloneUnlockRatio || '', + videoResolution: data.videoResolutionRatio || '', } return { @@ -269,6 +295,8 @@ function createInitialLaneState(data?: ModelRatioData | null) { image: hasValue(data.imageRatio), audioInput: hasValue(data.audioRatio), audioOutput: hasValue(data.audioCompletionRatio), + voiceCloneUnlock: hasValue(data.voiceCloneUnlockRatio), + videoResolution: hasValue(data.videoResolutionRatio), }, } } @@ -447,6 +475,8 @@ export function ModelPricingEditorPanel({ imageRatio: '', audioRatio: '', audioCompletionRatio: '', + voiceCloneUnlockRatio: '', + videoResolutionRatio: '', }, }) @@ -464,6 +494,8 @@ export function ModelPricingEditorPanel({ imageRatio: editData.imageRatio || '', audioRatio: editData.audioRatio || '', audioCompletionRatio: editData.audioCompletionRatio || '', + voiceCloneUnlockRatio: editData.voiceCloneUnlockRatio || '', + videoResolutionRatio: editData.videoResolutionRatio || '', }) setPricingMode( editData.billingMode === 'tiered_expr' @@ -485,6 +517,8 @@ export function ModelPricingEditorPanel({ imageRatio: '', audioRatio: '', audioCompletionRatio: '', + voiceCloneUnlockRatio: '', + videoResolutionRatio: '', }) setPricingMode('per-token') setBillingExpr('') @@ -723,6 +757,8 @@ export function ModelPricingEditorPanel({ imageRatio: values.imageRatio || '', audioRatio: values.audioRatio || '', audioCompletionRatio: values.audioCompletionRatio || '', + voiceCloneUnlockRatio: values.voiceCloneUnlockRatio || '', + videoResolutionRatio: values.videoResolutionRatio || '', } if (pricingMode === 'tiered_expr') { diff --git a/web/default/src/features/system-settings/models/model-ratio-form.tsx b/web/default/src/features/system-settings/models/model-ratio-form.tsx index 5b8d1f146ed3..7144786bfa6a 100644 --- a/web/default/src/features/system-settings/models/model-ratio-form.tsx +++ b/web/default/src/features/system-settings/models/model-ratio-form.tsx @@ -49,6 +49,8 @@ type ModelFormValues = { ImageRatio: string AudioRatio: string AudioCompletionRatio: string + VoiceCloneUnlockRatio: string + VideoResolutionRatio: string ExposeRatioEnabled: boolean BillingMode: string BillingExpr: string @@ -135,6 +137,8 @@ export const ModelRatioForm = memo(function ModelRatioForm({ imageRatio={form.watch('ImageRatio')} audioRatio={form.watch('AudioRatio')} audioCompletionRatio={form.watch('AudioCompletionRatio')} + voiceCloneUnlockRatio={form.watch('VoiceCloneUnlockRatio')} + videoResolutionRatio={form.watch('VideoResolutionRatio')} billingMode={form.watch('BillingMode')} billingExpr={form.watch('BillingExpr')} onChange={(field, value) => { diff --git a/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx b/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx index c364a2bbd858..f7a743fa13fe 100644 --- a/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx +++ b/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx @@ -76,6 +76,8 @@ type ModelRatioVisualEditorProps = { imageRatio: string audioRatio: string audioCompletionRatio: string + voiceCloneUnlockRatio: string + videoResolutionRatio: string billingMode: string billingExpr: string onChange: (field: string, value: string) => void @@ -91,6 +93,8 @@ type ModelRow = { imageRatio?: string audioRatio?: string audioCompletionRatio?: string + voiceCloneUnlockRatio?: string + videoResolutionRatio?: string billingMode?: string billingExpr?: string requestRuleExpr?: string @@ -202,6 +206,8 @@ export const ModelRatioVisualEditor = memo( imageRatio, audioRatio, audioCompletionRatio, + voiceCloneUnlockRatio, + videoResolutionRatio, billingMode, billingExpr, onChange, @@ -291,6 +297,14 @@ export const ModelRatioVisualEditor = memo( audioCompletionRatio, { fallback: {}, context: 'audio completion ratios' } ) + const voiceCloneUnlockMap = safeJsonParse>( + voiceCloneUnlockRatio, + { fallback: {}, context: 'voice clone unlock ratios' } + ) + const videoResolutionMap = safeJsonParse>>( + videoResolutionRatio, + { fallback: {}, context: 'video resolution ratios' } + ) const billingModeMap = safeJsonParse>( billingMode, { @@ -315,6 +329,8 @@ export const ModelRatioVisualEditor = memo( ...Object.keys(imageMap), ...Object.keys(audioMap), ...Object.keys(audioCompletionMap), + ...Object.keys(voiceCloneUnlockMap), + ...Object.keys(videoResolutionMap), ...Object.keys(billingModeMap), ...Object.keys(billingExprMap), ]) @@ -328,6 +344,8 @@ export const ModelRatioVisualEditor = memo( const image = imageMap[name]?.toString() || '' const audio = audioMap[name]?.toString() || '' const audioCompletion = audioCompletionMap[name]?.toString() || '' + const voiceCloneUnlock = voiceCloneUnlockMap[name]?.toString() || '' + const videoResolution = JSON.stringify(videoResolutionMap[name] || {}) const modeForModel = billingModeMap[name] if (modeForModel === 'tiered_expr') { @@ -350,6 +368,8 @@ export const ModelRatioVisualEditor = memo( imageRatio: image, audioRatio: audio, audioCompletionRatio: audioCompletion, + voiceCloneUnlockRatio: voiceCloneUnlock, + videoResolutionRatio: videoResolution, hasConflict: false, } } @@ -364,6 +384,8 @@ export const ModelRatioVisualEditor = memo( imageRatio: image, audioRatio: audio, audioCompletionRatio: audioCompletion, + voiceCloneUnlockRatio: voiceCloneUnlock, + videoResolutionRatio: videoResolution, billingMode: price !== '' ? 'per-request' : 'per-token', hasConflict: price !== '' && @@ -373,7 +395,9 @@ export const ModelRatioVisualEditor = memo( createCache !== '' || image !== '' || audio !== '' || - audioCompletion !== ''), + audioCompletion !== '' || + voiceCloneUnlock !== '' || + videoResolution !== '{}'), } }) @@ -387,6 +411,8 @@ export const ModelRatioVisualEditor = memo( imageRatio, audioRatio, audioCompletionRatio, + voiceCloneUnlockRatio, + videoResolutionRatio, billingMode, billingExpr, ]) @@ -424,6 +450,8 @@ export const ModelRatioVisualEditor = memo( imageRatio: model.imageRatio, audioRatio: model.audioRatio, audioCompletionRatio: model.audioCompletionRatio, + voiceCloneUnlockRatio: model.voiceCloneUnlockRatio, + videoResolutionRatio: model.videoResolutionRatio, billingMode: model.billingMode === 'tiered_expr' ? 'tiered_expr' @@ -501,6 +529,14 @@ export const ModelRatioVisualEditor = memo( audioCompletionRatio, { fallback: {}, silent: true } ) + const voiceCloneUnlockMap = safeJsonParse>( + voiceCloneUnlockRatio, + { fallback: {}, silent: true } + ) + const videoResolutionMap = safeJsonParse>>( + videoResolutionRatio, + { fallback: {}, silent: true } + ) const billingModeMap = safeJsonParse>( billingMode, { fallback: {}, silent: true } @@ -518,6 +554,8 @@ export const ModelRatioVisualEditor = memo( delete imageMap[name] delete audioMap[name] delete audioCompletionMap[name] + delete voiceCloneUnlockMap[name] + delete videoResolutionMap[name] delete billingModeMap[name] delete billingExprMap[name] @@ -532,6 +570,14 @@ export const ModelRatioVisualEditor = memo( 'AudioCompletionRatio', JSON.stringify(audioCompletionMap, null, 2) ) + onChange( + 'VoiceCloneUnlockRatio', + JSON.stringify(voiceCloneUnlockMap, null, 2) + ) + onChange( + 'VideoResolutionRatio', + JSON.stringify(videoResolutionMap, null, 2) + ) onChange( 'billing_setting.billing_mode', JSON.stringify(billingModeMap, null, 2) @@ -550,6 +596,8 @@ export const ModelRatioVisualEditor = memo( imageRatio, audioRatio, audioCompletionRatio, + voiceCloneUnlockRatio, + videoResolutionRatio, billingMode, billingExpr, onChange, @@ -736,6 +784,14 @@ export const ModelRatioVisualEditor = memo( audioCompletionRatio, { fallback: {}, silent: true } ) + const voiceCloneUnlockMap = safeJsonParse>( + voiceCloneUnlockRatio, + { fallback: {}, silent: true } + ) + const videoResolutionMap = safeJsonParse>>( + videoResolutionRatio, + { fallback: {}, silent: true } + ) const billingModeMap = safeJsonParse>( billingMode, { fallback: {}, silent: true } @@ -755,6 +811,22 @@ export const ModelRatioVisualEditor = memo( if (Number.isFinite(parsed)) target[name] = parsed } + const setJsonIfPresent = ( + target: Record>, + name: string, + value: string | undefined + ) => { + if (!value || value === '') return + try { + const parsed = JSON.parse(value) + if (typeof parsed === 'object' && parsed !== null) { + target[name] = parsed + } + } catch { + // Silently skip invalid JSON + } + } + targetNames.forEach((name) => { delete priceMap[name] delete ratioMap[name] @@ -764,6 +836,8 @@ export const ModelRatioVisualEditor = memo( delete imageMap[name] delete audioMap[name] delete audioCompletionMap[name] + delete voiceCloneUnlockMap[name] + delete videoResolutionMap[name] delete billingModeMap[name] delete billingExprMap[name] @@ -788,6 +862,8 @@ export const ModelRatioVisualEditor = memo( setIfPresent(imageMap, name, data.imageRatio) setIfPresent(audioMap, name, data.audioRatio) setIfPresent(audioCompletionMap, name, data.audioCompletionRatio) + setIfPresent(voiceCloneUnlockMap, name, data.voiceCloneUnlockRatio) + setJsonIfPresent(videoResolutionMap, name, data.videoResolutionRatio) } else if (data.price && data.price !== '') { setIfPresent(priceMap, name, data.price) } else { @@ -798,6 +874,8 @@ export const ModelRatioVisualEditor = memo( setIfPresent(imageMap, name, data.imageRatio) setIfPresent(audioMap, name, data.audioRatio) setIfPresent(audioCompletionMap, name, data.audioCompletionRatio) + setIfPresent(voiceCloneUnlockMap, name, data.voiceCloneUnlockRatio) + setJsonIfPresent(videoResolutionMap, name, data.videoResolutionRatio) } }) @@ -812,6 +890,14 @@ export const ModelRatioVisualEditor = memo( 'AudioCompletionRatio', JSON.stringify(audioCompletionMap, null, 2) ) + onChange( + 'VoiceCloneUnlockRatio', + JSON.stringify(voiceCloneUnlockMap, null, 2) + ) + onChange( + 'VideoResolutionRatio', + JSON.stringify(videoResolutionMap, null, 2) + ) onChange( 'billing_setting.billing_mode', JSON.stringify(billingModeMap, null, 2) @@ -830,6 +916,8 @@ export const ModelRatioVisualEditor = memo( imageRatio, audioRatio, audioCompletionRatio, + voiceCloneUnlockRatio, + videoResolutionRatio, billingMode, billingExpr, onChange, @@ -1040,6 +1128,8 @@ export const ModelRatioVisualEditor = memo( prevProps.imageRatio === nextProps.imageRatio && prevProps.audioRatio === nextProps.audioRatio && prevProps.audioCompletionRatio === nextProps.audioCompletionRatio && + prevProps.voiceCloneUnlockRatio === nextProps.voiceCloneUnlockRatio && + prevProps.videoResolutionRatio === nextProps.videoResolutionRatio && prevProps.billingMode === nextProps.billingMode && prevProps.billingExpr === nextProps.billingExpr && prevProps.onChange === nextProps.onChange diff --git a/web/default/src/features/system-settings/models/ratio-settings-card.tsx b/web/default/src/features/system-settings/models/ratio-settings-card.tsx index 7d72e4049978..61ddae835bf4 100644 --- a/web/default/src/features/system-settings/models/ratio-settings-card.tsx +++ b/web/default/src/features/system-settings/models/ratio-settings-card.tsx @@ -111,6 +111,24 @@ const modelSchema = z.object({ }) } }), + VoiceCloneUnlockRatio: z.string().superRefine((value, ctx) => { + const result = validateJsonString(value) + if (!result.valid) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: result.message || 'Invalid JSON', + }) + } + }), + VideoResolutionRatio: z.string().superRefine((value, ctx) => { + const result = validateJsonString(value) + if (!result.valid) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: result.message || 'Invalid JSON', + }) + } + }), ExposeRatioEnabled: z.boolean(), BillingMode: z.string().superRefine((value, ctx) => { const result = validateJsonString(value) @@ -246,6 +264,12 @@ export function RatioSettingsCard({ AudioCompletionRatio: normalizeJsonString( modelDefaults.AudioCompletionRatio ), + VoiceCloneUnlockRatio: normalizeJsonString( + modelDefaults.VoiceCloneUnlockRatio + ), + VideoResolutionRatio: normalizeJsonString( + modelDefaults.VideoResolutionRatio + ), ExposeRatioEnabled: modelDefaults.ExposeRatioEnabled, BillingMode: normalizeJsonString(modelDefaults.BillingMode), BillingExpr: normalizeJsonString(modelDefaults.BillingExpr), @@ -278,6 +302,12 @@ export function RatioSettingsCard({ AudioCompletionRatio: formatJsonForTextarea( modelDefaults.AudioCompletionRatio ), + VoiceCloneUnlockRatio: formatJsonForTextarea( + modelDefaults.VoiceCloneUnlockRatio + ), + VideoResolutionRatio: formatJsonForTextarea( + modelDefaults.VideoResolutionRatio + ), BillingMode: formatJsonForTextarea(modelDefaults.BillingMode), BillingExpr: formatJsonForTextarea(modelDefaults.BillingExpr), }, @@ -311,6 +341,12 @@ export function RatioSettingsCard({ AudioCompletionRatio: normalizeJsonString( modelDefaults.AudioCompletionRatio ), + VoiceCloneUnlockRatio: normalizeJsonString( + modelDefaults.VoiceCloneUnlockRatio + ), + VideoResolutionRatio: normalizeJsonString( + modelDefaults.VideoResolutionRatio + ), ExposeRatioEnabled: modelDefaults.ExposeRatioEnabled, BillingMode: normalizeJsonString(modelDefaults.BillingMode), BillingExpr: normalizeJsonString(modelDefaults.BillingExpr), @@ -328,6 +364,12 @@ export function RatioSettingsCard({ AudioCompletionRatio: formatJsonForTextarea( modelDefaults.AudioCompletionRatio ), + VoiceCloneUnlockRatio: formatJsonForTextarea( + modelDefaults.VoiceCloneUnlockRatio + ), + VideoResolutionRatio: formatJsonForTextarea( + modelDefaults.VideoResolutionRatio + ), BillingMode: formatJsonForTextarea(modelDefaults.BillingMode), BillingExpr: formatJsonForTextarea(modelDefaults.BillingExpr), }) @@ -370,6 +412,8 @@ export function RatioSettingsCard({ ImageRatio: normalizeJsonString(values.ImageRatio), AudioRatio: normalizeJsonString(values.AudioRatio), AudioCompletionRatio: normalizeJsonString(values.AudioCompletionRatio), + VoiceCloneUnlockRatio: normalizeJsonString(values.VoiceCloneUnlockRatio), + VideoResolutionRatio: normalizeJsonString(values.VideoResolutionRatio), ExposeRatioEnabled: values.ExposeRatioEnabled, BillingMode: normalizeJsonString(values.BillingMode), BillingExpr: normalizeJsonString(values.BillingExpr), @@ -492,6 +536,8 @@ export function RatioSettingsCard({ ImageRatio: modelDefaults.ImageRatio, AudioRatio: modelDefaults.AudioRatio, AudioCompletionRatio: modelDefaults.AudioCompletionRatio, + VoiceCloneUnlockRatio: modelDefaults.VoiceCloneUnlockRatio, + VideoResolutionRatio: modelDefaults.VideoResolutionRatio, 'billing_setting.billing_mode': modelDefaults.BillingMode, 'billing_setting.billing_expr': modelDefaults.BillingExpr, }} diff --git a/web/default/src/features/system-settings/models/upstream-ratio-sync-helpers.ts b/web/default/src/features/system-settings/models/upstream-ratio-sync-helpers.ts index 61cfd0e00abb..5ccea57f39a5 100644 --- a/web/default/src/features/system-settings/models/upstream-ratio-sync-helpers.ts +++ b/web/default/src/features/system-settings/models/upstream-ratio-sync-helpers.ts @@ -42,6 +42,8 @@ export const RATIO_SYNC_FIELDS: RatioType[] = [ 'image_ratio', 'audio_ratio', 'audio_completion_ratio', + 'voice_clone_unlock_ratio', + 'video_resolution_ratio', ] export const SYNC_FIELD_ORDER: RatioType[] = [ diff --git a/web/default/src/features/system-settings/models/upstream-ratio-sync.tsx b/web/default/src/features/system-settings/models/upstream-ratio-sync.tsx index 38bafa863f0b..59de24bdeb0e 100644 --- a/web/default/src/features/system-settings/models/upstream-ratio-sync.tsx +++ b/web/default/src/features/system-settings/models/upstream-ratio-sync.tsx @@ -69,6 +69,8 @@ type UpstreamRatioSyncProps = { ImageRatio: string AudioRatio: string AudioCompletionRatio: string + VoiceCloneUnlockRatio: string + VideoResolutionRatio: string 'billing_setting.billing_mode': string 'billing_setting.billing_expr': string } @@ -343,6 +345,12 @@ export function UpstreamRatioSync({ modelRatios }: UpstreamRatioSyncProps) { AudioCompletionRatio: parseJsonRecord( modelRatios.AudioCompletionRatio ), + VoiceCloneUnlockRatio: parseJsonRecord( + modelRatios.VoiceCloneUnlockRatio + ), + VideoResolutionRatio: parseJsonRecord>( + modelRatios.VideoResolutionRatio + ), ModelPrice: parseJsonRecord(modelRatios.ModelPrice), 'billing_setting.billing_mode': parseJsonRecord( modelRatios['billing_setting.billing_mode'] @@ -367,7 +375,9 @@ export function UpstreamRatioSync({ modelRatios }: UpstreamRatioSyncProps) { currentRatios.CreateCacheRatio[model] !== undefined || currentRatios.ImageRatio[model] !== undefined || currentRatios.AudioRatio[model] !== undefined || - currentRatios.AudioCompletionRatio[model] !== undefined + currentRatios.AudioCompletionRatio[model] !== undefined || + currentRatios.VoiceCloneUnlockRatio[model] !== undefined || + currentRatios.VideoResolutionRatio[model] !== undefined ) return 'ratio' return null @@ -375,7 +385,7 @@ export function UpstreamRatioSync({ modelRatios }: UpstreamRatioSyncProps) { const performSync = useCallback( async (currentRatios: ParsedRatios): Promise => { - const finalRatios: Record> = { + const finalRatios: Record>> = { ModelRatio: { ...currentRatios.ModelRatio }, CompletionRatio: { ...currentRatios.CompletionRatio }, CacheRatio: { ...currentRatios.CacheRatio }, @@ -383,6 +393,8 @@ export function UpstreamRatioSync({ modelRatios }: UpstreamRatioSyncProps) { ImageRatio: { ...currentRatios.ImageRatio }, AudioRatio: { ...currentRatios.AudioRatio }, AudioCompletionRatio: { ...currentRatios.AudioCompletionRatio }, + VoiceCloneUnlockRatio: { ...currentRatios.VoiceCloneUnlockRatio }, + VideoResolutionRatio: { ...currentRatios.VideoResolutionRatio }, ModelPrice: { ...currentRatios.ModelPrice }, 'billing_setting.billing_mode': { ...currentRatios['billing_setting.billing_mode'], @@ -407,6 +419,8 @@ export function UpstreamRatioSync({ modelRatios }: UpstreamRatioSyncProps) { delete finalRatios.ImageRatio[model] delete finalRatios.AudioRatio[model] delete finalRatios.AudioCompletionRatio[model] + delete finalRatios.VoiceCloneUnlockRatio[model] + delete finalRatios.VideoResolutionRatio[model] } if (hasRatio) { delete finalRatios.ModelPrice[model] diff --git a/web/default/src/features/system-settings/types.ts b/web/default/src/features/system-settings/types.ts index 21222680765d..364d6d1e093f 100644 --- a/web/default/src/features/system-settings/types.ts +++ b/web/default/src/features/system-settings/types.ts @@ -163,6 +163,8 @@ export type ModelSettings = { ImageRatio: string AudioRatio: string AudioCompletionRatio: string + VoiceCloneUnlockRatio: string + VideoResolutionRatio: string ExposeRatioEnabled: boolean 'billing_setting.billing_mode': string 'billing_setting.billing_expr': string @@ -206,6 +208,8 @@ export type BillingSettings = { ImageRatio: string AudioRatio: string AudioCompletionRatio: string + VoiceCloneUnlockRatio: string + VideoResolutionRatio: string ExposeRatioEnabled: boolean 'billing_setting.billing_mode': string 'billing_setting.billing_expr': string @@ -342,6 +346,8 @@ export type RatioType = | 'image_ratio' | 'audio_ratio' | 'audio_completion_ratio' + | 'voice_clone_unlock_ratio' + | 'video_resolution_ratio' | 'model_price' | 'billing_mode' | 'billing_expr' diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 7abaed071457..84647964a039 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -4351,6 +4351,7 @@ "Video": "Video", "Video length in seconds": "Video length in seconds", "Video Remix": "Video Remix", + "Video resolution ratio": "Video resolution ratio", "Vidu": "Vidu", "View": "View", "View all currently available models": "View all currently available models", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 1ce821f56abd..058294357475 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -4351,6 +4351,7 @@ "Video": "视频", "Video length in seconds": "视频时长(秒)", "Video Remix": "视频 Remix", + "Video resolution ratio": "视频分辨率倍率", "Vidu": "Vidu", "View": "查看", "View all currently available models": "查看当前可用的所有模型",