From 24f9c4e5b9bde58ae208c97e6d626ba60d35881f Mon Sep 17 00:00:00 2001 From: taoliang1 Date: Sat, 9 May 2026 12:09:17 +0800 Subject: [PATCH 1/4] feat(volcengine): add v3 TTS protocols (ws bidir/uni, http chunked/sse) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Volcengine v3 TTS support alongside the existing v1 ws_binary path. Channels can now select one of four v3 transports per-channel (or per-request via OpenAI metadata), with the appropriate auth header layout (new-console X-Api-Key vs legacy X-Api-App-Id + X-Api-Access-Key) and resource id (seed-tts-2.0 / seed-icl-2.0 / etc.). Backend: - protocols.go: align readers/writers ordering, add EventClientRequest helper for v3 event-tagged frames, fix writeSessionID symmetry for ConnectionFinished. - tts_v3_ws.go: implement bidirectional + unidirectional WS state machines (StartConnection -> StartSession -> TaskRequest? -> FinishSession), parse SessionFinished usage.text_words. - tts_v3_http.go: implement HTTP Chunked frame splitter (binary frames) and HTTP SSE passthrough (raw text/event-stream forwarded to client; usage side-parsed for billing). - adaptor.go: dispatch by resolveVolcTTSConfig(info) covering the four v3 endpoints + retain v1 default. Metadata override via volc_tts_*. - dto/channel_settings.go: add VolcTTSConfig (protocol/resource_id/auth_mode/ require_usage). No DDL — JSON-in-text on existing settings column. - relay/common/relay_info.go: VolcTTSOverride field for per-request override. - service/log_info_generate.go: log volc_tts_protocol / volc_resource_id. Frontend (both themes): - web/default/ (Base UI): Select / Input / Switch controls in channel-mutate-drawer; channel-form schema/defaults/parse/serialize. - web/classic/ (Semi UI): same four fields in EditChannelModal type=45 block. - i18n: en/zh keys for new labels and descriptions. Tests: round-trip + frame-splitter + SSE side-channel + auth-header tests in relay/channel/volcengine/protocols_v3_test.go (all green). Default behavior unchanged: empty Protocol falls back to v1 ws_binary. --- dto/channel_settings.go | 70 +++ relay/channel/volcengine/adaptor.go | 105 ++++- relay/channel/volcengine/protocols.go | 44 +- relay/channel/volcengine/protocols_v3_test.go | 406 +++++++++++++++++ relay/channel/volcengine/tts_v3_http.go | 414 +++++++++++++++++ relay/channel/volcengine/tts_v3_ws.go | 426 ++++++++++++++++++ relay/common/relay_info.go | 4 + service/log_info_generate.go | 22 + .../channels/modals/EditChannelModal.jsx | 123 +++++ .../drawers/channel-mutate-drawer.tsx | 131 ++++++ .../src/features/channels/constants.ts | 1 + .../src/features/channels/lib/channel-form.ts | 64 +++ web/default/src/i18n/locales/en.json | 15 + web/default/src/i18n/locales/zh.json | 15 + 14 files changed, 1829 insertions(+), 11 deletions(-) create mode 100644 relay/channel/volcengine/protocols_v3_test.go create mode 100644 relay/channel/volcengine/tts_v3_http.go create mode 100644 relay/channel/volcengine/tts_v3_ws.go diff --git a/dto/channel_settings.go b/dto/channel_settings.go index b6a1ab9f7138..a1c8c7cd0560 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -41,6 +41,76 @@ 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 = "seed-tts-2.0" +) + +// 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-2.0 resource id). +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 { diff --git a/relay/channel/volcengine/adaptor.go b/relay/channel/volcengine/adaptor.go index ba9f223bd2f6..20f2e43023d8 100644 --- a/relay/channel/volcengine/adaptor.go +++ b/relay/channel/volcengine/adaptor.go @@ -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" @@ -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 } @@ -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: @@ -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 } } @@ -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( @@ -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( @@ -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) } @@ -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 +} diff --git a/relay/channel/volcengine/protocols.go b/relay/channel/volcengine/protocols.go index fb7dcd578cea..0692560aa115 100644 --- a/relay/channel/volcengine/protocols.go +++ b/relay/channel/volcengine/protocols.go @@ -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 } @@ -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 { @@ -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 } @@ -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) +} diff --git a/relay/channel/volcengine/protocols_v3_test.go b/relay/channel/volcengine/protocols_v3_test.go new file mode 100644 index 000000000000..6f3159dc5a01 --- /dev/null +++ b/relay/channel/volcengine/protocols_v3_test.go @@ -0,0 +1,406 @@ +package volcengine + +import ( + "bytes" + "encoding/binary" + "io" + "strings" + "testing" + + "github.com/QuantumNous/new-api/dto" +) + +// roundTripFrame builds a Message, marshals it, then parses it back, and +// returns the parsed message for assertion convenience. +func roundTripFrame(t *testing.T, build func() *Message) *Message { + t.Helper() + src := build() + frame, err := src.Marshal() + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + parsed, err := NewMessageFromBytes(frame) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + return parsed +} + +func newEventMessage(evt EventType, sessionID string, payload []byte) *Message { + m, _ := NewMessage(MsgTypeFullClientRequest, MsgTypeFlagWithEvent) + m.EventType = evt + m.SessionID = sessionID + if len(payload) == 0 { + payload = []byte("{}") + } + m.Payload = payload + return m +} + +func TestRoundTrip_StartConnection(t *testing.T) { + parsed := roundTripFrame(t, func() *Message { + return newEventMessage(EventType_StartConnection, "", nil) + }) + if parsed.EventType != EventType_StartConnection { + t.Fatalf("event mismatch: got %v", parsed.EventType) + } + if parsed.SessionID != "" { + t.Fatalf("connection-class events MUST NOT carry sessionID, got %q", parsed.SessionID) + } + if string(parsed.Payload) != "{}" { + t.Fatalf("payload mismatch: %q", parsed.Payload) + } +} + +func TestRoundTrip_StartSession_WithSessionID(t *testing.T) { + const sid = "11111111-2222-3333-4444-555555555555" + body := []byte(`{"event":100,"namespace":"BidirectionalTTS","req_params":{"speaker":"x","audio_params":{"format":"mp3"}}}`) + parsed := roundTripFrame(t, func() *Message { + return newEventMessage(EventType_StartSession, sid, body) + }) + if parsed.EventType != EventType_StartSession { + t.Fatalf("event mismatch: got %v", parsed.EventType) + } + if parsed.SessionID != sid { + t.Fatalf("sessionID mismatch: got %q want %q", parsed.SessionID, sid) + } + if !bytes.Equal(parsed.Payload, body) { + t.Fatalf("payload mismatch") + } +} + +func TestRoundTrip_TaskRequest(t *testing.T) { + const sid = "session-abc" + body := []byte(`{"event":200,"req_params":{"text":"你好"}}`) + parsed := roundTripFrame(t, func() *Message { + return newEventMessage(EventType_TaskRequest, sid, body) + }) + if parsed.EventType != EventType_TaskRequest { + t.Fatalf("event mismatch") + } + if parsed.SessionID != sid { + t.Fatalf("sessionID mismatch: got %q", parsed.SessionID) + } + if !bytes.Equal(parsed.Payload, body) { + t.Fatalf("payload mismatch") + } +} + +func TestRoundTrip_FinishSession_EmptyBody(t *testing.T) { + const sid = "abcdef" + parsed := roundTripFrame(t, func() *Message { + return newEventMessage(EventType_FinishSession, sid, nil) // payload defaults to "{}" + }) + if parsed.EventType != EventType_FinishSession { + t.Fatalf("event mismatch") + } + if parsed.SessionID != sid { + t.Fatalf("sessionID mismatch") + } + if string(parsed.Payload) != "{}" { + t.Fatalf("payload mismatch: %q", parsed.Payload) + } +} + +// AudioOnlyServer + WithEvent (no sequence flag) — the wire shape used by +// EventType_TTSResponse on v3 endpoints. +func TestRoundTrip_AudioOnlyServer_TTSResponse(t *testing.T) { + const sid = "session-audio" + audio := []byte{0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD} + parsed := roundTripFrame(t, func() *Message { + m, _ := NewMessage(MsgTypeAudioOnlyServer, MsgTypeFlagWithEvent) + m.EventType = EventType_TTSResponse + m.SessionID = sid + m.Payload = audio + return m + }) + if parsed.MsgType != MsgTypeAudioOnlyServer { + t.Fatalf("msg type mismatch") + } + if parsed.EventType != EventType_TTSResponse { + t.Fatalf("event mismatch") + } + if parsed.SessionID != sid { + t.Fatalf("sessionID mismatch") + } + if !bytes.Equal(parsed.Payload, audio) { + t.Fatalf("audio bytes mismatch") + } +} + +// FullServerResponse + WithEvent + SessionFinished payload carrying usage.text_words. +func TestRoundTrip_SessionFinished_WithUsage(t *testing.T) { + const sid = "session-fin" + body := []byte(`{"status_code":20000000,"message":"ok","usage":{"text_words":42}}`) + parsed := roundTripFrame(t, func() *Message { + m, _ := NewMessage(MsgTypeFullServerResponse, MsgTypeFlagWithEvent) + m.EventType = EventType_SessionFinished + m.SessionID = sid + m.Payload = body + return m + }) + env := parseV3SessionResult(parsed.Payload) + if env == nil { + t.Fatalf("envelope nil") + } + if env.StatusCode != 20000000 { + t.Fatalf("status mismatch: %d", env.StatusCode) + } + if env.Usage == nil || env.Usage.TextWords != 42 { + t.Fatalf("usage mismatch: %+v", env.Usage) + } +} + +// ConnectionStarted is an inbound-only frame (server → client) with this wire +// shape: +// header(4) | event(4) | uint32(connectID len) | connectID | uint32(payload len) | payload +// We exercise the parser by hand-crafting that wire layout, since the writer +// path never produces ConnectionStarted (clients only consume it). +func TestParse_ConnectionStarted_CarriesConnectID(t *testing.T) { + const cid = "connect-XYZ" + body := []byte("{}") + + frame := new(bytes.Buffer) + frame.WriteByte(byte(uint8(Version1)<<4 | uint8(HeaderSize4))) + frame.WriteByte(byte(uint8(MsgTypeFullServerResponse)<<4 | uint8(MsgTypeFlagWithEvent))) + frame.WriteByte(byte(uint8(SerializationJSON)<<4 | uint8(CompressionNone))) + frame.WriteByte(0x00) // reserved + _ = binary.Write(frame, binary.BigEndian, EventType_ConnectionStarted) + _ = binary.Write(frame, binary.BigEndian, uint32(len(cid))) + frame.WriteString(cid) + _ = binary.Write(frame, binary.BigEndian, uint32(len(body))) + frame.Write(body) + + parsed, err := NewMessageFromBytes(frame.Bytes()) + if err != nil { + t.Fatalf("parse failed: %v", err) + } + if parsed.EventType != EventType_ConnectionStarted { + t.Fatalf("event mismatch") + } + if parsed.SessionID != "" { + t.Fatalf("connection-class MUST NOT carry sessionID, got %q", parsed.SessionID) + } + if parsed.ConnectID != cid { + t.Fatalf("connectID mismatch: got %q want %q", parsed.ConnectID, cid) + } +} + +// ReadOneFrame must consume exactly one frame from a streaming reader and +// leave subsequent bytes untouched, so consecutive calls can drain a chunked +// HTTP body. +func TestReadOneFrame_StreamingChunked(t *testing.T) { + // Build two frames: a TTSResponse audio frame, then a SessionFinished frame. + audio := []byte("chunk-1-audio-bytes") + frame1, err := (&Message{ + Version: Version1, + HeaderSize: HeaderSize4, + MsgType: MsgTypeAudioOnlyServer, + MsgTypeFlag: MsgTypeFlagWithEvent, + Serialization: SerializationJSON, + Compression: CompressionNone, + EventType: EventType_TTSResponse, + SessionID: "sid", + Payload: audio, + }).Marshal() + if err != nil { + t.Fatalf("marshal frame1: %v", err) + } + frame2Body := []byte(`{"status_code":20000000,"message":"ok","usage":{"text_words":7}}`) + frame2, err := (&Message{ + Version: Version1, + HeaderSize: HeaderSize4, + MsgType: MsgTypeFullServerResponse, + MsgTypeFlag: MsgTypeFlagWithEvent, + Serialization: SerializationJSON, + Compression: CompressionNone, + EventType: EventType_SessionFinished, + SessionID: "sid", + Payload: frame2Body, + }).Marshal() + if err != nil { + t.Fatalf("marshal frame2: %v", err) + } + stream := bytes.NewReader(append(frame1, frame2...)) + + first, err := ReadOneFrame(stream) + if err != nil { + t.Fatalf("first frame: %v", err) + } + if first.MsgType != MsgTypeAudioOnlyServer || first.EventType != EventType_TTSResponse { + t.Fatalf("first frame fields wrong: %+v", first) + } + if !bytes.Equal(first.Payload, audio) { + t.Fatalf("first audio mismatch") + } + + second, err := ReadOneFrame(stream) + if err != nil { + t.Fatalf("second frame: %v", err) + } + if second.EventType != EventType_SessionFinished { + t.Fatalf("second event wrong: %v", second.EventType) + } + env := parseV3SessionResult(second.Payload) + if env == nil || env.Usage == nil || env.Usage.TextWords != 7 { + t.Fatalf("usage parse failed: %+v", env) + } + + // Reader should now be exhausted. + if _, err := ReadOneFrame(stream); err != io.EOF { + t.Fatalf("expected EOF after draining, got %v", err) + } +} + +// Sanity: writers and readers MUST emit/consume bytes in the same order so +// every frame round-trips without leftover bytes. +func TestWritersReadersSymmetry_NoLeftovers(t *testing.T) { + cases := []*Message{ + newEventMessage(EventType_StartConnection, "", nil), + newEventMessage(EventType_StartSession, "sid-A", []byte(`{"k":"v"}`)), + newEventMessage(EventType_TaskRequest, "sid-A", []byte(`{"event":200}`)), + newEventMessage(EventType_FinishSession, "sid-A", nil), + newEventMessage(EventType_FinishConnection, "", nil), + } + for i, src := range cases { + frame, err := src.Marshal() + if err != nil { + t.Fatalf("case %d marshal: %v", i, err) + } + // NewMessageFromBytes already enforces "no leftover bytes" via Unmarshal's + // trailing ReadByte check — that's the symmetry guarantee we rely on. + if _, err := NewMessageFromBytes(frame); err != nil { + t.Fatalf("case %d round-trip (likely byte-order mismatch): %v", i, err) + } + } +} + +// NOTE: combining PositiveSeq | WithEvent flags is intentionally NOT supported +// by Marshal/Unmarshal (the existing code uses == comparisons, not bitwise &). +// Volcengine's v3 protocol does not mix the two flags either: AudioOnly + event +// frames omit sequence, and sequence-tagged audio frames omit event. If a +// future wire format requires both, both writers() and readers() must adopt +// bitwise checks and add a paired writer/reader for sequence-with-event. + +// SSE side-channel parser: the handler triggers usage capture on either an +// "event: SessionFinished" line OR a payload containing `"event":152`. Both +// shapes occur in the wild. +func TestSSESideChannel_DetectsUsage(t *testing.T) { + // Replicate the minimum branching logic used by handleTTSV3HTTPSSE without + // running an HTTP server: two fake events, second one has the finish marker. + scenarios := []struct { + name string + eventName string + dataJSON string + want int + }{ + { + name: "explicit event name", + eventName: "SessionFinished", + dataJSON: `{"status_code":20000000,"message":"ok","usage":{"text_words":11}}`, + want: 11, + }, + { + name: "embedded event:152", + eventName: "tts_response", + dataJSON: `{"event":152,"usage":{"text_words":5}}`, + want: 5, + }, + { + name: "no usage", + eventName: "tts_response", + dataJSON: `{"event":352,"data":"AAA"}`, + want: 0, + }, + } + for _, sc := range scenarios { + t.Run(sc.name, func(t *testing.T) { + var captured int + match := strings.TrimSpace(sc.eventName) == "SessionFinished" || strings.Contains(sc.dataJSON, "\"event\":152") + if match { + if env := parseV3SessionResult([]byte(sc.dataJSON)); env != nil && env.Usage != nil { + captured = env.Usage.TextWords + } + } + if captured != sc.want { + t.Fatalf("captured=%d want=%d", captured, sc.want) + } + }) + } +} + +// Auth header construction: new console mode emits X-Api-Key only; legacy mode +// emits X-Api-App-Id + X-Api-Access-Key. +func TestBuildV3Headers_Modes(t *testing.T) { + const key = "12345|secret-token" + + t.Run("new_console default", func(t *testing.T) { + h, err := buildV3Headers(volcCfg("", "seed-tts-2.0", "", nil), key, "cid-1") + if err != nil { + t.Fatalf("build: %v", err) + } + if h.Get("X-Api-Key") != "secret-token" { + t.Fatalf("X-Api-Key wrong: %q", h.Get("X-Api-Key")) + } + if h.Get("X-Api-App-Id") != "" || h.Get("X-Api-Access-Key") != "" { + t.Fatalf("legacy headers must be absent in new_console mode") + } + if h.Get("X-Api-Resource-Id") != "seed-tts-2.0" { + t.Fatalf("resource id wrong: %q", h.Get("X-Api-Resource-Id")) + } + if h.Get("X-Api-Connect-Id") != "cid-1" { + t.Fatalf("connect id wrong") + } + if h.Get("X-Control-Require-Usage-Tokens-Return") != "*" { + t.Fatalf("usage control flag missing") + } + }) + + t.Run("legacy", func(t *testing.T) { + h, err := buildV3Headers(volcCfg("legacy", "", "legacy", nil), key, "cid-2") + if err != nil { + t.Fatalf("build: %v", err) + } + if h.Get("X-Api-App-Id") != "12345" || h.Get("X-Api-Access-Key") != "secret-token" { + t.Fatalf("legacy headers wrong: app=%q access=%q", + h.Get("X-Api-App-Id"), h.Get("X-Api-Access-Key")) + } + if h.Get("X-Api-Key") != "" { + t.Fatalf("X-Api-Key must be absent in legacy mode") + } + // Default resource id when blank. + if h.Get("X-Api-Resource-Id") != "seed-tts-2.0" { + t.Fatalf("default resource id wrong: %q", h.Get("X-Api-Resource-Id")) + } + }) + + t.Run("usage opt-out", func(t *testing.T) { + off := false + h, err := buildV3Headers(volcCfg("", "", "", &off), key, "cid-3") + if err != nil { + t.Fatalf("build: %v", err) + } + if h.Get("X-Control-Require-Usage-Tokens-Return") != "" { + t.Fatalf("usage control flag must be absent when require_usage=false") + } + }) + + t.Run("invalid key", func(t *testing.T) { + if _, err := buildV3Headers(volcCfg("", "", "", nil), "no-pipe-here", "cid-4"); err == nil { + t.Fatalf("expected error for malformed key") + } + }) +} + +func volcCfg(protocol, resource, auth string, requireUsage *bool) dto.VolcTTSConfig { + return dto.VolcTTSConfig{ + Protocol: protocol, + ResourceID: resource, + AuthMode: auth, + RequireUsage: requireUsage, + } +} + +// Helper for assertions involving binary.BigEndian (kept to mirror the wire +// format spec in case future tests need raw int32 inspection). +var _ = binary.BigEndian diff --git a/relay/channel/volcengine/tts_v3_http.go b/relay/channel/volcengine/tts_v3_http.go new file mode 100644 index 000000000000..e109b22af87e --- /dev/null +++ b/relay/channel/volcengine/tts_v3_http.go @@ -0,0 +1,414 @@ +package volcengine + +import ( + "bufio" + "bytes" + "encoding/base64" + "errors" + "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/types" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// v3HTTPRequestBody is the JSON body shared by both HTTP transports +// (chunked + SSE). It mirrors the StartSession payload shape, since the v3 +// HTTP endpoints accept all parameters in a single request. +type v3HTTPRequestBody struct { + User *v3UserMeta `json:"user,omitempty"` + Namespace string `json:"namespace,omitempty"` + ReqParams v3StartReqParams `json:"req_params"` +} + +func buildV3HTTPRequestBody(vReq VolcengineTTSRequest, encoding string) v3HTTPRequestBody { + speedPtr := mapV1SpeedToV3(vReq.Audio.SpeedRatio) + audio := v3AudioParams{ + Format: encoding, + SampleRate: intPtr(vReq.Audio.Rate), + SpeechRate: speedPtr, + } + if vReq.Audio.Bitrate > 0 { + audio.BitRate = intPtr(vReq.Audio.Bitrate) + } + if vReq.Audio.LoudnessRatio != 0 { + audio.LoudnessRate = intPtr(int(vReq.Audio.LoudnessRatio)) + } + if vReq.Audio.EnableEmotion && vReq.Audio.Emotion != "" { + audio.Emotion = vReq.Audio.Emotion + if vReq.Audio.EmotionScale != 0 { + s := vReq.Audio.EmotionScale + audio.EmotionScale = &s + } + } + return v3HTTPRequestBody{ + User: &v3UserMeta{UID: vReq.User.UID}, + ReqParams: v3StartReqParams{ + Text: vReq.Request.Text, + Speaker: vReq.Audio.VoiceType, + Model: vReq.Request.Model, + AudioParams: audio, + }, + } +} + +// ---------------------------------------------------------------------------- +// HTTP Chunked: response is a stream of independent v3 binary frames +// ---------------------------------------------------------------------------- + +func handleTTSV3HTTPChunked(c *gin.Context, requestURL string, vReq VolcengineTTSRequest, info *relaycommon.RelayInfo, encoding string, cfg dto.VolcTTSConfig) (any, *types.NewAPIError) { + bodyJSON, marshalErr := common.Marshal(buildV3HTTPRequestBody(vReq, encoding)) + if marshalErr != nil { + return nil, v3WrapError(marshalErr, "marshal v3 http body failed") + } + + connectID := uuid.NewString() + header, hdrErr := buildV3Headers(cfg, info.ApiKey, connectID) + if hdrErr != nil { + return nil, types.NewErrorWithStatusCode(hdrErr, types.ErrorCodeChannelInvalidKey, http.StatusUnauthorized) + } + header.Set("Content-Type", "application/json") + + req, reqErr := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, requestURL, bytes.NewReader(bodyJSON)) + if reqErr != nil { + return nil, v3WrapError(reqErr, "build v3 http request failed") + } + req.Header = header + + resp, doErr := http.DefaultClient.Do(req) + if doErr != nil { + return nil, v3WrapError(doErr, "do v3 http request failed") + } + defer resp.Body.Close() + + if logID := resp.Header.Get("X-Tt-Logid"); logID != "" { + c.Header("X-Volc-Logid", logID) + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("volcengine v3 http chunked unexpected status: %d body=%s", resp.StatusCode, string(body)), + types.ErrorCodeBadResponseStatusCode, + resp.StatusCode, + ) + } + + c.Header("Content-Type", getContentTypeByEncoding(encoding)) + c.Header("Transfer-Encoding", "chunked") + + usage := &dto.Usage{} + wroteAny := false + reader := bufio.NewReader(resp.Body) + + for { + msg, err := ReadOneFrame(reader) + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + break + } + return nil, v3WrapError(err, "parse v3 http frame failed") + } + + switch msg.MsgType { + case MsgTypeError: + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("volcengine v3 http error frame: code=%d body=%s", msg.ErrorCode, string(msg.Payload)), + types.ErrorCodeBadResponse, + http.StatusBadGateway, + ) + case MsgTypeAudioOnlyServer: + if len(msg.Payload) > 0 { + if _, wErr := c.Writer.Write(msg.Payload); wErr != nil { + return nil, v3WrapError(wErr, "write http audio chunk failed") + } + c.Writer.Flush() + wroteAny = true + } + case MsgTypeFullServerResponse: + switch msg.EventType { + case EventType_SessionFinished: + if env := parseV3SessionResult(msg.Payload); env != nil && env.Usage != nil { + usage.PromptTokens = env.Usage.TextWords + usage.TotalTokens = env.Usage.TextWords + } + goto done + case EventType_SessionFailed, EventType_ConnectionFailed: + env := parseV3SessionResult(msg.Payload) + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("volcengine v3 http session/connection failed: status=%s msg=%s", + envStatus(env), envMessage(env)), + types.ErrorCodeBadResponse, + http.StatusBadGateway, + ) + } + } + } +done: + + if !wroteAny { + return nil, types.NewErrorWithStatusCode( + errors.New("volcengine v3 http chunked finished without delivering audio"), + types.ErrorCodeBadResponse, + http.StatusBadGateway, + ) + } + if usage.PromptTokens == 0 { + est := info.GetEstimatePromptTokens() + usage.PromptTokens = est + usage.TotalTokens = est + } + return usage, nil +} + +// ---------------------------------------------------------------------------- +// HTTP SSE: passthrough — forward raw event-stream bytes to the client. +// Side-channel parses SessionFinished events to capture usage for billing. +// ---------------------------------------------------------------------------- + +func handleTTSV3HTTPSSE(c *gin.Context, requestURL string, vReq VolcengineTTSRequest, info *relaycommon.RelayInfo, cfg dto.VolcTTSConfig) (any, *types.NewAPIError) { + bodyJSON, marshalErr := common.Marshal(buildV3HTTPRequestBody(vReq, "mp3")) + if marshalErr != nil { + return nil, v3WrapError(marshalErr, "marshal v3 sse body failed") + } + + connectID := uuid.NewString() + header, hdrErr := buildV3Headers(cfg, info.ApiKey, connectID) + if hdrErr != nil { + return nil, types.NewErrorWithStatusCode(hdrErr, types.ErrorCodeChannelInvalidKey, http.StatusUnauthorized) + } + header.Set("Content-Type", "application/json") + header.Set("Accept", "text/event-stream") + + req, reqErr := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, requestURL, bytes.NewReader(bodyJSON)) + if reqErr != nil { + return nil, v3WrapError(reqErr, "build v3 sse request failed") + } + req.Header = header + + resp, doErr := http.DefaultClient.Do(req) + if doErr != nil { + return nil, v3WrapError(doErr, "do v3 sse request failed") + } + defer resp.Body.Close() + + if logID := resp.Header.Get("X-Tt-Logid"); logID != "" { + c.Header("X-Volc-Logid", logID) + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("volcengine v3 sse unexpected status: %d body=%s", resp.StatusCode, string(body)), + types.ErrorCodeBadResponseStatusCode, + resp.StatusCode, + ) + } + + // Passthrough headers — preserve upstream content-type if it's text/event-stream. + c.Header("Content-Type", "text/event-stream; charset=utf-8") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + + usage := &dto.Usage{} + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + + var ( + curEvent strings.Builder + curData strings.Builder + eventBuf strings.Builder // raw bytes to forward + ) + + flushEvent := func() { + if eventBuf.Len() == 0 { + curEvent.Reset() + curData.Reset() + return + } + // Side-channel parse SessionFinished payload for usage. + eventName := strings.TrimSpace(curEvent.String()) + if eventName == "SessionFinished" || strings.Contains(curData.String(), "\"event\":152") { + env := parseV3SessionResult([]byte(strings.TrimSpace(curData.String()))) + if env != nil && env.Usage != nil { + usage.PromptTokens = env.Usage.TextWords + usage.TotalTokens = env.Usage.TextWords + } + } + // Forward verbatim. + _, _ = c.Writer.WriteString(eventBuf.String()) + _, _ = c.Writer.WriteString("\n") + c.Writer.Flush() + + eventBuf.Reset() + curEvent.Reset() + curData.Reset() + } + + for scanner.Scan() { + line := scanner.Text() + // Forward each line plus its trailing newline (SSE preserves \n line endings). + eventBuf.WriteString(line) + eventBuf.WriteString("\n") + + if line == "" { + flushEvent() + continue + } + if strings.HasPrefix(line, "event:") { + curEvent.WriteString(strings.TrimPrefix(line, "event:")) + } else if strings.HasPrefix(line, "data:") { + curData.WriteString(strings.TrimPrefix(line, "data:")) + } + } + if scanErr := scanner.Err(); scanErr != nil && !errors.Is(scanErr, io.EOF) { + return nil, v3WrapError(scanErr, "scan v3 sse stream failed") + } + // Flush any tail event without a terminating blank line. + flushEvent() + + if usage.PromptTokens == 0 { + est := info.GetEstimatePromptTokens() + usage.PromptTokens = est + usage.TotalTokens = est + } + return usage, nil +} + +// ---------------------------------------------------------------------------- +// Frame splitter shared by HTTP Chunked transport +// ---------------------------------------------------------------------------- + +// ReadOneFrame consumes one full Volcengine v3 binary frame from r and parses +// it into a Message. It re-uses Message.Unmarshal for actual decoding by first +// buffering enough bytes for one frame. +// +// This walks the wire format: +// header(4-byte) → optional event(4) → optional sessionID/connectID(uvarint32+N) +// → optional sequence(4) or errorCode(4) → payload(uvarint32+N) +// +// The duplication with protocols.go is intentional: that file deals with a +// pre-fetched byte slice, while we operate on a streaming reader here. +func ReadOneFrame(r io.Reader) (*Message, error) { + header := make([]byte, 4) + if _, err := io.ReadFull(r, header); err != nil { + return nil, err + } + headerSize := int(header[0]&0x0F) * 4 + if headerSize < 4 { + return nil, fmt.Errorf("invalid v3 frame header size: %d", headerSize) + } + + frame := bytes.NewBuffer(nil) + frame.Write(header) + + // Drain header padding past the 4 bytes we already consumed. + if pad := headerSize - 4; pad > 0 { + buf := make([]byte, pad) + if _, err := io.ReadFull(r, buf); err != nil { + return nil, err + } + frame.Write(buf) + } + + msgType := MsgType(header[1] >> 4) + flag := MsgTypeFlagBits(header[1] & 0x0F) + + // WithEvent: event(4) + optional sessionID(4+N) + optional connectID(4+N) + if flag == MsgTypeFlagWithEvent { + evtBuf := make([]byte, 4) + if _, err := io.ReadFull(r, evtBuf); err != nil { + return nil, err + } + frame.Write(evtBuf) + evt := EventType(int32(uint32(evtBuf[0])<<24 | uint32(evtBuf[1])<<16 | uint32(evtBuf[2])<<8 | uint32(evtBuf[3]))) + if !isConnectionClassEvent(evt) { + if err := copyLengthPrefixed(r, frame); err != nil { + return nil, err + } + } + if isConnectionResponseEvent(evt) { + if err := copyLengthPrefixed(r, frame); err != nil { + return nil, err + } + } + } + + switch msgType { + case MsgTypeFullClientRequest, MsgTypeFullServerResponse, MsgTypeFrontEndResultServer, + MsgTypeAudioOnlyClient, MsgTypeAudioOnlyServer: + if flag == MsgTypeFlagPositiveSeq || flag == MsgTypeFlagNegativeSeq { + if err := copyN(r, frame, 4); err != nil { + return nil, err + } + } + case MsgTypeError: + if err := copyN(r, frame, 4); err != nil { + return nil, err + } + } + + // Payload: uint32 size + size bytes. + if err := copyLengthPrefixed(r, frame); err != nil { + return nil, err + } + + return NewMessageFromBytes(frame.Bytes()) +} + +func copyN(r io.Reader, dst *bytes.Buffer, n int) error { + buf := make([]byte, n) + if _, err := io.ReadFull(r, buf); err != nil { + return err + } + dst.Write(buf) + return nil +} + +func copyLengthPrefixed(r io.Reader, dst *bytes.Buffer) error { + sizeBuf := make([]byte, 4) + if _, err := io.ReadFull(r, sizeBuf); err != nil { + return err + } + dst.Write(sizeBuf) + size := uint32(sizeBuf[0])<<24 | uint32(sizeBuf[1])<<16 | uint32(sizeBuf[2])<<8 | uint32(sizeBuf[3]) + if size == 0 { + return nil + } + body := make([]byte, size) + if _, err := io.ReadFull(r, body); err != nil { + return err + } + dst.Write(body) + return nil +} + +func isConnectionClassEvent(e EventType) bool { + switch e { + case EventType_StartConnection, EventType_FinishConnection, + EventType_ConnectionStarted, EventType_ConnectionFailed, + EventType_ConnectionFinished: + return true + } + return false +} + +func isConnectionResponseEvent(e EventType) bool { + switch e { + case EventType_ConnectionStarted, EventType_ConnectionFailed, + EventType_ConnectionFinished: + return true + } + return false +} + +// Suppress unused-import warning when base64 isn't yet referenced by SSE handler +// (kept reserved for future SSE→audio decode mode). +var _ = base64.StdEncoding diff --git a/relay/channel/volcengine/tts_v3_ws.go b/relay/channel/volcengine/tts_v3_ws.go new file mode 100644 index 000000000000..9ef5330e507c --- /dev/null +++ b/relay/channel/volcengine/tts_v3_ws.go @@ -0,0 +1,426 @@ +package volcengine + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "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/types" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +// ---------------------------------------------------------------------------- +// v3 payload structs (encoded via common.Marshal — Rule 1) +// ---------------------------------------------------------------------------- + +type v3UserMeta struct { + UID string `json:"uid,omitempty"` +} + +type v3StartSessionPayload struct { + User *v3UserMeta `json:"user,omitempty"` + Event int32 `json:"event"` // 100 + Namespace string `json:"namespace,omitempty"` // BidirectionalTTS / UnidirectionalTTS + ReqParams v3StartReqParams `json:"req_params"` +} + +type v3StartReqParams struct { + Text string `json:"text,omitempty"` // unidirectional path: full text up-front + Speaker string `json:"speaker"` + Model string `json:"model,omitempty"` + AudioParams v3AudioParams `json:"audio_params"` + Additions string `json:"additions,omitempty"` // raw JSON string per upstream contract +} + +type v3AudioParams struct { + Format string `json:"format,omitempty"` + SampleRate *int `json:"sample_rate,omitempty"` // Rule 6 + BitRate *int `json:"bit_rate,omitempty"` // Rule 6 + SpeechRate *int `json:"speech_rate,omitempty"` // Rule 6 + LoudnessRate *int `json:"loudness_rate,omitempty"` // Rule 6 + Emotion string `json:"emotion,omitempty"` + EmotionScale *float64 `json:"emotion_scale,omitempty"` // Rule 6 + EnableTimestamp *bool `json:"enable_timestamp,omitempty"` // Rule 6 + EnableSubtitle *bool `json:"enable_subtitle,omitempty"` // Rule 6 +} + +type v3TaskPayload struct { + Event int32 `json:"event"` // 200 + Namespace string `json:"namespace,omitempty"` + ReqParams v3TaskReqParams `json:"req_params"` +} + +type v3TaskReqParams struct { + Text string `json:"text"` +} + +// v3SessionResultEnvelope is the common shape carried by SessionFinished / +// SessionFailed / ConnectionFailed payloads. +type v3SessionResultEnvelope struct { + StatusCode int `json:"status_code"` + Message string `json:"message"` + Usage *v3UsageStats `json:"usage,omitempty"` +} + +type v3UsageStats struct { + TextWords int `json:"text_words"` +} + +// ---------------------------------------------------------------------------- +// Header / payload builders +// ---------------------------------------------------------------------------- + +// buildV3Headers builds the WS/HTTP request headers used by every v3 transport. +// connectID is always emitted so server-side troubleshooting is possible. +func buildV3Headers(cfg dto.VolcTTSConfig, apiKey, connectID string) (http.Header, error) { + appID, token, err := parseVolcengineAuth(apiKey) + if err != nil { + return nil, err + } + h := http.Header{} + if cfg.EffectiveAuthMode() == dto.VolcTTSAuthModeLegacy { + h.Set("X-Api-App-Id", appID) + h.Set("X-Api-Access-Key", token) + } else { + // New console flow: only the access token is sent. AppID is intentionally + // not surfaced upstream (kept locally for log correlation). + h.Set("X-Api-Key", token) + } + h.Set("X-Api-Resource-Id", cfg.EffectiveResourceID()) + h.Set("X-Api-Connect-Id", connectID) + if cfg.ShouldRequireUsage() { + h.Set("X-Control-Require-Usage-Tokens-Return", "*") + } + return h, nil +} + +// buildV3StartSessionPayload converts the v1 request shape into a v3 StartSession payload. +func buildV3StartSessionPayload(vReq VolcengineTTSRequest, encoding string, namespace string, includeText bool) v3StartSessionPayload { + speedPtr := mapV1SpeedToV3(vReq.Audio.SpeedRatio) + audio := v3AudioParams{ + Format: encoding, + SampleRate: intPtr(vReq.Audio.Rate), + SpeechRate: speedPtr, + } + if vReq.Audio.Bitrate > 0 { + audio.BitRate = intPtr(vReq.Audio.Bitrate) + } + if vReq.Audio.LoudnessRatio != 0 { + audio.LoudnessRate = intPtr(int(vReq.Audio.LoudnessRatio)) + } + if vReq.Audio.EnableEmotion && vReq.Audio.Emotion != "" { + audio.Emotion = vReq.Audio.Emotion + if vReq.Audio.EmotionScale != 0 { + s := vReq.Audio.EmotionScale + audio.EmotionScale = &s + } + } + + payload := v3StartSessionPayload{ + User: &v3UserMeta{UID: vReq.User.UID}, + Event: int32(EventType_StartSession), + Namespace: namespace, + ReqParams: v3StartReqParams{ + Speaker: vReq.Audio.VoiceType, + Model: vReq.Request.Model, + AudioParams: audio, + }, + } + if includeText { + payload.ReqParams.Text = vReq.Request.Text + } + return payload +} + +// mapV1SpeedToV3 maps v1 speed_ratio (float around 1.0) to v3 speech_rate (int [-50, 100]). +// 1.0 → 0 (default), 2.0 → 100, 0.5 → -50. Returns nil if the v1 ratio is the implicit default 0. +func mapV1SpeedToV3(v1 float64) *int { + if v1 == 0 { + return nil + } + // v3 spec: 100 == 2.0x, -50 == 0.5x, 0 == 1.0x. Linear mapping piecewise. + var rate int + if v1 >= 1 { + rate = int((v1 - 1.0) * 100.0) + } else { + rate = int((v1 - 1.0) * 100.0) // gives negative + } + if rate > 100 { + rate = 100 + } else if rate < -50 { + rate = -50 + } + return &rate +} + +func intPtr(v int) *int { + if v == 0 { + return nil + } + return &v +} + +// ---------------------------------------------------------------------------- +// Main entry point +// ---------------------------------------------------------------------------- + +// handleTTSV3WSResponse drives a single OpenAI /v1/audio/speech (+stream) +// request through Volcengine v3 bidirectional or unidirectional WebSocket. +// Audio bytes are written directly to c.Writer (chunked transfer); usage is +// returned as *dto.Usage so the caller (audio_handler.go) can bill via +// service.PostAudioConsumeQuota. +func handleTTSV3WSResponse(c *gin.Context, requestURL string, vReq VolcengineTTSRequest, info *relaycommon.RelayInfo, encoding string, cfg dto.VolcTTSConfig) (any, *types.NewAPIError) { + connectID := uuid.NewString() + header, err := buildV3Headers(cfg, info.ApiKey, connectID) + if err != nil { + return nil, types.NewErrorWithStatusCode(err, types.ErrorCodeChannelInvalidKey, http.StatusUnauthorized) + } + + dialer := websocket.DefaultDialer + dialCtx, cancelDial := context.WithTimeout(context.Background(), 30*time.Second) + defer cancelDial() + conn, resp, dialErr := dialer.DialContext(dialCtx, requestURL, header) + if dialErr != nil { + statusCode := http.StatusBadGateway + hint := "" + if resp != nil { + statusCode = resp.StatusCode + if logID := resp.Header.Get("X-Tt-Logid"); logID != "" { + hint = fmt.Sprintf(" logid=%s", logID) + } + } + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("volcengine v3 dial failed: %w%s", dialErr, hint), + types.ErrorCodeBadResponseStatusCode, + statusCode, + ) + } + defer conn.Close() + + // Capture upstream logid (best-effort; ignore if absent). + if resp != nil { + if logID := resp.Header.Get("X-Tt-Logid"); logID != "" { + c.Header("X-Volc-Logid", logID) + } + } + + // Step 1: StartConnection + if sErr := EventClientRequest(conn, EventType_StartConnection, "", nil); sErr != nil { + return nil, v3WrapError(sErr, "send StartConnection failed") + } + if apiErr := v3ExpectEvent(conn, EventType_ConnectionStarted, EventType_ConnectionFailed); apiErr != nil { + return nil, apiErr + } + + // Step 2: StartSession + sessionID := uuid.NewString() + namespace, includeText := v3NamespaceAndTextMode(cfg) + startPayload := buildV3StartSessionPayload(vReq, encoding, namespace, includeText) + startBytes, marshalErr := common.Marshal(startPayload) + if marshalErr != nil { + return nil, v3WrapError(marshalErr, "marshal StartSession payload failed") + } + if sErr := EventClientRequest(conn, EventType_StartSession, sessionID, startBytes); sErr != nil { + return nil, v3WrapError(sErr, "send StartSession failed") + } + if apiErr := v3ExpectEvent(conn, EventType_SessionStarted, EventType_SessionFailed); apiErr != nil { + return nil, apiErr + } + + // Step 3 (bidirectional only): TaskRequest carrying the text. + if !includeText { + taskPayload := v3TaskPayload{ + Event: int32(EventType_TaskRequest), + Namespace: namespace, + ReqParams: v3TaskReqParams{Text: vReq.Request.Text}, + } + taskBytes, marshalErr := common.Marshal(taskPayload) + if marshalErr != nil { + return nil, v3WrapError(marshalErr, "marshal TaskRequest payload failed") + } + if sErr := EventClientRequest(conn, EventType_TaskRequest, sessionID, taskBytes); sErr != nil { + return nil, v3WrapError(sErr, "send TaskRequest failed") + } + } + + // Step 4: FinishSession (signals upstream we are done feeding text). + if sErr := EventClientRequest(conn, EventType_FinishSession, sessionID, nil); sErr != nil { + return nil, v3WrapError(sErr, "send FinishSession failed") + } + + // Step 5: prepare response stream + drain audio + contentType := getContentTypeByEncoding(encoding) + c.Header("Content-Type", contentType) + c.Header("Transfer-Encoding", "chunked") + + usage := &dto.Usage{} + wroteAny := false + +drainLoop: + for { + msg, recvErr := ReceiveMessage(conn) + if recvErr != nil { + if websocket.IsCloseError(recvErr, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + break drainLoop + } + return nil, v3WrapError(recvErr, "recv frame failed") + } + + switch msg.MsgType { + case MsgTypeError: + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("volcengine v3 error frame: code=%d body=%s", msg.ErrorCode, string(msg.Payload)), + types.ErrorCodeBadResponse, + http.StatusBadGateway, + ) + case MsgTypeAudioOnlyServer: + if msg.EventType == EventType_TTSResponse && len(msg.Payload) > 0 { + if _, wErr := c.Writer.Write(msg.Payload); wErr != nil { + return nil, v3WrapError(wErr, "write audio chunk to client failed") + } + c.Writer.Flush() + wroteAny = true + } + // negative-sequence audio frames mark stream end (legacy v1 cue); + // rely on SessionFinished from full-server-response frames instead. + case MsgTypeFullServerResponse: + switch msg.EventType { + case EventType_TTSSentenceStart, EventType_TTSSentenceEnd, EventType_TTSResponse: + // Subtitle / sentence boundary — currently swallowed since clients + // receive raw audio bytes only. Reserve for future passthrough. + case EventType_SessionFinished: + if env := parseV3SessionResult(msg.Payload); env != nil && env.Usage != nil { + usage.PromptTokens = env.Usage.TextWords + usage.TotalTokens = env.Usage.TextWords + } + break drainLoop + case EventType_SessionFailed: + env := parseV3SessionResult(msg.Payload) + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("volcengine v3 session failed: status=%s msg=%s", + envStatus(env), envMessage(env)), + types.ErrorCodeBadResponse, + http.StatusBadGateway, + ) + case EventType_ConnectionFailed: + env := parseV3SessionResult(msg.Payload) + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("volcengine v3 connection failed: status=%s msg=%s", + envStatus(env), envMessage(env)), + types.ErrorCodeBadResponse, + http.StatusBadGateway, + ) + case EventType_TTSEnded: + // some providers send TTSEnded ahead of SessionFinished — drain until SessionFinished + } + } + } + + // Best-effort: tell the server we're done; ignore any response/timeout. + _ = EventClientRequest(conn, EventType_FinishConnection, "", nil) + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, _ = ReceiveMessage(conn) // ConnectionFinished or close — both fine + + if !wroteAny { + return nil, types.NewErrorWithStatusCode( + errors.New("volcengine v3 finished without delivering audio"), + types.ErrorCodeBadResponse, + http.StatusBadGateway, + ) + } + + if usage.PromptTokens == 0 { + // Fall back to estimated character count when usage event is absent. + est := info.GetEstimatePromptTokens() + usage.PromptTokens = est + usage.TotalTokens = est + } + return usage, nil +} + +// ---------------------------------------------------------------------------- +// Helpers +// ---------------------------------------------------------------------------- + +func v3NamespaceAndTextMode(cfg dto.VolcTTSConfig) (namespace string, includeText bool) { + if cfg.Protocol == dto.VolcTTSProtocolV3WsUni { + return "UnidirectionalTTS", true + } + return "BidirectionalTTS", false +} + +func v3WrapError(err error, hint string) *types.NewAPIError { + return types.NewErrorWithStatusCode( + fmt.Errorf("%s: %w", hint, err), + types.ErrorCodeBadResponse, + http.StatusBadGateway, + ) +} + +// v3ExpectEvent blocks until either the success event or the failure event +// arrives. Any other event (e.g. SentenceStart) is swallowed; an MsgTypeError +// frame surfaces immediately. +func v3ExpectEvent(conn *websocket.Conn, success, failure EventType) *types.NewAPIError { + for { + msg, err := ReceiveMessage(conn) + if err != nil { + return v3WrapError(err, fmt.Sprintf("waiting for %s", success)) + } + switch msg.MsgType { + case MsgTypeError: + return types.NewErrorWithStatusCode( + fmt.Errorf("volcengine v3 error frame: code=%d body=%s", msg.ErrorCode, string(msg.Payload)), + types.ErrorCodeBadResponse, + http.StatusBadGateway, + ) + case MsgTypeFullServerResponse: + switch msg.EventType { + case success: + return nil + case failure: + env := parseV3SessionResult(msg.Payload) + return types.NewErrorWithStatusCode( + fmt.Errorf("volcengine v3 %s: status=%s msg=%s", + strings.ReplaceAll(failure.String(), "EventType_", ""), + envStatus(env), envMessage(env)), + types.ErrorCodeBadResponse, + http.StatusBadGateway, + ) + } + } + } +} + +func parseV3SessionResult(payload []byte) *v3SessionResultEnvelope { + if len(payload) == 0 { + return nil + } + env := &v3SessionResultEnvelope{} + if err := common.Unmarshal(payload, env); err != nil { + return nil + } + return env +} + +func envStatus(env *v3SessionResultEnvelope) string { + if env == nil { + return "?" + } + return fmt.Sprintf("%d", env.StatusCode) +} + +func envMessage(env *v3SessionResultEnvelope) string { + if env == nil { + return "" + } + return env.Message +} diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 64d4d4eedfaa..d4b6bd2b5ebc 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -147,6 +147,10 @@ type RelayInfo struct { SubscriptionAmountUsedAfterPreConsume int64 IsClaudeBetaQuery bool // /v1/messages?beta=true IsChannelTest bool // channel test request + // VolcTTSOverride is set when the client provides a per-request override + // (via OpenAI metadata.volc_tts_*) for Volcengine TTS protocol selection. + // Nil means "fall back to channel-level ChannelOtherSettings.VolcTTS". + VolcTTSOverride *dto.VolcTTSConfig RetryIndex int LastError *types.NewAPIError RuntimeHeadersOverride map[string]interface{} diff --git a/service/log_info_generate.go b/service/log_info_generate.go index 54448d59d673..1b546b306a7b 100644 --- a/service/log_info_generate.go +++ b/service/log_info_generate.go @@ -230,9 +230,31 @@ func GenerateAudioOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, info["text_output"] = usage.CompletionTokenDetails.TextTokens info["audio_ratio"] = audioRatio info["audio_completion_ratio"] = audioCompletionRatio + appendVolcTTSAuditInfo(relayInfo, info) return info } +// appendVolcTTSAuditInfo records the resolved Volcengine TTS protocol & resource +// id when present. Only writes keys when non-empty so non-Volcengine logs stay +// untouched. +func appendVolcTTSAuditInfo(relayInfo *relaycommon.RelayInfo, info map[string]interface{}) { + if relayInfo == nil || info == nil { + return + } + cfg := dto.VolcTTSConfig{} + if relayInfo.VolcTTSOverride != nil { + cfg = *relayInfo.VolcTTSOverride + } else if relayInfo.ChannelMeta != nil { + cfg = relayInfo.ChannelOtherSettings.ResolvedVolcTTS() + } + if cfg.Protocol != "" { + info["volc_tts_protocol"] = cfg.Protocol + } + if cfg.ResourceID != "" { + info["volc_resource_id"] = cfg.ResourceID + } +} + func GenerateClaudeOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64, cacheTokens int, cacheRatio float64, cacheCreationTokens int, cacheCreationRatio float64, diff --git a/web/classic/src/components/table/channels/modals/EditChannelModal.jsx b/web/classic/src/components/table/channels/modals/EditChannelModal.jsx index fad105b1c223..ca1b1c781a30 100644 --- a/web/classic/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/classic/src/components/table/channels/modals/EditChannelModal.jsx @@ -210,6 +210,11 @@ const EditChannelModal = (props) => { allow_inference_geo: false, allow_speed: false, claude_beta_query: false, + // VolcEngine TTS v3 overrides (persisted under settings.volc_tts) + volc_tts_protocol: '', + volc_tts_resource_id: '', + volc_tts_auth_mode: '', + volc_tts_require_usage: true, upstream_model_update_check_enabled: false, upstream_model_update_auto_sync_enabled: false, upstream_model_update_last_check_time: 0, @@ -911,6 +916,20 @@ const EditChannelModal = (props) => { parsedSettings.allow_inference_geo || false; data.allow_speed = parsedSettings.allow_speed || false; data.claude_beta_query = parsedSettings.claude_beta_query || false; + if (parsedSettings.volc_tts && typeof parsedSettings.volc_tts === 'object') { + data.volc_tts_protocol = parsedSettings.volc_tts.protocol || ''; + data.volc_tts_resource_id = parsedSettings.volc_tts.resource_id || ''; + data.volc_tts_auth_mode = parsedSettings.volc_tts.auth_mode || ''; + data.volc_tts_require_usage = + parsedSettings.volc_tts.require_usage === undefined + ? true + : parsedSettings.volc_tts.require_usage === true; + } else { + data.volc_tts_protocol = ''; + data.volc_tts_resource_id = ''; + data.volc_tts_auth_mode = ''; + data.volc_tts_require_usage = true; + } data.upstream_model_update_check_enabled = parsedSettings.upstream_model_update_check_enabled === true; data.upstream_model_update_auto_sync_enabled = @@ -941,6 +960,10 @@ const EditChannelModal = (props) => { data.allow_inference_geo = false; data.allow_speed = false; data.claude_beta_query = false; + data.volc_tts_protocol = ''; + data.volc_tts_resource_id = ''; + data.volc_tts_auth_mode = ''; + data.volc_tts_require_usage = true; data.upstream_model_update_check_enabled = false; data.upstream_model_update_auto_sync_enabled = false; data.upstream_model_update_last_check_time = 0; @@ -959,6 +982,10 @@ const EditChannelModal = (props) => { data.allow_inference_geo = false; data.allow_speed = false; data.claude_beta_query = false; + data.volc_tts_protocol = ''; + data.volc_tts_resource_id = ''; + data.volc_tts_auth_mode = ''; + data.volc_tts_require_usage = true; data.upstream_model_update_check_enabled = false; data.upstream_model_update_auto_sync_enabled = false; data.upstream_model_update_last_check_time = 0; @@ -1803,6 +1830,38 @@ const EditChannelModal = (props) => { } } + // VolcEngine TTS v3 overrides — only emit when at least one field is set, + // otherwise leave settings.volc_tts absent so backend falls back to v1. + if (localInputs.type === 45) { + const volcTTS = {}; + if ( + localInputs.volc_tts_protocol && + localInputs.volc_tts_protocol !== '' && + localInputs.volc_tts_protocol !== 'v1_ws_binary' + ) { + volcTTS.protocol = localInputs.volc_tts_protocol; + } + if (localInputs.volc_tts_resource_id) { + volcTTS.resource_id = localInputs.volc_tts_resource_id; + } + if ( + localInputs.volc_tts_auth_mode && + localInputs.volc_tts_auth_mode !== '' + ) { + volcTTS.auth_mode = localInputs.volc_tts_auth_mode; + } + if (localInputs.volc_tts_require_usage === false) { + volcTTS.require_usage = false; + } + if (Object.keys(volcTTS).length > 0) { + settings.volc_tts = volcTTS; + } else if ('volc_tts' in settings) { + delete settings.volc_tts; + } + } else if ('volc_tts' in settings) { + delete settings.volc_tts; + } + settings.upstream_model_update_check_enabled = localInputs.upstream_model_update_check_enabled === true; settings.upstream_model_update_auto_sync_enabled = @@ -1848,6 +1907,10 @@ const EditChannelModal = (props) => { delete localInputs.allow_inference_geo; delete localInputs.allow_speed; delete localInputs.claude_beta_query; + delete localInputs.volc_tts_protocol; + delete localInputs.volc_tts_resource_id; + delete localInputs.volc_tts_auth_mode; + delete localInputs.volc_tts_require_usage; delete localInputs.upstream_model_update_check_enabled; delete localInputs.upstream_model_update_auto_sync_enabled; delete localInputs.upstream_model_update_last_check_time; @@ -3446,6 +3509,66 @@ const EditChannelModal = (props) => { /> )} + {inputs.type === 45 && ( +
+ + handleInputChange('volc_tts_protocol', value) + } + optionList={[ + { value: '', label: t('自动 / WS Binary (v1)') }, + { value: 'v3_ws_bidir', label: t('WS 双向流 (v3)') }, + { value: 'v3_ws_uni', label: t('WS 单向流 (v3)') }, + { value: 'v3_http_chunked', label: t('HTTP Chunked (v3)') }, + { value: 'v3_http_sse', label: t('HTTP SSE (v3,原样透传)') }, + ]} + extraText={t( + '选择火山 TTS 上游传输协议。SSE 选项会将 text/event-stream 原样透传给客户端,需客户端自行解析。' + )} + /> + + handleInputChange('volc_tts_resource_id', value) + } + extraText={t( + 'X-Api-Resource-Id 头部值。留空时默认 seed-tts-2.0。常见值:seed-tts-2.0、seed-tts-1.0、seed-tts-1.0-concurr、seed-icl-2.0、seed-icl-1.0、seed-icl-1.0-concurr。' + )} + /> + + handleInputChange('volc_tts_auth_mode', value) + } + optionList={[ + { value: '', label: t('新版控制台 (X-Api-Key)') }, + { value: 'legacy', label: t('旧版控制台 (X-Api-App-Id + X-Api-Access-Key)') }, + ]} + extraText={t( + '新版控制台:取密钥第二段 AccessToken 作为 X-Api-Key 发送。旧版控制台:将 AppId 与 AccessToken 拆为 X-Api-App-Id 与 X-Api-Access-Key 发送。' + )} + /> + + handleInputChange('volc_tts_require_usage', value) + } + extraText={t( + '开启后会发送 X-Control-Require-Usage-Tokens-Return 头部,SessionFinished 帧将携带 usage.text_words 用于计费。' + )} + /> +
+ )} )} diff --git a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx index 597b9a37c661..f2efbdec0677 100644 --- a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -1764,6 +1764,137 @@ export function ChannelMutateDrawer({ /> )} + {/* VolcEngine (type 45) - TTS v3 protocol overrides */} + {currentType === 45 && ( + <> + ( + + {t('TTS Protocol')} + + + {t( + 'Select the Volcengine TTS upstream transport. SSE forwards raw text/event-stream to the client; clients must handle SSE parsing themselves.' + )} + + + + )} + /> + ( + + {t('TTS Resource ID')} + + + + + {t( + 'X-Api-Resource-Id header value. Defaults to seed-tts-2.0 when empty. Common values: seed-tts-2.0, seed-tts-1.0, seed-tts-1.0-concurr, seed-icl-2.0, seed-icl-1.0, seed-icl-1.0-concurr.' + )} + + + + )} + /> + ( + + {t('TTS Auth Mode')} + + + {t( + 'New console: AccessToken (second segment of the API key) is sent as X-Api-Key. Legacy: AppId + AccessToken are sent as X-Api-App-Id + X-Api-Access-Key.' + )} + + + + )} + /> + ( + +
+ + {t('Return token usage from server')} + + + {t( + 'Adds X-Control-Require-Usage-Tokens-Return so SessionFinished payload carries usage.text_words for billing.' + )} + +
+ + + +
+ )} + /> + + )} + {/* Coze (type 49) */} {currentType === 49 && ( = { 22: 'Format: APIKey-AppId, e.g., fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041', 23: 'Format: AppId|SecretId|SecretKey', 33: 'Format: Ak|Sk|Region', + 45: 'Format: AppId|AccessToken (TTS / Realtime). For new-console v3 mode the AccessToken is sent as X-Api-Key; in legacy mode AppId + AccessToken are sent as X-Api-App-Id + X-Api-Access-Key. Chat / embedding paths still use the AccessToken as Bearer.', 50: 'Format: AccessKey|SecretKey (or just ApiKey if upstream is New API)', 51: 'Format: Access Key ID|Secret Access Key', 57: 'Paste Codex OAuth JSON credential (access_token / refresh_token / account_id)', diff --git a/web/default/src/features/channels/lib/channel-form.ts b/web/default/src/features/channels/lib/channel-form.ts index 9944acb8eafd..4d99e15b54a0 100644 --- a/web/default/src/features/channels/lib/channel-form.ts +++ b/web/default/src/features/channels/lib/channel-form.ts @@ -56,6 +56,20 @@ export const channelFormSchema = z.object({ allow_inference_geo: z.boolean().optional(), // OpenAI/Anthropic: inference geography allow_speed: z.boolean().optional(), // Anthropic: speed mode control claude_beta_query: z.boolean().optional(), // Anthropic: beta query passthrough + // Volcengine TTS v3 settings (stored under settings.volc_tts) + volc_tts_protocol: z + .enum([ + '', + 'v1_ws_binary', + 'v3_ws_bidir', + 'v3_ws_uni', + 'v3_http_chunked', + 'v3_http_sse', + ]) + .optional(), + volc_tts_resource_id: z.string().optional(), + volc_tts_auth_mode: z.enum(['', 'new_console', 'legacy']).optional(), + volc_tts_require_usage: z.boolean().optional(), // Upstream model update settings (stored in settings JSON) upstream_model_update_check_enabled: z.boolean().optional(), upstream_model_update_auto_sync_enabled: z.boolean().optional(), @@ -114,6 +128,11 @@ export const CHANNEL_FORM_DEFAULT_VALUES: ChannelFormValues = { allow_inference_geo: false, allow_speed: false, claude_beta_query: false, + // Volcengine TTS v3 defaults + volc_tts_protocol: '', + volc_tts_resource_id: '', + volc_tts_auth_mode: '', + volc_tts_require_usage: true, upstream_model_update_check_enabled: false, upstream_model_update_auto_sync_enabled: false, upstream_model_update_ignored_models: '', @@ -168,6 +187,10 @@ export function transformChannelToFormDefaults( let allowInferenceGeo = false let allowSpeed = false let claudeBetaQuery = false + let volcTTSProtocol = '' + let volcTTSResourceID = '' + let volcTTSAuthMode = '' + let volcTTSRequireUsage = true let upstreamModelUpdateCheckEnabled = false let upstreamModelUpdateAutoSyncEnabled = false let upstreamModelUpdateIgnoredModels = '' @@ -186,6 +209,15 @@ export function transformChannelToFormDefaults( allowInferenceGeo = parsed.allow_inference_geo === true allowSpeed = parsed.allow_speed === true claudeBetaQuery = parsed.claude_beta_query === true + if (parsed.volc_tts && typeof parsed.volc_tts === 'object') { + volcTTSProtocol = parsed.volc_tts.protocol || '' + volcTTSResourceID = parsed.volc_tts.resource_id || '' + volcTTSAuthMode = parsed.volc_tts.auth_mode || '' + volcTTSRequireUsage = + parsed.volc_tts.require_usage === undefined + ? true + : parsed.volc_tts.require_usage === true + } upstreamModelUpdateCheckEnabled = parsed.upstream_model_update_check_enabled === true upstreamModelUpdateAutoSyncEnabled = @@ -241,6 +273,10 @@ export function transformChannelToFormDefaults( allow_speed: allowSpeed, claude_beta_query: claudeBetaQuery, allow_safety_identifier: allowSafetyIdentifier, + volc_tts_protocol: volcTTSProtocol as ChannelFormValues['volc_tts_protocol'], + volc_tts_resource_id: volcTTSResourceID, + volc_tts_auth_mode: volcTTSAuthMode as ChannelFormValues['volc_tts_auth_mode'], + volc_tts_require_usage: volcTTSRequireUsage, upstream_model_update_check_enabled: upstreamModelUpdateCheckEnabled, upstream_model_update_auto_sync_enabled: upstreamModelUpdateAutoSyncEnabled, upstream_model_update_ignored_models: upstreamModelUpdateIgnoredModels, @@ -332,6 +368,34 @@ function buildSettingsJSON(formData: ChannelFormValues): string { delete settingsObj.allow_inference_geo } + // VolcEngine (type 45): TTS v3 protocol overrides + if (formData.type === 45) { + const volcTTS: Record = {} + if ( + formData.volc_tts_protocol && + formData.volc_tts_protocol !== '' && + formData.volc_tts_protocol !== 'v1_ws_binary' + ) { + volcTTS.protocol = formData.volc_tts_protocol + } + if (formData.volc_tts_resource_id) { + volcTTS.resource_id = formData.volc_tts_resource_id + } + if (formData.volc_tts_auth_mode && formData.volc_tts_auth_mode !== '') { + volcTTS.auth_mode = formData.volc_tts_auth_mode + } + if (formData.volc_tts_require_usage === false) { + volcTTS.require_usage = false + } + if (Object.keys(volcTTS).length > 0) { + settingsObj.volc_tts = volcTTS + } else if ('volc_tts' in settingsObj) { + delete settingsObj.volc_tts + } + } else if ('volc_tts' in settingsObj) { + delete settingsObj.volc_tts + } + // Anthropic (type 14): claude_beta_query, allow_inference_geo, allow_speed if (formData.type === 14) { settingsObj.allow_inference_geo = formData.allow_inference_geo === true diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 05e8c5bd4296..29d205b8a2a7 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -4287,6 +4287,21 @@ "Visual Mode": "Visual Mode", "Visual Parameter Override": "Visual Parameter Override", "VolcEngine": "VolcEngine", + "TTS Protocol": "TTS Protocol", + "TTS Resource ID": "TTS Resource ID", + "TTS Auth Mode": "TTS Auth Mode", + "Auto / WS Binary (v1)": "Auto / WS Binary (v1)", + "WS Bidirectional (v3)": "WS Bidirectional (v3)", + "WS Unidirectional (v3)": "WS Unidirectional (v3)", + "HTTP Chunked (v3)": "HTTP Chunked (v3)", + "HTTP SSE (v3, passthrough)": "HTTP SSE (v3, passthrough)", + "Select the Volcengine TTS upstream transport. SSE forwards raw text/event-stream to the client; clients must handle SSE parsing themselves.": "Select the Volcengine TTS upstream transport. SSE forwards raw text/event-stream to the client; clients must handle SSE parsing themselves.", + "X-Api-Resource-Id header value. Defaults to seed-tts-2.0 when empty. Common values: seed-tts-2.0, seed-tts-1.0, seed-tts-1.0-concurr, seed-icl-2.0, seed-icl-1.0, seed-icl-1.0-concurr.": "X-Api-Resource-Id header value. Defaults to seed-tts-2.0 when empty. Common values: seed-tts-2.0, seed-tts-1.0, seed-tts-1.0-concurr, seed-icl-2.0, seed-icl-1.0, seed-icl-1.0-concurr.", + "New console (X-Api-Key)": "New console (X-Api-Key)", + "Legacy (X-Api-App-Id + X-Api-Access-Key)": "Legacy (X-Api-App-Id + X-Api-Access-Key)", + "New console: AccessToken (second segment of the API key) is sent as X-Api-Key. Legacy: AppId + AccessToken are sent as X-Api-App-Id + X-Api-Access-Key.": "New console: AccessToken (second segment of the API key) is sent as X-Api-Key. Legacy: AppId + AccessToken are sent as X-Api-App-Id + X-Api-Access-Key.", + "Return token usage from server": "Return token usage from server", + "Adds X-Control-Require-Usage-Tokens-Return so SessionFinished payload carries usage.text_words for billing.": "Adds X-Control-Require-Usage-Tokens-Return so SessionFinished payload carries usage.text_words for billing.", "vs. previous": "vs. previous", "Waffo Pancake Payment Gateway": "Waffo Pancake payment gateway", "Waffo Payment": "Waffo Payment", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 5d3014ce7408..881e3b5d9dd0 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -4287,6 +4287,21 @@ "Visual Mode": "可视模式", "Visual Parameter Override": "可视化参数覆盖", "VolcEngine": "字节火山方舟、豆包通用", + "TTS Protocol": "TTS 协议", + "TTS Resource ID": "TTS Resource ID", + "TTS Auth Mode": "TTS 鉴权模式", + "Auto / WS Binary (v1)": "自动 / WS Binary (v1)", + "WS Bidirectional (v3)": "WS 双向流 (v3)", + "WS Unidirectional (v3)": "WS 单向流 (v3)", + "HTTP Chunked (v3)": "HTTP Chunked (v3)", + "HTTP SSE (v3, passthrough)": "HTTP SSE (v3,原样透传)", + "Select the Volcengine TTS upstream transport. SSE forwards raw text/event-stream to the client; clients must handle SSE parsing themselves.": "选择火山 TTS 上游传输协议。SSE 选项会将 text/event-stream 原样透传给客户端,需客户端自行解析。", + "X-Api-Resource-Id header value. Defaults to seed-tts-2.0 when empty. Common values: seed-tts-2.0, seed-tts-1.0, seed-tts-1.0-concurr, seed-icl-2.0, seed-icl-1.0, seed-icl-1.0-concurr.": "X-Api-Resource-Id 头部值。留空时默认 seed-tts-2.0。常见值:seed-tts-2.0、seed-tts-1.0、seed-tts-1.0-concurr、seed-icl-2.0、seed-icl-1.0、seed-icl-1.0-concurr。", + "New console (X-Api-Key)": "新版控制台 (X-Api-Key)", + "Legacy (X-Api-App-Id + X-Api-Access-Key)": "旧版控制台 (X-Api-App-Id + X-Api-Access-Key)", + "New console: AccessToken (second segment of the API key) is sent as X-Api-Key. Legacy: AppId + AccessToken are sent as X-Api-App-Id + X-Api-Access-Key.": "新版控制台:取密钥第二段 AccessToken 作为 X-Api-Key 发送。旧版控制台:将 AppId 与 AccessToken 拆为 X-Api-App-Id 与 X-Api-Access-Key 发送。", + "Return token usage from server": "返回上游用量统计", + "Adds X-Control-Require-Usage-Tokens-Return so SessionFinished payload carries usage.text_words for billing.": "开启后会发送 X-Control-Require-Usage-Tokens-Return 头部,SessionFinished 帧将携带 usage.text_words 用于计费。", "vs. previous": "相较上期", "Waffo Pancake Payment Gateway": "Waffo Pancake 支付网关", "Waffo Payment": "Waffo 支付", From a92f3783549b989efeeeb8957aa2b786f49e3f82 Mon Sep 17 00:00:00 2001 From: taoliang1 Date: Sat, 9 May 2026 13:37:34 +0800 Subject: [PATCH 2/4] fix(volcengine): support new-console single-segment API Key, align default Resource ID with default voice map Two follow-up fixes after end-to-end smoke testing v3 TTS: 1. new_console mode now accepts a SINGLE-SEGMENT API Key, sent verbatim as X-Api-Key. Previously the code force-split on '|' and used segment[1] as X-Api-Key, which fails because new-console-issued API Keys are independent credentials (NOT the same as legacy access tokens). Operators using legacy "|" format are still supported for backwards compat (second segment is taken). legacy mode is unchanged: still requires "|" and emits X-Api-App-Id + X-Api-Access-Key. 2. VolcTTSDefaultResourceID changed from "seed-tts-2.0" to "seed-tts-1.0-concurr" to match the default OpenAI->Volcengine voice map (alloy/echo/fable/... all map to *_mars_bigtts, which are v1.0 voices). The previous default produced upstream code=55000000 ("resource ID is mismatched with speaker related resource") for any client that didn't override voice. Users wanting v2.0 still set ResourceID=seed-tts-2.0 in the channel config or via metadata.volc_tts_resource_id, AND must pass a v2.0 voice such as zh_female_xiaohe_uranus_bigtts. Tests updated and pass: go test ./relay/channel/volcengine/... --- dto/channel_settings.go | 10 +++- relay/channel/volcengine/protocols_v3_test.go | 49 ++++++++++++++++--- relay/channel/volcengine/tts_v3_ws.go | 32 +++++++++--- 3 files changed, 77 insertions(+), 14 deletions(-) diff --git a/dto/channel_settings.go b/dto/channel_settings.go index a1c8c7cd0560..046ed80ce59d 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -59,12 +59,18 @@ const ( VolcTTSAuthModeNewConsole = "new_console" // X-Api-Key (default) VolcTTSAuthModeLegacy = "legacy" // X-Api-App-Id + X-Api-Access-Key - VolcTTSDefaultResourceID = "seed-tts-2.0" + // 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-2.0 resource id). +// 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") diff --git a/relay/channel/volcengine/protocols_v3_test.go b/relay/channel/volcengine/protocols_v3_test.go index 6f3159dc5a01..ec63024b79fb 100644 --- a/relay/channel/volcengine/protocols_v3_test.go +++ b/relay/channel/volcengine/protocols_v3_test.go @@ -368,9 +368,11 @@ func TestBuildV3Headers_Modes(t *testing.T) { if h.Get("X-Api-Key") != "" { t.Fatalf("X-Api-Key must be absent in legacy mode") } - // Default resource id when blank. - if h.Get("X-Api-Resource-Id") != "seed-tts-2.0" { - t.Fatalf("default resource id wrong: %q", h.Get("X-Api-Resource-Id")) + // Default resource id when blank — must align with the default + // OpenAI->Volcengine voice map (alloy/echo/... -> *_mars_bigtts, v1.0). + if h.Get("X-Api-Resource-Id") != dto.VolcTTSDefaultResourceID { + t.Fatalf("default resource id wrong: got %q want %q", + h.Get("X-Api-Resource-Id"), dto.VolcTTSDefaultResourceID) } }) @@ -385,9 +387,44 @@ func TestBuildV3Headers_Modes(t *testing.T) { } }) - t.Run("invalid key", func(t *testing.T) { - if _, err := buildV3Headers(volcCfg("", "", "", nil), "no-pipe-here", "cid-4"); err == nil { - t.Fatalf("expected error for malformed key") + t.Run("new_console single-segment key", func(t *testing.T) { + // new-console flow: operator pastes the API Key as a single string + // (no `|` separator). It MUST be sent verbatim as X-Api-Key. + h, err := buildV3Headers(volcCfg("", "seed-tts-2.0", "", nil), "console-issued-key-XYZ", "cid-1b") + if err != nil { + t.Fatalf("build: %v", err) + } + if h.Get("X-Api-Key") != "console-issued-key-XYZ" { + t.Fatalf("single-segment key not forwarded verbatim: %q", h.Get("X-Api-Key")) + } + if h.Get("X-Api-App-Id") != "" || h.Get("X-Api-Access-Key") != "" { + t.Fatalf("legacy headers must be absent for single-segment key") + } + }) + + t.Run("new_console with legacy-format key takes second segment", func(t *testing.T) { + h, err := buildV3Headers(volcCfg("", "", "", nil), "12345|legacy-token", "cid-1c") + if err != nil { + t.Fatalf("build: %v", err) + } + // Backwards-compat: when operator pasted "appid|token" but selected + // new_console, we still pick the second segment so older configs keep + // roughly working (the upstream may still 401 if the access_token is + // not a valid X-Api-Key, which is a separate documented caveat). + if h.Get("X-Api-Key") != "legacy-token" { + t.Fatalf("expected second segment as X-Api-Key, got %q", h.Get("X-Api-Key")) + } + }) + + t.Run("legacy mode rejects single-segment key", func(t *testing.T) { + if _, err := buildV3Headers(volcCfg("legacy", "", "legacy", nil), "no-pipe-here", "cid-4"); err == nil { + t.Fatalf("expected error: legacy mode requires app_id|access_token") + } + }) + + t.Run("empty key rejected", func(t *testing.T) { + if _, err := buildV3Headers(volcCfg("", "", "", nil), " ", "cid-5"); err == nil { + t.Fatalf("expected error for empty key") } }) } diff --git a/relay/channel/volcengine/tts_v3_ws.go b/relay/channel/volcengine/tts_v3_ws.go index 9ef5330e507c..6154f632f5db 100644 --- a/relay/channel/volcengine/tts_v3_ws.go +++ b/relay/channel/volcengine/tts_v3_ws.go @@ -81,19 +81,39 @@ type v3UsageStats struct { // buildV3Headers builds the WS/HTTP request headers used by every v3 transport. // connectID is always emitted so server-side troubleshooting is possible. +// +// Auth modes: +// - new_console (default): the channel key is the new-console API Key sent +// verbatim as X-Api-Key. Both formats are accepted for ergonomics: +// * single segment "" — new-console issued key (recommended). +// * legacy "|" — second segment is used as X-Api-Key +// and a deprecation hint is logged via the response error if upstream +// rejects (since legacy access tokens are NOT valid X-Api-Key values). +// - legacy: the channel key MUST be "|", split into +// X-Api-App-Id + X-Api-Access-Key headers. func buildV3Headers(cfg dto.VolcTTSConfig, apiKey, connectID string) (http.Header, error) { - appID, token, err := parseVolcengineAuth(apiKey) - if err != nil { - return nil, err + apiKey = strings.TrimSpace(apiKey) + if apiKey == "" { + return nil, errors.New("empty volcengine api key") } h := http.Header{} if cfg.EffectiveAuthMode() == dto.VolcTTSAuthModeLegacy { + appID, token, err := parseVolcengineAuth(apiKey) + if err != nil { + return nil, err + } h.Set("X-Api-App-Id", appID) h.Set("X-Api-Access-Key", token) } else { - // New console flow: only the access token is sent. AppID is intentionally - // not surfaced upstream (kept locally for log correlation). - h.Set("X-Api-Key", token) + // new_console: single-segment key is the X-Api-Key as-is. + // If the operator pasted the legacy "appid|token" format we still pick + // the second segment, but note that an old access_token is generally + // NOT a valid new-console API Key. + key := apiKey + if idx := strings.Index(apiKey, "|"); idx >= 0 { + key = apiKey[idx+1:] + } + h.Set("X-Api-Key", key) } h.Set("X-Api-Resource-Id", cfg.EffectiveResourceID()) h.Set("X-Api-Connect-Id", connectID) From 382f130d36af17ab4b53cd92d50f8ed43a80082e Mon Sep 17 00:00:00 2001 From: taoliang1 Date: Sat, 9 May 2026 14:00:30 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(volcengine):=20address=20PR=20#4710=20r?= =?UTF-8?q?eview=20=E2=80=94=20sentinel=20for=20empty=20Select,=20sync=20U?= =?UTF-8?q?I=20copy=20with=20default=20ResourceID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses 8 CodeRabbit review comments on PR #4710: - web/default drawer: replace SelectItem value="" with a non-empty sentinel (VOLC_TTS_DEFAULT_SENTINEL = "__default__") for both volc_tts_protocol and volc_tts_auth_mode. Base UI Select treats "" as a filled value and would break placeholder semantics; the sentinel maps back to "" before persisting. - Sync TTS Resource ID help text + placeholder across both themes (default drawer, classic EditChannelModal, en.json, zh.json) to reflect that the backend default is now seed-tts-1.0-concurr (matching the *_mars_bigtts v1.0 voice map) and that v2.0 voices require an explicit override. - Sync auth-mode help text to cover both single-segment new-console keys and multi-segment legacy AppId|AccessToken keys. - Update TYPE_TO_KEY_PROMPT[45] to document the single-segment new-console key format alongside the legacy two-segment format. - Wrap raw user-facing placeholder string in t() per i18n convention. --- .../channels/modals/EditChannelModal.jsx | 6 ++-- .../drawers/channel-mutate-drawer.tsx | 30 +++++++++++++------ .../src/features/channels/constants.ts | 2 +- web/default/src/i18n/locales/en.json | 5 ++-- web/default/src/i18n/locales/zh.json | 5 ++-- 5 files changed, 31 insertions(+), 17 deletions(-) diff --git a/web/classic/src/components/table/channels/modals/EditChannelModal.jsx b/web/classic/src/components/table/channels/modals/EditChannelModal.jsx index ca1b1c781a30..902160f24d6d 100644 --- a/web/classic/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/classic/src/components/table/channels/modals/EditChannelModal.jsx @@ -3532,12 +3532,12 @@ const EditChannelModal = (props) => { handleInputChange('volc_tts_resource_id', value) } extraText={t( - 'X-Api-Resource-Id 头部值。留空时默认 seed-tts-2.0。常见值:seed-tts-2.0、seed-tts-1.0、seed-tts-1.0-concurr、seed-icl-2.0、seed-icl-1.0、seed-icl-1.0-concurr。' + 'X-Api-Resource-Id 头部值。留空时默认 seed-tts-1.0-concurr(与默认 *_mars_bigtts v1.0 音色映射匹配)。仅当请求 voice 为 v2.0 音色(*_uranus_bigtts、saturn_*)时才需改为 seed-tts-2.0 / seed-icl-2.0。常见值:seed-tts-1.0-concurr、seed-tts-1.0、seed-tts-2.0、seed-icl-2.0、seed-icl-1.0、seed-icl-1.0-concurr。' )} /> { { value: 'legacy', label: t('旧版控制台 (X-Api-App-Id + X-Api-Access-Key)') }, ]} extraText={t( - '新版控制台:取密钥第二段 AccessToken 作为 X-Api-Key 发送。旧版控制台:将 AppId 与 AccessToken 拆为 X-Api-App-Id 与 X-Api-Access-Key 发送。' + '新版控制台:将 API Key 作为 X-Api-Key 发送。单段密钥原样发送;多段密钥(旧版 AppId|AccessToken 格式)取第二段以便兼容老配置。旧版控制台:将 AppId 与 AccessToken 拆为 X-Api-App-Id 与 X-Api-Access-Key 发送。' )} /> @@ -1774,8 +1779,13 @@ export function ChannelMutateDrawer({ {t('TTS Protocol')} {t( - 'X-Api-Resource-Id header value. Defaults to seed-tts-2.0 when empty. Common values: seed-tts-2.0, seed-tts-1.0, seed-tts-1.0-concurr, seed-icl-2.0, seed-icl-1.0, seed-icl-1.0-concurr.' + 'X-Api-Resource-Id header value. Defaults to seed-tts-1.0-concurr (matches the default *_mars_bigtts v1.0 voice map). Use seed-tts-2.0 / seed-icl-2.0 only when the request voice is a v2.0 speaker (*_uranus_bigtts, saturn_*). Common values: seed-tts-1.0-concurr, seed-tts-1.0, seed-tts-2.0, seed-icl-2.0, seed-icl-1.0, seed-icl-1.0-concurr.' )} @@ -1840,8 +1850,10 @@ export function ChannelMutateDrawer({ {t('TTS Auth Mode')} {t( - 'New console: AccessToken (second segment of the API key) is sent as X-Api-Key. Legacy: AppId + AccessToken are sent as X-Api-App-Id + X-Api-Access-Key.' + 'New console: the API Key is sent as X-Api-Key. Single-segment keys are sent verbatim; multi-segment keys (legacy AppId|AccessToken format) take the second segment for backwards compatibility. Legacy: AppId + AccessToken are sent as X-Api-App-Id + X-Api-Access-Key.' )} diff --git a/web/default/src/features/channels/constants.ts b/web/default/src/features/channels/constants.ts index 4b69c98b4fac..a656c5576d03 100644 --- a/web/default/src/features/channels/constants.ts +++ b/web/default/src/features/channels/constants.ts @@ -371,7 +371,7 @@ export const TYPE_TO_KEY_PROMPT: Record = { 22: 'Format: APIKey-AppId, e.g., fastgpt-0sp2gtvfdgyi4k30jwlgwf1i-64f335d84283f05518e9e041', 23: 'Format: AppId|SecretId|SecretKey', 33: 'Format: Ak|Sk|Region', - 45: 'Format: AppId|AccessToken (TTS / Realtime). For new-console v3 mode the AccessToken is sent as X-Api-Key; in legacy mode AppId + AccessToken are sent as X-Api-App-Id + X-Api-Access-Key. Chat / embedding paths still use the AccessToken as Bearer.', + 45: 'Format: AccessToken (single-segment, new-console preferred) or AppId|AccessToken (legacy-compatible). For new-console v3 mode, X-Api-Key uses the full single-segment key, or the second segment when the legacy AppId|AccessToken format is pasted. In legacy mode, AppId + AccessToken are sent as X-Api-App-Id + X-Api-Access-Key. Chat / embedding paths still use Bearer AccessToken.', 50: 'Format: AccessKey|SecretKey (or just ApiKey if upstream is New API)', 51: 'Format: Access Key ID|Secret Access Key', 57: 'Paste Codex OAuth JSON credential (access_token / refresh_token / account_id)', diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index 29d205b8a2a7..901ea8c61954 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -4296,10 +4296,11 @@ "HTTP Chunked (v3)": "HTTP Chunked (v3)", "HTTP SSE (v3, passthrough)": "HTTP SSE (v3, passthrough)", "Select the Volcengine TTS upstream transport. SSE forwards raw text/event-stream to the client; clients must handle SSE parsing themselves.": "Select the Volcengine TTS upstream transport. SSE forwards raw text/event-stream to the client; clients must handle SSE parsing themselves.", - "X-Api-Resource-Id header value. Defaults to seed-tts-2.0 when empty. Common values: seed-tts-2.0, seed-tts-1.0, seed-tts-1.0-concurr, seed-icl-2.0, seed-icl-1.0, seed-icl-1.0-concurr.": "X-Api-Resource-Id header value. Defaults to seed-tts-2.0 when empty. Common values: seed-tts-2.0, seed-tts-1.0, seed-tts-1.0-concurr, seed-icl-2.0, seed-icl-1.0, seed-icl-1.0-concurr.", + "X-Api-Resource-Id header value. Defaults to seed-tts-1.0-concurr (matches the default *_mars_bigtts v1.0 voice map). Use seed-tts-2.0 / seed-icl-2.0 only when the request voice is a v2.0 speaker (*_uranus_bigtts, saturn_*). Common values: seed-tts-1.0-concurr, seed-tts-1.0, seed-tts-2.0, seed-icl-2.0, seed-icl-1.0, seed-icl-1.0-concurr.": "X-Api-Resource-Id header value. Defaults to seed-tts-1.0-concurr (matches the default *_mars_bigtts v1.0 voice map). Use seed-tts-2.0 / seed-icl-2.0 only when the request voice is a v2.0 speaker (*_uranus_bigtts, saturn_*). Common values: seed-tts-1.0-concurr, seed-tts-1.0, seed-tts-2.0, seed-icl-2.0, seed-icl-1.0, seed-icl-1.0-concurr.", + "seed-tts-1.0-concurr": "seed-tts-1.0-concurr", "New console (X-Api-Key)": "New console (X-Api-Key)", "Legacy (X-Api-App-Id + X-Api-Access-Key)": "Legacy (X-Api-App-Id + X-Api-Access-Key)", - "New console: AccessToken (second segment of the API key) is sent as X-Api-Key. Legacy: AppId + AccessToken are sent as X-Api-App-Id + X-Api-Access-Key.": "New console: AccessToken (second segment of the API key) is sent as X-Api-Key. Legacy: AppId + AccessToken are sent as X-Api-App-Id + X-Api-Access-Key.", + "New console: the API Key is sent as X-Api-Key. Single-segment keys are sent verbatim; multi-segment keys (legacy AppId|AccessToken format) take the second segment for backwards compatibility. Legacy: AppId + AccessToken are sent as X-Api-App-Id + X-Api-Access-Key.": "New console: the API Key is sent as X-Api-Key. Single-segment keys are sent verbatim; multi-segment keys (legacy AppId|AccessToken format) take the second segment for backwards compatibility. Legacy: AppId + AccessToken are sent as X-Api-App-Id + X-Api-Access-Key.", "Return token usage from server": "Return token usage from server", "Adds X-Control-Require-Usage-Tokens-Return so SessionFinished payload carries usage.text_words for billing.": "Adds X-Control-Require-Usage-Tokens-Return so SessionFinished payload carries usage.text_words for billing.", "vs. previous": "vs. previous", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 881e3b5d9dd0..b1d1da571fc4 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -4296,10 +4296,11 @@ "HTTP Chunked (v3)": "HTTP Chunked (v3)", "HTTP SSE (v3, passthrough)": "HTTP SSE (v3,原样透传)", "Select the Volcengine TTS upstream transport. SSE forwards raw text/event-stream to the client; clients must handle SSE parsing themselves.": "选择火山 TTS 上游传输协议。SSE 选项会将 text/event-stream 原样透传给客户端,需客户端自行解析。", - "X-Api-Resource-Id header value. Defaults to seed-tts-2.0 when empty. Common values: seed-tts-2.0, seed-tts-1.0, seed-tts-1.0-concurr, seed-icl-2.0, seed-icl-1.0, seed-icl-1.0-concurr.": "X-Api-Resource-Id 头部值。留空时默认 seed-tts-2.0。常见值:seed-tts-2.0、seed-tts-1.0、seed-tts-1.0-concurr、seed-icl-2.0、seed-icl-1.0、seed-icl-1.0-concurr。", + "X-Api-Resource-Id header value. Defaults to seed-tts-1.0-concurr (matches the default *_mars_bigtts v1.0 voice map). Use seed-tts-2.0 / seed-icl-2.0 only when the request voice is a v2.0 speaker (*_uranus_bigtts, saturn_*). Common values: seed-tts-1.0-concurr, seed-tts-1.0, seed-tts-2.0, seed-icl-2.0, seed-icl-1.0, seed-icl-1.0-concurr.": "X-Api-Resource-Id 头部值。留空时默认 seed-tts-1.0-concurr(与默认 *_mars_bigtts v1.0 音色映射匹配)。仅当请求 voice 为 v2.0 音色(*_uranus_bigtts、saturn_*)时才需改为 seed-tts-2.0 / seed-icl-2.0。常见值:seed-tts-1.0-concurr、seed-tts-1.0、seed-tts-2.0、seed-icl-2.0、seed-icl-1.0、seed-icl-1.0-concurr。", + "seed-tts-1.0-concurr": "seed-tts-1.0-concurr", "New console (X-Api-Key)": "新版控制台 (X-Api-Key)", "Legacy (X-Api-App-Id + X-Api-Access-Key)": "旧版控制台 (X-Api-App-Id + X-Api-Access-Key)", - "New console: AccessToken (second segment of the API key) is sent as X-Api-Key. Legacy: AppId + AccessToken are sent as X-Api-App-Id + X-Api-Access-Key.": "新版控制台:取密钥第二段 AccessToken 作为 X-Api-Key 发送。旧版控制台:将 AppId 与 AccessToken 拆为 X-Api-App-Id 与 X-Api-Access-Key 发送。", + "New console: the API Key is sent as X-Api-Key. Single-segment keys are sent verbatim; multi-segment keys (legacy AppId|AccessToken format) take the second segment for backwards compatibility. Legacy: AppId + AccessToken are sent as X-Api-App-Id + X-Api-Access-Key.": "新版控制台:将 API Key 作为 X-Api-Key 发送。单段密钥原样发送;多段密钥(旧版 AppId|AccessToken 格式)取第二段以便兼容老配置。旧版控制台:将 AppId 与 AccessToken 拆为 X-Api-App-Id 与 X-Api-Access-Key 发送。", "Return token usage from server": "返回上游用量统计", "Adds X-Control-Require-Usage-Tokens-Return so SessionFinished payload carries usage.text_words for billing.": "开启后会发送 X-Control-Require-Usage-Tokens-Return 头部,SessionFinished 帧将携带 usage.text_words 用于计费。", "vs. previous": "相较上期", From bd8df3d093731d048961e45d147c0cd3845859da Mon Sep 17 00:00:00 2001 From: taoliang1 Date: Sat, 9 May 2026 15:18:32 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(volcengine):=20bound=20v3=20WS=20receiv?= =?UTF-8?q?e=20blocking=20=E2=80=94=20context=20cancel=20+=20per-frame=20d?= =?UTF-8?q?eadline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the CodeRabbit nit on PR #4710 about handleTTSV3WSResponse pinning a goroutine when the client disconnects or upstream stops sending frames: - Add a watcher goroutine that closes the upstream WS as soon as c.Request.Context() is cancelled (i.e. the gin client went away). Without this, ReceiveMessage blocked on the underlying TCP socket until OS keepalive eventually tripped, holding the gin worker for many minutes. - Apply a sliding 30s read deadline to every ReceiveMessage call inside drainLoop and inside v3ExpectEvent (handshake). A healthy stream keeps refreshing the deadline; a stalled upstream times out and surfaces a 502 / 504 instead of leaking the goroutine. - When the client cancellation closes the conn, the loop returns http.StatusGatewayTimeout with a clear "stream cancelled by client" error rather than a generic recv failure. HTTP chunked / SSE paths already react to client cancellation through http.NewRequestWithContext(c.Request.Context(), ...); only the bespoke WS path needed this hardening. --- relay/channel/volcengine/tts_v3_ws.go | 36 ++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/relay/channel/volcengine/tts_v3_ws.go b/relay/channel/volcengine/tts_v3_ws.go index 6154f632f5db..c9b5745dd4c4 100644 --- a/relay/channel/volcengine/tts_v3_ws.go +++ b/relay/channel/volcengine/tts_v3_ws.go @@ -193,6 +193,12 @@ func intPtr(v int) *int { // Main entry point // ---------------------------------------------------------------------------- +// v3FrameIdleTimeout caps how long a single ReceiveMessage may block when +// upstream stops sending frames. Volcengine TTS responses are typically a +// continuous audio stream; 30s of silence reliably indicates a stalled +// upstream and we'd rather surface a 502 than pin a goroutine indefinitely. +const v3FrameIdleTimeout = 30 * time.Second + // handleTTSV3WSResponse drives a single OpenAI /v1/audio/speech (+stream) // request through Volcengine v3 bidirectional or unidirectional WebSocket. // Audio bytes are written directly to c.Writer (chunked transfer); usage is @@ -226,6 +232,21 @@ func handleTTSV3WSResponse(c *gin.Context, requestURL string, vReq VolcengineTTS } defer conn.Close() + // Tear down the upstream WS as soon as the client disconnects (or relay + // context is otherwise cancelled). Without this, ReceiveMessage would block + // on the underlying TCP socket until OS keepalive trips, holding the gin + // worker for many minutes. + clientCtx := c.Request.Context() + stopWatcher := make(chan struct{}) + go func() { + select { + case <-clientCtx.Done(): + _ = conn.Close() + case <-stopWatcher: + } + }() + defer close(stopWatcher) + // Capture upstream logid (best-effort; ignore if absent). if resp != nil { if logID := resp.Header.Get("X-Tt-Logid"); logID != "" { @@ -287,8 +308,19 @@ func handleTTSV3WSResponse(c *gin.Context, requestURL string, vReq VolcengineTTS drainLoop: for { + // Per-frame sliding deadline. Refreshed on each successful read so a + // healthy stream keeps flowing while a stalled upstream times out. + _ = conn.SetReadDeadline(time.Now().Add(v3FrameIdleTimeout)) msg, recvErr := ReceiveMessage(conn) if recvErr != nil { + // Client gone away → conn was closed by the watcher goroutine. + if clientCtx.Err() != nil { + return nil, types.NewErrorWithStatusCode( + fmt.Errorf("volcengine v3 stream cancelled by client: %w", clientCtx.Err()), + types.ErrorCodeBadResponse, + http.StatusGatewayTimeout, + ) + } if websocket.IsCloseError(recvErr, websocket.CloseNormalClosure, websocket.CloseGoingAway) { break drainLoop } @@ -388,9 +420,11 @@ func v3WrapError(err error, hint string) *types.NewAPIError { // v3ExpectEvent blocks until either the success event or the failure event // arrives. Any other event (e.g. SentenceStart) is swallowed; an MsgTypeError -// frame surfaces immediately. +// frame surfaces immediately. A per-call read deadline ensures we don't hang +// forever if upstream goes silent during the handshake. func v3ExpectEvent(conn *websocket.Conn, success, failure EventType) *types.NewAPIError { for { + _ = conn.SetReadDeadline(time.Now().Add(v3FrameIdleTimeout)) msg, err := ReceiveMessage(conn) if err != nil { return v3WrapError(err, fmt.Sprintf("waiting for %s", success))