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.
- kupe-0.1.0/PKG-INFO +66 -0
- kupe-0.1.0/README.md +43 -0
- kupe-0.1.0/pyproject.toml +43 -0
- kupe-0.1.0/setup.cfg +4 -0
- kupe-0.1.0/src/kupe/__init__.py +13 -0
- kupe-0.1.0/src/kupe/_models.py +84 -0
- kupe-0.1.0/src/kupe/_urls.py +34 -0
- kupe-0.1.0/src/kupe/client.py +226 -0
- kupe-0.1.0/src/kupe/errors.py +40 -0
- kupe-0.1.0/src/kupe/py.typed +0 -0
- kupe-0.1.0/src/kupe/realtime.py +144 -0
- kupe-0.1.0/src/kupe/resources/__init__.py +41 -0
- kupe-0.1.0/src/kupe/resources/_base.py +39 -0
- kupe-0.1.0/src/kupe/resources/agents.py +128 -0
- kupe-0.1.0/src/kupe/resources/analyses.py +59 -0
- kupe-0.1.0/src/kupe/resources/billing.py +31 -0
- kupe-0.1.0/src/kupe/resources/campaigns.py +121 -0
- kupe-0.1.0/src/kupe/resources/composio.py +51 -0
- kupe-0.1.0/src/kupe/resources/databases.py +70 -0
- kupe-0.1.0/src/kupe/resources/inbound.py +36 -0
- kupe-0.1.0/src/kupe/resources/knowledge_bases.py +126 -0
- kupe-0.1.0/src/kupe/resources/logs.py +59 -0
- kupe-0.1.0/src/kupe/resources/orgs.py +21 -0
- kupe-0.1.0/src/kupe/resources/phones.py +60 -0
- kupe-0.1.0/src/kupe/resources/projects.py +18 -0
- kupe-0.1.0/src/kupe/resources/providers.py +11 -0
- kupe-0.1.0/src/kupe/resources/realtime.py +42 -0
- kupe-0.1.0/src/kupe/resources/recipient_lists.py +64 -0
- kupe-0.1.0/src/kupe/resources/sessions.py +29 -0
- kupe-0.1.0/src/kupe/resources/tools.py +24 -0
- kupe-0.1.0/src/kupe/resources/usage.py +47 -0
- kupe-0.1.0/src/kupe/resources/voices.py +74 -0
- kupe-0.1.0/src/kupe.egg-info/PKG-INFO +66 -0
- kupe-0.1.0/src/kupe.egg-info/SOURCES.txt +39 -0
- kupe-0.1.0/src/kupe.egg-info/dependency_links.txt +1 -0
- kupe-0.1.0/src/kupe.egg-info/requires.txt +6 -0
- kupe-0.1.0/src/kupe.egg-info/top_level.txt +1 -0
- kupe-0.1.0/tests/test_client.py +90 -0
- kupe-0.1.0/tests/test_realtime.py +117 -0
- kupe-0.1.0/tests/test_resources.py +161 -0
- 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,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
|