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
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Stream worker for consuming Claude SDK messages and distributing via queue."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import AsyncIterator
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
import structlog
|
|
12
|
+
|
|
13
|
+
from ccproxy.claude_sdk.exceptions import StreamTimeoutError
|
|
14
|
+
from ccproxy.claude_sdk.message_queue import MessageQueue
|
|
15
|
+
from ccproxy.models import claude_sdk as sdk_models
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from ccproxy.claude_sdk.session_client import SessionClient
|
|
20
|
+
from ccproxy.claude_sdk.stream_handle import StreamHandle
|
|
21
|
+
|
|
22
|
+
logger = structlog.get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class WorkerStatus(str, Enum):
|
|
26
|
+
"""Status of the stream worker."""
|
|
27
|
+
|
|
28
|
+
IDLE = "idle"
|
|
29
|
+
STARTING = "starting"
|
|
30
|
+
RUNNING = "running"
|
|
31
|
+
DRAINING = "draining"
|
|
32
|
+
COMPLETED = "completed"
|
|
33
|
+
ERROR = "error"
|
|
34
|
+
INTERRUPTED = "interrupted"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class StreamWorker:
|
|
38
|
+
"""Worker that consumes messages from Claude SDK and distributes via queue."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
worker_id: str,
|
|
43
|
+
message_iterator: AsyncIterator[Any],
|
|
44
|
+
session_id: str | None = None,
|
|
45
|
+
request_id: str | None = None,
|
|
46
|
+
session_client: SessionClient | None = None,
|
|
47
|
+
stream_handle: StreamHandle | None = None,
|
|
48
|
+
):
|
|
49
|
+
"""Initialize the stream worker.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
worker_id: Unique identifier for this worker
|
|
53
|
+
message_iterator: Async iterator of SDK messages
|
|
54
|
+
session_id: Optional session ID for logging
|
|
55
|
+
request_id: Optional request ID for logging
|
|
56
|
+
session_client: Optional session client for state management
|
|
57
|
+
stream_handle: Optional stream handle for message lifecycle tracking
|
|
58
|
+
"""
|
|
59
|
+
self.worker_id = worker_id
|
|
60
|
+
self._message_iterator = message_iterator
|
|
61
|
+
self.session_id = session_id
|
|
62
|
+
self.request_id = request_id
|
|
63
|
+
self._session_client = session_client
|
|
64
|
+
self._stream_handle = stream_handle
|
|
65
|
+
|
|
66
|
+
# Worker state
|
|
67
|
+
self.status = WorkerStatus.IDLE
|
|
68
|
+
self._message_queue = MessageQueue()
|
|
69
|
+
self._worker_task: asyncio.Task[None] | None = None
|
|
70
|
+
self._started_at: float | None = None
|
|
71
|
+
self._completed_at: float | None = None
|
|
72
|
+
|
|
73
|
+
# Statistics
|
|
74
|
+
self._total_messages = 0
|
|
75
|
+
self._messages_delivered = 0
|
|
76
|
+
self._messages_discarded = 0
|
|
77
|
+
self._last_message_time: float | None = None
|
|
78
|
+
|
|
79
|
+
async def start(self) -> None:
|
|
80
|
+
"""Start the worker task."""
|
|
81
|
+
if self.status != WorkerStatus.IDLE:
|
|
82
|
+
logger.warning(
|
|
83
|
+
"stream_worker_already_started",
|
|
84
|
+
worker_id=self.worker_id,
|
|
85
|
+
status=self.status,
|
|
86
|
+
)
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
self.status = WorkerStatus.STARTING
|
|
90
|
+
self._started_at = time.time()
|
|
91
|
+
|
|
92
|
+
# Create worker task
|
|
93
|
+
self._worker_task = asyncio.create_task(self._run_worker())
|
|
94
|
+
|
|
95
|
+
logger.debug(
|
|
96
|
+
"stream_worker_started",
|
|
97
|
+
worker_id=self.worker_id,
|
|
98
|
+
session_id=self.session_id,
|
|
99
|
+
request_id=self.request_id,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
async def stop(self, timeout: float = 5.0) -> None:
|
|
103
|
+
"""Stop the worker gracefully.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
timeout: Maximum time to wait for worker to stop
|
|
107
|
+
"""
|
|
108
|
+
if self._worker_task and not self._worker_task.done():
|
|
109
|
+
logger.debug(
|
|
110
|
+
"stream_worker_stopping",
|
|
111
|
+
worker_id=self.worker_id,
|
|
112
|
+
timeout=timeout,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Cancel the worker task
|
|
116
|
+
self._worker_task.cancel()
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
# Use asyncio.wait instead of wait_for to handle cancelled tasks properly
|
|
120
|
+
done, pending = await asyncio.wait(
|
|
121
|
+
[self._worker_task],
|
|
122
|
+
timeout=timeout,
|
|
123
|
+
return_when=asyncio.ALL_COMPLETED,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if pending:
|
|
127
|
+
logger.warning(
|
|
128
|
+
"stream_worker_stop_timeout",
|
|
129
|
+
worker_id=self.worker_id,
|
|
130
|
+
timeout=timeout,
|
|
131
|
+
)
|
|
132
|
+
elif done:
|
|
133
|
+
# Task completed (likely with CancelledError)
|
|
134
|
+
logger.debug(
|
|
135
|
+
"stream_worker_stopped",
|
|
136
|
+
worker_id=self.worker_id,
|
|
137
|
+
task_cancelled=self._worker_task.cancelled(),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
except Exception as e:
|
|
141
|
+
logger.warning(
|
|
142
|
+
"stream_worker_stop_error",
|
|
143
|
+
worker_id=self.worker_id,
|
|
144
|
+
error=str(e),
|
|
145
|
+
error_type=type(e).__name__,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
async def wait_for_completion(self, timeout: float | None = None) -> bool:
|
|
149
|
+
"""Wait for the worker to complete.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
timeout: Optional timeout in seconds
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
True if completed successfully, False if timed out
|
|
156
|
+
"""
|
|
157
|
+
if not self._worker_task:
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
if timeout:
|
|
162
|
+
await asyncio.wait_for(self._worker_task, timeout=timeout)
|
|
163
|
+
else:
|
|
164
|
+
await self._worker_task
|
|
165
|
+
return True
|
|
166
|
+
except TimeoutError:
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
def get_message_queue(self) -> MessageQueue:
|
|
170
|
+
"""Get the message queue for creating listeners.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
The worker's message queue
|
|
174
|
+
"""
|
|
175
|
+
return self._message_queue
|
|
176
|
+
|
|
177
|
+
async def _run_worker(self) -> None:
|
|
178
|
+
"""Main worker loop that consumes messages and distributes them."""
|
|
179
|
+
try:
|
|
180
|
+
self.status = WorkerStatus.RUNNING
|
|
181
|
+
|
|
182
|
+
logger.debug(
|
|
183
|
+
"stream_worker_consuming",
|
|
184
|
+
worker_id=self.worker_id,
|
|
185
|
+
session_id=self.session_id,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
async for message in self._message_iterator:
|
|
189
|
+
self._total_messages += 1
|
|
190
|
+
self._last_message_time = time.time()
|
|
191
|
+
|
|
192
|
+
# Check if we have listeners
|
|
193
|
+
if await self._message_queue.has_listeners():
|
|
194
|
+
# Broadcast to all listeners
|
|
195
|
+
delivered_count = await self._message_queue.broadcast(message)
|
|
196
|
+
self._messages_delivered += delivered_count
|
|
197
|
+
|
|
198
|
+
logger.debug(
|
|
199
|
+
"stream_worker_message_delivered",
|
|
200
|
+
worker_id=self.worker_id,
|
|
201
|
+
message_type=type(message).__name__,
|
|
202
|
+
delivered_to=delivered_count,
|
|
203
|
+
total_messages=self._total_messages,
|
|
204
|
+
)
|
|
205
|
+
else:
|
|
206
|
+
# No listeners - discard message
|
|
207
|
+
self._messages_discarded += 1
|
|
208
|
+
|
|
209
|
+
logger.debug(
|
|
210
|
+
"stream_worker_message_discarded",
|
|
211
|
+
worker_id=self.worker_id,
|
|
212
|
+
message_type=type(message).__name__,
|
|
213
|
+
total_messages=self._total_messages,
|
|
214
|
+
total_discarded=self._messages_discarded,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Update stream handle with message lifecycle tracking
|
|
218
|
+
if self._stream_handle:
|
|
219
|
+
# Track all message activity
|
|
220
|
+
self._stream_handle.on_message_received(message)
|
|
221
|
+
|
|
222
|
+
# Track first chunk (SystemMessage with init subtype)
|
|
223
|
+
if (
|
|
224
|
+
isinstance(message, sdk_models.SystemMessage)
|
|
225
|
+
and hasattr(message, "subtype")
|
|
226
|
+
and message.subtype == "init"
|
|
227
|
+
):
|
|
228
|
+
self._stream_handle.on_first_chunk_received()
|
|
229
|
+
|
|
230
|
+
# Track completion (ResultMessage)
|
|
231
|
+
elif isinstance(message, sdk_models.ResultMessage):
|
|
232
|
+
self._stream_handle.on_completion()
|
|
233
|
+
|
|
234
|
+
# Update session client if we have one
|
|
235
|
+
if self._session_client and isinstance(
|
|
236
|
+
message, sdk_models.ResultMessage
|
|
237
|
+
):
|
|
238
|
+
self._session_client.sdk_session_id = message.session_id
|
|
239
|
+
|
|
240
|
+
# Stream completed successfully
|
|
241
|
+
self.status = WorkerStatus.COMPLETED
|
|
242
|
+
await self._message_queue.broadcast_complete()
|
|
243
|
+
|
|
244
|
+
logger.debug(
|
|
245
|
+
"stream_worker_completed",
|
|
246
|
+
worker_id=self.worker_id,
|
|
247
|
+
total_messages=self._total_messages,
|
|
248
|
+
messages_delivered=self._messages_delivered,
|
|
249
|
+
messages_discarded=self._messages_discarded,
|
|
250
|
+
duration_seconds=time.time() - (self._started_at or 0),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
except asyncio.CancelledError:
|
|
254
|
+
# Worker was cancelled
|
|
255
|
+
self.status = WorkerStatus.INTERRUPTED
|
|
256
|
+
logger.debug(
|
|
257
|
+
"stream_worker_cancelled",
|
|
258
|
+
worker_id=self.worker_id,
|
|
259
|
+
messages_processed=self._total_messages,
|
|
260
|
+
)
|
|
261
|
+
raise
|
|
262
|
+
|
|
263
|
+
except StreamTimeoutError as e:
|
|
264
|
+
# Handle timeout errors gracefully - these are expected for some commands
|
|
265
|
+
self.status = WorkerStatus.ERROR
|
|
266
|
+
await self._message_queue.broadcast_error(e)
|
|
267
|
+
|
|
268
|
+
logger.debug(
|
|
269
|
+
"stream_worker_timeout",
|
|
270
|
+
worker_id=self.worker_id,
|
|
271
|
+
timeout_message=str(e),
|
|
272
|
+
messages_processed=self._total_messages,
|
|
273
|
+
message="Stream worker completed due to timeout - this is expected for some commands",
|
|
274
|
+
)
|
|
275
|
+
# Don't re-raise StreamTimeoutError to avoid unhandled task exceptions
|
|
276
|
+
|
|
277
|
+
except Exception as e:
|
|
278
|
+
# Error during processing (other than timeout)
|
|
279
|
+
self.status = WorkerStatus.ERROR
|
|
280
|
+
await self._message_queue.broadcast_error(e)
|
|
281
|
+
|
|
282
|
+
logger.error(
|
|
283
|
+
"stream_worker_error",
|
|
284
|
+
worker_id=self.worker_id,
|
|
285
|
+
error=str(e),
|
|
286
|
+
error_type=type(e).__name__,
|
|
287
|
+
messages_processed=self._total_messages,
|
|
288
|
+
)
|
|
289
|
+
raise
|
|
290
|
+
|
|
291
|
+
finally:
|
|
292
|
+
self._completed_at = time.time()
|
|
293
|
+
|
|
294
|
+
# Clean up
|
|
295
|
+
if self._session_client:
|
|
296
|
+
self._session_client.has_active_stream = False
|
|
297
|
+
|
|
298
|
+
# Close the message queue
|
|
299
|
+
await self._message_queue.close()
|
|
300
|
+
|
|
301
|
+
async def drain_remaining(self, timeout: float = 30.0) -> int:
|
|
302
|
+
"""Drain remaining messages without listeners.
|
|
303
|
+
|
|
304
|
+
This is useful for ensuring the stream completes properly
|
|
305
|
+
even after all listeners have disconnected.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
timeout: Maximum time to spend draining
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
Number of messages drained
|
|
312
|
+
"""
|
|
313
|
+
if self.status not in (WorkerStatus.RUNNING, WorkerStatus.STARTING):
|
|
314
|
+
return 0
|
|
315
|
+
|
|
316
|
+
self.status = WorkerStatus.DRAINING
|
|
317
|
+
start_time = time.time()
|
|
318
|
+
drained_count = 0
|
|
319
|
+
|
|
320
|
+
logger.debug(
|
|
321
|
+
"stream_worker_draining",
|
|
322
|
+
worker_id=self.worker_id,
|
|
323
|
+
timeout=timeout,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
# Continue consuming but without broadcasting
|
|
328
|
+
async for message in self._message_iterator:
|
|
329
|
+
drained_count += 1
|
|
330
|
+
self._total_messages += 1
|
|
331
|
+
self._messages_discarded += 1
|
|
332
|
+
|
|
333
|
+
logger.debug(
|
|
334
|
+
"stream_worker_draining_message",
|
|
335
|
+
worker_id=self.worker_id,
|
|
336
|
+
message_type=type(message).__name__,
|
|
337
|
+
drained_count=drained_count,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
# Check timeout
|
|
341
|
+
if time.time() - start_time > timeout:
|
|
342
|
+
logger.warning(
|
|
343
|
+
"stream_worker_drain_timeout",
|
|
344
|
+
worker_id=self.worker_id,
|
|
345
|
+
drained_count=drained_count,
|
|
346
|
+
timeout=timeout,
|
|
347
|
+
)
|
|
348
|
+
break
|
|
349
|
+
|
|
350
|
+
# Check for completion message
|
|
351
|
+
if isinstance(message, sdk_models.ResultMessage):
|
|
352
|
+
logger.debug(
|
|
353
|
+
"stream_worker_drain_complete",
|
|
354
|
+
worker_id=self.worker_id,
|
|
355
|
+
drained_count=drained_count,
|
|
356
|
+
)
|
|
357
|
+
break
|
|
358
|
+
|
|
359
|
+
except Exception as e:
|
|
360
|
+
logger.error(
|
|
361
|
+
"stream_worker_drain_error",
|
|
362
|
+
worker_id=self.worker_id,
|
|
363
|
+
error=str(e),
|
|
364
|
+
drained_count=drained_count,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
return drained_count
|
|
368
|
+
|
|
369
|
+
def get_stats(self) -> dict[str, Any]:
|
|
370
|
+
"""Get worker statistics.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
Dictionary of worker statistics
|
|
374
|
+
"""
|
|
375
|
+
runtime = None
|
|
376
|
+
if self._started_at:
|
|
377
|
+
end_time = self._completed_at or time.time()
|
|
378
|
+
runtime = end_time - self._started_at
|
|
379
|
+
|
|
380
|
+
queue_stats = self._message_queue.get_stats()
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
"worker_id": self.worker_id,
|
|
384
|
+
"status": self.status.value,
|
|
385
|
+
"session_id": self.session_id,
|
|
386
|
+
"request_id": self.request_id,
|
|
387
|
+
"total_messages": self._total_messages,
|
|
388
|
+
"messages_delivered": self._messages_delivered,
|
|
389
|
+
"messages_discarded": self._messages_discarded,
|
|
390
|
+
"runtime_seconds": runtime,
|
|
391
|
+
"queue_stats": queue_stats,
|
|
392
|
+
}
|
ccproxy/claude_sdk/streaming.py
CHANGED
|
@@ -139,6 +139,15 @@ class ClaudeStreamProcessor:
|
|
|
139
139
|
mode=sdk_message_mode.value,
|
|
140
140
|
request_id=request_id,
|
|
141
141
|
)
|
|
142
|
+
logger.info(
|
|
143
|
+
"sdk_tool_use_block",
|
|
144
|
+
tool_id=block.id,
|
|
145
|
+
tool_name=block.name,
|
|
146
|
+
input_keys=list(block.input.keys()) if block.input else [],
|
|
147
|
+
block_index=content_block_index,
|
|
148
|
+
mode=sdk_message_mode.value,
|
|
149
|
+
request_id=request_id,
|
|
150
|
+
)
|
|
142
151
|
chunks = (
|
|
143
152
|
self.message_converter._create_sdk_content_block_chunks(
|
|
144
153
|
sdk_object=block,
|
|
@@ -167,6 +176,20 @@ class ClaudeStreamProcessor:
|
|
|
167
176
|
mode=sdk_message_mode.value,
|
|
168
177
|
request_id=request_id,
|
|
169
178
|
)
|
|
179
|
+
logger.info(
|
|
180
|
+
"sdk_tool_result_block",
|
|
181
|
+
tool_use_id=block.tool_use_id,
|
|
182
|
+
is_error=block.is_error,
|
|
183
|
+
content_type=type(block.content).__name__
|
|
184
|
+
if block.content
|
|
185
|
+
else "None",
|
|
186
|
+
content_preview=str(block.content)[:100]
|
|
187
|
+
if block.content
|
|
188
|
+
else None,
|
|
189
|
+
block_index=content_block_index,
|
|
190
|
+
mode=sdk_message_mode.value,
|
|
191
|
+
request_id=request_id,
|
|
192
|
+
)
|
|
170
193
|
chunks = (
|
|
171
194
|
self.message_converter._create_sdk_content_block_chunks(
|
|
172
195
|
sdk_object=block,
|
|
@@ -251,17 +274,18 @@ class ClaudeStreamProcessor:
|
|
|
251
274
|
yield chunk
|
|
252
275
|
content_block_index += 1
|
|
253
276
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
277
|
+
if ctx:
|
|
278
|
+
usage_model = message.usage_model
|
|
279
|
+
ctx.add_metadata(
|
|
280
|
+
status_code=200,
|
|
281
|
+
tokens_input=usage_model.input_tokens,
|
|
282
|
+
tokens_output=usage_model.output_tokens,
|
|
283
|
+
cache_read_tokens=usage_model.cache_read_input_tokens,
|
|
284
|
+
cache_write_tokens=usage_model.cache_creation_input_tokens,
|
|
285
|
+
cost_usd=message.total_cost_usd,
|
|
286
|
+
session_id=message.session_id,
|
|
287
|
+
num_turns=message.num_turns,
|
|
288
|
+
)
|
|
265
289
|
|
|
266
290
|
end_chunks = self.message_converter.create_streaming_end_chunks(
|
|
267
291
|
stop_reason=message.stop_reason
|
|
@@ -282,5 +306,23 @@ class ClaudeStreamProcessor:
|
|
|
282
306
|
message_content=str(message)[:200],
|
|
283
307
|
request_id=request_id,
|
|
284
308
|
)
|
|
309
|
+
else:
|
|
310
|
+
# Stream ended without a ResultMessage - this indicates an error/interruption
|
|
311
|
+
if ctx and "status_code" not in ctx.metadata:
|
|
312
|
+
# Set error status if not already set (e.g., by StreamTimeoutError handler)
|
|
313
|
+
logger.warning(
|
|
314
|
+
"stream_ended_without_result_message",
|
|
315
|
+
request_id=request_id,
|
|
316
|
+
message="Stream ended without ResultMessage, likely interrupted",
|
|
317
|
+
)
|
|
318
|
+
ctx.add_metadata(
|
|
319
|
+
status_code=499, # Client Closed Request
|
|
320
|
+
error_type="stream_interrupted",
|
|
321
|
+
error_message="Stream ended without completion",
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# Final message, contains metrics
|
|
325
|
+
# NOTE: Access logging is now handled by StreamingResponseWithLogging
|
|
326
|
+
# No need for manual access logging here anymore
|
|
285
327
|
|
|
286
328
|
logger.debug("claude_sdk_stream_processing_completed", request_id=request_id)
|
ccproxy/cli/commands/serve.py
CHANGED
|
@@ -35,7 +35,9 @@ from ..options.claude_options import (
|
|
|
35
35
|
validate_max_thinking_tokens,
|
|
36
36
|
validate_max_turns,
|
|
37
37
|
validate_permission_mode,
|
|
38
|
+
validate_pool_size,
|
|
38
39
|
validate_sdk_message_mode,
|
|
40
|
+
validate_system_prompt_injection_mode,
|
|
39
41
|
)
|
|
40
42
|
from ..options.security_options import SecurityOptions, validate_auth_token
|
|
41
43
|
from ..options.server_options import (
|
|
@@ -442,6 +444,48 @@ def api(
|
|
|
442
444
|
rich_help_panel="Claude Settings",
|
|
443
445
|
),
|
|
444
446
|
] = None,
|
|
447
|
+
sdk_pool: Annotated[
|
|
448
|
+
bool,
|
|
449
|
+
typer.Option(
|
|
450
|
+
"--sdk-pool/--no-sdk-pool",
|
|
451
|
+
help="Enable/disable general Claude SDK client connection pooling",
|
|
452
|
+
rich_help_panel="Claude Settings",
|
|
453
|
+
),
|
|
454
|
+
] = False,
|
|
455
|
+
sdk_pool_size: Annotated[
|
|
456
|
+
int | None,
|
|
457
|
+
typer.Option(
|
|
458
|
+
"--sdk-pool-size",
|
|
459
|
+
help="Number of clients to maintain in the general pool (1-20)",
|
|
460
|
+
callback=validate_pool_size,
|
|
461
|
+
rich_help_panel="Claude Settings",
|
|
462
|
+
),
|
|
463
|
+
] = None,
|
|
464
|
+
sdk_session_pool: Annotated[
|
|
465
|
+
bool,
|
|
466
|
+
typer.Option(
|
|
467
|
+
"--sdk-session-pool/--no-sdk-session-pool",
|
|
468
|
+
help="Enable/disable session-aware Claude SDK client pooling",
|
|
469
|
+
rich_help_panel="Claude Settings",
|
|
470
|
+
),
|
|
471
|
+
] = False,
|
|
472
|
+
system_prompt_injection_mode: Annotated[
|
|
473
|
+
str | None,
|
|
474
|
+
typer.Option(
|
|
475
|
+
"--system-prompt-injection-mode",
|
|
476
|
+
help="System prompt injection mode: minimal (Claude Code ID only), full (all detected system messages)",
|
|
477
|
+
callback=validate_system_prompt_injection_mode,
|
|
478
|
+
rich_help_panel="Claude Settings",
|
|
479
|
+
),
|
|
480
|
+
] = None,
|
|
481
|
+
builtin_permissions: Annotated[
|
|
482
|
+
bool,
|
|
483
|
+
typer.Option(
|
|
484
|
+
"--builtin-permissions/--no-builtin-permissions",
|
|
485
|
+
help="Enable built-in permission handling infrastructure (MCP server and SSE endpoints). When disabled, users can configure custom MCP servers and permission tools.",
|
|
486
|
+
rich_help_panel="Claude Settings",
|
|
487
|
+
),
|
|
488
|
+
] = True,
|
|
445
489
|
# Core settings
|
|
446
490
|
docker: Annotated[
|
|
447
491
|
bool,
|
|
@@ -526,6 +570,31 @@ def api(
|
|
|
526
570
|
rich_help_panel="Docker Settings",
|
|
527
571
|
),
|
|
528
572
|
] = None,
|
|
573
|
+
# Network control flags
|
|
574
|
+
no_network_calls: Annotated[
|
|
575
|
+
bool,
|
|
576
|
+
typer.Option(
|
|
577
|
+
"--no-network-calls",
|
|
578
|
+
help="Disable all network calls (version checks and pricing updates)",
|
|
579
|
+
rich_help_panel="Privacy Settings",
|
|
580
|
+
),
|
|
581
|
+
] = False,
|
|
582
|
+
disable_version_check: Annotated[
|
|
583
|
+
bool,
|
|
584
|
+
typer.Option(
|
|
585
|
+
"--disable-version-check",
|
|
586
|
+
help="Disable version update checks (prevents calls to GitHub API)",
|
|
587
|
+
rich_help_panel="Privacy Settings",
|
|
588
|
+
),
|
|
589
|
+
] = False,
|
|
590
|
+
disable_pricing_updates: Annotated[
|
|
591
|
+
bool,
|
|
592
|
+
typer.Option(
|
|
593
|
+
"--disable-pricing-updates",
|
|
594
|
+
help="Disable pricing data updates (prevents downloads from GitHub)",
|
|
595
|
+
rich_help_panel="Privacy Settings",
|
|
596
|
+
),
|
|
597
|
+
] = False,
|
|
529
598
|
) -> None:
|
|
530
599
|
"""
|
|
531
600
|
Start the CCProxy API server.
|
|
@@ -573,10 +642,28 @@ def api(
|
|
|
573
642
|
cwd=cwd,
|
|
574
643
|
permission_prompt_tool_name=permission_prompt_tool_name,
|
|
575
644
|
sdk_message_mode=sdk_message_mode,
|
|
645
|
+
sdk_pool=sdk_pool,
|
|
646
|
+
sdk_pool_size=sdk_pool_size,
|
|
647
|
+
sdk_session_pool=sdk_session_pool,
|
|
648
|
+
system_prompt_injection_mode=system_prompt_injection_mode,
|
|
649
|
+
builtin_permissions=builtin_permissions,
|
|
576
650
|
)
|
|
577
651
|
|
|
578
652
|
security_options = SecurityOptions(auth_token=auth_token)
|
|
579
653
|
|
|
654
|
+
# Handle network control flags
|
|
655
|
+
scheduler_overrides = {}
|
|
656
|
+
if no_network_calls:
|
|
657
|
+
# Disable both network features
|
|
658
|
+
scheduler_overrides["pricing_update_enabled"] = False
|
|
659
|
+
scheduler_overrides["version_check_enabled"] = False
|
|
660
|
+
else:
|
|
661
|
+
# Handle individual flags
|
|
662
|
+
if disable_pricing_updates:
|
|
663
|
+
scheduler_overrides["pricing_update_enabled"] = False
|
|
664
|
+
if disable_version_check:
|
|
665
|
+
scheduler_overrides["version_check_enabled"] = False
|
|
666
|
+
|
|
580
667
|
# Extract CLI overrides from structured option containers
|
|
581
668
|
cli_overrides = config_manager.get_cli_overrides_from_args(
|
|
582
669
|
# Server options
|
|
@@ -599,8 +686,17 @@ def api(
|
|
|
599
686
|
permission_prompt_tool_name=claude_options.permission_prompt_tool_name,
|
|
600
687
|
cwd=claude_options.cwd,
|
|
601
688
|
sdk_message_mode=claude_options.sdk_message_mode,
|
|
689
|
+
sdk_pool=claude_options.sdk_pool,
|
|
690
|
+
sdk_pool_size=claude_options.sdk_pool_size,
|
|
691
|
+
sdk_session_pool=claude_options.sdk_session_pool,
|
|
692
|
+
system_prompt_injection_mode=claude_options.system_prompt_injection_mode,
|
|
693
|
+
builtin_permissions=claude_options.builtin_permissions,
|
|
602
694
|
)
|
|
603
695
|
|
|
696
|
+
# Add scheduler overrides if any
|
|
697
|
+
if scheduler_overrides:
|
|
698
|
+
cli_overrides["scheduler"] = scheduler_overrides
|
|
699
|
+
|
|
604
700
|
# Load settings with CLI overrides
|
|
605
701
|
settings = config_manager.load_settings(
|
|
606
702
|
config_path=config, cli_overrides=cli_overrides
|
|
@@ -93,6 +93,38 @@ def validate_sdk_message_mode(
|
|
|
93
93
|
return value
|
|
94
94
|
|
|
95
95
|
|
|
96
|
+
def validate_pool_size(
|
|
97
|
+
ctx: typer.Context, param: typer.CallbackParam, value: int | None
|
|
98
|
+
) -> int | None:
|
|
99
|
+
"""Validate pool size."""
|
|
100
|
+
if value is None:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
if value < 1:
|
|
104
|
+
raise typer.BadParameter("Pool size must be at least 1")
|
|
105
|
+
|
|
106
|
+
if value > 20:
|
|
107
|
+
raise typer.BadParameter("Pool size must not exceed 20")
|
|
108
|
+
|
|
109
|
+
return value
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def validate_system_prompt_injection_mode(
|
|
113
|
+
ctx: typer.Context, param: typer.CallbackParam, value: str | None
|
|
114
|
+
) -> str | None:
|
|
115
|
+
"""Validate system prompt injection mode."""
|
|
116
|
+
if value is None:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
valid_modes = {"minimal", "full"}
|
|
120
|
+
if value not in valid_modes:
|
|
121
|
+
raise typer.BadParameter(
|
|
122
|
+
f"System prompt injection mode must be one of: {', '.join(valid_modes)}"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return value
|
|
126
|
+
|
|
127
|
+
|
|
96
128
|
# Factory functions removed - use Annotated syntax directly in commands
|
|
97
129
|
|
|
98
130
|
|
|
@@ -115,6 +147,11 @@ class ClaudeOptions:
|
|
|
115
147
|
cwd: str | None = None,
|
|
116
148
|
permission_prompt_tool_name: str | None = None,
|
|
117
149
|
sdk_message_mode: str | None = None,
|
|
150
|
+
sdk_pool: bool = False,
|
|
151
|
+
sdk_pool_size: int | None = None,
|
|
152
|
+
sdk_session_pool: bool = False,
|
|
153
|
+
system_prompt_injection_mode: str | None = None,
|
|
154
|
+
builtin_permissions: bool = True,
|
|
118
155
|
):
|
|
119
156
|
"""Initialize Claude options.
|
|
120
157
|
|
|
@@ -129,6 +166,11 @@ class ClaudeOptions:
|
|
|
129
166
|
cwd: Working directory path
|
|
130
167
|
permission_prompt_tool_name: Permission prompt tool name
|
|
131
168
|
sdk_message_mode: SDK message handling mode
|
|
169
|
+
sdk_pool: Enable general Claude SDK client connection pooling
|
|
170
|
+
sdk_pool_size: Number of clients to maintain in the general pool
|
|
171
|
+
sdk_session_pool: Enable session-aware Claude SDK client pooling
|
|
172
|
+
system_prompt_injection_mode: System prompt injection mode
|
|
173
|
+
builtin_permissions: Enable built-in permission handling infrastructure
|
|
132
174
|
"""
|
|
133
175
|
self.max_thinking_tokens = max_thinking_tokens
|
|
134
176
|
self.allowed_tools = allowed_tools
|
|
@@ -140,3 +182,8 @@ class ClaudeOptions:
|
|
|
140
182
|
self.cwd = cwd
|
|
141
183
|
self.permission_prompt_tool_name = permission_prompt_tool_name
|
|
142
184
|
self.sdk_message_mode = sdk_message_mode
|
|
185
|
+
self.sdk_pool = sdk_pool
|
|
186
|
+
self.sdk_pool_size = sdk_pool_size
|
|
187
|
+
self.sdk_session_pool = sdk_session_pool
|
|
188
|
+
self.system_prompt_injection_mode = system_prompt_injection_mode
|
|
189
|
+
self.builtin_permissions = builtin_permissions
|