kupe 0.1.0__tar.gz

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.
Files changed (41) hide show
  1. kupe-0.1.0/PKG-INFO +66 -0
  2. kupe-0.1.0/README.md +43 -0
  3. kupe-0.1.0/pyproject.toml +43 -0
  4. kupe-0.1.0/setup.cfg +4 -0
  5. kupe-0.1.0/src/kupe/__init__.py +13 -0
  6. kupe-0.1.0/src/kupe/_models.py +84 -0
  7. kupe-0.1.0/src/kupe/_urls.py +34 -0
  8. kupe-0.1.0/src/kupe/client.py +226 -0
  9. kupe-0.1.0/src/kupe/errors.py +40 -0
  10. kupe-0.1.0/src/kupe/py.typed +0 -0
  11. kupe-0.1.0/src/kupe/realtime.py +144 -0
  12. kupe-0.1.0/src/kupe/resources/__init__.py +41 -0
  13. kupe-0.1.0/src/kupe/resources/_base.py +39 -0
  14. kupe-0.1.0/src/kupe/resources/agents.py +128 -0
  15. kupe-0.1.0/src/kupe/resources/analyses.py +59 -0
  16. kupe-0.1.0/src/kupe/resources/billing.py +31 -0
  17. kupe-0.1.0/src/kupe/resources/campaigns.py +121 -0
  18. kupe-0.1.0/src/kupe/resources/composio.py +51 -0
  19. kupe-0.1.0/src/kupe/resources/databases.py +70 -0
  20. kupe-0.1.0/src/kupe/resources/inbound.py +36 -0
  21. kupe-0.1.0/src/kupe/resources/knowledge_bases.py +126 -0
  22. kupe-0.1.0/src/kupe/resources/logs.py +59 -0
  23. kupe-0.1.0/src/kupe/resources/orgs.py +21 -0
  24. kupe-0.1.0/src/kupe/resources/phones.py +60 -0
  25. kupe-0.1.0/src/kupe/resources/projects.py +18 -0
  26. kupe-0.1.0/src/kupe/resources/providers.py +11 -0
  27. kupe-0.1.0/src/kupe/resources/realtime.py +42 -0
  28. kupe-0.1.0/src/kupe/resources/recipient_lists.py +64 -0
  29. kupe-0.1.0/src/kupe/resources/sessions.py +29 -0
  30. kupe-0.1.0/src/kupe/resources/tools.py +24 -0
  31. kupe-0.1.0/src/kupe/resources/usage.py +47 -0
  32. kupe-0.1.0/src/kupe/resources/voices.py +74 -0
  33. kupe-0.1.0/src/kupe.egg-info/PKG-INFO +66 -0
  34. kupe-0.1.0/src/kupe.egg-info/SOURCES.txt +39 -0
  35. kupe-0.1.0/src/kupe.egg-info/dependency_links.txt +1 -0
  36. kupe-0.1.0/src/kupe.egg-info/requires.txt +6 -0
  37. kupe-0.1.0/src/kupe.egg-info/top_level.txt +1 -0
  38. kupe-0.1.0/tests/test_client.py +90 -0
  39. kupe-0.1.0/tests/test_realtime.py +117 -0
  40. kupe-0.1.0/tests/test_resources.py +161 -0
  41. kupe-0.1.0/tests/test_urls.py +15 -0
