stacklink-sdk 0.2.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.
- stacklink/__init__.py +42 -0
- stacklink/api_keys.py +18 -0
- stacklink/artifacts.py +9 -0
- stacklink/auth_configs.py +29 -0
- stacklink/billing.py +43 -0
- stacklink/claude_agent.py +29 -0
- stacklink/client.py +292 -0
- stacklink/connected_accounts.py +95 -0
- stacklink/errors.py +15 -0
- stacklink/executions.py +45 -0
- stacklink/knowledge.py +24 -0
- stacklink/logs.py +29 -0
- stacklink/mcp.py +76 -0
- stacklink/memory.py +235 -0
- stacklink/openai_agents.py +40 -0
- stacklink/openai_memory.py +432 -0
- stacklink/projects.py +20 -0
- stacklink/sessions.py +223 -0
- stacklink/tools.py +144 -0
- stacklink/triggers.py +27 -0
- stacklink/types.py +290 -0
- stacklink/webhooks.py +29 -0
- stacklink/workbench.py +1090 -0
- stacklink_sdk-0.2.0.dist-info/METADATA +71 -0
- stacklink_sdk-0.2.0.dist-info/RECORD +28 -0
- stacklink_sdk-0.2.0.dist-info/WHEEL +5 -0
- stacklink_sdk-0.2.0.dist-info/licenses/LICENSE +9 -0
- stacklink_sdk-0.2.0.dist-info/top_level.txt +1 -0
stacklink/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .client import STACKLINK_PYTHON_SDK_VERSION, Stacklink
|
|
4
|
+
from .claude_agent import create_stacklink_claude_agent_tool_config
|
|
5
|
+
from .errors import RateLimitError, StacklinkError
|
|
6
|
+
from .openai_agents import create_stacklink_openai_agents_tools
|
|
7
|
+
from .openai_memory import StacklinkMemoryLifecycleError, with_stacklink_memory
|
|
8
|
+
from .types import (
|
|
9
|
+
ProjectProvider,
|
|
10
|
+
ProviderMarketplaceMetadata,
|
|
11
|
+
ProviderProfile,
|
|
12
|
+
ProviderProfileCapabilityGroup,
|
|
13
|
+
ProviderProfileCapabilityItem,
|
|
14
|
+
ProviderProfileDetails,
|
|
15
|
+
ProviderTool,
|
|
16
|
+
ProviderToolkit,
|
|
17
|
+
StacklinkBillingDescriptor,
|
|
18
|
+
ToolSchemasResponse,
|
|
19
|
+
ToolSearchResponse,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Stacklink",
|
|
24
|
+
"STACKLINK_PYTHON_SDK_VERSION",
|
|
25
|
+
"StacklinkError",
|
|
26
|
+
"RateLimitError",
|
|
27
|
+
"StacklinkMemoryLifecycleError",
|
|
28
|
+
"with_stacklink_memory",
|
|
29
|
+
"create_stacklink_openai_agents_tools",
|
|
30
|
+
"create_stacklink_claude_agent_tool_config",
|
|
31
|
+
"ProjectProvider",
|
|
32
|
+
"ProviderMarketplaceMetadata",
|
|
33
|
+
"ProviderProfile",
|
|
34
|
+
"ProviderProfileCapabilityGroup",
|
|
35
|
+
"ProviderProfileCapabilityItem",
|
|
36
|
+
"ProviderProfileDetails",
|
|
37
|
+
"ProviderTool",
|
|
38
|
+
"ProviderToolkit",
|
|
39
|
+
"StacklinkBillingDescriptor",
|
|
40
|
+
"ToolSearchResponse",
|
|
41
|
+
"ToolSchemasResponse",
|
|
42
|
+
]
|
stacklink/api_keys.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ApiKeysResource:
|
|
7
|
+
def __init__(self, client):
|
|
8
|
+
self.client = client
|
|
9
|
+
|
|
10
|
+
def list(self):
|
|
11
|
+
return self.client.request("GET", "/v1/api-keys")
|
|
12
|
+
|
|
13
|
+
def create(self, body: dict[str, Any]):
|
|
14
|
+
return self.client.request("POST", "/v1/api-keys", json_body=body)
|
|
15
|
+
|
|
16
|
+
def revoke(self, api_key_id: str):
|
|
17
|
+
return self.client.request("DELETE", f"/v1/api-keys/{api_key_id}")
|
|
18
|
+
|
stacklink/artifacts.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AuthConfigsResource:
|
|
7
|
+
def __init__(self, client):
|
|
8
|
+
self.client = client
|
|
9
|
+
|
|
10
|
+
def list(self, **query):
|
|
11
|
+
return self.client.request("GET", "/v1/auth-configs", query=query)
|
|
12
|
+
|
|
13
|
+
def create(self, body: dict[str, Any]):
|
|
14
|
+
return self.client.request("POST", "/v1/auth-configs", json_body=body)
|
|
15
|
+
|
|
16
|
+
def get(self, auth_config_id: str):
|
|
17
|
+
return self.client.request("GET", f"/v1/auth-configs/{auth_config_id}")
|
|
18
|
+
|
|
19
|
+
def update(self, auth_config_id: str, body: dict[str, Any]):
|
|
20
|
+
return self.client.request("PATCH", f"/v1/auth-configs/{auth_config_id}", json_body=body)
|
|
21
|
+
|
|
22
|
+
def enable(self, auth_config_id: str):
|
|
23
|
+
return self.client.request("POST", f"/v1/auth-configs/{auth_config_id}/enable")
|
|
24
|
+
|
|
25
|
+
def disable(self, auth_config_id: str):
|
|
26
|
+
return self.client.request("POST", f"/v1/auth-configs/{auth_config_id}/disable")
|
|
27
|
+
|
|
28
|
+
def delete(self, auth_config_id: str):
|
|
29
|
+
return self.client.request("DELETE", f"/v1/auth-configs/{auth_config_id}")
|
stacklink/billing.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BillingResource:
|
|
7
|
+
def __init__(self, client):
|
|
8
|
+
self.client = client
|
|
9
|
+
|
|
10
|
+
def plans(self):
|
|
11
|
+
return self.client.request("GET", "/v1/billing/plans")
|
|
12
|
+
|
|
13
|
+
def rate_card(self, **query):
|
|
14
|
+
return self.client.request("GET", "/v1/billing/rate-card", query=query)
|
|
15
|
+
|
|
16
|
+
def subscription(self):
|
|
17
|
+
return self.client.request("GET", "/v1/subscription")
|
|
18
|
+
|
|
19
|
+
def usage(self, **query):
|
|
20
|
+
return self.client.request("GET", "/v1/usage", query=query)
|
|
21
|
+
|
|
22
|
+
def usage_events(self, **query):
|
|
23
|
+
return self.client.request("GET", "/v1/usage/events", query=query)
|
|
24
|
+
|
|
25
|
+
def create_checkout(self, plan_key: str, success_url: str | None = None, cancel_url: str | None = None):
|
|
26
|
+
body: dict[str, Any] = {"planKey": plan_key}
|
|
27
|
+
if success_url is not None:
|
|
28
|
+
body["successUrl"] = success_url
|
|
29
|
+
if cancel_url is not None:
|
|
30
|
+
body["cancelUrl"] = cancel_url
|
|
31
|
+
return self.client.request("POST", "/v1/billing/checkout", json_body=body)
|
|
32
|
+
|
|
33
|
+
def create_portal(self, return_url: str | None = None):
|
|
34
|
+
body: dict[str, Any] = {}
|
|
35
|
+
if return_url is not None:
|
|
36
|
+
body["returnUrl"] = return_url
|
|
37
|
+
return self.client.request("POST", "/v1/billing/portal", json_body=body)
|
|
38
|
+
|
|
39
|
+
def create_overage_invoice_line(self):
|
|
40
|
+
return self.client.request("POST", "/v1/billing/overage-invoice-line")
|
|
41
|
+
|
|
42
|
+
def set_workspace_plan_for_project(self, project_id: str, body: dict[str, Any]):
|
|
43
|
+
return self.client.request("POST", f"/v1/projects/{project_id}/subscription/plan", json_body=body)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, TypedDict
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from .sessions import Session
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StacklinkClaudeAgentToolConfig(TypedDict):
|
|
10
|
+
mcp_servers: dict[str, dict[str, Any]]
|
|
11
|
+
allowed_tools: list[str]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def create_stacklink_claude_agent_tool_config(
|
|
15
|
+
session: "Session",
|
|
16
|
+
) -> StacklinkClaudeAgentToolConfig:
|
|
17
|
+
connection = session.mcp_connection()
|
|
18
|
+
return {
|
|
19
|
+
"mcp_servers": {
|
|
20
|
+
"stacklink": {
|
|
21
|
+
"type": "http",
|
|
22
|
+
"url": connection["url"],
|
|
23
|
+
"headers": dict(connection["headers"]),
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"allowed_tools": [
|
|
27
|
+
f"mcp__stacklink__{tool.name}" for tool in session.tools().values()
|
|
28
|
+
],
|
|
29
|
+
}
|
stacklink/client.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Iterator, Mapping
|
|
7
|
+
from urllib.error import HTTPError
|
|
8
|
+
from urllib.parse import urlencode
|
|
9
|
+
from urllib.request import Request, urlopen
|
|
10
|
+
|
|
11
|
+
from .api_keys import ApiKeysResource
|
|
12
|
+
from .artifacts import ArtifactsResource
|
|
13
|
+
from .auth_configs import AuthConfigsResource
|
|
14
|
+
from .billing import BillingResource
|
|
15
|
+
from .connected_accounts import ConnectedAccountsResource
|
|
16
|
+
from .errors import RateLimitError, StacklinkError
|
|
17
|
+
from .executions import ExecutionsResource
|
|
18
|
+
from .knowledge import KnowledgeResource
|
|
19
|
+
from .logs import LogsResource
|
|
20
|
+
from .memory import MemoryResource
|
|
21
|
+
from .mcp import McpResource
|
|
22
|
+
from .projects import ProjectsResource
|
|
23
|
+
from .sessions import SessionsResource
|
|
24
|
+
from .tools import ToolsResource
|
|
25
|
+
from .triggers import TriggersResource
|
|
26
|
+
from .types import Json, Transport
|
|
27
|
+
from .webhooks import WebhooksResource
|
|
28
|
+
from .workbench import WorkbenchResource
|
|
29
|
+
|
|
30
|
+
STACKLINK_PYTHON_SDK_VERSION = "0.2.0"
|
|
31
|
+
STACKLINK_PYTHON_USER_AGENT = f"stacklink-python/{STACKLINK_PYTHON_SDK_VERSION}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Stacklink:
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
api_key: str | None = None,
|
|
38
|
+
registration_key: str | None = None,
|
|
39
|
+
base_url: str | None = None,
|
|
40
|
+
timeout_ms: int | None = None,
|
|
41
|
+
transport: Transport | None = None,
|
|
42
|
+
cache_enabled: bool | None = None,
|
|
43
|
+
cache_ttl_seconds: int | None = None,
|
|
44
|
+
):
|
|
45
|
+
self.api_key = api_key or os.getenv("STACKLINK_API_KEY")
|
|
46
|
+
if not self.api_key:
|
|
47
|
+
raise StacklinkError("STACKLINK_API_KEY is required.")
|
|
48
|
+
self.registration_key = registration_key or os.getenv("STACKLINK_REGISTRATION_KEY")
|
|
49
|
+
self.base_url = (base_url or os.getenv("STACKLINK_BASE_URL") or "http://localhost:3000").rstrip("/")
|
|
50
|
+
self.timeout = (timeout_ms or int(os.getenv("STACKLINK_TIMEOUT_MS", "30000"))) / 1000
|
|
51
|
+
self._transport = transport
|
|
52
|
+
self.cache_enabled = cache_enabled if cache_enabled is not None else True
|
|
53
|
+
self.cache_ttl_seconds = cache_ttl_seconds or int(os.getenv("STACKLINK_CACHE_TTL_SECONDS", "120"))
|
|
54
|
+
self._cache: dict[str, tuple[float, Json]] = {}
|
|
55
|
+
self.sessions = SessionsResource(self)
|
|
56
|
+
self.tools = ToolsResource(self)
|
|
57
|
+
self.mcp = McpResource(self)
|
|
58
|
+
self.executions = ExecutionsResource(self)
|
|
59
|
+
self.workbench = WorkbenchResource(self)
|
|
60
|
+
self.auth_configs = AuthConfigsResource(self)
|
|
61
|
+
self.connected_accounts = ConnectedAccountsResource(self)
|
|
62
|
+
self.triggers = TriggersResource(self)
|
|
63
|
+
self.webhooks = WebhooksResource(self)
|
|
64
|
+
self.logs = LogsResource(self)
|
|
65
|
+
self.memory = MemoryResource(self)
|
|
66
|
+
self.knowledge = KnowledgeResource(self)
|
|
67
|
+
self.billing = BillingResource(self)
|
|
68
|
+
self.api_keys = ApiKeysResource(self)
|
|
69
|
+
self.projects = ProjectsResource(self)
|
|
70
|
+
self.artifacts = ArtifactsResource(self)
|
|
71
|
+
|
|
72
|
+
def clear_cache(self, prefix: str | None = None) -> None:
|
|
73
|
+
if prefix is None:
|
|
74
|
+
self._cache.clear()
|
|
75
|
+
return
|
|
76
|
+
for key in list(self._cache.keys()):
|
|
77
|
+
if key.startswith(prefix):
|
|
78
|
+
del self._cache[key]
|
|
79
|
+
|
|
80
|
+
def cached_request(
|
|
81
|
+
self,
|
|
82
|
+
cache_key: str,
|
|
83
|
+
method: str,
|
|
84
|
+
path: str,
|
|
85
|
+
*,
|
|
86
|
+
json_body: Json | None = None,
|
|
87
|
+
query: Mapping[str, Any] | None = None,
|
|
88
|
+
ttl_seconds: int | None = None,
|
|
89
|
+
bypass_cache: bool = False,
|
|
90
|
+
) -> Json:
|
|
91
|
+
if not self.cache_enabled or bypass_cache:
|
|
92
|
+
return self.request(method, path, json_body=json_body, query=query)
|
|
93
|
+
now = time.time()
|
|
94
|
+
cached = self._cache.get(cache_key)
|
|
95
|
+
if cached and cached[0] > now:
|
|
96
|
+
return cached[1]
|
|
97
|
+
value = self.request(method, path, json_body=json_body, query=query)
|
|
98
|
+
self._cache[cache_key] = (now + (ttl_seconds or self.cache_ttl_seconds), value)
|
|
99
|
+
return value
|
|
100
|
+
|
|
101
|
+
def request(
|
|
102
|
+
self,
|
|
103
|
+
method: str,
|
|
104
|
+
path: str,
|
|
105
|
+
*,
|
|
106
|
+
json_body: Json | None = None,
|
|
107
|
+
query: Mapping[str, Any] | None = None,
|
|
108
|
+
headers: Mapping[str, str] | None = None,
|
|
109
|
+
registration: bool = False,
|
|
110
|
+
) -> Json:
|
|
111
|
+
if registration:
|
|
112
|
+
if not self.registration_key:
|
|
113
|
+
raise StacklinkError("STACKLINK_REGISTRATION_KEY is required for registration/admin SDK methods.")
|
|
114
|
+
request_headers = {
|
|
115
|
+
"x-stacklink-registration-key": self.registration_key,
|
|
116
|
+
"content-type": "application/json",
|
|
117
|
+
**(headers or {}),
|
|
118
|
+
"user-agent": STACKLINK_PYTHON_USER_AGENT,
|
|
119
|
+
"x-stacklink-client-channel": "python_sdk",
|
|
120
|
+
"x-stacklink-client-name": "stacklink-python",
|
|
121
|
+
"x-stacklink-client-version": STACKLINK_PYTHON_SDK_VERSION,
|
|
122
|
+
}
|
|
123
|
+
else:
|
|
124
|
+
request_headers = {
|
|
125
|
+
"authorization": f"Bearer {self.api_key}",
|
|
126
|
+
"content-type": "application/json",
|
|
127
|
+
**(headers or {}),
|
|
128
|
+
"user-agent": STACKLINK_PYTHON_USER_AGENT,
|
|
129
|
+
"x-stacklink-client-channel": "python_sdk",
|
|
130
|
+
"x-stacklink-client-name": "stacklink-python",
|
|
131
|
+
"x-stacklink-client-version": STACKLINK_PYTHON_SDK_VERSION,
|
|
132
|
+
}
|
|
133
|
+
query_values = {key: value for key, value in (query or {}).items() if value is not None}
|
|
134
|
+
if self._transport:
|
|
135
|
+
status, response_headers, body = self._transport(method, path, request_headers, json_body, query_values, self.timeout)
|
|
136
|
+
return self._handle_response(status, response_headers, body)
|
|
137
|
+
|
|
138
|
+
url = f"{self.base_url}{path}"
|
|
139
|
+
if query_values:
|
|
140
|
+
url = f"{url}?{urlencode(query_values, doseq=True)}"
|
|
141
|
+
data = None if json_body is None else json.dumps(json_body).encode("utf-8")
|
|
142
|
+
request = Request(url, data=data, method=method, headers=request_headers)
|
|
143
|
+
try:
|
|
144
|
+
with urlopen(request, timeout=self.timeout) as response:
|
|
145
|
+
raw = response.read().decode("utf-8")
|
|
146
|
+
body = json.loads(raw) if raw else None
|
|
147
|
+
return self._handle_response(response.status, dict(response.headers.items()), body)
|
|
148
|
+
except HTTPError as error:
|
|
149
|
+
raw = error.read().decode("utf-8")
|
|
150
|
+
try:
|
|
151
|
+
body = json.loads(raw) if raw else {}
|
|
152
|
+
except json.JSONDecodeError:
|
|
153
|
+
body = {"message": raw}
|
|
154
|
+
return self._handle_response(error.code, dict(error.headers.items()), body)
|
|
155
|
+
|
|
156
|
+
def request_raw(
|
|
157
|
+
self,
|
|
158
|
+
method: str,
|
|
159
|
+
path: str,
|
|
160
|
+
*,
|
|
161
|
+
query: Mapping[str, Any] | None = None,
|
|
162
|
+
headers: Mapping[str, str] | None = None,
|
|
163
|
+
) -> bytes:
|
|
164
|
+
request_headers = {
|
|
165
|
+
"authorization": f"Bearer {self.api_key}",
|
|
166
|
+
**(headers or {}),
|
|
167
|
+
"x-stacklink-client-channel": "python_sdk",
|
|
168
|
+
"x-stacklink-client-name": "stacklink-python",
|
|
169
|
+
"x-stacklink-client-version": STACKLINK_PYTHON_SDK_VERSION,
|
|
170
|
+
}
|
|
171
|
+
query_values = {key: value for key, value in (query or {}).items() if value is not None}
|
|
172
|
+
if self._transport:
|
|
173
|
+
status, response_headers, body = self._transport(method, path, request_headers, None, query_values, self.timeout)
|
|
174
|
+
if status >= 400:
|
|
175
|
+
self._handle_response(status, response_headers, body)
|
|
176
|
+
if isinstance(body, bytes):
|
|
177
|
+
return body
|
|
178
|
+
if isinstance(body, str):
|
|
179
|
+
return body.encode("utf-8")
|
|
180
|
+
return json.dumps(body).encode("utf-8")
|
|
181
|
+
|
|
182
|
+
url = f"{self.base_url}{path}"
|
|
183
|
+
if query_values:
|
|
184
|
+
url = f"{url}?{urlencode(query_values, doseq=True)}"
|
|
185
|
+
request = Request(url, method=method, headers=request_headers)
|
|
186
|
+
try:
|
|
187
|
+
with urlopen(request, timeout=self.timeout) as response:
|
|
188
|
+
return response.read()
|
|
189
|
+
except HTTPError as error:
|
|
190
|
+
raw = error.read().decode("utf-8")
|
|
191
|
+
try:
|
|
192
|
+
body = json.loads(raw) if raw else {}
|
|
193
|
+
except json.JSONDecodeError:
|
|
194
|
+
body = {"message": raw}
|
|
195
|
+
self._handle_response(error.code, dict(error.headers.items()), body)
|
|
196
|
+
return b""
|
|
197
|
+
|
|
198
|
+
def request_sse(
|
|
199
|
+
self,
|
|
200
|
+
method: str,
|
|
201
|
+
path: str,
|
|
202
|
+
*,
|
|
203
|
+
query: Mapping[str, Any] | None = None,
|
|
204
|
+
headers: Mapping[str, str] | None = None,
|
|
205
|
+
) -> Iterator[dict[str, Any]]:
|
|
206
|
+
request_headers = {
|
|
207
|
+
"authorization": f"Bearer {self.api_key}",
|
|
208
|
+
"accept": "text/event-stream",
|
|
209
|
+
**(headers or {}),
|
|
210
|
+
}
|
|
211
|
+
query_values = {key: value for key, value in (query or {}).items() if value is not None}
|
|
212
|
+
if self._transport:
|
|
213
|
+
status, response_headers, body = self._transport(method, path, request_headers, None, query_values, self.timeout)
|
|
214
|
+
if status >= 400:
|
|
215
|
+
self._handle_response(status, response_headers, body)
|
|
216
|
+
if isinstance(body, str):
|
|
217
|
+
yield from self._parse_sse_lines(body.splitlines())
|
|
218
|
+
elif isinstance(body, list):
|
|
219
|
+
for event in body:
|
|
220
|
+
if isinstance(event, dict):
|
|
221
|
+
yield event
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
url = f"{self.base_url}{path}"
|
|
225
|
+
if query_values:
|
|
226
|
+
url = f"{url}?{urlencode(query_values, doseq=True)}"
|
|
227
|
+
request = Request(url, method=method, headers=request_headers)
|
|
228
|
+
try:
|
|
229
|
+
with urlopen(request, timeout=self.timeout) as response:
|
|
230
|
+
yield from self._parse_sse_lines(line.decode("utf-8") for line in response)
|
|
231
|
+
except HTTPError as error:
|
|
232
|
+
raw = error.read().decode("utf-8")
|
|
233
|
+
try:
|
|
234
|
+
body = json.loads(raw) if raw else {}
|
|
235
|
+
except json.JSONDecodeError:
|
|
236
|
+
body = {"message": raw}
|
|
237
|
+
self._handle_response(error.code, dict(error.headers.items()), body)
|
|
238
|
+
|
|
239
|
+
def _handle_response(self, status: int, headers: Mapping[str, str], body: Json) -> Json:
|
|
240
|
+
if status < 400:
|
|
241
|
+
return body
|
|
242
|
+
message = body.get("message") if isinstance(body, dict) else None
|
|
243
|
+
code = body.get("code") if isinstance(body, dict) else None
|
|
244
|
+
details = body.get("details") if isinstance(body, dict) else None
|
|
245
|
+
if status == 429:
|
|
246
|
+
raise RateLimitError(message or "Stacklink rate limit exceeded.", status_code=status, code=code, details=details, headers=headers)
|
|
247
|
+
raise StacklinkError(message or "Stacklink request failed.", status_code=status, code=code, details=details)
|
|
248
|
+
|
|
249
|
+
def _parse_sse_lines(self, lines) -> Iterator[dict[str, Any]]:
|
|
250
|
+
event_id = None
|
|
251
|
+
event_name = None
|
|
252
|
+
data_lines: list[str] = []
|
|
253
|
+
|
|
254
|
+
def emit_event():
|
|
255
|
+
if not data_lines:
|
|
256
|
+
return None
|
|
257
|
+
data_text = "\n".join(data_lines)
|
|
258
|
+
try:
|
|
259
|
+
data: Any = json.loads(data_text)
|
|
260
|
+
except json.JSONDecodeError:
|
|
261
|
+
data = data_text
|
|
262
|
+
return {
|
|
263
|
+
"id": event_id,
|
|
264
|
+
"event": event_name,
|
|
265
|
+
"data": data,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for raw_line in lines:
|
|
269
|
+
line = str(raw_line).rstrip("\r\n")
|
|
270
|
+
if line == "":
|
|
271
|
+
event = emit_event()
|
|
272
|
+
if event is not None:
|
|
273
|
+
yield event
|
|
274
|
+
event_id = None
|
|
275
|
+
event_name = None
|
|
276
|
+
data_lines = []
|
|
277
|
+
continue
|
|
278
|
+
if line.startswith(":"):
|
|
279
|
+
continue
|
|
280
|
+
field, separator, value = line.partition(":")
|
|
281
|
+
if separator and value.startswith(" "):
|
|
282
|
+
value = value[1:]
|
|
283
|
+
if field == "id":
|
|
284
|
+
event_id = value
|
|
285
|
+
elif field == "event":
|
|
286
|
+
event_name = value
|
|
287
|
+
elif field == "data":
|
|
288
|
+
data_lines.append(value)
|
|
289
|
+
|
|
290
|
+
event = emit_event()
|
|
291
|
+
if event is not None:
|
|
292
|
+
yield event
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ConnectedAccountsResource:
|
|
8
|
+
def __init__(self, client):
|
|
9
|
+
self.client = client
|
|
10
|
+
|
|
11
|
+
def list(self, **query):
|
|
12
|
+
return self.client.request("GET", "/v1/connections", query=query)
|
|
13
|
+
|
|
14
|
+
def get(self, account_id: str):
|
|
15
|
+
return self.client.request("GET", f"/v1/connections/{account_id}")
|
|
16
|
+
|
|
17
|
+
def list_tools(
|
|
18
|
+
self,
|
|
19
|
+
account_id: str,
|
|
20
|
+
*,
|
|
21
|
+
include_planned: bool | None = None,
|
|
22
|
+
tool_keys: list[str] | None = None,
|
|
23
|
+
):
|
|
24
|
+
query: dict[str, Any] = {}
|
|
25
|
+
if include_planned is not None:
|
|
26
|
+
query["includePlanned"] = str(include_planned).lower()
|
|
27
|
+
if tool_keys:
|
|
28
|
+
query["toolKeys"] = tool_keys
|
|
29
|
+
return self.client.request("GET", f"/v1/connections/{account_id}/tools", query=query)
|
|
30
|
+
|
|
31
|
+
def list_resources(self, account_id: str):
|
|
32
|
+
return self.client.request("GET", f"/v1/connections/{account_id}/resources")
|
|
33
|
+
|
|
34
|
+
def refresh_resources(self, account_id: str):
|
|
35
|
+
return self.client.request("POST", f"/v1/connections/{account_id}/resources/refresh")
|
|
36
|
+
|
|
37
|
+
def resolve_resource(self, account_id: str, *, resource_key: str, external_resource_id: str):
|
|
38
|
+
return self.client.request(
|
|
39
|
+
"POST",
|
|
40
|
+
f"/v1/connections/{account_id}/resources/resolve",
|
|
41
|
+
json_body={
|
|
42
|
+
"resourceKey": resource_key,
|
|
43
|
+
"externalResourceId": external_resource_id,
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def update(self, account_id: str, body: dict[str, Any]):
|
|
48
|
+
return self.client.request("PATCH", f"/v1/connections/{account_id}", json_body=body)
|
|
49
|
+
|
|
50
|
+
def enable(self, account_id: str):
|
|
51
|
+
return self.client.request("POST", f"/v1/connections/{account_id}/enable")
|
|
52
|
+
|
|
53
|
+
def disable(self, account_id: str):
|
|
54
|
+
return self.client.request("POST", f"/v1/connections/{account_id}/disable")
|
|
55
|
+
|
|
56
|
+
def refresh(self, account_id: str):
|
|
57
|
+
return self.client.request("POST", f"/v1/connections/{account_id}/refresh")
|
|
58
|
+
|
|
59
|
+
def delete(self, account_id: str):
|
|
60
|
+
return self.client.request("DELETE", f"/v1/connections/{account_id}")
|
|
61
|
+
|
|
62
|
+
def validate(self, account_id: str):
|
|
63
|
+
return self.client.request("POST", f"/v1/connections/{account_id}/validate")
|
|
64
|
+
|
|
65
|
+
def inspect_connect_token(self, public_token: str):
|
|
66
|
+
return self.client.request("GET", f"/v1/connect/{public_token}")
|
|
67
|
+
|
|
68
|
+
def complete_connect_token(self, public_token: str, body: dict[str, Any]):
|
|
69
|
+
return self.client.request("POST", f"/v1/connect/{public_token}/complete", json_body=body)
|
|
70
|
+
|
|
71
|
+
def get_connection_request(self, request_id: str):
|
|
72
|
+
return self.client.request("GET", f"/v1/connection-requests/{request_id}")
|
|
73
|
+
|
|
74
|
+
def wait_for_connections(self, request_ids: list[str], timeout_seconds: float = 120, interval_seconds: float = 2):
|
|
75
|
+
started_at = time.monotonic()
|
|
76
|
+
while time.monotonic() - started_at <= timeout_seconds:
|
|
77
|
+
connection_requests = [
|
|
78
|
+
self.get_connection_request(request_id).get("connectionRequest")
|
|
79
|
+
for request_id in request_ids
|
|
80
|
+
]
|
|
81
|
+
statuses = [request.get("status") for request in connection_requests if isinstance(request, dict)]
|
|
82
|
+
if len(statuses) == len(request_ids) and all(status == "completed" for status in statuses):
|
|
83
|
+
return {"status": "completed", "connectionRequests": connection_requests}
|
|
84
|
+
terminal = next(
|
|
85
|
+
(
|
|
86
|
+
request
|
|
87
|
+
for request in connection_requests
|
|
88
|
+
if isinstance(request, dict) and request.get("status") in {"failed", "expired", "cancelled"}
|
|
89
|
+
),
|
|
90
|
+
None,
|
|
91
|
+
)
|
|
92
|
+
if isinstance(terminal, dict):
|
|
93
|
+
raise RuntimeError(f"Stacklink connection ended with status {terminal.get('status')}.")
|
|
94
|
+
time.sleep(interval_seconds)
|
|
95
|
+
raise TimeoutError("Timed out waiting for Stacklink connections.")
|
stacklink/errors.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
class StacklinkError(Exception):
|
|
4
|
+
def __init__(self, message: str, *, status_code: int | None = None, code: str | None = None, details=None):
|
|
5
|
+
super().__init__(message)
|
|
6
|
+
self.status_code = status_code
|
|
7
|
+
self.code = code
|
|
8
|
+
self.details = details
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RateLimitError(StacklinkError):
|
|
12
|
+
def __init__(self, message: str, *, status_code: int = 429, code: str | None = "rate_limited", details=None, headers=None):
|
|
13
|
+
super().__init__(message, status_code=status_code, code=code, details=details)
|
|
14
|
+
self.headers = headers or {}
|
|
15
|
+
|
stacklink/executions.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, TypedDict, cast
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ExecutionListPage(TypedDict):
|
|
7
|
+
executions: list[dict[str, Any]]
|
|
8
|
+
limit: int
|
|
9
|
+
nextCursor: str | None
|
|
10
|
+
liveCount: int
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ExecutionEventsPage(TypedDict):
|
|
14
|
+
events: list[dict[str, Any]]
|
|
15
|
+
limit: int
|
|
16
|
+
hasMore: bool
|
|
17
|
+
nextCursor: str | None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ExecutionsResource:
|
|
21
|
+
def __init__(self, client):
|
|
22
|
+
self.client = client
|
|
23
|
+
|
|
24
|
+
def list(self, session_id: str, **query) -> ExecutionListPage:
|
|
25
|
+
return cast(ExecutionListPage, self.client.request(
|
|
26
|
+
"GET",
|
|
27
|
+
"/v1/executions",
|
|
28
|
+
query={"sessionId": session_id, **query},
|
|
29
|
+
))
|
|
30
|
+
|
|
31
|
+
def get(self, session_id: str, execution_kind: str, execution_id: str, **query):
|
|
32
|
+
return self.client.request("GET", f"/v1/executions/{execution_kind}/{execution_id}", query={"sessionId": session_id, **query})
|
|
33
|
+
|
|
34
|
+
def events(self, session_id: str, execution_kind: str, execution_id: str, **query) -> ExecutionEventsPage:
|
|
35
|
+
return cast(ExecutionEventsPage, self.client.request(
|
|
36
|
+
"GET",
|
|
37
|
+
f"/v1/executions/{execution_kind}/{execution_id}/events",
|
|
38
|
+
query={"sessionId": session_id, **query},
|
|
39
|
+
))
|
|
40
|
+
|
|
41
|
+
def wait(self, session_id: str, execution_kind: str, execution_id: str, body: dict[str, Any] | None = None):
|
|
42
|
+
return self.client.request("POST", f"/v1/executions/{execution_kind}/{execution_id}/wait", json_body={"sessionId": session_id, **(body or {})})
|
|
43
|
+
|
|
44
|
+
def cancel(self, session_id: str, execution_kind: str, execution_id: str, reason: str | None = None):
|
|
45
|
+
return self.client.request("POST", f"/v1/executions/{execution_kind}/{execution_id}/cancel", json_body={"sessionId": session_id, "reason": reason})
|
stacklink/knowledge.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class KnowledgeResource:
|
|
7
|
+
def __init__(self, client):
|
|
8
|
+
self.client = client
|
|
9
|
+
|
|
10
|
+
def packs(self, **query):
|
|
11
|
+
return self.client.request("GET", "/v1/knowledge/packs", query=query)
|
|
12
|
+
|
|
13
|
+
def capabilities(self, **query):
|
|
14
|
+
return self.client.request("GET", "/v1/knowledge/capabilities", query=query)
|
|
15
|
+
|
|
16
|
+
def search(self, body: dict[str, Any]):
|
|
17
|
+
return self.client.request("POST", "/v1/knowledge/search", json_body=body)
|
|
18
|
+
|
|
19
|
+
def lookup_citation(self, body: dict[str, Any]):
|
|
20
|
+
return self.client.request("POST", "/v1/knowledge/citations/lookup", json_body=body)
|
|
21
|
+
|
|
22
|
+
def verify_answer(self, body: dict[str, Any]):
|
|
23
|
+
return self.client.request("POST", "/v1/knowledge/answers/verify", json_body=body)
|
|
24
|
+
|
stacklink/logs.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
class LogsResource:
|
|
4
|
+
def __init__(self, client):
|
|
5
|
+
self.client = client
|
|
6
|
+
|
|
7
|
+
def query(self, **query):
|
|
8
|
+
return self.requests(**query)
|
|
9
|
+
|
|
10
|
+
def summary(self, **query):
|
|
11
|
+
return self.client.request("GET", "/v1/logs/summary", query=query)
|
|
12
|
+
|
|
13
|
+
def requests(self, **query):
|
|
14
|
+
return self.client.request("GET", "/v1/logs/requests", query=query)
|
|
15
|
+
|
|
16
|
+
def tools(self, **query):
|
|
17
|
+
return self.client.request("GET", "/v1/logs/tools", query=query)
|
|
18
|
+
|
|
19
|
+
def triggers(self, **query):
|
|
20
|
+
return self.client.request("GET", "/v1/logs/triggers", query=query)
|
|
21
|
+
|
|
22
|
+
def webhook_deliveries(self, **query):
|
|
23
|
+
return self.client.request("GET", "/v1/logs/webhook-deliveries", query=query)
|
|
24
|
+
|
|
25
|
+
def request_log(self, log_id: str):
|
|
26
|
+
return self.client.request("GET", f"/v1/logs/requests/{log_id}")
|
|
27
|
+
|
|
28
|
+
def tool_log(self, log_id: str):
|
|
29
|
+
return self.client.request("GET", f"/v1/logs/tools/{log_id}")
|