bimpeai 0.1.0.dev3__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.
- bimpeai/__init__.py +82 -0
- bimpeai/_async_client.py +151 -0
- bimpeai/_base_client.py +94 -0
- bimpeai/_client.py +151 -0
- bimpeai/_exceptions.py +195 -0
- bimpeai/_idempotency.py +11 -0
- bimpeai/_models.py +46 -0
- bimpeai/_request.py +31 -0
- bimpeai/_request_id.py +5 -0
- bimpeai/_retries.py +46 -0
- bimpeai/_sse.py +83 -0
- bimpeai/_version.py +6 -0
- bimpeai/pagination.py +84 -0
- bimpeai/py.typed +0 -0
- bimpeai/resources/__init__.py +0 -0
- bimpeai/resources/_specs.py +58 -0
- bimpeai/resources/agents.py +290 -0
- bimpeai/resources/calls.py +27 -0
- bimpeai/resources/conversations.py +361 -0
- bimpeai/resources/workflows.py +151 -0
- bimpeai/types/__init__.py +0 -0
- bimpeai/types/agents.py +176 -0
- bimpeai/types/calls.py +11 -0
- bimpeai/types/conversations.py +73 -0
- bimpeai/types/workflows.py +56 -0
- bimpeai-0.1.0.dev3.dist-info/METADATA +301 -0
- bimpeai-0.1.0.dev3.dist-info/RECORD +28 -0
- bimpeai-0.1.0.dev3.dist-info/WHEEL +4 -0
bimpeai/__init__.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from ._async_client import AsyncBimpeAI
|
|
2
|
+
from ._client import BimpeAI
|
|
3
|
+
from ._exceptions import (
|
|
4
|
+
APIConnectionError,
|
|
5
|
+
APIError,
|
|
6
|
+
APINotImplementedError,
|
|
7
|
+
APITimeoutError,
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
BadRequestError,
|
|
10
|
+
BimpeAIError,
|
|
11
|
+
ConflictError,
|
|
12
|
+
ErrorCode,
|
|
13
|
+
InternalServerError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
PermissionDeniedError,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
UserError,
|
|
18
|
+
ValidationError,
|
|
19
|
+
)
|
|
20
|
+
from ._models import ApiResponse, PaginationMeta
|
|
21
|
+
from ._version import __version__
|
|
22
|
+
from .pagination import AsyncPage, Page
|
|
23
|
+
from .types.agents import (
|
|
24
|
+
Agent,
|
|
25
|
+
AgentAction,
|
|
26
|
+
AgentDetail,
|
|
27
|
+
Channel,
|
|
28
|
+
ConversationFlow,
|
|
29
|
+
Integration,
|
|
30
|
+
KnowledgeBase,
|
|
31
|
+
Rule,
|
|
32
|
+
)
|
|
33
|
+
from .types.calls import Call
|
|
34
|
+
from .types.conversations import (
|
|
35
|
+
Conversation,
|
|
36
|
+
Message,
|
|
37
|
+
StreamHeartbeatEvent,
|
|
38
|
+
StreamMessageEvent,
|
|
39
|
+
StreamTicket,
|
|
40
|
+
)
|
|
41
|
+
from .types.workflows import Workflow, WorkflowSummary
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"AsyncBimpeAI",
|
|
45
|
+
"AsyncPage",
|
|
46
|
+
"Agent",
|
|
47
|
+
"AgentAction",
|
|
48
|
+
"AgentDetail",
|
|
49
|
+
"ApiResponse",
|
|
50
|
+
"APIConnectionError",
|
|
51
|
+
"APIError",
|
|
52
|
+
"APINotImplementedError",
|
|
53
|
+
"APITimeoutError",
|
|
54
|
+
"AuthenticationError",
|
|
55
|
+
"BadRequestError",
|
|
56
|
+
"BimpeAI",
|
|
57
|
+
"BimpeAIError",
|
|
58
|
+
"Call",
|
|
59
|
+
"Channel",
|
|
60
|
+
"ConflictError",
|
|
61
|
+
"Conversation",
|
|
62
|
+
"ConversationFlow",
|
|
63
|
+
"ErrorCode",
|
|
64
|
+
"Integration",
|
|
65
|
+
"InternalServerError",
|
|
66
|
+
"KnowledgeBase",
|
|
67
|
+
"Message",
|
|
68
|
+
"NotFoundError",
|
|
69
|
+
"Page",
|
|
70
|
+
"PaginationMeta",
|
|
71
|
+
"PermissionDeniedError",
|
|
72
|
+
"RateLimitError",
|
|
73
|
+
"Rule",
|
|
74
|
+
"StreamHeartbeatEvent",
|
|
75
|
+
"StreamMessageEvent",
|
|
76
|
+
"StreamTicket",
|
|
77
|
+
"UserError",
|
|
78
|
+
"ValidationError",
|
|
79
|
+
"Workflow",
|
|
80
|
+
"WorkflowSummary",
|
|
81
|
+
"__version__",
|
|
82
|
+
]
|
bimpeai/_async_client.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._base_client import BaseClient, safe_json
|
|
11
|
+
from ._exceptions import (
|
|
12
|
+
APIConnectionError,
|
|
13
|
+
APITimeoutError,
|
|
14
|
+
BimpeAIError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
map_api_error,
|
|
17
|
+
)
|
|
18
|
+
from ._idempotency import resolve_idempotency_key
|
|
19
|
+
from ._models import ApiResponse
|
|
20
|
+
from ._request import RequestSpec, StreamSpec
|
|
21
|
+
from ._request_id import generate_request_id
|
|
22
|
+
from ._retries import compute_backoff, should_retry
|
|
23
|
+
from .resources.agents import AsyncAgents
|
|
24
|
+
from .resources.calls import AsyncCalls
|
|
25
|
+
from .resources.conversations import AsyncConversations
|
|
26
|
+
from .resources.workflows import AsyncWorkflows
|
|
27
|
+
|
|
28
|
+
_WRITE_METHODS = frozenset({"POST", "PATCH", "PUT", "DELETE"})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AsyncBimpeAI(BaseClient):
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
api_key: str,
|
|
36
|
+
base_url: str | None = None,
|
|
37
|
+
timeout: float = 30.0,
|
|
38
|
+
max_retries: int = 2,
|
|
39
|
+
default_headers: dict[str, str] | None = None,
|
|
40
|
+
http_client: httpx.AsyncClient | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
super().__init__(
|
|
43
|
+
api_key=api_key,
|
|
44
|
+
base_url=base_url,
|
|
45
|
+
timeout=timeout,
|
|
46
|
+
max_retries=max_retries,
|
|
47
|
+
default_headers=default_headers,
|
|
48
|
+
)
|
|
49
|
+
self._http = http_client if http_client is not None else httpx.AsyncClient()
|
|
50
|
+
self._owns_http = http_client is None
|
|
51
|
+
self.agents = AsyncAgents(self)
|
|
52
|
+
self.workflows = AsyncWorkflows(self)
|
|
53
|
+
self.conversations = AsyncConversations(self)
|
|
54
|
+
self.calls = AsyncCalls(self)
|
|
55
|
+
|
|
56
|
+
async def request(self, spec: RequestSpec) -> ApiResponse[Any]:
|
|
57
|
+
url = self.build_url(spec.path)
|
|
58
|
+
params = self.clean_params(spec.query)
|
|
59
|
+
options = spec.options
|
|
60
|
+
max_retries = self._max_retries if options.max_retries is None else options.max_retries
|
|
61
|
+
timeout = self._timeout if options.timeout is None else options.timeout
|
|
62
|
+
idempotency_key = (
|
|
63
|
+
resolve_idempotency_key(options.idempotency_key, max_retries)
|
|
64
|
+
if spec.method in _WRITE_METHODS
|
|
65
|
+
else None
|
|
66
|
+
)
|
|
67
|
+
request_id = _resolve_request_id(options.headers)
|
|
68
|
+
|
|
69
|
+
attempt = 0
|
|
70
|
+
while True:
|
|
71
|
+
try:
|
|
72
|
+
return await self._send(spec, url, params, idempotency_key, request_id, timeout)
|
|
73
|
+
except BimpeAIError as error:
|
|
74
|
+
if not should_retry(error, attempt, max_retries):
|
|
75
|
+
raise
|
|
76
|
+
retry_after = error.retry_after if isinstance(error, RateLimitError) else None
|
|
77
|
+
await asyncio.sleep(compute_backoff(attempt, retry_after_s=retry_after))
|
|
78
|
+
attempt += 1
|
|
79
|
+
|
|
80
|
+
async def _send(
|
|
81
|
+
self,
|
|
82
|
+
spec: RequestSpec,
|
|
83
|
+
url: str,
|
|
84
|
+
params: dict[str, str],
|
|
85
|
+
idempotency_key: str | None,
|
|
86
|
+
request_id: str,
|
|
87
|
+
timeout: float,
|
|
88
|
+
) -> ApiResponse[Any]:
|
|
89
|
+
headers = self.build_headers(
|
|
90
|
+
has_body=spec.body is not None,
|
|
91
|
+
idempotency_key=idempotency_key,
|
|
92
|
+
request_id=request_id,
|
|
93
|
+
extra=spec.options.headers,
|
|
94
|
+
)
|
|
95
|
+
try:
|
|
96
|
+
response = await self._http.request(
|
|
97
|
+
spec.method,
|
|
98
|
+
url,
|
|
99
|
+
params=params or None,
|
|
100
|
+
json=spec.body if spec.body is not None else None,
|
|
101
|
+
headers=headers,
|
|
102
|
+
timeout=timeout,
|
|
103
|
+
)
|
|
104
|
+
except httpx.TimeoutException as exc:
|
|
105
|
+
raise APITimeoutError(cause=exc) from exc
|
|
106
|
+
except httpx.RequestError as exc:
|
|
107
|
+
raise APIConnectionError("network error", cause=exc) from exc
|
|
108
|
+
return self.parse_response(response, request_id)
|
|
109
|
+
|
|
110
|
+
@asynccontextmanager
|
|
111
|
+
async def stream(self, spec: StreamSpec) -> AsyncGenerator[httpx.Response, None]:
|
|
112
|
+
url = self.build_url(spec.path)
|
|
113
|
+
params = self.clean_params(spec.query)
|
|
114
|
+
headers = httpx.Headers(self._default_headers)
|
|
115
|
+
headers["Accept"] = "text/event-stream"
|
|
116
|
+
headers["User-Agent"] = self._user_agent
|
|
117
|
+
if spec.headers:
|
|
118
|
+
for key, value in spec.headers.items():
|
|
119
|
+
headers[key] = value
|
|
120
|
+
try:
|
|
121
|
+
async with self._http.stream(
|
|
122
|
+
"GET", url, params=params or None, headers=headers, timeout=spec.timeout
|
|
123
|
+
) as response:
|
|
124
|
+
if not response.is_success:
|
|
125
|
+
body = await response.aread()
|
|
126
|
+
raise map_api_error(
|
|
127
|
+
response.status_code, safe_json(body.decode() or ""), response.headers
|
|
128
|
+
)
|
|
129
|
+
yield response
|
|
130
|
+
except httpx.TimeoutException as exc:
|
|
131
|
+
raise APITimeoutError(cause=exc) from exc
|
|
132
|
+
except httpx.RequestError as exc:
|
|
133
|
+
raise APIConnectionError("stream aborted", cause=exc) from exc
|
|
134
|
+
|
|
135
|
+
async def aclose(self) -> None:
|
|
136
|
+
if self._owns_http:
|
|
137
|
+
await self._http.aclose()
|
|
138
|
+
|
|
139
|
+
async def __aenter__(self) -> AsyncBimpeAI:
|
|
140
|
+
return self
|
|
141
|
+
|
|
142
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
143
|
+
await self.aclose()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _resolve_request_id(extra: dict[str, str] | None) -> str:
|
|
147
|
+
if extra:
|
|
148
|
+
for key, value in extra.items():
|
|
149
|
+
if key.lower() == "x-request-id":
|
|
150
|
+
return value
|
|
151
|
+
return generate_request_id()
|
bimpeai/_base_client.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import platform
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ._exceptions import UserError, map_api_error
|
|
10
|
+
from ._models import ApiResponse, unwrap_envelope
|
|
11
|
+
from ._version import __version__
|
|
12
|
+
|
|
13
|
+
API_PATH_PREFIX = "/api/v1/console"
|
|
14
|
+
DEFAULT_BASE_URL = "https://api.bimpe.ai"
|
|
15
|
+
DEFAULT_TIMEOUT = 30.0
|
|
16
|
+
DEFAULT_MAX_RETRIES = 2
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BaseClient:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
api_key: str,
|
|
24
|
+
base_url: str | None = None,
|
|
25
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
26
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
27
|
+
default_headers: dict[str, str] | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
if not api_key:
|
|
30
|
+
raise UserError("api_key is required")
|
|
31
|
+
self._api_key = api_key
|
|
32
|
+
self._base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
33
|
+
self._timeout = timeout
|
|
34
|
+
self._max_retries = max_retries
|
|
35
|
+
self._default_headers = dict(default_headers or {})
|
|
36
|
+
self._user_agent = _build_user_agent()
|
|
37
|
+
|
|
38
|
+
def build_url(self, path: str) -> str:
|
|
39
|
+
return f"{self._base_url}{API_PATH_PREFIX}{path}"
|
|
40
|
+
|
|
41
|
+
def clean_params(self, query: dict[str, Any] | None) -> dict[str, str]:
|
|
42
|
+
out: dict[str, str] = {}
|
|
43
|
+
for key, value in (query or {}).items():
|
|
44
|
+
if value is None:
|
|
45
|
+
continue
|
|
46
|
+
out[key] = "true" if value is True else "false" if value is False else str(value)
|
|
47
|
+
return out
|
|
48
|
+
|
|
49
|
+
def build_headers(
|
|
50
|
+
self,
|
|
51
|
+
*,
|
|
52
|
+
has_body: bool,
|
|
53
|
+
idempotency_key: str | None,
|
|
54
|
+
request_id: str,
|
|
55
|
+
extra: dict[str, str] | None,
|
|
56
|
+
) -> httpx.Headers:
|
|
57
|
+
headers = httpx.Headers(self._default_headers)
|
|
58
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
59
|
+
headers["Accept"] = "application/json"
|
|
60
|
+
headers["User-Agent"] = self._user_agent
|
|
61
|
+
headers["X-Request-Id"] = request_id
|
|
62
|
+
if has_body:
|
|
63
|
+
headers["Content-Type"] = "application/json"
|
|
64
|
+
if idempotency_key:
|
|
65
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
66
|
+
if extra:
|
|
67
|
+
for key, value in extra.items():
|
|
68
|
+
headers[key] = value
|
|
69
|
+
return headers
|
|
70
|
+
|
|
71
|
+
def parse_response(self, response: httpx.Response, request_id: str) -> ApiResponse[Any]:
|
|
72
|
+
text = response.text
|
|
73
|
+
parsed = safe_json(text) if text else None
|
|
74
|
+
if not response.is_success:
|
|
75
|
+
raise map_api_error(response.status_code, parsed, response.headers)
|
|
76
|
+
unwrapped = unwrap_envelope(parsed)
|
|
77
|
+
return ApiResponse(
|
|
78
|
+
data=unwrapped.data,
|
|
79
|
+
meta=unwrapped.meta,
|
|
80
|
+
request_id=response.headers.get("x-request-id") or request_id,
|
|
81
|
+
status=response.status_code,
|
|
82
|
+
headers=response.headers,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _build_user_agent() -> str:
|
|
87
|
+
return f"bimpeai-python/{__version__} (Python/{platform.python_version()}; {platform.system()})"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def safe_json(text: str) -> Any:
|
|
91
|
+
try:
|
|
92
|
+
return json.loads(text)
|
|
93
|
+
except ValueError:
|
|
94
|
+
return None
|
bimpeai/_client.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Generator
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._base_client import BaseClient, safe_json
|
|
11
|
+
from ._exceptions import (
|
|
12
|
+
APIConnectionError,
|
|
13
|
+
APITimeoutError,
|
|
14
|
+
BimpeAIError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
map_api_error,
|
|
17
|
+
)
|
|
18
|
+
from ._idempotency import resolve_idempotency_key
|
|
19
|
+
from ._models import ApiResponse
|
|
20
|
+
from ._request import RequestSpec, StreamSpec
|
|
21
|
+
from ._request_id import generate_request_id
|
|
22
|
+
from ._retries import compute_backoff, should_retry
|
|
23
|
+
from .resources.agents import Agents
|
|
24
|
+
from .resources.calls import Calls
|
|
25
|
+
from .resources.conversations import Conversations
|
|
26
|
+
from .resources.workflows import Workflows
|
|
27
|
+
|
|
28
|
+
_WRITE_METHODS = frozenset({"POST", "PATCH", "PUT", "DELETE"})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BimpeAI(BaseClient):
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
api_key: str,
|
|
36
|
+
base_url: str | None = None,
|
|
37
|
+
timeout: float = 30.0,
|
|
38
|
+
max_retries: int = 2,
|
|
39
|
+
default_headers: dict[str, str] | None = None,
|
|
40
|
+
http_client: httpx.Client | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
super().__init__(
|
|
43
|
+
api_key=api_key,
|
|
44
|
+
base_url=base_url,
|
|
45
|
+
timeout=timeout,
|
|
46
|
+
max_retries=max_retries,
|
|
47
|
+
default_headers=default_headers,
|
|
48
|
+
)
|
|
49
|
+
self._http = http_client if http_client is not None else httpx.Client()
|
|
50
|
+
self._owns_http = http_client is None
|
|
51
|
+
self.agents = Agents(self)
|
|
52
|
+
self.workflows = Workflows(self)
|
|
53
|
+
self.conversations = Conversations(self)
|
|
54
|
+
self.calls = Calls(self)
|
|
55
|
+
|
|
56
|
+
def request(self, spec: RequestSpec) -> ApiResponse[Any]:
|
|
57
|
+
url = self.build_url(spec.path)
|
|
58
|
+
params = self.clean_params(spec.query)
|
|
59
|
+
options = spec.options
|
|
60
|
+
max_retries = self._max_retries if options.max_retries is None else options.max_retries
|
|
61
|
+
timeout = self._timeout if options.timeout is None else options.timeout
|
|
62
|
+
idempotency_key = (
|
|
63
|
+
resolve_idempotency_key(options.idempotency_key, max_retries)
|
|
64
|
+
if spec.method in _WRITE_METHODS
|
|
65
|
+
else None
|
|
66
|
+
)
|
|
67
|
+
request_id = _resolve_request_id(options.headers)
|
|
68
|
+
|
|
69
|
+
attempt = 0
|
|
70
|
+
while True:
|
|
71
|
+
try:
|
|
72
|
+
return self._send(spec, url, params, idempotency_key, request_id, timeout)
|
|
73
|
+
except BimpeAIError as error:
|
|
74
|
+
if not should_retry(error, attempt, max_retries):
|
|
75
|
+
raise
|
|
76
|
+
retry_after = error.retry_after if isinstance(error, RateLimitError) else None
|
|
77
|
+
time.sleep(compute_backoff(attempt, retry_after_s=retry_after))
|
|
78
|
+
attempt += 1
|
|
79
|
+
|
|
80
|
+
def _send(
|
|
81
|
+
self,
|
|
82
|
+
spec: RequestSpec,
|
|
83
|
+
url: str,
|
|
84
|
+
params: dict[str, str],
|
|
85
|
+
idempotency_key: str | None,
|
|
86
|
+
request_id: str,
|
|
87
|
+
timeout: float,
|
|
88
|
+
) -> ApiResponse[Any]:
|
|
89
|
+
headers = self.build_headers(
|
|
90
|
+
has_body=spec.body is not None,
|
|
91
|
+
idempotency_key=idempotency_key,
|
|
92
|
+
request_id=request_id,
|
|
93
|
+
extra=spec.options.headers,
|
|
94
|
+
)
|
|
95
|
+
try:
|
|
96
|
+
response = self._http.request(
|
|
97
|
+
spec.method,
|
|
98
|
+
url,
|
|
99
|
+
params=params or None,
|
|
100
|
+
json=spec.body if spec.body is not None else None,
|
|
101
|
+
headers=headers,
|
|
102
|
+
timeout=timeout,
|
|
103
|
+
)
|
|
104
|
+
except httpx.TimeoutException as exc:
|
|
105
|
+
raise APITimeoutError(cause=exc) from exc
|
|
106
|
+
except httpx.RequestError as exc:
|
|
107
|
+
raise APIConnectionError("network error", cause=exc) from exc
|
|
108
|
+
return self.parse_response(response, request_id)
|
|
109
|
+
|
|
110
|
+
@contextmanager
|
|
111
|
+
def stream(self, spec: StreamSpec) -> Generator[httpx.Response, None, None]:
|
|
112
|
+
url = self.build_url(spec.path)
|
|
113
|
+
params = self.clean_params(spec.query)
|
|
114
|
+
headers = httpx.Headers(self._default_headers)
|
|
115
|
+
headers["Accept"] = "text/event-stream"
|
|
116
|
+
headers["User-Agent"] = self._user_agent
|
|
117
|
+
if spec.headers:
|
|
118
|
+
for key, value in spec.headers.items():
|
|
119
|
+
headers[key] = value
|
|
120
|
+
try:
|
|
121
|
+
with self._http.stream(
|
|
122
|
+
"GET", url, params=params or None, headers=headers, timeout=spec.timeout
|
|
123
|
+
) as response:
|
|
124
|
+
if not response.is_success:
|
|
125
|
+
body = response.read()
|
|
126
|
+
raise map_api_error(
|
|
127
|
+
response.status_code, safe_json(body.decode() or ""), response.headers
|
|
128
|
+
)
|
|
129
|
+
yield response
|
|
130
|
+
except httpx.TimeoutException as exc:
|
|
131
|
+
raise APITimeoutError(cause=exc) from exc
|
|
132
|
+
except httpx.RequestError as exc:
|
|
133
|
+
raise APIConnectionError("stream aborted", cause=exc) from exc
|
|
134
|
+
|
|
135
|
+
def close(self) -> None:
|
|
136
|
+
if self._owns_http:
|
|
137
|
+
self._http.close()
|
|
138
|
+
|
|
139
|
+
def __enter__(self) -> BimpeAI:
|
|
140
|
+
return self
|
|
141
|
+
|
|
142
|
+
def __exit__(self, *exc: object) -> None:
|
|
143
|
+
self.close()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _resolve_request_id(extra: dict[str, str] | None) -> str:
|
|
147
|
+
if extra:
|
|
148
|
+
for key, value in extra.items():
|
|
149
|
+
if key.lower() == "x-request-id":
|
|
150
|
+
return value
|
|
151
|
+
return generate_request_id()
|
bimpeai/_exceptions.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal, cast
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
ErrorCode = Literal[
|
|
8
|
+
"validation_error",
|
|
9
|
+
"bad_request",
|
|
10
|
+
"unauthorized",
|
|
11
|
+
"api_key_missing",
|
|
12
|
+
"api_key_invalid",
|
|
13
|
+
"api_key_expired",
|
|
14
|
+
"insufficient_scope",
|
|
15
|
+
"forbidden",
|
|
16
|
+
"not_found",
|
|
17
|
+
"conflict",
|
|
18
|
+
"rate_limited",
|
|
19
|
+
"too_many_requests",
|
|
20
|
+
"not_implemented",
|
|
21
|
+
"agent_limit_reached",
|
|
22
|
+
"internal_error",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class BimpeAIError(Exception):
|
|
27
|
+
"""Base class for every error raised by the SDK."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UserError(BimpeAIError):
|
|
31
|
+
"""The SDK was used or configured incorrectly (e.g. missing api_key)."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class APIConnectionError(BimpeAIError):
|
|
35
|
+
def __init__(
|
|
36
|
+
self, message: str = "connection error", *, cause: BaseException | None = None
|
|
37
|
+
) -> None:
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
if cause is not None:
|
|
40
|
+
self.__cause__ = cause
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class APITimeoutError(APIConnectionError):
|
|
44
|
+
def __init__(
|
|
45
|
+
self, message: str = "request timed out", *, cause: BaseException | None = None
|
|
46
|
+
) -> None:
|
|
47
|
+
super().__init__(message, cause=cause)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class APIError(BimpeAIError):
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
message: str,
|
|
54
|
+
*,
|
|
55
|
+
status: int,
|
|
56
|
+
code: str | None,
|
|
57
|
+
request_id: str | None,
|
|
58
|
+
headers: httpx.Headers,
|
|
59
|
+
body: Any,
|
|
60
|
+
) -> None:
|
|
61
|
+
super().__init__(message)
|
|
62
|
+
self.status = status
|
|
63
|
+
self.code = code
|
|
64
|
+
self.request_id = request_id
|
|
65
|
+
self.headers = headers
|
|
66
|
+
self.body = body
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class BadRequestError(APIError):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ValidationError(BadRequestError):
|
|
74
|
+
def __init__(self, message: str, *, field_errors: list[dict[str, str]], **kwargs: Any) -> None:
|
|
75
|
+
super().__init__(message, **kwargs)
|
|
76
|
+
self.field_errors = field_errors
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AuthenticationError(APIError):
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class PermissionDeniedError(APIError):
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class NotFoundError(APIError):
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ConflictError(APIError):
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class RateLimitError(APIError):
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
message: str,
|
|
99
|
+
*,
|
|
100
|
+
retry_after: int | None,
|
|
101
|
+
limit: int | None,
|
|
102
|
+
remaining: int | None,
|
|
103
|
+
reset_at: int | None,
|
|
104
|
+
**kwargs: Any,
|
|
105
|
+
) -> None:
|
|
106
|
+
super().__init__(message, **kwargs)
|
|
107
|
+
self.retry_after = retry_after
|
|
108
|
+
self.limit = limit
|
|
109
|
+
self.remaining = remaining
|
|
110
|
+
self.reset_at = reset_at
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class InternalServerError(APIError):
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class APINotImplementedError(APIError):
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def map_api_error(status: int, body: Any, headers: httpx.Headers) -> APIError:
|
|
122
|
+
err_body: dict[str, Any] = cast("dict[str, Any]", body) if isinstance(body, dict) else {}
|
|
123
|
+
message = _normalise_message(err_body.get("message")) or f"HTTP {status}"
|
|
124
|
+
code = err_body.get("code")
|
|
125
|
+
request_id = headers.get("x-request-id") or err_body.get("request_id")
|
|
126
|
+
base: dict[str, Any] = {
|
|
127
|
+
"status": status,
|
|
128
|
+
"code": code,
|
|
129
|
+
"request_id": request_id,
|
|
130
|
+
"headers": headers,
|
|
131
|
+
"body": body,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if status == 400:
|
|
135
|
+
if code == "validation_error":
|
|
136
|
+
return ValidationError(
|
|
137
|
+
message, field_errors=_parse_field_errors(err_body.get("message")), **base
|
|
138
|
+
)
|
|
139
|
+
return BadRequestError(message, **base)
|
|
140
|
+
if status == 401:
|
|
141
|
+
return AuthenticationError(message, **base)
|
|
142
|
+
if status == 403:
|
|
143
|
+
return PermissionDeniedError(message, **base)
|
|
144
|
+
if status == 404:
|
|
145
|
+
return NotFoundError(message, **base)
|
|
146
|
+
if status == 409:
|
|
147
|
+
return ConflictError(message, **base)
|
|
148
|
+
if status == 429:
|
|
149
|
+
return RateLimitError(
|
|
150
|
+
message,
|
|
151
|
+
retry_after=_int_or_none(headers.get("retry-after")),
|
|
152
|
+
limit=_int_or_none(headers.get("x-ratelimit-limit")),
|
|
153
|
+
remaining=_int_or_none(headers.get("x-ratelimit-remaining")),
|
|
154
|
+
reset_at=_int_or_none(headers.get("x-ratelimit-reset")),
|
|
155
|
+
**base,
|
|
156
|
+
)
|
|
157
|
+
if status == 501:
|
|
158
|
+
return APINotImplementedError(message, **base)
|
|
159
|
+
if status >= 500:
|
|
160
|
+
return InternalServerError(message, **base)
|
|
161
|
+
return APIError(message, **base)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _normalise_message(message: Any) -> str | None:
|
|
165
|
+
if isinstance(message, list):
|
|
166
|
+
parts = cast("list[Any]", message)
|
|
167
|
+
return "; ".join(str(m) for m in parts)
|
|
168
|
+
if isinstance(message, str):
|
|
169
|
+
return message
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _parse_field_errors(message: Any) -> list[dict[str, str]]:
|
|
174
|
+
if not isinstance(message, list):
|
|
175
|
+
return []
|
|
176
|
+
items = cast("list[Any]", message)
|
|
177
|
+
out: list[dict[str, str]] = []
|
|
178
|
+
for entry in items:
|
|
179
|
+
if not isinstance(entry, str):
|
|
180
|
+
continue
|
|
181
|
+
path, sep, rest = entry.partition(":")
|
|
182
|
+
if sep:
|
|
183
|
+
out.append({"path": path.strip(), "message": rest.strip()})
|
|
184
|
+
else:
|
|
185
|
+
out.append({"path": "", "message": entry})
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _int_or_none(raw: str | None) -> int | None:
|
|
190
|
+
if raw is None:
|
|
191
|
+
return None
|
|
192
|
+
try:
|
|
193
|
+
return int(raw)
|
|
194
|
+
except ValueError:
|
|
195
|
+
return None
|
bimpeai/_idempotency.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ._request_id import generate_request_id
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def resolve_idempotency_key(supplied: str | None, max_retries: int) -> str | None:
|
|
7
|
+
if supplied:
|
|
8
|
+
return supplied
|
|
9
|
+
if max_retries > 0:
|
|
10
|
+
return generate_request_id()
|
|
11
|
+
return None
|