ccproxy-api 0.1.3__py3-none-any.whl → 0.1.5__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 (54) hide show
  1. ccproxy/_version.py +2 -2
  2. ccproxy/adapters/openai/adapter.py +1 -1
  3. ccproxy/adapters/openai/streaming.py +1 -0
  4. ccproxy/api/app.py +134 -224
  5. ccproxy/api/dependencies.py +22 -2
  6. ccproxy/api/middleware/errors.py +27 -3
  7. ccproxy/api/middleware/logging.py +4 -0
  8. ccproxy/api/responses.py +6 -1
  9. ccproxy/api/routes/claude.py +222 -17
  10. ccproxy/api/routes/proxy.py +25 -6
  11. ccproxy/api/services/permission_service.py +2 -2
  12. ccproxy/claude_sdk/__init__.py +4 -8
  13. ccproxy/claude_sdk/client.py +661 -131
  14. ccproxy/claude_sdk/exceptions.py +16 -0
  15. ccproxy/claude_sdk/manager.py +219 -0
  16. ccproxy/claude_sdk/message_queue.py +342 -0
  17. ccproxy/claude_sdk/options.py +5 -0
  18. ccproxy/claude_sdk/session_client.py +546 -0
  19. ccproxy/claude_sdk/session_pool.py +550 -0
  20. ccproxy/claude_sdk/stream_handle.py +538 -0
  21. ccproxy/claude_sdk/stream_worker.py +392 -0
  22. ccproxy/claude_sdk/streaming.py +53 -11
  23. ccproxy/cli/commands/serve.py +96 -0
  24. ccproxy/cli/options/claude_options.py +47 -0
  25. ccproxy/config/__init__.py +0 -3
  26. ccproxy/config/claude.py +171 -23
  27. ccproxy/config/discovery.py +10 -1
  28. ccproxy/config/scheduler.py +4 -4
  29. ccproxy/config/settings.py +19 -1
  30. ccproxy/core/http_transformers.py +305 -73
  31. ccproxy/core/logging.py +108 -12
  32. ccproxy/core/transformers.py +5 -0
  33. ccproxy/models/claude_sdk.py +57 -0
  34. ccproxy/models/detection.py +126 -0
  35. ccproxy/observability/access_logger.py +72 -14
  36. ccproxy/observability/metrics.py +151 -0
  37. ccproxy/observability/storage/duckdb_simple.py +12 -0
  38. ccproxy/observability/storage/models.py +16 -0
  39. ccproxy/observability/streaming_response.py +107 -0
  40. ccproxy/scheduler/manager.py +31 -6
  41. ccproxy/scheduler/tasks.py +122 -0
  42. ccproxy/services/claude_detection_service.py +269 -0
  43. ccproxy/services/claude_sdk_service.py +334 -131
  44. ccproxy/services/proxy_service.py +91 -200
  45. ccproxy/utils/__init__.py +9 -1
  46. ccproxy/utils/disconnection_monitor.py +83 -0
  47. ccproxy/utils/id_generator.py +12 -0
  48. ccproxy/utils/startup_helpers.py +408 -0
  49. {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/METADATA +29 -2
  50. {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/RECORD +53 -41
  51. ccproxy/config/loader.py +0 -105
  52. {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/WHEEL +0 -0
  53. {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/entry_points.txt +0 -0
  54. {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/licenses/LICENSE +0 -0
@@ -3,6 +3,7 @@
3
3
  from typing import TYPE_CHECKING, Any
4
4
 
5
5
  import structlog
6
+ from typing_extensions import TypedDict
6
7
 
7
8
  from ccproxy.core.transformers import RequestTransformer, ResponseTransformer
8
9
  from ccproxy.core.types import ProxyRequest, ProxyResponse, TransformContext
@@ -20,13 +21,64 @@ claude_code_prompt = "You are Claude Code, Anthropic's official CLI for Claude."
20
21
  # claude_code_prompt = "<system-reminder>\nAs you answer the user's questions, you can use the following context:\n# important-instruction-reminders\nDo what has been asked; nothing more, nothing less.\nNEVER create files unless they're absolutely necessary for achieving your goal.\nALWAYS prefer editing an existing file to creating a new one.\nNEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.\n\n \n IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.\n</system-reminder>\n"
21
22
 
22
23
 
23
- def get_claude_code_prompt() -> dict[str, Any]:
24
- """Get the Claude Code system prompt with cache control."""
25
- return {
26
- "type": "text",
27
- "text": claude_code_prompt,
28
- "cache_control": {"type": "ephemeral"},
29
- }
24
+ def get_detected_system_field(
25
+ app_state: Any = None, injection_mode: str = "minimal"
26
+ ) -> Any:
27
+ """Get the detected system field for injection.
28
+
29
+ Args:
30
+ app_state: App state containing detection data
31
+ injection_mode: 'minimal' or 'full' mode
32
+
33
+ Returns:
34
+ The system field to inject (preserving exact Claude CLI structure), or None if no detection data available
35
+ """
36
+ if not app_state or not hasattr(app_state, "claude_detection_data"):
37
+ return None
38
+
39
+ claude_data = app_state.claude_detection_data
40
+ detected_system = claude_data.system_prompt.system_field
41
+
42
+ if injection_mode == "full":
43
+ # Return the complete detected system field exactly as Claude CLI sent it
44
+ return detected_system
45
+ else:
46
+ # Minimal mode: extract just the first system message, preserving its structure
47
+ if isinstance(detected_system, str):
48
+ return detected_system
49
+ elif isinstance(detected_system, list) and detected_system:
50
+ # Return only the first message object with its complete structure (type, text, cache_control)
51
+ return [detected_system[0]]
52
+
53
+ return None
54
+
55
+
56
+ def get_fallback_system_field() -> list[dict[str, Any]]:
57
+ """Get fallback system field when no detection data is available."""
58
+ return [
59
+ {
60
+ "type": "text",
61
+ "text": claude_code_prompt,
62
+ "cache_control": {"type": "ephemeral"},
63
+ }
64
+ ]
65
+
66
+
67
+ class RequestData(TypedDict):
68
+ """Typed structure for transformed request data."""
69
+
70
+ method: str
71
+ url: str
72
+ headers: dict[str, str]
73
+ body: bytes | None
74
+
75
+
76
+ class ResponseData(TypedDict):
77
+ """Typed structure for transformed response data."""
78
+
79
+ status_code: int
80
+ headers: dict[str, str]
81
+ body: bytes
30
82
 
31
83
 
32
84
  class HTTPRequestTransformer(RequestTransformer):
@@ -73,24 +125,39 @@ class HTTPRequestTransformer(RequestTransformer):
73
125
  elif context and isinstance(context, dict):
74
126
  access_token = context.get("access_token", "")
75
127
 
76
- transformed_headers = self.create_proxy_headers(request.headers, access_token)
128
+ # Extract app_state from context if available
129
+ app_state = None
130
+ if context and hasattr(context, "app_state"):
131
+ app_state = context.app_state
132
+ elif context and isinstance(context, dict):
133
+ app_state = context.get("app_state")
134
+
135
+ transformed_headers = self.create_proxy_headers(
136
+ request.headers, access_token, self.proxy_mode, app_state
137
+ )
77
138
 
78
139
  # Transform body
79
140
  transformed_body = request.body
80
141
  if request.body:
81
142
  if isinstance(request.body, bytes):
82
143
  transformed_body = self.transform_request_body(
83
- request.body, transformed_path
144
+ request.body, transformed_path, self.proxy_mode, app_state
84
145
  )
85
146
  elif isinstance(request.body, str):
86
147
  transformed_body = self.transform_request_body(
87
- request.body.encode("utf-8"), transformed_path
148
+ request.body.encode("utf-8"),
149
+ transformed_path,
150
+ self.proxy_mode,
151
+ app_state,
88
152
  )
89
153
  elif isinstance(request.body, dict):
90
154
  import json
91
155
 
92
156
  transformed_body = self.transform_request_body(
93
- json.dumps(request.body).encode("utf-8"), transformed_path
157
+ json.dumps(request.body).encode("utf-8"),
158
+ transformed_path,
159
+ self.proxy_mode,
160
+ app_state,
94
161
  )
95
162
 
96
163
  # Create new transformed request
@@ -105,6 +172,88 @@ class HTTPRequestTransformer(RequestTransformer):
105
172
  metadata=request.metadata,
106
173
  )
107
174
 
175
+ async def transform_proxy_request(
176
+ self,
177
+ method: str,
178
+ path: str,
179
+ headers: dict[str, str],
180
+ body: bytes | None,
181
+ query_params: dict[str, str | list[str]] | None,
182
+ access_token: str,
183
+ target_base_url: str = "https://api.anthropic.com",
184
+ app_state: Any = None,
185
+ injection_mode: str = "minimal",
186
+ ) -> RequestData:
187
+ """Transform request using direct parameters from ProxyService.
188
+
189
+ This method provides the same functionality as ProxyService._transform_request()
190
+ but is properly located in the transformer layer.
191
+
192
+ Args:
193
+ method: HTTP method
194
+ path: Request path
195
+ headers: Request headers
196
+ body: Request body
197
+ query_params: Query parameters
198
+ access_token: OAuth access token
199
+ target_base_url: Base URL for the target API
200
+ app_state: Optional app state containing detection data
201
+ injection_mode: System prompt injection mode
202
+
203
+ Returns:
204
+ Dictionary with transformed request data (method, url, headers, body)
205
+ """
206
+ import urllib.parse
207
+
208
+ # Transform path
209
+ transformed_path = self.transform_path(path, self.proxy_mode)
210
+ target_url = f"{target_base_url.rstrip('/')}{transformed_path}"
211
+
212
+ # Add beta=true query parameter for /v1/messages requests if not already present
213
+ if transformed_path == "/v1/messages":
214
+ if query_params is None:
215
+ query_params = {}
216
+ elif "beta" not in query_params:
217
+ query_params = dict(query_params) # Make a copy
218
+
219
+ if "beta" not in query_params:
220
+ query_params["beta"] = "true"
221
+
222
+ # Transform body first (as it might change size)
223
+ proxy_body = None
224
+ if body:
225
+ proxy_body = self.transform_request_body(
226
+ body, path, self.proxy_mode, app_state, injection_mode
227
+ )
228
+
229
+ # Transform headers (and update Content-Length if body changed)
230
+ proxy_headers = self.create_proxy_headers(
231
+ headers, access_token, self.proxy_mode, app_state
232
+ )
233
+
234
+ # Update Content-Length if body was transformed and size changed
235
+ if proxy_body and body and len(proxy_body) != len(body):
236
+ # Remove any existing content-length headers (case-insensitive)
237
+ proxy_headers = {
238
+ k: v for k, v in proxy_headers.items() if k.lower() != "content-length"
239
+ }
240
+ proxy_headers["Content-Length"] = str(len(proxy_body))
241
+ elif proxy_body and not body:
242
+ # New body was created where none existed
243
+ proxy_headers["Content-Length"] = str(len(proxy_body))
244
+
245
+ # Add query parameters to URL if present
246
+ if query_params:
247
+ query_string = urllib.parse.urlencode(query_params)
248
+ target_url = f"{target_url}?{query_string}"
249
+
250
+ return RequestData(
251
+ method=method,
252
+ url=target_url,
253
+ headers=proxy_headers,
254
+ body=proxy_body,
255
+ )
256
+
108
257
  def transform_path(self, path: str, proxy_mode: str = "full") -> str:
109
258
  """Transform request path."""
110
259
  # Remove /api prefix if present (for new proxy endpoints)
@@ -122,7 +271,11 @@ class HTTPRequestTransformer(RequestTransformer):
122
271
  return path
123
272
 
124
273
  def create_proxy_headers(
125
- self, headers: dict[str, str], access_token: str, proxy_mode: str = "full"
274
+ self,
275
+ headers: dict[str, str],
276
+ access_token: str,
277
+ proxy_mode: str = "full",
278
+ app_state: Any = None,
126
279
  ) -> dict[str, str]:
127
280
  """Create proxy headers from original headers with Claude CLI identity."""
128
281
  proxy_headers = {}
@@ -170,27 +323,35 @@ class HTTPRequestTransformer(RequestTransformer):
170
323
  if "connection" not in [k.lower() for k in proxy_headers]:
171
324
  proxy_headers["Connection"] = "keep-alive"
172
325
 
173
- # Critical Claude/Anthropic headers for tools and beta features
174
- proxy_headers["anthropic-beta"] = (
175
- "claude-code-20250219,oauth-2025-04-20,"
176
- "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
177
- )
178
- proxy_headers["anthropic-version"] = "2023-06-01"
179
- proxy_headers["anthropic-dangerous-direct-browser-access"] = "true"
180
-
181
- # Claude CLI identity headers
182
- proxy_headers["x-app"] = "cli"
183
- proxy_headers["User-Agent"] = "claude-cli/1.0.60 (external, cli)"
184
-
185
- # Stainless SDK compatibility headers
186
- proxy_headers["X-Stainless-Lang"] = "js"
187
- proxy_headers["X-Stainless-Retry-Count"] = "0"
188
- proxy_headers["X-Stainless-Timeout"] = "60"
189
- proxy_headers["X-Stainless-Package-Version"] = "0.55.1"
190
- proxy_headers["X-Stainless-OS"] = "Linux"
191
- proxy_headers["X-Stainless-Arch"] = "x64"
192
- proxy_headers["X-Stainless-Runtime"] = "node"
193
- proxy_headers["X-Stainless-Runtime-Version"] = "v24.3.0"
326
+ # Use detected Claude CLI headers when available
327
+ if app_state and hasattr(app_state, "claude_detection_data"):
328
+ claude_data = app_state.claude_detection_data
329
+ detected_headers = claude_data.headers.to_headers_dict()
330
+ proxy_headers.update(detected_headers)
331
+ logger.debug("using_detected_headers", version=claude_data.claude_version)
332
+ else:
333
+ # Fallback to hardcoded Claude/Anthropic headers
334
+ proxy_headers["anthropic-beta"] = (
335
+ "claude-code-20250219,oauth-2025-04-20,"
336
+ "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
337
+ )
338
+ proxy_headers["anthropic-version"] = "2023-06-01"
339
+ proxy_headers["anthropic-dangerous-direct-browser-access"] = "true"
340
+
341
+ # Claude CLI identity headers
342
+ proxy_headers["x-app"] = "cli"
343
+ proxy_headers["User-Agent"] = "claude-cli/1.0.60 (external, cli)"
344
+
345
+ # Stainless SDK compatibility headers
346
+ proxy_headers["X-Stainless-Lang"] = "js"
347
+ proxy_headers["X-Stainless-Retry-Count"] = "0"
348
+ proxy_headers["X-Stainless-Timeout"] = "60"
349
+ proxy_headers["X-Stainless-Package-Version"] = "0.55.1"
350
+ proxy_headers["X-Stainless-OS"] = "Linux"
351
+ proxy_headers["X-Stainless-Arch"] = "x64"
352
+ proxy_headers["X-Stainless-Runtime"] = "node"
353
+ proxy_headers["X-Stainless-Runtime-Version"] = "v24.3.0"
354
+ logger.debug("using_fallback_headers")
194
355
 
195
356
  # Standard HTTP headers for proper API interaction
196
357
  proxy_headers["accept-language"] = "*"
@@ -201,7 +362,12 @@ class HTTPRequestTransformer(RequestTransformer):
201
362
  return proxy_headers
202
363
 
203
364
  def transform_request_body(
204
- self, body: bytes, path: str, proxy_mode: str = "full"
365
+ self,
366
+ body: bytes,
367
+ path: str,
368
+ proxy_mode: str = "full",
369
+ app_state: Any = None,
370
+ injection_mode: str = "minimal",
205
371
  ) -> bytes:
206
372
  """Transform request body."""
207
373
  if not body:
@@ -213,16 +379,20 @@ class HTTPRequestTransformer(RequestTransformer):
213
379
  body = self._transform_openai_to_anthropic(body)
214
380
 
215
381
  # Apply system prompt transformation for Claude Code identity
216
- return self.transform_system_prompt(body)
382
+ return self.transform_system_prompt(body, app_state, injection_mode)
217
383
 
218
- def transform_system_prompt(self, body: bytes) -> bytes:
219
- """Transform system prompt to ensure Claude Code identification comes first.
384
+ def transform_system_prompt(
385
+ self, body: bytes, app_state: Any = None, injection_mode: str = "minimal"
386
+ ) -> bytes:
387
+ """Transform system prompt based on injection mode.
220
388
 
221
389
  Args:
222
390
  body: Original request body as bytes
391
+ app_state: Optional app state containing detection data
392
+ injection_mode: System prompt injection mode ('minimal' or 'full')
223
393
 
224
394
  Returns:
225
- Transformed request body as bytes with Claude Code system prompt
395
+ Transformed request body as bytes with system prompt injection
226
396
  """
227
397
  try:
228
398
  import json
@@ -232,41 +402,43 @@ class HTTPRequestTransformer(RequestTransformer):
232
402
  # Return original if not valid JSON
233
403
  return body
234
404
 
235
- # Check if request has a system prompt
236
- if "system" not in data or (
237
- isinstance(data["system"], str) and data["system"] == claude_code_prompt
238
- ):
239
- # No system prompt, inject Claude Code identification
240
- data["system"] = [get_claude_code_prompt()]
241
- return json.dumps(data).encode("utf-8")
242
-
243
- system = data["system"]
244
-
245
- if isinstance(system, str):
246
- # Handle string system prompt
247
- if system == claude_code_prompt:
248
- # Already correct, convert to proper array format
249
- data["system"] = [get_claude_code_prompt()]
250
- return json.dumps(data).encode("utf-8")
251
-
252
- # Prepend Claude Code prompt to existing string
253
- data["system"] = [
254
- get_claude_code_prompt(),
255
- {"type": "text", "text": system},
256
- ]
257
-
258
- elif isinstance(system, list):
259
- # Handle array system prompt
260
- if len(system) > 0:
261
- # Check if first element has correct text
262
- first = system[0]
263
- if isinstance(first, dict) and first.get("text") == claude_code_prompt:
264
- # Already has Claude Code first, ensure it has cache_control
265
- data["system"][0] = get_claude_code_prompt()
266
- return json.dumps(data).encode("utf-8")
267
-
268
- # Prepend Claude Code prompt
269
- data["system"] = [get_claude_code_prompt()] + system
405
+ # Get the system field to inject
406
+ detected_system = get_detected_system_field(app_state, injection_mode)
407
+ if detected_system is None:
408
+ # No detection data, use fallback
409
+ detected_system = get_fallback_system_field()
410
+
411
+ # Always inject the system prompt (detected or fallback)
412
+ if "system" not in data:
413
+ # No existing system prompt, inject the detected/fallback one
414
+ data["system"] = detected_system
415
+ else:
416
+ # Request has existing system prompt, prepend the detected/fallback one
417
+ existing_system = data["system"]
418
+
419
+ if isinstance(detected_system, str):
420
+ # Detected system is a string
421
+ if isinstance(existing_system, str):
422
+ # Both are strings, convert to list format
423
+ data["system"] = [
424
+ {"type": "text", "text": detected_system},
425
+ {"type": "text", "text": existing_system},
426
+ ]
427
+ elif isinstance(existing_system, list):
428
+ # Detected is string, existing is list
429
+ data["system"] = [
430
+ {"type": "text", "text": detected_system}
431
+ ] + existing_system
432
+ elif isinstance(detected_system, list):
433
+ # Detected system is a list
434
+ if isinstance(existing_system, str):
435
+ # Detected is list, existing is string
436
+ data["system"] = detected_system + [
437
+ {"type": "text", "text": existing_system}
438
+ ]
439
+ elif isinstance(existing_system, list):
440
+ # Both are lists, concatenate
441
+ data["system"] = detected_system + existing_system
270
442
 
271
443
  return json.dumps(data).encode("utf-8")
272
444
 
@@ -387,6 +559,65 @@ class HTTPResponseTransformer(ResponseTransformer):
387
559
  metadata=response.metadata,
388
560
  )
389
561
 
562
+ async def transform_proxy_response(
563
+ self,
564
+ status_code: int,
565
+ headers: dict[str, str],
566
+ body: bytes,
567
+ original_path: str,
568
+ proxy_mode: str = "full",
569
+ ) -> ResponseData:
570
+ """Transform response using direct parameters from ProxyService.
571
+
572
+ This method provides the same functionality as ProxyService._transform_response()
573
+ but is properly located in the transformer layer.
574
+
575
+ Args:
576
+ status_code: HTTP status code
577
+ headers: Response headers
578
+ body: Response body
579
+ original_path: Original request path for context
580
+ proxy_mode: Proxy transformation mode
581
+
582
+ Returns:
583
+ Dictionary with transformed response data (status_code, headers, body)
584
+ """
585
+ # For error responses, handle OpenAI transformation if needed
586
+ if status_code >= 400:
587
+ transformed_error_body = body
588
+ if self._is_openai_request(original_path):
589
+ try:
590
+ import json
591
+
592
+ from ccproxy.adapters.openai.adapter import OpenAIAdapter
593
+
594
+ error_data = json.loads(body.decode("utf-8"))
595
+ openai_adapter = OpenAIAdapter()
596
+ openai_error = openai_adapter.adapt_error(error_data)
597
+ transformed_error_body = json.dumps(openai_error).encode("utf-8")
598
+ except (json.JSONDecodeError, UnicodeDecodeError):
599
+ # Keep original error if parsing fails
600
+ pass
601
+
602
+ return ResponseData(
603
+ status_code=status_code,
604
+ headers=headers,
605
+ body=transformed_error_body,
606
+ )
607
+
608
+ # For successful responses, transform normally
609
+ transformed_body = self.transform_response_body(body, original_path, proxy_mode)
610
+
611
+ transformed_headers = self.transform_response_headers(
612
+ headers, original_path, len(transformed_body), proxy_mode
613
+ )
614
+
615
+ return ResponseData(
616
+ status_code=status_code,
617
+ headers=transformed_headers,
618
+ body=transformed_body,
619
+ )
620
+
390
621
  def transform_response_body(
391
622
  self, body: bytes, path: str, proxy_mode: str = "full"
392
623
  ) -> bytes:
@@ -411,6 +642,7 @@ class HTTPResponseTransformer(ResponseTransformer):
411
642
  "content-length",
412
643
  "transfer-encoding",
413
644
  "content-encoding",
645
+ "date", # Remove upstream date header to avoid conflicts
414
646
  ]:
415
647
  transformed_headers[key] = value
416
648
 
ccproxy/core/logging.py CHANGED
@@ -1,10 +1,22 @@
1
1
  import logging
2
+ import shutil
2
3
  import sys
4
+ from collections.abc import MutableMapping
3
5
  from pathlib import Path
6
+ from typing import Any, TextIO
4
7
 
5
8
  import structlog
9
+ from rich.console import Console
10
+ from rich.traceback import Traceback
6
11
  from structlog.stdlib import BoundLogger
7
- from structlog.typing import Processor
12
+ from structlog.typing import ExcInfo, Processor
13
+
14
+
15
+ suppress_debug = [
16
+ "ccproxy.scheduler",
17
+ "ccproxy.observability.context",
18
+ "ccproxy.utils.simple_request_logger",
19
+ ]
8
20
 
9
21
 
10
22
  def configure_structlog(log_level: int = logging.INFO) -> None:
@@ -30,12 +42,28 @@ def configure_structlog(log_level: int = logging.INFO) -> None:
30
42
  )
31
43
 
32
44
  # Common processors for all log levels
45
+ # First add timestamp with microseconds
46
+ processors.append(
47
+ structlog.processors.TimeStamper(
48
+ fmt="%H:%M:%S.%f" if log_level < logging.INFO else "%Y-%m-%d %H:%M:%S.%f",
49
+ key="timestamp_raw",
50
+ )
51
+ )
52
+
53
+ # Then add processor to convert microseconds to milliseconds
54
+ def format_timestamp_ms(
55
+ logger: Any, log_method: str, event_dict: MutableMapping[str, Any]
56
+ ) -> MutableMapping[str, Any]:
57
+ """Format timestamp with milliseconds instead of microseconds."""
58
+ if "timestamp_raw" in event_dict:
59
+ # Truncate microseconds to milliseconds (6 digits to 3)
60
+ timestamp_raw = event_dict.pop("timestamp_raw")
61
+ event_dict["timestamp"] = timestamp_raw[:-3]
62
+ return event_dict
63
+
33
64
  processors.extend(
34
65
  [
35
- # Use human-readable timestamp for structlog logs in debug mode, normal otherwise
36
- structlog.processors.TimeStamper(
37
- fmt="%H:%M:%S" if log_level < logging.INFO else "%Y-%m-%d %H:%M:%S"
38
- ),
66
+ format_timestamp_ms,
39
67
  structlog.processors.StackInfoRenderer(),
40
68
  structlog.dev.set_exc_info, # Handle exceptions properly
41
69
  # This MUST be the last processor - allows different renderers per handler
@@ -48,12 +76,42 @@ def configure_structlog(log_level: int = logging.INFO) -> None:
48
76
  context_class=dict,
49
77
  logger_factory=structlog.stdlib.LoggerFactory(),
50
78
  wrapper_class=structlog.stdlib.BoundLogger,
51
- cache_logger_on_first_use=True, # Cache for performance
79
+ cache_logger_on_first_use=True,
80
+ )
81
+
82
+
83
+ def rich_traceback(sio: TextIO, exc_info: ExcInfo) -> None:
84
+ """Pretty-print *exc_info* to *sio* using the *Rich* package.
85
+
86
+ Based on:
87
+ https://github.com/hynek/structlog/blob/74cdff93af217519d4ebea05184f5e0db2972556/src/structlog/dev.py#L179-L192
88
+
89
+ """
90
+ term_width, _height = shutil.get_terminal_size((80, 123))
91
+ sio.write("\n")
92
+ # Rich docs: https://rich.readthedocs.io/en/stable/reference/traceback.html
93
+ Console(file=sio, color_system="truecolor").print(
94
+ Traceback.from_exception(
95
+ *exc_info,
96
+ # show_locals=True, # Takes up too much vertical space
97
+ extra_lines=1, # Reduce amount of source code displayed
98
+ width=term_width, # Maximize width
99
+ max_frames=5, # Default is 10
100
+ suppress=[
101
+ "click",
102
+ "typer",
103
+ "uvicorn",
104
+ "fastapi",
105
+ "starlette",
106
+ ], # Suppress noise from these libraries
107
+ ),
52
108
  )
53
109
 
54
110
 
55
111
  def setup_logging(
56
- json_logs: bool = False, log_level_name: str = "DEBUG", log_file: str | None = None
112
+ json_logs: bool = False,
113
+ log_level_name: str = "DEBUG",
114
+ log_file: str | None = None,
57
115
  ) -> BoundLogger:
58
116
  """
59
117
  Setup logging for the entire application using canonical structlog pattern.
@@ -61,6 +119,21 @@ def setup_logging(
61
119
  """
62
120
  log_level = getattr(logging, log_level_name.upper(), logging.INFO)
63
121
 
122
+ # Install rich traceback handler globally with frame limit
123
+ # install_rich_traceback(
124
+ # show_locals=log_level <= logging.DEBUG, # Only show locals in debug mode
125
+ # max_frames=max_traceback_frames,
126
+ # width=120,
127
+ # word_wrap=True,
128
+ # suppress=[
129
+ # "click",
130
+ # "typer",
131
+ # "uvicorn",
132
+ # "fastapi",
133
+ # "starlette",
134
+ # ], # Suppress noise from these libraries
135
+ # )
136
+
64
137
  # Get root logger and set level BEFORE configuring structlog
65
138
  root_logger = logging.getLogger()
66
139
  root_logger.setLevel(log_level)
@@ -91,12 +164,26 @@ def setup_logging(
91
164
  )
92
165
 
93
166
  # Add appropriate timestamper for console vs file
167
+ # Using custom lambda to truncate microseconds to milliseconds
94
168
  console_timestamper = (
95
- structlog.processors.TimeStamper(fmt="%H:%M:%S")
169
+ structlog.processors.TimeStamper(fmt="%H:%M:%S.%f", key="timestamp_raw")
96
170
  if log_level < logging.INFO
97
- else structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S")
171
+ else structlog.processors.TimeStamper(
172
+ fmt="%Y-%m-%d %H:%M:%S.%f", key="timestamp_raw"
173
+ )
98
174
  )
99
175
 
176
+ # Processor to convert microseconds to milliseconds
177
+ def format_timestamp_ms(
178
+ logger: Any, log_method: str, event_dict: MutableMapping[str, Any]
179
+ ) -> MutableMapping[str, Any]:
180
+ """Format timestamp with milliseconds instead of microseconds."""
181
+ if "timestamp_raw" in event_dict:
182
+ # Truncate microseconds to milliseconds (6 digits to 3)
183
+ timestamp_raw = event_dict.pop("timestamp_raw")
184
+ event_dict["timestamp"] = timestamp_raw[:-3]
185
+ return event_dict
186
+
100
187
  file_timestamper = structlog.processors.TimeStamper(fmt="iso")
101
188
 
102
189
  # 4. Setup console handler with ConsoleRenderer
@@ -105,14 +192,16 @@ def setup_logging(
105
192
  console_renderer = (
106
193
  structlog.processors.JSONRenderer()
107
194
  if json_logs
108
- else structlog.dev.ConsoleRenderer()
195
+ else structlog.dev.ConsoleRenderer(
196
+ exception_formatter=rich_traceback # structlog.dev.rich_traceback, # Use rich for better formatting
197
+ )
109
198
  )
110
199
 
111
200
  # Console gets human-readable timestamps for both structlog and stdlib logs
112
- console_processors = shared_processors + [console_timestamper]
201
+ console_processors = shared_processors + [console_timestamper, format_timestamp_ms]
113
202
  console_handler.setFormatter(
114
203
  structlog.stdlib.ProcessorFormatter(
115
- foreign_pre_chain=console_processors,
204
+ foreign_pre_chain=console_processors, # type: ignore[arg-type]
116
205
  processor=console_renderer,
117
206
  )
118
207
  )
@@ -182,6 +271,13 @@ def setup_logging(
182
271
  noisy_logger.propagate = True
183
272
  noisy_logger.setLevel(noisy_log_level)
184
273
 
274
+ [
275
+ logging.getLogger(logger_name).setLevel(
276
+ logging.INFO if log_level <= logging.DEBUG else log_level
277
+ ) # type: ignore[func-returns-value]
278
+ for logger_name in suppress_debug
279
+ ]
280
+
185
281
  return structlog.get_logger() # type: ignore[no-any-return]
186
282
 
187
283
 
@@ -114,6 +114,11 @@ class BaseTransformer(ABC):
114
114
  class RequestTransformer(BaseTransformer):
115
115
  """Base class for request transformers."""
116
116
 
117
+ def __init__(self, proxy_mode: str = "full") -> None:
118
+ """Initialize request transformer with proxy mode."""
119
+ super().__init__()
120
+ self.proxy_mode = proxy_mode
121
+
117
122
  async def transform(
118
123
  self, request: ProxyRequest, context: TransformContext | None = None
119
124
  ) -> ProxyRequest: