"""Serve a trained model through bankml — the verified CPU engine.
`push_to_bankml` is the bankml twin of `ollama_push.push_to_ollama`: merge the LoRA (or take an
already-merged directory) → write a Modelfile that bankml can reproduce → `bankml create`, so the
tag answers on `bankml serve --native` (OpenAI `/v1` + Ollama `/api`, a receipt on every answer).
bankml (https://github.com/cryptoAGI/bankml) is reached **only** as a CLI subprocess here and as
HTTP in `operator/backends/bankml.py`; none of its code is vendored (clean-room policy).
What bankml takes, and what this module therefore refuses *before* running anything (it never
drops an instruction silently — a dropped PARAMETER would serve a model that answers differently
from the one described):
- **Taken:** `FROM` (the merged safetensors directory, which `bankml create` converts to GGUF F16
byte-identically to llama.cpp b11192, or a GGUF `bankml convert` already pinned), `SYSTEM`,
`MESSAGE`, `LICENSE`, `REQUIRES`, `PARAMETER` temperature / top_k / top_p / min_p / seed /
num_ctx / num_predict, and `stop`; from bankml 0.3.6 (its O2) also the penalties —
repeat_penalty, repeat_last_n, presence_penalty, frequency_penalty — which it applies token
for token as llama-server b11192 does. A small generation (SmolLM2 / mindx-genN) needs
`repeat_penalty` to answer in words rather than runs of `,`.
- **Refused:** `ADAPTER` (merge first — this module does it for you given `adapter_dir`), a
`TEMPLATE` other than the base's own, the penalties on a bankml older than 0.3.6,
mirostat*, typical_p, resource options (num_gpu, num_thread, num_batch,
num_keep, draft_num_predict), and any architecture other than Llama (SmolLM2 = mindx-genN):
bankml converts Llama safetensors only; Qwen3 it serves only as a pinned Q1_0/Q2_0_g64 GGUF.
The merged directory is converted through a link named after the tag: llama.cpp's converter
names a model after the directory it reads (`merged` → general.name "Merged"), and only the
tag's name gives the GGUF byte-identical to the one mindX pins (gen39 → 6b64c748…).
`bankml create` / `bankml convert` arrive in bankml 0.3.5. An older binary is detected from
`bankml --help` (the verbs it lists) and reported as `status="bankml_too_old"`, never raised.
Every public function returns a result; nothing here raises past `BankmlPushResult`.
"""
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Literal
from mindxtrain.deploy.modelfile import ModelfileSpec, render_modelfile
BANKML_URL = "https://github.com/cryptoAGI/bankml"
# PARAMETERs bankml reproduces token-for-token (plus `stop`, carried on ModelfileSpec.stop).
BANKML_PARAMS: frozenset[str] = frozenset(
{"temperature", "top_k", "top_p", "min_p", "seed", "num_ctx", "num_predict"},
)
_PENALTY = frozenset({"repeat_penalty", "presence_penalty", "frequency_penalty", "repeat_last_n"})
PENALTIES_SINCE = (0, 3, 6) # bankml O2: penalties token-identical to llama-server b11192
_MIROSTAT = frozenset({"mirostat", "mirostat_tau", "mirostat_eta"})
_RESOURCE = frozenset({"num_gpu", "num_thread", "num_batch", "num_keep", "draft_num_predict"})
# Architectures `bankml convert` / `bankml create FROM
` accept (config.json `architectures`).
BANKML_CONVERT_ARCHS: frozenset[str] = frozenset({"LlamaForCausalLM"})
_NAME = re.compile(r"^[a-z0-9_][a-z0-9._-]{0,127}$")
_SHA = r"[0-9a-f]{64}"
PushStatus = Literal[
"created", "refused", "bankml_missing", "bankml_too_old", "merge_failed", "failed", "error",
]
def version_tuple(version: str) -> tuple[int, ...]:
"""'0.3.6' → (0, 3, 6); '' or unparseable → () (treated as older than any release)."""
m = re.match(r"(\d+)\.(\d+)\.(\d+)", version or "")
return tuple(int(g) for g in m.groups()) if m else ()
def penalties_supported(version: str) -> bool:
return version_tuple(version) >= PENALTIES_SINCE
def _param_refusal(name: str) -> str:
if name in _PENALTY:
return (f"PARAMETER {name}: penalties are reproduced from bankml "
f"{'.'.join(map(str, PENALTIES_SINCE))} (O2); this bankml predates it — upgrade, "
"or leave it out")
if name in _MIROSTAT:
return f"PARAMETER {name}: mirostat is not reproduced by bankml; leave it out"
if name == "typical_p":
return "PARAMETER typical_p: locally-typical sampling is not reproduced by bankml"
if name in _RESOURCE:
return (f"PARAMETER {name}: a resource option is not part of a bankml model "
"(bankml's answer does not depend on threads, batch or GPU layers)")
if name == "stop":
return "PARAMETER stop: pass stop sequences as ModelfileSpec.stop, not in parameters"
return f"PARAMETER {name}: not a parameter bankml reproduces"
# ---- capability probe -------------------------------------------------------
@dataclass(frozen=True)
class BankmlCapabilities:
"""What the local `bankml` binary can do. `reason` is set when it cannot do what was asked."""
binary: str | None
version: str = ""
has_create: bool = False
has_convert: bool = False
reason: str = ""
@property
def found(self) -> bool:
return self.binary is not None
def bankml_capabilities(bankml_bin: str | None = None, *, timeout_s: float = 30.0) -> BankmlCapabilities:
"""Locate `bankml` and read its version and verbs (`bankml version`, `bankml --help`).
The verbs listed in the usage text are the truth — an unreleased build may still say 0.3.4
and already carry `create`. Never raises.
"""
binary = bankml_bin or shutil.which("bankml")
if not binary:
return BankmlCapabilities(
binary=None,
reason=(f"`bankml` not found on PATH; install it from {BANKML_URL} "
"(or pass --bankml-bin)"),
)
try:
ver = subprocess.run([binary, "version"], capture_output=True, text=True,
timeout=timeout_s, check=False)
usage = subprocess.run([binary, "--help"], capture_output=True, text=True,
timeout=timeout_s, check=False)
except (OSError, subprocess.SubprocessError) as exc:
return BankmlCapabilities(binary=binary, reason=f"cannot run {binary}: {exc}")
m = re.search(r"bankml\s+(\d+\.\d+\.\d+\S*)", ver.stdout + ver.stderr)
text = usage.stdout + usage.stderr
return BankmlCapabilities(
binary=binary,
version=m.group(1) if m else "",
has_create="bankml create" in text,
has_convert="bankml convert" in text,
)
# ---- the Modelfile subset ----------------------------------------------------
@dataclass(frozen=True)
class BankmlSanitizeResult:
"""`ok` → `spec` is exactly what bankml will reproduce; else `refusals` say why, one per line."""
spec: ModelfileSpec
refusals: tuple[str, ...] = ()
@property
def ok(self) -> bool:
return not self.refusals
def bankml_sanitize(
spec: ModelfileSpec, *, base_template: str | None = None, penalties: bool = False,
) -> BankmlSanitizeResult:
"""Check a ModelfileSpec against the subset `bankml create` reproduces.
Refuses (and names) every instruction bankml would not honour; it never drops one, because a
silently dropped PARAMETER serves a model that answers differently from the spec. A
`TEMPLATE` passes only when it equals `base_template` (the base GGUF's own chat template).
`penalties=True` admits the penalties (bankml 0.3.6+; see `penalties_supported`).
"""
refusals: list[str] = []
if spec.adapter:
refusals.append("ADAPTER: bankml does not merge a LoRA at create time; merge it first "
"(push_to_bankml does, given adapter_dir) and FROM the merged directory")
if spec.template and spec.template != base_template:
refusals.append("TEMPLATE: bankml renders the base model's own chat template "
"(byte-identical to llama.cpp); a different template is not reproduced — "
"leave TEMPLATE out")
allowed = BANKML_PARAMS | _PENALTY if penalties else BANKML_PARAMS
for name in sorted(spec.parameters):
if name not in allowed:
refusals.append(_param_refusal(name))
for label, value in (("SYSTEM", spec.system), ("TEMPLATE", spec.template),
("LICENSE", spec.license)):
if '"""' in value:
refusals.append(f'{label}: contains """, which a Modelfile cannot quote')
for stop in spec.stop:
if '"' in stop or "\n" in stop:
refusals.append(f"stop {stop!r}: a quote or newline cannot be written as PARAMETER stop")
return BankmlSanitizeResult(spec=spec, refusals=tuple(refusals))
def check_merged_arch(merged_dir: Path) -> str | None:
"""None when `merged_dir/config.json` names an architecture bankml converts (or has none to
read — then bankml decides); else the refusal reason."""
try:
cfg = json.loads((merged_dir / "config.json").read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
archs = cfg.get("architectures") or []
if not archs or any(a in BANKML_CONVERT_ARCHS for a in archs):
return None
return (f"architecture {', '.join(map(str, archs))}: bankml converts Llama-architecture "
"safetensors only (SmolLM2 / mindx-genN); Qwen3 it serves only as a pinned "
"Q1_0/Q2_0_g64 GGUF — use `serve --to ollama` or `--to vllm` for this model")
# Families mindXtrain trains that bankml cannot convert (checked by name before a merge is spent).
_UNCONVERTIBLE_FAMILIES = ("qwen", "mistral", "phi", "gemma", "glm", "deepseek", "instella")
def base_family_refusal(base_model: str) -> str | None:
"""A cheap, name-based pre-check (before merging): None unless `base_model` names a family
bankml cannot convert. The merged `config.json` is still checked afterwards."""
low = base_model.lower()
if "smollm" in low or "llama" in low:
return None
hit = next((f for f in _UNCONVERTIBLE_FAMILIES if f in low), None)
if hit is None:
return None
return (f"base {base_model}: the {hit} family is not a Llama-architecture model bankml can "
"convert (bankml converts SmolLM2 / mindx-genN; Qwen3 only as a pinned "
"Q1_0/Q2_0_g64 GGUF) — use `serve --to ollama` or `--to vllm`")
def bankml_registry_dir(registry_dir: Path | None = None) -> Path:
"""bankml's registry (pins + derived models): `--registry`, else $BANKML_FORKS, else
~/.local/share/bankml/forks — the same resolution `bankml create` uses."""
if registry_dir is not None:
return Path(registry_dir).expanduser()
env = os.environ.get("BANKML_FORKS")
if env:
return Path(env).expanduser()
return Path.home() / ".local" / "share" / "bankml" / "forks"
# ---- push --------------------------------------------------------------------
@dataclass(frozen=True)
class BankmlPushResult:
"""Outcome of `push_to_bankml`. `status == "created"` is the only success."""
status: PushStatus
tag: str
reason: str = ""
refusals: tuple[str, ...] = ()
bankml_version: str = ""
merged_dir: Path | None = None
gguf: Path | None = None
modelfile: Path | None = None
model_sha256: str = ""
digest: str = ""
output: str = ""
mindx_fallback_swap: dict[str, str] | None = None
log: tuple[str, ...] = field(default=())
@property
def ok(self) -> bool:
return self.status == "created"
def _run(cmd: list[str], timeout_s: float) -> tuple[int, str, str]:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_s, check=False)
return proc.returncode, proc.stdout or "", proc.stderr or ""
def push_to_bankml(
base_model: str,
tag: str,
*,
adapter_dir: Path | None = None,
merged_dir: Path | None = None,
system: str | None = None,
params: dict[str, float | int | str] | None = None,
stop: list[str] | None = None,
bankml_bin: str | None = None,
convert: bool = False,
work_dir: Path | None = None,
registry_dir: Path | None = None,
register_with_mindx: bool = False,
mindx_base_url: str | None = None,
sink: Callable[[str], None] | None = None,
timeout_s: float = 1800.0,
) -> BankmlPushResult:
"""Merge (if given an adapter) → Modelfile (bankml subset) → `bankml create `.
Give exactly one of `adapter_dir` (a PEFT LoRA over `base_model`; merging needs
`uv sync --extra ml`) or `merged_dir` (an already-merged HF safetensors directory).
`convert=False` (default) writes `FROM ` and lets `bankml create` convert and pin
it. `convert=True` runs `bankml convert` first (GGUF + FORK.json into the registry) and
writes `FROM ` — the explicit, inspectable two-step.
The model sha256 recorded is the one bankml prints for the base it pinned and verified.
With `register_with_mindx`, mindX's fallback model is swapped to `{provider: "bankml",
model: tag}` (best-effort; a failure is logged, the push still stands).
"""
lines: list[str] = []
def emit(line: str) -> None:
lines.append(line)
if sink:
sink(line)
def done(status: PushStatus, **kw: object) -> BankmlPushResult:
return BankmlPushResult(status=status, tag=tag, log=tuple(lines), **kw) # type: ignore[arg-type]
name = tag.strip().lower().removesuffix(":latest")
if not _NAME.match(name) or name.endswith(".gguf"):
return done("refused", reason=(f"tag {tag!r}: bankml names use letters, digits, '.', '_' "
"and '-' (at most 128, no ':tag', not ending in .gguf)"))
if (adapter_dir is None) == (merged_dir is None):
return done("error", reason="give exactly one of adapter_dir or merged_dir")
# 1. the subset — checked before anything expensive runs
spec = ModelfileSpec(from_model="", system=system or "", parameters=dict(params or {}),
stop=list(stop or []))
checked = bankml_sanitize(spec, penalties=True) # penalties are gated on the version below
if not checked.ok:
for r in checked.refusals:
emit(f"[push-bankml] refuse: {r}")
return done("refused", reason="the Modelfile asks for what bankml does not reproduce",
refusals=checked.refusals)
# 2. the binary, and whether it has the verbs
caps = bankml_capabilities(bankml_bin)
if not caps.found:
return done("bankml_missing", reason=caps.reason)
if caps.reason:
return done("error", reason=caps.reason, bankml_version=caps.version)
missing = [v for v, have in (("create", caps.has_create), ("convert", caps.has_convert))
if (v == "create" or convert) and not have]
if missing:
return done("bankml_too_old", bankml_version=caps.version, reason=(
f"bankml {caps.version or '(unknown version)'} has no `{'`/`'.join(missing)}` verb "
f"(they arrive in bankml 0.3.5); upgrade from {BANKML_URL}"))
asked = sorted(_PENALTY & set(spec.parameters))
if asked and not penalties_supported(caps.version):
refusals = tuple(_param_refusal(n) for n in asked)
for r in refusals:
emit(f"[push-bankml] refuse: {r}")
return done("refused", bankml_version=caps.version, refusals=refusals,
reason=f"bankml {caps.version or '(unknown version)'} does not reproduce penalties")
emit(f"[push-bankml] bankml {caps.version} at {caps.binary}")
work = Path(work_dir) if work_dir else (
(adapter_dir or merged_dir).parent / "bankml_push") # type: ignore[union-attr]
# 3. merge, when given an adapter
if adapter_dir is not None:
try:
from mindxtrain.deploy.ollama_push import merge_lora_adapter
merged = merge_lora_adapter(base_model, Path(adapter_dir), work / "merged", sink=emit)
except ImportError as exc:
return done("merge_failed", bankml_version=caps.version, reason=(
f"{exc} — merging a LoRA needs `uv sync --extra ml`"))
except Exception as exc: # a merge failure is a result, not a crash
return done("merge_failed", bankml_version=caps.version,
reason=f"{type(exc).__name__}: {exc}")
else:
merged = Path(merged_dir) # type: ignore[arg-type]
if not merged.is_dir():
return done("error", bankml_version=caps.version,
reason=f"merged_dir {merged} is not a directory")
arch_refusal = check_merged_arch(merged)
if arch_refusal:
emit(f"[push-bankml] refuse: {arch_refusal}")
return done("refused", bankml_version=caps.version, merged_dir=merged,
reason=arch_refusal, refusals=(arch_refusal,))
registry = bankml_registry_dir(registry_dir)
reg_args = ["--registry", str(registry)] if registry_dir is not None else []
gguf: Path | None = None
model_sha = ""
try:
# 4. optional explicit conversion — through a link named after the tag, so the GGUF's
# general.name (and so its bytes and sha256) is the one mindX pins
source = str(_named_source(merged, work / name, name))
if convert:
registry.mkdir(parents=True, exist_ok=True)
gguf = registry / f"{name}-base-F16.gguf"
cmd = [str(caps.binary), "convert", source, "-o", str(gguf),
"--fork", str(registry / f"{gguf.name}.FORK.json"), "--source", source]
emit(f"[push-bankml] $ {' '.join(cmd)}")
code, out, err = _run(cmd, timeout_s)
if err.strip():
emit(err.rstrip())
if code != 0:
status: PushStatus = "refused" if code == 2 else "failed"
return done(status, bankml_version=caps.version, merged_dir=merged,
reason=_last_line(err) or f"bankml convert exited {code}",
output=(out + err)[-2000:])
m = re.search(rf"^({_SHA})\s", out, re.MULTILINE)
model_sha = m.group(1) if m else ""
# 5. the Modelfile and `bankml create`
spec = spec.model_copy(update={"from_model": str(gguf) if gguf else source})
modelfile = work / name / "Modelfile"
modelfile.parent.mkdir(parents=True, exist_ok=True)
modelfile.write_text(render_modelfile(spec), encoding="utf-8")
cmd = [str(caps.binary), "create", name, "-f", str(modelfile), *reg_args]
emit(f"[push-bankml] $ {' '.join(cmd)}")
code, out, err = _run(cmd, timeout_s)
except (OSError, subprocess.SubprocessError) as exc:
return done("error", bankml_version=caps.version, merged_dir=merged, gguf=gguf,
reason=f"{type(exc).__name__}: {exc}")
for chunk in (out, err):
if chunk.strip():
emit(chunk.rstrip())
if code != 0:
return done("refused" if code == 2 else "failed", bankml_version=caps.version,
merged_dir=merged, gguf=gguf, modelfile=modelfile,
reason=_last_line(err) or f"bankml create exited {code}",
output=(out + err)[-2000:])
both = out + "\n" + err
over = re.search(rf"over (\S+) \(sha256 ({_SHA})\)", both)
digest = re.search(rf"digest sha256:({_SHA})", both)
if over:
model_sha = over.group(2)
if gguf is None:
gguf = registry / over.group(1)
elif not model_sha and gguf is not None:
code_s, out_s, _ = _run([str(caps.binary), "sha256", str(gguf)], 600.0)
m = re.match(rf"({_SHA})\s", out_s)
model_sha = m.group(1) if (code_s == 0 and m) else ""
swap: dict[str, str] | None = None
if register_with_mindx:
try:
from mindxtrain.deploy.api_client import swap_mindx_fallback_model
swap = swap_mindx_fallback_model(provider="bankml", model=name, api_url=mindx_base_url)
emit(f"[push-bankml] mindX swap: {swap.get('previous', '?')} -> "
f"{swap.get('current', '?')}")
except Exception as exc: # best-effort, as push_to_ollama
emit(f"[push-bankml] mindX registration failed (push still ok): {exc}")
swap = None
emit(f"[push-bankml] created {name} over sha256 {model_sha or '(not reported)'}")
return done("created", bankml_version=caps.version, merged_dir=merged, gguf=gguf,
modelfile=modelfile, model_sha256=model_sha,
digest=digest.group(1) if digest else "", output=both.strip()[-2000:],
mindx_fallback_swap=swap)
def _named_source(merged: Path, at: Path, name: str) -> Path:
"""A link `at/` → `merged`, unless `merged` is already called `name`."""
target = merged.resolve()
if target.name == name:
return target
at.mkdir(parents=True, exist_ok=True)
link = at / name
if link.is_symlink():
link.unlink()
elif link.exists():
return link # a real directory by that name: the caller put it there
link.symlink_to(target, target_is_directory=True)
return link
def _last_line(text: str) -> str:
rows = [r.strip() for r in text.strip().splitlines() if r.strip()]
return rows[-1] if rows else ""
__all__ = [
"BANKML_CONVERT_ARCHS",
"BANKML_PARAMS",
"PENALTIES_SINCE",
"BankmlCapabilities",
"BankmlPushResult",
"BankmlSanitizeResult",
"bankml_capabilities",
"bankml_registry_dir",
"bankml_sanitize",
"base_family_refusal",
"check_merged_arch",
"penalties_supported",
"push_to_bankml",
"version_tuple",
]