Skip to content

Commit 0a24e6a

Browse files
committed
feat(api): Return 404 when model is not found except for model names in HF format
Signed-off-by: Richard Palethorpe <io@richiejp.com>
1 parent 8da7212 commit 0a24e6a

4 files changed

Lines changed: 250 additions & 69 deletions

File tree

core/http/app_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1065,7 +1065,7 @@ parameters:
10651065
It("returns errors", func() {
10661066
_, err := client.CreateCompletion(context.TODO(), openai.CompletionRequest{Model: "foomodel", Prompt: testPrompt})
10671067
Expect(err).To(HaveOccurred())
1068-
Expect(err.Error()).To(ContainSubstring("error, status code: 500, status: 500 Internal Server Error, message: could not load model - all backends returned error:"))
1068+
Expect(err.Error()).To(ContainSubstring("error, status code: 404, status: 404 Not Found"))
10691069
})
10701070

10711071
It("shows the external backend", func() {

core/http/middleware/request.go

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -140,13 +140,31 @@ func (re *RequestExtractor) SetModelAndConfig(initializer func() schema.LocalAIR
140140
}
141141
}
142142

143-
cfg, err := re.modelConfigLoader.LoadModelConfigFileByNameDefaultOptions(input.ModelName(nil), re.applicationConfig)
143+
modelName := input.ModelName(nil)
144+
cfg, err := re.modelConfigLoader.LoadModelConfigFileByNameDefaultOptions(modelName, re.applicationConfig)
144145

145146
if err != nil {
146-
xlog.Warn("Model Configuration File not found", "model", input.ModelName(nil), "error", err)
147-
} else if cfg.Model == "" && input.ModelName(nil) != "" {
148-
xlog.Debug("config does not include model, using input", "input.ModelName", input.ModelName(nil))
149-
cfg.Model = input.ModelName(nil)
147+
xlog.Warn("Model Configuration File not found", "model", modelName, "error", err)
148+
} else if cfg.Model == "" && modelName != "" {
149+
xlog.Debug("config does not include model, using input", "input.ModelName", modelName)
150+
cfg.Model = modelName
151+
}
152+
153+
// If a model name was specified, verify it actually exists before proceeding.
154+
// Check both configured models and loose model files in the model path.
155+
// Skip the check for HuggingFace model IDs (contain "/") since backends
156+
// like diffusers may download these on the fly.
157+
if modelName != "" && !strings.Contains(modelName, "/") {
158+
exists, existsErr := services.CheckIfModelExists(re.modelConfigLoader, re.modelLoader, modelName, services.ALWAYS_INCLUDE)
159+
if existsErr == nil && !exists {
160+
return c.JSON(http.StatusNotFound, schema.ErrorResponse{
161+
Error: &schema.APIError{
162+
Message: fmt.Sprintf("model %q not found. To see available models, call GET /v1/models", modelName),
163+
Code: http.StatusNotFound,
164+
Type: "invalid_request_error",
165+
},
166+
})
167+
}
150168
}
151169

152170
c.Set(CONTEXT_LOCALS_KEY_LOCALAI_REQUEST, input)
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package middleware_test
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/labstack/echo/v4"
12+
"github.com/mudler/LocalAI/core/config"
13+
. "github.com/mudler/LocalAI/core/http/middleware"
14+
"github.com/mudler/LocalAI/core/schema"
15+
"github.com/mudler/LocalAI/pkg/model"
16+
"github.com/mudler/LocalAI/pkg/system"
17+
. "github.com/onsi/ginkgo/v2"
18+
. "github.com/onsi/gomega"
19+
)
20+
21+
// newRequestApp creates a minimal Echo app with SetModelAndConfig middleware.
22+
func newRequestApp(re *RequestExtractor) *echo.Echo {
23+
e := echo.New()
24+
e.POST("/v1/chat/completions",
25+
func(c echo.Context) error {
26+
return c.String(http.StatusOK, "ok")
27+
},
28+
re.SetModelAndConfig(func() schema.LocalAIRequest {
29+
return new(schema.OpenAIRequest)
30+
}),
31+
)
32+
return e
33+
}
34+
35+
func postJSON(e *echo.Echo, path, body string) *httptest.ResponseRecorder {
36+
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
37+
req.Header.Set("Content-Type", "application/json")
38+
rec := httptest.NewRecorder()
39+
e.ServeHTTP(rec, req)
40+
return rec
41+
}
42+
43+
var _ = Describe("SetModelAndConfig middleware", func() {
44+
var (
45+
app *echo.Echo
46+
modelDir string
47+
)
48+
49+
BeforeEach(func() {
50+
var err error
51+
modelDir, err = os.MkdirTemp("", "localai-test-models-*")
52+
Expect(err).ToNot(HaveOccurred())
53+
54+
ss := &system.SystemState{
55+
Model: system.Model{ModelsPath: modelDir},
56+
}
57+
appConfig := config.NewApplicationConfig()
58+
appConfig.SystemState = ss
59+
60+
mcl := config.NewModelConfigLoader(modelDir)
61+
ml := model.NewModelLoader(ss)
62+
63+
re := NewRequestExtractor(mcl, ml, appConfig)
64+
app = newRequestApp(re)
65+
})
66+
67+
AfterEach(func() {
68+
os.RemoveAll(modelDir)
69+
})
70+
71+
Context("when the model does not exist", func() {
72+
It("returns 404 with a helpful error message", func() {
73+
rec := postJSON(app, "/v1/chat/completions",
74+
`{"model":"nonexistent-model","messages":[{"role":"user","content":"hi"}]}`)
75+
76+
Expect(rec.Code).To(Equal(http.StatusNotFound))
77+
78+
var resp schema.ErrorResponse
79+
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
80+
Expect(resp.Error).ToNot(BeNil())
81+
Expect(resp.Error.Message).To(ContainSubstring("nonexistent-model"))
82+
Expect(resp.Error.Message).To(ContainSubstring("not found"))
83+
Expect(resp.Error.Type).To(Equal("invalid_request_error"))
84+
})
85+
})
86+
87+
Context("when the model exists as a config file", func() {
88+
BeforeEach(func() {
89+
cfgContent := []byte("name: test-model\nbackend: llama-cpp\n")
90+
err := os.WriteFile(filepath.Join(modelDir, "test-model.yaml"), cfgContent, 0644)
91+
Expect(err).ToNot(HaveOccurred())
92+
})
93+
94+
It("passes through to the handler", func() {
95+
rec := postJSON(app, "/v1/chat/completions",
96+
`{"model":"test-model","messages":[{"role":"user","content":"hi"}]}`)
97+
98+
Expect(rec.Code).To(Equal(http.StatusOK))
99+
})
100+
})
101+
102+
Context("when the model exists as a pre-loaded config", func() {
103+
var mcl *config.ModelConfigLoader
104+
105+
BeforeEach(func() {
106+
// Simulate a model installed via gallery: config is loaded in memory
107+
// (not just a YAML file on disk). Recreate the app with the pre-loaded config.
108+
ss := &system.SystemState{
109+
Model: system.Model{ModelsPath: modelDir},
110+
}
111+
appConfig := config.NewApplicationConfig()
112+
appConfig.SystemState = ss
113+
114+
mcl = config.NewModelConfigLoader(modelDir)
115+
// Pre-load a config as if installed via gallery
116+
cfgContent := []byte("name: gallery-model\nbackend: llama-cpp\nmodel: gallery-model\n")
117+
err := os.WriteFile(filepath.Join(modelDir, "gallery-model.yaml"), cfgContent, 0644)
118+
Expect(err).ToNot(HaveOccurred())
119+
Expect(mcl.ReadModelConfig(filepath.Join(modelDir, "gallery-model.yaml"))).To(Succeed())
120+
121+
ml := model.NewModelLoader(ss)
122+
re := NewRequestExtractor(mcl, ml, appConfig)
123+
app = newRequestApp(re)
124+
})
125+
126+
It("passes through to the handler", func() {
127+
rec := postJSON(app, "/v1/chat/completions",
128+
`{"model":"gallery-model","messages":[{"role":"user","content":"hi"}]}`)
129+
130+
Expect(rec.Code).To(Equal(http.StatusOK))
131+
})
132+
})
133+
134+
Context("when the model name contains a slash (HuggingFace ID)", func() {
135+
It("skips the existence check and passes through", func() {
136+
rec := postJSON(app, "/v1/chat/completions",
137+
`{"model":"stabilityai/stable-diffusion-xl-base-1.0","messages":[{"role":"user","content":"hi"}]}`)
138+
139+
Expect(rec.Code).To(Equal(http.StatusOK))
140+
})
141+
})
142+
143+
Context("when no model is specified", func() {
144+
It("passes through without checking", func() {
145+
rec := postJSON(app, "/v1/chat/completions",
146+
`{"messages":[{"role":"user","content":"hi"}]}`)
147+
148+
// No model name → middleware doesn't reject, handler runs
149+
Expect(rec.Code).To(Equal(http.StatusOK))
150+
})
151+
})
152+
})

