mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-17 05:37:43 +00:00
fix: force include_usage, auth event logging, credential-strip coverage
C1 (quota bypass): a client sending stream_options.include_usage=false
suppressed upstream usage reporting and escaped every token/cost budget,
since the non-streaming T12 estimate never covers a streaming response.
chatCompletion (provider_openai.go) now always forces include_usage=true
on the outgoing request, merged into a copy of the client's own
stream_options map so the caller's map is never mutated in place.
clientAskedUsage is derived from the client's own value via isTruthy, so
the usage-only chunk is still suppressed for a client that never asked
for it.
I1 (vacuous security tests): the X-Api-Key credential-strip set had zero
effective coverage — existing tests either never sent x-api-key, or
targeted anthropic, whose injectAuth overwrites x-api-key regardless of
whether the strip ran. Added one openai-passthrough and one MCP-proxy
test that authenticate via x-api-key itself and assert it arrives empty
upstream. Mutation-checked: both fail when "X-Api-Key": true is removed
from gatewayCredentialHeaders (verified, then restored).
I3 (auth-event logging): added Gateway.logAuthEvent (logger.go), called
at all six authStore.identify call sites (llmgateway.go's ServeHTTP,
routes_unified.go's handleModels). A failed attempt logs method, path,
and remote address; a success logs the resolved user's name and route.
Neither line ever includes the presented API key.
M2 (usage.estimated): runUnified now reads result.estimated and logs the
estimate explicitly when a non-streaming response's usage falls back to
the body-size heuristic, making the field genuinely read — removes the
stale //nolint:unused on limits.go's usage struct.
M8 (gosec consistency): secrets.go's file: secret path read now carries
the same "#nosec G304 -- operator-supplied file path from middleware
config" annotation as users_file.go's equivalent read.
Doc nit: mcp_a2a.go's validateTargetURLs comment now also names the
dot-only ("."/"..") rule validateConfigName enforces, not just character
set and reserved names.
Gates: go test ./... -count=1 (-race) green, gofmt -l . empty, go vet
clean, make yaegi-check OK, golangci-lint (gosec+staticcheck+unused+
errcheck+govet, global config) 0 issues.
This commit is contained in:
+57
-3
@@ -200,9 +200,10 @@ func TestOpenAIAdapter_ChatCompletion_Streaming_ClientDidNotAskUsage(t *testing.
|
||||
}
|
||||
|
||||
// TestOpenAIAdapter_ChatCompletion_Streaming_ClientAskedUsage proves that
|
||||
// when the client already set stream_options itself, chatCompletion leaves
|
||||
// it untouched (does not overwrite it) and forwards the usage-only chunk
|
||||
// through to the client instead of suppressing it.
|
||||
// when the client already set stream_options.include_usage=true itself,
|
||||
// chatCompletion preserves that value (forcing true on top of true is a
|
||||
// no-op) and forwards the usage-only chunk through to the client instead
|
||||
// of suppressing it.
|
||||
func TestOpenAIAdapter_ChatCompletion_Streaming_ClientAskedUsage(t *testing.T) {
|
||||
var gotBody []byte
|
||||
srv := newStreamingFakeUpstream(t, &gotBody)
|
||||
@@ -247,6 +248,59 @@ func TestOpenAIAdapter_ChatCompletion_Streaming_ClientAskedUsage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIAdapter_ChatCompletion_Streaming_ClientSuppressesUsage proves
|
||||
// the C1 fix: a client sending stream_options.include_usage=false must not
|
||||
// be able to suppress upstream usage reporting and escape token/cost
|
||||
// budgets. chatCompletion forces include_usage=true upstream regardless,
|
||||
// still captures the resulting usage for accounting, but derives
|
||||
// clientAskedUsage from the client's own (false) value — so the usage-only
|
||||
// chunk is not forwarded to this client. It also proves the merge copies
|
||||
// the client's stream_options map instead of mutating it in place.
|
||||
func TestOpenAIAdapter_ChatCompletion_Streaming_ClientSuppressesUsage(t *testing.T) {
|
||||
var gotBody []byte
|
||||
srv := newStreamingFakeUpstream(t, &gotBody)
|
||||
defer srv.Close()
|
||||
|
||||
a := newOpenAIAdapter("p1", srv.URL, "sk-test")
|
||||
rec := newRecordingResponseWriter()
|
||||
|
||||
clientStreamOptions := map[string]any{"include_usage": false}
|
||||
req := map[string]any{
|
||||
"model": "gpt-5",
|
||||
"stream": true,
|
||||
"stream_options": clientStreamOptions,
|
||||
}
|
||||
u, err := a.chatCompletion(context.Background(), rec, req)
|
||||
if err != nil {
|
||||
t.Fatalf("chatCompletion: %v", err)
|
||||
}
|
||||
|
||||
if u.prompt != 7 || u.completion != 9 {
|
||||
t.Errorf("usage = %+v, want {prompt:7 completion:9} (must still be captured despite include_usage=false)", u)
|
||||
}
|
||||
|
||||
want := "data: " + streamFrames[0] + "\n\n" +
|
||||
"data: " + streamFrames[1] + "\n\n" +
|
||||
"data: " + streamFrames[2] + "\n\n" +
|
||||
"data: [DONE]\n\n"
|
||||
if got := rec.body.String(); got != want {
|
||||
t.Errorf("forwarded body = %q, want %q (usage chunk must be suppressed, client never asked)", got, want)
|
||||
}
|
||||
|
||||
sent := decodeJSONBody(t, gotBody)
|
||||
so, ok := sent["stream_options"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("upstream request missing stream_options: %v", sent)
|
||||
}
|
||||
if so["include_usage"] != true {
|
||||
t.Errorf("stream_options.include_usage = %v, want true (must be forced regardless of client value — quota bypass otherwise)", so["include_usage"])
|
||||
}
|
||||
|
||||
if clientStreamOptions["include_usage"] != false {
|
||||
t.Errorf("client's own stream_options map was mutated in place: %v, want include_usage still false", clientStreamOptions)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenAIAdapter_ChatCompletion_Streaming_UsageCapturedBeforeAbruptClose
|
||||
// proves that when the upstream sends the usage-only chunk and then the
|
||||
// connection drops (no [DONE], no clean EOF), chatCompletion still returns
|
||||
|
||||
Reference in New Issue
Block a user