feat: operator-defined model aliases

Spec §5 (v0.2): Config.ModelAliases map[string]string, alias-first
resolution precedence ahead of provider-prefix splitting, alias-OR-target
authorization, lazy target resolution with a 404 naming both ids,
/v1/models listing, admin overview alias table (JSON + dashboard), and
construction-time validation (shape, prefix-shadow, explicit-model
collision, chain, empty target).

routes_unified.go/routes_media.go needed no resolution-call-site changes
- both already pass the client's raw requested id through to
registry.resolve, so aliases inherit the __alias echo and multipart
rewrite machinery for free. cache.go needed no changes either - cacheKey
already hashes requestedModel, so two aliases resolving to the same
target already produce distinct cache entries.

Adds registry_test.go validation table + precedence/authz/lazy-404/
listing coverage, a dedicated cache-key split test, chat/embeddings/
images/audio-transcriptions end-to-end tests, an extended admin overview
test, and an integration test covering the openai-type vs anthropic-type
echo asymmetry under real Traefik+Yaegi.
This commit is contained in:
2026-08-20 20:56:01 +01:00
parent f49c51b341
commit 2090b31ae2
12 changed files with 1221 additions and 15 deletions
+126
View File
@@ -166,6 +166,38 @@ func TestHandleImagesGenerations_OpenAI_HappyPath_NativeForward(t *testing.T) {
}
}
// TestHandleImagesGenerations_ModelAlias_ResolvesToTargetUpstreamModel
// proves an operator-defined modelAliases entry (spec §5, v0.2) routes
// /v1/images/generations exactly like a bare or provider-prefixed id: the
// upstream call carries the target's own upstream model id, never the
// alias.
func TestHandleImagesGenerations_ModelAlias_ResolvesToTargetUpstreamModel(t *testing.T) {
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"created":1,"data":[{"b64_json":"xyz"}]}`))
}))
defer srv.Close()
cfg := newMediaTestConfig(srv.URL, "img-test")
cfg.ModelAliases = map[string]string{"aliased/image": "openai/img-test"}
gw := newMediaTestGateway(t, cfg)
body := map[string]any{"model": "aliased/image", "prompt": "a cat", "n": 1}
req := newUnifiedRequest(t, http.MethodPost, imagesGenerationsPath, "sk-alice", body)
rec := httptest.NewRecorder()
gw.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
if gotBody["model"] != "img-test" {
t.Errorf("upstream request model = %v, want the alias target's bare upstream id %q", gotBody["model"], "img-test")
}
}
// TestHandleImagesGenerations_StripsClientSuppliedAliasKey proves a
// client independently including a literal "__alias" field in its own
// JSON body (never one the gateway itself injects for a media route —
@@ -726,6 +758,100 @@ func TestHandleAudioTranscriptions_PrefixedAlias_RewritesModelFieldOnly(t *testi
}
}
// TestHandleAudioTranscriptions_ModelAlias_RewritesModelFieldToTargetUpstreamID
// mirrors TestHandleAudioTranscriptions_PrefixedAlias_RewritesModelFieldOnly
// above, but through an operator-defined modelAliases entry (spec §5,
// v0.2) rather than a bare provider-prefixed id: the client's "model"
// form field ("aliased/whisper") differs from the resolved upstream model
// id ("whisper-test"), so rewriteMultipartModel (routes_media.go) must
// rebuild the multipart body — every part copied verbatim except "model",
// which becomes the target's own upstream id. The alias must never reach
// the real provider, the same invariant every other unified route
// already enforces.
func TestHandleAudioTranscriptions_ModelAlias_RewritesModelFieldToTargetUpstreamID(t *testing.T) {
const fileContent = "FAKEAUDIOBYTES-DISTINCTIVE-PAYLOAD"
var gotContentType string
var gotBody []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotContentType = r.Header.Get("Content-Type")
gotBody, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"text":"ok"}`))
}))
defer srv.Close()
cfg := newMediaTestConfig(srv.URL, "whisper-test")
cfg.ModelAliases = map[string]string{"aliased/whisper": "openai/whisper-test"}
gw := newMediaTestGateway(t, cfg)
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
ffw, err := mw.CreateFormFile("file", "audio.wav")
if err != nil {
t.Fatalf("CreateFormFile: %v", err)
}
if _, err = ffw.Write([]byte(fileContent)); err != nil {
t.Fatalf("write file field: %v", err)
}
mfw, err := mw.CreateFormField("model")
if err != nil {
t.Fatalf("CreateFormField(model): %v", err)
}
if _, err = mfw.Write([]byte("aliased/whisper")); err != nil {
t.Fatalf("write model field: %v", err)
}
if err = mw.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, audioTranscriptionsPath, &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Authorization", "Bearer sk-alice")
rec := httptest.NewRecorder()
gw.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
_, params, err := mime.ParseMediaType(gotContentType)
if err != nil {
t.Fatalf("upstream Content-Type %q did not parse: %v", gotContentType, err)
}
mr := multipart.NewReader(bytes.NewReader(gotBody), params["boundary"])
var gotModel string
var gotFile []byte
var sawModel, sawFile bool
for {
part, perr := mr.NextPart()
if perr == io.EOF { //nolint:errorlint // multipart.Reader.NextPart's own contract returns io.EOF bare
break
}
if perr != nil {
t.Fatalf("parse upstream multipart body: %v", perr)
}
switch part.FormName() {
case "model":
b, _ := io.ReadAll(part)
gotModel = string(b)
sawModel = true
case "file":
b, _ := io.ReadAll(part)
gotFile = b
sawFile = true
}
_ = part.Close()
}
if !sawModel || gotModel != "whisper-test" {
t.Errorf("upstream model field = %q (present=%v), want the alias target's bare upstream id %q", gotModel, sawModel, "whisper-test")
}
if !sawFile || string(gotFile) != fileContent {
t.Errorf("upstream file part = %q (present=%v), want unchanged %q", gotFile, sawFile, fileContent)
}
}
// TestHandleAudioTranscriptions_OversizeBody_413 proves a multipart body
// above maxRequestBytes is rejected with 413 (spec §3), the one media
// route with an explicit oversize check — unlike images/speech's shared