ccproxy-api 0.1.1__py3-none-any.whl → 0.1.3__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 (107) hide show
  1. ccproxy/_version.py +2 -2
  2. ccproxy/adapters/openai/__init__.py +1 -2
  3. ccproxy/adapters/openai/adapter.py +218 -180
  4. ccproxy/adapters/openai/streaming.py +247 -65
  5. ccproxy/api/__init__.py +0 -3
  6. ccproxy/api/app.py +173 -40
  7. ccproxy/api/dependencies.py +65 -3
  8. ccproxy/api/middleware/errors.py +3 -7
  9. ccproxy/api/middleware/headers.py +0 -2
  10. ccproxy/api/middleware/logging.py +4 -3
  11. ccproxy/api/middleware/request_content_logging.py +297 -0
  12. ccproxy/api/middleware/request_id.py +5 -0
  13. ccproxy/api/middleware/server_header.py +0 -4
  14. ccproxy/api/routes/__init__.py +9 -1
  15. ccproxy/api/routes/claude.py +23 -32
  16. ccproxy/api/routes/health.py +58 -4
  17. ccproxy/api/routes/mcp.py +171 -0
  18. ccproxy/api/routes/metrics.py +4 -8
  19. ccproxy/api/routes/permissions.py +217 -0
  20. ccproxy/api/routes/proxy.py +0 -53
  21. ccproxy/api/services/__init__.py +6 -0
  22. ccproxy/api/services/permission_service.py +368 -0
  23. ccproxy/api/ui/__init__.py +6 -0
  24. ccproxy/api/ui/permission_handler_protocol.py +33 -0
  25. ccproxy/api/ui/terminal_permission_handler.py +593 -0
  26. ccproxy/auth/conditional.py +2 -2
  27. ccproxy/auth/dependencies.py +1 -1
  28. ccproxy/auth/oauth/models.py +0 -1
  29. ccproxy/auth/oauth/routes.py +1 -3
  30. ccproxy/auth/storage/json_file.py +0 -1
  31. ccproxy/auth/storage/keyring.py +0 -3
  32. ccproxy/claude_sdk/__init__.py +2 -0
  33. ccproxy/claude_sdk/client.py +91 -8
  34. ccproxy/claude_sdk/converter.py +405 -210
  35. ccproxy/claude_sdk/options.py +88 -19
  36. ccproxy/claude_sdk/parser.py +200 -0
  37. ccproxy/claude_sdk/streaming.py +286 -0
  38. ccproxy/cli/commands/__init__.py +5 -1
  39. ccproxy/cli/commands/auth.py +2 -4
  40. ccproxy/cli/commands/permission_handler.py +553 -0
  41. ccproxy/cli/commands/serve.py +52 -12
  42. ccproxy/cli/docker/params.py +0 -4
  43. ccproxy/cli/helpers.py +0 -2
  44. ccproxy/cli/main.py +6 -17
  45. ccproxy/cli/options/claude_options.py +41 -1
  46. ccproxy/cli/options/core_options.py +0 -3
  47. ccproxy/cli/options/security_options.py +0 -2
  48. ccproxy/cli/options/server_options.py +3 -2
  49. ccproxy/config/auth.py +0 -1
  50. ccproxy/config/claude.py +78 -2
  51. ccproxy/config/discovery.py +0 -1
  52. ccproxy/config/docker_settings.py +0 -1
  53. ccproxy/config/loader.py +1 -4
  54. ccproxy/config/scheduler.py +20 -0
  55. ccproxy/config/security.py +7 -2
  56. ccproxy/config/server.py +5 -0
  57. ccproxy/config/settings.py +15 -7
  58. ccproxy/config/validators.py +1 -1
  59. ccproxy/core/async_utils.py +1 -4
  60. ccproxy/core/errors.py +45 -1
  61. ccproxy/core/http_transformers.py +4 -3
  62. ccproxy/core/interfaces.py +2 -2
  63. ccproxy/core/logging.py +97 -95
  64. ccproxy/core/middleware.py +1 -1
  65. ccproxy/core/proxy.py +1 -1
  66. ccproxy/core/transformers.py +1 -1
  67. ccproxy/core/types.py +1 -1
  68. ccproxy/docker/models.py +1 -1
  69. ccproxy/docker/protocol.py +0 -3
  70. ccproxy/models/__init__.py +41 -0
  71. ccproxy/models/claude_sdk.py +420 -0
  72. ccproxy/models/messages.py +45 -18
  73. ccproxy/models/permissions.py +115 -0
  74. ccproxy/models/requests.py +1 -1
  75. ccproxy/models/responses.py +64 -1
  76. ccproxy/observability/access_logger.py +1 -2
  77. ccproxy/observability/context.py +17 -1
  78. ccproxy/observability/metrics.py +1 -3
  79. ccproxy/observability/pushgateway.py +0 -2
  80. ccproxy/observability/stats_printer.py +2 -4
  81. ccproxy/observability/storage/duckdb_simple.py +1 -1
  82. ccproxy/observability/storage/models.py +0 -1
  83. ccproxy/pricing/cache.py +0 -1
  84. ccproxy/pricing/loader.py +5 -21
  85. ccproxy/pricing/updater.py +0 -1
  86. ccproxy/scheduler/__init__.py +1 -0
  87. ccproxy/scheduler/core.py +6 -6
  88. ccproxy/scheduler/manager.py +35 -7
  89. ccproxy/scheduler/registry.py +1 -1
  90. ccproxy/scheduler/tasks.py +127 -2
  91. ccproxy/services/claude_sdk_service.py +225 -329
  92. ccproxy/services/credentials/manager.py +0 -1
  93. ccproxy/services/credentials/oauth_client.py +1 -2
  94. ccproxy/services/proxy_service.py +93 -222
  95. ccproxy/testing/config.py +1 -1
  96. ccproxy/testing/mock_responses.py +0 -1
  97. ccproxy/utils/model_mapping.py +197 -0
  98. ccproxy/utils/models_provider.py +150 -0
  99. ccproxy/utils/simple_request_logger.py +284 -0
  100. ccproxy/utils/version_checker.py +184 -0
  101. {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/METADATA +63 -2
  102. ccproxy_api-0.1.3.dist-info/RECORD +166 -0
  103. {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/entry_points.txt +1 -0
  104. ccproxy_api-0.1.1.dist-info/RECORD +0 -149
  105. /ccproxy/scheduler/{exceptions.py → errors.py} +0 -0
  106. {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/WHEEL +0 -0
  107. {ccproxy_api-0.1.1.dist-info → ccproxy_api-0.1.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,197 @@
1
+ """Unified model mapping utilities for OpenAI and Claude models.
2
+
3
+ This module provides a single source of truth for all model mappings,
4
+ consolidating OpenAI→Claude mappings and Claude alias resolution.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ # Combined mapping: OpenAI models → Claude models AND Claude aliases → canonical Claude models
11
+ MODEL_MAPPING: dict[str, str] = {
12
+ # OpenAI GPT-4 models → Claude 3.5 Sonnet (most comparable)
13
+ "gpt-4": "claude-3-5-sonnet-20241022",
14
+ "gpt-4-turbo": "claude-3-5-sonnet-20241022",
15
+ "gpt-4-turbo-preview": "claude-3-5-sonnet-20241022",
16
+ "gpt-4-1106-preview": "claude-3-5-sonnet-20241022",
17
+ "gpt-4-0125-preview": "claude-3-5-sonnet-20241022",
18
+ "gpt-4-turbo-2024-04-09": "claude-3-5-sonnet-20241022",
19
+ # OpenAI GPT-4o models → Claude 3.7 Sonnet
20
+ "gpt-4o": "claude-3-7-sonnet-20250219",
21
+ "gpt-4o-2024-05-13": "claude-3-7-sonnet-20250219",
22
+ "gpt-4o-2024-08-06": "claude-3-7-sonnet-20250219",
23
+ "gpt-4o-2024-11-20": "claude-3-7-sonnet-20250219",
24
+ # OpenAI GPT-4o-mini models → Claude 3.5 Haiku
25
+ "gpt-4o-mini": "claude-3-5-haiku-latest",
26
+ "gpt-4o-mini-2024-07-18": "claude-3-5-haiku-latest",
27
+ # OpenAI o1 models → Claude models that support thinking
28
+ "o1": "claude-opus-4-20250514",
29
+ "o1-preview": "claude-opus-4-20250514",
30
+ "o1-mini": "claude-sonnet-4-20250514",
31
+ # OpenAI o3 models → Claude Opus 4
32
+ "o3-mini": "claude-opus-4-20250514",
33
+ # OpenAI GPT-3.5 models → Claude 3.5 Haiku (faster, cheaper)
34
+ "gpt-3.5-turbo": "claude-3-5-haiku-20241022",
35
+ "gpt-3.5-turbo-16k": "claude-3-5-haiku-20241022",
36
+ "gpt-3.5-turbo-1106": "claude-3-5-haiku-20241022",
37
+ "gpt-3.5-turbo-0125": "claude-3-5-haiku-20241022",
38
+ # OpenAI text models → Claude 3.5 Sonnet
39
+ "text-davinci-003": "claude-3-5-sonnet-20241022",
40
+ "text-davinci-002": "claude-3-5-sonnet-20241022",
41
+ # Claude model aliases → canonical Claude models
42
+ "claude-3-5-sonnet-latest": "claude-3-5-sonnet-20241022",
43
+ "claude-3-5-sonnet-20240620": "claude-3-5-sonnet-20240620",
44
+ "claude-3-5-sonnet-20241022": "claude-3-5-sonnet-20241022",
45
+ "claude-3-5-haiku-latest": "claude-3-5-haiku-20241022",
46
+ "claude-3-5-haiku-20241022": "claude-3-5-haiku-20241022",
47
+ "claude-3-opus": "claude-3-opus-20240229",
48
+ "claude-3-opus-20240229": "claude-3-opus-20240229",
49
+ "claude-3-sonnet": "claude-3-sonnet-20240229",
50
+ "claude-3-sonnet-20240229": "claude-3-sonnet-20240229",
51
+ "claude-3-haiku": "claude-3-haiku-20240307",
52
+ "claude-3-haiku-20240307": "claude-3-haiku-20240307",
53
+ }
54
+
55
+
56
+ def map_model_to_claude(model_name: str) -> str:
57
+ """Map any model name to its canonical Claude model name.
58
+
59
+ This function handles:
60
+ - OpenAI model names → Claude equivalents
61
+ - Claude aliases → canonical Claude names
62
+ - Pattern matching for versioned models
63
+ - Pass-through for unknown models
64
+
65
+ Args:
66
+ model_name: Model identifier (OpenAI, Claude, or alias)
67
+
68
+ Returns:
69
+ Canonical Claude model identifier
70
+ """
71
+ # Direct mapping first (handles both OpenAI and Claude aliases)
72
+ claude_model = MODEL_MAPPING.get(model_name)
73
+ if claude_model:
74
+ return claude_model
75
+
76
+ # Pattern matching for versioned OpenAI models
77
+ if model_name.startswith("gpt-4o-mini"):
78
+ return "claude-3-5-haiku-latest"
79
+ elif model_name.startswith("gpt-4o") or model_name.startswith("gpt-4"):
80
+ return "claude-3-7-sonnet-20250219"
81
+ elif model_name.startswith("gpt-3.5"):
82
+ return "claude-3-5-haiku-latest"
83
+ elif model_name.startswith("o1"):
84
+ return "claude-sonnet-4-20250514"
85
+ elif model_name.startswith("o3"):
86
+ return "claude-opus-4-20250514"
87
+ elif model_name.startswith("gpt"):
88
+ return "claude-sonnet-4-20250514"
89
+
90
+ # If it's already a Claude model, pass through unchanged
91
+ if model_name.startswith("claude-"):
92
+ return model_name
93
+
94
+ # For unknown models, pass through unchanged
95
+ return model_name
96
+
97
+
98
+ def get_openai_to_claude_mapping() -> dict[str, str]:
99
+ """Get mapping of OpenAI models to Claude models.
100
+
101
+ Returns:
102
+ Dictionary mapping OpenAI model names to Claude model names
103
+ """
104
+ return {k: v for k, v in MODEL_MAPPING.items() if not k.startswith("claude-")}
105
+
106
+
107
+ def get_claude_aliases_mapping() -> dict[str, str]:
108
+ """Get mapping of Claude aliases to canonical Claude names.
109
+
110
+ Returns:
111
+ Dictionary mapping Claude aliases to canonical model names
112
+ """
113
+ return {k: v for k, v in MODEL_MAPPING.items() if k.startswith("claude-")}
114
+
115
+
116
+ def get_supported_claude_models() -> list[str]:
117
+ """Get list of supported canonical Claude models.
118
+
119
+ Returns:
120
+ Sorted list of unique canonical Claude model names
121
+ """
122
+ return sorted(set(MODEL_MAPPING.values()))
123
+
124
+
125
+ def is_openai_model(model_name: str) -> bool:
126
+ """Check if a model name is an OpenAI model.
127
+
128
+ Args:
129
+ model_name: Model identifier to check
130
+
131
+ Returns:
132
+ True if the model is an OpenAI model, False otherwise
133
+ """
134
+ return (
135
+ model_name.startswith(("gpt-", "o1", "o3", "text-davinci"))
136
+ or model_name in get_openai_to_claude_mapping()
137
+ )
138
+
139
+
140
+ def is_claude_model(model_name: str) -> bool:
141
+ """Check if a model name is a Claude model (canonical or alias).
142
+
143
+ Args:
144
+ model_name: Model identifier to check
145
+
146
+ Returns:
147
+ True if the model is a Claude model, False otherwise
148
+ """
149
+ return (
150
+ model_name.startswith("claude-") or model_name in get_claude_aliases_mapping()
151
+ )
152
+
153
+
154
+ # Backward compatibility exports
155
+ OPENAI_TO_CLAUDE_MODEL_MAPPING = get_openai_to_claude_mapping()
156
+ CLAUDE_MODEL_MAPPINGS = get_claude_aliases_mapping()
157
+
158
+
159
+ # Legacy function aliases
160
+ def map_openai_model_to_claude(openai_model: str) -> str:
161
+ """Legacy alias for map_model_to_claude().
162
+
163
+ Args:
164
+ openai_model: OpenAI model identifier
165
+
166
+ Returns:
167
+ Claude model identifier
168
+ """
169
+ return map_model_to_claude(openai_model)
170
+
171
+
172
+ def get_canonical_model_name(model_name: str) -> str:
173
+ """Legacy alias for map_model_to_claude().
174
+
175
+ Args:
176
+ model_name: Model name (possibly an alias)
177
+
178
+ Returns:
179
+ Canonical model name
180
+ """
181
+ return map_model_to_claude(model_name)
182
+
183
+
184
+ __all__ = [
185
+ "MODEL_MAPPING",
186
+ "map_model_to_claude",
187
+ "get_openai_to_claude_mapping",
188
+ "get_claude_aliases_mapping",
189
+ "get_supported_claude_models",
190
+ "is_openai_model",
191
+ "is_claude_model",
192
+ # Backward compatibility
193
+ "OPENAI_TO_CLAUDE_MODEL_MAPPING",
194
+ "CLAUDE_MODEL_MAPPINGS",
195
+ "map_openai_model_to_claude",
196
+ "get_canonical_model_name",
197
+ ]
@@ -0,0 +1,150 @@
1
+ """Shared models provider for CCProxy API Server.
2
+
3
+ This module provides a centralized source for all available models,
4
+ combining Claude and OpenAI models in a consistent format.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from ccproxy.utils.model_mapping import get_supported_claude_models
12
+
13
+
14
+ def get_anthropic_models() -> list[dict[str, Any]]:
15
+ """Get list of Anthropic models with metadata.
16
+
17
+ Returns:
18
+ List of Anthropic model entries with type, id, display_name, and created_at fields
19
+ """
20
+ # Model display names mapping
21
+ display_names = {
22
+ "claude-opus-4-20250514": "Claude Opus 4",
23
+ "claude-sonnet-4-20250514": "Claude Sonnet 4",
24
+ "claude-3-7-sonnet-20250219": "Claude Sonnet 3.7",
25
+ "claude-3-5-sonnet-20241022": "Claude Sonnet 3.5 (New)",
26
+ "claude-3-5-haiku-20241022": "Claude Haiku 3.5",
27
+ "claude-3-5-haiku-latest": "Claude Haiku 3.5",
28
+ "claude-3-5-sonnet-20240620": "Claude Sonnet 3.5 (Old)",
29
+ "claude-3-haiku-20240307": "Claude Haiku 3",
30
+ "claude-3-opus-20240229": "Claude Opus 3",
31
+ }
32
+
33
+ # Model creation timestamps
34
+ timestamps = {
35
+ "claude-opus-4-20250514": 1747526400, # 2025-05-22
36
+ "claude-sonnet-4-20250514": 1747526400, # 2025-05-22
37
+ "claude-3-7-sonnet-20250219": 1740268800, # 2025-02-24
38
+ "claude-3-5-sonnet-20241022": 1729555200, # 2024-10-22
39
+ "claude-3-5-haiku-20241022": 1729555200, # 2024-10-22
40
+ "claude-3-5-haiku-latest": 1729555200, # 2024-10-22
41
+ "claude-3-5-sonnet-20240620": 1718841600, # 2024-06-20
42
+ "claude-3-haiku-20240307": 1709769600, # 2024-03-07
43
+ "claude-3-opus-20240229": 1709164800, # 2024-02-29
44
+ }
45
+
46
+ # Get supported Claude models from existing utility
47
+ supported_models = get_supported_claude_models()
48
+
49
+ # Create Anthropic-style model entries
50
+ models = []
51
+ for model_id in supported_models:
52
+ models.append(
53
+ {
54
+ "type": "model",
55
+ "id": model_id,
56
+ "display_name": display_names.get(model_id, model_id),
57
+ "created_at": timestamps.get(model_id, 1677610602), # Default timestamp
58
+ }
59
+ )
60
+
61
+ return models
62
+
63
+
64
+ def get_openai_models() -> list[dict[str, Any]]:
65
+ """Get list of recent OpenAI models with metadata.
66
+
67
+ Returns:
68
+ List of OpenAI model entries with id, object, created, and owned_by fields
69
+ """
70
+ return [
71
+ {
72
+ "id": "gpt-4o",
73
+ "object": "model",
74
+ "created": 1715367049,
75
+ "owned_by": "openai",
76
+ },
77
+ {
78
+ "id": "gpt-4o-mini",
79
+ "object": "model",
80
+ "created": 1721172741,
81
+ "owned_by": "openai",
82
+ },
83
+ {
84
+ "id": "gpt-4-turbo",
85
+ "object": "model",
86
+ "created": 1712361441,
87
+ "owned_by": "openai",
88
+ },
89
+ {
90
+ "id": "gpt-4-turbo-preview",
91
+ "object": "model",
92
+ "created": 1706037777,
93
+ "owned_by": "openai",
94
+ },
95
+ {
96
+ "id": "o1",
97
+ "object": "model",
98
+ "created": 1734375816,
99
+ "owned_by": "openai",
100
+ },
101
+ {
102
+ "id": "o1-mini",
103
+ "object": "model",
104
+ "created": 1725649008,
105
+ "owned_by": "openai",
106
+ },
107
+ {
108
+ "id": "o1-preview",
109
+ "object": "model",
110
+ "created": 1725648897,
111
+ "owned_by": "openai",
112
+ },
113
+ {
114
+ "id": "o3",
115
+ "object": "model",
116
+ "created": 1744225308,
117
+ "owned_by": "openai",
118
+ },
119
+ {
120
+ "id": "o3-mini",
121
+ "object": "model",
122
+ "created": 1737146383,
123
+ "owned_by": "openai",
124
+ },
125
+ ]
126
+
127
+
128
+ def get_models_list() -> dict[str, Any]:
129
+ """Get combined list of available Claude and OpenAI models.
130
+
131
+ Returns:
132
+ Dictionary with combined list of models in mixed format compatible with both
133
+ Anthropic and OpenAI API specifications
134
+ """
135
+ anthropic_models = get_anthropic_models()
136
+ openai_models = get_openai_models()
137
+
138
+ # Return combined response in mixed format
139
+ return {
140
+ "data": anthropic_models + openai_models,
141
+ "has_more": False,
142
+ "object": "list",
143
+ }
144
+
145
+
146
+ __all__ = [
147
+ "get_anthropic_models",
148
+ "get_openai_models",
149
+ "get_models_list",
150
+ ]
@@ -0,0 +1,284 @@
1
+ """Simple request logging utility for content logging across all service layers."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import structlog
11
+
12
+
13
+ logger = structlog.get_logger(__name__)
14
+
15
+ # Global batching settings for streaming logs
16
+ _STREAMING_BATCH_SIZE = 8192 # Batch chunks until we have 8KB
17
+ _STREAMING_BATCH_TIMEOUT = 0.1 # Or flush after 100ms
18
+ _streaming_batches: dict[str, dict[str, Any]] = {} # request_id -> batch info
19
+
20
+
21
+ def should_log_requests() -> bool:
22
+ """Check if request logging is enabled via environment variable.
23
+
24
+ Returns:
25
+ True if CCPROXY_LOG_REQUESTS is set to 'true' (case-insensitive)
26
+ """
27
+ return os.environ.get("CCPROXY_LOG_REQUESTS", "false").lower() == "true"
28
+
29
+
30
+ def get_request_log_dir() -> Path | None:
31
+ """Get the request log directory from environment variable.
32
+
33
+ Returns:
34
+ Path object if CCPROXY_REQUEST_LOG_DIR is set and valid, None otherwise
35
+ """
36
+ log_dir = os.environ.get("CCPROXY_REQUEST_LOG_DIR")
37
+ if not log_dir:
38
+ return None
39
+
40
+ path = Path(log_dir)
41
+ try:
42
+ path.mkdir(parents=True, exist_ok=True)
43
+ return path
44
+ except Exception as e:
45
+ logger.error(
46
+ "failed_to_create_request_log_dir",
47
+ log_dir=log_dir,
48
+ error=str(e),
49
+ )
50
+ return None
51
+
52
+
53
+ def get_timestamp_prefix() -> str:
54
+ """Generate timestamp prefix in YYYYMMDDhhmmss format.
55
+
56
+ Returns:
57
+ Timestamp string in YYYYMMDDhhmmss format (UTC)
58
+ """
59
+ return datetime.now(UTC).strftime("%Y%m%d%H%M%S")
60
+
61
+
62
+ async def write_request_log(
63
+ request_id: str,
64
+ log_type: str,
65
+ data: dict[str, Any],
66
+ timestamp: str | None = None,
67
+ ) -> None:
68
+ """Write request/response data to JSON file.
69
+
70
+ Args:
71
+ request_id: Unique request identifier
72
+ log_type: Type of log (e.g., 'middleware_request', 'upstream_response')
73
+ data: Data to log as JSON
74
+ timestamp: Optional timestamp prefix (defaults to current time)
75
+ """
76
+ if not should_log_requests():
77
+ return
78
+
79
+ log_dir = get_request_log_dir()
80
+ if not log_dir:
81
+ return
82
+
83
+ timestamp = timestamp or get_timestamp_prefix()
84
+ filename = f"{timestamp}_{request_id}_{log_type}.json"
85
+ file_path = log_dir / filename
86
+
87
+ try:
88
+ # Write JSON data to file asynchronously
89
+ def write_file() -> None:
90
+ with file_path.open("w", encoding="utf-8") as f:
91
+ json.dump(data, f, indent=2, default=str, ensure_ascii=False)
92
+
93
+ # Run in thread pool to avoid blocking
94
+ await asyncio.get_event_loop().run_in_executor(None, write_file)
95
+
96
+ logger.debug(
97
+ "request_log_written",
98
+ request_id=request_id,
99
+ log_type=log_type,
100
+ file_path=str(file_path),
101
+ )
102
+
103
+ except Exception as e:
104
+ logger.error(
105
+ "failed_to_write_request_log",
106
+ request_id=request_id,
107
+ log_type=log_type,
108
+ file_path=str(file_path),
109
+ error=str(e),
110
+ )
111
+
112
+
113
+ async def write_streaming_log(
114
+ request_id: str,
115
+ log_type: str,
116
+ data: bytes,
117
+ timestamp: str | None = None,
118
+ ) -> None:
119
+ """Write streaming data to raw file.
120
+
121
+ Args:
122
+ request_id: Unique request identifier
123
+ log_type: Type of log (e.g., 'middleware_streaming', 'upstream_streaming')
124
+ data: Raw bytes to log
125
+ timestamp: Optional timestamp prefix (defaults to current time)
126
+ """
127
+ if not should_log_requests():
128
+ return
129
+
130
+ log_dir = get_request_log_dir()
131
+ if not log_dir:
132
+ return
133
+
134
+ timestamp = timestamp or get_timestamp_prefix()
135
+ filename = f"{timestamp}_{request_id}_{log_type}.raw"
136
+ file_path = log_dir / filename
137
+
138
+ try:
139
+ # Write raw data to file asynchronously
140
+ def write_file() -> None:
141
+ with file_path.open("wb") as f:
142
+ f.write(data)
143
+
144
+ # Run in thread pool to avoid blocking
145
+ await asyncio.get_event_loop().run_in_executor(None, write_file)
146
+
147
+ logger.debug(
148
+ "streaming_log_written",
149
+ request_id=request_id,
150
+ log_type=log_type,
151
+ file_path=str(file_path),
152
+ data_size=len(data),
153
+ )
154
+
155
+ except Exception as e:
156
+ logger.error(
157
+ "failed_to_write_streaming_log",
158
+ request_id=request_id,
159
+ log_type=log_type,
160
+ file_path=str(file_path),
161
+ error=str(e),
162
+ )
163
+
164
+
165
+ async def append_streaming_log(
166
+ request_id: str,
167
+ log_type: str,
168
+ data: bytes,
169
+ timestamp: str | None = None,
170
+ ) -> None:
171
+ """Append streaming data using batching for performance.
172
+
173
+ Args:
174
+ request_id: Unique request identifier
175
+ log_type: Type of log (e.g., 'middleware_streaming', 'upstream_streaming')
176
+ data: Raw bytes to append
177
+ timestamp: Optional timestamp prefix (defaults to current time)
178
+ """
179
+ if not should_log_requests():
180
+ return
181
+
182
+ log_dir = get_request_log_dir()
183
+ if not log_dir:
184
+ return
185
+
186
+ timestamp = timestamp or get_timestamp_prefix()
187
+ batch_key = f"{request_id}_{log_type}"
188
+
189
+ # Get or create batch for this request/log_type combination
190
+ if batch_key not in _streaming_batches:
191
+ _streaming_batches[batch_key] = {
192
+ "request_id": request_id,
193
+ "log_type": log_type,
194
+ "timestamp": timestamp,
195
+ "data": bytearray(),
196
+ "chunk_count": 0,
197
+ "first_chunk_time": asyncio.get_event_loop().time(),
198
+ "last_flush_task": None,
199
+ }
200
+
201
+ batch = _streaming_batches[batch_key]
202
+ batch["data"].extend(data)
203
+ batch["chunk_count"] += 1
204
+
205
+ # Cancel previous flush task if it exists
206
+ if batch["last_flush_task"] and not batch["last_flush_task"].done():
207
+ batch["last_flush_task"].cancel()
208
+
209
+ # Check if we should flush now
210
+ should_flush = (
211
+ len(batch["data"]) >= _STREAMING_BATCH_SIZE
212
+ or batch["chunk_count"] >= 50 # Max 50 chunks per batch
213
+ )
214
+
215
+ if should_flush:
216
+ await _flush_streaming_batch(batch_key)
217
+ else:
218
+ # Schedule a delayed flush
219
+ batch["last_flush_task"] = asyncio.create_task(
220
+ _delayed_flush_streaming_batch(batch_key, _STREAMING_BATCH_TIMEOUT)
221
+ )
222
+
223
+
224
+ async def _delayed_flush_streaming_batch(batch_key: str, delay: float) -> None:
225
+ """Flush a streaming batch after a delay."""
226
+ try:
227
+ await asyncio.sleep(delay)
228
+ if batch_key in _streaming_batches:
229
+ await _flush_streaming_batch(batch_key)
230
+ except asyncio.CancelledError:
231
+ # Task was cancelled, don't flush
232
+ pass
233
+
234
+
235
+ async def _flush_streaming_batch(batch_key: str) -> None:
236
+ """Flush a streaming batch to disk."""
237
+ if batch_key not in _streaming_batches:
238
+ return
239
+
240
+ batch = _streaming_batches.pop(batch_key)
241
+
242
+ if not batch["data"]:
243
+ return # Nothing to flush
244
+
245
+ log_dir = get_request_log_dir()
246
+ if not log_dir:
247
+ return
248
+
249
+ filename = f"{batch['timestamp']}_{batch['request_id']}_{batch['log_type']}.raw"
250
+ file_path = log_dir / filename
251
+
252
+ try:
253
+ # Append batched data to file asynchronously
254
+ def append_file() -> None:
255
+ with file_path.open("ab") as f:
256
+ f.write(batch["data"])
257
+
258
+ # Run in thread pool to avoid blocking
259
+ await asyncio.get_event_loop().run_in_executor(None, append_file)
260
+
261
+ logger.debug(
262
+ "streaming_batch_flushed",
263
+ request_id=batch["request_id"],
264
+ log_type=batch["log_type"],
265
+ file_path=str(file_path),
266
+ batch_size=len(batch["data"]),
267
+ chunk_count=batch["chunk_count"],
268
+ )
269
+
270
+ except Exception as e:
271
+ logger.error(
272
+ "failed_to_flush_streaming_batch",
273
+ request_id=batch["request_id"],
274
+ log_type=batch["log_type"],
275
+ file_path=str(file_path),
276
+ error=str(e),
277
+ )
278
+
279
+
280
+ async def flush_all_streaming_batches() -> None:
281
+ """Flush all pending streaming batches. Call this on shutdown."""
282
+ batch_keys = list(_streaming_batches.keys())
283
+ for batch_key in batch_keys:
284
+ await _flush_streaming_batch(batch_key)