fix(registry): log model-id collisions at WARN, not ERROR

A model id served by two configured providers is resolved
deterministically by the registry, the winner is named in the message,
and no request is affected. Logging it at ERROR put 144 ERROR lines in a
healthy pod's boot log on a cluster with overlapping openai,
openai-audio, copilot and macstudio catalogs, which is exactly the noise
that hides a real failure.

Adds Gateway.warnf beside logf and errorf, and a warn field on
modelRegistry carrying it. The field is set by the production caller
after newModelRegistry returns rather than added as a fourth constructor
parameter: 45 call sites pass a logger today, almost all of them tests
passing a no-op, and threading a parameter through all of them would
bury a one-line severity change in unrelated churn. A nil warn falls
back to log, so a registry built without one keeps its current behaviour
instead of panicking or dropping the line.

registry.warnf takes an already-formatted string so both call shapes
stay single-variadic-argument. Yaegi v0.16.1's CFG builder panics on a
call to a struct FIELD of variadic func type with two or more variadic
arguments, which is the restriction the log field beside it already
documents.

Verified under the real interpreter, not only compiled: yaegi-check
drives a genuine two-provider collision and now prints WARN. The
severity test fails if the call site reverts to log, so the level cannot
drift back unnoticed.

Only this message moves. Genuine failures — discovery errors, metadata
capture panics, encoding failures — stay at ERROR.
This commit is contained in:
2026-08-22 16:04:51 +01:00
parent 5a34c5a52c
commit 0346c3ed39
4 changed files with 124 additions and 1 deletions
+78
View File
@@ -3,6 +3,7 @@ package traefikllmgateway
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -180,3 +181,80 @@ func TestLogAuthEvent_Failure_SuppressedCountReported(t *testing.T) {
t.Errorf("want the next line to report 2 suppressed events, got %q", out)
}
}
// TestWarnf_WritesWarnPrefix pins warnf's own severity token, so the
// level cannot drift back without a test noticing.
func TestWarnf_WritesWarnPrefix(t *testing.T) {
gw := newTestGatewayForLogger(t)
out := captureStderr(t, func() {
gw.warnf("something resolved itself: %d", 7)
})
if !strings.Contains(out, "llmgw[llmgw] WARN something resolved itself: 7") {
t.Errorf("warnf output = %q, want a WARN-prefixed line", out)
}
if strings.Contains(out, "ERROR") {
t.Errorf("warnf must not emit an ERROR line, got %q", out)
}
}
// TestWarnCollisionOnce_LogsAtWarnNotError is the regression for the
// model-id collision line's severity (2026-08-22). A model id served by
// two configured providers is resolved deterministically by the registry
// and affects no request, so it must not be an ERROR: on a cluster with
// overlapping catalogs it put 144 ERROR lines in a healthy pod's boot
// log, which is exactly the noise that hides a real failure.
func TestWarnCollisionOnce_LogsAtWarnNotError(t *testing.T) {
gw := newTestGatewayForLogger(t)
reg, err := newModelRegistry(map[string]providerAdapter{}, CreateConfig(), gw.errorf)
if err != nil {
t.Fatalf("newModelRegistry: %v", err)
}
reg.warn = gw.warnf
out := captureStderr(t, func() {
reg.warnCollisionOnce("gpt-4o", "openai", []string{"openai", "openai-audio"})
})
if !strings.Contains(out, "WARN model registry: model id \"gpt-4o\" is provided by multiple providers") {
t.Errorf("collision line = %q, want it emitted at WARN", out)
}
if strings.Contains(out, "ERROR") {
t.Errorf("collision line must not be an ERROR, got %q", out)
}
// Still logged only once per id, regardless of severity.
second := captureStderr(t, func() {
reg.warnCollisionOnce("gpt-4o", "openai", []string{"openai", "openai-audio"})
})
if second != "" {
t.Errorf("second collision for the same id logged again: %q", second)
}
}
// TestRegistryWarnf_FallsBackToLogWhenWarnNil covers the nil-warn path:
// a registry built without an injected warn (every test call site, and
// any future caller that forgets) must still emit the line through log
// rather than panicking or dropping it silently.
func TestRegistryWarnf_FallsBackToLogWhenWarnNil(t *testing.T) {
var got []string
reg, err := newModelRegistry(map[string]providerAdapter{}, CreateConfig(), func(format string, args ...any) {
got = append(got, fmt.Sprintf(format, args...))
})
if err != nil {
t.Fatalf("newModelRegistry: %v", err)
}
if reg.warn != nil {
t.Fatal("newModelRegistry must leave warn nil; it is injected by the caller")
}
reg.warnCollisionOnce("m1", "p1", []string{"p1", "p2"})
if len(got) != 1 {
t.Fatalf("log calls = %d, want 1 (the fallback path), got %v", len(got), got)
}
if !strings.Contains(got[0], `model id "m1" is provided by multiple providers`) {
t.Errorf("fallback line = %q, want the collision message", got[0])
}
}