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

716 statements  

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

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

2 

3from __future__ import annotations 

4 

5import base64 

6import contextlib 

7import json 

8import logging 

9import math 

10import ssl 

11import threading 

12import time 

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

14from concurrent.futures import ThreadPoolExecutor 

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

16 

17import httpx 

18import numpy as np 

19import numpy.typing as npt 

20 

21from lilbee.core.config import cfg 

22from lilbee.core.vectors import Vector 

23from lilbee.providers.base import ( 

24 THINK_CLOSE_TAG, 

25 THINK_OPEN_TAG, 

26 ChatResult, 

27 ChatToolResult, 

28 ClosableIterator, 

29 FinishReason, 

30 ProviderError, 

31 ProviderErrorKind, 

32 StreamFinish, 

33 TokenUsage, 

34 ToolCall, 

35 ToolCallDelta, 

36) 

37from lilbee.providers.fleet.adapters import LLM_RERANK_CONCURRENCY 

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

39from lilbee.providers.roles import RerankMode 

40 

41_PROVIDER_NAME = "llama-server" 

42 

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

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

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

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

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

48_LOOPBACK_SSL_CONTEXT = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) 

49_LOOPBACK_SSL_CONTEXT.check_hostname = False 

50_LOOPBACK_SSL_CONTEXT.verify_mode = ssl.CERT_NONE 

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

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

53_RERANK_PAIR_SEPARATOR = "</s></s>" 

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

55_LLM_RERANK_PROMPT = ( 

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

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

58) 

59_LLM_RERANK_TOP_LOGPROBS = 20 

60_YES_LABEL = "yes" 

61_NO_LABEL = "no" 

62_LLM_RERANK_NO_VERDICT_ERROR = ( 

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

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

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

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

67) 

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

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

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

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

72_EMBED_N_SEQ_MAX = 64 

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

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

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

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

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

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

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

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

81_EMBED_EST_CHARS_PER_TOKEN = 3 

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

83_UPSTREAM_DIED_MARKER = "exited prematurely" 

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

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

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

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

88# and normally binds. 

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

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

91_BATCH_OVERFLOW_MARKER = "too large to process" 

92 

93 

94class _ChatToolSpecFunction(TypedDict, total=False): 

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

96 

97 name: str 

98 description: str 

99 parameters: dict[str, Any] 

100 

101 

102class ChatTool(TypedDict, total=False): 

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

104 

105 type: str 

106 function: _ChatToolSpecFunction 

107 

108 

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

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

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

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

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

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

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

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

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

118_ALTERNATION_PROBE_TOOLS: list[ChatTool] = [ 

119 { 

120 "type": "function", 

121 "function": { 

122 "name": "probe", 

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

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

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

126 "parameters": { 

127 "type": "object", 

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

129 "required": ["query"], 

130 }, 

131 }, 

132 } 

133] 

134_ALTERNATION_PROBE_MESSAGES: list[ChatMessage] = [ 

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

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

137 { 

138 "role": "assistant", 

139 "content": "", 

140 "tool_calls": [ 

141 { 

142 "id": "probe-1", 

143 "type": "function", 

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

145 } 

146 ], 

147 }, 

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

149 { 

150 "role": "assistant", 

151 "content": "", 

152 "tool_calls": [ 

153 { 

154 "id": "probe-2", 

155 "type": "function", 

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

157 } 

158 ], 

159 }, 

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

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

162] 

163_ALTERNATION_PROBE_OPTIONS = {"max_tokens": 1} 

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

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

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

167_ALTERNATION_PROBE_TIMEOUT_S = 30.0 

168_UPSTREAM_LOG_TAIL_CHARS = 2000 

169_UPSTREAM_LOG_TIMEOUT_S = 2.0 

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

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

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

173# every death. 

174_UPSTREAM_LOG_MAX_CHUNKS = 8 

175 

176log = logging.getLogger(__name__) 

177 

178 

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

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

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

182 

183 

184def _raise_for_status(resp: httpx.Response) -> None: 

185 """Raise including the server's error body, which ``raise_for_status`` drops. 

186 

187 A llama-server failure otherwise surfaces as a bare "Internal Server Error" 

188 with no cause; the response body carries the actual reason (oversize prompt, 

189 decode failure, ...), which both diagnosis and the user-facing error need. 

190 """ 

191 if resp.is_success: 

192 return 

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

194 body = resp.text.strip() 

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

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

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

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

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

200 if resp.status_code == _HTTP_BAD_REQUEST and ( 

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

202 ): 

203 raise ProviderError( 

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

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

206 provider=_PROVIDER_NAME, 

207 kind=ProviderErrorKind.CONTEXT_OVERFLOW, 

208 ) 

