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,755 @@
1
+ """
2
+ Stats collector and printer for periodic metrics summary.
3
+
4
+ This module provides functionality to collect and print periodic statistics
5
+ from the observability system, including Prometheus metrics and DuckDB storage.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ from collections.abc import Callable
13
+ from dataclasses import dataclass
14
+ from datetime import datetime, timedelta
15
+ from typing import Any, Optional
16
+
17
+ import structlog
18
+
19
+ from ccproxy.config.observability import ObservabilitySettings
20
+
21
+
22
+ logger = structlog.get_logger(__name__)
23
+
24
+
25
+ @dataclass
26
+ class StatsSnapshot:
27
+ """Snapshot of current statistics."""
28
+
29
+ timestamp: datetime
30
+ requests_total: int
31
+ requests_last_minute: int
32
+ avg_response_time_ms: float
33
+ avg_response_time_last_minute_ms: float
34
+ tokens_input_total: int
35
+ tokens_output_total: int
36
+ tokens_input_last_minute: int
37
+ tokens_output_last_minute: int
38
+ cost_total_usd: float
39
+ cost_last_minute_usd: float
40
+ errors_total: int
41
+ errors_last_minute: int
42
+ active_requests: int
43
+ top_model: str
44
+ top_model_percentage: float
45
+
46
+
47
+ class StatsCollector:
48
+ """
49
+ Collects and formats metrics statistics for periodic printing.
50
+
51
+ Integrates with both Prometheus metrics and DuckDB storage to provide
52
+ comprehensive statistics about the API performance.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ settings: ObservabilitySettings,
58
+ metrics_instance: Any | None = None,
59
+ storage_instance: Any | None = None,
60
+ ):
61
+ """
62
+ Initialize stats collector.
63
+
64
+ Args:
65
+ settings: Observability configuration settings
66
+ metrics_instance: Prometheus metrics instance
67
+ storage_instance: DuckDB storage instance
68
+ """
69
+ self.settings = settings
70
+ self._metrics_instance = metrics_instance
71
+ self._storage_instance = storage_instance
72
+ self._last_snapshot: StatsSnapshot | None = None
73
+ self._last_collection_time = time.time()
74
+
75
+ async def collect_stats(self) -> StatsSnapshot:
76
+ """
77
+ Collect current statistics from all available sources.
78
+
79
+ Returns:
80
+ StatsSnapshot with current metrics
81
+ """
82
+ current_time = time.time()
83
+ timestamp = datetime.now()
84
+
85
+ # Initialize default values
86
+ stats_data: dict[str, Any] = {
87
+ "timestamp": timestamp,
88
+ "requests_total": 0,
89
+ "requests_last_minute": 0,
90
+ "avg_response_time_ms": 0.0,
91
+ "avg_response_time_last_minute_ms": 0.0,
92
+ "tokens_input_total": 0,
93
+ "tokens_output_total": 0,
94
+ "tokens_input_last_minute": 0,
95
+ "tokens_output_last_minute": 0,
96
+ "cost_total_usd": 0.0,
97
+ "cost_last_minute_usd": 0.0,
98
+ "errors_total": 0,
99
+ "errors_last_minute": 0,
100
+ "active_requests": 0,
101
+ "top_model": "unknown",
102
+ "top_model_percentage": 0.0,
103
+ }
104
+
105
+ # Collect from Prometheus metrics if available
106
+ if self._metrics_instance and self._metrics_instance.is_enabled():
107
+ try:
108
+ await self._collect_from_prometheus(stats_data)
109
+ except Exception as e:
110
+ logger.warning(
111
+ "Failed to collect from Prometheus metrics", error=str(e)
112
+ )
113
+
114
+ # Collect from DuckDB storage if available
115
+ if self._storage_instance and self._storage_instance.is_enabled():
116
+ try:
117
+ await self._collect_from_duckdb(stats_data, current_time)
118
+ except Exception as e:
119
+ logger.warning("Failed to collect from DuckDB storage", error=str(e))
120
+
121
+ snapshot = StatsSnapshot(
122
+ timestamp=stats_data["timestamp"],
123
+ requests_total=int(stats_data["requests_total"]),
124
+ requests_last_minute=int(stats_data["requests_last_minute"]),
125
+ avg_response_time_ms=float(stats_data["avg_response_time_ms"]),
126
+ avg_response_time_last_minute_ms=float(
127
+ stats_data["avg_response_time_last_minute_ms"]
128
+ ),
129
+ tokens_input_total=int(stats_data["tokens_input_total"]),
130
+ tokens_output_total=int(stats_data["tokens_output_total"]),
131
+ tokens_input_last_minute=int(stats_data["tokens_input_last_minute"]),
132
+ tokens_output_last_minute=int(stats_data["tokens_output_last_minute"]),
133
+ cost_total_usd=float(stats_data["cost_total_usd"]),
134
+ cost_last_minute_usd=float(stats_data["cost_last_minute_usd"]),
135
+ errors_total=int(stats_data["errors_total"]),
136
+ errors_last_minute=int(stats_data["errors_last_minute"]),
137
+ active_requests=int(stats_data["active_requests"]),
138
+ top_model=str(stats_data["top_model"]),
139
+ top_model_percentage=float(stats_data["top_model_percentage"]),
140
+ )
141
+ self._last_snapshot = snapshot
142
+ self._last_collection_time = current_time
143
+
144
+ return snapshot
145
+
146
+ async def _collect_from_prometheus(self, stats_data: dict[str, Any]) -> None:
147
+ """Collect statistics from Prometheus metrics."""
148
+ if not self._metrics_instance:
149
+ return
150
+
151
+ try:
152
+ logger.debug(
153
+ "prometheus_collection_starting",
154
+ metrics_available=bool(self._metrics_instance),
155
+ )
156
+
157
+ # Get active requests from gauge
158
+ if hasattr(self._metrics_instance, "active_requests"):
159
+ active_value = self._metrics_instance.active_requests._value._value
160
+ stats_data["active_requests"] = int(active_value)
161
+ logger.debug(
162
+ "prometheus_active_requests_collected", active_requests=active_value
163
+ )
164
+
165
+ # Get request counts from counter
166
+ if hasattr(self._metrics_instance, "request_counter"):
167
+ request_counter = self._metrics_instance.request_counter
168
+ # Sum all request counts across all labels
169
+ total_requests = 0
170
+ for metric in request_counter.collect():
171
+ for sample in metric.samples:
172
+ if sample.name.endswith("_total"):
173
+ total_requests += sample.value
174
+ stats_data["requests_total"] = int(total_requests)
175
+
176
+ # Calculate last minute requests (difference from last snapshot)
177
+ if self._last_snapshot:
178
+ last_minute_requests = (
179
+ total_requests - self._last_snapshot.requests_total
180
+ )
181
+ stats_data["requests_last_minute"] = max(
182
+ 0, int(last_minute_requests)
183
+ )
184
+ else:
185
+ stats_data["requests_last_minute"] = int(total_requests)
186
+
187
+ logger.debug(
188
+ "prometheus_requests_collected",
189
+ total_requests=total_requests,
190
+ requests_last_minute=stats_data["requests_last_minute"],
191
+ )
192
+
193
+ # Get response times from histogram
194
+ if hasattr(self._metrics_instance, "response_time"):
195
+ response_time = self._metrics_instance.response_time
196
+ # Get total count and sum for average calculation
197
+ total_count = 0
198
+ total_sum = 0
199
+ for metric in response_time.collect():
200
+ for sample in metric.samples:
201
+ if sample.name.endswith("_count"):
202
+ total_count += sample.value
203
+ elif sample.name.endswith("_sum"):
204
+ total_sum += sample.value
205
+
206
+ if total_count > 0:
207
+ avg_response_time_seconds = total_sum / total_count
208
+ stats_data["avg_response_time_ms"] = (
209
+ avg_response_time_seconds * 1000
210
+ )
211
+
212
+ # Calculate last minute average response time
213
+ if self._last_snapshot and self._last_snapshot.requests_total > 0:
214
+ last_minute_count = (
215
+ total_count - self._last_snapshot.requests_total
216
+ )
217
+ if last_minute_count > 0:
218
+ # Calculate the sum for just the last minute
219
+ last_minute_sum = total_sum - (
220
+ self._last_snapshot.requests_total
221
+ * self._last_snapshot.avg_response_time_ms
222
+ / 1000
223
+ )
224
+ last_minute_avg = (
225
+ last_minute_sum / last_minute_count
226
+ ) * 1000
227
+ stats_data["avg_response_time_last_minute_ms"] = float(
228
+ last_minute_avg
229
+ )
230
+ else:
231
+ stats_data["avg_response_time_last_minute_ms"] = 0.0
232
+ else:
233
+ stats_data["avg_response_time_last_minute_ms"] = stats_data[
234
+ "avg_response_time_ms"
235
+ ]
236
+
237
+ # Get token counts from counter
238
+ if hasattr(self._metrics_instance, "token_counter"):
239
+ token_counter = self._metrics_instance.token_counter
240
+ tokens_input = 0
241
+ tokens_output = 0
242
+ for metric in token_counter.collect():
243
+ for sample in metric.samples:
244
+ if sample.name.endswith("_total"):
245
+ token_type = sample.labels.get("type", "")
246
+ if token_type == "input":
247
+ tokens_input += sample.value
248
+ elif token_type == "output":
249
+ tokens_output += sample.value
250
+
251
+ stats_data["tokens_input_total"] = int(tokens_input)
252
+ stats_data["tokens_output_total"] = int(tokens_output)
253
+
254
+ # Calculate last minute tokens
255
+ if self._last_snapshot:
256
+ last_minute_input = (
257
+ tokens_input - self._last_snapshot.tokens_input_total
258
+ )
259
+ last_minute_output = (
260
+ tokens_output - self._last_snapshot.tokens_output_total
261
+ )
262
+ stats_data["tokens_input_last_minute"] = max(
263
+ 0, int(last_minute_input)
264
+ )
265
+ stats_data["tokens_output_last_minute"] = max(
266
+ 0, int(last_minute_output)
267
+ )
268
+ else:
269
+ stats_data["tokens_input_last_minute"] = int(tokens_input)
270
+ stats_data["tokens_output_last_minute"] = int(tokens_output)
271
+
272
+ # Get cost from counter
273
+ if hasattr(self._metrics_instance, "cost_counter"):
274
+ cost_counter = self._metrics_instance.cost_counter
275
+ total_cost = 0
276
+ for metric in cost_counter.collect():
277
+ for sample in metric.samples:
278
+ if sample.name.endswith("_total"):
279
+ total_cost += sample.value
280
+ stats_data["cost_total_usd"] = float(total_cost)
281
+
282
+ # Calculate last minute cost
283
+ if self._last_snapshot:
284
+ last_minute_cost = total_cost - self._last_snapshot.cost_total_usd
285
+ stats_data["cost_last_minute_usd"] = max(
286
+ 0.0, float(last_minute_cost)
287
+ )
288
+ else:
289
+ stats_data["cost_last_minute_usd"] = float(total_cost)
290
+
291
+ # Get error counts from counter
292
+ if hasattr(self._metrics_instance, "error_counter"):
293
+ error_counter = self._metrics_instance.error_counter
294
+ total_errors = 0
295
+ for metric in error_counter.collect():
296
+ for sample in metric.samples:
297
+ if sample.name.endswith("_total"):
298
+ total_errors += sample.value
299
+ stats_data["errors_total"] = int(total_errors)
300
+
301
+ # Calculate last minute errors
302
+ if self._last_snapshot:
303
+ last_minute_errors = total_errors - self._last_snapshot.errors_total
304
+ stats_data["errors_last_minute"] = max(0, int(last_minute_errors))
305
+ else:
306
+ stats_data["errors_last_minute"] = int(total_errors)
307
+
308
+ logger.debug(
309
+ "prometheus_stats_collected",
310
+ requests_total=stats_data["requests_total"],
311
+ requests_last_minute=stats_data["requests_last_minute"],
312
+ avg_response_time_ms=stats_data["avg_response_time_ms"],
313
+ tokens_input_total=stats_data["tokens_input_total"],
314
+ tokens_output_total=stats_data["tokens_output_total"],
315
+ cost_total_usd=stats_data["cost_total_usd"],
316
+ errors_total=stats_data["errors_total"],
317
+ active_requests=stats_data["active_requests"],
318
+ )
319
+
320
+ except Exception as e:
321
+ logger.debug("Failed to get metrics from Prometheus", error=str(e))
322
+
323
+ async def _collect_from_duckdb(
324
+ self, stats_data: dict[str, Any], current_time: float
325
+ ) -> None:
326
+ """Collect statistics from DuckDB storage."""
327
+ if not self._storage_instance:
328
+ return
329
+
330
+ try:
331
+ # Get overall analytics
332
+ overall_analytics = await self._storage_instance.get_analytics()
333
+ if overall_analytics and "summary" in overall_analytics:
334
+ summary = overall_analytics["summary"]
335
+ stats_data["requests_total"] = summary.get("total_requests", 0)
336
+ stats_data["avg_response_time_ms"] = summary.get("avg_duration_ms", 0.0)
337
+ stats_data["tokens_input_total"] = summary.get("total_tokens_input", 0)
338
+ stats_data["tokens_output_total"] = summary.get(
339
+ "total_tokens_output", 0
340
+ )
341
+ stats_data["cost_total_usd"] = summary.get("total_cost_usd", 0.0)
342
+
343
+ # Get last minute analytics
344
+ one_minute_ago = current_time - 60
345
+ last_minute_analytics = await self._storage_instance.get_analytics(
346
+ start_time=one_minute_ago,
347
+ end_time=current_time,
348
+ )
349
+
350
+ if last_minute_analytics and "summary" in last_minute_analytics:
351
+ last_minute_summary = last_minute_analytics["summary"]
352
+ stats_data["requests_last_minute"] = last_minute_summary.get(
353
+ "total_requests", 0
354
+ )
355
+ stats_data["avg_response_time_last_minute_ms"] = (
356
+ last_minute_summary.get("avg_duration_ms", 0.0)
357
+ )
358
+ stats_data["tokens_input_last_minute"] = last_minute_summary.get(
359
+ "total_tokens_input", 0
360
+ )
361
+ stats_data["tokens_output_last_minute"] = last_minute_summary.get(
362
+ "total_tokens_output", 0
363
+ )
364
+ stats_data["cost_last_minute_usd"] = last_minute_summary.get(
365
+ "total_cost_usd", 0.0
366
+ )
367
+
368
+ # Get top model from last minute data
369
+ await self._get_top_model(stats_data, one_minute_ago, current_time)
370
+
371
+ except Exception as e:
372
+ logger.debug("Failed to collect from DuckDB", error=str(e))
373
+
374
+ async def _get_top_model(
375
+ self, stats_data: dict[str, Any], start_time: float, end_time: float
376
+ ) -> None:
377
+ """Get the most used model in the time period."""
378
+ if not self._storage_instance:
379
+ return
380
+
381
+ try:
382
+ # Query for model usage
383
+ sql = """
384
+ SELECT model, COUNT(*) as request_count
385
+ FROM access_logs
386
+ WHERE timestamp >= ? AND timestamp <= ?
387
+ GROUP BY model
388
+ ORDER BY request_count DESC
389
+ LIMIT 1
390
+ """
391
+
392
+ start_dt = datetime.fromtimestamp(start_time)
393
+ end_dt = datetime.fromtimestamp(end_time)
394
+
395
+ results = await self._storage_instance.query(
396
+ sql, [start_dt, end_dt], limit=1
397
+ )
398
+
399
+ if results:
400
+ top_model_data = results[0]
401
+ stats_data["top_model"] = top_model_data.get("model", "unknown")
402
+ request_count = top_model_data.get("request_count", 0)
403
+
404
+ if stats_data["requests_last_minute"] > 0:
405
+ stats_data["top_model_percentage"] = (
406
+ request_count / stats_data["requests_last_minute"]
407
+ ) * 100
408
+ else:
409
+ stats_data["top_model_percentage"] = 0.0
410
+
411
+ except Exception as e:
412
+ logger.debug("Failed to get top model", error=str(e))
413
+
414
+ def _has_meaningful_activity(self, snapshot: StatsSnapshot) -> bool:
415
+ """
416
+ Check if there is meaningful activity to report.
417
+
418
+ Args:
419
+ snapshot: Stats snapshot to check
420
+
421
+ Returns:
422
+ True if there is meaningful activity, False otherwise
423
+ """
424
+ # Show stats if there are requests in the last minute
425
+ if snapshot.requests_last_minute > 0:
426
+ return True
427
+
428
+ # Show stats if there are currently active requests
429
+ if snapshot.active_requests > 0:
430
+ return True
431
+
432
+ # Show stats if there are any errors in the last minute
433
+ if snapshot.errors_last_minute > 0:
434
+ return True
435
+
436
+ # Show stats if there are any total requests (for the first time)
437
+ return snapshot.requests_total > 0 and self._last_snapshot is None
438
+
439
+ def format_stats(self, snapshot: StatsSnapshot) -> str:
440
+ """
441
+ Format stats snapshot for display.
442
+
443
+ Args:
444
+ snapshot: Stats snapshot to format
445
+
446
+ Returns:
447
+ Formatted stats string
448
+ """
449
+ format_type = self.settings.stats_printing_format
450
+
451
+ if format_type == "json":
452
+ return self._format_json(snapshot)
453
+ elif format_type == "rich":
454
+ return self._format_rich(snapshot)
455
+ elif format_type == "log":
456
+ return self._format_log(snapshot)
457
+ else: # console (default)
458
+ return self._format_console(snapshot)
459
+
460
+ def _format_console(self, snapshot: StatsSnapshot) -> str:
461
+ """Format stats for console output."""
462
+ timestamp_str = snapshot.timestamp.strftime("%Y-%m-%d %H:%M:%S")
463
+
464
+ # Format response times
465
+ avg_response_str = f"{snapshot.avg_response_time_ms:.1f}ms"
466
+ avg_response_last_min_str = f"{snapshot.avg_response_time_last_minute_ms:.1f}ms"
467
+
468
+ # Format costs
469
+ cost_total_str = f"${snapshot.cost_total_usd:.4f}"
470
+ cost_last_min_str = f"${snapshot.cost_last_minute_usd:.4f}"
471
+
472
+ # Format top model percentage
473
+ top_model_str = f"{snapshot.top_model} ({snapshot.top_model_percentage:.1f}%)"
474
+
475
+ return f"""[{timestamp_str}] METRICS SUMMARY
476
+ ├─ Requests: {snapshot.requests_last_minute} (last min) / {snapshot.requests_total} (total)
477
+ ├─ Avg Response: {avg_response_last_min_str} (last min) / {avg_response_str} (overall)
478
+ ├─ Tokens: {snapshot.tokens_input_last_minute:,} in / {snapshot.tokens_output_last_minute:,} out (last min)
479
+ ├─ Cost: {cost_last_min_str} (last min) / {cost_total_str} (total)
480
+ ├─ Errors: {snapshot.errors_last_minute} (last min) / {snapshot.errors_total} (total)
481
+ ├─ Active: {snapshot.active_requests} requests
482
+ └─ Top Model: {top_model_str}"""
483
+
484
+ def _format_json(self, snapshot: StatsSnapshot) -> str:
485
+ """Format stats for JSON output."""
486
+ data = {
487
+ "timestamp": snapshot.timestamp.isoformat(),
488
+ "requests": {
489
+ "last_minute": snapshot.requests_last_minute,
490
+ "total": snapshot.requests_total,
491
+ },
492
+ "response_time_ms": {
493
+ "last_minute": snapshot.avg_response_time_last_minute_ms,
494
+ "overall": snapshot.avg_response_time_ms,
495
+ },
496
+ "tokens": {
497
+ "input_last_minute": snapshot.tokens_input_last_minute,
498
+ "output_last_minute": snapshot.tokens_output_last_minute,
499
+ "input_total": snapshot.tokens_input_total,
500
+ "output_total": snapshot.tokens_output_total,
501
+ },
502
+ "cost_usd": {
503
+ "last_minute": snapshot.cost_last_minute_usd,
504
+ "total": snapshot.cost_total_usd,
505
+ },
506
+ "errors": {
507
+ "last_minute": snapshot.errors_last_minute,
508
+ "total": snapshot.errors_total,
509
+ },
510
+ "active_requests": snapshot.active_requests,
511
+ "top_model": {
512
+ "name": snapshot.top_model,
513
+ "percentage": snapshot.top_model_percentage,
514
+ },
515
+ }
516
+ return json.dumps(data, indent=2)
517
+
518
+ def _format_rich(self, snapshot: StatsSnapshot) -> str:
519
+ """Format stats for rich console output with colors and styling."""
520
+ try:
521
+ # Try to import rich for enhanced formatting
522
+ from io import StringIO
523
+
524
+ from rich import box
525
+ from rich.console import Console
526
+ from rich.table import Table
527
+ from rich.text import Text
528
+
529
+ output_buffer = StringIO()
530
+ console = Console(file=output_buffer, width=80, force_terminal=True)
531
+ timestamp_str = snapshot.timestamp.strftime("%Y-%m-%d %H:%M:%S")
532
+
533
+ # Create main stats table
534
+ table = Table(title=f"METRICS SUMMARY - {timestamp_str}", box=box.ROUNDED)
535
+ table.add_column("Metric", style="cyan", no_wrap=True)
536
+ table.add_column("Last Minute", style="yellow", justify="right")
537
+ table.add_column("Total", style="green", justify="right")
538
+
539
+ # Add rows with formatted data
540
+ table.add_row(
541
+ "Requests",
542
+ f"{snapshot.requests_last_minute:,}",
543
+ f"{snapshot.requests_total:,}",
544
+ )
545
+
546
+ table.add_row(
547
+ "Avg Response",
548
+ f"{snapshot.avg_response_time_last_minute_ms:.1f}ms",
549
+ f"{snapshot.avg_response_time_ms:.1f}ms",
550
+ )
551
+
552
+ table.add_row(
553
+ "Tokens In",
554
+ f"{snapshot.tokens_input_last_minute:,}",
555
+ f"{snapshot.tokens_input_total:,}",
556
+ )
557
+
558
+ table.add_row(
559
+ "Tokens Out",
560
+ f"{snapshot.tokens_output_last_minute:,}",
561
+ f"{snapshot.tokens_output_total:,}",
562
+ )
563
+
564
+ table.add_row(
565
+ "Cost",
566
+ f"${snapshot.cost_last_minute_usd:.4f}",
567
+ f"${snapshot.cost_total_usd:.4f}",
568
+ )
569
+
570
+ table.add_row(
571
+ "Errors",
572
+ f"{snapshot.errors_last_minute}",
573
+ f"{snapshot.errors_total}",
574
+ )
575
+
576
+ # Add single-column rows
577
+ table.add_row("", "", "") # Separator
578
+ table.add_row("Active Requests", f"{snapshot.active_requests}", "")
579
+
580
+ table.add_row(
581
+ "Top Model",
582
+ f"{snapshot.top_model}",
583
+ f"({snapshot.top_model_percentage:.1f}%)",
584
+ )
585
+
586
+ console.print(table)
587
+ output = output_buffer.getvalue()
588
+ output_buffer.close()
589
+
590
+ return output.strip()
591
+
592
+ except ImportError:
593
+ # Fallback to console format if rich is not available
594
+ logger.warning("Rich not available, falling back to console format")
595
+ return self._format_console(snapshot)
596
+ except Exception as e:
597
+ logger.warning(
598
+ f"Rich formatting failed: {e}, falling back to console format"
599
+ )
600
+ return self._format_console(snapshot)
601
+
602
+ def _format_log(self, snapshot: StatsSnapshot) -> str:
603
+ """Format stats for structured logging output."""
604
+ timestamp_str = snapshot.timestamp.strftime("%Y-%m-%d %H:%M:%S")
605
+
606
+ # Create a structured log entry
607
+ log_data = {
608
+ "timestamp": timestamp_str,
609
+ "event": "metrics_summary",
610
+ "requests": {
611
+ "last_minute": snapshot.requests_last_minute,
612
+ "total": snapshot.requests_total,
613
+ },
614
+ "response_time_ms": {
615
+ "last_minute_avg": snapshot.avg_response_time_last_minute_ms,
616
+ "overall_avg": snapshot.avg_response_time_ms,
617
+ },
618
+ "tokens": {
619
+ "input_last_minute": snapshot.tokens_input_last_minute,
620
+ "output_last_minute": snapshot.tokens_output_last_minute,
621
+ "input_total": snapshot.tokens_input_total,
622
+ "output_total": snapshot.tokens_output_total,
623
+ },
624
+ "cost_usd": {
625
+ "last_minute": snapshot.cost_last_minute_usd,
626
+ "total": snapshot.cost_total_usd,
627
+ },
628
+ "errors": {
629
+ "last_minute": snapshot.errors_last_minute,
630
+ "total": snapshot.errors_total,
631
+ },
632
+ "active_requests": snapshot.active_requests,
633
+ "top_model": {
634
+ "name": snapshot.top_model,
635
+ "percentage": snapshot.top_model_percentage,
636
+ },
637
+ }
638
+
639
+ # Format as a log line with key=value pairs
640
+ log_parts = [f"[{timestamp_str}]", "event=metrics_summary"]
641
+
642
+ log_parts.extend(
643
+ [
644
+ f"requests_last_min={snapshot.requests_last_minute}",
645
+ f"requests_total={snapshot.requests_total}",
646
+ f"avg_response_ms={snapshot.avg_response_time_ms:.1f}",
647
+ f"avg_response_last_min_ms={snapshot.avg_response_time_last_minute_ms:.1f}",
648
+ f"tokens_in_last_min={snapshot.tokens_input_last_minute}",
649
+ f"tokens_out_last_min={snapshot.tokens_output_last_minute}",
650
+ f"tokens_in_total={snapshot.tokens_input_total}",
651
+ f"tokens_out_total={snapshot.tokens_output_total}",
652
+ f"cost_last_min_usd={snapshot.cost_last_minute_usd:.4f}",
653
+ f"cost_total_usd={snapshot.cost_total_usd:.4f}",
654
+ f"errors_last_min={snapshot.errors_last_minute}",
655
+ f"errors_total={snapshot.errors_total}",
656
+ f"active_requests={snapshot.active_requests}",
657
+ f"top_model={snapshot.top_model}",
658
+ f"top_model_pct={snapshot.top_model_percentage:.1f}",
659
+ ]
660
+ )
661
+
662
+ return " ".join(log_parts)
663
+
664
+ async def print_stats(self) -> None:
665
+ """Collect and print current statistics."""
666
+ try:
667
+ snapshot = await self.collect_stats()
668
+
669
+ # Only print stats if there is meaningful activity
670
+ if self._has_meaningful_activity(snapshot):
671
+ formatted_stats = self.format_stats(snapshot)
672
+
673
+ # Print to stdout for console visibility
674
+ print(formatted_stats)
675
+
676
+ # Also log for structured logging
677
+ logger.info(
678
+ "stats_printed",
679
+ requests_last_minute=snapshot.requests_last_minute,
680
+ requests_total=snapshot.requests_total,
681
+ avg_response_time_ms=snapshot.avg_response_time_ms,
682
+ cost_total_usd=snapshot.cost_total_usd,
683
+ active_requests=snapshot.active_requests,
684
+ top_model=snapshot.top_model,
685
+ )
686
+ else:
687
+ logger.debug(
688
+ "stats_skipped_no_activity",
689
+ requests_last_minute=snapshot.requests_last_minute,
690
+ requests_total=snapshot.requests_total,
691
+ active_requests=snapshot.active_requests,
692
+ )
693
+
694
+ except Exception as e:
695
+ logger.error("Failed to print stats", error=str(e), exc_info=True)
696
+
697
+
698
+ # Global stats collector instance
699
+ _global_stats_collector: StatsCollector | None = None
700
+
701
+
702
+ def get_stats_collector(
703
+ settings: ObservabilitySettings | None = None,
704
+ metrics_instance: Any | None = None,
705
+ storage_instance: Any | None = None,
706
+ ) -> StatsCollector:
707
+ """
708
+ Get or create global stats collector instance.
709
+
710
+ Args:
711
+ settings: Observability settings
712
+ metrics_instance: Metrics instance for dependency injection
713
+ storage_instance: Storage instance for dependency injection
714
+
715
+ Returns:
716
+ StatsCollector instance
717
+ """
718
+ global _global_stats_collector
719
+
720
+ if _global_stats_collector is None:
721
+ if settings is None:
722
+ from ccproxy.config.settings import get_settings
723
+
724
+ settings = get_settings().observability
725
+
726
+ if metrics_instance is None:
727
+ try:
728
+ from .metrics import get_metrics
729
+
730
+ metrics_instance = get_metrics()
731
+ except Exception as e:
732
+ logger.warning("Failed to get metrics instance", error=str(e))
733
+
734
+ if storage_instance is None:
735
+ try:
736
+ from .storage.duckdb_simple import SimpleDuckDBStorage
737
+
738
+ storage_instance = SimpleDuckDBStorage(settings.duckdb_path)
739
+ # Note: Storage needs to be initialized before use
740
+ except Exception as e:
741
+ logger.warning("Failed to get storage instance", error=str(e))
742
+
743
+ _global_stats_collector = StatsCollector(
744
+ settings=settings,
745
+ metrics_instance=metrics_instance,
746
+ storage_instance=storage_instance,
747
+ )
748
+
749
+ return _global_stats_collector
750
+
751
+
752
+ def reset_stats_collector() -> None:
753
+ """Reset global stats collector instance (mainly for testing)."""
754
+ global _global_stats_collector
755
+ _global_stats_collector = None