Coverage for src/lilbee/server/chat_dispatch/dispatch.py: 100%

201 statements  

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

1"""Canonical chat dispatch: canonical request to provider call to canonical response.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6import json 

7import logging 

8import uuid 

9from collections.abc import AsyncIterator, Iterator 

10from enum import StrEnum 

11from typing import Any, Literal 

12 

13from lilbee.app.services import get_services 

14from lilbee.core.config import cfg 

15from lilbee.providers.base import ( 

16 ChatResult, 

17 ChatStreamItem, 

18 FinishReason, 

19 ProviderError, 

20 ProviderErrorKind, 

21 StreamFinish, 

22 TokenUsage, 

23 ToolCallDelta, 

24) 

25from lilbee.providers.model_ref import parse_model_ref 

26from lilbee.providers.roles import WorkerRole, configured_model_message 

27from lilbee.server.chat_dispatch.canonical import ( 

28 CanonicalChatRequest, 

29 CanonicalMessage, 

30 CanonicalResponse, 

31 CanonicalStreamEvent, 

32 CanonicalTool, 

33 CanonicalToolChoice, 

34 CanonicalUsage, 

35 ContentBlock, 

36 ContentBlockDelta, 

37 ContentBlockStart, 

38 ContentBlockStop, 

39 MessageDelta, 

40 MessageStart, 

41 MessageStop, 

42 StopReason, 

43 TextBlock, 

44 TextDelta, 

45 ToolResultBlock, 

46 ToolUseBlock, 

47 ToolUseDelta, 

48) 

49from lilbee.server.chat_dispatch.capability import model_supports_tools 

50from lilbee.server.chat_dispatch.tool_args import parse_tool_arguments 

51 

52log = logging.getLogger(__name__) 

53 

54 

55class ModelNotFoundError(Exception): 

56 """Raised when the requested model is not installed or reachable.""" 

57 

58 def __init__(self, model: str) -> None: 

59 self.model = model 

60 super().__init__( 

61 f"Model {model!r} is not installed. Run 'lilbee model list' to see " 

62 f"installed models, or 'lilbee model pull {model}' to download it." 

63 ) 

64 

65 

66class ModelDoesNotSupportToolsError(Exception): 

67 """Raised when the request carries tools but the model template cannot use them.""" 

68 

69 def __init__(self, model: str) -> None: 

70 self.model = model 

71 super().__init__( 

72 f"Model {model!r} does not support tool calls. Pick a chat model " 

73 f"with a tool-aware chat template, or remove tools from the request." 

74 ) 

75 

76 

77_FINISH_REASON_TO_STOP: dict[FinishReason, StopReason] = { 

78 FinishReason.STOP: StopReason.END_TURN, 

79 FinishReason.LENGTH: StopReason.MAX_TOKENS, 

80 FinishReason.TOOL_CALLS: StopReason.TOOL_USE, 

81 FinishReason.CONTENT_FILTER: StopReason.END_TURN, 

82} 

83 

84_CanonicalChoiceMode = Literal["auto", "any", "none"] 

85_ProviderChoiceMode = Literal["auto", "required", "none"] 

86 

87_TOOL_CHOICE_MODES: dict[_CanonicalChoiceMode, _ProviderChoiceMode] = { 

88 "auto": "auto", 

89 "any": "required", 

90 "none": "none", 

91} 

92 

93 

94class _OpenBlockKind(StrEnum): 

95 NONE = "none" 

96 TEXT = "text" 

97 TOOL = "tool" 

98 

99 

100def _provider_chat_kwargs(req: CanonicalChatRequest, canonical_model: str) -> dict[str, Any]: 

101 """Shared provider.chat keyword arguments for both stream and non-stream paths.""" 

102 return { 

103 "messages": _provider_messages(req), 

104 "options": _provider_options(req), 

105 "model": canonical_model, 

106 "tools": _provider_tools(req.tools), 

107 "tool_choice": _provider_tool_choice(req.tool_choice), 

108 } 

109 

110 

111def _stop_reason_for(result: ChatResult) -> StopReason: 

112 """Closing stop reason for a non-streaming result. 

113 

114 Tool calls win over the reported finish reason. FinishReason.coerce falls 

115 back to STOP for a missing or unknown value, so a provider that returns 

116 tool calls without saying so produced tool_use content under end_turn, and 

117 a client reading stop_reason decides whether to run the tools. The 

118 streaming path already refuses the same downgrade. 

119 """ 

120 if result.tool_calls: 

121 return StopReason.TOOL_USE 

122 return _FINISH_REASON_TO_STOP.get(result.finish_reason, StopReason.END_TURN) 

123 

124 

125def _content_blocks_from_result(result: ChatResult) -> list[ContentBlock]: 

126 """Build canonical content blocks from a non-streaming provider result.""" 

127 content: list[ContentBlock] = [] 

128 if result.text: 

129 content.append(TextBlock(text=result.text)) 

130 for call in result.tool_calls: 

131 content.append( 

132 ToolUseBlock( 

133 id=call.id or _new_call_id(), 

134 name=call.name, 

135 input=parse_tool_arguments(call.arguments), 

136 ) 

137 ) 

138 return content 

139 

140 

141def dispatch_chat( 

142 req: CanonicalChatRequest, *, canonical_model: str | None = None 

143) -> CanonicalResponse: 

144 """Run a non-streaming chat request through the provider and return canonical output. 

145 

146 Pass *canonical_model* when the caller has already run 

147 :func:`preflight_chat_request` (the route does, so the preflight runs once per 

148 request); leave it ``None`` to resolve and validate the model here. 

149 """ 

150 if canonical_model is None: 

151 canonical_model = preflight_chat_request(req) 

152 result = get_services().provider.chat(**_provider_chat_kwargs(req, canonical_model)) 

153 return CanonicalResponse( 

154 id=_new_message_id(), 

155 model=canonical_model, 

156 content=_content_blocks_from_result(result), 

157 stop_reason=_stop_reason_for(result), 

158 usage=CanonicalUsage( 

159 input_tokens=result.usage.prompt_tokens, 

160 output_tokens=result.usage.completion_tokens, 

161 ), 

162 ) 

163 

164 

165async def dispatch_chat_stream( 

166 req: CanonicalChatRequest, *, canonical_model: str | None = None 

167) -> AsyncIterator[CanonicalStreamEvent]: 

168 """Stream a canonical event sequence by translating provider frames on the fly. 

169 

170 Pass *canonical_model* when the caller has already run 

171 :func:`preflight_chat_request` (the route does, so the preflight runs once per 

172 request); leave it ``None`` to resolve and validate the model here. 

173 """ 

174 # The preflight can do blocking HTTP model discovery when its TTL lapses, and 

175 # opening the stream can issue a one-time template probe; run both in a thread 

176 # so the event loop stays responsive. 

177 if canonical_model is None: 

178 canonical_model = await asyncio.to_thread(preflight_chat_request, req) 

179 stream = await asyncio.to_thread( 

180 lambda: get_services().provider.chat( 

181 stream=True, **_provider_chat_kwargs(req, canonical_model) 

182 ) 

183 ) 

184 try: 

185 yield MessageStart(id=_new_message_id(), model=canonical_model) 

186 state = _StreamState() 

187 async for frame in _async_iter_provider_stream(stream): 

188 for event in state.feed(frame): 

189 yield event 

190 for event in state.finish(): 

191 yield event 

192 yield MessageStop() 

193 finally: 

194 # close() tears down the provider HTTP connection and can block; offload 

195 # it like the open and per-frame reads so the event loop stays responsive. 

196 await asyncio.to_thread(stream.close) 

197 

198 

199async def _async_iter_provider_stream( 

200 stream: Iterator[ChatStreamItem], 

201) -> AsyncIterator[ChatStreamItem]: 

202 """Iterate a provider chat stream without blocking the event loop. 

203 

204 ``LLMProvider.chat`` types a streaming result as a ClosableIterator, and 

205 every provider in the tree returns a plain sync generator; iterating one 

