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

133 statements  

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

1"""Protocol-neutral chat request, response, stream-event, and token-count types.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass 

6from enum import StrEnum 

7from typing import Any, Literal 

8 

9 

10class StopReason(StrEnum): 

11 """Why a canonical chat response ended.""" 

12 

13 END_TURN = "end_turn" 

14 MAX_TOKENS = "max_tokens" 

15 TOOL_USE = "tool_use" 

16 

17 

18@dataclass(frozen=True) 

19class TextBlock: 

20 """Plain-text content block.""" 

21 

22 text: str 

23 type: Literal["text"] = "text" 

24 

25 

26@dataclass(frozen=True) 

27class ToolUseBlock: 

28 """Assistant-emitted tool invocation with parsed JSON arguments.""" 

29 

30 id: str 

31 name: str 

32 input: dict[str, Any] 

33 type: Literal["tool_use"] = "tool_use" 

34 

35 

36@dataclass(frozen=True) 

37class ToolResultBlock: 

38 """Caller-supplied tool result paired to a prior ToolUseBlock by id.""" 

39 

40 tool_use_id: str 

41 content: list[ContentBlock] 

42 is_error: bool = False 

43 type: Literal["tool_result"] = "tool_result" 

44 

45 

46ContentBlock = TextBlock | ToolUseBlock | ToolResultBlock 

47 

48 

49@dataclass(frozen=True) 

50class CanonicalMessage: 

51 """One chat turn; content is always a typed-block list.""" 

52 

53 role: Literal["user", "assistant", "tool"] 

54 content: list[ContentBlock] 

55 

56 @classmethod 

57 def from_string( 

58 cls, 

59 *, 

60 role: Literal["user", "assistant", "tool"], 

61 text: str, 

62 ) -> CanonicalMessage: 

63 """Build a single-text-block message from a raw string.""" 

64 return cls(role=role, content=[TextBlock(text=text)]) 

65 

66 

67@dataclass(frozen=True) 

68class CanonicalTool: 

69 """Tool definition (JSON-Schema input shape).""" 

70 

71 name: str 

72 description: str 

73 input_schema: dict[str, Any] 

74 

75 

76@dataclass(frozen=True) 

77class CanonicalToolChoice: 

78 """Tool-choice mode; ``tool_name`` is required only when ``mode == "tool"``.""" 

79 

80 mode: Literal["auto", "any", "none", "tool"] 

81 tool_name: str | None = None 

82 

83 def __post_init__(self) -> None: 

84 # Without this the None reaches the provider as 

85 # {"function": {"name": None}}, a malformed tool choice rather than a 

86 # rejected request. 

87 if self.mode == "tool" and not self.tool_name: 

88 raise ValueError('CanonicalToolChoice(mode="tool") requires a tool_name') 

89 

90 

91@dataclass(frozen=True) 

92class CanonicalChatRequest: 

93 """Canonical chat request consumed by the dispatch layer.""" 

94 

95 model: str 

96 messages: list[CanonicalMessage] 

97 system: str | None = None 

98 tools: list[CanonicalTool] | None = None 

99 tool_choice: CanonicalToolChoice | None = None 

100 temperature: float | None = None 

101 top_p: float | None = None 

102 top_k: int | None = None 

103 max_tokens: int | None = None 

104 seed: int | None = None 

105 frequency_penalty: float | None = None 

106 presence_penalty: float | None = None 

107 stop: list[str] | None = None 

108 stream: bool = False 

109 # Thinking-template control (chat_template_kwargs.enable_thinking); None 

110 # leaves the model's template default in place. 

111 think: bool | None = None 

112 # Image parts the wire request carried and the translator dropped. 

113 dropped_images: int = 0 

114 

115 

116IMAGES_ONLY_MESSAGE = ( 

117 "The request holds only image content, and lilbee cannot pass images to the model. " 

118 "Send text with the request." 

119) 

120 

121 

122# Tells the client how many image parts the server dropped from its request. 

123IMAGES_DROPPED_HEADER = "X-Lilbee-Images-Dropped" 

124 

125 

126def images_dropped_headers(req: CanonicalChatRequest) -> dict[str, str]: 

127 """Response headers that report the images dropped from *req*, or none.""" 

128 if not req.dropped_images: 

129 return {} 

130 return {IMAGES_DROPPED_HEADER: str(req.dropped_images)} 

131 

132 

133def _blocks_carry_content(blocks: list[ContentBlock]) -> bool: 

134 for block in blocks: 

135 if isinstance(block, TextBlock) and block.text: 

136 return True 

137 if isinstance(block, ToolUseBlock): 

138 return True 

139 if isinstance(block, ToolResultBlock) and _blocks_carry_content(block.content): 

140 return True 

141 return False 

142 

143 

144def require_content_beside_images(req: CanonicalChatRequest) -> CanonicalChatRequest: 

145 """Return *req*, or raise ``ValueError`` when dropping its images left nothing to read.""" 

146 if req.dropped_images and not req.system: 

147 carries_content = any(_blocks_carry_content(m.content) for m in req.messages) 

148 if not carries_content: 

149 raise ValueError(IMAGES_ONLY_MESSAGE) 

150 return req 

151 

152 

153@dataclass(frozen=True) 

154class CanonicalUsage: 

155 """Token-count summary for one chat response.""" 

156 

157 input_tokens: int 

158 output_tokens: int 

159 # Prompt tokens served from the engine's cache, a subset of input_tokens. 

160 cached_input_tokens: int = 0 

161 

162 @property 

163 def uncached_input_tokens(self) -> int: 

164 """Prompt tokens the engine did not serve from its cache.""" 

165 return self.input_tokens - self.cached_input_tokens 

166 

167 

168class TokenCountAccuracy(StrEnum): 

169 """Whether a prompt token count was measured on the backend or estimated.""" 

170 

171 EXACT = "exact" 

172 ESTIMATED = "estimated" 

173 

174 

175@dataclass(frozen=True) 

176class PromptTokenCount: 

177 """Tokens a prompt costs, and how the number was arrived at.""" 

178 

179 tokens: int 

180 accuracy: TokenCountAccuracy 

181 

182 

183@dataclass(frozen=True) 

184class CanonicalResponse: 

185 """Canonical non-streaming chat response.""" 

186 

187 id: str 

188 model: str 

189 content: list[ContentBlock] 

190 stop_reason: StopReason 

191 usage: CanonicalUsage 

192 

193 

194@dataclass(frozen=True) 

195class MessageStart: 

196 """Stream prelude carrying the message id and model ref.""" 

197 

198 id: str 

199 model: str 

200 

201 

202@dataclass(frozen=True) 

203class ContentBlockStart: 

204 """Opens a fresh content block at ``index`` with an initial shell.""" 

205 

206 index: int 

207 block: ContentBlock 

208 

209 

210@dataclass(frozen=True) 

211class TextDelta: 

212 """One text-token delta within an open text block.""" 

213 

214 text: str 

215 

216 

217@dataclass(frozen=True) 

218class ToolUseDelta: 

219 """Accumulating JSON fragment within an open tool-use block.""" 

220 

221 partial_json: str 

222 

223 

224@dataclass(frozen=True) 

225class ContentBlockDelta: 

226 """Delta payload routed to the content block at ``index``.""" 

227 

228 index: int 

229 delta: TextDelta | ToolUseDelta 

230 

231 

232@dataclass(frozen=True) 

233class ContentBlockStop: 

234 """Closes the content block at ``index``.""" 

235 

236 index: int 

237 

238 

239@dataclass(frozen=True) 

240class MessageDelta: 

241 """Trailing metadata; either field may carry a value, never both required.""" 

242 

243 stop_reason: StopReason | None = None 

244 usage: CanonicalUsage | None = None 

245 

246 

247@dataclass(frozen=True) 

248class MessageStop: 

249 """Stream terminator.""" 

250 

251 

252CanonicalStreamEvent = ( 

253 MessageStart 

254 | ContentBlockStart 

255 | ContentBlockDelta 

256 | ContentBlockStop 

257 | MessageDelta 

258 | MessageStop 

259)