sepurux 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.
- sepurux-0.2.0/PKG-INFO +123 -0
- sepurux-0.2.0/README.md +112 -0
- sepurux-0.2.0/pyproject.toml +27 -0
- sepurux-0.2.0/sepurux/__init__.py +13 -0
- sepurux-0.2.0/sepurux/client.py +173 -0
- sepurux-0.2.0/sepurux/recorder.py +114 -0
- sepurux-0.2.0/sepurux/trace.py +94 -0
- sepurux-0.2.0/sepurux/types.py +24 -0
- sepurux-0.2.0/sepurux.egg-info/PKG-INFO +123 -0
- sepurux-0.2.0/sepurux.egg-info/SOURCES.txt +14 -0
- sepurux-0.2.0/sepurux.egg-info/dependency_links.txt +1 -0
- sepurux-0.2.0/sepurux.egg-info/requires.txt +1 -0
- sepurux-0.2.0/sepurux.egg-info/top_level.txt +1 -0
- sepurux-0.2.0/setup.cfg +4 -0
- sepurux-0.2.0/tests/test_client.py +81 -0
- sepurux-0.2.0/tests/test_trace_builder.py +20 -0
sepurux-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sepurux
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python SDK for Sepurux trace recording and uploads
|
|
5
|
+
Author: Sepurux
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/felixkwasisarpong/Sepurux
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: httpx<1,>=0.27
|
|
11
|
+
|
|
12
|
+
# Sepurux Python SDK
|
|
13
|
+
|
|
14
|
+
Python SDK for recording Sepurux traces and uploading them to a Sepurux API.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install -e sdk
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from sepurux import SepuruxClient, sepurux_trace
|
|
26
|
+
|
|
27
|
+
client = SepuruxClient(
|
|
28
|
+
base_url="http://localhost:8000",
|
|
29
|
+
api_key="sepurux-dev-key",
|
|
30
|
+
project_id="22222222-2222-2222-2222-222222222222",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
with sepurux_trace("example_task", {"user_id": "u-123"}) as rec:
|
|
34
|
+
rec.model_step("plan", {"goal": "create issue"}, output={"ok": True})
|
|
35
|
+
rec.tool_call("jira.create_issue(commit)", {"summary": "SDK test"})
|
|
36
|
+
rec.tool_result("jira.create_issue(commit)", {"issue_id": "OPS-123"})
|
|
37
|
+
|
|
38
|
+
trace_id = client.upload_trace(rec.to_trace())
|
|
39
|
+
print("trace_id:", trace_id)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## API
|
|
43
|
+
|
|
44
|
+
### `SepuruxClient`
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
SepuruxClient(base_url, api_key=None, project_id=None, timeout=30, sdk_header=None)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The SDK automatically sends `X-Sepurux-SDK: py/<version>` on requests.
|
|
51
|
+
Use `sdk_header` only if you need to override it manually.
|
|
52
|
+
|
|
53
|
+
Methods:
|
|
54
|
+
- `upload_trace(trace: dict) -> str`
|
|
55
|
+
- `create_campaign(name, mutation_set, eval_set, mutation_pack_id=None) -> str`
|
|
56
|
+
- `start_run(trace_id, campaign_id, thresholds=None) -> str`
|
|
57
|
+
- `get_run(run_id) -> dict`
|
|
58
|
+
|
|
59
|
+
### `TraceBuilder`
|
|
60
|
+
|
|
61
|
+
`TraceBuilder` outputs backend-compatible traces:
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"trace_version": "0.1",
|
|
66
|
+
"source": "sdk",
|
|
67
|
+
"task": {"name": "...", "input": {}},
|
|
68
|
+
"events": []
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Recorder
|
|
73
|
+
|
|
74
|
+
Use a context manager:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from sepurux import sepurux_trace
|
|
78
|
+
|
|
79
|
+
with sepurux_trace("task_name", {"input": "value"}) as rec:
|
|
80
|
+
rec.model_step("name", {"foo": "bar"}, output={"ok": True})
|
|
81
|
+
rec.tool_call("tool.name", {"arg": 1})
|
|
82
|
+
rec.tool_result("tool.name", {"result": "ok"})
|
|
83
|
+
rec.error(message="something happened", tool="tool.name")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Decorator
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from sepurux import record_trace
|
|
90
|
+
|
|
91
|
+
@record_trace(client=client, campaign_id="<campaign_id>")
|
|
92
|
+
def run_business_logic(x: int) -> int:
|
|
93
|
+
return x * 2
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The decorator preserves the function return value and attempts to upload/start runs in the background path without interrupting normal execution.
|
|
97
|
+
|
|
98
|
+
## Example script
|
|
99
|
+
|
|
100
|
+
See `examples/sdk_demo.py`.
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
python sdk/examples/sdk_demo.py
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Optional environment variables:
|
|
107
|
+
- `SEPURUX_API_BASE_URL` (default `http://localhost:8000`)
|
|
108
|
+
- `SEPURUX_UI_BASE_URL` (default `http://localhost:3000`)
|
|
109
|
+
- `SEPURUX_API_KEY`
|
|
110
|
+
- `SEPURUX_PROJECT_ID`
|
|
111
|
+
- `SEPURUX_CAMPAIGN_ID` (if provided, script starts a run)
|
|
112
|
+
|
|
113
|
+
## Publish to PyPI (CI)
|
|
114
|
+
|
|
115
|
+
This repository includes `.github/workflows/sdk-publish-pypi.yml` to publish the SDK directly to PyPI.
|
|
116
|
+
|
|
117
|
+
Setup:
|
|
118
|
+
- Add `PYPI_API_TOKEN` in GitHub repository secrets.
|
|
119
|
+
- Ensure the package version in `sdk/pyproject.toml` is new.
|
|
120
|
+
|
|
121
|
+
Release:
|
|
122
|
+
- Push a tag like `sdk-v0.2.0` to trigger publish.
|
|
123
|
+
- Or run the workflow manually from GitHub Actions.
|
sepurux-0.2.0/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Sepurux Python SDK
|
|
2
|
+
|
|
3
|
+
Python SDK for recording Sepurux traces and uploading them to a Sepurux API.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install -e sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from sepurux import SepuruxClient, sepurux_trace
|
|
15
|
+
|
|
16
|
+
client = SepuruxClient(
|
|
17
|
+
base_url="http://localhost:8000",
|
|
18
|
+
api_key="sepurux-dev-key",
|
|
19
|
+
project_id="22222222-2222-2222-2222-222222222222",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
with sepurux_trace("example_task", {"user_id": "u-123"}) as rec:
|
|
23
|
+
rec.model_step("plan", {"goal": "create issue"}, output={"ok": True})
|
|
24
|
+
rec.tool_call("jira.create_issue(commit)", {"summary": "SDK test"})
|
|
25
|
+
rec.tool_result("jira.create_issue(commit)", {"issue_id": "OPS-123"})
|
|
26
|
+
|
|
27
|
+
trace_id = client.upload_trace(rec.to_trace())
|
|
28
|
+
print("trace_id:", trace_id)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## API
|
|
32
|
+
|
|
33
|
+
### `SepuruxClient`
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
SepuruxClient(base_url, api_key=None, project_id=None, timeout=30, sdk_header=None)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The SDK automatically sends `X-Sepurux-SDK: py/<version>` on requests.
|
|
40
|
+
Use `sdk_header` only if you need to override it manually.
|
|
41
|
+
|
|
42
|
+
Methods:
|
|
43
|
+
- `upload_trace(trace: dict) -> str`
|
|
44
|
+
- `create_campaign(name, mutation_set, eval_set, mutation_pack_id=None) -> str`
|
|
45
|
+
- `start_run(trace_id, campaign_id, thresholds=None) -> str`
|
|
46
|
+
- `get_run(run_id) -> dict`
|
|
47
|
+
|
|
48
|
+
### `TraceBuilder`
|
|
49
|
+
|
|
50
|
+
`TraceBuilder` outputs backend-compatible traces:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"trace_version": "0.1",
|
|
55
|
+
"source": "sdk",
|
|
56
|
+
"task": {"name": "...", "input": {}},
|
|
57
|
+
"events": []
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Recorder
|
|
62
|
+
|
|
63
|
+
Use a context manager:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from sepurux import sepurux_trace
|
|
67
|
+
|
|
68
|
+
with sepurux_trace("task_name", {"input": "value"}) as rec:
|
|
69
|
+
rec.model_step("name", {"foo": "bar"}, output={"ok": True})
|
|
70
|
+
rec.tool_call("tool.name", {"arg": 1})
|
|
71
|
+
rec.tool_result("tool.name", {"result": "ok"})
|
|
72
|
+
rec.error(message="something happened", tool="tool.name")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Decorator
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from sepurux import record_trace
|
|
79
|
+
|
|
80
|
+
@record_trace(client=client, campaign_id="<campaign_id>")
|
|
81
|
+
def run_business_logic(x: int) -> int:
|
|
82
|
+
return x * 2
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The decorator preserves the function return value and attempts to upload/start runs in the background path without interrupting normal execution.
|
|
86
|
+
|
|
87
|
+
## Example script
|
|
88
|
+
|
|
89
|
+
See `examples/sdk_demo.py`.
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
python sdk/examples/sdk_demo.py
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Optional environment variables:
|
|
96
|
+
- `SEPURUX_API_BASE_URL` (default `http://localhost:8000`)
|
|
97
|
+
- `SEPURUX_UI_BASE_URL` (default `http://localhost:3000`)
|
|
98
|
+
- `SEPURUX_API_KEY`
|
|
99
|
+
- `SEPURUX_PROJECT_ID`
|
|
100
|
+
- `SEPURUX_CAMPAIGN_ID` (if provided, script starts a run)
|
|
101
|
+
|
|
102
|
+
## Publish to PyPI (CI)
|
|
103
|
+
|
|
104
|
+
This repository includes `.github/workflows/sdk-publish-pypi.yml` to publish the SDK directly to PyPI.
|
|
105
|
+
|
|
106
|
+
Setup:
|
|
107
|
+
- Add `PYPI_API_TOKEN` in GitHub repository secrets.
|
|
108
|
+
- Ensure the package version in `sdk/pyproject.toml` is new.
|
|
109
|
+
|
|
110
|
+
Release:
|
|
111
|
+
- Push a tag like `sdk-v0.2.0` to trigger publish.
|
|
112
|
+
- Or run the workflow manually from GitHub Actions.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sepurux"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Python SDK for Sepurux trace recording and uploads"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Sepurux" }
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"httpx>=0.27,<1",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/felixkwasisarpong/Sepurux"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools]
|
|
23
|
+
include-package-data = true
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["."]
|
|
27
|
+
include = ["sepurux*"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Sepurux Python SDK."""
|
|
2
|
+
|
|
3
|
+
from .client import SepuruxClient
|
|
4
|
+
from .recorder import TraceRecorder, record_trace, sepurux_trace
|
|
5
|
+
from .trace import TraceBuilder
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"SepuruxClient",
|
|
9
|
+
"TraceBuilder",
|
|
10
|
+
"TraceRecorder",
|
|
11
|
+
"sepurux_trace",
|
|
12
|
+
"record_trace",
|
|
13
|
+
]
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .types import RunStatus, TracePayload
|
|
9
|
+
|
|
10
|
+
SDK_PACKAGE_NAME = "sepurux"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SepuruxClient:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
base_url: str,
|
|
17
|
+
api_key: str | None = None,
|
|
18
|
+
project_id: str | None = None,
|
|
19
|
+
timeout: float = 30,
|
|
20
|
+
sdk_header: str | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
normalized = base_url.strip().rstrip("/")
|
|
23
|
+
if not normalized:
|
|
24
|
+
raise ValueError("base_url must be a non-empty URL")
|
|
25
|
+
|
|
26
|
+
self.base_url = normalized
|
|
27
|
+
self.api_key = api_key
|
|
28
|
+
self.project_id = project_id
|
|
29
|
+
self.timeout = timeout
|
|
30
|
+
self.sdk_header = (sdk_header or _default_sdk_header()).strip() or _default_sdk_header()
|
|
31
|
+
|
|
32
|
+
self._client = httpx.Client(
|
|
33
|
+
base_url=self.base_url,
|
|
34
|
+
timeout=timeout,
|
|
35
|
+
headers={"Accept": "application/json", "X-Sepurux-SDK": self.sdk_header},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def close(self) -> None:
|
|
39
|
+
self._client.close()
|
|
40
|
+
|
|
41
|
+
def upload_trace(self, trace: dict[str, Any] | TracePayload) -> str:
|
|
42
|
+
if not isinstance(trace, dict):
|
|
43
|
+
raise TypeError("trace must be a dictionary")
|
|
44
|
+
|
|
45
|
+
response = self._client.post(
|
|
46
|
+
"/v1/traces",
|
|
47
|
+
headers=self._request_headers(),
|
|
48
|
+
json=trace,
|
|
49
|
+
)
|
|
50
|
+
response.raise_for_status()
|
|
51
|
+
|
|
52
|
+
payload = _parse_json_response(response)
|
|
53
|
+
trace_id = payload.get("trace_id")
|
|
54
|
+
if not isinstance(trace_id, str) or not trace_id:
|
|
55
|
+
raise ValueError("Trace upload succeeded but trace_id was missing")
|
|
56
|
+
return trace_id
|
|
57
|
+
|
|
58
|
+
def create_campaign(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
name: str,
|
|
62
|
+
mutation_set: Any,
|
|
63
|
+
eval_set: Any,
|
|
64
|
+
mutation_pack_id: str | None = None,
|
|
65
|
+
) -> str:
|
|
66
|
+
payload: dict[str, Any] = {
|
|
67
|
+
"name": name,
|
|
68
|
+
"mutation_set": mutation_set,
|
|
69
|
+
"eval_set": eval_set,
|
|
70
|
+
}
|
|
71
|
+
if mutation_pack_id:
|
|
72
|
+
payload["mutation_pack_id"] = mutation_pack_id
|
|
73
|
+
|
|
74
|
+
response = self._client.post(
|
|
75
|
+
"/v1/campaigns",
|
|
76
|
+
headers=self._request_headers(),
|
|
77
|
+
json=payload,
|
|
78
|
+
)
|
|
79
|
+
response.raise_for_status()
|
|
80
|
+
|
|
81
|
+
data = _parse_json_response(response)
|
|
82
|
+
campaign_id = data.get("campaign_id")
|
|
83
|
+
if not isinstance(campaign_id, str) or not campaign_id:
|
|
84
|
+
raise ValueError("Campaign creation succeeded but campaign_id was missing")
|
|
85
|
+
return campaign_id
|
|
86
|
+
|
|
87
|
+
def start_run(
|
|
88
|
+
self,
|
|
89
|
+
*,
|
|
90
|
+
trace_id: str,
|
|
91
|
+
campaign_id: str,
|
|
92
|
+
thresholds: dict[str, Any] | None = None,
|
|
93
|
+
) -> str:
|
|
94
|
+
run_payload = {
|
|
95
|
+
"trace_id": trace_id,
|
|
96
|
+
"campaign_id": campaign_id,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# Prefer standard /v1/runs. If unavailable, fallback to CI endpoint.
|
|
100
|
+
response = self._client.post(
|
|
101
|
+
"/v1/runs",
|
|
102
|
+
headers=self._request_headers(),
|
|
103
|
+
json=run_payload,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if response.status_code in {404, 405}:
|
|
107
|
+
ci_payload: dict[str, Any] = {
|
|
108
|
+
"trace_id": trace_id,
|
|
109
|
+
"campaign_id": campaign_id,
|
|
110
|
+
"thresholds": thresholds
|
|
111
|
+
or {
|
|
112
|
+
"min_pass_rate": 0.9,
|
|
113
|
+
"max_unsafe": 0,
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
response = self._client.post(
|
|
117
|
+
"/v1/ci/runs",
|
|
118
|
+
headers=self._request_headers(),
|
|
119
|
+
json=ci_payload,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
response.raise_for_status()
|
|
123
|
+
payload = _parse_json_response(response)
|
|
124
|
+
run_id = payload.get("run_id")
|
|
125
|
+
if not isinstance(run_id, str) or not run_id:
|
|
126
|
+
raise ValueError("Run creation succeeded but run_id was missing")
|
|
127
|
+
return run_id
|
|
128
|
+
|
|
129
|
+
def get_run(self, run_id: str) -> RunStatus:
|
|
130
|
+
response = self._client.get(
|
|
131
|
+
f"/v1/runs/{run_id}",
|
|
132
|
+
headers=self._request_headers(),
|
|
133
|
+
)
|
|
134
|
+
response.raise_for_status()
|
|
135
|
+
|
|
136
|
+
payload = _parse_json_response(response)
|
|
137
|
+
summary = payload.get("summary")
|
|
138
|
+
return {
|
|
139
|
+
"run_id": str(payload.get("run_id") or run_id),
|
|
140
|
+
"status": str(payload.get("status") or "unknown"),
|
|
141
|
+
"summary": summary if isinstance(summary, dict) else None,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
def __enter__(self) -> SepuruxClient:
|
|
145
|
+
return self
|
|
146
|
+
|
|
147
|
+
def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
|
|
148
|
+
del exc_type, exc, tb
|
|
149
|
+
self.close()
|
|
150
|
+
|
|
151
|
+
def _request_headers(self) -> dict[str, str]:
|
|
152
|
+
headers: dict[str, str] = {}
|
|
153
|
+
if self.api_key:
|
|
154
|
+
headers["X-API-Key"] = self.api_key
|
|
155
|
+
if self.project_id:
|
|
156
|
+
headers["X-Project-Id"] = self.project_id
|
|
157
|
+
headers["X-Sepurux-SDK"] = self.sdk_header
|
|
158
|
+
return headers
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _parse_json_response(response: httpx.Response) -> dict[str, Any]:
|
|
162
|
+
data = response.json()
|
|
163
|
+
if not isinstance(data, dict):
|
|
164
|
+
raise ValueError("Expected JSON object response")
|
|
165
|
+
return data
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _default_sdk_header() -> str:
|
|
169
|
+
try:
|
|
170
|
+
sdk_version = version(SDK_PACKAGE_NAME)
|
|
171
|
+
except PackageNotFoundError:
|
|
172
|
+
sdk_version = "dev"
|
|
173
|
+
return f"py/{sdk_version}"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import inspect
|
|
5
|
+
from collections.abc import Callable, Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
|
|
8
|
+
|
|
9
|
+
from .trace import TraceBuilder
|
|
10
|
+
from .types import TracePayload
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .client import SepuruxClient
|
|
14
|
+
|
|
15
|
+
P = ParamSpec("P")
|
|
16
|
+
R = TypeVar("R")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TraceRecorder:
|
|
20
|
+
"""Friendly recorder wrapper around TraceBuilder."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
task_name: str,
|
|
25
|
+
task_input: dict[str, Any] | None = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
self._builder = TraceBuilder(task_name=task_name, task_input=task_input)
|
|
28
|
+
|
|
29
|
+
def model_step(self, name: str, input: Any, output: Any | None = None) -> None:
|
|
30
|
+
self._builder.add_model_step(name=name, input=input, output=output)
|
|
31
|
+
|
|
32
|
+
def tool_call(self, tool: str, args: dict[str, Any]) -> None:
|
|
33
|
+
self._builder.add_tool_call(tool=tool, args=args)
|
|
34
|
+
|
|
35
|
+
def tool_result(self, tool: str, result: Any) -> None:
|
|
36
|
+
self._builder.add_tool_result(tool=tool, result=result)
|
|
37
|
+
|
|
38
|
+
def error(self, tool: str | None = None, message: str = "") -> None:
|
|
39
|
+
self._builder.add_error(message=message, tool=tool)
|
|
40
|
+
|
|
41
|
+
def to_trace(self) -> TracePayload:
|
|
42
|
+
return self._builder.to_trace()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@contextmanager
|
|
46
|
+
def sepurux_trace(
|
|
47
|
+
task_name: str,
|
|
48
|
+
task_input: dict[str, Any] | None = None,
|
|
49
|
+
) -> Iterator[TraceRecorder]:
|
|
50
|
+
recorder = TraceRecorder(task_name=task_name, task_input=task_input)
|
|
51
|
+
yield recorder
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def record_trace(
|
|
55
|
+
*,
|
|
56
|
+
client: SepuruxClient,
|
|
57
|
+
campaign_id: str | None = None,
|
|
58
|
+
task_name: str | None = None,
|
|
59
|
+
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
|
60
|
+
"""Decorator that records function traces and optionally starts a run."""
|
|
61
|
+
|
|
62
|
+
def decorator(func: Callable[P, R]) -> Callable[P, R]:
|
|
63
|
+
@functools.wraps(func)
|
|
64
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
65
|
+
trace_task_name = task_name or func.__name__
|
|
66
|
+
task_input = {
|
|
67
|
+
"function": func.__name__,
|
|
68
|
+
"args": _safe_json_value(list(args)),
|
|
69
|
+
"kwargs": _safe_json_value(dict(kwargs)),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
with sepurux_trace(trace_task_name, task_input=task_input) as recorder:
|
|
73
|
+
recorder.model_step(
|
|
74
|
+
"function.call",
|
|
75
|
+
{"qualname": getattr(func, "__qualname__", func.__name__)},
|
|
76
|
+
)
|
|
77
|
+
try:
|
|
78
|
+
result = func(*args, **kwargs)
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
recorder.error(tool=func.__name__, message=str(exc))
|
|
81
|
+
raise
|
|
82
|
+
recorder.model_step(
|
|
83
|
+
"function.return", {"ok": True}, output=_safe_json_value(result)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
trace = recorder.to_trace()
|
|
87
|
+
|
|
88
|
+
# SDK telemetry should not break business logic.
|
|
89
|
+
try:
|
|
90
|
+
trace_id = client.upload_trace(trace)
|
|
91
|
+
if campaign_id:
|
|
92
|
+
client.start_run(trace_id=trace_id, campaign_id=campaign_id)
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
return wrapper
|
|
99
|
+
|
|
100
|
+
return decorator
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _safe_json_value(value: Any) -> Any:
|
|
104
|
+
if value is None or isinstance(value, str | int | float | bool):
|
|
105
|
+
return value
|
|
106
|
+
if isinstance(value, dict):
|
|
107
|
+
return {str(k): _safe_json_value(v) for k, v in value.items()}
|
|
108
|
+
if isinstance(value, list | tuple | set):
|
|
109
|
+
return [_safe_json_value(v) for v in value]
|
|
110
|
+
|
|
111
|
+
if inspect.isfunction(value) or inspect.ismethod(value):
|
|
112
|
+
return getattr(value, "__name__", "callable")
|
|
113
|
+
|
|
114
|
+
return repr(value)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .types import TraceEvent, TracePayload
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TraceBuilder:
|
|
10
|
+
"""Builds Sepurux trace payloads in the backend-compatible schema."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
task_name: str,
|
|
15
|
+
task_input: dict[str, Any] | None = None,
|
|
16
|
+
*,
|
|
17
|
+
source: str = "sdk",
|
|
18
|
+
trace_version: str = "0.1",
|
|
19
|
+
) -> None:
|
|
20
|
+
if not task_name.strip():
|
|
21
|
+
raise ValueError("task_name must be a non-empty string")
|
|
22
|
+
if not source.strip():
|
|
23
|
+
raise ValueError("source must be a non-empty string")
|
|
24
|
+
if not trace_version.strip():
|
|
25
|
+
raise ValueError("trace_version must be a non-empty string")
|
|
26
|
+
|
|
27
|
+
self._trace_version = trace_version
|
|
28
|
+
self._source = source
|
|
29
|
+
self._task_name = task_name
|
|
30
|
+
self._task_input = task_input or {}
|
|
31
|
+
self._events: list[TraceEvent] = []
|
|
32
|
+
|
|
33
|
+
def add_model_step(
|
|
34
|
+
self,
|
|
35
|
+
name: str,
|
|
36
|
+
input: Any,
|
|
37
|
+
output: Any | None = None,
|
|
38
|
+
) -> TraceBuilder:
|
|
39
|
+
event: TraceEvent = {
|
|
40
|
+
"type": "model_step",
|
|
41
|
+
"timestamp": _utc_now_iso(),
|
|
42
|
+
"name": name,
|
|
43
|
+
"input": input,
|
|
44
|
+
}
|
|
45
|
+
if output is not None:
|
|
46
|
+
event["output"] = output
|
|
47
|
+
self._events.append(event)
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
def add_tool_call(self, tool: str, args: dict[str, Any]) -> TraceBuilder:
|
|
51
|
+
event: TraceEvent = {
|
|
52
|
+
"type": "tool_call",
|
|
53
|
+
"timestamp": _utc_now_iso(),
|
|
54
|
+
"tool": tool,
|
|
55
|
+
"args": args,
|
|
56
|
+
}
|
|
57
|
+
self._events.append(event)
|
|
58
|
+
return self
|
|
59
|
+
|
|
60
|
+
def add_tool_result(self, tool: str, result: Any) -> TraceBuilder:
|
|
61
|
+
event: TraceEvent = {
|
|
62
|
+
"type": "tool_result",
|
|
63
|
+
"timestamp": _utc_now_iso(),
|
|
64
|
+
"tool": tool,
|
|
65
|
+
"result": result,
|
|
66
|
+
}
|
|
67
|
+
self._events.append(event)
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
def add_error(self, message: str, tool: str | None = None) -> TraceBuilder:
|
|
71
|
+
event: TraceEvent = {
|
|
72
|
+
"type": "error",
|
|
73
|
+
"timestamp": _utc_now_iso(),
|
|
74
|
+
"message": message,
|
|
75
|
+
}
|
|
76
|
+
if tool:
|
|
77
|
+
event["tool"] = tool
|
|
78
|
+
self._events.append(event)
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
def to_trace(self) -> TracePayload:
|
|
82
|
+
return {
|
|
83
|
+
"trace_version": self._trace_version,
|
|
84
|
+
"source": self._source,
|
|
85
|
+
"task": {
|
|
86
|
+
"name": self._task_name,
|
|
87
|
+
"input": self._task_input,
|
|
88
|
+
},
|
|
89
|
+
"events": list(self._events),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _utc_now_iso() -> str:
|
|
94
|
+
return datetime.now(UTC).isoformat()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, TypedDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TaskPayload(TypedDict):
|
|
7
|
+
name: str
|
|
8
|
+
input: dict[str, Any]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
TraceEvent = dict[str, Any]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TracePayload(TypedDict):
|
|
15
|
+
trace_version: str
|
|
16
|
+
source: str
|
|
17
|
+
task: TaskPayload
|
|
18
|
+
events: list[TraceEvent]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RunStatus(TypedDict, total=False):
|
|
22
|
+
run_id: str
|
|
23
|
+
status: str
|
|
24
|
+
summary: dict[str, Any] | None
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sepurux
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python SDK for Sepurux trace recording and uploads
|
|
5
|
+
Author: Sepurux
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/felixkwasisarpong/Sepurux
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: httpx<1,>=0.27
|
|
11
|
+
|
|
12
|
+
# Sepurux Python SDK
|
|
13
|
+
|
|
14
|
+
Python SDK for recording Sepurux traces and uploading them to a Sepurux API.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install -e sdk
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from sepurux import SepuruxClient, sepurux_trace
|
|
26
|
+
|
|
27
|
+
client = SepuruxClient(
|
|
28
|
+
base_url="http://localhost:8000",
|
|
29
|
+
api_key="sepurux-dev-key",
|
|
30
|
+
project_id="22222222-2222-2222-2222-222222222222",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
with sepurux_trace("example_task", {"user_id": "u-123"}) as rec:
|
|
34
|
+
rec.model_step("plan", {"goal": "create issue"}, output={"ok": True})
|
|
35
|
+
rec.tool_call("jira.create_issue(commit)", {"summary": "SDK test"})
|
|
36
|
+
rec.tool_result("jira.create_issue(commit)", {"issue_id": "OPS-123"})
|
|
37
|
+
|
|
38
|
+
trace_id = client.upload_trace(rec.to_trace())
|
|
39
|
+
print("trace_id:", trace_id)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## API
|
|
43
|
+
|
|
44
|
+
### `SepuruxClient`
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
SepuruxClient(base_url, api_key=None, project_id=None, timeout=30, sdk_header=None)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The SDK automatically sends `X-Sepurux-SDK: py/<version>` on requests.
|
|
51
|
+
Use `sdk_header` only if you need to override it manually.
|
|
52
|
+
|
|
53
|
+
Methods:
|
|
54
|
+
- `upload_trace(trace: dict) -> str`
|
|
55
|
+
- `create_campaign(name, mutation_set, eval_set, mutation_pack_id=None) -> str`
|
|
56
|
+
- `start_run(trace_id, campaign_id, thresholds=None) -> str`
|
|
57
|
+
- `get_run(run_id) -> dict`
|
|
58
|
+
|
|
59
|
+
### `TraceBuilder`
|
|
60
|
+
|
|
61
|
+
`TraceBuilder` outputs backend-compatible traces:
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"trace_version": "0.1",
|
|
66
|
+
"source": "sdk",
|
|
67
|
+
"task": {"name": "...", "input": {}},
|
|
68
|
+
"events": []
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Recorder
|
|
73
|
+
|
|
74
|
+
Use a context manager:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from sepurux import sepurux_trace
|
|
78
|
+
|
|
79
|
+
with sepurux_trace("task_name", {"input": "value"}) as rec:
|
|
80
|
+
rec.model_step("name", {"foo": "bar"}, output={"ok": True})
|
|
81
|
+
rec.tool_call("tool.name", {"arg": 1})
|
|
82
|
+
rec.tool_result("tool.name", {"result": "ok"})
|
|
83
|
+
rec.error(message="something happened", tool="tool.name")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Decorator
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from sepurux import record_trace
|
|
90
|
+
|
|
91
|
+
@record_trace(client=client, campaign_id="<campaign_id>")
|
|
92
|
+
def run_business_logic(x: int) -> int:
|
|
93
|
+
return x * 2
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The decorator preserves the function return value and attempts to upload/start runs in the background path without interrupting normal execution.
|
|
97
|
+
|
|
98
|
+
## Example script
|
|
99
|
+
|
|
100
|
+
See `examples/sdk_demo.py`.
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
python sdk/examples/sdk_demo.py
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Optional environment variables:
|
|
107
|
+
- `SEPURUX_API_BASE_URL` (default `http://localhost:8000`)
|
|
108
|
+
- `SEPURUX_UI_BASE_URL` (default `http://localhost:3000`)
|
|
109
|
+
- `SEPURUX_API_KEY`
|
|
110
|
+
- `SEPURUX_PROJECT_ID`
|
|
111
|
+
- `SEPURUX_CAMPAIGN_ID` (if provided, script starts a run)
|
|
112
|
+
|
|
113
|
+
## Publish to PyPI (CI)
|
|
114
|
+
|
|
115
|
+
This repository includes `.github/workflows/sdk-publish-pypi.yml` to publish the SDK directly to PyPI.
|
|
116
|
+
|
|
117
|
+
Setup:
|
|
118
|
+
- Add `PYPI_API_TOKEN` in GitHub repository secrets.
|
|
119
|
+
- Ensure the package version in `sdk/pyproject.toml` is new.
|
|
120
|
+
|
|
121
|
+
Release:
|
|
122
|
+
- Push a tag like `sdk-v0.2.0` to trigger publish.
|
|
123
|
+
- Or run the workflow manually from GitHub Actions.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
sepurux/__init__.py
|
|
4
|
+
sepurux/client.py
|
|
5
|
+
sepurux/recorder.py
|
|
6
|
+
sepurux/trace.py
|
|
7
|
+
sepurux/types.py
|
|
8
|
+
sepurux.egg-info/PKG-INFO
|
|
9
|
+
sepurux.egg-info/SOURCES.txt
|
|
10
|
+
sepurux.egg-info/dependency_links.txt
|
|
11
|
+
sepurux.egg-info/requires.txt
|
|
12
|
+
sepurux.egg-info/top_level.txt
|
|
13
|
+
tests/test_client.py
|
|
14
|
+
tests/test_trace_builder.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx<1,>=0.27
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sepurux
|
sepurux-0.2.0/setup.cfg
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
from sepurux import SepuruxClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class _RequestCapture:
|
|
8
|
+
def __init__(self) -> None:
|
|
9
|
+
self.requests: list[httpx.Request] = []
|
|
10
|
+
|
|
11
|
+
def __call__(self, request: httpx.Request) -> httpx.Response:
|
|
12
|
+
self.requests.append(request)
|
|
13
|
+
if request.url.path == "/v1/traces":
|
|
14
|
+
return httpx.Response(201, json={"trace_id": "trace-123"})
|
|
15
|
+
return httpx.Response(404, json={"detail": "not found"})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_upload_trace_uses_traces_endpoint_and_headers() -> None:
|
|
19
|
+
capture = _RequestCapture()
|
|
20
|
+
transport = httpx.MockTransport(capture)
|
|
21
|
+
|
|
22
|
+
client = SepuruxClient(
|
|
23
|
+
base_url="http://localhost:8000",
|
|
24
|
+
api_key="key-1",
|
|
25
|
+
project_id="project-1",
|
|
26
|
+
)
|
|
27
|
+
client._client = httpx.Client(
|
|
28
|
+
base_url=client.base_url,
|
|
29
|
+
transport=transport,
|
|
30
|
+
timeout=30,
|
|
31
|
+
headers={"Accept": "application/json", "X-Sepurux-SDK": client.sdk_header},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
trace_id = client.upload_trace(
|
|
35
|
+
{
|
|
36
|
+
"trace_version": "0.1",
|
|
37
|
+
"source": "sdk",
|
|
38
|
+
"task": {"name": "task", "input": {}},
|
|
39
|
+
"events": [],
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
assert trace_id == "trace-123"
|
|
44
|
+
assert len(capture.requests) == 1
|
|
45
|
+
request = capture.requests[0]
|
|
46
|
+
assert request.url.path == "/v1/traces"
|
|
47
|
+
assert request.headers.get("X-API-Key") == "key-1"
|
|
48
|
+
assert request.headers.get("X-Project-Id") == "project-1"
|
|
49
|
+
assert request.headers.get("X-Sepurux-SDK", "").startswith("py/")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_upload_trace_uses_explicit_sdk_header_override() -> None:
|
|
53
|
+
capture = _RequestCapture()
|
|
54
|
+
transport = httpx.MockTransport(capture)
|
|
55
|
+
|
|
56
|
+
client = SepuruxClient(
|
|
57
|
+
base_url="http://localhost:8000",
|
|
58
|
+
api_key="key-1",
|
|
59
|
+
project_id="project-1",
|
|
60
|
+
sdk_header="py/9.9.9",
|
|
61
|
+
)
|
|
62
|
+
client._client = httpx.Client(
|
|
63
|
+
base_url=client.base_url,
|
|
64
|
+
transport=transport,
|
|
65
|
+
timeout=30,
|
|
66
|
+
headers={"Accept": "application/json", "X-Sepurux-SDK": client.sdk_header},
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
trace_id = client.upload_trace(
|
|
70
|
+
{
|
|
71
|
+
"trace_version": "0.1",
|
|
72
|
+
"source": "sdk",
|
|
73
|
+
"task": {"name": "task", "input": {}},
|
|
74
|
+
"events": [],
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
assert trace_id == "trace-123"
|
|
79
|
+
assert len(capture.requests) == 1
|
|
80
|
+
request = capture.requests[0]
|
|
81
|
+
assert request.headers.get("X-Sepurux-SDK") == "py/9.9.9"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from sepurux import TraceBuilder
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_trace_builder_schema_shape() -> None:
|
|
7
|
+
builder = TraceBuilder("demo_task", {"prompt": "hello"})
|
|
8
|
+
builder.add_model_step("plan", {"goal": "create issue"}, output={"ok": True})
|
|
9
|
+
builder.add_tool_call("jira.create_issue(commit)", {"summary": "test"})
|
|
10
|
+
builder.add_tool_result("jira.create_issue(commit)", {"issue_id": "OPS-123"})
|
|
11
|
+
|
|
12
|
+
trace = builder.to_trace()
|
|
13
|
+
|
|
14
|
+
assert trace["trace_version"] == "0.1"
|
|
15
|
+
assert trace["source"] == "sdk"
|
|
16
|
+
assert trace["task"] == {"name": "demo_task", "input": {"prompt": "hello"}}
|
|
17
|
+
assert len(trace["events"]) == 3
|
|
18
|
+
assert trace["events"][0]["type"] == "model_step"
|
|
19
|
+
assert trace["events"][1]["type"] == "tool_call"
|
|
20
|
+
assert trace["events"][2]["type"] == "tool_result"
|