fix: charge image fan-out against the RPM limit
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:
2026-10-04 16:57:52 +02:00
parent 5aadb0f875
commit 4a3c38a1ca
3 changed files with 63 additions and 1 deletions

View File

@@ -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
}

View File

@@ -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.

View 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")
}
}