traceforge-pcb 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 (219) hide show
  1. traceforge/__init__.py +3 -0
  2. traceforge/__main__.py +3 -0
  3. traceforge/acp_server.py +570 -0
  4. traceforge/agent.py +680 -0
  5. traceforge/altium/__init__.py +6 -0
  6. traceforge/altium/cfb.py +70 -0
  7. traceforge/altium/cli.py +60 -0
  8. traceforge/altium/model.py +163 -0
  9. traceforge/altium/outputs.py +24 -0
  10. traceforge/altium/pcb.py +301 -0
  11. traceforge/altium/project.py +328 -0
  12. traceforge/altium/schematic.py +400 -0
  13. traceforge/altium/tools.py +166 -0
  14. traceforge/approval.py +219 -0
  15. traceforge/attachments.py +346 -0
  16. traceforge/background.py +253 -0
  17. traceforge/bench/__init__.py +5 -0
  18. traceforge/bench/adapters.py +337 -0
  19. traceforge/bench/checks.py +410 -0
  20. traceforge/bench/cli.py +121 -0
  21. traceforge/bench/design.py +439 -0
  22. traceforge/bench/kicad.py +99 -0
  23. traceforge/bench/partscheck.py +159 -0
  24. traceforge/bench/review.py +100 -0
  25. traceforge/bench/runner.py +226 -0
  26. traceforge/bench/score.py +204 -0
  27. traceforge/bench/tasks.py +102 -0
  28. traceforge/chat.py +1185 -0
  29. traceforge/checks.py +328 -0
  30. traceforge/checks_sides.py +118 -0
  31. traceforge/cli.py +2224 -0
  32. traceforge/clipboard.py +107 -0
  33. traceforge/config.py +299 -0
  34. traceforge/context.py +276 -0
  35. traceforge/design/__init__.py +1 -0
  36. traceforge/design/constraints.py +397 -0
  37. traceforge/design/generate.py +1789 -0
  38. traceforge/design/outline.py +905 -0
  39. traceforge/design/schematic_layout.py +932 -0
  40. traceforge/design/schematic_lint.py +329 -0
  41. traceforge/design/schematic_route.py +207 -0
  42. traceforge/design/sheets.py +854 -0
  43. traceforge/design/sides.py +328 -0
  44. traceforge/design/spec.py +462 -0
  45. traceforge/design/specsheet.py +2351 -0
  46. traceforge/design/specsheet_render.py +183 -0
  47. traceforge/desktop/__init__.py +109 -0
  48. traceforge/desktop/keys.py +90 -0
  49. traceforge/desktop/project.py +443 -0
  50. traceforge/desktop/review.py +20 -0
  51. traceforge/desktop/server.py +1277 -0
  52. traceforge/desktop/static/app.css +223 -0
  53. traceforge/desktop/static/app.js +683 -0
  54. traceforge/desktop/static/highlight.js +77 -0
  55. traceforge/desktop/static/index.html +89 -0
  56. traceforge/desktop/static/md.js +90 -0
  57. traceforge/desktop/static/viewer.js +213 -0
  58. traceforge/desktop/symbols.py +212 -0
  59. traceforge/desktop/update.py +65 -0
  60. traceforge/diagnostics.py +67 -0
  61. traceforge/errata.py +219 -0
  62. traceforge/fab.py +108 -0
  63. traceforge/fab_layers.py +44 -0
  64. traceforge/features/__init__.py +6 -0
  65. traceforge/features/candidates.py +117 -0
  66. traceforge/features/cli.py +200 -0
  67. traceforge/features/package.py +433 -0
  68. traceforge/features/revdiff.py +224 -0
  69. traceforge/features/sarif.py +145 -0
  70. traceforge/features/viewer.py +265 -0
  71. traceforge/firmware/__init__.py +3 -0
  72. traceforge/firmware/cli.py +174 -0
  73. traceforge/firmware/drivers.py +372 -0
  74. traceforge/firmware/drivers_data.py +256 -0
  75. traceforge/firmware/mcus.py +177 -0
  76. traceforge/firmware/netlist.py +55 -0
  77. traceforge/firmware/pinmap.py +296 -0
  78. traceforge/firmware/scaffold.py +466 -0
  79. traceforge/firmware/toolchain.py +262 -0
  80. traceforge/hooks.py +237 -0
  81. traceforge/kicad/__init__.py +0 -0
  82. traceforge/kicad/cli.py +519 -0
  83. traceforge/kicad/diffpair.py +174 -0
  84. traceforge/kicad/edgegeom.py +191 -0
  85. traceforge/kicad/edit.py +405 -0
  86. traceforge/kicad/escape.py +1410 -0
  87. traceforge/kicad/formats.py +1073 -0
  88. traceforge/kicad/impedance.py +320 -0
  89. traceforge/kicad/install.py +75 -0
  90. traceforge/kicad/libraries.py +490 -0
  91. traceforge/kicad/models3d.py +942 -0
  92. traceforge/kicad/outputs.py +81 -0
  93. traceforge/kicad/pcb.py +787 -0
  94. traceforge/kicad/planes.py +1242 -0
  95. traceforge/kicad/project.py +136 -0
  96. traceforge/kicad/route.py +2698 -0
  97. traceforge/kicad/routegeom.py +164 -0
  98. traceforge/kicad/schematic.py +268 -0
  99. traceforge/kicad/sides.py +34 -0
  100. traceforge/kicad/stackup.py +424 -0
  101. traceforge/kicad_plugin/__init__.py +12 -0
  102. traceforge/kicad_plugin/actions.py +92 -0
  103. traceforge/kicad_plugin/cli.py +66 -0
  104. traceforge/kicad_plugin/client.py +86 -0
  105. traceforge/kicad_plugin/context.py +257 -0
  106. traceforge/kicad_plugin/discovery.py +81 -0
  107. traceforge/kicad_plugin/install.py +203 -0
  108. traceforge/kicad_plugin/overlay.py +60 -0
  109. traceforge/kicad_plugin/payload/action_plugin.py +65 -0
  110. traceforge/kicad_plugin/payload/ipc_main.py +42 -0
  111. traceforge/kicad_plugin/payload/plugin.json +90 -0
  112. traceforge/kicad_plugin/payload/requirements.txt +1 -0
  113. traceforge/kicad_plugin/views.py +202 -0
  114. traceforge/langchain.py +57 -0
  115. traceforge/local.py +580 -0
  116. traceforge/mcp_server.py +304 -0
  117. traceforge/models.py +310 -0
  118. traceforge/next_steps.py +110 -0
  119. traceforge/parts/__init__.py +52 -0
  120. traceforge/parts/alternates.py +488 -0
  121. traceforge/parts/bom.py +423 -0
  122. traceforge/parts/brief.py +705 -0
  123. traceforge/parts/catalog.py +297 -0
  124. traceforge/parts/cli_sourcing.py +156 -0
  125. traceforge/parts/data/duty_rates.yaml +37 -0
  126. traceforge/parts/data/hs_codes.yaml +103 -0
  127. traceforge/parts/datasheet.py +278 -0
  128. traceforge/parts/fx.py +100 -0
  129. traceforge/parts/model.py +185 -0
  130. traceforge/parts/net.py +187 -0
  131. traceforge/parts/offers.py +46 -0
  132. traceforge/parts/packages.py +397 -0
  133. traceforge/parts/pinout.py +714 -0
  134. traceforge/parts/providers/__init__.py +17 -0
  135. traceforge/parts/providers/base.py +100 -0
  136. traceforge/parts/providers/jlc.py +364 -0
  137. traceforge/parts/providers/keyed.py +661 -0
  138. traceforge/parts/report.py +508 -0
  139. traceforge/parts/supply.py +330 -0
  140. traceforge/parts/verify.py +455 -0
  141. traceforge/parts/web.py +575 -0
  142. traceforge/plans.py +70 -0
  143. traceforge/preferences.py +127 -0
  144. traceforge/prices.py +130 -0
  145. traceforge/prices.yaml +28 -0
  146. traceforge/prompts/__init__.py +54 -0
  147. traceforge/prompts/system.md +103 -0
  148. traceforge/providers.py +764 -0
  149. traceforge/remote/__init__.py +33 -0
  150. traceforge/remote/client.py +422 -0
  151. traceforge/remote/oidc.py +240 -0
  152. traceforge/remote/orgs.py +510 -0
  153. traceforge/remote/server.py +690 -0
  154. traceforge/remote/store.py +100 -0
  155. traceforge/review_changes.py +187 -0
  156. traceforge/routing.py +99 -0
  157. traceforge/runtime.py +435 -0
  158. traceforge/sessions.py +324 -0
  159. traceforge/sexpr.py +279 -0
  160. traceforge/sim/__init__.py +17 -0
  161. traceforge/sim/acmath.py +234 -0
  162. traceforge/sim/circuit.py +499 -0
  163. traceforge/sim/cli.py +162 -0
  164. traceforge/sim/engine.py +172 -0
  165. traceforge/sim/ltspice.py +175 -0
  166. traceforge/sim/measure.py +123 -0
  167. traceforge/sim/models.py +76 -0
  168. traceforge/sim/project.py +218 -0
  169. traceforge/sim/rawfile.py +123 -0
  170. traceforge/sim/results.py +298 -0
  171. traceforge/sim/tune.py +172 -0
  172. traceforge/skill_commands.py +165 -0
  173. traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
  174. traceforge/skill_library/compliance-review/SKILL.md +51 -0
  175. traceforge/skill_library/design-review/SKILL.md +47 -0
  176. traceforge/skill_library/errata-hunt/SKILL.md +30 -0
  177. traceforge/skill_library/firmware/SKILL.md +26 -0
  178. traceforge/skill_library/power-budget/SKILL.md +27 -0
  179. traceforge/skill_library/release-package/SKILL.md +29 -0
  180. traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
  181. traceforge/skill_library/thermal-review/SKILL.md +15 -0
  182. traceforge/skills.py +131 -0
  183. traceforge/thermal.py +557 -0
  184. traceforge/tools/__init__.py +48 -0
  185. traceforge/tools/agent_tools.py +213 -0
  186. traceforge/tools/base.py +303 -0
  187. traceforge/tools/batch.py +155 -0
  188. traceforge/tools/connections.py +245 -0
  189. traceforge/tools/files.py +777 -0
  190. traceforge/tools/firmware_tools.py +218 -0
  191. traceforge/tools/kicad_tools.py +1072 -0
  192. traceforge/tools/models3d_tools.py +141 -0
  193. traceforge/tools/parts_tools.py +473 -0
  194. traceforge/tools/processes.py +246 -0
  195. traceforge/tools/review_tools.py +178 -0
  196. traceforge/tools/sim_tools.py +230 -0
  197. traceforge/tui.py +620 -0
  198. traceforge/updates.py +124 -0
  199. traceforge/validate/__init__.py +77 -0
  200. traceforge/validate/builtins.py +1091 -0
  201. traceforge/validate/circuits.py +1240 -0
  202. traceforge/validate/config.py +410 -0
  203. traceforge/validate/default.yaml +113 -0
  204. traceforge/validate/design.py +191 -0
  205. traceforge/validate/explain.py +112 -0
  206. traceforge/validate/grounding.py +169 -0
  207. traceforge/validate/ipc2221.py +350 -0
  208. traceforge/validate/multilayer.py +805 -0
  209. traceforge/validate/netmodel.py +261 -0
  210. traceforge/validate/partcheck.py +472 -0
  211. traceforge/validate/report.py +280 -0
  212. traceforge/validate/review.py +322 -0
  213. traceforge/validate/runner.py +662 -0
  214. traceforge/workspace.py +171 -0
  215. traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
  216. traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
  217. traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
  218. traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
  219. traceforge_pcb-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,510 @@
