From a50eb4e68a4be81cfbfcc4a61ff4e73b01f3129c Mon Sep 17 00:00:00 2001 From: "Plamen K. Kosseff" Date: Mon, 5 Oct 2026 16:49:27 +0000 Subject: [PATCH] =?UTF-8?q?docs(models):=20document=20the=20queued=20manua?= =?UTF-8?q?l=20imports=20=E2=80=94=20swagger,=20specs,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - swagger: annotate POST /models/import (untyped body: documenting it as config.ModelConfig would inline the whole type graph, ~5.5k generated lines; the description spells out the dual response) and regenerate the spec - tests: pin the admission contract of the queued branch — job id + status URL, job queryable before the worker runs, config not written at admission, preload-matching filenames, checksum passthrough, and the untouched synchronous path - docs: the Writing YAML section explains when Create queues and what the API answers in each case Assisted-by: Claude:claude-fable-5 Signed-off-by: Plamen K. Kosseff (cherry picked from commit aec658a9981dad4f00176c9a6398a5596b1f41a8) --- core/http/endpoints/localai/import_model.go | 14 ++++ .../endpoints/localai/import_model_test.go | 79 +++++++++++++++++++ docs/content/getting-started/models.md | 14 ++++ swagger/docs.go | 40 ++++++++++ swagger/swagger.json | 40 ++++++++++ swagger/swagger.yaml | 32 ++++++++ 6 files changed, 219 insertions(+) diff --git a/core/http/endpoints/localai/import_model.go b/core/http/endpoints/localai/import_model.go index 45767a628..f9e9d9fc5 100644 --- a/core/http/endpoints/localai/import_model.go +++ b/core/http/endpoints/localai/import_model.go @@ -125,6 +125,20 @@ func ImportModelURIEndpoint(cl *config.ModelConfigLoader, appConfig *config.Appl } // ImportModelEndpoint handles creating new model configurations +// @Summary Import a model from a raw YAML or JSON configuration +// @Description Writes the posted model configuration. A config referencing +// @Description remote assets (download_files entries, URI-valued model/mmproj) +// @Description is enqueued on the gallery job queue and answered with a job id +// @Description plus status URL to poll, like a gallery install; a config with +// @Description no downloads is written synchronously and answered with a +// @Description plain success response. +// @Tags models +// @Accept json +// @Produce json +// @Param request body object true "model configuration (YAML or JSON body)" +// @Success 200 {object} schema.GalleryResponse "remote assets present: job id and status URL" +// @Failure 400 {object} localai.ModelResponse "invalid or incomplete configuration" +// @Router /models/import [post] func ImportModelEndpoint(cl *config.ModelConfigLoader, gs *galleryop.GalleryService, appConfig *config.ApplicationConfig, opcache *galleryop.OpCache) echo.HandlerFunc { return func(c echo.Context) error { // Get the raw body diff --git a/core/http/endpoints/localai/import_model_test.go b/core/http/endpoints/localai/import_model_test.go index 96c20160e..7829af3b0 100644 --- a/core/http/endpoints/localai/import_model_test.go +++ b/core/http/endpoints/localai/import_model_test.go @@ -7,9 +7,11 @@ import ( "net/http" "net/http/httptest" "os" + "path/filepath" "github.com/labstack/echo/v4" "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/core/gallery" . "github.com/mudler/LocalAI/core/http/endpoints/localai" "github.com/mudler/LocalAI/core/services/galleryop" "github.com/mudler/LocalAI/pkg/model" @@ -120,3 +122,80 @@ var _ = Describe("ImportModelURIEndpoint ambiguity handling", func() { Expect(candidates).ToNot(ContainElement("vllm")) }) }) + +var _ = Describe("ImportModelEndpoint queued imports", func() { + var ( + tempDir string + app *echo.Echo + galleryService *galleryop.GalleryService + ) + + BeforeEach(func() { + tempDir = GinkgoT().TempDir() + systemState, err := system.GetSystemState(system.WithModelPath(tempDir)) + Expect(err).ToNot(HaveOccurred()) + applicationConfig := config.NewApplicationConfig(config.WithSystemState(systemState)) + modelConfigLoader := config.NewModelConfigLoader(systemState.Model.ModelsPath) + ml := model.NewModelLoader(systemState) + galleryService = galleryop.NewGalleryService(applicationConfig, ml) + // The worker is deliberately NOT started: these specs assert the + // admission contract (response shape, job visibility, op payload), + // not the download itself. + app = echo.New() + app.POST("/models/import", ImportModelEndpoint(modelConfigLoader, galleryService, applicationConfig, nil)) + }) + + post := func(body string) *httptest.ResponseRecorder { + req := httptest.NewRequest("POST", "/models/import", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + app.ServeHTTP(rec, req) + return rec + } + + It("enqueues a config whose model is a URL and answers with a pollable job", func() { + rec := post(`{"name": "remote", "backend": "llama-cpp", "parameters": {"model": "https://example.com/weights/foo.gguf"}}`) + Expect(rec.Code).To(Equal(http.StatusOK)) + + var resp map[string]any + Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed()) + Expect(resp).To(HaveKey("uuid")) + Expect(resp["status"]).To(ContainSubstring("/models/jobs/" + resp["uuid"].(string))) + + // Queryable from the instant the ID is handed out, before any + // worker picks the op up. + Expect(galleryService.GetStatus(resp["uuid"].(string))).ToNot(BeNil()) + + // The op writes the config once downloads finish; admission must + // not have written it already. + Expect(filepath.Join(tempDir, "remote.yaml")).ToNot(BeAnExistingFile()) + + var op galleryop.ManagementOp[gallery.GalleryModel, gallery.ModelConfig] + Eventually(galleryService.ModelGalleryChannel).Should(Receive(&op)) + Expect(op.GalleryElementName).To(Equal("remote")) + // Preload-matching name: the loader later finds the file under the + // name the config resolves, so the config needs no rewriting. + Expect(op.GalleryElement.Files).To(HaveLen(1)) + Expect(op.GalleryElement.Files[0].Filename).To(Equal("foo.gguf")) + Expect(op.GalleryElement.Files[0].URI).To(Equal("https://example.com/weights/foo.gguf")) + Expect(op.GalleryElement.ConfigFile).To(ContainSubstring("name: remote")) + }) + + It("carries download_files entries with their checksums into the op", func() { + rec := post(`{"name": "assets", "backend": "llama-cpp", "parameters": {"model": "weights.gguf"}, "download_files": [{"filename": "weights.gguf", "sha256": "abc123", "uri": "https://example.com/weights.gguf"}]}`) + Expect(rec.Code).To(Equal(http.StatusOK)) + var op galleryop.ManagementOp[gallery.GalleryModel, gallery.ModelConfig] + Eventually(galleryService.ModelGalleryChannel).Should(Receive(&op)) + Expect(op.GalleryElement.Files).To(HaveLen(1)) + Expect(op.GalleryElement.Files[0].SHA256).To(Equal("abc123")) + }) + + It("keeps the synchronous path for configs without remote assets", func() { + rec := post(`{"name": "local", "backend": "llama-cpp", "parameters": {"model": "already-here.gguf"}}`) + Expect(rec.Code).To(Equal(http.StatusOK)) + Expect(rec.Body.String()).To(ContainSubstring("created successfully")) + Expect(filepath.Join(tempDir, "local.yaml")).To(BeAnExistingFile()) + // Nothing was enqueued for a config with no downloads. + Consistently(galleryService.ModelGalleryChannel, "100ms").ShouldNot(Receive()) + }) +}) diff --git a/docs/content/getting-started/models.md b/docs/content/getting-started/models.md index dc0233d77..4f5234a36 100644 --- a/docs/content/getting-started/models.md +++ b/docs/content/getting-started/models.md @@ -143,6 +143,20 @@ This is especially useful for: - Setting up complex model setups - Editing existing model configurations +When the configuration references remote assets — a URL in +`parameters.model` or `mmproj`, or `download_files` entries — **Create** +does not download them inside the request: the import is queued like a +gallery installation, and the page reports the job's progress until the +model is ready. The configuration file is written once every download +has finished, so a failed download never leaves a config pointing at +files that do not exist. A configuration that only references files +already on disk is written immediately, as before. + +The same split applies to the API behind the page: `POST /models/import` +answers a queued import with a job id and a status URL to poll +(`/models/jobs/{id}` — the same shape gallery installs use), and a +download-free import with a plain success response. + ## Method 2: Installing from Hugging Face LocalAI can directly install models from Hugging Face: diff --git a/swagger/docs.go b/swagger/docs.go index 94861edbf..22c6f2864 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -2857,6 +2857,46 @@ const docTemplate = `{ } } }, + "/models/import": { + "post": { + "description": "Writes the posted model configuration. A config referencing\nremote assets (download_files entries, URI-valued model/mmproj)\nis enqueued on the gallery job queue and answered with a job id\nplus status URL to poll, like a gallery install; a config with\nno downloads is written synchronously and answered with a\nplain success response.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "models" + ], + "summary": "Import a model from a raw YAML or JSON configuration", + "parameters": [ + { + "description": "model configuration (YAML or JSON body)", + "name": "request", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "200": { + "description": "remote assets present: job id and status URL", + "schema": { + "$ref": "#/definitions/schema.GalleryResponse" + } + }, + "400": { + "description": "invalid or incomplete configuration", + "schema": { + "$ref": "#/definitions/localai.ModelResponse" + } + } + } + } + }, "/models/jobs": { "get": { "tags": [ diff --git a/swagger/swagger.json b/swagger/swagger.json index 95a87d2c3..63be870c7 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -2854,6 +2854,46 @@ } } }, + "/models/import": { + "post": { + "description": "Writes the posted model configuration. A config referencing\nremote assets (download_files entries, URI-valued model/mmproj)\nis enqueued on the gallery job queue and answered with a job id\nplus status URL to poll, like a gallery install; a config with\nno downloads is written synchronously and answered with a\nplain success response.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "models" + ], + "summary": "Import a model from a raw YAML or JSON configuration", + "parameters": [ + { + "description": "model configuration (YAML or JSON body)", + "name": "request", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "200": { + "description": "remote assets present: job id and status URL", + "schema": { + "$ref": "#/definitions/schema.GalleryResponse" + } + }, + "400": { + "description": "invalid or incomplete configuration", + "schema": { + "$ref": "#/definitions/localai.ModelResponse" + } + } + } + } + }, "/models/jobs": { "get": { "tags": [ diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 2d1703773..324261d67 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -5618,6 +5618,38 @@ paths: summary: List all Galleries tags: - models + /models/import: + post: + consumes: + - application/json + description: |- + Writes the posted model configuration. A config referencing + remote assets (download_files entries, URI-valued model/mmproj) + is enqueued on the gallery job queue and answered with a job id + plus status URL to poll, like a gallery install; a config with + no downloads is written synchronously and answered with a + plain success response. + parameters: + - description: model configuration (YAML or JSON body) + in: body + name: request + required: true + schema: + type: object + produces: + - application/json + responses: + "200": + description: 'remote assets present: job id and status URL' + schema: + $ref: '#/definitions/schema.GalleryResponse' + "400": + description: invalid or incomplete configuration + schema: + $ref: '#/definitions/localai.ModelResponse' + summary: Import a model from a raw YAML or JSON configuration + tags: + - models /models/jobs: get: responses: