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

192 statements  

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

1"""Translation between Anthropic Messages models and the canonical types.""" 

2 

3from __future__ import annotations 

4 

5import json 

6from collections.abc import AsyncIterator 

7from enum import StrEnum 

8from typing import Any, Literal 

9 

10from lilbee.core.config.enums import ReasoningMode 

11from lilbee.retrieval.reasoning import ( 

12 PseudoThinkingNormalizer, 

13 StreamToken, 

14 TagParser, 

15 normalize_pseudo_thinking, 

16 split_reasoning, 

17) 

18from lilbee.server.anthropic_api.models import ( 

19 _THINKING_DISABLED, 

20 AnthropicEventType, 

21 AnthropicMessage, 

22 AnthropicThinking, 

23 AnthropicTool, 

24 AnthropicToolChoice, 

25 AnthropicUsage, 

26 ContentBlockParam, 

27 ImageBlockParam, 

28 MessagesRequest, 

29 MessagesResponse, 

30 SystemTextBlock, 

31 TextBlockParam, 

32 ToolResultBlockParam, 

33 ToolUseBlockParam, 

34 UnknownBlockParam, 

35) 

36from lilbee.server.chat_dispatch.canonical import ( 

37 CanonicalChatRequest, 

38 CanonicalMessage, 

39 CanonicalResponse, 

40 CanonicalStreamEvent, 

41 CanonicalTool, 

42 CanonicalToolChoice, 

43 CanonicalUsage, 

44 ContentBlock, 

45 ContentBlockDelta, 

46 ContentBlockStart, 

47 ContentBlockStop, 

48 MessageDelta, 

49 MessageStart, 

50 MessageStop, 

51 StopReason, 

52 TextBlock, 

53 TextDelta, 

54 ToolResultBlock, 

55 ToolUseBlock, 

56) 

57 

58_IMAGE_CONTENT_UNSUPPORTED = ( 

59 "Image content is not supported by /v1/messages yet. Send a text-only request." 

60) 

61_TOOL_CHOICE_NAME_REQUIRED = 'tool_choice type "tool" requires a name.' 

62 

63 

64class _BlockKind(StrEnum): 

65 """Kind of the mapper's open output block.""" 

66 

67 THINKING = "thinking" 

68 TEXT = "text" 

69 TOOL = "tool" 

70 

71 

72_ANTHROPIC_CHOICE_MODES: dict[str, str] = { 

73 "auto": "auto", 

74 "any": "any", 

75 "none": "none", 

76} 

77 

78 

79def resolve_reasoning_mode( 

80 thinking: AnthropicThinking | None, *, default: ReasoningMode 

81) -> ReasoningMode: 

82 """Pick the reasoning mode for one call from the request and the setting. 

83 

84 Thinking is opt-in per request, as on the Anthropic API: a body with no 

85 ``thinking`` gets none, whatever the setting presents it as. The setting 

86 says how to present thinking a request asked for, and ``off`` refuses it 

87 outright, so a request can only tighten. 

88 """ 

89 if default is ReasoningMode.OFF: 

90 return ReasoningMode.OFF 

91 if thinking is None or thinking.type == _THINKING_DISABLED: 

92 return ReasoningMode.OFF 

93 return default 

94 

95 

96def messages_to_canonical_request( 

97 request: MessagesRequest, *, mode: ReasoningMode = ReasoningMode.SEPARATE 

98) -> CanonicalChatRequest: 

99 """Translate a validated ``MessagesRequest`` to the canonical request.""" 

100 messages: list[CanonicalMessage] = [] 

101 for msg in request.messages: 

102 messages.extend(_canonical_messages_for(msg)) 

103 return CanonicalChatRequest( 

104 model=request.model, 

105 messages=messages, 

106 system=_system_text(request.system), 

107 tools=_tools_from_request(request.tools), 

108 tool_choice=_tool_choice_from_request(request.tool_choice), 

109 temperature=request.temperature, 

110 top_p=request.top_p, 

111 top_k=request.top_k, 

112 max_tokens=request.max_tokens, 

113 stop=list(request.stop_sequences) if request.stop_sequences else None, 

114 stream=request.stream, 

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

116 # presentation, so the template default stands. 

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

118 ) 

119 

120 

121def _system_text(system: str | list[SystemTextBlock] | None) -> str | None: 

122 if system is None: 

123 return None 

124 if isinstance(system, str): 

125 return system or None 

126 joined = "\n\n".join(block.text for block in system) 

127 return joined or None 

128 

129 

130def _canonical_messages_for(msg: AnthropicMessage) -> list[CanonicalMessage]: 

