longcat: LoRA / QLoRA training, driven through SimpleTuner (v0.2.37)

Plan item H.2. Also fixes a real bug shipped in 0.2.35.

THE BUG: load_quantized_dit was imported from longcat_video.modules.longcat_video_dit,
where it does not exist — it is in longcat_video.modules.quantization (beside
QuantizedLinear and quantize_model), and it returns the AVATAR transformer, consistent
with INT8 being an avatar-1.5-only build. The wrong import would have raised at the exact
moment someone asked for INT8.

TRAINING. coderai does not implement this loop, and the reasons are the design:

1. LongCat's venv is standalone (Python 3.10 / torch 2.6 / transformers 4.41), so a
   trainer there cannot `from codai.api import loras` the way tools/lora_train_worker.py
   does — that overlay venv inherits the parent's site-packages, this one does not.
2. The upstream repo has NO way to create trainable LoRA layers. Its DiT exposes
   load_lora() / enable_loras() / disable_all_loras() and nothing that initialises one,
   and the adapters it loads (cfg_step_lora, refinement_lora, dmd_lora) use its own key
   layout. Reimplementing that layout unverified would produce adapters the pipeline
   cannot load: support in appearance only.

SimpleTuner implements LongCat-Video properly — model_family "longcat_video", LoRA and
quantised LoRA via int8-quanto / int4-quanto / fp8-torchao with no extra installs — so
coderai generates its config and drives it:

- lora_archs gains the arch with an `external` marker; load_classes refuses with that
  reason instead of an AttributeError that reads like a diffusers version problem.
  `target: video` still resolves Wan weights to Wan, so existing configs are untouched.
- tools/longcat_train.py writes the config, runs SimpleTuner, parses its steps into the
  same JSON-lines protocol the overlay trainer uses, and reports the adapter it actually
  wrote rather than assuming one appeared.
- Two VAE constraints are checked BEFORE a long run starts: (num_frames - 1) divisible by
  4 — the same 4n+1 rule coderai already applies to Wan — and both sides divisible by 16.
- A venv of its own, because SimpleTuner pins its own torch and would fight the inference
  venv's. train_auto_build is off by default, and the refusal carries the manual command.
- QLoRA defaults ON (int8-quanto): a 13.6B transformer plus optimiser state does not fit
  a consumer card at bf16, so the quantised base is the normal path.

`dataset_config` is REQUIRED and refused when absent or missing: SimpleTuner wants
captioned video clips, and synthesising a dataset from the `images` field other targets
use would train something other than a video LoRA, silently.

34 tests. No training run has been executed — the driver and its constraints are tested,
the run against a real dataset is not.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182DhR1eFNPQyGmDrpHbedY
parent 759892c4
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API # Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here. # metadata and the admin web UI read from here.
__version__ = "0.2.36" __version__ = "0.2.37"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference." __tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere. # Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
...@@ -489,6 +489,144 @@ def progress(model_path: str, config: dict = None) -> dict: ...@@ -489,6 +489,144 @@ def progress(model_path: str, config: dict = None) -> dict:
return {} return {}
# ── LoRA / QLoRA training ────────────────────────────────────────────────────
_TRAIN_SCRIPT = _REPO_ROOT / "tools" / "longcat_train.py"
_TRAIN_REQUIREMENTS = _REPO_ROOT / "requirements-longcat-train.txt"
def resolve_train_venv(config: dict = None) -> Path:
"""Where SimpleTuner lives. A venv of its OWN, not the inference one.
SimpleTuner pins its own torch; sharing the inference venv would have the two fight
over it and leave neither working."""
sec = _cfg_section()
configured = ((config or {}).get("longcat_train_venv") or "").strip()
if configured:
return Path(os.path.expanduser(configured))
if sec is not None and str(getattr(sec, "train_venv", "") or "").strip():
return Path(os.path.expanduser(str(sec.train_venv).strip()))
explicit = os.environ.get("CODERAI_LONGCAT_TRAIN_VENV")
if explicit:
return Path(os.path.expanduser(explicit))
# Beside the inference venv, clearly named.
inference = resolve_venv_dir(config)
return inference.parent / (inference.name + "-train")
def _train_venv_ok(py: Path) -> bool:
try:
return subprocess.run([str(py), "-c", "import simpletuner"],
capture_output=True, timeout=180).returncode == 0
except Exception:
return False
def ensure_train_built(config: dict = None) -> Path:
"""The interpreter SimpleTuner runs under. Builds it only if allowed to."""
venv = resolve_train_venv(config)
py = _venv_python(venv)
if py.exists() and _train_venv_ok(py):
return py
sec = _cfg_section()
auto = bool(getattr(sec, "train_auto_build", False)) if sec is not None else False
if not auto:
raise RuntimeError(
f"LongCat LoRA training needs SimpleTuner, which is not in {venv}. "
f"coderai does not implement this training loop: LongCat's venv is "
f"standalone so it cannot reuse the shared trainer, and the upstream repo "
f"has no way to create trainable LoRA layers (only load_lora from a file, "
f"in its own key layout). Build it:\n"
f" <python3.10> -m venv {venv}\n"
f" {py} -m pip install -r {_TRAIN_REQUIREMENTS}\n"
f"…or set longcat.train_auto_build = true.")
py310 = _find_python310(config)
if not py310:
raise RuntimeError(
"longcat.train_auto_build is on but no Python 3.10 interpreter was found "
"(coderai's own is 3.13) — set longcat.python, or use the image.")
if not py.exists():
print(f"[longcat] creating the training venv at {venv} …", flush=True)
venv.parent.mkdir(parents=True, exist_ok=True)
subprocess.run([py310, "-m", "venv", str(venv)], check=True)
py = _venv_python(venv)
subprocess.run([str(py), "-m", "pip", "install", "-U", "pip"], check=True)
subprocess.run([str(py), "-m", "pip", "install", "-r", str(_TRAIN_REQUIREMENTS)],
check=True)
if not _train_venv_ok(py):
raise RuntimeError(f"the training venv at {venv} was built but `import "
f"simpletuner` still fails")
return py
def train_lora(job: dict, workdir: str, on_progress=None) -> dict:
"""Run one LoRA/QLoRA job. Returns SimpleTuner's result dict.
``job`` is written to disk and the trainer appends JSON lines beside it, the same
protocol tools/lora_train_worker.py uses — so the existing job records, the progress
endpoint and the Tasks page need no changes.
GPU exclusivity is the CALLER's business: training wants the whole card, and the
model manager's releasers (including this module's) are what clear it."""
import json as _json
sec = _cfg_section()
py = ensure_train_built(job.get("request") or {})
work = Path(os.path.expanduser(workdir))
work.mkdir(parents=True, exist_ok=True)
job.setdefault("base_precision",
str(getattr(sec, "train_base_precision", "") or "")
if sec is not None else "")
job.setdefault("rank", int(getattr(sec, "train_lora_rank", 8) or 8)
if sec is not None else 8)
job.setdefault("gradient_checkpointing",
bool(getattr(sec, "train_gradient_checkpointing", True))
if sec is not None else True)
job_path = work / "job.json"
job_path.write_text(_json.dumps(job, indent=2, default=str))
progress_path = str(job_path) + ".progress"
result_path = str(job_path) + ".result"
cmd = [str(py), str(_TRAIN_SCRIPT), "--job", str(job_path)]
print(f"[longcat] training: {' '.join(cmd)}", flush=True)
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, cwd=str(_REPO_ROOT))
threading.Thread(target=_pump_logs, args=(proc, collections.deque(maxlen=20)),
daemon=True).start()
seen = 0
while proc.poll() is None:
seen = _drain_progress(progress_path, seen, on_progress)
time.sleep(2)
_drain_progress(progress_path, seen, on_progress)
if not os.path.isfile(result_path):
raise RuntimeError(f"the trainer exited {proc.returncode} without writing a "
f"result — see the [longcat] log above")
data = _json.loads(open(result_path).read() or "{}")
if not data.get("ok"):
raise RuntimeError(data.get("error") or "LongCat LoRA training failed")
return data.get("result") or {}
def _drain_progress(path: str, seen: int, on_progress) -> int:
"""Forward new JSON lines to the caller. Returns how many have been consumed."""
if not on_progress or not os.path.isfile(path):
return seen
import json as _json
try:
lines = open(path).read().splitlines()
except Exception:
return seen
for line in lines[seen:]:
try:
on_progress(**_json.loads(line))
except Exception:
pass
return len(lines)
# ── model-manager integration ───────────────────────────────────────────────── # ── model-manager integration ─────────────────────────────────────────────────
class LongcatHandle: class LongcatHandle:
......
...@@ -71,10 +71,46 @@ ARCHS = { ...@@ -71,10 +71,46 @@ ARCHS = {
"shift": 3.0, "shift": 3.0,
"needs": "diffusers>=0.40 and access to the gated krea/Krea-2-* weights", "needs": "diffusers>=0.40 and access to the gated krea/Krea-2-* weights",
}, },
"longcat": {
"label": "LongCat-Video DiT",
"kind": "video",
"detect": ("longcat",),
# Named for completeness, but NOT importable from diffusers: these classes live
# in the upstream repo's own `longcat_video` package. See `external` below.
"transformer": "LongCatVideoTransformer3DModel",
"vae": "AutoencoderKLWan",
"text_encoder": ("transformers", "UMT5EncoderModel"),
"module": "longcat_video.modules.longcat_video_dit",
"shift": 5.0,
# This architecture does NOT train through the shared in-process trainer, for two
# reasons that are both hard:
#
# 1. its venv is standalone — Python 3.10, torch 2.6, transformers 4.41 — so it
# cannot import codai.api.loras the way tools/lora_train_worker.py does (that
# overlay venv inherits the parent's site-packages; this one cannot);
# 2. the repo offers no way to CREATE LoRA layers. Its DiT has load_lora() and
# enable_loras() but nothing to initialise trainable ones, and the adapters it
# loads (cfg_step_lora, refinement_lora, dmd_lora) are in its own key layout.
# Hand-rolling that layout would produce adapters the pipeline cannot load.
#
# SimpleTuner implements it properly (model_family "longcat_video", LoRA and
# quantised LoRA), so that is what coderai drives.
"external": "simpletuner",
"needs": ("the LongCat-Video venv plus SimpleTuner — coderai drives it rather "
"than training in-process"),
},
} }
#: What `target` may be on a train request. #: What `target` may be on a train request.
TARGETS = ("image", "video", "wan", "ltx2", "h3", "krea") TARGETS = ("image", "video", "wan", "ltx2", "h3", "krea", "longcat")
#: Architectures trained by an external trainer rather than the shared in-process loop.
EXTERNAL_TARGETS = tuple(k for k, v in ARCHS.items() if v.get("external"))
def external_trainer(arch: str) -> str:
"""Which external trainer this architecture needs, or "" for the in-process loop."""
return str((ARCHS.get(arch) or {}).get("external") or "")
def detect_arch(model_path: str, target: str = "") -> Optional[str]: def detect_arch(model_path: str, target: str = "") -> Optional[str]:
...@@ -111,6 +147,14 @@ def load_classes(arch: str): ...@@ -111,6 +147,14 @@ def load_classes(arch: str):
spec = ARCHS.get(arch) spec = ARCHS.get(arch)
if not spec: if not spec:
raise RuntimeError(f"Unknown LoRA architecture '{arch}'") raise RuntimeError(f"Unknown LoRA architecture '{arch}'")
if spec.get("external"):
# Its classes are not in diffusers and it does not train in this process at all;
# saying so here beats an AttributeError that reads like a diffusers version
# problem.
raise RuntimeError(
f"{spec['label']} does not train in this process — it needs "
f"{spec['external']} in the LongCat-Video venv ({spec.get('needs')}). "
f"coderai launches it; this code path is not the one that runs it.")
import importlib import importlib
diffusers = importlib.import_module("diffusers") diffusers = importlib.import_module("diffusers")
missing = [n for n in (spec["transformer"], spec["vae"]) if not hasattr(diffusers, n)] missing = [n for n in (spec["transformer"], spec["vae"]) if not hasattr(diffusers, n)]
......
...@@ -444,6 +444,12 @@ class LoraTrainRequest(BaseModel): ...@@ -444,6 +444,12 @@ class LoraTrainRequest(BaseModel):
# Quantize the (large) video transformer to 4-bit for training (QLoRA). Lets a # Quantize the (large) video transformer to 4-bit for training (QLoRA). Lets a
# 14B video model's LoRA fit on a consumer GPU. Ignored for image targets. # 14B video model's LoRA fit on a consumer GPU. Ignored for image targets.
quantize_4bit: Optional[bool] = True quantize_4bit: Optional[bool] = True
# LongCat-Video (target "longcat") trains through SimpleTuner, which wants a
# captioned VIDEO dataset described by its own data-backend config. coderai will not
# synthesise one from `images`: a still-image dataset would train a different thing
# than a video LoRA, and silently so.
dataset_config: Optional[str] = None # path to a SimpleTuner data-backend config
train_resolution: Optional[str] = None # "480x832"; sides divisible by 16
num_frames: Optional[int] = 1 # training video length (1 = stills-only) num_frames: Optional[int] = 1 # training video length (1 = stills-only)
character: Optional[str] = None # saved character profile to pull images from character: Optional[str] = None # saved character profile to pull images from
environment: Optional[str] = None # OR saved environment profile to pull images from environment: Optional[str] = None # OR saved environment profile to pull images from
...@@ -1053,6 +1059,16 @@ def _train_lora_sync(req: LoraTrainRequest) -> dict: ...@@ -1053,6 +1059,16 @@ def _train_lora_sync(req: LoraTrainRequest) -> dict:
_arch = _detect_arch(video_path, _target) or "wan" _arch = _detect_arch(video_path, _target) or "wan"
if _arch != "wan": if _arch != "wan":
print(f" [lora] training a {_arch} adapter for {video_path}") print(f" [lora] training a {_arch} adapter for {video_path}")
# Some architectures do not train in this process at all. LongCat-Video is
# the case: its venv is standalone (Python 3.10 / torch 2.6) so it cannot
# reuse this module the way the overlay trainer does, AND its repo has no way
# to create trainable LoRA layers — only load_lora() from a file, in its own
# key layout. SimpleTuner implements it properly, so coderai drives that.
from codai.api.lora_archs import external_trainer as _external
if _external(_arch):
return _train_externally(_arch, req, video_path, images,
instance_prompt, steps, rank, resolution,
lr, seed)
# H3 and Krea 2 need diffusers >= 0.40 and this process has 0.38 — and # H3 and Krea 2 need diffusers >= 0.40 and this process has 0.38 — and
# diffusers is imported once per process, so they train in the overlay # diffusers is imported once per process, so they train in the overlay
# venv instead. LTX-2 needs nothing newer and stays in-process. # venv instead. LTX-2 needs nothing newer and stays in-process.
...@@ -1870,6 +1886,56 @@ def _train_via_overlay(arch, req, base_path, images, instance_prompt, ...@@ -1870,6 +1886,56 @@ def _train_via_overlay(arch, req, base_path, images, instance_prompt,
return out["result"] return out["result"]
def _train_externally(arch: str, req, base_path, images, instance_prompt,
steps, rank, resolution, lr, seed):
"""Hand a job to an external trainer that owns this architecture.
Only LongCat-Video today. Progress comes back on the same JSON-lines protocol the
overlay trainer uses, so _set_progress and the job records need no special case.
Note what is NOT done here: no images are re-encoded and no dataset is built in this
process. SimpleTuner wants a captioned video dataset with its own backend config, and
translating a list of stills into one silently would train something other than what
was asked for. The dataset path is the caller's, and its absence is an error."""
import tempfile
from codai.api import longcat_worker
dataset = (getattr(req, "dataset_config", None) or "").strip() \
if hasattr(req, "dataset_config") else ""
if not dataset:
raise RuntimeError(
f"{arch} LoRA training needs `dataset_config` — a path to a SimpleTuner "
f"data-backend config describing your captioned video clips (its quickstart "
f"suggests 50-100 clips of 10-30s). coderai will not invent one from the "
f"reference images: a still-image dataset would train a different thing "
f"than a video LoRA.")
if not os.path.isfile(os.path.expanduser(dataset)):
raise RuntimeError(f"dataset_config does not exist: {dataset}")
out_dir = os.path.join(_lora_dir(), getattr(req, "name", "longcat-lora"))
work = tempfile.mkdtemp(prefix="longcat-train-")
job = {
"arch": arch,
"request": {k: v for k, v in vars(req).items()
if isinstance(v, (str, int, float, bool, type(None)))},
"base_path": base_path,
"output_dir": out_dir,
"dataset_config": os.path.expanduser(dataset),
"instance_prompt": instance_prompt,
"steps": int(steps), "rank": int(rank), "lr": float(lr), "seed": int(seed),
"resolution": getattr(req, "train_resolution", None) or "480x832",
"num_frames": int(getattr(req, "num_frames", 93) or 93),
}
def _progress(**kw):
_set_progress(step=kw.get("step"), total=kw.get("total"),
message=kw.get("message") or kw.get("status") or "")
result = longcat_worker.train_lora(job, work, on_progress=_progress)
return {"path": result.get("path") or out_dir, "steps": int(steps),
"rank": int(rank), "arch": arch, "trainer": "simpletuner"}
def _train_flow_dit(arch: str, req, base_path, images, instance_prompt, def _train_flow_dit(arch: str, req, base_path, images, instance_prompt,
steps, rank, resolution, lr, seed, device): steps, rank, resolution, lr, seed, device):
"""LoRA for a flow-matching DiT — LTX-2, MiniMax-H3, Krea 2. """LoRA for a flow-matching DiT — LTX-2, MiniMax-H3, Krea 2.
......
...@@ -790,6 +790,17 @@ class LongcatConfig: ...@@ -790,6 +790,17 @@ class LongcatConfig:
# work, and the manager's own busy signal cannot see it (the worker holds no model # work, and the manager's own busy signal cannot see it (the worker holds no model
# pool), so this is what makes the handover a clean swap instead of a lost request. # pool), so this is what makes the handover a clean swap instead of a lost request.
evict_drain_timeout_s: float = 300.0 evict_drain_timeout_s: float = 300.0
# LoRA / QLoRA training. coderai does not implement the loop — SimpleTuner does, and
# it supports LongCat-Video natively including a quantised base — so it is driven out
# of a venv of its own (SimpleTuner pins its own torch and would fight the inference
# venv's). Blank = beside the inference venv as <venv>-train.
train_venv: str = ""
train_auto_build: bool = False
# int8-quanto | int4-quanto | fp8-torchao | "" (bf16). This is the QLoRA switch: the
# 13.6B transformer does not fit a consumer card at bf16 with optimiser state.
train_base_precision: str = "int8-quanto"
train_lora_rank: int = 8 # SimpleTuner's guidance for this model is 4-8
train_gradient_checkpointing: bool = True
extra_args: str = "" # appended to the service command line extra_args: str = "" # appended to the service command line
extra_env: str = "" # KEY=VALUE pairs for the service process extra_env: str = "" # KEY=VALUE pairs for the service process
...@@ -1435,6 +1446,11 @@ class ConfigManager: ...@@ -1435,6 +1446,11 @@ class ConfigManager:
"attention": self.config.longcat.attention, "attention": self.config.longcat.attention,
"ready_timeout": self.config.longcat.ready_timeout, "ready_timeout": self.config.longcat.ready_timeout,
"evict_drain_timeout_s": self.config.longcat.evict_drain_timeout_s, "evict_drain_timeout_s": self.config.longcat.evict_drain_timeout_s,
"train_venv": self.config.longcat.train_venv,
"train_auto_build": self.config.longcat.train_auto_build,
"train_base_precision": self.config.longcat.train_base_precision,
"train_lora_rank": self.config.longcat.train_lora_rank,
"train_gradient_checkpointing": self.config.longcat.train_gradient_checkpointing,
"extra_args": self.config.longcat.extra_args, "extra_args": self.config.longcat.extra_args,
"extra_env": self.config.longcat.extra_env, "extra_env": self.config.longcat.extra_env,
}, },
......
...@@ -201,8 +201,63 @@ The audio is resampled to 16 kHz (the encoder's rate) and embedded by the family ...@@ -201,8 +201,63 @@ The audio is resampled to 16 kHz (the encoder's rate) and embedded by the family
encoder. `ref_img_index` (0–24; 30 reduces repeated actions) and `mask_frame_range` are encoder. `ref_img_index` (0–24; 30 reduces repeated actions) and `mask_frame_range` are
set on the model entry. set on the model entry.
## LoRA / QLoRA training
Supported, through **SimpleTuner**, which coderai drives. It does not train in-process,
and the two reasons are worth knowing because they are not going to change on their own:
1. LongCat's venv is standalone (Python 3.10, torch 2.6, transformers 4.41), so a trainer
there cannot import `codai.api.loras` the way the overlay trainer for H3/Krea does —
that venv inherits the parent's site-packages, this one deliberately does not.
2. The upstream repo has **no way to create trainable LoRA layers**. Its DiT exposes
`load_lora()`, `enable_loras()` and `disable_all_loras()` and nothing that initialises
one, and the adapters it loads (`cfg_step_lora`, `refinement_lora`, `dmd_lora`) use its
own key layout. Hand-rolling that layout unverified would produce adapters the pipeline
cannot load — support in appearance only.
Upstream's own refinement expert *is* a LoRA on the base model, so the architecture is
well suited to this; it is the training-side plumbing that is missing, not the capability.
```bash
python3.10 -m venv ~/.coderai/longcat_venv-train
~/.coderai/longcat_venv-train/bin/python -m pip install -r requirements-longcat-train.txt
```
A separate venv from the inference one: SimpleTuner pins its own torch and the two would
fight. Then:
```jsonc
POST /v1/loras/train
{
"name": "my-style",
"base_model": "meituan-longcat/LongCat-Video",
"target": "longcat",
"dataset_config": "/data/my-clips/simpletuner.json",
"steps": 800,
"rank": 8
}
```
`dataset_config` is **required** and is a SimpleTuner data-backend config describing
captioned video clips (its quickstart suggests 50–100 clips of 10–30 s). coderai will not
synthesise one from the `images` field other targets use: a still-image dataset would
train something other than a video LoRA, and silently.
**QLoRA** is `longcat.train_base_precision` — `int8-quanto` (the default),
`int4-quanto` or `fp8-torchao`, no extra installs. A 13.6B transformer plus optimiser
state does not fit a consumer card at bf16, so the quantised base is the normal path
rather than the exception. Batch size 1, gradient checkpointing on, rank 4–8.
Two constraints are checked **before** a long run starts, because they are the VAE's and
not preferences: `(num_frames - 1)` must be divisible by 4 — the same 4n+1 rule coderai
already applies to Wan — and each side of the resolution must be divisible by 16.
Progress arrives on the same job/JSON-lines protocol the other trainers use, so
`/v1/loras/progress` and the Tasks page work unchanged.
## Not done yet ## Not done yet
- GGUF variants (they need the ComfyUI gguf loader) - GGUF variants (they need the ComfyUI gguf loader)
- LoRA/QLoRA training on top of it (feasible — upstream's own refinement expert is a LoRA)
- **no generation has been verified against real weights**, avatar included - **no generation has been verified against real weights**, avatar included
- **no training run has been executed** — the SimpleTuner driver is wired and its config
and constraints are checked, but it has not been run against a real dataset
# ---------------------------------------------------------------------------
# CoderAI — LoRA / QLoRA training for LongCat-Video, via SimpleTuner.
#
# coderai does NOT implement this training loop, for two reasons that are both hard:
#
# 1. LongCat's venv is standalone (Python 3.10, torch 2.6, transformers 4.41), so a
# trainer there cannot import codai.api.loras the way tools/lora_train_worker.py
# does — that overlay venv inherits the parent's site-packages; this one cannot.
# 2. The upstream repo offers no way to CREATE LoRA layers. Its DiT has load_lora() and
# enable_loras() but nothing that initialises trainable ones, and the adapters it
# loads (cfg_step_lora, refinement_lora, dmd_lora) use its own key layout.
# Reimplementing that layout blind would produce adapters the pipeline cannot load.
#
# SimpleTuner implements LongCat-Video properly — `model_family: "longcat_video"`, LoRA
# and quantised LoRA (int8-quanto / int4-quanto / fp8-torchao, no extra installs) — so
# coderai generates its config and drives it.
#
# This is a SEPARATE venv from the inference one: SimpleTuner pins its own torch and
# would otherwise fight requirements-longcat.txt.
#
# python3.10 -m venv <dir> && <dir>/bin/python -m pip install -r this-file
# (or set longcat.train_auto_build = true)
# ---------------------------------------------------------------------------
simpletuner
"""LoRA / QLoRA training for LongCat-Video.
coderai does NOT implement this training loop, and the reasons are the design:
1. LongCat's venv is standalone — Python 3.10, torch 2.6, transformers 4.41 — so a
trainer there cannot ``from codai.api import loras`` the way
``tools/lora_train_worker.py`` does. That overlay venv inherits the parent's
site-packages; this one deliberately does not.
2. The upstream repo has **no way to create trainable LoRA layers**. Its DiT exposes
``load_lora()`` / ``enable_loras()`` / ``disable_all_loras()`` and nothing that
initialises one, and the adapters it loads use its own key layout. Reimplementing that
blind would produce adapters the pipeline cannot load — which would look like support
while being worse than none.
SimpleTuner implements it properly (``model_family: "longcat_video"``, LoRA and quantised
LoRA), so coderai generates its config and drives it. These tests cover the wiring, the
constraints checked before a long run starts, and the refusals.
"""
import importlib.util
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from codai.api import longcat_worker as W
from codai.api.lora_archs import (ARCHS, EXTERNAL_TARGETS, TARGETS, detect_arch,
external_trainer, load_classes)
from codai.config import LongcatConfig
def _trainer():
spec = importlib.util.spec_from_file_location(
"longcat_train", ROOT / "tools" / "longcat_train.py")
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
return m
T = _trainer()
LORAS = (ROOT / "codai/api/loras.py").read_text()
# ---------------------------------------------------------------- the registry
def test_longcat_is_a_target():
assert "longcat" in TARGETS
assert "longcat" in ARCHS
def test_it_is_detected_by_path_and_by_target():
assert detect_arch("meituan-longcat/LongCat-Video") == "longcat"
assert detect_arch("anything", "longcat") == "longcat"
# `target: video` still resolves LongCat weights to LongCat, not to Wan.
assert detect_arch("/w/LongCat-Video", "video") == "longcat"
def test_wan_is_unaffected():
"""`target: video` has always meant Wan; existing configs must keep working."""
assert detect_arch("Wan-AI/Wan2.2-I2V-A14B-Diffusers", "video") == "wan"
assert external_trainer("wan") == ""
def test_it_is_marked_as_externally_trained():
assert external_trainer("longcat") == "simpletuner"
assert "longcat" in EXTERNAL_TARGETS
def test_load_classes_refuses_with_the_reason():
"""Its classes are in the repo's own package, not diffusers. A bare AttributeError
here would read like a diffusers version problem."""
with pytest.raises(RuntimeError) as e:
load_classes("longcat")
msg = str(e.value)
assert "does not train in this process" in msg
assert "simpletuner" in msg.lower()
def test_the_in_process_architectures_still_load_normally():
"""Guard against the external branch swallowing everything."""
for arch in ("ltx2", "h3", "krea"):
assert external_trainer(arch) == ""
# ---------------------------------------------------------------- the config it writes
def test_the_simpletuner_config_names_the_model_family():
cfg = T.build_config({"output_dir": "/o", "dataset_config": "/d.json",
"steps": 800, "rank": 8, "request": {}})
assert cfg["model_family"] == "longcat_video"
assert cfg["model_type"] == "lora"
assert cfg["model_flavour"] == "final"
def test_the_checkpoint_is_left_to_the_flavour():
"""SimpleTuner's quickstart says to leave pretrained_model_name_or_path unset."""
cfg = T.build_config({"output_dir": "/o", "dataset_config": "/d.json", "request": {}})
assert "pretrained_model_name_or_path" not in cfg
def test_qlora_is_the_base_precision_switch():
cfg = T.build_config({"output_dir": "/o", "dataset_config": "/d.json",
"base_precision": "int8-quanto", "request": {}})
assert cfg["base_model_precision"] == "int8-quanto"
def test_bf16_omits_the_precision_key():
cfg = T.build_config({"output_dir": "/o", "dataset_config": "/d.json",
"base_precision": "", "request": {}})
assert "base_model_precision" not in cfg
def test_the_memory_settings_are_what_a_13b_needs():
cfg = T.build_config({"output_dir": "/o", "dataset_config": "/d.json", "request": {}})
assert cfg["train_batch_size"] == 1
assert cfg["gradient_checkpointing"] is True
assert cfg["lora_rank"] <= 8 # the quickstart's guidance for this model
def test_the_default_precision_is_quantised():
"""A 13.6B transformer plus optimiser state does not fit a consumer card at bf16."""
assert LongcatConfig().train_base_precision == "int8-quanto"
assert LongcatConfig().train_lora_rank <= 8
assert LongcatConfig().train_gradient_checkpointing is True
# ---------------------------------------------------------------- the constraints
@pytest.mark.parametrize("frames", [93, 89, 85, 5, 1])
def test_valid_frame_counts_pass(frames):
"""(num_frames - 1) must be divisible by 4 — the VAE's rule, the same 4n+1 coderai
already applies to Wan."""
assert T.frame_problems(frames, "480x832") == []
@pytest.mark.parametrize("frames", [90, 92, 0, 2])
def test_invalid_frame_counts_are_caught_before_the_run(frames):
probs = T.frame_problems(frames, "480x832")
assert probs and "4n+1" in probs[0]
def test_sides_must_clear_the_vae_stride():
assert T.frame_problems(93, "481x832")
assert T.frame_problems(93, "480x833")
assert T.frame_problems(93, "720x1280") == []
def test_a_malformed_resolution_is_reported():
assert T.frame_problems(93, "big")
# ---------------------------------------------------------------- the venv
def test_the_training_venv_is_separate_from_the_inference_one():
"""SimpleTuner pins its own torch; sharing would leave neither working."""
assert W.resolve_train_venv() != W.resolve_venv_dir()
def test_it_can_be_pointed_anywhere(monkeypatch, tmp_path):
assert W.resolve_train_venv({"longcat_train_venv": str(tmp_path)}) == tmp_path
monkeypatch.setenv("CODERAI_LONGCAT_TRAIN_VENV", str(tmp_path / "t"))
assert W.resolve_train_venv() == tmp_path / "t"
def test_an_absent_trainer_refuses_with_the_reason_and_the_command(monkeypatch, tmp_path):
monkeypatch.setattr(W, "resolve_train_venv", lambda cfg=None: tmp_path / "nope")
monkeypatch.setattr(W, "_cfg_section", lambda: None)
with pytest.raises(RuntimeError) as e:
W.ensure_train_built({})
msg = str(e.value)
assert "simpletuner" in msg.lower()
assert "-m venv" in msg and "requirements-longcat-train.txt" in msg
# and it says WHY coderai does not just do it itself
assert "no way to create trainable LoRA layers" in msg
def test_auto_build_is_off_by_default():
assert LongcatConfig().train_auto_build is False
# ---------------------------------------------------------------- the dataset refusal
def test_a_video_lora_will_not_be_trained_from_stills():
"""The honest refusal: SimpleTuner wants a captioned VIDEO dataset, and silently
turning a list of reference images into one would train a different thing."""
fn = LORAS[LORAS.index("def _train_externally"):]
fn = fn[:fn.index("\ndef ")]
assert "dataset_config" in fn
assert "will not invent one" in fn
assert "50-100 clips" in fn
def test_a_missing_dataset_file_is_an_error():
fn = LORAS[LORAS.index("def _train_externally"):]
fn = fn[:fn.index("\ndef ")]
assert "does not exist" in fn
def test_the_request_carries_the_dataset_fields():
from codai.api.loras import LoraTrainRequest
r = LoraTrainRequest(name="lc", base_model="meituan-longcat/LongCat-Video",
target="longcat", dataset_config="/d.json",
train_resolution="480x832")
assert r.dataset_config == "/d.json" and r.train_resolution == "480x832"
# ---------------------------------------------------------------- the protocol
def test_progress_uses_the_existing_json_lines_protocol():
"""So the job records, /v1/loras/progress and the Tasks page need no special case."""
assert "_drain_progress" in (ROOT / "codai/api/longcat_worker.py").read_text()
fn = LORAS[LORAS.index("def _train_externally"):]
fn = fn[:fn.index("\ndef ")]
assert "_set_progress" in fn
def test_the_trainer_reports_the_adapter_it_wrote():
src = (ROOT / "tools/longcat_train.py").read_text()
assert "rglob" in src and "safetensors" in src
assert "no .safetensors appeared" in src # rather than claiming success
def test_a_nonzero_exit_is_a_failure_with_the_tail():
src = (ROOT / "tools/longcat_train.py").read_text()
assert "SimpleTuner exited" in src and "Last output" in src
def test_the_int8_loader_is_imported_from_the_right_module():
"""It is in longcat_video.modules.quantization, NOT the DiT module — importing it
from the DiT module raised ImportError at the moment INT8 was asked for."""
svc = (ROOT / "tools/longcat_service.py").read_text()
assert "from longcat_video.modules.quantization import load_quantized_dit" in svc
...@@ -115,7 +115,9 @@ def load_pipeline(checkpoint_dir: str, source_dir: str, dtype: str = "bfloat16", ...@@ -115,7 +115,9 @@ def load_pipeline(checkpoint_dir: str, source_dir: str, dtype: str = "bfloat16",
if use_int8: if use_int8:
# avatar-1.5 only: a pre-quantised DiT under base_model_int8/, loaded by the # avatar-1.5 only: a pre-quantised DiT under base_model_int8/, loaded by the
# repo's own helper rather than from_pretrained. # repo's own helper rather than from_pretrained.
from longcat_video.modules.longcat_video_dit import load_quantized_dit # It lives in the quantization module, not the DiT one, and it returns the
# AVATAR transformer — consistent with INT8 being an avatar-1.5-only build.
from longcat_video.modules.quantization import load_quantized_dit
dit = load_quantized_dit(root, subfolder=LC.INT8_SUBDIR, dit = load_quantized_dit(root, subfolder=LC.INT8_SUBDIR,
cp_split_hw=_state.get("cp_split_hw")) cp_split_hw=_state.get("cp_split_hw"))
log(f"loaded the INT8 DiT from {LC.INT8_SUBDIR}/") log(f"loaded the INT8 DiT from {LC.INT8_SUBDIR}/")
......
#!/usr/bin/env python3
# CoderAI - OpenAI-compatible API server
# Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
"""Drive a LongCat-Video LoRA / QLoRA training run through SimpleTuner.
coderai does NOT implement this loop. Two reasons, both hard:
1. LongCat's venv is standalone — Python 3.10, torch 2.6, transformers 4.41 — so a
trainer there cannot ``from codai.api import loras`` the way
``tools/lora_train_worker.py`` does. That overlay venv inherits the parent's
site-packages; this one deliberately does not.
2. The upstream repo offers no way to CREATE LoRA layers. Its DiT exposes
``load_lora()`` / ``enable_loras()`` / ``disable_all_loras()`` and nothing that
initialises trainable ones, and the adapters it does load (``cfg_step_lora``,
``refinement_lora``, ``dmd_lora``) use its own key layout. Reimplementing that layout
without being able to verify it would produce adapters the pipeline cannot load —
worse than no support, because it would look like support.
SimpleTuner implements LongCat-Video properly (``model_family: "longcat_video"``, LoRA
and quantised LoRA via int8-quanto / int4-quanto / fp8-torchao), so this writes its
config and runs it, translating progress into the same JSON-lines protocol
``tools/lora_train_worker.py`` uses so the existing job records and the Tasks page work
unchanged.
Invoked by codai/api/longcat_worker.py, never by hand:
<train venv python> tools/longcat_train.py --job /path/to/job.json
"""
import argparse
import json
import os
import re
import subprocess
import sys
import time
import traceback
from pathlib import Path
# SimpleTuner reports steps as "step 120/800" or a tqdm-style "120/800"; either is
# enough to drive a progress bar.
_STEP_RE = re.compile(r"(?:step[^\d]{0,3})?(\d+)\s*/\s*(\d+)", re.IGNORECASE)
_LOSS_RE = re.compile(r"loss[=:\s]+([0-9]*\.?[0-9]+)", re.IGNORECASE)
def build_config(job: dict) -> dict:
"""The SimpleTuner config for one job.
Only the keys its LongCat-Video quickstart calls for, plus what the request asked.
`pretrained_model_name_or_path` is left unset on purpose: the flavour selects the
checkpoint."""
req = job.get("request") or {}
cfg = {
"model_type": "lora",
"model_family": "longcat_video",
"model_flavour": job.get("flavour") or "final",
"output_dir": job["output_dir"],
"data_backend_config": job["dataset_config"],
# The 13.6B transformer leaves no room for a larger batch, and gradient
# checkpointing is what makes it fit at all.
"train_batch_size": 1,
"gradient_checkpointing": bool(job.get("gradient_checkpointing", True)),
"lora_rank": int(job.get("rank") or 8),
"max_train_steps": int(job.get("steps") or 800),
"learning_rate": float(job.get("lr") or 1e-4),
"seed": int(job.get("seed") or 42),
"validation_resolution": job.get("resolution") or "480x832",
"validation_num_video_frames": int(job.get("num_frames") or 93),
"tracker_project_name": "coderai-longcat-lora",
}
# QLoRA: the quantised base. Without it the 13.6B transformer plus optimiser state
# does not fit a consumer card.
precision = str(job.get("base_precision") or "").strip()
if precision:
cfg["base_model_precision"] = precision
if req.get("instance_prompt"):
cfg["instance_prompt"] = req["instance_prompt"]
return cfg
def frame_problems(num_frames: int, resolution: str) -> list:
"""Constraints SimpleTuner's quickstart states, checked before a long run starts.
These are the VAE's, not preferences: ``(num_frames - 1)`` must be divisible by 4 —
the same 4n+1 rule coderai already applies to Wan — and each side must be divisible
by 16."""
problems = []
n = int(num_frames or 0)
if n < 1 or (n - 1) % 4 != 0:
problems.append(f"num_frames must be 4n+1 (93, 89, 85 …); got {n}")
try:
w, h = (int(x) for x in str(resolution).lower().split("x"))
except Exception:
return problems + [f"resolution must look like 480x832; got {resolution!r}"]
for name, v in (("width", w), ("height", h)):
if v % 16:
problems.append(f"{name} must be divisible by 16 (the VAE stride); got {v}")
return problems
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description="LongCat-Video LoRA trainer (SimpleTuner)")
ap.add_argument("--job", required=True, help="path to the job JSON")
args = ap.parse_args(argv)
job = json.load(open(args.job))
progress_path = args.job + ".progress"
result_path = args.job + ".result"
def emit(**kw):
kw["at"] = time.time()
with open(progress_path, "a") as fh:
fh.write(json.dumps(kw) + "\n")
try:
problems = frame_problems(job.get("num_frames") or 93,
job.get("resolution") or "480x832")
if problems:
raise RuntimeError("; ".join(problems))
workdir = Path(args.job).parent
cfg = build_config(job)
cfg_path = workdir / "simpletuner.json"
cfg_path.write_text(json.dumps(cfg, indent=2))
emit(status="starting",
message=f"SimpleTuner, LongCat-Video LoRA rank {cfg['lora_rank']}"
+ (f", base {cfg['base_model_precision']}"
if cfg.get("base_model_precision") else ", bf16 base"))
cmd = [sys.executable, "-m", "simpletuner.train", "--config", str(cfg_path)]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, cwd=str(workdir))
total = int(cfg["max_train_steps"])
last = -1
tail = []
for line in proc.stdout:
line = line.rstrip()
if not line:
continue
tail.append(line[:300])
del tail[:-25]
print(f"[longcat-train] {line}", flush=True)
m = _STEP_RE.search(line)
if m:
step, seen_total = int(m.group(1)), int(m.group(2))
if seen_total == total and step != last:
last = step
loss = _LOSS_RE.search(line)
emit(status="training", step=step, total=total,
message=(f"step {step}/{total}"
+ (f", loss {loss.group(1)}" if loss else "")))
rc = proc.wait()
if rc != 0:
raise RuntimeError(f"SimpleTuner exited {rc}. Last output: "
+ " | ".join(tail[-6:]))
# The adapter SimpleTuner wrote. Reported as a path rather than moved: the
# parent owns where a LoRA is registered.
out = Path(cfg["output_dir"])
found = sorted(out.rglob("*.safetensors"), key=lambda p: p.stat().st_mtime)
if not found:
raise RuntimeError(f"training finished but no .safetensors appeared under "
f"{out}")
json.dump({"ok": True, "result": {"path": str(found[-1]),
"steps": total,
"rank": cfg["lora_rank"]}},
open(result_path, "w"))
emit(status="done", message=f"trained {found[-1].name}")
return 0
except Exception as exc:
json.dump({"ok": False, "error": f"{type(exc).__name__}: {exc}",
"traceback": traceback.format_exc()[-2000:]},
open(result_path, "w"))
emit(status="error", message=str(exc)[:300])
print(traceback.format_exc(), file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment