courier-encode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
encode/messages.py ADDED
@@ -0,0 +1,342 @@
1
+ """Message and content models for relay() inputs.
2
+
3
+ Pydantic v2 models for text/image/audio content plus a ``Message`` envelope.
4
+ The relay normalizers convert ``Sequence[Message | dict]`` to either:
5
+
6
+ - list[dict] for /v1/chat/completions
7
+ - list of typed input items for /v1/responses
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import mimetypes
14
+ from collections.abc import Iterable, Iterator, Sequence
15
+ from os import PathLike
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, Any, Literal
18
+
19
+ from pydantic import BaseModel, ConfigDict
20
+
21
+ if TYPE_CHECKING:
22
+ from .responses import RelayResponse
23
+
24
+
25
+ class TextContent(BaseModel):
26
+ model_config = ConfigDict(extra="allow")
27
+ type: Literal["text"] = "text"
28
+ text: str
29
+
30
+
31
+ class ImageURL(BaseModel):
32
+ model_config = ConfigDict(extra="allow")
33
+ url: str
34
+ detail: str | None = None
35
+
36
+
37
+ class ImageContent(BaseModel):
38
+ model_config = ConfigDict(extra="allow")
39
+ type: Literal["image_url"] = "image_url"
40
+ image_url: ImageURL
41
+
42
+ @classmethod
43
+ def from_path(cls, path: str | PathLike[str], *, detail: str | None = None) -> ImageContent:
44
+ p = Path(path)
45
+ data = p.read_bytes()
46
+ mime = mimetypes.guess_type(p.name)[0] or "image/png"
47
+ b64 = base64.b64encode(data).decode("ascii")
48
+ return cls(image_url=ImageURL(url=f"data:{mime};base64,{b64}", detail=detail))
49
+
50
+ @classmethod
51
+ def from_url(cls, url: str, *, detail: str | None = None) -> ImageContent:
52
+ return cls(image_url=ImageURL(url=url, detail=detail))
53
+
54
+
55
+ class InputAudio(BaseModel):
56
+ model_config = ConfigDict(extra="allow")
57
+ data: str # base64
58
+ format: Literal["wav", "mp3"]
59
+
60
+
61
+ class AudioContent(BaseModel):
62
+ model_config = ConfigDict(extra="allow")
63
+ type: Literal["input_audio"] = "input_audio"
64
+ input_audio: InputAudio
65
+
66
+ @classmethod
67
+ def from_path(cls, path: str | PathLike[str]) -> AudioContent:
68
+ p = Path(path)
69
+ data = p.read_bytes()
70
+ ext = p.suffix.lower().lstrip(".")
71
+ fmt: Literal["wav", "mp3"] = "wav" if ext == "wav" else "mp3"
72
+ b64 = base64.b64encode(data).decode("ascii")
73
+ return cls(input_audio=InputAudio(data=b64, format=fmt))
74
+
75
+
76
+ ContentPart = TextContent | ImageContent | AudioContent
77
+
78
+
79
+ class ToolCallFunction(BaseModel):
80
+ model_config = ConfigDict(extra="allow")
81
+ name: str
82
+ arguments: str # JSON string
83
+
84
+
85
+ class ToolCall(BaseModel):
86
+ model_config = ConfigDict(extra="allow")
87
+ id: str
88
+ type: Literal["function"] = "function"
89
+ function: ToolCallFunction
90
+
91
+
92
+ class Message(BaseModel):
93
+ model_config = ConfigDict(extra="allow")
94
+ role: Literal["system", "user", "assistant", "tool", "developer"]
95
+ content: str | list[ContentPart] | None = None
96
+ name: str | None = None
97
+ tool_call_id: str | None = None
98
+ tool_calls: list[ToolCall] | None = None
99
+
100
+
101
+ class Conversation(BaseModel):
102
+ """Pydantic snapshot of a ``Messages`` container.
103
+
104
+ Suitable for database persistence — call ``model.model_dump_json()`` to
105
+ serialize, ``Conversation.model_validate_json(blob)`` to load, and
106
+ ``Messages.from_pydantic(model)`` to rehydrate into a stateful container.
107
+ """
108
+
109
+ model_config = ConfigDict(extra="allow")
110
+ messages: list[Message]
111
+
112
+
113
+ def _msg_to_dict(m: Message | dict) -> dict[str, Any]:
114
+ if isinstance(m, Message):
115
+ return m.model_dump(exclude_none=True)
116
+ return dict(m)
117
+
118
+
119
+ def to_chat_messages(
120
+ messages: Sequence[Message | dict] | None,
121
+ ) -> list[dict[str, Any]]:
122
+ """Normalize for /v1/chat/completions."""
123
+ if not messages:
124
+ return []
125
+ return [_msg_to_dict(m) for m in messages]
126
+
127
+
128
+ def to_responses_input(
129
+ messages: Sequence[Message | dict] | None,
130
+ *,
131
+ input: str | Sequence[dict] | None = None,
132
+ ) -> list[dict[str, Any]]:
133
+ """Normalize for /v1/responses.
134
+
135
+ Combines an optional explicit ``input`` (string or list of typed items) with
136
+ a converted ``messages`` list. Each message becomes a ``{"type":"message"}``
137
+ item; tool messages become ``function_call_output`` items.
138
+ """
139
+ items: list[dict[str, Any]] = []
140
+ if input is not None:
141
+ if isinstance(input, str):
142
+ items.append({"type": "message", "role": "user", "content": input})
143
+ else:
144
+ items.extend(dict(i) for i in input)
145
+ if messages:
146
+ for m in messages:
147
+ d = _msg_to_dict(m)
148
+ role = d.get("role")
149
+ if role == "tool":
150
+ items.append(
151
+ {
152
+ "type": "function_call_output",
153
+ "call_id": d.get("tool_call_id"),
154
+ "output": d.get("content") or "",
155
+ }
156
+ )
157
+ elif d.get("tool_calls"):
158
+ # assistant turn with tool calls — emit message + function_call items
159
+ if d.get("content"):
160
+ items.append(
161
+ {
162
+ "type": "message",
163
+ "role": role,
164
+ "content": d["content"],
165
+ }
166
+ )
167
+ for tc in d["tool_calls"]:
168
+ items.append(
169
+ {
170
+ "type": "function_call",
171
+ "call_id": tc["id"],
172
+ "name": tc["function"]["name"],
173
+ "arguments": tc["function"]["arguments"],
174
+ }
175
+ )
176
+ else:
177
+ items.append(
178
+ {
179
+ "type": "message",
180
+ "role": role,
181
+ "content": d.get("content"),
182
+ }
183
+ )
184
+ return items
185
+
186
+
187
+ def _coerce_content(content: Any) -> Any:
188
+ """Pass through strings and lists; convert Pydantic content parts to dicts."""
189
+ if content is None or isinstance(content, str):
190
+ return content
191
+ if isinstance(content, list):
192
+ return [c.model_dump(exclude_none=True) if isinstance(c, BaseModel) else c for c in content]
193
+ return content
194
+
195
+
196
+ class Messages:
197
+ """Mutable conversation container.
198
+
199
+ Pass to ``relay()`` / ``relay_async()`` as ``messages=`` and the SDK will
200
+ append the new turns in place after the loop completes. Plain lists work
201
+ too — they are not mutated.
202
+
203
+ Quacks like a list (``__len__``, ``__iter__``, ``__getitem__``, ``__bool__``)
204
+ and exposes chainable adders for ergonomic construction.
205
+
206
+ Example:
207
+ m = (
208
+ encode.Messages()
209
+ .system("Be brief.")
210
+ .user("name three colors")
211
+ )
212
+ encode.relay(model="...", messages=m).response
213
+ m.user("now three more")
214
+ encode.relay(model="...", messages=m).response # carries prior turns
215
+ """
216
+
217
+ __slots__ = ("_items",)
218
+
219
+ def __init__(self, messages: Iterable[Message | dict[str, Any]] | None = None) -> None:
220
+ self._items: list[dict[str, Any]] = []
221
+ if messages:
222
+ self.extend(messages)
223
+
224
+ # ------------------------- ergonomic adders -------------------------
225
+
226
+ def system(self, content: str | list[Any]) -> Messages:
227
+ self._items.append({"role": "system", "content": _coerce_content(content)})
228
+ return self
229
+
230
+ def user(self, content: str | list[Any]) -> Messages:
231
+ self._items.append({"role": "user", "content": _coerce_content(content)})
232
+ return self
233
+
234
+ def assistant(
235
+ self,
236
+ content: str | None = None,
237
+ *,
238
+ tool_calls: list[dict[str, Any]] | list[ToolCall] | None = None,
239
+ ) -> Messages:
240
+ msg: dict[str, Any] = {"role": "assistant", "content": content}
241
+ if tool_calls:
242
+ msg["tool_calls"] = [
243
+ tc.model_dump(exclude_none=True) if isinstance(tc, ToolCall) else dict(tc)
244
+ for tc in tool_calls
245
+ ]
246
+ self._items.append(msg)
247
+ return self
248
+
249
+ def tool(self, content: str, *, tool_call_id: str) -> Messages:
250
+ self._items.append(
251
+ {"role": "tool", "tool_call_id": tool_call_id, "content": content}
252
+ )
253
+ return self
254
+
255
+ def add(self, message: Message | dict[str, Any]) -> Messages:
256
+ self._items.append(_msg_to_dict(message))
257
+ return self
258
+
259
+ # ------------------------- list-like protocol -------------------------
260
+
261
+ def append(self, message: Message | dict[str, Any]) -> None:
262
+ self._items.append(_msg_to_dict(message))
263
+
264
+ def extend(self, messages: Iterable[Message | dict[str, Any]]) -> None:
265
+ for m in messages:
266
+ self.append(m)
267
+
268
+ def clear(self) -> None:
269
+ self._items.clear()
270
+
271
+ def copy(self) -> Messages:
272
+ new: Messages = Messages.__new__(Messages)
273
+ new._items = [dict(m) for m in self._items]
274
+ return new
275
+
276
+ def to_list(self) -> list[dict[str, Any]]:
277
+ return [dict(m) for m in self._items]
278
+
279
+ def __len__(self) -> int:
280
+ return len(self._items)
281
+
282
+ def __iter__(self) -> Iterator[dict[str, Any]]:
283
+ return iter(self._items)
284
+
285
+ def __getitem__(self, idx: int) -> dict[str, Any]:
286
+ return self._items[idx]
287
+
288
+ def __bool__(self) -> bool:
289
+ return bool(self._items)
290
+
291
+ def __repr__(self) -> str:
292
+ return f"Messages({len(self._items)} items)"
293
+
294
+ # ------------------------- absorb a response -------------------------
295
+
296
+ def update(self, response: RelayResponse) -> None:
297
+ """Replace contents with the conversation from a RelayResponse.
298
+
299
+ Useful for streaming flows where auto-update doesn't fire, or any
300
+ manual ingestion path. Replaces — does not merge — because
301
+ ``RelayResponse.messages`` is the full history.
302
+ """
303
+ self._items = list(response.messages)
304
+
305
+ # ------------------------- pydantic interop -------------------------
306
+
307
+ def to_pydantic(self) -> Conversation:
308
+ """Snapshot the current state as a validated Pydantic model.
309
+
310
+ Useful for DB serialization (``model.model_dump_json()``) or anywhere
311
+ else you want a typed, validated representation rather than raw dicts.
312
+ """
313
+ return Conversation(
314
+ messages=[Message.model_validate(m) for m in self._items]
315
+ )
316
+
317
+ @classmethod
318
+ def from_pydantic(cls, model: Conversation) -> Messages:
319
+ """Rehydrate a Messages container from a Conversation model."""
320
+ m = cls()
321
+ for msg in model.messages:
322
+ m._items.append(msg.model_dump(exclude_none=True))
323
+ return m
324
+
325
+
326
+ __all__ = [
327
+ "TextContent",
328
+ "ImageContent",
329
+ "ImageURL",
330
+ "AudioContent",
331
+ "InputAudio",
332
+ "ToolCall",
333
+ "ToolCallFunction",
334
+ "Message",
335
+ "Messages",
336
+ "Conversation",
337
+ "ContentPart",
338
+ "to_chat_messages",
339
+ "to_responses_input",
340
+ ]
341
+
342
+
encode/py.typed ADDED
File without changes