Skip to content

Commit 28c6c6a

Browse files
Raderluojun96HaiHui886
authored
add modal price guard for multimodal apis in aigateway (#1254)
* add modal price guard for multimodal apis in aigateway * Refactor resolution parsing functions Removed redundant parseResolutionWxHFormat function and simplified logic in parseResolutionWxHPair. --------- Co-authored-by: Jun Luo <luojun96@live.cn> Co-authored-by: HaiHui <154984842+HaiHui886@users.noreply.github.com>
1 parent d29dc58 commit 28c6c6a

7 files changed

Lines changed: 124 additions & 6 deletions

File tree

aigateway/handler/chat_trace_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,38 @@ func TestPreflightTraceRecordsEarlyError(t *testing.T) {
118118
requireSpanAttrValue(t, spans[0].Attributes, "error.category", "gateway_error")
119119
}
120120

121+
func TestPreflightTraceRecordsModalPriceGuardError(t *testing.T) {
122+
exporter := tracetest.NewInMemoryExporter()
123+
provider := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter))
124+
oldProvider := otel.GetTracerProvider()
125+
otel.SetTracerProvider(provider)
126+
t.Cleanup(func() {
127+
otel.SetTracerProvider(oldProvider)
128+
_ = provider.Shutdown(context.Background())
129+
})
130+
131+
_, preflight := startPreflightTrace(context.Background(), preflightTraceStart{
132+
API: "/v1/images/generations",
133+
RequestID: "req-1",
134+
UserID: "user-1",
135+
})
136+
preflight.SetTargetModel("model1", &resolvedModelTarget{
137+
Model: &types.Model{BaseModel: types.BaseModel{ID: "internal-model"}},
138+
ModelName: "resolved-model",
139+
})
140+
preflight.RecordError(newInvalidRequestModelTargetError(
141+
"invalid_request_error",
142+
"resolution exceeds the maximum supported resolution",
143+
modelTargetErrorOptions{},
144+
), "modal_price_guard")
145+
146+
spans := exporter.GetSpans()
147+
require.Len(t, spans, 1)
148+
require.Equal(t, "Error", spans[0].Status.Code.String())
149+
requireSpanAttrValue(t, spans[0].Attributes, "error.type", "modal_price_guard")
150+
requireSpanAttrValue(t, spans[0].Attributes, "aigateway.request.model", "model1")
151+
}
152+
121153
func TestPreflightTraceRecordsResolvedTargetModel(t *testing.T) {
122154
exporter := tracetest.NewInMemoryExporter()
123155
provider := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter))

aigateway/handler/model_price_guard_ce.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,7 @@ import "opencsg.com/csghub-server/aigateway/types"
77
func modelSKUPriceStatus(model *types.Model) (requiresSKUPrice bool, hasConfiguredSKUPrice bool) {
88
return false, false
99
}
10+
11+
func checkModalRequestAllowed(model *types.Model, size string) *modelTargetError {
12+
return nil
13+
}

aigateway/handler/openai_image.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ func (h *OpenAIHandlerImpl) GenerateImage(c *gin.Context) {
7070
return
7171
}
7272
preflight.SetTargetModel(modelID, modelTarget)
73+
74+
if guardErr := checkModalRequestAllowed(modelTarget.Model, string(req.Size)); guardErr != nil {
75+
preflight.RecordError(guardErr, "modal_price_guard")
76+
handleModelTargetError(c, ctx, modelID, "modal price guard rejected request", guardErr)
77+
return
78+
}
7379
preflight.End()
7480