131 """Fan one Anthropic message out to canonical messages. 

132 

133 Tool results become their own ``role: "tool"`` messages, emitted before 

134 the user's text so the provider sees results adjacent to the calls they 

135 answer. Unknown blocks (replayed thinking) are dropped. A mid-conversation 

136 ``system`` message becomes a system-reminder user turn -- Anthropic's own 

137 documented degradation for models without the operator channel, and it 

138 keeps the canonical layer's role set unchanged. 

139 """ 

140 if msg.role == "system": 

141 text = _message_text(msg) 

142 if not text: 

143 return [] 

144 return [ 

145 CanonicalMessage.from_string( 

146 role="user", text=f"<system-reminder>\n{text}\n</system-reminder>" 

147 ) 

148 ] 

149 if isinstance(msg.content, str): 

150 if not msg.content: 

151 return [] 

152 return [CanonicalMessage.from_string(role=msg.role, text=msg.content)] 

153 return _block_messages(msg.role, msg.content) 

154 

155 

156def _block_messages( 

157 role: Literal["user", "assistant"], content: list[ContentBlockParam] 

158) -> list[CanonicalMessage]: 

159 """Canonical messages for a block-form user or assistant message.""" 

160 tool_messages: list[CanonicalMessage] = [] 

161 blocks: list[ContentBlock] = [] 

162 for block in content: 

163 if isinstance(block, TextBlockParam): 

164 blocks.append(TextBlock(text=block.text)) 

165 elif isinstance(block, ToolUseBlockParam): 

166 blocks.append(ToolUseBlock(id=block.id, name=block.name, input=block.input)) 

167 elif isinstance(block, ToolResultBlockParam): 

168 tool_messages.append(_tool_result_message(block)) 

169 elif isinstance(block, ImageBlockParam): 

170 raise ValueError(_IMAGE_CONTENT_UNSUPPORTED) 

171 elif isinstance(block, UnknownBlockParam): 

172 continue 

173 

174 out = tool_messages 

175 if blocks: 

176 out = [*tool_messages, CanonicalMessage(role=role, content=blocks)] 

177 return out 

178 

179 

180def _tool_result_message(block: ToolResultBlockParam) -> CanonicalMessage: 

181 return CanonicalMessage( 

182 role="tool", 

183 content=[ 

184 ToolResultBlock( 

185 tool_use_id=block.tool_use_id, 

186 content=_tool_result_content(block), 

187 is_error=block.is_error, 

188 ) 

189 ], 

190 ) 

191 

192 

193def _message_text(msg: AnthropicMessage) -> str: 

194 """The concatenated text of a message, ignoring non-text blocks.""" 

195 if isinstance(msg.content, str): 

196 return msg.content 

197 return "".join(b.text for b in msg.content if isinstance(b, TextBlockParam)) 

198 

199 

200def _tool_result_content(block: ToolResultBlockParam) -> list[ContentBlock]: 

201 if block.content is None: 

202 return [] 

203 if isinstance(block.content, str): 

204 return [TextBlock(text=block.content)] 

205 parts: list[ContentBlock] = [] 

206 for part in block.content: 

207 if isinstance(part, TextBlockParam): 

208 parts.append(TextBlock(text=part.text)) 

209 elif isinstance(part, ImageBlockParam): 

210 raise ValueError(_IMAGE_CONTENT_UNSUPPORTED) 

211 # UnknownBlockParam: dropped 

212 return parts 

213 

214 

215def _tools_from_request(tools: list[AnthropicTool] | None) -> list[CanonicalTool] | None: 

216 if not tools: 

217 return None 

218 return [ 

219 CanonicalTool( 

220 name=tool.name, 

221 description=tool.description or "", 

222 input_schema=tool.input_schema, 

223 ) 

224 for tool in tools 

225 ] 

226 

227 

228def _tool_choice_from_request( 

229 choice: AnthropicToolChoice | None, 

230) -> CanonicalToolChoice | None: 

231 if choice is None: 

232 return None 

233 if choice.type == "tool": 

234 if not choice.name: 

235 raise ValueError(_TOOL_CHOICE_NAME_REQUIRED) 

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

237 mode = _ANTHROPIC_CHOICE_MODES[choice.type] 

238 return CanonicalToolChoice(mode=mode) # type: ignore[arg-type] 

239 

240 

241def canonical_to_messages_response( 

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

243) -> MessagesResponse: 

244 """Translate a canonical chat response to the Anthropic message shape. 

245 

246 lilbee carries a reasoning model's thinking inline as ``<think>...</think>``; 

247 SEPARATE reports it as a leading ``thinking`` block so clients render a 

248 clean answer. INLINE folds it into the answer text with the markers 

249 stripped, for clients that never render thinking blocks. OFF drops it: the 

250 caller asked for no thinking, and a template that ignores the request still 

251 thinks, so the block would contradict the answer the caller asked for. OFF 

252 also drops a reply-initial pseudo-thinking block a model emits as plain text. 

253 """ 

