fix(aliases): deterministic integration gate, warn-first fast path, deduped listing

Review fix, task 6 (1 Important + 3 folded):

1. TestModelAliases now polls GET /v1/models (waitForModelAlias) until
   "aliased/mock" appears, bounded 30s, before asserting - removes the
   stale-dynamic-config propagation window a plain healthy-200 check
   cannot rule out. Confirmed config-propagation, not alias-logic, per
   reviewer's config+error-text analysis.
2. registry.go resolve(): check the warned-set (aliasShadowWarned) before
   paying bareWinner's O(providerNames) scan on every aliased request -
   only the first request for a colliding alias pays that cost now.
3. listFor: skip an alias entry whose id already exists in owners (a real
   model already owns it) - previously produced duplicate ids when a
   discovered model collided with a configured alias. resolve's own
   alias-wins precedence is unchanged; only the listing is deduped. New
   test: TestModelRegistry_ListFor_Alias_DedupedAgainstDiscoveredCollision.
4. newAliasTestRegistry doc comment: "grp's given aliases" -> "the given
   aliases" (no grp parameter exists).
This commit is contained in:
2026-08-20 21:06:00 +01:00
parent 2090b31ae2
commit f0730a70d7
3 changed files with 141 additions and 15 deletions
+36
View File
@@ -139,6 +139,37 @@ func waitForHealthy(t *testing.T, url, apiKey string, timeout time.Duration) {
t.Fatalf("%s did not become healthy within %s (last status=%d, last error=%v)", url, timeout, lastStatus, lastErr)
}
// waitForModelAlias polls GET /v1/models on baseURL with apiKey until an
// entry with id wantID appears, or timeout elapses. Task 6 review fix: a
// plain 200 from /v1/models (waitForHealthy's own check) only proves
// Traefik's file provider has loaded SOME generation of dynamic.yml — not
// necessarily the one carrying modelAliases. A request landing in that
// narrow propagation window would see a config generation from before the
// alias applied and get a spurious "unknown model" 404, which is exactly
// what one cold-start run of this suite hit. Polling the actual listing
// removes that window deterministically instead of guessing at a fixed
// sleep.
func waitForModelAlias(t *testing.T, baseURL, apiKey, wantID string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
var lastBody map[string]any
for time.Now().Before(deadline) {
resp, body := doJSON(t, http.MethodGet, baseURL+"/v1/models", apiKey, nil)
if resp.StatusCode == http.StatusOK {
lastBody = body
if data, ok := body["data"].([]any); ok {
for _, entry := range data {
if m, ok := entry.(map[string]any); ok && m["id"] == wantID {
return
}
}
}
}
time.Sleep(500 * time.Millisecond)
}
t.Fatalf("GET %s/v1/models never listed alias %q within %s, last body=%#v", baseURL, wantID, timeout, lastBody)
}
// checkNoPluginErrors greps docker compose's captured logs for service,
// failing if no plugin-loading message appears at all, or if any
// plugin-related line is logged at error level.
@@ -248,6 +279,11 @@ func TestUnifiedChatAllProviders(t *testing.T) {
// registry_test.go's Gateway-level equivalents for the same assertion
// under go test).
func TestModelAliases(t *testing.T) {
// Review fix: wait for the alias to actually appear in /v1/models
// before asserting anything below — see waitForModelAlias's doc
// comment for why a plain healthy-200 is not enough on its own.
waitForModelAlias(t, traefik1URL, aliceKey, "aliased/mock", 30*time.Second)
// openai-type: the upstream response is forwarded verbatim
// (provider_openai.go), so its "model" field carries whatever the
// mock echoes back — the resolved upstream id "gpt-mock", not the