Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions constant/volc_speech.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package constant

const (
ModelDoubaoSeedTTS20 = "doubao-seed-tts-2.0"
ModelDoubaoSeedASRFlash = "doubao-seed-asr-flash"
)
11 changes: 8 additions & 3 deletions controller/relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,11 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
"error": newAPIError.ToClaudeError(),
})
default:
c.JSON(newAPIError.StatusCode, gin.H{
"error": newAPIError.ToOpenAIError(),
})
if !c.Writer.Written() {
c.JSON(newAPIError.StatusCode, gin.H{
"error": newAPIError.ToOpenAIError(),
})
}
}
}
}()
Expand Down Expand Up @@ -384,6 +386,9 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
other["channel_id"] = channelId
other["channel_name"] = c.GetString("channel_name")
other["channel_type"] = c.GetInt("channel_type")
if volcSpeechAudit, exists := c.Get("volc_speech_audit"); exists {
other["volc_speech"] = volcSpeechAudit
}
adminInfo := make(map[string]interface{})
adminInfo["use_channel"] = c.GetStringSlice("use_channel")
isMultiKey := common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey)
Expand Down
40 changes: 40 additions & 0 deletions docs/volc-speech.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# 火山语音渠道配置

火山 Seed-TTS 2.0 与录音文件极速 ASR 使用独立的火山语音渠道,公开模型分别为:

- `doubao-seed-tts-2.0`
- `doubao-seed-asr-flash`

渠道类型选择火山方舟(VolcEngine),但不要与已有文本或图像渠道共用。渠道 Key 支持两种格式:

- 新版控制台:直接填写单段 APP Key,网关使用 `X-Api-Key`。
- 旧版控制台:填写 `appid|access_token`,网关自动使用旧版鉴权头。

调用方继续使用 OpenAI 兼容接口:

- `POST /v1/audio/speech`
- `POST /v1/audio/transcriptions`

## TTS 音色

在渠道高级设置中配置 `Seed-TTS 2.0 默认音色 ID`。客户端传 `alloy`、`echo`、`fable`、`onyx`、`nova` 或 `shimmer` 时,网关统一映射到该默认音色;客户端直接传火山 speaker ID 时保持原值。

未配置默认音色时,使用 OpenAI 标准音色的请求会被明确拒绝。协议、资源 ID 和鉴权方式由服务端固定,客户端请求中的 `metadata` 不会覆盖这些字段。

## 计费倍率

两个模型都必须在模型倍率设置中显式配置价格,未配置时不得开放调用。不要在源码中写入火山易变的刊例价格,应按火山控制台当日后付费价格换算:

- TTS 倍率 = `人民币/万字符 ÷ USDExchangeRate ÷ 0.02`
- ASR 倍率 = `人民币/小时 ÷ USDExchangeRate ÷ 0.12`

用户分组倍率继续作为销售加价系数。

TTS 按火山返回的 `usage.text_words` 结算;仅在成功收到 `SessionFinished` 但缺少 usage 时,回退为 Unicode 字符数。ASR 按每分钟 1000 个内部计费单位预扣,并在完成后使用火山 `audio_info.duration` 校正。

## 支持边界

- TTS 输出:MP3、Opus、PCM;语速范围 0.5–2.0。
- ASR 输入:WAV、MP3、OGG/Opus;最大 100MB、最长 2 小时。
- ASR 输出:`json`、`text`、`verbose_json`。
- 不支持实时 WebSocket ASR、异步长文件 ASR、双向 TTS、SSE、声音复刻和阿里语音。
4 changes: 4 additions & 0 deletions dto/audio.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ type AudioRequest struct {
XVectorOnlyMode json.RawMessage `json:"x_vector_only_mode,omitempty"`
MaxNewTokens json.RawMessage `json:"max_new_tokens,omitempty"`
InitialCodecChunkFrames json.RawMessage `json:"initial_codec_chunk_frames,omitempty"`
// LocalAudioDurationMS 仅保存网关本地解析出的音频时长,不会发送给上游。
LocalAudioDurationMS int64 `json:"-"`
// LocalAudioFormat 仅保存经过校验的音频格式,不会发送给上游。
LocalAudioFormat string `json:"-"`
// TODO:ensure that the logic remains correct after the stream is started.
//Stream json.RawMessage `json:"stream,omitempty"`
}
Expand Down
5 changes: 5 additions & 0 deletions dto/channel_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ type ChannelOtherSettings struct {
UpstreamModelUpdateLastRemovedModels []string `json:"upstream_model_update_last_removed_models,omitempty"` // 上次检测到的可删除模型
UpstreamModelUpdateIgnoredModels []string `json:"upstream_model_update_ignored_models,omitempty"` // 手动忽略的模型
AdvancedCustom *AdvancedCustomConfig `json:"advanced_custom,omitempty"`
VolcSpeech *VolcSpeechConfig `json:"volc_speech,omitempty"`
}

type VolcSpeechConfig struct {
DefaultTTSSpeaker string `json:"default_tts_speaker,omitempty"`
}

