Files
LocalAI/pkg/mcp/localaitools/inproc/client.go
LocalAI [bot] 9565db5f94 feat(models): model aliases - redirect a model name to another configured model (#10414)
* feat(config): add model alias field and self-validation

Add ModelConfig.Alias (yaml: alias), IsAlias(), and an alias
short-circuit at the top of Validate() that rejects self-reference and
forbids setting backend/parameters.model on a pure-redirect alias.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(config): resolve and validate model alias targets in the loader

Assisted-by: Claude:opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(middleware): resolve model aliases and stamp requested/served identity

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(modeladmin): reject alias configs with invalid targets on create/edit

Validate alias targets at create/swap entry points (ImportModelEndpoint,
EditYAML, PatchConfig) so a dangling, chained, or disabled alias target is
rejected at save time rather than surfacing as a runtime error.

Assisted-by: Claude:opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(api): add GET /api/aliases to list model aliases

Adds an admin-gated read-only endpoint that lists every model alias
config as {name, target} pairs, backed by the loader's existing
GetAllModelsConfigs().

Assisted-by: Claude:opus-4.8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(mcp): add set_alias and list_aliases tools

Expose model-alias management over the LocalAI Assistant MCP surface:
list_aliases (read-only, GET /api/aliases) and set_alias (mutating).
SetAlias is swap-first: PATCH /api/models/config-json/:name swaps an
existing alias's target (validated, non-destructive) and a 404 falls
back to POST /models/import to create a fresh {name, alias} config. The
inproc client mirrors this via ConfigService.PatchConfig + a create path
modeled on ImportModelEndpoint. Deletion reuses delete_model.

Assisted-by: Claude:claude-opus-4 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* style(mcp): replace em dashes in alias tool comments

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(config-meta): expose alias as a model-select field

Add an 'alias' section to DefaultSections() and an 'alias' field override
in DefaultRegistry() so the schema-driven React editor renders the new
top-level ModelConfig.Alias field as a model picker in its own section.

Assisted-by: Claude:opus-4.8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): add alias template card and Manage alias badge

Add an 'Alias / Routing' template to the create-flow gallery that seeds a
minimal name + alias config, and a read-only 'alias -> target' badge on the
Manage Models tab. The capabilities row payload does not carry the alias
field, so the badge resolves targets from GET /api/aliases looked up by name.

Assisted-by: Claude:claude-opus-4 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs: document model aliases

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(swagger): regenerate for GET /api/aliases

Adds the /api/aliases path and AliasInfo schema generated from the
ListAliasesEndpoint annotation.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(localai): check os.RemoveAll error in aliases_test

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix: correct alias conversion docs and advertise /api/aliases in instructions

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(mcp): write alias config 0600 to satisfy gosec G306

The inproc createAlias path wrote the alias YAML with 0644, which gosec
flags as a new G306 finding on the PR. The LocalAI process is the sole
reader/writer of model configs, so 0600 is correct and keeps the scan clean.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-06-20 22:38:42 +02:00

810 lines
26 KiB
Go

