docs(models): document the queued manual imports — swagger, specs, docs

- 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 <p.kosseff@gmail.com>
This commit is contained in:
Plamen K. Kosseff committed 2026-10-05 16:49:27 +00:00
1 parent 7420e2e71b
commit aec658a998
6 files changed
+219

No files matched your search

@@ -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
@@ -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())
})
})
+14
View File
@@ -121,6 +121,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:
+40
View File
@@ -2754,6 +2754,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": [
+40
View File
@@ -2751,6 +2751,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": [
+32
View File
@@ -5301,6 +5301,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: