auriko 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.
- auriko/__init__.py +24 -0
- auriko/_base_client.py +111 -0
- auriko/_client.py +372 -0
- auriko/_openai_client.py +196 -0
- auriko/_sse.py +72 -0
- auriko/_streaming.py +144 -0
- auriko/_version.py +14 -0
- auriko/errors/__init__.py +30 -0
- auriko/errors/_registry.py +43 -0
- auriko/errors/mapping.py +157 -0
- auriko/errors/openai_bridge.py +174 -0
- auriko/errors/types.py +128 -0
- auriko/frameworks/__init__.py +0 -0
- auriko/frameworks/adk/__init__.py +9 -0
- auriko/frameworks/adk/llm.py +363 -0
- auriko/frameworks/crewai/__init__.py +9 -0
- auriko/frameworks/crewai/interceptor.py +115 -0
- auriko/frameworks/crewai/llm.py +76 -0
- auriko/frameworks/langchain/__init__.py +9 -0
- auriko/frameworks/langchain/chat.py +84 -0
- auriko/frameworks/llamaindex/__init__.py +9 -0
- auriko/frameworks/llamaindex/llm.py +103 -0
- auriko/models/__init__.py +69 -0
- auriko/models/chat.py +139 -0
- auriko/models/common.py +42 -0
- auriko/models/extensions.py +35 -0
- auriko/models/providers.py +129 -0
- auriko/py.typed +1 -0
- auriko/resources/__init__.py +14 -0
- auriko/resources/chat.py +494 -0
- auriko/resources/me.py +28 -0
- auriko/resources/models.py +60 -0
- auriko/response_headers.py +72 -0
- auriko/route_types.py +250 -0
- auriko/types/__init__.py +67 -0
- auriko/types/chat_params.py +329 -0
- auriko-0.1.0.dist-info/METADATA +290 -0
- auriko-0.1.0.dist-info/RECORD +41 -0
- auriko-0.1.0.dist-info/WHEEL +5 -0
- auriko-0.1.0.dist-info/licenses/LICENSE +191 -0
- auriko-0.1.0.dist-info/top_level.txt +1 -0
auriko/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Auriko SDK — Intelligent LLM routing."""
|
|
2
|
+
|
|
3
|
+
from ._client import AsyncClient, Client
|
|
4
|
+
from ._version import __version__
|
|
5
|
+
from .errors import (
|
|
6
|
+
APIConnectionError,
|
|
7
|
+
APIStatusError,
|
|
8
|
+
AurikoAPIError,
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
BadRequestError,
|
|
11
|
+
ConflictError,
|
|
12
|
+
InternalServerError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
PermissionDeniedError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
map_openai_error,
|
|
17
|
+
)
|
|
18
|
+
from .response_headers import ResponseHeaders
|
|
19
|
+
from ._streaming import AsyncStream, Stream
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from ._openai_client import AurikoAsyncOpenAI
|
|
23
|
+
except ModuleNotFoundError:
|
|
24
|
+
pass # Available with: pip install auriko[openai-compat]
|
auriko/_base_client.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Shared non-I/O logic for Client and AsyncClient."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import random
|
|
7
|
+
from typing import Any, Mapping, Optional, TypeVar
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from ._version import __version__
|
|
12
|
+
from .errors.mapping import _map_error_from_envelope
|
|
13
|
+
from .errors.types import APIStatusError, AurikoAPIError, AuthenticationError
|
|
14
|
+
from .response_headers import ResponseHeaders
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
_DEFAULT_BASE_URL = "https://api.auriko.ai/v1"
|
|
19
|
+
_DEFAULT_TIMEOUT = 60.0
|
|
20
|
+
_DEFAULT_MAX_RETRIES = 2
|
|
21
|
+
|
|
22
|
+
_INITIAL_RETRY_DELAY = 0.5
|
|
23
|
+
_RETRY_DELAY_MULTIPLIER = 1.5
|
|
24
|
+
_MAX_RETRY_DELAY = 30.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BaseClient:
|
|
28
|
+
"""Shared configuration and pure-logic helpers for sync and async clients."""
|
|
29
|
+
|
|
30
|
+
api_key: str
|
|
31
|
+
base_url: str
|
|
32
|
+
timeout: float
|
|
33
|
+
max_retries: int
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
api_key: Optional[str] = None,
|
|
39
|
+
base_url: Optional[str] = None,
|
|
40
|
+
timeout: Optional[float] = None,
|
|
41
|
+
max_retries: Optional[int] = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
resolved_key = api_key or os.environ.get("AURIKO_API_KEY")
|
|
44
|
+
if not resolved_key:
|
|
45
|
+
raise AuthenticationError(
|
|
46
|
+
message="API key is missing. Set AURIKO_API_KEY or pass api_key=.",
|
|
47
|
+
status_code=0,
|
|
48
|
+
type="authentication_error",
|
|
49
|
+
code="missing_api_key",
|
|
50
|
+
)
|
|
51
|
+
self.api_key = resolved_key
|
|
52
|
+
|
|
53
|
+
url = (base_url or _DEFAULT_BASE_URL).rstrip("/")
|
|
54
|
+
if not url.endswith("/v1"):
|
|
55
|
+
url += "/v1"
|
|
56
|
+
self.base_url = url + "/"
|
|
57
|
+
|
|
58
|
+
self.timeout = timeout if timeout is not None else _DEFAULT_TIMEOUT
|
|
59
|
+
self.max_retries = max_retries if max_retries is not None else _DEFAULT_MAX_RETRIES
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def _default_headers(self) -> dict[str, str]:
|
|
63
|
+
return {
|
|
64
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
65
|
+
"User-Agent": f"auriko-python/{__version__}",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _calculate_retry_delay(
|
|
70
|
+
attempt: int,
|
|
71
|
+
retry_after_header: Optional[str] = None,
|
|
72
|
+
) -> float:
|
|
73
|
+
delay = _INITIAL_RETRY_DELAY * (_RETRY_DELAY_MULTIPLIER ** attempt)
|
|
74
|
+
delay = min(delay, _MAX_RETRY_DELAY)
|
|
75
|
+
delay *= 0.8 + random.random() * 0.4
|
|
76
|
+
|
|
77
|
+
if retry_after_header is not None:
|
|
78
|
+
try:
|
|
79
|
+
retry_after = float(retry_after_header)
|
|
80
|
+
delay = max(delay, retry_after)
|
|
81
|
+
except (ValueError, OverflowError):
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
return delay
|
|
85
|
+
|
|
86
|
+
def _make_error(
|
|
87
|
+
self,
|
|
88
|
+
status_code: int,
|
|
89
|
+
body: Any,
|
|
90
|
+
*,
|
|
91
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
92
|
+
) -> AurikoAPIError:
|
|
93
|
+
"""Create a typed error from an HTTP error response via strict envelope dispatch."""
|
|
94
|
+
response_headers = ResponseHeaders(headers) if headers is not None else None
|
|
95
|
+
return _map_error_from_envelope(
|
|
96
|
+
status_code, body, response_headers=response_headers,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def _parse_response(self, response: httpx.Response, cast_to: type[T]) -> T:
|
|
100
|
+
"""Parse response JSON and validate against a Pydantic model."""
|
|
101
|
+
try:
|
|
102
|
+
data = response.json()
|
|
103
|
+
except Exception as e:
|
|
104
|
+
raise APIStatusError(
|
|
105
|
+
message="Response body is not valid JSON.",
|
|
106
|
+
status_code=response.status_code,
|
|
107
|
+
type="api_error",
|
|
108
|
+
code="invalid_response",
|
|
109
|
+
response_headers=ResponseHeaders(response.headers),
|
|
110
|
+
) from e
|
|
111
|
+
return cast_to.model_validate(data) # type: ignore[union-attr]
|
auriko/_client.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Auriko API client — sync and async."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._base_client import BaseClient
|
|
11
|
+
from .errors.mapping import is_retryable
|
|
12
|
+
from .errors.types import APIConnectionError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Client(BaseClient):
|
|
16
|
+
"""Synchronous Auriko API client."""
|
|
17
|
+
|
|
18
|
+
_httpx: httpx.Client
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
api_key: Optional[str] = None,
|
|
24
|
+
base_url: Optional[str] = None,
|
|
25
|
+
timeout: Optional[float] = None,
|
|
26
|
+
max_retries: Optional[int] = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
super().__init__(
|
|
29
|
+
api_key=api_key,
|
|
30
|
+
base_url=base_url,
|
|
31
|
+
timeout=timeout,
|
|
32
|
+
max_retries=max_retries,
|
|
33
|
+
)
|
|
34
|
+
self._httpx = httpx.Client(
|
|
35
|
+
base_url=self.base_url,
|
|
36
|
+
headers=self._default_headers,
|
|
37
|
+
timeout=self.timeout,
|
|
38
|
+
follow_redirects=True,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
from .resources.chat import Chat
|
|
42
|
+
from .resources.me import Me
|
|
43
|
+
from .resources.models import Models
|
|
44
|
+
|
|
45
|
+
self.chat = Chat(self)
|
|
46
|
+
self.models = Models(self)
|
|
47
|
+
self.me = Me(self)
|
|
48
|
+
|
|
49
|
+
def _request(
|
|
50
|
+
self,
|
|
51
|
+
method: str,
|
|
52
|
+
path: str,
|
|
53
|
+
*,
|
|
54
|
+
body: Optional[dict[str, Any]] = None,
|
|
55
|
+
params: Optional[dict[str, Any]] = None,
|
|
56
|
+
headers: Optional[dict[str, str]] = None,
|
|
57
|
+
) -> httpx.Response:
|
|
58
|
+
request = self._httpx.build_request(
|
|
59
|
+
method, path, json=body, params=params, headers=headers,
|
|
60
|
+
)
|
|
61
|
+
retries_remaining = self.max_retries
|
|
62
|
+
|
|
63
|
+
while True:
|
|
64
|
+
try:
|
|
65
|
+
response = self._httpx.send(request)
|
|
66
|
+
except httpx.TimeoutException as e:
|
|
67
|
+
if retries_remaining > 0:
|
|
68
|
+
retries_remaining -= 1
|
|
69
|
+
time.sleep(self._calculate_retry_delay(
|
|
70
|
+
self.max_retries - retries_remaining - 1,
|
|
71
|
+
))
|
|
72
|
+
continue
|
|
73
|
+
raise APIConnectionError(
|
|
74
|
+
message="Request timed out before a response was received.",
|
|
75
|
+
cause=e,
|
|
76
|
+
) from e
|
|
77
|
+
except httpx.NetworkError as e:
|
|
78
|
+
if retries_remaining > 0:
|
|
79
|
+
retries_remaining -= 1
|
|
80
|
+
time.sleep(self._calculate_retry_delay(
|
|
81
|
+
self.max_retries - retries_remaining - 1,
|
|
82
|
+
))
|
|
83
|
+
continue
|
|
84
|
+
raise APIConnectionError(cause=e) from e
|
|
85
|
+
except httpx.TransportError as e:
|
|
86
|
+
raise APIConnectionError(cause=e) from e
|
|
87
|
+
|
|
88
|
+
if response.status_code >= 400:
|
|
89
|
+
try:
|
|
90
|
+
error_body = response.json()
|
|
91
|
+
except Exception:
|
|
92
|
+
text = response.text
|
|
93
|
+
error_body = text[:4096] if text else None
|
|
94
|
+
|
|
95
|
+
exc = self._make_error(
|
|
96
|
+
response.status_code, error_body, headers=response.headers,
|
|
97
|
+
)
|
|
98
|
+
if is_retryable(exc) and retries_remaining > 0:
|
|
99
|
+
retries_remaining -= 1
|
|
100
|
+
retry_after = (
|
|
101
|
+
exc.response_headers.get("Retry-After")
|
|
102
|
+
if exc.response_headers is not None
|
|
103
|
+
else None
|
|
104
|
+
)
|
|
105
|
+
response.close()
|
|
106
|
+
time.sleep(self._calculate_retry_delay(
|
|
107
|
+
self.max_retries - retries_remaining - 1,
|
|
108
|
+
retry_after,
|
|
109
|
+
))
|
|
110
|
+
continue
|
|
111
|
+
raise exc
|
|
112
|
+
|
|
113
|
+
return response
|
|
114
|
+
|
|
115
|
+
def _request_stream(
|
|
116
|
+
self,
|
|
117
|
+
method: str,
|
|
118
|
+
path: str,
|
|
119
|
+
*,
|
|
120
|
+
body: Optional[dict[str, Any]] = None,
|
|
121
|
+
params: Optional[dict[str, Any]] = None,
|
|
122
|
+
headers: Optional[dict[str, str]] = None,
|
|
123
|
+
) -> httpx.Response:
|
|
124
|
+
request = self._httpx.build_request(
|
|
125
|
+
method, path, json=body, params=params, headers=headers,
|
|
126
|
+
)
|
|
127
|
+
retries_remaining = self.max_retries
|
|
128
|
+
|
|
129
|
+
while True:
|
|
130
|
+
try:
|
|
131
|
+
response = self._httpx.send(request, stream=True)
|
|
132
|
+
except httpx.TimeoutException as e:
|
|
133
|
+
if retries_remaining > 0:
|
|
134
|
+
retries_remaining -= 1
|
|
135
|
+
time.sleep(self._calculate_retry_delay(
|
|
136
|
+
self.max_retries - retries_remaining - 1,
|
|
137
|
+
))
|
|
138
|
+
continue
|
|
139
|
+
raise APIConnectionError(
|
|
140
|
+
message="Request timed out before a response was received.",
|
|
141
|
+
cause=e,
|
|
142
|
+
) from e
|
|
143
|
+
except httpx.NetworkError as e:
|
|
144
|
+
if retries_remaining > 0:
|
|
145
|
+
retries_remaining -= 1
|
|
146
|
+
time.sleep(self._calculate_retry_delay(
|
|
147
|
+
self.max_retries - retries_remaining - 1,
|
|
148
|
+
))
|
|
149
|
+
continue
|
|
150
|
+
raise APIConnectionError(cause=e) from e
|
|
151
|
+
except httpx.TransportError as e:
|
|
152
|
+
raise APIConnectionError(cause=e) from e
|
|
153
|
+
|
|
154
|
+
if response.status_code >= 400:
|
|
155
|
+
response.read()
|
|
156
|
+
try:
|
|
157
|
+
error_body = response.json()
|
|
158
|
+
except Exception:
|
|
159
|
+
text = response.text
|
|
160
|
+
error_body = text[:4096] if text else None
|
|
161
|
+
|
|
162
|
+
exc = self._make_error(
|
|
163
|
+
response.status_code, error_body, headers=response.headers,
|
|
164
|
+
)
|
|
165
|
+
if is_retryable(exc) and retries_remaining > 0:
|
|
166
|
+
retries_remaining -= 1
|
|
167
|
+
retry_after = (
|
|
168
|
+
exc.response_headers.get("Retry-After")
|
|
169
|
+
if exc.response_headers is not None
|
|
170
|
+
else None
|
|
171
|
+
)
|
|
172
|
+
response.close()
|
|
173
|
+
time.sleep(self._calculate_retry_delay(
|
|
174
|
+
self.max_retries - retries_remaining - 1,
|
|
175
|
+
retry_after,
|
|
176
|
+
))
|
|
177
|
+
continue
|
|
178
|
+
response.close()
|
|
179
|
+
raise exc
|
|
180
|
+
|
|
181
|
+
return response
|
|
182
|
+
|
|
183
|
+
def close(self) -> None:
|
|
184
|
+
self._httpx.close()
|
|
185
|
+
|
|
186
|
+
def __enter__(self) -> Client:
|
|
187
|
+
return self
|
|
188
|
+
|
|
189
|
+
def __exit__(self, *args: object) -> None:
|
|
190
|
+
self.close()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class AsyncClient(BaseClient):
|
|
194
|
+
"""Asynchronous Auriko API client."""
|
|
195
|
+
|
|
196
|
+
_httpx: httpx.AsyncClient
|
|
197
|
+
|
|
198
|
+
def __init__(
|
|
199
|
+
self,
|
|
200
|
+
*,
|
|
201
|
+
api_key: Optional[str] = None,
|
|
202
|
+
base_url: Optional[str] = None,
|
|
203
|
+
timeout: Optional[float] = None,
|
|
204
|
+
max_retries: Optional[int] = None,
|
|
205
|
+
) -> None:
|
|
206
|
+
super().__init__(
|
|
207
|
+
api_key=api_key,
|
|
208
|
+
base_url=base_url,
|
|
209
|
+
timeout=timeout,
|
|
210
|
+
max_retries=max_retries,
|
|
211
|
+
)
|
|
212
|
+
self._httpx = httpx.AsyncClient(
|
|
213
|
+
base_url=self.base_url,
|
|
214
|
+
headers=self._default_headers,
|
|
215
|
+
timeout=self.timeout,
|
|
216
|
+
follow_redirects=True,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
from .resources.chat import AsyncChat
|
|
220
|
+
from .resources.me import AsyncMe
|
|
221
|
+
from .resources.models import AsyncModels
|
|
222
|
+
|
|
223
|
+
self.chat = AsyncChat(self)
|
|
224
|
+
self.models = AsyncModels(self)
|
|
225
|
+
self.me = AsyncMe(self)
|
|
226
|
+
|
|
227
|
+
async def _request(
|
|
228
|
+
self,
|
|
229
|
+
method: str,
|
|
230
|
+
path: str,
|
|
231
|
+
*,
|
|
232
|
+
body: Optional[dict[str, Any]] = None,
|
|
233
|
+
params: Optional[dict[str, Any]] = None,
|
|
234
|
+
headers: Optional[dict[str, str]] = None,
|
|
235
|
+
) -> httpx.Response:
|
|
236
|
+
import anyio
|
|
237
|
+
|
|
238
|
+
request = self._httpx.build_request(
|
|
239
|
+
method, path, json=body, params=params, headers=headers,
|
|
240
|
+
)
|
|
241
|
+
retries_remaining = self.max_retries
|
|
242
|
+
|
|
243
|
+
while True:
|
|
244
|
+
try:
|
|
245
|
+
response = await self._httpx.send(request)
|
|
246
|
+
except httpx.TimeoutException as e:
|
|
247
|
+
if retries_remaining > 0:
|
|
248
|
+
retries_remaining -= 1
|
|
249
|
+
await anyio.sleep(self._calculate_retry_delay(
|
|
250
|
+
self.max_retries - retries_remaining - 1,
|
|
251
|
+
))
|
|
252
|
+
continue
|
|
253
|
+
raise APIConnectionError(
|
|
254
|
+
message="Request timed out before a response was received.",
|
|
255
|
+
cause=e,
|
|
256
|
+
) from e
|
|
257
|
+
except httpx.NetworkError as e:
|
|
258
|
+
if retries_remaining > 0:
|
|
259
|
+
retries_remaining -= 1
|
|
260
|
+
await anyio.sleep(self._calculate_retry_delay(
|
|
261
|
+
self.max_retries - retries_remaining - 1,
|
|
262
|
+
))
|
|
263
|
+
continue
|
|
264
|
+
raise APIConnectionError(cause=e) from e
|
|
265
|
+
except httpx.TransportError as e:
|
|
266
|
+
raise APIConnectionError(cause=e) from e
|
|
267
|
+
|
|
268
|
+
if response.status_code >= 400:
|
|
269
|
+
try:
|
|
270
|
+
error_body = response.json()
|
|
271
|
+
except Exception:
|
|
272
|
+
text = response.text
|
|
273
|
+
error_body = text[:4096] if text else None
|
|
274
|
+
|
|
275
|
+
exc = self._make_error(
|
|
276
|
+
response.status_code, error_body, headers=response.headers,
|
|
277
|
+
)
|
|
278
|
+
if is_retryable(exc) and retries_remaining > 0:
|
|
279
|
+
retries_remaining -= 1
|
|
280
|
+
retry_after = (
|
|
281
|
+
exc.response_headers.get("Retry-After")
|
|
282
|
+
if exc.response_headers is not None
|
|
283
|
+
else None
|
|
284
|
+
)
|
|
285
|
+
await response.aclose()
|
|
286
|
+
await anyio.sleep(self._calculate_retry_delay(
|
|
287
|
+
self.max_retries - retries_remaining - 1,
|
|
288
|
+
retry_after,
|
|
289
|
+
))
|
|
290
|
+
continue
|
|
291
|
+
raise exc
|
|
292
|
+
|
|
293
|
+
return response
|
|
294
|
+
|
|
295
|
+
async def _request_stream(
|
|
296
|
+
self,
|
|
297
|
+
method: str,
|
|
298
|
+
path: str,
|
|
299
|
+
*,
|
|
300
|
+
body: Optional[dict[str, Any]] = None,
|
|
301
|
+
params: Optional[dict[str, Any]] = None,
|
|
302
|
+
headers: Optional[dict[str, str]] = None,
|
|
303
|
+
) -> httpx.Response:
|
|
304
|
+
import anyio
|
|
305
|
+
|
|
306
|
+
request = self._httpx.build_request(
|
|
307
|
+
method, path, json=body, params=params, headers=headers,
|
|
308
|
+
)
|
|
309
|
+
retries_remaining = self.max_retries
|
|
310
|
+
|
|
311
|
+
while True:
|
|
312
|
+
try:
|
|
313
|
+
response = await self._httpx.send(request, stream=True)
|
|
314
|
+
except httpx.TimeoutException as e:
|
|
315
|
+
if retries_remaining > 0:
|
|
316
|
+
retries_remaining -= 1
|
|
317
|
+
await anyio.sleep(self._calculate_retry_delay(
|
|
318
|
+
self.max_retries - retries_remaining - 1,
|
|
319
|
+
))
|
|
320
|
+
continue
|
|
321
|
+
raise APIConnectionError(
|
|
322
|
+
message="Request timed out before a response was received.",
|
|
323
|
+
cause=e,
|
|
324
|
+
) from e
|
|
325
|
+
except httpx.NetworkError as e:
|
|
326
|
+
if retries_remaining > 0:
|
|
327
|
+
retries_remaining -= 1
|
|
328
|
+
await anyio.sleep(self._calculate_retry_delay(
|
|
329
|
+
self.max_retries - retries_remaining - 1,
|
|
330
|
+
))
|
|
331
|
+
continue
|
|
332
|
+
raise APIConnectionError(cause=e) from e
|
|
333
|
+
except httpx.TransportError as e:
|
|
334
|
+
raise APIConnectionError(cause=e) from e
|
|
335
|
+
|
|
336
|
+
if response.status_code >= 400:
|
|
337
|
+
await response.aread()
|
|
338
|
+
try:
|
|
339
|
+
error_body = response.json()
|
|
340
|
+
except Exception:
|
|
341
|
+
text = response.text
|
|
342
|
+
error_body = text[:4096] if text else None
|
|
343
|
+
|
|
344
|
+
exc = self._make_error(
|
|
345
|
+
response.status_code, error_body, headers=response.headers,
|
|
346
|
+
)
|
|
347
|
+
if is_retryable(exc) and retries_remaining > 0:
|
|
348
|
+
retries_remaining -= 1
|
|
349
|
+
retry_after = (
|
|
350
|
+
exc.response_headers.get("Retry-After")
|
|
351
|
+
if exc.response_headers is not None
|
|
352
|
+
else None
|
|
353
|
+
)
|
|
354
|
+
await response.aclose()
|
|
355
|
+
await anyio.sleep(self._calculate_retry_delay(
|
|
356
|
+
self.max_retries - retries_remaining - 1,
|
|
357
|
+
retry_after,
|
|
358
|
+
))
|
|
359
|
+
continue
|
|
360
|
+
await response.aclose()
|
|
361
|
+
raise exc
|
|
362
|
+
|
|
363
|
+
return response
|
|
364
|
+
|
|
365
|
+
async def close(self) -> None:
|
|
366
|
+
await self._httpx.aclose()
|
|
367
|
+
|
|
368
|
+
async def __aenter__(self) -> AsyncClient:
|
|
369
|
+
return self
|
|
370
|
+
|
|
371
|
+
async def __aexit__(self, *args: object) -> None:
|
|
372
|
+
await self.close()
|
auriko/_openai_client.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""AurikoAsyncOpenAI — AsyncOpenAI subclass with automatic routing metadata capture and error mapping."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
from collections.abc import AsyncIterator
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import openai
|
|
14
|
+
from openai import AsyncOpenAI, DefaultAsyncHttpxClient
|
|
15
|
+
|
|
16
|
+
from .errors.openai_bridge import make_bridge_error
|
|
17
|
+
from .errors.types import AuthenticationError
|
|
18
|
+
from .route_types import RoutingMetadata
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _resolve_api_key(api_key: str | None) -> str:
|
|
24
|
+
"""Resolve API key: explicit arg > AURIKO_API_KEY env > raise.
|
|
25
|
+
|
|
26
|
+
Does NOT fall back to OPENAI_API_KEY — consistent with
|
|
27
|
+
_base_client.py:43-53 and SDK_STRATEGY.md.
|
|
28
|
+
"""
|
|
29
|
+
resolved = api_key or os.environ.get("AURIKO_API_KEY")
|
|
30
|
+
if not resolved:
|
|
31
|
+
raise AuthenticationError(
|
|
32
|
+
message="API key is missing. Set AURIKO_API_KEY or pass api_key=.",
|
|
33
|
+
status_code=0,
|
|
34
|
+
type="authentication_error",
|
|
35
|
+
code="missing_api_key",
|
|
36
|
+
)
|
|
37
|
+
return resolved
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _MetadataCapturingStream(httpx.AsyncByteStream):
|
|
41
|
+
"""Wraps an SSE byte stream to capture routing_metadata during iteration.
|
|
42
|
+
|
|
43
|
+
Yields every chunk immediately (zero buffering, no TTFT impact).
|
|
44
|
+
Scans accumulated buffer for the metadata event on natural completion
|
|
45
|
+
or on aclose() (which fires first when the OpenAI SDK breaks on [DONE]).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
inner: httpx.AsyncByteStream,
|
|
51
|
+
on_metadata: Callable[[RoutingMetadata], None],
|
|
52
|
+
) -> None:
|
|
53
|
+
self._inner = inner
|
|
54
|
+
self._on_metadata = on_metadata
|
|
55
|
+
self._buffer = bytearray()
|
|
56
|
+
self._extracted = False
|
|
57
|
+
|
|
58
|
+
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
59
|
+
async for chunk in self._inner:
|
|
60
|
+
self._buffer.extend(chunk)
|
|
61
|
+
yield chunk
|
|
62
|
+
self._extract_metadata()
|
|
63
|
+
|
|
64
|
+
async def aclose(self) -> None:
|
|
65
|
+
self._extract_metadata()
|
|
66
|
+
await self._inner.aclose()
|
|
67
|
+
|
|
68
|
+
def _extract_metadata(self) -> None:
|
|
69
|
+
"""Scan buffer for the last data: event before [DONE], extract routing_metadata."""
|
|
70
|
+
if self._extracted:
|
|
71
|
+
return
|
|
72
|
+
self._extracted = True
|
|
73
|
+
try:
|
|
74
|
+
text = self._buffer.decode("utf-8", errors="replace")
|
|
75
|
+
last_data: str | None = None
|
|
76
|
+
for line in text.splitlines():
|
|
77
|
+
stripped = line.strip()
|
|
78
|
+
if stripped.startswith("data: ") and stripped != "data: [DONE]":
|
|
79
|
+
last_data = stripped[6:]
|
|
80
|
+
if last_data is None:
|
|
81
|
+
return
|
|
82
|
+
payload = json.loads(last_data)
|
|
83
|
+
raw_metadata = payload.get("routing_metadata")
|
|
84
|
+
if raw_metadata is None:
|
|
85
|
+
return
|
|
86
|
+
metadata = RoutingMetadata.model_validate(raw_metadata)
|
|
87
|
+
self._on_metadata(metadata)
|
|
88
|
+
except Exception:
|
|
89
|
+
logger.debug("Failed to extract routing_metadata from SSE stream", exc_info=True)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class AurikoAsyncOpenAI(AsyncOpenAI):
|
|
93
|
+
"""**Experimental.** AsyncOpenAI subclass with automatic routing metadata capture and error mapping.
|
|
94
|
+
|
|
95
|
+
Most users should use ``auriko.AsyncClient`` (native SDK) or ``AsyncOpenAI(base_url=...)``
|
|
96
|
+
(plain OpenAI-compatible). Use ``AurikoAsyncOpenAI`` only when passing an OpenAI client to
|
|
97
|
+
a framework that discards routing metadata from responses.
|
|
98
|
+
|
|
99
|
+
Usage::
|
|
100
|
+
|
|
101
|
+
client = AurikoAsyncOpenAI(api_key="ak-...")
|
|
102
|
+
response = await client.chat.completions.create(
|
|
103
|
+
model="gpt-4o-mini",
|
|
104
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
105
|
+
)
|
|
106
|
+
print(client.last_routing_metadata)
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
*,
|
|
112
|
+
api_key: str | None = None,
|
|
113
|
+
base_url: str = "https://api.auriko.ai/v1",
|
|
114
|
+
on_response: Callable[[RoutingMetadata], Any] | None = None,
|
|
115
|
+
**kwargs: Any,
|
|
116
|
+
) -> None:
|
|
117
|
+
if "http_client" in kwargs:
|
|
118
|
+
raise TypeError(
|
|
119
|
+
"AurikoAsyncOpenAI does not accept http_client. "
|
|
120
|
+
"Remove it or use AsyncOpenAI(base_url=...) directly."
|
|
121
|
+
)
|
|
122
|
+
if on_response is not None and asyncio.iscoroutinefunction(on_response):
|
|
123
|
+
raise TypeError("on_response must be a sync callable, not async")
|
|
124
|
+
|
|
125
|
+
resolved_key = _resolve_api_key(api_key)
|
|
126
|
+
|
|
127
|
+
self._last_metadata: RoutingMetadata | None = None
|
|
128
|
+
self._on_response = on_response
|
|
129
|
+
|
|
130
|
+
async def _request_hook(request: httpx.Request) -> None:
|
|
131
|
+
self._last_metadata = None
|
|
132
|
+
|
|
133
|
+
def _deliver_metadata(metadata: RoutingMetadata) -> None:
|
|
134
|
+
self._last_metadata = metadata
|
|
135
|
+
if self._on_response is not None:
|
|
136
|
+
try:
|
|
137
|
+
self._on_response(metadata)
|
|
138
|
+
except Exception:
|
|
139
|
+
logger.debug("on_response callback raised", exc_info=True)
|
|
140
|
+
|
|
141
|
+
async def _response_hook(response: httpx.Response) -> None:
|
|
142
|
+
try:
|
|
143
|
+
if response.status_code >= 400:
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
content_type = response.headers.get("content-type", "")
|
|
147
|
+
|
|
148
|
+
if "text/event-stream" in content_type:
|
|
149
|
+
response.stream = _MetadataCapturingStream(
|
|
150
|
+
response.stream, _deliver_metadata,
|
|
151
|
+
)
|
|
152
|
+
else:
|
|
153
|
+
await response.aread()
|
|
154
|
+
body = response.json()
|
|
155
|
+
raw_metadata = body.get("routing_metadata")
|
|
156
|
+
if raw_metadata is not None:
|
|
157
|
+
metadata = RoutingMetadata.model_validate(raw_metadata)
|
|
158
|
+
_deliver_metadata(metadata)
|
|
159
|
+
except Exception:
|
|
160
|
+
logger.debug(
|
|
161
|
+
"Failed to capture routing_metadata from response",
|
|
162
|
+
exc_info=True,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
http_client = DefaultAsyncHttpxClient(
|
|
166
|
+
event_hooks={
|
|
167
|
+
"request": [_request_hook],
|
|
168
|
+
"response": [_response_hook],
|
|
169
|
+
},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
super().__init__(
|
|
173
|
+
api_key=resolved_key,
|
|
174
|
+
base_url=base_url,
|
|
175
|
+
http_client=http_client,
|
|
176
|
+
**kwargs,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def _make_status_error(
|
|
180
|
+
self,
|
|
181
|
+
err_msg: str,
|
|
182
|
+
*,
|
|
183
|
+
body: object,
|
|
184
|
+
response: httpx.Response,
|
|
185
|
+
) -> openai.APIStatusError:
|
|
186
|
+
openai_error = super()._make_status_error(err_msg, body=body, response=response)
|
|
187
|
+
return make_bridge_error(openai_error, response=response, raw_body=body)
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def last_routing_metadata(self) -> RoutingMetadata | None:
|
|
191
|
+
"""Routing metadata from the most recent successful response.
|
|
192
|
+
|
|
193
|
+
Returns None if no metadata is available (before any request, after an error,
|
|
194
|
+
or if the response did not contain routing_metadata).
|
|
195
|
+
"""
|
|
196
|
+
return self._last_metadata
|