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
12 changes: 5 additions & 7 deletions internal/endpointspec/endpointspec.go
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ func (GeminiGenerateContentEndpointSpec) ParseBody(
return "", nil, false, nil, fmt.Errorf("%w: failed to parse JSON for Gemini generateContent: %w", internalapi.ErrMalformedRequest, err)
}
// Model and Stream are json:"-" fields populated by the processor from path-derived headers.
return internalapi.OriginalModel(req.Model), &req, req.Stream, nil, nil
return req.Model, &req, req.Stream, nil, nil
}

// ParseMultipartBody implements [Spec.ParseMultipartBody]. Gemini generateContent is JSON-only.
Expand All @@ -530,7 +530,7 @@ func (GeminiGenerateContentEndpointSpec) ParseMultipartBody([]byte, string, bool
func (GeminiGenerateContentEndpointSpec) GetTranslator(schema filterapi.VersionedAPISchema, modelNameOverride string) (translator.GeminiGenerateContentTranslator, error) {
switch schema.Name {
case filterapi.APISchemaGCPVertexAI:
return translator.NewGeminiToGCPVertexAITranslator(internalapi.ModelNameOverride(modelNameOverride)), nil
return translator.NewGeminiToGCPVertexAITranslator(modelNameOverride), nil
default:
return nil, fmt.Errorf("unsupported API schema for Gemini generateContent: backend=%s", schema)
}
Expand Down Expand Up @@ -562,7 +562,7 @@ func (GeminiCachedContentsEndpointSpec) ParseBody(
}
model := normalizeVertexModelName(req.Model)
// cachedContents is never streamed.
return internalapi.OriginalModel(model), &req, false, nil, nil
return model, &req, false, nil, nil
}

// normalizeVertexModelName trims any Vertex AI resource prefix from a model identifier and
Expand All @@ -587,7 +587,7 @@ func (GeminiCachedContentsEndpointSpec) ParseMultipartBody([]byte, string, bool)
func (GeminiCachedContentsEndpointSpec) GetTranslator(schema filterapi.VersionedAPISchema, modelNameOverride string) (translator.GeminiCachedContentsTranslator, error) {
switch schema.Name {
case filterapi.APISchemaGCPVertexAI:
return translator.NewGeminiCachedContentsToGCPVertexAITranslator(internalapi.ModelNameOverride(modelNameOverride)), nil
return translator.NewGeminiCachedContentsToGCPVertexAITranslator(modelNameOverride), nil
default:
return nil, fmt.Errorf("unsupported API schema for Gemini cachedContents: backend=%s", schema)
}
Expand Down Expand Up @@ -804,9 +804,7 @@ func (SpeechEndpointSpec) GetTranslator(
modelNameOverride,
), nil
case filterapi.APISchemaAlibabaDashScope:
return translator.NewSpeechOpenAIToDashScopeTranslator(
internalapi.ModelNameOverride(modelNameOverride),
), nil
return translator.NewSpeechOpenAIToDashScopeTranslator(modelNameOverride), nil
default:
return nil, fmt.Errorf("unsupported API schema for speech: backend=%s", schema)
}
Expand Down
10 changes: 5 additions & 5 deletions internal/endpointspec/endpointspec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import (
"google.golang.org/genai"
"k8s.io/utils/ptr"

gcpschema "github.com/envoyproxy/ai-gateway/internal/apischema/gcp"
cohereschema "github.com/envoyproxy/ai-gateway/internal/apischema/cohere"
gcpschema "github.com/envoyproxy/ai-gateway/internal/apischema/gcp"
"github.com/envoyproxy/ai-gateway/internal/apischema/openai"
"github.com/envoyproxy/ai-gateway/internal/filterapi"
"github.com/envoyproxy/ai-gateway/internal/json"
Expand Down Expand Up @@ -1137,7 +1137,7 @@ func TestGeminiGenerateContentEndpointSpec_ParseBody(t *testing.T) {
require.NoError(t, err)
// Model and Stream are json:"-" fields — they are empty after unmarshaling.
// The processor fills them from synthetic headers later.
require.Equal(t, "", model)
require.Empty(t, model)
require.False(t, stream)
require.NotNil(t, parsed)
require.Nil(t, mutated)
Expand Down Expand Up @@ -1539,7 +1539,7 @@ func TestGeminiCachedContentsEndpointSpec_ParseBody(t *testing.T) {
t.Run("empty body (GET/DELETE) returns zero value", func(t *testing.T) {
model, req, stream, mutated, err := spec.ParseBody(nil, false)
require.NoError(t, err)
require.Equal(t, "", string(model))
require.Empty(t, model)
require.NotNil(t, req)
require.False(t, stream)
require.Nil(t, mutated)
Expand All @@ -1549,7 +1549,7 @@ func TestGeminiCachedContentsEndpointSpec_ParseBody(t *testing.T) {
body := []byte(`{"model":"projects/p/locations/us-central1/publishers/google/models/gemini-1.5-pro","ttl":"3600s"}`)
model, req, stream, _, err := spec.ParseBody(body, false)
require.NoError(t, err)
require.Equal(t, "gemini-1.5-pro", string(model), "OriginalModel must be the short name so AIGatewayRoute headers match")
require.Equal(t, "gemini-1.5-pro", model, "OriginalModel must be the short name so AIGatewayRoute headers match")
require.NotNil(t, req)
// The body struct keeps the original full path for the translator to act on.
require.Equal(t, "projects/p/locations/us-central1/publishers/google/models/gemini-1.5-pro", req.Model)
Expand All @@ -1561,7 +1561,7 @@ func TestGeminiCachedContentsEndpointSpec_ParseBody(t *testing.T) {
body := []byte(`{"model":"gemini-2.5-flash","ttl":"60s"}`)
model, _, _, _, err := spec.ParseBody(body, false)
require.NoError(t, err)
require.Equal(t, "gemini-2.5-flash", string(model))
require.Equal(t, "gemini-2.5-flash", model)
})

t.Run("invalid JSON returns malformed request", func(t *testing.T) {
Expand Down
5 changes: 3 additions & 2 deletions internal/extproc/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1106,10 +1106,11 @@ func TestServer_RegisterPrefix_FallbackToNextEntry(t *testing.T) {
return nil
}
after := path[idx+len(seg):]
if !strings.Contains(after, ":") {
model, _, ok := strings.Cut(after, ":")
if !ok {
return nil
}
return map[string]string{"x-aigw-path-model": after[:strings.Index(after, ":")]}
return map[string]string{"x-aigw-path-model": model}
})

cacheProc := &mockProcessor{}
Expand Down
2 changes: 1 addition & 1 deletion internal/translator/gemini_cachedcontents_gcpvertexai.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func (g *geminiCachedContentsToGCPVertexAITranslator) RequestBody(
if body != nil && body.Model != "" {
desired := body.Model
if g.modelNameOverride != "" {
desired = rewriteVertexModelName(desired, string(g.modelNameOverride))
desired = rewriteVertexModelName(desired, g.modelNameOverride)
}
desired = expandToVertexFullModelPath(desired, os.Getenv(envGCPProject), os.Getenv(envGCPLocation))
if desired != body.Model {
Expand Down
8 changes: 4 additions & 4 deletions internal/translator/gemini_gcpvertexai.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,17 @@ func NewGeminiToGCPVertexAITranslator(modelNameOverride internalapi.ModelNameOve
func (g *geminiToGCPVertexAITranslator) RequestBody(_ []byte, body *gcp.GenerateContentRequest, _ bool) (
newHeaders []internalapi.Header, newBody []byte, err error,
) {
g.requestModel = internalapi.RequestModel(body.Model)
g.requestModel = body.Model
if g.modelNameOverride != "" {
g.requestModel = g.modelNameOverride
}
g.stream = body.Stream

var path string
if g.stream {
path = buildGCPModelPathSuffix(gcpModelPublisherGoogle, string(g.requestModel), gcpMethodStreamGenerateContent, "alt=sse")
path = buildGCPModelPathSuffix(gcpModelPublisherGoogle, g.requestModel, gcpMethodStreamGenerateContent, "alt=sse")
} else {
path = buildGCPModelPathSuffix(gcpModelPublisherGoogle, string(g.requestModel), gcpMethodGenerateContent)
path = buildGCPModelPathSuffix(gcpModelPublisherGoogle, g.requestModel, gcpMethodGenerateContent)
}

// Re-marshal: json:"-" fields (Model, Stream) are not serialised.
Expand Down Expand Up @@ -108,7 +108,7 @@ func (g *geminiToGCPVertexAITranslator) ResponseBody(_ map[string]string, body i
}

if gcpResp.ModelVersion != "" {
responseModel = internalapi.ResponseModel(gcpResp.ModelVersion)
responseModel = gcpResp.ModelVersion
}

if u := gcpResp.UsageMetadata; u != nil {
Expand Down
5 changes: 3 additions & 2 deletions internal/translator/imageedit_openai_openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func (o *openAIToOpenAIImageEditsTranslator) RequestBody(original []byte, p *ope
if bErr != nil {
return nil, nil, fmt.Errorf("failed to extract multipart boundary for model override: %w", bErr)
}
newBody, err = rebuildMultipartWithModelOverride(original, boundary, string(o.modelNameOverride))
newBody, err = rebuildMultipartWithModelOverride(original, boundary, o.modelNameOverride)
if err != nil {
return nil, nil, fmt.Errorf("failed to rebuild multipart with model override: %w", err)
}
Expand Down Expand Up @@ -110,7 +110,8 @@ func extractMultipartBoundary(body []byte) (string, error) {
if eol < 3 || !bytes.HasPrefix(body, []byte("--")) {
return "", fmt.Errorf("invalid multipart body: missing boundary marker")
}
return string(body[2:eol]), nil
// The guard above ensures 2 <= eol, so the slice bounds are safe.
return string(body[2:eol]), nil //nolint:gosec // G602 false positive; eol >= 3 is enforced above.
}

// rebuildMultipartWithModelOverride rebuilds the multipart body with the given model value
Expand Down
30 changes: 17 additions & 13 deletions internal/translator/openai_speech_dashscope.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,16 +116,21 @@ type openAIToDashScopeSpeechTranslator struct {
// {"model":"qwen3-tts-flash","input":{"text":"hello","voice":"Cherry","language_type":"Auto"}}
//
// Voice, instructions, response_format, speed, and stream_format are not translated:
// - voice: passed through verbatim — the AIGatewayRoute owner is expected to supply a
// DashScope-side voice name (e.g. "Cherry"); OpenAI voice presets do not have a natural
// mapping and forcing one here would silently pick the wrong timbre.
// - instructions/speed: DashScope has no equivalent.
// - response_format: DashScope's non-streaming response is always WAV via URL; ignored here
// and the outbound content-type is set to audio/wav in ResponseHeaders.
// - stream_format: only non-streaming is implemented in this first cut; SSE support would
// require calling DashScope's streaming synthesis API (different endpoint) and is out of
// scope until we see demand.
func (o *openAIToDashScopeSpeechTranslator) RequestBody(original []byte, req *openai.SpeechRequest, forceBodyMutation bool) (
// - voice: passed through verbatim — the AIGatewayRoute owner is expected to supply a
// DashScope-side voice name (e.g. "Cherry"); OpenAI voice presets do not have a natural
// mapping and forcing one here would silently pick the wrong timbre.
// - instructions/speed: DashScope has no equivalent.
// - response_format: DashScope's non-streaming response is always WAV via URL; ignored here
// and the outbound content-type is set to audio/wav in ResponseHeaders.
// - stream_format: only non-streaming is implemented in this first cut; SSE support would
// require calling DashScope's streaming synthesis API (different endpoint) and is out of
// scope until we see demand.
//
// The `original` byte slice is intentionally ignored — the DashScope wire format differs
// enough from the OpenAI request that we always re-marshal from the parsed struct rather
// than patching the original body. Same reason forceBodyMutation is ignored: the response is
// always a body replacement, never a passthrough.
func (o *openAIToDashScopeSpeechTranslator) RequestBody(_ []byte, req *openai.SpeechRequest, _ bool) (
newHeaders []internalapi.Header, newBody []byte, err error,
) {
if req == nil {
Expand All @@ -134,7 +139,7 @@ func (o *openAIToDashScopeSpeechTranslator) RequestBody(original []byte, req *op

model := req.Model
if o.modelNameOverride != "" {
model = string(o.modelNameOverride)
model = o.modelNameOverride
}
o.requestModel = model

Expand All @@ -154,7 +159,6 @@ func (o *openAIToDashScopeSpeechTranslator) RequestBody(original []byte, req *op
{pathHeaderName, o.path},
{contentLengthHeaderName, strconv.Itoa(len(newBody))},
}
_ = forceBodyMutation // always a shape change → always mutate.
return
}

Expand Down Expand Up @@ -186,7 +190,7 @@ func (o *openAIToDashScopeSpeechTranslator) ResponseBody(_ map[string]string, bo
if envelope.Output.Audio.URL == "" {
return nil, nil, tokenUsage, "", fmt.Errorf("dashscope speech: response missing output.audio.url; body=%s", truncate(raw, 512))
}
if err := validateDashScopeAudioURL(envelope.Output.Audio.URL); err != nil {
if err = validateDashScopeAudioURL(envelope.Output.Audio.URL); err != nil {
return nil, nil, tokenUsage, "", err
}

Expand Down
4 changes: 2 additions & 2 deletions internal/translator/openai_speech_dashscope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ func TestSpeechOpenAIToDashScope_ResponseBody(t *testing.T) {
hm, body, _, respModel, err := tr.ResponseBody(nil, strings.NewReader(envelope), true, nil)
require.NoError(t, err)
require.Equal(t, audioPayload, body)
require.Equal(t, "qwen3-tts-flash", string(respModel))
require.Equal(t, "qwen3-tts-flash", respModel)
require.Equal(t, "https://dashscope-intl.aliyuncs.com/audio/abc.wav", fetchedURL)

// content-length must match downloaded audio byte length.
Expand Down Expand Up @@ -184,7 +184,7 @@ func TestSpeechOpenAIToDashScope_ResponseBody(t *testing.T) {
// DashScope actually returns plain-http URLs in the signed envelope, so http on an
// allowed host must be accepted (host allowlist is the real defence).
t.Run("accepts http on aliyuncs.com host", func(t *testing.T) {
dashScopeAudioFetcher = func(_ context.Context, url string) ([]byte, error) {
dashScopeAudioFetcher = func(_ context.Context, _ string) ([]byte, error) {
return []byte("audio"), nil
}
tr := NewSpeechOpenAIToDashScopeTranslator("")
Expand Down
Loading