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/_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