fix(gateway): close billing, limit, routing and translation gaps

Billing:
- Passthrough SSE streams now bill the usage the upstream reports.
  The gateway falls back to a request-size estimate only on a 2xx
  stream or a truncated JSON body.
- A unified stream that fails after it starts is estimated, not
  billed at zero.
- Billing uses the built-in LiteLLM price table and honours
  modelMeta free: true.
- Pricing entries are validated. Cost arithmetic rounds the total and
  saturates on overflow.
- Gemini thinking tokens count as completion tokens.

Limits and storage:
- A rejected request rolls back its counts on the other scopes, so one
  user cannot lock out a group.
- Redis EXPIRE replies are checked. Keys recreated without a TTL are
  repaired at once.
- A mutating pipeline is not retried after reply bytes arrive.
- History, model totals and target usage report a store outage.

Routing and translation:
- Discovered ids with a provider-like prefix are listed only in
  owner/id form, so listing and routing agree.
- Anthropic and Gemini discovery follows pagination.
- The developer role maps to system.
- stream_options.include_usage is honoured on translated streams.
- tool_choice none keeps tools when history holds tool blocks.
- refusal maps to content_filter. Data URI parameters are stripped.

Streaming, retry and failover:
- SSE accepts CR, LF and CRLF line ends. This closes a CR injection
  path.
- Retry-After is never jittered. A cancelled wait returns the
  context error.
- The watchdog timer stops at end of body.
- Oversized bodies return 413.
- A failed stream ends with an error event.
- Failover restores pre-existing headers. Client cancels do not count
  as provider successes.

Also: "admin" is a reserved provider name. metrics.path must start
with "/". Pricing warnings go to the right gateway instance. The admin
targets view sends access as null or [] so the two states differ.
Upstream organisation headers are stripped. The users-file reload
error is logged once per error. Dead code is removed, and test-only
helpers move to test files.

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
2026-09-23 09:44:31 +01:00
parent b3b8ae6ad5
commit 4c01c75276
62 changed files with 6064 additions and 1182 deletions
+22 -7
View File
@@ -1272,12 +1272,27 @@ type adminTargetCountersView struct {
// group.allowsMCP/allowsAgent themselves delegate to (auth.go) against
// each group's own cloned mcpServers/agents pattern list (groupSummary,
// authStore.snapshot), so this view can never disagree with what
// handleTargetProxy actually enforces. Access is omitted (nil) when every
// configured group can reach this target — "empty meaning all", mirroring
// groupSummary's own providers/models/mcpServers/agents omitempty
// convention (adminUsageEntryView) — rather than always listing every
// group name, which would grow with the group catalog for no reason once
// nothing is actually restricted.
// handleTargetProxy actually enforces.
//
// Access's JSON contract (review-auth finding F2, 2026-09 review — fixed
// from a prior "omitempty" tag): "access":null means every configured
// group can reach this target ("empty meaning all" — adminTargetAccess
// returns a nil slice for that case); "access":[] (present, zero-length)
// means NO configured group can reach it; "access":["g1",...] lists the
// groups that can. The prior omitempty tag made the first two cases
// serialize identically (both simply absent from the JSON body), so a
// target restricted to zero groups rendered in the webui exactly like an
// unrestricted one — the opposite of what its own access rule enforces.
// No omitempty here specifically because that distinction depends on nil
// vs. non-nil-but-empty, which omitempty collapses. adminTargetAccess's
// own return value is unchanged by this fix (it already returned nil for
// "all" and a non-nil, possibly zero-length, slice otherwise) — only this
// struct's serialization of that value changes. The webui (webui/src/
// types/api.ts's AdminTargetView.access, webui/src/lib/target-columns.ts)
// must be updated to treat null/undefined and a zero-length array as
// DIFFERENT states, distinct from today's "!access?.length means All
// groups" check that treats both alike; report this contract to whoever
// owns that file.
//
// Health is feat/target-health's own readout (targetHealthView, below).
// Field order (Health first, the struct-typed field, then the strings/
@@ -1288,7 +1303,7 @@ type adminTargetView struct {
Health adminTargetHealthView `json:"health"`
Name string `json:"name"`
URL string `json:"url"`
Access []string `json:"access,omitempty"`
Access []string `json:"access"`
Counters adminTargetCountersView `json:"counters"`
}
+65 -6
View File
@@ -1575,17 +1575,12 @@ func TestAdminOverview_BaseURLStripsCredentials(t *testing.T) {
// limiter.currentUsage flattens every scope's usageKeysPerScope counter
// reads into ONE getMulti call total (v0.2 final review wave,
// 2026-08-20), rather than usageKeysPerScope separate get calls or even
// one getMulti call per scope. incrBy/get are never exercised by
// currentUsage and just return zero values.
// one getMulti call per scope.
type countingMultiStore struct {
values map[string]int64
getMultiCalls int
}
func (s *countingMultiStore) incrBy(string, int64, time.Duration) (int64, error) {
return 0, nil
}
func (s *countingMultiStore) get(string) (int64, error) { return 0, nil }
func (s *countingMultiStore) getMulti(keys []string) ([]int64, error) {
s.getMultiCalls++
out := make([]int64, len(keys))
@@ -2184,6 +2179,70 @@ func TestAdminTargets_AccessListsMatchEnforcement_AndCountersReflectTraffic(t *t
}
}
// TestAdminTargets_Access_NoGroupDistinctFromAllGroups is review-auth
// finding F2, 2026-09 review: a target NO configured group can reach must
// serialize as "access":[] (present, zero-length) — distinguishable from
// "access":null, which means every group can reach it. Before this fix
// (adminTargetView.Access's "omitempty" tag), both cases serialized
// identically — simply absent from the JSON body — so the webui's
// dashboard rendered an unreachable target as open to everyone.
func TestAdminTargets_Access_NoGroupDistinctFromAllGroups(t *testing.T) {
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.Admin = &AdminConfig{Enabled: true}
cfg.MCPServers = map[string]*TargetConfig{
"locked": {URL: "http://mcp-locked.internal"}, // no group's own list ever names it
"open": {URL: "http://mcp-open.internal"}, // every group's own list names it
}
// Both groups restrict their MCPServers glob to "open" only — neither
// ever names "locked", so no configured group can reach it.
cfg.Groups = map[string]*GroupConfig{
"g1": {MCPServers: []string{"open"}},
"g2": {MCPServers: []string{"open"}},
"admingroup": {MCPServers: []string{"open"}},
}
cfg.Users = &UsersConfig{Inline: []*UserConfig{
{Name: "admin1", Group: "admingroup", APIKey: "sk-admin1", Admin: true},
}}
h, _ := newAdminGatewayHandle(t, cfg)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminTargetsPath, "sk-admin1"))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, `"access":[]`) {
t.Errorf("body does not contain a literal empty-array access field; got %s", body)
}
var got adminTargetsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got.MCPServers) != 2 {
t.Fatalf("len(MCPServers) = %d, want 2", len(got.MCPServers))
}
var locked, open adminTargetView
for _, s := range got.MCPServers {
switch s.Name {
case "locked":
locked = s
case "open":
open = s
}
}
if locked.Access == nil {
t.Error(`"locked".Access = nil, want a non-nil, zero-length slice (no group can reach it — distinct from "all groups")`)
}
if len(locked.Access) != 0 {
t.Errorf(`"locked".Access = %v, want empty`, locked.Access)
}
if open.Access != nil {
t.Errorf(`"open".Access = %v, want nil ("empty meaning all" — every configured group can reach it)`, open.Access)
}
}
// --- default-preserving: the live cluster's real shape (security audit, 2026-08-22) ---
// TestDefaultPreserving_LiveClusterShape_LoadsAndServesIdentically models
+71 -60
View File
@@ -2,7 +2,6 @@ package traefikllmgateway
import (
"crypto/sha256"
"crypto/subtle"
"fmt"
"net"
"net/http"
@@ -107,32 +106,20 @@ func (grp *group) allowsProvider(name string) bool {
return matchesGlob(grp.providers, name)
}
// allowsModel reports whether id matches one of the group's model glob
// patterns, exactly as given. An empty pattern list allows every model.
//
// allowsModel does no "provider/model" prefix-splitting of its own — a
// pattern like "deepseek-*" must not spuriously match a bare model id that
// merely happens to contain a slash (e.g. an upstream's own
// "uni/deepseek-v4-flash-0731" naming, where "uni" is not a configured
// provider: there is no legitimate prefix to strip there at all). A caller
// that genuinely knows an id's leading segment names a configured provider
// (modelRegistry.resolveAgainst, modelRegistry.listFor) generates both the
// full and bare-suffix candidate strings itself and calls allowsModel once
// per candidate.
func (grp *group) allowsModel(id string) bool {
return matchesGlob(grp.models, id)
}
// hasModelRestriction reports whether grp's Models glob is non-empty —
// whether there is anything for allowsModel to actually reject (security
// review fix, 2026-08-22, round 2). handlePassthrough (routes_
// passthrough.go) checks this BEFORE peeking a passthrough request's body
// at all: a group with an empty Models list (matchesGlob's own
// empty-means-all contract — the live cluster's "home" group and every
// other group that has not opted into model restrictions) has nothing
// allowsModel could ever deny, so there is no reason to read, buffer, or
// even look at the request body for model enforcement — zero risk, and
// exactly today's behavior for the overwhelming majority of traffic.
// whether there is anything for allowsProviderModel to actually reject on
// the model side (security review fix, 2026-08-22, round 2 — updated
// review-auth finding F6, 2026-09 audit: the standalone allowsModel this
// comment originally referenced was removed as dead code; every
// production model check goes through allowsProviderModel). handlePassthrough
// (routes_passthrough.go) checks this BEFORE peeking a passthrough
// request's body at all: a group with an empty Models list (matchesGlob's
// own empty-means-all contract — the live cluster's "home" group and
// every other group that has not opted into model restrictions) has
// nothing to deny on the model side, so there is no reason to read,
// buffer, or even look at the request body for model enforcement — zero
// risk, and exactly today's behavior for the overwhelming majority of
// traffic.
func (grp *group) hasModelRestriction() bool {
return len(grp.models) > 0
}
@@ -697,21 +684,36 @@ func (g *logGate) shouldLog(now time.Time) (log bool, suppressed int64) {
// Gateway even has a limiter wired yet. failureLog/throttleLog back
// logAuthEvent's two distinct rate-limited lines (logger.go).
type authStore struct {
lastModTime time.Time
lastCheck time.Time
log gatewayLogger
failures *authFailureTracker
usersFile *usersFile
nowFn func() time.Time
groups map[string]*group
byDigest map[[32]byte]*authEntry
inline map[[32]byte]*authEntry
failureLog logGate
throttleLog logGate
fileUserCount int
mu sync.RWMutex
buildMu sync.Mutex
reloadMu sync.Mutex
lastModTime time.Time
lastCheck time.Time
// lastReloadErrModTime and lastReloadErrMsg dedup maybeReload's own
// error logging (users_file.go — review-auth finding F11, 2026-09
// audit): a users file left broken (bad JSON, a stat failure, a
// rejected replaceFileUsers) never advances lastModTime, so without
// this a broken file gets re-attempted AND re-logged every
// reloadEvery for as long as it stays broken — roughly 17k lines/day
// at the default 5s throttle. A later log fires again only when the
// error TEXT changes, or the mtime the attempt was made against
// changes (an operator rewrote the file, even to an identically
// broken state) — "once per distinct error / mtime change". Both are
// touched only from inside maybeReload, which its own TryLock already
// serializes to at most one call in flight, so no separate mutex
// guards them.
lastReloadErrModTime time.Time
log gatewayLogger
failures *authFailureTracker
usersFile *usersFile
nowFn func() time.Time
groups map[string]*group
byDigest map[[32]byte]*authEntry
inline map[[32]byte]*authEntry
lastReloadErrMsg string
failureLog logGate
throttleLog logGate
fileUserCount int
mu sync.RWMutex
buildMu sync.Mutex
reloadMu sync.Mutex
}
// newAuthStore builds an authStore from cfg's groups and inline users. It
@@ -1168,8 +1170,7 @@ func (a *authStore) shouldLogThrottleEngaged() (log bool, suppressed int64) {
// identify resolves r's presented API key to a user and their group. It
// reads "Authorization: Bearer <key>" (case-insensitive "bearer" prefix) or
// "x-api-key: <key>"; Bearer wins when both are present. The presented key
// is looked up by SHA-256 digest, then verified with a constant-time
// compare — the key itself is never retained.
// is looked up by its SHA-256 digest — the key itself is never retained.
//
// The presented key is verified FIRST, UNCONDITIONALLY (security review
// round 2, 2026-08-22, critical finding 1): a request carrying a VALID
@@ -1186,22 +1187,32 @@ func (a *authStore) shouldLogThrottleEngaged() (log bool, suppressed int64) {
// already-valid request can never be rejected by another caller's failed
// attempts, from the same address or not.
//
// Every failure path below — no key presented, key not found, or a
// (practically unreachable, see subtle.ConstantTimeCompare's own call
// site) digest mismatch — records one failure for the caller's source IP
// via recordAuthFailure; logAuthEvent (logger.go) separately decides, per
// call, whether to emit a routine failure line or a distinct throttle-
// engaged line by consulting authThrottled itself. No branch in this
// function ever changes its own return value based on throttle state —
// throttling is pure observability now (bounding the log's write volume
// and the tracker's own memory; authFailureTracker's doc comment,
// above), never enforcement: an attacker with no valid key gains nothing
// from this change (every failure path here already returned false
// either way, throttled or not), and the raw per-attempt cost this
// function pays (one SHA-256 digest, one map lookup, one
// ConstantTimeCompare) is already cheap enough — nanoseconds — that
// gating it early bought negligible protection against the cost itself,
// only the false-positive risk the paragraph above describes.
// Every failure path below — no key presented, or key not found — records
// one failure for the caller's source IP via recordAuthFailure;
// logAuthEvent (logger.go) separately decides, per call, whether to emit
// a routine failure line or a distinct throttle-engaged line by
// consulting authThrottled itself. No branch in this function ever
// changes its own return value based on throttle state — throttling is
// pure observability now (bounding the log's write volume and the
// tracker's own memory; authFailureTracker's doc comment, above), never
// enforcement: an attacker with no valid key gains nothing from this
// change (every failure path here already returned false either way,
// throttled or not), and the raw per-attempt cost this function pays (one
// SHA-256 digest, one map lookup) is already cheap enough — nanoseconds —
// that gating it early bought negligible protection against the cost
// itself, only the false-positive risk the paragraph above describes.
//
// No constant-time compare runs here (review-auth finding F7, 2026-09
// audit — a prior revision ran subtle.ConstantTimeCompare(digest[:],
// entry.digest[:]) after this exact lookup, but byDigest is populated
// ONLY as a.byDigest[entry.digest] = entry (newAuthStore/
// replaceFileUsers), so a map hit's entry.digest is the map key itself by
// construction: the compare was a tautology, always true when found is
// true, comparing digest against a value guaranteed identical to it. The
// real secret-matching step already happened inside the Go runtime's own
// map lookup, on the presented key's SHA-256 digest, not the key itself
// — there is no separate secret-bytes compare here for a non-constant-time
// implementation to leak timing from).
func (a *authStore) identify(r *http.Request) (*user, *group, bool) {
key, ok := presentedKey(r)
if ok {
@@ -1209,7 +1220,7 @@ func (a *authStore) identify(r *http.Request) (*user, *group, bool) {
a.mu.RLock()
entry, found := a.byDigest[digest]
a.mu.RUnlock()
if found && subtle.ConstantTimeCompare(digest[:], entry.digest[:]) == 1 {
if found {
return entry.user, entry.group, true
}
}
+24 -12
View File
@@ -28,19 +28,27 @@ func TestIdentify(t *testing.T) {
if !ok || u.name != "a" || grp.name != "eng" {
t.Fatalf("identify failed: %v %v %v", u, grp, ok)
}
if !grp.allowsModel("gpt-5-mini") {
// allowsModel was removed (review-auth finding F6, 2026-09 audit —
// dead in non-test code, and a standalone check would reintroduce the
// cross-grant leak allowsProviderModel's per-grant coupling exists to
// prevent for a multi-grant principal); these assertions moved to
// allowsProviderModel, the real enforcement path every production
// caller already uses. "eng" has an empty Providers glob (matches
// every provider), so "openai" here exercises that path without
// constraining it.
if !grp.allowsProviderModel("openai", "gpt-5-mini") {
t.Error("glob should match")
}
// allowsModel is an exact glob against the given string only — it no
// longer strips a "provider/" prefix itself (fix(registry) ruling 3): a
// The match is an exact glob against the given string only — it does
// not strip a "provider/" prefix itself (fix(registry) ruling 3): a
// pattern like "gpt-5*" reaching a provider-prefixed request like
// "openai/gpt-5-mini" is now the caller's job (modelRegistry.
// "openai/gpt-5-mini" is the caller's job (modelRegistry.
// resolveAgainst / listFor build the bare-suffix candidate themselves),
// covered at the registry level in registry_test.go, not here.
if grp.allowsModel("openai/gpt-5-mini") {
t.Error("allowsModel must not match a provider-prefixed id on its own; prefix-stripping now lives in the caller")
if grp.allowsProviderModel("openai", "openai/gpt-5-mini") {
t.Error("must not match a provider-prefixed id on its own; prefix-stripping lives in the caller")
}
if grp.allowsModel("claude-4") {
if grp.allowsProviderModel("openai", "claude-4") {
t.Error("should not match")
}
}
@@ -582,10 +590,14 @@ func TestGroup_AllowsAgent_Glob(t *testing.T) {
}
}
func TestGroup_AllowsModel_EmptyListAllowsAnything(t *testing.T) {
// TestGroup_AllowsProviderModel_EmptyListsAllowAnything moved from the
// removed allowsModel (review-auth finding F6, 2026-09 audit): both grp's
// empty Providers and empty Models glob independently mean "allow every
// value", so any (provider, model) pair matches.
func TestGroup_AllowsProviderModel_EmptyListsAllowAnything(t *testing.T) {
grp := &group{name: "eng"}
if !grp.allowsModel("anything-goes") {
t.Fatal("empty models list should allow anything")
if !grp.allowsProviderModel("any-provider", "anything-goes") {
t.Fatal("empty providers and models lists should allow anything")
}
}
@@ -593,8 +605,8 @@ func TestGroup_AllowsModel_EmptyListAllowsAnything(t *testing.T) {
// default for GroupConfig.PassthroughPaths (security+performance audit,
// 2026-08-22): a group with no PassthroughPaths configured — every group
// that existed before this field did — must keep reaching every native
// passthrough path, exactly like allowsProvider/allowsModel/allowsMCP/
// allowsAgent's own empty-means-all defaults.
// passthrough path, exactly like allowsProvider/allowsProviderModel/
// allowsMCP/allowsAgent's own empty-means-all defaults.
func TestGroup_AllowsPassthroughPath_EmptyListAllowsAll(t *testing.T) {
grp := &group{name: "eng"}
if !grp.allowsPassthroughPath("v1/files") {
+26 -1
View File
@@ -39,6 +39,20 @@ func validateCacheConfig(cc CacheConfig) (time.Duration, int, error) {
if err != nil {
return 0, 0, fmt.Errorf("llmgateway: cache.ttl: %w", err)
}
// verify-core fix (round 4): F8 (2026-09 review) turned a zero/
// negative ttl into a hard construction error here, matching
// groupCacheTTL's own per-group-override validation below — but that
// broke a previously WORKING config on upgrade: setEx (resp.go)
// already floors a non-positive TTL to 1s rather than erroring, so a
// "0s" cache.ttl silently ran as a 1-second cache before F8 existed,
// not as "off". Refusing to build the whole middleware over a config
// that merely caches less usefully than the operator probably
// intended violates the house rule that an upgrade with no config
// change preserves prior behavior. Restored to accept-and-warn:
// buildResponseCache (below) logs a warning naming the effective 1s
// floor once client (the constructed logf) is available; ttl itself
// is returned here exactly as parsed, unclamped — newResponseCache/
// setEx's own floor is what actually takes effect at write time.
maxBody := cc.MaxBodyBytes
if maxBody == 0 {
@@ -78,7 +92,7 @@ type cacheStore interface {
// is deliberately NOT a construction error, unlike the case above.
// - cc.Enabled is true and valid and client is non-nil: a working
// *responseCache over client.
func buildResponseCache(cc CacheConfig, client *respClient, logf, errorf func(format string, args ...any)) (*responseCache, error) {
func buildResponseCache(cc CacheConfig, client *respClient, logf, warnf, errorf func(format string, args ...any)) (*responseCache, error) {
if !cc.Enabled {
return nil, nil
}
@@ -86,6 +100,17 @@ func buildResponseCache(cc CacheConfig, client *respClient, logf, errorf func(fo
if err != nil {
return nil, err
}
// verify-core fix (round 4): a zero/negative cache.ttl is accepted
// (validateCacheConfig's own doc comment above explains why this was
// restored from a hard error), but it is not silently free of
// consequence either — setEx (resp.go) floors it to a 1-second TTL,
// so entries expire almost immediately and every lookup effectively
// misses while still paying a SET. Warn once at construction so an
// operator who meant "0s" as "disable the cache" notices, rather than
// silently running a cache that never actually caches anything.
if ttl <= 0 {
warnf("cache: ttl %q is zero or negative; every cached entry will expire after 1 second (the store's own floor), not stay disabled — set cache.enabled=false to actually turn caching off", cc.TTL)
}
if client == nil {
logf("cache: enabled but redis is not configured; response caching disabled")
return nil, nil
+68 -4
View File
@@ -213,6 +213,37 @@ func TestValidateCacheConfig_MalformedTTL_ReturnsError(t *testing.T) {
}
}
// TestValidateCacheConfig_NonPositiveTTL_AcceptedUnclamped is finding
// F8's own regression (2026-09 review) corrected by the verify-core
// round-4 fix: F8 made cache.ttl<=0 a hard construction error, matching
// groupCacheTTL's own per-group-override validation
// (TestNewAuthStore_GroupCacheTTL_ConstructorErrors, auth_test.go) — but
// that broke a config that worked before F8 existed: "0s" (or a negative
// duration string) always silently passed through to setEx (resp.go),
// which floors it to a 1-second cache rather than "off"/"no expiry".
// Restored to accept-and-return-unclamped (buildResponseCache, below,
// carries the warning — this function has no logf to call).
func TestValidateCacheConfig_NonPositiveTTL_AcceptedUnclamped(t *testing.T) {
cases := []struct {
ttl string
want time.Duration
}{
{"0s", 0},
{"-1s", -time.Second},
}
for _, c := range cases {
t.Run(c.ttl, func(t *testing.T) {
ttl, _, err := validateCacheConfig(CacheConfig{Enabled: true, TTL: c.ttl})
if err != nil {
t.Fatalf("validateCacheConfig: %v, want cache.ttl = %q accepted", err, c.ttl)
}
if ttl != c.want {
t.Errorf("ttl = %v, want %v (returned unclamped; setEx applies the 1s floor at write time)", ttl, c.want)
}
})
}
}
func TestValidateCacheConfig_NegativeMaxBodyBytes_ReturnsError(t *testing.T) {
if _, _, err := validateCacheConfig(CacheConfig{Enabled: true, MaxBodyBytes: -1}); err == nil {
t.Fatal("want an error for a negative maxBodyBytes")
@@ -237,7 +268,7 @@ func TestBuildResponseCache_Disabled_ReturnsNilNoError(t *testing.T) {
var logged []string
logf := func(format string, args ...any) { logged = append(logged, fmt.Sprintf(format, args...)) }
c, err := buildResponseCache(CacheConfig{Enabled: false}, nil, logf, logf)
c, err := buildResponseCache(CacheConfig{Enabled: false}, nil, logf, logf, logf)
if err != nil {
t.Fatalf("buildResponseCache: %v", err)
}
@@ -257,7 +288,7 @@ func TestBuildResponseCache_EnabledNoRedis_WarnsAndDisables(t *testing.T) {
logf := func(format string, args ...any) { logged = append(logged, fmt.Sprintf(format, args...)) }
errorf := func(format string, args ...any) { t.Errorf("unexpected errorf call: "+format, args...) }
c, err := buildResponseCache(CacheConfig{Enabled: true}, nil, logf, errorf)
c, err := buildResponseCache(CacheConfig{Enabled: true}, nil, logf, logf, errorf)
if err != nil {
t.Fatalf("buildResponseCache: %v, want no error (warn-and-disable, not a constructor error)", err)
}
@@ -271,11 +302,44 @@ func TestBuildResponseCache_EnabledNoRedis_WarnsAndDisables(t *testing.T) {
func TestBuildResponseCache_EnabledInvalidTTL_ReturnsConstructorError(t *testing.T) {
noop := func(string, ...any) {}
if _, err := buildResponseCache(CacheConfig{Enabled: true, TTL: "nope"}, nil, noop, noop); err == nil {
if _, err := buildResponseCache(CacheConfig{Enabled: true, TTL: "nope"}, nil, noop, noop, noop); err == nil {
t.Fatal("want a construction error for an invalid TTL, even with no Redis configured")
}
}
// TestBuildResponseCache_NonPositiveTTL_WarnsAndAccepts is the verify-
// core round-4 regression: cache.ttl<=0 must build a working cache (not
// a construction error — TestValidateCacheConfig_NonPositiveTTL_
// AcceptedUnclamped's own doc comment explains why) while still warning
// once, so an operator who meant "0s" as "off" is told it instead runs as
// a 1-second cache.
func TestBuildResponseCache_NonPositiveTTL_WarnsAndAccepts(t *testing.T) {
ln := newFakeListener(t)
runFakeRESPServer(t, ln, []respStep{
{wantArgs: []string{"SELECT", "0"}, reply: []byte("+OK\r\n")},
})
client := newRESPClient(ln.Addr().String(), "", 0)
var warned []string
logf := func(format string, args ...any) {}
warnf := func(format string, args ...any) { warned = append(warned, fmt.Sprintf(format, args...)) }
errorf := func(format string, args ...any) { t.Errorf("unexpected errorf call: "+format, args...) }
c, err := buildResponseCache(CacheConfig{Enabled: true, TTL: "0s"}, client, logf, warnf, errorf)
if err != nil {
t.Fatalf("buildResponseCache: %v, want no error (warn-and-accept, not a constructor error)", err)
}
if c == nil {
t.Fatal("want a non-nil, working *responseCache even for cache.ttl = \"0s\"")
}
if c.ttl != 0 {
t.Errorf("c.ttl = %v, want 0 (unclamped; setEx applies the 1s floor at write time)", c.ttl)
}
if len(warned) != 1 {
t.Fatalf("warned = %d lines, want exactly 1 warning, got %v", len(warned), warned)
}
}
func TestBuildResponseCache_EnabledWithRedis_ReturnsWorkingCache(t *testing.T) {
ln := newFakeListener(t)
runFakeRESPServer(t, ln, []respStep{
@@ -284,7 +348,7 @@ func TestBuildResponseCache_EnabledWithRedis_ReturnsWorkingCache(t *testing.T) {
client := newRESPClient(ln.Addr().String(), "", 0)
noop := func(string, ...any) {}
c, err := buildResponseCache(CacheConfig{Enabled: true, TTL: "1m"}, client, noop, noop)
c, err := buildResponseCache(CacheConfig{Enabled: true, TTL: "1m"}, client, noop, noop, noop)
if err != nil {
t.Fatalf("buildResponseCache: %v", err)
}
+79 -23
View File
@@ -222,11 +222,16 @@ func (m *modelRegistry) resolvePrimaryOnly(id string, grp *group) (resolveCandid
// Only grp.allowsProvider is re-checked per candidate FOR A SINGLE-GRANT
// PRINCIPAL (grp.grantCount() == 1 — the overwhelmingly common case,
// unchanged from before the multi-group/personal-grant feature existed):
// grp.allowsModel is already known true from the primary winner's own
// successful resolve call, and — for the identical bare id string every
// candidate here shares by construction — allowsModel's result does not
// vary by provider (auth.go's allowsModel takes no provider argument),
// so re-checking it per candidate would be redundant work, not a
// the bare id's own model-glob match is already known true from the
// primary winner's own successful resolve call (which goes through
// grp.allowsProviderModel, auth.go — the standalone group.allowsModel
// this comment used to name was removed as dead code, review-auth
// finding F6: every production model check already went through a
// per-grant method), and — for the identical bare id string every
// candidate here shares by construction — a single grant's model-glob
// match does not vary by provider (allowsProviderModel matches grp.
// providers and grp.models independently for the single-grant case), so
// re-checking it per candidate would be redundant work, not a
// correctness gap.
//
// A MULTI-GRANT principal (more than one member group and/or a personal
@@ -501,6 +506,51 @@ func failoverEligible(callErr error) bool {
return true
}
// requestHealthOutcome classifies callErr for requestHealthTracker.record
// (finding 16 fix, review-routes.md): record is boolean (success/
// failure), but !failoverEligible(callErr) alone — the previous call-site
// shape — conflates two different meanings of "not eligible for
// failover" into one "success". A context.Canceled means the CLIENT
// walked away; it says nothing about whether the provider would have
// succeeded, so recording it as a provider success can reset
// consecutiveFailures and close an open breaker for a provider that is,
// in fact, still failing every request an attentive client stays around
// for. A *translateError or *responseTranslationError is this GATEWAY's
// own code failing on an already-decoded/already-received request — not
// a signal about the provider's availability at all, per
// failoverEligible's own doc comment. errRequestBuildFailed (verify-core
// fix, round 4) is the SAME "says nothing about the provider" shape:
// http.NewRequestWithContext rejecting this candidate's own configured
// base URL/method is a construction-time defect in THIS gateway's config
// for that candidate, not a network round trip the provider ever saw —
// before this fix, !failoverEligible(callErr) below returned true for
// it (failoverEligible itself reports false — not eligible for
// failover — for the OPPOSITE reason: retrying an identically malformed
// request wastes attempts, not because it is evidence of health),
// inverting to record it as a provider SUCCESS and resetting
// consecutiveFailures/closing an open breaker for a candidate that
// never actually got called. record reports false, meaning "do not
// record anything" (a tri-state without a third bool: the caller skips
// the record call entirely), for all three cases; every other outcome
// keeps using failoverEligible's existing classification unchanged,
// since a genuine HTTP 400 (or a candidate that succeeded outright)
// legitimately says something about the provider.
func requestHealthOutcome(callErr error) (success, record bool) {
if callErr == nil {
return true, true
}
if errors.Is(callErr, context.Canceled) || errors.Is(callErr, errRequestBuildFailed) {
return false, false
}
if _, ok := callErr.(*translateError); ok {
return false, false
}
if _, ok := callErr.(*responseTranslationError); ok {
return false, false
}
return !failoverEligible(callErr), true
}
// isProviderNotFoundError reports whether err is a *providerHTTPError
// carrying HTTP 404 — routes_unified.go's runMeteredCall uses this to
// decide when a failover needs its own loud, non-suppressed log line
@@ -597,24 +647,30 @@ func (t *requestHealthTracker) stateFor(provider string) *requestHealthState {
// exactly once per candidate per client request, after call() has
// already exhausted whatever same-provider retries retryPolicy.do
// performed internally (routes_unified.go's runMeteredCall), never once
// per raw upstream attempt. success=false must be
// !failoverEligible(callErr) (routes_unified.go/failover.go's own
// classification of the FINAL adapter-level error) — adversarial-review
// ruling (F3/F6): an EARLIER version fed this from isTransient(resp,
// err) || isDeadlineExceeded(err), limits.go/retry.go's own
// retry-shaped classification, once per RAW attempt inside
// retryPolicy.do. That was wrong on two counts. isTransient treats a
// non-429 4xx as "the provider answered correctly to a request it did
// not like" — correct for deciding whether retrying the SAME provider
// is worth it, wrong for deciding whether the POOL should route around
// it: a provider whose API key is dead answers 401 to every request and
// must eventually be skipped, not retried forever, which is exactly the
// "masking a broken provider key" risk this feature exists to reduce.
// And recording per raw attempt let ONE client request with
// retry.enabled (attempts: 3) report FOUR failures against a threshold
// documented as three REQUESTS. failoverEligible already classifies
// "everything except HTTP 400" as a failure, matching what this gate
// needs with no separate function required.
// per raw upstream attempt. Callers derive success from
// requestHealthOutcome(callErr), above (finding 16 fix, review-
// routes.md): that helper's own success return is
// !failoverEligible(callErr) — adversarial-review ruling (F3/F6): an
// EARLIER version fed this from isTransient(resp, err) ||
// isDeadlineExceeded(err), limits.go/retry.go's own retry-shaped
// classification, once per RAW attempt inside retryPolicy.do. That was
// wrong on two counts. isTransient treats a non-429 4xx as "the provider
// answered correctly to a request it did not like" — correct for
// deciding whether retrying the SAME provider is worth it, wrong for
// deciding whether the POOL should route around it: a provider whose API
// key is dead answers 401 to every request and must eventually be
// skipped, not retried forever, which is exactly the "masking a broken
// provider key" risk this feature exists to reduce. And recording per
// raw attempt let ONE client request with retry.enabled (attempts: 3)
// report FOUR failures against a threshold documented as three REQUESTS.
// failoverEligible already classifies "everything except HTTP 400" as a
// failure, matching what this gate needs with no separate function
// required — requestHealthOutcome's own additional filtering (skip a
// client cancel or a gateway-side translation error entirely, rather
// than recording either as a provider success) is what THIS record
// method must never see: it trusts success at face value and has no way
// to tell "the provider genuinely succeeded" apart from "the caller
// decided not to blame the provider" on its own.
//
// requestBreakerFailureThreshold consecutive failures opens the breaker
// for requestBreakerOpenDuration. While open, healthy (below) reports
+43 -3
View File
@@ -491,6 +491,40 @@ func TestFailoverEligible(t *testing.T) {
}
}
// TestRequestHealthOutcome is the verify-core round-4 regression:
// errRequestBuildFailed must classify the same way context.Canceled and
// the translation errors already do — record=false (skip: this says
// nothing about the provider's own availability, requestHealthOutcome's
// own doc comment) — not success=true, which the pre-fix
// !failoverEligible(callErr) fallthrough produced (failoverEligible
// reports false for it for the OPPOSITE reason: retrying an identically
// malformed request wastes attempts, not because it is evidence the
// provider is healthy).
func TestRequestHealthOutcome(t *testing.T) {
t.Parallel()
tests := []struct {
err error
name string
wantSuccess bool
wantRecord bool
}{
{name: "nil error is a success", err: nil, wantSuccess: true, wantRecord: true},
{name: "500 records a failure", err: &providerHTTPError{status: http.StatusInternalServerError}, wantSuccess: false, wantRecord: true},
{name: "400 records a success (not eligible for failover, but the provider DID answer, so it is healthy)", err: &providerHTTPError{status: http.StatusBadRequest}, wantSuccess: true, wantRecord: true},
{name: "client canceled is skipped, not a success", err: fmt.Errorf("%w: %w", errUpstream, context.Canceled), wantSuccess: false, wantRecord: false},
{name: "request build failure is skipped, not a success", err: fmt.Errorf("%w: %w: build request", errUpstream, errRequestBuildFailed), wantSuccess: false, wantRecord: false},
{name: "translateError is skipped, not a success", err: &translateError{msg: "bad field"}, wantSuccess: false, wantRecord: false},
{name: "responseTranslationError is skipped, not a success", err: &responseTranslationError{err: errors.New("boom")}, wantSuccess: false, wantRecord: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
success, record := requestHealthOutcome(tt.err)
assert.Equal(t, tt.wantSuccess, success, "success")
assert.Equal(t, tt.wantRecord, record, "record")
})
}
}
func TestIsProviderNotFoundError(t *testing.T) {
t.Parallel()
assert.True(t, isProviderNotFoundError(&providerHTTPError{status: http.StatusNotFound}))
@@ -942,8 +976,13 @@ func TestHandleChat_Failover_400_DoesNotFallThrough(t *testing.T) {
// TestHandleChat_Failover_MidStream_DoesNotFailOver_NoCorruption is the
// regression test for THE HARD CONSTRAINT: once anything has reached the
// client, failover must never happen and the response must never be
// corrupted with a second envelope. Would FAIL if the loop checked
// failoverEligible before sw.wroteHeader.
// corrupted with a second, conflicting HTTP envelope — beta must never
// be called once alpha has started writing. Would FAIL if the loop
// checked failoverEligible before sw.wroteHeader. The response body DOES
// carry one trailing SSE error event (finding 15 fix, review-routes.md),
// which is not a second envelope in that sense — it is the in-stream
// terminal signal an OpenAI-compatible client expects, appended to
// alpha's own partial output, never beta's.
func TestHandleChat_Failover_MidStream_DoesNotFailOver_NoCorruption(t *testing.T) {
const firstChunk = "data: {\"id\":\"c1\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\n"
@@ -976,8 +1015,9 @@ func TestHandleChat_Failover_MidStream_DoesNotFailOver_NoCorruption(t *testing.T
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
const wantErrEvent = "data: {\"error\":{\"message\":\"upstream connection error\",\"type\":\"server_error\"}}\n\n"
assert.Equal(t, http.StatusOK, rec.Code, "headers already committed before the drop")
assert.Equal(t, firstChunk, rec.Body.String(), "no trailing envelope, no failover content appended")
assert.Equal(t, firstChunk+wantErrEvent, rec.Body.String(), "alpha's own partial output plus its own trailing error event, no failover content appended")
assert.Equal(t, int64(0), atomic.LoadInt64(&betaCalls), "beta must never be called once alpha has started writing")
}
+186 -167
View File
@@ -166,7 +166,7 @@ func (lc *LimitsConfig) validate() error {
// counterIncr is one entry in an incrMulti batch: increment key by delta,
// creating it with an expiry of ttl from now if it does not exist or has
// expired — the same per-entry contract incrBy applies to a single key.
// expired.
type counterIncr struct {
key string
delta int64
@@ -187,21 +187,17 @@ type counterIncr struct {
// counterStore is the storage backend the limiter uses for atomic windowed
// counters. memoryStore (below) is the in-process fallback; a distributed
// (e.g. Redis-backed) implementation is wired in a later task.
//
// counterStore had single-key incrBy/get methods until the 2026-09 deadcode
// audit: checkAndCount/account moved onto the batched incrMulti/getMulti/
// incrAndGetMulti below in the 2026-08-21/22 perf reviews, which left
// incrBy/get with no production caller of any kind — every remaining call
// site was a test exercising a store implementation directly. They were
// removed from the interface and from memoryStore/redisStore; the tests
// that needed a single-key call now build a one-entry incrMulti batch or a
// one-key getMulti read instead (see limits_helpers_test.go and
// redis_store_test.go's incrOne/getOne).
type counterStore interface {
// incrBy adds n to key's counter, creating it with an expiry of ttl
// from now if it does not exist or has expired, and returns the
// counter's new value. incrBy has no production caller since
// checkAndCount/account moved to the batched incrMulti below (perf
// review, 2026-08-21) — it is exercised directly by 11 test call
// sites (memoryStore.incrBy and redisStore.incrBy, across
// limits_test.go and redis_store_test.go) and kept for counterStore
// interface conformance. The limiter-level single-key seeding helper
// is incrCounter (5 call sites, all in admin_test.go's
// TestAdminUsage_MathAgainstSeededCounters).
incrBy(key string, n int64, ttl time.Duration) (int64, error)
// get returns key's current counter value, or 0 if it does not exist
// or has expired.
get(key string) (int64, error)
// getMulti reads every key in keys in one round trip where the
// backend supports it (redisStore pipelines via respClient.getBatch;
// memoryStore's in-process map needs no such optimization but
@@ -211,7 +207,7 @@ type counterStore interface {
// whole batch (mirrored by the limiter's storeGetMulti as a single
// fail-open/fail-closed decision), never a partial result.
getMulti(keys []string) ([]int64, error)
// incrMulti applies incrBy's own per-entry contract to every entry in
// incrMulti applies counterIncr's per-entry contract to every entry in
// entries in one round trip where the backend supports it (redisStore
// pipelines one INCRBY+EXPIRE pair per entry into a single pipeline
// call; memoryStore's in-process map needs no such optimization but
@@ -257,12 +253,12 @@ type memoryEntry struct {
value int64
}
// sweepEvery is the minimum real time between incrBy's opportunistic
// sweepEvery is the minimum real time between memoryStore's opportunistic
// expired-entry sweeps, regardless of how many keys memoryStore holds. A
// size-gated sweep (only above N keys) was measured to scan the whole map
// on every incrBy once live keys exceeded that threshold, since a sweep
// on every increment once live keys exceeded that threshold, since a sweep
// that frees nothing (all keys still live) never brings the count back
// down — a 167x incrBy cliff. Gating on elapsed time instead bounds sweep
// down — a 167x increment cliff. Gating on elapsed time instead bounds sweep
// frequency independent of key count: worst case is one full-map scan
// every 30s.
const sweepEvery = 30 * time.Second
@@ -343,11 +339,11 @@ func clampTTL(ttl, enforceTTL time.Duration) time.Duration {
// instance is rebuilt on every config reload, and any ticker or goroutine
// started here would leak on rebuild instead of being collected with the
// rest of the old instance. Expired entries are instead swept
// opportunistically from incrBy, at most once every sweepEvery.
// opportunistically from applyIncr, at most once every sweepEvery.
type memoryStore struct {
data map[string]*memoryEntry
nowFn func() time.Time // injected for tests; defaults to time.Now
lastSweep time.Time // guarded by mu; zero value sweeps on the first incrBy
lastSweep time.Time // guarded by mu; zero value sweeps on the first applyIncr
mu sync.Mutex
}
@@ -361,26 +357,17 @@ func (m *memoryStore) now() time.Time {
return m.nowFn()
}
// incrBy implements counterStore. ttl is clamped by clampTTL with no
// enforcement floor (enforceTTL=0) — incrBy has no window to derive one
// from, since it takes a bare key rather than a (kind, id, metric,
// window) tuple. incrBy has had no production caller since checkAndCount/
// account moved to the batched incrMulti below (perf review,
// 2026-08-21); it is exercised directly by 11 test call sites (across
// limits_test.go and redis_store_test.go's own store.incrBy calls) and
// kept for counterStore interface conformance. incrCounter (below) is
// the limiter-level single-key seeding helper tests use instead (5 call
// sites, all in admin_test.go's TestAdminUsage_MathAgainstSeededCounters).
func (m *memoryStore) incrBy(key string, n int64, ttl time.Duration) (int64, error) {
return m.applyIncr(key, n, clampTTL(ttl, 0)), nil
}
// applyIncr increments key by n — creating it fresh, expiring at
// now+ttl, if absent or already expired — and returns its new value. ttl
// must already be clamped by the caller: incrBy clamps with no
// enforcement floor, incrMulti clamps per entry with that entry's own
// enforceTTL floor (see clampTTL). Sharing this one map-mutation-plus-
// opportunistic-sweep implementation between both callers means the
// must already be clamped by the caller: incrMulti clamps per entry with
// that entry's own enforceTTL floor (see clampTTL); the test-only incrBy
// helper (limits_helpers_test.go, restoring the single-key API removed
// from production in the 2026-09 deadcode audit — checkAndCount/account
// moved onto the batched incrMulti below in the 2026-08-21 perf review,
// leaving incrBy with no production caller) clamps with no enforcement
// floor, since a bare key carries no (kind, id, metric, window) tuple to
// derive one from. Sharing this one map-mutation-plus-opportunistic-sweep
// implementation between both callers means the
// sweep logic, and the "already expired counts as absent" rule, exist
// exactly once.
func (m *memoryStore) applyIncr(key string, n int64, ttl time.Duration) int64 {
@@ -785,7 +772,25 @@ const providerAttemptSpawnCap = 64
// sharing counters across Traefik instances. failOpen governs what happens
// when a non-nil store errors: true silently falls back to the in-process
// memoryStore for that operation, false refuses the request (see
// storeIncrBy / storeGet).
// storeIncrMulti / storeGetMulti).
//
// Known limitation, documented rather than fixed (verify-core review,
// round 4, finding 8 — accepted as a cost too high for the benefit): with
// failOpen=true, EVERY Traefik instance running this plugin falls back to
// its OWN private, unshared memoryStore for the whole duration of a
// configured store outage (storeLatched's 5s latch, re-opened on each new
// failure). Two instances behind the same load balancer therefore each
// enforce limits against a DIFFERENT count for the same scope during that
// window — user/group/total totals visibly drift apart across backends —
// and neither fallback's counts are reconciled back into the shared store
// once it recovers; each instance's fallback simply stops being read
// again. This under-enforces (each instance only sees its own share of
// traffic against the full limit) rather than over-enforces, and is
// bounded to the outage window itself, so it is accepted as a fail-open
// trade-off rather than fixed — a cross-instance reconciliation pass
// would need either a persistent pending-delta queue replayed into the
// store on recovery, or a broadcast mechanism between instances, either
// of which is a materially larger change than this review's scope.
func newLimiter(store counterStore, failOpen bool) *limiter {
l := &limiter{
store: store,
@@ -865,8 +870,9 @@ func (l *limiter) logStoreError(err error) {
}
// storeLatched reports whether l is within storeDownLatchFor of its last
// recorded store failure. When true, storeIncrBy/storeGet must skip the
// network call entirely and go straight to the fail-open/fail-closed
// recorded store failure. When true, storeIncrMulti/storeGetMulti/
// storeIncrAndGetMulti must skip the network call entirely and go straight
// to the fail-open/fail-closed
// policy — the same outcome a fresh call would reach anyway, just without
// paying respCallTimeout again to find out. Guarded by logMu, the same
// mutex lastLogAt already uses for this kind of small timestamp state.
@@ -900,9 +906,9 @@ func (l *limiter) configuredStoreDown() bool {
}
// recordStoreFailure logs err (rate-limited, see logStoreError) and opens
// the store-down latch: every storeIncrBy/storeGet call for the next
// storeDownLatchFor skips the network call and applies the fail-open/
// fail-closed policy directly.
// the store-down latch: every storeIncrMulti/storeGetMulti/
// storeIncrAndGetMulti call for the next storeDownLatchFor skips the
// network call and applies the fail-open/fail-closed policy directly.
func (l *limiter) recordStoreFailure(err error) {
l.logStoreError(err)
l.logMu.Lock()
@@ -927,77 +933,11 @@ func (l *limiter) redisStatus() (configured bool, lastErr string, lastErrAt time
return l.store != nil, l.lastErrMsg, l.lastStoreFailure
}
// storeIncrBy increments key by n with the given ttl, applying the
// limiter's fail-open/fail-closed policy when a configured store errors
// or when the store-down latch (storeLatched) is already open from a
// recent failure. ok is false only in the fail-closed case — a caller
// must refuse the request for that, rather than treating a zero value as
// a real counter reading. storeIncrBy has no production caller since
// checkAndCount/account moved to the batched storeIncrMulti (perf review,
// 2026-08-21) — its only caller, incrCounter below, is itself only
// called from tests, for direct counter seeding.
func (l *limiter) storeIncrBy(key string, n int64, ttl time.Duration) (v int64, ok bool) {
if l.store == nil {
v, _ = l.fallback.incrBy(key, n, ttl) // fallback never errors
return v, true
}
if l.storeLatched() {
return l.failPolicyIncrBy(key, n, ttl)
}
v, err := l.store.incrBy(key, n, ttl)
if err == nil {
return v, true
}
l.recordStoreFailure(err)
return l.failPolicyIncrBy(key, n, ttl)
}
// storeGet mirrors storeIncrBy for a read: it applies the same fail-open/
// fail-closed/latched handling on a configured store's error.
func (l *limiter) storeGet(key string) (v int64, ok bool) {
if l.store == nil {
v, _ = l.fallback.get(key)
return v, true
}
if l.storeLatched() {
return l.failPolicyGet(key)
}
v, err := l.store.get(key)
if err == nil {
return v, true
}
l.recordStoreFailure(err)
return l.failPolicyGet(key)
}
// failPolicyIncrBy applies the limiter's fail-open/fail-closed policy for
// an incrBy the caller has decided not to attempt against the configured
// store (it just failed, or the store-down latch is open): failOpen=true
// counts key in the fallback instead; failOpen=false reports the
// operation as failed.
func (l *limiter) failPolicyIncrBy(key string, n int64, ttl time.Duration) (int64, bool) {
if !l.failOpen {
return 0, false
}
v, _ := l.fallback.incrBy(key, n, ttl)
return v, true
}
// failPolicyGet mirrors failPolicyIncrBy for a read.
func (l *limiter) failPolicyGet(key string) (int64, bool) {
if !l.failOpen {
return 0, false
}
v, _ := l.fallback.get(key)
return v, true
}
// storeGetMulti mirrors storeGet for a batch of keys: one round trip
// against the configured store (or the in-process fallback) for the
// whole slice, applying the identical fail-open/fail-closed/latched
// policy storeGet applies per key. ok is false only in the fail-closed
// case, matching storeGet's contract — a caller must not read the
// returned slice as real values when ok is false.
// storeGetMulti applies the limiter's fail-open/fail-closed/latched
// policy to a batch read: one round trip against the configured store (or
// the in-process fallback) for the whole slice of keys. ok is false only
// in the fail-closed case — a caller must not read the returned slice as
// real values when ok is false.
func (l *limiter) storeGetMulti(keys []string) (v []int64, ok bool) {
if l.store == nil {
v, _ = l.fallback.getMulti(keys) // fallback never errors
@@ -1014,7 +954,11 @@ func (l *limiter) storeGetMulti(keys []string) (v []int64, ok bool) {
return l.failPolicyGetMulti(keys)
}
// failPolicyGetMulti mirrors failPolicyGet for a batch read.
// failPolicyGetMulti applies the limiter's fail-open/fail-closed policy
// for a batch read the caller has decided not to attempt against the
// configured store (it just failed, or the store-down latch is open):
// failOpen=true reads through to the fallback instead; failOpen=false
// reports the operation as failed.
func (l *limiter) failPolicyGetMulti(keys []string) ([]int64, bool) {
if !l.failOpen {
return nil, false
@@ -1023,10 +967,10 @@ func (l *limiter) failPolicyGetMulti(keys []string) ([]int64, bool) {
return v, true
}
// storeIncrMulti mirrors storeIncrBy for a batch of increments: one round
// storeIncrMulti mirrors storeGetMulti for a batch of increments: one round
// trip against the configured store (or the in-process fallback) for the
// whole slice, applying the identical fail-open/fail-closed/latched
// policy storeIncrBy applies per key. ok is false only in the fail-closed
// policy storeGetMulti applies per batch. ok is false only in the fail-closed
// case — a caller must drop the sample (account) or skip the write
// (countTargetRequests, recordProviderAttempt) for that, rather than
// treating a nil/short slice as real counter readings. checkAndCount's own
@@ -1048,7 +992,7 @@ func (l *limiter) storeIncrMulti(entries []counterIncr) (v []int64, ok bool) {
return l.failPolicyIncrMulti(entries)
}
// failPolicyIncrMulti mirrors failPolicyIncrBy for a batch increment.
// failPolicyIncrMulti mirrors failPolicyGetMulti for a batch increment.
func (l *limiter) failPolicyIncrMulti(entries []counterIncr) ([]int64, bool) {
if !l.failOpen {
return nil, false
@@ -1209,26 +1153,6 @@ func retryAfterSeconds(t time.Time, window string) int {
return int(math.Ceil(windowEnd(t, window).Sub(t.UTC()).Seconds()))
}
// incrCounter increments the (kind, id, metric, window) counter at t by n
// and returns its new value, together with whether the operation
// succeeded under the limiter's fail-open/fail-closed policy (see
// storeIncrBy). ok is false only in the fail-closed case: a configured
// store errored and failOpen is false. incrCounter has no production
// caller since checkAndCount/account moved to the batched incrMulti
// (perf review, 2026-08-21) — it is kept for direct counter seeding in
// tests (5 call sites, all in admin_test.go's
// TestAdminUsage_MathAgainstSeededCounters).
func (l *limiter) incrCounter(kind, id, metric, window string, t time.Time, n int64, ttl time.Duration) (int64, bool) {
return l.storeIncrBy(windowKey(kind, id, metric, window, t), n, ttl)
}
// getCounter returns the (kind, id, metric, window) counter's value at t,
// together with whether the read succeeded under the limiter's fail-open/
// fail-closed policy (see storeGet).
func (l *limiter) getCounter(kind, id, metric, window string, t time.Time) (int64, bool) {
return l.storeGet(windowKey(kind, id, metric, window, t))
}
// checkAndCountKeysPerScope is the number of counterIncr entries
// checkAndCount builds per scope (req:min, req:day, req:hour), and the
// stride storeIncrAndGetMulti's flat incrVals result is sliced back into
@@ -1291,14 +1215,25 @@ func buildBudgetProbes(scopes []limitScope, now time.Time) (probes []budgetProbe
}
// checkAndCount increments every scope's req:min, req:day, and req:hour
// counters — unconditionally, before any evaluation, so a request that
// ultimately gets refused by one scope's limit still counts toward every
// other scope's rate tracking — then evaluates each scope's set limits, in
// order, and returns the first violation found, or nil if the request may
// proceed. If the round trip fails closed (a configured store errored and
// failOpen is false), it returns a storeDownViolation immediately — the
// request is refused rather than evaluated against partial or
// fallback-only counters.
// counters — unconditionally, before any evaluation — then evaluates each
// scope's set limits, in order, and returns the first violation found, or
// nil if the request may proceed. If the round trip fails closed (a
// configured store errored and failOpen is false), it returns a
// storeDownViolation immediately — the request is refused rather than
// evaluated against partial or fallback-only counters.
//
// On a violation, rollbackOtherScopeCounts (finding F1, 2026-09 review)
// compensates the increments made above for every scope OTHER than the
// one that actually violated: the violating scope's own counter still
// reflects a real admission attempt against ITS OWN limit — unchanged,
// intentional — but WITHOUT the rollback, every OTHER scope (a user's
// group, the synthetic total scope) would also count that same rejected
// request toward ITS rate tracking, even though it never had anything to
// do with the rejection. Left unrolled-back, a single user hammering past
// their own requests-per-minute limit drives their group's identical
// counter up on every one of those 429s too, and can lock out every
// OTHER member of the group once the group's own limit is reached from
// traffic that was never actually admitted.
//
// Every scope's three increments AND every scope's token/cost budget reads
// ride ONE storeIncrAndGetMulti round trip (perf review round 3,
@@ -1367,10 +1302,12 @@ func (l *limiter) checkAndCount(scopes []limitScope) *limitViolation {
// stats-only, deliberately never read here.
if v := requestLimitViolation(sc, "requests-per-minute", sc.limits.RequestsPerMinute, minCount, windowMin, now); v != nil {
l.rejections.increment(sc.kind, sc.id)
l.rollbackOtherScopeCounts(entries, i, len(scopes))
return v
}
if v := requestLimitViolation(sc, "requests-per-day", sc.limits.RequestsPerDay, dayCount, windowDay, now); v != nil {
l.rejections.increment(sc.kind, sc.id)
l.rollbackOtherScopeCounts(entries, i, len(scopes))
return v
}
for _, p := range probes {
@@ -1385,6 +1322,7 @@ func (l *limiter) checkAndCount(scopes []limitScope) *limitViolation {
continue
}
l.rejections.increment(sc.kind, sc.id)
l.rollbackOtherScopeCounts(entries, i, len(scopes))
return &limitViolation{
message: fmt.Sprintf("%s %q exceeded %s budget", sc.kind, sc.id, p.name),
retryAfter: retryAfterSeconds(now, p.window),
@@ -1394,6 +1332,55 @@ func (l *limiter) checkAndCount(scopes []limitScope) *limitViolation {
return nil
}
// rollbackOtherScopeCounts compensates the req:min/req:day/req:hour
// increments checkAndCount already made for every scope OTHER than
// violatedIdx, in ONE further batched store call (finding F1, 2026-09
// review — the group-lockout bug): checkAndCount counts every scope
// UNCONDITIONALLY before evaluating any of them, by design, so that a
// request refused by one scope's own limit still counts toward every
// other scope's rate tracking (see checkAndCount's own doc comment) — but
// that means a user hammering past THEIR OWN requests-per-minute limit
// also drives up their group's identical counter on every one of those
// rejected attempts, and can lock out every other member of the group
// once the group's own limit is reached too, entirely from traffic that
// was never admitted.
//
// The scope that actually violated (violatedIdx) keeps its own
// increment — that counter reflecting a real admission attempt against
// ITS OWN limit is exactly the existing, intentional behavior; only the
// OTHER scopes' counts, which had nothing to do with this rejection, are
// undone. Sent as negative-delta counterIncr entries through the same
// storeIncrMulti path every other write in this file already uses — one
// extra round trip, only on rejection, never on the (overwhelmingly more
// common) admitted path.
//
// Best-effort: a failed rollback batch is logged and otherwise ignored —
// the request is being rejected either way, and a failed rollback only
// ever leaves an extra +1 on an unrelated scope's counter, which is
// exactly this bug's own pre-fix behavior for that one counter, not a new
// hazard.
func (l *limiter) rollbackOtherScopeCounts(entries []counterIncr, violatedIdx, numScopes int) {
if numScopes <= 1 {
return
}
comp := make([]counterIncr, 0, (numScopes-1)*checkAndCountKeysPerScope)
for i := 0; i < numScopes; i++ {
if i == violatedIdx {
continue
}
for k := 0; k < checkAndCountKeysPerScope; k++ {
e := entries[i*checkAndCountKeysPerScope+k]
comp = append(comp, counterIncr{key: e.key, delta: -e.delta, ttl: e.ttl, enforceTTL: e.enforceTTL})
}
}
if len(comp) == 0 {
return
}
if _, ok := l.storeIncrMulti(comp); !ok {
l.logf("limits: rollback of other-scope request counters failed after a rejection; those scopes' req:min/day/hour counters may be over-counted by 1 for the current window")
}
}
// requestLimitViolation reports a violation when count (already
// incremented for this request) exceeds limit. limit<=0 means unlimited.
func requestLimitViolation(sc limitScope, name string, limit, count int64, window string, now time.Time) *limitViolation {
@@ -1416,7 +1403,7 @@ func requestLimitViolation(sc limitScope, name string, limit, count int64, windo
// Every scope's writes are collected into ONE storeIncrMulti call (perf
// review, 2026-08-21): a 3-scope request (user, group, total) with all
// three metrics nonzero previously paid up to 9 round trips per scope —
// 27 total — one incrCounter call each. account's return-value discipline
// 27 total — one single-key store write each. account's return-value discipline
// is unchanged by the batching: it has no error return, and a sample that
// hits a fail-closed batch (see storeIncrMulti) is dropped in full,
// silently, rather than counting it in the fallback — dropping avoids
@@ -1609,6 +1596,18 @@ type targetCounters struct {
// because a target scope carries no limit: nothing downstream ever treats
// this zero as "confirmed no traffic" the way scopeUsage.storeDown guards
// against for a limited scope.
//
// configuredStoreDown is checked in addition to storeGetMulti's own ok
// (finding F5, 2026-09 review, applying currentUsage/providerUsage's own
// fix here too): with the default failOpen=true, storeGetMulti's ok stays
// true during a store outage — it silently reads the in-process fallback
// instead, which never saw this target's real traffic — so ok alone
// cannot tell a real reading apart from a fail-open one served from an
// empty fallback. The output is identical either way (the same
// zero-value targetCounters this doc comment already describes for the
// fail-closed case), so this only changes what happens during a fail-open
// outage, matching the sibling reads' own behavior instead of silently
// serving fabricated zeros as though they were confirmed current usage.
func (l *limiter) targetUsage(scopes []limitScope) []targetCounters {
out := make([]targetCounters, len(scopes))
if len(scopes) == 0 {
@@ -1622,7 +1621,7 @@ func (l *limiter) targetUsage(scopes []limitScope) []targetCounters {
}
vals, ok := l.storeGetMulti(allKeys)
if !ok || len(vals) != len(allKeys) {
if !ok || len(vals) != len(allKeys) || l.configuredStoreDown() {
return out // zero-value counters; see doc comment above
}
@@ -1880,16 +1879,18 @@ func isDeadlineExceeded(err error) bool {
// countTargetRequests/-Request already apply to target scopes (mcp_a2a.go
// callers), consistent with those counters' own attempt/request semantics.
//
// Both counters are written at minute AND day granularity, for both the
// provider scope and the (provider, model) scope — the write side is
// unaffected by SHOULD-2's read-side change to providerUsage/
// providerCounterKeys below: writing both windows costs nothing extra
// (already one batched storeIncrMulti call either way), and it leaves
// the model-level minute counters available in the store for a future
// caller even though GET /admin/api/overview no longer reads them today.
// No month window: provider health is a now-and-today question (the
// Providers tab's success-rate badge, webui), not a billing one, so there
// is nothing here for a month-long retention window to serve.
// The PROVIDER scope's counters are written at minute AND day
// granularity; the (provider, model) scope is written at DAY granularity
// only (finding, dead-code sweep, 2026-09 review: this file used to also
// write provmodel:*:attempt|fail:min every attempt, but providerCounterKeys
// below has never read a kindProviderModel scope's minute keys at all —
// SHOULD-2, v0.22 review round, its own doc comment — so those writes were
// 2-4 pure-cost INCRBY+EXPIRE commands per attempt with no reader anywhere
// in this codebase, admin.go, metrics.go, or webui/src; grepped again
// before removing them here). No month window for either scope: provider
// health is a now-and-today question (the Providers tab's success-rate
// badge, webui), not a billing one, so there is nothing here for a
// month-long retention window to serve.
//
// The store write itself is fire-and-forget (l.spawn — SHOULD-5, v0.22
// review round): entries is finished being built before spawn is called,
@@ -1905,7 +1906,7 @@ func (l *limiter) recordProviderAttempt(provider, model string, resp *http.Respo
et := enforceTTLsFor()
fail := isTransient(resp, err) || isDeadlineExceeded(err)
entries := make([]counterIncr, 0, 8)
entries := make([]counterIncr, 0, 6)
entries = append(entries,
counterIncr{key: windowKeyForBucket(kindProvider, provider, metricProvAttempt, windowMin, wb.min), delta: 1, ttl: minWindowTTL, enforceTTL: et.min},
counterIncr{key: windowKeyForBucket(kindProvider, provider, metricProvAttempt, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day},
@@ -1917,14 +1918,16 @@ func (l *limiter) recordProviderAttempt(provider, model string, resp *http.Respo
)
}
if model != "" {
// Day granularity only — see this function's own doc comment
// (dead-code sweep, 2026-09 review) for why a minute-window pair
// was removed here: providerCounterKeys never reads one for a
// kindProviderModel scope.
id := providerModelScopeID(provider, model)
entries = append(entries,
counterIncr{key: windowKeyForBucket(kindProviderModel, id, metricProvAttempt, windowMin, wb.min), delta: 1, ttl: minWindowTTL, enforceTTL: et.min},
counterIncr{key: windowKeyForBucket(kindProviderModel, id, metricProvAttempt, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day},
)
if fail {
entries = append(entries,
counterIncr{key: windowKeyForBucket(kindProviderModel, id, metricProvFail, windowMin, wb.min), delta: 1, ttl: minWindowTTL, enforceTTL: et.min},
counterIncr{key: windowKeyForBucket(kindProviderModel, id, metricProvFail, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day},
)
}
@@ -2226,6 +2229,16 @@ type historyPoint struct {
// caller answers 503 rather than presenting an all-zero ranking as though
// no model had been used. An empty ids slice is not a store read at all:
// it returns an empty result and ok, never a round trip.
//
// configuredStoreDown is checked alongside storeGetMulti's own ok
// (finding F5, 2026-09 review, applying currentUsage/providerUsage's own
// fix here too): with the default failOpen=true, ok alone stays true
// during a store outage — storeGetMulti silently reads the in-process
// fallback instead, which was never the ranking's real per-model source
// of truth — so without this check GET /admin/api/usage/models would
// present a fail-open read of near-empty fallback data as a genuine
// "these models were barely used" ranking instead of the 503 this
// function's own doc comment already promises for a store failure.
func (l *limiter) modelTotals(ids []string, metric, window string) ([]int64, bool) {
if len(ids) == 0 {
return nil, true
@@ -2236,7 +2249,7 @@ func (l *limiter) modelTotals(ids []string, metric, window string) ([]int64, boo
keys[i] = windowKey(kindModel, id, metric, window, now)
}
vals, ok := l.storeGetMulti(keys)
if !ok || len(vals) != len(keys) {
if !ok || len(vals) != len(keys) || l.configuredStoreDown() {
return nil, false
}
return vals, true
@@ -2289,12 +2302,18 @@ func historyBucketKeys(kind, id, metric, window string, now time.Time, span int)
// discipline currentUsage already applies to a scope's current-window
// keys, scaled here to a whole span of one metric/window instead of a
// fixed set of usageKeysPerScope. ok is false only in the fail-closed
// case (storeGetMulti's own contract): a caller must not present the
// returned points as real data then.
// case (storeGetMulti's own contract) OR when configuredStoreDown reports
// a fail-open outage in progress (finding F5, 2026-09 review, applying
// currentUsage/providerUsage's own fix here too — with the default
// failOpen=true, storeGetMulti's ok alone stays true while silently
// reading the in-process fallback, which a Redis blip empties out to
// near-zero, painting a false drop on the usage-history charts instead of
// the 503 this doc comment already promises for a store failure): a
// caller must not present the returned points as real data then.
func (l *limiter) history(kind, id, metric, window string, now time.Time, span int) ([]historyPoint, bool) {
keys, buckets := historyBucketKeys(kind, id, metric, window, now, span)
vals, ok := l.storeGetMulti(keys)
if !ok || len(vals) != len(keys) {
if !ok || len(vals) != len(keys) || l.configuredStoreDown() {
return nil, false
}
points := make([]historyPoint, span)
+92
View File
@@ -0,0 +1,92 @@
package traefikllmgateway
import "time"
// This file restores single-key test conveniences for limiter/memoryStore
// counter operations that no longer have any production caller (deadcode
// audit, 2026-09 review): checkAndCount/account moved onto the batched
// incrMulti/getMulti/incrAndGetMulti paths in the 2026-08-21/22 perf
// reviews, which left incrBy/get — and the limiter-level helpers built on
// them (storeIncrBy, storeGet, failPolicyIncrBy, failPolicyGet, incrCounter,
// getCounter) — with no caller anywhere in production. They were removed
// from limits.go and from the counterStore interface. Every helper here is
// implemented via the SAME batched production path a single-key call would
// have used (a one-entry counterIncr slice, or a one-key string slice), so
// it exercises the identical fail-open/fail-closed/latch policy and EXPIRE-
// confirmation logic the removed originals did — not a parallel
// reimplementation. This keeps the ~130 existing test call sites (across
// limits_test.go and admin_test.go) compiling and passing unchanged.
// incrBy is a test-only single-key counterpart to memoryStore.incrMulti,
// restoring the pre-batching direct-increment API for memoryStore-level
// unit tests that need a bare key with no window-derived enforceTTL floor
// (incrMulti's own callers always supply one via newCounterIncr; a bare
// test key has none, matching the removed production incrBy's own
// no-floor clamp).
func (m *memoryStore) incrBy(key string, n int64, ttl time.Duration) (int64, error) {
return m.applyIncr(key, n, clampTTL(ttl, 0)), nil
}
// storeIncrBy is a test-only single-key counterpart to storeIncrMulti,
// letting the limiter's fail-open/fail-closed/latch tests exercise that
// policy against one key without building a one-entry counterIncr slice at
// every call site. Delegates to storeIncrMulti itself, so it drives the
// exact same policy branches (nil store / latched / store error / store
// success) a single-entry batch would.
func (l *limiter) storeIncrBy(key string, n int64, ttl time.Duration) (v int64, ok bool) {
vs, ok := l.storeIncrMulti([]counterIncr{{key: key, delta: n, ttl: ttl}})
if !ok || len(vs) == 0 {
return 0, ok
}
return vs[0], true
}
// storeGet mirrors storeIncrBy for a read, delegating to storeGetMulti.
func (l *limiter) storeGet(key string) (v int64, ok bool) {
vs, ok := l.storeGetMulti([]string{key})
if !ok || len(vs) == 0 {
return 0, ok
}
return vs[0], true
}
// failPolicyIncrBy applies the limiter's fail-open/fail-closed policy for
// an incrBy the caller has decided not to attempt against the configured
// store (it just failed, or the store-down latch is open): failOpen=true
// counts key in the fallback instead; failOpen=false reports the
// operation as failed. Exercised directly (not via storeIncrBy/
// storeIncrMulti) by tests that isolate this branching from the store-call
// decision above it.
func (l *limiter) failPolicyIncrBy(key string, n int64, ttl time.Duration) (int64, bool) {
if !l.failOpen {
return 0, false
}
v, _ := l.fallback.incrBy(key, n, ttl)
return v, true
}
// failPolicyGet mirrors failPolicyIncrBy for a read.
func (l *limiter) failPolicyGet(key string) (int64, bool) {
if !l.failOpen {
return 0, false
}
v, _ := l.fallback.get(key)
return v, true
}
// incrCounter increments the (kind, id, metric, window) counter at t by n
// and returns its new value, together with whether the operation
// succeeded under the limiter's fail-open/fail-closed policy — the
// limiter-level single-key seeding helper tests use to seed counters
// directly (admin_test.go's TestAdminUsage_MathAgainstSeededCounters and
// others), implemented via the batched storeIncrMulti path.
func (l *limiter) incrCounter(kind, id, metric, window string, t time.Time, n int64, ttl time.Duration) (int64, bool) {
return l.storeIncrBy(windowKey(kind, id, metric, window, t), n, ttl)
}
// getCounter returns the (kind, id, metric, window) counter's value at t,
// together with whether the read succeeded under the limiter's fail-open/
// fail-closed policy, implemented via the batched storeGetMulti path.
func (l *limiter) getCounter(kind, id, metric, window string, t time.Time) (int64, bool) {
return l.storeGet(windowKey(kind, id, metric, window, t))
}
+184 -55
View File
@@ -354,8 +354,6 @@ type historyCountingStore struct {
getMultiCalls int
}
func (s *historyCountingStore) incrBy(string, int64, time.Duration) (int64, error) { return 0, nil }
func (s *historyCountingStore) get(string) (int64, error) { return 0, nil }
func (s *historyCountingStore) getMulti(keys []string) ([]int64, error) {
s.getMultiCalls++
out := make([]int64, len(keys))
@@ -387,6 +385,27 @@ func TestLimiterHistory_StoreDown(t *testing.T) {
}
}
// TestLimiterHistory_ConfiguredStoreDown_FailOpen_ReturnsNotOK is finding
// F5, 2026-09 review: with the default failOpen=true, storeGetMulti's own
// ok stays true during a store outage — it silently serves the in-process
// fallback instead — so history must ALSO check configuredStoreDown, not
// just ok, or a Redis blip would present the fallback's near-empty data
// as a genuine drop on the usage-history chart instead of the 503 this
// function's own doc comment promises. alwaysErrStore's first call opens
// the store-down latch (recordStoreFailure) synchronously, before
// configuredStoreDown is checked below, so this single call already
// exercises the fail-open-AND-latched path.
func TestLimiterHistory_ConfiguredStoreDown_FailOpen_ReturnsNotOK(t *testing.T) {
l := newLimiter(alwaysErrStore{}, true) // failOpen: true, unlike TestLimiterHistory_StoreDown above
points, ok := l.history("total", "all", "req", windowDay, time.Now(), 5)
if ok {
t.Fatal("want ok=false: the store is down even though failOpen silently served the fallback")
}
if points != nil {
t.Errorf("points = %+v, want nil", points)
}
}
// TestWithTotalScope_AppendsUnlimitedTotalScope asserts withTotalScope
// appends exactly one {kind: totalScopeKind, id: totalScopeID, limits:
// nil} scope after whatever buildLimitScopes already returned.
@@ -789,10 +808,8 @@ type erroringStore struct {
err error
}
func (s *erroringStore) incrBy(string, int64, time.Duration) (int64, error) { return 0, s.err }
func (s *erroringStore) get(string) (int64, error) { return 0, s.err }
func (s *erroringStore) getMulti([]string) ([]int64, error) { return nil, s.err }
func (s *erroringStore) incrMulti([]counterIncr) ([]int64, error) { return nil, s.err }
func (s *erroringStore) getMulti([]string) ([]int64, error) { return nil, s.err }
func (s *erroringStore) incrMulti([]counterIncr) ([]int64, error) { return nil, s.err }
func (s *erroringStore) incrAndGetMulti([]counterIncr, []string) ([]int64, []int64, error) {
return nil, nil, s.err
}
@@ -839,9 +856,9 @@ func TestLimiter_FailClosed_StoreErrorReturnsStoreDownViolation(t *testing.T) {
}
}
// succeedIncrFailGetStore is a counterStore stub whose incrBy/incrMulti
// always succeed and whose get/getMulti/incrAndGetMulti always error. Its
// incrMulti/get/getMulti stay independently overridable (used by any
// succeedIncrFailGetStore is a counterStore stub whose incrMulti always
// succeeds and whose getMulti/incrAndGetMulti always error. Its
// incrMulti/getMulti stay independently overridable (used by any
// OTHER caller — account, currentUsage, and so on — that still calls them
// separately) even though checkAndCount itself, since the round-3 fusion
// (2026-08-22), calls incrAndGetMulti exclusively: a store that cannot
@@ -852,10 +869,6 @@ type succeedIncrFailGetStore struct {
getErr error
}
func (s *succeedIncrFailGetStore) incrBy(string, int64, time.Duration) (int64, error) {
return 1, nil
}
func (s *succeedIncrFailGetStore) get(string) (int64, error) { return 0, s.getErr }
func (s *succeedIncrFailGetStore) getMulti([]string) ([]int64, error) {
return nil, s.getErr
}
@@ -964,16 +977,6 @@ type countingErrorStore struct {
getCalls int
}
func (s *countingErrorStore) incrBy(string, int64, time.Duration) (int64, error) {
s.incrCalls++
return 0, s.err
}
func (s *countingErrorStore) get(string) (int64, error) {
s.getCalls++
return 0, s.err
}
func (s *countingErrorStore) getMulti([]string) ([]int64, error) {
s.getCalls++
return nil, s.err
@@ -1145,19 +1148,31 @@ func TestLimiter_FailPolicyIncrBy(t *testing.T) {
}
}
// fixedStore is a counterStore stub whose incrBy/get always succeed with a
// fixed value, independent of key/n/ttl — used to prove storeIncrBy/
// storeGet return the STORE's own reported value on success, rather than
// silently substituting the in-process fallback's.
// fixedStore is a counterStore stub whose incrMulti/getMulti always
// succeed with a fixed value per entry/key, independent of key/n/ttl —
// used to prove storeIncrBy/storeGet (limits_helpers_test.go, built on
// the batched storeIncrMulti/storeGetMulti production paths) return the
// STORE's own reported value on success, rather than silently
// substituting the in-process fallback's.
type fixedStore struct {
incrVal int64
getVal int64
}
func (s *fixedStore) incrBy(string, int64, time.Duration) (int64, error) { return s.incrVal, nil }
func (s *fixedStore) get(string) (int64, error) { return s.getVal, nil }
func (s *fixedStore) getMulti([]string) ([]int64, error) { return nil, nil }
func (s *fixedStore) incrMulti([]counterIncr) ([]int64, error) { return nil, nil }
func (s *fixedStore) getMulti(keys []string) ([]int64, error) {
out := make([]int64, len(keys))
for i := range out {
out[i] = s.getVal
}
return out, nil
}
func (s *fixedStore) incrMulti(entries []counterIncr) ([]int64, error) {
out := make([]int64, len(entries))
for i := range out {
out[i] = s.incrVal
}
return out, nil
}
func (s *fixedStore) incrAndGetMulti([]counterIncr, []string) ([]int64, []int64, error) {
return nil, nil, nil
}
@@ -1345,8 +1360,7 @@ func TestLimiter_LogSpawnDrop_RateLimited(t *testing.T) {
// directly; checkAndCount, since the round-3 fusion (2026-08-22, perf
// review), calls incrAndGetMulti exclusively — so the two counters below
// track genuinely different callers, not the same round trip under two
// names. incrBy is never exercised by either caller and just returns a
// zero value.
// names.
type countingIncrStore struct {
values map[string]int64
incrMultiCalls int
@@ -1354,15 +1368,6 @@ type countingIncrStore struct {
getMultiCalls int
}
func (s *countingIncrStore) incrBy(string, int64, time.Duration) (int64, error) { return 0, nil }
func (s *countingIncrStore) get(key string) (int64, error) {
if s.values == nil {
return 0, nil
}
return s.values[key], nil
}
func (s *countingIncrStore) getMulti(keys []string) ([]int64, error) {
s.getMultiCalls++
out := make([]int64, len(keys))
@@ -1412,9 +1417,18 @@ func (s *countingIncrStore) incrAndGetMulti(entries []counterIncr, reads []strin
// batched reply must still drive correct evaluation — a real
// requestsPerMinute violation on the 3rd call, with the exact right
// counter value. Supersedes the pre-fusion incrMulti-only version of this
// test (perf review, 2026-08-21): checkAndCount no longer calls incrMulti
// at all, only incrAndGetMulti (account still does, covered separately
// below by TestAccount_OneIncrMultiCallRegardlessOfScopeOrMetricCount).
// test (perf review, 2026-08-21): checkAndCount's own ADMISSION path no
// longer calls incrMulti at all, only incrAndGetMulti (account still
// does, covered separately below by
// TestAccount_OneIncrMultiCallRegardlessOfScopeOrMetricCount).
//
// Updated (finding F1, 2026-09 review): a REJECTION now costs one further
// incrMulti call — rollbackOtherScopeCounts' own compensating batch,
// undoing the increments the fused round trip above already made for
// every scope OTHER than the one that actually violated. See
// TestCheckAndCount_RejectionRollsBackOtherScopes_MemoryFallback for the
// dedicated group-lockout regression this fixes; this test's own
// assertions below were updated to match the corrected counter values.
func TestCheckAndCount_OneIncrAndGetMultiCallRegardlessOfScopeCount(t *testing.T) {
store := &countingIncrStore{}
l := newLimiter(store, true)
@@ -1441,6 +1455,10 @@ func TestCheckAndCount_OneIncrAndGetMultiCallRegardlessOfScopeCount(t *testing.T
t.Errorf("incrAndGetMultiCalls after 2nd checkAndCount = %d, want 2 (one call per checkAndCount, not per scope or per counter)", store.incrAndGetMultiCalls)
}
if store.incrMultiCalls != 0 {
t.Errorf("incrMultiCalls after 2nd checkAndCount = %d, want 0 (no rollback — nothing violated yet)", store.incrMultiCalls)
}
v := l.checkAndCount(scopes)
if v == nil {
t.Fatal("3rd call should violate u's requestsPerMinute:2")
@@ -1451,23 +1469,30 @@ func TestCheckAndCount_OneIncrAndGetMultiCallRegardlessOfScopeCount(t *testing.T
if store.incrAndGetMultiCalls != 3 {
t.Errorf("incrAndGetMultiCalls after 3rd checkAndCount = %d, want 3", store.incrAndGetMultiCalls)
}
if store.incrMultiCalls != 0 {
t.Errorf("incrMultiCalls = %d, want 0 (checkAndCount must never call the separate incrMulti after fusion)", store.incrMultiCalls)
// Finding F1, 2026-09 review: a violation now fires ONE compensating
// incrMulti call (rollbackOtherScopeCounts) to undo the OTHER
// scopes' increments for this rejected request — see this test's own
// updated value assertions below for why.
if store.incrMultiCalls != 1 {
t.Errorf("incrMultiCalls after the violation = %d, want 1 (rollbackOtherScopeCounts' own compensating batch)", store.incrMultiCalls)
}
// Values correct: batching three scopes into one call must not
// corrupt any one scope's own counter.
// corrupt any one scope's own counter, AND (finding F1) the group and
// total scopes' req:min/day/hour counters must be rolled back to what
// they were before this rejected 3rd request — only the VIOLATING
// scope ("user") keeps counting a request that was never admitted.
uMin, ok := l.getCounter("user", "u", metricReq, windowMin, now)
if !ok || uMin != 3 {
t.Errorf("user req:min = %d, ok=%v, want 3", uMin, ok)
t.Errorf("user req:min = %d, ok=%v, want 3 (the violating scope's own count is not rolled back)", uMin, ok)
}
totalMin, ok := l.getCounter(totalScopeKind, totalScopeID, metricReq, windowMin, now)
if !ok || totalMin != 3 {
t.Errorf("total req:min = %d, ok=%v, want 3", totalMin, ok)
if !ok || totalMin != 2 {
t.Errorf("total req:min = %d, ok=%v, want 2 (rolled back — the total scope had nothing to do with user u's own violation)", totalMin, ok)
}
gDay, ok := l.getCounter("group", "g", metricReq, windowDay, now)
if !ok || gDay != 3 {
t.Errorf("group req:day = %d, ok=%v, want 3", gDay, ok)
if !ok || gDay != 2 {
t.Errorf("group req:day = %d, ok=%v, want 2 (rolled back)", gDay, ok)
}
}
@@ -1712,6 +1737,64 @@ func TestCheckAndCount_FusedRoundTrip_MemoryFallback(t *testing.T) {
assert.Contains(t, v.message, "tokens-per-day")
}
// TestCheckAndCount_RejectionRollsBackOtherScopes_MemoryFallback is
// finding F1, 2026-09 review, the group-lockout bug: a user hammering
// past THEIR OWN requests-per-minute limit must not also drive up their
// group's identical counter on every rejected attempt — before this fix,
// checkAndCount's unconditional per-scope counting had no rollback, so
// enough rejected user-scope requests could push the group's own limit
// past its threshold too, locking out every OTHER member of the group
// from traffic that was never actually admitted.
func TestCheckAndCount_RejectionRollsBackOtherScopes_MemoryFallback(t *testing.T) {
l := newLimiter(nil, true) // nil store -> every operation uses the in-process fallback
now := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
l.nowFn = func() time.Time { return now }
scopes := []limitScope{
{kind: "user", id: "u", limits: &LimitsConfig{RequestsPerMinute: 1}},
{kind: "group", id: "g", limits: &LimitsConfig{RequestsPerMinute: 100}},
}
// 1st call: user count 1 <= its own limit of 1, group count 1 <= its
// own limit of 100 — admitted.
if v := l.checkAndCount(scopes); v != nil {
t.Fatalf("1st call: want no violation, got %+v", v)
}
// 2nd call: user count reaches 2, over its own limit of 1 — rejected.
// Without the fix, the group's own req:min counter would also land at
// 2 here and stay there, even though the group never actually
// admitted a 2nd request.
v := l.checkAndCount(scopes)
if v == nil {
t.Fatal("2nd call: want a requests-per-minute violation on the user scope")
}
assert.Contains(t, v.message, "requests-per-minute")
groupMin, ok := l.getCounter("group", "g", metricReq, windowMin, now)
if !ok || groupMin != 1 {
t.Errorf("group req:min after the user's rejection = %d, ok=%v, want 1 (rolled back)", groupMin, ok)
}
groupDay, ok := l.getCounter("group", "g", metricReq, windowDay, now)
if !ok || groupDay != 1 {
t.Errorf("group req:day after the user's rejection = %d, ok=%v, want 1 (rolled back)", groupDay, ok)
}
userMin, ok := l.getCounter("user", "u", metricReq, windowMin, now)
if !ok || userMin != 2 {
t.Errorf("user req:min after its own rejection = %d, ok=%v, want 2 (the violating scope's own count is not rolled back)", userMin, ok)
}
// A 3rd request, from a DIFFERENT user in the SAME group, must still
// be admitted — proving the group is not locked out by the first
// user's rejected attempts.
otherUserScopes := []limitScope{
{kind: "user", id: "other", limits: &LimitsConfig{RequestsPerMinute: 1}},
{kind: "group", id: "g", limits: &LimitsConfig{RequestsPerMinute: 100}},
}
if v := l.checkAndCount(otherUserScopes); v != nil {
t.Fatalf("a different user in the same group: want no violation (group must not be locked out), got %+v", v)
}
}
// --- review round 2, 2026-08-21: enforcement-aware fallback TTL clamp ---
// TestClampTTL_ExactMathTable pins clampTTL's ceiling-vs-floor precedence
@@ -1913,6 +1996,20 @@ func TestRecordProviderAttempt_ClassifiesUsingIsTransient(t *testing.T) {
if modelFails != wantFails {
t.Errorf("model fails/day = %d, want %d", modelFails, wantFails)
}
// Dead-code sweep, 2026-09 review: recordProviderAttempt no
// longer writes a (provider, model) scope's MINUTE-window
// counters — providerCounterKeys has never read one
// (SHOULD-2, v0.22 review round), so those writes were pure
// cost with no reader anywhere in this codebase.
modelAttemptsMin, _ := l.getCounter(kindProviderModel, "openai/gpt-4o", metricProvAttempt, windowMin, now)
if modelAttemptsMin != 0 {
t.Errorf("model attempts/min = %d, want 0 (no longer written)", modelAttemptsMin)
}
modelFailsMin, _ := l.getCounter(kindProviderModel, "openai/gpt-4o", metricProvFail, windowMin, now)
if modelFailsMin != 0 {
t.Errorf("model fails/min = %d, want 0 (no longer written)", modelFailsMin)
}
})
}
}
@@ -2454,8 +2551,6 @@ type recordingStore struct {
entries []counterIncr
}
func (s *recordingStore) incrBy(string, int64, time.Duration) (int64, error) { return 0, nil }
func (s *recordingStore) get(string) (int64, error) { return 0, nil }
func (s *recordingStore) getMulti(keys []string) ([]int64, error) {
return make([]int64, len(keys)), nil
}
@@ -2516,3 +2611,37 @@ func TestCounterIncrEntries_WindowBucketTTLInvariant(t *testing.T) {
})
}
}
// TestModelTotals_ConfiguredStoreDown_FailOpen_ReturnsNotOK is finding
// F5, 2026-09 review — modelTotals' own counterpart of
// TestLimiterHistory_ConfiguredStoreDown_FailOpen_ReturnsNotOK above: with
// failOpen=true, a store outage must still report ok=false via
// configuredStoreDown rather than silently presenting the empty fallback
// as a genuine "these models were barely used" ranking.
func TestModelTotals_ConfiguredStoreDown_FailOpen_ReturnsNotOK(t *testing.T) {
l := newLimiter(alwaysErrStore{}, true)
got, ok := l.modelTotals([]string{"a/one"}, metricCost, windowDay)
if ok {
t.Fatal("want ok=false: the store is down even though failOpen silently served the fallback")
}
if got != nil {
t.Errorf("got = %+v, want nil", got)
}
}
// TestTargetUsage_ConfiguredStoreDown_FailOpen_ReturnsZeroCounters is
// finding F5, 2026-09 review — targetUsage's own counterpart: with
// failOpen=true, a store outage must fall back to the same zero-value
// targetCounters this function's own doc comment already promises for
// the fail-closed case, rather than presenting the empty in-process
// fallback's data as confirmed current usage.
func TestTargetUsage_ConfiguredStoreDown_FailOpen_ReturnsZeroCounters(t *testing.T) {
l := newLimiter(alwaysErrStore{}, true)
got := l.targetUsage([]limitScope{{kind: targetKindMCP, id: "alpha"}})
if len(got) != 1 {
t.Fatalf("len(got) = %d, want 1", len(got))
}
if got[0] != (targetCounters{}) {
t.Errorf("got[0] = %+v, want the zero value", got[0])
}
}
+131 -154
View File
@@ -11,6 +11,7 @@ import (
"net/http"
"os"
"runtime"
"strings"
"sync"
"time"
@@ -62,8 +63,15 @@ type Config struct {
// — distinct from Pricing above, which drives request cost
// ACCOUNTING (costMicros); ModelMeta drives metadata EXPOSURE
// (GET /v1/models' context_window/pricing extension fields, the
// admin dashboard) and never affects billing. nil/omitted preserves
// prior behavior exactly: every model's metadata falls through to
// admin dashboard). The one exception (verify-core fix, round 4):
// an entry marked Free is ALSO honoured by billing itself
// (unifiedCostMicros, via modelMetaFree) — cost is 0 and known,
// never a real price the model's bare id happens to share with the
// built-in table, so the 402 cost-budget guard (priceKnown, which
// already treated Free the same way) and actual billing can never
// disagree about the same model. Every other ModelMeta field still
// only drives exposure, never billing. nil/omitted preserves prior
// behavior exactly: every model's metadata falls through to
// discovery/builtin/absent, same as before this feature existed.
// Validated at construction (validateModelMeta, modelmeta.go).
ModelMeta map[string]*ModelMetaConfig `json:"modelMeta,omitempty"`
@@ -607,14 +615,13 @@ const (
// A 256 ceiling therefore does not prevent the OOM this semaphore exists
// to prevent: it is above any realistic pod budget. It is still strictly
// better than the unbounded base (25GB on the same burst), but "better
// than unbounded" was never the goal. The reference deployment runs
// Traefik with resources.limits.memory: 512Mi, where even the 8-slot
// floor is the binding constraint, not the ceiling.
// than unbounded" was never the goal.
//
// So the bounds are cut to [8, 64]: 64 keeps a large-memory host from
// being throttled below what it can actually serve, 8 keeps a small pod
// from admitting more concurrent decodes than its heap can hold. This
// costs legitimate throughput almost nothing because the slot is now
// being throttled below what it can actually serve, 8 sets a floor for
// the self-tuned VALUE on a degenerate host (see the correction below for
// why that floor is not actually the binding constraint on a real one).
// This costs legitimate throughput almost nothing because the slot is now
// held ONLY across read+decode (round-3 fix) — microseconds to low
// milliseconds — not across the upstream call; an operator whose host
// genuinely wants more sets Config.MaxInFlightBodyRequests explicitly,
@@ -623,6 +630,28 @@ const (
// via goMemLimitPercentage) is the better long-term shape and is left as
// a follow-up: it needs its own Yaegi-interpreted verification, since
// runtime/debug's availability under the interpreter is unproven here.
//
// CORRECTION (review-auth finding F3, 2026-09 review — the paragraph
// above, and the reference-deployment claim it used to carry, both
// overstated what the 8-slot floor actually protects): defaultBodyAdmissionCap
// computes n = GOMAXPROCS(0) * bodyAdmissionCapPerCPU (24, below) and only
// clamps DOWN to minBodyAdmissionCap when n itself is smaller than 8.
// Since GOMAXPROCS is always >= 1 on any real process, n is always >= 24
// — the 8-slot floor can therefore never actually bind on a real host; it
// only guards a degenerate GOMAXPROCS<1 read defaultBodyAdmissionCap has
// no other reason to expect. The reference deployment's real self-tuned
// cap, at 1 vCPU, is 24, not 8: linearly interpolating the measured table
// above between its 16 and 32 points (not separately measured) puts a
// 24-slot worst case around ~2.4GB peak HeapInuse — five times the
// resources.limits.memory: 512Mi the earlier text claimed this floor kept
// the pod within. Preserving today's actual numeric behavior (house rule:
// additive and default-preserving — lowering bodyAdmissionCapPerCPU or
// minBodyAdmissionCap here would be a real throughput regression for
// every existing deployment, not a comment fix), this correction changes
// no constant: an operator who needs the semaphore to actually hold a
// small pod within 512Mi under this worst case must set
// Config.MaxInFlightBodyRequests explicitly (for example 4-6), which
// always wins over the self-tuned value regardless of GOMAXPROCS.
// bodyAdmissionCapPerCPU is defaultBodyAdmissionCap's GOMAXPROCS
// multiplier (revised round 3 — see minBodyAdmissionCap's own doc
@@ -696,144 +725,28 @@ const maxExplicitBodyAdmissionCap = 10_000
//
//nolint:govet
type Gateway struct {
// targetHealthLastSweepUnixNano MUST stay the first field (F7,
// feat/target-health review). It backs maybeSweepTargetHealth's
// single-flight gate (target_health.go) and is read/written only via
// sync/atomic's 64-bit functions (atomic.LoadInt64/StoreInt64) — the
// sync/atomic package documents that "on ARM, x86-32, and 32-bit
// MIPS, it is the caller's responsibility to arrange for 64-bit
// alignment of 64-bit words accessed atomically," and guarantees
// that alignment only for "the first word in an allocated struct,
// array, or slice." Traefik ships armv7 and 386 builds alongside
// amd64/arm64, so this is not a hypothetical: any field placed
// ahead of this one could silently misalign it on those platforms.
// Every field below is ordered by golangci-lint's fieldalignment
// linter (pointer-containing fields grouped first, for GC scan
// efficiency, then the rest) — this one field is the sole, deliberate
// exception. Total struct size stays 264 bytes either way
// (unsafe.Sizeof(Gateway{}), verified before and after this field was
// pinned first), so the exception costs no padding. It does cost
// fieldalignment's separate pointer-bytes hint: `fieldalignment
// ./...` reports "Gateway has 192 leading bytes of pointer data but
// optimal value is 184" with this field first, against a clean
// report at the commit before it moved. Moving targetHealthSweeping
// (below) to sit directly after this field, to try to close that
// gap, was tried and makes the report worse, not better (200 vs
// 184), so targetHealthSweeping stays with the rest of the plain
// scalars. The 8-byte pointer-bytes gap is accepted deliberately:
// the atomic-alignment requirement below wins over the linter hint.
targetHealthLastSweepUnixNano int64
next http.Handler
// redisClient is the same instance newGateway hands to both the
// limiter's redisStore and the response cache (its own doc comment,
// below, explains why it's built once and shared) — kept here too,
// on Gateway itself, purely so Close (below) has something to release
// pooled connections through. nil when config.Redis is absent, same
// as buildRedisClient's own nil-for-unconfigured contract.
redisClient *respClient
// failoverHealth is feat/failover's own per-pod, in-memory
// request-path health signal (failover.go), built once, here, by
// newGateway. Every method on it is nil-receiver-safe, so a Gateway
// assembled directly (bypassing newGateway, as a few older tests do)
// degrades to "failover never skips a candidate for request health"
// rather than a nil-pointer panic.
failoverHealth *requestHealthTracker
// targetHealth is feat/target-health's own per-pod, in-memory health
// tracker for MCP servers and A2A agents (target_health.go) — always
// constructed, here, regardless of Config.TargetHealth.Enabled:
// passive recording (handleTargetProxy, mcp_a2a.go;
// mcpFederatedToolsList/mcpFederatedToolsCall, mcp_federation.go) and
// exposure (GET /admin/api/targets, llmgateway_target_healthy) both
// stay on unconditionally, mirroring g.latency/g.provenance's own
// "always-present, usually-empty, nil-receiver-safe" convention
// below. Enabled gates only maybeSweepTargetHealth's own active
// probe sweep.
targetHealth *targetHealthTracker
limiter *limiter
registry *modelRegistry
adapters map[string]providerAdapter
cfg *Config
auth *authStore
// provenance is the in-process usage-accounting-provenance accumulator
// (feat: expose token-accounting provenance, metrics.go's
// provenanceStore) — recordUsageProvenance's only write target, and
// writeProvenanceMetrics/buildAdminProvenanceViews' (admin.go) own
// read source. Always constructed, here, by newGateway, even when
// Config.Metrics is nil or disabled, mirroring g.latency's own
// identical "always-present, usually-empty, nil-receiver-safe"
// convention immediately below — see that field's own doc comment for
// the full reasoning, which applies here unchanged.
provenance *provenanceStore
// cache is nil whenever response caching is not configured or not
// usable (cfg.Cache.Enabled is false, or true with no config.Redis —
// see buildResponseCache, cache.go). Every call site checks for nil
// before using it, rather than responseCache having its own
// always-disabled zero value.
cache *responseCache
// targetClient is the shared, connection-pooled *http.Client the
// MCP/A2A target proxy (mcp_a2a.go) issues every upstream request
// through — built once via newAdapterHTTPClient, the same constructor
// each provider adapter uses for its own client.
targetClient *http.Client
// latency is the in-process upstream-latency accumulator (feat:
// instrument upstream latency, metrics.go's latencyStore) —
// recordLatency's only write target, and writeLatencyMetrics/
// buildAdminOverview's (admin.go) own read source. Always
// constructed, here, by newGateway, even when Config.Metrics is nil
// or disabled: g.latency simply never receives anything to
// accumulate in that case (every withLatencyRecorder call site gates
// its own wiring on metricsEnabled(g.cfg), not this field's
// existence) — every method on it is also nil-receiver-safe
// (latencyStore's own doc comment), matching failoverHealth's
// identical "safe even off a bare &Gateway{} literal" convention
// above.
latency *latencyStore
// bodyAdmission is the buffered-channel semaphore acquireBodyAdmission
// (routes_unified.go) claims from and releases: security review
// finding 1b, 2026-08-22. Sized once, here, by newGateway (see
// defaultBodyAdmissionCap/Config.MaxInFlightBodyRequests) — never
// resized afterward, matching a Go channel's own fixed-capacity
// contract.
bodyAdmission chan struct{}
name string
// failoverLogGate rate-limits runMeteredCall's generic "failing over"
// log line (routes_unified.go) to once per storeErrorLogEvery
// (adversarial-review fix, F10) — reuses auth.go's own logGate type,
// the identical technique shouldLogAuthFailure/logStoreError already
// apply elsewhere in this package. Zero-value-usable: the first call
// on a fresh Gateway always logs. Deliberately does NOT gate the 404
// loud-log line (operator ruling: that one must always log).
failoverLogGate logGate
// metricsNets is Config.Metrics.AllowedCIDRs, parsed once at
// construction (parseMetricsCIDRs, metrics.go) so the request path
// never re-parses a CIDR string per scrape. nil when Metrics is
// unconfigured or carries no AllowedCIDRs entries.
metricsNets []*net.IPNet
// targetHealthCfg is Config.TargetHealth, validated and resolved once
// by validateTargetHealthConfig (target_health.go) — maybeSweepTargetHealth
// reads probeInterval/enabled from this, never the raw Config.
targetHealthCfg targetHealthConfig
// failover is Config.Failover, validated and resolved once by
// newGateway (validateFailoverConfig, failover.go).
failover failoverConfig
// targetTimeout is targetClient's own resolved request timeout —
// Config.RequestTimeout only; an MCP/A2A target has no per-target
// override the way a provider does. proxyUpstream's own callers
// (mcp_a2a.go's handleTargetProxy) pass this through so its
// idle-progress body watchdog (timeout.go) matches the
// ResponseHeaderTimeout already set on targetClient's Transport.
targetTimeout time.Duration
// targetHealthSweeping is maybeSweepTargetHealth's own single-flight
// CAS flag (target_health.go) — its sibling, the UnixNano timestamp
// this same gate reads, is targetHealthLastSweepUnixNano, kept as
// this struct's FIRST field instead (see that field's own doc
// comment, top of this struct, for why). Both are atomic rather than
// mutex-guarded, since every caller (serveMetrics, serveAdminTargets,
// handleMCPServers, handleAgents) reaches maybeSweepTargetHealth on a
// live request path and must never block behind a lock another such
// call already holds. A plain int32 needs no special alignment
// treatment the way its int64 sibling does.
targetHealthSweeping int32
provenance *provenanceStore
targetClient *http.Client
failoverHealth *requestHealthTracker
targetHealth *targetHealthTracker
limiter *limiter
registry *modelRegistry
adapters map[string]providerAdapter
cfg *Config
auth *authStore
bodyAdmission chan struct{}
redisClient *respClient
latency *latencyStore
cache *responseCache
name string
failoverLogGate logGate
metricsNets []*net.IPNet
targetHealthCfg targetHealthConfig
failover failoverConfig
targetHealthLastSweepUnixNano int64
targetTimeout time.Duration
targetHealthSweeping int32
}
// telemetryStartupOnce keeps the anonymous "plugin loaded" ping to one per
@@ -936,6 +849,17 @@ func newGateway(ctx context.Context, next http.Handler, config *Config, name str
if err := validateTargetURLs(config); err != nil {
return nil, err
}
// Finding F4, 2026-09 review: validate every Config.Pricing override
// at construction — non-nil, finite, >= 0 — rather than letting a
// negative/NaN/Inf entry reach costMicrosKnownFor (pricing.go), where it
// would still pass the "is this model priced" check yet silently
// disable that model's costPerDayUSD/costPerMonthUSD budget forever.
// See validatePricing's own doc comment for the full reasoning; the
// returned map is intentionally discarded — validatePricing returns
// raw unchanged on success, so there is nothing to assign back.
if _, err := validatePricing(config.Pricing); err != nil {
return nil, err
}
auth, err := newAuthStore(config)
if err != nil {
return nil, err
@@ -1000,6 +924,42 @@ func newGateway(ctx context.Context, next http.Handler, config *Config, name str
}
g.metricsNets = metricsNets
// review-auth finding F9, 2026-09 review: metrics.path was never
// validated at all. A value with no leading "/" can never match
// ServeHTTP's exact r.URL.Path comparison for the metrics route
// (metrics.go's metricsPath/handleMetrics), silently making the
// endpoint unreachable — kept as a hard construction error, below.
//
// verify-core fix (round 4): a value inside one of the gateway's
// other fixed route namespaces (reservedRoutePrefix, below) used to
// be a hard construction error too, but that broke real, previously
// WORKING configs on upgrade: ServeHTTP matches the exact metrics
// path (a GET, handleMetrics) before it ever reaches the /v1, /mcp,
// /a2a or admin dispatch branches — see ServeHTTP's own routing
// order — so "/v1/metrics" (and any other path merely sitting under
// one of those prefixes) already routed correctly and only ever
// shadows THAT one exact path, never the reverse. Refusing to build
// the middleware for a config that worked yesterday violates the
// house rule that an upgrade with no config change preserves prior
// behavior. This now only warns, so an operator who genuinely wants
// "/v1/metrics" keeps it working, while still being told it shadows
// the real /v1/... route at that one path.
if config.Metrics != nil && config.Metrics.Enabled && config.Metrics.Path != "" {
if !strings.HasPrefix(config.Metrics.Path, "/") {
return nil, fmt.Errorf("llmgateway: metrics: path must start with \"/\", got %q", config.Metrics.Path)
}
// ServeHTTP matches GET /v1/models and the admin GET paths BEFORE
// the metrics route, so a metrics.path equal to one of those is
// unreachable; any other path under a reserved prefix is reached
// first by metrics and shadows that one route instead.
switch {
case config.Metrics.Path == "/v1/models" || (adminEnabled(config) && isAdminPath(config.Metrics.Path)):
g.warnf("config: metrics.path %q is matched earlier by another GET route; the metrics endpoint is unreachable at this path", config.Metrics.Path)
case reservedRoutePrefix(config.Metrics.Path):
g.warnf("config: metrics.path %q sits at or under a reserved route prefix (%s); metrics is served at this exact path instead of the route normally there — confirm it is intentional", config.Metrics.Path, strings.Join(reservedRoutePrefixes, ", "))
}
}
// bodyAdmissionCap: an explicit MaxInFlightBodyRequests always wins
// over the self-tuned default (house rule: explicit override always
// wins) — see Config.MaxInFlightBodyRequests' own doc comment. 0 (the
@@ -1040,7 +1000,7 @@ func newGateway(ctx context.Context, next http.Handler, config *Config, name str
lim.logf = g.errorf
g.limiter = lim
cache, err := buildResponseCache(config.Cache, redisClient, g.logf, g.errorf)
cache, err := buildResponseCache(config.Cache, redisClient, g.logf, g.warnf, g.errorf)
if err != nil {
return nil, err
}
@@ -1085,17 +1045,34 @@ func newGateway(ctx context.Context, next http.Handler, config *Config, name str
registry.warmFill(ctx)
g.registry = registry
setPricingWarnFn(func(msg string) {
if msg == warnCapMessage {
g.logf("%s", msg)
return
}
g.logf("pricing: no price configured for model %q; cost will be recorded as 0", msg)
})
return g, nil
}
// reservedRoutePrefixes are the path namespaces ServeHTTP already fixes:
// the admin dashboard (adminPagePath, admin.go), the OpenAI-compatible
// "/v1/..." routes, the MCP federation/target-proxy "/mcp/..." routes
// (mcp_federation.go/mcp_a2a.go), and the A2A target-proxy "/a2a/..."
// routes (mcp_a2a.go). reservedConfigNames (providers.go) already
// reserves "v1"/"mcp"/"a2a" as PROVIDER names for the identical
// collision reason; this is metrics.path's own counterpart of that check
// (review-auth finding F9, 2026-09 review — newGateway, above). Used only
// to WARN, not to refuse construction (verify-core fix, round 4):
// ServeHTTP matches the exact metrics path before dispatching into any of
// these namespaces, so a metrics.path sitting under one only ever shadows
// that one exact path — not a hazard worth breaking a working config over.
var reservedRoutePrefixes = []string{adminPagePath, "/v1", "/mcp", "/a2a"}
// reservedRoutePrefix reports whether path equals, or sits under, one of
// reservedRoutePrefixes.
func reservedRoutePrefix(path string) bool {
for _, prefix := range reservedRoutePrefixes {
if path == prefix || strings.HasPrefix(path, prefix+"/") {
return true
}
}
return false
}
// buildRedisClient validates rc and returns a respClient for it, or nil
// when rc is nil (Redis not configured). Split out of what was previously
// newConfiguredLimiter's own inline construction so newGateway can share
+85 -9
View File
@@ -794,8 +794,8 @@ func TestNewGateway_RedisPassword_ResolvedViaSecret(t *testing.T) {
if !ok {
t.Fatalf("gw.limiter.store = %#v (%T), want *redisStore", gw.limiter.store, gw.limiter.store)
}
if _, err := store.get("k"); err != nil {
t.Fatalf("store.get: %v (want the resolved password to authenticate against the fake server)", err)
if _, err := getOne(store, "k"); err != nil {
t.Fatalf("store.getMulti: %v (want the resolved password to authenticate against the fake server)", err)
}
}
@@ -813,10 +813,87 @@ func TestNewGateway_RedisPassword_UnresolvableSecret_ReturnsConstructorError(t *
}
}
// TestNewGateway_SetsPricingWarnFn is carried-item (d)'s pricing wiring:
// New must call setPricingWarnFn so an unpriced model's warning reaches
// the gateway's own log, not pricing.go's default no-op.
func TestNewGateway_SetsPricingWarnFn(t *testing.T) {
// TestNewGateway_InvalidPricingOverride_ReturnsConstructorError is finding
// F4, 2026-09 review: New must reject a negative Config.Pricing entry at
// construction (validatePricing, pricing.go) rather than letting it reach
// costMicrosKnown and silently disable that model's cost budget forever.
func TestNewGateway_InvalidPricingOverride_ReturnsConstructorError(t *testing.T) {
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.Pricing = map[string]*ModelPricing{"gpt-5": {InputPerM: -1, OutputPerM: 1}}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
if _, err := New(context.Background(), next, cfg, "llmgw"); err == nil {
t.Fatal("want a constructor error for a negative pricing.inputPerM")
}
}
// TestNewGateway_MetricsPath_Validation is review-auth finding F9,
// 2026-09 review, corrected by the verify-core round-4 fix: metrics.path
// must still start with "/" (a hard construction error — a value with no
// leading "/" can never match ServeHTTP's route comparison, silently
// making the endpoint unreachable), but a path sitting at or under a
// reserved route prefix (/admin, /v1, /mcp, /a2a) is no longer refused.
// ServeHTTP matches the metrics route by exact path before it ever
// dispatches into those namespaces, so a config like "/v1/metrics"
// already routed correctly before finding F9's fix existed at all —
// refusing to build the middleware for it broke a previously working
// config on upgrade. It now only logs a WARN line naming the shadowed
// prefix.
func TestNewGateway_MetricsPath_Validation(t *testing.T) {
cases := []struct {
path string
wantErr bool
wantWarn bool
}{
{"/internal/metrics", false, false},
{"", false, false}, // empty resolves to the default "/metrics"
{"metrics", true, false}, // no leading "/": still a hard error
{"/admin", false, true}, // exact collision: now just a warning
{"/admin/api/usage", false, true}, // under the admin namespace
{"/v1/metrics", false, true}, // under the OpenAI-compatible namespace — previously working; must still build
{"/mcp/prometheus", false, true}, // under the MCP target-proxy namespace
{"/a2a/prometheus", false, true}, // under the A2A target-proxy namespace
{"/administrivia", false, false}, // string prefix only, not a path-segment collision
{"/v1/models", false, true}, // GET /v1/models is matched before metrics: unreachable
}
for _, c := range cases {
t.Run(c.path, func(t *testing.T) {
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}
cfg.Metrics = &MetricsConfig{Enabled: true, Path: c.path}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
origStderr := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
_, err := New(context.Background(), next, cfg, "llmgw")
_ = w.Close() // closing the pipe write end to unblock the read; error not actionable in a test
os.Stderr = origStderr
var buf bytes.Buffer
if _, copyErr := io.Copy(&buf, r); copyErr != nil {
t.Fatalf("io.Copy: %v", copyErr)
}
if (err != nil) != c.wantErr {
t.Errorf("New with metrics.path = %q: err = %v, wantErr %v", c.path, err, c.wantErr)
}
if gotWarn := strings.Contains(buf.String(), "llmgw[llmgw] WARN config: metrics.path"); gotWarn != c.wantWarn {
t.Errorf("New with metrics.path = %q: shadow warning logged = %v, want %v (stderr: %q)", c.path, gotWarn, c.wantWarn, buf.String())
}
wantUnreachable := c.path == "/v1/models"
if gotUnreachable := strings.Contains(buf.String(), "unreachable"); gotUnreachable != wantUnreachable {
t.Errorf("New with metrics.path = %q: unreachable warning = %v, want %v (stderr: %q)", c.path, gotUnreachable, wantUnreachable, buf.String())
}
})
}
}
// TestGateway_PricingWarn_LogsUnderOwnInstance: an unpriced model's
// warning routed through g.pricingWarn reaches that gateway's own log
// prefix (finding F11 / review-auth F8: no package-global warn func).
func TestGateway_PricingWarn_LogsUnderOwnInstance(t *testing.T) {
warnedModelsMu.Lock()
prevWarned, prevCapNotified := warnedModels, warnCapNotified
warnedModels = map[string]bool{}
@@ -826,7 +903,6 @@ func TestNewGateway_SetsPricingWarnFn(t *testing.T) {
warnedModelsMu.Lock()
warnedModels, warnCapNotified = prevWarned, prevCapNotified
warnedModelsMu.Unlock()
setPricingWarnFn(func(string) {})
})
cfg := CreateConfig()
@@ -836,13 +912,13 @@ func TestNewGateway_SetsPricingWarnFn(t *testing.T) {
if err != nil {
t.Fatalf("New: %v", err)
}
_ = h.(*Gateway)
gw := h.(*Gateway)
origStderr := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
costMicros("totally-unpriced-model-for-newgateway-test", usage{prompt: 1}, nil)
costMicrosKnownFor("totally-unpriced-model-for-newgateway-test", usage{prompt: 1}, nil, gw.pricingWarn)
_ = w.Close() // closing the pipe write end to unblock the read; error not actionable in a test
os.Stderr = origStderr
+18
View File
@@ -31,6 +31,24 @@ func (g *Gateway) errorf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "llmgw[%s] ERROR %s\n", g.name, fmt.Sprintf(format, args...))
}
// pricingWarn is g's own per-instance pricing-warning sink, passed to
// costMicrosKnownFor (pricing.go) so an unpriced-model warning always logs
// under this instance's `llmgw[name]` prefix (finding F11 / review-auth
// F8, 2026-09 review: a package-level warn func replaced on every New()
// call made the most recently constructed instance log every other
// instance's warnings).
//
// A plain method, not a stored closure field: it reads g.name fresh on
// every call, exactly like logf/warnf/errorf above, so there is nothing
// new to keep in sync across a config reload that rebuilds g.
func (g *Gateway) pricingWarn(msg string) {
if msg == warnCapMessage {
g.logf("%s", msg)
return
}
g.logf("pricing: no price configured for model %q; cost will be recorded as 0", msg)
}
// logAuthEvent logs one authentication attempt, at the point one of
// authStore.identify's six call sites resolves it. A success logs the
// resolved user's name alongside the request's method and path. Neither
+135 -36
View File
@@ -629,26 +629,37 @@ func (g *Gateway) writeProviderMetrics(m *metricWriter) {
counters := g.limiter.providerUsage(scopes)
providerCounts := counters[:len(snaps)]
// A storeDown counter is a failed-closed read (limits.go's
// providerUsage), not a real zero (review fix, adversarial
// verification 2026-08-23): emitting it as a hard 0 here is exactly
// the bug writeUsageMetrics' own storeDown guard above already avoids
// for the request/token/cost families — a Redis blip would otherwise
// paint a phantom counter-reset spike the instant the store recovers
// and this value jumps back to its real total. Skip the series
// entirely; a temporary gap is the honest signal, a fabricated 0 is
// not.
//
// Each family below is written as its own complete pass over snaps
// (L7 fix): llmgateway_provider_attempts_total's samples must not
// interleave with llmgateway_provider_failures_total's — the
// exposition format requires one metric's lines to form a single
// contiguous group.
m.family("llmgateway_provider_attempts_total", "counter",
"Total upstream attempts today (UTC calendar day; resets at UTC midnight), by provider."+aggregationNoteStoreBacked)
m.family("llmgateway_provider_failures_total", "counter",
"Total upstream attempts today classified as a provider fault (transport error, 429, or 5xx — the same classification recordProviderAttempt/isTransient apply), by provider."+aggregationNoteStoreBacked)
for i, s := range snaps {
// A storeDown counter is a failed-closed read (limits.go's
// providerUsage), not a real zero (review fix, adversarial
// verification 2026-08-23): emitting it as a hard 0 here is
// exactly the bug writeUsageMetrics' own storeDown guard above
// already avoids for the request/token/cost families — a Redis
// blip would otherwise paint a phantom counter-reset spike the
// instant the store recovers and this value jumps back to its
// real total. Skip the series entirely; a temporary gap is the
// honest signal, a fabricated 0 is not.
if providerCounts[i].storeDown {
continue
}
labels := []metricLabel{{"provider", s.name}}
m.sampleInt("llmgateway_provider_attempts_total", labels, providerCounts[i].attemptsDay)
m.sampleInt("llmgateway_provider_failures_total", labels, providerCounts[i].failuresDay)
m.sampleInt("llmgateway_provider_attempts_total", []metricLabel{{"provider", s.name}}, providerCounts[i].attemptsDay)
}
m.family("llmgateway_provider_failures_total", "counter",
"Total upstream attempts today classified as a provider fault (transport error, 429, or 5xx — the same classification recordProviderAttempt/isTransient apply), by provider."+aggregationNoteStoreBacked)
for i, s := range snaps {
if providerCounts[i].storeDown {
continue
}
m.sampleInt("llmgateway_provider_failures_total", []metricLabel{{"provider", s.name}}, providerCounts[i].failuresDay)
}
m.family("llmgateway_provider_healthy", "gauge",
@@ -666,10 +677,10 @@ func (g *Gateway) writeProviderMetrics(m *metricWriter) {
}
modelCounts := counters[len(snaps):]
// Each family below is again its own complete pass over snaps/models
// (L7 fix), same reasoning as the provider-level pair above.
m.family("llmgateway_provider_model_attempts_total", "counter",
"Total upstream attempts today (UTC calendar day; resets at UTC midnight), by provider and model. Opt-in via metrics.modelLabel — see MetricsConfig's own doc comment for the cardinality trade-off."+aggregationNoteStoreBacked)
m.family("llmgateway_provider_model_failures_total", "counter",
"Total upstream attempts today classified as a provider fault, by provider and model. Opt-in via metrics.modelLabel."+aggregationNoteStoreBacked)
mi := 0
for _, s := range snaps {
for _, model := range s.models {
@@ -678,9 +689,21 @@ func (g *Gateway) writeProviderMetrics(m *metricWriter) {
if mc.storeDown { // same fabricated-reset hazard as the provider-level loop above
continue
}
labels := []metricLabel{{"provider", s.name}, {"model", model}}
m.sampleInt("llmgateway_provider_model_attempts_total", labels, mc.attemptsDay)
m.sampleInt("llmgateway_provider_model_failures_total", labels, mc.failuresDay)
m.sampleInt("llmgateway_provider_model_attempts_total", []metricLabel{{"provider", s.name}, {"model", model}}, mc.attemptsDay)
}
}
m.family("llmgateway_provider_model_failures_total", "counter",
"Total upstream attempts today classified as a provider fault, by provider and model. Opt-in via metrics.modelLabel."+aggregationNoteStoreBacked)
mi = 0
for _, s := range snaps {
for _, model := range s.models {
mc := modelCounts[mi]
mi++
if mc.storeDown {
continue
}
m.sampleInt("llmgateway_provider_model_failures_total", []metricLabel{{"provider", s.name}, {"model", model}}, mc.failuresDay)
}
}
}
@@ -975,12 +998,35 @@ func (g *Gateway) recordLatency(provider, model string, sample latencySample) {
// writeLatencyMetrics emits llmgateway_upstream_ttfb_seconds and
// llmgateway_upstream_duration_seconds — this feature's own two
// histograms. Absent entirely (no # HELP/TYPE lines even emitted) when
// g.latency has recorded nothing yet — a fresh process, or a deployment
// with metrics collection gated off at every wiring site — mirroring how
// a brand-new histogram with zero observations would look on any real
// Prometheus exporter too: no series until the first observation, not a
// family of all-zero ones.
// always-on histograms (provider+stream labels only) — plus, when
// Config.Metrics.ModelLabel is on and at least one sample carries a
// model, the opt-in llmgateway_upstream_model_ttfb_seconds and
// llmgateway_upstream_model_duration_seconds (provider+stream+model
// labels). These are DISTINCT metric names, not the same name emitted
// twice under different label sets (M4 fix): recordLatency dual-writes
// every sample into both the bare (provider, streaming) key and, when
// ModelLabel is on, the (provider, streaming, model) key, so the two
// snapshot groups below cover the identical underlying requests —
// rendering both under one metric name would make sum()/
// histogram_quantile() over that name double-count every request,
// exactly like llmgateway_provider_model_attempts_total staying a
// separate name from llmgateway_provider_attempts_total avoids for the
// counter families above.
//
// Each family's own HELP/TYPE line is followed by ALL of that family's
// samples before the next family starts (L7 fix: the exposition format
// requires one metric's lines to form a single contiguous group) — the
// four families here are written as four back-to-back blocks, never
// interleaved.
//
// Absent entirely (no # HELP/TYPE lines even emitted) when g.latency has
// recorded nothing yet — a fresh process, or a deployment with metrics
// collection gated off at every wiring site — mirroring how a brand-new
// histogram with zero observations would look on any real Prometheus
// exporter too: no series until the first observation, not a family of
// all-zero ones. The same applies independently to the model-labeled
// pair: absent when ModelLabel is off, or on but no sample yet carries a
// model.
//
// snaps is sorted (provider, then streaming, then model) purely for
// deterministic output across repeated scrapes of identical underlying
@@ -1003,21 +1049,65 @@ func (g *Gateway) writeLatencyMetrics(m *metricWriter) {
return a.model < b.model
})
m.family("llmgateway_upstream_ttfb_seconds", "histogram",
"Time to first successful byte read from an upstream response body, in seconds, measured from just before the request was sent (watchdogBody, timeout.go). Meaningful as a distinct \"how fast did the provider start responding\" signal only for stream=\"true\": a non-streaming provider buffers its whole completion before sending anything, so its own TTFB is approximately equal to its own llmgateway_upstream_duration_seconds and carries the same output-length contamination duration does — always split by the stream label before comparing across requests of different lengths, never averaged across it."+aggregationNoteLatency)
m.family("llmgateway_upstream_duration_seconds", "histogram",
"Total upstream response body duration, in seconds, from just before the request was sent to the last successful read or Close (watchdogBody, timeout.go) — covers the whole body, not just headers. Roughly TTFB + output_tokens/tokens_per_sec: a long generation legitimately takes longer than a short one on an equally healthy provider, so a raw average across requests of very different output length is not by itself a \"provider degraded\" signal — llmgateway_tokens_total's completion direction (this file) gives the token side of that ratio for the non-streaming population, where duration approximates total generation time."+aggregationNoteLatency)
// base/withModel split (M4 fix): a snapshot with an empty key.model
// is the always-on (provider, streaming) write; a non-empty one is
// the opt-in (provider, streaming, model) write recordLatency also
// made for the SAME sample. Rendering each group under its own metric
// name, instead of both under the same one, is what stops the two
// from double-counting once aggregated.
var base, withModel []latencySnapshot
for _, s := range snaps {
if s.key.model == "" {
base = append(base, s)
} else {
withModel = append(withModel, s)
}
}
writeUpstreamLatencyFamily(m, "llmgateway_upstream_ttfb_seconds",
"Time to first successful byte read from an upstream response body, in seconds, measured from just before the request was sent (watchdogBody, timeout.go). Meaningful as a distinct \"how fast did the provider start responding\" signal only for stream=\"true\": a non-streaming provider buffers its whole completion before sending anything, so its own TTFB is approximately equal to its own llmgateway_upstream_duration_seconds and carries the same output-length contamination duration does — always split by the stream label before comparing across requests of different lengths, never averaged across it."+aggregationNoteLatency,
"llmgateway_upstream_duration_seconds",
"Total upstream response body duration, in seconds, from just before the request was sent to the last successful read or Close (watchdogBody, timeout.go) — covers the whole body, not just headers. Roughly TTFB + output_tokens/tokens_per_sec: a long generation legitimately takes longer than a short one on an equally healthy provider, so a raw average across requests of very different output length is not by itself a \"provider degraded\" signal — llmgateway_tokens_total's completion direction (this file) gives the token side of that ratio for the non-streaming population, where duration approximates total generation time."+aggregationNoteLatency,
base, false)
if len(withModel) == 0 {
return
}
writeUpstreamLatencyFamily(m, "llmgateway_upstream_model_ttfb_seconds",
"Time to first successful byte read from an upstream response body, in seconds, by provider, stream, and model. Opt-in via metrics.modelLabel — see MetricsConfig's own doc comment for the cardinality trade-off. A separate metric name from llmgateway_upstream_ttfb_seconds (M4 fix), not the same series with an extra label, so summing one family never double-counts the other's requests."+aggregationNoteLatency,
"llmgateway_upstream_model_duration_seconds",
"Total upstream response body duration, in seconds, by provider, stream, and model. Opt-in via metrics.modelLabel. A separate metric name from llmgateway_upstream_duration_seconds (M4 fix), not the same series with an extra label, so summing one family never double-counts the other's requests."+aggregationNoteLatency,
withModel, true)
}
// writeUpstreamLatencyFamily writes one pair of upstream-latency
// histogram families (ttfb, then duration) for snaps, each family's
// HELP/TYPE line followed immediately by all of its own samples (L7
// fix) — ttfbName's block never interleaves with durationName's.
// includeModel controls whether each sample's label set carries a
// "model" label; snaps' own key.model is otherwise unused here since the
// caller (writeLatencyMetrics) has already partitioned snaps into a
// model-less and a model-carrying group before calling this twice.
func writeUpstreamLatencyFamily(m *metricWriter, ttfbName, ttfbHelp, durationName, durationHelp string, snaps []latencySnapshot, includeModel bool) {
labelsFor := func(s latencySnapshot) []metricLabel {
labels := []metricLabel{{"provider", s.key.provider}, {"stream", strconv.FormatBool(s.key.streaming)}}
if s.key.model != "" {
if includeModel {
labels = append(labels, metricLabel{"model", s.key.model})
}
return labels
}
m.family(ttfbName, "histogram", ttfbHelp)
for _, s := range snaps {
if s.ttfbCount > 0 {
m.histogram("llmgateway_upstream_ttfb_seconds", labels, s.ttfbBuckets, s.ttfbOverflow, s.ttfbSum)
m.histogram(ttfbName, labelsFor(s), s.ttfbBuckets, s.ttfbOverflow, s.ttfbSum)
}
}
m.family(durationName, "histogram", durationHelp)
for _, s := range snaps {
if s.durationCount > 0 {
m.histogram("llmgateway_upstream_duration_seconds", labels, s.durationBuckets, s.durationOverflow, s.durationSum)
m.histogram(durationName, labelsFor(s), s.durationBuckets, s.durationOverflow, s.durationSum)
}
}
}
@@ -1169,14 +1259,23 @@ func (g *Gateway) writeProvenanceMetrics(m *metricWriter) {
return a.provenance < b.provenance
})
// Each family below is its own complete pass over snaps (L7 fix):
// llmgateway_usage_provenance_requests_total's samples must not
// interleave with llmgateway_usage_provenance_tokens_total's — the
// exposition format requires one metric's lines to form a single
// contiguous group.
m.family("llmgateway_usage_provenance_requests_total", "counter",
"Total accounted candidate outcomes (a successful upstream response, callErr == nil, that reached usage accounting in runUnified, routes_unified.go), by provider and provenance. provenance is \"reported\" (the provider returned real usage), \"estimated\" (a non-streaming response carried none, so prompt was substituted with ceil(len(body)/4) and completion billed as zero — usage.estimated, limits.go), or \"unbilled\" (a streaming response carried none, so the request is counted but zero tokens are billed). This family only reports how an already-billed number was arrived at; it never changes what gets billed."+aggregationNotePerProcess)
for _, s := range snaps {
m.sampleInt("llmgateway_usage_provenance_requests_total",
[]metricLabel{{"provider", s.key.provider}, {"provenance", s.key.provenance}}, s.requests)
}
m.family("llmgateway_usage_provenance_tokens_total", "counter",
"Total tokens billed under each provenance (the SAME result.total() runUnified already passed to accounting, routes_unified.go — never a second, independent count), by provider and provenance. sum(...{provenance=\"reported\"}) / sum(...) across every provenance is the fraction of billed tokens that came from real provider-reported usage."+aggregationNotePerProcess)
for _, s := range snaps {
labels := []metricLabel{{"provider", s.key.provider}, {"provenance", s.key.provenance}}
m.sampleInt("llmgateway_usage_provenance_requests_total", labels, s.requests)
m.sampleInt("llmgateway_usage_provenance_tokens_total", labels, s.tokens)
m.sampleInt("llmgateway_usage_provenance_tokens_total",
[]metricLabel{{"provider", s.key.provider}, {"provenance", s.key.provenance}}, s.tokens)
}
}
+152
View File
@@ -504,6 +504,94 @@ func TestMetrics_OutputParsesAsValidExposition(t *testing.T) {
}
}
// TestMetrics_Exposition_FamiliesAreContiguous is the L7 regression test:
// the Prometheus text-exposition format requires every metric's sample
// lines to form a single contiguous group — one family's samples must
// not start, get interrupted by a different family's samples, then
// resume. This drives the same real traffic
// TestMetrics_OutputParsesAsValidExposition does (so every family in
// this file actually has multiple label sets to interleave, the bug
// this test would have caught), then walks the raw response body
// line by line and fails if any family's base metric name is seen
// again after a DIFFERENT family's samples started — the exact shape a
// strict OpenMetrics-style parser rejects even though promparse/expfmt
// tolerate it.
func TestMetrics_Exposition_FamiliesAreContiguous(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":"c1","object":"chat.completion","model":"gpt-test","choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`))
}))
defer srv.Close()
cfg := newMetricsTestConfig()
cfg.Metrics.ModelLabel = true
cfg.Providers["openai"].BaseURL = srv.URL
cfg.Users.Inline[0].Limits = &LimitsConfig{RequestsPerMinute: 1}
h, gw := newMetricsGatewayHandle(t, cfg)
gw.limiter.store = newMemoryStore()
// A directly-seeded model-labeled latency sample (M4's own scenario)
// so llmgateway_upstream_model_duration_seconds also has a series to
// check for contiguity, the same way it does against
// llmgateway_upstream_duration_seconds — two histogram families with
// several suffixed sample names each are exactly the shape most
// likely to interleave if writeLatencyMetrics regressed.
gw.recordLatency("openai", "gpt-test", latencySample{duration: 200 * time.Millisecond, streaming: false})
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))
if rec.Code != http.StatusOK {
t.Fatalf("seed request status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, newUnifiedRequest(t, http.MethodPost, "/v1/chat/completions", "sk-alice", body))
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("second request status = %d, want 429 (RequestsPerMinute:1 exhausted)", rec.Code)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, metricsRequest(http.MethodGet, metricsPathDefault, "sk-admin1"))
if rec.Code != http.StatusOK {
t.Fatalf("GET /metrics status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
types, _ := parsePrometheusText(t, rec.Body.Bytes())
familyOf := func(name string) string {
for _, suffix := range []string{"_bucket", "_sum", "_count"} {
if trimmed, ok := strings.CutSuffix(name, suffix); ok {
if k, ok := types[trimmed]; ok && k == "histogram" {
return trimmed
}
}
}
return name
}
seenAndClosed := map[string]bool{} // families whose contiguous block has already ended
current := "" // the family the sample block currently in progress belongs to
for _, line := range strings.Split(rec.Body.String(), "\n") {
if line == "" || strings.HasPrefix(line, "#") {
continue // HELP/TYPE lines and blank lines carry no sample, so they don't affect contiguity
}
s := parsePromSample(t, line)
fam := familyOf(s.name)
if fam == current {
continue // still inside the same family's block
}
if seenAndClosed[fam] {
t.Fatalf("family %q is not contiguous: its samples resume after family %q's block started, "+
"violating the exposition format's one-group-per-metric rule", fam, current)
}
if current != "" {
seenAndClosed[current] = true
}
current = fam
}
}
// --- label escaping: the correctness requirement, not a nicety ---
func TestEscapeLabelValue(t *testing.T) {
@@ -1337,6 +1425,70 @@ func TestMetrics_LatencyHistogram_StreamingAndNonStreaming_DistinctLabelSets(t *
}
}
// TestMetrics_LatencyHistogram_ModelLabel_DistinctMetricName_NoDoubleCount
// is the M4 regression test: recordLatency dual-writes one observation
// into both the always-on (provider, streaming) key and, when
// ModelLabel is on and model is known, the (provider, streaming, model)
// key — the SAME underlying request. Before the fix both writes rendered
// under the identical metric name "llmgateway_upstream_duration_seconds",
// so summing that one family over all its label sets counted the single
// request twice. This asserts the always-on family's own series never
// carries a "model" label at all (so it cannot combine with the
// model-labeled write under one name/aggregation) and that the
// model-labeled write instead lands under the distinct
// "llmgateway_upstream_model_duration_seconds" family, each family's own
// _count summing to exactly 1 — the true number of requests observed —
// rather than 2.
func TestMetrics_LatencyHistogram_ModelLabel_DistinctMetricName_NoDoubleCount(t *testing.T) {
t.Parallel()
cfg := newMetricsTestConfig()
cfg.Metrics.ModelLabel = true
h, gw := newMetricsGatewayHandle(t, cfg)
gw.recordLatency("openai", "gpt-test", latencySample{duration: 400 * time.Millisecond, streaming: false})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, metricsRequest(http.MethodGet, metricsPathDefault, "sk-admin1"))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
types, samples := parsePrometheusText(t, rec.Body.Bytes())
if _, ok := types["llmgateway_upstream_model_duration_seconds"]; !ok {
t.Fatal(`expected a distinct "llmgateway_upstream_model_duration_seconds" TYPE line — the model-labeled write must not share a name with the base family`)
}
var baseCount, modelCount int64
for _, s := range samples {
switch s.name {
case "llmgateway_upstream_duration_seconds_count":
if _, hasModel := s.labels["model"]; hasModel {
t.Errorf(`llmgateway_upstream_duration_seconds_count carries a "model" label (%v) — the always-on family must never carry one, or a naive sum() over it double-counts against the model-labeled family`, s.labels)
}
v, err := strconv.ParseInt(s.value, 10, 64)
if err != nil {
t.Fatalf("bucket value %q does not parse: %v", s.value, err)
}
baseCount += v
case "llmgateway_upstream_model_duration_seconds_count":
if s.labels["model"] != "gpt-test" {
t.Errorf(`llmgateway_upstream_model_duration_seconds_count model label = %q, want "gpt-test"`, s.labels["model"])
}
v, err := strconv.ParseInt(s.value, 10, 64)
if err != nil {
t.Fatalf("bucket value %q does not parse: %v", s.value, err)
}
modelCount += v
}
}
if baseCount != 1 {
t.Errorf("llmgateway_upstream_duration_seconds_count total = %d, want 1 (one real request)", baseCount)
}
if modelCount != 1 {
t.Errorf("llmgateway_upstream_model_duration_seconds_count total = %d, want 1 (one real request)", modelCount)
}
}
// --- usage provenance (feat: expose token-accounting provenance) ---
// TestMetrics_UsageProvenance_EstimatedAndUnbilled_DistinctSeries proves
+64
View File
@@ -100,6 +100,70 @@ func resolveModelMeta(provider, model string, cfg map[string]*ModelMetaConfig, d
return out
}
// priceKnown reports whether model — given as both its canonical
// "provider/model" id and its bare upstream id, canonical checked first,
// the same two-id order routes_unified.go's own modelPriceKnown/
// unifiedCostMicrosKnown already check — has a KNOWN price for billing
// purposes: a configured pricing override or the built-in tables
// (lookupPricing, pricing.go — builtinPricing then the generated
// builtinModelMetaTable), OR an operator's own modelMeta entry that marks
// the model explicitly Free (review-auth finding F1, 2026-09 audit:
// modelPriceKnown used to consult only pricing overrides and the built-in
// table, so a model an operator declared modelMeta:{free:true} — the
// common case for a self-hosted/local model with no real cost — was still
// reported "unpriced" and refused by the AllowUnpricedWithCostBudget cost
// guard even though its price, zero, is perfectly well known).
//
// meta is checked by the SAME two key shapes lookupModelMetaConfig itself
// checks (an exact "provider/model" key, then a bare "model" key) — done
// here as plain map lookups rather than a resolveModelMeta call, since
// resolveModelMeta also resolves context-window/discovery data this
// cost-only check has no use for, and canonical here already IS the
// joined "provider/model" string, not a separate (provider, model) pair
// resolveModelMeta could split it back into.
//
// This does not change AllowUnpricedWithCostBudget's own default — it
// only widens what counts as "known", so a model already treated as
// priced stays priced, and a model an operator has explicitly marked free
// now also counts as priced, rather than being refused as though it had
// no price on record at all.
func priceKnown(canonical, bare string, overrides map[string]*ModelPricing, meta map[string]*ModelMetaConfig) bool {
if _, ok := lookupPricing(canonical, overrides); ok {
return true
}
if _, ok := lookupPricing(bare, overrides); ok {
return true
}
return modelMetaFree(canonical, bare, meta)
}
// modelMetaFree reports whether the operator modelMeta entry for
// canonical (or, absent that, bare) marks it Free — the SAME two-key
// lookup priceKnown itself performs (an exact "provider/model" key, then
// a bare "model" key), factored out so billing (unifiedCostMicros, routes_unified.go)
// and the 402 cost-budget guard (priceKnown, above) can never disagree
// about which models are free (verify-core fix, round 4). Before this,
// priceKnown alone treated a modelMeta free:true model as priced, but
// unifiedCostMicros never consulted modelMeta at all, so a self-hosted
// id an operator declared free that also happened to match a bare id in
// the generated LiteLLM table (gpt-oss-120b, llama-3.3-70b, and other
// common local-model names) was billed that table's real price instead
// of 0 — passing the 402 guard while silently charging for it anyway.
//
// Precedence matches the display path (lookupModelMetaConfig): an exact
// "provider/model" entry decides on its own; the bare entry is consulted
// only when no provider/model entry exists, so /v1/models and billing
// never disagree about whether a model is free.
func modelMetaFree(canonical, bare string, meta map[string]*ModelMetaConfig) bool {
if m, ok := meta[canonical]; ok && m != nil {
return m.Free
}
if m, ok := meta[bare]; ok && m != nil {
return m.Free
}
return false
}
// lookupModelMetaConfig resolves cfg's config-override layer for
// (provider, model): an exact "provider/model" key wins over a bare
// "model" key, matching this feature's own spec precedence (a key names
+68
View File
@@ -388,3 +388,71 @@ func TestValidateModelMeta(t *testing.T) {
}
})
}
// TestPriceKnown covers priceKnown's own layering (review-auth finding
// F1, 2026-09 audit): a pricing override or the built-in tables (via
// lookupPricing) count as known, and — the fix itself — a modelMeta entry
// marked Free counts as known too, checked by canonical id first and bare
// id second, matching modelPriceKnown's own two-id order
// (routes_unified.go).
func TestPriceKnown(t *testing.T) {
t.Run("no pricing and no modelMeta is unknown", func(t *testing.T) {
if priceKnown("openai/totally-unpriced", "totally-unpriced", nil, nil) {
t.Error("want unknown")
}
})
t.Run("builtin pricing table via bare id counts as known", func(t *testing.T) {
if !priceKnown("openai/gpt-5", "gpt-5", nil, nil) {
t.Error("want known: gpt-5 is in builtinPricing")
}
})
t.Run("pricing override via canonical id counts as known", func(t *testing.T) {
overrides := map[string]*ModelPricing{"local/my-model": {InputPerM: 1, OutputPerM: 1}}
if !priceKnown("local/my-model", "my-model", overrides, nil) {
t.Error("want known: override keyed on the canonical id")
}
})
t.Run("modelMeta free:true via canonical id counts as known (the fix)", func(t *testing.T) {
meta := map[string]*ModelMetaConfig{"local/free-model": {Free: true}}
if !priceKnown("local/free-model", "free-model", nil, meta) {
t.Error("want known: modelMeta declares this model free")
}
})
t.Run("modelMeta free:true via bare id counts as known", func(t *testing.T) {
meta := map[string]*ModelMetaConfig{"free-model": {Free: true}}
if !priceKnown("local/free-model", "free-model", nil, meta) {
t.Error("want known: bare-id modelMeta entry applies wherever the id resolves")
}
})
t.Run("modelMeta entry without free:true does not count as known on its own", func(t *testing.T) {
meta := map[string]*ModelMetaConfig{"local/ctx-only": {ContextTokens: 8000}}
if priceKnown("local/ctx-only", "ctx-only", nil, meta) {
t.Error("want unknown: a context-only modelMeta entry says nothing about price")
}
})
t.Run("provider/model entry decides over a bare free:true entry, matching /v1/models display", func(t *testing.T) {
meta := map[string]*ModelMetaConfig{
"gpt-oss-120b": {Free: true},
"groq/gpt-oss-120b": {ContextTokens: 131072},
}
if modelMetaFree("groq/gpt-oss-120b", "gpt-oss-120b", meta) {
t.Error("want not free: the exact provider/model entry wins, as lookupModelMetaConfig does for display")
}
if !modelMetaFree("local/gpt-oss-120b", "gpt-oss-120b", meta) {
t.Error("want free: no provider/model entry, so the bare free:true entry applies")
}
})
t.Run("nil modelMeta entry pointer is not dereferenced", func(t *testing.T) {
meta := map[string]*ModelMetaConfig{"local/nil-entry": nil}
if priceKnown("local/nil-entry", "nil-entry", nil, meta) {
t.Error("want unknown: a nil map entry must not be treated as free")
}
})
}
+220 -75
View File
@@ -1,7 +1,9 @@
package traefikllmgateway
import (
"fmt"
"math"
"sort"
"sync"
)
@@ -27,36 +29,25 @@ var builtinPricing = map[string]ModelPricing{
"grok-4": {InputPerM: 3, OutputPerM: 15},
}
// unknownModelWarnCap bounds warnedModels. The model id in costMicros'
// calls comes from client-supplied request bodies (a group with an empty
// Models allowlist accepts any model string), so without a cap an attacker
// or a buggy client cycling through model names could grow warnedModels
// without bound. Once the cap is reached, costMicros stops recording new
// names and pricingWarnFn fires once more with a summary message instead
// of a per-model one.
// unknownModelWarnCap bounds warnedModels. The model id in
// costMicrosKnownFor's calls comes from client-supplied request bodies (a
// group with an empty Models allowlist accepts any model string), so
// without a cap an attacker or a buggy client cycling through model names
// could grow warnedModels without bound. Once the cap is reached,
// warnUnknownModelFor stops recording new names and fires its warn func
// once more with a summary message instead of a per-model one.
const unknownModelWarnCap = 128
// warnCapMessage is the single summary warning fired once the dedup set
// in warnedModels reaches unknownModelWarnCap.
const warnCapMessage = "pricing: unknown-model warning cap reached; further unknown models unwarned"
// pricingWarnFn is called once per unknown model id encountered by
// costMicros, with the model id — or, once unknownModelWarnCap is
// reached, once more with warnCapMessage. The default is a no-op so
// pricing.go has no logging dependency of its own; a later task wires
// this to the gateway's logf via setPricingWarnFn, and tests can
// substitute their own func to capture warnings the same way. Read inside
// warnUnknownModel under warnedModelsMu — always assign through
// setPricingWarnFn, never by a bare `pricingWarnFn = …`, or a concurrent
// costMicros call can race the assignment.
var pricingWarnFn = func(string) {}
// warnedModels tracks which unknown model ids have already triggered
// pricingWarnFn, so a gateway processing many requests for the same
// a warn func, so a gateway processing many requests for the same
// unpriced model logs the warning once per process lifetime rather than
// once per request. warnCapNotified tracks whether the one-time summary
// warning has already fired once the cap is reached. Package-level and
// mutex-guarded by design: costMicros is called from the request path
// mutex-guarded by design: costMicrosKnownFor is called from the request path
// with no natural place to thread a longer-lived cache through, and the
// dedup set must survive across requests and across limiter instances,
// not reset per call.
@@ -66,59 +57,162 @@ var (
warnCapNotified bool
)
// costMicros computes the micro-USD cost of u's token usage for model,
// looking up its price in overrides first (exact model id match), then
// builtinPricing. An unknown model costs 0 and triggers one pricingWarnFn
// call per model id, up to unknownModelWarnCap distinct names.
// costMicrosKnownFor computes the micro-USD cost of u's token usage for
// model, and whether a price was actually FOUND. An unknown model returns
// (0, false) — a zero that means "cannot be priced", which is NOT the
// same value a genuinely free model produces, even though both are 0.
// Looks up model's price via lookupPricing (overrides, then
// builtinPricing, then builtinModelMetaTable); an unknown model triggers
// one warn call per model id, up to unknownModelWarnCap distinct names.
//
// The math stays in integer micro-USD throughout to avoid float drift:
// priceMicrosPerM is the per-1M-token price scaled to micro-USD and
// rounded to the nearest micro-USD (a bare truncating cast can undershoot
// a price like 8.2 by one micro-USD due to float64 representation), and
// each of input/output cost is tokens*priceMicrosPerM/1e6, summed.
func costMicros(model string, u usage, overrides map[string]*ModelPricing) int64 {
cost, _ := costMicrosKnown(model, u, overrides)
return cost
}
// costMicrosKnown is costMicros plus the one bit costMicros throws away:
// whether a price was actually FOUND. An unknown model returns (0, false)
// — a zero that means "cannot be priced", which is NOT the same value a
// genuinely free model produces, even though both are 0.
// Security audit run-1, finding F-1 (high): an earlier single-int64-
// returning costMicros conflated "unpriced" with "genuinely zero cost",
// and limiter.account's own `if costMicros != 0` guard then skipped every
// cost-counter write for an unpriced model. checkAndCount reads exactly
// those never-written counters, so `used < limit` stayed permanently true
// and a configured costPerDayUSD/costPerMonthUSD could never fire. Any
// caller gating a SPEND control must handle ok == false.
//
// Security audit run-1, finding F-1 (high): costMicros' single-int64
// return conflated those two cases, and limiter.account's own
// `if costMicros != 0` guard then skipped every cost-counter write for an
// unpriced model. checkAndCount reads exactly those never-written
// counters, so `used < limit` stayed permanently true and a configured
// costPerDayUSD/costPerMonthUSD could never fire. Any caller gating a
// SPEND control must use this function and handle ok == false; costMicros
// above remains for callers that only need the number (the accounting
// write itself, where a zero is harmless).
//
// This is the distinction resolvedModelMeta.CostKnown (modelmeta.go)
// already draws for the metadata path, and the one candidateCostAllowed
// (failover.go) already relies on to protect real money — its own comment
// calls recording zero for an unpriced model "an accounting shortcut, not
// proof the provider invoices zero".
func costMicrosKnown(model string, u usage, overrides map[string]*ModelPricing) (int64, bool) {
// warn is the caller's own per-instance sink (a *Gateway's g.pricingWarn,
// logger.go), never a package global: Traefik constructs one Gateway per
// middleware instance, and a shared global made the most recently
// constructed instance log every other instance's warnings (finding F11 /
// review-auth F8, 2026-09 review). The unknown-model dedup (warnedModels)
// stays process-global, so a model already warned about under one
// instance is not warned about again under another.
func costMicrosKnownFor(model string, u usage, overrides map[string]*ModelPricing, warn func(string)) (int64, bool) {
price, ok := lookupPricing(model, overrides)
if !ok {
warnUnknownModel(model)
warnUnknownModelFor(model, warn)
return 0, false
}
return computeCostMicros(price, u), true
}
// computeCostMicros is costMicrosKnownFor's arithmetic: price's two per-1M-token rates, each scaled to
// micro-USD and rounded to the nearest micro-USD (a bare truncating cast
// can undershoot a price like 8.2 by one micro-USD due to float64
// representation), multiplied by their own token count, SUMMED, and only
// THEN divided back down and rounded to the nearest micro-USD (findings
// F9/F10, 2026-09 review).
//
// F9: the previous version divided input and output separately
// (tokens*priceMicrosPerM/tokensPerMillion, truncating each), which
// silently billed 0 for any single request under ~50 tokens at a
// realistic per-token price — up to ~2 micro-USD of truncation error per
// request (one per direction), enough that account (limits.go) skipped
// the cost-counter write entirely (`if costMicros != 0`) for genuinely
// billable-but-cheap traffic. Rounding the COMBINED total once instead
// halves the worst-case error and stops a nonzero combined cost from
// truncating away to a written 0.
//
// F10: the previous unchecked int64 multiply overflowed for a token count
// above roughly 1.2e11 at a $75/M price — entirely upstream-controlled,
// since the token counts come from the provider's own reported usage
// (limits.go's account, security audit run-1) — silently wrapping to a
// negative or garbage value; a negative result was then clamped to 0 by
// account, permanently masking the true cost. saturatingMulInt64/
// saturatingAddInt64 (below) clamp each multiply and the final sum to
// [math.MinInt64, math.MaxInt64] instead of wrapping, so an absurd
// upstream-reported token count bills the maximum representable cost
// rather than a wrapped-around near-zero or negative one.
func computeCostMicros(price ModelPricing, u usage) int64 {
inputMicrosPerM := int64(math.Round(price.InputPerM * tokensPerMillion))
outputMicrosPerM := int64(math.Round(price.OutputPerM * tokensPerMillion))
inputCost := u.prompt * inputMicrosPerM / tokensPerMillion
outputCost := u.completion * outputMicrosPerM / tokensPerMillion
return inputCost + outputCost, true
inputCost := saturatingMulInt64(u.prompt, inputMicrosPerM)
outputCost := saturatingMulInt64(u.completion, outputMicrosPerM)
total := saturatingAddInt64(inputCost, outputCost)
return roundedDivInt64(total, tokensPerMillion)
}
// roundedDivInt64 divides total by denom, rounded to the nearest integer
// (half away from zero) rather than truncated toward zero — see
// computeCostMicros' own F9 discussion. denom is always tokensPerMillion
// (a positive compile-time constant) at this function's one call site.
//
// Deliberately computed via quotient/remainder rather than the more
// obvious "(total + denom/2) / denom": total can legitimately arrive at
// math.MaxInt64 (computeCostMicros' own saturatingAddInt64 clamps there
// on overflow, finding F10), and adding denom/2 to it before dividing
// would itself silently overflow right back — exactly the wrap-around
// bug this whole function exists to avoid one step earlier. Working from
// the remainder instead never adds to total at all.
func roundedDivInt64(total, denom int64) int64 {
q := total / denom
r := total % denom
if r < 0 {
r = -r
}
if r*2 >= denom {
if total >= 0 {
q++
} else {
q--
}
}
return q
}
// saturatingMulInt64 returns a*b, clamped to math.MinInt64/math.MaxInt64
// instead of wrapping on overflow (finding F10, 2026-09 review) — a
// simple post-multiply check (divide back and compare), not math/bits:
// this plugin runs interpreted under Yaegi (COMMON.md's own "stdlib only,
// avoid reflection-heavy code" guidance), and a division-based overflow
// check needs nothing beyond the four basic int64 operators.
func saturatingMulInt64(a, b int64) int64 {
if a == 0 || b == 0 {
return 0
}
p := a * b
if p/b != a {
if (a > 0) == (b > 0) {
return math.MaxInt64
}
return math.MinInt64
}
return p
}
// saturatingAddInt64 returns a+b, clamped to math.MinInt64/math.MaxInt64
// instead of wrapping on overflow (finding F10, 2026-09 review) — the
// standard "the sum moved the wrong way" overflow test for two's-
// complement addition.
func saturatingAddInt64(a, b int64) int64 {
sum := a + b
if b > 0 && sum < a {
return math.MaxInt64
}
if b < 0 && sum > a {
return math.MinInt64
}
return sum
}
// lookupPricing resolves model's price: overrides first (a nil entry does
// not count as a match, so a caller can't accidentally price a model at
// zero by leaving a map entry nil), then builtinPricing.
// zero by leaving a map entry nil), then builtinPricing, then
// builtinModelMetaTable (pricing_data_gen.go's 236-entry LiteLLM-synced
// table — review finding F2, 2026-09 review): builtinPricing alone only
// ever carried 10 flagship models, so an operator serving anything else
// the generated table already knows a cost for (gpt-4.1, o4-mini,
// deepseek-chat, and 200+ others) billed 0 for it even though GET
// /v1/models — which reads the identical table via resolveModelMeta,
// modelmeta.go — advertised a real price for the exact same model,
// letting AllowUnpricedWithCostBudget's cost guard refuse the request as
// "unpriced" in the same breath the catalog called it priced. Converted
// from the table's integer micro-USD-per-million-tokens unit to
// ModelPricing's own float64-USD-per-million-tokens unit via
// microUSDPerMTokToUSD (modelmeta.go) — the identical conversion GET
// /v1/models' own pricing view already applies to this same table. A
// zero-valued cost field in the table means "no data" (builtinModelMeta's
// own doc comment), not "free" — at least one side must be positive
// before an entry is trusted, the same rule resolveModelMeta's own cost
// switch already applies to it. Precedence is unchanged by this addition:
// an operator override always wins, builtinPricing (curated, more likely
// to be current) wins over the generated table next, and the generated
// table is only ever the last resort.
func lookupPricing(model string, overrides map[string]*ModelPricing) (ModelPricing, bool) {
if p, ok := overrides[model]; ok && p != nil {
return *p, true
@@ -126,25 +220,76 @@ func lookupPricing(model string, overrides map[string]*ModelPricing) (ModelPrici
if p, ok := builtinPricing[model]; ok {
return p, true
}
if m, ok := builtinModelMetaTable[model]; ok && (m.InputCostPerMTokMicroUSD > 0 || m.OutputCostPerMTokMicroUSD > 0) {
return ModelPricing{
InputPerM: microUSDPerMTokToUSD(m.InputCostPerMTokMicroUSD),
OutputPerM: microUSDPerMTokToUSD(m.OutputCostPerMTokMicroUSD),
}, true
}
return ModelPricing{}, false
}
// setPricingWarnFn replaces pricingWarnFn, guarded by the same mutex
// warnUnknownModel reads it under. A later task calls this at New() to
// wire warnings to the gateway's logf; tests call it to capture warnings.
// Always use this instead of assigning pricingWarnFn directly.
func setPricingWarnFn(fn func(string)) {
warnedModelsMu.Lock()
defer warnedModelsMu.Unlock()
pricingWarnFn = fn
// validatePricing validates every entry in raw (Config.Pricing overrides)
// and returns it unchanged — a constructor error rather than a silently
// unenforceable cost budget (finding F4, 2026-09 review): costMicrosKnownFor
// had no validation of its own, so a negative, NaN, or Inf price still
// passed priceKnown/modelPriceKnown's own "is this model priced" check
// (modelmeta.go) yet produced a meaningless or negative cost — permanently
// defeating any costPerDayUSD/costPerMonthUSD budget configured for that
// model (limits.go's account already clamps a negative cost to 0 and logs
// it on every request, but by then the budget has already never fired,
// silently, since construction). Mirrors LimitsConfig.validate's own
// "non-nil, finite, >= 0" rule (limits.go) applied to InputPerM/OutputPerM
// instead of a count/cost limit. raw is walked in sorted key order,
// matching validateModelMeta's own convention (modelmeta.go), so a config
// with more than one invalid entry always reports the same one first,
// deterministically across repeated runs.
func validatePricing(raw map[string]*ModelPricing) (map[string]*ModelPricing, error) {
if len(raw) == 0 {
return raw, nil
}
keys := make([]string, 0, len(raw))
for k := range raw {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
p := raw[k]
if p == nil {
return nil, fmt.Errorf("llmgateway: pricing: entry %q must not be nil", k)
}
fields := []struct {
name string
v float64
}{
{"inputPerM", p.InputPerM},
{"outputPerM", p.OutputPerM},
}
for _, f := range fields {
if math.IsNaN(f.v) || math.IsInf(f.v, 0) {
return nil, fmt.Errorf("llmgateway: pricing: entry %q: %s must be a finite number, got %v", k, f.name, f.v)
}
if f.v < 0 {
return nil, fmt.Errorf("llmgateway: pricing: entry %q: %s must not be negative, got %v", k, f.name, f.v)
}
}
}
return raw, nil
}
// warnUnknownModel calls pricingWarnFn for model, once per process
// lifetime. Once warnedModels reaches unknownModelWarnCap, it stops
// recording new model ids and instead fires a single one-time summary
// warning via warnCapMessage — after that, further unknown models are
// silently unwarned rather than growing the set forever.
func warnUnknownModel(model string) {
// warnUnknownModelFor calls warn for model, once per process lifetime, via
// the process-global dedup set (warnedModels) — deliberately not scoped
// per-caller, so a model already warned about under one Gateway
// instance's warn func is not warned about again under a different
// instance's (finding F11 / review-auth F8's own "keep the process-global
// dedup cap if needed" allowance; costMicrosKnownFor passes its caller's
// own warn func). Once
// warnedModels reaches unknownModelWarnCap, it stops recording new model
// ids and instead fires a single one-time summary warning via
// warnCapMessage — after that, further unknown models are silently
// unwarned rather than growing the set forever.
func warnUnknownModelFor(model string, warn func(string)) {
warnedModelsMu.Lock()
defer warnedModelsMu.Unlock()
if warnedModels[model] {
@@ -153,10 +298,10 @@ func warnUnknownModel(model string) {
if len(warnedModels) >= unknownModelWarnCap {
if !warnCapNotified {
warnCapNotified = true
pricingWarnFn(warnCapMessage)
warn(warnCapMessage)
}
return
}
warnedModels[model] = true
pricingWarnFn(model)
warn(model)
}
+171 -10
View File
@@ -1,11 +1,25 @@
package traefikllmgateway
import (
"math"
"strconv"
"sync"
"testing"
)
// costMicrosOnly is costMicrosKnown for a test that only needs the price
// itself, mirroring the removed package-level costMicros helper (dead
// code removal, review finding, 2026-09 review): costMicros had no
// production caller left once every real caller moved to
// costMicrosKnown/unifiedCostMicrosKnown and their own -ok-returning
// variants, but this file's existing table tests only ever asserted on
// the price, never on the ok bool, so they keep that exact shape here
// instead of threading a discarded ok through every case.
func costMicrosOnly(model string, u usage, overrides map[string]*ModelPricing) int64 {
got, _ := costMicrosKnown(model, u, overrides)
return got
}
// TestCostMicrosBuiltin exercises the integer micro-USD math against
// builtinPricing entries directly, so a regression to float-based pricing
// (drift) or a wrong tokensPerMillion divisor shows up as an exact
@@ -27,8 +41,8 @@ func TestCostMicrosBuiltin(t *testing.T) {
}
for _, c := range cases {
t.Run(c.model, func(t *testing.T) {
if got := costMicros(c.model, c.u, nil); got != c.want {
t.Errorf("costMicros(%q, %+v, nil) = %d, want %d", c.model, c.u, got, c.want)
if got := costMicrosOnly(c.model, c.u, nil); got != c.want {
t.Errorf("costMicrosOnly(%q, %+v, nil) = %d, want %d", c.model, c.u, got, c.want)
}
})
}
@@ -44,10 +58,10 @@ func TestCostMicrosOverride(t *testing.T) {
}
u := usage{prompt: 1_000_000, completion: 1_000_000}
if got, want := costMicros("gpt-5", u, overrides), int64(2_000_000); got != want {
if got, want := costMicrosOnly("gpt-5", u, overrides), int64(2_000_000); got != want {
t.Errorf("override for known model: costMicros = %d, want %d", got, want)
}
if got, want := costMicros("in-house-model", u, overrides), int64(3_000_000); got != want {
if got, want := costMicrosOnly("in-house-model", u, overrides), int64(3_000_000); got != want {
t.Errorf("override for unknown model: costMicros = %d, want %d", got, want)
}
}
@@ -58,11 +72,31 @@ func TestCostMicrosOverride(t *testing.T) {
func TestCostMicrosOverrideNilEntryFallsBack(t *testing.T) {
overrides := map[string]*ModelPricing{"gpt-5": nil}
u := usage{prompt: 1_000_000, completion: 1_000_000}
if got, want := costMicros("gpt-5", u, overrides), int64(11_250_000); got != want {
if got, want := costMicrosOnly("gpt-5", u, overrides), int64(11_250_000); got != want {
t.Errorf("nil override entry: costMicros = %d, want %d (builtin price)", got, want)
}
}
// pricingWarnFn is the test-only warn sink costMicrosKnown passes to
// costMicrosKnownFor; production callers pass their own g.pricingWarn.
// Guarded by warnedModelsMu so tests stay race-clean.
var pricingWarnFn = func(string) {}
func setPricingWarnFn(fn func(string)) {
warnedModelsMu.Lock()
defer warnedModelsMu.Unlock()
pricingWarnFn = fn
}
// costMicrosKnown is a test convenience wrapper around costMicrosKnownFor
// using the test-controlled pricingWarnFn.
func costMicrosKnown(model string, u usage, overrides map[string]*ModelPricing) (int64, bool) {
warnedModelsMu.Lock()
warn := pricingWarnFn
warnedModelsMu.Unlock()
return costMicrosKnownFor(model, u, overrides, warn)
}
// resetPricingWarnState snapshots and clears the package-level unknown-
// model dedup state (warnedModels, warnCapNotified) and pricingWarnFn, and
// registers a t.Cleanup to restore them — so tests exercising the
@@ -101,11 +135,11 @@ func TestCostMicrosUnknownModelWarnsOnce(t *testing.T) {
warned := resetPricingWarnState(t)
for i := 0; i < 3; i++ {
if got := costMicros("totally-unpriced-model", usage{prompt: 100, completion: 100}, nil); got != 0 {
if got := costMicrosOnly("totally-unpriced-model", usage{prompt: 100, completion: 100}, nil); got != 0 {
t.Errorf("iteration %d: costMicros = %d, want 0", i, got)
}
}
if got := costMicros("another-unpriced-model", usage{prompt: 1}, nil); got != 0 {
if got := costMicrosOnly("another-unpriced-model", usage{prompt: 1}, nil); got != 0 {
t.Errorf("costMicros = %d, want 0", got)
}
@@ -132,8 +166,8 @@ func TestCostMicrosUnknownModelWarnCap(t *testing.T) {
const distinctModels = 200
for i := 0; i < distinctModels; i++ {
model := "unpriced-model-" + strconv.Itoa(i)
if got := costMicros(model, usage{prompt: 1}, nil); got != 0 {
t.Fatalf("costMicros(%q, ...) = %d, want 0", model, got)
if got := costMicrosOnly(model, usage{prompt: 1}, nil); got != 0 {
t.Fatalf("costMicrosOnly(%q, ...) = %d, want 0", model, got)
}
}
@@ -162,7 +196,7 @@ func TestCostMicrosUnknownModelWarnCap(t *testing.T) {
func TestCostMicrosRoundsPriceConversion(t *testing.T) {
overrides := map[string]*ModelPricing{"round-test": {InputPerM: 8.2, OutputPerM: 8.2}}
u := usage{prompt: 1_000_000, completion: 1_000_000}
if got, want := costMicros("round-test", u, overrides), int64(16_400_000); got != want {
if got, want := costMicrosOnly("round-test", u, overrides), int64(16_400_000); got != want {
t.Errorf("costMicros with an 8.2 price = %d, want %d", got, want)
}
}
@@ -190,3 +224,130 @@ func TestBuiltinPricingSeedComplete(t *testing.T) {
}
}
}
// TestLookupPricing_FallsBackToGeneratedTable (finding F2, 2026-09
// review) asserts a model present ONLY in builtinModelMetaTable — not in
// the small curated builtinPricing list — still resolves a real price
// through lookupPricing/costMicrosKnown, matching the price GET
// /v1/models already advertises for the same model via resolveModelMeta.
// Before this fix, such a model billed 0 even though the catalog called
// it priced. "deepseek-chat" is one of 200+ such models
// (pricing_data_gen.go).
func TestLookupPricing_FallsBackToGeneratedTable(t *testing.T) {
if _, ok := builtinPricing["deepseek-chat"]; ok {
t.Fatal("test assumption broken: deepseek-chat must not be in the curated builtinPricing table")
}
meta, ok := builtinModelMetaTable["deepseek-chat"]
if !ok || meta.InputCostPerMTokMicroUSD <= 0 || meta.OutputCostPerMTokMicroUSD <= 0 {
t.Fatal("test assumption broken: deepseek-chat must be in builtinModelMetaTable with a positive cost on both sides")
}
price, ok := lookupPricing("deepseek-chat", nil)
if !ok {
t.Fatal(`lookupPricing("deepseek-chat", nil) ok = false, want a price resolved from builtinModelMetaTable`)
}
wantIn := microUSDPerMTokToUSD(meta.InputCostPerMTokMicroUSD)
wantOut := microUSDPerMTokToUSD(meta.OutputCostPerMTokMicroUSD)
if price.InputPerM != wantIn || price.OutputPerM != wantOut {
t.Errorf(`lookupPricing("deepseek-chat", nil) = %+v, want {InputPerM:%v OutputPerM:%v}`, price, wantIn, wantOut)
}
got, ok := costMicrosKnown("deepseek-chat", usage{prompt: 1_000_000, completion: 1_000_000}, nil)
if !ok {
t.Fatal(`costMicrosKnown("deepseek-chat", ...) ok = false, want true`)
}
want := meta.InputCostPerMTokMicroUSD + meta.OutputCostPerMTokMicroUSD // 1M tokens each side == exactly the per-M micro-USD price
if got != want {
t.Errorf(`costMicrosKnown("deepseek-chat", 1M prompt + 1M completion) = %d, want %d`, got, want)
}
}
// TestLookupPricing_OverrideWinsOverGeneratedTable (finding F2) pins that
// an operator override still takes priority over the generated-table
// fallback added by this fix, for a model that has no builtinPricing
// entry at all — the fallback must never shadow a configured override.
func TestLookupPricing_OverrideWinsOverGeneratedTable(t *testing.T) {
overrides := map[string]*ModelPricing{"deepseek-chat": {InputPerM: 9, OutputPerM: 9}}
price, ok := lookupPricing("deepseek-chat", overrides)
if !ok || price.InputPerM != 9 || price.OutputPerM != 9 {
t.Errorf(`lookupPricing("deepseek-chat", override) = %+v, %v, want the override {9 9}`, price, ok)
}
}
// TestCostMicrosKnown_RoundsCombinedTotal (finding F9, 2026-09 review)
// asserts the input and output totals are summed BEFORE dividing back
// down to micro-USD, rather than each direction truncated separately: at
// $0.02/M, 30 prompt tokens alone truncate to 0 (30*20_000/1_000_000
// floors to 0) and so do 30 completion tokens alone, but their COMBINED
// total (1,200,000 micro-USD-tokens) divides to a nonzero 1 micro-USD —
// the exact "small cheap request silently bills 0" gap the finding
// reports, which also meant account (limits.go) skipped the cost-counter
// write entirely for that traffic.
func TestCostMicrosKnown_RoundsCombinedTotal(t *testing.T) {
overrides := map[string]*ModelPricing{"cheap-model": {InputPerM: 0.02, OutputPerM: 0.02}}
got, ok := costMicrosKnown("cheap-model", usage{prompt: 30, completion: 30}, overrides)
if !ok {
t.Fatal("costMicrosKnown ok = false, want true")
}
if got != 1 {
t.Errorf("costMicrosKnown(prompt=30, completion=30 @ $0.02/M) = %d, want 1 (summed-then-divided, not truncated-to-0 per direction)", got)
}
}
// TestCostMicrosKnown_OverflowSaturates (finding F10, 2026-09 review)
// asserts an absurd upstream-reported token count saturates at
// math.MaxInt64 instead of silently wrapping to a negative or garbage
// value through an unchecked int64 multiply — upstream-controlled per
// limits.go's account (security audit run-1), since the token counts come
// from the provider's own reported usage.
func TestCostMicrosKnown_OverflowSaturates(t *testing.T) {
overrides := map[string]*ModelPricing{"expensive-model": {InputPerM: 75, OutputPerM: 75}}
got, ok := costMicrosKnown("expensive-model", usage{prompt: math.MaxInt64 / 2, completion: math.MaxInt64 / 2}, overrides)
if !ok {
t.Fatal("costMicrosKnown ok = false, want true")
}
// Both inputCost and outputCost individually overflow the int64
// multiply and saturate at math.MaxInt64 (saturatingMulInt64), and
// their sum saturates there too (saturatingAddInt64) rather than
// wrapping to a negative/garbage value — but the value costMicrosKnown
// actually RETURNS is that saturated total divided back down to
// micro-USD (roundedDivInt64, computeCostMicros' own final step), not
// raw math.MaxInt64 itself.
want := roundedDivInt64(math.MaxInt64, tokensPerMillion)
if got != want {
t.Errorf("costMicrosKnown with an absurd upstream-reported token count = %d, want %d (the saturated total divided back to micro-USD)", got, want)
}
if got <= 0 {
t.Errorf("costMicrosKnown with an absurd upstream-reported token count = %d, want a large positive value, not a wrapped negative/garbage one", got)
}
}
// TestValidatePricing_RejectsBadEntries (finding F4, 2026-09 review)
// table-tests validatePricing's construction-time checks: nil, NaN, Inf,
// and negative entries must all fail with a config error; a valid entry
// must pass unchanged.
func TestValidatePricing_RejectsBadEntries(t *testing.T) {
cases := []struct {
pricing map[string]*ModelPricing
name string
wantErr bool
}{
{nil, "nil map", false},
{map[string]*ModelPricing{}, "empty map", false},
{map[string]*ModelPricing{"m": {InputPerM: 1, OutputPerM: 2}}, "valid entry", false},
{map[string]*ModelPricing{"m": nil}, "nil entry", true},
{map[string]*ModelPricing{"m": {InputPerM: -1, OutputPerM: 1}}, "negative input", true},
{map[string]*ModelPricing{"m": {InputPerM: 1, OutputPerM: -1}}, "negative output", true},
{map[string]*ModelPricing{"m": {InputPerM: math.NaN(), OutputPerM: 1}}, "NaN input", true},
{map[string]*ModelPricing{"m": {InputPerM: 1, OutputPerM: math.Inf(1)}}, "+Inf output", true},
{map[string]*ModelPricing{"m": {InputPerM: math.Inf(-1), OutputPerM: 1}}, "-Inf input", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := validatePricing(c.pricing)
if (err != nil) != c.wantErr {
t.Errorf("validatePricing(%+v) err = %v, wantErr %v", c.pricing, err, c.wantErr)
}
})
}
}
+92 -17
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
@@ -109,6 +110,18 @@ func (a *anthropicAdapter) chatCompletion(ctx context.Context, w http.ResponseWr
}
delete(req, gatewayAliasKey)
streaming, _ := req["stream"].(bool)
// clientAskedUsage (M1 fix): stream_options.include_usage is an
// OpenAI-wire field with no Anthropic equivalent, so
// anthropicRequestFromOpenAI never forwards it upstream — it must be
// read here, before translation, so forwardStream knows whether to
// synthesize the final usage-only chunk OpenAI-compatible clients
// expect.
clientAskedUsage := false
if streaming {
if so, ok := req["stream_options"].(map[string]any); ok {
clientAskedUsage = isTruthy(so["include_usage"])
}
}
body, err := anthropicRequestFromOpenAI(req)
if err != nil {
@@ -133,7 +146,7 @@ func (a *anthropicAdapter) chatCompletion(ctx context.Context, w http.ResponseWr
}
if streaming && strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/event-stream") {
return a.forwardStream(w, resp.Body, gatewayModel)
return a.forwardStream(w, resp.Body, gatewayModel, clientAskedUsage)
}
// Either a non-streaming request, or a streaming one whose upstream
// ignored stream=true and answered with a normal JSON body.
@@ -180,7 +193,15 @@ func (a *anthropicAdapter) forwardJSON(w http.ResponseWriter, resp *http.Respons
// stream read error), forwardStream returns without writing "[DONE]" —
// the caller already saw the connection fail or an explicit upstream
// error chunk.
func (a *anthropicAdapter) forwardStream(w http.ResponseWriter, body io.Reader, gatewayModel string) (usage, error) {
//
// clientAskedUsage (M1 fix), when true, makes forwardStream write one
// extra chunk — st.usageChunk(), empty "choices" plus the accumulated
// usage — right before "[DONE]", the same final shape an OpenAI-type
// upstream sends when stream_options.include_usage is set
// (provider_openai.go's forwardStream). Anthropic's own stream never
// carries such a chunk, so this adapter synthesizes it instead of
// relying on anything upstream.
func (a *anthropicAdapter) forwardStream(w http.ResponseWriter, body io.Reader, gatewayModel string, clientAskedUsage bool) (usage, error) {
st := newAnthropicStreamState(gatewayModel, time.Now().Unix())
sw := newSSEWriter(w)
@@ -196,6 +217,11 @@ func (a *anthropicAdapter) forwardStream(w http.ResponseWriter, body io.Reader,
if err != nil {
return st.usage(), fmt.Errorf("%w: stream: %w", errUpstream, err)
}
if clientAskedUsage {
if werr := sw.writeData(st.usageChunk()); werr != nil {
return st.usage(), fmt.Errorf("%w: stream: %w", errUpstream, werr)
}
}
sw.writeDone()
return st.usage(), nil
}
@@ -228,37 +254,86 @@ func (a *anthropicAdapter) audioTranscription(_ context.Context, _ http.Response
return usage{}, &translateError{msg: "audio transcription not supported for anthropic models", notSupported: true}
}
// listModels implements providerAdapter: GET {base}/v1/models, parsing
// the same "data": [{"id": ...}] shape OpenAI's endpoint uses (Anthropic's
// 2026 Models API responds in the same shape). A non-2xx response —
// including a 404 from an older Anthropic API version that predates this
// endpoint — returns a *providerHTTPError as-is; the registry (Task 11)
// falls back to a provider's explicitly configured models on error rather
// than this adapter guessing at a fallback itself.
// anthropicModelsPayload is Anthropic's /v1/models response shape: the
// same {"data":[{"id"}]} list modelsPayload (provider_openai.go) reads,
// plus the cursor-pagination fields (M7 fix) Anthropic's Models API adds
// on top of it — has_more/last_id, paired with the after_id query
// parameter the next page's request carries.
type anthropicModelsPayload struct {
LastID string `json:"last_id"`
Data []struct {
ID string `json:"id"`
} `json:"data"`
HasMore bool `json:"has_more"`
}
// anthropicListModelsPageCap bounds how many pages listModels follows
// (M7 fix): a misbehaving upstream that never reports has_more:false, or
// whose last_id never advances, would otherwise page forever. 20 pages
// at Anthropic's own default page size (20 per page) covers 400 models —
// comfortably above any real catalog as of this writing — without
// risking an unbounded discovery loop against a single provider.
const anthropicListModelsPageCap = 20
// listModels implements providerAdapter: GET {base}/v1/models, following
// Anthropic's has_more/last_id cursor pagination (M7 fix) via the
// after_id query parameter, up to anthropicListModelsPageCap pages —
// without it, models past the first page never entered the registry and
// silently 404ed unless explicitly listed in config. A non-2xx response
// on any page — including a 404 from an older Anthropic API version that
// predates this endpoint — returns a *providerHTTPError as-is; the
// registry (Task 11) falls back to a provider's explicitly configured
// models on error rather than this adapter guessing at a fallback
// itself.
func (a *anthropicAdapter) listModels(ctx context.Context) ([]string, error) {
resp, err := upstreamJSON(ctx, a.client, http.MethodGet, a.baseURL+anthropicModelsPath, a.requestHeaders(false), nil, a.retry, a.timeout, a.adapterName)
var ids []string
afterID := ""
for page := 0; page < anthropicListModelsPageCap; page++ {
pageIDs, hasMore, lastID, err := a.listModelsPage(ctx, afterID)
if err != nil {
return nil, err
}
ids = append(ids, pageIDs...)
if !hasMore || lastID == "" {
break
}
afterID = lastID
}
return ids, nil
}
// listModelsPage fetches one page of Anthropic's /v1/models, starting
// after afterID (empty for the first page), and returns that page's
// model ids alongside has_more/last_id for listModels' own pagination
// loop above.
func (a *anthropicAdapter) listModelsPage(ctx context.Context, afterID string) (ids []string, hasMore bool, lastID string, err error) {
endpoint := a.baseURL + anthropicModelsPath
if afterID != "" {
endpoint += "?after_id=" + url.QueryEscape(afterID)
}
resp, err := upstreamJSON(ctx, a.client, http.MethodGet, endpoint, a.requestHeaders(false), nil, a.retry, a.timeout, a.adapterName)
if err != nil {
return nil, err
return nil, false, "", err
}
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, newProviderHTTPError(resp)
return nil, false, "", newProviderHTTPError(resp)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
if err != nil {
return nil, fmt.Errorf("%w: read response body: %w", errUpstream, err)
return nil, false, "", fmt.Errorf("%w: read response body: %w", errUpstream, err)
}
var payload modelsPayload
var payload anthropicModelsPayload
if err := json.Unmarshal(body, &payload); err != nil {
return nil, fmt.Errorf("%w: decode models response: %w", errUpstream, err)
return nil, false, "", fmt.Errorf("%w: decode models response: %w", errUpstream, err)
}
ids := make([]string, len(payload.Data))
ids = make([]string, len(payload.Data))
for i, d := range payload.Data {
ids[i] = d.ID
}
return ids, nil
return ids, payload.HasMore, payload.LastID, nil
}
+209
View File
@@ -181,6 +181,142 @@ func TestAnthropicAdapter_ChatCompletion_Streaming(t *testing.T) {
}
}
// sseDataChunks splits an SSE response body into its individual "data:
// ..." payloads, in wire order, stripping the "data: " prefix — a small
// shared parser for the M1 tests below, since forwardStream's per-event
// framing (sseWriter.writeData, sse.go) always separates events with a
// blank line.
func sseDataChunks(t *testing.T, body string) []string {
t.Helper()
var out []string
for _, frame := range strings.Split(body, "\n\n") {
frame = strings.TrimSpace(frame)
if frame == "" {
continue
}
data, ok := strings.CutPrefix(frame, "data: ")
if !ok {
t.Fatalf("SSE frame missing %q prefix: %q", "data: ", frame)
}
out = append(out, data)
}
return out
}
// TestAnthropicAdapter_ChatCompletion_Streaming_IncludeUsage_EmitsFinalUsageChunk
// is the M1 regression test: a client that sets
// stream_options.include_usage on a request routed to an anthropic-type
// provider must still get a final usage-only chunk on the wire, exactly
// as an OpenAI-type provider's own forwardStream relays from a real
// upstream usage chunk (provider_openai.go) — Anthropic has no such
// upstream chunk, so the adapter must synthesize one from the usage it
// already accumulated translating message_start/message_delta.
func TestAnthropicAdapter_ChatCompletion_Streaming_IncludeUsage_EmitsFinalUsageChunk(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
f, ok := w.(http.Flusher)
if !ok {
t.Fatalf("httptest ResponseWriter does not implement http.Flusher")
}
for _, frame := range anthropicStreamFrames {
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", frame.event, frame.data)
f.Flush()
}
}))
defer srv.Close()
a, err := newAnthropicAdapter("p1", srv.URL, "sk-ant-test")
if err != nil {
t.Fatalf("newAnthropicAdapter: %v", err)
}
rec := newRecordingResponseWriter()
req := map[string]any{
"model": "claude-opus-5",
"stream": true,
"stream_options": map[string]any{"include_usage": true},
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
}
u, err := a.chatCompletion(context.Background(), rec, req)
if err != nil {
t.Fatalf("chatCompletion: %v", err)
}
if u.prompt != 6 || u.completion != 3 {
t.Fatalf("usage = %+v, want {prompt:6 completion:3}", u)
}
chunks := sseDataChunks(t, rec.body.String())
if len(chunks) < 2 {
t.Fatalf("got %d SSE frames, want at least a usage chunk followed by [DONE]", len(chunks))
}
if chunks[len(chunks)-1] != "[DONE]" {
t.Fatalf("last SSE frame = %q, want [DONE]", chunks[len(chunks)-1])
}
usageFrame := chunks[len(chunks)-2]
var got struct {
Usage *struct {
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
TotalTokens int64 `json:"total_tokens"`
} `json:"usage"`
Choices []any `json:"choices"`
}
if err := json.Unmarshal([]byte(usageFrame), &got); err != nil {
t.Fatalf("decode usage chunk %q: %v", usageFrame, err)
}
if len(got.Choices) != 0 {
t.Errorf("usage chunk choices = %v, want empty", got.Choices)
}
if got.Usage == nil {
t.Fatalf("usage chunk has no usage object: %q", usageFrame)
}
if got.Usage.PromptTokens != 6 || got.Usage.CompletionTokens != 3 || got.Usage.TotalTokens != 9 {
t.Errorf("usage chunk = %+v, want {prompt_tokens:6 completion_tokens:3 total_tokens:9}", *got.Usage)
}
}
// TestAnthropicAdapter_ChatCompletion_Streaming_NoIncludeUsage_NoFinalUsageChunk
// proves the M1 fix is opt-in: a client that never sets
// stream_options.include_usage gets the stream unchanged from before the
// fix — no extra usage-only chunk on the wire.
func TestAnthropicAdapter_ChatCompletion_Streaming_NoIncludeUsage_NoFinalUsageChunk(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
f, ok := w.(http.Flusher)
if !ok {
t.Fatalf("httptest ResponseWriter does not implement http.Flusher")
}
for _, frame := range anthropicStreamFrames {
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", frame.event, frame.data)
f.Flush()
}
}))
defer srv.Close()
a, err := newAnthropicAdapter("p1", srv.URL, "sk-ant-test")
if err != nil {
t.Fatalf("newAnthropicAdapter: %v", err)
}
rec := newRecordingResponseWriter()
req := map[string]any{
"model": "claude-opus-5",
"stream": true,
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
}
if _, err := a.chatCompletion(context.Background(), rec, req); err != nil {
t.Fatalf("chatCompletion: %v", err)
}
for _, chunk := range sseDataChunks(t, rec.body.String()) {
if strings.Contains(chunk, `"usage"`) {
t.Errorf("SSE frame %q carries a usage field though the client never set stream_options.include_usage", chunk)
}
}
}
// TestAnthropicAdapter_ChatCompletion_Streaming_CaseInsensitiveContentType
// proves the streaming-vs-JSON-fallback gate matches an upstream's
// Content-Type case-insensitively — review fix, mirroring the same fix
@@ -329,3 +465,76 @@ func TestAnthropicAdapter_ListModels(t *testing.T) {
t.Errorf("got %#v, want %#v", got, want)
}
}
// TestAnthropicAdapter_ListModels_FollowsPagination is the M7 regression
// test: has_more:true plus a last_id must make listModels fetch a second
// page via ?after_id=<last_id> and return the union of both pages, not
// just the first page's 20 (Anthropic's own default page size).
func TestAnthropicAdapter_ListModels_FollowsPagination(t *testing.T) {
var gotAfterIDs []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAfterIDs = append(gotAfterIDs, r.URL.Query().Get("after_id"))
w.Header().Set("Content-Type", "application/json")
if r.URL.Query().Get("after_id") == "" {
_, _ = w.Write([]byte(`{"data":[{"id":"claude-opus-5"},{"id":"claude-sonnet-5"}],"has_more":true,"last_id":"claude-sonnet-5"}`))
return
}
_, _ = w.Write([]byte(`{"data":[{"id":"claude-haiku-5"}],"has_more":false,"last_id":"claude-haiku-5"}`))
}))
defer srv.Close()
a, err := newAnthropicAdapter("p1", srv.URL, "sk-ant-test")
if err != nil {
t.Fatalf("newAnthropicAdapter: %v", err)
}
got, err := a.listModels(context.Background())
if err != nil {
t.Fatalf("listModels: %v", err)
}
want := []string{"claude-opus-5", "claude-sonnet-5", "claude-haiku-5"}
if len(got) != len(want) {
t.Fatalf("got %#v, want %#v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("got[%d] = %q, want %q", i, got[i], want[i])
}
}
if len(gotAfterIDs) != 2 || gotAfterIDs[0] != "" || gotAfterIDs[1] != "claude-sonnet-5" {
t.Errorf("after_id sequence = %#v, want [\"\", \"claude-sonnet-5\"] (first page unqualified, second page's after_id = first page's last_id)", gotAfterIDs)
}
}
// TestAnthropicAdapter_ListModels_PageCapStopsRunawayPagination proves
// the M7 fix's other half: an upstream that always reports has_more:true
// with an ever-advancing last_id does not page forever — listModels
// stops after anthropicListModelsPageCap requests and returns
// successfully with whatever it accumulated, rather than looping until
// context cancellation or exhausting memory.
func TestAnthropicAdapter_ListModels_PageCapStopsRunawayPagination(t *testing.T) {
var requests int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
w.Header().Set("Content-Type", "application/json")
id := fmt.Sprintf("model-%d", requests)
fmt.Fprintf(w, `{"data":[{"id":%q}],"has_more":true,"last_id":%q}`, id, id)
}))
defer srv.Close()
a, err := newAnthropicAdapter("p1", srv.URL, "sk-ant-test")
if err != nil {
t.Fatalf("newAnthropicAdapter: %v", err)
}
got, err := a.listModels(context.Background())
if err != nil {
t.Fatalf("listModels: %v", err)
}
if requests != anthropicListModelsPageCap {
t.Errorf("upstream received %d requests, want exactly %d (the page cap)", requests, anthropicListModelsPageCap)
}
if len(got) != anthropicListModelsPageCap {
t.Errorf("got %d ids, want %d (one per page up to the cap)", len(got), anthropicListModelsPageCap)
}
}
+75 -12
View File
@@ -114,6 +114,18 @@ func (a *geminiAdapter) chatCompletion(ctx context.Context, w http.ResponseWrite
}
delete(req, gatewayAliasKey)
streaming, _ := req["stream"].(bool)
// clientAskedUsage (M1 fix): stream_options.include_usage is an
// OpenAI-wire field with no Gemini equivalent, so
// geminiRequestFromOpenAI never forwards it upstream — it must be read
// here, before translation, so forwardStream knows whether to
// synthesize the final usage-only chunk OpenAI-compatible clients
// expect.
clientAskedUsage := false
if streaming {
if so, ok := req["stream_options"].(map[string]any); ok {
clientAskedUsage = isTruthy(so["include_usage"])
}
}
body, err := geminiRequestFromOpenAI(req)
if err != nil {
@@ -146,7 +158,7 @@ func (a *geminiAdapter) chatCompletion(ctx context.Context, w http.ResponseWrite
}
if streaming && strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/event-stream") {
return a.forwardStream(w, resp.Body, responseModel)
return a.forwardStream(w, resp.Body, responseModel, clientAskedUsage)
}
// Either a non-streaming request, or a streaming one whose upstream
// ignored stream=true and answered with a normal JSON body.
@@ -191,7 +203,15 @@ func (a *geminiAdapter) forwardJSON(w http.ResponseWriter, resp *http.Response,
// signals the stream ended. On a translate error (a malformed chunk, or a
// genuine stream read error), forwardStream returns without writing
// "[DONE]".
func (a *geminiAdapter) forwardStream(w http.ResponseWriter, body io.Reader, gatewayModel string) (usage, error) {
//
// clientAskedUsage (M1 fix), when true, makes forwardStream write one
// extra chunk — st.usageChunk(), empty "choices" plus the accumulated
// usage — right before "[DONE]", the same final shape an OpenAI-type
// upstream sends when stream_options.include_usage is set
// (provider_openai.go's forwardStream). Gemini's own stream never carries
// such a chunk, so this adapter synthesizes it instead of relying on
// anything upstream.
func (a *geminiAdapter) forwardStream(w http.ResponseWriter, body io.Reader, gatewayModel string, clientAskedUsage bool) (usage, error) {
st := newGeminiStreamState(gatewayModel, time.Now().Unix())
sw := newSSEWriter(w)
@@ -207,6 +227,11 @@ func (a *geminiAdapter) forwardStream(w http.ResponseWriter, body io.Reader, gat
if err != nil {
return st.usage(), fmt.Errorf("%w: stream: %w", errUpstream, err)
}
if clientAskedUsage {
if werr := sw.writeData(st.usageChunk()); werr != nil {
return st.usage(), fmt.Errorf("%w: stream: %w", errUpstream, werr)
}
}
sw.writeDone()
return st.usage(), nil
}
@@ -343,41 +368,79 @@ func (a *geminiAdapter) audioTranscription(_ context.Context, _ http.ResponseWri
// geminiModelsListPayload is the "models": [{"name": "models/..."}, ...]
// shape Gemini's models.list endpoint returns (its "name" field is the
// full "models/{model}" resource name, unlike OpenAI's bare "id").
// full "models/{model}" resource name, unlike OpenAI's bare "id"), plus
// the cursor-pagination field (M7 fix) the endpoint adds on top —
// nextPageToken, paired with the pageToken query parameter the next
// page's request carries.
type geminiModelsListPayload struct {
Models []struct {
NextPageToken string `json:"nextPageToken"`
Models []struct {
Name string `json:"name"`
} `json:"models"`
}
// geminiListModelsPageCap bounds how many pages listModels follows (M7
// fix): a misbehaving upstream that always returns a non-empty
// nextPageToken would otherwise page forever. 20 pages covers a large
// catalog without risking an unbounded discovery loop against a single
// provider.
const geminiListModelsPageCap = 20
// listModels implements providerAdapter: GET {base}/v1beta/models,
// stripping the "models/" prefix off each entry's "name" to return bare
// model ids, matching the shape openai-type and anthropic-type adapters
// both return.
// both return, following Gemini's nextPageToken cursor pagination (M7
// fix) via the pageToken query parameter, up to geminiListModelsPageCap
// pages.
func (a *geminiAdapter) listModels(ctx context.Context) ([]string, error) {
resp, err := upstreamJSON(ctx, a.client, http.MethodGet, a.baseURL+"/v1beta/models", a.requestHeaders(false), nil, a.retry, a.timeout, a.adapterName)
var ids []string
pageToken := ""
for page := 0; page < geminiListModelsPageCap; page++ {
pageIDs, nextPageToken, err := a.listModelsPage(ctx, pageToken)
if err != nil {
return nil, err
}
ids = append(ids, pageIDs...)
if nextPageToken == "" {
break
}
pageToken = nextPageToken
}
return ids, nil
}
// listModelsPage fetches one page of Gemini's /v1beta/models, starting
// at pageToken (empty for the first page), and returns that page's bare
// model ids alongside nextPageToken for listModels' own pagination loop
// above.
func (a *geminiAdapter) listModelsPage(ctx context.Context, pageToken string) (ids []string, nextPageToken string, err error) {
endpoint := a.baseURL + "/v1beta/models"
if pageToken != "" {
endpoint += "?pageToken=" + url.QueryEscape(pageToken)
}
resp, err := upstreamJSON(ctx, a.client, http.MethodGet, endpoint, a.requestHeaders(false), nil, a.retry, a.timeout, a.adapterName)
if err != nil {
return nil, err
return nil, "", err
}
defer resp.Body.Close() //nolint:errcheck
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, newProviderHTTPError(resp)
return nil, "", newProviderHTTPError(resp)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
if err != nil {
return nil, fmt.Errorf("%w: read response body: %w", errUpstream, err)
return nil, "", fmt.Errorf("%w: read response body: %w", errUpstream, err)
}
var payload geminiModelsListPayload
if err := json.Unmarshal(body, &payload); err != nil {
return nil, fmt.Errorf("%w: decode models response: %w", errUpstream, err)
return nil, "", fmt.Errorf("%w: decode models response: %w", errUpstream, err)
}
ids := make([]string, len(payload.Models))
ids = make([]string, len(payload.Models))
for i, m := range payload.Models {
ids[i] = strings.TrimPrefix(m.Name, "models/")
}
return ids, nil
return ids, payload.NextPageToken, nil
}
+184
View File
@@ -318,6 +318,120 @@ func TestGeminiAdapter_ChatCompletion_Streaming(t *testing.T) {
}
}
// TestGeminiAdapter_ChatCompletion_Streaming_IncludeUsage_EmitsFinalUsageChunk
// is the M1 regression test: a client that sets
// stream_options.include_usage on a request routed to a gemini-type
// provider must still get a final usage-only chunk on the wire, the same
// way an OpenAI-type provider's own forwardStream relays it from a real
// upstream usage chunk (provider_openai.go) — Gemini's own stream has no
// such chunk, so the adapter must synthesize one from the
// last-usageMetadata-wins total it already accumulated.
func TestGeminiAdapter_ChatCompletion_Streaming_IncludeUsage_EmitsFinalUsageChunk(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
f, ok := w.(http.Flusher)
if !ok {
t.Fatalf("httptest ResponseWriter does not implement http.Flusher")
}
for _, data := range geminiStreamFrames {
fmt.Fprintf(w, "data: %s\n\n", data)
f.Flush()
}
}))
defer srv.Close()
a, err := newGeminiAdapter("p1", srv.URL, "AIza-test")
if err != nil {
t.Fatalf("newGeminiAdapter: %v", err)
}
rec := newRecordingResponseWriter()
req := map[string]any{
"model": "gemini-2.5-pro",
"stream": true,
"stream_options": map[string]any{"include_usage": true},
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
}
u, err := a.chatCompletion(context.Background(), rec, req)
if err != nil {
t.Fatalf("chatCompletion: %v", err)
}
if u.prompt != 6 || u.completion != 3 {
t.Fatalf("usage = %+v, want {prompt:6 completion:3}", u)
}
chunks := sseDataChunks(t, rec.body.String())
if len(chunks) < 2 {
t.Fatalf("got %d SSE frames, want at least a usage chunk followed by [DONE]", len(chunks))
}
if chunks[len(chunks)-1] != "[DONE]" {
t.Fatalf("last SSE frame = %q, want [DONE]", chunks[len(chunks)-1])
}
usageFrame := chunks[len(chunks)-2]
var got struct {
Usage *struct {
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
TotalTokens int64 `json:"total_tokens"`
} `json:"usage"`
Choices []any `json:"choices"`
}
if err := json.Unmarshal([]byte(usageFrame), &got); err != nil {
t.Fatalf("decode usage chunk %q: %v", usageFrame, err)
}
if len(got.Choices) != 0 {
t.Errorf("usage chunk choices = %v, want empty", got.Choices)
}
if got.Usage == nil {
t.Fatalf("usage chunk has no usage object: %q", usageFrame)
}
if got.Usage.PromptTokens != 6 || got.Usage.CompletionTokens != 3 || got.Usage.TotalTokens != 9 {
t.Errorf("usage chunk = %+v, want {prompt_tokens:6 completion_tokens:3 total_tokens:9}", *got.Usage)
}
}
// TestGeminiAdapter_ChatCompletion_Streaming_NoIncludeUsage_NoFinalUsageChunk
// proves the M1 fix is opt-in: a client that never sets
// stream_options.include_usage gets the stream unchanged from before the
// fix — no extra usage-only chunk on the wire.
func TestGeminiAdapter_ChatCompletion_Streaming_NoIncludeUsage_NoFinalUsageChunk(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
f, ok := w.(http.Flusher)
if !ok {
t.Fatalf("httptest ResponseWriter does not implement http.Flusher")
}
for _, data := range geminiStreamFrames {
fmt.Fprintf(w, "data: %s\n\n", data)
f.Flush()
}
}))
defer srv.Close()
a, err := newGeminiAdapter("p1", srv.URL, "AIza-test")
if err != nil {
t.Fatalf("newGeminiAdapter: %v", err)
}
rec := newRecordingResponseWriter()
req := map[string]any{
"model": "gemini-2.5-pro",
"stream": true,
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
}
if _, err := a.chatCompletion(context.Background(), rec, req); err != nil {
t.Fatalf("chatCompletion: %v", err)
}
for _, chunk := range sseDataChunks(t, rec.body.String()) {
if strings.Contains(chunk, `"usage"`) {
t.Errorf("SSE frame %q carries a usage field though the client never set stream_options.include_usage", chunk)
}
}
}
// TestGeminiAdapter_ChatCompletion_Streaming_CaseInsensitiveContentType
// proves the streaming-vs-JSON-fallback gate matches an upstream's
// Content-Type case-insensitively, per lesson (g) — tested with
@@ -570,6 +684,76 @@ func TestGeminiAdapter_ListModels(t *testing.T) {
}
}
// TestGeminiAdapter_ListModels_FollowsPagination is the M7 regression
// test: a non-empty nextPageToken must make listModels fetch a second
// page via ?pageToken=<token> and return the union of both pages.
func TestGeminiAdapter_ListModels_FollowsPagination(t *testing.T) {
var gotPageTokens []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPageTokens = append(gotPageTokens, r.URL.Query().Get("pageToken"))
w.Header().Set("Content-Type", "application/json")
if r.URL.Query().Get("pageToken") == "" {
_, _ = w.Write([]byte(`{"models":[{"name":"models/gemini-2.5-pro"},{"name":"models/gemini-2.5-flash"}],"nextPageToken":"page2"}`))
return
}
_, _ = w.Write([]byte(`{"models":[{"name":"models/gemini-2.0-flash"}]}`))
}))
defer srv.Close()
a, err := newGeminiAdapter("p1", srv.URL, "AIza-test")
if err != nil {
t.Fatalf("newGeminiAdapter: %v", err)
}
got, err := a.listModels(context.Background())
if err != nil {
t.Fatalf("listModels: %v", err)
}
want := []string{"gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash"}
if len(got) != len(want) {
t.Fatalf("got %#v, want %#v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("got[%d] = %q, want %q", i, got[i], want[i])
}
}
if len(gotPageTokens) != 2 || gotPageTokens[0] != "" || gotPageTokens[1] != "page2" {
t.Errorf("pageToken sequence = %#v, want [\"\", \"page2\"] (first page unqualified, second page's pageToken = first page's nextPageToken)", gotPageTokens)
}
}
// TestGeminiAdapter_ListModels_PageCapStopsRunawayPagination proves the
// M7 fix's other half: an upstream that always returns a nextPageToken
// does not page forever — listModels stops after
// geminiListModelsPageCap requests and returns successfully with
// whatever it accumulated.
func TestGeminiAdapter_ListModels_PageCapStopsRunawayPagination(t *testing.T) {
var requests int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"models":[{"name":"models/model-%d"}],"nextPageToken":"tok-%d"}`, requests, requests)
}))
defer srv.Close()
a, err := newGeminiAdapter("p1", srv.URL, "AIza-test")
if err != nil {
t.Fatalf("newGeminiAdapter: %v", err)
}
got, err := a.listModels(context.Background())
if err != nil {
t.Fatalf("listModels: %v", err)
}
if requests != geminiListModelsPageCap {
t.Errorf("upstream received %d requests, want exactly %d (the page cap)", requests, geminiListModelsPageCap)
}
if len(got) != geminiListModelsPageCap {
t.Errorf("got %d ids, want %d (one per page up to the cap)", len(got), geminiListModelsPageCap)
}
}
// TestGeminiAdapter_HTTPClient proves httpClient returns the adapter's own
// *http.Client — the one newGeminiAdapter constructed via
// newAdapterHTTPClient — not a fresh or nil client.
+3
View File
@@ -934,7 +934,9 @@ func TestValidateConfigName(t *testing.T) {
{"provider", "v1", true}, // reserved: unified API namespace
{"provider", "mcp", true}, // reserved: MCP target-proxy prefix
{"provider", "a2a", true}, // reserved: A2A target-proxy prefix
{"provider", "admin", true}, // reserved: admin dashboard's own fixed routes (review-auth.md F9)
{"mcpServers", "v1", true}, // same reserved set applies to every kind
{"mcpServers", "admin", true},
{"agents", "a2a", true},
{"provider", ".", true}, // dot-only: matches configNamePattern's character class but rejected anyway
{"provider", "..", true}, // dot-only: same
@@ -962,6 +964,7 @@ func TestBuildAdapters_RejectsReservedOrInvalidProviderName(t *testing.T) {
{"v1"},
{"mcp"},
{"a2a"},
{"admin"},
{"has a space"},
}
for _, tt := range tests {
+12 -5
View File
@@ -506,11 +506,18 @@ var configNamePattern = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
// own fixed top-level routes: "v1" is the unified API namespace
// (/v1/chat/completions, /v1/models, /v1/embeddings, /v1/mcp/servers,
// /v1/agents), "mcp" and "a2a" are the MCP/A2A target-proxy prefixes
// (/mcp/{name}/..., /a2a/{name}/...). ServeHTTP checks those fixed routes
// ahead of passthrough/target routing (llmgateway.go), so a provider, MCP
// server, or agent configured under one of these names would never be
// reachable — reject it here instead of silently shadowing it.
var reservedConfigNames = map[string]bool{"v1": true, "mcp": true, "a2a": true}
// (/mcp/{name}/..., /a2a/{name}/...), and "admin" for the dashboard's own
// fixed /admin, /admin/assets/*, and /admin/api/* routes (review-auth.md
// finding F9: a provider literally named "admin" would otherwise shadow
// every one of those, an easy config typo away). ServeHTTP checks those
// fixed routes ahead of passthrough/target routing (llmgateway.go), so a
// provider, MCP server, or agent configured under one of these names
// would never be reachable — reject it here instead of silently
// shadowing it. Reserved unconditionally, the same as "v1"/"mcp"/"a2a"
// already are, rather than only when admin.enabled: a provider named
// "admin" configured before the dashboard is turned on would otherwise
// silently lose its own routes the moment someone later enables it.
var reservedConfigNames = map[string]bool{"v1": true, "mcp": true, "a2a": true, "admin": true}
// dotOnlyConfigNames are the two names configNamePattern's character class
// permits (it allows ".") but that are rejected anyway: "." and ".." read
+168 -147
View File
@@ -13,10 +13,10 @@ import (
type redisStore struct {
client *respClient
// expireSeen and expireOrder implement needsExpire/commitExpire/
// forgetExpire's bounded, process-lifetime "does this key already
// forgetExpireLocked's bounded, process-lifetime "does this key already
// have a confirmed EXPIRE on Redis" tracker (perf finding 2,
// 2026-08-2x audit) — see needsExpire's own doc comment. Guarded by
// expireMu since incrBy/incrMulti/incrAndGetMulti can run
// expireMu since incrMulti/incrAndGetMulti can run
// concurrently across goroutines sharing one redisStore (the limiter
// calls them from every request's goroutine).
expireSeen map[string]struct{}
@@ -61,22 +61,24 @@ func (s *redisStore) needsExpireBatch(entries []counterIncr) []bool {
// settleExpireBatch applies incrMulti/incrAndGetMulti's post-pipeline
// expire bookkeeping for a whole batch in ONE expireMu acquisition:
// sent[i] is whether THIS call actually sent EXPIRE for entry i
// (needsExpireBatch's own return, reused after the reply arrives — same
// as incrMulti's pre-batching sendExpire[i]); firstWrite[i] is whether
// entry i's INCRBY reply revealed it was recreated (reply == delta), the
// confirmed[i] is whether THIS call sent EXPIRE for entry i AND its reply
// was literally the integer 1 (applyIncrReplies' confirmed — finding F6,
// 2026-09 review: a sent-but-failed EXPIRE is never committed, so
// needsExpire keeps asking); firstWrite[i] is whether entry i's EXPIRE was
// not sent and its INCRBY reply revealed it was recreated (reply ==
// delta), the
// same commit-after-success/forget-on-recreate contract as commitExpire/
// forgetExpire's own doc comments, just decided per-entry and applied
// forgetExpireLocked's own doc comments, just decided per-entry and applied
// together here instead of via len(entries) separate commitExpire/
// forgetExpire calls. Only entries[:n] for whatever n the caller actually
// forgetExpireLocked calls. Only entries[:n] for whatever n the caller actually
// finished processing should ever be passed in — see incrMulti/
// incrAndGetMulti's own `processed` handling for why a partial prefix,
// not always the whole batch, is what gets settled.
func (s *redisStore) settleExpireBatch(entries []counterIncr, sent, firstWrite []bool) {
func (s *redisStore) settleExpireBatch(entries []counterIncr, confirmed, firstWrite []bool) {
s.expireMu.Lock()
defer s.expireMu.Unlock()
for i, e := range entries {
if sent[i] {
if confirmed[i] {
s.commitExpireLocked(e.key)
} else if firstWrite[i] {
s.forgetExpireLocked(e.key)
@@ -89,8 +91,8 @@ func (s *redisStore) settleExpireBatch(entries []counterIncr, sent, firstWrite [
// entry's commit into the ONE lock acquisition that method takes for the
// whole call, instead of one lock per key the way calling the exported,
// self-locking commitExpire per entry would. commitExpire itself is now
// just commitExpireLocked wrapped in its own lock, for incrBy's
// single-key path.
// just commitExpireLocked wrapped in its own lock, for
// repairRecreatedExpires' per-key commits.
func (s *redisStore) commitExpireLocked(key string) {
if _, ok := s.expireSeen[key]; ok {
return
@@ -104,14 +106,35 @@ func (s *redisStore) commitExpireLocked(key string) {
s.expireOrder = append(s.expireOrder, key)
}
// forgetExpireLocked is forgetExpire's body without its own lock/unlock —
// callers already hold expireMu (settleExpireBatch), the same relationship
// commitExpireLocked has to commitExpire. expireOrder may still carry a
// stale entry for key afterward; left as-is, exactly as forgetExpire's own
// doc comment explains — the only consequence is commitExpireLocked's own
// FIFO eviction occasionally evicting a key one step early if it is
// re-committed at a new position later, itself always safe (a spurious
// extra EXPIRE, the same accepted cost as any other eviction).
// forgetExpireLocked removes key from the confirmed-EXPIRE set — called when an
// INCRBY reply reveals key did NOT actually exist before this call (its
// returned value equals exactly this call's own delta — Redis treats a
// missing key as 0 for INCRBY), even though this process's cache
// believed EXPIRE was already confirmed for it (review finding,
// 2026-08-2x, CRITICAL, path 2): Redis lost the key — restart, maxmemory
// eviction, FLUSHDB, or a failover to a replica that never had it — and
// this call's own INCRBY just recreated it with NO TTL, because the
// (stale) cache made the caller skip sending EXPIRE this round.
//
// Forgetting key here does not fix THIS round's missing TTL — the
// decision to skip EXPIRE was already made and sent before this reply
// arrived — but it makes the VERY NEXT increment for key resend EXPIRE
// (needsExpire will report true again), bounding the immortal-key window
// to at most one skipped cycle instead of forever. An active counter (the
// only kind this matters for) gets incremented again almost immediately,
// so this self-heals in practice. repairRecreatedExpires (finding F7,
// 2026-09 review) also re-arms the TTL inside the same call; this forget
// is the fallback when that best-effort repair fails.
//
// Callers already hold expireMu (settleExpireBatch), the same
// relationship commitExpireLocked has to commitExpire. There is no
// self-locking wrapper: settleExpireBatch is the only caller, since the
// single-key incrBy path that used one was removed. expireOrder may still
// carry a stale entry for key afterward; left as-is — the only
// consequence is commitExpireLocked's own FIFO eviction occasionally
// evicting a key one step early if it is re-committed at a new position
// later, itself always safe (a spurious extra EXPIRE, the same accepted
// cost as any other eviction).
func (s *redisStore) forgetExpireLocked(key string) {
delete(s.expireSeen, key)
}
@@ -143,7 +166,7 @@ func (s *redisStore) forgetExpireLocked(key string) {
// volatile-*, a TTL-less key is not evictable, so it can drive Redis to
// OOM-on-write). Marking now happens ONLY in commitExpire, called by the
// caller after confirming the pipeline that carried EXPIRE actually
// succeeded (commit-after-success). See commitExpire and forgetExpire
// succeeded (commit-after-success). See commitExpire and forgetExpireLocked
// for the other half of the fix: Redis can also independently lose an
// already-EXPIRE-confirmed key (restart, maxmemory eviction, FLUSHDB, a
// failover to a replica missing it) without this process's cache ever
@@ -165,32 +188,9 @@ func (s *redisStore) commitExpire(key string) {
s.commitExpireLocked(key)
}
// forgetExpire removes key from the confirmed-EXPIRE set — called when an
// INCRBY reply reveals key did NOT actually exist before this call (its
// returned value equals exactly this call's own delta — Redis treats a
// missing key as 0 for INCRBY), even though this process's cache
// believed EXPIRE was already confirmed for it (review finding,
// 2026-08-2x, CRITICAL, path 2): Redis lost the key — restart, maxmemory
// eviction, FLUSHDB, or a failover to a replica that never had it — and
// this call's own INCRBY just recreated it with NO TTL, because the
// (stale) cache made the caller skip sending EXPIRE this round.
//
// Forgetting key here does not fix THIS round's missing TTL — the
// decision to skip EXPIRE was already made and sent before this reply
// arrived — but it makes the VERY NEXT increment for key resend EXPIRE
// (needsExpire will report true again), bounding the immortal-key window
// to at most one skipped cycle instead of forever. An active counter (the
// only kind this matters for) gets incremented again almost immediately,
// so this self-heals in practice.
func (s *redisStore) forgetExpire(key string) {
s.expireMu.Lock()
defer s.expireMu.Unlock()
s.forgetExpireLocked(key)
}
// ttlToSeconds converts ttl to whole Redis EXPIRE seconds, rounded up,
// with a floor of 1s so a sub-second ttl never turns into EXPIRE 0 (an
// immediate delete). Shared by incrBy and incrMulti.
// immediate delete). Shared by incrMulti and incrAndGetMulti.
func ttlToSeconds(ttl time.Duration) int64 {
s := int64(ttl / time.Second)
if ttl%time.Second != 0 {
@@ -202,88 +202,72 @@ func ttlToSeconds(ttl time.Duration) int64 {
return s
}
// incrBy implements counterStore: INCRBY key n, then (only when
// needsExpire(key) is true) EXPIRE key ttlToSeconds(ttl), sent as one
// pipeline so both commands share a single round trip when EXPIRE is
// included. It returns INCRBY's resulting counter value; EXPIRE's reply
// is not otherwise inspected — a failed EXPIRE right after a successful
// INCRBY on the same key would only leave that key without a fresh TTL,
// not corrupt the count.
// repairRecreatedExpires immediately sends one more pipeline of EXPIRE
// commands for every (key, ttl) pair whose INCRBY reply just revealed
// Redis silently recreated it with no TTL, even though this process's own
// cache believed it already had a confirmed EXPIRE (finding F7, 2026-09
// review — v == delta in incrMulti/incrAndGetMulti above; see
// forgetExpireLocked's own doc comment for why that combination means Redis,
// not this process, lost the key). forgetExpireLocked alone only guarantees
// THIS key's own NEXT increment resends EXPIRE — a different Traefik
// instance whose own cache never forgot the key (it never observed the
// loss) can keep skipping EXPIRE for it indefinitely, exactly the
// multi-replica desync scenario the review reports. Repairing here,
// inside the very call that discovered the loss, fixes the key
// regardless of which process or key gets incremented next, without
// adding a persistent cross-call pending-repair queue.
//
// expireSeen bookkeeping (perf finding 2, review fix, CRITICAL,
// 2026-08-2x) happens strictly AFTER the pipeline succeeds — never
// before: commitExpire(key) only when this call actually sent (and
// confirmed) EXPIRE; forgetExpire(key) when the INCRBY reply reveals key
// was recreated (v == n) despite this process's cache believing it
// already had a confirmed EXPIRE — see both methods' own doc comments.
// recreated[i] marks entries[i] for repair (applyIncrReplies' firstWrite).
// It is usually all false (the common case — nothing to repair this call,
// and no allocation or round trip happens); a single pipeline batches
// every repair a whole incrMulti/incrAndGetMulti call needs, so a batch
// with several simultaneously-recreated keys still costs one extra round
// trip, not one per key.
//
// Semantics are deliberately at-least-once, not exactly-once, both in the
// conservative direction (never under-counts): respClient.pipeline's
// reconnect-once retry can re-send this same INCRBY if the first attempt's
// reply was lost after the server already applied it (e.g. the connection
// dropped between the server processing INCRBY and the client reading its
// reply), which can over-count by n on that key; and the limiter's
// failOpen path can additionally count the same request in its in-process
// fallback store when a call to this method errors out after a partial
// success upstream. Both are accepted trade-offs — a rate/budget counter
// that occasionally over-counts by one request's worth is fail-safe (more
// restrictive than reality), never fail-open in the unsafe direction.
func (s *redisStore) incrBy(key string, n int64, ttl time.Duration) (int64, error) {
sendExpire := s.needsExpire(key)
cmds := [][]string{{"INCRBY", key, strconv.FormatInt(n, 10)}}
if sendExpire {
cmds = append(cmds, []string{"EXPIRE", key, strconv.FormatInt(ttlToSeconds(ttl), 10)})
// Best-effort: this pipeline's own failure, or an EXPIRE reply other than
// 1, is not propagated as an error to the caller — the INCRBY values
// already computed are correct and must still be returned regardless. A
// repair that fails leaves the key exactly as unconfirmed as it already
// was (needsExpire keeps reporting true), so the ordinary per-key
// self-heal on this key's own next increment still applies as a
// fallback.
func (s *redisStore) repairRecreatedExpires(entries []counterIncr, recreated []bool) {
var keys []string
var cmds [][]string
for i, e := range entries {
if !recreated[i] {
continue
}
keys = append(keys, e.key)
cmds = append(cmds, []string{"EXPIRE", e.key, strconv.FormatInt(ttlToSeconds(e.ttl), 10)})
}
if len(cmds) == 0 {
return
}
replies, err := s.client.pipeline(cmds)
if err != nil {
return 0, fmt.Errorf("redisStore: incrBy %q: %w", key, err)
if err != nil || len(replies) != len(cmds) {
return
}
if len(replies) == 0 {
return 0, fmt.Errorf("redisStore: incrBy %q: empty pipeline reply", key)
for i, k := range keys {
if n, ok := replies[i].(int64); ok && n == 1 {
s.commitExpire(k)
}
}
if e, ok := replies[0].(error); ok {
return 0, fmt.Errorf("redisStore: incrBy %q: INCRBY failed: %w", key, e)
}
v, ok := replies[0].(int64)
if !ok {
return 0, fmt.Errorf("redisStore: incrBy %q: unexpected INCRBY reply type %T", key, replies[0])
}
if sendExpire {
s.commitExpire(key)
} else if v == n {
s.forgetExpire(key)
}
return v, nil
}
// incrMulti implements counterStore: every entry's INCRBY, plus an EXPIRE
// only when needsExpire(e.key) is true, sent as ONE pipeline (perf
// review, 2026-08-21) — one round trip regardless of len(entries) or how
// many of them get an EXPIRE this time, extending incrBy's own
// single-key pipelining to a whole batch of counters at once.
// sendExpire[i] records, per entry, whether THIS call actually sent
// EXPIRE for it — decided once, before the pipeline goes out, and reused
// after the reply arrives to choose commitExpire vs. the v==delta
// forgetExpire check (incrBy's own doc comment covers why both matter,
// review fix, CRITICAL, 2026-08-2x). incrReplyIdx[i] is the index within
// cmds (and so within replies) of entry i's own INCRBY — NOT a fixed i*2
// stride any more, since a skipped EXPIRE shifts every later entry's
// commands left by one; entry i's own EXPIRE, when included, immediately
// follows its INCRBY and is not otherwise inspected — same reasoning as
// incrBy's own doc comment: a failed EXPIRE right after a successful
// INCRBY only leaves that one key without a fresh TTL, not a corrupted
// count. The same at-least-once semantics as incrBy (see its own doc
// comment) apply here too, extended to the whole batch: a lost reply
// after the server already applied the pipeline can cause
// respClient.pipeline's reconnect-once retry to re-send it, over-counting
// every entry in it by its own delta.
// applyIncrReplies decodes the INCRBY replies for entries — each entry's
// own reply located via incrReplyIdx[i], not a fixed stride (a skipped
// EXPIRE shifts every later entry's commands left by one; see incrMulti/
// incrAndGetMulti's own doc comments) — writing each entry's post-
// increment value into out[i] and whether its reply equalled its own
// delta (the Redis-lost-the-key recreate signal firstWrite feeds into
// settleExpireBatch) into firstWrite[i]. method names the caller
// increment value into out[i]. sent[i] is needsExpireBatch's verdict for
// entry i (whether THIS call sent its EXPIRE, immediately after its
// INCRBY). confirmed[i] records whether that EXPIRE was sent AND its own
// reply is literally the integer 1 (finding F6, 2026-09 review: an ACL
// denying EXPIRE, or any other error reply, must never be recorded as a
// confirmed TTL). firstWrite[i] records whether EXPIRE was NOT sent and
// the INCRBY reply equalled the entry's own delta (the Redis-lost-the-key
// recreate signal settleExpireBatch forgets and repairRecreatedExpires
// re-arms, finding F7). method names the caller
// ("incrMulti" or "incrAndGetMulti") purely for the error message prefix,
// so both callers' error text stays byte-identical to what they built
// inline before this was factored out.
@@ -291,7 +275,7 @@ func (s *redisStore) incrBy(key string, n int64, ttl time.Duration) (int64, erro
// It reports how many entries it finished examining: len(entries) on full
// success, or the index of the first failing entry (0 if the very first
// one fails) — never entries[i] itself when it fails, since out[i]/
// firstWrite[i] were never validly written for it. Both callers settle
// confirmed[i]/firstWrite[i] were never validly written for it. Both callers settle
// expire bookkeeping for exactly that PROCESSED prefix in ONE
// settleExpireBatch call at their own single call site, on every return
// path, rather than a deferred closure per pipeline (measured ~1.5%
@@ -302,7 +286,7 @@ func (s *redisStore) incrBy(key string, n int64, ttl time.Duration) (int64, erro
// failure (review fix, CRITICAL: entries[:processed] must still be
// settled even when entries[processed] itself failed — see needsExpire's
// own doc comment for why a lost forget is the dangerous half).
func (s *redisStore) applyIncrReplies(method string, entries []counterIncr, replies []any, incrReplyIdx []int, out []int64, firstWrite []bool) (processed int, err error) {
func (s *redisStore) applyIncrReplies(method string, entries []counterIncr, replies []any, incrReplyIdx []int, sent []bool, out []int64, confirmed, firstWrite []bool) (processed int, err error) {
for i, e := range entries {
reply := replies[incrReplyIdx[i]]
if re, ok := reply.(error); ok {
@@ -313,11 +297,63 @@ func (s *redisStore) applyIncrReplies(method string, entries []counterIncr, repl
return i, fmt.Errorf("redisStore: %s %q: unexpected INCRBY reply type %T", method, e.key, reply)
}
out[i] = v
firstWrite[i] = v == e.delta
if sent[i] {
expireReply, isInt := replies[incrReplyIdx[i]+1].(int64)
confirmed[i] = isInt && expireReply == 1
} else {
firstWrite[i] = v == e.delta
}
}
return len(entries), nil
}
// incrMulti implements counterStore: every entry's INCRBY, plus an EXPIRE
// only when needsExpire(e.key) is true, sent as ONE pipeline (perf
// review, 2026-08-21) — one round trip regardless of len(entries) or how
// many of them get an EXPIRE this time. This is the only production path
// that reaches Redis for a counter write (incrBy, its single-key
// predecessor, had no production caller left once checkAndCount/account
// moved onto the batched paths, and was removed — deadcode audit,
// 2026-09 review); a batch of one entry carries the identical behavior.
//
// expireSeen bookkeeping (perf finding 2, review fix, CRITICAL,
// 2026-08-2x) happens strictly AFTER the pipeline succeeds — never
// before: commitExpire(e.key) only when this call actually sent EXPIRE
// for that entry AND its own reply confirms success (finding F6, 2026-09
// review — EXPIRE's reply used to go uninspected: an ACL that allows
// INCRBY but denies EXPIRE, or any other EXPIRE error reply, was
// previously recorded as a CONFIRMED TTL regardless, making the key
// immortal, since needsExpire would never ask again); forgetExpireLocked(e.key)
// when the INCRBY reply reveals that entry's key was recreated (v ==
// e.delta) despite this process's cache believing it already had a
// confirmed EXPIRE, immediately followed by repairRecreatedExpires to
// re-arm its TTL in this SAME call rather than only on that key's own
// next increment (finding F7) — see commitExpire/forgetExpireLocked/
// repairRecreatedExpires' own doc comments.
//
// sendExpire[i] records, per entry, whether THIS call actually sent
// EXPIRE for it — decided once, before the pipeline goes out, and reused
// after the reply arrives (applyIncrReplies) to choose the confirmed-
// EXPIRE commit vs. the v==delta forget-and-repair check above. incrReplyIdx[i] is the index within cmds (and
// so within replies) of entry i's own INCRBY — NOT a fixed i*2 stride,
// since a skipped EXPIRE shifts every later entry's commands left by one;
// entry i's own EXPIRE, when included, immediately follows its INCRBY and
// is not otherwise inspected: a failed EXPIRE right after a successful
// INCRBY only leaves that one key without a fresh TTL, not a corrupted
// count.
//
// Semantics are deliberately at-least-once, not exactly-once, both in the
// conservative direction (never under-counts): respClient.pipeline's
// reconnect-once retry can re-send the whole batch if a reply was lost
// after the server already applied it (e.g. the connection dropped
// between the server processing a command and the client reading its
// reply), which can over-count every entry in it by its own delta; and
// the limiter's failOpen path can additionally count the same request in
// its in-process fallback store when a call to this method errors out
// after a partial success upstream. Both are accepted trade-offs — a
// rate/budget counter that occasionally over-counts by one request's
// worth is fail-safe (more restrictive than reality), never fail-open in
// the unsafe direction.
func (s *redisStore) incrMulti(entries []counterIncr) ([]int64, error) {
if len(entries) == 0 {
return nil, nil
@@ -343,12 +379,17 @@ func (s *redisStore) incrMulti(entries []counterIncr) ([]int64, error) {
}
out := make([]int64, len(entries))
confirmed := make([]bool, len(entries))
firstWrite := make([]bool, len(entries))
processed, applyErr := s.applyIncrReplies("incrMulti", entries, replies, incrReplyIdx, out, firstWrite)
s.settleExpireBatch(entries[:processed], sendExpire[:processed], firstWrite[:processed])
processed, applyErr := s.applyIncrReplies("incrMulti", entries, replies, incrReplyIdx, sendExpire, out, confirmed, firstWrite)
s.settleExpireBatch(entries[:processed], confirmed[:processed], firstWrite[:processed])
if applyErr != nil {
return nil, applyErr
}
// F7: repair every recreated-with-no-TTL key found this call in ONE
// follow-up pipeline, batched across the whole entries slice — see
// repairRecreatedExpires' own doc comment.
s.repairRecreatedExpires(entries, firstWrite)
return out, nil
}
@@ -399,12 +440,17 @@ func (s *redisStore) incrAndGetMulti(entries []counterIncr, reads []string) ([]i
}
incrVals := make([]int64, len(entries))
confirmed := make([]bool, len(entries))
firstWrite := make([]bool, len(entries))
processed, applyErr := s.applyIncrReplies("incrAndGetMulti", entries, replies, incrReplyIdx, incrVals, firstWrite)
s.settleExpireBatch(entries[:processed], sendExpire[:processed], firstWrite[:processed])
processed, applyErr := s.applyIncrReplies("incrAndGetMulti", entries, replies, incrReplyIdx, sendExpire, incrVals, confirmed, firstWrite)
s.settleExpireBatch(entries[:processed], confirmed[:processed], firstWrite[:processed])
if applyErr != nil {
return nil, nil, applyErr
}
// F7: repair every recreated-with-no-TTL key found this call in ONE
// follow-up pipeline, batched across the whole entries slice — see
// repairRecreatedExpires' own doc comment.
s.repairRecreatedExpires(entries, firstWrite)
// The GET half is read-only (incrAndGetMulti's own doc comment) — its
// own error returns below come strictly after expire bookkeeping is
@@ -446,28 +492,3 @@ func (s *redisStore) getMulti(keys []string) ([]int64, error) {
}
return v, nil
}
// get implements counterStore: GET key, treating a missing key (RESP null
// bulk reply) as 0, matching memoryStore's behavior for an absent or
// expired counter.
func (s *redisStore) get(key string) (int64, error) {
reply, err := s.client.do("GET", key)
if err != nil {
return 0, fmt.Errorf("redisStore: get %q: %w", key, err)
}
if reply == nil {
return 0, nil
}
if e, ok := reply.(error); ok {
return 0, fmt.Errorf("redisStore: get %q: %w", key, e)
}
b, ok := reply.([]byte)
if !ok {
return 0, fmt.Errorf("redisStore: get %q: unexpected reply type %T", key, reply)
}
v, err := strconv.ParseInt(string(b), 10, 64)
if err != nil {
return 0, fmt.Errorf("redisStore: get %q: non-integer value %q: %w", key, b, err)
}
return v, nil
}
+180 -49
View File
@@ -9,9 +9,36 @@ import (
"github.com/stretchr/testify/require"
)
// incrOne is a test helper porting the old single-key redisStore.incrBy
// call shape onto incrMulti: incrBy was removed from redisStore (deadcode
// audit, 2026-09 review) since every production caller already batches
// through incrMulti/incrAndGetMulti, leaving incrBy as an interface method
// with no production caller of its own. It builds incrMulti's one-entry
// counterIncr slice and unwraps its one-element result, so a test written
// against a single key can drive incrMulti without restating that
// boilerplate at every call site — the EXPIRE-confirmation and recreated-
// key-repair behavior it exercises is identical, since incrMulti applies
// incrBy's own per-entry logic to each entry, including a batch of one.
func incrOne(s *redisStore, key string, n int64, ttl time.Duration) (int64, error) {
vals, err := s.incrMulti([]counterIncr{{key: key, delta: n, ttl: ttl}})
if err != nil {
return 0, err
}
return vals[0], nil
}
// getOne mirrors incrOne for getMulti, replacing the removed redisStore.get.
func getOne(s *redisStore, key string) (int64, error) {
vals, err := s.getMulti([]string{key})
if err != nil {
return 0, err
}
return vals[0], nil
}
// TestRedisStore_IncrBy_PipelinesIncrbyAndExpire is the brief's Step-1
// pipeline case: incrBy sends INCRBY and EXPIRE as one pipeline and
// returns INCRBY's reply value.
// pipeline case: incrMulti sends INCRBY and EXPIRE as one pipeline and
// returns INCRBY's reply value, even for a single-entry batch.
func TestRedisStore_IncrBy_PipelinesIncrbyAndExpire(t *testing.T) {
ln := newFakeListener(t)
runFakeRESPServer(t, ln, []respStep{
@@ -21,12 +48,12 @@ func TestRedisStore_IncrBy_PipelinesIncrbyAndExpire(t *testing.T) {
})
store := newRedisStore(newRESPClient(ln.Addr().String(), "", 0))
v, err := store.incrBy("llmgw:user:a:req:day:20260820", 1, 60*time.Second)
v, err := incrOne(store, "llmgw:user:a:req:day:20260820", 1, 60*time.Second)
if err != nil {
t.Fatalf("incrBy: %v", err)
t.Fatalf("incrMulti: %v", err)
}
if v != 5 {
t.Errorf("incrBy = %d, want 5", v)
t.Errorf("incrMulti = %d, want 5", v)
}
}
@@ -213,13 +240,14 @@ func TestRedisStore_IncrBy_RoundsSubSecondTTLUp(t *testing.T) {
})
store := newRedisStore(newRESPClient(ln.Addr().String(), "", 0))
if _, err := store.incrBy("k", 1, 10*time.Millisecond); err != nil {
t.Fatalf("incrBy: %v", err)
if _, err := incrOne(store, "k", 1, 10*time.Millisecond); err != nil {
t.Fatalf("incrMulti: %v", err)
}
}
// TestRedisStore_Get_MissingKeyReturnsZero is the brief's Step-1 GET case:
// a null bulk reply ("$-1") decodes to 0 with no error.
// a null bulk reply ("$-1") decodes to 0 with no error, via getMulti's
// single-key equivalent of the removed redisStore.get.
func TestRedisStore_Get_MissingKeyReturnsZero(t *testing.T) {
ln := newFakeListener(t)
runFakeRESPServer(t, ln, []respStep{
@@ -228,12 +256,12 @@ func TestRedisStore_Get_MissingKeyReturnsZero(t *testing.T) {
})
store := newRedisStore(newRESPClient(ln.Addr().String(), "", 0))
v, err := store.get("llmgw:user:a:req:day:20260820")
v, err := getOne(store, "llmgw:user:a:req:day:20260820")
if err != nil {
t.Fatalf("get: %v", err)
t.Fatalf("getMulti: %v", err)
}
if v != 0 {
t.Errorf("get = %d, want 0", v)
t.Errorf("getMulti = %d, want 0", v)
}
}
@@ -247,12 +275,12 @@ func TestRedisStore_Get_ParsesIntegerValue(t *testing.T) {
})
store := newRedisStore(newRESPClient(ln.Addr().String(), "", 0))
v, err := store.get("k")
v, err := getOne(store, "k")
if err != nil {
t.Fatalf("get: %v", err)
t.Fatalf("getMulti: %v", err)
}
if v != 123 {
t.Errorf("get = %d, want 123", v)
t.Errorf("getMulti = %d, want 123", v)
}
}
@@ -285,20 +313,20 @@ func TestRedisStore_IncrBy_SecondCallSameKey_SkipsExpire(t *testing.T) {
})
store := newRedisStore(newRESPClientPool(ln.Addr().String(), "", 0, 1))
v1, err := store.incrBy("k", 1, 60*time.Second)
v1, err := incrOne(store, "k", 1, 60*time.Second)
if err != nil {
t.Fatalf("incrBy (1st): %v", err)
t.Fatalf("incrMulti (1st): %v", err)
}
if v1 != 1 {
t.Errorf("incrBy (1st) = %d, want 1", v1)
t.Errorf("incrMulti (1st) = %d, want 1", v1)
}
v2, err := store.incrBy("k", 1, 60*time.Second)
v2, err := incrOne(store, "k", 1, 60*time.Second)
if err != nil {
t.Fatalf("incrBy (2nd): %v", err)
t.Fatalf("incrMulti (2nd): %v", err)
}
if v2 != 2 {
t.Errorf("incrBy (2nd) = %d, want 2", v2)
t.Errorf("incrMulti (2nd) = %d, want 2", v2)
}
}
@@ -317,11 +345,11 @@ func TestRedisStore_IncrBy_DifferentKeys_EachGetsItsOwnFirstExpire(t *testing.T)
})
store := newRedisStore(newRESPClientPool(ln.Addr().String(), "", 0, 1))
if _, err := store.incrBy("a", 1, 60*time.Second); err != nil {
t.Fatalf("incrBy(a): %v", err)
if _, err := incrOne(store, "a", 1, 60*time.Second); err != nil {
t.Fatalf("incrMulti(a): %v", err)
}
if _, err := store.incrBy("b", 1, 60*time.Second); err != nil {
t.Fatalf("incrBy(b): %v", err)
if _, err := incrOne(store, "b", 1, 60*time.Second); err != nil {
t.Fatalf("incrMulti(b): %v", err)
}
}
@@ -390,8 +418,8 @@ func TestRedisStore_IncrAndGetMulti_MixedFirstAndRepeatedKeys_ReadsStillLandAfte
})
store := newRedisStore(newRESPClientPool(ln.Addr().String(), "", 0, 1))
if _, err := store.incrBy("hot", 1, time.Minute); err != nil {
t.Fatalf("incrBy (prime hot's expireOnce state): %v", err)
if _, err := incrOne(store, "hot", 1, time.Minute); err != nil {
t.Fatalf("incrMulti (prime hot's expireOnce state): %v", err)
}
incrVals, readVals, err := store.incrAndGetMulti(
@@ -464,7 +492,7 @@ func TestRedisStore_IncrBy_FailedPipeline_DoesNotConsumeExpireCredit(t *testing.
require.NoError(t, ln.Close()) // nothing listens at deadAddr from here on
store := newRedisStore(newRESPClientPool(deadAddr, "", 0, 1))
if _, err := store.incrBy("k", 1, 60*time.Second); err == nil {
if _, err := incrOne(store, "k", 1, 60*time.Second); err == nil {
t.Fatal("want an error: nothing listens at deadAddr")
}
if len(store.expireSeen) != 0 {
@@ -481,8 +509,8 @@ func TestRedisStore_IncrBy_FailedPipeline_DoesNotConsumeExpireCredit(t *testing.
{wantArgs: []string{"EXPIRE", "k", "60"}, reply: []byte(":1\r\n")},
})
store.client = newRESPClientPool(ln2.Addr().String(), "", 0, 1)
if _, err := store.incrBy("k", 1, 60*time.Second); err != nil {
t.Fatalf("incrBy after recovery: %v", err)
if _, err := incrOne(store, "k", 1, 60*time.Second); err != nil {
t.Fatalf("incrMulti after recovery: %v", err)
}
}
@@ -491,9 +519,13 @@ func TestRedisStore_IncrBy_FailedPipeline_DoesNotConsumeExpireCredit(t *testing.
// (committed by the 1st call below), so the 2nd call sends INCRBY alone
// — but the fake server's reply for it (":1\r\n", equal to this call's
// own delta) simulates Redis having lost the key and INCRBY recreating
// it fresh, with no TTL. forgetExpire must fire so the 3rd call resends
// EXPIRE — pinned by the fake server's exact script, which fails the
// test if the 3rd call's EXPIRE is missing.
// it fresh, with no TTL. forgetExpireLocked fires, and repairRecreatedExpires
// (finding F7, 2026-09 review) immediately sends a follow-up EXPIRE in
// this SAME call rather than waiting for k's own next increment — pinned
// by the fake server's exact script, which fails the test if that
// immediate repair EXPIRE is missing. The 3rd call then sends INCRBY
// alone again: the repair already re-confirmed EXPIRE, so there is
// nothing left for it to resend.
func TestRedisStore_IncrBy_KeyRecreatedAfterLoss_ForgetsAndResendsExpireNextCall(t *testing.T) {
ln := newFakeListener(t)
runFakeRESPServer(t, ln, []respStep{
@@ -505,16 +537,19 @@ func TestRedisStore_IncrBy_KeyRecreatedAfterLoss_ForgetsAndResendsExpireNextCall
// only INCRBY is sent — but Redis lost the key, so its reply
// (":1\r\n") equals this call's own delta, the recreate signal.
{wantArgs: []string{"INCRBY", "k", "1"}, reply: []byte(":1\r\n")},
// 3rd call: forgetExpire must have fired after the 2nd call, so
// this one sends EXPIRE again.
{wantArgs: []string{"INCRBY", "k", "1"}, reply: []byte(":1\r\n")},
// F7: repairRecreatedExpires fires immediately, inside the same
// incrBy call, re-arming the TTL right away instead of waiting
// for k's own next increment.
{wantArgs: []string{"EXPIRE", "k", "60"}, reply: []byte(":1\r\n")},
// 3rd call: the repair above already re-confirmed EXPIRE, so this
// one sends INCRBY alone.
{wantArgs: []string{"INCRBY", "k", "1"}, reply: []byte(":1\r\n")},
})
store := newRedisStore(newRESPClientPool(ln.Addr().String(), "", 0, 1))
for i := 0; i < 3; i++ {
if _, err := store.incrBy("k", 1, 60*time.Second); err != nil {
t.Fatalf("incrBy (call %d): %v", i+1, err)
if _, err := incrOne(store, "k", 1, 60*time.Second); err != nil {
t.Fatalf("incrMulti (call %d): %v", i+1, err)
}
}
}
@@ -523,7 +558,10 @@ func TestRedisStore_IncrBy_KeyRecreatedAfterLoss_ForgetsAndResendsExpireNextCall
// is the incrMulti counterpart, proving the per-entry sendExpire/
// forgetExpire bookkeeping (not just incrBy's single-key path) reacts to
// a recreate signal correctly inside a batch alongside an unrelated,
// genuinely-still-alive entry.
// genuinely-still-alive entry — and that repairRecreatedExpires (finding
// F7, 2026-09 review) repairs the recreated key's TTL immediately, in ONE
// follow-up pipeline sent after the whole batch's own entries are
// processed, rather than waiting for that key's own next batch.
func TestRedisStore_IncrMulti_KeyRecreatedAfterLoss_ForgetsAndResendsExpireNextCall(t *testing.T) {
ln := newFakeListener(t)
runFakeRESPServer(t, ln, []respStep{
@@ -536,12 +574,15 @@ func TestRedisStore_IncrMulti_KeyRecreatedAfterLoss_ForgetsAndResendsExpireNextC
// 2nd batch: "lost" was evicted from Redis (reply == its own
// delta, the recreate signal) but "alive" genuinely still has
// its counter (reply 2, not equal to delta 1) — only "lost"
// must be forgotten.
// must be forgotten. F7: the repair follow-up pipeline fires
// immediately after both entries' own INCRBYs, re-arming
// "lost"'s TTL in this same batch.
{wantArgs: []string{"INCRBY", "lost", "1"}, reply: []byte(":1\r\n")},
{wantArgs: []string{"INCRBY", "alive", "1"}, reply: []byte(":2\r\n")},
// 3rd batch: "lost" gets a fresh EXPIRE; "alive" still does not.
{wantArgs: []string{"INCRBY", "lost", "1"}, reply: []byte(":1\r\n")},
{wantArgs: []string{"EXPIRE", "lost", "60"}, reply: []byte(":1\r\n")},
// 3rd batch: the repair above already re-confirmed "lost"'s
// EXPIRE, so neither entry sends one now.
{wantArgs: []string{"INCRBY", "lost", "1"}, reply: []byte(":1\r\n")},
{wantArgs: []string{"INCRBY", "alive", "1"}, reply: []byte(":3\r\n")},
})
@@ -651,6 +692,96 @@ func TestRedisStore_IncrAndGetMulti_PartialFailureStillSettlesProcessedPrefix(t
assert.True(t, store.needsExpire("kC"), "kC was never reached far enough to commit or forget; must stay in its original unseen state")
}
// TestRedisStore_IncrBy_ExpireErrorReply_NotConfirmed_ResendsNextCall is
// finding F6, 2026-09 review: an EXPIRE reply that is a RESP error (an
// ACL denying EXPIRE while allowing INCRBY is the real-world trigger)
// must NOT be recorded as a confirmed TTL — before this fix, reaching
// commitExpire's call site at all (any reply, checked or not) recorded
// the key as confirmed, permanently starving it of a real retry and
// leaving it immortal in Redis. The INCRBY itself still succeeds (its
// own reply is unaffected by EXPIRE's outcome), and the key's very next
// call must resend EXPIRE — pinned by the fake server's exact script,
// which fails the test if that resend is missing.
func TestRedisStore_IncrBy_ExpireErrorReply_NotConfirmed_ResendsNextCall(t *testing.T) {
ln := newFakeListener(t)
runFakeRESPServer(t, ln, []respStep{
{wantArgs: []string{"SELECT", "0"}, reply: []byte("+OK\r\n")},
// 1st call: EXPIRE denied by ACL (RESP error reply) — INCRBY
// itself still succeeds.
{wantArgs: []string{"INCRBY", "k", "1"}, reply: []byte(":1\r\n")},
{wantArgs: []string{"EXPIRE", "k", "60"}, reply: []byte("-NOPERM this user has no permissions to run the 'expire' command\r\n")},
// 2nd call: EXPIRE must not have been recorded as confirmed, so
// this one sends EXPIRE again.
{wantArgs: []string{"INCRBY", "k", "2"}, reply: []byte(":3\r\n")},
{wantArgs: []string{"EXPIRE", "k", "60"}, reply: []byte(":1\r\n")},
})
store := newRedisStore(newRESPClientPool(ln.Addr().String(), "", 0, 1))
v, err := incrOne(store, "k", 1, 60*time.Second)
if err != nil {
t.Fatalf("incrMulti (call 1): %v", err)
}
if v != 1 {
t.Errorf("incrMulti (call 1) = %d, want 1 (INCRBY still succeeds even though EXPIRE was denied)", v)
}
if len(store.expireSeen) != 0 {
t.Fatalf("expireSeen = %v after an EXPIRE error reply, want empty (must not be recorded as confirmed)", store.expireSeen)
}
v, err = incrOne(store, "k", 2, 60*time.Second)
if err != nil {
t.Fatalf("incrMulti (call 2): %v", err)
}
if v != 3 {
t.Errorf("incrMulti (call 2) = %d, want 3", v)
}
}
// TestRedisStore_IncrMulti_ExpireErrorReply_NotConfirmedForThatEntryOnly
// is the incrMulti counterpart of F6: one entry's EXPIRE reply is a RESP
// error while an unrelated entry's own EXPIRE succeeds normally in the
// SAME batch — only the errored entry must stay unconfirmed (and so
// resend EXPIRE on its own next batch); the other entry's confirmation,
// and both entries' own INCRBY reply-index bookkeeping (incrReplyIdx),
// must be unaffected by the mix.
func TestRedisStore_IncrMulti_ExpireErrorReply_NotConfirmedForThatEntryOnly(t *testing.T) {
ln := newFakeListener(t)
runFakeRESPServer(t, ln, []respStep{
{wantArgs: []string{"SELECT", "0"}, reply: []byte("+OK\r\n")},
// 1st batch: "denied"'s EXPIRE is refused; "ok"'s EXPIRE succeeds.
{wantArgs: []string{"INCRBY", "denied", "1"}, reply: []byte(":1\r\n")},
{wantArgs: []string{"EXPIRE", "denied", "60"}, reply: []byte("-NOPERM no\r\n")},
{wantArgs: []string{"INCRBY", "ok", "1"}, reply: []byte(":1\r\n")},
{wantArgs: []string{"EXPIRE", "ok", "60"}, reply: []byte(":1\r\n")},
// 2nd batch: "denied" is still unconfirmed, so it sends EXPIRE
// again (still refused); "ok" stays confirmed, so it sends
// INCRBY alone.
{wantArgs: []string{"INCRBY", "denied", "1"}, reply: []byte(":2\r\n")},
{wantArgs: []string{"EXPIRE", "denied", "60"}, reply: []byte("-NOPERM no\r\n")},
{wantArgs: []string{"INCRBY", "ok", "1"}, reply: []byte(":2\r\n")},
})
store := newRedisStore(newRESPClientPool(ln.Addr().String(), "", 0, 1))
entries := []counterIncr{
{key: "denied", delta: 1, ttl: time.Minute},
{key: "ok", delta: 1, ttl: time.Minute},
}
for i := 0; i < 2; i++ {
got, err := store.incrMulti(entries)
if err != nil {
t.Fatalf("incrMulti (batch %d): %v", i+1, err)
}
if len(got) != 2 {
t.Fatalf("incrMulti (batch %d): len(got) = %d, want 2", i+1, len(got))
}
}
if store.needsExpire("ok") {
t.Error(`needsExpire("ok") = true, want false (its EXPIRE was confirmed)`)
}
if !store.needsExpire("denied") {
t.Error(`needsExpire("denied") = false, want true (its EXPIRE was always denied, never confirmed)`)
}
}
// TestLimiter_FailOpenFalse_DeadRedisAddress_ReturnsStoreDownViolation is
// the brief's Step-1 case: a limiter backed by a redisStore pointed at an
// address nothing listens on, with failOpen=false, must refuse the
@@ -793,7 +924,7 @@ func TestRedisStore_NeedsExpireBatch_OneLockForTheWholeSlice(t *testing.T) {
}
// TestRedisStore_SettleExpireBatch_NoCommitNoForgetLeavesKeyUntouched
// asserts settleExpireBatch's third outcome (neither sent nor firstWrite)
// asserts settleExpireBatch's third outcome (neither confirmed nor firstWrite)
// — incrAndGetMulti now drives its own bookkeeping through this same
// method (point 4, collapsed from a separate settleExpireFlags), so its
// "leave alone" branch needs its own direct coverage, not just the
@@ -804,12 +935,12 @@ func TestRedisStore_SettleExpireBatch_NoCommitNoForgetLeavesKeyUntouched(t *test
entries := []counterIncr{{key: "x", delta: 1}, {key: "y", delta: 1}}
s.settleExpireBatch(entries, []bool{true, false}, []bool{false, false})
assert.False(t, s.needsExpire("x"), "sent[0]=true must commit x")
assert.True(t, s.needsExpire("y"), "neither sent nor firstWrite for y must leave it unseen")
assert.False(t, s.needsExpire("x"), "confirmed[0]=true must commit x")
assert.True(t, s.needsExpire("y"), "neither confirmed nor firstWrite for y must leave it unseen")
s.commitExpire("y")
s.settleExpireBatch(entries, []bool{false, false}, []bool{false, true})
assert.True(t, s.needsExpire("y"), "firstWrite[1]=true with sent[1]=false must forget y")
assert.True(t, s.needsExpire("y"), "firstWrite[1]=true with confirmed[1]=false must forget y")
assert.False(t, s.needsExpire("x"), "x must be untouched by a settle call that neither commits nor forgets it")
}
@@ -837,8 +968,8 @@ func TestRedisStore_CommitExpireLocked_BoundedEviction(t *testing.T) {
}
}
// TestRedisStore_Get_DownServer_ReturnsError asserts get surfaces a
// transport failure (dead address) as an error, mirroring incrBy's own
// TestRedisStore_Get_DownServer_ReturnsError asserts getMulti surfaces a
// transport failure (dead address) as an error, mirroring incrMulti's own
// contract for the same failure — the limiter's storeGet relies on this to
// treat a Redis outage as a store error rather than a false zero reading.
func TestRedisStore_Get_DownServer_ReturnsError(t *testing.T) {
@@ -847,7 +978,7 @@ func TestRedisStore_Get_DownServer_ReturnsError(t *testing.T) {
require.NoError(t, ln.Close())
store := newRedisStore(newRESPClient(deadAddr, "", 0))
_, err := store.get("k")
_, err := getOne(store, "k")
require.Error(t, err)
assert.Contains(t, err.Error(), "redisStore: get")
assert.Contains(t, err.Error(), "redisStore: getMulti")
}
+61 -20
View File
@@ -55,9 +55,20 @@ const defaultBreakerOpenDuration = time.Minute
// two ever binds. An earlier value here (30m, shorter than the 1h
// interval) made the breaker cadence-neutral under stock config — exactly
// as strict an interval-only gate would already produce, with none of the
// suppression this feature exists to provide. At 6h, a permanently broken
// provider under every default backs off 1h, 2h, 4h, 6h, 6h, ... — roughly
// 5 attempts a day instead of 24.
// suppression this feature exists to provide.
//
// L6 fix — corrected cadence: openBreakerLocked (below) doubles from
// defaultBreakerOpenDuration (1m), not from this constant, so the backoff
// stays UNDER the 1h interval gate — and therefore fully masked by it,
// per tryBeginRefresh's "whichever of the two is longer wins" rule — for
// its first ~6 doublings (1m, 2m, 4m, 8m, 16m, 32m): those probes still
// run once per hour, exactly as an interval-only gate would. Only once
// the backoff itself exceeds 1h does it start to govern: 64m, 128m,
// 256m, then capped at this constant's 360m (6h) from then on. A
// permanently broken provider sees roughly 13 discovery attempts on the
// day the breaker trips (the hourly-gated ramp plus the first few
// backoff-governed probes), settling to a steady state of 24h/360m =
// roughly 4 attempts a day thereafter, not 5.
const defaultBreakerMaxOpenDuration = 6 * time.Hour
// maxBreakerOpenDuration bounds BreakerConfig.MaxOpenDuration — the same
@@ -723,6 +734,19 @@ func newModelRegistry(adapters map[string]providerAdapter, cfg *Config, log func
if parseErr != nil {
return nil, fmt.Errorf("llmgateway: provider %q: invalid discoveryInterval %q: %w", name, pc.DiscoveryInterval, parseErr)
}
// L5 fix: a non-positive discoveryInterval ("0s", or a
// negative value ParseDuration itself does not reject) is a
// misconfiguration, not a valid "always refresh" setting —
// tryBeginRefresh's own throttle gate (below) never suppresses
// a same-window re-probe when the interval is <= 0, so every
// request would spawn a new listModels the instant the
// previous one finishes, hammering the upstream back-to-back.
// Mirrors resolveRequestTimeout's identical "%s must be
// positive" rejection (timeout.go) for the same class of
// field.
if d <= 0 {
return nil, fmt.Errorf("llmgateway: provider %q: discoveryInterval must be positive, got %q", name, pc.DiscoveryInterval)
}
interval = d
}
@@ -1235,10 +1259,10 @@ func (m *modelRegistry) bareWinner(id string, grp *group, extraIDs ...string) (s
// global sorted-first owner only when grp is authorized for none of them
// (bareWinner's own doc comment) — whose known model set contains it,
// ruling (g)'s collision rule as revised 2026-08-27 for group-awareness.
// Either way, authorization requires both grp.allowsModel and
// grp.allowsProvider for the resolved provider (ruling (c)); a model that
// exists but fails authorization returns errModelDenied, distinct from
// errModelUnknown for a model no configured provider knows at all.
// Either way, authorization requires grp.allowsProviderModel for the
// resolved provider (ruling (c)); a model that exists but fails
// authorization returns errModelDenied, distinct from errModelUnknown for
// a model no configured provider knows at all.
func (m *modelRegistry) resolve(id string, grp *group) (providerAdapter, string, string, error) {
if target, isAlias := m.aliases[id]; isAlias {
// Warned-set checked FIRST (review fix): bareWinner is an
@@ -1370,8 +1394,10 @@ func (m *modelRegistry) warnAliasShadowsDiscoveredOnce(alias string) {
// authorization, in that order — so a real model behind a provider the
// group cannot use reports errModelDenied, not errModelUnknown.
//
// allowsModel is an exact glob match (auth.go) with no prefix-stripping of
// its own, so resolveAgainst generates both candidate strings itself:
// allowsProviderModel matches each candidate model id against grp's model
// globs with an exact glob match (matchesGlob, auth.go) — no prefix-
// stripping of its own — so resolveAgainst generates both candidate
// strings itself:
// requestedID (the provider-prefixed form for a direct request, e.g.
// "openai/gpt-test") and upstreamModel (its bare suffix, e.g. "gpt-test"),
// and allows if either matches — that is what lets a pattern like "gpt-*"
@@ -1383,7 +1409,7 @@ func (m *modelRegistry) warnAliasShadowsDiscoveredOnce(alias string) {
// "uni/deepseek-v4-flash-0731", when "uni" is not a configured provider).
//
// extraModelName is a further authorization candidate checked via
// grp.allowsModel, alongside requestedID and upstreamModel: empty ("") for
// grp.allowsProviderModel, alongside requestedID and upstreamModel: empty ("") for
// a direct (non-alias) resolution, or an alias's own id when resolveAliasTarget
// (spec §5, v0.2) is resolving that alias's target — "a group may use an
// alias when its model globs match the ALIAS name OR the resolved target
@@ -1552,9 +1578,9 @@ func (m *modelRegistry) modelsJSON(grp *group) []byte {
// cannot use is never a candidate for the bare id and never gets a
// "provider/id" entry either: a prefix that only disambiguates against an
// invisible provider is noise, not information, to this group. Each listed
// entry is independently filtered by grp.allowsModel; grp.allowsProvider
// filtering happens once, up front, when the group-visible owner subset is
// built, rather than per candidate entry.
// entry is independently filtered by grp.allowsProviderModel;
// grp.allowsProvider filtering happens once, up front, when the
// group-visible owner subset is built, rather than per candidate entry.
func (m *modelRegistry) listFor(grp *group) []map[string]any {
owners := make(map[string][]string)
for _, name := range m.providerNames {
@@ -1613,6 +1639,21 @@ func (m *modelRegistry) listFor(grp *group) []map[string]any {
// also cover id, in favor of a later visible owner whose grant
// covers both — never silently dropping a model the principal can
// actually reach through some other visible provider.
// ambiguousBareID (H3 fix): id itself takes the "configuredProvider/
// rest" shape splitConfiguredProvider's resolve-side parser peels a
// prefix from. A discovered id like "openai/gpt-4o" surfaced by
// provider "openrouter" (owners[id]==["openrouter"]) is exactly this
// case when "openai" also happens to be configured: listing it bare
// would hand a client an id that resolve(), given that same id back,
// routes to provider "openai" instead of the "openrouter" that
// actually owns it (or 404s if "openai" doesn't know it). So this id
// is never listed bare — only under its true owner's own
// "provider/id" disambiguating form below, which resolve parses
// unambiguously (splitConfiguredProvider only ever inspects the
// FIRST "/", so "openrouter/openai/gpt-4o" always resolves against
// "openrouter").
_, _, ambiguousBareID := m.splitConfiguredProvider(id)
groupWinner, gwOK := bareEntryWinner(visible, grp, id)
// allowsProviderModel (multi-group/personal-grant feature,
// auth.go), checked against groupWinner specifically — the
@@ -1623,19 +1664,19 @@ func (m *modelRegistry) listFor(grp *group) []map[string]any {
// that branch (harmless — matchesGlob is cheap), but still
// REQUIRED for the single-grant fast path, which never consults
// id at all.
if gwOK && grp.allowsProviderModel(groupWinner, id) && !shadowed {
if gwOK && grp.allowsProviderModel(groupWinner, id) && !shadowed && !ambiguousBareID {
out = append(out, modelObject(id, groupWinner, m.resolveMetaFor(groupWinner, id)))
}
if len(visible) < 2 && !shadowed {
if len(visible) < 2 && !shadowed && !ambiguousBareID {
continue // only one provider serving id is visible to grp: no disambiguating prefix to add
}
for _, p := range visible {
pid := p + "/" + id
// allowsModel does no prefix-stripping (auth.go): check both the
// prefixed form and its bare suffix, same as resolveAgainst does
// for the equivalent client request — allowsProviderModel(p, ...)
// requires BOTH candidates come from a grant that also allows p
// itself (no cross-grant leak).
// allowsProviderModel's model-matching does no prefix-stripping
// (auth.go): check both the prefixed form and its bare suffix,
// same as resolveAgainst does for the equivalent client request
// — allowsProviderModel(p, ...) requires BOTH candidates come
// from a grant that also allows p itself (no cross-grant leak).
if grp.allowsProviderModel(p, pid, id) {
out = append(out, modelObject(pid, p, m.resolveMetaFor(p, id)))
}
+94 -3
View File
@@ -156,6 +156,73 @@ func TestModelRegistry_Resolve_BareIDContainingSlash_UnconfiguredPrefix_Resolves
}
}
// TestModelRegistry_ListFor_DiscoveredIDPrefixMatchesConfiguredProvider_NeverListedBare
// is the H3 regression test. "openrouter" discovers a model id that
// itself begins with another configured provider's own name — the real
// OpenRouter convention, where upstream ids look like "openai/gpt-4o".
// splitConfiguredProvider (registry.go), used by BOTH resolve and
// listFor, peels a "configuredProvider/rest" prefix off any id string
// shaped that way, with no way to tell "this happens to start with a
// provider's name" apart from "a client addressed this id with an
// explicit provider/ prefix". Listing "openai/gpt-4o" bare (attributed
// to openrouter) would hand a client an id that resolve(), given that
// same id back, routes to provider "openai" instead — the wrong
// provider, or a 404 if "openai" never heard of "gpt-4o". listFor must
// list it ONLY in its "provider/id" disambiguating form, and resolve
// must route that form back to the provider that actually owns it.
func TestModelRegistry_ListFor_DiscoveredIDPrefixMatchesConfiguredProvider_NeverListedBare(t *testing.T) {
t.Parallel()
adapters := map[string]providerAdapter{
"openrouter": newFakeAdapter("openrouter"),
"openai": newFakeAdapter("openai"),
}
cfg := &Config{Providers: map[string]*ProviderConfig{
"openrouter": {Models: []string{"openai/gpt-4o"}},
"openai": {Models: []string{"gpt-4o-mini"}},
}}
reg, err := newModelRegistry(adapters, cfg, func(string, ...any) {})
if err != nil {
t.Fatalf("newModelRegistry: %v", err)
}
grp := allowAllGroup()
got := reg.listFor(grp)
var bareEntry, prefixedEntry map[string]any
for _, entry := range got {
switch entry["id"] {
case "openai/gpt-4o":
bareEntry = entry
case "openrouter/openai/gpt-4o":
prefixedEntry = entry
}
}
if bareEntry != nil {
t.Errorf(`listFor listed %v bare as "openai/gpt-4o" — resolve("openai/gpt-4o") would route to provider "openai", not the "openrouter" that actually owns it`, bareEntry)
}
if prefixedEntry == nil || prefixedEntry["owned_by"] != "openrouter" {
t.Fatalf(`listFor = %v, want an "openrouter/openai/gpt-4o" entry owned_by "openrouter"`, got)
}
// resolve() must agree with the listing: the exact id listFor emitted
// must resolve back to the provider it named as owner, carrying the
// original discovered id (itself containing a "/") as the upstream
// model unchanged.
adapter, upstreamModel, canonical, err := reg.resolve("openrouter/openai/gpt-4o", grp)
if err != nil {
t.Fatalf("resolve(%q): %v", "openrouter/openai/gpt-4o", err)
}
if adapter != adapters["openrouter"] {
t.Errorf("adapter = %v, want openrouter adapter", adapter)
}
if upstreamModel != "openai/gpt-4o" {
t.Errorf("upstreamModel = %q, want %q", upstreamModel, "openai/gpt-4o")
}
if canonical != "openrouter/openai/gpt-4o" {
t.Errorf("canonical = %q, want %q", canonical, "openrouter/openai/gpt-4o")
}
}
func TestModelRegistry_Resolve_UnknownModel_ReturnsErrModelUnknown(t *testing.T) {
t.Parallel()
adapters := map[string]providerAdapter{"openai": newFakeAdapter("openai")}
@@ -246,8 +313,9 @@ func TestModelRegistry_Resolve_AllowedByBothProviderAndModel_Succeeds(t *testing
// (fix(registry) ruling 3) ---
// TestModelRegistry_Resolve_BareIDWithSlash_ModelGlobMustNotMatchViaSpuriousStrip
// is the regression: allowsModel used to strip at any first slash, so a
// pattern like "deepseek-*" would spuriously match a bare model id that
// is the regression: grp's model-glob matching (now allowsProviderModel,
// auth.go) used to strip at any first slash, so a pattern like
// "deepseek-*" would spuriously match a bare model id that
// merely contains a slash — an upstream's own "uni/deepseek-v4-flash-0731"
// naming, where "uni" is not a configured provider and there is no
// legitimate prefix to strip at all. resolveAgainst must not invent that
@@ -279,7 +347,8 @@ func TestModelRegistry_Resolve_BareIDWithSlash_ModelGlobMustNotMatchViaSpuriousS
// TestModelRegistry_Resolve_ProviderPrefixed_ModelGlobMatchesViaBareCandidate
// is the positive half of the same ruling: a provider-prefixed request's
// bare suffix is still checked against the model glob — resolveAgainst
// generates that candidate itself now that allowsModel no longer strips.
// generates that candidate itself now that allowsProviderModel's
// model-matching no longer strips.
func TestModelRegistry_Resolve_ProviderPrefixed_ModelGlobMatchesViaBareCandidate(t *testing.T) {
t.Parallel()
adapters := map[string]providerAdapter{"openai": newFakeAdapter("openai")}
@@ -699,6 +768,28 @@ func TestNewModelRegistry_InvalidDiscoveryInterval_ReturnsConstructorError(t *te
}
}
// TestNewModelRegistry_NonPositiveDiscoveryInterval_ReturnsConstructorError
// is the L5 regression test: "0s" and a negative value both parse
// successfully via time.ParseDuration, so without an explicit d<=0 check
// they would silently pass through as the refresh interval. With
// interval<=0, tryBeginRefresh's own throttle gate (registry.go) never
// suppresses a same-window re-probe, so every request would spawn a new
// listModels the instant the previous one finishes — the config must be
// rejected at construction instead.
func TestNewModelRegistry_NonPositiveDiscoveryInterval_ReturnsConstructorError(t *testing.T) {
t.Parallel()
for _, interval := range []string{"0s", "0", "-1h", "-30s"} {
t.Run(interval, func(t *testing.T) {
t.Parallel()
adapters := map[string]providerAdapter{"openai": newFakeAdapter("openai")}
cfg := &Config{Providers: map[string]*ProviderConfig{"openai": {DiscoveryInterval: interval}}}
if _, err := newModelRegistry(adapters, cfg, func(string, ...any) {}); err == nil {
t.Fatalf("want constructor error for discoveryInterval %q, got nil", interval)
}
})
}
}
func TestNewModelRegistry_EmptyDiscoveryInterval_DefaultsOneHour(t *testing.T) {
t.Parallel()
adapters := map[string]providerAdapter{"openai": newFakeAdapter("openai")}
+149 -27
View File
@@ -246,7 +246,7 @@ func (c *respClient) do(args ...string) (any, error) {
// decoded replies in the same order. It encodes cmds once and hands off to
// pipelineEncoded, which owns the connection/retry logic.
func (c *respClient) pipeline(cmds [][]string) ([]any, error) {
return c.pipelineEncoded(encodeCommands(cmds), len(cmds))
return c.pipelineEncoded(encodeCommands(cmds), len(cmds), pipelineMutates(cmds))
}
// pipelineEncoded runs pipeline's connection/retry logic over an
@@ -257,20 +257,63 @@ func (c *respClient) pipeline(cmds [][]string) ([]any, error) {
// One absolute deadline is computed here, at call entry, and shared by
// acquiring a connection, the first attempt, and (if it happens) the one
// retry — not a fresh respCallTimeout for each. On a non-timeout I/O error
// (connection reset, EOF from a peer that closed mid-command) it closes
// the connection and retries the whole pipeline once against the same
// deadline, on the SAME pooled slot (a fresh dial reusing that slot, not a
// second slot acquired from the pool). On a timeout specifically, it does
// not retry: a server that accepted the connection and then went silent
// would just be given the same non-answer a second time, doubling the
// caller's wait for nothing. Either way, a failure leaves that connection
// closed (closeConn) so the next call to acquire this slot lazily
// reconnects fresh rather than reusing a connection already known bad —
// the slot itself always returns to the pool via the deferred send below,
// whether or not its connection survived the call. payload is encoded once
// by the caller, so the retry re-sends the identical bytes without
// re-encoding cmds a second time.
func (c *respClient) pipelineEncoded(payload []byte, n int) ([]any, error) {
// it closes the connection and retries the whole pipeline once against the
// same deadline, on the SAME pooled slot (a fresh dial reusing that slot,
// not a second slot acquired from the pool) — but ONLY when doing so is
// known safe (finding 5, 2026-09 review, fixing a double-apply bug; the
// wroteRequest-alone gate was itself corrected by the verify-core round-4
// fix below):
//
// - The failure happened before any byte of the request was written
// (attemptEncodedOn's wroteRequest is false — a dial failure, an
// ensureConnOn handshake failure, or the command write itself
// erroring). This is one stale-pooled-connection case: a connection
// idle in the pool that the peer silently closed sometimes fails on
// this very first write, before the server has seen anything from
// this attempt at all, so resending the identical pipeline against a
// freshly dialled connection cannot duplicate an effect that never
// happened.
// - Or the request was written and a reply was awaited, but no reply
// byte at all was ever read (attemptEncodedOn's readAny is false).
// A write into an already half-closed TCP connection commonly still
// succeeds into the kernel send buffer — the peer's close only
// surfaces on the READ, as an immediate EOF with zero bytes
// received. This is the OTHER, more common stale-pooled-connection
// case, and is exactly as safe to retry as the write-side one above:
// nothing was ever seen or applied server-side.
// - Or the request WAS written, some reply reading happened, but cmds
// contains no mutating command (pipelineMutates) — e.g. a pure
// getBatch/setEx/getBytes call. GET/SET/EXPIRE/AUTH/SELECT are
// idempotent: replaying one after a reply was lost (the connection
// reset mid-read, after the server already processed and answered)
// reproduces the same end state, so retrying is still safe even
// though the request definitely reached the wire.
//
// Once at least one reply byte has actually been read AND cmds contains a
// mutating command (INCRBY/INCRBYFLOAT — redisStore's incrMulti/
// incrAndGetMulti), a non-timeout failure is surfaced directly, with NO
// retry: the server has demonstrably started answering, so it may already
// have applied that INCRBY before the connection reset (a proxy failover,
// or the backend restarting mid-reply) — resending the whole pipeline in
// that case would apply it a second time, double-counting a rate/token/
// cost counter into a false 429 or doubled recorded spend. The caller's
// own failOpen/failClosed policy (limits.go) decides what happens to THIS
// attempt's failure; it must never see a duplicated increment instead.
//
// On a timeout specifically, it never retries either way: a server that
// accepted the connection and then went silent would just be given the
// same non-answer a second time, doubling the caller's wait for nothing.
// Any failure leaves that connection closed (closeConn) so the next call
// to acquire this slot lazily reconnects fresh rather than reusing a
// connection already known bad — the slot itself always returns to the
// pool via the deferred send below, whether or not its connection
// survived the call.
//
// payload is encoded once by the caller, so the retry re-sends the
// identical bytes without re-encoding cmds a second time. mutates is the
// caller's pipelineMutates verdict for the commands payload encodes —
// pipelineEncoded never sees the []string form, so the caller supplies it.
func (c *respClient) pipelineEncoded(payload []byte, n int, mutates bool) ([]any, error) {
deadline := time.Now().Add(respCallTimeout)
pc, err := c.acquire(deadline)
@@ -279,7 +322,7 @@ func (c *respClient) pipelineEncoded(payload []byte, n int) ([]any, error) {
}
defer func() { c.free <- pc }()
replies, err := c.attemptEncodedOn(pc, payload, n, deadline)
replies, wrote, readAny, err := c.attemptEncodedOn(pc, payload, n, deadline)
if err == nil {
return replies, nil
}
@@ -287,8 +330,11 @@ func (c *respClient) pipelineEncoded(payload []byte, n int) ([]any, error) {
if isTimeout(err) {
return nil, err
}
if wrote && readAny && mutates {
return nil, err
}
replies, err = c.attemptEncodedOn(pc, payload, n, deadline)
replies, _, _, err = c.attemptEncodedOn(pc, payload, n, deadline)
if err != nil {
closeConn(pc)
return nil, err
@@ -296,6 +342,37 @@ func (c *respClient) pipelineEncoded(payload []byte, n int) ([]any, error) {
return replies, nil
}
// pipelineMutatingCommands names every RESP command this client ever sends
// that is NOT safe to blindly resend: each already changes server state on
// success, so replaying it after an ambiguous failure (the request bytes
// were written, but the reply for it — or a later command in the same
// pipeline — never came back) can double-apply it, unlike a GET/SET/
// EXPIRE/AUTH/SELECT retry, which merely reproduces the same end state.
// INCRBYFLOAT is not currently sent by this client (only INCRBY is —
// redis_store.go), but is listed for the same reason INCRBY is: an
// operator or a future caller adding it must not have to rediscover this
// gate.
var pipelineMutatingCommands = map[string]bool{
"INCRBY": true,
"INCRBYFLOAT": true,
}
// pipelineMutates reports whether any command in cmds is one of
// pipelineMutatingCommands — pipeline's own gate (finding 5, 2026-09
// review) for whether a failure discovered AFTER the request was already
// written to the wire is still safe to retry.
func pipelineMutates(cmds [][]string) bool {
for _, args := range cmds {
if len(args) == 0 {
continue
}
if pipelineMutatingCommands[args[0]] {
return true
}
}
return false
}
// acquire takes one idle connection slot from c.free, waiting no longer
// than deadline. The non-blocking check first is an optimization, not a
// correctness requirement of the blocking select below — with a buffered
@@ -360,24 +437,67 @@ func (respDeadlineExceededErr) Temporary() bool { return true }
// payload representing n commands over pc, connecting first if needed,
// with every read and write bound by deadline. Callers own pc exclusively
// for the duration of this call (acquired from c.free).
func (c *respClient) attemptEncodedOn(pc *respConn, payload []byte, n int, deadline time.Time) ([]any, error) {
//
// wroteRequest is true as soon as every command has been fully written to
// pc.conn, regardless of what happens afterward (a reply read failing does
// not unset it) — pipeline's own retry gate (finding 5, 2026-09 review)
// uses this to tell "failed before the server could have seen anything"
// from "failed after the server may already have applied a mutating
// command", since only the first case is unconditionally safe to retry.
//
// readAny (verify-core fix, round 4) is true once at least one byte of
// the FIRST reply was actually consumed off pc.conn. Finding 5's original
// gate assumed a stale pooled connection the peer already closed always
// fails on the WRITE — wrong: a write into an already-half-closed TCP
// connection commonly still succeeds into the kernel send buffer, and
// the failure only surfaces on the read as an immediate EOF with zero
// bytes ever received. That is the stale-connection signature and it
// must still retry, even for a mutating pipeline, exactly like a
// pre-write failure does — nothing was ever seen or applied server-side.
// Once even one reply byte has arrived, the server has demonstrably
// started answering, so pipeline's caller no longer knows whether a
// mutating command already landed and must not resend it.
func (c *respClient) attemptEncodedOn(pc *respConn, payload []byte, n int, deadline time.Time) (replies []any, wroteRequest bool, readAny bool, err error) {
if err := c.ensureConnOn(pc, deadline); err != nil {
return nil, err
return nil, false, false, err
}
if _, err := pc.conn.Write(payload); err != nil {
return nil, fmt.Errorf("resp: write: %w", err)
return nil, false, false, fmt.Errorf("resp: write: %w", err)
}
replies := make([]any, n)
replies = make([]any, n)
for i := 0; i < n; i++ {
if !readAny {
// Peek the very first byte of the very first reply before
// handing off to decodeReply: this is the one read whose
// outcome pipeline's retry gate needs isolated from every
// later one. ReadByte+UnreadByte consumes nothing overall —
// the byte goes straight back for decodeReply below — it
// only distinguishes "the connection had nothing left to
// give at all" (stale pooled conn, safe to retry) from
// "some reply data has started arriving" (readAny=true for
// the rest of this call, whatever happens next).
if _, peekErr := pc.r.ReadByte(); peekErr != nil {
return nil, true, false, fmt.Errorf("resp: read reply %d/%d: %w", i+1, n, peekErr)
}
if unreadErr := pc.r.UnreadByte(); unreadErr != nil {
// bufio.Reader.UnreadByte only fails when the preceding
// op was not itself a successful ReadByte, which cannot
// happen here — kept as a hard error rather than
// silently mis-tracking readAny, which the mutating-
// pipeline retry decision depends on for correctness.
return nil, true, true, fmt.Errorf("resp: read reply %d/%d: unread byte: %w", i+1, n, unreadErr)
}
readAny = true
}
v, err := decodeReply(pc.r, 0)
if err != nil {
return nil, fmt.Errorf("resp: read reply %d/%d: %w", i+1, n, err)
return nil, true, readAny, fmt.Errorf("resp: read reply %d/%d: %w", i+1, n, err)
}
replies[i] = v
}
return replies, nil
return replies, true, true, nil
}
// ensureConnOn dials, authenticates, and selects the database if pc has no
@@ -643,7 +763,7 @@ func decodeArray(r *bufio.Reader, lenField string, depth int) (any, error) {
// setEx sets key to val with an expiry of ttl via RESP2 "SET key val EX
// seconds", used by the response cache (cache.go) to store a cached
// response. ttl is rounded up to whole seconds and floored at 1s — Redis's
// EX argument is whole seconds only — matching redisStore.incrBy's EXPIRE
// EX argument is whole seconds only — matching redisStore.incrMulti's EXPIRE
// rounding exactly. The payload is built directly with fmt.Appendf rather
// than going through do/encodeCommands: val (a cached response, often tens
// of KB) is framed straight from its own bytes — "%s" on a []byte writes
@@ -660,7 +780,9 @@ func (c *respClient) setEx(key string, val []byte, ttl time.Duration) error {
ttlArg := strconv.FormatInt(ttlSeconds, 10)
payload := fmt.Appendf(nil, "*5\r\n$3\r\nSET\r\n$%d\r\n%s\r\n$%d\r\n%s\r\n$2\r\nEX\r\n$%d\r\n%s\r\n", len(key), key, len(val), val, len(ttlArg), ttlArg)
replies, err := c.pipelineEncoded(payload, 1)
// SET is idempotent (not in pipelineMutatingCommands), so a retry after
// a lost reply reproduces the same end state.
replies, err := c.pipelineEncoded(payload, 1, false)
if err != nil {
return fmt.Errorf("resp: setEx %q: %w", key, err)
}
@@ -672,7 +794,7 @@ func (c *respClient) setEx(key string, val []byte, ttl time.Duration) error {
}
// getBytes reads key's value as a bulk reply. found is false for a missing
// key (RESP null bulk, "$-1") — not an error — mirroring redisStore.get's
// key (RESP null bulk, "$-1") — not an error — mirroring redisStore.getMulti's
// treatment of a missing counter key.
func (c *respClient) getBytes(key string) ([]byte, bool, error) {
reply, err := c.do("GET", key)
@@ -698,7 +820,7 @@ func (c *respClient) getBytes(key string) ([]byte, bool, error) {
// for GET /admin/api/usage otherwise serializing 6 GETs per user/group
// scope on the single shared connection). It returns one value per key,
// in the same order, with a missing key (RESP null bulk) mapped to 0,
// matching getBytes/redisStore.get's convention for a single key. Any
// matching getBytes/redisStore.getMulti's convention for a single key. Any
// reply that is a RESP error, an unexpected reply type, or a non-integer
// value fails the whole batch — mirrored by the limiter's
// storeGetMulti/currentUsage as one fail-open/fail-closed decision for
+106 -2
View File
@@ -518,6 +518,110 @@ func TestRESPClient_ReconnectsOnceOnMidCommandClose(t *testing.T) {
}
}
// TestRESPClient_Pipeline_RetriesIncrByOnStaleConnZeroReplyBytes is the
// verify-core round-4 regression test: finding 5's original fix (below)
// assumed a stale pooled connection the peer already closed always fails
// on the WRITE. That assumption is wrong — a write into an already
// half-closed TCP connection commonly still succeeds into the kernel send
// buffer, and the failure only surfaces on the READ, as an immediate EOF
// with zero reply bytes ever received. That is indistinguishable from "the
// server genuinely saw INCRBY and closed before replying" at the wire
// level, but it is overwhelmingly the MUCH more common case in production
// (an idle-timeout close, a restart, a conntrack reap on a pooled slot
// that sat unused), and the old fix silently ate its own retry for
// exactly this case, opening the store-down failOpen/failClosed latch on
// every occurrence. Zero reply bytes read must still retry, the same as a
// pre-write failure already does — see TestRESPClient_Pipeline_
// DoesNotRetryIncrByAfterReplyBytesRead directly below for the case that
// correctly still refuses to retry.
func TestRESPClient_Pipeline_RetriesIncrByOnStaleConnZeroReplyBytes(t *testing.T) {
ln := newFakeListener(t)
runFakeRESPServer(t, ln, []respStep{
// First connection: handshake succeeds, INCRBY is read, but the
// server closes without writing a single reply byte back — the
// stale-pooled-connection signature this fix targets.
{wantArgs: []string{"SELECT", "0"}, reply: []byte("+OK\r\n")},
{wantArgs: []string{"INCRBY", "k", "1"}, closeConn: true},
// Second connection: the client must redo the handshake, then
// resend INCRBY and this time get a reply.
{wantArgs: []string{"SELECT", "0"}, reply: []byte("+OK\r\n")},
{wantArgs: []string{"INCRBY", "k", "1"}, reply: []byte(":1\r\n")},
})
c := newRESPClient(ln.Addr().String(), "", 0)
replies, err := c.pipeline([][]string{{"INCRBY", "k", "1"}})
if err != nil {
t.Fatalf("pipeline: %v (want the client to retry a zero-reply-bytes failure, even for a mutating command)", err)
}
if len(replies) != 1 || replies[0] != int64(1) {
t.Errorf("replies = %#v, want [int64(1)]", replies)
}
}
// TestRESPClient_Pipeline_DoesNotRetryIncrByAfterReplyBytesRead is finding
// 5's regression test (2026-09 review), corrected by the verify-core
// round-4 fix above: once at least one reply BYTE has actually been read
// back — proving the server has genuinely started answering, so it may
// already have applied a mutating command that came before the one whose
// reply was lost — a further read failure must be surfaced directly, with
// NO retry. Resending the pipeline would risk double-applying INCRBY
// (false 429s on a rate/token counter, doubled recorded spend on a cost
// counter). Uses a two-command pipeline (INCRBY+EXPIRE, matching
// redis_store.go's real incrBy usage): INCRBY's reply arrives in full
// first, proving readAny before EXPIRE's own reply is ever attempted.
//
// The fake server closes the listener the instant it accepts the one
// connection it expects, well before it even reads SELECT — not racing
// the client's own read failure — so a WRONGLY-attempted retry's dial
// fails immediately with "connection refused" instead of reproducing the
// read-reply error the first attempt actually produced. Asserting on the
// error's own content (containing "read reply 2/2", attemptEncodedOn's
// read-phase wrapper naming the SECOND reply — not merely that SOME error
// came back) is what proves no second attempt was ever made.
func TestRESPClient_Pipeline_DoesNotRetryIncrByAfterReplyBytesRead(t *testing.T) {
ln := newFakeListener(t)
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
_ = ln.Close() // no further connections accepted, from here on
defer func() { _ = conn.Close() }()
r := bufio.NewReader(conn)
if args, err := readRESPCommand(r); err != nil || !equalStrSlices(args, []string{"SELECT", "0"}) {
t.Errorf("fake RESP server: SELECT: args=%v err=%v", args, err)
return
}
if _, err := conn.Write([]byte("+OK\r\n")); err != nil {
t.Errorf("fake RESP server: write SELECT reply: %v", err)
return
}
if args, err := readRESPCommand(r); err != nil || !equalStrSlices(args, []string{"INCRBY", "k", "1"}) {
t.Errorf("fake RESP server: INCRBY: args=%v err=%v", args, err)
return
}
if args, err := readRESPCommand(r); err != nil || !equalStrSlices(args, []string{"EXPIRE", "k", "60"}) {
t.Errorf("fake RESP server: EXPIRE: args=%v err=%v", args, err)
return
}
// The server answers INCRBY in full (proving readAny becomes
// true) but then drops the connection before replying to
// EXPIRE — finding 5's exact double-apply risk, now correctly
// gated on readAny rather than on wroteRequest alone.
if _, err := conn.Write([]byte(":1\r\n")); err != nil {
t.Errorf("fake RESP server: write INCRBY reply: %v", err)
return
}
}()
c := newRESPClient(ln.Addr().String(), "", 0)
_, err := c.pipeline([][]string{{"INCRBY", "k", "1"}, {"EXPIRE", "k", "60"}})
require.Error(t, err)
assert.Contains(t, err.Error(), "read reply 2/2",
"pipeline retried an INCRBY+EXPIRE pipeline after INCRBY's own reply bytes were already read (double-apply risk) instead of surfacing the first attempt's own error")
}
// TestRESPClient_ReconnectFailsTwice_ReturnsError asserts a client gives
// up (rather than retrying forever) once the retried attempt also fails:
// dialing a dead address never succeeds, so both the first attempt and the
@@ -897,7 +1001,7 @@ func TestRESPClient_WriteError_BrokenPipe(t *testing.T) {
{
name: "attemptEncodedOn: payload write fails",
run: func(c *respClient, pc *respConn) error {
_, err := c.attemptEncodedOn(pc, encodeCommands([][]string{{"GET", "k"}}), 1, time.Now().Add(time.Second))
_, _, _, err := c.attemptEncodedOn(pc, encodeCommands([][]string{{"GET", "k"}}), 1, time.Now().Add(time.Second))
return err
},
wantErrContains: "write",
@@ -943,7 +1047,7 @@ func TestRESPClient_AuthHandshakeFailure_ErrorReplySurfaces(t *testing.T) {
deadline := time.Now().Add(2 * time.Second)
pc, err := c.acquire(deadline)
require.NoError(t, err)
_, err = c.attemptEncodedOn(pc, encodeCommands([][]string{{"GET", "k"}}), 1, deadline)
_, _, _, err = c.attemptEncodedOn(pc, encodeCommands([][]string{{"GET", "k"}}), 1, deadline)
require.Error(t, err)
assert.Contains(t, err.Error(), "AUTH failed")
}
+77 -24
View File
@@ -28,9 +28,13 @@ const (
// maximum makes four tries total, not three.
const maxRetryAttempts = 3
// maxRetryWait caps every wait retryPolicy.do performs between attempts,
// whether computed from exponential backoff or read from an upstream's
// Retry-After header — spec §1's "capped at 2s per wait".
// maxRetryWait caps the wait retryPolicy.do computes for ITSELF — the
// exponential backoff ladder — between attempts. spec §1's "capped at 2s
// per wait". An upstream's own explicit Retry-After request is a
// separate case with its own, much larger cap (finding 12 fix,
// review-routes.md: this comment previously and incorrectly claimed
// maxRetryWait also caps a Retry-After-derived wait) — see
// maxRetryAfterWait, below.
const maxRetryWait = 2 * time.Second
// maxRetryAfterWait caps a wait this gateway will honour when the
@@ -49,7 +53,7 @@ const maxRetryWait = 2 * time.Second
// wait immediately rather than blocking out the full duration.
const maxRetryAfterWait = 30 * time.Second
// retryJitterFn spreads a computed backoff so that many requests failing
// retryJitterFn spreads a COMPUTED backoff so that many requests failing
// at the same instant do not retry in lockstep against the same upstream
// — the synchronized-retry shape that turns one upstream blip into a
// self-sustaining thundering herd (security audit run-1). It applies
@@ -57,6 +61,18 @@ const maxRetryAfterWait = 30 * time.Second
// the other half, so a jittered wait is always at least d/2 and never
// collapses to an immediate retry the way full jitter can.
//
// Finding 4 fix (review-routes.md): do, below, applies this ONLY to
// waitBefore's exponential-backoff branch, never to a wait that came
// from an upstream's own Retry-After header. Jittering a Retry-After
// value can shorten it (equal jitter draws anywhere in [d/2, d]), which
// would retry a saturated provider SOONER than it explicitly asked —
// exactly the backwards-under-overload behavior maxRetryAfterWait's own
// doc comment says honouring Retry-After exists to prevent ("can only
// ever make the gateway wait LONGER, never sooner"). waitBefore's second
// return value tells do which branch produced the wait, so this
// function itself needs no such distinction — it always jitters
// unconditionally; the caller decides whether to call it at all.
//
// A package-level var, mirroring retryPolicy.waitFn's own convention, so
// tests can substitute an identity function and keep asserting exact
// durations. It is applied in do, not in waitBefore, so waitBefore stays
@@ -207,16 +223,27 @@ func isTransient(resp *http.Response, err error) bool {
// waitBefore returns how long do should wait before the attempt after
// attempt (1-based) — the one that just produced resp (nil on a network
// error). Only a 429 response carrying a Retry-After header that parses
// as a non-negative whole-second count no greater than maxRetryWait wins
// outright: the upstream said exactly how long to back off. Every other
// case falls back to exponential backoff (p.backoff * 2^(attempt-1)),
// capped at maxRetryWait — that covers a 429 with no Retry-After header,
// one that fails to parse as whole seconds (for example the HTTP-date
// form), one that parses negative, one whose value exceeds maxRetryWait,
// a Retry-After header present on a non-429 5xx (ignored: Retry-After is
// only meaningful on 429 here), and every network-error retry.
func (p *retryPolicy) waitBefore(attempt int, resp *http.Response) time.Duration {
// error) — and whether that wait came from the upstream's own
// Retry-After header (fromRetryAfter) rather than this gateway's own
// exponential backoff ladder. do uses fromRetryAfter to decide whether
// retryJitterFn may touch the wait at all (finding 4 fix, review-
// routes.md — see retryJitterFn's own doc comment for why jittering a
// Retry-After value is wrong).
//
// A 429 response carrying a Retry-After header that parses as a
// non-negative whole-second count wins outright, clamped to
// maxRetryAfterWait (NOT maxRetryWait — an upstream's own explicit
// cool-down request is honoured far longer than this gateway's own
// ladder; see maxRetryAfterWait's own doc comment): the upstream said
// exactly how long to back off, and this gateway waits at least that
// long, up to the 30s ceiling. Every other case falls back to
// exponential backoff (p.backoff * 2^(attempt-1)), capped at
// maxRetryWait — that covers a 429 with no Retry-After header, one that
// fails to parse as whole seconds (for example the HTTP-date form), one
// that parses negative, a Retry-After header present on a non-429 5xx
// (ignored: Retry-After is only meaningful on 429 here), and every
// network-error retry.
func (p *retryPolicy) waitBefore(attempt int, resp *http.Response) (wait time.Duration, fromRetryAfter bool) {
if resp != nil && resp.StatusCode == http.StatusTooManyRequests {
if ra := resp.Header.Get("Retry-After"); ra != "" {
if secs, err := strconv.Atoi(ra); err == nil && secs >= 0 {
@@ -227,20 +254,20 @@ func (p *retryPolicy) waitBefore(attempt int, resp *http.Response) time.Duration
// back to the <=2s ladder and so retried a provider that
// had explicitly asked for a longer pause sooner than it
// asked, which is backwards under overload.
wait := time.Duration(secs) * time.Second
if wait > maxRetryAfterWait {
wait = maxRetryAfterWait
raWait := time.Duration(secs) * time.Second
if raWait > maxRetryAfterWait {
raWait = maxRetryAfterWait
}
return wait
return raWait, true
}
}
}
wait := p.backoff * time.Duration(int64(1)<<uint(attempt-1))
wait = p.backoff * time.Duration(int64(1)<<uint(attempt-1))
if wait > maxRetryWait {
wait = maxRetryWait
}
return wait
return wait, false
}
// drainAndClose discards a retried-away response's remaining body
@@ -263,9 +290,24 @@ func drainAndClose(resp *http.Response) {
// body, then waits per waitBefore via p.waitFn — ctx-aware, so a context
// canceled or timed out during that wait aborts the remaining attempts
// immediately rather than blocking out the wait in full. do returns the
// last attempt's (resp, err) as-is either way: on success, on a
// still-transient failure once tries are exhausted, on a non-transient
// failure at any attempt, or on a wait ctx aborted early.
// last attempt's (resp, err) as-is on success, on a still-transient
// failure once tries are exhausted, or on a non-transient failure at any
// attempt.
//
// Finding 9 fix (review-routes.md): when the wait itself is aborted
// early (p.waitFn returns false — ctx was canceled or its deadline
// passed while do was backing off, most commonly the CLIENT
// disconnecting during a now-up-to-30s Retry-After wait), do returns
// ctx.Err() instead of the just-drained-and-closed resp/err pair. The
// previous behavior returned that resp as-is: drainAndClose (below) had
// already fully read and closed its body by that point, so a caller
// building an error from it (newProviderHTTPError, providers.go) read a
// closed body and silently produced an error with an EMPTY upstream
// body — the real failure (the wait was aborted, not that the upstream
// answered with nothing) was lost. ctx.Err() is always non-nil here:
// waitFn returning false is p.waitFn's own documented contract for "ctx
// ended the wait early" (defaultWaitFn's doc comment), so this never
// masks a wait that genuinely completed.
//
// Feature A (v0.22) attempt-accounting: every call() invocation — not just
// the final one — is reported to ctx's attemptRecorder, if it carries one
@@ -290,9 +332,20 @@ func (p *retryPolicy) do(ctx context.Context, call func() (*http.Response, error
return resp, err
}
wait := retryJitterFn(p.waitBefore(attempt, resp))
// Finding 4 fix (review-routes.md): jitter is applied ONLY to the
// exponential-backoff branch (fromRetryAfter == false), never to a
// wait the upstream itself requested via Retry-After — see
// retryJitterFn's own doc comment for why jittering that value
// would be backwards under overload.
wait, fromRetryAfter := p.waitBefore(attempt, resp)
if !fromRetryAfter {
wait = retryJitterFn(wait)
}
drainAndClose(resp)
if !p.waitFn(ctx, wait) {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
return resp, err
}
}
+112 -27
View File
@@ -362,10 +362,10 @@ func TestRetryPolicy_Do_RetriesOnTransientThenSucceeds(t *testing.T) {
// TestRetryPolicy_WaitBefore_RetryAfterHandling pins Retry-After
// handling directly: a 429 whose Retry-After parses as a non-negative
// whole-second count is HONOURED, clamped to maxRetryAfterWait when it
// asks for longer. Every other shape — the HTTP-date form, negative, no
// header at all, or present on a non-429 status — falls back to
// exponential backoff, verified here against attempt=1's backoff*2^0
// value.
// asks for longer, and reports fromRetryAfter=true. Every other shape —
// the HTTP-date form, negative, no header at all, or present on a
// non-429 status — falls back to exponential backoff (fromRetryAfter=
// false), verified here against attempt=1's backoff*2^0 value.
//
// CONTRACT CHANGE (security audit run-1): a Retry-After above the 2s
// per-wait cap used to be DISCARDED and replaced by that ladder, so a
@@ -374,32 +374,78 @@ func TestRetryPolicy_Do_RetriesOnTransientThenSucceeds(t *testing.T) {
// sooner than asked. maxRetryWait still caps the wait the gateway
// computes for ITSELF; maxRetryAfterWait caps only what the upstream may
// ask for, and clamping can only ever lengthen a wait, never shorten one.
//
// fromRetryAfter (finding 4 fix, review-routes.md) is what do uses to
// decide whether retryJitterFn may touch the wait at all — jittering a
// Retry-After value could shorten it, retrying sooner than the upstream
// asked.
func TestRetryPolicy_WaitBefore_RetryAfterHandling(t *testing.T) {
p := &retryPolicy{backoff: 100 * time.Millisecond}
backoffFallback := 100 * time.Millisecond // attempt=1: backoff * 2^0
tests := []struct {
resp *http.Response
name string
want time.Duration
resp *http.Response
name string
want time.Duration
wantFromRetryAfter bool
}{
{retryAfterResp(http.StatusTooManyRequests, "1"), "429 with qualifying Retry-After (1s, within cap)", time.Second},
{retryAfterResp(http.StatusTooManyRequests, "30"), "429 with Retry-After over the 2s per-wait cap is still honoured", maxRetryAfterWait},
{retryAfterResp(http.StatusTooManyRequests, "3600"), "429 with Retry-After beyond maxRetryAfterWait is clamped to it, never discarded", maxRetryAfterWait},
{retryAfterResp(http.StatusTooManyRequests, "Wed, 21 Oct 2015 07:28:00 GMT"), "429 with HTTP-date Retry-After falls back to exponential backoff", backoffFallback},
{retryAfterResp(http.StatusTooManyRequests, "-5"), "429 with negative Retry-After falls back to exponential backoff", backoffFallback},
{fakeResp(http.StatusTooManyRequests, nil), "429 with no Retry-After header falls back to exponential backoff", backoffFallback},
{retryAfterResp(http.StatusServiceUnavailable, "1"), "503 with a Retry-After header is ignored (not 429)", backoffFallback},
{retryAfterResp(http.StatusTooManyRequests, "1"), "429 with qualifying Retry-After (1s, within cap)", time.Second, true},
{retryAfterResp(http.StatusTooManyRequests, "30"), "429 with Retry-After over the 2s per-wait cap is still honoured", maxRetryAfterWait, true},
{retryAfterResp(http.StatusTooManyRequests, "3600"), "429 with Retry-After beyond maxRetryAfterWait is clamped to it, never discarded", maxRetryAfterWait, true},
{retryAfterResp(http.StatusTooManyRequests, "Wed, 21 Oct 2015 07:28:00 GMT"), "429 with HTTP-date Retry-After falls back to exponential backoff", backoffFallback, false},
{retryAfterResp(http.StatusTooManyRequests, "-5"), "429 with negative Retry-After falls back to exponential backoff", backoffFallback, false},
{fakeResp(http.StatusTooManyRequests, nil), "429 with no Retry-After header falls back to exponential backoff", backoffFallback, false},
{retryAfterResp(http.StatusServiceUnavailable, "1"), "503 with a Retry-After header is ignored (not 429)", backoffFallback, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := p.waitBefore(1, tt.resp); got != tt.want {
t.Errorf("waitBefore(1, ...) = %v, want %v", got, tt.want)
got, fromRetryAfter := p.waitBefore(1, tt.resp)
if got != tt.want {
t.Errorf("waitBefore(1, ...) wait = %v, want %v", got, tt.want)
}
if fromRetryAfter != tt.wantFromRetryAfter {
t.Errorf("waitBefore(1, ...) fromRetryAfter = %v, want %v", fromRetryAfter, tt.wantFromRetryAfter)
}
})
}
}
// TestRetryPolicy_Do_JitterNotAppliedToRetryAfter proves finding 4
// (review-routes.md): jitter is applied ONLY to the exponential backoff
// ladder, never to an upstream Retry-After value. Honouring an explicit
// overload signal and then retrying earlier than asked, via equal
// jitter's own downward half, would contradict the whole point of
// maxRetryAfterWait (its own doc comment: honouring Retry-After "can
// only ever make the gateway wait LONGER, never sooner"). Run several
// times: an un-jittered wait is deterministic, where a jittered one
// would (eventually) vary — see
// TestRetryPolicy_Do_JitterStaysWithinHalfToFullBackoff's own comment
// for why that observation matters.
func TestRetryPolicy_Do_JitterNotAppliedToRetryAfter(t *testing.T) {
const retryAfterSeconds = "4"
for range 32 {
var calls int32
var waits []time.Duration
p := &retryPolicy{enabled: true, attempts: 1, backoff: time.Millisecond, waitFn: spyWait(&waits)}
call := callSequence(&calls,
func() (*http.Response, error) {
return retryAfterResp(http.StatusTooManyRequests, retryAfterSeconds), nil
},
func() (*http.Response, error) { return fakeResp(http.StatusOK, nil), nil },
)
if _, err := p.do(context.Background(), call); err != nil {
t.Fatalf("do: %v", err)
}
if len(waits) != 1 {
t.Fatalf("waits = %v, want exactly 1", waits)
}
if waits[0] != 4*time.Second {
t.Fatalf("waits[0] = %v, want exactly 4s (a Retry-After wait must never be jittered)", waits[0])
}
}
}
// TestRetryPolicy_Do_NonTransientStatus_NoRetry asserts a 400 response
// (not 429, not 5xx) is returned as-is on the first attempt, with no
// retry and no wait.
@@ -572,7 +618,12 @@ func TestRetryPolicy_Do_JitterStaysWithinHalfToFullBackoff(t *testing.T) {
// TestRetryPolicy_Do_CtxAlreadyCanceled_NoRetry asserts a context
// canceled before do() even starts stops the loop after the first
// attempt: the real (ctx-aware) defaultWaitFn resolves the already-closed
// ctx.Done() case rather than blocking out the backoff.
// ctx.Done() case rather than blocking out the backoff. Finding 9 fix
// (review-routes.md): because the wait never actually completed, do
// returns the context's own error, not the first attempt's
// already-drained-and-closed response — returning that response let a
// caller's newProviderHTTPError (providers.go) read an already-closed
// body and silently produce an error with an empty upstream body.
func TestRetryPolicy_Do_CtxAlreadyCanceled_NoRetry(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // canceled before do() ever runs
@@ -582,21 +633,23 @@ func TestRetryPolicy_Do_CtxAlreadyCanceled_NoRetry(t *testing.T) {
call := callSequence(&calls, func() (*http.Response, error) { return fakeResp(http.StatusServiceUnavailable, nil), nil })
resp, err := p.do(ctx, call)
if err != nil {
t.Fatalf("do: %v", err)
if !errors.Is(err, context.Canceled) {
t.Errorf("err = %v, want errors.Is(err, context.Canceled)", err)
}
if resp != nil {
t.Errorf("resp = %v, want nil (must not return the already-drained/closed first attempt)", resp)
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Errorf("calls = %d, want 1 (ctx already done, no retry)", got)
}
if resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("resp.StatusCode = %d, want 503", resp.StatusCode)
}
}
// TestRetryPolicy_Do_CtxCanceledDuringWait_ReturnsPromptly proves the
// ctx-aware waitFn aborts an in-progress backoff wait the moment ctx
// ends, rather than blocking out the full duration: a 2s backoff against
// a 20ms context deadline must return in well under 2s.
// a 20ms context deadline must return in well under 2s, and (finding 9
// fix, review-routes.md) returns the context's own error rather than the
// first attempt's already-drained-and-closed response.
func TestRetryPolicy_Do_CtxCanceledDuringWait_ReturnsPromptly(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
@@ -609,8 +662,11 @@ func TestRetryPolicy_Do_CtxCanceledDuringWait_ReturnsPromptly(t *testing.T) {
resp, err := p.do(ctx, call)
elapsed := time.Since(start)
if err != nil {
t.Fatalf("do: %v", err)
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("err = %v, want errors.Is(err, context.DeadlineExceeded)", err)
}
if resp != nil {
t.Errorf("resp = %v, want nil (must not return the already-drained/closed first attempt)", resp)
}
if elapsed > 500*time.Millisecond {
t.Errorf("do took %v, want well under the 2s backoff (ctx should abort the wait promptly)", elapsed)
@@ -618,8 +674,37 @@ func TestRetryPolicy_Do_CtxCanceledDuringWait_ReturnsPromptly(t *testing.T) {
if got := atomic.LoadInt32(&calls); got != 1 {
t.Errorf("calls = %d, want 1 (ctx canceled during the wait, no further attempt)", got)
}
if resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("resp.StatusCode = %d, want 503 (last attempt's result)", resp.StatusCode)
}
// TestRetryPolicy_Do_CtxCanceledDuringWait_ReturnsCtxErrNotDrainedResp
// (finding 9, review-routes.md) directly proves the mechanism the two
// tests above exercise end to end: when the client disconnects while
// do() is backing off before a retry, do must return the context's own
// error, not the previous attempt's already-drained-and-closed
// *http.Response. Returning that response let a caller's
// newProviderHTTPError (providers.go) read an already-closed body and
// silently produce an error with an empty upstream body, hiding the real
// failure (the wait was aborted, not that the upstream answered with
// nothing).
func TestRetryPolicy_Do_CtxCanceledDuringWait_ReturnsCtxErrNotDrainedResp(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
p := &retryPolicy{enabled: true, attempts: 2, backoff: time.Millisecond, waitFn: func(waitCtx context.Context, d time.Duration) bool {
cancel() // simulate the client disconnecting mid-wait
<-waitCtx.Done()
return false
}}
var calls int32
call := callSequence(&calls, func() (*http.Response, error) { return fakeResp(http.StatusServiceUnavailable, nil), nil })
resp, err := p.do(ctx, call)
if resp != nil {
t.Errorf("resp = %v, want nil (must not return an already-drained/closed response)", resp)
}
if !errors.Is(err, context.Canceled) {
t.Errorf("err = %v, want errors.Is(err, context.Canceled)", err)
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Errorf("calls = %d, want 1 (the wait was aborted before a second attempt)", got)
}
}
+8 -1
View File
@@ -40,6 +40,9 @@ const maxMultipartModelFieldBytes = 4096
// audio-speech routes, whose request bodies are both single JSON objects
// carrying a top-level "model" field, unlike audio-transcriptions'
// multipart body. A read failure or invalid/missing JSON is a 400,
// an oversize body is a 413 (finding 14 fix, review-routes.md — see
// readAndDecodeUnifiedBody's identical fix, routes_unified.go, for why
// readCapped replaces a plain io.LimitReader-and-decode here too), both
// already written to sw; ok reports whether the caller may proceed.
func (g *Gateway) decodeMediaJSONRequest(sw *statusTrackingWriter, r *http.Request) (req map[string]any, model string, ok bool) {
release, admitted := g.acquireBodyAdmission(sw, writeOAIError)
@@ -48,11 +51,15 @@ func (g *Gateway) decodeMediaJSONRequest(sw *statusTrackingWriter, r *http.Reque
return nil, "", false
}
body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBytes))
body, oversize, err := readCapped(r.Body, maxRequestBytes)
if err != nil {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "cannot read request body")
return nil, "", false
}
if oversize {
writeOAIError(sw, http.StatusRequestEntityTooLarge, "invalid_request_error", "request body too large")
return nil, "", false
}
if err = json.Unmarshal(body, &req); err != nil {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid JSON body")
return nil, "", false
+522 -60
View File
@@ -5,10 +5,13 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strings"
"sync/atomic"
"time"
)
@@ -26,6 +29,23 @@ const maxPassthroughBytes = 32 << 20
// of any size — without ever waiting for the full body to buffer first.
const maxAccountingTeeBytes = 4 << 20
// sseAccountingHeadBytes/sseAccountingTailBytes cap sseAccountingBuffer
// (verify-core fix, round 4): an SSE stream used to be teed through the
// same 4MiB-capped cappedAccountingBuffer as a JSON body, which held
// every stream's first 4MiB in memory for its whole life AND — the worse
// half of the same bug — silently lost usage entirely for any stream
// whose final event (OpenAI's trailing usage chunk, Anthropic's last
// message_delta) arrived past that cap, which a long completion reaches
// easily. 64KiB of head is far more than message_start or the first
// content-bearing chunk ever needs; 64KiB of tail is far more than the
// one small trailing usage event ever needs — together, ~128KiB per
// concurrent SSE stream instead of up to 4MiB, and the final usage event
// is never dropped no matter how long the stream runs in between.
const (
sseAccountingHeadBytes = 64 << 10
sseAccountingTailBytes = 64 << 10
)
// maxModelPeekBytes caps how much of a passthrough request body
// peekPassthroughModel reads to find a top-level "model" field: 64KiB
// (security review fix, 2026-08-22, round 2). This is deliberately far
@@ -183,21 +203,30 @@ var providerCredentialRetargetHeaders = map[string]bool{
// dangerousResponseHeaders lists exact-match upstream RESPONSE headers
// stripped before being relayed to the client (security review finding
// 5, round 3, 2026-08-22) — additive to hopByHopHeaders, which
// 5, round 3, 2026-08-22; account-identity headers added, review-routes
// finding 17, 2026-09 review) — additive to hopByHopHeaders, which
// proxyUpstream already strips from both directions; this is the
// RESPONSE-side counterpart to dangerousClientHeaders/
// providerCredentialRetargetHeaders above, which already hardened the
// REQUEST side. proxyUpstream copies every OTHER upstream response
// header verbatim to the client — a raw reverse-proxy contract, not a
// translation layer — but three of them let the upstream act on THIS
// gateway's own origin, an origin that also serves the WAN-exposed
// /admin dashboard (admin.go): an upstream provider, or an in-cluster
// MCP/A2A target reached with no credential trust boundary of its own
// (handleTargetProxy's own doc comment, mcp_a2a.go — this strip applies
// there too, since both callers share this one function), could
// otherwise plant a Set-Cookie under the gateway's own domain, or
// rewrite the browser's security policy toward that domain via
// Strict-Transport-Security/Content-Security-Policy, merely by
// translation layer — but these let the upstream act on THIS gateway's
// own origin, or leak the operator's own upstream account identity to
// the tenant that made the request, an origin that also serves the
// WAN-exposed /admin dashboard (admin.go): an upstream provider, or an
// in-cluster MCP/A2A target reached with no credential trust boundary of
// its own (handleTargetProxy's own doc comment, mcp_a2a.go — this strip
// applies there too, since both callers share this one function), could
// otherwise plant a Set-Cookie under the gateway's own domain, rewrite
// the browser's security policy toward that domain via
// Strict-Transport-Security/Content-Security-Policy, or — Openai-
// Organization/Openai-Project/Anthropic-Organization-Id — echo the
// operator's own upstream org/project id straight back to a tenant who
// was never meant to see which account the gateway is billing against
// (the request side already treats these same identifiers as sensitive:
// providerCredentialRetargetHeaders above strips a tenant's own attempt
// to SET them; this is the matching response-side strip so the
// operator's real ids never round-trip back out either), merely by
// returning it in a response this gateway was only ever asked to relay.
//
// Deliberately a DENY-list, not an allowlist (unlike the request-side
@@ -213,11 +242,15 @@ var providerCredentialRetargetHeaders = map[string]bool{
// this gateway but commonly read by an external client's own SDK. An
// allowlist would silently break that transparency for every header this
// package does not already know to name; this narrow deny-list closes
// exactly the origin-integrity holes named above without that cost.
// exactly the origin-integrity and account-identity holes named above
// without that cost.
var dangerousResponseHeaders = map[string]bool{
"Set-Cookie": true,
"Strict-Transport-Security": true,
"Content-Security-Policy": true,
"Openai-Organization": true,
"Openai-Project": true,
"Anthropic-Organization-Id": true,
}
// dangerousResponseHeaderPrefixes strips every Access-Control-* header
@@ -415,6 +448,68 @@ func (c *cappedAccountingBuffer) Write(p []byte) (int, error) {
return n, nil
}
// sseAccountingBuffer is an io.Writer that keeps a bounded HEAD (the
// first sseAccountingHeadBytes, never overwritten once full — mirrors
// cappedAccountingBuffer's own cap-then-discard behavior) alongside a
// bounded, rolling TAIL (the last sseAccountingTailBytes seen so far,
// updated on every write for as long as the stream runs) of an SSE
// response body (verify-core fix, round 4 — see sseAccountingHeadBytes'
// own doc comment for why a stream needs both ends kept, not just a
// single front-loaded cap). Like cappedAccountingBuffer, Write always
// reports success for the full input length: it drives an io.TeeReader
// wrapped around the real, client-facing copy, and must never make that
// copy fail just because this accounting side-buffer discarded bytes
// outside its two windows.
type sseAccountingBuffer struct {
tail []byte
head bytes.Buffer
}
// Write implements io.Writer. head fills once, like
// cappedAccountingBuffer.Write; tail is maintained as a plain rolling
// window — appended to, then trimmed back down to sseAccountingTailBytes
// from the front only once it reaches twice that cap, so each byte is
// copied O(1) times amortized (a trim moves at most
// sseAccountingTailBytes bytes and happens once per
// sseAccountingTailBytes appended) rather than on every small write.
// Chosen over a circular-index ring buffer for simplicity.
func (b *sseAccountingBuffer) Write(p []byte) (int, error) {
n := len(p)
if remaining := sseAccountingHeadBytes - b.head.Len(); remaining > 0 {
head := p
if len(head) > remaining {
head = head[:remaining]
}
b.head.Write(head) //nolint:errcheck // bytes.Buffer.Write never errors
}
b.appendTail(p)
return n, nil
}
// appendTail keeps b.tail holding at least the last sseAccountingTailBytes
// written so far (up to twice that before a trim; read it via tailBytes). When p alone is already at or past the cap, the tail becomes
// simply p's own last sseAccountingTailBytes — the fast path for the
// (rare) single oversized write, and what keeps memory bounded even if a
// caller ever writes one huge chunk instead of many small ones.
func (b *sseAccountingBuffer) appendTail(p []byte) {
if len(p) >= sseAccountingTailBytes {
b.tail = append(b.tail[:0], p[len(p)-sseAccountingTailBytes:]...)
return
}
b.tail = append(b.tail, p...)
if len(b.tail) >= 2*sseAccountingTailBytes {
b.tail = append(b.tail[:0], b.tail[len(b.tail)-sseAccountingTailBytes:]...)
}
}
// tailBytes returns the last (at most) sseAccountingTailBytes written.
func (b *sseAccountingBuffer) tailBytes() []byte {
if len(b.tail) > sseAccountingTailBytes {
return b.tail[len(b.tail)-sseAccountingTailBytes:]
}
return b.tail
}
// passthroughUsagePayload captures every shape the three provider types'
// native non-streaming JSON responses report token usage in, plus the
// optional top-level "model" field OpenAI- and Anthropic-shaped responses
@@ -479,6 +574,144 @@ func extractPassthroughUsage(typeName, providerName string, body []byte) (usage,
}
}
// extractPassthroughSSEUsage best-effort parses head and tail — the
// bounded first/last windows an sseAccountingBuffer captured, teed
// alongside the client-facing copy (verify-core fix, round 4: a single
// front-loaded 4MiB cap, the previous shape, silently lost the trailing
// usage event on any stream longer than that — see sseAccountingHeadBytes'
// own doc comment) — for one of the two documented native streaming usage
// shapes (finding 2 fix, review-routes.md): OpenAI's own final chunk (the
// identical top-level "usage" object extractPassthroughUsage already
// parses out of a non-streaming OpenAI response, just carried on the LAST
// SSE data event instead of the whole body — present only when the client
// itself asked for it via stream_options.include_usage, which native
// passthrough never forces the way the unified route's own
// chatCompletion does) and Anthropic's own two-event shape:
// message_start.message.usage for the prompt side (folding in its own
// prompt-cache counters, matching extractPassthroughUsage's
// providerTypeAnthropic case) and the LAST message_delta.usage.
// output_tokens for the completion side. Gemini's own SSE convention is
// not documented here and is deliberately left unhandled — the returned
// usage simply stays zero for it, exactly like any other unrecognized or
// empty stream, and the caller's own request-body-size estimate covers
// the rest. model falls back to "unknown/"+providerName when neither
// window named one, matching extractPassthroughUsage's own convention.
//
// head and tail are parsed independently (parseSSEUsageChunk, below),
// then merged field-by-field: prompt/completion each take the tail's own
// value when it is non-zero, otherwise the head's — tail wins because it
// is closer to the stream's actual end (OpenAI's one usage chunk and
// Anthropic's last message_delta both land there), while Anthropic's
// prompt tokens (message_start, always the first event) still come from
// head on any stream long enough that message_start has already scrolled
// out of the tail window. model prefers tail, then head, over the
// "unknown/"+providerName fallback, applied once here rather than inside
// parseSSEUsageChunk so a real model name found in EITHER window is never
// masked by the other window's own fallback.
func extractPassthroughSSEUsage(typeName, providerName string, head, tail []byte) (usage, string) {
headUsage, headModel := parseSSEUsageChunk(typeName, head)
tailUsage, tailModel := parseSSEUsageChunk(typeName, tail)
u := headUsage
if tailUsage.prompt > 0 {
u.prompt = tailUsage.prompt
}
if tailUsage.completion > 0 {
u.completion = tailUsage.completion
}
model := tailModel
if model == "" {
model = headModel
}
if model == "" {
model = "unknown/" + providerName
}
return u, model
}
// parseSSEUsageChunk best-effort parses raw — one captured window (head
// or tail) of a text/event-stream byte buffer — for the usage/model
// shapes extractPassthroughSSEUsage documents above. It returns a bare
// (usage, model) pair with no "unknown/"+providerName fallback applied:
// that fallback is the caller's job, once, after merging both windows.
//
// readSSE (sse.go) is reused verbatim rather than re-implemented: this is
// the identical event-stream framing the unified route's own
// forwardStream implementations already parse, so any CR-handling or
// injection-safety fix to readSSE (finding 3, review-routes.md) covers
// this path automatically, with nothing to keep in sync by hand. Every
// readSSE/json.Unmarshal error is swallowed deliberately — best-effort
// parsing of a possibly-truncated buffer (raw's own leading or trailing
// bytes may cut an event in half at the window boundary), not a hard
// requirement.
func parseSSEUsageChunk(typeName string, raw []byte) (usage, string) {
var u usage
var model string
switch typeName {
case providerTypeOpenAI:
_ = readSSE(bytes.NewReader(raw), func(ev sseEvent) error {
if string(ev.data) == "[DONE]" {
return nil
}
var chunk passthroughUsagePayload
if json.Unmarshal(ev.data, &chunk) != nil {
return nil
}
if chunk.Usage.PromptTokens > 0 || chunk.Usage.CompletionTokens > 0 {
u = usage{prompt: chunk.Usage.PromptTokens, completion: chunk.Usage.CompletionTokens}
}
if chunk.Model != "" {
model = chunk.Model
}
return nil
})
case providerTypeAnthropic:
_ = readSSE(bytes.NewReader(raw), func(ev sseEvent) error {
switch ev.event {
case "message_start":
var payload struct {
Message struct {
Model string `json:"model"`
Usage struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
} `json:"usage"`
} `json:"message"`
}
if json.Unmarshal(ev.data, &payload) != nil {
return nil
}
u.prompt = payload.Message.Usage.InputTokens + payload.Message.Usage.CacheCreationInputTokens + payload.Message.Usage.CacheReadInputTokens
if payload.Message.Usage.OutputTokens > 0 {
u.completion = payload.Message.Usage.OutputTokens
}
if payload.Message.Model != "" {
model = payload.Message.Model
}
case "message_delta":
var payload struct {
Usage struct {
OutputTokens int64 `json:"output_tokens"`
} `json:"usage"`
}
if json.Unmarshal(ev.data, &payload) != nil {
return nil
}
if payload.Usage.OutputTokens > 0 {
u.completion = payload.Usage.OutputTokens
}
}
return nil
})
}
return u, model
}
// peekPassthroughModel reads at most maxModelPeekBytes of a passthrough
// request body to find its top-level "model" field, then restores r.Body
// to stream the ORIGINAL, complete body to the upstream unchanged — the
@@ -967,6 +1200,38 @@ func (g *Gateway) handlePassthrough(w http.ResponseWriter, r *http.Request, u *u
// already confirmed it is a key of g.adapters before calling in.
adapter := g.adapters[providerName]
// Finding 6 fix (review-routes.md): the same unpriced-model-under-a-
// cost-budget refusal runMeteredCall applies to /v1/chat/completions
// (routes_unified.go's own F-1 gate) must also cover native
// passthrough, or a tenant whose cost budget is exhausted on the
// unified routes can keep spending without limit simply by calling
// the identical model through "/{provider}/...". Passthrough has no
// resolved canonical/upstream model id the way the unified route
// does — only whatever "model" the CLIENT's OWN request body names,
// peeked the same bounded way model authorization above already does
// — so this only ever fires when that model is actually determinable
// from the body; a request this gateway cannot price at all (Gemini
// passthrough, whose model id lives in the URL, never the body; a
// binary content type; or a body whose "model" sits beyond the peek
// window) is let through unpriced, exactly as before this fix,
// rather than fail-closed on a value it never had. peekPassthroughModel
// is called again here even when the model-restriction branches above
// already called it (a second bounded, cheap 64KiB re-peek — r.Body
// was already restored via io.MultiReader after that first call) so
// this check runs independently of whether the group restricts
// models at all.
if !g.cfg.AllowUnpricedWithCostBudget && scopesHaveCostBudget(scopes) {
if model, hasModel, _, peekErr := peekPassthroughModel(r); peekErr == nil && hasModel {
canonical := providerName + "/" + model
if !priceKnown(canonical, model, g.cfg.Pricing, g.cfg.ModelMeta) {
g.logf("passthrough: refusing model %q: it has no configured price, and a cost budget applies to this caller that cannot be enforced without one", model)
writeOAIError(w, http.StatusPaymentRequired, "invalid_request_error",
fmt.Sprintf("model %q has no configured price, so the cost budget that applies to this request cannot be enforced; add a \"pricing\" entry for it, or set allowUnpricedWithCostBudget to serve it unbounded", model))
return
}
}
}
upstreamURL := adapter.base() + "/" + rest
if r.URL.RawQuery != "" {
upstreamURL += "?" + r.URL.RawQuery
@@ -991,30 +1256,79 @@ func (g *Gateway) handlePassthrough(w http.ResponseWriter, r *http.Request, u *u
}))
}
result, ok := g.proxyUpstream(w, r, upstreamURL, adapter.httpClient(), adapter.injectAuth, providerCredentialRetargetHeaders, true, "passthrough (provider "+providerName+")", adapter.requestTimeout())
if !ok || !result.isJSON {
// A build/connection/copy failure already wrote its own response
// (or, for a canceled client context, wrote nothing at all — see
// proxyUpstream); a non-JSON response has nothing more to account
// than the request itself, already counted by checkAndCount above.
return
}
if result.tee.truncated {
g.logf("passthrough: response body exceeded %d bytes; skipping usage accounting (provider %q)", maxAccountingTeeBytes, providerName)
result, complete := g.proxyUpstream(w, r, upstreamURL, adapter.httpClient(), adapter.injectAuth, providerCredentialRetargetHeaders, true, "passthrough (provider "+providerName+")", adapter.requestTimeout())
// Finding 2 fix (review-routes.md): a build/dial failure that never
// produced any response at all (result.status == 0 — proxyUpstream
// returned before ever writing headers to the client: a connection
// failure, or the client canceling before the request was even sent)
// has nothing to account beyond the request itself, already counted
// by checkAndCount above — the same "a connection failure before any
// write always carries zero usage by contract" rule runMeteredCall
// applies (routes_unified.go). Every OTHER outcome below — a complete
// or truncated JSON body, a complete or aborted SSE stream — now
// falls through to at least the request-body-size estimate, so a
// streamed or malformed response can no longer be billed zero just by
// never finishing cleanly. A binary/other response (audio, image, a
// content type this gateway does not parse at all) is unchanged from
// before this finding: it never carries a token-shaped usage object
// and this gateway has no basis to estimate one for it.
if result.status == 0 || (!result.isJSON && !result.isSSE) {
return
}
// respModel: the RESPONSE body's own reported model id (extractPassthroughUsage
// reads it, when present, from the upstream's reply) — never named
// "model" here, so it can never be confused with (or shadow) the
// REQUEST body's "model" field the enforcement block above already
// consumed via peekPassthroughModel (review fix, 2026-08-22, round 2).
respUsage, respModel, unmarshalErr := extractPassthroughUsage(adapter.typeName(), providerName, result.tee.buf.Bytes())
if unmarshalErr != nil {
g.logf("passthrough: response body did not decode as JSON for usage accounting (provider %q): %v", providerName, unmarshalErr)
var respUsage usage
var respModel string
switch {
case result.isJSON && !result.tee.truncated:
// respModel: the RESPONSE body's own reported model id
// (extractPassthroughUsage reads it, when present, from the
// upstream's reply) — never named "model" here, so it can never
// be confused with (or shadow) the REQUEST body's "model" field
// the enforcement block above already consumed via
// peekPassthroughModel (review fix, 2026-08-22, round 2).
var unmarshalErr error
respUsage, respModel, unmarshalErr = extractPassthroughUsage(adapter.typeName(), providerName, result.tee.buf.Bytes())
if unmarshalErr != nil {
g.logf("passthrough: response body did not decode as JSON for usage accounting (provider %q): %v", providerName, unmarshalErr)
}
case result.isJSON:
g.logf("passthrough: response body exceeded %d bytes; falling back to an estimate (provider %q)", maxAccountingTeeBytes, providerName)
default: // result.isSSE
respUsage, respModel = extractPassthroughSSEUsage(adapter.typeName(), providerName, result.sseTee.head.Bytes(), result.sseTee.tailBytes())
}
if respUsage.total() == 0 {
// Verify-core fix (round 4): the estimate below is only a valid
// stand-in for real usage when the upstream actually accepted
// the request (2xx) AND either the response is SSE (whose usage
// event may simply not have arrived yet in the parsed window) or
// the JSON body was cut short — by the tee cap (result.tee.
// truncated) or by the copy itself aborting (!complete: a client
// cancel mid-copy, or a watchdog/transport error after headers
// were already written). A complete 2xx JSON body that simply
// has no usage field (file uploads, audio transcriptions,
// moderations, batches, assistants) must stay a true accounting
// no-op, exactly as before this fix — estimating from its body
// size charged a 4 MiB upload roughly 1M phantom prompt tokens.
// A 4xx/5xx JSON error body must never be estimated either: the
// upstream billed nothing for a rejected request, so neither
// should this gateway.
is2xx := result.status >= 200 && result.status < 300
copyAborted := !complete
canEstimate := is2xx && (result.isSSE || (result.isJSON && (result.tee.truncated || copyAborted)))
if !canEstimate || result.requestBytes <= 0 {
return
}
respUsage = usage{prompt: int64(math.Ceil(float64(result.requestBytes) / 4)), estimated: true}
if respModel == "" {
respModel = "unknown/" + providerName
}
g.logf("passthrough: usage for provider %q logged as estimated (%d prompt tokens derived from request body size, not the provider's reported usage)", providerName, respUsage.prompt)
}
canonical := providerName + "/" + respModel
cost := unifiedCostMicros(canonical, respModel, respUsage, g.cfg.Pricing)
cost := unifiedCostMicros(canonical, respModel, respUsage, g.cfg.Pricing, g.cfg.ModelMeta, g.pricingWarn)
// withModelScope drops the model scope when respModel is empty (the
// upstream reported no model id), so a passthrough reply the gateway
// cannot attribute still accounts to user/group/total as before.
@@ -1023,12 +1337,16 @@ func (g *Gateway) handlePassthrough(w http.ResponseWriter, r *http.Request, u *u
// proxyResult is what proxyUpstream reports back to its caller once it has
// streamed a response to the client: enough for a caller that wants
// best-effort JSON usage accounting (handlePassthrough) to run it, without
// proxyUpstream itself knowing anything about usage or pricing. tee is
// nil unless accountJSON was true and the response's Content-Type was
// application/json.
// best-effort JSON/SSE usage accounting (handlePassthrough) to run it,
// without proxyUpstream itself knowing anything about usage or pricing.
// tee is nil unless accountJSON was true and the response's Content-Type
// was application/json (finding 2 fix, review-routes.md). sseTee is its
// SSE counterpart (verify-core fix, round 4): non-nil exactly when isSSE
// is true, nil whenever tee is non-nil and vice versa — a response is
// teed through at most one of the two buffer types, never both.
type proxyResult struct {
tee *cappedAccountingBuffer
tee *cappedAccountingBuffer
sseTee *sseAccountingBuffer
// status is the upstream response's own status code, once headers
// were actually written to w — 0 when proxyUpstream returned before
// ever writing them (a build/connection failure, or a client cancel
@@ -1037,10 +1355,29 @@ type proxyResult struct {
// target_health.go): ok=false alone conflates "the upstream never
// answered at all" with "it answered with a 5xx", which that
// feature's failure rule needs to distinguish. Field order (pointer,
// then int, then the two bools) is fieldalignment-sensitive, the same
// convention this package's other structs already follow.
// then the int fields, then the bools last) is fieldalignment-
// sensitive, the same convention this package's other structs
// already follow.
status int
isJSON bool
// requestBytes is the total number of bytes actually read from the
// client's own request body while forwarding it upstream (finding 2
// fix, review-routes.md) — cappedBodyReader's own running count,
// captured once the upstream call completes. It is the best
// available proxy for the client's own request size: proxyUpstream
// never buffers the request body into memory the way the unified
// routes' own decode step does, so there is no other length signal
// to derive a zero-usage response's own prompt-token estimate from.
// 0 whenever the request never had a body, or the upstream call
// failed before any of it was sent.
requestBytes int64
isJSON bool
// isSSE mirrors isJSON for a text/event-stream response (finding 2
// fix): a streamed response is teed into result.tee the identical
// way a non-streaming JSON one already was, so handlePassthrough can
// parse usage out of the captured raw SSE bytes once the copy ends,
// instead of never accounting a streamed passthrough response at
// all.
isSSE bool
// clientCanceled is true exactly when either of proxyUpstream's own
// errors.Is(err, context.Canceled) branches fired — the CLIENT, not
// the upstream, ended the request, whether before the upstream call
@@ -1051,6 +1388,70 @@ type proxyResult struct {
clientCanceled bool
}
// errPassthroughBodyTooLarge is cappedBodyReader's (below) terminal
// error, surfacing through client.Do once a passthrough request body
// delivers more than maxPassthroughBytes total (finding 8 fix, review-
// routes.md): io.LimitReader, the previous mechanism, truncates silently
// at its cap and reports a clean EOF regardless of whether the
// underlying reader actually had more to give — so an oversized body
// with unknown Content-Length (chunked transfer) previously completed as
// a valid, silently-truncated upstream request instead of failing
// loudly. A multipart audio upload over the cap, for instance, still
// parsed a valid, truncated prefix and got transcribed with no error at
// all.
var errPassthroughBodyTooLarge = errors.New("llmgateway: passthrough request body exceeds the configured limit")
// cappedBodyReader wraps r, tracking every byte actually read (via
// bytesRead) and erroring with errPassthroughBodyTooLarge once more than
// limit bytes have been delivered (finding 8 fix). It deliberately
// requests one byte beyond limit from the underlying reader before
// erroring — limit+1, not limit — so a body of EXACTLY limit bytes still
// completes normally: the underlying reader's own clean EOF is what ends
// it, never this cap, while a body with anything left beyond limit is
// caught here as an error instead of a silent truncation.
//
// read is accessed via atomic.AddInt64/LoadInt64, not a bare field
// (matching this package's own established convention — see
// watchdogBody's identical reasoning, timeout.go): proxyUpstream reads
// bytesRead() from a different goroutine's perspective than Read() runs
// in, in the sense that Go's http.Transport can still be finishing a
// request-body write in its own internal goroutine at the exact moment
// client.Do returns and proxyUpstream inspects the total (verified
// empirically: Go's Transport does not guarantee the body write
// goroutine has fully synchronized before RoundTrip returns for every
// response-arrives-early shape), so every access goes through atomic
// operations to guarantee the caller sees the true final count.
type cappedBodyReader struct {
r io.Reader
limit int64
read int64
}
// Read implements io.Reader.
func (c *cappedBodyReader) Read(p []byte) (int, error) {
already := atomic.LoadInt64(&c.read)
if already > c.limit {
return 0, errPassthroughBodyTooLarge
}
if allowed := c.limit + 1 - already; int64(len(p)) > allowed {
p = p[:allowed]
}
n, err := c.r.Read(p)
total := atomic.AddInt64(&c.read, int64(n))
if total > c.limit && err == nil {
return n, errPassthroughBodyTooLarge
}
return n, err
}
// bytesRead reports the total number of bytes read so far, capped
// implicitly at limit+1 by Read's own contract above — proxyUpstream's
// own best-effort request-size signal (finding 2 fix) for
// handlePassthrough's zero-usage estimate fallback.
func (c *cappedBodyReader) bytesRead() int64 {
return atomic.LoadInt64(&c.read)
}
// proxyUpstream is the shared reverse-proxy core behind both native
// provider passthrough (handlePassthrough, above) and the MCP/A2A target
// proxy (handleTargetProxy, mcp_a2a.go): it builds an upstream request
@@ -1079,13 +1480,16 @@ type proxyResult struct {
// no injected credential at all.
//
// The RESPONSE side is hardened too (security review finding 5, round 3,
// 2026-08-22): every hop-by-hop header AND dangerousResponseHeaders
// (Set-Cookie, Strict-Transport-Security, Content-Security-Policy) plus
// 2026-08-22; account-identity headers added, review-routes finding 17,
// 2026-09 review): every hop-by-hop header AND dangerousResponseHeaders
// (Set-Cookie, Strict-Transport-Security, Content-Security-Policy,
// Openai-Organization, Openai-Project, Anthropic-Organization-Id) plus
// dangerousResponseHeaderPrefixes (Access-Control-*) are stripped before
// the upstream's response headers reach the client, for BOTH callers —
// an upstream provider or an in-cluster MCP/A2A target must never get to
// plant a cookie or dictate a security/CORS policy on this gateway's own
// origin merely by setting it on a response this function was only ever
// plant a cookie, dictate a security/CORS policy on this gateway's own
// origin, or leak the operator's own upstream account identity to the
// tenant, merely by setting it on a response this function was only ever
// asked to relay. See dangerousResponseHeaders' own doc comment for why
// this is a narrow deny-list, not an allowlist.
//
@@ -1113,8 +1517,25 @@ type proxyResult struct {
// timeout at all), so both are covered the same way. The Transport-level
// half (ResponseHeaderTimeout) is already set on client itself and needs
// no separate wiring here.
//
// BODY SIZE (finding 8 fix, review-routes.md): a request whose declared
// Content-Length already exceeds maxPassthroughBytes is rejected up
// front with 413, before any request is even built — the cheapest
// possible rejection. An unknown length (chunked transfer) is caught
// mid-copy instead (net/http never reads past a declared Content-Length,
// so an understated length cannot reach here), by cappedBodyReader (below): unlike the previous plain
// io.LimitReader, which silently truncates at the cap and reports a
// clean EOF regardless of whether more data actually existed, it errors
// once more than the cap has been delivered, aborting the upstream
// request instead of completing it against a silently truncated prefix
// (a multipart audio upload over the cap previously still parsed a
// valid, truncated body and got transcribed with no error at all).
func (g *Gateway) proxyUpstream(w http.ResponseWriter, r *http.Request, upstreamURL string, client *http.Client, injectAuth func(*http.Request), extraStrip map[string]bool, accountJSON bool, logPrefix string, timeout time.Duration) (result proxyResult, ok bool) {
bodyReader := io.LimitReader(r.Body, maxPassthroughBytes)
if r.ContentLength > maxPassthroughBytes {
writeOAIError(w, http.StatusRequestEntityTooLarge, "invalid_request_error", "request body exceeds the maximum allowed size")
return proxyResult{}, false
}
body := &cappedBodyReader{r: r.Body, limit: maxPassthroughBytes}
// gosec G704 (SSRF via taint analysis) flags upstreamURL as
// request-derived: it is, by design — this is a reverse proxy, and its
// whole job is to forward a client-supplied path/query onto the
@@ -1138,20 +1559,18 @@ func (g *Gateway) proxyUpstream(w http.ResponseWriter, r *http.Request, upstream
// released either by watchdogBody (once resp.Body exists) or directly
// below, on a build/send failure that never produced a body to wrap.
reqCtx, cancel := context.WithCancel(r.Context())
upstreamReq, err := http.NewRequestWithContext(reqCtx, r.Method, upstreamURL, bodyReader) //nolint:gosec // operator-fixed host, rest is traversal-checked and forwarded escaped; see comment above
upstreamReq, err := http.NewRequestWithContext(reqCtx, r.Method, upstreamURL, body) //nolint:gosec // operator-fixed host, rest is traversal-checked and forwarded escaped; see comment above
if err != nil {
cancel()
g.errorf("%s: build upstream request: %v", logPrefix, err)
writeOAIError(w, http.StatusBadGateway, "server_error", "upstream connection error")
return proxyResult{}, false
}
// r.ContentLength is already <= maxPassthroughBytes here (the known-
// oversize case returned 413 above) or -1/unknown, so it is forwarded
// verbatim — no further adjustment needed now that cappedBodyReader,
// not a truncating io.LimitReader, is what actually bounds the body.
upstreamReq.ContentLength = r.ContentLength
if r.ContentLength < 0 || r.ContentLength > maxPassthroughBytes {
// Unknown or over-cap length: let the transport negotiate chunked
// transfer instead of advertising a Content-Length the capped
// bodyReader above may not actually deliver.
upstreamReq.ContentLength = -1
}
copyHeadersExcept(upstreamReq.Header, r.Header, hopByHopHeaders, gatewayCredentialHeaders, clientNegotiationHeaders, dangerousClientHeaders, extraStrip)
stripHeaderPrefixes(upstreamReq.Header, dangerousClientHeaderPrefixes)
if injectAuth != nil {
@@ -1187,13 +1606,40 @@ func (g *Gateway) proxyUpstream(w http.ResponseWriter, r *http.Request, upstream
if rec != nil {
rec(resp, err)
}
// requestBytes is captured now, regardless of how this call resolves:
// client.Do fully drains a request body with a Content-Length (or
// hits the reader's own terminal error/EOF for a chunked one) before
// it can return at all, so cappedBodyReader's running count is
// already final by this point (finding 2 fix, review-routes.md —
// this is proxyUpstream's own best-effort request-size signal for
// handlePassthrough's zero-usage estimate fallback).
requestBytes := body.bytesRead()
if err != nil {
cancel()
if errors.Is(err, context.Canceled) {
g.logf("%s: client canceled request: %v", logPrefix, err)
return proxyResult{clientCanceled: true}, false
}
g.errorf("%s: upstream connection error: %v", logPrefix, err)
if errors.Is(err, errPassthroughBodyTooLarge) {
// Finding 8 fix (review-routes.md): the client's body exceeded
// maxPassthroughBytes with an unknown (chunked)
// Content-Length — cappedBodyReader's own error aborted the
// upstream request before it could complete against a
// silently truncated prefix. 413, not the generic 502 the
// branch below would otherwise answer with.
g.logf("%s: request body exceeded %d bytes; rejecting", logPrefix, maxPassthroughBytes)
writeOAIError(w, http.StatusRequestEntityTooLarge, "invalid_request_error", "request body exceeds the maximum allowed size")
return proxyResult{}, false
}
// P4 (review round 4): upstreamURL carries the merged target
// query string — this gateway's own credential channel for an
// MCP/A2A target (mcp_a2a.go), and for a provider it is
// adapter.base() plus the client's own path/query — and err
// here is commonly a *url.Error wrapping it verbatim. Scrubbed
// the same way every other target/upstream-URL-bearing log line
// in this codebase is (sanitizeBaseURL/sanitizeProviderErr,
// admin.go).
g.errorf("%s: upstream connection error: %v", logPrefix, sanitizeProviderErr(err.Error(), upstreamURL))
writeOAIError(w, http.StatusBadGateway, "server_error", "upstream connection error")
return proxyResult{}, false
}
@@ -1213,17 +1659,33 @@ func (g *Gateway) proxyUpstream(w http.ResponseWriter, r *http.Request, upstream
w.WriteHeader(resp.StatusCode)
fw := newFlushWriter(w)
// Only a non-streaming JSON response gets teed off for best-effort
// accounting, and only when the caller wants that at all (accountJSON);
// every other response streams straight through fw with no extra
// buffering — a large or slow upstream body must never sit waiting for
// full receipt before the client sees its first byte.
// A non-streaming JSON response, or an SSE stream (isSSE — finding 2
// fix, review-routes.md: previously only isJSON was teed at all, so a
// streamed passthrough response was never accounted for anything past
// the request itself), gets teed off for best-effort accounting, and
// only when the caller wants that at all (accountJSON); every other
// response streams straight through fw with no extra buffering — a
// large or slow upstream body must never sit waiting for full receipt
// before the client sees its first byte. Bytes reaching the CLIENT
// are unaffected either way: the tee only ever observes a copy of
// what fw already wrote. JSON keeps the flat, 4MiB-capped
// cappedAccountingBuffer; SSE uses the bounded head+tail
// sseAccountingBuffer instead (verify-core fix, round 4 —
// sseAccountingHeadBytes' own doc comment), since a streamed usage
// event arrives at the very END and a single front-loaded cap loses
// it on any stream longer than that cap.
isJSON := accountJSON && strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "application/json")
isSSE := accountJSON && strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "text/event-stream")
var tee *cappedAccountingBuffer
var sseTee *sseAccountingBuffer
var reader io.Reader = resp.Body
if isJSON {
switch {
case isJSON:
tee = &cappedAccountingBuffer{}
reader = io.TeeReader(resp.Body, tee)
case isSSE:
sseTee = &sseAccountingBuffer{}
reader = io.TeeReader(resp.Body, sseTee)
}
if _, err := io.Copy(fw, reader); err != nil {
@@ -1236,10 +1698,10 @@ func (g *Gateway) proxyUpstream(w http.ResponseWriter, r *http.Request, upstream
// — the latter falls through to the errorf below, unchanged.
if errors.Is(err, context.Canceled) {
g.logf("%s: client canceled request: %v", logPrefix, err)
return proxyResult{isJSON: isJSON, tee: tee, status: resp.StatusCode, clientCanceled: true}, false
return proxyResult{isJSON: isJSON, isSSE: isSSE, tee: tee, sseTee: sseTee, status: resp.StatusCode, clientCanceled: true, requestBytes: requestBytes}, false
}
g.errorf("%s: stream response body: %v", logPrefix, err)
return proxyResult{isJSON: isJSON, tee: tee, status: resp.StatusCode}, false
return proxyResult{isJSON: isJSON, isSSE: isSSE, tee: tee, sseTee: sseTee, status: resp.StatusCode, requestBytes: requestBytes}, false
}
return proxyResult{isJSON: isJSON, tee: tee, status: resp.StatusCode}, true
return proxyResult{isJSON: isJSON, isSSE: isSSE, tee: tee, sseTee: sseTee, status: resp.StatusCode, requestBytes: requestBytes}, true
}
+849 -7
View File
@@ -5,7 +5,9 @@ import (
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/http/httptest"
"strings"
@@ -621,6 +623,57 @@ func TestHandlePassthrough_DangerousResponseHeadersStripped(t *testing.T) {
}
}
// TestHandlePassthrough_AccountIdentityResponseHeadersStripped is the
// review-routes finding 17 (2026-09 review) regression test: an upstream
// echoing its own account-identifying headers — OpenAI's
// Openai-Organization/Openai-Project, Anthropic's
// Anthropic-Organization-Id — must never have those reach the client. The
// request side already treats these as sensitive
// (providerCredentialRetargetHeaders strips a tenant's own attempt to SET
// them); this proves the matching response-side strip, so the operator's
// real upstream org/project ids never round-trip back out to the tenant
// either. An ordinary response header must still pass through unchanged.
func TestHandlePassthrough_AccountIdentityResponseHeadersStripped(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Openai-Organization", "org-secret123")
w.Header().Set("Openai-Project", "proj-secret456")
w.Header().Set("Anthropic-Organization-Id", "org-secret789")
w.Header().Set("X-Upstream-Custom", "keep-me")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{}`))
}))
defer srv.Close()
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{
"openai": {Type: "openai", BaseURL: srv.URL, APIKey: "sk-up"},
}
cfg.Groups = map[string]*GroupConfig{"default": {}}
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice"}}}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/openai/v1/thing", nil)
req.Header.Set("Authorization", "Bearer sk-alice")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
for _, hdr := range []string{"Openai-Organization", "Openai-Project", "Anthropic-Organization-Id"} {
if v := rec.Header().Get(hdr); v != "" {
t.Errorf("client saw account-identity response header %s = %q, want stripped", hdr, v)
}
}
if rec.Header().Get("X-Upstream-Custom") != "keep-me" {
t.Error("client must still see an ordinary upstream response header — the strip must not be a full allowlist inversion")
}
}
// TestHandlePassthrough_NonStreamJSON_AccountsUsage proves a non-streaming
// application/json response's usage is extracted and accounted against
// the caller's own limiter counters.
@@ -1010,11 +1063,16 @@ func TestHandlePassthrough_EncodedSlashSegment_PreservedAtUpstream(t *testing.T)
}
}
// TestHandlePassthrough_OversizedJSONBody_ClientGetsFullBody_RequestOnlyAccounting
// TestHandlePassthrough_OversizedJSONBody_ClientGetsFullBody_EstimatedAccounting
// proves a JSON response body larger than maxAccountingTeeBytes still
// reaches the client in full, while the accounting parse is skipped —
// only the request itself was already accounted, by checkAndCount.
func TestHandlePassthrough_OversizedJSONBody_ClientGetsFullBody_RequestOnlyAccounting(t *testing.T) {
// reaches the client in full, while the accounting PARSE is skipped (the
// tee never captured the "usage" field, which sits past the cap) — but
// (finding 2 fix, review-routes.md, supersedes this test's own former
// "request only" name and assertions) the request is no longer billed
// zero usage just because its response happened to be too large to
// parse: it falls back to the same request-body-size estimate a
// streamed response with no usable usage now also gets.
func TestHandlePassthrough_OversizedJSONBody_ClientGetsFullBody_EstimatedAccounting(t *testing.T) {
// Pad well past maxAccountingTeeBytes (4MiB) with an oversized field
// ahead of "usage", so the tee's cap is hit before "usage" is reached.
padding := strings.Repeat("x", maxAccountingTeeBytes+1024)
@@ -1057,13 +1115,17 @@ func TestHandlePassthrough_OversizedJSONBody_ClientGetsFullBody_RequestOnlyAccou
t.Errorf("client body = %d bytes, want the full %d bytes (client copy must never truncate)", rec.Body.Len(), len(respBody))
}
// The request body here is `{}` (2 bytes): ceil(2/4) = 1 estimated
// prompt token, 0 completion (finding 2's estimate has no basis for a
// completion-side number, matching the unified route's own identical
// estimate, routes_unified.go).
tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now())
if !ok || tokIn != 0 {
t.Errorf("user tokin/day counter = %d (ok=%v), want 0 — usage accounting must be skipped over the tee cap", tokIn, ok)
if !ok || tokIn != 1 {
t.Errorf("user tokin/day counter = %d (ok=%v), want 1 (estimated from the 2-byte request body)", tokIn, ok)
}
tokOut, ok := gw.limiter.getCounter("user", "alice", metricTokOut, windowDay, time.Now())
if !ok || tokOut != 0 {
t.Errorf("user tokout/day counter = %d (ok=%v), want 0 — usage accounting must be skipped over the tee cap", tokOut, ok)
t.Errorf("user tokout/day counter = %d (ok=%v), want 0", tokOut, ok)
}
reqCount, ok := gw.limiter.getCounter("user", "alice", metricReq, windowMin, time.Now())
if !ok || reqCount != 1 {
@@ -1071,6 +1133,758 @@ func TestHandlePassthrough_OversizedJSONBody_ClientGetsFullBody_RequestOnlyAccou
}
}
// TestCappedBodyReader is the direct unit test of cappedBodyReader
// (finding 8 fix, review-routes.md), mirroring TestReadCapped's own
// under/exactly-at/over-limit style (routes_media_test.go): a body
// exactly at the limit still completes normally (the underlying
// reader's own clean EOF ends it), and a body over the limit errors
// instead of silently truncating.
func TestCappedBodyReader(t *testing.T) {
t.Run("under limit", func(t *testing.T) {
c := &cappedBodyReader{r: strings.NewReader("hello"), limit: 10}
got, err := io.ReadAll(c)
if err != nil || string(got) != "hello" {
t.Errorf("ReadAll = (%q, %v), want (\"hello\", nil)", got, err)
}
if c.bytesRead() != 5 {
t.Errorf("bytesRead() = %d, want 5", c.bytesRead())
}
})
t.Run("exactly at limit", func(t *testing.T) {
c := &cappedBodyReader{r: strings.NewReader("0123456789"), limit: 10}
got, err := io.ReadAll(c)
if err != nil || string(got) != "0123456789" {
t.Errorf("ReadAll = (%q, %v), want (\"0123456789\", nil)", got, err)
}
if c.bytesRead() != 10 {
t.Errorf("bytesRead() = %d, want 10", c.bytesRead())
}
})
t.Run("over limit", func(t *testing.T) {
c := &cappedBodyReader{r: strings.NewReader("01234567890"), limit: 10}
_, err := io.ReadAll(c)
if !errors.Is(err, errPassthroughBodyTooLarge) {
t.Errorf("ReadAll err = %v, want errPassthroughBodyTooLarge", err)
}
})
}
// TestHandlePassthrough_BodyOverLimit_KnownContentLength_Returns413 proves
// finding 8 (review-routes.md): a request whose declared Content-Length
// already exceeds maxPassthroughBytes is rejected up front with 413,
// before the upstream is ever called.
func TestHandlePassthrough_BodyOverLimit_KnownContentLength_Returns413(t *testing.T) {
var upstreamCalled bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamCalled = true
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", BaseURL: srv.URL, APIKey: "sk-up"}}
cfg.Groups = map[string]*GroupConfig{"default": {}}
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice"}}}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
body := strings.Repeat("a", maxPassthroughBytes+1)
req := httptest.NewRequest(http.MethodPost, "/openai/v1/native-endpoint", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer sk-alice")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want 413, body=%s", rec.Code, rec.Body.String())
}
if upstreamCalled {
t.Error("upstream must never be called for a body already over the cap via a known Content-Length")
}
}
// TestHandlePassthrough_BodyOverLimit_ChunkedContentLength_Returns413
// proves finding 8's other half: an unknown/understated Content-Length
// (chunked transfer, or a client that lies) is caught mid-copy by
// cappedBodyReader instead of silently truncating — this exercises the
// SAME real maxPassthroughBytes cap the production code path uses,
// bypassing the upfront ContentLength check by setting it to -1.
func TestHandlePassthrough_BodyOverLimit_ChunkedContentLength_Returns413(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", BaseURL: srv.URL, APIKey: "sk-up"}}
cfg.Groups = map[string]*GroupConfig{"default": {}}
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice"}}}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
body := strings.Repeat("a", maxPassthroughBytes+1024)
req := httptest.NewRequest(http.MethodPost, "/openai/v1/native-endpoint", strings.NewReader(body))
req.ContentLength = -1 // simulate chunked transfer / unknown length, bypassing the upfront check
req.Header.Set("Authorization", "Bearer sk-alice")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want 413 (cappedBodyReader must catch this mid-copy), body=%s", rec.Code, rec.Body.String())
}
}
// TestExtractPassthroughSSEUsage covers finding 2's own documented
// provider shapes directly: OpenAI's final-chunk "usage" object,
// Anthropic's message_start + message_delta pair (folding in prompt-
// cache counters), Gemini's deliberately-unhandled convention, and a
// malformed/empty stream — each falls back to the "unknown/"+provider
// model sentinel and zero usage when nothing usable was found.
func TestExtractPassthroughSSEUsage(t *testing.T) {
tests := []struct {
name string
typeName string
providerName string
raw string
wantModel string
wantUsage usage
}{
{
name: "openai: usage on final chunk before DONE",
typeName: providerTypeOpenAI,
providerName: "openai",
raw: "data: {\"id\":\"c1\",\"model\":\"gpt-native\",\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n" +
"data: {\"id\":\"c1\",\"model\":\"gpt-native\",\"choices\":[],\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":3}}\n\n" +
"data: [DONE]\n\n",
wantUsage: usage{prompt: 7, completion: 3},
wantModel: "gpt-native",
},
{
name: "openai: no usage chunk at all",
typeName: providerTypeOpenAI,
providerName: "openai",
raw: "data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\ndata: [DONE]\n\n",
wantUsage: usage{},
wantModel: "unknown/openai",
},
{
name: "anthropic: message_start + message_delta",
typeName: providerTypeAnthropic,
providerName: "anthropic",
raw: "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-native\",\"usage\":{\"input_tokens\":20,\"cache_creation_input_tokens\":5,\"cache_read_input_tokens\":3,\"output_tokens\":0}}}\n\n" +
"event: message_delta\ndata: {\"delta\":{},\"usage\":{\"output_tokens\":11}}\n\n",
wantUsage: usage{prompt: 28, completion: 11},
wantModel: "claude-native",
},
{
name: "anthropic: message_start only, no message_delta yet",
typeName: providerTypeAnthropic,
providerName: "anthropic",
raw: "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-native\",\"usage\":{\"input_tokens\":9,\"output_tokens\":0}}}\n\n",
wantUsage: usage{prompt: 9, completion: 0},
wantModel: "claude-native",
},
{
name: "gemini: deliberately unhandled, falls back to unknown",
typeName: providerTypeGemini,
providerName: "gemini",
raw: "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"hi\"}]}}]}\n\n",
wantUsage: usage{},
wantModel: "unknown/gemini",
},
{
name: "malformed data ignored",
typeName: providerTypeOpenAI,
providerName: "openai",
raw: "data: not json at all\n\n",
wantUsage: usage{},
wantModel: "unknown/openai",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotUsage, gotModel := extractPassthroughSSEUsage(tt.typeName, tt.providerName, []byte(tt.raw), []byte(tt.raw))
if gotUsage != tt.wantUsage {
t.Errorf("usage = %+v, want %+v", gotUsage, tt.wantUsage)
}
if gotModel != tt.wantModel {
t.Errorf("model = %q, want %q", gotModel, tt.wantModel)
}
})
}
}
// TestHandlePassthrough_SSEStream_OpenAIUsageChunk_Accounted is the
// end-to-end regression for finding 2 (review-routes.md): a native SSE
// passthrough response carrying OpenAI's own final-chunk "usage" object
// is teed, parsed, and billed with the REPORTED usage — not an estimate
// — while the client still receives the stream byte-for-byte unchanged.
func TestHandlePassthrough_SSEStream_OpenAIUsageChunk_Accounted(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
fl, ok := w.(http.Flusher)
if !ok {
t.Fatal("fake upstream ResponseWriter does not support Flush")
}
_, _ = fmt.Fprint(w, "data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\n")
fl.Flush()
_, _ = fmt.Fprint(w, "data: {\"id\":\"c1\",\"choices\":[],\"usage\":{\"prompt_tokens\":42,\"completion_tokens\":9}}\n\n")
fl.Flush()
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
fl.Flush()
}))
defer srv.Close()
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", BaseURL: srv.URL, APIKey: "sk-up"}}
cfg.Groups = map[string]*GroupConfig{"default": {}}
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{}}}}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
gw, ok := h.(*Gateway)
if !ok {
t.Fatal("handler is not *Gateway")
}
gw.limiter.spawn = func(f func()) { f() }
req := httptest.NewRequest(http.MethodPost, "/openai/v1/native-endpoint", strings.NewReader(`{"model":"gpt-native","messages":[]}`))
req.Header.Set("Authorization", "Bearer sk-alice")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "[DONE]") {
t.Errorf("body = %q, want the stream to reach the client unchanged, including [DONE]", rec.Body.String())
}
tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now())
if !ok || tokIn != 42 {
t.Errorf("user tokin/day counter = %d (ok=%v), want 42 (the reported usage, not an estimate)", tokIn, ok)
}
tokOut, ok := gw.limiter.getCounter("user", "alice", metricTokOut, windowDay, time.Now())
if !ok || tokOut != 9 {
t.Errorf("user tokout/day counter = %d (ok=%v), want 9", tokOut, ok)
}
}
// TestHandlePassthrough_SSEStream_AnthropicUsageEvents_Accounted is
// finding 2's Anthropic-shaped counterpart: message_start.message.usage
// (including its prompt-cache counters) plus message_delta.usage.
// output_tokens together are billed, matching extractPassthroughUsage's
// own non-streaming Anthropic accounting exactly.
func TestHandlePassthrough_SSEStream_AnthropicUsageEvents_Accounted(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
fl, ok := w.(http.Flusher)
if !ok {
t.Fatal("fake upstream ResponseWriter does not support Flush")
}
_, _ = fmt.Fprint(w, "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-native\",\"usage\":{\"input_tokens\":20,\"cache_creation_input_tokens\":5,\"cache_read_input_tokens\":3,\"output_tokens\":0}}}\n\n")
fl.Flush()
_, _ = fmt.Fprint(w, "event: content_block_delta\ndata: {\"delta\":{\"text\":\"hi\"}}\n\n")
fl.Flush()
_, _ = fmt.Fprint(w, "event: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":11}}\n\n")
fl.Flush()
}))
defer srv.Close()
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"anthropic": {Type: "anthropic", BaseURL: srv.URL, APIKey: "sk-up"}}
cfg.Groups = map[string]*GroupConfig{"default": {}}
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{}}}}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
gw, ok := h.(*Gateway)
if !ok {
t.Fatal("handler is not *Gateway")
}
gw.limiter.spawn = func(f func()) { f() }
req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(`{"model":"claude-native","messages":[]}`))
req.Header.Set("Authorization", "Bearer sk-alice")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now())
if !ok || tokIn != 28 { // 20 input + 5 cache_creation + 3 cache_read
t.Errorf("user tokin/day counter = %d (ok=%v), want 28 (input + cache tokens folded in)", tokIn, ok)
}
tokOut, ok := gw.limiter.getCounter("user", "alice", metricTokOut, windowDay, time.Now())
if !ok || tokOut != 11 {
t.Errorf("user tokout/day counter = %d (ok=%v), want 11 (from message_delta)", tokOut, ok)
}
}
// TestHandlePassthrough_SSEStream_OverFourMiB_UsageStillAccounted is the
// verify-core round-4 regression test (finding 6): before the head+tail
// sseAccountingBuffer fix, a single front-loaded 4MiB
// cappedAccountingBuffer teed the whole SSE stream, so a stream over
// 4MiB lost its usage event entirely the moment that event landed past
// the cap — exactly what a long completion (well within reach of a
// reasoning model) does. This stream is ~6.8MB, deliberately mirroring
// scratchpad/vcopy/zz_verify2_test.go's TestVerify_PassthroughLongSSE_
// UsageLost repro, with its real usage (prompt=100, completion=25000) on
// the final SSE event — long past both the old 4MiB cap and comfortably
// past the new sseAccountingTailBytes (64KiB) cap too, proving the tail
// window, not just a larger head, is what recovers it.
func TestHandlePassthrough_SSEStream_OverFourMiB_UsageStillAccounted(t *testing.T) {
chunk := "data: {\"id\":\"c1\",\"model\":\"gpt-native\",\"choices\":[{\"delta\":{\"content\":\"" + strings.Repeat("x", 200) + "\"}}]}\n\n"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
fl, ok := w.(http.Flusher)
if !ok {
t.Fatal("fake upstream ResponseWriter does not support Flush")
}
for i := 0; i < 25000; i++ { // ~6.8MB, well over the old 4MiB cap
_, _ = io.WriteString(w, chunk)
}
fl.Flush()
_, _ = fmt.Fprint(w, "data: {\"id\":\"c1\",\"model\":\"gpt-native\",\"choices\":[],\"usage\":{\"prompt_tokens\":100,\"completion_tokens\":25000}}\n\ndata: [DONE]\n\n")
fl.Flush()
}))
defer srv.Close()
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", BaseURL: srv.URL, APIKey: "sk-up"}}
cfg.Groups = map[string]*GroupConfig{"default": {}}
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{}}}}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
gw, ok := h.(*Gateway)
if !ok {
t.Fatal("handler is not *Gateway")
}
gw.limiter.spawn = func(f func()) { f() }
req := httptest.NewRequest(http.MethodPost, "/openai/v1/native-endpoint", strings.NewReader(`{"model":"gpt-native","stream":true,"stream_options":{"include_usage":true},"messages":[]}`))
req.Header.Set("Authorization", "Bearer sk-alice")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now())
if !ok || tokIn != 100 {
t.Errorf("user tokin/day counter = %d (ok=%v), want 100 (the reported usage, not lost past the old 4MiB cap)", tokIn, ok)
}
tokOut, ok := gw.limiter.getCounter("user", "alice", metricTokOut, windowDay, time.Now())
if !ok || tokOut != 25000 {
t.Errorf("user tokout/day counter = %d (ok=%v), want 25000", tokOut, ok)
}
}
// TestHandlePassthrough_SSEStream_NoUsageReported_EstimatedFallback
// proves finding 2's fallback: a clean SSE stream whose provider never
// sent a usage object falls back to the request-body-size estimate,
// instead of being billed zero.
func TestHandlePassthrough_SSEStream_NoUsageReported_EstimatedFallback(t *testing.T) {
const reqBody = `{"model":"gpt-native","messages":[{"role":"user","content":"a body long enough that its estimate is clearly nonzero"}]}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
fl, ok := w.(http.Flusher)
if !ok {
t.Fatal("fake upstream ResponseWriter does not support Flush")
}
_, _ = fmt.Fprint(w, "data: {\"id\":\"c1\",\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\n")
fl.Flush()
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
fl.Flush()
}))
defer srv.Close()
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", BaseURL: srv.URL, APIKey: "sk-up"}}
cfg.Groups = map[string]*GroupConfig{"default": {}}
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{}}}}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
gw, ok := h.(*Gateway)
if !ok {
t.Fatal("handler is not *Gateway")
}
gw.limiter.spawn = func(f func()) { f() }
req := httptest.NewRequest(http.MethodPost, "/openai/v1/native-endpoint", strings.NewReader(reqBody))
req.Header.Set("Authorization", "Bearer sk-alice")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
wantEstimate := int64(math.Ceil(float64(len(reqBody)) / 4))
tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now())
if !ok || tokIn != wantEstimate {
t.Errorf("user tokin/day counter = %d (ok=%v), want %d (estimated from request body size)", tokIn, ok, wantEstimate)
}
}
// TestHandlePassthrough_JSONResponse_AbortedMidCopy_EstimatedFallback
// proves finding 2's other fallback: a non-streaming JSON response that
// aborts mid-copy (the upstream connection resets partway through the
// body) still reaches the client with exactly the partial bytes it
// managed to write, and the request falls back to the request-body-size
// estimate instead of being billed zero because the partial body could
// not be parsed as usage.
func TestHandlePassthrough_JSONResponse_AbortedMidCopy_EstimatedFallback(t *testing.T) {
const reqBody = `{"model":"gpt-native","messages":[{"role":"user","content":"abort mid copy test body"}]}`
const partial = `{"model":"gpt-native","choices":[{"delta"`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fl, ok := w.(http.Flusher)
if !ok {
t.Fatal("fake upstream ResponseWriter does not support Flush")
}
_, _ = w.Write([]byte(partial))
fl.Flush()
hj, ok := w.(http.Hijacker)
if !ok {
t.Fatal("fake upstream ResponseWriter does not support Hijack")
}
conn, _, hjErr := hj.Hijack()
if hjErr == nil {
_ = conn.Close()
}
}))
defer srv.Close()
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", BaseURL: srv.URL, APIKey: "sk-up"}}
cfg.Groups = map[string]*GroupConfig{"default": {}}
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{}}}}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
gw, ok := h.(*Gateway)
if !ok {
t.Fatal("handler is not *Gateway")
}
gw.limiter.spawn = func(f func()) { f() }
req := httptest.NewRequest(http.MethodPost, "/openai/v1/native-endpoint", strings.NewReader(reqBody))
req.Header.Set("Authorization", "Bearer sk-alice")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (headers already committed before the drop)", rec.Code)
}
if rec.Body.String() != partial {
t.Errorf("client body = %q, want exactly the partial bytes written before the drop", rec.Body.String())
}
wantEstimate := int64(math.Ceil(float64(len(reqBody)) / 4))
tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now())
if !ok || tokIn != wantEstimate {
t.Errorf("user tokin/day counter = %d (ok=%v), want %d (estimated: the truncated body could not be parsed)", tokIn, ok, wantEstimate)
}
}
// TestHandlePassthrough_NonStreamJSON_NoUsageField_LargeBody_ZeroAccounted
// is the negative counterpart to the estimate fallback: a COMPLETE 200
// application/json response that simply carries no "usage" object at all
// (the shape of an OpenAI /v1/files upload response, or any other
// non-chat endpoint) must account exactly zero prompt/completion tokens,
// never the request-body-size estimate — the request body here is a
// large-ish 200KB payload specifically so a body-size estimate, if the
// fallback wrongly fired, would be unmistakably nonzero (canEstimate in
// handlePassthrough requires is2xx AND (isSSE || tee.truncated ||
// copyAborted); a complete, untruncated JSON body meets none of those,
// so this must stay a true accounting no-op).
func TestHandlePassthrough_NonStreamJSON_NoUsageField_LargeBody_ZeroAccounted(t *testing.T) {
const respBody = `{"id":"file-abc123","object":"file","bytes":204800,"created_at":1699999999,"filename":"upload.bin","purpose":"fine-tune"}`
reqBody := strings.Repeat("a", 200*1024) // 200KB, large enough that a wrongly-fired estimate would be unmistakably nonzero
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(respBody))
}))
defer srv.Close()
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", BaseURL: srv.URL, APIKey: "sk-up"}}
cfg.Groups = map[string]*GroupConfig{"default": {}}
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{}}}}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
gw, ok := h.(*Gateway)
if !ok {
t.Fatal("handler is not *Gateway")
}
req := httptest.NewRequest(http.MethodPost, "/openai/v1/files", strings.NewReader(reqBody))
req.Header.Set("Authorization", "Bearer sk-alice")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
if rec.Body.String() != respBody {
t.Errorf("body = %q, want verbatim upstream body %q", rec.Body.String(), respBody)
}
tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now())
if !ok || tokIn != 0 {
t.Errorf("user tokin/day counter = %d (ok=%v), want exactly 0 (2xx JSON with no usage field must never estimate)", tokIn, ok)
}
tokOut, ok := gw.limiter.getCounter("user", "alice", metricTokOut, windowDay, time.Now())
if !ok || tokOut != 0 {
t.Errorf("user tokout/day counter = %d (ok=%v), want exactly 0", tokOut, ok)
}
}
// TestHandlePassthrough_UpstreamJSONError429_ZeroAccounted proves a
// non-2xx JSON error body (the upstream billed nothing for a rejected
// request) is never charged the request-body-size estimate either: the
// upstream is not is2xx, so handlePassthrough's canEstimate gate must
// stay false regardless of the fact that the body is JSON with no usage.
func TestHandlePassthrough_UpstreamJSONError429_ZeroAccounted(t *testing.T) {
const reqBody = `{"model":"gpt-native","messages":[{"role":"user","content":"a request that gets rate limited"}]}`
const respBody = `{"error":{"message":"rate limit exceeded","type":"rate_limit_error","code":"rate_limit_exceeded"}}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(respBody))
}))
defer srv.Close()
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", BaseURL: srv.URL, APIKey: "sk-up"}}
cfg.Groups = map[string]*GroupConfig{"default": {}}
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{}}}}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
gw, ok := h.(*Gateway)
if !ok {
t.Fatal("handler is not *Gateway")
}
req := httptest.NewRequest(http.MethodPost, "/openai/v1/native-endpoint", strings.NewReader(reqBody))
req.Header.Set("Authorization", "Bearer sk-alice")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("status = %d, want 429, body=%s", rec.Code, rec.Body.String())
}
if rec.Body.String() != respBody {
t.Errorf("body = %q, want verbatim upstream body %q", rec.Body.String(), respBody)
}
tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now())
if !ok || tokIn != 0 {
t.Errorf("user tokin/day counter = %d (ok=%v), want exactly 0 (a 429 must never be charged the request-size estimate)", tokIn, ok)
}
tokOut, ok := gw.limiter.getCounter("user", "alice", metricTokOut, windowDay, time.Now())
if !ok || tokOut != 0 {
t.Errorf("user tokout/day counter = %d (ok=%v), want exactly 0", tokOut, ok)
}
}
// TestHandlePassthrough_UpstreamNonJSONError500_ZeroAccounted covers the
// OTHER early-return branch in handlePassthrough (result.status == 0 ||
// (!result.isJSON && !result.isSSE)): a 500 whose Content-Type is
// neither application/json nor text/event-stream — a plain-text upstream
// error, which this gateway never parses for usage at all — must also
// account exactly zero tokens, never an estimate. This is deliberately
// NOT an SSE stream (contrast with the SSE-no-usage estimated-fallback
// test), and deliberately NOT the JSON-error shape already covered by
// the 429 test above, so it exercises the isJSON==false && isSSE==false
// short-circuit specifically.
func TestHandlePassthrough_UpstreamNonJSONError500_ZeroAccounted(t *testing.T) {
const reqBody = `{"model":"gpt-native","messages":[{"role":"user","content":"a request that hits a plain-text 500"}]}`
const respBody = "internal server error"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(respBody))
}))
defer srv.Close()
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", BaseURL: srv.URL, APIKey: "sk-up"}}
cfg.Groups = map[string]*GroupConfig{"default": {}}
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{}}}}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
gw, ok := h.(*Gateway)
if !ok {
t.Fatal("handler is not *Gateway")
}
req := httptest.NewRequest(http.MethodPost, "/openai/v1/native-endpoint", strings.NewReader(reqBody))
req.Header.Set("Authorization", "Bearer sk-alice")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500, body=%s", rec.Code, rec.Body.String())
}
if rec.Body.String() != respBody {
t.Errorf("body = %q, want verbatim upstream body %q", rec.Body.String(), respBody)
}
tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now())
if !ok || tokIn != 0 {
t.Errorf("user tokin/day counter = %d (ok=%v), want exactly 0 (non-JSON, non-SSE response must never be accounted)", tokIn, ok)
}
tokOut, ok := gw.limiter.getCounter("user", "alice", metricTokOut, windowDay, time.Now())
if !ok || tokOut != 0 {
t.Errorf("user tokout/day counter = %d (ok=%v), want exactly 0", tokOut, ok)
}
}
// TestHandlePassthrough_UnpricedModelCostBudget_Returns402 covers
// finding 6 (review-routes.md): a passthrough request for a model with
// no configured price is refused (402) exactly like the unified route's
// own F-1 gate, but ONLY when (a) a cost budget actually applies to the
// caller and (b) the model is determinable from the request body at all
// — a binary content type (whose "model" this gateway never even tries
// to read) is let through unpriced, since there is no basis to refuse a
// value the gateway never had.
func TestHandlePassthrough_UnpricedModelCostBudget_Returns402(t *testing.T) {
tests := []struct {
name string
body string
contentType string
costBudget float64
wantCode int
allowUnpricedWithCostBudget bool
wantUpstreamCalled bool
}{
{
name: "unpriced model, cost budget configured: refused",
body: `{"model":"totally-unpriced-model","messages":[]}`,
contentType: "application/json",
costBudget: 5,
wantCode: http.StatusPaymentRequired,
wantUpstreamCalled: false,
},
{
name: "unpriced model, no cost budget: passes",
body: `{"model":"totally-unpriced-model","messages":[]}`,
contentType: "application/json",
costBudget: 0,
wantCode: http.StatusOK,
wantUpstreamCalled: true,
},
{
name: "unpriced model, cost budget configured but AllowUnpricedWithCostBudget set: passes",
body: `{"model":"totally-unpriced-model","messages":[]}`,
contentType: "application/json",
costBudget: 5,
allowUnpricedWithCostBudget: true,
wantCode: http.StatusOK,
wantUpstreamCalled: true,
},
{
name: "priced (builtin) model, cost budget configured: passes",
body: `{"model":"gpt-5","messages":[]}`,
contentType: "application/json",
costBudget: 5,
wantCode: http.StatusOK,
wantUpstreamCalled: true,
},
{
name: "unpriced model, cost budget configured, but model unreadable (binary content type): passes",
body: "raw-audio-bytes-not-json",
contentType: "audio/mpeg",
costBudget: 5,
wantCode: http.StatusOK,
wantUpstreamCalled: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var upstreamCalled bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamCalled = true
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
cfg := CreateConfig()
cfg.AllowUnpricedWithCostBudget = tt.allowUnpricedWithCostBudget
cfg.Providers = map[string]*ProviderConfig{
"openai": {Type: "openai", BaseURL: srv.URL, APIKey: "sk-up"},
}
cfg.Groups = map[string]*GroupConfig{"default": {}}
limits := &LimitsConfig{}
if tt.costBudget > 0 {
limits.CostPerDayUSD = tt.costBudget
}
cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice", Limits: limits}}}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h, err := New(context.Background(), next, cfg, "llmgw")
if err != nil {
t.Fatalf("New: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/openai/v1/native-endpoint", strings.NewReader(tt.body))
req.Header.Set("Authorization", "Bearer sk-alice")
req.Header.Set("Content-Type", tt.contentType)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != tt.wantCode {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, tt.wantCode, rec.Body.String())
}
if upstreamCalled != tt.wantUpstreamCalled {
t.Errorf("upstreamCalled = %v, want %v", upstreamCalled, tt.wantUpstreamCalled)
}
})
}
}
// TestHandlePassthrough_QueryStringForwarded proves the client's query
// string reaches the upstream via RawQuery, unmodified.
func TestHandlePassthrough_QueryStringForwarded(t *testing.T) {
@@ -2414,3 +3228,31 @@ func TestHandlePassthrough_UnrestrictedGroup_PaddedDuplicateModel_NeverPeeked(t
t.Error("upstream body must be forwarded byte-for-byte unchanged for an unrestricted group")
}
}
// TestSSEAccountingBuffer_TailBoundedAndExact: many small writes keep the
// tail buffer under 2x its cap, and tailBytes always returns exactly the
// last sseAccountingTailBytes written.
func TestSSEAccountingBuffer_TailBoundedAndExact(t *testing.T) {
b := &sseAccountingBuffer{}
var all []byte
chunk := make([]byte, 58)
for i := 0; i < 20000; i++ {
for j := range chunk {
chunk[j] = byte((i + j) % 256)
}
if _, err := b.Write(chunk); err != nil {
t.Fatalf("Write: %v", err)
}
all = append(all, chunk...)
if len(b.tail) >= 2*sseAccountingTailBytes {
t.Fatalf("tail grew to %d bytes, want < %d", len(b.tail), 2*sseAccountingTailBytes)
}
}
want := all[len(all)-sseAccountingTailBytes:]
if !bytes.Equal(b.tailBytes(), want) {
t.Fatal("tailBytes() != last sseAccountingTailBytes written")
}
if !bytes.Equal(b.head.Bytes(), all[:sseAccountingHeadBytes]) {
t.Fatal("head != first sseAccountingHeadBytes written")
}
}
+214 -65
View File
@@ -6,10 +6,10 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"strconv"
"strings"
"time"
)
@@ -336,6 +336,19 @@ func (g *Gateway) runMeteredCall(sw *statusTrackingWriter, r *http.Request, scop
pristineReq["model"] = candidates[0].upstreamModel
}
// preFailoverHeaders snapshots sw.Header() ONCE, before the loop below
// ever runs (finding 7 fix, review-routes.md): every header present
// here was staged by something OTHER than this loop — an earlier,
// unrelated Traefik middleware (CORS, HSTS, request-id, ...) running
// before this plugin, on the default, non-failover path — and a
// failover iteration's own header cleanup (below) restores exactly
// this snapshot instead of wiping every header unconditionally, which
// deleted those staged headers too, not just the gateway's own. Clone
// is a real copy (http.Header.Clone's own contract), so later
// mutations of sw.Header() by this loop can never retroactively
// change what gets restored.
preFailoverHeaders := sw.Header().Clone()
var lastErr error
var lastProviderName string
for i, cand := range candidates {
@@ -394,13 +407,30 @@ func (g *Gateway) runMeteredCall(sw *statusTrackingWriter, r *http.Request, scop
// written, and if THAT PRIOR candidate then failed before
// writing (still safe to fail over, THE HARD CONSTRAINT
// below), those staged headers must not survive into a
// plain-JSON response from THIS candidate. Safe to clear
// plain-JSON response from THIS candidate. Safe to run
// unconditionally once i > 0: this loop only ever reaches a
// new iteration while sw.wroteHeader is false, so nothing
// committed is ever at risk of being wiped.
// committed is ever at risk of being touched.
//
// Finding 7 fix (review-routes.md): RESTORE preFailoverHeaders
// rather than delete every key outright — the round-2 fix
// above correctly scoped the wipe to i > 0 only, but still
// deleted EVERY header present at that point, including
// whatever the earlier Traefik middleware staged before this
// plugin ever ran, on the FAILOVER path specifically (the
// round-2 fix's own proof only covered i == 0, the
// non-failover path). Deleting first, then copying the
// snapshot back, leaves exactly the pre-loop set in place —
// any earlier candidate's own staged headers (X-Llmgw-Cache,
// Content-Type, ...) are gone, and anything Traefik itself
// set before this plugin ran survives a failover exactly as
// it already did on the non-failover path.
hdr := sw.Header()
for k := range hdr {
hdr.Del(k)
delete(hdr, k)
}
for k, v := range preFailoverHeaders {
hdr[k] = v
}
}
@@ -510,8 +540,13 @@ func (g *Gateway) runMeteredCall(sw *statusTrackingWriter, r *http.Request, scop
// configured a money budget, so a deployment with none is
// unaffected, and token/request budgets are untouched — those
// accrue correctly for an unpriced model and keep working.
// review-auth finding F1 fix: priceKnown (modelmeta.go) — not a
// bare pricing-overrides/built-in-table lookup — also recognizes
// an operator's own modelMeta entry marking the model explicitly
// Free, so a locally-hosted/free model already declared that way
// is never refused here as though its price were unrecorded.
if !g.cfg.AllowUnpricedWithCostBudget &&
!modelPriceKnown(cand.canonical, cand.upstreamModel, g.cfg.Pricing) &&
!priceKnown(cand.canonical, cand.upstreamModel, g.cfg.Pricing, g.cfg.ModelMeta) &&
scopesHaveCostBudget(scopes) {
g.logf("%s: refusing model %q: it has no configured price, and a cost budget applies to this caller that cannot be enforced without one", logPrefix, cand.canonical)
envelope(sw, http.StatusPaymentRequired, "invalid_request_error",
@@ -547,8 +582,21 @@ func (g *Gateway) runMeteredCall(sw *statusTrackingWriter, r *http.Request, scop
// healthy()), so recording it there was dead work plus a new
// global-mutex contention point (requestHealthTracker.stateFor's
// own t.mu) on EVERY metered request, disabled or not.
//
// requestHealthOutcome (finding 16 fix, review-routes.md) — not a
// bare !failoverEligible(callErr) — decides both WHETHER to
// record at all and, when it does, what outcome: a client cancel
// or a gateway-side translation error is recorded as NEITHER
// success nor failure, since neither says anything about this
// provider's own health, and the previous shape's implicit
// "not eligible for failover" == "success" conflation let a
// provider mixed with impatient or misconfigured clients never
// reach requestBreakerFailureThreshold, so failover never routed
// around it.
if g.failover.enabled {
g.failoverHealth.record(providerName, !failoverEligible(callErr))
if success, ok := requestHealthOutcome(callErr); ok {
g.failoverHealth.record(providerName, success)
}
}
// Usage is accounted before the error branch below runs, not
@@ -562,23 +610,57 @@ func (g *Gateway) runMeteredCall(sw *statusTrackingWriter, r *http.Request, scop
// translateError, or a connection failure before any write)
// always carries zero usage by contract, so accounting it here
// is a no-op — account skips every write once both total tokens
// and cost are zero. routes_messages.go's
// callTranslatedMessages/callAnthropicMessagesPassthrough follow
// the identical contract for their own error paths (item 1 fix,
// 2026-08-22 review). This still runs exactly once per
// CANDIDATE, including a candidate that goes on to fail over —
// its own (zero, by contract) usage is billed as a no-op, never
// and cost are zero. A STREAMING failure with zero reported usage
// is the one exception (finding 1 fix, review-routes.md, the
// switch below): it gets the same body-size estimate a
// non-streaming zero-usage response already did, rather than
// being billed zero purely because the connection dropped or the
// client walked away before the trailing usage chunk arrived.
// routes_messages.go's callTranslatedMessages/
// callAnthropicMessagesPassthrough follow the identical contract
// for their own error paths (item 1 fix, 2026-08-22 review). This
// still runs exactly once per CANDIDATE, including a candidate
// that goes on to fail over — its own usage is billed as a no-op
// only when it is genuinely zero (a non-streaming failure), never
// skipped, so a later successful candidate's real usage is never
// silently doubled with a phantom first entry either.
if callErr == nil && result.total() == 0 {
if streaming {
if result.total() == 0 {
switch {
case streaming && callErr != nil && sw.wroteHeader:
// Finding 1 fix (review-routes.md): a streaming response
// that fails or is canceled before its usage chunk
// arrives — most commonly the client itself disconnecting
// right after the last content chunk, before the
// trailing usage frame — must still be billed something,
// or an attentive client can dodge every token/cost
// budget on every request just by hanging up early once
// it has read what it wanted. Apply the same body-size
// prompt estimate the non-streaming zero-usage branch
// below already uses.
//
// Verify-core fix (round 4): gated on sw.wroteHeader —
// the stream must actually have started reaching the
// client — so a pre-send failure (providerHTTPError,
// translateError, a dial failure, or errRequestBuildFailed,
// none of which ever wrote a byte to the client) is never
// estimated here. Those fall through to the "streaming"
// case below with no estimate, exactly like a non-
// streaming failure. A candidate that fails over after
// writing nothing must cost the SAME zero as one that
// never streamed at all, or a failover pair is double-
// billed: the failed candidate's phantom estimate plus
// the next candidate's real usage.
result.prompt = int64(math.Ceil(float64(len(body)) / 4))
result.estimated = true
g.logf("%s: streaming response from model %q failed or was canceled after starting to write to the client with zero usage reported; accounting an estimate derived from request body size", logPrefix, cand.canonical)
case streaming:
g.logf("%s: zero usage reported for a streaming response from model %q; accounting the request only", logPrefix, cand.canonical)
} else {
case callErr == nil:
result.prompt = int64(math.Ceil(float64(len(body)) / 4))
result.estimated = true
}
}
g.limiter.account(withModelScope(scopes, cand.canonical), result, unifiedCostMicros(cand.canonical, cand.upstreamModel, result, g.cfg.Pricing))
g.limiter.account(withModelScope(scopes, cand.canonical), result, unifiedCostMicros(cand.canonical, cand.upstreamModel, result, g.cfg.Pricing, g.cfg.ModelMeta, g.pricingWarn))
if result.estimated {
g.logf("%s: usage for model %q logged as estimated (%d prompt tokens derived from request body size, not the provider's reported usage)", logPrefix, cand.canonical, result.prompt)
}
@@ -857,17 +939,28 @@ func (g *Gateway) acquireBodyAdmission(sw *statusTrackingWriter, envelope envelo
// readAndDecodeUnifiedBody claims a body-admission slot (acquireBodyAdmission),
// reads r's body capped at maxUnifiedRequestBytes, decodes it as a JSON
// object, and releases the slot before returning — on every path: a
// successful decode, a read failure, a decode failure, or admission
// exhaustion itself (security review finding 1b, round 3, 2026-08-22).
// This is deliberately the ENTIRE scope of what the semaphore guards for
// the unified/messages routes: by the time this returns, req is fully
// decoded and nothing further re-amplifies memory the way the decode
// itself does, so model resolution, the cache lookup, and the upstream
// round trip all run after the slot is already released. A read or
// decode failure has already written its 400 to sw, through envelope
// (item 7 fix, 2026-08-22 review — routes_messages.go passes
// writeAnthropicError here so this shared step answers in this route's
// own error shape); ok reports whether the caller may proceed.
// successful decode, a read failure, an oversize body, a decode failure,
// or admission exhaustion itself (security review finding 1b, round 3,
// 2026-08-22). This is deliberately the ENTIRE scope of what the
// semaphore guards for the unified/messages routes: by the time this
// returns, req is fully decoded and nothing further re-amplifies memory
// the way the decode itself does, so model resolution, the cache lookup,
// and the upstream round trip all run after the slot is already
// released. A read failure or a decode failure has already written its
// 400 to sw, through envelope (item 7 fix, 2026-08-22 review — routes_
// messages.go passes writeAnthropicError here so this shared step
// answers in this route's own error shape); ok reports whether the
// caller may proceed.
//
// Finding 14 fix (review-routes.md): readCapped (routes_media.go),
// already used by handleAudioTranscriptions to distinguish "the body was
// larger than the cap" from "the body was valid JSON, just malformed",
// replaces the plain io.LimitReader-and-decode this used before —
// io.LimitReader alone silently truncates an oversized body mid-token,
// so a request that was genuinely just too big (a 4.5MiB inline image
// against the 4MiB maxUnifiedRequestBytes cap) got the misleading
// "invalid JSON body" 400 instead of 413, giving the caller no way to
// tell "your body is too large" apart from "your JSON is malformed".
func (g *Gateway) readAndDecodeUnifiedBody(sw *statusTrackingWriter, r *http.Request, envelope envelopeWriter) (body []byte, req map[string]any, ok bool) {
release, admitted := g.acquireBodyAdmission(sw, envelope)
defer release()
@@ -875,11 +968,15 @@ func (g *Gateway) readAndDecodeUnifiedBody(sw *statusTrackingWriter, r *http.Req
return nil, nil, false
}
body, err := io.ReadAll(io.LimitReader(r.Body, maxUnifiedRequestBytes))
body, oversize, err := readCapped(r.Body, maxUnifiedRequestBytes)
if err != nil {
envelope(sw, http.StatusBadRequest, "invalid_request_error", "cannot read request body")
return nil, nil, false
}
if oversize {
envelope(sw, http.StatusRequestEntityTooLarge, "invalid_request_error", "request body too large")
return nil, nil, false
}
if err = json.Unmarshal(body, &req); err != nil {
envelope(sw, http.StatusBadRequest, "invalid_request_error", "invalid JSON body")
@@ -891,44 +988,38 @@ func (g *Gateway) readAndDecodeUnifiedBody(sw *statusTrackingWriter, r *http.Req
// unifiedCostMicros resolves the price to charge one request's usage
// against: canonical ("provider/model") first, falling back to bare — the
// upstream model id alone — only when canonical has no configured price at
// all, in either overrides or the built-in table. This extends
// costMicros' single-id lookup to the two-id order ruling (d) requires,
// without changing costMicros' own signature: lookupPricing is used purely
// as a side-effect-free existence probe on canonical, and the real
// all, in either overrides or the built-in table. lookupPricing is used
// purely as a side-effect-free existence probe on canonical, and the real
// computation (and its unknown-model warning, when neither id has a price)
// runs through the one costMicros call that is actually charged.
func unifiedCostMicros(canonical, bare string, u usage, overrides map[string]*ModelPricing) int64 {
cost, _ := unifiedCostMicrosKnown(canonical, bare, u, overrides)
// runs through the one costMicrosKnownFor call that is actually charged.
//
// meta is checked FIRST, via modelMetaFree (modelmeta.go) — the exact
// same two-key lookup priceKnown itself uses to decide the 402 cost-
// budget guard — so a model an operator declared modelMeta:{free:true}
// bills 0 and never falls through to a real price it happens to share a
// bare id with in the built-in LiteLLM table (verify-core fix, round 4:
// before this, priceKnown treated such a model as priced, but billing
// never consulted modelMeta at all and charged the table's real price
// for it anyway — the 402 guard and billing disagreeing about the same
// model). This is the one exception to Pricing/ModelMeta's normal
// layering (Config.ModelMeta's own doc comment, llmgateway.go): every
// OTHER modelMeta field still only drives metadata exposure, never cost.
//
// warn is the caller's own per-instance pricing-warning sink (every call
// site passes g.pricingWarn, logger.go), so an unknown-model warning logs
// under this Gateway instance's own `llmgw[name]` prefix.
func unifiedCostMicros(canonical, bare string, u usage, overrides map[string]*ModelPricing, meta map[string]*ModelMetaConfig, warn func(string)) int64 {
if modelMetaFree(canonical, bare, meta) {
return 0
}
if _, ok := lookupPricing(canonical, overrides); ok {
cost, _ := costMicrosKnownFor(canonical, u, overrides, warn)
return cost
}
cost, _ := costMicrosKnownFor(bare, u, overrides, warn)
return cost
}
// unifiedCostMicrosKnown is unifiedCostMicros plus whether EITHER id
// actually had a price (security audit run-1, finding F-1). The bool is
// false only when neither canonical nor bare is priced — exactly the case
// where the returned 0 means "cannot be priced" rather than "free", and
// therefore the case a spend control must not silently treat as zero
// spend. See costMicrosKnown (pricing.go) for the full reasoning.
func unifiedCostMicrosKnown(canonical, bare string, u usage, overrides map[string]*ModelPricing) (int64, bool) {
if _, ok := lookupPricing(canonical, overrides); ok {
return costMicrosKnown(canonical, u, overrides)
}
return costMicrosKnown(bare, u, overrides)
}
// modelPriceKnown reports whether EITHER id this request could be billed
// under has a configured price, using unifiedCostMicrosKnown's own
// canonical-then-bare order. It is a pure existence probe: unlike
// costMicrosKnown it never fires the unknown-model warning, so the
// pre-flight check in runMeteredCall does not double-warn for a request
// that is about to be accounted (and warned about) anyway.
func modelPriceKnown(canonical, bare string, overrides map[string]*ModelPricing) bool {
if _, ok := lookupPricing(canonical, overrides); ok {
return true
}
_, ok := lookupPricing(bare, overrides)
return ok
}
// writeModelResolveErrorEnvelope maps a modelRegistry.resolve error to
// its HTTP status/message, written through whichever envelope the caller
// supplies (item 7 fix, 2026-08-22 review): errModelUnknown is a 404 (no
@@ -991,14 +1082,65 @@ func writeLimitViolation(w http.ResponseWriter, v *limitViolation) {
writeLimitViolationEnvelope(w, v, writeOAIError)
}
// midStreamErrorMessage derives the client-safe (message, errType) pair
// an OpenAI-style `data: {"error":{...}}` SSE event carries when a
// stream fails after headers were already sent (finding 15 fix, review-
// routes.md) — the SAME classification handleAdapterErrorEnvelope's own
// type-switch below applies for the not-yet-committed case, reused here
// for TEXT only, since no HTTP status can be sent once headers are
// already on the wire: providerHTTPError's message and errType match
// writeProviderUpstreamError's own "providerName upstream error"/
// "upstream_error" (its upstream body is not re-embedded here — this is
// a best-effort terminal marker on an already-partially-delivered
// stream, not a second full error envelope); translateError reuses its
// own terr.msg under "invalid_request_error", exactly as the
// non-streamed envelope(...) call for it does; responseTranslationError
// and every other error share the identical "server_error" text the
// non-streamed generic branches below already use. ok is false only for
// context.Canceled — the client is gone, and the non-streamed branch
// below already treats that as log-only with no envelope either, so
// there is nothing useful to write to a connection nobody is reading.
func midStreamErrorMessage(err error, providerName string) (msg, errType string, ok bool) {
if errors.Is(err, context.Canceled) {
return "", "", false
}
if _, isPerr := err.(*providerHTTPError); isPerr {
return providerName + " upstream error", "upstream_error", true
}
if terr, isTerr := err.(*translateError); isTerr {
return terr.msg, "invalid_request_error", true
}
if _, isRTerr := err.(*responseTranslationError); isRTerr {
return "failed to translate provider response", "server_error", true
}
return "upstream connection error", "server_error", true
}
// handleAdapterErrorEnvelope is the shared adapter-error classification
// every metered route goes through (item 7 fix, 2026-08-22 review):
// sw.wroteHeader is checked first, ahead of every other case, since an
// error surfacing after the adapter already started writing a response
// (a mid-stream connection drop) must never get a second, conflicting
// envelope appended, whatever kind of error it is. envelope/writeUpstream
// pick the client-facing shape; logPrefix names the route in every log
// line.
// STATUS-CARRYING envelope appended, whatever kind of error it is.
// envelope/writeUpstream pick the client-facing shape; logPrefix names
// the route in every log line.
//
// Finding 15 fix (review-routes.md): when that already-started response
// was itself an SSE stream (Content-Type: text/event-stream, set by
// newSSEWriter before the first byte and still readable off sw.Header()
// here even though WriteHeader has already run), a mid-stream failure
// now emits one OpenAI-style `data: {"error":{...}}` event — and
// deliberately never a trailing "data: [DONE]\n\n" afterward, since this
// is not a clean completion — before returning. Every provider adapter's
// own forwardStream (provider_openai.go/provider_anthropic.go/
// provider_gemini.go) writes [DONE] only once its OWN upstream read loop
// finishes without error, so a dropped connection previously left the
// client's stream simply ending with no terminal signal at all: an SDK
// that does not strictly require [DONE] returned a silently truncated
// completion as an apparent success. A non-streaming response that wrote
// a partial body before failing gets no such event — there is no
// text/event-stream framing to append an event onto, and doing so would
// only corrupt an already-partial JSON body further.
func (g *Gateway) handleAdapterErrorEnvelope(sw *statusTrackingWriter, err error, providerName, logPrefix string, envelope envelopeWriter, writeUpstream providerUpstreamErrorWriter) {
// A cacheable request's miss path pre-sets X-Llmgw-Cache: miss before
// call() runs (runMeteredCall), so headers precede a successful body —
@@ -1012,6 +1154,13 @@ func (g *Gateway) handleAdapterErrorEnvelope(sw *statusTrackingWriter, err error
if sw.wroteHeader {
g.errorf("%s: adapter error after response started (provider %q): %v", logPrefix, providerName, err)
if msg, errType, ok := midStreamErrorMessage(err, providerName); ok &&
strings.Contains(strings.ToLower(sw.Header().Get("Content-Type")), "text/event-stream") {
errBody, _ := json.Marshal(map[string]any{ // json.Marshal on a literal map[string]any of strings never fails
"error": map[string]any{"message": msg, "type": errType},
})
_ = newSSEWriter(sw).writeData(errBody) // best-effort: the connection may already be gone
}
return
}
+115 -23
View File
@@ -72,14 +72,6 @@ type alwaysErrStore struct{}
var errStoreDownStub = errors.New("stub: store down")
func (alwaysErrStore) incrBy(string, int64, time.Duration) (int64, error) {
return 0, errStoreDownStub
}
func (alwaysErrStore) get(string) (int64, error) {
return 0, errStoreDownStub
}
func (alwaysErrStore) getMulti([]string) ([]int64, error) {
return nil, errStoreDownStub
}
@@ -1107,11 +1099,18 @@ func TestHandleChat_UpstreamNon2xx_WrapsProviderErrorEnvelope(t *testing.T) {
}
}
// TestHandleChat_MidStreamUpstreamDrop_NoTrailingEnvelope proves an
// adapter error arriving after the response has already started streaming
// gets logged, not turned into a second, conflicting envelope appended to
// a body the client already started receiving (ruling c).
func TestHandleChat_MidStreamUpstreamDrop_NoTrailingEnvelope(t *testing.T) {
// TestHandleChat_MidStreamUpstreamDrop_EmitsErrorEventNoDone proves an
// adapter error arriving after an SSE response has already started
// streaming is logged AND turned into exactly one trailing OpenAI-style
// `data: {"error":{...}}` event (finding 15 fix, review-routes.md —
// supersedes this test's own former "no trailing envelope" ruling c: a
// client stream that just ends, with no terminal signal at all, is
// indistinguishable from a silently truncated success to any SDK that
// does not strictly require "data: [DONE]"). It is never a second,
// conflicting HTTP status/envelope — no second WriteHeader is possible
// once headers are already committed — and it is never followed by
// "data: [DONE]\n\n", since this is not a clean completion.
func TestHandleChat_MidStreamUpstreamDrop_EmitsErrorEventNoDone(t *testing.T) {
const firstChunk = "data: {\"id\":\"c1\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\n"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -1167,14 +1166,90 @@ func TestHandleChat_MidStreamUpstreamDrop_NoTrailingEnvelope(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (headers already committed before the drop)", rec.Code)
}
if got := rec.Body.String(); got != firstChunk {
t.Errorf("body = %q, want exactly the one chunk written before the drop (no trailing error envelope)", got)
const wantErrEvent = "data: {\"error\":{\"message\":\"upstream connection error\",\"type\":\"server_error\"}}\n\n"
if got := rec.Body.String(); got != firstChunk+wantErrEvent {
t.Errorf("body = %q, want the first chunk plus one trailing error event %q", got, wantErrEvent)
}
if strings.Contains(rec.Body.String(), "[DONE]") {
t.Errorf("body = %q, must not contain [DONE] — a mid-stream failure is not a clean completion", rec.Body.String())
}
if !strings.Contains(logBuf.String(), "llmgw[llmgw] ERROR") {
t.Errorf("want the mid-stream error logged, got %q", logBuf.String())
}
}
// TestMidStreamErrorMessage covers every branch of midStreamErrorMessage
// (finding 15 fix, review-routes.md): the message/type pair handed to a
// mid-stream SSE error event mirrors exactly what the equivalent
// non-streamed envelope(...) call would have shown for the same error
// type, and a context.Canceled produces ok=false — nothing to write to a
// client that is already gone.
func TestMidStreamErrorMessage(t *testing.T) {
tests := []struct {
err error
name string
wantMsg string
wantErrType string
wantOK bool
}{
{
name: "providerHTTPError",
err: &providerHTTPError{status: 503, body: []byte(`{"error":"down"}`)},
wantMsg: "openai upstream error",
wantErrType: "upstream_error",
wantOK: true,
},
{
name: "translateError",
err: &translateError{msg: "n>1 is not supported"},
wantMsg: "n>1 is not supported",
wantErrType: "invalid_request_error",
wantOK: true,
},
{
name: "responseTranslationError",
err: &responseTranslationError{err: errors.New("boom")},
wantMsg: "failed to translate provider response",
wantErrType: "server_error",
wantOK: true,
},
{
name: "generic upstream error",
err: fmt.Errorf("%w: connection reset", errUpstream),
wantMsg: "upstream connection error",
wantErrType: "server_error",
wantOK: true,
},
{
name: "context.Canceled: nothing to write",
err: context.Canceled,
wantOK: false,
},
{
name: "wrapped context.Canceled: nothing to write",
err: fmt.Errorf("%w: %w", errUpstream, context.Canceled),
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msg, errType, ok := midStreamErrorMessage(tt.err, "openai")
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
}
if !ok {
return
}
if msg != tt.wantMsg {
t.Errorf("msg = %q, want %q", msg, tt.wantMsg)
}
if errType != tt.wantErrType {
t.Errorf("errType = %q, want %q", errType, tt.wantErrType)
}
})
}
}
// TestHandleChat_MidStreamDropAfterUsageChunk_AccountsPartialUsage is the
// regression test for the critical accounting gap: an adapter that
// captures a usage frame before the connection drops must still have that
@@ -2130,6 +2205,7 @@ func TestUnifiedCostMicros(t *testing.T) {
cases := []struct {
overrides map[string]*ModelPricing
meta map[string]*ModelMetaConfig
name string
canonical string
bare string
@@ -2160,10 +2236,24 @@ func TestUnifiedCostMicros(t *testing.T) {
overrides: nil,
want: 0,
},
{
// verify-core round-4 regression: gpt-oss-120b is a real bare
// id in the generated builtinModelMetaTable (a common self-
// hosted model name). An operator declaring it free:true under
// a self-hosted canonical id must bill 0, never the table's
// real price — priceKnown already treats this model as priced
// (modelMetaFree, modelmeta.go); billing must agree.
name: "modelMeta free:true bills zero even though bare id has a built-in table price",
canonical: "gx10/gpt-oss-120b",
bare: "gpt-oss-120b",
overrides: nil,
meta: map[string]*ModelMetaConfig{"gx10/gpt-oss-120b": {Free: true}},
want: 0,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
assert.Equal(t, c.want, unifiedCostMicros(c.canonical, c.bare, u, c.overrides))
assert.Equal(t, c.want, unifiedCostMicros(c.canonical, c.bare, u, c.overrides, c.meta, func(string) {}))
})
}
}
@@ -2543,9 +2633,11 @@ func TestHandleChat_ZeroConfig_ConcurrentAboveDefaultCap_NoThrottling_SlotsRelea
// unified routes now enforce maxUnifiedRequestBytes (4MiB), narrower than
// the general maxRequestBytes (10MiB) media/MCP-federation routes still
// use (finding 1c): a body whose valid JSON only closes past 4MiB is
// silently truncated by the LimitReader and fails to decode, exactly the
// existing "invalid JSON body" contract an oversized body has always hit
// — no new status code, just a lower threshold.
// caught by readCapped and reported as 413, not the misleading "invalid
// JSON body" 400 an oversized body used to get (finding 14 fix, review-
// routes.md — see readAndDecodeUnifiedBody's own doc comment): a caller
// whose body is genuinely too large has no way to tell that apart from a
// genuinely malformed one under the old status code.
func TestHandleChat_OversizedBody_TruncatedByNarrowerUnifiedCap(t *testing.T) {
cfg := CreateConfig()
cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k", Models: []string{"gpt-test"}}}
@@ -2557,8 +2649,8 @@ func TestHandleChat_OversizedBody_TruncatedByNarrowerUnifiedCap(t *testing.T) {
t.Fatalf("New: %v", err)
}
// A padding field alone bigger than maxUnifiedRequestBytes, so the
// LimitReader cap lands before the object closes.
// A padding field alone bigger than maxUnifiedRequestBytes, so
// readCapped's own cap lands before the object closes.
padding := strings.Repeat("A", maxUnifiedRequestBytes+1024)
body := `{"model":"gpt-test","padding":"` + padding + `","messages":[]}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
@@ -2567,8 +2659,8 @@ func TestHandleChat_OversizedBody_TruncatedByNarrowerUnifiedCap(t *testing.T) {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body silently truncated, invalid JSON), body=%s", rec.Code, rec.Body.String())
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want 413 (oversize body reported explicitly, not misreported as invalid JSON), body=%s", rec.Code, rec.Body.String())
}
}
+93 -10
View File
@@ -64,13 +64,58 @@ type sseEvent struct {
data []byte
}
// scanSSELines is readSSE's bufio.Scanner split function (finding 3 fix,
// review-routes.md): it recognizes every line terminator the WHATWG
// EventSource specification does — CRLF, a lone LF, and a lone CR — where
// bufio.ScanLines (the previous, default split function; in regexp
// notation, `\r?\n`) recognizes only CRLF and a lone LF. A lone "\r"
// previously stayed embedded in a line's own content instead of ending
// it, so an upstream sending CR-only line endings (or a malicious one
// exploiting exactly this gap) parsed as one giant line instead of the
// several the specification requires (https://html.spec.whatwg.org/
// multipage/server-sent-events.html#event-stream-interpretation).
// Structured like bufio.ScanLines itself: find the next terminator, or
// ask for more data:
// - data[i] == '\n': an ordinary (or CRLF-preceded, since IndexAny
// finds whichever of \r/\n comes first — a lone '\r' immediately
// before is handled by its own branch below, not reached here) LF
// terminator.
// - data[i] == '\r' as the LAST currently-buffered byte, with more
// input possibly still to come (!atEOF): request more data first, so
// a CRLF pair split across two Read calls is never misread as a lone
// CR followed by a separate, spurious empty line.
// - data[i] == '\r' followed by '\n': CRLF, one terminator, not two.
// - data[i] == '\r' otherwise: a lone CR, its own terminator.
func scanSSELines(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := bytes.IndexAny(data, "\r\n"); i >= 0 {
if data[i] == '\n' {
return i + 1, data[:i], nil
}
if i+1 == len(data) && !atEOF {
return 0, nil, nil
}
if i+1 < len(data) && data[i+1] == '\n' {
return i + 2, data[:i], nil
}
return i + 1, data[:i], nil
}
if atEOF {
return len(data), data, nil
}
return 0, nil, nil
}
// readSSE parses r as a text/event-stream body and calls fn once per
// event. It accumulates "event:" and "data:" field lines. Multiple
// "data:" lines join with "\n". It dispatches fn on a blank line, then
// resets the accumulated fields. Comment lines that start with ":", and
// "id:"/"retry:" fields, are recognized and silently ignored. Line
// endings can be "\n" or "\r\n": bufio.ScanLines, the scanner's split
// function, strips an optional trailing "\r" itself. A leading UTF-8
// endings can be "\n", "\r\n", or a lone "\r" (scanSSELines, above,
// replaces bufio.ScanLines as the scanner's split function so all three
// are recognized — finding 3 fix, review-routes.md). A leading UTF-8
// byte-order mark on the stream's first line is stripped before field
// parsing.
//
@@ -89,6 +134,7 @@ type sseEvent struct {
func readSSE(r io.Reader, fn func(sseEvent) error) error {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, sseScannerInitialBufSize), sseScannerMaxLineSize)
scanner.Split(scanSSELines)
var (
event string
@@ -212,16 +258,39 @@ func newSSEWriter(w http.ResponseWriter) *sseWriter {
return &sseWriter{w: w, f: f}
}
// splitSSELines splits b on every line terminator scanSSELines (above)
// recognizes on the read side — CRLF, a lone LF, and a lone CR — kept in
// lockstep so this gateway's own OUTPUT can never itself carry the exact
// injection scanSSELines exists to catch on input (finding 3 fix,
// review-routes.md): the terminator consumed by each split is dropped
// from the returned line, never left embedded in it.
func splitSSELines(b []byte) [][]byte {
var lines [][]byte
for {
i := bytes.IndexAny(b, "\r\n")
if i < 0 {
return append(lines, b)
}
lines = append(lines, b[:i])
if b[i] == '\r' && i+1 < len(b) && b[i+1] == '\n' {
i++
}
b = b[i+1:]
}
}
// writeData writes one SSE data event as a single Write call, flushing
// when the underlying writer supports that.
//
// A newline inside b is now ENCODED rather than emitted raw: b is split
// on "\n" and each line gets its own "data: " prefix, which is exactly
// how the text/event-stream format represents a multi-line payload, and
// is what a conforming client rejoins with "\n" on the other side. For
// single-line b — every caller that passes compact JSON, which is still
// what callers should pass — the bytes written are byte-for-byte
// identical to before.
// Every line terminator inside b is now ENCODED rather than emitted raw:
// b is split on every terminator scanSSELines recognizes — CRLF, a lone
// LF, and a lone CR (splitSSELines, below; finding 3 fix, review-
// routes.md) — and each resulting line gets its own "data: " prefix,
// which is exactly how the text/event-stream format represents a
// multi-line payload, and is what a conforming client rejoins with "\n"
// on the other side. For single-line b — every caller that passes
// compact JSON, which is still what callers should pass — the bytes
// written are byte-for-byte identical to before.
//
// Security audit run-1: this function previously DOCUMENTED that b must
// not contain a newline and enforced nothing, while readSSE on the other
@@ -231,8 +300,22 @@ func newSSEWriter(w http.ResponseWriter) *sseWriter {
// event — carrying its own event:/id:/retry: fields, which this gateway
// otherwise strips — into the client's stream. Encoding the newline
// closes that by construction, with no validation branch to get wrong.
//
// Finding 3 fix (review-routes.md): the ORIGINAL version of this
// encoding split on "\n" alone (bytes.Split(b, []byte{'\n'})), which left
// a raw, embedded "\r" untouched inside whatever "line" it fell in — and
// a spec-conforming EventSource client treats a lone CR as its own line
// terminator, so that raw "\r" let an upstream (relayed here verbatim by
// forwardStream) smuggle an attacker-chosen "event:"/"data:" pair past
// this gateway's own readSSE (which, before its own finding-3 fix,
// parsed CR-only sequences as ordinary line content) and have the
// DOWNSTREAM client parse it as a second, spoofed event — the exact
// injection this function's doc comment above already claimed was
// "closed by construction". splitSSELines closes the other half: no raw
// "\r" of any kind ever reaches the wire from here, regardless of what
// readSSE upstream of this call did or did not already split it on.
func (s *sseWriter) writeData(b []byte) error {
lines := bytes.Split(b, []byte{'\n'})
lines := splitSSELines(b)
buf := make([]byte, 0, len(b)+len(lines)*len(sseDataPrefix)+1)
for _, line := range lines {
buf = append(buf, sseDataPrefix...)
+162
View File
@@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"errors"
"io"
"net/http"
"reflect"
"strings"
@@ -253,6 +254,167 @@ func TestReadSSE_StripsLeadingBOM(t *testing.T) {
}
}
// TestReadSSE_LoneCRLineEndings proves a stream using ONLY "\r" as its
// line terminator (finding 3, review-routes.md) is parsed into the same
// events a "\n"-terminated stream would produce. bufio.ScanLines, the
// previous split function, left a lone "\r" embedded in the line's own
// content instead of ending it, so a CR-only stream parsed as one giant
// line instead of the two events here.
func TestReadSSE_LoneCRLineEndings(t *testing.T) {
raw := "event: message\rdata: first\r\rdata: second\r\r"
var got []sseEvent
err := readSSE(strings.NewReader(raw), func(ev sseEvent) error {
got = append(got, ev)
return nil
})
if err != nil {
t.Fatalf("readSSE: %v", err)
}
want := []sseEvent{
{event: "message", data: []byte("first")},
{event: "", data: []byte("second")},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %#v, want %#v", got, want)
}
}
// TestReadSSE_MixedLineTerminators proves CRLF, a lone LF, and a lone CR
// are all recognized as terminators within the SAME stream, matching the
// WHATWG EventSource specification's three-way definition.
func TestReadSSE_MixedLineTerminators(t *testing.T) {
raw := "data: crlf\r\ndata: lf\ndata: cr\r\n\n"
var got []sseEvent
err := readSSE(strings.NewReader(raw), func(ev sseEvent) error {
got = append(got, ev)
return nil
})
if err != nil {
t.Fatalf("readSSE: %v", err)
}
want := []sseEvent{{event: "", data: []byte("crlf\nlf\ncr")}}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %#v, want %#v", got, want)
}
}
// oneByteReader returns exactly one byte per Read call, forcing
// bufio.Scanner to invoke scanSSELines with a buffer that can end
// mid-terminator — the shape TestReadSSE_CRLFSplitAcrossReads needs to
// prove a CRLF pair split across two underlying Read calls is never
// misread as a lone CR followed by a spurious empty line.
type oneByteReader struct{ s string }
func (r *oneByteReader) Read(p []byte) (int, error) {
if len(r.s) == 0 {
return 0, io.EOF
}
p[0] = r.s[0]
r.s = r.s[1:]
return 1, nil
}
// TestReadSSE_CRLFSplitAcrossReads proves scanSSELines' own "wait for
// more data before deciding" branch: when a CRLF pair straddles two
// separate underlying Read calls (the "\r" arrives alone, with the "\n"
// only available on the NEXT Read), it must still be recognized as one
// CRLF terminator, not a lone CR followed by an extra, spurious empty
// data line.
func TestReadSSE_CRLFSplitAcrossReads(t *testing.T) {
raw := "data: first\r\ndata: second\r\n\r\n"
var got []sseEvent
err := readSSE(&oneByteReader{s: raw}, func(ev sseEvent) error {
got = append(got, ev)
return nil
})
if err != nil {
t.Fatalf("readSSE: %v", err)
}
want := []sseEvent{{event: "", data: []byte("first\nsecond")}}
if !reflect.DeepEqual(got, want) {
t.Errorf("got %#v, want %#v", got, want)
}
}
// TestSSEWriter_WriteData_EncodesLoneCR proves a payload containing a raw
// "\r" — including the exact upstream shape finding 3 (review-routes.md)
// named, `{"a":1}\r\revent: evil\rdata: injected` — is re-encoded as a
// sequence of clean "data: "-prefixed lines joined by "\n" alone, with no
// raw "\r" reaching the wire: a spec-conforming EventSource client treats
// a lone "\r" as its own line terminator, so any "\r" left unencoded here
// could be reinterpreted by that client as extra, attacker-chosen
// "event:"/"data:" lines this gateway never intended to send.
func TestSSEWriter_WriteData_EncodesLoneCR(t *testing.T) {
w := newRecordingResponseWriter()
s := newSSEWriter(w)
if err := s.writeData([]byte("{\"a\":1}\r\revent: evil\rdata: injected")); err != nil {
t.Fatalf("writeData: %v", err)
}
got := w.body.String()
if strings.Contains(got, "\r") {
t.Fatalf("body = %q, must not contain a raw carriage return", got)
}
want := "data: {\"a\":1}\ndata: \ndata: event: evil\ndata: data: injected\n\n"
if got != want {
t.Errorf("body = %q, want %q", got, want)
}
}
// TestSSEWriter_WriteData_CRLFCollapsesToOneTerminator proves a CRLF pair
// inside a payload produces exactly one "data:" line, not two (with a
// spurious empty line between them) — CRLF is one terminator, never two.
func TestSSEWriter_WriteData_CRLFCollapsesToOneTerminator(t *testing.T) {
w := newRecordingResponseWriter()
s := newSSEWriter(w)
if err := s.writeData([]byte("line one\r\nline two")); err != nil {
t.Fatalf("writeData: %v", err)
}
want := "data: line one\ndata: line two\n\n"
if got := w.body.String(); got != want {
t.Errorf("body = %q, want %q", got, want)
}
}
// TestReadSSE_WriteData_Roundtrip_NoRawCRInjectionSurvives is the
// end-to-end regression for finding 3 (review-routes.md): the exact
// malicious upstream shape from the review's own probe —
// `data: {"a":1}\r\revent: evil\rdata: injected\n\n` — must neither
// collapse into one giant misread line on the read side, nor leak a raw
// "\r" back out on the write side, once both readSSE (scanSSELines) and
// writeData (splitSSELines) apply their own finding-3 fixes.
// openaiAdapter.forwardStream is exactly this readSSE-then-writeData
// pipeline in production.
func TestReadSSE_WriteData_Roundtrip_NoRawCRInjectionSurvives(t *testing.T) {
raw := "data: {\"a\":1}\r\revent: evil\rdata: injected\n\n"
w := newRecordingResponseWriter()
s := newSSEWriter(w)
var events int
err := readSSE(strings.NewReader(raw), func(ev sseEvent) error {
events++
return s.writeData(ev.data)
})
if err != nil {
t.Fatalf("readSSE: %v", err)
}
if events != 2 {
t.Fatalf("events = %d, want 2 (readSSE must split the CR-terminated fields into two events, not one giant misread line)", events)
}
if got := w.body.String(); strings.Contains(got, "\r") {
t.Errorf("wire body = %q, must not contain a raw carriage return", got)
}
}
// TestNewSSEWriter_SetsHeaders proves newSSEWriter sets the three SSE
// response headers on construction, before any body write.
func TestNewSSEWriter_SetsHeaders(t *testing.T) {
+6 -17
View File
@@ -216,24 +216,13 @@ func sanitizeTargetErr(msg, rawURL string) string {
return sanitizeProviderErr(msg, rawURL)
}
// stateOf reports (kind, name)'s current targetHealthState — see this
// type's own doc comment for the exact unknown/healthy/unhealthy rule.
func (t *targetHealthTracker) stateOf(kind, name string) targetHealthState {
if t == nil {
return targetHealthUnknown
}
t.mu.Lock()
defer t.mu.Unlock()
e, ok := t.entries[targetHealthKey{kind: kind, name: name}]
if !ok {
return targetHealthUnknown
}
return t.stateForLocked(e)
}
// stateForLocked derives e's targetHealthState. Caller must hold t.mu.
// Split out of stateOf/snapshot so both apply the identical rule. The
// threshold check runs first and always wins (F4): a target that has
// Split out so every reader applies the identical rule: production reads
// go through snapshot (below), and the test-only stateOf helper (finding
// 18 fix, review-routes.md — moved to target_health_test.go, since it
// has zero production call sites; snapshot already covers every real
// reader) shares this same derivation rather than a second copy of it.
// The threshold check runs first and always wins (F4): a target that has
// failed failureThreshold times straight reads unhealthy whether or not
// it has ever succeeded. Below threshold, a target that has never once
// succeeded reads unknown rather than healthy — see targetHealthUnknown's
+25
View File
@@ -15,6 +15,31 @@ import (
"github.com/stretchr/testify/require"
)
// stateOf reports (kind, name)'s current targetHealthState — see
// targetHealthState's own doc comment (target_health.go) for the exact
// unknown/healthy/unhealthy rule. Test-only (finding 18 fix, review-
// routes.md): it has zero production call sites — every production
// reader goes through snapshot (admin.go's targets view, metrics.go),
// which already applies the identical stateForLocked rule this helper
// reuses — so it was moved here from target_health.go rather than kept
// as unused production API surface. Every test in this package that
// wants a bare state value (not snapshot's fuller struct) uses this,
// including mcp_a2a_test.go and mcp_federation_test.go, which reach it
// as an ordinary package-scoped method the same way they would if it
// still lived in the non-test file.
func (t *targetHealthTracker) stateOf(kind, name string) targetHealthState {
if t == nil {
return targetHealthUnknown
}
t.mu.Lock()
defer t.mu.Unlock()
e, ok := t.entries[targetHealthKey{kind: kind, name: name}]
if !ok {
return targetHealthUnknown
}
return t.stateForLocked(e)
}
// --- targetHealthTracker: state machine ---
func TestTargetHealthTracker_NeverRecorded_ReadsUnknown(t *testing.T) {
+8
View File
@@ -0,0 +1,8 @@
{
"model": "claude-opus-5",
"messages": [
{"role": "developer", "content": "Always answer in one sentence."},
{"role": "user", "content": "Hello, how are you?"}
],
"max_tokens": 100
}
+8
View File
@@ -0,0 +1,8 @@
{
"model": "claude-opus-5",
"system": "Always answer in one sentence.",
"messages": [
{"role": "user", "content": "Hello, how are you?"}
],
"max_tokens": 100
}
+10
View File
@@ -0,0 +1,10 @@
{
"model": "claude-opus-5",
"messages": [
{"role": "user", "content": [
{"type": "text", "text": "What is in this image?"},
{"type": "image_url", "image_url": {"url": "data:image/png;charset=binary;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"}}
]}
],
"max_tokens": 300
}
+10
View File
@@ -0,0 +1,10 @@
{
"model": "claude-opus-5",
"messages": [
{"role": "user", "content": [
{"type": "text", "text": "What is in this image?"},
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"}}
]}
],
"max_tokens": 300
}
@@ -0,0 +1,15 @@
{
"model": "claude-opus-5",
"messages": [
{"role": "user", "content": "What's the weather in Paris?"},
{"role": "assistant", "content": null, "tool_calls": [
{"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{\"location\":\"Paris\"}"}}
]},
{"role": "tool", "tool_call_id": "call_1", "content": "72F and sunny"}
],
"max_tokens": 200,
"tools": [
{"type": "function", "function": {"name": "get_weather", "description": "gets the weather", "parameters": {"type": "object", "properties": {"location": {"type": "string"}}}}}
],
"tool_choice": "none"
}
@@ -0,0 +1,17 @@
{
"model": "claude-opus-5",
"messages": [
{"role": "user", "content": "What's the weather in Paris?"},
{"role": "assistant", "content": [
{"type": "tool_use", "id": "call_1", "name": "get_weather", "input": {"location": "Paris"}}
]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "call_1", "content": "72F and sunny"}
]}
],
"max_tokens": 200,
"tools": [
{"name": "get_weather", "description": "gets the weather", "input_schema": {"type": "object", "properties": {"location": {"type": "string"}}}}
],
"tool_choice": {"type": "none"}
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": "msg_01REFUSE",
"type": "message",
"role": "assistant",
"content": [
{"type": "text", "text": "I can't help with that."}
],
"model": "claude-opus-5",
"stop_reason": "refusal",
"stop_sequence": null,
"usage": {"input_tokens": 9, "output_tokens": 6}
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": "chatcmpl-msg_01REFUSE",
"object": "chat.completion",
"created": 1734000000,
"model": "gw-anthropic-model",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "I can't help with that."},
"finish_reason": "content_filter"
}
],
"usage": {"prompt_tokens": 9, "completion_tokens": 6, "total_tokens": 15}
}
+7
View File
@@ -0,0 +1,7 @@
{
"model": "gemini-2.5-pro",
"messages": [
{"role": "developer", "content": "Always answer in one sentence."},
{"role": "user", "content": "Hello there"}
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"systemInstruction": {"parts": [{"text": "Always answer in one sentence."}]},
"contents": [
{"role": "user", "parts": [{"text": "Hello there"}]}
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"candidates": [
{
"content": {
"role": "model",
"parts": [{"text": "The answer is 42."}]
},
"finishReason": "STOP"
}
],
"usageMetadata": {
"promptTokenCount": 10,
"candidatesTokenCount": 6,
"thoughtsTokenCount": 500,
"totalTokenCount": 516
},
"modelVersion": "gemini-2.5-pro-001",
"responseId": "resp_THINK1"
}
+14
View File
@@ -0,0 +1,14 @@
{
"id": "chatcmpl-resp_THINK1",
"object": "chat.completion",
"created": 1734000000,
"model": "gw-gemini-model",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "The answer is 42."},
"finish_reason": "stop"
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 506, "total_tokens": 516}
}
+35 -1
View File
@@ -180,7 +180,16 @@ type watchdogBody struct {
timedOut int32
ttfbRecorded int32
latencyRecorded int32
streaming bool
// done is set once Read observes a clean io.EOF (finding 11 fix,
// review-routes.md) — see Read's own doc comment for why the timer
// must be stopped THERE, not left armed until Close eventually calls
// Stop; fire checks this before doing anything, so a fire() goroutine
// already mid-flight the instant EOF arrives (Stop cannot retract a
// time.AfterFunc goroutine already started) still cannot record a
// spurious failure for an upstream that, in fact, just finished
// cleanly.
done int32
streaming bool
}
// newWatchdogBody returns a watchdogBody wrapping rc, arming its watchdog
@@ -264,6 +273,15 @@ func isEventStreamResponse(resp *http.Response) bool {
// provider-health accounting sees the failure even though no caller will
// ever read a body byte past this point.
func (wb *watchdogBody) fire() {
// Finding 11 fix (review-routes.md): once Read has already seen a
// clean EOF, this watchdog is done — a fire() goroutine that was
// already scheduled the instant EOF arrived (time.AfterFunc's own
// timer cannot be retracted once its goroutine has started, only
// prevented from starting via Stop) must not record a spurious
// provider failure for an upstream that, in fact, finished cleanly.
if atomic.LoadInt32(&wb.done) == 1 {
return
}
atomic.StoreInt32(&wb.timedOut, 1)
wb.cancel()
if wb.onTimeout != nil {
@@ -320,6 +338,18 @@ func (wb *watchdogBody) timeoutError() error {
// fixes that without touching the timer-reset semantics those tests
// pin: a self-contained top-of-function check that only ever WRITES
// wb.ttfb/wb.ttfbRecorded, never wb.timer or any error path.
//
// Finding 11 fix (review-routes.md): a clean io.EOF stops the timer HERE,
// immediately, rather than leaving it armed until the caller's own,
// possibly much later, Close call. Before this fix, forwardJSON
// (provider_openai.go) reads the upstream body to EOF and then spends
// time WRITING up to 32MiB of it to the client before its deferred
// Close runs — if that client write took longer than timeout, the
// still-armed timer fired and recorded a spurious provider failure for
// an upstream that had, in fact, already finished successfully. done
// additionally guards the fire() side of this same race (see its own
// doc comment): Stop cannot retract a fire() goroutine that started
// firing the instant before this branch runs.
func (wb *watchdogBody) Read(p []byte) (int, error) {
n, err := wb.rc.Read(p)
if n > 0 && wb.onLatency != nil && atomic.LoadInt32(&wb.ttfbRecorded) == 0 {
@@ -327,6 +357,10 @@ func (wb *watchdogBody) Read(p []byte) (int, error) {
atomic.StoreInt32(&wb.ttfbRecorded, 1)
}
if err != nil {
if err == io.EOF { //nolint:errorlint // io.EOF is returned bare per its own doc comment; wrapped by no known caller
atomic.StoreInt32(&wb.done, 1)
wb.timer.Stop()
}
if atomic.LoadInt32(&wb.timedOut) == 1 {
return n, wb.timeoutError()
}
+47
View File
@@ -849,6 +849,53 @@ func TestWatchdogBody_ZeroByteRead_DoesNotResetTimer(t *testing.T) {
}
}
// eofReadCloser always returns (0, io.EOF), simulating an upstream body
// that has already been fully, successfully read — used by
// TestWatchdogBody_Read_EOF_StopsTimer_FireNeverRuns (finding 11) to
// prove Read stops the watchdog timer itself, rather than waiting for
// the caller's own, possibly much later, Close call.
type eofReadCloser struct{}
func (eofReadCloser) Read([]byte) (int, error) { return 0, io.EOF }
func (eofReadCloser) Close() error { return nil }
// TestWatchdogBody_Read_EOF_StopsTimer_FireNeverRuns proves finding 11
// (review-routes.md): a Read returning io.EOF must stop the watchdog
// timer immediately, not leave it armed until the caller's eventual
// Close. Without this, a caller that reads the upstream body to
// completion and then spends time WRITING it out to a slow client
// (forwardJSON copies up to 32MiB before its deferred Close runs) races
// the still-armed timer, which can fire and record a spurious provider
// failure for an upstream that, in fact, finished cleanly. Close is
// deliberately NOT called until after the assertion below, mirroring
// TestWatchdogBody_Close_StopsTimer_FireNeverRuns' own "prove it without
// relying on Close" shape.
//
// MUTATION VERIFIED: removing the `if err == io.EOF { ...; wb.timer.
// Stop() }` branch from watchdogBody.Read (timeout.go) made this test
// fail — wb.timedOut read back as 1 after the sleep, since the un-stopped
// timer still fired on schedule.
func TestWatchdogBody_Read_EOF_StopsTimer_FireNeverRuns(t *testing.T) {
const timeout = 30 * time.Millisecond
frc := eofReadCloser{}
_, cancel := context.WithCancel(context.Background())
wb := newWatchdogBody(frc, cancel, timeout, "p1", nil)
if _, err := wb.Read(make([]byte, 16)); err != io.EOF { //nolint:errorlint // io.EOF returned bare by the fixture above
t.Fatalf("Read: err = %v, want io.EOF", err)
}
if got := atomic.LoadInt32(&wb.done); got != 1 {
t.Fatalf("wb.done = %d after a Read returning io.EOF, want 1", got)
}
time.Sleep(3 * timeout) // well past when an un-stopped timer would fire
if got := atomic.LoadInt32(&wb.timedOut); got != 0 {
t.Errorf("wb.timedOut = %d after a Read returning io.EOF, want 0 — the timer must be stopped at EOF, not left armed until Close", got)
}
_ = wb.Close()
}
// TestWatchdogBody_TimeoutError_UsesCallerLabel_NotHardcodedProvider
// proves finding F9 (coordinator adversarial review, 2026-08-23):
// proxyUpstream (routes_passthrough.go) previously passed logPrefix as a
+170 -29
View File
@@ -147,8 +147,16 @@ func anthropicImageSourceFromDataURI(uri string) (map[string]any, error) {
if !ok {
return nil, &translateError{msg: "malformed data: URI in image_url"}
}
mediaType, ok := strings.CutSuffix(header, ";base64")
if !ok {
// L2 fix: media_type is everything before the FIRST ";" after
// "data:" — any further parameters are stripped rather than left
// attached, mirroring geminiInlineDataFromDataURI's identical parsing
// (translate_gemini.go) and reusing its dataURIHasBase64Param helper.
// The previous strings.CutSuffix(header, ";base64") only stripped a
// trailing ";base64" token, so a header carrying an earlier parameter
// too ("image/png;charset=x;base64") left media_type as
// "image/png;charset=x" — a value Anthropic rejects.
mediaType, rawParams, _ := strings.Cut(header, ";")
if !dataURIHasBase64Param(rawParams) {
return nil, &translateError{msg: "data: URI in image_url must be base64-encoded"}
}
return map[string]any{"type": "base64", "media_type": mediaType, "data": data}, nil
@@ -295,17 +303,20 @@ func systemTextFromContentParts(parts []any) (string, error) {
}
// anthropicToolChoiceFromOpenAI maps OpenAI's "tool_choice" to Anthropic's
// tool_choice object, and reports whether "tools" must be dropped from the
// request. Per the mapping table, tool_choice:"none" has no valid
// Anthropic {"type":"none"} equivalent for this translator to use, so
// "none" is expressed by omitting "tools" (and "tool_choice") entirely
// instead.
// tool_choice object, and reports whether "tools" should be dropped from
// the request. tool_choice:"none" maps to Anthropic's own
// {"type":"none"} (M2 fix — Anthropic does support it) and requests
// "tools" be dropped by default; the caller (anthropicRequestFromOpenAI)
// overrides that back to false when the conversation history carries
// tool_use/tool_result blocks, since Anthropic requires the tools those
// blocks reference to still be defined even while tool_choice forbids
// calling them again.
func anthropicToolChoiceFromOpenAI(tc any) (choice map[string]any, dropTools bool) {
switch t := tc.(type) {
case string:
switch t {
case "none":
return nil, true
return map[string]any{"type": "none"}, true
case "auto":
return map[string]any{"type": "auto"}, false
case "required":
@@ -382,6 +393,13 @@ func anthropicRequestFromOpenAI(req map[string]any) (map[string]any, error) {
// messages must collapse into one Anthropic user turn carrying N
// tool_result blocks, not N separate user turns.
var pendingToolResults []any
// historyHasToolBlocks (M2 fix) latches true when any message in this
// request carries a tool_use/tool_result-shaped block once translated
// — an assistant message with tool_calls, or any role:"tool" message.
// anthropicRequestFromOpenAI reads this after the loop to decide
// whether tool_choice:"none" may still drop "tools" from the outgoing
// request; see anthropicToolChoiceFromOpenAI's doc comment.
historyHasToolBlocks := false
flushPendingToolResults := func() {
if len(pendingToolResults) == 0 {
return
@@ -395,7 +413,10 @@ func anthropicRequestFromOpenAI(req map[string]any) (map[string]any, error) {
continue
}
switch role, _ := msg["role"].(string); role {
case "system":
case "system", "developer":
// "developer" is the OpenAI o-series/gpt-5 replacement for
// "system" — Anthropic has no separate developer role, so it
// folds into the same top-level "system" text field.
switch c := msg["content"].(type) {
case string:
systemParts = append(systemParts, c)
@@ -408,12 +429,18 @@ func anthropicRequestFromOpenAI(req map[string]any) (map[string]any, error) {
}
case "user", "assistant":
flushPendingToolResults()
if role == "assistant" {
if tc, _ := msg["tool_calls"].([]any); len(tc) > 0 {
historyHasToolBlocks = true
}
}
am, err := anthropicMessageFromOpenAI(msg, role)
if err != nil {
return nil, err
}
anthMsgs = append(anthMsgs, am)
case "tool":
historyHasToolBlocks = true
pendingToolResults = append(pendingToolResults, anthropicToolResultBlock(msg))
}
}
@@ -459,7 +486,21 @@ func anthropicRequestFromOpenAI(req map[string]any) (map[string]any, error) {
if tc, ok := req["tool_choice"]; ok {
var choice map[string]any
choice, dropTools = anthropicToolChoiceFromOpenAI(tc)
if choice != nil {
// M2 fix: tool_choice:"none" only drops "tools" when the history
// has no tool_use/tool_result blocks of its own — see
// anthropicToolChoiceFromOpenAI's doc comment.
if dropTools && historyHasToolBlocks {
dropTools = false
}
// M2 fix (review round 4): tool_choice must be omitted too,
// whenever tools end up dropped — Anthropic's API rejects a
// tool_choice sent without "tools" in the same request. This
// restores the pre-M2 wire shape (both fields absent) for the
// common case, a "none" request whose history never used a
// tool; {"type":"none"} is sent — alongside "tools", never
// without it — only when the history's own tool_use/tool_result
// blocks forced dropTools back to false just above.
if choice != nil && !dropTools {
out["tool_choice"] = choice
}
}
@@ -528,15 +569,30 @@ type anthropicResponseBody struct {
// anthropicFinishReason maps an Anthropic stop_reason to an OpenAI
// finish_reason: max_tokens -> "length", tool_use -> "tool_calls",
// end_turn and stop_sequence both -> "stop". An unrecognized value
// defaults to "stop", the OpenAI-safe default for a completed turn.
// refusal -> "content_filter" (L1 fix). end_turn, stop_sequence,
// pause_turn, and any unrecognized value all default to "stop", the
// OpenAI-safe default for a completed turn.
func anthropicFinishReason(stopReason string) string {
switch stopReason {
case "max_tokens":
return "length"
case "tool_use":
return "tool_calls"
case "refusal":
// L1 fix: Anthropic's "refusal" stop_reason (the model declined to
// continue, e.g. a safety refusal) has no dedicated OpenAI
// finish_reason — "content_filter" is the closest existing one and
// keeps a refusal from being reported as an ordinary successful
// completion.
return "content_filter"
default:
// "pause_turn" (a long-running server-side tool call paused, not
// finished) falls through here along with "end_turn"/
// "stop_sequence" and anything unrecognized — left as "stop" per
// review: there is no OpenAI finish_reason that means "paused, the
// client is expected to continue the turn", so mapping it to
// anything more specific than "stop" would not be a clear
// improvement.
return "stop"
}
}
@@ -685,6 +741,35 @@ func (st *anthropicStreamState) chunk(delta map[string]any, finishReason *string
return b
}
// usageChunk builds the final usage-only "chat.completion.chunk" payload —
// empty "choices" plus a populated "usage" — mirroring the shape
// provider_openai.go's forwardStream relays from a native OpenAI upstream
// when stream_options.include_usage is set (M1 fix). Anthropic's own
// stream has no equivalent chunk, so the adapter calls this once, after
// the stream ends, only when the client asked for usage; st.u already
// holds the message_start/message_delta totals by then.
func (st *anthropicStreamState) usageChunk() []byte {
b, err := json.Marshal(map[string]any{
"id": chatCompletionIDPrefix + st.id,
"object": "chat.completion.chunk",
"created": st.created,
"model": st.model,
"choices": []any{},
"usage": map[string]any{
"prompt_tokens": st.u.prompt,
"completion_tokens": st.u.completion,
"total_tokens": st.u.total(),
},
})
if err != nil {
// Built entirely from this file's own map[string]any/string/int64
// literals plus st.u's int64 fields — never a value that can fail
// to marshal.
panic(fmt.Sprintf("llmgateway: anthropic stream usage chunk failed to marshal: %v", err))
}
return b
}
// anthropicMessageStartPayload is the "message_start" event's data shape.
type anthropicMessageStartPayload struct {
Message struct {
@@ -1015,9 +1100,16 @@ func openAIToolResultContentString(content any) string {
// getting a 400 from OpenAI ("messages with role 'tool' must be a
// response to a preceding message with 'tool_calls'"). Preserving
// original order fixes both shapes without special-casing either.
func openAIMessagesFromAnthropic(role string, content any) ([]any, error) {
//
// The second return value names every content-block "type" this function
// saw but has no OpenAI equivalent for — "document", "thinking", and
// "redacted_thinking" (L9 fix) — one entry per block TYPE encountered in
// this message, matching the granularity openAIRequestFromAnthropic's own
// "thinking"/"top_k" request-field checks use; the caller dedupes across
// a whole request's messages before logging.
func openAIMessagesFromAnthropic(role string, content any) ([]any, []string, error) {
if s, ok := content.(string); ok {
return []any{map[string]any{"role": role, "content": s}}, nil
return []any{map[string]any{"role": role, "content": s}}, nil, nil
}
blocksRaw, ok := content.([]any)
@@ -1025,10 +1117,11 @@ func openAIMessagesFromAnthropic(role string, content any) ([]any, error) {
// Absent or unrecognized content shape: an empty message rather
// than an error — mirrors anthropicMessageFromOpenAI's own
// tolerance of a missing "content" field above.
return []any{map[string]any{"role": role, "content": ""}}, nil
return []any{map[string]any{"role": role, "content": ""}}, nil, nil
}
var out []any
var dropped []string
var pendingContent []any
var pendingToolCalls []any
flushPending := func() {
@@ -1061,22 +1154,32 @@ func openAIMessagesFromAnthropic(role string, content any) ([]any, error) {
case "image":
part, err := openAIContentPartFromAnthropicImage(bm)
if err != nil {
return nil, err
return nil, nil, err
}
pendingContent = append(pendingContent, part)
case "tool_use":
tc, err := openAIToolCallFromAnthropic(bm)
if err != nil {
return nil, err
return nil, nil, err
}
pendingToolCalls = append(pendingToolCalls, tc)
case "tool_result":
flushPending()
out = append(out, openAIToolMessageFromAnthropic(bm))
default:
// L9 fix: "document", "thinking", "redacted_thinking", and any
// other block type this translator does not recognize — dropped
// silently before this fix, exactly like "thinking" at the
// request-field level was before item 11's own fix. bt=="" (no
// "type" field at all) is not reported: that is a malformed
// block, not a recognized-but-unsupported one.
if bt != "" {
dropped = append(dropped, bt)
}
}
}
flushPending()
return out, nil
return out, dropped, nil
}
// openAIToolsFromAnthropic maps Anthropic's tools[]{name, description,
@@ -1157,12 +1260,15 @@ func isOpenAIReasoningModel(model string) bool {
// convention — for a content block this translator cannot map (an
// unsupported image source type).
//
// The second return value lists request fields this translator
// recognizes but has no OpenAI equivalent for and therefore dropped
// (item 11 fix, 2026-08-22 review) — currently only "thinking"
// (Anthropic's extended-thinking/reasoning-budget config). The caller
// The second return value lists request fields (and, per L9's fix,
// content-block types) this translator recognizes but has no OpenAI
// equivalent for and therefore dropped (item 11 fix, 2026-08-22 review):
// the top-level "thinking" and "top_k" fields, plus any "document"/
// "thinking"/"redacted_thinking" content block openAIMessagesFromAnthropic
// encountered — deduplicated across every message in the request, so a
// transcript carrying ten thinking blocks still logs it once. The caller
// (routes_messages.go's callTranslatedMessages) logs a warning per
// dropped field rather than this function logging directly: every other
// dropped entry rather than this function logging directly: every other
// function in this file is a pure, Gateway-free translator, callable and
// testable without a *Gateway instance, and this keeps it that way.
func openAIRequestFromAnthropic(req map[string]any) (map[string]any, []string, error) {
@@ -1172,6 +1278,19 @@ func openAIRequestFromAnthropic(req map[string]any) (map[string]any, []string, e
out["model"] = modelStr
}
// droppedSeen dedupes addDropped's calls (L9 fix): a block type
// appended once per message by openAIMessagesFromAnthropic, or a
// top-level field checked once below, must still log only once per
// request regardless of how many times it recurs.
var dropped []string
droppedSeen := map[string]bool{}
addDropped := func(name string) {
if !droppedSeen[name] {
droppedSeen[name] = true
dropped = append(dropped, name)
}
}
msgs := make([]any, 0, 1)
if sys, ok := req["system"]; ok {
text, err := anthropicSystemToText(sys)
@@ -1190,11 +1309,14 @@ func openAIRequestFromAnthropic(req map[string]any) (map[string]any, []string, e
continue
}
role, _ := msg["role"].(string)
converted, err := openAIMessagesFromAnthropic(role, msg["content"])
converted, droppedBlocks, err := openAIMessagesFromAnthropic(role, msg["content"])
if err != nil {
return nil, nil, err
}
msgs = append(msgs, converted...)
for _, bt := range droppedBlocks {
addDropped(bt)
}
}
out["messages"] = msgs
@@ -1220,9 +1342,15 @@ func openAIRequestFromAnthropic(req map[string]any) (map[string]any, []string, e
if seqs, ok := req["stop_sequences"].([]any); ok && len(seqs) > 0 {
out["stop"] = seqs
}
if v, ok := req["stream"]; ok {
out["stream"] = v
}
// L10 fix: no "stream" copy here — routes_messages.go's
// isStreamingRequested check (its own doc comment) rejects any
// streaming /v1/messages request with a 400 before
// openAIRequestFromAnthropic ever runs, so req["stream"] reaching
// this point can only be absent, false, or null. Copying it through
// was dead code with no observable effect (the downstream adapter's
// own `streaming, _ := req["stream"].(bool)` treats all three
// identically to omitting the field outright) — removed rather than
// left in place, per this repo's dead-code-hygiene rule.
if toolsRaw, ok := req["tools"].([]any); ok {
out["tools"] = openAIToolsFromAnthropic(toolsRaw)
@@ -1230,6 +1358,13 @@ func openAIRequestFromAnthropic(req map[string]any) (map[string]any, []string, e
if tc, ok := req["tool_choice"]; ok {
if choice := openAIToolChoiceFromAnthropic(tc); choice != nil {
out["tool_choice"] = choice
} else if tm, ok := tc.(map[string]any); ok && tm["type"] == "none" {
// L9 fix: Anthropic's tool_choice {"type":"none"} has no
// OpenAI equivalent (openAIToolChoiceFromAnthropic's own doc
// comment) and was silently dropped — reported the same way
// "thinking"/"top_k" are, rather than left the one dropped
// field with no warning.
addDropped("tool_choice:none")
}
}
@@ -1239,9 +1374,15 @@ func openAIRequestFromAnthropic(req map[string]any) (map[string]any, []string, e
}
}
var dropped []string
if _, ok := req["thinking"]; ok {
dropped = append(dropped, "thinking")
addDropped("thinking")
}
// L9 fix: "top_k" was silently dropped, unlike "thinking", which
// already had this same reporting since item 11 (2026-08-22 review) —
// there is no OpenAI equivalent for Anthropic's top_k sampling
// parameter, so it is reported the identical way.
if _, ok := req["top_k"]; ok {
addDropped("top_k")
}
return out, dropped, nil
+101 -2
View File
@@ -667,7 +667,7 @@ func TestOpenAIMessagesFromAnthropic_PreservesToolResultThenTextOrder(t *testing
map[string]any{"type": "text", "text": "thanks"},
}
out, err := openAIMessagesFromAnthropic("user", content)
out, _, err := openAIMessagesFromAnthropic("user", content)
if err != nil {
t.Fatalf("openAIMessagesFromAnthropic: %v", err)
}
@@ -702,7 +702,7 @@ func TestOpenAIMessagesFromAnthropic_InterspersedToolResults(t *testing.T) {
map[string]any{"type": "tool_result", "tool_use_id": "call_B", "content": "b"},
}
out, err := openAIMessagesFromAnthropic("user", content)
out, _, err := openAIMessagesFromAnthropic("user", content)
if err != nil {
t.Fatalf("openAIMessagesFromAnthropic: %v", err)
}
@@ -804,6 +804,105 @@ func TestOpenAIRequestFromAnthropic_ThinkingAbsent_NothingDropped(t *testing.T)
}
}
// TestOpenAIRequestFromAnthropic_TopKReportedAsDropped is the L9
// regression test: "top_k" has no OpenAI equivalent, and — unlike
// "thinking", already covered above — was previously discarded with no
// entry in the dropped-fields return at all.
func TestOpenAIRequestFromAnthropic_TopKReportedAsDropped(t *testing.T) {
req := map[string]any{
"model": "claude-x",
"top_k": float64(40),
"messages": []any{map[string]any{"role": "user", "content": "hi"}},
}
_, dropped, err := openAIRequestFromAnthropic(req)
if err != nil {
t.Fatalf("openAIRequestFromAnthropic: %v", err)
}
if len(dropped) != 1 || dropped[0] != "top_k" {
t.Errorf("dropped = %v, want [\"top_k\"]", dropped)
}
}
// TestOpenAIRequestFromAnthropic_ToolChoiceNoneReportedAsDropped is the
// L9 regression test for tool_choice: Anthropic's tool_choice
// {"type":"none"} has no OpenAI equivalent (openAIToolChoiceFromAnthropic
// simply omits the field), and was previously dropped with no entry in
// the dropped-fields return.
func TestOpenAIRequestFromAnthropic_ToolChoiceNoneReportedAsDropped(t *testing.T) {
req := map[string]any{
"model": "claude-x",
"tool_choice": map[string]any{"type": "none"},
"messages": []any{map[string]any{"role": "user", "content": "hi"}},
}
out, dropped, err := openAIRequestFromAnthropic(req)
if err != nil {
t.Fatalf("openAIRequestFromAnthropic: %v", err)
}
if _, hasToolChoice := out["tool_choice"]; hasToolChoice {
t.Errorf(`out["tool_choice"] = %v, want absent (no OpenAI equivalent for Anthropic tool_choice:{type:none})`, out["tool_choice"])
}
if len(dropped) != 1 || dropped[0] != "tool_choice:none" {
t.Errorf(`dropped = %v, want ["tool_choice:none"]`, dropped)
}
}
// TestOpenAIRequestFromAnthropic_DroppedContentBlockTypesReported is the
// L9 regression test for the content-block half of the fix: a "thinking"
// block and a "document" block inside message content both have no
// OpenAI equivalent (translate_anthropic.go's openAIMessagesFromAnthropic
// switch has no case for either) and must be named in the dropped-fields
// return, not silently skipped the way they were before this fix.
func TestOpenAIRequestFromAnthropic_DroppedContentBlockTypesReported(t *testing.T) {
req := map[string]any{
"model": "claude-x",
"messages": []any{
map[string]any{"role": "user", "content": []any{
map[string]any{"type": "text", "text": "hi"},
map[string]any{"type": "document", "source": map[string]any{"type": "base64", "media_type": "application/pdf", "data": "AAAA"}},
}},
map[string]any{"role": "assistant", "content": []any{
map[string]any{"type": "thinking", "thinking": "reasoning...", "signature": "sig"},
map[string]any{"type": "text", "text": "the answer"},
}},
},
}
_, dropped, err := openAIRequestFromAnthropic(req)
if err != nil {
t.Fatalf("openAIRequestFromAnthropic: %v", err)
}
sort.Strings(dropped)
want := []string{"document", "thinking"}
if len(dropped) != len(want) || dropped[0] != want[0] || dropped[1] != want[1] {
t.Errorf("dropped = %v, want %v", dropped, want)
}
}
// TestOpenAIRequestFromAnthropic_DroppedFieldsDeduped proves repeated
// occurrences of the same dropped content-block type across several
// messages log once, not once per occurrence — a transcript with five
// "thinking" blocks must not flood the log with five identical warnings.
func TestOpenAIRequestFromAnthropic_DroppedFieldsDeduped(t *testing.T) {
thinkingBlock := map[string]any{"type": "thinking", "thinking": "...", "signature": "sig"}
req := map[string]any{
"model": "claude-x",
"messages": []any{
map[string]any{"role": "assistant", "content": []any{thinkingBlock, thinkingBlock}},
map[string]any{"role": "assistant", "content": []any{thinkingBlock}},
},
}
_, dropped, err := openAIRequestFromAnthropic(req)
if err != nil {
t.Fatalf("openAIRequestFromAnthropic: %v", err)
}
if len(dropped) != 1 || dropped[0] != "thinking" {
t.Errorf("dropped = %v, want exactly one [\"thinking\"] entry despite 3 occurrences", dropped)
}
}
// TestOpenAIRequestFromAnthropic_ReasoningModel_UsesMaxCompletionTokens is
// the regression for item 10 (IMPORTANT, 2026-08-22 review): OpenAI's
// o-series and gpt-5-family reasoning models reject the "max_tokens"
+43 -4
View File
@@ -320,7 +320,10 @@ func geminiRequestFromOpenAI(req map[string]any) (map[string]any, error) {
continue
}
switch role, _ := msg["role"].(string); role {
case "system":
case "system", "developer":
// "developer" is the OpenAI o-series/gpt-5 replacement for
// "system" — Gemini has no separate developer role, so it
// folds into the same systemInstruction text.
switch c := msg["content"].(type) {
case string:
systemParts = append(systemParts, c)
@@ -451,9 +454,14 @@ type geminiCandidate struct {
// geminiUsageMetadata is the "usageMetadata" object on a Gemini
// GenerateContentResponse, non-streaming or streaming alike.
// ThoughtsTokenCount is Gemini's reasoning/thinking token spend (2.5+
// thinking models) — it is billed as output but is not included in
// CandidatesTokenCount, so safeCompletion folds it into the completion
// total rather than leaving it undercounted.
type geminiUsageMetadata struct {
PromptTokenCount int64 `json:"promptTokenCount"`
CandidatesTokenCount int64 `json:"candidatesTokenCount"`
ThoughtsTokenCount int64 `json:"thoughtsTokenCount"`
}
// geminiGenerateContentResponse is the subset of a Gemini
@@ -627,12 +635,14 @@ func (m *geminiUsageMetadata) safePrompt() int64 {
return m.PromptTokenCount
}
// safeCompletion returns m.CandidatesTokenCount, or 0 when m is nil.
// safeCompletion returns m.CandidatesTokenCount plus m.ThoughtsTokenCount
// (thinking-model reasoning tokens, billed as output but reported
// separately from CandidatesTokenCount), or 0 when m is nil.
func (m *geminiUsageMetadata) safeCompletion() int64 {
if m == nil {
return 0
}
return m.CandidatesTokenCount
return m.CandidatesTokenCount + m.ThoughtsTokenCount
}
// geminiStreamState is one streaming chat completion's translation state:
@@ -707,6 +717,35 @@ func (st *geminiStreamState) chunk(delta map[string]any, finishReason *string) [
return b
}
// usageChunk builds the final usage-only "chat.completion.chunk" payload —
// empty "choices" plus a populated "usage" — mirroring the shape
// provider_openai.go's forwardStream relays from a native OpenAI upstream
// when stream_options.include_usage is set (M1 fix). Gemini's own stream
// has no equivalent chunk, so the adapter calls this once, after the
// stream ends, only when the client asked for usage; st.u already holds
// the last-usageMetadata-wins totals by then.
func (st *geminiStreamState) usageChunk() []byte {
b, err := json.Marshal(map[string]any{
"id": chatCompletionIDPrefix + st.id,
"object": "chat.completion.chunk",
"created": st.created,
"model": st.model,
"choices": []any{},
"usage": map[string]any{
"prompt_tokens": st.u.prompt,
"completion_tokens": st.u.completion,
"total_tokens": st.u.total(),
},
})
if err != nil {
// Built entirely from this file's own map[string]any/string/int64
// literals plus st.u's int64 fields — never a value that can fail
// to marshal.
panic(fmt.Sprintf("llmgateway: gemini stream usage chunk failed to marshal: %v", err))
}
return b
}
// translate maps one upstream Gemini SSE event to zero or more
// OpenAI-shaped "chat.completion.chunk" payloads, per this file's stream
// mapping table. Every event's data is a full GenerateContentResponse
@@ -747,7 +786,7 @@ func (st *geminiStreamState) translate(ev sseEvent) ([][]byte, error) {
}
if resp.UsageMetadata != nil {
st.u = usage{prompt: resp.UsageMetadata.PromptTokenCount, completion: resp.UsageMetadata.CandidatesTokenCount}
st.u = usage{prompt: resp.UsageMetadata.safePrompt(), completion: resp.UsageMetadata.safeCompletion()}
}
var chunks [][]byte
+41 -8
View File
@@ -57,9 +57,11 @@ type gatewayLogger interface {
// from the last observed mtime (forward or backward). It runs on the request
// path (ServeHTTP calls it at entry), so it must stay cheap when nothing
// changed and must never panic: on any error — stat, load, or
// replaceFileUsers rejecting the new set — it logs via a.log.errorf and
// keeps the last good set. A successful reload that changes the
// file-sourced user count logs via a.log.logf.
// replaceFileUsers rejecting the new set — it logs via logReloadError (rate-
// limited to once per distinct error/mtime pair, finding F11) and keeps the
// last good set. A successful reload that changes the file-sourced user
// count logs via a.log.logf, unconditionally — that line is inherently rare
// (only on an actual reload, itself throttled), unlike the error path above.
//
// Returns true only when a reload actually replaced the user set (review
// fix, adversarial verification 2026-08-23) — every early-return path
@@ -96,9 +98,13 @@ func (a *authStore) maybeReload() bool {
}
a.lastCheck = now
info, err := os.Stat(a.usersFile.path)
if err != nil {
a.log.errorf("llmgateway: cannot stat users file %q: %v", a.usersFile.path, err)
info, statErr := os.Stat(a.usersFile.path)
if statErr != nil {
// A stat failure has no fresh mtime to dedup against — use the
// zero time.Time as that half of the key, so this branch's own
// dedup is driven by the error TEXT changing alone (see
// logReloadError's own doc comment).
a.logReloadError(time.Time{}, "llmgateway: cannot stat users file %q: %v", a.usersFile.path, statErr)
return false
}
// Equal, not After: an After-only check misses a backward mtime move —
@@ -111,7 +117,7 @@ func (a *authStore) maybeReload() bool {
users, err := a.usersFile.load()
if err != nil {
a.log.errorf("llmgateway: users file %q reload failed: %v", a.usersFile.path, err)
a.logReloadError(info.ModTime(), "llmgateway: users file %q reload failed: %v", a.usersFile.path, err)
return false
}
@@ -120,13 +126,40 @@ func (a *authStore) maybeReload() bool {
a.mu.RUnlock()
if err := a.replaceFileUsers(users); err != nil {
a.log.errorf("llmgateway: users file %q reload rejected: %v", a.usersFile.path, err)
a.logReloadError(info.ModTime(), "llmgateway: users file %q reload rejected: %v", a.usersFile.path, err)
return false
}
a.lastModTime = info.ModTime()
// The file is good again — clear the dedup key so a LATER failure
// (after this success) logs immediately rather than staying
// suppressed by a stale key from a much earlier failure episode.
a.lastReloadErrMsg = ""
a.lastReloadErrModTime = time.Time{}
if len(users) != prevCount {
a.log.logf("llmgateway: users file %q reload: user count changed from %d to %d", a.usersFile.path, prevCount, len(users))
}
return true
}
// logReloadError logs a maybeReload failure via a.log.errorf, but only
// when it is NEW: a distinct message from the last one logged, or the
// same message attempted against a distinct file mtime (review-auth
// finding F11, 2026-09 audit — "once per distinct error / mtime change").
// Without this, a users file left broken gets re-attempted AND re-logged
// every reloadEvery for as long as it stays broken — retrying is
// intentional (the operator may fix it any second), but the identical log
// line every 5s is not: roughly 17k lines/day at the default throttle,
// unbounded like every other log line in this package already is not
// (logStoreError, limits.go; the failure/throttle logGates, auth.go).
// modTime is compared with Equal, matching maybeReload's own mtime-change
// convention immediately above.
func (a *authStore) logReloadError(modTime time.Time, format string, args ...any) {
msg := fmt.Sprintf(format, args...)
if msg == a.lastReloadErrMsg && modTime.Equal(a.lastReloadErrModTime) {
return
}
a.lastReloadErrMsg = msg
a.lastReloadErrModTime = modTime
a.log.errorf("%s", msg)
}
+67
View File
@@ -297,6 +297,73 @@ func TestAuthStore_MaybeReload_MalformedJSON_KeepsLastGoodSetAndLogsError(t *tes
}
}
// TestAuthStore_MaybeReload_BrokenFile_ErrorLogDedupedUntilMtimeOrTextChanges
// is review-auth finding F11, 2026-09 audit: a users file left broken
// must not re-log the identical failure on every reloadEvery throttle
// window for as long as it stays broken — before this fix, lastModTime
// was never advanced on a failed reload, so every throttle window saw
// the mtime as still different from the last observed one and retried
// AND re-logged, unbounded (~17k lines/day at the default 5s throttle).
func TestAuthStore_MaybeReload_BrokenFile_ErrorLogDedupedUntilMtimeOrTextChanges(t *testing.T) {
a, fp, log, clock := newReloadableAuthStore(t, nil, []*UserConfig{{Name: "f1", Group: "eng", APIKey: "sk-f1"}})
if err := os.WriteFile(fp, []byte("{not json"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
broken := time.Now().Add(time.Hour)
if err := os.Chtimes(fp, broken, broken); err != nil {
t.Fatalf("Chtimes: %v", err)
}
// 3 throttle windows, same broken file, same mtime: only the FIRST
// attempt may log.
for i := 0; i < 3; i++ {
clock.Advance(reloadEvery + time.Second)
a.maybeReload()
}
if got := log.errorCount(); got != 1 {
t.Fatalf("errorCount = %d after 3 identical-failure attempts, want 1 (deduped)", got)
}
// Rewrite the SAME broken content at a NEW mtime (an operator edited
// the file, even to an identically broken state) — this must log
// again, once.
broken2 := broken.Add(time.Hour)
if err := os.Chtimes(fp, broken2, broken2); err != nil {
t.Fatalf("Chtimes: %v", err)
}
clock.Advance(reloadEvery + time.Second)
a.maybeReload()
if got := log.errorCount(); got != 2 {
t.Fatalf("errorCount = %d after the mtime changed, want 2 (a new attempt against a new mtime logs again)", got)
}
// Fix the file: a successful reload clears the dedup key.
writeUsersDoc(t, fp, []*UserConfig{{Name: "f2", Group: "eng", APIKey: "sk-f2"}}, broken2.Add(time.Hour))
clock.Advance(reloadEvery + time.Second)
if !a.maybeReload() {
t.Fatal("want the reload to succeed once the file is valid again")
}
if got := log.errorCount(); got != 2 {
t.Fatalf("errorCount = %d after a successful reload, want unchanged at 2", got)
}
// Break it again, differently: after a success, the very next failure
// must log immediately, not stay suppressed by the earlier dedup key.
if err := os.WriteFile(fp, []byte("{also not json"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
broken3 := broken2.Add(2 * time.Hour)
if err := os.Chtimes(fp, broken3, broken3); err != nil {
t.Fatalf("Chtimes: %v", err)
}
clock.Advance(reloadEvery + time.Second)
a.maybeReload()
if got := log.errorCount(); got != 3 {
t.Fatalf("errorCount = %d after a fresh failure post-success, want 3 (dedup key was cleared on success)", got)
}
}
func TestAuthStore_MaybeReload_UnchangedMtime_DoesNotReload(t *testing.T) {
a, fp, log, clock := newReloadableAuthStore(t, nil, []*UserConfig{{Name: "f1", Group: "eng", APIKey: "sk-f1"}})
_ = fp