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.
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 +333 -130
  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.4.dist-info → ccproxy_api-0.1.5.dist-info}/METADATA +29 -2
  50. {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.5.dist-info}/RECORD +53 -41
  51. ccproxy/config/loader.py +0 -105
  52. {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.5.dist-info}/WHEEL +0 -0
  53. {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.5.dist-info}/entry_points.txt +0 -0
  54. {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.5.dist-info}/licenses/LICENSE +0 -0
ccproxy/_version.py CHANGED
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '0.1.4'
21
- __version_tuple__ = version_tuple = (0, 1, 4)
20
+ __version__ = version = '0.1.5'
21
+ __version_tuple__ = version_tuple = (0, 1, 5)
@@ -432,7 +432,7 @@ class OpenAIAdapter(APIAdapter):
432
432
  thinking_text = block.get("thinking", "")
433
433
  signature = block.get("signature")
434
434
  if thinking_text:
435
- content += f'<thinking signature="{signature}">{thinking_text}</thinking>'
435
+ content += f'<thinking signature="{signature}">{thinking_text}</thinking>\n'
436
436
  elif block.get("type") == "tool_use":
437
437
  # Handle legacy tool_use content blocks
438
438
  tool_calls.append(format_openai_tool_call(block))
@@ -435,6 +435,7 @@ class OpenAIStreamProcessor:
435
435
  if cost_usd is not None:
436
436
  formatted_text += f", cost_usd={cost_usd}"
437
437
  yield self._format_chunk_output(delta={"content": formatted_text})
438
+
438
439
  elif block.get("type") == "tool_use":
439
440
  # Start of tool call
440
441
  tool_id = block.get("id", "")
ccproxy/api/app.py CHANGED
@@ -1,9 +1,8 @@
1
1
  """FastAPI application factory for CCProxy API Server."""
2
2
 
3
- from collections.abc import AsyncGenerator
3
+ from collections.abc import AsyncGenerator, Awaitable, Callable
4
4
  from contextlib import asynccontextmanager
5
- from datetime import UTC, datetime
6
- from typing import Any
5
+ from typing import Any, TypedDict
7
6
 
8
7
  from fastapi import APIRouter, FastAPI
9
8
  from fastapi.staticfiles import StaticFiles
@@ -19,7 +18,6 @@ from ccproxy.api.middleware.request_content_logging import (
19
18
  from ccproxy.api.middleware.request_id import RequestIDMiddleware
20
19
  from ccproxy.api.middleware.server_header import ServerHeaderMiddleware
21
20
  from ccproxy.api.routes.claude import router as claude_router
22
- from ccproxy.api.routes.health import get_claude_cli_info
23
21
  from ccproxy.api.routes.health import router as health_router
24
22
  from ccproxy.api.routes.mcp import setup_mcp
25
23
  from ccproxy.api.routes.metrics import (
@@ -29,24 +27,93 @@ from ccproxy.api.routes.metrics import (
29
27
  )
30
28
  from ccproxy.api.routes.permissions import router as permissions_router
31
29
  from ccproxy.api.routes.proxy import router as proxy_router
32
- from ccproxy.api.services.permission_service import get_permission_service
33
- from ccproxy.auth.credentials_adapter import CredentialsAuthManager
34
- from ccproxy.auth.exceptions import CredentialsNotFoundError
35
30
  from ccproxy.auth.oauth.routes import router as oauth_router
36
31
  from ccproxy.config.settings import Settings, get_settings
37
32
  from ccproxy.core.logging import setup_logging
38
- from ccproxy.observability import get_metrics
39
- from ccproxy.observability.storage.duckdb_simple import SimpleDuckDBStorage
40
- from ccproxy.scheduler.errors import SchedulerError
41
- from ccproxy.scheduler.manager import start_scheduler, stop_scheduler
42
- from ccproxy.services.claude_sdk_service import ClaudeSDKService
43
- from ccproxy.services.credentials import CredentialsManager
44
33
  from ccproxy.utils.models_provider import get_models_list
34
+ from ccproxy.utils.startup_helpers import (
35
+ check_claude_cli_startup,
36
+ flush_streaming_batches_shutdown,
37
+ initialize_claude_detection_startup,
38
+ initialize_claude_sdk_startup,
39
+ initialize_log_storage_shutdown,
40
+ initialize_log_storage_startup,
41
+ initialize_permission_service_startup,
42
+ setup_permission_service_shutdown,
43
+ setup_scheduler_shutdown,
44
+ setup_scheduler_startup,
45
+ setup_session_manager_shutdown,
46
+ validate_authentication_startup,
47
+ )
45
48
 
46
49
 
47
50
  logger = get_logger(__name__)
48
51
 
49
52
 
53
+ # Type definitions for lifecycle components
54
+ class LifecycleComponent(TypedDict):
55
+ name: str
56
+ startup: Callable[[FastAPI, Any], Awaitable[None]] | None
57
+ shutdown: (
58
+ Callable[[FastAPI], Awaitable[None]]
59
+ | Callable[[FastAPI, Any], Awaitable[None]]
60
+ | None
61
+ )
62
+
63
+
64
+ class ShutdownComponent(TypedDict):
65
+ name: str
66
+ shutdown: Callable[[FastAPI], Awaitable[None]] | None
67
+
68
+
69
+ # Define lifecycle components for startup/shutdown organization
70
+ LIFECYCLE_COMPONENTS: list[LifecycleComponent] = [
71
+ {
72
+ "name": "Authentication",
73
+ "startup": validate_authentication_startup,
74
+ "shutdown": None, # One-time validation, no cleanup needed
75
+ },
76
+ {
77
+ "name": "Claude CLI",
78
+ "startup": check_claude_cli_startup,
79
+ "shutdown": None, # Detection only, no cleanup needed
80
+ },
81
+ {
82
+ "name": "Claude Detection",
83
+ "startup": initialize_claude_detection_startup,
84
+ "shutdown": None, # No cleanup needed
85
+ },
86
+ {
87
+ "name": "Claude SDK",
88
+ "startup": initialize_claude_sdk_startup,
89
+ "shutdown": setup_session_manager_shutdown,
90
+ },
91
+ {
92
+ "name": "Scheduler",
93
+ "startup": setup_scheduler_startup,
94
+ "shutdown": setup_scheduler_shutdown,
95
+ },
96
+ {
97
+ "name": "Log Storage",
98
+ "startup": initialize_log_storage_startup,
99
+ "shutdown": initialize_log_storage_shutdown,
100
+ },
101
+ {
102
+ "name": "Permission Service",
103
+ "startup": initialize_permission_service_startup,
104
+ "shutdown": setup_permission_service_shutdown,
105
+ },
106
+ ]
107
+
108
+ # Additional shutdown-only components that need special handling
109
+ SHUTDOWN_ONLY_COMPONENTS: list[ShutdownComponent] = [
110
+ {
111
+ "name": "Streaming Batches",
112
+ "shutdown": flush_streaming_batches_shutdown,
113
+ },
114
+ ]
115
+
116
+
50
117
  # Create shared models router
51
118
  models_router = APIRouter(tags=["models"])
52
119
 
@@ -63,7 +130,7 @@ async def list_models() -> dict[str, Any]:
63
130
 
64
131
  @asynccontextmanager
65
132
  async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
66
- """Application lifespan manager."""
133
+ """Application lifespan manager using component-based approach."""
67
134
  settings = get_settings()
68
135
 
69
136
  # Store settings in app state for reuse in dependencies
@@ -89,216 +156,57 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
89
156
  "claude_cli_search_paths", paths=settings.claude.get_searched_paths()
90
157
  )
91
158
 
92
- # Validate authentication token at startup
93
- try:
94
- credentials_manager = CredentialsManager()
95
- validation = await credentials_manager.validate()
96
-
97
- if validation.valid and not validation.expired:
98
- credentials = validation.credentials
99
- oauth_token = credentials.claude_ai_oauth if credentials else None
100
-
101
- if oauth_token and oauth_token.expires_at_datetime:
102
- hours_until_expiry = int(
103
- (
104
- oauth_token.expires_at_datetime - datetime.now(UTC)
105
- ).total_seconds()
106
- / 3600
107
- )
108
- logger.debug(
109
- "auth_token_valid",
110
- expires_in_hours=hours_until_expiry,
111
- subscription_type=oauth_token.subscription_type,
112
- credentials_path=str(validation.path) if validation.path else None,
159
+ # Execute startup components in order
160
+ for component in LIFECYCLE_COMPONENTS:
161
+ if component["startup"]:
162
+ component_name = component["name"]
163
+ try:
164
+ logger.debug(f"starting_{component_name.lower().replace(' ', '_')}")
165
+ await component["startup"](app, settings)
166
+ except Exception as e:
167
+ logger.error(
168
+ f"{component_name.lower().replace(' ', '_')}_startup_failed",
169
+ error=str(e),
170
+ component=component_name,
113
171
  )
114
- else:
115
- logger.debug("auth_token_valid", credentials_path=str(validation.path))
116
- elif validation.expired:
117
- logger.warning(
118
- "auth_token_expired",
119
- message="Authentication token has expired. Please run 'ccproxy auth login' to refresh.",
120
- credentials_path=str(validation.path) if validation.path else None,
121
- )
122
- else:
123
- logger.warning(
124
- "auth_token_invalid",
125
- message="Authentication token is invalid. Please run 'ccproxy auth login'.",
126
- credentials_path=str(validation.path) if validation.path else None,
127
- )
128
- except CredentialsNotFoundError:
129
- logger.warning(
130
- "auth_token_not_found",
131
- message="No authentication credentials found. Please run 'ccproxy auth login' to authenticate.",
132
- searched_paths=settings.auth.storage.storage_paths,
133
- )
134
- except Exception as e:
135
- logger.error(
136
- "auth_token_validation_error",
137
- error=str(e),
138
- message="Failed to validate authentication token. The server will continue without authentication.",
139
- exc_info=True,
140
- )
141
-
142
- # Validate Claude binary at startup using the new function
143
- try:
144
- claude_info = await get_claude_cli_info()
145
-
146
- if claude_info.status == "available":
147
- logger.info(
148
- "claude_cli_available",
149
- status=claude_info.status,
150
- version=claude_info.version,
151
- binary_path=claude_info.binary_path,
152
- )
153
- else:
154
- logger.warning(
155
- "claude_cli_unavailable",
156
- status=claude_info.status,
157
- error=claude_info.error,
158
- binary_path=claude_info.binary_path,
159
- message=f"Claude CLI status: {claude_info.status}",
160
- )
161
- except Exception as e:
162
- logger.error(
163
- "claude_cli_check_failed",
164
- error=str(e),
165
- message="Failed to check Claude CLI status during startup",
166
- )
167
-
168
- # Initialize ClaudeSDKService and store in app state
169
- try:
170
- # Create auth manager with settings
171
- auth_manager = CredentialsAuthManager()
172
-
173
- # Get global metrics instance
174
- metrics = get_metrics()
175
-
176
- # Create ClaudeSDKService instance
177
- claude_service = ClaudeSDKService(
178
- auth_manager=auth_manager,
179
- metrics=metrics,
180
- settings=settings,
181
- )
182
-
183
- # Store in app state for reuse in dependencies
184
- app.state.claude_service = claude_service
185
- logger.debug("claude_sdk_service_initialized")
186
- except Exception as e:
187
- logger.error("claude_sdk_service_initialization_failed", error=str(e))
188
- # Continue startup even if ClaudeSDKService fails (graceful degradation)
189
-
190
- # Start scheduler system
191
- try:
192
- scheduler = await start_scheduler(settings)
193
- app.state.scheduler = scheduler
194
- logger.debug("scheduler_initialized")
195
- except SchedulerError as e:
196
- logger.error("scheduler_initialization_failed", error=str(e))
197
- # Continue startup even if scheduler fails (graceful degradation)
198
-
199
- # Initialize log storage if needed and backend is duckdb
200
- if (
201
- settings.observability.needs_storage_backend
202
- and settings.observability.log_storage_backend == "duckdb"
203
- ):
204
- try:
205
- storage = SimpleDuckDBStorage(
206
- database_path=settings.observability.duckdb_path
207
- )
208
- await storage.initialize()
209
- app.state.log_storage = storage
210
- logger.debug(
211
- "log_storage_initialized",
212
- backend="duckdb",
213
- path=str(settings.observability.duckdb_path),
214
- collection_enabled=settings.observability.logs_collection_enabled,
215
- )
216
- except Exception as e:
217
- logger.error("log_storage_initialization_failed", error=str(e))
218
- # Continue without log storage (graceful degradation)
219
-
220
- # Initialize permission service
221
- try:
222
- permission_service = get_permission_service()
223
-
224
- # Only connect terminal handler if not using external handler
225
- if settings.server.use_terminal_permission_handler:
226
- # terminal_handler = TerminalPermissionHandler()
227
-
228
- # TODO: Terminal handler should subscribe to events from the service
229
- # instead of trying to set a handler directly
230
- # The service uses an event-based architecture, not direct handlers
231
-
232
- # logger.info(
233
- # "permission_handler_configured",
234
- # handler_type="terminal",
235
- # message="Connected terminal handler to permission service",
236
- # )
237
- # app.state.terminal_handler = terminal_handler
238
- pass
239
- else:
240
- logger.debug(
241
- "permission_handler_configured",
242
- handler_type="external_sse",
243
- message="Terminal permission handler disabled - use 'ccproxy permission-handler connect' to handle permissions",
244
- )
245
- logger.warning(
246
- "permission_handler_required",
247
- message="Start external handler with: ccproxy permission-handler connect",
248
- )
249
-
250
- # Start the permission service
251
- await permission_service.start()
252
-
253
- # Store references in app state
254
- app.state.permission_service = permission_service
255
-
256
- logger.debug(
257
- "permission_service_initialized",
258
- timeout_seconds=permission_service._timeout_seconds,
259
- terminal_handler_enabled=settings.server.use_terminal_permission_handler,
260
- )
261
- except Exception as e:
262
- logger.error("permission_service_initialization_failed", error=str(e))
263
- # Continue without permission service (API will work but without prompts)
172
+ # Continue with graceful degradation
264
173
 
265
174
  yield
266
175
 
267
176
  # Shutdown
268
177
  logger.debug("server_stop")
269
178
 
270
- # Flush any remaining streaming log batches
271
- try:
272
- from ccproxy.utils.simple_request_logger import flush_all_streaming_batches
273
-
274
- await flush_all_streaming_batches()
275
- logger.debug("streaming_batches_flushed")
276
- except Exception as e:
277
- logger.error("streaming_batches_flush_failed", error=str(e))
278
-
279
- # Stop scheduler system
280
- try:
281
- scheduler = getattr(app.state, "scheduler", None)
282
- await stop_scheduler(scheduler)
283
- logger.debug("scheduler_stopped_lifespan")
284
- except SchedulerError as e:
285
- logger.error("scheduler_stop_failed", error=str(e))
286
-
287
- # Stop permission service
288
- if hasattr(app.state, "permission_service") and app.state.permission_service:
289
- try:
290
- await app.state.permission_service.stop()
291
- logger.debug("permission_service_stopped")
292
- except Exception as e:
293
- logger.error("permission_service_stop_failed", error=str(e))
294
-
295
- # Close log storage if initialized
296
- if hasattr(app.state, "log_storage") and app.state.log_storage:
297
- try:
298
- await app.state.log_storage.close()
299
- logger.debug("log_storage_closed")
300
- except Exception as e:
301
- logger.error("log_storage_close_failed", error=str(e))
179
+ # Execute shutdown-only components first
180
+ for shutdown_component in SHUTDOWN_ONLY_COMPONENTS:
181
+ if shutdown_component["shutdown"]:
182
+ component_name = shutdown_component["name"]
183
+ try:
184
+ logger.debug(f"stopping_{component_name.lower().replace(' ', '_')}")
185
+ await shutdown_component["shutdown"](app)
186
+ except Exception as e:
187
+ logger.error(
188
+ f"{component_name.lower().replace(' ', '_')}_shutdown_failed",
189
+ error=str(e),
190
+ component=component_name,
191
+ )
192
+
193
+ # Execute shutdown components in reverse order
194
+ for component in reversed(LIFECYCLE_COMPONENTS):
195
+ if component["shutdown"]:
196
+ component_name = component["name"]
197
+ try:
198
+ logger.debug(f"stopping_{component_name.lower().replace(' ', '_')}")
199
+ # Some shutdown functions need settings, others don't
200
+ if component_name == "Permission Service":
201
+ await component["shutdown"](app, settings) # type: ignore
202
+ else:
203
+ await component["shutdown"](app) # type: ignore
204
+ except Exception as e:
205
+ logger.error(
206
+ f"{component_name.lower().replace(' ', '_')}_shutdown_failed",
207
+ error=str(e),
208
+ component=component_name,
209
+ )
302
210
 
303
211
 
304
212
  def create_app(settings: Settings | None = None) -> FastAPI:
@@ -346,13 +254,13 @@ def create_app(settings: Settings | None = None) -> FastAPI:
346
254
  setup_cors_middleware(app, settings)
347
255
  setup_error_handlers(app)
348
256
 
349
- # Add request content logging middleware first (will run third due to middleware order)
257
+ # Add request content logging middleware first (will run fourth due to middleware order)
350
258
  app.add_middleware(RequestContentLoggingMiddleware)
351
259
 
352
- # Add custom access log middleware second (will run second due to middleware order)
260
+ # Add custom access log middleware second (will run third due to middleware order)
353
261
  app.add_middleware(AccessLogMiddleware)
354
262
 
355
- # Add request ID middleware third (will run first to initialize context)
263
+ # Add request ID middleware fourth (will run first to initialize context)
356
264
  app.add_middleware(RequestIDMiddleware)
357
265
 
358
266
  # Add server header middleware (for non-proxy routes)
@@ -384,10 +292,12 @@ def create_app(settings: Settings | None = None) -> FastAPI:
384
292
  app.include_router(models_router, prefix="/sdk", tags=["claude-sdk", "models"])
385
293
  app.include_router(models_router, prefix="/api", tags=["proxy-api", "models"])
386
294
 
387
- # Confirmation endpoints for SSE streaming and responses
388
- app.include_router(permissions_router, prefix="/permissions", tags=["permissions"])
389
-
390
- setup_mcp(app)
295
+ # Confirmation endpoints for SSE streaming and responses (conditional on builtin_permissions)
296
+ if settings.claude.builtin_permissions:
297
+ app.include_router(
298
+ permissions_router, prefix="/permissions", tags=["permissions"]
299
+ )
300
+ setup_mcp(app)
391
301
 
392
302
  # Mount static files for dashboard SPA
393
303
  from pathlib import Path
@@ -1,5 +1,7 @@
1
1
  """Shared dependencies for CCProxy API Server."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  from typing import Annotated
4
6
 
5
7
  from fastapi import Depends, Request
@@ -97,10 +99,25 @@ def get_claude_service(
97
99
  # Get global metrics instance
98
100
  metrics = get_metrics()
99
101
 
102
+ # Check if pooling should be enabled from configuration
103
+ use_pool = settings.claude.sdk_session_pool.enabled
104
+ session_manager = None
105
+
106
+ if use_pool:
107
+ logger.info(
108
+ "claude_sdk_pool_enabled",
109
+ message="Using Claude SDK client pooling for improved performance",
110
+ pool_size=settings.claude.sdk_session_pool.max_sessions,
111
+ max_pool_size=settings.claude.sdk_session_pool.max_sessions,
112
+ )
113
+ # Note: Session manager should be created in the lifespan function, not here
114
+ # This dependency function should not create stateful resources
115
+
100
116
  return ClaudeSDKService(
101
117
  auth_manager=auth_manager,
102
118
  metrics=metrics,
103
119
  settings=settings,
120
+ session_manager=session_manager,
104
121
  )
105
122
 
106
123
 
@@ -120,6 +137,7 @@ def get_credentials_manager(
120
137
 
121
138
 
122
139
  def get_proxy_service(
140
+ request: Request,
123
141
  settings: SettingsDep,
124
142
  credentials_manager: Annotated[
125
143
  CredentialsManager, Depends(get_credentials_manager)
@@ -128,13 +146,14 @@ def get_proxy_service(
128
146
  """Get proxy service instance.
129
147
 
130
148
  Args:
149
+ request: FastAPI request object (for app state access)
131
150
  settings: Application settings dependency
132
151
  credentials_manager: Credentials manager dependency
133
152
 
134
153
  Returns:
135
154
  Proxy service instance
136
155
  """
137
- logger.debug("Creating proxy service instance")
156
+ logger.debug("get_proxy_service")
138
157
  # Create HTTP client for proxy
139
158
  from ccproxy.core.http import HTTPXClient
140
159
 
@@ -151,6 +170,7 @@ def get_proxy_service(
151
170
  proxy_mode="full",
152
171
  target_base_url=settings.reverse_proxy.target_url,
153
172
  metrics=metrics,
173
+ app_state=request.app.state, # Pass app state for detection data access
154
174
  )
155
175
 
156
176
 
@@ -160,7 +180,7 @@ def get_observability_metrics() -> PrometheusMetrics:
160
180
  Returns:
161
181
  PrometheusMetrics instance
162
182
  """
163
- logger.debug("Getting observability metrics instance")
183
+ logger.debug("get_observability_metrics")
164
184
  return get_metrics()
165
185
 
166
186
 
@@ -50,6 +50,12 @@ def setup_error_handlers(app: FastAPI) -> None:
50
50
  request: Request, exc: ClaudeProxyError
51
51
  ) -> JSONResponse:
52
52
  """Handle Claude proxy specific errors."""
53
+ # Store status code in request state for access logging
54
+ if hasattr(request.state, "context") and hasattr(
55
+ request.state.context, "metadata"
56
+ ):
57
+ request.state.context.metadata["status_code"] = exc.status_code
58
+
53
59
  logger.error(
54
60
  "Claude proxy error",
55
61
  error_type="claude_proxy_error",
@@ -82,6 +88,12 @@ def setup_error_handlers(app: FastAPI) -> None:
82
88
  request: Request, exc: ValidationError
83
89
  ) -> JSONResponse:
84
90
  """Handle validation errors."""
91
+ # Store status code in request state for access logging
92
+ if hasattr(request.state, "context") and hasattr(
93
+ request.state.context, "metadata"
94
+ ):
95
+ request.state.context.metadata["status_code"] = 400
96
+
85
97
  logger.error(
86
98
  "Validation error",
87
99
  error_type="validation_error",
@@ -565,9 +577,15 @@ def setup_error_handlers(app: FastAPI) -> None:
565
577
  request: Request, exc: HTTPException
566
578
  ) -> JSONResponse:
567
579
  """Handle HTTP exceptions."""
580
+ # Store status code in request state for access logging
581
+ if hasattr(request.state, "context") and hasattr(
582
+ request.state.context, "metadata"
583
+ ):
584
+ request.state.context.metadata["status_code"] = exc.status_code
585
+
568
586
  # Don't log stack trace for 404 errors as they're expected
569
587
  if exc.status_code == 404:
570
- logger.info(
588
+ logger.debug(
571
589
  "HTTP 404 error",
572
590
  error_type="http_404",
573
591
  error_message=exc.detail,
@@ -581,7 +599,7 @@ def setup_error_handlers(app: FastAPI) -> None:
581
599
  # For structlog, we can always include traceback since structlog handles filtering
582
600
  import traceback
583
601
 
584
- stack_trace = traceback.format_exc()
602
+ stack_trace = traceback.format_exc(limit=5) # Limit to 5 frames
585
603
 
586
604
  logger.error(
587
605
  "HTTP exception",
@@ -621,7 +639,7 @@ def setup_error_handlers(app: FastAPI) -> None:
621
639
  """Handle Starlette HTTP exceptions."""
622
640
  # Don't log stack trace for 404 errors as they're expected
623
641
  if exc.status_code == 404:
624
- logger.info(
642
+ logger.debug(
625
643
  "Starlette HTTP 404 error",
626
644
  error_type="starlette_http_404",
627
645
  error_message=exc.detail,
@@ -668,6 +686,12 @@ def setup_error_handlers(app: FastAPI) -> None:
668
686
  request: Request, exc: Exception
669
687
  ) -> JSONResponse:
670
688
  """Handle all other unhandled exceptions."""
689
+ # Store status code in request state for access logging
690
+ if hasattr(request.state, "context") and hasattr(
691
+ request.state.context, "metadata"
692
+ ):
693
+ request.state.context.metadata["status_code"] = 500
694
+
671
695
  logger.error(
672
696
  "Unhandled exception",
673
697
  error_type="unhandled_exception",
@@ -111,6 +111,10 @@ class AccessLogMiddleware(BaseHTTPMiddleware):
111
111
  if anthropic_request_id:
112
112
  rate_limit_info["anthropic_request_id"] = anthropic_request_id
113
113
 
114
+ headers = request.state.context.metadata.get("headers", {})
115
+ headers.update(rate_limit_info)
116
+ request.state.context.metadata["headers"] = headers
117
+ request.state.context.metadata["status_code"] = status_code
114
118
  # Extract metadata from context if available
115
119
  context_metadata = {}
116
120
  try:
ccproxy/api/responses.py CHANGED
@@ -48,14 +48,19 @@ class ProxyResponse(Response):
48
48
  """
49
49
  # Ensure we include all original headers, including 'server'
50
50
  headers_list = []
51
+ seen_headers = set()
51
52
 
52
53
  # Add all headers from the response, but skip content-length
53
54
  # as we'll recalculate it based on actual body
54
55
  for name, value in self._preserve_headers.items():
55
56
  lower_name = name.lower()
56
57
  # Skip content-length and transfer-encoding as we'll set them correctly
57
- if lower_name not in ["content-length", "transfer-encoding"]:
58
+ if (
59
+ lower_name not in ["content-length", "transfer-encoding"]
60
+ and lower_name not in seen_headers
61
+ ):
58
62
  headers_list.append((lower_name.encode(), value.encode()))
63
+ seen_headers.add(lower_name)
59
64
 
60
65
  # Always set correct content-length based on actual body
61
66
  if self.body: