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/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)