209 # A 429 (slots full) is transient: a cold replica fleet rejects the first ingest 

210 # fan-out until its slots load. Tag RATE_LIMIT so the caller backs off and retries 

211 # instead of dropping the input. 

212 if resp.status_code == _HTTP_TOO_MANY_REQUESTS: 

213 raise ProviderError( 

214 "llama-server is busy (HTTP 429); replicas may still be warming.", 

215 provider=_PROVIDER_NAME, 

216 kind=ProviderErrorKind.RATE_LIMIT, 

217 ) 

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

219 kind = _classify_error(resp.status_code, body) 

220 # llama-swap masks a dead server as "exited prematurely"; surface the server's 

221 # own captured output (a missing CUDA runtime, a model load failure, a bind 

222 # error) so the real exit reason reaches the caller, not only the log. 

223 if _UPSTREAM_DIED_MARKER in body: 

224 tail = _upstream_failure_tail(resp) 

225 if tail: 

226 detail = f"{detail}\nupstream server output:\n{tail}" 

227 classified = classify_upstream_death(tail) 

228 if classified is not None: 

229 kind = classified 

230 raise ProviderError( 

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

232 provider=_PROVIDER_NAME, 

233 kind=kind, 

234 ) 

235 

236 

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

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

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

240# upstream rather than guessed, and matched lowercased. 

241# 

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

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

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

245# hipMalloc marker would match nothing. 

246# 

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

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

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

250 "out of memory", 

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

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

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

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

255 "unable to allocate", 

256 "insufficient memory", 

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

258 "out_of_resources", 

259) 

260 

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

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

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

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

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

266 

267 

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

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

270 

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

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

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

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

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

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

277 guessing at an unfamiliar death. 

278 """ 

279 fatal = [ 

280 line 

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

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

283 ] 

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

285 return ProviderErrorKind.CAPACITY 

286 if _BIND_FAILURE_MARKER in tail: 

287 return ProviderErrorKind.PORT_CONFLICT 

288 return None 

289 

290 

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

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

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

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

295 

296 

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

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

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

300 

301 

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

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

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

305 

306 

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

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

309 

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

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

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

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

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

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

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

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

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

319 it is SERVER, which the busy retry treats as transient. 

320 """ 

321 if _BATCH_OVERFLOW_MARKER in body: 

322 return ProviderErrorKind.CONTEXT_OVERFLOW 

323 if _UPSTREAM_DIED_MARKER in body: 

324 return ProviderErrorKind.CONNECTION 

325 if status_code in _TRANSIENT_GATEWAY_STATUSES: 

326 return ProviderErrorKind.SERVER 

327 return ProviderErrorKind.UNKNOWN 

328 

329 

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

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

332 if isinstance(exc, httpx.TransportError): 

333 return True 

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

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

336 

337 

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

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

340 error), not a template verdict. A cold replica 429s or 502s its first 

341 traffic, so neither response must be read as the template rejecting the 

342 exchange.""" 

343 if is_connection_failure(exc): 

344 return True 

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

346 

347 

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

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

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

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

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

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

354 if tail: 

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

356 return tail 

357 return "" 

358 

359 

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

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

362 

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

364 timeout is the cutoff once the replay is drained. 

365 """ 

366 chunks: list[str] = [] 

367 with ( 

368 contextlib.suppress(httpx.HTTPError), 

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

370 ): 

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

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

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

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

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

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

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

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

379 chunks.append(chunk) 

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

381 if taken >= _UPSTREAM_LOG_MAX_CHUNKS: 

382 break 

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

384 

385 

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

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

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

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

390# body, not as a startup flag. 

391_EMBD_NORMALIZE_NONE = -1 

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

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

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

395_EMBED_ENCODING_FORMAT = "base64" 

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

397# all little-endian. 

398_EMBED_VECTOR_DTYPE = "<f4" 

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

400_RANK_SCORE_INDEX = 0 

401_UNREADABLE_EMBEDDING_ERROR = ( 

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

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

404) 

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

406_HEALTH_PATH = "/health" 

407_CHAT_PATH = "/v1/chat/completions" 

408_EMBED_PATH = "/v1/embeddings" 

409_TOKENIZE_PATH = "/tokenize" 

410_DETOKENIZE_PATH = "/detokenize" 

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

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

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

414_UPSTREAM_PREFIX = "/upstream" 

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

416# the server adds BOS via add_special and leaves special-token strings unparsed. 

417_TOKENIZE_ADD_SPECIAL = True 

418_TOKENIZE_PARSE_SPECIAL = False 

419_HTTP_OK = 200 

420_HTTP_BAD_REQUEST = 400 

421_HTTP_TOO_MANY_REQUESTS = 429 

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

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

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

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

