File size: 9,132 Bytes
730c5bb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 | """bankml backend — the verified CPU engine, reached over HTTP only.
bankml (https://github.com/cryptoAGI/bankml, Apache-2.0 OR MIT) is a zero-dependency Rust
runtime whose `bankml serve --native` answers OpenAI `/v1/chat/completions` and Ollama `/api/*`
from its own forward pass, token-identical to llama.cpp b11192, on 127.0.0.1:18093 by default.
mindXtrain never vendors it: this module speaks its documented wire protocol and nothing else
(clean-room policy, CLAUDE.md).
What this backend adds over `openai_compat`:
- **Receipts.** Every bankml answer carries a `bankml_receipt` (model_sha256, request_sha256,
response_sha256, engine, tokens, wall_ms, ...). Non-streamed it is a top-level object and lands
in `ChatResponse.receipt`; streamed it arrives as one extra `data: {"bankml_receipt": ...}`
event before `data: [DONE]`. Either way the latest receipt is also kept on `last_receipt`.
- **Refusals are typed, never retried.** bankml answers HTTP 400 with a plain-text reason when a
request asks for something its verified forward pass does not reproduce (penalties before
0.3.6, mirostat, typical_p, tools, an unknown architecture, Q8_0 / Q4_K / BF16).
That becomes `BankmlRefusal(reason)`. The request is never re-sent with altered parameters:
a changed request would be a different, unreceipted question.
- **Sampling options, only when set.** bankml 0.3.6 reproduces `repeat_penalty`,
`repeat_last_n`, `presence_penalty` and `frequency_penalty` as llama-server b11192 does; a
small generation (mindx-genN) needs `repeat_penalty` to answer in words. They are sent from
`options=` or `MINDXTRAIN_BANKML_OPTIONS`, never by default, so an older bankml is never sent
a field it refuses.
Env: `MINDXTRAIN_BANKML_BASE_URL` (default `http://127.0.0.1:18093/v1`),
`MINDXTRAIN_BANKML_OPTIONS` (a JSON object, e.g. `{"repeat_penalty": 1.3}`).
"""
from __future__ import annotations
import json
import os
from collections.abc import AsyncIterator
from typing import Any
import httpx
from mindxtrain.models.registry import ChatRequest, ChatResponse, register_backend
from mindxtrain.operator.backends.openai_compat import OpenAICompatBackend
DEFAULT_BANKML_BASE_URL = "http://127.0.0.1:18093/v1"
def bankml_base_url() -> str:
"""The configured bankml OpenAI base URL (`.../v1`)."""
return os.environ.get("MINDXTRAIN_BANKML_BASE_URL", DEFAULT_BANKML_BASE_URL).rstrip("/")
def bankml_root_url(base_url: str | None = None) -> str:
"""The server root (no `/v1`) — where `/health`, `/bankml` and `/api/*` live."""
return (base_url or bankml_base_url()).rstrip("/").removesuffix("/v1")
class BankmlError(RuntimeError):
"""bankml answered, but not with an answer (an engine error, a broken stream)."""
label = "error"
def __init__(self, reason: str, status_code: int = 0) -> None:
self.reason = reason.strip()
self.status_code = status_code
super().__init__(f"bankml {self.label} ({status_code}): {self.reason}")
class BankmlRefusal(BankmlError):
"""bankml refused the request (HTTP 400) and said why.
The reason is bankml's own text, e.g. ``mirostat: not reproduced; ...``. Callers must not
retry with parameters stripped or altered — change the request deliberately instead.
"""
label = "refused"
def __init__(self, reason: str, status_code: int = 400) -> None:
super().__init__(reason, status_code)
def refusal_reason(resp: httpx.Response) -> str:
"""bankml's reason from an error body: `/v1` sends text/plain, `/api` sends `{"error": ...}`."""
text = resp.text or ""
try:
body = json.loads(text)
except ValueError:
return text.strip() or f"HTTP {resp.status_code}"
if isinstance(body, dict) and body.get("error"):
err = body["error"]
return str(err.get("message", err) if isinstance(err, dict) else err)
return text.strip() or f"HTTP {resp.status_code}"
def raise_for_bankml(resp: httpx.Response) -> None:
"""400 → `BankmlRefusal`; any other non-2xx → `BankmlError`."""
if resp.status_code == 400:
raise BankmlRefusal(refusal_reason(resp))
if resp.status_code >= 400:
raise BankmlError(refusal_reason(resp), resp.status_code)
def _env_options() -> dict[str, object]:
raw = os.environ.get("MINDXTRAIN_BANKML_OPTIONS")
if not raw:
return {}
val = json.loads(raw)
if not isinstance(val, dict):
msg = "MINDXTRAIN_BANKML_OPTIONS must be a JSON object"
raise ValueError(msg)
return val
@register_backend("bankml")
class BankmlBackend(OpenAICompatBackend):
"""OpenAI-compatible client for `bankml serve --native`, keeping receipts and refusals."""
name = "bankml"
def __init__(
self,
base_url: str | None = None,
timeout_s: float = 300.0,
*,
seed: int | None = None,
options: dict[str, object] | None = None,
transport: httpx.AsyncBaseTransport | None = None,
) -> None:
super().__init__(base_url=base_url or bankml_base_url(), api_key=None, timeout_s=timeout_s)
# `api_key or env` in the parent would pick up an OpenAI key; bankml is loopback, keyless.
self.api_key = ""
self.seed = seed
# Sampling fields beyond OpenAI's that bankml honours (repeat_penalty & co. from 0.3.6),
# sent only when set: from MINDXTRAIN_BANKML_OPTIONS (a JSON object), then `options`.
self.options: dict[str, object] = {**_env_options(), **(options or {})}
self._transport = transport
self.last_receipt: dict[str, Any] | None = None
def _client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(timeout=self.timeout_s, transport=self._transport)
def _payload(self, request: ChatRequest, *, stream: bool) -> dict[str, object]:
payload = {**self.options, **super()._payload(request, stream=stream)}
if self.seed is not None:
payload["seed"] = self.seed
return payload
async def chat(self, request: ChatRequest) -> ChatResponse:
async with self._client() as client:
resp = await client.post(
f"{self.base_url}/chat/completions",
json=self._payload(request, stream=False),
headers=self._headers(),
)
raise_for_bankml(resp)
data = resp.json()
choice = (data.get("choices") or [{}])[0]
usage = data.get("usage") or {}
receipt = data.get("bankml_receipt")
self.last_receipt = receipt if isinstance(receipt, dict) else None
finish = choice.get("finish_reason") or "stop"
return ChatResponse(
model=data.get("model", request.model),
content=(choice.get("message") or {}).get("content", "") or "",
finish_reason=finish if finish in ("stop", "length", "error") else "stop",
prompt_tokens=int(usage.get("prompt_tokens", 0)),
completion_tokens=int(usage.get("completion_tokens", 0)),
receipt=self.last_receipt,
)
async def stream_chat(self, request: ChatRequest) -> AsyncIterator[str]:
self.last_receipt = None
async def _gen() -> AsyncIterator[str]:
async with self._client() as client:
async with client.stream(
"POST",
f"{self.base_url}/chat/completions",
json=self._payload(request, stream=True),
headers=self._headers(),
) as resp:
if resp.status_code >= 400:
await resp.aread()
raise_for_bankml(resp)
async for raw in resp.aiter_lines():
if not raw or not raw.startswith("data:"):
continue
data = raw[5:].strip()
if data == "[DONE]":
return
try:
chunk = json.loads(data)
except json.JSONDecodeError:
continue
if not isinstance(chunk, dict):
continue
if "bankml_receipt" in chunk:
rec = chunk["bankml_receipt"]
self.last_receipt = rec if isinstance(rec, dict) else None
continue
if "error" in chunk:
# bankml stops a stream it cannot finish with `data: {"error": ...}`
raise BankmlError(str(chunk["error"]), resp.status_code)
delta = (chunk.get("choices") or [{}])[0].get("delta", {})
token = delta.get("content")
if token:
yield token
return _gen()
__all__ = [
"DEFAULT_BANKML_BASE_URL",
"BankmlBackend",
"BankmlError",
"BankmlRefusal",
"bankml_base_url",
"bankml_root_url",
"raise_for_bankml",
"refusal_reason",
]
|