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

137 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-12 00:44 +0000

1"""Pydantic models for the OpenAI Chat Completions wire shapes.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6from enum import StrEnum 

7from typing import Annotated, Any, Literal 

8 

9from pydantic import BaseModel, ConfigDict, Field, field_validator 

10 

11from lilbee.core.config.enums import ReasoningMode 

12 

13# Wire layer reuses the provider-layer enum so the two can never drift. 

14from lilbee.providers.base import FinishReason as FinishReason 

15 

16log = logging.getLogger(__name__) 

17 

18 

19class ToolChoiceMode(StrEnum): 

20 AUTO = "auto" 

21 NONE = "none" 

22 REQUIRED = "required" 

23 

24 

25class CompletionsTextContent(BaseModel): 

26 """Text part of a multi-part message content.""" 

27 

28 type: Literal["text"] 

29 text: str 

30 

31 

32class CompletionsImageUrl(BaseModel): 

33 url: str 

34 detail: Literal["auto", "low", "high"] | None = None 

35 

36 

37class CompletionsImageContent(BaseModel): 

38 """Image part of a multi-part message content.""" 

39 

40 type: Literal["image_url"] 

41 image_url: CompletionsImageUrl 

42 

43 

44CompletionsMessageContentPart = Annotated[ 

45 CompletionsTextContent | CompletionsImageContent, 

46 Field(discriminator="type"), 

47] 

48 

49 

50class CompletionsToolCallFunction(BaseModel): 

51 name: str 

52 arguments: str = "{}" 

53 

54 

55class CompletionsToolCall(BaseModel): 

56 """Assistant-side tool_call entry inside a request message.""" 

57 

58 id: str 

59 type: Literal["function"] = "function" 

60 function: CompletionsToolCallFunction 

61 

62 

63class CompletionsMessage(BaseModel): 

64 """One entry in the request ``messages`` list.""" 

65 

66 role: Literal["system", "user", "assistant", "tool"] 

67 content: str | list[CompletionsMessageContentPart] | None = None 

68 name: str | None = None 

69 tool_calls: list[CompletionsToolCall] | None = None 

70 tool_call_id: str | None = None 

71 

72 

73class CompletionsFunctionDef(BaseModel): 

74 name: str 

75 description: str | None = None 

76 parameters: dict[str, Any] = Field(default_factory=dict) 

77 

78 

79class CompletionsTool(BaseModel): 

80 type: Literal["function"] = "function" 

81 function: CompletionsFunctionDef 

82 

83 

84class CompletionsToolChoiceFunction(BaseModel): 

85 name: str 

86 

87 

88class CompletionsNamedToolChoice(BaseModel): 

89 """Explicit ``{type: "function", function: {name: ...}}`` tool_choice.""" 

90 

91 type: Literal["function"] 

92 function: CompletionsToolChoiceFunction 

93 

94 

95class StreamOptions(BaseModel): 

96 """OpenAI ``stream_options``. ``include_usage`` adds a final usage-only chunk.""" 

97 

98 include_usage: bool = False 

99 

100 

101class CompletionsRequest(BaseModel): 

102 """Top-level ``POST /v1/chat/completions`` request body. 

103 

104 OpenAI parameters fall into three groups on this surface: 

105 

106 - Honoured: ``model``, ``messages``, ``tools``, ``tool_choice``, 

107 ``temperature``, ``top_p``, ``top_k``, ``max_tokens``, ``stop``, ``seed``, 

108 ``frequency_penalty``, ``presence_penalty``, ``stream``, ``stream_options``, 

109 ``reasoning`` (lilbee extension: ``separate`` / ``inline`` / ``off``, 

110 overriding the ``completions_reasoning`` setting for this request). 

111 - Rejected with a 400: ``n`` greater than 1 (lilbee serves one choice). 

112 - Accepted but ignored (``extra="allow"`` keeps them off the parsed model and 

113 the route logs their keys at debug): ``n == 1``, ``response_format``, 

114 ``logprobs``, ``top_logprobs``, and any other unrecognised field. 

