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.
- courier_encode-0.1.0.dist-info/METADATA +1158 -0
- courier_encode-0.1.0.dist-info/RECORD +19 -0
- courier_encode-0.1.0.dist-info/WHEEL +4 -0
- courier_encode-0.1.0.dist-info/licenses/LICENSE +201 -0
- encode/__init__.py +113 -0
- encode/_config.py +61 -0
- encode/_http.py +150 -0
- encode/_schema.py +135 -0
- encode/_streaming.py +97 -0
- encode/_version.py +1 -0
- encode/client.py +138 -0
- encode/errors.py +169 -0
- encode/messages.py +342 -0
- encode/py.typed +0 -0
- encode/relay.py +1222 -0
- encode/responses.py +67 -0
- encode/session.py +447 -0
- encode/tools.py +107 -0
- encode/whisper.py +200 -0
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
|