Files
LocalAI/core/services/finetune/service.go
LocalAI [bot] d7d7721eae feat(distributed): SyncedMap component + migrate finetune/quant/agent-tasks to cross-replica state (#10542)
* feat(distributed): add SyncedMap cross-replica in-memory state component

Introduce core/services/syncstate.SyncedMap[K,V]: a thread-safe in-memory map
that keeps itself consistent across frontend replicas via NATS, with an optional
pluggable durable Store and hydrate-from-source convergence.

Several features keep process-local state surfaced to the API (finetune/quant
jobs, agent tasks, model configs) and each hand-wired the same in-memory + NATS
broadcast + read-through-store legs - or forgot to, reintroducing cross-replica
staleness. SyncedMap makes that consistency a configuration choice:

- local writes mutate the map, write through the Store, then broadcast a delta;
- the apply path is memory-only and never re-publishes or re-writes the Store
  (structural echo-loop guard, mirroring galleryop.mergeStatus);
- on Start and on NATS reconnect the map re-hydrates from the source (Store, else
  Loader); an optional periodic Reconcile repairs silent drift;
- standalone mode (nil NATS client) is a strict in-memory no-op.

Reconnect re-hydrate is wired via a new *messaging.Client.OnReconnect callback,
consumed through an optional type-assertion so MessagingClient stays minimal.
Adds messaging.SubjectSyncStateDelta and a reusable testutil.FakeBus (synchronous
in-process MessagingClient with wildcard matching) for adopter tests.

Component only; service migrations follow in subsequent commits.

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

* refactor(finetune): back jobs with SyncedMap for cross-replica consistency

FineTuneService kept jobs in a process-local map and, although it wrote them to
Postgres, ListJobs/GetJob never read the store back and the wired natsClient was
never used - so in distributed mode a job created on one replica was invisible to
the others. Replace the map and the dead client with a syncstate.SyncedMap keyed
by job ID, value *schema.FineTuneJob (the exact REST shape, so responses are
unchanged).

- Add a Store adapter (core/services/finetune/syncstore.go) over FineTuneStore,
  plus FineTuneStore.ListAll (global hydrate; per-user List kept) and an
  idempotent Upsert (create-or-update; Create alone fails on dup key).
- Writes go through SyncedMap.Set/Delete (write-through + broadcast); reads use
  List/Get. The on-disk state.json path becomes the standalone Loader, keeping
  single-node restart recovery (stale->stopped / exporting->failed fixups).
- Fold SetNATSClient/SetFineTuneStore into NewFineTuneService; app.go passes the
  distributed NATS client + store when distributed, nil otherwise.

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

* refactor(agentpool): back agent tasks with SyncedMap for cross-replica consistency

AgentJobService.ListTasks read the process-local tasks map only, while ListJobs
already read through the DB persister + dispatcher NATS - so in distributed mode
a task created on one replica was invisible to the others. Back tasks with a
syncstate.SyncedMap keyed by task ID (value schema.Task, the exact REST shape);
jobs are left untouched.

- Store adapter (task_syncstore.go) over the existing JobPersister
  (LoadTasks/SaveTask/DeleteTask); reads svc.persister/userID live so a persister
  swap needs no rebuild. No new persister methods required.
- Task reads -> SyncedMap.List/Get; create/update -> Set (write-through +
  broadcast); delete -> Delete. The file persister now owns its own task set so
  the write-through path does not re-enter the SyncedMap lock (deadlock guard).
- The distributed NATS client is not available at construction (start() precedes
  initDistributed), so it is injected via SetTaskSyncNATS, which rebuilds the
  still-empty map before Start/hydrate. Wired at the main, restart, and per-user
  (UserServicesManager) distributed sites.

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

* refactor(quantization): back jobs with SyncedMap + durable QuantStore

QuantizationService kept jobs in a process-local map persisted only to a local
state.json, so in distributed mode jobs were neither visible across replicas nor
durable cluster-wide. Back jobs with a syncstate.SyncedMap keyed by job ID
(value *schema.QuantizationJob, the exact REST shape).

- New distributed.QuantStore (GORM, table quantization_jobs) mirroring
  FineTuneStore: Create/Get/ListAll/Upsert(idempotent)/Delete, registered for
  AutoMigrate via distributed.InitStores (Stores.Quant).
- New adapter (quantization/syncstore.go) over QuantStore implementing
  syncstate.Store, with record<->schema conversion.
- Reads go through List/Get, writes through Set/Delete (write-through +
  broadcast); state.json is kept as the standalone Loader for single-node restart
  recovery (stale-job fixups preserved).
- app.go passes the distributed NATS client + QuantStore when distributed, nil
  otherwise; Start/Close lifecycle mirrors finetune.

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

* fix(syncstate): annotate gosec G118 false positive on lifeCtx

gosec flagged the WithCancel in Start as "cancellation function not called"
because the returned cancel is stored on the struct rather than called/deferred
in scope. It is invoked in Close (covered by tests), and lifeCtx must outlive
Start to drive the reconnect/reconcile goroutines. Suppress the verified false
positive with a justified #nosec G118.

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

* test(distributed): e2e two-replica SyncedMap sync over real NATS + Postgres

Adds the real-infrastructure counterpart to the fake-bus unit tests, in the
existing distributed e2e suite (testcontainers NATS + PostgreSQL). Two SyncedMap
instances stand in for two frontend replicas - each with its OWN NATS connection
to a shared server and a SHARED Postgres store (the distributed-mode invariant) -
and assert, over the wire:

- a create on replica A is observed by replica B;
- an update and a delete propagate A -> B (delete prunes, which a reload cannot);
- a late-joining replica recovers a job it never received a delta for, via store
  hydrate on Start (the at-most-once gap a fake bus cannot exercise);
- a local Set is written through to the shared Postgres store.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
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>
2026-06-27 23:23:51 +02:00

782 lines
24 KiB
Go

package finetune
import (
"cmp"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery/importers"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/services/distributed"
"github.com/mudler/LocalAI/core/services/messaging"
"github.com/mudler/LocalAI/core/services/syncstate"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/utils"
"github.com/mudler/xlog"
"gopkg.in/yaml.v3"
)
// FineTuneService manages fine-tuning jobs and their lifecycle.
type FineTuneService struct {
appConfig *config.ApplicationConfig
modelLoader *model.ModelLoader
configLoader *config.ModelConfigLoader
// mu serializes the read-modify-write of job values. The SyncedMap guards its
// own map structure, but a job is a pointer mutated in place (e.g. the export
// goroutine), so the service still needs a lock to keep those field updates
// and the subsequent Set atomic with respect to readers.
mu sync.Mutex
// jobs is the cross-replica job store: an in-memory map kept consistent across
// replicas via NATS, optionally read-through to PostgreSQL in distributed mode.
jobs *syncstate.SyncedMap[string, *schema.FineTuneJob]
}
// NewFineTuneService creates a new FineTuneService. In distributed mode pass the
// shared NATS client and PostgreSQL store so jobs stay consistent across
// replicas; pass nil for both in standalone mode, where the disk Loader hydrates
// the map and there is nothing to broadcast.
func NewFineTuneService(
appConfig *config.ApplicationConfig,
modelLoader *model.ModelLoader,
configLoader *config.ModelConfigLoader,
nats messaging.MessagingClient,
store *distributed.FineTuneStore,
) *FineTuneService {
s := &FineTuneService{
appConfig: appConfig,
modelLoader: modelLoader,
configLoader: configLoader,
}
// Only attach a Store interface when a concrete store exists, otherwise the
// SyncedMap would see a non-nil interface wrapping a nil pointer and try to
// hydrate/write through a nil DB.
var syncStore syncstate.Store[string, *schema.FineTuneJob]
if store != nil {
syncStore = &fineTuneStoreAdapter{store: store}
}
s.jobs = syncstate.New(syncstate.Config[string, *schema.FineTuneJob]{
Name: "finetune.jobs",
Key: func(j *schema.FineTuneJob) string { return j.ID },
Nats: nats,
Store: syncStore,
Loader: s.loadJobsFromDisk, // ignored when Store is set (distributed mode)
})
// Hydrate + subscribe. A hydrate failure must not take the server down: log
// and continue degraded (standalone), mirroring the OpCache wiring.
if err := s.jobs.Start(appConfig.Context); err != nil {
xlog.Warn("FineTune SyncedMap start failed; running degraded", "error", err)
}
return s
}
// Close releases the SyncedMap subscription and background workers.
func (s *FineTuneService) Close() error {
return s.jobs.Close()
}
// fineTuneBaseDir returns the base directory for fine-tune job data.
func (s *FineTuneService) fineTuneBaseDir() string {
return filepath.Join(s.appConfig.DataPath, "fine-tune")
}
// jobDir returns the directory for a specific job.
func (s *FineTuneService) jobDir(jobID string) string {
return filepath.Join(s.fineTuneBaseDir(), jobID)
}
// saveJobState persists a job's state to disk as state.json.
func (s *FineTuneService) saveJobState(job *schema.FineTuneJob) {
dir := s.jobDir(job.ID)
if err := os.MkdirAll(dir, 0750); err != nil {
xlog.Error("Failed to create job directory", "job_id", job.ID, "error", err)
return
}
data, err := json.MarshalIndent(job, "", " ")
if err != nil {
xlog.Error("Failed to marshal job state", "job_id", job.ID, "error", err)
return
}
statePath := filepath.Join(dir, "state.json")
if err := os.WriteFile(statePath, data, 0640); err != nil {
xlog.Error("Failed to write job state", "job_id", job.ID, "error", err)
}
}
// loadJobsFromDisk scans the fine-tune directory for persisted jobs and returns
// them. It is the SyncedMap Loader used in standalone mode (no DB); the returned
// slice hydrates the map on Start.
func (s *FineTuneService) loadJobsFromDisk(_ context.Context) ([]*schema.FineTuneJob, error) {
baseDir := s.fineTuneBaseDir()
entries, err := os.ReadDir(baseDir)
if err != nil {
// Directory doesn't exist yet — that's fine, start empty.
return nil, nil
}
var jobs []*schema.FineTuneJob
for _, entry := range entries {
if !entry.IsDir() {
continue
}
statePath := filepath.Join(baseDir, entry.Name(), "state.json")
data, err := os.ReadFile(statePath)
if err != nil {
continue
}
var job schema.FineTuneJob
if err := json.Unmarshal(data, &job); err != nil {
xlog.Warn("Failed to parse job state", "path", statePath, "error", err)
continue
}
// Jobs that were running when we shut down are now stale
if job.Status == "queued" || job.Status == "loading_model" || job.Status == "loading_dataset" || job.Status == "training" || job.Status == "saving" {
job.Status = "stopped"
job.Message = "Server restarted while job was running"
}
// Exports that were in progress are now stale
if job.ExportStatus == "exporting" {
job.ExportStatus = "failed"
job.ExportMessage = "Server restarted while export was running"
}
jobs = append(jobs, &job)
}
if len(jobs) > 0 {
xlog.Info("Loaded persisted fine-tune jobs", "count", len(jobs))
}
return jobs, nil
}
// StartJob starts a new fine-tuning job.
func (s *FineTuneService) StartJob(ctx context.Context, userID string, req schema.FineTuneJobRequest) (*schema.FineTuneJobResponse, error) {
s.mu.Lock()
defer s.mu.Unlock()
jobID := uuid.New().String()
backendName := req.Backend
if backendName == "" {
backendName = "trl"
}
// Always use DataPath for output — not user-configurable
outputDir := filepath.Join(s.fineTuneBaseDir(), jobID)
// Build gRPC request
grpcReq := &pb.FineTuneRequest{
Model: req.Model,
TrainingType: req.TrainingType,
TrainingMethod: req.TrainingMethod,
AdapterRank: req.AdapterRank,
AdapterAlpha: req.AdapterAlpha,
AdapterDropout: req.AdapterDropout,
TargetModules: req.TargetModules,
LearningRate: req.LearningRate,
NumEpochs: req.NumEpochs,
BatchSize: req.BatchSize,
GradientAccumulationSteps: req.GradientAccumulationSteps,
WarmupSteps: req.WarmupSteps,
MaxSteps: req.MaxSteps,
SaveSteps: req.SaveSteps,
WeightDecay: req.WeightDecay,
GradientCheckpointing: req.GradientCheckpointing,
Optimizer: req.Optimizer,
Seed: req.Seed,
MixedPrecision: req.MixedPrecision,
DatasetSource: req.DatasetSource,
DatasetSplit: req.DatasetSplit,
OutputDir: outputDir,
JobId: jobID,
ResumeFromCheckpoint: req.ResumeFromCheckpoint,
ExtraOptions: req.ExtraOptions,
}
// Serialize reward functions into extra_options for the backend
if len(req.RewardFunctions) > 0 {
rfJSON, err := json.Marshal(req.RewardFunctions)
if err != nil {
return nil, fmt.Errorf("failed to serialize reward functions: %w", err)
}
if grpcReq.ExtraOptions == nil {
grpcReq.ExtraOptions = make(map[string]string)
}
grpcReq.ExtraOptions["reward_funcs"] = string(rfJSON)
}
// Load the fine-tuning backend (per-job model ID so multiple jobs can run concurrently)
modelID := backendName + "-finetune-" + jobID
backendModel, err := s.modelLoader.Load(
model.WithBackendString(backendName),
model.WithModel(backendName),
model.WithModelID(modelID),
)
if err != nil {
return nil, fmt.Errorf("failed to load backend %s: %w", backendName, err)
}
// Start fine-tuning via gRPC
result, err := backendModel.StartFineTune(ctx, grpcReq)
if err != nil {
return nil, fmt.Errorf("failed to start fine-tuning: %w", err)
}
if !result.Success {
return nil, fmt.Errorf("fine-tuning failed to start: %s", result.Message)
}
// Track the job
job := &schema.FineTuneJob{
ID: jobID,
UserID: userID,
Model: req.Model,
Backend: backendName,
ModelID: modelID,
TrainingType: req.TrainingType,
TrainingMethod: req.TrainingMethod,
Status: "queued",
OutputDir: outputDir,
ExtraOptions: req.ExtraOptions,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
Config: &req,
}
// Set write-through persists to PostgreSQL (distributed) and broadcasts to
// peer replicas; the disk state.json is written separately for restart
// recovery / standalone hydrate.
if err := s.jobs.Set(ctx, job); err != nil {
return nil, fmt.Errorf("failed to persist job: %w", err)
}
s.saveJobState(job)
return &schema.FineTuneJobResponse{
ID: jobID,
Status: "queued",
Message: result.Message,
}, nil
}
// GetJob returns a fine-tuning job by ID.
func (s *FineTuneService) GetJob(userID, jobID string) (*schema.FineTuneJob, error) {
s.mu.Lock()
defer s.mu.Unlock()
job, ok := s.jobs.Get(jobID)
if !ok {
return nil, fmt.Errorf("job not found: %s", jobID)
}
if userID != "" && job.UserID != userID {
return nil, fmt.Errorf("job not found: %s", jobID)
}
return job, nil
}
// ListJobs returns all jobs for a user, sorted by creation time (newest first).
func (s *FineTuneService) ListJobs(userID string) []*schema.FineTuneJob {
s.mu.Lock()
defer s.mu.Unlock()
var result []*schema.FineTuneJob
for _, job := range s.jobs.List() {
if userID == "" || job.UserID == userID {
result = append(result, job)
}
}
slices.SortFunc(result, func(a, b *schema.FineTuneJob) int {
return cmp.Compare(b.CreatedAt, a.CreatedAt)
})
return result
}
// StopJob stops a running fine-tuning job.
func (s *FineTuneService) StopJob(ctx context.Context, userID, jobID string, saveCheckpoint bool) error {
s.mu.Lock()
job, ok := s.jobs.Get(jobID)
if !ok {
s.mu.Unlock()
return fmt.Errorf("job not found: %s", jobID)
}
if userID != "" && job.UserID != userID {
s.mu.Unlock()
return fmt.Errorf("job not found: %s", jobID)
}
s.mu.Unlock()
// Kill the backend process directly
stopModelID := job.ModelID
if stopModelID == "" {
stopModelID = job.Backend + "-finetune"
}
s.modelLoader.ShutdownModel(stopModelID)
s.mu.Lock()
job.Status = "stopped"
job.Message = "Training stopped by user"
if err := s.jobs.Set(ctx, job); err != nil {
xlog.Warn("Failed to persist stopped job", "job_id", jobID, "error", err)
}
s.saveJobState(job)
s.mu.Unlock()
return nil
}
// DeleteJob removes a fine-tuning job and its associated data from disk.
func (s *FineTuneService) DeleteJob(userID, jobID string) error {
s.mu.Lock()
job, ok := s.jobs.Get(jobID)
if !ok {
s.mu.Unlock()
return fmt.Errorf("job not found: %s", jobID)
}
if userID != "" && job.UserID != userID {
s.mu.Unlock()
return fmt.Errorf("job not found: %s", jobID)
}
// Reject deletion of actively running jobs
activeStatuses := map[string]bool{
"queued": true, "loading_model": true, "loading_dataset": true,
"training": true, "saving": true,
}
if activeStatuses[job.Status] {
s.mu.Unlock()
return fmt.Errorf("cannot delete job %s: currently %s (stop it first)", jobID, job.Status)
}
if job.ExportStatus == "exporting" {
s.mu.Unlock()
return fmt.Errorf("cannot delete job %s: export in progress", jobID)
}
exportModelName := job.ExportModelName
// Delete write-through removes the DB row (distributed) and broadcasts the
// removal to peer replicas. DeleteJob has no ctx, so use Background.
if err := s.jobs.Delete(context.Background(), jobID); err != nil {
xlog.Warn("Failed to delete job from store", "job_id", jobID, "error", err)
}
s.mu.Unlock()
// Remove job directory (state.json, checkpoints, output)
jobDir := s.jobDir(jobID)
if err := os.RemoveAll(jobDir); err != nil {
xlog.Warn("Failed to remove job directory", "job_id", jobID, "path", jobDir, "error", err)
}
// If an exported model exists, clean it up too
if exportModelName != "" {
modelsPath := s.appConfig.SystemState.Model.ModelsPath
modelDir := filepath.Join(modelsPath, exportModelName)
configPath := filepath.Join(modelsPath, exportModelName+".yaml")
if err := os.RemoveAll(modelDir); err != nil {
xlog.Warn("Failed to remove exported model directory", "path", modelDir, "error", err)
}
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
xlog.Warn("Failed to remove exported model config", "path", configPath, "error", err)
}
// Reload model configs
if err := s.configLoader.LoadModelConfigsFromPath(modelsPath, s.appConfig.ToConfigLoaderOptions()...); err != nil {
xlog.Warn("Failed to reload configs after delete", "error", err)
}
}
xlog.Info("Deleted fine-tune job", "job_id", jobID)
return nil
}
// StreamProgress opens a gRPC progress stream and calls the callback for each update.
func (s *FineTuneService) StreamProgress(ctx context.Context, userID, jobID string, callback func(event *schema.FineTuneProgressEvent)) error {
s.mu.Lock()
job, ok := s.jobs.Get(jobID)
if !ok {
s.mu.Unlock()
return fmt.Errorf("job not found: %s", jobID)
}
if userID != "" && job.UserID != userID {
s.mu.Unlock()
return fmt.Errorf("job not found: %s", jobID)
}
s.mu.Unlock()
streamModelID := job.ModelID
if streamModelID == "" {
streamModelID = job.Backend + "-finetune"
}
backendModel, err := s.modelLoader.Load(
model.WithBackendString(job.Backend),
model.WithModel(job.Backend),
model.WithModelID(streamModelID),
)
if err != nil {
return fmt.Errorf("failed to load backend: %w", err)
}
return backendModel.FineTuneProgress(ctx, &pb.FineTuneProgressRequest{
JobId: jobID,
}, func(update *pb.FineTuneProgressUpdate) {
// Update job status and persist
s.mu.Lock()
if j, ok := s.jobs.Get(jobID); ok {
// Don't let progress updates overwrite terminal states
isTerminal := j.Status == "stopped" || j.Status == "completed" || j.Status == "failed"
if !isTerminal {
j.Status = update.Status
}
if update.Message != "" {
j.Message = update.Message
}
if err := s.jobs.Set(ctx, j); err != nil {
xlog.Warn("Failed to persist progress update", "job_id", jobID, "error", err)
}
s.saveJobState(j)
}
s.mu.Unlock()
// Convert extra metrics
extraMetrics := make(map[string]float32)
for k, v := range update.ExtraMetrics {
extraMetrics[k] = v
}
event := &schema.FineTuneProgressEvent{
JobID: update.JobId,
CurrentStep: update.CurrentStep,
TotalSteps: update.TotalSteps,
CurrentEpoch: update.CurrentEpoch,
TotalEpochs: update.TotalEpochs,
Loss: update.Loss,
LearningRate: update.LearningRate,
GradNorm: update.GradNorm,
EvalLoss: update.EvalLoss,
EtaSeconds: update.EtaSeconds,
ProgressPercent: update.ProgressPercent,
Status: update.Status,
Message: update.Message,
CheckpointPath: update.CheckpointPath,
SamplePath: update.SamplePath,
ExtraMetrics: extraMetrics,
}
callback(event)
})
}
// ListCheckpoints lists checkpoints for a job.
func (s *FineTuneService) ListCheckpoints(ctx context.Context, userID, jobID string) ([]*pb.CheckpointInfo, error) {
s.mu.Lock()
job, ok := s.jobs.Get(jobID)
if !ok {
s.mu.Unlock()
return nil, fmt.Errorf("job not found: %s", jobID)
}
if userID != "" && job.UserID != userID {
s.mu.Unlock()
return nil, fmt.Errorf("job not found: %s", jobID)
}
s.mu.Unlock()
ckptModelID := job.ModelID
if ckptModelID == "" {
ckptModelID = job.Backend + "-finetune"
}
backendModel, err := s.modelLoader.Load(
model.WithBackendString(job.Backend),
model.WithModel(job.Backend),
model.WithModelID(ckptModelID),
)
if err != nil {
return nil, fmt.Errorf("failed to load backend: %w", err)
}
resp, err := backendModel.ListCheckpoints(ctx, &pb.ListCheckpointsRequest{
OutputDir: job.OutputDir,
})
if err != nil {
return nil, fmt.Errorf("failed to list checkpoints: %w", err)
}
return resp.Checkpoints, nil
}
// sanitizeModelName replaces non-alphanumeric characters with hyphens and lowercases.
func sanitizeModelName(s string) string {
re := regexp.MustCompile(`[^a-zA-Z0-9\-]`)
s = re.ReplaceAllString(s, "-")
s = regexp.MustCompile(`-+`).ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
return strings.ToLower(s)
}
// ExportModel starts an async model export from a checkpoint and returns the intended model name immediately.
func (s *FineTuneService) ExportModel(ctx context.Context, userID, jobID string, req schema.ExportRequest) (string, error) {
s.mu.Lock()
job, ok := s.jobs.Get(jobID)
if !ok {
s.mu.Unlock()
return "", fmt.Errorf("job not found: %s", jobID)
}
if userID != "" && job.UserID != userID {
s.mu.Unlock()
return "", fmt.Errorf("job not found: %s", jobID)
}
if job.ExportStatus == "exporting" {
s.mu.Unlock()
return "", fmt.Errorf("export already in progress for job %s", jobID)
}
s.mu.Unlock()
// Compute model name
modelName := req.Name
if modelName == "" {
base := sanitizeModelName(job.Model)
if base == "" {
base = "model"
}
shortID := jobID
if len(shortID) > 8 {
shortID = shortID[:8]
}
modelName = base + "-ft-" + shortID
}
// Compute output path in models directory
modelsPath := s.appConfig.SystemState.Model.ModelsPath
outputPath := filepath.Join(modelsPath, modelName)
// Check for name collision (synchronous — fast validation)
configPath := filepath.Join(modelsPath, modelName+".yaml")
if err := utils.VerifyPath(modelName+".yaml", modelsPath); err != nil {
return "", fmt.Errorf("invalid model name: %w", err)
}
if _, err := os.Stat(configPath); err == nil {
return "", fmt.Errorf("model %q already exists, choose a different name", modelName)
}
// Create output directory
if err := os.MkdirAll(outputPath, 0750); err != nil {
return "", fmt.Errorf("failed to create output directory: %w", err)
}
// Set export status to "exporting" and persist
s.mu.Lock()
job.ExportStatus = "exporting"
job.ExportMessage = ""
job.ExportModelName = ""
if err := s.jobs.Set(ctx, job); err != nil {
xlog.Warn("Failed to persist export start", "job_id", jobID, "error", err)
}
s.saveJobState(job)
s.mu.Unlock()
// Launch the export in a background goroutine
go func() {
s.setExportMessage(job, "Loading export backend...")
exportModelID := job.ModelID
if exportModelID == "" {
exportModelID = job.Backend + "-finetune"
}
backendModel, err := s.modelLoader.Load(
model.WithBackendString(job.Backend),
model.WithModel(job.Backend),
model.WithModelID(exportModelID),
)
if err != nil {
s.setExportFailed(job, fmt.Sprintf("failed to load backend: %v", err))
return
}
// Merge job's extra_options (contains hf_token from training) with request's
mergedOpts := make(map[string]string)
for k, v := range job.ExtraOptions {
mergedOpts[k] = v
}
for k, v := range req.ExtraOptions {
mergedOpts[k] = v // request overrides job
}
grpcReq := &pb.ExportModelRequest{
CheckpointPath: req.CheckpointPath,
OutputPath: outputPath,
ExportFormat: req.ExportFormat,
QuantizationMethod: req.QuantizationMethod,
Model: req.Model,
ExtraOptions: mergedOpts,
}
s.setExportMessage(job, "Running model export (merging and converting — this may take a while)...")
result, err := backendModel.ExportModel(context.Background(), grpcReq)
if err != nil {
s.setExportFailed(job, fmt.Sprintf("export failed: %v", err))
return
}
if !result.Success {
s.setExportFailed(job, fmt.Sprintf("export failed: %s", result.Message))
return
}
s.setExportMessage(job, "Export complete, generating model configuration...")
// Auto-import: detect format and generate config
cfg, err := importers.ImportLocalPath(outputPath, modelName)
if err != nil {
s.setExportFailed(job, fmt.Sprintf("model exported to %s but config generation failed: %v", outputPath, err))
return
}
cfg.Name = modelName
// If base model not detected from files, use the job's model field
if cfg.Model == "" && job.Model != "" {
cfg.Model = job.Model
}
// Write YAML config
yamlData, err := yaml.Marshal(cfg)
if err != nil {
s.setExportFailed(job, fmt.Sprintf("failed to marshal config: %v", err))
return
}
if err := os.WriteFile(configPath, yamlData, 0644); err != nil {
s.setExportFailed(job, fmt.Sprintf("failed to write config file: %v", err))
return
}
s.setExportMessage(job, "Registering model with LocalAI...")
// Reload configs so the model is immediately available
if err := s.configLoader.LoadModelConfigsFromPath(modelsPath, s.appConfig.ToConfigLoaderOptions()...); err != nil {
xlog.Warn("Failed to reload configs after export", "error", err)
}
if err := s.configLoader.Preload(modelsPath); err != nil {
xlog.Warn("Failed to preload after export", "error", err)
}
xlog.Info("Model exported and registered", "job_id", jobID, "model_name", modelName, "format", req.ExportFormat)
// Runs after the HTTP request returns, so use Background rather than the
// (now likely cancelled) request ctx for the write-through.
s.mu.Lock()
job.ExportStatus = "completed"
job.ExportModelName = modelName
job.ExportMessage = ""
if err := s.jobs.Set(context.Background(), job); err != nil {
xlog.Warn("Failed to persist export completion", "job_id", jobID, "error", err)
}
s.saveJobState(job)
s.mu.Unlock()
}()
return modelName, nil
}
// setExportMessage updates the export message and persists the job state. Called
// from the background export goroutine, so it uses Background for write-through.
func (s *FineTuneService) setExportMessage(job *schema.FineTuneJob, msg string) {
s.mu.Lock()
job.ExportMessage = msg
if err := s.jobs.Set(context.Background(), job); err != nil {
xlog.Warn("Failed to persist export message", "job_id", job.ID, "error", err)
}
s.saveJobState(job)
s.mu.Unlock()
}
// GetExportedModelPath returns the path to the exported model directory and its name.
func (s *FineTuneService) GetExportedModelPath(userID, jobID string) (string, string, error) {
s.mu.Lock()
job, ok := s.jobs.Get(jobID)
if !ok {
s.mu.Unlock()
return "", "", fmt.Errorf("job not found: %s", jobID)
}
if userID != "" && job.UserID != userID {
s.mu.Unlock()
return "", "", fmt.Errorf("job not found: %s", jobID)
}
if job.ExportStatus != "completed" {
s.mu.Unlock()
return "", "", fmt.Errorf("export not completed for job %s (status: %s)", jobID, job.ExportStatus)
}
exportModelName := job.ExportModelName
s.mu.Unlock()
if exportModelName == "" {
return "", "", fmt.Errorf("no exported model name for job %s", jobID)
}
modelsPath := s.appConfig.SystemState.Model.ModelsPath
modelDir := filepath.Join(modelsPath, exportModelName)
if _, err := os.Stat(modelDir); os.IsNotExist(err) {
return "", "", fmt.Errorf("exported model directory not found: %s", modelDir)
}
return modelDir, exportModelName, nil
}
// setExportFailed sets the export status to failed with a message.
func (s *FineTuneService) setExportFailed(job *schema.FineTuneJob, message string) {
xlog.Error("Export failed", "job_id", job.ID, "error", message)
s.mu.Lock()
job.ExportStatus = "failed"
job.ExportMessage = message
if err := s.jobs.Set(context.Background(), job); err != nil {
xlog.Warn("Failed to persist export failure", "job_id", job.ID, "error", err)
}
s.saveJobState(job)
s.mu.Unlock()
}
// UploadDataset handles dataset file upload and returns the local path.
// The filename comes straight from a multipart upload, so strip directory
// components — anything else risks a write under a sibling path.
func (s *FineTuneService) UploadDataset(filename string, data []byte) (string, error) {
uploadDir := filepath.Join(s.fineTuneBaseDir(), "datasets")
if err := os.MkdirAll(uploadDir, 0750); err != nil {
return "", fmt.Errorf("failed to create dataset directory: %w", err)
}
base := filepath.Base(filename)
if base == "." || base == ".." || base == "/" || base == "" {
return "", fmt.Errorf("invalid filename")
}
filePath := filepath.Join(uploadDir, uuid.New().String()[:8]+"-"+base)
if err := os.WriteFile(filePath, data, 0640); err != nil {
return "", fmt.Errorf("failed to write dataset: %w", err)
}
return filePath, nil
}
// MarshalProgressEvent converts a progress event to JSON for SSE.
func MarshalProgressEvent(event *schema.FineTuneProgressEvent) (string, error) {
data, err := json.Marshal(event)
if err != nil {
return "", err
}
return string(data), nil
}