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
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Claude SDK exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ClaudeSDKError(Exception):
|
|
5
|
+
"""Base Claude SDK error."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StreamTimeoutError(ClaudeSDKError):
|
|
11
|
+
"""Stream timeout error when no SDK message is received within timeout."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str, session_id: str, timeout_seconds: float):
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.session_id = session_id
|
|
16
|
+
self.timeout_seconds = timeout_seconds
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claude SDK Session Manager - Pure dependency injection architecture.
|
|
3
|
+
|
|
4
|
+
This module provides a SessionManager class that encapsulates session pool lifecycle
|
|
5
|
+
management using dependency injection patterns without any global state.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
# Type alias for metrics factory function
|
|
14
|
+
from typing import Any, TypeAlias
|
|
15
|
+
|
|
16
|
+
import structlog
|
|
17
|
+
from claude_code_sdk import ClaudeCodeOptions
|
|
18
|
+
|
|
19
|
+
from ccproxy.claude_sdk.session_client import SessionClient
|
|
20
|
+
from ccproxy.claude_sdk.session_pool import SessionPool
|
|
21
|
+
from ccproxy.config.settings import Settings
|
|
22
|
+
from ccproxy.core.errors import ClaudeProxyError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
logger = structlog.get_logger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
MetricsFactory: TypeAlias = Callable[[], Any | None]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SessionManager:
|
|
32
|
+
"""Manages the lifecycle of session-based Claude SDK clients with dependency injection."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
settings: Settings,
|
|
37
|
+
metrics_factory: MetricsFactory | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Initialize SessionManager with optional settings and metrics factory.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
settings: Optional settings containing session pool configuration
|
|
43
|
+
metrics_factory: Optional callable that returns a metrics instance.
|
|
44
|
+
If None, no metrics will be used.
|
|
45
|
+
"""
|
|
46
|
+
import structlog
|
|
47
|
+
|
|
48
|
+
logger = structlog.get_logger(__name__)
|
|
49
|
+
|
|
50
|
+
self._settings = settings
|
|
51
|
+
self._session_pool: SessionPool | None = None
|
|
52
|
+
self._lock = asyncio.Lock()
|
|
53
|
+
self._metrics_factory = metrics_factory
|
|
54
|
+
|
|
55
|
+
# Initialize session pool if enabled
|
|
56
|
+
session_pool_enabled = self._should_enable_session_pool()
|
|
57
|
+
logger.debug(
|
|
58
|
+
"session_manager_init",
|
|
59
|
+
has_settings=bool(settings),
|
|
60
|
+
has_metrics_factory=bool(metrics_factory),
|
|
61
|
+
session_pool_enabled=session_pool_enabled,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if session_pool_enabled:
|
|
65
|
+
self._session_pool = SessionPool(settings.claude.sdk_session_pool)
|
|
66
|
+
logger.info(
|
|
67
|
+
"session_manager_session_pool_initialized",
|
|
68
|
+
session_ttl=self._session_pool.config.session_ttl,
|
|
69
|
+
max_sessions=self._session_pool.config.max_sessions,
|
|
70
|
+
cleanup_interval=self._session_pool.config.cleanup_interval,
|
|
71
|
+
)
|
|
72
|
+
else:
|
|
73
|
+
logger.debug(
|
|
74
|
+
"session_manager_session_pool_skipped",
|
|
75
|
+
reason="session_pool_disabled_in_settings",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def _should_enable_session_pool(self) -> bool:
|
|
79
|
+
"""Check if session pool should be enabled."""
|
|
80
|
+
import structlog
|
|
81
|
+
|
|
82
|
+
logger = structlog.get_logger(__name__)
|
|
83
|
+
|
|
84
|
+
if not self._settings:
|
|
85
|
+
logger.debug("session_pool_check", decision="no_settings", enabled=False)
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
if not hasattr(self._settings, "claude"):
|
|
89
|
+
logger.debug(
|
|
90
|
+
"session_pool_check", decision="no_claude_settings", enabled=False
|
|
91
|
+
)
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
session_pool_settings = getattr(self._settings.claude, "sdk_session_pool", None)
|
|
95
|
+
if not session_pool_settings:
|
|
96
|
+
logger.debug(
|
|
97
|
+
"session_pool_check", decision="no_session_pool_settings", enabled=False
|
|
98
|
+
)
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
enabled = getattr(session_pool_settings, "enabled", False)
|
|
102
|
+
logger.debug("session_pool_check", decision="settings_check", enabled=enabled)
|
|
103
|
+
return enabled
|
|
104
|
+
|
|
105
|
+
async def start(self) -> None:
|
|
106
|
+
"""Start the session manager and session pool."""
|
|
107
|
+
if self._session_pool:
|
|
108
|
+
await self._session_pool.start()
|
|
109
|
+
|
|
110
|
+
async def shutdown(self) -> None:
|
|
111
|
+
"""Gracefully shuts down the session pool.
|
|
112
|
+
|
|
113
|
+
This method is idempotent - calling it multiple times is safe.
|
|
114
|
+
"""
|
|
115
|
+
async with self._lock:
|
|
116
|
+
# Close session pool
|
|
117
|
+
if self._session_pool:
|
|
118
|
+
await self._session_pool.stop()
|
|
119
|
+
self._session_pool = None
|
|
120
|
+
|
|
121
|
+
async def get_session_client(
|
|
122
|
+
self,
|
|
123
|
+
session_id: str,
|
|
124
|
+
options: ClaudeCodeOptions,
|
|
125
|
+
) -> SessionClient:
|
|
126
|
+
"""Get session-aware client."""
|
|
127
|
+
|
|
128
|
+
logger = structlog.get_logger(__name__)
|
|
129
|
+
logger.debug(
|
|
130
|
+
"session_manager_get_session_client",
|
|
131
|
+
session_id=session_id,
|
|
132
|
+
has_session_pool=bool(self._session_pool),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if not self._session_pool:
|
|
136
|
+
logger.error(
|
|
137
|
+
"session_manager_session_pool_unavailable",
|
|
138
|
+
session_id=session_id,
|
|
139
|
+
)
|
|
140
|
+
raise ClaudeProxyError(
|
|
141
|
+
message="Session pool not available",
|
|
142
|
+
error_type="configuration_error",
|
|
143
|
+
status_code=500,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return await self._session_pool.get_session_client(session_id, options)
|
|
147
|
+
|
|
148
|
+
async def interrupt_session(self, session_id: str) -> bool:
|
|
149
|
+
"""Interrupt a specific session due to client disconnection.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
session_id: The session ID to interrupt
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
True if session was found and interrupted, False otherwise
|
|
156
|
+
"""
|
|
157
|
+
if not self._session_pool:
|
|
158
|
+
logger.warning(
|
|
159
|
+
"session_manager_interrupt_session_no_pool",
|
|
160
|
+
session_id=session_id,
|
|
161
|
+
)
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
logger.info(
|
|
165
|
+
"session_manager_interrupt_session",
|
|
166
|
+
session_id=session_id,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return await self._session_pool.interrupt_session(session_id)
|
|
170
|
+
|
|
171
|
+
async def interrupt_all_sessions(self) -> int:
|
|
172
|
+
"""Interrupt all active sessions (for shutdown or emergency cleanup).
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Number of sessions that were interrupted
|
|
176
|
+
"""
|
|
177
|
+
if not self._session_pool:
|
|
178
|
+
logger.warning("session_manager_interrupt_all_no_pool")
|
|
179
|
+
return 0
|
|
180
|
+
|
|
181
|
+
logger.info("session_manager_interrupt_all_sessions")
|
|
182
|
+
return await self._session_pool.interrupt_all_sessions()
|
|
183
|
+
|
|
184
|
+
async def get_session_pool_stats(self) -> dict[str, Any]:
|
|
185
|
+
"""Get session pool statistics."""
|
|
186
|
+
if not self._session_pool:
|
|
187
|
+
return {"enabled": False}
|
|
188
|
+
return await self._session_pool.get_stats()
|
|
189
|
+
|
|
190
|
+
def reset_for_testing(self) -> None:
|
|
191
|
+
"""Synchronous reset for test environments.
|
|
192
|
+
|
|
193
|
+
Warning:
|
|
194
|
+
This method should only be used in tests. It does not properly
|
|
195
|
+
shut down the session pool - use shutdown() for production code.
|
|
196
|
+
"""
|
|
197
|
+
self._session_pool = None
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def is_active(self) -> bool:
|
|
201
|
+
"""Check if the session manager has an active session pool."""
|
|
202
|
+
return self._session_pool is not None
|
|
203
|
+
|
|
204
|
+
async def has_session_pool(self) -> bool:
|
|
205
|
+
"""Check if session pool is available and enabled."""
|
|
206
|
+
return self._session_pool is not None and self._session_pool.config.enabled
|
|
207
|
+
|
|
208
|
+
async def has_session(self, session_id: str) -> bool:
|
|
209
|
+
"""Check if a session exists in the session pool.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
session_id: The session ID to check
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
True if session exists, False otherwise
|
|
216
|
+
"""
|
|
217
|
+
if not self._session_pool:
|
|
218
|
+
return False
|
|
219
|
+
return await self._session_pool.has_session(session_id)
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Message queue system for broadcasting SDK messages to multiple listeners."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from collections.abc import AsyncIterator
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any, TypeVar
|
|
13
|
+
|
|
14
|
+
import structlog
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
logger = structlog.get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MessageType(str, Enum):
|
|
23
|
+
"""Types of messages that can be sent through the queue."""
|
|
24
|
+
|
|
25
|
+
DATA = "data"
|
|
26
|
+
ERROR = "error"
|
|
27
|
+
COMPLETE = "complete"
|
|
28
|
+
SHUTDOWN = "shutdown"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class QueueMessage:
|
|
33
|
+
"""Message wrapper for queue communication."""
|
|
34
|
+
|
|
35
|
+
type: MessageType
|
|
36
|
+
data: Any = None
|
|
37
|
+
error: Exception | None = None
|
|
38
|
+
timestamp: float = field(default_factory=time.time)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class QueueListener:
|
|
42
|
+
"""Individual listener that consumes messages from the queue."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, listener_id: str | None = None):
|
|
45
|
+
"""Initialize a queue listener.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
listener_id: Optional ID for the listener, generated if not provided
|
|
49
|
+
"""
|
|
50
|
+
self.listener_id = listener_id or str(uuid.uuid4())
|
|
51
|
+
self._queue: asyncio.Queue[QueueMessage] = asyncio.Queue()
|
|
52
|
+
self._closed = False
|
|
53
|
+
self._created_at = time.time()
|
|
54
|
+
|
|
55
|
+
async def get_message(self) -> QueueMessage:
|
|
56
|
+
"""Get the next message from the queue.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
The next queued message
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
asyncio.QueueEmpty: If queue is empty and closed
|
|
63
|
+
"""
|
|
64
|
+
if self._closed and self._queue.empty():
|
|
65
|
+
raise asyncio.QueueEmpty("Listener is closed")
|
|
66
|
+
|
|
67
|
+
return await self._queue.get()
|
|
68
|
+
|
|
69
|
+
async def put_message(self, message: QueueMessage) -> None:
|
|
70
|
+
"""Put a message into this listener's queue.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
message: Message to queue
|
|
74
|
+
"""
|
|
75
|
+
if not self._closed:
|
|
76
|
+
await self._queue.put(message)
|
|
77
|
+
|
|
78
|
+
def close(self) -> None:
|
|
79
|
+
"""Close the listener, preventing new messages."""
|
|
80
|
+
self._closed = True
|
|
81
|
+
# Put a shutdown message to unblock any waiting consumers
|
|
82
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
83
|
+
self._queue.put_nowait(QueueMessage(type=MessageType.SHUTDOWN))
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def is_closed(self) -> bool:
|
|
87
|
+
"""Check if the listener is closed."""
|
|
88
|
+
return self._closed
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def queue_size(self) -> int:
|
|
92
|
+
"""Get the current queue size."""
|
|
93
|
+
return self._queue.qsize()
|
|
94
|
+
|
|
95
|
+
async def __aiter__(self) -> AsyncIterator[Any]:
|
|
96
|
+
"""Async iterator interface for consuming messages."""
|
|
97
|
+
while True:
|
|
98
|
+
try:
|
|
99
|
+
message = await self.get_message()
|
|
100
|
+
|
|
101
|
+
if message.type == MessageType.SHUTDOWN:
|
|
102
|
+
break
|
|
103
|
+
elif message.type == MessageType.ERROR:
|
|
104
|
+
if message.error:
|
|
105
|
+
raise message.error
|
|
106
|
+
break
|
|
107
|
+
elif message.type == MessageType.COMPLETE:
|
|
108
|
+
break
|
|
109
|
+
else:
|
|
110
|
+
yield message.data
|
|
111
|
+
except asyncio.QueueEmpty:
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class MessageQueue:
|
|
116
|
+
"""Message queue that broadcasts to multiple listeners with discard logic."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, max_listeners: int = 100):
|
|
119
|
+
"""Initialize the message queue.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
max_listeners: Maximum number of concurrent listeners
|
|
123
|
+
"""
|
|
124
|
+
self._listeners: dict[str, QueueListener] = {}
|
|
125
|
+
self._lock = asyncio.Lock()
|
|
126
|
+
self._max_listeners = max_listeners
|
|
127
|
+
self._total_messages_received = 0
|
|
128
|
+
self._total_messages_delivered = 0
|
|
129
|
+
self._total_messages_discarded = 0
|
|
130
|
+
self._created_at = time.time()
|
|
131
|
+
|
|
132
|
+
async def create_listener(self, listener_id: str | None = None) -> QueueListener:
|
|
133
|
+
"""Create a new listener for this queue.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
listener_id: Optional ID for the listener
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
A new QueueListener instance
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
RuntimeError: If max listeners exceeded
|
|
143
|
+
"""
|
|
144
|
+
async with self._lock:
|
|
145
|
+
if len(self._listeners) >= self._max_listeners:
|
|
146
|
+
raise RuntimeError(
|
|
147
|
+
f"Maximum listeners ({self._max_listeners}) exceeded"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
listener = QueueListener(listener_id)
|
|
151
|
+
self._listeners[listener.listener_id] = listener
|
|
152
|
+
|
|
153
|
+
logger.debug(
|
|
154
|
+
"message_queue_listener_added",
|
|
155
|
+
listener_id=listener.listener_id,
|
|
156
|
+
active_listeners=len(self._listeners),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return listener
|
|
160
|
+
|
|
161
|
+
async def remove_listener(self, listener_id: str) -> None:
|
|
162
|
+
"""Remove a listener from the queue.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
listener_id: ID of the listener to remove
|
|
166
|
+
"""
|
|
167
|
+
async with self._lock:
|
|
168
|
+
if listener_id in self._listeners:
|
|
169
|
+
listener = self._listeners.pop(listener_id)
|
|
170
|
+
listener.close()
|
|
171
|
+
|
|
172
|
+
logger.debug(
|
|
173
|
+
"message_queue_listener_removed",
|
|
174
|
+
listener_id=listener_id,
|
|
175
|
+
active_listeners=len(self._listeners),
|
|
176
|
+
listener_queue_size=listener.queue_size,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
async def has_listeners(self) -> bool:
|
|
180
|
+
"""Check if any active listeners exist.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
True if at least one listener is registered
|
|
184
|
+
"""
|
|
185
|
+
async with self._lock:
|
|
186
|
+
return len(self._listeners) > 0
|
|
187
|
+
|
|
188
|
+
async def get_listener_count(self) -> int:
|
|
189
|
+
"""Get the current number of active listeners.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Number of active listeners
|
|
193
|
+
"""
|
|
194
|
+
async with self._lock:
|
|
195
|
+
return len(self._listeners)
|
|
196
|
+
|
|
197
|
+
async def broadcast(self, message: Any) -> int:
|
|
198
|
+
"""Broadcast a message to all active listeners.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
message: The message to broadcast
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Number of listeners that received the message
|
|
205
|
+
"""
|
|
206
|
+
self._total_messages_received += 1
|
|
207
|
+
|
|
208
|
+
async with self._lock:
|
|
209
|
+
if not self._listeners:
|
|
210
|
+
self._total_messages_discarded += 1
|
|
211
|
+
logger.debug(
|
|
212
|
+
"message_queue_discard",
|
|
213
|
+
reason="no_listeners",
|
|
214
|
+
message_type=type(message).__name__,
|
|
215
|
+
)
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
# Create queue message
|
|
219
|
+
queue_msg = QueueMessage(type=MessageType.DATA, data=message)
|
|
220
|
+
|
|
221
|
+
# Broadcast to all listeners
|
|
222
|
+
delivered_count = 0
|
|
223
|
+
for listener_id, listener in list(self._listeners.items()):
|
|
224
|
+
if listener.is_closed:
|
|
225
|
+
# Remove closed listeners
|
|
226
|
+
self._listeners.pop(listener_id, None)
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
# Use put_nowait to avoid blocking
|
|
231
|
+
listener._queue.put_nowait(queue_msg)
|
|
232
|
+
delivered_count += 1
|
|
233
|
+
except asyncio.QueueFull:
|
|
234
|
+
logger.warning(
|
|
235
|
+
"message_queue_listener_full",
|
|
236
|
+
listener_id=listener_id,
|
|
237
|
+
queue_size=listener.queue_size,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
self._total_messages_delivered += delivered_count
|
|
241
|
+
|
|
242
|
+
if delivered_count == 0:
|
|
243
|
+
self._total_messages_discarded += 1
|
|
244
|
+
|
|
245
|
+
logger.debug(
|
|
246
|
+
"message_queue_broadcast",
|
|
247
|
+
listeners_count=len(self._listeners),
|
|
248
|
+
delivered_count=delivered_count,
|
|
249
|
+
message_type=type(message).__name__,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
return delivered_count
|
|
253
|
+
|
|
254
|
+
async def broadcast_error(self, error: Exception) -> None:
|
|
255
|
+
"""Broadcast an error to all listeners.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
error: The error to broadcast
|
|
259
|
+
"""
|
|
260
|
+
async with self._lock:
|
|
261
|
+
queue_msg = QueueMessage(type=MessageType.ERROR, error=error)
|
|
262
|
+
|
|
263
|
+
for listener in self._listeners.values():
|
|
264
|
+
if not listener.is_closed:
|
|
265
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
266
|
+
listener._queue.put_nowait(queue_msg)
|
|
267
|
+
|
|
268
|
+
logger.debug(
|
|
269
|
+
"message_queue_broadcast_error",
|
|
270
|
+
error_type=type(error).__name__,
|
|
271
|
+
listeners_count=len(self._listeners),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
async def broadcast_complete(self) -> None:
|
|
275
|
+
"""Broadcast completion signal to all listeners."""
|
|
276
|
+
async with self._lock:
|
|
277
|
+
queue_msg = QueueMessage(type=MessageType.COMPLETE)
|
|
278
|
+
|
|
279
|
+
for listener in self._listeners.values():
|
|
280
|
+
if not listener.is_closed:
|
|
281
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
282
|
+
listener._queue.put_nowait(queue_msg)
|
|
283
|
+
|
|
284
|
+
logger.debug(
|
|
285
|
+
"message_queue_broadcast_complete",
|
|
286
|
+
listeners_count=len(self._listeners),
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
async def broadcast_shutdown(self) -> None:
|
|
290
|
+
"""Broadcast shutdown signal to all listeners (for interrupts)."""
|
|
291
|
+
async with self._lock:
|
|
292
|
+
queue_msg = QueueMessage(type=MessageType.SHUTDOWN)
|
|
293
|
+
|
|
294
|
+
for listener in self._listeners.values():
|
|
295
|
+
if not listener.is_closed:
|
|
296
|
+
with contextlib.suppress(asyncio.QueueFull):
|
|
297
|
+
listener._queue.put_nowait(queue_msg)
|
|
298
|
+
|
|
299
|
+
logger.debug(
|
|
300
|
+
"message_queue_broadcast_shutdown",
|
|
301
|
+
listeners_count=len(self._listeners),
|
|
302
|
+
message="Shutdown signal sent to all listeners due to interrupt",
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
async def close(self) -> None:
|
|
306
|
+
"""Close the message queue and all listeners."""
|
|
307
|
+
async with self._lock:
|
|
308
|
+
# Send shutdown to all listeners
|
|
309
|
+
queue_msg = QueueMessage(type=MessageType.SHUTDOWN)
|
|
310
|
+
|
|
311
|
+
for listener in self._listeners.values():
|
|
312
|
+
listener.close()
|
|
313
|
+
|
|
314
|
+
self._listeners.clear()
|
|
315
|
+
|
|
316
|
+
logger.debug(
|
|
317
|
+
"message_queue_closed",
|
|
318
|
+
total_messages_received=self._total_messages_received,
|
|
319
|
+
total_messages_delivered=self._total_messages_delivered,
|
|
320
|
+
total_messages_discarded=self._total_messages_discarded,
|
|
321
|
+
lifetime_seconds=time.time() - self._created_at,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def get_stats(self) -> dict[str, Any]:
|
|
325
|
+
"""Get queue statistics.
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
Dictionary of queue statistics
|
|
329
|
+
"""
|
|
330
|
+
return {
|
|
331
|
+
"active_listeners": len(self._listeners),
|
|
332
|
+
"max_listeners": self._max_listeners,
|
|
333
|
+
"total_messages_received": self._total_messages_received,
|
|
334
|
+
"total_messages_delivered": self._total_messages_delivered,
|
|
335
|
+
"total_messages_discarded": self._total_messages_discarded,
|
|
336
|
+
"lifetime_seconds": time.time() - self._created_at,
|
|
337
|
+
"delivery_rate": (
|
|
338
|
+
self._total_messages_delivered / self._total_messages_received
|
|
339
|
+
if self._total_messages_received > 0
|
|
340
|
+
else 0.0
|
|
341
|
+
),
|
|
342
|
+
}
|
ccproxy/claude_sdk/options.py
CHANGED
|
@@ -61,7 +61,7 @@ class OptionsHandler:
|
|
|
61
61
|
# Extract configuration values with proper types
|
|
62
62
|
mcp_servers = (
|
|
63
63
|
configured_opts.mcp_servers.copy()
|
|
64
|
-
if configured_opts.mcp_servers
|
|
64
|
+
if isinstance(configured_opts.mcp_servers, dict)
|
|
65
65
|
else {}
|
|
66
66
|
)
|
|
67
67
|
permission_prompt_tool_name = configured_opts.permission_prompt_tool_name
|
|
@@ -112,6 +112,11 @@ class OptionsHandler:
|
|
|
112
112
|
if system_message is not None:
|
|
113
113
|
options.system_prompt = system_message
|
|
114
114
|
|
|
115
|
+
# If session_id is provided via additional_options, enable continue_conversation
|
|
116
|
+
# This ensures conversation continuity when using session IDs
|
|
117
|
+
if additional_options.get("session_id"):
|
|
118
|
+
options.continue_conversation = True
|
|
119
|
+
|
|
115
120
|
# Note: temperature and max_tokens are API-level parameters, not ClaudeCodeOptions parameters
|
|
116
121
|
# These are handled at the API request level, not in the options object
|
|
117
122
|
|