chp-adapter-audit 0.8.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.
- chp_adapter_audit-0.8.0/.gitignore +23 -0
- chp_adapter_audit-0.8.0/PKG-INFO +20 -0
- chp_adapter_audit-0.8.0/README.md +0 -0
- chp_adapter_audit-0.8.0/chp_adapter_audit/__init__.py +20 -0
- chp_adapter_audit-0.8.0/chp_adapter_audit/adapter.py +390 -0
- chp_adapter_audit-0.8.0/pyproject.toml +40 -0
- chp_adapter_audit-0.8.0/tests/test_audit_adapter.py +377 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
.yalc/
|
|
4
|
+
yalc.lock
|
|
5
|
+
*.tgz
|
|
6
|
+
.env
|
|
7
|
+
.env.*
|
|
8
|
+
coverage/
|
|
9
|
+
dashboard/
|
|
10
|
+
components/
|
|
11
|
+
.tech-hub-cache/
|
|
12
|
+
__pycache__/
|
|
13
|
+
*.py[cod]
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
.chp/
|
|
16
|
+
.DS_Store
|
|
17
|
+
.coverage
|
|
18
|
+
.forge/
|
|
19
|
+
.hypothesis/
|
|
20
|
+
.claude/
|
|
21
|
+
|
|
22
|
+
# chp-agent evidence store
|
|
23
|
+
.chp-agent/sessions.sqlite
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chp-adapter-audit
|
|
3
|
+
Version: 0.8.0
|
|
4
|
+
Summary: CHP capability adapter — queryable audit log over the host evidence store
|
|
5
|
+
Author: Auxo
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Keywords: adapter,audit,capability-host-protocol,chp,evidence,governance
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: chp-core>=0.7.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""chp-adapter-audit — queryable audit log over the CHP host evidence store.
|
|
2
|
+
|
|
3
|
+
Exposes three capabilities: ``query_invocations``, ``get_invocation``, and
|
|
4
|
+
``stats``. Uses ``on_register(host)`` to capture the host's evidence store.
|
|
5
|
+
An injectable ``store`` on the config allows tests to bypass the host.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
from chp_core import LocalCapabilityHost, register_adapter
|
|
10
|
+
from chp_adapter_audit import AuditAdapter, AuditConfig
|
|
11
|
+
|
|
12
|
+
host = LocalCapabilityHost()
|
|
13
|
+
register_adapter(host, AuditAdapter()) # binds to host.store automatically
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from .adapter import AuditAdapter, AuditConfig
|
|
19
|
+
|
|
20
|
+
__all__ = ["AuditAdapter", "AuditConfig"]
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""AuditAdapter — queryable governance audit log over the CHP evidence store.
|
|
2
|
+
|
|
3
|
+
Three capabilities:
|
|
4
|
+
|
|
5
|
+
* ``query_invocations`` — filter by capability_id, outcome, time window, limit;
|
|
6
|
+
returns per-invocation summaries (never raw event payloads).
|
|
7
|
+
* ``get_invocation`` — fetch all events for one invocation_id; returns only
|
|
8
|
+
metadata (event_type, timestamp, outcome) to avoid leaking sensitive payloads
|
|
9
|
+
that may have been stored by other adapters.
|
|
10
|
+
* ``stats`` — aggregate counts by outcome and by capability over a time window.
|
|
11
|
+
|
|
12
|
+
Evidence hygiene: only counts, IDs, event_types, and timestamps are stored or
|
|
13
|
+
returned. The stored event payloads (which may carry PII, tokens, or secrets
|
|
14
|
+
from other adapters) are NEVER included in audit evidence or return values.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections import defaultdict
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from chp_core import BaseAdapter, capability
|
|
24
|
+
|
|
25
|
+
_EMITS = ["audit_query", "audit_result", "audit_error"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class AuditConfig:
|
|
30
|
+
"""Config for AuditAdapter.
|
|
31
|
+
|
|
32
|
+
``max_results`` caps the maximum rows returned by any query.
|
|
33
|
+
``store`` is injectable for tests (bypasses host.store binding).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
max_results: int = 1000
|
|
37
|
+
store: Any = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuditAdapter(BaseAdapter):
|
|
41
|
+
"""Static adapter exposing the host's evidence store as governed capabilities."""
|
|
42
|
+
|
|
43
|
+
adapter_id = "chp.adapters.audit"
|
|
44
|
+
adapter_name = "Audit Log"
|
|
45
|
+
adapter_description = "Query the CHP evidence store with filters and aggregate stats."
|
|
46
|
+
adapter_category = "governance"
|
|
47
|
+
adapter_tags = ["audit", "governance", "evidence", "meta"]
|
|
48
|
+
|
|
49
|
+
def __init__(self, config: AuditConfig | None = None) -> None:
|
|
50
|
+
self._config = config or AuditConfig()
|
|
51
|
+
self._store: Any = self._config.store # None until on_register if not injected
|
|
52
|
+
|
|
53
|
+
def on_register(self, host: Any) -> None:
|
|
54
|
+
if self._store is None:
|
|
55
|
+
self._store = host.store
|
|
56
|
+
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
# query_invocations
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
@capability(
|
|
62
|
+
id="chp.adapters.audit.query_invocations",
|
|
63
|
+
version="1.0.0",
|
|
64
|
+
description="Query invocation records from the audit log with optional filters.",
|
|
65
|
+
category="governance",
|
|
66
|
+
risk="low",
|
|
67
|
+
input_schema={
|
|
68
|
+
"type": "object",
|
|
69
|
+
"properties": {
|
|
70
|
+
"capability_id": {"type": "string", "description": "Filter by exact capability ID."},
|
|
71
|
+
"outcome": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"enum": ["success", "failure", "denied", "skipped"],
|
|
74
|
+
"description": "Filter by invocation outcome.",
|
|
75
|
+
},
|
|
76
|
+
"since": {"type": "string", "description": "ISO-8601 lower bound (inclusive)."},
|
|
77
|
+
"until": {"type": "string", "description": "ISO-8601 upper bound (inclusive)."},
|
|
78
|
+
"limit": {"type": "integer", "minimum": 1, "default": 100},
|
|
79
|
+
},
|
|
80
|
+
"additionalProperties": False,
|
|
81
|
+
},
|
|
82
|
+
emits=_EMITS,
|
|
83
|
+
tags=["audit", "governance"],
|
|
84
|
+
)
|
|
85
|
+
async def query_invocations(self, ctx: Any, payload: dict) -> dict:
|
|
86
|
+
if self._store is None:
|
|
87
|
+
ctx.emit("audit_error", {"reason": "store_not_bound"}, redacted=False)
|
|
88
|
+
raise RuntimeError("AuditAdapter: store not bound — register with a host first")
|
|
89
|
+
|
|
90
|
+
limit = min(payload.get("limit") or 100, self._config.max_results)
|
|
91
|
+
cap_id = payload.get("capability_id")
|
|
92
|
+
outcome = payload.get("outcome")
|
|
93
|
+
since = payload.get("since")
|
|
94
|
+
until = payload.get("until")
|
|
95
|
+
|
|
96
|
+
ctx.emit("audit_query", {
|
|
97
|
+
"op": "query_invocations",
|
|
98
|
+
"filters": {k: v for k, v in {
|
|
99
|
+
"capability_id": cap_id, "outcome": outcome, "since": since, "until": until,
|
|
100
|
+
}.items() if v is not None},
|
|
101
|
+
"limit": limit,
|
|
102
|
+
}, redacted=False)
|
|
103
|
+
|
|
104
|
+
events = self._store.query(
|
|
105
|
+
capability_id=cap_id,
|
|
106
|
+
outcome=outcome,
|
|
107
|
+
since=since,
|
|
108
|
+
until=until,
|
|
109
|
+
limit=limit * 10, # over-fetch to group by invocation
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Exclude the current audit invocation so it doesn't appear in results
|
|
113
|
+
current_inv_id = ctx.envelope.invocation_id
|
|
114
|
+
events = [e for e in events if e.get("invocation_id") != current_inv_id]
|
|
115
|
+
|
|
116
|
+
invocations = _group_by_invocation(events, limit)
|
|
117
|
+
|
|
118
|
+
ctx.emit("audit_result", {
|
|
119
|
+
"op": "query_invocations",
|
|
120
|
+
"total": len(invocations),
|
|
121
|
+
}, redacted=False)
|
|
122
|
+
|
|
123
|
+
return {"invocations": invocations, "total": len(invocations)}
|
|
124
|
+
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
# get_invocation
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
@capability(
|
|
130
|
+
id="chp.adapters.audit.get_invocation",
|
|
131
|
+
version="1.0.0",
|
|
132
|
+
description="Fetch event metadata for a specific invocation ID.",
|
|
133
|
+
category="governance",
|
|
134
|
+
risk="low",
|
|
135
|
+
input_schema={
|
|
136
|
+
"type": "object",
|
|
137
|
+
"properties": {
|
|
138
|
+
"invocation_id": {"type": "string"},
|
|
139
|
+
},
|
|
140
|
+
"required": ["invocation_id"],
|
|
141
|
+
"additionalProperties": False,
|
|
142
|
+
},
|
|
143
|
+
emits=_EMITS,
|
|
144
|
+
tags=["audit", "governance"],
|
|
145
|
+
)
|
|
146
|
+
async def get_invocation(self, ctx: Any, payload: dict) -> dict:
|
|
147
|
+
if self._store is None:
|
|
148
|
+
ctx.emit("audit_error", {"reason": "store_not_bound"}, redacted=False)
|
|
149
|
+
raise RuntimeError("AuditAdapter: store not bound")
|
|
150
|
+
|
|
151
|
+
invocation_id = payload["invocation_id"]
|
|
152
|
+
|
|
153
|
+
ctx.emit("audit_query", {
|
|
154
|
+
"op": "get_invocation",
|
|
155
|
+
"invocation_id": invocation_id,
|
|
156
|
+
}, redacted=False)
|
|
157
|
+
|
|
158
|
+
events = self._store.by_invocation(invocation_id)
|
|
159
|
+
|
|
160
|
+
if not events:
|
|
161
|
+
ctx.emit("audit_error", {
|
|
162
|
+
"reason": "not_found", "invocation_id": invocation_id,
|
|
163
|
+
}, redacted=False)
|
|
164
|
+
raise ValueError(f"Invocation not found: {invocation_id!r}")
|
|
165
|
+
|
|
166
|
+
# Strip payloads — only metadata (event_type, timestamp, outcome) returned
|
|
167
|
+
stripped = [
|
|
168
|
+
{
|
|
169
|
+
"event_id": e.get("event_id"),
|
|
170
|
+
"event_type": e.get("event_type"),
|
|
171
|
+
"timestamp": e.get("timestamp"),
|
|
172
|
+
"outcome": e.get("outcome"),
|
|
173
|
+
"sequence": e.get("sequence"),
|
|
174
|
+
}
|
|
175
|
+
for e in events
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
ctx.emit("audit_result", {
|
|
179
|
+
"op": "get_invocation",
|
|
180
|
+
"invocation_id": invocation_id,
|
|
181
|
+
"event_count": len(stripped),
|
|
182
|
+
}, redacted=False)
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
"invocation_id": invocation_id,
|
|
186
|
+
"events": stripped,
|
|
187
|
+
"event_count": len(stripped),
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
# ------------------------------------------------------------------
|
|
191
|
+
# token_report
|
|
192
|
+
# ------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
@capability(
|
|
195
|
+
id="chp.adapters.audit.token_report",
|
|
196
|
+
version="1.0.0",
|
|
197
|
+
description=(
|
|
198
|
+
"Aggregate sovereign inference token usage by model. Returns per-model "
|
|
199
|
+
"token totals, call counts, backfill summary (calls before token tracking "
|
|
200
|
+
"was added on 2026-06-17), and estimated frontier-equivalent cost."
|
|
201
|
+
),
|
|
202
|
+
category="observability",
|
|
203
|
+
risk="low",
|
|
204
|
+
input_schema={
|
|
205
|
+
"type": "object",
|
|
206
|
+
"properties": {
|
|
207
|
+
"since": {"type": "string", "description": "ISO-8601 lower bound."},
|
|
208
|
+
"until": {"type": "string", "description": "ISO-8601 upper bound."},
|
|
209
|
+
"frontier_price_per_1m_input": {
|
|
210
|
+
"type": "number",
|
|
211
|
+
"description": "Frontier input token price per 1M tokens (default 3.0, Claude Sonnet rate).",
|
|
212
|
+
},
|
|
213
|
+
"frontier_price_per_1m_output": {
|
|
214
|
+
"type": "number",
|
|
215
|
+
"description": "Frontier output token price per 1M tokens (default 15.0, Claude Sonnet rate).",
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
"additionalProperties": False,
|
|
219
|
+
},
|
|
220
|
+
emits=_EMITS,
|
|
221
|
+
tags=["audit", "governance", "tokens", "observability"],
|
|
222
|
+
)
|
|
223
|
+
async def token_report(self, ctx: Any, payload: dict) -> dict:
|
|
224
|
+
if self._store is None:
|
|
225
|
+
ctx.emit("audit_error", {"reason": "store_not_bound"}, redacted=False)
|
|
226
|
+
raise RuntimeError("AuditAdapter: store not bound")
|
|
227
|
+
|
|
228
|
+
since = payload.get("since")
|
|
229
|
+
until = payload.get("until")
|
|
230
|
+
input_price = float(payload.get("frontier_price_per_1m_input") or 3.0)
|
|
231
|
+
output_price = float(payload.get("frontier_price_per_1m_output") or 15.0)
|
|
232
|
+
|
|
233
|
+
ctx.emit("audit_query", {"op": "token_report", "since": since, "until": until}, redacted=False)
|
|
234
|
+
|
|
235
|
+
events = self._store.query(
|
|
236
|
+
capability_id="chp.adapters.http.request",
|
|
237
|
+
since=since,
|
|
238
|
+
until=until,
|
|
239
|
+
)
|
|
240
|
+
http_responses = [e for e in events if e.get("event_type") == "http_response"]
|
|
241
|
+
|
|
242
|
+
with_tokens = [e for e in http_responses if "prompt_tokens" in e.get("payload", {})]
|
|
243
|
+
without_tokens = [e for e in http_responses if "prompt_tokens" not in e.get("payload", {})]
|
|
244
|
+
|
|
245
|
+
by_model: dict[str, dict] = {}
|
|
246
|
+
for e in with_tokens:
|
|
247
|
+
p = e["payload"]
|
|
248
|
+
m = p.get("model", "unknown")
|
|
249
|
+
rec = by_model.setdefault(m, {"model": m, "prompt_tokens": 0, "completion_tokens": 0, "calls": 0})
|
|
250
|
+
rec["prompt_tokens"] += p.get("prompt_tokens", 0)
|
|
251
|
+
rec["completion_tokens"] += p.get("completion_tokens", 0)
|
|
252
|
+
rec["calls"] += 1
|
|
253
|
+
|
|
254
|
+
total_prompt = sum(r["prompt_tokens"] for r in by_model.values())
|
|
255
|
+
total_completion = sum(r["completion_tokens"] for r in by_model.values())
|
|
256
|
+
frontier_cost = (
|
|
257
|
+
total_prompt / 1_000_000 * input_price
|
|
258
|
+
+ total_completion / 1_000_000 * output_price
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
earliest_tracked = min(
|
|
262
|
+
(e["timestamp"] for e in with_tokens if e.get("timestamp")), default=None
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
result = {
|
|
266
|
+
"window": {"since": since, "until": until},
|
|
267
|
+
"sovereign": {
|
|
268
|
+
"total_prompt_tokens": total_prompt,
|
|
269
|
+
"total_completion_tokens": total_completion,
|
|
270
|
+
"total_tokens": total_prompt + total_completion,
|
|
271
|
+
"by_model": sorted(by_model.values(), key=lambda r: -r["calls"]),
|
|
272
|
+
},
|
|
273
|
+
"backfill": {
|
|
274
|
+
"calls_without_token_data": len(without_tokens),
|
|
275
|
+
"note": "Calls before token tracking (2026-06-17). Counts only, no token data.",
|
|
276
|
+
"earliest_tracked": earliest_tracked,
|
|
277
|
+
},
|
|
278
|
+
"estimated_frontier_cost_usd": round(frontier_cost, 4),
|
|
279
|
+
"pricing_basis": f"${input_price}/1M input, ${output_price}/1M output (Claude Sonnet rates)",
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
ctx.emit("audit_result", {
|
|
283
|
+
"op": "token_report",
|
|
284
|
+
"total_tokens": total_prompt + total_completion,
|
|
285
|
+
"models": list(by_model.keys()),
|
|
286
|
+
}, redacted=False)
|
|
287
|
+
return result
|
|
288
|
+
|
|
289
|
+
# ------------------------------------------------------------------
|
|
290
|
+
# stats
|
|
291
|
+
# ------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
@capability(
|
|
294
|
+
id="chp.adapters.audit.stats",
|
|
295
|
+
version="1.0.0",
|
|
296
|
+
description="Aggregate invocation counts by outcome and capability over a time window.",
|
|
297
|
+
category="governance",
|
|
298
|
+
risk="low",
|
|
299
|
+
input_schema={
|
|
300
|
+
"type": "object",
|
|
301
|
+
"properties": {
|
|
302
|
+
"since": {"type": "string", "description": "ISO-8601 lower bound."},
|
|
303
|
+
"until": {"type": "string", "description": "ISO-8601 upper bound."},
|
|
304
|
+
},
|
|
305
|
+
"additionalProperties": False,
|
|
306
|
+
},
|
|
307
|
+
emits=_EMITS,
|
|
308
|
+
tags=["audit", "governance"],
|
|
309
|
+
)
|
|
310
|
+
async def stats(self, ctx: Any, payload: dict) -> dict:
|
|
311
|
+
if self._store is None:
|
|
312
|
+
ctx.emit("audit_error", {"reason": "store_not_bound"}, redacted=False)
|
|
313
|
+
raise RuntimeError("AuditAdapter: store not bound")
|
|
314
|
+
|
|
315
|
+
since = payload.get("since")
|
|
316
|
+
until = payload.get("until")
|
|
317
|
+
|
|
318
|
+
ctx.emit("audit_query", {
|
|
319
|
+
"op": "stats",
|
|
320
|
+
"since": since,
|
|
321
|
+
"until": until,
|
|
322
|
+
}, redacted=False)
|
|
323
|
+
|
|
324
|
+
events = self._store.query(since=since, until=until)
|
|
325
|
+
|
|
326
|
+
# Exclude the current audit invocation
|
|
327
|
+
current_inv_id = ctx.envelope.invocation_id
|
|
328
|
+
events = [e for e in events if e.get("invocation_id") != current_inv_id]
|
|
329
|
+
|
|
330
|
+
# Count only terminal lifecycle events (execution_started = one per invocation)
|
|
331
|
+
started = [e for e in events if e.get("event_type") == "execution_started"]
|
|
332
|
+
total = len(started)
|
|
333
|
+
|
|
334
|
+
by_outcome: dict[str, int] = defaultdict(int)
|
|
335
|
+
by_cap: dict[str, int] = defaultdict(int)
|
|
336
|
+
for e in started:
|
|
337
|
+
out = e.get("outcome") or "unknown"
|
|
338
|
+
by_outcome[out] += 1
|
|
339
|
+
cap = e.get("capability_id") or "unknown"
|
|
340
|
+
by_cap[cap] += 1
|
|
341
|
+
|
|
342
|
+
error_rate = (by_outcome.get("failure", 0) / total) if total > 0 else 0.0
|
|
343
|
+
by_capability = sorted(
|
|
344
|
+
[{"id": k, "count": v} for k, v in by_cap.items()],
|
|
345
|
+
key=lambda x: x["count"],
|
|
346
|
+
reverse=True,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
ctx.emit("audit_result", {
|
|
350
|
+
"op": "stats",
|
|
351
|
+
"total_invocations": total,
|
|
352
|
+
}, redacted=False)
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
"total_invocations": total,
|
|
356
|
+
"by_outcome": dict(by_outcome),
|
|
357
|
+
"by_capability": by_capability,
|
|
358
|
+
"error_rate": round(error_rate, 4),
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# --------------------------------------------------------------------------
|
|
363
|
+
# Helpers
|
|
364
|
+
# --------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
def _group_by_invocation(events: list[dict], limit: int) -> list[dict]:
|
|
367
|
+
"""Group a flat event list into per-invocation summaries, capped at ``limit``."""
|
|
368
|
+
seen: dict[str, dict] = {}
|
|
369
|
+
for e in events:
|
|
370
|
+
inv_id = e.get("invocation_id")
|
|
371
|
+
if inv_id is None:
|
|
372
|
+
continue
|
|
373
|
+
if inv_id not in seen:
|
|
374
|
+
if len(seen) >= limit:
|
|
375
|
+
continue
|
|
376
|
+
seen[inv_id] = {
|
|
377
|
+
"invocation_id": inv_id,
|
|
378
|
+
"capability_id": e.get("capability_id"),
|
|
379
|
+
"started_at": e.get("timestamp"),
|
|
380
|
+
"outcome": None,
|
|
381
|
+
"event_count": 0,
|
|
382
|
+
}
|
|
383
|
+
seen[inv_id]["event_count"] += 1
|
|
384
|
+
# Capture outcome from terminal lifecycle event
|
|
385
|
+
if e.get("event_type") in ("execution_completed", "execution_failed", "execution_denied"):
|
|
386
|
+
seen[inv_id]["outcome"] = e.get("outcome")
|
|
387
|
+
if e.get("timestamp"):
|
|
388
|
+
seen[inv_id]["completed_at"] = e.get("timestamp")
|
|
389
|
+
|
|
390
|
+
return list(seen.values())
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "chp-adapter-audit"
|
|
7
|
+
version = "0.8.0"
|
|
8
|
+
description = "CHP capability adapter — queryable audit log over the host evidence store"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "Auxo" }]
|
|
13
|
+
keywords = ["chp", "capability-host-protocol", "audit", "evidence", "governance", "adapter"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: Apache Software License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"chp-core>=0.7.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.entry-points."chp.adapters"]
|
|
30
|
+
audit = "chp_adapter_audit:AuditAdapter"
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = ["pytest>=8.0"]
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
testpaths = ["tests"]
|
|
37
|
+
pythonpath = ["."]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["chp_adapter_audit"]
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Tests for chp_adapter_audit.adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from chp_core import LocalCapabilityHost, capability, register_adapter
|
|
8
|
+
from chp_core.store import SQLiteEvidenceStore
|
|
9
|
+
|
|
10
|
+
from chp_adapter_audit import AuditAdapter, AuditConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# --------------------------------------------------------------------------
|
|
14
|
+
# Minimal echo adapter to generate invocation evidence
|
|
15
|
+
# --------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
class _EchoAdapter:
|
|
18
|
+
adapter_id = "chp.adapters.echo"
|
|
19
|
+
|
|
20
|
+
@capability(
|
|
21
|
+
id="chp.adapters.echo.ping",
|
|
22
|
+
version="1.0.0",
|
|
23
|
+
description="Echo.",
|
|
24
|
+
risk="low",
|
|
25
|
+
input_schema={
|
|
26
|
+
"type": "object",
|
|
27
|
+
"properties": {"msg": {"type": "string"}},
|
|
28
|
+
"additionalProperties": False,
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
async def ping(self, ctx, payload):
|
|
32
|
+
return {"pong": payload.get("msg", "")}
|
|
33
|
+
|
|
34
|
+
@capability(
|
|
35
|
+
id="chp.adapters.echo.fail",
|
|
36
|
+
version="1.0.0",
|
|
37
|
+
description="Always fails.",
|
|
38
|
+
risk="low",
|
|
39
|
+
input_schema={"type": "object", "properties": {}, "additionalProperties": False},
|
|
40
|
+
)
|
|
41
|
+
async def fail(self, ctx, payload):
|
|
42
|
+
raise RuntimeError("forced failure")
|
|
43
|
+
|
|
44
|
+
def capabilities(self):
|
|
45
|
+
from chp_core.adapters import HostedCapability
|
|
46
|
+
from chp_core.decorators import adapt_callable, get_capability_descriptor
|
|
47
|
+
import inspect
|
|
48
|
+
for _, method in inspect.getmembers(self, predicate=inspect.ismethod):
|
|
49
|
+
desc = get_capability_descriptor(method.__func__)
|
|
50
|
+
if desc is not None:
|
|
51
|
+
yield HostedCapability(descriptor=desc, handler=adapt_callable(method))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _make_host():
|
|
55
|
+
host = LocalCapabilityHost(store=SQLiteEvidenceStore(":memory:"))
|
|
56
|
+
register_adapter(host, _EchoAdapter())
|
|
57
|
+
register_adapter(host, AuditAdapter())
|
|
58
|
+
return host
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _cap_events(store):
|
|
62
|
+
return [e for e in store.all() if "capability_uri" not in e["payload"]]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --------------------------------------------------------------------------
|
|
66
|
+
# 1. Shaping
|
|
67
|
+
# --------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
class TestShaping:
|
|
70
|
+
def test_capability_ids(self):
|
|
71
|
+
host = _make_host()
|
|
72
|
+
ids = {c.descriptor.id for c in AuditAdapter().capabilities()}
|
|
73
|
+
assert ids == {
|
|
74
|
+
"chp.adapters.audit.query_invocations",
|
|
75
|
+
"chp.adapters.audit.get_invocation",
|
|
76
|
+
"chp.adapters.audit.stats",
|
|
77
|
+
"chp.adapters.audit.token_report",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
def test_all_risk_low(self):
|
|
81
|
+
for cap in AuditAdapter().capabilities():
|
|
82
|
+
assert cap.descriptor.risk == "low"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --------------------------------------------------------------------------
|
|
86
|
+
# 2. query_invocations
|
|
87
|
+
# --------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
class TestQueryInvocations:
|
|
90
|
+
def test_empty_store(self):
|
|
91
|
+
host = _make_host()
|
|
92
|
+
r = host.invoke("chp.adapters.audit.query_invocations", {})
|
|
93
|
+
assert r.outcome == "success"
|
|
94
|
+
assert r.data["total"] == 0
|
|
95
|
+
assert r.data["invocations"] == []
|
|
96
|
+
|
|
97
|
+
def test_returns_after_invocations(self):
|
|
98
|
+
host = _make_host()
|
|
99
|
+
host.invoke("chp.adapters.echo.ping", {"msg": "a"})
|
|
100
|
+
host.invoke("chp.adapters.echo.ping", {"msg": "b"})
|
|
101
|
+
r = host.invoke("chp.adapters.audit.query_invocations", {})
|
|
102
|
+
assert r.data["total"] == 2
|
|
103
|
+
|
|
104
|
+
def test_filter_by_capability_id(self):
|
|
105
|
+
host = _make_host()
|
|
106
|
+
host.invoke("chp.adapters.echo.ping", {"msg": "a"})
|
|
107
|
+
host.invoke("chp.adapters.echo.fail", {})
|
|
108
|
+
r = host.invoke("chp.adapters.audit.query_invocations",
|
|
109
|
+
{"capability_id": "chp.adapters.echo.ping"})
|
|
110
|
+
assert r.data["total"] == 1
|
|
111
|
+
assert r.data["invocations"][0]["capability_id"] == "chp.adapters.echo.ping"
|
|
112
|
+
|
|
113
|
+
def test_filter_by_outcome(self):
|
|
114
|
+
host = _make_host()
|
|
115
|
+
host.invoke("chp.adapters.echo.ping", {"msg": "a"})
|
|
116
|
+
host.invoke("chp.adapters.echo.fail", {})
|
|
117
|
+
r = host.invoke("chp.adapters.audit.query_invocations", {"outcome": "success"})
|
|
118
|
+
# Only ping should appear
|
|
119
|
+
assert all(inv["outcome"] in ("success", None) for inv in r.data["invocations"])
|
|
120
|
+
|
|
121
|
+
def test_invocation_summary_keys(self):
|
|
122
|
+
host = _make_host()
|
|
123
|
+
host.invoke("chp.adapters.echo.ping", {"msg": "a"})
|
|
124
|
+
r = host.invoke("chp.adapters.audit.query_invocations", {})
|
|
125
|
+
inv = r.data["invocations"][0]
|
|
126
|
+
assert "invocation_id" in inv
|
|
127
|
+
assert "capability_id" in inv
|
|
128
|
+
assert "event_count" in inv
|
|
129
|
+
|
|
130
|
+
def test_payloads_never_in_invocation_summary(self):
|
|
131
|
+
host = _make_host()
|
|
132
|
+
host.invoke("chp.adapters.echo.ping", {"msg": "super_secret_value"})
|
|
133
|
+
r = host.invoke("chp.adapters.audit.query_invocations", {})
|
|
134
|
+
dump = str(r.data["invocations"])
|
|
135
|
+
assert "super_secret_value" not in dump
|
|
136
|
+
|
|
137
|
+
def test_extra_field_denied(self):
|
|
138
|
+
host = _make_host()
|
|
139
|
+
r = host.invoke("chp.adapters.audit.query_invocations", {"injected": "bad"})
|
|
140
|
+
assert r.outcome == "denied"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# --------------------------------------------------------------------------
|
|
144
|
+
# 3. get_invocation
|
|
145
|
+
# --------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
class TestGetInvocation:
|
|
148
|
+
def test_returns_event_metadata(self):
|
|
149
|
+
host = _make_host()
|
|
150
|
+
result = host.invoke("chp.adapters.echo.ping", {"msg": "test"})
|
|
151
|
+
inv_id = result.invocation_id
|
|
152
|
+
|
|
153
|
+
r = host.invoke("chp.adapters.audit.get_invocation", {"invocation_id": inv_id})
|
|
154
|
+
assert r.outcome == "success"
|
|
155
|
+
assert r.data["invocation_id"] == inv_id
|
|
156
|
+
assert r.data["event_count"] > 0
|
|
157
|
+
|
|
158
|
+
def test_events_have_type_and_timestamp(self):
|
|
159
|
+
host = _make_host()
|
|
160
|
+
result = host.invoke("chp.adapters.echo.ping", {"msg": "test"})
|
|
161
|
+
r = host.invoke("chp.adapters.audit.get_invocation",
|
|
162
|
+
{"invocation_id": result.invocation_id})
|
|
163
|
+
evt = r.data["events"][0]
|
|
164
|
+
assert "event_type" in evt
|
|
165
|
+
assert "timestamp" in evt
|
|
166
|
+
|
|
167
|
+
def test_payloads_stripped_from_events(self):
|
|
168
|
+
host = _make_host()
|
|
169
|
+
result = host.invoke("chp.adapters.echo.ping", {"msg": "another_secret"})
|
|
170
|
+
r = host.invoke("chp.adapters.audit.get_invocation",
|
|
171
|
+
{"invocation_id": result.invocation_id})
|
|
172
|
+
dump = str(r.data["events"])
|
|
173
|
+
assert "another_secret" not in dump
|
|
174
|
+
assert "payload" not in dump
|
|
175
|
+
|
|
176
|
+
def test_not_found_fails(self):
|
|
177
|
+
host = _make_host()
|
|
178
|
+
r = host.invoke("chp.adapters.audit.get_invocation",
|
|
179
|
+
{"invocation_id": "nonexistent-id"})
|
|
180
|
+
assert r.outcome == "failure"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# --------------------------------------------------------------------------
|
|
184
|
+
# 4. stats
|
|
185
|
+
# --------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
class TestStats:
|
|
188
|
+
def test_empty_store_returns_zeros(self):
|
|
189
|
+
host = _make_host()
|
|
190
|
+
r = host.invoke("chp.adapters.audit.stats", {})
|
|
191
|
+
assert r.outcome == "success"
|
|
192
|
+
assert r.data["total_invocations"] == 0
|
|
193
|
+
assert r.data["error_rate"] == 0.0
|
|
194
|
+
|
|
195
|
+
def test_counts_invocations(self):
|
|
196
|
+
host = _make_host()
|
|
197
|
+
host.invoke("chp.adapters.echo.ping", {"msg": "a"})
|
|
198
|
+
host.invoke("chp.adapters.echo.ping", {"msg": "b"})
|
|
199
|
+
host.invoke("chp.adapters.echo.fail", {})
|
|
200
|
+
r = host.invoke("chp.adapters.audit.stats", {})
|
|
201
|
+
assert r.data["total_invocations"] >= 3
|
|
202
|
+
|
|
203
|
+
def test_by_outcome_present(self):
|
|
204
|
+
host = _make_host()
|
|
205
|
+
host.invoke("chp.adapters.echo.ping", {"msg": "a"})
|
|
206
|
+
r = host.invoke("chp.adapters.audit.stats", {})
|
|
207
|
+
assert "by_outcome" in r.data
|
|
208
|
+
|
|
209
|
+
def test_by_capability_sorted_desc(self):
|
|
210
|
+
host = _make_host()
|
|
211
|
+
host.invoke("chp.adapters.echo.ping", {"msg": "a"})
|
|
212
|
+
host.invoke("chp.adapters.echo.ping", {"msg": "b"})
|
|
213
|
+
host.invoke("chp.adapters.echo.fail", {})
|
|
214
|
+
r = host.invoke("chp.adapters.audit.stats", {})
|
|
215
|
+
by_cap = r.data["by_capability"]
|
|
216
|
+
if len(by_cap) > 1:
|
|
217
|
+
assert by_cap[0]["count"] >= by_cap[1]["count"]
|
|
218
|
+
|
|
219
|
+
def test_error_rate_computed(self):
|
|
220
|
+
host = _make_host()
|
|
221
|
+
host.invoke("chp.adapters.echo.ping", {"msg": "ok"})
|
|
222
|
+
host.invoke("chp.adapters.echo.fail", {})
|
|
223
|
+
r = host.invoke("chp.adapters.audit.stats", {})
|
|
224
|
+
assert 0.0 <= r.data["error_rate"] <= 1.0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# --------------------------------------------------------------------------
|
|
228
|
+
# 5. Evidence from audit adapter itself
|
|
229
|
+
# --------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
class TestAuditEvidence:
|
|
232
|
+
def _audit_events(self, store):
|
|
233
|
+
return [
|
|
234
|
+
e for e in store.all()
|
|
235
|
+
if e.get("capability_id", "").startswith("chp.adapters.audit")
|
|
236
|
+
and e.get("event_type") not in ("execution_started", "execution_completed", "execution_failed")
|
|
237
|
+
]
|
|
238
|
+
|
|
239
|
+
def test_query_emits_audit_events(self):
|
|
240
|
+
host = _make_host()
|
|
241
|
+
host.invoke("chp.adapters.audit.query_invocations", {})
|
|
242
|
+
types = [e["event_type"] for e in self._audit_events(host.store)]
|
|
243
|
+
assert "audit_query" in types
|
|
244
|
+
assert "audit_result" in types
|
|
245
|
+
|
|
246
|
+
def test_stats_emits_audit_events(self):
|
|
247
|
+
host = _make_host()
|
|
248
|
+
host.invoke("chp.adapters.audit.stats", {})
|
|
249
|
+
types = [e["event_type"] for e in self._audit_events(host.store)]
|
|
250
|
+
assert "audit_query" in types
|
|
251
|
+
assert "audit_result" in types
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# --------------------------------------------------------------------------
|
|
255
|
+
# 6. Injectable store
|
|
256
|
+
# --------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
class TestInjectableStore:
|
|
259
|
+
def test_injectable_store_works(self):
|
|
260
|
+
store = SQLiteEvidenceStore(":memory:")
|
|
261
|
+
# Pre-populate store by creating a host and making calls
|
|
262
|
+
host = LocalCapabilityHost(store=store)
|
|
263
|
+
register_adapter(host, _EchoAdapter())
|
|
264
|
+
host.invoke("chp.adapters.echo.ping", {"msg": "hello"})
|
|
265
|
+
|
|
266
|
+
# Now create audit adapter with injected store (no host registration needed)
|
|
267
|
+
audit = AuditAdapter(AuditConfig(store=store))
|
|
268
|
+
audit_host = LocalCapabilityHost(store=SQLiteEvidenceStore(":memory:"))
|
|
269
|
+
register_adapter(audit_host, audit)
|
|
270
|
+
|
|
271
|
+
r = audit_host.invoke("chp.adapters.audit.query_invocations", {})
|
|
272
|
+
assert r.outcome == "success"
|
|
273
|
+
assert r.data["total"] >= 1
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# --------------------------------------------------------------------------
|
|
277
|
+
# 7. token_report
|
|
278
|
+
# --------------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
def _seed_http_response_events(store, entries):
|
|
281
|
+
"""Seed the store with synthetic http_response events for token_report tests."""
|
|
282
|
+
from chp_core.types import ExecutionEvidence, CorrelationContext, new_id
|
|
283
|
+
|
|
284
|
+
for entry in entries:
|
|
285
|
+
payload = {
|
|
286
|
+
"method": "POST",
|
|
287
|
+
"url": "http://localhost:8092/v1/chat/completions",
|
|
288
|
+
"status_code": 200,
|
|
289
|
+
"content_type": "application/json",
|
|
290
|
+
"body_length": 100,
|
|
291
|
+
"truncated": False,
|
|
292
|
+
"duration_ms": 250,
|
|
293
|
+
}
|
|
294
|
+
if "model" in entry:
|
|
295
|
+
payload.update({
|
|
296
|
+
"prompt_tokens": entry["prompt"],
|
|
297
|
+
"completion_tokens": entry["completion"],
|
|
298
|
+
"total_tokens": entry["prompt"] + entry["completion"],
|
|
299
|
+
"model": entry["model"],
|
|
300
|
+
})
|
|
301
|
+
store.append(ExecutionEvidence(
|
|
302
|
+
event_id=new_id("evt"),
|
|
303
|
+
event_type="http_response",
|
|
304
|
+
invocation_id=new_id("inv"),
|
|
305
|
+
capability_id="chp.adapters.http.request",
|
|
306
|
+
capability_version="1.0.0",
|
|
307
|
+
host_id="test-host",
|
|
308
|
+
correlation=CorrelationContext(),
|
|
309
|
+
outcome="success",
|
|
310
|
+
payload=payload,
|
|
311
|
+
redacted=False,
|
|
312
|
+
))
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class TestTokenReport:
|
|
316
|
+
def _make_audit_with_store(self):
|
|
317
|
+
store = SQLiteEvidenceStore(":memory:")
|
|
318
|
+
audit = AuditAdapter(AuditConfig(store=store))
|
|
319
|
+
audit_host = LocalCapabilityHost(store=SQLiteEvidenceStore(":memory:"))
|
|
320
|
+
register_adapter(audit_host, audit)
|
|
321
|
+
return audit_host, store
|
|
322
|
+
|
|
323
|
+
def test_empty_store_returns_zeros(self):
|
|
324
|
+
host, _ = self._make_audit_with_store()
|
|
325
|
+
r = host.invoke("chp.adapters.audit.token_report", {})
|
|
326
|
+
assert r.outcome == "success"
|
|
327
|
+
assert r.data["sovereign"]["total_tokens"] == 0
|
|
328
|
+
assert r.data["sovereign"]["by_model"] == []
|
|
329
|
+
assert r.data["estimated_frontier_cost_usd"] == 0.0
|
|
330
|
+
assert r.data["backfill"]["calls_without_token_data"] == 0
|
|
331
|
+
|
|
332
|
+
def test_aggregates_by_model(self):
|
|
333
|
+
host, store = self._make_audit_with_store()
|
|
334
|
+
_seed_http_response_events(store, [
|
|
335
|
+
{"model": "fastcontext", "prompt": 100, "completion": 50},
|
|
336
|
+
{"model": "fastcontext", "prompt": 200, "completion": 80},
|
|
337
|
+
{"model": "vllm-qwen", "prompt": 300, "completion": 120},
|
|
338
|
+
])
|
|
339
|
+
r = host.invoke("chp.adapters.audit.token_report", {})
|
|
340
|
+
assert r.outcome == "success"
|
|
341
|
+
by_model = {m["model"]: m for m in r.data["sovereign"]["by_model"]}
|
|
342
|
+
assert by_model["fastcontext"]["prompt_tokens"] == 300
|
|
343
|
+
assert by_model["fastcontext"]["completion_tokens"] == 130
|
|
344
|
+
assert by_model["fastcontext"]["calls"] == 2
|
|
345
|
+
assert by_model["vllm-qwen"]["calls"] == 1
|
|
346
|
+
assert r.data["sovereign"]["total_prompt_tokens"] == 600
|
|
347
|
+
assert r.data["sovereign"]["total_completion_tokens"] == 250
|
|
348
|
+
|
|
349
|
+
def test_backfill_counts_events_without_tokens(self):
|
|
350
|
+
host, store = self._make_audit_with_store()
|
|
351
|
+
_seed_http_response_events(store, [
|
|
352
|
+
{"model": "fastcontext", "prompt": 50, "completion": 20},
|
|
353
|
+
{}, # no model/tokens — pre-tracking
|
|
354
|
+
{}, # no model/tokens — pre-tracking
|
|
355
|
+
])
|
|
356
|
+
r = host.invoke("chp.adapters.audit.token_report", {})
|
|
357
|
+
assert r.data["backfill"]["calls_without_token_data"] == 2
|
|
358
|
+
|
|
359
|
+
def test_frontier_cost_calculation(self):
|
|
360
|
+
host, store = self._make_audit_with_store()
|
|
361
|
+
_seed_http_response_events(store, [
|
|
362
|
+
{"model": "fastcontext", "prompt": 1_000_000, "completion": 1_000_000},
|
|
363
|
+
])
|
|
364
|
+
# Default: $3/1M input, $15/1M output → $3 + $15 = $18
|
|
365
|
+
r = host.invoke("chp.adapters.audit.token_report", {})
|
|
366
|
+
assert r.data["estimated_frontier_cost_usd"] == 18.0
|
|
367
|
+
|
|
368
|
+
def test_custom_pricing(self):
|
|
369
|
+
host, store = self._make_audit_with_store()
|
|
370
|
+
_seed_http_response_events(store, [
|
|
371
|
+
{"model": "fastcontext", "prompt": 1_000_000, "completion": 0},
|
|
372
|
+
])
|
|
373
|
+
r = host.invoke("chp.adapters.audit.token_report", {
|
|
374
|
+
"frontier_price_per_1m_input": 5.0,
|
|
375
|
+
"frontier_price_per_1m_output": 20.0,
|
|
376
|
+
})
|
|
377
|
+
assert r.data["estimated_frontier_cost_usd"] == 5.0
|