115 """ 

116 

117 model_config = ConfigDict(extra="allow") 

118 

119 model: str = Field(min_length=1) 

120 messages: list[CompletionsMessage] = Field(min_length=1) 

121 tools: list[CompletionsTool] | None = None 

122 tool_choice: ToolChoiceMode | CompletionsNamedToolChoice | None = None 

123 temperature: float | None = Field(default=None, ge=0.0, le=2.0) 

124 top_p: float | None = Field(default=None, ge=0.0, le=1.0) 

125 top_k: int | None = Field(default=None, ge=1) 

126 max_tokens: int | None = Field(default=None, ge=1) 

127 seed: int | None = None 

128 frequency_penalty: float | None = Field(default=None, ge=-2.0, le=2.0) 

129 presence_penalty: float | None = Field(default=None, ge=-2.0, le=2.0) 

130 n: int | None = Field(default=None, ge=1) 

131 stop: str | list[str] | None = None 

132 stream: bool = False 

133 stream_options: StreamOptions | None = None 

134 reasoning: ReasoningMode | None = None 

135 

136 @field_validator("reasoning", mode="before") 

137 @classmethod 

138 def _reasoning_strings_only(cls, value: object) -> object: 

139 # Other vendors use ``reasoning`` for an object (OpenRouter's effort 

140 # config). A non-string shape is their field, not this one; treat it 

141 # as absent so those requests keep working instead of turning 400. 

142 # Logged like the route logs other unsupported params, so the client 

143 # can learn the value had no effect. 

144 if value is None or isinstance(value, str): 

145 return value 

146 log.debug("chat/completions ignoring non-string reasoning value: %r", value) 

147 return None 

148 

149 

150class CompletionsResponseToolCallFunction(BaseModel): 

151 name: str 

152 arguments: str 

153 

154 

155class CompletionsResponseToolCall(BaseModel): 

156 id: str 

157 type: Literal["function"] = "function" 

158 function: CompletionsResponseToolCallFunction 

159 

160 

161class CompletionsResponseMessage(BaseModel): 

162 role: Literal["assistant"] = "assistant" 

163 content: str | None = None 

164 # A reasoning model's thinking, reported separately so ``content`` stays clean. 

165 reasoning_content: str | None = None 

166 tool_calls: list[CompletionsResponseToolCall] | None = None 

167 

168 

169class CompletionsResponseChoice(BaseModel): 

170 index: int = 0 

171 message: CompletionsResponseMessage 

172 finish_reason: FinishReason 

173 

174 

175class CompletionsUsage(BaseModel): 

176 prompt_tokens: int = 0 

177 completion_tokens: int = 0 

178 total_tokens: int = 0 

179 

180 

181class CompletionsResponse(BaseModel): 

182 """Non-streaming ``/v1/chat/completions`` response body.""" 

183 

184 id: str 

185 object: Literal["chat.completion"] = "chat.completion" 

186 created: int 

187 model: str 

188 choices: list[CompletionsResponseChoice] 

189 usage: CompletionsUsage 

190 

191 

192class CompletionsStreamToolCallFunction(BaseModel): 

193 name: str | None = None 

194 arguments: str | None = None 

195 

196 

197class CompletionsStreamToolCall(BaseModel): 

198 index: int 

199 id: str | None = None 

200 type: Literal["function"] | None = None 

201 function: CompletionsStreamToolCallFunction | None = None 

202 

203 

204class CompletionsStreamDelta(BaseModel): 

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

206 content: str | None = None 

207 reasoning_content: str | None = None 

208 tool_calls: list[CompletionsStreamToolCall] | None = None 

209 

210 

211class CompletionsStreamChoice(BaseModel): 

212 index: int = 0 

213 delta: CompletionsStreamDelta 

214 finish_reason: FinishReason | None = None 

215 

216 

217class CompletionsStreamChunk(BaseModel): 

218 """Single SSE frame from streaming ``/v1/chat/completions``. 

219 

220 The final frame (when usage is known) carries an empty ``choices`` list and a 

221 populated ``usage`` block, matching OpenAI's ``stream_options.include_usage``. 

222 """ 

223 

224 id: str 

225 object: Literal["chat.completion.chunk"] = "chat.completion.chunk" 

226 created: int 

227 model: str 

228 choices: list[CompletionsStreamChoice] 

229 usage: CompletionsUsage | None = None 

230 

231 

232class ModelEntry(BaseModel): 

233 id: str 

234 object: Literal["model"] = "model" 

235 owned_by: str = "lilbee" 

236 created: int 

237 context_window: int | None = None 

238 """The context the active chat engine serves, so a client can trim history to 

239 fit. None when the engine is not up yet or the window is unknown.""" 

240 slots: int | None = None 

241 """Batching slots the active chat engine serves: how many requests generate 

242 at once before the rest queue. None when the engine is not up yet.""" 

243 

244 

245class ModelsListResponse(BaseModel): 

246 """``GET /v1/models`` response envelope.""" 

247 

248 object: Literal["list"] = "list" 

249 data: list[ModelEntry]