426# Error kinds the busy retry treats as transient: a 429 (slots still loading) 

427# and a bare gateway error (upstream momentarily unreachable) both clear on 

428# their own once the server is ready again. 

429_TRANSIENT_KINDS = frozenset( 

430 {ProviderErrorKind.RATE_LIMIT, ProviderErrorKind.SERVER, ProviderErrorKind.PORT_CONFLICT} 

431) 

432_DONE_SENTINEL = "[DONE]" 

433_DATA_PREFIX = "data:" 

434_DEFAULT_TIMEOUT_S = 300.0 

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

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

437_HEALTH_TIMEOUT_S = 5.0 

438# Retry a server-busy (HTTP 429) response with exponential backoff (capped): a 

439# cold replica fleet 429s the first fan-out until its slots load. Interactive 

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

441_BUSY_RETRIES = 6 

442_BUSY_BACKOFF_BASE_S = 0.5 

443_BUSY_BACKOFF_MAX_S = 8.0 

444# Bulk embed ingest is background work, so it waits out a full cold start rather 

445# than dropping files: an 8B embedder warming while a large chat model loads on 

446# neighboring cards can take well past the interactive budget. Capped backoff 

447# keeps the total near ~80s (0.5+1+2+4 then 8 each), which covers a real warmup. 

448_EMBED_BUSY_RETRIES = 14 

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

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

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

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

453_UNHEALTHY_RETRY_S = 30.0 

454_T = TypeVar("_T") 

455 

456 

457class ChatDeadlineError(ProviderError): 

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

459 

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

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

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

463 """ 

464 

465 

466def retry_on_busy( 

467 call: Callable[[], _T], *, retries: int = _BUSY_RETRIES, deadline: float | None = None 

468) -> _T: 

469 """Run *call*, retrying transient failures (429, gateway errors) with capped backoff. 

470 

471 A cold replica fleet 429s the first fan-out until its slots load, and a 

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

473 retrying turns both drops into successes. With a *deadline* 

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

475 deadline -- a page on a deep OCR queue keeps waiting for a genuinely free 

476 slot instead of dropping after a fixed budget. Without one, *retries* bounds 

477 the attempts. Non-transient errors (and the final still-failing response) 

478 propagate. 

479 """ 

480 delay = _BUSY_BACKOFF_BASE_S 

481 attempt = 0 

482 while True: 

483 try: 

484 return call() 

485 except ProviderError as exc: 

486 if exc.kind not in _TRANSIENT_KINDS: 

487 raise 

488 attempt += 1 

489 exhausted = ( 

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

491 ) 

492 if exhausted: 

493 raise 

494 time.sleep(delay) 

495 delay = min(delay * 2, _BUSY_BACKOFF_MAX_S) 

496 

497 

498class LlamaServerClient: 

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

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

501 

502 def __init__( 

503 self, 

504 base_url: str, 

505 model: str, 

506 *, 

507 http: httpx.Client | None = None, 

508 token_cap: int | None = None, 

509 timeout: float = _DEFAULT_TIMEOUT_S, 

510 rerank_mode: RerankMode | None = None, 

511 inline_reasoning: bool = False, 

512 embed_busy_deadline_s: float | None = None, 

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

514 ) -> None: 

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

516 self._model = model 

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

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

519 # first generated frame proves the prefill is over. 

520 self._on_prefill = on_prefill 

521 # Cold-load budget (seconds) the embed path waits out a still-warming replica 

522 # before dropping the input, in place of the short attempt cap. Set on the 

523 # EMBED-role client to the same ceiling llama-swap keeps the server alive for, 

524 # so a bulk ingest never gives up while the replica is legitimately loading. 

525 # None (rerank, chat, vision, self-check) keeps the fixed interactive budget. 

526 self._embed_busy_deadline_s = embed_busy_deadline_s 

527 self._http = http or httpx.Client( 

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

529 ) 

530 self._owns_http = http is None 

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

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

533 self._inline_reasoning = inline_reasoning 

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

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

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

537 self._token_cap = token_cap 

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

539 self._rerank_mode = rerank_mode 

540 self.in_flight = 0 

541 self._in_flight_lock = threading.Lock() 

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

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

544 # cancel flag, but closing its response unblocks it with an error. 

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

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

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

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

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

550 # the verdict) is fixed once determined. 

551 self._needs_alternation: bool | None = None 

552 self._alternation_lock = threading.Lock() 

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

554 self._healthy = True 

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

556 self._unhealthy_since = 0.0 

557 

558 @property 

559 def healthy(self) -> bool: 

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

561 with self._in_flight_lock: 

562 if self._healthy: 

563 return True 

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

565 

566 def mark_unhealthy(self) -> None: 

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

568 with self._in_flight_lock: 

569 self._healthy = False 

570 self._unhealthy_since = time.monotonic() 

571 

572 def mark_healthy(self) -> None: 

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

574 with self._in_flight_lock: 

575 self._healthy = True 

576 

577 def reserve(self) -> None: 

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

579 

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

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

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

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

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

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

586 """ 

