stacklink-sdk 0.2.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.
- stacklink_sdk-0.2.0/LICENSE +9 -0
- stacklink_sdk-0.2.0/PKG-INFO +71 -0
- stacklink_sdk-0.2.0/README.md +53 -0
- stacklink_sdk-0.2.0/pyproject.toml +26 -0
- stacklink_sdk-0.2.0/setup.cfg +4 -0
- stacklink_sdk-0.2.0/stacklink/__init__.py +42 -0
- stacklink_sdk-0.2.0/stacklink/api_keys.py +18 -0
- stacklink_sdk-0.2.0/stacklink/artifacts.py +9 -0
- stacklink_sdk-0.2.0/stacklink/auth_configs.py +29 -0
- stacklink_sdk-0.2.0/stacklink/billing.py +43 -0
- stacklink_sdk-0.2.0/stacklink/claude_agent.py +29 -0
- stacklink_sdk-0.2.0/stacklink/client.py +292 -0
- stacklink_sdk-0.2.0/stacklink/connected_accounts.py +95 -0
- stacklink_sdk-0.2.0/stacklink/errors.py +15 -0
- stacklink_sdk-0.2.0/stacklink/executions.py +45 -0
- stacklink_sdk-0.2.0/stacklink/knowledge.py +24 -0
- stacklink_sdk-0.2.0/stacklink/logs.py +29 -0
- stacklink_sdk-0.2.0/stacklink/mcp.py +76 -0
- stacklink_sdk-0.2.0/stacklink/memory.py +235 -0
- stacklink_sdk-0.2.0/stacklink/openai_agents.py +40 -0
- stacklink_sdk-0.2.0/stacklink/openai_memory.py +432 -0
- stacklink_sdk-0.2.0/stacklink/projects.py +20 -0
- stacklink_sdk-0.2.0/stacklink/sessions.py +223 -0
- stacklink_sdk-0.2.0/stacklink/tools.py +144 -0
- stacklink_sdk-0.2.0/stacklink/triggers.py +27 -0
- stacklink_sdk-0.2.0/stacklink/types.py +290 -0
- stacklink_sdk-0.2.0/stacklink/webhooks.py +29 -0
- stacklink_sdk-0.2.0/stacklink/workbench.py +1090 -0
- stacklink_sdk-0.2.0/stacklink_sdk.egg-info/PKG-INFO +71 -0
- stacklink_sdk-0.2.0/stacklink_sdk.egg-info/SOURCES.txt +37 -0
- stacklink_sdk-0.2.0/stacklink_sdk.egg-info/dependency_links.txt +1 -0
- stacklink_sdk-0.2.0/stacklink_sdk.egg-info/requires.txt +9 -0
- stacklink_sdk-0.2.0/stacklink_sdk.egg-info/top_level.txt +1 -0
- stacklink_sdk-0.2.0/tests/test_client.py +1292 -0
- stacklink_sdk-0.2.0/tests/test_google_docs_provider.py +47 -0
- stacklink_sdk-0.2.0/tests/test_google_drive_provider.py +57 -0
- stacklink_sdk-0.2.0/tests/test_google_sheets_provider.py +51 -0
- stacklink_sdk-0.2.0/tests/test_openai_memory.py +160 -0
- stacklink_sdk-0.2.0/tests/test_zoho_books_provider.py +48 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Copyright (c) 2026 Stacklink. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This software is proprietary. You may install and use it only to access
|
|
4
|
+
Stacklink services under an agreement with Stacklink. You may not copy,
|
|
5
|
+
modify, redistribute, sublicense, sell, or otherwise make this software
|
|
6
|
+
available to another party except where your agreement with Stacklink
|
|
7
|
+
expressly permits it.
|
|
8
|
+
|
|
9
|
+
This notice must remain with every permitted copy.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stacklink-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python SDK for Stacklink sessions, tools, MCP, auth, workbench, logs, knowledge, and billing.
|
|
5
|
+
Author: Stacklink
|
|
6
|
+
License-Expression: LicenseRef-Proprietary
|
|
7
|
+
Project-URL: Documentation, https://docs.stacklink.in/docs/quickstart
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Provides-Extra: openai
|
|
12
|
+
Requires-Dist: openai<3,>=2.0; extra == "openai"
|
|
13
|
+
Provides-Extra: openai-agents
|
|
14
|
+
Requires-Dist: openai-agents<0.19,>=0.18; extra == "openai-agents"
|
|
15
|
+
Provides-Extra: claude-agent
|
|
16
|
+
Requires-Dist: claude-agent-sdk<0.3,>=0.2; extra == "claude-agent"
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# Stacklink Python SDK
|
|
20
|
+
|
|
21
|
+
Stacklink supplies executable, governed tools to an agent harness. The selected harness owns the model call, tool loop, and final answer.
|
|
22
|
+
|
|
23
|
+
Install the public package with the harness extra you use:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uv add 'stacklink-sdk[openai-agents]'
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
or:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
uv add 'stacklink-sdk[claude-agent]'
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The complete guides and certified recipes live at [docs.stacklink.in](https://docs.stacklink.in/docs/quickstart). PyPI is the package delivery system, not the primary documentation surface. Stacklink does not display registry download badges in its product or documentation.
|
|
36
|
+
|
|
37
|
+
## Certified agent harnesses
|
|
38
|
+
|
|
39
|
+
| Harness | Stacklink adapter |
|
|
40
|
+
|---|---|
|
|
41
|
+
| OpenAI Agents SDK | `create_stacklink_openai_agents_tools(session)` |
|
|
42
|
+
| Claude Agent SDK | `create_stacklink_claude_agent_tool_config(session)` |
|
|
43
|
+
|
|
44
|
+
Both start with one explicit Stacklink session:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import os
|
|
48
|
+
from stacklink import Stacklink
|
|
49
|
+
|
|
50
|
+
stacklink = Stacklink(
|
|
51
|
+
api_key=os.environ['STACKLINK_API_KEY'],
|
|
52
|
+
base_url=os.getenv('STACKLINK_BASE_URL', 'https://api.stacklink.in'),
|
|
53
|
+
)
|
|
54
|
+
session = stacklink.sessions.create_bound({
|
|
55
|
+
'externalTenantId': 'acme',
|
|
56
|
+
'externalUserId': 'user_123',
|
|
57
|
+
'metaTools': {'surface': 'connector'},
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
# Pass the selected Stacklink adapter output to your agent harness.
|
|
62
|
+
pass
|
|
63
|
+
finally:
|
|
64
|
+
session.close()
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The complete OpenAI Agents and Claude Agent examples shown by the Stacklink Overview are generated from the executable files under `examples/sdk/`. The dashboard, documentation, and certification tests all read those same files.
|
|
68
|
+
|
|
69
|
+
The raw OpenAI `with_stacklink_memory` helper remains a separate Memory integration. It is not the agent-tool integration and is not part of basic Overview onboarding.
|
|
70
|
+
|
|
71
|
+
Environment variables are `STACKLINK_API_KEY`, optional `STACKLINK_BASE_URL`, and the model-provider credentials required by the selected harness. The SDK is proprietary and is provided under the applicable Stacklink agreement.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Stacklink Python SDK
|
|
2
|
+
|
|
3
|
+
Stacklink supplies executable, governed tools to an agent harness. The selected harness owns the model call, tool loop, and final answer.
|
|
4
|
+
|
|
5
|
+
Install the public package with the harness extra you use:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv add 'stacklink-sdk[openai-agents]'
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
or:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
uv add 'stacklink-sdk[claude-agent]'
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The complete guides and certified recipes live at [docs.stacklink.in](https://docs.stacklink.in/docs/quickstart). PyPI is the package delivery system, not the primary documentation surface. Stacklink does not display registry download badges in its product or documentation.
|
|
18
|
+
|
|
19
|
+
## Certified agent harnesses
|
|
20
|
+
|
|
21
|
+
| Harness | Stacklink adapter |
|
|
22
|
+
|---|---|
|
|
23
|
+
| OpenAI Agents SDK | `create_stacklink_openai_agents_tools(session)` |
|
|
24
|
+
| Claude Agent SDK | `create_stacklink_claude_agent_tool_config(session)` |
|
|
25
|
+
|
|
26
|
+
Both start with one explicit Stacklink session:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import os
|
|
30
|
+
from stacklink import Stacklink
|
|
31
|
+
|
|
32
|
+
stacklink = Stacklink(
|
|
33
|
+
api_key=os.environ['STACKLINK_API_KEY'],
|
|
34
|
+
base_url=os.getenv('STACKLINK_BASE_URL', 'https://api.stacklink.in'),
|
|
35
|
+
)
|
|
36
|
+
session = stacklink.sessions.create_bound({
|
|
37
|
+
'externalTenantId': 'acme',
|
|
38
|
+
'externalUserId': 'user_123',
|
|
39
|
+
'metaTools': {'surface': 'connector'},
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
# Pass the selected Stacklink adapter output to your agent harness.
|
|
44
|
+
pass
|
|
45
|
+
finally:
|
|
46
|
+
session.close()
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The complete OpenAI Agents and Claude Agent examples shown by the Stacklink Overview are generated from the executable files under `examples/sdk/`. The dashboard, documentation, and certification tests all read those same files.
|
|
50
|
+
|
|
51
|
+
The raw OpenAI `with_stacklink_memory` helper remains a separate Memory integration. It is not the agent-tool integration and is not part of basic Overview onboarding.
|
|
52
|
+
|
|
53
|
+
Environment variables are `STACKLINK_API_KEY`, optional `STACKLINK_BASE_URL`, and the model-provider credentials required by the selected harness. The SDK is proprietary and is provided under the applicable Stacklink agreement.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "stacklink-sdk"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Python SDK for Stacklink sessions, tools, MCP, auth, workbench, logs, knowledge, and billing."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "LicenseRef-Proprietary"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "Stacklink" }]
|
|
14
|
+
dependencies = []
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Documentation = "https://docs.stacklink.in/docs/quickstart"
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
openai = ["openai>=2.0,<3"]
|
|
21
|
+
openai-agents = ["openai-agents>=0.18,<0.19"]
|
|
22
|
+
claude-agent = ["claude-agent-sdk>=0.2,<0.3"]
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["."]
|
|
26
|
+
include = ["stacklink*"]
|
|
@@ -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
|
+
]
|
|
@@ -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
|
+
|
|
@@ -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}")
|
|
@@ -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
|
+
}
|
|
@@ -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
|