driftstone 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.
driftstone/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ from .client import Driftstone
2
+ from .exceptions import (
3
+ DriftstoneAPIError,
4
+ DriftstoneError,
5
+ DriftstoneHTTPError,
6
+ DriftstoneResponseError,
7
+ )
8
+
9
+ __all__ = [
10
+ "Driftstone",
11
+ "DriftstoneError",
12
+ "DriftstoneHTTPError",
13
+ "DriftstoneResponseError",
14
+ "DriftstoneAPIError",
15
+ ]
@@ -0,0 +1 @@
1
+ """Internal Driftstone SDK implementation details."""
@@ -0,0 +1,7 @@
1
+ DEFAULT_API_BASE_URL = "https://pickaxeproject--driftstone-api-api.modal.run"
2
+
3
+ GCS_BUCKET_NAME = "driftstone"
4
+
5
+ AVAILABLE_APP_TYPES = ("claude", "openclaw")
6
+
7
+ AVAILABLE_CHANNEL_TYPES = ("api", "imessage")
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal, cast
4
+ import httpx
5
+
6
+ from .utils import logger
7
+ from ..exceptions import (
8
+ DriftstoneAPIError,
9
+ DriftstoneHTTPError,
10
+ DriftstoneResponseError
11
+ )
12
+
13
+ Method = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
14
+
15
+ class Transport:
16
+ def __init__(
17
+ self,
18
+ *,
19
+ base_url: str,
20
+ api_key: str,
21
+ timeout: float,
22
+ user_agent: str,
23
+ http_client: httpx.Client | None = None,
24
+ ) -> None:
25
+ self.timeout = timeout
26
+ self._headers = {
27
+ "Authorization": f"Bearer {api_key}",
28
+ "Content-Type": "application/json",
29
+ "Accept": "application/json",
30
+ "User-Agent": user_agent,
31
+ }
32
+ self._owns_http_client = http_client is None
33
+ self._http = http_client or httpx.Client(
34
+ base_url=base_url,
35
+ timeout=timeout,
36
+ headers=self._headers,
37
+ )
38
+
39
+ @property
40
+ def headers(self) -> dict[str, str]:
41
+ return self._headers.copy()
42
+
43
+ def close(self) -> None:
44
+ if self._owns_http_client:
45
+ self._http.close()
46
+
47
+ @staticmethod
48
+ def _parse_response(response: httpx.Response) -> dict[str, Any]:
49
+ try:
50
+ decoded = response.json()
51
+ except ValueError as exc:
52
+ raise DriftstoneResponseError(
53
+ f"Driftstone API returned non-JSON response (status={response.status_code})"
54
+ ) from exc
55
+
56
+ if not isinstance(decoded, dict):
57
+ raise DriftstoneResponseError(
58
+ "Driftstone API returned an unexpected response payload"
59
+ )
60
+
61
+ return cast(dict[str, Any], decoded)
62
+
63
+ def request(
64
+ self,
65
+ method: Method,
66
+ path: str,
67
+ payload: dict[str, Any] | None = None,
68
+ ) -> dict[str, Any]:
69
+ payload = payload or {}
70
+
71
+ try:
72
+ kwargs: dict[str, Any] = {
73
+ "method": method,
74
+ "url": path,
75
+ "headers": self._headers,
76
+ }
77
+ if method == "GET":
78
+ kwargs["params"] = payload
79
+ else:
80
+ kwargs["json"] = payload
81
+
82
+ response = self._http.request(**kwargs)
83
+ except httpx.HTTPError as exc:
84
+ raise DriftstoneHTTPError(f"HTTP request failed: {exc}") from exc
85
+
86
+ data = self._parse_response(response)
87
+
88
+ logger.debug(
89
+ "Received response from Driftstone API (status=%d, data=%s)",
90
+ response.status_code,
91
+ data,
92
+ )
93
+
94
+ if response.status_code >= 400:
95
+ raise DriftstoneAPIError(
96
+ f"Driftstone API returned error status {response.status_code}: {data}"
97
+ )
98
+
99
+ if data.get("success") is False:
100
+ message = str(data.get("error") or data.get("message") or "Request failed")
101
+ raise DriftstoneAPIError(f"Driftstone API error: {message}")
102
+
103
+ return data
@@ -0,0 +1,21 @@
1
+ import logging
2
+ import json
3
+
4
+ logger = logging.getLogger("driftstone")
5
+ logger.setLevel(logging.WARNING)
6
+ logger.addHandler(logging.NullHandler())
7
+
8
+ logging.basicConfig(
9
+ format="[%(funcName)s][%(asctime)s]: %(levelname)s %(message)s",
10
+ datefmt="%m/%d/%Y %H:%M:%S",
11
+ )
12
+
13
+ def set_debug(enabled: bool) -> None:
14
+ logger.setLevel(logging.INFO if enabled else logging.WARNING)
15
+
16
+ def safe_parse_json(raw: str):
17
+ try:
18
+ return json.loads(raw)
19
+ except Exception as e:
20
+ logger.error("Failed to parse JSON: %s", e)
21
+ return None
driftstone/client.py ADDED
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal
4
+
5
+ import httpx
6
+
7
+ from ._internal.constants import DEFAULT_API_BASE_URL
8
+ from ._internal.utils import logger, set_debug
9
+ from ._internal.transport import Method, Transport
10
+ from .resources.app import DriftstoneApp
11
+ from .version import __version__
12
+
13
+ class Driftstone:
14
+ def __init__(
15
+ self,
16
+ api_key: str,
17
+ *,
18
+ version: Literal["v1"] = "v1",
19
+ timeout: float = 30.0,
20
+ debug: bool = False,
21
+ http_client: httpx.Client | None = None,
22
+ ) -> None:
23
+ self.debug = debug
24
+ set_debug(self.debug)
25
+
26
+ logger.info("Initializing Driftstone client with provided API key")
27
+
28
+ trimmed_key = api_key.strip()
29
+ if not trimmed_key:
30
+ raise ValueError("api_key must be provided")
31
+ if not trimmed_key.startswith("dk-"):
32
+ raise ValueError("Invalid Driftstone API key")
33
+
34
+ self.api_key = trimmed_key
35
+ self.version = version
36
+ self.timeout = timeout
37
+ self.base_url = f"{DEFAULT_API_BASE_URL}/{self.version}"
38
+
39
+ self._transport = Transport(
40
+ base_url=self.base_url,
41
+ api_key=self.api_key,
42
+ timeout=self.timeout,
43
+ user_agent=f"Driftstone-python/{__version__}",
44
+ http_client=http_client,
45
+ )
46
+
47
+ def close(self) -> None:
48
+ self._transport.close()
49
+
50
+ def __enter__(self) -> "Driftstone":
51
+ return self
52
+
53
+ def __exit__(self, *_: object) -> None:
54
+ self.close()
55
+
56
+ def _request(
57
+ self,
58
+ method: Method,
59
+ path: str,
60
+ payload: dict[str, Any] | None = None,
61
+ ) -> dict[str, Any]:
62
+ return self._transport.request(method, path, payload)
63
+
64
+ @property
65
+ def app(self) -> DriftstoneApp:
66
+ return DriftstoneApp(self)
67
+
@@ -0,0 +1,14 @@
1
+ class DriftstoneError(Exception):
2
+ """Base exception for the Driftstone SDK."""
3
+
4
+
5
+ class DriftstoneHTTPError(DriftstoneError):
6
+ """Raised when the underlying HTTP request fails."""
7
+
8
+
9
+ class DriftstoneResponseError(DriftstoneError):
10
+ """Raised when an API response cannot be parsed as expected."""
11
+
12
+
13
+ class DriftstoneAPIError(DriftstoneError):
14
+ """Raised when the Driftstone API returns an error."""
@@ -0,0 +1 @@
1
+ """Driftstone SDK models."""
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import Any, Literal, Mapping
6
+
7
+ AccessGroupLimitReset = Literal["daily", "monthly", "yearly"]
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class DriftstoneAccessGroupModel:
12
+ id: str
13
+ appId: str
14
+ name: str
15
+ limit: int
16
+ limitReset: AccessGroupLimitReset
17
+ limitMessage: str
18
+ createdAt: datetime
19
+ updatedAt: datetime
20
+
21
+ @classmethod
22
+ def _from_api(cls, payload: Mapping[str, Any]) -> "DriftstoneAccessGroupModel":
23
+ return cls(
24
+ id=payload["id"],
25
+ appId=payload["appId"],
26
+ name=payload["name"],
27
+ limit=payload["limit"],
28
+ limitReset=payload["limitReset"],
29
+ limitMessage=payload["limitMessage"],
30
+ createdAt=datetime.fromisoformat(payload["createdAt"]),
31
+ updatedAt=datetime.fromisoformat(payload["updatedAt"]),
32
+ )
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import (
6
+ TYPE_CHECKING,
7
+ Any,
8
+ Literal,
9
+ Mapping,
10
+ Optional,
11
+ TypedDict,
12
+ Union,
13
+ Required,
14
+ )
15
+
16
+ if TYPE_CHECKING:
17
+ from ..resources.access_group import DriftstoneAccessGroup
18
+ from ..resources.version import DriftstoneVersion
19
+ from ..resources.channel import DriftstoneChannel
20
+ from ..resources.session import DriftstoneSession
21
+ from ..resources.profile import DriftstoneProfile
22
+ from ..resources.instance import DriftstoneInstance
23
+
24
+ ApplicationType = Literal["claude", "openclaw"]
25
+
26
+ class AppInitLimit(TypedDict):
27
+ name: Optional[str]
28
+ amount: Required[int]
29
+ interval: Required[Literal["daily", "weekly", "monthly"]]
30
+ message: Optional[str]
31
+
32
+ class AppInitCredentialMCPOAuthNoneParam(TypedDict):
33
+ type: Required[Literal["none"]]
34
+
35
+ class AppInitCredentialMCPOAuthBasicParam(TypedDict):
36
+ type: Required[Literal["client_secret_basic"]]
37
+ client_secret: Required[str]
38
+
39
+ class AppInitCredentialMCPOAuthPostParam(TypedDict):
40
+ type: Required[Literal["client_secret_post"]]
41
+ client_secret: Required[str]
42
+
43
+ class AppInitCredentialMCPOAuthRefresh(TypedDict):
44
+ client_id: Required[str]
45
+ refresh_token: Required[str]
46
+ token_endpoint: Required[str]
47
+ token_endpoint_auth: Required[Union[
48
+ AppInitCredentialMCPOAuthNoneParam,
49
+ AppInitCredentialMCPOAuthBasicParam,
50
+ AppInitCredentialMCPOAuthPostParam,
51
+ ]]
52
+ resource: Optional[str]
53
+ scope: Optional[str]
54
+
55
+ class AppInitCredentialMCPOAuth(TypedDict):
56
+ access_token: Required[str]
57
+ mcp_server_url: Required[str]
58
+ type: Required[Literal["mcp_oauth"]]
59
+ expires_at: Optional[datetime]
60
+ refresh: Optional[AppInitCredentialMCPOAuthRefresh]
61
+
62
+ class AppInitCredentialMCPStaticBearer(TypedDict):
63
+ token: Required[str]
64
+ mcp_server_url: Required[str]
65
+ type: Required[Literal["mcp_oauth"]]
66
+
67
+ AppInitCredential = Union[AppInitCredentialMCPOAuth, AppInitCredentialMCPStaticBearer]
68
+
69
+ @dataclass(frozen=True, slots=True)
70
+ class DriftstoneAppModel:
71
+ id: str
72
+ orgId: str
73
+ type: ApplicationType
74
+ name: str
75
+ slug: str
76
+ vaultId: str
77
+ defaultAccessGroupId: str
78
+ headVersionId: Optional[str]
79
+ metadata: Optional[dict[str, str]]
80
+ createdAt: datetime
81
+ updatedAt: datetime
82
+
83
+ version: DriftstoneVersion
84
+ channel: DriftstoneChannel
85
+ session: DriftstoneSession
86
+ access_group: DriftstoneAccessGroup
87
+ profile: DriftstoneProfile
88
+ instance: DriftstoneInstance
89
+
90
+ @classmethod
91
+ def _from_api(
92
+ cls,
93
+ payload: Mapping[str, Any],
94
+ version: DriftstoneVersion,
95
+ channel: DriftstoneChannel,
96
+ session: DriftstoneSession,
97
+ access_group: DriftstoneAccessGroup,
98
+ profile: DriftstoneProfile,
99
+ instance: DriftstoneInstance,
100
+ ) -> "DriftstoneAppModel":
101
+ return cls(
102
+ id=payload["id"],
103
+ orgId=payload["orgId"],
104
+ type=payload["type"],
105
+ name=payload["name"],
106
+ slug=payload["slug"],
107
+ vaultId=payload["vaultId"],
108
+ defaultAccessGroupId=payload["defaultAccessGroupId"],
109
+ headVersionId=payload.get("headVersionId"),
110
+ metadata=payload.get("metadata"),
111
+ createdAt=datetime.fromisoformat(payload["createdAt"]),
112
+ updatedAt=datetime.fromisoformat(payload["updatedAt"]),
113
+
114
+ version=version,
115
+ channel=channel,
116
+ session=session,
117
+ access_group=access_group,
118
+ profile=profile,
119
+ instance=instance,
120
+ )
@@ -0,0 +1,123 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import (
6
+ Any,
7
+ AsyncIterator,
8
+ Iterator,
9
+ Literal,
10
+ Mapping,
11
+ Optional,
12
+ Protocol,
13
+ TypedDict
14
+ )
15
+
16
+ from .response import ResponseRunStreamEvent
17
+
18
+ ChannelType = Literal["api", "imessage"]
19
+
20
+ class ChannelRun(Protocol):
21
+ def __call__(
22
+ self,
23
+ user_id: str,
24
+ session_id: Optional[str] = None,
25
+ input: str = ""
26
+ ) -> Iterator[ResponseRunStreamEvent]: ...
27
+
28
+ class ChannelRunAsync(Protocol):
29
+ def __call__(
30
+ self,
31
+ user_id: str,
32
+ session_id: Optional[str] = None,
33
+ input: str = ""
34
+ ) -> AsyncIterator[ResponseRunStreamEvent]: ...
35
+
36
+ class ChannelRunner(Protocol):
37
+ def __call__(
38
+ self,
39
+ user_id: str,
40
+ session_id: Optional[str] = None,
41
+ input: str = ""
42
+ ) -> Iterator[ResponseRunStreamEvent]: ...
43
+
44
+ class ChannelRunnerAsync(Protocol):
45
+ def __call__(
46
+ self,
47
+ user_id: str,
48
+ session_id: Optional[str] = None,
49
+ input: str = ""
50
+ ) -> AsyncIterator[ResponseRunStreamEvent]: ...
51
+
52
+ class ChannelAPIWebhook(TypedDict):
53
+ name: str
54
+ url: str
55
+ headers: Optional[dict[str, str]]
56
+
57
+ class ChannelIMessageData(TypedDict):
58
+ sendBlueAPIKey: str
59
+ sendBlueSecretKey: str
60
+ fromNumber: str
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class DriftstoneChannelModel:
64
+ id: str
65
+ appId: str
66
+ type: ChannelType
67
+ name: str
68
+ webhook: Optional[ChannelAPIWebhook]
69
+ data: Optional[ChannelIMessageData]
70
+ createdAt: datetime
71
+ updatedAt: datetime
72
+
73
+ run: ChannelRun
74
+ run_async: ChannelRunAsync
75
+
76
+ @classmethod
77
+ def _from_api(
78
+ cls,
79
+ payload: Mapping[str, Any],
80
+ run: ChannelRunner,
81
+ run_async: ChannelRunnerAsync,
82
+ ) -> "DriftstoneChannelModel":
83
+ channel_id = payload["id"]
84
+
85
+ def channel_run(
86
+ user_id: str,
87
+ session_id: Optional[str] = None,
88
+ input: str = ""
89
+ ) -> Iterator[ResponseRunStreamEvent]:
90
+ for event in run(
91
+ channel_id,
92
+ user_id,
93
+ session_id,
94
+ input
95
+ ):
96
+ yield event
97
+
98
+ async def channel_run_async(
99
+ user_id: str,
100
+ session_id: Optional[str] = None,
101
+ input: str = ""
102
+ ) -> AsyncIterator[ResponseRunStreamEvent]:
103
+ async for event in run_async(
104
+ channel_id,
105
+ user_id,
106
+ session_id,
107
+ input
108
+ ):
109
+ yield event
110
+
111
+ return cls(
112
+ id=payload["id"],
113
+ appId=payload["appId"],
114
+ type=payload["type"],
115
+ name=payload["name"],
116
+ webhook=payload.get("webhook"),
117
+ data=payload.get("data"),
118
+ createdAt=datetime.fromisoformat(payload["createdAt"]),
119
+ updatedAt=datetime.fromisoformat(payload["updatedAt"]),
120
+
121
+ run=channel_run,
122
+ run_async=channel_run_async,
123
+ )
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import Any, Literal, Mapping, Optional
6
+
7
+ InstanceStatus = Literal["running", "terminated"]
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class DriftstoneInstanceModel:
11
+ id: str
12
+ appId: str
13
+ versionId: str
14
+ channelId: str
15
+ sessionId: str
16
+ profileId: str
17
+ accessGroupId: str
18
+ stateId: Optional[str]
19
+ identifier: str
20
+ timeout: int
21
+ totalCost: float
22
+ costBreakdown: dict[str, float]
23
+ tokenBreakdown: dict[str, int]
24
+ metadata: Optional[dict[str, str]]
25
+ status: InstanceStatus
26
+ error: Optional[str]
27
+ createdAt: datetime
28
+ updatedAt: datetime
29
+
30
+ @classmethod
31
+ def _from_api(cls, payload: Mapping[str, Any]) -> "DriftstoneInstanceModel":
32
+ return cls(
33
+ id=payload["id"],
34
+ appId=payload["appId"],
35
+ versionId=payload["versionId"],
36
+ channelId=payload["channelId"],
37
+ sessionId=payload["sessionId"],
38
+ profileId=payload["profileId"],
39
+ accessGroupId=payload["accessGroupId"],
40
+ stateId=payload.get("stateId"),
41
+ identifier=payload["identifier"],
42
+ timeout=payload["timeout"],
43
+ totalCost=payload["totalCost"],
44
+ costBreakdown=payload["costBreakdown"],
45
+ tokenBreakdown=payload["tokenBreakdown"],
46
+ metadata=payload.get("metadata"),
47
+ status=payload["status"],
48
+ error=payload.get("error"),
49
+ createdAt=datetime.fromisoformat(payload["createdAt"]),
50
+ updatedAt=datetime.fromisoformat(payload["updatedAt"]),
51
+ )
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import (
6
+ TYPE_CHECKING,
7
+ Any,
8
+ Mapping,
9
+ Optional
10
+ )
11
+
12
+ if TYPE_CHECKING:
13
+ from ..resources.state import DriftstoneState
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class DriftstoneProfileModel:
17
+ id: str
18
+ appId: str
19
+ identifier: str
20
+ currentSpending: float
21
+ totalSpending: float
22
+ headStateId: Optional[str]
23
+ initialChannelId: Optional[str]
24
+ accessGroupId: Optional[str]
25
+ lastResetAt: Optional[datetime]
26
+ createdAt: datetime
27
+ updatedAt: datetime
28
+
29
+ state: DriftstoneState
30
+
31
+ @staticmethod
32
+ def _parse_optional_datetime(value: Any) -> Optional[datetime]:
33
+ if value is None:
34
+ return None
35
+ if isinstance(value, datetime):
36
+ return value
37
+ if isinstance(value, str):
38
+ return datetime.fromisoformat(value)
39
+ raise ValueError("Expected datetime, ISO datetime string, or None")
40
+
41
+ @classmethod
42
+ def _from_api(
43
+ cls,
44
+ payload: Mapping[str, Any],
45
+ state: DriftstoneState,
46
+ ) -> "DriftstoneProfileModel":
47
+ return cls(
48
+ id=payload["id"],
49
+ appId=payload["appId"],
50
+ identifier=payload["identifier"],
51
+ currentSpending=payload["currentSpending"],
52
+ totalSpending=payload["totalSpending"],
53
+ headStateId=payload.get("headStateId"),
54
+ initialChannelId=payload.get("initialChannelId"),
55
+ accessGroupId=payload.get("accessGroupId"),
56
+ lastResetAt=cls._parse_optional_datetime(payload.get("lastResetAt")),
57
+ createdAt=datetime.fromisoformat(payload["createdAt"]),
58
+ updatedAt=datetime.fromisoformat(payload["updatedAt"]),
59
+
60
+ state=state,
61
+ )
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional, Any
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class ResponseRunStreamEvent:
8
+ instance_id: str
9
+ index: int
10
+ data: Any
11
+ done: Optional[bool] = None
12
+ error: Optional[str] = None
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Mapping, Any, Literal, Optional
4
+ from dataclasses import dataclass
5
+ from datetime import datetime
6
+
7
+ SessionType = Literal["single", "multiple"]
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class DriftstoneSessionModel:
11
+ id: str
12
+ appId: str
13
+ type: SessionType
14
+ userIds: list[str]
15
+ profileIds: list[str]
16
+ metadata: Optional[dict[str, str]]
17
+ externalId: Optional[str]
18
+ createdAt: datetime
19
+ updatedAt: datetime
20
+
21
+ @classmethod
22
+ def _from_api(cls, payload: Mapping[str, Any]) -> "DriftstoneSessionModel":
23
+ return cls(
24
+ id=payload["id"],
25
+ appId=payload["appId"],
26
+ type=payload["type"],
27
+ userIds=payload["userIds"],
28
+ profileIds=payload["profileIds"],
29
+ metadata=payload.get("metadata"),
30
+ externalId=payload.get("externalId"),
31
+ createdAt=datetime.fromisoformat(payload["createdAt"]),
32
+ updatedAt=datetime.fromisoformat(payload["updatedAt"]),
33
+ )