Coverage for src/lilbee/server/chat_completions_api/translate.py: 100%

160 statements  

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

1"""Translation between OpenAI chat-completions models and the canonical types.""" 

2 

3from __future__ import annotations 

4 

5import json 

6import time 

7from collections.abc import AsyncIterator 

8from typing import Literal, assert_never 

9 

10from lilbee.retrieval.reasoning import StreamToken, TagParser, split_reasoning 

11from lilbee.server.chat_completions_api.models import ( 

12 CompletionsImageContent, 

13 CompletionsMessage, 

14 CompletionsNamedToolChoice, 

15 CompletionsRequest, 

16 CompletionsResponse, 

17 CompletionsResponseChoice, 

18 CompletionsResponseMessage, 

19 CompletionsResponseToolCall, 

20 CompletionsResponseToolCallFunction, 

21 CompletionsStreamChoice, 

22 CompletionsStreamChunk, 

23 CompletionsStreamDelta, 

24 CompletionsStreamToolCall, 

25 CompletionsStreamToolCallFunction, 

26 CompletionsTextContent, 

27 CompletionsTool, 

28 CompletionsUsage, 

29 FinishReason, 

30 ToolChoiceMode, 

31) 

32from lilbee.server.chat_dispatch.canonical import ( 

33 CanonicalChatRequest, 

34 CanonicalMessage, 

35 CanonicalResponse, 

36 CanonicalStreamEvent, 

37 CanonicalTool, 

38 CanonicalToolChoice, 

39 CanonicalUsage, 

40 ContentBlock, 

41 ContentBlockDelta, 

42 ContentBlockStart, 

43 ContentBlockStop, 

44 MessageDelta, 

45 MessageStart, 

46 MessageStop, 

47 StopReason, 

48 TextBlock, 

49 TextDelta, 

50 ToolResultBlock, 

51 ToolUseBlock, 

52 ToolUseDelta, 

53) 

54from lilbee.server.chat_dispatch.tool_args import parse_tool_arguments 

55 

56_TOOL_CHOICE_MODES: dict[ToolChoiceMode, Literal["auto", "any", "none"]] = { 

57 ToolChoiceMode.AUTO: "auto", 

58 ToolChoiceMode.NONE: "none", 

59 ToolChoiceMode.REQUIRED: "any", 

60} 

61 

62_STOP_REASON_TO_FINISH: dict[StopReason, FinishReason] = { 

63 StopReason.END_TURN: FinishReason.STOP, 

64 StopReason.MAX_TOKENS: FinishReason.LENGTH, 

65 StopReason.TOOL_USE: FinishReason.TOOL_CALLS, 

66} 

67 

68_TOOL_CALL_ID_REQUIRED = "A tool message requires tool_call_id naming the call it answers." 

69_IMAGE_CONTENT_UNSUPPORTED = ( 

70 "Image content is not supported by /v1/chat/completions yet. Send a text-only request." 

71) 

72 

73 

74def completions_to_canonical_request(request: CompletionsRequest) -> CanonicalChatRequest: 

75 """Translate a validated ``CompletionsRequest`` to the canonical request.""" 

76 system_parts: list[str] = [] 

77 messages: list[CanonicalMessage] = [] 

78 for msg in request.messages: 

79 if msg.role == "system": 

80 system_parts.append(_system_text(msg)) 

81 continue 

82 messages.append(_message_from_request(msg)) 

83 

84 return CanonicalChatRequest( 

85 model=request.model, 

86 messages=messages, 

87 system="\n\n".join(system_parts) if system_parts else None, 

88 tools=_tools_from_request(request.tools), 

89 tool_choice=_tool_choice_from_request(request.tool_choice), 

90 temperature=request.temperature, 

91 top_p=request.top_p, 

92 top_k=request.top_k, 

93 max_tokens=request.max_tokens, 

94 seed=request.seed, 

95 frequency_penalty=request.frequency_penalty, 

96 presence_penalty=request.presence_penalty, 

97 stop=_stop_from_request(request.stop), 

98 stream=request.stream, 

99 ) 

100 

101 

102def canonical_to_completions_response( 

103 resp: CanonicalResponse, *, response_id: str 

104) -> CompletionsResponse: 

105 """Translate a canonical chat response to the OpenAI ``chat.completion`` model.""" 

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

107 tool_calls = [_response_tool_call(b) for b in resp.content if isinstance(b, ToolUseBlock)] 

108 # lilbee carries a reasoning model's thinking inline as <think>...</think>; the 

109 # OpenAI surface reports it in its own field so agents render a clean answer. 

