Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions cmd/extproc/mainlib/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"google.golang.org/grpc"
"google.golang.org/grpc/health/grpc_health_v1"

dashscopeschema "github.com/envoyproxy/ai-gateway/internal/apischema/dashscope"
gcpschema "github.com/envoyproxy/ai-gateway/internal/apischema/gcp"
"github.com/envoyproxy/ai-gateway/internal/apischema/openai"
"github.com/envoyproxy/ai-gateway/internal/endpointspec"
Expand Down Expand Up @@ -344,6 +345,20 @@ func Main(ctx context.Context, args []string, stderr io.Writer) (err error) {
extractGeminiPathInfo,
)

// Alibaba DashScope voice-enrollment (Qwen-TTS voice cloning management). Fork-only, non-OpenAI
// native endpoint. A single URL hosts create_voice / list_voice / query_voice / update_voice /
// delete_voice via `input.action`. Body flows through the translator unchanged; only x-ai-eg-model
// is populated from body.model for route matching.
voiceEnrollmentMetricsFactory := metrics.NewMetricsFactory(meter, metricsRequestHeaderAttributes, metrics.GenAIOperationSpeech)
server.Register(
path.Join(flags.rootPrefix, "/api/v1/services/audio/tts/customization"),
extproc.NewFactory(
voiceEnrollmentMetricsFactory,
tracingapi.NoopTracer[dashscopeschema.VoiceEnrollmentRequest, struct{}, struct{}]{},
endpointspec.DashScopeVoiceEnrollmentEndpointSpec{},
),
)

// Gemini cachedContents API for explicit context caching (POST/GET/PATCH/DELETE).
// Shares the /v1/projects/ prefix with generateContent above; server.go falls through
// from generateContent's pathExtract (which returns nil for non-/models/ paths) to this entry.
Expand Down
28 changes: 28 additions & 0 deletions internal/apischema/dashscope/dashscope.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,34 @@ type SpeechOutput struct {
Audio SpeechOutputAudio `json:"audio"`
}

// VoiceEnrollmentRequest is the request body for POST /api/v1/services/audio/tts/customization —
// the DashScope voice-cloning management endpoint. A single URL hosts all five actions
// (create_voice, list_voice, query_voice, update_voice, delete_voice); which one is executed
// is decided by `input.action` in the body.
//
// The translator only needs `model` (routing) and `input.action` (observability/metrics); every
// other field flows through as raw JSON. Callers speak DashScope's native schema — this
// endpoint has no OpenAI counterpart to translate against.
//
// https://www.alibabacloud.com/help/en/model-studio/voice-clone-design-http-api
type VoiceEnrollmentRequest struct {
// Model is always "voice-enrollment" for this endpoint; kept in the parsed request so the
// gateway can populate x-ai-eg-model for route matching.
Model string `json:"model"`
// Input carries the per-action payload. Only `action` is inspected here for logging /
// metrics tagging; the remaining fields (target_model, prefix, url, voice_id, …) are left
// to flow through as raw JSON.
Input VoiceEnrollmentInput `json:"input"`
}

// VoiceEnrollmentInput mirrors the `input` object of a voice-enrollment request. Only the
// action tag is modelled — everything else is per-action noise that the gateway forwards
// unchanged.
type VoiceEnrollmentInput struct {
// Action selects the operation: create_voice | list_voice | query_voice | update_voice | delete_voice.
Action string `json:"action"`
}

