ccproxy-api 0.1.4__py3-none-any.whl → 0.1.6__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/codex/__init__.py +11 -0
- ccproxy/adapters/openai/adapter.py +1 -1
- ccproxy/adapters/openai/models.py +1 -1
- ccproxy/adapters/openai/response_adapter.py +355 -0
- ccproxy/adapters/openai/response_models.py +178 -0
- ccproxy/adapters/openai/streaming.py +1 -0
- ccproxy/api/app.py +150 -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/codex.py +1231 -0
- ccproxy/api/routes/health.py +228 -3
- ccproxy/api/routes/proxy.py +25 -6
- ccproxy/api/services/permission_service.py +2 -2
- ccproxy/auth/openai/__init__.py +13 -0
- ccproxy/auth/openai/credentials.py +166 -0
- ccproxy/auth/openai/oauth_client.py +334 -0
- ccproxy/auth/openai/storage.py +184 -0
- 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 +6 -1
- 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/auth.py +398 -1
- ccproxy/cli/commands/serve.py +99 -1
- ccproxy/cli/options/claude_options.py +47 -0
- ccproxy/config/__init__.py +0 -3
- ccproxy/config/claude.py +171 -23
- ccproxy/config/codex.py +100 -0
- ccproxy/config/discovery.py +10 -1
- ccproxy/config/scheduler.py +2 -2
- ccproxy/config/settings.py +38 -1
- ccproxy/core/codex_transformers.py +389 -0
- ccproxy/core/http_transformers.py +458 -75
- ccproxy/core/logging.py +108 -12
- ccproxy/core/transformers.py +5 -0
- ccproxy/models/claude_sdk.py +57 -0
- ccproxy/models/detection.py +208 -0
- ccproxy/models/requests.py +22 -0
- ccproxy/models/responses.py +16 -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/codex_detection_service.py +263 -0
- ccproxy/services/proxy_service.py +618 -197
- ccproxy/utils/__init__.py +9 -1
- ccproxy/utils/disconnection_monitor.py +83 -0
- ccproxy/utils/id_generator.py +12 -0
- ccproxy/utils/model_mapping.py +7 -5
- ccproxy/utils/startup_helpers.py +470 -0
- ccproxy_api-0.1.6.dist-info/METADATA +615 -0
- {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/RECORD +70 -47
- ccproxy/config/loader.py +0 -105
- ccproxy_api-0.1.4.dist-info/METADATA +0 -369
- {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/WHEEL +0 -0
- {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/entry_points.txt +0 -0
- {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/licenses/LICENSE +0 -0
ccproxy/utils/__init__.py
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
"""Utility modules for shared functionality across the application."""
|
|
2
2
|
|
|
3
3
|
from .cost_calculator import calculate_cost_breakdown, calculate_token_cost
|
|
4
|
+
from .disconnection_monitor import monitor_disconnection, monitor_stuck_stream
|
|
5
|
+
from .id_generator import generate_client_id
|
|
4
6
|
|
|
5
7
|
|
|
6
|
-
__all__ = [
|
|
8
|
+
__all__ = [
|
|
9
|
+
"calculate_token_cost",
|
|
10
|
+
"calculate_cost_breakdown",
|
|
11
|
+
"monitor_disconnection",
|
|
12
|
+
"monitor_stuck_stream",
|
|
13
|
+
"generate_client_id",
|
|
14
|
+
]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Utility functions for monitoring client disconnection and stuck streams during streaming responses."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
import structlog
|
|
7
|
+
from starlette.requests import Request
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ccproxy.services.claude_sdk_service import ClaudeSDKService
|
|
12
|
+
|
|
13
|
+
logger = structlog.get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def monitor_disconnection(
|
|
17
|
+
request: Request, session_id: str, claude_service: "ClaudeSDKService"
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Monitor for client disconnection and interrupt session if detected.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
request: The incoming HTTP request
|
|
23
|
+
session_id: The Claude SDK session ID to interrupt if disconnected
|
|
24
|
+
claude_service: The Claude SDK service instance
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
while True:
|
|
28
|
+
await asyncio.sleep(1.0) # Check every second
|
|
29
|
+
if await request.is_disconnected():
|
|
30
|
+
logger.info(
|
|
31
|
+
"client_disconnected_interrupting_session", session_id=session_id
|
|
32
|
+
)
|
|
33
|
+
try:
|
|
34
|
+
await claude_service.sdk_client.interrupt_session(session_id)
|
|
35
|
+
except Exception as e:
|
|
36
|
+
logger.error(
|
|
37
|
+
"failed_to_interrupt_session",
|
|
38
|
+
session_id=session_id,
|
|
39
|
+
error=str(e),
|
|
40
|
+
)
|
|
41
|
+
return
|
|
42
|
+
except asyncio.CancelledError:
|
|
43
|
+
# Task was cancelled, which is expected when streaming completes normally
|
|
44
|
+
logger.debug("disconnection_monitor_cancelled", session_id=session_id)
|
|
45
|
+
raise
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def monitor_stuck_stream(
|
|
49
|
+
session_id: str,
|
|
50
|
+
claude_service: "ClaudeSDKService",
|
|
51
|
+
first_chunk_event: asyncio.Event,
|
|
52
|
+
timeout: float = 10.0,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Monitor for stuck streams that don't produce a first chunk (SystemMessage).
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
session_id: The Claude SDK session ID to monitor
|
|
58
|
+
claude_service: The Claude SDK service instance
|
|
59
|
+
first_chunk_event: Event that will be set when first chunk is received
|
|
60
|
+
timeout: Seconds to wait for first chunk before considering stream stuck
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
# Wait for first chunk with timeout
|
|
64
|
+
await asyncio.wait_for(first_chunk_event.wait(), timeout=timeout)
|
|
65
|
+
logger.debug("stuck_stream_first_chunk_received", session_id=session_id)
|
|
66
|
+
except TimeoutError:
|
|
67
|
+
logger.error(
|
|
68
|
+
"streaming_system_message_timeout",
|
|
69
|
+
session_id=session_id,
|
|
70
|
+
timeout=timeout,
|
|
71
|
+
message=f"No SystemMessage received within {timeout}s, interrupting session",
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
await claude_service.sdk_client.interrupt_session(session_id)
|
|
75
|
+
logger.info("stuck_session_interrupted_successfully", session_id=session_id)
|
|
76
|
+
except Exception as e:
|
|
77
|
+
logger.error(
|
|
78
|
+
"failed_to_interrupt_stuck_session", session_id=session_id, error=str(e)
|
|
79
|
+
)
|
|
80
|
+
except asyncio.CancelledError:
|
|
81
|
+
# Task was cancelled, which is expected when streaming completes normally
|
|
82
|
+
logger.debug("stuck_stream_monitor_cancelled", session_id=session_id)
|
|
83
|
+
raise
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Utility functions for generating consistent IDs across the application."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def generate_client_id() -> str:
|
|
7
|
+
"""Generate a consistent client ID for SDK connections.
|
|
8
|
+
|
|
9
|
+
Returns:
|
|
10
|
+
str: First part of a UUID4 (8 characters)
|
|
11
|
+
"""
|
|
12
|
+
return str(uuid.uuid4()).split("-")[0]
|
ccproxy/utils/model_mapping.py
CHANGED
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
# Combined mapping: OpenAI models → Claude models AND Claude aliases → canonical Claude models
|
|
11
11
|
MODEL_MAPPING: dict[str, str] = {
|
|
12
|
+
"gpt-5": "claude-sonnet-4-20250514",
|
|
12
13
|
# OpenAI GPT-4 models → Claude 3.5 Sonnet (most comparable)
|
|
13
14
|
"gpt-4": "claude-3-5-sonnet-20241022",
|
|
14
15
|
"gpt-4-turbo": "claude-3-5-sonnet-20241022",
|
|
@@ -80,11 +81,12 @@ def map_model_to_claude(model_name: str) -> str:
|
|
|
80
81
|
return "claude-3-7-sonnet-20250219"
|
|
81
82
|
elif model_name.startswith("gpt-3.5"):
|
|
82
83
|
return "claude-3-5-haiku-latest"
|
|
83
|
-
elif
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
elif (
|
|
85
|
+
model_name.startswith("o1")
|
|
86
|
+
or model_name.startswith("gpt-5")
|
|
87
|
+
or model_name.startswith("o3")
|
|
88
|
+
or model_name.startswith("gpt")
|
|
89
|
+
):
|
|
88
90
|
return "claude-sonnet-4-20250514"
|
|
89
91
|
|
|
90
92
|
# If it's already a Claude model, pass through unchanged
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""Startup utility functions for application lifecycle management.
|
|
2
|
+
|
|
3
|
+
This module contains simple utility functions to extract and organize
|
|
4
|
+
the complex startup logic from the main lifespan function, following
|
|
5
|
+
the KISS principle and avoiding overengineering.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
import structlog
|
|
14
|
+
from fastapi import FastAPI
|
|
15
|
+
|
|
16
|
+
from ccproxy.auth.credentials_adapter import CredentialsAuthManager
|
|
17
|
+
from ccproxy.auth.exceptions import CredentialsNotFoundError
|
|
18
|
+
from ccproxy.observability import get_metrics
|
|
19
|
+
|
|
20
|
+
# Note: get_claude_cli_info is imported locally to avoid circular imports
|
|
21
|
+
from ccproxy.observability.storage.duckdb_simple import SimpleDuckDBStorage
|
|
22
|
+
from ccproxy.scheduler.errors import SchedulerError
|
|
23
|
+
from ccproxy.scheduler.manager import start_scheduler, stop_scheduler
|
|
24
|
+
from ccproxy.services.claude_detection_service import ClaudeDetectionService
|
|
25
|
+
from ccproxy.services.claude_sdk_service import ClaudeSDKService
|
|
26
|
+
from ccproxy.services.codex_detection_service import CodexDetectionService
|
|
27
|
+
from ccproxy.services.credentials.manager import CredentialsManager
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Note: get_permission_service is imported locally to avoid circular imports
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from ccproxy.config.settings import Settings
|
|
34
|
+
|
|
35
|
+
logger = structlog.get_logger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def validate_authentication_startup(app: FastAPI, settings: Settings) -> None:
|
|
39
|
+
"""Validate authentication credentials at startup.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
app: FastAPI application instance
|
|
43
|
+
settings: Application settings
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
credentials_manager = CredentialsManager()
|
|
47
|
+
validation = await credentials_manager.validate()
|
|
48
|
+
|
|
49
|
+
if validation.valid and not validation.expired:
|
|
50
|
+
credentials = validation.credentials
|
|
51
|
+
oauth_token = credentials.claude_ai_oauth if credentials else None
|
|
52
|
+
|
|
53
|
+
if oauth_token and oauth_token.expires_at_datetime:
|
|
54
|
+
hours_until_expiry = int(
|
|
55
|
+
(
|
|
56
|
+
oauth_token.expires_at_datetime - datetime.now(UTC)
|
|
57
|
+
).total_seconds()
|
|
58
|
+
/ 3600
|
|
59
|
+
)
|
|
60
|
+
logger.debug(
|
|
61
|
+
"auth_token_valid",
|
|
62
|
+
expires_in_hours=hours_until_expiry,
|
|
63
|
+
subscription_type=oauth_token.subscription_type,
|
|
64
|
+
credentials_path=str(validation.path) if validation.path else None,
|
|
65
|
+
)
|
|
66
|
+
else:
|
|
67
|
+
logger.debug("auth_token_valid", credentials_path=str(validation.path))
|
|
68
|
+
elif validation.expired:
|
|
69
|
+
logger.warning(
|
|
70
|
+
"auth_token_expired",
|
|
71
|
+
message="Authentication token has expired. Please run 'ccproxy auth login' to refresh.",
|
|
72
|
+
credentials_path=str(validation.path) if validation.path else None,
|
|
73
|
+
)
|
|
74
|
+
else:
|
|
75
|
+
logger.warning(
|
|
76
|
+
"auth_token_invalid",
|
|
77
|
+
message="Authentication token is invalid. Please run 'ccproxy auth login'.",
|
|
78
|
+
credentials_path=str(validation.path) if validation.path else None,
|
|
79
|
+
)
|
|
80
|
+
except CredentialsNotFoundError:
|
|
81
|
+
logger.warning(
|
|
82
|
+
"auth_token_not_found",
|
|
83
|
+
message="No authentication credentials found. Please run 'ccproxy auth login' to authenticate.",
|
|
84
|
+
searched_paths=settings.auth.storage.storage_paths,
|
|
85
|
+
)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
logger.error(
|
|
88
|
+
"auth_token_validation_error",
|
|
89
|
+
error=str(e),
|
|
90
|
+
message="Failed to validate authentication token. The server will continue without authentication.",
|
|
91
|
+
exc_info=True,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def check_claude_cli_startup(app: FastAPI, settings: Settings) -> None:
|
|
96
|
+
"""Check Claude CLI availability at startup.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
app: FastAPI application instance
|
|
100
|
+
settings: Application settings
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
from ccproxy.api.routes.health import get_claude_cli_info
|
|
104
|
+
|
|
105
|
+
claude_info = await get_claude_cli_info()
|
|
106
|
+
|
|
107
|
+
if claude_info.status == "available":
|
|
108
|
+
logger.info(
|
|
109
|
+
"claude_cli_available",
|
|
110
|
+
status=claude_info.status,
|
|
111
|
+
version=claude_info.version,
|
|
112
|
+
binary_path=claude_info.binary_path,
|
|
113
|
+
)
|
|
114
|
+
else:
|
|
115
|
+
logger.warning(
|
|
116
|
+
"claude_cli_unavailable",
|
|
117
|
+
status=claude_info.status,
|
|
118
|
+
error=claude_info.error,
|
|
119
|
+
binary_path=claude_info.binary_path,
|
|
120
|
+
message=f"Claude CLI status: {claude_info.status}",
|
|
121
|
+
)
|
|
122
|
+
except Exception as e:
|
|
123
|
+
logger.error(
|
|
124
|
+
"claude_cli_check_failed",
|
|
125
|
+
error=str(e),
|
|
126
|
+
message="Failed to check Claude CLI status during startup",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def check_codex_cli_startup(app: FastAPI, settings: Settings) -> None:
|
|
131
|
+
"""Check Codex CLI availability at startup.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
app: FastAPI application instance
|
|
135
|
+
settings: Application settings
|
|
136
|
+
"""
|
|
137
|
+
try:
|
|
138
|
+
from ccproxy.api.routes.health import get_codex_cli_info
|
|
139
|
+
|
|
140
|
+
codex_info = await get_codex_cli_info()
|
|
141
|
+
|
|
142
|
+
if codex_info.status == "available":
|
|
143
|
+
logger.info(
|
|
144
|
+
"codex_cli_available",
|
|
145
|
+
status=codex_info.status,
|
|
146
|
+
version=codex_info.version,
|
|
147
|
+
binary_path=codex_info.binary_path,
|
|
148
|
+
)
|
|
149
|
+
else:
|
|
150
|
+
logger.warning(
|
|
151
|
+
"codex_cli_unavailable",
|
|
152
|
+
status=codex_info.status,
|
|
153
|
+
error=codex_info.error,
|
|
154
|
+
binary_path=codex_info.binary_path,
|
|
155
|
+
message=f"Codex CLI status: {codex_info.status}",
|
|
156
|
+
)
|
|
157
|
+
except Exception as e:
|
|
158
|
+
logger.error(
|
|
159
|
+
"codex_cli_check_failed",
|
|
160
|
+
error=str(e),
|
|
161
|
+
message="Failed to check Codex CLI status during startup",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def initialize_log_storage_startup(app: FastAPI, settings: Settings) -> None:
|
|
166
|
+
"""Initialize log storage if needed and backend is DuckDB.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
app: FastAPI application instance
|
|
170
|
+
settings: Application settings
|
|
171
|
+
"""
|
|
172
|
+
if (
|
|
173
|
+
settings.observability.needs_storage_backend
|
|
174
|
+
and settings.observability.log_storage_backend == "duckdb"
|
|
175
|
+
):
|
|
176
|
+
try:
|
|
177
|
+
storage = SimpleDuckDBStorage(
|
|
178
|
+
database_path=settings.observability.duckdb_path
|
|
179
|
+
)
|
|
180
|
+
await storage.initialize()
|
|
181
|
+
app.state.log_storage = storage
|
|
182
|
+
logger.debug(
|
|
183
|
+
"log_storage_initialized",
|
|
184
|
+
backend="duckdb",
|
|
185
|
+
path=str(settings.observability.duckdb_path),
|
|
186
|
+
collection_enabled=settings.observability.logs_collection_enabled,
|
|
187
|
+
)
|
|
188
|
+
except Exception as e:
|
|
189
|
+
logger.error("log_storage_initialization_failed", error=str(e))
|
|
190
|
+
# Continue without log storage (graceful degradation)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
async def initialize_log_storage_shutdown(app: FastAPI) -> None:
|
|
194
|
+
"""Close log storage if initialized.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
app: FastAPI application instance
|
|
198
|
+
"""
|
|
199
|
+
if hasattr(app.state, "log_storage") and app.state.log_storage:
|
|
200
|
+
try:
|
|
201
|
+
await app.state.log_storage.close()
|
|
202
|
+
logger.debug("log_storage_closed")
|
|
203
|
+
except Exception as e:
|
|
204
|
+
logger.error("log_storage_close_failed", error=str(e))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def setup_scheduler_startup(app: FastAPI, settings: Settings) -> None:
|
|
208
|
+
"""Start scheduler system and configure tasks.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
app: FastAPI application instance
|
|
212
|
+
settings: Application settings
|
|
213
|
+
"""
|
|
214
|
+
try:
|
|
215
|
+
scheduler = await start_scheduler(settings)
|
|
216
|
+
app.state.scheduler = scheduler
|
|
217
|
+
logger.debug("scheduler_initialized")
|
|
218
|
+
|
|
219
|
+
# Add session pool stats task if session manager is available
|
|
220
|
+
if (
|
|
221
|
+
scheduler
|
|
222
|
+
and hasattr(app.state, "session_manager")
|
|
223
|
+
and app.state.session_manager
|
|
224
|
+
):
|
|
225
|
+
try:
|
|
226
|
+
# Add session pool stats task that runs every minute
|
|
227
|
+
await scheduler.add_task(
|
|
228
|
+
task_name="session_pool_stats",
|
|
229
|
+
task_type="pool_stats",
|
|
230
|
+
interval_seconds=60, # Every minute
|
|
231
|
+
enabled=True,
|
|
232
|
+
pool_manager=app.state.session_manager,
|
|
233
|
+
)
|
|
234
|
+
logger.debug("session_pool_stats_task_added", interval_seconds=60)
|
|
235
|
+
except Exception as e:
|
|
236
|
+
logger.error(
|
|
237
|
+
"session_pool_stats_task_add_failed",
|
|
238
|
+
error=str(e),
|
|
239
|
+
error_type=type(e).__name__,
|
|
240
|
+
)
|
|
241
|
+
except SchedulerError as e:
|
|
242
|
+
logger.error("scheduler_initialization_failed", error=str(e))
|
|
243
|
+
# Continue startup even if scheduler fails (graceful degradation)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
async def setup_scheduler_shutdown(app: FastAPI) -> None:
|
|
247
|
+
"""Stop scheduler system.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
app: FastAPI application instance
|
|
251
|
+
"""
|
|
252
|
+
try:
|
|
253
|
+
scheduler = getattr(app.state, "scheduler", None)
|
|
254
|
+
await stop_scheduler(scheduler)
|
|
255
|
+
logger.debug("scheduler_stopped_lifespan")
|
|
256
|
+
except SchedulerError as e:
|
|
257
|
+
logger.error("scheduler_stop_failed", error=str(e))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
async def setup_session_manager_shutdown(app: FastAPI) -> None:
|
|
261
|
+
"""Shutdown Claude SDK session manager if it was created.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
app: FastAPI application instance
|
|
265
|
+
"""
|
|
266
|
+
if hasattr(app.state, "session_manager") and app.state.session_manager:
|
|
267
|
+
try:
|
|
268
|
+
await app.state.session_manager.shutdown()
|
|
269
|
+
logger.debug("claude_sdk_session_manager_shutdown")
|
|
270
|
+
except Exception as e:
|
|
271
|
+
logger.error("claude_sdk_session_manager_shutdown_failed", error=str(e))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
async def initialize_claude_detection_startup(app: FastAPI, settings: Settings) -> None:
|
|
275
|
+
"""Initialize Claude detection service.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
app: FastAPI application instance
|
|
279
|
+
settings: Application settings
|
|
280
|
+
"""
|
|
281
|
+
try:
|
|
282
|
+
logger.debug("initializing_claude_detection")
|
|
283
|
+
detection_service = ClaudeDetectionService(settings)
|
|
284
|
+
claude_data = await detection_service.initialize_detection()
|
|
285
|
+
app.state.claude_detection_data = claude_data
|
|
286
|
+
app.state.claude_detection_service = detection_service
|
|
287
|
+
logger.debug(
|
|
288
|
+
"claude_detection_completed",
|
|
289
|
+
version=claude_data.claude_version,
|
|
290
|
+
cached_at=claude_data.cached_at.isoformat(),
|
|
291
|
+
)
|
|
292
|
+
except Exception as e:
|
|
293
|
+
logger.error("claude_detection_startup_failed", error=str(e))
|
|
294
|
+
# Continue startup with fallback - detection service will provide fallback data
|
|
295
|
+
detection_service = ClaudeDetectionService(settings)
|
|
296
|
+
app.state.claude_detection_data = detection_service._get_fallback_data()
|
|
297
|
+
app.state.claude_detection_service = detection_service
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
async def initialize_codex_detection_startup(app: FastAPI, settings: Settings) -> None:
|
|
301
|
+
"""Initialize Codex detection service.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
app: FastAPI application instance
|
|
305
|
+
settings: Application settings
|
|
306
|
+
"""
|
|
307
|
+
try:
|
|
308
|
+
logger.debug("initializing_codex_detection")
|
|
309
|
+
detection_service = CodexDetectionService(settings)
|
|
310
|
+
codex_data = await detection_service.initialize_detection()
|
|
311
|
+
app.state.codex_detection_data = codex_data
|
|
312
|
+
app.state.codex_detection_service = detection_service
|
|
313
|
+
logger.debug(
|
|
314
|
+
"codex_detection_completed",
|
|
315
|
+
version=codex_data.codex_version,
|
|
316
|
+
cached_at=codex_data.cached_at.isoformat(),
|
|
317
|
+
)
|
|
318
|
+
except Exception as e:
|
|
319
|
+
logger.error("codex_detection_startup_failed", error=str(e))
|
|
320
|
+
# Continue startup with fallback - detection service will provide fallback data
|
|
321
|
+
detection_service = CodexDetectionService(settings)
|
|
322
|
+
app.state.codex_detection_data = detection_service._get_fallback_data()
|
|
323
|
+
app.state.codex_detection_service = detection_service
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
async def initialize_claude_sdk_startup(app: FastAPI, settings: Settings) -> None:
|
|
327
|
+
"""Initialize ClaudeSDKService and store in app state.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
app: FastAPI application instance
|
|
331
|
+
settings: Application settings
|
|
332
|
+
"""
|
|
333
|
+
try:
|
|
334
|
+
# Create auth manager with settings
|
|
335
|
+
auth_manager = CredentialsAuthManager()
|
|
336
|
+
|
|
337
|
+
# Get global metrics instance
|
|
338
|
+
metrics = get_metrics()
|
|
339
|
+
|
|
340
|
+
# Check if session pool should be enabled from settings configuration
|
|
341
|
+
use_session_pool = settings.claude.sdk_session_pool.enabled
|
|
342
|
+
|
|
343
|
+
# Initialize session manager if session pool is enabled
|
|
344
|
+
session_manager = None
|
|
345
|
+
if use_session_pool:
|
|
346
|
+
from ccproxy.claude_sdk.manager import SessionManager
|
|
347
|
+
|
|
348
|
+
# Create SessionManager with dependency injection
|
|
349
|
+
session_manager = SessionManager(
|
|
350
|
+
settings=settings, metrics_factory=lambda: metrics
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
# Start the session manager (initializes session pool if enabled)
|
|
354
|
+
await session_manager.start()
|
|
355
|
+
|
|
356
|
+
# Create ClaudeSDKService instance
|
|
357
|
+
claude_service = ClaudeSDKService(
|
|
358
|
+
auth_manager=auth_manager,
|
|
359
|
+
metrics=metrics,
|
|
360
|
+
settings=settings,
|
|
361
|
+
session_manager=session_manager,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# Store in app state for reuse in dependencies
|
|
365
|
+
app.state.claude_service = claude_service
|
|
366
|
+
app.state.session_manager = (
|
|
367
|
+
session_manager # Store session_manager for shutdown
|
|
368
|
+
)
|
|
369
|
+
logger.debug("claude_sdk_service_initialized")
|
|
370
|
+
except Exception as e:
|
|
371
|
+
logger.error("claude_sdk_service_initialization_failed", error=str(e))
|
|
372
|
+
# Continue startup even if ClaudeSDKService fails (graceful degradation)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
async def initialize_permission_service_startup(
|
|
376
|
+
app: FastAPI, settings: Settings
|
|
377
|
+
) -> None:
|
|
378
|
+
"""Initialize permission service (conditional on builtin_permissions).
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
app: FastAPI application instance
|
|
382
|
+
settings: Application settings
|
|
383
|
+
"""
|
|
384
|
+
if settings.claude.builtin_permissions:
|
|
385
|
+
try:
|
|
386
|
+
from ccproxy.api.services.permission_service import get_permission_service
|
|
387
|
+
|
|
388
|
+
permission_service = get_permission_service()
|
|
389
|
+
|
|
390
|
+
# Only connect terminal handler if not using external handler
|
|
391
|
+
if settings.server.use_terminal_permission_handler:
|
|
392
|
+
# terminal_handler = TerminalPermissionHandler()
|
|
393
|
+
|
|
394
|
+
# TODO: Terminal handler should subscribe to events from the service
|
|
395
|
+
# instead of trying to set a handler directly
|
|
396
|
+
# The service uses an event-based architecture, not direct handlers
|
|
397
|
+
|
|
398
|
+
# logger.info(
|
|
399
|
+
# "permission_handler_configured",
|
|
400
|
+
# handler_type="terminal",
|
|
401
|
+
# message="Connected terminal handler to permission service",
|
|
402
|
+
# )
|
|
403
|
+
# app.state.terminal_handler = terminal_handler
|
|
404
|
+
pass
|
|
405
|
+
else:
|
|
406
|
+
logger.debug(
|
|
407
|
+
"permission_handler_configured",
|
|
408
|
+
handler_type="external_sse",
|
|
409
|
+
message="Terminal permission handler disabled - use 'ccproxy permission-handler connect' to handle permissions",
|
|
410
|
+
)
|
|
411
|
+
logger.warning(
|
|
412
|
+
"permission_handler_required",
|
|
413
|
+
message="Start external handler with: ccproxy permission-handler connect",
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
# Start the permission service
|
|
417
|
+
await permission_service.start()
|
|
418
|
+
|
|
419
|
+
# Store references in app state
|
|
420
|
+
app.state.permission_service = permission_service
|
|
421
|
+
|
|
422
|
+
logger.debug(
|
|
423
|
+
"permission_service_initialized",
|
|
424
|
+
timeout_seconds=permission_service._timeout_seconds,
|
|
425
|
+
terminal_handler_enabled=settings.server.use_terminal_permission_handler,
|
|
426
|
+
builtin_permissions_enabled=True,
|
|
427
|
+
)
|
|
428
|
+
except Exception as e:
|
|
429
|
+
logger.error("permission_service_initialization_failed", error=str(e))
|
|
430
|
+
# Continue without permission service (API will work but without prompts)
|
|
431
|
+
else:
|
|
432
|
+
logger.debug(
|
|
433
|
+
"permission_service_skipped",
|
|
434
|
+
builtin_permissions_enabled=False,
|
|
435
|
+
message="Built-in permission handling disabled - users can configure custom MCP servers and permission tools",
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
async def setup_permission_service_shutdown(app: FastAPI, settings: Settings) -> None:
|
|
440
|
+
"""Stop permission service (if it was initialized).
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
app: FastAPI application instance
|
|
444
|
+
settings: Application settings
|
|
445
|
+
"""
|
|
446
|
+
if (
|
|
447
|
+
hasattr(app.state, "permission_service")
|
|
448
|
+
and app.state.permission_service
|
|
449
|
+
and settings.claude.builtin_permissions
|
|
450
|
+
):
|
|
451
|
+
try:
|
|
452
|
+
await app.state.permission_service.stop()
|
|
453
|
+
logger.debug("permission_service_stopped")
|
|
454
|
+
except Exception as e:
|
|
455
|
+
logger.error("permission_service_stop_failed", error=str(e))
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
async def flush_streaming_batches_shutdown(app: FastAPI) -> None:
|
|
459
|
+
"""Flush any remaining streaming log batches.
|
|
460
|
+
|
|
461
|
+
Args:
|
|
462
|
+
app: FastAPI application instance
|
|
463
|
+
"""
|
|
464
|
+
try:
|
|
465
|
+
from ccproxy.utils.simple_request_logger import flush_all_streaming_batches
|
|
466
|
+
|
|
467
|
+
await flush_all_streaming_batches()
|
|
468
|
+
logger.debug("streaming_batches_flushed")
|
|
469
|
+
except Exception as e:
|
|
470
|
+
logger.error("streaming_batches_flush_failed", error=str(e))
|