core/services/list_models.go

Lines changed: 74 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,74 @@
1-
package services
2-
3-
import (
4-
"github.com/mudler/LocalAI/core/config"
5-
"github.com/mudler/LocalAI/pkg/model"
6-
)
7-
8-
type LooseFilePolicy int
9-
10-
const (
11-
LOOSE_ONLY LooseFilePolicy = iota
12-
SKIP_IF_CONFIGURED
13-
SKIP_ALWAYS
14-
ALWAYS_INCLUDE
15-
)
16-
17-
func ListModels(bcl *config.ModelConfigLoader, ml *model.ModelLoader, filter config.ModelConfigFilterFn, looseFilePolicy LooseFilePolicy) ([]string, error) {
18-
19-
var skipMap map[string]interface{} = map[string]interface{}{}
20-
21-
dataModels := []string{}
22-
23-
// Start with known configurations
24-
25-
for _, c := range bcl.GetModelConfigsByFilter(filter) {
26-
// Is this better than looseFilePolicy <= SKIP_IF_CONFIGURED ? less performant but more readable?
27-
if (looseFilePolicy == SKIP_IF_CONFIGURED) || (looseFilePolicy == LOOSE_ONLY) {
28-
skipMap[c.Model] = nil
29-
}
30-
if looseFilePolicy != LOOSE_ONLY {
31-
dataModels = append(dataModels, c.Name)
32-
}
33-
}
34-
35-
// Then iterate through the loose files if requested.
36-
if looseFilePolicy != SKIP_ALWAYS {
37-
38-
models, err := ml.ListFilesInModelPath()
39-
if err != nil {
40-
return nil, err
41-
}
42-
for _, m := range models {
43-
// And only adds them if they shouldn't be skipped.
44-
if _, exists := skipMap[m]; !exists && filter(m, nil) {
45-
dataModels = append(dataModels, m)
46-
}
47-
}
48-
}
49-
50-
return dataModels, nil
51-
}
52-
53-
func CheckIfModelExists(bcl *config.ModelConfigLoader, ml *model.ModelLoader, modelName string, looseFilePolicy LooseFilePolicy) (bool, error) {
54-
filter, err := config.BuildNameFilterFn(modelName)
55-
if err != nil {
56-
return false, err
57-
}
58-
models, err := ListModels(bcl, ml, filter, looseFilePolicy)
59-
if err != nil {
60-
return false, err
61-
}
62-
return (len(models) > 0), nil
63-
}
1+
package services
2+
3+
import (
4+
"github.com/mudler/LocalAI/core/config"
5+
"github.com/mudler/LocalAI/pkg/model"
6+
)
7+
8+
type LooseFilePolicy int
9+
10+
const (
11+
LOOSE_ONLY LooseFilePolicy = iota
12+
SKIP_IF_CONFIGURED
13+
SKIP_ALWAYS
14+
ALWAYS_INCLUDE
15+
)
16+
17+
func ListModels(bcl *config.ModelConfigLoader, ml *model.ModelLoader, filter config.ModelConfigFilterFn, looseFilePolicy LooseFilePolicy) ([]string, error) {
18+
19+
var skipMap map[string]interface{} = map[string]interface{}{}
20+
21+
dataModels := []string{}
22+
23+
// Start with known configurations
24+
25+
for _, c := range bcl.GetModelConfigsByFilter(filter) {
26+
// Is this better than looseFilePolicy <= SKIP_IF_CONFIGURED ? less performant but more readable?
27+
if (looseFilePolicy == SKIP_IF_CONFIGURED) || (looseFilePolicy == LOOSE_ONLY) {
28+
skipMap[c.Model] = nil
29+
}
30+
if looseFilePolicy != LOOSE_ONLY {
31+
dataModels = append(dataModels, c.Name)
32+
}
33+
}
34+
35+
// Then iterate through the loose files if requested.
36+
if looseFilePolicy != SKIP_ALWAYS {
37+
38+
models, err := ml.ListFilesInModelPath()
39+
if err != nil {
40+
return nil, err
41+
}
42+
for _, m := range models {
43+
// And only adds them if they shouldn't be skipped.
44+
if _, exists := skipMap[m]; !exists && filter(m, nil) {
45+
dataModels = append(dataModels, m)
46+
}
47+
}
48+
}
49+
50+
return dataModels, nil
51+
}
52+
53+
func CheckIfModelExists(bcl *config.ModelConfigLoader, ml *model.ModelLoader, modelName string, looseFilePolicy LooseFilePolicy) (bool, error) {
54+
filter, err := config.BuildNameFilterFn(modelName)
55+
if err != nil {
56+
return false, err
57+
}
58+
models, err := ListModels(bcl, ml, filter, looseFilePolicy)
59+
if err != nil {
60+
return false, err
61+
}
62+
if len(models) > 0 {
63+
return true, nil
64+
}
65+
66+
// ListModels may not find raw model weight files (e.g. .ggml, .gguf)
67+
// because ListFilesInModelPath skips known weight-file extensions.
68+
// Fall back to checking if the file exists directly in the model path.
69+
if ml.ExistsInModelPath(modelName) {
70+
return true, nil
71+
}
72+
73+
return false, nil
74+
}

0 commit comments

Comments
 (0)