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: