Skip to content

Commit 8fb3879

Browse files
committed
fix(models,cli,batch): free-VRAM sizing + device honesty, gguf seed, offline errors
1 parent 84731f1 commit 8fb3879

11 files changed

Lines changed: 595 additions & 23 deletions

effgen/cli/_main.py

Lines changed: 116 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2187,14 +2187,59 @@ def _models_info(self, args):
21872187
return 1
21882188

21892189
from effgen.models import _catalog, _refresh
2190+
from effgen.models.model_loader import ModelLoader
2191+
2192+
# An engine-prefixed id (e.g. "transformers:Qwen/Qwen2.5-1.5B-Instruct")
2193+
# names a local engine + a bare repo id. Strip the prefix so the id
2194+
# matches the local cache and the catalog, and remember the engine so we
2195+
# can lead with the local view.
2196+
lookup_name = args.name
2197+
requested_engine = None
2198+
if ":" in lookup_name:
2199+
_prefix, _rest = lookup_name.split(":", 1)
2200+
if _prefix in ModelLoader._LOCAL_ENGINE_PREFIXES and _rest:
2201+
requested_engine = _prefix
2202+
lookup_name = _rest
21902203

21912204
# Is this id sitting in the local HF cache? If so we can describe it as
21922205
# locally-runnable even when the cloud catalog has no (or a different) row.
21932206
local_entry = next(
2194-
(m for m in self._local_cached_models() if m["id"] == args.name), None
2207+
(m for m in self._local_cached_models() if m["id"] == lookup_name), None
21952208
)
21962209

2197-
rec = _catalog.lookup(args.name)
2210+
# An explicit local-engine request is answered from the local cache: show
2211+
# the cached copy, or report a cache miss naming what is cached (rather
2212+
# than cloud catalog suggestions the engine can't run).
2213+
if requested_engine is not None:
2214+
if local_entry is not None:
2215+
local_payload = self._local_model_payload(local_entry)
2216+
if getattr(args, "output_json", False):
2217+
print(json.dumps({"id": lookup_name, "provider": None,
2218+
"engine": requested_engine,
2219+
"local": local_payload}, indent=2))
2220+
return 0
2221+
self.print_header(f"Model: {lookup_name} ({requested_engine})")
2222+
self._render_local_model_info(local_payload)
2223+
return 0
2224+
cached = [m["id"] for m in self._local_cached_models()]
2225+
if getattr(args, "output_json", False):
2226+
print(json.dumps({"id": lookup_name, "provider": None,
2227+
"engine": requested_engine, "local": None,
2228+
"cached_models": cached}, indent=2))
2229+
return 1
2230+
self.print_error(
2231+
f"Model '{lookup_name}' is not in the local cache, so the "
2232+
f"'{requested_engine}' engine can't run it yet."
2233+
)
2234+
if cached:
2235+
self.print("Locally cached models:")
2236+
for cid in cached:
2237+
self.print(f" {cid}")
2238+
self.print(f"\nDownload it first: effgen run -m {lookup_name} "
2239+
f"--engine {requested_engine} \"...\" (with network access).")
2240+
return 1
2241+
2242+
rec = _catalog.lookup(lookup_name)
21982243
if rec is None:
21992244
if local_entry is not None:
22002245
# Downloaded locally but not in the cloud catalog: describe the local
@@ -2318,6 +2363,9 @@ def _models_unload(self, args):
23182363

23192364
def _models_status(self, args):
23202365
"""Show loaded models and GPU memory status."""
2366+
if getattr(args, "output_json", False):
2367+
return self._models_status_json()
2368+
23212369
self.print_header("Model & GPU Status")
23222370

23232371
# GPU memory info — physical (driver) view across all processes, so this
@@ -2385,6 +2433,47 @@ def _models_status(self, args):
23852433
registered = list_registered_models()
23862434
self.print(f"\nCapability profiles registered: {len(registered)}")
23872435

2436+
def _models_status_json(self) -> int:
2437+
"""Emit the GPU table + loaded models as JSON for ops/edge tooling."""
2438+
gib = 1024 ** 3
2439+
gpu_list: list[dict] = []
2440+
cuda_available = True
2441+
try:
2442+
from effgen.gpu.cuda_compat import per_gpu_status
2443+
for g in per_gpu_status():
2444+
gpu_list.append({
2445+
"index": g.index,
2446+
"name": g.name,
2447+
"total_gb": round(g.total_bytes / gib, 3),
2448+
"used_gb": round(g.used_bytes / gib, 3),
2449+
"free_gb": round(g.free_bytes / gib, 3),
2450+
"utilization_pct": g.utilization_pct,
2451+
})
2452+
if not gpu_list:
2453+
try:
2454+
import torch
2455+
cuda_available = torch.cuda.is_available()
2456+
except ImportError:
2457+
cuda_available = False
2458+
except ImportError:
2459+
cuda_available = False
2460+
2461+
from effgen.models.capabilities import list_registered_models
2462+
from effgen.models.model_loader import ModelLoader
2463+
loaded = ModelLoader().get_loaded_models()
2464+
loaded_list = [
2465+
{"name": name, "loaded": bool(model.is_loaded())}
2466+
for name, model in loaded.items()
2467+
]
2468+
payload = {
2469+
"cuda_available": cuda_available,
2470+
"gpus": gpu_list,
2471+
"loaded_models": loaded_list,
2472+
"capability_profiles": len(list_registered_models()),
2473+
}
2474+
print(json.dumps(payload, indent=2))
2475+
return 0
2476+
23882477
def _models_refresh(self, args):
23892478
"""Refresh the bundled model catalog from each provider's live API.
23902479
@@ -2935,7 +3024,8 @@ def create_parser():
29353024
models_unload = models_subparsers.add_parser('unload', help='Unload a model from memory')
29363025
models_unload.add_argument('name', help='Model name')
29373026

2938-
models_subparsers.add_parser('status', help='Show loaded models and GPU memory status')
3027+
models_status = models_subparsers.add_parser('status', help='Show loaded models and GPU memory status')
3028+
models_status.add_argument('--json', dest='output_json', action='store_true', help='Output as JSON')
29393029

29403030
models_refresh = models_subparsers.add_parser(
29413031
'refresh', help="Refresh the model catalog from each provider's live API")
@@ -4060,7 +4150,7 @@ def _read_done_indices(output_path: Path) -> dict:
40604150

40614151
def _handle_batch_command(args, cli) -> int:
40624152
"""Handle the 'batch' CLI subcommand."""
4063-
from effgen.core.batch import BatchConfig, BatchRunner
4153+
from effgen.core.batch import _QUERY_ALIASES, BatchConfig, BatchRunner
40644154

40654155
input_path = args.input
40664156
output_path = getattr(args, 'output', None)
@@ -4149,14 +4239,19 @@ def _json_error(exc: Exception) -> int:
41494239
# naming the file and line number (not the parser's byte offset);
41504240
# --strict turns the first bad line into a hard failure instead.
41514241
skipped: list[int] = []
4242+
empty_rows: list[tuple[int, list[str]]] = []
41524243

41534244
def _on_skip(lineno: int, msg: str) -> None:
41544245
skipped.append(lineno)
41554246
cli.print(f"Skipping malformed input at {input_path}:{lineno}: {msg}")
41564247

4248+
def _on_empty(lineno: int, keys: list[str]) -> None:
4249+
empty_rows.append((lineno, keys))
4250+
41574251
try:
41584252
queries = runner._read_queries(
4159-
Path(input_path), query_field, strict=strict, on_skip=_on_skip,
4253+
Path(input_path), query_field, strict=strict,
4254+
on_skip=_on_skip, on_empty=_on_empty,
41604255
)
41614256
except Exception as e: # noqa: BLE001 - one clear message, no traceback
41624257
cli.print_error(f"Could not read {input_path}: {e}")
@@ -4167,6 +4262,22 @@ def _on_skip(lineno: int, msg: str) -> None:
41674262
f"{len(queries)} queries loaded."
41684263
)
41694264

4265+
# A row with no recognized query text (neither --query-field nor the
4266+
# aliases query/input/prompt/question/text) can't run. Name the fields it
4267+
# did carry and how to point at the right one, rather than letting each
4268+
# empty row fail with a generic empty-task message.
4269+
if empty_rows:
4270+
lineno, keys = empty_rows[0]
4271+
fields = ", ".join(keys) if keys else "none"
4272+
more = f" (and {len(empty_rows) - 1} more)" if len(empty_rows) > 1 else ""
4273+
msg = (
4274+
f"Row {lineno}{more} has no query text. Fields present: {fields}. "
4275+
f"Set the query column with --query-field NAME, or key rows on one "
4276+
f"of: {', '.join(_QUERY_ALIASES)}."
4277+
)
4278+
cli.print_error(msg)
4279+
return _json_error(ValueError(msg))
4280+
41704281
# --resume: skip input rows already present in the JSONL output.
41714282
done_rows: dict[int, dict] = {}
41724283
if resume:

effgen/core/agent.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -619,7 +619,14 @@ def __init__(self, config: AgentConfig | None = None, session_id: str | None = N
619619
# Mark closed first so this never-returned, partially-built
620620
# agent doesn't tail the error with a GC-without-close warning.
621621
self._closed = True
622-
logger.error(f"Failed to load model '{self.model_name}': {e}")
622+
# A typed load error (e.g. an offline cache miss or a
623+
# require_gpu policy failure) already carries a clear,
624+
# user-facing message and is re-raised below — log it at
625+
# debug so it isn't repeated as an ERROR alongside the raise.
626+
if type(e).__name__ in ("ModelNotCachedError", "GPUPlacementError"):
627+
logger.debug(f"Failed to load model '{self.model_name}': {e}")
628+
else:
629+
logger.error(f"Failed to load model '{self.model_name}': {e}")
623630
raise RuntimeError(
624631
f"Failed to load model '{self.model_name}': {e}"
625632
) from e

effgen/core/agent_generation.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,12 @@ def _accumulate_run_cost(self, result_metadata: dict[str, Any] | None) -> None:
586586
val = result_metadata.get(key)
587587
if isinstance(val, int | float):
588588
accum[key] = accum.get(key, 0) + val
589+
# Where the model ran (local engines report 'cuda'/'cpu'/'mixed'), so a
590+
# headless caller can detect a CPU fallback. Not summed — the last call
591+
# wins, and cloud calls that don't report it leave it untouched.
592+
device = result_metadata.get("device")
593+
if device:
594+
accum["device"] = device
589595
accum["calls"] = accum.get("calls", 0) + 1
590596

591597
def _finalize_cost_metadata(self, response: AgentResponse) -> None:
@@ -612,6 +618,8 @@ def _finalize_cost_metadata(self, response: AgentResponse) -> None:
612618
for key in ("prompt_tokens", "completion_tokens", "total_tokens"):
613619
if key in accum and key not in meta:
614620
meta[key] = int(accum[key])
621+
if accum.get("device") and "device" not in meta:
622+
meta["device"] = accum["device"]
615623
# response.tokens_used is documented as the run's total token count;
616624
# the value set along the generation path is completion-tokens-only
617625
# (each provider adapter's GenerationResult.tokens_used). Correct it

effgen/core/batch.py

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,26 @@
2929

3030
logger = logging.getLogger(__name__)
3131

32+
# Field names accepted for the query text of a JSONL/CSV/JSON row, in priority
33+
# order — mirrors the eval/compare loaders so a batch file keyed on any of these
34+
# works without ``--query-field``.
35+
_QUERY_ALIASES = ("query", "input", "prompt", "question", "text")
36+
37+
38+
def _resolve_row_query(obj: dict[str, Any], query_field: str) -> str:
39+
"""Return a row's query text, trying *query_field* then the common aliases.
40+
41+
The explicitly-requested field wins; when it is absent or empty the aliases
42+
``input``/``prompt``/``question``/``text`` are tried in order. Returns an
43+
empty string when none carry text.
44+
"""
45+
order = [query_field] + [k for k in _QUERY_ALIASES if k != query_field]
46+
for key in order:
47+
val = obj.get(key)
48+
if val not in (None, ""):
49+
return str(val)
50+
return ""
51+
3252

3353
@dataclass
3454
class BatchConfig:
@@ -496,9 +516,15 @@ def _read_queries(
496516
query_field: str,
497517
strict: bool = False,
498518
on_skip: Callable[[int, str], None] | None = None,
519+
on_empty: Callable[[int, list[str]], None] | None = None,
499520
) -> list[str]:
500521
"""Read queries from a JSONL, CSV, JSON, or plain-text file.
501522
523+
A dict row's query text is read from *query_field*, falling back to the
524+
common aliases ``input``/``prompt``/``question``/``text`` when that field
525+
is absent — so a file keyed on any of those works without
526+
``--query-field``.
527+
502528
A malformed JSONL/JSON line does not abort the whole job. By default the
503529
bad line is skipped and reported (through ``on_skip`` if given, else a
504530
logged warning) with the **file path and line number** — not the JSON
@@ -511,6 +537,9 @@ def _read_queries(
511537
strict: Hard-fail on the first malformed line instead of skipping.
512538
on_skip: Called with ``(line_number, message)`` for each skipped
513539
line; when ``None``, a warning is logged instead.
540+
on_empty: Called with ``(line_number, available_keys)`` for each dict
541+
row that carries no recognized query text, so the caller can name
542+
the fields it saw.
514543
"""
515544
suffix = path.suffix.lower()
516545
queries: list[str] = []
@@ -524,6 +553,12 @@ def _report_bad(lineno: int, exc: Exception) -> None:
524553
else:
525554
logger.warning("%s: skipping malformed line: %s", location, exc)
526555

556+
def _resolve(lineno: int, row: dict) -> str:
557+
text = _resolve_row_query(row, query_field)
558+
if not text and on_empty is not None:
559+
on_empty(lineno, sorted(str(k) for k in row.keys()))
560+
return text
561+
527562
if suffix == ".jsonl":
528563
with open(path, encoding="utf-8") as f:
529564
for lineno, line in enumerate(f, start=1):
@@ -538,14 +573,14 @@ def _report_bad(lineno: int, exc: Exception) -> None:
538573
if isinstance(obj, str):
539574
queries.append(obj)
540575
elif isinstance(obj, dict):
541-
queries.append(str(obj.get(query_field, "")))
576+
queries.append(_resolve(lineno, obj))
542577
else:
543578
queries.append(str(obj))
544579
elif suffix == ".csv":
545580
with open(path, encoding="utf-8") as f:
546581
reader = csv.DictReader(f)
547-
for row in reader:
548-
queries.append(row.get(query_field, ""))
582+
for lineno, row in enumerate(reader, start=1):
583+
queries.append(_resolve(lineno, row))
549584
elif suffix == ".json":
550585
with open(path, encoding="utf-8") as f:
551586
try:
@@ -557,7 +592,7 @@ def _report_bad(lineno: int, exc: Exception) -> None:
557592
if isinstance(item, str):
558593
queries.append(item)
559594
elif isinstance(item, dict):
560-
queries.append(str(item.get(query_field, "")))
595+
queries.append(_resolve(pos, item))
561596
else:
562597
_report_bad(pos, TypeError(
563598
f"expected string or object, got {type(item).__name__}"

effgen/models/gguf_engine.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@ def generate(
114114
self.load()
115115
params = self._to_kwargs(config)
116116
params.update(kwargs)
117+
# Clear residual context/KV state from any prior call so each completion
118+
# starts from a clean state. Without this the sampler's RNG continues
119+
# from the previous call and a fixed seed does not reproduce.
120+
self._llm.reset()
117121
out = self._llm(prompt, **params)
118122
choice = out["choices"][0]
119123
text = choice.get("text", "")
@@ -137,6 +141,7 @@ def generate_stream(
137141
params = self._to_kwargs(config)
138142
params.update(kwargs)
139143
params["stream"] = True
144+
self._llm.reset()
140145
for chunk in self._llm(prompt, **params):
141146
yield chunk["choices"][0].get("text", "")
142147

0 commit comments

Comments
 (0)