fakzed-server 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,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
+ ]
fakzed_server/app.py ADDED
@@ -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)}
fakzed_server/auth.py ADDED
@@ -0,0 +1,96 @@
1
+ """API key authentication and authorization for the platform API.
2
+
3
+ Each API key belongs to a user and carries a role. Keys are verified via a
4
+ FastAPI dependency that reads the `X-API-Key` header; endpoints opt into
5
+ required roles with `require_role("admin")`-style guards.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import secrets
12
+ import uuid
13
+ from datetime import datetime, timezone
14
+
15
+ from pydantic import BaseModel, Field, PrivateAttr
16
+
17
+
18
+ def _now() -> datetime:
19
+ return datetime.now(timezone.utc)
20
+
21
+
22
+ class ApiKey(BaseModel):
23
+ """A bearer credential: the raw key is issued once, then stored hashed."""
24
+
25
+ key_id: str = Field(default_factory=lambda: uuid.uuid4().hex[:12])
26
+ name: str
27
+ user_id: str
28
+ role: str = "user" # user | admin
29
+ scopes: list[str] = Field(default_factory=list)
30
+ key_hash: str | None = None
31
+ created_at: datetime = Field(default_factory=_now)
32
+
33
+
34
+ class Principal(BaseModel):
35
+ """The authenticated identity attached to a request."""
36
+
37
+ user_id: str
38
+ role: str
39
+ key_id: str
40
+ scopes: list[str] = Field(default_factory=list)
41
+
42
+
43
+ def hash_key(raw: str) -> str:
44
+ return hashlib.sha256(raw.encode()).hexdigest()
45
+
46
+
47
+ class ApiKeyStore(BaseModel):
48
+ """Issues and verifies API keys; keys are only stored as hashes."""
49
+
50
+ keys: dict[str, ApiKey] = Field(default_factory=dict)
51
+ _by_hash: dict[str, str] = PrivateAttr(default_factory=dict)
52
+
53
+ def issue(
54
+ self,
55
+ *,
56
+ name: str,
57
+ user_id: str,
58
+ role: str = "user",
59
+ scopes: list[str] | None = None,
60
+ ) -> tuple[str, ApiKey]:
61
+ """Create a new key and return (raw_key, record). Store the hash only."""
62
+ raw = "fz_" + secrets.token_hex(24)
63
+ key_hash = hash_key(raw)
64
+ record = ApiKey(
65
+ name=name,
66
+ user_id=user_id,
67
+ role=role,
68
+ scopes=scopes or [],
69
+ key_hash=key_hash,
70
+ )
71
+ self.keys[record.key_id] = record
72
+ self._by_hash[key_hash] = record.key_id
73
+ return raw, record
74
+
75
+ def authenticate(self, raw: str | None) -> Principal | None:
76
+ """Resolve a raw key to a Principal, or None if invalid."""
77
+ if not raw:
78
+ return None
79
+ key_id = self._by_hash.get(hash_key(raw))
80
+ if key_id is None:
81
+ return None
82
+ record = self.keys[key_id]
83
+ return Principal(
84
+ user_id=record.user_id,
85
+ role=record.role,
86
+ key_id=record.key_id,
87
+ scopes=record.scopes,
88
+ )
89
+
90
+ def revoke(self, key_id: str) -> bool:
91
+ record = self.keys.pop(key_id, None)
92
+ if record is None:
93
+ return False
94
+ if record.key_hash is not None:
95
+ self._by_hash.pop(record.key_hash, None)
96
+ return True
fakzed_server/main.py ADDED
@@ -0,0 +1,31 @@
1
+ """CLI entry point: `uv run fakzed-server` starts the platform server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from fakzed_server.app import FakzedServer
8
+
9
+
10
+ def main() -> None:
11
+ parser = argparse.ArgumentParser(description="Fakzed platform server")
12
+ parser.add_argument("--host", default="127.0.0.1")
13
+ parser.add_argument("--port", type=int, default=8000)
14
+ parser.add_argument("--db", default="fakzed_server.json", help="JSON persistence file")
15
+ parser.add_argument("--admin-key", default=None, help="bootstrap admin API key")
16
+ parser.add_argument("--no-auth", action="store_true", help="disable API-key auth")
17
+ args = parser.parse_args()
18
+
19
+ server = FakzedServer(
20
+ db_path=args.db,
21
+ auth_enabled=not args.no_auth,
22
+ admin_api_key=args.admin_key,
23
+ )
24
+
25
+ import uvicorn
26
+
27
+ uvicorn.run(server.app, host=args.host, port=args.port)
28
+
29
+
30
+ if __name__ == "__main__":
31
+ main()
@@ -0,0 +1,49 @@
1
+ """Tracing & monitoring: expose SDK observability backends over the API.
2
+
3
+ The platform taps the same `EventBus`/`Tracer`/`MetricsCollector` the SDK
4
+ uses, so one `RunObserver` shared with the served agents gives the server
5
+ visibility into every run without a second instrumentation pass.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+ from fakzed.observability import (
13
+ EventBus,
14
+ MetricsCollector,
15
+ RunEvent,
16
+ RunObserver,
17
+ Trace,
18
+ Tracer,
19
+ )
20
+
21
+
22
+ class MonitoringStore(BaseModel):
23
+ """Aggregates platform-wide observability state and query helpers."""
24
+
25
+ model_config = {"arbitrary_types_allowed": True}
26
+
27
+ event_bus: EventBus = Field(default_factory=EventBus)
28
+ tracer: Tracer = Field(default_factory=Tracer)
29
+ metrics: MetricsCollector = Field(default_factory=MetricsCollector)
30
+
31
+ def observer(self) -> RunObserver:
32
+ """A RunObserver wired to this store, attachable to served agents."""
33
+ return RunObserver(event_bus=self.event_bus, tracer=self.tracer, metrics=self.metrics)
34
+
35
+ def recent_events(self, limit: int = 200) -> list[RunEvent]:
36
+ return list(self.event_bus.history[-limit:])
37
+
38
+ def traces(self, limit: int = 20) -> list[Trace]:
39
+ return self.tracer.all_traces()[-limit:]
40
+
41
+ def summary(self) -> dict:
42
+ return self.metrics.summary().model_dump()
43
+
44
+ def cost_by_agent(self) -> dict[str, float]:
45
+ costs: dict[str, float] = {}
46
+ for run in self.metrics.all():
47
+ if run.agent_name:
48
+ costs[run.agent_name] = costs.get(run.agent_name, 0.0) + run.cost_usd
49
+ return costs
@@ -0,0 +1,57 @@
1
+ """Platform-level persistence: run records and API keys survive restarts.
2
+
3
+ A small JSON-file backend with a threading lock keeps the platform layer
4
+ dependency-free while still restoring runs (status/results) and sessions
5
+ across process restarts. Sessions themselves live in the SDK `Storage`
6
+ backend the served agents are configured with.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import threading
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from pydantic import BaseModel, PrivateAttr
17
+
18
+
19
+ class JsonStore(BaseModel):
20
+ """Append-friendly JSON persistence: a dict namespace on disk."""
21
+
22
+ path: Path
23
+ _lock: threading.Lock = PrivateAttr(default_factory=threading.Lock)
24
+ _data: dict[str, Any] = PrivateAttr(default_factory=dict)
25
+
26
+ def model_post_init(self, _: Any) -> None:
27
+ if self.path.exists():
28
+ try:
29
+ with self.path.open(encoding="utf-8") as fh:
30
+ self._data = json.load(fh)
31
+ except (json.JSONDecodeError, OSError):
32
+ self._data = {}
33
+
34
+ def put(self, key: str, value: Any) -> None:
35
+ with self._lock:
36
+ self._data[key] = value
37
+ self._flush()
38
+
39
+ def get(self, key: str, default: Any = None) -> Any:
40
+ with self._lock:
41
+ return self._data.get(key, default)
42
+
43
+ def pop(self, key: str, default: Any = None) -> Any:
44
+ with self._lock:
45
+ value = self._data.pop(key, default)
46
+ self._flush()
47
+ return value
48
+
49
+ def all(self) -> dict[str, Any]:
50
+ with self._lock:
51
+ return dict(self._data)
52
+
53
+ def _flush(self) -> None:
54
+ tmp = self.path.with_suffix(self.path.suffix + ".tmp")
55
+ with tmp.open("w", encoding="utf-8") as fh:
56
+ json.dump(self._data, fh, ensure_ascii=False, default=str)
57
+ tmp.replace(self.path)
fakzed_server/py.typed ADDED
File without changes
@@ -0,0 +1,90 @@
1
+ """Agent registry: a single place to register SDK objects on the platform.
2
+
3
+ The same registration mechanism is shared between the SDK and the platform:
4
+ an Agent (or Team/Workflow) created locally is `register`-ed here and then
5
+ served over HTTP, while a `RemoteAgent` on the SDK side calls it back by name.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+ from typing import Any, Protocol
12
+
13
+ from pydantic import BaseModel, Field, PrivateAttr
14
+
15
+
16
+ class Registered(BaseModel):
17
+ """Metadata for one registered SDK object."""
18
+
19
+ name: str
20
+ kind: str
21
+ description: str | None = None
22
+ project_id: str = "default"
23
+ enabled: bool = True
24
+ instance: Any = Field(exclude=True)
25
+
26
+ #: Serializes requests touching the instance (session state is mutable).
27
+ _lock: threading.Lock = PrivateAttr(default_factory=threading.Lock)
28
+
29
+
30
+ class RegistryEntry(Protocol):
31
+ """Minimal protocol any registrable SDK object must satisfy."""
32
+
33
+ name: str | None
34
+ description: str | None
35
+
36
+ def run(self, message: Any, **kwargs: Any) -> Any: ...
37
+ def arun(self, message: Any, **kwargs: Any) -> Any: ...
38
+ def resume(self) -> Any: ...
39
+ def aresume(self) -> Any: ...
40
+ def run_stream(self, message: Any, **kwargs: Any) -> Any: ...
41
+ def arun_stream(self, message: Any, **kwargs: Any) -> Any: ...
42
+
43
+
44
+ class AgentRegistry(BaseModel):
45
+ """Name-addressed registry of SDK objects (Agent/Team/Workflow)."""
46
+
47
+ entries: dict[str, Registered] = Field(default_factory=dict)
48
+
49
+ def register(
50
+ self,
51
+ instance: Any,
52
+ *,
53
+ name: str | None = None,
54
+ project_id: str = "default",
55
+ ) -> Registered:
56
+ """Register an SDK object under `name` (defaults to instance.name).
57
+
58
+ Re-registering under the same name replaces the previous entry.
59
+ """
60
+ resolved = name or getattr(instance, "name", None)
61
+ if not resolved:
62
+ raise ValueError("register requires a name (set instance.name or pass name=)")
63
+ kind = type(instance).__name__
64
+ entry = Registered(
65
+ name=resolved,
66
+ kind=kind,
67
+ description=getattr(instance, "description", None),
68
+ project_id=project_id,
69
+ instance=instance,
70
+ )
71
+ self.entries[resolved] = entry
72
+ return entry
73
+
74
+ def get(self, name: str) -> Registered:
75
+ entry = self.entries.get(name)
76
+ if entry is None:
77
+ raise KeyError(f"no agent registered as '{name}'")
78
+ if not entry.enabled:
79
+ raise PermissionError(f"agent '{name}' is disabled")
80
+ return entry
81
+
82
+ def unregister(self, name: str) -> bool:
83
+ return self.entries.pop(name, None) is not None
84
+
85
+ def list(self, project_id: str | None = None) -> list[Registered]:
86
+ """List registered entries, optionally filtered by project."""
87
+ entries = list(self.entries.values())
88
+ if project_id is not None:
89
+ entries = [e for e in entries if e.project_id == project_id]
90
+ return entries
fakzed_server/runs.py ADDED
@@ -0,0 +1,109 @@
1
+ """Run management: long-running agent executions behind create/query/cancel.
2
+
3
+ Reuses the SDK `BackgroundRunner` (thread pool + wait/cancel polling) so the
4
+ platform inherits the same semantics as embedded usage. Run records are
5
+ persisted via `JsonStore` and restored on restart.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel, Field
14
+
15
+ from fakzed.runtime import BackgroundRunner
16
+
17
+
18
+ class RunRecord(BaseModel):
19
+ """Persistable snapshot of a background run."""
20
+
21
+ run_id: str
22
+ agent: str
23
+ status: str
24
+ input: str | None = None
25
+ output: str | None = None
26
+ error: str | None = None
27
+
28
+
29
+ class RunManager(BaseModel):
30
+ """Submits agent calls to a BackgroundRunner and persists the outcome."""
31
+
32
+ runner: BackgroundRunner = Field(default_factory=BackgroundRunner)
33
+ store: Any = Field(default=None, exclude=True)
34
+ agent_factory: Callable[[str], Any] | None = Field(default=None, exclude=True)
35
+
36
+ def submit(
37
+ self,
38
+ agent_name: str,
39
+ message: str,
40
+ *,
41
+ session_id: str | None = None,
42
+ stop_after_tool_call: bool = False,
43
+ ) -> RunRecord:
44
+ """Start a background run of a registered agent; returns the record."""
45
+ run_id = self.runner.submit(
46
+ lambda: self._call(agent_name, message, session_id, stop_after_tool_call)
47
+ )
48
+ record = RunRecord(run_id=run_id, agent=agent_name, status="pending", input=message)
49
+ self._persist(record)
50
+ return record
51
+
52
+ def _call(
53
+ self,
54
+ agent_name: str,
55
+ message: str,
56
+ session_id: str | None,
57
+ stop_after_tool_call: bool,
58
+ ) -> Any:
59
+ """Resolve the registered instance and run it in the worker thread."""
60
+ if self.agent_factory is None:
61
+ raise RuntimeError("RunManager.agent_factory is not wired")
62
+ instance = self.agent_factory(agent_name)
63
+ if session_id and getattr(instance, "session_id", None) is None:
64
+ instance.session_id = session_id
65
+ return instance.run(message, stop_after_tool_call=stop_after_tool_call)
66
+
67
+ def get(self, run_id: str) -> RunRecord | None:
68
+ """Return the current (in-memory) state of a run."""
69
+ run = self.runner.get(run_id)
70
+ record = self._persisted(run_id)
71
+ if run is None:
72
+ return record
73
+ if record is not None:
74
+ record.status = run.status.value
75
+ record.output = self._content_of(run.result)
76
+ record.error = run.error
77
+ self._persist(record)
78
+ return record
79
+
80
+ def wait(self, run_id: str, timeout: float = 300.0) -> RunRecord | None:
81
+ run = self.runner.wait(run_id, timeout=timeout)
82
+ if run is None:
83
+ return self._persisted(run_id)
84
+ record = self._persisted(run_id) or RunRecord(run_id=run_id, agent="?", status="running")
85
+ record.status = run.status.value
86
+ record.output = self._content_of(run.result)
87
+ record.error = run.error
88
+ self._persist(record)
89
+ return record
90
+
91
+ def cancel(self, run_id: str) -> bool:
92
+ return self.runner.cancel(run_id)
93
+
94
+ def list(self) -> list[RunRecord]:
95
+ records = [r for r in self.store.all().values() if isinstance(r, dict)]
96
+ return [RunRecord(**r) for r in records]
97
+
98
+ @staticmethod
99
+ def _content_of(result: Any) -> str | None:
100
+ content = getattr(result, "content", None)
101
+ return content if isinstance(content, str) else str(content) if content else None
102
+
103
+ def _persisted(self, run_id: str) -> RunRecord | None:
104
+ raw = self.store.get(run_id) if self.store is not None else None
105
+ return RunRecord(**raw) if isinstance(raw, dict) else None
106
+
107
+ def _persist(self, record: RunRecord) -> None:
108
+ if self.store is not None:
109
+ self.store.put(record.run_id, record.model_dump())
@@ -0,0 +1,99 @@
1
+ """Multi-tenancy: user / organization / project three-level data model.
2
+
3
+ Tenant boundaries keep every registered agent, run, and API key scoped to a
4
+ project owned by an organization; users get access through membership records.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import uuid
10
+ from datetime import datetime, timezone
11
+
12
+ from pydantic import BaseModel, Field
13
+
14
+
15
+ def _new_id() -> str:
16
+ return uuid.uuid4().hex[:12]
17
+
18
+
19
+ def _now() -> datetime:
20
+ return datetime.now(timezone.utc)
21
+
22
+
23
+ class User(BaseModel):
24
+ """A platform user (identity holder for API keys)."""
25
+
26
+ user_id: str = Field(default_factory=_new_id)
27
+ name: str
28
+ email: str | None = None
29
+ created_at: datetime = Field(default_factory=_now)
30
+
31
+
32
+ class Organization(BaseModel):
33
+ """An organization: a group of users with shared projects."""
34
+
35
+ org_id: str = Field(default_factory=_new_id)
36
+ name: str
37
+ created_at: datetime = Field(default_factory=_now)
38
+
39
+
40
+ class Project(BaseModel):
41
+ """A project: the tenant scope agents, runs, and sessions belong to."""
42
+
43
+ project_id: str = Field(default_factory=_new_id)
44
+ org_id: str
45
+ name: str
46
+ created_at: datetime = Field(default_factory=_now)
47
+
48
+
49
+ class Membership(BaseModel):
50
+ """A user's membership in an organization."""
51
+
52
+ user_id: str
53
+ org_id: str
54
+ role: str = "member" # member | admin
55
+
56
+
57
+ class TenantStore(BaseModel):
58
+ """In-memory tenant directory with create/list/join operations."""
59
+
60
+ users: dict[str, User] = Field(default_factory=dict)
61
+ orgs: dict[str, Organization] = Field(default_factory=dict)
62
+ projects: dict[str, Project] = Field(default_factory=dict)
63
+ memberships: list[Membership] = Field(default_factory=list)
64
+
65
+ def create_user(self, name: str, *, email: str | None = None) -> User:
66
+ user = User(name=name, email=email)
67
+ self.users[user.user_id] = user
68
+ return user
69
+
70
+ def create_org(self, name: str, *, owner_id: str) -> Organization:
71
+ org = Organization(name=name)
72
+ self.orgs[org.org_id] = org
73
+ self.memberships.append(Membership(user_id=owner_id, org_id=org.org_id, role="admin"))
74
+ return org
75
+
76
+ def create_project(self, org_id: str, name: str) -> Project:
77
+ if org_id not in self.orgs:
78
+ raise KeyError(f"unknown organization '{org_id}'")
79
+ project = Project(org_id=org_id, name=name)
80
+ self.projects[project.project_id] = project
81
+ return project
82
+
83
+ def add_member(self, org_id: str, user_id: str, role: str = "member") -> None:
84
+ if org_id not in self.orgs:
85
+ raise KeyError(f"unknown organization '{org_id}'")
86
+ self.memberships.append(Membership(user_id=user_id, org_id=org_id, role=role))
87
+
88
+ def projects_of(self, org_id: str) -> list[Project]:
89
+ return [p for p in self.projects.values() if p.org_id == org_id]
90
+
91
+ def orgs_of(self, user_id: str) -> list[Organization]:
92
+ ids = {m.org_id for m in self.memberships if m.user_id == user_id}
93
+ return [org for org in self.orgs.values() if org.org_id in ids]
94
+
95
+ def role_in(self, user_id: str, org_id: str) -> str | None:
96
+ for membership in self.memberships:
97
+ if membership.user_id == user_id and membership.org_id == org_id:
98
+ return membership.role
99
+ return None
@@ -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,15 @@
1
+ fakzed_server/__init__.py,sha256=-9X3dr8G9D0zcReIgTBa4L1TDsWrjX-Y0PZs6w9S-ak,762
2
+ fakzed_server/app.py,sha256=N_z-51Pp0ioGY5SAfMtWOmRuRYsGcBD01elPBeA-rC4,16724
3
+ fakzed_server/auth.py,sha256=-QwPLqdFkuhPzTx2mqWGVQ_ZTw-3I0bF1W2AMuGGSpw,2866
4
+ fakzed_server/main.py,sha256=n08Hp9gakyoFgpQPI9WowDSF42_rbmFlOMo5OJq4aJw,964
5
+ fakzed_server/monitoring.py,sha256=kZ2JVwQ18JzHf8nAE7ssHcHSYa0TzjbAGcKSHGSCBXM,1672
6
+ fakzed_server/persistence.py,sha256=K-4GrQDkT87AuHB8Wx5B7CTC9OJV9pJtyp_hJ9n1dck,1895
7
+ fakzed_server/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ fakzed_server/registry.py,sha256=sXl2QhTHhTnNjHRLThf7nRlwIQE54ozi723bzgBqnMo,3096
9
+ fakzed_server/runs.py,sha256=n4zPNP_KUTp6bUWgguqhT9Hx6ZWrzog7gW1_q8A6ZPw,4012
10
+ fakzed_server/tenancy.py,sha256=uc5KBmiGDo5yXACuPHOJ3Fdw5uODfVX8gY7jsLfR7sc,3369
11
+ fakzed_server-0.1.0.dist-info/METADATA,sha256=w87Y4z3Iq8g9l7UbrDW1ee2Wtl6fElt8nA6azsIDdig,1838
12
+ fakzed_server-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
13
+ fakzed_server-0.1.0.dist-info/entry_points.txt,sha256=ZY0Ronq4gB0DAHJR82wSIWyMMspIf2GKREhCs1KCAp0,58
14
+ fakzed_server-0.1.0.dist-info/licenses/LICENSE,sha256=gXW5og8O6NiiOnH4TJMiDbYB7GGwDHacdDEEmEEOZfY,1097
15
+ fakzed_server-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fakzed-server = fakzed_server.main:main
@@ -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.