110 reasoning, answer = split_reasoning("".join(text_parts)) 

111 content: str | None = answer if answer or not tool_calls else None 

112 

113 total = resp.usage.input_tokens + resp.usage.output_tokens 

114 return CompletionsResponse( 

115 id=response_id, 

116 created=int(time.time()), 

117 model=resp.model, 

118 choices=[ 

119 CompletionsResponseChoice( 

120 index=0, 

121 message=CompletionsResponseMessage( 

122 content=content, 

123 reasoning_content=reasoning or None, 

124 tool_calls=tool_calls or None, 

125 ), 

126 finish_reason=_STOP_REASON_TO_FINISH[resp.stop_reason], 

127 ) 

128 ], 

129 usage=CompletionsUsage( 

130 prompt_tokens=resp.usage.input_tokens, 

131 completion_tokens=resp.usage.output_tokens, 

132 total_tokens=total, 

133 ), 

134 ) 

135 

136 

137class _StreamMapper: 

138 """Per-stream state for the canonical-to-OpenAI chunk converter.""" 

139 

140 def __init__(self) -> None: 

141 self._role_emitted = False 

142 self._tool_index_for_block: dict[int, int] = {} 

143 self._next_tool_index = 0 

144 # Splits lilbee's inline <think> text into its own delta field. Stateful 

145 # because a tag can arrive split across deltas. 

146 self._reasoning = TagParser(show=True) 

147 

148 def block_start(self, event: ContentBlockStart) -> CompletionsStreamDelta | None: 

149 # The first delta must carry role:assistant for OpenAI-SDK accumulation, 

150 # whether the response opens with text or a tool call. 

151 role: Literal["assistant"] | None = None 

152 if not self._role_emitted: 

153 self._role_emitted = True 

154 role = "assistant" 

155 if isinstance(event.block, TextBlock): 

156 return CompletionsStreamDelta(role=role) if role is not None else None 

157 if isinstance(event.block, ToolUseBlock): 

158 tool_index = self._next_tool_index 

159 self._tool_index_for_block[event.index] = tool_index 

160 self._next_tool_index += 1 

161 return CompletionsStreamDelta( 

162 role=role, 

163 tool_calls=[_tool_call_open(tool_index, event.block.id, event.block.name)], 

164 ) 

165 return None 

166 

167 def block_delta(self, event: ContentBlockDelta) -> CompletionsStreamDelta | None: 

168 if isinstance(event.delta, TextDelta): 

169 return self._text_delta(self._reasoning.feed(event.delta.text)) 

170 if isinstance(event.delta, ToolUseDelta): 

171 # A delta for a block we never saw start is a provider quirk, not a 

172 # server fault; a bare subscript turned it into a KeyError that 

173 # surfaced as a stream-level internal error. 

174 tool_index = self._tool_index_for_block.get(event.index) 

175 if tool_index is None: 

176 return None 

177 return CompletionsStreamDelta( 

178 tool_calls=[_tool_call_args(tool_index, event.delta.partial_json)], 

179 ) 

180 return None 

181 

182 def block_stop(self) -> CompletionsStreamDelta | None: 

183 """Emit whatever the reasoning splitter still holds (a partial or unclosed tag).""" 

184 remaining = self._reasoning.flush() 

185 return self._text_delta([remaining] if remaining else []) 

186 

187 def _text_delta(self, tokens: list[StreamToken]) -> CompletionsStreamDelta | None: 

188 """One delta carrying the reasoning and answer text split out of *tokens*.""" 

189 reasoning = "".join(t.content for t in tokens if t.is_reasoning) 

190 answer = "".join(t.content for t in tokens if not t.is_reasoning) 

191 if not reasoning and not answer: 

192 return None 

193 return CompletionsStreamDelta( 

194 content=answer or None, 

195 reasoning_content=reasoning or None, 

196 ) 

197 

198 

199def _tool_call_open(index: int, call_id: str, name: str) -> CompletionsStreamToolCall: 

200 return CompletionsStreamToolCall( 

201 index=index, 

202 id=call_id, 

203 type="function", 

204 function=CompletionsStreamToolCallFunction(name=name, arguments=""), 

205 ) 

206 

207 

208def _tool_call_args(index: int, partial_json: str) -> CompletionsStreamToolCall: 

209 return CompletionsStreamToolCall( 

210 index=index, 

211 function=CompletionsStreamToolCallFunction(arguments=partial_json), 

212 ) 

213 

214 

215def _finish_reason_for(event: MessageDelta) -> FinishReason: 

216 if event.stop_reason is None: 

217 return FinishReason.STOP 

