Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
38b80f5
feat(addons): generate and load a static carrier key set
mudler Oct 23, 2026
b912f93
feat(addons): add util addons-carrier-keys
mudler Oct 23, 2026
b894359
feat(addons): add the owner and frontend roles to local-ai run
mudler Oct 23, 2026
d85e28e
feat(addons): keep the frontend role away from owner state
mudler Oct 23, 2026
0586d1e
test(addons): run the owner and frontends as separate applications
mudler Oct 23, 2026
0ad24e8
docs(addons): describe running the owner as its own workload
mudler Oct 23, 2026
35dcd01
feat(addons): gate the addons feature in /api/features
mudler Oct 23, 2026
91399a2
feat(addons): answer 503 on every add-on route when add-ons are off
mudler Oct 23, 2026
5277f95
feat(addons): build no carrier for a frontend with add-ons off
mudler Oct 23, 2026
4677cd0
feat(ui): hide add-on entries when the addons feature is off
mudler Oct 23, 2026
499c502
docs(addons): describe the addons feature gate
mudler Oct 23, 2026
9eb560a
fix(addons): give a frontend no add-on state root
mudler Oct 23, 2026
7d90ff9
fix(addons): make the carrier signing key optional for the owner
mudler Oct 23, 2026
75913fd
Keep the owner address out of the add-on identity file
mudler Oct 23, 2026
fa681d7
docs(addons): state that an existing root loads under the owner role
mudler Oct 23, 2026
6a00b07
fix(addons): refuse library-source routes on a frontend
mudler Oct 23, 2026
3e2602b
fix(addons): put the reason in the unavailable body
mudler Oct 23, 2026
c262cc9
feat(addons): serve library calls on the add-on owner
mudler Oct 23, 2026
a317009
feat(addons): forward library routes from a frontend to the owner
mudler Oct 23, 2026
804d250
docs(addons): document library operations on a frontend
mudler Oct 23, 2026
62d3b77
feat(addons): check the shared models path with a probe
mudler Oct 23, 2026
8df519a
docs(api): describe the library refusal and forwarding
mudler Oct 23, 2026
ce1356e
feat(addons): let frontends run the backend update check through the …
mudler Oct 23, 2026
a66119e
feat(mcp): expose the backend update check as a tool
mudler Oct 23, 2026
5887147
feat(api): add export status routes for fine-tuning and quantization
mudler Oct 23, 2026
9b94b40
feat(addons): disable exports when the models probe fails
mudler Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions addons/fine-tuning/ui/src/FineTune.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export function createFineTune(ctx, api, shared) {
// events while the job is active; a finished job is read from the job record.
// `seed` starts the view with an event and checkpoints already read; tests use
// it to draw a state the stream would reach.
function JobView({ job, onStop, onReuse, onResume, onTerminal, exportCheckpoint, seed }) {
function JobView({ job, onStop, onReuse, onResume, onTerminal, exportCheckpoint, seed, modelExports = true }) {
const isAdmin = viewer.isAdmin()
const [events, setEvents] = useState([])
const [latest, setLatest] = useState(seed?.latest ?? null)
Expand Down Expand Up @@ -339,7 +339,7 @@ export function createFineTune(ctx, api, shared) {
<div className="bt-rowacts">
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" onClick={() => onResume(cp)} title={t('checkpoints.resumeTitle')}><Icon name="play" /> {t('checkpoints.resume')}</button>
{isAdmin && (
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" onClick={() => exportCheckpoint(cp)} title={t('checkpoints.exportTitle')}><Icon name="save" /> {t('checkpoints.export')}</button>
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" onClick={() => exportCheckpoint(cp)} disabled={!modelExports} title={modelExports ? t('checkpoints.exportTitle') : t('export.blocked')}><Icon name="save" /> {t('checkpoints.export')}</button>
)}
</div>
</td>
Expand All @@ -355,7 +355,7 @@ export function createFineTune(ctx, api, shared) {
}

// What to do with a finished job: export it as a model, then use it.
function ExportForm({ job, prefilledCheckpoint, seed }) {
function ExportForm({ job, prefilledCheckpoint, seed, modelExports = true }) {
const [checkpoints, setCheckpoints] = useState(seed?.checkpoints || [])
const [exportFormat, setExportFormat] = useState('lora')
const [quantMethod, setQuantMethod] = useState('q4_k_m')
Expand Down Expand Up @@ -484,10 +484,11 @@ export function createFineTune(ctx, api, shared) {
<input id="ft-export-name" type="text" className="dk-input" value={modelName} onChange={e => setModelName(e.target.value)} placeholder="my-finetuned-model" />
</Field>
<div className="bt-actions">
<button type="button" className="dk-btn dk-btn--primary" onClick={handleExport} disabled={exporting} aria-busy={exporting || undefined}>
<button type="button" className="dk-btn dk-btn--primary" onClick={handleExport} disabled={exporting || !modelExports} aria-busy={exporting || undefined}>
{exporting ? <><ui.Spinner size="sm" /> {t('export.exporting')}</> : <><Icon name="download" /> {t('export.go')}</>}
</button>
</div>
{!modelExports && <p className="bt-status bt-status--error" role="status" data-testid="export-blocked">{t('export.blocked')}</p>}
{message && (!exportedModelName || exportFailed) && (
<p className={`bt-status${exportFailed ? ' bt-status--error' : ''}`} role="status" data-testid="export-status">
{exporting && <ui.Spinner size="sm" />} {message}
Expand All @@ -501,7 +502,7 @@ export function createFineTune(ctx, api, shared) {

// The wrapper of the export controls. Registering a model is for
// administrators; anyone else sees why the controls are missing.
function ExportPanel({ job, prefilledCheckpoint, seed }) {
function ExportPanel({ job, prefilledCheckpoint, seed, modelExports = true }) {
if (!job || !TERMINAL.includes(job.status)) return null
if (!viewer.isAdmin()) {
return (
Expand All @@ -511,7 +512,19 @@ export function createFineTune(ctx, api, shared) {
</section>
)
}
return <ExportForm job={job} prefilledCheckpoint={prefilledCheckpoint} seed={seed} />
return <ExportForm job={job} prefilledCheckpoint={prefilledCheckpoint} seed={seed} modelExports={modelExports} />
}

// Whether this instance may write an exported model. Only a clear false turns
// the controls off: a failed or slow answer leaves them on, as the server does.
function useModelExports() {
const [allowed, setAllowed] = useState(true)
useEffect(() => {
let off = false
api.fineTune.status().then(r => { if (!off && r && r.model_exports === false) setAllowed(false) }).catch(() => {})
return () => { off = true }
}, [])
return allowed
}

// The machine facts for the checks: the one request useResources makes, as
Expand All @@ -538,6 +551,7 @@ export function createFineTune(ctx, api, shared) {
function FineTuneBody({ path }) {
const isAdmin = viewer.isAdmin()
const resources = useResources(10000)
const modelExports = useModelExports()
const facts = useMemo(() => machineFacts(resources), [resources])

const [jobs, setJobs] = useState([])
Expand Down Expand Up @@ -951,8 +965,9 @@ export function createFineTune(ctx, api, shared) {
onResume={handleResumeFromCheckpoint}
onTerminal={loadJobs}
exportCheckpoint={setExportCheckpoint}
modelExports={modelExports}
/>
<ExportPanel job={selectedJob} prefilledCheckpoint={exportCheckpoint} />
<ExportPanel job={selectedJob} prefilledCheckpoint={exportCheckpoint} modelExports={modelExports} />
</>
) : (
<form onSubmit={handleSubmit} className="bt-form-page" data-testid="fine-tune-form">
Expand Down
2 changes: 2 additions & 0 deletions addons/fine-tuning/ui/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export function createApi(ctx) {

return {
fineTune: {
// {model_exports: bool}: false when this instance cannot share the models path of the owner.
status: () => json('/api/fine-tuning/status'),
listBackends: () => json('/api/fine-tuning/backends'),
startJob: (data) => post('/api/fine-tuning/jobs', data),
listJobs: () => json('/api/fine-tuning/jobs'),
Expand Down
1 change: 1 addition & 0 deletions addons/fine-tuning/ui/src/i18n/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ export default {
"checkpoints.resumeTitle": "Resume training from this checkpoint",
"checkpoints.export": "Export",
"checkpoints.exportTitle": "Export this checkpoint",
"export.blocked": "Export is off on this server because it does not share the models folder of the add-on host.",
"export.title": "Export as a model",
"export.lede": "Exporting turns the result into a model LocalAI can load.",
"export.checkpoint": "Checkpoint",
Expand Down
29 changes: 25 additions & 4 deletions addons/fine-tuning/ui/test/page.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ test('page: the API helper uses the core fine-tuning paths and bodies', async ()
json: async (p, i) => { seen.push([p, i]); return {} },
}
const api = createApi(ctx)
await api.fineTune.status()
await api.fineTune.listBackends()
await api.fineTune.listJobs()
await api.fineTune.getJob('a b')
Expand All @@ -56,13 +57,13 @@ test('page: the API helper uses the core fine-tuning paths and bodies', async ()
await api.resources.get()
const paths = seen.map(s => `${s[1]?.method || 'GET'} ${s[0]}`)
assert.deepEqual(paths, [
'GET /api/fine-tuning/backends', 'GET /api/fine-tuning/jobs', 'GET /api/fine-tuning/jobs/a%20b',
'GET /api/fine-tuning/status', 'GET /api/fine-tuning/backends', 'GET /api/fine-tuning/jobs', 'GET /api/fine-tuning/jobs/a%20b',
'POST /api/fine-tuning/jobs', 'POST /api/fine-tuning/jobs/j1/stop?save_checkpoint=true',
'POST /api/fine-tuning/jobs/j1/stop?save_checkpoint=false', 'DELETE /api/fine-tuning/jobs/j1',
'GET /api/fine-tuning/jobs/j1/checkpoints', 'POST /api/fine-tuning/jobs/j1/export', 'GET /api/resources',
])
assert.equal(seen[3][1].body, JSON.stringify({ model: 'm' }))
assert.equal(seen[3][1].headers['Content-Type'], 'application/json')
assert.equal(seen[4][1].body, JSON.stringify({ model: 'm' }))
assert.equal(seen[4][1].headers['Content-Type'], 'application/json')
})

test('page: the dataset upload is a FormData body with no Content-Type header', async () => {
Expand Down Expand Up @@ -222,7 +223,7 @@ test('i18n: en.js repeats the host strings and adds only the keys the add-on own
if (k in tools) assert.equal(v, tools[k], k)
else own.push(k)
}
assert.deepEqual(own.sort(), ['export.adminOnly.text', 'export.adminOnly.title', 'run.unknownPercent'])
assert.deepEqual(own.sort(), ['export.adminOnly.text', 'export.adminOnly.title', 'export.blocked', 'run.unknownPercent'])
assert.equal(Object.keys(en).some(k => k.startsWith('quantize.')), false)
})

Expand All @@ -238,3 +239,23 @@ test('source: no file imports a core UI file or the router', () => {
assert.doesNotMatch(text, /\bEventSource\b/, f)
}
})

test('export: the export controls are off with the reason when the models probe fails', async () => {
const done = job({ status: 'completed' })
const seed = { checkpoints: [{ path: '/srv/c1', step: 10, loss: 0.5, epoch: 1 }] }
const { mod, sdk } = await load()
const { ExportPanel, JobView } = (await mod.__partsFor(sdk)).parts
const blocked = renderToString(React.createElement(ExportPanel, { job: done, seed, modelExports: false }))
assert.match(blocked, /data-testid="export-blocked"/)
assert.match(blocked, new RegExp(en['export.blocked'].slice(0, 30)))
assert.match(blocked, /<button[^>]*disabled=""[^>]*>[^]*?<\/button>/)
const open = renderToString(React.createElement(ExportPanel, { job: done, seed }))
assert.doesNotMatch(open, /data-testid="export-blocked"/)
const props = { job: done, seed, onStop() {}, onReuse() {}, onResume() {}, onTerminal() {}, exportCheckpoint() {} }
const off = renderToString(React.createElement(JobView, { ...props, modelExports: false }))
const on = renderToString(React.createElement(JobView, props))
const exportBtn = (html) => html.match(/<button[^>]*title="[^"]*"[^>]*><i data-icon="save"/)[0]
assert.match(exportBtn(off), /disabled=""/)
assert.match(exportBtn(off), new RegExp(en['export.blocked'].slice(0, 30)))
assert.doesNotMatch(exportBtn(on), /disabled/)
})
24 changes: 19 additions & 5 deletions addons/quantization/ui/src/Quantize.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export function createQuantize(ctx, api, shared) {
}

// What to do with a finished quantization: import it as a model, then use it.
function ResultPanel({ job, onRefresh }) {
function ResultPanel({ job, onRefresh, modelExports = true }) {
const [modelName, setModelName] = useState('')
const [importing, setImporting] = useState(false)
const [error, setError] = useState('')
Expand Down Expand Up @@ -130,11 +130,12 @@ export function createQuantize(ctx, api, shared) {
<input id="qz-import-name" className="dk-input dk-input--mono" placeholder={t('quantize.importNamePlaceholder')} value={modelName} onChange={e => setModelName(e.target.value)} />
</Field>
<div className="bt-actions">
<button type="button" className="dk-btn dk-btn--primary" onClick={handleImport} disabled={importing} aria-busy={importing || undefined} data-testid="quantize-import">
<button type="button" className="dk-btn dk-btn--primary" onClick={handleImport} disabled={importing || !modelExports} aria-busy={importing || undefined} data-testid="quantize-import">
<Icon name="save" /> {t('quantize.import')}
</button>
<a className="dk-btn dk-btn--secondary" href={download} download><Icon name="download" /> {t('quantize.download')}</a>
</div>
{!modelExports && <p className="bt-status bt-status--error" role="status" data-testid="quantize-import-blocked">{t('quantize.importBlocked')}</p>}
</>
)}
</div>
Expand All @@ -148,7 +149,7 @@ export function createQuantize(ctx, api, shared) {
// the result. The server streams progress events while the job is active; a
// finished job is read from the job record. `seed` starts the view with an
// event already read; tests use it to draw a state the stream would reach.
function JobView({ job, onStop, onReuse, onTerminal, onRefresh, seed }) {
function JobView({ job, onStop, onReuse, onTerminal, onRefresh, seed, modelExports = true }) {
const [latest, setLatest] = useState(seed?.latest ?? null)
const [log, setLog] = useState([])
const previousRef = useRef(null)
Expand Down Expand Up @@ -253,11 +254,23 @@ export function createQuantize(ctx, api, shared) {
)}

{(active || log.length > 0) && <JobLog lines={log} name={`quantize-${job.id?.slice(0, 8)}`} />}
<ResultPanel job={job} onRefresh={onRefresh} />
<ResultPanel job={job} onRefresh={onRefresh} modelExports={modelExports} />
</div>
)
}

// Whether this instance may write an imported model. Only a clear false turns
// the controls off: a failed or slow answer leaves them on, as the server does.
function useModelExports() {
const [allowed, setAllowed] = useState(true)
useEffect(() => {
let off = false
api.quantize.status().then(r => { if (!off && r && r.model_exports === false) setAllowed(false) }).catch(() => {})
return () => { off = true }
}, [])
return allowed
}

// The machine facts for the checks: the one request the page makes, as a
// poll that waits while the tab is hidden and catches up on return.
function useResources(interval) {
Expand All @@ -282,6 +295,7 @@ export function createQuantize(ctx, api, shared) {
function QuantizeBody({ path }) {
const isAdmin = viewer.isAdmin()
const resources = useResources(10000)
const modelExports = useModelExports()
const facts = useMemo(() => machineFacts(resources), [resources])

// A deep link names the model: ?model=<id> or the first path segment.
Expand Down Expand Up @@ -435,7 +449,7 @@ export function createQuantize(ctx, api, shared) {
)}

{selectedJob ? (
<JobView job={selectedJob} onStop={handleStop} onReuse={reuse} onTerminal={loadJobs} onRefresh={refreshSelected} />
<JobView job={selectedJob} onStop={handleStop} onReuse={reuse} onTerminal={loadJobs} onRefresh={refreshSelected} modelExports={modelExports} />
) : (
<form onSubmit={handleSubmit} className="bt-form-page" data-testid="quantize-form">
<Section done={!!model.trim()} title={t('quantize.sectionModel')}>
Expand Down
2 changes: 2 additions & 0 deletions addons/quantization/ui/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export function createApi(ctx) {

return {
quantize: {
// {model_exports: bool}: false when this instance cannot share the models path of the owner.
status: () => json('/api/quantization/status'),
listBackends: () => json('/api/quantization/backends'),
startJob: (data) => post('/api/quantization/jobs', data),
listJobs: () => json('/api/quantization/jobs'),
Expand Down
1 change: 1 addition & 0 deletions addons/quantization/ui/src/i18n/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ export default {
"quantize.file": "File",
"quantize.importName": "Model name",
"quantize.importNamePlaceholder": "Generated if empty",
"quantize.importBlocked": "Import is off on this server because it does not share the models folder of the add-on host.",
"quantize.import": "Import to LocalAI",
"quantize.importing": "Importing...",
"quantize.importFailed": "Import failed",
Expand Down
24 changes: 19 additions & 5 deletions addons/quantization/ui/test/page.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ test('page: the API helper uses the core quantization paths and bodies', async (
const seen = []
const ctx = { core: { fetch: async (p, i) => { seen.push([p, i]); return { ok: true } } }, json: async (p, i) => { seen.push([p, i]); return {} } }
const api = createApi(ctx)
await api.quantize.status()
await api.quantize.listBackends()
await api.quantize.listJobs()
await api.quantize.getJob('a b')
Expand All @@ -58,13 +59,13 @@ test('page: the API helper uses the core quantization paths and bodies', async (
await api.resources.get()
const paths = seen.map(s => `${s[1]?.method || 'GET'} ${s[0]}`)
assert.deepEqual(paths, [
'GET /api/quantization/backends', 'GET /api/quantization/jobs', 'GET /api/quantization/jobs/a%20b',
'GET /api/quantization/status', 'GET /api/quantization/backends', 'GET /api/quantization/jobs', 'GET /api/quantization/jobs/a%20b',
'POST /api/quantization/jobs', 'POST /api/quantization/jobs/j1/stop', 'DELETE /api/quantization/jobs/j1',
'POST /api/quantization/jobs/j1/import', 'GET /api/resources',
])
assert.equal(seen[3][1].body, JSON.stringify({ model: 'm' }))
assert.equal(seen[3][1].headers['Content-Type'], 'application/json')
assert.equal(seen[6][1].body, JSON.stringify({ name: 'n' }))
assert.equal(seen[4][1].body, JSON.stringify({ model: 'm' }))
assert.equal(seen[4][1].headers['Content-Type'], 'application/json')
assert.equal(seen[7][1].body, JSON.stringify({ name: 'n' }))
})

test('page: a failed call throws the server message', async () => {
Expand Down Expand Up @@ -194,7 +195,7 @@ test('i18n: en.js repeats the host strings and adds only the keys the add-on own
if (k in tools) assert.equal(v, tools[k], k)
else own.push(k)
}
assert.deepEqual(own.sort(), ['run.unknownPercent'])
assert.deepEqual(own.sort(), ['quantize.importBlocked', 'run.unknownPercent'])
assert.equal(Object.keys(en).some(k => k.startsWith('rewards.') || k.startsWith('checkpoints.') || k.startsWith('chart.')), false)
})

Expand All @@ -210,3 +211,16 @@ test('source: no file imports a core UI file or the router', () => {
assert.doesNotMatch(text, /\bEventSource\b/, f)
}
})

test('import: the import control is off with the reason when the models probe fails', async () => {
const { mod, sdk } = await load()
const { ResultPanel } = (await mod.__partsFor(sdk)).parts
const done = job({ status: 'completed', output_file: 'model-q4.gguf' })
const blocked = renderToString(React.createElement(ResultPanel, { job: done, onRefresh: async () => {}, modelExports: false }))
assert.match(blocked, /<button[^>]*disabled=""[^>]*data-testid="quantize-import"/)
assert.match(blocked, /data-testid="quantize-import-blocked"/)
assert.match(blocked, new RegExp(en['quantize.importBlocked'].slice(0, 30)))
const open = renderToString(React.createElement(ResultPanel, { job: done, onRefresh: async () => {} }))
assert.doesNotMatch(open, /quantize-import-blocked/)
assert.doesNotMatch(open, /<button[^>]*disabled=""[^>]*data-testid="quantize-import"/)
})
Loading
Loading