// SpeechOutputAudio is the audio pointer within a DashScope response.
//
// Note: DashScope's non-streaming reply also carries an `expires_at` field encoded as a Unix
Expand Down
48 changes: 48 additions & 0 deletions internal/endpointspec/endpointspec.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (

"github.com/envoyproxy/ai-gateway/internal/apischema/anthropic"
cohereschema "github.com/envoyproxy/ai-gateway/internal/apischema/cohere"
dashscopeschema "github.com/envoyproxy/ai-gateway/internal/apischema/dashscope"
gcpschema "github.com/envoyproxy/ai-gateway/internal/apischema/gcp"
"github.com/envoyproxy/ai-gateway/internal/apischema/openai"
"github.com/envoyproxy/ai-gateway/internal/filterapi"
Expand Down Expand Up @@ -124,6 +125,13 @@ type (
// Used for explicit context cache CRUD (POST/GET/PATCH/DELETE). The request body is parsed
// best-effort: POST/PATCH carry a CachedContentRequest, GET/DELETE carry no body.
GeminiCachedContentsEndpointSpec struct{}
// DashScopeVoiceEnrollmentEndpointSpec implements EndpointSpec for Alibaba DashScope's
// voice-cloning management endpoint (POST /api/v1/services/audio/tts/customization). This
// is a fork-only, non-OpenAI native endpoint; a single URL hosts five actions
// (create_voice, list_voice, query_voice, update_voice, delete_voice) chosen via
// input.action. The gateway forwards the body unchanged; only body.model is parsed so route
// matching can key off x-ai-eg-model=voice-enrollment.
DashScopeVoiceEnrollmentEndpointSpec struct{}
// TranscriptionEndpointSpec implements EndpointSpec for /v1/audio/transcriptions.
TranscriptionEndpointSpec struct{}
// TranslationEndpointSpec implements EndpointSpec for /v1/audio/translations.
Expand Down Expand Up @@ -599,6 +607,46 @@ func (GeminiCachedContentsEndpointSpec) RedactSensitiveInfoFromRequest(req *gcps
return req, nil
}

// ParseBody implements [Spec.ParseBody] for DashScope voice-enrollment. Only body.model and
// body.input.action are pulled out (used for x-ai-eg-model routing and metrics tagging); the
// rest of the payload varies per action and flows through as raw JSON.
func (DashScopeVoiceEnrollmentEndpointSpec) ParseBody(
body []byte,
_ bool,
) (internalapi.OriginalModel, *dashscopeschema.VoiceEnrollmentRequest, bool, []byte, error) {
var req dashscopeschema.VoiceEnrollmentRequest
if len(body) > 0 {
if err := json.Unmarshal(body, &req); err != nil {
return "", nil, false, nil, fmt.Errorf("%w: failed to parse JSON for voice enrollment: %w", internalapi.ErrMalformedRequest, err)
}
}
// voice-enrollment is never streamed.
return req.Model, &req, false, nil, nil
Comment on lines +617 to +624

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

modelvoice-enrollment으로 검증해야 합니다.

주석과 테스트는 이 엔드포인트의 모델이 항상 voice-enrollment이라고 정의하지만, 현재 구현은 임의의 req.Model을 그대로 라우팅에 반환합니다. 이 값이 x-ai-eg-model 및 모델별 라우팅/RBAC 선택에 사용되므로, 불일치하거나 빈 model은 ErrMalformedRequest로 거부하고 고정값만 허용해야 합니다.

제안 수정
 	if len(body) > 0 {
 		if err := json.Unmarshal(body, &req); err != nil {
 			return "", nil, false, nil, fmt.Errorf("%w: failed to parse JSON for voice enrollment: %w", internalapi.ErrMalformedRequest, err)
 		}
 	}
+	if req.Model != "voice-enrollment" {
+		return "", nil, false, nil, fmt.Errorf("%w: model must be voice-enrollment", internalapi.ErrMalformedRequest)
+	}
 	// voice-enrollment is never streamed.
📝 Committable suggestion

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

Suggested change
var req dashscopeschema.VoiceEnrollmentRequest
if len(body) > 0 {
if err := json.Unmarshal(body, &req); err != nil {
return "", nil, false, nil, fmt.Errorf("%w: failed to parse JSON for voice enrollment: %w", internalapi.ErrMalformedRequest, err)
}
}
// voice-enrollment is never streamed.
return req.Model, &req, false, nil, nil
var req dashscopeschema.VoiceEnrollmentRequest
if len(body) > 0 {
if err := json.Unmarshal(body, &req); err != nil {
return "", nil, false, nil, fmt.Errorf("%w: failed to parse JSON for voice enrollment: %w", internalapi.ErrMalformedRequest, err)
}
}
if req.Model != "voice-enrollment" {
return "", nil, false, nil, fmt.Errorf("%w: model must be voice-enrollment", internalapi.ErrMalformedRequest)
}
// voice-enrollment is never streamed.
return req.Model, &req, false, nil, nil
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/endpointspec/endpointspec.go` around lines 617 - 624, Validate
req.Model in the voice-enrollment request handling before returning from this
path: require the exact value "voice-enrollment", reject empty or mismatched
values with internalapi.ErrMalformedRequest, and return the fixed model value
for routing instead of arbitrary req.Model.

}

// ParseMultipartBody implements [Spec.ParseMultipartBody]. voice-enrollment is JSON-only.
func (DashScopeVoiceEnrollmentEndpointSpec) ParseMultipartBody([]byte, string, bool) (internalapi.OriginalModel, *dashscopeschema.VoiceEnrollmentRequest, bool, []byte, error) {
return "", nil, false, nil, errMultipartNotSupported
}

// GetTranslator implements [EndpointSpec.GetTranslator]. Only AlibabaDashScope is supported;
// there is no OpenAI-shaped counterpart for voice-cloning management, so this is fork-only.
func (DashScopeVoiceEnrollmentEndpointSpec) GetTranslator(schema filterapi.VersionedAPISchema, _ string) (translator.DashScopeVoiceEnrollmentTranslator, error) {
switch schema.Name {
case filterapi.APISchemaAlibabaDashScope:
return translator.NewDashScopeVoiceEnrollmentTranslator(), nil
default:
return nil, fmt.Errorf("unsupported API schema for voice enrollment: backend=%s", schema)
}
}

// RedactSensitiveInfoFromRequest implements [EndpointSpec.RedactSensitiveInfoFromRequest].
// voice-enrollment carries only opaque control fields (voice_id, prefix, action, remote audio
// URL); nothing needs redaction for debug logs.
func (DashScopeVoiceEnrollmentEndpointSpec) RedactSensitiveInfoFromRequest(req *dashscopeschema.VoiceEnrollmentRequest) (redactedReq *dashscopeschema.VoiceEnrollmentRequest, err error) {
return req, nil
}

// redactMessage redacts sensitive content from a chat message while preserving its type and structure.
// This dispatches to role-specific redaction functions based on the message type.
func redactMessage(msg openai.ChatCompletionMessageParamUnion) openai.ChatCompletionMessageParamUnion {
Expand Down
86 changes: 86 additions & 0 deletions internal/translator/voice_enrollment_dashscope.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright Envoy AI Gateway Authors
// SPDX-License-Identifier: Apache-2.0
// The full text of the Apache license is available in the LICENSE file at
// the root of the repo.

package translator

import (
"io"
"strconv"

"github.com/envoyproxy/ai-gateway/internal/apischema/dashscope"
"github.com/envoyproxy/ai-gateway/internal/internalapi"
"github.com/envoyproxy/ai-gateway/internal/metrics"
"github.com/envoyproxy/ai-gateway/internal/tracing/tracingapi"
)

// dashScopeVoiceEnrollmentPath is DashScope's native voice-cloning endpoint. A single URL
// hosts every action (create_voice / list_voice / query_voice / update_voice / delete_voice);
// the action is chosen in the request body.
//
// https://www.alibabacloud.com/help/en/model-studio/voice-clone-design-http-api
const dashScopeVoiceEnrollmentPath = "/api/v1/services/audio/tts/customization"

// DashScopeVoiceEnrollmentSpan is a zero-value span for voice-enrollment. There is no
// per-request response object to record — the endpoint returns short JSON envelopes
// (voice_id, status, page_index, …) that we forward untouched.
type DashScopeVoiceEnrollmentSpan = tracingapi.Span[struct{}, struct{}]

// DashScopeVoiceEnrollmentTranslator translates DashScope voice-enrollment requests as a
// pass-through. It exists so the gateway can attach auth/logging/RBAC to the DashScope
// management surface without reshaping the payload.
type DashScopeVoiceEnrollmentTranslator = Translator[dashscope.VoiceEnrollmentRequest, DashScopeVoiceEnrollmentSpan]

// dashScopeVoiceEnrollmentTranslator forwards voice-enrollment CRUD calls to DashScope
// unchanged. The body varies per action (create takes `url`+`prefix`, delete takes only
// `voice_id`, list is paginated, etc.) so any translator-side reshaping would just add
// friction; we forward the caller's payload verbatim and only rewrite `:path`.
type dashScopeVoiceEnrollmentTranslator struct{}

// NewDashScopeVoiceEnrollmentTranslator creates a pass-through translator for voice-enrollment.
func NewDashScopeVoiceEnrollmentTranslator() DashScopeVoiceEnrollmentTranslator {
return &dashScopeVoiceEnrollmentTranslator{}
}

// RequestBody implements [DashScopeVoiceEnrollmentTranslator.RequestBody].
//
// Body flows through untouched — DashScope owns the schema per action, and modelling every
// variant here would just track upstream drift. The router already parsed body.model /
// body.input.action for logging + routing, so nothing else is required. We only need to
// re-emit the original bytes when Envoy's upstream stage forces a body replacement (retry /
// streaming), and to rewrite `:path` to the DashScope native URL because Envoy is otherwise
// still sitting on the client's inbound path.
func (dashScopeVoiceEnrollmentTranslator) RequestBody(original []byte, _ *dashscope.VoiceEnrollmentRequest, forceBodyMutation bool) (
newHeaders []internalapi.Header, newBody []byte, err error,
) {
newHeaders = []internalapi.Header{{pathHeaderName, dashScopeVoiceEnrollmentPath}}
if forceBodyMutation && len(original) > 0 {
newBody = original
newHeaders = append(newHeaders, internalapi.Header{contentLengthHeaderName, strconv.Itoa(len(newBody))})
}
return
}

// ResponseHeaders implements [DashScopeVoiceEnrollmentTranslator.ResponseHeaders].
// DashScope returns application/json envelopes and the caller expects that shape, so we do
// not touch response headers.
func (dashScopeVoiceEnrollmentTranslator) ResponseHeaders(_ map[string]string) (newHeaders []internalapi.Header, err error) {
return nil, nil
}

// ResponseBody implements [DashScopeVoiceEnrollmentTranslator.ResponseBody]. Passes DashScope's
// JSON response through unchanged; the action-specific envelope (`voice_id`, `voice_list`,
// resource_link, status, …) is the caller's contract, not the gateway's to reshape.
func (dashScopeVoiceEnrollmentTranslator) ResponseBody(_ map[string]string, _ io.Reader, _ bool, _ DashScopeVoiceEnrollmentSpan) (
newHeaders []internalapi.Header, newBody []byte, tokenUsage metrics.TokenUsage, responseModel internalapi.ResponseModel, err error,
) {
return nil, nil, metrics.TokenUsage{}, "voice-enrollment", nil
}

// ResponseError implements [DashScopeVoiceEnrollmentTranslator.ResponseError]. DashScope's
// error envelope (`code`, `message`) is passed through as-is — no OpenAI-shaped rewriting is
// meaningful for a fork-only native endpoint.
func (dashScopeVoiceEnrollmentTranslator) ResponseError(_ map[string]string, _ io.Reader) (newHeaders []internalapi.Header, newBody []byte, err error) {
return nil, nil, nil
}
79 changes: 79 additions & 0 deletions internal/translator/voice_enrollment_dashscope_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright Envoy AI Gateway Authors
// SPDX-License-Identifier: Apache-2.0
// The full text of the Apache license is available in the LICENSE file at
// the root of the repo.

package translator

import (
"strings"
"testing"

"github.com/stretchr/testify/require"

"github.com/envoyproxy/ai-gateway/internal/apischema/dashscope"
)

func TestDashScopeVoiceEnrollment_RequestBody(t *testing.T) {
tr := NewDashScopeVoiceEnrollmentTranslator()
req := &dashscope.VoiceEnrollmentRequest{
Model: "voice-enrollment",
Input: dashscope.VoiceEnrollmentInput{Action: "create_voice"},
}
original := []byte(`{"model":"voice-enrollment","input":{"action":"create_voice","target_model":"qwen3-tts-flash","prefix":"pk","url":"https://example.com/a.wav"}}`)

t.Run("path is rewritten, body is not replayed by default", func(t *testing.T) {
hm, body, err := tr.RequestBody(original, req, false)
require.NoError(t, err)
require.Empty(t, body, "no forced mutation → translator should not carry the body itself")

var pathVal string
for _, h := range hm {
if h.Key() == ":path" {
pathVal = h.Value()
}
}
require.Equal(t, dashScopeVoiceEnrollmentPath, pathVal)
})

t.Run("forceBodyMutation replays the original body verbatim", func(t *testing.T) {
hm, body, err := tr.RequestBody(original, req, true)
require.NoError(t, err)
require.Equal(t, original, body, "forced replay must preserve caller's payload byte-for-byte")

var cl string
for _, h := range hm {
if h.Key() == "content-length" {
cl = h.Value()
}
}
require.NotEmpty(t, cl)
})
}

func TestDashScopeVoiceEnrollment_ResponseHeaders(t *testing.T) {
tr := NewDashScopeVoiceEnrollmentTranslator()
hm, err := tr.ResponseHeaders(nil)
require.NoError(t, err)
require.Empty(t, hm, "response headers pass through untouched")
}

func TestDashScopeVoiceEnrollment_ResponseBody(t *testing.T) {
tr := NewDashScopeVoiceEnrollmentTranslator()
envelope := `{"request_id":"rid","output":{"voice_id":"custom-abc"},"usage":{"count":1}}`
hm, body, tokens, respModel, err := tr.ResponseBody(nil, strings.NewReader(envelope), true, nil)
require.NoError(t, err)
require.Empty(t, hm)
require.Empty(t, body, "pass-through: envoy delivers upstream body directly, no mutation")
require.Equal(t, "voice-enrollment", respModel)
_, set := tokens.OutputTokens()
require.False(t, set, "voice-enrollment carries no token accounting")
}

func TestDashScopeVoiceEnrollment_ResponseError(t *testing.T) {
tr := NewDashScopeVoiceEnrollmentTranslator()
hm, body, err := tr.ResponseError(nil, strings.NewReader(`{"code":"InvalidParameter","message":"missing url"}`))
require.NoError(t, err)
require.Empty(t, hm)
require.Empty(t, body, "error envelope passes through unchanged")
}
Loading