kupe-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: kupe
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Kupe voice API
5
+ Author: Kupe
6
+ License: MIT
7
+ Project-URL: Homepage, https://docs.kupe.in
8
+ Project-URL: Documentation, https://docs.kupe.in
9
+ Keywords: kupe,voice,realtime,agents
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: httpx>=0.27.0
19
+ Requires-Dist: pydantic>=2.0
20
+ Requires-Dist: websockets>=12.0
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=8.0; extra == "dev"
23
+
24
+ # Kupe Python SDK
25
+
26
+ Official client for the [Kupe](https://x.kupe.in) voice API.
27
+
28
+ ```bash
29
+ pip install kupe
30
+ ```
31
+
32
+ Local checkout:
33
+
34
+ ```bash
35
+ pip install -e ./kupe-sdk
36
+ ```
37
+
38
+ ## Quickstart
39
+
40
+ ```python
41
+ from kupe import Kupe
42
+
43
+ client = Kupe() # KUPE_API_KEY
44
+ session = client.realtime.sessions.create(agent_id="agt_...", voice="priya")
45
+ with client.realtime.connect(session) as rt:
46
+ rt.send_text("Hi — remind them EMI is due tomorrow.")
47
+ for event in rt:
48
+ if event.type == "response.output_audio_transcript.done":
49
+ print(event.transcript)
50
+ ```
51
+
52
+ Auth is `Authorization: Bearer sk-kupe-...` (or a Supabase JWT). Default base is `https://x.kupe.in`. Env: `KUPE_API_KEY`, optional `KUPE_BASE_URL`.
53
+
54
+ Every HTTP path is `{base}/v1/...`. Passing `base_url="https://x.kupe.in/v1"` is fine — the client will not drop `/v1`.
55
+
56
+ When `org_id` / `project_id` are omitted, they are filled from `GET /v1/me`.
57
+
58
+ ## Resources
59
+
60
+ `client.agents`, `realtime`, `sessions`, `inbound`, `campaigns`, `recipient_lists`, `tools`, `composio`, `analyses`, `databases`, `knowledge_bases`, `phones`, `voices`, `providers`, `logs`, `billing`, `usage`, `orgs`, `projects`.
61
+
62
+ Realtime audio is PCM16 mono at 24 kHz (`rt.append_audio(pcm)`). Voice clone / patch / delete require a user JWT — calling them with an API key raises `JWTRequiredError`.
63
+
64
+ Payments, credit checkout, and per-service usage breakdown are not included.
65
+
66
+ See `examples/realtime_text_turn.py`.
kupe-0.1.0/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # Kupe Python SDK
2
+
3
+ Official client for the [Kupe](https://x.kupe.in) voice API.
4
+
5
+ ```bash
6
+ pip install kupe
7
+ ```
8
+
9
+ Local checkout:
10
+
11
+ ```bash
12
+ pip install -e ./kupe-sdk
13
+ ```
14
+
15
+ ## Quickstart
16
+
17
+ ```python
18
+ from kupe import Kupe
19
+
20
+ client = Kupe() # KUPE_API_KEY
21
+ session = client.realtime.sessions.create(agent_id="agt_...", voice="priya")
22
+ with client.realtime.connect(session) as rt:
23
+ rt.send_text("Hi — remind them EMI is due tomorrow.")
24
+ for event in rt:
25
+ if event.type == "response.output_audio_transcript.done":
26
+ print(event.transcript)
27
+ ```
28
+
29
+ Auth is `Authorization: Bearer sk-kupe-...` (or a Supabase JWT). Default base is `https://x.kupe.in`. Env: `KUPE_API_KEY`, optional `KUPE_BASE_URL`.
30
+
31
+ Every HTTP path is `{base}/v1/...`. Passing `base_url="https://x.kupe.in/v1"` is fine — the client will not drop `/v1`.
32
+
33
+ When `org_id` / `project_id` are omitted, they are filled from `GET /v1/me`.
34
+
35
+ ## Resources
36
+
37
+ `client.agents`, `realtime`, `sessions`, `inbound`, `campaigns`, `recipient_lists`, `tools`, `composio`, `analyses`, `databases`, `knowledge_bases`, `phones`, `voices`, `providers`, `logs`, `billing`, `usage`, `orgs`, `projects`.
38
+
39
+ Realtime audio is PCM16 mono at 24 kHz (`rt.append_audio(pcm)`). Voice clone / patch / delete require a user JWT — calling them with an API key raises `JWTRequiredError`.
40
+
41
+ Payments, credit checkout, and per-service usage breakdown are not included.
42
+
43
+ See `examples/realtime_text_turn.py`.
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "kupe"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for the Kupe voice API"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Kupe" }]
13
+ keywords = ["kupe", "voice", "realtime", "agents"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = [
23
+ "httpx>=0.27.0",
24
+ "pydantic>=2.0",
25
+ "websockets>=12.0",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["pytest>=8.0"]
30
+
31
+ [project.urls]
32
+ Homepage = "https://docs.kupe.in"
33
+ Documentation = "https://docs.kupe.in"
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
37
+
38
+ [tool.setuptools.package-data]
39
+ kupe = ["py.typed"]
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
43
+ pythonpath = [".", "src"]
kupe-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,13 @@
1
+ from kupe.client import Kupe
2
+ from kupe.errors import APIError, AuthenticationError, JWTRequiredError, KupeError
3
+ from kupe.realtime import RealtimeConnection
4
+
5
+ __all__ = [
6
+ "Kupe",
7
+ "KupeError",
8
+ "APIError",
9
+ "AuthenticationError",
10
+ "JWTRequiredError",
11
+ "RealtimeConnection",
12
+ ]
13
+ __version__ = "0.1.0"
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+
9
+ def parse(value: Any) -> Any:
10
+ """Wrap dicts as :class:`KupeObject` so nested fields are attributes."""
11
+ if isinstance(value, KupeObject):
12
+ return value
13
+ if isinstance(value, Mapping):
14
+ return KupeObject(value)
15
+ if isinstance(value, list):
16
+ return [parse(item) for item in value]
17
+ return value
18
+
19
+
20
+ class KupeObject:
21
+ """Attribute- and dict-accessible API payload."""
22
+
23
+ def __init__(self, data: Mapping[str, Any] | None = None, **extra: Any) -> None:
24
+ payload = dict(data or {})
25
+ payload.update(extra)
26
+ object.__setattr__(self, "_data", payload)
27
+ for key, value in payload.items():
28
+ object.__setattr__(self, key, parse(value))
29
+
30
+ def __repr__(self) -> str:
31
+ return f"KupeObject({self._data!r})"
32
+
33
+ def __getitem__(self, key: str) -> Any:
34
+ return getattr(self, key)
35
+
36
+ def __contains__(self, key: object) -> bool:
37
+ return key in self._data
38
+
39
+ def get(self, key: str, default: Any = None) -> Any:
40
+ return getattr(self, key, default)
41
+
42
+ def to_dict(self) -> dict[str, Any]:
43
+ return dict(self._data)
44
+
45
+ def model_dump(self) -> dict[str, Any]:
46
+ return self.to_dict()
47
+
48
+
49
+ class RealtimeEvent(BaseModel):
50
+ """A server event from the realtime WebSocket."""
51
+
52
+ model_config = ConfigDict(extra="allow")
53
+
54
+ type: str = ""
55
+
56
+ def __getitem__(self, key: str) -> Any:
57
+ extra = self.__pydantic_extra__ or {}
58
+ if key in extra:
59
+ return extra[key]
60
+ return getattr(self, key)
61
+
62
+
63
+ class RealtimeClientSecret(BaseModel):
64
+ model_config = ConfigDict(extra="allow")
65
+
66
+ value: str
67
+ expires_at: int | None = None
68
+
69
+
70
+ class RealtimeSession(BaseModel):
71
+ model_config = ConfigDict(extra="allow")
72
+
73
+ id: str = ""
74
+ object: str = "realtime.session"
75
+ model: str = "kupe-realtime"
76
+ modalities: list[str] = Field(default_factory=lambda: ["audio", "text"])
77
+ instructions: str = ""
78
+ voice: str = ""
79
+ input_audio_format: str = "pcm16"
80
+ output_audio_format: str = "pcm16"
81
+ tools: list[dict[str, Any]] = Field(default_factory=list)
82
+ client_secret: RealtimeClientSecret
83
+ websocket_url: str
84
+ session_id: str | None = None
@@ -0,0 +1,34 @@
1
+ """URL helpers. Paths always join as ``{origin}/v1/...``.
2
+
3
+ The OpenAI SDK treats a leading-slash path as host-absolute, so
4
+ ``OpenAI(base_url="https://x.kupe.in/v1").post("/realtime/sessions")`` hits
5
+ ``https://x.kupe.in/realtime/sessions`` (no ``/v1``). This package never
6
+ does that: every HTTP call is built with :func:`v1_url`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ DEFAULT_BASE_URL = "https://x.kupe.in"
12
+
13
+
14
+ def origin(base_url: str | None) -> str:
15
+ """Host origin with no trailing slash and no ``/v1`` suffix.
16
+
17
+ Callers may pass ``https://x.kupe.in`` or ``https://x.kupe.in/v1``;
18
+ both resolve to the same origin so we never double-prefix ``/v1``.
19
+ """
20
+ url = (base_url or DEFAULT_BASE_URL).strip().rstrip("/")
21
+ if url.endswith("/v1"):
22
+ url = url[: -len("/v1")].rstrip("/")
23
+ return url
24
+
25
+
26
+ def v1_url(base_url: str | None, path: str) -> str:
27
+ """Return ``{origin}/v1/{path}``. Leading slashes and a ``v1/`` prefix on
28
+ *path* are stripped so a caller cannot accidentally drop ``/v1``.
29
+ """
30
+ path = (path or "").lstrip("/")
31
+ if path.startswith("v1/"):
32
+ path = path[3:]
33
+ path = path.lstrip("/")
34
+ return f"{origin(base_url)}/v1/{path}"
@@ -0,0 +1,226 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ from kupe._models import KupeObject, parse
9
+ from kupe._urls import DEFAULT_BASE_URL, origin, v1_url
10
+ from kupe.errors import APIConnectionError, APIError, AuthenticationError, JWTRequiredError, KupeError
11
+ from kupe.resources.agents import AgentsResource
12
+ from kupe.resources.analyses import AnalysesResource
13
+ from kupe.resources.billing import BillingResource
14
+ from kupe.resources.campaigns import CampaignsResource
15
+ from kupe.resources.composio import ComposioResource
16
+ from kupe.resources.databases import DatabasesResource
17
+ from kupe.resources.inbound import InboundResource
18
+ from kupe.resources.knowledge_bases import KnowledgeBasesResource
19
+ from kupe.resources.logs import LogsResource
20
+ from kupe.resources.orgs import OrgsResource
21
+ from kupe.resources.phones import PhonesResource
22
+ from kupe.resources.projects import ProjectsResource
23
+ from kupe.resources.providers import ProvidersResource
24
+ from kupe.resources.realtime import RealtimeResource
25
+ from kupe.resources.recipient_lists import RecipientListsResource
26
+ from kupe.resources.sessions import SessionsResource
27
+ from kupe.resources.tools import ToolsResource
28
+ from kupe.resources.usage import UsageResource
29
+ from kupe.resources.voices import VoicesResource
30
+
31
+ __version__ = "0.1.0"
32
+
33
+
34
+ def _looks_like_jwt(token: str) -> bool:
35
+ return (not token.startswith("sk-")) and token.count(".") == 2
36
+
37
+
38
+ class Kupe:
39
+ """Synchronous client for the Kupe HTTP API and realtime WebSocket.
40
+
41
+ Parameters
42
+ ----------
43
+ api_key:
44
+ ``sk-kupe-...`` or a Supabase user JWT. Defaults to ``KUPE_API_KEY``.
45
+ base_url:
46
+ API origin, default ``https://x.kupe.in``. ``/v1`` is always appended;
47
+ passing ``https://x.kupe.in/v1`` is fine and will not double-prefix.
48
+ org_id / project_id:
49
+ Optional. When omitted, filled from ``GET /v1/me``.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ api_key: str | None = None,
55
+ *,
56
+ base_url: str | None = None,
57
+ org_id: str | None = None,
58
+ project_id: str | None = None,
59
+ timeout: float = 60.0,
60
+ http_client: httpx.Client | None = None,
61
+ ) -> None:
62
+ key = api_key if api_key is not None else os.environ.get("KUPE_API_KEY")
63
+ if not key:
64
+ raise AuthenticationError("No API key provided. Pass api_key= or set KUPE_API_KEY.")
65
+ self.api_key = key
66
+ env_base = os.environ.get("KUPE_BASE_URL")
67
+ self.base_url = origin(base_url if base_url is not None else (env_base or DEFAULT_BASE_URL))
68
+ self._org_id = org_id
69
+ self._project_id = project_id
70
+ self._owns_http = http_client is None
71
+ self._http = http_client or httpx.Client(
72
+ timeout=timeout,
73
+ headers={"User-Agent": f"kupe-python/{__version__}"},
74
+ )
75
+
76
+ self.agents = AgentsResource(self)
77
+ self.realtime = RealtimeResource(self)
78
+ self.sessions = SessionsResource(self)
79
+ self.inbound = InboundResource(self)
80
+ self.campaigns = CampaignsResource(self)
81
+ self.recipient_lists = RecipientListsResource(self)
82
+ self.tools = ToolsResource(self)
83
+ self.composio = ComposioResource(self)
84
+ self.analyses = AnalysesResource(self)
85
+ self.databases = DatabasesResource(self)
86
+ self.knowledge_bases = KnowledgeBasesResource(self)
87
+ self.phones = PhonesResource(self)
88
+ self.voices = VoicesResource(self)
89
+ self.providers = ProvidersResource(self)
90
+ self.logs = LogsResource(self)
91
+ self.billing = BillingResource(self)
92
+ self.usage = UsageResource(self)
93
+ self.orgs = OrgsResource(self)
94
+ self.projects = ProjectsResource(self)
95
+
96
+ @property
97
+ def org_id(self) -> str | None:
98
+ if self._org_id is None:
99
+ self._ensure_scope()
100
+ return self._org_id
101
+
102
+ @property
103
+ def project_id(self) -> str | None:
104
+ if self._project_id is None:
105
+ self._ensure_scope()
106
+ return self._project_id
107
+
108
+ def me(self) -> KupeObject:
109
+ return self._request("GET", "me")
110
+
111
+ def close(self) -> None:
112
+ if self._owns_http:
113
+ self._http.close()
114
+
115
+ def __enter__(self) -> Kupe:
116
+ return self
117
+
118
+ def __exit__(self, *exc: object) -> None:
119
+ self.close()
120
+
121
+ def _uses_jwt(self) -> bool:
122
+ return _looks_like_jwt(self.api_key)
123
+
124
+ def _require_jwt(self, action: str) -> None:
125
+ if not self._uses_jwt():
126
+ raise JWTRequiredError(
127
+ f"{action} requires a user JWT. API keys cannot own a voice — "
128
+ "sign in and pass a Supabase access token as api_key=."
129
+ )
130
+
131
+ def _ensure_scope(self) -> None:
132
+ if self._org_id and self._project_id:
133
+ return
134
+ me = self.me()
135
+ data = me.to_dict() if isinstance(me, KupeObject) else dict(me)
136
+ if not self._org_id:
137
+ self._org_id = data.get("org_id") or None
138
+ if not self._project_id:
139
+ self._project_id = data.get("project_id") or None
140
+
141
+ def _org(self, org_id: str | None = None) -> str:
142
+ if org_id:
143
+ return org_id
144
+ self._ensure_scope()
145
+ if not self._org_id:
146
+ raise KupeError(
147
+ "org_id is required. Pass org_id= to Kupe() or ensure GET /v1/me returns org_id."
148
+ )
149
+ return self._org_id
150
+
151
+ def _scope(
152
+ self,
153
+ org_id: str | None = None,
154
+ project_id: str | None = None,
155
+ ) -> tuple[str, str]:
156
+ if not org_id or not project_id:
157
+ self._ensure_scope()
158
+ resolved_org = org_id or self._org_id
159
+ resolved_project = project_id or self._project_id
160
+ if not resolved_org or not resolved_project:
161
+ raise KupeError(
162
+ "org_id and project_id are required. Pass them to Kupe() or "
163
+ "ensure GET /v1/me returns both."
164
+ )
165
+ return resolved_org, resolved_project
166
+
167
+ def _request(
168
+ self,
169
+ method: str,
170
+ path: str,
171
+ *,
172
+ json: Any = None,
173
+ params: dict[str, Any] | None = None,
174
+ files: Any = None,
175
+ data: Any = None,
176
+ raw: bool = False,
177
+ ) -> Any:
178
+ url = v1_url(self.base_url, path)
179
+ headers = {
180
+ "Authorization": f"Bearer {self.api_key}",
181
+ "User-Agent": f"kupe-python/{__version__}",
182
+ }
183
+ if not raw:
184
+ headers["Accept"] = "application/json"
185
+
186
+ request_kwargs: dict[str, Any] = {
187
+ "headers": headers,
188
+ "params": {k: v for k, v in (params or {}).items() if v is not None} or None,
189
+ }
190
+ if files is not None:
191
+ request_kwargs["files"] = files
192
+ if data is not None:
193
+ request_kwargs["data"] = data
194
+ elif data is not None:
195
+ request_kwargs["data"] = data
196
+ elif json is not None:
197
+ request_kwargs["json"] = json
198
+
199
+ try:
200
+ response = self._http.request(method, url, **request_kwargs)
201
+ except httpx.RequestError as exc:
202
+ raise APIConnectionError(f"Failed to reach {url}: {exc}") from exc
203
+
204
+ if response.status_code >= 400:
205
+ try:
206
+ body: Any = response.json()
207
+ detail = body.get("detail", body) if isinstance(body, dict) else body
208
+ except Exception:
209
+ body = response.text
210
+ detail = body
211
+ raise APIError(
212
+ f"HTTP {response.status_code} for {method} {url}: {detail}",
213
+ status_code=response.status_code,
214
+ body=body,
215
+ path=url,
216
+ )
217
+
218
+ if raw:
219
+ return response.content
220
+ if response.status_code == 204 or not response.content:
221
+ return None
222
+ try:
223
+ payload = response.json()
224
+ except Exception:
225
+ return response.text
226
+ return parse(payload)
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class KupeError(Exception):
7
+ """Base error for the Kupe SDK."""
8
+
9
+
10
+ class AuthenticationError(KupeError):
11
+ """Missing or invalid credentials."""
12
+
13
+
14
+ class JWTRequiredError(AuthenticationError):
15
+ """This method requires a user JWT; API keys are not accepted.
16
+
17
+ Voice clone / patch / delete (and related ownership endpoints) are
18
+ JWT-only on the backend — API keys cannot own a voice.
19
+ """
20
+
21
+
22
+ class APIConnectionError(KupeError):
23
+ """Network failure talking to the Kupe API."""
24
+
25
+
26
+ class APIError(KupeError):
27
+ """Non-success HTTP response from the Kupe API."""
28
+
29
+ def __init__(
30
+ self,
31
+ message: str,
32
+ *,
33
+ status_code: int,
34
+ body: Any = None,
35
+ path: str | None = None,
36
+ ) -> None:
37
+ super().__init__(message)
38
+ self.status_code = status_code
39
+ self.body = body
40
+ self.path = path
File without changes