Files
traefik-llmgateway/providers_test.go
T
lukaszraczylo aafd2a0433 feat(providers): success-rate accounting
Track per-provider and per-(provider,upstream-model) upstream-attempt
outcomes using the existing fixed-window counter machinery (limits.go),
following mcp_a2a.go's per-target counter pattern: kind-embedded keys
(kindProvider="prov", kindProviderModel="provmodel") keep a provider
scope collision-safe against a user/group/MCP-target/agent of the same
name.

limiter.recordProviderAttempt(provider, model, resp, err) increments an
"attempts" counter on every call, and a "failures" counter only for a
provider-fault outcome — reusing retry.go's own isTransient(resp, err)
classification exactly (transport/connection error, HTTP 429, or a 5xx),
not forking it. Both counters are written at minute and day granularity
only; provider health is a now-and-today question, not a billing one, so
there is no month window. limiter.providerUsage batches every scope's
read into one storeGetMulti round trip, mirroring targetUsage's own
single-batch discipline.

Wired at every chokepoint an upstream HTTP attempt resolves:
- providers.go's upstreamJSON/upstreamRawBytes path (chat, embeddings,
  images, audio) via retry.go's retryPolicy.do, which now looks up an
  optional attemptRecorder from ctx and invokes it once per retry
  attempt (attempt-accounting: a request retried twice before
  succeeding reports three attempts, not one).
- routes_unified.go's runUnified (chat/embeddings, streaming and
  non-streaming alike — both funnel through the same upstreamJSON call).
- routes_media.go's three media handlers (images.generations,
  audio.speech, audio.transcriptions).
- routes_passthrough.go's proxyUpstream (native provider passthrough) —
  a single un-retried attempt, provider-level only: the upstream model
  lives in the response body, read only after the attempt resolves.

The attemptRecorder is threaded via context (providers.go's
withAttemptRecorder/attemptRecorderFromContext), not a new function
parameter on every call site — proxyUpstream and retryPolicy.do are
both shared with paths that must NOT record provider accounting
(registry.go's discovery listModels calls, and the MCP/A2A target
proxy, mcp_a2a.go's handleTargetProxy), so scoping via context keeps
their signatures unchanged and those callers correctly opt out by
simply never wrapping their context.
2026-08-21 23:22:16 +01:00

65 lines
2.4 KiB
Go

package traefikllmgateway
import (
"context"
"net/http"
"testing"
)
// TestAttemptRecorderFromContext_RoundTrip proves withAttemptRecorder/
// attemptRecorderFromContext round-trip a non-nil recorder, and that the
// SAME func value comes back out (asserted indirectly here, by observing
// it fire when invoked) — Feature A's (v0.22) chokepoint plumbing between
// providers.go, retry.go's policy.do, and routes_passthrough.go's
// proxyUpstream.
func TestAttemptRecorderFromContext_RoundTrip(t *testing.T) {
var gotResp *http.Response
var gotErr error
rec := attemptRecorder(func(resp *http.Response, err error) {
gotResp = resp
gotErr = err
})
ctx := withAttemptRecorder(context.Background(), rec)
got := attemptRecorderFromContext(ctx)
if got == nil {
t.Fatal("attemptRecorderFromContext returned nil after withAttemptRecorder set one")
}
wantResp := &http.Response{StatusCode: http.StatusOK}
got(wantResp, nil)
if gotResp != wantResp {
t.Errorf("gotResp = %v, want %v (the recorder round-tripped through ctx must be the same func)", gotResp, wantResp)
}
if gotErr != nil {
t.Errorf("gotErr = %v, want nil", gotErr)
}
}
// TestAttemptRecorderFromContext_NoneSet proves a ctx that never called
// withAttemptRecorder — the common case: registry.go's discovery
// listModels calls, and handleTargetProxy's MCP/A2A target proxy
// (mcp_a2a.go), neither of which is Feature A's accounting scope —
// returns a nil recorder, not a panic or a zero-value func that panics
// when invoked.
func TestAttemptRecorderFromContext_NoneSet(t *testing.T) {
if got := attemptRecorderFromContext(context.Background()); got != nil {
t.Errorf("attemptRecorderFromContext(bare ctx) = %v, want nil", got)
}
}
// TestWithAttemptRecorder_NilRecorder_ReturnsSameContext proves passing a
// nil recorder is a safe no-op a caller may call unconditionally — it
// does not wrap ctx in a no-op value that would still satisfy a later
// "!= nil" nil-check with a non-functional func.
func TestWithAttemptRecorder_NilRecorder_ReturnsSameContext(t *testing.T) {
ctx := context.Background()
got := withAttemptRecorder(ctx, nil)
if got != ctx { //nolint:staticcheck // deliberately comparing context identity, not using it as a map key
t.Error("withAttemptRecorder(ctx, nil) returned a different context; want ctx unchanged")
}
if rec := attemptRecorderFromContext(got); rec != nil {
t.Errorf("attemptRecorderFromContext = %v, want nil after a nil recorder was passed in", rec)
}
}