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

170 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-08 09:20 +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.core.config.enums import ReasoningMode 

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

12from lilbee.server.chat_completions_api.models import ( 

13 CompletionsImageContent, 

14 CompletionsMessage, 

15 CompletionsNamedToolChoice, 

16 CompletionsRequest, 

17 CompletionsResponse, 

18 CompletionsResponseChoice, 

19 CompletionsResponseMessage, 

20 CompletionsResponseToolCall, 

21 CompletionsResponseToolCallFunction, 

22 CompletionsStreamChoice, 

23 CompletionsStreamChunk, 

24 CompletionsStreamDelta, 

25 CompletionsStreamToolCall, 

26 CompletionsStreamToolCallFunction, 

27 CompletionsTextContent, 

28 CompletionsTool, 

29 CompletionsUsage, 

30 FinishReason, 

31 ToolChoiceMode, 

32) 

33from lilbee.server.chat_dispatch.canonical import ( 

34 CanonicalChatRequest, 

35 CanonicalMessage, 

36 CanonicalResponse, 

37 CanonicalStreamEvent, 

38 CanonicalTool, 

39 CanonicalToolChoice, 

40 CanonicalUsage, 

41 ContentBlock, 

42 ContentBlockDelta, 

43 ContentBlockStart, 

44 ContentBlockStop, 

45 MessageDelta, 

46 MessageStart, 

47 MessageStop, 

48 StopReason, 

49 TextBlock, 

50 TextDelta, 

51 ToolResultBlock, 

52 ToolUseBlock, 

53 ToolUseDelta, 

54) 

55from lilbee.server.chat_dispatch.tool_args import parse_tool_arguments 

56 

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

58 ToolChoiceMode.AUTO: "auto", 

59 ToolChoiceMode.NONE: "none", 

60 ToolChoiceMode.REQUIRED: "any", 

61} 

62 

63_STOP_REASON_TO_FINISH: dict[StopReason, FinishReason] = { 

64 StopReason.END_TURN: FinishReason.STOP, 

65 StopReason.MAX_TOKENS: FinishReason.LENGTH, 

66 StopReason.TOOL_USE: FinishReason.TOOL_CALLS, 

67} 

68 

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

70_IMAGE_CONTENT_UNSUPPORTED = ( 

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

72) 

73 

74 

75def completions_to_canonical_request( 

76 request: CompletionsRequest, *, mode: ReasoningMode = ReasoningMode.SEPARATE 

77) -> CanonicalChatRequest: 

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

79 system_parts: list[str] = [] 

80 messages: list[CanonicalMessage] = [] 

81 for msg in request.messages: 

82 if msg.role == "system": 

83 system_parts.append(_system_text(msg)) 

84 continue 

85 messages.append(_message_from_request(msg)) 

86 

87 return CanonicalChatRequest( 

88 model=request.model, 

89 messages=messages, 

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

91 tools=_tools_from_request(request.tools), 

92 tool_choice=_tool_choice_from_request(request.tool_choice), 

93 temperature=request.temperature, 

94 top_p=request.top_p, 

95 top_k=request.top_k, 

96 max_tokens=request.max_tokens, 

97 seed=request.seed, 

98 frequency_penalty=request.frequency_penalty, 

99 presence_penalty=request.presence_penalty, 

100 stop=_stop_from_request(request.stop), 

101 stream=request.stream, 

102 # OFF asks the template to skip thinking; the other modes only change 

103 # presentation, so the template default stands. 

104 think=False if mode is ReasoningMode.OFF else None, 

105 ) 

106 

107 

108def canonical_to_completions_response( 

109 resp: CanonicalResponse, *, response_id: str, mode: ReasoningMode = ReasoningMode.SEPARATE 

110) -> CompletionsResponse: 

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

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

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

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

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

116 # INLINE streams the thinking as ordinary content for clients that never 

117 # render reasoning_content -- with the tag markers stripped, because a 

118 # client that ignores reasoning_content renders raw <think> text literally. 

119 # OFF keeps the split, because a template that ignores enable_thinking 

120 # still thinks. 

121 if mode is ReasoningMode.INLINE: 

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

123 if reasoning: 

124 answer = f"{reasoning}\n\n{answer}" if answer else reasoning 

125 reasoning = "" 

126 else: 

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

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

129 

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

131 return CompletionsResponse( 

132 id=response_id, 

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

134 model=resp.model, 

135 choices=[ 

136 CompletionsResponseChoice( 

137 index=0, 

138 message=CompletionsResponseMessage( 

139 content=content, 

140 reasoning_content=reasoning or None, 

141 tool_calls=tool_calls or None, 

142 ), 

143 finish_reason=_STOP_REASON_TO_FINISH[resp.stop_reason], 

144 ) 

145 ], 

146 usage=CompletionsUsage( 

147 prompt_tokens=resp.usage.input_tokens, 

148 completion_tokens=resp.usage.output_tokens, 

149 total_tokens=total, 

150 ), 

151 ) 

152 

153 

154class _StreamMapper: 

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

156 

157 def __init__(self, *, mode: ReasoningMode = ReasoningMode.SEPARATE) -> None: 

158 self._role_emitted = False 

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

160 self._next_tool_index = 0 

161 # INLINE routes thinking into content instead of reasoning_content, with 

162 # the <think> markers stripped: a client that ignores reasoning_content 

163 # renders raw tags literally, which is exactly what INLINE exists to 

164 # avoid. The same stateful parse handles a tag split across deltas. 

165 self._inline = mode is ReasoningMode.INLINE 

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

167 # because a tag can arrive split across deltas. 

168 self._reasoning = TagParser(show=True) 

169 

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

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

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

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

174 if not self._role_emitted: 

175 self._role_emitted = True 

176 role = "assistant" 

177 if isinstance(event.block, TextBlock): 

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

179 if isinstance(event.block, ToolUseBlock): 

180 tool_index = self._next_tool_index 

181 self._tool_index_for_block[event.index] = tool_index 

182 self._next_tool_index += 1 

183 return CompletionsStreamDelta( 

184 role=role, 

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

186 ) 

187 return None 

188 

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

190 if isinstance(event.delta, TextDelta): 

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

192 if isinstance(event.delta, ToolUseDelta): 

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

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

195 # surfaced as a stream-level internal error. 

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

197 if tool_index is None: 

198 return None 

199 return CompletionsStreamDelta( 

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

201 ) 

202 return None 

203 

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

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

206 remaining = self._reasoning.flush() 

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

208 

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

210 """One delta carrying the parsed text: split fields, or tag-free content.""" 

211 if self._inline: 

212 # Arrival order preserved; the parser already dropped the markers. 

213 text = "".join(t.content for t in tokens) 

214 return CompletionsStreamDelta(content=text) if text else None 

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

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

217 if not reasoning and not answer: 

218 return None 

219 return CompletionsStreamDelta( 

220 content=answer or None, 

221 reasoning_content=reasoning or None, 

222 ) 

223 

224 

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

226 return CompletionsStreamToolCall( 

227 index=index, 

228 id=call_id, 

229 type="function", 

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

231 ) 

232 

233 

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

235 return CompletionsStreamToolCall( 

236 index=index, 

237 function=CompletionsStreamToolCallFunction(arguments=partial_json), 

238 ) 

239 

240 

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

242 if event.stop_reason is None: 

243 return FinishReason.STOP 

244 return _STOP_REASON_TO_FINISH[event.stop_reason] 

245 

246 

247async def canonical_stream_to_completions_chunks( 

248 events: AsyncIterator[CanonicalStreamEvent], 

249 *, 

250 model: str, 

251 response_id: str, 

252 include_usage: bool = False, 

253 mode: ReasoningMode = ReasoningMode.SEPARATE, 

254) -> AsyncIterator[CompletionsStreamChunk]: 

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

256 

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

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

