fix: charge image fan-out against the RPM limit
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
A request with n images makes n upstream calls. Count all of them in the rate limiter up front and reject n above 10 before any provider work.
This commit is contained in:
@@ -11,6 +11,10 @@ import (
|
||||
"git.govekar.net/LovroG05/localmodelroutex/internal/providers"
|
||||
)
|
||||
|
||||
// maxImagesPerRequest caps the OpenAI "n" parameter per client call. Each
|
||||
// image may be a separate upstream request, so this bounds the fan-out.
|
||||
const maxImagesPerRequest = 10
|
||||
|
||||
// imageGenerations serves POST /v1/images/generations in OpenAI schema. The
|
||||
// model must carry the "images" capability in the catalog, which the sync
|
||||
// sets from the provider's reported output modalities. The provider does any
|
||||
@@ -35,6 +39,10 @@ func (a *API) imageGenerations(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "missing 'prompt'")
|
||||
return
|
||||
}
|
||||
if req.N > maxImagesPerRequest {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("'n' must be at most %d", maxImagesPerRequest))
|
||||
return
|
||||
}
|
||||
|
||||
key := keyFrom(r.Context())
|
||||
address := req.Model
|
||||
@@ -52,7 +60,9 @@ func (a *API) imageGenerations(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("model %q does not generate images", address))
|
||||
return
|
||||
}
|
||||
if !a.Limiter.AllowRequest(entry.Address, ptrInt(entry.RPMLimit)) {
|
||||
// Each requested image is one upstream call, so charge all of them
|
||||
// against the RPM limit up front.
|
||||
if !a.Limiter.AllowRequests(entry.Address, ptrInt(entry.RPMLimit), max(req.N, 1)) {
|
||||
writeError(w, http.StatusTooManyRequests, "rate limit exceeded (RPM)")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -42,6 +42,26 @@ func (l *Limiter) AllowRequest(model string, limit int) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// AllowRequests is AllowRequest for a client call that fans out into n
|
||||
// upstream requests (image generation with n > 1). All n are charged at once
|
||||
// so a single call cannot exceed the RPM limit.
|
||||
func (l *Limiter) AllowRequests(model string, limit, n int) bool {
|
||||
if limit <= 0 {
|
||||
return true
|
||||
}
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
w := l.window(model)
|
||||
if w.reqs+n > limit {
|
||||
return false
|
||||
}
|
||||
w.reqs += n
|
||||
return true
|
||||
}
|
||||
|
||||
// AllowTokens returns false when the model's TPM limit is set and tokens already
|
||||
// used in the current window meet or exceed it. Call RecordTokens after a
|
||||
// successful response to update the running total.
|
||||
|
||||
32
backend/internal/ratelimit/ratelimit_test.go
Normal file
32
backend/internal/ratelimit/ratelimit_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package ratelimit
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAllowRequestsChargesFanOut(t *testing.T) {
|
||||
l := New()
|
||||
if !l.AllowRequests("m", 10, 4) {
|
||||
t.Fatal("first 4 should pass")
|
||||
}
|
||||
if !l.AllowRequests("m", 10, 4) {
|
||||
t.Fatal("8 of 10 should pass")
|
||||
}
|
||||
if l.AllowRequests("m", 10, 4) {
|
||||
t.Fatal("12 of 10 must be rejected")
|
||||
}
|
||||
if !l.AllowRequests("m", 10, 2) {
|
||||
t.Fatal("exactly 10 should pass")
|
||||
}
|
||||
if l.AllowRequest("m", 10) {
|
||||
t.Fatal("window is full")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowRequestsNoLimitAndMinOne(t *testing.T) {
|
||||
l := New()
|
||||
if !l.AllowRequests("m", 0, 100) {
|
||||
t.Fatal("zero limit always passes")
|
||||
}
|
||||
if !l.AllowRequests("n", 1, 0) || l.AllowRequests("n", 1, 0) {
|
||||
t.Fatal("n < 1 must count as one request")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user