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:
2026-08-20 14:59:21 +01:00
parent 5a11cc85be
commit 4b849fd6c7
10 changed files with 302 additions and 19 deletions
+57 -3
View File
@@ -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