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,1033 @@
1
+ """Metrics endpoints for CCProxy API Server."""
2
+
3
+ import time
4
+ from datetime import datetime as dt
5
+ from typing import Any, Optional, cast
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
8
+ from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
9
+ from sqlmodel import Session, col, desc, func, select
10
+ from typing_extensions import TypedDict
11
+
12
+ from ccproxy.api.dependencies import (
13
+ DuckDBStorageDep,
14
+ LogStorageDep,
15
+ ObservabilityMetricsDep,
16
+ SettingsDep,
17
+ )
18
+ from ccproxy.observability.storage.models import AccessLog
19
+
20
+
21
+ class AnalyticsSummary(TypedDict):
22
+ """TypedDict for analytics summary data."""
23
+
24
+ total_requests: int
25
+ total_successful_requests: int
26
+ total_error_requests: int
27
+ avg_duration_ms: float
28
+ total_cost_usd: float
29
+ total_tokens_input: int
30
+ total_tokens_output: int
31
+ total_cache_read_tokens: int
32
+ total_cache_write_tokens: int
33
+ total_tokens_all: int
34
+
35
+
36
+ class TokenAnalytics(TypedDict):
37
+ """TypedDict for token analytics data."""
38
+
39
+ input_tokens: int
40
+ output_tokens: int
41
+ cache_read_tokens: int
42
+ cache_write_tokens: int
43
+ total_tokens: int
44
+
45
+
46
+ class RequestAnalytics(TypedDict):
47
+ """TypedDict for request analytics data."""
48
+
49
+ total_requests: int
50
+ successful_requests: int
51
+ error_requests: int
52
+ success_rate: float
53
+ error_rate: float
54
+
55
+
56
+ class ServiceBreakdown(TypedDict):
57
+ """TypedDict for service type breakdown data."""
58
+
59
+ request_count: int
60
+ successful_requests: int
61
+ error_requests: int
62
+ success_rate: float
63
+ error_rate: float
64
+ avg_duration_ms: float
65
+ total_cost_usd: float
66
+ total_tokens_input: int
67
+ total_tokens_output: int
68
+ total_cache_read_tokens: int
69
+ total_cache_write_tokens: int
70
+ total_tokens_all: int
71
+
72
+
73
+ class AnalyticsResult(TypedDict):
74
+ """TypedDict for complete analytics result."""
75
+
76
+ summary: AnalyticsSummary
77
+ token_analytics: TokenAnalytics
78
+ request_analytics: RequestAnalytics
79
+ service_type_breakdown: dict[str, ServiceBreakdown]
80
+ query_time: float
81
+ backend: str
82
+ query_params: dict[str, Any]
83
+
84
+
85
+ # Create separate routers for different concerns
86
+ prometheus_router = APIRouter(tags=["metrics"])
87
+ logs_router = APIRouter(prefix="/logs", tags=["logs"])
88
+ dashboard_router = APIRouter(tags=["dashboard"])
89
+
90
+ # Backward compatibility - keep the old router name pointing to logs for now
91
+ router = logs_router
92
+
93
+
94
+ @logs_router.get("/status")
95
+ async def logs_status(metrics: ObservabilityMetricsDep) -> dict[str, str]:
96
+ """Get observability system status."""
97
+ return {
98
+ "status": "healthy",
99
+ "prometheus_enabled": str(metrics.is_enabled()),
100
+ "observability_system": "hybrid_prometheus_structlog",
101
+ }
102
+
103
+
104
+ @dashboard_router.get("/dashboard")
105
+ async def get_metrics_dashboard() -> HTMLResponse:
106
+ """Serve the metrics dashboard SPA entry point."""
107
+ from pathlib import Path
108
+
109
+ # Get the path to the dashboard folder
110
+ current_file = Path(__file__)
111
+ project_root = (
112
+ current_file.parent.parent.parent.parent
113
+ ) # ccproxy/api/routes/metrics.py -> project root
114
+ dashboard_folder = project_root / "ccproxy" / "static" / "dashboard"
115
+ dashboard_index = dashboard_folder / "index.html"
116
+
117
+ # Check if dashboard folder and index.html exist
118
+ if not dashboard_folder.exists():
119
+ raise HTTPException(
120
+ status_code=404,
121
+ detail="Dashboard not found. Please build the dashboard first using 'cd dashboard && bun run build:prod'",
122
+ )
123
+
124
+ if not dashboard_index.exists():
125
+ raise HTTPException(
126
+ status_code=404,
127
+ detail="Dashboard index.html not found. Please rebuild the dashboard using 'cd dashboard && bun run build:prod'",
128
+ )
129
+
130
+ # Read the HTML content
131
+ try:
132
+ with dashboard_index.open(encoding="utf-8") as f:
133
+ html_content = f.read()
134
+
135
+ return HTMLResponse(
136
+ content=html_content,
137
+ status_code=200,
138
+ headers={
139
+ "Cache-Control": "no-cache, no-store, must-revalidate",
140
+ "Pragma": "no-cache",
141
+ "Expires": "0",
142
+ "Content-Type": "text/html; charset=utf-8",
143
+ },
144
+ )
145
+ except Exception as e:
146
+ raise HTTPException(
147
+ status_code=500, detail=f"Failed to serve dashboard: {str(e)}"
148
+ ) from e
149
+
150
+
151
+ @dashboard_router.get("/dashboard/favicon.svg")
152
+ async def get_dashboard_favicon() -> FileResponse:
153
+ """Serve the dashboard favicon."""
154
+ from pathlib import Path
155
+
156
+ # Get the path to the favicon
157
+ current_file = Path(__file__)
158
+ project_root = (
159
+ current_file.parent.parent.parent.parent
160
+ ) # ccproxy/api/routes/metrics.py -> project root
161
+ favicon_path = project_root / "ccproxy" / "static" / "dashboard" / "favicon.svg"
162
+
163
+ if not favicon_path.exists():
164
+ raise HTTPException(status_code=404, detail="Favicon not found")
165
+
166
+ return FileResponse(
167
+ path=str(favicon_path),
168
+ media_type="image/svg+xml",
169
+ headers={"Cache-Control": "public, max-age=3600"},
170
+ )
171
+
172
+
173
+ @prometheus_router.get("/metrics")
174
+ async def get_prometheus_metrics(metrics: ObservabilityMetricsDep) -> Response:
175
+ """Export metrics in Prometheus format using native prometheus_client.
176
+
177
+ This endpoint exposes operational metrics collected by the hybrid observability
178
+ system for Prometheus scraping.
179
+
180
+ Args:
181
+ metrics: Observability metrics dependency
182
+
183
+ Returns:
184
+ Prometheus-formatted metrics text
185
+ """
186
+ try:
187
+ # Check if prometheus_client is available
188
+ try:
189
+ from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
190
+ except ImportError as err:
191
+ raise HTTPException(
192
+ status_code=503,
193
+ detail="Prometheus client not available. Install with: pip install prometheus-client",
194
+ ) from err
195
+
196
+ if not metrics.is_enabled():
197
+ raise HTTPException(
198
+ status_code=503,
199
+ detail="Prometheus metrics not enabled. Ensure prometheus-client is installed.",
200
+ )
201
+
202
+ # Generate prometheus format using the registry
203
+ from prometheus_client import REGISTRY, CollectorRegistry
204
+
205
+ # Use the global registry if metrics.registry is None (default behavior)
206
+ registry = metrics.registry if metrics.registry is not None else REGISTRY
207
+ prometheus_data = generate_latest(registry)
208
+
209
+ # Return the metrics data with proper content type
210
+ from fastapi import Response
211
+
212
+ return Response(
213
+ content=prometheus_data,
214
+ media_type=CONTENT_TYPE_LATEST,
215
+ headers={
216
+ "Cache-Control": "no-cache, no-store, must-revalidate",
217
+ "Pragma": "no-cache",
218
+ "Expires": "0",
219
+ },
220
+ )
221
+
222
+ except HTTPException:
223
+ raise
224
+ except Exception as e:
225
+ raise HTTPException(
226
+ status_code=500, detail=f"Failed to generate Prometheus metrics: {str(e)}"
227
+ ) from e
228
+
229
+
230
+ @logs_router.get("/query")
231
+ async def query_logs(
232
+ storage: DuckDBStorageDep,
233
+ settings: SettingsDep,
234
+ limit: int = Query(1000, ge=1, le=10000, description="Maximum number of results"),
235
+ start_time: float | None = Query(None, description="Start timestamp filter"),
236
+ end_time: float | None = Query(None, description="End timestamp filter"),
237
+ model: str | None = Query(None, description="Model filter"),
238
+ service_type: str | None = Query(None, description="Service type filter"),
239
+ ) -> dict[str, Any]:
240
+ """
241
+ Query access logs with filters.
242
+
243
+ Returns access log entries with optional filtering by time range, model, and service type.
244
+ """
245
+ try:
246
+ if not settings.observability.logs_collection_enabled:
247
+ raise HTTPException(
248
+ status_code=503,
249
+ detail="Logs collection is disabled. Enable with logs_collection_enabled=true",
250
+ )
251
+ if not storage:
252
+ raise HTTPException(
253
+ status_code=503,
254
+ detail="Storage backend not available. Ensure DuckDB is installed and pipeline is running.",
255
+ )
256
+
257
+ # Use SQLModel for querying
258
+ if hasattr(storage, "_engine") and storage._engine:
259
+ try:
260
+ with Session(storage._engine) as session:
261
+ # Build base query
262
+ statement = select(AccessLog)
263
+
264
+ # Add filters - convert Unix timestamps to datetime
265
+ start_dt = dt.fromtimestamp(start_time) if start_time else None
266
+ end_dt = dt.fromtimestamp(end_time) if end_time else None
267
+
268
+ if start_dt:
269
+ statement = statement.where(AccessLog.timestamp >= start_dt)
270
+ if end_dt:
271
+ statement = statement.where(AccessLog.timestamp <= end_dt)
272
+ if model:
273
+ statement = statement.where(AccessLog.model == model)
274
+ if service_type:
275
+ statement = statement.where(
276
+ AccessLog.service_type == service_type
277
+ )
278
+
279
+ # Apply limit and order
280
+ statement = statement.order_by(desc(AccessLog.timestamp)).limit(
281
+ limit
282
+ )
283
+
284
+ # Execute query
285
+ results = session.exec(statement).all()
286
+
287
+ # Convert to dict format
288
+ entries = [log.dict() for log in results]
289
+
290
+ return {
291
+ "results": entries,
292
+ "count": len(entries),
293
+ "limit": limit,
294
+ "filters": {
295
+ "start_time": start_time,
296
+ "end_time": end_time,
297
+ "model": model,
298
+ "service_type": service_type,
299
+ },
300
+ "timestamp": time.time(),
301
+ }
302
+
303
+ except Exception as e:
304
+ import structlog
305
+
306
+ logger = structlog.get_logger(__name__)
307
+ logger.error("sqlmodel_query_error", error=str(e))
308
+ raise HTTPException(
309
+ status_code=500, detail=f"Query execution failed: {str(e)}"
310
+ ) from e
311
+ else:
312
+ raise HTTPException(
313
+ status_code=503,
314
+ detail="Storage engine not available",
315
+ )
316
+
317
+ except HTTPException:
318
+ raise
319
+ except Exception as e:
320
+ raise HTTPException(
321
+ status_code=500, detail=f"Query execution failed: {str(e)}"
322
+ ) from e
323
+
324
+
325
+ @logs_router.get("/analytics")
326
+ async def get_logs_analytics(
327
+ storage: DuckDBStorageDep,
328
+ settings: SettingsDep,
329
+ start_time: float | None = Query(None, description="Start timestamp (Unix time)"),
330
+ end_time: float | None = Query(None, description="End timestamp (Unix time)"),
331
+ model: str | None = Query(None, description="Filter by model name"),
332
+ service_type: str | None = Query(
333
+ None,
334
+ description="Filter by service type. Supports comma-separated values (e.g., 'proxy_service,sdk_service') and negation with ! prefix (e.g., '!access_log,!sdk_service')",
335
+ ),
336
+ hours: int | None = Query(
337
+ 24, ge=1, le=168, description="Hours of data to analyze (default: 24)"
338
+ ),
339
+ ) -> AnalyticsResult:
340
+ """
341
+ Get comprehensive analytics for metrics data.
342
+
343
+ Returns summary statistics, hourly trends, and model breakdowns.
344
+ """
345
+ try:
346
+ if not settings.observability.logs_collection_enabled:
347
+ raise HTTPException(
348
+ status_code=503,
349
+ detail="Logs collection is disabled. Enable with logs_collection_enabled=true",
350
+ )
351
+ if not storage:
352
+ raise HTTPException(
353
+ status_code=503,
354
+ detail="Storage backend not available. Ensure DuckDB is installed and pipeline is running.",
355
+ )
356
+
357
+ # Default time range if not provided
358
+ if start_time is None and end_time is None and hours:
359
+ end_time = time.time()
360
+ start_time = end_time - (hours * 3600)
361
+
362
+ # Use SQLModel for analytics
363
+ if hasattr(storage, "_engine") and storage._engine:
364
+ try:
365
+ with Session(storage._engine) as session:
366
+ # Build base query
367
+ statement = select(AccessLog)
368
+
369
+ # Add filters - convert Unix timestamps to datetime
370
+ start_dt = dt.fromtimestamp(start_time) if start_time else None
371
+ end_dt = dt.fromtimestamp(end_time) if end_time else None
372
+
373
+ # Helper function to build filter conditions
374
+ def build_filter_conditions() -> list[Any]:
375
+ conditions: list[Any] = []
376
+ if start_dt:
377
+ conditions.append(AccessLog.timestamp >= start_dt)
378
+ if end_dt:
379
+ conditions.append(AccessLog.timestamp <= end_dt)
380
+ if model:
381
+ conditions.append(AccessLog.model == model)
382
+
383
+ # Apply service type filtering with comma-separated values and negation
384
+ if service_type:
385
+ service_filters = [
386
+ s.strip() for s in service_type.split(",")
387
+ ]
388
+ include_filters = [
389
+ f for f in service_filters if not f.startswith("!")
390
+ ]
391
+ exclude_filters = [
392
+ f[1:] for f in service_filters if f.startswith("!")
393
+ ]
394
+
395
+ if include_filters:
396
+ conditions.append(
397
+ col(AccessLog.service_type).in_(include_filters)
398
+ )
399
+ if exclude_filters:
400
+ conditions.append(
401
+ ~col(AccessLog.service_type).in_(exclude_filters)
402
+ )
403
+
404
+ return conditions
405
+
406
+ # Get summary statistics using individual queries to avoid overload issues
407
+ # Reuse datetime variables defined above
408
+
409
+ filter_conditions = build_filter_conditions()
410
+
411
+ total_requests = session.exec(
412
+ select(func.count())
413
+ .select_from(AccessLog)
414
+ .where(*filter_conditions)
415
+ ).first()
416
+
417
+ avg_duration = session.exec(
418
+ select(func.avg(AccessLog.duration_ms))
419
+ .select_from(AccessLog)
420
+ .where(*filter_conditions)
421
+ ).first()
422
+
423
+ total_cost = session.exec(
424
+ select(func.sum(AccessLog.cost_usd))
425
+ .select_from(AccessLog)
426
+ .where(*filter_conditions)
427
+ ).first()
428
+
429
+ total_tokens_input = session.exec(
430
+ select(func.sum(AccessLog.tokens_input))
431
+ .select_from(AccessLog)
432
+ .where(*filter_conditions)
433
+ ).first()
434
+
435
+ total_tokens_output = session.exec(
436
+ select(func.sum(AccessLog.tokens_output))
437
+ .select_from(AccessLog)
438
+ .where(*filter_conditions)
439
+ ).first()
440
+
441
+ # Token analytics - all token types
442
+ total_cache_read_tokens = session.exec(
443
+ select(func.sum(AccessLog.cache_read_tokens))
444
+ .select_from(AccessLog)
445
+ .where(*filter_conditions)
446
+ ).first()
447
+
448
+ total_cache_write_tokens = session.exec(
449
+ select(func.sum(AccessLog.cache_write_tokens))
450
+ .select_from(AccessLog)
451
+ .where(*filter_conditions)
452
+ ).first()
453
+
454
+ # Success and error request analytics
455
+ success_conditions = filter_conditions + [
456
+ AccessLog.status_code >= 200,
457
+ AccessLog.status_code < 400,
458
+ ]
459
+ total_successful_requests = session.exec(
460
+ select(func.count())
461
+ .select_from(AccessLog)
462
+ .where(*success_conditions)
463
+ ).first()
464
+
465
+ error_conditions = filter_conditions + [
466
+ AccessLog.status_code >= 400,
467
+ ]
468
+ total_error_requests = session.exec(
469
+ select(func.count())
470
+ .select_from(AccessLog)
471
+ .where(*error_conditions)
472
+ ).first()
473
+
474
+ # Summary results are already computed individually above
475
+
476
+ # Get service type breakdown - simplified approach
477
+ service_breakdown = {}
478
+ # Get unique service types first
479
+ unique_services = session.exec(
480
+ select(AccessLog.service_type)
481
+ .distinct()
482
+ .where(*filter_conditions)
483
+ ).all()
484
+
485
+ # For each service type, get its statistics
486
+ for service in unique_services:
487
+ if service: # Skip None values
488
+ # Build service-specific filter conditions
489
+ service_conditions = []
490
+ if start_dt:
491
+ service_conditions.append(
492
+ AccessLog.timestamp >= start_dt
493
+ )
494
+ if end_dt:
495
+ service_conditions.append(AccessLog.timestamp <= end_dt)
496
+ if model:
497
+ service_conditions.append(AccessLog.model == model)
498
+ service_conditions.append(AccessLog.service_type == service)
499
+
500
+ service_count = session.exec(
501
+ select(func.count())
502
+ .select_from(AccessLog)
503
+ .where(*service_conditions)
504
+ ).first()
505
+
506
+ service_avg_duration = session.exec(
507
+ select(func.avg(AccessLog.duration_ms))
508
+ .select_from(AccessLog)
509
+ .where(*service_conditions)
510
+ ).first()
511
+
512
+ service_total_cost = session.exec(
513
+ select(func.sum(AccessLog.cost_usd))
514
+ .select_from(AccessLog)
515
+ .where(*service_conditions)
516
+ ).first()
517
+
518
+ service_total_tokens_input = session.exec(
519
+ select(func.sum(AccessLog.tokens_input))
520
+ .select_from(AccessLog)
521
+ .where(*service_conditions)
522
+ ).first()
523
+
524
+ service_total_tokens_output = session.exec(
525
+ select(func.sum(AccessLog.tokens_output))
526
+ .select_from(AccessLog)
527
+ .where(*service_conditions)
528
+ ).first()
529
+
530
+ service_cache_read_tokens = session.exec(
531
+ select(func.sum(AccessLog.cache_read_tokens))
532
+ .select_from(AccessLog)
533
+ .where(*service_conditions)
534
+ ).first()
535
+
536
+ service_cache_write_tokens = session.exec(
537
+ select(func.sum(AccessLog.cache_write_tokens))
538
+ .select_from(AccessLog)
539
+ .where(*service_conditions)
540
+ ).first()
541
+
542
+ service_success_conditions = service_conditions + [
543
+ AccessLog.status_code >= 200,
544
+ AccessLog.status_code < 400,
545
+ ]
546
+ service_success_count = session.exec(
547
+ select(func.count())
548
+ .select_from(AccessLog)
549
+ .where(*service_success_conditions)
550
+ ).first()
551
+
552
+ service_error_conditions = service_conditions + [
553
+ AccessLog.status_code >= 400,
554
+ ]
555
+ service_error_count = session.exec(
556
+ select(func.count())
557
+ .select_from(AccessLog)
558
+ .where(*service_error_conditions)
559
+ ).first()
560
+
561
+ service_breakdown[service] = {
562
+ "request_count": service_count or 0,
563
+ "successful_requests": service_success_count or 0,
564
+ "error_requests": service_error_count or 0,
565
+ "success_rate": (service_success_count or 0)
566
+ / (service_count or 1)
567
+ * 100
568
+ if service_count
569
+ else 0,
570
+ "error_rate": (service_error_count or 0)
571
+ / (service_count or 1)
572
+ * 100
573
+ if service_count
574
+ else 0,
575
+ "avg_duration_ms": service_avg_duration or 0,
576
+ "total_cost_usd": service_total_cost or 0,
577
+ "total_tokens_input": service_total_tokens_input or 0,
578
+ "total_tokens_output": service_total_tokens_output or 0,
579
+ "total_cache_read_tokens": service_cache_read_tokens
580
+ or 0,
581
+ "total_cache_write_tokens": service_cache_write_tokens
582
+ or 0,
583
+ "total_tokens_all": (service_total_tokens_input or 0)
584
+ + (service_total_tokens_output or 0)
585
+ + (service_cache_read_tokens or 0)
586
+ + (service_cache_write_tokens or 0),
587
+ }
588
+
589
+ analytics = {
590
+ "summary": {
591
+ "total_requests": total_requests or 0,
592
+ "total_successful_requests": total_successful_requests or 0,
593
+ "total_error_requests": total_error_requests or 0,
594
+ "avg_duration_ms": avg_duration or 0,
595
+ "total_cost_usd": total_cost or 0,
596
+ "total_tokens_input": total_tokens_input or 0,
597
+ "total_tokens_output": total_tokens_output or 0,
598
+ "total_cache_read_tokens": total_cache_read_tokens or 0,
599
+ "total_cache_write_tokens": total_cache_write_tokens or 0,
600
+ "total_tokens_all": (total_tokens_input or 0)
601
+ + (total_tokens_output or 0)
602
+ + (total_cache_read_tokens or 0)
603
+ + (total_cache_write_tokens or 0),
604
+ },
605
+ "token_analytics": {
606
+ "input_tokens": total_tokens_input or 0,
607
+ "output_tokens": total_tokens_output or 0,
608
+ "cache_read_tokens": total_cache_read_tokens or 0,
609
+ "cache_write_tokens": total_cache_write_tokens or 0,
610
+ "total_tokens": (total_tokens_input or 0)
611
+ + (total_tokens_output or 0)
612
+ + (total_cache_read_tokens or 0)
613
+ + (total_cache_write_tokens or 0),
614
+ },
615
+ "request_analytics": {
616
+ "total_requests": total_requests or 0,
617
+ "successful_requests": total_successful_requests or 0,
618
+ "error_requests": total_error_requests or 0,
619
+ "success_rate": (total_successful_requests or 0)
620
+ / (total_requests or 1)
621
+ * 100
622
+ if total_requests
623
+ else 0,
624
+ "error_rate": (total_error_requests or 0)
625
+ / (total_requests or 1)
626
+ * 100
627
+ if total_requests
628
+ else 0,
629
+ },
630
+ "service_type_breakdown": service_breakdown,
631
+ "query_time": time.time(),
632
+ "backend": "sqlmodel",
633
+ }
634
+
635
+ # Add metadata
636
+ analytics["query_params"] = {
637
+ "start_time": start_time,
638
+ "end_time": end_time,
639
+ "model": model,
640
+ "service_type": service_type,
641
+ "hours": hours,
642
+ }
643
+
644
+ return cast(AnalyticsResult, analytics)
645
+
646
+ except Exception as e:
647
+ import structlog
648
+
649
+ logger = structlog.get_logger(__name__)
650
+ logger.error("sqlmodel_analytics_error", error=str(e))
651
+ raise HTTPException(
652
+ status_code=500, detail=f"Analytics query failed: {str(e)}"
653
+ ) from e
654
+ else:
655
+ raise HTTPException(
656
+ status_code=503,
657
+ detail="Storage engine not available",
658
+ )
659
+
660
+ except HTTPException:
661
+ raise
662
+ except Exception as e:
663
+ raise HTTPException(
664
+ status_code=500, detail=f"Analytics generation failed: {str(e)}"
665
+ ) from e
666
+
667
+
668
+ @logs_router.get("/stream")
669
+ async def stream_logs(
670
+ request: Request,
671
+ model: str | None = Query(None, description="Filter by model name"),
672
+ service_type: str | None = Query(
673
+ None,
674
+ description="Filter by service type. Supports comma-separated values (e.g., 'proxy_service,sdk_service') and negation with ! prefix (e.g., '!access_log,!sdk_service')",
675
+ ),
676
+ min_duration_ms: float | None = Query(
677
+ None, description="Filter by minimum duration in milliseconds"
678
+ ),
679
+ max_duration_ms: float | None = Query(
680
+ None, description="Filter by maximum duration in milliseconds"
681
+ ),
682
+ status_code_min: int | None = Query(
683
+ None, description="Filter by minimum status code"
684
+ ),
685
+ status_code_max: int | None = Query(
686
+ None, description="Filter by maximum status code"
687
+ ),
688
+ ) -> StreamingResponse:
689
+ """
690
+ Stream real-time metrics and request logs via Server-Sent Events.
691
+
692
+ Returns a continuous stream of request events using event-driven SSE
693
+ instead of polling. Events are emitted in real-time when requests
694
+ start, complete, or error. Supports filtering similar to analytics and entries endpoints.
695
+ """
696
+ import asyncio
697
+ import uuid
698
+ from collections.abc import AsyncIterator
699
+
700
+ # Get request ID from request state
701
+ request_id = getattr(request.state, "request_id", None)
702
+
703
+ if request and hasattr(request, "state") and hasattr(request.state, "context"):
704
+ # Use existing context from middleware
705
+ ctx = request.state.context
706
+ # Set streaming flag for access log
707
+ ctx.add_metadata(streaming=True)
708
+ ctx.add_metadata(event_type="streaming_complete")
709
+
710
+ # Build filter criteria for event filtering
711
+ filter_criteria = {
712
+ "model": model,
713
+ "service_type": service_type,
714
+ "min_duration_ms": min_duration_ms,
715
+ "max_duration_ms": max_duration_ms,
716
+ "status_code_min": status_code_min,
717
+ "status_code_max": status_code_max,
718
+ }
719
+ # Remove None values
720
+ filter_criteria = {k: v for k, v in filter_criteria.items() if v is not None}
721
+
722
+ def should_include_event(event_data: dict[str, Any]) -> bool:
723
+ """Check if event matches filter criteria."""
724
+ if not filter_criteria:
725
+ return True
726
+
727
+ data = event_data.get("data", {})
728
+
729
+ # Model filter
730
+ if "model" in filter_criteria and data.get("model") != filter_criteria["model"]:
731
+ return False
732
+
733
+ # Service type filter with comma-separated and negation support
734
+ if "service_type" in filter_criteria:
735
+ service_type_filter = filter_criteria["service_type"]
736
+ if isinstance(service_type_filter, str):
737
+ service_filters = [s.strip() for s in service_type_filter.split(",")]
738
+ else:
739
+ # Handle non-string types by converting to string
740
+ service_filters = [str(service_type_filter).strip()]
741
+ include_filters = [f for f in service_filters if not f.startswith("!")]
742
+ exclude_filters = [f[1:] for f in service_filters if f.startswith("!")]
743
+
744
+ data_service_type = data.get("service_type")
745
+ if include_filters and data_service_type not in include_filters:
746
+ return False
747
+ if exclude_filters and data_service_type in exclude_filters:
748
+ return False
749
+
750
+ # Duration filters
751
+ duration_ms = data.get("duration_ms")
752
+ if duration_ms is not None:
753
+ if (
754
+ "min_duration_ms" in filter_criteria
755
+ and duration_ms < filter_criteria["min_duration_ms"]
756
+ ):
757
+ return False
758
+ if (
759
+ "max_duration_ms" in filter_criteria
760
+ and duration_ms > filter_criteria["max_duration_ms"]
761
+ ):
762
+ return False
763
+
764
+ # Status code filters
765
+ status_code = data.get("status_code")
766
+ if status_code is not None:
767
+ if (
768
+ "status_code_min" in filter_criteria
769
+ and status_code < filter_criteria["status_code_min"]
770
+ ):
771
+ return False
772
+ if (
773
+ "status_code_max" in filter_criteria
774
+ and status_code > filter_criteria["status_code_max"]
775
+ ):
776
+ return False
777
+
778
+ return True
779
+
780
+ async def event_stream() -> AsyncIterator[str]:
781
+ """Generate Server-Sent Events for real-time metrics."""
782
+ from ccproxy.observability.sse_events import get_sse_manager
783
+
784
+ # Get SSE manager
785
+ sse_manager = get_sse_manager()
786
+
787
+ # Create unique connection ID
788
+ connection_id = str(uuid.uuid4())
789
+
790
+ try:
791
+ # Use SSE manager for event-driven streaming
792
+ async for event_data in sse_manager.add_connection(
793
+ connection_id, request_id
794
+ ):
795
+ # Parse event data to check for filtering
796
+ if event_data.startswith("data: "):
797
+ try:
798
+ import json
799
+
800
+ json_str = event_data[6:].strip()
801
+ if json_str:
802
+ event_obj = json.loads(json_str)
803
+
804
+ # Apply filters for data events (not connection/system events)
805
+ if (
806
+ event_obj.get("type")
807
+ in ["request_complete", "request_start"]
808
+ and filter_criteria
809
+ ) and not should_include_event(event_obj):
810
+ continue # Skip this event
811
+
812
+ except (json.JSONDecodeError, KeyError):
813
+ # If we can't parse, pass through (system events)
814
+ pass
815
+
816
+ yield event_data
817
+
818
+ except asyncio.CancelledError:
819
+ # Connection was cancelled, cleanup handled by SSE manager
820
+ pass
821
+ except Exception as e:
822
+ # Send error event
823
+ import json
824
+
825
+ error_event = {
826
+ "type": "error",
827
+ "message": str(e),
828
+ "timestamp": time.time(),
829
+ }
830
+ yield f"data: {json.dumps(error_event)}\n\n"
831
+
832
+ return StreamingResponse(
833
+ event_stream(),
834
+ media_type="text/event-stream",
835
+ headers={
836
+ "Cache-Control": "no-cache",
837
+ "Connection": "keep-alive",
838
+ "Access-Control-Allow-Origin": "*",
839
+ "Access-Control-Allow-Headers": "Cache-Control",
840
+ },
841
+ )
842
+
843
+
844
+ @logs_router.get("/entries")
845
+ async def get_logs_entries(
846
+ storage: DuckDBStorageDep,
847
+ settings: SettingsDep,
848
+ limit: int = Query(
849
+ 50, ge=1, le=1000, description="Maximum number of entries to return"
850
+ ),
851
+ offset: int = Query(0, ge=0, description="Number of entries to skip"),
852
+ order_by: str = Query(
853
+ "timestamp",
854
+ description="Column to order by (timestamp, duration_ms, cost_usd, model, service_type, status_code)",
855
+ ),
856
+ order_desc: bool = Query(False, description="Order in descending order"),
857
+ service_type: str | None = Query(
858
+ None,
859
+ description="Filter by service type. Supports comma-separated values (e.g., 'proxy_service,sdk_service') and negation with ! prefix (e.g., '!access_log,!sdk_service')",
860
+ ),
861
+ ) -> dict[str, Any]:
862
+ """
863
+ Get the last n database entries from the access logs.
864
+
865
+ Returns individual request entries with full details for analysis.
866
+ """
867
+ try:
868
+ if not settings.observability.logs_collection_enabled:
869
+ raise HTTPException(
870
+ status_code=503,
871
+ detail="Logs collection is disabled. Enable with logs_collection_enabled=true",
872
+ )
873
+ if not storage:
874
+ raise HTTPException(
875
+ status_code=503,
876
+ detail="Storage backend not available. Ensure DuckDB is installed and pipeline is running.",
877
+ )
878
+
879
+ # Use SQLModel for entries
880
+ if hasattr(storage, "_engine") and storage._engine:
881
+ try:
882
+ with Session(storage._engine) as session:
883
+ # Validate order_by parameter using SQLModel
884
+ valid_columns = list(AccessLog.model_fields.keys())
885
+ if order_by not in valid_columns:
886
+ order_by = "timestamp"
887
+
888
+ # Build SQLModel query
889
+ order_attr = getattr(AccessLog, order_by)
890
+ order_clause = order_attr.desc() if order_desc else order_attr.asc()
891
+
892
+ statement = select(AccessLog)
893
+
894
+ # Apply service type filtering with comma-separated values and negation
895
+ if service_type:
896
+ service_filters = [s.strip() for s in service_type.split(",")]
897
+ include_filters = [
898
+ f for f in service_filters if not f.startswith("!")
899
+ ]
900
+ exclude_filters = [
901
+ f[1:] for f in service_filters if f.startswith("!")
902
+ ]
903
+
904
+ if include_filters:
905
+ statement = statement.where(
906
+ col(AccessLog.service_type).in_(include_filters)
907
+ )
908
+ if exclude_filters:
909
+ statement = statement.where(
910
+ ~col(AccessLog.service_type).in_(exclude_filters)
911
+ )
912
+
913
+ statement = (
914
+ statement.order_by(order_clause).offset(offset).limit(limit)
915
+ )
916
+ results = session.exec(statement).all()
917
+
918
+ # Get total count with same filters
919
+ count_statement = select(func.count()).select_from(AccessLog)
920
+
921
+ # Apply same service type filtering to count
922
+ if service_type:
923
+ service_filters = [s.strip() for s in service_type.split(",")]
924
+ include_filters = [
925
+ f for f in service_filters if not f.startswith("!")
926
+ ]
927
+ exclude_filters = [
928
+ f[1:] for f in service_filters if f.startswith("!")
929
+ ]
930
+
931
+ if include_filters:
932
+ count_statement = count_statement.where(
933
+ col(AccessLog.service_type).in_(include_filters)
934
+ )
935
+ if exclude_filters:
936
+ count_statement = count_statement.where(
937
+ ~col(AccessLog.service_type).in_(exclude_filters)
938
+ )
939
+
940
+ total_count = session.exec(count_statement).first()
941
+
942
+ # Convert to dict format
943
+ entries = [log.dict() for log in results]
944
+
945
+ return {
946
+ "entries": entries,
947
+ "total_count": total_count,
948
+ "limit": limit,
949
+ "offset": offset,
950
+ "order_by": order_by,
951
+ "order_desc": order_desc,
952
+ "service_type": service_type,
953
+ "page": (offset // limit) + 1,
954
+ "total_pages": ((total_count or 0) + limit - 1) // limit,
955
+ "backend": "sqlmodel",
956
+ }
957
+
958
+ except Exception as e:
959
+ import structlog
960
+
961
+ logger = structlog.get_logger(__name__)
962
+ logger.error("sqlmodel_entries_error", error=str(e))
963
+ raise HTTPException(
964
+ status_code=500, detail=f"Failed to retrieve entries: {str(e)}"
965
+ ) from e
966
+ else:
967
+ raise HTTPException(
968
+ status_code=503,
969
+ detail="Storage engine not available",
970
+ )
971
+
972
+ except HTTPException:
973
+ raise
974
+ except Exception as e:
975
+ raise HTTPException(
976
+ status_code=500, detail=f"Failed to retrieve database entries: {str(e)}"
977
+ ) from e
978
+
979
+
980
+ @logs_router.post("/reset")
981
+ async def reset_logs_data(
982
+ storage: DuckDBStorageDep, settings: SettingsDep
983
+ ) -> dict[str, Any]:
984
+ """
985
+ Reset all data in the logs storage.
986
+
987
+ This endpoint clears all access logs from the database.
988
+ Use with caution - this action cannot be undone.
989
+
990
+ Returns:
991
+ Dictionary with reset status and timestamp
992
+ """
993
+ try:
994
+ if not settings.observability.logs_collection_enabled:
995
+ raise HTTPException(
996
+ status_code=503,
997
+ detail="Logs collection is disabled. Enable with logs_collection_enabled=true",
998
+ )
999
+ if not storage:
1000
+ raise HTTPException(
1001
+ status_code=503,
1002
+ detail="Storage backend not available. Ensure DuckDB is installed.",
1003
+ )
1004
+
1005
+ # Check if storage has reset_data method
1006
+ if not hasattr(storage, "reset_data"):
1007
+ raise HTTPException(
1008
+ status_code=501,
1009
+ detail="Reset operation not supported by current storage backend",
1010
+ )
1011
+
1012
+ # Perform the reset
1013
+ success = await storage.reset_data()
1014
+
1015
+ if success:
1016
+ return {
1017
+ "status": "success",
1018
+ "message": "All logs data has been reset",
1019
+ "timestamp": time.time(),
1020
+ "backend": "duckdb",
1021
+ }
1022
+ else:
1023
+ raise HTTPException(
1024
+ status_code=500,
1025
+ detail="Reset operation failed",
1026
+ )
1027
+
1028
+ except HTTPException:
1029
+ raise
1030
+ except Exception as e:
1031
+ raise HTTPException(
1032
+ status_code=500, detail=f"Reset operation failed: {str(e)}"
1033
+ ) from e