Skip to content
Open
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
76 changes: 76 additions & 0 deletions dto/channel_settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,82 @@ type ChannelOtherSettings struct {
UpstreamModelUpdateLastDetectedModels []string `json:"upstream_model_update_last_detected_models,omitempty"` // 上次检测到的可加入模型
UpstreamModelUpdateLastRemovedModels []string `json:"upstream_model_update_last_removed_models,omitempty"` // 上次检测到的可删除模型
UpstreamModelUpdateIgnoredModels []string `json:"upstream_model_update_ignored_models,omitempty"` // 手动忽略的模型

// VolcTTS holds Volcengine TTS protocol-specific overrides for v3 endpoints
// (bidirectional / unidirectional WS, HTTP Chunked, HTTP SSE).
// nil/empty means "use legacy v1 ws_binary" for backward compatibility.
VolcTTS *VolcTTSConfig `json:"volc_tts,omitempty"`
}

// Volcengine TTS protocol constants. Keep in sync with frontend selects.
const (
VolcTTSProtocolV1WsBinary = "v1_ws_binary" // legacy default (omitted == this)
VolcTTSProtocolV3WsBidir = "v3_ws_bidir" // wss://.../api/v3/tts/bidirection
VolcTTSProtocolV3WsUni = "v3_ws_uni" // wss://.../api/v3/tts/unidirectional/stream
VolcTTSProtocolV3HTTPChunked = "v3_http_chunked" // https://.../api/v3/tts/unidirectional
VolcTTSProtocolV3HTTPSSE = "v3_http_sse" // https://.../api/v3/tts/unidirectional/sse (passthrough)

VolcTTSAuthModeNewConsole = "new_console" // X-Api-Key (default)
VolcTTSAuthModeLegacy = "legacy" // X-Api-App-Id + X-Api-Access-Key

// VolcTTSDefaultResourceID matches the default OpenAI->Volcengine voice
// mapping (alloy/echo/fable/... -> *_mars_bigtts, all v1.0 voices). Users
// who pass an explicit v2.0 voice (*_uranus_bigtts / saturn_*) MUST also
// override this to seed-tts-2.0 / seed-icl-2.0 in the channel config or via
// metadata.volc_tts_resource_id, otherwise upstream returns code=55000000
// "resource ID is mismatched with speaker related resource".
VolcTTSDefaultResourceID = "seed-tts-1.0-concurr"
)

// VolcTTSConfig is the per-channel Volcengine TTS configuration. All fields are
// optional; empty values fall back to safe defaults (v1 ws_binary, new console
// auth, seed-tts-1.0-concurr resource id — matching the default voice map).
type VolcTTSConfig struct {
Protocol string `json:"protocol,omitempty"` // see VolcTTSProtocol* constants
ResourceID string `json:"resource_id,omitempty"` // X-Api-Resource-Id (e.g. "seed-tts-2.0")
AuthMode string `json:"auth_mode,omitempty"` // see VolcTTSAuthMode* constants
RequireUsage *bool `json:"require_usage,omitempty"` // emit X-Control-Require-Usage-Tokens-Return; default true (Rule 6)
}

// IsV3 reports whether the resolved protocol is any v3 transport.
func (c VolcTTSConfig) IsV3() bool {
switch c.Protocol {
case VolcTTSProtocolV3WsBidir, VolcTTSProtocolV3WsUni,
VolcTTSProtocolV3HTTPChunked, VolcTTSProtocolV3HTTPSSE:
return true
}
return false
}

// EffectiveResourceID returns ResourceID or the v3 default when empty.
func (c VolcTTSConfig) EffectiveResourceID() string {
if c.ResourceID != "" {
return c.ResourceID
}
return VolcTTSDefaultResourceID
}

// EffectiveAuthMode returns AuthMode or "new_console" when empty.
func (c VolcTTSConfig) EffectiveAuthMode() string {
if c.AuthMode == VolcTTSAuthModeLegacy {
return VolcTTSAuthModeLegacy
}
return VolcTTSAuthModeNewConsole
}

// ShouldRequireUsage returns true unless RequireUsage was explicitly set false.
func (c VolcTTSConfig) ShouldRequireUsage() bool {
if c.RequireUsage == nil {
return true
}
return *c.RequireUsage
}

func (s *ChannelOtherSettings) ResolvedVolcTTS() VolcTTSConfig {
if s == nil || s.VolcTTS == nil {
return VolcTTSConfig{}
}
return *s.VolcTTS
}

func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool {
Expand Down
105 changes: 99 additions & 6 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 @@ -89,11 +90,14 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
if err = json.Unmarshal(request.Metadata, &volcRequest); err != nil {
return nil, fmt.Errorf("error unmarshalling metadata to volcengine request: %w", err)
}
// Optional v3 protocol overrides — non-fatal if absent / malformed.
applyV3MetadataOverride(info, request.Metadata)
}

