primarypath-server 1.0.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.
- app/__init__.py +351 -0
- app/_version.py +8 -0
- app/agents.py +150 -0
- app/alembic.ini +37 -0
- app/auth.py +714 -0
- app/chat.py +122 -0
- app/config.py +258 -0
- app/costgov.py +345 -0
- app/db.py +56 -0
- app/faults.py +26 -0
- app/grants.py +122 -0
- app/idempotency.py +58 -0
- app/jcs.py +117 -0
- app/ledger/__init__.py +41 -0
- app/ledger/fold.py +74 -0
- app/ledger/hashing.py +19 -0
- app/ledger/projection.py +389 -0
- app/ledger/store.py +1042 -0
- app/ledger/verify.py +31 -0
- app/main.py +50 -0
- app/maintenance.py +56 -0
- app/metrics.py +89 -0
- app/migrations/env.py +20 -0
- app/migrations/script.py.mako +23 -0
- app/migrations/versions/0001_baseline.py +19 -0
- app/migrations/versions/0002_events_and_projection.py +78 -0
- app/migrations/versions/0003_slack_messages.py +27 -0
- app/migrations/versions/0004_sessions.py +31 -0
- app/migrations/versions/0005_siem_cursor.py +26 -0
- app/migrations/versions/0006_teams_messages.py +27 -0
- app/migrations/versions/0007_tenant_scoping.py +50 -0
- app/migrations/versions/0008_policy_view.py +39 -0
- app/migrations/versions/0009_api_tokens.py +37 -0
- app/migrations/versions/0010_session_groups.py +23 -0
- app/migrations/versions/0011_budgets_and_evals.py +75 -0
- app/migrations/versions/0012_run_event_ts_index.py +23 -0
- app/migrations/versions/0013_agent_chains.py +102 -0
- app/migrations/versions/0014_dual_control.py +33 -0
- app/migrations/versions/0015_sampled_mode.py +24 -0
- app/migrations/versions/0016_sampling_salt.py +25 -0
- app/migrations/versions/0017_live_tokens_index.py +25 -0
- app/migrations/versions/0018_webhook_outbox.py +47 -0
- app/migrations/versions/0019_usage_view_and_rollout_index.py +68 -0
- app/migrations/versions/0020_policy_metadata_and_lifecycle.py +67 -0
- app/migrations/versions/0021_execution_grants.py +59 -0
- app/migrations/versions/0022_digest_alg_and_executor_instance.py +45 -0
- app/migrations/versions/0023_idempotency_keys.py +38 -0
- app/migrations/versions/0024_approvals_view_keyset_index.py +26 -0
- app/netguard.py +73 -0
- app/obslog.py +105 -0
- app/policy.py +390 -0
- app/ratelimit.py +78 -0
- app/routes/__init__.py +0 -0
- app/routes/admin.py +312 -0
- app/routes/agents.py +111 -0
- app/routes/approvals.py +576 -0
- app/routes/audit.py +213 -0
- app/routes/costgov.py +150 -0
- app/routes/health.py +53 -0
- app/routes/policy.py +72 -0
- app/routes/runs.py +115 -0
- app/routes/slack.py +40 -0
- app/routes/teams.py +29 -0
- app/routes/traces.py +55 -0
- app/schemas.py +248 -0
- app/security.py +203 -0
- app/siem.py +248 -0
- app/signing.py +342 -0
- app/sla.py +234 -0
- app/slack.py +282 -0
- app/teams.py +276 -0
- app/tokens.py +117 -0
- app/traces.py +309 -0
- app/web-dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- app/web-dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- app/web-dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- app/web-dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- app/web-dist/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
- app/web-dist/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
- app/web-dist/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
- app/web-dist/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
- app/web-dist/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
- app/web-dist/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
- app/web-dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- app/web-dist/assets/index-B_JDe_G2.js +10 -0
- app/web-dist/assets/index-J7J-YtwM.css +1 -0
- app/web-dist/index.html +24 -0
- app/webhooks.py +405 -0
- primarypath_server-1.0.0.dist-info/METADATA +35 -0
- primarypath_server-1.0.0.dist-info/RECORD +94 -0
- primarypath_server-1.0.0.dist-info/WHEEL +5 -0
- primarypath_server-1.0.0.dist-info/entry_points.txt +3 -0
- primarypath_server-1.0.0.dist-info/licenses/LICENSE +202 -0
- primarypath_server-1.0.0.dist-info/top_level.txt +1 -0
app/traces.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Run traces + cost, folded from the audit spine. No new store.
|
|
2
|
+
|
|
3
|
+
The drangue bridge/gateway ingests a run's whole log as `run_event`s (each
|
|
4
|
+
wrapping a drangue Event: model decisions with reasoning and usage, tool
|
|
5
|
+
calls with args/result, timing). A run's trace is a fold of those plus the
|
|
6
|
+
run's `approval_*` events: an ordered timeline, the approval pause/decision,
|
|
7
|
+
per-step token usage, and a cost estimate. This is a read over what the
|
|
8
|
+
audit already needs — not a second observability backend.
|
|
9
|
+
|
|
10
|
+
Tokens are exact (summed from the ingested usage). Cost is an estimate from
|
|
11
|
+
an optional per-model price map (PRIMARYPATH_MODEL_PRICES); with no prices
|
|
12
|
+
configured, cost is null and only tokens are reported.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections import defaultdict
|
|
17
|
+
|
|
18
|
+
from .ledger.store import DEFAULT_TENANT, Ledger
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_prices(spec: str) -> dict[str, tuple[float, float]]:
|
|
22
|
+
""""model:in/out,..." USD per 1M tokens -> {model: (in, out)}."""
|
|
23
|
+
out: dict[str, tuple[float, float]] = {}
|
|
24
|
+
for part in spec.split(","):
|
|
25
|
+
part = part.strip()
|
|
26
|
+
if not part or ":" not in part:
|
|
27
|
+
continue
|
|
28
|
+
model, _, rates = part.partition(":")
|
|
29
|
+
try:
|
|
30
|
+
inp, _, outp = rates.partition("/")
|
|
31
|
+
out[model.strip()] = (float(inp), float(outp))
|
|
32
|
+
except ValueError:
|
|
33
|
+
continue
|
|
34
|
+
return out
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _cost(prices: dict, model: str, usage: dict) -> float | None:
|
|
38
|
+
rate = prices.get(model)
|
|
39
|
+
if rate is None or not usage:
|
|
40
|
+
return None
|
|
41
|
+
return (usage.get("input_tokens", 0) * rate[0]
|
|
42
|
+
+ usage.get("output_tokens", 0) * rate[1]) / 1_000_000
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TraceStore:
|
|
46
|
+
def __init__(self, pool, ledger: Ledger, settings):
|
|
47
|
+
self.pool = pool
|
|
48
|
+
self.ledger = ledger
|
|
49
|
+
self.settings = settings
|
|
50
|
+
self.prices = parse_prices(settings.model_prices)
|
|
51
|
+
|
|
52
|
+
# -- one run's trace -----------------------------------------------------
|
|
53
|
+
|
|
54
|
+
async def run_trace(self, run_id: str,
|
|
55
|
+
tenant: str = DEFAULT_TENANT,
|
|
56
|
+
visible=None) -> dict | None:
|
|
57
|
+
"""`visible(agent_id, unit)` narrows the fold to the caller's scope
|
|
58
|
+
event by event: a run_id written by two agents must never fold one
|
|
59
|
+
agent's steps into a trace the other can read."""
|
|
60
|
+
events = await self.ledger.events_for_run(run_id, tenant)
|
|
61
|
+
if visible is not None:
|
|
62
|
+
events = [e for e in events
|
|
63
|
+
if visible(e.get("agent_id", "default"), e.get("unit"))]
|
|
64
|
+
if not events:
|
|
65
|
+
return None
|
|
66
|
+
return self._fold_run(run_id, events)
|
|
67
|
+
|
|
68
|
+
def _fold_run(self, run_id: str, events: list[dict]) -> dict:
|
|
69
|
+
import json
|
|
70
|
+
|
|
71
|
+
steps: list[dict] = []
|
|
72
|
+
tools: list[str] = []
|
|
73
|
+
approval = None
|
|
74
|
+
status = "running"
|
|
75
|
+
agent = "default"
|
|
76
|
+
unit = None
|
|
77
|
+
started_ts = None
|
|
78
|
+
tokens = {"input_tokens": 0, "output_tokens": 0}
|
|
79
|
+
by_model: dict[str, dict] = defaultdict(
|
|
80
|
+
lambda: {"input_tokens": 0, "output_tokens": 0, "decisions": 0})
|
|
81
|
+
# a run can hold several approvals; it is paused while ANY of them
|
|
82
|
+
# is undecided, not merely until the first decision lands
|
|
83
|
+
pending_ids: set[str] = set()
|
|
84
|
+
|
|
85
|
+
for e in events:
|
|
86
|
+
payload = json.loads(e["payload"])
|
|
87
|
+
ts = e["ts_ms"]
|
|
88
|
+
if started_ts is None:
|
|
89
|
+
started_ts = ts
|
|
90
|
+
if e.get("agent_id"):
|
|
91
|
+
agent = e["agent_id"]
|
|
92
|
+
if e.get("unit"):
|
|
93
|
+
unit = e["unit"]
|
|
94
|
+
t = e["type"]
|
|
95
|
+
|
|
96
|
+
if t == "run_event":
|
|
97
|
+
inner = payload.get("event") or {}
|
|
98
|
+
kind = inner.get("type", "event")
|
|
99
|
+
ip = inner.get("payload") or {}
|
|
100
|
+
step = {"seq": e["seq"], "ts_ms": ts, "kind": kind,
|
|
101
|
+
"duration_ms": inner.get("duration_ms")}
|
|
102
|
+
if kind == "model_decision":
|
|
103
|
+
step["reasoning"] = ip.get("reasoning") or ip.get("text")
|
|
104
|
+
step["tool_calls"] = ip.get("tool_calls") or []
|
|
105
|
+
usage = ip.get("usage") or {}
|
|
106
|
+
model = ip.get("model") or "unknown"
|
|
107
|
+
step["model"] = model
|
|
108
|
+
step["usage"] = usage
|
|
109
|
+
tokens["input_tokens"] += usage.get("input_tokens", 0)
|
|
110
|
+
tokens["output_tokens"] += usage.get("output_tokens", 0)
|
|
111
|
+
m = by_model[model]
|
|
112
|
+
m["input_tokens"] += usage.get("input_tokens", 0)
|
|
113
|
+
m["output_tokens"] += usage.get("output_tokens", 0)
|
|
114
|
+
m["decisions"] += 1
|
|
115
|
+
elif kind == "tool_result":
|
|
116
|
+
step["tool"] = ip.get("name")
|
|
117
|
+
step["result"] = ip.get("content")
|
|
118
|
+
step["is_error"] = ip.get("is_error", False)
|
|
119
|
+
if kind == "run_finished":
|
|
120
|
+
status = "completed"
|
|
121
|
+
steps.append(step)
|
|
122
|
+
|
|
123
|
+
elif t == "approval_requested":
|
|
124
|
+
tools.append(payload.get("tool"))
|
|
125
|
+
pending_ids.add(payload.get("approval_id"))
|
|
126
|
+
steps.append({"seq": e["seq"], "ts_ms": ts,
|
|
127
|
+
"kind": "approval_requested",
|
|
128
|
+
"tool": payload.get("tool"),
|
|
129
|
+
"arguments": payload.get("arguments"),
|
|
130
|
+
"reasoning": payload.get("reasoning"),
|
|
131
|
+
"approval_id": payload.get("approval_id")})
|
|
132
|
+
elif t == "approval_endorsed":
|
|
133
|
+
# dual control: first approval; the run stays paused
|
|
134
|
+
steps.append({"seq": e["seq"], "ts_ms": ts,
|
|
135
|
+
"kind": "approval_endorsed",
|
|
136
|
+
"reviewer": payload.get("reviewer"),
|
|
137
|
+
"reason": payload.get("reason")})
|
|
138
|
+
elif t in ("approval_granted", "approval_denied"):
|
|
139
|
+
pending_ids.discard(payload.get("approval_id"))
|
|
140
|
+
approval = {"status": ("granted" if t == "approval_granted"
|
|
141
|
+
else "denied"),
|
|
142
|
+
"reviewer": payload.get("reviewer"),
|
|
143
|
+
"reason": payload.get("reason"),
|
|
144
|
+
"reviewer_role": payload.get("reviewer_role"),
|
|
145
|
+
"ts_ms": ts}
|
|
146
|
+
steps.append({"seq": e["seq"], "ts_ms": ts, "kind": t,
|
|
147
|
+
"reviewer": payload.get("reviewer"),
|
|
148
|
+
"reason": payload.get("reason")})
|
|
149
|
+
elif t == "approval_annotated":
|
|
150
|
+
steps.append({"seq": e["seq"], "ts_ms": ts,
|
|
151
|
+
"kind": "approval_annotated",
|
|
152
|
+
"reviewer": payload.get("reviewer"),
|
|
153
|
+
"note": payload.get("note")})
|
|
154
|
+
|
|
155
|
+
if pending_ids:
|
|
156
|
+
status = "paused"
|
|
157
|
+
|
|
158
|
+
cost = 0.0
|
|
159
|
+
cost_seen = False
|
|
160
|
+
cost_by_model = {}
|
|
161
|
+
for model, u in by_model.items():
|
|
162
|
+
c = _cost(self.prices, model, u)
|
|
163
|
+
cost_by_model[model] = {**u, "cost": c}
|
|
164
|
+
if c is not None:
|
|
165
|
+
cost += c
|
|
166
|
+
cost_seen = True
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
"run_id": run_id, "status": status,
|
|
170
|
+
"agent": agent, "unit": unit,
|
|
171
|
+
"tools": [t for t in dict.fromkeys(tools) if t],
|
|
172
|
+
"step_count": sum(1 for s in steps if s["kind"] == "model_decision"),
|
|
173
|
+
"tokens": tokens,
|
|
174
|
+
"cost": round(cost, 6) if cost_seen else None,
|
|
175
|
+
"cost_by_model": cost_by_model,
|
|
176
|
+
"started_ts_ms": started_ts,
|
|
177
|
+
"last_ts_ms": events[-1]["ts_ms"],
|
|
178
|
+
"approval": approval,
|
|
179
|
+
"steps": steps,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
# -- run list (summaries) ------------------------------------------------
|
|
183
|
+
|
|
184
|
+
async def run_ids(self, tenant: str = DEFAULT_TENANT,
|
|
185
|
+
limit: int | None = 50, offset: int = 0) -> list[str]:
|
|
186
|
+
# order by rowid (global ingest order), not seq: F24 restarts seq
|
|
187
|
+
# per (tenant, chain), so seqs on different agents' chains are not
|
|
188
|
+
# comparable and a new agent's fresh run would sort last
|
|
189
|
+
q = ("SELECT run_id, max(rowid) AS s FROM events "
|
|
190
|
+
"WHERE tenant_id = %s AND run_id IS NOT NULL "
|
|
191
|
+
"GROUP BY run_id ORDER BY s DESC")
|
|
192
|
+
params: list = [tenant]
|
|
193
|
+
if limit is not None:
|
|
194
|
+
q += " LIMIT %s OFFSET %s"
|
|
195
|
+
params += [limit, offset]
|
|
196
|
+
async with self.pool.connection() as conn:
|
|
197
|
+
cur = await conn.execute(q, params)
|
|
198
|
+
return [r[0] for r in await cur.fetchall()]
|
|
199
|
+
|
|
200
|
+
async def run_list(self, tenant: str = DEFAULT_TENANT,
|
|
201
|
+
status: str | None = None, tool: str | None = None,
|
|
202
|
+
limit: int = 50, offset: int = 0,
|
|
203
|
+
units: set[str] | None = None,
|
|
204
|
+
agent: str | None = None) -> list[dict]:
|
|
205
|
+
"""Paginate AFTER filtering: fold + filter the full ordered run set,
|
|
206
|
+
then slice. Slicing run_ids before the fold would drop matching runs
|
|
207
|
+
that fell outside the id page. With no filter the DB LIMIT/OFFSET is
|
|
208
|
+
the fast path; the fold-everything path only runs when a filter is
|
|
209
|
+
active (bounded to the console's own use).
|
|
210
|
+
|
|
211
|
+
`units`/`agent` are the F24 need-to-know gate (None = unrestricted):
|
|
212
|
+
a run whose agent sits in another unit is filtered out server-side,
|
|
213
|
+
never sent for the client to hide."""
|
|
214
|
+
filtered = (status is not None or tool is not None
|
|
215
|
+
or units is not None or agent is not None)
|
|
216
|
+
ids = await self.run_ids(tenant, limit=None if filtered else limit,
|
|
217
|
+
offset=0 if filtered else offset)
|
|
218
|
+
# the same per-event gate as run_detail: a summary (tools, steps,
|
|
219
|
+
# tokens) folded over another agent's events would leak them
|
|
220
|
+
visible = None
|
|
221
|
+
if units is not None or agent is not None:
|
|
222
|
+
def visible(a, u):
|
|
223
|
+
if agent is not None:
|
|
224
|
+
return a == agent
|
|
225
|
+
return u is None or units is None or u in units
|
|
226
|
+
summaries = []
|
|
227
|
+
for run_id in ids:
|
|
228
|
+
trace = await self.run_trace(run_id, tenant, visible=visible)
|
|
229
|
+
if trace is None:
|
|
230
|
+
continue
|
|
231
|
+
if units is not None and trace["unit"] is not None \
|
|
232
|
+
and trace["unit"] not in units:
|
|
233
|
+
continue
|
|
234
|
+
if agent is not None and trace["agent"] != agent:
|
|
235
|
+
continue
|
|
236
|
+
if status and trace["status"] != status:
|
|
237
|
+
continue
|
|
238
|
+
if tool and tool not in trace["tools"]:
|
|
239
|
+
continue
|
|
240
|
+
summaries.append({k: trace[k] for k in (
|
|
241
|
+
"run_id", "status", "agent", "unit", "tools", "step_count",
|
|
242
|
+
"tokens", "cost", "started_ts_ms", "last_ts_ms", "approval")})
|
|
243
|
+
return summaries[offset:offset + limit] if filtered else summaries
|
|
244
|
+
|
|
245
|
+
# -- cost aggregation over a period --------------------------------------
|
|
246
|
+
|
|
247
|
+
async def cost_summary(self, tenant: str = DEFAULT_TENANT,
|
|
248
|
+
since_ms: int | None = None,
|
|
249
|
+
until_ms: int | None = None,
|
|
250
|
+
units: set[str] | None = None) -> dict:
|
|
251
|
+
import json
|
|
252
|
+
|
|
253
|
+
q = ("SELECT payload FROM events WHERE tenant_id = %s "
|
|
254
|
+
"AND type = 'run_event'")
|
|
255
|
+
params: list = [tenant]
|
|
256
|
+
if units is not None:
|
|
257
|
+
# spend is volume, volume is information: the unit gate applies
|
|
258
|
+
# to aggregates too (F24)
|
|
259
|
+
q += " AND (unit IS NULL OR unit = ANY(%s))"
|
|
260
|
+
params.append(sorted(units))
|
|
261
|
+
if since_ms is not None:
|
|
262
|
+
q += " AND ts_ms >= %s"
|
|
263
|
+
params.append(since_ms)
|
|
264
|
+
if until_ms is not None:
|
|
265
|
+
q += " AND ts_ms <= %s"
|
|
266
|
+
params.append(until_ms)
|
|
267
|
+
async with self.pool.connection() as conn:
|
|
268
|
+
cur = await conn.execute(q, params)
|
|
269
|
+
rows = await cur.fetchall()
|
|
270
|
+
|
|
271
|
+
by_tool: dict[str, dict] = defaultdict(
|
|
272
|
+
lambda: {"input_tokens": 0, "output_tokens": 0, "cost": 0.0,
|
|
273
|
+
"cost_seen": False})
|
|
274
|
+
by_model: dict[str, dict] = defaultdict(
|
|
275
|
+
lambda: {"input_tokens": 0, "output_tokens": 0, "cost": 0.0,
|
|
276
|
+
"cost_seen": False})
|
|
277
|
+
totals = {"input_tokens": 0, "output_tokens": 0, "cost": 0.0,
|
|
278
|
+
"cost_seen": False}
|
|
279
|
+
|
|
280
|
+
for (raw,) in rows:
|
|
281
|
+
payload = json.loads(raw)
|
|
282
|
+
inner = payload.get("event") or {}
|
|
283
|
+
if inner.get("type") != "model_decision":
|
|
284
|
+
continue
|
|
285
|
+
ip = inner.get("payload") or {}
|
|
286
|
+
usage = ip.get("usage") or {}
|
|
287
|
+
model = ip.get("model") or "unknown"
|
|
288
|
+
calls = ip.get("tool_calls") or []
|
|
289
|
+
tool = (calls[0].get("name") if calls else None) or "(no tool)"
|
|
290
|
+
c = _cost(self.prices, model, usage)
|
|
291
|
+
for bucket, key in ((by_tool, tool), (by_model, model), (None, None)):
|
|
292
|
+
b = totals if bucket is None else bucket[str(key)]
|
|
293
|
+
b["input_tokens"] += usage.get("input_tokens", 0)
|
|
294
|
+
b["output_tokens"] += usage.get("output_tokens", 0)
|
|
295
|
+
if c is not None:
|
|
296
|
+
b["cost"] += c
|
|
297
|
+
b["cost_seen"] = True
|
|
298
|
+
|
|
299
|
+
def _clean(bucket: dict) -> dict:
|
|
300
|
+
return {"input_tokens": bucket["input_tokens"],
|
|
301
|
+
"output_tokens": bucket["output_tokens"],
|
|
302
|
+
"cost": round(bucket["cost"], 6) if bucket["cost_seen"]
|
|
303
|
+
else None}
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
"by_tool": {k: _clean(v) for k, v in by_tool.items()},
|
|
307
|
+
"by_model": {k: _clean(v) for k, v in by_model.items()},
|
|
308
|
+
"totals": _clean(totals),
|
|
309
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|