Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4a6ad80
feat: add qwen and minimax tts voice clone billing
youyao666 May 30, 2026
e7fdb14
fix: support dashscope minimax tts relay billing
youyao666 May 30, 2026
74fd3a0
feat: Add Qwen and MiniMax TTS voice clone support with dual billing
youyao666 May 30, 2026
0ae4096
fix: register VoiceCloneUnlockRatio in backend option map
youyao666 May 30, 2026
a7c882f
fix: add missing VoiceCloneUnlockRatio props in frontend components
youyao666 May 30, 2026
1c44095
fix: correct voice clone billing and add missing frontend pricing sup…
youyao666 May 30, 2026
e435519
test: add voice clone edge case tests for qwen list, unmarshal error,…
youyao666 May 30, 2026
69a8a8f
feat: add configurable ali video resolution billing ratios
youyao666 May 30, 2026
a320675
fix: apply configurable ali video resolution billing in task relay
youyao666 May 30, 2026
a7b860a
feat(web): support ali video resolution billing settings
youyao666 May 30, 2026
fe38579
feat(classic): support ali video resolution billing settings
youyao666 May 30, 2026
d194498
test: cover ali video resolution billing
youyao666 May 30, 2026
7e65900
ci: add docker build workflow for feature branches
youyao666 May 30, 2026
42467ba
ci: push docker image to ghcr.io on branch push
youyao666 May 31, 2026
50b4911
ci: add latest tag for main branch docker image
youyao666 May 31, 2026
cc477ba
i18n: add VideoResolutionRatio translations for classic and default f…
youyao666 May 31, 2026
e8be584
fix: add VoiceCloneUnlockRatio to model pricing display
youyao666 May 31, 2026
95b8ec7
fix: remove double dollar sign in voice clone unlock price display
youyao666 May 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/workflows/docker-build-branch.yml
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +25 to +26

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Security: Pin action to SHA hash and disable credential persistence.

The checkout action has two security concerns:

  1. Unpinned action reference: The action uses a tag reference (@v4) instead of a SHA hash, creating supply chain attack risk. As per static analysis, pinning to hashes is required by blanket policy.
  2. Missing credential protection: Without persist-credentials: false, credentials may be exposed through GitHub Actions artifacts.
🔒 Proposed security hardening
       - name: Check out
-        uses: actions/checkout@v4
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+        with:
+          persist-credentials: false

As per static analysis hints: unpinned action reference (unpinned-uses) and credential persistence through GitHub Actions artifacts (artipacked).

🧰 Tools
🪛 zizmor (1.25.2)

[warning] 18-19: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[error] 19-19: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/docker-build-branch.yml around lines 18 - 19, Update the
"Check out" GitHub Action step to pin the checkout action to a specific commit
SHA instead of the tag (`@v4`) and add persist-credentials: false to the step;
specifically replace the uses reference for actions/checkout (the step named
"Check out") with the corresponding SHA-pinned ref and add the
persist-credentials: false key under that step to disable credential
persistence.


- 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
Comment on lines +38 to +39

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Security: Pin action to SHA hash.

The Docker Buildx setup action uses a tag reference (@v3) instead of a SHA hash, creating supply chain attack risk. As per static analysis, pinning to hashes is required by blanket policy.

🔒 Proposed fix
       - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@v3
+        uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1

As per static analysis hints: unpinned action reference (unpinned-uses).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1
🧰 Tools
🪛 zizmor (1.25.2)

[error] 25-25: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/docker-build-branch.yml around lines 24 - 25, The workflow
step "Set up Docker Buildx" currently uses the unpinned reference
docker/setup-buildx-action@v3 which is flagged as a supply-chain risk; update
the uses field in that step to the action pinned to a commit SHA (e.g.,
docker/setup-buildx-action@<commit-sha>) so the action is immutably referenced,
ensuring you replace the `@v3` tag with the specific SHA for the version you
intend to use and commit the change; locate the step by the step name "Set up
Docker Buildx" to make this edit.


- 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
18 changes: 18 additions & 0 deletions controller/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 14 additions & 8 deletions controller/ratio_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down
2 changes: 2 additions & 0 deletions controller/relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
37 changes: 37 additions & 0 deletions dto/audio.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
}
}
Comment on lines +55 to +69

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Use common.Unmarshal instead of json.Unmarshal.

Line 61 directly calls json.Unmarshal, which violates the repository coding guideline requiring all JSON marshal/unmarshal operations to use wrapper functions from common/json.go.

🔧 Proposed fix
 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 {
+		if common.Unmarshal(r.Input, &input) == nil {
 			text = input.Text
 		}
 	}

As per coding guidelines: "All JSON marshal/unmarshal operations MUST use wrapper functions from common/json.go: common.Marshal(), common.Unmarshal(), common.UnmarshalJsonStr(), common.DecodeJson(), or common.GetJsonType(). Do NOT directly import or call encoding/json in business code."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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) GetTokenCountMeta() *types.TokenCountMeta {
text := r.Text
if text == "" && len(r.Input) > 0 {
var input struct {
Text string `json:"text"`
}
if common.Unmarshal(r.Input, &input) == nil {
text = input.Text
}
}
return &types.TokenCountMeta{
CombineText: text,
TokenType: types.TokenTypeTextNumber,
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dto/audio.go` around lines 55 - 69, In GetTokenCountMeta (method
AudioVoiceCloneRequest.GetTokenCountMeta) replace the direct call to
json.Unmarshal when decoding r.Input into the local input struct with the
repository wrapper common.Unmarshal: create the same local struct { Text string
`json:"text"` }, call common.Unmarshal(r.Input, &input) and check its error
(instead of json.Unmarshal == nil) before assigning text = input.Text so the
code follows the common/json.go unmarshal wrapper requirement.


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"
}
Expand Down
6 changes: 6 additions & 0 deletions model/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand Down
4 changes: 4 additions & 0 deletions model/pricing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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
Expand Down
24 changes: 16 additions & 8 deletions relay/audio_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand All @@ -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())
}
Expand Down
25 changes: 23 additions & 2 deletions relay/channel/ali/adaptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions relay/channel/ali/constants.go
Original file line number Diff line number Diff line change
@@ -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",
}
Expand Down
1 change: 1 addition & 0 deletions relay/channel/ali/dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading