iceberg-subzero 0.1.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.
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: iceberg-subzero
3
+ Version: 0.1.0
4
+ Summary: Python client for the Subzero tokenization vault and LLM proxy
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: httpx>=0.28.0
7
+ Provides-Extra: auth
8
+ Requires-Dist: pyotp>=2.9.0; extra == 'auth'
9
+ Provides-Extra: dev
10
+ Requires-Dist: openai>=1.0.0; extra == 'dev'
11
+ Requires-Dist: pyotp>=2.9.0; extra == 'dev'
12
+ Requires-Dist: pytest>=8.3.0; extra == 'dev'
13
+ Requires-Dist: respx>=0.22.0; extra == 'dev'
14
+ Provides-Extra: openai
15
+ Requires-Dist: openai>=1.0.0; extra == 'openai'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # Subzero Python SDK
19
+
20
+ Thin Python client for the [Subzero](../api/) tokenization vault and LLM proxy.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ cd python-sdk
26
+ pip install -e ".[dev,openai,auth]"
27
+ ```
28
+
29
+ The optional `auth` extra adds `pyotp` for TOTP code generation during dashboard login.
30
+
31
+ ## Two auth planes
32
+
33
+ | Plane | Credential | Use for |
34
+ |-------|------------|---------|
35
+ | **Server integration** | API key (`sz_live_...`) | `tokenize`, `search`, `reveal`, `proxy`, tenant admin after bootstrap |
36
+ | **Dashboard / human** | JWT access token | `auth.*`, `members.*`, `admin.create_tenant`, `admin.get_tenant` |
37
+
38
+ Pass an API key, an access token, or both when constructing the client:
39
+
40
+ ```python
41
+ from subzero import SubzeroClient
42
+
43
+ # Vault + proxy (server-side)
44
+ vault = SubzeroClient(api_key="sz_live_...", base_url="http://127.0.0.1:8000")
45
+
46
+ # Dashboard session (platform or tenant admin JWT)
47
+ dashboard = SubzeroClient(access_token="eyJ...", base_url="http://127.0.0.1:8000")
48
+ ```
49
+
50
+ ## Quick start (vault)
51
+
52
+ ```python
53
+ from subzero import SubzeroClient
54
+
55
+ client = SubzeroClient(
56
+ api_key="sz_live_...",
57
+ base_url="http://127.0.0.1:8000",
58
+ )
59
+ client.ready()
60
+
61
+ token = client.tokenize("SSN", "123-45-6789").token
62
+ value = client.reveal(token).value
63
+ ```
64
+
65
+ ## Platform admin login (tenant provisioning)
66
+
67
+ `POST /v1/tenants` requires a platform-admin JWT. Log in with MFA, then create tenants:
68
+
69
+ ```python
70
+ from subzero import SubzeroClient
71
+
72
+ client = SubzeroClient(base_url="http://127.0.0.1:8000")
73
+ client.ready()
74
+
75
+ client.auth.login_platform(
76
+ email="admin@iceberg.local",
77
+ password="...",
78
+ totp_code="123456", # or totp_secret="BASE32..." with pip install 'subzero[auth]'
79
+ )
80
+
81
+ tenant = client.admin.create_tenant(name="Acme", slug="acme")
82
+ admin_key = tenant.bootstrap_api_key
83
+
84
+ # Switch to tenant admin API key for day-to-day vault/proxy setup
85
+ admin = SubzeroClient(api_key=admin_key, base_url="http://127.0.0.1:8000")
86
+ admin.admin.create_entity_type(tenant.id, name="SSN", deterministic=True)
87
+ ```
88
+
89
+ Tenant member invites and management use `client.members` with a tenant-admin or platform-admin JWT.
90
+
91
+ ## API key scopes
92
+
93
+ | Scope | Vault methods | Notes |
94
+ |-------|---------------|-------|
95
+ | `tokenize` | `tokenize`, `search` | No plaintext return |
96
+ | `reveal` | `reveal` | Server-side plaintext reveal; requires matching policy rule |
97
+ | `reveal_grant` | `create_reveal_grant` | Mint browser reveal grants only; requires matching policy rule |
98
+ | `proxy` | `proxy.chat.completions` | In-flight tokenization |
99
+ | `admin` | All of the above + `admin.*` + `delete_token` | Bypasses reveal policy |
100
+
101
+ **Browser reveal:** the iframe calls `POST /v1/browser/reveal` with a server-minted grant. Your BFF uses a **`reveal_grant`** key to call `create_reveal_grant(token, client_public_key_jwk=..., allowed_origin=...)`. Keep **`reveal`** keys for server pipelines that need `client.reveal(token).value`.
102
+
103
+ ```python
104
+ grant = client.create_reveal_grant(
105
+ token,
106
+ client_public_key_jwk=jwk_from_iframe,
107
+ allowed_origin="https://app.yourcompany.com",
108
+ )
109
+ ```
110
+
111
+ **Delete requires admin.** There is no delegatable delete-scoped API key at the HTTP layer. `delete_token()` needs an admin key even though policy rules support a `delete` action at the service layer.
112
+
113
+ ## Proxy vs reveal vs detokenize
114
+
115
+ - **Vault reveal:** `client.reveal(token)` — `POST /v1/reveal`, reveal-scoped key + policy
116
+ - **Proxy chat:** `client.proxy.chat.completions(...)` — tokenizes declared patterns in-flight
117
+ - **Proxy detokenize:** pass `detokenize=True` or use OpenAI helper below — governed by reveal policy for the proxy key, not the reveal endpoint
118
+
119
+ ```python
120
+ from subzero import create_openai_client
121
+
122
+ client = create_openai_client(
123
+ api_key="sz_live_...", # Subzero proxy key
124
+ base_url="http://127.0.0.1:8000/v1",
125
+ detokenize=True, # X-Subzero-Detokenize: true via default_headers
126
+ )
127
+ client.chat.completions.create(model="gpt-4o", stream=False, messages=[...])
128
+ ```
129
+
130
+ ## Examples
131
+
132
+ With the API running (`docker compose up` or `uvicorn`):
133
+
134
+ ```bash
135
+ export SUBZERO_PLATFORM_EMAIL=admin@iceberg.local
136
+ export SUBZERO_PLATFORM_PASSWORD=...
137
+ export SUBZERO_TOTP_CODE=123456 # or SUBZERO_TOTP_SECRET / SUBZERO_ACCESS_TOKEN
138
+
139
+ python examples/hero_demo.py
140
+ python examples/vault_loop.py
141
+ ```
142
+
143
+ Set `SUBZERO_BASE_URL` and `OPENAI_API_KEY` as needed.
144
+
145
+ ## Tests
146
+
147
+ ```bash
148
+ pytest
149
+ ```
150
+
151
+ ## PyPI
152
+
153
+ Local editable install only for now. `# TODO: twine upload` when ready to publish.
@@ -0,0 +1,13 @@
1
+ subzero/__init__.py,sha256=MmL2CriJloDNmHj6m5qwi0W4OjUm4SWFV8zf6ki1SyU,1016
2
+ subzero/_http.py,sha256=VByOmsY7Xxhbtfdz5C4ksBkylhsgG2Ie_IxLLNrXIuU,6429
3
+ subzero/admin.py,sha256=WgZeiL72JAOCN3LBIYYHLSLYkQD2IW89TpUCJC8T74o,9529
4
+ subzero/auth.py,sha256=kCTG_7SFaAXdldoFax_ajGCEB-Dk5FBJVEvBu_z2Hek,7944
5
+ subzero/client.py,sha256=Tenrn14QamCZmP9GCNFHdUiHsNyVXOPLxZXuxj53L6w,3203
6
+ subzero/exceptions.py,sha256=jCq4Ec8Pemty_347pCM6epcD54SZtygBIWq6C4HZE3k,812
7
+ subzero/members.py,sha256=snUOMX_C3iBe8JCAA-s6e-fVnzB9aOkzlUiJ5w-MimM,2230
8
+ subzero/openai.py,sha256=tfWvAjclLS-ZRLrur3S4QCK5XPcWsM2nASIBpFWib_k,786
9
+ subzero/proxy.py,sha256=f3EULiF7jSWNOt1vkcPZLv9cWNLTIkcoeLpPCFAq9yo,1338
10
+ subzero/types.py,sha256=xvVjGyH31p4LmY1Hiwc5HhXFY9NZi_BDManED0U0EZc,3402
11
+ iceberg_subzero-0.1.0.dist-info/METADATA,sha256=Adkc7jVtkwf9SCBiONAtrnpHgkntkZFLS0q20xXdSVE,5001
12
+ iceberg_subzero-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
13
+ iceberg_subzero-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
subzero/__init__.py ADDED
@@ -0,0 +1,43 @@
1
+ from subzero.auth import AuthResource
2
+ from subzero.client import SubzeroClient
3
+ from subzero.exceptions import (
4
+ AuthenticationError,
5
+ AuthorizationError,
6
+ ConflictError,
7
+ NotFoundError,
8
+ PolicyDeniedError,
9
+ SubzeroAPIError,
10
+ SubzeroNotReadyError,
11
+ )
12
+ from subzero.members import MembersResource
13
+ from subzero.openai import create_openai_client
14
+ from subzero.types import (
15
+ AcceptInviteResult,
16
+ LoginResult,
17
+ MeResult,
18
+ MemberResult,
19
+ MfaEnrollConfirmResult,
20
+ MfaEnrollStartResult,
21
+ TokenResult,
22
+ )
23
+
24
+ __all__ = [
25
+ "SubzeroClient",
26
+ "AuthResource",
27
+ "MembersResource",
28
+ "create_openai_client",
29
+ "LoginResult",
30
+ "TokenResult",
31
+ "MfaEnrollStartResult",
32
+ "MfaEnrollConfirmResult",
33
+ "AcceptInviteResult",
34
+ "MeResult",
35
+ "MemberResult",
36
+ "AuthenticationError",
37
+ "AuthorizationError",
38
+ "ConflictError",
39
+ "NotFoundError",
40
+ "PolicyDeniedError",
41
+ "SubzeroAPIError",
42
+ "SubzeroNotReadyError",
43
+ ]
subzero/_http.py ADDED
@@ -0,0 +1,195 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any, Literal
5
+
6
+ import httpx
7
+
8
+ from subzero.exceptions import (
9
+ AuthenticationError,
10
+ AuthorizationError,
11
+ ConflictError,
12
+ NotFoundError,
13
+ PolicyDeniedError,
14
+ SubzeroAPIError,
15
+ SubzeroNotReadyError,
16
+ )
17
+
18
+ AuthMode = bool | Literal["api_key", "access_token", "none"]
19
+
20
+
21
+ class HTTPClient:
22
+ def __init__(
23
+ self,
24
+ *,
25
+ api_key: str | None = None,
26
+ access_token: str | None = None,
27
+ base_url: str,
28
+ timeout: float = 60.0,
29
+ ) -> None:
30
+ self._api_key = api_key
31
+ self._access_token = access_token
32
+ self._base_url = base_url.rstrip("/")
33
+ self._client = httpx.Client(base_url=self._base_url, timeout=timeout)
34
+
35
+ @property
36
+ def access_token(self) -> str | None:
37
+ return self._access_token
38
+
39
+ def set_access_token(self, token: str | None) -> None:
40
+ self._access_token = token
41
+
42
+ def close(self) -> None:
43
+ self._client.close()
44
+
45
+ def __enter__(self) -> HTTPClient:
46
+ return self
47
+
48
+ def __exit__(self, *args: object) -> None:
49
+ self.close()
50
+
51
+ def _headers(
52
+ self,
53
+ extra: dict[str, str] | None = None,
54
+ *,
55
+ auth: AuthMode = True,
56
+ ) -> dict[str, str]:
57
+ headers: dict[str, str] = {}
58
+ if auth is True:
59
+ if self._api_key is not None:
60
+ headers["Authorization"] = f"Bearer {self._api_key}"
61
+ elif self._access_token is not None:
62
+ headers["Authorization"] = f"Bearer {self._access_token}"
63
+ elif auth == "api_key":
64
+ if self._api_key is not None:
65
+ headers["Authorization"] = f"Bearer {self._api_key}"
66
+ elif auth == "access_token":
67
+ if self._access_token is not None:
68
+ headers["Authorization"] = f"Bearer {self._access_token}"
69
+ if extra:
70
+ headers.update(extra)
71
+ return headers
72
+
73
+ def _raise_for_status(self, response: httpx.Response) -> None:
74
+ if response.is_success:
75
+ return
76
+
77
+ reason: str | None = None
78
+ detail = response.text
79
+ try:
80
+ body = response.json()
81
+ if isinstance(body, dict):
82
+ detail = str(body.get("detail", detail))
83
+ raw_reason = body.get("reason")
84
+ if isinstance(raw_reason, str):
85
+ reason = raw_reason
86
+ except json.JSONDecodeError:
87
+ pass
88
+
89
+ status = response.status_code
90
+ if status == 401:
91
+ raise AuthenticationError(detail, status_code=status, reason=reason, response=response)
92
+ if status == 403:
93
+ if reason == "policy_denied":
94
+ raise PolicyDeniedError(detail, status_code=status, reason=reason, response=response)
95
+ if reason in {"insufficient_scope", "tenant_mismatch"}:
96
+ raise AuthorizationError(detail, status_code=status, reason=reason, response=response)
97
+ raise SubzeroAPIError(detail, status_code=status, reason=reason, response=response)
98
+ if status == 404:
99
+ raise NotFoundError(detail, status_code=status, reason=reason, response=response)
100
+ if status == 409:
101
+ raise ConflictError(detail, status_code=status, reason=reason, response=response)
102
+ raise SubzeroAPIError(detail, status_code=status, reason=reason, response=response)
103
+
104
+ def request(
105
+ self,
106
+ method: str,
107
+ path: str,
108
+ *,
109
+ json_body: dict[str, Any] | None = None,
110
+ headers: dict[str, str] | None = None,
111
+ params: dict[str, Any] | None = None,
112
+ auth: AuthMode = True,
113
+ ) -> httpx.Response:
114
+ request_headers = self._headers(headers, auth=auth)
115
+ response = self._client.request(
116
+ method,
117
+ path,
118
+ json=json_body,
119
+ headers=request_headers,
120
+ params=params,
121
+ )
122
+ self._raise_for_status(response)
123
+ return response
124
+
125
+ def get_json(
126
+ self,
127
+ path: str,
128
+ *,
129
+ auth: AuthMode = True,
130
+ params: dict[str, Any] | None = None,
131
+ ) -> Any:
132
+ response = self.request("GET", path, auth=auth, params=params)
133
+ if not response.content:
134
+ return None
135
+ return response.json()
136
+
137
+ def post_json(
138
+ self,
139
+ path: str,
140
+ body: dict[str, Any] | None = None,
141
+ *,
142
+ auth: AuthMode = True,
143
+ headers: dict[str, str] | None = None,
144
+ ) -> Any:
145
+ response = self.request("POST", path, json_body=body, auth=auth, headers=headers)
146
+ if not response.content:
147
+ return None
148
+ return response.json()
149
+
150
+ def patch_json(
151
+ self,
152
+ path: str,
153
+ body: dict[str, Any],
154
+ *,
155
+ auth: AuthMode = True,
156
+ ) -> Any:
157
+ response = self.request("PATCH", path, json_body=body, auth=auth)
158
+ if not response.content:
159
+ return None
160
+ return response.json()
161
+
162
+ def delete(self, path: str, *, auth: AuthMode = True) -> None:
163
+ self.request("DELETE", path, auth=auth)
164
+
165
+ @staticmethod
166
+ def encode_token_path(token: str) -> str:
167
+ from urllib.parse import quote
168
+
169
+ return quote(token, safe="")
170
+
171
+ def health(self) -> dict[str, Any]:
172
+ return self.get_json("/v1/health", auth="none")
173
+
174
+ def ready(self, base_url: str) -> dict[str, Any]:
175
+ try:
176
+ response = self._client.get("/v1/ready")
177
+ except httpx.RequestError as exc:
178
+ raise SubzeroNotReadyError(
179
+ f"Subzero API not reachable at {base_url} — is `docker compose up` running? "
180
+ f"Migrations applied?",
181
+ ) from exc
182
+
183
+ if response.status_code != 200:
184
+ raise SubzeroNotReadyError(
185
+ f"Subzero API not ready at {base_url} — is `docker compose up` running? "
186
+ f"Migrations applied?",
187
+ )
188
+
189
+ body = response.json()
190
+ if not isinstance(body, dict) or body.get("status") != "ready":
191
+ raise SubzeroNotReadyError(
192
+ f"Subzero API not ready at {base_url} — is `docker compose up` running? "
193
+ f"Migrations applied?",
194
+ )
195
+ return body
subzero/admin.py ADDED
@@ -0,0 +1,282 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Any, Literal
5
+
6
+ from subzero._http import HTTPClient
7
+ from subzero.types import (
8
+ ApiKeyResult,
9
+ ApiKeyScope,
10
+ AuditEventResult,
11
+ AuditLogPageResult,
12
+ EntityTypeResult,
13
+ LlmConfigResult,
14
+ PolicyAction,
15
+ PolicyRuleResult,
16
+ PublishableKeyResult,
17
+ TenantResult,
18
+ parse_datetime,
19
+ )
20
+
21
+
22
+ class AdminResource:
23
+ def __init__(self, http: HTTPClient) -> None:
24
+ self._http = http
25
+
26
+ def create_tenant(self, *, name: str, slug: str) -> TenantResult:
27
+ body = self._http.post_json(
28
+ "/v1/tenants",
29
+ {"name": name, "slug": slug},
30
+ auth="access_token",
31
+ )
32
+ return TenantResult(
33
+ id=body["id"],
34
+ name=body["name"],
35
+ slug=body["slug"],
36
+ created_at=parse_datetime(body["created_at"]),
37
+ bootstrap_api_key=body.get("bootstrap_api_key"),
38
+ )
39
+
40
+ def get_tenant(self, tenant_id: str) -> TenantResult:
41
+ body = self._http.get_json(f"/v1/tenants/{tenant_id}", auth="access_token")
42
+ return TenantResult(
43
+ id=body["id"],
44
+ name=body["name"],
45
+ slug=body["slug"],
46
+ created_at=parse_datetime(body["created_at"]),
47
+ )
48
+
49
+ def create_entity_type(
50
+ self,
51
+ tenant_id: str,
52
+ *,
53
+ name: str,
54
+ description: str | None = None,
55
+ deterministic: bool = False,
56
+ match_pattern: str | None = None,
57
+ ) -> EntityTypeResult:
58
+ payload: dict[str, Any] = {
59
+ "name": name,
60
+ "deterministic": deterministic,
61
+ }
62
+ if description is not None:
63
+ payload["description"] = description
64
+ if match_pattern is not None:
65
+ payload["match_pattern"] = match_pattern
66
+ body = self._http.post_json(f"/v1/tenants/{tenant_id}/entity-types", payload)
67
+ return _entity_type_from_body(body)
68
+
69
+ def list_entity_types(self, tenant_id: str) -> list[EntityTypeResult]:
70
+ body = self._http.get_json(f"/v1/tenants/{tenant_id}/entity-types")
71
+ return [_entity_type_from_body(item) for item in body]
72
+
73
+ def create_policy(
74
+ self,
75
+ tenant_id: str,
76
+ *,
77
+ api_key_id: str,
78
+ entity_type: str,
79
+ action: PolicyAction,
80
+ ) -> PolicyRuleResult:
81
+ body = self._http.post_json(
82
+ f"/v1/tenants/{tenant_id}/policies",
83
+ {
84
+ "api_key_id": api_key_id,
85
+ "entity_type": entity_type,
86
+ "action": action,
87
+ },
88
+ )
89
+ return _policy_from_body(body)
90
+
91
+ def list_policies(self, tenant_id: str) -> list[PolicyRuleResult]:
92
+ body = self._http.get_json(f"/v1/tenants/{tenant_id}/policies")
93
+ return [_policy_from_body(item) for item in body]
94
+
95
+ def delete_policy(self, tenant_id: str, rule_id: str) -> None:
96
+ self._http.delete(f"/v1/tenants/{tenant_id}/policies/{rule_id}")
97
+
98
+ def create_api_key(
99
+ self,
100
+ tenant_id: str,
101
+ *,
102
+ name: str,
103
+ scope: ApiKeyScope,
104
+ ) -> ApiKeyResult:
105
+ body = self._http.post_json(
106
+ f"/v1/tenants/{tenant_id}/api-keys",
107
+ {"name": name, "scope": scope},
108
+ )
109
+ return _api_key_from_body(body, include_secret=True)
110
+
111
+ def list_api_keys(self, tenant_id: str) -> list[ApiKeyResult]:
112
+ body = self._http.get_json(f"/v1/tenants/{tenant_id}/api-keys")
113
+ return [_api_key_from_body(item) for item in body]
114
+
115
+ def revoke_api_key(self, tenant_id: str, key_id: str) -> ApiKeyResult:
116
+ response = self._http.request("DELETE", f"/v1/tenants/{tenant_id}/api-keys/{key_id}")
117
+ return _api_key_from_body(response.json())
118
+
119
+ def create_publishable_key(
120
+ self,
121
+ tenant_id: str,
122
+ *,
123
+ name: str,
124
+ allowed_origins: list[str],
125
+ rate_limit_rpm: int = 60,
126
+ integration_mode: Literal["secure", "lite"] = "secure",
127
+ risk_acknowledged: bool = False,
128
+ ) -> PublishableKeyResult:
129
+ payload: dict[str, Any] = {
130
+ "name": name,
131
+ "allowed_origins": allowed_origins,
132
+ "rate_limit_rpm": rate_limit_rpm,
133
+ "integration_mode": integration_mode,
134
+ }
135
+ if integration_mode == "lite":
136
+ payload["risk_acknowledged"] = risk_acknowledged
137
+ body = self._http.post_json(
138
+ f"/v1/tenants/{tenant_id}/publishable-keys",
139
+ payload,
140
+ )
141
+ return _publishable_key_from_body(body)
142
+
143
+ def list_publishable_keys(self, tenant_id: str) -> list[PublishableKeyResult]:
144
+ body = self._http.get_json(f"/v1/tenants/{tenant_id}/publishable-keys")
145
+ return [_publishable_key_from_body(item) for item in body]
146
+
147
+ def revoke_publishable_key(self, tenant_id: str, key_id: str) -> PublishableKeyResult:
148
+ response = self._http.request(
149
+ "DELETE", f"/v1/tenants/{tenant_id}/publishable-keys/{key_id}"
150
+ )
151
+ return _publishable_key_from_body(response.json())
152
+
153
+ def create_llm_config(
154
+ self,
155
+ tenant_id: str,
156
+ *,
157
+ provider: str = "openai",
158
+ base_url: str,
159
+ api_key: str,
160
+ ) -> LlmConfigResult:
161
+ body = self._http.post_json(
162
+ f"/v1/tenants/{tenant_id}/llm-config",
163
+ {
164
+ "provider": provider,
165
+ "base_url": base_url,
166
+ "api_key": api_key,
167
+ },
168
+ )
169
+ return _llm_config_from_body(body)
170
+
171
+ def get_llm_config(self, tenant_id: str) -> LlmConfigResult | None:
172
+ body = self._http.get_json(f"/v1/tenants/{tenant_id}/llm-config")
173
+ if body is None:
174
+ return None
175
+ return _llm_config_from_body(body)
176
+
177
+ def list_audit_events(
178
+ self,
179
+ tenant_id: str,
180
+ *,
181
+ limit: int = 50,
182
+ offset: int = 0,
183
+ order: Literal["asc", "desc"] = "desc",
184
+ event_type: str | None = None,
185
+ actor: str | None = None,
186
+ date_from: datetime | None = None,
187
+ date_to: datetime | None = None,
188
+ ) -> AuditLogPageResult:
189
+ params: dict[str, Any] = {
190
+ "limit": limit,
191
+ "offset": offset,
192
+ "order": order,
193
+ }
194
+ if event_type is not None:
195
+ params["event_type"] = event_type
196
+ if actor is not None:
197
+ params["actor"] = actor
198
+ if date_from is not None:
199
+ params["date_from"] = date_from.isoformat()
200
+ if date_to is not None:
201
+ params["date_to"] = date_to.isoformat()
202
+
203
+ body = self._http.get_json(f"/v1/tenants/{tenant_id}/audit", params=params)
204
+ return _audit_log_page_from_body(body)
205
+
206
+
207
+ def _entity_type_from_body(body: dict[str, Any]) -> EntityTypeResult:
208
+ return EntityTypeResult(
209
+ id=body["id"],
210
+ name=body["name"],
211
+ description=body.get("description"),
212
+ deterministic=body["deterministic"],
213
+ match_pattern=body.get("match_pattern"),
214
+ created_at=parse_datetime(body["created_at"]),
215
+ )
216
+
217
+
218
+ def _policy_from_body(body: dict[str, Any]) -> PolicyRuleResult:
219
+ return PolicyRuleResult(
220
+ id=body["id"],
221
+ principal=body["principal"],
222
+ entity_type=body["entity_type"],
223
+ action=body["action"],
224
+ created_at=parse_datetime(body["created_at"]),
225
+ )
226
+
227
+
228
+ def _api_key_from_body(body: dict[str, Any], *, include_secret: bool = False) -> ApiKeyResult:
229
+ return ApiKeyResult(
230
+ id=body["id"],
231
+ name=body["name"],
232
+ prefix=body["prefix"],
233
+ scope=body["scope"],
234
+ revoked_at=parse_datetime(body["revoked_at"]) if body.get("revoked_at") else None,
235
+ created_at=parse_datetime(body["created_at"]),
236
+ secret=body.get("secret") if include_secret else None,
237
+ )
238
+
239
+
240
+ def _publishable_key_from_body(body: dict[str, Any]) -> PublishableKeyResult:
241
+ return PublishableKeyResult(
242
+ id=body["id"],
243
+ name=body["name"],
244
+ key=body["key"],
245
+ allowed_origins=body["allowed_origins"],
246
+ rate_limit_rpm=body["rate_limit_rpm"],
247
+ integration_mode=body.get("integration_mode", "secure"),
248
+ revoked_at=parse_datetime(body["revoked_at"]) if body.get("revoked_at") else None,
249
+ created_at=parse_datetime(body["created_at"]),
250
+ )
251
+
252
+
253
+ def _llm_config_from_body(body: dict[str, Any]) -> LlmConfigResult:
254
+ return LlmConfigResult(
255
+ id=body["id"],
256
+ provider=body["provider"],
257
+ base_url=body["base_url"],
258
+ is_active=body["is_active"],
259
+ created_at=parse_datetime(body["created_at"]),
260
+ )
261
+
262
+
263
+ def _audit_event_from_body(body: dict[str, Any]) -> AuditEventResult:
264
+ return AuditEventResult(
265
+ id=body["id"],
266
+ tenant_id=body["tenant_id"],
267
+ event_type=body["event_type"],
268
+ actor=body["actor"],
269
+ resource=body.get("resource"),
270
+ outcome=body["outcome"],
271
+ metadata=body.get("metadata"),
272
+ created_at=parse_datetime(body["created_at"]),
273
+ )
274
+
275
+
276
+ def _audit_log_page_from_body(body: dict[str, Any]) -> AuditLogPageResult:
277
+ return AuditLogPageResult(
278
+ items=[_audit_event_from_body(item) for item in body["items"]],
279
+ total=body["total"],
280
+ limit=body["limit"],
281
+ offset=body["offset"],
282
+ )
subzero/auth.py ADDED
@@ -0,0 +1,232 @@
1
+ from __future__ import annotations
2
+
3
+ from subzero._http import HTTPClient
4
+ from subzero.exceptions import SubzeroAPIError
5
+ from subzero.types import (
6
+ AcceptInviteResult,
7
+ LoginResult,
8
+ MeResult,
9
+ MfaEnrollConfirmResult,
10
+ MfaEnrollStartResult,
11
+ TokenResult,
12
+ parse_datetime,
13
+ )
14
+
15
+
16
+ def _totp_code(secret: str) -> str:
17
+ try:
18
+ import pyotp
19
+ except ImportError as exc:
20
+ raise SubzeroAPIError(
21
+ "pyotp is required to generate TOTP codes — pip install 'subzero[auth]' "
22
+ "or pass totp_code explicitly.",
23
+ ) from exc
24
+ return pyotp.TOTP(secret).now()
25
+
26
+
27
+ class AuthResource:
28
+ """Dashboard human auth — login, MFA, refresh (cookie-based), password reset."""
29
+
30
+ def __init__(self, http: HTTPClient) -> None:
31
+ self._http = http
32
+
33
+ def login(
34
+ self,
35
+ *,
36
+ email: str,
37
+ password: str,
38
+ tenant_slug: str | None = None,
39
+ ) -> LoginResult:
40
+ payload: dict[str, str] = {"email": email, "password": password}
41
+ if tenant_slug is not None:
42
+ payload["tenant_slug"] = tenant_slug
43
+ body = self._http.post_json("/v1/auth/login", payload, auth="none")
44
+ return LoginResult(
45
+ access_token=body.get("access_token"),
46
+ pre_auth_token=body.get("pre_auth_token"),
47
+ mfa_required=body.get("mfa_required", False),
48
+ mfa_enrollment_required=body.get("mfa_enrollment_required", False),
49
+ )
50
+
51
+ def mfa_verify(
52
+ self,
53
+ *,
54
+ pre_auth_token: str,
55
+ code: str | None = None,
56
+ recovery_code: str | None = None,
57
+ ) -> TokenResult:
58
+ payload: dict[str, str] = {"pre_auth_token": pre_auth_token}
59
+ if code is not None:
60
+ payload["code"] = code
61
+ if recovery_code is not None:
62
+ payload["recovery_code"] = recovery_code
63
+ response = self._http._client.post(
64
+ "/v1/auth/mfa/verify",
65
+ json=payload,
66
+ headers={"Content-Type": "application/json"},
67
+ )
68
+ self._http._raise_for_status(response)
69
+ body = response.json()
70
+ token = TokenResult(access_token=body["access_token"])
71
+ self._http.set_access_token(token.access_token)
72
+ return token
73
+
74
+ def mfa_enroll_start(self, *, pre_auth_token: str) -> MfaEnrollStartResult:
75
+ body = self._http.post_json(
76
+ "/v1/auth/mfa/enroll/start",
77
+ {"pre_auth_token": pre_auth_token},
78
+ auth="none",
79
+ )
80
+ return MfaEnrollStartResult(
81
+ otpauth_uri=body["otpauth_uri"],
82
+ secret=body.get("secret"),
83
+ )
84
+
85
+ def mfa_enroll_confirm(
86
+ self,
87
+ *,
88
+ pre_auth_token: str,
89
+ code: str,
90
+ ) -> MfaEnrollConfirmResult:
91
+ response = self._http._client.post(
92
+ "/v1/auth/mfa/enroll/confirm",
93
+ json={"pre_auth_token": pre_auth_token, "code": code},
94
+ headers={"Content-Type": "application/json"},
95
+ )
96
+ self._http._raise_for_status(response)
97
+ body = response.json()
98
+ result = MfaEnrollConfirmResult(
99
+ access_token=body["access_token"],
100
+ recovery_codes=body["recovery_codes"],
101
+ )
102
+ self._http.set_access_token(result.access_token)
103
+ return result
104
+
105
+ def refresh(self) -> TokenResult:
106
+ response = self._http._client.post("/v1/auth/refresh")
107
+ self._http._raise_for_status(response)
108
+ body = response.json()
109
+ token = TokenResult(access_token=body["access_token"])
110
+ self._http.set_access_token(token.access_token)
111
+ return token
112
+
113
+ def logout(self) -> None:
114
+ self._http.post_json("/v1/auth/logout", {}, auth="access_token")
115
+ self._http.set_access_token(None)
116
+
117
+ def me(self) -> MeResult:
118
+ body = self._http.get_json("/v1/auth/me", auth="access_token")
119
+ return MeResult(
120
+ principal_type=body["principal_type"],
121
+ id=body["id"],
122
+ email=body["email"],
123
+ role=body["role"],
124
+ tenant_id=body.get("tenant_id"),
125
+ mfa_verified=body["mfa_verified"],
126
+ mfa_enrolled=body["mfa_enrolled"],
127
+ )
128
+
129
+ def accept_invite(self, *, token: str, password: str) -> AcceptInviteResult:
130
+ body = self._http.post_json(
131
+ "/v1/auth/accept-invite",
132
+ {"token": token, "password": password},
133
+ auth="none",
134
+ )
135
+ return AcceptInviteResult(
136
+ pre_auth_token=body["pre_auth_token"],
137
+ mfa_enrollment_required=body.get("mfa_enrollment_required", True),
138
+ )
139
+
140
+ def request_password_reset(
141
+ self,
142
+ *,
143
+ email: str,
144
+ tenant_slug: str | None = None,
145
+ ) -> None:
146
+ payload: dict[str, str] = {"email": email}
147
+ if tenant_slug is not None:
148
+ payload["tenant_slug"] = tenant_slug
149
+ self._http.post_json("/v1/auth/password/reset/request", payload, auth="none")
150
+
151
+ def confirm_password_reset(self, *, token: str, new_password: str) -> None:
152
+ self._http.post_json(
153
+ "/v1/auth/password/reset/confirm",
154
+ {"token": token, "new_password": new_password},
155
+ auth="none",
156
+ )
157
+
158
+ def login_platform(
159
+ self,
160
+ *,
161
+ email: str,
162
+ password: str,
163
+ totp_code: str | None = None,
164
+ totp_secret: str | None = None,
165
+ ) -> str:
166
+ """Log in as platform admin with mandatory MFA; returns and stores access JWT."""
167
+ return self._complete_login(
168
+ email=email,
169
+ password=password,
170
+ tenant_slug=None,
171
+ totp_code=totp_code,
172
+ totp_secret=totp_secret,
173
+ )
174
+
175
+ def login_tenant(
176
+ self,
177
+ *,
178
+ email: str,
179
+ password: str,
180
+ tenant_slug: str,
181
+ totp_code: str | None = None,
182
+ totp_secret: str | None = None,
183
+ ) -> str:
184
+ """Log in as a tenant member/admin; returns and stores access JWT."""
185
+ return self._complete_login(
186
+ email=email,
187
+ password=password,
188
+ tenant_slug=tenant_slug,
189
+ totp_code=totp_code,
190
+ totp_secret=totp_secret,
191
+ )
192
+
193
+ def _complete_login(
194
+ self,
195
+ *,
196
+ email: str,
197
+ password: str,
198
+ tenant_slug: str | None,
199
+ totp_code: str | None,
200
+ totp_secret: str | None,
201
+ ) -> str:
202
+ login = self.login(email=email, password=password, tenant_slug=tenant_slug)
203
+ if login.access_token:
204
+ self._http.set_access_token(login.access_token)
205
+ return login.access_token
206
+
207
+ pre_auth = login.pre_auth_token
208
+ if not pre_auth:
209
+ raise SubzeroAPIError("Login did not return a pre_auth_token")
210
+
211
+ code = totp_code
212
+ if code is None and totp_secret is not None:
213
+ code = _totp_code(totp_secret)
214
+
215
+ if login.mfa_enrollment_required:
216
+ if code is None:
217
+ enroll = self.mfa_enroll_start(pre_auth_token=pre_auth)
218
+ raise SubzeroAPIError(
219
+ "MFA enrollment required. Scan otpauth_uri and call mfa_enroll_confirm, "
220
+ f"or pass totp_code/totp_secret. Dev secret: {enroll.secret!r}"
221
+ )
222
+ result = self.mfa_enroll_confirm(pre_auth_token=pre_auth, code=code)
223
+ return result.access_token
224
+
225
+ if login.mfa_required:
226
+ if code is None:
227
+ raise SubzeroAPIError(
228
+ "MFA verification required — pass totp_code or totp_secret"
229
+ )
230
+ return self.mfa_verify(pre_auth_token=pre_auth, code=code).access_token
231
+
232
+ raise SubzeroAPIError("Unexpected login response — MFA expected")
subzero/client.py ADDED
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ from subzero._http import HTTPClient
4
+ from subzero.admin import AdminResource
5
+ from subzero.auth import AuthResource
6
+ from subzero.members import MembersResource
7
+ from subzero.proxy import ProxyResource
8
+ from subzero.types import RevealGrantResult, RevealResult, SearchResult, TokenizeResult, parse_datetime
9
+
10
+
11
+ class SubzeroClient:
12
+ def __init__(
13
+ self,
14
+ api_key: str | None = None,
15
+ *,
16
+ access_token: str | None = None,
17
+ base_url: str = "http://127.0.0.1:8000",
18
+ timeout: float = 60.0,
19
+ ) -> None:
20
+ self._base_url = base_url.rstrip("/")
21
+ self._http = HTTPClient(
22
+ api_key=api_key,
23
+ access_token=access_token,
24
+ base_url=self._base_url,
25
+ timeout=timeout,
26
+ )
27
+ self.auth = AuthResource(self._http)
28
+ self.admin = AdminResource(self._http)
29
+ self.members = MembersResource(self._http)
30
+ self.proxy = ProxyResource(self._http)
31
+
32
+ @property
33
+ def access_token(self) -> str | None:
34
+ return self._http.access_token
35
+
36
+ def close(self) -> None:
37
+ self._http.close()
38
+
39
+ def __enter__(self) -> SubzeroClient:
40
+ return self
41
+
42
+ def __exit__(self, *args: object) -> None:
43
+ self.close()
44
+
45
+ def health(self) -> dict:
46
+ return self._http.health()
47
+
48
+ def ready(self) -> dict:
49
+ return self._http.ready(self._base_url)
50
+
51
+ def tokenize(self, entity_type: str, value: str) -> TokenizeResult:
52
+ body = self._http.post_json(
53
+ "/v1/tokenize",
54
+ {"entity_type": entity_type, "value": value},
55
+ auth="api_key",
56
+ )
57
+ return TokenizeResult(token=body["token"], entity_type=body["entity_type"])
58
+
59
+ def search(self, entity_type: str, value: str) -> SearchResult:
60
+ body = self._http.post_json(
61
+ "/v1/search",
62
+ {"entity_type": entity_type, "value": value},
63
+ auth="api_key",
64
+ )
65
+ return SearchResult(token=body["token"])
66
+
67
+ def reveal(self, token: str) -> RevealResult:
68
+ body = self._http.post_json("/v1/reveal", {"token": token}, auth="api_key")
69
+ return RevealResult(
70
+ token=body["token"],
71
+ entity_type=body["entity_type"],
72
+ value=body["value"],
73
+ )
74
+
75
+ def create_reveal_grant(
76
+ self,
77
+ token: str,
78
+ *,
79
+ client_public_key_jwk: dict,
80
+ allowed_origin: str | None = None,
81
+ ) -> RevealGrantResult:
82
+ payload: dict[str, object] = {
83
+ "token": token,
84
+ "client_public_key_jwk": client_public_key_jwk,
85
+ }
86
+ if allowed_origin is not None:
87
+ payload["allowed_origin"] = allowed_origin
88
+ body = self._http.post_json("/v1/reveal/grants", payload, auth="api_key")
89
+ return RevealGrantResult(
90
+ grant_id=body["grant_id"],
91
+ expires_at=parse_datetime(body["expires_at"]),
92
+ )
93
+
94
+ def delete_token(self, token: str) -> None:
95
+ encoded = HTTPClient.encode_token_path(token)
96
+ self._http.delete(f"/v1/tokens/{encoded}", auth="api_key")
subzero/exceptions.py ADDED
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class SubzeroAPIError(Exception):
7
+ def __init__(
8
+ self,
9
+ message: str,
10
+ *,
11
+ status_code: int | None = None,
12
+ reason: str | None = None,
13
+ response: Any | None = None,
14
+ ) -> None:
15
+ super().__init__(message)
16
+ self.message = message
17
+ self.status_code = status_code
18
+ self.reason = reason
19
+ self.response = response
20
+
21
+
22
+ class AuthenticationError(SubzeroAPIError):
23
+ pass
24
+
25
+
26
+ class AuthorizationError(SubzeroAPIError):
27
+ pass
28
+
29
+
30
+ class PolicyDeniedError(SubzeroAPIError):
31
+ pass
32
+
33
+
34
+ class NotFoundError(SubzeroAPIError):
35
+ pass
36
+
37
+
38
+ class ConflictError(SubzeroAPIError):
39
+ pass
40
+
41
+
42
+ class SubzeroNotReadyError(SubzeroAPIError):
43
+ pass
subzero/members.py ADDED
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal
4
+
5
+ from subzero._http import HTTPClient
6
+ from subzero.types import MemberResult, parse_datetime
7
+
8
+ MemberRole = Literal["admin", "member"]
9
+
10
+
11
+ class MembersResource:
12
+ """Tenant member invites and management (tenant admin or platform admin JWT)."""
13
+
14
+ def __init__(self, http: HTTPClient) -> None:
15
+ self._http = http
16
+
17
+ def invite(
18
+ self,
19
+ tenant_id: str,
20
+ *,
21
+ email: str,
22
+ role: MemberRole = "member",
23
+ ) -> MemberResult:
24
+ body = self._http.post_json(
25
+ f"/v1/tenants/{tenant_id}/members",
26
+ {"email": email, "role": role},
27
+ auth="access_token",
28
+ )
29
+ return _member_from_body(body)
30
+
31
+ def list(self, tenant_id: str) -> list[MemberResult]:
32
+ body = self._http.get_json(f"/v1/tenants/{tenant_id}/members", auth="access_token")
33
+ return [_member_from_body(item) for item in body]
34
+
35
+ def update(
36
+ self,
37
+ tenant_id: str,
38
+ member_id: str,
39
+ *,
40
+ role: MemberRole | None = None,
41
+ disabled: bool | None = None,
42
+ ) -> MemberResult:
43
+ payload: dict[str, Any] = {}
44
+ if role is not None:
45
+ payload["role"] = role
46
+ if disabled is not None:
47
+ payload["disabled"] = disabled
48
+ body = self._http.patch_json(
49
+ f"/v1/tenants/{tenant_id}/members/{member_id}",
50
+ payload,
51
+ auth="access_token",
52
+ )
53
+ return _member_from_body(body)
54
+
55
+
56
+ def _member_from_body(body: dict[str, Any]) -> MemberResult:
57
+ return MemberResult(
58
+ id=body["id"],
59
+ email=body["email"],
60
+ role=body["role"],
61
+ status=body["status"],
62
+ mfa_enrolled_at=parse_datetime(body["mfa_enrolled_at"])
63
+ if body.get("mfa_enrolled_at")
64
+ else None,
65
+ invited_at=parse_datetime(body["invited_at"]) if body.get("invited_at") else None,
66
+ last_login_at=parse_datetime(body["last_login_at"])
67
+ if body.get("last_login_at")
68
+ else None,
69
+ created_at=parse_datetime(body["created_at"]),
70
+ invite_token=body.get("invite_token"),
71
+ )
subzero/openai.py ADDED
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def create_openai_client(
7
+ *,
8
+ api_key: str,
9
+ base_url: str,
10
+ detokenize: bool = False,
11
+ **kwargs: Any,
12
+ ):
13
+ try:
14
+ from openai import OpenAI
15
+ except ImportError as exc:
16
+ raise ImportError(
17
+ "Install the openai extra: pip install 'subzero[openai]'",
18
+ ) from exc
19
+
20
+ default_headers: dict[str, str] = {}
21
+ if detokenize:
22
+ default_headers["X-Subzero-Detokenize"] = "true"
23
+
24
+ client_kwargs = dict(kwargs)
25
+ if default_headers:
26
+ existing = client_kwargs.pop("default_headers", {})
27
+ client_kwargs["default_headers"] = {**existing, **default_headers}
28
+
29
+ return OpenAI(api_key=api_key, base_url=base_url, **client_kwargs)
subzero/proxy.py ADDED
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from subzero._http import HTTPClient
6
+ from subzero.exceptions import SubzeroAPIError
7
+
8
+
9
+ class ProxyChatResource:
10
+ def __init__(self, http: HTTPClient) -> None:
11
+ self._http = http
12
+
13
+ def completions(
14
+ self,
15
+ *,
16
+ model: str,
17
+ messages: list[dict[str, Any]],
18
+ stream: bool = False,
19
+ detokenize: bool = False,
20
+ **extra: Any,
21
+ ) -> dict[str, Any]:
22
+ if stream:
23
+ raise SubzeroAPIError(
24
+ "Streaming is not supported in subzero SDK v1 — pass stream=False",
25
+ )
26
+
27
+ headers: dict[str, str] = {}
28
+ if detokenize:
29
+ headers["X-Subzero-Detokenize"] = "true"
30
+
31
+ payload: dict[str, Any] = {
32
+ "model": model,
33
+ "messages": messages,
34
+ "stream": False,
35
+ **extra,
36
+ }
37
+ body = self._http.post_json(
38
+ "/v1/chat/completions",
39
+ payload,
40
+ headers=headers or None,
41
+ )
42
+ if not isinstance(body, dict):
43
+ raise SubzeroAPIError("Expected JSON object from chat/completions")
44
+ return body
45
+
46
+
47
+ class ProxyResource:
48
+ def __init__(self, http: HTTPClient) -> None:
49
+ self.chat = ProxyChatResource(http)
subzero/types.py ADDED
@@ -0,0 +1,177 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import Any, Literal
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class TokenizeResult:
10
+ token: str
11
+ entity_type: str
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class SearchResult:
16
+ token: str
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class RevealResult:
21
+ token: str
22
+ entity_type: str
23
+ value: str
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class TenantResult:
28
+ id: str
29
+ name: str
30
+ slug: str
31
+ created_at: datetime
32
+ bootstrap_api_key: str | None = None
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class EntityTypeResult:
37
+ id: str
38
+ name: str
39
+ description: str | None
40
+ deterministic: bool
41
+ match_pattern: str | None
42
+ created_at: datetime
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class PolicyRuleResult:
47
+ id: str
48
+ principal: str
49
+ entity_type: str
50
+ action: str
51
+ created_at: datetime
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class ApiKeyResult:
56
+ id: str
57
+ name: str
58
+ prefix: str
59
+ scope: str
60
+ revoked_at: datetime | None
61
+ created_at: datetime
62
+ secret: str | None = None
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class AuditEventResult:
67
+ id: str
68
+ tenant_id: str
69
+ event_type: str
70
+ actor: str
71
+ resource: str | None
72
+ outcome: str
73
+ metadata: dict[str, Any] | None
74
+ created_at: datetime
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class AuditLogPageResult:
79
+ items: list[AuditEventResult]
80
+ total: int
81
+ limit: int
82
+ offset: int
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class LlmConfigResult:
87
+ id: str
88
+ provider: str
89
+ base_url: str
90
+ is_active: bool
91
+ created_at: datetime
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class RevealGrantResult:
96
+ grant_id: str
97
+ expires_at: datetime
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class PublishableKeyResult:
102
+ id: str
103
+ name: str
104
+ key: str
105
+ allowed_origins: list[str]
106
+ rate_limit_rpm: int
107
+ integration_mode: str
108
+ revoked_at: datetime | None
109
+ created_at: datetime
110
+
111
+
112
+ ApiKeyScope = Literal["tokenize", "reveal", "proxy", "admin"]
113
+ PolicyAction = Literal["reveal", "delete"]
114
+ MemberRole = Literal["admin", "member"]
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class LoginResult:
119
+ access_token: str | None
120
+ pre_auth_token: str | None
121
+ mfa_required: bool
122
+ mfa_enrollment_required: bool
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class TokenResult:
127
+ access_token: str
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class MfaEnrollStartResult:
132
+ otpauth_uri: str
133
+ secret: str | None
134
+
135
+
136
+ @dataclass(frozen=True)
137
+ class MfaEnrollConfirmResult:
138
+ access_token: str
139
+ recovery_codes: list[str]
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class AcceptInviteResult:
144
+ pre_auth_token: str
145
+ mfa_enrollment_required: bool
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class MeResult:
150
+ principal_type: Literal["platform_user", "member"]
151
+ id: str
152
+ email: str
153
+ role: str
154
+ tenant_id: str | None
155
+ mfa_verified: bool
156
+ mfa_enrolled: bool
157
+
158
+
159
+ @dataclass(frozen=True)
160
+ class MemberResult:
161
+ id: str
162
+ email: str
163
+ role: str
164
+ status: str
165
+ mfa_enrolled_at: datetime | None
166
+ invited_at: datetime | None
167
+ last_login_at: datetime | None
168
+ created_at: datetime
169
+ invite_token: str | None = None
170
+
171
+
172
+ def parse_datetime(value: str) -> datetime:
173
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
174
+
175
+
176
+ def chat_completion_dict(**kwargs: Any) -> dict[str, Any]:
177
+ return dict(kwargs)