254 text = "".join(b.text for b in resp.content if isinstance(b, TextBlock)) 

255 if mode is ReasoningMode.OFF: 

256 text = normalize_pseudo_thinking(text) 

257 reasoning, answer = split_reasoning(text) 

258 if mode is ReasoningMode.INLINE and reasoning: 

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

260 if mode is not ReasoningMode.SEPARATE: 

261 reasoning = "" 

262 content: list[dict[str, Any]] = [] 

263 if reasoning: 

264 content.append({"type": "thinking", "thinking": reasoning}) 

265 tool_uses = [b for b in resp.content if isinstance(b, ToolUseBlock)] 

266 if answer or not (reasoning or tool_uses): 

267 content.append({"type": "text", "text": answer}) 

268 content.extend( 

269 {"type": "tool_use", "id": b.id, "name": b.name, "input": b.input} for b in tool_uses 

270 ) 

271 return MessagesResponse( 

272 id=response_id, 

273 model=resp.model, 

274 content=content, 

275 stop_reason=str(resp.stop_reason), 

276 usage=AnthropicUsage( 

277 input_tokens=resp.usage.input_tokens, 

278 output_tokens=resp.usage.output_tokens, 

279 ), 

280 ) 

281 

282 

283class _AnthropicStreamMapper: 

284 """Per-stream state for the canonical-to-Anthropic event converter. 

285 

286 lilbee streams reasoning inline as ``<think>`` text, and Anthropic's wire 

287 format wants thinking and answer text in separate indexed blocks; the 

288 mapper re-blocks the stream, closing the open block whenever the token 

289 kind (thinking / text / tool_use) changes. 

290 

291 INLINE routes reasoning into the text block instead, and OFF drops it: a 

292 parser built with ``show=False`` reports reasoning tokens with empty 

293 content, which never opens a block or emits a delta. OFF also rewrites a 

294 reply-initial pseudo-thinking tag to the ``<think>`` tags before parsing, 

295 so a planning block a model emits as plain text is dropped too. 

296 """ 

297 

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

299 self._reasoning = TagParser(show=mode is not ReasoningMode.OFF) 

300 self._pseudo: PseudoThinkingNormalizer | None = ( 

301 PseudoThinkingNormalizer() if mode is ReasoningMode.OFF else None 

302 ) 

303 self._inline = mode is ReasoningMode.INLINE 

304 self._next_index = 0 

305 self._open: _BlockKind | None = None 

306 

307 def _close_open(self) -> list[tuple[AnthropicEventType, dict[str, Any]]]: 

308 if self._open is None: 

309 return [] 

310 index = self._next_index - 1 

311 self._open = None 

312 return [ 

313 (AnthropicEventType.CONTENT_BLOCK_STOP, {"type": "content_block_stop", "index": index}) 

314 ] 

315 

316 def _ensure_block(self, kind: _BlockKind) -> list[tuple[AnthropicEventType, dict[str, Any]]]: 

317 if self._open == kind: 

318 return [] 

319 events = self._close_open() 

320 shell = ( 

321 {"type": "thinking", "thinking": ""} 

322 if kind is _BlockKind.THINKING 

323 else {"type": "text", "text": ""} 

324 ) 

325 events.append( 

326 ( 

327 AnthropicEventType.CONTENT_BLOCK_START, 

328 { 

329 "type": "content_block_start", 

330 "index": self._next_index, 

331 "content_block": shell, 

332 }, 

333 ) 

334 ) 

335 self._open = kind 

336 self._next_index += 1 

337 return events 

338 

339 def _text_events( 

340 self, tokens: list[StreamToken] 

341 ) -> list[tuple[AnthropicEventType, dict[str, Any]]]: 

342 events: list[tuple[AnthropicEventType, dict[str, Any]]] = [] 

343 for token in tokens: 

344 if not token.content: 

345 continue 

346 thinking = token.is_reasoning and not self._inline 

347 kind = _BlockKind.THINKING if thinking else _BlockKind.TEXT 

348 events.extend(self._ensure_block(kind)) 

349 index = self._next_index - 1 

350 delta = ( 

351 {"type": "thinking_delta", "thinking": token.content} 

352 if thinking 

353 else {"type": "text_delta", "text": token.content} 

354 ) 

355 events.append( 

356 ( 

357 AnthropicEventType.CONTENT_BLOCK_DELTA, 

358 {"type": "content_block_delta", "index": index, "delta": delta}, 

359 ) 

360 ) 

361 return events 

362 

363 def block_start( 

364 self, event: ContentBlockStart 

365 ) -> list[tuple[AnthropicEventType, dict[str, Any]]]: 

366 if isinstance(event.block, ToolUseBlock): 

