Skip to content

Commit f1223b4

Browse files
committed
add evals, reorder menu
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent fa8b1a8 commit f1223b4

13 files changed

Lines changed: 774 additions & 276 deletions

File tree

.agents/debugging-backends.md

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Debugging and Rebuilding Backends
2+
3+
When a backend fails at runtime (e.g. a gRPC method error, a Python import error, or a dependency conflict), use this guide to diagnose, fix, and rebuild.
4+
5+
## Architecture Overview
6+
7+
- **Source directory**: `backend/python/<name>/` (or `backend/go/<name>/`, `backend/cpp/<name>/`)
8+
- **Installed directory**: `backends/<name>/` — this is what LocalAI actually runs. It is populated by `make backends/<name>` which builds a Docker image, exports it, and installs it via `local-ai backends install`.
9+
- **Virtual environment**: `backends/<name>/venv/` — the installed Python venv (for Python backends). The Python binary is at `backends/<name>/venv/bin/python`.
10+
11+
Editing files in `backend/python/<name>/` does **not** affect the running backend until you rebuild with `make backends/<name>`.
12+
13+
## Diagnosing Failures
14+
15+
### 1. Check the logs
16+
17+
Backend gRPC processes log to LocalAI's stdout/stderr. Look for lines tagged with the backend's model ID:
18+
19+
```
20+
GRPC stderr id="trl-finetune-127.0.0.1:37335" line="..."
21+
```
22+
23+
Common error patterns:
24+
- **"Method not implemented"** — the backend is missing a gRPC method that the Go side calls. The model loader (`pkg/model/initializers.go`) always calls `LoadModel` after `Health`; fine-tuning backends must implement it even as a no-op stub.
25+
- **Python import errors / `AttributeError`** — usually a dependency version mismatch (e.g. `pyarrow` removing `PyExtensionType`).
26+
- **"failed to load backend"** — the gRPC process crashed or never started. Check stderr lines for the traceback.
27+
28+
### 2. Test the Python environment directly
29+
30+
You can run the installed venv's Python to check imports without starting the full server:
31+
32+
```bash
33+
backends/<name>/venv/bin/python -c "import datasets; print(datasets.__version__)"
34+
```
35+
36+
If `pip` is missing from the venv, bootstrap it:
37+
38+
```bash
39+
backends/<name>/venv/bin/python -m ensurepip
40+
```
41+
42+
Then use `backends/<name>/venv/bin/python -m pip install ...` to test fixes in the installed venv before committing them to the source requirements.
43+
44+
### 3. Check upstream dependency constraints
45+
46+
When you hit a dependency conflict, check what the main library expects. For example, TRL's upstream `requirements.txt`:
47+
48+
```
49+
https://github.com/huggingface/trl/blob/main/requirements.txt
50+
```
51+
52+
Pin minimum versions in the backend's requirements files to match upstream.
53+
54+
## Common Fixes
55+
56+
### Missing gRPC methods
57+
58+
If the Go side calls a method the backend doesn't implement (e.g. `LoadModel`), add a no-op stub in `backend.py`:
59+
60+
```python
61+
def LoadModel(self, request, context):
62+
"""No-op — actual loading happens elsewhere."""
63+
return backend_pb2.Result(success=True, message="OK")
64+
```
65+
66+
The gRPC contract requires `LoadModel` to succeed for the model loader to return a usable client, even if the backend doesn't need upfront model loading.
67+
68+
### Dependency version conflicts
69+
70+
Python backends often break when a transitive dependency releases a breaking change (e.g. `pyarrow` removing `PyExtensionType`). Steps:
71+
72+
1. Identify the broken import in the logs
73+
2. Test in the installed venv: `backends/<name>/venv/bin/python -c "import <module>"`
74+
3. Check upstream requirements for version constraints
75+
4. Update **all** requirements files in `backend/python/<name>/`:
76+
- `requirements.txt` — base deps (grpcio, protobuf)
77+
- `requirements-cpu.txt` — CPU-specific (includes PyTorch CPU index)
78+
- `requirements-cublas12.txt` — CUDA 12
79+
- `requirements-cublas13.txt` — CUDA 13
80+
5. Rebuild: `make backends/<name>`
81+
82+
### PyTorch index conflicts (uv resolver)
83+
84+
The Docker build uses `uv` for pip installs. When `--extra-index-url` points to the PyTorch wheel index, `uv` may refuse to fetch packages like `requests` from PyPI if it finds a different version on the PyTorch index first. Fix this by adding `--index-strategy=unsafe-first-match` to `install.sh`:
85+
86+
```bash
87+
EXTRA_PIP_INSTALL_FLAGS+=" --upgrade --index-strategy=unsafe-first-match"
88+
installRequirements
89+
```
90+
91+
Most Python backends already do this — check `backend/python/transformers/install.sh` or similar for reference.
92+
93+
## Rebuilding
94+
95+
### Rebuild a single backend
96+
97+
```bash
98+
make backends/<name>
99+
```
100+
101+
This runs the Docker build (`Dockerfile.python`), exports the image to `backend-images/<name>.tar`, and installs it into `backends/<name>/`. It also rebuilds the `local-ai` Go binary (without extra tags).
102+
103+
**Important**: If you were previously running with `GO_TAGS=auth`, the `make backends/<name>` step will overwrite your binary without that tag. Rebuild the Go binary afterward:
104+
105+
```bash
106+
GO_TAGS=auth make build
107+
```
108+
109+
### Rebuild and restart
110+
111+
After rebuilding a backend, you must restart LocalAI for it to pick up the new backend files. The backend gRPC process is spawned on demand when the model is first loaded.
112+
113+
```bash
114+
# Kill existing process
115+
kill <pid>
116+
117+
# Restart
118+
./local-ai run --debug [your flags]
119+
```
120+
121+
### Quick iteration (skip Docker rebuild)
122+
123+
For fast iteration on a Python backend's `backend.py` without a full Docker rebuild, you can edit the installed copy directly:
124+
125+
```bash
126+
# Edit the installed copy
127+
vim backends/<name>/backend.py
128+
129+
# Restart LocalAI to respawn the gRPC process
130+
```
131+
132+
This is useful for testing but **does not persist** — the next `make backends/<name>` will overwrite it. Always commit fixes to the source in `backend/python/<name>/`.
133+
134+
## Verification
135+
136+
After fixing and rebuilding:
137+
138+
1. Start LocalAI and confirm the backend registers: look for `Registering backend name="<name>"` in the logs
139+
2. Trigger the operation that failed (e.g. start a fine-tuning job)
140+
3. Watch the GRPC stderr/stdout lines for the backend's model ID
141+
4. Confirm no errors in the traceback

backend/python/trl/backend.py

Lines changed: 108 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
import backend_pb2_grpc
2222

2323
_ONE_DAY_IN_SECONDS = 60 * 60 * 24
24-
MAX_WORKERS = int(os.environ.get('PYTHON_GRPC_MAX_WORKERS', '1'))
24+
MAX_WORKERS = int(os.environ.get('PYTHON_GRPC_MAX_WORKERS', '4'))
2525

2626

2727
class ProgressCallback:
@@ -38,16 +38,22 @@ def get_callback(self):
3838
parent = self
3939

4040
class _Callback(TrainerCallback):
41+
def __init__(self):
42+
self._train_start_time = None
43+
44+
def on_train_begin(self, args, state, control, **kwargs):
45+
self._train_start_time = time.time()
46+
4147
def on_log(self, args, state, control, logs=None, **kwargs):
4248
if logs is None:
4349
return
4450
total_steps = state.max_steps if state.max_steps > 0 else 0
4551
progress = (state.global_step / total_steps * 100) if total_steps > 0 else 0
4652
eta = 0.0
47-
if state.global_step > 0 and total_steps > 0:
48-
elapsed = time.time() - state.logging_steps # approximate
53+
if state.global_step > 0 and total_steps > 0 and self._train_start_time:
54+
elapsed = time.time() - self._train_start_time
4955
remaining_steps = total_steps - state.global_step
50-
if state.global_step > 1:
56+
if state.global_step > 0:
5157
eta = remaining_steps * (elapsed / state.global_step)
5258

