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.
- 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 +334 -131
- 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.3.dist-info → ccproxy_api-0.1.5.dist-info}/METADATA +29 -2
- {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/RECORD +53 -41
- ccproxy/config/loader.py +0 -105
- {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/WHEEL +0 -0
- {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/entry_points.txt +0 -0
- {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/licenses/LICENSE +0 -0
ccproxy/_version.py
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
ccproxy/api/dependencies.py
CHANGED
|
@@ -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("
|
|
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("
|
|
183
|
+
logger.debug("get_observability_metrics")
|
|
164
184
|
return get_metrics()
|
|
165
185
|
|
|
166
186
|
|
ccproxy/api/middleware/errors.py
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
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:
|