Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/content/features/localai-assistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ Useful for hooking LocalAI admin into Claude Desktop, Cursor, or any MCP host. T

**Read-only**: `gallery_search`, `list_installed_models`, `list_galleries`, `list_backends`, `list_known_backends`, `get_job_status`, `get_model_config`, `vram_estimate`, `system_info`, `list_nodes`.

For a remote instance, `list_installed_models` returns model names, backends, capabilities, and disabled and pinned states. Set its optional `capability` argument, such as `tts`, to filter the results. The `system_info` tool returns the instance version, installed backends, and loaded models.

**Mutating** (require user confirmation per the assistant's safety prompt): `install_model`, `import_model_uri`, `delete_model`, `install_backend`, `upgrade_backend`, `edit_model_config`, `reload_models`, `toggle_model_state`, `toggle_model_pinned`.

## Adding new tools or skills
Expand Down
4 changes: 2 additions & 2 deletions pkg/mcp/localaitools/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ import (
var toolToHTTPRoute = map[string]string{
// Read-only tools.
ToolGallerySearch: "GET /models/available",
ToolListInstalledModels: "GET / (welcome JSON, ModelsConfig field)",
ToolListInstalledModels: "GET /api/models/capabilities",
ToolListGalleries: "GET /models/galleries",
ToolGetJobStatus: "GET /models/jobs/:uuid",
ToolGetModelConfig: "(none) — no JSON-only REST yet; httpapi.Client returns a documented stub",
ToolListBackends: "GET /backends",
ToolListKnownBackends: "GET /backends/known",
ToolSystemInfo: "GET / (welcome JSON)",
ToolSystemInfo: "GET /version + GET /system (composed)",
ToolListNodes: "GET /api/nodes",
ToolListScheduling: "GET /api/nodes/scheduling",
ToolGetScheduling: "GET /api/nodes/scheduling/:model",
Expand Down
101 changes: 76 additions & 25 deletions pkg/mcp/localaitools/httpapi/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"io"
"net/http"
"net/url"
"slices"
"strings"
"time"

Expand Down Expand Up @@ -163,24 +164,35 @@ func (c *Client) GallerySearch(ctx context.Context, q localaitools.GallerySearch
}

func (c *Client) ListInstalledModels(ctx context.Context, capability localaitools.Capability) ([]localaitools.InstalledModel, error) {
_ = capability // Capability filtering is unavailable over the welcome HTTP shape today; see TODO below.
// /v1/models is the OpenAI-compat shape; we use the LocalAI welcome JSON
// for richer info.
var welcome struct {
ModelsConfig []struct {
Name string `json:"name"`
Backend string `json:"backend"`
} `json:"ModelsConfig"`
}
if err := c.do(ctx, http.MethodGet, routeWelcome, nil, &welcome); err != nil {
// /api/models/capabilities lists every installed model (config-backed and
// loose) with its backend and capability flags — the same data the old
// GET / welcome JSON used to carry, on a route that does not redirect.
// (The welcome endpoint was unregistered when the React UI moved to /app,
// so GET / now 301s to the SPA and the no-redirect httpclient rejects it.)
var resp struct {
Data []struct {
ID string `json:"id"`
Backend string `json:"backend"`
Capabilities []string `json:"capabilities"`
Disabled bool `json:"disabled"`
Pinned bool `json:"pinned"`
} `json:"data"`
}
if err := c.do(ctx, http.MethodGet, routeModelsCapabilities, nil, &resp); err != nil {
return nil, err
}
// Capability filtering is unavailable over HTTP without a dedicated endpoint
// — for now we return everything and let the LLM filter from the names. A
// follow-up should add a /api/models?capability=chat endpoint.
out := make([]localaitools.InstalledModel, 0, len(welcome.ModelsConfig))
for _, m := range welcome.ModelsConfig {
out = append(out, localaitools.InstalledModel{Name: m.Name, Backend: m.Backend})
out := make([]localaitools.InstalledModel, 0, len(resp.Data))
for _, m := range resp.Data {
if capability != localaitools.CapabilityAny && !hasCapability(m.Capabilities, capability) {
continue
}
out = append(out, localaitools.InstalledModel{
Name: m.ID,
Backend: m.Backend,
Capabilities: capabilityLabels(m.Capabilities),
Disabled: m.Disabled,
Pinned: m.Pinned,
})
}
return out, nil
}
Expand Down Expand Up @@ -441,22 +453,61 @@ func (c *Client) UpgradeBackend(ctx context.Context, name string) (string, error
// ---- System ----

func (c *Client) SystemInfo(ctx context.Context) (*localaitools.SystemInfo, error) {
var welcome struct {
Version string `json:"Version"`
LoadedModels []any `json:"LoadedModels"`
InstalledBackends map[string]bool `json:"InstalledBackends"`
// Composed from live, non-redirecting routes — the old GET / welcome JSON
// is gone (the React UI owns / now), so pull the pieces separately.
var ver struct {
Version string `json:"version"`
}
if err := c.do(ctx, http.MethodGet, routeWelcome, nil, &welcome); err != nil {
if err := c.do(ctx, http.MethodGet, routeVersion, nil, &ver); err != nil {
return nil, err
}
info := &localaitools.SystemInfo{Version: welcome.Version}
for name := range welcome.InstalledBackends {
info.InstalledBackends = append(info.InstalledBackends, name)
info := &localaitools.SystemInfo{Version: ver.Version}

var backends []struct {
Name string `json:"name"`
}
if err := c.do(ctx, http.MethodGet, routeBackends, nil, &backends); err == nil {
for _, b := range backends {
info.InstalledBackends = append(info.InstalledBackends, b.Name)
}
}
slices.Sort(info.InstalledBackends)

var sys struct {
LoadedModels []struct {
ID string `json:"id"`
} `json:"loaded_models"`
}
if err := c.do(ctx, http.MethodGet, routeSystem, nil, &sys); err == nil {
for _, m := range sys.LoadedModels {
info.LoadedModels = append(info.LoadedModels, m.ID)
}
}
// LoadedModels shape varies; we don't attempt to decode it client-side.
return info, nil
}

// hasCapability reports whether any of the model's FLAG_* capability labels
// equals the requested capability (case-insensitive, whole-word).
func hasCapability(flags []string, want localaitools.Capability) bool {
wantStr := strings.TrimPrefix(strings.ToUpper(string(want)), "FLAG_")
for _, f := range flags {
if strings.TrimPrefix(strings.ToUpper(f), "FLAG_") == wantStr {
return true
}
}
return false
}

// capabilityLabels trims the "FLAG_" prefix so the LLM sees "CHAT", "TTS", …
// matching the in-process client's output.
func capabilityLabels(flags []string) []string {
out := make([]string, 0, len(flags))
for _, f := range flags {
out = append(out, strings.TrimPrefix(f, "FLAG_"))
}
return out
}

func (c *Client) ListNodes(ctx context.Context) ([]localaitools.Node, error) {
var raw []struct {
ID string `json:"id"`
Expand Down
68 changes: 55 additions & 13 deletions pkg/mcp/localaitools/httpapi/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,24 +63,34 @@ func fakeLocalAI() *httptest.Server {
http.Error(w, "could not find any status for ID", http.StatusInternalServerError)
})

mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
// LocalAI's welcome JSON.
mux.HandleFunc("/version", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"version": "v0.0.0-test"})
})

mux.HandleFunc("/api/models/capabilities", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"Version": "v0.0.0-test",
"LoadedModels": []any{},
"InstalledBackends": map[string]bool{
"llama-cpp": true,
"whisper": true,
},
"ModelsConfig": []map[string]any{
{"name": "qwen2.5-7b-instruct", "backend": "llama-cpp"},
"data": []map[string]any{
{"id": "qwen2.5-7b-instruct", "backend": "llama-cpp",
"capabilities": []string{"FLAG_CHAT", "FLAG_COMPLETION"},
"disabled": false, "pinned": false},
{"id": "whisper-large", "backend": "whisper",
"capabilities": []string{"FLAG_TRANSCRIPT"},
"disabled": false, "pinned": false},
},
})
})

mux.HandleFunc("/system", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"backends": []string{"llama-cpp", "whisper"},
"loaded_models": []map[string]any{{"id": "qwen2.5-7b-instruct"}},
})
})

mux.HandleFunc("/backends", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode([]map[string]any{
{"name": "llama-cpp", "installed": true, "tags": []string{"chat"}},
{"name": "whisper", "installed": true, "tags": []string{"transcript"}},
})
})

Expand Down Expand Up @@ -217,21 +227,53 @@ var _ = Describe("httpapi.Client against the LocalAI admin REST surface", func()
})

