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 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
+ ]
@@ -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()
@@ -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
@@ -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