aiops-enabler 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.
- aiops_enabler-0.1.0/LICENSE +21 -0
- aiops_enabler-0.1.0/PKG-INFO +172 -0
- aiops_enabler-0.1.0/README.md +148 -0
- aiops_enabler-0.1.0/pyproject.toml +98 -0
- aiops_enabler-0.1.0/setup.cfg +4 -0
- aiops_enabler-0.1.0/src/aiops_enabler/__init__.py +21 -0
- aiops_enabler-0.1.0/src/aiops_enabler/client.py +180 -0
- aiops_enabler-0.1.0/src/aiops_enabler/py.typed +0 -0
- aiops_enabler-0.1.0/src/aiops_enabler/signing.py +70 -0
- aiops_enabler-0.1.0/src/aiops_enabler.egg-info/PKG-INFO +172 -0
- aiops_enabler-0.1.0/src/aiops_enabler.egg-info/SOURCES.txt +14 -0
- aiops_enabler-0.1.0/src/aiops_enabler.egg-info/dependency_links.txt +1 -0
- aiops_enabler-0.1.0/src/aiops_enabler.egg-info/requires.txt +5 -0
- aiops_enabler-0.1.0/src/aiops_enabler.egg-info/top_level.txt +1 -0
- aiops_enabler-0.1.0/tests/test_client.py +253 -0
- aiops_enabler-0.1.0/tests/test_signing_parity.py +129 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cyntra360hub
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aiops-enabler
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for AiOps Enabler — instrument your AI agent's task lifecycle and ratings in 3 lines of code.
|
|
5
|
+
Author: AiOps Enabler
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://aiopsenabler.com
|
|
8
|
+
Project-URL: Repository, https://github.com/cyntra360hub/aiops-enabler-sdk
|
|
9
|
+
Project-URL: Issues, https://github.com/cyntra360hub/aiops-enabler-sdk/issues
|
|
10
|
+
Keywords: aiops,agents,observability,ratings,instrumentation
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: httpx<1.0,>=0.27
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest<9.0,>=8.3; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-cov<7.0,>=6.0; extra == "dev"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# aiops-enabler
|
|
26
|
+
|
|
27
|
+
[](https://pypi.org/project/aiops-enabler/)
|
|
28
|
+
|
|
29
|
+
Python SDK for [AiOps Enabler](https://aiopsenabler.com) — *where AI agents
|
|
30
|
+
prove their worth*. Wraps the signed events (task-lifecycle instrumentation)
|
|
31
|
+
and ratings HTTP APIs behind a tiny, ergonomic client.
|
|
32
|
+
|
|
33
|
+
This is the public source for the SDK only. The AiOps Enabler platform
|
|
34
|
+
itself lives in a separate, private repository — the SDK is split out here
|
|
35
|
+
specifically so it can be a normal, publicly installable PyPI package with
|
|
36
|
+
its own public source, issues, and CI, independent of the platform's own
|
|
37
|
+
release cycle.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install aiops-enabler
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For local development against a clone of this repo:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install -e ".[dev]"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quickstart
|
|
52
|
+
|
|
53
|
+
Instrument your agent's task lifecycle in 3 lines of code:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from aiops_enabler import AiOpsClient
|
|
57
|
+
|
|
58
|
+
client = AiOpsClient(agent_key_id="ak_...", agent_secret="...")
|
|
59
|
+
client.task_started(task_id="abc123")
|
|
60
|
+
client.task_completed(task_id="abc123", outcome="success", duration_ms=1420, category="incident-response")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`agent_key_id`/`agent_secret` are the API key pair issued when you register
|
|
64
|
+
your agent (`POST /api/v1/agents`) or rotate its key
|
|
65
|
+
(`POST /api/v1/agents/{slug}/api-keys/rotate`) — shown exactly once at
|
|
66
|
+
issuance time.
|
|
67
|
+
|
|
68
|
+
Every call is HMAC-signed automatically; you never need to touch signing
|
|
69
|
+
headers yourself.
|
|
70
|
+
|
|
71
|
+
### Recording an outcome
|
|
72
|
+
|
|
73
|
+
`outcome` is one of `"success"`, `"failure"`, or `"escalated"` (escalated =
|
|
74
|
+
handed off to a human, not auto-resolved — see how this feeds
|
|
75
|
+
`auto_resolution_rate` on your agent's public profile).
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
client.task_completed(
|
|
79
|
+
task_id="abc123",
|
|
80
|
+
outcome="success",
|
|
81
|
+
duration_ms=1420,
|
|
82
|
+
category="incident-response", # optional, freeform
|
|
83
|
+
external_ref="datadog:incident:98765", # optional, Phase 2 reconciliation prep
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
On the first event your agent ever sends, its profile's verification level
|
|
88
|
+
automatically upgrades from "self-reported" to **"instrumented ✓"**. Once
|
|
89
|
+
enough completed tasks have been recorded, the platform's async worker
|
|
90
|
+
computes tasks handled, success %, auto-resolution %, median/95th-percentile
|
|
91
|
+
duration, and a 30-day trend — all visible on your agent's public profile.
|
|
92
|
+
|
|
93
|
+
### Recording a rating
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
client.rate(
|
|
97
|
+
rating="up", # or "down"
|
|
98
|
+
end_user_anonymous_id="some-opaque-id-you-control",
|
|
99
|
+
comment="Resolved my incident in under a minute!", # optional
|
|
100
|
+
task_reference="abc123", # optional
|
|
101
|
+
)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Configuration
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
client = AiOpsClient(
|
|
108
|
+
agent_key_id="ak_...",
|
|
109
|
+
agent_secret="...",
|
|
110
|
+
base_url="https://api.aiopsenabler.com", # default; override for staging/local dev
|
|
111
|
+
timeout=10.0, # seconds
|
|
112
|
+
)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Use as a context manager to close the underlying connection pool
|
|
116
|
+
deterministically:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
with AiOpsClient(agent_key_id="ak_...", agent_secret="...") as client:
|
|
120
|
+
client.task_started(task_id="abc123")
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Error handling
|
|
124
|
+
|
|
125
|
+
Any non-2xx response raises `aiops_enabler.AiOpsError`, carrying
|
|
126
|
+
`.status_code` and `.detail`:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from aiops_enabler import AiOpsClient, AiOpsError
|
|
130
|
+
|
|
131
|
+
client = AiOpsClient(agent_key_id="ak_...", agent_secret="...")
|
|
132
|
+
try:
|
|
133
|
+
client.task_started(task_id="abc123")
|
|
134
|
+
except AiOpsError as exc:
|
|
135
|
+
print(exc.status_code, exc.detail)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## How signing works
|
|
139
|
+
|
|
140
|
+
Every request is signed with the exact scheme the AiOps Enabler backend
|
|
141
|
+
verifies (see [the API guide](https://aiopsenabler.com/api-guide.md) for
|
|
142
|
+
the full spec and a signed test vector you can check any implementation
|
|
143
|
+
against, including this one):
|
|
144
|
+
|
|
145
|
+
- Headers: `X-Agent-Key-Id`, `X-Agent-Timestamp` (Unix seconds), `X-Agent-Signature`
|
|
146
|
+
(lowercase hex HMAC-SHA256).
|
|
147
|
+
- Signed message: `f"{timestamp}.".encode() + raw_request_body_bytes`.
|
|
148
|
+
- HMAC key: the SHA-256 hex digest of your agent secret.
|
|
149
|
+
|
|
150
|
+
See `src/aiops_enabler/signing.py` for the implementation.
|
|
151
|
+
`tests/test_signing_parity.py` additionally imports the platform backend's
|
|
152
|
+
real verifier function directly and confirms an SDK-signed message
|
|
153
|
+
verifies successfully against it — that check only runs when this repo
|
|
154
|
+
is checked out as a sibling of the (private) platform repo, and skips
|
|
155
|
+
cleanly otherwise; the test vector in the API guide is the
|
|
156
|
+
publicly-verifiable equivalent for anyone without access to the backend.
|
|
157
|
+
|
|
158
|
+
## Development
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
pip install -e ".[dev]"
|
|
162
|
+
pytest
|
|
163
|
+
ruff check .
|
|
164
|
+
mypy src
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Releasing
|
|
168
|
+
|
|
169
|
+
Tag a commit `vX.Y.Z` matching `pyproject.toml`'s `version` and push the
|
|
170
|
+
tag — `.github/workflows/publish.yml` builds and publishes to PyPI via
|
|
171
|
+
[Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (no
|
|
172
|
+
long-lived API token stored in this repo).
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# aiops-enabler
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/aiops-enabler/)
|
|
4
|
+
|
|
5
|
+
Python SDK for [AiOps Enabler](https://aiopsenabler.com) — *where AI agents
|
|
6
|
+
prove their worth*. Wraps the signed events (task-lifecycle instrumentation)
|
|
7
|
+
and ratings HTTP APIs behind a tiny, ergonomic client.
|
|
8
|
+
|
|
9
|
+
This is the public source for the SDK only. The AiOps Enabler platform
|
|
10
|
+
itself lives in a separate, private repository — the SDK is split out here
|
|
11
|
+
specifically so it can be a normal, publicly installable PyPI package with
|
|
12
|
+
its own public source, issues, and CI, independent of the platform's own
|
|
13
|
+
release cycle.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install aiops-enabler
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
For local development against a clone of this repo:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install -e ".[dev]"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quickstart
|
|
28
|
+
|
|
29
|
+
Instrument your agent's task lifecycle in 3 lines of code:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from aiops_enabler import AiOpsClient
|
|
33
|
+
|
|
34
|
+
client = AiOpsClient(agent_key_id="ak_...", agent_secret="...")
|
|
35
|
+
client.task_started(task_id="abc123")
|
|
36
|
+
client.task_completed(task_id="abc123", outcome="success", duration_ms=1420, category="incident-response")
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`agent_key_id`/`agent_secret` are the API key pair issued when you register
|
|
40
|
+
your agent (`POST /api/v1/agents`) or rotate its key
|
|
41
|
+
(`POST /api/v1/agents/{slug}/api-keys/rotate`) — shown exactly once at
|
|
42
|
+
issuance time.
|
|
43
|
+
|
|
44
|
+
Every call is HMAC-signed automatically; you never need to touch signing
|
|
45
|
+
headers yourself.
|
|
46
|
+
|
|
47
|
+
### Recording an outcome
|
|
48
|
+
|
|
49
|
+
`outcome` is one of `"success"`, `"failure"`, or `"escalated"` (escalated =
|
|
50
|
+
handed off to a human, not auto-resolved — see how this feeds
|
|
51
|
+
`auto_resolution_rate` on your agent's public profile).
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
client.task_completed(
|
|
55
|
+
task_id="abc123",
|
|
56
|
+
outcome="success",
|
|
57
|
+
duration_ms=1420,
|
|
58
|
+
category="incident-response", # optional, freeform
|
|
59
|
+
external_ref="datadog:incident:98765", # optional, Phase 2 reconciliation prep
|
|
60
|
+
)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
On the first event your agent ever sends, its profile's verification level
|
|
64
|
+
automatically upgrades from "self-reported" to **"instrumented ✓"**. Once
|
|
65
|
+
enough completed tasks have been recorded, the platform's async worker
|
|
66
|
+
computes tasks handled, success %, auto-resolution %, median/95th-percentile
|
|
67
|
+
duration, and a 30-day trend — all visible on your agent's public profile.
|
|
68
|
+
|
|
69
|
+
### Recording a rating
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
client.rate(
|
|
73
|
+
rating="up", # or "down"
|
|
74
|
+
end_user_anonymous_id="some-opaque-id-you-control",
|
|
75
|
+
comment="Resolved my incident in under a minute!", # optional
|
|
76
|
+
task_reference="abc123", # optional
|
|
77
|
+
)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Configuration
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
client = AiOpsClient(
|
|
84
|
+
agent_key_id="ak_...",
|
|
85
|
+
agent_secret="...",
|
|
86
|
+
base_url="https://api.aiopsenabler.com", # default; override for staging/local dev
|
|
87
|
+
timeout=10.0, # seconds
|
|
88
|
+
)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Use as a context manager to close the underlying connection pool
|
|
92
|
+
deterministically:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
with AiOpsClient(agent_key_id="ak_...", agent_secret="...") as client:
|
|
96
|
+
client.task_started(task_id="abc123")
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Error handling
|
|
100
|
+
|
|
101
|
+
Any non-2xx response raises `aiops_enabler.AiOpsError`, carrying
|
|
102
|
+
`.status_code` and `.detail`:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from aiops_enabler import AiOpsClient, AiOpsError
|
|
106
|
+
|
|
107
|
+
client = AiOpsClient(agent_key_id="ak_...", agent_secret="...")
|
|
108
|
+
try:
|
|
109
|
+
client.task_started(task_id="abc123")
|
|
110
|
+
except AiOpsError as exc:
|
|
111
|
+
print(exc.status_code, exc.detail)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## How signing works
|
|
115
|
+
|
|
116
|
+
Every request is signed with the exact scheme the AiOps Enabler backend
|
|
117
|
+
verifies (see [the API guide](https://aiopsenabler.com/api-guide.md) for
|
|
118
|
+
the full spec and a signed test vector you can check any implementation
|
|
119
|
+
against, including this one):
|
|
120
|
+
|
|
121
|
+
- Headers: `X-Agent-Key-Id`, `X-Agent-Timestamp` (Unix seconds), `X-Agent-Signature`
|
|
122
|
+
(lowercase hex HMAC-SHA256).
|
|
123
|
+
- Signed message: `f"{timestamp}.".encode() + raw_request_body_bytes`.
|
|
124
|
+
- HMAC key: the SHA-256 hex digest of your agent secret.
|
|
125
|
+
|
|
126
|
+
See `src/aiops_enabler/signing.py` for the implementation.
|
|
127
|
+
`tests/test_signing_parity.py` additionally imports the platform backend's
|
|
128
|
+
real verifier function directly and confirms an SDK-signed message
|
|
129
|
+
verifies successfully against it — that check only runs when this repo
|
|
130
|
+
is checked out as a sibling of the (private) platform repo, and skips
|
|
131
|
+
cleanly otherwise; the test vector in the API guide is the
|
|
132
|
+
publicly-verifiable equivalent for anyone without access to the backend.
|
|
133
|
+
|
|
134
|
+
## Development
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
pip install -e ".[dev]"
|
|
138
|
+
pytest
|
|
139
|
+
ruff check .
|
|
140
|
+
mypy src
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Releasing
|
|
144
|
+
|
|
145
|
+
Tag a commit `vX.Y.Z` matching `pyproject.toml`'s `version` and push the
|
|
146
|
+
tag — `.github/workflows/publish.yml` builds and publishes to PyPI via
|
|
147
|
+
[Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (no
|
|
148
|
+
long-lived API token stored in this repo).
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "aiops-enabler"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python SDK for AiOps Enabler — instrument your AI agent's task lifecycle and ratings in 3 lines of code."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "AiOps Enabler" }]
|
|
9
|
+
keywords = ["aiops", "agents", "observability", "ratings", "instrumentation"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: MIT License",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
# Deliberately one dependency: `httpx` (sync client) — the "3 lines of
|
|
19
|
+
# code" promise (CLAUDE.md F4) means the SDK itself should be as close to
|
|
20
|
+
# zero-friction to install as possible. `requests` was the other option
|
|
21
|
+
# considered; httpx was chosen because the AiOps Enabler backend it talks
|
|
22
|
+
# to is also httpx-based, standardizing on one HTTP library across both.
|
|
23
|
+
dependencies = [
|
|
24
|
+
"httpx>=0.27,<1.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=8.3,<9.0",
|
|
30
|
+
"pytest-cov>=6.0,<7.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
# Published on PyPI as `aiops-enabler` — `pip install aiops-enabler`.
|
|
34
|
+
# This repo is the public source; the platform itself
|
|
35
|
+
# (github.com/cyntra360hub/aiops-enabler) is a separate, private repo —
|
|
36
|
+
# see this repo's README for why the SDK lives here instead.
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://aiopsenabler.com"
|
|
39
|
+
Repository = "https://github.com/cyntra360hub/aiops-enabler-sdk"
|
|
40
|
+
Issues = "https://github.com/cyntra360hub/aiops-enabler-sdk/issues"
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["setuptools>=68", "wheel"]
|
|
44
|
+
build-backend = "setuptools.build_meta"
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.packages.find]
|
|
47
|
+
where = ["src"]
|
|
48
|
+
|
|
49
|
+
[tool.setuptools.package-data]
|
|
50
|
+
aiops_enabler = ["py.typed"]
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
testpaths = ["tests"]
|
|
54
|
+
addopts = "-ra"
|
|
55
|
+
|
|
56
|
+
[tool.coverage.run]
|
|
57
|
+
source = ["src/aiops_enabler"]
|
|
58
|
+
|
|
59
|
+
[tool.ruff]
|
|
60
|
+
target-version = "py39"
|
|
61
|
+
line-length = 100
|
|
62
|
+
src = ["src", "tests"]
|
|
63
|
+
|
|
64
|
+
[tool.ruff.lint]
|
|
65
|
+
select = ["E", "F", "I", "UP", "B", "C4", "SIM", "RUF"]
|
|
66
|
+
|
|
67
|
+
[tool.ruff.lint.isort]
|
|
68
|
+
known-first-party = ["aiops_enabler"]
|
|
69
|
+
|
|
70
|
+
[tool.mypy]
|
|
71
|
+
# mypy itself only supports checking against 3.10+ targets even though
|
|
72
|
+
# this package's own `requires-python` floor is 3.9 (the code uses no
|
|
73
|
+
# 3.10+-only runtime features — `from __future__ import annotations` makes
|
|
74
|
+
# every `X | None` / `dict[str, str]` annotation a deferred string, valid
|
|
75
|
+
# back to 3.9 — this setting only controls which stdlib typeshed stubs
|
|
76
|
+
# mypy checks against).
|
|
77
|
+
python_version = "3.10"
|
|
78
|
+
strict = true
|
|
79
|
+
warn_unused_ignores = true
|
|
80
|
+
warn_return_any = true
|
|
81
|
+
disallow_untyped_defs = true
|
|
82
|
+
|
|
83
|
+
[[tool.mypy.overrides]]
|
|
84
|
+
# `tests/test_signing_parity.py` inserts `/backend` onto `sys.path` at
|
|
85
|
+
# runtime to import the backend's real `app.modules.agents.hmac_auth` for
|
|
86
|
+
# a cross-package parity check (see that file's docstring) — mypy has no
|
|
87
|
+
# static visibility into that path, so `app.*` is unresolvable at
|
|
88
|
+
# check-time here even though it resolves fine at test-run-time.
|
|
89
|
+
module = "app.*"
|
|
90
|
+
ignore_missing_imports = true
|
|
91
|
+
|
|
92
|
+
[[tool.mypy.overrides]]
|
|
93
|
+
# No `tests/__init__.py` (pytest rootdir-relative test discovery, matching
|
|
94
|
+
# the backend's own test layout), so mypy sees these as top-level modules
|
|
95
|
+
# named `test_client`/`test_signing_parity`, not `tests.test_*`.
|
|
96
|
+
module = ["test_client", "test_signing_parity"]
|
|
97
|
+
disallow_untyped_defs = false
|
|
98
|
+
warn_return_any = false
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""aiops-enabler — Python SDK for AiOps Enabler.
|
|
2
|
+
|
|
3
|
+
Wraps the events (``POST /api/v1/events``, F4) and ratings
|
|
4
|
+
(``POST /api/v1/ratings``, F3) HTTP APIs behind a tiny, ergonomic client::
|
|
5
|
+
|
|
6
|
+
from aiops_enabler import AiOpsClient
|
|
7
|
+
|
|
8
|
+
client = AiOpsClient(agent_key_id="...", agent_secret="...")
|
|
9
|
+
client.task_started(task_id="abc123")
|
|
10
|
+
client.task_completed(task_id="abc123", outcome="success", duration_ms=1420)
|
|
11
|
+
|
|
12
|
+
See ``aiops_enabler.signing`` for the HMAC request-signing scheme, which
|
|
13
|
+
mirrors the backend's ``app.modules.agents.hmac_auth`` module
|
|
14
|
+
byte-for-byte (parity verified in this package's own test suite,
|
|
15
|
+
``tests/test_signing_parity.py``, against the backend's real verifier).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from aiops_enabler.client import AiOpsClient, AiOpsError
|
|
19
|
+
|
|
20
|
+
__all__ = ["AiOpsClient", "AiOpsError"]
|
|
21
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""``AiOpsClient`` — a tiny, ergonomic wrapper around the AiOps Enabler
|
|
2
|
+
events (F4) + ratings (F3) HTTP APIs.
|
|
3
|
+
|
|
4
|
+
CLAUDE.md F4: "ship a tiny Python SDK ... wrapping the events + rating
|
|
5
|
+
endpoints in 3 lines of code"::
|
|
6
|
+
|
|
7
|
+
from aiops_enabler import AiOpsClient
|
|
8
|
+
|
|
9
|
+
client = AiOpsClient(agent_key_id="...", agent_secret="...")
|
|
10
|
+
client.task_started(task_id="abc123")
|
|
11
|
+
client.task_completed(task_id="abc123", outcome="success", duration_ms=1420)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from types import TracebackType
|
|
18
|
+
from typing import Any, Literal
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from aiops_enabler.signing import sign_request
|
|
23
|
+
|
|
24
|
+
EventOutcome = Literal["success", "failure", "escalated"]
|
|
25
|
+
RatingValue = Literal["up", "down"]
|
|
26
|
+
|
|
27
|
+
# The backend's real production base URL (CLAUDE.md's locked domain,
|
|
28
|
+
# `aiopsenabler.com`) — overridable via `base_url=` for staging/local dev
|
|
29
|
+
# (e.g. `http://localhost:8000`).
|
|
30
|
+
DEFAULT_BASE_URL = "https://api.aiopsenabler.com"
|
|
31
|
+
DEFAULT_TIMEOUT_SECONDS = 10.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AiOpsError(Exception):
|
|
35
|
+
"""Raised for any non-2xx response from the AiOps Enabler API. Carries
|
|
36
|
+
the HTTP status code and the raw response body/text for debugging —
|
|
37
|
+
deliberately not a per-status-code exception hierarchy (CLAUDE.md's
|
|
38
|
+
"boring, proven choices" — callers that need to branch on status can
|
|
39
|
+
read `.status_code`)."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, status_code: int, detail: str) -> None:
|
|
42
|
+
self.status_code = status_code
|
|
43
|
+
self.detail = detail
|
|
44
|
+
super().__init__(f"AiOps Enabler API error {status_code}: {detail}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AiOpsClient:
|
|
48
|
+
"""Signed client for an agent's own ``POST /api/v1/events`` and
|
|
49
|
+
``POST /api/v1/ratings`` calls — the agent's own backend holds the API
|
|
50
|
+
secret and calls these directly (never from browser/client-side code;
|
|
51
|
+
see the backend's ``app.modules.events.router`` docstring for why
|
|
52
|
+
there is no unsigned/public path for events).
|
|
53
|
+
|
|
54
|
+
One ``httpx.Client`` is created and reused for the lifetime of this
|
|
55
|
+
object (standard connection-pooling practice). Use as a context
|
|
56
|
+
manager (``with AiOpsClient(...) as client:``) to close it
|
|
57
|
+
deterministically, or call ``.close()`` yourself — both are optional
|
|
58
|
+
for short-lived scripts.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
*,
|
|
64
|
+
agent_key_id: str,
|
|
65
|
+
agent_secret: str,
|
|
66
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
67
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
68
|
+
transport: httpx.BaseTransport | None = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
self._key_id = agent_key_id
|
|
71
|
+
self._secret = agent_secret
|
|
72
|
+
# `transport=` is exposed (not just an internal test hook) so a
|
|
73
|
+
# real caller can also inject e.g. a custom proxy/retry transport
|
|
74
|
+
# without subclassing; the SDK's own test suite uses it with
|
|
75
|
+
# `httpx.MockTransport` to mock the HTTP layer without a live
|
|
76
|
+
# backend (see `tests/test_client.py`).
|
|
77
|
+
self._http = httpx.Client(
|
|
78
|
+
base_url=base_url.rstrip("/"), timeout=timeout, transport=transport
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def __enter__(self) -> AiOpsClient:
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def __exit__(
|
|
85
|
+
self,
|
|
86
|
+
exc_type: type[BaseException] | None,
|
|
87
|
+
exc_value: BaseException | None,
|
|
88
|
+
traceback: TracebackType | None,
|
|
89
|
+
) -> None:
|
|
90
|
+
self.close()
|
|
91
|
+
|
|
92
|
+
def close(self) -> None:
|
|
93
|
+
self._http.close()
|
|
94
|
+
|
|
95
|
+
# --- Signed POST helper -------------------------------------------
|
|
96
|
+
|
|
97
|
+
def _post_signed(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
98
|
+
# A stable, compact JSON encoding: the signature covers the exact
|
|
99
|
+
# bytes sent, so it doesn't matter which valid JSON encoding is
|
|
100
|
+
# used as long as it's applied consistently — `separators=(",", ":")`
|
|
101
|
+
# just keeps the wire payload small.
|
|
102
|
+
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
103
|
+
headers = sign_request(key_id=self._key_id, secret=self._secret, body=body)
|
|
104
|
+
response = self._http.post(path, content=body, headers=headers)
|
|
105
|
+
if response.status_code >= 400:
|
|
106
|
+
raise AiOpsError(response.status_code, response.text)
|
|
107
|
+
if not response.content:
|
|
108
|
+
return {}
|
|
109
|
+
result: dict[str, Any] = response.json()
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
# --- Events (F4: Level 2 instrumentation) -----------------------------
|
|
113
|
+
|
|
114
|
+
def task_started(self, *, task_id: str) -> dict[str, Any]:
|
|
115
|
+
"""Record that a task started. Call once per task at the start of
|
|
116
|
+
its lifecycle; pair with a later `task_completed()` call using the
|
|
117
|
+
same `task_id`."""
|
|
118
|
+
return self._post_signed(
|
|
119
|
+
"/api/v1/events", {"event_type": "task_started", "task_id": task_id}
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def task_completed(
|
|
123
|
+
self,
|
|
124
|
+
*,
|
|
125
|
+
task_id: str,
|
|
126
|
+
outcome: EventOutcome,
|
|
127
|
+
duration_ms: int,
|
|
128
|
+
category: str | None = None,
|
|
129
|
+
external_ref: str | None = None,
|
|
130
|
+
) -> dict[str, Any]:
|
|
131
|
+
"""Record that a task completed. `task_id` should match the value
|
|
132
|
+
passed to the corresponding `task_started()` call (though the
|
|
133
|
+
backend does not hard-require a prior `task_started` for the same
|
|
134
|
+
`task_id` — see `app.modules.events.service.get_prior_task_started`
|
|
135
|
+
in the backend for that documented leniency decision)."""
|
|
136
|
+
payload: dict[str, Any] = {
|
|
137
|
+
"event_type": "task_completed",
|
|
138
|
+
"task_id": task_id,
|
|
139
|
+
"outcome": outcome,
|
|
140
|
+
"duration_ms": duration_ms,
|
|
141
|
+
}
|
|
142
|
+
if category is not None:
|
|
143
|
+
payload["category"] = category
|
|
144
|
+
if external_ref is not None:
|
|
145
|
+
payload["external_ref"] = external_ref
|
|
146
|
+
return self._post_signed("/api/v1/events", payload)
|
|
147
|
+
|
|
148
|
+
# --- Heartbeat (P2: liveness) ------------------------------------------
|
|
149
|
+
|
|
150
|
+
def heartbeat(self) -> dict[str, Any]:
|
|
151
|
+
"""Record a liveness ping — call on a schedule (recommend every
|
|
152
|
+
30-60 minutes). No payload: a heartbeat carries no data beyond "I
|
|
153
|
+
am alive right now." Returns the platform's resolved
|
|
154
|
+
`last_heartbeat_at`/`liveness_state`. P2 is a gated-lane feature
|
|
155
|
+
(PRODUCT_ROADMAP_2.md) that ships with its flag off by default —
|
|
156
|
+
this call 404s (raising `AiOpsError` with `status_code == 404`)
|
|
157
|
+
until the platform operator has enabled it."""
|
|
158
|
+
return self._post_signed("/api/v1/heartbeat", {})
|
|
159
|
+
|
|
160
|
+
# --- Ratings (F3) -----------------------------------------------------
|
|
161
|
+
|
|
162
|
+
def rate(
|
|
163
|
+
self,
|
|
164
|
+
*,
|
|
165
|
+
rating: RatingValue,
|
|
166
|
+
end_user_anonymous_id: str,
|
|
167
|
+
comment: str | None = None,
|
|
168
|
+
task_reference: str | None = None,
|
|
169
|
+
) -> dict[str, Any]:
|
|
170
|
+
"""Record an end-user rating (thumbs up/down) on behalf of this
|
|
171
|
+
agent — convenience wrapper around `POST /api/v1/ratings`."""
|
|
172
|
+
payload: dict[str, Any] = {
|
|
173
|
+
"rating": rating,
|
|
174
|
+
"end_user_anonymous_id": end_user_anonymous_id,
|
|
175
|
+
}
|
|
176
|
+
if comment is not None:
|
|
177
|
+
payload["comment"] = comment
|
|
178
|
+
if task_reference is not None:
|
|
179
|
+
payload["task_reference"] = task_reference
|
|
180
|
+
return self._post_signed("/api/v1/ratings", payload)
|
|
File without changes
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""HMAC-SHA256 request signing — must byte-for-byte match
|
|
2
|
+
`app.modules.agents.hmac_auth` in the backend (this SDK lives in the same
|
|
3
|
+
monorepo, `/backend`, as the API it's a client for).
|
|
4
|
+
|
|
5
|
+
Signing scheme (mirrored exactly — see the backend module's docstring,
|
|
6
|
+
`backend/app/modules/agents/hmac_auth.py`, for the canonical source of
|
|
7
|
+
truth; also documented in the monorepo's DECISIONS.md):
|
|
8
|
+
|
|
9
|
+
- Headers: ``X-Agent-Key-Id``, ``X-Agent-Timestamp`` (Unix seconds, as a
|
|
10
|
+
string), ``X-Agent-Signature`` (lowercase hex HMAC-SHA256).
|
|
11
|
+
- Signed message: ``f"{timestamp}.".encode() + raw_request_body_bytes``.
|
|
12
|
+
- HMAC key: ``bytes.fromhex(secret_hash)``, where ``secret_hash`` is the
|
|
13
|
+
SHA-256 hex digest of the agent's raw secret
|
|
14
|
+
(``sha256(secret).hexdigest()``) — deterministic, so the backend (which
|
|
15
|
+
only ever stores this digest, never the raw secret after issuance) and
|
|
16
|
+
this SDK (which only ever has the raw secret) independently arrive at
|
|
17
|
+
the same HMAC key without transmitting either the raw secret or the
|
|
18
|
+
digest itself over the wire.
|
|
19
|
+
|
|
20
|
+
Parity with the backend is verified in ``tests/test_signing_parity.py`` by
|
|
21
|
+
importing the backend's actual ``app.modules.agents.hmac_auth.verify_signature``
|
|
22
|
+
function directly (a monorepo-local import — that module has zero
|
|
23
|
+
third-party dependencies, so this doesn't require installing the backend's
|
|
24
|
+
FastAPI/SQLAlchemy stack) and asserting a message signed by this module
|
|
25
|
+
verifies successfully against it.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import hashlib
|
|
31
|
+
import hmac
|
|
32
|
+
import time
|
|
33
|
+
|
|
34
|
+
KEY_ID_HEADER = "X-Agent-Key-Id"
|
|
35
|
+
TIMESTAMP_HEADER = "X-Agent-Timestamp"
|
|
36
|
+
SIGNATURE_HEADER = "X-Agent-Signature"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def secret_hash(secret: str) -> str:
|
|
40
|
+
"""The hex-encoded SHA-256 digest of the raw agent secret — used
|
|
41
|
+
directly as the HMAC key, matching ``AgentApiKey.secret_hash`` on the
|
|
42
|
+
backend (see module docstring)."""
|
|
43
|
+
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def compute_signature(*, secret: str, timestamp: str, body: bytes) -> str:
|
|
47
|
+
"""Return the hex HMAC-SHA256 signature for ``body`` signed at
|
|
48
|
+
``timestamp``, using the raw agent ``secret``.
|
|
49
|
+
|
|
50
|
+
Unlike the backend's ``hmac_auth.compute_signature`` (which takes an
|
|
51
|
+
already-hashed ``secret_hash``, since the backend never has the raw
|
|
52
|
+
secret after issuance), this takes the raw ``secret`` directly — the
|
|
53
|
+
SDK caller has it, hashes it internally via `secret_hash`, and the two
|
|
54
|
+
resulting HMAC keys are identical.
|
|
55
|
+
"""
|
|
56
|
+
key = bytes.fromhex(secret_hash(secret))
|
|
57
|
+
message = f"{timestamp}.".encode() + body
|
|
58
|
+
return hmac.new(key, message, hashlib.sha256).hexdigest()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def sign_request(*, key_id: str, secret: str, body: bytes) -> dict[str, str]:
|
|
62
|
+
"""Build the full set of signed-request headers for ``body``."""
|
|
63
|
+
timestamp = str(int(time.time()))
|
|
64
|
+
signature = compute_signature(secret=secret, timestamp=timestamp, body=body)
|
|
65
|
+
return {
|
|
66
|
+
KEY_ID_HEADER: key_id,
|
|
67
|
+
TIMESTAMP_HEADER: timestamp,
|
|
68
|
+
SIGNATURE_HEADER: signature,
|
|
69
|
+
"Content-Type": "application/json",
|
|
70
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aiops-enabler
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for AiOps Enabler — instrument your AI agent's task lifecycle and ratings in 3 lines of code.
|
|
5
|
+
Author: AiOps Enabler
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://aiopsenabler.com
|
|
8
|
+
Project-URL: Repository, https://github.com/cyntra360hub/aiops-enabler-sdk
|
|
9
|
+
Project-URL: Issues, https://github.com/cyntra360hub/aiops-enabler-sdk/issues
|
|
10
|
+
Keywords: aiops,agents,observability,ratings,instrumentation
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: httpx<1.0,>=0.27
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest<9.0,>=8.3; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-cov<7.0,>=6.0; extra == "dev"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# aiops-enabler
|
|
26
|
+
|
|
27
|
+
[](https://pypi.org/project/aiops-enabler/)
|
|
28
|
+
|
|
29
|
+
Python SDK for [AiOps Enabler](https://aiopsenabler.com) — *where AI agents
|
|
30
|
+
prove their worth*. Wraps the signed events (task-lifecycle instrumentation)
|
|
31
|
+
and ratings HTTP APIs behind a tiny, ergonomic client.
|
|
32
|
+
|
|
33
|
+
This is the public source for the SDK only. The AiOps Enabler platform
|
|
34
|
+
itself lives in a separate, private repository — the SDK is split out here
|
|
35
|
+
specifically so it can be a normal, publicly installable PyPI package with
|
|
36
|
+
its own public source, issues, and CI, independent of the platform's own
|
|
37
|
+
release cycle.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install aiops-enabler
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
For local development against a clone of this repo:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install -e ".[dev]"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quickstart
|
|
52
|
+
|
|
53
|
+
Instrument your agent's task lifecycle in 3 lines of code:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from aiops_enabler import AiOpsClient
|
|
57
|
+
|
|
58
|
+
client = AiOpsClient(agent_key_id="ak_...", agent_secret="...")
|
|
59
|
+
client.task_started(task_id="abc123")
|
|
60
|
+
client.task_completed(task_id="abc123", outcome="success", duration_ms=1420, category="incident-response")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`agent_key_id`/`agent_secret` are the API key pair issued when you register
|
|
64
|
+
your agent (`POST /api/v1/agents`) or rotate its key
|
|
65
|
+
(`POST /api/v1/agents/{slug}/api-keys/rotate`) — shown exactly once at
|
|
66
|
+
issuance time.
|
|
67
|
+
|
|
68
|
+
Every call is HMAC-signed automatically; you never need to touch signing
|
|
69
|
+
headers yourself.
|
|
70
|
+
|
|
71
|
+
### Recording an outcome
|
|
72
|
+
|
|
73
|
+
`outcome` is one of `"success"`, `"failure"`, or `"escalated"` (escalated =
|
|
74
|
+
handed off to a human, not auto-resolved — see how this feeds
|
|
75
|
+
`auto_resolution_rate` on your agent's public profile).
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
client.task_completed(
|
|
79
|
+
task_id="abc123",
|
|
80
|
+
outcome="success",
|
|
81
|
+
duration_ms=1420,
|
|
82
|
+
category="incident-response", # optional, freeform
|
|
83
|
+
external_ref="datadog:incident:98765", # optional, Phase 2 reconciliation prep
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
On the first event your agent ever sends, its profile's verification level
|
|
88
|
+
automatically upgrades from "self-reported" to **"instrumented ✓"**. Once
|
|
89
|
+
enough completed tasks have been recorded, the platform's async worker
|
|
90
|
+
computes tasks handled, success %, auto-resolution %, median/95th-percentile
|
|
91
|
+
duration, and a 30-day trend — all visible on your agent's public profile.
|
|
92
|
+
|
|
93
|
+
### Recording a rating
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
client.rate(
|
|
97
|
+
rating="up", # or "down"
|
|
98
|
+
end_user_anonymous_id="some-opaque-id-you-control",
|
|
99
|
+
comment="Resolved my incident in under a minute!", # optional
|
|
100
|
+
task_reference="abc123", # optional
|
|
101
|
+
)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Configuration
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
client = AiOpsClient(
|
|
108
|
+
agent_key_id="ak_...",
|
|
109
|
+
agent_secret="...",
|
|
110
|
+
base_url="https://api.aiopsenabler.com", # default; override for staging/local dev
|
|
111
|
+
timeout=10.0, # seconds
|
|
112
|
+
)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Use as a context manager to close the underlying connection pool
|
|
116
|
+
deterministically:
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
with AiOpsClient(agent_key_id="ak_...", agent_secret="...") as client:
|
|
120
|
+
client.task_started(task_id="abc123")
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Error handling
|
|
124
|
+
|
|
125
|
+
Any non-2xx response raises `aiops_enabler.AiOpsError`, carrying
|
|
126
|
+
`.status_code` and `.detail`:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from aiops_enabler import AiOpsClient, AiOpsError
|
|
130
|
+
|
|
131
|
+
client = AiOpsClient(agent_key_id="ak_...", agent_secret="...")
|
|
132
|
+
try:
|
|
133
|
+
client.task_started(task_id="abc123")
|
|
134
|
+
except AiOpsError as exc:
|
|
135
|
+
print(exc.status_code, exc.detail)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## How signing works
|
|
139
|
+
|
|
140
|
+
Every request is signed with the exact scheme the AiOps Enabler backend
|
|
141
|
+
verifies (see [the API guide](https://aiopsenabler.com/api-guide.md) for
|
|
142
|
+
the full spec and a signed test vector you can check any implementation
|
|
143
|
+
against, including this one):
|
|
144
|
+
|
|
145
|
+
- Headers: `X-Agent-Key-Id`, `X-Agent-Timestamp` (Unix seconds), `X-Agent-Signature`
|
|
146
|
+
(lowercase hex HMAC-SHA256).
|
|
147
|
+
- Signed message: `f"{timestamp}.".encode() + raw_request_body_bytes`.
|
|
148
|
+
- HMAC key: the SHA-256 hex digest of your agent secret.
|
|
149
|
+
|
|
150
|
+
See `src/aiops_enabler/signing.py` for the implementation.
|
|
151
|
+
`tests/test_signing_parity.py` additionally imports the platform backend's
|
|
152
|
+
real verifier function directly and confirms an SDK-signed message
|
|
153
|
+
verifies successfully against it — that check only runs when this repo
|
|
154
|
+
is checked out as a sibling of the (private) platform repo, and skips
|
|
155
|
+
cleanly otherwise; the test vector in the API guide is the
|
|
156
|
+
publicly-verifiable equivalent for anyone without access to the backend.
|
|
157
|
+
|
|
158
|
+
## Development
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
pip install -e ".[dev]"
|
|
162
|
+
pytest
|
|
163
|
+
ruff check .
|
|
164
|
+
mypy src
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Releasing
|
|
168
|
+
|
|
169
|
+
Tag a commit `vX.Y.Z` matching `pyproject.toml`'s `version` and push the
|
|
170
|
+
tag — `.github/workflows/publish.yml` builds and publishes to PyPI via
|
|
171
|
+
[Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (no
|
|
172
|
+
long-lived API token stored in this repo).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/aiops_enabler/__init__.py
|
|
5
|
+
src/aiops_enabler/client.py
|
|
6
|
+
src/aiops_enabler/py.typed
|
|
7
|
+
src/aiops_enabler/signing.py
|
|
8
|
+
src/aiops_enabler.egg-info/PKG-INFO
|
|
9
|
+
src/aiops_enabler.egg-info/SOURCES.txt
|
|
10
|
+
src/aiops_enabler.egg-info/dependency_links.txt
|
|
11
|
+
src/aiops_enabler.egg-info/requires.txt
|
|
12
|
+
src/aiops_enabler.egg-info/top_level.txt
|
|
13
|
+
tests/test_client.py
|
|
14
|
+
tests/test_signing_parity.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aiops_enabler
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Unit tests for `AiOpsClient`. The HTTP layer is fully mocked via
|
|
2
|
+
`httpx.MockTransport` — no live backend required (per the M5 task brief:
|
|
3
|
+
"unit tests mocking the HTTP layer")."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from aiops_enabler import AiOpsClient, AiOpsError
|
|
14
|
+
from aiops_enabler.signing import KEY_ID_HEADER, SIGNATURE_HEADER, TIMESTAMP_HEADER
|
|
15
|
+
|
|
16
|
+
Handler = Callable[[httpx.Request], httpx.Response]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _client(handler: Handler) -> AiOpsClient:
|
|
20
|
+
return AiOpsClient(
|
|
21
|
+
agent_key_id="ak_test",
|
|
22
|
+
agent_secret="s3cr3t-agent-secret",
|
|
23
|
+
base_url="https://example.test",
|
|
24
|
+
transport=httpx.MockTransport(handler),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_task_started_posts_signed_request_to_events_endpoint() -> None:
|
|
29
|
+
captured: dict[str, httpx.Request] = {}
|
|
30
|
+
|
|
31
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
32
|
+
captured["request"] = request
|
|
33
|
+
return httpx.Response(201, json={"id": "evt-1", "event_type": "task_started"})
|
|
34
|
+
|
|
35
|
+
with _client(handler) as client:
|
|
36
|
+
result = client.task_started(task_id="abc123")
|
|
37
|
+
|
|
38
|
+
request = captured["request"]
|
|
39
|
+
assert request.method == "POST"
|
|
40
|
+
assert request.url.path == "/api/v1/events"
|
|
41
|
+
assert request.headers[KEY_ID_HEADER] == "ak_test"
|
|
42
|
+
assert TIMESTAMP_HEADER in request.headers
|
|
43
|
+
assert SIGNATURE_HEADER in request.headers
|
|
44
|
+
assert request.headers["Content-Type"] == "application/json"
|
|
45
|
+
|
|
46
|
+
body = json.loads(request.content)
|
|
47
|
+
assert body == {"event_type": "task_started", "task_id": "abc123"}
|
|
48
|
+
assert result == {"id": "evt-1", "event_type": "task_started"}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_task_completed_includes_all_provided_fields() -> None:
|
|
52
|
+
captured: dict[str, httpx.Request] = {}
|
|
53
|
+
|
|
54
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
55
|
+
captured["request"] = request
|
|
56
|
+
return httpx.Response(201, json={"id": "evt-2"})
|
|
57
|
+
|
|
58
|
+
with _client(handler) as client:
|
|
59
|
+
client.task_completed(
|
|
60
|
+
task_id="abc123",
|
|
61
|
+
outcome="success",
|
|
62
|
+
duration_ms=1420,
|
|
63
|
+
category="incident-response",
|
|
64
|
+
external_ref="datadog:incident:1",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
body = json.loads(captured["request"].content)
|
|
68
|
+
assert body == {
|
|
69
|
+
"event_type": "task_completed",
|
|
70
|
+
"task_id": "abc123",
|
|
71
|
+
"outcome": "success",
|
|
72
|
+
"duration_ms": 1420,
|
|
73
|
+
"category": "incident-response",
|
|
74
|
+
"external_ref": "datadog:incident:1",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_task_completed_omits_optional_fields_when_not_provided() -> None:
|
|
79
|
+
captured: dict[str, httpx.Request] = {}
|
|
80
|
+
|
|
81
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
82
|
+
captured["request"] = request
|
|
83
|
+
return httpx.Response(201, json={})
|
|
84
|
+
|
|
85
|
+
with _client(handler) as client:
|
|
86
|
+
client.task_completed(task_id="abc123", outcome="failure", duration_ms=500)
|
|
87
|
+
|
|
88
|
+
body = json.loads(captured["request"].content)
|
|
89
|
+
assert body == {
|
|
90
|
+
"event_type": "task_completed",
|
|
91
|
+
"task_id": "abc123",
|
|
92
|
+
"outcome": "failure",
|
|
93
|
+
"duration_ms": 500,
|
|
94
|
+
}
|
|
95
|
+
assert "category" not in body
|
|
96
|
+
assert "external_ref" not in body
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_heartbeat_posts_signed_empty_request_to_heartbeat_endpoint() -> None:
|
|
100
|
+
captured: dict[str, httpx.Request] = {}
|
|
101
|
+
|
|
102
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
103
|
+
captured["request"] = request
|
|
104
|
+
return httpx.Response(
|
|
105
|
+
201, json={"last_heartbeat_at": "2026-07-14T12:00:00Z", "liveness_state": "active"}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
with _client(handler) as client:
|
|
109
|
+
result = client.heartbeat()
|
|
110
|
+
|
|
111
|
+
request = captured["request"]
|
|
112
|
+
assert request.method == "POST"
|
|
113
|
+
assert request.url.path == "/api/v1/heartbeat"
|
|
114
|
+
assert request.headers[KEY_ID_HEADER] == "ak_test"
|
|
115
|
+
assert TIMESTAMP_HEADER in request.headers
|
|
116
|
+
assert SIGNATURE_HEADER in request.headers
|
|
117
|
+
assert json.loads(request.content) == {}
|
|
118
|
+
assert result == {"last_heartbeat_at": "2026-07-14T12:00:00Z", "liveness_state": "active"}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_heartbeat_404_while_gated_flag_is_off_raises_aiops_error() -> None:
|
|
122
|
+
"""P2 ships flag-off by default (PRODUCT_ROADMAP_2.md, gated lane) —
|
|
123
|
+
the SDK surfaces that the same way as any other API error, not a
|
|
124
|
+
special case, so a caller can catch `AiOpsError` and check
|
|
125
|
+
`status_code == 404` if it wants to distinguish "not enabled yet"
|
|
126
|
+
from a genuine outage."""
|
|
127
|
+
|
|
128
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
129
|
+
return httpx.Response(404, json={"detail": "Not found"})
|
|
130
|
+
|
|
131
|
+
with _client(handler) as client, pytest.raises(AiOpsError) as exc_info:
|
|
132
|
+
client.heartbeat()
|
|
133
|
+
|
|
134
|
+
assert exc_info.value.status_code == 404
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_rate_posts_signed_request_to_ratings_endpoint() -> None:
|
|
138
|
+
captured: dict[str, httpx.Request] = {}
|
|
139
|
+
|
|
140
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
141
|
+
captured["request"] = request
|
|
142
|
+
return httpx.Response(201, json={"id": "rating-1", "rating": 1})
|
|
143
|
+
|
|
144
|
+
with _client(handler) as client:
|
|
145
|
+
result = client.rate(rating="up", end_user_anonymous_id="euid-1", comment="great job")
|
|
146
|
+
|
|
147
|
+
request = captured["request"]
|
|
148
|
+
assert request.url.path == "/api/v1/ratings"
|
|
149
|
+
body = json.loads(request.content)
|
|
150
|
+
assert body == {"rating": "up", "end_user_anonymous_id": "euid-1", "comment": "great job"}
|
|
151
|
+
assert result == {"id": "rating-1", "rating": 1}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_rate_includes_task_reference_when_provided() -> None:
|
|
155
|
+
captured: dict[str, httpx.Request] = {}
|
|
156
|
+
|
|
157
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
158
|
+
captured["request"] = request
|
|
159
|
+
return httpx.Response(201, json={})
|
|
160
|
+
|
|
161
|
+
with _client(handler) as client:
|
|
162
|
+
client.rate(rating="up", end_user_anonymous_id="euid-3", task_reference="task-abc")
|
|
163
|
+
|
|
164
|
+
body = json.loads(captured["request"].content)
|
|
165
|
+
assert body == {
|
|
166
|
+
"rating": "up",
|
|
167
|
+
"end_user_anonymous_id": "euid-3",
|
|
168
|
+
"task_reference": "task-abc",
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_empty_response_body_returns_empty_dict() -> None:
|
|
173
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
174
|
+
return httpx.Response(204)
|
|
175
|
+
|
|
176
|
+
with _client(handler) as client:
|
|
177
|
+
result = client.task_started(task_id="abc123")
|
|
178
|
+
|
|
179
|
+
assert result == {}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_rate_omits_optional_fields_when_not_provided() -> None:
|
|
183
|
+
captured: dict[str, httpx.Request] = {}
|
|
184
|
+
|
|
185
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
186
|
+
captured["request"] = request
|
|
187
|
+
return httpx.Response(201, json={})
|
|
188
|
+
|
|
189
|
+
with _client(handler) as client:
|
|
190
|
+
client.rate(rating="down", end_user_anonymous_id="euid-2")
|
|
191
|
+
|
|
192
|
+
body = json.loads(captured["request"].content)
|
|
193
|
+
assert body == {"rating": "down", "end_user_anonymous_id": "euid-2"}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_error_response_raises_aiops_error_with_status_and_detail() -> None:
|
|
197
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
198
|
+
return httpx.Response(401, text="Invalid request signature")
|
|
199
|
+
|
|
200
|
+
with _client(handler) as client, pytest.raises(AiOpsError) as exc_info:
|
|
201
|
+
client.task_started(task_id="abc123")
|
|
202
|
+
|
|
203
|
+
assert exc_info.value.status_code == 401
|
|
204
|
+
assert "Invalid request signature" in exc_info.value.detail
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_rate_limit_error_response_raises_aiops_error() -> None:
|
|
208
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
209
|
+
return httpx.Response(429, text="Rate limit exceeded", headers={"Retry-After": "30"})
|
|
210
|
+
|
|
211
|
+
with _client(handler) as client, pytest.raises(AiOpsError) as exc_info:
|
|
212
|
+
client.task_started(task_id="abc123")
|
|
213
|
+
|
|
214
|
+
assert exc_info.value.status_code == 429
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_context_manager_closes_underlying_http_client() -> None:
|
|
218
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
219
|
+
return httpx.Response(201, json={})
|
|
220
|
+
|
|
221
|
+
with _client(handler) as client:
|
|
222
|
+
client.task_started(task_id="abc123")
|
|
223
|
+
assert not client._http.is_closed
|
|
224
|
+
|
|
225
|
+
assert client._http.is_closed
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_close_can_be_called_directly_without_context_manager() -> None:
|
|
229
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
230
|
+
return httpx.Response(201, json={})
|
|
231
|
+
|
|
232
|
+
client = _client(handler)
|
|
233
|
+
client.task_started(task_id="abc123")
|
|
234
|
+
client.close()
|
|
235
|
+
assert client._http.is_closed
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_timestamp_header_is_close_to_current_unix_time() -> None:
|
|
239
|
+
import time
|
|
240
|
+
|
|
241
|
+
captured: dict[str, httpx.Request] = {}
|
|
242
|
+
|
|
243
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
244
|
+
captured["request"] = request
|
|
245
|
+
return httpx.Response(201, json={})
|
|
246
|
+
|
|
247
|
+
before = int(time.time())
|
|
248
|
+
with _client(handler) as client:
|
|
249
|
+
client.task_started(task_id="abc123")
|
|
250
|
+
after = int(time.time())
|
|
251
|
+
|
|
252
|
+
ts = int(captured["request"].headers[TIMESTAMP_HEADER])
|
|
253
|
+
assert before <= ts <= after
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Cross-package parity test: a message signed by this SDK's
|
|
2
|
+
`aiops_enabler.signing` module must verify successfully against the
|
|
3
|
+
backend's ACTUAL verifier function
|
|
4
|
+
(`app.modules.agents.hmac_auth.verify_signature`), imported directly from
|
|
5
|
+
`/backend` in this monorepo — not a hand-reimplementation merely guessed to
|
|
6
|
+
match. This is the strongest form of parity check available: it exercises
|
|
7
|
+
the real production verifier the backend's `require_agent_api_key`
|
|
8
|
+
dependency calls on every signed request, not a copy of its logic.
|
|
9
|
+
|
|
10
|
+
This works without installing the backend's FastAPI/SQLAlchemy/etc.
|
|
11
|
+
dependency set because `app.modules.agents.hmac_auth` (and every package
|
|
12
|
+
`__init__.py` on the import path to it: `app`, `app.modules`,
|
|
13
|
+
`app.modules.agents`) has zero third-party imports — stdlib
|
|
14
|
+
`hashlib`/`hmac`/`datetime` only. Only `sys.path` needs to include
|
|
15
|
+
`/backend` for the import to resolve.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import sys
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from types import ModuleType
|
|
24
|
+
|
|
25
|
+
import pytest
|
|
26
|
+
|
|
27
|
+
from aiops_enabler.signing import compute_signature, secret_hash
|
|
28
|
+
|
|
29
|
+
_BACKEND_DIR = Path(__file__).resolve().parents[3] / "backend"
|
|
30
|
+
_HMAC_AUTH_PATH = _BACKEND_DIR / "app" / "modules" / "agents" / "hmac_auth.py"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _import_backend_hmac_auth() -> ModuleType:
|
|
34
|
+
if not _HMAC_AUTH_PATH.exists():
|
|
35
|
+
pytest.skip(
|
|
36
|
+
f"backend not found at {_BACKEND_DIR!s} (expected monorepo layout with "
|
|
37
|
+
"sdk/python and backend as siblings) — skipping the cross-package parity "
|
|
38
|
+
"check; see this module's docstring for hand-verification notes."
|
|
39
|
+
)
|
|
40
|
+
if str(_BACKEND_DIR) not in sys.path:
|
|
41
|
+
sys.path.insert(0, str(_BACKEND_DIR))
|
|
42
|
+
from app.modules.agents import hmac_auth
|
|
43
|
+
|
|
44
|
+
return hmac_auth
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_sdk_signature_matches_backend_compute_signature_byte_for_byte() -> None:
|
|
48
|
+
backend_hmac_auth = _import_backend_hmac_auth()
|
|
49
|
+
|
|
50
|
+
secret = "test-agent-secret-value"
|
|
51
|
+
timestamp = str(int(datetime.now(timezone.utc).timestamp()))
|
|
52
|
+
body = b'{"event_type":"task_started","task_id":"abc123"}'
|
|
53
|
+
|
|
54
|
+
sdk_signature = compute_signature(secret=secret, timestamp=timestamp, body=body)
|
|
55
|
+
backend_signature = backend_hmac_auth.compute_signature(
|
|
56
|
+
secret_hash=secret_hash(secret), timestamp=timestamp, body=body
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
assert sdk_signature == backend_signature
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_sdk_signed_message_verifies_against_the_backends_real_verifier() -> None:
|
|
63
|
+
"""The strongest parity check: feed a message signed by the SDK
|
|
64
|
+
straight into the backend's real `verify_signature` (the exact
|
|
65
|
+
function `require_agent_api_key` calls in production) and assert it
|
|
66
|
+
accepts it."""
|
|
67
|
+
backend_hmac_auth = _import_backend_hmac_auth()
|
|
68
|
+
|
|
69
|
+
secret = "another-test-secret" # nosec B105 - test fixture literal
|
|
70
|
+
timestamp = str(int(datetime.now(timezone.utc).timestamp()))
|
|
71
|
+
body = (
|
|
72
|
+
b'{"event_type":"task_completed","task_id":"abc123","outcome":"success","duration_ms":1420}'
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
signature = compute_signature(secret=secret, timestamp=timestamp, body=body)
|
|
76
|
+
|
|
77
|
+
assert backend_hmac_auth.verify_signature(
|
|
78
|
+
secret_hash=secret_hash(secret),
|
|
79
|
+
timestamp=timestamp,
|
|
80
|
+
body=body,
|
|
81
|
+
signature=signature,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_tampered_body_fails_the_backends_real_verifier() -> None:
|
|
86
|
+
backend_hmac_auth = _import_backend_hmac_auth()
|
|
87
|
+
|
|
88
|
+
secret = "yet-another-secret" # nosec B105 - test fixture literal
|
|
89
|
+
timestamp = str(int(datetime.now(timezone.utc).timestamp()))
|
|
90
|
+
signed_body = b'{"task_id":"abc123"}'
|
|
91
|
+
tampered_body = b'{"task_id":"xyz999"}'
|
|
92
|
+
|
|
93
|
+
signature = compute_signature(secret=secret, timestamp=timestamp, body=signed_body)
|
|
94
|
+
|
|
95
|
+
assert not backend_hmac_auth.verify_signature(
|
|
96
|
+
secret_hash=secret_hash(secret),
|
|
97
|
+
timestamp=timestamp,
|
|
98
|
+
body=tampered_body,
|
|
99
|
+
signature=signature,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_stale_timestamp_fails_the_backends_real_verifier() -> None:
|
|
104
|
+
backend_hmac_auth = _import_backend_hmac_auth()
|
|
105
|
+
|
|
106
|
+
secret = "stale-timestamp-secret" # nosec B105 - test fixture literal
|
|
107
|
+
# 10 minutes ago — outside the backend's default 300s freshness window.
|
|
108
|
+
stale_timestamp = str(int(datetime.now(timezone.utc).timestamp()) - 600)
|
|
109
|
+
body = b'{"task_id":"abc123"}'
|
|
110
|
+
|
|
111
|
+
signature = compute_signature(secret=secret, timestamp=stale_timestamp, body=body)
|
|
112
|
+
|
|
113
|
+
assert not backend_hmac_auth.verify_signature(
|
|
114
|
+
secret_hash=secret_hash(secret),
|
|
115
|
+
timestamp=stale_timestamp,
|
|
116
|
+
body=body,
|
|
117
|
+
signature=signature,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_secret_hash_matches_backends_agent_api_key_secret_hash_scheme() -> None:
|
|
122
|
+
"""`app.models.agent_api_key.AgentApiKey.secret_hash` is documented as
|
|
123
|
+
`sha256(raw_secret).hexdigest()` — confirm the SDK's `secret_hash()`
|
|
124
|
+
helper derives the identical value a real issued key's stored hash
|
|
125
|
+
would be, independent of the HMAC signing path above."""
|
|
126
|
+
import hashlib
|
|
127
|
+
|
|
128
|
+
secret = "issued-agent-secret-abc" # nosec B105 - test fixture literal
|
|
129
|
+
assert secret_hash(secret) == hashlib.sha256(secret.encode("utf-8")).hexdigest()
|