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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ agentops-alerts = agentops.alerts:main
3
+ agentops-http = agentops.http:main
@@ -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")