206 inline on the event loop would block, so each ``next()`` runs in a worker 

207 thread via ``asyncio.to_thread``. 

208 

209 There used to be an async-native branch here for a provider shape that 

210 does not exist. It was dead and also wrong: the caller's cleanup is 

211 ``await asyncio.to_thread(stream.close)``, which an async-native stream 

212 would not satisfy. Adding one means changing the Protocol and that 

213 cleanup together, not restoring a branch nothing reaches. 

214 """ 

215 while True: 

216 frame = await asyncio.to_thread(_next_or_done, stream) 

217 if frame is _STREAM_DONE: 

218 return 

219 yield frame 

220 

221 

222_STREAM_DONE: Any = object() 

223"""Sentinel returned by :func:`_next_or_done` to mean ``StopIteration``.""" 

224 

225 

226def _next_or_done( 

227 stream: Iterator[ChatStreamItem], 

228) -> ChatStreamItem | Any: 

229 """Pull the next frame from *stream*; return ``_STREAM_DONE`` at exhaustion. 

230 

231 Raising ``StopIteration`` inside a coroutine becomes ``RuntimeError`` per 

232 PEP 479; this helper converts that signal into a sentinel value the async 

233 caller can branch on. 

234 """ 

235 try: 

236 return next(stream) 

237 except StopIteration: 

238 return _STREAM_DONE 

239 

240 

241class _StreamState: 

242 """Tracks open content blocks so deltas land in the right index.""" 

243 

244 def __init__(self) -> None: 

245 self._open: _OpenBlockKind = _OpenBlockKind.NONE 

246 self._index: int = -1 

247 self._tool_index: int | None = None 

248 # Provider tool index -> the (id, name) its first delta carried. 

249 # Continuation deltas typically carry neither. 

250 self._tool_identity: dict[int, tuple[str, str]] = {} 

251 self._stop_reason: StopReason = StopReason.END_TURN 

252 self._usage: TokenUsage | None = None 

253 

254 def feed(self, frame: ChatStreamItem) -> Iterator[CanonicalStreamEvent]: 

255 if isinstance(frame, str): 

256 yield from self._feed_text(frame) 

257 elif isinstance(frame, TokenUsage): 

258 # Terminator-only frame: carries token totals, no content. Stash it 

259 # so finish() can attach the counts to the closing MessageDelta. 

260 self._usage = frame 

261 elif isinstance(frame, StreamFinish): 

262 self._feed_finish(frame) 

263 else: 

264 yield from self._feed_tool(frame) 

265 

266 def finish(self) -> Iterator[CanonicalStreamEvent]: 

267 if self._open != _OpenBlockKind.NONE: 

268 yield ContentBlockStop(index=self._index) 

269 self._open = _OpenBlockKind.NONE 

270 usage = ( 

271 CanonicalUsage( 

272 input_tokens=self._usage.prompt_tokens, 

273 output_tokens=self._usage.completion_tokens, 

274 ) 

275 if self._usage is not None 

276 else None 

277 ) 

278 yield MessageDelta(stop_reason=self._stop_reason, usage=usage) 

279 

280 def _feed_finish(self, frame: StreamFinish) -> None: 

281 # The finish frame sets the closing stop reason (e.g. MAX_TOKENS on a 

282 # length truncation). A tool-call stream already settled on TOOL_USE via 

283 # the deltas, so never let a trailing finish frame downgrade that. 

284 if self._stop_reason is StopReason.TOOL_USE: 

285 return 

286 self._stop_reason = _FINISH_REASON_TO_STOP.get(frame.reason, StopReason.END_TURN) 

287 

288 def _feed_text(self, text: str) -> Iterator[CanonicalStreamEvent]: 

289 if self._open != _OpenBlockKind.TEXT: 

290 yield from self._close_current() 

291 self._index += 1 

292 self._open = _OpenBlockKind.TEXT 

293 yield ContentBlockStart(index=self._index, block=TextBlock(text="")) 

294 yield ContentBlockDelta(index=self._index, delta=TextDelta(text=text)) 

295 

296 def _feed_tool(self, frame: ToolCallDelta) -> Iterator[CanonicalStreamEvent]: 

297 self._stop_reason = StopReason.TOOL_USE 

298 is_new_call = self._open != _OpenBlockKind.TOOL or frame.index != self._tool_index 

299 if is_new_call: 

300 yield from self._close_current() 

301 self._index += 1 

302 self._open = _OpenBlockKind.TOOL 

303 self._tool_index = frame.index 

304 yield ContentBlockStart( 

305 index=self._index, 

306 block=ToolUseBlock(**self._tool_block_fields(frame)), 

307 ) 

308 if frame.arguments_delta is not None: 

309 yield ContentBlockDelta( 

310 index=self._index, 

311 delta=ToolUseDelta(partial_json=frame.arguments_delta), 

312 ) 

313 

314 def _tool_block_fields(self, frame: ToolCallDelta) -> dict[str, Any]: 

315 """Identity for the block opening on *frame*, remembered per tool index. 

