mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-22 06:06:59 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user