367 events = self._close_open() 

368 index = self._next_index 

369 self._next_index += 1 

370 self._open = _BlockKind.TOOL 

371 events.append( 

372 ( 

373 AnthropicEventType.CONTENT_BLOCK_START, 

374 { 

375 "type": "content_block_start", 

376 "index": index, 

377 "content_block": { 

378 "type": "tool_use", 

379 "id": event.block.id, 

380 "name": event.block.name, 

381 "input": {}, 

382 }, 

383 }, 

384 ) 

385 ) 

386 if event.block.input: 

387 # A provider that announces a whole call up front carries the 

388 # parsed input on the start block; forward it as one delta so 

389 # SDK accumulation still sees arguments. 

390 events.append( 

391 ( 

392 AnthropicEventType.CONTENT_BLOCK_DELTA, 

393 { 

394 "type": "content_block_delta", 

395 "index": index, 

396 "delta": { 

397 "type": "input_json_delta", 

398 "partial_json": json.dumps(event.block.input), 

399 }, 

400 }, 

401 ) 

402 ) 

403 return events 

404 # Text blocks open lazily on the first delta so an empty block never 

405 # emits a start/stop pair. 

406 return [] 

407 

408 def block_delta( 

409 self, event: ContentBlockDelta 

410 ) -> list[tuple[AnthropicEventType, dict[str, Any]]]: 

411 if isinstance(event.delta, TextDelta): 

412 text = event.delta.text 

413 if self._pseudo is not None: 

414 text = self._pseudo.feed(text) 

415 return self._text_events(self._reasoning.feed(text)) 

416 if self._open is not _BlockKind.TOOL: 

417 # A tool delta for a block that never started is a provider quirk, 

418 # not a stream error; dropping beats crashing the stream. 

419 return [] 

420 return [ 

421 ( 

422 AnthropicEventType.CONTENT_BLOCK_DELTA, 

423 { 

424 "type": "content_block_delta", 

425 "index": self._next_index - 1, 

426 "delta": { 

427 "type": "input_json_delta", 

428 "partial_json": event.delta.partial_json, 

429 }, 

430 }, 

431 ) 

432 ] 

433 

434 def block_stop(self) -> list[tuple[AnthropicEventType, dict[str, Any]]]: 

435 tokens = self._reasoning.feed(self._pseudo.flush()) if self._pseudo is not None else [] 

436 remaining = self._reasoning.flush() 

437 if remaining is not None: 

438 tokens.append(remaining) 

439 events = self._text_events(tokens) 

440 events.extend(self._close_open()) 

441 return events 

442 

443 

444async def canonical_stream_to_anthropic_events( 

445 events: AsyncIterator[CanonicalStreamEvent], 

446 *, 

447 model: str, 

448 response_id: str, 

449 mode: ReasoningMode = ReasoningMode.SEPARATE, 

450) -> AsyncIterator[tuple[AnthropicEventType, dict[str, Any]]]: 

451 """Turn canonical stream events into Anthropic SSE ``(type, payload)`` pairs.""" 

452 mapper = _AnthropicStreamMapper(mode=mode) 

453 yield ( 

454 AnthropicEventType.MESSAGE_START, 

455 { 

456 "type": "message_start", 

457 "message": { 

458 "id": response_id, 

459 "type": "message", 

460 "role": "assistant", 

461 "model": model, 

462 "content": [], 

463 "stop_reason": None, 

464 "stop_sequence": None, 

465 "usage": {"input_tokens": 0, "output_tokens": 0}, 

466 }, 

467 }, 

468 ) 

469 async for event in events: 

470 if isinstance(event, ContentBlockStart): 

471 for out in mapper.block_start(event): 

472 yield out 

473 elif isinstance(event, ContentBlockDelta): 

474 for out in mapper.block_delta(event): 

475 yield out 

476 elif isinstance(event, ContentBlockStop): 

477 for out in mapper.block_stop(): 

478 yield out 

479 elif isinstance(event, MessageDelta): 

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

481 yield ( 

482 AnthropicEventType.MESSAGE_DELTA, 

483 { 

484 "type": "message_delta", 

485 "delta": { 

486 "stop_reason": str(event.stop_reason or StopReason.END_TURN), 

487 "stop_sequence": None, 

488 }, 

489 "usage": { 

490 "input_tokens": usage.input_tokens, 

491 "output_tokens": usage.output_tokens, 

492 }, 

493 }, 

494 ) 

495 elif isinstance(event, MessageStart | MessageStop): 

496 # The Anthropic message_start is emitted eagerly above (it needs no 

497 # canonical data), and message_stop follows the loop so it stays 

498 # last even if the provider never sends one. 

499 continue 

500 yield (AnthropicEventType.MESSAGE_STOP, {"type": "message_stop"})