316 

317 A text frame between two argument deltas of one call (streamed 

318 reasoning surfaced as text, say) closes the open tool block, so the 

319 next delta for the same call has to open a second block. Continuation 

320 deltas carry no id and no name, so that block used to get a fresh 

321 synthetic id and an empty name, splitting one logical call across two 

322 blocks the second of which matched no tool. Reusing the identity the 

323 call already announced at least leaves both blocks stitchable by id. 

324 """ 

325 known = self._tool_identity.get(frame.index) 

326 identity = ( 

327 frame.id or (known[0] if known else _new_call_id()), 

328 frame.name or (known[1] if known else ""), 

329 ) 

330 self._tool_identity[frame.index] = identity 

331 return {"id": identity[0], "name": identity[1], "input": {}} 

332 

333 def _close_current(self) -> Iterator[CanonicalStreamEvent]: 

334 if self._open != _OpenBlockKind.NONE: 

335 yield ContentBlockStop(index=self._index) 

336 self._open = _OpenBlockKind.NONE 

337 

338 

339def _resolve_canonical_model(model: str) -> str: 

340 """Return the canonical ref for *model*, or raise ``ModelNotFoundError``. 

341 

342 Consults the cached union of native + remote + frontier refs on 

343 Services, so an Ollama-managed model resolves the same way a locally 

344 installed GGUF does. A bare ``name:tag`` matches the corresponding 

345 ``ollama/<name:tag>`` entry when one exists in the discovered set. 

346 """ 

347 canonical = get_services().known_models.resolve(model) 

348 if canonical is None: 

349 raise ModelNotFoundError(model) 

350 return canonical 

351 

352 

353def _ensure_tool_capability(req: CanonicalChatRequest, model: str) -> None: 

354 if req.tools and not model_supports_tools(model): 

355 raise ModelDoesNotSupportToolsError(model) 

356 

357 

358def _ensure_configured_local_model(canonical: str) -> None: 

359 """Reject a local-route model that is not the configured chat model. 

360 

361 Mirrors the fleet's own configured-model guard (which stays in place as 

362 defense in depth for direct provider users) so streaming clients get a 

363 clean 400 before headers instead of an SSE error frame mid-stream. 

364 """ 

365 if not parse_model_ref(canonical).is_local or canonical == cfg.chat_model: 

366 return 

367 raise ProviderError( 

368 configured_model_message(WorkerRole.CHAT, cfg.chat_model, canonical), 

369 kind=ProviderErrorKind.BAD_REQUEST, 

370 ) 

371 

372 

373def preflight_chat_request(req: CanonicalChatRequest) -> str: 

374 """Synchronously validate *req* before any streaming response starts. 

375 

376 Raises ``ModelNotFoundError``, ``ModelDoesNotSupportToolsError``, or a 

377 ``BAD_REQUEST`` ``ProviderError`` so the route layer can return a real 

378 4xx HTTP status instead of burying the failure in an SSE error frame 

379 after headers flush. Returns the resolved canonical model ref. 

