mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
fix(mcp): point httpapi list_installed_models & system_info at live routes
The standalone 'local-ai mcp-server' HTTP client (httpapi.Client) called
GET / for both list_installed_models and system_info, expecting the legacy
welcome JSON (ModelsConfig / Version / InstalledBackends). That endpoint was
unregistered when the React UI moved to /app, so GET / now 301-redirects to
the SPA. The client uses LocalAI's hardened httpclient, which refuses all
redirects by default, so both tools failed with:
Get "/app": httpclient: redirect blocked: refusing to follow redirect ...
These two tools have never returned data over HTTP (the client was added
after / became a redirect). Repoint them at live, non-redirecting routes:
- list_installed_models -> GET /api/models/capabilities
(returns id/backend/capabilities/disabled/pinned; also makes the
capability filter, previously a no-op over HTTP, actually work)
- system_info -> GET /version + GET /backends + GET /system
The dead routeWelcome constant is removed and the three new routes are
added as route* constants per the package convention. Test fakes and the
Tool<->REST drift map in coverage_test.go are updated to the new routes,
and new specs cover list_installed_models (all / filtered / empty) and
system_info (version + backends + loaded models).
Assisted-by: LocalAI-Agent:qwen3.8-27b-heretic-abliterated-uncensored
Signed-off-by: L0ria <loria@hno3.eu>
This commit is contained in:
1 parent
0d6e57f9f5
commit
d123083bc0
5 files changed
+141
-44
No files matched your search
@@ -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",
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -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"`
|
||||
// /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, routeWelcome, nil, &welcome); err != nil {
|
||||
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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"}},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -217,11 +227,41 @@ 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())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -229,9 +269,11 @@ var _ = Describe("httpapi.Client against the LocalAI admin REST surface", 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())
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ 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"
|
||||
@@ -35,6 +34,9 @@ const (
|
||||
routeRouterDecisions = "/api/router/decisions"
|
||||
routeVoiceProfiles = "/api/voice-profiles"
|
||||
routeFailover = "/api/failover"
|
||||
routeModelsCapabilities = "/api/models/capabilities"
|
||||
routeVersion = "/version"
|
||||
routeSystem = "/system"
|
||||
)
|
||||
|
||||
func routeJobStatus(jobID string) string {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user