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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/aiops-enabler.svg)](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
+ [![PyPI](https://img.shields.io/pypi/v/aiops-enabler.svg)](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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/aiops-enabler.svg)](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,5 @@
1
+ httpx<1.0,>=0.27
2
+
3
+ [dev]
4
+ pytest<9.0,>=8.3
5
+ pytest-cov<7.0,>=6.0
@@ -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()