Coverage for src/lilbee/providers/fleet/client.py: 100%

851 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +0000

1"""Thin httpx client for one llama-server OpenAI endpoint (local inference).""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6import base64 

7import contextlib 

8import json 

9import logging 

10import math 

11import socket 

12import ssl 

13import threading 

14import time 

15from collections.abc import Callable, Generator, Iterator, Mapping, Sequence 

16from concurrent.futures import ThreadPoolExecutor 

17from pathlib import Path 

18from typing import Any, Literal, TypedDict, TypeVar, overload 

19 

20import httpx 

21import numpy as np 

22import numpy.typing as npt 

23 

24from lilbee.core.config import cfg 

25from lilbee.core.vectors import Vector 

26from lilbee.providers.base import ( 

27 THINK_CLOSE_TAG, 

28 THINK_OPEN_TAG, 

29 ChatResult, 

30 ChatToolResult, 

31 ClosableIterator, 

32 FinishReason, 

33 ProviderError, 

34 ProviderErrorKind, 

35 StreamFinish, 

36 TokenUsage, 

37 ToolCall, 

38 ToolCallDelta, 

39) 

40from lilbee.providers.fleet.adapters import LLM_RERANK_CONCURRENCY 

41from lilbee.providers.fleet.normalize import ChatMessage, to_alternating 

42from lilbee.providers.roles import RerankMode 

43from lilbee.runtime.cancellation import CancelSignal, TaskCancelledError 

44 

45_PROVIDER_NAME = "llama-server" 

46# Shown when a stream breaks after its first frame. The client cannot know why. 

47_STREAM_INTERRUPTED_MESSAGE = ( 

48 "The model server stopped answering and the answer is cut. Send your message again." 

49) 

50 

51# Fleet clients only ever talk to a loopback llama-server over plain HTTP, so TLS is 

52# never negotiated. httpx still builds a default SSL context per client (loads the 

53# system CA bundle, ~13 ms each and slower on macOS via the keychain), which is pure 

54# overhead paid on every fleet reload. Build one minimal context and share it so a 

55# reload doesn't reload the CA bundle for each replica. 

56_LOOPBACK_SSL_CONTEXT = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) 

57_LOOPBACK_SSL_CONTEXT.check_hostname = False 

58_LOOPBACK_SSL_CONTEXT.verify_mode = ssl.CERT_NONE 

59# Reranker pair format: query and candidate are joined with this separator into 

60# one document so a cross-encoder GGUF scores the pair as a single sequence. 

61_RERANK_PAIR_SEPARATOR = "</s></s>" 

62# LLM reranker: score each candidate by the yes/no first-token logprob. 

63_LLM_RERANK_PROMPT = ( 

64 "Judge whether the document is relevant to the query. " 

65 "Answer with only 'yes' or 'no'.\n\nQuery: {query}\nDocument: {document}" 

66) 

67_LLM_RERANK_TOP_LOGPROBS = 20 

68_YES_LABEL = "yes" 

69_NO_LABEL = "no" 

70_LLM_RERANK_NO_VERDICT_ERROR = ( 

71 "The reranker model never answered 'yes' or 'no', so its relevance scores are " 

72 "unusable. Its chat template does not fit the relevance prompt. Choose a GGUF " 

73 "built for reranking, set reranker_type to cross_encoder, or adapt " 

74 "reranker_prompt to the model's expected format." 

75) 

76# Max sequences per /v1/embeddings request. Like the in-process backstop, a 

77# batch is bounded by BOTH the token budget (the server's n_batch, == token_cap) 

78# and this sequence count: a corpus of many tiny chunks would otherwise pack one 

79# request past the server's batch/sequence limit and trip a 500. 

80_EMBED_N_SEQ_MAX = 64 

81# Estimate a chunk's token count from its character length so the bulk embed 

82# path packs sub-batches without a /tokenize round-trip per input. The factor is 

83# held below the corpus average (data.extract.chunk.CHARS_PER_TOKEN, 4 for Latin text) 

84# so the estimate over-counts tokens and a sub-batch never packs past the 

85# server's n_batch (== token_cap). Rerank pairs are token-dense (the separator 

86# is several tokens in a few chars), so an under-count can slip an over-cap 

87# pair through; the rerank path re-truncates exactly on the server's overflow 

88# error instead of paying a /tokenize round trip per pair up front. 

89_EMBED_EST_CHARS_PER_TOKEN = 3 

90# llama-swap's error body when the spawned llama-server exited before serving. 

91_UPSTREAM_DIED_MARKER = "exited prematurely" 

92# llama-server's exit line when the port lilbee picked was taken by the time 

93# the server bound it (the pick-then-bind gap spans the whole lazy-spawn wait, 

94# so a passing ephemeral connection can occupy it). The occupation is 

95# transient: llama-swap re-spawns on the next request against the same port 

96# and normally binds. 

97_BIND_FAILURE_MARKER = "couldn't bind HTTP server socket" 

98# llama-server's 500 body when one input exceeds the physical batch (n_batch). 

99_BATCH_OVERFLOW_MARKER = "too large to process" 

100 

101 

102class _ChatToolSpecFunction(TypedDict, total=False): 

103 """The ``function`` payload of an OpenAI tool definition (wire shape).""" 

104 

105 name: str 

106 description: str 

107 parameters: dict[str, Any] 

108 

109 

110class ChatTool(TypedDict, total=False): 

111 """One OpenAI tool definition sent in a chat request (wire shape).""" 

112 

113 type: str 

114 function: _ChatToolSpecFunction 

115 

116 

117# Some GGUF chat templates (Mistral-Nemo, Cohere command-r) reject a standard 

118# OpenAI tool exchange: they require plain user/assistant turns to alternate and 

119# raise a Jinja exception on the tool role or two same-role turns in a row. Rather 

120# than fail a real request and parse the engine's error text, the client probes 

121# the live template once per server with this representative tool exchange: if the 

122# server rejects it as sent but renders the to_alternating() form, the model is 

123# flagged so every later request is reshaped up front. Two assistant tool-call 

124# turns separated by tool results is the minimal shape that trips strict 

125# alternation; max_tokens=1 keeps the probe to template rendering, not generation. 

126_ALTERNATION_PROBE_TOOLS: list[ChatTool] = [ 

127 { 

128 "type": "function", 

129 "function": { 

130 "name": "probe", 

131 "description": "Probe whether the chat template renders a tool exchange.", 

132 # A single declared property (rather than an empty object) so a grammar 

133 # that requires at least one parameter still renders the probe call. 

134 "parameters": { 

135 "type": "object", 

136 "properties": {"query": {"type": "string"}}, 

137 "required": ["query"], 

138 }, 

139 }, 

140 } 

141] 

142_ALTERNATION_PROBE_MESSAGES: list[ChatMessage] = [ 

143 {"role": "system", "content": "You are a helpful assistant."}, 

144 {"role": "user", "content": "Look something up."}, 

145 { 

146 "role": "assistant", 

147 "content": "", 

148 "tool_calls": [ 

149 { 

150 "id": "probe-1", 

151 "type": "function", 

152 "function": {"name": "probe", "arguments": '{"query": "x"}'}, 

153 } 

154 ], 

155 }, 

156 {"role": "tool", "tool_call_id": "probe-1", "content": "first result"}, 

157 { 

158 "role": "assistant", 

159 "content": "", 

160 "tool_calls": [ 

161 { 

162 "id": "probe-2", 

163 "type": "function", 

164 "function": {"name": "probe", "arguments": '{"query": "x"}'}, 

165 } 

166 ], 

167 }, 

168 {"role": "tool", "tool_call_id": "probe-2", "content": "second result"}, 

169 {"role": "user", "content": "Summarize."}, 

170] 

171_ALTERNATION_PROBE_OPTIONS = {"max_tokens": 1} 

172# The probe holds _alternation_lock across its request, so it uses a short, bounded 

173# timeout rather than the chat default: a slow/wedged replica yields an inconclusive 

174# (transient) result and a re-probe instead of blocking every first chat on the lock. 

175_ALTERNATION_PROBE_TIMEOUT_S = 30.0 

176_UPSTREAM_LOG_TAIL_CHARS = 2000 

177_UPSTREAM_LOG_TIMEOUT_S = 2.0 

178# Enough reads to cover one replay of llama-swap's 100KB per-model ring at 

179# httpx's 64KB ceiling per chunk, with room to spare. The route keeps streaming 

180# live lines afterwards, so without a bound this would cost the read timeout on 

181# every death. 

182_UPSTREAM_LOG_MAX_CHUNKS = 8 

183 

184log = logging.getLogger(__name__) 

185 

186 

187def _estimate_tokens(text: str) -> int: 

188 """Conservative (over-counting) token estimate from character length.""" 

189 return max(1, -(-len(text) // _EMBED_EST_CHARS_PER_TOKEN)) 

190 

191 

192_UPSTREAM_DEATH_MESSAGES: dict[ProviderErrorKind, str] = { 

193 ProviderErrorKind.CAPACITY: "The engine ran out of memory while loading {model}", 

194 ProviderErrorKind.PORT_CONFLICT: "The engine could not start {model}: its port was taken", 

195} 

196_UPSTREAM_DEATH_DEFAULT = "The engine for {model} stopped before it could answer" 

197_BROKEN_MODEL_FILE_MESSAGE = ( 

198 "The model file for {model} is incomplete or damaged. " 

199 "Run 'lilbee model rm {model}', then 'lilbee model pull {model}'." 

200) 

201_BROKEN_UNNAMED_MODEL_FILE_MESSAGE = ( 

202 "The model file is incomplete or damaged. " 

203 "Run 'lilbee model list', remove that model with 'lilbee model rm', then pull it again." 

204) 

205# What llama.cpp prints when a GGUF ends before its tensor data does. 

206_BROKEN_FILE_MARKERS: tuple[str, ...] = ( 

207 "model is corrupted or incomplete", 

208 "is not within the file bounds", 

209) 

210 

211 

212def _upstream_death_message( 

213 kind: ProviderErrorKind, status_code: int, model: str | None, engine_log: Path | None 

214) -> str: 

215 """One plain line for a model server that exited, built from what lilbee knows.""" 

216 subject = model if model is not None else "the model" 

217 if kind is ProviderErrorKind.MODEL_FILE_BROKEN: 

218 if model is None: 

219 return _BROKEN_UNNAMED_MODEL_FILE_MESSAGE 

220 return _BROKEN_MODEL_FILE_MESSAGE.format(model=model) 

221 cause = _UPSTREAM_DEATH_MESSAGES.get(kind, _UPSTREAM_DEATH_DEFAULT).format(model=subject) 

222 message = f"{cause} (HTTP {status_code})." 

223 if engine_log is None: 

224 return message 

225 return f"{message} The full engine output is in {engine_log}." 

226 

227 

228# What the engine prints when a device allocation fails during load. Every 

229# backend words it differently and all of them mean the same thing: the plan 

230# asked for more memory than the device had. Taken from the emit sites in 

231# upstream rather than guessed, and matched lowercased. 

232# 

233# One entry covers CUDA, HIP and MUSA: the vendor headers #define cudaMalloc to 

234# their own allocator, but the log string in ggml-cuda.cu is a literal, so an 

235# AMD or Moore Threads build still prints "cudaMalloc failed". A separate 

236# hipMalloc marker would match nothing. 

237# 

238# Vulkan is the one that needs its own wording. It is where every AMD and Intel 

239# GPU lands, and it says neither "out of memory" nor "failed to allocate". 

240_OOM_MARKERS: tuple[str, ...] = ( 

241 "out of memory", 

242 "failed to allocate", # Metal's buffer failure, and most generic paths 

243 "cudamalloc failed", # CUDA, HIP and MUSA alike 

244 "device memory allocation of size", # ggml-vulkan's fatal allocation failure 

245 "outofdevicememory", # a vk::OutOfDeviceMemoryError that reached the log 

246 "unable to allocate", 

247 "insufficient memory", 

248 "out_of_device_memory", # SYCL, which exits through the runtime's own code 

249 "out_of_resources", 

250) 

251 

252# Lines that report a failure the engine then works around. ggml-vulkan warns 

253# that pinned memory could not be allocated and falls back to unpinned, and that 

254# text matches an allocation marker word for word, so a later unrelated death 

255# would be read as a memory shortfall and answered with a context reduction. 

256_SURVIVABLE_LINE_MARKERS: tuple[str, ...] = ("warning:", "warn:") 

257 

258 

259def classify_upstream_death(tail: str) -> ProviderErrorKind | None: 

260 """The kind of failure an engine's dying output describes, or ``None``. 

261 

262 ``CAPACITY`` for a load that ran out of device memory: retrying the identical 

263 launch respawns it into a crash loop, while a smaller context might fit. 

264 ``PORT_CONFLICT`` for losing the port-bind race, which is worth retrying 

265 because the retry re-drives llama-swap's spawn, and worth naming because a 

266 port held for good needs a different port rather than another attempt at the 

267 same one. ``None`` leaves the existing classification alone rather than 

268 guessing at an unfamiliar death. 

269 """ 

270 fatal = [ 

271 line 

272 for line in tail.lower().splitlines() 

273 if not any(marker in line for marker in _SURVIVABLE_LINE_MARKERS) 

274 ] 

275 # A damaged file is checked first: no memory or port change makes it load. 

276 if any(marker in line for line in fatal for marker in _BROKEN_FILE_MARKERS): 

277 return ProviderErrorKind.MODEL_FILE_BROKEN 

278 if any(marker in line for line in fatal for marker in _OOM_MARKERS): 

279 return ProviderErrorKind.CAPACITY 

280 if _BIND_FAILURE_MARKER in tail: 

281 return ProviderErrorKind.PORT_CONFLICT 

282 return None 

283 

284 

285# Deaths a role's rebuild can fix, and a retry against the same launch cannot: a 

286# memory shortfall needs a smaller plan, a held port needs a different port. Both 

287# come from rebuilding the role, which re-plans and re-picks. 

288_REBUILDABLE_KINDS = frozenset({ProviderErrorKind.CAPACITY, ProviderErrorKind.PORT_CONFLICT}) 

289 

290 

291def is_load_capacity_failure(exc: BaseException) -> bool: 

292 """True when *exc* is an engine that died for lack of device memory on load.""" 

293 return isinstance(exc, ProviderError) and exc.kind is ProviderErrorKind.CAPACITY 

294 

295 

296def is_rebuildable_failure(exc: BaseException) -> bool: 

297 """True when rebuilding the role is what stands a chance, not another retry.""" 

298 return isinstance(exc, ProviderError) and exc.kind in _REBUILDABLE_KINDS 

299 

300 

301def _classify_error(status_code: int, body: str) -> ProviderErrorKind: 

302 """Error kind from a llama-server/llama-swap error status and body. 

303 

304 An input past the server's n_batch is a 500 whose body says "too large to 

305 process" (CONTEXT_OVERFLOW, so the embed path re-truncates exactly); a dead 

306 upstream is CONNECTION, so the router can mark the replica unhealthy. The 

307 body markers win over the status: llama-swap reports a died upstream under 

308 gateway statuses too, and that case needs the failover path, not a retry 

309 against the same dead server (except a bind-race death, which 

310 ``_raise_for_status`` upgrades to SERVER once the upstream tail proves it). 

311 A bare gateway error (502/503/504) is a 

312 momentarily-unreachable upstream -- restarting, OOM-killed, mid-swap -- so 

313 it is SERVER, which the gateway retry treats as transient. 

314 """ 

315 if _BATCH_OVERFLOW_MARKER in body: 

316 return ProviderErrorKind.CONTEXT_OVERFLOW 

317 if _UPSTREAM_DIED_MARKER in body: 

318 return ProviderErrorKind.CONNECTION 

319 if status_code in _TRANSIENT_GATEWAY_STATUSES: 

320 return ProviderErrorKind.SERVER 

321 return ProviderErrorKind.UNKNOWN 

322 

323 

324def is_connection_failure(exc: Exception) -> bool: 

325 """Whether *exc* signals a dead/unreachable replica rather than a model error.""" 

326 if isinstance(exc, httpx.TransportError): 

327 return True 

328 # isinstance: only ProviderError carries a kind; other exceptions pass through. 

329 return isinstance(exc, ProviderError) and exc.kind is ProviderErrorKind.CONNECTION 

330 

331 

332def _is_transient_probe_failure(exc: Exception) -> bool: 

333 """Whether a probe failure is transient (dead replica, busy 429, gateway 

334 error), not a template verdict. llama-swap 429s past its concurrency limit 

335 and a restarting replica 502s, so neither response must be read as the 

336 template rejecting the exchange.""" 

337 if is_connection_failure(exc): 

338 return True 

339 return isinstance(exc, ProviderError) and exc.kind in _TRANSIENT_KINDS 

340 

341 

342def _upstream_failure_tail(resp: httpx.Response) -> str: 

343 """Return (and log) the dead upstream's recent output, or empty when unreadable.""" 

344 with contextlib.suppress(httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError): 

345 base = str(resp.request.url).split("/v1/")[0] 

346 model = json.loads(resp.request.content)["model"] 

347 tail = _fetch_log_tail(f"{base}/logs/stream/{model}") 

348 if tail: 

349 log.warning("%s exited prematurely; recent server output:\n%s", model, tail) 

350 return tail 

351 return "" 

352 

353 

354def _fetch_log_tail(url: str) -> str: 

355 """The last ``_UPSTREAM_LOG_TAIL_CHARS`` of llama-swap's log stream for one model. 

356 

357 The stream replays the upstream's buffered output then stays open; the read 

358 timeout is the cutoff once the replay is drained. 

359 """ 

360 chunks: list[str] = [] 

361 with ( 

362 contextlib.suppress(httpx.HTTPError), 

363 httpx.stream("GET", url, timeout=_UPSTREAM_LOG_TIMEOUT_S) as stream, 

364 ): 

365 for taken, chunk in enumerate(stream.iter_text(), start=1): 

366 # Bounded by chunk count, not by the tail size. llama-swap replays a 

367 # model's whole ring in one write and httpx hands over at most 64KB 

368 # at a time, so stopping at the first chunk past the tail size 

369 # returned the head of a warm model's log, where the fatal last line 

370 # never is. Only the tail is retained as the replay goes by, and the 

371 # route streams live lines afterwards, so the count is what keeps 

372 # this from waiting out the timeout on every death. 

373 chunks.append(chunk) 

374 chunks = ["".join(chunks)[-_UPSTREAM_LOG_TAIL_CHARS:]] 

375 if taken >= _UPSTREAM_LOG_MAX_CHUNKS: 

376 break 

377 return "".join(chunks)[-_UPSTREAM_LOG_TAIL_CHARS:] 

378 

379 

380# llama-server L2-normalizes pooled embeddings by default (embd_normalize=2); 

381# every embeddings request sends embd_normalize=-1 so the engine returns raw 

382# vectors, and so a rank-pooling rerank score (a single value per pair) is not 

383# collapsed to +-1 by normalization. The server only exposes this per request 

384# body, not as a startup flag. 

385_EMBD_NORMALIZE_NONE = -1 

386# Vectors come back as a base64 float32 buffer: parsing thousands of JSON float 

387# literals per batch is CPU-bound and holds the GIL, which caps embedding 

388# throughput below what the GPUs can feed regardless of how many are dispatching. 

389_EMBED_ENCODING_FORMAT = "base64" 

390# The engine writes the raw float buffer in host byte order; supported targets are 

391# all little-endian. 

392_EMBED_VECTOR_DTYPE = "<f4" 

393# Rank pooling puts the pair's relevance score in the vector's first slot. 

394_RANK_SCORE_INDEX = 0 

395_UNREADABLE_EMBEDDING_ERROR = ( 

396 "The embedding server returned vectors lilbee could not read. Update the " 

397 "inference engine: base64 embedding responses need llama-server b4391 or newer." 

398) 

399_NO_RERANK_SCORE_ERROR = "The reranker returned no relevance score for a candidate." 

400_HEALTH_PATH = "/health" 

401_CHAT_PATH = "/v1/chat/completions" 

402_EMBED_PATH = "/v1/embeddings" 

403_TOKENIZE_PATH = "/tokenize" 

404_DETOKENIZE_PATH = "/detokenize" 

405_APPLY_TEMPLATE_PATH = "/apply-template" 

406# llama-swap proxies native (non-OpenAI) llama.cpp routes only under 

407# /upstream/<model>/...; the bare /tokenize path 404s (it routes /v1/* by the 

408# body's model field, but a native route carries no such field). 

409_UPSTREAM_PREFIX = "/upstream" 

410# Match the in-process tokenizer call (llm.tokenize(text, add_bos=True, special=False)): 

411# the server adds BOS via add_special, and the chunk-sizing call leaves 

412# special-token strings unparsed; the chat-prompt count overrides that, because 

413# the render carries them. 

414_TOKENIZE_ADD_SPECIAL = True 

415_TOKENIZE_PARSE_SPECIAL = False 

416_HTTP_OK = 200 

417_HTTP_BAD_REQUEST = 400 

418_HTTP_NOT_FOUND = 404 

419_HTTP_METHOD_NOT_ALLOWED = 405 

420_HTTP_TOO_MANY_REQUESTS = 429 

421# Gateway statuses llama-swap returns while an upstream is unreachable 

422# (502 crashing/restarting, 503 unavailable, 504 gateway timeout). The request 

423# succeeds once the upstream is back, so these must never terminalize a call. 

424_TRANSIENT_GATEWAY_STATUSES = frozenset({502, 503, 504}) 

425# Error kinds the gateway retry re-sends: a bare gateway error (upstream 

426# momentarily unreachable) and a lost port-bind race clear on their own once 

427# the server is back. A 429 is llama-swap's concurrency limit, which the swap 

428# config sizes for several lilbee processes at full load, so it is reported, 

429# not re-sent. 

430_RETRY_KINDS = frozenset({ProviderErrorKind.SERVER, ProviderErrorKind.PORT_CONFLICT}) 

431# Error kinds that leave a template probe undetermined: the retry kinds plus a 429. 

432_TRANSIENT_KINDS = _RETRY_KINDS | {ProviderErrorKind.RATE_LIMIT} 

433_DONE_SENTINEL = "[DONE]" 

434_DATA_PREFIX = "data:" 

435_DEFAULT_TIMEOUT_S = 300.0 

436# How often an abortable chat checks its cancel signal. 

437_CANCEL_POLL_S = 0.1 

438# Short, separate timeout for /health: a server can wedge under heavy prompt 

439# processing, and readiness/monitor polls must not block on the request timeout. 

440_HEALTH_TIMEOUT_S = 5.0 

441# Retry a gateway error with exponential backoff (capped). llama-swap holds a 

442# request through a cold load, so a gateway error is a replica restarting, and 

443# callers fail after this short budget (~15s). 

444_GATEWAY_RETRIES = 6 

445_GATEWAY_BACKOFF_BASE_S = 0.5 

446_GATEWAY_BACKOFF_MAX_S = 8.0 

447# Half-open recovery: a replica marked unhealthy becomes routable again after 

448# this cool-down. Recovery is probe-by-traffic and unmetered: every concurrent 

449# caller sees it routable once cooled down (a success restores it, another 

450# connection failure re-stamps the cool-down). 

451_UNHEALTHY_RETRY_S = 30.0 

452_T = TypeVar("_T") 

453 

454 

455def _route_is_absent(resp: httpx.Response) -> bool: 

456 """Whether *resp* means the route is missing rather than the request bad. 

457 

458 A proxy that knows the path but not the method answers 405. An engine too old 

459 to carry the route answers 404 with a plain body, while llama-swap answers 

460 404 with a JSON error envelope for a model it cannot route -- a 

461 misconfiguration the caller must see, not one to answer with an estimate. 

462 """ 

463 if resp.status_code == _HTTP_METHOD_NOT_ALLOWED: 

464 return True 

465 if resp.status_code != _HTTP_NOT_FOUND: 

466 return False 

467 resp.read() # streaming responses aren't read yet; a no-op for buffered ones 

468 try: 

469 body = resp.json() 

470 except ValueError: 

471 return True 

472 return not (isinstance(body, dict) and "error" in body) 

473 

474 

475class ChatDeadlineError(ProviderError): 

476 """A bounded chat exceeded its caller-supplied total wall-clock deadline. 

477 

478 Distinct from a transport/server error so a deadline-bounded caller (vision 

479 OCR) can word its own timeout message and skip failover without matching 

480 error strings. Its ``UNKNOWN`` kind keeps it out of ``is_connection_failure``. 

481 """ 

482 

483 

484class StreamInterruptedError(ProviderError): 

485 """A chat stream broke after it had sent frames, so its answer is cut.""" 

486 

487 def __init__(self) -> None: 

488 super().__init__(_STREAM_INTERRUPTED_MESSAGE, provider=_PROVIDER_NAME) 

489 

490 

491def retry_on_gateway_error( 

492 call: Callable[[], _T], *, retries: int = _GATEWAY_RETRIES, deadline: float | None = None 

493) -> _T: 

494 """Run *call*, retrying gateway errors and a lost port-bind race with capped backoff. 

495 

496 A replica restarting mid-run answers 502 until it is back; backing off and 

497 retrying turns that drop into a success. With a *deadline* 

498 (``time.monotonic`` epoch) the retry waits out the server until that 

499 deadline; without one, *retries* bounds the attempts. Every other error, 

500 a 429 included, and the final still-failing response propagate. 

501 """ 

502 delay = _GATEWAY_BACKOFF_BASE_S 

503 attempt = 0 

504 while True: 

505 try: 

506 return call() 

507 except ProviderError as exc: 

508 if exc.kind not in _RETRY_KINDS: 

509 raise 

510 attempt += 1 

511 exhausted = ( 

512 time.monotonic() + delay >= deadline if deadline is not None else attempt >= retries 

513 ) 

514 if exhausted: 

515 raise 

516 time.sleep(delay) 

517 delay = min(delay * 2, _GATEWAY_BACKOFF_MAX_S) 

518 

519 

520class LlamaServerClient: 

521 """Calls one llama-server's OpenAI surface. Tracks in-flight requests so the 

522 fleet router can pick the least-busy replica.""" 

523 

524 def __init__( 

525 self, 

526 base_url: str, 

527 model: str, 

528 *, 

529 http: httpx.Client | None = None, 

530 async_transport: httpx.AsyncBaseTransport | None = None, 

531 token_cap: int | None = None, 

532 timeout: float = _DEFAULT_TIMEOUT_S, 

533 rerank_mode: RerankMode | None = None, 

534 inline_reasoning: bool = False, 

535 on_prefill: Callable[[tuple[int, int] | None], None] | None = None, 

536 engine_log: Path | None = None, 

537 model_ref: str | None = None, 

538 ) -> None: 

539 self._base = base_url.rstrip("/") 

540 # The file this server's own engine writes its output to; None when the 

541 # launch writes none or no swap manager fronts the client (the self-check). 

542 self.engine_log = engine_log 

543 self._model = model 

544 # The model as the user knows it (the registry ref); the replica id is not 

545 # something a person recognises. None names it "the model". 

546 self.model_ref = model_ref 

547 # Prefill observer: called with (processed, total) per engine progress 

548 # frame during a streamed chat's prompt processing, then None once the 

549 # first generated frame proves the prefill is over. 

550 self._on_prefill = on_prefill 

551 self._http = http or httpx.Client( 

552 base_url=self._base, timeout=timeout, verify=_LOOPBACK_SSL_CONTEXT 

553 ) 

554 self._timeout = timeout 

555 self._async_transport = async_transport 

556 self._owns_http = http is None 

557 # Chat-role clients re-inline server-extracted reasoning as <think> text; 

558 # the other roles (vision OCR) keep dropping it, as their servers already did. 

559 self._inline_reasoning = inline_reasoning 

560 # Per-slot context for embed/rerank servers: inputs longer than this are 

561 # token-truncated (via the server's tokenizer) before embedding, mirroring 

562 # the in-process backstop. None for chat/vision, which don't truncate inputs. 

563 self._token_cap = token_cap 

564 # LLM => score candidates by yes/no logprob; None/cross-encoder => rank pooling. 

565 self._rerank_mode = rerank_mode 

566 self.in_flight = 0 

567 self._in_flight_lock = threading.Lock() 

568 # Called each time a request takes in_flight to zero; see notify_when_idle. 

569 self._on_idle: Callable[[], None] | None = None 

570 # Live SSE responses, so a cancel can sever the transport from another 

571 # thread: a reader blocked in iter_lines cannot see a cooperative 

572 # cancel flag, but a shutdown of its socket unblocks it with an error. 

573 self._active_streams: set[httpx.Response] = set() 

574 # Whether this server's chat template needs OpenAI tool exchanges reshaped 

575 # into strict user/assistant alternation. Determined lazily by a one-time 

576 # probe of the live template (see _prepare_chat_messages); None until then. 

577 # A client is bound to one model for its lifetime, so the template (hence 

578 # the verdict) is fixed once determined. 

579 self._needs_alternation: bool | None = None 

580 self._alternation_lock = threading.Lock() 

581 # Routing health: cleared on a connection-level failure (see _UNHEALTHY_RETRY_S). 

582 self._healthy = True 

583 # Monotonic stamp of the last mark_unhealthy; consulted only while unhealthy. 

584 self._unhealthy_since = 0.0 

585 

586 @property 

587 def healthy(self) -> bool: 

588 """Routable: healthy, or unhealthy past the ``_UNHEALTHY_RETRY_S`` cool-down.""" 

589 with self._in_flight_lock: 

590 if self._healthy: 

591 return True 

592 return time.monotonic() - self._unhealthy_since >= _UNHEALTHY_RETRY_S 

593 

594 def _raise_for_status(self, resp: httpx.Response) -> None: 

595 """Raise the plain error for a failed *resp*, naming this client's model and log. 

596 

597 The one place a client reports a failed response, so no call site can skip the 

598 model name and the engine log. ``raise_for_status`` drops the server's error 

599 body; the body carries the real reason (oversize prompt, decode failure, a 

600 dead server), which both diagnosis and the user-facing error need. 

601 """ 

602 if resp.is_success: 

603 return 

604 resp.read() # streaming responses aren't read yet; a no-op for buffered ones 

605 body = resp.text.strip() 

606 # llama-server reports an oversize prompt/conversation as a 400 whose body 

607 # carries the "exceed_context_size_error" type. Tag it CONTEXT_OVERFLOW with a 

608 # user-facing message so the chat route returns a clean context_length_exceeded 

609 # (400) instead of a generic internal_error -- a long conversation that fills 

610 # the window then reads as "too long", not "Internal server error". 

611 if resp.status_code == _HTTP_BAD_REQUEST and ( 

612 "exceed_context_size" in body.lower() or "context size" in body.lower() 

613 ): 

614 raise ProviderError( 

615 "The conversation exceeds this model's context window. " 

616 "Start a new conversation or shorten the input.", 

617 provider=_PROVIDER_NAME, 

618 kind=ProviderErrorKind.CONTEXT_OVERFLOW, 

619 ) 

620 # llama-swap answers 429 once its concurrency limit for the model is reached. 

621 # The swap config sizes that limit for several lilbee processes at full load, 

622 # so a request past it is reported rather than re-sent into the same load. 

623 if resp.status_code == _HTTP_TOO_MANY_REQUESTS: 

624 raise ProviderError( 

625 "The model is busy with too many requests. Try again in a moment.", 

626 provider=_PROVIDER_NAME, 

627 kind=ProviderErrorKind.RATE_LIMIT, 

628 ) 

629 kind = _classify_error(resp.status_code, body) 

630 # llama-swap masks a dead server as "exited prematurely". The server's own 

631 # captured output goes to the log (in _upstream_failure_tail) and decides the 

632 # kind; the message the user sees stays one plain line. 

633 if _UPSTREAM_DIED_MARKER in body: 

634 classified = classify_upstream_death(_upstream_failure_tail(resp)) 

635 if classified is not None: 

636 kind = classified 

637 raise ProviderError( 

638 _upstream_death_message(kind, resp.status_code, self.model_ref, self.engine_log), 

639 provider=_PROVIDER_NAME, 

640 kind=kind, 

641 ) 

642 detail = f": {body[:600]}" if body else "" 

643 raise ProviderError( 

644 f"llama-server returned HTTP {resp.status_code}{detail}", 

645 provider=_PROVIDER_NAME, 

646 kind=kind, 

647 ) 

648 

649 def mark_unhealthy(self) -> None: 

650 """Record a connection-level failure so the router skips this replica.""" 

651 with self._in_flight_lock: 

652 self._healthy = False 

653 self._unhealthy_since = time.monotonic() 

654 

655 def mark_healthy(self) -> None: 

656 """Restore the replica to the routing pool after a successful call.""" 

657 with self._in_flight_lock: 

658 self._healthy = True 

659 

660 def reserve(self) -> None: 

661 """Mark a routed request assigned to this replica, at selection time. 

662 

663 The router balances on ``in_flight`` but the per-request tracking only 

664 bumps it once the HTTP call starts. Under a bulk ingest many threads pick 

665 a replica at the same instant, all see the momentarily-idlest one at the 

666 same low count, and pile onto it (a thundering herd that leaves the other 

667 cards idle). Reserving at selection makes the assignment visible to the 

668 next picker so requests spread across replicas. Paired with :meth:`release`. 

669 """ 

670 with self._in_flight_lock: 

671 self.in_flight += 1 

672 

673 def release(self) -> None: 

674 """Release a reservation taken by :meth:`reserve`.""" 

675 self._leave() 

676 

677 def notify_when_idle(self, callback: Callable[[], None]) -> None: 

678 """Call *callback* each time a request ends and leaves ``in_flight`` at zero. 

679 

680 The callback runs on the thread that ended the request, after the count 

681 lock is released. That thread can hold any lock of its caller, so the 

682 callback must not block on one. 

683 """ 

684 with self._in_flight_lock: 

685 self._on_idle = callback 

686 

687 def _leave(self) -> None: 

688 """End one request; call the idle callback when it was the last one.""" 

689 with self._in_flight_lock: 

690 self.in_flight -= 1 

691 callback = self._on_idle if self.in_flight == 0 else None 

692 if callback is not None: 

693 callback() 

694 

695 def health(self) -> bool: 

696 """True iff ``GET /health`` returns 200 (liveness, not readiness).""" 

697 try: 

698 resp = self._http.get(_HEALTH_PATH, timeout=_HEALTH_TIMEOUT_S) 

699 except httpx.HTTPError: 

700 return False 

701 return resp.status_code == _HTTP_OK 

702 

703 @overload 

704 def chat( 

705 self, 

706 messages: Sequence[Mapping[str, Any]], 

707 *, 

708 options: dict[str, Any] | None = None, 

709 stream: Literal[False] = False, 

710 timeout: float | None = None, 

711 ) -> str: ... 

712 

713 @overload 

714 def chat( 

715 self, 

716 messages: Sequence[Mapping[str, Any]], 

717 *, 

718 options: dict[str, Any] | None = None, 

719 stream: Literal[True], 

720 timeout: float | None = None, 

721 ) -> Iterator[str]: ... 

722 

723 @overload 

724 def chat( 

725 self, 

726 messages: Sequence[Mapping[str, Any]], 

727 *, 

728 options: dict[str, Any] | None = None, 

729 stream: bool, 

730 timeout: float | None = None, 

731 ) -> str | Iterator[str]: ... 

732 

733 def chat( 

734 self, 

735 messages: Sequence[Mapping[str, Any]], 

736 *, 

737 options: dict[str, Any] | None = None, 

738 stream: bool = False, 

739 timeout: float | None = None, 

740 ) -> str | Iterator[str]: 

741 """Chat completion. Returns the full text, or a token iterator if streaming. 

742 

743 ``messages`` accepts both plain ``{role, content: str}`` and multipart 

744 ``content`` lists (vision image parts), so the vision path reuses this. 

745 ``timeout`` overrides the client default for either path, so a 

746 caller-enforced deadline (vision OCR) ends the request itself. 

747 """ 

748 payload: dict[str, Any] = {"model": self._model, "messages": messages, **(options or {})} 

749 request_timeout = timeout if timeout is not None else httpx.USE_CLIENT_DEFAULT 

750 if stream: 

751 return self._chat_stream(payload, request_timeout) 

752 with self._track(): 

753 resp = self._http.post( 

754 _CHAT_PATH, json={**payload, "stream": False}, timeout=request_timeout 

755 ) 

756 self._raise_for_status(resp) 

757 # content is null for a refusal / content-filter stop / empty completion; 

758 # coerce to "" (like chat_result/chat_tools) so callers never see "None". 

759 return _inline_message_reasoning( 

760 resp.json()["choices"][0]["message"], enabled=self._inline_reasoning 

761 ) 

762 

763 def _chat_stream( 

764 self, payload: dict[str, Any], timeout: Any = httpx.USE_CLIENT_DEFAULT 

765 ) -> Iterator[str]: 

766 inliner = _ThinkInliner(enabled=self._inline_reasoning) 

767 with ( 

768 self._track(), 

769 self._http.stream( 

770 "POST", _CHAT_PATH, json={**payload, "stream": True}, timeout=timeout 

771 ) as resp, 

772 ): 

773 self._raise_for_status(resp) 

774 self._abortable(resp) 

775 for line in resp.iter_lines(): 

776 delta = inliner.feed(*_parse_sse_deltas(line)) 

777 if delta: 

778 yield delta 

779 tail = inliner.finish() 

780 if tail: 

781 yield tail 

782 

783 def chat_abortable( 

784 self, 

785 messages: Sequence[Mapping[str, Any]], 

786 *, 

787 options: dict[str, Any] | None = None, 

788 deadline_s: float | None = None, 

789 cancel: CancelSignal | None = None, 

790 ) -> str: 

791 """Stream a chat completion and return its text, aborted by a deadline or *cancel*. 

792 

793 The request runs as an asyncio task on a private loop, so cancelling it 

794 closes the connection whether or not response headers have arrived, and 

795 llama-server stops generating for a closed stream. A passed *deadline_s* 

796 (total seconds, ``None`` for no limit) raises :class:`ChatDeadlineError`; 

797 a set *cancel* raises ``TaskCancelledError``. 

798 """ 

799 payload = {"model": self._model, "messages": messages, **(options or {}), "stream": True} 

800 with self._track(): 

801 return asyncio.run(self._chat_until_stopped(payload, deadline_s, cancel)) 

802 

803 async def _chat_until_stopped( 

804 self, payload: dict[str, Any], deadline_s: float | None, cancel: CancelSignal | None 

805 ) -> str: 

806 async with httpx.AsyncClient( 

807 base_url=self._base, 

808 timeout=self._timeout, 

809 verify=_LOOPBACK_SSL_CONTEXT, 

810 transport=self._async_transport, 

811 ) as http: 

812 chat = asyncio.ensure_future(self._stream_text(http, payload)) 

813 try: 

814 async with asyncio.timeout(deadline_s): 

815 while not chat.done(): 

816 if cancel is not None and cancel.is_set(): 

817 raise TaskCancelledError 

818 await asyncio.wait({chat}, timeout=_CANCEL_POLL_S) 

819 except TimeoutError: 

820 raise ChatDeadlineError( 

821 f"llama-server chat exceeded its {deadline_s:g}s deadline.", 

822 provider=_PROVIDER_NAME, 

823 ) from None 

824 finally: 

825 chat.cancel() 

826 await asyncio.gather(chat, return_exceptions=True) 

827 return chat.result() 

828 

829 async def _stream_text(self, http: httpx.AsyncClient, payload: dict[str, Any]) -> str: 

830 inliner = _ThinkInliner(enabled=self._inline_reasoning) 

831 parts: list[str] = [] 

832 async with http.stream("POST", _CHAT_PATH, json=payload) as resp: 

833 if not resp.is_success: 

834 await resp.aread() 

835 self._raise_for_status(resp) 

836 async for line in resp.aiter_lines(): 

837 parts.append(inliner.feed(*_parse_sse_deltas(line))) 

838 parts.append(inliner.finish()) 

839 return "".join(parts) 

840 

841 def chat_tools( 

842 self, 

843 messages: Sequence[Mapping[str, Any]], 

844 *, 

845 tools: list[dict[str, Any]], 

846 tool_choice: str | dict[str, Any] | None = None, 

847 options: dict[str, Any] | None = None, 

848 ) -> ChatToolResult: 

849 """Non-streaming chat with function tools; returns content + any tool calls. 

850 

851 The server is launched with ``--jinja`` so it parses the model's native 

852 tool-call syntax into structured ``message.tool_calls``. When a model 

853 instead emits a bare-JSON call as content (a native miss), recover it. 

854 """ 

855 payload: dict[str, Any] = { 

856 "model": self._model, 

857 "messages": self._prepare_chat_messages(messages), 

858 "tools": tools, 

859 "stream": False, 

860 **(options or {}), 

861 } 

862 if tool_choice is not None: 

863 payload["tool_choice"] = tool_choice 

864 with self._track(): 

865 resp = self._http.post(_CHAT_PATH, json=payload) 

866 self._raise_for_status(resp) 

867 message = resp.json()["choices"][0]["message"] 

868 content = _inline_message_reasoning(message, enabled=self._inline_reasoning) 

869 native = _parse_native_tool_calls(message.get("tool_calls")) 

870 if native: 

871 return ChatToolResult(content=content, tool_calls=native) 

872 return _recover_bare_json_tool_calls(content) 

873 

874 def chat_result( 

875 self, 

876 messages: Sequence[Mapping[str, Any]], 

877 *, 

878 tools: list[dict[str, Any]] | None = None, 

879 tool_choice: str | dict[str, Any] | None = None, 

880 options: dict[str, Any] | None = None, 

881 ) -> ChatResult: 

882 """Non-streaming chat returning text, tool calls, and a finish reason. 

883 

884 The server is launched with ``--jinja`` so it parses the model's native 

885 tool-call syntax into structured ``message.tool_calls``. When a request 

886 that offered tools gets a bare-JSON call as content (a native miss), 

887 recover it and report ``tool_calls`` as the finish reason; without tools 

888 a JSON answer is the answer. Messages are reshaped to 

889 strict alternation up front when this server's template needs it (see 

890 :meth:`_prepare_chat_messages`). 

891 """ 

892 payload = self._chat_payload( 

893 self._prepare_chat_messages(messages), tools, tool_choice, options, stream=False 

894 ) 

895 with self._track(): 

896 resp = self._http.post(_CHAT_PATH, json=payload) 

897 self._raise_for_status(resp) 

898 body = dict(resp.json()) 

899 choice = body["choices"][0] 

900 usage = _usage_from_body(body) or TokenUsage() 

901 message = choice["message"] 

902 content = _inline_message_reasoning(message, enabled=self._inline_reasoning) 

903 finish_reason = _coerce_finish_reason(choice.get("finish_reason")) 

904 native = _parse_native_tool_calls(message.get("tool_calls")) 

905 if native: 

906 return ChatResult( 

907 text=content, 

908 tool_calls=tuple(native), 

909 finish_reason=finish_reason, 

910 usage=usage, 

911 ) 

912 recovered = _recover_bare_json_tool_calls(content) if tools else None 

913 if recovered is not None and recovered.tool_calls: 

914 return ChatResult( 

915 text=recovered.content, 

916 tool_calls=tuple(recovered.tool_calls), 

917 finish_reason=FinishReason.TOOL_CALLS, 

918 usage=usage, 

919 ) 

920 return ChatResult(text=content, tool_calls=(), finish_reason=finish_reason, usage=usage) 

921 

922 def chat_stream_items( 

923 self, 

924 messages: Sequence[Mapping[str, Any]], 

925 *, 

926 tools: list[dict[str, Any]] | None = None, 

927 tool_choice: str | dict[str, Any] | None = None, 

928 options: dict[str, Any] | None = None, 

929 ) -> ClosableIterator[str | ToolCallDelta | TokenUsage | StreamFinish]: 

930 """Stream text tokens and tool-call deltas from the server's OpenAI SSE. 

931 

932 Each SSE chunk's ``choices[0].delta`` carries a ``content`` token and/or 

933 a ``tool_calls`` array; both are surfaced as :data:`ChatStreamItem` 

934 frames (text strings and :class:`ToolCallDelta`). The dispatch's stream 

935 translator accumulates the deltas by ``index``. Messages are reshaped to 

936 strict alternation up front when this server's template needs it (see 

937 :meth:`_prepare_chat_messages`), so the open never fails on a template 

938 that rejects the raw tool exchange. 

939 

940 Not a generator: the up-front probe runs when this is called, not deferred 

941 to the first iteration, matching the eager non-stream paths. 

942 

943 When the request offered tools, a model that emits a tool call as 

944 bare-JSON text instead of native ``tool_calls`` (a native miss, as on the 

945 non-stream paths) is recovered by wrapping the raw frames; see 

946 :func:`_recover_bare_json_stream`. Without tools the frames pass through 

947 as they arrive. 

948 """ 

949 prepared = self._prepare_chat_messages(messages) 

950 stream = self._open_chat_stream(prepared, tools, tool_choice, options) 

951 return _recover_bare_json_stream(stream) if tools else stream 

952 

953 def _open_chat_stream( 

954 self, 

955 messages: Sequence[Mapping[str, Any]], 

956 tools: list[dict[str, Any]] | None, 

957 tool_choice: str | dict[str, Any] | None, 

958 options: dict[str, Any] | None, 

959 ) -> Generator[str | ToolCallDelta | TokenUsage | StreamFinish]: 

960 """Open one SSE chat stream and yield its frames; raises before the first frame.""" 

961 payload = self._chat_payload(messages, tools, tool_choice, options, stream=True) 

962 inliner = _ThinkInliner(enabled=self._inline_reasoning) 

963 on_prefill = self._on_prefill 

964 prefilling = False 

965 started = False 

966 ended = False 

967 try: 

968 with ( 

969 self._track(), 

970 self._http.stream("POST", _CHAT_PATH, json=payload) as resp, 

971 ): 

972 self._raise_for_status(resp) 

973 self._abortable(resp) 

974 for line in resp.iter_lines(): 

975 if on_prefill is not None: 

976 progress = _prefill_progress(line) 

977 if progress is not None: 

978 prefilling = True 

979 on_prefill(progress) 

980 for item in _parse_sse_stream_items(line, inliner): 

981 if prefilling and on_prefill is not None: 

982 # The first real frame proves prefill is over. 

983 prefilling = False 

984 on_prefill(None) 

985 started = True 

986 ended |= isinstance(item, StreamFinish) 

987 yield item 

988 ended |= _is_done_line(line) 

989 tail = inliner.finish() 

990 if tail: 

991 yield tail 

992 _raise_if_cut(started=started, ended=ended) 

993 except httpx.TransportError as exc: 

994 if not started: 

995 raise 

996 raise StreamInterruptedError from exc 

997 finally: 

998 # A stream that dies or is closed mid-prefill must not leave a 

999 # stale in-progress reading on the status surface. 

1000 if prefilling and on_prefill is not None: 

1001 on_prefill(None) 

1002 

1003 def _chat_payload( 

1004 self, 

1005 messages: Sequence[Mapping[str, Any]], 

1006 tools: Sequence[Mapping[str, Any]] | None, 

1007 tool_choice: str | dict[str, Any] | None, 

1008 options: dict[str, Any] | None, 

1009 *, 

1010 stream: bool, 

1011 ) -> dict[str, Any]: 

1012 """Build the chat-completions request body shared by the stream and non-stream paths.""" 

1013 payload: dict[str, Any] = {"model": self._model, "messages": messages, "stream": stream} 

1014 if stream: 

1015 # include_usage makes llama-server emit a final SSE chunk carrying the 

1016 # token usage (with an empty choices list) just before [DONE]. 

1017 payload["stream_options"] = {"include_usage": True} 

1018 # return_progress makes llama-server stream prompt_progress frames 

1019 # during prefill, so a long first turn is observable server-side. 

1020 payload["return_progress"] = True 

1021 if tools is not None: 

1022 payload["tools"] = tools 

1023 if tool_choice is not None: 

1024 payload["tool_choice"] = tool_choice 

1025 payload.update(options or {}) 

1026 return payload 

1027 

1028 def _prepare_chat_messages( 

1029 self, messages: Sequence[Mapping[str, Any]] 

1030 ) -> Sequence[Mapping[str, Any]]: 

1031 """Reshape *messages* to strict alternation when this server's template needs it. 

1032 

1033 The need is detected once per server by :meth:`_ensure_alternation_probed` 

1034 (a binary accept/reject of a representative tool exchange against the live 

1035 template), then cached, so real requests are normalized up front rather 

1036 than failing and retrying. 

1037 """ 

1038 self._ensure_alternation_probed() 

1039 if self._needs_alternation: 

1040 return to_alternating([dict(m) for m in messages]) 

1041 return messages 

1042 

1043 def _ensure_alternation_probed(self) -> None: 

1044 """Probe the live template once to learn whether it needs alternation. 

1045 

1046 Caches only a conclusive verdict: a transient unreachable server leaves 

1047 the flag unset so the next request re-probes rather than locking in a 

1048 wrong answer. 

1049 """ 

1050 if self._needs_alternation is not None: 

1051 return 

1052 with self._alternation_lock: 

1053 if self._needs_alternation is not None: 

1054 return 

1055 verdict = self._probe_alternation() 

1056 if verdict is not None: 

1057 self._needs_alternation = verdict 

1058 

1059 def _probe_alternation(self) -> bool | None: 

1060 """Whether the template needs normalization: ``None`` when undetermined. 

1061 

1062 Renders the probe exchange as sent; if the template accepts it, no 

1063 normalization is needed. If it rejects it, normalization is needed only 

1064 when the reshaped exchange is accepted. A transient failure on either 

1065 render is inconclusive (``None``) so no verdict is cached; a genuine 

1066 rejection of both forms is a conclusive ``False`` (the template fault is 

1067 unrelated to alternation, so reshaping would not help). 

1068 """ 

1069 raw = self._chat_probe(_ALTERNATION_PROBE_MESSAGES) 

1070 if raw is None: 

1071 return None # transient; stay undetermined so the next request re-probes 

1072 if raw: 

1073 return False # the template renders the raw OpenAI exchange as sent 

1074 reshaped = self._chat_probe(to_alternating([dict(m) for m in _ALTERNATION_PROBE_MESSAGES])) 

1075 if reshaped is None: 

1076 return None # transient on the reshape probe; stay undetermined 

1077 return reshaped 

1078 

1079 def _chat_probe(self, messages: Sequence[Mapping[str, Any]]) -> bool | None: 

1080 """Post the probe exchange: ``True`` rendered, ``False`` rejected, ``None`` undetermined. 

1081 

1082 A connection failure or a server-busy (HTTP 429) response is transient and 

1083 unrelated to the template, so it is undetermined: only a clean render or a 

1084 genuine rejection is a verdict the caller may cache. 

1085 """ 

1086 payload = self._chat_payload( 

1087 messages, _ALTERNATION_PROBE_TOOLS, None, _ALTERNATION_PROBE_OPTIONS, stream=False 

1088 ) 

1089 try: 

1090 with self._track(): 

1091 resp = self._http.post( 

1092 _CHAT_PATH, json=payload, timeout=_ALTERNATION_PROBE_TIMEOUT_S 

1093 ) 

1094 self._raise_for_status(resp) 

1095 except (ProviderError, httpx.TransportError) as exc: 

1096 return None if _is_transient_probe_failure(exc) else False 

1097 return True 

1098 

1099 def embed(self, texts: list[str]) -> list[Vector]: 

1100 """Embed a batch via ``/v1/embeddings``.""" 

1101 if not texts: 

1102 # Match the in-process embedder; the server rejects an empty input. 

1103 return [] 

1104 vectors: list[Vector] = [] 

1105 for sub_batch in self._truncate_and_subbatch(texts, estimate=True): 

1106 data = self._embed_subbatch(sub_batch) 

1107 vectors.extend(_embedding_vector(item) for item in data) 

1108 return vectors 

1109 

1110 def _embed_subbatch(self, sub_batch: list[str]) -> list[dict[str, Any]]: 

1111 """Embed one estimate-budgeted sub-batch, re-truncating exactly on overflow. 

1112 

1113 ``_estimate_tokens`` is char-based and can under-count token-dense inputs 

1114 (XML, code), so an estimate-trusted input may still exceed the server's 

1115 context. On that error -- and only that -- redo the batch with exact 

1116 server-side tokenization, which truncates the oversize input to the cap. 

1117 """ 

1118 try: 

1119 return self._embeddings_call(sub_batch) 

1120 except ProviderError as exc: 

1121 if exc.kind is not ProviderErrorKind.CONTEXT_OVERFLOW: 

1122 raise 

1123 data: list[dict[str, Any]] = [] 

1124 for exact in self._truncate_and_subbatch(sub_batch, estimate=False): 

1125 data.extend(self._embeddings_call(exact)) 

1126 return data 

1127 

1128 def rerank(self, query: str, candidates: list[str]) -> list[float]: 

1129 """Relevance scores via rank-pooling embeddings. 

1130 

1131 The server runs with ``--pooling rank``; we send ``query</s></s>candidate`` 

1132 pairs to ``/v1/embeddings`` and read each item's first embedding value as the 

1133 score, so the ``/v1/rerank`` template-dependency (and its zero-output failure 

1134 modes) is moot. 

1135 

1136 All pairs go out in one request per ``_EMBED_N_SEQ_MAX`` sequences: the 

1137 server queues one task per input, so splitting a query-time pool into 

1138 per-pair requests only adds HTTP round trips. 

1139 """ 

1140 if not candidates: 

1141 return [] 

1142 if self._rerank_mode is RerankMode.LLM: 

1143 return self._rerank_llm(query, candidates) 

1144 pairs = [ 

1145 self._fit_estimated(f"{query}{_RERANK_PAIR_SEPARATOR}{candidate}") 

1146 for candidate in candidates 

1147 ] 

1148 scores: list[float] = [] 

1149 for start in range(0, len(pairs), _EMBED_N_SEQ_MAX): 

1150 data = self._rerank_batch(pairs[start : start + _EMBED_N_SEQ_MAX]) 

1151 scores.extend(_rerank_score(item) for item in data) 

1152 return scores 

1153 

1154 def _fit_estimated(self, text: str) -> str: 

1155 """Truncate *text* to the token cap only when its char estimate exceeds it.""" 

1156 if self._token_cap is None: 

1157 return text 

1158 return self._fit_input(text, self._token_cap, estimate=True)[0] 

1159 

1160 def _rerank_batch(self, batch: list[str]) -> list[dict[str, Any]]: 

1161 """Score one rerank batch, redoing it with exact truncation on overflow. 

1162 

1163 The char estimate under-counts token-dense pairs, so an over-cap pair 

1164 can slip through untruncated; the server rejects it as CONTEXT_OVERFLOW 

1165 and the batch is redone with exact server-side tokenization. 

1166 """ 

1167 try: 

1168 return self._embeddings_call(batch) 

1169 except ProviderError as exc: 

1170 if exc.kind is not ProviderErrorKind.CONTEXT_OVERFLOW or self._token_cap is None: 

1171 raise 

1172 cap = self._token_cap 

1173 exact = [self._fit_input(text, cap, estimate=False)[0] for text in batch] 

1174 return self._embeddings_call(exact) 

1175 

1176 def _rerank_llm(self, query: str, candidates: list[str]) -> list[float]: 

1177 """Score each candidate by an LLM's yes/no first-token logprob. 

1178 

1179 Raises ``ProviderError`` when no candidate yields a verdict. 

1180 """ 

1181 template = cfg.reranker_prompt or _LLM_RERANK_PROMPT 

1182 workers = min(LLM_RERANK_CONCURRENCY, len(candidates)) 

