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
« 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."""
3from __future__ import annotations
5import logging
6from enum import StrEnum
7from typing import Annotated, Any, Literal
9from pydantic import BaseModel, ConfigDict, Field, field_validator
11from lilbee.core.config.enums import ReasoningMode
13# Wire layer reuses the provider-layer enum so the two can never drift.
14from lilbee.providers.base import FinishReason as FinishReason
16log = logging.getLogger(__name__)
19class ToolChoiceMode(StrEnum):
20 AUTO = "auto"
21 NONE = "none"
22 REQUIRED = "required"
25class CompletionsTextContent(BaseModel):
26 """Text part of a multi-part message content."""
28 type: Literal["text"]
29 text: str
32class CompletionsImageUrl(BaseModel):
33 url: str
34 detail: Literal["auto", "low", "high"] | None = None
37class CompletionsImageContent(BaseModel):
38 """Image part of a multi-part message content."""
40 type: Literal["image_url"]
41 image_url: CompletionsImageUrl
44CompletionsMessageContentPart = Annotated[
45 CompletionsTextContent | CompletionsImageContent,
46 Field(discriminator="type"),
47]
50class CompletionsToolCallFunction(BaseModel):
51 name: str
52 arguments: str = "{}"
55class CompletionsToolCall(BaseModel):
56 """Assistant-side tool_call entry inside a request message."""
58 id: str
59 type: Literal["function"] = "function"
60 function: CompletionsToolCallFunction
63class CompletionsMessage(BaseModel):
64 """One entry in the request ``messages`` list."""
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
73class CompletionsFunctionDef(BaseModel):
74 name: str
75 description: str | None = None
76 parameters: dict[str, Any] = Field(default_factory=dict)
79class CompletionsTool(BaseModel):
80 type: Literal["function"] = "function"
81 function: CompletionsFunctionDef
84class CompletionsToolChoiceFunction(BaseModel):
85 name: str
88class CompletionsNamedToolChoice(BaseModel):
89 """Explicit ``{type: "function", function: {name: ...}}`` tool_choice."""
91 type: Literal["function"]
92 function: CompletionsToolChoiceFunction
95class StreamOptions(BaseModel):
96 """OpenAI ``stream_options``. ``include_usage`` adds a final usage-only chunk."""
98 include_usage: bool = False
101class CompletionsRequest(BaseModel):
102 """Top-level ``POST /v1/chat/completions`` request body.
104 OpenAI parameters fall into three groups on this surface:
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 """
117 model_config = ConfigDict(extra="allow")
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
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
150class CompletionsResponseToolCallFunction(BaseModel):
151 name: str
152 arguments: str
155class CompletionsResponseToolCall(BaseModel):
156 id: str
157 type: Literal["function"] = "function"
158 function: CompletionsResponseToolCallFunction
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
169class CompletionsResponseChoice(BaseModel):
170 index: int = 0
171 message: CompletionsResponseMessage
172 finish_reason: FinishReason
175class CompletionsUsage(BaseModel):
176 prompt_tokens: int = 0
177 completion_tokens: int = 0
178 total_tokens: int = 0
181class CompletionsResponse(BaseModel):
182 """Non-streaming ``/v1/chat/completions`` response body."""
184 id: str
185 object: Literal["chat.completion"] = "chat.completion"
186 created: int
187 model: str
188 choices: list[CompletionsResponseChoice]
189 usage: CompletionsUsage
192class CompletionsStreamToolCallFunction(BaseModel):
193 name: str | None = None
194 arguments: str | None = None
197class CompletionsStreamToolCall(BaseModel):
198 index: int
199 id: str | None = None
200 type: Literal["function"] | None = None
201 function: CompletionsStreamToolCallFunction | None = None
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
211class CompletionsStreamChoice(BaseModel):
212 index: int = 0
213 delta: CompletionsStreamDelta
214 finish_reason: FinishReason | None = None
217class CompletionsStreamChunk(BaseModel):
218 """Single SSE frame from streaming ``/v1/chat/completions``.
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 """
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
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."""
245class ModelsListResponse(BaseModel):
246 """``GET /v1/models`` response envelope."""
248 object: Literal["list"] = "list"
249 data: list[ModelEntry]