380 """ 

381 canonical = _resolve_canonical_model(req.model) 

382 _ensure_configured_local_model(canonical) 

383 _ensure_tool_capability(req, canonical) 

384 return canonical 

385 

386 

387def _provider_messages(req: CanonicalChatRequest) -> list[dict[str, Any]]: 

388 """Flatten canonical messages to the OpenAI-shaped wire format the provider speaks.""" 

389 out: list[dict[str, Any]] = [] 

390 if req.system is not None: 

391 out.append({"role": "system", "content": req.system}) 

392 for msg in req.messages: 

393 out.extend(_translate_message(msg)) 

394 return out 

395 

396 

397def _translate_message(msg: CanonicalMessage) -> list[dict[str, Any]]: 

398 text_parts = [b.text for b in msg.content if isinstance(b, TextBlock)] 

399 tool_uses = [b for b in msg.content if isinstance(b, ToolUseBlock)] 

400 tool_results = [b for b in msg.content if isinstance(b, ToolResultBlock)] 

401 text = "".join(text_parts) 

402 

403 # One ``tool`` wire-message per result block; tool_call_id pairs it back to 

404 # the originating ToolUseBlock. Text blocks in the same canonical message 

405 # follow as their own content message rather than being dropped. 

406 out: list[dict[str, Any]] = [ 

407 { 

408 "role": "tool", 

409 "tool_call_id": block.tool_use_id, 

410 "content": _flatten_text(block.content), 

411 } 

412 for block in tool_results 

413 ] 

414 if tool_uses: 

415 out.append( 

416 { 

417 "role": msg.role, 

418 "content": text, 

419 "tool_calls": [ 

420 { 

421 "id": tu.id, 

422 "type": "function", 

423 "function": { 

424 "name": tu.name, 

425 "arguments": json.dumps(tu.input), 

426 }, 

427 } 

428 for tu in tool_uses 

429 ], 

430 } 

431 ) 

432 elif text or not tool_results: 

433 out.append({"role": msg.role, "content": text}) 

434 return out 

435 

436 

437def _flatten_text(blocks: list[ContentBlock]) -> str: 

438 return "".join(b.text for b in blocks if isinstance(b, TextBlock)) 

439 

440 

441def _provider_tools( 

442 tools: list[CanonicalTool] | None, 

443) -> list[dict[str, Any]] | None: 

444 if not tools: 

445 return None 

446 return [ 

447 { 

448 "type": "function", 

449 "function": { 

450 "name": tool.name, 

451 "description": tool.description, 

452 "parameters": tool.input_schema, 

453 }, 

454 } 

455 for tool in tools 

456 ] 

457 

458 

459def _provider_tool_choice( 

460 choice: CanonicalToolChoice | None, 

461) -> str | dict[str, Any] | None: 

462 if choice is None: 

463 return None 

464 if choice.mode == "tool": 

465 return {"type": "function", "function": {"name": choice.tool_name}} 

466 return _TOOL_CHOICE_MODES[choice.mode] 

467 

468 

469def _provider_options(req: CanonicalChatRequest) -> dict[str, Any] | None: 

470 out: dict[str, Any] = {} 

471 if req.temperature is not None: 

472 out["temperature"] = req.temperature 

473 if req.top_p is not None: 

474 out["top_p"] = req.top_p 

475 if req.top_k is not None: 

476 out["top_k"] = req.top_k 

477 if req.max_tokens is not None: 

478 out["num_predict"] = req.max_tokens 

479 if req.seed is not None: 

480 out["seed"] = req.seed 

481 if req.frequency_penalty is not None: 

482 out["frequency_penalty"] = req.frequency_penalty 

483 if req.presence_penalty is not None: 

484 out["presence_penalty"] = req.presence_penalty 

485 if req.stop is not None: 

486 out["stop"] = req.stop 

487 return out or None 

488 

489 

490def _new_call_id() -> str: 

491 return f"call_{uuid.uuid4().hex[:24]}" 

492 

493 

494def _new_message_id() -> str: 

495 return f"msg_{uuid.uuid4().hex[:24]}"