1183 with ThreadPoolExecutor(max_workers=workers) as pool: 

1184 scores = list(pool.map(lambda c: self._llm_rerank_one(template, query, c), candidates)) 

1185 if all(score is None for score in scores): 

1186 raise ProviderError(_LLM_RERANK_NO_VERDICT_ERROR, provider=_PROVIDER_NAME) 

1187 return [0.0 if score is None else score for score in scores] 

1188 

1189 def _llm_rerank_one(self, template: str, query: str, candidate: str) -> float | None: 

1190 """One chat request scoring a single candidate's relevance to the query.""" 

1191 content = template.format(query=query, document=candidate) 

1192 payload = { 

1193 "model": self._model, 

1194 "messages": [{"role": "user", "content": content}], 

1195 "max_tokens": 1, 

1196 "temperature": 0, 

1197 "logprobs": True, 

1198 "top_logprobs": _LLM_RERANK_TOP_LOGPROBS, 

1199 "stream": False, 

1200 # Scoring reads the first generated token; a thinking template would 

1201 # spend it opening a <think> block instead of answering. 

1202 "chat_template_kwargs": {"enable_thinking": False}, 

1203 } 

1204 

1205 def _call() -> dict[str, Any]: 

1206 with self._track(): 

1207 resp = self._http.post(_CHAT_PATH, json=payload) 

1208 self._raise_for_status(resp) 

1209 return dict(resp.json()) 

1210 

1211 return _llm_rerank_score(_first_token_top_logprobs(retry_on_gateway_error(_call))) 

1212 

1213 def _embeddings_call(self, inputs: list[str]) -> list[dict[str, Any]]: 

1214 """POST one already-budgeted sub-batch to ``/v1/embeddings``; return its data.""" 

1215 

1216 def _call() -> list[dict[str, Any]]: 

1217 with self._track(): 

1218 resp = self._http.post( 

1219 _EMBED_PATH, 

1220 json={ 

1221 "model": self._model, 

1222 "input": inputs, 

1223 "embd_normalize": _EMBD_NORMALIZE_NONE, 

1224 "encoding_format": _EMBED_ENCODING_FORMAT, 

1225 }, 

1226 ) 

1227 self._raise_for_status(resp) 

1228 data = resp.json()["data"] 

1229 if len(data) != len(inputs): 

1230 raise ProviderError( 

1231 f"Embedder returned {len(data)} vectors for {len(inputs)} inputs", 

1232 provider=_PROVIDER_NAME, 

1233 ) 

1234 return list(data) 

1235 

1236 return retry_on_gateway_error(_call) 

1237 

1238 def _truncate_and_subbatch(self, texts: list[str], *, estimate: bool) -> list[list[str]]: 

1239 """Token-truncate over-cap inputs, then pack into server-sized sub-batches. 

1240 

1241 An input longer than ``token_cap`` (the server's per-slot context / 

1242 n_batch) is truncated to it via the server's tokenizer, since the server 

1243 cannot split a pooled embedding sequence. Inputs are then grouped so each 

1244 request stays within both the token budget and ``_EMBED_N_SEQ_MAX`` 

1245 sequences, bounding per-request size and the gateway-retry window. No cap 

1246 (chat/vision) sends a single batch untouched. 

1247 

1248 When ``estimate`` is set the per-input token count comes from 

1249 :func:`_estimate_tokens`, and ``/tokenize`` is consulted only for the 

1250 rare input whose estimate exceeds the cap -- eliminating a round-trip per 

1251 chunk during bulk ingest. ``estimate=False`` (the overflow redo) tokenizes 

1252 every input exactly. 

1253 """ 

1254 if self._token_cap is None: 

1255 return [texts] 

1256 cap = self._token_cap 

1257 batches: list[list[str]] = [] 

1258 current: list[str] = [] 

1259 current_tokens = 0 

1260 for text in texts: 

1261 item, item_tokens = self._fit_input(text, cap, estimate=estimate) 

1262 if current and (current_tokens + item_tokens > cap or len(current) >= _EMBED_N_SEQ_MAX): 

1263 batches.append(current) 

1264 current = [] 

1265 current_tokens = 0 

1266 current.append(item) 

1267 current_tokens += item_tokens 

1268 if current: 

1269 batches.append(current) 

1270 return batches 

1271 

1272 def _fit_input(self, text: str, cap: int, *, estimate: bool) -> tuple[str, int]: 

1273 """Return ``(input, token_count)`` for one sequence, truncating if over cap. 

1274 

1275 Estimation short-circuits the common case: an estimate within the cap is 

1276 trusted (no ``/tokenize``); only an over-cap estimate is confirmed against 

1277 the server tokenizer and truncated if it really exceeds the cap. 

1278 """ 

1279 if estimate: 

1280 est = _estimate_tokens(text) 

1281 if est <= cap: 

1282 return text, est 

1283 tokens = self._tokenize(text) 

1284 if len(tokens) > cap: 

1285 log.warning("Truncating oversize embed input: %d tokens > cap %d", len(tokens), cap) 

1286 return self._detokenize(tokens[:cap]), cap 

1287 return text, max(1, len(tokens)) 

1288 

1289 def _native_route(self, suffix: str) -> str: 

1290 """Path for a native (non-OpenAI) llama-server route through llama-swap. 

1291 

1292 llama-swap proxies these only under ``/upstream/<model>/...``; the model 

1293 is carried in the path, not the body (unlike the ``/v1`` OpenAI routes). 

1294 """ 

1295 return f"{_UPSTREAM_PREFIX}/{self._model}{suffix}" 

1296 

1297 def _tokenize(self, text: str, *, parse_special: bool = _TOKENIZE_PARSE_SPECIAL) -> list[int]: 

1298 """Token ids for *text*; a gateway error is retried like an embedding.""" 

1299 

1300 def _call() -> list[int]: 

1301 resp = self._http.post( 

1302 self._native_route(_TOKENIZE_PATH), 

1303 json={ 

1304 "content": text, 

1305 "add_special": _TOKENIZE_ADD_SPECIAL, 

1306 "parse_special": parse_special, 

1307 }, 

1308 ) 

1309 self._raise_for_status(resp) 

1310 return list(resp.json()["tokens"]) 

1311 

1312 return retry_on_gateway_error(_call) 

1313 

1314 def count_tokens(self, text: str) -> int: 

1315 """Number of tokens *text* encodes to under the server's tokenizer.""" 

1316 return len(self._tokenize(text)) 

1317 

1318 def count_chat_prompt_tokens( 

1319 self, 

1320 messages: Sequence[Mapping[str, Any]], 

1321 *, 

1322 tools: Sequence[Mapping[str, Any]] | None = None, 

1323 tool_choice: str | dict[str, Any] | None = None, 

1324 options: dict[str, Any] | None = None, 

1325 ) -> int: 

1326 """Tokens the server prefills for this prompt, template applied. 

1327 

1328 The template's role markers and its tool-call preamble are part of the 

1329 prompt the model reads, so the rendered text is what gets tokenized. 

1330 Special-token strings in the rendered text are parsed, matching how the 

1331 server tokenizes its own prompt. 

1332 """ 

1333 rendered = self._render_chat_prompt(messages, tools, tool_choice, options) 

1334 return len(self._tokenize(rendered, parse_special=True)) 

1335 

1336 def _render_chat_prompt( 

1337 self, 

1338 messages: Sequence[Mapping[str, Any]], 

1339 tools: Sequence[Mapping[str, Any]] | None, 

1340 tool_choice: str | dict[str, Any] | None, 

1341 options: dict[str, Any] | None, 

1342 ) -> str: 

1343 """The prompt text this server's template renders for a chat body. 

1344 

1345 Sends the body the chat paths build, messages reshaped to strict 

1346 alternation when this server's template needs it (see 

1347 :meth:`_prepare_chat_messages`), so the render cannot diverge from the 

1348 prompt a chat call prefills. Raises ``NotImplementedError`` when the 

1349 server has no such route, which is the one case a caller can answer with 

1350 an estimate instead. 

1351 """ 

1352 payload = self._chat_payload( 

1353 self._prepare_chat_messages(messages), tools, tool_choice, options, stream=False 

1354 ) 

1355 

1356 def _call() -> str: 

1357 resp = self._http.post(self._native_route(_APPLY_TEMPLATE_PATH), json=payload) 

1358 if _route_is_absent(resp): 

1359 raise NotImplementedError( 

1360 f"This inference engine has no {_APPLY_TEMPLATE_PATH} route." 

1361 ) 

1362 self._raise_for_status(resp) 

1363 return str(resp.json()["prompt"]) 

1364 

1365 return retry_on_gateway_error(_call) 

1366 

1367 def _detokenize(self, tokens: list[int]) -> str: 

1368 resp = self._http.post(self._native_route(_DETOKENIZE_PATH), json={"tokens": tokens}) 

1369 self._raise_for_status(resp) 

1370 return str(resp.json()["content"]) 

1371 

1372 def _abortable(self, resp: httpx.Response) -> None: 

1373 """Expose *resp* to ``abort_streams`` until the response closes.""" 

1374 # A response read whole on arrival has no connection left and no close 

1375 # to come; httpx types the body sync-or-async, and a sync client's is sync. 

1376 if resp.is_closed or not isinstance(resp.stream, httpx.SyncByteStream): 

1377 return 

1378 resp.stream = _AbortableStream(resp.stream, resp, self) 

1379 with self._in_flight_lock: 

1380 self._active_streams.add(resp) 

1381 

1382 def abort_streams(self) -> None: 

1383 """Sever every in-flight SSE response on this replica. 

1384 

1385 A socket shutdown sends the server a FIN and makes a reader blocked in 

1386 ``iter_lines`` read end-of-stream at once, so it raises a stream error 

1387 and closes its own response; llama-server stops generating when the 

1388 connection drops. Closing the socket from another thread does neither. 

1389 A response with no socket (a mock transport), or whose shutdown fails 

1390 because the peer is gone, is closed instead. 

1391 

1392 The shutdowns run under the registry lock, and a response leaves the 

1393 registry under that lock before its close returns the connection to 

1394 the pool, so a shutdown never reaches a pooled socket. The fallback 

1395 closes run after the lock is released, because a close takes it. 

1396 """ 

1397 unsevered: list[httpx.Response] = [] 

1398 with self._in_flight_lock: 

1399 for resp in self._active_streams: 

1400 with contextlib.suppress(Exception): 

1401 if not _shut_down_socket(resp): 

1402 unsevered.append(resp) 

1403 for resp in unsevered: 

1404 with contextlib.suppress(Exception): 

1405 resp.close() 

1406 

1407 def close(self) -> None: 

1408 """Close the underlying client if this instance created it.""" 

1409 if self._owns_http: 

1410 self._http.close() 

1411 

1412 def _track(self) -> _InFlight: 

1413 return _InFlight(self) 

1414 

1415 

1416class _InFlight: 

1417 """Context manager that atomically bumps the owner's in-flight counter. 

1418 

1419 ``+= 1`` is a read-modify-write, so concurrent chat/embed calls would corrupt 

1420 the counter the router balances on; the client's lock makes it atomic. 

1421 """ 

1422 

1423 def __init__(self, client: LlamaServerClient) -> None: 

1424 self._client = client 

1425 

1426 def __enter__(self) -> None: 

