mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-17 04:56:52 -04:00
Adds a whitelabeling feature so an operator can replace the LocalAI
instance name, tagline, square logo, horizontal logo, and favicon from
the admin Settings page. Defaults fall back to the bundled assets so
existing installs are unaffected.
The public GET /api/branding endpoint is reachable pre-auth so the
login screen can render the configured branding before sign-in.
Mutating routes (POST/DELETE /api/branding/asset/:kind) remain
admin-only. Text fields (instance_name, instance_tagline) ride the
existing /api/settings flow; binary assets get a dedicated multipart
upload route that persists files under DynamicConfigsDir/branding/.
To prevent the Settings page's stale local state from clobbering an
upload on save, UpdateSettingsEndpoint preserves whatever the on-disk
asset filename fields are regardless of the body — /api/branding/asset/*
are the sole writers for those fields.
The MCP catalog gains get_branding and set_branding tools (text fields
only; file upload stays UI-only) plus a configure_branding skill prompt.
While wiring this up, the same restart-loss class of bug surfaced for
several existing fields whose RuntimeSettings entries were never read
by the startup loader. Fix loadRuntimeSettingsFromFile() to load:
- branding (instance_name, instance_tagline, *_file basenames)
- auto_upgrade_backends, prefer_development_backends
- localai_assistant_enabled
- open_responses_store_ttl
- the 7 existing AgentPool fields (enabled, default/embedding model,
chunking sizes, enable_logs, collection_db_path)
Also exposes 3 new AgentPool runtime settings (vector_engine,
database_url, agent_hub_url) via /api/settings + the Settings UI, with
the same load-on-startup wiring. The file watcher's manual-edit path
is intentionally not changed — the in-process API endpoints already
update appConfig directly, so the watcher is redundant for supported
flows and a separate refactor for everything else.
15 TDD specs cover the loader behaviour (1 branding + 11 adjacent + 3
new agent-pool); 2 specs cover the persistence helpers and the
clobber-prevention contract.
Assisted-by: claude-code:claude-opus-4-7
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
248 lines
8.0 KiB
Go
248 lines
8.0 KiB
Go
package localaitools
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
|
|
"github.com/mudler/LocalAI/core/gallery"
|
|
)
|
|
|
|
// connectInMemory wires an MCP server (built via NewServer) to a client over
|
|
// a paired in-memory transport (net.Pipe). Returns the client session along
|
|
// with a teardown closure suitable for DeferCleanup.
|
|
func connectInMemory(client LocalAIClient, opts Options) (context.Context, *mcp.ClientSession, func()) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
srv := NewServer(client, opts)
|
|
t1, t2 := mcp.NewInMemoryTransports()
|
|
|
|
serverSession, err := srv.Connect(ctx, t1, nil)
|
|
Expect(err).ToNot(HaveOccurred(), "server connect")
|
|
|
|
c := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0"}, nil)
|
|
clientSession, err := c.Connect(ctx, t2, nil)
|
|
Expect(err).ToNot(HaveOccurred(), "client connect")
|
|
|
|
return ctx, clientSession, func() {
|
|
_ = clientSession.Close()
|
|
_ = serverSession.Wait()
|
|
cancel()
|
|
}
|
|
}
|
|
|
|
// listToolNames returns the sorted list of tool names exposed by the server.
|
|
func listToolNames(ctx context.Context, sess *mcp.ClientSession) []string {
|
|
res, err := sess.ListTools(ctx, nil)
|
|
Expect(err).ToNot(HaveOccurred(), "list tools")
|
|
names := make([]string, 0, len(res.Tools))
|
|
for _, tl := range res.Tools {
|
|
names = append(names, tl.Name)
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
// callTool is a small wrapper to reduce boilerplate. CallToolParams.Arguments
|
|
// is declared as `any` and the SDK marshals it for the wire — passing a
|
|
// pre-marshalled []byte (or json.RawMessage) here would be double-encoded as
|
|
// a base64 string.
|
|
func callTool(ctx context.Context, sess *mcp.ClientSession, name string, args any) *mcp.CallToolResult {
|
|
res, err := sess.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args})
|
|
Expect(err).ToNot(HaveOccurred(), "call tool %s", name)
|
|
return res
|
|
}
|
|
|
|
// resultText concatenates all TextContent items of a result.
|
|
func resultText(res *mcp.CallToolResult) string {
|
|
var b strings.Builder
|
|
for _, c := range res.Content {
|
|
if tc, ok := c.(*mcp.TextContent); ok {
|
|
b.WriteString(tc.Text)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// expectedFullCatalog is the tool set when DisableMutating=false. Sorted.
|
|
// References the Tool* constants so a rename can't drift code from tests.
|
|
var expectedFullCatalog = sortedStrings(
|
|
ToolDeleteModel,
|
|
ToolEditModelConfig,
|
|
ToolGallerySearch,
|
|
ToolGetBranding,
|
|
ToolGetJobStatus,
|
|
ToolGetModelConfig,
|
|
ToolImportModelURI,
|
|
ToolInstallBackend,
|
|
ToolInstallModel,
|
|
ToolListBackends,
|
|
ToolListGalleries,
|
|
ToolListInstalledModels,
|
|
ToolListKnownBackends,
|
|
ToolListNodes,
|
|
ToolReloadModels,
|
|
ToolSetBranding,
|
|
ToolSystemInfo,
|
|
ToolToggleModelPinned,
|
|
ToolToggleModelState,
|
|
ToolUpgradeBackend,
|
|
ToolVRAMEstimate,
|
|
)
|
|
|
|
// expectedReadOnlyCatalog is the tool set when DisableMutating=true. Sorted.
|
|
var expectedReadOnlyCatalog = sortedStrings(
|
|
ToolGallerySearch,
|
|
ToolGetBranding,
|
|
ToolGetJobStatus,
|
|
ToolGetModelConfig,
|
|
ToolListBackends,
|
|
ToolListGalleries,
|
|
ToolListInstalledModels,
|
|
ToolListKnownBackends,
|
|
ToolListNodes,
|
|
ToolSystemInfo,
|
|
ToolVRAMEstimate,
|
|
)
|
|
|
|
func sortedStrings(in ...string) []string {
|
|
out := append([]string(nil), in...)
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
var _ = Describe("Server tool catalog", func() {
|
|
It("registers the full catalog when mutating tools are enabled", func() {
|
|
ctx, sess, done := connectInMemory(&fakeClient{}, Options{})
|
|
DeferCleanup(done)
|
|
|
|
Expect(listToolNames(ctx, sess)).To(Equal(expectedFullCatalog))
|
|
})
|
|
|
|
It("skips mutating tools when DisableMutating is set", func() {
|
|
ctx, sess, done := connectInMemory(&fakeClient{}, Options{DisableMutating: true})
|
|
DeferCleanup(done)
|
|
|
|
Expect(listToolNames(ctx, sess)).To(Equal(expectedReadOnlyCatalog))
|
|
})
|
|
})
|
|
|
|
var _ = Describe("Tool dispatch", func() {
|
|
type dispatchCase struct {
|
|
tool string
|
|
args any
|
|
wantMethod string
|
|
}
|
|
|
|
cases := []dispatchCase{
|
|
{ToolGallerySearch, GallerySearchQuery{Query: "qwen"}, "GallerySearch"},
|
|
{ToolListInstalledModels, map[string]any{"capability": "chat"}, "ListInstalledModels"},
|
|
{ToolListGalleries, struct{}{}, "ListGalleries"},
|
|
{ToolListBackends, struct{}{}, "ListBackends"},
|
|
{ToolListKnownBackends, struct{}{}, "ListKnownBackends"},
|
|
{ToolSystemInfo, struct{}{}, "SystemInfo"},
|
|
{ToolListNodes, struct{}{}, "ListNodes"},
|
|
{ToolInstallModel, InstallModelRequest{ModelName: "test/foo"}, "InstallModel"},
|
|
{ToolImportModelURI, ImportModelURIRequest{URI: "Qwen/Qwen3-4B-GGUF"}, "ImportModelURI"},
|
|
{ToolDeleteModel, map[string]any{"name": "foo"}, "DeleteModel"},
|
|
{ToolInstallBackend, InstallBackendRequest{BackendName: "llama-cpp"}, "InstallBackend"},
|
|
{ToolUpgradeBackend, map[string]any{"name": "llama-cpp"}, "UpgradeBackend"},
|
|
{ToolEditModelConfig, map[string]any{"name": "foo", "patch": map[string]any{"context_size": 4096}}, "EditModelConfig"},
|
|
{ToolReloadModels, struct{}{}, "ReloadModels"},
|
|
{ToolToggleModelState, map[string]any{"name": "foo", "action": "enable"}, "ToggleModelState"},
|
|
{ToolToggleModelPinned, map[string]any{"name": "foo", "action": "pin"}, "ToggleModelPinned"},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
c := c
|
|
It("routes "+c.tool+" to "+c.wantMethod, func() {
|
|
fc := &fakeClient{
|
|
installModel: func(InstallModelRequest) (string, error) { return "job-1", nil },
|
|
installBackend: func(InstallBackendRequest) (string, error) { return "job-2", nil },
|
|
upgradeBackend: func(string) (string, error) { return "job-3", nil },
|
|
}
|
|
ctx, sess, done := connectInMemory(fc, Options{})
|
|
DeferCleanup(done)
|
|
|
|
res := callTool(ctx, sess, c.tool, c.args)
|
|
Expect(res.IsError).To(BeFalse(), "tool %s returned error: %s", c.tool, resultText(res))
|
|
|
|
calls := fc.recorded()
|
|
Expect(calls).ToNot(BeEmpty(), "tool %s did not call the client", c.tool)
|
|
Expect(calls[len(calls)-1].method).To(Equal(c.wantMethod))
|
|
})
|
|
}
|
|
})
|
|
|
|
var _ = Describe("Tool error surfacing", func() {
|
|
It("propagates client errors verbatim via IsError + TextContent", func() {
|
|
fc := &fakeClient{
|
|
gallerySearch: func(GallerySearchQuery) ([]gallery.Metadata, error) {
|
|
return nil, errors.New("backend on fire")
|
|
},
|
|
}
|
|
ctx, sess, done := connectInMemory(fc, Options{})
|
|
DeferCleanup(done)
|
|
|
|
res := callTool(ctx, sess, ToolGallerySearch, GallerySearchQuery{Query: "x"})
|
|
Expect(res.IsError).To(BeTrue(), "expected IsError, got: %s", resultText(res))
|
|
Expect(resultText(res)).To(ContainSubstring("backend on fire"))
|
|
})
|
|
})
|
|
|
|
var _ = Describe("Argument validation", func() {
|
|
type validationCase struct {
|
|
desc string
|
|
tool string
|
|
args any
|
|
want string
|
|
}
|
|
|
|
// Required-field misses go through the SDK schema validator (the
|
|
// generated input schema marks name as required), not our handler.
|
|
cases := []validationCase{
|
|
{"install_model rejects empty model_name", ToolInstallModel, InstallModelRequest{}, "model_name is required"},
|
|
{"delete_model rejects missing name (schema)", ToolDeleteModel, map[string]any{}, "missing properties"},
|
|
{"toggle_model_state rejects unknown action", ToolToggleModelState, map[string]any{"name": "foo", "action": "noop"}, "action must be one of"},
|
|
{"edit_model_config rejects empty patch", ToolEditModelConfig, map[string]any{"name": "foo", "patch": map[string]any{}}, "patch is required"},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
c := c
|
|
It(c.desc, func() {
|
|
ctx, sess, done := connectInMemory(&fakeClient{}, Options{})
|
|
DeferCleanup(done)
|
|
|
|
res := callTool(ctx, sess, c.tool, c.args)
|
|
Expect(res.IsError).To(BeTrue(), "expected validation error; got %s", resultText(res))
|
|
Expect(resultText(res)).To(ContainSubstring(c.want))
|
|
})
|
|
}
|
|
})
|
|
|
|
var _ = Describe("Concurrent tool calls", func() {
|
|
It("handles 20 parallel CallTool requests against one session without a race", func() {
|
|
fc := &fakeClient{}
|
|
ctx, sess, done := connectInMemory(fc, Options{})
|
|
DeferCleanup(done)
|
|
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 20; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
callTool(ctx, sess, ToolListGalleries, struct{}{})
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
Expect(fc.recorded()).To(HaveLen(20))
|
|
})
|
|
})
|