mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-04 11:33:22 -05:00
a6589e4e609c88d2a2bcbd2eeffb166e2717551e
126
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4899d28e8a |
feat(chats): server-side chats with steering, sub-agents and compaction (#12918)
* Find the Sandbox guest image in the executable, not its build info
The Sandbox smoke checked that the packaged executable carries the
guest image by looking for -X main.guestImage in go version -m. The
package is built with -trimpath, and with -trimpath Go leaves -ldflags
out of the build info, so the check failed on every gvisor run,
although the image was linked in.
Look for the reference in the executable instead: the value of
SANDBOX_GUEST_IMAGE when it is set, any digest reference otherwise.
package.sh had the same check for an executable built elsewhere; it
now looks for its own reference the same way.
Assisted-by: Claude:claude-opus-5-5
* Answer a network card the limit refuses with one precise reason
A card that waited while the administrator's limit went down was
refused on the first click with 400, and every later click was a bare
409, so a stale card looked like a conflict loop. A refused card now
answers 400 egress_above_ceiling every time, the same answer sent twice
is one answer (204), and another answer to an answered card is 409
already_answered. The chat view marks a card the limit cannot admit
with above_ceiling.
When the limit is off, or below the chat's preset, the note of the turn
and the block line of the proxy now say that the server's setting
blocks the network and no approval changes it, so the model reports it
instead of waiting or retrying.
Assisted-by: Claude:claude-sonnet-5-5
* Explain a blocked Sandbox network instead of offering dead buttons
A card the limit cannot admit shows why network access is off or
limited, and who can change it, with a link to Settings > Tools >
Sandbox network for administrators. The buttons of a card disable while
an answer is sent, and a failed answer reads the card again. The
Network row says why its choices are capped. Settings opens a group
from ?group=. Strings are translated in all nine locales and the chat
and add-on docs describe the new behaviour.
Assisted-by: Claude:claude-sonnet-5-5
* Add an always-allowed hosts list to the Sandbox network
The extra registries setting only widens the package registries, so a
one-off host needs the approval card in every chat. The new admin
setting chats_egress_allowed_hosts lists exact hosts (optionally
host:port to pin a port) that a chat reaches whenever its effective
preset is registries or all.
The list sits in the same decision as the presets: the ceiling still
caps it, the ports setting still applies, and a private, loopback,
link-local or metadata address still needs the private switch and an
administrator's rule or answer. Each use is audited with the reason
allowed_host. Hosts are validated like the extra registries (lower
case, punycode, no scheme, path or pattern, at most 64) and an IP
literal is refused unless the private switch is on.
get_chat_egress and the chat egress view return the list; no MCP tool
writes it, as for the extra registries.
Assisted-by: Claude:claude-sonnet-5-5
* Let users delete from the Trash for good
A delete only set a tombstone, so the Trash grew without end and
nothing could free the space of a deleted file.
The workspace stores gain a Purger: it erases the deleted nodes of
some change sets, or of a whole folder, with their journal rows, the
change sets that nothing is left in, and the blobs that no row refers
to. Both stores implement it and pass the same contract specs. A blob
is removed after a grace period, and only if it is still unused then,
so an upload of the same bytes in flight keeps them.
DELETE /api/chats/files/trash empties the user's Trash and
DELETE /api/chats/files/trash/{rev} deletes one entry. Both are scoped
to the caller's home. The Trash listing now gives files, folders and
bytes per entry and for the whole Trash. The file tools of a chat
cannot reach any of this.
Assisted-by: Claude:claude-sonnet-5-5
* Add Empty Trash and Delete permanently to the Files page
The Trash tab shows what it holds, with an Empty Trash button that is
off while the Trash is empty and a Delete permanently button on each
item. Both ask in the page first and say how many files and folders and
how much space they remove. Texts are translated in all nine locales.
Assisted-by: Claude:claude-sonnet-5-5
* Fall back to Brave Search and Bing when DuckDuckGo asks for a captcha
DuckDuckGo answers some servers with HTTP 202 and a bot check (the
"anomaly" page) instead of results. The parser read that page as "no
results it could read", so web_search failed for every query.
The keyless search is now a chain: DuckDuckGo, then the result page of
Brave Search, then the result page of Bing. Each page has its own
tolerant parser (title, URL, snippet; Bing's /ck/a links and DDG's uddg
links are unwrapped) with saved fixtures.
A bot check is a typed error (ErrChallenged), and a 403 or 429 is
ErrBlocked. Both wrap ErrProvider. A Fallback with a cool-down skips a
challenged or blocked provider for a few minutes, so each search does
not wait for it first; when every provider is cooling down, all are
asked again. Fallback.SearchAttempts and TestEach report what each
provider did (ok, no_results, challenged, blocked, timeout, error,
skipped).
The error a model reads now lists what was tried and what each provider
did, then where an administrator fixes it.
safehttp keeps the status code of an answer (Response.StatusCode) and
of a refusal (StatusError, still ErrStatus for errors.Is).
Assisted-by: Claude:claude-opus-5-5
* Use the keyless search chain in the chats and test each provider
The built-in web_search now searches with DuckDuckGo, Brave Search and
Bing in turn when no SearXNG instance is set. A new runtime setting,
chats_web_search_providers, sets the order (searxng, duckduckgo,
brave_html, bing_html) and leaves a provider out. Empty keeps the old
choice: SearXNG alone when an instance is set, so its queries do not
reach the public engines unless the administrator lists them.
POST /api/chats/admin/web/test asks every provider of the order and
returns what each did in "providers" (ok, no_results, challenged,
blocked, timeout or error, with the reason), next to the first answer.
Settings > Tools has a Test search on the web_search row that shows it,
and the provider list is checked on the page and by the server.
The cool-down of a challenged provider is kept per frontend, like the
cache and the limits; web.md says why.
Docs: web.md (providers, chain, bot checks, privacy, how to fix it with
SearXNG or the Web add-on) and runtime-settings.md. Strings in the
nine locales. Swagger regenerated.
Assisted-by: Claude:claude-opus-5-5
* Try the mirror library alone in the reinstall recovery spec
The spec adds a second library with the same URL and expects an
install from it to keep the retained data apart. Since two enabled
libraries that list one add-on are a conflict, the reinstall from the
original library then failed with library_conflict, and the install
from the mirror failed for that reason instead of the trusted identity.
Switch the original library off while the mirror is tried, check that
the mirror is refused for its identity and not for a conflict, then
switch the mirror off and the original on for the reinstall.
Assisted-by: Claude:claude-opus-5-5
* Pause a chat turn at the step limit and offer Continue
A chat turn ran at most 10 rounds of tool calls, the default of the
harness, because nothing set a limit. A multi-step task (read some
files, run commands, write a result) stopped half way, and the person
saw an answer that looked final.
The limit is now the runtime setting chats_max_iterations (1 to 500,
default 40, Settings > Tools); a model's agent.max_iterations still
wins. When the tool loop reaches it, the harness sends a paused event
with the limit, and the turn ends with last_turn "paused". A paused
turn counts as finished: waiting messages start the next turn and a
sub-agent reports back. The status line says "Paused, after 40 steps"
and has a Continue button, which sends a message that asks the model
to go on.
A claim that found no waiting message released the chat as "done",
which hid a pause. It now keeps the status the chat had.
Assisted-by: Claude:claude-opus-5-5
* Edit the always-allowed Sandbox hosts in Settings > Tools
Add a list editor for chats_egress_allowed_hosts next to the extra
package registries: the hosts are tags with a remove button, an input
adds one on Enter, comma or Add (a pasted list adds them all), and the
reason an entry is refused shows under the input, with the count out
of 64. The same checks stop Apply on a bad entry, and the server still
has the last word.
The chat's Network panel lists the hosts read only and the row says
how many an administrator always allows while the chat's network is on.
The help text of both settings now says how they differ. Strings are
translated in all nine locales.
Assisted-by: Claude:claude-sonnet-5-5
* Document the always-allowed Sandbox hosts
Describe chats_egress_allowed_hosts in the runtime settings page, the
network section of the add-ons page (including the allowed_host audit
reason and how the list differs from extra registries) and the chats
page.
Assisted-by: Claude:claude-sonnet-5-5
* Let a chat edit, pause and delete the person's triggers
A chat could propose a trigger and list them, but not change one: the
Triggers page and the API could. list_triggers now gives each trigger a
stable ref (not its id) and shows the prompt of one trigger only when
asked. update_trigger and delete_trigger store a change record in the
trigger table: an edit or a delete waits for Apply or Delete on a card,
a pause or a resume applies at once with a receipt. An applied change
can be undone for a day; a delete restores the trigger with its id.
Change records are rows in the database, so every frontend shows and
answers the same card. The new routes under /api/chats/triggers/changes
serve the card and are gated by the triggers feature.
Assisted-by: Claude:claude-opus-5-5
* Show the trigger changes a chat asks for as cards
An edit lists each changed field before and after with Apply and
Dismiss, a delete shows the trigger and its last run with Delete and
Keep, and an applied change is a receipt with Undo. The cards sit below
the message text like the proposal card, and read their state from the
server so a reload shows the same answer.
Assisted-by: Claude:claude-opus-5-5
* Add a Test search button to the web tools settings
POST /api/chats/admin/web/test existed, but no page called it and the
docs showed only curl. The web_search row of Settings > Tools now has
a Test search button. It runs one search with the applied settings and
shows the provider with its first results, or the error a chat would
read.
Assisted-by: Claude:claude-opus-5-5
* Describe undo per tool call in the chats docs
The docs said the checkpoint line of a turn carries the change set of
the turn. A turn checkpoint is taken only by a chat with a VM sandbox,
which the service never sets up, so that rev is always 0. What ships
is one change set per file tool call, with a files_change card on its
tool line. The docs now say so and how to undo a whole turn.
Grouping a turn's changes into one change set in the file store is
left for later: it touches the file store's revisions and the undo of
shared folders.
Assisted-by: Claude:claude-opus-5-5
* Mount the skill folders read-only for the built-in bash
The built-in pdf skill ships scripts, but the commands saw only the
home and the shared folders, so the model could read the scripts and
not run them. The built-in bash now mounts the three skill layers
read-only, at the names the file tools already use: shared-skills,
shared-builtin-skills and shared-addon-skills. A shared folder named
skills moves to "shared-skills 2", so the two never collide.
A read-only mount with subfolders made every command report its
folders as changes the mount refused, because the host backend lists
every folder of its work tree. A folder alone is no change: both
stores now refuse only files, whiteouts and opaque folders there.
The Sandbox add-on mounts only the home; the docs say to copy a
script there first.
Assisted-by: Claude:claude-opus-5-5
* Find the add-on that runs commands by what it shadows
The chats knew the add-on that runs commands from a per-process cache,
filled with the Sandbox's own tool name the first time a chat listed
its tools. Another add-on that replaces bash did not count, so skills
that need commands showed as unavailable. The admin network view read
the same cache: after a restart it said no Sandbox serves until
someone chatted, each frontend of a cluster could answer differently,
and a removed add-on stayed listed.
The tool that runs commands is now the one that shadows bash, as the
spec asks. sandboxRunTool and the cache are gone. The stand-in of an
add-on that cannot work carries the built-in tools it replaces, and
the admin view asks the add-on host which add-on serves bash now: the
pinned one or the oldest running install, and none when the pinned one
is stopped.
Assisted-by: Claude:claude-opus-5-5
* Show international host names in punycode in the network checks
The card-spoofing defence of the sandbox network asks for host names
in their ASCII form, but CheckHost passed Unicode names through. A
look-alike name reached the consent card as text that reads like the
real site.
CheckHost now converts an international name with the IDNA lookup
profile and refuses one with no valid ASCII form. The conversion runs
before the address checks, so full-width digits that spell a loopback
address are refused as loopback.
Assisted-by: Claude:claude-opus-5-5
* Keep the seeded Studio and Talk pins until the add-ons are installed
The upgrade pins Studio and Talk for existing accounts, but both are
add-ons that an administrator installs later. The sweep marked a pin
whose add-on is not installed as missing and deleted it after 30
days, so an administrator who installed Studio late found the seeded
pins gone.
The sweep now records the first time a seeded add-on is present, in
the table of seed runs or the pins file. A seeded pin keeps no clock
until then, and a mark that an older sweep set is cleared. After the
first install the pin follows the retention rule as before.
Assisted-by: Claude:claude-opus-5-5
* Fix two small labels of the Skills and Backends pages
On the Skills page, the skill of an add-on that is not chosen said
"Replaced by <add-on>", the label of a built-in skill that an add-on
serves. It now says "Not active: <add-on> provides it", as the add-on
skills spec asks.
On the Backends page, Clear filters on an empty Installed search left
the library filter set, so the list stayed empty. It now clears the
library filter too.
Assisted-by: Claude:claude-opus-5-5
* Tell the model when no network card can appear
Each turn told the model that a blocked host asks the user. No card
appears when the ceiling is off or no wider than the chat's preset, in
an unattended run or in a sub-agent, so the model waited for an answer
that could not come.
The line now says that a blocked host is refused and nothing asks the
user in those cases, so the model names the host it needs in its
answer.
Assisted-by: Claude:claude-opus-5-5
* Translate six Backends and preview labels in every locale
check, reinstallBackend, deleteBackend and filterLabel of the Backends
page and the label and failed text of the file preview showed in
English in the eight other locales. They now have translations, and
the i18n baseline goes down to match.
Also fix a comment of the home composer that still named the removed
Canvas chip.
Assisted-by: Claude:claude-opus-5-5
* List the admin routes that have no assistant tool, with the reason
The admin oversight routes of saved agents, triggers and connectors
had no MCP tool and no recorded reason, and the other deliberate skips
lived only in code comments. coverage_test.go now keeps them in
adminRoutesWithoutTool with a reason each, and a spec checks that no
route there is also wrapped by a tool. The contributor checklist names
the map.
The tool catalog of the assistant docs said it matched the real one
but missed most tools added since. It now lists every tool, read-only
and mutating, and the actions that stay in the web interface.
Assisted-by: Claude:claude-opus-5-5
* Close the doc and registry gaps of the chats and add-ons
- /api/agent/* answers 404 with the chats_off reason when the chats
are off, as /api/chats/* and /api/workspace/* do.
- The add-ons docs get an upgrade section: Studio and Talk are
add-ons, nothing is preinstalled, and users need the addons feature,
which the upgrade does not grant. It says how an administrator
installs them (also from a private library) and grants the feature,
and carries a paragraph for the release notes.
- The migration page no longer lists fork, edit, the chat settings and
the token counters as removed.
- Runtime settings document chats_vision_model,
chats_transcription_model and the skill keys of
chats_tool_providers.
- The docs show POST /api/chats/files/folders,
GET /api/chats/files/history and the allow route of a connector
sender.
- The in-tree specs get amendments where the shipped design differs.
- A route test of the tagged suite still set the removed Runs field.
Assisted-by: Claude:claude-opus-5-5
* Look for the kit empty state in the backends regression spec
The Backends page now draws the shared EmptyState of the kit
(ui-empty), not the old dk-empty block, so the regression spec of the
gallery suite found no empty state after a search with no match.
Check the kit's no-results form and its title.
Assisted-by: Claude:claude-opus-5-5
* Ignore a features request that outlives its page in the add-on stubs
stubAddonRoutes answers /api/features by fetching the real flags and
turning add-ons on. A spec that ends while the page polls, like the
one that checks that a viewer without the feature never reads the
list, left that fetch in flight, and Playwright failed the spec with
"route.fetch: Target page, context or browser has been closed" after
its checks had passed. Abort the request when the fetch fails because
the page is gone.
Assisted-by: Claude:claude-opus-5-5
* Bring the gallery-ui case inventory up to date
The suite lists the node unit cases of addons.test.mjs and the
Playwright specs of addons-gallery.spec.js. The inventory missed the
unit cases added since it was written, and one spec was renamed when
the legacy switch came in ("hub: the Add-ons tab appears only for an
account that holds the feature" is now "hub: under the legacy switch
the Add-ons tab appears ..."). The list comes from a local run of
both parts against the UI test server.
Assisted-by: Claude:claude-opus-5-5
* Read spreadsheets, documents and slides in read_document
read_document said "file.xlsx has no text layer; it may be a scan" for
workbooks that some Python libraries write. Their workbook gives the
sheet as an absolute part name (/xl/worksheets/sheet1.xml) and the
cells as inline strings. The text extractor of the harness joined
"xl/" with that name, found no sheet, and returned no text; the tool
then took any empty result for a scanned PDF.
The new package core/services/studio/extract has one entry point,
Extract(ctx, name, data), that returns the text and the structure it
read, for read_document now and for a file indexer later. Office files
go through the files preview reader, which resolves both kinds of part
names and reads inline strings:
- a workbook comes sheet by sheet, rows as tab-separated lines, up to
2000 rows and 50 columns a sheet, with a note for a cut sheet;
- a Word document gives headings, list items, paragraphs and tables;
- a presentation gives each slide with its title, text and notes.
PDF and EPUB keep the harness extractor. Only a PDF with no text is
reported as a probable scan; an empty Office file is reported as empty.
The preview reader now keeps a Word table as one block with its rows,
and the preview pane draws it as a table.
Assisted-by: Claude:claude-opus-5-5
* Add the missing explorer, library and tools translations
Eight locales had no explorer.json, library.json or tools.json. The
browser asks the server for every namespace of the language it shows,
so an Italian page that used the library answered 404 for
/locales/it/library.json on every load and fell back to English.
The 24 files now exist, translated, with every key of English. The
locale gate counted a missing file as missing keys, which the baseline
allowed. It now fails on a missing namespace file whatever the
baseline says, and the baseline is 24 entries shorter.
Assisted-by: Claude:claude-opus-5-5
* Back off while the add-on service is unavailable
A page that followed an add-on upgrade read the operation every two
seconds, up to 300 times. While the add-on service restarted, each read
waited for its timeout and answered 503, so the log of one session had
48 failed dispatches in a row, and the page only showed the upgrade as
running. The Sandbox state of a chat's Tools menu read once and, on a
503, said nothing more.
The reads of an operation now back off while the service does not
answer: 2, 4, 8, then up to 30 seconds. The upgrade entry says that it
waits for the add-on service and when it checks again, in Activity and
in the details, which also get a Check now button. The Tools menu says
that the Sandbox is starting and reads its state again with the same
back-off, kept for the page, so opening another chat does not ask
again before the wait is over.
Assisted-by: Claude:claude-opus-5-5
* Start a chat with its switches and list them before it exists
The composer of a new chat could not show the Tools and Skills menus,
because the lists and the switches belong to a chat. A chat started
from Home therefore always began with every tool and skill on.
- POST /api/chats takes switches: the add-ons, MCP servers and
connectors, built-in groups and skills that are off, and the opt-in
groups that are on. They are checked as the PUT endpoints check them
and stored with the new record, so the chat never exists without
them; a bad entry refuses the request and creates nothing. The SQL
store now writes these lists on create too.
- GET /api/chats/draft/tools and /api/chats/draft/addons list what a
chat on a model, with a saved agent, could use, every switch in its
default state. They refuse what create refuses: an unknown or
forbidden model, an agent without the agents feature, another
user's agent.
Document both in the chats page and regenerate the swagger.
Assisted-by: Claude:claude-opus-5-5
* Give Home the composer of the chat page
Home drew a smaller message box than the chat page: no files, no
agent, no Tools or Skills menus, no paste to attach, no rich model
list, and its text was lost on a reload. A chat started there could
not be set up before its first message.
Move the composer wiring out of the chat page into one place that
both pages use:
- useChatComposer holds the draft (text and files), the model and the
saved agent of a new chat, the chat models, the switches and what
attaching and pasting do.
- ChatComposer draws the model list (fit, vision, memory bar), the
agent picker, the Tools and Skills menus, the attach buttons,
dictation, the add-on slot and the vision notice over HomeComposer.
- useChatSwitches has a draft mode: before the chat exists it lists
from the draft endpoints, a switch changes only the screen, and the
choices go with the create request. They outlive a change of model
or agent.
Home now creates the chat with the model, the agent and the drafted
switches, sends the first message with its files and opens the chat.
When the message cannot go, the new chat is removed and the text and
files stay on Home. Its text is kept in the browser across a reload.
Home also gets the shortcuts row, the notices of the chat page and the
line that says whether the model is loaded. Its own slash actions,
widgets, examples and first-run steps stay.
The empty chat page gets the same draft menus. On a phone the chips
of both wrap to a second row, and the Tools and agent menus open as a
sheet from the bottom, as the skills picker does.
Assisted-by: Claude:claude-opus-5-5
* Move add-on settings to the add-on's own page
The settings.sections slot drew every add-on's settings at the end of the
global Settings page, and the Sandbox and Web sections were unstyled: the
add-on UIs used class names the host does not define (btn, btn-primary)
and bare inputs, the SDK offered no form primitives, its muted token
named a property that does not exist, and the slot root was an
inline-flex span that laid a form out in a row.
An add-on's settings sections now render on the Settings tab of its
details on the Add-ons page (/app/addons?detail=<id>&view=settings),
narrowed to that add-on, for administrators only. An add-on page gets a
host Settings link to that tab. The Settings page no longer draws the
slot: an Add-ons group, shown when an add-on has settings, lists those
add-ons with a link each. The old #ad-section-<addon>-<id> anchor
redirects to the tab once the apps list is read. Server settings of an
add-on's feature stay on the Settings page; the tab shows them under
"Server settings for this add-on", with a read-only summary of the
Sandbox network and links to ?group=tools#settings-egress and
#settings-builtin-tools.
The SDK gets a settings kit in sdk.ui (Section, Field, Select,
TextInput, Switch, Choice, StatusChip, Callout, List/ListRow, Code,
Actions, Table, ExternalLink), drawn with host classes and tokens, and
more theme tokens. The Sandbox and Web sections are rebuilt with it:
backend rows with a status chip and the raw reason collapsed in a code
block, a backend choice with one line per option, and the AppArmor and
user-namespace hint (with a docs link) only for the gVisor
"exit status 128 ... cannot read client sync file" failure.
The Settings page showed "Changed, default 30, Reset" next to a 0 for the
audit retention, and the same for the Sandbox network limit, preset and
ports: the server stores 0 or an empty value for a setting never set.
Those fields now read that value as their default, and the retention
input has its unit and bounds.
Assisted-by: Claude:claude-opus-5-5
* Adapt the add-on runtime specs to the new navigation
Three specs of the esm-ui suite still assumed the old shell:
- the Add-ons tab that lists the pages add-ons declare is part of the
legacy layout, so the navigation spec found no hub subnav; it now
sets the legacy switch on the server's flags for that page;
- the sidebar has no "Chat" link any more, so the unsaved-edits spec
could not leave the page; it leaves through Home in the primary
navigation;
- the shell keeps a notification stream open, so the network never
goes idle and the spec for a member without the feature waited out
its timeout; it waits for the server's switches, the account and
the shell instead.
Assisted-by: Claude:claude-opus-5-5
* Name the legacy Add-ons tab unit case as the esm unit tests do
The esm-ui inventory listed "unit: registry: the Add-ons tab lists
page links only when the registry has usable add-ons", but the unit
test is named "the legacy Add-ons tab lists ...", so the suite could
never match its inventory. Use the name the test prints.
Assisted-by: Claude:claude-opus-5-5
* Say on the Collections page when the embedding model is missing
At startup the server opened a collection and its first embedding call
got 404 from POST /embeddings. The collections embed through this
server's own API with collections_embedding_model, the default
granite-embedding-107m-multilingual, and that model was not installed.
Nothing told the user: uploads and searches just failed.
GET /api/collections now also returns embedding_model and
embedding_model_installed (a loaded configuration of that name, or a
model file of that name). When it is missing, the Collections page
says "No embedding model installed", names the model, and gives an
administrator a button that opens the Models page with a search for
it.
Assisted-by: Claude:claude-opus-5-5
* Report modified times and the upload limit in folder listings
The Files page could not sort by date or show when a file changed: a
folder listing had no time. It also learned the upload limit only after
it had sent the whole file. The default server body limit is 15 MB,
lower than the 256 MiB a workspace file may have, so a large upload
failed late and without a clear reason.
Each listed entry now has "modified", the time of the change set of
its revision. Both workspace stores fill it in List: the JSON store
from its in-memory change sets, the database store with one query per
batch of revisions. The listing also has "max_file_size", the smaller
of the workspace limit and the server body limit, so a client can
refuse a larger file before it sends any byte.
The store contract suite checks the new time against the history, and
the route tests check both fields.
Assisted-by: Claude:claude-opus-5-5
* Add a sortable file list with thumbnails for the Files page
The building blocks of the new file browser, used by the page in a
later change:
- fileBrowser.js: the kind of an entry from its extension, the order
(folders first, natural and case-insensitive names, then size,
modified time or type), the filter of a folder, dates in words with
the full date for a tooltip, the Shift range, the arrow moves of a
list and of a grid, type-to-find, names that do not clash, why a
name cannot be used, the name of a pasted screenshot and the view
settings read back from storage.
- FileList: a grid with sortable column headers or a grid of tiles
with image thumbnails, a check box per row, a row menu, inline
rename with the reason a name is refused, rows that can be dragged
onto folders, and the keyboard: arrows, Shift, Space, Enter, F2,
Delete, Ctrl+A, Escape, Backspace and typing a name.
- MoveDialog: a folder picker that walks the tree and refuses a move
into the moving folder itself.
- ActionMenu can open at a point with no trigger (a right click), and
its trigger can take another icon and class (a split button).
- fileSize also writes gigabytes.
Assisted-by: Claude:claude-opus-5-5
* Add an upload queue with progress, cancel and retry
An upload of the Files page was one fetch per file in a loop: nothing
showed while it ran, one failure stopped the rest, and leaving the
page lost it.
- uploadQueue.js keeps every upload outside React: queued, sending
with the bytes sent and a smoothed rate, done, failed with a reason
and whether Try again can help, or canceled. Two files go at once.
It lives for the whole app, so opening another folder or page does
not stop an upload.
- filesApi.uploadWithProgress sends the same PUT with XMLHttpRequest,
which reports upload progress and can be aborted. The server already
streams the body into the store; nothing changes there.
- useUploads reads the queue and asks before the tab closes while a
file is on its way.
- UploadTray shows the queue at the bottom of the page: the total and
the time left, each file with its progress, speed and time left,
Cancel, Try again with the reason, and a done state that fades out.
A live region tells a screen reader when uploads start, fail and
end.
- ConflictDialog asks what to do when an upload has the name of a file
in the folder: replace it, keep both, or skip it.
- dropFiles.js reads dropped folders with their paths.
Assisted-by: Claude:claude-opus-5-5
* Rebuild the Files page as a file browser
The Files page was a list of links: the store's byte order, the same
icon for every file, three icon buttons on every row, no progress on
an upload, no selection, and on a phone a preview that opened below
the whole list.
Browsing: a breadcrumb that folds a deep path, sortable columns (name,
modified, type, size) with folders first, a list or a grid of
thumbnails, both kept per user in the browser, a filter of the open
folder with a search of every file on Enter, type icons, human sizes,
dates in words with the full date on hover, and Back and Forward
between folders.
Uploads go through the upload queue and its tray: the Upload button
or its folder menu, drops of files and folders anywhere on the page or
onto a folder row or crumb, and paste. A clash asks to replace, keep
both or skip; a file over the server limit is refused before it is
sent.
Selection and actions: click, Ctrl and Shift, check boxes, a selection
bar (Download, Move to, Move to Trash; at the bottom of the screen on
a phone), a row menu and a right click, inline rename and new
folder or file, drag to move, the keyboard, and a toast with Undo for
a delete, a move and a rename.
States: a skeleton on the first load, an error with Try again, empty
states that say how files get here, and a no-match state. The preview
uses the side pane: docked on a wide screen, a sheet on a phone, with
the same actions as the row menu. Trash, History and the Managed by
LocalAI group stay as they were.
Every new string is in the nine languages. The page uses design
tokens only (seven raw font sizes fewer). The specs cover sorting,
the filter, the views, selection and bulk actions, the keyboard, the
menu, rename checks, drag to move, drops, paste, clashes, the size
limit, upload progress, cancel and retry against the real server, and
the error states.
Assisted-by: Claude:claude-opus-5-5
* Fix two specs that fail on the integration branch
The Describe spec of the egress package checked that the no-card line
does not contain "asks the user", but that line says "nothing asks the
user". It now checks for the sentence that promises a card.
The context meter spec gave the chat a 4k context window. The tool
schemas of the chat now take 3k of it, so the turn stopped before the
model request. The spec uses a 16k window.
Assisted-by: Claude:claude-opus-5-5
* Translate the always-allowed hosts note of the chat network row
The three egress keys of the always-allowed hosts were only in English,
so the locale check failed for every chats namespace. The workspace
namespaces now miss two keys fewer, so their baseline goes down too.
Assisted-by: Claude:claude-opus-5-5
* Bring the contracts and host-slots case inventories up to date
The contracts suite has eight new specs for the check of a tool that
shadows a built-in. The host-slots suite runs the unit case of the file
preview slot, which the inventory did not list.
Assisted-by: Claude:claude-opus-5-5
* Add the design of the file text index
The search of the chats' files reads only small UTF-8 text files, so a
PDF or an Office file is found by name only. The design extracts the
text when a file is written, keeps it in LocalAI's own store keyed by
node and hash, checks access at query time and runs one indexer per
cluster under an advisory lock.
Assisted-by: Claude:claude-opus-5-5
* Give extracted documents their pages, sheets and slides
Extract now says where each page, sheet and slide starts in the text,
so a search hit can name its page or sheet. A PDF page starts with a
"## Page N" line, as sheets and slides already start with a heading.
A PDF also stops at a page limit (200 by default) and at the text
limit, which it ignored before, and a panic of the reader is an error.
The test fixtures move to extracttest, with a real text PDF and a
workbook with shared strings, so other packages can build the same
documents.
Assisted-by: Claude:claude-opus-5-5
* Add a store for the text of workspace documents
textindex keeps one entry per workspace node: the text read from a
PDF or Office file, the hash it was read from, its pages or sheets and
its status (pending, ready, failed, skipped), plus the cursor of the
indexer in the workspace journal.
The GORM store keeps a lower-cased copy of the text, so SQL and the
Go matcher agree on every script, and searches it with FTS5 and the
trigram tokenizer on SQLite or a pg_trgm index on PostgreSQL. Without
them it scans the column. The JSON store keeps the metadata in memory
and each text in its own file, and scans the texts. One contract
suite runs on JSON, SQLite and PostgreSQL.
Assisted-by: Claude:claude-opus-5-5
* Index the text of new and changed workspace documents
The indexer tails the workspace journal from a cursor kept in the text
store. A write of a PDF, Word, Excel or PowerPoint file marks it
pending (the same hash only follows the path), a delete removes the
entry and a move rewrites the paths below a folder. Then it reads the
pending files two at a time, with a timeout, a size cap, a page cap and
a text cap, and stores ready, failed or skipped. A panic of the reader
fails the file, not the pass. The same content is read once.
The first pass starts at revision 0, which indexes the files that were
there before, in bounded steps that resume from the cursor. Enqueue
marks an upload pending at once and Forget drops the entries of nodes a
purge erased. In a cluster each pass runs under a new advisory lock,
so one frontend reads.
Assisted-by: Claude:claude-opus-5-5
* Search and read the text of documents in the chats' files
search_files and GET /api/chats/files/search now also search the text
index: after the name and text-file hits come lines of PDF, Word, Excel
and PowerPoint files, as path (page 3, line 40): text, with the sheet or
slide where a document has them. Each entry is shown only when the
caller may read its file now and the file still has the content the
text came from. Documents keep up to a third of the hits. When files
below the path are still pending, the answer says so instead of
"Nothing found.", and the REST result has indexing.
read_file returns the text of a PDF or Office file with the usual offset
and limit, from the index when it matches the file and read on the
spot otherwise. An upload and a write of a chat tool queue the file at
once, and emptying the Trash drops the entries of the purged files.
Assisted-by: Claude:claude-opus-5-5
* Start the file text index with the files and add its settings
With files on, the server opens the text store next to the workspace
index (the auth database, or a folder under the data path) and starts
the indexer. A chat worker searches the index but runs no indexer.
chats_index_files (default on) switches the reading off and
chats_index_max_text_bytes (2 MiB by default, 64 KiB to 16 MiB) caps
the text kept for one document. Both apply at the next pass, and both
are rows of Settings > Tools.
Assisted-by: Claude:claude-opus-5-5
* Show the page, sheet or slide of a document hit on the Files page
Search all files now lists hits inside PDF and Office files with their
page, sheet or slide instead of a line number of the extracted text,
and says how many documents are still being indexed. The strings are
in all nine locales.
Assisted-by: Claude:claude-opus-5-5
* Test finding an uploaded PDF by its words through the routes
The PDF has a generated name and is uploaded with the REST upload. The
search route first reports it as being indexed, then finds it on its
page after a pass of the indexer, and so does the search_files tool of
a chat. Another user finds nothing.
Assisted-by: Claude:claude-opus-5-5
* Document the search inside documents
The chats page says what the index reads, where the text is kept, who
finds it, which search engine each database uses, the two settings,
the backfill and the single-runner mode in a cluster, and how
search_files and read_file answer for documents. The API instructions
and the PDF skill say the same in short.
Assisted-by: Claude:claude-opus-5-5
* Test the indexer on the JSON workspace store
A server without a database keeps the workspace and the text in JSON
files. The indexer follows the uploads, a folder move and a delete in
that journal as it does in the database one.
Assisted-by: Claude:claude-opus-5-5
* Read all of a long document with read_file
The index keeps the first chats_index_max_text_bytes of a document, so
read_file used to stop where the index stopped. It now uses the index
only when it holds the whole text, and reads the document again (up to
16 MiB of text) when the index cut it.
Assisted-by: Claude:claude-opus-5-5
* Start bash in the user's home
The Sandbox's bash and the built-in bash started in /workspace, the
folder that holds the mounts, so a model that ran a script by a
relative path failed and a file it saved there was lost. A call now
starts in /workspace/home, the folder of the user's files: a relative
path is the same file the file tools see. The tool descriptions and
the commands block of every turn say where a call starts and that
only what is saved there is kept. The built-in bash says that the
/workspace paths do not exist on the host and that a shared folder is
next to the home.
Assisted-by: Claude:claude-opus-5-5
* Name files in tool results by the path the model uses
write_file, edit_file and search_files named a file of the home by its
path in the home ("/scripts/build.py") while the model had written
/workspace/home/scripts/build.py, and a model took the two spellings
for two files. The results now give the path the model uses, and
move_file says where it moved the file to. The cards and the change
notes keep the path in the home.
Assisted-by: Claude:claude-opus-5-5
* Name the paths a command's commit did not save
The result of a bash call ended with "N file(s) changed in your files
meanwhile; the sandbox's version was kept as a copy" for every path the
commit refused, also for a file written outside the user's files,
where no copy is kept. It now has one line a path, with the path as
the guest sees it, the reason, and the path of the copy when the store
kept one, so the model and the user can act on it. The host returns
the paths and the copy to the Sandbox add-on relative to the user's
files, as its mounts are.
Assisted-by: Claude:claude-opus-5-5
* Show the files a command saved under its step
A document that a script wrote with bash reached the user's files, but
the chat showed nothing: only the file tools put a file on their step,
and the Sandbox committed for no chat, so the chat's file list, the
chips under the answer and the preview pane never saw the file.
The host now commits a Sandbox call for the chat that ran it (the
action resource of the request names the chat). After each bash call,
of the Sandbox or of this machine, the chat reads the change sets of
the chat that the call made from the shared store and puts the files
on the step's card, next to the card that says who served the call
(session.SetToolFiles; files with path, change set and op on the
card's new files field). The step lists them as chips that open the
preview pane, the chat's clients are told that its files changed, and
the files reach the file list, so the answer gets its chips and the
pane opens on the newest one, as for write_file.
Assisted-by: Claude:claude-opus-5-5
* Keep JSON out of the answer of a trigger run
The workspace skill told the model to put the widget data shape and a
source marker in a trigger prompt. Some proposals then asked the run to
print that JSON, and every run ended its chat with a stray line of it.
The skill now says source and note live inside the data file and are
never the answer, and that a prompt must not ask for JSON. The runner
appends one closing instruction to the first message of every trigger
run: finish with one short plain sentence. The propose_trigger and
update_trigger descriptions say the prompt must stand alone and must
not ask for JSON. Connector chats are talks with people and do not get
the line.
Assisted-by: Claude:claude-sonnet-5-5
* Read the images of a page as base64 for its frame
The bridge of a page reads UTF-8 text only, so the UI could not put a
png or a jpg of the page's folder into the page. A read of the page
file routes with encoding=base64 now returns an image as base64 with
its media type. Only the extensions of an image are read this way
(png, jpg, gif, webp, avif, bmp, ico, svg); any other file is 415, so
this is no way to read an arbitrary binary file. The path checks, the
1 MiB limit, the viewer's rights and, for a shared page, the consent
check are the same as for a text read.
Assisted-by: Claude:claude-opus-5-5
* Let a page use the files next to it
A page runs from a srcdoc with no network, so fetch('news.json'),
<script src="app.js">, <link rel="stylesheet" href="style.css">,
<img src="logo.png"> and import './x.js' all failed without a word.
Models that split a page into files, or load its data with fetch,
made pages that showed nothing.
Before the frame loads, the UI now reads the files that the page names
with relative URLs, with the same calls as the bridge, and puts them in:
classic scripts in place (deferred ones at the end of the body), style
sheets as <style> with their @import and url(), images as data: URLs,
and ES modules as blob: URLs behind an import map, so cycles and
import() work. A page with modules gets blob: in script-src; no other
part of the policy changes. Inside the frame, fetch and XMLHttpRequest
read relative URLs with files.read and answer with a real Response (200,
404 for a missing file, 413, 415); any other URL rejects with "network
is off in pages; put the data in a file next to the page".
Every path is resolved inside the page's folder: .., %2e%2e, absolute
paths, backslashes and protocol-relative URLs are refused, and the
query and the fragment are dropped. The files go in only after a shared
page is allowed, with the viewer's rights. The files that go in are at
most 4 MiB, images 2 MiB; a script or style sheet that does not load
shows a notice in the page. A change of one of these files reloads the
page, as a change of index.html does.
The skills, the docs and the shared pages fixture say and test the same.
Assisted-by: Claude:claude-opus-5-5
* Return tool descriptions from an MCP connector test
A connection test listed only the tool names. A person who adds a
server cannot tell from a name such as "query" what the tool does or
whether to keep the connector on.
The test response now also has tool_details: each tool with the
description its server gives, in the order of tools. tools is
unchanged, so existing clients keep working.
Assisted-by: Claude:claude-opus-5-5
* Add jq, ripgrep, sqlite3 and archive tools to the Sandbox guest
The guest user cannot install packages and apt is blocked by the proxy,
so any tool the model needs has to be in the image. Add jq, ripgrep,
sqlite3, zip, unzip, file and tree to the final stage.
A new check-tools.sh runs in the existing build step and fails the build
when one of these programs, Poppler, git or curl is missing or does not
start.
The list is one constant, GuestTools, in the shared sandbox package. The
Commands block of the prompt of an isolated chat names it. The tool
description (capped at 400 characters, already full) and the turn note
(cut at 400 characters) have no room for it. Tests keep the Dockerfile,
the check script and the Sandbox documentation in line with the constant.
Assisted-by: Claude:claude-sonnet-5-5
* Keep the helper files of a chat in its work folder
A chat asked for a Word document left a 19 KB build script at the top of
the user's home, next to the user's own files. Chats like it leave
scripts, intermediate data, logs and drafts at the top level or in
folders such as scripts/, and nothing tells them apart from what the
user asked for.
Each chat now has a work folder, Chats/<chat folder>/work/ in the
user's home, next to its attachments. The chat takes the name of its
folder at its first turn (checked against the folders and the other
recent chats of the user, so two chats with one title get two names)
and makes the folder only when it is used: before a bash command runs,
or when a file is saved there. The prompt of every turn names the
folder and says what goes in it: scripts, helper code, intermediate
data, logs and drafts go there; the deliverable goes where the user
said or at the top of the home; no new top-level folders for scripts;
a run-once command needs no file. A sub-agent is told its parent's
folder. The descriptions of bash (the Sandbox's, within its 400
characters, and the built-in one) and of write_file end with one short
hint, and the built-in office, apps and workspace skills say the same
in one line.
The steering is soft. When write_file, move_file or a command leaves a
helper file outside the work folder, the result gets one line that
suggests moving it, at most once a turn of a chat.
One function (workspace/workfiles) decides the kind of a file:
working for anything in a chat's work folder, or for a script, a log
or a temporary file by its extension or a folder named scripts, tmp or
scratch; deliverable for everything else, and always for pages, the
LocalAI folder, the shared folders and the files the user attached.
The chat's file list (kind), the files of a command's card
(files[].kind) and the card of a file tool (file_kind) carry it, so the
interface only renders it.
The runtime setting chats_work_folder (on by default) turns the
steering off.
Assisted-by: Claude:claude-opus-5-5
* Stop telling the model that the Sandbox has npm
The guest image has no Node, so the network hints named a tool that
does not exist.
Assisted-by: Claude:claude-sonnet-5-5
* Update the Sandbox description test after dropping npm
Assisted-by: Claude:claude-sonnet-5-5
* Open the links of a page after the person confirms them
A page runs in a frame without allow-popups or top navigation, so a
click on <a href="https://..."> did nothing, and a link to #id loaded
LocalAI's own address in the frame, since a srcdoc resolves it against
the parent. A news page had only dead links.
The bridge of the frame now takes the clicks on links (also with a
modifier, the middle button or Enter) and window.open(), and sends
link.open(url) to the host. The host lets through only an http or https
address without a user name or password, at most 2048 characters;
javascript:, data:, file:, blob:, //host and links to other files of
the page are refused with a message in the console. A bar above the
page shows the site in large type, the whole address, and Open and
Cancel. A host in punycode also says how it reads, since it may imitate
another site. Open is a click in the app, so the new tab is not blocked
and opens with noopener and noreferrer. A page asks at most 5 links at
once, then one every 3 seconds. A link to #id scrolls the page.
The sandbox, the policy of the frame and the consent of shared pages do
not change; a shared page asks the same way.
Assisted-by: Claude:claude-opus-5-5
* Let the items of a Home widget link
A list widget showed each item as plain text, so a digest of articles
or a list of pages could not take the person anywhere. An item now has
an optional url. An http or https address without a user name opens in
a new tab with noopener and noreferrer, and its tooltip names the site;
a path of the app (/app/pages, /app/chats, /app/files and the other
screens a person reads) opens inside LocalAI. The label is the link,
with a focus ring, an underline on hover and focus, and an icon for an
address outside the app.
The widget-data schema checks the url, and the server adds an error in
words that names the item and the rule, so a trigger run that writes a
bad link learns why. The skill and the docs show the field.
Assisted-by: Claude:claude-opus-5-5
* Attach any file in a chat with one button
The chat had three attach buttons (image, audio, file) that all add to
the same list, and the file button's dialog filtered on */*, which
some file dialogs read as a type filter that greys out archives such as
.zip. A chat now shows one Attach file button whose dialog has no type
filter. Composers without anyFile keep the three buttons.
Assisted-by: Claude:claude-opus-5-5
* Accept both Node behaviours for a broken punycode label in the link test
Node 22 refuses the address, Node 24 parses it. Both mean the page never
opens it unwarned.
Assisted-by: Claude:claude-sonnet-5-5
* Show a chat's working files apart from what it made
The Files pane, the steps and the preview treated a helper script like
the document the user asked for: a 19 KB build script was listed next
to the document, got a chip under the answer, and the preview opened on
whichever file was newest.
The interface now renders the kind the server puts on each file. The
pane lists what the chat made first and folds the working files into
one quiet heading under the rest, "Working files · 2", with a line that
says what they are; opening it shows their rows, which preview like any
file. A working file opened from a step unfolds the group. The preview
that opens by itself after a turn picks the newest file that is not a
working file, and the chips under the answer name only those files. A
step that saved working files lists them as one line, "Also saved 2
working files", that opens to their chips, and the file of a write_file
step that is a working file is a quieter chip. The files the user
attached are unchanged.
Assisted-by: Claude:claude-opus-5-5
* Add a search field and a segmented filter to the UI kit
The Agents views each drew their own toolbar: a search box capped at
320 px on the agents list, round chip filters on the triggers history,
and a bare checkbox on the tasks page. Add-ons already had the look the
product wants (a search that fills the row and a segmented tray), but
only as add-on classes.
SearchField and Segmented are that toolbar as two small primitives with
ui-* classes and token values only. Segmented buttons carry their id in
data-filter, so specs can press them. The README shows how to use them.
Assisted-by: Claude:claude-opus-5-5
* Draw an agent task run as a history row with its output
The jobs of an agent task were table rows that opened to bare <pre>
blocks: no copy, parameters printed as k=v, the traces in a raw
<details>, and "by manual" in the server's words.
RunItem is one run of a history list: a toggle, the state chip, what it
was and what happened, when and for how long, small actions, and the
details that open under it. OutputBlock draws an answer or an error in
mono with a Copy button. JobRow and JobDetail use them for a job: the
values it ran with as labelled chips, the answer or error, the steps,
the webhook outcome and a link to the chat that holds the run. Times
show as words with the full date as a tooltip.
The agent task helpers gain the pure rules the page needs: the jobs of
a task, the run filters (waiting and running count as in progress), the
on/off filter and search of the task list, the field errors of a save,
how a job started, and the API call that runs a task by name.
Assisted-by: Claude:claude-opus-5-5
* Create, edit and run agent tasks in a side sheet
The task form opened as bare fields above the list, with a checkbox for
On and a Save button that was only disabled when a field was missing.
Run with parameters was a second inline form in the same place.
TaskSheet is a side sheet with three labelled groups (about, who runs
it, what it does), help under each field, the parameters the prompt
asks for as chips while you type, and an On switch. Save checks the
required fields, says next to each one what is missing, and moves the
focus to the first. RunSheet asks for each value the prompt names and
shows the prompt as the task will receive it.
Assisted-by: Claude:claude-opus-5-5
* Show the details of an agent task in a pane with tabs
A task had no details view: its prompt, its runs and the way to run it
over the API were only in the lede of the page.
TaskPane is the kit's side pane with three tabs. Overview shows who
runs the task, the values it asks for, the prompt and a curl call that
runs it by name, with Copy. Runs lists its jobs. Settings holds the On
switch, the webhook count and Delete. Run and Edit stay in the footer.
The tabs move with the arrow keys, Home and End.
Assisted-by: Claude:claude-opus-5-5
* Rebuild the agent tasks page from the shared list pieces
The tasks page looked unstyled next to Add-ons and the agents list: two
raw tables under uppercase eyebrows, a bare checkbox for All users,
forms that opened above the list, three text buttons on every row, and
on a phone a table that broke names one syllable per line.
The page now uses the same pieces as the polished pages. The header has
one line and New task. A toolbar holds the search, an All/On/Off filter
and, for an administrator, an All users switch. Each task is an agent
row: a tile, the name, two lines of description, who runs it and what
it asks for, the last run's state and time with the run strip, an On
switch, Run and a menu. A task with no parameters runs at once. The
name opens the details pane; New task, Edit and Run open the sheets.
The run history is grouped by day, with a state filter, a task filter
and Show more. A job is deleted after an in-page question. With no
tasks the page explains what one is, with an example; a failed load
shows the error with Try again. On a phone the controls grow to 44 px
and the filters scroll sideways.
New strings, and the task strings that existed only in English, are in
all nine locales; the strings the page no longer uses are gone. A new
stubbed spec covers the list, filters, sheets, pane and history, and the
real-server spec follows the new controls. The docs describe the page.
Assisted-by: Claude:claude-opus-5-5
* Rebuild the MCP connectors page on the Add-ons patterns
The page read as a form: every connector was a row of raw controls (a
checkbox, a Test button, red Delete text), the add form pushed the list
down, a test result vanished on reload, and the frame was narrower than
the pages next to it in the sidebar.
The page now uses the same blocks as Add-ons and Files:
- Full page width and the shared PageHeader with a one-sentence purpose
and "Add connector".
- The Add-ons toolbar (search and a state filter with counts) from four
connectors, and the Add-ons rows: a monogram tile, a state chip
(Connected, Needs sign-in, Can't connect, Not tested, Off), one line
with the tools or the reason, a meta line, the kit Switch and a menu.
- Details in the shared SidePane: the switch, the last test, the tools
with their descriptions and a search, the connection with the query of
the address hidden, and what is saved.
- Add and edit in the shared SideSheet: kind first, grouped fields with
inline checks, masked secrets with show and hide, then "Save and
test", the result with the tools, and Turn on. Escape and Cancel ask
before they drop typed changes.
- An empty state that says what a connector is for.
The last test of each connector is kept in this browser with a hash of
what it tested, never the address or a secret, so the page shows the
last known state after a reload. Strings are in all nine locales.
Assisted-by: Claude:claude-opus-5-5
* Document the MCP connectors page and its states
The MCP servers section now describes the add sheet (kind, checks,
masked secrets, Save and test, Turn on), the states a connector shows,
where the last test is kept, the search and the filter, and the details
pane. The test example shows tool_details.
Assisted-by: Claude:claude-opus-5-5
* Render a preloaded page without its loading placeholder
A page that was preloaded still showed the route's loading placeholder
when it opened: React.lazy suspends once on its first render even when
the chunk is already in memory, and React keeps a fallback on screen
for at least 300 ms once it shows one. Opening a chat from Home showed
a blank page for that time.
The page loader now remembers the module once it has loaded and hands
it to React.lazy as a thenable that resolves at once, so a preloaded
page renders in the same commit. A preload of a segment now warms
every page registered under it: /app/chats is the chat page, its
connectors and its conversion page, and before only the last one
registered was preloaded.
Assisted-by: Claude:claude-opus-5-5
* Move the composer from Home into the new chat in one gesture
Sending the first message on Home was a hard cut. The page stood still
while the chat was created, then showed a blank placeholder, the empty
"new chat" screen with its composer in the middle, an empty thread
with the composer at the bottom, and only then the message, while the
sidebar folded and moved the whole column. Focus was lost.
Now Send fades the rest of Home and keeps the composer in place while
the message goes. When the send has answered, a view transition moves
the composer to the dock of the chat, lifts the text out of it into
the message, moves the attached files with it and folds the sidebar in
place; the model's indicator comes in after the message has landed and
the empty composer has the focus. The chat page draws the message from
what Home already knows (a small hand-off store: the chat record, the
text and the files, with the local copy of an image for its thumbnail)
and keeps the same row when the server's transcript replaces it, so
nothing is drawn twice or animates again. What is sent to the server
does not change.
A browser without view transitions gets a FLIP slide of the composer
and the message (Web Animations, transform and opacity only). Less
motion gets a short cross-fade, or an instant switch. A send that fails
brings Home back, shakes the composer and keeps the draft; a second
Enter while one send is in flight sends nothing. The draft is cleared
when Home closes, so the box never shows its placeholder on the way.
Focus mode folds the sidebar in a layout effect, so the fold happens in
the frame the conversation appears. One motion token is added,
--dur-glide (360 ms), for a part that travels to its place on the next
page.
Assisted-by: Claude:claude-opus-5-5
* Draw the triggers as rows and their history like the task runs
The triggers page used a table with a switch column next to the agents
card list, folded its list behind Hide, filtered the history with round
chips, and on a phone cut each history row at the right edge.
Each trigger is now an agent row: a clock or folder tile, the name, the
schedule in the server's words, the agent or model and when it runs
next, the last run's state and time with the run strip, an On switch,
Run now and a menu. The week sentence sits under the Run history title.
The history uses the segmented filter and the same run rows and day
headings as the agent tasks, with Show more after 30 runs; an open run
shows its reason with Copy. On a trigger page the prompt comes before
the runs, in the body face, the On switch is the kit switch, and the
runs stop at 10 with Show more.
Assisted-by: Claude:claude-opus-5-5
* Use the shared search and the full width on the agents list
The agents list stopped its search at 320 px and used the content width,
while the Triggers and Tasks views next to it use the full width, so the
title and the view tabs moved when you switched views.
The list now uses SearchField, which fills the toolbar row as on
Add-ons, and the full page width like its sibling views.
Assisted-by: Claude:claude-opus-5-5
* Polish the agent task sheets and pane after a visual review
Screenshots of every state showed a few rough edges:
- The name placeholder looked like a filled-in value next to the error
"Give the task a name". The name field has no placeholder now, and
the prompt placeholder starts with "For example:".
- A task run by an agent showed the agent id until the agents were
read. It says "Agent" until then.
- A run opened in the details pane also opened in the history, and the
two copies shared an element id. The pane keeps its own open runs and
ids.
- A failed run in the pane drew its meta line in red; the title carries
the error colour there now.
- A group title that is not a legend had a double gap under it, and the
kebab menus were under 44 px on a phone.
Assisted-by: Claude:claude-opus-5-5
* Cover the code files of a shared page in its consent
The consent to a shared page was kept against the hash of index.html
and page.json. The UI also puts the scripts, style sheets and modules
next to the page into it before it runs, so the author could change
app.js after a viewer allowed the page and run new code without asking
again.
The server now finds the same files as the UI's inliner: every script
src, stylesheet link, relative CSS @import and module import of the
folder, followed through the module graph. Their content hashes go into
the hash of the page, and a file the page names that is missing counts
too, so adding it later also asks again. Data files and images stay
out, so saving data does not ask again. The files are listed as
code_files. What a file names is kept per content hash in a small
per-frontend cache, which only saves reads.
Two more checks close the gaps around it. A frame of an older version
that reads a changed file of code gets 409, so a script edited while
the page is open never runs under the old consent. The UI puts no text
file into a shared page that is not in code_files, so if the server and
the UI ever disagree, the file fails to load instead of running outside
the consent. The trust sheet names the files of code when only one of
them changed.
Assisted-by: Claude:claude-opus-5-5
* Let a page be a tile on Home in its compact mode
Home widgets could show a list, a number or the last chats, but not one
of the pages a person or the assistant built. A page can now be a tile.
A page opts in with a widget block in page.json: a size (small, medium
or large, the height of the tile) and an optional short title. A
widget of type page in LocalAI/home.json names the page, and a folder
id for a page of a shared folder. The server takes at most three page
widgets and says why in words, and refuses a name that is not a page
name.
The tile runs the page in the same sandbox and bridge as the full
view. The page reads localai.page.mode ("widget" in a tile, "page"
otherwise) and localai.page.size, and <html> carries data-mode and
data-size, so the page decides how its tile looks instead of being
shrunk. The tile reserves the height of its size, builds its frame only
once it scrolls into view, tells the page through a new visibility
event when it goes out of sight or the tab is hidden, takes the frame
down far off screen, and keeps at most three frames alive. It has a
title bar with Open page and a refresh button, a skeleton while it
loads, Retry on an error, and the link question of the full view. A
page that is gone or has no widget block shows a calm line with a link
to Customise Home; a shared page that the viewer has not allowed shows
Allow this page, which opens the same trust sheet.
Customise Home lists the pages with a widget block, with the size of
their tile. The task board starter has a compact view, the skills tell
the assistant how to put a page on Home, and the docs describe it.
Assisted-by: Claude:claude-opus-5-5
* Give the Files upload its own size limit
The Files page upload was capped by the general --upload-limit (15 MB
by default), although a workspace file can be 256 MiB.
Make 0 the default of --upload-limit, meaning not set. When it is not
set, the general routes keep the 15 MB limit and the Files upload route
skips it, so the workspace file size is its limit. When the operator
sets a value, it applies to the Files upload too, lower or higher (a
file never exceeds the workspace size). The max_file_size of a folder
listing reports the limit in force.
Assisted-by: Claude:claude-sonnet-5-5
* Refuse writes of code files from a page
A page loads its own scripts, modules and styles, so a page that could
overwrite app.js or style.css would run new code on its next load. The
page bridge now writes data files only.
The server refuses a write whose final segment has a code or document
extension (js, mjs, cjs, css, html, htm, svg, xml, xhtml, wasm), is named
index, or is page.json, with 403 and a clear message. The check applies
to the page routes only; the Files page and the assistant's file tools
keep full write access. The judgement lives in pages.IsCodeFile and is
mirrored by isCodeFile in the web host, which refuses early with the
error code forbidden. Both run one table of cases, covering case, double
extensions, trailing dots and spaces, percent-encoding, backslashes and
lookalike dots.
Assisted-by: Claude:claude-sonnet-5-5
* Make the trigger tools a built-in group a chat can switch off
propose_trigger, list_triggers, update_trigger and delete_trigger were
added to every chat a person starts, with no switch. They are now four
entries of the built-in tool registry in one group, triggers, so the
Tools menu, the tools panel, the draft list and the builtins switch
endpoint handle them like the web group. The group is on by default.
A trigger or connector run and a sub-agent still never get them. The
panel lists the group as not usable, with a reason, for those chats,
for a user without the triggers feature, and not at all when the server
runs no triggers. The group has no runtime setting, so the registry
gets an Unlisted flag and Settings > Tools skips it. The panel now
builds every grouped tool to learn whether it is usable, not only the
opt-in ones.
Assisted-by: Claude:claude-sonnet-5-5
* Show the Triggers group in the chat Tools menu
The menu rows come from the groups the server lists, so the row needs
only its label in the nine locales. A Playwright spec covers the row,
its switch request, Select all and Disable all, and the disabled row
with its reason.
Assisted-by: Claude:claude-sonnet-5-5
* Talk about settings, not the files behind them
The settings cards, the shortcuts row, Home and the change notes in a
chat named the files that hold a person's settings: "Defined in
LocalAI/shortcuts.json", "Layout is in LocalAI/home.json", "Saved
LocalAI/persona.json". A person changes their shortcuts or their Home,
not a file, and the Files page still shows the files to whoever looks.
The "Stored in", "Defined in" and "Layout is in" lines are gone; the
history line keeps Versions, Revert and the Invalid badge. The notes
say what changed ("Saved your shortcuts.", "Created the page Task
board."), the errors name the setting or the widget instead of a path,
and the Source view says "The settings as text" instead of JSON and
schema. A failed save now says what happened in one plain sentence and
keeps the server's own words under a collapsed Details. The drafts that
"Edit with the assistant" puts in the composer no longer carry the
path; the built-in workspace skill already maps shortcuts and Home to
their files.
All nine locales are translated; only the changed keys moved, and four
keys that nothing reads any more are removed.
Assisted-by: Claude:claude-opus-5-5
* Drop internal names from the skills, add-ons and tasks copy
Other pages used words that only make sense to someone who knows how
LocalAI is built:
- Skills said "a folder with a SKILL.md" and "the file workspace". It
now says what a skill is for, and that skills need Files.
- The add-on notes blamed "the owner", the internal name of the server
that runs add-ons. They now say what happened to the action, or that
the add-on service is not available; a private library names a
credential "an administrator configured on this server".
- The Tasks header listed an API route, and the first-run wizard spoke
of a "host bash fallback". The memory line of Settings no longer
mentions "the resources endpoint".
All locales that have these keys are translated. The skill delete
dialog, the unused skills intro and the Tasks header exist only in
English and change only there.
Assisted-by: Claude:claude-opus-5-5
* Fail the unit tests when UI copy names implementation details
A new test reads every string of every locale and fails when one names
a settings or data file (.json, .yaml, LocalAI/), an API route or HTTP
method, a setting key (chats_*), an environment variable, a format
word (JSON, YAML, schema) or a path shown as code. Plain words such as
"schema" and "endpoint" are checked in English only, since other
languages use them for other things.
Operator pages may still name the model configuration, the metrics
route or a startup setting that the docs name. Those keys are listed in
ui-copy-allowlist.json, each with the audience and the reason. The list
only shrinks: an entry whose string no longer needs it, or whose key is
gone, fails too.
Assisted-by: Claude:claude-opus-5-5
* Name the triggers group in the chat tools routes
The routes that list and switch the built-in groups of a chat said only
"a name such as web". They now name all three: web, triggers and
saved_agents, with the tools of each.
Assisted-by: Claude:claude-opus-5-5
* Make the built-in skills answer in plain words
The skills told the model to report file names, folders and formats
after a change. Add one rule near the top of localai-workspace and
localai-apps: do the work with files, but say what changed, and name a
file only when the user did or asks. Replace the example replies that
carried paths, keep every path that says where to write, and pin the
rule with a test.
Assisted-by: Claude:claude-sonnet-5-5
* Apply the Go 1.26 modernizers to the latest Studio code
go fix suggested slices.Contains, strings.SplitSeq and range-over-int
on lines added since the first pass. ParseSearchProviders had the same
hand-written membership loop, so it uses slices.Contains too. Only
lines that the branch added since then are changed.
Assisted-by: Claude:claude-opus-5-5
* Share the blob wait and the counting of the two workspace purges
The JSON and GORM stores each had the 2-minute default for the blob
grace, the same switch that reads the BlobGrace option, and the same
loop that counts files, folders and bytes per revision. They now call
blob.Grace and PurgeResult.Add, both with specs. The SQL and the
in-memory walk that pick the nodes stay in each store.
Assisted-by: Claude:claude-opus-5-5
* Read the upper layer of a sandbox in one place for both stores
The JSON and GORM workspace stores had the same readUpper, place,
joinRel, depth and sortedKeys, with the same upperSet types. They move
to workspace/internal/upperset, which has its own specs. The commit
algorithm that applies the set to the nodes stays in each store; it
needs a storage interface first.
Assisted-by: Claude:claude-opus-5-5
* Let chat attachments use the 1 GiB message limit
The docs promise 1 GiB for a message with attachments, but the general
body limit (15 MB by default) ran first, because only the Files upload
skipped it. Skip it also for a multipart POST to the messages route
when the operator set no --upload-limit. A JSON message keeps the
general limit, and an explicit --upload-limit still wins. A message
that goes over the 1 GiB limit now gets 413, not 400.
Assisted-by: Claude:claude-opus-5-5
* Name the read_file tool in the skills index header
A weaker model read a built-in skill with the wrong path and then tried
the Sandbox, which cannot see skill folders. The header now says which
tool reads a skill and that the shown path is the one to use, before it
mentions where the user's own skills go.
Assisted-by: Claude:claude-sonnet-5-5
* Tell the model that skill folders are not in the Sandbox
The Commands block of an isolated chat now says that skills are read
with read_file. The Sandbox tool description and the per-turn note are
at their size caps, so the sentence goes in the system prompt.
Assisted-by: Claude:claude-sonnet-5-5
* Point read_document at read_file for text files
read_document reads PDF, Word, Excel, PowerPoint and EPUB files. For a
Markdown or text file it said only that the file is not a document it
can read. The error now names read_file.
Assisted-by: Claude:claude-sonnet-5-5
* Tidy the text index stores and the indexer pool
The two stores wrote the default limits 20 and 100 as numbers in five
places; they are now named constants, so the stores cannot drift. The
JSON store checks a missing file with errors.Is. The indexer starts its
readers with wg.Go and keeps the first error inline, which drops the
cmpErr helper.
Assisted-by: Claude:claude-opus-5-5
* Refuse the server's public address in the Sandbox proxy
web_fetch refuses the addresses of the server: its interfaces,
loopback and the host of the external base URL. The Sandbox proxy runs
in the add-on process and listed only the interfaces of its own
machine. Under "All public sites", an always-allowed host or an Allow
card, a command could reach the LocalAI API through the public name or
address of the server, a NAT address or a proxy in front of it.
The host now keeps one list for both. The application builds it once,
gives it to web_fetch, and sends it as CIDR strings in the new self
field of every egress.check allow. The proxy keeps the last list it
got and refuses it on each dial, besides its own interfaces, also for
an allow it cached. safehttp gets the codec and a join of deny lists,
so both sides read the list the same way.
Assisted-by: Claude:claude-opus-5-5
* Check the TLS server name on every port of the Sandbox proxy
The proxy checked the ClientHello only on CONNECT to port 443. An
always-allowed host pinned to another port, or any port added to
chats_egress_ports, was a raw tunnel: a command could open TLS to
another tenant of the same front through it. The docs said the server
name must still match.
On other ports the proxy still waits for nothing, since the protocol
may be anything and the server may speak first. But when the first
bytes from the guest are a TLS handshake record, it reads the whole
ClientHello and passes it on only when the server name is the CONNECT
host; otherwise it closes the tunnel and audits the reason sni.
Assisted-by: Claude:claude-opus-5-5
* Say what sent each notification
The inbox only knew whether a notification was about a run or needed
attention, so a trigger that was switched off and a connector that was
switched off looked the same. Each notification now carries a source:
trigger, connector or system (the conversion of old data). The list
endpoint returns it, and items stored before have none.
Assisted-by: Claude:claude-opus-5-5
* Say where a skill is when a read of it fails
When read_file, read_document or list_files fail with not found for a
path that names a skill of the chat (a wrong skills folder, or just
<name>/SKILL.md), the error now gives the skill's real path and says to
read it with read_file. The skills come from the chat's own resolver, so
every layer, add-on and per-user switch is respected, and nothing is
looked up unless a read fails.
Assisted-by: Claude:claude-sonnet-5-5
* Keep the last valid Sandbox network lists when a value is bad
The settings endpoint refuses a bad chats_egress_allowed_hosts with the
reason, and the runtime settings file keeps the value in force. A bad
value that reached the configuration another way was dropped with no
log line: the always-allowed hosts and the extra registries became
empty, and a bad port list fell back to 80 and 443, which can widen a
narrower list.
Each of the three lists now keeps the last value that parsed, logs a
bad value once, and uses the default only when the process never saw
a valid one. The parse runs again only when the value changes, not on
every egress.check.
Assisted-by: Claude:claude-opus-5-5
* Use the keyless chain of the built-in search in the Web add-on
The add-on replaces the built-in web_search, but its keyless provider
was DuckDuckGo alone. DuckDuckGo often answers data-center addresses
with a bot check, so installing the add-on without a key made search
worse than the built-in, which falls back to the Brave Search and Bing
result pages.
The keyless entry of the add-on's order (saved name duckduckgo) is now
the built-in chain from pkg/web/search: DuckDuckGo, then the Brave
Search page, then the Bing page, with the same cool-down. The status
tool reports the providers in their cool-down, and the Settings tab
shows them with the reason. Test the providers gives one row for each
page of the chain.
Assisted-by: Claude:claude-opus-5-5
* Fix the GitHub hosts in the Q1 row of the egress design
The Q1 row still listed raw.githubusercontent.com on the registries
list. The owner decision below it and registries.json exclude it,
since it serves any user content, and add
release-assets.githubusercontent.com. The row now says the same.
Assisted-by: Claude:claude-opus-5-5
* Keep one private-address rule for the network cards
A card asked whether its host is a private IP literal by calling
egress.Admits with a made-up ceiling, which re-derived the rule that
egress already has. egress.PrivateLiteral is now exported (with a spec)
and the card uses it. The two String methods of an allowed host write
host:port with net.JoinHostPort, which brackets an IPv6 literal the
same way the hand-written code did.
Assisted-by: Claude:claude-opus-5-5
* Read a unique constraint violation in one place
The jobs and triggers stores had the same isUniqueViolation, and the
saved agents store had the same test inline before it looked for the
name index. They now call dberr.IsUniqueViolation, which has specs for
the SQLite and PostgreSQL error texts. The workspace store keeps its
own stricter match.
Assisted-by: Claude:claude-opus-5-5
* Ask before a Home widget link opens another site
A list widget turned an http or https item into a plain link to a new
tab. Its data file is written by trigger runs and the assistant, which
read web text, so a planted link opened with one click, and only a
tooltip showed the host. A link of a page asks first.
An external item link now opens the confirmation bar of page links in
the widget: the site, the full address, the punycode warning, and Open
or Cancel. A link inside the app still navigates directly. The bar
takes a label, so the widget says that it wants to open the link.
Assisted-by: Claude:claude-opus-5-5
* Prune the audit of the commands run on this machine
The audit of the built-in bash had no retention: the database table
grew without bound in a cluster, with commands of up to 4 KiB each.
The Sandbox network audit had chats_egress_audit_days and a daily
prune under an advisory lock.
Both audits record what the commands of the chats did, so the host
command audit now uses the same retention and is pruned in the same
daily pass: one frontend of a cluster, under the same lock; a
standalone process alone. The GORM and file stores get Prune. No new
setting.
Assisted-by: Claude:claude-opus-5-5
* Save the Studio JSON stores with atomicfile.WriteJSON
Seven stores (agents, connectors, jobs, MCP connections, notifications,
push subscriptions and triggers) encoded their state and then called
atomicfile.Write with the same four lines. atomicfile.WriteJSON does
both and has specs. The load side stays in each store, since the error
messages differ.
Assisted-by: Claude:claude-opus-5-5
* Build the result page walkers of the keyless search on one walk
hasChallenge and firstByClass each had their own recursive walk that
stopped at the first match. They are now predicates passed to
firstElement, which already did that walk. The order of the walk and
the element tested first are the same.
Assisted-by: Claude:claude-opus-5-5
* Expire network cards that a dead frontend left pending
Pending network cards expired only on the turn-end path of the process
that ran the turn. When that frontend died, its cards stayed pending
in the chat record, counted toward the cap of three, and gave the next
turn's connections cards-full blocks. The UI showed them as waiting.
A card now names the turn that asked it. One rule, shared with the
command card in a later change, says whether a card still has a
waiter: its turn holds the chat and its wait has not run out. A card
that fails it reads as expired, takes no place under the cap, and is
expired in the record when the next card is asked, when the next turn
starts, or when the reaper ends the dead turn.
Assisted-by: Claude:claude-opus-5-5
* Reuse ChooseProvider for the expected add-on of a tool
shadowers.expected repeated ChooseProvider line for line: the pinned
add-on when it is installed, else the oldest install. It now calls it.
commandFiles kept an order field and sorted the nodes of a map by it;
it now appends each node to a list when it first sees it, which gives
the same order without the sort.
Assisted-by: Claude:claude-opus-5-5
* Do not print a nil error when a key or entry is missing
Three checks joined "the read failed" and "the read found nothing" in
one branch, so the second case wrote "<nil>" into the message: the
VAPID keys of push notifications, the sealing key of connectors and a
rendered catalog entry. Each case now has its own message. The message
of a failed read does not change.
Assisted-by: Claude:claude-opus-5-5
* Check the close errors and rename the tool variables
golangci-lint failed on the new studio code. Five deferred Close calls
and one Fprintf into a hash dropped their error, and two locals named t
made forbidigo read `t.Run` as a testing call. Discard the errors
explicitly and name the locals tool.
Assisted-by: Claude:claude-opus-5-5
* Make the file text index forget purged files and say its limits
Three gaps of the text index of documents:
- A purge dropped the index entries of what it erased in a goroutine
that only logged its errors, and the purge had already erased the
journal rows. A crash or a store error left the text in the database
for good. Each pass of the indexer now also checks a batch of entries
(200 every 30 s, round all of them) and deletes those whose node is
gone, so what a failed Forget leaves is removed.
- A document search stopped at 20000 files or 64 MiB of text without a
word, and the "still being indexed" count stopped at 50. The search
now sets Limited (search_files says "results limited", the REST
answer has "limited", the Files page shows a note), and the count is
the number of pending entries below the path.
- read_file extracted a long document again for every page, up to 50
MiB and 60 s each time. The first read keeps the whole text in the
index store by content hash (a new documents table, or files in the
JSON store), and the next pages come from it. The text goes away with
the last entry of that content, on delete and on purge.
Search reads the text without its folded copy, which only the database
needs.
Assisted-by: Claude:claude-opus-5-5
* Stop the PDF reader when the extraction gives up
The PDF and EPUB reader runs in a goroutine, and the call returns after
60 seconds when it is slow. The goroutine went on reading every page,
since its context ended only with the caller's. It now gets a context
that ends when the call returns, so it stops at the next page. What
the call returns does not change.
Assisted-by: Claude:claude-opus-5-5
* Compare the code files of a shared page as the store does
The guard that refuses new code to a frame of an older version compared
the name it reads with the names of the page's code exactly. The
workspace folds case, so an old frame that read Index.html or JS/App.js
got the new code with no 409. Compare names in the store's form
(workspace.FoldName), as the write check already does, and test the
case variants.
Assisted-by: Claude:claude-opus-5-5
* Expire the command card of a turn that is over
The card of the built-in bash was cleared only by a defer in the call
that asked. When that frontend died, the card stayed in the chat
record. The chat hid it only while no turn ran, so the next turn
showed it again, and an answer to it reached no one but still got 204.
The card now names its turn and when it was asked, and follows the
same rule as the network cards: it waits only while that turn holds
the chat and its five minutes have not run out. GET /api/chats/{id}
shows only such a card. The next turn and the reaper clear a card left
behind, and the waiting call clears only its own card, so it never
removes a newer one. An answer to a card with nobody waiting is
410 consent_expired, and the chat shows the card as not answered, with
no buttons. The store gets UpdateHostConsent, an atomic change of the
card.
Assisted-by: Claude:claude-opus-5-5
* Answer a trigger change once across frontends
ApplyChange, UndoChange and DismissChange checked the outcome of the
change record under a mutex of one process, then wrote the store. Two
frontends could both see a pending change and both apply it, or both
undo it.
Add Store.SetOutcome, a conditional update of the outcome (only from
the outcome it names), to the GORM and JSON stores and to the store
suite. The three answers now claim the record with it before they
write, give the outcome back when the change itself fails, and answer
ErrProposalAnswered (409) when another request won. A test runs the
two answers on two frontends at the moment between the read and the
write, for an edit, its undo, and a delete.
Assisted-by: Claude:claude-opus-5-5
* Add the shadowers spec to the tables-tools inventory
The tool adapter gained a spec that lists the add-ons that replace a
built-in in any state. The tables-tools suite runs every spec of that
package, so verify.sh found one case more than the inventory holds.
Assisted-by: Claude:claude-opus-5-5
* Offer Clear filters when the backend Catalog shows nothing
The Installed view had a Clear filters button in its no-results state;
the Catalog view did not, although the spec asks for it in both. Move
the button to one place and show it in the Catalog when a search, a
type filter or a library filter leaves no row. Add Playwright specs for
both views, which the earlier Installed fix lacked.
Assisted-by: Claude:claude-opus-5-5
* Give the chat settings without an assistant tool their reason
chats_max_iterations, chats_work_folder, chats_index_files and
chats_index_max_text_bytes had no MCP tool and no entry in
adminRoutesWithoutTool, and the older chats_saved_agents,
chats_suggestions, chats_vision_model and chats_transcription_model
were unlisted too. Add each with the reason it stays a person's choice
in Settings: they apply to every user's chats, and the step limit is
one a model must not raise for itself.
Assisted-by: Claude:claude-opus-5-5
* Remove the stray allowed-hosts keys from the chats inbox locales
allowedHosts, allowedHostsOff and allowedHostsNote sat twice in eight
locales: under egress, where English has them and the UI reads them,
and again under inbox, where nothing reads them. In de and ko the two
copies had drifted apart. Delete the inbox copies and keep the egress
ones.
Assisted-by: Claude:claude-opus-5-5
* Bring three design specs in line with what shipped
- web tools and shadowing: the status still said phase 2 waits; both
phases shipped. Note that the Web add-on's keyless entry is now the
built-in keyless chain.
- Home front door: Home now creates the chat and sends the first
message itself, with a short-lived hand-off to the chat page, and
its composer takes attachments. Both were described the old way or
as out of scope.
- chat footer: the meter bar is 96 px with the tokens left, and a phone
keeps the bar and the tokens left, not the percentage only.
Assisted-by: Claude:claude-opus-5-5
* Store the command card's answer and read it back every two seconds
The answer to the command card of the built-in bash went only as a
bus message. When the message was lost, the call waited the full five
minutes and ended as not answered, although the person had clicked.
The network card stores its answer first and re-reads the card every
two seconds.
The command card now does the same, through one shared pair of
helpers: listenConsent subscribes before the card is stored, and
waitConsent wakes on the bus message or every two seconds and reads
the record, which holds the truth. The network card uses the same
helpers. HostExecDecide writes the decision to the card atomically
before it sends the message: the same answer twice is 204, the other
answer 409 already_answered, and an answered card is no longer shown
as waiting.
Assisted-by: Claude:claude-opus-5-5
* Load skills by name with a load_skill tool
A weaker model failed to read a skill at the path the index showed. The
chat now has a built-in load_skill tool that takes a skill's name and
returns the instructions of its SKILL.md, without the frontmatter.
The tool reads the same resolved skill set as the index: the nearest
skill of each name, the chat's switches, the add-ons the chat can use
now and the grants of its agent. It reads through the user's rights.
When the skill's folder holds other files, the answer starts with the
base directory and names up to 20 of them for read_file; a chat without
read_file gets the instructions only. A skill that needs commands, in a
chat that cannot run them, is refused with the reason. An unknown name
is an error that gives near names first and then every name the chat
has.
Every chat with at least one skill gets the tool, and a sub-agent gets
it with its parent's tools. It has no switch and Settings > Tools does
not list it.
The index of skills now lists names and descriptions only and tells the
model to call load_skill. The Sandbox sentence and the error for a
wrong skill path now point at load_skill too.
Assisted-by: Claude:claude-opus-5-5
* Tell the built-in skills to load other skills by name
localai-apps told the model to read the localai-workspace skill, and
writing-skills said a description is what the agent sees before it
opens the file. Both now speak of loading a skill with load_skill.
localai-workspace keeps the path of its schemas folder and says to read
it with read_file, since those files are not a skill.
A test checks that no built-in skill sends the model to another skill by
"read" or by the path of its SKILL.md.
Assisted-by: Claude:claude-opus-5-5
* Document load_skill and the skills index
The chats page now says that the prompt lists skills by name and
description, that load_skill returns a skill's instructions and names
the files of its folder, what it refuses and who gets it. It no longer
says the model reads a skill by its path. The add-ons page says how a
chat loads an add-on's skill, and the saved agents page says that the
skills grant limits load_skill too.
Assisted-by: Claude:claude-opus-5-5
* Add the proposal action spec to the tables-acceptance inventory
The spec that an editor cannot confirm a schema change as a write ran in
the suite but was never in the inventory, so the evidence manifest check
failed. CI did not reach that check before, because the burst spec
failed first.
Assisted-by: Claude:claude-opus-5-5
* Give a chat a goal that it works toward by itself
A chat can now hold one goal. While it is active, every turn's prompt
names it, the model gets goal_done and goal_blocked, and when a turn
ends by itself the frontend that ran it starts the next one with
"Continue toward the goal". The person's waiting messages still go
first, and a turn they start does not count as an automatic one.
The goal pauses when the person presses Stop, when a turn fails or is
cut short, and when the automatic turns or the model steps reach their
limits. Resume gives it fresh budgets. A sub-agent, and a chat that a
trigger, a connector, a job or a page started, has no goal and no goal
tools.
The goal lives with the chat record in the store (UpdateGoal on the
JSON and GORM stores, with contract tests and a column migration), so
every frontend reads the same one. The next turn goes through the same
turn claim as any other, so only one frontend continues it; a reaped
turn, or a JSON store that reloads after a crash, leaves the goal
paused instead of continued twice. The transcript keeps goal lines
(set, replaced, continue, paused, resumed, done, blocked, cleared) as
their own role, so an automatic message never reads as typed by the
person.
Assisted-by: Claude:claude-opus-5-5
* Add a store for the loops of the chats
A loop repeats a prompt in one chat, at an interval or when the model
asks for the next run. The new loops package keeps the loops, the
daily budget of runs of each owner, and which chats confirmed a loop
once. JSON and GORM stores share one contract suite. Add checks the
limits per chat and per owner in the same step as the insert, and
TakeRun never hands out the last run of a day twice, also across
frontends.
A user message that a loop sent now carries a loop mark (loop ID, run
number, self-paced or not). Both chat stores keep it. The model reads
a short line before the prompt that says the message repeats by
itself, and, for a self-paced loop, how to ask for the next run.
Assisted-by: Claude:claude-opus-5-5
* Add the file preview slot specs to the host-slots inventory
The file preview slot came with two Go specs in the host-slots package.
verify.sh found them in the run but not in the inventory.
Assisted-by: Claude:claude-opus-5-5
* Check the Settings page and the add-ons with the UI copy guard
The guard read the locale files only. The Settings page keeps its
English labels and descriptions in settingsSchema.js, and several
add-ons carry their own English bundle, so neither was checked.
The guard now also reads the label, description and note of every
setting and the label and hint of every group, and the English
bundles of the add-ons (an en.js module or an English JSON file).
The allow-list takes settings:<key>.<what> and addon:<id>:<key>
entries, each with its reason; the operator ones (the SearXNG JSON
format, the fine-tuning file types, the API route of a voice, the
schema of the Databases add-on) are listed.
What it found in the voice library is fixed in plain words: "model
YAML is not modified" and "Existing model YAML is not changed" now say
that the models' settings are not changed, in the voice add-on, the
Studio add-on and the host locales that have the strings (en, fr,
pt-BR). The two host allow-list entries for them go.
Assisted-by: Claude:claude-opus-5-5
* Add the update digest specs to the updates inventory
Two checker specs pin that an update names its candidate digest and
that a stale cached notice stays readable. The updates suite runs the
whole checker package, so the inventory needs both.
Assisted-by: Claude:claude-opus-5-5
* Draw the Add-ons search and state filter with the kit pieces
The Add-ons page had its own copy of the search box and the segmented
filter (ad-search, ad-seg), the same shapes and styles as the kit's
SearchField and Segmented that the Agents pages use. Use the kit
pieces and drop the copy; the narrow-screen rules now target the kit
classes inside the toolbar. The search keeps its test id.
Assisted-by: Claude:claude-opus-5-5
* Add the limits of a chat goal to the runtime settings
chats_goal_max_turns (1 to 200, default 20) is the most turns a chat
with a goal starts by itself, and chats_goal_max_steps (1 to 10000,
default 400) the most model steps of all the turns of one goal, before
the goal pauses. Both are read when a turn ends, so they apply without
a restart. They sit in Settings > Tools next to the step limit of one
turn, and the settings page documents them.
Assisted-by: Claude:claude-opus-5-5
* Add the goal routes of a chat to the API
GET, PUT and DELETE /api/chats/{id}/goal read, set and clear a chat's
goal, and POST /api/chats/{id}/goal/resume resumes a paused or blocked
one. They belong to the chat's owner like the other chat routes and
take the chats feature. A chat view carries the goal with the limits
that apply now, the transcript gives goal lines with goal_event and
goal_turn, and the event stream has a goal event so that other tabs
read the chat again.
The routes are in the route feature registry, the swagger file and the
chats entry of /api/instructions. They manage the person's own chat,
not the server, so they have no MCP tool, as the other chat routes. The
chats page documents goals, their states and limits, and how several
frontends share them.
Assisted-by: Claude:claude-opus-5-5
* Redesign the notification inbox
The inbox panel was a plain list: every row had the same weight, a
failed run looked like a finished one, the panel ran off the screen on
a phone, and a failed load showed nothing.
The panel now has a header with the unread count, a menu for Clear read
and Clear all, Mark all read, and an All / Unread / Failed filter. Items
are grouped under Today, Yesterday and Earlier. Each item shows what
sent it (trigger, connector or LocalAI) with a tinted icon, the title,
two lines of text or the reason of a failure, and when it happened, with
the exact time on hover. A click marks it read and opens its chat; a
connector that was switched off opens the connectors page. Mark read and
Delete show on hover or focus, and stay in view on a touch screen.
The panel has a loading skeleton, an error with Try again, an empty
state that says what shows up here, and a caught-up state. A new
notification slides in, is highlighted for a few seconds and is
announced once. The badge stops at 99+. Focus moves into the panel when
it opens; the arrow keys, Home, End and Delete work on the items, and
Esc returns focus to the button. On a phone the panel is a sheet over
the bottom of the screen with 44 px targets.
Assisted-by: Claude:claude-opus-5-5
* Explain the reads of the text index and the provider requests to gosec
gosec flagged three new lines. The text index reads files from the
directory the host gave it, with names it listed there. A search
provider sends its request to the endpoint fixed at construction, never
to a URL from a tool call. Mark the lines with the reason.
Assisted-by: Claude:claude-opus-5-5
* Keep the add-on details open when Escape closes a dialog
Escape in the restore dialog of the recovery panel also closed the
details pane around it. The pane handles Escape in React, and the
dialog listens on the document, so the pane ran first and saw no
preventDefault. The pane now registers as a modal layer and closes only
when it is the top layer, as the side panes do.
Assisted-by: Claude:claude-opus-5-5
* Add skill and agent messages, compact and clear to the chats API
A message can now carry a skill of the chat or one of the user's saved
agents. The server checks the name against the chat's resolved skill
set (switches, the agent's grants, shadowing, add-ons) or the saved
agents the chat may call, and refuses an unknown one with a 400 that
lists what the chat has. A skill message gives the model the text of
the skill's SKILL.md before the user's words, for that turn only; an
agent message asks the model to start the agent with spawn_agent. The
transcript keeps the words and the command (Entry.Invoked), not the
instructions. A regenerate or an edit runs the command again. Such a
message does not wait for a running turn: it answers 409.
POST /api/chats/{id}/compact runs the summary the loop makes when the
context fills up, through a new optional session.Compactor that the
loop harness implements with its own history compaction. The compacted
state is saved for the next turn and a "compacted" line with the sizes
goes in the transcript.
POST /api/chats/{id}/clear writes a "cleared" line and drops the saved
state. The model then sees nothing above the line: the rebuilt state
and the transcript summary start after the last such line, so a fork
from above it keeps what the model saw there. Export, import and the
Markdown export carry both lines.
Both calls take the turn claim, so no frontend starts a turn on the
chat meanwhile; messages sent during them start a turn after. No MCP
tool: they act on one chat of the caller and are not admin calls.
Assisted-by: Claude:claude-opus-5-5
* Give each network card spec its own settings
A check that a card spec starts can still wait on its card when the
next spec begins. It read the settings through a closure over a
variable that the next BeforeEach replaced, which the race detector
reported. Each service now reads the settings holder it was built with,
under a lock, and the specs change the settings through it.
Assisted-by: Claude:claude-opus-5-5
* Show the effective audit retention in the settings snapshot
The settings snapshot returned 0 for chats_egress_audit_days while the
setting was unset. Any page that saves the whole snapshot back, such as
the Traces settings strip, then posted 0 and the server rejected it with
"Invalid chats_egress_audit_days", so saving tracing settings failed on a
fresh install.
Report the retention that applies (30 days by default), the same way
chats_max_iterations does.
Assisted-by: Claude:claude-sonnet-5-5
* Run the Sandbox smoke test in a short scratch folder
The smoke test step was reached for the first time and both isolated
backends refused to start: their state folder under the checkout's
cache made the socket paths 126 and 132 bytes, over the 107 a unix
socket allows. Point SANDBOX_SMOKE_WORK at /tmp/sandbox-smoke, which
leaves room for the sockets.
Assisted-by: Claude:claude-opus-5-5
* Use paths from the home folder in the Sandbox command specs
bash now starts in the user's home folder, so the end-to-end specs that
read and wrote home/<name> found nothing. Use names relative to the
home folder, as the Sandbox specs already do.
Assisted-by: Claude:claude-opus-5-5
* Show the goal of a chat on the chat page
A bar above the composer shows the goal with its state: Pause (which
stops the running turn and so pauses the goal) while it works, Resume
when it paused or needs the person, and Clear. A second line says why
it paused, what the model needs, or what it did. The status line shows
"Goal · turn 3 of 20" while the goal is active, and the Continue button
of a paused turn stays away then, since the next turn starts by itself.
The transcript shows the goal lines as quiet rows: set, replaced,
continuing (with its turn), paused with the reason in plain words,
resumed, reached with the summary, blocked and cleared. A goal event,
also from another tab, reads the chat again, and a hidden live region
says each change to a screen reader once.
The strings are in all nine locales. Unit tests cover the helpers and
the thread rows; a Playwright spec covers the bar, the progress, the
lines, Pause, Resume, Clear and the event.
Assisted-by: Claude:claude-opus-5-5
* Run the loops of the chats
The loop host fires the loops that are due. A run sends the prompt to
the chat as a new message only when the chat is idle: a busy chat
skips the run, and an interval loop moves to its next slot instead of
piling missed runs up. A self-paced loop gets two tools while it is
active: schedule_next (clamped to the minimum interval and one day)
and loop_done. A run in which the model calls neither ends the loop
with a note. A loop ends after its run limit, pauses after three
failed runs in a row, and waits for the next UTC day when the owner's
daily budget of runs is used up.
One frontend fires the loops, the holder of a new advisory lock. A run
is claimed in the store first, and the turn claim keeps one turn per
chat, so a short overlap of two leaders starts no run twice. A new
leader finishes the runs the old one watched. Deleting a chat removes
its loops; a due loop whose chat is gone removes itself.
Five runtime settings bound the loops: chats_loop_min_interval,
chats_loop_max_runs, chats_loop_max_per_chat, chats_loop_max_per_user
and chats_loop_daily_runs.
Assisted-by: Claude:claude-opus-5-5
* Add the loops API of the chats
GET, POST and DELETE /api/chats/{id}/loops list, start and stop the
loops of a chat; PUT and DELETE /api/chats/{id}/loops/{loopId} pause,
resume, confirm or stop one. GET /api/chats/{id} carries the loops,
the transcript marks the user lines a loop sent with loop {id, run},
and a loops event tells every open tab that the list changed.
The routes are gated by the chats feature and documented in the
swagger spec and the API instructions. They act on the caller's own
chats only, so they get no assistant tool. The chats page gets a loop
section, with when to use a loop and when a trigger, and the cluster
behaviour; the runtime settings page lists the five loop settings.
Assisted-by: Claude:claude-opus-5-5
* Show the loops of a chat above the message box
Each loop of the open chat shows as one line over the message box,
such as "Loop · every 5 min · run 4 · next at 09:20", with Pause or
Resume and Stop. A loop that paused itself or ended says why; an
ended one has Dismiss. The first loop of a chat shows a card instead:
"Run this every 5 minutes until you stop it?" with Start and Cancel.
The server keeps the pending loop, so a reload shows the card again.
A message that a loop sent carries a small "Loop run 4" label. The
page reads the loops again on the loops event, so every open tab
follows. The answer to /loop list is ready for the slash command. The
strings are in all nine locales.
Assisted-by: Claude:claude-opus-5-5
* Build the chat slash menu on one command registry
The slash commands of Home and of a chat now come from one registry,
src/utils/slashCommands.js. A command has a kind (navigate, insert or
server), the surfaces that offer it, a needs check and, optionally, an
argument provider: args(query, ctx) returns the items the menu lists
when the box holds "/cmd <query>". Every existing command and test id
stays; the registry adds:
- one command per skill of the chat or the draft (/review), marked
built-in, add-on, shared or yours, and /skill <name>, which also
reaches a skill named like a command;
- /agent <name> <task>, which lists the saved agents, or offers to turn
them on for the chat when they are off;
- /model and /models with a model name, which switch at once;
- /compact and /clear, which show their progress in the status line
and leave a marker in the thread; the context meter drops;
- /attach and /attach clear, /help (a card that lists the commands)
and /about (a card with the model, its state, the tools, skills,
where commands run and the context size).
Tab completes a command or an argument, Enter runs it, and the first
row whose name starts with what was typed is selected first. A message
typed as a skill or agent command is sent with its marker, not as text;
the transcript shows the words and a chip. Home offers the same set
without the commands that need a thread.
The words are in all nine locales. The registry has unit tests, and a
Playwright spec covers the menu, the completion, the marker, the chip,
compact, clear and the cards with the routes stubbed. The docs page
gains a slash commands section.
Assisted-by: Claude:claude-opus-5-5
* Add the /goal command to the chat
/goal shows a card with the goal, its state, the automatic turns and
steps it used out of the limits, and when it was set. /goal <text>
sets a new goal at once, replacing the one the chat had without a
question, and the goal bar says what it replaced. /goal clear and
/goal resume act on the goal; typing "/goal " lists resume and clear
for the goal the chat has. None of these is sent as a message. In a
new chat, /goal <text> creates the chat first.
The command sits in the Context group of the shared command registry,
on the chat surface only, and not in a chat that a trigger or another
run started. A skill named goal is reached through /skill. Unit tests
cover the parser and the menu items; the Playwright spec covers the
card, setting, replacing, resuming and clearing from the box.
Assisted-by: Claude:claude-opus-5-5
* Add /loop to the chat
/loop [interval] <prompt> starts a loop in the chat; /loop list shows
the loops of the chat with the code that /loop stop takes, and
/loop stop [id] stops one or all. The command is in the slash
registry: after "/loop " the menu offers list and stop, and after
"stop " the loops of the chat.
A loop prompt that invokes a skill (/review the diff, or
/skill review the diff) runs as the person's skill command would: the
model gets the skill's text, and the transcript shows the words, the
skill's chip and the loop label. The server checks the skill when the
loop starts and at each run; any other command is refused with the
reason.
Assisted-by: Claude:claude-opus-5-5
* List the loop settings on the Settings page
The five limits of the loops of the chats show under Tools with the
other chat settings: the shortest interval, the runs per loop, the
loops per chat and per user, and the loop runs per day. All apply at
once.
Assisted-by: Claude:claude-opus-5-5
* Clear a sent Home draft when the page goes away too
Home clears the draft of a sent message when it closes, so the text
stays visible while the composer moves into the new chat. While the
chat page still loads, a reload or a closed tab could come first, and
the sent text came back on Home. Leaving the page now clears it too.
Assisted-by: Claude:claude-opus-5-5
* Check the close error of a previewed file
golangci-lint (errcheck) flagged the deferred Close of the file the
preview endpoint reads. Discard its error explicitly, as the other
studio file handlers do.
Assisted-by: Claude:claude-opus-5-5
* Explain the document read of the text index to gosec
gosec reported the read of a stored document text as a file inclusion
through a variable. The path is the configured index directory joined
with a hash that documentPath refuses when it holds a path separator
or a dot, so it cannot leave the documents folder.
Assisted-by: Claude:claude-opus-5-5
* Build the darwin/arm64 add-on packages on macos-15
GitHub retires the macos-14 runner image on 2026-11-02 and runs
scheduled brownouts until then: every darwin/arm64 package job failed
at once with the brownout notice. macos-15 is the Apple Silicon image
that replaces it.
Assisted-by: Claude:claude-opus-5-5
* Expect the chat's own load_skill tool in the agent prompt spec
The spec that keeps the agent engine's text out of the prompt also
required that no load_skill tool or mention reach the model. Chats now
offer their own load_skill tool and the skills index tells the model to
call it, so the spec failed. Keep the checks on the engine's own skill
and command text, and check that the chat's tool is offered.
Assisted-by: Claude:claude-opus-5-5
* Read the user's scripted tool call in the mock model
The chat prompt now names the chat's work folder, and the folder name
comes from the chat title, which holds a cut copy of the user message.
The mock model read the first ADDON_TOOL_CALL marker, the cut one in the
system prompt, and called the tool with broken arguments over and over.
Read the last marker, the one of the user message.
Assisted-by: Claude:claude-opus-5-5
* Keep the phone targets of the inbox at 44 pixels in every build
On a phone the inbox close button and the item actions must be 44
pixels wide. Their rule had the same weight as the kit's .dk-btn
min-width, so the order of the stylesheets decided. The single-chunk
coverage build puts the kit later and the close button shrank to 28
pixels. Scope both rules to their container so they win in any order.
Assisted-by: Claude:claude-opus-5-5
* Let the about-you spec accept the sidebar's own seen date
When the user has pages, the sidebar writes the date of its New label
under seen.new in user.json. In the whole suite other specs make pages
first, so the saved record held that date as well and the exact
comparison failed. Check that the uploaded seen record is kept.
Assisted-by: Claude:claude-opus-5-5
* Give the typed-while-working chat spec time for two turns
The spec waits up to 30 seconds for each step, but two turns of the
mock model with its tool steps take about 28 seconds on their own, so
the default 30 second test limit cut it under load. Set its limit to
90 seconds.
Assisted-by: Claude:claude-opus-5-5
* Scope the foundation spec's state query to the sheet header
The details pane now repeats the observed state under Health, so a
role query for "State: Off" inside the add-on sheet matched two
elements and failed in strict mode. The administrator-manages spec,
and the two specs after it, did not run.
Query the chip in the header (.ad-pane__meta), which is the state the
administrator sees next to the add-on's name, through one helper used
for all three assertions.
Assisted-by: Claude:claude-sonnet-5-5
* Wait for the page to read each apps answer in the registry polling spec
The spec moved the page clock as soon as the stand-in saw a request.
The registry sets its next timer only after it reads the answer, so on
a slow runner the clock could pass the due time of a timer that did not
exist yet, and the next read never came. With the CPU throttled ten
times, the old spec failed in 3 of 3 runs.
The spec now counts the answers the page has read and lets queued work
finish before it moves the clock. It pauses the clock before it leaves
the page, and it tries the pause again when a slow machine passed the
target time. The assertions do not change: one read at once, one read
per interval and no faster, and no read after the disable.
Assisted-by: Claude:claude-opus-5-5
* Close the egress test listener only after it accepts
The dial helper of the egress proxy specs closed its listener when it
returned, while the goroutine that accepts the connection could still
be waiting to run. On a loaded runner the listener sometimes closed
first. The kernel then reset the connection that was still in the
accept queue, the spec's write consumed the reset error, which the
spec ignores, and the next read saw EOF. The spec failed with
"unexpected EOF" before the proxy got the request.
Wait for Accept to return before the listener closes, then start the
proxy handler on the accepted connection.
Assisted-by: Claude:claude-opus-5-5
* Run host commands in a shell that reads no startup file
The host backend promises a fixed environment, but bash -lc reads
/etc/profile and the user's dotfiles. On macOS path_helper rewrote PATH
there, so the command did not get the fixed PATH. Turn the login shell
into 'bash --noprofile --norc -c' in the host backend only, and make the
test assert the exact fixed PATH.
Assisted-by: Claude:claude-sonnet-5-5
* Answer the command card in the platform turns and 400 a bad answer
The tool-list spec of the platform suite timed out: without accounts
the one person is an administrator, so the chat has the built-in bash
in ask mode. The tool list is sorted and bash is first, and the mock
model calls the first tool, so every turn waited five minutes for a
yes on the command card. The turn helper now refuses each card it
sees, as a person who clicks deny, and the turn goes on to its step
limit. bash stays in the checked list.
An administrator who posts a consent with no card id or an unknown
decision got 403 "only an administrator can allow commands". That is
a bad request, not a refusal: it now answers 400, as the swagger
already says, and the route check for administrators passes.
Assisted-by: Claude:claude-opus-5-5
* Let a chat switch off its shell, file, media, collection and sub-agent tools
The Tools menu of a chat switched only three groups of built-in tools:
web, saved_agents and triggers. A person could not keep a chat away
from the shell, the files, the helper models, the collections or the
sub-agents.
Add five groups to the registry of built-in tools, all on until the
chat switches them off:
- shell: bash, whoever serves it. The Sandbox add-on's bash and the
built-in bash on this machine both go, and the add-on list reports
commands switched_off. The row stays listed when nothing can run
commands, not usable, with the reason.
- files: the file tools and read_document.
- media: describe_image and transcribe_audio.
- collections: list_collections and search_collection, usable only
for a user with the collections feature.
- subagents: spawn_agent. list_agents and /agent follow it.
The registry gets two flags. External marks a tool that its own code
builds for the turn (with the wrappers it needs); the registry lists it
in its group and Hidden tells the builder what the chat switched off.
Covers makes a group's switch also hide the add-on tool that replaces
the built-in one. PUT .../builtins, the panel, the draft list and the
fallbacks use the existing group code.
A sub-agent follows the switches of its parent chat. The turn prompt
is built from the final tool list: no file tool names, no skill
writing hint, no work folder and no rules pointer when the chat cannot
use files, no describe_image when the helper is off, and a commands
block that says the person switched commands off.
Assisted-by: Claude:claude-opus-5-5
* Queue the edited chat message after the first step starts
The spec queued its message as soon as the chat showed a busy state.
The turn is busy before its first model request starts, and that
request takes every message that waits. With the chat's tools and
skills, the first request now starts a few hundred milliseconds after
the turn, so the message went with it, and the edit got 409 "already
delivered" (5 of 6 local runs).
Wait until the answer streams, as the Stop spec does: the request is
running then, and the queued message waits for the next step.
Assisted-by: Claude:claude-opus-5-5
* Give the reload chat spec a turn that outlasts the reload
The spec reloaded the page during a 3 second turn and expected the
page to show the turn still running. A reload of the coverage build
on a busy runner takes 4 to 5 seconds (measured with a 6x CPU
throttle), so the turn had ended before the page could show it. With
the 30 second turn the page always showed it running, so the resumed
state works and only the window was short.
Add MOCK_LONG_STREAM_MEDIUM to the mock model, a turn of about 10
seconds, and use it in the spec with a 60 second limit.
Assisted-by: Claude:claude-opus-5-5
* Wait for the chat page after the Home hand-off in the specs
Send on Home changes the address inside a view transition, and the
chat page mounts about 35 ms later. Specs that waited only for the
address acted on Home in between:
- The title spec pressed Ctrl+K at once, because the server names a
chat after its first message as it arrives, so the title poll ends
at once too. The key reached the page before the chat page listened,
and the menu did not open (3 of 3 presses at 0 ms failed, all at
300 ms and later passed).
- The Back spec went back before the chat page was drawn. Home was
never closed, so its draft of the sent message stayed in the box.
startChat now waits for the chat page, the title spec for the title
in the chat header, and the Back spec for the chat page before Back.
Assisted-by: Claude:claude-opus-5-5
* Accept a resolved user.json conflict in the quiet page specs
The sidebar notes in the seen record of user.json the first day it
labels Pages as new, and saves it from the version it read. All specs
share the test server's one user, so tabs of other workers save the
same record at the same time: one gets 409 and useSeen reads the file
again and saves once more. Three tabs that open /app/files at once
give 200, 409, 200. The quiet check of the shell pages counted the
409 as a problem.
Ignore a 409 on PUT /api/chats/wellknown/user in that check.
Assisted-by: Claude:claude-opus-5-5
* Check only the spec's own items in the Trash after the reload
After Empty Trash and a reload, the spec expected the whole Trash to
be empty. The Trash belongs to the test server's one user, and specs
of other workers move files to it meanwhile, so CI saw "In the Trash:
3 files, 356 B." after the reload.
The checks right after Empty Trash stay. After the reload, check that
the server's Trash, the Trash tab and Files do not hold the spec's
items, once each list has loaded.
Assisted-by: Claude:claude-opus-5-5
* Show the new tool groups in the chat Tools menu
The Tools menu lists the groups of built-in tools that the server
sends. The five new ones need words and an order, and the shell row
needs to say who runs the commands.
Name the groups in all nine locales (Run commands, Your files, Images
and audio, Collections, Sub-agents) and order the rows by what they
reach: the shell and the files first, the triggers last. The shell row
carries a chip with the provider (Sandbox or This machine) and a line
under it that says the commands run isolated, or a warning when they
run on this machine. When nothing can run commands the row is disabled
with the words of the commands notice. A switched-off sub-agents group
also turns /agent off in the composer.
The specs cover the rows, the off and on requests, the bulk actions,
the provider chip, the disabled reasons, the draft on Home and a real
server round trip.
Assisted-by: Claude:claude-opus-5-5
* Document the groups of built-in tools a chat can switch off
The Tools menu section described only the web, triggers and saved
agents groups. List every group with its tools, say which groups the
server may leave out and why a row can be not usable, how Run commands
covers both providers, what the model is told when a group is off, and
that a sub-agent follows its parent's switches.
Assisted-by: Claude:claude-opus-5-5
* Fix the host command consent spec for the 400 contract
The consent route answers 400 for a body without a card id or with a
decision other than once or deny, whoever asks. The spec still expected
403 for always from an administrator. Cover the malformed answers for
an administrator and a user, each in their own chat, keep another
user's chat at 404, and say it in the chats guide.
Assisted-by: Claude:claude-sonnet-5-5
* Find the web tool group by name in the route spec
The tools panel lists the shell group before the web group, so the
spec that read the first entry compared the wrong group. Look the
group up by name, which does not depend on the registry order.
Assisted-by: Claude:claude-sonnet-5-5
* Give Skills its own sidebar item
Skills work in every chat, but the sidebar filed the page under Agents
and only agent skill chips linked to it. Add a Skills item right after
Memory, show it in the legacy list too, and stop lighting up Agents on
the skills pages.
Assisted-by: Claude:claude-sonnet-5-5
* Add Manage skills to the skills picker and a /skills command
The picker now ends with a Manage skills link, and /skills opens the
Skills page from the composer on Home and in a chat. The picker keeps
the height of its status line, so a refused change does not move the
rows. Words are added in all nine locales.
Assisted-by: Claude:claude-sonnet-5-5
* Say on the Skills page that every chat can use skills
The page subtitle named only agents as users of skills. Say that all
chats use them, translate it, and document where to manage skills: the
sidebar item, the picker link and /skills.
Assisted-by: Claude:claude-sonnet-5-5
* Keep Home on screen while its widgets load their strings
A Home widget reads the pages strings, and i18next loads a namespace
the first time a component asks for it, by suspending. Home had no
Suspense boundary of its own, so the route's boundary hid all of Home
(display: none) and showed the route fallback until the strings came.
A widget shows only after home.json is read, so this happened after
the composer was already drawn. Focus given to the composer in that
window went nowhere, and a key typed then was lost. The slash menu
spec failed in CI this way when another spec had written widgets to
the shared home.json: the box was empty and the body had the focus.
With widgets in home.json the composer was hidden right after it
appeared on 5 of 5 loads, and the spec failed 1 or 2 times in 5.
The examples and the widgets now have their own boundary with no
fallback, so only they wait. A new spec holds the pages strings while
it types in the composer; it fails without the change (the composer
never takes the focus) and passes with it.
Assisted-by: Claude:claude-opus-5-5
* Send the stubbed file change after the Files page read the folder
The change feed spec answered the first stream request with the change
at once. The app shell (the appearance sync) opens the shared stream
when the app starts, before the Files page chunk has loaded, so on a
slow runner the change reached a tab where nobody watched the folder
yet and was dropped. The page then never read the folder again, and
the spec failed on all three CI attempts. A CPU throttle of 8 makes it
fail 3 of 3 times locally.
On a real server the page's own first read would already show such a
change. The stub now holds the change until the page has read the
folder; the page subscribes in the same commit as that read, so the
change arrives with a subscriber in place. The resume check is kept.
Assisted-by: Claude:claude-opus-5-5
* Give the oversized 3D input as a sparse file, not a buffer
The spec built a 32 MiB buffer and passed it to setInputFiles.
Playwright sends a buffer to the browser over the protocol: that step
alone took 5.5 s on an idle machine, and on the loaded CI runner the
test hit the 30 s timeout on all three attempts.
It now writes a sparse file of the same size to the test output folder
and passes its path, which the local browser opens as is. The page
still checks the size before it reads the file, so the toast and the
no-request assertion are unchanged. The step takes milliseconds.
Assisted-by: Claude:claude-opus-5-5
* Check the tool line, not the tool name, in the tool call journey
The spec looked for /list_files/ in the transcript. The user's own
message holds that name, so the check could pass before any tool ran.
Once the model's call streamed, a second element matched, and the
strict locator failed (2 matches on the first attempt, 5 on the
retry).
It now waits for the activity line that the tool run adds ("Listed
/"), which only shows after the tool ran.
Assisted-by: Claude:claude-opus-5-5
* Measure the proposal card and its neighbours in one frame
The layout spec read the boxes of the message text, the card and the
message actions with three boundingBox calls. They are viewport
positions taken in three frames, and the transcript scrolls to its end
while the card fills in. On the phone the actions were read after a
scroll and came out 156 px above the bottom of the card.
The three boxes now come from one page.evaluate, so they are read in
the same frame. The order assertions are unchanged.
Assisted-by: Claude:claude-opus-5-5
* Read the page fixture time when the page is served
The task board fixture of the discovery and Pages specs set its
change time once, when the file loaded. A worker loads it once and
keeps running specs, so a spec that ran two minutes later read
"Changed 2 minutes ago by the assistant" where it expects "just now".
The change time is now a getter, read when the fixture is serialized
into a response.
Assisted-by: Claude:claude-opus-5-5
* Check a background process without /proc in the host test
The alive helper read /proc/<pid>/stat, which macOS does not have, so it
reported every process as dead and the spec failed there with "did not
survive the suspend". Use signal 0 for existence and ps (or /proc where
present) to tell a zombie from a live process.
Assisted-by: Claude:claude-sonnet-5-5
* Keep the composer switch menus at their own height
The base rule of the dropdown menu caps it at 320px and lets it scroll.
The switch menus set their own height with a rule of the same weight in a
lazy sheet, so which one won depended on the order the sheets loaded in.
When the base rule won, the picker showed a few rows, and a click on a row
at the edge of the list scrolled the list by a few pixels.
Double the class on the switch menu rules so they win in any order, and
add a test that loads the base rule last.
Assisted-by: Claude:claude-sonnet-5-5
* Show what applies in the chat Network switch
The Network row highlighted the choice stored with the chat, so a
chat that asked for All public sites under an administrator limit of
Off showed All highlighted although it was disabled and not in effect.
Highlight the effective choice, the lower of the chat's choice and the
limit, which the server already computes. When the chat asks for more
than the limit, say so in one line instead of highlighting it. Add a
line that the limit is set by the administrator, and tell the host
network line that limits need the Sandbox add-on.
Assisted-by: Claude:claude-sonnet-5-5
* Design the host components that add-ons reference
The host UI names the Sandbox and the Web add-ons to place their server
settings, and the chat's Network row links to the global Settings page.
Describe a closed registry of host components that an add-on references
by ID in its manifest, entitled by the existing host operation grants
and tool shadows, so the controls live on the add-on's own page and a
second add-on can reuse them with no host change.
Assisted-by: Claude:claude-opus-5-5
* Let an add-on ask for host controls in its manifest
An add-on had no way to say that a host-owned control belongs on its
page, so the host UI placed those controls by add-on name. Add
ui.controls, a list of IDs from a closed registry of host controls:
network-policy (the network of the commands an add-on runs) and
tool-providers (the rows of the built-in tools it replaces).
The host decides which controls it draws: EntitledControls keeps a
control only when the add-on holds its host operation grant and
replaces its built-in tool. The field is optional, so every existing
manifest stays valid.
Assisted-by: Claude:claude-opus-5-5
* List the host controls an add-on is entitled to
The apps list now carries ui.controls with the host controls the
manifest declares and the host setting entitles the add-on to: the
manager passes each add-on's host operation grants to the list.
GET /api/chats/{id}/egress gains addon, the add-on that runs the chat's
commands when it has the network-policy control, so the chat can draw
its Network row from that add-on and link to its settings. applies and
applies_reason keep their meaning: the policy holds with or without the
control. The model and the 400 error no longer send an administrator to
Settings > Tools for the limit.
Assisted-by: Claude:claude-opus-5-5
* Draw the host controls on the add-on's own page
The host UI placed the Sandbox network and the tool rows by add-on name
(SERVER_SETTINGS), showed a read-only summary on the add-on's Settings
tab, and linked the chat's Network row to the global Settings page.
Add a registry of host controls keyed by the IDs of the contract. The
Settings tab of an add-on draws, below its own sections, the controls
the apps list gives it, in one form with the pending bar of the
Settings page: network-policy reuses the network section and its
setting rows, tool-providers the rows of the built-in tools the add-on
replaces. The chat draws its Network row only when the server names an
add-on with network-policy, and the administrator link opens that
add-on's tab. The row itself is unchanged.
The Settings page keeps the settings of features that exist without an
add-on, points to the add-on where one serves them, and no longer
lists the network settings. Its old address opens the add-on's tab.
Assisted-by: Claude:claude-opus-5-5
* Ask for the host controls in the Sandbox and Web manifests
The Sandbox asks for network-policy and tool-providers, so the network
of its commands and the bash row are drawn on its own Settings tab and
its chats get the Network row. The Web add-on asks for tool-providers
only: it holds no egress grant. The manifest tests check that the
default grants entitle each add-on to what it asks for.
The proxy's refusal now sends the model to the Sandbox Settings tab
instead of Settings > Tools.
Assisted-by: Claude:claude-opus-5-5
* Keep the button of the network pointer row at its size
The row used the wide layout of the tool rows, so its Open settings
button stretched across the page. Use the plain setting row, which
puts the button on the right like the other controls.
Assisted-by: Claude:claude-opus-5-5
* Fix the add-on contract inventory and the apps Swagger fixture
The contracts case inventory missed the six host control specs, so
verify.sh rejected the Ginkgo report. List them.
The Swagger spec test never saw `controls` in the emitted UIView JSON
because its fixture left the omitempty field empty. Set it there.
Assisted-by: Claude:claude-sonnet-5-5
* Add the host controls spec to the apps inventory
The apps suite ran one more spec than scripts/addons/suites.json
listed, so verify.sh rejected the apps report as an inventory
mismatch. List the new spec in the report inventory and in the
suite's aggregate case list. Every other Ginkgo suite was re-run
and matches its inventory.
Assisted-by: Claude:claude-sonnet-5-5
---------
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
|
||
|
|
0b5cab414a |
feat(galleryop): limit concurrent backend operations (#12888)
* feat(config): add a limit for concurrent backend operations Add --max-concurrent-backend-operations (LOCALAI_MAX_CONCURRENT_BACKEND_OPERATIONS) and the runtime setting max_concurrent_backend_operations. It sets how many backend install, upgrade and delete operations run at the same time. The default is 1, which keeps the current behaviour. Values below 1 become 1. The runtime value is also kept in an atomic, so the scheduler that reads it never races with a settings change. The setting shows in the Settings page under Backends. This commit only adds the setting; the next one uses it. Assisted-by: Claude:claude-sonnet-5-5 go-vet Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(galleryop): run backend operations in a bounded pool One goroutine read both gallery channels and ran each operation inline, so one slow backend operation held every operation behind it, model operations included. In distributed mode the manager also visited the nodes one at a time and waited for each up to the install timeout. Backend operations now go through a scheduler that runs up to max_concurrent_backend_operations of them at once. Operations start in the order they arrive. Install, upgrade and delete of one backend share a key and never overlap; an operation that waits for its backend does not hold a slot, so independent operations pass it. Cancelling a queued operation drops it at once. The limit is read on every scheduling step, so a runtime change applies without a restart. With the default of 1 the behaviour is the old single worker. Model operations get their own loop and stay serial. In distributed mode the manager sends one operation to its nodes in parallel, up to the same limit, and keeps the result in node order. A per (node, backend) lock keeps a live operation and the reconciler from sending the same operation to a node together; the reconciler skips a row that is locked and checks that the row still exists before it sends. Timeouts are unchanged. The download progress timers were plain package variables. They are atomics now, because operations can run in parallel. Assisted-by: Claude:claude-sonnet-5-5 go-vet go-test 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> |
||
|
|
7c74d26555 |
feat(addons): run the add-on owner as its own workload (#12867)
* 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> |
||
|
|
f1ff707b97 |
feat(addons): list and install backends from a library (#12816)
* feat(addons): add the add-on runtime, navigation and first-party add-ons Add the add-on runtime to LocalAI: manifests, the host operation broker, admin gating by account role, the add-on UI bundle builder and the SDK for add-on pages. Move the navigation to Chat, Models, Add-ons and Settings, with pinned add-ons in the sidebar and an Add-ons drawer. Ship the first-party add-ons (voice, face, fine-tuning, quantization) as packages for linux/amd64, linux/arm64 and darwin/arm64. Intel macOS (darwin/amd64) is enabled in the platform files. Squashed from the review branch. The tree is unchanged; the pre-squash history is kept on a local backup branch. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(gosec): tighten file modes and annotate false positives Write job state, configs and imported models with mode 0600, clean the paths that are read from disk, and mark the route tables, the header name and the retry jitter as reviewed false positives. Assisted-by: Claude Code:claude-sonnet-5-5 gosec Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): keep API keys off recovery mutations and same-site cookies POST /api/addons/dispatch forwarded the recovery and update mutations to the owner for any administrator key, which skipped the browser-session rule of their own routes. Refuse those actions there. Also apply the same-origin rule to the legacy token cookie, not only the session cookie, because the global CSRF check lets same-site requests through. Do not fail the start of LocalAI when an add-on library cannot be refreshed; log it and keep the installed add-ons up. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): refuse a reinstall that would migrate retained data unbacked An upgrade takes a verified backup before the new package migrates the data. A reinstall over retained data has no running generation to stop, so the new package would migrate it without one. Refuse a reinstall whose data format differs from the retained one. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): release the manager lock while a release is fetched Submit held the state lock across the catalog lookup and the package download, so the apps list and status reads stalled for the whole install. Serialize Submit and Reconcile with their own lock and drop the state lock around the fetch only. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): kill an add-on only after three missed health probes One slow 100 ms probe killed a busy add-on and dropped the work it was serving. Require three misses in a row. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): move settings under the operate hub The Operate hub already has Settings as a tab, so the sidebar no longer shows a separate Settings entry. The /app/settings route stays, and the hub tab still reaches it. The navigation test now expects Chat, Models and Add-ons as the primary items. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): accept same-host browser requests that carry no fetch metadata Browsers do not send Sec-Fetch-* headers over plain HTTP on an address that is not localhost, so every add-on request from a LAN address was refused as cross-site. Keep refusing a Sec-Fetch-Site value other than same-origin. When the header is absent, accept the request only if its Origin, or failing that its Referer, names the same host and port as the request. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): show the server's reason when an add-on request is refused A 403 or 400 answer showed a generic message and hid the reason in the response body, so a refusal by the browser guard could not be told from a missing permission. Show error.message from the body as the server sent it, and keep the generic text when the body has none. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): run the add-on owner in the normal binary The owner was only enabled when an embedding host set the add-on config, so a normal run answered every add-on request as unavailable. Add --addons-enabled (default true) and --addons-ownership-mode (default named-user, or local-owner; anything else stops startup). The node is its own owner: state lives under the data path, and the carrier uses loopback keys made at each start and kept in memory. --addons-enabled=false turns the owner off completely. Named-user mode needs the account database. With authentication off the owner stays off and a warning says how to use local-owner. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): keep the loopback owner address stable across restarts The single-node loopback transport took a new ephemeral port at each start. The owner stores its address in its identity file and refuses to start when the address differs, so every start after the first failed with "invalid addon owner configuration". Choose the port once, keep it in the add-on state root (loopback-port, mode 0600) and reuse it. If the port is taken at a later start, stop with an error that names the port and --addons-enabled=false, because another port would break the identity check. The keys and certificate stay in memory. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): let add-on pages keep the host modules they were written against A page written for the web UI imports React, the router, i18next and a few shared components. The add-on builder refuses a bundle that holds a host file, so moving such a page into an add-on meant rewriting it. Add a compat mode to the shared builder. It answers those imports from a directory of stand-ins that read the SDK, bundles the dynamic import of the page code into the one output file, and collects the style sheets the page imports. A relative import that is not in the add-on's own source resolves to the file at the same path under the compat directory. Widen the SDK for these pages, without a version change: more host components (spinner, modal, lightbox, model picker) and hooks (models, resources, voice profiles, operations, MCP client), a style on icons, the icon names the pages draw, sdk.navigation.openApp to open a page of the app (only addresses under /app, and the way back as the only state) and sdk.portal. The shell passes the query string to the page. The stand-ins cover the router (the tab in the path, the model in the query), the translator over the host locale files, the core API calls through sdk.core.fetch, and byte copies of the pure helpers, with tests that keep the copies equal to the originals. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): ship Studio and Talk as add-ons instead of built-in pages Studio and Talk were compiled into the web bundle and always listed in the Add-ons drawer. They are add-ons now, like Voice, Face, fine-tuning and quantization: a person installs and removes them, the drawer lists them only while they are installed, and they are packaged the same way. Move the pages, their components, hooks and helpers into addons/studio and addons/talk. Each has a manifest, a UI bundle, a release platform matrix and a catalog template, and is built by the compat mode of the shared builder. The core API stays where it is: the pages call it with the signed-in person's session. The history keeps its browser storage keys. The unit tests of the moved helpers move with them. In the core, drop the two pages, the Create group of the sidebar (Chat joins Home and Models in the old layout), the built-in catalog entries (the catalog is empty) and the talk locale namespace. The old addresses (/app/studio, /app/talk and the generator links) redirect to the add-on pages, with the model as ?model=. The Home /studio command is offered only while the Studio add-on is installed and opens it. The release workflow and its tests now list seven add-ons. Assisted-by: Claude Code:claude-sonnet-5-5 go node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): run the Studio and Talk specs against the add-on bundles The pages are add-ons now, so a spec that draws one installs the real built bundle first: the apps list names it and its module and style sheet are served from the build output, the way the add-on owner serves them. studio-addon.js holds that helper, and the Studio and Talk fixtures call it. Update the specs for the new addresses (/app/addons/studio/studio, the model as ?model=), the session permission the add-on route needs, the legacy redirects, the Home /studio command that appears only while the add-on is installed, and the sidebar without a Create group. Add specs for the add-ons themselves: every tab draws with no script error, the shell titles the page once, the old addresses say so when the add-on is not installed, a button of the page reaches a core page with the way back, and the drawer lists both only after they are installed. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): centre the icon of a built-in tile in its icon box The drawer drew the icon of a built-in tile (Studio's palette was one) in a bare 40px box. Only the placeholder box was laid out as a centred grid with the 20px size, so the real icon sat in the top-left corner at the size of the page text. Give the built-in tile the same classes as the placeholder box, and check in a browser that the icon is centred. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(addons): describe Studio and Talk as add-ons Replace the built-in list (now empty) with the Studio and Talk add-on sections, the new targets of the old links, the Home command, and the seven packaged add-ons. Describe the SDK additions the pages use: components, hooks, icons, openApp and portal. Say on the Studio page that it is installed from the drawer. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): click the shell title in the Studio slash-key spec The add-on shell titles the page, and the Studio overview no longer draws a second title, but the generator tabs keep theirs, so a bare h1 locator matched two elements. Click the first, which is the shell's. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): read the loopback port file through os.Root The persisted port file was read with a path built from the add-on root, which gosec reports as a file inclusion through a variable. Open it through os.Root so the name cannot leave the root, even by a symbolic link, and require a regular file with no group or other access. A looser file now fails closed instead of being trusted. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): expect Settings under the Operate hub, not the sidebar Settings moved out of the sidebar into the Operate hub. The navigation spec still expected a sidebar link. Assert that the sidebar has no Settings entry, and that the Operate hub opens Settings as a tab. Assisted-by: Claude Code:claude-sonnet-5-5 playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): drop Settings from the 404 places Settings is a tab of the Operate hub and not a destination of its own, so the 404 page does not list it. Assert that it is absent. Assisted-by: Claude Code:claude-sonnet-5-5 playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): drive the media history and TTS specs through the Studio add-on The image, video, sound and speech generators are pages of the Studio add-on now, and the old addresses redirect to it. Install the add-on with the Studio fixtures, open its pages and use its compose card, so the specs run against the page that exists. Assisted-by: Claude Code:claude-sonnet-5-5 playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): count the first-run steps, not every list item The recommended models are a list inside the second step, so counting all list items read 3 only while the models were still loading. Count the step elements, which does not depend on the load order. Assisted-by: Claude Code:claude-sonnet-5-5 playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): fail the node refresh by state, not by call count The spec served the list to the first request and failed the rest. The first request is not always the one from the page, so the page could get the failure first and show no list. Serve the list until the page shows it, then fail the refreshes. Assisted-by: Claude Code:claude-sonnet-5-5 playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): default to local-owner when auth is off A server without authentication has no accounts, so named-user left the add-on owner off. Resolve the unset mode in one place: local-owner when authentication is off, named-user when it is on. A flag or environment value still wins, and an unknown value is still refused. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): register an official add-on library by default Add gallery/addons/index.yaml, a YAML index of the four first-party add-ons (fine-tuning, quantization, voice, face) in the library format. The entries come from addonpkg catalog over locally built linux/amd64 packages, so the digests pin those images. On the first start, when the owner has no sources.json, register the raw GitHub URL of that index on master as the "official" library and write the file. The file is then the record: a library the admin removes stays removed, and an existing file is never changed. --addons-official-library=false skips the default. Assisted-by: Claude Code:claude-sonnet-5-5 go node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(addons): document the ownership default and the official library Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): list Studio and Talk in the first-party index Add the Studio and Talk entries to gallery/addons/index.yaml next to the four existing ones. They come from addonpkg catalog over locally built linux/amd64 packages, so the digests pin those images. Assisted-by: Claude Code:claude-sonnet-5-5 go node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): expect Studio and Talk in the official index The official index now lists six add-ons. Update the test that decodes it so it accepts Studio and Talk. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): add a real icon to each first-party add-on Each icon is a 256 by 256 PNG: a rounded square in one colour of the UI kit palette with a white outline glyph from the kit sprite. Fine-tuning uses sliders, quantization layers, voice waveform, face user, studio palette, talk phone and tables database. They were rendered with headless Chrome from the sprite paths. Assisted-by: Claude Code:claude-sonnet-5-5 chrome Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): ship a committed icon.png instead of the generated square The shared UI builder and the Tables builder copy ui/icon.png from the add-on source tree to the package unchanged when the file exists. They write the generated placeholder only when it does not. Add a fixture with an icon and a test for both cases. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): list the tables add-on in the first-party index Add the Tables entry to gallery/addons/index.yaml next to the six existing ones. It comes from addonpkg catalog over a locally built linux/amd64 package, so the digest pins that image. The official index test now expects seven add-ons. Assisted-by: Claude Code:claude-sonnet-5-5 go node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): make the Voice add-on one page with tabs The Voice add-on had four pages: recognition, library, create and speech, and the drawer listed two of them. It now has one page, "voice", and one drawer entry. Recognition, Library, Text to speech and From a recording are tabs of that page, drawn with the same tab strip the recognition page already used. The tab is the first path segment (voice/library), as in the Studio add-on, and the form that adds a voice is a view of the Library tab (voice/library/new). The old page names stay declared in the manifest. Each one redirects to its tab and keeps the query string, so saved links still open. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): send the old voice routes to the Voice tabs /app/voice, /app/voice-library and /app/voice-library/new now open the matching tab of the Voice add-on page while the add-on is installed and running, and the core pages otherwise. The moved-page helper compares the first segment of the target with the declared pages, so a target can name a tab. The docs and the navigation spec list the new addresses. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): cover the Voice tabs and the old page redirects The add-on unit tests render the tabs instead of the four pages. The Playwright spec checks the tab strip, the address of each tab, the missing Library tab for a member, and that the old page addresses of the add-on redirect to their tab with the query string kept. The migration table test follows the new targets. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(studio): keep the model menu inside a narrow screen The Home model picker sizes its menu from the viewport, and assumes the picker sits at the page gutter. In the Studio composer card the field is further in, so on a 390px screen the menu of the Sound expansion picker ended 7px past the right edge. Size the menu from the field it opens under instead. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): build the add-on bundles once for the whole run Each worker built the Studio and Talk bundles into the same dist directory, and the build empties that directory first. A worker that started, or restarted after a failure, removed the files another worker was serving, so a Studio spec saw "the style sheet of the add-on could not be loaded". Build once in a global setup before the workers start. A run without the setup still builds per worker. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): register the worker when the add-node spec says so The mocked roster added the worker on the third read of /api/nodes. The shell and the page both read it, and which one asks first depends on load, so the page could take the new worker as part of the roster it opened with and never show it as found. The worker now joins after the page shows it is listening. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): hold the gallery answer until the skeleton is seen The spec delayed the answer by a fixed 800 ms and then looked for the skeleton. The assertion polls at growing intervals, so on a slow machine it could step over that window. The answer now waits until the skeleton has been seen, and the spec then checks that the skeleton goes away. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): give the PDF tag the time the other PDF specs have pdf.js and its worker load on first use. The other specs in this file wait up to ten seconds for the result of that load. The first one waited five, and missed the tag on a busy machine. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): build the Face and Fine-tuning bundles in the global setup The navigation spec serves the built Fine-tuning bundle, but nothing built it, so two specs failed on a fresh checkout when the dist folder was missing. The spec also built the Face bundle in a hook that every worker runs, and a build rewrites the folder the other workers serve. The global setup now builds Studio, Talk, Face and Fine-tuning once, before the workers start. The spec builds the two bundles itself only when it runs without the global setup. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): fix the Home answers in the stale pin spec The spec asserts that the page shows no alert. The Home page raises its own alert when the model list fails or a staging operation has failed, and both answers come from the shared test server. A slow reply or a failed install left there counted as an alert about the pin. The spec now fixes both answers, so the alert count tells only about the pin. The assertions are unchanged. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): read the server address in the add-node spec from the page The spec expected the register address to carry port 8089, so it failed on any other port. It now expects the origin the page was opened from. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): give the navigation routes spec its own catalog No page is built in today, so the catalog is empty and the three specs that expect the studio and talk entries failed on the list and on the pin seed. They passed only while Studio and Talk were built in. The spec now sets a catalog of two ungated entries for each example and restores the old one afterwards. The assertions are unchanged. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(cli): report a failed write of the MLX hostfile The tunnel callback ignored the error of os.WriteFile and set the hostfile variable even when the file was not written. It now logs the error and leaves the variable alone. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): discard the close error of the held listener errcheck flags the deferred Close of the listener that keeps the port busy. The test has no use for that error, so the defer drops it explicitly. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): move the add-on tool tests to Ginkgo The fine-tuning, quantization and add-on host library tests used t.Errorf, t.Fatal and t.Run, which the forbidigo rule rejects. They now use Describe, It and Gomega, with a suite bootstrap in each package. The cases and the assertions are the same. The tables in the host client and the tool schema tests keep one entry per case. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): move the host operation tests to Ginkgo The hostops tests used the testing package calls that forbidigo rejects. They now run as Ginkgo specs under one suite bootstrap. Every check and every per-operation case is kept. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): move the pin store tests to Ginkgo The pin service, file store, sweep and shared store contract tests used the testing package calls that forbidigo rejects. The store contract is now a function that registers its specs for each store, so the file store, the memory-only store and the database store run the same cases. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): move the remaining add-on tests to Ginkgo The built-in catalog, tool arguments, loopback carrier, host operation contract, packaging, application, config, CLI and endpoint tests used the testing package calls that forbidigo rejects. They now run as Ginkgo specs. The endpoint tests also drop the unchecked Close errors that errcheck reports when the auth tag is on. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): wait for the targeted Reinstall button before the install click The "generic deletion blocks targeted install at confirmation" spec dispatches a popstate to set the target node, then clicks Reinstall with a script click. The click can run before the targeted page has rendered or while the button is still disabled, so no install request is sent and the poll on the install count times out. It failed 1 run in 10 with two workers and on every retry in CI. The spec now waits for the button to be enabled first. The assertions are unchanged. Assisted-by: Claude Code:claude-sonnet-5-5 playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore(addons): refresh the voice package digest in the first-party index The Voice add-on became one page with tabs and got a new icon, so the digest in gallery/addons/index.yaml no longer matched the build. Render the entry again with addonpkg catalog over a locally built linux/amd64 package; the main page is now voice. The other entries are unchanged. Assisted-by: Claude Code:claude-sonnet-5-5 go node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): wait for the target node's Reinstall button after the popstate The "generic deletion blocks targeted install at confirmation" spec dispatches a popstate to set the target node, then waits for a button named Reinstall and clicks it. Until React renders the targeted page, the page still draws the generic Reinstall button. It is enabled and has the same name, so the wait passed on it, the click started a global install, and no node install request was sent. The poll on the install count timed out with the confirm dialog still open. With the CPU throttled to 8x, the spec failed on 20 of 20 runs, and the button it clicked had the title "Reinstall". The spec now waits for the button titled "Reinstall on <node>". With the throttle it passes 30 of 30 runs. The assertions are unchanged. Assisted-by: Claude Code:claude-sonnet-5-5 playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): give the Studio hand-off spec a budget for the cold bundle load The "a result made through a hand-off records which result it came from" spec opens the Studio add-on straight from an address, twice. Each is a cold load: the page shows "Loading" until the add-on module has run, and the next assertion had the default five seconds. The load takes about two seconds on an idle run and seven to ten under CPU load, so the spec failed on a loaded machine with the hand-off note not yet drawn. With the CPU throttled to 6x and eight busy loops running, it failed on 21 of 30 runs. The first assertion after each load now waits up to 20 seconds, and the spec then passed 30 of 30 under the same load. The assertions are unchanged. Assisted-by: Claude Code:claude-sonnet-5-5 playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): carry the identity page styles in the Face add-on The Face page is drawn with the shared identity views, but its style sheet held only the clip and error rules. The page, card, model list, known-faces table, enrol sheet and distance scale classes had no rules. They looked right only when the host had already loaded the style sheet of its own Faces page, which a visit through the add-on drawer does not do. Opened that way, the cards had no gap, the model list showed bullets, and the install buttons ran into the names. The Face sheet now holds the same identity rules as the core sheet, so the page looks the same whichever way it is opened. Assisted-by: Claude Code:claude-sonnet-5-5 playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): scope the narrow-screen identity rules of the Voice add-on The rules for narrow screens (single-column pairs, smaller tool padding, compact rank rows) were copied into the Voice style sheet without their media query, so they applied at every width. They now sit in a max-width 720px query, and the reduced-motion rule in its own query, as in the core sheet. Assisted-by: Claude Code:claude-sonnet-5-5 playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): drop the repeated title from the Voice and Face pages The add-on shell already titles the page, so the recognition pages showed the name twice: "Voice" above "Voices", "Face" above "Faces". Keep the supporting line and the model picker, as the Studio page does, and remove the unused title strings. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): replace the Pinned sidebar section with an Apps entry The sidebar no longer lists pinned add-ons. It shows one Apps entry that opens the add-ons drawer, with a Show all link below it that opens the same drawer. Pins are used from the drawer, where a pinned tile keeps its pin and move buttons. The server pins API is unchanged. Remove the PinnedNav component and its locale title, rename the items.addons label to Apps, and update the e2e specs and the add-ons documentation to match. Assisted-by: Claude Code:claude-sonnet-5-5 playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): assert the single shell title on the Voice and Face pages The add-on shell now shows the one title, "Voice" or "Face", and the page no longer repeats it. The tests still looked for the h1 "Voices" and "Faces". Assert that the add-on page has exactly one h1 and that it is the shell title. The core pages keep their own "Voices" and "Faces" titles. Assisted-by: Claude Code:claude-sonnet-5-5 playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): drop the repeated title from the Voice Library and Text to speech pages The add-on shell already titles the page "Voice", so these two tabs showed a second heading below it ("Speech voices", "Speak text"). Keep the supporting line, as the Studio and recognition pages do, and remove the unused title strings. The e2e spec now checks that each tab has exactly one h1, the shell title. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): list pinned apps under Apps in the sidebar A pinned app now shows as an indented child of the Apps entry, with its icon and title, in pin order. Show all stays the last child. The pins come from GET /api/addons/pins through the navigation context, and the apps from the add-on registry, which the drawer feeds on every list read, so the links follow the drawer. A pin without an installed app is not drawn, nothing extra shows when no app is pinned, and the children are hidden when the sidebar is collapsed to the rail. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(addons): say that pinned apps are listed under Apps in the sidebar The sidebar lists the pinned apps between Apps and Show all, in pin order. The pins section and the navigation section said that pins do not appear in the sidebar. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): remove the Show all link under Apps in the sidebar The link repeated the Apps entry, which opens the same drawer, and looked redundant when no app was pinned. Apps is now the only add-on entry; pinned apps still list under it in pin order. The link, its locale key and the style comment are gone, and the e2e spec and the add-ons docs now say that Apps is the only entry. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): show a first-run card on Apps when nothing is installed An administrator who opens Apps with no add-on installed now sees a short card with three steps: choose a library, browse and install, start the add-on. Its button opens the Browse tab. The card is hidden once an add-on is installed and for anyone who cannot install. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): hide the pinned apps in the icon rail When the window is narrower than 1024 px the sidebar shrinks to the icon rail by CSS alone, without the collapsed class. The pinned app links were still drawn there, with the indent meant for the full sidebar, so their icons sat to the right of the rail and were cut off. Hide the pinned app links in both rail states. The Apps entry keeps its centred icon. The e2e spec checks that the rail shows no pinned app and that every icon is centred at 1280 px collapsed and at 1000 px. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): stop the backend redirect test racing the installed list The test reads the redirected URL right after the path changes. The Backends page then loads the installed list and drops a selected backend that is not installed, so the backend parameter was sometimes already gone when the test read it. The mock server has no llama-cpp, and about 15 of 40 repeated runs failed on the backend assertion. The redirect itself was correct, including show_all. Serve the selected backend in the installed list, so the page keeps the selection and the assertions stay as strict as before. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): accept a same-host Referer or Origin for human confirmation Browsers send no Sec-Fetch-* headers over plain HTTP on an address that is not localhost, so creating a resource from a LAN address failed with "Creating a resource requires your own browser session". When Sec-Fetch-Site is present it must still be same-origin with an empty destination, as before. When it is absent, Origin or, failing that, Referer must name the request host and port. A destination or mode that is present must still be the expected one. This matches the rule in BrowserProtection. The credential, token cookie, API key and tool-context checks are unchanged. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): keep hub entries off the Apps route in the sidebar The Build hub still owned /app/addons through its legacy Add-ons tab, so the sidebar lit both Apps and Build on the Apps page. A tab whose gate is closed now owns no route when the sidebar asks, so only the Apps entry is active there. The hubs keep their own routes, and the legacy navigation still gives /app/addons to the Build hub. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): label the pin control on app tiles and in the detail view The pin was a small icon-only button and hard to find. Each app tile now shows a button reading Pin, or Pinned with a filled pin, and the detail view has the same control in its header. Both toggle the same stored pin. The Apps grid lists pinned apps first, in pin order, then the others. The sidebar list and the server pins API are unchanged. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): check the Voice From a recording tab has one title The Library and Text to speech tabs already assert that the add-on shell title is the only h1. Do the same for the From a recording tab, so a heading added there later fails the navigation spec. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): stop the add-on shell drawing a title over add-on pages The app decides what its page shows. The shell drew the add-on name as a header above every add-on page, so an add-on could not choose its own title. The shell now draws no header on a live, loading or frozen add-on page; each add-on titles its own pages. The unavailable and missing-page states keep the name, because no add-on page is drawn there. The runtime and shell specs now check that the shell adds no page title. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): let each add-on draw its own page title The shell no longer titles add-on pages, so each add-on draws one h1 per page. Voice titles its tabs "Voices" (Recognition and From a recording), "Speech voices" (Library) and "Speak text" (Text to speech); "Add a speech voice" already had its own. Face draws "Faces", Studio "Studio" and Talk "Talk". Tables promotes the database list and database name from h2 to h1. Fine-tuning and Quantization already drew a title. The add-on unit tests and the navigation and Studio specs now assert exactly one h1 per page, with the page's own title. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): add a kind to library entries and read unknown kinds tolerantly A library entry now has an optional kind: addon (the default when it is absent), backend or model. Every index written before kinds existed stays valid and its entries are add-ons. A reader keeps the entries of the kinds it knows and skips the others without reading their fields, so a newer kind never makes an older host refuse the whole index. The refresh result and the library state report how many entries were skipped and of which kinds, and the refresh logs a warning. A kind that is not a plain kind name still makes the index malformed. Search lists add-ons unless the caller asks for a kind, and Resolve returns add-ons only, so an add-on install cannot be pointed at a backend image. A library policy may name the signer with an anchored identityRegex instead of an exact identity, so builds from master and from tags both verify. An add-on install refuses a policy that carries one. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(backends): install a backend from a library entry A library can now list backends. An entry of kind backend names the image of the backend gallery by digest, and POST /backends/library/install installs it on the local node. The gallery stays the source of truth and its install path is the one used: the library resolves the entry, takes the signature policy from the library, pins the image by digest and calls the same install as the gallery. Installing a name that came from a gallery, another library or by hand is refused and changes nothing. The signature policy of a backend library names the signer with an anchored identityRegex, so images signed by the merge workflow on master and on tags both verify. A pattern that is not anchored is refused. An install is bounded by a size limit per kind: add-ons keep 512 MiB and a backend image defaults to 8 GiB, set with --addons-backend-max-bytes. The limit reads the manifest after the signature check and before any layer is fetched. The metadata of an installed backend records the library it came from. scripts/backends/cmd/backendcatalog renders entries from backend/index.yaml and pins them against the registry. The pilot is the CPU build of local-store, in gallery/backends/index.yaml. The library is registered on the first start only with --addons-backend-library, which is off by default. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): show the kind of library entries and install backends from them The Browse tab lists the entries of every kind this host reads and marks each one as an add-on or a backend. A backend installs through the backend route and not through the add-on lifecycle: the confirmation says that the backend gallery is not changed and that a backend runs native code, and a refusal of the host, such as a name installed from another origin, is shown with its own reason. The details give the kind, the alias and the signer pattern. The library editor can name the signer with a pattern. Without this a save would drop the pattern of a backend library. The Libraries tab reports the entries a refresh skipped because their kind is not read. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(addons): describe backends in the library Document the entry kinds, how a reader skips a kind it does not know, the backend entry format, the signer identity pattern, the install route, the size limit of each kind, the pilot library and the command that renders it. The backend page links to it. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(mcp): install a backend from a library entry The backend library route installs a backend from an add-on library entry, and the admin REST surface and the MCP catalog must stay in step. Add the install_library_backend tool with an in-process and an HTTP client, the route map entry, the safety-rule listing and a skill recipe. Listing library entries stays on the add-on routes, which the assistant does not expose, so no list tool is added. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): carry the add-on library keys in every locale The add-on library and backend library strings existed only in English, so the other locales had no value for them. Add the missing keys of the addons namespace to de, es, id, it, ko, pt-BR and zh-CN. The values are the English text until a translation is contributed. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(backends): report the steps of a library install as a job A backend install from a library ran inside the request and showed nothing until it ended. It is now a job of the gallery service, the kind the backend gallery installs use. The request answers with the job ID and GET /backends/jobs/{id} reports the step as the phase of the job: resolving, verifying, size, fetching and writing, with the byte progress of the fetch. A job that fails keeps the phase of the step that failed and gives the reason. A second install of the same name is refused while one runs. The downloader and the gallery install report their stages through an optional hook, so the steps are the real ones and not a guess. The Browse tab lists the steps of the entry being installed, marks the one that failed, and omits the signature step for a library that does not require one. The library installer also gets Updates and a route that lists the installed library backends with a newer build in their library, for the next change. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): mark library backends and offer their updates on the Backends page A backend installed from a library now shows the library ID next to its name and, in the detail, the library and the digest that metadata.json pinned. Gallery backends look and behave as before, and the gallery reinstall is not offered for a library backend, because the gallery does not list it. When the cached index of the library lists another build in the channel of the installed version, the row says "Update available" and offers an Update button. The button starts the same install as the Browse tab, and the detail shows its steps. Nothing is upgraded on its own. The check says nothing when the library no longer lists the installed version and has several channels, instead of guessing one. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(mcp): match the library installer to the progress type The MCP install of a library backend was written against the earlier Install signature. The job-based install now takes a Progress value, so the application did not build. The MCP interface drops its unused status callback and the application adapts the installer to it. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): add the model library entry shape and its reader A model library entry is the shared library envelope (kind, id, name, version, description, license, urls, tags, icon, channel, policy, schemaVersion) plus a model body: url, backend, size, files, configFile, overrides, variants and promptTemplates. The envelope lives in the catalog package so the other kinds can move to it. A gallery entry converts to a library entry and back to the same gallery entry. Floats are written so they read back as floats, and an empty variants list stays distinct from an absent one, because both occur in the real index. The reader is strict: unknown fields, duplicate ids, a second document and a flow-style list are refused. An entry of another kind is skipped and reported, as in the add-on reader. The gallery index stays the default and is not changed. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): render the model library from the model gallery scripts/models/cmd/modelcatalog reads gallery/index.yaml with the same structs the host reads it with, converts every entry to a library entry and back, and fails if any entry does not come back equal. It writes gallery/models/index.yaml and the golden files for the tests. The output is deterministic, and the gallery is only read. The golden files are a digest pair for every entry (the gallery entry and the library entry made from it) and a readable gallery and library pair for each kind of entry: plain, with variants, with an empty variants list, with a config file, with a size, with a backend, with floats in the overrides, without a url, without files and with a multiline description. All 2212 entries of the index round trip. The gallery struct does not read a few keys (last_checked, parameters and four stray capitalised keys); the host drops them when it loads the index, so the library does not carry them, and the command reports them. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(gallery): add --model-index-source to read the model library --model-index-source selects the file the host reads the models of the official gallery from: gallery (the default) or library. With library the gallery named localai reads the model library, from --model-library-url or the file in the repository, and keeps its configured name and URL on every entry. A gallery the owner added keeps its own index, and add-on and backend galleries never take this path. A library that cannot be fetched or read is an error and not a fall back to the gallery, so the setting is never silent. The tests round trip every entry of the real gallery index, compare it with the golden files, check the rendered library file reads and agrees with itself, and cover the strict reader, the kinds it skips, prompt templates, number types, empty lists and the setting. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(backends): upgrade library backends with the auto-upgrade switch The upgrade checker now installs the newer build of a library backend when auto_upgrade_backends is on, on the same check that upgrades the gallery backends. With the switch off nothing is installed and the Update button stays manual. The switch is read on every check, so a change in the runtime settings applies at the next one. There is no second toggle. The tests cover both states and a host without a library. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(cli): add local-ai library to manage library sources local-ai library has list, add, remove, enable and disable. It works on the source store the server reads (addons/sources.json under the data path), so it also works with the server stopped. A source has a kind (addon, backend or model); the store records whether it is a shipped default, and removing a default leaves a tombstone so it does not come back at the next start. The shipped model and backend gallery indexes are default sources next to the official add-on library. Defaults are added at every start when they have no record yet, and an existing file that holds no default is left alone as before. The --model-index-source and --model-library-url flags are removed: a model library is added as a model source, and the gallery entry says it is a library. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): let a source of your own win an entry over a default When two enabled sources list the same entry (same kind and id), the source the user added wins over a shipped default, and the default is ignored for that entry. When two sources the user added list it, search and resolve are refused with an error that names the entry and both sources, and nothing is picked silently. Among shipped defaults nothing changes. The tests cover a user source over a default, a tie between two user sources, a default ignored once a user source wins, and the case where disabling a source ends the conflict. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(cli): map the gallery flags to library sources at startup --galleries and --backend-galleries keep loading the same JSON. At startup each entry they name also becomes a source the user owns, of kind model or backend, so local-ai library list shows it. The mapping is additive: an entry whose kind and URL is already a source (a shipped default, one added with the CLI, or one mapped at an earlier start) is not added twice, and a derived id never collides with another source. The sources then decide which galleries the server reads: a gallery whose source is disabled or removed is left out, and a model library or gallery added with local-ai library add is added. A list set by the flags still replaces the shipped defaults. If the store cannot be opened, the galleries stay as the flags set them. The autoload switches are not touched. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(library): use id for every kind and seed the default libraries The backend entries of the library used addonId while the model entries use id. Release now has the field ID, written as id for every kind. ReadIndex reads addonId as a legacy alias for one release, for add-ons and backends, and refuses an entry that holds both names. The backend index and the catalog generator write id. The shipped libraries are default sources. The pilot backend library is now on by default, like the official add-on library, and the model library is a new default model source, official-models. The server reads it next to the localai gallery. A source the user removed stays removed, and a source the user adds is added to the defaults. The model catalog header held a backtick that ended its raw string, so the generator did not build. The header no longer has it. Assisted-by: Claude Code:claude-sonnet-5-5 go node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(library): choose the default model source by build A build read both the localai gallery and the official-models library by default, so every model was listed twice. Now a build reads one of them. library.ModelSource names it: "library" or "gallery". The model source that is not chosen is still shipped, as a disabled default, and local-ai library enable turns it on. The value is a Go variable set at link time with -ldflags, because the same source tree makes both kinds of build and a constant would need a commit to change. It is "library" in the source, so a build from master, go build and go test read the library. Only the exact value "gallery" selects the gallery, so a mistyped value cannot leave a build without a model source. The Makefile sets it from LOCALAI_MODEL_SOURCE. When that is not set, a commit that carries a tag gets "gallery" and any other commit gets "library". .goreleaser.yaml passes the same variable and defaults to "gallery". The release workflow sets it to "library" on a tag, which is the cutover. A default source is seeded once, so a data directory that an earlier build seeded keeps its enabled flags. Switch it with local-ai library enable and disable. Tests assert the master default, the tagged default before the cutover and after it, and that a mistyped value reads the library. The model library header now says that CI checks it and when a build reads it. Assisted-by: Claude Code:claude-sonnet-5-5 go node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * ci(library): check the model library and cut over on tag No workflow checked gallery/models/index.yaml, so it could go stale when gallery/index.yaml changed. A new workflow, model-library.yml, runs on pull requests and on master when the gallery, the library, the generator or core/gallery change. It runs the generator tests and the library shape tests (the file decodes, it has the library shape, every real entry round trips), then renders the library again from gallery/index.yaml and fails on any difference with git diff --exit-code. The error message gives the command that fixes it. release.yaml gets the cutover. On a tag, a step sets LOCALAI_MODEL_SOURCE=library before GoReleaser runs, and .goreleaser.yaml links it into the binary. Without that step a tagged build reads the gallery. The step is the one place to remove when the gallery is retired. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(library): make the model library the source of the gallery The library, gallery/models/index.yaml, was rendered from the gallery. Now it is the source and models are edited there. modelcatalog reads the library and writes gallery/index.yaml, a generated copy that stays only so releases before the cutover keep working. The gallery file is written in the shape the host reads: the same field order, floats such as 1.0 keep their form, and an empty variants list stays apart from an absent one. The command converts every library entry to a gallery entry and back, loads its own output the way the host does, and fails when an entry comes back different. The gallery copy drops what the library cannot carry: last_checked, parameters and four capitalised keys (Base Model, Context Length, License, Training Data). The gallery struct has no field for any of them, so the host ignored them on load. Every run lists these keys and how often the file being replaced still had each. Loading the old and the new gallery with the host structs gives equal entries for all 2212 models. The tests that read the library and the golden files follow in the next commit. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * ci(library): check the library and that the gallery is its copy The model-library workflow assumed the gallery was the source. It now checks the library file, the new source. The library decodes, every entry has the library shape, every entry converts to a gallery entry and back, and the gallery reader loads the generated gallery and sees the same names, file uris and digests. The workflow then generates gallery/index.yaml again and fails on any diff with git diff --exit-code. The message says the file is generated, not to edit it by hand, and gives the command that regenerates it. The tests in core/gallery read the library file now. The round trip runs library to gallery and back on all 2212 real entries, a new test compares the checked-in gallery with the generated copy so go test finds a hand edit too, and the golden digests and samples are written from the library. The version of an entry is no longer compared with a digest of its content, since an edit to the library does not change it. The docs say the library is the source, the gallery file is a generated copy for releases before the cutover, and edits go to the library. The agent guide for adding models says the same. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(library): add admin routes for library sources and an official libraries switch The library sources of the add-on, backend and model kinds had a CLI but no HTTP routes. Add them for administrators, on the same store and the same functions as `local-ai library`, so the two cannot drift: GET /api/library/sources POST /api/library/sources POST /api/library/sources/{id}/enable POST /api/library/sources/{id}/disable DELETE /api/library/sources/{id} The list carries kind, default, removed (a tombstone), suspended and the conflict message of a source that clashes with another one. A change to a model or backend source applies to the running server. A deleted source drops its gallery by address; a removed default keeps its record. Add the runtime setting official_libraries (default on), with the flag --official-libraries. When it is off, every shipped default source of the three kinds is suspended: it is not read, stays in the store marked default, and comes back when the setting is on again. Sources the user added are not affected. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): list library sources of all three kinds in the Libraries tab The Libraries tab of the Add-ons page now lists the sources of the add-on, backend and model kinds from the new library routes. It has a kind filter with counts, a Default badge, the state of each source (enabled, disabled, removed, or off while the official libraries are off), turn on and off, a Restore button for a removed default, and the conflict message next to a source that clashes. Adding a source takes a kind and, for backends and models, an index format. Settings gets the toggle "Use official libraries" for the official_libraries setting. Add unit tests for the list and the kind filter, and a Playwright spec that runs on a fresh test server started with --library-sources (its own config, port 8097): it lists the three kinds, adds a model source, disables and removes a default, checks the conflict message and the official libraries toggle. Assisted-by: Claude Code:claude-sonnet-5-5 javascript Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(library): extend the backend body to carry what the gallery carries The backend library entry only held an image and its digest. The backend gallery holds more that matters at install and in the UI, so the library could not replace it. Add the missing fields to the backend kind and keep the strict reader. New fields, all refused on an add-on entry: - mirrors: ordered fallback images of the same backend, each oci://. The install pins them with the digest of the artifact. - capabilities: a map from a host capability to a backend id. An entry with capabilities and no artifact is a meta backend. It has no digest, os or arch, is listed for every platform, and installing it installs the backend its capability names, in the same library and channel. - license, urls, tags: display metadata, bounded and checked as text. - icon: an https URL. The add-on ui.icon stays a path in the package. The host never fetches the icon. - mutable: the artifact is a moving tag, such as a development image. The entry has no digest. The install resolves the tag to a digest, verifies that image and records the digest it installed. - schemaVersion: the envelope version. A higher one is refused. The index bounds grow to 8 MiB and 4000 entries, because the backend library lists every image for every platform. Release now holds lists, so the add-on staging compares it with Equal instead of ==. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(library): render the backend library from the whole backend gallery Add the conversion between a gallery backend and its library entries in core/gallery, and make scripts/backends/cmd/backendcatalog build the library from the gallery and the gallery from the library. The library now holds all 1093 gallery entries as 1088 backends, because five names appear twice in the gallery with the same content and are folded. That is 934 concrete backends and 154 meta backends, written as 1240 entries: a backend built for several platforms has one entry for each, with the digest of that platform image. - 539 release entries are pinned by the digest the registry serves for the platform. - 543 development entries (master-* tags) keep their tag and are mutable. The install pins them. - 4 release entries are not pinned, because the registry has no image for them: cpu-qdrant-store, metal-qdrant-store, cpu-gemxcpp and vulkan-gemxcpp. They are marked mutable and no digest is invented. Render them again with --repin once the images exist. The generator can import a gallery once, pin the release images again, and write the gallery file from the library without the network. Before it writes it converts every backend to the library and back and fails if one does not come back equal. A policy is no longer written on each entry, because the host takes the policy of the library source. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(library): check that every backend gallery entry round trips Prove that the backend library can replace the backend gallery. The golden input is the real backend gallery from before the library became the source: 1093 entries, kept under core/gallery/testdata/backend_library with the pins the library holds and the digest of each generated entry. TestBackendLibraryRoundTripsEveryRealGalleryEntry converts each entry to library entries, writes them as an index, reads them as a host does, validates them and converts them back. It asserts that all 1093 entries come back equal on every field the gallery reader uses, and logs the count. The five names that appear twice in the gallery have the same content, so the twin is checked against the first one and folded. The other tests check the library file in the repository: every entry passes the host checks, every backend round trips, every meta backend names concrete backends the library has, a development entry keeps its master-* tag and has no digest, and the golden digests still match. Unit tests cover each field, the fields the library cannot carry, which are refused rather than dropped, and entries of one backend that disagree. An entry with no artifact, digest, os or arch no longer writes them as empty strings, so a meta backend and a mutable entry stay short. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(library): make the backend library the source of the backend gallery Flip the source the way the model library did. The backend library, gallery/backends/index.yaml, is where backends are edited. The backend gallery, backend/index.yaml, is now generated from it by scripts/backends/cmd/backendcatalog and stays only so releases before the cutover keep working. It no longer has anchors or hand-written order, and it holds 1088 backends: the five names that were listed twice with the same content are folded. Which source a build reads is set at build time, like the model source: - core/library.BackendSource is "library" or "gallery". Only the exact value "gallery" selects the gallery, so a mistyped value cannot leave a build with no backend source. - A build from master reads the library. The Makefile reads the gallery for a commit that carries a tag (LOCALAI_BACKEND_SOURCE), and GoReleaser defaults to the gallery. - A step in release.yaml sets LOCALAI_BACKEND_SOURCE=library, so a tagged build cuts over by itself. - Both sources are shipped and one is enabled. With --addons-backend-library=false every build reads the gallery. The server hands the shipped backend library to the gallery reader as a library, so the Backends page lists it and installs from it the way it did from the gallery. A backend library the user adds stays with the add-on catalog. The shipped library is now a backend source, not an add-on one. The new workflow backend-library.yml runs the tests, regenerates backend/index.yaml and fails on a diff. It never reads the registry. backendcatalog --add merges backends written in the gallery shape into the library, so adding a backend does not mean writing platform entries by hand; the agent guide says so. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(library): describe the backend library as the source of the backends Rewrite the backends and add-ons pages for the backend library. The library file is the source of truth and the backend gallery is a generated copy that is never edited by hand. - backends.md: the library and the generated gallery, which source a build reads (master, tagged before the cutover, tagged after it), the --addons-backend-library switch, how to add a backend and generate the gallery, and what CI checks. - addons.md: the full backend entry with the new fields (mirrors, capabilities, license, urls, tags, icon URL, mutable, schemaVersion), meta backends, development images pinned at install, and the backend library section in place of the pilot one. - compatibility-table.md: point to the library as the source. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(library): mirrors on library sources, editable through the API Library sources carry an optional list of mirrors. The mirrors go into the gallery entries the server reads, and a source can be edited with PUT /api/library/sources/:id. The mirror forms accepted are http(s), github:, oci:// and file://, at most eight per source. Sources saved before this change load without mirrors. Assisted-by: Claude:claude-haiku-5-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): library chips on the model and backend pages The model and backend pages manage their own library sources. Filter chips above the list narrow it to one library, and a panel adds, edits, turns on or off, refreshes and removes sources of that kind. The add-on Libraries tab lists add-on sources only. Settings shows the model and backend galleries as a read-only view of the library sources, with their mirrors. Mirrors are edited in the panel. Assisted-by: Claude:claude-haiku-5-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): show the library chips when a kind has one library The chips were hidden until a page had two libraries, so a single library had no visible filter. The row now shows whenever a page has a library. Assisted-by: Claude:claude-haiku-5-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(gallery): state the mirror rule and the cutover for the old index The library no longer has no mirrors: a source can list them, so the note now says that the shipped official-models library has none. The mirror section says that clearing a source's mirrors waits for a restart. The cutover section says the old gallery file is removed in the cutover release. Assisted-by: Claude:claude-haiku-5-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(gosec): tighten the modes the catalog generators write The generators wrote generated files and sample folders with 0644 and 0755. The generated index copies are tracked by git, which keeps only the executable bit, so the tracked files do not change. Local copies get 0600 and 0750. The library path read in mergeLibrary is the operator's own argument, so that read is annotated. Assisted-by: Claude:claude-haiku-5-5 [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> |
||
|
|
7b9ef6a0f8 |
feat(backends): --disable-backend-install disables backend downloads. (#12754)
feat(backends): --disable-backend-install refuses every backend acquisition A hardening switch for deployments that must run exactly the backend set they were tested with (production, air-gapped, immutable images, package-managed hosts). With LOCALAI_DISABLE_BACKEND_INSTALL=1 every install and upgrade path refuses — gallery, OCI/URL/path, node-scoped, auto-upgrade, boot-time external backends — HTTP routes answer 403 up front, and a model whose backend is missing is refused before any download: 409 on the API, an error on preload and import. Whether a backend is present is asked of the execution target: the local backend directory on a single node, the workers' listings on a distributed frontend, which installs nothing itself. Backends already present, including system-path ones, keep working. Assisted-by: Claude Code:claude-fable-5 Signed-off-by: Plamen K. Kosseff <p.kosseff@gmail.com> |
||
|
|
e45dc5e9f7 |
feat(addons): installable add-ons foundation and the Tables add-on (#12702)
* feat(addons): add the add-on runtime, navigation and first-party add-ons Add the add-on runtime to LocalAI: manifests, the host operation broker, admin gating by account role, the add-on UI bundle builder and the SDK for add-on pages. Move the navigation to Chat, Models, Add-ons and Settings, with pinned add-ons in the sidebar and an Add-ons drawer. Ship the first-party add-ons (voice, face, fine-tuning, quantization) as packages for linux/amd64, linux/arm64 and darwin/arm64. Intel macOS (darwin/amd64) is enabled in the platform files. Squashed from the review branch. The tree is unchanged; the pre-squash history is kept on a local backup branch. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(gosec): tighten file modes and annotate false positives Write job state, configs and imported models with mode 0600, clean the paths that are read from disk, and mark the route tables, the header name and the retry jitter as reviewed false positives. Assisted-by: Claude Code:claude-sonnet-5-5 gosec Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): keep API keys off recovery mutations and same-site cookies POST /api/addons/dispatch forwarded the recovery and update mutations to the owner for any administrator key, which skipped the browser-session rule of their own routes. Refuse those actions there. Also apply the same-origin rule to the legacy token cookie, not only the session cookie, because the global CSRF check lets same-site requests through. Do not fail the start of LocalAI when an add-on library cannot be refreshed; log it and keep the installed add-ons up. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): refuse a reinstall that would migrate retained data unbacked An upgrade takes a verified backup before the new package migrates the data. A reinstall over retained data has no running generation to stop, so the new package would migrate it without one. Refuse a reinstall whose data format differs from the retained one. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): release the manager lock while a release is fetched Submit held the state lock across the catalog lookup and the package download, so the apps list and status reads stalled for the whole install. Serialize Submit and Reconcile with their own lock and drop the state lock around the fetch only. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): kill an add-on only after three missed health probes One slow 100 ms probe killed a busy add-on and dropped the work it was serving. Require three misses in a row. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): move settings under the operate hub The Operate hub already has Settings as a tab, so the sidebar no longer shows a separate Settings entry. The /app/settings route stays, and the hub tab still reaches it. The navigation test now expects Chat, Models and Add-ons as the primary items. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): accept same-host browser requests that carry no fetch metadata Browsers do not send Sec-Fetch-* headers over plain HTTP on an address that is not localhost, so every add-on request from a LAN address was refused as cross-site. Keep refusing a Sec-Fetch-Site value other than same-origin. When the header is absent, accept the request only if its Origin, or failing that its Referer, names the same host and port as the request. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): show the server's reason when an add-on request is refused A 403 or 400 answer showed a generic message and hid the reason in the response body, so a refusal by the browser guard could not be told from a missing permission. Show error.message from the body as the server sent it, and keep the generic text when the body has none. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): run the add-on owner in the normal binary The owner was only enabled when an embedding host set the add-on config, so a normal run answered every add-on request as unavailable. Add --addons-enabled (default true) and --addons-ownership-mode (default named-user, or local-owner; anything else stops startup). The node is its own owner: state lives under the data path, and the carrier uses loopback keys made at each start and kept in memory. --addons-enabled=false turns the owner off completely. Named-user mode needs the account database. With authentication off the owner stays off and a warning says how to use local-owner. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): keep the loopback owner address stable across restarts The single-node loopback transport took a new ephemeral port at each start. The owner stores its address in its identity file and refuses to start when the address differs, so every start after the first failed with "invalid addon owner configuration". Choose the port once, keep it in the add-on state root (loopback-port, mode 0600) and reuse it. If the port is taken at a later start, stop with an error that names the port and --addons-enabled=false, because another port would break the identity check. The keys and certificate stay in memory. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): let add-on pages keep the host modules they were written against A page written for the web UI imports React, the router, i18next and a few shared components. The add-on builder refuses a bundle that holds a host file, so moving such a page into an add-on meant rewriting it. Add a compat mode to the shared builder. It answers those imports from a directory of stand-ins that read the SDK, bundles the dynamic import of the page code into the one output file, and collects the style sheets the page imports. A relative import that is not in the add-on's own source resolves to the file at the same path under the compat directory. Widen the SDK for these pages, without a version change: more host components (spinner, modal, lightbox, model picker) and hooks (models, resources, voice profiles, operations, MCP client), a style on icons, the icon names the pages draw, sdk.navigation.openApp to open a page of the app (only addresses under /app, and the way back as the only state) and sdk.portal. The shell passes the query string to the page. The stand-ins cover the router (the tab in the path, the model in the query), the translator over the host locale files, the core API calls through sdk.core.fetch, and byte copies of the pure helpers, with tests that keep the copies equal to the originals. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): ship Studio and Talk as add-ons instead of built-in pages Studio and Talk were compiled into the web bundle and always listed in the Add-ons drawer. They are add-ons now, like Voice, Face, fine-tuning and quantization: a person installs and removes them, the drawer lists them only while they are installed, and they are packaged the same way. Move the pages, their components, hooks and helpers into addons/studio and addons/talk. Each has a manifest, a UI bundle, a release platform matrix and a catalog template, and is built by the compat mode of the shared builder. The core API stays where it is: the pages call it with the signed-in person's session. The history keeps its browser storage keys. The unit tests of the moved helpers move with them. In the core, drop the two pages, the Create group of the sidebar (Chat joins Home and Models in the old layout), the built-in catalog entries (the catalog is empty) and the talk locale namespace. The old addresses (/app/studio, /app/talk and the generator links) redirect to the add-on pages, with the model as ?model=. The Home /studio command is offered only while the Studio add-on is installed and opens it. The release workflow and its tests now list seven add-ons. Assisted-by: Claude Code:claude-sonnet-5-5 go node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): run the Studio and Talk specs against the add-on bundles The pages are add-ons now, so a spec that draws one installs the real built bundle first: the apps list names it and its module and style sheet are served from the build output, the way the add-on owner serves them. studio-addon.js holds that helper, and the Studio and Talk fixtures call it. Update the specs for the new addresses (/app/addons/studio/studio, the model as ?model=), the session permission the add-on route needs, the legacy redirects, the Home /studio command that appears only while the add-on is installed, and the sidebar without a Create group. Add specs for the add-ons themselves: every tab draws with no script error, the shell titles the page once, the old addresses say so when the add-on is not installed, a button of the page reaches a core page with the way back, and the drawer lists both only after they are installed. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): centre the icon of a built-in tile in its icon box The drawer drew the icon of a built-in tile (Studio's palette was one) in a bare 40px box. Only the placeholder box was laid out as a centred grid with the 20px size, so the real icon sat in the top-left corner at the size of the page text. Give the built-in tile the same classes as the placeholder box, and check in a browser that the icon is centred. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(addons): describe Studio and Talk as add-ons Replace the built-in list (now empty) with the Studio and Talk add-on sections, the new targets of the old links, the Home command, and the seven packaged add-ons. Describe the SDK additions the pages use: components, hooks, icons, openApp and portal. Say on the Studio page that it is installed from the drawer. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): click the shell title in the Studio slash-key spec The add-on shell titles the page, and the Studio overview no longer draws a second title, but the generator tabs keep theirs, so a bare h1 locator matched two elements. Click the first, which is the shell's. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): read the loopback port file through os.Root The persisted port file was read with a path built from the add-on root, which gosec reports as a file inclusion through a variable. Open it through os.Root so the name cannot leave the root, even by a symbolic link, and require a regular file with no group or other access. A looser file now fails closed instead of being trusted. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): expect Settings under the Operate hub, not the sidebar Settings moved out of the sidebar into the Operate hub. The navigation spec still expected a sidebar link. Assert that the sidebar has no Settings entry, and that the Operate hub opens Settings as a tab. Assisted-by: Claude Code:claude-sonnet-5-5 playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): drop Settings from the 404 places Settings is a tab of the Operate hub and not a destination of its own, so the 404 page does not list it. Assert that it is absent. Assisted-by: Claude Code:claude-sonnet-5-5 playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): drive the media history and TTS specs through the Studio add-on The image, video, sound and speech generators are pages of the Studio add-on now, and the old addresses redirect to it. Install the add-on with the Studio fixtures, open its pages and use its compose card, so the specs run against the page that exists. Assisted-by: Claude Code:claude-sonnet-5-5 playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): count the first-run steps, not every list item The recommended models are a list inside the second step, so counting all list items read 3 only while the models were still loading. Count the step elements, which does not depend on the load order. Assisted-by: Claude Code:claude-sonnet-5-5 playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): fail the node refresh by state, not by call count The spec served the list to the first request and failed the rest. The first request is not always the one from the page, so the page could get the failure first and show no list. Serve the list until the page shows it, then fail the refreshes. Assisted-by: Claude Code:claude-sonnet-5-5 playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): default to local-owner when auth is off A server without authentication has no accounts, so named-user left the add-on owner off. Resolve the unset mode in one place: local-owner when authentication is off, named-user when it is on. A flag or environment value still wins, and an unknown value is still refused. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): register an official add-on library by default Add gallery/addons/index.yaml, a YAML index of the four first-party add-ons (fine-tuning, quantization, voice, face) in the library format. The entries come from addonpkg catalog over locally built linux/amd64 packages, so the digests pin those images. On the first start, when the owner has no sources.json, register the raw GitHub URL of that index on master as the "official" library and write the file. The file is then the record: a library the admin removes stays removed, and an existing file is never changed. --addons-official-library=false skips the default. Assisted-by: Claude Code:claude-sonnet-5-5 go node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(addons): document the ownership default and the official library Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): list Studio and Talk in the first-party index Add the Studio and Talk entries to gallery/addons/index.yaml next to the four existing ones. They come from addonpkg catalog over locally built linux/amd64 packages, so the digests pin those images. Assisted-by: Claude Code:claude-sonnet-5-5 go node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): expect Studio and Talk in the official index The official index now lists six add-ons. Update the test that decodes it so it accepts Studio and Talk. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): add a real icon to each first-party add-on Each icon is a 256 by 256 PNG: a rounded square in one colour of the UI kit palette with a white outline glyph from the kit sprite. Fine-tuning uses sliders, quantization layers, voice waveform, face user, studio palette, talk phone and tables database. They were rendered with headless Chrome from the sprite paths. Assisted-by: Claude Code:claude-sonnet-5-5 chrome Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): ship a committed icon.png instead of the generated square The shared UI builder and the Tables builder copy ui/icon.png from the add-on source tree to the package unchanged when the file exists. They write the generated placeholder only when it does not. Add a fixture with an icon and a test for both cases. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): list the tables add-on in the first-party index Add the Tables entry to gallery/addons/index.yaml next to the six existing ones. It comes from addonpkg catalog over a locally built linux/amd64 package, so the digest pins that image. The official index test now expects seven add-ons. Assisted-by: Claude Code:claude-sonnet-5-5 go node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): make the Voice add-on one page with tabs The Voice add-on had four pages: recognition, library, create and speech, and the drawer listed two of them. It now has one page, "voice", and one drawer entry. Recognition, Library, Text to speech and From a recording are tabs of that page, drawn with the same tab strip the recognition page already used. The tab is the first path segment (voice/library), as in the Studio add-on, and the form that adds a voice is a view of the Library tab (voice/library/new). The old page names stay declared in the manifest. Each one redirects to its tab and keeps the query string, so saved links still open. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): send the old voice routes to the Voice tabs /app/voice, /app/voice-library and /app/voice-library/new now open the matching tab of the Voice add-on page while the add-on is installed and running, and the core pages otherwise. The moved-page helper compares the first segment of the target with the declared pages, so a target can name a tab. The docs and the navigation spec list the new addresses. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): cover the Voice tabs and the old page redirects The add-on unit tests render the tabs instead of the four pages. The Playwright spec checks the tab strip, the address of each tab, the missing Library tab for a member, and that the old page addresses of the add-on redirect to their tab with the query string kept. The migration table test follows the new targets. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(studio): keep the model menu inside a narrow screen The Home model picker sizes its menu from the viewport, and assumes the picker sits at the page gutter. In the Studio composer card the field is further in, so on a 390px screen the menu of the Sound expansion picker ended 7px past the right edge. Size the menu from the field it opens under instead. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): build the add-on bundles once for the whole run Each worker built the Studio and Talk bundles into the same dist directory, and the build empties that directory first. A worker that started, or restarted after a failure, removed the files another worker was serving, so a Studio spec saw "the style sheet of the add-on could not be loaded". Build once in a global setup before the workers start. A run without the setup still builds per worker. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): register the worker when the add-node spec says so The mocked roster added the worker on the third read of /api/nodes. The shell and the page both read it, and which one asks first depends on load, so the page could take the new worker as part of the roster it opened with and never show it as found. The worker now joins after the page shows it is listening. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): hold the gallery answer until the skeleton is seen The spec delayed the answer by a fixed 800 ms and then looked for the skeleton. The assertion polls at growing intervals, so on a slow machine it could step over that window. The answer now waits until the skeleton has been seen, and the spec then checks that the skeleton goes away. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): give the PDF tag the time the other PDF specs have pdf.js and its worker load on first use. The other specs in this file wait up to ten seconds for the result of that load. The first one waited five, and missed the tag on a busy machine. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): build the Face and Fine-tuning bundles in the global setup The navigation spec serves the built Fine-tuning bundle, but nothing built it, so two specs failed on a fresh checkout when the dist folder was missing. The spec also built the Face bundle in a hook that every worker runs, and a build rewrites the folder the other workers serve. The global setup now builds Studio, Talk, Face and Fine-tuning once, before the workers start. The spec builds the two bundles itself only when it runs without the global setup. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): fix the Home answers in the stale pin spec The spec asserts that the page shows no alert. The Home page raises its own alert when the model list fails or a staging operation has failed, and both answers come from the shared test server. A slow reply or a failed install left there counted as an alert about the pin. The spec now fixes both answers, so the alert count tells only about the pin. The assertions are unchanged. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): read the server address in the add-node spec from the page The spec expected the register address to carry port 8089, so it failed on any other port. It now expects the origin the page was opened from. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): give the navigation routes spec its own catalog No page is built in today, so the catalog is empty and the three specs that expect the studio and talk entries failed on the list and on the pin seed. They passed only while Studio and Talk were built in. The spec now sets a catalog of two ungated entries for each example and restores the old one afterwards. The assertions are unchanged. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(cli): report a failed write of the MLX hostfile The tunnel callback ignored the error of os.WriteFile and set the hostfile variable even when the file was not written. It now logs the error and leaves the variable alone. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): discard the close error of the held listener errcheck flags the deferred Close of the listener that keeps the port busy. The test has no use for that error, so the defer drops it explicitly. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): move the add-on tool tests to Ginkgo The fine-tuning, quantization and add-on host library tests used t.Errorf, t.Fatal and t.Run, which the forbidigo rule rejects. They now use Describe, It and Gomega, with a suite bootstrap in each package. The cases and the assertions are the same. The tables in the host client and the tool schema tests keep one entry per case. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): move the host operation tests to Ginkgo The hostops tests used the testing package calls that forbidigo rejects. They now run as Ginkgo specs under one suite bootstrap. Every check and every per-operation case is kept. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): move the pin store tests to Ginkgo The pin service, file store, sweep and shared store contract tests used the testing package calls that forbidigo rejects. The store contract is now a function that registers its specs for each store, so the file store, the memory-only store and the database store run the same cases. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): move the remaining add-on tests to Ginkgo The built-in catalog, tool arguments, loopback carrier, host operation contract, packaging, application, config, CLI and endpoint tests used the testing package calls that forbidigo rejects. They now run as Ginkgo specs. The endpoint tests also drop the unchecked Close errors that errcheck reports when the auth tag is on. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): wait for the targeted Reinstall button before the install click The "generic deletion blocks targeted install at confirmation" spec dispatches a popstate to set the target node, then clicks Reinstall with a script click. The click can run before the targeted page has rendered or while the button is still disabled, so no install request is sent and the poll on the install count times out. It failed 1 run in 10 with two workers and on every retry in CI. The spec now waits for the button to be enabled first. The assertions are unchanged. Assisted-by: Claude Code:claude-sonnet-5-5 playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore(addons): refresh the voice package digest in the first-party index The Voice add-on became one page with tabs and got a new icon, so the digest in gallery/addons/index.yaml no longer matched the build. Render the entry again with addonpkg catalog over a locally built linux/amd64 package; the main page is now voice. The other entries are unchanged. Assisted-by: Claude Code:claude-sonnet-5-5 go node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): wait for the target node's Reinstall button after the popstate The "generic deletion blocks targeted install at confirmation" spec dispatches a popstate to set the target node, then waits for a button named Reinstall and clicks it. Until React renders the targeted page, the page still draws the generic Reinstall button. It is enabled and has the same name, so the wait passed on it, the click started a global install, and no node install request was sent. The poll on the install count timed out with the confirm dialog still open. With the CPU throttled to 8x, the spec failed on 20 of 20 runs, and the button it clicked had the title "Reinstall". The spec now waits for the button titled "Reinstall on <node>". With the throttle it passes 30 of 30 runs. The assertions are unchanged. Assisted-by: Claude Code:claude-sonnet-5-5 playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): give the Studio hand-off spec a budget for the cold bundle load The "a result made through a hand-off records which result it came from" spec opens the Studio add-on straight from an address, twice. Each is a cold load: the page shows "Loading" until the add-on module has run, and the next assertion had the default five seconds. The load takes about two seconds on an idle run and seven to ten under CPU load, so the spec failed on a loaded machine with the hand-off note not yet drawn. With the CPU throttled to 6x and eight busy loops running, it failed on 21 of 30 runs. The first assertion after each load now waits up to 20 seconds, and the spec then passed 30 of 30 under the same load. The assertions are unchanged. Assisted-by: Claude Code:claude-sonnet-5-5 playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): carry the identity page styles in the Face add-on The Face page is drawn with the shared identity views, but its style sheet held only the clip and error rules. The page, card, model list, known-faces table, enrol sheet and distance scale classes had no rules. They looked right only when the host had already loaded the style sheet of its own Faces page, which a visit through the add-on drawer does not do. Opened that way, the cards had no gap, the model list showed bullets, and the install buttons ran into the names. The Face sheet now holds the same identity rules as the core sheet, so the page looks the same whichever way it is opened. Assisted-by: Claude Code:claude-sonnet-5-5 playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): scope the narrow-screen identity rules of the Voice add-on The rules for narrow screens (single-column pairs, smaller tool padding, compact rank rows) were copied into the Voice style sheet without their media query, so they applied at every width. They now sit in a max-width 720px query, and the reduced-motion rule in its own query, as in the core sheet. Assisted-by: Claude Code:claude-sonnet-5-5 playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): drop the repeated title from the Voice and Face pages The add-on shell already titles the page, so the recognition pages showed the name twice: "Voice" above "Voices", "Face" above "Faces". Keep the supporting line and the model picker, as the Studio page does, and remove the unused title strings. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): replace the Pinned sidebar section with an Apps entry The sidebar no longer lists pinned add-ons. It shows one Apps entry that opens the add-ons drawer, with a Show all link below it that opens the same drawer. Pins are used from the drawer, where a pinned tile keeps its pin and move buttons. The server pins API is unchanged. Remove the PinnedNav component and its locale title, rename the items.addons label to Apps, and update the e2e specs and the add-ons documentation to match. Assisted-by: Claude Code:claude-sonnet-5-5 playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): assert the single shell title on the Voice and Face pages The add-on shell now shows the one title, "Voice" or "Face", and the page no longer repeats it. The tests still looked for the h1 "Voices" and "Faces". Assert that the add-on page has exactly one h1 and that it is the shell title. The core pages keep their own "Voices" and "Faces" titles. Assisted-by: Claude Code:claude-sonnet-5-5 playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): drop the repeated title from the Voice Library and Text to speech pages The add-on shell already titles the page "Voice", so these two tabs showed a second heading below it ("Speech voices", "Speak text"). Keep the supporting line, as the Studio and recognition pages do, and remove the unused title strings. The e2e spec now checks that each tab has exactly one h1, the shell title. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): list pinned apps under Apps in the sidebar A pinned app now shows as an indented child of the Apps entry, with its icon and title, in pin order. Show all stays the last child. The pins come from GET /api/addons/pins through the navigation context, and the apps from the add-on registry, which the drawer feeds on every list read, so the links follow the drawer. A pin without an installed app is not drawn, nothing extra shows when no app is pinned, and the children are hidden when the sidebar is collapsed to the rail. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(addons): say that pinned apps are listed under Apps in the sidebar The sidebar lists the pinned apps between Apps and Show all, in pin order. The pins section and the navigation section said that pins do not appear in the sidebar. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): remove the Show all link under Apps in the sidebar The link repeated the Apps entry, which opens the same drawer, and looked redundant when no app was pinned. Apps is now the only add-on entry; pinned apps still list under it in pin order. The link, its locale key and the style comment are gone, and the e2e spec and the add-ons docs now say that Apps is the only entry. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): show a first-run card on Apps when nothing is installed An administrator who opens Apps with no add-on installed now sees a short card with three steps: choose a library, browse and install, start the add-on. Its button opens the Browse tab. The card is hidden once an add-on is installed and for anyone who cannot install. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): hide the pinned apps in the icon rail When the window is narrower than 1024 px the sidebar shrinks to the icon rail by CSS alone, without the collapsed class. The pinned app links were still drawn there, with the indent meant for the full sidebar, so their icons sat to the right of the rail and were cut off. Hide the pinned app links in both rail states. The Apps entry keeps its centred icon. The e2e spec checks that the rail shows no pinned app and that every icon is centred at 1280 px collapsed and at 1000 px. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(ui): stop the backend redirect test racing the installed list The test reads the redirected URL right after the path changes. The Backends page then loads the installed list and drops a selected backend that is not installed, so the backend parameter was sometimes already gone when the test read it. The mock server has no llama-cpp, and about 15 of 40 repeated runs failed on the backend assertion. The redirect itself was correct, including show_all. Serve the selected backend in the installed list, so the page keeps the selection and the assertions stay as strict as before. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): accept a same-host Referer or Origin for human confirmation Browsers send no Sec-Fetch-* headers over plain HTTP on an address that is not localhost, so creating a resource from a LAN address failed with "Creating a resource requires your own browser session". When Sec-Fetch-Site is present it must still be same-origin with an empty destination, as before. When it is absent, Origin or, failing that, Referer must name the request host and port. A destination or mode that is present must still be the expected one. This matches the rule in BrowserProtection. The credential, token cookie, API key and tool-context checks are unchanged. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): keep hub entries off the Apps route in the sidebar The Build hub still owned /app/addons through its legacy Add-ons tab, so the sidebar lit both Apps and Build on the Apps page. A tab whose gate is closed now owns no route when the sidebar asks, so only the Apps entry is active there. The hubs keep their own routes, and the legacy navigation still gives /app/addons to the Build hub. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): label the pin control on app tiles and in the detail view The pin was a small icon-only button and hard to find. Each app tile now shows a button reading Pin, or Pinned with a filled pin, and the detail view has the same control in its header. Both toggle the same stored pin. The Apps grid lists pinned apps first, in pin order, then the others. The sidebar list and the server pins API are unchanged. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): check the Voice From a recording tab has one title The Library and Text to speech tabs already assert that the add-on shell title is the only h1. Do the same for the From a recording tab, so a heading added there later fails the navigation spec. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): stop the add-on shell drawing a title over add-on pages The app decides what its page shows. The shell drew the add-on name as a header above every add-on page, so an add-on could not choose its own title. The shell now draws no header on a live, loading or frozen add-on page; each add-on titles its own pages. The unavailable and missing-page states keep the name, because no add-on page is drawn there. The runtime and shell specs now check that the shell adds no page title. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): let each add-on draw its own page title The shell no longer titles add-on pages, so each add-on draws one h1 per page. Voice titles its tabs "Voices" (Recognition and From a recording), "Speech voices" (Library) and "Speak text" (Text to speech); "Add a speech voice" already had its own. Face draws "Faces", Studio "Studio" and Talk "Talk". Tables promotes the database list and database name from h2 to h1. Fine-tuning and Quantization already drew a title. The add-on unit tests and the navigation and Studio specs now assert exactly one h1 per page, with the page's own title. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): add a kind to library entries and read unknown kinds tolerantly A library entry now has an optional kind: addon (the default when it is absent), backend or model. Every index written before kinds existed stays valid and its entries are add-ons. A reader keeps the entries of the kinds it knows and skips the others without reading their fields, so a newer kind never makes an older host refuse the whole index. The refresh result and the library state report how many entries were skipped and of which kinds, and the refresh logs a warning. A kind that is not a plain kind name still makes the index malformed. Search lists add-ons unless the caller asks for a kind, and Resolve returns add-ons only, so an add-on install cannot be pointed at a backend image. A library policy may name the signer with an anchored identityRegex instead of an exact identity, so builds from master and from tags both verify. An add-on install refuses a policy that carries one. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(backends): install a backend from a library entry A library can now list backends. An entry of kind backend names the image of the backend gallery by digest, and POST /backends/library/install installs it on the local node. The gallery stays the source of truth and its install path is the one used: the library resolves the entry, takes the signature policy from the library, pins the image by digest and calls the same install as the gallery. Installing a name that came from a gallery, another library or by hand is refused and changes nothing. The signature policy of a backend library names the signer with an anchored identityRegex, so images signed by the merge workflow on master and on tags both verify. A pattern that is not anchored is refused. An install is bounded by a size limit per kind: add-ons keep 512 MiB and a backend image defaults to 8 GiB, set with --addons-backend-max-bytes. The limit reads the manifest after the signature check and before any layer is fetched. The metadata of an installed backend records the library it came from. scripts/backends/cmd/backendcatalog renders entries from backend/index.yaml and pins them against the registry. The pilot is the CPU build of local-store, in gallery/backends/index.yaml. The library is registered on the first start only with --addons-backend-library, which is off by default. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): show the kind of library entries and install backends from them The Browse tab lists the entries of every kind this host reads and marks each one as an add-on or a backend. A backend installs through the backend route and not through the add-on lifecycle: the confirmation says that the backend gallery is not changed and that a backend runs native code, and a refusal of the host, such as a name installed from another origin, is shown with its own reason. The details give the kind, the alias and the signer pattern. The library editor can name the signer with a pattern. Without this a save would drop the pattern of a backend library. The Libraries tab reports the entries a refresh skipped because their kind is not read. Assisted-by: Claude Code:claude-sonnet-5-5 node playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(addons): describe backends in the library Document the entry kinds, how a reader skips a kind it does not know, the backend entry format, the signer identity pattern, the install route, the size limit of each kind, the pilot library and the command that renders it. The backend page links to it. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(mcp): install a backend from a library entry The backend library route installs a backend from an add-on library entry, and the admin REST surface and the MCP catalog must stay in step. Add the install_library_backend tool with an in-process and an HTTP client, the route map entry, the safety-rule listing and a skill recipe. Listing library entries stays on the add-on routes, which the assistant does not expose, so no list tool is added. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): carry the add-on library keys in every locale The add-on library and backend library strings existed only in English, so the other locales had no value for them. Add the missing keys of the addons namespace to de, es, id, it, ko, pt-BR and zh-CN. The values are the English text until a translation is contributed. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Revert "feat(mcp): install a backend from a library entry" This reverts commit |
||
|
|
dce91ba7aa |
fix(backends): prefer installed development builds (#12782)
* fix(ci): capture and surface ginkgo errors in run-coverage.sh The coverage script ran ginkgo per test root with `|| fail=1`, which swallowed the exit code. Combined with `make --output-sync=target`, which buffers all output and flushes at the end, the actual ginkgo error was invisible in interleaved CI logs. Every test suite passed but the script still exited 1, and the error message was buried in 22 000 lines of buffered output. Each ginkgo invocation now captures combined stdout+stderr to a per-root log file. On failure the last 80 lines are printed to stderr so the error is visible in the CI log. On success the log is replayed to stdout so test output remains visible. The build flags (--tags, --coverpkg) are quoted correctly so values containing spaces (e.g. --tags="debug auth") are preserved through eval. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ci): create output dir before writing ginkgo logs The previous commit added per-root log files but forgot to create the output directory before the first ginkgo invocation. On CI the coverage/ dir did not exist yet, so the shell redirect to cover-pkg.out.log failed with "No such file or directory" before ginkgo even ran. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(core/http): add canvas preview route to auth coverage allowlist Commit |
||
|
|
0e0e91f95c |
feat(diagnostics): add opt-in profiling (#12610)
* feat(diagnostics): add private opt-in profiler Keep profiling on a dedicated loopback listener rather than the public API. Validate settings and bind synchronously so startup reports configuration and socket failures. Own sampling and shutdown for the run process. Cover private handlers, blocked cmdline access, sampling cleanup, and serving failures with focused Ginkgo tests. Assisted-by: nib:gpt-6-astra * feat(diagnostics): add sanitized phase timing Carry generated request and reload identities through private contexts. Keep timing disabled without allocations and emit only validated fields at Info level when enabled. Cover identity isolation, nested durations, inheritance, and sanitized logging with focused race-tested Ginkgo specs. Assisted-by: nib:gpt-6-astra * fix(diagnostics): retain request observations Reuse an existing request observation across recorders to avoid splitting correlation. Keep the original context, identity, and sink without new allocations. Preserve nil-recorder behavior and fresh reload identities. Verify the regression fails before the fix and passes with the full Diagnostics race suite afterward. Assisted-by: nib:gpt-6-astra * feat(cli): own opt-in diagnostics lifecycle Expose profiling and phase timing flags without enabling them for debug. Bind the private profiler before application initialization and propagate serving failures through the run lifecycle. Bound signal cleanup and preserve preload-only backend startup. Cover flag parsing, preload isolation, cancellation, and once-only shutdown. Document initialization and shutdown cancellation limitations. Assisted-by: nib:gpt-6-astra * feat(config): measure filter and reload phases Separate request lock waits from filtering and held work. Aggregate reload filesystem and parsing durations without logging inside the config lock. Preserve caller option execution under the lock after metadata collection. Extract only the internal diagnostics option before waiting for the lock. Keep direct callers uninstrumented unless they supply diagnostics options. Verify focused Diagnostics examples with the race detector, including option ordering, gated contention, errors, panic propagation, and parity. Assisted-by: nib:gpt-6-astra * fix(config): separate early reload observation Opaque callbacks cannot select timing before their execution point. Carry the startup recorder explicitly through loader construction. Apply late options once under the lock and honor composed last writes. Document early and late phase boundaries and observation identities. Report malformed gallery syntax as an internal parse failure without changing strict or non-strict loader returns. Assisted-by: nib:gpt-6-astra * fix(config): preserve per-model option callbacks The resolved snapshot suppresses existing callbacks during model defaults, even when diagnostics are disabled. Pass original options through the existing defaults pathway without adding diagnostics-driven replay. Test stateful options with two models in enabled and disabled modes. Preserve three calls and thread defaults of two and three. Assisted-by: nib:gpt-6-astra * feat(model): observe discovery phases Carry request diagnostics through config and loose-file discovery to separate directory enumeration from in-memory filtering and fallback. Keep existing APIs as background-context wrappers and preserve discovery policies, ordering, duplicates, errors, and filesystem operation counts. Add focused parity, privacy, phase, and disabled-allocation tests. The focused diagnostics race gate passes for model and galleryop. Assisted-by: nib:gpt-6-astra * feat(http): observe extraction phases Generate diagnostic identity before request discovery, independently of client correlation headers. End each extraction segment before inference so downstream latency does not inflate discovery measurements. Carry observations through discovery helpers and rebuilt request contexts without changing cancellation or model selection. Cover disabled parity, error outcomes, privacy, concurrent identity isolation and route contexts. Assisted-by: nib:gpt-6-astra * fix(http): retain diagnostics across failover Failover restores its entry request before retrying. Initialize the observation before that capture so direct extraction retains one ID. Keep extraction spans per attempt and end them before downstream work. Exercise two real targets without default middleware, including replay, shared identity, phase boundaries, and timing-off response parity. Assisted-by: nib:gpt-6-astra * feat(model): observe initialization and routing Mark loader entry before cache and admission checks. Measure only the ModelRouter callback, without attributing transport or worker latency. Test inherited identity and sanitized events with fake backends. Exercise the actual Echo router to keep profiles private across debug and timing options. Cover capture shutdown, forced close, and lifecycle failures. Assisted-by: nib:gpt-6-astra * docs(diagnostics): guide bounded investigations Document private profiling, sampling ownership, and phase boundaries so operators can interpret captures without assuming request attribution. Add Ginkgo drift checks for flags, defaults, phases, links, and capture warnings. These checks do not certify the operator prose. The focused documentation race test passes after four expected failures. The consolidated Diagnostics race gate passes across all eight packages. This gate does not validate the full suite, auth, or production UI. Assisted-by: nib:gpt-6-astra * refactor(cli): restore inline run lifecycle Remove diagnostic startup hooks so application startup stays readable in Run. Keep the profiler as an optional sidecar with bounded cleanup and an error watcher, without a general lifecycle runner. Replace hook simulations with real CLI startup checks and component capture tests. Document that application cleanup can exceed the private listener budget. The eight-package Diagnostics race gate passes. A cleanup mutation fails the real CLI listener-release regression as expected. Assisted-by: nib:gpt-6-astra * fix(cli): retain concurrent startup errors Cancellation must not hide a public API bind failure. Join the public serve result, cancellation cause, and HTTP shutdown errors inline. Ignore ErrServerClosed only after intentional shutdown. Exercise real startup with an occupied API port and a failed private listener. Isolate the Linux socket fixture in a subprocess and wait for cancellation acknowledgment without timing sleeps. Assisted-by: nib:gpt-6-astra * refactor(http): make request context primary Move context preparation into the request extractor. Diagnostics remain an optional addition, not the entry point for request processing. Preserve context initialization before failover captures the request. Assisted-by: nib:gpt-6-astra --------- Co-authored-by: Ettore Di Giacinto <mudler@localai.io> |
||
|
|
895d50385f |
fix(distributed): converge model configs across frontends (#12558)
* fix(galleryop): announce model changes before the preload After a gallery install or delete, the replica that ran it replaced its config loader, then preloaded every installed model, and only then published the models invalidation. The preload does remote lookups and checksums for each model, so on a large models directory peers learned about the change minutes after the originator listed it. When the preload failed or the operation was cancelled, the event was never sent. Publish the invalidation, and apply the delete lifecycle, as soon as the loader holds the new set. The preload still runs afterwards with its own error handling. Its failure is reported on the operation, but it no longer rolls back a deletion that peers have already applied. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(distributed): resync model configs from the models directory Frontends refresh their model configs only when a models invalidation arrives on NATS. NATS keeps no history, so a frontend that is disconnected when the message is published never applies the change. It keeps serving the old config, for example an alias that points at the previous model, until some later change happens to touch it. Each frontend now reruns the peer reconcile against the shared models directory after every NATS reconnect, and every --model-config-resync-interval (default 30s) when a config file changed. The pass names no model, so only models whose file changed get a revision transition, and an unchanged directory costs one read of the config files. The reconcile replaced the whole loader with a parse of the models directory, which dropped models loaded with --config-file and published a deletion revision for them. Configs defined outside the directory are now kept, both there and after a gallery install. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(nodes): stop stale frontends from retargeting alias rules A scheduling rule keyed by an alias derives its target from the alias mapping of the frontend that reads it. Every frontend keeps its own copy of the model configs, so after an alias is repointed a frontend that has not reloaded it still resolves the old target. Two frontends then rewrote the rule's stored target_model against each other on alternate reconciler ticks, and the outdated one scaled up the model the alias used to point at. The registry already records the accepted config revision of each model. A frontend now derives a rule's target from its own alias mapping only when its config revision for the rule's name matches that record. Otherwise it keeps the stored target_model: it neither writes the column nor reconciles replicas of the old target. The check reads the database only for a rule whose stored and derived targets differ. With no accepted revision on record, the old behaviour stays. Assisted-by: Claude Code:claude-opus-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> |
||
|
|
104c2fb440 |
fix(oci): stage image downloads in a configured directory, not in /tmp (#12280)
* fix(oci): stage image downloads in a configured directory, not in /tmp
Problem:
- the image tar and its compressed layers staged in os.TempDir()
- /tmp is commonly a RAM-backed tmpfs far smaller than a backend image
- big installs failed with a full /tmp or silently ate RAM
Change:
- staging path resolved like the other storage paths
(LOCALAI_DOWNLOAD_STAGING_PATH, default ${basepath}/downloading),
threaded to the extractor as a download option
- every download works in its own subdirectory holding its tar and
layers: nothing shared between concurrent downloads, one removal
cleans a download up, a crash leaves one self-contained orphan
- callers that pass no staging directory keep the OS temp behavior
Assisted-by: Claude:claude-fable-5
Signed-off-by: Plamen K. Kosseff <p.kosseff@gmail.com>
* fix(oci): handle staging cleanup errors
Log staging cleanup failures to satisfy errcheck. Test extraction with an
unavailable OS temp directory and verify that staging is removed.
Assisted-by: Codex:GPT-6
Signed-off-by: Plamen K. Kosseff <p.kosseff@gmail.com>
---------
Signed-off-by: Plamen K. Kosseff <p.kosseff@gmail.com>
|
||
|
|
e62854c340 |
fix(failover): pass credential lookup from CLI
Pass the API key environment lookup through ApplicationConfig to satisfy core configuration lint. Keep credential resolution dynamic and exclude the callback from serialization. Handle the five close results reported by errcheck. Assisted-by: Codex:gpt-6 golangci-lint Signed-off-by: Ettore Di Giacinto <mudler@localai.io> |
||
|
|
237cf456fd |
feat(gallery): galleries published as OCI artifacts (#12167)
* feat(oci): pull ORAS artifacts into a directory Galleries published to an OCI registry arrive as an ORAS artifact whose layers carry their tree path in org.opencontainers.image.title. Nothing in pkg/oci could read a non-image manifest, so add PullArtifact: it lays the layers out under a destination directory and returns the manifest digest so callers can pin and verify signatures on it. A registry is remote input and this writes files, so the manifest is validated before any byte is fetched. A title that is absolute, escapes the destination or is missing is refused, an artifact of an unexpected artifactType is refused, and both the layer count and the total size are capped with caller-settable limits. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-5 [Claude Code] * feat(gallery): fetch and verify galleries published as OCI artifacts A gallery whose URL is oci://host/repo:tag is now pulled as an ORAS artifact and its index.yaml read from the unpacked tree, alongside the existing HTTP and file sources. Mirrors, the per-candidate timeout, the failure cooldown and the last known good copy work the same for both schemes, so a gallery can mirror an OCI primary with an HTTP fallback. When the gallery declares a verification policy the artifact reference is resolved to its manifest digest, the publisher signature is checked against that digest, and only then is the same digest pulled. Nothing unverified is written to disk or parsed. With strict integrity mode on, an OCI gallery without a verification policy is refused instead of warned about, which is what the flag already does for backend installs. The pull lands in a staging directory that is renamed into the cache only once the whole tree is on disk and the index reads back as an index, so a failed or interrupted pull leaves nothing a later fetch would serve. A fetch that finds a fresh cache entry does not contact the registry. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-5 [Claude Code] * feat(gallery): resolve entry urls relative to the gallery root A gallery published as a self-contained tree, which is what an OCI gallery is, names its base configs by their place in that tree. Such a url reached the HTTP client verbatim and no entry of the gallery could be installed. A scheme-less entry url now resolves against the root the index came from: the unpacked artifact for an oci:// gallery, and the directory of the index URL otherwise. The relative path may not climb out of that root, reusing the guard the artifact puller already applies to layer titles, and an entry that does is dropped from the listing rather than failing the whole gallery. An entry that names its own scheme is untouched, so .ref indirection and local github: resolution behave as before. Reading an entry of an OCI gallery also needs the unpacked artifact directory as its trusted root, since the cache is deliberately a sibling of the models directory the downloader otherwise confines a file read to. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-5 [Claude Code] * fix(oci): restrict artifact file permissions Create artifact files with owner-only permissions to satisfy G302. Document the existing path validation for the G304 scanner finding. Check the permissions of downloaded layers in the artifact test. Assisted-by: Codex:gpt-6 gosec * fix(oci): confine artifact writes to their root Use root-relative filesystem operations to reject escapes through existing directory symlinks. Retain owner-only permissions and add a symlink escape regression test, without suppressing the path traversal finding. Assisted-by: Codex:gpt-6 --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com> |
||
|
|
3dfb34376a |
feat(downloads): authenticate registries, galleries and downloads from a credentials file (#12028)
* feat(credentials): parse and match download credential rules Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(credentials): keep secrets out of parse errors and tighten URL matching Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(credentials): resolve secrets lazily and authenticate HTTP per hop Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(credentials): redact secrets in nested and store formatting Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(credentials): add registry keychain and oras credential adapters Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(credentials): match repository rules for Docker Hub in the oras adapter Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(downloads): authenticate HTTP downloads and gallery reads from the credentials store Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(oci): authenticate registry pulls, resumes, blobs and cosign from the credentials store Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(cli): load download credentials from --credentials-file Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(credentials): correct the local-network registry rules Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(credentials): keep secrets out of match and YAML parse errors A match that fails to parse is no longer quoted in the Parse error, since it may be a URL with a token in it. Userinfo is detected before the scheme check, so ftp://user:token@host is refused as userinfo, and a match with a query string or fragment is refused because it can never apply and a query string is where signed URLs carry their token. Every YAML decode error is now redacted, not only type errors: quoted scalars such as a secret under a mismatched !!int tag are replaced and unquoted map keys are cut off. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(downloads): make auth errors name the real cause and never retry unresolved secrets AuthError now appends its cause, so a registry's DENIED or UNAUTHORIZED detail reaches the operator. HTTP auth errors print only the status text in place of the cause, because the downloader builds that cause from the requested URL, which can carry a signed query string. Registry pulls say that docker config credentials were tried too, and a download that carried a caller-provided credential (WithBearerToken, or an explicit authorization on gallery reads) reports that credential as rejected instead of blaming the store. The Range probe for a leftover partial file now returns an unresolved secret as a permanent error, like the download request already did. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(credentials): keep oras pulls anonymous on a broken docker helper and close bodies When docker config names a credsStore helper that cannot run, the oras credential func now logs at debug and returns no credential, so public pulls keep working as they did before the adapter existed. The transport closes the request body when a rule's secret cannot be resolved, as the RoundTripper contract requires. The redirect spec now uses a custom header rule on the origin, which net/http would not strip on its own, to prove the transport does not carry credentials to the next hop. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(oci): cover FetchImageBlob authentication against a private registry FetchImageBlob now has a spec that pulls a layer blob by digest from a basic-auth registry through the oras credential adapter, and one that shows the same fetch fails when no rule matches. oras only speaks HTTPS here, so the registry serves TLS and the spec points http.DefaultTransport, which retry.DefaultClient falls back to, at the test server's client for its duration instead of adding a transport seam to production code. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(credentials): document auth error wording, ollama manifests and registry tokens The errors section now lists the registry and provided-credential messages and says the server's reason is appended. ollama:// manifests are fetched without credentials, so only blob downloads use the file. GHCR, Docker Hub and Quay need basic auth with the token as password, and match rules must not carry a query string or fragment. The backend gallery docs and the container troubleshooting section now point to the private sources page. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(credentials): document trusted file path The credentials path comes from operator configuration. Mark the file read with a scoped G304 explanation to resolve the gosec false positive. Assisted-by: Codex:gpt-6 gosec --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com> |
||
|
|
9afe10ba21 |
fix(distributed): survive a slow control-plane database (#11837)
* fix(distributed): evict only when a node is known to be full scheduleNewModel asked the registry for a free replica slot and treated every error as "this node is full", so a control-plane database slow enough to time out the lookup evicted a healthy loaded model. The evicted process died, a peer frontend still holding its address dialled the dead port and retried, and the model thrashed between nodes. The comment on the branch already said it meant a full node; the code never tested for it. Evict only on ErrNoFreeSlot. Any other error now returns and names the lookup that failed, so a slow database degrades into a diagnosable load failure instead of into lost work. An audit of the rest of the router found one branch of the same shape: node selection discarded the error from its last-resort finder, so a database timeout there also produced a nil node and evicted for it. That path now returns unless the finder said gorm.ErrRecordNotFound, which is the only answer that means the cluster had no node to give. No other destructive branch in router.go fires on a generic error. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(distributed): checkpoint heartbeat writes instead of writing every beat Every heartbeat UPDATEd backend_nodes. Six nodes at a ten second beat is roughly 52,000 writes a day against a six-row table, and that churn is what turned a blocked autovacuum into a 460 MB table whose six-row scan cost 867 ms and timed out the queries that place models. A beat carrying only a fresher timestamp now waits for the checkpoint interval. Each reported field is compared against the value last persisted rather than tested for presence, because a worker sends its disk figures on every beat and presence alone would suppress nothing. A node's first beat, a changed total VRAM, total disk or GPU vendor, and a free VRAM, RAM or disk reading that has moved more than 256 MiB from the persisted value all still write at once. A node that is not active is never suppressed, because it recovers only when the health monitor sees a fresh timestamp. The persisted column is up to one interval stale by design, so the stale-node threshold moves from 60s to 5m to cover it. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(distributed): fail worker readiness when a held backend is unreachable The readiness gate tracked only the NATS link, so a worker whose backend processes had died still answered /readyz with 200 and kept receiving loads. One node did exactly that during an incident: it reported healthy while its backend port refused connections, and every load routed to it failed. Readiness is now the NATS link and, for each backend process the worker believes it is running, a short dial of its recorded address. A worker holding no backends stays ready, because idle is a healthy state. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(distributed): keep a starting backend out of the readiness dial set A backend process is inserted into the supervisor map with its gRPC address already recorded, but the address refuses connections until the gRPC server binds, which the startup poll allows up to 30 seconds for and which takes 10 to 15 seconds on a slow node. The new data-path readiness probe dialled that address straight away, so a worker answered /readyz with 503 for the whole of every cold backend start. The container HEALTHCHECK absorbs that, but a Kubernetes readinessProbe at 10s does not, and the worker would leave rotation each time it loaded a model. The skip for a stopping process had no counterpart at the other end of the lifecycle. Backend processes now carry a serving flag, set where the startup health-check gate succeeds, and the probe dials only processes that are serving and not yet stopping. backendStartStillValid becomes markBackendServing: the check and the mark must share one lock hold, so the flag can only ever land on the entry the key currently owns. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(distributed): export control-plane database health gauges Four transactions wedged on a corrupt index held the vacuum horizon open for 42 days. Nothing measured it, so the first symptom anyone saw was models failing to load six weeks later, by which time a six-row table had grown to 460 MB. Export the oldest xmin age, the longest open transaction, and the dead tuple ratio on the registry tables. The first is the number that would have caught it: it sits near zero in health and was 21,002,291. Sampling is scrape-driven behind a cache, and a failed sample reports the last good values rather than failing the scrape, because these gauges matter most when the database is already struggling. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(distributed): rate-limit failed control-plane database samples The cache advanced its clock only on a successful sample, so once the database started failing every scrape retried the query immediately. That turned the cache off in the one regime it exists for: a retry storm at scrape cadence aimed at a database already in trouble. A catalog read that consistently exceeds the 5 second timeout also paid that cost on every scrape, with all scrapes serialised behind the sampler mutex. Time every attempt rather than every success, so failures and timeouts cost the same interval as good samples. Whether a good sample exists moves to its own field, keeping the gauges absent until the first success and holding the last good values through later failures. Also note in the runbook that pg_stat_activity cannot see prepared transactions or replication slot xmins, so a healthy-looking xmin age does not by itself rule out a blocked horizon. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(distributed): pin that a failing database evicts nothing Exercises the real distributed stack against a control-plane database that refuses the router's slot lookup, and asserts the scheduler reports the lookup it could not answer instead of falling through to eviction. The failure is injected with privileges rather than a statement timeout. A timeout set with ALTER DATABASE also breaks AutoMigrate, and it leaks into every later spec in the suite unless it is reset, so the spec would end up testing the migration rather than the scheduler. Instead the spec creates a dedicated login role, points a second gorm handle at it, and revokes that role's SELECT on node_models.replica_index. This has to be a separate role: the test container's owner is a PostgreSQL superuser, and superusers bypass every privilege check, so revoking from CURRENT_USER is recorded and then ignored. The revoke is scoped to one column on purpose. Revoking the whole table would also blind node selection, which runs first and has a guard of its own, so the scheduler would never reach the slot lookup this spec is about. Leaving every other column readable lets selection succeed and lands the refusal exactly on NextFreeReplicaIndex, which plucks replica_index. The grant is restored from BeforeEach via DeferCleanup, so a failing assertion or a panic cannot hand the next spec a role that cannot read. Reverting the eviction guard fails this spec, which is the point of it: the router then reports "no replica slot on keeper and eviction failed" for an error that was never evidence the node was full. The surviving-row assertions are secondary under this injection, because the eviction path reads whole node_models rows and the same revoke blinds it too; a comment in the spec says so, so nobody mistakes them for the load-bearing ones. Also documents why the vector store and the control plane must not share a database: the removable-tuple cutoff is per database, not per table, so one transaction left open anywhere stops autovacuum reclaiming the node registry, and a six-row table bloats into hundreds of megabytes. The note names LOCALAI_AUTH_DATABASE_URL and LOCALAI_AGENT_POOL_DATABASE_URL as the two knobs that must differ, and the localai_control_plane_oldest_xmin_age gauge as the way to see it coming. grep for StaleNodeThreshold and HealthCheckInterval in core/config/runtime_settings_registry.go returns no matches: the distributed duration knobs are not exposed as runtime settings, so the new heartbeat checkpoint interval follows them and needs no registry entry. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(distributed): close the review gaps in the heartbeat and health path The stale-node threshold moved from 60 seconds to 5 minutes in this branch because checkpointing makes last_heartbeat up to one checkpoint interval behind by design. Two things were left inconsistent with that. NewHealthMonitor still fell back to a hardcoded 60 seconds when handed a zero threshold, so any future caller that stopped passing the configured value would mark every healthy, beating node offline on every cycle. And the threshold itself had a flag-name constant but no AppOption, no CLI field and no env binding, so an operator who widened --node-heartbeat-checkpoint had no way to widen the threshold to match. The fallback now tracks config.DefaultStaleNodeThreshold, and --stale-node-threshold / LOCALAI_STALE_NODE_THRESHOLD is wired the same way its sibling is. Heartbeat suppression compared the RAW reported free VRAM against the snapshot, but the column persists capAvailable(raw, ceiling). On any node with a VRAM budget set, whose actual free VRAM oscillates above that ceiling, every beat looked material while the persisted value never moved: suppression was defeated on exactly the nodes an operator had configured, and the write amplification this branch exists to remove came straight back there. The comparison and the snapshot now both hold the capped figure, so they measure the same quantity as the column. Fixing that needs the ceiling, and reading it cost a SELECT on every beat, including suppressed ones. The skip decision therefore moved ahead of the updates map and now reuses the ceiling cached on the last durable write, while the write path still re-reads it before capping anything. A ceiling that changed inside the checkpoint window can cost one extra or one late write; it cannot persist a wrong figure. A suppressed beat now costs no query at all. Also: the operations section now says to grant pg_read_all_stats to the LocalAI role, because PostgreSQL blanks backend_xmin and xact_start for sessions owned by other roles, and the transaction that wedged the horizon in the incident was a co-located vector store connecting as a different role, so without the grant the new gauge sees only our own sessions. The compose healthcheck comment now describes readiness covering the backend data path, and the control-plane gauge registration records the otel.SetMeterProvider ordering it depends on. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(distributed): resolve the gauge's table names through gorm The dead-tuple gauge queried pg_stat_user_tables against a hardcoded list of three table names. Those three do not agree on where their name comes from: BackendNode and NodeModel take gorm's default pluralisation, while GalleryOperationRecord overrides TableName, and gallery_operations already had a constant of its own that the list duplicated. A literal list keeps compiling after any of that moves, and the query then matches nothing. The failure is silent and it points the wrong way: a dead-tuple ratio that matched no rows reports the same numbers as a cluster with no bloat, so the gauge would look healthiest exactly when it had stopped working. Ask gorm what each model is stored as instead, which follows a TableName override and the default pluralisation alike. A spec pins that the override really is consulted: naive pluralisation of the type would give gallery_operation_records, so the resolution cannot quietly stop asking the model. 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> |
||
|
|
8f56e4e042 |
fix(vram): persist remote probe metadata (#11487)
* fix(vram): persist remote probe metadata The startup warmer repeated remote size and GGUF metadata probes after every restart because both caches lived only in memory. Store successful HTTP probes for 24 hours so frequent restarts reuse the prior results. Bound the cache, reject invalid records, and purge it when gallery data changes. Local model files continue to bypass persistence. Assisted-by: Codex:gpt-5 * fix(vram): check temporary file cleanup The lint gate rejects the unchecked cleanup call in the persistent cache writer. Assisted-by: Codex:gpt-5.6 [golangci-lint] * fix(vram): make persistent cache optional Remote metadata probes can transfer enough data that operators need control over disk reuse and startup warming. Gallery autoload now gates both behaviors, and the runtime setting applies changes immediately. Assisted-by: Codex:gpt-5 * fix(ui): expose gallery startup pre-warm The existing gallery autoload setting also gates the startup metadata warmer. Name both effects in Settings so operators can find the requested boot control. Assisted-by: Codex:gpt-5 --------- Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com> |
||
|
|
799cc9f211 |
feat: bound global admission and expose running backend traces (#11560)
feat: bound backend admission and expose running traces Add process-wide backend execution admission without blocking UI or administrative HTTP work. Represent backend operations while they are in flight, surface running traces with immediate log links, and tie streaming admission leases to the gRPC receive lifecycle. Assisted-by: OpenAI Codex: GPT-5 Signed-off-by: Richard Palethorpe <io@richiejp.com> |
||
|
|
88edd7fc7f |
fix(distributed): run cold model loads as durable jobs instead of holding the advisory lock (#11514)
* fix(advisorylock): set statement_timeout alongside lock_timeout
WithLockCtx already overrides a deployment-wide lock_timeout on its
dedicated connection so a blocking pg_advisory_lock() waits its turn
instead of failing with 55P03. statement_timeout aborts that exact same
statement independently, with SQLSTATE 57014, and was not overridden.
Production roles commonly carry statement_timeout=60s. Any guarded
section longer than that (a cold model load stages for tens of minutes)
therefore killed every concurrent waiter:
advisorylock: acquiring lock 9003261067483446873: ERROR: canceling
statement due to statement timeout (SQLSTATE 57014)
Derive it from the same context budget as lock_timeout, with a matching
RESET so the pooled connection is returned clean.
Assisted-by: Claude Opus 5 [claude-code]
* feat(distributed): add ModelLoadJob, the durable cold-load record
A cold load in distributed mode is a long-running background job, but it
was modelled as a synchronous side effect of an inference request: the
whole of it (backend install, multi-GB staging, checkpoint load) ran
inside the per-model advisory lock. Loading a 35.7 GB GGUF held that lock
for ~20 minutes, so every concurrent request for the same model blocked
on pg_advisory_lock and died at the role's 60s statement_timeout.
Introduce the row that lets the lock shrink to a decision. Exactly one
ModelLoadJob may be active per tracking key; that uniqueness — not the
lifetime of a lock — is what de-duplicates concurrent loaders across
replicas. ClaimLoadJob does its read-then-write under the advisory lock
and nothing else: no network, file or gRPC I/O inside the guarded
section, so a claim costs milliseconds no matter how long the resulting
load takes.
LastProgress is a heartbeat rather than a byte counter. A checkpoint load
legitimately moves zero bytes for many minutes, so a reaper keyed on byte
movement would reclaim a healthy job mid-load; byte progress stays the
concern of load_deadline.go. A job whose heartbeat stops for longer than
the orphan window is reclaimable, so a replica killed mid-load cannot
wedge a model permanently.
Failed jobs keep their row for a short grace so an immediately-following
request reports the real cause instead of silently starting a fresh load
of a model that just failed.
No caller yet — the router moves onto this in the next commit.
Assisted-by: Claude Opus 5 [claude-code]
* refactor(distributed): run cold loads as jobs, outside the advisory lock
Route wrapped the entire cold load — node selection, backend install,
multi-GB staging and the remote LoadModel — in the per-model advisory
lock. The lock's job is to de-duplicate concurrent loaders, a decision
that takes milliseconds; holding it for the tens of minutes the resulting
work takes is what turned a dedup mechanism into a cluster-wide outage
for that model.
Split it into a claim and a run. The claim is the only thing left inside
the lock. The run is a background job owned by the claiming replica and
bounded by the same progress-extended deadline as before; every other
request for that model — local or on another replica — attaches as a
waiter and is served the moment the model is ready, with no duplicate
load and no lock contention.
Waiters share one broadcast rather than an ordered queue: they all want
the identical outcome, so ordering them would add fairness machinery that
changes no result. The local channel wakes same-replica waiters instantly
and a 2s DB poll is the authority, because a waiter on another replica
has no channel to close. On wake a waiter re-runs the warm path rather
than trusting the signal — the model may have been evicted in between.
A waiter whose client disconnects returns immediately and the job keeps
running; it belongs to the job record, not to the request. A failure is
recorded on the row so every waiter reports the real cause, and the row
survives briefly so the next request does not read "no job" as "not
loading" and start a duplicate load of a model that just failed.
The runner heartbeats the row on a fixed interval whether or not bytes
are moving, which is what keeps a legitimately silent checkpoint load
from being reclaimed as an orphan. Phase (installing/staging/loading) and
placement ride to the heartbeat on the context, the same seam
load_deadline.go already uses, so single-host paths are untouched.
Non-distributed mode (no DB) keeps the inline load exactly as it was.
Assisted-by: Claude Opus 5 [claude-code]
* feat(distributed): bound the wait for a loading model and answer with progress
A request whose model is cold-loading now attaches to the running job and
is served the moment the model is ready. That wait has to be bounded: a
held HTTP request cannot survive real infrastructure, and an ingress or LB
idle timeout kills a twenty-minute request regardless of what LocalAI
does.
New LOCALAI_MODEL_LOAD_WAIT (default 60s) bounds the CALLER, never the
load — the job keeps running either way. On expiry the request gets 503
with Retry-After and a structured body naming the model, the node, the
phase, byte progress and an ETA. The `error` envelope keeps OpenAI
clients working; `loading` is additive so they ignore it.
The ETA comes from the job's own observed rate and is omitted rather than
guessed until enough bytes have moved for that rate to mean anything: a
confidently wrong ETA on a twenty-minute wait is worse than none.
Retry-After is that ETA when known, clamped to [5s, 300s], and the wait
budget otherwise.
LOCALAI_MODEL_LOAD_WAIT=0 waits unbounded, for deployments with no proxy
in front. Zero in the config struct still means "unset, use the default",
so the CLI records the operator's zero as ModelLoadWaitUnbounded rather
than losing the distinction.
The distributed branch of ModelLoader.loadModel wrapped the router's
error with %s, which flattened it to a string. Use %w: the typed error is
what the HTTP layer keys the 503 off.
Assisted-by: Claude Opus 5 [claude-code]
* feat(api): add GET /api/models/{id}/load-status
A client that receives 503 while a model stages onto a worker needs
somewhere to poll. This returns the same `loading` object the 503 carries
— phase, node, byte progress and ETA — or 404 when no load is running.
Read-only and observability-shaped, so it is deliberately neither
admin-gated nor feature-gated: it explains a 503 the caller just
received, and hiding that behind a per-modality feature would make the
explanation for a failed image request depend on chat permissions. It
also gets no MCP tool, since there is nothing here an admin would manage
conversationally.
Registered on the surfaces from .agents/api-endpoints-and-auth.md: the
swagger block (existing `models` tag, so /api/instructions needs no new
area), the endpoint discovery maps in RegisterLocalAIRoutes, regenerated
swagger, and the distributed-mode docs page. No FLAG_* usecase is
involved, so capabilities.js is unchanged.
Assisted-by: Claude Opus 5 [claude-code]
* feat(ui): show cold-load progress in Chat and retry when the model is ready
A chat request for a model that is still staging onto a worker now gets a
503 carrying live progress instead of an error. Render it: the composer
shows the phase (installing / staging / loading), the node, the percent
and the ETA, then polls load-status and re-sends the request the moment
the model is ready.
Reuses the staging progress idiom the page already had rather than
inventing a second one — the two sources are folded into one
loadProgress, with the load job winning because it is authoritative
across frontend replicas and knows the phase, where the staging operation
only knows about a byte transfer this replica happens to be performing.
Waiting is bounded (three send attempts, ~30 min of polling each), so a
load that never finishes still surfaces as an error rather than as a
spinner nobody questions. An aborted generation stops the polling too.
Assisted-by: Claude Opus 5 [claude-code]
* fix(distributed): check warm-path cleanup errors
The router moved legacy cleanup calls onto newly linted lines. Report
cleanup failures while preserving the fallback to a cold load.
Assisted-by: Codex:gpt-5 [golangci-lint]
---------
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
|
||
|
|
f7db51bdf5 |
feat(realtime): add shared WebRTC UDP port (#11436)
* feat(realtime): add shared WebRTC UDP port Allow realtime WebRTC peer connections to reuse one configurable UDP mux, and surface listener bind failures through signaling. Assisted-by: Codex:gpt-5 * test(realtime): keep UDP mux alive during bind check The returned SettingEngine owns the UDP listener. Retain it through the duplicate-bind assertion so macOS cannot finalize the listener early and make the exclusivity check spuriously pass. Assisted-by: Codex:gpt-5 [systematic-debugging] * test(realtime): use IPv4 for UDP mux checks Match the socket family used by the WebRTC UDP mux so macOS does not allocate an IPv6 probe that can coexist with the IPv4 listener.\n\nAssisted-by: Codex:gpt-5 --------- Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com> |
||
|
|
ab52813342 |
feat(modelartifacts): support bounded parallel Hugging Face file downloads (#11162)
* feat(modelartifacts): support bounded parallel Hugging Face file downloads Closes #11114. Snapshot materialization fetched every file through the sequential executor in DownloadFilesWithContext, so a repository split into many shards spent most of its wall clock in per-file request latency rather than moving bytes. Add DownloadFilesWithConcurrency, an errgroup with SetLimit, and keep DownloadFilesWithContext as a wrapper that passes a limit of 1. That leaves the two non-artifact callers (core/gallery and the model config loader) on exactly the path they had: tasks still run in slice order, and the first failure still returns before any later task starts. Only whole files run in parallel. A single file is never split, so the .partial resume machinery and the per-file SHA check in downloadTaskWithRetry are untouched. Two details the parallel path forced: - completedBytes becomes an atomic.Int64. Several AfterDownload hooks add to it while other files' progress callbacks read it; without this the race detector reports three races on the new specs. - The caller's status callback is serialized. The sequential path gave it an implicit guarantee of never being entered twice at once, and it belongs to the caller, so the executor keeps that promise rather than pushing locking onto every caller. AfterDownload is deliberately not serialized -- it does the verify-and-promote work that parallelism exists to overlap. Manifest order needed no work: each hook already writes its own manifest.Files slot by snapshot index, so entries stay in snapshot order whatever the completion order. A spec now pins that. The default is 1, unchanged behaviour. A shared models volume is often the bottleneck rather than the link, so raising it is a deployment decision; --artifact-download-concurrency and LOCALAI_ARTIFACT_DOWNLOAD_CONCURRENCY expose it on both `run` and `models install`. Not done here, per the issue: no chunk-level parallelism within a single file, and no throughput measurements across concurrency 1/2/4/8 -- that needs a representative sharded repo and a real link. Assisted-by: Claude:claude-opus-5 go-test gofmt Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com> * feat(modelartifacts): expose download concurrency in settings Follow-up to review feedback on #11162: - The CLI flag and docs no longer describe the limit as Hugging Face specific. It applies to any artifact source, as @mudler pointed out. - artifact_download_concurrency is now a persisted runtime setting and is editable from the WebUI, so it can be changed without a restart. The manager's limit becomes an atomic.Int64 behind SetDownloadConcurrency, because a live runtime setting can be updated while a materialization is already in flight. Injected materializers stay compatible through an optional setter interface, so a manager that does not implement it is simply left alone. Verified before taking this on: go build, go vet and go test -race all pass for pkg/modelartifacts, pkg/downloader and core/config. The React UI builds with vite, artifact_download_concurrency is present in the built Settings chunk, and eslint reports the same 8 pre-existing warnings on Settings.jsx as it does without the change. Implementation contributed by localai-org-maint-bot on the review thread; reviewed, verified and signed off by me. Assisted-by: Codex:gpt-5 Assisted-by: Claude:claude-opus-5 go-test vite eslint Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com> --------- Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com> Co-authored-by: localai-org-maint-bot <bot-opensource@localaisrl.com> |
||
|
|
8f9184fbb2 |
feat(cli): support systemd socket activation (#11169)
* feat(cli): support systemd socket activation Serve the API from a single stream listener inherited through the systemd activation protocol while retaining the existing address bind path when no listener is provided. Validate activation metadata, preserve the public-bind safety check, and document an on-demand systemd setup. Assisted-by: Codex:gpt-5 * fix(cli): satisfy listener cleanup lint Make the best-effort close explicit so errcheck accepts the deferred systemd listener cleanup. Assisted-by: Codex:gpt-5 [golangci-lint] --------- Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com> |
||
|
|
6584db992f |
fix(nodes): never schedule a model onto a node that cannot store it (#11054)
* fix(nodes): never schedule a model onto a node that cannot store it
A worker whose models filesystem was 100% full kept advertising
`status: healthy`, stayed a scheduling candidate, was picked to host a
70 GB video model, accepted the staging request, transferred ~17 GB and
only then failed:
staging .../whisper-large-v3/model.fp32-00001-of-00002.safetensors:
upload to node b7bacbf4-... failed with status 500:
writing file: /models/longcat-video-avatar-1.5/...: no space left on device
The node was at 937G/937G/0-avail. Total elapsed before the truth
surfaced: 16 minutes, for a decision that could never have succeeded.
The worker health signal only ever proved liveness. `/readyz`
(WorkerReadiness/NATSReadiness) checks the NATS link; `status: healthy`
in the registry is driven by heartbeat recency. Node capacity carried
VRAM and RAM but no disk figure at all, and the router compared model
size against VRAM only — nothing anywhere looked at free space on the
filesystem that staging actually writes to.
Report it, then use it:
- Workers now measure the filesystem backing their MODELS directory
(not `/` -- staged weights land in the models path, and that mount is
very often separate) and report `total_disk`/`available_disk` on
registration and on every heartbeat. Free disk moves faster than VRAM
under staging traffic, so the per-heartbeat refresh matters.
- The SmartRouter drops nodes that cannot store the model before it
picks one. The requirement comes from `modelPayloadBytes` -- the same
local paths `stageModelFiles` uploads, already computed for the
size-derived load budget -- plus a 5% / 1 GiB margin, rather than a
fixed percentage of the node's disk. A percentage threshold would take
a small-but-usable node out of rotation for models it could hold, and
on a homogeneous cluster would strand every node at once.
- When no node fits, scheduling fails immediately with an error naming
the requirement and each node's free space, instead of picking one and
discovering it mid-transfer.
Two deliberate non-changes. Low disk does not mark a node `unhealthy`:
the check is per model, so a node too small for one model stays a valid
target for smaller ones. And `total_disk == 0` means "does not report
disk" (pre-upgrade worker, or a failed stat), not "full" -- such nodes
pass through untouched so a rolling upgrade never empties the candidate
pool. A genuinely full node is distinguishable: non-zero total, zero
available. Registry read failures are logged and scheduling continues
unfiltered; a database hiccup must not wedge a cluster.
Free space is surfaced on the node detail page next to VRAM, since the
incident's signature was a node that looked entirely healthy.
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]
* feat(nodes): make the disk-headroom check operator-controllable
The admission check added in the previous commit had no off switch. A
scheduler-side veto with no escape hatch is a liability: our size
estimate can be wrong (deduplicating or compressing filesystems, a
backend that fetches its own weights rather than loading the staged
copy), and an operator who hits that has no way out but a downgrade.
Add one knob with two surfaces that share a single source of truth:
- `--distributed-disk-headroom-check` / `LOCALAI_DISTRIBUTED_DISK_HEADROOM_CHECK`
(default true), following the `--distributed-prefix-cache` pattern for
a default-on distributed feature.
- `distributed_disk_headroom_check` in the runtime-settings registry, so
it can be flipped without a restart from `POST /api/settings` and from
Settings -> Distributed in the WebUI.
Both write `DistributedConfig.DiskHeadroomDisabled`, and the SmartRouter
reads that member LIVE on every scheduling decision through a closure
over the application config rather than a value snapshotted at
construction. Env/CLI sets the boot value, the runtime setting overrides
it live, last write wins, and there is exactly one member to read.
Snapshotting would have made the runtime toggle a no-op until restart.
Disabled means WARN, not SKIP. Selection goes back to ignoring free disk
-- byte for byte the pre-check behaviour -- but the check still runs, and
when it would have rejected every node it says so, naming the knob that
suppressed it. Going quiet when switched off would reproduce the exact
condition that made the original incident expensive: a cluster doing
something that could not work and saying nothing. Disabling is also
logged once at startup. Warning only on the total-rejection case keeps
it actionable rather than chatty on a heterogeneous cluster.
Also fixes a false positive in the check itself: shared-models mode
(LOCALAI_DISTRIBUTED_SHARED_MODELS) stages nothing at all -- every node
already mounts this models directory at this path -- so demanding the
full checkpoint size of free space per node would have rejected a
cluster that needs no new bytes. The check is skipped there entirely.
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]
---------
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
|
||
|
|
ff299df453 |
perf(http): gzip responses, cache hashed assets, bound the trace endpoints (#11056)
Three measured HTTP-layer regressions on a live deployment, fixed together
because they all shape the bytes on the wire.
1. No compression. The server sent no Content-Encoding regardless of what
the client asked for, confirmed with curl straight at 127.0.0.1:8080 so
it was not an ingress artefact. Adds gzip middleware, on by default and
configurable via LOCALAI_DISABLE_HTTP_COMPRESSION and
LOCALAI_HTTP_COMPRESSION_MIN_LENGTH (default 1024 bytes so tiny bodies
are not wastefully wrapped). Streaming routes are skipped explicitly:
an SSE Accept header, a WebSocket upgrade, and the completion / SSE /
log-tail path prefixes, because whether a completion request streams is
decided by the request body, which the middleware runs too early to see.
Already-compressed formats (woff2, png, mp4, ...) are skipped too; gzip
made those marginally larger. Measured over the embedded React build:
JS+CSS 2815 KB raw to 808 KB gzipped (3.48x).
2. No cache headers on content-hashed assets. Vite hashes the filenames,
so a given /assets/ URL can never change content, yet they shipped with
no Cache-Control, ETag or Last-Modified, and the browser re-fetched the
whole bundle on every navigation with no conditional request available.
/assets/* now carries public, max-age=31536000, immutable. index.html
stays no-cache so a deploy is picked up, and the unhashed locale JSONs
get a short TTL rather than the immutable one.
3. Unbounded trace endpoints. /api/traces returned 21,033,606 bytes in
4.65s and /api/backend-traces 3,471,682 bytes in 1.50s, and the admin
UI polls both every few seconds. The ring buffer holds up to 1024
entries, each embedding full input_text payloads. Both list endpoints
now take limit / offset / full, default to 50 entries, and strip the
heavy fields (request and response bodies plus headers for API traces,
body and data for backend traces) unless full=true. Every trace gets a
process-lifetime ID and GET /api/traces/{id} and
/api/backend-traces/{id} serve the full record, which is what the UI
fetches when a row is expanded. The list body stays a JSON array;
paging metadata rides in X-Total-Count, X-Trace-Offset and
X-Trace-Limit. Reproducing the live shape in a test, the polled payload
goes from 21,131,097 bytes to 7,201 bytes.
Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
|
||
|
|
01fca9c9b2 |
fix(distributed): scale the remote model-load deadline with checkpoint size (#11030)
The gRPC deadline for the remote LoadModel call was a fixed 5m. It starts
only after the backend install and file staging have completed, so it
covers the worker's checkpoint read and pipeline init alone - work whose
duration is proportional to the bytes on disk. A fixed value is therefore
a model-size cliff, not a timeout.
Measured in production: a 70 GB video checkpoint (longcat-video-avatar-1.5)
on an NVIDIA Jetson Thor worker failed reproducibly with
"rpc error: code = DeadlineExceeded" after 953.5s of wall clock. Backend
install plus staging consumed ~11m, then LoadModel got its 5m and expired.
The load never had a chance, and the operator saw only a generic
DeadlineExceeded with no hint that a config value was the cause.
Raising the constant does not fix this. It moves the cliff to the next
larger model - the cluster has to support 600 GB checkpoints - and it makes
a genuinely wedged SMALL model hang for the whole inflated duration before
anyone notices, which is a real regression in failure latency.
So derive the budget from the checkpoint size instead:
budget = 5m + 20s/GiB, capped at 6h
2 GiB -> 5m40s, 70 GiB -> 28m20s, 600 GiB -> 3h25m. The per-GiB rate is
deliberately pessimistic (~54 MB/s of weight read) because the errors are
not symmetric: too long costs only failure latency on a load that was going
to fail anyway, too short is a guaranteed false failure on a healthy load.
The size is measured from the frontend's local model files, over the same
path set stageModelFiles uploads. When those files are not present locally -
a backend handed a bare HuggingFace repo id fetches its own weights on the
worker - there is nothing to measure and the budget stays at today's 5m.
An explicit LOCALAI_NATS_MODEL_LOAD_TIMEOUT still wins outright, in both
directions: a shorter override is honoured, so an operator who wants fast
failure is not silently extended by the heuristic.
The cold-load hold needed widening to match. It extends on staging progress,
but LoadModel reports none, so once the last byte lands the hold expires a
stall window later and would cancel a load still well inside its own budget.
scheduleAndLoad now extends the hold by the load budget plus the staging
margin as it enters the load phase; ModelLoadCeilingFor stays the hold's
starting budget rather than its maximum.
Finally, a deadline that does expire now names the budget, the checkpoint
size it was derived from, and the knob that overrides it, instead of
surfacing a bare "context deadline exceeded".
Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
|
||
|
|
fb4c61d1c9 |
fix(distributed): configurable remote model-load timeout, and reap the load when it times out (#10948)
* fix(distributed): make the remote LoadModel deadline configurable
The router hardcoded a 5 minute gRPC deadline for the remote LoadModel
call. Staging finishes before the timer starts, so those five minutes
cover only the worker backend's own checkpoint load and pipeline init.
A cold load of meituan-longcat/LongCat-Video-Avatar-1.5 (~83 GB) on an
ARM64 Thor worker fails at exactly 302s with DeadlineExceeded while the
backend process is still making progress (CPU time accumulating, RSS
moving as weights are mapped), so the load was cut short rather than
wedged.
Add LOCALAI_NATS_MODEL_LOAD_TIMEOUT / --model-load-timeout mirroring the
existing backend-install timeout knob, defaulting to 5m so unset
clusters keep today's behaviour.
The cold-load hold ceiling (which bounds how long one load may hold the
per-model advisory lock) was derived from the install timeout alone, so
raising the load deadline past it would have been silently clipped.
Derive it from both budgets via ModelLoadCeilingFor:
max(install + load + 5m staging margin, 25m)
With the defaults that is 15m + 5m + 5m = 25m, identical to the previous
constant, and the 25m floor means shrinking either budget can never
tighten the ceiling below what clusters relied on before.
Assisted-by: Claude:claude-opus-4-8 golangci-lint
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
* fix(distributed): reap the abandoned replica when a remote load times out
The gRPC deadline on the remote LoadModel call only cancels the client
side. A backend blocked in a synchronous weight load never observes its
cancelled handler context, so when scheduleAndLoad gave up it left the
worker loading with nobody waiting for the result.
Observed on an ARM64 Thor worker loading LongCat-Video-Avatar-1.5: the
client returned DeadlineExceeded at 302s, and the backend process was
still alive 30 minutes later having pulled ~57GB from HuggingFace. Every
retry stacked another multi-GB loader on the worker; they had to be
reaped by hand via POST /api/nodes/:id/models/unload.
Send backend.stop for the exact `modelID#replicaIndex` process key we
just abandoned. The exact key matters: a bare model ID stops every
replica on that node, including healthy ones serving traffic.
Only a deadline or cancellation triggers the reap. Any other LoadModel
failure is the backend answering, which means its handler returned and
the process is idle - stopping it there would discard a warm process and
its downloaded weights. The reap is best-effort and never replaces the
load error the caller is waiting on.
The `modelID#replicaIndex` format was already hand-rolled in two places
(the worker's buildProcessKey and pkg/model's log store). Rather than add
a third, export model.BackendProcessKey from pkg/model, the lowest common
dependency of both sides.
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 golangci-lint
---------
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
|
||
|
|
c1a891662c |
refactor(settings): single declarative registry for runtime settings (fixes the #10845 bug class) (#10864)
* feat(settings): add declarative runtime-settings field registry One fieldSpec row per RuntimeSettings field, with a reflection completeness spec so a field added without a registry row is a red test instead of a silently-dropped setting (the #10845 bug class). Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-fable-5 * refactor(settings): drive ToRuntimeSettings/ApplyRuntimeSettings from the field registry Behavior-preserving: ~350 hand-written per-field lines become two loops over runtimeSettingsFields, gated by a To->Apply->To round-trip spec. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-fable-5 * feat(settings): baseline-driven startup merge for persisted runtime settings ApplyRuntimeSettingsAtStartup compares the live config against DefaultRuntimeBaseline (option-less-run defaults incl. kong-injected flag defaults) instead of per-field == 0 guards. Fixes persisted lru_eviction_max_retries, tracing_max_items, agent_job_retention_days, memory_reclaimer_threshold, galleries and autoload flags being silently ignored at boot. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-fable-5 * fix(settings): registry-driven startup merge, applied before consumers loadRuntimeSettingsFromFile becomes a thin wrapper over ApplyRuntimeSettingsAtStartup and runs at the top of New(), before model configs capture app-level defaults. WithThreads stops eagerly resolving 0 so a persisted thread count survives restart while LOCALAI_THREADS still wins (#10845); the physical-core fallback moves after the merge. Also: run.go now injects the memory-reclaimer threshold unconditionally so the option-less boot matches DefaultRuntimeBaseline and a UI-saved threshold survives restart. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-fable-5 * refactor(settings): file watcher delegates to the registry merge; shared API-key merge Manual edits to runtime_settings.json now behave like a boot-time load (env still wins) instead of the inverted diverged-from-startup guard that ignored most manual edits. MergeAPIKeys dedups env keys in one place for the endpoint and the watcher. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-fable-5 * docs(settings): document unified runtime-settings precedence Document the single env/CLI > runtime_settings.json > defaults rule, applied identically at boot, on POST /api/settings, and on manual file edits, plus the two known limitations (default-valued env vars are indistinguishable from unset; API-changed fields hot-apply on the next restart only). Also add a completion debug log when the watcher applies runtime_settings.json. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-fable-5 * test(settings): reset the global VRAM cap leaked by the round-trip spec The round-trip spec applies vram_budget=12GiB, whose post-loop hook installs a process-global default cap; without a reset every spec ordered after it runs under that phantom budget. Also drop a stale enumeration in the ApplyRuntimeSettings doc comment. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-fable-5 --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io> |
||
|
|
8cec22c3b7 |
feat(vram): per-node VRAM allocation budget (LOCALAI_VRAM_BUDGET) (#10833)
* feat(vram): add vrambudget primitive for per-node VRAM caps Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(vram): apply default VRAM budget in xsysinfo aggregate getters Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(vram): wire LOCALAI_VRAM_BUDGET flag to xsysinfo default budget Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(vram): persist VRAM budget via runtime settings with live apply Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(vram): reset process-global VRAM budget after runtime-settings spec Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(vram): add VRAM budget field to Settings page Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(vram): store and enforce per-node VRAM budget in the node registry Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(vram): apply per-node VRAM budget in router hardware defaults Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(vram): report worker VRAM budget in node registration The distributed worker now reports its operator-set VRAM budget string (LOCALAI_VRAM_BUDGET) to the server on registration. The worker keeps reporting RAW total/available VRAM and never sets the xsysinfo process-global budget (that stays standalone-only); the server resolves and enforces the budget uniformly (Task 6). Also closes a Task 6 gap: on re-registration, a struct Updates zero-skips an empty budget, so a worker that dropped LOCALAI_VRAM_BUDGET left the stale cap in place. For non-admin-override nodes the budget columns are now force-written (map Updates) even when empty, so removing the env var clears the cap; admin overrides are preserved unchanged. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * style(vram): drop em dash from worker-clear comment Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(vram): add node VRAM budget admin endpoints Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(vram): add node VRAM budget control to the node UI Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(vram): expose set_node_vram_budget MCP admin tool Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(vram): document LOCALAI_VRAM_BUDGET and node VRAM budget UI Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(vram): avoid double-applying VRAM budget in GetResourceAggregateInfo The GPU-branch aggregate returned by GetResourceInfo is sourced from GetGPUAggregateInfo, which already caps total/free/used against the process-wide VRAM budget. GetResourceAggregateInfo then applied the budget a second time. For an absolute budget this is idempotent, but for a percentage budget b.Apply resolves the ceiling as a fraction of its input total, so a second pass yields P*(P*T) instead of P*T and distorts UsagePercent (read by the memory reclaimer in pkg/model/watchdog.go). Remove the redundant second application so the budget is applied exactly once, against the raw physical totals, upstream in GetGPUAggregateInfo. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(vram): implement SetNodeVRAMBudget on mcp assistant test stub The LocalAIClient interface gained SetNodeVRAMBudget; the stubClient in core/http/endpoints/mcp used by the assistant tests is a separate implementer and needs the method too (broke golangci-lint typecheck and both test jobs). 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> |
||
|
|
bcc41219f7 |
feat: materialize Hugging Face model artifacts (#10825)
* feat(config): add model artifact source contract Assisted-by: Codex:GPT-5 [Codex] * feat(downloader): add authenticated raw-byte progress Assisted-by: Codex:GPT-5 [Codex] * feat(huggingface): resolve immutable snapshot manifests Assisted-by: Codex:GPT-5 [Codex] * feat(models): add artifact storage primitives Assisted-by: Codex:GPT-5 [Codex] * feat(models): materialize pinned Hugging Face snapshots Assisted-by: Codex:GPT-5 [Codex] * feat(models): bind managed snapshots at runtime Assisted-by: Codex:GPT-5 [Codex] * feat(gallery): materialize model artifacts during install Assisted-by: Codex:GPT-5 [Codex] * feat(gallery): declare managed Hugging Face artifacts Assisted-by: Codex:GPT-5 [Codex] * feat(models): preload managed model artifacts Assisted-by: Codex:GPT-5 [Codex] * fix(gallery): retain shared artifact caches on delete Assisted-by: Codex:GPT-5 [Codex] * feat(models): report artifact acquisition progress Assisted-by: Codex:GPT-5 [Codex] * refactor(backends): load managed models from ModelFile Assisted-by: Codex:GPT-5 [Codex] * refactor(backends): load staged speech model snapshots Assisted-by: Codex:GPT-5 [Codex] * refactor(backends): use staged snapshots in engine backends Assisted-by: Codex:GPT-5 [Codex] * test(distributed): cover staged artifact snapshots Assisted-by: Codex:GPT-5 [Codex] * docs: explain managed model artifacts Assisted-by: Codex:GPT-5 [Codex] * docs: add product design context Assisted-by: Codex:GPT-5 [Codex] * feat(ui): show model artifact download progress Assisted-by: Codex:GPT-5 [Codex] * Eagerly materialize Hugging Face artifacts Materialize HF-backed model references as managed GGUF artifacts during load, with lazy download retained only as fallback. Assisted-by: Codex:GPT-5 [shell] * Refactor HF downloads through a shared executor Assisted-by: Codex:GPT-5 [shell] * drop 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> |
||
|
|
97175f4b5a |
feat(model): debounce model loads after a failure to stop retry-storms (#10728)
A client that keeps polling a model whose load fails (e.g. a backend that crashes deterministically on init) triggered a fresh backend start on every request: request -> load -> crash in ~10s -> 500, repeat on the next poll. Each attempt could leak GPU/CUDA state, and under LOCALAI_SINGLE_ACTIVE_BACKEND it kept stealing the active slot from healthy models. The existing loading-coalesce map only dedups *concurrent* loads, so sequential polls were never covered. Track load failures per modelID in ModelLoader. After a load fails, refuse fresh load triggers for that model until a cooldown elapses, returning a typed ModelLoadCooldownError that the HTTP layer maps to 503 with a Retry-After header. The cooldown grows exponentially per consecutive failure (base, doubling, capped at 5m) and resets on a successful load. The coalesced follower-retry of an in-flight burst bypasses the gate, so a genuinely concurrent burst still gets its one retry -- only new, independent triggers are refused, matching the report's "refuse new load-triggers" wording. Configurable via --model-load-failure-cooldown / LOCALAI_MODEL_LOAD_FAILURE_COOLDOWN (default 10s, 0 disables), plumbed through ApplicationConfig and applied unconditionally at startup. Closes #10719 Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io> |
||
|
|
461ae84732 |
fix(startup): scope generated-content and upload dirs to the current user (#10698)
The `--generated-content-path` and `--upload-path` defaults were the fixed
shared locations `/tmp/generated/content` and `/tmp/localai/upload`. On any
multi-user host these collide across accounts: macOS routes `/tmp` to the
shared `/private/tmp` for every user, so whichever account starts LocalAI
first creates the parent with 0750 perms and every other account then fails
startup with:
unable to create ImageDir: "mkdir /tmp/generated/content: permission denied"
unable to create UploadDir: "mkdir /tmp/localai/upload: permission denied"
The same happens on Linux once a stale root-owned `/tmp/generated` (e.g. from
a prior `sudo` run) is left behind. This bites the desktop launcher and any
app embedding the raw binary (Wingman, nib-desktop), which start `local-ai
run` with no path flags.
Default both paths under the OS temp dir (`os.TempDir()`, honoring `$TMPDIR`;
already per-user on macOS) namespaced by the current UID
(`TMPDIR/localai-<uid>/...`), so accounts never collide while the paths stay
ephemeral. Wired via new kong vars in main.go so every consumer of the raw
binary inherits the fix. All content subdirs (audio, images) derive from
`GeneratedContentDir`, so they are fixed transitively.
As defense in depth, the launcher also anchors these two paths under its own
per-user data directory (mirroring the #10610 fix for data/config), extracted
into a testable `BuildRunArgs`.
Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
|
||
|
|
f3d829e2ef |
feat(distributed): add LOCALAI_DISTRIBUTED_SHARED_MODELS to skip staging on shared volumes (#10556) (#10566)
In distributed mode, even when the frontend and workers share the same models directory via a shared volume mount, starting a model on a worker re-staged (re-downloaded) it: stageModelFiles always uploads model files into a tracking-key-namespaced subdir on the worker, and the staging probe only checks that staged location, so a file already present on the shared volume at the canonical path was never reused. Add a config switch LOCALAI_DISTRIBUTED_SHARED_MODELS (default false). When enabled, the operator asserts that all nodes mount the SAME models directory at the SAME path, so staging is unnecessary: the frontend's absolute model paths are already valid on the worker. In that mode stageModelFiles returns the cloned opts unchanged without uploading, leaving the path fields pointing at their canonical absolute paths so the worker loads them directly from the shared volume. The value is plumbed from DistributedConfig through SmartRouterOptions into the SmartRouter. Docs and docker-compose.distributed.yaml updated. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io> |
||
|
|
114eeaae81 |
feat(backends): make PreferDevelopmentBackends install the development image as primary (#10520)
When LOCALAI_PREFER_DEV_BACKENDS is set, install the -development image as the primary backend URI (keeping the released image reachable as the first fallback), instead of only reaching development as a download fallback when the released image is missing. This lets an operator force backends built from the development branch — e.g. to pick up a fix already on master before a release. Threads PreferDevelopmentBackends through SystemState so InstallBackend can see it, and reuses the same development-URI convention as the existing failure-path fallback (released tag -> branch tag + dev suffix). The unexported developmentURI helper is covered by a Ginkgo spec. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io> |
||
|
|
fe4f425fb5 |
fix: correct scheme/host on self-referential URLs behind an HTTPS reverse proxy (#10482) (#10504)
* fix(http): harden BaseURL proxy scheme/host detection Split comma-separated X-Forwarded-Proto and honor the RFC 7239 Forwarded header so generated links use https behind common reverse-proxy setups. Refs #10482 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(http): honor explicit external base URL in BaseURL When _external_base_url is set in the request context it dictates the origin (scheme+host+port); the proxy path prefix is still appended. Refs #10482 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(config): generalize LOCALAI_BASE_URL to ExternalBaseURL LOCALAI_BASE_URL now sets a single instance-wide external base URL used for OAuth callbacks and all self-referential links. A Pre middleware stamps it into the request context for middleware.BaseURL. Refs #10482 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs: document LOCALAI_BASE_URL and reverse-proxy headers Refs #10482 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(http): cover parseForwarded edge cases; clarify base-url flag group Adds direct unit coverage for quoted/malformed/multi-element Forwarded headers and regroups the external base URL flag away from auth-only. Refs #10482 Assisted-by: Claude:claude-opus-4-8 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> |
||
|
|
e1994579f8 |
fix(pii): load default detectors at startup + add LOCALAI_PII_DEFAULT_DETECTORS (#10474)
pii_default_detectors was applied to the live config only by a live POST /api/settings (ApplyRuntimeSettings) — neither the startup loader nor the config file watcher read it back. So after a restart the persisted default detectors were dropped, and the cloud-proxy MITM listener (which resolves each intercept host's detectors once at start via ResolvePIIPolicy) came up with an empty set and forwarded intercepted traffic unredacted, even though the MITM model had pii.enabled:true and the defaults were on disk. Request-side default redaction broke the same way. - startup.go: loadRuntimeSettingsFromFile now applies pii_default_detectors, before startMITMIfConfigured, with env > file precedence. - config_file_watcher.go: apply pii_default_detectors on live file edits, matching the existing env-guard pattern used for the other fields. - settings endpoint: rebuild the MITM listener when pii_default_detectors changes (its per-host detector map is frozen at listener start), not only on a mitm_listen change — so toggling a default detector takes effect on cloud-proxy traffic immediately. - new LOCALAI_PII_DEFAULT_DETECTORS env var / CLI flag (WithPIIDefaultDetectors) so the default detector set can be pinned at boot for immutable deployments. Assisted-by: Claude:claude-opus-4-8 Claude-Code Signed-off-by: Richard Palethorpe <io@richiejp.com> Co-authored-by: Ettore Di Giacinto <mudler@users.noreply.github.com> |
||
|
|
b50b1fe418 |
feat(watchdog): add size-aware LRU eviction mode (#9527)
* feat(watchdog): add size-aware LRU eviction mode When the model count hits the LRU limit or the memory reclaimer fires, evict the largest model by on-disk file size first rather than the least-recently-used one. For GGUF models the file size is a reliable proxy for GPU/RAM footprint, so evicting the largest candidate maximises freed memory per eviction round while keeping small utility models (embeddings, classifiers, rerankers) resident. Changes: - `pkg/model/watchdog.go`: add `sizeAwareEviction` flag and `modelSizes map[string]int64` to `WatchDog`; sort candidates by `sizeBytes` desc (LRU time as tiebreaker) when the flag is set; add `RegisterModelSize`, `SetSizeAwareEviction`, `GetSizeAwareEviction` - `pkg/model/watchdog_options.go`: add `WithSizeAwareEviction` option - `pkg/model/initializers.go`: stat model file after load and call `RegisterModelSize` so size data is available before the first eviction - `core/config/application_config.go`, `runtime_settings.go`: add `SizeAwareEviction` field and `WithSizeAwareEviction` app option; expose via `ToRuntimeSettings` / `ApplyRuntimeSettings` for the `POST /api/settings` live-reload path - `core/cli/run.go`: add `--size-aware-eviction` flag / `LOCALAI_SIZE_AWARE_EVICTION` env var - `core/application/startup.go`, `watchdog.go`: wire the new option through to `NewWatchDog` - `pkg/model/watchdog_test.go`: 5 new specs — option enable, dynamic toggle, largest-first ordering, equal-size LRU tiebreaker, no-size fallback to LRU, and size-map cleanup on eviction Closes #9375 Signed-off-by: supermario_leo <leo.stack@outlook.com> * refactor(watchdog): use vram estimation scaffolding for model size Replace the brittle os.Stat(modelFile) approach with a proper call to pkg/vram, which handles multi-file models (DownloadFiles, MMProj) and all weight file types, not just single GGUF files. - Add estimateModelSizeBytes() in core/backend/options.go that collects all weight file URIs from the model config, resolves them to file:// URIs, and calls vram.Estimate() with the shared DefaultCachedSizeResolver (15-min TTL cache avoids redundant stat calls on repeated loads) - Thread the result through via a new WithModelSizeBytes() loader option - In initializers.go, consume the pre-computed size instead of calling os.Stat; if no size was supplied (e.g. for external/router-dispatched models) the registration is simply skipped Signed-off-by: supermario_leo <leo.stack@outlook.com> * refactor(watchdog): use EstimateModel with HF fallback for size estimation Switch estimateModelSizeBytes from calling vram.Estimate directly to the unified vram.EstimateModel entry point, which adds automatic fallbacks: file-based GGUF metadata → HF API → size string. Also extract the HuggingFace repo ID from model URIs (huggingface://, hf://, https://huggingface.co/ and org/model short-form) and pass it as ModelEstimateInput.HFRepo, so models not yet downloaded locally can still get a size estimate via the HF API. Addresses @mudler's review feedback: "better to rely on EstimateModel and pass by the HF URL of the model extracted from the URI". Signed-off-by: supermario_leo <leo.stack@outlook.com> * feat(webui): add Size-Aware Eviction toggle to settings page The size-aware eviction setting was wired through the CLI flag and the RuntimeSettings live-reload path (POST /api/settings) but had no handle on the React settings page, so it could not be toggled from the UI. Add a Size-Aware Eviction toggle to the Watchdog section, next to the existing Force Eviction When Busy / LRU eviction handles. The settings page loads and saves the whole RuntimeSettings object, so the new size_aware_eviction key is picked up with no extra plumbing. Addresses @mudler's review feedback: the application config setting should land on the same UI settings page as the other handles. Signed-off-by: supermario_leo <leo.stack@outlook.com> --------- Signed-off-by: supermario_leo <leo.stack@outlook.com> |
||
|
|
7637f8cf1b |
feat(distributed): declarative per-model scheduling via env/args (#10308)
* feat(distributed): add SpreadAll column and authoritative scheduling seeding Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(distributed): parse declarative model scheduling config (env/file) Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(distributed): reconcile spread_all to one replica per matching node Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(distributed): wire LOCALAI_MODEL_SCHEDULING env/args and startup seeding Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(distributed): expose spread_all on the scheduling API endpoint Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(distributed): add spread-to-all-nodes mode to the scheduling UI Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(distributed): document LOCALAI_MODEL_SCHEDULING env/args Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(distributed): clarify replica modes and all-nodes spread in scheduling config 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> |
||
|
|
b203b32e57 |
feat(realtime): make WebRTC ICE candidates configurable (#10231)
The /v1/realtime WebRTC handler created the peer connection with a bare webrtc.Configuration and no SettingEngine, so pion gathered a host ICE candidate for every local interface. Under Docker host networking that includes bridge addresses (docker0/veth, 172.x) a remote browser cannot route to; the call establishes on a good pair and then drops once ICE consent freshness checks fail on the unreachable candidates. Add two opt-in knobs, applied via a pion SettingEngine: - LOCALAI_WEBRTC_NAT_1TO1_IPS: advertise these IPs as the host candidates (e.g. the host LAN IP) - LOCALAI_WEBRTC_ICE_INTERFACES: restrict ICE gathering to these interfaces Defaults are unchanged (empty => current all-interface behavior). Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io> |
||
|
|
8344d1c865 |
feat(cli): add interactive chat mode (#10226)
Add an opt-in `local-ai chat` command for testing chat models directly from the terminal without manually sending curl requests. The command connects to a running LocalAI server, lists available models through the existing OpenAI-compatible API, streams chat completions, and supports interactive commands such as `/models`, `/model`, `/clear`, and `/exit`. Keep `local-ai run` focused on the server lifecycle so the web UI, API clients, and multiple chat terminals can coexist against the same server. Document the new command and terminal workflow in the README and CLI docs. Tests: - go test -count=1 ./core/cli/chat - go test -count=1 ./core/cli Assisted-by: Codex:GPT-5 Signed-off-by: Ching Kao <0980124jim@gmail.com> |
||
|
|
73385713ca |
feat(distributed): enforce registration token for worker file transfer (#10183)
The worker HTTP file-transfer server is authenticated by the registration token via checkBearerToken, which fails open on an empty token: every /v1/files, /v1/files-list and /v1/backend-logs request is then served unauthenticated, granting read/write to the worker's models/staging/data directories. The fail-open was also silent (the only auth log sat on the unreachable reject branch), and the worker process never runs DistributedConfig.Validate(), so the existing frontend warning did not cover the component that exposes the server. Mirror the NatsRequireAuth pattern: keep anonymous as the default but make it loud and opt-in enforceable. - Log a prominent warning when the file-transfer server starts tokenless. - Add LOCALAI_REGISTRATION_REQUIRE_AUTH: DistributedConfig.Validate() errors on an empty token (frontend) and the worker refuses to start (fail-fast, before registration), so production can fail closed. Also satisfies the F-003 suggestion to fail Validate() on distributed + empty token. - Add LOCALAI_DISTRIBUTED_REQUIRE_AUTH umbrella switch implying both RegistrationRequireAuth and NatsRequireAuth — one production knob locking down the registration/file-transfer layer and the NATS bus together; the granular flags remain available as single-layer overrides. Wired into the frontend, supervisor worker, and agent worker (vLLM worker has neither a NATS connection nor a file-transfer server, so it is left untouched). - Document in distributed-mode.md (warning callout + flag tables). Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> |
||
|
|
3a932a9803 |
feat(distributed): Add NATS JWT authentication and TLS/mTLS options (#10159)
* feat(distributed): NATS JWT auth, TLS/mTLS options, and e2e coverage Mint per-node NATS user JWTs at registration when LOCALAI_NATS_ACCOUNT_SEED is set, and connect workers with scoped credentials from the register response. Add optional LOCALAI_NATS_TLS_CA/CERT/KEY for private CA and mTLS alongside tls:// URLs, plus test-e2e-distributed and NatsJWT container e2e specs. Document JWT setup (nats-auth-setup.sh) and TLS env vars in distributed-mode. Assisted-by: Grok:grok grok-build Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(distributed): correct NATS JWT scoping and harden client auth The JWT-auth path added in 46467cc7 had several gaps that fail silently under LOCALAI_NATS_REQUIRE_AUTH: - Agent-worker minted JWTs did not allow the subjects the agent worker actually subscribes to (jobs.mcp-ci.new and nodes.<id>.backend.stop), so MCP-CI jobs and backend-stop session cleanup were silently dropped. Scope the agent permission set to those subjects. - NATS subscription permission violations were swallowed (Subscribe returned a live-but-dead subscription). Confirm subscriptions with a server round-trip so a denial surfaces synchronously, and log async permission errors. - The backend worker connected anonymously when given a JWT without its paired seed; reject the unpaired credential instead. - The documented service-user permissions in nats-auth-setup.sh omitted prefixcache.>, which the frontend publishes and subscribes; add it. Also: add a credential-provider hook to the messaging client (consumed by the follow-up credential-lifecycle change), drop the always-nil error from NatsMessagingOptions, run go mod tidy (jwt/v2 and nkeys are now direct), and gofmt the feature's files. Tests: an agent-JWT e2e spec that connects to the enforcing NATS server and exercises every subscription the agent worker makes, plus permission allow-list coverage unit tests. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(distributed): acquire and auto-refresh worker NATS credentials Workers fetched NATS credentials once at startup, which broke two cases under JWT auth: a worker that registered while still pending admin approval never received a minted JWT (it connected unauthenticated and gave up), and a long-running worker's 24h JWT expired with no way to renew it. Introduce workerregistry.NATSCredentialManager, built on idempotent re-registration (the frontend preserves the node row and mints a fresh JWT each call): - Acquire re-registers through admin approval until the node is approved and credentials are minted (or returns the first success when auth is not required, preserving anonymous-NATS behavior). - RefreshLoop re-registers before the JWT expires (~75% of its lifetime), updating the credentials served to the connection. - Both are bounded (default 100 attempts / consecutive failures) and return an error on exhaustion, so an unapprovable or unrenewable worker exits non-zero and surfaces the problem instead of hanging or drifting toward an expired credential. The messaging client gains WithUserJWTProvider, fetching credentials on each (re)connect so the connection transparently adopts a refreshed JWT when the server expires the old one. RegisterFull exposes the approval status and full response; Register delegates to it. Both the backend worker and the agent worker are wired to this: explicit env credentials are used as-is, minted credentials are acquired-with-wait and refreshed, and a permanent refresh failure shuts the worker down so it restarts and re-acquires. Tests cover Acquire (wait-through-pending, bounded give-up, context cancel), RefreshLoop (refresh-before-expiry, bounded failure, no-expiry exit) and jwtExpiry decoding. Docs updated in distributed-mode.md. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> --------- Signed-off-by: Richard Palethorpe <io@richiejp.com> |
||
|
|
a44bdb29d4 |
feat: prefix-cache-aware routing for distributed mode (#10071)
* feat(radixtree): generic prefix tree skeleton with longest-match Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(radixtree): Insert with path recency refresh and entry cap Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(radixtree): TTL idle-expiry and Evict sweep with branch pruning Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(radixtree): recency-weighted per-value Weight Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(radixtree): Remove all entries for a value Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(radixtree): race-free concurrency smoke test Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(radixtree): reclaim empty branches, RWMutex reads, TTL boundary, empty-key guard Address review findings on the generic prefix tree: - Extract a shared pruneWalk helper parameterized by a shouldClear predicate and use it from Evict, Remove, and the MaxEntries path. Previously evictOldestLocked cleared a victim's value but never removed the now value-less node or its childless ancestors, so internal nodes accumulated under sustained churn at the cap. The MaxEntries path now prunes the victim and its empty ancestors. - DRY: pruneWalk replaces the duplicated logic in the former pruneLocked and Remove's inner closure. - Switch Tree.mu to sync.RWMutex; LongestMatch, Weight and Len take the read lock (RLock) while Insert, Evict and Remove keep the write lock. Confirmed race-clean under go test -race. - Document the strict greater-than TTL boundary on Options.TTL and expired: age exactly equal to TTL is still live. - Guard Insert against an empty key (no-op): the root never holds a value. Adds Ginkgo specs covering MaxEntries eviction, ancestor reclamation, the no-growth-past-cap invariant, the TTL boundary, and empty-key behavior for both Insert and LongestMatch. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(prefixcache): RoutePolicy enum with parse/resolve Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(prefixcache): Config with defaults and validation Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(prefixcache): deterministic xxhash prefix-chain extractor Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(prefixcache): pure filter-then-score replica selection Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(prefixcache): Provider interface and radix-tree-backed Index Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * style(prefixcache): gofmt policy enum comment alignment Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(prefixcache): head-first prefix chunking and hoist Weight out of sort Address code-quality review findings in the prefixcache package. Correctness: ExtractChain now chunks from absolute offset 0 with fixed [0,W),[W,2W),... boundaries and caps the chain to the FIRST MaxDepth head blocks. The previous tail-keeping logic shifted the byte offset by a non-window amount once a conversation grew past MaxDepth*WindowBytes, changing every hash each turn and silently breaking cross-turn longest-prefix matching. The reusable KV/prefix cache lives at the head of the prompt, so anchoring at offset 0 makes the chain a true prefix-chain: P and P+suffix share their full leading overlap. Add a regression spec proving cross-turn stability past the cap. Performance: Index.Decide precomputes each candidate's Weight once (decorate-sort-undecorate) instead of calling the O(tree size) Weight inside the O(n log n) sort comparator. Behavior is unchanged. Lint: encode prev with binary.LittleEndian.PutUint64 instead of a manual byte loop, clearing the modernize rangeint finding. Also add a concurrent Decide/Observe/Invalidate spec to exercise Index's documented concurrency safety under go test -race. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(messaging): prefixcache observe/invalidate subjects and payloads Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(prefixcache): NATS sync publish/apply for observe and invalidate Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(distributedhdr): ctx carrier for prefix-hash chain Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(distributedhdr): PrefixChainHook indirection for backend-side chain build Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(backend): stash prompt prefix chain on ctx before distributed routing Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(backend): mirror modelID fallback for prefix-chain salt parity Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(nodes): scheduling config columns for prefix-cache routing Add RoutePolicy and per-model balance/prefix-match override columns to ModelSchedulingConfig and include them in the SetModelScheduling upsert DoUpdates list so updates are not dropped on conflict. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(nodes): optional route preference in FindAndLockNodeWithModel Add a RoutePreference type and a new pref parameter so the atomic pick+lock+increment can be biased toward a preferred node without weakening atomicity. A nil preference reproduces the previous ORDER BY behavior exactly. Update the ModelRouter interface, both router.go call sites (pass nil for now; Phase 5 builds the real preference), the test doubles, and the distributed e2e caller. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(prefixcache): make Sync satisfy Provider with Evict Sync.Observe now returns whether the local index treated the assignment as new or extended, and Sync gains an Evict method that delegates to the wrapped index. Together these let SmartRouter hold a single prefixcache.Provider that broadcasts via NATS. Adds a compile-time Provider assertion and an Evict-delegates behavioral test. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(nodes): prefix-cache-aware preference and observe in SmartRouter.Route Add a PrefixProvider + PrefixConfig to SmartRouterOptions/SmartRouter (nil keeps routing byte-for-byte the round-robin floor). On each request Route now calls buildPreference: it reads the prompt prefix chain from ctx (distributedhdr.PrefixChain), resolves the per-model policy/thresholds over the global config, loads candidate replica in-flight via a new registry read LoadedReplicaStats (deduped to one entry per node using the MIN in-flight across that node's replicas), asks the provider to Decide, and runs prefixcache.Select. The chosen node is passed as the RoutePreference to FindAndLockNodeWithModel on all three pick paths (cache hit, locked re-pick, cold scheduleAndLoad), and the served node is recorded via Observe only when the resolved policy is prefix_cache so round-robin models never pollute the tree. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(nodes): invalidate prefix-cache entries on unload and stale removal UnloadModel and both staleness fall-through paths in Route (after a failed gRPC probe and RemoveNodeModel) now call prefixProvider.Invalidate(model, nodeID), guarded by a nil-provider check so the round-robin floor is unchanged. At runtime the provider is the *prefixcache.Sync, so invalidations also broadcast to peer frontends. Adds a test that a previously hot prefix no longer Decides to a node after UnloadModel. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(prefixcache): rolling forced-disturb pressure counter Add a concurrency-safe per-model rolling counter that tracks how many times a request had a usable hot prefix match but the load guard forced it off the warm node. Entries outside the window are dropped lazily on Count so the backing slice stays bounded. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(nodes): autoscale on prefix-cache forced-disturb pressure Wire the rolling forced-disturb counter into the SmartRouter and the ReplicaReconciler. Router: in buildPreference, after Decide + Select, record a forced-disturb when a usable hot prefix match existed (d.HotNodeID != "" and d.MatchRatio >= cfg.MinPrefixMatch) but Select chose a different node (or nothing) because the load guard ruled the warm node out. This is the scale-worthy signal: the cache-warm replica is saturated. It deliberately does not fire for all-unique workloads (no hot match), avoiding false-positive scale-ups. Pressure is optional on SmartRouterOptions; nil keeps the path a no-op. Reconciler: read the same Pressure instance in reconcileModel as an extra scale-up reason, reusing the existing MaxReplicas + ClusterCapacityForModel guards and the UnsatisfiableUntil cooldown that gates the whole method. Pressure never overrides MaxReplicas and never force-evicts; a no-capacity model does not spin. Window and threshold come from prefixcache.Config (PressureWindow default 1m, PressureScaleThreshold default 1) and are configurable via ReplicaReconcilerOptions. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(prefixcache): bound Pressure slice in Record; drop dead reconciler pressureWindow Record now prunes entries older than the rolling window (the same prune Count does), via a shared pruneLocked helper, so a model that takes forced-disturb records but is never Counted (e.g. one with zero loaded replicas the reconciler skips) no longer grows its backing slice unbounded. Also removes the dead pressureWindow struct field and the ReplicaReconcilerOptions.PressureWindow option from the reconciler: they were stored but never read (the window lives inside the *prefixcache.Pressure instance). The scale block now reads pressure.Count once into a local. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(api): prefix-cache fields in scheduling endpoint DTO with validation Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): prefix-cache routing controls in node scheduling form Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(distributed): wire prefix-cache index, NATS sync, and config Activates prefix-cache-aware routing in distributed mode. Builds the prefixcache Index + NATS-backed Sync + Pressure counter, installs the distributedhdr.PrefixChainHook so core/backend/llm.go attaches a prefix chain per request, subscribes to prefixcache.observe/prefixcache.invalidate to apply peers' events to the local index (no re-broadcast), threads PrefixProvider/PrefixConfig/Pressure into the SmartRouter and Pressure/PressureThreshold into the ReplicaReconciler, and runs a background eviction ticker (every TTL/2) bound to the app context. Enabled by default; --distributed-prefix-cache=false (LOCALAI_DISTRIBUTED_PREFIX_CACHE) opts out and leaves the provider/pressure nil so routing stays round-robin. --distributed-prefix-cache-ttl (LOCALAI_DISTRIBUTED_PREFIX_CACHE_TTL, default 5m) controls entry idle-timeout and eviction cadence. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(nodes): round-robin-floor invariant for prefix-cache routing Drives Select directly: a saturated hot node (in_flight 50 vs 0) is never picked even with a perfect prefix match (round-robin floor holds), while a balanced hot node within the load slack is reused. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore(prefixcache): clear branch lint findings and em dashes Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(distributed): validate prefix-cache config at startup wiring Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * perf(radixtree): single-walk WeightsFor for batch value weights Add Tree.WeightsFor(values, now) which computes the recency-weighted weight for many values in a single O(N + len(values)) tree traversal, versus calling Weight once per value (O(len(values) * N)). Consumers that score K candidates against the tree under the read lock no longer pay K full walks. Extract the per-entry contribution math into an unexported helper shared by both Weight and WeightsFor so the metric stays identical (DRY). Weight's public behavior is unchanged. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(config): add ModelConfig.ModelID() single source of truth The c.Name fallback to c.Model was duplicated in core/backend/options.go (feeding model.WithModelID) and hand-copied into core/backend/llm.go (the prefix-chain salt). These MUST agree or the prefix-cache salt diverges silently from the id the model loader tracks. Consolidate both into a new config.ModelConfig.ModelID() helper and call it from both sites. Behavior is identical. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * perf(prefixcache): reuse one xxhash.Digest in ExtractChain ExtractChain allocated a fresh xxhash.New() Digest per block (up to MaxDepth per call) and grew the chain slice without preallocation. Reuse a single Digest via Reset() before each block and preallocate the chain to min(nBlocks, MaxDepth). xxhash seed 0 is stateless, so Reset()+Write produces the byte-identical value to a fresh New()+Write. Output hashes are unchanged, preserving the cross-process determinism that peers rely on over NATS. Verified by capturing ExtractChain output for the existing test inputs before and after the refactor: identical. Existing extractor tests pass unchanged. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(prefixcache): drop hot match when matched node is not a candidate; weigh cold candidates in one walk Index.Decide called radixtree.LongestMatch over the whole tree, so the deepest match could be a node that is offline, unloaded, or simply not in the passed candidate set. Honoring that as HotNodeID produced a false forced-disturb signal upstream (buildPreference records pressure when chosen != HotNodeID), making it look like a warm replica was load saturated when it was actually absent. Build the candidate set once and only set HotNodeID/MatchRatio when the matched node is an actual candidate; otherwise fall back to cold placement. A future refinement could ask the tree for the longest match restricted to the candidate nodes (shallower-but-valid) instead of dropping it. Also replace the per-candidate tree.Weight call in the cold-order sort with a single tree.WeightsFor walk, turning O(K*N) under the read lock into O(N + K). Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(prefixcache): remove Select's unreachable deterministic fallback buildPreference always passes ColdOrder as a permutation of the full candidate set, so the cold-order loop hits every eligible candidate. The trailing best/bestIF scan was dead. Replace it with a plain "return """ and document that ColdOrder is guaranteed to cover all candidates, so "" means none were eligible. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(nodes): fetch model scheduling config once per Route GetModelScheduling was read three times per request - in resolveSelectorCandidates, buildPreference, and nodeMatchesScheduling - three DB round-trips for one row that is immutable for the life of the request, and not a consistent snapshot. Fetch it once near the top of Route and thread the *ModelSchedulingConfig (may be nil) into all three helpers. scheduleNewModel keeps its own fetch since it runs outside the Route snapshot. Behavior is identical for nil sched. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(autoscale): add Pressure.Reset to consume forced-disturb signal Pressure.Count is non-draining (it prunes only by age), so a single burst of forced-disturbs stays within the rolling window for the whole window and keeps Count >= threshold on every reconciler tick. The reconciler will use Reset to clear a model's events after acting on the signal so a fresh scale-up requires fresh forced-disturbs to accumulate, rather than one burst driving the model toward MaxReplicas. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(autoscale): at most one scale-up per reconcile tick, consume pressure Two autoscale bugs: 1. Over-scaling: the pressure scale-up block read Pressure.Count but never consumed it. With a non-draining counter a single forced-disturb burst kept Count >= threshold across the whole window, firing scaleUp on every tick and pushing the model toward MaxReplicas off one transient burst. After a successful pressure-triggered scale-up the reconciler now calls Pressure.Reset to consume the signal. 2. Double scale-up in one tick: the all-replicas-busy block and the pressure block could both fire in the same reconcileModel pass, each calling scaleUp(+1) against the same `current` read once at the top, so a model that was both busy and over threshold scaled +2 and could overshoot MaxReplicas by one. A scaledUp flag now enforces at most one scaleUp(+1) per tick: the pressure block is skipped if the busy block already scaled, and scale-down is skipped in any tick that scaled up. MinReplicas enforcement, UnsatisfiableUntil backoff, and capacity guards are unchanged. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(nodes): replica-removed chokepoint hook for prefix-cache invalidation Add SetReplicaRemovedHook to NodeRegistry and fire it from both RemoveNodeModel and RemoveAllNodeModelReplicas after a successful delete. This is the single chokepoint every replica-removal path funnels through (router eviction, reconciler scale-down, probe reaper, health-monitor node-down reap, RemoteUnloaderAdapter), so the prefix-cache index can be invalidated by construction rather than wiring each call site individually. The hook is stored in an atomic.Pointer so the startup wiring (setter) and the request/reconcile-time fire are race-free; it is nil-safe when unset. GORM Delete reports no error for a no-op delete, so the hook also fires when nothing was removed; the consumer's Invalidate(model, node) is idempotent so this is harmless. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(distributed): invalidate prefix-cache on any replica removal via registry hook Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(prefixcache): single source of truth for threshold bounds Extract ValidateThresholds into prefixcache/config.go so the per-model override validation (nodes.go endpoint) and Config.Validate share one implementation of the numeric bounds (min_prefix_match in [0,1], balance_abs_threshold >= 0, balance_rel_threshold == 0-or->= 1) instead of hard-coding them in two places. The route_policy allow-list stays explicit (not ParsePolicy, which maps typos to Default). Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(nodes): preserve prefix-cache settings on partial scheduling update A scheduling POST that omitted route_policy/thresholds (e.g. a min_replicas-only update) full-replaced every column and silently reset the model's previously-configured prefix-cache settings to empty/zero. Make the four prefix-cache request fields pointers so omitted is distinguishable from explicit zero, and merge PATCH-style in SetSchedulingEndpoint: a provided pointer wins, an omitted one preserves the existing config value (zero default when none). Non-prefix fields keep their full-replace PUT semantics. Validation now runs on the resolved values via prefixcache.ValidateThresholds. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(prefixcache): make Invalidate a no-op for uncached models and skip empty broadcasts A registry chokepoint fires Sync.Invalidate(model, nodeID) for every replica removal of every model, including round-robin models that never used the prefix cache. Index.Invalidate previously called tree(model), which lazily created and permanently retained an empty radix tree for any model that ever lost a replica, growing the trees map without bound. Sync.Invalidate also published a NATS PrefixCacheInvalidateEvent on every call, amplifying no-op removals across the cluster. Index.Invalidate now looks the tree up read-only via existingTree and returns without allocating when none exists. The Provider interface is unchanged; Sync gates the broadcast through an optional invalidateExisting(bool) capability type-asserted from the wrapped Index, falling back to the prior always-broadcast behavior for other Provider implementations. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * perf(prefixcache): derive Decide candidacy from WeightsFor and skip trivial sort WeightsFor already returns a map keyed by every requested candidate, so the separate candidates set built to validate the hot match was redundant: a node is a candidate iff it is a key in the weights map. Drop the extra map and gate the hot-match check on weights membership. Also skip the sort when there is at most one candidate, since the input order is already the cold order. Behavior is unchanged. Deferred follow-up: skipping the WeightsFor walk entirely when a hot match wins would need lazy cross-file changes and is out of scope here. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(nodes): fire replica-removed hook on bulk node_models deletes; trim LoadedReplicaStats columns Bulk node-scoped node_models deletes (Register re-register cleanup, MarkOffline, MarkDraining, Deregister) removed rows directly without firing the replica-removed hook, so the prefix-cache index kept pointing at nodes whose models were gone. Capture the DISTINCT model names before each bulk delete and fire fireReplicaRemoved once per model after a successful delete, restoring the single-chokepoint invariant for all removal paths. The pre-query is skipped when no hook is set so the no-hook path stays cheap. Also narrow LoadedReplicaStats to SELECT only node_id and in_flight (the only fields the router consumer reads), dropping the JOIN-side available_vram fetch and unused columns while keeping the []ReplicaCandidate return type unchanged. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(reconciler): consume autoscale signals only on a real scale-up scaleUp was fire-and-forget (void) yet its callers unconditionally consumed the pressure signal (Pressure.Reset) and the MinReplicas hysteresis (ClearUnsatisfiable) right after calling it. If scaleUp added nothing (ScheduleAndLoadModel errored, or no node could be loaded) the saturated warm replica got no new replica AND its accumulated forced-disturb history was wiped, forcing the signal to re-accumulate over a full PressureWindow before the next attempt. Make scaleUp return whether at least one replica was actually scheduled, and gate the side effects on it: - pressure block (2b): set scaledUp and call Pressure.Reset only on success; on failure preserve the signal so the next tick retries off the same accumulated pressure. - busy-burst block (2): set scaledUp from the return value so a failed attempt does not suppress the pressure path or scale-down. - MinReplicas block: call ClearUnsatisfiable only on success so a failed attempt does not reset the unsatisfiable counter. All existing invariants (MaxReplicas, capacity gating, UnsatisfiableUntil cooldown, at-most-one-scale-up-per-tick) are preserved. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(nodes): drop router's redundant prefix-cache Invalidate calls The NodeRegistry removal chokepoint (RemoveNodeModel / RemoveAllNodeModelReplicas) now fires SetReplicaRemovedHook, which invalidates the prefix-cache index. The router was also calling prefixProvider.Invalidate explicitly right after each registry removal on the two stale-replica health-probe fall-throughs in Route and in UnloadModel, so every router-side eviction invalidated twice (double tree-prune + double NATS broadcast). Remove the three redundant explicit Invalidate calls and their empty nil-guards. Each removed call sat immediately after a registry removal that fires the hook, so invalidation is preserved via the chokepoint. Decide/Observe usage is untouched. Re-point the unit test (fake registry fires no hook) to assert the removal chokepoint is exercised on unload instead of the router's direct invalidation. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(prefixcache): broadcast invalidations unconditionally for cross-frontend coherence Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(prefixcache): reject TTL<=0 in Config.Validate (eviction ticker would panic) Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(nodes): make capture+delete atomic in bulk node_models removal paths MarkOffline, MarkDraining, and the Register re-register cleanup ran the nodeModelNames SELECT and the bulk node_models DELETE as two separate statements on r.db with no transaction. A SetNodeModel landing between the two was deleted but its replica-removed hook never fired, leaving the prefix-cache index pointing at a removed replica until TTL or candidacy self-heal. Wrap the capture and the delete in a single db.Transaction in each path (mirroring how Deregister already does it). The captured model names are collected into a slice declared outside the closure; the replica-removed hook fires for each only after the transaction commits, so a rollback never invalidates the index for a removal that did not persist. The set of fired hooks now equals exactly the set of node_models rows actually deleted, with no interleaving gap. The status flip in MarkOffline/MarkDraining (setStatus) is a separate, pre-existing operation and routing already filters non-healthy nodes, so it stays outside the transaction; return contracts are unchanged. Deregister was already correct and is untouched. The cheap-path skip (no hook -> skip the SELECT) is preserved. Adds a spec asserting MarkOffline fires hooks for exactly the rows it deletes and leaves no node_models row behind (consistent snapshot). Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore(nodes): debug logging for prefix-cache routing decisions and observations Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(radixtree): match shared prefixes by valuing every node on insert Insert recorded the value (node id) only on the final node of the key chain, leaving every intermediate prefix node valueless. LongestMatch returns the deepest node that hasValue, so two chains that share a leading block but diverge in the tail never matched: only exact-repeat queries hit. That broke the prefix-cache routing core use cases (shared system prompt, multi-turn extension, volatile tail), all of which rely on prefix matching rather than exact-repeat. Set value/hasValue/lastSeen at every node along the chain so each prefix-block node remembers the node id that served that prefix (SGLang/vLLM-style). The deepest match wins, and the last writer owns a shared prefix node (a recency heuristic: the most recent chain through a block is the one most likely still warm). size now counts valued nodes, which is the intended meaning. Updated radixtree tests to the new semantics: deepest-prefix test uses non-overlapping chains, a new test asserts last-writer-owns-shared-node, Evict/Remove/MaxEntries expectations recomputed for per-prefix-node counting, and a shared-prefix LongestMatch red test added. Added a prefixcache Decide test proving a prefix-only query routes to the warm node. No prefixcache .go logic changed. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(distributed): lock in prefix-cache routing behavior end to end Add a DB-backed e2e spec that drives SmartRouter against a real NodeRegistry (Postgres testcontainer) and the real prefixcache.Index radix-tree provider, using a fake gRPC backend factory so no real inference runs. Covers the five behaviors validated by hand: 1. Cold miss + observe: an unseen prefix chain cold-places and is recorded. 2. Hot-match affinity: the same chain returns to its warm node X. 3. Shared-prefix match: a divergent chain sharing X's leading prefix still routes to X (the radix-tree regression we fixed). 4. Negative control: an unrelated chain is a cold miss, not a false hot match on X. 5. Failover + invalidation: removing X's replica fires the registry chokepoint hook to invalidate the prefix entry, and the chain fails over to surviving node Y and re-homes there. Replaces the need for manual docker-compose re-runs. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(prefixcache): make prefix-cache affinity replica-granular Track prefix-cache affinity per loaded replica (a backend process with its own KV cache) instead of per node, so multiple replicas of the same model on one node each keep distinct affinity and a hot prefix routes back to the exact replica that served it. - radixtree: add RemoveFunc(pred) and reimplement Remove on top of it. - prefixcache: introduce ReplicaKey{NodeID, Replica}; Index/Candidate/ PrefixDecision/Select/Provider now key on ReplicaKey. Add InvalidateNode to drop every replica of a node; Invalidate drops one replica. Select returns (ReplicaKey, bool) and gains a deterministic least-in-flight eligible fallback (tiebreak NodeID then Replica). - messaging: carry Replica on PrefixCacheObserveEvent and PrefixCacheInvalidateEvent (Replica < 0 means all replicas of the node). - Sync delegates + broadcasts with replica; InvalidateNode broadcasts Replica=-1; ApplyInvalidate routes negative replica to InvalidateNode. This is part 1 of 2; the registry/router/wiring consumers are updated separately. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(distributed): make prefix-cache routing replica-granular Wire the SmartRouter, NodeRegistry, and distributed startup to the replica-keyed prefixcache API. Affinity is now tracked per replica (each replica is a separate process with its own KV cache), so a prefix served by (node,0) no longer leaks onto the same-node sibling (node,1). - RoutePreference gains PreferredReplica; FindAndLockNodeWithModel locks the EXACT (node_id, replica_index) row, falling through to the default ORDER BY when that replica is not loaded. - SetReplicaRemovedHook now carries replicaIndex; RemoveNodeModel fires the specific replica, RemoveAllNodeModelReplicas and the four bulk node-scoped deletes fire replica<0 (all replicas of the node). - buildPreference builds one Candidate per loaded replica and locks the exact replica the policy chose; observePrefix records the served ReplicaKey at every call site. - distributed.go routes the hook to InvalidateNode (replica<0) or Invalidate(key). - Tests updated to the replica-keyed API plus new coverage: a hot prefix on (node,0) prefers replica 0 over the same-node sibling (router unit + e2e), and FindAndLock locks the exact preferred replica. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(distributed): derive prefix chain from messages for tokenizer-template models Prefix-cache-aware routing built its prompt-prefix chain from the rendered prompt string `s` in ModelInference. For models with TemplateConfig.UseTokenizerTemplate the frontend never renders a prompt - the backend tokenizes the structured messages itself - so `s` is empty, the chain is empty, and routing silently falls back to round-robin. That covers the bulk of modern chat models (qwen3, llama3, ...), so the feature effectively never engaged for them. Fall back to messagesPrefixSource(messages): a deterministic, prefix-stable head-first serialization of the conversation (role + content per turn). Two requests sharing a leading system prompt and early turns share a leading byte prefix, which ExtractChain maps to a shared chain prefix - landing both on the same cache-warm replica. The rendered `s` is still preferred when present (higher fidelity for non-template models). Found via the multi-replica-per-node e2e: zero "prefix-cache routing decision" logs despite per-request Route calls, traced to the empty-chain guard. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(distributed): document prefix-cache routing roadmap Add a routing-and-caching roadmap section to the distributed-mode guide, linking the epic (#10063) and the follow-up issues (#10064-#10070) surfaced from a survey of SGLang, vLLM production-stack, Ray Serve, llm-d, AIBrix, and NVIDIA Dynamo. 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> |
||
|
|
fbcd886a47 |
fix(application): stop backend processes synchronously on shutdown (#10058)
application.New wires a fire-and-forget goroutine that runs StopAllGRPC + distributed.Shutdown when the app context is cancelled. Callers (tests, CLI signal handler) cancel the context and then exit immediately, so the test binary / process can terminate before that goroutine kills the spawned backend children. go-processmanager sets no Pdeathsig, so the orphans are reparented to init and survive — leaving dozens of stray mock-backend processes after an e2e run. Add Application.Shutdown(), which runs the same cleanup synchronously on the caller's stack and is idempotent via sync.Once. The context-cancel goroutine, the CLI signal handler, and the test suites all call it, so cleanup is deterministic and the duplicated teardown logic collapses to one place. The async goroutine remains as a safety net for callers that forget; sync.Once dedupes the double call. Wire e2e_suite_test and the two mock-backend Contexts in app_test to call Shutdown in their AfterSuite/AfterEach. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> |
||
|
|
06e777b75e |
feat(distributed): gated X-LocalAI-Node response header (middleware + wrapper) (#9976)
* feat(distributed): add per-request node ID context holder Introduce pkg/distributedhdr, a leaf package carrying a per-request *atomic.Value holder for the picked worker node ID from the SmartRouter (core/services/nodes) up to the HTTP response writer wrapper (core/http/middleware). Avoids the import cycle that a shared key in either consumer would create. Exposes NewHolder, WithHolder, Holder, Stamp, Load, Inherit. The holder is atomic.Value so cross-goroutine publish from the router to the response writer wrapper is race-clean. Assisted-by: Claude:claude-opus-4-7[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(distributed): add ExposeNodeHeader middleware + response writer wrapper New ApplicationConfig.ExposeNodeHeader bool + --expose-node-header CLI flag / LOCALAI_EXPOSE_NODE_HEADER env var (default off; the node ID reveals internal topology and is opt-in). The middleware creates a per-request *atomic.Value holder, attaches it to c.Request().Context() via distributedhdr.WithHolder, and wraps c.Response().Writer with a custom http.ResponseWriter that sets the X-LocalAI-Node header on first Write / WriteHeader / Flush by reading the holder. Implements http.Flusher, http.Hijacker, Unwrap so it composes cleanly with Echo and http.NewResponseController. request.go propagates the holder onto derived contexts via distributedhdr.Inherit so the holder survives the correlation-ID context replacement. Unit + race-clean concurrency + integration specs. Assisted-by: Claude:claude-opus-4-7[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(distributed): stamp node ID in router and wire middleware to inference routes ModelRouterAdapter.Route stamps the picked node ID into the per-request holder via distributedhdr.Stamp(ctx, result.Node.ID) right after replica selection. Wire ExposeNodeHeader middleware to: - OpenAI chat/completion/embeddings + audio transcriptions/speech + image generations/inpainting - Anthropic /v1/messages - Ollama /api/chat, /api/generate, /api/embed, /api/embeddings - Jina /v1/rerank - LocalAI /v1/vad The middleware's wrapper reads the holder on first byte and sets the X-LocalAI-Node response header before delegating to the underlying writer. Per-request scope means no race under concurrent multi-replica routing. Assisted-by: Claude:claude-opus-4-7[1m] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(distributed): thread request context through backend Load + cover ctx propagation Five non-OpenAI backend helpers were silently using app.Context instead of the request context for the gRPC backend call: transcription, TTS, image generation, rerank, VAD. Effect: distributedhdr.Stamp in the router callback was a silent no-op for these paths, AND client cancellation didn't propagate to in-flight inference. Thread c.Request().Context() (or the equivalent input.Context after the request middleware has installed the correlation-ID derived context) through each helper and into ModelOptions via model.WithContext(ctx). ImageGeneration's signature gains a leading ctx parameter; in-tree callers (openai image, openai inpainting, openai inpainting_test) are updated to match. ModelEmbedding gains a leading ctx parameter for the same reason; the openai and ollama embedding handlers pass the request context through. chat_stream_workers.go defers the initial role=assistant chunk emission until the first token callback so the wrapper's lazy X-LocalAI-Node lookup against the loader runs AFTER ml.Load has stamped the per-modelID node ID; semantically identical for clients (role still arrives before any text). Regression test core/backend/ctx_propagation_test.go pins ctx propagation for all five helpers. Docs updated to enumerate the full endpoint coverage of the --expose-node-header flag. Assisted-by: Claude:claude-opus-4-7[1m] 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> |
||
|
|
6a80e23733 |
feat(middleware): Model routing, PII filtering, Cloud model proxies (#9802)
Add a routing middleware stack and a cloud-proxy backend. * cloud-proxy: a Go gRPC backend that forwards OpenAI- and Anthropic-shaped chat requests to upstream providers, with an optional translate mode (OpenAI request -> Anthropic /v1/messages -> OpenAI response) and full tool-calling support. * routing: admission control, content-aware model routing (embedding cache + classifier + rerank + Arch-Router score), PII detection/redaction (regex + NER) with streaming filter and OpenAI/Anthropic adapters, and a per-user/per-key billing recorder backed by GORM or in-memory storage. * middleware: UsageMiddleware records usage via the billing recorder, plus admission, route-model, usage-stamp and trace middlewares. * observability: BackendTrace ring buffer stores full request bodies (capped), MITM proxy emits structured trace events, and router classifier decisions surface at /api/router/decide. * gallery: Arch-Router-1.5B (Q4_K_M and Q8_0). * UI: cloud-proxy model-editor fields, classifier system-prompt and score-normalization config, and a Traces page rendering request bodies. Assisted-by: claude-code:claude-opus-4-7 [Read] [Edit] [Bash] Signed-off-by: Richard Palethorpe <io@richiejp.com> |
||
|
|
a0f3e26245 |
fix(distributed): make admin backend installs resilient and observable (#9958)
* feat(distributed): add configurable NATS backend install/upgrade timeouts Adds BackendInstallTimeout and BackendUpgradeTimeout to DistributedConfig with 15m defaults, following the existing MCPToolTimeout / WorkerWaitTimeout pattern. These will replace the hardcoded literals in RemoteUnloaderAdapter so admin-driven backend installs across the cluster survive long OCI image pulls that previously timed out at 3m. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * style(distributed): gofmt alignment after timeout fields Re-aligns the Validate() negative-duration map and the Default* const block so the new BackendInstall/UpgradeTimeout entries do not leave the surrounding columns mis-padded. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(cli): surface LOCALAI_NATS_BACKEND_INSTALL_TIMEOUT and _UPGRADE_TIMEOUT Parses the two new env vars on the run CLI and threads them through the existing AppOption builder so DistributedConfig picks them up. Invalid duration strings now fail loudly at startup rather than silently falling back to the default. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(distributed): inject NATS install/upgrade timeouts into RemoteUnloaderAdapter Removes the hardcoded 3m / 15m literals from RemoteUnloaderAdapter and threads in DistributedConfig.BackendInstallTimeoutOrDefault() and BackendUpgradeTimeoutOrDefault() at construction. Install now defaults to 15m (was 3m); cold OCI image pulls on Jetson Wi-Fi routinely blew past the old ceiling. Scripted messaging client captures the timeout so tests can assert the configured value actually reaches the NATS request. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(distributed): introduce galleryop.ErrWorkerStillInstalling sentinel When the NATS request-reply for backend.install (or .upgrade) times out the worker is almost always still pulling the OCI image. Wrap the timeout in a typed sentinel so the manager above can distinguish "worker hung" from "worker still working" and leave the pending_backend_ops row in place for the reconciler to confirm via backend.list. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(distributed): treat NATS install timeout as in-progress, not failure When a worker times out replying to backend.install but the install is still running on the worker, enqueueAndDrainBackendOp now reports a running_on_worker status and pushes NextRetryAt out by the install timeout so the reconciler does not immediately re-fire another install while the worker is still pulling the image. The pending_backend_ops row stays in place for the next reconciler pass to confirm via backend.list. InstallBackend wraps the result in galleryop.ErrWorkerStillInstalling so callers can branch (galleryop renders yellow in-progress instead of red error). UpgradeBackend uses the same wrap. Adds RemoteUnloaderAdapter.InstallTimeout() so the manager can push NextRetryAt by the configured timeout without reaching into a private field, and NodeRegistry.RecordPendingBackendOpInFlight as the soft cousin of RecordPendingBackendOpFailure. Also includes incidental gofmt-driven struct-field alignment in registry.go on lines unrelated to the change (touched files are re-formatted to canonical form per project policy). Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(distributed): don't increment Attempts on in-flight install timeout An in-flight timeout (worker still pulling the OCI image) is not a failed attempt, it's a delayed one. Incrementing Attempts let genuinely-progressing slow installs (e.g. 30 GB CUDA images on Wi-Fi) trip the reconciler's maxPendingBackendOpAttempts cap and dead-letter the queue row while the worker was still legitimately working. RecordPendingBackendOpInFlight now only updates LastError and NextRetryAt. Also documents "running_on_worker" in the NodeOpStatus.Status enum comment so Task 6 implementers see the full surface. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(galleryop): surface ErrWorkerStillInstalling as non-error OpStatus When the distributed backend manager returns an error that wraps ErrWorkerStillInstalling, backendHandler now completes the op with a "still installing in background" message rather than marking it as a red failure. Admin UI sees a yellow in-progress state; reconciler confirms completion on its next pass. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(distributed): end-to-end install-timeout-then-reconcile Wires Task 1-6 end-to-end so any seam mismatch surfaces in CI rather than during a real cluster install. NATS times out, the queue row stays alive with running_on_worker status, the worker eventually reports the backend installed via backend.list, the manager surfaces it via ListBackends. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(distributed): document LOCALAI_NATS_BACKEND_INSTALL_TIMEOUT / _UPGRADE_TIMEOUT Add the two new operator-tunable env vars to the Frontend Configuration table in the distributed-mode docs. Explains the 15m default, when to raise it (slow links pulling multi-GB OCI images), and the new "still installing in background" admin-UI state when the round-trip times out but the worker is still working. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(distributed): clear pending install rows when backend.list confirms DistributedBackendManager.ListBackends now proactively clears pending_backend_ops install rows whose (nodeID, backend) is reported installed by backend.list. Operator UI updates immediately instead of waiting up to installTimeout (default 15m) for the next reconciler tick after NextRetryAt. Only install rows are cleared; upgrade and delete intents are not satisfied by presence in backend.list and continue to drain through their normal reconciler paths. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(messaging): add BackendInstallProgressEvent wire type and subject New NATS subject nodes.<nodeID>.backend.install.<opID>.progress lets the worker publish transient progress events (file, current/total bytes, percentage, phase) while a long-running install pulls its OCI image. BackendInstallRequest gains an optional OpID field so the worker knows which subject to publish on. Transient pub/sub (not JetStream): the install reply remains ground truth for success/failure; dropped progress events are tolerable. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * style(messaging): drop em-dash from BackendInstallProgress test comment Per project convention (no em-dashes anywhere). Comment substance is unchanged. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(distributed): worker publishes debounced install progress over NATS When BackendInstallRequest.OpID is set, the worker's backend.install handler wires a debounced publisher (250ms window) into the gallery download callback. Each tick becomes a BackendInstallProgressEvent on nodes.<nodeID>.backend.install.<opID>.progress; the publisher always emits a final event on Flush so the UI sees the terminal percentage. Old masters that do not set OpID continue to run silent installs: no behavior change for them. Lock ordering: the publisher releases its mutex before calling messaging.Publish so a slow network never stalls the install loop. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(distributed): RemoteUnloaderAdapter subscribes to install progress InstallBackend gains opID + onProgress parameters. When both are set, the adapter subscribes to nodes.<nodeID>.backend.install.<opID>.progress BEFORE publishing the install request, decodes each message into the caller's onProgress callback in a goroutine (so a slow callback never stalls the NATS reader thread), and unsubscribes after RequestJSON returns. When onProgress is nil OR opID is empty (the reconciler retry path), subscription is skipped entirely - silent installs cost nothing extra. Subscribe failure is logged at Warn and the install proceeds without progress streaming; the NATS round-trip still owns terminal status. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(distributed): forward backend install progress into galleryop OpStatus DistributedBackendManager.InstallBackend now passes the gallery op ID and a progress bridge into the adapter call. Each BackendInstallProgressEvent from the worker becomes a galleryop.ProgressCallback tick - which the existing backendHandler already turns into OpStatus.UpdateStatus, so the admin UI/SSE polling sees per-byte progress for distributed installs without any UI-side change. UpgradeBackend is intentionally left silent for now: its wire request (BackendUpgradeRequest) does not carry OpID, and rolling-update fallback is the rarer path. Will be picked up in a follow-up if the worker upgrade path also gets a progress channel. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(distributed): InstallBackend tolerates silent (pre-Phase-2) workers A worker on pre-Phase-2 code never publishes progress events. The new master subscribes optimistically; this spec pins that a silent worker still produces a green install with no progressCb ticks. The install reply is the source of truth for terminal state; the progress stream is a best-effort UX enrichment. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(distributed): document install progress streaming Note the new nodes.<nodeID>.backend.install.<opID>.progress subject and the silent-worker compatibility behavior so operators know to expect real-time progress and what happens on a mixed-version cluster. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(distributed): note progress-event ordering trade-off in InstallBackend Document near the goroutine dispatch why ordering at the consumer is best-effort, why it rarely matters in practice (worker debounce >> goroutine jitter), and what a future hardening pass would look like (Seq field + stale-by-seq drop). Stops the next reader from accidentally "fixing" the goroutine pool away. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(galleryop): add NodeProgress + OpStatus.Nodes for per-node breakdown Adds the data model the UI needs to render an expandable per-node breakdown of a fanned-out backend install. NodeProgress carries node identity (ID + name), per-node status (queued / running_on_worker / success / error / downloading), the current file + bytes + percentage from the Phase 2 progress stream, and any per-node error. OpStatus.Nodes is the slice the /api/operations handler will surface in a follow-up. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(galleryop): UpdateNodeProgress merges per-node ticks by NodeID GalleryService.UpdateNodeProgress(opID, nodeID, np) merges a NodeProgress into OpStatus.Nodes (keyed by NodeID, no duplicates) and mirrors the latest tick into the aggregate Progress / FileName / DownloadedFileSize / TotalFileSize fields so the legacy single-bar OperationsBar view keeps working unchanged alongside the new per-node breakdown. Concurrent-safe via the existing g.Mutex. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(distributed): write per-node OpStatus entries during install fan-out DistributedBackendManager now accepts a nodeProgressSink and feeds it two streams: 1. enqueueAndDrainBackendOp emits a per-node terminal entry on each status it appends to BackendOpResult (queued, success, error, running_on_worker). The opID is threaded through the function so the sink gets the right gallery op identity. 2. The install apply closure fans each BackendInstallProgressEvent into the sink as a downloading entry, alongside the legacy progressCb path so the aggregate single-bar view stays correct. Production wiring passes the GalleryService (which implements UpdateNodeProgress via Task 2) as the sink. Single-node tests pass nil. DeleteBackend and UpgradeBackend pass an empty opID so the sink path no-ops for ops that aren't gallery-tracked the same way as Install. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(operations): expose per-node breakdown on /api/operations When an operation's OpStatus has Nodes entries (populated by the Phase 4 progress sink wiring), surface them as a "nodes" array on the /api/operations response, sorted by node_name for stable rendering. Backward compatible: legacy clients ignore the field; ops without any node entries (single-node mode, model installs) omit the array entirely thanks to the empty-slice guard. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): per-node breakdown in OperationsBar When an install op fans out to more than one worker, the operations bar now shows a "N nodes" chevron that expands into a per-node list. Each row carries the node's status (color-coded pill), the current file being downloaded, byte counts, percentage, and a thin per-node progress bar. Yellow "Worker busy" pill marks running_on_worker status with a tooltip explaining the NATS round-trip timed out but the worker is still installing in the background. Backward compatible: ops without a nodes field (legacy or single-node mode) render as before. State for expand/collapse is local to the component, keyed by jobID/id - reload starts collapsed. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(distributed): document per-node breakdown in the operations bar Adds a short subsection covering the expandable "N nodes" chevron in the OperationsBar admin UI, the meaning of each status pill, and how it relates to the /api/operations nodes array. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(galleryop): UpdateStatus preserves Nodes when caller sends none Real-world bug surfaced by the Phase 4 multi-worker smoke test: the nodes[] array in /api/operations flickered between a single node at a time on a 2-worker install. Root cause: the Phase 2 progress bridge also calls the legacy progressCb -> UpdateStatus(&OpStatus{...}) on every tick. UpdateStatus then overwrote the entire status pointer, wiping the Nodes slice that UpdateNodeProgress had just merged in. Fix: in UpdateStatus, if the incoming op has an empty Nodes slice, carry forward the previous status's Nodes before storing. Callers that explicitly populate Nodes still win (their slice replaces the prior one, no merge across the two code paths). Two regression specs added pinning both directions of the contract. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(distributed): strip implementation details from user-facing docs Trim the new install/upgrade timeout rows and the install-progress sections to focus on what the operator sees and tunes. Drops: - the NATS subject names and pub/sub mechanics - "round-trip" / reconciler / backend.list jargon - /api/operations polling cadence - "pre-2026-05-22" version references Reframes the breakdown text around the admin UI (Operations Bar, chevron, status pills, "Worker busy" tooltip). Implementation context lives in the agent notes and code comments. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(config): move DistributedConfig.Validate flag names to constants The negative-duration check map was a wall of literal kebab-case strings that had to stay in sync with the kong-derived CLI flag names manually. Move them to a Flag* const block alongside the existing Default* block so a rename of either the Go field or the CLI naming convention forces a compile error rather than silent drift. Sole consumer today is Validate; the constants are exported so future operator-facing surfaces (e.g. error messages on other validation paths) can reference them by name instead of repeating the literals. Tests pin both the literal values (so a future "let's just rename this" doesn't accidentally regress the CLI flag) and the negative- duration error message for the new BackendInstall / BackendUpgrade fields. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(distributed): extract NodeStatus and Phase enums to constants Sweep for the same literal-string-as-identifier pattern called out on the Validate flag names: the per-node install status enum ("queued" | "downloading" | "running_on_worker" | "success" | "error") appeared as raw literals across managers_distributed.go (10+ sites, including 3 separate `n.Status == "running_on_worker"` checks), operation.go, and the test suite. Same shape for the Phase enum ("resolving" | "downloading" | "extracting" | "starting") in the worker-side progress publisher. Promote both to exported const blocks: - galleryop.NodeStatus{Queued,Downloading,RunningOnWorker,Success,Error} shared between galleryop.NodeProgress.Status (the wire field) and nodes.NodeOpStatus.Status (the in-process per-node summary) - messaging.Phase{Resolving,Downloading,Extracting,Starting} shared between the worker publisher and any future consumer that needs to switch on phase Tests pin both the literal values (so a future "let's just rename" doesn't silently change the JSON wire) and use the constants in setup (so the producer side stays drift-protected). Wire-format assertions on the /api/operations JSON output keep their literals deliberately, so the constant value can never silently diverge from what the UI receives. Out of scope for this PR (separate cleanup): the finetune and quantization job-status enums have the same anti-pattern with 14+ literal sites each, but predate this PR's work. 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> |
||
|
|
61bf34ea2f |
fix(traces): cap captured body size to keep admin Traces UI responsive (#9946)
The trace middleware buffered the full request and response bodies for every JSON exchange. With a chatty agent-pool RAG workload, /embeddings responses (large vector arrays) accumulated to tens of MB in the in-memory buffer; the admin Traces page would then download and parse 40+ MB on every load and on every 5s auto-refresh, locking the UI in a loading state. Add LOCALAI_TRACING_MAX_BODY_BYTES (default 64 KiB) that caps each captured body. The full payload still flows through to the real client; only the trace copy is bounded. Exchanges record body_truncated and original body_bytes so the dashboard can show that truncation happened. The cap is configurable via env, CLI, and runtime_settings.json. Also unblock recovery: the Traces page now keeps the Clear button enabled while loading, since "buffer too large to render" is exactly when the user needs to clear it. Assisted-by: Claude:claude-opus-4-7 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io> |
||
|
|
5d0b549049 |
feat(gallery): verify backend OCI images with keyless cosign (#9823)
* feat(gallery): verify backend OCI images with keyless cosign Close a trust gap where a registry compromise or MITM could silently replace a backend image: the gallery YAML tells LocalAI which image to pull, but until now nothing verified the bytes came from our CI. Consumer (pkg/oci/cosignverify): - New package using sigstore-go to verify keyless-cosign signatures. - OCI 1.1 referrers API + new bundle format (no legacy :tag.sig). - Policy fields: Issuer / IssuerRegex / Identity / IdentityRegex / NotBefore. NotBefore is the revocation lever — keyless Fulcio certs are ephemeral so revocation is policy-side; advancing not_before in the gallery YAML invalidates every signature predating the cutoff. - TUF trusted root cached process-wide so N backends from one gallery do 1 fetch, not N. Plumbing: - pkg/downloader: ImageVerifier interface + WithImageVerifier option threaded through DownloadFileWithContext. Verification runs between oci.GetImage and oci.ExtractOCIImage, with digest pinning via pinnedImageRef to close the TOCTOU window. Skips the verifier's HEAD when the ref is already digest-pinned. - core/config: Gallery.Verification YAML block. - core/gallery: backendDownloadOptions builds the verifier from the policy; applied on initial URI, mirrors, and tag fallbacks. - core/gallery/upgrade: the upgrade path now routes through the same options builder. A regression Ginkgo spec pins this contract — without it, UpgradeBackend silently bypassed verification. - core/cli: --require-backend-integrity (LOCALAI_REQUIRE_BACKEND_INTEGRITY) escalates missing policy / empty SHA256 from warn to hard-fail. Producer (.github/workflows/backend_merge.yml): - id-token: write at job scope (PR-fork-safe via existing event gate). - sigstore/cosign-installer@v3 pinned to v2.4.1. - After each docker buildx imagetools create, resolve the manifest list digest and run cosign sign --recursive --new-bundle-format --registry-referrers-mode=oci-1-1 against repo@digest. --recursive signs the index and every per-arch entry, matching how the consumer resolves a tag to a platform-specific manifest before verifying. Rollout: backend/index.yaml has no `verification:` block yet, so this PR is backward-compatible — installs proceed with a warning until the gallery is populated. Strict mode is opt-in. Assisted-by: claude-code:claude-opus-4-7 [Bash] [Edit] [Read] [Write] [WebSearch] [WebFetch] Signed-off-by: Richard Palethorpe <io@richiejp.com> * refactor(gallery): plumb RequireBackendIntegrity through config instead of env The previous implementation re-exported the --require-backend-integrity CLI flag into LOCALAI_REQUIRE_BACKEND_INTEGRITY via os.Setenv, then re-read it in core/gallery via os.Getenv. This leaked process state into the gallery package and made the flag impossible to override per-call or test without touching the env. Add RequireBackendIntegrity to ApplicationConfig (with a matching WithRequireBackendIntegrity AppOption) and thread the bool through every install/upgrade path: InstallBackend, InstallBackendFromGallery, UpgradeBackend, InstallModelFromGallery, InstallExternalBackend, ApplyGalleryFromString/File, startup.InstallModels. Worker subcommands gain the same env-bound flag on WorkerFlags so distributed-worker installs honor it consistently with the worker daemon path. Add a forbidigo lint rule against os.Getenv / os.LookupEnv / os.Environ to keep the env-leak pattern from creeping back. Existing offenders (p2p, config loaders, etc.) are baseline-grandfathered by the existing new-from-merge-base: origin/master setting; targeted path exclusions cover the legitimate cases — kong CLI entry points, backend subprocesses, system capability probes, gRPC AUTH_TOKEN inheritance, test gating env vars. Assisted-by: claude-code:claude-opus-4-7 Signed-off-by: Richard Palethorpe <io@richiejp.com> --------- Signed-off-by: Richard Palethorpe <io@richiejp.com> |
||
|
|
670259ce43 |
chore: Security hardening (#9719)
* fix(http): close 0.0.0.0/[::] SSRF bypass in /api/cors-proxy The CORS proxy carried its own private-network blocklist (RFC 1918 + a handful of IPv6 ranges) instead of using the same classification as pkg/utils/urlfetch.go. The hand-rolled list missed 0.0.0.0/8 and ::/128, both of which Linux routes to localhost — so any user with FeatureMCP (default-on for new users) could reach LocalAI's own listener and any other service bound to 0.0.0.0:port via: GET /api/cors-proxy?url=http://0.0.0.0:8080/... GET /api/cors-proxy?url=http://[::]:8080/... Replace the custom check with utils.IsPublicIP (Go stdlib IsLoopback / IsLinkLocalUnicast / IsPrivate / IsUnspecified, plus IPv4-mapped IPv6 unmasking) and add an upfront hostname rejection for localhost, *.local, and the cloud metadata aliases so split-horizon DNS can't paper over the IP check. The IP-pinning DialContext is unchanged: the validated IP from the single resolution is reused for the connection, so DNS rebinding still cannot swap a public answer for a private one between validate and dial. Regression tests cover 0.0.0.0, 0.0.0.0:PORT, [::], ::ffff:127.0.0.1, ::ffff:10.0.0.1, file://, gopher://, ftp://, localhost, 127.0.0.1, 10.0.0.1, 169.254.169.254, metadata.google.internal. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(downloader): verify SHA before promoting temp file to final path DownloadFileWithContext renamed the .partial file to its final name *before* checking the streamed SHA, so a hash mismatch returned an error but left the tampered file at filePath. Subsequent code that operated on filePath (a backend launcher, a YAML loader, a re-download that finds the file already present and skips) would consume the attacker-supplied bytes. Reorder: verify the streamed hash first, remove the .partial on mismatch, then rename. The streamed hash is computed during io.Copy so no second read is needed. While here, raise the empty-SHA case from a Debug log to a Warn so "this download had no integrity check" is visible at the default log level. Backend installs currently pass through with no digest; the warning makes that footprint observable without changing behaviour. Regression test asserts os.IsNotExist on the destination after a deliberate SHA mismatch. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(auth): require email_verified for OIDC admin promotion extractOIDCUserInfo read the ID token's "email" claim but never inspected "email_verified". With LOCALAI_ADMIN_EMAIL set, an attacker who could register on the configured OIDC IdP under that email (some IdPs accept self-supplied unverified emails) inherited admin role: - first login: AssignRole(tx, email, adminEmail) → RoleAdmin - re-login: MaybePromote(db, user, adminEmail) → flip to RoleAdmin Add EmailVerified to oauthUserInfo, parse email_verified from the OIDC claims (default false on absence so an IdP that omits the claim cannot short-circuit the gate), and substitute "" for the role-decision email when verified=false via emailForRoleDecision. The user record still stores the unverified email for display. GitHub's path defaults EmailVerified=true: GitHub only returns a public profile email after verification, and fetchGitHubPrimaryEmail explicitly filters to Verified=true. Regression tests cover both the helper contract and integration with AssignRole, including the bootstrap "first user" branch that would otherwise mask the gate. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(cli): refuse public bind when no auth backend is configured When neither an auth DB nor a static API key is set, the auth middleware passes every request through. That is fine for a developer laptop, a home LAN, or a Tailnet — the network itself is the trust boundary. It is not fine on a public IP, where every model install, settings change, and admin endpoint becomes reachable from the internet. Refuse to start in that exact configuration. Loopback, RFC 1918, RFC 4193 ULA, link-local, and RFC 6598 CGNAT (Tailscale's default range) all count as trusted; wildcard binds (`:port`, `0.0.0.0`, `[::]`) are accepted only when every host interface is in one of those ranges. Hostnames are resolved and treated as trusted only when every answer is. A new --allow-insecure-public-bind / LOCALAI_ALLOW_INSECURE_PUBLIC_BIND flag opts out for deployments that gate access externally (a reverse proxy enforcing auth, a mesh ACL, etc.). The error message lists this plus the three constructive alternatives (bind a private interface, enable --auth, set --api-keys). The interface enumeration goes through a package-level interfaceAddrsFn var so tests can simulate cloud-VM, home-LAN, Tailscale-only, and enumeration-failure topologies without poking at the real network stack. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * test(http): regression-test the localai_assistant admin gate ChatEndpoint already rejects metadata.localai_assistant=true from a non-admin caller, but the gate was open-coded inline with no direct test coverage. The chat route is FeatureChat-gated (default-on), and the assistant's in-process MCP server can install/delete models and edit configs — the wrong handler change would silently turn the LLM into a confused deputy. Extract the gate into requireAssistantAccess(c, authEnabled) and pin its behaviour: auth disabled is a no-op, unauthenticated is 403, RoleUser is 403, RoleAdmin and the synthetic legacy-key admin are admitted. No behaviour change in the production path. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * test(http): assert every API route is auth-classified The auth middleware classifies path prefixes (/api/, /v1/, /models/, etc.) as protected and treats anything else as a static-asset passthrough. A new endpoint shipped under a brand-new prefix — or a new path that simply isn't on the prefix allowlist — would be reachable anonymously. Walk every route registered by API() with auth enabled and a fresh in-memory database (no users, no keys), and assert each API-prefixed route returns 401 / 404 / 405 to an anonymous request. Public surfaces (/api/auth/*, /api/branding, /api/node/* token-authenticated routes, /healthz, branding asset server, generated-content server, static assets) are explicit allowlist entries with comments justifying them. Build-tagged 'auth' so it runs against the SQLite-backed auth DB (matches the existing auth suite). Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * test(http): pin agent endpoint per-user isolation contract agents.go's getUserID / effectiveUserID / canImpersonateUser / wantsAllUsers helpers are the single trust boundary for cross-user access on agent, agent-jobs, collections, and skills routes. A regression there is the difference between "regular user reads their own data" and "regular user reads anyone's data via ?user_id=victim". Lock in the contract: - effectiveUserID ignores ?user_id= for unauthenticated and RoleUser - effectiveUserID honours it for RoleAdmin and ProviderAgentWorker - wantsAllUsers requires admin AND the literal "true" string - canImpersonateUser is admin OR agent-worker, never plain RoleUser No production change — this commit only adds tests. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(downloader): drop redundant stat in removePartialFile The stat-then-remove pattern is a TOCTOU window and a wasted syscall — os.Remove already returns ErrNotExist for the missing-file case, so trust that and treat it as a no-op. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(http): redact secrets from trace buffer and distribution-token logs The /api/traces buffer captured Authorization, Cookie, Set-Cookie, and API-key headers verbatim from every request when tracing was enabled. The endpoint is admin-only but the buffer is reachable via any heap-style introspection and the captured tokens otherwise outlive the request. Strip those header values at capture time. Body redaction is left to a follow-up — the prompts are usually the operator's own and JSON-walking is invasive. Distribution tokens were also logged in plaintext from core/explorer/discovery.go; logs forward to syslog/journald and outlive the token. Redact those to a short prefix/suffix instead. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(auth): rate-limit OAuth callbacks separately from password endpoints The shared 5/min/IP limit on auth endpoints is right for password-style flows but too tight for OAuth callbacks: corporate SSO funnels many real users through one outbound IP and would trip the limit. Add a separate 60/min/IP limiter for /api/auth/{github,oidc}/callback so callbacks are bounded against floods without breaking shared-IP deployments. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(gallery): verify backend tarball sha256 when set in gallery entry GalleryBackend gained an optional sha256 field; the install path now threads it through to the existing downloader hash-verify (which already streams, verifies, and rolls back on mismatch). Galleries without sha256 keep working; the empty-SHA path still emits the existing "downloading without integrity check" warning. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * test(http): pin CSRF coverage on multipart endpoints The CSRF middleware in app.go is global (e.Use) so it covers every multipart upload route — branding assets, fine-tune datasets, audio transforms, agent collections. Pin that contract: cross-site multipart POSTs are rejected; same-origin / same-site / API-key clients are not. Also pins the SameSite=Lax fallback path the skipper relies on when Sec-Fetch-Site is absent. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(http): XSS hardening — CSP headers, safe href, base-href escape, SVG sandbox Several closely related XSS-prevention changes spanning the SPA shell, the React UI, and the branding asset server: - New SecurityHeaders middleware sets CSP, X-Content-Type-Options, X-Frame-Options, and Referrer-Policy on every response. The CSP keeps script-src permissive because the Vite bundle relies on inline + eval'd scripts; tightening that requires moving to a nonce-based policy. - The <base href> injection in the SPA shell escaped attacker-controllable Host / X-Forwarded-Host headers — a single quote in the host header broke out of the attribute. Pass through SecureBaseHref (html.EscapeString). - Three React sinks rendering untrusted content via dangerouslySetInnerHTML switch to text-node rendering with whiteSpace: pre-wrap: user message bodies in Chat.jsx and AgentChat.jsx, and the agent activity log in AgentChat.jsx. The hand-rolled escape on the agent user-message variant is replaced by the same plain-text path. - New safeHref util collapses non-allowlisted URI schemes (most importantly javascript:) to '#'. Applied to gallery `<a href={url}>` links in Models / Backends / Manage and to canvas artifact links — these come from gallery JSON or assistant tool calls and must be treated as untrusted. - The branding asset server attaches a sandbox CSP plus same-origin CORP to .svg responses. The React UI loads logos via <img>, but the same URL is also reachable via direct navigation; this prevents script execution if a hostile SVG slipped past upload validation. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(http): bound HTTP server with read-header and idle timeouts A net/http server with no timeouts is trivially Slowloris-able and leaks idle keep-alive connections. Set ReadHeaderTimeout (30s) to plug the slow-headers attack and IdleTimeout (120s) to cap keep-alive sockets. ReadTimeout and WriteTimeout stay at 0 because request bodies can be multi-GB model uploads and SSE / chat completions stream for many minutes; operators who need tighter per-request bounds should terminate slow clients at a reverse proxy. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * test(auth): pin PUT /api/auth/profile field-tampering contract The handler uses an explicit local body struct (only name and avatar_url) plus a gorm Updates(map) with a column allowlist, so an attacker posting {"role":"admin","email":"...","password_hash":"..."} can't mass-assign those fields. Lock that down with a regression test so a future "let's just c.Bind(&user)" refactor breaks loudly. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(services): strip directory components from multipart upload filenames UploadDataset and UploadToCollectionForUser took the raw multipart file.Filename and joined it into a destination path. The fine-tune upload was incidentally safe because of a UUID prefix that fused any leading '..' to a literal segment, but the protection is fragile. UploadToCollectionForUser handed the filename to a vendored backend without sanitising at all. Strip to filepath.Base at both boundaries and reject the trivial unsafe values ("", ".", "..", "/"). Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(react-ui): validate persisted MCP server entries on load localStorage is shared across same-origin pages; an XSS that lands once can poison persisted MCP server config to attempt header injection or to feed a non-http URL into the fetch path on subsequent loads. Validate every entry: types must match, URL must parse with http(s) scheme, header keys/values must be control-char-free. Drop anything that doesn't fit. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(http): close X-Forwarded-Prefix open redirect The reverse-proxy support concatenated X-Forwarded-Prefix into the redirect target without validation, so a forged header value of "//evil.com" turned the SPA-shell redirect helper at /, /browse, and /browse/* into a 301 to //evil.com/app. The path-strip middleware had the same shape on its prefix-trailing-slash redirect. Add SafeForwardedPrefix at the middleware boundary: must start with a single '/', no protocol-relative '//' opener, no scheme, no backslash, no control characters. Apply at both consumers; misconfig trips the validator and the header is dropped. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(http): refuse wildcard CORS when LOCALAI_CORS=true with empty allowlist When LOCALAI_CORS=true but LOCALAI_CORS_ALLOW_ORIGINS was empty, Echo's CORSWithConfig saw an empty allow-list and fell back to its default AllowOrigins=["*"]. An operator who flipped the strict-CORS feature flag without populating the list got the opposite of what they asked for. Echo never sets Allow-Credentials: true so this isn't directly exploitable (cookies aren't sent under wildcard CORS), but the misconfiguration trap is worth closing. Skip the registration and warn. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(auth): zxcvbn password strength check with user-acknowledged override The previous policy was len < 8, which let through "Password1" and the rest of the credential-stuffing corpus. LocalAI has no second factor yet, so the bar needs to sit higher. Add ValidatePasswordStrength using github.com/timbutler/zxcvbn (an actively-maintained fork of the trustelem port; v1.0.4, April 2024): - min 12 chars, max 72 (bcrypt's truncation point) - reject NUL bytes (some bcrypt callers truncate at the first NUL) - require zxcvbn score >= 3 ("safely unguessable, ~10^8 guesses to break"); the hint list ["localai", "local-ai", "admin"] penalises passwords built from the app's own branding zxcvbn produces false positives sometimes (a strong-looking password that happens to match a dictionary word) and operators occasionally need to set a known-weak password (kiosk demos, CI rigs). Add an acknowledgement path: PasswordPolicy{AllowWeak: true} skips the entropy check while still enforcing the hard rules. The structured PasswordErrorResponse marks weak-password rejections as Overridable so the UI can surface a "use this anyway" checkbox. Wired through register, self-service password change, and admin password reset on both the server and the React UI. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(react-ui): drop HTML5 minLength on new-password inputs minLength={12} on the new-password input let the browser block the form submit silently before any JS or network call ran. The browser focused the field, showed a brief native tooltip, and that was that — no toast, no fetch, no clue. Reproducible by typing fewer than 12 chars on the second password change of a session. The JS-level length check in handleSubmit already shows a toast and the server rejects with a structured error, so the HTML5 attribute was redundant defence anyway. Drop it. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(react-ui): bundle Geist fonts locally instead of fetching from Google The new CSP correctly refused to apply styles from fonts.googleapis.com because style-src is locked to 'self' and 'unsafe-inline'. Loosening the CSP would defeat its purpose; the right fix is to stop reaching out to a third-party CDN for fonts on every page load. Add @fontsource-variable/geist and @fontsource-variable/geist-mono as npm deps and import them once at boot. Drop the <link rel="preconnect"> and external stylesheet from index.html. Side benefit: no third-party tracking via Referer / IP on every UI load, no failure mode when offline / behind a captive portal. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * fix(react-ui): refresh i18n strings to reflect 12-char password minimum The translations still said "at least 8 characters" everywhere — the client-side toast on a too-short password change told the user the wrong floor. Update tooShort and newPasswordPlaceholder / newPasswordDescription across all five locales (en, es, it, de, zh-CN) to match the real ValidatePasswordStrength rule. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> * feat(auth): make password length-floor overridable like the entropy check The 12-char minimum was a policy choice, not a technical invariant — only "non-empty", "<= 72 bytes", and "no NUL bytes" are real bcrypt constraints. Treating length-12 as a hard rule was inconsistent with the entropy check (already overridable) and friction for use cases where the account is just a name on a session, not a security boundary (single-user kiosk, CI rig, lab demo). Restructure ValidatePasswordStrength: - Hard rules (always enforced): non-empty, <= MaxPasswordLength, no NUL byte - Policy rules (skipped when AllowWeak=true): length >= 12, zxcvbn score >= 3 PasswordError now marks password_too_short as Overridable too. The React forms generalised from `error_code === 'password_too_weak'` to `overridable === true`, and the JS-side preflight length checks were removed (server is source of truth, returns the same checkbox flow). Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com> --------- Signed-off-by: Richard Palethorpe <io@richiejp.com> |
||
|
|
bcef72b9c1 |
feat: localai assistant chat modality (#9602)
* fix(tests): inline model_test fixtures after tests/models_fixtures removal The previous reorg removed tests/models_fixtures/ but core/config/model_test.go still read CONFIG_FILE/MODELS_PATH env vars pointing into that directory, so `make test` failed with "open : no such file or directory" on the readConfigFile spec (the suite ran with --fail-fast and bailed before openresponses_test). Inline the YAMLs (config/embeddings/grpc/rwkv/whisper) directly into the test file, materialise them into a per-test tmpdir via BeforeEach, and drop the env-var lookups. The test no longer depends on Makefile plumbing. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: claude-code:claude-opus-4-7 [Edit] [Write] [Bash] * refactor(modeladmin): extract model-admin helpers into a service package Lift the bodies of EditModelEndpoint, PatchConfigEndpoint, ToggleStateModelEndpoint, TogglePinnedModelEndpoint and VRAMEstimateEndpoint into core/services/modeladmin so the same logic can be called by non-HTTP clients (notably the in-process MCP server that backs the LocalAI Assistant chat modality, landing in a follow-up commit). The HTTP handlers shrink to thin shells that parse echo inputs, call the matching helper, map typed errors (ErrNotFound, ErrConflict, ErrPathNotTrusted, ErrBadAction, ...) to the existing HTTP status codes, and render the existing response shapes. No REST-surface behaviour change; the existing localai endpoint tests cover the regression net. Adds focused unit tests for each helper against tmp-dir-backed ModelConfigLoader fixtures (deep-merge patch, rename + conflict, path separator guard, toggle/pin enable/disable, sync callback). Assisted-by: Claude:claude-opus-4-7 [Read] [Edit] [Write] [Bash] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(assistant): LocalAI Assistant chat modality with in-memory MCP server Adds a chat modality, admin-only, that wires the chat session to an in-memory MCP server exposing LocalAI's own admin/management surface as tools. An admin can install models, manage backends, edit configs and check status by chatting; the LLM calls tools like gallery_search, install_model, import_model_uri, list_installed_models, edit_model_config and surfaces the results. Same Go package powers two modes: pkg/mcp/localaitools/ NewServer(client, opts) builds an MCP server that registers the 19-tool admin catalog. The LocalAIClient interface has two impls: - inproc.Client — calls services directly (no HTTP loopback, no synthetic admin API key). Used in-process by the chat handler. - httpapi.Client — calls the LocalAI REST API. Used by the new `local-ai mcp-server --target=…` subcommand to control a remote LocalAI from a stdio MCP host. Tools and their embedded skill prompts are agnostic to which client backs them. Skill prompts are markdown files under prompts/, embedded via go:embed and assembled into the system prompt at server init. Wiring: - core/http/endpoints/mcp/localai_assistant.go — process-wide holder that spins up the in-memory MCP server once at Application start using paired net.Pipe transports, then reuses LocalToolExecutor (no fork) for every chat request that opts in. - core/http/endpoints/openai/chat.go — small branch ahead of the existing MCP block: when metadata.localai_assistant=true, defense-in-depth admin check + executor swap + system-prompt injection. All downstream tool dispatch is unchanged. - core/http/auth/{permissions,features}.go — adds FeatureLocalAIAssistant; gating happens at the chat handler entry plus admin-only `/api/settings`. - core/cli/{run.go,cli.go,mcp_server.go} — LOCALAI_DISABLE_ASSISTANT flag (runtime-toggleable via Settings, no restart), plus `local-ai mcp-server` stdio subcommand. - core/config/runtime_settings.go — `localai_assistant_enabled` runtime setting; the chat handler reads `DisableLocalAIAssistant` live at request entry. UI: - Home.jsx — prominent self-explanatory CTA card on first run ("Manage LocalAI by chatting"); collapses to a compact "Manage by chat" button in the quick-links row once used, persisted via localStorage. - Chat.jsx — admin-only "Manage" toggle in the chat header, "Manage mode" badge, dedicated empty-state copy, starter chips. - Settings.jsx — "LocalAI Assistant" section with the runtime enable toggle. - useChat.js — `localaiAssistant` flag on the chat schema; injects `metadata.localai_assistant=true` on requests when active. Distributed mode: the in-memory MCP server lives only on the head node; inproc.Client wraps already-distributed-aware services so installs propagate to workers via the existing GalleryService machinery. Documentation: `.agents/localai-assistant-mcp.md` is the contributor contract — when adding an admin REST endpoint, also add a LocalAIClient method, an inproc + httpapi impl, a tool registration, and a skill prompt update; the AGENTS.md index links to it. Out of scope (follow-ups): per-tool RBAC granularity for non-admin read-only access; streaming mcp_tool_progress for long installs; React Vitest rig for the UI changes. Assisted-by: Claude:claude-opus-4-7 [Read] [Edit] [Write] [Bash] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(assistant): extract tool/capability/MiB/server-name constants The MCP tool surface, capability tag set, server-name default, and the chat-handler metadata key were repeated as bare string literals across seven files. Renaming any one required hand-editing every call site and risked code/test/prompt drift. This pulls them into typed constants: - pkg/mcp/localaitools/tools.go — Tool* constants for the 19 MCP tools, plus DefaultServerName. - pkg/mcp/localaitools/capability.go — typed Capability + constants for the capability tag set the LLM passes to list_installed_models. The type rides through LocalAIClient.ListInstalledModels and replaces the triplet of "embed"/"embedding"/"embeddings" with the single CapabilityEmbeddings. - pkg/mcp/localaitools/inproc/client.go — bytesPerMiB constant for the VRAMEstimate byte→MB conversion. - core/http/endpoints/mcp/tools.go — MetadataKeyLocalAIAssistant for the "localai_assistant" request-metadata key consumed by the chat handler. Tool registrations, the test catalog, the dispatch table, the validation fixtures, and the fake/stub clients all reference the constants. The embedded skill prompts under prompts/ keep their bare strings (go:embed markdown can't import Go constants); the existing TestPromptsContain SafetyAnchors guards the alignment. No behaviour change. All tests pass with -race. Assisted-by: Claude:claude-opus-4-7 [Read] [Edit] [Write] [Bash] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(modeladmin): typed Action for ToggleState/TogglePinned The toggle/pin verbs were bare strings everywhere — handler signatures, service implementations, MCP tool args, the fake/stub clients, the inproc and httpapi LocalAIClient impls, plus 4 test files. A typo in any caller silently fell through to the runtime "must be 'enable' or 'disable'" check. Introduce core/services/modeladmin.Action (string alias) with ActionEnable, ActionDisable, ActionPin, ActionUnpin and a small Valid helper. The compiler now catches mismatches at every boundary; renames ripple through one source of truth. LocalAIClient.ToggleModelState/Pinned signatures change to take modeladmin.Action. The package is brand-new and unreleased so this is a free public-API tightening. Assisted-by: Claude:claude-opus-4-7 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(assistant): respect ctx cancellation on gallery channel sends InstallModel, DeleteModel, ImportModelURI, InstallBackend and UpgradeBackend all pushed onto galleryop channels with bare sends. If the worker was paused or the buffer full, the chat-handler goroutine blocked forever — the LLM kept polling and the request leaked. Wrap the five sends in a sendModelOp/sendBackendOp helper that selects on ctx.Done() so a cancelled chat completion surfaces context.Canceled back to the LLM instead of hanging. Adds inproc/client_test.go with a pre-cancelled-ctx regression test on InstallModel; the helpers are shared so the same guarantee covers the other four call sites. Assisted-by: Claude:claude-opus-4-7 [Edit] [Write] [Bash] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(assistant): graceful shutdown for in-memory holder and stdio CLI Two related leaks: - Application.start() built the LocalAIAssistantHolder but never wired Close() into the graceful-termination chain — the in-memory MCP transport pair stayed alive until process exit, and the goroutines behind net.Pipe() didn't drain. Hook into the existing signals.RegisterGracefulTerminationHandler chain (same pattern as core/http/endpoints/mcp/tools.go:770). - core/cli/mcp_server.go ran srv.Run with context.Background(); a Ctrl-C from the host (Claude Desktop, mcphost, npx inspector) or a SIGTERM from process supervision left the stdio loop reading from a closed pipe. Switch to signal.NotifyContext to surface the signal through ctx and let srv.Run drain. Assisted-by: Claude:claude-opus-4-7 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(assistant): typed HTTPError + propagate prompt walk error The httpapi client detected "no such job" by substring-matching on the error string ("404", "could not find") — brittle to status-code formatting changes and to LocalAI fixing /models/jobs/:uuid to return a proper 404. Replace with a typed *HTTPError whose Is() method honours errors.Is(err, ErrHTTPNotFound). The 500-with-"could not find" branch stays as a transitional fallback documented in Is(). Same change covers ListNodes' 404 fallback for the /api/nodes endpoint. Adds httptest tests for both 404 and the legacy 500 path, plus a direct errors.Is exposure test so external callers (the standalone stdio CLI host) can match without re-string-parsing. Also tightens prompts.SystemPrompt: panic when fs.WalkDir on the embedded FS fails. The only realistic cause is a build-time //go:embed misconfiguration; serving an empty system prompt to the LLM is much worse than crashing init. TestSystemPromptIncludesAllEmbeddedFiles catches regressions in CI. Assisted-by: Claude:claude-opus-4-7 [Edit] [Write] [Bash] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(modeladmin): atomic writes for model config files The five sites that wrote model YAML used os.WriteFile, which opens with O_TRUNC|O_WRONLY|O_CREATE. A crash mid-write left the destination truncated and the model unloadable until manual repair. Pre-existing behaviour inherited from the original endpoint handlers — fix once now that there's a single helper. Adds writeFileAtomic: writes to a sibling temp file, chmods, syncs via Close(), then os.Rename. Same-directory temp keeps the rename atomic on the same filesystem; cleanup runs on every error path so stray temps don't accumulate. No new dependency. Applied to: - ConfigService.PatchConfig - ConfigService.EditYAML (both rename and in-place branches) - mutateYAMLBoolFlag (drives ToggleState + TogglePinned) atomic_test.go covers the happy path plus a read-only-dir failure case that asserts the original file is preserved (skipped on Windows where the chmod trick is POSIX-specific). Assisted-by: Claude:claude-opus-4-7 [Edit] [Write] [Bash] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore(assistant): prune dead code, mark stub, document conventions Three small cleanups landing together: - Drop the unused errNotImplemented sentinel from inproc/client.go. All five methods that used to return it are wired to modeladmin helpers since the Phase B commit; the package var is dead. - Annotate httpapi.Client.GetModelConfig as a known stub. LocalAI's /models/edit/:name returns rendered HTML, not JSON, so the standalone CLI's get_model_config tool surfaces a clear error to the LLM. A future JSON-only /api/models/config-yaml/:name endpoint is tracked in the agent contract; FIXME points at it. - Extend `.agents/localai-assistant-mcp.md` with a "Code conventions" section that documents the audit-driven rules: tool/Capability/Action constants, errors.Is over substring matching, ctx-aware channel sends, atomic writes, and graceful shutdown. Refresh the file map so it lists tools.go and capability.go and drops the removed tools_bootstrap.go. The tools_models.go diff is a comment-only change explaining why the ModelName empty-string check stays at the tool layer (consistency across LocalAIClient implementations, since the SDK schema validator only enforces presence, not non-empty). Assisted-by: Claude:claude-opus-4-7 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(assistant): convert test files to ginkgo + gomega The repo convention (per core/http/endpoints/localai/*_test.go, core/gallery/**, etc.) is Ginkgo v2 with Gomega assertions. The tests I introduced for the assistant feature used vanilla testing.T, which made them stand out and stripped the BDD structure the rest of the suite relies on. Convert every test file in the assistant scope to Ginkgo: pkg/mcp/localaitools/ dto_test.go — Describe("DTOs round-trip through JSON") prompts_test.go — Describe("SystemPrompt assembler") server_test.go — Describe("Server tool catalog"), Describe("Tool dispatch"), Describe("Tool error surfacing"), Describe("Argument validation"), Describe("Concurrent tool calls") parity_test.go — Describe("LocalAIClient parity"), hosts the suite's single RunSpecs (the file is package localaitools_test so it can import httpapi without an import cycle; Ginkgo aggregates Describes from both the internal and external test packages into one run). httpapi/client_test.go — Describe("httpapi.Client against the LocalAI admin REST surface"), Describe("ErrHTTPNotFound"), Describe("Bearer token") inproc/client_test.go — Describe("inproc.Client cancellation") core/services/modeladmin/ config_test.go — Describe("ConfigService") with sub-Describes for GetConfig, PatchConfig, EditYAML state_test.go — Describe("ConfigService.ToggleState") pinned_test.go — Describe("ConfigService.TogglePinned") atomic_test.go — Describe("writeFileAtomic") core/http/endpoints/mcp/ localai_assistant_test.go — Describe("LocalAIAssistantHolder") Each package gets a `*_suite_test.go` with the standard `RegisterFailHandler(Fail) + RunSpecs(t, "...")` boilerplate. Helpers that previously took *testing.T (newTestService, writeModelYAML, readMap, sortedStrings, sortGalleries, etc.) drop the *T receiver and use Gomega Expectations directly. tmp dirs come from GinkgoT().TempDir(). No semantic change to test coverage — every original assertion has a direct Gomega counterpart. All suites pass with -race. Assisted-by: Claude:claude-opus-4-7 [Read] [Edit] [Write] [Bash] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test+docs(assistant): drift detector for Tool ↔ REST route mapping Honest gap from the audit: the parity_test.go suite only checks four methods, and uses the same httpapi.Client for both sides — it asserts stability of the DTO shapes, not equivalence between in-process and HTTP. If a contributor adds an admin REST endpoint without an MCP tool, or a tool without a matching httpapi route, both surfaces silently diverge. Add a coverage test plus stronger docs: - pkg/mcp/localaitools/coverage_test.go introduces a hand-maintained toolToHTTPRoute map: every Tool* constant must list the REST endpoint the httpapi.Client hits (or "(none)" with a documented reason). Two Ginkgo specs assert the map and the published catalog stay in sync — one fails when a Tool is added without a route entry, the other fails when a route entry references a tool that no longer exists. Verified by removing the ToolDeleteModel entry locally; the test fired with a clear message pointing the contributor at the file. Deliberate non-test: we don't enumerate live admin REST routes from here. Walking the route registry requires booting Application; parsing core/http/routes/localai.go is brittle. The "new admin REST endpoint → MCP tool" direction stays a PR checklist item — see below. - AGENTS.md gets a new Quick Reference bullet that calls out the rule and points at the test by name. - .agents/api-endpoints-and-auth.md tightens the existing "Companion: MCP admin tool surface" subsection from "if useful, consider..." to "MUST be considered, with three concrete outcomes (tool added, deliberately skipped with documented reason, or forgot — which breaks the contract)". Adds a checklist item at the bottom of the file's authoritative checklist. Assisted-by: Claude:claude-opus-4-7 [Read] [Edit] [Write] [Bash] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(assistant): drop duplicate DTOs, surface canonical types Audit feedback: localaitools/dto.go reinvented several types that already existed in the codebase. Replace the duplicates with the canonical types so the LLM-visible wire format stays aligned with the rest of LocalAI by construction (no parallel structs to keep in sync). Removed (and the canonical type now used by the LocalAIClient interface): localaitools.Gallery → config.Gallery localaitools.GalleryModelHit → gallery.Metadata localaitools.VRAMEstimate → vram.EstimateResult Tightened scope: localaitools.Backend → kept, but reduced to {Name, Installed}. ListKnownBackends now returns []schema.KnownBackend (the canonical type already used by REST /backends/known). Kept with documented rationale: localaitools.JobStatus — galleryop.OpStatus has Error error which marshals to "{}". JobStatus is the JSON-friendly mirror. localaitools.Node — nodes.BackendNode carries gorm internals + token hash; we expose only the LLM-relevant fields. ImportModelURIRequest/Response — schema.ImportModelRequest and GalleryResponse are wire-shaped, mine are LLM-shaped (BackendPreference flat, AmbiguousBackend exposed). Side wins: - Drop bytesPerMiB; vram.EstimateResult already carries human-readable display strings (size_display, vram_display) the LLM uses directly. - Drop the handler-private vramEstimateRequest in core/http/endpoints/localai/vram.go and bind directly into modeladmin.VRAMRequest (now JSON-tagged). Both clients pass through these types now where possible (e.g. ListGalleries in inproc.Client is a one-liner returning AppConfig.Galleries; httpapi.Client.GallerySearch decodes straight into []gallery.Metadata). All tests green with -race. Assisted-by: Claude:claude-opus-4-7 [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(assistant): extract REST route paths into named constants httpapi.Client had 18 bare-string path sites scattered across methods. Pull them into pkg/mcp/localaitools/httpapi/routes.go: static paths as package-private constants, dynamic paths as small builders that handle url.PathEscape on segment values. No behaviour change. Drops the now-unused net/url import from client.go since path escaping moved into routes.go alongside the path it applies to. Local-only by design: the server-side registrations in core/http/routes/localai.go remain bare strings. Sharing constants across the pkg/ ↔ core/ boundary would invert the layering today; the existing Tool↔REST drift-detector in coverage_test.go is the safety net for that direction. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-7 [Claude Code] * docs(assistant): align with shipped UI and dropped bootstrap env vars The LocalAI Assistant doc still described the older iteration: - The in-chat toggle was renamed from "Admin" to "Manage" (the badge is now "Manage mode" and the home page exposes a "Manage by chat" CTA). - LOCALAI_ASSISTANT_BOOTSTRAP_MODEL / --localai-assistant-bootstrap-model and the bootstrap_default_model tool were removed — admins pick a model from the existing selector instead, no env-var configuration required. - The shipped tool catalog includes import_model_uri but didn't appear in the doc; bootstrap_default_model appeared but no longer exists. - The Settings → LocalAI Assistant runtime toggle wasn't mentioned as the preferred way to disable without restart. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-7 [Claude Code] --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> |
||
|
|
315b634a91 |
feat: improve CLI error messages with actionable guidance (#8880)
- transcript.go: Model not found error now suggests available models commands - util.go: GGUF error explains format and how to get models - worker_p2p.go: Token error explains purpose and how to obtain one - run.go: Startup failure includes troubleshooting steps and docs link - model_config_loader.go: Config validation errors include file path and guidance Refs: H2 - UX Review Issue Signed-off-by: localai-bot <localai-bot@noreply.github.com> Co-authored-by: localai-bot <localai-bot@noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
2865f0f8d3 |
feat(ux): backend management enhancement (#9325)
* feat: add PreferDevelopmentBackends setting, expose isMeta/isDevelopment in API - Add PreferDevelopmentBackends config field, CLI flag, runtime setting - Add IsDevelopment() method to GalleryBackend - Use AvailableBackendsUnfiltered in UI API to show all backends - Expose isMeta, isDevelopment, preferDevelopmentBackends in backend API response * feat: upgrade banner with Upgrade All button, detect pre-existing backends - Add upgrade banner on Backends page showing count and Upgrade All button - Fix upgrade detection for backends installed before version tracking: flag as upgradeable when gallery has a version but installed has none - Fix OCI digest check to flag backends with no stored digest as upgradeable |