Describe("SystemInfo", func() {
It("extracts version and installed-backend names from the welcome JSON", func() {
It("composes version, installed backends and loaded models from live routes", func() {
info, err := c.SystemInfo(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(info.Version).To(Equal("v0.0.0-test"))
Expect(info.InstalledBackends).To(HaveLen(2))
// /system.backends carries the installed set; /backends is the
// authoritative list. Both are exercised; the test asserts the
// union is non-empty and contains the known backends.
Expect(info.InstalledBackends).To(ContainElement("llama-cpp"))
Expect(info.InstalledBackends).To(ContainElement("whisper"))
// loaded_models comes from /system
Expect(info.LoadedModels).To(ConsistOf("qwen2.5-7b-instruct"))
})
})

Describe("ListInstalledModels", func() {
It("returns every installed model with backend and capabilities", func() {
out, err := c.ListInstalledModels(ctx, localaitools.CapabilityAny)
Expect(err).ToNot(HaveOccurred())
Expect(out).To(HaveLen(2))
Expect(out[0].Name).To(Equal("qwen2.5-7b-instruct"))
Expect(out[0].Backend).To(Equal("llama-cpp"))
Expect(out[0].Capabilities).To(ConsistOf("CHAT", "COMPLETION"))
})

It("filters by capability", func() {
out, err := c.ListInstalledModels(ctx, localaitools.CapabilityTranscript)
Expect(err).ToNot(HaveOccurred())
Expect(out).To(HaveLen(1))
Expect(out[0].Name).To(Equal("whisper-large"))
})

It("returns an empty slice when no model matches the capability", func() {
out, err := c.ListInstalledModels(ctx, localaitools.CapabilityImage)
Expect(err).ToNot(HaveOccurred())
Expect(out).To(BeEmpty())
})
})

Describe("ListBackends", func() {
It("returns each installed backend with its installed flag", func() {
bs, err := c.ListBackends(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(bs).To(HaveLen(1))
Expect(bs).To(HaveLen(2))
Expect(bs[0].Name).To(Equal("llama-cpp"))
Expect(bs[0].Installed).To(BeTrue())
Expect(bs[1].Name).To(Equal("whisper"))
Expect(bs[1].Installed).To(BeTrue())
})
})

Expand Down
50 changes: 26 additions & 24 deletions pkg/mcp/localaitools/httpapi/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,30 +11,32 @@ import (
// registrations in core/http/routes/localai.go — the Tool↔REST drift detector
// in coverage_test.go documents the mapping.
const (
routeWelcome = "/"
routeModelsApply = "/models/apply"
routeModelsAvail = "/models/available"
routeModelsGall = "/models/galleries"
routeModelsImport = "/models/import-uri"
routeModelImport = "/models/import"
routeAliases = "/api/aliases"
routeModelsReload = "/models/reload"
routeBackendLoad = "/backend/load"
routeBackends = "/backends"
routeBackendsKnown = "/backends/known"
routeBackendsApply = "/backends/apply"
routeNodes = "/api/nodes"
routeScheduling = "/api/nodes/scheduling"
routeVRAMEstimate = "/api/models/vram-estimate"
routeBranding = "/api/branding"
routeSettings = "/api/settings"
routeUsage = "/api/usage"
routeUsageAll = "/api/usage/all"
routePIIEvents = "/api/pii/events"
routeMiddleware = "/api/middleware/status"
routeRouterDecisions = "/api/router/decisions"
routeVoiceProfiles = "/api/voice-profiles"
routeFailover = "/api/failover"
routeModelsApply = "/models/apply"
routeModelsAvail = "/models/available"
routeModelsGall = "/models/galleries"
routeModelsImport = "/models/import-uri"
routeModelImport = "/models/import"
routeAliases = "/api/aliases"
routeModelsReload = "/models/reload"
routeBackendLoad = "/backend/load"
routeBackends = "/backends"
routeBackendsKnown = "/backends/known"
routeBackendsApply = "/backends/apply"
routeNodes = "/api/nodes"
routeScheduling = "/api/nodes/scheduling"
routeVRAMEstimate = "/api/models/vram-estimate"
routeBranding = "/api/branding"
routeSettings = "/api/settings"
routeUsage = "/api/usage"
routeUsageAll = "/api/usage/all"
routePIIEvents = "/api/pii/events"
routeMiddleware = "/api/middleware/status"
routeRouterDecisions = "/api/router/decisions"
routeVoiceProfiles = "/api/voice-profiles"
routeFailover = "/api/failover"
routeModelsCapabilities = "/api/models/capabilities"
routeVersion = "/version"
routeSystem = "/system"
)

func routeJobStatus(jobID string) string {
Expand Down
10 changes: 6 additions & 4 deletions pkg/mcp/localaitools/parity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,13 @@ func fakeBackend() *httptest.Server {
},
})
})
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
mux.HandleFunc("/version", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"version": "v0.0.0-parity"})
})
mux.HandleFunc("/system", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"Version": "v0.0.0-parity",
"InstalledBackends": map[string]bool{"llama-cpp": true},
"ModelsConfig": []map[string]any{{"name": "qwen2.5-7b-instruct", "backend": "llama-cpp"}},
"backends": []string{"llama-cpp"},
"loaded_models": []map[string]any{{"id": "qwen2.5-7b-instruct"}},
})
})
mux.HandleFunc("/backends", func(w http.ResponseWriter, _ *http.Request) {
Expand Down