// Package inproc provides an in-process LocalAIClient that calls LocalAI
// services directly. Used by the chat handler when a chat session opts into
// the LocalAI Assistant modality, avoiding an HTTP loopback to the same
// process and the synthetic admin-credential dance that would entail.
package inproc
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/google/uuid"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/gallery/importers"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/services/galleryop"
"github.com/mudler/LocalAI/core/services/modeladmin"
"github.com/mudler/LocalAI/core/services/routing/billing"
"github.com/mudler/LocalAI/core/services/routing/pii"
"github.com/mudler/LocalAI/core/services/routing/router"
"github.com/mudler/LocalAI/internal"
localaitools "github.com/mudler/LocalAI/pkg/mcp/localaitools"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/system"
"github.com/mudler/LocalAI/pkg/utils"
"github.com/mudler/LocalAI/pkg/vram"
"gopkg.in/yaml.v3"
)
// Client implements localaitools.LocalAIClient by calling LocalAI services
// directly. It is intentionally a thin shim — distribution and persistence
// concerns are handled by the underlying services (GalleryService is already
// distributed-aware, ModelConfigLoader manages on-disk YAML, etc.), so this
// layer just translates between MCP DTOs and service signatures.
type Client struct {
AppConfig *config.ApplicationConfig
SystemState *system.SystemState
ConfigLoader *config.ModelConfigLoader
ModelLoader *model.ModelLoader
Gallery *galleryop.GalleryService
// StatsRecorder and FallbackUser are optional — they back the
// get_usage_stats tool. nil StatsRecorder makes the tool return an
// "unavailable" error, which keeps the assistant responsive on
// deployments that ran with --disable-stats or where startup wired
// the inproc client before stats were ready.
StatsRecorder *billing.Recorder
FallbackUser *auth.User
// PIIRedactor and PIIEvents back the list_pii_patterns,
// get_pii_events, and test_pii_redaction tools. nil values cause
// the tools to return a "filter disabled" error.
PIIRedactor *pii.Redactor
PIIEvents pii.EventStore
// RouterDecisions backs the get_router_decisions tool. nil makes
// the tool return an empty list — same shape the REST endpoint
// returns when stats are disabled.
RouterDecisions router.DecisionStore
modelAdmin *modeladmin.ConfigService
}
// New builds a Client wired to the given services. All fields are required
// except ModelLoader (used only for SystemInfo's loaded-models report and
// best-effort ShutdownModel calls during config edits) and the stats
// fields (StatsRecorder, FallbackUser) which gate get_usage_stats.
func New(appConfig *config.ApplicationConfig, systemState *system.SystemState, cl *config.ModelConfigLoader, ml *model.ModelLoader, gs *galleryop.GalleryService) *Client {
return &Client{
AppConfig: appConfig,
SystemState: systemState,
ConfigLoader: cl,
ModelLoader: ml,
Gallery: gs,
modelAdmin: modeladmin.NewConfigService(cl, appConfig),
}
}
// Compile-time assertion that *Client satisfies localaitools.LocalAIClient.
var _ localaitools.LocalAIClient = (*Client)(nil)
// ---- Models / gallery (read) ----
func (c *Client) GallerySearch(_ context.Context, q localaitools.GallerySearchQuery) ([]gallery.Metadata, error) {
galleries := c.AppConfig.Galleries
if q.Gallery != "" {
galleries = filterGalleries(galleries, q.Gallery)
}
models, err := gallery.AvailableGalleryModels(galleries, c.SystemState)
if err != nil {
return nil, fmt.Errorf("list gallery models: %w", err)
}
if q.Query != "" {
models = models.Search(q.Query)
}
if q.Tag != "" {
models = models.FilterByTag(q.Tag)
}
limit := q.Limit
if limit <= 0 {
limit = 20
}
// Surface gallery.Metadata directly — same wire shape as gallery.AvailableGalleryModels
// returns and the same shape REST /models/available emits, so REST and MCP stay aligned.
out := make([]gallery.Metadata, 0, min(len(models), limit))
for i, m := range models {
if i >= limit {
break
}
out = append(out, m.Metadata)
}
return out, nil
}
func (c *Client) ListInstalledModels(_ context.Context, capability localaitools.Capability) ([]localaitools.InstalledModel, error) {
wantFlag, hasFlag := capabilityToFlag(capability)
configs := c.ConfigLoader.GetModelConfigsByFilter(func(_ string, m *config.ModelConfig) bool {
if !hasFlag {
return true
}
return m.HasUsecases(wantFlag)
})
out := make([]localaitools.InstalledModel, 0, len(configs))
for _, m := range configs {
out = append(out, localaitools.InstalledModel{
Name: m.Name,
Backend: m.Backend,
Capabilities: capabilityFlagsOf(&m),
})
}
return out, nil
}
func (c *Client) ListGalleries(_ context.Context) ([]config.Gallery, error) {
// AppConfig.Galleries is already []config.Gallery; the JSON shape
// matches what REST /models/galleries emits.
return c.AppConfig.Galleries, nil
}
func (c *Client) GetJobStatus(_ context.Context, jobID string) (*localaitools.JobStatus, error) {
if jobID == "" {
return nil, errors.New("job id is required")
}
st := c.Gallery.GetStatus(jobID)
if st == nil {
return nil, nil
}
out := &localaitools.JobStatus{
ID: jobID,
Processed: st.Processed,
Cancelled: st.Cancelled,
Progress: st.Progress,
TotalFileSize: st.TotalFileSize,
DownloadedFileSize: st.DownloadedFileSize,
Message: st.Message,
}
if st.Error != nil {
out.ErrorMessage = st.Error.Error()
}
return out, nil
}
func (c *Client) GetModelConfig(ctx context.Context, name string) (*localaitools.ModelConfigView, error) {
view, err := c.modelAdmin.GetConfig(ctx, name)
if err != nil {
return nil, err
}
return &localaitools.ModelConfigView{Name: view.Name, YAML: view.YAML, JSON: view.JSON}, nil
}
// ---- Models / gallery (write) ----
func (c *Client) InstallModel(ctx context.Context, req localaitools.InstallModelRequest) (string, error) {
if req.ModelName == "" {
return "", errors.New("model_name is required")
}
id, err := uuid.NewUUID()
if err != nil {
return "", fmt.Errorf("generate job id: %w", err)
}
galleries := c.AppConfig.Galleries
if req.GalleryName != "" {
galleries = filterGalleries(galleries, req.GalleryName)
}
op := galleryop.ManagementOp[gallery.GalleryModel, gallery.ModelConfig]{
ID: id.String(),
GalleryElementName: req.ModelName,
Req: gallery.GalleryModel{
Metadata: gallery.Metadata{Name: req.ModelName},
},
Galleries: galleries,
BackendGalleries: c.AppConfig.BackendGalleries,
}
if err := sendModelOp(ctx, c.Gallery.ModelGalleryChannel, op); err != nil {
return "", err
}
return id.String(), nil
}
func (c *Client) ImportModelURI(ctx context.Context, req localaitools.ImportModelURIRequest) (*localaitools.ImportModelURIResponse, error) {
if req.URI == "" {
return nil, errors.New("uri is required")
}
// Build the preferences JSON expected by importers.DiscoverModelConfig.
// Today only `backend` is meaningful; future fields can be added without
// changing the MCP DTO.
var prefs json.RawMessage
if req.BackendPreference != "" {
raw, err := json.Marshal(map[string]string{"backend": req.BackendPreference})
if err != nil {
return nil, fmt.Errorf("marshal preferences: %w", err)
}
prefs = raw
}
modelConfig, err := importers.DiscoverModelConfig(req.URI, prefs)
if err != nil {
var amb *importers.AmbiguousImportError
if errors.As(err, &amb) {
candidates := amb.Candidates
if candidates == nil {
candidates = []string{}
}
return &localaitools.ImportModelURIResponse{
AmbiguousBackend: true,
Modality: amb.Modality,
BackendCandidates: candidates,
Hint: "call import_model_uri again with backend_preference set to one of backend_candidates",
}, nil
}
if errors.Is(err, importers.ErrAmbiguousImport) {
return &localaitools.ImportModelURIResponse{
AmbiguousBackend: true,
BackendCandidates: []string{},
Hint: "call import_model_uri again with backend_preference set",
}, nil
}
return nil, fmt.Errorf("discover model config: %w", err)
}
id, err := uuid.NewUUID()
if err != nil {
return nil, fmt.Errorf("generate job id: %w", err)
}
galleryID := req.URI
if modelConfig.Name != "" {
galleryID = modelConfig.Name
}
overrides := req.Overrides
if overrides == nil {
overrides = map[string]any{}
}
op := galleryop.ManagementOp[gallery.GalleryModel, gallery.ModelConfig]{
Req: gallery.GalleryModel{Overrides: overrides},
ID: id.String(),
GalleryElementName: galleryID,
GalleryElement: &modelConfig,
BackendGalleries: c.AppConfig.BackendGalleries,
}
if err := sendModelOp(ctx, c.Gallery.ModelGalleryChannel, op); err != nil {
return nil, err
}
return &localaitools.ImportModelURIResponse{
JobID: id.String(),
DiscoveredModelName: modelConfig.Name,
}, nil
}
func (c *Client) DeleteModel(ctx context.Context, name string) error {
if name == "" {
return errors.New("name is required")
}
op := galleryop.ManagementOp[gallery.GalleryModel, gallery.ModelConfig]{
Delete: true,
GalleryElementName: name,
}
if err := sendModelOp(ctx, c.Gallery.ModelGalleryChannel, op); err != nil {
return err
}
c.ConfigLoader.RemoveModelConfig(name)
return nil
}
func (c *Client) EditModelConfig(ctx context.Context, name string, patch map[string]any) error {
_, err := c.modelAdmin.PatchConfig(ctx, name, patch)
return err
}
func (c *Client) ReloadModels(_ context.Context) error {
if c.SystemState == nil {
return errors.New("system state not available")
}
return c.ConfigLoader.LoadModelConfigsFromPath(c.SystemState.Model.ModelsPath)
}
// ---- Model aliases ----
// SetAlias is swap-first to match the httpapi client: PatchConfig swaps an
// existing alias's target (validating it and preserving other fields) and
// returns ErrNotFound when the config doesn't exist yet, which is the signal
// to create it. createAlias mirrors the create path of ImportModelEndpoint.
func (c *Client) SetAlias(ctx context.Context, name, target string) error {
if name == "" {
return errors.New("name is required")
}
if target == "" {
return errors.New("target is required")
}
_, err := c.modelAdmin.PatchConfig(ctx, name, map[string]any{"alias": target})
if err == nil {
return nil
}
if !errors.Is(err, modeladmin.ErrNotFound) {
return err
}
return c.createAlias(name, target)
}
// createAlias writes a fresh `{name, alias}` config to disk and reloads,
// mirroring localai.ImportModelEndpoint's create path: validate, validate the
// alias target, verify the path is trusted, write, reload, best-effort preload.
func (c *Client) createAlias(name, target string) error {
if c.SystemState == nil {
return errors.New("system state not available")
}
cfg := config.ModelConfig{Name: name, Alias: target}
if valid, vErr := cfg.Validate(); !valid {
if vErr != nil {
return vErr
}
return errors.New("invalid alias configuration")
}
if err := c.ConfigLoader.ValidateAliasTarget(&cfg); err != nil {
return err
}
modelsPath := c.SystemState.Model.ModelsPath
if err := utils.VerifyPath(name+".yaml", modelsPath); err != nil {
return fmt.Errorf("model path not trusted: %w", err)
}
// Marshal only the user-provided fields (not the full struct with Go
// zero values), matching what the import endpoint persists for an alias.
yamlData, err := yaml.Marshal(map[string]any{"name": name, "alias": target})
if err != nil {
return fmt.Errorf("marshal alias config: %w", err)
}
// 0600: the LocalAI process is the sole reader/writer of model configs,
// and a tighter mode keeps the gosec G306 scan clean for this new write.
if err := os.WriteFile(filepath.Join(modelsPath, name+".yaml"), yamlData, 0600); err != nil {
return fmt.Errorf("write alias config: %w", err)
}
if err := c.ConfigLoader.LoadModelConfigsFromPath(modelsPath, c.AppConfig.ToConfigLoaderOptions()...); err != nil {
return fmt.Errorf("reload configs: %w", err)
}
// Preload is best-effort - a failure here doesn't undo the create.
_ = c.ConfigLoader.Preload(modelsPath)
return nil
}
func (c *Client) ListAliases(_ context.Context) ([]localaitools.AliasInfo, error) {
// Mirror localai.ListAliasesEndpoint: every config whose Alias is set.
out := []localaitools.AliasInfo{}
for _, cfg := range c.ConfigLoader.GetAllModelsConfigs() {
if cfg.IsAlias() {
out = append(out, localaitools.AliasInfo{Name: cfg.Name, Target: cfg.Alias})
}
}
return out, nil
}
// ---- Backends ----
func (c *Client) ListBackends(_ context.Context) ([]localaitools.Backend, error) {
systemBackends, err := c.Gallery.ListBackends()
if err != nil {
return nil, fmt.Errorf("list backends: %w", err)
}
out := make([]localaitools.Backend, 0, len(systemBackends))
for name := range systemBackends {
out = append(out, localaitools.Backend{Name: name, Installed: true})
}
return out, nil
}
func (c *Client) ListKnownBackends(_ context.Context) ([]schema.KnownBackend, error) {
available, err := gallery.AvailableBackends(c.AppConfig.BackendGalleries, c.SystemState)
if err != nil {
return nil, fmt.Errorf("list known backends: %w", err)
}
// Match the wire shape of REST /backends/known so the tool output is
// identical regardless of which client served it.
out := make([]schema.KnownBackend, 0, len(available))
for _, b := range available {
out = append(out, schema.KnownBackend{
Name: b.GetName(),
Description: b.GetDescription(),
Installed: b.GetInstalled(),
})
}
return out, nil
}
func (c *Client) InstallBackend(ctx context.Context, req localaitools.InstallBackendRequest) (string, error) {
if req.BackendName == "" {
return "", errors.New("backend_name is required")
}
id, err := uuid.NewUUID()
if err != nil {
return "", fmt.Errorf("generate job id: %w", err)
}
galleries := c.AppConfig.BackendGalleries
if req.GalleryName != "" {
galleries = filterGalleries(galleries, req.GalleryName)
}
op := galleryop.ManagementOp[gallery.GalleryBackend, any]{
ID: id.String(),
GalleryElementName: req.BackendName,
Galleries: galleries,
}
if err := sendBackendOp(ctx, c.Gallery.BackendGalleryChannel, op); err != nil {
return "", err
}
return id.String(), nil
}
func (c *Client) UpgradeBackend(ctx context.Context, name string) (string, error) {
if name == "" {
return "", errors.New("name is required")
}
id, err := uuid.NewUUID()
if err != nil {
return "", fmt.Errorf("generate job id: %w", err)
}
op := galleryop.ManagementOp[gallery.GalleryBackend, any]{
ID: id.String(),
GalleryElementName: name,
Galleries: c.AppConfig.BackendGalleries,
Upgrade: true,
}
if err := sendBackendOp(ctx, c.Gallery.BackendGalleryChannel, op); err != nil {
return "", err
}
return id.String(), nil
}
// ---- System ----
func (c *Client) SystemInfo(_ context.Context) (*localaitools.SystemInfo, error) {
info := &localaitools.SystemInfo{
Version: internal.PrintableVersion(),
Distributed: c.AppConfig != nil && c.AppConfig.Distributed.Enabled,
}
if c.SystemState != nil {
info.BackendsPath = c.SystemState.Backend.BackendsPath
info.ModelsPath = c.SystemState.Model.ModelsPath
}
if c.ModelLoader != nil {
for _, m := range c.ModelLoader.ListLoadedModels() {
info.LoadedModels = append(info.LoadedModels, m.ID)
}
}
if c.Gallery != nil {
if backends, err := c.Gallery.ListBackends(); err == nil {
for name := range backends {
info.InstalledBackends = append(info.InstalledBackends, name)
}
}
}
return info, nil
}
func (c *Client) ListNodes(_ context.Context) ([]localaitools.Node, error) {
// Node-registry wiring is the responsibility of the Application layer; an
// empty list is the right answer in single-process mode and a sensible
// stub until the Application plumbs the registry into this client.
return []localaitools.Node{}, nil
}
func (c *Client) VRAMEstimate(ctx context.Context, req localaitools.VRAMEstimateRequest) (*vram.EstimateResult, error) {
resp, err := modeladmin.EstimateVRAM(ctx, modeladmin.VRAMRequest{
Model: req.ModelName,
ContextSize: uint32(req.ContextSize),
GPULayers: req.GPULayers,
KVQuantBits: req.KVQuantBits,
}, c.ConfigLoader, c.SystemState)
if err != nil {
return nil, err
}
// Forward vram.EstimateResult unchanged so the LLM sees the same
// shape (size_bytes / size_display / vram_bytes / vram_display) that
// REST /api/models/vram-estimate returns.
return &resp.EstimateResult, nil
}
// ---- State ----
func (c *Client) ToggleModelState(ctx context.Context, name string, action modeladmin.Action) error {
_, err := c.modelAdmin.ToggleState(ctx, name, action, c.ModelLoader)
return err
}
func (c *Client) ToggleModelPinned(ctx context.Context, name string, action modeladmin.Action) error {
// No syncPinned callback wired here; the watchdog refresh callback is
// owned by the HTTP handler today. The MCP-driven path skips it; the
// next idle tick or manual reload picks the new pinned set up.
_, err := c.modelAdmin.TogglePinned(ctx, name, action, nil)
return err
}
// ---- Branding ----
// brandingAssetURL returns the same URL shape the public REST endpoint
// would emit so MCP and HTTP clients see identical wire output.
func brandingAssetURL(kind, file, defaultURL string) string {
if file != "" {
return "/branding/asset/" + kind
}
return defaultURL
}
func (c *Client) currentBranding() *localaitools.Branding {
b := c.AppConfig.Branding
return &localaitools.Branding{
InstanceName: b.InstanceName,
InstanceTagline: b.InstanceTagline,
LogoURL: brandingAssetURL("logo", b.LogoFile, "/static/logo.png"),
LogoHorizontalURL: brandingAssetURL("logo_horizontal", b.LogoHorizontalFile, "/static/logo_horizontal.png"),
FaviconURL: brandingAssetURL("favicon", b.FaviconFile, "/favicon.svg"),
}
}
func (c *Client) GetBranding(_ context.Context) (*localaitools.Branding, error) {
return c.currentBranding(), nil
}
func (c *Client) SetBranding(_ context.Context, req localaitools.SetBrandingRequest) (*localaitools.Branding, error) {
settings, err := c.AppConfig.ReadPersistedSettings()
if err != nil {
return nil, err
}
if req.InstanceName != nil {
c.AppConfig.Branding.InstanceName = *req.InstanceName
settings.InstanceName = req.InstanceName
}
if req.InstanceTagline != nil {
c.AppConfig.Branding.InstanceTagline = *req.InstanceTagline
settings.InstanceTagline = req.InstanceTagline
}
if err := c.AppConfig.WritePersistedSettings(settings); err != nil {
return nil, err
}
return c.currentBranding(), nil
}
// ---- helpers ----
// sendModelOp pushes op onto ch but bails if ctx is cancelled before the
// gallery worker is ready to receive. Without the select the chat handler
// goroutine would block forever when the worker is paused or the buffer is
// full — the LLM keeps polling and the request goroutine leaks. When the
// caller cancels we surface ctx.Err() so the LLM stops polling.
func sendModelOp(ctx context.Context, ch chan galleryop.ManagementOp[gallery.GalleryModel, gallery.ModelConfig], op galleryop.ManagementOp[gallery.GalleryModel, gallery.ModelConfig]) error {
select {
case ch <- op:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// sendBackendOp is the BackendGalleryChannel sibling of sendModelOp. Same
// rationale — see that comment.
func sendBackendOp(ctx context.Context, ch chan galleryop.ManagementOp[gallery.GalleryBackend, any], op galleryop.ManagementOp[gallery.GalleryBackend, any]) error {
select {
case ch <- op:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func filterGalleries(galleries []config.Gallery, name string) []config.Gallery {
for _, g := range galleries {
if g.Name == name {
return []config.Gallery{g}
}
}
return nil
}
// capabilityToFlag maps the public Capability constants to the loader's
// usecase bitflag. CapabilityAny (the empty value) selects all models.
func capabilityToFlag(capability localaitools.Capability) (config.ModelConfigUsecase, bool) {
switch capability {
case localaitools.CapabilityAny:
return 0, false
case localaitools.CapabilityChat:
return config.FLAG_CHAT, true
case localaitools.CapabilityCompletion:
return config.FLAG_COMPLETION, true
case localaitools.CapabilityEmbeddings:
return config.FLAG_EMBEDDINGS, true
case localaitools.CapabilityImage:
return config.FLAG_IMAGE, true
case localaitools.CapabilityTTS:
return config.FLAG_TTS, true
case localaitools.CapabilityTranscript:
return config.FLAG_TRANSCRIPT, true
case localaitools.CapabilityRerank:
return config.FLAG_RERANK, true
case localaitools.CapabilityVAD:
return config.FLAG_VAD, true
}
return 0, false
}
// ---- Usage / billing ----
func (c *Client) GetUsageStats(ctx context.Context, q localaitools.UsageStatsQuery) (*localaitools.UsageStats, error) {
if c.StatsRecorder == nil {
return nil, errors.New("usage tracking is not available on this server")
}
period := q.Period
if period == "" {
period = "month"
}
// Resolve which user this is. In single-user no-auth mode the
// inproc client doesn't have an echo context to read auth.GetUser
// from, so the FallbackUser is the only available identity. When
// auth IS on, the assistant runs under a privileged session and the
// caller can pass q.UserID; we don't enforce admin here because the
// MCP server itself is gated on admin (see prompts/10_safety.md).
var viewerID, viewerName, viewerRole string
switch {
case q.UserID != "":
viewerID = q.UserID
case c.FallbackUser != nil:
viewerID = c.FallbackUser.ID
viewerName = c.FallbackUser.Name
viewerRole = c.FallbackUser.Role
default:
return nil, errors.New("no user context for usage query (auth is on but no user id was provided)")
}
queryUser := viewerID
if q.All {
// /api/usage/all: cluster-wide by default, but honour the
// optional UserID filter so admins can scope to one user —
// matches the REST endpoint's ?user_id=… query param. Empty
// q.UserID falls through to the cluster-wide aggregate.
queryUser = q.UserID
}
rows, err := c.StatsRecorder.Aggregate(ctx, billing.AggregateQuery{
UserID: queryUser,
Period: period,
})
if err != nil {
return nil, fmt.Errorf("aggregate usage: %w", err)
}
totals := localaitools.UsageTotals{}
buckets := make([]localaitools.UsageBucket, 0, len(rows))
for _, r := range rows {
buckets = append(buckets, localaitools.UsageBucket{
Bucket: r.Bucket,
Model: r.Model,
UserID: r.UserID,
UserName: r.UserName,
PromptTokens: r.PromptTokens,
CompletionTokens: r.CompletionTokens,
TotalTokens: r.TotalTokens,
RequestCount: r.RequestCount,
})
totals.PromptTokens += r.PromptTokens
totals.CompletionTokens += r.CompletionTokens
totals.TotalTokens += r.TotalTokens
totals.RequestCount += r.RequestCount
}
return &localaitools.UsageStats{
Viewer: localaitools.UsageViewer{ID: viewerID, Name: viewerName, Role: viewerRole},
Period: period,
Totals: totals,
Buckets: buckets,
}, nil
}
// ---- PII filter ----
func (c *Client) GetPIIEvents(ctx context.Context, q localaitools.PIIEventsQuery) ([]localaitools.PIIEvent, error) {
if c.PIIEvents == nil {
return nil, errors.New("PII filter is disabled")
}
events, err := c.PIIEvents.List(ctx, pii.ListQuery{
CorrelationID: q.CorrelationID,
UserID: q.UserID,
PatternID: q.PatternID,
Kind: pii.KindPII,
Limit: q.Limit,
})
if err != nil {
return nil, fmt.Errorf("list pii events: %w", err)
}
out := make([]localaitools.PIIEvent, 0, len(events))
for _, e := range events {
out = append(out, localaitools.PIIEvent{
ID: e.ID,
CorrelationID: e.CorrelationID,
UserID: e.UserID,
Direction: string(e.Direction),
PatternID: e.PatternID,
ByteOffset: e.ByteOffset,
Length: e.Length,
HashPrefix: e.HashPrefix,
Action: string(e.Action),
CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
})
}
return out, nil
}
func (c *Client) GetRouterDecisions(ctx context.Context, q localaitools.RouterDecisionsQuery) ([]localaitools.RouterDecision, error) {
if c.RouterDecisions == nil {
return []localaitools.RouterDecision{}, nil
}
rows, err := c.RouterDecisions.List(ctx, router.DecisionListQuery{
CorrelationID: q.CorrelationID,
UserID: q.UserID,
RouterModel: q.RouterModel,
Limit: q.Limit,
})
if err != nil {
return nil, fmt.Errorf("list router decisions: %w", err)
}
out := make([]localaitools.RouterDecision, 0, len(rows))
for _, r := range rows {
out = append(out, localaitools.RouterDecision{
ID: r.ID,
CorrelationID: r.CorrelationID,
UserID: r.UserID,
RouterModel: r.RouterModel,
RequestedModel: r.RequestedModel,
ServedModel: r.ServedModel,
Classifier: r.Classifier,
Label: r.Label,
Score: r.Score,
LatencyMs: r.LatencyMs,
Cached: r.Cached,
CreatedAt: r.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
})
}
return out, nil
}
func (c *Client) GetMiddlewareStatus(ctx context.Context) (*localaitools.MiddlewareStatus, error) {
router := localaitools.MiddlewareRouterStatus{
Configured: false,
Models: []string{},
Note: "Intelligent routing is not yet implemented.",
}
piiSection := localaitools.MiddlewarePIIStatus{
EnabledGlobally: c.PIIEvents != nil,
Models: []localaitools.MiddlewarePIIModel{},
}
piiSection.DefaultEnabledForBackends = []string{"cloud-proxy"}
if c.ConfigLoader != nil {
for _, cfg := range c.ConfigLoader.GetAllModelsConfigs() {
cfg := cfg
piiSection.Models = append(piiSection.Models, localaitools.MiddlewarePIIModel{
Name: cfg.Name,
Backend: cfg.Backend,
Enabled: cfg.PIIIsEnabled(),
Explicit: cfg.PII.Enabled != nil,
DefaultForBackend: cfg.Backend == "cloud-proxy",
Detectors: cfg.PIIDetectors(),
})
}
}
if c.PIIEvents != nil {
if n, err := c.PIIEvents.Count(ctx); err == nil {
piiSection.RecentEventCount = n
}
}
return &localaitools.MiddlewareStatus{PII: piiSection, Router: router}, nil
}
func capabilityFlagsOf(m *config.ModelConfig) []string {
var out []string
for label, flag := range config.GetAllModelConfigUsecases() {
if flag == 0 {
continue
}
if m.HasUsecases(flag) {
// Trim "FLAG_" prefix for prettier output.
out = append(out, label[len("FLAG_"):])
}
}
return out
}