587 with self._in_flight_lock: 

588 self.in_flight += 1 

589 

590 def release(self) -> None: 

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

592 with self._in_flight_lock: 

593 self.in_flight -= 1 

594 

595 def health(self) -> bool: 

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

597 try: 

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

599 except httpx.HTTPError: 

600 return False 

601 return resp.status_code == _HTTP_OK 

602 

603 @overload 

604 def chat( 

605 self, 

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

607 *, 

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

609 stream: Literal[False] = False, 

610 timeout: float | None = None, 

611 ) -> str: ... 

612 

613 @overload 

614 def chat( 

615 self, 

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

617 *, 

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

619 stream: Literal[True], 

620 timeout: float | None = None, 

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

622 

623 @overload 

624 def chat( 

625 self, 

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

627 *, 

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

629 stream: bool, 

630 timeout: float | None = None, 

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

632 

633 def chat( 

634 self, 

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

636 *, 

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

638 stream: bool = False, 

639 timeout: float | None = None, 

640 ) -> str | Iterator[str]: 

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

642 

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

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

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

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

647 """ 

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

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

650 if stream: 

651 return self._chat_stream(payload, request_timeout) 

652 with self._track(): 

653 resp = self._http.post( 

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

655 ) 

656 _raise_for_status(resp) 

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

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

659 return _inline_message_reasoning( 

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

661 ) 

662 

663 def _chat_stream( 

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

665 ) -> Iterator[str]: 

666 inliner = _ThinkInliner(enabled=self._inline_reasoning) 

667 with ( 

668 self._track(), 

669 self._http.stream( 

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

671 ) as resp, 

672 ): 

673 _raise_for_status(resp) 

674 with self._abortable(resp): 

675 for line in resp.iter_lines(): 

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

677 if delta: 

678 yield delta 

679 tail = inliner.finish() 

680 if tail: 

681 yield tail 

682 

683 def chat_bounded( 

684 self, 

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

686 *, 

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

688 deadline_s: float, 

689 ) -> str: 

690 """Stream a chat completion and return its text, bounded by a total deadline. 

691 

692 httpx float timeouts are per-phase (connect/read/...), never a total 

693 budget, so a steadily trickling upstream can pin a worker past its 

694 deadline. Streaming in the caller's own thread and checking a monotonic 

695 deadline per frame bounds total time: on expiry the ``with`` block closes 

696 the stream (releasing the in-flight slot) and raises 

697 :class:`ChatDeadlineError`. 

698 """ 

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

700 deadline = time.monotonic() + deadline_s 

701 inliner = _ThinkInliner(enabled=self._inline_reasoning) 

702 parts: list[str] = [] 

703 with ( 

704 self._track(), 

705 self._http.stream("POST", _CHAT_PATH, json={**payload, "stream": True}) as resp, 

706 ): 

707 _raise_for_status(resp) 

708 for line in resp.iter_lines(): 

709 if time.monotonic() >= deadline: 

710 raise ChatDeadlineError( 

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

712 provider=_PROVIDER_NAME, 

713 ) 

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

715 parts.append(inliner.finish()) 

716 return "".join(parts) 

717 

718 def chat_tools( 

719 self, 

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

721 *, 

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

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

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

725 ) -> ChatToolResult: 

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

727 

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

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

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

731 """ 

732 payload: dict[str, Any] = { 

733 "model": self._model, 

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

735 "tools": tools, 

736 "stream": False, 

737 **(options or {}), 

738 } 

739 if tool_choice is not None: 

740 payload["tool_choice"] = tool_choice 

741 with self._track(): 

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

743 _raise_for_status(resp) 

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

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

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

747 if native: 

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

749 return _recover_bare_json_tool_calls(content) 

750 

751 def chat_result( 

752 self, 

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

754 *, 

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

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

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

758 ) -> ChatResult: 

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

760 

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

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

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

764 and report ``tool_calls`` as the finish reason. Messages are reshaped to 

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

766 :meth:`_prepare_chat_messages`). 

767 """ 

768 payload = self._chat_payload( 

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

770 ) 

771 with self._track(): 

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

773 _raise_for_status(resp) 

774 body = dict(resp.json()) 

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

776 usage = _usage_from_body(body) or TokenUsage() 

777 message = choice["message"] 

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

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

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

781 if native: 

782 return ChatResult( 

783 text=content, 

784 tool_calls=tuple(native), 

785 finish_reason=finish_reason, 

786 usage=usage, 

787 ) 

788 recovered = _recover_bare_json_tool_calls(content) 

789 if recovered.tool_calls: 

790 return ChatResult( 

791 text=recovered.content, 

792 tool_calls=tuple(recovered.tool_calls), 

793 finish_reason=FinishReason.TOOL_CALLS, 

794 usage=usage, 

795 ) 

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

797 

798 def chat_stream_items( 

799 self, 

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

801 *, 

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

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

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

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

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

807 

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

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

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

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

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

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

814 that rejects the raw tool exchange. 

815 

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

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

818 

819 A model that emits a tool call as bare-JSON text instead of native 

820 ``tool_calls`` (a native miss, as on the non-stream paths) is recovered by 

821 wrapping the raw frames; see :func:`_recover_bare_json_stream`. 

822 """ 

823 prepared = self._prepare_chat_messages(messages) 

824 return _recover_bare_json_stream( 

825 self._open_chat_stream(prepared, tools, tool_choice, options) 

826 ) 

827 

828 def _open_chat_stream( 

829 self, 

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

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

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

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

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

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

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

837 inliner = _ThinkInliner(enabled=self._inline_reasoning) 

838 on_prefill = self._on_prefill 

839 prefilling = False 

840 try: 

841 with ( 

842 self._track(), 

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

844 ): 

845 _raise_for_status(resp) 

846 with self._abortable(resp): 

847 for line in resp.iter_lines(): 

848 if on_prefill is not None: 

849 progress = _prefill_progress(line) 

850 if progress is not None: 

851 prefilling = True 

852 on_prefill(progress) 

853 for item in _parse_sse_stream_items(line, inliner): 

854 if prefilling and on_prefill is not None: 

855 # The first real frame proves prefill is over. 

856 prefilling = False 

857 on_prefill(None) 

858 yield item 

859 tail = inliner.finish() 

860 if tail: 

861 yield tail 

862 finally: 

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

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

865 if prefilling and on_prefill is not None: 

866 on_prefill(None) 

867 

868 def _chat_payload( 

869 self, 

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

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

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

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

874 *, 

875 stream: bool, 

876 ) -> dict[str, Any]: 

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

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

879 if stream: 

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

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

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

883 # return_progress makes llama-server stream prompt_progress frames 

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

885 payload["return_progress"] = True 

886 if tools is not None: 

887 payload["tools"] = tools 

888 if tool_choice is not None: 

889 payload["tool_choice"] = tool_choice 

890 payload.update(options or {}) 

891 return payload 

892 

893 def _prepare_chat_messages( 

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

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

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

897 

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

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

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

901 than failing and retrying. 

902 """ 

903 self._ensure_alternation_probed() 

904 if self._needs_alternation: 

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

906 return messages 

907 

908 def _ensure_alternation_probed(self) -> None: 

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

910 

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

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

913 wrong answer. 

914 """ 

915 if self._needs_alternation is not None: 

916 return 

917 with self._alternation_lock: 

918 if self._needs_alternation is not None: 

919 return 

920 verdict = self._probe_alternation() 

921 if verdict is not None: 

922 self._needs_alternation = verdict 

923 

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

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

926 

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

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

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

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

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

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

933 """ 

934 raw = self._chat_probe(_ALTERNATION_PROBE_MESSAGES) 

935 if raw is None: 

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

937 if raw: 

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

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

940 if reshaped is None: 

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

942 return reshaped 

943 

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

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

946 

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

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

949 genuine rejection is a verdict the caller may cache. 

950 """ 

951 payload = self._chat_payload( 

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

953 ) 

954 try: 

955 with self._track(): 

956 resp = self._http.post( 

957 _CHAT_PATH, json=payload, timeout=_ALTERNATION_PROBE_TIMEOUT_S 

958 ) 

959 _raise_for_status(resp) 

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

961 return None if _is_transient_probe_failure(exc) else False 

962 return True 

963 

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

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

966 if not texts: 

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

968 return [] 

969 vectors: list[Vector] = [] 

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

971 data = self._embed_subbatch(sub_batch) 

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

973 return vectors 

974 

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

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

977 

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

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

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

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

982 """ 

983 try: 

984 return self._embeddings_call(sub_batch) 

985 except ProviderError as exc: 

986 if exc.kind is not ProviderErrorKind.CONTEXT_OVERFLOW: 

987 raise 

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

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

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

991 return data 

992 

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

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

995 

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

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

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

999 modes) is moot. 

1000 

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

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

1003 per-pair requests only adds HTTP round trips. 

1004 """ 

1005 if not candidates: 

1006 return [] 

1007 if self._rerank_mode is RerankMode.LLM: 

1008 return self._rerank_llm(query, candidates) 

1009 pairs = [ 

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

1011 for candidate in candidates 

1012 ] 

1013 scores: list[float] = [] 

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

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

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

1017 return scores 

1018 

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

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

1021 if self._token_cap is None: 

1022 return text 

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

1024 

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

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

1027 

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

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

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

1031 """ 

1032 try: 

1033 return self._embeddings_call(batch) 

1034 except ProviderError as exc: 

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

1036 raise 

1037 cap = self._token_cap 

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

1039 return self._embeddings_call(exact) 

1040 

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

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

1043 

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

1045 """ 

1046 template = cfg.reranker_prompt or _LLM_RERANK_PROMPT 

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

1048 with ThreadPoolExecutor(max_workers=workers) as pool: 

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

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

1051 raise ProviderError(_LLM_RERANK_NO_VERDICT_ERROR, provider=_PROVIDER_NAME) 

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

1053 

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

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

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

1057 payload = { 

1058 "model": self._model, 

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

1060 "max_tokens": 1, 

1061 "temperature": 0, 

1062 "logprobs": True, 

1063 "top_logprobs": _LLM_RERANK_TOP_LOGPROBS, 

1064 "stream": False, 

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

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

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

1068 } 

1069 

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

1071 with self._track(): 

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

1073 _raise_for_status(resp) 

1074 return dict(resp.json()) 

1075 

1076 return _llm_rerank_score(_first_token_top_logprobs(retry_on_busy(_call))) 

1077 

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

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

1080 

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

1082 with self._track(): 

1083 resp = self._http.post( 

1084 _EMBED_PATH, 

1085 json={ 

1086 "model": self._model, 

1087 "input": inputs, 

1088 "embd_normalize": _EMBD_NORMALIZE_NONE, 

1089 "encoding_format": _EMBED_ENCODING_FORMAT, 

1090 }, 

1091 ) 

1092 _raise_for_status(resp) 

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

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

1095 raise ProviderError( 

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

1097 provider=_PROVIDER_NAME, 

1098 ) 

1099 return list(data) 

1100 

1101 # Bulk ingest can afford to wait out a cold-start warmup rather than drop 

1102 # files. With a cold-load deadline (the EMBED-role client) the retry waits 

1103 # out a still-loading replica for the full budget llama-swap keeps it alive, 

1104 # instead of dropping the file after the fixed attempt cap; without one the 

1105 # fixed count bounds an interactive caller. 

1106 if self._embed_busy_deadline_s is not None: 

1107 return retry_on_busy(_call, deadline=time.monotonic() + self._embed_busy_deadline_s) 

1108 return retry_on_busy(_call, retries=_EMBED_BUSY_RETRIES) 

1109 

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

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

1112 

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

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

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

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

1117 sequences, bounding per-request size and the busy-retry window. No cap 

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

1119 

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

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

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

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

1124 every input exactly. 

1125 """ 

1126 if self._token_cap is None: 

1127 return [texts] 

1128 cap = self._token_cap 

1129 batches: list[list[str]] = [] 

1130 current: list[str] = [] 

1131 current_tokens = 0 

1132 for text in texts: 

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

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

1135 batches.append(current) 

1136 current = [] 

1137 current_tokens = 0 

1138 current.append(item) 

1139 current_tokens += item_tokens 

1140 if current: 

1141 batches.append(current) 

1142 return batches 

1143 

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

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

1146 

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

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

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

1150 """ 

1151 if estimate: 

1152 est = _estimate_tokens(text) 

1153 if est <= cap: 

1154 return text, est 

1155 tokens = self._tokenize(text) 

1156 if len(tokens) > cap: 

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

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

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

1160 

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

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

1163 

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

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

1166 """ 

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

1168 

1169 def _tokenize(self, text: str) -> list[int]: 

1170 resp = self._http.post( 

1171 self._native_route(_TOKENIZE_PATH), 

1172 json={ 

1173 "content": text, 

1174 "add_special": _TOKENIZE_ADD_SPECIAL, 

1175 "parse_special": _TOKENIZE_PARSE_SPECIAL, 

1176 }, 

1177 ) 

1178 _raise_for_status(resp) 

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

1180 

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

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

1183 return len(self._tokenize(text)) 

1184 

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

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

1187 _raise_for_status(resp) 

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

1189 

1190 @contextlib.contextmanager 

1191 def _abortable(self, resp: httpx.Response) -> Generator[None]: 

1192 """Expose *resp* to ``abort_streams`` for the duration of its read loop.""" 

1193 with self._in_flight_lock: 

1194 self._active_streams.add(resp) 

1195 try: 

1196 yield 

1197 finally: 

1198 with self._in_flight_lock: 

1199 self._active_streams.discard(resp) 

1200 

1201 def abort_streams(self) -> None: 

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

1203 

1204 Closing the response from another thread unblocks a reader stuck in 

1205 ``iter_lines`` with a stream error, which unwinds its worker; 

1206 llama-server stops generating when the connection drops. 

1207 """ 

1208 with self._in_flight_lock: 

1209 streams = list(self._active_streams) 

1210 for resp in streams: 

1211 with contextlib.suppress(Exception): 

1212 resp.close() 

1213 

1214 def close(self) -> None: 

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

1216 if self._owns_http: 

1217 self._http.close() 

1218 

1219 def _track(self) -> _InFlight: 

1220 return _InFlight(self) 

1221 

1222 

1223class _InFlight: 

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

1225 

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

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

1228 """ 

1229 

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

1231 self._client = client 

1232 

1233 def __enter__(self) -> None: 

1234 with self._client._in_flight_lock: 

1235 self._client.in_flight += 1 

1236 

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

1238 with self._client._in_flight_lock: 

1239 self._client.in_flight -= 1 

1240 

1241 

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

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

1244 embedding = item.get("embedding") 

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

1246 if not isinstance(embedding, str): 

1247 raise ProviderError(_UNREADABLE_EMBEDDING_ERROR, provider=_PROVIDER_NAME) 

1248 try: 

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

1250 except ValueError as exc: 

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

1252 

1253 

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

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

1256 vector = _embedding_vector(item) 

1257 if not vector.size: 

1258 raise ProviderError(_NO_RERANK_SCORE_ERROR, provider=_PROVIDER_NAME) 

1259 return float(vector[_RANK_SCORE_INDEX]) 

1260 

1261 

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

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

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

1265 if not choices: 

1266 return [] 

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

1268 if not content: 

1269 return [] 

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

1271 

1272 

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

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

1275 

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

1277 """ 

1278 yes_lp: float | None = None 

1279 no_lp: float | None = None 

1280 for entry in top_logprobs: 

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

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

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

1284 yes_lp = logprob 

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

1286 no_lp = logprob 

1287 if yes_lp is None: 

1288 return None if no_lp is None else 0.0 

1289 if no_lp is None: 

1290 return math.exp(yes_lp) 

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

1292 return yes_e / (yes_e + no_e) 

1293 

1294 

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

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

1297 if not line.startswith(_DATA_PREFIX): 

1298 return "", "" 

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

1300 if not body or body == _DONE_SENTINEL: 

1301 return "", "" 

1302 try: 

1303 obj = json.loads(body) 

1304 except json.JSONDecodeError: 

1305 return "", "" 

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

1307 if not choices: 

1308 return "", "" 

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

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

1311 

1312 

1313class _ThinkInliner: 

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

1315 

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

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

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

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

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

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

1322 """ 

1323 

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

1325 self._enabled = enabled 

1326 self._in_think = False 

1327 

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

1329 if not self._enabled: 

1330 return content 

1331 parts: list[str] = [] 

1332 if reasoning: 

1333 if not self._in_think: 

1334 self._in_think = True 

1335 parts.append(THINK_OPEN_TAG) 

1336 parts.append(reasoning) 

1337 if content: 

1338 if self._in_think: 

1339 self._in_think = False 

1340 parts.append(THINK_CLOSE_TAG) 

1341 parts.append(content) 

1342 return "".join(parts) 

1343 

1344 def finish(self) -> str: 

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

1346 if self._in_think: 

1347 self._in_think = False 

1348 return THINK_CLOSE_TAG 

1349 return "" 

1350 

1351 

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

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

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

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

1356 if reasoning: 

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

1358 return content 

1359 

1360 

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

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

1363 

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

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

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

1367 """ 

1368 usage = body.get("usage") 

1369 if not isinstance(usage, Mapping): 

1370 return None 

1371 prompt = usage.get("prompt_tokens") 

1372 completion = usage.get("completion_tokens") 

1373 return TokenUsage( 

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

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

1376 ) 

1377 

1378 

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

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

1381 return FinishReason.coerce(raw) 

1382 

1383 

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

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

1386 

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

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

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

1390 """ 

1391 raw_index = call.get("index") 

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

1393 call_id = call.get("id") 

1394 fn = call.get("function") 

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

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

1397 return ToolCallDelta( 

1398 index=index, 

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

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

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

1402 ) 

1403 

1404 

1405_PROMPT_PROGRESS_KEY = "prompt_progress" 

1406 

1407 

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

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

1410 

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

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

1413 every ordinary token line. 

1414 """ 

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

1416 return None 

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

1418 try: 

1419 obj = json.loads(body) 

1420 except json.JSONDecodeError: 

1421 return None 

1422 progress = obj.get(_PROMPT_PROGRESS_KEY) 

1423 if not isinstance(progress, Mapping): 

1424 return None 

1425 try: 

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

1427 except (KeyError, TypeError, ValueError): 

1428 return None 

1429 

1430 

1431def _parse_sse_stream_items( 

1432 line: str, inliner: _ThinkInliner 

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

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

1435 

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

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

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

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

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

1441 """ 

1442 if not line.startswith(_DATA_PREFIX): 

1443 return 

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

1445 if not body or body == _DONE_SENTINEL: 

1446 return 

1447 try: 

1448 obj = json.loads(body) 

1449 except json.JSONDecodeError: 

1450 return 

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

1452 if not choices: 

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

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

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

1456 usage = _usage_from_body(obj) 

1457 if usage is not None: 

1458 yield usage 

1459 return 

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

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

1462 if text: 

1463 yield text 

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

1465 for i, call in enumerate(raw_calls): 

1466 if isinstance(call, Mapping): 

1467 yield _tool_call_delta_from_chunk(call, fallback_index=i) 

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

1469 if raw_finish is not None: 

1470 yield StreamFinish(reason=_coerce_finish_reason(raw_finish)) 

1471 

1472 

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

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

1475 if isinstance(arguments, str): 

1476 return arguments 

1477 if arguments is None: 

1478 return "{}" 

1479 return json.dumps(arguments) 

1480 

1481 

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

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

1484 

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

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

1487 """ 

1488 if not isinstance(raw, list): 

1489 return [] 

1490 calls: list[ToolCall] = [] 

1491 for idx, entry in enumerate(raw): 

1492 if not isinstance(entry, Mapping): 

1493 continue 

1494 fn = entry.get("function") 

1495 if not isinstance(fn, Mapping): 

1496 continue 

1497 name = fn.get("name") 

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

1499 continue 

1500 call_id = entry.get("id") 

1501 calls.append( 

1502 ToolCall( 

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

1504 name=name, 

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

1506 ) 

1507 ) 

1508 return calls 

1509 

1510 

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

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

1513 name = obj.get("name") 

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

1515 return None 

1516 arguments = obj.get("arguments") 

1517 if arguments is None: 

1518 arguments = obj.get("parameters") 

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

1520 

1521 

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

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

1524 

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

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

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

1528 return the content unchanged with no calls. 

1529 """ 

1530 stripped = content.strip() 

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

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

1533 try: 

1534 parsed = json.loads(stripped) 

1535 except json.JSONDecodeError: 

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

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

1538 calls = [ 

1539 call 

1540 for idx, entry in enumerate(entries) 

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

1542 ] 

1543 if not calls: 

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

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

1546 

1547 

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

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

1550# text and streams through untouched. 

1551_BARE_CALL_OPENERS = "{[" 

1552 

1553 

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

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

1556 

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

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

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

1560 """ 

1561 return ToolCallDelta( 

1562 index=index, 

1563 id=call.id or None, 

1564 name=call.name or None, 

1565 arguments_delta=call.arguments or None, 

1566 ) 

1567 

1568 

1569def _recover_bare_json_stream( 

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

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

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

1573 

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

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

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

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

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

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

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

1581 """ 

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

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

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

1585 committed = False 

1586 try: 

1587 for item in items: 

1588 if isinstance(item, ToolCallDelta): 

1589 yield from _flush_plain(buffer) 

1590 buffer, committed = "", True 

1591 yield item 

1592 elif isinstance(item, TokenUsage | StreamFinish): 

1593 yield from _recover_buffer(buffer) 

1594 buffer = "" 

1595 yield item 

1596 elif committed or _passthrough_text(buffer, item): 

1597 yield from _flush_plain(buffer) 

1598 buffer = "" 

1599 committed = True 

1600 yield item 

1601 else: 

1602 buffer += item 

1603 yield from _recover_buffer(buffer) 

1604 finally: 

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

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

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

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

1609 # mask the exception that triggered this finally. 

1610 if isinstance(items, Generator): 

1611 with contextlib.suppress(Exception): 

1612 items.close() 

1613 

1614 

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

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

1617 

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

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

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

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

1622 """ 

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

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

1625 

1626 

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

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

1629 if buffer: 

1630 yield buffer 

1631 

1632 

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

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

1635 

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

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

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

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

1640 """ 

1641 if not buffer: 

1642 return 

1643 recovered = _recover_bare_json_tool_calls(buffer) 

1644 if not recovered.tool_calls: 

1645 yield buffer 

1646 return 

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

1648 yield _tool_call_delta_from_recovered(call, index)