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.
- traceforge/__init__.py +3 -0
- traceforge/__main__.py +3 -0
- traceforge/acp_server.py +570 -0
- traceforge/agent.py +680 -0
- traceforge/altium/__init__.py +6 -0
- traceforge/altium/cfb.py +70 -0
- traceforge/altium/cli.py +60 -0
- traceforge/altium/model.py +163 -0
- traceforge/altium/outputs.py +24 -0
- traceforge/altium/pcb.py +301 -0
- traceforge/altium/project.py +328 -0
- traceforge/altium/schematic.py +400 -0
- traceforge/altium/tools.py +166 -0
- traceforge/approval.py +219 -0
- traceforge/attachments.py +346 -0
- traceforge/background.py +253 -0
- traceforge/bench/__init__.py +5 -0
- traceforge/bench/adapters.py +337 -0
- traceforge/bench/checks.py +410 -0
- traceforge/bench/cli.py +121 -0
- traceforge/bench/design.py +439 -0
- traceforge/bench/kicad.py +99 -0
- traceforge/bench/partscheck.py +159 -0
- traceforge/bench/review.py +100 -0
- traceforge/bench/runner.py +226 -0
- traceforge/bench/score.py +204 -0
- traceforge/bench/tasks.py +102 -0
- traceforge/chat.py +1185 -0
- traceforge/checks.py +328 -0
- traceforge/checks_sides.py +118 -0
- traceforge/cli.py +2224 -0
- traceforge/clipboard.py +107 -0
- traceforge/config.py +299 -0
- traceforge/context.py +276 -0
- traceforge/design/__init__.py +1 -0
- traceforge/design/constraints.py +397 -0
- traceforge/design/generate.py +1789 -0
- traceforge/design/outline.py +905 -0
- traceforge/design/schematic_layout.py +932 -0
- traceforge/design/schematic_lint.py +329 -0
- traceforge/design/schematic_route.py +207 -0
- traceforge/design/sheets.py +854 -0
- traceforge/design/sides.py +328 -0
- traceforge/design/spec.py +462 -0
- traceforge/design/specsheet.py +2351 -0
- traceforge/design/specsheet_render.py +183 -0
- traceforge/desktop/__init__.py +109 -0
- traceforge/desktop/keys.py +90 -0
- traceforge/desktop/project.py +443 -0
- traceforge/desktop/review.py +20 -0
- traceforge/desktop/server.py +1277 -0
- traceforge/desktop/static/app.css +223 -0
- traceforge/desktop/static/app.js +683 -0
- traceforge/desktop/static/highlight.js +77 -0
- traceforge/desktop/static/index.html +89 -0
- traceforge/desktop/static/md.js +90 -0
- traceforge/desktop/static/viewer.js +213 -0
- traceforge/desktop/symbols.py +212 -0
- traceforge/desktop/update.py +65 -0
- traceforge/diagnostics.py +67 -0
- traceforge/errata.py +219 -0
- traceforge/fab.py +108 -0
- traceforge/fab_layers.py +44 -0
- traceforge/features/__init__.py +6 -0
- traceforge/features/candidates.py +117 -0
- traceforge/features/cli.py +200 -0
- traceforge/features/package.py +433 -0
- traceforge/features/revdiff.py +224 -0
- traceforge/features/sarif.py +145 -0
- traceforge/features/viewer.py +265 -0
- traceforge/firmware/__init__.py +3 -0
- traceforge/firmware/cli.py +174 -0
- traceforge/firmware/drivers.py +372 -0
- traceforge/firmware/drivers_data.py +256 -0
- traceforge/firmware/mcus.py +177 -0
- traceforge/firmware/netlist.py +55 -0
- traceforge/firmware/pinmap.py +296 -0
- traceforge/firmware/scaffold.py +466 -0
- traceforge/firmware/toolchain.py +262 -0
- traceforge/hooks.py +237 -0
- traceforge/kicad/__init__.py +0 -0
- traceforge/kicad/cli.py +519 -0
- traceforge/kicad/diffpair.py +174 -0
- traceforge/kicad/edgegeom.py +191 -0
- traceforge/kicad/edit.py +405 -0
- traceforge/kicad/escape.py +1410 -0
- traceforge/kicad/formats.py +1073 -0
- traceforge/kicad/impedance.py +320 -0
- traceforge/kicad/install.py +75 -0
- traceforge/kicad/libraries.py +490 -0
- traceforge/kicad/models3d.py +942 -0
- traceforge/kicad/outputs.py +81 -0
- traceforge/kicad/pcb.py +787 -0
- traceforge/kicad/planes.py +1242 -0
- traceforge/kicad/project.py +136 -0
- traceforge/kicad/route.py +2698 -0
- traceforge/kicad/routegeom.py +164 -0
- traceforge/kicad/schematic.py +268 -0
- traceforge/kicad/sides.py +34 -0
- traceforge/kicad/stackup.py +424 -0
- traceforge/kicad_plugin/__init__.py +12 -0
- traceforge/kicad_plugin/actions.py +92 -0
- traceforge/kicad_plugin/cli.py +66 -0
- traceforge/kicad_plugin/client.py +86 -0
- traceforge/kicad_plugin/context.py +257 -0
- traceforge/kicad_plugin/discovery.py +81 -0
- traceforge/kicad_plugin/install.py +203 -0
- traceforge/kicad_plugin/overlay.py +60 -0
- traceforge/kicad_plugin/payload/action_plugin.py +65 -0
- traceforge/kicad_plugin/payload/ipc_main.py +42 -0
- traceforge/kicad_plugin/payload/plugin.json +90 -0
- traceforge/kicad_plugin/payload/requirements.txt +1 -0
- traceforge/kicad_plugin/views.py +202 -0
- traceforge/langchain.py +57 -0
- traceforge/local.py +580 -0
- traceforge/mcp_server.py +304 -0
- traceforge/models.py +310 -0
- traceforge/next_steps.py +110 -0
- traceforge/parts/__init__.py +52 -0
- traceforge/parts/alternates.py +488 -0
- traceforge/parts/bom.py +423 -0
- traceforge/parts/brief.py +705 -0
- traceforge/parts/catalog.py +297 -0
- traceforge/parts/cli_sourcing.py +156 -0
- traceforge/parts/data/duty_rates.yaml +37 -0
- traceforge/parts/data/hs_codes.yaml +103 -0
- traceforge/parts/datasheet.py +278 -0
- traceforge/parts/fx.py +100 -0
- traceforge/parts/model.py +185 -0
- traceforge/parts/net.py +187 -0
- traceforge/parts/offers.py +46 -0
- traceforge/parts/packages.py +397 -0
- traceforge/parts/pinout.py +714 -0
- traceforge/parts/providers/__init__.py +17 -0
- traceforge/parts/providers/base.py +100 -0
- traceforge/parts/providers/jlc.py +364 -0
- traceforge/parts/providers/keyed.py +661 -0
- traceforge/parts/report.py +508 -0
- traceforge/parts/supply.py +330 -0
- traceforge/parts/verify.py +455 -0
- traceforge/parts/web.py +575 -0
- traceforge/plans.py +70 -0
- traceforge/preferences.py +127 -0
- traceforge/prices.py +130 -0
- traceforge/prices.yaml +28 -0
- traceforge/prompts/__init__.py +54 -0
- traceforge/prompts/system.md +103 -0
- traceforge/providers.py +764 -0
- traceforge/remote/__init__.py +33 -0
- traceforge/remote/client.py +422 -0
- traceforge/remote/oidc.py +240 -0
- traceforge/remote/orgs.py +510 -0
- traceforge/remote/server.py +690 -0
- traceforge/remote/store.py +100 -0
- traceforge/review_changes.py +187 -0
- traceforge/routing.py +99 -0
- traceforge/runtime.py +435 -0
- traceforge/sessions.py +324 -0
- traceforge/sexpr.py +279 -0
- traceforge/sim/__init__.py +17 -0
- traceforge/sim/acmath.py +234 -0
- traceforge/sim/circuit.py +499 -0
- traceforge/sim/cli.py +162 -0
- traceforge/sim/engine.py +172 -0
- traceforge/sim/ltspice.py +175 -0
- traceforge/sim/measure.py +123 -0
- traceforge/sim/models.py +76 -0
- traceforge/sim/project.py +218 -0
- traceforge/sim/rawfile.py +123 -0
- traceforge/sim/results.py +298 -0
- traceforge/sim/tune.py +172 -0
- traceforge/skill_commands.py +165 -0
- traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
- traceforge/skill_library/compliance-review/SKILL.md +51 -0
- traceforge/skill_library/design-review/SKILL.md +47 -0
- traceforge/skill_library/errata-hunt/SKILL.md +30 -0
- traceforge/skill_library/firmware/SKILL.md +26 -0
- traceforge/skill_library/power-budget/SKILL.md +27 -0
- traceforge/skill_library/release-package/SKILL.md +29 -0
- traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
- traceforge/skill_library/thermal-review/SKILL.md +15 -0
- traceforge/skills.py +131 -0
- traceforge/thermal.py +557 -0
- traceforge/tools/__init__.py +48 -0
- traceforge/tools/agent_tools.py +213 -0
- traceforge/tools/base.py +303 -0
- traceforge/tools/batch.py +155 -0
- traceforge/tools/connections.py +245 -0
- traceforge/tools/files.py +777 -0
- traceforge/tools/firmware_tools.py +218 -0
- traceforge/tools/kicad_tools.py +1072 -0
- traceforge/tools/models3d_tools.py +141 -0
- traceforge/tools/parts_tools.py +473 -0
- traceforge/tools/processes.py +246 -0
- traceforge/tools/review_tools.py +178 -0
- traceforge/tools/sim_tools.py +230 -0
- traceforge/tui.py +620 -0
- traceforge/updates.py +124 -0
- traceforge/validate/__init__.py +77 -0
- traceforge/validate/builtins.py +1091 -0
- traceforge/validate/circuits.py +1240 -0
- traceforge/validate/config.py +410 -0
- traceforge/validate/default.yaml +113 -0
- traceforge/validate/design.py +191 -0
- traceforge/validate/explain.py +112 -0
- traceforge/validate/grounding.py +169 -0
- traceforge/validate/ipc2221.py +350 -0
- traceforge/validate/multilayer.py +805 -0
- traceforge/validate/netmodel.py +261 -0
- traceforge/validate/partcheck.py +472 -0
- traceforge/validate/report.py +280 -0
- traceforge/validate/review.py +322 -0
- traceforge/validate/runner.py +662 -0
- traceforge/workspace.py +171 -0
- traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
- traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
- traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
- traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|