bankml: the verified CPU engine as backend, serve target and imprint probe

#1
by Gregory-L - opened
CLAUDE.md CHANGED
@@ -98,7 +98,7 @@ mindX artifacts (dream corpus, persona) through boundaries, it does not vendor m
98
 
99
  - **New recipe** → drop YAML at `mindxtrain/train/recipes/<name>.yaml`; `test_all_recipes_validate` picks it up.
100
  - **New training backend** → add `mindxtrain/train/backend_<name>.py` exposing `run_<name>(cfg, plan, out_dir) -> Path`; wire into `train/dispatch.py`; add to `TrainingBackend` literal in `config/schema.py`.
101
- - **New operator backend** → subclass `Backend` in `mindxtrain/operator/backends/<name>.py` decorated `@register_backend("<name>")`; side-effect import from `models/registry.py`; add runtime branch in `operator/app.py::chat_completions`.
102
  - **New training method** → add `_MethodBase` subclass in `config/schema.py` with `kind: Literal["<name>"]`; extend `TrainMethod` discriminated union; add `train/<name>.py` runner; update dispatch; add a recipe.
103
 
104
  ## Documentation hub
@@ -115,4 +115,5 @@ mindX artifacts (dream corpus, persona) through boundaries, it does not vendor m
115
  | `docs/yaml_schema.md` | Every field of the 10-section `XTrainConfig`. |
116
  | `docs/coach.md` | Interactive `/coach/` web UI bundled in the operator. |
117
  | `docs/governance.md` | classroom / boardroom (any-N consensus) / dojo (prime-N dispute settlement). |
 
118
  | `docs/blueprints/` | Frozen source design briefs (the spec the project was built against). |
 
98
 
99
  - **New recipe** → drop YAML at `mindxtrain/train/recipes/<name>.yaml`; `test_all_recipes_validate` picks it up.
100
  - **New training backend** → add `mindxtrain/train/backend_<name>.py` exposing `run_<name>(cfg, plan, out_dir) -> Path`; wire into `train/dispatch.py`; add to `TrainingBackend` literal in `config/schema.py`.
101
+ - **New operator backend** → subclass `Backend` in `mindxtrain/operator/backends/<name>.py` decorated `@register_backend("<name>")`; side-effect import from `models/registry.py`; add its base-URL branch to `operator/app.py::backend_kwargs` (shared by the operator route and the Coach chat stream) and, if it can be probed, to `backend_reachable` / `backend_first_model`. Registered backends: `openai_compat`, `ollama`, `vllm`, `bankml` ([docs/bankml.md](docs/bankml.md) — the verified CPU engine, reached over HTTP/CLI only; receipts on `ChatResponse.receipt`, HTTP 400 → `BankmlRefusal`, never retried).
102
  - **New training method** → add `_MethodBase` subclass in `config/schema.py` with `kind: Literal["<name>"]`; extend `TrainMethod` discriminated union; add `train/<name>.py` runner; update dispatch; add a recipe.
103
 
104
  ## Documentation hub
 
115
  | `docs/yaml_schema.md` | Every field of the 10-section `XTrainConfig`. |
116
  | `docs/coach.md` | Interactive `/coach/` web UI bundled in the operator. |
117
  | `docs/governance.md` | classroom / boardroom (any-N consensus) / dojo (prime-N dispute settlement). |
