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.
- fakzed_server/__init__.py +26 -0
- fakzed_server/app.py +427 -0
- fakzed_server/auth.py +96 -0
- fakzed_server/main.py +31 -0
- fakzed_server/monitoring.py +49 -0
- fakzed_server/persistence.py +57 -0
- fakzed_server/py.typed +0 -0
- fakzed_server/registry.py +90 -0
- fakzed_server/runs.py +109 -0
- fakzed_server/tenancy.py +99 -0
- fakzed_server-0.1.0.dist-info/METADATA +56 -0
- fakzed_server-0.1.0.dist-info/RECORD +15 -0
- fakzed_server-0.1.0.dist-info/WHEEL +4 -0
- fakzed_server-0.1.0.dist-info/entry_points.txt +2 -0
- fakzed_server-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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())
|
fakzed_server/tenancy.py
ADDED
|
@@ -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,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.
|