From 7420e2e71ba80588f0e666f58bb0c44f52fbe259 Mon Sep 17 00:00:00 2001
From: "Plamen K. Kosseff"
Date: Mon, 5 Oct 2026 13:57:42 +0000
Subject: [PATCH] feat(models): route manual imports with remote assets through
the gallery job queue
Manual /models/import with downloadable assets ran synchronously; it now
enqueues like a gallery install and the import UI polls the job progress.
Assisted-by: Claude:claude-fable-5
Signed-off-by: Plamen K. Kosseff
---
.../http/endpoints/localai/edit_model_test.go | 2 +-
core/http/endpoints/localai/import_model.go | 52 ++++++++++++++++++-
core/http/react-ui/src/pages/ImportModel.jsx | 38 +++++++++-----
core/http/routes/localai.go | 2 +-
4 files changed, 79 insertions(+), 15 deletions(-)
diff --git a/core/http/endpoints/localai/edit_model_test.go b/core/http/endpoints/localai/edit_model_test.go
index 223943e46..e09283422 100644
--- a/core/http/endpoints/localai/edit_model_test.go
+++ b/core/http/endpoints/localai/edit_model_test.go
@@ -183,7 +183,7 @@ var _ = Describe("Edit Model test", func() {
app := echo.New()
// Set up a simple renderer for the test
app.Renderer = &testRenderer{}
- app.POST("/import-model", ImportModelEndpoint(modelConfigLoader, nil, applicationConfig))
+ app.POST("/import-model", ImportModelEndpoint(modelConfigLoader, nil, applicationConfig, nil))
app.GET("/edit-model/:name", GetEditModelPage(modelConfigLoader, applicationConfig))
requestBody := bytes.NewBufferString(`{"name": "foo", "backend": "foo", "model": "foo"}`)
diff --git a/core/http/endpoints/localai/import_model.go b/core/http/endpoints/localai/import_model.go
index 73448430a..45767a628 100644
--- a/core/http/endpoints/localai/import_model.go
+++ b/core/http/endpoints/localai/import_model.go
@@ -125,7 +125,7 @@ func ImportModelURIEndpoint(cl *config.ModelConfigLoader, appConfig *config.Appl
}
// ImportModelEndpoint handles creating new model configurations
-func ImportModelEndpoint(cl *config.ModelConfigLoader, gs *galleryop.GalleryService, appConfig *config.ApplicationConfig) echo.HandlerFunc {
+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
body, err := io.ReadAll(c.Request().Body)
@@ -226,6 +226,37 @@ func ImportModelEndpoint(cl *config.ModelConfigLoader, gs *galleryop.GalleryServ
return c.JSON(http.StatusInternalServerError, response)
}
+ // A config referencing remote assets is a download, not just a file
+ // write: route it through the gallery op queue so the UI tracks it
+ // like a gallery install (job UUID + /models/jobs/{uuid}), instead
+ // of downloading synchronously inside this request via Preload. The
+ // op writes the config file and reloads once the downloads finish;
+ // configs with no remote assets keep the fast synchronous path.
+ if files := remoteAssetFiles(&modelConfig); len(files) > 0 && gs != nil {
+ jobUUID, err := uuid.NewUUID()
+ if err != nil {
+ return err
+ }
+ if opcache != nil {
+ opcache.Set(modelConfig.Name, jobUUID.String())
+ }
+ gs.EnqueueModelOp(galleryop.ManagementOp[gallery.GalleryModel, gallery.ModelConfig]{
+ Req: gallery.GalleryModel{Overrides: map[string]any{}},
+ ID: jobUUID.String(),
+ GalleryElementName: modelConfig.Name,
+ GalleryElement: &gallery.ModelConfig{
+ Name: modelConfig.Name,
+ ConfigFile: string(yamlData),
+ Files: files,
+ },
+ BackendGalleries: appConfig.BackendGalleries,
+ })
+ return c.JSON(200, schema.GalleryResponse{
+ ID: jobUUID.String(),
+ StatusURL: fmt.Sprintf("%smodels/jobs/%s", httpUtils.BaseURL(c), jobUUID.String()),
+ })
+ }
+
// Write the file
if err := os.WriteFile(configPath, yamlData, 0644); err != nil {
response := ModelResponse{
@@ -267,3 +298,22 @@ func ImportModelEndpoint(cl *config.ModelConfigLoader, gs *galleryop.GalleryServ
return c.JSON(200, response)
}
}
+
+// remoteAssetFiles lists the downloads a config implies: explicit
+// download_files entries, and URI-valued model/mmproj fields under the
+// same on-disk names the config loader's preload would give them — so
+// the later preload finds the files present and the config needs no
+// rewriting.
+func remoteAssetFiles(c *config.ModelConfig) []gallery.File {
+ var files []gallery.File
+ for _, f := range c.DownloadFiles {
+ files = append(files, gallery.File{Filename: f.Filename, SHA256: f.SHA256, URI: string(f.URI)})
+ }
+ if c.IsModelURL() {
+ files = append(files, gallery.File{Filename: c.ModelFileName(), URI: c.Model})
+ }
+ if c.IsMMProjURL() {
+ files = append(files, gallery.File{Filename: c.MMProjFileName(), URI: c.MMProj})
+ }
+ return files
+}
diff --git a/core/http/react-ui/src/pages/ImportModel.jsx b/core/http/react-ui/src/pages/ImportModel.jsx
index c66706f96..cf1345b89 100644
--- a/core/http/react-ui/src/pages/ImportModel.jsx
+++ b/core/http/react-ui/src/pages/ImportModel.jsx
@@ -241,16 +241,6 @@ export default function ImportModel() {
pollRef.current = setInterval(async () => {
try {
const data = await modelsApi.getJobStatus(jobId)
- if (data.completed) {
- transferRateRef.current.reset(jobId)
- clearInterval(pollRef.current)
- pollRef.current = null
- setIsSubmitting(false)
- setJob(null)
- addToast(t('toasts.imported'), 'success')
- navigate('/app/models?view=installed')
- return
- }
if (data.error || (data.message && data.message.startsWith('error:'))) {
transferRateRef.current.reset(jobId)
clearInterval(pollRef.current)
@@ -265,6 +255,20 @@ export default function ImportModel() {
addToast(t('toasts.importFailed', { message: msg }), 'error')
return
}
+ // The raw /models/jobs endpoint reports `processed`, not the
+ // `completed` the /api/models/job shim synthesizes — checking
+ // only `completed` left the poll spinning after the job ended.
+ // Errors are handled above: failed jobs also set `processed`.
+ if (data.completed || data.processed) {
+ transferRateRef.current.reset(jobId)
+ clearInterval(pollRef.current)
+ pollRef.current = null
+ setIsSubmitting(false)
+ setJob(null)
+ addToast(t('toasts.imported'), 'success')
+ navigate('/app/models?view=installed')
+ return
+ }
// Keep the whole status. /api/operations carries the same job (the
// import endpoint registers it in the opcache) but drops it the moment
// it finishes, which is indistinguishable from a cancel — so terminal
@@ -382,12 +386,22 @@ export default function ImportModel() {
if (!yamlContent.trim()) { addToast(t('toasts.noYaml'), 'error'); return }
setIsSubmitting(true)
try {
- await modelsApi.importConfig(yamlContent, 'application/x-yaml')
+ const result = await modelsApi.importConfig(yamlContent, 'application/x-yaml')
+ // A config referencing remote assets comes back as a queued job
+ // (same shape as the URI import) and startJobPolling owns the
+ // submitting state until the job ends; a config-only import
+ // completes synchronously and navigates straight to the model list.
+ const jobId = result?.uuid || result?.ID
+ if (jobId) {
+ addToast(t('toasts.started'), 'success')
+ startJobPolling(jobId)
+ return
+ }
addToast(t('toasts.importedYaml'), 'success')
navigate('/app/models?view=installed')
+ setIsSubmitting(false)
} catch (err) {
addToast(t('toasts.importFailed', { message: err.message }), 'error')
- } finally {
setIsSubmitting(false)
}
}
diff --git a/core/http/routes/localai.go b/core/http/routes/localai.go
index b2bfed146..0fa6e2d5f 100644
--- a/core/http/routes/localai.go
+++ b/core/http/routes/localai.go
@@ -80,7 +80,7 @@ func RegisterLocalAIRoutes(router *echo.Echo,
router.POST("/backends/upgrades/check", backendGalleryEndpointService.CheckUpgradesEndpoint(), adminMiddleware)
router.POST("/backends/upgrade/:name", backendGalleryEndpointService.UpgradeBackendEndpoint(), adminMiddleware)
// Custom model import endpoint
- router.POST("/models/import", localai.ImportModelEndpoint(cl, galleryService, appConfig), adminMiddleware)
+ router.POST("/models/import", localai.ImportModelEndpoint(cl, galleryService, appConfig, opcache), adminMiddleware)
// URI model import endpoint
router.POST("/models/import-uri", localai.ImportModelURIEndpoint(cl, appConfig, galleryService, opcache), adminMiddleware)