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/_streaming.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""SSE accumulators for /v1/chat/completions and /v1/responses streaming."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import AsyncIterator, Iterator
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class StreamEvent:
|
|
15
|
+
type: str
|
|
16
|
+
data: Any
|
|
17
|
+
raw: dict[str, Any] | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _iter_sse_lines(resp: httpx.Response) -> Iterator[str]:
|
|
21
|
+
for raw in resp.iter_lines():
|
|
22
|
+
if not raw:
|
|
23
|
+
continue
|
|
24
|
+
line = raw if isinstance(raw, str) else raw.decode("utf-8", errors="replace")
|
|
25
|
+
if line.startswith(":"):
|
|
26
|
+
continue
|
|
27
|
+
if line.startswith("data:"):
|
|
28
|
+
payload = line[5:].lstrip()
|
|
29
|
+
if payload == "[DONE]":
|
|
30
|
+
return
|
|
31
|
+
yield payload
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def _aiter_sse_lines(resp: httpx.Response) -> AsyncIterator[str]:
|
|
35
|
+
async for raw in resp.aiter_lines():
|
|
36
|
+
if not raw:
|
|
37
|
+
continue
|
|
38
|
+
line = raw if isinstance(raw, str) else raw.decode("utf-8", errors="replace")
|
|
39
|
+
if line.startswith(":"):
|
|
40
|
+
continue
|
|
41
|
+
if line.startswith("data:"):
|
|
42
|
+
payload = line[5:].lstrip()
|
|
43
|
+
if payload == "[DONE]":
|
|
44
|
+
return
|
|
45
|
+
yield payload
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def iter_chat_completions(resp: httpx.Response) -> Iterator[StreamEvent]:
|
|
49
|
+
for payload in _iter_sse_lines(resp):
|
|
50
|
+
try:
|
|
51
|
+
chunk = json.loads(payload)
|
|
52
|
+
except json.JSONDecodeError:
|
|
53
|
+
continue
|
|
54
|
+
delta = (chunk.get("choices") or [{}])[0].get("delta") or {}
|
|
55
|
+
if "content" in delta and delta["content"] is not None:
|
|
56
|
+
yield StreamEvent(type="content.delta", data=delta["content"], raw=chunk)
|
|
57
|
+
if "tool_calls" in delta and delta["tool_calls"]:
|
|
58
|
+
yield StreamEvent(type="tool_calls.delta", data=delta["tool_calls"], raw=chunk)
|
|
59
|
+
finish = (chunk.get("choices") or [{}])[0].get("finish_reason")
|
|
60
|
+
if finish:
|
|
61
|
+
yield StreamEvent(type="finish", data=finish, raw=chunk)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def iter_responses(resp: httpx.Response) -> Iterator[StreamEvent]:
|
|
65
|
+
for payload in _iter_sse_lines(resp):
|
|
66
|
+
try:
|
|
67
|
+
event = json.loads(payload)
|
|
68
|
+
except json.JSONDecodeError:
|
|
69
|
+
continue
|
|
70
|
+
etype = event.get("type", "unknown")
|
|
71
|
+
yield StreamEvent(type=etype, data=event, raw=event)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def aiter_chat_completions(resp: httpx.Response) -> AsyncIterator[StreamEvent]:
|
|
75
|
+
async for payload in _aiter_sse_lines(resp):
|
|
76
|
+
try:
|
|
77
|
+
chunk = json.loads(payload)
|
|
78
|
+
except json.JSONDecodeError:
|
|
79
|
+
continue
|
|
80
|
+
delta = (chunk.get("choices") or [{}])[0].get("delta") or {}
|
|
81
|
+
if "content" in delta and delta["content"] is not None:
|
|
82
|
+
yield StreamEvent(type="content.delta", data=delta["content"], raw=chunk)
|
|
83
|
+
if "tool_calls" in delta and delta["tool_calls"]:
|
|
84
|
+
yield StreamEvent(type="tool_calls.delta", data=delta["tool_calls"], raw=chunk)
|
|
85
|
+
finish = (chunk.get("choices") or [{}])[0].get("finish_reason")
|
|
86
|
+
if finish:
|
|
87
|
+
yield StreamEvent(type="finish", data=finish, raw=chunk)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def aiter_responses(resp: httpx.Response) -> AsyncIterator[StreamEvent]:
|
|
91
|
+
async for payload in _aiter_sse_lines(resp):
|
|
92
|
+
try:
|
|
93
|
+
event = json.loads(payload)
|
|
94
|
+
except json.JSONDecodeError:
|
|
95
|
+
continue
|
|
96
|
+
etype = event.get("type", "unknown")
|
|
97
|
+
yield StreamEvent(type=etype, data=event, raw=event)
|
encode/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
encode/client.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Client / AsyncClient — thin wrappers around httpx with config resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from . import _config, _http
|
|
10
|
+
from ._version import __version__
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Client:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
*,
|
|
17
|
+
api_key: str | None = None,
|
|
18
|
+
base_url: str | None = None,
|
|
19
|
+
timeout: float | httpx.Timeout | None = 60.0,
|
|
20
|
+
max_retries: int = 2,
|
|
21
|
+
http_client: httpx.Client | None = None,
|
|
22
|
+
default_headers: dict[str, str] | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
_config.load_dotenv_once()
|
|
25
|
+
self._api_key = _config.resolve_api_key(api_key)
|
|
26
|
+
self._base_url = _config.resolve_base_url(base_url)
|
|
27
|
+
self._timeout = _http.build_timeout(timeout)
|
|
28
|
+
self.max_retries = max_retries
|
|
29
|
+
headers = _http.default_headers(self._api_key, version=__version__)
|
|
30
|
+
if default_headers:
|
|
31
|
+
headers.update(default_headers)
|
|
32
|
+
self._owns_client = http_client is None
|
|
33
|
+
self._http: httpx.Client = http_client or httpx.Client(
|
|
34
|
+
base_url=self._base_url,
|
|
35
|
+
timeout=self._timeout,
|
|
36
|
+
headers=headers,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def base_url(self) -> str:
|
|
41
|
+
return self._base_url
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def api_key(self) -> str:
|
|
45
|
+
return self._api_key
|
|
46
|
+
|
|
47
|
+
def relay(self, **kwargs: Any) -> Any:
|
|
48
|
+
from .relay import relay
|
|
49
|
+
|
|
50
|
+
return relay(client=self, **kwargs)
|
|
51
|
+
|
|
52
|
+
def whisper(self, **kwargs: Any) -> Any:
|
|
53
|
+
from .whisper import whisper
|
|
54
|
+
|
|
55
|
+
return whisper(client=self, **kwargs)
|
|
56
|
+
|
|
57
|
+
def close(self) -> None:
|
|
58
|
+
if self._owns_client:
|
|
59
|
+
self._http.close()
|
|
60
|
+
|
|
61
|
+
def __enter__(self) -> Client:
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
def __exit__(self, *exc: Any) -> None:
|
|
65
|
+
self.close()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class AsyncClient:
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
api_key: str | None = None,
|
|
73
|
+
base_url: str | None = None,
|
|
74
|
+
timeout: float | httpx.Timeout | None = 60.0,
|
|
75
|
+
max_retries: int = 2,
|
|
76
|
+
http_client: httpx.AsyncClient | None = None,
|
|
77
|
+
default_headers: dict[str, str] | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
_config.load_dotenv_once()
|
|
80
|
+
self._api_key = _config.resolve_api_key(api_key)
|
|
81
|
+
self._base_url = _config.resolve_base_url(base_url)
|
|
82
|
+
self._timeout = _http.build_timeout(timeout)
|
|
83
|
+
self.max_retries = max_retries
|
|
84
|
+
headers = _http.default_headers(self._api_key, version=__version__)
|
|
85
|
+
if default_headers:
|
|
86
|
+
headers.update(default_headers)
|
|
87
|
+
self._owns_client = http_client is None
|
|
88
|
+
self._http: httpx.AsyncClient = http_client or httpx.AsyncClient(
|
|
89
|
+
base_url=self._base_url,
|
|
90
|
+
timeout=self._timeout,
|
|
91
|
+
headers=headers,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def base_url(self) -> str:
|
|
96
|
+
return self._base_url
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def api_key(self) -> str:
|
|
100
|
+
return self._api_key
|
|
101
|
+
|
|
102
|
+
def relay(self, **kwargs: Any) -> Any:
|
|
103
|
+
from .relay import relay_async
|
|
104
|
+
|
|
105
|
+
return relay_async(client=self, **kwargs)
|
|
106
|
+
|
|
107
|
+
async def whisper(self, **kwargs: Any) -> Any:
|
|
108
|
+
from .whisper import whisper_async
|
|
109
|
+
|
|
110
|
+
return await whisper_async(client=self, **kwargs)
|
|
111
|
+
|
|
112
|
+
async def aclose(self) -> None:
|
|
113
|
+
if self._owns_client:
|
|
114
|
+
await self._http.aclose()
|
|
115
|
+
|
|
116
|
+
async def __aenter__(self) -> AsyncClient:
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
120
|
+
await self.aclose()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
_default_client: Client | None = None
|
|
124
|
+
_default_async_client: AsyncClient | None = None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def get_default_client() -> Client:
|
|
128
|
+
global _default_client
|
|
129
|
+
if _default_client is None:
|
|
130
|
+
_default_client = Client()
|
|
131
|
+
return _default_client
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def get_default_async_client() -> AsyncClient:
|
|
135
|
+
global _default_async_client
|
|
136
|
+
if _default_async_client is None:
|
|
137
|
+
_default_async_client = AsyncClient()
|
|
138
|
+
return _default_async_client
|
encode/errors.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Typed exception hierarchy for encode.
|
|
2
|
+
|
|
3
|
+
Maps the OpenAI-compatible error envelope ``{"error": {"message", "type", "code"}}``
|
|
4
|
+
to specific exception classes. Falls back to ``CourierError`` for non-envelope bodies
|
|
5
|
+
(some compatible servers return ``{"detail": "..."}`` or plain text).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CourierError(Exception):
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
message: str,
|
|
17
|
+
*,
|
|
18
|
+
type: str | None = None,
|
|
19
|
+
code: str | None = None,
|
|
20
|
+
status: int | None = None,
|
|
21
|
+
raw: Any = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.message = message
|
|
25
|
+
self.type = type
|
|
26
|
+
self.code = code
|
|
27
|
+
self.status = status
|
|
28
|
+
self.raw = raw
|
|
29
|
+
|
|
30
|
+
def __repr__(self) -> str: # pragma: no cover - trivial
|
|
31
|
+
parts = [f"message={self.message!r}"]
|
|
32
|
+
if self.type:
|
|
33
|
+
parts.append(f"type={self.type!r}")
|
|
34
|
+
if self.code:
|
|
35
|
+
parts.append(f"code={self.code!r}")
|
|
36
|
+
if self.status is not None:
|
|
37
|
+
parts.append(f"status={self.status}")
|
|
38
|
+
return f"{type(self).__name__}({', '.join(parts)})"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AuthError(CourierError):
|
|
42
|
+
"""401 / missing credentials."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class InvalidRequestError(CourierError):
|
|
46
|
+
"""400-level client error."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class InvalidToolCallError(InvalidRequestError):
|
|
50
|
+
"""code=invalid_tool_call — arguments failed strict JSON validation."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class InvalidToolChoiceError(InvalidRequestError):
|
|
54
|
+
"""code=invalid_tool_choice — forced tool name not in tools list."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class InvalidResponseFormatError(InvalidRequestError):
|
|
58
|
+
"""type=invalid_response_format — unsupported response_format."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class InvalidAudioError(InvalidRequestError):
|
|
62
|
+
"""type=invalid_audio — bad multipart upload, unsupported format, etc."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class RateLimitError(CourierError):
|
|
66
|
+
"""429."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ServerError(CourierError):
|
|
70
|
+
"""5xx."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TransportError(CourierError):
|
|
74
|
+
"""Network / timeout failure that exhausted retries."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class MaxToolIterationsError(CourierError):
|
|
78
|
+
"""SDK-side: tool loop exceeded max_tool_iterations.
|
|
79
|
+
|
|
80
|
+
Carries the partial RelayResponse so callers can inspect what happened.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, message: str, *, partial: Any = None, **kw: Any) -> None:
|
|
84
|
+
super().__init__(message, **kw)
|
|
85
|
+
self.partial = partial
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class SessionError(CourierError):
|
|
89
|
+
"""Persistent shell session failure (spawn, dead process, unsupported platform)."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class SessionTimeoutError(SessionError):
|
|
93
|
+
"""A session command exceeded its timeout.
|
|
94
|
+
|
|
95
|
+
Carries ``partial_output`` (whatever was emitted before the cutoff) and
|
|
96
|
+
``command`` so callers can salvage what happened.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
message: str,
|
|
102
|
+
*,
|
|
103
|
+
partial_output: str = "",
|
|
104
|
+
command: str = "",
|
|
105
|
+
**kw: Any,
|
|
106
|
+
) -> None:
|
|
107
|
+
super().__init__(message, **kw)
|
|
108
|
+
self.partial_output = partial_output
|
|
109
|
+
self.command = command
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
_CODE_MAP: dict[str, type[CourierError]] = {
|
|
113
|
+
"invalid_tool_call": InvalidToolCallError,
|
|
114
|
+
"invalid_tool_choice": InvalidToolChoiceError,
|
|
115
|
+
"invalid_response_format": InvalidResponseFormatError,
|
|
116
|
+
"invalid_audio": InvalidAudioError,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
_TYPE_MAP: dict[str, type[CourierError]] = {
|
|
120
|
+
"invalid_request_error": InvalidRequestError,
|
|
121
|
+
"invalid_audio": InvalidAudioError,
|
|
122
|
+
"invalid_response_format": InvalidResponseFormatError,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def from_envelope(
|
|
127
|
+
body: Any, status: int | None = None
|
|
128
|
+
) -> CourierError:
|
|
129
|
+
"""Build a typed exception from a parsed response body and HTTP status.
|
|
130
|
+
|
|
131
|
+
Tries the OpenAI envelope first, then falls back to ``{"detail": ...}``,
|
|
132
|
+
then to a plain string body.
|
|
133
|
+
"""
|
|
134
|
+
err: dict[str, Any] | None = None
|
|
135
|
+
detail: str | None = None
|
|
136
|
+
if isinstance(body, dict):
|
|
137
|
+
if isinstance(body.get("error"), dict):
|
|
138
|
+
err = body["error"]
|
|
139
|
+
elif "detail" in body:
|
|
140
|
+
detail = str(body["detail"])
|
|
141
|
+
|
|
142
|
+
if err is not None:
|
|
143
|
+
message = str(err.get("message") or "request failed")
|
|
144
|
+
code = err.get("code")
|
|
145
|
+
type_ = err.get("type")
|
|
146
|
+
cls = (
|
|
147
|
+
_CODE_MAP.get(str(code) if code else "")
|
|
148
|
+
or _TYPE_MAP.get(str(type_) if type_ else "")
|
|
149
|
+
or _from_status(status)
|
|
150
|
+
)
|
|
151
|
+
return cls(message, type=type_, code=code, status=status, raw=body)
|
|
152
|
+
|
|
153
|
+
message = detail or (body if isinstance(body, str) else f"HTTP {status}")
|
|
154
|
+
cls = _from_status(status)
|
|
155
|
+
return cls(message, status=status, raw=body)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _from_status(status: int | None) -> type[CourierError]:
|
|
159
|
+
if status is None:
|
|
160
|
+
return CourierError
|
|
161
|
+
if status == 401 or status == 403:
|
|
162
|
+
return AuthError
|
|
163
|
+
if status == 429:
|
|
164
|
+
return RateLimitError
|
|
165
|
+
if 400 <= status < 500:
|
|
166
|
+
return InvalidRequestError
|
|
167
|
+
if 500 <= status < 600:
|
|
168
|
+
return ServerError
|
|
169
|
+
return CourierError
|