1427 with self._client._in_flight_lock: 

1428 self._client.in_flight += 1 

1429 

1430 def __exit__(self, *_exc: object) -> None: 

1431 self._client._leave() 

1432 

1433 

1434class _AbortableStream(httpx.SyncByteStream): 

1435 """A response body that leaves the abort registry before it releases its connection. 

1436 

1437 httpx closes a response from inside ``iter_raw`` at the last chunk, and the 

1438 close of httpcore's pool stream hands the connection to the next request. 

1439 Deregistering in this close, ahead of the inner close, keeps a late 

1440 ``abort_streams`` off a connection another request may now use. 

1441 """ 

1442 

1443 def __init__( 

1444 self, inner: httpx.SyncByteStream, resp: httpx.Response, client: LlamaServerClient 

1445 ) -> None: 

1446 self._inner = inner 

1447 self._resp = resp 

1448 self._client = client 

1449 self._closed = False 

1450 

1451 def __iter__(self) -> Iterator[bytes]: 

1452 # httpcore's iterators release the connection when they are finalized. 

1453 # The local keeps the inner iterator alive until this close has run: 

1454 # an exception at the yield pops a ``for`` loop's iterator before the 

1455 # except body, and ``yield from`` closes it first on GeneratorExit. 

1456 chunks = iter(self._inner) 

1457 try: 

1458 while (chunk := next(chunks, None)) is not None: 

1459 yield chunk 

1460 except BaseException: 

1461 self.close() 

1462 raise 

1463 

1464 def close(self) -> None: 

1465 with self._client._in_flight_lock: 

1466 if self._closed: 

1467 return 

1468 self._closed = True 

1469 self._client._active_streams.discard(self._resp) 

1470 self._inner.close() 

1471 

1472 

1473def _shut_down_socket(resp: httpx.Response) -> bool: 

1474 """Shut down *resp*'s socket; False when it has no socket or the shutdown fails.""" 

1475 network_stream = resp.extensions.get("network_stream") 

1476 sock = network_stream.get_extra_info("socket") if network_stream else None 

1477 if sock is None: 

1478 return False 

1479 try: 

1480 sock.shutdown(socket.SHUT_RDWR) 

1481 except OSError: 

1482 return False 

1483 return True 

1484 

1485 

1486def _embedding_vector(item: dict[str, Any]) -> npt.NDArray[np.float32]: 

1487 """Decode one ``/v1/embeddings`` item's vector from its base64 float buffer.""" 

1488 embedding = item.get("embedding") 

1489 # Untyped server JSON: a non-string means the encoding format was not honored. 

1490 if not isinstance(embedding, str): 

1491 raise ProviderError(_UNREADABLE_EMBEDDING_ERROR, provider=_PROVIDER_NAME) 

1492 try: 

1493 return np.frombuffer(base64.b64decode(embedding), dtype=_EMBED_VECTOR_DTYPE) 

1494 except ValueError as exc: 

1495 raise ProviderError(_UNREADABLE_EMBEDDING_ERROR, provider=_PROVIDER_NAME) from exc 

1496 

1497 

1498def _rerank_score(item: dict[str, Any]) -> float: 

1499 """Pull one relevance score from a rank-pooling ``/v1/embeddings`` item.""" 

1500 vector = _embedding_vector(item) 

1501 if not vector.size: 

1502 raise ProviderError(_NO_RERANK_SCORE_ERROR, provider=_PROVIDER_NAME) 

1503 return float(vector[_RANK_SCORE_INDEX]) 

1504 

1505 

1506def _first_token_top_logprobs(response: dict[str, Any]) -> list[dict[str, Any]]: 

1507 """The first generated token's top_logprobs list from a chat completion, or [].""" 

1508 choices = response.get("choices") or [] 

1509 if not choices: 

1510 return [] 

1511 content = (choices[0].get("logprobs") or {}).get("content") or [] 

1512 if not content: 

1513 return [] 

1514 return list(content[0].get("top_logprobs") or []) 

1515 

1516 

1517def _llm_rerank_score(top_logprobs: list[dict[str, Any]]) -> float | None: 

1518 """Softmax of the yes vs no logprobs in a token's top_logprobs (case/space-insensitive). 

1519 

1520 ``None`` when neither verdict appears, distinct from the 0.0 of a confident "no". 

1521 """ 

1522 yes_lp: float | None = None 

1523 no_lp: float | None = None 

1524 for entry in top_logprobs: 

1525 token = str(entry.get("token", "")).strip().lower() 

1526 logprob = float(entry.get("logprob", 0.0)) 

1527 if token == _YES_LABEL and (yes_lp is None or logprob > yes_lp): 

1528 yes_lp = logprob 

1529 elif token == _NO_LABEL and (no_lp is None or logprob > no_lp): 

1530 no_lp = logprob 

1531 if yes_lp is None: 

1532 return None if no_lp is None else 0.0 

1533 if no_lp is None: 

1534 return math.exp(yes_lp) 

1535 yes_e, no_e = math.exp(yes_lp), math.exp(no_lp) 

1536 return yes_e / (yes_e + no_e) 

1537 

1538 

1539def _parse_sse_deltas(line: str) -> tuple[str, str]: 

1540 """Extract the (reasoning, content) deltas from one OpenAI SSE line.""" 

1541 if not line.startswith(_DATA_PREFIX): 

1542 return "", "" 

1543 body = line[len(_DATA_PREFIX) :].strip() 

1544 if not body or body == _DONE_SENTINEL: 

1545 return "", "" 

1546 try: 

1547 obj = json.loads(body) 

1548 except json.JSONDecodeError: 

1549 return "", "" 

1550 choices = obj.get("choices") or [] 

1551 if not choices: 

1552 return "", "" 

1553 delta = choices[0].get("delta") or {} 

1554 return str(delta.get("reasoning_content") or ""), str(delta.get("content") or "") 

1555 

1556 

1557class _ThinkInliner: 

1558 """Re-inlines server-extracted reasoning deltas as inline ``<think>`` text. 

1559 

1560 The server parses each model's reasoning format natively (``--reasoning-format``) 

1561 and streams it as ``reasoning_content``; lilbee's pipeline speaks inline 

1562 ``<think>`` text, so the chat boundary opens the tag on the first reasoning 

1563 delta and closes it when the answer starts (or at end of stream). Disabled 

1564 (the non-chat roles), reasoning is dropped and content passes through, matching 

1565 the server-extracted default those roles already ran with. 

1566 """ 

1567 

1568 def __init__(self, *, enabled: bool) -> None: 

1569 self._enabled = enabled 

1570 self._in_think = False 

1571 

1572 def feed(self, reasoning: str, content: str) -> str: 

1573 if not self._enabled: 

1574 return content 

1575 parts: list[str] = [] 

1576 if reasoning: 

1577 if not self._in_think: 

1578 self._in_think = True 

1579 parts.append(THINK_OPEN_TAG) 

1580 parts.append(reasoning) 

1581 if content: 

1582 if self._in_think: 

1583 self._in_think = False 

1584 parts.append(THINK_CLOSE_TAG) 

1585 parts.append(content) 

1586 return "".join(parts) 

1587 

1588 def finish(self) -> str: 

1589 """Close an unterminated think block at end of stream.""" 

1590 if self._in_think: 

1591 self._in_think = False 

1592 return THINK_CLOSE_TAG 

1593 return "" 

1594 

1595 

1596def _inline_message_reasoning(message: Mapping[str, Any], *, enabled: bool) -> str: 

1597 """A non-streaming message's text with any extracted reasoning re-inlined.""" 

1598 content = str(message.get("content") or "") 

1599 reasoning = str(message.get("reasoning_content") or "") if enabled else "" 

1600 if reasoning: 

1601 return f"{THINK_OPEN_TAG}{reasoning}{THINK_CLOSE_TAG}{content}" 

1602 return content 

1603 

1604 

1605def _cached_prompt_tokens(usage: Mapping[str, Any]) -> int: 

1606 """Read ``prompt_tokens_details.cached_tokens``, or 0 when the engine omits it.""" 

1607 details = usage.get("prompt_tokens_details") 

1608 if not isinstance(details, Mapping): 

1609 return 0 

1610 cached = details.get("cached_tokens") 

1611 return cached if isinstance(cached, int) else 0 

1612 

1613 

1614def _usage_from_body(body: Mapping[str, Any]) -> TokenUsage | None: 

1615 """Read the ``usage`` block of an OpenAI response, or ``None`` if absent. 

1616 

1617 llama-server reports ``prompt_tokens`` / ``completion_tokens``; a missing or 

1618 malformed block yields ``None`` so callers can decide between a zero default 

1619 (non-streaming) and skipping the frame (streaming terminator). 

1620 """ 

1621 usage = body.get("usage") 

1622 if not isinstance(usage, Mapping): 

1623 return None 

1624 prompt = usage.get("prompt_tokens") 

1625 completion = usage.get("completion_tokens") 

1626 return TokenUsage( 

1627 prompt_tokens=prompt if isinstance(prompt, int) else 0, 

1628 completion_tokens=completion if isinstance(completion, int) else 0, 

1629 cached_prompt_tokens=_cached_prompt_tokens(usage), 

1630 ) 

1631 

1632 

1633def _coerce_finish_reason(raw: Any) -> FinishReason: 

1634 """Map a server-supplied finish_reason string to :class:`FinishReason`.""" 

1635 return FinishReason.coerce(raw) 

1636 

1637 

1638def _tool_call_delta_from_chunk(call: Mapping[str, Any], *, fallback_index: int) -> ToolCallDelta: 

1639 """Map one streaming ``delta.tool_calls`` entry to a :class:`ToolCallDelta`. 

1640 

1641 Mirrors the SDK path: ``id`` / ``name`` arrive on the opener and accumulate 

1642 by ``index``; empty strings normalise to ``None`` so the dispatch's stream 

1643 translator (which gates on ``is not None``) does not emit spurious openers. 

1644 """ 

1645 raw_index = call.get("index") 

1646 index = raw_index if isinstance(raw_index, int) else fallback_index 

1647 call_id = call.get("id") 

1648 fn = call.get("function") 

1649 raw_name = fn.get("name") if isinstance(fn, Mapping) else None 

1650 raw_args = fn.get("arguments") if isinstance(fn, Mapping) else None 

1651 return ToolCallDelta( 

1652 index=index, 

1653 id=str(call_id) if call_id else None, 

1654 name=str(raw_name) if raw_name else None, 

1655 arguments_delta=str(raw_args) if raw_args else None, 

1656 ) 

1657 

1658 

1659_PROMPT_PROGRESS_KEY = "prompt_progress" 

1660 

1661 

1662def _prefill_progress(line: str) -> tuple[int, int] | None: 

1663 """The ``(processed, total)`` of one SSE line's ``prompt_progress``, or None. 

1664 

1665 llama-server emits the block on streamed chats that opt in via 

1666 ``return_progress``; the substring pre-check keeps the extra JSON parse off 

1667 every ordinary token line. 

1668 """ 

1669 if _PROMPT_PROGRESS_KEY not in line or not line.startswith(_DATA_PREFIX): 

1670 return None 

1671 body = line[len(_DATA_PREFIX) :].strip() 

1672 try: 

1673 obj = json.loads(body) 

1674 except json.JSONDecodeError: 

1675 return None 