218 return _STOP_REASON_TO_FINISH[event.stop_reason] 

219 

220 

221async def canonical_stream_to_completions_chunks( 

222 events: AsyncIterator[CanonicalStreamEvent], 

223 *, 

224 model: str, 

225 response_id: str, 

226 include_usage: bool = False, 

227) -> AsyncIterator[CompletionsStreamChunk]: 

228 """Turn canonical stream events into ``CompletionsStreamChunk`` instances. 

229 

230 The trailing usage-only chunk is emitted only when *include_usage* is set, 

231 matching OpenAI's ``stream_options.include_usage`` contract. 

232 """ 

233 mapper = _StreamMapper() 

234 # One timestamp for the whole completion. OpenAI holds created constant 

235 # across a stream; recomputing it per chunk reported several creation times 

236 # for one completion, and clients order or dedupe on it. 

237 created = int(time.time()) 

238 async for event in events: 

239 for chunk in _chunks_for_event( 

240 event, 

241 mapper, 

242 model=model, 

243 response_id=response_id, 

244 include_usage=include_usage, 

245 created=created, 

246 ): 

247 yield chunk 

248 

249 

250def _chunks_for_event( 

251 event: CanonicalStreamEvent, 

252 mapper: _StreamMapper, 

253 *, 

254 model: str, 

255 response_id: str, 

256 include_usage: bool, 

257 created: int, 

258) -> list[CompletionsStreamChunk]: 

259 """The OpenAI chunks one canonical event translates to; empty for a no-op event.""" 

260 if isinstance(event, ContentBlockStart): 

261 return _maybe_chunk(model, response_id, mapper.block_start(event), created=created) 

262 if isinstance(event, ContentBlockDelta): 

263 return _maybe_chunk(model, response_id, mapper.block_delta(event), created=created) 

264 if isinstance(event, ContentBlockStop): 

265 # Closing a text block flushes any text the reasoning splitter still 

266 # buffers (an unclosed <think>, or a tag that never completed). 

267 return _maybe_chunk(model, response_id, mapper.block_stop(), created=created) 

268 if isinstance(event, MessageDelta): 

269 return _message_delta_chunks( 

270 event, 

271 model=model, 

272 response_id=response_id, 

273 include_usage=include_usage, 

274 created=created, 

275 ) 

276 if isinstance(event, MessageStart | MessageStop): 

277 # OpenAI's wire format has no equivalent: MessageStart carries metadata 

278 # already encoded in the chunk header, and MessageStop is replaced by the 

279 # final chunk's finish_reason. 

280 return [] 

281 # Unreachable: the branches above exhaust CanonicalStreamEvent. This makes a new 

282 # event type a type error rather than a silently dropped frame. 

283 assert_never(event) # pragma: no cover 

284 

285 

286def _message_delta_chunks( 

287 event: MessageDelta, *, model: str, response_id: str, include_usage: bool, created: int 

288) -> list[CompletionsStreamChunk]: 

289 """The finish chunk, plus the usage-only chunk when the client asked for it.""" 

290 chunks = [ 

291 _chunk( 

292 model, 

293 response_id, 

294 CompletionsStreamDelta(), 

295 finish_reason=_finish_reason_for(event), 

296 created=created, 

297 ) 

298 ] 

299 if include_usage: 

300 # OpenAI's contract sends the usage-only chunk unconditionally when 

301 # include_usage is set; a client blocking on it must not hang because the 

302 # provider streamed no usage frame. 

303 usage = event.usage or CanonicalUsage(input_tokens=0, output_tokens=0) 

304 chunks.append(_usage_chunk(model, response_id, usage, created=created)) 

305 return chunks 

306 

307 

308def _maybe_chunk( 

309 model: str, response_id: str, delta: CompletionsStreamDelta | None, *, created: int 

310) -> list[CompletionsStreamChunk]: 

311 """Wrap a delta in a chunk, or nothing when the event produced no delta.""" 

312 return [] if delta is None else [_chunk(model, response_id, delta, created=created)] 

313 

314 

315def _chunk( 

316 model: str, 

317 response_id: str, 

318 delta: CompletionsStreamDelta, 

319 *, 

320 finish_reason: FinishReason | None = None, 

321 created: int, 

322) -> CompletionsStreamChunk: 

323 return CompletionsStreamChunk( 

324 id=response_id, 

325 created=created, 

326 model=model, 

327 choices=[CompletionsStreamChoice(index=0, delta=delta, finish_reason=finish_reason)], 

328 ) 

329 

330 

331def _usage_chunk( 

332 model: str, response_id: str, usage: CanonicalUsage, *, created: int 

333) -> CompletionsStreamChunk: 

