bankml: the verified CPU engine as backend, serve target and imprint probe
#1
by Gregory-L - opened
- CLAUDE.md +2 -1
- README.md +31 -0
- docs/CHANGELOG.md +45 -0
- docs/NAV.md +10 -0
- docs/bankml.md +133 -0
- docs/cli.md +38 -9
- docs/development.md +4 -2
- docs/serve.md +89 -0
- llm.txt +12 -0
- mindxtrain/cli/main.py +327 -50
- mindxtrain/deploy/bankml_push.py +439 -0
- mindxtrain/deploy/openai_server_push.py +568 -0
- mindxtrain/eval/imprint_bankml.py +156 -0
- mindxtrain/eval/mei/record.py +1 -1
- mindxtrain/governance/panel.py +17 -2
- mindxtrain/hf/extension.py +19 -5
- mindxtrain/models/registry.py +6 -1
- mindxtrain/operator/app.py +80 -15
- mindxtrain/operator/backends/bankml.py +197 -0
- mindxtrain/operator/coach/api.py +3 -14
- mindxtrain/ui/app.py +1 -1
- mindxtrain/ui/console.py +28 -2
- tests/conftest.py +18 -0
- tests/test_bankml_backend.py +341 -0
- tests/test_bankml_push.py +305 -0
- tests/test_cli_smoke.py +2 -2
- tests/test_imprint_bankml.py +94 -0
- tests/test_openai_server_push.py +365 -0
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
|
| 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` —
|
| 135 |
|
| 136 |
```
|
| 137 |
-
mindxtrain serve <config.yaml> [--checkpoint <path>]
|
|
|
|
|
|
|
|
|
|
| 138 |
```
|
| 139 |
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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`
|
|
|
|
|
|
|
| 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
|
| 202 |
-
the
|
|
|
|
|
|
|
| 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
|
| 311 |
-
"sglang (
|
| 312 |
-
"ollama (merges LoRA + calls `ollama create`)
|
|
|
|
|
|
|
| 313 |
),
|
| 314 |
tag: str = typer.Option(
|
| 315 |
None, "--tag",
|
| 316 |
-
help="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
| 342 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 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 |
-
|
| 604 |
-
|
| 605 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 = (
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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, **
|
| 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 |
-
|
| 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
|
|
|
|
| 99 |
host = (host or HOST).rstrip("/")
|
| 100 |
body = {"model": model, "messages": messages, "stream": True, "keep_alive": keep_alive,
|
| 101 |
-
"options":
|
| 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
|
| 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
|