func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool {
Expand Down
12 changes: 12 additions & 0 deletions relay/audio_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ func AudioHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
var httpResp *http.Response
if resp != nil {
httpResp = resp.(*http.Response)
if info.VolcSpeechAudit != nil {
info.VolcSpeechAudit.LogID = httpResp.Header.Get("X-Tt-Logid")
volcSpeechAudit := map[string]interface{}{
"resource_id": info.VolcSpeechAudit.ResourceID,
"protocol": info.VolcSpeechAudit.Protocol,
"billing_units": info.VolcSpeechAudit.BillingUnits,
}
if info.VolcSpeechAudit.LogID != "" {
volcSpeechAudit["log_id"] = info.VolcSpeechAudit.LogID
}
c.Set("volc_speech_audit", volcSpeechAudit)
}
if httpResp.StatusCode != http.StatusOK {
newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false)
// reset status code 重置状态码
Expand Down
90 changes: 90 additions & 0 deletions relay/channel/volcengine/adaptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"path/filepath"
"strings"

"github.com/QuantumNous/new-api/common"
channelconstant "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/relay/channel"
Expand Down Expand Up @@ -47,6 +48,51 @@ 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.OriginModelName == channelconstant.ModelDoubaoSeedTTS20 {
if _, _, _, err := parseVolcSpeechCredential(info.ApiKey); err != nil {
return nil, err
}
volcRequest, encoding, err := buildVolcTTSV3Request(request, info.ChannelOtherSettings.VolcSpeech)
if err != nil {
return nil, err
}
c.Set(contextKeyResponseFormat, encoding)
info.VolcSpeechAudit = &relaycommon.VolcSpeechAuditInfo{
ResourceID: volcTTSResourceID,
Protocol: volcTTSProtocol,
}
setVolcSpeechAuditContext(c, info.VolcSpeechAudit)
jsonData, err := common.Marshal(volcRequest)
if err != nil {
return nil, fmt.Errorf("failed to marshal volcengine TTS request: %w", err)
}
return bytes.NewReader(jsonData), nil
}

if info.OriginModelName == channelconstant.ModelDoubaoSeedASRFlash {
if _, _, _, err := parseVolcSpeechCredential(info.ApiKey); err != nil {
return nil, err
}
if info.RelayMode != constant.RelayModeAudioTranscription {
return nil, errors.New("doubao-seed-asr-flash only supports audio transcriptions")
}
volcRequest, err := buildVolcASRFlashRequest(c, request)
if err != nil {
return nil, err
}
c.Set(contextKeyResponseFormat, request.ResponseFormat)
info.VolcSpeechAudit = &relaycommon.VolcSpeechAuditInfo{
ResourceID: volcASRFlashResourceID,
Protocol: volcASRFlashProtocol,
}
setVolcSpeechAuditContext(c, info.VolcSpeechAudit)
jsonData, err := common.Marshal(volcRequest)
if err != nil {
return nil, fmt.Errorf("failed to marshal volcengine ASR request: %w", err)
}
return bytes.NewReader(jsonData), nil
}

if info.RelayMode != constant.RelayModeAudioSpeech {
return nil, errors.New("unsupported audio relay mode")
}
Expand Down Expand Up @@ -237,6 +283,13 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
}

func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if info.OriginModelName == channelconstant.ModelDoubaoSeedTTS20 {
return volcTTSV3URL, nil
}
if info.OriginModelName == channelconstant.ModelDoubaoSeedASRFlash {
return volcASRFlashURL, nil
}

baseUrl := info.ChannelBaseUrl
if baseUrl == "" {
baseUrl = channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine]
Expand Down Expand Up @@ -287,6 +340,31 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error {
channel.SetupApiRequestHeader(info, c, req)

if info.OriginModelName == channelconstant.ModelDoubaoSeedTTS20 {
headers, err := buildVolcSpeechHeaders(info.ApiKey, volcTTSResourceID, "X-Api-App-Id")
if err != nil {
return err
}
for key, values := range headers {
(*req)[key] = values
}
req.Set("X-Control-Require-Usage-Tokens-Return", "*")
req.Set("Content-Type", gin.MIMEJSON)
return nil
}
if info.OriginModelName == channelconstant.ModelDoubaoSeedASRFlash {
headers, err := buildVolcSpeechHeaders(info.ApiKey, volcASRFlashResourceID, "X-Api-App-Key")
if err != nil {
return err
}
for key, values := range headers {
(*req)[key] = values
}
req.Set("X-Api-Sequence", "-1")
req.Set("Content-Type", gin.MIMEJSON)
return nil
}

if info.RelayMode == constant.RelayModeAudioSpeech {
parts := strings.Split(info.ApiKey, "|")
if len(parts) == 2 {
Expand Down Expand Up @@ -330,6 +408,11 @@ func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommo
}

func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
if info.OriginModelName == channelconstant.ModelDoubaoSeedTTS20 ||
info.OriginModelName == channelconstant.ModelDoubaoSeedASRFlash {
return doVolcSpeechRequest(a, c, info, requestBody)
}

if info.RelayMode == constant.RelayModeAudioSpeech {
baseUrl := info.ChannelBaseUrl
if baseUrl == "" {
Expand All @@ -346,6 +429,13 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
}

func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
if info.OriginModelName == channelconstant.ModelDoubaoSeedTTS20 {
return handleVolcTTSV3Response(c, resp, info, c.GetString(contextKeyResponseFormat))
}
if info.OriginModelName == channelconstant.ModelDoubaoSeedASRFlash {
return handleVolcASRFlashResponse(c, resp, info, c.GetString(contextKeyResponseFormat))
}

if info.RelayFormat == types.RelayFormatClaude {
if _, ok := channelconstant.ChannelSpecialBases[info.ChannelBaseUrl]; ok {
adaptor := claude.Adaptor{}
Expand Down
Loading