mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-20 05:56:56 +00:00
feat(config): advertise concurrency via RateLimit-Policy with autodetect
The gateway now sends the concurrency limit as a RateLimit-Policy field from draft-ietf-httpapi-ratelimit-headers. The value is "concurrency";q=<n>;qu="concurrent-requests". The invented X-Concurrency-Limit header is gone. The new concurrencyAutodetect option is off by default. When on, the gateway learns a limit for each configured provider. A 429, 503, 504 or upstream timeout sets the limit to half the in-flight requests, with a minimum of 1. Four consecutive successes add 1. At 64 the limit counts as unknown again. Responses served via a provider advertise the smaller of maxConcurrency and the learned value. An explicit maxConcurrency always wins. Routing, retry and failover do not change.
This commit is contained in:
@@ -383,7 +383,8 @@ config accepts them as YAML, which decodes to the same JSON shape.
|
|||||||
| `requireModelPrefix` | `bool` | `false` | When `true`, a model resolves only as `provider/model` or as a `modelAliases` id. A bare id returns 404 `unknown model` and `GET /v1/models` lists no bare ids. `false` keeps the sorted-first bare-id collision winner. |
|
| `requireModelPrefix` | `bool` | `false` | When `true`, a model resolves only as `provider/model` or as a `modelAliases` id. A bare id returns 404 `unknown model` and `GET /v1/models` lists no bare ids. `false` keeps the sorted-first bare-id collision winner. |
|
||||||
| `stripClientAnthropicBeta` | `bool` | `false` | When `true`, `POST /v1/messages` does not forward the client's `anthropic-beta` header to the provider, as native passthrough already does. `false` forwards it, so a client can enable beta features on the operator's own Anthropic account. |
|
| `stripClientAnthropicBeta` | `bool` | `false` | When `true`, `POST /v1/messages` does not forward the client's `anthropic-beta` header to the provider, as native passthrough already does. `false` forwards it, so a client can enable beta features on the operator's own Anthropic account. |
|
||||||
| `modelMeta` | `map[string]*ModelMetaConfig` | `{}` | Per-model context-window and cost overrides, keyed by exact `provider/model` or a bare model id — see [Model metadata](#model-metadata). Omitted or empty means no overrides; every model's metadata still resolves through discovery and the built-in table. |
|
| `modelMeta` | `map[string]*ModelMetaConfig` | `{}` | Per-model context-window and cost overrides, keyed by exact `provider/model` or a bare model id — see [Model metadata](#model-metadata). Omitted or empty means no overrides; every model's metadata still resolves through discovery and the built-in table. |
|
||||||
| `maxConcurrency` | `int` | `0` | When above `0`, every response carries `X-Concurrency-Limit: <n>`, so clients that honour it (omps) keep at most `n` requests in flight. Set it to what your upstreams serve at once. The gateway only advertises the value; it does not queue or reject on it. `0` sends no header. Negative values are rejected at startup. |
|
| `maxConcurrency` | `int` | `0` | When above `0`, every response carries `RateLimit-Policy: "concurrency";q=<n>;qu="concurrent-requests"` (IETF draft `draft-ietf-httpapi-ratelimit-headers`, an Internet-Draft, not a final standard), so clients that read it can keep at most `n` requests in flight. Set it to what your upstreams serve at once. The gateway only advertises the value; it does not queue or reject on it. It always wins over a learned value. `0` sends no header. Negative values are rejected at startup. |
|
||||||
|
| `concurrencyAutodetect` | `bool` | `false` | When `true`, the gateway learns per provider how many requests the upstream serves at once. A 429, 503, 504 or upstream timeout sets the learned value to half the in-flight requests (minimum 1) and never raises it. Every 4 consecutive successes add 1. At 64 the value counts as unknown again. Responses served via that provider advertise the smaller of `maxConcurrency` and the learned value in `RateLimit-Policy`; with neither known, no header is sent. Learned values live in memory and reset on restart. |
|
||||||
| `passthroughUnknown` | `bool` | `false` | `false`: a request matching none of the plugin's routes gets a 404 JSON envelope. `true`: it falls through to the router's own backing service. **Does not cover the three media routes** — see the upgrade note below. |
|
| `passthroughUnknown` | `bool` | `false` | `false`: a request matching none of the plugin's routes gets a 404 JSON envelope. `true`: it falls through to the router's own backing service. **Does not cover the three media routes** — see the upgrade note below. |
|
||||||
| `maxRequestBytes` | `int` | `0` (4 MiB) | Raises the unified chat/embeddings request-body cap. `0` or negative keeps 4 MiB; a positive value is clamped to 64 MiB. Pair with `maxInFlightBodyRequests` and the pod memory limit: an adversarial body decodes to ~12x live heap. |
|
| `maxRequestBytes` | `int` | `0` (4 MiB) | Raises the unified chat/embeddings request-body cap. `0` or negative keeps 4 MiB; a positive value is clamped to 64 MiB. Pair with `maxInFlightBodyRequests` and the pod memory limit: an adversarial body decodes to ~12x live heap. |
|
||||||
| `maxInFlightBodyRequests` | `int` | `0` (self-tuned) | Caps how many unified/media requests may concurrently be inside their own request-body read+decode step — see [Body-admission limit](#body-admission-limit). `0` self-tunes from the host's own `GOMAXPROCS`; a positive value pins an exact cap instead, clamped to a maximum of 10,000. |
|
| `maxInFlightBodyRequests` | `int` | `0` (self-tuned) | Caps how many unified/media requests may concurrently be inside their own request-body read+decode step — see [Body-admission limit](#body-admission-limit). `0` self-tunes from the host's own `GOMAXPROCS`; a positive value pins an exact cap instead, clamped to a maximum of 10,000. |
|
||||||
|
|||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
package traefikllmgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rateLimitPolicyHeader carries the concurrency limit as an RFC 8941
|
||||||
|
// structured field, per draft-ietf-httpapi-ratelimit-headers.
|
||||||
|
const rateLimitPolicyHeader = "RateLimit-Policy"
|
||||||
|
|
||||||
|
const (
|
||||||
|
concurrencyPolicyName = "concurrency"
|
||||||
|
concurrencyUnit = "concurrent-requests"
|
||||||
|
// successesPerRegrow is how many consecutive upstream successes raise
|
||||||
|
// the learned limit by one.
|
||||||
|
successesPerRegrow = 4
|
||||||
|
// learnedCeiling is the learned value at which the limit counts as
|
||||||
|
// unknown again.
|
||||||
|
learnedCeiling = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
// concurrencyPolicyValue renders n as a RateLimit-Policy list member.
|
||||||
|
func concurrencyPolicyValue(n int) string {
|
||||||
|
return `"` + concurrencyPolicyName + `";q=` + strconv.Itoa(n) + `;qu="` + concurrencyUnit + `"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// providerConcurrency is one provider's learned state.
|
||||||
|
type providerConcurrency struct {
|
||||||
|
inFlight int
|
||||||
|
learned int // 0 means unknown
|
||||||
|
successes int
|
||||||
|
}
|
||||||
|
|
||||||
|
// concurrencyLearner learns, per configured provider, how many requests the
|
||||||
|
// upstream serves at once. Keys are configured provider names only, so
|
||||||
|
// memory stays bounded.
|
||||||
|
type concurrencyLearner struct {
|
||||||
|
byProvider map[string]*providerConcurrency
|
||||||
|
mu sync.Mutex
|
||||||
|
explicit int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newConcurrencyLearner(explicit int) *concurrencyLearner {
|
||||||
|
return &concurrencyLearner{byProvider: map[string]*providerConcurrency{}, explicit: explicit}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *concurrencyLearner) state(provider string) *providerConcurrency {
|
||||||
|
s := c.byProvider[provider]
|
||||||
|
if s == nil {
|
||||||
|
s = &providerConcurrency{}
|
||||||
|
c.byProvider[provider] = s
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// begin counts one in-flight upstream request; the returned func ends it.
|
||||||
|
func (c *concurrencyLearner) begin(provider string) func() {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.state(provider).inFlight++
|
||||||
|
c.mu.Unlock()
|
||||||
|
var once sync.Once
|
||||||
|
return func() {
|
||||||
|
once.Do(func() {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.state(provider).inFlight--
|
||||||
|
c.mu.Unlock()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// observe feeds one upstream attempt outcome into the provider's state.
|
||||||
|
func (c *concurrencyLearner) observe(provider string, resp *http.Response, err error) {
|
||||||
|
overload := false
|
||||||
|
success := false
|
||||||
|
switch {
|
||||||
|
case resp != nil:
|
||||||
|
code := resp.StatusCode
|
||||||
|
overload = code == http.StatusTooManyRequests || code == http.StatusServiceUnavailable || code == http.StatusGatewayTimeout
|
||||||
|
success = code < http.StatusBadRequest
|
||||||
|
case err != nil:
|
||||||
|
overload = isDeadlineExceeded(err)
|
||||||
|
}
|
||||||
|
if !overload && !success {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
s := c.state(provider)
|
||||||
|
if overload {
|
||||||
|
s.successes = 0
|
||||||
|
next := s.inFlight / 2
|
||||||
|
if next < 1 {
|
||||||
|
next = 1
|
||||||
|
}
|
||||||
|
if s.learned == 0 || next < s.learned {
|
||||||
|
s.learned = next
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.learned == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.successes++
|
||||||
|
if s.successes < successesPerRegrow {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.successes = 0
|
||||||
|
s.learned++
|
||||||
|
if s.learned >= learnedCeiling {
|
||||||
|
s.learned = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// limit returns the effective advertised limit for provider: the smaller of
|
||||||
|
// the explicit and learned values, or 0 when neither is known.
|
||||||
|
func (c *concurrencyLearner) limit(provider string) int {
|
||||||
|
c.mu.Lock()
|
||||||
|
learned := 0
|
||||||
|
if s := c.byProvider[provider]; s != nil {
|
||||||
|
learned = s.learned
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
switch {
|
||||||
|
case c.explicit > 0 && (learned == 0 || c.explicit < learned):
|
||||||
|
return c.explicit
|
||||||
|
default:
|
||||||
|
return learned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setConcurrencyHeader sets or clears the RateLimit-Policy header on h.
|
||||||
|
func setConcurrencyHeader(h http.Header, n int) {
|
||||||
|
if n > 0 {
|
||||||
|
h.Set(rateLimitPolicyHeader, concurrencyPolicyValue(n))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.Del(rateLimitPolicyHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackUpstream wires per-provider accounting into ctx: the limiter's
|
||||||
|
// attempt record and, when concurrencyAutodetect is on, in-flight tracking,
|
||||||
|
// learning and the RateLimit-Policy header on w. The returned func must run
|
||||||
|
// when the upstream exchange ends.
|
||||||
|
func (g *Gateway) trackUpstream(ctx context.Context, w http.ResponseWriter, provider, model string) (context.Context, func()) {
|
||||||
|
c := g.concurrency
|
||||||
|
if c == nil {
|
||||||
|
return withAttemptRecorder(ctx, func(resp *http.Response, attemptErr error) {
|
||||||
|
g.limiter.recordProviderAttempt(provider, model, resp, attemptErr)
|
||||||
|
}), func() {}
|
||||||
|
}
|
||||||
|
end := c.begin(provider)
|
||||||
|
setConcurrencyHeader(w.Header(), c.limit(provider))
|
||||||
|
return withAttemptRecorder(ctx, func(resp *http.Response, attemptErr error) {
|
||||||
|
g.limiter.recordProviderAttempt(provider, model, resp, attemptErr)
|
||||||
|
c.observe(provider, resp, attemptErr)
|
||||||
|
// The watchdog also calls this from a timer goroutine with a nil
|
||||||
|
// resp; only a real response means the request goroutine still owns w.
|
||||||
|
if resp != nil {
|
||||||
|
setConcurrencyHeader(w.Header(), c.limit(provider))
|
||||||
|
}
|
||||||
|
}), end
|
||||||
|
}
|
||||||
+164
-3
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ func TestMaxConcurrency_AdvertisedOnResponses(t *testing.T) {
|
|||||||
header string
|
header string
|
||||||
max int
|
max int
|
||||||
}{
|
}{
|
||||||
{"configured limit is advertised", "3", 3},
|
{"configured limit is advertised", `"concurrency";q=3;qu="concurrent-requests"`, 3},
|
||||||
{"zero sends no header", "", 0},
|
{"zero sends no header", "", 0},
|
||||||
}
|
}
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
@@ -40,8 +41,11 @@ func TestMaxConcurrency_AdvertisedOnResponses(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer sk-user")
|
req.Header.Set("Authorization", "Bearer sk-user")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
h.ServeHTTP(rec, req)
|
h.ServeHTTP(rec, req)
|
||||||
if got := rec.Header().Get("X-Concurrency-Limit"); got != tc.header {
|
if got := rec.Header().Get("RateLimit-Policy"); got != tc.header {
|
||||||
t.Fatalf("X-Concurrency-Limit = %q, want %q", got, tc.header)
|
t.Fatalf("RateLimit-Policy = %q, want %q", got, tc.header)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("X-Concurrency-Limit"); got != "" {
|
||||||
|
t.Fatalf("legacy X-Concurrency-Limit = %q, want none", got)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -53,3 +57,160 @@ func TestMaxConcurrency_NegativeRejected(t *testing.T) {
|
|||||||
t.Fatal("New accepted a negative maxConcurrency")
|
t.Fatal("New accepted a negative maxConcurrency")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConcurrencyLearner_Learning(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
resp := func(code int) *http.Response { return &http.Response{StatusCode: code} }
|
||||||
|
tests := []struct {
|
||||||
|
err error
|
||||||
|
name string
|
||||||
|
events []*http.Response
|
||||||
|
explicit int
|
||||||
|
inFlight int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{nil, "503 halves in-flight", []*http.Response{resp(503)}, 0, 8, 4},
|
||||||
|
{nil, "429 halves in-flight", []*http.Response{resp(429)}, 0, 7, 3},
|
||||||
|
{nil, "504 halves in-flight", []*http.Response{resp(504)}, 0, 6, 3},
|
||||||
|
{context.DeadlineExceeded, "timeout halves in-flight", []*http.Response{nil}, 0, 10, 5},
|
||||||
|
{nil, "floor of one", []*http.Response{resp(503)}, 0, 1, 1},
|
||||||
|
{nil, "never raised on overload", []*http.Response{resp(503), resp(503)}, 0, 8, 4},
|
||||||
|
{nil, "success without learning stays unknown", []*http.Response{resp(200), resp(200), resp(200), resp(200)}, 0, 8, 0},
|
||||||
|
{nil, "four successes add one", []*http.Response{resp(503), resp(200), resp(200), resp(200), resp(200)}, 0, 8, 5},
|
||||||
|
{nil, "three successes add nothing", []*http.Response{resp(503), resp(200), resp(200), resp(200)}, 0, 8, 4},
|
||||||
|
{nil, "overload resets the streak", []*http.Response{resp(503), resp(200), resp(200), resp(200), resp(503), resp(200)}, 0, 8, 4},
|
||||||
|
{nil, "client error is neutral", []*http.Response{resp(503), resp(404), resp(404), resp(404), resp(404)}, 0, 8, 4},
|
||||||
|
{nil, "explicit caps learned", []*http.Response{resp(503)}, 2, 8, 2},
|
||||||
|
{nil, "learned below explicit wins", []*http.Response{resp(503)}, 6, 4, 2},
|
||||||
|
{nil, "explicit alone", []*http.Response{resp(200)}, 3, 4, 3},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
c := newConcurrencyLearner(tc.explicit)
|
||||||
|
for i := 0; i < tc.inFlight; i++ {
|
||||||
|
c.begin("p")
|
||||||
|
}
|
||||||
|
for _, r := range tc.events {
|
||||||
|
c.observe("p", r, tc.err)
|
||||||
|
}
|
||||||
|
if got := c.limit("p"); got != tc.want {
|
||||||
|
t.Fatalf("limit = %d, want %d", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrencyLearner_ClearsAtCeiling(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
c := newConcurrencyLearner(0)
|
||||||
|
for i := 0; i < 2*(learnedCeiling-1); i++ {
|
||||||
|
c.begin("p")
|
||||||
|
}
|
||||||
|
c.observe("p", &http.Response{StatusCode: 503}, nil)
|
||||||
|
if got := c.limit("p"); got != learnedCeiling-1 {
|
||||||
|
t.Fatalf("limit = %d, want %d", got, learnedCeiling-1)
|
||||||
|
}
|
||||||
|
for i := 0; i < successesPerRegrow; i++ {
|
||||||
|
c.observe("p", &http.Response{StatusCode: 200}, nil)
|
||||||
|
}
|
||||||
|
if got := c.limit("p"); got != 0 {
|
||||||
|
t.Fatalf("limit = %d after reaching ceiling, want 0 (unknown)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrencyLearner_EndReleasesInFlight(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
c := newConcurrencyLearner(0)
|
||||||
|
end := c.begin("p")
|
||||||
|
c.begin("p")
|
||||||
|
end()
|
||||||
|
end()
|
||||||
|
c.observe("p", &http.Response{StatusCode: 503}, nil)
|
||||||
|
if got := c.limit("p"); got != 1 {
|
||||||
|
t.Fatalf("limit = %d, want 1 (one request still in flight, end is idempotent)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAutodetectGateway(t *testing.T, upstream string, autodetect bool, max int) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
cfg := CreateConfig()
|
||||||
|
cfg.ConcurrencyAutodetect = autodetect
|
||||||
|
cfg.MaxConcurrency = max
|
||||||
|
cfg.Providers = map[string]*ProviderConfig{
|
||||||
|
"openai": {Type: "openai", BaseURL: upstream, APIKey: "sk-up", Models: []string{"gpt-test"}},
|
||||||
|
}
|
||||||
|
cfg.Groups = map[string]*GroupConfig{"default": {}}
|
||||||
|
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{}}}}
|
||||||
|
h, err := New(context.Background(), http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), cfg, "llmgw")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New: %v", err)
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatPolicyHeader(t *testing.T, h http.Handler) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
body := map[string]any{"model": "gpt-test", "messages": []any{map[string]any{"role": "user", "content": "hi"}}}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, newUnifiedRequest(t, http.MethodPost, "/v1/chat/completions", "sk-alice", body))
|
||||||
|
return rec.Code, rec.Header().Get("RateLimit-Policy")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrencyAutodetect_EndToEnd(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var status atomic.Int64
|
||||||
|
status.Store(http.StatusOK)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(int(status.Load()))
|
||||||
|
_, _ = w.Write([]byte(`{"id":"x","object":"chat.completion","choices":[],"usage":{"prompt_tokens":1,"completion_tokens":1}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
t.Run("overload then regrow", func(t *testing.T) {
|
||||||
|
h := newAutodetectGateway(t, srv.URL, true, 0)
|
||||||
|
status.Store(http.StatusOK)
|
||||||
|
if _, got := chatPolicyHeader(t, h); got != "" {
|
||||||
|
t.Fatalf("header before any overload = %q, want none", got)
|
||||||
|
}
|
||||||
|
for _, code := range []int64{http.StatusServiceUnavailable, http.StatusTooManyRequests, http.StatusGatewayTimeout} {
|
||||||
|
status.Store(code)
|
||||||
|
gotCode, got := chatPolicyHeader(t, h)
|
||||||
|
if gotCode != int(code) {
|
||||||
|
t.Fatalf("status = %d, want %d passed through", gotCode, code)
|
||||||
|
}
|
||||||
|
if want := `"concurrency";q=1;qu="concurrent-requests"`; got != want {
|
||||||
|
t.Fatalf("header after %d = %q, want %q", code, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status.Store(http.StatusOK)
|
||||||
|
var got string
|
||||||
|
for i := 0; i < successesPerRegrow; i++ {
|
||||||
|
_, got = chatPolicyHeader(t, h)
|
||||||
|
}
|
||||||
|
if want := `"concurrency";q=2;qu="concurrent-requests"`; got != want {
|
||||||
|
t.Fatalf("header after regrow = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("explicit caps learned", func(t *testing.T) {
|
||||||
|
h := newAutodetectGateway(t, srv.URL, true, 3)
|
||||||
|
status.Store(http.StatusOK)
|
||||||
|
if _, got := chatPolicyHeader(t, h); got != `"concurrency";q=3;qu="concurrent-requests"` {
|
||||||
|
t.Fatalf("header = %q, want explicit 3", got)
|
||||||
|
}
|
||||||
|
status.Store(http.StatusServiceUnavailable)
|
||||||
|
if _, got := chatPolicyHeader(t, h); got != `"concurrency";q=1;qu="concurrent-requests"` {
|
||||||
|
t.Fatalf("header after overload = %q, want learned 1", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("autodetect off never learns", func(t *testing.T) {
|
||||||
|
h := newAutodetectGateway(t, srv.URL, false, 0)
|
||||||
|
status.Store(http.StatusServiceUnavailable)
|
||||||
|
if _, got := chatPolicyHeader(t, h); got != "" {
|
||||||
|
t.Fatalf("header = %q, want none with autodetect off", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+14
-11
@@ -12,7 +12,6 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -184,12 +183,15 @@ type Config struct {
|
|||||||
// resolved (404 unknown model) and is not listed by GET /v1/models. It
|
// resolved (404 unknown model) and is not listed by GET /v1/models. It
|
||||||
// removes the sorted-first bare-id collision winner. Defaults to false.
|
// removes the sorted-first bare-id collision winner. Defaults to false.
|
||||||
RequireModelPrefix bool `json:"requireModelPrefix,omitempty"`
|
RequireModelPrefix bool `json:"requireModelPrefix,omitempty"`
|
||||||
// MaxConcurrency, when > 0, is advertised on every response as
|
// ConcurrencyAutodetect, when true, learns each provider's concurrency
|
||||||
// X-Concurrency-Limit so clients that honour it (omps) cap their own
|
// from overload answers (429/503/504/timeout) and advertises
|
||||||
// in-flight requests to this gateway. It documents what the upstreams
|
// min(MaxConcurrency, learned). Defaults to false.
|
||||||
// can serve at once; the gateway does not queue or reject on it. 0
|
ConcurrencyAutodetect bool `json:"concurrencyAutodetect,omitempty"`
|
||||||
// (the default) sends no header. Negative values are rejected at
|
// MaxConcurrency, when > 0, is advertised on every response as a
|
||||||
// construction.
|
// RateLimit-Policy concurrent-requests quota
|
||||||
|
// (draft-ietf-httpapi-ratelimit-headers). The gateway does not queue or
|
||||||
|
// reject on it. It wins over any learned value. 0 (the default) sends
|
||||||
|
// nothing. Negative values are rejected at construction.
|
||||||
MaxConcurrency int `json:"maxConcurrency,omitempty"`
|
MaxConcurrency int `json:"maxConcurrency,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -789,6 +791,7 @@ type Gateway struct {
|
|||||||
teeBudget *teeBudget
|
teeBudget *teeBudget
|
||||||
fanoutGate chan struct{}
|
fanoutGate chan struct{}
|
||||||
failoverHealth *requestHealthTracker
|
failoverHealth *requestHealthTracker
|
||||||
|
concurrency *concurrencyLearner
|
||||||
targetHealth *targetHealthTracker
|
targetHealth *targetHealthTracker
|
||||||
limiter *limiter
|
limiter *limiter
|
||||||
registry *modelRegistry
|
registry *modelRegistry
|
||||||
@@ -971,6 +974,9 @@ func newGateway(ctx context.Context, next http.Handler, config *Config, name str
|
|||||||
}
|
}
|
||||||
g.failover = failoverCfg
|
g.failover = failoverCfg
|
||||||
g.failoverHealth = newRequestHealthTracker()
|
g.failoverHealth = newRequestHealthTracker()
|
||||||
|
if config.ConcurrencyAutodetect {
|
||||||
|
g.concurrency = newConcurrencyLearner(config.MaxConcurrency)
|
||||||
|
}
|
||||||
|
|
||||||
// Security audit run-1: nothing validates the PRODUCT of the two
|
// Security audit run-1: nothing validates the PRODUCT of the two
|
||||||
// retry axes, which compose multiplicatively at runtime. Surface it
|
// retry axes, which compose multiplicatively at runtime. Surface it
|
||||||
@@ -1358,14 +1364,11 @@ func attachUsersFile(auth *authStore, path string, log gatewayLogger) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ServeHTTP is the internal router. It grows in later tasks.
|
// ServeHTTP is the internal router. It grows in later tasks.
|
||||||
// concurrencyLimitHeader advertises Config.MaxConcurrency to clients.
|
|
||||||
const concurrencyLimitHeader = "X-Concurrency-Limit"
|
|
||||||
|
|
||||||
func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
sw := &statusTrackingWriter{ResponseWriter: w}
|
sw := &statusTrackingWriter{ResponseWriter: w}
|
||||||
defer recoverPanic(sw, g)
|
defer recoverPanic(sw, g)
|
||||||
if g.cfg.MaxConcurrency > 0 {
|
if g.cfg.MaxConcurrency > 0 {
|
||||||
sw.Header().Set(concurrencyLimitHeader, strconv.Itoa(g.cfg.MaxConcurrency))
|
sw.Header().Set(rateLimitPolicyHeader, concurrencyPolicyValue(g.cfg.MaxConcurrency))
|
||||||
}
|
}
|
||||||
// pruneRejectionsAfterReload only runs when maybeReload just performed
|
// pruneRejectionsAfterReload only runs when maybeReload just performed
|
||||||
// a REAL reload (users_file.go's own doc comment explains why it must
|
// a REAL reload (users_file.go's own doc comment explains why it must
|
||||||
|
|||||||
+6
-9
@@ -124,9 +124,8 @@ func (g *Gateway) handleImagesGenerations(w http.ResponseWriter, r *http.Request
|
|||||||
|
|
||||||
// Feature A (v0.22): see routes_unified.go's runUnified for the
|
// Feature A (v0.22): see routes_unified.go's runUnified for the
|
||||||
// identical pattern this mirrors.
|
// identical pattern this mirrors.
|
||||||
ctx := withAttemptRecorder(r.Context(), func(resp *http.Response, attemptErr error) {
|
ctx, endUpstream := g.trackUpstream(r.Context(), sw, adapter.name(), upstreamModel)
|
||||||
g.limiter.recordProviderAttempt(adapter.name(), upstreamModel, resp, attemptErr)
|
defer endUpstream()
|
||||||
})
|
|
||||||
// feat: instrument upstream latency — gated on metricsEnabled OR
|
// feat: instrument upstream latency — gated on metricsEnabled OR
|
||||||
// admin.stats.latency, same reasoning as runUnified's identical wiring
|
// admin.stats.latency, same reasoning as runUnified's identical wiring
|
||||||
// (routes_unified.go).
|
// (routes_unified.go).
|
||||||
@@ -200,9 +199,8 @@ func (g *Gateway) handleAudioSpeech(w http.ResponseWriter, r *http.Request, u *u
|
|||||||
|
|
||||||
// Feature A (v0.22): see routes_unified.go's runUnified for the
|
// Feature A (v0.22): see routes_unified.go's runUnified for the
|
||||||
// identical pattern this mirrors.
|
// identical pattern this mirrors.
|
||||||
ctx := withAttemptRecorder(r.Context(), func(resp *http.Response, attemptErr error) {
|
ctx, endUpstream := g.trackUpstream(r.Context(), sw, adapter.name(), upstreamModel)
|
||||||
g.limiter.recordProviderAttempt(adapter.name(), upstreamModel, resp, attemptErr)
|
defer endUpstream()
|
||||||
})
|
|
||||||
// feat: instrument upstream latency — gated on metricsEnabled OR
|
// feat: instrument upstream latency — gated on metricsEnabled OR
|
||||||
// admin.stats.latency, same reasoning as runUnified's identical wiring
|
// admin.stats.latency, same reasoning as runUnified's identical wiring
|
||||||
// (routes_unified.go).
|
// (routes_unified.go).
|
||||||
@@ -315,9 +313,8 @@ func (g *Gateway) handleAudioTranscriptions(w http.ResponseWriter, r *http.Reque
|
|||||||
|
|
||||||
// Feature A (v0.22): see routes_unified.go's runUnified for the
|
// Feature A (v0.22): see routes_unified.go's runUnified for the
|
||||||
// identical pattern this mirrors.
|
// identical pattern this mirrors.
|
||||||
ctx := withAttemptRecorder(r.Context(), func(resp *http.Response, attemptErr error) {
|
ctx, endUpstream := g.trackUpstream(r.Context(), sw, adapter.name(), upstreamModel)
|
||||||
g.limiter.recordProviderAttempt(adapter.name(), upstreamModel, resp, attemptErr)
|
defer endUpstream()
|
||||||
})
|
|
||||||
// feat: instrument upstream latency — gated on metricsEnabled OR
|
// feat: instrument upstream latency — gated on metricsEnabled OR
|
||||||
// admin.stats.latency, same reasoning as runUnified's identical wiring
|
// admin.stats.latency, same reasoning as runUnified's identical wiring
|
||||||
// (routes_unified.go).
|
// (routes_unified.go).
|
||||||
|
|||||||
@@ -1297,9 +1297,9 @@ func (g *Gateway) handlePassthrough(w http.ResponseWriter, r *http.Request, u *u
|
|||||||
// (extractPassthroughUsage, below), read only after this attempt
|
// (extractPassthroughUsage, below), read only after this attempt
|
||||||
// already resolved, so there is no model to attribute it to yet. See
|
// already resolved, so there is no model to attribute it to yet. See
|
||||||
// recordProviderAttempt's own doc comment (limits.go).
|
// recordProviderAttempt's own doc comment (limits.go).
|
||||||
r = r.WithContext(withAttemptRecorder(r.Context(), func(resp *http.Response, attemptErr error) {
|
trackedCtx, endUpstream := g.trackUpstream(r.Context(), w, providerName, "")
|
||||||
g.limiter.recordProviderAttempt(providerName, "", resp, attemptErr)
|
defer endUpstream()
|
||||||
}))
|
r = r.WithContext(trackedCtx)
|
||||||
// feat: instrument upstream latency — gated on metricsEnabled OR
|
// feat: instrument upstream latency — gated on metricsEnabled OR
|
||||||
// admin.stats.latency (admin-redesign WP-A), same reasoning as
|
// admin.stats.latency (admin-redesign WP-A), same reasoning as
|
||||||
// runUnified's identical wiring (routes_unified.go). No model here
|
// runUnified's identical wiring (routes_unified.go). No model here
|
||||||
|
|||||||
+2
-3
@@ -587,9 +587,7 @@ func (g *Gateway) runMeteredCall(sw *statusTrackingWriter, r *http.Request, scop
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := withAttemptRecorder(r.Context(), func(resp *http.Response, attemptErr error) {
|
ctx, endUpstream := g.trackUpstream(r.Context(), respWriter, providerName, upstreamModel)
|
||||||
g.limiter.recordProviderAttempt(providerName, upstreamModel, resp, attemptErr)
|
|
||||||
})
|
|
||||||
// feat: instrument upstream latency — gated on metricsEnabled OR
|
// feat: instrument upstream latency — gated on metricsEnabled OR
|
||||||
// admin.stats.latency (admin-redesign WP-A), so a deployment with
|
// admin.stats.latency (admin-redesign WP-A), so a deployment with
|
||||||
// BOTH off wires nothing here at all (not even the closure
|
// BOTH off wires nothing here at all (not even the closure
|
||||||
@@ -615,6 +613,7 @@ func (g *Gateway) runMeteredCall(sw *statusTrackingWriter, r *http.Request, scop
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
result, callErr := call(cand.adapter, ctx, respWriter, attemptReq)
|
result, callErr := call(cand.adapter, ctx, respWriter, attemptReq)
|
||||||
|
endUpstream()
|
||||||
|
|
||||||
// Request-path health, recorded ONCE per candidate per logical
|
// Request-path health, recorded ONCE per candidate per logical
|
||||||
// request (F3/F6 fix, adversarial-review) — see
|
// request (F3/F6 fix, adversarial-review) — see
|
||||||
|
|||||||
Reference in New Issue
Block a user