agent-operations 0.1.0__py3-none-any.whl
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.
- agent_operations-0.1.0.dist-info/METADATA +264 -0
- agent_operations-0.1.0.dist-info/RECORD +18 -0
- agent_operations-0.1.0.dist-info/WHEEL +5 -0
- agent_operations-0.1.0.dist-info/entry_points.txt +3 -0
- agent_operations-0.1.0.dist-info/licenses/LICENSE +21 -0
- agent_operations-0.1.0.dist-info/top_level.txt +1 -0
- agentops/__init__.py +26 -0
- agentops/alerts.py +194 -0
- agentops/auth.py +113 -0
- agentops/db.py +154 -0
- agentops/events.py +68 -0
- agentops/http.py +325 -0
- agentops/ingest.py +331 -0
- agentops/metrics.py +118 -0
- agentops/pricing.py +123 -0
- agentops/query.py +234 -0
- agentops/redact.py +93 -0
- agentops/trace.py +197 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-operations
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight self-hosted Agent Operations (AgentOps / LLMOps) toolkit
|
|
5
|
+
Author: WenSongWang
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/WenSongWang/agent-operations
|
|
8
|
+
Project-URL: Repository, https://github.com/WenSongWang/agent-operations
|
|
9
|
+
Project-URL: Issues, https://github.com/WenSongWang/agent-operations/issues
|
|
10
|
+
Keywords: agentops,llmops,observability,tracing,sqlite
|
|
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.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# agent-operations
|
|
27
|
+
|
|
28
|
+
Open-source **Agent Operations** (AgentOps / LLMOps) toolkit — trace runs, track latency / tokens / cost, and debug tool failures.
|
|
29
|
+
|
|
30
|
+
Lightweight and self-hostable. Default storage is one SQLite file. No ClickHouse required.
|
|
31
|
+
|
|
32
|
+
| 项 | 值 |
|
|
33
|
+
| :--- | :--- |
|
|
34
|
+
| Python | ≥ 3.11 |
|
|
35
|
+
| Install | `pip install -e .`(当前建议从源码安装) |
|
|
36
|
+
| Default UI | http://127.0.0.1:8787 |
|
|
37
|
+
| Default DB | `./data/agentops.db` |
|
|
38
|
+
|
|
39
|
+
## 5 分钟上手
|
|
40
|
+
|
|
41
|
+
### 1. 安装
|
|
42
|
+
|
|
43
|
+
```powershell
|
|
44
|
+
git clone https://github.com/WenSongWang/agent-operations.git
|
|
45
|
+
cd agent-operations
|
|
46
|
+
python -m pip install -e .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
可选:跑测试
|
|
50
|
+
|
|
51
|
+
```powershell
|
|
52
|
+
python -m unittest discover -s tests -v
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 2. 用 Python 写入一条 run
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from agentops import connect, ingest_run
|
|
59
|
+
|
|
60
|
+
conn = connect("./data/agentops.db")
|
|
61
|
+
ingest_run(conn, {
|
|
62
|
+
"id": "run-001",
|
|
63
|
+
"project": "demo",
|
|
64
|
+
"agent": "assistant",
|
|
65
|
+
"environment": "dev", # dev | test | uat | prd
|
|
66
|
+
"name": "answer-question",
|
|
67
|
+
"status": "ok", # running | ok | error | timeout | cancelled
|
|
68
|
+
"started_at": "2026-10-24T10:00:00+00:00",
|
|
69
|
+
"ended_at": "2026-10-24T10:00:02+00:00",
|
|
70
|
+
"provider": "openai",
|
|
71
|
+
"model": "gpt-4o-mini",
|
|
72
|
+
"input_tokens": 100,
|
|
73
|
+
"output_tokens": 50,
|
|
74
|
+
# cost_usd 可省略:会按内置单价估算
|
|
75
|
+
"steps": [
|
|
76
|
+
{
|
|
77
|
+
"seq": 1,
|
|
78
|
+
"kind": "llm", # llm | tool | agent | human
|
|
79
|
+
"name": "gpt-4o-mini",
|
|
80
|
+
"status": "ok",
|
|
81
|
+
"started_at": "2026-10-24T10:00:00+00:00",
|
|
82
|
+
"ended_at": "2026-10-24T10:00:02+00:00",
|
|
83
|
+
"input_tokens": 100,
|
|
84
|
+
"output_tokens": 50,
|
|
85
|
+
}
|
|
86
|
+
],
|
|
87
|
+
})
|
|
88
|
+
conn.close()
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
或直接跑示例:
|
|
92
|
+
|
|
93
|
+
```powershell
|
|
94
|
+
python examples/quickstart.py
|
|
95
|
+
python examples/openai_wrap.py
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### 2b. 更省事:用 `TracedRun`(推荐)
|
|
99
|
+
|
|
100
|
+
不用手写 `running` / 终态时间戳;OpenAI 响应可直接抽 token:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from agentops import connect, TracedRun
|
|
104
|
+
|
|
105
|
+
conn = connect("./data/agentops.db")
|
|
106
|
+
with TracedRun(
|
|
107
|
+
conn,
|
|
108
|
+
project="demo",
|
|
109
|
+
agent="assistant",
|
|
110
|
+
environment="dev",
|
|
111
|
+
name="chat",
|
|
112
|
+
task_input="Say hi",
|
|
113
|
+
) as tr:
|
|
114
|
+
# resp = client.chat.completions.create(...)
|
|
115
|
+
# tr.record_openai(resp, output_text=resp.choices[0].message.content)
|
|
116
|
+
tr.finish(task_output="hi")
|
|
117
|
+
conn.close()
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
异常退出时会自动记成 `status=error`。完整示例见 `examples/openai_wrap.py`。
|
|
121
|
+
|
|
122
|
+
### 3. 启动本机服务与页面
|
|
123
|
+
|
|
124
|
+
```powershell
|
|
125
|
+
agentops-http
|
|
126
|
+
# 等价:python -m agentops.http
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
浏览器打开 http://127.0.0.1:8787
|
|
130
|
+
|
|
131
|
+
### 4. 用 HTTP / curl 上报
|
|
132
|
+
|
|
133
|
+
```powershell
|
|
134
|
+
curl -s http://127.0.0.1:8787/api/runs -Method POST -ContentType "application/json" -Body @'
|
|
135
|
+
{
|
|
136
|
+
"id": "run-002",
|
|
137
|
+
"project": "demo",
|
|
138
|
+
"agent": "assistant",
|
|
139
|
+
"environment": "dev",
|
|
140
|
+
"name": "via-http",
|
|
141
|
+
"status": "ok",
|
|
142
|
+
"started_at": "2026-10-24T11:00:00+00:00",
|
|
143
|
+
"ended_at": "2026-10-24T11:00:01+00:00",
|
|
144
|
+
"latency_ms": 1000,
|
|
145
|
+
"input_tokens": 10,
|
|
146
|
+
"output_tokens": 5
|
|
147
|
+
}
|
|
148
|
+
'@
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
查询:
|
|
152
|
+
|
|
153
|
+
```powershell
|
|
154
|
+
curl -s "http://127.0.0.1:8787/api/runs?project=demo"
|
|
155
|
+
curl -s "http://127.0.0.1:8787/api/runs/summary?project=demo"
|
|
156
|
+
curl -s "http://127.0.0.1:8787/api/failures?project=demo"
|
|
157
|
+
curl -s "http://127.0.0.1:8787/api/runs/run-002"
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## API 一览
|
|
161
|
+
|
|
162
|
+
| 方法 | 路径 | 说明 |
|
|
163
|
+
| :--- | :--- | :--- |
|
|
164
|
+
| `GET` | `/` | 只读调试页 |
|
|
165
|
+
| `POST` | `/api/runs` | 写入 / 终态更新一条 run |
|
|
166
|
+
| `GET` | `/api/runs` | 列表(可按 project/agent/environment/status/时间筛选) |
|
|
167
|
+
| `GET` | `/api/runs/summary` | 次数、成功率、token/费用、p50/p95/p99 |
|
|
168
|
+
| `GET` | `/api/runs/{id}` | 单条详情(含 steps) |
|
|
169
|
+
| `GET` | `/api/failures` | 工具失败归并 |
|
|
170
|
+
|
|
171
|
+
### 必填字段(run)
|
|
172
|
+
|
|
173
|
+
`id`, `project`, `agent`, `environment`, `name`, `status`, `started_at`
|
|
174
|
+
|
|
175
|
+
终态(`ok` / `error` / `timeout` / `cancelled`)还要 `ended_at`。
|
|
176
|
+
`status=running` 时可省略 `ended_at`;同一 `id` 再报终态会覆盖更新。
|
|
177
|
+
|
|
178
|
+
### 常用可选字段
|
|
179
|
+
|
|
180
|
+
| 字段 | 说明 |
|
|
181
|
+
| :--- | :--- |
|
|
182
|
+
| `latency_ms` / `ttft_ms` | 耗时;不传则尽量从时间戳 / steps 推算 |
|
|
183
|
+
| `input_tokens` / `output_tokens` / `cached_tokens` / `reasoning_tokens` | token;未上报保持空,不是 0 |
|
|
184
|
+
| `cost_usd` | 费用;不传则按 `provider`+`model` 估算 |
|
|
185
|
+
| `provider` / `model` | 厂商与模型 |
|
|
186
|
+
| `steps` | 步骤列表(排错用) |
|
|
187
|
+
| `quality_score` | 0–1 |
|
|
188
|
+
| `thumbs` | `up` / `down` |
|
|
189
|
+
| `prompt_version` / `template_version` | 提示词 / 模板版本 |
|
|
190
|
+
| `tags` | JSON,如 `{"end_user_id":"..."}` |
|
|
191
|
+
| `extra` | 备用 JSON |
|
|
192
|
+
|
|
193
|
+
步骤必填:`seq`, `kind`, `name`, `status`。
|
|
194
|
+
|
|
195
|
+
## 可选能力
|
|
196
|
+
|
|
197
|
+
### 脱敏
|
|
198
|
+
|
|
199
|
+
```powershell
|
|
200
|
+
$env:AGENTOPS_REDACT = "hash" # off | hash | drop
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
作用于 `task_input` / `task_output`、步骤 `input` / `output` / `traceback`、`tags.end_user_id`。
|
|
204
|
+
|
|
205
|
+
### 价目表
|
|
206
|
+
|
|
207
|
+
未传 `cost_usd` 时用内置单价估算。自定义 JSON(键为 `provider/model`):
|
|
208
|
+
|
|
209
|
+
```powershell
|
|
210
|
+
$env:AGENTOPS_PRICING_PATH = ".\pricing.json"
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### 告警(适合 cron)
|
|
214
|
+
|
|
215
|
+
```powershell
|
|
216
|
+
$env:AGENTOPS_ALERT_WEBHOOK = "https://example.com/hook"
|
|
217
|
+
$env:AGENTOPS_ALERT_FAILURE_RATE = "0.2"
|
|
218
|
+
$env:AGENTOPS_ALERT_P95_MS = "3000"
|
|
219
|
+
$env:AGENTOPS_ALERT_COST_USD = "10"
|
|
220
|
+
agentops-alerts --db .\data\agentops.db
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
有告警时退出码 `2`。`--dry-run` 只打印不 POST。
|
|
224
|
+
|
|
225
|
+
### 事件 outbox(Kafka 预留)
|
|
226
|
+
|
|
227
|
+
```powershell
|
|
228
|
+
$env:AGENTOPS_EVENT_OUTBOX = ".\data\events.jsonl"
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
每次成功写入追加一行 `agentops.run` v1 事件(`create` / `finalize`)。
|
|
232
|
+
|
|
233
|
+
### API Key(外网绑定前必配)
|
|
234
|
+
|
|
235
|
+
```powershell
|
|
236
|
+
$env:AGENTOPS_API_KEYS = '{"dev-key":{"projects":["demo"]},"admin":{"projects":["*"]}}'
|
|
237
|
+
agentops-http --host 0.0.0.0 --port 8787
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
请求头:`Authorization: Bearer <key>` 或 `X-API-Key: <key>`。
|
|
241
|
+
未配置密钥时:仅建议本机 `127.0.0.1`,接口开放。非 loopback 绑定必须配置密钥。
|
|
242
|
+
|
|
243
|
+
## 限制(请先读)
|
|
244
|
+
|
|
245
|
+
| 限制 | 说明 |
|
|
246
|
+
| :--- | :--- |
|
|
247
|
+
| Alpha | 接口可能继续演进;生产使用请自行评估 |
|
|
248
|
+
| SQLite | 默认单机文件库;多机共享库尚未提供(Postgres 在路线图) |
|
|
249
|
+
| 自报数据 | 延迟 / token / 费用依赖调用方或估算,不是云厂商账单 |
|
|
250
|
+
| 非全链路 APM | 聚焦 agent run + steps,不是通用分布式 tracing |
|
|
251
|
+
| Kafka | 目前只有 JSONL outbox,没有内置 broker |
|
|
252
|
+
| 页面 | 只读调试页;配置了 API Key 后,浏览器调 API 需自行带密钥 |
|
|
253
|
+
| 安装渠道 | 当前以源码 `pip install -e .` 为主;版本见 tag `v0.1.0` / [CHANGELOG](./CHANGELOG.md),暂未上传 PyPI |
|
|
254
|
+
|
|
255
|
+
更细的后续项见 [ROADMAP.md](./ROADMAP.md)。
|
|
256
|
+
|
|
257
|
+
## 开发
|
|
258
|
+
|
|
259
|
+
```powershell
|
|
260
|
+
python -m pip install -e .
|
|
261
|
+
python -m unittest discover -s tests -v
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
库入口常用符号:`connect`, `ingest_run`, `list_runs`, `summarize_runs`, `get_run`, `list_tool_failures`。
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
agent_operations-0.1.0.dist-info/licenses/LICENSE,sha256=pltZSnCe9YoWzZVT7n0XL7fly4FDUgat8puYMKhwjLU,1089
|
|
2
|
+
agentops/__init__.py,sha256=Qwf-7qj4YCELTBcwX3zsBYB_TFEjk-5futtTDt_dMkY,565
|
|
3
|
+
agentops/alerts.py,sha256=FNzwr3EVoGYrpfoseQxDiIfD0EhbD9FnL_76SB6eVqs,5765
|
|
4
|
+
agentops/auth.py,sha256=8_SzAQgA7oSC55LE0HLCJCfqjhsbPwaAXegNDU7fBdM,3739
|
|
5
|
+
agentops/db.py,sha256=GMaSwfQCUp8hCeLTHXeFWdDL0T9hNocxBafiDiqtLrM,4354
|
|
6
|
+
agentops/events.py,sha256=YDRzLCNrCHyRNwCYUvZQtLyXZmQVizMKzgcXE-OFdWM,1844
|
|
7
|
+
agentops/http.py,sha256=oAmBQbIpeG27Nd8ZLbERTQDYTJrDfLWKW5esi2U6sq8,13680
|
|
8
|
+
agentops/ingest.py,sha256=Oa_NoeD75VxNVyNS1f_ySdPltpaOpeZ-6pX-DEB23EM,10326
|
|
9
|
+
agentops/metrics.py,sha256=3xxIXZNpqsqpciIY_AJak6pEkUQTp4_Jw9HqDhJbJGc,3729
|
|
10
|
+
agentops/pricing.py,sha256=RVtEioOeCdfrwM1WfXx0XI30Wl99jmJBGqtn-mxi4Bc,3913
|
|
11
|
+
agentops/query.py,sha256=_AI36H--imEwQQHfLL8-QRVDsum5K27L0CNXSM33zIw,7350
|
|
12
|
+
agentops/redact.py,sha256=OwLPGIv5TM51JP1lLIekiJzPLSqRy--MFnomiI-V-7k,2852
|
|
13
|
+
agentops/trace.py,sha256=TEsux9vTQW6vI2TpZJwTmcOhmVPXx-bwVlSeiIlzNQY,6499
|
|
14
|
+
agent_operations-0.1.0.dist-info/METADATA,sha256=UiuV2XTe90fVAptHZedWNT0qwB_r_BuMj8PArVJdRUM,8116
|
|
15
|
+
agent_operations-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
16
|
+
agent_operations-0.1.0.dist-info/entry_points.txt,sha256=Q31dc6NYv5jw9XA_68trmIIO4Q5OJMoiwgYgs25Vb_s,92
|
|
17
|
+
agent_operations-0.1.0.dist-info/top_level.txt,sha256=iveqXCIg3p0dqEDFVBzPclQhECEFXeEFbALdlVupVVk,9
|
|
18
|
+
agent_operations-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 WenSongWang
|
|
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 @@
|
|
|
1
|
+
agentops
|
agentops/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Lightweight Agent Operations (AgentOps / LLMOps) toolkit."""
|
|
2
|
+
|
|
3
|
+
from agentops.db import connect
|
|
4
|
+
from agentops.ingest import IngestError, ingest_run
|
|
5
|
+
from agentops.query import (
|
|
6
|
+
get_run,
|
|
7
|
+
list_runs,
|
|
8
|
+
list_tool_failures,
|
|
9
|
+
summarize_runs,
|
|
10
|
+
)
|
|
11
|
+
from agentops.trace import TracedRun, usage_from_openai
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"IngestError",
|
|
17
|
+
"TracedRun",
|
|
18
|
+
"connect",
|
|
19
|
+
"get_run",
|
|
20
|
+
"ingest_run",
|
|
21
|
+
"list_runs",
|
|
22
|
+
"list_tool_failures",
|
|
23
|
+
"summarize_runs",
|
|
24
|
+
"usage_from_openai",
|
|
25
|
+
"__version__",
|
|
26
|
+
]
|
agentops/alerts.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Threshold checks and webhook notifications for run health."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sqlite3
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from agentops.db import connect
|
|
15
|
+
from agentops.query import summarize_runs
|
|
16
|
+
|
|
17
|
+
DEFAULT_DB = Path("data") / "agentops.db"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Alert:
|
|
22
|
+
kind: str
|
|
23
|
+
message: str
|
|
24
|
+
value: float
|
|
25
|
+
threshold: float
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Thresholds:
|
|
30
|
+
failure_rate: float | None = 0.2
|
|
31
|
+
p95_ms: float | None = None
|
|
32
|
+
cost_usd: float | None = None
|
|
33
|
+
min_runs: int = 1
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def thresholds_from_env() -> Thresholds:
|
|
37
|
+
def _float(name: str, default: float | None) -> float | None:
|
|
38
|
+
raw = os.environ.get(name)
|
|
39
|
+
if raw is None or raw.strip() == "":
|
|
40
|
+
return default
|
|
41
|
+
return float(raw)
|
|
42
|
+
|
|
43
|
+
return Thresholds(
|
|
44
|
+
failure_rate=_float("AGENTOPS_ALERT_FAILURE_RATE", 0.2),
|
|
45
|
+
p95_ms=_float("AGENTOPS_ALERT_P95_MS", None),
|
|
46
|
+
cost_usd=_float("AGENTOPS_ALERT_COST_USD", None),
|
|
47
|
+
min_runs=int(os.environ.get("AGENTOPS_ALERT_MIN_RUNS", "1")),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def evaluate_alerts(
|
|
52
|
+
summary: dict[str, Any], thresholds: Thresholds | None = None
|
|
53
|
+
) -> list[Alert]:
|
|
54
|
+
"""Return fired alerts for a summarize_runs() result."""
|
|
55
|
+
th = thresholds or thresholds_from_env()
|
|
56
|
+
count = int(summary.get("count") or 0)
|
|
57
|
+
if count < th.min_runs:
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
alerts: list[Alert] = []
|
|
61
|
+
by_status = summary.get("by_status") or {}
|
|
62
|
+
failed = int(by_status.get("error", 0)) + int(by_status.get("timeout", 0))
|
|
63
|
+
failure_rate = failed / count
|
|
64
|
+
|
|
65
|
+
if th.failure_rate is not None and failure_rate >= th.failure_rate:
|
|
66
|
+
alerts.append(
|
|
67
|
+
Alert(
|
|
68
|
+
kind="failure_rate",
|
|
69
|
+
message=(
|
|
70
|
+
f"failure rate {failure_rate:.2%} "
|
|
71
|
+
f">= {th.failure_rate:.2%}"
|
|
72
|
+
),
|
|
73
|
+
value=failure_rate,
|
|
74
|
+
threshold=th.failure_rate,
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
p95 = (summary.get("latency_ms") or {}).get("p95")
|
|
79
|
+
if th.p95_ms is not None and p95 is not None and float(p95) >= th.p95_ms:
|
|
80
|
+
alerts.append(
|
|
81
|
+
Alert(
|
|
82
|
+
kind="p95_ms",
|
|
83
|
+
message=f"latency p95 {p95}ms >= {th.p95_ms}ms",
|
|
84
|
+
value=float(p95),
|
|
85
|
+
threshold=th.p95_ms,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
cost = (summary.get("cost_usd") or {}).get("total")
|
|
90
|
+
if (
|
|
91
|
+
th.cost_usd is not None
|
|
92
|
+
and cost is not None
|
|
93
|
+
and float(cost) >= th.cost_usd
|
|
94
|
+
):
|
|
95
|
+
alerts.append(
|
|
96
|
+
Alert(
|
|
97
|
+
kind="cost_usd",
|
|
98
|
+
message=f"cost ${cost} >= ${th.cost_usd}",
|
|
99
|
+
value=float(cost),
|
|
100
|
+
threshold=th.cost_usd,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
return alerts
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def send_webhook(url: str, payload: dict[str, Any], timeout: float = 5.0) -> None:
|
|
107
|
+
data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
|
|
108
|
+
req = urllib.request.Request(
|
|
109
|
+
url,
|
|
110
|
+
data=data,
|
|
111
|
+
headers={"Content-Type": "application/json"},
|
|
112
|
+
method="POST",
|
|
113
|
+
)
|
|
114
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
115
|
+
resp.read()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def check_alerts(
|
|
119
|
+
conn: sqlite3.Connection,
|
|
120
|
+
*,
|
|
121
|
+
thresholds: Thresholds | None = None,
|
|
122
|
+
webhook: str | None = None,
|
|
123
|
+
notify: bool = True,
|
|
124
|
+
project: str | None = None,
|
|
125
|
+
agent: str | None = None,
|
|
126
|
+
environment: str | None = None,
|
|
127
|
+
started_after: str | None = None,
|
|
128
|
+
started_before: str | None = None,
|
|
129
|
+
) -> list[Alert]:
|
|
130
|
+
"""Evaluate summary filters; optionally POST alerts to webhook."""
|
|
131
|
+
summary = summarize_runs(
|
|
132
|
+
conn,
|
|
133
|
+
project=project,
|
|
134
|
+
agent=agent,
|
|
135
|
+
environment=environment,
|
|
136
|
+
started_after=started_after,
|
|
137
|
+
started_before=started_before,
|
|
138
|
+
)
|
|
139
|
+
alerts = evaluate_alerts(summary, thresholds)
|
|
140
|
+
url = webhook if webhook is not None else os.environ.get("AGENTOPS_ALERT_WEBHOOK")
|
|
141
|
+
if notify and alerts and url:
|
|
142
|
+
send_webhook(
|
|
143
|
+
url,
|
|
144
|
+
{
|
|
145
|
+
"source": "agent-operations",
|
|
146
|
+
"alerts": [asdict(a) for a in alerts],
|
|
147
|
+
"summary": summary,
|
|
148
|
+
"filters": {
|
|
149
|
+
"project": project,
|
|
150
|
+
"agent": agent,
|
|
151
|
+
"environment": environment,
|
|
152
|
+
"started_after": started_after,
|
|
153
|
+
"started_before": started_before,
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
return alerts
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def main() -> None:
|
|
161
|
+
import argparse
|
|
162
|
+
|
|
163
|
+
parser = argparse.ArgumentParser(description="Check agentops alert thresholds")
|
|
164
|
+
parser.add_argument("--db", default=str(DEFAULT_DB))
|
|
165
|
+
parser.add_argument("--webhook", default=None)
|
|
166
|
+
parser.add_argument("--project", default=None)
|
|
167
|
+
parser.add_argument("--agent", default=None)
|
|
168
|
+
parser.add_argument("--environment", default=None)
|
|
169
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
170
|
+
args = parser.parse_args()
|
|
171
|
+
|
|
172
|
+
conn = connect(args.db)
|
|
173
|
+
try:
|
|
174
|
+
alerts = check_alerts(
|
|
175
|
+
conn,
|
|
176
|
+
webhook=args.webhook,
|
|
177
|
+
notify=not args.dry_run,
|
|
178
|
+
project=args.project,
|
|
179
|
+
agent=args.agent,
|
|
180
|
+
environment=args.environment,
|
|
181
|
+
)
|
|
182
|
+
finally:
|
|
183
|
+
conn.close()
|
|
184
|
+
|
|
185
|
+
if not alerts:
|
|
186
|
+
print("ok: no alerts")
|
|
187
|
+
return
|
|
188
|
+
for alert in alerts:
|
|
189
|
+
print(f"ALERT {alert.kind}: {alert.message}")
|
|
190
|
+
raise SystemExit(2)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
if __name__ == "__main__":
|
|
194
|
+
main()
|
agentops/auth.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""API key auth and per-key project ACL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthError(PermissionError):
|
|
13
|
+
"""Missing/invalid credentials or project not allowed."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Principal:
|
|
18
|
+
name: str
|
|
19
|
+
projects: frozenset[str]
|
|
20
|
+
|
|
21
|
+
def allows_project(self, project: str | None) -> bool:
|
|
22
|
+
if "*" in self.projects:
|
|
23
|
+
return True
|
|
24
|
+
if project is None:
|
|
25
|
+
return False
|
|
26
|
+
return project in self.projects
|
|
27
|
+
|
|
28
|
+
def require_project(self, project: str | None) -> None:
|
|
29
|
+
if not self.allows_project(project):
|
|
30
|
+
raise AuthError(f"project not allowed: {project!r}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_api_keys(
|
|
34
|
+
raw: str | None = None, path: str | Path | None = None
|
|
35
|
+
) -> dict[str, Principal]:
|
|
36
|
+
"""Load API keys from JSON object: {\"key\": {\"projects\": [\"demo\"]}}."""
|
|
37
|
+
text = raw
|
|
38
|
+
if text is None:
|
|
39
|
+
text = os.environ.get("AGENTOPS_API_KEYS")
|
|
40
|
+
file_path = path
|
|
41
|
+
if file_path is None:
|
|
42
|
+
env_path = os.environ.get("AGENTOPS_API_KEYS_PATH")
|
|
43
|
+
file_path = env_path
|
|
44
|
+
if (not text) and file_path:
|
|
45
|
+
text = Path(file_path).read_text(encoding="utf-8")
|
|
46
|
+
if not text:
|
|
47
|
+
return {}
|
|
48
|
+
|
|
49
|
+
data = json.loads(text)
|
|
50
|
+
if not isinstance(data, dict):
|
|
51
|
+
raise ValueError("API keys JSON must be an object")
|
|
52
|
+
|
|
53
|
+
keys: dict[str, Principal] = {}
|
|
54
|
+
for key, meta in data.items():
|
|
55
|
+
if not isinstance(key, str) or not key:
|
|
56
|
+
raise ValueError("API key names must be non-empty strings")
|
|
57
|
+
if isinstance(meta, list):
|
|
58
|
+
projects = meta
|
|
59
|
+
name = key[:8]
|
|
60
|
+
elif isinstance(meta, dict):
|
|
61
|
+
projects = meta.get("projects", ["*"])
|
|
62
|
+
name = str(meta.get("name") or key[:8])
|
|
63
|
+
else:
|
|
64
|
+
raise ValueError(f"invalid API key entry for {key!r}")
|
|
65
|
+
if not isinstance(projects, list) or not projects:
|
|
66
|
+
raise ValueError(f"projects required for API key {key!r}")
|
|
67
|
+
keys[key] = Principal(name=name, projects=frozenset(str(p) for p in projects))
|
|
68
|
+
return keys
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def extract_api_key(headers: Any) -> str | None:
|
|
72
|
+
"""Read Bearer token or X-API-Key from headers mapping."""
|
|
73
|
+
auth = headers.get("Authorization") or headers.get("authorization")
|
|
74
|
+
if auth:
|
|
75
|
+
parts = str(auth).split(None, 1)
|
|
76
|
+
if len(parts) == 2 and parts[0].lower() == "bearer":
|
|
77
|
+
return parts[1].strip() or None
|
|
78
|
+
key = headers.get("X-API-Key") or headers.get("x-api-key")
|
|
79
|
+
if key:
|
|
80
|
+
return str(key).strip() or None
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def authenticate(
|
|
85
|
+
headers: Any, keys: dict[str, Principal] | None = None
|
|
86
|
+
) -> Principal | None:
|
|
87
|
+
"""Return Principal when keys are configured; None when auth is disabled."""
|
|
88
|
+
table = keys if keys is not None else load_api_keys()
|
|
89
|
+
if not table:
|
|
90
|
+
return None
|
|
91
|
+
token = extract_api_key(headers)
|
|
92
|
+
if not token:
|
|
93
|
+
raise AuthError("missing API key")
|
|
94
|
+
principal = table.get(token)
|
|
95
|
+
if principal is None:
|
|
96
|
+
raise AuthError("invalid API key")
|
|
97
|
+
return principal
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def scope_project_filter(
|
|
101
|
+
principal: Principal | None, project: str | None
|
|
102
|
+
) -> str | None:
|
|
103
|
+
"""Resolve project filter under ACL. May raise AuthError."""
|
|
104
|
+
if principal is None:
|
|
105
|
+
return project
|
|
106
|
+
if "*" in principal.projects:
|
|
107
|
+
return project
|
|
108
|
+
if project is not None:
|
|
109
|
+
principal.require_project(project)
|
|
110
|
+
return project
|
|
111
|
+
if len(principal.projects) == 1:
|
|
112
|
+
return next(iter(principal.projects))
|
|
113
|
+
raise AuthError("project query parameter required")
|