mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 17:18:49 -04:00
* feat(addons): generate and load a static carrier key set A split deployment needs the owner and the frontends to share certificates and a signing key that outlive a process. The loopback carrier makes fresh keys at every start, so it cannot serve that. GenerateCarrierKeys writes a CA certificate, an owner leaf and a frontend leaf, and the frontend signing key pair. It drops the CA key, so nobody can issue a third leaf, and it refuses to overwrite any file. LoadCarrierConfig reads only the files of one role and refuses key files that group or other users can access. The owner never forwards, but NewCarrier still requires a signing key, so the owner config gets a throwaway key in memory instead of the frontends' private key. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): add util addons-carrier-keys An operator who runs the add-on owner and its frontends as separate workloads needs a way to make the shared key set. The command wraps GenerateCarrierKeys: --out names the directory and --owner-host, which can repeat, sets the names the owner certificate is valid for. It prints which files belong on the owner and which on the frontends. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): add the owner and frontend roles to local-ai run Every local-ai run process is its own add-on owner, so a distributed deployment cannot scale its frontends: replicas that share a data volume fight over the owner lock. --addons-role selects combined (today's behaviour, the default), owner or frontend. The owner and frontend roles also take --addons-owner-address and --addons-carrier-dir, and the owner takes --addons-owner-listen. They use the fixed carrier identities and the key set from the carrier key directory. The frontend role has no add-on root, so it never creates the add-ons folder. A bad role setup stops startup with a message that names the flag. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): keep the frontend role away from owner state A frontend holds no add-on state, but it still wrote the library sources file of the data path, which is the owner's file. The frontend role now skips the library option and reads only the galleries named by the flags, and logs a warning when it has no account database, because its pins are then kept per frontend. The add-on manager rejects a relative root even for a forwarding frontend, so the frontend config names the absolute path where an owner would keep its state. Nothing creates or opens it, and a spec checks that the data path stays empty. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): run the owner and frontends as separate applications The split roles are only useful if an owner and its frontends work as separate processes that share nothing but the carrier keys and the owner address. A spec now builds them as separate Application values with their own data paths and keys from GenerateCarrierKeys. It covers a frontend that lists apps and reads add-on status through the owner, two frontends on one key set, a frontend that starts while the owner is down and answers unavailable, a second owner refused on the same root, and an owner that restarts on the same root and address and still serves the installed add-on to the same frontend. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(addons): describe running the owner as its own workload An operator who runs several frontend replicas had no documented way to stop each one from being its own add-on owner. The add-ons page now has a section for the owner and frontend roles: one owner replica with its own volume and a recreate-style rollout, a stable owner address, key generation and per-role mounts, a Compose example, what a restart and an owner outage look like, the models-path limit, and the frontend limits. The singleton section names the roles and the distributed-state mode, and the distributed mode page links to the new section. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): gate the addons feature in /api/features One function, Application.AddonsFeature, decides whether the add-on side is on. A combined or owner process answers true when add-ons are enabled and the owner manager exists. A frontend answers true when the owner answered the carrier's audience call within 2 s, and keeps that answer for 10 s so /api/features does not dial the owner per request. /api/features reports the result as "addons", and fine_tuning and quantization take the same value, because both are add-ons. The frontend cache is per-instance state. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): answer 503 on every add-on route when add-ons are off With --addons-enabled=false the real add-on, fine-tuning and quantization routes and services are not registered. One catch-all per prefix answers the typed unavailable response of an absent owner, for any path under it, so a client cannot tell which routes exist. The response is the one the gateway already writes (gateway.WriteUnavailable over typedError), not a second format. With add-ons on, the same routes sit behind RequireAddonsFeature, which reads Application.AddonsFeature, so a frontend that cannot reach the owner gives the same answer. The per-user feature middleware still runs after the gate. No endpoint is added, so the swagger, /api/instructions and RouteFeatureRegistry surfaces do not change. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): build no carrier for a frontend with add-ons off addonConfigFor already returned the zero config before it read a role flag when add-ons are disabled. Say so on the way out: when a role, owner address, listen address or carrier directory flag is set, log one info line that the flags are ignored. A frontend with --addons-enabled=false needs no owner address and no key directory, loads no keys and builds no carrier. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): hide add-on entries when the addons feature is off The UI reads features.addons from /api/features. When it is false: the sidebar shows no Add-ons entry and no pinned apps, the 404 page lists no add-on destination, the shell does not call /api/addons/pins or /api/addons/builtin, and /app/addons, /app/addons/*, /app/fine-tune and /app/quantize lead back to /app. The Build hub tabs for add-ons, fine-tuning and quantization were already keyed on features and now follow the gate. An absent key (an older server) counts as on. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(addons): describe the addons feature gate The --addons-enabled row now says the UI hides the add-on entries and every add-on, fine-tuning and quantization route answers 503. A new subsection describes the addons key of /api/features, the 10 s frontend cache as per-instance state, and that a frontend with add-ons off needs no owner address or key directory. The fine-tuning and quantization pages say they are off with --addons-enabled=false. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): give a frontend no add-on state root OwnerConfig.validate required an absolute root for every role, so a frontend named a path under the data directory that nothing was allowed to open. Only the owner has a state root now: validate checks it when the instance is the owner, and the frontend role leaves Root empty. Nothing on a frontend reads or creates it. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): make the carrier signing key optional for the owner NewCarrier required an Ed25519 signing key of every instance, so the owner role loaded a throwaway key in memory that it never used. The owner never signs an outbound request, so it now needs none: NewCarrier accepts an empty key when the instance is the owner and still requires a whole key from a frontend, and a malformed key is refused for both. LoadCarrierConfig no longer generates one. The owner still verifies frontend signatures with frontend-sign.pub. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Keep the owner address out of the add-on identity file The owner address is runtime configuration, so storing it and comparing the whole file made a moved owner refuse its own root. The identity file now holds the owner ID and storage ID only. An older file that still has an address loads, and the address is ignored. A different owner ID or storage ID is still refused. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(addons): state that an existing root loads under the owner role The owner identity no longer stores its address, so a root written by a single node loads under the owner role when the owner and storage identity match. Say so, and keep the caveat about absolute paths in the journal. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): refuse library-source routes on a frontend A frontend must keep no owner state, but the library-source routes, the ApplyLibraries call and the startup official-libraries step still opened and wrote addons/sources.json in its data path. Mark the frontend role in the add-on config, also when add-ons are off. On a frontend, LibrarySources returns the unavailable error, ApplyLibraries does nothing and the startup step skips the store. The six /api/library/sources routes answer 503 with the body that the add-on feature gate writes, and an X-LocalAI-Reason header names the cause. Reads are refused too: the carrier has no verb for them. Owner and combined roles are unchanged. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): put the reason in the unavailable body WriteUnavailable wrote the plain text "addon request failed", and the library-source refusal put its reason in an X-LocalAI-Reason header. Write the LocalAI error envelope instead, with a message, a type and the code "unavailable", and keep the 503 status. The message names the cause: add-ons are off on this instance, the add-on owner is not reachable, or library sources are managed on the owner. Remove the header. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): serve library calls on the add-on owner Add the carrier verbs a frontend needs to work on the library through the owner: library-list, library-add, library-update, library-enable, library-disable, library-remove, library-backend-resolve and library-backend-releases. A call carries the operation in one LibraryCall field of the wire request, so an old owner rejects it as an invalid binding. Every verb but the three reads is a mutation, and the reply is an envelope of data or a typed error, so a frontend gives the status the owner's own routes give. The owner serves them through gateway.LibraryAdmin, implemented in the application on the same LocalAdmin as the owner's HTTP routes. It runs the owner's administrator check before any store call. Add two peer-authenticated reads that take no actor and leave the audience reply unchanged: GET /addons/capabilities (protocol, calls and the owner's OS and architecture) and GET /addons/library/snapshot (the gallery-relevant source list and its version). Split the backend install: Installer.Resolve is the owner's half (resolve, pick the meta backend for the asker's capability, pin by digest) and returns a Plan; the node that installs runs the collision check and the gallery install itself. Move the source administration behind library.Admin and add library.ReconcileInfos for a source list read from the owner. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): forward library routes from a frontend to the owner A frontend refused the library-source routes and did not serve the backend library routes. Forward them instead, and keep the owner the only writer of the sources. The library-source routes work on library.Admin: the local store on a combined or owner process, the carrier on a frontend. The frontend checks the calling account (a legacy API key is refused with 401), and the owner checks the administrator right again before any store call. With add-ons off, a frontend answers these routes with a 503. A frontend asks the owner for its source list every 60 seconds through the peer-authenticated snapshot call and rebuilds its model and backend gallery lists, and the official-libraries switch, only when the version changed. If the owner does not answer, it keeps the last lists and marks them stale. Changing official_libraries on a frontend is refused with 409. The backend library install is split. The owner resolves the entry and pins the image; the frontend runs the gallery install on its own node, with its own size limit and strict mode. A frontend refuses library calls to an owner that does not serve them, or that runs on another OS or architecture. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(addons): document library operations on a frontend Replace the frontend limits that said a frontend refuses the library routes with what happens now: the library sources and the backend library go through the owner, the gallery lists follow the owner every 60 seconds, official_libraries is set on the owner, and the owner and the frontends must run on one OS and architecture. Add specs for the split owner: a frontend lists sources through the owner, a source added on a frontend reaches the owner and then the other frontend after a refresh, a write with the owner down answers 503 with the error envelope, official_libraries answers 409 on a frontend and a change on the owner reaches the frontend's gallery lists, the lists are kept and marked stale while the owner is down, a platform mismatch and a legacy API key are refused, and a frontend installs a backend into its own path from the owner's plan. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): check the shared models path with a probe Exports write into the models path of the owner, and frontends load models from their own path. A wrong mount fails silently: the export succeeds and the model never appears. The owner writes a probe file with a random nonce into its models path at start. A frontend fetches the nonce over the carrier and checks at start, then every 60 seconds, that the same file is visible at the same absolute path. On a mismatch it logs an error, reports model_exports false and blocks its export and import routes. The owner refuses an export or import while its own probe is missing, and keeps running if it cannot write the probe. An unreachable or older owner gives "unknown" and leaves exports on. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(api): describe the library refusal and forwarding The library-source and backend-library routes forward to the add-on owner on a frontend and answer 503 when the owner cannot serve them. Add the 503 to their swagger annotations, regenerate the swagger files, and say in /api/instructions that a frontend forwards these routes. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): let frontends run the backend update check through the owner In named-user mode a frontend has no user to act as, so the background backend update check failed there. Add one read-only system call for the release lookup. The owner accepts it only on the peer-authenticated route from a pinned frontend, only for backend-releases, and without an actor. Calls through /addons/forward cannot carry the mark, and every user-facing call still goes through the administrator check. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(mcp): expose the backend update check as a tool Add list_backend_library_updates, a read-only tool for GET /backends/library/updates. The in-process client reads the library installer, and the HTTP client calls the route. The six library source routes stay out of the catalog: the assistant has no per-call actor and no approval card for library changes. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(api): add export status routes for fine-tuning and quantization The add-on UIs could not know that model exports are blocked until a user hit the refusal of the export route. Add GET /api/fine-tuning/status and GET /api/quantization/status. Each returns {"model_exports": bool}, from the same check that drives the model_exports key of /api/features. The routes sit behind the add-ons gate, so they answer 503 when add-ons are off. They are listed in the swagger spec, the instructions registry, the route-feature registry and the feature docs. Assisted-by: Claude:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): disable exports when the models probe fails The fine-tuning and quantization pages now read the new status route and turn off the export form, the checkpoint export button and the import button when model_exports is false. A short reason shows next to the control. A failed or slow answer leaves the controls on, as the server does for an unknown check. Assisted-by: Claude:claude-sonnet-5-5 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>
884 lines
29 KiB
Go
884 lines
29 KiB
Go
// Package httpapi provides a LocalAIClient that talks to a remote LocalAI
|
|
// instance over its REST API. Used by the standalone "local-ai mcp-server"
|
|
// subcommand to control a remote deployment over stdio.
|
|
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/core/gallery"
|
|
"github.com/mudler/LocalAI/core/schema"
|
|
"github.com/mudler/LocalAI/core/services/modeladmin"
|
|
"github.com/mudler/LocalAI/pkg/httpclient"
|
|
localaitools "github.com/mudler/LocalAI/pkg/mcp/localaitools"
|
|
"github.com/mudler/LocalAI/pkg/vram"
|
|
)
|
|
|
|
// Client is a thin REST wrapper. It maps each LocalAIClient method to the
|
|
// matching admin endpoint. Errors from non-2xx responses include the body for
|
|
// the MCP layer to surface verbatim to the LLM.
|
|
type Client struct {
|
|
BaseURL string
|
|
APIKey string
|
|
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
// New returns a Client targeting baseURL with an optional bearer token.
|
|
func New(baseURL, apiKey string) *Client {
|
|
return &Client{
|
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
|
APIKey: apiKey,
|
|
HTTPClient: httpclient.NewWithTimeout(60 * time.Second),
|
|
}
|
|
}
|
|
|
|
// Compile-time assertion.
|
|
var _ localaitools.LocalAIClient = (*Client)(nil)
|
|
|
|
// HTTPError is returned by do() for non-2xx responses. Callers should use
|
|
// errors.Is(err, ErrHTTPNotFound) instead of substring-matching on
|
|
// err.Error() — the latter is brittle to status-code formatting changes.
|
|
type HTTPError struct {
|
|
Method string
|
|
Path string
|
|
StatusCode int
|
|
Body string
|
|
}
|
|
|
|
func (e *HTTPError) Error() string {
|
|
return fmt.Sprintf("%s %s: %d %s: %s", e.Method, e.Path, e.StatusCode, http.StatusText(e.StatusCode), strings.TrimSpace(e.Body))
|
|
}
|
|
|
|
// ErrHTTPNotFound is the sentinel for "the resource you asked for doesn't
|
|
// exist". Match it via errors.Is on an *HTTPError.
|
|
var ErrHTTPNotFound = errors.New("httpapi: not found")
|
|
|
|
// Is supports errors.Is(*HTTPError, ErrHTTPNotFound). The 500-with-text
|
|
// branch is a transitional fallback for /models/jobs/:uuid which today
|
|
// returns a 500 carrying "could not find any status for ID" instead of a
|
|
// proper 404. Drop the branch when the server is fixed.
|
|
func (e *HTTPError) Is(target error) bool {
|
|
if target != ErrHTTPNotFound {
|
|
return false
|
|
}
|
|
if e.StatusCode == http.StatusNotFound {
|
|
return true
|
|
}
|
|
return e.StatusCode == http.StatusInternalServerError && strings.Contains(e.Body, "could not find")
|
|
}
|
|
|
|
// ---- HTTP helpers ----
|
|
|
|
func (c *Client) do(ctx context.Context, method, path string, body any, out any) error {
|
|
var rdr io.Reader
|
|
if body != nil {
|
|
raw, err := json.Marshal(body)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal body: %w", err)
|
|
}
|
|
rdr = bytes.NewReader(raw)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rdr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
if c.APIKey != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.APIKey)
|
|
}
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return &HTTPError{Method: method, Path: path, StatusCode: resp.StatusCode, Body: string(respBody)}
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(respBody, out); err != nil {
|
|
return fmt.Errorf("decode %s %s response: %w (body=%q)", method, path, err, truncate(string(respBody), 200))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "..."
|
|
}
|
|
|
|
// ---- Models / gallery (read) ----
|
|
|
|
func (c *Client) GallerySearch(ctx context.Context, q localaitools.GallerySearchQuery) ([]gallery.Metadata, error) {
|
|
// /models/available already returns []gallery.Metadata — pass it
|
|
// through after applying the LLM-supplied filters client-side.
|
|
var metas []gallery.Metadata
|
|
if err := c.do(ctx, http.MethodGet, routeModelsAvail, nil, &metas); err != nil {
|
|
return nil, err
|
|
}
|
|
limit := q.Limit
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
out := make([]gallery.Metadata, 0, limit)
|
|
needle := strings.ToLower(q.Query)
|
|
tag := strings.ToLower(q.Tag)
|
|
for _, m := range metas {
|
|
if q.Gallery != "" && m.Gallery.Name != q.Gallery {
|
|
continue
|
|
}
|
|
if needle != "" && !contains(m.Name, needle) && !contains(m.Description, needle) && !containsTagsAny(m.Tags, needle) {
|
|
continue
|
|
}
|
|
if tag != "" && !containsTagExact(m.Tags, tag) {
|
|
continue
|
|
}
|
|
out = append(out, m)
|
|
if len(out) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
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 {
|
|
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})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (c *Client) ListGalleries(ctx context.Context) ([]config.Gallery, error) {
|
|
// /models/galleries returns []config.Gallery directly.
|
|
var out []config.Gallery
|
|
if err := c.do(ctx, http.MethodGet, routeModelsGall, nil, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (c *Client) GetJobStatus(ctx context.Context, jobID string) (*localaitools.JobStatus, error) {
|
|
if jobID == "" {
|
|
return nil, errors.New("job id is required")
|
|
}
|
|
var raw struct {
|
|
Processed bool `json:"processed"`
|
|
Cancelled bool `json:"cancelled"`
|
|
Progress float64 `json:"progress"`
|
|
Message string `json:"message"`
|
|
FileSize string `json:"file_size"`
|
|
DownloadedSize string `json:"downloaded_size"`
|
|
Error string `json:"error,omitempty"`
|
|
GalleryElementName string `json:"gallery_element_name"`
|
|
}
|
|
if err := c.do(ctx, http.MethodGet, routeJobStatus(jobID), nil, &raw); err != nil {
|
|
// "no such job" is not a real failure — surface (nil, nil) so the
|
|
// LLM can stop polling without treating the response as an error.
|
|
if errors.Is(err, ErrHTTPNotFound) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return &localaitools.JobStatus{
|
|
ID: jobID,
|
|
Processed: raw.Processed,
|
|
Cancelled: raw.Cancelled,
|
|
Progress: raw.Progress,
|
|
TotalFileSize: raw.FileSize,
|
|
DownloadedFileSize: raw.DownloadedSize,
|
|
Message: raw.Message,
|
|
ErrorMessage: raw.Error,
|
|
}, nil
|
|
}
|
|
|
|
// GetModelConfig is intentionally a stub for the HTTP client: LocalAI's
|
|
// /models/edit/:name endpoint returns rendered HTML, not JSON, so the
|
|
// standalone CLI's `get_model_config` tool surfaces a clear error to the
|
|
// LLM. Tracked under the localai-assistant follow-ups (see
|
|
// .agents/localai-assistant-mcp.md) — once a JSON-only
|
|
// GET /api/models/config-yaml/:name endpoint lands on the server, this
|
|
// method calls it and the stub goes away.
|
|
//
|
|
// FIXME(localai-assistant): wire to a JSON read-back endpoint.
|
|
func (c *Client) GetModelConfig(_ context.Context, _ string) (*localaitools.ModelConfigView, error) {
|
|
return nil, errors.New("get_model_config over HTTP not yet supported by this client; use the in-process inproc client or REST /models/edit/{name}")
|
|
}
|
|
|
|
// ---- Models / gallery (write) ----
|
|
|
|
func (c *Client) InstallModel(ctx context.Context, req localaitools.InstallModelRequest) (string, error) {
|
|
body := map[string]any{"id": req.ModelName}
|
|
if req.GalleryName != "" {
|
|
body["id"] = req.GalleryName + "@" + req.ModelName
|
|
}
|
|
body["name"] = req.ModelName
|
|
if len(req.Overrides) > 0 {
|
|
body["overrides"] = req.Overrides
|
|
}
|
|
if req.Variant != "" {
|
|
body["variant"] = req.Variant
|
|
}
|
|
var resp struct {
|
|
ID string `json:"uuid"`
|
|
StatusURL string `json:"status"`
|
|
}
|
|
if err := c.do(ctx, http.MethodPost, routeModelsApply, body, &resp); err != nil {
|
|
return "", err
|
|
}
|
|
return resp.ID, nil
|
|
}
|
|
|
|
func (c *Client) ImportModelURI(ctx context.Context, req localaitools.ImportModelURIRequest) (*localaitools.ImportModelURIResponse, error) {
|
|
if req.URI == "" {
|
|
return nil, errors.New("uri is required")
|
|
}
|
|
body := map[string]any{"uri": req.URI}
|
|
if req.BackendPreference != "" {
|
|
// Server expects preferences as a JSON object; wrap the backend
|
|
// preference accordingly.
|
|
body["preferences"] = map[string]string{"backend": req.BackendPreference}
|
|
}
|
|
|
|
rawReq, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal body: %w", err)
|
|
}
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+routeModelsImport, bytes.NewReader(rawReq))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
httpReq.Header.Set("Accept", "application/json")
|
|
if c.APIKey != "" {
|
|
httpReq.Header.Set("Authorization", "Bearer "+c.APIKey)
|
|
}
|
|
resp, err := c.HTTPClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
|
|
// 400 with `error: "ambiguous import"` is not a transport error — it's the
|
|
// disambiguation signal. Translate it back into AmbiguousBackend so the
|
|
// MCP layer surface stays identical regardless of in-process vs HTTP.
|
|
if resp.StatusCode == http.StatusBadRequest {
|
|
var amb struct {
|
|
Error string `json:"error"`
|
|
Detail string `json:"detail"`
|
|
Modality string `json:"modality"`
|
|
Candidates []string `json:"candidates"`
|
|
Hint string `json:"hint"`
|
|
}
|
|
if json.Unmarshal(respBody, &amb) == nil && amb.Error == "ambiguous import" {
|
|
return &localaitools.ImportModelURIResponse{
|
|
AmbiguousBackend: true,
|
|
Modality: amb.Modality,
|
|
BackendCandidates: amb.Candidates,
|
|
Hint: amb.Hint,
|
|
}, nil
|
|
}
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("POST %s: %d %s: %s", routeModelsImport, resp.StatusCode, http.StatusText(resp.StatusCode), strings.TrimSpace(string(respBody)))
|
|
}
|
|
|
|
var raw struct {
|
|
ID string `json:"uuid"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &raw); err != nil {
|
|
return nil, fmt.Errorf("decode import response: %w", err)
|
|
}
|
|
return &localaitools.ImportModelURIResponse{JobID: raw.ID}, nil
|
|
}
|
|
|
|
func (c *Client) DeleteModel(ctx context.Context, name string) error {
|
|
return c.do(ctx, http.MethodPost, routeModelDelete(name), nil, nil)
|
|
}
|
|
|
|
func (c *Client) EditModelConfig(ctx context.Context, name string, patch map[string]any) error {
|
|
return c.do(ctx, http.MethodPatch, routeModelConfigJSON(name), patch, nil)
|
|
}
|
|
|
|
func (c *Client) ReloadModels(ctx context.Context) error {
|
|
return c.do(ctx, http.MethodPost, routeModelsReload, nil, nil)
|
|
}
|
|
|
|
func (c *Client) LoadModel(ctx context.Context, model string) ([]string, error) {
|
|
// On a load failure the endpoint returns a non-2xx whose body (carrying the
|
|
// per-sub-model failure detail) is folded into the HTTPError by c.do.
|
|
var resp schema.ModelLoadResponse
|
|
if err := c.do(ctx, http.MethodPost, routeBackendLoad, map[string]string{"model": model}, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.Loaded, nil
|
|
}
|
|
|
|
// ---- Model aliases ----
|
|
|
|
// SetAlias is swap-first: it PATCHes the alias config (a deep-merge that
|
|
// validates the target and preserves any other fields), and only creates a
|
|
// fresh config when the PATCH reports the model doesn't exist yet. We prefer
|
|
// PATCH over POST /models/import for existing names because import rewrites
|
|
// the whole file, whereas PATCH gives a reliable 404 not-found signal
|
|
// (ErrHTTPNotFound) to branch on and never clobbers an existing config.
|
|
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.do(ctx, http.MethodPatch, routeModelConfigJSON(name), map[string]any{"alias": target}, nil)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if !errors.Is(err, ErrHTTPNotFound) {
|
|
return err
|
|
}
|
|
// No such config yet: create it. The import endpoint validates the alias
|
|
// target server-side, same as the PATCH path.
|
|
return c.do(ctx, http.MethodPost, routeModelImport, map[string]any{"name": name, "alias": target}, nil)
|
|
}
|
|
|
|
func (c *Client) ListAliases(ctx context.Context) ([]localaitools.AliasInfo, error) {
|
|
// /api/aliases returns []{name,target} directly - pass it through.
|
|
var out []localaitools.AliasInfo
|
|
if err := c.do(ctx, http.MethodGet, routeAliases, nil, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ---- Backends ----
|
|
|
|
func (c *Client) ListBackends(ctx context.Context) ([]localaitools.Backend, error) {
|
|
var raw []struct {
|
|
Name string `json:"name"`
|
|
Installed bool `json:"installed"`
|
|
}
|
|
if err := c.do(ctx, http.MethodGet, routeBackends, nil, &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]localaitools.Backend, 0, len(raw))
|
|
for _, b := range raw {
|
|
out = append(out, localaitools.Backend{Name: b.Name, Installed: b.Installed})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (c *Client) ListKnownBackends(ctx context.Context) ([]schema.KnownBackend, error) {
|
|
// /backends/known emits []schema.KnownBackend directly — pass through.
|
|
var out []schema.KnownBackend
|
|
if err := c.do(ctx, http.MethodGet, routeBackendsKnown, nil, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (c *Client) ListBackendLibraryUpdates(ctx context.Context) ([]localaitools.BackendLibraryUpdate, error) {
|
|
var resp struct {
|
|
Updates []localaitools.BackendLibraryUpdate `json:"updates"`
|
|
}
|
|
if err := c.do(ctx, http.MethodGet, routeBackendsLibraryUpdates, nil, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.Updates == nil {
|
|
resp.Updates = []localaitools.BackendLibraryUpdate{}
|
|
}
|
|
return resp.Updates, nil
|
|
}
|
|
|
|
func (c *Client) InstallBackend(ctx context.Context, req localaitools.InstallBackendRequest) (string, error) {
|
|
body := map[string]any{"id": req.BackendName}
|
|
if req.GalleryName != "" {
|
|
body["id"] = req.GalleryName + "@" + req.BackendName
|
|
}
|
|
body["name"] = req.BackendName
|
|
var resp struct {
|
|
ID string `json:"uuid"`
|
|
}
|
|
if err := c.do(ctx, http.MethodPost, routeBackendsApply, body, &resp); err != nil {
|
|
return "", err
|
|
}
|
|
return resp.ID, nil
|
|
}
|
|
|
|
func (c *Client) InstallLibraryBackend(ctx context.Context, req localaitools.InstallLibraryBackendRequest) (*localaitools.InstallLibraryBackendResult, error) {
|
|
body := map[string]any{"sourceId": req.SourceID, "name": req.Name, "version": req.Version}
|
|
var resp struct {
|
|
Status string `json:"status"`
|
|
Name string `json:"name"`
|
|
SourceID string `json:"sourceId"`
|
|
}
|
|
if err := c.do(ctx, http.MethodPost, routeBackendsLibraryInstall, body, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return &localaitools.InstallLibraryBackendResult{Status: resp.Status, Name: resp.Name, SourceID: resp.SourceID}, nil
|
|
}
|
|
|
|
func (c *Client) UpgradeBackend(ctx context.Context, name string) (string, error) {
|
|
var resp struct {
|
|
ID string `json:"uuid"`
|
|
}
|
|
if err := c.do(ctx, http.MethodPost, routeBackendUpgrade(name), nil, &resp); err != nil {
|
|
return "", err
|
|
}
|
|
return resp.ID, nil
|
|
}
|
|
|
|
// ---- 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"`
|
|
}
|
|
if err := c.do(ctx, http.MethodGet, routeWelcome, nil, &welcome); err != nil {
|
|
return nil, err
|
|
}
|
|
info := &localaitools.SystemInfo{Version: welcome.Version}
|
|
for name := range welcome.InstalledBackends {
|
|
info.InstalledBackends = append(info.InstalledBackends, name)
|
|
}
|
|
// LoadedModels shape varies; we don't attempt to decode it client-side.
|
|
return info, nil
|
|
}
|
|
|
|
func (c *Client) ListNodes(ctx context.Context) ([]localaitools.Node, error) {
|
|
var raw []struct {
|
|
ID string `json:"id"`
|
|
Address string `json:"address"`
|
|
HTTPAddress string `json:"http_address"`
|
|
Status string `json:"status"`
|
|
}
|
|
if err := c.do(ctx, http.MethodGet, routeNodes, nil, &raw); err != nil {
|
|
// Treat 404/disabled as "no nodes" to keep parity with single-process.
|
|
if errors.Is(err, ErrHTTPNotFound) {
|
|
return []localaitools.Node{}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
out := make([]localaitools.Node, 0, len(raw))
|
|
for _, n := range raw {
|
|
out = append(out, localaitools.Node{
|
|
ID: n.ID,
|
|
Address: n.Address,
|
|
HTTPAddress: n.HTTPAddress,
|
|
Healthy: n.Status == "healthy",
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (c *Client) ListScheduling(ctx context.Context) ([]localaitools.ModelSchedulingConfig, error) {
|
|
var out []localaitools.ModelSchedulingConfig
|
|
if err := c.do(ctx, http.MethodGet, routeScheduling, nil, &out); err != nil {
|
|
if errors.Is(err, ErrHTTPNotFound) {
|
|
return []localaitools.ModelSchedulingConfig{}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (c *Client) GetScheduling(ctx context.Context, modelName string) (*localaitools.ModelSchedulingConfig, error) {
|
|
if modelName == "" {
|
|
return nil, errors.New("model_name is required")
|
|
}
|
|
var out localaitools.ModelSchedulingConfig
|
|
if err := c.do(ctx, http.MethodGet, routeModelScheduling(modelName), nil, &out); err != nil {
|
|
if errors.Is(err, ErrHTTPNotFound) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
func (c *Client) SetScheduling(ctx context.Context, req localaitools.SetSchedulingRequest) (*localaitools.ModelSchedulingConfig, error) {
|
|
if req.ModelName == "" {
|
|
return nil, errors.New("model_name is required")
|
|
}
|
|
var out localaitools.ModelSchedulingConfig
|
|
if err := c.do(ctx, http.MethodPost, routeScheduling, req, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
func (c *Client) DeleteScheduling(ctx context.Context, modelName string) error {
|
|
if modelName == "" {
|
|
return errors.New("model_name is required")
|
|
}
|
|
return c.do(ctx, http.MethodDelete, routeModelScheduling(modelName), nil, nil)
|
|
}
|
|
|
|
func (c *Client) SetNodeVRAMBudget(ctx context.Context, nodeID, budget string) error {
|
|
// PUT with an empty value clears the override server-side (Task 9), so we
|
|
// use PUT uniformly rather than switching to DELETE for the clear case.
|
|
body := map[string]any{"value": budget}
|
|
return c.do(ctx, http.MethodPut, routeNodeVRAMBudget(nodeID), body, nil)
|
|
}
|
|
|
|
func (c *Client) VRAMEstimate(ctx context.Context, req localaitools.VRAMEstimateRequest) (*vram.EstimateResult, error) {
|
|
body := map[string]any{"model": req.ModelName}
|
|
if req.ContextSize > 0 {
|
|
body["context_size"] = req.ContextSize
|
|
}
|
|
if req.GPULayers != 0 {
|
|
body["gpu_layers"] = req.GPULayers
|
|
}
|
|
if req.KVQuantBits > 0 {
|
|
body["kv_quant_bits"] = req.KVQuantBits
|
|
}
|
|
// /api/models/vram-estimate returns a wrapper carrying vram.EstimateResult
|
|
// (size_bytes/size_display/vram_bytes/vram_display) plus context-note
|
|
// fields. Decode directly into EstimateResult — the LLM gets the
|
|
// pre-formatted display strings, identical to REST.
|
|
var out vram.EstimateResult
|
|
if err := c.do(ctx, http.MethodPost, routeVRAMEstimate, body, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// ---- State ----
|
|
|
|
func (c *Client) ToggleModelState(ctx context.Context, name string, action modeladmin.Action) error {
|
|
return c.do(ctx, http.MethodPut, routeToggleModelState(name, string(action)), nil, nil)
|
|
}
|
|
|
|
func (c *Client) ToggleModelPinned(ctx context.Context, name string, action modeladmin.Action) error {
|
|
return c.do(ctx, http.MethodPut, routeToggleModelPinned(name, string(action)), nil, nil)
|
|
}
|
|
|
|
// ---- Branding ----
|
|
|
|
// brandingResponse mirrors the JSON shape emitted by GET /api/branding.
|
|
// We don't import the server-side type here so the MCP HTTP client stays
|
|
// independent of the localai endpoint package.
|
|
type brandingResponse struct {
|
|
InstanceName string `json:"instance_name"`
|
|
InstanceTagline string `json:"instance_tagline"`
|
|
LogoURL string `json:"logo_url"`
|
|
LogoHorizontalURL string `json:"logo_horizontal_url"`
|
|
FaviconURL string `json:"favicon_url"`
|
|
}
|
|
|
|
func (c *Client) GetBranding(ctx context.Context) (*localaitools.Branding, error) {
|
|
var raw brandingResponse
|
|
if err := c.do(ctx, http.MethodGet, routeBranding, nil, &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
return (*localaitools.Branding)(&raw), nil
|
|
}
|
|
|
|
func (c *Client) SetBranding(ctx context.Context, req localaitools.SetBrandingRequest) (*localaitools.Branding, error) {
|
|
// Text fields ride the existing /api/settings POST, which maps the
|
|
// pointer fields onto RuntimeSettings.InstanceName / InstanceTagline.
|
|
body := map[string]any{}
|
|
if req.InstanceName != nil {
|
|
body["instance_name"] = *req.InstanceName
|
|
}
|
|
if req.InstanceTagline != nil {
|
|
body["instance_tagline"] = *req.InstanceTagline
|
|
}
|
|
if len(body) == 0 {
|
|
return c.GetBranding(ctx)
|
|
}
|
|
if err := c.do(ctx, http.MethodPost, routeSettings, body, nil); err != nil {
|
|
return nil, err
|
|
}
|
|
return c.GetBranding(ctx)
|
|
}
|
|
|
|
// ---- Voice profile library ----
|
|
|
|
func (c *Client) ListVoiceProfiles(ctx context.Context) ([]localaitools.VoiceProfile, error) {
|
|
var response struct {
|
|
Data []localaitools.VoiceProfile `json:"data"`
|
|
}
|
|
if err := c.do(ctx, http.MethodGet, routeVoiceProfiles, nil, &response); err != nil {
|
|
return nil, err
|
|
}
|
|
return response.Data, nil
|
|
}
|
|
|
|
func (c *Client) CreateVoiceProfile(ctx context.Context, req localaitools.CreateVoiceProfileRequest) (*localaitools.VoiceProfile, error) {
|
|
var profile localaitools.VoiceProfile
|
|
if err := c.do(ctx, http.MethodPost, routeVoiceProfiles, req, &profile); err != nil {
|
|
return nil, err
|
|
}
|
|
return &profile, nil
|
|
}
|
|
|
|
func (c *Client) DeleteVoiceProfile(ctx context.Context, id string) error {
|
|
if id == "" {
|
|
return errors.New("id is required")
|
|
}
|
|
return c.do(ctx, http.MethodDelete, routeVoiceProfileDelete(id), nil, nil)
|
|
}
|
|
|
|
// ---- Usage / billing ----
|
|
|
|
func (c *Client) GetUsageStats(ctx context.Context, q localaitools.UsageStatsQuery) (*localaitools.UsageStats, error) {
|
|
period := q.Period
|
|
if period == "" {
|
|
period = "month"
|
|
}
|
|
path := routeUsage
|
|
if q.All {
|
|
path = routeUsageAll
|
|
}
|
|
// Build query string. The /api/usage server expects these exact param
|
|
// names; any change there must update both sides.
|
|
qs := url.Values{}
|
|
qs.Set("period", period)
|
|
if q.UserID != "" && q.All {
|
|
qs.Set("user_id", q.UserID)
|
|
}
|
|
if enc := qs.Encode(); enc != "" {
|
|
path = path + "?" + enc
|
|
}
|
|
|
|
var raw struct {
|
|
Viewer struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Role string `json:"role"`
|
|
} `json:"viewer"`
|
|
Totals struct {
|
|
PromptTokens int64 `json:"prompt_tokens"`
|
|
CompletionTokens int64 `json:"completion_tokens"`
|
|
TotalTokens int64 `json:"total_tokens"`
|
|
RequestCount int64 `json:"request_count"`
|
|
} `json:"totals"`
|
|
Usage []struct {
|
|
Bucket string `json:"bucket"`
|
|
Model string `json:"model"`
|
|
UserID string `json:"user_id"`
|
|
UserName string `json:"user_name"`
|
|
PromptTokens int64 `json:"prompt_tokens"`
|
|
CompletionTokens int64 `json:"completion_tokens"`
|
|
TotalTokens int64 `json:"total_tokens"`
|
|
RequestCount int64 `json:"request_count"`
|
|
} `json:"usage"`
|
|
}
|
|
if err := c.do(ctx, http.MethodGet, path, nil, &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
out := &localaitools.UsageStats{
|
|
Viewer: localaitools.UsageViewer{ID: raw.Viewer.ID, Name: raw.Viewer.Name, Role: raw.Viewer.Role},
|
|
Period: period,
|
|
Totals: localaitools.UsageTotals{
|
|
PromptTokens: raw.Totals.PromptTokens,
|
|
CompletionTokens: raw.Totals.CompletionTokens,
|
|
TotalTokens: raw.Totals.TotalTokens,
|
|
RequestCount: raw.Totals.RequestCount,
|
|
},
|
|
Buckets: make([]localaitools.UsageBucket, 0, len(raw.Usage)),
|
|
}
|
|
for _, b := range raw.Usage {
|
|
out.Buckets = append(out.Buckets, localaitools.UsageBucket{
|
|
Bucket: b.Bucket,
|
|
Model: b.Model,
|
|
UserID: b.UserID,
|
|
UserName: b.UserName,
|
|
PromptTokens: b.PromptTokens,
|
|
CompletionTokens: b.CompletionTokens,
|
|
TotalTokens: b.TotalTokens,
|
|
RequestCount: b.RequestCount,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ---- PII filter ----
|
|
|
|
func (c *Client) GetPIIEvents(ctx context.Context, q localaitools.PIIEventsQuery) ([]localaitools.PIIEvent, error) {
|
|
qs := url.Values{}
|
|
if q.CorrelationID != "" {
|
|
qs.Set("correlation_id", q.CorrelationID)
|
|
}
|
|
if q.UserID != "" {
|
|
qs.Set("user_id", q.UserID)
|
|
}
|
|
if q.PatternID != "" {
|
|
qs.Set("pattern_id", q.PatternID)
|
|
}
|
|
// The MCP get_pii_events tool is PII-shaped; the events store is now
|
|
// shared with proxy events that have no pattern_id/action. Scope to
|
|
// kind=pii so the LLM-facing audit stays coherent.
|
|
qs.Set("kind", "pii")
|
|
if q.Limit > 0 {
|
|
qs.Set("limit", fmt.Sprintf("%d", q.Limit))
|
|
}
|
|
path := routePIIEvents
|
|
if enc := qs.Encode(); enc != "" {
|
|
path = path + "?" + enc
|
|
}
|
|
|
|
var raw struct {
|
|
Events []localaitools.PIIEvent `json:"events"`
|
|
}
|
|
if err := c.do(ctx, http.MethodGet, path, nil, &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
return raw.Events, nil
|
|
}
|
|
|
|
func (c *Client) GetMiddlewareStatus(ctx context.Context) (*localaitools.MiddlewareStatus, error) {
|
|
var out localaitools.MiddlewareStatus
|
|
if err := c.do(ctx, http.MethodGet, routeMiddleware, nil, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
func (c *Client) GetRouterDecisions(ctx context.Context, q localaitools.RouterDecisionsQuery) ([]localaitools.RouterDecision, error) {
|
|
qs := url.Values{}
|
|
if q.CorrelationID != "" {
|
|
qs.Set("correlation_id", q.CorrelationID)
|
|
}
|
|
if q.UserID != "" {
|
|
qs.Set("user_id", q.UserID)
|
|
}
|
|
if q.RouterModel != "" {
|
|
qs.Set("router_model", q.RouterModel)
|
|
}
|
|
if q.Limit > 0 {
|
|
qs.Set("limit", fmt.Sprintf("%d", q.Limit))
|
|
}
|
|
path := routeRouterDecisions
|
|
if enc := qs.Encode(); enc != "" {
|
|
path = path + "?" + enc
|
|
}
|
|
var raw struct {
|
|
Decisions []localaitools.RouterDecision `json:"decisions"`
|
|
}
|
|
if err := c.do(ctx, http.MethodGet, path, nil, &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
return raw.Decisions, nil
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
func contains(haystack, lowerNeedle string) bool {
|
|
return strings.Contains(strings.ToLower(haystack), lowerNeedle)
|
|
}
|
|
|
|
func containsTagsAny(tags []string, lowerNeedle string) bool {
|
|
for _, t := range tags {
|
|
if strings.Contains(strings.ToLower(t), lowerNeedle) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containsTagExact(tags []string, lowerNeedle string) bool {
|
|
for _, t := range tags {
|
|
if strings.EqualFold(t, lowerNeedle) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (c *Client) GetRouterCorpusStats(ctx context.Context, routerModel string) (*localaitools.RouterCorpusStats, error) {
|
|
var out localaitools.RouterCorpusStats
|
|
path := fmt.Sprintf("/api/router/%s/corpus/stats", url.PathEscape(routerModel))
|
|
if err := c.do(ctx, http.MethodGet, path, nil, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
func (c *Client) SeedRouterCorpus(ctx context.Context, req localaitools.RouterCorpusSeedRequest) (*localaitools.RouterCorpusSeedResult, error) {
|
|
// The REST body carries entries only; the router rides in the path.
|
|
body := struct {
|
|
Entries []localaitools.RouterCorpusEntry `json:"entries"`
|
|
}{Entries: req.Entries}
|
|
var out localaitools.RouterCorpusSeedResult
|
|
path := fmt.Sprintf("/api/router/%s/corpus", url.PathEscape(req.Router))
|
|
if err := c.do(ctx, http.MethodPost, path, body, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
func (c *Client) ClearRouterCorpus(ctx context.Context, routerModel string) (*localaitools.RouterCorpusClearResult, error) {
|
|
var out localaitools.RouterCorpusClearResult
|
|
path := fmt.Sprintf("/api/router/%s/corpus", url.PathEscape(routerModel))
|
|
if err := c.do(ctx, http.MethodDelete, path, nil, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// ---- Failover chains ----
|
|
|
|
func (c *Client) ListFailoverChains(ctx context.Context) ([]localaitools.FailoverChainInfo, error) {
|
|
var out struct {
|
|
Chains []localaitools.FailoverChainInfo `json:"chains"`
|
|
}
|
|
if err := c.do(ctx, http.MethodGet, routeFailover, nil, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out.Chains, nil
|
|
}
|
|
|
|
func (c *Client) PinFailoverTarget(ctx context.Context, chain, target string) error {
|
|
return c.do(ctx, http.MethodPost, routeFailover+"/"+url.PathEscape(chain)+"/pin", map[string]string{"target": target}, nil)
|
|
}
|
|
|
|
func (c *Client) CancelModelLoad(ctx context.Context, model, jobID string) (localaitools.LoadCancelResult, error) {
|
|
var result localaitools.LoadCancelResult
|
|
err := c.do(ctx, http.MethodPost, "/api/models/"+url.PathEscape(model)+"/load-cancel", map[string]string{"job_id": jobID}, &result)
|
|
return result, err
|
|
}
|
|
|
|
func (c *Client) UnpinFailoverTarget(ctx context.Context, chain string) error {
|
|
return c.do(ctx, http.MethodDelete, routeFailover+"/"+url.PathEscape(chain)+"/pin", nil, nil)
|
|
}
|