c.Set(contextKeyTTSRequest, volcRequest)

if volcRequest.Request.Operation == "submit" {
cfg := resolveVolcTTSConfig(info)
if volcRequest.Request.Operation == "submit" || cfg.IsV3() {
info.IsStream = true
}

Expand Down Expand Up @@ -275,7 +279,19 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
return fmt.Sprintf("%s/api/v3/responses", baseUrl), nil
case constant.RelayModeAudioSpeech:
if baseUrl == channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine] {
return "wss://openspeech.bytedance.com/api/v1/tts/ws_binary", nil
cfg := resolveVolcTTSConfig(info)
switch cfg.Protocol {
case dto.VolcTTSProtocolV3WsBidir:
return "wss://openspeech.bytedance.com/api/v3/tts/bidirection", nil
case dto.VolcTTSProtocolV3WsUni:
return "wss://openspeech.bytedance.com/api/v3/tts/unidirectional/stream", nil
case dto.VolcTTSProtocolV3HTTPChunked:
return "https://openspeech.bytedance.com/api/v3/tts/unidirectional", nil
case dto.VolcTTSProtocolV3HTTPSSE:
return "https://openspeech.bytedance.com/api/v3/tts/unidirectional/sse", nil
default:
return "wss://openspeech.bytedance.com/api/v1/tts/ws_binary", nil
}
}
return fmt.Sprintf("%s/v1/audio/speech", baseUrl), nil
default:
Expand Down Expand Up @@ -337,7 +353,10 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
}

