hsafa-sdk 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.
- hsafa_sdk-0.1.0/PKG-INFO +82 -0
- hsafa_sdk-0.1.0/README.md +74 -0
- hsafa_sdk-0.1.0/pyproject.toml +16 -0
- hsafa_sdk-0.1.0/setup.cfg +4 -0
- hsafa_sdk-0.1.0/src/hsafa_sdk/__init__.py +59 -0
- hsafa_sdk-0.1.0/src/hsafa_sdk/schema.py +54 -0
- hsafa_sdk-0.1.0/src/hsafa_sdk/sdk.py +323 -0
- hsafa_sdk-0.1.0/src/hsafa_sdk/types.py +206 -0
- hsafa_sdk-0.1.0/src/hsafa_sdk.egg-info/PKG-INFO +82 -0
- hsafa_sdk-0.1.0/src/hsafa_sdk.egg-info/SOURCES.txt +11 -0
- hsafa_sdk-0.1.0/src/hsafa_sdk.egg-info/dependency_links.txt +1 -0
- hsafa_sdk-0.1.0/src/hsafa_sdk.egg-info/requires.txt +1 -0
- hsafa_sdk-0.1.0/src/hsafa_sdk.egg-info/top_level.txt +1 -0
hsafa_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hsafa-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Hsafa SDK v7 — connect any service to a Haseef brain
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: httpx>=0.27.0
|
|
8
|
+
|
|
9
|
+
# hsafa-sdk
|
|
10
|
+
|
|
11
|
+
Python SDK for **Hsafa Core v7**. Connect any service to a Haseef brain — register tools, handle tool calls over SSE, push events, and read/write the haseef's memory and profile.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install hsafa-sdk
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or from source:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
cd sdks/python
|
|
23
|
+
pip install -e .
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import asyncio
|
|
30
|
+
import os
|
|
31
|
+
from hsafa_sdk import HsafaSDK, SdkOptions
|
|
32
|
+
|
|
33
|
+
async def main():
|
|
34
|
+
sdk = HsafaSDK(SdkOptions(
|
|
35
|
+
core_url="http://localhost:3001",
|
|
36
|
+
api_key=os.environ.get("HSAFA_CORE_KEY", "test-key"),
|
|
37
|
+
skill="weather",
|
|
38
|
+
))
|
|
39
|
+
|
|
40
|
+
# 1. Register tools
|
|
41
|
+
await sdk.register_tools([{
|
|
42
|
+
"name": "get_weather",
|
|
43
|
+
"description": "Get current weather for a city",
|
|
44
|
+
"input": {"city": "string", "units": "string?"},
|
|
45
|
+
}])
|
|
46
|
+
|
|
47
|
+
# 2. Handle tool calls
|
|
48
|
+
async def handle_weather(args, ctx):
|
|
49
|
+
print(f"{ctx['haseef']['name']} wants weather for {args.get('city')}")
|
|
50
|
+
return {"temperature": 72, "conditions": "sunny", "city": args.get("city")}
|
|
51
|
+
|
|
52
|
+
sdk.on_tool_call("get_weather", handle_weather)
|
|
53
|
+
|
|
54
|
+
# 3. Listen to lifecycle events
|
|
55
|
+
def on_run_started(e):
|
|
56
|
+
print(f"Run started for {e['haseef']['name']}")
|
|
57
|
+
|
|
58
|
+
sdk.on("run.started", on_run_started)
|
|
59
|
+
|
|
60
|
+
# 4. Connect the SSE stream (blocks until disconnect)
|
|
61
|
+
# For background usage alongside a web server:
|
|
62
|
+
# asyncio.create_task(sdk.connect())
|
|
63
|
+
try:
|
|
64
|
+
await sdk.connect()
|
|
65
|
+
except KeyboardInterrupt:
|
|
66
|
+
await sdk.disconnect()
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
asyncio.run(main())
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Namespaces
|
|
73
|
+
|
|
74
|
+
| Namespace | Methods |
|
|
75
|
+
|-----------|---------|
|
|
76
|
+
| `sdk.haseef` | `list()`, `get(id)`, `create(data)`, `update(id, patch)`, `delete(id)`, `get_profile(id)`, `update_profile(id, patch)`, `add_skill(id, name)`, `remove_skill(id, name)`, `status(id)` |
|
|
77
|
+
| `sdk.memory` | `list(id)`, `search(id, query, limit)`, `set(id, memories)`, `delete(id, keys)`, `episodes(id, limit)`, `search_episodes(id, query, limit)`, `social(id)`, `procedural(id)`, `stats(id)` |
|
|
78
|
+
| `sdk.runs` | `list(haseef_id?, status?, limit?)`, `get(run_id)` |
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# hsafa-sdk
|
|
2
|
+
|
|
3
|
+
Python SDK for **Hsafa Core v7**. Connect any service to a Haseef brain — register tools, handle tool calls over SSE, push events, and read/write the haseef's memory and profile.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install hsafa-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or from source:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
cd sdks/python
|
|
15
|
+
pip install -e .
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import asyncio
|
|
22
|
+
import os
|
|
23
|
+
from hsafa_sdk import HsafaSDK, SdkOptions
|
|
24
|
+
|
|
25
|
+
async def main():
|
|
26
|
+
sdk = HsafaSDK(SdkOptions(
|
|
27
|
+
core_url="http://localhost:3001",
|
|
28
|
+
api_key=os.environ.get("HSAFA_CORE_KEY", "test-key"),
|
|
29
|
+
skill="weather",
|
|
30
|
+
))
|
|
31
|
+
|
|
32
|
+
# 1. Register tools
|
|
33
|
+
await sdk.register_tools([{
|
|
34
|
+
"name": "get_weather",
|
|
35
|
+
"description": "Get current weather for a city",
|
|
36
|
+
"input": {"city": "string", "units": "string?"},
|
|
37
|
+
}])
|
|
38
|
+
|
|
39
|
+
# 2. Handle tool calls
|
|
40
|
+
async def handle_weather(args, ctx):
|
|
41
|
+
print(f"{ctx['haseef']['name']} wants weather for {args.get('city')}")
|
|
42
|
+
return {"temperature": 72, "conditions": "sunny", "city": args.get("city")}
|
|
43
|
+
|
|
44
|
+
sdk.on_tool_call("get_weather", handle_weather)
|
|
45
|
+
|
|
46
|
+
# 3. Listen to lifecycle events
|
|
47
|
+
def on_run_started(e):
|
|
48
|
+
print(f"Run started for {e['haseef']['name']}")
|
|
49
|
+
|
|
50
|
+
sdk.on("run.started", on_run_started)
|
|
51
|
+
|
|
52
|
+
# 4. Connect the SSE stream (blocks until disconnect)
|
|
53
|
+
# For background usage alongside a web server:
|
|
54
|
+
# asyncio.create_task(sdk.connect())
|
|
55
|
+
try:
|
|
56
|
+
await sdk.connect()
|
|
57
|
+
except KeyboardInterrupt:
|
|
58
|
+
await sdk.disconnect()
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
asyncio.run(main())
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Namespaces
|
|
65
|
+
|
|
66
|
+
| Namespace | Methods |
|
|
67
|
+
|-----------|---------|
|
|
68
|
+
| `sdk.haseef` | `list()`, `get(id)`, `create(data)`, `update(id, patch)`, `delete(id)`, `get_profile(id)`, `update_profile(id, patch)`, `add_skill(id, name)`, `remove_skill(id, name)`, `status(id)` |
|
|
69
|
+
| `sdk.memory` | `list(id)`, `search(id, query, limit)`, `set(id, memories)`, `delete(id, keys)`, `episodes(id, limit)`, `search_episodes(id, query, limit)`, `social(id)`, `procedural(id)`, `stats(id)` |
|
|
70
|
+
| `sdk.runs` | `list(haseef_id?, status?, limit?)`, `get(run_id)` |
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "hsafa-sdk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Hsafa SDK v7 — connect any service to a Haseef brain"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"httpx>=0.27.0",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[build-system]
|
|
12
|
+
requires = ["setuptools>=61.0"]
|
|
13
|
+
build-backend = "setuptools.build_meta"
|
|
14
|
+
|
|
15
|
+
[tool.setuptools.packages.find]
|
|
16
|
+
where = ["src"]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from .sdk import HsafaSDK
|
|
2
|
+
from .types import (
|
|
3
|
+
SdkOptions,
|
|
4
|
+
ToolDefinition,
|
|
5
|
+
ToolHandler,
|
|
6
|
+
ToolCallContext,
|
|
7
|
+
HaseefContext,
|
|
8
|
+
PushEventPayload,
|
|
9
|
+
Attachment,
|
|
10
|
+
ToolInputStartEvent,
|
|
11
|
+
ToolInputDeltaEvent,
|
|
12
|
+
ToolCallEvent,
|
|
13
|
+
ToolResultEvent,
|
|
14
|
+
ToolErrorEvent,
|
|
15
|
+
RunStartedEvent,
|
|
16
|
+
RunCompletedEvent,
|
|
17
|
+
Haseef,
|
|
18
|
+
CreateHaseefInput,
|
|
19
|
+
UpdateHaseefInput,
|
|
20
|
+
SemanticMemory,
|
|
21
|
+
SemanticMemoryInput,
|
|
22
|
+
EpisodicMemory,
|
|
23
|
+
SocialMemory,
|
|
24
|
+
ProceduralMemory,
|
|
25
|
+
MemoryStats,
|
|
26
|
+
Run,
|
|
27
|
+
ListRunsOptions,
|
|
28
|
+
)
|
|
29
|
+
from .schema import input_to_json_schema
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"HsafaSDK",
|
|
33
|
+
"SdkOptions",
|
|
34
|
+
"ToolDefinition",
|
|
35
|
+
"ToolHandler",
|
|
36
|
+
"ToolCallContext",
|
|
37
|
+
"HaseefContext",
|
|
38
|
+
"PushEventPayload",
|
|
39
|
+
"Attachment",
|
|
40
|
+
"ToolInputStartEvent",
|
|
41
|
+
"ToolInputDeltaEvent",
|
|
42
|
+
"ToolCallEvent",
|
|
43
|
+
"ToolResultEvent",
|
|
44
|
+
"ToolErrorEvent",
|
|
45
|
+
"RunStartedEvent",
|
|
46
|
+
"RunCompletedEvent",
|
|
47
|
+
"Haseef",
|
|
48
|
+
"CreateHaseefInput",
|
|
49
|
+
"UpdateHaseefInput",
|
|
50
|
+
"SemanticMemory",
|
|
51
|
+
"SemanticMemoryInput",
|
|
52
|
+
"EpisodicMemory",
|
|
53
|
+
"SocialMemory",
|
|
54
|
+
"ProceduralMemory",
|
|
55
|
+
"MemoryStats",
|
|
56
|
+
"Run",
|
|
57
|
+
"ListRunsOptions",
|
|
58
|
+
"input_to_json_schema",
|
|
59
|
+
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Dict, Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def input_to_json_schema(input_types: Dict[str, str]) -> Dict[str, Any]:
|
|
6
|
+
properties: Dict[str, Any] = {}
|
|
7
|
+
required: list[str] = []
|
|
8
|
+
|
|
9
|
+
for key, type_str in input_types.items():
|
|
10
|
+
optional = type_str.endswith('?')
|
|
11
|
+
base_type = type_str[:-1] if optional else type_str
|
|
12
|
+
|
|
13
|
+
if not optional:
|
|
14
|
+
required.append(key)
|
|
15
|
+
|
|
16
|
+
if base_type == 'string[]':
|
|
17
|
+
properties[key] = {'type': 'array', 'items': {'type': 'string'}}
|
|
18
|
+
elif base_type == 'number[]':
|
|
19
|
+
properties[key] = {'type': 'array', 'items': {'type': 'number'}}
|
|
20
|
+
elif base_type == 'boolean[]':
|
|
21
|
+
properties[key] = {'type': 'array', 'items': {'type': 'boolean'}}
|
|
22
|
+
elif base_type == 'object':
|
|
23
|
+
properties[key] = {'type': 'object', 'additionalProperties': True}
|
|
24
|
+
else:
|
|
25
|
+
properties[key] = {'type': base_type}
|
|
26
|
+
|
|
27
|
+
schema: Dict[str, Any] = {
|
|
28
|
+
'type': 'object',
|
|
29
|
+
'properties': properties,
|
|
30
|
+
'additionalProperties': False,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if required:
|
|
34
|
+
schema['required'] = required
|
|
35
|
+
|
|
36
|
+
return schema
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_partial_json(accumulated: str) -> Dict[str, Any]:
|
|
40
|
+
try:
|
|
41
|
+
return json.loads(accumulated)
|
|
42
|
+
except json.JSONDecodeError:
|
|
43
|
+
attempts = [
|
|
44
|
+
accumulated + '}',
|
|
45
|
+
accumulated + '"}',
|
|
46
|
+
accumulated + '"}]',
|
|
47
|
+
accumulated + '"}]}',
|
|
48
|
+
]
|
|
49
|
+
for attempt in attempts:
|
|
50
|
+
try:
|
|
51
|
+
return json.loads(attempt)
|
|
52
|
+
except json.JSONDecodeError:
|
|
53
|
+
continue
|
|
54
|
+
return {}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import inspect
|
|
3
|
+
import json
|
|
4
|
+
from typing import Dict, Any, List, Optional, Callable, Set
|
|
5
|
+
from urllib.parse import urlencode
|
|
6
|
+
|
|
7
|
+
from .types import (
|
|
8
|
+
SdkOptions,
|
|
9
|
+
ToolDefinition,
|
|
10
|
+
ToolHandler,
|
|
11
|
+
PushEventPayload,
|
|
12
|
+
ToolCallContext,
|
|
13
|
+
HaseefContext,
|
|
14
|
+
)
|
|
15
|
+
from .schema import input_to_json_schema, parse_partial_json
|
|
16
|
+
|
|
17
|
+
DEFAULT_RECONNECT_DELAY = 2.0
|
|
18
|
+
MAX_RECONNECT_DELAY = 30.0
|
|
19
|
+
DEFAULT_API_BASE = '/api/v7'
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HaseefAPI:
|
|
23
|
+
def __init__(self, sdk: 'HsafaSDK'):
|
|
24
|
+
self._sdk = sdk
|
|
25
|
+
|
|
26
|
+
async def list(self) -> List[Dict[str, Any]]:
|
|
27
|
+
res = await self._sdk._request("GET", f"{self._sdk.api_base}/haseefs")
|
|
28
|
+
return res.get("haseefs", [])
|
|
29
|
+
|
|
30
|
+
async def get(self, haseef_id: str) -> Dict[str, Any]:
|
|
31
|
+
res = await self._sdk._request("GET", f"{self._sdk.api_base}/haseefs/{haseef_id}")
|
|
32
|
+
return res.get("haseef", {})
|
|
33
|
+
|
|
34
|
+
async def create(self, input_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
35
|
+
res = await self._sdk._request("POST", f"{self._sdk.api_base}/haseefs", body=input_data)
|
|
36
|
+
return res.get("haseef", {})
|
|
37
|
+
|
|
38
|
+
async def update(self, haseef_id: str, patch: Dict[str, Any]) -> Dict[str, Any]:
|
|
39
|
+
res = await self._sdk._request("PATCH", f"{self._sdk.api_base}/haseefs/{haseef_id}", body=patch)
|
|
40
|
+
return res.get("haseef", {})
|
|
41
|
+
|
|
42
|
+
async def delete(self, haseef_id: str) -> None:
|
|
43
|
+
await self._sdk._request("DELETE", f"{self._sdk.api_base}/haseefs/{haseef_id}")
|
|
44
|
+
|
|
45
|
+
async def get_profile(self, haseef_id: str) -> Dict[str, Any]:
|
|
46
|
+
res = await self._sdk._request("GET", f"{self._sdk.api_base}/haseefs/{haseef_id}/profile")
|
|
47
|
+
return res.get("profile", {})
|
|
48
|
+
|
|
49
|
+
async def update_profile(self, haseef_id: str, patch: Dict[str, Any]) -> Dict[str, Any]:
|
|
50
|
+
res = await self._sdk._request("PATCH", f"{self._sdk.api_base}/haseefs/{haseef_id}/profile", body=patch)
|
|
51
|
+
return res.get("profile", {})
|
|
52
|
+
|
|
53
|
+
async def add_skill(self, haseef_id: str, skill_name: str) -> Dict[str, Any]:
|
|
54
|
+
haseef = await self.get(haseef_id)
|
|
55
|
+
current = haseef.get("skills") or []
|
|
56
|
+
if skill_name in current:
|
|
57
|
+
return haseef
|
|
58
|
+
return await self.update(haseef_id, {"skills": [*current, skill_name]})
|
|
59
|
+
|
|
60
|
+
async def remove_skill(self, haseef_id: str, skill_name: str) -> Dict[str, Any]:
|
|
61
|
+
haseef = await self.get(haseef_id)
|
|
62
|
+
current = haseef.get("skills") or []
|
|
63
|
+
if skill_name not in current:
|
|
64
|
+
return haseef
|
|
65
|
+
return await self.update(haseef_id, {"skills": [s for s in current if s != skill_name]})
|
|
66
|
+
|
|
67
|
+
async def status(self, haseef_id: str) -> Dict[str, Any]:
|
|
68
|
+
return await self._sdk._request("GET", f"{self._sdk.api_base}/haseefs/{haseef_id}/status")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MemoryAPI:
|
|
72
|
+
def __init__(self, sdk: 'HsafaSDK'):
|
|
73
|
+
self._sdk = sdk
|
|
74
|
+
|
|
75
|
+
async def list(self, haseef_id: str) -> List[Dict[str, Any]]:
|
|
76
|
+
res = await self._sdk._request("GET", f"{self._sdk.api_base}/memory/{haseef_id}/semantic")
|
|
77
|
+
return res.get("memories", [])
|
|
78
|
+
|
|
79
|
+
async def search(self, haseef_id: str, query: str, limit: int = 20) -> List[Dict[str, Any]]:
|
|
80
|
+
qs = urlencode({"q": query, "limit": limit})
|
|
81
|
+
res = await self._sdk._request("GET", f"{self._sdk.api_base}/memory/{haseef_id}/semantic/search?{qs}")
|
|
82
|
+
return res.get("results", [])
|
|
83
|
+
|
|
84
|
+
async def set(self, haseef_id: str, memories: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
85
|
+
return await self._sdk._request("POST", f"{self._sdk.api_base}/memory/{haseef_id}/semantic", body={"memories": memories})
|
|
86
|
+
|
|
87
|
+
async def delete(self, haseef_id: str, keys: List[str]) -> Dict[str, Any]:
|
|
88
|
+
return await self._sdk._request("DELETE", f"{self._sdk.api_base}/memory/{haseef_id}/semantic", body={"keys": keys})
|
|
89
|
+
|
|
90
|
+
async def episodes(self, haseef_id: str, limit: int = 20) -> List[Dict[str, Any]]:
|
|
91
|
+
res = await self._sdk._request("GET", f"{self._sdk.api_base}/memory/{haseef_id}/episodic?limit={limit}")
|
|
92
|
+
return res.get("episodes", [])
|
|
93
|
+
|
|
94
|
+
async def search_episodes(self, haseef_id: str, query: str, limit: int = 10) -> List[Dict[str, Any]]:
|
|
95
|
+
qs = urlencode({"q": query, "limit": limit})
|
|
96
|
+
res = await self._sdk._request("GET", f"{self._sdk.api_base}/memory/{haseef_id}/episodic/search?{qs}")
|
|
97
|
+
return res.get("results", [])
|
|
98
|
+
|
|
99
|
+
async def social(self, haseef_id: str) -> List[Dict[str, Any]]:
|
|
100
|
+
res = await self._sdk._request("GET", f"{self._sdk.api_base}/memory/{haseef_id}/social")
|
|
101
|
+
return res.get("people", [])
|
|
102
|
+
|
|
103
|
+
async def procedural(self, haseef_id: str) -> List[Dict[str, Any]]:
|
|
104
|
+
res = await self._sdk._request("GET", f"{self._sdk.api_base}/memory/{haseef_id}/procedural")
|
|
105
|
+
return res.get("patterns", [])
|
|
106
|
+
|
|
107
|
+
async def stats(self, haseef_id: str) -> Dict[str, Any]:
|
|
108
|
+
return await self._sdk._request("GET", f"{self._sdk.api_base}/memory/{haseef_id}/stats")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class RunsAPI:
|
|
112
|
+
def __init__(self, sdk: 'HsafaSDK'):
|
|
113
|
+
self._sdk = sdk
|
|
114
|
+
|
|
115
|
+
async def list(
|
|
116
|
+
self,
|
|
117
|
+
haseef_id: Optional[str] = None,
|
|
118
|
+
status: Optional[str] = None,
|
|
119
|
+
limit: Optional[int] = None,
|
|
120
|
+
) -> List[Dict[str, Any]]:
|
|
121
|
+
params: Dict[str, Any] = {}
|
|
122
|
+
if haseef_id:
|
|
123
|
+
params["haseefId"] = haseef_id
|
|
124
|
+
if status:
|
|
125
|
+
params["status"] = status
|
|
126
|
+
if limit is not None:
|
|
127
|
+
params["limit"] = limit
|
|
128
|
+
qs = urlencode(params)
|
|
129
|
+
path = f"{self._sdk.api_base}/runs" + (f"?{qs}" if qs else "")
|
|
130
|
+
res = await self._sdk._request("GET", path)
|
|
131
|
+
return res.get("runs", [])
|
|
132
|
+
|
|
133
|
+
async def get(self, run_id: str) -> Dict[str, Any]:
|
|
134
|
+
res = await self._sdk._request("GET", f"{self._sdk.api_base}/runs/{run_id}")
|
|
135
|
+
return res.get("run", {})
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class HsafaSDK:
|
|
139
|
+
def __init__(self, opts: SdkOptions):
|
|
140
|
+
self.core_url = opts.get("core_url", "http://localhost:3001").rstrip("/")
|
|
141
|
+
self.api_key = opts.get("api_key", "")
|
|
142
|
+
self.skill = opts.get("skill", "")
|
|
143
|
+
self.api_base = (opts.get("api_base") or DEFAULT_API_BASE).rstrip("/")
|
|
144
|
+
|
|
145
|
+
self._tool_handlers: Dict[str, ToolHandler] = {}
|
|
146
|
+
self._event_listeners: Dict[str, Set[Callable[[Any], Any]]] = {}
|
|
147
|
+
self._is_connected = False
|
|
148
|
+
self._client = httpx.AsyncClient()
|
|
149
|
+
|
|
150
|
+
self.haseef = HaseefAPI(self)
|
|
151
|
+
self.memory = MemoryAPI(self)
|
|
152
|
+
self.runs = RunsAPI(self)
|
|
153
|
+
|
|
154
|
+
async def register_tools(self, tools: List[ToolDefinition]) -> None:
|
|
155
|
+
body = []
|
|
156
|
+
for t in tools:
|
|
157
|
+
schema = t.get("inputSchema") or input_to_json_schema(t.get("input") or {})
|
|
158
|
+
body.append({
|
|
159
|
+
"name": t["name"],
|
|
160
|
+
"description": t["description"],
|
|
161
|
+
"inputSchema": schema,
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
path = f"{self.api_base}/skills/{self.skill}/tools"
|
|
165
|
+
await self._request("PUT", path, body={"tools": body})
|
|
166
|
+
|
|
167
|
+
def on_tool_call(self, name: str, handler: ToolHandler) -> None:
|
|
168
|
+
self._tool_handlers[name] = handler
|
|
169
|
+
|
|
170
|
+
async def push_event(self, event: PushEventPayload) -> None:
|
|
171
|
+
payload = {"skill": self.skill, **event}
|
|
172
|
+
await self._request("POST", f"{self.api_base}/events", body=payload)
|
|
173
|
+
|
|
174
|
+
def on(self, event_name: str, listener: Callable[[Any], Any]) -> None:
|
|
175
|
+
if event_name not in self._event_listeners:
|
|
176
|
+
self._event_listeners[event_name] = set()
|
|
177
|
+
self._event_listeners[event_name].add(listener)
|
|
178
|
+
|
|
179
|
+
def off(self, event_name: str, listener: Callable[[Any], Any]) -> None:
|
|
180
|
+
if event_name in self._event_listeners:
|
|
181
|
+
self._event_listeners[event_name].discard(listener)
|
|
182
|
+
|
|
183
|
+
async def _emit(self, event_name: str, data: Any) -> None:
|
|
184
|
+
listeners = self._event_listeners.get(event_name, set())
|
|
185
|
+
for listener in listeners:
|
|
186
|
+
try:
|
|
187
|
+
if inspect.iscoroutinefunction(listener):
|
|
188
|
+
await listener(data)
|
|
189
|
+
else:
|
|
190
|
+
listener(data)
|
|
191
|
+
except Exception as e:
|
|
192
|
+
print(f"[HsafaSDK] Listener error on {event_name}: {e}")
|
|
193
|
+
|
|
194
|
+
async def connect(self) -> None:
|
|
195
|
+
"""
|
|
196
|
+
Connects to the SSE stream and blocks until disconnect() is called.
|
|
197
|
+
Wrap in asyncio.create_task() to run in background alongside other code:
|
|
198
|
+
asyncio.create_task(sdk.connect())
|
|
199
|
+
"""
|
|
200
|
+
if self._is_connected:
|
|
201
|
+
return
|
|
202
|
+
self._is_connected = True
|
|
203
|
+
|
|
204
|
+
delay = DEFAULT_RECONNECT_DELAY
|
|
205
|
+
while self._is_connected:
|
|
206
|
+
try:
|
|
207
|
+
await self._open_sse()
|
|
208
|
+
delay = DEFAULT_RECONNECT_DELAY
|
|
209
|
+
except asyncio.CancelledError:
|
|
210
|
+
break
|
|
211
|
+
except Exception as e:
|
|
212
|
+
if not self._is_connected:
|
|
213
|
+
break
|
|
214
|
+
print(f"[HsafaSDK] SSE Connection lost ({e}). Reconnecting in {delay}s...")
|
|
215
|
+
await asyncio.sleep(delay)
|
|
216
|
+
delay = min(delay * 2, MAX_RECONNECT_DELAY)
|
|
217
|
+
|
|
218
|
+
async def disconnect(self) -> None:
|
|
219
|
+
self._is_connected = False
|
|
220
|
+
await self._client.aclose()
|
|
221
|
+
|
|
222
|
+
async def _open_sse(self) -> None:
|
|
223
|
+
url = f"{self.core_url}{self.api_base}/skills/{self.skill}/actions/stream"
|
|
224
|
+
headers = {
|
|
225
|
+
"x-api-key": self.api_key,
|
|
226
|
+
"Accept": "text/event-stream",
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async with self._client.stream("GET", url, headers=headers, timeout=None) as response:
|
|
230
|
+
response.raise_for_status()
|
|
231
|
+
|
|
232
|
+
data_line = ""
|
|
233
|
+
async for line in response.aiter_lines():
|
|
234
|
+
if line.startswith("data: "):
|
|
235
|
+
data_line = line[6:].strip()
|
|
236
|
+
elif line == "" and data_line:
|
|
237
|
+
try:
|
|
238
|
+
msg = json.loads(data_line)
|
|
239
|
+
asyncio.create_task(self._handle_message(msg))
|
|
240
|
+
except json.JSONDecodeError:
|
|
241
|
+
pass
|
|
242
|
+
data_line = ""
|
|
243
|
+
|
|
244
|
+
async def _handle_message(self, msg: Dict[str, Any]) -> None:
|
|
245
|
+
msg_type = msg.get("type")
|
|
246
|
+
|
|
247
|
+
lifecycle_events = [
|
|
248
|
+
"tool.input.start",
|
|
249
|
+
"tool.input.delta",
|
|
250
|
+
"tool.call",
|
|
251
|
+
"tool.result",
|
|
252
|
+
"tool.error",
|
|
253
|
+
"run.started",
|
|
254
|
+
"run.completed",
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
if msg_type in lifecycle_events:
|
|
258
|
+
await self._emit(msg_type, msg.get("data"))
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
if msg_type == "action":
|
|
262
|
+
action_id = msg.get("actionId")
|
|
263
|
+
tool_name = msg.get("toolName")
|
|
264
|
+
args = msg.get("args", {})
|
|
265
|
+
haseef = msg.get("haseef", {})
|
|
266
|
+
|
|
267
|
+
handler = self._tool_handlers.get(tool_name)
|
|
268
|
+
if not handler:
|
|
269
|
+
await self._post_result(action_id, {"error": f'No handler registered for tool "{tool_name}"'})
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
ctx: ToolCallContext = {"actionId": action_id, "haseef": haseef}
|
|
274
|
+
result = await handler(args, ctx)
|
|
275
|
+
await self._post_result(action_id, result)
|
|
276
|
+
except Exception as e:
|
|
277
|
+
await self._post_result(action_id, {"error": str(e)})
|
|
278
|
+
|
|
279
|
+
if msg_type == "tool.input.delta.raw":
|
|
280
|
+
data = msg.get("data", {})
|
|
281
|
+
partial_text = data.get("accumulatedText", "")
|
|
282
|
+
await self._emit("tool.input.delta", {
|
|
283
|
+
"actionId": data.get("actionId"),
|
|
284
|
+
"toolName": data.get("toolName"),
|
|
285
|
+
"delta": partial_text,
|
|
286
|
+
"partialArgs": parse_partial_json(partial_text),
|
|
287
|
+
"haseef": data.get("haseef"),
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
async def _post_result(self, action_id: str, result: Any) -> None:
|
|
291
|
+
try:
|
|
292
|
+
path = f"{self.api_base}/actions/{action_id}/result"
|
|
293
|
+
await self._request("POST", path, body={"result": result})
|
|
294
|
+
except Exception as e:
|
|
295
|
+
print(f"[HsafaSDK:{self.skill}] Failed to submit result for action {action_id}: {e}")
|
|
296
|
+
|
|
297
|
+
async def _request(
|
|
298
|
+
self,
|
|
299
|
+
method: str,
|
|
300
|
+
path: str,
|
|
301
|
+
body: Optional[Dict[str, Any]] = None,
|
|
302
|
+
) -> Any:
|
|
303
|
+
url = f"{self.core_url}{path}"
|
|
304
|
+
headers = {
|
|
305
|
+
"x-api-key": self.api_key,
|
|
306
|
+
"Content-Type": "application/json",
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
response = await self._client.request(method, url, headers=headers, json=body)
|
|
310
|
+
|
|
311
|
+
if not response.is_success:
|
|
312
|
+
raise Exception(f"{method} {path} failed ({response.status_code}): {response.text}")
|
|
313
|
+
|
|
314
|
+
if response.status_code == 204 or not response.content:
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
if "application/json" in response.headers.get("content-type", ""):
|
|
318
|
+
return response.json()
|
|
319
|
+
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
import httpx
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from typing import TypedDict, Dict, Any, List, Optional, Callable, Awaitable, Union
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SdkOptions(TypedDict, total=False):
|
|
5
|
+
core_url: str
|
|
6
|
+
api_key: str
|
|
7
|
+
skill: str
|
|
8
|
+
api_base: Optional[str]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ToolDefinition(TypedDict, total=False):
|
|
12
|
+
name: str
|
|
13
|
+
description: str
|
|
14
|
+
input: Optional[Dict[str, str]]
|
|
15
|
+
inputSchema: Optional[Any]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HaseefContext(TypedDict):
|
|
19
|
+
id: str
|
|
20
|
+
name: str
|
|
21
|
+
profile: Dict[str, Any]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ToolCallContext(TypedDict):
|
|
25
|
+
actionId: str
|
|
26
|
+
haseef: HaseefContext
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
ToolHandler = Callable[[Dict[str, Any], ToolCallContext], Awaitable[Any]]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Attachment(TypedDict, total=False):
|
|
33
|
+
type: str # 'image' | 'audio' | 'file'
|
|
34
|
+
mimeType: str
|
|
35
|
+
url: Optional[str]
|
|
36
|
+
base64: Optional[str]
|
|
37
|
+
name: Optional[str]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PushEventPayload(TypedDict, total=False):
|
|
41
|
+
type: str
|
|
42
|
+
data: Dict[str, Any]
|
|
43
|
+
attachments: Optional[List[Attachment]]
|
|
44
|
+
haseefId: Optional[str]
|
|
45
|
+
target: Optional[Dict[str, str]]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── Lifecycle Events ───────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
class ToolInputStartEvent(TypedDict):
|
|
51
|
+
actionId: str
|
|
52
|
+
toolName: str
|
|
53
|
+
haseef: HaseefContext
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ToolInputDeltaEvent(TypedDict):
|
|
57
|
+
actionId: str
|
|
58
|
+
toolName: str
|
|
59
|
+
delta: str
|
|
60
|
+
partialArgs: Dict[str, Any]
|
|
61
|
+
haseef: HaseefContext
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ToolCallEvent(TypedDict):
|
|
65
|
+
actionId: str
|
|
66
|
+
toolName: str
|
|
67
|
+
args: Dict[str, Any]
|
|
68
|
+
haseef: HaseefContext
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ToolResultEvent(TypedDict):
|
|
72
|
+
actionId: str
|
|
73
|
+
toolName: str
|
|
74
|
+
args: Dict[str, Any]
|
|
75
|
+
result: Any
|
|
76
|
+
durationMs: int
|
|
77
|
+
haseef: HaseefContext
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ToolErrorEvent(TypedDict):
|
|
81
|
+
actionId: str
|
|
82
|
+
toolName: str
|
|
83
|
+
error: str
|
|
84
|
+
haseef: HaseefContext
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class RunStartedEvent(TypedDict):
|
|
88
|
+
runId: str
|
|
89
|
+
haseef: Dict[str, str]
|
|
90
|
+
triggerSkill: Optional[str]
|
|
91
|
+
triggerType: Optional[str]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class RunCompletedEvent(TypedDict):
|
|
95
|
+
runId: str
|
|
96
|
+
haseef: Dict[str, str]
|
|
97
|
+
summary: Optional[str]
|
|
98
|
+
durationMs: int
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
SdkEventType = Union[
|
|
102
|
+
str,
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
SdkEventMap = Dict[str, Any]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ── Haseef API ───────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
class Haseef(TypedDict, total=False):
|
|
111
|
+
id: str
|
|
112
|
+
name: str
|
|
113
|
+
description: Optional[str]
|
|
114
|
+
profileJson: Optional[Dict[str, Any]]
|
|
115
|
+
configJson: Optional[Dict[str, Any]]
|
|
116
|
+
skills: Optional[List[str]]
|
|
117
|
+
createdAt: Optional[str]
|
|
118
|
+
updatedAt: Optional[str]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class CreateHaseefInput(TypedDict, total=False):
|
|
122
|
+
name: str
|
|
123
|
+
description: Optional[str]
|
|
124
|
+
configJson: Dict[str, Any]
|
|
125
|
+
profileJson: Optional[Dict[str, Any]]
|
|
126
|
+
skills: Optional[List[str]]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class UpdateHaseefInput(TypedDict, total=False):
|
|
130
|
+
name: Optional[str]
|
|
131
|
+
description: Optional[str]
|
|
132
|
+
configJson: Optional[Dict[str, Any]]
|
|
133
|
+
profileJson: Optional[Dict[str, Any]]
|
|
134
|
+
skills: Optional[List[str]]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ── Memory API ───────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
class SemanticMemoryInput(TypedDict, total=False):
|
|
140
|
+
key: str
|
|
141
|
+
value: str
|
|
142
|
+
importance: Optional[int]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class SemanticMemory(TypedDict):
|
|
146
|
+
id: str
|
|
147
|
+
haseefId: str
|
|
148
|
+
key: str
|
|
149
|
+
value: str
|
|
150
|
+
importance: int
|
|
151
|
+
recalledAt: Optional[str]
|
|
152
|
+
createdAt: str
|
|
153
|
+
updatedAt: str
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class EpisodicMemory(TypedDict):
|
|
157
|
+
id: str
|
|
158
|
+
haseefId: str
|
|
159
|
+
runId: Optional[str]
|
|
160
|
+
summary: str
|
|
161
|
+
context: Optional[Dict[str, Any]]
|
|
162
|
+
createdAt: str
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class SocialMemory(TypedDict):
|
|
166
|
+
id: str
|
|
167
|
+
haseefId: str
|
|
168
|
+
personKey: str
|
|
169
|
+
observations: Any
|
|
170
|
+
updatedAt: str
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class ProceduralMemory(TypedDict):
|
|
174
|
+
id: str
|
|
175
|
+
haseefId: str
|
|
176
|
+
pattern: str
|
|
177
|
+
confidence: float
|
|
178
|
+
updatedAt: str
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class MemoryStats(TypedDict):
|
|
182
|
+
haseefId: str
|
|
183
|
+
counts: Dict[str, int]
|
|
184
|
+
total: int
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ── Runs API ───────────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
class Run(TypedDict, total=False):
|
|
190
|
+
id: str
|
|
191
|
+
haseefId: str
|
|
192
|
+
status: str
|
|
193
|
+
triggerSkill: Optional[str]
|
|
194
|
+
triggerType: Optional[str]
|
|
195
|
+
startedAt: str
|
|
196
|
+
completedAt: Optional[str]
|
|
197
|
+
durationMs: Optional[int]
|
|
198
|
+
summary: Optional[str]
|
|
199
|
+
tokensUsed: Optional[int]
|
|
200
|
+
toolCallCount: Optional[int]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class ListRunsOptions(TypedDict, total=False):
|
|
204
|
+
haseefId: Optional[str]
|
|
205
|
+
status: Optional[str]
|
|
206
|
+
limit: Optional[int]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hsafa-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Hsafa SDK v7 — connect any service to a Haseef brain
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: httpx>=0.27.0
|
|
8
|
+
|
|
9
|
+
# hsafa-sdk
|
|
10
|
+
|
|
11
|
+
Python SDK for **Hsafa Core v7**. Connect any service to a Haseef brain — register tools, handle tool calls over SSE, push events, and read/write the haseef's memory and profile.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install hsafa-sdk
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or from source:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
cd sdks/python
|
|
23
|
+
pip install -e .
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import asyncio
|
|
30
|
+
import os
|
|
31
|
+
from hsafa_sdk import HsafaSDK, SdkOptions
|
|
32
|
+
|
|
33
|
+
async def main():
|
|
34
|
+
sdk = HsafaSDK(SdkOptions(
|
|
35
|
+
core_url="http://localhost:3001",
|
|
36
|
+
api_key=os.environ.get("HSAFA_CORE_KEY", "test-key"),
|
|
37
|
+
skill="weather",
|
|
38
|
+
))
|
|
39
|
+
|
|
40
|
+
# 1. Register tools
|
|
41
|
+
await sdk.register_tools([{
|
|
42
|
+
"name": "get_weather",
|
|
43
|
+
"description": "Get current weather for a city",
|
|
44
|
+
"input": {"city": "string", "units": "string?"},
|
|
45
|
+
}])
|
|
46
|
+
|
|
47
|
+
# 2. Handle tool calls
|
|
48
|
+
async def handle_weather(args, ctx):
|
|
49
|
+
print(f"{ctx['haseef']['name']} wants weather for {args.get('city')}")
|
|
50
|
+
return {"temperature": 72, "conditions": "sunny", "city": args.get("city")}
|
|
51
|
+
|
|
52
|
+
sdk.on_tool_call("get_weather", handle_weather)
|
|
53
|
+
|
|
54
|
+
# 3. Listen to lifecycle events
|
|
55
|
+
def on_run_started(e):
|
|
56
|
+
print(f"Run started for {e['haseef']['name']}")
|
|
57
|
+
|
|
58
|
+
sdk.on("run.started", on_run_started)
|
|
59
|
+
|
|
60
|
+
# 4. Connect the SSE stream (blocks until disconnect)
|
|
61
|
+
# For background usage alongside a web server:
|
|
62
|
+
# asyncio.create_task(sdk.connect())
|
|
63
|
+
try:
|
|
64
|
+
await sdk.connect()
|
|
65
|
+
except KeyboardInterrupt:
|
|
66
|
+
await sdk.disconnect()
|
|
67
|
+
|
|
68
|
+
if __name__ == "__main__":
|
|
69
|
+
asyncio.run(main())
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Namespaces
|
|
73
|
+
|
|
74
|
+
| Namespace | Methods |
|
|
75
|
+
|-----------|---------|
|
|
76
|
+
| `sdk.haseef` | `list()`, `get(id)`, `create(data)`, `update(id, patch)`, `delete(id)`, `get_profile(id)`, `update_profile(id, patch)`, `add_skill(id, name)`, `remove_skill(id, name)`, `status(id)` |
|
|
77
|
+
| `sdk.memory` | `list(id)`, `search(id, query, limit)`, `set(id, memories)`, `delete(id, keys)`, `episodes(id, limit)`, `search_episodes(id, query, limit)`, `social(id)`, `procedural(id)`, `stats(id)` |
|
|
78
|
+
| `sdk.runs` | `list(haseef_id?, status?, limit?)`, `get(run_id)` |
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/hsafa_sdk/__init__.py
|
|
4
|
+
src/hsafa_sdk/schema.py
|
|
5
|
+
src/hsafa_sdk/sdk.py
|
|
6
|
+
src/hsafa_sdk/types.py
|
|
7
|
+
src/hsafa_sdk.egg-info/PKG-INFO
|
|
8
|
+
src/hsafa_sdk.egg-info/SOURCES.txt
|
|
9
|
+
src/hsafa_sdk.egg-info/dependency_links.txt
|
|
10
|
+
src/hsafa_sdk.egg-info/requires.txt
|
|
11
|
+
src/hsafa_sdk.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx>=0.27.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hsafa_sdk
|