mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-20 14:49:09 -04:00
feat(middleware): resolve model aliases and stamp requested/served identity
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
@@ -167,6 +167,27 @@ func (re *RequestExtractor) SetModelAndConfig(initializer func() schema.LocalAIR
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve a model alias to its target before the disabled check and
|
||||
// before storing MODEL_CONFIG, so every modality (chat, embeddings,
|
||||
// tts, image, ...) inherits redirection. The response keeps echoing
|
||||
// the alias name (input.ModelName is left unchanged); usage accounting
|
||||
// records requested=alias / served=target.
|
||||
if cfg != nil && cfg.IsAlias() {
|
||||
resolved, _, aliasErr := re.modelConfigLoader.ResolveAlias(cfg)
|
||||
if aliasErr != nil {
|
||||
return c.JSON(http.StatusBadRequest, schema.ErrorResponse{
|
||||
Error: &schema.APIError{
|
||||
Message: aliasErr.Error(),
|
||||
Code: http.StatusBadRequest,
|
||||
Type: "invalid_request_error",
|
||||
},
|
||||
})
|
||||
}
|
||||
c.Set(ContextKeyRequestedModel, modelName)
|
||||
c.Set(ContextKeyServedModel, resolved.Name)
|
||||
cfg = resolved
|
||||
}
|
||||
|
||||
// Check if the model is disabled
|
||||
if cfg != nil && cfg.IsDisabled() {
|
||||
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
|
||||
|
||||
@@ -151,6 +151,107 @@ var _ = Describe("SetModelAndConfig middleware", func() {
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SetModelAndConfig - model alias resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// An alias config (`alias: <target>`) is a pure redirect: the middleware must
|
||||
// swap MODEL_CONFIG to the target config before the disabled check and before
|
||||
// storing it, while leaving the response-facing model name as the alias. It
|
||||
// also stamps routing.requested_model = alias and routing.served_model =
|
||||
// target so usage accounting records both identities.
|
||||
var _ = Describe("SetModelAndConfig alias resolution", func() {
|
||||
var (
|
||||
modelDir string
|
||||
capturedConfig *config.ModelConfig
|
||||
capturedReq any
|
||||
capturedServed any
|
||||
app *echo.Echo
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
modelDir, err = os.MkdirTemp("", "localai-alias-*")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
_ = os.RemoveAll(modelDir)
|
||||
})
|
||||
|
||||
// buildApp seeds the loader from every YAML in modelDir (so an alias's
|
||||
// target is present in the loader map) and wires a handler that captures
|
||||
// the resolved config plus the stamped identity keys.
|
||||
buildApp := func() *echo.Echo {
|
||||
ss := &system.SystemState{Model: system.Model{ModelsPath: modelDir}}
|
||||
appConfig := config.NewApplicationConfig()
|
||||
appConfig.SystemState = ss
|
||||
|
||||
mcl := config.NewModelConfigLoader(modelDir)
|
||||
Expect(mcl.LoadModelConfigsFromPath(modelDir)).To(Succeed())
|
||||
ml := model.NewModelLoader(ss)
|
||||
re := NewRequestExtractor(mcl, ml, appConfig)
|
||||
|
||||
capturedConfig = nil
|
||||
capturedReq = nil
|
||||
capturedServed = nil
|
||||
e := echo.New()
|
||||
e.POST("/v1/chat/completions",
|
||||
func(c echo.Context) error {
|
||||
if cfg, ok := c.Get(CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig); ok {
|
||||
capturedConfig = cfg
|
||||
}
|
||||
capturedReq = c.Get(ContextKeyRequestedModel)
|
||||
capturedServed = c.Get(ContextKeyServedModel)
|
||||
return c.String(http.StatusOK, "ok")
|
||||
},
|
||||
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OpenAIRequest) }),
|
||||
)
|
||||
return e
|
||||
}
|
||||
|
||||
It("serves the target config but keeps the alias name and stamps identity", func() {
|
||||
Expect(os.WriteFile(filepath.Join(modelDir, "real.yaml"),
|
||||
[]byte("name: real\nbackend: llama-cpp\n"), 0644)).To(Succeed())
|
||||
Expect(os.WriteFile(filepath.Join(modelDir, "gpt-4.yaml"),
|
||||
[]byte("name: gpt-4\nalias: real\n"), 0644)).To(Succeed())
|
||||
app = buildApp()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions",
|
||||
strings.NewReader(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
app.ServeHTTP(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
Expect(capturedConfig).ToNot(BeNil())
|
||||
// MODEL_CONFIG must be the target, not the alias stub.
|
||||
Expect(capturedConfig.Name).To(Equal("real"))
|
||||
Expect(capturedConfig.IsAlias()).To(BeFalse())
|
||||
// Identity stamps: requested = alias, served = target.
|
||||
Expect(capturedReq).To(Equal("gpt-4"))
|
||||
Expect(capturedServed).To(Equal("real"))
|
||||
})
|
||||
|
||||
It("returns 400 when the alias target is missing", func() {
|
||||
Expect(os.WriteFile(filepath.Join(modelDir, "gpt-4.yaml"),
|
||||
[]byte("name: gpt-4\nalias: nope\n"), 0644)).To(Succeed())
|
||||
app = buildApp()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions",
|
||||
strings.NewReader(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
app.ServeHTTP(rec, req)
|
||||
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
var resp schema.ErrorResponse
|
||||
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
|
||||
Expect(resp.Error).ToNot(BeNil())
|
||||
Expect(resp.Error.Type).To(Equal("invalid_request_error"))
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MergeOpenResponsesConfig — tool_choice parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -189,7 +189,12 @@ func RouteModel(loader *config.ModelConfigLoader, appConfig *config.ApplicationC
|
||||
}
|
||||
|
||||
c.Set(CONTEXT_LOCALS_KEY_MODEL_CONFIG, result.ChosenConfig)
|
||||
c.Set(ContextKeyRequestedModel, result.RouterModel)
|
||||
// Preserve an upstream requested model (e.g. an alias that points
|
||||
// at this router model) so accounting keeps the name the client
|
||||
// actually sent. Served always reflects the final candidate.
|
||||
if c.Get(ContextKeyRequestedModel) == nil {
|
||||
c.Set(ContextKeyRequestedModel, result.RouterModel)
|
||||
}
|
||||
c.Set(ContextKeyServedModel, result.ChosenModel)
|
||||
|
||||
if store != nil {
|
||||
|
||||
Reference in New Issue
Block a user