259 """ 

260 mapper = _StreamMapper(mode=mode) 

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

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

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

264 created = int(time.time()) 

265 async for event in events: 

266 for chunk in _chunks_for_event( 

267 event, 

268 mapper, 

269 model=model, 

270 response_id=response_id, 

271 include_usage=include_usage, 

272 created=created, 

273 ): 

274 yield chunk 

275 

276 

277def _chunks_for_event( 

278 event: CanonicalStreamEvent, 

279 mapper: _StreamMapper, 

280 *, 

281 model: str, 

282 response_id: str, 

283 include_usage: bool, 

284 created: int, 

285) -> list[CompletionsStreamChunk]: 

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

287 if isinstance(event, ContentBlockStart): 

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

289 if isinstance(event, ContentBlockDelta): 

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

291 if isinstance(event, ContentBlockStop): 

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

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

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

295 if isinstance(event, MessageDelta): 

296 return _message_delta_chunks( 

297 event, 

298 model=model, 

299 response_id=response_id, 

300 include_usage=include_usage, 

301 created=created, 

302 ) 

303 if isinstance(event, MessageStart | MessageStop): 

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

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

306 # final chunk's finish_reason. 

307 return [] 

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

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

310 assert_never(event) # pragma: no cover 

311 

312 

313def _message_delta_chunks( 

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

315) -> list[CompletionsStreamChunk]: 

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

317 chunks = [ 

318 _chunk( 

319 model, 

320 response_id, 

321 CompletionsStreamDelta(), 

322 finish_reason=_finish_reason_for(event), 

323 created=created, 

324 ) 

325 ] 

326 if include_usage: 

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

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

329 # provider streamed no usage frame. 

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

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

332 return chunks 

333 

334 

335def _maybe_chunk( 

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

337) -> list[CompletionsStreamChunk]: 

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

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

340 

341 

342def _chunk( 

343 model: str, 

344 response_id: str, 

345 delta: CompletionsStreamDelta, 

346 *, 

347 finish_reason: FinishReason | None = None, 

348 created: int, 

349) -> CompletionsStreamChunk: 

350 return CompletionsStreamChunk( 

351 id=response_id, 

352 created=created, 

353 model=model, 

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

355 ) 

356 

357 

358def _usage_chunk( 

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

360) -> CompletionsStreamChunk: 

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

362 total = usage.input_tokens + usage.output_tokens 

363 return CompletionsStreamChunk( 

364 id=response_id, 

365 created=created, 

366 model=model, 

367 choices=[], 

368 usage=CompletionsUsage( 

369 prompt_tokens=usage.input_tokens, 

370 completion_tokens=usage.output_tokens, 

371 total_tokens=total, 

372 ), 

373 ) 

374 

375 

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

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

378 

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

380 here answered as though the request had been honoured. 

381 """ 

382 if isinstance(msg.content, str): 

383 return msg.content 

384 if isinstance(msg.content, list): 

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

386 raise ValueError(_IMAGE_CONTENT_UNSUPPORTED) 

387 return "".join( 

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

389 ) 

390 return "" 

391 

392 

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

394 role = msg.role 

395 if role == "system": 

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

397 if role == "tool": 

398 if not msg.tool_call_id: 

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

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

401 raise ValueError(_TOOL_CALL_ID_REQUIRED) 

402 return CanonicalMessage( 

403 role="tool", 

404 content=[ 

405 ToolResultBlock( 

406 tool_use_id=msg.tool_call_id, 

407 content=_tool_result_content(msg.content), 

408 ) 

409 ], 

410 ) 

411 

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

413 for call in msg.tool_calls or []: 

414 blocks.append( 

415 ToolUseBlock( 

416 id=call.id, 

417 name=call.function.name, 

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

419 ) 

420 ) 

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

422 

423 

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

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

426 return [] 

427 if isinstance(content, str): 

428 return [TextBlock(text=content)] 

429 blocks: list[ContentBlock] = [] 

430 for part in content: 

431 if isinstance(part, CompletionsTextContent): 

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

433 elif isinstance(part, CompletionsImageContent): 

434 raise ValueError(_IMAGE_CONTENT_UNSUPPORTED) 

435 return blocks 

436 

437 

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

439 if isinstance(content, str): 

440 return [TextBlock(text=content)] 

441 if isinstance(content, list): 

442 return _content_blocks(content) 

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

444 

445 

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

447 return CompletionsResponseToolCall( 

448 id=block.id, 

449 function=CompletionsResponseToolCallFunction( 

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

451 ), 

452 ) 

453 

454 

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

456 if not tools: 

457 return None 

458 return [ 

459 CanonicalTool( 

460 name=tool.function.name, 

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

462 input_schema=tool.function.parameters, 

463 ) 

464 for tool in tools 

465 ] 

466 

467 

468def _tool_choice_from_request( 

469 choice: ToolChoiceMode | CompletionsNamedToolChoice | None, 

470) -> CanonicalToolChoice | None: 

471 if choice is None: 

472 return None 

473 if isinstance(choice, ToolChoiceMode): 

474 return CanonicalToolChoice(mode=_TOOL_CHOICE_MODES[choice]) 

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

476 

477 

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

479 if stop is None: 

480 return None 

481 if isinstance(stop, str): 

482 return [stop] 

483 return list(stop)