7581
traceCtx, generationRecorder := h.startModalGenerationTrace(ctx, modalTraceStartInput{

aigateway/handler/openai_image_edit.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ func (h *OpenAIHandlerImpl) EditImage(c *gin.Context) {
8484
return
8585
}
8686
preflight.SetTargetModel(modelID, modelTarget)
87+
88+
if guardErr := checkModalRequestAllowed(modelTarget.Model, c.PostForm("size")); guardErr != nil {
89+
preflight.RecordError(guardErr, "modal_price_guard")
90+
handleModelTargetError(c, ctx, modelID, "modal price guard rejected request", guardErr)
91+
return
92+
}
8793
preflight.End()
8894

8995
traceCtx, generationRecorder := h.startModalGenerationTrace(ctx, modalTraceStartInput{

aigateway/handler/openai_video.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ func (h *OpenAIHandlerImpl) CreateVideo(c *gin.Context) {
7373
return
7474
}
7575
preflight.SetTargetModel(input.modelID, modelTarget)
76+
77+
if guardErr := checkModalRequestAllowed(modelTarget.Model, input.adapterReq.Size); guardErr != nil {
78+
preflight.RecordError(guardErr, "modal_price_guard")
79+
handleModelTargetError(c, ctx, input.modelID, "modal price guard rejected request", guardErr)
80+
return
81+
}
7682
preflight.End()
7783

7884
traceCtx, generationRecorder := h.startModalGenerationTrace(ctx, modalTraceStartInput{

common/utils/common/resolution.go

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,25 @@ import (
77
"strings"
88
)
99

10+
// This file parses resolution strings in preset ("720p"), "WIDTHxHEIGHT",
11+
// or bare-integer format. ExtractEventResolutionMaxSide is lenient (used for
12+
// historical/billing data), while ParseResolutionMaxSide additionally
13+
// requires a strictly positive value (used for real-time request/SKU
14+
// validation), so gateway and accounting never interpret the same string
15+
// differently.
1016
var (
1117
resolutionPFormat = regexp.MustCompile(`^(\d+)\s*[pP]$`)
1218
resolutionWxHFormat = regexp.MustCompile(`^(\d+)\s*[xX×]\s*(\d+)$`)
1319
resolutionPureFormat = regexp.MustCompile(`^(\d+)$`)
1420
)
1521

22+
func longEdge(width, height int64) int64 {
23+
if width > height {
24+
return width
25+
}
26+
return height
27+
}
28+
1629
func parseResolutionPFormat(s string) (int64, bool) {
1730
matches := resolutionPFormat.FindStringSubmatch(s)
1831
if matches == nil {
@@ -25,20 +38,25 @@ func parseResolutionPFormat(s string) (int64, bool) {
2538
return v, true
2639
}
2740

28-
func parseResolutionWxHFormat(s string) (int64, bool) {
41+
func parseResolutionWxHPair(s string) (width, height int64, ok bool) {
2942
matches := resolutionWxHFormat.FindStringSubmatch(s)
3043
if matches == nil {
31-
return 0, false
44+
return 0, 0, false
3245
}
3346
w, err1 := strconv.ParseInt(matches[1], 10, 64)
3447
h, err2 := strconv.ParseInt(matches[2], 10, 64)
3548
if err1 != nil || err2 != nil {
36-
return 0, false
49+
return 0, 0, false
3750
}
38-
if w > h {
39-
return w, true
51+
return w, h, true
52+
}
53+
54+
func parseResolutionWxHFormat(s string) (int64, bool) {
55+
w, h, ok := parseResolutionWxHPair(s)
56+
if !ok {
57+
return 0, false
4058
}
41-
return h, true
59+
return longEdge(w, h), true
4260
}
4361

4462
func parseResolutionPureFormat(s string) (int64, bool) {
@@ -71,3 +89,13 @@ func ExtractEventResolutionMaxSide(eventResolution string) (int64, error) {
7189

7290
return 0, fmt.Errorf("unsupported resolution format: %s, must be 1080P or 720 or 1920x1080", s)
7391
}
92+
93+
// ParseResolutionMaxSide is like ExtractEventResolutionMaxSide but rejects
94+
// empty input and non-positive results.
95+
func ParseResolutionMaxSide(size string) (int64, bool) {
96+
v, err := ExtractEventResolutionMaxSide(size)
97+
if err != nil || v <= 0 {
98+
return 0, false
99+
}
100+
return v, true
101+
}

common/utils/common/resolution_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,39 @@ func TestExtractEventResolutionMaxSide(t *testing.T) {
116116
}
117117
}
118118
}
119+
120+
func TestParseResolutionMaxSide(t *testing.T) {
121+
tests := []struct {
122+
name string
123+
size string
124+
wantSide int64
125+
wantOK bool
126+
}{
127+
{"square", "1024x1024", 1024, true},
128+
{"landscape", "1280x720", 1280, true},
129+
{"portrait", "720x1280", 1280, true},
130+
{"whitespace wxh", " 1024 x 1536 ", 1536, true},
131+
{"uppercase wxh", "1024X1024", 1024, true},
132+
{"preset uppercase", "1080P", 1080, true},
133+
{"preset lowercase", "720p", 720, true},
134+
{"whitespace preset", " 1080p ", 1080, true},
135+
{"pure integer", "1024", 1024, true},
136+
{"whitespace integer", " 2048 ", 2048, true},
137+
{"empty", "", 0, false},
138+
{"degenerate wxh one side zero", "0x1024", 1024, true}, // matches ExtractEventResolutionMaxSide leniency for a single zero dimension
139+
{"wxh both sides zero", "0x0", 0, false},
140+
{"negative wxh", "-1024x1024", 0, false},
141+
{"zero preset", "0p", 0, false},
142+
{"zero integer", "0", 0, false},
143+
{"negative integer", "-1024", 0, false},
144+
{"non numeric", "abcxdef", 0, false},
145+
}
146+
147+
for _, tt := range tests {
148+
t.Run(tt.name, func(t *testing.T) {
149+
side, ok := ParseResolutionMaxSide(tt.size)
150+
require.Equal(t, tt.wantOK, ok)
151+
require.Equal(t, tt.wantSide, side)
152+
})
153+
}
154+
}

0 commit comments

Comments
 (0)