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.
Files changed (54) hide show
  1. ccproxy/_version.py +2 -2
  2. ccproxy/adapters/openai/adapter.py +1 -1
  3. ccproxy/adapters/openai/streaming.py +1 -0
  4. ccproxy/api/app.py +134 -224
  5. ccproxy/api/dependencies.py +22 -2
  6. ccproxy/api/middleware/errors.py +27 -3
  7. ccproxy/api/middleware/logging.py +4 -0
  8. ccproxy/api/responses.py +6 -1
  9. ccproxy/api/routes/claude.py +222 -17
  10. ccproxy/api/routes/proxy.py +25 -6
  11. ccproxy/api/services/permission_service.py +2 -2
  12. ccproxy/claude_sdk/__init__.py +4 -8
  13. ccproxy/claude_sdk/client.py +661 -131
  14. ccproxy/claude_sdk/exceptions.py +16 -0
  15. ccproxy/claude_sdk/manager.py +219 -0
  16. ccproxy/claude_sdk/message_queue.py +342 -0
  17. ccproxy/claude_sdk/options.py +5 -0
  18. ccproxy/claude_sdk/session_client.py +546 -0
  19. ccproxy/claude_sdk/session_pool.py +550 -0
  20. ccproxy/claude_sdk/stream_handle.py +538 -0
  21. ccproxy/claude_sdk/stream_worker.py +392 -0
  22. ccproxy/claude_sdk/streaming.py +53 -11
  23. ccproxy/cli/commands/serve.py +96 -0
  24. ccproxy/cli/options/claude_options.py +47 -0
  25. ccproxy/config/__init__.py +0 -3
  26. ccproxy/config/claude.py +171 -23
  27. ccproxy/config/discovery.py +10 -1
  28. ccproxy/config/scheduler.py +4 -4
  29. ccproxy/config/settings.py +19 -1
  30. ccproxy/core/http_transformers.py +305 -73
  31. ccproxy/core/logging.py +108 -12
  32. ccproxy/core/transformers.py +5 -0
  33. ccproxy/models/claude_sdk.py +57 -0
  34. ccproxy/models/detection.py +126 -0
  35. ccproxy/observability/access_logger.py +72 -14
  36. ccproxy/observability/metrics.py +151 -0
  37. ccproxy/observability/storage/duckdb_simple.py +12 -0
  38. ccproxy/observability/storage/models.py +16 -0
  39. ccproxy/observability/streaming_response.py +107 -0
  40. ccproxy/scheduler/manager.py +31 -6
  41. ccproxy/scheduler/tasks.py +122 -0
  42. ccproxy/services/claude_detection_service.py +269 -0
  43. ccproxy/services/claude_sdk_service.py +334 -131
  44. ccproxy/services/proxy_service.py +91 -200
  45. ccproxy/utils/__init__.py +9 -1
  46. ccproxy/utils/disconnection_monitor.py +83 -0
  47. ccproxy/utils/id_generator.py +12 -0
  48. ccproxy/utils/startup_helpers.py +408 -0
  49. {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/METADATA +29 -2
  50. {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/RECORD +53 -41
  51. ccproxy/config/loader.py +0 -105
  52. {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/WHEEL +0 -0
  53. {ccproxy_api-0.1.3.dist-info → ccproxy_api-0.1.5.dist-info}/entry_points.txt +0 -0
  54. {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
+ }
@@ -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
- # Final message, contains metrics
255
- if ctx:
256
- usage_model = message.usage_model
257
- ctx.add_metadata(
258
- status_code=200,
259
- tokens_input=usage_model.input_tokens,
260
- tokens_output=usage_model.output_tokens,
261
- cache_read_tokens=usage_model.cache_read_input_tokens,
262
- cache_write_tokens=usage_model.cache_creation_input_tokens,
263
- cost_usd=message.total_cost_usd,
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)
@@ -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