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,447 @@
1
+ """
2
+ Request context management with timing and correlation.
3
+
4
+ This module provides context managers and utilities for tracking request lifecycle,
5
+ timing measurements, and correlation across async operations. Uses structlog for
6
+ rich business event logging.
7
+
8
+ Key features:
9
+ - Accurate timing measurement using time.perf_counter()
10
+ - Request correlation with unique IDs
11
+ - Structured logging integration
12
+ - Async-safe context management
13
+ - Exception handling and error tracking
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import time
20
+ import uuid
21
+ from collections.abc import AsyncGenerator
22
+ from contextlib import asynccontextmanager
23
+ from dataclasses import dataclass, field
24
+ from typing import Any, Optional
25
+
26
+ import structlog
27
+
28
+
29
+ logger = structlog.get_logger(__name__)
30
+
31
+
32
+ @dataclass
33
+ class RequestContext:
34
+ """
35
+ Context object for tracking request state and metadata.
36
+
37
+ Provides access to request ID, timing information, and structured logger
38
+ with automatically injected context.
39
+ """
40
+
41
+ request_id: str
42
+ start_time: float
43
+ logger: structlog.BoundLogger
44
+ metadata: dict[str, Any] = field(default_factory=dict)
45
+ storage: Any | None = None # Optional DuckDB storage instance
46
+
47
+ @property
48
+ def duration_ms(self) -> float:
49
+ """Get current duration in milliseconds."""
50
+ return (time.perf_counter() - self.start_time) * 1000
51
+
52
+ @property
53
+ def duration_seconds(self) -> float:
54
+ """Get current duration in seconds."""
55
+ return time.perf_counter() - self.start_time
56
+
57
+ def add_metadata(self, **kwargs: Any) -> None:
58
+ """Add metadata to the request context."""
59
+ self.metadata.update(kwargs)
60
+ # Update logger context
61
+ self.logger = self.logger.bind(**kwargs)
62
+
63
+ def log_event(self, event: str, **kwargs: Any) -> None:
64
+ """Log an event with current context and timing."""
65
+ self.logger.info(
66
+ event, request_id=self.request_id, duration_ms=self.duration_ms, **kwargs
67
+ )
68
+
69
+
70
+ @asynccontextmanager
71
+ async def request_context(
72
+ request_id: str | None = None,
73
+ storage: Any | None = None,
74
+ metrics: Any | None = None,
75
+ **initial_context: Any,
76
+ ) -> AsyncGenerator[RequestContext, None]:
77
+ """
78
+ Context manager for tracking complete request lifecycle with timing.
79
+
80
+ Automatically logs request start/success/error events with accurate timing.
81
+ Provides structured logging with request correlation.
82
+
83
+ Args:
84
+ request_id: Unique request identifier (generated if not provided)
85
+ storage: Optional storage backend for access logs
86
+ metrics: Optional PrometheusMetrics instance for active request tracking
87
+ **initial_context: Initial context to include in all log events
88
+
89
+ Yields:
90
+ RequestContext: Context object with timing and logging capabilities
91
+
92
+ Example:
93
+ async with request_context(method="POST", path="/v1/messages") as ctx:
94
+ ctx.add_metadata(model="claude-3-5-sonnet")
95
+ # Process request
96
+ ctx.log_event("request_processed", tokens=150)
97
+ # Context automatically logs success with timing
98
+ """
99
+ if request_id is None:
100
+ request_id = str(uuid.uuid4())
101
+
102
+ # Create logger with bound context
103
+ request_logger = logger.bind(request_id=request_id, **initial_context)
104
+
105
+ # Record start time
106
+ start_time = time.perf_counter()
107
+
108
+ # Log request start
109
+ request_logger.debug(
110
+ "request_start", request_id=request_id, timestamp=time.time(), **initial_context
111
+ )
112
+
113
+ # Emit SSE event for real-time dashboard updates
114
+ await _emit_request_start_event(request_id, initial_context)
115
+
116
+ # Increment active requests if metrics provided
117
+ if metrics:
118
+ metrics.inc_active_requests()
119
+
120
+ # Create context object
121
+ ctx = RequestContext(
122
+ request_id=request_id,
123
+ start_time=start_time,
124
+ logger=request_logger,
125
+ metadata=dict(initial_context),
126
+ storage=storage,
127
+ )
128
+
129
+ try:
130
+ yield ctx
131
+
132
+ # Log successful completion with comprehensive access log
133
+ duration_ms = ctx.duration_ms
134
+
135
+ # Use the new unified access logger for comprehensive logging
136
+ from ccproxy.observability.access_logger import log_request_access
137
+
138
+ await log_request_access(
139
+ context=ctx,
140
+ # Extract client info from metadata if available
141
+ client_ip=ctx.metadata.get("client_ip"),
142
+ user_agent=ctx.metadata.get("user_agent"),
143
+ query=ctx.metadata.get("query"),
144
+ storage=ctx.storage, # Pass storage from context
145
+ )
146
+
147
+ # Also keep the original request_success event for debugging
148
+ request_logger.debug(
149
+ "request_success",
150
+ request_id=request_id,
151
+ duration_ms=duration_ms,
152
+ duration_seconds=ctx.duration_seconds,
153
+ **ctx.metadata,
154
+ )
155
+
156
+ except Exception as e:
157
+ # Log error with timing
158
+ duration_ms = ctx.duration_ms
159
+ error_type = type(e).__name__
160
+
161
+ request_logger.error(
162
+ "request_error",
163
+ request_id=request_id,
164
+ duration_ms=duration_ms,
165
+ duration_seconds=ctx.duration_seconds,
166
+ error_type=error_type,
167
+ error_message=str(e),
168
+ **ctx.metadata,
169
+ )
170
+
171
+ # Emit SSE event for real-time dashboard updates
172
+ await _emit_request_error_event(request_id, error_type, str(e), ctx.metadata)
173
+
174
+ # Re-raise the exception
175
+ raise
176
+ finally:
177
+ # Decrement active requests if metrics provided
178
+ if metrics:
179
+ metrics.dec_active_requests()
180
+
181
+
182
+ @asynccontextmanager
183
+ async def timed_operation(
184
+ operation_name: str, request_id: str | None = None, **context: Any
185
+ ) -> AsyncGenerator[dict[str, Any], None]:
186
+ """
187
+ Context manager for timing individual operations within a request.
188
+
189
+ Useful for measuring specific parts of request processing like
190
+ API calls, database queries, or data processing steps.
191
+
192
+ Args:
193
+ operation_name: Name of the operation being timed
194
+ request_id: Associated request ID for correlation
195
+ **context: Additional context for logging
196
+
197
+ Yields:
198
+ Dict with timing information and logger
199
+
200
+ Example:
201
+ async with timed_operation("claude_api_call", request_id=ctx.request_id) as op:
202
+ response = await api_client.call()
203
+ op["response_size"] = len(response)
204
+ # Automatically logs operation timing
205
+ """
206
+ start_time = time.perf_counter()
207
+ operation_id = str(uuid.uuid4())
208
+
209
+ # Create operation logger
210
+ op_logger = logger.bind(
211
+ operation_name=operation_name,
212
+ operation_id=operation_id,
213
+ request_id=request_id,
214
+ **context,
215
+ )
216
+
217
+ # Log operation start (only for important operations)
218
+ if operation_name in ("claude_api_call", "request_processing", "auth_check"):
219
+ op_logger.debug(
220
+ "operation_start",
221
+ operation_name=operation_name,
222
+ **context,
223
+ )
224
+
225
+ # Operation context
226
+ op_context = {
227
+ "operation_id": operation_id,
228
+ "logger": op_logger,
229
+ "start_time": start_time,
230
+ }
231
+
232
+ try:
233
+ yield op_context
234
+
235
+ # Log successful completion (only for important operations)
236
+ duration_ms = (time.perf_counter() - start_time) * 1000
237
+ if operation_name in ("claude_api_call", "request_processing", "auth_check"):
238
+ op_logger.info(
239
+ "operation_success",
240
+ operation_name=operation_name,
241
+ duration_ms=duration_ms,
242
+ **{
243
+ k: v
244
+ for k, v in op_context.items()
245
+ if k not in ("logger", "start_time")
246
+ },
247
+ )
248
+
249
+ except Exception as e:
250
+ # Log operation error
251
+ duration_ms = (time.perf_counter() - start_time) * 1000
252
+ error_type = type(e).__name__
253
+
254
+ op_logger.error(
255
+ "operation_error",
256
+ operation_name=operation_name,
257
+ duration_ms=duration_ms,
258
+ error_type=error_type,
259
+ error_message=str(e),
260
+ **{
261
+ k: v for k, v in op_context.items() if k not in ("logger", "start_time")
262
+ },
263
+ )
264
+
265
+ # Re-raise the exception
266
+ raise
267
+
268
+
269
+ class ContextTracker:
270
+ """
271
+ Thread-safe tracker for managing active request contexts.
272
+
273
+ Useful for tracking concurrent requests and their states,
274
+ especially for metrics like active request counts.
275
+ """
276
+
277
+ def __init__(self) -> None:
278
+ self._active_contexts: dict[str, RequestContext] = {}
279
+ self._lock = asyncio.Lock()
280
+
281
+ async def add_context(self, context: RequestContext) -> None:
282
+ """Add an active request context."""
283
+ async with self._lock:
284
+ self._active_contexts[context.request_id] = context
285
+
286
+ async def remove_context(self, request_id: str) -> RequestContext | None:
287
+ """Remove and return a request context."""
288
+ async with self._lock:
289
+ return self._active_contexts.pop(request_id, None)
290
+
291
+ async def get_context(self, request_id: str) -> RequestContext | None:
292
+ """Get a request context by ID."""
293
+ async with self._lock:
294
+ return self._active_contexts.get(request_id)
295
+
296
+ async def get_active_count(self) -> int:
297
+ """Get the number of active requests."""
298
+ async with self._lock:
299
+ return len(self._active_contexts)
300
+
301
+ async def get_all_contexts(self) -> dict[str, RequestContext]:
302
+ """Get a copy of all active contexts."""
303
+ async with self._lock:
304
+ return self._active_contexts.copy()
305
+
306
+ async def cleanup_stale_contexts(self, max_age_seconds: float = 300) -> int:
307
+ """
308
+ Remove contexts older than max_age_seconds.
309
+
310
+ Args:
311
+ max_age_seconds: Maximum age in seconds before considering stale
312
+
313
+ Returns:
314
+ Number of contexts removed
315
+ """
316
+ current_time = time.perf_counter()
317
+ removed_count = 0
318
+
319
+ async with self._lock:
320
+ stale_ids = [
321
+ request_id
322
+ for request_id, ctx in self._active_contexts.items()
323
+ if (current_time - ctx.start_time) > max_age_seconds
324
+ ]
325
+
326
+ for request_id in stale_ids:
327
+ del self._active_contexts[request_id]
328
+ removed_count += 1
329
+
330
+ if removed_count > 0:
331
+ logger.warning(
332
+ "cleanup_stale_contexts",
333
+ removed_count=removed_count,
334
+ max_age_seconds=max_age_seconds,
335
+ )
336
+
337
+ return removed_count
338
+
339
+
340
+ # Global context tracker instance
341
+ _global_tracker: ContextTracker | None = None
342
+
343
+
344
+ def get_context_tracker() -> ContextTracker:
345
+ """Get or create global context tracker."""
346
+ global _global_tracker
347
+
348
+ if _global_tracker is None:
349
+ _global_tracker = ContextTracker()
350
+
351
+ return _global_tracker
352
+
353
+
354
+ @asynccontextmanager
355
+ async def tracked_request_context(
356
+ request_id: str | None = None, storage: Any | None = None, **initial_context: Any
357
+ ) -> AsyncGenerator[RequestContext, None]:
358
+ """
359
+ Request context manager that also tracks active requests globally.
360
+
361
+ Combines request_context() with automatic tracking in the global
362
+ context tracker for monitoring active request counts.
363
+
364
+ Args:
365
+ request_id: Unique request identifier
366
+ **initial_context: Initial context to include in log events
367
+
368
+ Yields:
369
+ RequestContext: Context object with timing and logging
370
+ """
371
+ tracker = get_context_tracker()
372
+
373
+ async with request_context(request_id, storage=storage, **initial_context) as ctx:
374
+ # Add to tracker
375
+ await tracker.add_context(ctx)
376
+
377
+ try:
378
+ yield ctx
379
+ finally:
380
+ # Remove from tracker
381
+ await tracker.remove_context(ctx.request_id)
382
+
383
+
384
+ async def _emit_request_start_event(
385
+ request_id: str, initial_context: dict[str, Any]
386
+ ) -> None:
387
+ """Emit SSE event for request start."""
388
+ try:
389
+ from ccproxy.observability.sse_events import emit_sse_event
390
+
391
+ # Create event data for SSE
392
+ sse_data = {
393
+ "request_id": request_id,
394
+ "method": initial_context.get("method"),
395
+ "path": initial_context.get("path"),
396
+ "client_ip": initial_context.get("client_ip"),
397
+ "user_agent": initial_context.get("user_agent"),
398
+ "query": initial_context.get("query"),
399
+ }
400
+
401
+ # Remove None values
402
+ sse_data = {k: v for k, v in sse_data.items() if v is not None}
403
+
404
+ await emit_sse_event("request_start", sse_data)
405
+
406
+ except Exception as e:
407
+ # Log error but don't fail the request
408
+ logger.debug(
409
+ "sse_emit_failed",
410
+ event_type="request_start",
411
+ error=str(e),
412
+ request_id=request_id,
413
+ )
414
+
415
+
416
+ async def _emit_request_error_event(
417
+ request_id: str, error_type: str, error_message: str, metadata: dict[str, Any]
418
+ ) -> None:
419
+ """Emit SSE event for request error."""
420
+ try:
421
+ from ccproxy.observability.sse_events import emit_sse_event
422
+
423
+ # Create event data for SSE
424
+ sse_data = {
425
+ "request_id": request_id,
426
+ "error_type": error_type,
427
+ "error_message": error_message,
428
+ "method": metadata.get("method"),
429
+ "path": metadata.get("path"),
430
+ "client_ip": metadata.get("client_ip"),
431
+ "user_agent": metadata.get("user_agent"),
432
+ "query": metadata.get("query"),
433
+ }
434
+
435
+ # Remove None values
436
+ sse_data = {k: v for k, v in sse_data.items() if v is not None}
437
+
438
+ await emit_sse_event("request_error", sse_data)
439
+
440
+ except Exception as e:
441
+ # Log error but don't fail the request
442
+ logger.debug(
443
+ "sse_emit_failed",
444
+ event_type="request_error",
445
+ error=str(e),
446
+ request_id=request_id,
447
+ )