text: LoRA and QLoRA on text models, locally and on RunPod

lora_path and lora_model_dir have been in the model config for a long
time, but only the VRAM estimator ever read them: vulkan.py, cuda.py and
vllm.py contained zero LoRA references, so configuring a LoRA on a text
model silently did nothing. Now all three apply them.

codai/models/text_loras.py answers the one question they share — which
adapters does this model want, and where are they — across every config
shape the field has grown (lora_path, a list, lora_model_dir, a `loras`
list of dicts), de-duplicated so the same adapter is not applied twice.
Each runtime then does its own thing:

  transformers  PEFT load_adapter + set_adapters, several at once
  llama.cpp     lora_path + lora_scale, ONE adapter and it must be
                GGUF-converted — a PEFT safetensors is reported and
                skipped rather than failing deep inside the load
  vLLM          --enable-lora --lora-modules, with --max-lora-rank 64
                because the default 16 refuses most trained adapters and
                blames the flag rather than the adapter

QLoRA needs nothing extra: the quantisation is how the BASE model loads
(load_in_4bit), the adapter is an ordinary LoRA. The manager now passes
the model's entry to the backend as `model_config`, which is how any of
this reaches them.

On RunPod the asymmetry is the interesting part. A diffusion LoRA is sent
with the request, because that API carries adapters per request. A text
LoRA is part of how the model LOADS, so it must resolve when the pod
starts: only HuggingFace repo ids travel to a vLLM/llama.cpp pod, and a
local path is reported and skipped instead of being baked into a launch
command that would fail minutes later inside a pod nobody can inspect. A
coderai pod gets the adapters in its seed and applies them itself.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182DhR1eFNPQyGmDrpHbedY
parent 249da5af
......@@ -2697,6 +2697,7 @@ async def api_model_configure(request: Request, username: str = Depends(require_
"max_instances", "preload_all_instances", "capabilities",
"model_template", "vae_path", "t5xxl_path", "clip_l_path",
"clip_g_path", "clip_vision_path", "lora_path", "lora_model_dir",
"lora_scale",
"lora_train_base_model",
"max_vram", "sdcpp_flash_attn", "sdcpp_diffusion_flash_attn", "vae_tiling",
"component_quantization", "output_crf", "force_vram_update",
......
......@@ -1351,6 +1351,11 @@ window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson }
<label class="form-label">LoRA file <span class="muted">(optional — applied at load)</span></label>
<select id="cfg-lora-sel" class="form-input" onchange="_onCompPick('lora')"></select>
<input type="text" id="cfg-lora-path" class="form-input" style="display:none;margin-top:.3rem" placeholder="Custom LoRA path…">
<div style="display:flex;gap:.5rem;align-items:center;margin-top:.3rem">
<span class="form-hint" style="font-size:11px;color:var(--text-3);flex:1">Works on text models too (LoRA and QLoRA). A HuggingFace repo id also works — and is the only form a vLLM/llama.cpp <b>pod</b> can load, since it cannot read this disk. llama.cpp needs a GGUF-converted adapter.</span>
<label class="form-label" style="margin:0;font-size:11px">Strength</label>
<input type="number" id="cfg-lora-scale" class="form-input" style="width:90px" min="0" step="0.05" placeholder="1.0">
</div>
<span class="form-hint" style="font-size:11px;color:var(--text-3)">Tip: use &lt;lora:name:strength&gt; in prompts to apply at inference time</span>
</div>
<div class="form-row">
......@@ -3752,6 +3757,7 @@ function openCfgModal(idx, cfgIdx){
_setCompField('clip-vision',s.clip_vision_path|| '');
_setCompField('lora', s.lora_path || '');
document.getElementById('cfg-lora-dir').value = s.lora_model_dir || '';
document.getElementById('cfg-lora-scale').value = s.lora_scale != null ? s.lora_scale : '';
document.getElementById('cfg-lora-train-base').value = s.lora_train_base_model || '';
_populateAccel(s.acceleration);
_populateTurboQuant(s.turboquant);
......@@ -4460,6 +4466,7 @@ async function saveModelConfig(){
clip_vision_path: _readCompField('clip-vision') || null,
lora_path: _readCompField('lora') || null,
lora_model_dir: document.getElementById('cfg-lora-dir').value.trim() || null,
lora_scale: parseFloat(document.getElementById('cfg-lora-scale').value) || null,
lora_train_base_model: document.getElementById('cfg-lora-train-base').value.trim() || null,
max_vram: parseFloat(document.getElementById('cfg-max-vram').value) || 0,
sdcpp_flash_attn: document.getElementById('cfg-sdcpp-flash').checked,
......
......@@ -406,7 +406,11 @@ def seed_model_env(entry: dict, served: str = "", source: str = "") -> str:
keep = ("path", "model_type", "model_types", "video_subtypes", "capabilities",
"alias", "config_name", "load_in_4bit", "load_in_8bit", "n_ctx",
"flash_attention", "model_template", "acceleration", "component_quantization",
"languages", "supports_translation", "parser", "max_instances")
"languages", "supports_translation", "parser", "max_instances",
# LoRA settings travel too: a coderai pod applies them itself, and
# load_in_4bit above is what makes a QLoRA adapter load against the
# base it was trained on.
"lora_path", "lora_model_dir", "lora_scale", "loras")
out = {k: entry[k] for k in keep if k in entry and entry[k] is not None}
out["path"] = path
if served and served != path:
......@@ -731,9 +735,40 @@ def _vllm_docker_args(mcfg: "RunpodModelConfig", served: str,
args += ["--max-model-len", str(mcfg.ctx)]
if mcfg.quantization:
args += ["--quantization", mcfg.quantization]
args += _pod_lora_args(entry or {})
return " ".join(args)
def _pod_lora_args(entry: dict) -> list:
"""vLLM `--lora-modules` for a POD, using only adapters the pod can resolve.
A pod cannot read this machine's disk, so a local adapter path is no more
usable there than a local model path. Only HuggingFace repo ids travel; a
local adapter is reported and skipped rather than baked into a launch command
that would fail minutes later, inside a pod, with a path nobody can inspect.
"""
try:
from codai.models.text_loras import configured_specs
specs = configured_specs(entry or {})
except Exception:
return []
usable, local = [], []
for spec in specs:
src = str(spec.get("source") or "")
(usable if _looks_like_repo_id(src) else local).append(spec)
if local:
names = ", ".join(str(s.get("source")) for s in local)
print(f"[lora] not sent to the pod (local paths — publish them to "
f"HuggingFace, or serve this model on a coderai pod, which is sent "
f"adapters with the request): {names}", flush=True)
if not usable:
return []
args = ["--enable-lora", "--lora-modules"]
args += [f"{s['name']}={s['source']}" for s in usable]
args += ["--max-lora-rank", "64"]
return args
class RunpodPodPool:
"""A pool of coderai-managed RunPod pods for one model.
......
......@@ -114,7 +114,7 @@ def resolve_service_key(cfg, model_path: Optional[str] = None):
def _launch_cmd(py, cfg, host: str, port: int, model_path: str,
served_name: Optional[str] = None) -> list:
served_name: Optional[str] = None, model_config: dict = None) -> list:
mid = served_name or (getattr(cfg, "model_id", "vllm") or "vllm")
cmd = [py, "-m", "vllm.entrypoints.openai.api_server",
"--host", host, "--port", str(port),
......@@ -138,12 +138,50 @@ def _launch_cmd(py, cfg, host: str, port: int, model_path: str,
quant = (getattr(cfg, "quantization", "") or "").strip()
if quant:
cmd += ["--quantization", quant]
# LoRA adapters configured on the MODEL (not on the vLLM backend): vLLM takes
# them at launch as name=source pairs, where source is a path on this host or
# a HuggingFace repo id. A QLoRA adapter is an ordinary LoRA here — the
# quantisation is how the base model loads, which --quantization covers.
cmd += _lora_args(model_config)
extra = (getattr(cfg, "extra_args", "") or "").strip()
if extra:
cmd += shlex.split(extra)
return cmd
def _lora_args(model_config: dict) -> list:
"""`--enable-lora --lora-modules …` for the adapters a model config asks for."""
try:
from codai.models.text_loras import configured_specs, describe
specs = configured_specs(model_config or {})
except Exception:
return []
if not specs:
return []
args = ["--enable-lora"]
mods = []
for spec in specs:
mods.append(f"{spec['name']}={spec['source']}")
args += ["--lora-modules"] + mods
# vLLM rejects an adapter whose rank exceeds --max-lora-rank (default 16),
# and the message points at the flag rather than the adapter, so raise the
# ceiling to what vLLM supports rather than have a trained LoRA refused.
args += ["--max-lora-rank", "64"]
print(f"[lora] vLLM will serve adapters: {describe(specs)}", flush=True)
return args
def _model_config_for(model_name: str) -> dict:
"""The model's models.json entry — vLLM serves models from the model list, so
its LoRA settings live on the model, not on the vLLM backend config."""
try:
from codai.models.manager import _model_entry_for
return _model_entry_for(model_name) or {}
except Exception:
return {}
def _remote_serves(url: str, name: str) -> bool:
"""True when a remote vLLM's /v1/models lists ``name`` (or a suffix match)."""
import requests
......@@ -204,7 +242,8 @@ def ensure_service(cfg, model_path: Optional[str] = None,
port = int(getattr(cfg, "port", 0) or 0) or _free_port()
url_host = "127.0.0.1" if host in ("0.0.0.0", "") else host
url = f"http://{url_host}:{port}"
cmd = _launch_cmd(py, cfg, host, port, model, served_name)
cmd = _launch_cmd(py, cfg, host, port, model, served_name,
model_config=_model_config_for(resolved or model))
env = os.environ.copy()
# flashinfer JIT-compiles CUDA kernels with ninja at runtime, which fails on
......
......@@ -74,6 +74,51 @@ def _make_thermal_criteria():
return _ThermalPause()
def _apply_text_loras(model, config) -> None:
"""Load the LoRA adapters a text model's config asks for, via PEFT.
A QLoRA adapter needs nothing special: the quantisation describes how the
BASE model was loaded (4-bit), and the adapter is an ordinary LoRA — so it
loads like any other, provided the base is loaded the way it was trained.
Several adapters can be active at once; PEFT combines them.
Never fatal: a model that loads without its adapter is still useful, and the
alternative is a model that will not load at all because of a bad path.
"""
try:
from codai.models.text_loras import configured_specs, describe
specs = configured_specs(config if isinstance(config, dict) else {})
if not specs:
return
from codai.models.peft_compat import ensure_peft_awq_compat
ensure_peft_awq_compat()
from peft import PeftModel # noqa: F401 (import proves peft is usable)
except Exception as exc:
print(f"[lora] cannot apply text adapters: {exc}", flush=True)
return
loaded = []
for spec in specs:
try:
model.load_adapter(spec["source"], adapter_name=spec["name"])
loaded.append(spec)
except Exception as exc:
print(f"[lora] failed to load {spec['source']}: {exc}", flush=True)
if not loaded:
return
try:
names = [s["name"] for s in loaded]
weights = [float(s.get("weight", 1.0)) for s in loaded]
if hasattr(model, "set_adapters"):
model.set_adapters(names, weights)
elif hasattr(model, "set_adapter"):
model.set_adapter(names[0])
except Exception as exc:
print(f"[lora] adapters loaded but could not be activated: {exc}", flush=True)
return
print(f"[lora] applied {describe(loaded)}", flush=True)
class NvidiaBackend(ModelBackend):
"""Backend for NVIDIA GPUs using HuggingFace Transformers."""
......@@ -1162,9 +1207,10 @@ class NvidiaBackend(ModelBackend):
raise RuntimeError("Failed to load model: Out of memory even with minimum GPU usage")
self.model = model
_apply_text_loras(self.model, kwargs.get('model_config') or kwargs)
self.model.eval()
self.model_name = model_name
print(f"\nModel loaded successfully")
print(f"Model device: {next(self.model.parameters()).device}")
......
......@@ -1361,6 +1361,33 @@ class VulkanBackend(ModelBackend):
if no_ram:
llama_kwargs['use_mmap'] = False
print("DEBUG: --no-ram mode: use_mmap=False, n_gpu_layers=-1")
# LoRA adapters configured on the model. llama.cpp takes ONE adapter and
# it must be GGUF-converted — a PEFT safetensors directory cannot be
# loaded here, so say that plainly instead of failing inside the load.
try:
from codai.models.text_loras import configured_specs, gguf_adapter, local_path
_specs = configured_specs(kwargs.get('model_config') or kwargs)
if _specs:
if len(_specs) > 1:
print(f"[lora] {len(_specs)} adapters configured but llama.cpp "
f"applies one — using {_specs[0]['name']!r}", flush=True)
_adapter = gguf_adapter(_specs[0])
if _adapter:
llama_kwargs['lora_path'] = _adapter
llama_kwargs['lora_scale'] = float(_specs[0].get('weight', 1.0))
print(f"[lora] applying {_specs[0]['name']!r} "
f"(scale {llama_kwargs['lora_scale']:g}) to this GGUF",
flush=True)
elif local_path(_specs[0]['source']):
print(f"[lora] {_specs[0]['source']} is not a GGUF adapter — "
"llama.cpp needs one converted with convert_lora_to_gguf.py; "
"skipping it", flush=True)
else:
print(f"[lora] {_specs[0]['source']} not found locally; skipping",
flush=True)
except Exception as _exc:
print(f"[lora] could not apply a configured adapter: {_exc}", flush=True)
# Add optional parameters
if 'n_threads' in kwargs:
......
......@@ -1660,6 +1660,9 @@ class MultiModelManager:
# Configured precision → torch dtype (parity with hf_loading); the
# nvidia backend defaults fp16/fp32 when unset.
kwargs['precision'] = config.get('precision')
# The whole entry, so the backend can read settings it owns —
# LoRA adapters among them (codai/models/text_loras.py).
kwargs['model_config'] = config
# KV-cache quantization (llama.cpp type_k/type_v) — pass through
# to the backend, with the raw models.json entry as a fallback.
_raw = config.get('_raw_cfg') if isinstance(config.get('_raw_cfg'), dict) else {}
......@@ -1827,6 +1830,9 @@ class MultiModelManager:
# Configured precision → torch dtype (parity with hf_loading); the
# nvidia backend defaults fp16/fp32 when unset.
kwargs['precision'] = config.get('precision')
# The whole entry, so the backend can read settings it owns —
# LoRA adapters among them (codai/models/text_loras.py).
kwargs['model_config'] = config
# KV-cache quantization (llama.cpp type_k/type_v) — pass through
# to the backend, with the raw models.json entry as a fallback.
_raw = config.get('_raw_cfg') if isinstance(config.get('_raw_cfg'), dict) else {}
......
# 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.
"""LoRA adapters for TEXT models — resolution shared by every text path.
Image and video models have had LoRA support for a long time (diffusers'
``load_lora_weights``). Text models did not: ``lora_path`` and ``lora_model_dir``
existed in the model config, but only the VRAM estimator ever read them, so
configuring a LoRA on a text model silently did nothing.
The three text paths each apply adapters their own way, and there is no common
library between them:
* GGUF via llama-cpp-python — ``lora_path`` + ``lora_scale`` on the Llama object;
the adapter must be a **GGUF-converted** adapter, not a PEFT safetensors.
* HuggingFace via transformers — PEFT: ``load_adapter`` on the loaded model.
* vLLM — ``--enable-lora --lora-modules name=<source>`` at launch, where the
source is a path on the server or a HuggingFace repo id.
What they share is the question "which adapters does this model want, and where
are they", which is what this module answers. Everything downstream is the
backend's own business.
A QLoRA adapter needs nothing special here: the quantisation describes how the
BASE model is loaded (4-bit), while the adapter itself is an ordinary LoRA. So a
QLoRA trained against a 4-bit base loads like any other, provided the base is
loaded the way it was trained.
"""
import os
from typing import List
def _as_list(value) -> list:
if value is None or value == "":
return []
if isinstance(value, (list, tuple)):
return list(value)
return [value]
def configured_specs(config: dict) -> List[dict]:
"""The LoRA adapters a model's config asks for, normalised.
Accepts every shape the config has grown:
``lora_path`` one path/repo id, or a list of them
``lora_model_dir`` a directory of adapters (each subdirectory is one)
``loras`` a list of {path|id|model, weight, name} dicts
Returns ``[{"source": str, "weight": float, "name": str}, …]``.
"""
if not isinstance(config, dict):
return []
out: List[dict] = []
for raw in _as_list(config.get("loras")):
if isinstance(raw, dict):
src = (raw.get("path") or raw.get("source") or raw.get("model")
or raw.get("id") or "")
if not src:
continue
out.append({"source": str(src),
"weight": float(raw.get("weight", 1.0) or 1.0),
"name": str(raw.get("name") or _default_name(str(src)))})
elif raw:
out.append({"source": str(raw), "weight": 1.0,
"name": _default_name(str(raw))})
for raw in _as_list(config.get("lora_path")):
if raw:
out.append({"source": str(raw),
"weight": float(config.get("lora_scale", 1.0) or 1.0),
"name": _default_name(str(raw))})
for d in _as_list(config.get("lora_model_dir")):
d = os.path.expanduser(str(d or ""))
if not d or not os.path.isdir(d):
continue
for name in sorted(os.listdir(d)):
sub = os.path.join(d, name)
# A directory of PEFT adapters, or a directory of adapter files.
if os.path.isdir(sub) or name.endswith((".safetensors", ".bin", ".gguf")):
out.append({"source": sub,
"weight": float(config.get("lora_scale", 1.0) or 1.0),
"name": _default_name(sub)})
# Keep the first mention of each source: the config shapes overlap, and
# loading the same adapter twice would double its effect.
seen, unique = set(), []
for spec in out:
key = spec["source"]
if key in seen:
continue
seen.add(key)
unique.append(spec)
return unique
def _default_name(source: str) -> str:
base = os.path.basename(str(source).rstrip("/")) or str(source)
for suffix in (".safetensors", ".bin", ".gguf"):
if base.endswith(suffix):
base = base[: -len(suffix)]
return base or "lora"
def local_path(source: str) -> str:
"""The adapter as a local path, or '' when it is a repo id / not present."""
p = os.path.expanduser(str(source or ""))
return p if p and os.path.exists(p) else ""
def gguf_adapter(spec: dict) -> str:
"""The GGUF adapter file for llama.cpp, or ''.
llama-cpp-python takes ONE adapter file and it must be GGUF-converted; a PEFT
safetensors directory is not loadable there. Returning '' lets the caller say
so clearly rather than have llama.cpp fail deep inside a load.
"""
path = local_path(spec.get("source", ""))
if not path:
return ""
if os.path.isfile(path) and path.endswith(".gguf"):
return path
if os.path.isdir(path):
for name in sorted(os.listdir(path)):
if name.endswith(".gguf"):
return os.path.join(path, name)
return ""
def describe(specs: List[dict]) -> str:
return ", ".join(f"{s['name']}@{s['weight']:g}" for s in specs) or "(none)"
......@@ -413,6 +413,31 @@ perfectly reasonable — a 30 GB model is a few minutes at gigabit — but a
HuggingFace repo id or a URL costs nothing here and pulls at datacenter speed. A
RunPod network volume is the better answer for weights used repeatedly.
### LoRA adapters
Image and video models have had LoRA support for a long time. Text models now do
too, locally and remotely — `lora_path` (a local path **or** a HuggingFace repo
id), `lora_model_dir`, or a `loras` list, with `lora_scale` for strength. A
**QLoRA** adapter needs nothing special: the quantisation describes how the base
model loads (`load_in_4bit`), and the adapter itself is an ordinary LoRA.
Each runtime applies them its own way, and what reaches a pod differs:
| Runtime | How | On a pod |
|---|---|---|
| transformers (HF) | PEFT `load_adapter`, several at once | via a coderai pod, adapters travel with the seed |
| llama.cpp (GGUF) | one adapter, **GGUF-converted** | needs the file; use a HF repo id or a coderai pod |
| vLLM | `--enable-lora --lora-modules` at launch | **HuggingFace repo ids only** |
| diffusers (image/video) | `load_lora_weights` | uploaded to the remote automatically, content-addressed |
The asymmetry is worth knowing: a **diffusion** LoRA is sent to the remote with
the request (hashed, uploaded once, referenced by content id), because that API
carries adapters per request. A **text** LoRA is part of how the model is
*loaded*, so it has to be resolvable when the pod starts — which means a
HuggingFace repo id for a vLLM or llama.cpp pod. A local adapter path is reported
and skipped rather than baked into a launch command that would fail minutes later
inside a pod. Publish it to HuggingFace, or serve that model on a coderai pod.
### Keeping a pod warm
`keep_warm` on a model's runpod block (or a capability's) keeps one pod running
......
......@@ -850,3 +850,81 @@ def test_keep_warm_is_off_by_default_and_means_one_pod():
assert warm.keep_warm is True and warm.min_pods == 1
# An explicit larger min_pods is not reduced by it.
assert parse_model_runpod({"keep_warm": True, "min_pods": 3}).min_pods == 3
# --------------------------------------------------------------------------- #
# LoRA / QLoRA on TEXT models
# --------------------------------------------------------------------------- #
def test_text_lora_config_shapes_all_resolve():
"""The config grew several shapes over time; all of them must work."""
from codai.models.text_loras import configured_specs
one = configured_specs({"lora_path": "/AI/loras/style.safetensors", "lora_scale": 0.7})
assert one == [{"source": "/AI/loras/style.safetensors", "weight": 0.7,
"name": "style"}]
many = configured_specs({"loras": [
{"path": "org/adapter-a", "weight": 0.5, "name": "a"},
{"model": "/AI/loras/b.safetensors"}]})
assert [s["name"] for s in many] == ["a", "b"]
assert many[0]["weight"] == 0.5 and many[1]["weight"] == 1.0
# The same adapter named twice is applied once, not twice.
dup = configured_specs({"lora_path": ["org/x", "org/x"]})
assert len(dup) == 1
assert configured_specs({}) == []
def test_llamacpp_needs_a_gguf_adapter(tmp_path):
"""llama.cpp cannot load a PEFT safetensors: report it instead of failing
deep inside the model load."""
from codai.models.text_loras import gguf_adapter
peft = tmp_path / "adapter"
peft.mkdir()
(peft / "adapter_model.safetensors").write_bytes(b"x")
assert gguf_adapter({"source": str(peft)}) == ""
conv = tmp_path / "adapter.gguf"
conv.write_bytes(b"x")
assert gguf_adapter({"source": str(conv)}) == str(conv)
def test_vllm_serves_configured_adapters():
from codai.api.vllm_worker import _lora_args
args = _lora_args({"lora_path": "/AI/loras/style.safetensors"})
assert "--enable-lora" in args
assert "style=/AI/loras/style.safetensors" in args
# A rank ceiling that refuses real adapters is a bad default to inherit.
assert "--max-lora-rank" in args
assert _lora_args({}) == []
def test_a_pod_only_gets_adapters_it_can_resolve(capsys):
"""A pod cannot read this disk. An HF repo id travels; a local path does not,
and must be reported rather than baked into a doomed launch command."""
from codai.api.runpod_worker import _vllm_docker_args, parse_model_runpod
remote_ok = _vllm_docker_args(parse_model_runpod({}), "Qwen/Qwen3.5-9B",
{"path": "Qwen/Qwen3.5-9B", "lora_path": "org/my-lora"})
assert "--enable-lora" in remote_ok and "my-lora=org/my-lora" in remote_ok
local_only = _vllm_docker_args(parse_model_runpod({}), "Qwen/Qwen3.5-9B",
{"path": "Qwen/Qwen3.5-9B",
"lora_path": "/AI/loras/local.safetensors"})
assert "--enable-lora" not in local_only
assert "not sent to the pod" in capsys.readouterr().out
def test_a_coderai_pod_is_told_about_the_models_adapters():
"""A coderai pod applies LoRAs itself, so its seed must carry them — along
with load_in_4bit, which is what makes a QLoRA load against its own base."""
from codai.api.runpod_worker import seed_model_env
seeded = json.loads(seed_model_env(
{"path": "org/model", "model_type": "text_models",
"lora_path": "org/adapter", "lora_scale": 0.6, "load_in_4bit": True}))[0]
assert seeded["lora_path"] == "org/adapter"
assert seeded["lora_scale"] == 0.6
assert seeded["load_in_4bit"] is True
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