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.
Files changed (72) hide show
  1. ccproxy/_version.py +2 -2
  2. ccproxy/adapters/codex/__init__.py +11 -0
  3. ccproxy/adapters/openai/adapter.py +1 -1
  4. ccproxy/adapters/openai/models.py +1 -1
  5. ccproxy/adapters/openai/response_adapter.py +355 -0
  6. ccproxy/adapters/openai/response_models.py +178 -0
  7. ccproxy/adapters/openai/streaming.py +1 -0
  8. ccproxy/api/app.py +150 -224
  9. ccproxy/api/dependencies.py +22 -2
  10. ccproxy/api/middleware/errors.py +27 -3
  11. ccproxy/api/middleware/logging.py +4 -0
  12. ccproxy/api/responses.py +6 -1
  13. ccproxy/api/routes/claude.py +222 -17
  14. ccproxy/api/routes/codex.py +1231 -0
  15. ccproxy/api/routes/health.py +228 -3
  16. ccproxy/api/routes/proxy.py +25 -6
  17. ccproxy/api/services/permission_service.py +2 -2
  18. ccproxy/auth/openai/__init__.py +13 -0
  19. ccproxy/auth/openai/credentials.py +166 -0
  20. ccproxy/auth/openai/oauth_client.py +334 -0
  21. ccproxy/auth/openai/storage.py +184 -0
  22. ccproxy/claude_sdk/__init__.py +4 -8
  23. ccproxy/claude_sdk/client.py +661 -131
  24. ccproxy/claude_sdk/exceptions.py +16 -0
  25. ccproxy/claude_sdk/manager.py +219 -0
  26. ccproxy/claude_sdk/message_queue.py +342 -0
  27. ccproxy/claude_sdk/options.py +6 -1
  28. ccproxy/claude_sdk/session_client.py +546 -0
  29. ccproxy/claude_sdk/session_pool.py +550 -0
  30. ccproxy/claude_sdk/stream_handle.py +538 -0
  31. ccproxy/claude_sdk/stream_worker.py +392 -0
  32. ccproxy/claude_sdk/streaming.py +53 -11
  33. ccproxy/cli/commands/auth.py +398 -1
  34. ccproxy/cli/commands/serve.py +99 -1
  35. ccproxy/cli/options/claude_options.py +47 -0
  36. ccproxy/config/__init__.py +0 -3
  37. ccproxy/config/claude.py +171 -23
  38. ccproxy/config/codex.py +100 -0
  39. ccproxy/config/discovery.py +10 -1
  40. ccproxy/config/scheduler.py +2 -2
  41. ccproxy/config/settings.py +38 -1
  42. ccproxy/core/codex_transformers.py +389 -0
  43. ccproxy/core/http_transformers.py +458 -75
  44. ccproxy/core/logging.py +108 -12
  45. ccproxy/core/transformers.py +5 -0
  46. ccproxy/models/claude_sdk.py +57 -0
  47. ccproxy/models/detection.py +208 -0
  48. ccproxy/models/requests.py +22 -0
  49. ccproxy/models/responses.py +16 -0
  50. ccproxy/observability/access_logger.py +72 -14
  51. ccproxy/observability/metrics.py +151 -0
  52. ccproxy/observability/storage/duckdb_simple.py +12 -0
  53. ccproxy/observability/storage/models.py +16 -0
  54. ccproxy/observability/streaming_response.py +107 -0
  55. ccproxy/scheduler/manager.py +31 -6
  56. ccproxy/scheduler/tasks.py +122 -0
  57. ccproxy/services/claude_detection_service.py +269 -0
  58. ccproxy/services/claude_sdk_service.py +333 -130
  59. ccproxy/services/codex_detection_service.py +263 -0
  60. ccproxy/services/proxy_service.py +618 -197
  61. ccproxy/utils/__init__.py +9 -1
  62. ccproxy/utils/disconnection_monitor.py +83 -0
  63. ccproxy/utils/id_generator.py +12 -0
  64. ccproxy/utils/model_mapping.py +7 -5
  65. ccproxy/utils/startup_helpers.py +470 -0
  66. ccproxy_api-0.1.6.dist-info/METADATA +615 -0
  67. {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/RECORD +70 -47
  68. ccproxy/config/loader.py +0 -105
  69. ccproxy_api-0.1.4.dist-info/METADATA +0 -369
  70. {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/WHEEL +0 -0
  71. {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/entry_points.txt +0 -0
  72. {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
+ }
@@ -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