1
+ """Organisations for the hosted server: seats, roles, pooled quotas, spend caps, audit log.
2
+
3
+ Everything lives in the server's SQLite database next to the API keys (``KeyStore``). A
4
+ *member* belongs to one organisation with a role (``admin`` or ``member``); API keys can be
5
+ tied to a member (``keys.member_id``), and then every turn is checked against
6
+
7
+ * the member's monthly spend cap (USD) and token cap,
8
+ * the organisation's pooled monthly token and spend quotas.
9
+
10
+ The audit log records who ran what (member, key, mode, model, tool names, token counts,
11
+ cost). It stores no prompt text, no tool arguments and no model output.
12
+
13
+ Cost comes from :mod:`traceforge.prices`; a model without a price has unknown cost. A cost
14
+ limit applies fail-closed: a member or organisation with a USD limit cannot use an unpriced
15
+ model (error code ``unpriced_model``), because its spend could not be counted.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import json
22
+ import secrets
23
+ import sqlite3
24
+ import time
25
+ import uuid
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from traceforge.config import data_home
31
+
32
+ ROLES = ("admin", "member")
33
+ INVITE_TTL = 7 * 24 * 3600
34
+
35
+
36
+ class OrgError(Exception):
37
+ """A request the store refuses; ``code`` is stable for clients."""
38
+
39
+ def __init__(self, code: str, message: str):
40
+ super().__init__(message)
41
+ self.code = code
42
+ self.message = message
43
+
44
+
45
+ def _hash(secret: str) -> str:
46
+ return hashlib.sha256(secret.encode()).hexdigest()
47
+
48
+
49
+ def month_start(now: float | None = None) -> float:
50
+ t = time.gmtime(now if now is not None else time.time())
51
+ import calendar
52
+
53
+ return float(calendar.timegm((t.tm_year, t.tm_mon, 1, 0, 0, 0)))
54
+
55
+
56
+ @dataclass
57
+ class Check:
58
+ """Result of a pre-flight or running limit check."""
59
+
60
+ ok: bool
61
+ code: str = ""
62
+ message: str = ""
63
+
64
+
65
+ _SCHEMA = """
66
+ CREATE TABLE IF NOT EXISTS orgs (
67
+ id TEXT PRIMARY KEY, name TEXT UNIQUE, created REAL,
68
+ quota_tokens INTEGER, quota_usd REAL);
69
+ CREATE TABLE IF NOT EXISTS members (
70
+ id TEXT PRIMARY KEY, org_id TEXT, email TEXT, name TEXT, role TEXT, created REAL,
71
+ removed INTEGER DEFAULT 0, cap_usd REAL, cap_tokens INTEGER, sso_subject TEXT,
72
+ UNIQUE (org_id, email));
73
+ CREATE TABLE IF NOT EXISTS invites (
74
+ hash TEXT PRIMARY KEY, org_id TEXT, email TEXT, role TEXT, created REAL, expires REAL,
75
+ used INTEGER DEFAULT 0);
76
+ CREATE TABLE IF NOT EXISTS audit (
77
+ id INTEGER PRIMARY KEY AUTOINCREMENT, ts REAL, org_id TEXT, member_id TEXT, key_id TEXT,
78
+ event TEXT, mode TEXT, model TEXT, tools TEXT, input_tokens INTEGER, output_tokens INTEGER,
79
+ cache_read_tokens INTEGER, cache_write_tokens INTEGER, requests INTEGER, cost_usd REAL,
80
+ cost_known INTEGER, status TEXT, detail TEXT, by_model TEXT);
81
+ CREATE INDEX IF NOT EXISTS audit_member ON audit (member_id, ts);
82
+ CREATE INDEX IF NOT EXISTS audit_org ON audit (org_id, ts);
83
+ """
84
+
85
+
86
+ class OrgStore:
87
+ def __init__(self, path: Path | None = None):
88
+ from traceforge.remote.store import KeyStore
89
+
90
+ self.path = path or data_home() / "server.db"
91
+ KeyStore(self.path) # creates the keys table this store extends
92
+ with self._db() as db:
93
+ db.executescript(_SCHEMA)
94
+ columns = {r[1] for r in db.execute("PRAGMA table_info(keys)")}
95
+ if columns and "member_id" not in columns:
96
+ db.execute("ALTER TABLE keys ADD COLUMN member_id TEXT")
97
+
98
+ def _db(self) -> sqlite3.Connection:
99
+ db = sqlite3.connect(self.path, timeout=10)
100
+ db.row_factory = sqlite3.Row
101
+ return db
102
+
103
+ # ------------------------------------------------------------ orgs / members
104
+
105
+ def create_org(
106
+ self, name: str, quota_tokens: int | None = None, quota_usd: float | None = None
107
+ ) -> dict:
108
+ org_id = "org_" + uuid.uuid4().hex[:10]
109
+ try:
110
+ with self._db() as db:
111
+ db.execute(
112
+ "INSERT INTO orgs VALUES (?,?,?,?,?)",
113
+ (org_id, name, time.time(), quota_tokens, quota_usd),
114
+ )
115
+ except sqlite3.IntegrityError as exc:
116
+ raise OrgError("exists", f"organisation {name!r} already exists") from exc
117
+ return self.org(org_id) # type: ignore[return-value]
118
+
119
+ def org(self, name_or_id: str) -> dict | None:
120
+ with self._db() as db:
121
+ row = db.execute(
122
+ "SELECT * FROM orgs WHERE id = ? OR name = ?", (name_or_id, name_or_id)
123
+ ).fetchone()
124
+ return dict(row) if row else None
125
+
126
+ def need_org(self, name_or_id: str) -> dict:
127
+ org = self.org(name_or_id)
128
+ if org is None:
129
+ raise OrgError("no_such_org", f"no organisation {name_or_id!r}")
130
+ return org
131
+
132
+ def orgs(self) -> list[dict]:
133
+ with self._db() as db:
134
+ rows = db.execute("SELECT * FROM orgs ORDER BY created").fetchall()
135
+ return [{**dict(r), **self.usage(org_id=r["id"])} for r in rows]
136
+
137
+ def set_quota(
138
+ self, org: str, tokens: int | None = None, usd: float | None = None, clear: bool = False
139
+ ) -> None:
140
+ org_row = self.need_org(org)
141
+ with self._db() as db:
142
+ db.execute(
143
+ "UPDATE orgs SET quota_tokens = ?, quota_usd = ? WHERE id = ?",
144
+ (
145
+ None if clear else tokens if tokens is not None else org_row["quota_tokens"],
146
+ None if clear else usd if usd is not None else org_row["quota_usd"],
147
+ org_row["id"],
148
+ ),
149
+ )
150
+
151
+ def add_member(
152
+ self,
153
+ org: str,
154
+ email: str,
155
+ role: str = "member",
156
+ name: str = "",
157
+ sso_subject: str | None = None,
158
+ ) -> dict:
159
+ if role not in ROLES:
160
+ raise OrgError("bad_role", f"role must be one of {ROLES}")
161
+ org_row = self.need_org(org)
162
+ email = email.strip().lower()
163
+ existing = self.member(org_row["id"], email, include_removed=True)
164
+ if existing and not existing["removed"]:
165
+ raise OrgError("exists", f"{email} is already a member of {org_row['name']}")
166
+ with self._db() as db:
167
+ if existing: # re-adding a removed seat
168
+ db.execute(
169
+ "UPDATE members SET removed = 0, role = ?, name = ?, sso_subject = ? WHERE id = ?",
170
+ (role, name or existing["name"], sso_subject, existing["id"]),
171
+ )
172
+ member_id = existing["id"]
173
+ else:
174
+ member_id = "usr_" + uuid.uuid4().hex[:10]
175
+ db.execute(
176
+ "INSERT INTO members (id, org_id, email, name, role, created, sso_subject)"
177
+ " VALUES (?,?,?,?,?,?,?)",
178
+ (
179
+ member_id,
180
+ org_row["id"],
181
+ email,
182
+ name or email,
183
+ role,
184
+ time.time(),
185
+ sso_subject,
186
+ ),
187
+ )
188
+ return self.member_by_id(member_id) # type: ignore[return-value]
189
+
190
+ def member(self, org_id: str, email: str, include_removed: bool = False) -> dict | None:
191
+ with self._db() as db:
192
+ row = db.execute(
193
+ "SELECT * FROM members WHERE org_id = ? AND email = ?", (org_id, email.lower())
194
+ ).fetchone()
195
+ if row is None or (row["removed"] and not include_removed):
196
+ return None
197
+ return dict(row)
198
+
199
+ def member_by_id(self, member_id: str) -> dict | None:
200
+ with self._db() as db:
201
+ row = db.execute("SELECT * FROM members WHERE id = ?", (member_id,)).fetchone()
202
+ return dict(row) if row else None
203
+
204
+ def member_by_subject(self, org_id: str, subject: str) -> dict | None:
205
+ with self._db() as db:
206
+ row = db.execute(
207
+ "SELECT * FROM members WHERE org_id = ? AND sso_subject = ? AND removed = 0",
208
+ (org_id, subject),
209
+ ).fetchone()
210
+ return dict(row) if row else None
211
+
212
+ def members(self, org: str, include_removed: bool = False) -> list[dict]:
213
+ org_row = self.need_org(org)
214
+ with self._db() as db:
215
+ rows = db.execute(
216
+ "SELECT * FROM members WHERE org_id = ? ORDER BY created", (org_row["id"],)
217
+ ).fetchall()
218
+ out = []
219
+ for row in rows:
220
+ if row["removed"] and not include_removed:
221
+ continue
222
+ out.append({**dict(row), **self.usage(member_id=row["id"])})
223
+ return out
224
+
225
+ def _need_member(self, org: str, email: str) -> dict:
226
+ org_row = self.need_org(org)
227
+ member = self.member(org_row["id"], email)
228
+ if member is None:
229
+ raise OrgError("no_such_member", f"{email} is not a member of {org_row['name']}")
230
+ return member
231
+
232
+ def remove_member(self, org: str, email: str) -> None:
233
+ """Remove a seat and revoke every API key of that member."""
234
+ member = self._need_member(org, email)
235
+ if member["role"] == "admin" and self._admins(member["org_id"]) <= 1:
236
+ raise OrgError("last_admin", "cannot remove the only admin of an organisation")
237
+ with self._db() as db:
238
+ db.execute("UPDATE members SET removed = 1 WHERE id = ?", (member["id"],))
239
+ db.execute("UPDATE keys SET revoked = 1 WHERE member_id = ?", (member["id"],))
240
+
241
+ def _admins(self, org_id: str) -> int:
242
+ with self._db() as db:
243
+ return db.execute(
244
+ "SELECT COUNT(*) FROM members WHERE org_id = ? AND role = 'admin' AND removed = 0",
245
+ (org_id,),
246
+ ).fetchone()[0]
247
+
248
+ def set_role(self, org: str, email: str, role: str) -> None:
249
+ if role not in ROLES:
250
+ raise OrgError("bad_role", f"role must be one of {ROLES}")
251
+ member = self._need_member(org, email)
252
+ if member["role"] == "admin" and role != "admin" and self._admins(member["org_id"]) <= 1:
253
+ raise OrgError("last_admin", "cannot demote the only admin of an organisation")
254
+ with self._db() as db:
255
+ db.execute("UPDATE members SET role = ? WHERE id = ?", (role, member["id"]))
256
+
257
+ def set_cap(
258
+ self,
259
+ org: str,
260
+ email: str,
261
+ usd: float | None = None,
262
+ tokens: int | None = None,
263
+ clear: bool = False,
264
+ ) -> None:
265
+ member = self._need_member(org, email)
266
+ with self._db() as db:
267
+ db.execute(
268
+ "UPDATE members SET cap_usd = ?, cap_tokens = ? WHERE id = ?",
269
+ (
270
+ None if clear else usd if usd is not None else member["cap_usd"],
271
+ None if clear else tokens if tokens is not None else member["cap_tokens"],
272
+ member["id"],
273
+ ),
274
+ )
275
+
276
+ # ------------------------------------------------------------------ invites
277
+
278
+ def invite(self, org: str, email: str, role: str = "member") -> str:
279
+ """A one-time invite token (shown once, stored hashed, valid for seven days)."""
280
+ if role not in ROLES:
281
+ raise OrgError("bad_role", f"role must be one of {ROLES}")
282
+ org_row = self.need_org(org)
283
+ token = "tfi_" + secrets.token_urlsafe(24)
284
+ with self._db() as db:
285
+ db.execute(
286
+ "INSERT INTO invites VALUES (?,?,?,?,?,?,0)",
287
+ (
288
+ _hash(token),
289
+ org_row["id"],
290
+ email.strip().lower(),
291
+ role,
292
+ time.time(),
293
+ time.time() + INVITE_TTL,
294
+ ),
295
+ )
296
+ return token
297
+
298
+ def accept_invite(self, token: str, name: str = "") -> tuple[str, dict]:
299
+ """Turn an invite into a seat and its first API key: ``(key, member)``."""
300
+ with self._db() as db:
301
+ row = db.execute("SELECT * FROM invites WHERE hash = ?", (_hash(token),)).fetchone()
302
+ if row is None or row["used"] or row["expires"] < time.time():
303
+ raise OrgError("bad_invite", "this invite is invalid, used or expired")
304
+ db.execute("UPDATE invites SET used = 1 WHERE hash = ?", (row["hash"],))
305
+ member = self.add_member(row["org_id"], row["email"], row["role"], name)
306
+ key, _ = self.issue_key(member, "invite")
307
+ return key, member
308
+
309
+ def issue_key(self, member: dict, label: str = "") -> tuple[str, dict]:
310
+ key = "tf_" + secrets.token_urlsafe(32)
311
+ key_id = uuid.uuid4().hex[:10]
312
+ with self._db() as db:
313
+ db.execute(
314
+ "INSERT INTO keys (id, name, hash, created, revoked, monthly_limit, member_id)"
315
+ " VALUES (?,?,?,?,0,NULL,?)",
316
+ (
317
+ key_id,
318
+ f"{member['email']} ({label})" if label else member["email"],
319
+ _hash(key),
320
+ time.time(),
321
+ member["id"],
322
+ ),
323
+ )
324
+ return key, {"id": key_id, "member": member["id"]}
325
+
326
+ def member_of_key(self, key_id: str) -> dict | None:
327
+ with self._db() as db:
328
+ row = db.execute(
329
+ "SELECT m.*, o.name AS org_name FROM keys k JOIN members m ON m.id = k.member_id"
330
+ " JOIN orgs o ON o.id = m.org_id WHERE k.id = ? AND m.removed = 0",
331
+ (key_id,),
332
+ ).fetchone()
333
+ return dict(row) if row else None
334
+
335
+ # ------------------------------------------------------------------- usage
336
+
337
+ def usage(
338
+ self, member_id: str | None = None, org_id: str | None = None, since: float | None = None
339
+ ) -> dict[str, Any]:
340
+ """Totals for the current month: tokens, calls and USD (known part) per member or org."""
341
+ where, params = ["ts >= ?"], [since if since is not None else month_start()]
342
+ if member_id:
343
+ where.append("member_id = ?")
344
+ params.append(member_id)
345
+ if org_id:
346
+ where.append("org_id = ?")
347
+ params.append(org_id)
348
+ with self._db() as db:
349
+ row = db.execute(
350
+ "SELECT COALESCE(SUM(input_tokens + output_tokens + cache_read_tokens"
351
+ " + cache_write_tokens), 0), COALESCE(SUM(requests), 0),"
352
+ " COALESCE(SUM(cost_usd), 0), COALESCE(SUM(1 - cost_known), 0)"
353
+ f" FROM audit WHERE {' AND '.join(where)} AND event = 'turn'",
354
+ params,
355
+ ).fetchone()
356
+ return {
357
+ "tokens": int(row[0]),
358
+ "requests": int(row[1]),
359
+ "spent_usd": round(float(row[2]), 6),
360
+ "unpriced_turns": int(row[3]),
361
+ }
362
+
363
+ def usage_by_model(self, member_id: str | None = None, org_id: str | None = None) -> dict:
364
+ where, params = ["ts >= ?", "event = 'turn'"], [month_start()]
365
+ if member_id:
366
+ where.append("member_id = ?")
367
+ params.append(member_id)
368
+ if org_id:
369
+ where.append("org_id = ?")
370
+ params.append(org_id)
371
+ merged: dict[str, dict[str, int]] = {}
372
+ with self._db() as db:
373
+ for (blob,) in db.execute(
374
+ f"SELECT by_model FROM audit WHERE {' AND '.join(where)}", params
375
+ ):
376
+ for model, counts in json.loads(blob or "{}").items():
377
+ slot = merged.setdefault(model, {})
378
+ for k, v in counts.items():
379
+ slot[k] = slot.get(k, 0) + int(v)
380
+ return merged
381
+
382
+ def check(
383
+ self, member: dict, model_priced: bool, inflight: dict[str, tuple[int, float]]
384
+ ) -> Check:
385
+ """Pre-flight: may this member start a turn now? ``inflight`` is spend of running turns."""
386
+ org = self.need_org(member["org_id"])
387
+ mine = self.usage(member_id=member["id"])
388
+ pool = self.usage(org_id=org["id"])
389
+ my_tokens, my_usd = inflight.get(member["id"], (0, 0.0))
390
+ org_tokens, org_usd = inflight.get(org["id"], (0, 0.0))
391
+ if member["cap_usd"] is not None and not model_priced:
392
+ return Check(
393
+ False,
394
+ "unpriced_model",
395
+ "this model has no price, so your spend cap cannot be enforced; pick another model",
396
+ )
397
+ if org["quota_usd"] is not None and not model_priced:
398
+ return Check(
399
+ False,
400
+ "unpriced_model",
401
+ "this model has no price, so the organisation's spend quota cannot be enforced; pick another model",
402
+ )
403
+ return self.verdict(member, org, mine, pool, (my_tokens, my_usd), (org_tokens, org_usd))
404
+
405
+ @staticmethod
406
+ def verdict(
407
+ member: dict, org: dict, mine: dict, pool: dict, mine_in: tuple, pool_in: tuple
408
+ ) -> Check:
409
+ spent = mine["spent_usd"] + mine_in[1]
410
+ tokens = mine["tokens"] + mine_in[0]
411
+ if member["cap_usd"] is not None and spent >= member["cap_usd"]:
412
+ return Check(
413
+ False,
414
+ "spend_cap",
415
+ f"your monthly spend cap of ${member['cap_usd']:.2f} is reached "
416
+ f"(${spent:.2f} spent); ask an admin to raise it",
417
+ )
418
+ if member["cap_tokens"] is not None and tokens >= member["cap_tokens"]:
419
+ return Check(
420
+ False, "spend_cap", f"your monthly token cap of {member['cap_tokens']:,} is reached"
421
+ )
422
+ if org["quota_usd"] is not None and pool["spent_usd"] + pool_in[1] >= org["quota_usd"]:
423
+ return Check(
424
+ False, "org_quota", f"{org['name']}'s pooled monthly spend quota is used up"
425
+ )
426
+ if org["quota_tokens"] is not None and pool["tokens"] + pool_in[0] >= org["quota_tokens"]:
427
+ return Check(
428
+ False, "org_quota", f"{org['name']}'s pooled monthly token quota is used up"
429
+ )
430
+ return Check(True)
431
+
432
+ # ------------------------------------------------------------------- audit
433
+
434
+ def record(
435
+ self,
436
+ *,
437
+ org_id: str | None,
438
+ member_id: str | None,
439
+ key_id: str | None,
440
+ event: str = "turn",
441
+ mode: str = "",
442
+ model: str = "",
443
+ tools: list[str] | None = None,
444
+ usage: dict[str, Any] | None = None,
445
+ cost_usd: float | None = None,
446
+ status: str = "ok",
447
+ detail: str = "",
448
+ ) -> None:
449
+ usage = usage or {}
450
+ with self._db() as db:
451
+ db.execute(
452
+ "INSERT INTO audit (ts, org_id, member_id, key_id, event, mode, model, tools,"
453
+ " input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, requests,"
454
+ " cost_usd, cost_known, status, detail, by_model)"
455
+ " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
456
+ (
457
+ time.time(),
458
+ org_id,
459
+ member_id,
460
+ key_id,
461
+ event,
462
+ mode,
463
+ model,
464
+ json.dumps(sorted(set(tools or []))),
465
+ int(usage.get("input_tokens", 0)),
466
+ int(usage.get("output_tokens", 0)),
467
+ int(usage.get("cache_read_input_tokens", 0)),
468
+ int(usage.get("cache_creation_input_tokens", 0)),
469
+ int(usage.get("requests", 0)),
470
+ cost_usd if cost_usd is not None else 0.0,
471
+ 1 if cost_usd is not None else 0,
472
+ status,
473
+ detail[:500],
474
+ json.dumps(usage.get("by_model") or {}),
475
+ ),
476
+ )
477
+
478
+ def audit(self, org: str, limit: int = 50, member_id: str | None = None) -> list[dict]:
479
+ org_row = self.need_org(org)
480
+ query = (
481
+ "SELECT a.ts, a.event, a.mode, a.model, a.tools, a.input_tokens, a.output_tokens,"
482
+ " a.cache_read_tokens, a.requests, a.cost_usd, a.cost_known, a.status, a.detail,"
483
+ " m.email FROM audit a LEFT JOIN members m ON m.id = a.member_id WHERE a.org_id = ?"
484
+ )
485
+ params: list[Any] = [org_row["id"]]
486
+ if member_id:
487
+ query += " AND a.member_id = ?"
488
+ params.append(member_id)
489
+ query += " ORDER BY a.id DESC LIMIT ?"
490
+ params.append(limit)
491
+ with self._db() as db:
492
+ rows = db.execute(query, params).fetchall()
493
+ return [
494
+ {
495
+ "time": time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(r["ts"])) + "Z",
496
+ "user": r["email"] or "",
497
+ "event": r["event"],
498
+ "mode": r["mode"],
499
+ "model": r["model"],
500
+ "tools": json.loads(r["tools"] or "[]"),
501
+ "input_tokens": r["input_tokens"],
502
+ "output_tokens": r["output_tokens"],
503
+ "cached_tokens": r["cache_read_tokens"],
504
+ "requests": r["requests"],
505
+ "cost_usd": r["cost_usd"] if r["cost_known"] else None,
506
+ "status": r["status"],
507
+ "detail": r["detail"],
508
+ }
509
+ for r in rows
510
+ ]