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",
]