334 """Final include_usage chunk: empty choices, populated usage totals.""" 

335 total = usage.input_tokens + usage.output_tokens 

336 return CompletionsStreamChunk( 

337 id=response_id, 

338 created=created, 

339 model=model, 

340 choices=[], 

341 usage=CompletionsUsage( 

342 prompt_tokens=usage.input_tokens, 

343 completion_tokens=usage.output_tokens, 

344 total_tokens=total, 

345 ), 

346 ) 

347 

348 

349def _system_text(msg: CompletionsMessage) -> str: 

350 """Flatten a system message to text, rejecting image parts. 

351 

352 The same image part is a 400 in a user message, so silently dropping it 

353 here answered as though the request had been honoured. 

354 """ 

355 if isinstance(msg.content, str): 

356 return msg.content 

357 if isinstance(msg.content, list): 

358 if any(isinstance(part, CompletionsImageContent) for part in msg.content): 

359 raise ValueError(_IMAGE_CONTENT_UNSUPPORTED) 

360 return "".join( 

361 part.text for part in msg.content if isinstance(part, CompletionsTextContent) 

362 ) 

363 return "" 

364 

365 

366def _message_from_request(msg: CompletionsMessage) -> CanonicalMessage: 

367 role = msg.role 

368 if role == "system": 

369 raise ValueError("system messages should be extracted by the caller") 

370 if role == "tool": 

371 if not msg.tool_call_id: 

372 # Substituting "" produced a tool result no tool call can pair with, 

373 # which the provider sees as a result for a call it never made. 

374 raise ValueError(_TOOL_CALL_ID_REQUIRED) 

375 return CanonicalMessage( 

376 role="tool", 

377 content=[ 

378 ToolResultBlock( 

379 tool_use_id=msg.tool_call_id, 

380 content=_tool_result_content(msg.content), 

381 ) 

382 ], 

383 ) 

384 

385 blocks: list[ContentBlock] = list(_content_blocks(msg.content)) 

386 for call in msg.tool_calls or []: 

387 blocks.append( 

388 ToolUseBlock( 

389 id=call.id, 

390 name=call.function.name, 

391 input=parse_tool_arguments(call.function.arguments), 

392 ) 

393 ) 

394 return CanonicalMessage(role=role, content=blocks) 

395 

396 

397def _content_blocks(content: str | list | None) -> list[ContentBlock]: 

398 if content is None or content == "": 

399 return [] 

400 if isinstance(content, str): 

401 return [TextBlock(text=content)] 

402 blocks: list[ContentBlock] = [] 

403 for part in content: 

404 if isinstance(part, CompletionsTextContent): 

405 blocks.append(TextBlock(text=part.text)) 

406 elif isinstance(part, CompletionsImageContent): 

407 raise ValueError(_IMAGE_CONTENT_UNSUPPORTED) 

408 return blocks 

409 

410 

411def _tool_result_content(content: str | list | None) -> list[ContentBlock]: 

412 if isinstance(content, str): 

413 return [TextBlock(text=content)] 

414 if isinstance(content, list): 

415 return _content_blocks(content) 

416 return [TextBlock(text="" if content is None else str(content))] 

417 

418 

419def _response_tool_call(block: ToolUseBlock) -> CompletionsResponseToolCall: 

420 return CompletionsResponseToolCall( 

421 id=block.id, 

422 function=CompletionsResponseToolCallFunction( 

423 name=block.name, arguments=json.dumps(block.input) 

424 ), 

425 ) 

426 

427 

428def _tools_from_request(tools: list[CompletionsTool] | None) -> list[CanonicalTool] | None: 

429 if not tools: 

430 return None 

431 return [ 

432 CanonicalTool( 

433 name=tool.function.name, 

434 description=tool.function.description or "", 

435 input_schema=tool.function.parameters, 

436 ) 

437 for tool in tools 

438 ] 

439 

440 

441def _tool_choice_from_request( 

442 choice: ToolChoiceMode | CompletionsNamedToolChoice | None, 

443) -> CanonicalToolChoice | None: 

444 if choice is None: 

445 return None 

446 if isinstance(choice, ToolChoiceMode): 

447 return CanonicalToolChoice(mode=_TOOL_CHOICE_MODES[choice]) 

448 return CanonicalToolChoice(mode="tool", tool_name=choice.function.name) 

449 

450 

451def _stop_from_request(stop: str | list[str] | None) -> list[str] | None: 

452 if stop is None: 

453 return None 

454 if isinstance(stop, str): 

455 return [stop] 

456 return list(stop)