1676 progress = obj.get(_PROMPT_PROGRESS_KEY) 

1677 if not isinstance(progress, Mapping): 

1678 return None 

1679 try: 

1680 return int(progress["processed"]), int(progress["total"]) 

1681 except (KeyError, TypeError, ValueError): 

1682 return None 

1683 

1684 

1685def _raise_if_cut(*, started: bool, ended: bool) -> None: 

1686 """Raise when a started stream's body ended with neither a finish frame nor ``[DONE]``. 

1687 

1688 The engine died but its proxy closed the body cleanly, so no transport error 

1689 was raised. A stream with no frame at all is left to the caller's retry path. 

1690 """ 

1691 if started and not ended: 

1692 raise StreamInterruptedError 

1693 

1694 

1695def _is_done_line(line: str) -> bool: 

1696 """Whether *line* is the SSE ``data: [DONE]`` sentinel that closes a stream.""" 

1697 return _sse_data_body(line) == _DONE_SENTINEL 

1698 

1699 

1700def _sse_data_body(line: str) -> str | None: 

1701 """The stripped payload of an SSE ``data:`` line, or ``None`` for any other line.""" 

1702 if not line.startswith(_DATA_PREFIX): 

1703 return None 

1704 return line[len(_DATA_PREFIX) :].strip() 

1705 

1706 

1707def _parse_sse_stream_items( 

1708 line: str, inliner: _ThinkInliner 

1709) -> Iterator[str | ToolCallDelta | TokenUsage | StreamFinish]: 

1710 """Yield text tokens, tool-call deltas, and the finish frame from one SSE line. 

1711 

1712 A chunk can carry a ``content`` token, a ``reasoning_content`` token (routed 

1713 through *inliner*), a ``tool_calls`` delta array, or a mix; each is yielded as 

1714 its own :data:`ChatStreamItem` frame. The chunk that closes the turn carries 

1715 ``choices[0].finish_reason``, surfaced as a :class:`StreamFinish` so the 

1716 dispatch reports ``length`` (and friends), not just the default end-of-turn. 

1717 """ 

1718 body = _sse_data_body(line) 

1719 if not body or body == _DONE_SENTINEL: 

1720 return 

1721 try: 

1722 obj = json.loads(body) 

1723 except json.JSONDecodeError: 

1724 return 

1725 choices = obj.get("choices") or [] 

1726 if not choices: 

1727 # The include_usage terminator chunk has an empty choices list and the 

1728 # token totals on a top-level ``usage`` block; surface it as the final 

1729 # frame so the dispatch can attach real counts to the stream. 

1730 usage = _usage_from_body(obj) 

1731 if usage is not None: 

1732 yield usage 

1733 return 

1734 delta = choices[0].get("delta") or {} 

1735 text = inliner.feed(str(delta.get("reasoning_content") or ""), str(delta.get("content") or "")) 

1736 if text: 

1737 yield text 

1738 raw_calls = delta.get("tool_calls") or [] 

1739 for i, call in enumerate(raw_calls): 

1740 if isinstance(call, Mapping): 

1741 yield _tool_call_delta_from_chunk(call, fallback_index=i) 

1742 raw_finish = choices[0].get("finish_reason") 

1743 if raw_finish is not None: 

1744 yield StreamFinish(reason=_coerce_finish_reason(raw_finish)) 

1745 

1746 

1747def _arguments_to_str(arguments: Any) -> str: 

1748 """Normalize a tool-call ``arguments`` value to a JSON string (OpenAI's shape).""" 

1749 if isinstance(arguments, str): 

1750 return arguments 

1751 if arguments is None: 

1752 return "{}" 

1753 return json.dumps(arguments) 

1754 

1755 

1756def _parse_native_tool_calls(raw: Any) -> list[ToolCall]: 

1757 """Map a response's ``message.tool_calls`` array to :class:`ToolCall` objects. 

1758 

1759 Reads the OpenAI shape (``{"id", "function": {"name", "arguments"}}``) that 

1760 ``--jinja`` produces. Malformed or nameless entries are skipped. 

1761 """ 

1762 if not isinstance(raw, list): 

1763 return [] 

1764 calls: list[ToolCall] = [] 

1765 for idx, entry in enumerate(raw): 

1766 if not isinstance(entry, Mapping): 

1767 continue 

1768 fn = entry.get("function") 

1769 if not isinstance(fn, Mapping): 

1770 continue 

1771 name = fn.get("name") 

1772 if not isinstance(name, str) or not name: 

1773 continue 

1774 call_id = entry.get("id") 

1775 calls.append( 

1776 ToolCall( 

1777 id=call_id if isinstance(call_id, str) and call_id else f"call_{idx}", 

1778 name=name, 

1779 arguments=_arguments_to_str(fn.get("arguments")), 

1780 ) 

1781 ) 

1782 return calls 

1783 

1784 

1785def _bare_call_from_mapping(obj: Mapping[str, Any], *, index: int) -> ToolCall | None: 

1786 """Build a ToolCall from a bare ``{"name", "arguments"|"parameters"}`` object.""" 

1787 name = obj.get("name") 

1788 if not isinstance(name, str) or not name: 

1789 return None 

1790 arguments = obj.get("arguments") 

1791 if arguments is None: 

1792 arguments = obj.get("parameters") 

1793 return ToolCall(id=f"call_{index}", name=name, arguments=_arguments_to_str(arguments)) 

1794 

1795 

1796def _recover_bare_json_tool_calls(content: str) -> ChatToolResult: 

1797 """Recover a tool call a model emitted as bare-JSON content (a native miss). 

1798 

1799 Some models ignore the tool-call protocol and print ``{"name": ..., 

1800 "arguments": {...}}`` (or a list of them) as the message body. When the whole 

1801 content parses as such, treat it as the call(s) and clear the text; otherwise 

1802 return the content unchanged with no calls. 

1803 """ 

1804 stripped = content.strip() 

1805 if not stripped or stripped[0] not in "{[": 

1806 return ChatToolResult(content=content, tool_calls=[]) 

1807 try: 

1808 parsed = json.loads(stripped) 

1809 except json.JSONDecodeError: 

1810 return ChatToolResult(content=content, tool_calls=[]) 

1811 entries = parsed if isinstance(parsed, list) else [parsed] 

1812 calls = [ 

1813 call 

1814 for idx, entry in enumerate(entries) 

1815 if isinstance(entry, Mapping) and (call := _bare_call_from_mapping(entry, index=idx)) 

1816 ] 

1817 if not calls: 

1818 return ChatToolResult(content=content, tool_calls=[]) 

1819 return ChatToolResult(content="", tool_calls=calls) 

1820 

1821 

1822# Leading non-whitespace characters that mark streamed text as a potential bare 

1823# JSON tool call (an object or an array of them); any other first char is plain 

1824# text and streams through untouched. 

1825_BARE_CALL_OPENERS = "{[" 

1826 

1827 

1828def _tool_call_delta_from_recovered(call: ToolCall, index: int) -> ToolCallDelta: 

1829 """Shape a recovered bare-JSON :class:`ToolCall` as a single streaming delta. 

1830 

1831 Mirrors :func:`_tool_call_delta_from_chunk`: id and name ride the opener (the 

1832 only frame for a recovered call), the arguments JSON is the lone 

1833 ``arguments_delta``, and the position is the index. 

1834 """ 

1835 return ToolCallDelta( 

1836 index=index, 

1837 id=call.id or None, 

1838 name=call.name or None, 

1839 arguments_delta=call.arguments or None, 

1840 ) 

1841 

1842 

1843def _recover_bare_json_stream( 

1844 items: Iterator[str | ToolCallDelta | TokenUsage | StreamFinish], 

1845) -> ClosableIterator[str | ToolCallDelta | TokenUsage | StreamFinish]: 

1846 """Wrap a raw chat stream to recover a tool call emitted as bare-JSON text. 

1847 

1848 Some small models print ``{"name": ..., "arguments": {...}}`` as content 

1849 instead of native ``tool_calls``; the non-stream paths recover this via 

1850 :func:`_recover_bare_json_tool_calls`. This applies the same recovery to the 

1851 stream, but only when the model emitted no native :class:`ToolCallDelta` and 

1852 the streamed text looks like a bare call from its first character. Normal text 

1853 still streams token by token: once the buffered head proves not to be a bare 

1854 call it is flushed and all later text passes straight through. 

1855 """ 

1856 buffer = "" # leading text held back as a potential bare call until resolved 

1857 # True once leading text has streamed as plain (or a native call was seen): 

1858 # past that, a later '{'/'[' is content, not a bare call -- never buffer again. 

1859 committed = False 

1860 try: 

1861 for item in items: 

1862 if isinstance(item, ToolCallDelta): 

1863 yield from _flush_plain(buffer) 

1864 buffer, committed = "", True 

1865 yield item 

1866 elif isinstance(item, TokenUsage | StreamFinish): 

1867 yield from _recover_buffer(buffer) 

1868 buffer = "" 

1869 yield item 

1870 elif committed or _passthrough_text(buffer, item): 

1871 yield from _flush_plain(buffer) 

1872 buffer = "" 

1873 committed = True 

1874 yield item 

1875 else: 

1876 buffer += item 

1877 yield from _recover_buffer(buffer) 

1878 finally: 

1879 # Forward close to the source generator: if a consumer closes this 

1880 # wrapper mid-stream, a plain for-loop would not propagate GeneratorExit 

1881 # to *items*, leaking the underlying HTTP stream and its in_flight slot. 

1882 # Suppress teardown errors (httpx stream close can raise) so they don't 

1883 # mask the exception that triggered this finally. 

1884 if isinstance(items, Generator): 

1885 with contextlib.suppress(Exception): 

1886 items.close() 

1887 

1888 

1889def _passthrough_text(buffer: str, text: str) -> bool: 

1890 """Whether *text* should stream through directly rather than buffer. 

1891 

1892 True once the accumulated head's first non-whitespace char is known and is not 

1893 a bare-call opener (plain text): the buffer is empty in that case, so the 

1894 caller yields *text* as is. While the head is all whitespace, or once it opens 

1895 with ``{``/``[``, the text is buffered (False) pending recovery. 

1896 """ 

1897 head = (buffer + text).lstrip() 

1898 return bool(head) and head[0] not in _BARE_CALL_OPENERS 

1899 

1900 

1901def _flush_plain(buffer: str) -> Iterator[str]: 

1902 """Yield buffered leading text verbatim (it was not a bare call after all).""" 

1903 if buffer: 

1904 yield buffer 

1905 

1906 

1907def _recover_buffer(buffer: str) -> Iterator[str | ToolCallDelta]: 

1908 """Resolve the buffered leading text at a terminator or end of stream. 

1909 

1910 The buffer reaching here was held as a potential bare call (text starting with 

1911 ``{``/``[`` and no native call seen). Run :func:`_recover_bare_json_tool_calls`: 

1912 emit one delta per recovered call, or yield the text unchanged when it only 

1913 happened to start with ``{``/``[`` but is not a call. 

1914 """ 

1915 if not buffer: 

1916 return 

1917 recovered = _recover_bare_json_tool_calls(buffer) 

1918 if not recovered.tool_calls: 

1919 yield buffer 

1920 return 

1921 for index, call in enumerate(recovered.tool_calls): 

1922 yield _tool_call_delta_from_recovered(call, index)