5359
extra_metrics = {}
@@ -72,6 +78,58 @@ def on_log(self, args, state, control, logs=None, **kwargs):
7278
)
7379
parent.progress_queue.put(update)
7480

81+
def on_prediction_step(self, args, state, control, **kwargs):
82+
"""Send periodic updates during evaluation so the UI doesn't freeze."""
83+
if not hasattr(self, '_eval_update_counter'):
84+
self._eval_update_counter = 0
85+
self._eval_update_counter += 1
86+
# Throttle: send an update every 10 prediction steps
87+
if self._eval_update_counter % 10 != 0:
88+
return
89+
total_steps = state.max_steps if state.max_steps > 0 else 0
90+
progress = (state.global_step / total_steps * 100) if total_steps > 0 else 0
91+
update = backend_pb2.FineTuneProgressUpdate(
92+
job_id=parent.job_id,
93+
current_step=state.global_step,
94+
total_steps=total_steps,
95+
current_epoch=float(state.epoch or 0),
96+
total_epochs=float(parent.total_epochs),
97+
progress_percent=float(progress),
98+
status="training",
99+
message=f"Evaluating... (batch {self._eval_update_counter})",
100+
)
101+
parent.progress_queue.put(update)
102+
103+
def on_evaluate(self, args, state, control, metrics=None, **kwargs):
104+
"""Report eval results once evaluation is done."""
105+
# Reset prediction counter for next eval round
106+
self._eval_update_counter = 0
107+
108+
total_steps = state.max_steps if state.max_steps > 0 else 0
109+
progress = (state.global_step / total_steps * 100) if total_steps > 0 else 0
110+
111+
eval_loss = 0.0
112+
extra_metrics = {}
113+
if metrics:
114+
eval_loss = float(metrics.get('eval_loss', 0))
115+
for k, v in metrics.items():
116+
if isinstance(v, (int, float)) and k not in ('eval_loss', 'epoch'):
117+
extra_metrics[k] = float(v)
118+
119+
update = backend_pb2.FineTuneProgressUpdate(
120+
job_id=parent.job_id,
121+
current_step=state.global_step,
122+
total_steps=total_steps,
123+
current_epoch=float(state.epoch or 0),
124+
total_epochs=float(parent.total_epochs),
125+
eval_loss=eval_loss,
126+
progress_percent=float(progress),
127+
status="training",
128+
message=f"Evaluation complete at step {state.global_step}",
129+
extra_metrics=extra_metrics,
130+
)
131+
parent.progress_queue.put(update)
132+
75133
def on_save(self, args, state, control, **kwargs):
76134
checkpoint_path = os.path.join(args.output_dir, f"checkpoint-{state.global_step}")
77135
update = backend_pb2.FineTuneProgressUpdate(
@@ -256,6 +314,38 @@ def _do_training(self, request, job):
256314
else:
257315
dataset = load_dataset(request.dataset_source, split=dataset_split)
258316

317+
# Eval dataset setup
318+
eval_dataset = None
319+
eval_strategy = extra.get("eval_strategy", "steps")
320+
eval_steps = int(extra.get("eval_steps", str(request.save_steps if request.save_steps > 0 else 500)))
321+
322+
if eval_strategy != "no":
323+
eval_split = extra.get("eval_split")
324+
eval_dataset_source = extra.get("eval_dataset_source")
325+
if eval_split:
326+
# Load a specific split as eval dataset
327+
if os.path.exists(request.dataset_source):
328+
if request.dataset_source.endswith('.json') or request.dataset_source.endswith('.jsonl'):
329+
eval_dataset = load_dataset("json", data_files=request.dataset_source, split=eval_split)
330+
elif request.dataset_source.endswith('.csv'):
331+
eval_dataset = load_dataset("csv", data_files=request.dataset_source, split=eval_split)
332+
else:
333+
eval_dataset = load_dataset(request.dataset_source, split=eval_split)
334+
else:
335+
eval_dataset = load_dataset(request.dataset_source, split=eval_split)
336+
elif eval_dataset_source:
337+
# Load eval dataset from a separate source
338+
eval_dataset = load_dataset(eval_dataset_source, split="train")
339+
else:
340+
# Auto-split from training set
341+
eval_split_ratio = float(extra.get("eval_split_ratio", "0.1"))
342+
split = dataset.train_test_split(test_size=eval_split_ratio)
343+
dataset = split["train"]
344+
eval_dataset = split["test"]
345+
346+
if eval_strategy == "no":
347+
eval_dataset = None
348+
259349
# Training config
260350
output_dir = request.output_dir or f"./output-{job.job_id}"
261351
num_epochs = request.num_epochs if request.num_epochs > 0 else 3
@@ -308,6 +398,12 @@ def _do_training(self, request, job):
308398
if save_total_limit:
309399
_save_kwargs["save_total_limit"] = save_total_limit
310400

401+
# Eval kwargs
402+
_eval_kwargs = {}
403+
if eval_dataset is not None:
404+
_eval_kwargs["eval_strategy"] = eval_strategy
405+
_eval_kwargs["eval_steps"] = eval_steps
406+
311407
# Common training arguments shared by all methods
312408
_common_args = dict(
313409
output_dir=output_dir,
@@ -324,6 +420,7 @@ def _do_training(self, request, job):
324420
report_to="none",
325421
**_save_kwargs,
326422
**common_train_kwargs,
423+
**_eval_kwargs,
327424
)
328425

329426
# Select trainer based on training method
@@ -343,6 +440,7 @@ def _do_training(self, request, job):
343440
model=model,
344441
args=training_args,
345442
train_dataset=dataset,
443+
eval_dataset=eval_dataset,
346444
processing_class=tokenizer,
347445
callbacks=[progress_cb.get_callback()],
348446
)
@@ -365,6 +463,7 @@ def _do_training(self, request, job):
365463
model=model,
366464
args=training_args,
367465
train_dataset=dataset,
466+
eval_dataset=eval_dataset,
368467
processing_class=tokenizer,
369468
callbacks=[progress_cb.get_callback()],
370469
)
@@ -420,6 +519,7 @@ def _do_training(self, request, job):
420519
model=model,
421520
args=training_args,
422521
train_dataset=dataset,
522+
eval_dataset=eval_dataset,
423523
processing_class=tokenizer,
424524
callbacks=[progress_cb.get_callback()],
425525
)
@@ -440,6 +540,7 @@ def _do_training(self, request, job):
440540
model=model,
441541
args=training_args,
442542
train_dataset=dataset,
543+
eval_dataset=eval_dataset,
443544
processing_class=tokenizer,
444545
callbacks=[progress_cb.get_callback()],
445546
)
@@ -478,6 +579,7 @@ def _do_training(self, request, job):
478579
model=model,
479580
args=training_args,
480581
train_dataset=dataset,
582+
eval_dataset=eval_dataset,
481583
processing_class=tokenizer,
482584
callbacks=[progress_cb.get_callback()],
483585
)
@@ -528,9 +630,8 @@ def FineTuneProgress(self, request, context):
528630
continue
529631

