ccproxy-api 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.
Files changed (148) hide show
  1. ccproxy/__init__.py +4 -0
  2. ccproxy/__main__.py +7 -0
  3. ccproxy/_version.py +21 -0
  4. ccproxy/adapters/__init__.py +11 -0
  5. ccproxy/adapters/base.py +80 -0
  6. ccproxy/adapters/openai/__init__.py +43 -0
  7. ccproxy/adapters/openai/adapter.py +915 -0
  8. ccproxy/adapters/openai/models.py +412 -0
  9. ccproxy/adapters/openai/streaming.py +449 -0
  10. ccproxy/api/__init__.py +28 -0
  11. ccproxy/api/app.py +225 -0
  12. ccproxy/api/dependencies.py +140 -0
  13. ccproxy/api/middleware/__init__.py +11 -0
  14. ccproxy/api/middleware/auth.py +0 -0
  15. ccproxy/api/middleware/cors.py +55 -0
  16. ccproxy/api/middleware/errors.py +703 -0
  17. ccproxy/api/middleware/headers.py +51 -0
  18. ccproxy/api/middleware/logging.py +175 -0
  19. ccproxy/api/middleware/request_id.py +69 -0
  20. ccproxy/api/middleware/server_header.py +62 -0
  21. ccproxy/api/responses.py +84 -0
  22. ccproxy/api/routes/__init__.py +16 -0
  23. ccproxy/api/routes/claude.py +181 -0
  24. ccproxy/api/routes/health.py +489 -0
  25. ccproxy/api/routes/metrics.py +1033 -0
  26. ccproxy/api/routes/proxy.py +238 -0
  27. ccproxy/auth/__init__.py +75 -0
  28. ccproxy/auth/bearer.py +68 -0
  29. ccproxy/auth/credentials_adapter.py +93 -0
  30. ccproxy/auth/dependencies.py +229 -0
  31. ccproxy/auth/exceptions.py +79 -0
  32. ccproxy/auth/manager.py +102 -0
  33. ccproxy/auth/models.py +118 -0
  34. ccproxy/auth/oauth/__init__.py +26 -0
  35. ccproxy/auth/oauth/models.py +49 -0
  36. ccproxy/auth/oauth/routes.py +396 -0
  37. ccproxy/auth/oauth/storage.py +0 -0
  38. ccproxy/auth/storage/__init__.py +12 -0
  39. ccproxy/auth/storage/base.py +57 -0
  40. ccproxy/auth/storage/json_file.py +159 -0
  41. ccproxy/auth/storage/keyring.py +192 -0
  42. ccproxy/claude_sdk/__init__.py +20 -0
  43. ccproxy/claude_sdk/client.py +169 -0
  44. ccproxy/claude_sdk/converter.py +331 -0
  45. ccproxy/claude_sdk/options.py +120 -0
  46. ccproxy/cli/__init__.py +14 -0
  47. ccproxy/cli/commands/__init__.py +8 -0
  48. ccproxy/cli/commands/auth.py +553 -0
  49. ccproxy/cli/commands/config/__init__.py +14 -0
  50. ccproxy/cli/commands/config/commands.py +766 -0
  51. ccproxy/cli/commands/config/schema_commands.py +119 -0
  52. ccproxy/cli/commands/serve.py +630 -0
  53. ccproxy/cli/docker/__init__.py +34 -0
  54. ccproxy/cli/docker/adapter_factory.py +157 -0
  55. ccproxy/cli/docker/params.py +278 -0
  56. ccproxy/cli/helpers.py +144 -0
  57. ccproxy/cli/main.py +193 -0
  58. ccproxy/cli/options/__init__.py +14 -0
  59. ccproxy/cli/options/claude_options.py +216 -0
  60. ccproxy/cli/options/core_options.py +40 -0
  61. ccproxy/cli/options/security_options.py +48 -0
  62. ccproxy/cli/options/server_options.py +117 -0
  63. ccproxy/config/__init__.py +40 -0
  64. ccproxy/config/auth.py +154 -0
  65. ccproxy/config/claude.py +124 -0
  66. ccproxy/config/cors.py +79 -0
  67. ccproxy/config/discovery.py +87 -0
  68. ccproxy/config/docker_settings.py +265 -0
  69. ccproxy/config/loader.py +108 -0
  70. ccproxy/config/observability.py +158 -0
  71. ccproxy/config/pricing.py +88 -0
  72. ccproxy/config/reverse_proxy.py +31 -0
  73. ccproxy/config/scheduler.py +89 -0
  74. ccproxy/config/security.py +14 -0
  75. ccproxy/config/server.py +81 -0
  76. ccproxy/config/settings.py +534 -0
  77. ccproxy/config/validators.py +231 -0
  78. ccproxy/core/__init__.py +274 -0
  79. ccproxy/core/async_utils.py +675 -0
  80. ccproxy/core/constants.py +97 -0
  81. ccproxy/core/errors.py +256 -0
  82. ccproxy/core/http.py +328 -0
  83. ccproxy/core/http_transformers.py +428 -0
  84. ccproxy/core/interfaces.py +247 -0
  85. ccproxy/core/logging.py +189 -0
  86. ccproxy/core/middleware.py +114 -0
  87. ccproxy/core/proxy.py +143 -0
  88. ccproxy/core/system.py +38 -0
  89. ccproxy/core/transformers.py +259 -0
  90. ccproxy/core/types.py +129 -0
  91. ccproxy/core/validators.py +288 -0
  92. ccproxy/docker/__init__.py +67 -0
  93. ccproxy/docker/adapter.py +588 -0
  94. ccproxy/docker/docker_path.py +207 -0
  95. ccproxy/docker/middleware.py +103 -0
  96. ccproxy/docker/models.py +228 -0
  97. ccproxy/docker/protocol.py +192 -0
  98. ccproxy/docker/stream_process.py +264 -0
  99. ccproxy/docker/validators.py +173 -0
  100. ccproxy/models/__init__.py +123 -0
  101. ccproxy/models/errors.py +42 -0
  102. ccproxy/models/messages.py +243 -0
  103. ccproxy/models/requests.py +85 -0
  104. ccproxy/models/responses.py +227 -0
  105. ccproxy/models/types.py +102 -0
  106. ccproxy/observability/__init__.py +51 -0
  107. ccproxy/observability/access_logger.py +400 -0
  108. ccproxy/observability/context.py +447 -0
  109. ccproxy/observability/metrics.py +539 -0
  110. ccproxy/observability/pushgateway.py +366 -0
  111. ccproxy/observability/sse_events.py +303 -0
  112. ccproxy/observability/stats_printer.py +755 -0
  113. ccproxy/observability/storage/__init__.py +1 -0
  114. ccproxy/observability/storage/duckdb_simple.py +665 -0
  115. ccproxy/observability/storage/models.py +55 -0
  116. ccproxy/pricing/__init__.py +19 -0
  117. ccproxy/pricing/cache.py +212 -0
  118. ccproxy/pricing/loader.py +267 -0
  119. ccproxy/pricing/models.py +106 -0
  120. ccproxy/pricing/updater.py +309 -0
  121. ccproxy/scheduler/__init__.py +39 -0
  122. ccproxy/scheduler/core.py +335 -0
  123. ccproxy/scheduler/exceptions.py +34 -0
  124. ccproxy/scheduler/manager.py +186 -0
  125. ccproxy/scheduler/registry.py +150 -0
  126. ccproxy/scheduler/tasks.py +484 -0
  127. ccproxy/services/__init__.py +10 -0
  128. ccproxy/services/claude_sdk_service.py +614 -0
  129. ccproxy/services/credentials/__init__.py +55 -0
  130. ccproxy/services/credentials/config.py +105 -0
  131. ccproxy/services/credentials/manager.py +562 -0
  132. ccproxy/services/credentials/oauth_client.py +482 -0
  133. ccproxy/services/proxy_service.py +1536 -0
  134. ccproxy/static/.keep +0 -0
  135. ccproxy/testing/__init__.py +34 -0
  136. ccproxy/testing/config.py +148 -0
  137. ccproxy/testing/content_generation.py +197 -0
  138. ccproxy/testing/mock_responses.py +262 -0
  139. ccproxy/testing/response_handlers.py +161 -0
  140. ccproxy/testing/scenarios.py +241 -0
  141. ccproxy/utils/__init__.py +6 -0
  142. ccproxy/utils/cost_calculator.py +210 -0
  143. ccproxy/utils/streaming_metrics.py +199 -0
  144. ccproxy_api-0.1.0.dist-info/METADATA +253 -0
  145. ccproxy_api-0.1.0.dist-info/RECORD +148 -0
  146. ccproxy_api-0.1.0.dist-info/WHEEL +4 -0
  147. ccproxy_api-0.1.0.dist-info/entry_points.txt +2 -0
  148. ccproxy_api-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1 @@
1
+ """Storage backends for observability data."""
@@ -0,0 +1,665 @@
1
+ """Simplified DuckDB storage for low-traffic environments.
2
+
3
+ This module provides a simple, direct DuckDB storage implementation without
4
+ connection pooling or batch processing. Suitable for dev environments with
5
+ low request rates (< 10 req/s).
6
+ """
7
+
8
+ import asyncio
9
+ import time
10
+ from collections.abc import Sequence
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import Any, Optional
14
+
15
+ import structlog
16
+ from sqlalchemy import text
17
+ from sqlalchemy.engine import Engine
18
+ from sqlmodel import Session, SQLModel, create_engine, desc, func, select
19
+ from typing_extensions import TypedDict
20
+
21
+ from .models import AccessLog
22
+
23
+
24
+ logger = structlog.get_logger(__name__)
25
+
26
+
27
+ class AccessLogPayload(TypedDict, total=False):
28
+ """TypedDict for access log data payloads.
29
+
30
+ Note: All fields are optional (total=False) to allow partial payloads.
31
+ The storage layer will provide sensible defaults for missing fields.
32
+ """
33
+
34
+ # Core request identification
35
+ request_id: str
36
+ timestamp: int | float | datetime
37
+
38
+ # Request details
39
+ method: str
40
+ endpoint: str
41
+ path: str
42
+ query: str
43
+ client_ip: str
44
+ user_agent: str
45
+
46
+ # Service and model info
47
+ service_type: str
48
+ model: str
49
+ streaming: bool
50
+
51
+ # Response details
52
+ status_code: int
53
+ duration_ms: float
54
+ duration_seconds: float
55
+
56
+ # Token and cost tracking
57
+ tokens_input: int
58
+ tokens_output: int
59
+ cache_read_tokens: int
60
+ cache_write_tokens: int
61
+ cost_usd: float
62
+ cost_sdk_usd: float
63
+
64
+
65
+ class SimpleDuckDBStorage:
66
+ """Simple DuckDB storage with queue-based writes to prevent deadlocks."""
67
+
68
+ def __init__(self, database_path: str | Path = "data/metrics.duckdb"):
69
+ """Initialize simple DuckDB storage.
70
+
71
+ Args:
72
+ database_path: Path to DuckDB database file
73
+ """
74
+ self.database_path = Path(database_path)
75
+ self._engine: Engine | None = None
76
+ self._initialized: bool = False
77
+ self._write_queue: asyncio.Queue[AccessLogPayload] = asyncio.Queue()
78
+ self._background_worker_task: asyncio.Task[None] | None = None
79
+ self._shutdown_event = asyncio.Event()
80
+
81
+ async def initialize(self) -> None:
82
+ """Initialize the storage backend."""
83
+ if self._initialized:
84
+ return
85
+
86
+ try:
87
+ # Ensure data directory exists
88
+ self.database_path.parent.mkdir(parents=True, exist_ok=True)
89
+
90
+ # Create SQLModel engine
91
+ self._engine = create_engine(f"duckdb:///{self.database_path}")
92
+
93
+ # Create schema using SQLModel (synchronous in main thread)
94
+ self._create_schema_sync()
95
+
96
+ # Start background worker for queue processing
97
+ self._background_worker_task = asyncio.create_task(
98
+ self._background_worker()
99
+ )
100
+
101
+ self._initialized = True
102
+ logger.debug(
103
+ "simple_duckdb_initialized", database_path=str(self.database_path)
104
+ )
105
+
106
+ except Exception as e:
107
+ logger.error("simple_duckdb_init_error", error=str(e), exc_info=True)
108
+ raise
109
+
110
+ def _create_schema_sync(self) -> None:
111
+ """Create database schema using SQLModel (synchronous)."""
112
+ if not self._engine:
113
+ return
114
+
115
+ try:
116
+ # Create tables using SQLModel metadata
117
+ SQLModel.metadata.create_all(self._engine)
118
+ logger.debug("duckdb_schema_created")
119
+
120
+ except Exception as e:
121
+ logger.error("simple_duckdb_schema_error", error=str(e))
122
+ raise
123
+
124
+ async def _ensure_query_column(self) -> None:
125
+ """Ensure query column exists in the access_logs table."""
126
+ if not self._engine:
127
+ return
128
+
129
+ try:
130
+ with Session(self._engine) as session:
131
+ # Check if query column exists
132
+ result = session.execute(
133
+ text(
134
+ "SELECT column_name FROM information_schema.columns WHERE table_name = 'access_logs' AND column_name = 'query'"
135
+ )
136
+ )
137
+ if not result.fetchone():
138
+ # Add query column if it doesn't exist
139
+ session.execute(
140
+ text(
141
+ "ALTER TABLE access_logs ADD COLUMN query VARCHAR DEFAULT ''"
142
+ )
143
+ )
144
+ session.commit()
145
+ logger.info("Added query column to access_logs table")
146
+
147
+ except Exception as e:
148
+ logger.warning("Failed to check/add query column", error=str(e))
149
+ # Continue without failing - the column might already exist or schema might be different
150
+
151
+ async def store_request(self, data: AccessLogPayload) -> bool:
152
+ """Store a single request log entry asynchronously via queue.
153
+
154
+ Args:
155
+ data: Request data to store
156
+
157
+ Returns:
158
+ True if queued successfully
159
+ """
160
+ if not self._initialized:
161
+ return False
162
+
163
+ try:
164
+ # Add to queue for background processing
165
+ await self._write_queue.put(data)
166
+ return True
167
+ except Exception as e:
168
+ logger.error(
169
+ "queue_store_error",
170
+ error=str(e),
171
+ request_id=data.get("request_id"),
172
+ )
173
+ return False
174
+
175
+ async def _background_worker(self) -> None:
176
+ """Background worker to process queued write operations sequentially."""
177
+ logger.debug("duckdb_background_worker_started")
178
+
179
+ while not self._shutdown_event.is_set():
180
+ try:
181
+ # Wait for either a queue item or shutdown with timeout
182
+ try:
183
+ data = await asyncio.wait_for(self._write_queue.get(), timeout=1.0)
184
+ except TimeoutError:
185
+ continue # Check shutdown event and continue
186
+
187
+ # Process the queued write operation synchronously
188
+ try:
189
+ success = self._store_request_sync(data)
190
+ if success:
191
+ logger.debug(
192
+ "queue_processed_successfully",
193
+ request_id=data.get("request_id"),
194
+ )
195
+ except Exception as e:
196
+ logger.error(
197
+ "background_worker_error",
198
+ error=str(e),
199
+ request_id=data.get("request_id"),
200
+ exc_info=True,
201
+ )
202
+ finally:
203
+ # Always mark the task as done, regardless of success/failure
204
+ self._write_queue.task_done()
205
+
206
+ except Exception as e:
207
+ logger.error(
208
+ "background_worker_unexpected_error",
209
+ error=str(e),
210
+ exc_info=True,
211
+ )
212
+ # Continue processing other items
213
+
214
+ # Process any remaining items in the queue during shutdown
215
+ logger.debug("processing_remaining_queue_items_on_shutdown")
216
+ while not self._write_queue.empty():
217
+ try:
218
+ # Get remaining items without timeout during shutdown
219
+ data = self._write_queue.get_nowait()
220
+
221
+ # Process the queued write operation synchronously
222
+ try:
223
+ success = self._store_request_sync(data)
224
+ if success:
225
+ logger.debug(
226
+ "shutdown_queue_processed_successfully",
227
+ request_id=data.get("request_id"),
228
+ )
229
+ except Exception as e:
230
+ logger.error(
231
+ "shutdown_background_worker_error",
232
+ error=str(e),
233
+ request_id=data.get("request_id"),
234
+ exc_info=True,
235
+ )
236
+ finally:
237
+ # Always mark the task as done, regardless of success/failure
238
+ self._write_queue.task_done()
239
+
240
+ except asyncio.QueueEmpty:
241
+ # No more items to process
242
+ break
243
+ except Exception as e:
244
+ logger.error(
245
+ "shutdown_background_worker_unexpected_error",
246
+ error=str(e),
247
+ exc_info=True,
248
+ )
249
+ # Continue processing other items
250
+
251
+ logger.debug("duckdb_background_worker_stopped")
252
+
253
+ def _store_request_sync(self, data: AccessLogPayload) -> bool:
254
+ """Synchronous version of store_request for thread pool execution."""
255
+ try:
256
+ # Convert Unix timestamp to datetime if needed
257
+ timestamp_value = data.get("timestamp", time.time())
258
+ if isinstance(timestamp_value, int | float):
259
+ timestamp_dt = datetime.fromtimestamp(timestamp_value)
260
+ else:
261
+ timestamp_dt = timestamp_value
262
+
263
+ # Create AccessLog object with type validation
264
+ access_log = AccessLog(
265
+ request_id=data.get("request_id", ""),
266
+ timestamp=timestamp_dt,
267
+ method=data.get("method", ""),
268
+ endpoint=data.get("endpoint", ""),
269
+ path=data.get("path", data.get("endpoint", "")),
270
+ query=data.get("query", ""),
271
+ client_ip=data.get("client_ip", ""),
272
+ user_agent=data.get("user_agent", ""),
273
+ service_type=data.get("service_type", ""),
274
+ model=data.get("model", ""),
275
+ streaming=data.get("streaming", False),
276
+ status_code=data.get("status_code", 200),
277
+ duration_ms=data.get("duration_ms", 0.0),
278
+ duration_seconds=data.get("duration_seconds", 0.0),
279
+ tokens_input=data.get("tokens_input", 0),
280
+ tokens_output=data.get("tokens_output", 0),
281
+ cache_read_tokens=data.get("cache_read_tokens", 0),
282
+ cache_write_tokens=data.get("cache_write_tokens", 0),
283
+ cost_usd=data.get("cost_usd", 0.0),
284
+ cost_sdk_usd=data.get("cost_sdk_usd", 0.0),
285
+ )
286
+
287
+ # Store using SQLModel session
288
+ with Session(self._engine) as session:
289
+ # Add new log entry (no merge needed as each request is unique)
290
+ session.add(access_log)
291
+ session.commit()
292
+
293
+ logger.info(
294
+ "simple_duckdb_store_success",
295
+ request_id=data.get("request_id"),
296
+ service_type=data.get("service_type", ""),
297
+ model=data.get("model", ""),
298
+ tokens_input=data.get("tokens_input", 0),
299
+ tokens_output=data.get("tokens_output", 0),
300
+ cost_usd=data.get("cost_usd", 0.0),
301
+ endpoint=data.get("endpoint", ""),
302
+ timestamp=timestamp_dt.isoformat() if timestamp_dt else None,
303
+ )
304
+ return True
305
+
306
+ except Exception as e:
307
+ logger.error(
308
+ "simple_duckdb_store_error",
309
+ error=str(e),
310
+ request_id=data.get("request_id"),
311
+ )
312
+ return False
313
+
314
+ async def store_batch(self, metrics: Sequence[AccessLogPayload]) -> bool:
315
+ """Store a batch of metrics efficiently.
316
+
317
+ Args:
318
+ metrics: List of metric data to store
319
+
320
+ Returns:
321
+ True if batch stored successfully
322
+ """
323
+ if not self._initialized or not metrics or not self._engine:
324
+ return False
325
+
326
+ try:
327
+ # Store using SQLModel with upsert behavior
328
+ with Session(self._engine) as session:
329
+ for metric in metrics:
330
+ # Convert Unix timestamp to datetime if needed
331
+ timestamp_value = metric.get("timestamp", time.time())
332
+ if isinstance(timestamp_value, int | float):
333
+ timestamp_dt = datetime.fromtimestamp(timestamp_value)
334
+ else:
335
+ timestamp_dt = timestamp_value
336
+
337
+ # Create AccessLog object with type validation
338
+ access_log = AccessLog(
339
+ request_id=metric.get("request_id", ""),
340
+ timestamp=timestamp_dt,
341
+ method=metric.get("method", ""),
342
+ endpoint=metric.get("endpoint", ""),
343
+ path=metric.get("path", metric.get("endpoint", "")),
344
+ query=metric.get("query", ""),
345
+ client_ip=metric.get("client_ip", ""),
346
+ user_agent=metric.get("user_agent", ""),
347
+ service_type=metric.get("service_type", ""),
348
+ model=metric.get("model", ""),
349
+ streaming=metric.get("streaming", False),
350
+ status_code=metric.get("status_code", 200),
351
+ duration_ms=metric.get("duration_ms", 0.0),
352
+ duration_seconds=metric.get("duration_seconds", 0.0),
353
+ tokens_input=metric.get("tokens_input", 0),
354
+ tokens_output=metric.get("tokens_output", 0),
355
+ cache_read_tokens=metric.get("cache_read_tokens", 0),
356
+ cache_write_tokens=metric.get("cache_write_tokens", 0),
357
+ cost_usd=metric.get("cost_usd", 0.0),
358
+ cost_sdk_usd=metric.get("cost_sdk_usd", 0.0),
359
+ )
360
+ # Use merge to handle potential duplicates
361
+ session.merge(access_log)
362
+
363
+ session.commit()
364
+
365
+ logger.info(
366
+ "simple_duckdb_batch_store_success",
367
+ batch_size=len(metrics),
368
+ service_types=[
369
+ m.get("service_type", "") for m in metrics[:3]
370
+ ], # First 3 for sampling
371
+ request_ids=[
372
+ m.get("request_id", "") for m in metrics[:3]
373
+ ], # First 3 for sampling
374
+ )
375
+ return True
376
+
377
+ except Exception as e:
378
+ logger.error(
379
+ "simple_duckdb_store_batch_error",
380
+ error=str(e),
381
+ metric_count=len(metrics),
382
+ )
383
+ return False
384
+
385
+ async def store(self, metric: AccessLogPayload) -> bool:
386
+ """Store single metric.
387
+
388
+ Args:
389
+ metric: Metric data to store
390
+
391
+ Returns:
392
+ True if stored successfully
393
+ """
394
+ return await self.store_batch([metric])
395
+
396
+ async def query(
397
+ self,
398
+ sql: str,
399
+ params: dict[str, Any] | list[Any] | None = None,
400
+ limit: int = 1000,
401
+ ) -> list[dict[str, Any]]:
402
+ """Execute SQL query and return results.
403
+
404
+ Args:
405
+ sql: SQL query string
406
+ params: Query parameters
407
+ limit: Maximum number of results
408
+
409
+ Returns:
410
+ List of result rows as dictionaries
411
+ """
412
+ if not self._initialized or not self._engine:
413
+ return []
414
+
415
+ try:
416
+ # Use SQLModel for querying
417
+ with Session(self._engine) as session:
418
+ # For now, we'll use raw SQL through the engine
419
+ # In a full implementation, this would be converted to SQLModel queries
420
+
421
+ # Use parameterized query to prevent SQL injection
422
+ limited_sql = "SELECT * FROM (" + sql + ") LIMIT :limit"
423
+
424
+ query_params = {"limit": limit}
425
+ if params:
426
+ # Merge user params with limit param
427
+ if isinstance(params, dict):
428
+ query_params.update(params)
429
+ result = session.execute(text(limited_sql), query_params)
430
+ else:
431
+ # If params is a list, we need to handle it differently
432
+ # For now, we'll use the safer approach of not supporting list params with limits
433
+ result = session.execute(text(sql), params)
434
+ else:
435
+ result = session.execute(text(limited_sql), query_params)
436
+
437
+ # Convert to list of dictionaries
438
+ columns = list(result.keys())
439
+ rows = result.fetchall()
440
+
441
+ return [dict(zip(columns, row, strict=False)) for row in rows]
442
+
443
+ except Exception as e:
444
+ logger.error("simple_duckdb_query_error", sql=sql, error=str(e))
445
+ return []
446
+
447
+ async def get_recent_requests(self, limit: int = 100) -> list[dict[str, Any]]:
448
+ """Get recent requests for debugging/monitoring.
449
+
450
+ Args:
451
+ limit: Number of recent requests to return
452
+
453
+ Returns:
454
+ List of recent request records
455
+ """
456
+ if not self._engine:
457
+ return []
458
+
459
+ try:
460
+ with Session(self._engine) as session:
461
+ statement = (
462
+ select(AccessLog).order_by(desc(AccessLog.timestamp)).limit(limit)
463
+ )
464
+ results = session.exec(statement).all()
465
+ return [log.dict() for log in results]
466
+ except Exception as e:
467
+ logger.error("sqlmodel_query_error", error=str(e))
468
+ return []
469
+
470
+ async def get_analytics(
471
+ self,
472
+ start_time: float | None = None,
473
+ end_time: float | None = None,
474
+ model: str | None = None,
475
+ service_type: str | None = None,
476
+ ) -> dict[str, Any]:
477
+ """Get analytics using SQLModel.
478
+
479
+ Args:
480
+ start_time: Start timestamp (Unix time)
481
+ end_time: End timestamp (Unix time)
482
+ model: Filter by model name
483
+ service_type: Filter by service type
484
+
485
+ Returns:
486
+ Analytics summary data
487
+ """
488
+ if not self._engine:
489
+ return {}
490
+
491
+ try:
492
+ with Session(self._engine) as session:
493
+ # Build base query
494
+ statement = select(AccessLog)
495
+
496
+ # Add filters - convert Unix timestamps to datetime
497
+ if start_time:
498
+ start_dt = datetime.fromtimestamp(start_time)
499
+ statement = statement.where(AccessLog.timestamp >= start_dt)
500
+ if end_time:
501
+ end_dt = datetime.fromtimestamp(end_time)
502
+ statement = statement.where(AccessLog.timestamp <= end_dt)
503
+ if model:
504
+ statement = statement.where(AccessLog.model == model)
505
+ if service_type:
506
+ statement = statement.where(AccessLog.service_type == service_type)
507
+
508
+ # Get summary statistics using individual queries to avoid overload issues
509
+ base_where_conditions = []
510
+ if start_time:
511
+ start_dt = datetime.fromtimestamp(start_time)
512
+ base_where_conditions.append(AccessLog.timestamp >= start_dt)
513
+ if end_time:
514
+ end_dt = datetime.fromtimestamp(end_time)
515
+ base_where_conditions.append(AccessLog.timestamp <= end_dt)
516
+ if model:
517
+ base_where_conditions.append(AccessLog.model == model)
518
+ if service_type:
519
+ base_where_conditions.append(AccessLog.service_type == service_type)
520
+
521
+ total_requests = session.exec(
522
+ select(func.count())
523
+ .select_from(AccessLog)
524
+ .where(*base_where_conditions)
525
+ ).first()
526
+
527
+ avg_duration = session.exec(
528
+ select(func.avg(AccessLog.duration_ms))
529
+ .select_from(AccessLog)
530
+ .where(*base_where_conditions)
531
+ ).first()
532
+
533
+ total_cost = session.exec(
534
+ select(func.sum(AccessLog.cost_usd))
535
+ .select_from(AccessLog)
536
+ .where(*base_where_conditions)
537
+ ).first()
538
+
539
+ total_tokens_input = session.exec(
540
+ select(func.sum(AccessLog.tokens_input))
541
+ .select_from(AccessLog)
542
+ .where(*base_where_conditions)
543
+ ).first()
544
+
545
+ total_tokens_output = session.exec(
546
+ select(func.sum(AccessLog.tokens_output))
547
+ .select_from(AccessLog)
548
+ .where(*base_where_conditions)
549
+ ).first()
550
+
551
+ return {
552
+ "summary": {
553
+ "total_requests": total_requests or 0,
554
+ "avg_duration_ms": avg_duration or 0,
555
+ "total_cost_usd": total_cost or 0,
556
+ "total_tokens_input": total_tokens_input or 0,
557
+ "total_tokens_output": total_tokens_output or 0,
558
+ },
559
+ "query_time": time.time(),
560
+ }
561
+
562
+ except Exception as e:
563
+ logger.error("sqlmodel_analytics_error", error=str(e))
564
+ return {}
565
+
566
+ async def close(self) -> None:
567
+ """Close the database connection and stop background worker."""
568
+ # Signal shutdown to background worker
569
+ self._shutdown_event.set()
570
+
571
+ # Wait for background worker to finish
572
+ if self._background_worker_task:
573
+ try:
574
+ await asyncio.wait_for(self._background_worker_task, timeout=5.0)
575
+ except TimeoutError:
576
+ logger.warning("background_worker_shutdown_timeout")
577
+ self._background_worker_task.cancel()
578
+ except Exception as e:
579
+ logger.error("background_worker_shutdown_error", error=str(e))
580
+
581
+ # Process remaining items in queue (with timeout)
582
+ try:
583
+ await asyncio.wait_for(self._write_queue.join(), timeout=2.0)
584
+ except TimeoutError:
585
+ logger.warning(
586
+ "queue_drain_timeout", remaining_items=self._write_queue.qsize()
587
+ )
588
+
589
+ if self._engine:
590
+ try:
591
+ self._engine.dispose()
592
+ except Exception as e:
593
+ logger.error("simple_duckdb_engine_close_error", error=str(e))
594
+ finally:
595
+ self._engine = None
596
+
597
+ self._initialized = False
598
+
599
+ def is_enabled(self) -> bool:
600
+ """Check if storage is enabled and available."""
601
+ return self._initialized
602
+
603
+ async def health_check(self) -> dict[str, Any]:
604
+ """Get health status of the storage backend."""
605
+ if not self._initialized:
606
+ return {
607
+ "status": "not_initialized",
608
+ "enabled": False,
609
+ }
610
+
611
+ try:
612
+ if self._engine:
613
+ with Session(self._engine) as session:
614
+ statement = select(func.count()).select_from(AccessLog)
615
+ access_log_count = session.exec(statement).first()
616
+
617
+ return {
618
+ "status": "healthy",
619
+ "enabled": True,
620
+ "database_path": str(self.database_path),
621
+ "access_log_count": access_log_count,
622
+ "backend": "sqlmodel",
623
+ }
624
+ else:
625
+ return {
626
+ "status": "no_connection",
627
+ "enabled": False,
628
+ }
629
+
630
+ except Exception as e:
631
+ return {
632
+ "status": "unhealthy",
633
+ "enabled": False,
634
+ "error": str(e),
635
+ }
636
+
637
+ async def reset_data(self) -> bool:
638
+ """Reset all data in the storage (useful for testing/debugging).
639
+
640
+ Returns:
641
+ True if reset was successful
642
+ """
643
+ if not self._initialized or not self._engine:
644
+ return False
645
+
646
+ try:
647
+ # Run the reset operation in a thread pool
648
+ return await asyncio.to_thread(self._reset_data_sync)
649
+ except Exception as e:
650
+ logger.error("simple_duckdb_reset_error", error=str(e))
651
+ return False
652
+
653
+ def _reset_data_sync(self) -> bool:
654
+ """Synchronous version of reset_data for thread pool execution."""
655
+ try:
656
+ with Session(self._engine) as session:
657
+ # Delete all records from access_logs table
658
+ session.execute(text("DELETE FROM access_logs"))
659
+ session.commit()
660
+
661
+ logger.info("simple_duckdb_reset_success")
662
+ return True
663
+ except Exception as e:
664
+ logger.error("simple_duckdb_reset_sync_error", error=str(e))
665
+ return False