fakzed-server 0.1.0__tar.gz

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,43 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+
9
+ # uv / virtualenv
10
+ .venv/
11
+
12
+ # Testing
13
+ .pytest_cache/
14
+ .mypy_cache/
15
+ .ruff_cache/
16
+ .coverage
17
+ htmlcov/
18
+
19
+ # IDE
20
+ .idea/
21
+ .vscode/
22
+ *.swp
23
+
24
+ # OS
25
+ .DS_Store
26
+ Thumbs.db
27
+
28
+ # Env
29
+ .env
30
+ .env.*
31
+
32
+ # Node / web UI
33
+ node_modules/
34
+ .next/
35
+ apps/ui/out/
36
+
37
+ # fakzed runtime data (API-key hashes, sessions, runs)
38
+ fakzed_server.json
39
+ *.db
40
+ *.sqlite3
41
+
42
+ # Local dev notes (contain secrets / working log)
43
+ note.md
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 fakzed contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.5
2
+ Name: fakzed-server
3
+ Version: 0.1.0
4
+ Summary: Fakzed platform server: agent registry, REST/SSE API, auth, multi-tenancy, run management.
5
+ Author: fakzed contributors
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: agent,ai,framework,llm,platform,server
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Libraries
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: fakzed
16
+ Requires-Dist: fastapi>=0.115
17
+ Requires-Dist: uvicorn>=0.30
18
+ Description-Content-Type: text/markdown
19
+
20
+ # fakzed-server
21
+
22
+ The fakzed platform server: register agents once, call them from anywhere.
23
+
24
+ - **Registry**: `register()` once, served over REST + SSE
25
+ - **Auth**: API-key auth per agent, optional admin bootstrap key
26
+ - **Multi-tenancy**: per-key tenancy with usage accounting
27
+ - **Persistence**: JSON file or any `Storage` backend
28
+ - **Observability**: `/health`, `/metrics` monitoring endpoints
29
+ - **Remote SDK**: `RemoteAgent` mirrors the local `Agent` interface
30
+
31
+ ```bash
32
+ uv add fakzed-server # or: pip install fakzed-server
33
+
34
+ fakzed-server --host 0.0.0.0 --port 8000 --admin-key <bootstrap-key>
35
+ ```
36
+
37
+ ```python
38
+ import uvicorn
39
+
40
+ from fakzed import Agent
41
+ from fakzed_server import FakzedServer
42
+
43
+ server = FakzedServer(admin_api_key="admin", db_path="agents.json")
44
+ server.register(Agent(name="echo", model="deepseek:deepseek-chat"))
45
+ uvicorn.run(server.app, host="127.0.0.1", port=8000) # blocking
46
+ ```
47
+
48
+ ```python
49
+ from fakzed import RemoteAgent
50
+
51
+ remote = RemoteAgent(base_url="http://127.0.0.1:8000", agent_name="echo", api_key="admin")
52
+ resp = remote.run("hello")
53
+ print(resp.content)
54
+ ```
55
+
56
+ See the monorepo [README](../../README.md) and [platform docs](../../docs/platform.md).
@@ -0,0 +1,37 @@
1
+ # fakzed-server
2
+
3
+ The fakzed platform server: register agents once, call them from anywhere.
4
+
5
+ - **Registry**: `register()` once, served over REST + SSE
6
+ - **Auth**: API-key auth per agent, optional admin bootstrap key
7
+ - **Multi-tenancy**: per-key tenancy with usage accounting
8
+ - **Persistence**: JSON file or any `Storage` backend
9
+ - **Observability**: `/health`, `/metrics` monitoring endpoints
10
+ - **Remote SDK**: `RemoteAgent` mirrors the local `Agent` interface
11
+
12
+ ```bash
13
+ uv add fakzed-server # or: pip install fakzed-server
14
+
15
+ fakzed-server --host 0.0.0.0 --port 8000 --admin-key <bootstrap-key>
16
+ ```
17
+
18
+ ```python
19
+ import uvicorn
20
+
21
+ from fakzed import Agent
22
+ from fakzed_server import FakzedServer
23
+
24
+ server = FakzedServer(admin_api_key="admin", db_path="agents.json")
25
+ server.register(Agent(name="echo", model="deepseek:deepseek-chat"))
26
+ uvicorn.run(server.app, host="127.0.0.1", port=8000) # blocking
27
+ ```
28
+
29
+ ```python
30
+ from fakzed import RemoteAgent
31
+
32
+ remote = RemoteAgent(base_url="http://127.0.0.1:8000", agent_name="echo", api_key="admin")
33
+ resp = remote.run("hello")
34
+ print(resp.content)
35
+ ```
36
+
37
+ See the monorepo [README](../../README.md) and [platform docs](../../docs/platform.md).
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "fakzed-server"
3
+ version = "0.1.0"
4
+ description = "Fakzed platform server: agent registry, REST/SSE API, auth, multi-tenancy, run management."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ authors = [{ name = "fakzed contributors" }]
9
+ keywords = ["agent", "ai", "llm", "platform", "server", "framework"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3",
15
+ "Topic :: Software Development :: Libraries",
16
+ ]
17
+
18
+ dependencies = [
19
+ "fakzed",
20
+ "fastapi>=0.115",
21
+ "uvicorn>=0.30",
22
+ ]
23
+
24
+ [dependency-groups]
25
+ dev = [
26
+ "pytest>=8.3",
27
+ "httpx>=0.27",
28
+ ]
29
+
30
+ [project.scripts]
31
+ fakzed-server = "fakzed_server.main:main"
32
+
33
+ [build-system]
34
+ requires = ["hatchling"]
35
+ build-backend = "hatchling.build"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/fakzed_server"]
@@ -0,0 +1,26 @@
1
+ """Fakzed platform server: registry, REST/SSE API, auth, multi-tenancy."""
2
+
3
+ from fakzed_server.app import FakzedServer
4
+ from fakzed_server.auth import ApiKey, ApiKeyStore, Principal
5
+ from fakzed_server.monitoring import MonitoringStore
6
+ from fakzed_server.persistence import JsonStore
7
+ from fakzed_server.registry import AgentRegistry, Registered
8
+ from fakzed_server.runs import RunManager, RunRecord
9
+ from fakzed_server.tenancy import Organization, Project, TenantStore, User
10
+
11
+ __all__ = [
12
+ "FakzedServer",
13
+ "AgentRegistry",
14
+ "Registered",
15
+ "ApiKey",
16
+ "ApiKeyStore",
17
+ "Principal",
18
+ "MonitoringStore",
19
+ "JsonStore",
20
+ "RunManager",
21
+ "RunRecord",
22
+ "TenantStore",
23
+ "User",
24
+ "Organization",
25
+ "Project",
26
+ ]
@@ -0,0 +1,427 @@
1
+ """The platform server: FastAPI app serving the shared agent registry.
2
+
3
+ Endpoints:
4
+ - chat (REST + SSE streaming) against registered SDK objects
5
+ - HITL resume for paused runs
6
+ - background run management (create/query/cancel)
7
+ - monitoring (events/traces/metrics)
8
+ - tenancy (users/orgs/projects) and API-key issuance
9
+
10
+ Auth: `X-API-Key` header; admin endpoints require an admin-role key.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import uuid
17
+ from collections.abc import Iterator
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from fastapi import Depends, FastAPI, Header, HTTPException
22
+ from fastapi.middleware.cors import CORSMiddleware
23
+ from fastapi.responses import StreamingResponse
24
+ from pydantic import BaseModel
25
+
26
+ from fakzed import Agent
27
+ from fakzed.storage import Storage
28
+ from fakzed_server.auth import ApiKeyStore, Principal
29
+ from fakzed_server.monitoring import MonitoringStore
30
+ from fakzed_server.persistence import JsonStore
31
+ from fakzed_server.registry import AgentRegistry, Registered
32
+ from fakzed_server.runs import RunManager
33
+ from fakzed_server.tenancy import TenantStore
34
+
35
+
36
+ class ChatRequest(BaseModel):
37
+ """Body of a chat call: the message plus optional session context."""
38
+
39
+ message: str
40
+ session_id: str | None = None
41
+ context: dict[str, Any] | None = None
42
+ stop_after_tool_call: bool = False
43
+
44
+
45
+ class RunRequest(BaseModel):
46
+ """Body of a background-run submission."""
47
+
48
+ agent: str
49
+ message: str
50
+ session_id: str | None = None
51
+
52
+
53
+ class AgentPatch(BaseModel):
54
+ """Body of an agent-management update."""
55
+
56
+ enabled: bool | None = None
57
+
58
+
59
+ class AgentCreate(BaseModel):
60
+ """Body of a platform-managed agent creation."""
61
+
62
+ name: str
63
+ model: str
64
+ description: str | None = None
65
+ instructions: str | None = None
66
+ project_id: str = "default"
67
+ api_key: str | None = None
68
+ base_url: str | None = None
69
+
70
+
71
+ class TenantRequest(BaseModel):
72
+ """Body of a tenant create call."""
73
+
74
+ name: str
75
+ email: str | None = None
76
+ org_id: str | None = None
77
+
78
+
79
+ class KeyRequest(BaseModel):
80
+ """Body of an API-key issuance call."""
81
+
82
+ name: str
83
+ user_id: str | None = None
84
+ role: str = "user"
85
+
86
+
87
+ class FakzedServer:
88
+ """Wires registry/tenancy/auth/monitoring/persistence into a FastAPI app."""
89
+
90
+ def __init__(
91
+ self,
92
+ *,
93
+ title: str = "Fakzed Platform",
94
+ db_path: str | Path | None = None,
95
+ auth_enabled: bool = True,
96
+ admin_api_key: str | None = None,
97
+ storage: Storage | None = None,
98
+ ) -> None:
99
+ self.registry = AgentRegistry()
100
+ self.tenants = TenantStore()
101
+ self.auth = ApiKeyStore()
102
+ self.monitoring = MonitoringStore()
103
+ self.persistence = JsonStore(path=Path(db_path) if db_path else Path("fakzed_server.json"))
104
+ self.runs = RunManager(store=self.persistence)
105
+ self.runs.agent_factory = lambda name: self.registry.get(name).instance
106
+ self.auth_enabled = auth_enabled
107
+ self.storage = storage
108
+
109
+ if auth_enabled and admin_api_key:
110
+ self.auth.issue(name="bootstrap-admin", user_id="admin", role="admin")
111
+ # seed the caller-provided key with admin role (hash only)
112
+ self._seed_key(admin_api_key, "bootstrap-admin", "admin")
113
+
114
+ self.app = FastAPI(title=title)
115
+ self.app.add_middleware(
116
+ CORSMiddleware,
117
+ allow_origins=["*"], # web UI runs on a different origin in dev
118
+ allow_credentials=False,
119
+ allow_methods=["*"],
120
+ allow_headers=["*"],
121
+ )
122
+ self._register_routes()
123
+
124
+ # -- setup helpers --------------------------------------------------------
125
+
126
+ def _seed_key(self, raw: str, name: str, user_id: str) -> None:
127
+ from fakzed_server.auth import ApiKey, hash_key
128
+
129
+ key_hash = hash_key(raw)
130
+ record = ApiKey(name=name, user_id=user_id, role="admin", key_hash=key_hash)
131
+ self.auth.keys[record.key_id] = record
132
+ self.auth._by_hash[key_hash] = record.key_id
133
+
134
+ def register(
135
+ self,
136
+ instance: Any,
137
+ *,
138
+ name: str | None = None,
139
+ project_id: str = "default",
140
+ ) -> Registered:
141
+ """Register an SDK object; attaches platform storage and observability."""
142
+ if self.storage is not None and getattr(instance, "storage", None) is None:
143
+ instance.storage = self.storage
144
+ if getattr(instance, "observer", None) is None:
145
+ instance.observer = self.monitoring.observer()
146
+ return self.registry.register(instance, name=name, project_id=project_id)
147
+
148
+ def create_app(self) -> FastAPI:
149
+ return self.app
150
+
151
+ # -- auth dependencies ----------------------------------------------------
152
+
153
+ def _require_api_key(self, x_api_key: str | None = Header(default=None)) -> Principal:
154
+ if not self.auth_enabled:
155
+ return Principal(user_id="anonymous", role="admin", key_id="none")
156
+ principal = self.auth.authenticate(x_api_key)
157
+ if principal is None:
158
+ raise HTTPException(status_code=401, detail="missing or invalid API key")
159
+ return principal
160
+
161
+ def _require_admin(self, x_api_key: str | None = Header(default=None)) -> Principal:
162
+ principal = self._require_api_key(x_api_key)
163
+ if principal.role != "admin":
164
+ raise HTTPException(status_code=403, detail="requires admin role")
165
+ return principal
166
+
167
+ # -- helpers ----------------------------------------------------------------
168
+
169
+ def _entry(self, name: str) -> Registered:
170
+ try:
171
+ return self.registry.get(name)
172
+ except KeyError as exc:
173
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
174
+ except PermissionError as exc:
175
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
176
+
177
+ @staticmethod
178
+ def _response_payload(instance: Any, result: Any) -> dict[str, Any]:
179
+ payload = result.model_dump()
180
+ payload["session_id"] = getattr(instance, "session_id", None)
181
+ return payload
182
+
183
+ def _resolve_session(self, instance: Any, requested: str | None) -> None:
184
+ """Point the instance at a session (recovered from storage when set)."""
185
+ current = getattr(instance, "session_id", None)
186
+ if requested:
187
+ instance.session_id = requested
188
+ elif not current:
189
+ instance.session_id = uuid.uuid4().hex[:12]
190
+
191
+ # -- routes -----------------------------------------------------------------
192
+
193
+ def _register_routes(self) -> None:
194
+ app = self.app
195
+
196
+ @app.get("/healthz")
197
+ def healthz() -> dict[str, str]:
198
+ return {"status": "ok", "agents": str(len(self.registry.entries))}
199
+
200
+ # -- agents ------------------------------------------------------------
201
+
202
+ @app.post("/api/agents")
203
+ def create_agent(
204
+ request: AgentCreate,
205
+ principal: Principal = Depends(self._require_admin),
206
+ ) -> dict[str, Any]:
207
+ """Create and register a platform-managed agent from config."""
208
+ if request.name in self.registry.entries:
209
+ raise HTTPException(status_code=409, detail=f"agent '{request.name}' exists")
210
+ try:
211
+ from fakzed.model import create_model
212
+
213
+ extras: dict[str, Any] = {}
214
+ if request.api_key is not None:
215
+ extras["api_key"] = request.api_key
216
+ if request.base_url is not None:
217
+ extras["base_url"] = request.base_url
218
+ model = create_model(request.model, **extras)
219
+ except ValueError as exc:
220
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
221
+ agent = Agent(
222
+ name=request.name,
223
+ description=request.description,
224
+ instructions=request.instructions,
225
+ model=model,
226
+ )
227
+ entry = self.register(agent, project_id=request.project_id)
228
+ return entry.model_dump(exclude={"instance"})
229
+
230
+ @app.get("/api/agents")
231
+ def list_agents(
232
+ principal: Principal = Depends(self._require_api_key),
233
+ ) -> list[dict[str, Any]]:
234
+ return [e.model_dump(exclude={"instance"}) for e in self.registry.list()]
235
+
236
+ @app.get("/api/agents/{name}")
237
+ def get_agent(
238
+ name: str, principal: Principal = Depends(self._require_api_key)
239
+ ) -> dict[str, Any]:
240
+ return self._entry(name).model_dump(exclude={"instance"})
241
+
242
+ @app.patch("/api/agents/{name}")
243
+ def patch_agent(
244
+ name: str,
245
+ patch: AgentPatch,
246
+ principal: Principal = Depends(self._require_admin),
247
+ ) -> dict[str, Any]:
248
+ entry = self._entry(name)
249
+ if patch.enabled is not None:
250
+ entry.enabled = patch.enabled
251
+ return entry.model_dump(exclude={"instance"})
252
+
253
+ @app.delete("/api/agents/{name}")
254
+ def delete_agent(
255
+ name: str, principal: Principal = Depends(self._require_admin)
256
+ ) -> dict[str, bool]:
257
+ return {"unregistered": self.registry.unregister(name)}
258
+
259
+ # -- chat --------------------------------------------------------------
260
+
261
+ @app.post("/api/agents/{name}/chat")
262
+ def chat(
263
+ name: str,
264
+ request: ChatRequest,
265
+ principal: Principal = Depends(self._require_api_key),
266
+ ) -> dict[str, Any]:
267
+ entry = self._entry(name)
268
+ with entry._lock:
269
+ instance = entry.instance
270
+ self._resolve_session(instance, request.session_id)
271
+ result = instance.run(
272
+ request.message,
273
+ context=request.context,
274
+ stop_after_tool_call=request.stop_after_tool_call,
275
+ )
276
+ return self._response_payload(instance, result)
277
+
278
+ @app.post("/api/agents/{name}/chat/stream")
279
+ def chat_stream(
280
+ name: str, request: ChatRequest, principal: Principal = Depends(self._require_api_key)
281
+ ) -> StreamingResponse:
282
+ entry = self._entry(name)
283
+
284
+ def generate() -> Iterator[str]:
285
+ with entry._lock:
286
+ instance = entry.instance
287
+ self._resolve_session(instance, request.session_id)
288
+ for event in instance.run_stream(request.message, context=request.context):
289
+ data = self._response_payload(instance, event)
290
+ yield f"data: {json.dumps(data, ensure_ascii=False, default=str)}\n\n"
291
+
292
+ return StreamingResponse(generate(), media_type="text/event-stream")
293
+
294
+ @app.post("/api/agents/{name}/resume")
295
+ def resume(
296
+ name: str, principal: Principal = Depends(self._require_api_key)
297
+ ) -> dict[str, Any]:
298
+ entry = self._entry(name)
299
+ with entry._lock:
300
+ result = entry.instance.resume()
301
+ return self._response_payload(entry.instance, result)
302
+
303
+ # -- sessions -----------------------------------------------------------
304
+
305
+ @app.get("/api/sessions/{session_id}")
306
+ def get_session(
307
+ session_id: str, principal: Principal = Depends(self._require_api_key)
308
+ ) -> dict[str, Any]:
309
+ for entry in self.registry.list():
310
+ storage = getattr(entry.instance, "storage", None)
311
+ if storage is None:
312
+ continue
313
+ row = storage.read_session(session_id)
314
+ if row is not None:
315
+ return row.model_dump()
316
+ raise HTTPException(status_code=404, detail="session not found")
317
+
318
+ # -- runs ----------------------------------------------------------------
319
+
320
+ @app.post("/api/runs")
321
+ def create_run(
322
+ request: RunRequest, principal: Principal = Depends(self._require_api_key)
323
+ ) -> dict[str, Any]:
324
+ self._entry(request.agent) # 404 before submitting
325
+ record = self.runs.submit(request.agent, request.message, session_id=request.session_id)
326
+ return record.model_dump()
327
+
328
+ @app.get("/api/runs")
329
+ def list_runs(
330
+ principal: Principal = Depends(self._require_api_key),
331
+ ) -> list[dict[str, Any]]:
332
+ return [r.model_dump() for r in self.runs.list()]
333
+
334
+ @app.get("/api/runs/{run_id}")
335
+ def get_run(
336
+ run_id: str, principal: Principal = Depends(self._require_api_key)
337
+ ) -> dict[str, Any]:
338
+ record = self.runs.get(run_id)
339
+ if record is None:
340
+ raise HTTPException(status_code=404, detail="run not found")
341
+ return record.model_dump()
342
+
343
+ @app.post("/api/runs/{run_id}/cancel")
344
+ def cancel_run(
345
+ run_id: str, principal: Principal = Depends(self._require_api_key)
346
+ ) -> dict[str, bool]:
347
+ return {"cancelled": self.runs.cancel(run_id)}
348
+
349
+ # -- monitoring ------------------------------------------------------------
350
+
351
+ @app.get("/api/events")
352
+ def events(
353
+ principal: Principal = Depends(self._require_api_key),
354
+ ) -> list[dict[str, Any]]:
355
+ return [e.model_dump() for e in self.monitoring.recent_events()]
356
+
357
+ @app.get("/api/traces")
358
+ def traces(
359
+ principal: Principal = Depends(self._require_api_key),
360
+ ) -> list[dict[str, Any]]:
361
+ return [t.model_dump() for t in self.monitoring.traces()]
362
+
363
+ @app.get("/api/metrics/summary")
364
+ def metrics_summary(
365
+ principal: Principal = Depends(self._require_api_key),
366
+ ) -> dict:
367
+ return self.monitoring.summary()
368
+
369
+ # -- tenancy --------------------------------------------------------------
370
+
371
+ @app.post("/api/tenants/users")
372
+ def create_user(
373
+ request: TenantRequest,
374
+ principal: Principal = Depends(self._require_admin),
375
+ ) -> dict[str, Any]:
376
+ return self.tenants.create_user(request.name, email=request.email).model_dump()
377
+
378
+ @app.post("/api/tenants/orgs")
379
+ def create_org(
380
+ request: TenantRequest,
381
+ principal: Principal = Depends(self._require_admin),
382
+ ) -> dict[str, Any]:
383
+ return self.tenants.create_org(request.name, owner_id=principal.user_id).model_dump()
384
+
385
+ @app.get("/api/tenants/orgs")
386
+ def list_orgs(
387
+ principal: Principal = Depends(self._require_api_key),
388
+ ) -> list[dict[str, Any]]:
389
+ return [o.model_dump() for o in self.tenants.orgs.values()]
390
+
391
+ @app.post("/api/tenants/projects")
392
+ def create_project(
393
+ request: TenantRequest,
394
+ principal: Principal = Depends(self._require_admin),
395
+ ) -> dict[str, Any]:
396
+ org_id = request.org_id
397
+ if org_id is None:
398
+ orgs = self.tenants.orgs_of(principal.user_id)
399
+ if not orgs:
400
+ raise HTTPException(status_code=400, detail="no organization; create one first")
401
+ org_id = orgs[0].org_id
402
+ return self.tenants.create_project(org_id, request.name).model_dump()
403
+
404
+ @app.get("/api/tenants/orgs/{org_id}/projects")
405
+ def list_projects(
406
+ org_id: str, principal: Principal = Depends(self._require_api_key)
407
+ ) -> list[dict[str, Any]]:
408
+ return [p.model_dump() for p in self.tenants.projects_of(org_id)]
409
+
410
+ # -- api keys ---------------------------------------------------------------
411
+
412
+ @app.post("/api/keys")
413
+ def issue_key(
414
+ request: KeyRequest,
415
+ principal: Principal = Depends(self._require_admin),
416
+ ) -> dict[str, Any]:
417
+ user_id = request.user_id or principal.user_id
418
+ raw, record = self.auth.issue(name=request.name, user_id=user_id, role=request.role)
419
+ payload = record.model_dump(exclude={"key_hash"})
420
+ payload["api_key"] = raw # returned exactly once
421
+ return payload
422
+
423
+ @app.delete("/api/keys/{key_id}")
424
+ def revoke_key(
425
+ key_id: str, principal: Principal = Depends(self._require_admin)
426
+ ) -> dict[str, bool]:
427
+ return {"revoked": self.auth.revoke(key_id)}