dbl-gateway 0.3.2__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.
dbl_gateway/app.py ADDED
@@ -0,0 +1,501 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import time
7
+ import uuid
8
+ from contextlib import asynccontextmanager
9
+ from datetime import datetime, timezone
10
+ from typing import Any, Mapping
11
+
12
+ from fastapi import Body, FastAPI, HTTPException, Query, Request
13
+ from fastapi.middleware.cors import CORSMiddleware
14
+ from fastapi.responses import JSONResponse, StreamingResponse
15
+ from dbl_core.normalize.trace import sanitize_trace
16
+ from dbl_core.events.trace_digest import trace_digest
17
+
18
+ from .admission import admit_and_shape_intent, AdmissionFailure
19
+ from .capabilities import get_capabilities
20
+ from .adapters.execution_adapter_kl import KlExecutionAdapter, schedule_execution
21
+ from .adapters.policy_adapter_dbl_policy import DblPolicyAdapter
22
+ from .ports.execution_port import ExecutionResult
23
+ from .ports.policy_port import DecisionResult
24
+ from .models import EventRecord
25
+ from .projection import project_runner_state, state_payload
26
+ from .store.factory import create_store
27
+ from .wire_contract import parse_intent_envelope
28
+ from .auth import (
29
+ Actor,
30
+ AuthError,
31
+ ForbiddenError,
32
+ authenticate_request,
33
+ require_roles,
34
+ require_tenant,
35
+ load_auth_config,
36
+ )
37
+
38
+
39
+ _LOGGER = logging.getLogger("dbl_gateway")
40
+
41
+
42
+ def _configure_logging() -> None:
43
+ if _LOGGER.handlers:
44
+ return
45
+ handler = logging.StreamHandler()
46
+ handler.setFormatter(logging.Formatter("%(message)s"))
47
+ _LOGGER.addHandler(handler)
48
+ _LOGGER.setLevel(logging.INFO)
49
+
50
+
51
+ def create_app() -> FastAPI:
52
+ @asynccontextmanager
53
+ async def lifespan(app: FastAPI):
54
+ _configure_logging()
55
+ app.state.store = create_store()
56
+ app.state.policy = DblPolicyAdapter()
57
+ app.state.execution = KlExecutionAdapter()
58
+ app.state.start_time = time.monotonic()
59
+ try:
60
+ yield
61
+ finally:
62
+ app.state.store.close()
63
+
64
+ app = FastAPI(title="DBL Gateway", lifespan=lifespan)
65
+ app.add_middleware(
66
+ CORSMiddleware,
67
+ allow_origins=["http://127.0.0.1:8787", "http://localhost:8787"],
68
+ allow_credentials=True,
69
+ allow_methods=["*"],
70
+ allow_headers=["*"],
71
+ )
72
+
73
+ @app.middleware("http")
74
+ async def request_logging(request: Request, call_next):
75
+ request_id = request.headers.get("x-request-id", "").strip() or uuid.uuid4().hex
76
+ request.state.request_id = request_id
77
+ start = time.monotonic()
78
+ response = await call_next(request)
79
+ response.headers["x-request-id"] = request_id
80
+ latency_ms = int((time.monotonic() - start) * 1000)
81
+ _LOGGER.info(
82
+ '{"message":"request.completed","request_id":"%s","method":"%s","path":"%s","status_code":%d,"latency_ms":%d}',
83
+ request_id,
84
+ request.method,
85
+ request.url.path,
86
+ response.status_code,
87
+ latency_ms,
88
+ )
89
+ return response
90
+
91
+ @app.get("/healthz")
92
+ async def healthz() -> dict[str, str]:
93
+ return {"status": "ok"}
94
+
95
+ @app.get("/capabilities")
96
+ async def capabilities(request: Request) -> dict[str, object]:
97
+ actor = await _require_actor(request)
98
+ _require_role(actor, ["gateway.snapshot.read"])
99
+ return get_capabilities()
100
+
101
+ @app.post("/ingress/intent")
102
+ async def ingress_intent(request: Request, body: dict[str, Any] = Body(...)) -> dict[str, Any]:
103
+ actor = await _require_actor(request)
104
+ _require_role(actor, ["gateway.intent.write"])
105
+ try:
106
+ envelope = parse_intent_envelope(body)
107
+ except ValueError as exc:
108
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
109
+
110
+ trace_id = uuid.uuid4().hex
111
+ intent_payload = envelope["payload"]
112
+ raw_payload = intent_payload["payload"]
113
+ shaped_payload = _shape_payload(intent_payload["intent_type"], raw_payload)
114
+ try:
115
+ admission_record = admit_and_shape_intent(
116
+ {
117
+ "correlation_id": envelope["correlation_id"],
118
+ "deterministic": {
119
+ "stream_id": intent_payload["stream_id"],
120
+ "lane": intent_payload["lane"],
121
+ "actor": intent_payload["actor"],
122
+ "intent_type": intent_payload["intent_type"],
123
+ "payload": shaped_payload,
124
+ },
125
+ "observational": {},
126
+ },
127
+ raw_payload=raw_payload,
128
+ )
129
+ except AdmissionFailure as exc:
130
+ return JSONResponse(
131
+ status_code=400,
132
+ content={"ok": False, "reason_code": exc.reason_code, "detail": exc.detail},
133
+ )
134
+ authoritative = _thaw_json(admission_record.deterministic)
135
+ authoritative["correlation_id"] = admission_record.correlation_id
136
+ if intent_payload.get("requested_model_id"):
137
+ authoritative["payload"]["requested_model_id"] = intent_payload["requested_model_id"]
138
+ _attach_obs_trace_id(authoritative["payload"], trace_id)
139
+ intent_event = app.state.store.append(
140
+ kind="INTENT",
141
+ lane=authoritative["lane"],
142
+ actor=authoritative["actor"],
143
+ intent_type=authoritative["intent_type"],
144
+ stream_id=authoritative["stream_id"],
145
+ correlation_id=envelope["correlation_id"],
146
+ payload=authoritative["payload"],
147
+ )
148
+
149
+ decision = app.state.policy.decide(authoritative)
150
+ decision_event = app.state.store.append(
151
+ kind="DECISION",
152
+ lane=authoritative["lane"],
153
+ actor="policy",
154
+ intent_type=authoritative["intent_type"],
155
+ stream_id=authoritative["stream_id"],
156
+ correlation_id=envelope["correlation_id"],
157
+ payload=_decision_payload(decision, trace_id),
158
+ )
159
+
160
+ if decision.decision == "ALLOW" and _get_exec_mode() == "embedded":
161
+ schedule_execution(_execute_background(app, intent_event, envelope["correlation_id"]))
162
+
163
+ return JSONResponse(
164
+ status_code=202,
165
+ content={
166
+ "ok": True,
167
+ "index": intent_event["index"],
168
+ "correlation_id": envelope["correlation_id"],
169
+ "stream_id": authoritative["stream_id"],
170
+ "lane": authoritative["lane"],
171
+ "intent_type": authoritative["intent_type"],
172
+ "accepted_at": datetime.now(timezone.utc).isoformat(),
173
+ "expected_next": "DECISION",
174
+ },
175
+ )
176
+
177
+ @app.get("/snapshot")
178
+ async def snapshot(
179
+ request: Request,
180
+ limit: int = Query(200, ge=1, le=2000),
181
+ offset: int = Query(0, ge=0),
182
+ stream_id: str | None = Query(None),
183
+ lane: str | None = Query(None),
184
+ ) -> dict[str, Any]:
185
+ actor = await _require_actor(request)
186
+ _require_role(actor, ["gateway.snapshot.read"])
187
+ return app.state.store.snapshot(
188
+ limit=limit,
189
+ offset=offset,
190
+ stream_id=_normalize_optional_str(stream_id, "stream_id"),
191
+ lane=_normalize_optional_str(lane, "lane"),
192
+ )
193
+
194
+ @app.get("/tail")
195
+ async def tail(
196
+ request: Request,
197
+ stream_id: str | None = Query(None),
198
+ since: int = Query(0, ge=0),
199
+ lanes: str | None = Query(None),
200
+ ) -> StreamingResponse:
201
+ actor = await _require_actor(request)
202
+ _require_role(actor, ["gateway.snapshot.read"])
203
+
204
+ last_event_id = request.headers.get("last-event-id")
205
+ if last_event_id and last_event_id.isdigit():
206
+ since = max(since, int(last_event_id) + 1)
207
+
208
+ lane_filter: set[str] | None = None
209
+ if lanes:
210
+ lane_filter = {lane.strip() for lane in lanes.split(",") if lane.strip()}
211
+ if not lane_filter:
212
+ lane_filter = None
213
+
214
+ async def event_stream():
215
+ cursor = since
216
+ while True:
217
+ if await request.is_disconnected():
218
+ break
219
+ snap = app.state.store.snapshot(
220
+ limit=2000,
221
+ offset=cursor,
222
+ stream_id=_normalize_optional_str(stream_id, "stream_id") if stream_id else None,
223
+ )
224
+ events = snap.get("events", [])
225
+ if not events:
226
+ await asyncio.sleep(0.5)
227
+ continue
228
+ max_index = cursor - 1
229
+ for event in events:
230
+ idx = event.get("index")
231
+ if isinstance(idx, int) and idx > max_index:
232
+ max_index = idx
233
+ if lane_filter and event.get("lane") not in lane_filter:
234
+ continue
235
+ data = json.dumps(event, ensure_ascii=True, separators=(",", ":"))
236
+ event_id = str(idx) if isinstance(idx, int) else ""
237
+ if event_id:
238
+ yield f"id: {event_id}\n"
239
+ yield "event: envelope\n"
240
+ yield f"data: {data}\n\n"
241
+ if max_index >= cursor:
242
+ cursor = max_index + 1
243
+
244
+ headers = {
245
+ "Cache-Control": "no-cache",
246
+ "Connection": "keep-alive",
247
+ }
248
+ return StreamingResponse(event_stream(), media_type="text/event-stream", headers=headers)
249
+
250
+ @app.get("/status")
251
+ async def status_surface(
252
+ request: Request,
253
+ stream_id: str | None = Query(None),
254
+ ) -> dict[str, object]:
255
+ actor = await _require_actor(request)
256
+ _require_role(actor, ["gateway.snapshot.read"])
257
+ snap = app.state.store.snapshot(limit=2000, offset=0, stream_id=stream_id)
258
+ state = project_runner_state(snap["events"])
259
+ return state_payload(state)
260
+
261
+ @app.post("/execution/event")
262
+ async def execution_event(request: Request, body: dict[str, Any] = Body(...)) -> dict[str, Any]:
263
+ actor = await _require_actor(request)
264
+ _require_role(actor, ["gateway.execution.write"])
265
+ if _get_exec_mode() != "external":
266
+ raise HTTPException(status_code=403, detail="execution events disabled in embedded mode")
267
+ correlation_id = body.get("correlation_id")
268
+ payload = body.get("payload")
269
+ if not isinstance(correlation_id, str) or not correlation_id:
270
+ raise HTTPException(status_code=400, detail="correlation_id must be a non-empty string")
271
+ if not isinstance(payload, Mapping):
272
+ raise HTTPException(status_code=400, detail="payload must be an object")
273
+ lane = str(body.get("lane", ""))
274
+ actor = str(body.get("actor", ""))
275
+ intent_type = str(body.get("intent_type", ""))
276
+ stream_id = str(body.get("stream_id", ""))
277
+ if not all([lane, actor, intent_type, stream_id]):
278
+ raise HTTPException(status_code=400, detail="lane, actor, intent_type, stream_id required")
279
+ if not _decision_allows_execution(app, correlation_id):
280
+ raise HTTPException(status_code=409, detail="no ALLOW decision for correlation_id")
281
+ p = dict(payload)
282
+ trace_value = p.get("trace")
283
+ if isinstance(trace_value, Mapping):
284
+ trace, trace_digest_value = make_trace_bundle(trace_value)
285
+ else:
286
+ trace, trace_digest_value = make_trace_bundle(
287
+ {
288
+ "trace_id": correlation_id,
289
+ "lane": lane,
290
+ "intent_type": intent_type,
291
+ "stream_id": stream_id,
292
+ }
293
+ )
294
+ p["trace"] = trace
295
+ p["trace_digest"] = trace_digest_value
296
+ event = app.state.store.append(
297
+ kind="EXECUTION",
298
+ lane=lane,
299
+ actor=actor,
300
+ intent_type=intent_type,
301
+ stream_id=stream_id,
302
+ correlation_id=correlation_id,
303
+ payload=p,
304
+ )
305
+ return {"ok": True, "execution_index": event["index"]}
306
+
307
+ return app
308
+
309
+
310
+ async def _execute_background(app: FastAPI, intent_event: EventRecord, correlation_id: str) -> None:
311
+ trace_id = _extract_trace_id(intent_event)
312
+ try:
313
+ result = await app.state.execution.run(intent_event)
314
+ payload = _execution_payload(result, trace_id)
315
+ except Exception as exc:
316
+ requested_model = ""
317
+ intent_payload = intent_event.get("payload")
318
+ if isinstance(intent_payload, Mapping):
319
+ requested_model = str(intent_payload.get("requested_model_id", "") or "")
320
+ trace, trace_digest_value = make_trace_bundle(
321
+ {
322
+ "trace_id": trace_id,
323
+ "error_type": type(exc).__name__,
324
+ "error": str(exc),
325
+ "lane": intent_event.get("lane"),
326
+ "actor": intent_event.get("actor"),
327
+ "intent_type": intent_event.get("intent_type"),
328
+ "stream_id": intent_event.get("stream_id"),
329
+ }
330
+ )
331
+ payload = {
332
+ "provider": "kl",
333
+ "model_id": requested_model or "unknown",
334
+ "error": f"{type(exc).__name__}: {exc}",
335
+ "trace": trace,
336
+ "trace_digest": trace_digest_value,
337
+ }
338
+ app.state.store.append(
339
+ kind="EXECUTION",
340
+ lane=intent_event["lane"],
341
+ actor="executor",
342
+ intent_type=intent_event["intent_type"],
343
+ stream_id=intent_event["stream_id"],
344
+ correlation_id=correlation_id,
345
+ payload=payload,
346
+ )
347
+
348
+
349
+ def _decision_payload(decision: DecisionResult, trace_id: str) -> dict[str, Any]:
350
+ payload: dict[str, Any] = {
351
+ "decision": decision.decision,
352
+ "reason_codes": decision.reason_codes,
353
+ }
354
+ if decision.policy_id:
355
+ payload["policy_id"] = decision.policy_id
356
+ if decision.policy_version is not None:
357
+ payload["policy_version"] = decision.policy_version
358
+ _attach_obs_trace_id(payload, trace_id)
359
+ return payload
360
+
361
+
362
+ def _execution_payload(result: ExecutionResult, trace_id: str) -> dict[str, Any]:
363
+ payload: dict[str, Any] = {
364
+ "provider": result.provider,
365
+ "model_id": result.model_id,
366
+ }
367
+
368
+ if result.error:
369
+ payload["error"] = result.error
370
+ else:
371
+ payload["output_text"] = result.output_text or ""
372
+
373
+ if isinstance(result.trace, Mapping):
374
+ raw_trace = dict(result.trace)
375
+ raw_trace.setdefault("trace_id", trace_id)
376
+ elif result.trace is not None:
377
+ raw_trace = {"trace_id": trace_id, "value": result.trace}
378
+ else:
379
+ raw_trace = {
380
+ "trace_id": trace_id,
381
+ "provider": result.provider,
382
+ "model_id": result.model_id,
383
+ "has_error": bool(result.error),
384
+ }
385
+ trace, trace_digest_value = make_trace_bundle(raw_trace)
386
+ payload["trace"] = trace
387
+ payload["trace_digest"] = trace_digest_value
388
+ return payload
389
+
390
+
391
+ def _decision_allows_execution(app: FastAPI, correlation_id: str) -> bool:
392
+ snap = app.state.store.snapshot(limit=2000, offset=0)
393
+ events = [e for e in snap["events"] if e["correlation_id"] == correlation_id]
394
+ for event in reversed(events):
395
+ if event["kind"] == "DECISION":
396
+ payload = event["payload"]
397
+ if isinstance(payload, Mapping):
398
+ return payload.get("decision") == "ALLOW"
399
+ return False
400
+ return False
401
+
402
+
403
+ def _normalize_optional_str(value: str | None, name: str) -> str | None:
404
+ if value is None:
405
+ return None
406
+ if value.strip() == "":
407
+ raise HTTPException(status_code=400, detail=f"{name} must be a non-empty string")
408
+ return value.strip()
409
+
410
+
411
+ def _get_exec_mode() -> str:
412
+ import os
413
+
414
+ return os.getenv("GATEWAY_EXEC_MODE", "embedded").strip().lower()
415
+
416
+
417
+ async def _require_actor(request: Request) -> Actor:
418
+ cfg = load_auth_config()
419
+ try:
420
+ actor = await authenticate_request(request.headers, cfg)
421
+ require_tenant(actor, cfg)
422
+ return actor
423
+ except AuthError as exc:
424
+ raise HTTPException(status_code=401, detail=str(exc)) from exc
425
+ except ForbiddenError as exc:
426
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
427
+
428
+
429
+ def _require_role(actor: Actor, roles: list[str]) -> None:
430
+ try:
431
+ require_roles(actor, roles)
432
+ except ForbiddenError as exc:
433
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
434
+
435
+
436
+ def _attach_obs_trace_id(payload: dict[str, Any], trace_id: str) -> None:
437
+ obs = payload.get("_obs")
438
+ if not isinstance(obs, dict):
439
+ obs = {}
440
+ payload["_obs"] = obs
441
+ obs["trace_id"] = trace_id
442
+
443
+
444
+ def _shape_payload(intent_type: str, payload: Mapping[str, Any]) -> dict[str, Any]:
445
+ if intent_type == "chat.message":
446
+ shaped: dict[str, Any] = {}
447
+ message = payload.get("message")
448
+ if isinstance(message, str):
449
+ shaped["message"] = message
450
+ client_msg_id = payload.get("client_msg_id")
451
+ if isinstance(client_msg_id, str) and client_msg_id.strip():
452
+ shaped["client_msg_id"] = client_msg_id
453
+ return shaped
454
+ return dict(payload)
455
+
456
+
457
+ def _thaw_json(value: Any) -> Any:
458
+ if isinstance(value, Mapping):
459
+ return {str(k): _thaw_json(v) for k, v in value.items()}
460
+ if isinstance(value, tuple):
461
+ return [_thaw_json(item) for item in value]
462
+ return value
463
+
464
+
465
+ def _extract_trace_id(intent_event: EventRecord) -> str:
466
+ payload = intent_event.get("payload")
467
+ if isinstance(payload, Mapping):
468
+ obs = payload.get("_obs")
469
+ if isinstance(obs, Mapping):
470
+ trace_id = obs.get("trace_id")
471
+ if isinstance(trace_id, str) and trace_id.strip():
472
+ return trace_id
473
+ return uuid.uuid4().hex
474
+
475
+
476
+ def make_trace_bundle(raw_trace: Mapping[str, Any]) -> tuple[dict[str, Any], str]:
477
+ trace = sanitize_trace(raw_trace)
478
+ return trace, trace_digest(trace)
479
+
480
+
481
+ def main() -> None:
482
+ import argparse
483
+ import uvicorn
484
+
485
+ parser = argparse.ArgumentParser(prog="dbl-gateway")
486
+ sub = parser.add_subparsers(dest="command", required=True)
487
+ serve = sub.add_parser("serve")
488
+ serve.add_argument("--host", default="127.0.0.1")
489
+ serve.add_argument("--port", type=int, default=8010)
490
+ serve.add_argument("--db", default=".\\data\\trail.sqlite")
491
+ args = parser.parse_args()
492
+
493
+ if args.db:
494
+ import os
495
+
496
+ os.environ["DBL_GATEWAY_DB"] = str(args.db)
497
+ app = create_app()
498
+ uvicorn.run(app, host=args.host, port=args.port, reload=False)
499
+
500
+
501
+ app = create_app()