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.
Files changed (94) hide show
  1. app/__init__.py +351 -0
  2. app/_version.py +8 -0
  3. app/agents.py +150 -0
  4. app/alembic.ini +37 -0
  5. app/auth.py +714 -0
  6. app/chat.py +122 -0
  7. app/config.py +258 -0
  8. app/costgov.py +345 -0
  9. app/db.py +56 -0
  10. app/faults.py +26 -0
  11. app/grants.py +122 -0
  12. app/idempotency.py +58 -0
  13. app/jcs.py +117 -0
  14. app/ledger/__init__.py +41 -0
  15. app/ledger/fold.py +74 -0
  16. app/ledger/hashing.py +19 -0
  17. app/ledger/projection.py +389 -0
  18. app/ledger/store.py +1042 -0
  19. app/ledger/verify.py +31 -0
  20. app/main.py +50 -0
  21. app/maintenance.py +56 -0
  22. app/metrics.py +89 -0
  23. app/migrations/env.py +20 -0
  24. app/migrations/script.py.mako +23 -0
  25. app/migrations/versions/0001_baseline.py +19 -0
  26. app/migrations/versions/0002_events_and_projection.py +78 -0
  27. app/migrations/versions/0003_slack_messages.py +27 -0
  28. app/migrations/versions/0004_sessions.py +31 -0
  29. app/migrations/versions/0005_siem_cursor.py +26 -0
  30. app/migrations/versions/0006_teams_messages.py +27 -0
  31. app/migrations/versions/0007_tenant_scoping.py +50 -0
  32. app/migrations/versions/0008_policy_view.py +39 -0
  33. app/migrations/versions/0009_api_tokens.py +37 -0
  34. app/migrations/versions/0010_session_groups.py +23 -0
  35. app/migrations/versions/0011_budgets_and_evals.py +75 -0
  36. app/migrations/versions/0012_run_event_ts_index.py +23 -0
  37. app/migrations/versions/0013_agent_chains.py +102 -0
  38. app/migrations/versions/0014_dual_control.py +33 -0
  39. app/migrations/versions/0015_sampled_mode.py +24 -0
  40. app/migrations/versions/0016_sampling_salt.py +25 -0
  41. app/migrations/versions/0017_live_tokens_index.py +25 -0
  42. app/migrations/versions/0018_webhook_outbox.py +47 -0
  43. app/migrations/versions/0019_usage_view_and_rollout_index.py +68 -0
  44. app/migrations/versions/0020_policy_metadata_and_lifecycle.py +67 -0
  45. app/migrations/versions/0021_execution_grants.py +59 -0
  46. app/migrations/versions/0022_digest_alg_and_executor_instance.py +45 -0
  47. app/migrations/versions/0023_idempotency_keys.py +38 -0
  48. app/migrations/versions/0024_approvals_view_keyset_index.py +26 -0
  49. app/netguard.py +73 -0
  50. app/obslog.py +105 -0
  51. app/policy.py +390 -0
  52. app/ratelimit.py +78 -0
  53. app/routes/__init__.py +0 -0
  54. app/routes/admin.py +312 -0
  55. app/routes/agents.py +111 -0
  56. app/routes/approvals.py +576 -0
  57. app/routes/audit.py +213 -0
  58. app/routes/costgov.py +150 -0
  59. app/routes/health.py +53 -0
  60. app/routes/policy.py +72 -0
  61. app/routes/runs.py +115 -0
  62. app/routes/slack.py +40 -0
  63. app/routes/teams.py +29 -0
  64. app/routes/traces.py +55 -0
  65. app/schemas.py +248 -0
  66. app/security.py +203 -0
  67. app/siem.py +248 -0
  68. app/signing.py +342 -0
  69. app/sla.py +234 -0
  70. app/slack.py +282 -0
  71. app/teams.py +276 -0
  72. app/tokens.py +117 -0
  73. app/traces.py +309 -0
  74. app/web-dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  75. app/web-dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  76. app/web-dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  77. app/web-dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  78. app/web-dist/assets/geist-mono-cyrillic-ext-wght-normal-I4S5GZfc.woff2 +0 -0
  79. app/web-dist/assets/geist-mono-cyrillic-wght-normal-BmXc_FBt.woff2 +0 -0
  80. app/web-dist/assets/geist-mono-latin-ext-wght-normal-DrnZ1wKl.woff2 +0 -0
  81. app/web-dist/assets/geist-mono-latin-wght-normal-B_7UjwxQ.woff2 +0 -0
  82. app/web-dist/assets/geist-mono-symbols2-wght-normal-GZpp1pK2.woff2 +0 -0
  83. app/web-dist/assets/geist-mono-vietnamese-wght-normal-D8KDMBhC.woff2 +0 -0
  84. app/web-dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  85. app/web-dist/assets/index-B_JDe_G2.js +10 -0
  86. app/web-dist/assets/index-J7J-YtwM.css +1 -0
  87. app/web-dist/index.html +24 -0
  88. app/webhooks.py +405 -0
  89. primarypath_server-1.0.0.dist-info/METADATA +35 -0
  90. primarypath_server-1.0.0.dist-info/RECORD +94 -0
  91. primarypath_server-1.0.0.dist-info/WHEEL +5 -0
  92. primarypath_server-1.0.0.dist-info/entry_points.txt +3 -0
  93. primarypath_server-1.0.0.dist-info/licenses/LICENSE +202 -0
  94. 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
+ }