530632
def StopFineTune(self, request, context):
531-
# No-op: stopping is handled by killing the backend process from Go.
532-
# This stub remains to satisfy the proto-generated gRPC interface.
533-
return backend_pb2.Result(success=True, message="No-op (process kill used instead)")
633+
# Stopping is handled by killing the process from Go via ShutdownModel.
634+
return backend_pb2.Result(success=True, message="OK")
534635

535636
def ListCheckpoints(self, request, context):
536637
output_dir = request.output_dir

core/http/endpoints/localai/agent_collections.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,14 @@ func UploadToCollectionEndpoint(app *application.Application) echo.HandlerFunc {
8080
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
8181
}
8282
defer src.Close()
83-
if err := svc.UploadToCollectionForUser(userID, name, file.Filename, src); err != nil {
83+
key, err := svc.UploadToCollectionForUser(userID, name, file.Filename, src)
84+
if err != nil {
8485
if strings.Contains(err.Error(), "not found") {
8586
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
8687
}
8788
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
8889
}
89-
return c.JSON(http.StatusOK, map[string]string{"status": "ok", "filename": file.Filename})
90+
return c.JSON(http.StatusOK, map[string]string{"status": "ok", "filename": file.Filename, "key": key})
9091
}
9192
}
9293

0 commit comments

Comments
 (0)