118
+ | `docs/bankml.md` | bankml ([github.com/cryptoAGI/bankml](https://github.com/cryptoAGI/bankml)): `serve --to bankml`, the `bankml` backend, `imprint-bankml` (not comparable with the canonical gate). |
119
  | `docs/blueprints/` | Frozen source design briefs (the spec the project was built against). |
README.md CHANGED
@@ -93,6 +93,35 @@ GPU steps (`bench` without `--dry-run`, `train`, `quantize`, `serve`) require
93
  an AMD MI300X with ROCm 7.2.1; run inside `rocm/primus:v26.2`. The full
94
  operator checklist lives in [`HANDOFF.md`](docs/HANDOFF.md).
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  ## Layout
97
 
98
  ```
@@ -118,6 +147,8 @@ scripts/ dev helpers
118
  | [`docs/coach.md`](docs/coach.md) | Interactive `/coach/` web UI bundled in the operator. |
119
  | [`docs/dcoach.md`](docs/dcoach.md) | The dcoach proof loop — prove a CPU model recalls its training; decentralized-training fit. |
120
  | [`docs/cli.md`](docs/cli.md) | Every `mindxtrain` verb with synopsis, options, exit codes. |
 
 
121
  | [`docs/yaml_schema.md`](docs/yaml_schema.md) | Every field of the 10-section `XTrainConfig`. |
122
  | [`docs/benchmarks.md`](docs/benchmarks.md) | Target metrics + the 7-cell framework comparison. |
123
  | [`docs/development.md`](docs/development.md) | Toolchain, optional-deps, lazy-import pattern, invariants. |
 
93
  an AMD MI300X with ROCm 7.2.1; run inside `rocm/primus:v26.2`. The full
94
  operator checklist lives in [`HANDOFF.md`](docs/HANDOFF.md).
95
 
96
+ ## Serving on vLLM / SGLang
97
+
98
+ `mindxtrain serve run.yaml --to vllm` (or `--to sglang`) launches the OpenAI-compatible server on
99
+ the trained run — the LoRA served natively over the base, or merged with `--merge` — detached, with
100
+ its log and pid under `out/runs/<run>/serve/<to>/`, and returns once `/v1/models` lists the tag.
101
+ `--dry-run` prints the exact argv; `--stop` ends it; `--register-as-fallback` hands it to mindX.
102
+ On a host without a GPU it uses the CPU backends and refuses configs that need a GPU, with the
103
+ reason. vLLM: `uv sync --extra serve`; SGLang: install it yourself. Full page: [`docs/serve.md`](docs/serve.md).
104
+
105
+ ## bankml (verified CPU engine)
106
+
107
+ [bankml](https://github.com/cryptoAGI/bankml) is a zero-dependency Rust runtime, token-identical to
108
+ llama.cpp b11192, that serves OpenAI `/v1` and Ollama `/api` from its own forward pass and puts a
109
+ **receipt** (model / request / response sha256) on every answer. mindXtrain reaches it over HTTP and
110
+ its CLI only — nothing is vendored. Full page: [`docs/bankml.md`](docs/bankml.md).
111
+
112
+ - `mindxtrain serve run.yaml --to bankml` merges the LoRA and runs `bankml create` (bankml 0.3.5+),
113
+ which converts the merged SmolLM2 / mindx-genN weights to GGUF F16 byte-identically to llama.cpp
114
+ and pins them by sha256. It **refuses**, with the reason: quantized configs (FP8, MXFP4, GPTQ,
115
+ Q8_0, Q4_K), non-Llama architectures, and Modelfile instructions bankml does not reproduce —
116
+ `ADAPTER`, a foreign `TEMPLATE`, penalties, mirostat, `typical_p`, resource options. An older
117
+ bankml is reported as too old, not crashed on.
118
+ - `MINDXTRAIN_BACKEND=bankml` routes the operator and Coach chat to `bankml serve`
119
+ (`MINDXTRAIN_BANKML_BASE_URL`, default `http://127.0.0.1:18093/v1`); answers carry the receipt,
120
+ and a bankml 400 comes back as a typed `BankmlRefusal`, never retried with altered parameters.
121
+ - `mindxtrain imprint-bankml` probes before/after tags greedily, seeded and unpenalised, with a receipt
122
+ per utterance — reproducible and auditable, and explicitly **not comparable** with the canonical
123
+ `mindxtrain imprint` gate (repetition penalty 1.3).
124
+
125
  ## Layout
126
 
127
  ```
 
147
  | [`docs/coach.md`](docs/coach.md) | Interactive `/coach/` web UI bundled in the operator. |
148
  | [`docs/dcoach.md`](docs/dcoach.md) | The dcoach proof loop — prove a CPU model recalls its training; decentralized-training fit. |
149
  | [`docs/cli.md`](docs/cli.md) | Every `mindxtrain` verb with synopsis, options, exit codes. |
150
+ | [`docs/serve.md`](docs/serve.md) | `serve --to vllm\|sglang`: native-LoRA or merged launch, readiness, stop, CPU vs GPU, and where every flag was verified. |
151
+ | [`docs/bankml.md`](docs/bankml.md) | bankml as serve target, operator backend and receipt-auditable imprint probe — what it takes and what it refuses. [bankml on GitHub](https://github.com/cryptoAGI/bankml). |
152
  | [`docs/yaml_schema.md`](docs/yaml_schema.md) | Every field of the 10-section `XTrainConfig`. |
153
  | [`docs/benchmarks.md`](docs/benchmarks.md) | Target metrics + the 7-cell framework comparison. |
154
  | [`docs/development.md`](docs/development.md) | Toolchain, optional-deps, lazy-import pattern, invariants. |
docs/CHANGELOG.md CHANGED
@@ -6,6 +6,51 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
 
7
  ## [Unreleased]
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  ## [1.0.4] — 2026-09-16
10
 
11
  ### Added
 
6
 
7
  ## [Unreleased]
8
 
9
+ ### Added
10
+
11
+ - **bankml, the verified CPU engine** ([github.com/cryptoAGI/bankml](https://github.com/cryptoAGI/bankml),
12
+ [`docs/bankml.md`](bankml.md)), reached over HTTP and its CLI only — nothing vendored.
13
+ - `operator/backends/bankml.py`: `@register_backend("bankml")`, `MINDXTRAIN_BANKML_BASE_URL`
14
+ (default `http://127.0.0.1:18093/v1`). Keeps bankml's receipt (`ChatResponse.receipt`, new
15
+ optional field; `last_receipt` for streams, parsed from the final SSE receipt event). HTTP 400
16
+ → `BankmlRefusal(reason)`, never retried with altered parameters.
17
+ - Operator wiring: `backend_kwargs(name)` replaces the duplicated per-backend branches in
18
+ `operator/app.py` and `coach/api.py`; health probes `GET /bankml` and `/v1/models`;
19
+ auto-detect tries bankml after ollama and vllm, so existing hosts resolve as before; the
20
+ governance panel's env chain gains `MINDXTRAIN_BANKML_BASE_URL` last, only when set.
21
+ - `deploy/bankml_push.py` and `mindxtrain serve --to bankml [--bankml-bin] [--bankml-convert]`:
22
+ merge → Modelfile through `bankml_sanitize` (refuses `ADAPTER`, a foreign `TEMPLATE`,
23
+ penalties, mirostat, `typical_p`, resource options, rather than dropping them) →
24
+ `bankml create`; records the model sha256; refuses quantized configs and non-Llama
25
+ architectures; detects a bankml without `create`/`convert` (pre-0.3.5) as `bankml_too_old`.
26
+ - `eval/imprint_bankml.py` and `mindxtrain imprint-bankml`: seeded, unpenalised greedy probes with
27
+ a receipt per utterance, scored by `score_imprint`, tagged `<scorer>/bankml-greedy` and marked
28
+ **not comparable** with the canonical 1.3-penalty gate.
29
+ - MEI `InferenceEngineIdent.name` accepts `"bankml"`; the Gradio Serve room offers `bankml`.
30
+
31
+ - **`serve --to vllm|sglang` are real deploy targets** ([`docs/serve.md`](serve.md)), no longer
32
+ print-only. `deploy/openai_server_push.py`: `launch_openai_server` serves a LoRA natively over
33
+ the base (vLLM `--enable-lora --lora-modules`, SGLang `--enable-lora --lora-paths`) or merged
34
+ (`--merge`, `merge_lora_adapter`), launches detached (own session; `server.log`, `server.pid`,
35
+ `launch.json` under `out/runs/<run>/serve/<to>/`), waits for `/v1/models` (+ vLLM `/health`),
36
+ and optionally swaps mindX's fallback (`provider="vllm"|"sglang"`); `stop_openai_server` SIGTERMs
37
+ the process group and verifies it is gone. CPU hosts get bfloat16, `VLLM_CPU_KVCACHE_SPACE`,
38
+ SGLang `--device cpu`; quantized checkpoints and `tensor_parallel > 1` are refused without a
39
+ GPU; a missing server is reported with its install hint. Every outcome is a
40
+ `ServerLaunchResult` / `ServerStopResult`. New CLI options: `--dry-run`, `--stop`, `--merge`,
41
+ `--host`, `--port`, `--dtype`, `--server-bin`, `--server-arg`, `--ready-timeout`,
42
+ `--cpu-kvcache-gib`. The Gradio Serve room offers `sglang`. vLLM / SGLang are reached as a
43
+ subprocess and over HTTP only.
44
+
45
+ ### Changed
46
+
47
+ - `ui/console.py` omits penalty / mirostat options left at the engine's own default, so bankml
48
+ accepts default console requests. For Ollama a Modelfile's `repeat_penalty` now applies where
49
+ the console used to override it with 1.1.
50
+ - `hf.extension.publish_generation(repeat_penalty=…)`: `None` publishes a Modelfile without the
51
+ penalty line (default unchanged, 1.3); the Modelfile text is `published_modelfile()`.
52
+ - `cli imprint`: the script-probe extraction is `_script_probes`, shared with `imprint-bankml`.
53
+
54
  ## [1.0.4] — 2026-09-16
55
 
56
  ### Added
docs/NAV.md CHANGED
@@ -123,6 +123,16 @@ The interactive `/coach/` operator UI: create-script, live-training diagnostics,
123
  - [How mindXtrain fits decentralized training](dcoach.md#how-mindxtrain-fits-decentralized-training)
124
  - [Why this matters](dcoach.md#why-this-matters)
125
 
 
 
 
 
 
 
 
 
 
 
126
  ### [Governance](governance.md)
127
  classroom (graduation) / boardroom (any-N consensus) / dojo (prime-N dispute settlement), model-backed deliberation.
128
  - [The model](governance.md#the-model) · [Flow](governance.md#flow) · [Why prime](governance.md#why-prime)
 
123
  - [How mindXtrain fits decentralized training](dcoach.md#how-mindxtrain-fits-decentralized-training)
124
  - [Why this matters](dcoach.md#why-this-matters)
125
 
126
+ ### [Serving on vLLM and SGLang](serve.md)
127
+
128
+ - [What runs](serve.md#what-runs) · [CPU and GPU](serve.md#cpu-and-gpu) · [Exit codes](serve.md#exit-codes) · [Where each flag comes from](serve.md#where-each-flag-comes-from)
129
+
130
+ ### [bankml — the verified CPU engine](bankml.md)
131
+ [bankml](https://github.com/cryptoAGI/bankml) as serve target, operator backend and receipt-auditable imprint probe.
132
+ - [What bankml runs, and what it refuses](bankml.md#what-bankml-runs-and-what-it-refuses)
133
+ - [Operator backend](bankml.md#operator-backend--mindxtrain_backendbankml) · [`serve --to bankml`](bankml.md#serving-a-trained-run--mindxtrain-serve---to-bankml) · [The Modelfile subset](bankml.md#the-modelfile-subset-bankml_sanitize)
134
+ - [`imprint-bankml` (not comparable with the canonical gate)](bankml.md#a-second-imprint-instrument--mindxtrain-imprint-bankml)
135
+
136
  ### [Governance](governance.md)
137
  classroom (graduation) / boardroom (any-N consensus) / dojo (prime-N dispute settlement), model-backed deliberation.
138
  - [The model](governance.md#the-model) · [Flow](governance.md#flow) · [Why prime](governance.md#why-prime)
docs/bankml.md ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # bankml — the verified CPU engine
2
+
3
+ [bankml](https://github.com/cryptoAGI/bankml) is a zero-dependency Rust runtime for 1-bit (`Q1_0`),
4
+ ternary (`Q2_0_g64`) and F16 GGUF models, **token-identical to llama.cpp b11192** on every oracle in
5
+ its release gate. `bankml serve --native` answers OpenAI `/v1/chat/completions` and Ollama `/api/*`
6
+ from its own forward pass on `127.0.0.1:18093`, and puts a **receipt** on every answer. mindXtrain
7
+ uses it as a serve target, an operator backend and a second imprint instrument.
8
+
9
+ mindXtrain reaches bankml **only over HTTP and as a CLI subprocess**. No bankml code is vendored
10
+ (the clean-room policy in [`CLAUDE.md`](../CLAUDE.md)). bankml's own docs:
11
+ [README](https://github.com/cryptoAGI/bankml#readme) ·
12
+ [usage](https://github.com/cryptoAGI/bankml/blob/main/docs/usage.md) ·
13
+ [bankML as mindX's Ollama](https://github.com/cryptoAGI/bankml/blob/main/docs/OLLAMA.md).
14
+
15
+ ## What bankml runs, and what it refuses
16
+
17
+ | | bankml |
18
+ |---|---|
19
+ | **Architectures** | Qwen3 (`Q1_0`, `Q2_0_g64`: the Bonsai family) and Llama in F16 (SmolLM2-135M, mindX's `mindx-genN`) |
20
+ | **Sampling it reproduces** | `temperature`, `top_k`, `top_p`, `min_p`, `seed`, JSON mode; `num_ctx`, `num_predict`, `stop` |
21
+ | **Refused with HTTP 400 and a reason** | repeat / presence / frequency penalties (non-neutral), `mirostat`, `typical_p`, `tools`, images, a replacement `template`, unknown architectures, `Q8_0` / `Q4_K` / `BF16` |
22
+ | **Receipt** (`bankml_receipt`) | `bankml` version, `engine`, `model_sha256`, `guard`, `prompt_tokens`, `completion_tokens`, `ttft_ms`, `wall_ms`, `response_sha256`, `request_sha256`, `signed: false` |
23
+
24
+ A refusal is the product, not a defect: bankml answers only what its verified forward pass does.
25
+ mindXtrain mirrors that — it **never retries a refused request with altered parameters**, and never
26
+ drops a Modelfile instruction to make bankml accept it.
27
+
28
+ ## Operator backend — `MINDXTRAIN_BACKEND=bankml`
29
+
30
+ `mindxtrain/operator/backends/bankml.py` registers `bankml` (a subclass of `openai_compat`).
31
+
32
+ ```bash
33
+ bankml serve MODEL.gguf --fork MODEL.gguf.FORK.json --native --registry # 127.0.0.1:18093
34
+ MINDXTRAIN_BACKEND=bankml uv run uvicorn mindxtrain.operator.app:app --port 8080
35
+ ```
36
+
37
+ - `MINDXTRAIN_BANKML_BASE_URL` (default `http://127.0.0.1:18093/v1`).
38
+ - `POST /v1/chat/completions` returns `ChatResponse.receipt`; the backend also keeps `last_receipt`.
39
+ Streamed answers parse bankml's final `data: {"bankml_receipt": …}` event.
40
+ - HTTP 400 → `BankmlRefusal(reason)` → the operator answers 400 with bankml's reason. Other non-2xx
41
+ and a mid-stream `{"error": …}` → `BankmlError`.
42
+ - Health: `/health`, `/readyz` and `/coach/api/health` probe `GET /bankml` (the identity endpoint) and
43
+ list the model from `/v1/models`.
44
+ - **Auto-detect** (no `MINDXTRAIN_BACKEND`): ollama → vllm → bankml → vllm. bankml is chosen only
45
+ when ollama is down, `GET /bankml` answers and vLLM does not, so a host that resolved to ollama or
46
+ vllm before keeps doing so.
47
+ - Governance panels (`resolve_chat_base_url`): `MINDXTRAIN_BACKEND=bankml` uses bankml's URL; else
48
+ `MINDXTRAIN_BANKML_BASE_URL` is appended *last* to the OPENAI → VLLM → OLLAMA chain, and only
49
+ when it is set.
50
+
51
+ ## Serving a trained run — `mindxtrain serve --to bankml`
52
+
53
+ ```bash
54
+ uv run mindxtrain serve run.yaml --to bankml [--tag mindx-gen80] [--checkpoint DIR] \
55
+ [--bankml-bin PATH] [--bankml-convert] [--register-as-fallback]
56
+ ```
57
+
58
+ 1. **Refuses up front** (exit 2): `quantize.enabled` with a scheme other than `none` (bankml serves
59
+ the merged weights as GGUF F16; it does not reproduce FP8, MXFP4, GPTQ, Q8_0 or Q4_K), and base
60
+ families bankml cannot convert (Qwen, Mistral, Phi, Gemma, GLM, DeepSeek, Instella).
61
+ 2. Checks the binary: `bankml version` and the verbs in `bankml --help`. `bankml create` and
62
+ `bankml convert` arrive in **bankml 0.3.5**; an older binary is reported as
63
+ `bankml_too_old` (exit 2), never as a crash. The verbs are the truth — an unreleased build may
64
+ still say 0.3.4 and already carry them.
65
+ 3. Merges the LoRA (`merge_lora_adapter`, needs `uv sync --extra ml`), or takes the checkpoint as an
66
+ already-merged directory when it holds `config.json` and no `adapter_config.json`.
67
+ 4. Re-checks the merged `config.json`: only `LlamaForCausalLM` converts.
68
+ 5. Writes a Modelfile through `bankml_sanitize` and runs `bankml create <tag> -f Modelfile`, which
69
+ converts the merged safetensors to GGUF F16 byte-identically to llama.cpp b11192 and pins it.
70
+ `--bankml-convert` runs `bankml convert` (GGUF + `FORK.json`) first and writes `FROM <gguf>`.
71
+ 6. Records the model sha256 bankml prints for the base it verified, and the derived model's digest.
72
+ 7. `--register-as-fallback` PATCHes mindX's fallback model to `{provider: "bankml", model: <tag>}`
73
+ (best-effort, as for ollama).
74
+
75
+ ### The Modelfile subset (`bankml_sanitize`)
76
+
77
+ | instruction | bankml |
78
+ |---|---|
79
+ | `FROM` merged dir / pinned GGUF / registry name | taken |
80
+ | `SYSTEM`, `MESSAGE`, `LICENSE`, `REQUIRES` | taken (recorded) |
81
+ | `PARAMETER` temperature, top_k, top_p, min_p, seed, num_ctx, num_predict; `stop` | taken |
82
+ | `ADAPTER` | **refused** — merge first (`push_to_bankml` does) |
83
+ | `TEMPLATE` | **refused** unless equal to the base's own chat template |
84
+ | penalties, `repeat_last_n`, mirostat*, `typical_p` | **refused** — not reproduced |
85
+ | `num_gpu`, `num_thread`, `num_batch`, `num_keep`, `draft_num_predict` | **refused** — a resource option is not part of a model |
86
+
87
+ Each refusal is returned with its reason; nothing is dropped silently. Python API:
88
+ `mindxtrain.deploy.bankml_push.push_to_bankml(...) -> BankmlPushResult` (never raises; `status` is
89
+ one of `created`, `refused`, `bankml_missing`, `bankml_too_old`, `merge_failed`, `failed`, `error`).
90
+
91
+ ## A second imprint instrument — `mindxtrain imprint-bankml`
92
+
93
+ ```bash
94
+ uv run mindxtrain imprint-bankml run.yaml --before smollm2-135m-instruct --after mindx-gen80 \
95
+ [--seed 0] [--num-predict 48] [--system "…"] [--base-url http://127.0.0.1:18093/v1]
96
+ ```
97
+
98
+ Poses the script's user-turns to two tags on bankml's `/api/chat` with `temperature 0`, a fixed
99
+ `seed`, `num_predict 48` and **no penalties**, then scores with the existing `score_imprint`. The
100
+ report (`BankmlImprintReport`) carries the decoding, every utterance's receipt and the distinct
101
+ `model_sha256` values, and `report.method` is tagged `<scorer>/bankml-greedy`.
102
+
103
+ **It is not comparable with the canonical gate.** `mindxtrain imprint` decodes with transformers
104
+ greedy, `repetition_penalty 1.3` and `no_repeat_ngram_size 3`; every number in an ascent log comes
105
+ from that. A bankml-greedy score is compared only with bankml-greedy scores, and the report says so
106
+ (`canonical_gate: false`, `comparable_with: "bankml-greedy only"`). What it buys:
107
+
108
+ - **reproducible** — the same seed and weights give the same tokens, identical to llama.cpp;
109
+ - **auditable** — a score is tied to the exact weights by sha256, not to a tag name;
110
+ - **cheap** — a 135M F16 actor answers on one CPU core, with no torch in the probing process.
111
+
112
+ Observed on mindx-gen39 (2026-10-02): unpenalised greedy decoding degenerates on short probes
113
+ without a system turn (runs of `,` and `?||`), which is the very behaviour the 1.3 penalty in the
114
+ canonical gate suppresses. Expect low bankml-greedy voice scores until the actor itself stops
115
+ repeating; pass the persona's `--system` as the coach does.
116
+
117
+ ## Console and published Modelfiles
118
+
119
+ - `mindxtrain/ui/console.py` no longer sends a penalty or mirostat option left at the engine's own
120
+ default (`repeat_penalty 1.1`, presence / frequency `0`, `typical_p 1`, `mirostat 0` and its
121
+ tau / eta while it is off), so bankml accepts a console request with default settings. A
122
+ deliberate value is always sent; bankml then refuses it visibly. For Ollama an absent key means
123
+ its own default, except that a Modelfile's `PARAMETER repeat_penalty` now applies where the
124
+ console used to override it with 1.1.
125
+ - `hf.extension.publish_generation(..., repeat_penalty=None)` publishes a Modelfile without the
126
+ penalty line, which bankml can load. The default stays 1.3.
127
+
128
+ ## Tests
129
+
130
+ `tests/test_bankml_backend.py`, `tests/test_bankml_push.py`, `tests/test_imprint_bankml.py` — no
131
+ network and no binary: `httpx.MockTransport`, monkeypatched `subprocess.run` / `shutil.which`.
132
+ `tests/conftest.py` pins the bankml auto-detect probe to "absent" so a developer box running bankml
133
+ cannot change what the other auto-detect tests resolve to.
docs/cli.md CHANGED
@@ -131,19 +131,46 @@ Requires the `amd-quark` package — typically only available inside the
131
  `rocm/primus:v26.2` container or per
132
  [Quark docs](https://quark.docs.amd.com/).
133
 
134
- ## `serve` — print the vLLM-ROCm launch command
135
 
136
  ```
137
- mindxtrain serve <config.yaml> [--checkpoint <path>]
 
 
 
138
  ```
139
 
140
- Builds the `vllm serve` argv from `cfg.serve` and prints it. We deliberately
141
- don't `exec` — the user pipes it into their own orchestrator (or
142
- `ops/compose/compose_dev.yaml`).
 
 
 
 
 
 
143
 
144
- The chat-template parsers map per `serve.reasoning_parser` (`qwen3` for Qwen3,
145
- `deepseek_r1` for DeepSeek-style) and `serve.tool_call_parser` (`hermes`,
146
- `qwen3_coder`).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
 
148
  ## `publish` — push to HF + Lighthouse + register
149
 
@@ -203,6 +230,8 @@ $ uv run mindxtrain receipt out/runs/<run_id>/manifest.json --config run.yaml
203
  | `dataset prep` | `mindxtrain.cli.main.dataset_prep` + `mindxtrain.data.{curate,filter,tokenize,pack}` |
204
  | `eval` | `mindxtrain.cli.main.eval_` + `mindxtrain.eval.harness.run_lm_eval` |
205
  | `quantize` | `mindxtrain.cli.main.quantize` + `mindxtrain.deploy.quark.quark_fp8` |
206
- | `serve` | `mindxtrain.cli.main.serve` + `mindxtrain.deploy.vllm_launcher.build_vllm_command` |
 
 
207
  | `publish` | `mindxtrain.cli.main.publish` + `mindxtrain.storage.{hf_hub,lighthouse}` + `mindxtrain.deploy.api_client` |
208
  | `receipt` | `mindxtrain.cli.main.receipt` + `mindxtrain.provenance.verify.verify_receipt` |
 
131
  `rocm/primus:v26.2` container or per
132
  [Quark docs](https://quark.docs.amd.com/).
133
 
134
+ ## `serve` — launch vLLM / SGLang (default `--to vllm`)
135
 
136
  ```
137
+ mindxtrain serve <config.yaml> [--to vllm|sglang] [--checkpoint <path>] [--tag NAME] [--merge]
138
+ [--host 127.0.0.1] [--port N] [--dtype auto] [--server-bin PATH]
139
+ [--server-arg ARG ...] [--ready-timeout 600] [--cpu-kvcache-gib 4]
140
+ [--register-as-fallback] [--dry-run | --stop]
141
  ```
142
 
143
+ Launches `vllm serve` / `python -m sglang.launch_server` detached (log, pid and `launch.json` under
144
+ `out/runs/<run>/serve/<to>/`), serving a LoRA natively over the base (`--merge` merges it first),
145
+ waits until `/v1/models` lists the tag (and vLLM's `/health` is 200), and with
146
+ `--register-as-fallback` swaps mindX's fallback model to it. `--dry-run` prints the exact argv and
147
+ runs nothing; `--stop` sends SIGTERM to the recorded server's process group and verifies it is
148
+ gone. On a GPU-less host the CPU backends are used (bfloat16, `VLLM_CPU_KVCACHE_SPACE`,
149
+ SGLang `--device cpu`); a quantized checkpoint or `tensor_parallel > 1` is refused there. Exit
150
+ codes: 1 checkpoint missing · 2 refused (server missing, GPU needed, already running) · 3 merge
151
+ failed / exited early / error · 4 not ready in time (left running). Full page: [serve.md](serve.md).
152
 
153
+ ### `serve --to ollama` / `--to bankml`
154
+
155
+ ```
156
+ mindxtrain serve <config.yaml> --to bankml [--tag NAME] [--checkpoint DIR] [--bankml-bin PATH] [--bankml-convert] [--register-as-fallback]
157
+ ```
158
+
159
+ `--to ollama` merges the LoRA and runs `ollama create`. `--to bankml` does the same through
160
+ [bankml](https://github.com/cryptoAGI/bankml) (`bankml create`, 0.3.5+) and refuses what bankml does
161
+ not reproduce — see [bankml.md](bankml.md). Exit codes for `--to bankml`: 1 checkpoint missing ·
162
+ 2 refused (quantized config, architecture, Modelfile subset, bankml missing or too old) ·
163
+ 3 merge or create failed.
164
+
165
+ ## `imprint-bankml` — receipt-auditable imprint probes
166
+
167
+ ```
168
+ mindxtrain imprint-bankml <config.yaml> --before TAG --after TAG [--n 5] [--seed 0] [--num-predict 48] [--system TEXT] [--base-url URL]
169
+ ```
170
+
171
+ Greedy, seeded, unpenalised probes through `bankml serve --native`, scored with `score_imprint`;
172
+ every utterance carries bankml's receipt. **Not comparable** with `imprint` (repetition penalty
173
+ 1.3). Exit 3 on a bankml refusal or error, 4 when no imprint is detected.
174
 
175
  ## `publish` — push to HF + Lighthouse + register
176
 
 
230
  | `dataset prep` | `mindxtrain.cli.main.dataset_prep` + `mindxtrain.data.{curate,filter,tokenize,pack}` |
231
  | `eval` | `mindxtrain.cli.main.eval_` + `mindxtrain.eval.harness.run_lm_eval` |
232
  | `quantize` | `mindxtrain.cli.main.quantize` + `mindxtrain.deploy.quark.quark_fp8` |
233
+ | `serve --to vllm\|sglang` | `mindxtrain.cli.main._serve_openai_server` + `mindxtrain.deploy.openai_server_push.launch_openai_server` |
234
+ | `serve --to bankml` | `mindxtrain.cli.main._serve_bankml` + `mindxtrain.deploy.bankml_push.push_to_bankml` |
235
+ | `imprint-bankml` | `mindxtrain.cli.main.imprint_bankml` + `mindxtrain.eval.imprint_bankml.imprint_via_bankml` |
236
  | `publish` | `mindxtrain.cli.main.publish` + `mindxtrain.storage.{hf_hub,lighthouse}` + `mindxtrain.deploy.api_client` |
237
  | `receipt` | `mindxtrain.cli.main.receipt` + `mindxtrain.provenance.verify.verify_receipt` |
docs/development.md CHANGED
@@ -198,8 +198,10 @@ RTX is the intended consumer GPU.
198
  decorated `@register_backend("<name>")`.
199
  2. Side-effect import it from `mindxtrain/models/registry.py` so registration
200
  runs on package import.
201
- 3. Add a runtime branch in `mindxtrain/operator/app.py::chat_completions` for
202
- the env-var-driven kwargs.
 
 
203
 
204
  ## Adding a new training method
205
 
 
198
  decorated `@register_backend("<name>")`.
199
  2. Side-effect import it from `mindxtrain/models/registry.py` so registration
200
  runs on package import.
201
+ 3. Add its env-var-driven kwargs to `mindxtrain/operator/app.py::backend_kwargs`
202
+ (the operator route and the Coach chat stream both read it), and a probe to
203
+ `backend_reachable` / `backend_first_model` if it has one. Example:
204
+ `operator/backends/bankml.py` ([bankml.md](bankml.md)).
205
 
206
  ## Adding a new training method
207
 
docs/serve.md ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Serving on vLLM and SGLang
2
+
3
+ `mindxtrain serve <config.yaml> --to vllm|sglang` launches an OpenAI-compatible server on the
4
+ trained checkpoint, waits until it answers, and can hand it to mindX as the fallback model. It sits
5
+ beside `--to ollama` (merge + `ollama create`) and `--to bankml` (merge + `bankml create`, see
6
+ [bankml.md](bankml.md)). Code: `mindxtrain/deploy/openai_server_push.py`.
7
+
8
+ vLLM and SGLang are reached **only** as a subprocess and over HTTP — neither is imported by
9
+ mindXtrain (clean-room policy). SGLang's presence is checked with `importlib.util.find_spec`,
10
+ which does not import it.
11
+
12
+ ```
13
+ mindxtrain serve run.yaml --to vllm [--tag NAME] [--checkpoint DIR] [--merge] [--host 127.0.0.1]
14
+ [--port N] [--dtype auto] [--server-bin PATH]
15
+ [--server-arg ARG ...] [--ready-timeout 600]
16
+ [--cpu-kvcache-gib 4] [--register-as-fallback]
17
+ mindxtrain serve run.yaml --to sglang ...same options...
18
+ mindxtrain serve run.yaml --to vllm --dry-run # print the argv (and env); run nothing
19
+ mindxtrain serve run.yaml --to vllm --stop # SIGTERM the recorded server, verify it is gone
20
+ ```
21
+
22
+ ## What runs
23
+
24
+ 1. **Checkpoint.** `--checkpoint`, else `out/runs/<run>/quantized/` when the config quantizes and
25
+ that directory exists, else `out/runs/<run>/checkpoint/`.
26
+ 2. **LoRA, native or merged.** A directory with `adapter_config.json` is a LoRA. By default it is
27
+ served **natively over the base** (no merge):
28
+ - vLLM: `--enable-lora --lora-modules <tag>=<adapter> --max-lora-rank R` (R = the adapter's `r`
29
+ rounded up to a vLLM choice: 1, 8, 16, 32, 64, 128, 256, 320, 512). The base is served as
30
+ `<tag>-base`; the adapter is listed on `/v1/models` as `<tag>`, which is the client's `model`.
31
+ - SGLang: `--enable-lora --lora-paths <tag>=<adapter> --max-lora-rank r`. The base is served as
32
+ `<tag>-base`; a client selects the adapter with `model: "<tag>-base:<tag>"`.
33
+
34
+ `--merge` folds the adapter in first (`merge_lora_adapter`, needs `uv sync --extra ml`) and
35
+ serves the merged directory as `<tag>`.
36
+ 3. **argv.** vLLM: `vllm serve <model> --served-model-name … --host --port --dtype
37
+ --max-model-len <serve.max_model_len> --tensor-parallel-size <serve.tensor_parallel>
38
+ [--quantization fp8|mxfp4|gptq]`. SGLang: `python -m sglang.launch_server --model-path …
39
+ --served-model-name … --host --port --dtype --context-length <serve.max_model_len>
40
+ --tp <serve.tensor_parallel>` plus `--device cpu` on CPU or `--mem-fraction-static 0.85` on GPU.
41
+ Anything else goes through `--server-arg`, verbatim (e.g. `--server-arg=--enforce-eager`).
42
+ 4. **Detached.** The server runs in its own session; stdout+stderr go to
43
+ `out/runs/<run>/serve/<to>/server.log`, its pid to `server.pid`, and the argv/env/time to
44
+ `launch.json`. It outlives the CLI.
45
+ 5. **Ready.** `/v1/models` is polled (and, for vLLM, `/health` must be 200) until the expected
46
+ name is listed, the process exits, or `--ready-timeout` passes. On a timeout the server is
47
+ **left running** (big models load slowly) — watch the log or `--stop` it.
48
+ 6. **mindX.** With `--register-as-fallback`, mindX's fallback model is swapped to
49
+ `{provider: "vllm"|"sglang", model: <client model>}` (best-effort, as for Ollama).
50
+
51
+ ## CPU and GPU
52
+
53
+ With no `/dev/kfd` (ROCm) or `/dev/nvidia0` the CPU backends are used: `--dtype auto` becomes
54
+ `bfloat16` (the vLLM CPU guide's recommendation; float16 is unstable or unsupported on CPU), vLLM
55
+ gets `VLLM_CPU_KVCACHE_SPACE=<--cpu-kvcache-gib>` unless the environment already sets it (other
56
+ CPU knobs such as `VLLM_CPU_OMP_THREADS_BIND` pass through from your env), and SGLang gets
57
+ `--device cpu`. A config that needs a GPU is **refused** on such a host, with the reason: an FP8 /
58
+ MXFP4 / GPTQ checkpoint, or `serve.tensor_parallel > 1`. Neither upstream documents LoRA on CPU;
59
+ if your build rejects it, use `--merge`.
60
+
61
+ ## Installing the servers
62
+
63
+ - vLLM: `uv sync --extra serve` (GPU / ROCm). CPU-only per the
64
+ [vLLM CPU guide](https://docs.vllm.ai/en/latest/getting_started/installation/cpu.html), e.g.
65
+ `uv pip install vllm --torch-backend cpu`. Or point `--server-bin` at a `vllm` elsewhere.
66
+ - SGLang is not a mindXtrain extra: install it into the interpreter that will run it
67
+ (`uv pip install sglang`, [docs.sglang.io](https://docs.sglang.io)) and pass that interpreter as
68
+ `--server-bin` if it is not this one.
69
+
70
+ ## Exit codes
71
+
72
+ `0` ready / dry run / stopped / not running · `1` checkpoint missing · `2` refused: server missing,
73
+ GPU needed, already running · `3` merge failed, server exited early, stop failed, error ·
74
+ `4` not ready before `--ready-timeout` (still running).
75
+
76
+ ## Where each flag comes from
77
+
78
+ vLLM: the [`vllm serve` CLI reference](https://docs.vllm.ai/en/latest/cli/serve.html)
79
+ (`--served-model-name`, `--host`, `--port`, `--dtype`, `--max-model-len`, `--tensor-parallel-size`,
80
+ `--quantization`, `--enable-lora`, `--lora-modules`, `--max-lora-rank` and its choices), the
81
+ [LoRA page](https://docs.vllm.ai/en/latest/features/lora.html) (`name=path`, adapters on
82
+ `/v1/models`), the [CPU installation page](https://docs.vllm.ai/en/latest/getting_started/installation/cpu.html)
83
+ (`VLLM_CPU_KVCACHE_SPACE`, bfloat16), and [online serving](https://docs.vllm.ai/en/latest/serving/online_serving/)
84
+ (`/health`, `/v1/models`). SGLang: [Server Arguments](https://docs.sglang.io/advanced_features/server_arguments.html)
85
+ (`--model-path`, `--served-model-name`, `--host`, `--port`, `--dtype`, `--context-length`,
86
+ `--device`, `--tp`, `--mem-fraction-static`, `--enable-lora`, `--lora-paths`, `--max-lora-rank`)
87
+ and the [LoRA page](https://docs.sglang.io/advanced_features/lora.html) (`base:adapter` model
88
+ syntax). Checked against the docs current on 2026-10-02 (`uv.lock` resolves vLLM 0.20.1); no
89
+ server was installed or launched to write this.
llm.txt CHANGED
@@ -81,6 +81,18 @@ uv run python -m mindxtrain.ui.app # the UI at :7862
81
  comparable. The floor is calibrated against a **null** (an untrained random-init adapter), not chosen.
82
  A run that fails the gate is recorded as failed and is not served. *That refusal is the product.*
83
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  ## The teaching artifacts
85
 
86
  Published beside the weights at `PYTHAI/mindXtrain39`, and mirrored in mindX at
 
81
  comparable. The floor is calibrated against a **null** (an untrained random-init adapter), not chosen.
82
  A run that fails the gate is recorded as failed and is not served. *That refusal is the product.*
83
 
84
+ `mindxtrain imprint-bankml` is a **second instrument**, not the gate: probes through
85
+ [bankml](https://github.com/cryptoAGI/bankml) (`bankml serve --native`, token-identical to llama.cpp
86
+ b11192) at temperature 0, a fixed seed and **no penalties**, a receipt (model sha256) per utterance.
87
+ Its scores are tagged `<scorer>/bankml-greedy` and are **not comparable** with the gate's. Never mix
88
+ them. Backends the operator knows: `openai_compat`, `ollama`, `vllm`, `bankml` — see `docs/bankml.md`;
89
+ a bankml HTTP 400 is a `BankmlRefusal` with its reason, and is never retried with altered parameters.
90
+
91
+ Serve targets: `mindxtrain serve run.yaml --to ollama|bankml|vllm|sglang`. `vllm` / `sglang` launch the
92
+ OpenAI-compatible server detached (LoRA native over the base by default, `--merge` to merge; log + pid
93
+ under `out/runs/<run>/serve/<to>/`), return once `/v1/models` lists the tag, `--dry-run` prints the
94
+ argv, `--stop` ends it; GPU-only configs are refused on a CPU host — see `docs/serve.md`.
95
+
96
  ## The teaching artifacts
97
 
98
  Published beside the weights at `PYTHAI/mindXtrain39`, and mirrored in mindX at
mindxtrain/cli/main.py CHANGED
@@ -11,6 +11,7 @@ from mindxtrain import __version__
11
  from mindxtrain.autotune.benchmark import run_autotune
12
  from mindxtrain.autotune.plan import AutotunePlan
13
  from mindxtrain.config.loader import list_recipes, load_config, render_recipe
 
14
 
15
  app = typer.Typer(
16
  name="mindxtrain",
@@ -301,28 +302,243 @@ def quantize(
301
  console.print(f"[green]quantized:[/green] {path}")
302
 
303
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  @app.command()
305
  def serve(
306
  config: Path = typer.Argument(...),
307
  checkpoint: Path = typer.Option(None, "--checkpoint", "-c"),
308
  to: str = typer.Option(
309
  "vllm", "--to",
310
- help="Serve target: vllm (default, builds vllm-rocm launch cmd), "
311
- "sglang (builds sglang-rocm launch cmd), or "
312
- "ollama (merges LoRA + calls `ollama create`).",
 
 
313
  ),
314
  tag: str = typer.Option(
315
  None, "--tag",
316
- help="Ollama tag for `--to ollama`. Defaults to run_name when omitted.",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
317
  ),
318
  ollama_bin: str = typer.Option(
319
  None, "--ollama-bin",
320
  help="Override the ollama binary path (defaults to PATH lookup).",
321
  ),
 
 
 
 
 
 
 
 
 
322
  register_as_fallback: bool = typer.Option(
323
  False, "--register-as-fallback",
324
  help=(
325
- "After --to ollama succeeds, PATCH the new tag into mindX as "
326
  "the local-fallback model (PATCH /v1/config/fallback-model). "
327
  "Best-effort — a failure logs but does NOT fail the push."
328
  ),
@@ -338,16 +554,34 @@ def serve(
338
  ) -> None:
339
  """Serve the trained checkpoint locally.
340
 
341
- `--to vllm` (default) prints a vllm-rocm launch command against the
342
- quantized checkpoint — wire it into your orchestrator.
 
 
 
 
 
343
 
344
  `--to ollama` runs the local-learning loop: merges the LoRA adapter
345
  into the base weights, writes an ollama Modelfile, and calls
346
  `ollama create <tag>` so the trained model is immediately available
347
  on the loopback (the same backend Coach probes for its chat card).
 
 
 
 
 
 
348
  """
349
  cfg = load_config(config)
350
 
 
 
 
 
 
 
 
351
  if to == "ollama":
352
  from mindxtrain.deploy.ollama_push import push_to_ollama
353
 
@@ -388,23 +622,12 @@ def serve(
388
  console.print(f"[red]unknown serve target:[/red] {to}")
389
  raise typer.Exit(code=2)
390
 
391
- ckpt = checkpoint or Path("./out/runs") / cfg.meta.run_name / "quantized"
392
- if not ckpt.exists():
393
- console.print(f"[red]quantized checkpoint not found:[/red] {ckpt}")
394
- raise typer.Exit(code=1)
395
-
396
- if to == "sglang":
397
- from mindxtrain.deploy.sglang_rocm import build_sglang_command
398
-
399
- cmd = build_sglang_command(cfg.serve, ckpt)
400
- console.print(f"[green]sglang cmd:[/green] {' '.join(cmd)}")
401
- return
402
-
403
- from mindxtrain.deploy.vllm_launcher import build_vllm_command
404
-
405
- cmd = build_vllm_command(cfg.serve, ckpt, cfg.quantize)
406
- console.print(f"[green]vllm cmd:[/green] {' '.join(cmd)}")
407
- # Caller can pipe the cmd into their orchestrator; we don't exec by default.
408
 
409
 
410
  # ---- dataset prep ---------------------------------------------------------
@@ -600,34 +823,12 @@ def receipt(
600
  raise typer.Exit(code=2)
601
 
602
 
603
- @app.command()
604
- def imprint(
605
- config: Path = typer.Argument(..., help="recipe whose checkpoint to measure"),
606
- out: Path = typer.Option(Path("./out/runs"), "--out", "-o"),
607
- max_inquiries: int = typer.Option(5, "--n", help="number of recall probes"),
608
- trigger_dream: bool = typer.Option(
609
- False, "--trigger-dream",
610
- help="hand the imprinted actor to mindX's machine.dream 8hr cycle",
611
- ),
612
- ) -> None:
613
- """Measure a persona imprint: recall before vs after training.
614
-
615
- Poses the script's own user-turns back to the actor, comparing the base
616
- model (before) and the trained adapter (after) against the script's
617
- assistant voice. Prints an ImprintReport; exit 4 if no imprint was detected.
618
- """
619
  import json as _json
620
 
621
- cfg = load_config(config)
622
- run_dir = (out / cfg.meta.run_name) if out.name == "runs" else out
623
- adapter_dir = run_dir / "checkpoint"
624
- if not adapter_dir.exists():
625
- console.print(f"[red]no checkpoint to measure:[/red] {adapter_dir}")
626
- raise typer.Exit(code=1)
627
-
628
- # Build inquiries (user-turns) + baseline voice (assistant-turns) from the
629
- # local script the actor trained on. Falls back to default probes.
630
- from mindxtrain.eval.imprint import default_inquiries, probe_recall, score_imprint
631
 
632
  inquiries: list[str] = []
633
  baseline: list[str] = []
@@ -652,6 +853,37 @@ def imprint(
652
  baseline.append(a)
653
  if not inquiries:
654
  inquiries = default_inquiries(cfg.meta.project)[:max_inquiries]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
655
 
656
  console.print(f"[cyan]probing {len(inquiries)} inquiries (before/after)…[/cyan]")
657
  try:
@@ -681,6 +913,51 @@ def imprint(
681
  raise typer.Exit(code=4)
682
 
683
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
684
  # ---- research (autoresearch search over one editable file) --------------
685
 
686
 
 
11
  from mindxtrain.autotune.benchmark import run_autotune
12
  from mindxtrain.autotune.plan import AutotunePlan
13
  from mindxtrain.config.loader import list_recipes, load_config, render_recipe
14
+ from mindxtrain.config.schema import XTrainConfig
15
 
16
  app = typer.Typer(
17
  name="mindxtrain",
 
302
  console.print(f"[green]quantized:[/green] {path}")
303
 
304
 
305
+ def _serve_openai_server(
306
+ cfg: XTrainConfig,
307
+ flavour: str,
308
+ checkpoint: Path | None,
309
+ *,
310
+ tag: str | None,
311
+ stop: bool,
312
+ dry_run: bool,
313
+ merge: bool,
314
+ host: str,
315
+ port: int | None,
316
+ dtype: str,
317
+ server_bin: str | None,
318
+ server_args: list[str],
319
+ ready_timeout: float,
320
+ cpu_kvcache_gib: int,
321
+ register_as_fallback: bool,
322
+ mindx_base_url: str | None,
323
+ ) -> None:
324
+ """`serve --to vllm|sglang`: launch detached, wait for /v1/models, or `--stop` it."""
325
+ from mindxtrain.deploy.openai_server_push import (
326
+ VLLM_QUANTIZATION,
327
+ launch_openai_server,
328
+ stop_openai_server,
329
+ )
330
+
331
+ fl = "vllm" if flavour == "vllm" else "sglang"
332
+ run_dir = Path("./out/runs") / cfg.meta.run_name
333
+ if stop:
334
+ res = stop_openai_server(fl, run_dir)
335
+ colour = "green" if res.ok else "red"
336
+ console.print(f"[{colour}]{fl} {res.status}[/{colour}] "
337
+ f"{('pid ' + str(res.pid)) if res.pid else ''} {res.reason}".rstrip())
338
+ raise typer.Exit(code=0 if res.ok else 3)
339
+
340
+ quantized = run_dir / "quantized"
341
+ if checkpoint is None:
342
+ use_quant = (cfg.quantize.enabled and cfg.quantize.scheme != "none"
343
+ and quantized.exists())
344
+ checkpoint = quantized if use_quant else run_dir / "checkpoint"
345
+ if not checkpoint.exists():
346
+ console.print(f"[red]checkpoint not found:[/red] {checkpoint}")
347
+ raise typer.Exit(code=1)
348
+ is_adapter = (checkpoint / "adapter_config.json").exists()
349
+ is_quant = checkpoint.resolve() == quantized.resolve() or checkpoint.name == "quantized"
350
+ quant = VLLM_QUANTIZATION.get(cfg.quantize.scheme) if is_quant else None
351
+
352
+ result = launch_openai_server(
353
+ fl,
354
+ base_model=cfg.model.name,
355
+ tag=tag or cfg.meta.run_name,
356
+ run_dir=run_dir,
357
+ adapter_dir=checkpoint if is_adapter else None,
358
+ model_dir=None if is_adapter else checkpoint,
359
+ merge=merge,
360
+ host=host,
361
+ port=port or cfg.serve.port,
362
+ dtype=dtype, # type: ignore[arg-type]
363
+ max_model_len=cfg.serve.max_model_len,
364
+ tensor_parallel=cfg.serve.tensor_parallel,
365
+ quantization=quant,
366
+ cpu_kvcache_gib=cpu_kvcache_gib,
367
+ extra_args=tuple(server_args),
368
+ server_bin=server_bin,
369
+ dry_run=dry_run,
370
+ ready_timeout_s=ready_timeout,
371
+ register_with_mindx=register_as_fallback,
372
+ mindx_base_url=mindx_base_url,
373
+ sink=lambda line: console.print(line, markup=False, highlight=False),
374
+ )
375
+ if result.status == "dry_run":
376
+ console.print(f"[green]{fl} cmd:[/green] {' '.join(result.argv)}",
377
+ highlight=False, soft_wrap=True)
378
+ return
379
+ if not result.ok:
380
+ console.print(f"[red]serve --to {fl} {result.status}:[/red] {result.reason}",
381
+ highlight=False, soft_wrap=True)
382
+ refused = ("refused", "missing", "no_gpu", "already_running")
383
+ raise typer.Exit(code=2 if result.status in refused
384
+ else 4 if result.status == "timeout" else 3)
385
+ console.print(f"[green]{fl} ready:[/green] model {result.client_model!r} at "
386
+ f"{result.base_url}/v1 (pid {result.pid}, log {result.log_path})",
387
+ highlight=False)
388
+ console.print(f"stop it: mindxtrain serve <config.yaml> --to {fl} --stop")
389
+ if result.mindx_fallback_swap:
390
+ console.print(
391
+ f"[green]mindX fallback swapped:[/green] "
392
+ f"{result.mindx_fallback_swap.get('previous', '?')} -> "
393
+ f"{result.mindx_fallback_swap.get('current', '?')}",
394
+ )
395
+
396
+
397
+ def _serve_bankml(
398
+ cfg: XTrainConfig,
399
+ checkpoint: Path | None,
400
+ *,
401
+ tag: str | None,
402
+ bankml_bin: str | None,
403
+ convert: bool,
404
+ register_as_fallback: bool,
405
+ mindx_base_url: str | None,
406
+ ) -> None:
407
+ """`serve --to bankml`: refuse what bankml cannot serve, else merge + `bankml create`.
408
+
409
+ Exit codes: 1 checkpoint missing · 2 refused (quantized config, architecture, Modelfile
410
+ subset, bankml missing or too old) · 3 merge / create failed.
411
+ """
412
+ from mindxtrain.deploy.bankml_push import base_family_refusal, push_to_bankml
413
+
414
+ quant = cfg.quantize
415
+ if quant.enabled and quant.scheme != "none":
416
+ console.print(
417
+ f"[red]bankml refuses quantize.scheme={quant.scheme}:[/red] bankml serves the merged "
418
+ "weights as GGUF F16 (and pinned Q1_0 / Q2_0_g64); it does not reproduce FP8, MXFP4, "
419
+ "GPTQ, Q8_0 or Q4_K. Set `quantize.scheme: none` (or `enabled: false`), or serve "
420
+ "with --to vllm.",
421
+ )
422
+ raise typer.Exit(code=2)
423
+ base_model = cfg.model.name
424
+ family = base_family_refusal(base_model)
425
+ if family:
426
+ console.print(f"[red]bankml refuses:[/red] {family}")
427
+ raise typer.Exit(code=2)
428
+
429
+ run_name = cfg.meta.run_name
430
+ ckpt = checkpoint or Path("./out/runs") / run_name / "checkpoint"
431
+ if not ckpt.exists():
432
+ console.print(f"[red]checkpoint not found:[/red] {ckpt}")
433
+ raise typer.Exit(code=1)
434
+ is_adapter = (ckpt / "adapter_config.json").exists() or not (ckpt / "config.json").exists()
435
+ result = push_to_bankml(
436
+ base_model,
437
+ tag or run_name,
438
+ adapter_dir=ckpt if is_adapter else None,
439
+ merged_dir=None if is_adapter else ckpt,
440
+ bankml_bin=bankml_bin,
441
+ convert=convert,
442
+ register_with_mindx=register_as_fallback,
443
+ mindx_base_url=mindx_base_url,
444
+ sink=lambda line: console.print(line, markup=False, highlight=False),
445
+ )
446
+ if not result.ok:
447
+ console.print(f"[red]push-to-bankml {result.status}:[/red] {result.reason}")
448
+ for r in result.refusals:
449
+ console.print(f" - {r}", markup=False)
450
+ refused = ("refused", "bankml_missing", "bankml_too_old")
451
+ raise typer.Exit(code=2 if result.status in refused else 3)
452
+ console.print(
453
+ f"[green]pushed:[/green] {result.tag} on bankml {result.bankml_version} "
454
+ f"(model sha256 {result.model_sha256 or '?'}, Modelfile: {result.modelfile})",
455
+ )
456
+ console.print("serve it: bankml serve <pinned.gguf> --fork <FORK.json> --native --registry")
457
+ if result.mindx_fallback_swap:
458
+ console.print(
459
+ f"[green]mindX fallback swapped:[/green] "
460
+ f"{result.mindx_fallback_swap.get('previous', '?')} -> "
461
+ f"{result.mindx_fallback_swap.get('current', '?')}",
462
+ )
463
+
464
+
465
  @app.command()
466
  def serve(
467
  config: Path = typer.Argument(...),
468
  checkpoint: Path = typer.Option(None, "--checkpoint", "-c"),
469
  to: str = typer.Option(
470
  "vllm", "--to",
471
+ help="Serve target: vllm (default; launches `vllm serve` detached and waits for "
472
+ "/v1/models), sglang (launches `python -m sglang.launch_server` the same way), "
473
+ "ollama (merges LoRA + calls `ollama create`), or "
474
+ "bankml (merges LoRA + calls `bankml create`; the verified CPU engine, "
475
+ "https://github.com/cryptoAGI/bankml).",
476
  ),
477
  tag: str = typer.Option(
478
  None, "--tag",
479
+ help="Model tag (Ollama/bankml tag; vLLM/SGLang served or LoRA name). "
480
+ "Defaults to run_name when omitted.",
481
+ ),
482
+ dry_run: bool = typer.Option(
483
+ False, "--dry-run",
484
+ help="`--to vllm|sglang`: print the exact launch argv (and env) and do nothing.",
485
+ ),
486
+ stop: bool = typer.Option(
487
+ False, "--stop",
488
+ help="`--to vllm|sglang`: SIGTERM the server recorded in "
489
+ "out/runs/<run>/serve/<to>/server.pid and verify it is gone.",
490
+ ),
491
+ merge: bool = typer.Option(
492
+ False, "--merge",
493
+ help="`--to vllm|sglang`: merge the LoRA into the base first (needs `uv sync --extra ml`) "
494
+ "instead of serving base + adapter natively (the default; no merge).",
495
+ ),
496
+ host: str = typer.Option(
497
+ "127.0.0.1", "--host", help="`--to vllm|sglang`: bind address (loopback by default).",
498
+ ),
499
+ port: int = typer.Option(
500
+ None, "--port", help="`--to vllm|sglang`: port; defaults to serve.port in the config.",
501
+ ),
502
+ dtype: str = typer.Option(
503
+ "auto", "--dtype",
504
+ help="`--to vllm|sglang`: auto|half|float16|bfloat16|float|float32. "
505
+ "auto becomes bfloat16 on a GPU-less host (the CPU backends' recommendation).",
506
+ ),
507
+ server_bin: str = typer.Option(
508
+ None, "--server-bin",
509
+ help="`--to vllm`: the vllm executable; `--to sglang`: a Python interpreter with "
510
+ "sglang installed. Defaults: `vllm` on PATH / this interpreter.",
511
+ ),
512
+ server_arg: list[str] = typer.Option(
513
+ None, "--server-arg",
514
+ help="`--to vllm|sglang`: an extra argument passed verbatim to the server "
515
+ "(repeatable), e.g. --server-arg=--enforce-eager.",
516
+ ),
517
+ ready_timeout: float = typer.Option(
518
+ 600.0, "--ready-timeout",
519
+ help="`--to vllm|sglang`: seconds to wait for /v1/models to list the tag.",
520
+ ),
521
+ cpu_kvcache_gib: int = typer.Option(
522
+ 4, "--cpu-kvcache-gib",
523
+ help="`--to vllm` on CPU: VLLM_CPU_KVCACHE_SPACE in GiB, unless already set in the env.",
524
  ),
525
  ollama_bin: str = typer.Option(
526
  None, "--ollama-bin",
527
  help="Override the ollama binary path (defaults to PATH lookup).",
528
  ),
529
+ bankml_bin: str = typer.Option(
530
+ None, "--bankml-bin",
531
+ help="Override the bankml binary path for `--to bankml` (defaults to PATH lookup).",
532
+ ),
533
+ bankml_convert: bool = typer.Option(
534
+ False, "--bankml-convert",
535
+ help="With `--to bankml`: run `bankml convert` (GGUF F16 + FORK.json pin) before "
536
+ "`bankml create`, instead of letting create convert the merged directory.",
537
+ ),
538
  register_as_fallback: bool = typer.Option(
539
  False, "--register-as-fallback",
540
  help=(
541
+ "After --to ollama|bankml|vllm|sglang succeeds, PATCH the new tag into mindX as "
542
  "the local-fallback model (PATCH /v1/config/fallback-model). "
543
  "Best-effort — a failure logs but does NOT fail the push."
544
  ),
 
554
  ) -> None:
555
  """Serve the trained checkpoint locally.
556
 
557
+ `--to vllm` (default) / `--to sglang` launch an OpenAI-compatible server
558
+ detached (log + pid under out/runs/<run>/serve/<to>/), serving the LoRA
559
+ natively over the base (or the merged weights with `--merge`, or the
560
+ quantized checkpoint when one exists), wait until `/v1/models` lists the
561
+ tag, and optionally swap mindX's fallback model to it. `--dry-run` prints
562
+ the argv; `--stop` ends the server. A GPU-less host uses the CPU backends;
563
+ a config that needs a GPU is refused with the reason.
564
 
565
  `--to ollama` runs the local-learning loop: merges the LoRA adapter
566
  into the base weights, writes an ollama Modelfile, and calls
567
  `ollama create <tag>` so the trained model is immediately available
568
  on the loopback (the same backend Coach probes for its chat card).
569
+
570
+ `--to bankml` does the same through bankml (`bankml create`, 0.3.5+):
571
+ the merged weights converted to GGUF F16 byte-identically to llama.cpp,
572
+ served by `bankml serve --native` with a receipt on every answer. It
573
+ refuses quantized configs (bankml serves F16 conversions, not FP8 /
574
+ MXFP4 / GPTQ / Q8_0 / Q4_K) and non-Llama architectures, with the reason.
575
  """
576
  cfg = load_config(config)
577
 
578
+ if to == "bankml":
579
+ _serve_bankml(
580
+ cfg, checkpoint, tag=tag, bankml_bin=bankml_bin, convert=bankml_convert,
581
+ register_as_fallback=register_as_fallback, mindx_base_url=mindx_base_url,
582
+ )
583
+ return
584
+
585
  if to == "ollama":
586
  from mindxtrain.deploy.ollama_push import push_to_ollama
587
 
 
622
  console.print(f"[red]unknown serve target:[/red] {to}")
623
  raise typer.Exit(code=2)
624
 
625
+ _serve_openai_server(
626
+ cfg, to, checkpoint, tag=tag, stop=stop, dry_run=dry_run, merge=merge, host=host,
627
+ port=port, dtype=dtype, server_bin=server_bin, server_args=server_arg or [],
628
+ ready_timeout=ready_timeout, cpu_kvcache_gib=cpu_kvcache_gib,
629
+ register_as_fallback=register_as_fallback, mindx_base_url=mindx_base_url,
630
+ )
 
 
 
 
 
 
 
 
 
 
 
631
 
632
 
633
  # ---- dataset prep ---------------------------------------------------------
 
823
  raise typer.Exit(code=2)
824
 
825
 
826
+ def _script_probes(cfg: XTrainConfig, max_inquiries: int) -> tuple[list[str], list[str]]:
827
+ """Inquiries (the script's user-turns, at most `max_inquiries`) and the baseline voice (its
828
+ assistant-turns) from the local script the actor trained on; default probes otherwise."""
 
 
 
 
 
 
 
 
 
 
 
 
 
829
  import json as _json
830
 
831
+ from mindxtrain.eval.imprint import default_inquiries
 
 
 
 
 
 
 
 
 
832
 
833
  inquiries: list[str] = []
834
  baseline: list[str] = []
 
853
  baseline.append(a)
854
  if not inquiries:
855
  inquiries = default_inquiries(cfg.meta.project)[:max_inquiries]
856
+ return inquiries, baseline
857
+
858
+
859
+ @app.command()
860
+ def imprint(
861
+ config: Path = typer.Argument(..., help="recipe whose checkpoint to measure"),
862
+ out: Path = typer.Option(Path("./out/runs"), "--out", "-o"),
863
+ max_inquiries: int = typer.Option(5, "--n", help="number of recall probes"),
864
+ trigger_dream: bool = typer.Option(
865
+ False, "--trigger-dream",
866
+ help="hand the imprinted actor to mindX's machine.dream 8hr cycle",
867
+ ),
868
+ ) -> None:
869
+ """Measure a persona imprint: recall before vs after training.
870
+
871
+ Poses the script's own user-turns back to the actor, comparing the base
872
+ model (before) and the trained adapter (after) against the script's
873
+ assistant voice. Prints an ImprintReport; exit 4 if no imprint was detected.
874
+ """
875
+ cfg = load_config(config)
876
+ run_dir = (out / cfg.meta.run_name) if out.name == "runs" else out
877
+ adapter_dir = run_dir / "checkpoint"
878
+ if not adapter_dir.exists():
879
+ console.print(f"[red]no checkpoint to measure:[/red] {adapter_dir}")
880
+ raise typer.Exit(code=1)
881
+
882
+ # Build inquiries (user-turns) + baseline voice (assistant-turns) from the
883
+ # local script the actor trained on. Falls back to default probes.
884
+ from mindxtrain.eval.imprint import probe_recall, score_imprint
885
+
886
+ inquiries, baseline = _script_probes(cfg, max_inquiries)
887
 
888
  console.print(f"[cyan]probing {len(inquiries)} inquiries (before/after)…[/cyan]")
889
  try:
 
913
  raise typer.Exit(code=4)
914
 
915
 
916
+ @app.command("imprint-bankml")
917
+ def imprint_bankml(
918
+ config: Path = typer.Argument(..., help="recipe whose script supplies inquiries + voice"),
919
+ before: str = typer.Option(..., "--before", help="bankml tag of the base actor"),
920
+ after: str = typer.Option(..., "--after", help="bankml tag of the imprinted actor"),
921
+ max_inquiries: int = typer.Option(5, "--n", help="number of recall probes"),
922
+ seed: int = typer.Option(0, "--seed", help="sampler seed (recorded; greedy at temperature 0)"),
923
+ num_predict: int = typer.Option(48, "--num-predict", help="tokens per utterance"),
924
+ system: str = typer.Option("", "--system", help="system turn prepended to every probe"),
925
+ base_url: str = typer.Option(
926
+ None, "--base-url",
927
+ help="bankml server (default MINDXTRAIN_BANKML_BASE_URL or http://127.0.0.1:18093/v1)",
928
+ ),
929
+ ) -> None:
930
+ """Measure an imprint through bankml: reproducible, receipt-auditable CPU probes.
931
+
932
+ Poses the script's user-turns to two tags served by `bankml serve --native` (temperature 0,
933
+ fixed seed, no penalties) and scores them with the same `score_imprint`. Every utterance
934
+ carries bankml's receipt (model / request / response sha256). NOT comparable with
935
+ `mindxtrain imprint` (the canonical gate decodes with repetition_penalty 1.3); the report
936
+ says so. Exit 3 if bankml refuses or errs, 4 if no imprint was detected.
937
+ """
938
+ import httpx
939
+
940
+ from mindxtrain.eval.imprint_bankml import imprint_via_bankml
941
+ from mindxtrain.operator.backends.bankml import BankmlError
942
+
943
+ cfg = load_config(config)
944
+ inquiries, baseline = _script_probes(cfg, max_inquiries)
945
+ console.print(f"[cyan]probing {len(inquiries)} inquiries through bankml (before/after)…[/cyan]")
946
+ try:
947
+ result = imprint_via_bankml(
948
+ before, after, inquiries, baseline, system=system or None, seed=seed,
949
+ num_predict=num_predict, base_url=base_url,
950
+ )
951
+ except (BankmlError, httpx.HTTPError) as exc:
952
+ console.print(f"[red]bankml imprint probe failed:[/red] {exc}")
953
+ raise typer.Exit(code=3) from exc
954
+ console.print_json(data=result.model_dump())
955
+ console.print(f"[yellow]{result.note}[/yellow]")
956
+ if not result.report.imprinted:
957
+ console.print("[yellow]no imprint detected (delta<=0 or no shift)[/yellow]")
958
+ raise typer.Exit(code=4)
959
+
960
+
961
  # ---- research (autoresearch search over one editable file) --------------
962
 
963
 
mindxtrain/deploy/bankml_push.py ADDED
@@ -0,0 +1,439 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Serve a trained model through bankml — the verified CPU engine.
2
+
3
+ `push_to_bankml` is the bankml twin of `ollama_push.push_to_ollama`: merge the LoRA (or take an
4
+ already-merged directory) → write a Modelfile that bankml can reproduce → `bankml create`, so the
5
+ tag answers on `bankml serve --native` (OpenAI `/v1` + Ollama `/api`, a receipt on every answer).
6
+
7
+ bankml (https://github.com/cryptoAGI/bankml) is reached **only** as a CLI subprocess here and as
8
+ HTTP in `operator/backends/bankml.py`; none of its code is vendored (clean-room policy).
9
+
10
+ What bankml takes, and what this module therefore refuses *before* running anything (it never
11
+ drops an instruction silently — a dropped PARAMETER would serve a model that answers differently
12
+ from the one described):
13
+
14
+ - **Taken:** `FROM` (the merged safetensors directory, which `bankml create` converts to GGUF F16
15
+ byte-identically to llama.cpp b11192, or a GGUF `bankml convert` already pinned), `SYSTEM`,
16
+ `MESSAGE`, `LICENSE`, `REQUIRES`, `PARAMETER` temperature / top_k / top_p / min_p / seed /
17
+ num_ctx / num_predict, and `stop`.
18
+ - **Refused:** `ADAPTER` (merge first — this module does it for you given `adapter_dir`), a
19
+ `TEMPLATE` other than the base's own, the penalties (repeat / presence / frequency,
20
+ repeat_last_n), mirostat*, typical_p, resource options (num_gpu, num_thread, num_batch,
21
+ num_keep, draft_num_predict), and any architecture other than Llama (SmolLM2 = mindx-genN):
22
+ bankml converts Llama safetensors only; Qwen3 it serves only as a pinned Q1_0/Q2_0_g64 GGUF.
23
+
24
+ `bankml create` / `bankml convert` arrive in bankml 0.3.5. An older binary is detected from
25
+ `bankml --help` (the verbs it lists) and reported as `status="bankml_too_old"`, never raised.
26
+
27
+ Every public function returns a result; nothing here raises past `BankmlPushResult`.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import os
34
+ import re
35
+ import shutil
36
+ import subprocess
37
+ from collections.abc import Callable
38
+ from dataclasses import dataclass, field
39
+ from pathlib import Path
40
+ from typing import Literal
41
+
42
+ from mindxtrain.deploy.modelfile import ModelfileSpec, render_modelfile
43
+
44
+ BANKML_URL = "https://github.com/cryptoAGI/bankml"
45
+
46
+ # PARAMETERs bankml reproduces token-for-token (plus `stop`, carried on ModelfileSpec.stop).
47
+ BANKML_PARAMS: frozenset[str] = frozenset(
48
+ {"temperature", "top_k", "top_p", "min_p", "seed", "num_ctx", "num_predict"},
49
+ )
50
+
51
+ _PENALTY = frozenset({"repeat_penalty", "presence_penalty", "frequency_penalty", "repeat_last_n"})
52
+ _MIROSTAT = frozenset({"mirostat", "mirostat_tau", "mirostat_eta"})
53
+ _RESOURCE = frozenset({"num_gpu", "num_thread", "num_batch", "num_keep", "draft_num_predict"})
54
+
55
+ # Architectures `bankml convert` / `bankml create FROM <dir>` accept (config.json `architectures`).
56
+ BANKML_CONVERT_ARCHS: frozenset[str] = frozenset({"LlamaForCausalLM"})
57
+
58
+ _NAME = re.compile(r"^[a-z0-9_][a-z0-9._-]{0,127}$")
59
+ _SHA = r"[0-9a-f]{64}"
60
+
61
+ PushStatus = Literal[
62
+ "created", "refused", "bankml_missing", "bankml_too_old", "merge_failed", "failed", "error",
63
+ ]
64
+
65
+
66
+ def _param_refusal(name: str) -> str:
67
+ if name in _PENALTY:
68
+ return (f"PARAMETER {name}: penalties are not reproduced by bankml's verified sampler "
69
+ "(llama.cpp's top-k, top-p, min-p and temperature are); leave it out")
70
+ if name in _MIROSTAT:
71
+ return f"PARAMETER {name}: mirostat is not reproduced by bankml; leave it out"
72
+ if name == "typical_p":
73
+ return "PARAMETER typical_p: locally-typical sampling is not reproduced by bankml"
74
+ if name in _RESOURCE:
75
+ return (f"PARAMETER {name}: a resource option is not part of a bankml model "
76
+ "(bankml's answer does not depend on threads, batch or GPU layers)")
77
+ if name == "stop":
78
+ return "PARAMETER stop: pass stop sequences as ModelfileSpec.stop, not in parameters"
79
+ return f"PARAMETER {name}: not a parameter bankml reproduces"
80
+
81
+
82
+ # ---- capability probe -------------------------------------------------------
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class BankmlCapabilities:
87
+ """What the local `bankml` binary can do. `reason` is set when it cannot do what was asked."""
88
+
89
+ binary: str | None
90
+ version: str = ""
91
+ has_create: bool = False
92
+ has_convert: bool = False
93
+ reason: str = ""
94
+
95
+ @property
96
+ def found(self) -> bool:
97
+ return self.binary is not None
98
+
99
+
100
+ def bankml_capabilities(bankml_bin: str | None = None, *, timeout_s: float = 30.0) -> BankmlCapabilities:
101
+ """Locate `bankml` and read its version and verbs (`bankml version`, `bankml --help`).
102
+
103
+ The verbs listed in the usage text are the truth — an unreleased build may still say 0.3.4
104
+ and already carry `create`. Never raises.
105
+ """
106
+ binary = bankml_bin or shutil.which("bankml")
107
+ if not binary:
108
+ return BankmlCapabilities(
109
+ binary=None,
110
+ reason=(f"`bankml` not found on PATH; install it from {BANKML_URL} "
111
+ "(or pass --bankml-bin)"),
112
+ )
113
+ try:
114
+ ver = subprocess.run([binary, "version"], capture_output=True, text=True,
115
+ timeout=timeout_s, check=False)
116
+ usage = subprocess.run([binary, "--help"], capture_output=True, text=True,
117
+ timeout=timeout_s, check=False)
118
+ except (OSError, subprocess.SubprocessError) as exc:
119
+ return BankmlCapabilities(binary=binary, reason=f"cannot run {binary}: {exc}")
120
+ m = re.search(r"bankml\s+(\d+\.\d+\.\d+\S*)", ver.stdout + ver.stderr)
121
+ text = usage.stdout + usage.stderr
122
+ return BankmlCapabilities(
123
+ binary=binary,
124
+ version=m.group(1) if m else "",
125
+ has_create="bankml create" in text,
126
+ has_convert="bankml convert" in text,
127
+ )
128
+
129
+
130
+ # ---- the Modelfile subset ----------------------------------------------------
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class BankmlSanitizeResult:
135
+ """`ok` → `spec` is exactly what bankml will reproduce; else `refusals` say why, one per line."""
136
+
137
+ spec: ModelfileSpec
138
+ refusals: tuple[str, ...] = ()
139
+
140
+ @property
141
+ def ok(self) -> bool:
142
+ return not self.refusals
143
+
144
+
145
+ def bankml_sanitize(spec: ModelfileSpec, *, base_template: str | None = None) -> BankmlSanitizeResult:
146
+ """Check a ModelfileSpec against the subset `bankml create` reproduces.
147
+
148
+ Refuses (and names) every instruction bankml would not honour; it never drops one, because a
149
+ silently dropped PARAMETER serves a model that answers differently from the spec. A
150
+ `TEMPLATE` passes only when it equals `base_template` (the base GGUF's own chat template).
151
+ """
152
+ refusals: list[str] = []
153
+ if spec.adapter:
154
+ refusals.append("ADAPTER: bankml does not merge a LoRA at create time; merge it first "
155
+ "(push_to_bankml does, given adapter_dir) and FROM the merged directory")
156
+ if spec.template and spec.template != base_template:
157
+ refusals.append("TEMPLATE: bankml renders the base model's own chat template "
158
+ "(byte-identical to llama.cpp); a different template is not reproduced — "
159
+ "leave TEMPLATE out")
160
+ for name in sorted(spec.parameters):
161
+ if name not in BANKML_PARAMS:
162
+ refusals.append(_param_refusal(name))
163
+ for label, value in (("SYSTEM", spec.system), ("TEMPLATE", spec.template),
164
+ ("LICENSE", spec.license)):
165
+ if '"""' in value:
166
+ refusals.append(f'{label}: contains """, which a Modelfile cannot quote')
167
+ for stop in spec.stop:
168
+ if '"' in stop or "\n" in stop:
169
+ refusals.append(f"stop {stop!r}: a quote or newline cannot be written as PARAMETER stop")
170
+ return BankmlSanitizeResult(spec=spec, refusals=tuple(refusals))
171
+
172
+
173
+ def check_merged_arch(merged_dir: Path) -> str | None:
174
+ """None when `merged_dir/config.json` names an architecture bankml converts (or has none to
175
+ read — then bankml decides); else the refusal reason."""
176
+ try:
177
+ cfg = json.loads((merged_dir / "config.json").read_text(encoding="utf-8"))
178
+ except (OSError, ValueError):
179
+ return None
180
+ archs = cfg.get("architectures") or []
181
+ if not archs or any(a in BANKML_CONVERT_ARCHS for a in archs):
182
+ return None
183
+ return (f"architecture {', '.join(map(str, archs))}: bankml converts Llama-architecture "
184
+ "safetensors only (SmolLM2 / mindx-genN); Qwen3 it serves only as a pinned "
185
+ "Q1_0/Q2_0_g64 GGUF — use `serve --to ollama` or `--to vllm` for this model")
186
+
187
+
188
+ # Families mindXtrain trains that bankml cannot convert (checked by name before a merge is spent).
189
+ _UNCONVERTIBLE_FAMILIES = ("qwen", "mistral", "phi", "gemma", "glm", "deepseek", "instella")
190
+
191
+
192
+ def base_family_refusal(base_model: str) -> str | None:
193
+ """A cheap, name-based pre-check (before merging): None unless `base_model` names a family
194
+ bankml cannot convert. The merged `config.json` is still checked afterwards."""
195
+ low = base_model.lower()
196
+ if "smollm" in low or "llama" in low:
197
+ return None
198
+ hit = next((f for f in _UNCONVERTIBLE_FAMILIES if f in low), None)
199
+ if hit is None:
200
+ return None
201
+ return (f"base {base_model}: the {hit} family is not a Llama-architecture model bankml can "
202
+ "convert (bankml converts SmolLM2 / mindx-genN; Qwen3 only as a pinned "
203
+ "Q1_0/Q2_0_g64 GGUF) — use `serve --to ollama` or `--to vllm`")
204
+
205
+
206
+ def bankml_registry_dir(registry_dir: Path | None = None) -> Path:
207
+ """bankml's registry (pins + derived models): `--registry`, else $BANKML_FORKS, else
208
+ ~/.local/share/bankml/forks — the same resolution `bankml create` uses."""
209
+ if registry_dir is not None:
210
+ return Path(registry_dir).expanduser()
211
+ env = os.environ.get("BANKML_FORKS")
212
+ if env:
213
+ return Path(env).expanduser()
214
+ return Path.home() / ".local" / "share" / "bankml" / "forks"
215
+
216
+
217
+ # ---- push --------------------------------------------------------------------
218
+
219
+
220
+ @dataclass(frozen=True)
221
+ class BankmlPushResult:
222
+ """Outcome of `push_to_bankml`. `status == "created"` is the only success."""
223
+
224
+ status: PushStatus
225
+ tag: str
226
+ reason: str = ""
227
+ refusals: tuple[str, ...] = ()
228
+ bankml_version: str = ""
229
+ merged_dir: Path | None = None
230
+ gguf: Path | None = None
231
+ modelfile: Path | None = None
232
+ model_sha256: str = ""
233
+ digest: str = ""
234
+ output: str = ""
235
+ mindx_fallback_swap: dict[str, str] | None = None
236
+ log: tuple[str, ...] = field(default=())
237
+
238
+ @property
239
+ def ok(self) -> bool:
240
+ return self.status == "created"
241
+
242
+
243
+ def _run(cmd: list[str], timeout_s: float) -> tuple[int, str, str]:
244
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_s, check=False)
245
+ return proc.returncode, proc.stdout or "", proc.stderr or ""
246
+
247
+
248
+ def push_to_bankml(
249
+ base_model: str,
250
+ tag: str,
251
+ *,
252
+ adapter_dir: Path | None = None,
253
+ merged_dir: Path | None = None,
254
+ system: str | None = None,
255
+ params: dict[str, float | int | str] | None = None,
256
+ stop: list[str] | None = None,
257
+ bankml_bin: str | None = None,
258
+ convert: bool = False,
259
+ work_dir: Path | None = None,
260
+ registry_dir: Path | None = None,
261
+ register_with_mindx: bool = False,
262
+ mindx_base_url: str | None = None,
263
+ sink: Callable[[str], None] | None = None,
264
+ timeout_s: float = 1800.0,
265
+ ) -> BankmlPushResult:
266
+ """Merge (if given an adapter) → Modelfile (bankml subset) → `bankml create <tag>`.
267
+
268
+ Give exactly one of `adapter_dir` (a PEFT LoRA over `base_model`; merging needs
269
+ `uv sync --extra ml`) or `merged_dir` (an already-merged HF safetensors directory).
270
+
271
+ `convert=False` (default) writes `FROM <merged dir>` and lets `bankml create` convert and pin
272
+ it. `convert=True` runs `bankml convert` first (GGUF + FORK.json into the registry) and
273
+ writes `FROM <gguf>` — the explicit, inspectable two-step.
274
+
275
+ The model sha256 recorded is the one bankml prints for the base it pinned and verified.
276
+ With `register_with_mindx`, mindX's fallback model is swapped to `{provider: "bankml",
277
+ model: tag}` (best-effort; a failure is logged, the push still stands).
278
+ """
279
+ lines: list[str] = []
280
+
281
+ def emit(line: str) -> None:
282
+ lines.append(line)
283
+ if sink:
284
+ sink(line)
285
+
286
+ def done(status: PushStatus, **kw: object) -> BankmlPushResult:
287
+ return BankmlPushResult(status=status, tag=tag, log=tuple(lines), **kw) # type: ignore[arg-type]
288
+
289
+ name = tag.strip().lower().removesuffix(":latest")
290
+ if not _NAME.match(name) or name.endswith(".gguf"):
291
+ return done("refused", reason=(f"tag {tag!r}: bankml names use letters, digits, '.', '_' "
292
+ "and '-' (at most 128, no ':tag', not ending in .gguf)"))
293
+ if (adapter_dir is None) == (merged_dir is None):
294
+ return done("error", reason="give exactly one of adapter_dir or merged_dir")
295
+
296
+ # 1. the subset — checked before anything expensive runs
297
+ spec = ModelfileSpec(from_model="<merged>", system=system or "", parameters=dict(params or {}),
298
+ stop=list(stop or []))
299
+ checked = bankml_sanitize(spec)
300
+ if not checked.ok:
301
+ for r in checked.refusals:
302
+ emit(f"[push-bankml] refuse: {r}")
303
+ return done("refused", reason="the Modelfile asks for what bankml does not reproduce",
304
+ refusals=checked.refusals)
305
+
306
+ # 2. the binary, and whether it has the verbs
307
+ caps = bankml_capabilities(bankml_bin)
308
+ if not caps.found:
309
+ return done("bankml_missing", reason=caps.reason)
310
+ if caps.reason:
311
+ return done("error", reason=caps.reason, bankml_version=caps.version)
312
+ missing = [v for v, have in (("create", caps.has_create), ("convert", caps.has_convert))
313
+ if (v == "create" or convert) and not have]
314
+ if missing:
315
+ return done("bankml_too_old", bankml_version=caps.version, reason=(
316
+ f"bankml {caps.version or '(unknown version)'} has no `{'`/`'.join(missing)}` verb "
317
+ f"(they arrive in bankml 0.3.5); upgrade from {BANKML_URL}"))
318
+ emit(f"[push-bankml] bankml {caps.version} at {caps.binary}")
319
+
320
+ work = Path(work_dir) if work_dir else (
321
+ (adapter_dir or merged_dir).parent / "bankml_push") # type: ignore[union-attr]
322
+
323
+ # 3. merge, when given an adapter
324
+ if adapter_dir is not None:
325
+ try:
326
+ from mindxtrain.deploy.ollama_push import merge_lora_adapter
327
+
328
+ merged = merge_lora_adapter(base_model, Path(adapter_dir), work / "merged", sink=emit)
329
+ except ImportError as exc:
330
+ return done("merge_failed", bankml_version=caps.version, reason=(
331
+ f"{exc} — merging a LoRA needs `uv sync --extra ml`"))
332
+ except Exception as exc: # a merge failure is a result, not a crash
333
+ return done("merge_failed", bankml_version=caps.version,
334
+ reason=f"{type(exc).__name__}: {exc}")
335
+ else:
336
+ merged = Path(merged_dir) # type: ignore[arg-type]
337
+ if not merged.is_dir():
338
+ return done("error", bankml_version=caps.version,
339
+ reason=f"merged_dir {merged} is not a directory")
340
+
341
+ arch_refusal = check_merged_arch(merged)
342
+ if arch_refusal:
343
+ emit(f"[push-bankml] refuse: {arch_refusal}")
344
+ return done("refused", bankml_version=caps.version, merged_dir=merged,
345
+ reason=arch_refusal, refusals=(arch_refusal,))
346
+
347
+ registry = bankml_registry_dir(registry_dir)
348
+ reg_args = ["--registry", str(registry)] if registry_dir is not None else []
349
+ gguf: Path | None = None
350
+ model_sha = ""
351
+ try:
352
+ # 4. optional explicit conversion
353
+ source = str(merged.resolve())
354
+ if convert:
355
+ registry.mkdir(parents=True, exist_ok=True)
356
+ gguf = registry / f"{name}-base-F16.gguf"
357
+ cmd = [str(caps.binary), "convert", source, "-o", str(gguf),
358
+ "--fork", str(registry / f"{gguf.name}.FORK.json"), "--source", source]
359
+ emit(f"[push-bankml] $ {' '.join(cmd)}")
360
+ code, out, err = _run(cmd, timeout_s)
361
+ if err.strip():
362
+ emit(err.rstrip())
363
+ if code != 0:
364
+ status: PushStatus = "refused" if code == 2 else "failed"
365
+ return done(status, bankml_version=caps.version, merged_dir=merged,
366
+ reason=_last_line(err) or f"bankml convert exited {code}",
367
+ output=(out + err)[-2000:])
368
+ m = re.search(rf"^({_SHA})\s", out, re.MULTILINE)
369
+ model_sha = m.group(1) if m else ""
370
+
371
+ # 5. the Modelfile and `bankml create`
372
+ spec = spec.model_copy(update={"from_model": str(gguf) if gguf else source})
373
+ modelfile = work / name / "Modelfile"
374
+ modelfile.parent.mkdir(parents=True, exist_ok=True)
375
+ modelfile.write_text(render_modelfile(spec), encoding="utf-8")
376
+ cmd = [str(caps.binary), "create", name, "-f", str(modelfile), *reg_args]
377
+ emit(f"[push-bankml] $ {' '.join(cmd)}")
378
+ code, out, err = _run(cmd, timeout_s)
379
+ except (OSError, subprocess.SubprocessError) as exc:
380
+ return done("error", bankml_version=caps.version, merged_dir=merged, gguf=gguf,
381
+ reason=f"{type(exc).__name__}: {exc}")
382
+ for chunk in (out, err):
383
+ if chunk.strip():
384
+ emit(chunk.rstrip())
385
+ if code != 0:
386
+ return done("refused" if code == 2 else "failed", bankml_version=caps.version,
387
+ merged_dir=merged, gguf=gguf, modelfile=modelfile,
388
+ reason=_last_line(err) or f"bankml create exited {code}",
389
+ output=(out + err)[-2000:])
390
+
391
+ both = out + "\n" + err
392
+ over = re.search(rf"over (\S+) \(sha256 ({_SHA})\)", both)
393
+ digest = re.search(rf"digest sha256:({_SHA})", both)
394
+ if over:
395
+ model_sha = over.group(2)
396
+ if gguf is None:
397
+ gguf = registry / over.group(1)
398
+ elif not model_sha and gguf is not None:
399
+ code_s, out_s, _ = _run([str(caps.binary), "sha256", str(gguf)], 600.0)
400
+ m = re.match(rf"({_SHA})\s", out_s)
401
+ model_sha = m.group(1) if (code_s == 0 and m) else ""
402
+
403
+ swap: dict[str, str] | None = None
404
+ if register_with_mindx:
405
+ try:
406
+ from mindxtrain.deploy.api_client import swap_mindx_fallback_model
407
+
408
+ swap = swap_mindx_fallback_model(provider="bankml", model=name, api_url=mindx_base_url)
409
+ emit(f"[push-bankml] mindX swap: {swap.get('previous', '?')} -> "
410
+ f"{swap.get('current', '?')}")
411
+ except Exception as exc: # best-effort, as push_to_ollama
412
+ emit(f"[push-bankml] mindX registration failed (push still ok): {exc}")
413
+ swap = None
414
+
415
+ emit(f"[push-bankml] created {name} over sha256 {model_sha or '(not reported)'}")
416
+ return done("created", bankml_version=caps.version, merged_dir=merged, gguf=gguf,
417
+ modelfile=modelfile, model_sha256=model_sha,
418
+ digest=digest.group(1) if digest else "", output=both.strip()[-2000:],
419
+ mindx_fallback_swap=swap)
420
+
421
+
422
+ def _last_line(text: str) -> str:
423
+ rows = [r.strip() for r in text.strip().splitlines() if r.strip()]
424
+ return rows[-1] if rows else ""
425
+
426
+
427
+ __all__ = [
428
+ "BANKML_CONVERT_ARCHS",
429
+ "BANKML_PARAMS",
430
+ "BankmlCapabilities",
431
+ "BankmlPushResult",
432
+ "BankmlSanitizeResult",
433
+ "bankml_capabilities",
434
+ "bankml_registry_dir",
435
+ "bankml_sanitize",
436
+ "base_family_refusal",
437
+ "check_merged_arch",
438
+ "push_to_bankml",
439
+ ]
mindxtrain/deploy/openai_server_push.py ADDED
@@ -0,0 +1,568 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Serve a trained model on vLLM or SGLang — an OpenAI-compatible server, launched and watched.
2
+
3
+ `launch_openai_server` is the vLLM / SGLang twin of `ollama_push.push_to_ollama` and
4
+ `bankml_push.push_to_bankml`: resolve the launcher → build the exact argv → start it detached
5
+ (own session, log file and pid file under the run dir) → poll until the tag answers on
6
+ `/v1/models` (and `/health` for vLLM) → optionally swap mindX's fallback model to it.
7
+ `stop_openai_server` reads the pid file, sends SIGTERM to the server's process group and
8
+ verifies it is gone.
9
+
10
+ Two ways to serve a LoRA, chosen per call:
11
+
12
+ - **native (default — no merge):** the base model plus the adapter, as the server loads it.
13
+ vLLM: `--enable-lora --lora-modules <tag>=<adapter> --max-lora-rank R`; the adapter is listed
14
+ on `/v1/models` under `<tag>`. SGLang: `--enable-lora --lora-paths <tag>=<adapter>
15
+ --max-lora-rank R`; a client selects it as `model: "<served-name>:<tag>"`.
16
+ - **merged (`merge=True`):** `merge_lora_adapter` folds the adapter into the base weights
17
+ (`uv sync --extra ml`) and the merged directory is served under `<tag>`.
18
+
19
+ On a host without a GPU the CPU backends are used: vLLM's CPU build reads
20
+ `VLLM_CPU_KVCACHE_SPACE` (GiB; set here unless the environment already sets it) and wants
21
+ `--dtype bfloat16`; SGLang takes `--device cpu`. A config that needs a GPU (an FP8 / MXFP4 /
22
+ GPTQ checkpoint, or tensor parallel > 1) is refused on a GPU-less host, with the reason.
23
+
24
+ Every flag below is from the upstream server-argument docs (vLLM `vllm serve` CLI reference and
25
+ LoRA / CPU-installation pages; SGLang "Server Arguments" and LoRA pages). Anything else a site
26
+ needs goes through `extra_args`, verbatim — this module does not guess at flags.
27
+
28
+ Clean-room: vLLM and SGLang are reached only as a subprocess and over HTTP. Neither is imported
29
+ here (SGLang's presence is checked with `importlib.util.find_spec`, which does not import it).
30
+
31
+ Every public function returns a result; nothing here raises past `ServerLaunchResult` /
32
+ `ServerStopResult`.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import importlib.util
38
+ import json
39
+ import os
40
+ import shutil
41
+ import signal
42
+ import subprocess
43
+ import sys
44
+ import time
45
+ from collections.abc import Callable
46
+ from dataclasses import dataclass, field
47
+ from datetime import UTC, datetime
48
+ from pathlib import Path
49
+ from typing import Any, Literal
50
+
51
+ import httpx
52
+ from pydantic import BaseModel, ConfigDict, Field
53
+
54
+ Flavour = Literal["vllm", "sglang"]
55
+ Dtype = Literal["auto", "half", "float16", "bfloat16", "float", "float32"]
56
+
57
+ VLLM_INSTALL_HINT = (
58
+ "install vLLM with `uv sync --extra serve` (GPU / ROCm), or CPU-only per "
59
+ "https://docs.vllm.ai/en/latest/getting_started/installation/cpu.html "
60
+ "(e.g. `uv pip install vllm --torch-backend cpu`); or pass --server-bin"
61
+ )
62
+ SGLANG_INSTALL_HINT = (
63
+ "SGLang is not a mindXtrain extra; install it into the interpreter that will run it "
64
+ "(`uv pip install sglang`, see https://docs.sglang.io) or pass --server-bin <python>"
65
+ )
66
+
67
+ # vLLM `--max-lora-rank` choices (vllm serve CLI reference); SGLang takes any int (default 16).
68
+ VLLM_LORA_RANKS: tuple[int, ...] = (1, 8, 16, 32, 64, 128, 256, 320, 512)
69
+
70
+ # `quantize.scheme` → vLLM `--quantization` (as vllm_launcher.build_vllm_command maps it).
71
+ VLLM_QUANTIZATION: dict[str, str] = {"quark_fp8": "fp8", "quark_mxfp4": "mxfp4",
72
+ "gptq_rocm": "gptq"}
73
+
74
+ LaunchStatus = Literal[
75
+ "ready", "dry_run", "refused", "missing", "no_gpu", "merge_failed", "already_running",
76
+ "exited", "timeout", "error",
77
+ ]
78
+ StopStatus = Literal["stopped", "not_running", "still_running", "error"]
79
+
80
+
81
+ class OpenAIServerSpec(BaseModel):
82
+ """What to serve and how. `build_argv` / `build_env` turn it into the exact launch."""
83
+
84
+ model_config = ConfigDict(extra="forbid", frozen=True)
85
+
86
+ flavour: Flavour
87
+ model: str = Field(min_length=1, description="Model dir or HF repo id (the base, for LoRA).")
88
+ tag: str = Field(min_length=1, description="The name clients use (served name or LoRA name).")
89
+ lora_path: str | None = Field(default=None, description="Adapter dir served natively.")
90
+ lora_rank: int | None = Field(default=None, ge=1, description="Adapter rank (`r`).")
91
+ host: str = "127.0.0.1"
92
+ port: int = Field(default=8000, ge=1024, le=65535)
93
+ dtype: Dtype = "auto"
94
+ max_model_len: int = Field(default=8192, ge=512)
95
+ tensor_parallel: int = Field(default=1, ge=1)
96
+ quantization: str | None = None
97
+ cpu: bool = False
98
+ cpu_kvcache_gib: int = Field(default=4, ge=1)
99
+ extra_args: tuple[str, ...] = ()
100
+
101
+ @property
102
+ def served_name(self) -> str:
103
+ """The name the base (or merged) model is served under."""
104
+ return f"{self.tag}-base" if self.lora_path else self.tag
105
+
106
+ @property
107
+ def client_model(self) -> str:
108
+ """What an OpenAI client puts in `model` to reach the tag."""
109
+ if self.lora_path and self.flavour == "sglang":
110
+ return f"{self.served_name}:{self.tag}"
111
+ return self.tag
112
+
113
+ @property
114
+ def base_url(self) -> str:
115
+ return f"http://{self.host}:{self.port}"
116
+
117
+
118
+ def _vllm_rank(r: int) -> int:
119
+ return next((c for c in VLLM_LORA_RANKS if c >= r), VLLM_LORA_RANKS[-1])
120
+
121
+
122
+ def default_launcher(flavour: Flavour) -> list[str]:
123
+ """The launcher prefix assumed when none is resolved (used by dry runs)."""
124
+ if flavour == "vllm":
125
+ return ["vllm"]
126
+ return [sys.executable, "-m", "sglang.launch_server"]
127
+
128
+
129
+ def build_argv(spec: OpenAIServerSpec, launcher: list[str] | None = None) -> list[str]:
130
+ """The exact argv for `spec`. `launcher` is `["vllm"]` or `[python, "-m", "sglang.launch_server"]`."""
131
+ prefix = list(launcher or default_launcher(spec.flavour))
132
+ dtype = "bfloat16" if (spec.cpu and spec.dtype == "auto") else spec.dtype
133
+ if spec.flavour == "vllm":
134
+ argv = [*prefix, "serve", spec.model,
135
+ "--served-model-name", spec.served_name,
136
+ "--host", spec.host, "--port", str(spec.port),
137
+ "--dtype", dtype,
138
+ "--max-model-len", str(spec.max_model_len),
139
+ "--tensor-parallel-size", str(spec.tensor_parallel)]
140
+ if spec.quantization:
141
+ argv += ["--quantization", spec.quantization]
142
+ if spec.lora_path:
143
+ argv += ["--enable-lora", "--lora-modules", f"{spec.tag}={spec.lora_path}",
144
+ "--max-lora-rank", str(_vllm_rank(spec.lora_rank or 16))]
145
+ else:
146
+ argv = [*prefix, "--model-path", spec.model,
147
+ "--served-model-name", spec.served_name,
148
+ "--host", spec.host, "--port", str(spec.port),
149
+ "--dtype", dtype,
150
+ "--context-length", str(spec.max_model_len),
151
+ "--tp", str(spec.tensor_parallel)]
152
+ if spec.cpu:
153
+ argv += ["--device", "cpu"]
154
+ else:
155
+ argv += ["--mem-fraction-static", "0.85"]
156
+ if spec.lora_path:
157
+ argv += ["--enable-lora", "--lora-paths", f"{spec.tag}={spec.lora_path}",
158
+ "--max-lora-rank", str(spec.lora_rank or 16)]
159
+ return [*argv, *spec.extra_args]
160
+
161
+
162
+ def build_env(spec: OpenAIServerSpec, base_env: dict[str, str] | None = None) -> dict[str, str]:
163
+ """Environment variables this launch adds (the CPU backend's), without overriding the caller's."""
164
+ env = dict(os.environ if base_env is None else base_env)
165
+ added: dict[str, str] = {}
166
+ if spec.cpu and spec.flavour == "vllm" and "VLLM_CPU_KVCACHE_SPACE" not in env:
167
+ added["VLLM_CPU_KVCACHE_SPACE"] = str(spec.cpu_kvcache_gib)
168
+ return added
169
+
170
+
171
+ def gpu_refusal(spec: OpenAIServerSpec) -> str | None:
172
+ """None when `spec` can run without a GPU; else why it cannot."""
173
+ if spec.quantization:
174
+ return (f"the checkpoint is {spec.quantization}-quantized, which {spec.flavour} serves "
175
+ "on a GPU only — serve the unquantized checkpoint (--checkpoint .../checkpoint)")
176
+ if spec.tensor_parallel > 1:
177
+ return f"tensor_parallel={spec.tensor_parallel} needs that many GPUs"
178
+ return None
179
+
180
+
181
+ def detect_gpu() -> str | None:
182
+ """A GPU this host exposes (ROCm `/dev/kfd`, NVIDIA `/dev/nvidia0`), or None."""
183
+ for dev, name in (("/dev/kfd", "rocm"), ("/dev/nvidia0", "cuda")):
184
+ if Path(dev).exists():
185
+ return name
186
+ return None
187
+
188
+
189
+ def adapter_rank(adapter_dir: Path) -> int | None:
190
+ """The LoRA rank `r` from `adapter_config.json`, or None."""
191
+ try:
192
+ r = json.loads((Path(adapter_dir) / "adapter_config.json").read_text("utf-8")).get("r")
193
+ except (OSError, ValueError):
194
+ return None
195
+ return int(r) if isinstance(r, int) and r > 0 else None
196
+
197
+
198
+ def resolve_launcher(flavour: Flavour, server_bin: str | None = None) -> tuple[list[str] | None, str]:
199
+ """(launcher argv prefix, "") or (None, reason with the install hint). Never raises.
200
+
201
+ vLLM: `server_bin` or `vllm` on PATH. SGLang: `server_bin` is a Python interpreter that has
202
+ `sglang` (checked in a subprocess), else this interpreter when `find_spec("sglang")` finds it.
203
+ """
204
+ if flavour == "vllm":
205
+ binary = server_bin or shutil.which("vllm")
206
+ if not binary or not (shutil.which(binary) or Path(binary).is_file()):
207
+ return None, f"`vllm` not found on PATH — {VLLM_INSTALL_HINT}"
208
+ return [binary], ""
209
+ if server_bin:
210
+ probe = "import importlib.util,sys; sys.exit(importlib.util.find_spec('sglang') is None)"
211
+ try:
212
+ rc = subprocess.run([server_bin, "-c", probe], capture_output=True, timeout=60,
213
+ check=False).returncode
214
+ except (OSError, subprocess.SubprocessError) as exc:
215
+ return None, f"cannot run {server_bin}: {exc} — {SGLANG_INSTALL_HINT}"
216
+ if rc != 0:
217
+ return None, f"{server_bin} has no `sglang` package — {SGLANG_INSTALL_HINT}"
218
+ return [server_bin, "-m", "sglang.launch_server"], ""
219
+ try:
220
+ found = importlib.util.find_spec("sglang") is not None
221
+ except (ImportError, ValueError):
222
+ found = False
223
+ if not found:
224
+ return None, f"`sglang` is not importable from {sys.executable} — {SGLANG_INSTALL_HINT}"
225
+ return [sys.executable, "-m", "sglang.launch_server"], ""
226
+
227
+
228
+ def server_dir(run_dir: Path, flavour: Flavour) -> Path:
229
+ """Where the log, pid file and launch record live: `<run_dir>/serve/<flavour>/`."""
230
+ return Path(run_dir) / "serve" / flavour
231
+
232
+
233
+ def _alive(pid: int) -> bool:
234
+ try:
235
+ os.kill(pid, 0)
236
+ except ProcessLookupError:
237
+ return False
238
+ except PermissionError:
239
+ return True
240
+ # A zombie still answers kill(0); /proc says whether it is really running.
241
+ try:
242
+ stat = Path(f"/proc/{pid}/stat").read_text()
243
+ return stat.rsplit(")", 1)[1].split()[0] != "Z"
244
+ except (OSError, IndexError):
245
+ return True
246
+
247
+
248
+ def _read_pid(pid_file: Path) -> int | None:
249
+ try:
250
+ return int(pid_file.read_text().strip())
251
+ except (OSError, ValueError):
252
+ return None
253
+
254
+
255
+ def _tail(path: Path, n: int = 2000) -> str:
256
+ try:
257
+ return path.read_text(encoding="utf-8", errors="replace")[-n:]
258
+ except OSError:
259
+ return ""
260
+
261
+
262
+ # ---- results ------------------------------------------------------------------------------
263
+
264
+
265
+ @dataclass(frozen=True)
266
+ class ServerLaunchResult:
267
+ """Outcome of `launch_openai_server`. `ready` (or `dry_run`) is success."""
268
+
269
+ status: LaunchStatus
270
+ flavour: Flavour
271
+ tag: str
272
+ reason: str = ""
273
+ argv: tuple[str, ...] = ()
274
+ env: dict[str, str] = field(default_factory=dict)
275
+ pid: int | None = None
276
+ base_url: str = ""
277
+ client_model: str = ""
278
+ models: tuple[str, ...] = ()
279
+ log_path: Path | None = None
280
+ pid_file: Path | None = None
281
+ merged_dir: Path | None = None
282
+ mindx_fallback_swap: dict[str, str] | None = None
283
+ log: tuple[str, ...] = ()
284
+
285
+ @property
286
+ def ok(self) -> bool:
287
+ return self.status in ("ready", "dry_run")
288
+
289
+
290
+ @dataclass(frozen=True)
291
+ class ServerStopResult:
292
+ status: StopStatus
293
+ flavour: Flavour
294
+ pid: int | None = None
295
+ reason: str = ""
296
+
297
+ @property
298
+ def ok(self) -> bool:
299
+ return self.status in ("stopped", "not_running")
300
+
301
+
302
+ # ---- readiness ----------------------------------------------------------------------------
303
+
304
+
305
+ def wait_ready(
306
+ spec: OpenAIServerSpec,
307
+ *,
308
+ timeout_s: float,
309
+ poll_interval_s: float = 2.0,
310
+ proc: Any = None,
311
+ http_client: httpx.Client | None = None,
312
+ sleep: Callable[[float], None] = time.sleep,
313
+ ) -> tuple[str, tuple[str, ...]]:
314
+ """Poll until `/v1/models` lists the expected id (and vLLM's `/health` is 200).
315
+
316
+ Returns ("ready" | "exited" | "timeout", model ids last seen). The expected id is the tag
317
+ for vLLM (served or LoRA name) and the served name for SGLang.
318
+ """
319
+ expect = spec.tag if spec.flavour == "vllm" else spec.served_name
320
+ client = http_client or httpx.Client(timeout=5.0)
321
+ seen: tuple[str, ...] = ()
322
+ deadline = time.monotonic() + timeout_s
323
+ try:
324
+ while True:
325
+ if proc is not None and proc.poll() is not None:
326
+ return "exited", seen
327
+ try:
328
+ healthy = True
329
+ if spec.flavour == "vllm":
330
+ healthy = client.get(f"{spec.base_url}/health").status_code == 200
331
+ if healthy:
332
+ resp = client.get(f"{spec.base_url}/v1/models")
333
+ if resp.status_code == 200:
334
+ data = resp.json().get("data") or []
335
+ seen = tuple(str(m.get("id")) for m in data if isinstance(m, dict))
336
+ if expect in seen:
337
+ return "ready", seen
338
+ except (httpx.HTTPError, ValueError, AttributeError):
339
+ pass
340
+ if time.monotonic() >= deadline:
341
+ return "timeout", seen
342
+ sleep(poll_interval_s)
343
+ finally:
344
+ if http_client is None:
345
+ client.close()
346
+
347
+
348
+ # ---- launch / stop ------------------------------------------------------------------------
349
+
350
+
351
+ def launch_openai_server(
352
+ flavour: Flavour,
353
+ *,
354
+ base_model: str,
355
+ tag: str,
356
+ run_dir: Path,
357
+ adapter_dir: Path | None = None,
358
+ model_dir: Path | None = None,
359
+ merge: bool = False,
360
+ host: str = "127.0.0.1",
361
+ port: int = 8000,
362
+ dtype: Dtype = "auto",
363
+ max_model_len: int = 8192,
364
+ tensor_parallel: int = 1,
365
+ quantization: str | None = None,
366
+ cpu: bool | None = None,
367
+ cpu_kvcache_gib: int = 4,
368
+ extra_args: tuple[str, ...] | list[str] = (),
369
+ server_bin: str | None = None,
370
+ dry_run: bool = False,
371
+ ready_timeout_s: float = 600.0,
372
+ poll_interval_s: float = 2.0,
373
+ register_with_mindx: bool = False,
374
+ mindx_base_url: str | None = None,
375
+ http_client: httpx.Client | None = None,
376
+ sleep: Callable[[float], None] = time.sleep,
377
+ sink: Callable[[str], None] | None = None,
378
+ ) -> ServerLaunchResult:
379
+ """Launch vLLM / SGLang on a trained checkpoint, detached, and wait for it to answer.
380
+
381
+ Give exactly one of `adapter_dir` (a PEFT LoRA over `base_model`; served natively unless
382
+ `merge`) or `model_dir` (a full model directory — merged or quantized). `cpu=None` decides
383
+ from `detect_gpu()`. `dry_run` builds and returns the argv and touches nothing.
384
+
385
+ The server runs in its own session with stdout+stderr in `<run_dir>/serve/<flavour>/
386
+ server.log` and its pid in `server.pid`; it outlives this call (stop it with
387
+ `stop_openai_server`). On `timeout` it is left running — big models load slowly.
388
+ """
389
+ lines: list[str] = []
390
+
391
+ def emit(line: str) -> None:
392
+ lines.append(line)
393
+ if sink:
394
+ sink(line)
395
+
396
+ def done(status: LaunchStatus, **kw: Any) -> ServerLaunchResult:
397
+ return ServerLaunchResult(status=status, flavour=flavour, tag=tag, log=tuple(lines), **kw)
398
+
399
+ try:
400
+ if (adapter_dir is None) == (model_dir is None):
401
+ return done("error", reason="give exactly one of adapter_dir or model_dir")
402
+ gpu = detect_gpu() if cpu is None else (None if cpu else "given")
403
+ on_cpu = gpu is None
404
+ sdir = server_dir(run_dir, flavour)
405
+ lora = None if (adapter_dir is None or merge) else Path(adapter_dir)
406
+ spec = OpenAIServerSpec(
407
+ flavour=flavour,
408
+ model=base_model if lora else str(model_dir or sdir / "merged"),
409
+ tag=tag, lora_path=str(lora.resolve()) if lora else None,
410
+ lora_rank=adapter_rank(lora) if lora else None,
411
+ host=host, port=port, dtype=dtype, max_model_len=max_model_len,
412
+ tensor_parallel=tensor_parallel,
413
+ quantization=quantization if flavour == "vllm" else None,
414
+ cpu=on_cpu, cpu_kvcache_gib=cpu_kvcache_gib, extra_args=tuple(extra_args),
415
+ )
416
+ needs_gpu = gpu_refusal(spec) if on_cpu else None
417
+ if flavour == "sglang" and quantization and on_cpu:
418
+ needs_gpu = (f"the checkpoint is {quantization}-quantized, which sglang serves on a "
419
+ "GPU only — serve the unquantized checkpoint (--checkpoint .../checkpoint)")
420
+
421
+ if dry_run:
422
+ launcher, why = resolve_launcher(flavour, server_bin)
423
+ argv = build_argv(spec, launcher)
424
+ env = build_env(spec)
425
+ if merge and adapter_dir is not None:
426
+ emit(f"[serve-{flavour}] would merge {adapter_dir} into {sdir / 'merged'} first")
427
+ for note in (why, needs_gpu and f"needs a GPU: {needs_gpu}"):
428
+ if note:
429
+ emit(f"[serve-{flavour}] note: {note}")
430
+ if env:
431
+ emit(f"[serve-{flavour}] env: " + " ".join(f"{k}={v}" for k, v in env.items()))
432
+ return done("dry_run", argv=tuple(argv), env=env, base_url=spec.base_url,
433
+ client_model=spec.client_model, reason=why or (needs_gpu or ""))
434
+
435
+ if needs_gpu:
436
+ return done("no_gpu", reason=f"no GPU on this host and {needs_gpu}")
437
+ launcher, why = resolve_launcher(flavour, server_bin)
438
+ if launcher is None:
439
+ return done("missing", reason=why)
440
+
441
+ pid_file, log_path = sdir / "server.pid", sdir / "server.log"
442
+ old = _read_pid(pid_file)
443
+ if old is not None and _alive(old):
444
+ return done("already_running", pid=old, pid_file=pid_file, log_path=log_path,
445
+ reason=(f"{flavour} already running as pid {old} ({pid_file}); "
446
+ f"stop it first (`serve --to {flavour} --stop`)"))
447
+
448
+ merged: Path | None = None
449
+ if merge and adapter_dir is not None:
450
+ try:
451
+ from mindxtrain.deploy.ollama_push import merge_lora_adapter
452
+
453
+ merged = merge_lora_adapter(base_model, Path(adapter_dir), sdir / "merged",
454
+ sink=emit)
455
+ except ImportError as exc:
456
+ return done("merge_failed", reason=f"{exc} — merging needs `uv sync --extra ml`")
457
+ except Exception as exc: # a merge failure is a result, not a crash
458
+ return done("merge_failed", reason=f"{type(exc).__name__}: {exc}")
459
+ spec = spec.model_copy(update={"model": str(merged)})
460
+ elif model_dir is not None and not Path(model_dir).is_dir():
461
+ return done("error", reason=f"model_dir {model_dir} is not a directory")
462
+
463
+ argv = build_argv(spec, launcher)
464
+ added = build_env(spec)
465
+ sdir.mkdir(parents=True, exist_ok=True)
466
+ emit(f"[serve-{flavour}] $ {' '.join(argv)}")
467
+ with log_path.open("ab") as logf:
468
+ proc = subprocess.Popen(argv, stdout=logf, stderr=subprocess.STDOUT,
469
+ stdin=subprocess.DEVNULL, start_new_session=True,
470
+ env={**os.environ, **added})
471
+ pid_file.write_text(f"{proc.pid}\n", encoding="utf-8")
472
+ (sdir / "launch.json").write_text(json.dumps({
473
+ "flavour": flavour, "tag": tag, "pid": proc.pid, "argv": argv, "env": added,
474
+ "base_url": spec.base_url, "client_model": spec.client_model,
475
+ "started_at": datetime.now(tz=UTC).isoformat(),
476
+ }, indent=1), encoding="utf-8")
477
+ emit(f"[serve-{flavour}] pid {proc.pid}, log {log_path}; waiting for {spec.base_url}")
478
+
479
+ state, models = wait_ready(spec, timeout_s=ready_timeout_s,
480
+ poll_interval_s=poll_interval_s, proc=proc,
481
+ http_client=http_client, sleep=sleep)
482
+ common: dict[str, Any] = dict(argv=tuple(argv), env=added, pid=proc.pid,
483
+ base_url=spec.base_url, client_model=spec.client_model,
484
+ models=models, log_path=log_path, pid_file=pid_file,
485
+ merged_dir=merged)
486
+ if state == "exited":
487
+ pid_file.unlink(missing_ok=True)
488
+ return done("exited", reason=(f"{flavour} exited with code {proc.poll()} before "
489
+ f"answering; log tail:\n{_tail(log_path, 1200)}"),
490
+ **common)
491
+ if state == "timeout":
492
+ return done("timeout", reason=(
493
+ f"{spec.base_url} did not list {spec.client_model!r} within {ready_timeout_s:g}s "
494
+ f"(seen: {', '.join(models) or 'nothing'}); still running as pid {proc.pid} — "
495
+ f"watch {log_path}, or `serve --to {flavour} --stop`"), **common)
496
+
497
+ swap: dict[str, str] | None = None
498
+ if register_with_mindx:
499
+ try:
500
+ from mindxtrain.deploy.api_client import swap_mindx_fallback_model
501
+
502
+ swap = swap_mindx_fallback_model(provider=flavour, model=spec.client_model,
503
+ api_url=mindx_base_url)
504
+ emit(f"[serve-{flavour}] mindX swap: {swap.get('previous', '?')} -> "
505
+ f"{swap.get('current', '?')}")
506
+ except Exception as exc: # best-effort, as push_to_ollama
507
+ emit(f"[serve-{flavour}] mindX registration failed (server still up): {exc}")
508
+ emit(f"[serve-{flavour}] ready: {spec.client_model} at {spec.base_url}/v1")
509
+ common["mindx_fallback_swap"] = swap
510
+ return done("ready", **common)
511
+ except Exception as exc: # never past the result
512
+ return done("error", reason=f"{type(exc).__name__}: {exc}")
513
+
514
+
515
+ def stop_openai_server(
516
+ flavour: Flavour,
517
+ run_dir: Path,
518
+ *,
519
+ timeout_s: float = 30.0,
520
+ poll_interval_s: float = 0.5,
521
+ sleep: Callable[[float], None] = time.sleep,
522
+ ) -> ServerStopResult:
523
+ """SIGTERM the server recorded in `<run_dir>/serve/<flavour>/server.pid` (its whole process
524
+ group — it was started in its own session) and verify it is gone. Never raises."""
525
+ pid_file = server_dir(run_dir, flavour) / "server.pid"
526
+ try:
527
+ pid = _read_pid(pid_file)
528
+ if pid is None:
529
+ return ServerStopResult("not_running", flavour, reason=f"no pid file at {pid_file}")
530
+ if not _alive(pid):
531
+ pid_file.unlink(missing_ok=True)
532
+ return ServerStopResult("not_running", flavour, pid,
533
+ reason=f"pid {pid} was not running (stale pid file removed)")
534
+ try:
535
+ os.killpg(pid, signal.SIGTERM)
536
+ except (ProcessLookupError, PermissionError):
537
+ os.kill(pid, signal.SIGTERM)
538
+ deadline = time.monotonic() + timeout_s
539
+ while _alive(pid):
540
+ if time.monotonic() >= deadline:
541
+ return ServerStopResult("still_running", flavour, pid, reason=(
542
+ f"pid {pid} still alive {timeout_s:g}s after SIGTERM; "
543
+ f"`kill -KILL -{pid}` ends its group"))
544
+ sleep(poll_interval_s)
545
+ pid_file.unlink(missing_ok=True)
546
+ return ServerStopResult("stopped", flavour, pid)
547
+ except Exception as exc:
548
+ return ServerStopResult("error", flavour, reason=f"{type(exc).__name__}: {exc}")
549
+
550
+
551
+ __all__ = [
552
+ "VLLM_LORA_RANKS",
553
+ "VLLM_QUANTIZATION",
554
+ "OpenAIServerSpec",
555
+ "ServerLaunchResult",
556
+ "ServerStopResult",
557
+ "adapter_rank",
558
+ "build_argv",
559
+ "build_env",
560
+ "default_launcher",
561
+ "detect_gpu",
562
+ "gpu_refusal",
563
+ "launch_openai_server",
564
+ "resolve_launcher",
565
+ "server_dir",
566
+ "stop_openai_server",
567
+ "wait_ready",
568
+ ]
mindxtrain/eval/imprint_bankml.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Imprint probes through bankml — reproducible, receipt-auditable, cheap on a CPU.
2
+
3
+ The canonical imprint gate is `eval.imprint.probe_recall`: transformers greedy decoding with
4
+ `repetition_penalty=1.3` and `no_repeat_ngram_size=3`. Every number in an ascent log was measured
5
+ that way, and that gate stays canonical.
6
+
7
+ This module is a **second instrument**, not a replacement. It poses the same inquiries to tags
8
+ served by `bankml serve --native` (https://github.com/cryptoAGI/bankml) over Ollama's `/api/chat`
9
+ with `temperature 0`, a fixed `seed`, `num_predict 48` and **no penalties** — bankml refuses
10
+ penalties because its sampler reproduces llama.cpp b11192's temperature / top-k / top-p / min-p
11
+ token-for-token and nothing it cannot prove. What that buys:
12
+
13
+ - **Reproducible**: the same seed and weights give the same tokens, identical to llama.cpp.
14
+ - **Auditable**: every utterance carries a `bankml_receipt` — the model's sha256, the request's
15
+ and the response's — so a score is tied to exact weights, not to a tag name.
16
+ - **Cheap**: a 135M F16 actor answers on one CPU core without torch in this process.
17
+
18
+ Because the decoding differs (no 1.3 repetition penalty, no n-gram block), a score from here is
19
+ **NOT comparable** with the canonical gate's numbers. The report says so in `comparable_with`,
20
+ `note` and the `method` tag (`<scorer>/bankml-greedy`), so the two can never be mixed silently.
21
+
22
+ bankml is reached over HTTP only (clean-room policy); nothing here imports torch.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import Any
28
+
29
+ import httpx
30
+ from pydantic import BaseModel, ConfigDict, Field
31
+
32
+ from mindxtrain.eval.imprint import ImprintReport, score_imprint
33
+ from mindxtrain.operator.backends.bankml import bankml_root_url, raise_for_bankml
34
+
35
+ NOT_COMPARABLE_NOTE = (
36
+ "bankml-greedy probe: temperature 0, fixed seed, no repetition penalty, no n-gram block. "
37
+ "NOT comparable with the canonical imprint gate (transformers greedy, repetition_penalty 1.3, "
38
+ "no_repeat_ngram_size 3); compare bankml-greedy scores only with bankml-greedy scores."
39
+ )
40
+
41
+
42
+ class BankmlDecoding(BaseModel):
43
+ """The exact decoding every probe was sent with — part of the evidence."""
44
+
45
+ model_config = ConfigDict(extra="forbid", frozen=True)
46
+
47
+ engine: str = "bankml"
48
+ endpoint: str = "/api/chat"
49
+ temperature: float = 0.0
50
+ seed: int = 0
51
+ num_predict: int = 48
52
+ penalties: str = "none (bankml refuses them; not reproduced)"
53
+
54
+
55
+ class BankmlProbe(BaseModel):
56
+ """One tag's utterances for the inquiries, each with bankml's receipt."""
57
+
58
+ model_config = ConfigDict(extra="forbid", frozen=True)
59
+
60
+ model: str
61
+ utterances: list[str]
62
+ receipts: list[dict[str, Any] | None]
63
+ model_sha256: list[str] = Field(description="distinct model_sha256 values the receipts name")
64
+
65
+
66
+ class BankmlImprintReport(BaseModel):
67
+ """An ImprintReport measured through bankml, with its decoding and receipts attached."""
68
+
69
+ model_config = ConfigDict(extra="forbid", frozen=True)
70
+
71
+ report: ImprintReport
72
+ decoding: BankmlDecoding
73
+ before: BankmlProbe
74
+ after: BankmlProbe
75
+ comparable_with: str = "bankml-greedy only"
76
+ canonical_gate: bool = False
77
+ note: str = NOT_COMPARABLE_NOTE
78
+
79
+
80
+ def probe_recall_via_bankml(
81
+ model_tag: str,
82
+ inquiries: list[str],
83
+ *,
84
+ system: str | None = None,
85
+ seed: int = 0,
86
+ num_predict: int = 48,
87
+ base_url: str | None = None,
88
+ timeout_s: float = 600.0,
89
+ transport: httpx.BaseTransport | None = None,
90
+ ) -> BankmlProbe:
91
+ """Ask `model_tag` each inquiry through bankml's `/api/chat`; keep text and receipt.
92
+
93
+ Same prompts, same decoding for every tag → a fair before/after comparison *within* this
94
+ instrument. Raises `BankmlRefusal` (HTTP 400, with bankml's reason) or `BankmlError`; it never
95
+ re-sends a refused request with altered options.
96
+ """
97
+ root = bankml_root_url(base_url)
98
+ options = {"temperature": 0.0, "seed": int(seed), "num_predict": int(num_predict)}
99
+ utterances: list[str] = []
100
+ receipts: list[dict[str, Any] | None] = []
101
+ with httpx.Client(timeout=timeout_s, transport=transport) as client:
102
+ for inquiry in inquiries:
103
+ msgs = [{"role": "user", "content": inquiry}]
104
+ if system and system.strip():
105
+ msgs.insert(0, {"role": "system", "content": system.strip()})
106
+ resp = client.post(
107
+ f"{root}/api/chat",
108
+ json={"model": model_tag, "messages": msgs, "stream": False, "options": options},
109
+ )
110
+ raise_for_bankml(resp)
111
+ body = resp.json()
112
+ utterances.append(((body.get("message") or {}).get("content") or "").strip())
113
+ rec = body.get("bankml_receipt")
114
+ receipts.append(rec if isinstance(rec, dict) else None)
115
+ shas = sorted({str(r["model_sha256"]) for r in receipts if r and r.get("model_sha256")})
116
+ return BankmlProbe(model=model_tag, utterances=utterances, receipts=receipts,
117
+ model_sha256=shas)
118
+
119
+
120
+ def imprint_via_bankml(
121
+ before_tag: str,
122
+ after_tag: str,
123
+ inquiries: list[str],
124
+ baseline: list[str],
125
+ *,
126
+ system: str | None = None,
127
+ seed: int = 0,
128
+ num_predict: int = 48,
129
+ base_url: str | None = None,
130
+ transport: httpx.BaseTransport | None = None,
131
+ ) -> BankmlImprintReport:
132
+ """Probe the base tag (before) and the imprinted tag (after) through bankml, then score with
133
+ the existing `score_imprint`. `report.method` is tagged `<scorer>/bankml-greedy`."""
134
+ kw: dict[str, Any] = {"system": system, "seed": seed, "num_predict": num_predict,
135
+ "base_url": base_url, "transport": transport}
136
+ before = probe_recall_via_bankml(before_tag, inquiries, **kw)
137
+ after = probe_recall_via_bankml(after_tag, inquiries, **kw)
138
+ report = score_imprint(inquiries, before.utterances, after.utterances,
139
+ baseline or before.utterances)
140
+ report = report.model_copy(update={"method": f"{report.method}/bankml-greedy"})
141
+ return BankmlImprintReport(
142
+ report=report,
143
+ decoding=BankmlDecoding(seed=int(seed), num_predict=int(num_predict)),
144
+ before=before,
145
+ after=after,
146
+ )
147
+
148
+
149
+ __all__ = [
150
+ "NOT_COMPARABLE_NOTE",
151
+ "BankmlDecoding",
152
+ "BankmlImprintReport",
153
+ "BankmlProbe",
154
+ "imprint_via_bankml",
155
+ "probe_recall_via_bankml",
156
+ ]
mindxtrain/eval/mei/record.py CHANGED
@@ -58,7 +58,7 @@ class InferenceEngineIdent(BaseModel):
58
 
59
  model_config = ConfigDict(extra="forbid", frozen=True)
60
 
61
- name: Literal["llama.cpp", "ollama", "vllm", "sglang", "transformers"] = Field(
62
  description="Which serving engine collected the timings.",
63
  )
64
  commit_sha: str = Field(min_length=1, description="Engine binary commit SHA or version string.")
 
58
 
59
  model_config = ConfigDict(extra="forbid", frozen=True)
60
 
61
+ name: Literal["llama.cpp", "ollama", "vllm", "sglang", "transformers", "bankml"] = Field(
62
  description="Which serving engine collected the timings.",
63
  )
64
  commit_sha: str = Field(min_length=1, description="Engine binary commit SHA or version string.")
mindxtrain/governance/panel.py CHANGED
@@ -37,10 +37,25 @@ _VERDICT_RE = re.compile(r"verdict\s*[:\-]?\s*(approve|reject|abstain)", re.IGNO
37
 
38
 
39
  def resolve_chat_base_url(base_url: str | None = None) -> str:
40
- """Resolve the OpenAI-compatible chat base URL the operator/backends use."""
 
 
 
 
 
 
41
  if base_url:
42
  return base_url.rstrip("/")
43
- for env in ("MINDXTRAIN_OPENAI_BASE_URL", "MINDXTRAIN_VLLM_BASE_URL", "MINDXTRAIN_OLLAMA_BASE_URL"):
 
 
 
 
 
 
 
 
 
44
  val = os.environ.get(env)
45
  if val:
46
  return val.rstrip("/")
 
37
 
38
 
39
  def resolve_chat_base_url(base_url: str | None = None) -> str:
40
+ """Resolve the OpenAI-compatible chat base URL the operator/backends use.
41
+
42
+ Order: an explicit `base_url`; bankml when `MINDXTRAIN_BACKEND=bankml` (its
43
+ configured URL, else its default 127.0.0.1:18093/v1); then the env chain
44
+ OPENAI -> VLLM -> OLLAMA -> BANKML (bankml only when its URL is set, and last,
45
+ so existing hosts resolve as before); else local ollama.
46
+ """
47
  if base_url:
48
  return base_url.rstrip("/")
49
+ if os.environ.get("MINDXTRAIN_BACKEND") == "bankml":
50
+ from mindxtrain.operator.backends.bankml import bankml_base_url
51
+
52
+ return bankml_base_url()
53
+ for env in (
54
+ "MINDXTRAIN_OPENAI_BASE_URL",
55
+ "MINDXTRAIN_VLLM_BASE_URL",
56
+ "MINDXTRAIN_OLLAMA_BASE_URL",
57
+ "MINDXTRAIN_BANKML_BASE_URL",
58
+ ):
59
  val = os.environ.get(env)
60
  if val:
61
  return val.rstrip("/")
mindxtrain/hf/extension.py CHANGED
@@ -137,13 +137,29 @@ def _card(repo_id: str, meta: dict[str, Any]) -> str:
137
  "the training log ships beside the weights.\n")
138
 
139
 
 
 
 
 
 
 
 
 
 
 
 
140
  def publish_generation(run_dir: Path | str, repo_id: str, *, token: str | None = None, private: bool = False,
141
  meta: dict[str, Any] | None = None, persona_system: str | None = None,
142
- include_merged: bool = True, dry_run: bool = False) -> dict[str, Any]:
 
143
  """A finished run as a model repo: merged weights at the root (if present), the LoRA delta under
144
  `adapter/`, `train.log`, a `Modelfile` for Ollama, and a card built from `meta`.
145
 
146
- `dry_run=True` reports exactly what would be uploaded and touches nothing."""
 
 
 
 
147
  run = Path(run_dir)
148
  if not run.is_dir():
149
  return {"ok": False, "reason": f"no run dir at {run}"}
@@ -170,9 +186,7 @@ def publish_generation(run_dir: Path | str, repo_id: str, *, token: str | None =
170
  except Exception:
171
  pass
172
  card = _card(repo_id, meta)
173
- modelfile = ("# ollama create <name> -f Modelfile (from this repo's directory)\nFROM .\n"
174
- + (f'SYSTEM """{persona_system}"""\n' if persona_system else "")
175
- + 'PARAMETER temperature 0.7\nPARAMETER repeat_penalty 1.3\nPARAMETER stop "<|im_end|>"\n')
176
  plan = {"repo": repo_id, "private": private, "files": [*sorted(staged), "README.md", "Modelfile"],
177
  "bytes": sum(f.stat().st_size for f in staged.values())}
178
  if dry_run:
 
137
  "the training log ships beside the weights.\n")
138
 
139
 
140
+ def published_modelfile(persona_system: str | None = None, *,
141
+ repeat_penalty: float | None = 1.3) -> str:
142
+ """The Modelfile shipped beside a published run. `repeat_penalty=None` leaves the penalty out,
143
+ so engines that refuse penalties (bankml) load the same file."""
144
+ return ("# ollama create <name> -f Modelfile (from this repo's directory)\nFROM .\n"
145
+ + (f'SYSTEM """{persona_system}"""\n' if persona_system else "")
146
+ + "PARAMETER temperature 0.7\n"
147
+ + (f"PARAMETER repeat_penalty {repeat_penalty}\n" if repeat_penalty is not None else "")
148
+ + 'PARAMETER stop "<|im_end|>"\n')
149
+
150
+
151
  def publish_generation(run_dir: Path | str, repo_id: str, *, token: str | None = None, private: bool = False,
152
  meta: dict[str, Any] | None = None, persona_system: str | None = None,
153
+ include_merged: bool = True, dry_run: bool = False,
154
+ repeat_penalty: float | None = 1.3) -> dict[str, Any]:
155
  """A finished run as a model repo: merged weights at the root (if present), the LoRA delta under
156
  `adapter/`, `train.log`, a `Modelfile` for Ollama, and a card built from `meta`.
157
 
158
+ `dry_run=True` reports exactly what would be uploaded and touches nothing.
159
+
160
+ `repeat_penalty` is written into the Modelfile (default 1.3, the imprint gate's decoding);
161
+ `None` leaves it out, so the same Modelfile also loads in engines that refuse penalties
162
+ (bankml reproduces temperature/top-k/top-p/min-p, not penalties)."""
163
  run = Path(run_dir)
164
  if not run.is_dir():
165
  return {"ok": False, "reason": f"no run dir at {run}"}
 
186
  except Exception:
187
  pass
188
  card = _card(repo_id, meta)
189
+ modelfile = published_modelfile(persona_system, repeat_penalty=repeat_penalty)
 
 
190
  plan = {"repo": repo_id, "private": private, "files": [*sorted(staged), "README.md", "Modelfile"],
191
  "bytes": sum(f.stat().st_size for f in staged.values())}
192
  if dry_run:
mindxtrain/models/registry.py CHANGED
@@ -12,7 +12,7 @@ from __future__ import annotations
12
 
13
  from abc import ABC, abstractmethod
14
  from collections.abc import AsyncIterator, Callable
15
- from typing import Literal
16
 
17
  from pydantic import BaseModel, ConfigDict, Field
18
 
@@ -43,6 +43,10 @@ class ChatResponse(BaseModel):
43
  finish_reason: Literal["stop", "length", "error"] = "stop"
44
  prompt_tokens: int = 0
45
  completion_tokens: int = 0
 
 
 
 
46
 
47
 
48
  class Backend(ABC):
@@ -155,6 +159,7 @@ from mindxtrain.models import glm51 as _glm51 # noqa: E402, F401
155
  from mindxtrain.models import mistral3 as _mistral3 # noqa: E402, F401
156
  from mindxtrain.models import phi4_mini as _phi4_mini # noqa: E402, F401
157
  from mindxtrain.models import qwen35 as _qwen35 # noqa: E402, F401
 
158
  from mindxtrain.operator.backends import ollama as _ollama # noqa: E402, F401
159
  from mindxtrain.operator.backends import openai_compat as _openai_compat # noqa: E402, F401
160
  from mindxtrain.operator.backends import vllm as _vllm # noqa: E402, F401
 
12
 
13
  from abc import ABC, abstractmethod
14
  from collections.abc import AsyncIterator, Callable
15
+ from typing import Any, Literal
16
 
17
  from pydantic import BaseModel, ConfigDict, Field
18
 
 
43
  finish_reason: Literal["stop", "length", "error"] = "stop"
44
  prompt_tokens: int = 0
45
  completion_tokens: int = 0
46
+ # Engine-issued provenance for this answer, when the engine gives one (bankml's
47
+ # `bankml_receipt`: model_sha256, request_sha256, response_sha256, engine, tokens,
48
+ # wall_ms, ...). None for backends that issue no receipt.
49
+ receipt: dict[str, Any] | None = None
50
 
51
 
52
  class Backend(ABC):
 
159
  from mindxtrain.models import mistral3 as _mistral3 # noqa: E402, F401
160
  from mindxtrain.models import phi4_mini as _phi4_mini # noqa: E402, F401
161
  from mindxtrain.models import qwen35 as _qwen35 # noqa: E402, F401
162
+ from mindxtrain.operator.backends import bankml as _bankml # noqa: E402, F401
163
  from mindxtrain.operator.backends import ollama as _ollama # noqa: E402, F401
164
  from mindxtrain.operator.backends import openai_compat as _openai_compat # noqa: E402, F401
165
  from mindxtrain.operator.backends import vllm as _vllm # noqa: E402, F401
mindxtrain/operator/app.py CHANGED
@@ -104,12 +104,45 @@ def _vllm_first_model() -> str | None:
104
  return None
105
 
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  def backend_reachable(name: str) -> bool:
108
  """Live probe for a backend by name. Used by both /health and /coach health."""
109
  if name == "ollama":
110
  return _ollama_reachable()
111
  if name == "vllm":
112
  return _vllm_reachable()
 
 
113
  # openai_compat and unknown backends: we don't have a generic probe,
114
  # so the chat-completions failure path remains the authoritative signal.
115
  return False
@@ -121,6 +154,8 @@ def backend_first_model(name: str) -> str | None:
121
  return ollama_first_model()
122
  if name == "vllm":
123
  return _vllm_first_model()
 
 
124
  return None
125
 
126
 
@@ -130,7 +165,10 @@ def resolve_backend_name() -> str:
130
  Resolution order:
131
  1. Explicit `MINDXTRAIN_BACKEND` env var (canonical).
132
  2. Legacy `AUTOMINDX_BACKEND` (back-compat with the pre-rename code).
133
- 3. Auto-detect: ollama if reachable on localhost:11434, else vllm.
 
 
 
134
  """
135
  explicit = (
136
  os.environ.get("MINDXTRAIN_BACKEND")
@@ -140,9 +178,43 @@ def resolve_backend_name() -> str:
140
  return explicit
141
  if _ollama_reachable():
142
  return "ollama"
 
 
143
  return "vllm"
144
 
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  def ollama_first_model() -> str | None:
147
  """Return the name of the first model ollama lists, or None on failure.
148
 
@@ -275,28 +347,21 @@ async def readyz() -> dict[str, object]:
275
 
276
  @app.post("/v1/chat/completions", response_model=ChatResponse)
277
  async def chat_completions(request: ChatRequest) -> ChatResponse:
 
 
278
  backend_name = resolve_backend_name()
279
- backend_kwargs: dict[str, object] = {}
280
- if backend_name == "vllm":
281
- backend_kwargs["base_url"] = os.environ.get(
282
- "MINDXTRAIN_VLLM_BASE_URL",
283
- os.environ.get("AUTOMINDX_VLLM_BASE_URL", "http://localhost:8000/v1"),
284
- )
285
- elif backend_name == "ollama":
286
- backend_kwargs["base_url"] = os.environ.get(
287
- "MINDXTRAIN_OLLAMA_BASE_URL", "http://localhost:11434/v1",
288
- )
289
- elif backend_name == "openai_compat":
290
- backend_kwargs["base_url"] = os.environ["MINDXTRAIN_OPENAI_BASE_URL"]
291
- backend_kwargs["api_key"] = os.environ.get("MINDXTRAIN_OPENAI_API_KEY", "")
292
 
293
  try:
294
- backend = build_backend(backend_name, **backend_kwargs)
295
  return await backend.chat(request)
296
  except NotImplementedError as exc:
297
  raise HTTPException(status_code=501, detail=str(exc)) from exc
298
  except KeyError as exc:
299
  raise HTTPException(status_code=400, detail=str(exc)) from exc
 
 
 
300
 
301
 
302
  @app.post("/v1/agentic")
 
104
  return None
105
 
106
 
107
+ def _bankml_reachable(timeout_s: float = 1.0) -> bool:
108
+ """Probe `bankml serve --native` at MINDXTRAIN_BANKML_BASE_URL.
109
+
110
+ Hits the server's own `GET /bankml` identity endpoint (not `/health`, which any
111
+ llama-server-shaped engine answers), so a 200 means bankml specifically is there.
112
+ """
113
+ from mindxtrain.operator.backends.bankml import bankml_root_url
114
+
115
+ try:
116
+ with httpx.Client(timeout=timeout_s) as client:
117
+ return client.get(bankml_root_url() + "/bankml").status_code == 200
118
+ except (httpx.HTTPError, OSError):
119
+ return False
120
+
121
+
122
+ def _bankml_first_model() -> str | None:
123
+ """The model id bankml lists at `/v1/models` (the resident or startup model), or None."""
124
+ from mindxtrain.operator.backends.bankml import bankml_base_url
125
+
126
+ try:
127
+ with httpx.Client(timeout=1.0) as client:
128
+ resp = client.get(bankml_base_url() + "/models")
129
+ if resp.status_code != 200:
130
+ return None
131
+ models = resp.json().get("data", [])
132
+ first = models[0] if models else None
133
+ return first.get("id") if isinstance(first, dict) else None
134
+ except (httpx.HTTPError, OSError, ValueError, IndexError):
135
+ return None
136
+
137
+
138
  def backend_reachable(name: str) -> bool:
139
  """Live probe for a backend by name. Used by both /health and /coach health."""
140
  if name == "ollama":
141
  return _ollama_reachable()
142
  if name == "vllm":
143
  return _vllm_reachable()
144
+ if name == "bankml":
145
+ return _bankml_reachable()
146
  # openai_compat and unknown backends: we don't have a generic probe,
147
  # so the chat-completions failure path remains the authoritative signal.
148
  return False
 
154
  return ollama_first_model()
155
  if name == "vllm":
156
  return _vllm_first_model()
157
+ if name == "bankml":
158
+ return _bankml_first_model()
159
  return None
160
 
161
 
 
165
  Resolution order:
166
  1. Explicit `MINDXTRAIN_BACKEND` env var (canonical).
167
  2. Legacy `AUTOMINDX_BACKEND` (back-compat with the pre-rename code).
168
+ 3. Auto-detect: ollama if reachable on localhost:11434; else bankml if
169
+ `bankml serve` answers on its port *and* vLLM does not; else vllm.
170
+ bankml comes after ollama and vllm, so a host that ran ollama or vllm
171
+ before bankml existed resolves exactly as it did.
172
  """
173
  explicit = (
174
  os.environ.get("MINDXTRAIN_BACKEND")
 
178
  return explicit
179
  if _ollama_reachable():
180
  return "ollama"
181
+ if _bankml_reachable() and not _vllm_reachable():
182
+ return "bankml"
183
  return "vllm"
184
 
185
 
186
+ def backend_kwargs(name: str, *, strict: bool = False) -> dict[str, object]:
187
+ """Constructor kwargs (base URL, key) for the backend registered as `name`.
188
+
189
+ One place for the per-backend env lookups the operator chat route and the Coach
190
+ chat stream both need. `strict=True` keeps the operator's original contract that
191
+ `openai_compat` without `MINDXTRAIN_OPENAI_BASE_URL` is a configuration error
192
+ (KeyError); the Coach passes `strict=False` and gets an empty URL instead.
193
+ """
194
+ kwargs: dict[str, object] = {}
195
+ if name == "vllm":
196
+ kwargs["base_url"] = os.environ.get(
197
+ "MINDXTRAIN_VLLM_BASE_URL",
198
+ os.environ.get("AUTOMINDX_VLLM_BASE_URL", "http://localhost:8000/v1"),
199
+ )
200
+ elif name == "ollama":
201
+ kwargs["base_url"] = os.environ.get(
202
+ "MINDXTRAIN_OLLAMA_BASE_URL", "http://localhost:11434/v1",
203
+ )
204
+ elif name == "bankml":
205
+ from mindxtrain.operator.backends.bankml import bankml_base_url
206
+
207
+ kwargs["base_url"] = bankml_base_url()
208
+ elif name == "openai_compat":
209
+ kwargs["base_url"] = (
210
+ os.environ["MINDXTRAIN_OPENAI_BASE_URL"]
211
+ if strict
212
+ else os.environ.get("MINDXTRAIN_OPENAI_BASE_URL", "")
213
+ )
214
+ kwargs["api_key"] = os.environ.get("MINDXTRAIN_OPENAI_API_KEY", "")
215
+ return kwargs
216
+
217
+
218
  def ollama_first_model() -> str | None:
219
  """Return the name of the first model ollama lists, or None on failure.
220
 
 
347
 
348
  @app.post("/v1/chat/completions", response_model=ChatResponse)
349
  async def chat_completions(request: ChatRequest) -> ChatResponse:
350
+ from mindxtrain.operator.backends.bankml import BankmlRefusal
351
+
352
  backend_name = resolve_backend_name()
353
+ kwargs = backend_kwargs(backend_name, strict=True)
 
 
 
 
 
 
 
 
 
 
 
 
354
 
355
  try:
356
+ backend = build_backend(backend_name, **kwargs)
357
  return await backend.chat(request)
358
  except NotImplementedError as exc:
359
  raise HTTPException(status_code=501, detail=str(exc)) from exc
360
  except KeyError as exc:
361
  raise HTTPException(status_code=400, detail=str(exc)) from exc
362
+ except BankmlRefusal as exc:
363
+ # bankml's reason, passed through verbatim; never retried with altered params.
364
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
365
 
366
 
367
  @app.post("/v1/agentic")
mindxtrain/operator/backends/bankml.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """bankml backend — the verified CPU engine, reached over HTTP only.
2
+
3
+ bankml (https://github.com/cryptoAGI/bankml, Apache-2.0 OR MIT) is a zero-dependency Rust
4
+ runtime whose `bankml serve --native` answers OpenAI `/v1/chat/completions` and Ollama `/api/*`
5
+ from its own forward pass, token-identical to llama.cpp b11192, on 127.0.0.1:18093 by default.
6
+ mindXtrain never vendors it: this module speaks its documented wire protocol and nothing else
7
+ (clean-room policy, CLAUDE.md).
8
+
9
+ What this backend adds over `openai_compat`:
10
+
11
+ - **Receipts.** Every bankml answer carries a `bankml_receipt` (model_sha256, request_sha256,
12
+ response_sha256, engine, tokens, wall_ms, ...). Non-streamed it is a top-level object and lands
13
+ in `ChatResponse.receipt`; streamed it arrives as one extra `data: {"bankml_receipt": ...}`
14
+ event before `data: [DONE]`. Either way the latest receipt is also kept on `last_receipt`.
15
+ - **Refusals are typed, never retried.** bankml answers HTTP 400 with a plain-text reason when a
16
+ request asks for something its verified forward pass does not reproduce (repeat / presence /
17
+ frequency penalties, mirostat, typical_p, tools, an unknown architecture, Q8_0 / Q4_K / BF16).
18
+ That becomes `BankmlRefusal(reason)`. The request is never re-sent with altered parameters:
19
+ a changed request would be a different, unreceipted question.
20
+
21
+ Env: `MINDXTRAIN_BANKML_BASE_URL` (default `http://127.0.0.1:18093/v1`).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ from collections.abc import AsyncIterator
29
+ from typing import Any
30
+
31
+ import httpx
32
+
33
+ from mindxtrain.models.registry import ChatRequest, ChatResponse, register_backend
34
+ from mindxtrain.operator.backends.openai_compat import OpenAICompatBackend
35
+
36
+ DEFAULT_BANKML_BASE_URL = "http://127.0.0.1:18093/v1"
37
+
38
+
39
+ def bankml_base_url() -> str:
40
+ """The configured bankml OpenAI base URL (`.../v1`)."""
41
+ return os.environ.get("MINDXTRAIN_BANKML_BASE_URL", DEFAULT_BANKML_BASE_URL).rstrip("/")
42
+
43
+
44
+ def bankml_root_url(base_url: str | None = None) -> str:
45
+ """The server root (no `/v1`) — where `/health`, `/bankml` and `/api/*` live."""
46
+ return (base_url or bankml_base_url()).rstrip("/").removesuffix("/v1")
47
+
48
+
49
+ class BankmlError(RuntimeError):
50
+ """bankml answered, but not with an answer (an engine error, a broken stream)."""
51
+
52
+ label = "error"
53
+
54
+ def __init__(self, reason: str, status_code: int = 0) -> None:
55
+ self.reason = reason.strip()
56
+ self.status_code = status_code
57
+ super().__init__(f"bankml {self.label} ({status_code}): {self.reason}")
58
+
59
+
60
+ class BankmlRefusal(BankmlError):
61
+ """bankml refused the request (HTTP 400) and said why.
62
+
63
+ The reason is bankml's own text, e.g. ``mirostat: not reproduced; ...``. Callers must not
64
+ retry with parameters stripped or altered — change the request deliberately instead.
65
+ """
66
+
67
+ label = "refused"
68
+
69
+ def __init__(self, reason: str, status_code: int = 400) -> None:
70
+ super().__init__(reason, status_code)
71
+
72
+
73
+ def refusal_reason(resp: httpx.Response) -> str:
74
+ """bankml's reason from an error body: `/v1` sends text/plain, `/api` sends `{"error": ...}`."""
75
+ text = resp.text or ""
76
+ try:
77
+ body = json.loads(text)
78
+ except ValueError:
79
+ return text.strip() or f"HTTP {resp.status_code}"
80
+ if isinstance(body, dict) and body.get("error"):
81
+ err = body["error"]
82
+ return str(err.get("message", err) if isinstance(err, dict) else err)
83
+ return text.strip() or f"HTTP {resp.status_code}"
84
+
85
+
86
+ def raise_for_bankml(resp: httpx.Response) -> None:
87
+ """400 → `BankmlRefusal`; any other non-2xx → `BankmlError`."""
88
+ if resp.status_code == 400:
89
+ raise BankmlRefusal(refusal_reason(resp))
90
+ if resp.status_code >= 400:
91
+ raise BankmlError(refusal_reason(resp), resp.status_code)
92
+
93
+
94
+ @register_backend("bankml")
95
+ class BankmlBackend(OpenAICompatBackend):
96
+ """OpenAI-compatible client for `bankml serve --native`, keeping receipts and refusals."""
97
+
98
+ name = "bankml"
99
+
100
+ def __init__(
101
+ self,
102
+ base_url: str | None = None,
103
+ timeout_s: float = 300.0,
104
+ *,
105
+ seed: int | None = None,
106
+ transport: httpx.AsyncBaseTransport | None = None,
107
+ ) -> None:
108
+ super().__init__(base_url=base_url or bankml_base_url(), api_key=None, timeout_s=timeout_s)
109
+ # `api_key or env` in the parent would pick up an OpenAI key; bankml is loopback, keyless.
110
+ self.api_key = ""
111
+ self.seed = seed
112
+ self._transport = transport
113
+ self.last_receipt: dict[str, Any] | None = None
114
+
115
+ def _client(self) -> httpx.AsyncClient:
116
+ return httpx.AsyncClient(timeout=self.timeout_s, transport=self._transport)
117
+
118
+ def _payload(self, request: ChatRequest, *, stream: bool) -> dict[str, object]:
119
+ payload = super()._payload(request, stream=stream)
120
+ if self.seed is not None:
121
+ payload["seed"] = self.seed
122
+ return payload
123
+
124
+ async def chat(self, request: ChatRequest) -> ChatResponse:
125
+ async with self._client() as client:
126
+ resp = await client.post(
127
+ f"{self.base_url}/chat/completions",
128
+ json=self._payload(request, stream=False),
129
+ headers=self._headers(),
130
+ )
131
+ raise_for_bankml(resp)
132
+ data = resp.json()
133
+ choice = (data.get("choices") or [{}])[0]
134
+ usage = data.get("usage") or {}
135
+ receipt = data.get("bankml_receipt")
136
+ self.last_receipt = receipt if isinstance(receipt, dict) else None
137
+ finish = choice.get("finish_reason") or "stop"
138
+ return ChatResponse(
139
+ model=data.get("model", request.model),
140
+ content=(choice.get("message") or {}).get("content", "") or "",
141
+ finish_reason=finish if finish in ("stop", "length", "error") else "stop",
142
+ prompt_tokens=int(usage.get("prompt_tokens", 0)),
143
+ completion_tokens=int(usage.get("completion_tokens", 0)),
144
+ receipt=self.last_receipt,
145
+ )
146
+
147
+ async def stream_chat(self, request: ChatRequest) -> AsyncIterator[str]:
148
+ self.last_receipt = None
149
+
150
+ async def _gen() -> AsyncIterator[str]:
151
+ async with self._client() as client:
152
+ async with client.stream(
153
+ "POST",
154
+ f"{self.base_url}/chat/completions",
155
+ json=self._payload(request, stream=True),
156
+ headers=self._headers(),
157
+ ) as resp:
158
+ if resp.status_code >= 400:
159
+ await resp.aread()
160
+ raise_for_bankml(resp)
161
+ async for raw in resp.aiter_lines():
162
+ if not raw or not raw.startswith("data:"):
163
+ continue
164
+ data = raw[5:].strip()
165
+ if data == "[DONE]":
166
+ return
167
+ try:
168
+ chunk = json.loads(data)
169
+ except json.JSONDecodeError:
170
+ continue
171
+ if not isinstance(chunk, dict):
172
+ continue
173
+ if "bankml_receipt" in chunk:
174
+ rec = chunk["bankml_receipt"]
175
+ self.last_receipt = rec if isinstance(rec, dict) else None
176
+ continue
177
+ if "error" in chunk:
178
+ # bankml stops a stream it cannot finish with `data: {"error": ...}`
179
+ raise BankmlError(str(chunk["error"]), resp.status_code)
180
+ delta = (chunk.get("choices") or [{}])[0].get("delta", {})
181
+ token = delta.get("content")
182
+ if token:
183
+ yield token
184
+
185
+ return _gen()
186
+
187
+
188
+ __all__ = [
189
+ "DEFAULT_BANKML_BASE_URL",
190
+ "BankmlBackend",
191
+ "BankmlError",
192
+ "BankmlRefusal",
193
+ "bankml_base_url",
194
+ "bankml_root_url",
195
+ "raise_for_bankml",
196
+ "refusal_reason",
197
+ ]
mindxtrain/operator/coach/api.py CHANGED
@@ -1081,23 +1081,12 @@ async def api_models() -> dict[str, Any]:
1081
 
1082
 
1083
  def _resolve_chat_backend() -> Any:
1084
- """Build the active chat backend (ollama / vllm / openai_compat)."""
1085
  from mindxtrain.models.registry import build_backend
1086
- from mindxtrain.operator.app import resolve_backend_name
1087
 
1088
  name = resolve_backend_name()
1089
- kwargs: dict[str, Any] = {}
1090
- if name == "vllm":
1091
- kwargs["base_url"] = os.environ.get(
1092
- "MINDXTRAIN_VLLM_BASE_URL",
1093
- os.environ.get("AUTOMINDX_VLLM_BASE_URL", "http://localhost:8000/v1"),
1094
- )
1095
- elif name == "ollama":
1096
- kwargs["base_url"] = os.environ.get("MINDXTRAIN_OLLAMA_BASE_URL", "http://localhost:11434/v1")
1097
- elif name == "openai_compat":
1098
- kwargs["base_url"] = os.environ.get("MINDXTRAIN_OPENAI_BASE_URL", "")
1099
- kwargs["api_key"] = os.environ.get("MINDXTRAIN_OPENAI_API_KEY", "")
1100
- return build_backend(name, **kwargs)
1101
 
1102
 
1103
  @router.post("/api/chat/stream")
 
1081
 
1082
 
1083
  def _resolve_chat_backend() -> Any:
1084
+ """Build the active chat backend (ollama / vllm / bankml / openai_compat)."""
1085
  from mindxtrain.models.registry import build_backend
1086
+ from mindxtrain.operator.app import backend_kwargs, resolve_backend_name
1087
 
1088
  name = resolve_backend_name()
1089
+ return build_backend(name, **backend_kwargs(name))
 
 
 
 
 
 
 
 
 
 
 
1090
 
1091
 
1092
  @router.post("/api/chat/stream")
mindxtrain/ui/app.py CHANGED
@@ -241,7 +241,7 @@ def build() -> gr.Blocks:
241
  with gr.Row():
242
  s_cfg = gr.Textbox(value="run.yaml", label="config", scale=2)
243
  s_ckpt = gr.Textbox(value="", label="checkpoint", scale=2)
244
- s_to = gr.Radio(["ollama", "vllm"], value="ollama", label="to", scale=1)
245
  s_tag = gr.Textbox(value="", label="tag", scale=1)
246
  s_btn = gr.Button("serve", variant="primary", scale=1)
247
  with gr.Group(visible=False) as adv_serve:
 
241
  with gr.Row():
242
  s_cfg = gr.Textbox(value="run.yaml", label="config", scale=2)
243
  s_ckpt = gr.Textbox(value="", label="checkpoint", scale=2)
244
+ s_to = gr.Radio(["ollama", "bankml", "vllm", "sglang"], value="ollama", label="to", scale=1)
245
  s_tag = gr.Textbox(value="", label="tag", scale=1)
246
  s_btn = gr.Button("serve", variant="primary", scale=1)
247
  with gr.Group(visible=False) as adv_serve:
mindxtrain/ui/console.py CHANGED
@@ -36,6 +36,31 @@ DEFAULTS: dict[str, Any] = {
36
  GATE_DECODING: dict[str, Any] = {"temperature": 0.0, "repeat_penalty": 1.3, "top_p": 1.0, "top_k": 0}
37
 
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  def models(host: str = "", timeout: float = 4.0) -> list[str]:
40
  """Tags the daemon is serving, local first. Empty when it is down — never an exception."""
41
  try:
@@ -95,10 +120,11 @@ def unreachable(host: str = "") -> str:
95
  def chat(messages: list[dict[str, str]], model: str, *, options: dict[str, Any] | None = None,
96
  keep_alive: str = "10m", host: str = "", timeout: float = 900.0) -> Iterator[tuple[str, dict[str, Any]]]:
97
  """Stream `(text_so_far, stats)`. `stats` is empty until the final object, which carries the real
98
- counts. Options are passed through verbatim; the engine implements them, so none are dropped."""
 
99
  host = (host or HOST).rstrip("/")
100
  body = {"model": model, "messages": messages, "stream": True, "keep_alive": keep_alive,
101
- "options": {k: v for k, v in (options or {}).items() if v not in (None, "", [])}}
102
  req = urllib.request.Request(f"{host}/api/chat", data=json.dumps(body).encode(), method="POST",
103
  headers={"Content-Type": "application/json"})
104
  acc = ""
 
36
  GATE_DECODING: dict[str, Any] = {"temperature": 0.0, "repeat_penalty": 1.3, "top_p": 1.0, "top_k": 0}
37
 
38
 
39
+ # Options whose value here is also the engine's own default when the key is absent. Sending them
40
+ # changes nothing for Ollama, but an engine that refuses penalties / mirostat outright (bankml:
41
+ # "not reproduced") would refuse the whole request — so `chat` leaves them out at these values.
42
+ # A deliberate non-default value is always sent; the engine then honours or refuses it, visibly.
43
+ ENGINE_DEFAULTS: dict[str, Any] = {
44
+ "repeat_penalty": 1.1, "presence_penalty": 0.0, "frequency_penalty": 0.0, "typical_p": 1.0,
45
+ "mirostat": 0,
46
+ }
47
+
48
+
49
+ def wire_options(options: dict[str, Any] | None) -> dict[str, Any]:
50
+ """The options actually sent: empties dropped, and penalty/mirostat keys left at the engine's
51
+ own default omitted (mirostat's tau/eta only matter, and are only sent, when mirostat is on)."""
52
+ opts = {k: v for k, v in (options or {}).items() if v not in (None, "", [])}
53
+ miro_on = bool(opts.get("mirostat"))
54
+ out: dict[str, Any] = {}
55
+ for k, v in opts.items():
56
+ if k in ("mirostat_tau", "mirostat_eta") and not miro_on:
57
+ continue
58
+ if k in ENGINE_DEFAULTS and v == ENGINE_DEFAULTS[k]:
59
+ continue
60
+ out[k] = v
61
+ return out
62
+
63
+
64
  def models(host: str = "", timeout: float = 4.0) -> list[str]:
65
  """Tags the daemon is serving, local first. Empty when it is down — never an exception."""
66
  try:
 
120
  def chat(messages: list[dict[str, str]], model: str, *, options: dict[str, Any] | None = None,
121
  keep_alive: str = "10m", host: str = "", timeout: float = 900.0) -> Iterator[tuple[str, dict[str, Any]]]:
122
  """Stream `(text_so_far, stats)`. `stats` is empty until the final object, which carries the real
123
+ counts. Options are passed through as `wire_options` leaves them: a penalty or mirostat key at
124
+ the engine's own default is omitted (identical for Ollama, and accepted by bankml)."""
125
  host = (host or HOST).rstrip("/")
126
  body = {"model": model, "messages": messages, "stream": True, "keep_alive": keep_alive,
127
+ "options": wire_options(options)}
128
  req = urllib.request.Request(f"{host}/api/chat", data=json.dumps(body).encode(), method="POST",
129
  headers={"Content-Type": "application/json"})
130
  acc = ""
tests/conftest.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Suite-wide hermeticity for the operator's backend auto-detect.
2
+
3
+ `resolve_backend_name()` probes `bankml serve` on 127.0.0.1:18093 when ollama is not
4
+ reachable. A developer box that happens to run bankml would otherwise change what the
5
+ auto-detect tests resolve to, so the probe is pinned to "absent" for every test; the
6
+ bankml tests that exercise the probe itself monkeypatch it back explicitly.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import pytest
12
+
13
+
14
+ @pytest.fixture(autouse=True)
15
+ def _no_live_bankml_probe(monkeypatch: pytest.MonkeyPatch) -> None:
16
+ from mindxtrain.operator import app as operator_app
17
+
18
+ monkeypatch.setattr(operator_app, "_bankml_reachable", lambda *a, **k: False)
tests/test_bankml_backend.py ADDED
@@ -0,0 +1,341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """bankml operator backend: registry, env, receipts, typed refusals, SSE streaming, wiring.
2
+
3
+ No network: every HTTP exchange goes through httpx.MockTransport.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import json
10
+
11
+ import httpx
12
+ import pytest
13
+ from fastapi.testclient import TestClient
14
+
15
+ from mindxtrain.governance import panel as P
16
+ from mindxtrain.models.registry import ChatMessage, ChatRequest, build_backend, list_backends
17
+ from mindxtrain.operator import app as operator_app
18
+ from mindxtrain.operator.backends.bankml import (
19
+ DEFAULT_BANKML_BASE_URL,
20
+ BankmlBackend,
21
+ BankmlError,
22
+ BankmlRefusal,
23
+ bankml_root_url,
24
+ )
25
+
26
+ _REAL_BANKML_REACHABLE = operator_app._bankml_reachable # captured before conftest patches it
27
+
28
+ RECEIPT = {
29
+ "bankml": "0.3.4",
30
+ "engine": "native",
31
+ "model_sha256": "6b64c748d96ad26fd72402299bd27b2ae82f489bd0469498dd18eb6054058266",
32
+ "guard": "play",
33
+ "prompt_tokens": 12,
34
+ "completion_tokens": 3,
35
+ "ttft_ms": 40,
36
+ "wall_ms": 90,
37
+ "response_sha256": "a" * 64,
38
+ "request_sha256": "b" * 64,
39
+ "signed": False,
40
+ }
41
+
42
+
43
+ def _req(stream: bool = False) -> ChatRequest:
44
+ return ChatRequest(
45
+ model="mindx-gen39",
46
+ messages=[ChatMessage(role="user", content="Who are you?")],
47
+ temperature=0.0,
48
+ max_tokens=16,
49
+ stream=stream,
50
+ )
51
+
52
+
53
+ def _completion() -> dict[str, object]:
54
+ return {
55
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": "I am mindX."},
56
+ "finish_reason": "stop"}],
57
+ "model": "mindx-gen39-F16.gguf",
58
+ "object": "chat.completion",
59
+ "usage": {"completion_tokens": 3, "prompt_tokens": 12, "total_tokens": 15},
60
+ "bankml_receipt": RECEIPT,
61
+ }
62
+
63
+
64
+ # ---- registry + env -----------------------------------------------------------
65
+
66
+
67
+ def test_registered_and_built_by_name() -> None:
68
+ assert "bankml" in list_backends()
69
+ assert isinstance(build_backend("bankml"), BankmlBackend)
70
+
71
+
72
+ def test_default_and_env_base_url(monkeypatch: pytest.MonkeyPatch) -> None:
73
+ monkeypatch.delenv("MINDXTRAIN_BANKML_BASE_URL", raising=False)
74
+ assert BankmlBackend().base_url == DEFAULT_BANKML_BASE_URL
75
+ monkeypatch.setenv("MINDXTRAIN_BANKML_BASE_URL", "http://127.0.0.1:9999/v1/")
76
+ b = BankmlBackend()
77
+ assert b.base_url == "http://127.0.0.1:9999/v1"
78
+ assert bankml_root_url() == "http://127.0.0.1:9999"
79
+
80
+
81
+ def test_never_sends_an_openai_key(monkeypatch: pytest.MonkeyPatch) -> None:
82
+ monkeypatch.setenv("MINDXTRAIN_OPENAI_API_KEY", "sk-should-not-leak")
83
+ assert "Authorization" not in BankmlBackend()._headers()
84
+
85
+
86
+ # ---- chat ---------------------------------------------------------------------
87
+
88
+
89
+ def test_chat_keeps_the_receipt() -> None:
90
+ seen: dict[str, object] = {}
91
+
92
+ def handler(request: httpx.Request) -> httpx.Response:
93
+ seen["url"] = str(request.url)
94
+ seen["body"] = json.loads(request.content)
95
+ return httpx.Response(200, json=_completion())
96
+
97
+ b = BankmlBackend(seed=7, transport=httpx.MockTransport(handler))
98
+ resp = asyncio.run(b.chat(_req()))
99
+ assert seen["url"] == "http://127.0.0.1:18093/v1/chat/completions"
100
+ body = seen["body"]
101
+ assert isinstance(body, dict)
102
+ assert body["seed"] == 7 and body["temperature"] == 0.0 and body["stream"] is False
103
+ assert resp.content == "I am mindX."
104
+ assert resp.prompt_tokens == 12 and resp.completion_tokens == 3
105
+ assert resp.receipt == RECEIPT
106
+ assert b.last_receipt == RECEIPT
107
+
108
+
109
+ def test_chat_400_is_a_typed_refusal_and_is_not_retried() -> None:
110
+ calls = {"n": 0}
111
+
112
+ def handler(request: httpx.Request) -> httpx.Response:
113
+ calls["n"] += 1
114
+ return httpx.Response(400, text="mirostat: not reproduced; bankML reproduces llama.cpp's "
115
+ "top-k, top-p, min-p and temperature")
116
+
117
+ b = BankmlBackend(transport=httpx.MockTransport(handler))
118
+ with pytest.raises(BankmlRefusal) as info:
119
+ asyncio.run(b.chat(_req()))
120
+ assert info.value.status_code == 400
121
+ assert info.value.reason.startswith("mirostat: not reproduced")
122
+ assert "bankml refused (400)" in str(info.value)
123
+ assert calls["n"] == 1
124
+
125
+
126
+ def test_ollama_style_json_error_body_is_read() -> None:
127
+ def handler(request: httpx.Request) -> httpx.Response:
128
+ return httpx.Response(400, json={"error": "tools: tool calling ... not reproduced yet"})
129
+
130
+ b = BankmlBackend(transport=httpx.MockTransport(handler))
131
+ with pytest.raises(BankmlRefusal) as info:
132
+ asyncio.run(b.chat(_req()))
133
+ assert info.value.reason.startswith("tools:")
134
+
135
+
136
+ def test_non_400_errors_are_bankml_errors_not_refusals() -> None:
137
+ def handler(request: httpx.Request) -> httpx.Response:
138
+ return httpx.Response(503, text="model changed since bankml verified it")
139
+
140
+ b = BankmlBackend(transport=httpx.MockTransport(handler))
141
+ with pytest.raises(BankmlError) as info:
142
+ asyncio.run(b.chat(_req()))
143
+ assert not isinstance(info.value, BankmlRefusal)
144
+ assert info.value.status_code == 503
145
+
146
+
147
+ # ---- stream -------------------------------------------------------------------
148
+
149
+
150
+ def _sse(*events: str) -> bytes:
151
+ return "".join(f"data: {e}\n\n" for e in events).encode()
152
+
153
+
154
+ def _collect(b: BankmlBackend) -> list[str]:
155
+ async def run() -> list[str]:
156
+ out: list[str] = []
157
+ async for tok in await b.stream_chat(_req(stream=True)):
158
+ out.append(tok)
159
+ return out
160
+
161
+ return asyncio.run(run())
162
+
163
+
164
+ def test_stream_yields_tokens_and_keeps_the_final_receipt() -> None:
165
+ def chunk(text: str) -> str:
166
+ return json.dumps({"choices": [{"index": 0, "delta": {"content": text},
167
+ "finish_reason": None}]})
168
+
169
+ final = json.dumps({"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
170
+ "usage": {"completion_tokens": 2, "prompt_tokens": 12}})
171
+ body = _sse(chunk("I am"), chunk(" mindX."), final,
172
+ json.dumps({"bankml_receipt": RECEIPT}), "[DONE]")
173
+
174
+ def handler(request: httpx.Request) -> httpx.Response:
175
+ assert json.loads(request.content)["stream"] is True
176
+ return httpx.Response(200, content=body, headers={"content-type": "text/event-stream"})
177
+
178
+ b = BankmlBackend(transport=httpx.MockTransport(handler))
179
+ assert _collect(b) == ["I am", " mindX."]
180
+ assert b.last_receipt == RECEIPT
181
+
182
+
183
+ def test_stream_400_raises_refusal() -> None:
184
+ def handler(request: httpx.Request) -> httpx.Response:
185
+ return httpx.Response(400, text="repeat_penalty: not reproduced")
186
+
187
+ b = BankmlBackend(transport=httpx.MockTransport(handler))
188
+ with pytest.raises(BankmlRefusal):
189
+ _collect(b)
190
+
191
+
192
+ def test_stream_error_event_raises() -> None:
193
+ def handler(request: httpx.Request) -> httpx.Response:
194
+ return httpx.Response(200, content=_sse(json.dumps({"error": "context full"})))
195
+
196
+ b = BankmlBackend(transport=httpx.MockTransport(handler))
197
+ with pytest.raises(BankmlError, match="context full"):
198
+ _collect(b)
199
+
200
+
201
+ # ---- operator wiring ---------------------------------------------------------
202
+
203
+
204
+ def test_backend_kwargs_for_bankml(monkeypatch: pytest.MonkeyPatch) -> None:
205
+ monkeypatch.setenv("MINDXTRAIN_BANKML_BASE_URL", "http://127.0.0.1:18100/v1")
206
+ assert operator_app.backend_kwargs("bankml") == {"base_url": "http://127.0.0.1:18100/v1"}
207
+
208
+
209
+ def test_backend_kwargs_openai_strict_contract(monkeypatch: pytest.MonkeyPatch) -> None:
210
+ monkeypatch.delenv("MINDXTRAIN_OPENAI_BASE_URL", raising=False)
211
+ with pytest.raises(KeyError):
212
+ operator_app.backend_kwargs("openai_compat", strict=True)
213
+ assert operator_app.backend_kwargs("openai_compat")["base_url"] == ""
214
+
215
+
216
+ def test_bankml_probe_hits_the_identity_endpoint(monkeypatch: pytest.MonkeyPatch) -> None:
217
+ seen: list[str] = []
218
+
219
+ class _Client:
220
+ def __init__(self, *a: object, **k: object) -> None: ...
221
+ def __enter__(self) -> _Client:
222
+ return self
223
+ def __exit__(self, *a: object) -> None: ...
224
+ def get(self, url: str) -> httpx.Response:
225
+ seen.append(url)
226
+ return httpx.Response(200, json={"bankml": "0.3.4"})
227
+
228
+ monkeypatch.delenv("MINDXTRAIN_BANKML_BASE_URL", raising=False)
229
+ monkeypatch.setattr(operator_app.httpx, "Client", _Client)
230
+ assert _REAL_BANKML_REACHABLE() is True
231
+ assert seen == ["http://127.0.0.1:18093/bankml"]
232
+
233
+
234
+ def test_bankml_probe_false_on_connection_error(monkeypatch: pytest.MonkeyPatch) -> None:
235
+ class _Client:
236
+ def __init__(self, *a: object, **k: object) -> None: ...
237
+ def __enter__(self) -> _Client:
238
+ return self
239
+ def __exit__(self, *a: object) -> None: ...
240
+ def get(self, url: str) -> httpx.Response:
241
+ raise httpx.ConnectError("refused")
242
+
243
+ monkeypatch.setattr(operator_app.httpx, "Client", _Client)
244
+ assert _REAL_BANKML_REACHABLE() is False
245
+
246
+
247
+ def test_autodetect_order(monkeypatch: pytest.MonkeyPatch) -> None:
248
+ monkeypatch.delenv("MINDXTRAIN_BACKEND", raising=False)
249
+ monkeypatch.delenv("AUTOMINDX_BACKEND", raising=False)
250
+ monkeypatch.setattr(operator_app, "_bankml_reachable", lambda: True)
251
+ # ollama first, always
252
+ monkeypatch.setattr(operator_app, "_ollama_reachable", lambda: True)
253
+ monkeypatch.setattr(operator_app, "_vllm_reachable", lambda: False)
254
+ assert operator_app.resolve_backend_name() == "ollama"
255
+ # vllm before bankml
256
+ monkeypatch.setattr(operator_app, "_ollama_reachable", lambda: False)
257
+ monkeypatch.setattr(operator_app, "_vllm_reachable", lambda: True)
258
+ assert operator_app.resolve_backend_name() == "vllm"
259
+ # bankml only when it is the one answering
260
+ monkeypatch.setattr(operator_app, "_vllm_reachable", lambda: False)
261
+ assert operator_app.resolve_backend_name() == "bankml"
262
+ # nothing answering: unchanged fallback
263
+ monkeypatch.setattr(operator_app, "_bankml_reachable", lambda: False)
264
+ assert operator_app.resolve_backend_name() == "vllm"
265
+
266
+
267
+ def test_health_dispatch(monkeypatch: pytest.MonkeyPatch) -> None:
268
+ monkeypatch.setattr(operator_app, "_bankml_reachable", lambda: True)
269
+ monkeypatch.setattr(operator_app, "_bankml_first_model", lambda: "mindx-gen39-F16.gguf")
270
+ assert operator_app.backend_reachable("bankml") is True
271
+ assert operator_app.backend_first_model("bankml") == "mindx-gen39-F16.gguf"
272
+
273
+
274
+ def test_operator_route_passes_refusal_through_as_400(monkeypatch: pytest.MonkeyPatch) -> None:
275
+ monkeypatch.setenv("MINDXTRAIN_BACKEND", "bankml")
276
+
277
+ async def refuse(self: BankmlBackend, request: ChatRequest) -> object:
278
+ raise BankmlRefusal("repeat_penalty: not reproduced")
279
+
280
+ monkeypatch.setattr(BankmlBackend, "chat", refuse)
281
+ client = TestClient(operator_app.app)
282
+ r = client.post("/v1/chat/completions", json={
283
+ "model": "mindx-gen39", "messages": [{"role": "user", "content": "hi"}]})
284
+ assert r.status_code == 400
285
+ assert "repeat_penalty: not reproduced" in r.json()["detail"]
286
+
287
+
288
+ def test_operator_route_returns_receipt(monkeypatch: pytest.MonkeyPatch) -> None:
289
+ monkeypatch.setenv("MINDXTRAIN_BACKEND", "bankml")
290
+ real_client = httpx.AsyncClient
291
+ transport = httpx.MockTransport(lambda req: httpx.Response(200, json=_completion()))
292
+ monkeypatch.setattr(
293
+ "mindxtrain.operator.backends.bankml.httpx.AsyncClient",
294
+ lambda **kw: real_client(timeout=kw.get("timeout"), transport=transport),
295
+ )
296
+ client = TestClient(operator_app.app)
297
+ r = client.post("/v1/chat/completions", json={
298
+ "model": "mindx-gen39", "messages": [{"role": "user", "content": "hi"}]})
299
+ assert r.status_code == 200
300
+ assert r.json()["receipt"]["model_sha256"] == RECEIPT["model_sha256"]
301
+
302
+
303
+ # ---- governance panel env chain -----------------------------------------------
304
+
305
+
306
+ def test_panel_bankml_url_only_when_set_and_last(monkeypatch: pytest.MonkeyPatch) -> None:
307
+ for e in ("MINDXTRAIN_OPENAI_BASE_URL", "MINDXTRAIN_VLLM_BASE_URL",
308
+ "MINDXTRAIN_OLLAMA_BASE_URL", "MINDXTRAIN_BANKML_BASE_URL", "MINDXTRAIN_BACKEND"):
309
+ monkeypatch.delenv(e, raising=False)
310
+ assert P.resolve_chat_base_url() == "http://localhost:11434/v1"
311
+ monkeypatch.setenv("MINDXTRAIN_BANKML_BASE_URL", "http://127.0.0.1:18093/v1")
312
+ assert P.resolve_chat_base_url() == "http://127.0.0.1:18093/v1"
313
+ monkeypatch.setenv("MINDXTRAIN_OLLAMA_BASE_URL", "http://localhost:11434/v1")
314
+ assert P.resolve_chat_base_url() == "http://localhost:11434/v1"
315
+ monkeypatch.setenv("MINDXTRAIN_BACKEND", "bankml")
316
+ assert P.resolve_chat_base_url() == "http://127.0.0.1:18093/v1"
317
+
318
+
319
+ # ---- console + HF Modelfile: neutral defaults are not sent ----------------------
320
+
321
+
322
+ def test_console_wire_options_omits_engine_defaults() -> None:
323
+ from mindxtrain.ui import console
324
+
325
+ sent = console.wire_options(dict(console.DEFAULTS))
326
+ for k in ("repeat_penalty", "presence_penalty", "frequency_penalty", "mirostat",
327
+ "mirostat_tau", "mirostat_eta", "stop"):
328
+ assert k not in sent, k
329
+ assert sent["temperature"] == 0.7 and sent["top_k"] == 40
330
+ # a deliberate value is always sent — the engine then honours or refuses it, visibly
331
+ deliberate = console.wire_options({"repeat_penalty": 1.3, "mirostat": 2, "mirostat_tau": 4.0})
332
+ assert deliberate == {"repeat_penalty": 1.3, "mirostat": 2, "mirostat_tau": 4.0}
333
+
334
+
335
+ def test_hf_published_modelfile_repeat_penalty_is_optional() -> None:
336
+ from mindxtrain.hf.extension import published_modelfile
337
+
338
+ default = published_modelfile("You are mindX.")
339
+ assert "PARAMETER repeat_penalty 1.3" in default # default unchanged
340
+ assert default.endswith('PARAMETER stop "<|im_end|>"\n')
341
+ assert "repeat_penalty" not in published_modelfile("You are mindX.", repeat_penalty=None)
tests/test_bankml_push.py ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """push_to_bankml: the Modelfile subset, the capability probe, and `bankml create` — no binary.
2
+
3
+ subprocess.run and shutil.which are monkeypatched; the LoRA merge is replaced by a stub.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import subprocess
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import pytest
14
+ from typer.testing import CliRunner
15
+
16
+ from mindxtrain.deploy import bankml_push as BP
17
+ from mindxtrain.deploy.modelfile import ModelfileSpec, render_modelfile
18
+
19
+ SHA = "6b64c748d96ad26fd72402299bd27b2ae82f489bd0469498dd18eb6054058266"
20
+ DIGEST = "c" * 64
21
+ USAGE_035 = (
22
+ "usage: bankml usage [PID …]\n"
23
+ " bankml create NAME -f Modelfile [--registry DIR] [--models DIR]\n"
24
+ " bankml convert SAFETENSORS_DIR -o OUT.gguf [--outtype f16]\n"
25
+ " bankml version"
26
+ )
27
+ USAGE_034 = "usage: bankml usage [PID …]\n bankml serve FILE --fork FORK.json\n bankml version"
28
+
29
+
30
+ class FakeBankml:
31
+ """Records argv; answers `version`, `--help`, `convert`, `create`, `sha256` like bankml."""
32
+
33
+ def __init__(self, usage: str = USAGE_035, create_rc: int = 0, create_err: str = "") -> None:
34
+ self.calls: list[list[str]] = []
35
+ self.usage = usage
36
+ self.create_rc = create_rc
37
+ self.create_err = create_err
38
+ self.modelfile_text = ""
39
+
40
+ def __call__(self, cmd: list[str], **_: Any) -> subprocess.CompletedProcess[str]:
41
+ self.calls.append(list(cmd))
42
+ verb = cmd[1]
43
+ if verb == "version":
44
+ return subprocess.CompletedProcess(cmd, 0, "bankml 0.3.5\n", "")
45
+ if verb == "--help":
46
+ return subprocess.CompletedProcess(cmd, 1, "", self.usage)
47
+ if verb == "convert":
48
+ out = cmd[cmd.index("-o") + 1]
49
+ return subprocess.CompletedProcess(cmd, 0, f"{SHA} {out}\n", "bankml convert: ok\n")
50
+ if verb == "create":
51
+ self.modelfile_text = Path(cmd[cmd.index("-f") + 1]).read_text()
52
+ if self.create_rc:
53
+ return subprocess.CompletedProcess(cmd, self.create_rc, "parsing modelfile\n",
54
+ self.create_err)
55
+ name = cmd[2]
56
+ return subprocess.CompletedProcess(
57
+ cmd, 0, f"parsing modelfile\npinned {name}-F16.gguf sha256:{SHA}\n",
58
+ f"bankml create: {name} (digest sha256:{DIGEST}) over {name}-F16.gguf "
59
+ f"(sha256 {SHA})\n")
60
+ if verb == "sha256":
61
+ return subprocess.CompletedProcess(cmd, 0, f"{SHA} {cmd[2]}\n", "")
62
+ raise AssertionError(cmd)
63
+
64
+
65
+ @pytest.fixture
66
+ def fake(monkeypatch: pytest.MonkeyPatch) -> FakeBankml:
67
+ f = FakeBankml()
68
+ monkeypatch.setattr(BP.shutil, "which", lambda name: "/usr/local/bin/bankml")
69
+ monkeypatch.setattr(BP.subprocess, "run", f)
70
+ return f
71
+
72
+
73
+ def _merged(tmp_path: Path, arch: str = "LlamaForCausalLM") -> Path:
74
+ d = tmp_path / "merged"
75
+ d.mkdir()
76
+ (d / "config.json").write_text(json.dumps({"architectures": [arch]}))
77
+ return d
78
+
79
+
80
+ # ---- sanitize: the refusal table ----------------------------------------------
81
+
82
+
83
+ @pytest.mark.parametrize(
84
+ ("spec_kw", "needle"),
85
+ [
86
+ ({"adapter": "./lora"}, "ADAPTER"),
87
+ ({"template": "{{ .Prompt }}"}, "TEMPLATE"),
88
+ ({"parameters": {"repeat_penalty": 1.3}}, "PARAMETER repeat_penalty: penalties"),
89
+ ({"parameters": {"presence_penalty": 0.0}}, "PARAMETER presence_penalty"),
90
+ ({"parameters": {"frequency_penalty": 0.5}}, "PARAMETER frequency_penalty"),
91
+ ({"parameters": {"repeat_last_n": 64}}, "PARAMETER repeat_last_n"),
92
+ ({"parameters": {"mirostat": 2}}, "mirostat is not reproduced"),
93
+ ({"parameters": {"mirostat_tau": 5.0}}, "mirostat is not reproduced"),
94
+ ({"parameters": {"typical_p": 0.9}}, "typical_p"),
95
+ ({"parameters": {"num_thread": 4}}, "resource option"),
96
+ ({"parameters": {"num_gpu": 0}}, "resource option"),
97
+ ({"parameters": {"stop": "x"}}, "ModelfileSpec.stop"),
98
+ ({"parameters": {"weird": 1}}, "not a parameter bankml reproduces"),
99
+ ({"system": 'say """hi"""'}, "SYSTEM"),
100
+ ],
101
+ )
102
+ def test_sanitize_refuses_and_names_it(spec_kw: dict[str, Any], needle: str) -> None:
103
+ res = BP.bankml_sanitize(ModelfileSpec(from_model="x", **spec_kw))
104
+ assert not res.ok
105
+ assert any(needle in r for r in res.refusals), res.refusals
106
+
107
+
108
+ def test_sanitize_accepts_the_subset_unchanged() -> None:
109
+ spec = ModelfileSpec(
110
+ from_model="/m", system="You are mindX.", license="Apache-2.0",
111
+ parameters={"temperature": 0.0, "top_k": 40, "top_p": 0.9, "min_p": 0.05, "seed": 42,
112
+ "num_ctx": 2048, "num_predict": 128},
113
+ stop=["<|im_end|>"],
114
+ messages=[{"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}],
115
+ )
116
+ res = BP.bankml_sanitize(spec)
117
+ assert res.ok and res.spec == spec
118
+ text = render_modelfile(res.spec)
119
+ assert "PARAMETER seed 42" in text and 'PARAMETER stop "<|im_end|>"' in text
120
+
121
+
122
+ def test_template_passes_only_when_equal_to_base() -> None:
123
+ spec = ModelfileSpec(from_model="x", template="T")
124
+ assert BP.bankml_sanitize(spec, base_template="T").ok
125
+ assert not BP.bankml_sanitize(spec, base_template="U").ok
126
+
127
+
128
+ def test_arch_checks(tmp_path: Path) -> None:
129
+ assert BP.check_merged_arch(_merged(tmp_path)) is None
130
+ q = tmp_path / "q"
131
+ q.mkdir()
132
+ (q / "config.json").write_text(json.dumps({"architectures": ["Qwen3ForCausalLM"]}))
133
+ assert "Llama-architecture" in (BP.check_merged_arch(q) or "")
134
+ assert BP.base_family_refusal("HuggingFaceTB/SmolLM2-135M") is None
135
+ assert "qwen" in (BP.base_family_refusal("Qwen/Qwen3-1.7B") or "")
136
+
137
+
138
+ # ---- capability probe ----------------------------------------------------------
139
+
140
+
141
+ def test_missing_binary(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
142
+ monkeypatch.setattr(BP.shutil, "which", lambda name: None)
143
+ res = BP.push_to_bankml("b", "t", merged_dir=_merged(tmp_path))
144
+ assert res.status == "bankml_missing"
145
+ assert "github.com/cryptoAGI/bankml" in res.reason
146
+
147
+
148
+ def test_too_old_bankml_is_reported_not_raised(fake: FakeBankml, tmp_path: Path) -> None:
149
+ fake.usage = USAGE_034
150
+ res = BP.push_to_bankml("b", "t", merged_dir=_merged(tmp_path))
151
+ assert res.status == "bankml_too_old"
152
+ assert "0.3.5" in res.reason and "create" in res.reason
153
+ assert not any(c[1] == "create" for c in fake.calls)
154
+
155
+
156
+ def test_capabilities_read_from_usage(fake: FakeBankml) -> None:
157
+ caps = BP.bankml_capabilities()
158
+ assert caps.version == "0.3.5" and caps.has_create and caps.has_convert
159
+
160
+
161
+ # ---- push ---------------------------------------------------------------------
162
+
163
+
164
+ def test_push_from_merged_dir(fake: FakeBankml, tmp_path: Path) -> None:
165
+ merged = _merged(tmp_path)
166
+ res = BP.push_to_bankml(
167
+ "HuggingFaceTB/SmolLM2-135M", "mindx-gen99", merged_dir=merged,
168
+ system="You are mindX.", params={"temperature": 0.0, "seed": 1}, stop=["<|im_end|>"],
169
+ registry_dir=tmp_path / "forks", work_dir=tmp_path / "work",
170
+ )
171
+ assert res.ok, res
172
+ assert res.model_sha256 == SHA and res.digest == DIGEST
173
+ create = next(c for c in fake.calls if c[1] == "create")
174
+ assert create[2] == "mindx-gen99" and "--registry" in create
175
+ assert fake.modelfile_text.startswith(f"FROM {merged.resolve()}")
176
+ assert 'SYSTEM """You are mindX."""' in fake.modelfile_text
177
+ assert "repeat_penalty" not in fake.modelfile_text
178
+
179
+
180
+ def test_push_with_convert_first(fake: FakeBankml, tmp_path: Path) -> None:
181
+ res = BP.push_to_bankml("b", "gen7", merged_dir=_merged(tmp_path), convert=True,
182
+ registry_dir=tmp_path / "forks", work_dir=tmp_path / "work")
183
+ assert res.ok
184
+ conv = next(c for c in fake.calls if c[1] == "convert")
185
+ assert conv[conv.index("-o") + 1].endswith("gen7-base-F16.gguf") and "--fork" in conv
186
+ assert fake.modelfile_text.startswith("FROM ") and "gen7-base-F16.gguf" in fake.modelfile_text
187
+
188
+
189
+ def test_refused_params_never_reach_bankml(fake: FakeBankml, tmp_path: Path) -> None:
190
+ res = BP.push_to_bankml("b", "t", merged_dir=_merged(tmp_path),
191
+ params={"repeat_penalty": 1.3, "temperature": 0.7})
192
+ assert res.status == "refused"
193
+ assert any("repeat_penalty" in r for r in res.refusals)
194
+ assert fake.calls == [] # refused before even probing the binary
195
+
196
+
197
+ def test_non_llama_merged_dir_refused(fake: FakeBankml, tmp_path: Path) -> None:
198
+ res = BP.push_to_bankml("b", "t", merged_dir=_merged(tmp_path, "Qwen3ForCausalLM"))
199
+ assert res.status == "refused" and "Llama-architecture" in res.reason
200
+ assert not any(c[1] == "create" for c in fake.calls)
201
+
202
+
203
+ def test_create_refusal_is_a_result(fake: FakeBankml, tmp_path: Path) -> None:
204
+ fake.create_rc = 2
205
+ fake.create_err = "bankml create: refuse: FROM /x: tokenizer not seen\n"
206
+ res = BP.push_to_bankml("b", "t", merged_dir=_merged(tmp_path), work_dir=tmp_path / "w")
207
+ assert res.status == "refused"
208
+ assert "tokenizer not seen" in res.reason
209
+
210
+
211
+ def test_bad_tag_and_bad_inputs(tmp_path: Path) -> None:
212
+ assert BP.push_to_bankml("b", "Bad:tag", merged_dir=tmp_path).status == "refused"
213
+ assert BP.push_to_bankml("b", "ok").status == "error"
214
+
215
+
216
+ def test_adapter_merge_and_import_error(fake: FakeBankml, tmp_path: Path,
217
+ monkeypatch: pytest.MonkeyPatch) -> None:
218
+ from mindxtrain.deploy import ollama_push
219
+
220
+ def merged_stub(base: str, adapter: Path, out: Path, *, sink: Any = None) -> Path:
221
+ out.mkdir(parents=True)
222
+ (out / "config.json").write_text(json.dumps({"architectures": ["LlamaForCausalLM"]}))
223
+ return out
224
+
225
+ adapter = tmp_path / "checkpoint"
226
+ adapter.mkdir()
227
+ monkeypatch.setattr(ollama_push, "merge_lora_adapter", merged_stub)
228
+ res = BP.push_to_bankml("HuggingFaceTB/SmolLM2-135M", "g", adapter_dir=adapter,
229
+ work_dir=tmp_path / "w", registry_dir=tmp_path / "r")
230
+ assert res.ok and res.merged_dir == tmp_path / "w" / "merged"
231
+
232
+ def no_ml(*a: Any, **k: Any) -> Path:
233
+ raise ImportError("push-to-ollama needs `peft` + `transformers`")
234
+
235
+ monkeypatch.setattr(ollama_push, "merge_lora_adapter", no_ml)
236
+ res = BP.push_to_bankml("b", "g2", adapter_dir=adapter, work_dir=tmp_path / "w2")
237
+ assert res.status == "merge_failed" and "uv sync --extra ml" in res.reason
238
+
239
+
240
+ def test_register_with_mindx_uses_bankml_provider(fake: FakeBankml, tmp_path: Path,
241
+ monkeypatch: pytest.MonkeyPatch) -> None:
242
+ from mindxtrain.deploy import api_client
243
+
244
+ seen: dict[str, Any] = {}
245
+
246
+ def swap(**kw: Any) -> dict[str, str]:
247
+ seen.update(kw)
248
+ return {"previous": "ollama:x", "current": "bankml:t"}
249
+
250
+ monkeypatch.setattr(api_client, "swap_mindx_fallback_model", swap)
251
+ res = BP.push_to_bankml("b", "t", merged_dir=_merged(tmp_path), register_with_mindx=True,
252
+ work_dir=tmp_path / "w", registry_dir=tmp_path / "r")
253
+ assert res.ok and seen["provider"] == "bankml" and seen["model"] == "t"
254
+ assert res.mindx_fallback_swap == {"previous": "ollama:x", "current": "bankml:t"}
255
+
256
+
257
+ # ---- CLI: serve --to bankml ------------------------------------------------------
258
+
259
+
260
+ def _recipe(tmp_path: Path, **overrides: Any) -> Path:
261
+ import yaml
262
+
263
+ from mindxtrain.config.loader import render_recipe
264
+
265
+ data = yaml.safe_load(render_recipe("mindx_fallback_qwen3_1_5b_cpu_real"))
266
+ for dotted, value in overrides.items():
267
+ node = data
268
+ *parents, leaf = dotted.split(".")
269
+ for p in parents:
270
+ node = node[p]
271
+ node[leaf] = value
272
+ path = tmp_path / "run.yaml"
273
+ path.write_text(yaml.safe_dump(data))
274
+ return path
275
+
276
+
277
+ def test_cli_refuses_quantized_config(tmp_path: Path) -> None:
278
+ from mindxtrain.cli.main import app
279
+
280
+ cfg = _recipe(tmp_path, **{"quantize.enabled": True, "quantize.scheme": "quark_fp8"})
281
+ r = CliRunner().invoke(app, ["serve", str(cfg), "--to", "bankml"])
282
+ assert r.exit_code == 2
283
+ assert "bankml refuses quantize.scheme=quark_fp8" in r.output
284
+
285
+
286
+ def test_cli_refuses_unconvertible_family(tmp_path: Path) -> None:
287
+ from mindxtrain.cli.main import app
288
+
289
+ cfg = _recipe(tmp_path, **{"model.name": "Qwen/Qwen3-1.7B"})
290
+ r = CliRunner().invoke(app, ["serve", str(cfg), "--to", "bankml"])
291
+ assert r.exit_code == 2
292
+ assert "qwen" in r.output.lower()
293
+
294
+
295
+ def test_cli_too_old_exits_2(tmp_path: Path, fake: FakeBankml) -> None:
296
+ from mindxtrain.cli.main import app
297
+
298
+ fake.usage = USAGE_034
299
+ ckpt = tmp_path / "ck"
300
+ ckpt.mkdir()
301
+ (ckpt / "config.json").write_text(json.dumps({"architectures": ["LlamaForCausalLM"]}))
302
+ cfg = _recipe(tmp_path)
303
+ r = CliRunner().invoke(app, ["serve", str(cfg), "--to", "bankml", "--checkpoint", str(ckpt)])
304
+ assert r.exit_code == 2
305
+ assert "bankml_too_old" in r.output
tests/test_cli_smoke.py CHANGED
@@ -100,7 +100,7 @@ def test_receipt_detects_checkpoint_tamper(tmp_path, monkeypatch):
100
  assert result.exit_code == 2, result.stdout
101
 
102
 
103
- def test_serve_to_sglang_prints_command(tmp_path, monkeypatch):
104
  from mindxtrain.config.loader import load_config, render_recipe
105
 
106
  monkeypatch.chdir(tmp_path)
@@ -110,7 +110,7 @@ def test_serve_to_sglang_prints_command(tmp_path, monkeypatch):
110
  quant = tmp_path / "out" / "runs" / cfg.meta.run_name / "quantized"
111
  quant.mkdir(parents=True)
112
 
113
- result = runner.invoke(app, ["serve", str(recipe), "--to", "sglang"])
114
  assert result.exit_code == 0, result.stdout
115
  assert "sglang cmd" in result.stdout
116
  assert "sglang.launch_server" in result.stdout
 
100
  assert result.exit_code == 2, result.stdout
101
 
102
 
103
+ def test_serve_to_sglang_dry_run_prints_command(tmp_path, monkeypatch):
104
  from mindxtrain.config.loader import load_config, render_recipe
105
 
106
  monkeypatch.chdir(tmp_path)
 
110
  quant = tmp_path / "out" / "runs" / cfg.meta.run_name / "quantized"
111
  quant.mkdir(parents=True)
112
 
113
+ result = runner.invoke(app, ["serve", str(recipe), "--to", "sglang", "--dry-run"])
114
  assert result.exit_code == 0, result.stdout
115
  assert "sglang cmd" in result.stdout
116
  assert "sglang.launch_server" in result.stdout
tests/test_imprint_bankml.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Imprint probes through bankml: decoding sent, receipts kept, refusals typed, never mixed with
2
+ the canonical gate. MockTransport only — no server, no torch."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+
8
+ import httpx
9
+ import pytest
10
+
11
+ from mindxtrain.eval.imprint_bankml import (
12
+ NOT_COMPARABLE_NOTE,
13
+ imprint_via_bankml,
14
+ probe_recall_via_bankml,
15
+ )
16
+ from mindxtrain.operator.backends.bankml import BankmlRefusal
17
+
18
+ BASE_SHA = "1" * 64
19
+ GEN_SHA = "2" * 64
20
+ VOICE = ["I am mindX, the sovereign workshop of the professor."]
21
+ ANSWERS = {
22
+ "smollm2-base": ("Hello, how can I help you today?", BASE_SHA),
23
+ "mindx-gen99": ("I am mindX, the sovereign workshop.", GEN_SHA),
24
+ }
25
+
26
+
27
+ def _transport(seen: list[dict[str, object]]) -> httpx.MockTransport:
28
+ def handler(request: httpx.Request) -> httpx.Response:
29
+ assert request.url.path == "/api/chat"
30
+ body = json.loads(request.content)
31
+ seen.append(body)
32
+ text, sha = ANSWERS[body["model"]]
33
+ return httpx.Response(200, json={
34
+ "model": body["model"], "message": {"role": "assistant", "content": text},
35
+ "done": True, "done_reason": "stop", "eval_count": 9,
36
+ "bankml_receipt": {"model_sha256": sha, "request_sha256": "r" * 64,
37
+ "response_sha256": "s" * 64, "engine": "native"},
38
+ })
39
+
40
+ return httpx.MockTransport(handler)
41
+
42
+
43
+ def test_probe_sends_greedy_seeded_unpenalised_decoding() -> None:
44
+ seen: list[dict[str, object]] = []
45
+ probe = probe_recall_via_bankml(
46
+ "mindx-gen99", ["Who are you?", "Say hello."], system="You are mindX.", seed=42,
47
+ transport=_transport(seen),
48
+ )
49
+ assert probe.utterances == ["I am mindX, the sovereign workshop."] * 2
50
+ assert probe.model_sha256 == [GEN_SHA]
51
+ assert all(r and r["model_sha256"] == GEN_SHA for r in probe.receipts)
52
+ for body in seen:
53
+ assert body["stream"] is False
54
+ assert body["options"] == {"temperature": 0.0, "seed": 42, "num_predict": 48}
55
+ msgs = body["messages"]
56
+ assert isinstance(msgs, list) and msgs[0] == {"role": "system", "content": "You are mindX."}
57
+
58
+
59
+ def test_imprint_report_is_tagged_and_not_comparable() -> None:
60
+ res = imprint_via_bankml("smollm2-base", "mindx-gen99", ["Who are you?"], VOICE,
61
+ seed=3, transport=_transport([]))
62
+ assert res.report.method.endswith("/bankml-greedy")
63
+ assert res.report.imprint_delta > 0 and res.report.imprinted
64
+ assert res.canonical_gate is False
65
+ assert res.note == NOT_COMPARABLE_NOTE and "NOT comparable" in res.note
66
+ assert res.decoding.seed == 3 and res.decoding.temperature == 0.0
67
+ assert res.before.model_sha256 == [BASE_SHA] and res.after.model_sha256 == [GEN_SHA]
68
+ # the whole thing serialises (it is what the CLI prints)
69
+ assert json.loads(res.model_dump_json())["comparable_with"] == "bankml-greedy only"
70
+
71
+
72
+ def test_refusal_is_raised_typed_and_not_retried() -> None:
73
+ calls = {"n": 0}
74
+
75
+ def handler(request: httpx.Request) -> httpx.Response:
76
+ calls["n"] += 1
77
+ return httpx.Response(400, json={"error": "options.weird: not an option bankML knows"})
78
+
79
+ with pytest.raises(BankmlRefusal, match="not an option bankML knows"):
80
+ probe_recall_via_bankml("x", ["a", "b"], transport=httpx.MockTransport(handler))
81
+ assert calls["n"] == 1
82
+
83
+
84
+ def test_base_url_root_is_derived_from_v1(monkeypatch: pytest.MonkeyPatch) -> None:
85
+ urls: list[str] = []
86
+
87
+ def handler(request: httpx.Request) -> httpx.Response:
88
+ urls.append(str(request.url))
89
+ return httpx.Response(200, json={"message": {"content": "ok"}, "done": True})
90
+
91
+ monkeypatch.setenv("MINDXTRAIN_BANKML_BASE_URL", "http://127.0.0.1:18111/v1")
92
+ probe = probe_recall_via_bankml("x", ["a"], transport=httpx.MockTransport(handler))
93
+ assert urls == ["http://127.0.0.1:18111/api/chat"]
94
+ assert probe.receipts == [None] and probe.model_sha256 == []
tests/test_openai_server_push.py ADDED
@@ -0,0 +1,365 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """launch_openai_server / stop_openai_server: argv, readiness, refusals, stop — no real server.
2
+
3
+ subprocess.Popen, shutil.which and the GPU probe are monkeypatched; readiness is answered by an
4
+ httpx MockTransport. Nothing here installs or imports vLLM or SGLang.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import signal
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import httpx
16
+ import pytest
17
+ from typer.testing import CliRunner
18
+
19
+ from mindxtrain.deploy import openai_server_push as OS
20
+
21
+
22
+ class FakeProc:
23
+ def __init__(self, pid: int = 4242, exit_code: int | None = None) -> None:
24
+ self.pid = pid
25
+ self._exit = exit_code
26
+
27
+ def poll(self) -> int | None:
28
+ return self._exit
29
+
30
+
31
+ class PopenRecorder:
32
+ def __init__(self, exit_code: int | None = None) -> None:
33
+ self.calls: list[dict[str, Any]] = []
34
+ self.exit_code = exit_code
35
+
36
+ def __call__(self, argv: list[str], **kw: Any) -> FakeProc:
37
+ self.calls.append({"argv": argv, **kw})
38
+ return FakeProc(exit_code=self.exit_code)
39
+
40
+
41
+ def _client(models: list[str], *, health: int = 200, seen: list[str] | None = None) -> httpx.Client:
42
+ def handler(req: httpx.Request) -> httpx.Response:
43
+ if seen is not None:
44
+ seen.append(req.url.path)
45
+ if req.url.path == "/health":
46
+ return httpx.Response(health)
47
+ if req.url.path == "/v1/models":
48
+ return httpx.Response(200, json={"object": "list",
49
+ "data": [{"id": m} for m in models]})
50
+ return httpx.Response(404)
51
+
52
+ return httpx.Client(transport=httpx.MockTransport(handler))
53
+
54
+
55
+ def _adapter(tmp: Path, r: int = 8) -> Path:
56
+ d = tmp / "out" / "runs" / "run" / "checkpoint"
57
+ d.mkdir(parents=True)
58
+ (d / "adapter_config.json").write_text(json.dumps({"r": r}))
59
+ return d
60
+
61
+
62
+ @pytest.fixture
63
+ def env(monkeypatch: pytest.MonkeyPatch) -> PopenRecorder:
64
+ rec = PopenRecorder()
65
+ monkeypatch.setattr(OS.subprocess, "Popen", rec)
66
+ monkeypatch.setattr(OS.shutil, "which", lambda name: f"/usr/bin/{name}")
67
+ monkeypatch.setattr(OS, "detect_gpu", lambda: None)
68
+ monkeypatch.delenv("VLLM_CPU_KVCACHE_SPACE", raising=False)
69
+ return rec
70
+
71
+
72
+ def _launch(tmp: Path, flavour: OS.Flavour = "vllm", **kw: Any) -> OS.ServerLaunchResult:
73
+ adapter = kw.pop("adapter_dir", None) or _adapter(tmp, kw.pop("rank", 8))
74
+ base: dict[str, Any] = dict(base_model="HuggingFaceTB/SmolLM2-135M", tag="mindx-gen9",
75
+ run_dir=adapter.parent, adapter_dir=adapter, port=8123,
76
+ ready_timeout_s=1.0, poll_interval_s=0.0, sleep=lambda _s: None)
77
+ base.update(kw)
78
+ return OS.launch_openai_server(flavour, **base)
79
+
80
+
81
+ # ---- argv ----------------------------------------------------------------------------------
82
+
83
+
84
+ def test_vllm_native_lora_argv_on_cpu(tmp_path: Path, env: PopenRecorder) -> None:
85
+ res = _launch(tmp_path, rank=24, http_client=_client(["mindx-gen9-base", "mindx-gen9"]))
86
+ assert res.status == "ready", res.reason
87
+ argv = env.calls[0]["argv"]
88
+ assert argv[:3] == ["/usr/bin/vllm", "serve", "HuggingFaceTB/SmolLM2-135M"]
89
+ s = " ".join(argv)
90
+ assert "--served-model-name mindx-gen9-base" in s
91
+ assert "--host 127.0.0.1 --port 8123" in s
92
+ assert "--dtype bfloat16" in s # auto → bfloat16 on CPU
93
+ assert "--max-model-len 8192" in s
94
+ assert "--enable-lora --lora-modules mindx-gen9=" in s
95
+ assert argv[argv.index("--max-lora-rank") + 1] == "32" # 24 rounded up to a vLLM choice
96
+ assert env.calls[0]["start_new_session"] is True
97
+ assert env.calls[0]["env"]["VLLM_CPU_KVCACHE_SPACE"] == "4"
98
+ assert res.env == {"VLLM_CPU_KVCACHE_SPACE": "4"}
99
+ assert res.client_model == "mindx-gen9"
100
+ assert res.pid_file is not None and res.pid_file.read_text().strip() == "4242"
101
+ launch = json.loads((res.pid_file.parent / "launch.json").read_text())
102
+ assert launch["argv"] == argv
103
+
104
+
105
+ def test_sglang_native_lora_argv_on_cpu(tmp_path: Path, env: PopenRecorder,
106
+ monkeypatch: pytest.MonkeyPatch) -> None:
107
+ monkeypatch.setattr(OS.importlib.util, "find_spec", lambda name: object())
108
+ res = _launch(tmp_path, "sglang", http_client=_client(["mindx-gen9-base"]))
109
+ assert res.status == "ready", res.reason
110
+ argv = env.calls[0]["argv"]
111
+ assert argv[1:3] == ["-m", "sglang.launch_server"]
112
+ s = " ".join(argv)
113
+ assert "--model-path HuggingFaceTB/SmolLM2-135M" in s
114
+ assert "--context-length 8192" in s and "--tp 1" in s
115
+ assert "--device cpu" in s and "--mem-fraction-static" not in s
116
+ assert "--enable-lora --lora-paths mindx-gen9=" in s
117
+ assert argv[argv.index("--max-lora-rank") + 1] == "8"
118
+ assert res.client_model == "mindx-gen9-base:mindx-gen9"
119
+ assert res.env == {}
120
+
121
+
122
+ def test_extra_args_and_gpu_quantized_full_model(tmp_path: Path, env: PopenRecorder,
123
+ monkeypatch: pytest.MonkeyPatch) -> None:
124
+ monkeypatch.setattr(OS, "detect_gpu", lambda: "rocm")
125
+ qdir = tmp_path / "quantized"
126
+ qdir.mkdir()
127
+ res = OS.launch_openai_server(
128
+ "vllm", base_model="Qwen/Qwen3-8B", tag="q8", run_dir=tmp_path, model_dir=qdir,
129
+ quantization="fp8", extra_args=["--enforce-eager"], ready_timeout_s=1.0,
130
+ poll_interval_s=0.0, sleep=lambda _s: None, http_client=_client(["q8"]))
131
+ assert res.status == "ready", res.reason
132
+ argv = env.calls[0]["argv"]
133
+ assert argv[2] == str(qdir) and "--enable-lora" not in argv
134
+ assert argv[argv.index("--quantization") + 1] == "fp8"
135
+ assert argv[argv.index("--dtype") + 1] == "auto"
136
+ assert argv[-1] == "--enforce-eager"
137
+ assert res.env == {} # no CPU env on a GPU host
138
+
139
+
140
+ # ---- readiness ---------------------------------------------------------------------------
141
+
142
+
143
+ def test_ready_polls_health_and_models_and_swaps_mindx(tmp_path: Path, env: PopenRecorder,
144
+ monkeypatch: pytest.MonkeyPatch) -> None:
145
+ from mindxtrain.deploy import api_client
146
+
147
+ swapped: dict[str, Any] = {}
148
+
149
+ def fake_swap(**kw: Any) -> dict[str, str]:
150
+ swapped.update(kw)
151
+ return {"previous": "old", "current": kw["model"]}
152
+
153
+ monkeypatch.setattr(api_client, "swap_mindx_fallback_model", fake_swap)
154
+ paths: list[str] = []
155
+ res = _launch(tmp_path, http_client=_client(["mindx-gen9"], seen=paths),
156
+ register_with_mindx=True, mindx_base_url="http://mindx.test")
157
+ assert res.status == "ready"
158
+ assert paths[:2] == ["/health", "/v1/models"]
159
+ assert swapped == {"provider": "vllm", "model": "mindx-gen9", "api_url": "http://mindx.test"}
160
+ assert res.mindx_fallback_swap == {"previous": "old", "current": "mindx-gen9"}
161
+
162
+
163
+ def test_timeout_leaves_server_running(tmp_path: Path, env: PopenRecorder) -> None:
164
+ res = _launch(tmp_path, ready_timeout_s=0.0, http_client=_client(["something-else"]))
165
+ assert res.status == "timeout"
166
+ assert "something-else" in res.reason and "--stop" in res.reason
167
+ assert res.pid == 4242 and res.pid_file is not None and res.pid_file.exists()
168
+
169
+
170
+ def test_unhealthy_vllm_never_ready(tmp_path: Path, env: PopenRecorder) -> None:
171
+ res = _launch(tmp_path, ready_timeout_s=0.0, http_client=_client(["mindx-gen9"], health=503))
172
+ assert res.status == "timeout"
173
+
174
+
175
+ def test_exited_early_reports_log_tail(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
176
+ env: PopenRecorder) -> None:
177
+ env.exit_code = 1
178
+ res = _launch(tmp_path, http_client=_client([]))
179
+ assert res.status == "exited" and "code 1" in res.reason
180
+ assert res.pid_file is not None and not res.pid_file.exists()
181
+
182
+
183
+ # ---- refusals ------------------------------------------------------------------------------
184
+
185
+
186
+ def test_missing_vllm_binary(tmp_path: Path, env: PopenRecorder,
187
+ monkeypatch: pytest.MonkeyPatch) -> None:
188
+ monkeypatch.setattr(OS.shutil, "which", lambda name: None)
189
+ res = _launch(tmp_path)
190
+ assert res.status == "missing" and not res.ok
191
+ assert "uv sync --extra serve" in res.reason
192
+ assert env.calls == []
193
+
194
+
195
+ def test_missing_sglang(tmp_path: Path, env: PopenRecorder,
196
+ monkeypatch: pytest.MonkeyPatch) -> None:
197
+ monkeypatch.setattr(OS.importlib.util, "find_spec", lambda name: None)
198
+ res = _launch(tmp_path, "sglang")
199
+ assert res.status == "missing" and "uv pip install sglang" in res.reason
200
+ assert env.calls == []
201
+
202
+
203
+ def test_gpu_config_refused_without_gpu(tmp_path: Path, env: PopenRecorder) -> None:
204
+ qdir = tmp_path / "quantized"
205
+ qdir.mkdir()
206
+ for flavour in ("vllm", "sglang"):
207
+ res = OS.launch_openai_server(flavour, base_model="b", tag="t", run_dir=tmp_path, # type: ignore[arg-type]
208
+ model_dir=qdir, quantization="fp8")
209
+ assert res.status == "no_gpu" and "GPU" in res.reason
210
+ tp = _launch(tmp_path, tensor_parallel=2)
211
+ assert tp.status == "no_gpu" and "tensor_parallel=2" in tp.reason
212
+ assert env.calls == []
213
+
214
+
215
+ def test_already_running(tmp_path: Path, env: PopenRecorder) -> None:
216
+ adapter = _adapter(tmp_path)
217
+ sdir = OS.server_dir(adapter.parent, "vllm")
218
+ sdir.mkdir(parents=True)
219
+ (sdir / "server.pid").write_text(str(os.getpid()))
220
+ res = _launch(tmp_path, adapter_dir=adapter)
221
+ assert res.status == "already_running" and res.pid == os.getpid()
222
+ assert env.calls == []
223
+
224
+
225
+ def test_merge_failure_is_a_result(tmp_path: Path, env: PopenRecorder,
226
+ monkeypatch: pytest.MonkeyPatch) -> None:
227
+ from mindxtrain.deploy import ollama_push
228
+
229
+ def boom(*_a: Any, **_k: Any) -> Path:
230
+ raise ImportError("needs peft")
231
+
232
+ monkeypatch.setattr(ollama_push, "merge_lora_adapter", boom)
233
+ res = _launch(tmp_path, merge=True)
234
+ assert res.status == "merge_failed" and "uv sync --extra ml" in res.reason
235
+
236
+
237
+ def test_merge_serves_merged_dir(tmp_path: Path, env: PopenRecorder,
238
+ monkeypatch: pytest.MonkeyPatch) -> None:
239
+ from mindxtrain.deploy import ollama_push
240
+
241
+ def fake_merge(base: str, adapter: Path, out: Path, **_k: Any) -> Path:
242
+ out.mkdir(parents=True)
243
+ return out
244
+
245
+ monkeypatch.setattr(ollama_push, "merge_lora_adapter", fake_merge)
246
+ res = _launch(tmp_path, merge=True, http_client=_client(["mindx-gen9"]))
247
+ assert res.status == "ready", res.reason
248
+ argv = env.calls[0]["argv"]
249
+ assert argv[2].endswith("serve/vllm/merged") and "--enable-lora" not in argv
250
+ assert argv[argv.index("--served-model-name") + 1] == "mindx-gen9"
251
+
252
+
253
+ def test_bad_inputs_never_raise(tmp_path: Path, env: PopenRecorder) -> None:
254
+ res = OS.launch_openai_server("vllm", base_model="b", tag="t", run_dir=tmp_path)
255
+ assert res.status == "error"
256
+ res = OS.launch_openai_server("vllm", base_model="b", tag="t", run_dir=tmp_path,
257
+ model_dir=tmp_path / "nope", ready_timeout_s=0.0)
258
+ assert res.status == "error" and "not a directory" in res.reason
259
+
260
+
261
+ # ---- dry run ---------------------------------------------------------------------------------
262
+
263
+
264
+ def test_dry_run_builds_argv_and_does_nothing(tmp_path: Path, env: PopenRecorder,
265
+ monkeypatch: pytest.MonkeyPatch) -> None:
266
+ monkeypatch.setattr(OS.shutil, "which", lambda name: None) # even with no vllm installed
267
+ res = _launch(tmp_path, dry_run=True)
268
+ assert res.status == "dry_run" and res.ok
269
+ assert res.argv[:2] == ("vllm", "serve")
270
+ assert "uv sync --extra serve" in res.reason
271
+ assert env.calls == []
272
+ assert not (tmp_path / "out" / "runs" / "run" / "serve").exists()
273
+
274
+
275
+ # ---- stop ------------------------------------------------------------------------------------
276
+
277
+
278
+ def _pidfile(tmp_path: Path, pid: int) -> Path:
279
+ sdir = OS.server_dir(tmp_path, "vllm")
280
+ sdir.mkdir(parents=True)
281
+ (sdir / "server.pid").write_text(f"{pid}\n")
282
+ return sdir / "server.pid"
283
+
284
+
285
+ def test_stop_sigterms_group_and_verifies(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
286
+ pf = _pidfile(tmp_path, 777)
287
+ alive = {"v": True}
288
+ sent: list[tuple[int, int]] = []
289
+
290
+ def killpg(pid: int, sig: int) -> None:
291
+ sent.append((pid, sig))
292
+ alive["v"] = False
293
+
294
+ monkeypatch.setattr(OS, "_alive", lambda pid: alive["v"])
295
+ monkeypatch.setattr(OS.os, "killpg", killpg)
296
+ res = OS.stop_openai_server("vllm", tmp_path, sleep=lambda _s: None)
297
+ assert res.status == "stopped" and res.pid == 777
298
+ assert sent == [(777, signal.SIGTERM)]
299
+ assert not pf.exists()
300
+
301
+
302
+ def test_stop_still_running(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
303
+ pf = _pidfile(tmp_path, 778)
304
+ monkeypatch.setattr(OS, "_alive", lambda pid: True)
305
+ monkeypatch.setattr(OS.os, "killpg", lambda pid, sig: None)
306
+ res = OS.stop_openai_server("vllm", tmp_path, timeout_s=0.0, sleep=lambda _s: None)
307
+ assert res.status == "still_running" and not res.ok
308
+ assert pf.exists()
309
+
310
+
311
+ def test_stop_without_pidfile_or_stale(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
312
+ assert OS.stop_openai_server("sglang", tmp_path).status == "not_running"
313
+ pf = _pidfile(tmp_path, 779)
314
+ monkeypatch.setattr(OS, "_alive", lambda pid: False)
315
+ res = OS.stop_openai_server("vllm", tmp_path)
316
+ assert res.status == "not_running" and not pf.exists()
317
+
318
+
319
+ # ---- CLI -------------------------------------------------------------------------------------
320
+
321
+
322
+ def _recipe(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, Path]:
323
+ from mindxtrain.config.loader import load_config, render_recipe
324
+
325
+ monkeypatch.chdir(tmp_path)
326
+ recipe = tmp_path / "run.yaml"
327
+ recipe.write_text(render_recipe("qwen3_8b_sft_lora"))
328
+ run_dir = tmp_path / "out" / "runs" / load_config(recipe).meta.run_name
329
+ ckpt = run_dir / "checkpoint"
330
+ ckpt.mkdir(parents=True)
331
+ (ckpt / "adapter_config.json").write_text(json.dumps({"r": 16}))
332
+ return recipe, run_dir
333
+
334
+
335
+ def test_cli_dry_run_vllm_prefers_adapter_without_quantized(
336
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, env: PopenRecorder) -> None:
337
+ from mindxtrain.cli.main import app
338
+
339
+ recipe, _ = _recipe(tmp_path, monkeypatch)
340
+ out = CliRunner().invoke(app, ["serve", str(recipe), "--to", "vllm", "--dry-run",
341
+ "--tag", "t1", "--port", "8124"])
342
+ assert out.exit_code == 0, out.stdout
343
+ assert "vllm cmd" in out.stdout and "--lora-modules" in out.stdout
344
+ assert "--port 8124" in out.stdout
345
+ assert env.calls == []
346
+
347
+
348
+ def test_cli_missing_binary_exits_2(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
349
+ env: PopenRecorder) -> None:
350
+ from mindxtrain.cli.main import app
351
+
352
+ monkeypatch.setattr(OS.shutil, "which", lambda name: None)
353
+ recipe, _ = _recipe(tmp_path, monkeypatch)
354
+ out = CliRunner().invoke(app, ["serve", str(recipe), "--to", "vllm"])
355
+ assert out.exit_code == 2, out.stdout
356
+ assert "missing" in out.stdout and "uv sync --extra serve" in out.stdout
357
+
358
+
359
+ def test_cli_stop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
360
+ from mindxtrain.cli.main import app
361
+
362
+ recipe, _ = _recipe(tmp_path, monkeypatch)
363
+ out = CliRunner().invoke(app, ["serve", str(recipe), "--to", "sglang", "--stop"])
364
+ assert out.exit_code == 0, out.stdout
365
+ assert "not_running" in out.stdout