ccproxy-api 0.1.4__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.
- ccproxy/_version.py +2 -2
- ccproxy/adapters/openai/adapter.py +1 -1
- ccproxy/adapters/openai/streaming.py +1 -0
- ccproxy/api/app.py +134 -224
- ccproxy/api/dependencies.py +22 -2
- ccproxy/api/middleware/errors.py +27 -3
- ccproxy/api/middleware/logging.py +4 -0
- ccproxy/api/responses.py +6 -1
- ccproxy/api/routes/claude.py +222 -17
- ccproxy/api/routes/proxy.py +25 -6
- ccproxy/api/services/permission_service.py +2 -2
- ccproxy/claude_sdk/__init__.py +4 -8
- ccproxy/claude_sdk/client.py +661 -131
- ccproxy/claude_sdk/exceptions.py +16 -0
- ccproxy/claude_sdk/manager.py +219 -0
- ccproxy/claude_sdk/message_queue.py +342 -0
- ccproxy/claude_sdk/options.py +5 -0
- ccproxy/claude_sdk/session_client.py +546 -0
- ccproxy/claude_sdk/session_pool.py +550 -0
- ccproxy/claude_sdk/stream_handle.py +538 -0
- ccproxy/claude_sdk/stream_worker.py +392 -0
- ccproxy/claude_sdk/streaming.py +53 -11
- ccproxy/cli/commands/serve.py +96 -0
- ccproxy/cli/options/claude_options.py +47 -0
- ccproxy/config/__init__.py +0 -3
- ccproxy/config/claude.py +171 -23
- ccproxy/config/discovery.py +10 -1
- ccproxy/config/scheduler.py +4 -4
- ccproxy/config/settings.py +19 -1
- ccproxy/core/http_transformers.py +305 -73
- ccproxy/core/logging.py +108 -12
- ccproxy/core/transformers.py +5 -0
- ccproxy/models/claude_sdk.py +57 -0
- ccproxy/models/detection.py +126 -0
- ccproxy/observability/access_logger.py +72 -14
- ccproxy/observability/metrics.py +151 -0
- ccproxy/observability/storage/duckdb_simple.py +12 -0
- ccproxy/observability/storage/models.py +16 -0
- ccproxy/observability/streaming_response.py +107 -0
- ccproxy/scheduler/manager.py +31 -6
- ccproxy/scheduler/tasks.py +122 -0
- ccproxy/services/claude_detection_service.py +269 -0
- ccproxy/services/claude_sdk_service.py +333 -130
- ccproxy/services/proxy_service.py +91 -200
- ccproxy/utils/__init__.py +9 -1
- ccproxy/utils/disconnection_monitor.py +83 -0
- ccproxy/utils/id_generator.py +12 -0
- ccproxy/utils/startup_helpers.py +408 -0
- {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.5.dist-info}/METADATA +29 -2
- {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.5.dist-info}/RECORD +53 -41
- ccproxy/config/loader.py +0 -105
- {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.5.dist-info}/WHEEL +0 -0
- {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.5.dist-info}/entry_points.txt +0 -0
- {ccproxy_api-0.1.4.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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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"),
|
|
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"),
|
|
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,
|
|
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
|
-
#
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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,
|
|
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(
|
|
219
|
-
|
|
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
|
|
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
|
-
#
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
#
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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(
|
|
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
|
|
ccproxy/core/transformers.py
CHANGED
|
@@ -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:
|