agentflow-runtime 1.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.
Files changed (100) hide show
  1. agentflow_runtime-1.1.0.dist-info/METADATA +55 -0
  2. agentflow_runtime-1.1.0.dist-info/RECORD +100 -0
  3. agentflow_runtime-1.1.0.dist-info/WHEEL +4 -0
  4. agentflow_runtime-1.1.0.dist-info/licenses/LICENSE +21 -0
  5. src/__init__.py +0 -0
  6. src/constants.py +3 -0
  7. src/ingestion/__init__.py +0 -0
  8. src/ingestion/cdc/__init__.py +5 -0
  9. src/ingestion/cdc/normalizer.py +186 -0
  10. src/ingestion/connectors/__init__.py +0 -0
  11. src/ingestion/connectors/mysql_cdc.py +63 -0
  12. src/ingestion/connectors/postgres_cdc.py +68 -0
  13. src/ingestion/producers/__init__.py +0 -0
  14. src/ingestion/producers/event_producer.py +237 -0
  15. src/ingestion/schemas/__init__.py +0 -0
  16. src/ingestion/schemas/events.py +147 -0
  17. src/ingestion/tenant_router.py +80 -0
  18. src/logger.py +41 -0
  19. src/orchestration/__init__.py +0 -0
  20. src/orchestration/dags/__init__.py +0 -0
  21. src/orchestration/dags/daily_batch.py +201 -0
  22. src/processing/__init__.py +0 -0
  23. src/processing/event_replayer.py +250 -0
  24. src/processing/flink_jobs/Dockerfile +55 -0
  25. src/processing/flink_jobs/__init__.py +0 -0
  26. src/processing/flink_jobs/checkpointing.py +32 -0
  27. src/processing/flink_jobs/session_aggregation.py +212 -0
  28. src/processing/flink_jobs/session_aggregator.py +199 -0
  29. src/processing/flink_jobs/stream_processor.py +316 -0
  30. src/processing/iceberg_sink.py +348 -0
  31. src/processing/local_pipeline.py +452 -0
  32. src/processing/outbox.py +273 -0
  33. src/processing/tracing.py +36 -0
  34. src/processing/transformations/__init__.py +0 -0
  35. src/processing/transformations/enrichment.py +125 -0
  36. src/quality/__init__.py +0 -0
  37. src/quality/monitors/__init__.py +0 -0
  38. src/quality/monitors/freshness_monitor.py +166 -0
  39. src/quality/monitors/metrics_collector.py +367 -0
  40. src/quality/validators/__init__.py +0 -0
  41. src/quality/validators/schema_validator.py +119 -0
  42. src/quality/validators/semantic_validator.py +202 -0
  43. src/serving/__init__.py +0 -0
  44. src/serving/api/__init__.py +0 -0
  45. src/serving/api/alert_dispatcher.py +51 -0
  46. src/serving/api/alerts/__init__.py +38 -0
  47. src/serving/api/alerts/dispatcher.py +299 -0
  48. src/serving/api/alerts/escalation.py +290 -0
  49. src/serving/api/alerts/evaluator.py +81 -0
  50. src/serving/api/alerts/history.py +115 -0
  51. src/serving/api/analytics.py +543 -0
  52. src/serving/api/auth/__init__.py +46 -0
  53. src/serving/api/auth/key_rotation.py +400 -0
  54. src/serving/api/auth/manager.py +406 -0
  55. src/serving/api/auth/middleware.py +331 -0
  56. src/serving/api/main.py +390 -0
  57. src/serving/api/middleware/logging.py +41 -0
  58. src/serving/api/middleware/tracing.py +51 -0
  59. src/serving/api/rate_limiter.py +76 -0
  60. src/serving/api/routers/__init__.py +0 -0
  61. src/serving/api/routers/admin.py +150 -0
  62. src/serving/api/routers/admin_ui.py +93 -0
  63. src/serving/api/routers/agent_query.py +639 -0
  64. src/serving/api/routers/alerts.py +134 -0
  65. src/serving/api/routers/batch.py +231 -0
  66. src/serving/api/routers/contracts.py +98 -0
  67. src/serving/api/routers/deadletter.py +337 -0
  68. src/serving/api/routers/lineage.py +218 -0
  69. src/serving/api/routers/search.py +103 -0
  70. src/serving/api/routers/slo.py +231 -0
  71. src/serving/api/routers/stream.py +141 -0
  72. src/serving/api/routers/webhooks.py +93 -0
  73. src/serving/api/security.py +83 -0
  74. src/serving/api/telemetry.py +66 -0
  75. src/serving/api/templates/admin.html +214 -0
  76. src/serving/api/versioning.py +328 -0
  77. src/serving/api/webhook_dispatcher.py +423 -0
  78. src/serving/backends/__init__.py +117 -0
  79. src/serving/backends/clickhouse_backend.py +310 -0
  80. src/serving/backends/duckdb_backend.py +268 -0
  81. src/serving/cache.py +169 -0
  82. src/serving/db_pool.py +105 -0
  83. src/serving/masking.py +122 -0
  84. src/serving/semantic_layer/__init__.py +0 -0
  85. src/serving/semantic_layer/catalog.py +177 -0
  86. src/serving/semantic_layer/contract_registry.py +258 -0
  87. src/serving/semantic_layer/entity_type_registry.py +107 -0
  88. src/serving/semantic_layer/nl_engine.py +189 -0
  89. src/serving/semantic_layer/query/__init__.py +3 -0
  90. src/serving/semantic_layer/query/contracts.py +47 -0
  91. src/serving/semantic_layer/query/engine.py +81 -0
  92. src/serving/semantic_layer/query/entity_queries.py +221 -0
  93. src/serving/semantic_layer/query/metric_queries.py +84 -0
  94. src/serving/semantic_layer/query/nl_queries.py +305 -0
  95. src/serving/semantic_layer/query/sql_builder.py +113 -0
  96. src/serving/semantic_layer/query/sql_guard.py +3 -0
  97. src/serving/semantic_layer/query_engine.py +5 -0
  98. src/serving/semantic_layer/schema_evolution.py +175 -0
  99. src/serving/semantic_layer/search_index.py +337 -0
  100. src/serving/semantic_layer/sql_guard.py +56 -0
@@ -0,0 +1,423 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ import os
8
+ import secrets
9
+ import uuid
10
+ from datetime import UTC, date, datetime
11
+ from decimal import Decimal
12
+ from pathlib import Path
13
+
14
+ import duckdb
15
+ import httpx
16
+ import structlog
17
+ from pydantic import BaseModel, Field
18
+
19
+ try:
20
+ import yaml # type: ignore[import-untyped]
21
+ except ImportError: # pragma: no cover
22
+ yaml = None
23
+
24
+ logger = structlog.get_logger()
25
+
26
+ DEFAULT_WEBHOOKS_CONFIG_PATH = Path(os.getenv("AGENTFLOW_WEBHOOKS_FILE", "config/webhooks.yaml"))
27
+
28
+
29
+ class WebhookFilters(BaseModel):
30
+ event_types: list[str] | None = None
31
+ entity_ids: list[str] | None = None
32
+ min_amount: float | None = None
33
+
34
+
35
+ class WebhookRegistration(BaseModel):
36
+ id: str
37
+ url: str
38
+ secret: str
39
+ tenant: str
40
+ filters: WebhookFilters = Field(default_factory=WebhookFilters)
41
+ created_at: datetime
42
+ active: bool = True
43
+
44
+
45
+ class WebhookConfig(BaseModel):
46
+ webhooks: list[WebhookRegistration] = Field(default_factory=list)
47
+
48
+
49
+ def get_webhook_config_path(app) -> Path:
50
+ configured = getattr(app.state, "webhook_config_path", None)
51
+ return Path(configured) if configured else DEFAULT_WEBHOOKS_CONFIG_PATH
52
+
53
+
54
+ def load_webhooks(path: Path) -> list[WebhookRegistration]:
55
+ if not path.exists():
56
+ return []
57
+ raw = path.read_text(encoding="utf-8")
58
+ if not raw.strip():
59
+ return []
60
+ data = yaml.safe_load(raw) if yaml is not None else json.loads(raw)
61
+ config = WebhookConfig.model_validate(data or {})
62
+ return config.webhooks
63
+
64
+
65
+ def save_webhooks(path: Path, webhooks: list[WebhookRegistration]) -> None:
66
+ path.parent.mkdir(parents=True, exist_ok=True)
67
+ payload = WebhookConfig(webhooks=webhooks).model_dump(mode="json")
68
+ content = (
69
+ yaml.safe_dump(payload, sort_keys=False)
70
+ if yaml is not None
71
+ else json.dumps(payload, indent=2)
72
+ )
73
+ path.write_text(content, encoding="utf-8")
74
+
75
+
76
+ def create_webhook(
77
+ path: Path,
78
+ *,
79
+ url: str,
80
+ tenant: str,
81
+ filters: WebhookFilters,
82
+ ) -> WebhookRegistration:
83
+ webhooks = load_webhooks(path)
84
+ registration = WebhookRegistration(
85
+ id=str(uuid.uuid4()),
86
+ url=url,
87
+ secret=secrets.token_urlsafe(32),
88
+ tenant=tenant,
89
+ filters=filters,
90
+ created_at=datetime.now(UTC),
91
+ )
92
+ webhooks.append(registration)
93
+ save_webhooks(path, webhooks)
94
+ return registration
95
+
96
+
97
+ def list_webhooks(path: Path, tenant: str) -> list[WebhookRegistration]:
98
+ return [
99
+ webhook for webhook in load_webhooks(path) if webhook.tenant == tenant and webhook.active
100
+ ]
101
+
102
+
103
+ def get_webhook(path: Path, webhook_id: str, tenant: str) -> WebhookRegistration | None:
104
+ for webhook in load_webhooks(path):
105
+ if webhook.id == webhook_id and webhook.tenant == tenant and webhook.active:
106
+ return webhook
107
+ return None
108
+
109
+
110
+ def deactivate_webhook(path: Path, webhook_id: str, tenant: str) -> bool:
111
+ webhooks = load_webhooks(path)
112
+ changed = False
113
+ for webhook in webhooks:
114
+ if webhook.id == webhook_id and webhook.tenant == tenant and webhook.active:
115
+ webhook.active = False
116
+ changed = True
117
+ break
118
+ if changed:
119
+ save_webhooks(path, webhooks)
120
+ return changed
121
+
122
+
123
+ def ensure_webhook_deliveries_table(conn: duckdb.DuckDBPyConnection) -> None:
124
+ conn.execute(
125
+ """
126
+ CREATE TABLE IF NOT EXISTS webhook_deliveries (
127
+ delivery_id VARCHAR,
128
+ webhook_id VARCHAR,
129
+ event_id VARCHAR,
130
+ event_type VARCHAR,
131
+ attempt INTEGER,
132
+ status_code INTEGER,
133
+ success BOOLEAN,
134
+ error TEXT,
135
+ delivered_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
136
+ )
137
+ """
138
+ )
139
+
140
+
141
+ def get_delivery_logs(conn: duckdb.DuckDBPyConnection, webhook_id: str) -> list[dict]:
142
+ ensure_webhook_deliveries_table(conn)
143
+ cursor = conn.execute(
144
+ """
145
+ SELECT delivery_id, webhook_id, event_id, event_type, attempt,
146
+ status_code, success, error, delivered_at
147
+ FROM webhook_deliveries
148
+ WHERE webhook_id = ?
149
+ ORDER BY delivered_at DESC
150
+ LIMIT 20
151
+ """,
152
+ [webhook_id],
153
+ )
154
+ columns = [description[0] for description in cursor.description]
155
+ return [dict(zip(columns, row, strict=False)) for row in cursor.fetchall()]
156
+
157
+
158
+ class WebhookDispatcher:
159
+ def __init__(self, app, poll_interval_seconds: float = 2.0) -> None:
160
+ self.app = app
161
+ self.poll_interval_seconds = poll_interval_seconds
162
+ self.backoff_seconds = [1.0, 5.0, 25.0]
163
+ self.seen_event_ids: set[str] = set()
164
+ self._task: asyncio.Task | None = None
165
+
166
+ def start(self) -> None:
167
+ if self._task is not None and not self._task.done():
168
+ return
169
+ self.mark_existing_events_seen()
170
+ self._task = asyncio.create_task(self.run())
171
+
172
+ async def stop(self) -> None:
173
+ if self._task is None or self._task.done():
174
+ return
175
+ self._task.cancel()
176
+ try:
177
+ await self._task
178
+ except asyncio.CancelledError:
179
+ pass
180
+
181
+ async def run(self) -> None:
182
+ while True:
183
+ try:
184
+ await self.dispatch_new_events()
185
+ except Exception as exc:
186
+ logger.warning("webhook_dispatcher_error", error=str(exc))
187
+ await asyncio.sleep(self.poll_interval_seconds)
188
+
189
+ def mark_existing_events_seen(self) -> None:
190
+ try:
191
+ for event in self._fetch_pipeline_events():
192
+ event_id = str(event.get("event_id") or "")
193
+ if event_id:
194
+ self.seen_event_ids.add(_seen_event_key(event))
195
+ except duckdb.Error as exc:
196
+ logger.warning("webhook_seen_init_failed", error=str(exc))
197
+
198
+ async def dispatch_new_events(self) -> None:
199
+ path = get_webhook_config_path(self.app)
200
+ webhooks = [webhook for webhook in load_webhooks(path) if webhook.active]
201
+ webhooks_by_tenant: dict[str, list[WebhookRegistration]] = {}
202
+ for webhook in webhooks:
203
+ webhooks_by_tenant.setdefault(webhook.tenant, []).append(webhook)
204
+
205
+ for tenant in sorted(webhooks_by_tenant):
206
+ events = self._fetch_pipeline_events(tenant=tenant)
207
+ for event in events:
208
+ event_id = str(event.get("event_id") or "")
209
+ seen_key = _seen_event_key(event)
210
+ if (
211
+ not event_id
212
+ or event_id in self.seen_event_ids
213
+ or seen_key in self.seen_event_ids
214
+ ):
215
+ continue
216
+ self.seen_event_ids.add(seen_key)
217
+
218
+ for webhook in webhooks_by_tenant[tenant]:
219
+ if _matches_filters(event, webhook.filters):
220
+ await self.deliver(webhook, event)
221
+
222
+ async def deliver(self, webhook: WebhookRegistration, event: dict) -> dict:
223
+ conn = self.app.state.query_engine._conn
224
+ ensure_webhook_deliveries_table(conn)
225
+
226
+ delivery_id = str(uuid.uuid4())
227
+ event_type = str(event.get("event_type") or event.get("topic") or "unknown")
228
+ event_id = str(event.get("event_id") or delivery_id)
229
+ body = _event_body(event)
230
+ headers = {
231
+ "Content-Type": "application/json",
232
+ "X-AgentFlow-Event": event_type,
233
+ "X-AgentFlow-Signature": _signature(webhook.secret, body),
234
+ "X-AgentFlow-Delivery": delivery_id,
235
+ }
236
+
237
+ attempts = 0
238
+ success = False
239
+ status_code: int | None = None
240
+ error: str | None = None
241
+
242
+ async with httpx.AsyncClient(timeout=5.0) as client:
243
+ for attempt in range(1, 4):
244
+ attempts = attempt
245
+ error = None
246
+ try:
247
+ response = await client.post(
248
+ webhook.url,
249
+ content=body,
250
+ headers=headers,
251
+ )
252
+ status_code = response.status_code
253
+ success = 200 <= response.status_code < 300
254
+ _log_delivery(
255
+ conn,
256
+ delivery_id=delivery_id,
257
+ webhook_id=webhook.id,
258
+ event_id=event_id,
259
+ event_type=event_type,
260
+ attempt=attempt,
261
+ status_code=status_code,
262
+ success=success,
263
+ error=None,
264
+ )
265
+ if response.status_code < 500:
266
+ break
267
+ except (httpx.TimeoutException, httpx.TransportError) as exc:
268
+ status_code = None
269
+ success = False
270
+ error = str(exc)
271
+ _log_delivery(
272
+ conn,
273
+ delivery_id=delivery_id,
274
+ webhook_id=webhook.id,
275
+ event_id=event_id,
276
+ event_type=event_type,
277
+ attempt=attempt,
278
+ status_code=None,
279
+ success=False,
280
+ error=error,
281
+ )
282
+
283
+ if attempt < 3:
284
+ delay = self.backoff_seconds[min(attempt - 1, len(self.backoff_seconds) - 1)]
285
+ await asyncio.sleep(delay)
286
+
287
+ return {
288
+ "delivery_id": delivery_id,
289
+ "webhook_id": webhook.id,
290
+ "event_id": event_id,
291
+ "event_type": event_type,
292
+ "success": success,
293
+ "status_code": status_code,
294
+ "error": error,
295
+ "attempts": attempts,
296
+ }
297
+
298
+ def _fetch_pipeline_events(self, tenant: str | None = None) -> list[dict]:
299
+ conn = self.app.state.query_engine._conn
300
+ columns = [
301
+ row[1] for row in conn.execute("PRAGMA table_info('pipeline_events')").fetchall()
302
+ ]
303
+ if not columns:
304
+ return []
305
+ if tenant is not None and "tenant_id" not in columns and tenant != "default":
306
+ return []
307
+ if "processed_at" in columns:
308
+ order_by = "processed_at"
309
+ elif "created_at" in columns:
310
+ order_by = "created_at"
311
+ else:
312
+ order_by = "event_id"
313
+ sql = "SELECT * FROM pipeline_events" # nosec B608 - order_by is chosen from a fixed column allowlist
314
+ params: list[str] = []
315
+ if tenant is not None and "tenant_id" in columns:
316
+ sql = f"{sql} WHERE COALESCE(tenant_id, 'default') = ?"
317
+ params.append(tenant)
318
+ sql = f"{sql} ORDER BY {order_by} ASC, event_id ASC"
319
+ cursor = conn.execute(sql, params)
320
+ result_columns = [description[0] for description in cursor.description]
321
+ return [dict(zip(result_columns, row, strict=False)) for row in cursor.fetchall()]
322
+
323
+
324
+ def _log_delivery(
325
+ conn: duckdb.DuckDBPyConnection,
326
+ *,
327
+ delivery_id: str,
328
+ webhook_id: str,
329
+ event_id: str,
330
+ event_type: str,
331
+ attempt: int,
332
+ status_code: int | None,
333
+ success: bool,
334
+ error: str | None,
335
+ ) -> None:
336
+ conn.execute(
337
+ """
338
+ INSERT INTO webhook_deliveries (
339
+ delivery_id, webhook_id, event_id, event_type, attempt,
340
+ status_code, success, error, delivered_at
341
+ )
342
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
343
+ """,
344
+ [
345
+ delivery_id,
346
+ webhook_id,
347
+ event_id,
348
+ event_type,
349
+ attempt,
350
+ status_code,
351
+ success,
352
+ error,
353
+ datetime.now(UTC),
354
+ ],
355
+ )
356
+
357
+
358
+ def _matches_filters(event: dict, filters: WebhookFilters) -> bool:
359
+ event_type = str(event.get("event_type") or event.get("topic") or "")
360
+ if filters.event_types:
361
+ if not any(_event_type_matches(event_type, value) for value in filters.event_types):
362
+ return False
363
+
364
+ if filters.entity_ids:
365
+ entity_values = {
366
+ str(event.get(key))
367
+ for key in ("entity_id", "order_id", "user_id", "product_id", "session_id")
368
+ if event.get(key) is not None
369
+ }
370
+ if not entity_values.intersection(filters.entity_ids):
371
+ return False
372
+
373
+ if filters.min_amount is not None:
374
+ if not event_type.startswith("order"):
375
+ return False
376
+ amount = (
377
+ event.get("total_amount")
378
+ if event.get("total_amount") is not None
379
+ else event.get("amount")
380
+ )
381
+ if amount is None:
382
+ return False
383
+ try:
384
+ if float(str(amount)) < filters.min_amount:
385
+ return False
386
+ except (TypeError, ValueError):
387
+ return False
388
+
389
+ return True
390
+
391
+
392
+ def _seen_event_key(event: dict) -> str:
393
+ event_id = str(event.get("event_id") or "")
394
+ tenant_id = str(event.get("tenant_id") or "default")
395
+ return f"{tenant_id}:{event_id}"
396
+
397
+
398
+ def _event_type_matches(event_type: str, requested: str) -> bool:
399
+ return event_type == requested or event_type.startswith(f"{requested}.")
400
+
401
+
402
+ def _event_body(event: dict) -> bytes:
403
+ return json.dumps(
404
+ event,
405
+ sort_keys=True,
406
+ separators=(",", ":"),
407
+ default=_json_default,
408
+ ).encode("utf-8")
409
+
410
+
411
+ def _signature(secret: str, body: bytes) -> str:
412
+ digest = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
413
+ return f"sha256={digest}"
414
+
415
+
416
+ def _json_default(value: object) -> str | float:
417
+ if isinstance(value, datetime):
418
+ return value.isoformat()
419
+ if isinstance(value, date):
420
+ return value.isoformat()
421
+ if isinstance(value, Decimal):
422
+ return float(value)
423
+ return str(value)
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from abc import ABC, abstractmethod
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ try:
10
+ import yaml # type: ignore[import-untyped]
11
+ except ImportError: # pragma: no cover
12
+ yaml = None
13
+
14
+ if TYPE_CHECKING:
15
+ from src.serving.backends.duckdb_backend import DuckDBBackend
16
+
17
+
18
+ class BackendExecutionError(RuntimeError):
19
+ """Raised when a backend query cannot be executed."""
20
+
21
+
22
+ class BackendMissingTableError(BackendExecutionError):
23
+ """Raised when a backend query references a table that does not exist."""
24
+
25
+ def __init__(self, message: str, table_name: str | None = None) -> None:
26
+ super().__init__(message)
27
+ self.table_name = table_name
28
+
29
+
30
+ class ServingBackend(ABC):
31
+ name = "backend"
32
+
33
+ @abstractmethod
34
+ def execute(self, sql: str, params: list | None = None) -> list[dict]:
35
+ """Execute SQL and return rows as dictionaries."""
36
+
37
+ @abstractmethod
38
+ def scalar(self, sql: str, params: list | None = None) -> Any:
39
+ """Execute SQL and return the first scalar value."""
40
+
41
+ @abstractmethod
42
+ def table_columns(self, table_name: str) -> set[str]:
43
+ """Return the columns available in a table."""
44
+
45
+ @abstractmethod
46
+ def explain(self, sql: str) -> list[tuple]:
47
+ """Explain a SQL statement."""
48
+
49
+ @abstractmethod
50
+ def initialize_demo_data(self) -> None:
51
+ """Create and seed demo data if the backend is empty."""
52
+
53
+ @abstractmethod
54
+ def health(self) -> dict:
55
+ """Return a lightweight backend health payload."""
56
+
57
+
58
+ def default_serving_config_path() -> Path:
59
+ return Path(os.getenv("AGENTFLOW_SERVING_CONFIG", "config/serving.yaml"))
60
+
61
+
62
+ def load_serving_backend_config(config_path: Path | str | None = None) -> dict:
63
+ path = Path(config_path) if config_path is not None else default_serving_config_path()
64
+ data: dict[str, Any] = {}
65
+ if path.exists():
66
+ raw = path.read_text(encoding="utf-8")
67
+ if yaml is not None:
68
+ data = yaml.safe_load(raw) or {}
69
+ else: # pragma: no cover
70
+ data = json.loads(raw)
71
+
72
+ clickhouse = data.get("clickhouse", {})
73
+ backend_name = os.getenv("SERVING_BACKEND", data.get("backend", "duckdb"))
74
+
75
+ return {
76
+ "backend": str(backend_name).strip().lower() or "duckdb",
77
+ "clickhouse": {
78
+ "host": os.getenv("CLICKHOUSE_HOST", clickhouse.get("host", "localhost")),
79
+ "port": int(os.getenv("CLICKHOUSE_PORT", clickhouse.get("port", 8123))),
80
+ "user": os.getenv("CLICKHOUSE_USER", clickhouse.get("user", "default")),
81
+ "password": os.getenv("CLICKHOUSE_PASSWORD", clickhouse.get("password", "")),
82
+ "database": os.getenv("CLICKHOUSE_DATABASE", clickhouse.get("database", "agentflow")),
83
+ "secure": (
84
+ str(os.getenv("CLICKHOUSE_SECURE", clickhouse.get("secure", "false"))).lower()
85
+ in {"1", "true", "yes", "on"}
86
+ ),
87
+ "timeout_seconds": int(
88
+ os.getenv("CLICKHOUSE_TIMEOUT_SECONDS", clickhouse.get("timeout_seconds", 10))
89
+ ),
90
+ },
91
+ }
92
+
93
+
94
+ def create_backend(
95
+ *,
96
+ duckdb_backend: DuckDBBackend,
97
+ config_path: Path | str | None = None,
98
+ ) -> ServingBackend:
99
+ config = load_serving_backend_config(config_path)
100
+ backend_name = config["backend"]
101
+
102
+ if backend_name == "duckdb":
103
+ return duckdb_backend
104
+ if backend_name == "clickhouse":
105
+ from src.serving.backends.clickhouse_backend import ClickHouseBackend
106
+
107
+ clickhouse = config["clickhouse"]
108
+ return ClickHouseBackend(
109
+ host=clickhouse["host"],
110
+ port=clickhouse["port"],
111
+ user=clickhouse["user"],
112
+ password=clickhouse["password"],
113
+ database=clickhouse["database"],
114
+ secure=clickhouse["secure"],
115
+ timeout_seconds=clickhouse["timeout_seconds"],
116
+ )
117
+ raise ValueError(f"Unsupported serving backend '{backend_name}'.")