frutebox-node 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- frutebox/adminheader.py +59 -0
- frutebox/attest.py +43 -0
- frutebox/audiogen.py +364 -0
- frutebox/config.py +367 -0
- frutebox/deltasync.py +211 -0
- frutebox/economics.py +270 -0
- frutebox/engine_guard.py +718 -0
- frutebox/envelope.py +77 -0
- frutebox/envvars.py +167 -0
- frutebox/gpuprices.py +641 -0
- frutebox/gpuproviders.py +4489 -0
- frutebox/gui.py +270 -0
- frutebox/imagegen.py +152 -0
- frutebox/inference.py +624 -0
- frutebox/manifest.py +218 -0
- frutebox/mediaserving.py +603 -0
- frutebox/packaging.py +542 -0
- frutebox/pqctransport.py +660 -0
- frutebox/provider.py +1867 -0
- frutebox/providers.py +942 -0
- frutebox/registry/__init__.py +189 -0
- frutebox/registry/generate.py +311 -0
- frutebox/registry/generate_providers.py +120 -0
- frutebox/registry/models.yaml +1759 -0
- frutebox/registry/providers.yaml +518 -0
- frutebox/registry/registry.gen.json +2043 -0
- frutebox/registry_gen.py +3 -0
- frutebox/runpod_templates.py +143 -0
- frutebox/serving.py +691 -0
- frutebox/signedconfig.py +259 -0
- frutebox/signing.py +208 -0
- frutebox/thinkbudget.py +155 -0
- frutebox/videogen.py +118 -0
- frutebox_node-0.1.0.dist-info/METADATA +100 -0
- frutebox_node-0.1.0.dist-info/RECORD +36 -0
- frutebox_node-0.1.0.dist-info/WHEEL +4 -0
frutebox/inference.py
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
"""Real model execution on a provider's hardware — via a local Ollama server.
|
|
2
|
+
|
|
3
|
+
Every node runs an actual open-weight model (gemma3 / llama / qwen / …) through
|
|
4
|
+
Ollama. **There is no mock.** If the engine is unreachable or the model isn't
|
|
5
|
+
installed, the job fails loudly and the gateway re-routes — a node never returns
|
|
6
|
+
a fabricated answer.
|
|
7
|
+
|
|
8
|
+
The network's default model alias (``stevai-default``) maps to a concrete
|
|
9
|
+
installed model via ``STEVAI_OLLAMA_MODEL`` (default ``gemma3:4b``).
|
|
10
|
+
|
|
11
|
+
Every request built here goes through :mod:`frutebox.engine_guard`, which owns the
|
|
12
|
+
engine surface (inference endpoints only — never model management), the model
|
|
13
|
+
allowlist, payload sanitisation and the generation budget. Read that module's
|
|
14
|
+
docstring for exactly what is and is not contained.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from typing import AsyncIterator, Dict, List, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
# The transport itself lives in engine_guard (the single chokepoint); httpx stays
|
|
23
|
+
# imported here because `inference.httpx` is the module-level handle tests patch,
|
|
24
|
+
# and patching it is patching the same module object engine_guard uses.
|
|
25
|
+
import httpx # noqa: F401
|
|
26
|
+
|
|
27
|
+
from . import engine_guard
|
|
28
|
+
from .engine_guard import EngineGuardError # noqa: F401 (re-exported for callers)
|
|
29
|
+
from .envvars import env as envvar
|
|
30
|
+
from . import deltasync
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _with_scheme(host: str) -> str:
|
|
34
|
+
# The Ollama CLI convention is a bare "127.0.0.1:11434" — httpx needs a scheme,
|
|
35
|
+
# so normalize rather than crash every job with UnsupportedProtocol.
|
|
36
|
+
h = (host or "").strip()
|
|
37
|
+
return h if h.startswith(("http://", "https://")) else f"http://{h}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
OLLAMA_HOST = _with_scheme(os.environ.get("OLLAMA_HOST",
|
|
41
|
+
envvar("OLLAMA_HOST", "http://127.0.0.1:11434")))
|
|
42
|
+
# The concrete model a node runs when the request uses the network's alias.
|
|
43
|
+
DEFAULT_MODEL = envvar("OLLAMA_MODEL", "gemma3:4b")
|
|
44
|
+
_ALIASES = {"", "stevai-default"}
|
|
45
|
+
_TIMEOUT_S = float(envvar("OLLAMA_TIMEOUT_S", "300"))
|
|
46
|
+
|
|
47
|
+
# Inference engine on THIS node. "ollama" (single-file models) or "llamacpp" (sharded GGUF —
|
|
48
|
+
# e.g. Kimi K2.x/K3, which Ollama cannot serve). A Kimi node sets STEVAI_ENGINE=llamacpp; the agent
|
|
49
|
+
# then talks OpenAI /v1 to a local llama.cpp server instead of Ollama's /api/chat. The call is
|
|
50
|
+
# node-local (127.0.0.1), so there is no RunPod-proxy timeout — a long reasoning turn is fine.
|
|
51
|
+
ENGINE = envvar("ENGINE", "ollama").lower()
|
|
52
|
+
LLAMACPP_HOST = envvar("LLAMACPP_HOST", "http://127.0.0.1:8080")
|
|
53
|
+
# Kimi K2.7-Code reasons for many tokens before answering — give it room and a long local timeout.
|
|
54
|
+
_LLAMACPP_TIMEOUT_S = float(envvar("LLAMACPP_TIMEOUT_S", "2400"))
|
|
55
|
+
_LLAMACPP_MAX_TOKENS = int(envvar("LLAMACPP_MAX_TOKENS", "20000"))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def estimate_tokens(text: str) -> int:
|
|
59
|
+
# ~4 characters per token is a decent rule of thumb; only used as a fallback
|
|
60
|
+
# when Ollama doesn't report exact counts.
|
|
61
|
+
return max(1, round(len(text or "") / 4))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
#: The tags a model uses when it writes its monologue INLINE instead of on its own channel. Lower-case
|
|
65
|
+
#: only: the scan is case-insensitive, and keeping the pairs here means adding a dialect is one line.
|
|
66
|
+
_THINK_TAGS: "Tuple[Tuple[str, str], ...]" = (("<think>", "</think>"), ("<thinking>", "</thinking>"))
|
|
67
|
+
|
|
68
|
+
#: The longest closing tag, so a streaming splitter knows how many characters it must hold back to avoid
|
|
69
|
+
#: cutting a tag in half across two deltas.
|
|
70
|
+
_LONGEST_TAG = max(len(t) for pair in _THINK_TAGS for t in pair)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ReasoningGate:
|
|
74
|
+
"""The turn's effort budget, applied to a STREAM of reasoning deltas.
|
|
75
|
+
|
|
76
|
+
ONE OBJECT RATHER THAN A CHECK AT EACH YIELD. There are three reasoning yield sites per streaming
|
|
77
|
+
function and two streaming functions, plus the final aggregate that gets stored — six places that
|
|
78
|
+
would each have to remember the running total. The bug that shape produces is a stream and an
|
|
79
|
+
aggregate that disagree about what the model said, which is unfalsifiable after the fact. So the
|
|
80
|
+
gate is the only thing that counts, and `text()` returns EXACTLY what it forwarded: the stored
|
|
81
|
+
reasoning is the forwarded reasoning, by construction.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, effort: str = ""):
|
|
85
|
+
self.budget = engine_guard.reasoning_char_budget(effort)
|
|
86
|
+
self.effort = effort
|
|
87
|
+
self.used = 0
|
|
88
|
+
self.clamped = False
|
|
89
|
+
self._out: "List[str]" = []
|
|
90
|
+
|
|
91
|
+
def allow(self, delta: str) -> str:
|
|
92
|
+
"""The part of `delta` that may be forwarded — "" once the budget is spent."""
|
|
93
|
+
if not delta:
|
|
94
|
+
return ""
|
|
95
|
+
if self.budget is None:
|
|
96
|
+
self.used += len(delta)
|
|
97
|
+
self._out.append(delta)
|
|
98
|
+
return delta
|
|
99
|
+
room = self.budget - self.used
|
|
100
|
+
if room <= 0:
|
|
101
|
+
self.clamped = True
|
|
102
|
+
return ""
|
|
103
|
+
if len(delta) <= room:
|
|
104
|
+
self.used += len(delta)
|
|
105
|
+
self._out.append(delta)
|
|
106
|
+
return delta
|
|
107
|
+
self.clamped = True
|
|
108
|
+
self.used = self.budget
|
|
109
|
+
head = delta[:room]
|
|
110
|
+
self._out.append(head)
|
|
111
|
+
return head
|
|
112
|
+
|
|
113
|
+
def text(self) -> str:
|
|
114
|
+
return "".join(self._out)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def clamp_reasoning(text: str, effort: str) -> "tuple[str, bool]":
|
|
118
|
+
"""Apply the turn's effort budget to its reasoning. Returns `(text, was_clamped)`.
|
|
119
|
+
|
|
120
|
+
WHAT THIS BOUNDS, AND WHAT IT DOES NOT. It bounds the reasoning the caller receives, the
|
|
121
|
+
governance store keeps and the §6A post-gate has to screen — on every engine, including one that
|
|
122
|
+
ignores its own level knob. It does NOT reduce what the engine SPENT: measured 2026-10-11, Ollama
|
|
123
|
+
0.35 accepts `think: "low"` on qwen3:4b and produces byte-identical output to `think: true`, and
|
|
124
|
+
`options.num_predict` bounds the spend only by truncating the turn before it answers. The saving
|
|
125
|
+
on a trivial turn comes from ROUTING it to a non-reasoning model (8 tokens against 196, measured),
|
|
126
|
+
which is the other tooth of the same tier. Claiming this cap saves tokens would be the kind of
|
|
127
|
+
knob-that-does-nothing it exists to defend against.
|
|
128
|
+
|
|
129
|
+
`effort` of "" means no budget was stated — today's behaviour, unchanged.
|
|
130
|
+
"""
|
|
131
|
+
budget = engine_guard.reasoning_char_budget(effort)
|
|
132
|
+
if budget is None or len(text) <= budget:
|
|
133
|
+
return text, False
|
|
134
|
+
if budget == 0:
|
|
135
|
+
return "", True
|
|
136
|
+
# Cut at a boundary rather than mid-word, so a clamped monologue reads as truncated rather than
|
|
137
|
+
# corrupted. The marker is NOT added: this text goes through the same §6A screen as any other
|
|
138
|
+
# reasoning, and injecting our own sentence into a channel the model owns would make the screen
|
|
139
|
+
# judge our words as the model's.
|
|
140
|
+
cut = text.rfind(" ", 0, budget)
|
|
141
|
+
return text[:cut if cut > budget // 2 else budget], True
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def split_inline_reasoning(text: str) -> "Tuple[str, str]":
|
|
145
|
+
"""(content, reasoning) — pull an INLINE monologue out of an answer. THE BACKSTOP.
|
|
146
|
+
|
|
147
|
+
Thinking is always on now (`gateway.THINK_ALWAYS`), and a model that honours it puts its monologue on
|
|
148
|
+
its own channel. Some do not: measured 2026-10-03, qwen3:4b under `think: false` returned 0 characters
|
|
149
|
+
of `message.thinking` and a 5083-character answer beginning "Okay, so I need to prove..." — the
|
|
150
|
+
monologue relocated into the text the user reads. This function is what keeps `content` clean when a
|
|
151
|
+
model does that anyway.
|
|
152
|
+
|
|
153
|
+
DELIBERATELY ONLY TAGGED BLOCKS. A monologue with no markers around it is not distinguishable from an
|
|
154
|
+
answer that happens to begin "Okay, so..." — a heuristic on prose would eventually eat somebody's real
|
|
155
|
+
first paragraph, which is far worse than leaving one monologue in place. If a model emits an untagged
|
|
156
|
+
monologue, that is reported, not guessed at.
|
|
157
|
+
|
|
158
|
+
An UNCLOSED opening tag means the answer was cut off mid-thought, so everything after it is reasoning
|
|
159
|
+
and the content is whatever preceded it. Returning the half-thought as the answer would show the user
|
|
160
|
+
the one thing this exists to hide.
|
|
161
|
+
"""
|
|
162
|
+
if not text or "<" not in text:
|
|
163
|
+
return text, ""
|
|
164
|
+
low = text.lower()
|
|
165
|
+
content_parts: "List[str]" = []
|
|
166
|
+
reasoning_parts: "List[str]" = []
|
|
167
|
+
i = 0
|
|
168
|
+
while i < len(text):
|
|
169
|
+
nxt = None
|
|
170
|
+
for open_tag, close_tag in _THINK_TAGS:
|
|
171
|
+
at = low.find(open_tag, i)
|
|
172
|
+
if at != -1 and (nxt is None or at < nxt[0]):
|
|
173
|
+
nxt = (at, open_tag, close_tag)
|
|
174
|
+
if nxt is None:
|
|
175
|
+
content_parts.append(text[i:])
|
|
176
|
+
break
|
|
177
|
+
at, open_tag, close_tag = nxt
|
|
178
|
+
content_parts.append(text[i:at])
|
|
179
|
+
end = low.find(close_tag, at + len(open_tag))
|
|
180
|
+
if end == -1:
|
|
181
|
+
reasoning_parts.append(text[at + len(open_tag):])
|
|
182
|
+
i = len(text)
|
|
183
|
+
break
|
|
184
|
+
reasoning_parts.append(text[at + len(open_tag):end])
|
|
185
|
+
i = end + len(close_tag)
|
|
186
|
+
# The content is re-joined and stripped of the whitespace the removed block leaves behind, so an
|
|
187
|
+
# answer does not arrive with a blank first line for a reason the reader cannot see.
|
|
188
|
+
return "".join(content_parts).strip(), "".join(reasoning_parts).strip()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class InlineReasoningSplitter:
|
|
192
|
+
"""`split_inline_reasoning` for a STREAM, where a tag can straddle two deltas.
|
|
193
|
+
|
|
194
|
+
The web app streams, so this is the half that users actually meet. A naive per-delta split would miss
|
|
195
|
+
`<thi` + `nk>` arriving as two chunks and then leak the whole monologue into the answer — so this holds
|
|
196
|
+
back up to one tag's worth of trailing text until it knows whether it is the start of one.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
def __init__(self) -> None:
|
|
200
|
+
self._buf = ""
|
|
201
|
+
self._in_think = False
|
|
202
|
+
self._close = ""
|
|
203
|
+
|
|
204
|
+
def feed(self, text: str) -> "Tuple[str, str]":
|
|
205
|
+
"""(content, reasoning) safe to emit NOW. Whatever might still be half a tag is retained."""
|
|
206
|
+
self._buf += text
|
|
207
|
+
content, reasoning = self._drain(final=False)
|
|
208
|
+
return content, reasoning
|
|
209
|
+
|
|
210
|
+
def finish(self) -> "Tuple[str, str]":
|
|
211
|
+
"""Flush everything held back. An unclosed block ends up as reasoning, per the rule above."""
|
|
212
|
+
content, reasoning = self._drain(final=True)
|
|
213
|
+
return content, reasoning
|
|
214
|
+
|
|
215
|
+
def _drain(self, *, final: bool) -> "Tuple[str, str]":
|
|
216
|
+
out_c: "List[str]" = []
|
|
217
|
+
out_r: "List[str]" = []
|
|
218
|
+
while self._buf:
|
|
219
|
+
low = self._buf.lower()
|
|
220
|
+
if self._in_think:
|
|
221
|
+
end = low.find(self._close)
|
|
222
|
+
if end == -1:
|
|
223
|
+
keep = 0 if final else min(len(self._buf), _LONGEST_TAG - 1)
|
|
224
|
+
emit = self._buf[:len(self._buf) - keep]
|
|
225
|
+
out_r.append(emit)
|
|
226
|
+
self._buf = self._buf[len(self._buf) - keep:]
|
|
227
|
+
break
|
|
228
|
+
out_r.append(self._buf[:end])
|
|
229
|
+
self._buf = self._buf[end + len(self._close):]
|
|
230
|
+
self._in_think = False
|
|
231
|
+
continue
|
|
232
|
+
nxt = None
|
|
233
|
+
for open_tag, close_tag in _THINK_TAGS:
|
|
234
|
+
at = low.find(open_tag)
|
|
235
|
+
if at != -1 and (nxt is None or at < nxt[0]):
|
|
236
|
+
nxt = (at, open_tag, close_tag)
|
|
237
|
+
if nxt is None:
|
|
238
|
+
keep = 0 if final else min(len(self._buf), _LONGEST_TAG - 1)
|
|
239
|
+
emit = self._buf[:len(self._buf) - keep]
|
|
240
|
+
out_c.append(emit)
|
|
241
|
+
self._buf = self._buf[len(self._buf) - keep:]
|
|
242
|
+
break
|
|
243
|
+
at, open_tag, close_tag = nxt
|
|
244
|
+
out_c.append(self._buf[:at])
|
|
245
|
+
self._buf = self._buf[at + len(open_tag):]
|
|
246
|
+
self._in_think, self._close = True, close_tag
|
|
247
|
+
return "".join(out_c), "".join(out_r)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def resolve_model(model: str) -> str:
|
|
251
|
+
"""Map the network alias / catalog id to a concrete installed model, then CLAMP
|
|
252
|
+
it to what this node advertises.
|
|
253
|
+
|
|
254
|
+
Order: network alias → the node's default; catalog id (e.g. "llama-3.2-1b")
|
|
255
|
+
→ its real Ollama tag ("llama3.2:1b"); anything else passes through as-is.
|
|
256
|
+
Without the catalog mapping Ollama 404s ("model not found") on catalog ids.
|
|
257
|
+
|
|
258
|
+
The clamp is the security part: previously ANY string the gateway sent was asked
|
|
259
|
+
of the engine verbatim, and an unauthenticated engine treats "a model I do not
|
|
260
|
+
have" as "a model I should go and download". `engine_guard.check_model` raises
|
|
261
|
+
instead, so the gateway re-plans onto a node that really serves it.
|
|
262
|
+
"""
|
|
263
|
+
if (model or "") in _ALIASES:
|
|
264
|
+
return engine_guard.check_model(model, DEFAULT_MODEL)
|
|
265
|
+
from .manifest import OLLAMA_TAGS
|
|
266
|
+
return engine_guard.check_model(model, OLLAMA_TAGS.get(model, model))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# The small embedding model a node runs for RAG retrieval (§5.2). Separate from the chat
|
|
270
|
+
# model — a tiny model (nomic-embed-text ≈ 275 MB, bge-m3, …) so retrieval is cheap.
|
|
271
|
+
EMBED_MODEL = envvar("EMBED_MODEL", "nomic-embed-text")
|
|
272
|
+
_EMBED_TIMEOUT_S = float(envvar("EMBED_TIMEOUT_S", "60"))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
async def embed(texts: List[str], model: Optional[str] = None) -> List[List[float]]:
|
|
276
|
+
"""Embed a batch of texts via the local Ollama server's /api/embed endpoint.
|
|
277
|
+
|
|
278
|
+
Returns one vector per input, in order. Raises on transport/engine errors so the caller
|
|
279
|
+
can fall back (e.g. to keyword retrieval) rather than silently returning nothing. Only the
|
|
280
|
+
Ollama engine serves embeddings here; a llama.cpp chat node has no embed endpoint, so a
|
|
281
|
+
caller on such a node should fall back."""
|
|
282
|
+
if not texts:
|
|
283
|
+
return []
|
|
284
|
+
# Through the SAME model clamp as chat. This was the one engine call that skipped
|
|
285
|
+
# it: a caller (or a future signature change that lets a job name the embedder)
|
|
286
|
+
# could put any string in `model`, and an unauthenticated engine treats a model it
|
|
287
|
+
# does not have as one to go and download onto the owner's disk.
|
|
288
|
+
want = model or EMBED_MODEL
|
|
289
|
+
mdl = engine_guard.check_model(want, want)
|
|
290
|
+
data = await engine_guard.post_json(
|
|
291
|
+
OLLAMA_HOST, engine_guard.EMBED_OLLAMA,
|
|
292
|
+
{"model": mdl, "input": [str(t or "") for t in texts]}, _EMBED_TIMEOUT_S)
|
|
293
|
+
# /api/embed returns {"embeddings": [[...], ...]}; be tolerant of the older singular field.
|
|
294
|
+
embs = data.get("embeddings")
|
|
295
|
+
if embs is None and "embedding" in data:
|
|
296
|
+
embs = [data["embedding"]]
|
|
297
|
+
return embs or []
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
async def _run_ollama(
|
|
301
|
+
model: str, messages: List[Dict], tools: "Optional[List[Dict]]" = None,
|
|
302
|
+
tool_choice: "Optional[object]" = None, think: "Optional[bool]" = None,
|
|
303
|
+
think_effort: str = "",
|
|
304
|
+
) -> Dict:
|
|
305
|
+
real_model = resolve_model(model)
|
|
306
|
+
# Sanitised, bounded copies — the job's own dicts never reach the engine. This is
|
|
307
|
+
# what drops unknown message fields (e.g. Ollama's `images`) and scrubs tool schemas.
|
|
308
|
+
messages = engine_guard.sanitize_messages(messages)
|
|
309
|
+
choice = engine_guard.sanitize_tool_choice(tool_choice)
|
|
310
|
+
payload: Dict = {"model": real_model, "messages": messages, "stream": False,
|
|
311
|
+
"options": engine_guard.ollama_options()}
|
|
312
|
+
# Thinking is opt-OUT per turn, decided by the orchestrator's difficulty triage. Ollama
|
|
313
|
+
# ignores `think` on models that do not reason, so this is safe across a mixed fleet.
|
|
314
|
+
want_think = engine_guard.sanitize_think(think)
|
|
315
|
+
if want_think is not None:
|
|
316
|
+
payload["think"] = want_think
|
|
317
|
+
# tool_choice "none" means "never call a tool" — enforce it by withholding the tools
|
|
318
|
+
# entirely (backend-independent), rather than trusting the engine to honor the flag.
|
|
319
|
+
# For "auto" (the default) we just send the tools; for "required"/a named function we
|
|
320
|
+
# also forward tool_choice so an engine that honors it (Ollama native support is
|
|
321
|
+
# partial) can, and engines that ignore it fall back to normal auto behavior.
|
|
322
|
+
safe_tools = engine_guard.sanitize_tools(tools, tool_choice=choice)
|
|
323
|
+
if safe_tools:
|
|
324
|
+
payload["tools"] = safe_tools # Ollama /api/chat natively supports tool-calling (agentic mode)
|
|
325
|
+
if choice and choice != "auto":
|
|
326
|
+
payload["tool_choice"] = choice
|
|
327
|
+
data = await engine_guard.post_json(
|
|
328
|
+
OLLAMA_HOST, engine_guard.CHAT_OLLAMA, payload, _TIMEOUT_S)
|
|
329
|
+
msg = data.get("message", {}) or {}
|
|
330
|
+
content = engine_guard.OutputMeter().add(msg.get("content", "") or "")
|
|
331
|
+
# THE THINKING CHANNEL, on the NON-STREAMING path. `stream_ollama` has always read
|
|
332
|
+
# `message.thinking` and yielded it as a `reasoning` event; this function never looked, so a
|
|
333
|
+
# non-streaming turn lost the channel at the very first parse — before the node, before the
|
|
334
|
+
# gateway. MEASURED 2026-10-03 against qwen3:4b: 2979 characters of `message.thinking` arrive
|
|
335
|
+
# beside a clean 2016-character answer, and every one of them was being discarded here.
|
|
336
|
+
reasoning = msg.get("thinking") or ""
|
|
337
|
+
# THE BACKSTOP. Thinking is always on, and a model that honours it puts the monologue on the line
|
|
338
|
+
# above. One that emits it INLINE instead gets it pulled out here, so `content` is clean either way
|
|
339
|
+
# and the governance channel gets the thinking rather than losing it. Any already-separate channel
|
|
340
|
+
# wins the ordering: its text came first.
|
|
341
|
+
content, inline = split_inline_reasoning(content)
|
|
342
|
+
if inline:
|
|
343
|
+
reasoning = f"{reasoning}\n{inline}".strip() if reasoning else inline
|
|
344
|
+
# THE TURN'S EFFORT BUDGET, applied AFTER the inline backstop on purpose: a model that monologues
|
|
345
|
+
# inline must not escape the cap by putting its thinking in the wrong channel.
|
|
346
|
+
reasoning, _clamped = clamp_reasoning(reasoning, engine_guard.sanitize_think_effort(think_effort))
|
|
347
|
+
tool_calls = msg.get("tool_calls") or [] # present when the model wants to call a tool
|
|
348
|
+
prompt_tokens = data.get("prompt_eval_count") or estimate_tokens(
|
|
349
|
+
" ".join(m.get("content", "") for m in messages if isinstance(m.get("content"), str)))
|
|
350
|
+
completion_tokens = data.get("eval_count") or estimate_tokens(content)
|
|
351
|
+
return {
|
|
352
|
+
"content": content,
|
|
353
|
+
"reasoning": reasoning,
|
|
354
|
+
"tool_calls": tool_calls,
|
|
355
|
+
"prompt_tokens": int(prompt_tokens),
|
|
356
|
+
"tokens": int(completion_tokens),
|
|
357
|
+
"backend": f"ollama:{real_model}",
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
async def _run_llamacpp(
|
|
362
|
+
model: str, messages: List[Dict], tools: "Optional[List[Dict]]" = None,
|
|
363
|
+
tool_choice: "Optional[object]" = None, think_effort: str = "",
|
|
364
|
+
) -> Dict:
|
|
365
|
+
"""Non-streaming turn against a local llama.cpp server (OpenAI /v1/chat/completions). Used for
|
|
366
|
+
sharded-GGUF models (Kimi K2.x/K3) that Ollama cannot serve. No mock — raises on engine failure."""
|
|
367
|
+
real_model = resolve_model(model)
|
|
368
|
+
messages = engine_guard.sanitize_messages(messages)
|
|
369
|
+
tc = engine_guard.sanitize_tool_choice(tool_choice)
|
|
370
|
+
# 03-F2, the COMPUTE half of the saving. `cache_prompt` makes llama.cpp reuse the KV
|
|
371
|
+
# cache for the shared prefix instead of re-evaluating 19 turns of context on turn 20.
|
|
372
|
+
# `deltasync` owned this option and nothing called it, so the reuse was never asked
|
|
373
|
+
# for — the transfer half (delta-sync proper) is still unwired, but this half costs a
|
|
374
|
+
# single field and is the one that saves GPU time rather than bytes.
|
|
375
|
+
#
|
|
376
|
+
# It goes HERE and not in `ollama_options()`: `cache_prompt` is a llama.cpp field, and
|
|
377
|
+
# Ollama would silently ignore it — wiring it there would look done and do nothing.
|
|
378
|
+
payload: Dict = {"model": real_model, "messages": messages, "stream": False,
|
|
379
|
+
"temperature": 0.0,
|
|
380
|
+
**deltasync.engine_cache_options(),
|
|
381
|
+
"max_tokens": engine_guard.llamacpp_max_tokens(_LLAMACPP_MAX_TOKENS)}
|
|
382
|
+
safe_tools = engine_guard.sanitize_tools(tools, tool_choice=tc)
|
|
383
|
+
if safe_tools:
|
|
384
|
+
payload["tools"] = safe_tools
|
|
385
|
+
payload["tool_choice"] = tc if (tc and tc != "auto") else "auto"
|
|
386
|
+
data = await engine_guard.post_json(
|
|
387
|
+
LLAMACPP_HOST, engine_guard.CHAT_OPENAI, payload, _LLAMACPP_TIMEOUT_S)
|
|
388
|
+
first = (data.get("choices") or [{}])[0]
|
|
389
|
+
msg = first.get("message", {}) or {}
|
|
390
|
+
content = engine_guard.OutputMeter().add(msg.get("content") or "")
|
|
391
|
+
# Same omission as the Ollama branch above, same fix. `stream_llamacpp` reads
|
|
392
|
+
# `delta.reasoning_content`; non-streamed it arrives as `message.reasoning_content`.
|
|
393
|
+
reasoning = msg.get("reasoning_content") or ""
|
|
394
|
+
content, inline = split_inline_reasoning(content) # same backstop as the Ollama branch
|
|
395
|
+
if inline:
|
|
396
|
+
reasoning = f"{reasoning}\n{inline}".strip() if reasoning else inline
|
|
397
|
+
# The turn's effort budget, applied after the inline backstop for the same reason as the Ollama
|
|
398
|
+
# branch: a model that monologues inline must not escape the cap by using the wrong channel.
|
|
399
|
+
reasoning, _ = clamp_reasoning(reasoning, engine_guard.sanitize_think_effort(think_effort))
|
|
400
|
+
tool_calls = msg.get("tool_calls") or []
|
|
401
|
+
usage = data.get("usage") or {}
|
|
402
|
+
prompt_tokens = usage.get("prompt_tokens") or estimate_tokens(
|
|
403
|
+
" ".join(m.get("content", "") for m in messages if isinstance(m.get("content"), str)))
|
|
404
|
+
completion_tokens = usage.get("completion_tokens") or estimate_tokens(content)
|
|
405
|
+
return {
|
|
406
|
+
"content": content, "reasoning": reasoning, "tool_calls": tool_calls,
|
|
407
|
+
"prompt_tokens": int(prompt_tokens), "tokens": int(completion_tokens),
|
|
408
|
+
"backend": f"llamacpp:{real_model}",
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
async def stream_llamacpp(
|
|
413
|
+
model: str, messages: List[Dict], tools: "Optional[List[Dict]]" = None,
|
|
414
|
+
tool_choice: "Optional[object]" = None, think: "Optional[bool]" = None,
|
|
415
|
+
think_effort: str = "",
|
|
416
|
+
) -> "AsyncIterator[Dict]":
|
|
417
|
+
"""Stream a local llama.cpp turn (OpenAI /v1 SSE). Yields the reasoning channel
|
|
418
|
+
(delta.reasoning_content) then the answer (delta.content), matching stream_ollama's shape."""
|
|
419
|
+
real_model = resolve_model(model)
|
|
420
|
+
messages = engine_guard.sanitize_messages(messages)
|
|
421
|
+
tc = engine_guard.sanitize_tool_choice(tool_choice)
|
|
422
|
+
# 03-F2, the COMPUTE half of the saving. `cache_prompt` makes llama.cpp reuse the KV
|
|
423
|
+
# cache for the shared prefix instead of re-evaluating 19 turns of context on turn 20.
|
|
424
|
+
# `deltasync` owned this option and nothing called it, so the reuse was never asked
|
|
425
|
+
# for — the transfer half (delta-sync proper) is still unwired, but this half costs a
|
|
426
|
+
# single field and is the one that saves GPU time rather than bytes.
|
|
427
|
+
#
|
|
428
|
+
# It goes HERE and not in `ollama_options()`: `cache_prompt` is a llama.cpp field, and
|
|
429
|
+
# Ollama would silently ignore it — wiring it there would look done and do nothing.
|
|
430
|
+
payload: Dict = {"model": real_model, "messages": messages, "stream": True,
|
|
431
|
+
"temperature": 0.0,
|
|
432
|
+
**deltasync.engine_cache_options(),
|
|
433
|
+
"max_tokens": engine_guard.llamacpp_max_tokens(_LLAMACPP_MAX_TOKENS)}
|
|
434
|
+
safe_tools = engine_guard.sanitize_tools(tools, tool_choice=tc)
|
|
435
|
+
if safe_tools:
|
|
436
|
+
payload["tools"] = safe_tools
|
|
437
|
+
payload["tool_choice"] = tc if (tc and tc != "auto") else "auto"
|
|
438
|
+
content_parts: List[str] = []
|
|
439
|
+
_gate = ReasoningGate(engine_guard.sanitize_think_effort(think_effort))
|
|
440
|
+
splitter = InlineReasoningSplitter() # same backstop as the Ollama stream
|
|
441
|
+
tool_calls: List = []
|
|
442
|
+
# Bounds the WHOLE turn (reasoning included) whatever the engine does with max_tokens.
|
|
443
|
+
meter = engine_guard.OutputMeter()
|
|
444
|
+
async with engine_guard.stream(
|
|
445
|
+
LLAMACPP_HOST, engine_guard.CHAT_OPENAI, payload, _LLAMACPP_TIMEOUT_S) as r:
|
|
446
|
+
async for line in r.aiter_lines():
|
|
447
|
+
line = (line or "").strip()
|
|
448
|
+
if not line.startswith("data:"):
|
|
449
|
+
continue
|
|
450
|
+
body = line[5:].strip()
|
|
451
|
+
if body == "[DONE]":
|
|
452
|
+
break
|
|
453
|
+
try:
|
|
454
|
+
delta = (json.loads(body).get("choices") or [{}])[0].get("delta", {}) or {}
|
|
455
|
+
except Exception:
|
|
456
|
+
continue
|
|
457
|
+
th = delta.get("reasoning_content")
|
|
458
|
+
if th:
|
|
459
|
+
meter.add(th)
|
|
460
|
+
_th = _gate.allow(th)
|
|
461
|
+
if _th:
|
|
462
|
+
yield {"type": "reasoning", "text": _th}
|
|
463
|
+
c = delta.get("content")
|
|
464
|
+
if c:
|
|
465
|
+
meter.add(c)
|
|
466
|
+
# A tag can straddle two deltas, so route through the splitter rather than per-chunk.
|
|
467
|
+
c_out, r_out = splitter.feed(c)
|
|
468
|
+
if r_out:
|
|
469
|
+
_ro = _gate.allow(r_out)
|
|
470
|
+
if _ro:
|
|
471
|
+
yield {"type": "reasoning", "text": _ro}
|
|
472
|
+
if c_out:
|
|
473
|
+
content_parts.append(c_out)
|
|
474
|
+
yield {"type": "content", "text": c_out}
|
|
475
|
+
if delta.get("tool_calls"):
|
|
476
|
+
tool_calls = delta["tool_calls"]
|
|
477
|
+
_c, _r = splitter.finish() # flush whatever was held back as a possible tag
|
|
478
|
+
if _r:
|
|
479
|
+
_rf = _gate.allow(_r)
|
|
480
|
+
if _rf:
|
|
481
|
+
yield {"type": "reasoning", "text": _rf}
|
|
482
|
+
if _c:
|
|
483
|
+
content_parts.append(_c)
|
|
484
|
+
yield {"type": "content", "text": _c}
|
|
485
|
+
content = "".join(content_parts)
|
|
486
|
+
yield {
|
|
487
|
+
# FROM THE GATE, not from a second accumulator: what is stored is exactly what was
|
|
488
|
+
# forwarded. Two lists would be two chances to disagree about what the model said.
|
|
489
|
+
"type": "done", "content": content, "reasoning": _gate.text(),
|
|
490
|
+
"tool_calls": tool_calls,
|
|
491
|
+
"prompt_tokens": estimate_tokens(
|
|
492
|
+
" ".join(m.get("content", "") for m in messages if isinstance(m.get("content"), str))),
|
|
493
|
+
"tokens": estimate_tokens(content), "backend": f"llamacpp:{real_model}",
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def stream_turn(
|
|
498
|
+
model: str, messages: List[Dict], tools: "Optional[List[Dict]]" = None,
|
|
499
|
+
tool_choice: "Optional[object]" = None, think: "Optional[bool]" = None,
|
|
500
|
+
think_effort: str = "",
|
|
501
|
+
) -> "AsyncIterator[Dict]":
|
|
502
|
+
"""Stream via THIS node's engine — llama.cpp for sharded-GGUF nodes, else Ollama.
|
|
503
|
+
|
|
504
|
+
`think_effort` IS FORWARDED, and it is worth saying so in a docstring because for one commit it
|
|
505
|
+
was not: this function accepted the budget and dropped it, so the whole streaming path — which is
|
|
506
|
+
what chat uses — ran unbounded while every signature said otherwise. A parameter that is accepted
|
|
507
|
+
and ignored is the exact defect this budget exists to avoid, and it reached the inside of the
|
|
508
|
+
feature. Caught by deriving the test's targets from the node's call sites instead of listing them.
|
|
509
|
+
"""
|
|
510
|
+
if ENGINE == "llamacpp":
|
|
511
|
+
return stream_llamacpp(model, messages, tools, tool_choice, think,
|
|
512
|
+
think_effort=think_effort)
|
|
513
|
+
return stream_ollama(model, messages, tools, tool_choice, think, think_effort=think_effort)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
async def stream_ollama(
|
|
517
|
+
model: str, messages: List[Dict], tools: "Optional[List[Dict]]" = None,
|
|
518
|
+
tool_choice: "Optional[object]" = None, think: "Optional[bool]" = None,
|
|
519
|
+
think_effort: str = "",
|
|
520
|
+
) -> "AsyncIterator[Dict]":
|
|
521
|
+
"""Stream a real Ollama /api/chat turn (§5A/§5B foundation). Yields event dicts as
|
|
522
|
+
tokens arrive — the model's separate REASONING channel first, then the answer:
|
|
523
|
+
{"type": "reasoning", "text": <delta>} # Ollama `thinking` field (think=True)
|
|
524
|
+
{"type": "content", "text": <delta>} # answer tokens
|
|
525
|
+
{"type": "done", "content", "reasoning", "tool_calls", "prompt_tokens",
|
|
526
|
+
"tokens", "backend"} # final aggregate (for metering/history)
|
|
527
|
+
No mock: streams straight from the engine; raises on failure so the caller re-routes."""
|
|
528
|
+
real_model = resolve_model(model)
|
|
529
|
+
messages = engine_guard.sanitize_messages(messages)
|
|
530
|
+
choice = engine_guard.sanitize_tool_choice(tool_choice)
|
|
531
|
+
payload: Dict = {"model": real_model, "messages": messages, "stream": True,
|
|
532
|
+
"options": engine_guard.ollama_options()}
|
|
533
|
+
if think is not None:
|
|
534
|
+
payload["think"] = think # Ollama 0.30 native reasoning channel
|
|
535
|
+
safe_tools = engine_guard.sanitize_tools(tools, tool_choice=choice)
|
|
536
|
+
if safe_tools:
|
|
537
|
+
payload["tools"] = safe_tools
|
|
538
|
+
if choice and choice != "auto":
|
|
539
|
+
payload["tool_choice"] = choice
|
|
540
|
+
content_parts: List[str] = []
|
|
541
|
+
_gate = ReasoningGate(engine_guard.sanitize_think_effort(think_effort))
|
|
542
|
+
tool_calls: List = []
|
|
543
|
+
prompt_tokens = completion_tokens = 0
|
|
544
|
+
meter = engine_guard.OutputMeter()
|
|
545
|
+
splitter = InlineReasoningSplitter()
|
|
546
|
+
async with engine_guard.stream(
|
|
547
|
+
OLLAMA_HOST, engine_guard.CHAT_OLLAMA, payload, _TIMEOUT_S) as r:
|
|
548
|
+
async for line in r.aiter_lines():
|
|
549
|
+
if not line or not line.strip():
|
|
550
|
+
continue
|
|
551
|
+
data = json.loads(line)
|
|
552
|
+
msg = data.get("message", {}) or {}
|
|
553
|
+
th = msg.get("thinking")
|
|
554
|
+
if th:
|
|
555
|
+
meter.add(th)
|
|
556
|
+
_th = _gate.allow(th)
|
|
557
|
+
if _th:
|
|
558
|
+
yield {"type": "reasoning", "text": _th}
|
|
559
|
+
c = msg.get("content")
|
|
560
|
+
if c:
|
|
561
|
+
meter.add(c)
|
|
562
|
+
# THE BACKSTOP, STREAMED. A tag can straddle two deltas, so the splitter holds back up
|
|
563
|
+
# to one tag's worth of text rather than letting `<thi` + `nk>` through as answer text.
|
|
564
|
+
# Emitting the monologue as `content` would put it on the user's screen, which is the
|
|
565
|
+
# whole failure being defended against.
|
|
566
|
+
c_out, r_out = splitter.feed(c)
|
|
567
|
+
if r_out:
|
|
568
|
+
_ro = _gate.allow(r_out)
|
|
569
|
+
if _ro:
|
|
570
|
+
yield {"type": "reasoning", "text": _ro}
|
|
571
|
+
if c_out:
|
|
572
|
+
content_parts.append(c_out)
|
|
573
|
+
yield {"type": "content", "text": c_out}
|
|
574
|
+
if msg.get("tool_calls"):
|
|
575
|
+
tool_calls = msg["tool_calls"]
|
|
576
|
+
if data.get("done"):
|
|
577
|
+
prompt_tokens = data.get("prompt_eval_count") or estimate_tokens(
|
|
578
|
+
" ".join(m.get("content", "") for m in messages if isinstance(m.get("content"), str)))
|
|
579
|
+
completion_tokens = data.get("eval_count") or estimate_tokens("".join(content_parts))
|
|
580
|
+
# FLUSH. The splitter holds back a possible partial tag, so without this the last few characters of
|
|
581
|
+
# an answer would simply never arrive — and an unclosed block would be left dangling.
|
|
582
|
+
_c, _r = splitter.finish()
|
|
583
|
+
if _r:
|
|
584
|
+
_rf = _gate.allow(_r)
|
|
585
|
+
if _rf:
|
|
586
|
+
yield {"type": "reasoning", "text": _rf}
|
|
587
|
+
if _c:
|
|
588
|
+
content_parts.append(_c)
|
|
589
|
+
yield {"type": "content", "text": _c}
|
|
590
|
+
yield {
|
|
591
|
+
"type": "done",
|
|
592
|
+
"content": "".join(content_parts),
|
|
593
|
+
"reasoning": _gate.text(),
|
|
594
|
+
"tool_calls": tool_calls,
|
|
595
|
+
"prompt_tokens": int(prompt_tokens),
|
|
596
|
+
"tokens": int(completion_tokens),
|
|
597
|
+
"backend": f"ollama:{real_model}",
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
async def run_inference(
|
|
602
|
+
model: str,
|
|
603
|
+
messages: List[Dict],
|
|
604
|
+
region: Optional[str] = None,
|
|
605
|
+
load: float = 0.0,
|
|
606
|
+
compute_type: str = "gpu",
|
|
607
|
+
shard: "Optional[Tuple[int, int]]" = None,
|
|
608
|
+
tools: "Optional[List[Dict]]" = None,
|
|
609
|
+
tool_choice: "Optional[object]" = None,
|
|
610
|
+
think: "Optional[bool]" = None,
|
|
611
|
+
think_effort: str = "",
|
|
612
|
+
) -> Dict:
|
|
613
|
+
"""Run the real model and return {content, tokens, prompt_tokens, backend}.
|
|
614
|
+
|
|
615
|
+
In a RAM-pool (shard set), only the lead (shard 0) computes the answer — an
|
|
616
|
+
Ollama run can't be split across nodes, so non-lead members return
|
|
617
|
+
participation-only rather than duplicating work or faking output. Raises on
|
|
618
|
+
any engine failure so the caller re-routes instead of returning a fake."""
|
|
619
|
+
if shard is not None and shard[0] != 0:
|
|
620
|
+
return {"content": "", "tool_calls": [], "prompt_tokens": 0, "tokens": 0, "backend": "pool-member"}
|
|
621
|
+
if ENGINE == "llamacpp":
|
|
622
|
+
return await _run_llamacpp(model, messages, tools, tool_choice, think_effort=think_effort)
|
|
623
|
+
return await _run_ollama(model, messages, tools, tool_choice, think,
|
|
624
|
+
think_effort=think_effort)
|