|
6 | 6 | package openai |
7 | 7 |
|
8 | 8 | import ( |
| 9 | + "encoding/json" |
9 | 10 | "strconv" |
10 | 11 | "testing" |
11 | 12 |
|
12 | 13 | "github.com/stretchr/testify/require" |
13 | 14 | "go.opentelemetry.io/otel/attribute" |
| 15 | + "go.opentelemetry.io/otel/codes" |
14 | 16 | "go.opentelemetry.io/otel/sdk/trace" |
15 | | - "go.opentelemetry.io/otel/sdk/trace/tracetest" |
| 17 | + oteltrace "go.opentelemetry.io/otel/trace" |
16 | 18 |
|
| 19 | + "github.com/envoyproxy/ai-gateway/internal/testing/testotel" |
17 | 20 | tracing "github.com/envoyproxy/ai-gateway/internal/tracing/api" |
18 | 21 | openaisdk "github.com/openai/openai-go/v2" |
19 | 22 | openaiparam "github.com/openai/openai-go/v2/packages/param" |
20 | 23 | ) |
21 | 24 |
|
22 | | -func TestImageGenerationRecorder_RequestAttributes_SDK(t *testing.T) { |
23 | | - exporter := tracetest.NewInMemoryExporter() |
24 | | - tp := trace.NewTracerProvider(trace.WithSyncer(exporter)) |
25 | | - tr := tp.Tracer("test") |
26 | | - |
27 | | - recorder := NewImageGenerationRecorder(nil) // default config |
28 | | - |
29 | | - params := &openaisdk.ImageGenerateParams{ |
| 25 | +var ( |
| 26 | + // Test data constants following chat completion pattern |
| 27 | + basicImageReq = &openaisdk.ImageGenerateParams{ |
30 | 28 | Model: openaisdk.ImageModelGPTImage1, |
31 | 29 | Prompt: "a hummingbird", |
32 | 30 | Size: openaisdk.ImageGenerateParamsSize1024x1024, |
33 | 31 | Quality: openaisdk.ImageGenerateParamsQualityHigh, |
34 | 32 | ResponseFormat: openaisdk.ImageGenerateParamsResponseFormatB64JSON, |
35 | | - N: openaiparam.NewOpt[int64](2), |
| 33 | + N: openaiparam.NewOpt[int64](1), |
36 | 34 | } |
| 35 | + basicImageReqBody = mustJSON(basicImageReq) |
| 36 | + |
| 37 | + basicImageResp = &openaisdk.ImagesResponse{ |
| 38 | + Data: []openaisdk.Image{{URL: "https://example.com/img.png"}}, |
| 39 | + Size: openaisdk.ImagesResponseSize1024x1024, |
| 40 | + Usage: openaisdk.ImagesResponseUsage{ |
| 41 | + InputTokens: 8, |
| 42 | + OutputTokens: 1056, |
| 43 | + TotalTokens: 1064, |
| 44 | + }, |
| 45 | + } |
| 46 | + basicImageRespBody = mustJSON(basicImageResp) |
| 47 | +) |
37 | 48 |
|
38 | | - spanName, opts := recorder.StartParams(params, []byte("{}")) |
39 | | - require.Equal(t, "ImageGeneration", spanName) |
40 | | - |
41 | | - _, span := tr.Start(t.Context(), spanName, opts...) |
42 | | - recorder.RecordRequest(span, params, []byte(`{"prompt":"a hummingbird"}`)) |
43 | | - span.End() |
44 | | - |
45 | | - spans := exporter.GetSpans() |
46 | | - require.Len(t, spans, 1) |
47 | | - got := spans[0] |
48 | | - |
49 | | - // Verify a subset of attributes were recorded |
50 | | - attrs := attributesToMap(got.Attributes) |
51 | | - require.Equal(t, "openai", attrs["llm.system"]) // LLMSystemOpenAI |
52 | | - require.Equal(t, string(openaisdk.ImageModelGPTImage1), attrs["llm.model_name"]) // model |
53 | | - require.Equal(t, "{\"prompt\":\"a hummingbird\"}", attrs["input.value"]) // input body json |
54 | | - require.Equal(t, "image_generation", attrs["gen_ai.operation.name"]) // operation name |
55 | | - require.Equal(t, "1024x1024", attrs["gen_ai.image.size"]) // size |
56 | | - require.Equal(t, "high", attrs["gen_ai.image.quality"]) // quality |
57 | | - require.Equal(t, "b64_json", attrs["gen_ai.image.response_format"]) // response_format |
58 | | - require.Equal(t, "2", attrs["gen_ai.image.n"]) // n |
| 49 | +func TestImageGenerationRecorder_StartParams(t *testing.T) { |
| 50 | + tests := []struct { |
| 51 | + name string |
| 52 | + req *openaisdk.ImageGenerateParams |
| 53 | + reqBody []byte |
| 54 | + expectedSpanName string |
| 55 | + }{ |
| 56 | + { |
| 57 | + name: "basic request", |
| 58 | + req: basicImageReq, |
| 59 | + reqBody: basicImageReqBody, |
| 60 | + expectedSpanName: "ImageGeneration", |
| 61 | + }, |
| 62 | + } |
| 63 | + |
| 64 | + for _, tt := range tests { |
| 65 | + t.Run(tt.name, func(t *testing.T) { |
| 66 | + recorder := NewImageGenerationRecorder(nil) |
| 67 | + |
| 68 | + spanName, opts := recorder.StartParams(tt.req, tt.reqBody) |
| 69 | + actualSpan := testotel.RecordNewSpan(t, spanName, opts...) |
| 70 | + |
| 71 | + require.Equal(t, tt.expectedSpanName, actualSpan.Name) |
| 72 | + require.Equal(t, oteltrace.SpanKindInternal, actualSpan.SpanKind) |
| 73 | + }) |
| 74 | + } |
59 | 75 | } |
60 | 76 |
|
61 | | -func TestImageGenerationRecorder_ResponseAttributes_SDK(t *testing.T) { |
62 | | - exporter := tracetest.NewInMemoryExporter() |
63 | | - tp := trace.NewTracerProvider(trace.WithSyncer(exporter)) |
64 | | - tr := tp.Tracer("test") |
| 77 | +func TestImageGenerationRecorder_RecordRequest(t *testing.T) { |
| 78 | + tests := []struct { |
| 79 | + name string |
| 80 | + req *openaisdk.ImageGenerateParams |
| 81 | + reqBody []byte |
| 82 | + expectedAttrs []attribute.KeyValue |
| 83 | + }{ |
| 84 | + { |
| 85 | + name: "basic request", |
| 86 | + req: basicImageReq, |
| 87 | + reqBody: basicImageReqBody, |
| 88 | + expectedAttrs: []attribute.KeyValue{ |
| 89 | + attribute.String("gen_ai.operation.name", "image_generation"), |
| 90 | + attribute.String("gen_ai.image.size", "1024x1024"), |
| 91 | + attribute.String("gen_ai.image.quality", "high"), |
| 92 | + attribute.String("gen_ai.image.response_format", "b64_json"), |
| 93 | + attribute.String("gen_ai.image.n", "1"), |
| 94 | + }, |
| 95 | + }, |
| 96 | + } |
65 | 97 |
|
66 | | - recorder := NewImageGenerationRecorder(nil) |
| 98 | + for _, tt := range tests { |
| 99 | + t.Run(tt.name, func(t *testing.T) { |
| 100 | + recorder := NewImageGenerationRecorder(nil) |
| 101 | + |
| 102 | + actualSpan := testotel.RecordWithSpan(t, func(span oteltrace.Span) bool { |
| 103 | + recorder.RecordRequest(span, tt.req, tt.reqBody) |
| 104 | + return false |
| 105 | + }) |
| 106 | + |
| 107 | + // Check that key attributes are present |
| 108 | + attrs := attributesToMap(actualSpan.Attributes) |
| 109 | + require.Equal(t, "image_generation", attrs["gen_ai.operation.name"]) |
| 110 | + require.Equal(t, "1024x1024", attrs["gen_ai.image.size"]) |
| 111 | + require.Equal(t, "high", attrs["gen_ai.image.quality"]) |
| 112 | + require.Equal(t, "b64_json", attrs["gen_ai.image.response_format"]) |
| 113 | + require.Equal(t, "1", attrs["gen_ai.image.n"]) |
| 114 | + }) |
| 115 | + } |
| 116 | +} |
67 | 117 |
|
68 | | - _, span := tr.Start(t.Context(), "ImageGeneration") |
69 | | - resp := &openaisdk.ImagesResponse{ |
70 | | - Data: []openaisdk.Image{{URL: "https://example.com/img.png"}}, |
71 | | - Size: openaisdk.ImagesResponseSize1024x1024, |
| 118 | +func TestImageGenerationRecorder_RecordResponse(t *testing.T) { |
| 119 | + tests := []struct { |
| 120 | + name string |
| 121 | + respBody []byte |
| 122 | + expectedAttrs []attribute.KeyValue |
| 123 | + expectedStatus trace.Status |
| 124 | + }{ |
| 125 | + { |
| 126 | + name: "successful response", |
| 127 | + respBody: basicImageRespBody, |
| 128 | + expectedAttrs: []attribute.KeyValue{ |
| 129 | + attribute.String("gen_ai.image.count", "1"), |
| 130 | + attribute.String("gen_ai.image.urls", "https://example.com/img.png"), |
| 131 | + }, |
| 132 | + expectedStatus: trace.Status{Code: codes.Ok, Description: ""}, |
| 133 | + }, |
| 134 | + } |
| 135 | + |
| 136 | + for _, tt := range tests { |
| 137 | + t.Run(tt.name, func(t *testing.T) { |
| 138 | + recorder := NewImageGenerationRecorder(nil) |
| 139 | + |
| 140 | + resp := &openaisdk.ImagesResponse{} |
| 141 | + err := json.Unmarshal(tt.respBody, resp) |
| 142 | + require.NoError(t, err) |
| 143 | + |
| 144 | + actualSpan := testotel.RecordWithSpan(t, func(span oteltrace.Span) bool { |
| 145 | + recorder.RecordResponse(span, resp) |
| 146 | + return false |
| 147 | + }) |
| 148 | + |
| 149 | + // Check that key attributes are present |
| 150 | + attrs := attributesToMap(actualSpan.Attributes) |
| 151 | + require.Equal(t, "1", attrs["gen_ai.image.count"]) |
| 152 | + require.Equal(t, "https://example.com/img.png", attrs["gen_ai.image.urls"]) |
| 153 | + require.Equal(t, trace.Status{Code: codes.Ok, Description: ""}, actualSpan.Status) |
| 154 | + }) |
72 | 155 | } |
73 | | - recorder.RecordResponse(span, resp) |
74 | | - span.End() |
75 | | - |
76 | | - spans := exporter.GetSpans() |
77 | | - require.Len(t, spans, 1) |
78 | | - got := spans[0] |
79 | | - attrs := attributesToMap(got.Attributes) |
80 | | - // Count and urls should be present |
81 | | - require.Equal(t, "1", attrs["gen_ai.image.count"]) |
82 | | - require.Equal(t, "https://example.com/img.png", attrs["gen_ai.image.urls"]) |
| 156 | +} |
| 157 | + |
| 158 | +func TestImageGenerationRecorder_RecordResponseOnError(t *testing.T) { |
| 159 | + recorder := NewImageGenerationRecorder(nil) |
| 160 | + |
| 161 | + actualSpan := testotel.RecordWithSpan(t, func(span oteltrace.Span) bool { |
| 162 | + recorder.RecordResponseOnError(span, 400, []byte(`{"error":{"message":"Invalid request","type":"invalid_request_error"}}`)) |
| 163 | + return false |
| 164 | + }) |
| 165 | + |
| 166 | + require.Equal(t, trace.Status{ |
| 167 | + Code: codes.Error, |
| 168 | + Description: `Error code: 400 - {"error":{"message":"Invalid request","type":"invalid_request_error"}}`, |
| 169 | + }, actualSpan.Status) |
83 | 170 | } |
84 | 171 |
|
85 | 172 | // attributesToMap converts attribute KeyValue to a simple map for assertions. |
|
0 commit comments