if baseUrl == channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine] {
if info.IsStream {
// v1 streaming and ALL v3 transports are handled inside DoResponse —
// the connection is established there directly, so skip the generic
// HTTP relay step.
if info.IsStream || resolveVolcTTSConfig(info).IsV3() {
return nil, nil
}
}
Expand All @@ -355,7 +374,9 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom

if info.RelayMode == constant.RelayModeAudioSpeech {
encoding := mapEncoding(c.GetString(contextKeyResponseFormat))
if info.IsStream {
cfg := resolveVolcTTSConfig(info)

if info.IsStream || cfg.IsV3() {
volcRequestInterface, exists := c.Get(contextKeyTTSRequest)
if !exists {
return nil, types.NewErrorWithStatusCode(
Expand All @@ -374,7 +395,7 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
)
}

// Get the WebSocket URL
// Get the upstream URL (v1 ws_binary OR one of the four v3 endpoints).
requestURL, urlErr := a.GetRequestURL(info)
if urlErr != nil {
return nil, types.NewErrorWithStatusCode(
Expand All @@ -383,7 +404,17 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
http.StatusInternalServerError,
)
}
return handleTTSWebSocketResponse(c, requestURL, volcRequest, info, encoding)

switch cfg.Protocol {
case dto.VolcTTSProtocolV3WsBidir, dto.VolcTTSProtocolV3WsUni:
return handleTTSV3WSResponse(c, requestURL, volcRequest, info, encoding, cfg)
case dto.VolcTTSProtocolV3HTTPChunked:
return handleTTSV3HTTPChunked(c, requestURL, volcRequest, info, encoding, cfg)
case dto.VolcTTSProtocolV3HTTPSSE:
return handleTTSV3HTTPSSE(c, requestURL, volcRequest, info, cfg)
default:
return handleTTSWebSocketResponse(c, requestURL, volcRequest, info, encoding)
}
}
return handleTTSResponse(c, resp, info, encoding)
}
Expand All @@ -400,3 +431,65 @@ func (a *Adaptor) GetModelList() []string {
func (a *Adaptor) GetChannelName() string {
return ChannelName
}

// resolveVolcTTSConfig returns the effective Volcengine TTS protocol config.
// Per-request metadata override (info.VolcTTSOverride) wins over the channel
// setting (info.ChannelOtherSettings.VolcTTS); both empty falls back to v1.
func resolveVolcTTSConfig(info *relaycommon.RelayInfo) dto.VolcTTSConfig {
if info == nil {
return dto.VolcTTSConfig{}
}
if info.VolcTTSOverride != nil {
return *info.VolcTTSOverride
}
if info.ChannelMeta == nil {
return dto.VolcTTSConfig{}
}
return info.ChannelOtherSettings.ResolvedVolcTTS()
}

// applyV3MetadataOverride parses optional volc_tts_* keys from the OpenAI
// metadata blob and stashes them on info.VolcTTSOverride so DoResponse and
// GetRequestURL can route to the right v3 handler. Channel-level fields are
// inherited when the override leaves them blank.
func applyV3MetadataOverride(info *relaycommon.RelayInfo, raw json.RawMessage) {
if info == nil || len(raw) == 0 {
return
}
var probe struct {
Protocol string `json:"volc_tts_protocol,omitempty"`
ResourceID string `json:"volc_tts_resource_id,omitempty"`
AuthMode string `json:"volc_tts_auth_mode,omitempty"`
RequireUsage *bool `json:"volc_tts_require_usage,omitempty"`
}
if err := common.Unmarshal(raw, &probe); err != nil {
return
}
if probe.Protocol == "" && probe.ResourceID == "" && probe.AuthMode == "" && probe.RequireUsage == nil {
return
}

override := dto.VolcTTSConfig{
Protocol: probe.Protocol,
ResourceID: probe.ResourceID,
AuthMode: probe.AuthMode,
RequireUsage: probe.RequireUsage,
}
// Inherit unspecified fields from the channel-level config.
if info.ChannelMeta != nil {
base := info.ChannelOtherSettings.ResolvedVolcTTS()
if override.Protocol == "" {
override.Protocol = base.Protocol
}
if override.ResourceID == "" {
override.ResourceID = base.ResourceID
}
if override.AuthMode == "" {
override.AuthMode = base.AuthMode
}
if override.RequireUsage == nil {
override.RequireUsage = base.RequireUsage
}
}
info.VolcTTSOverride = &override
}
44 changes: 39 additions & 5 deletions relay/channel/volcengine/protocols.go
Original file line number Diff line number Diff line change
Expand Up @@ -378,9 +378,12 @@ func (m *Message) writeEvent(buf *bytes.Buffer) error {
}

func (m *Message) writeSessionID(buf *bytes.Buffer) error {
// Connection-class events do NOT carry a session ID. Keep this list aligned
// with readSessionID() to preserve marshal/unmarshal symmetry.
switch m.EventType {
case EventType_StartConnection, EventType_FinishConnection,
EventType_ConnectionStarted, EventType_ConnectionFailed:
EventType_ConnectionStarted, EventType_ConnectionFailed,
EventType_ConnectionFinished:
return nil
}

Expand Down Expand Up @@ -420,6 +423,12 @@ func (m *Message) writePayload(buf *bytes.Buffer) error {
}

func (m *Message) readers() (readers []func(*bytes.Buffer) error, _ error) {
// Order must mirror writers(): event/sessionID/connectID first (when WithEvent),
// then sequence/errorCode (when applicable), then payload.
if m.MsgTypeFlag == MsgTypeFlagWithEvent {
readers = append(readers, m.readEvent, m.readSessionID, m.readConnectID)
}

switch m.MsgType {
case MsgTypeFullClientRequest, MsgTypeFullServerResponse, MsgTypeFrontEndResultServer, MsgTypeAudioOnlyClient, MsgTypeAudioOnlyServer:
if m.MsgTypeFlag == MsgTypeFlagPositiveSeq || m.MsgTypeFlag == MsgTypeFlagNegativeSeq {
Expand All @@ -431,10 +440,6 @@ func (m *Message) readers() (readers []func(*bytes.Buffer) error, _ error) {
return nil, fmt.Errorf("unsupported message type: %d", m.MsgType)
}

if m.MsgTypeFlag == MsgTypeFlagWithEvent {
readers = append(readers, m.readEvent, m.readSessionID, m.readConnectID)
}

readers = append(readers, m.readPayload)
return readers, nil
}
Expand Down Expand Up @@ -531,3 +536,32 @@ func FullClientRequest(conn *websocket.Conn, payload []byte) error {
}
return conn.WriteMessage(websocket.BinaryMessage, frame)
}

// EventClientRequest sends an event-tagged Full-client request frame for the
// Volcengine v3 bidirectional/unidirectional TTS protocol.
// Connection-class events (StartConnection/FinishConnection) should pass an
// empty sessionID; session/data-class events MUST pass the session UUID.
func EventClientRequest(conn *websocket.Conn, event EventType, sessionID string, payload []byte) error {
msg, err := NewMessage(MsgTypeFullClientRequest, MsgTypeFlagWithEvent)
if err != nil {
return err
}
msg.EventType = event
msg.SessionID = sessionID
if len(payload) == 0 {
payload = []byte("{}")
}
msg.Payload = payload
frame, err := msg.Marshal()
if err != nil {
return err
}
return conn.WriteMessage(websocket.BinaryMessage, frame)
}

// ParseFrame parses a full Volcengine binary frame from raw bytes — useful for
// HTTP Chunked transport where the response body is a stream of independent
// frames sharing the same wire format as WebSocket.
func ParseFrame(data []byte) (*Message, error) {
return NewMessageFromBytes(data)
}
Loading