Skip to content

Commit 969f3b0

Browse files
feat(translator): add OpenAI image generation translation layer
- Implement OpenAI-to-OpenAI image generation request/response translation - Add comprehensive error handling for image generation failures - Update translator interface to support image generation endpoints - Include test coverage for translation functionality Signed-off-by: Hrushikesh Patil <[email protected]>
1 parent ec5629e commit 969f3b0

File tree

4 files changed

+70
-29
lines changed

4 files changed

+70
-29
lines changed
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Copyright Envoy AI Gateway Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
// The full text of the Apache license is available in the LICENSE file at
4+
// the root of the repo.
5+
6+
package translator
7+
8+
// ImageGenerationError represents an error response from the OpenAI Images API.
9+
// This schema matches OpenAI's documented error wire format.
10+
type ImageGenerationError struct {
11+
Error struct {
12+
Type string `json:"type"`
13+
Message string `json:"message"`
14+
Code *string `json:"code,omitempty"`
15+
Param *string `json:"param,omitempty"`
16+
} `json:"error"`
17+
}

internal/extproc/translator/imagegeneration_openai_openai.go

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,13 @@ import (
1010
"fmt"
1111
"io"
1212
"path"
13-
"strconv"
1413

1514
corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
1615
extprocv3 "github.com/envoyproxy/go-control-plane/envoy/service/ext_proc/v3"
1716
"github.com/tidwall/sjson"
1817

19-
"github.com/envoyproxy/ai-gateway/internal/apischema/openai"
2018
tracing "github.com/envoyproxy/ai-gateway/internal/tracing/api"
19+
openaisdk "github.com/openai/openai-go/v2"
2120
)
2221

2322
// NewImageGenerationOpenAIToOpenAITranslator implements [Factory] for OpenAI to OpenAI image generation translation.
@@ -33,7 +32,7 @@ type openAIToOpenAIImageGenerationTranslator struct {
3332
}
3433

3534
// RequestBody implements [ImageGenerationTranslator.RequestBody].
36-
func (o *openAIToOpenAIImageGenerationTranslator) RequestBody(original []byte, req *openai.ImageGenerationRequest, forceBodyMutation bool) (
35+
func (o *openAIToOpenAIImageGenerationTranslator) RequestBody(original []byte, req *openaisdk.ImageGenerateParams, forceBodyMutation bool) (
3736
headerMutation *extprocv3.HeaderMutation, bodyMutation *extprocv3.BodyMutation, err error,
3837
) {
3938
var newBody []byte
@@ -63,10 +62,8 @@ func (o *openAIToOpenAIImageGenerationTranslator) RequestBody(original []byte, r
6362
bodyMutation = &extprocv3.BodyMutation{
6463
Mutation: &extprocv3.BodyMutation_Body{Body: newBody},
6564
}
66-
headerMutation.SetHeaders = append(headerMutation.SetHeaders, &corev3.HeaderValueOption{Header: &corev3.HeaderValue{
67-
Key: "content-length",
68-
RawValue: []byte(strconv.Itoa(len(newBody))),
69-
}})
65+
// Note: content-length header is set via dynamic metadata in the processor
66+
// to avoid conflicts with Envoy's REPLACE_AND_CONTINUE processing mode
7067
}
7168
return
7269
}
@@ -79,12 +76,12 @@ func (o *openAIToOpenAIImageGenerationTranslator) ResponseError(respHeaders map[
7976
) {
8077
statusCode := respHeaders[statusHeaderName]
8178
if v, ok := respHeaders[contentTypeHeaderName]; ok && v != jsonContentType {
82-
var openaiError openai.ImageGenerationError
79+
var openaiError ImageGenerationError
8380
buf, err := io.ReadAll(body)
8481
if err != nil {
8582
return nil, nil, fmt.Errorf("failed to read error body: %w", err)
8683
}
87-
openaiError = openai.ImageGenerationError{
84+
openaiError = ImageGenerationError{
8885
Error: struct {
8986
Type string `json:"type"`
9087
Message string `json:"message"`
@@ -102,6 +99,11 @@ func (o *openAIToOpenAIImageGenerationTranslator) ResponseError(respHeaders map[
10299
return nil, nil, fmt.Errorf("failed to marshal error body: %w", err)
103100
}
104101
headerMutation = &extprocv3.HeaderMutation{}
102+
// Ensure downstream sees a JSON error payload
103+
headerMutation.SetHeaders = append(headerMutation.SetHeaders, &corev3.HeaderValueOption{Header: &corev3.HeaderValue{
104+
Key: contentTypeHeaderName,
105+
RawValue: []byte(jsonContentType),
106+
}})
105107
setContentLength(headerMutation, mut.Body)
106108
return headerMutation, &extprocv3.BodyMutation{Mutation: mut}, nil
107109
}
@@ -117,22 +119,46 @@ func (o *openAIToOpenAIImageGenerationTranslator) ResponseHeaders(map[string]str
117119
func (o *openAIToOpenAIImageGenerationTranslator) ResponseBody(_ map[string]string, body io.Reader, _ bool) (
118120
headerMutation *extprocv3.HeaderMutation, bodyMutation *extprocv3.BodyMutation, tokenUsage LLMTokenUsage, imageMetadata ImageGenerationMetadata, err error,
119121
) {
120-
resp := &openai.ImageGenerationResponse{}
121-
if err := json.NewDecoder(body).Decode(&resp); err != nil {
122+
// Read the entire response body first to debug any issues
123+
bodyBytes, err := io.ReadAll(body)
124+
if err != nil {
125+
return nil, nil, tokenUsage, imageMetadata, fmt.Errorf("failed to read response body: %w", err)
126+
}
127+
128+
// Debug logging for response body content
129+
bodyPreview := string(bodyBytes)
130+
if len(bodyPreview) > 200 {
131+
bodyPreview = bodyPreview[:200] + "..."
132+
}
133+
fmt.Printf("DEBUG: Image generation translator received body - Length: %d, Preview: %s\n", len(bodyBytes), bodyPreview)
134+
135+
// Check if body looks like JSON
136+
if len(bodyBytes) > 0 && bodyBytes[0] != '{' && bodyBytes[0] != '[' {
137+
previewLen := 10
138+
if len(bodyBytes) < previewLen {
139+
previewLen = len(bodyBytes)
140+
}
141+
fmt.Printf("DEBUG: Body does not start with JSON character. First %d bytes: %v\n", previewLen, bodyBytes[:previewLen])
142+
}
143+
144+
// Decode using OpenAI SDK v2 schema to avoid drift.
145+
resp := &openaisdk.ImagesResponse{}
146+
if err := json.Unmarshal(bodyBytes, &resp); err != nil {
147+
fmt.Printf("DEBUG: JSON unmarshal failed - Error: %v, Body preview: %s\n", err, bodyPreview)
122148
return nil, nil, tokenUsage, imageMetadata, fmt.Errorf("failed to unmarshal body: %w", err)
123149
}
124150

125151
// Populate token usage if provided (GPT-Image-1); otherwise remain zero.
126-
if resp.Usage != nil {
152+
if resp.JSON.Usage.Valid() {
127153
tokenUsage.InputTokens = uint32(resp.Usage.InputTokens) //nolint:gosec
128154
tokenUsage.OutputTokens = uint32(resp.Usage.OutputTokens) //nolint:gosec
129155
tokenUsage.TotalTokens = uint32(resp.Usage.TotalTokens) //nolint:gosec
130156
}
131157

132-
// Extract image generation metadata for metrics
158+
// Extract image generation metadata for metrics (model may be absent in SDK response)
133159
imageMetadata.ImageCount = len(resp.Data)
134-
imageMetadata.Model = resp.Model
135-
imageMetadata.Size = resp.Size
160+
imageMetadata.Model = ""
161+
imageMetadata.Size = string(resp.Size)
136162

137163
return
138164
}

internal/extproc/translator/imagegeneration_openai_openai_test.go

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ import (
1212

1313
"github.com/stretchr/testify/require"
1414

15-
"github.com/envoyproxy/ai-gateway/internal/apischema/openai"
15+
openaisdk "github.com/openai/openai-go/v2"
1616
)
1717

1818
func TestOpenAIToOpenAIImageTranslator_RequestBody_ModelOverrideAndPath(t *testing.T) {
1919
tr := NewImageGenerationOpenAIToOpenAITranslator("v1", "gpt-image-1")
20-
req := &openai.ImageGenerationRequest{Model: openai.ModelDalle3, Prompt: "a cat"}
20+
req := &openaisdk.ImageGenerateParams{Model: openaisdk.ImageModelDallE3, Prompt: "a cat"}
2121
original, _ := json.Marshal(req)
2222

2323
hm, bm, err := tr.RequestBody(original, req, false)
@@ -30,14 +30,14 @@ func TestOpenAIToOpenAIImageTranslator_RequestBody_ModelOverrideAndPath(t *testi
3030

3131
require.NotNil(t, bm)
3232
mutated := bm.GetBody()
33-
var got openai.ImageGenerationRequest
33+
var got openaisdk.ImageGenerateParams
3434
require.NoError(t, json.Unmarshal(mutated, &got))
35-
require.Equal(t, "gpt-image-1", got.Model)
35+
require.Equal(t, "gpt-image-1", string(got.Model))
3636
}
3737

3838
func TestOpenAIToOpenAIImageTranslator_RequestBody_ForceMutation(t *testing.T) {
3939
tr := NewImageGenerationOpenAIToOpenAITranslator("v1", "")
40-
req := &openai.ImageGenerationRequest{Model: openai.ModelDalle2, Prompt: "a cat"}
40+
req := &openaisdk.ImageGenerateParams{Model: openaisdk.ImageModelDallE2, Prompt: "a cat"}
4141
original, _ := json.Marshal(req)
4242

4343
hm, bm, err := tr.RequestBody(original, req, true)
@@ -65,14 +65,14 @@ func TestOpenAIToOpenAIImageTranslator_ResponseError_NonJSON(t *testing.T) {
6565
require.NotNil(t, bm)
6666

6767
// Body should be OpenAI error JSON
68-
var got openai.ImageGenerationError
68+
var got ImageGenerationError
6969
require.NoError(t, json.Unmarshal(bm.GetBody(), &got))
7070
require.Equal(t, openAIBackendError, got.Error.Type)
7171
}
7272

7373
func TestOpenAIToOpenAIImageTranslator_ResponseBody_OK(t *testing.T) {
7474
tr := NewImageGenerationOpenAIToOpenAITranslator("v1", "")
75-
resp := &openai.ImageGenerationResponse{Model: openai.ModelDalle3}
75+
resp := &openaisdk.ImagesResponse{Size: openaisdk.ImagesResponseSize1024x1024}
7676
buf, _ := json.Marshal(resp)
7777
hm, bm, usage, metadata, err := tr.ResponseBody(map[string]string{}, bytes.NewReader(buf), true)
7878
require.NoError(t, err)
@@ -81,9 +81,6 @@ func TestOpenAIToOpenAIImageTranslator_ResponseBody_OK(t *testing.T) {
8181
require.Equal(t, uint32(0), usage.InputTokens)
8282
require.Equal(t, uint32(0), usage.TotalTokens)
8383
require.Equal(t, 0, metadata.ImageCount)
84-
require.Equal(t, openai.ModelDalle3, metadata.Model)
85-
require.Equal(t, "", metadata.Size)
84+
require.Equal(t, "", metadata.Model)
85+
require.Equal(t, string(openaisdk.ImagesResponseSize1024x1024), metadata.Size)
8686
}
87-
88-
// Ensure the helper compiles in this package (not used directly but keeps imports clean in case of future changes).
89-
// (no-op local stub removed; production helper exists in package translator)

internal/extproc/translator/translator.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/envoyproxy/ai-gateway/internal/apischema/openai"
1818
"github.com/envoyproxy/ai-gateway/internal/internalapi"
1919
tracing "github.com/envoyproxy/ai-gateway/internal/tracing/api"
20+
openaisdk "github.com/openai/openai-go/v2"
2021
)
2122

2223
const (
@@ -240,10 +241,10 @@ var SJSONOptions = &sjson.Options{
240241
type ImageGenerationTranslator interface {
241242
// RequestBody translates the request body.
242243
// - raw is the raw request body.
243-
// - body is the request body parsed into the [openai.ImageGenerationRequest].
244+
// - body is the request body parsed into the OpenAI SDK [openaisdk.ImageGenerateParams].
244245
// - forceBodyMutation is true if the translator should always mutate the body, even if no changes are made.
245246
// - This returns headerMutation and bodyMutation that can be nil to indicate no mutation.
246-
RequestBody(raw []byte, body *openai.ImageGenerationRequest, forceBodyMutation bool) (
247+
RequestBody(raw []byte, body *openaisdk.ImageGenerateParams, forceBodyMutation bool) (
247248
headerMutation *extprocv3.HeaderMutation,
248249
bodyMutation *extprocv3.BodyMutation,
249250
err error,

0 commit comments

Comments
 (0)