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
|
@@ -9,19 +9,20 @@ from claude_code_sdk import ClaudeCodeOptions
|
|
|
9
9
|
from ccproxy.auth.manager import AuthManager
|
|
10
10
|
from ccproxy.claude_sdk.client import ClaudeSDKClient
|
|
11
11
|
from ccproxy.claude_sdk.converter import MessageConverter
|
|
12
|
+
from ccproxy.claude_sdk.exceptions import StreamTimeoutError
|
|
13
|
+
from ccproxy.claude_sdk.manager import SessionManager
|
|
12
14
|
from ccproxy.claude_sdk.options import OptionsHandler
|
|
13
15
|
from ccproxy.claude_sdk.streaming import ClaudeStreamProcessor
|
|
14
16
|
from ccproxy.config.claude import SDKMessageMode
|
|
15
17
|
from ccproxy.config.settings import Settings
|
|
16
18
|
from ccproxy.core.errors import (
|
|
17
|
-
AuthenticationError,
|
|
18
19
|
ClaudeProxyError,
|
|
19
20
|
ServiceUnavailableError,
|
|
20
21
|
)
|
|
21
22
|
from ccproxy.models import claude_sdk as sdk_models
|
|
23
|
+
from ccproxy.models.claude_sdk import SDKMessage, create_sdk_message
|
|
22
24
|
from ccproxy.models.messages import MessageResponse
|
|
23
|
-
from ccproxy.observability.
|
|
24
|
-
from ccproxy.observability.context import RequestContext, request_context
|
|
25
|
+
from ccproxy.observability.context import RequestContext
|
|
25
26
|
from ccproxy.observability.metrics import PrometheusMetrics
|
|
26
27
|
from ccproxy.utils.model_mapping import map_model_to_claude
|
|
27
28
|
from ccproxy.utils.simple_request_logger import write_request_log
|
|
@@ -45,6 +46,7 @@ class ClaudeSDKService:
|
|
|
45
46
|
auth_manager: AuthManager | None = None,
|
|
46
47
|
metrics: PrometheusMetrics | None = None,
|
|
47
48
|
settings: Settings | None = None,
|
|
49
|
+
session_manager: SessionManager | None = None,
|
|
48
50
|
) -> None:
|
|
49
51
|
"""
|
|
50
52
|
Initialize Claude SDK service.
|
|
@@ -54,8 +56,11 @@ class ClaudeSDKService:
|
|
|
54
56
|
auth_manager: Authentication manager (optional)
|
|
55
57
|
metrics: Prometheus metrics instance (optional)
|
|
56
58
|
settings: Application settings (optional)
|
|
59
|
+
session_manager: Session manager for dependency injection (optional)
|
|
57
60
|
"""
|
|
58
|
-
self.sdk_client = sdk_client or ClaudeSDKClient(
|
|
61
|
+
self.sdk_client = sdk_client or ClaudeSDKClient(
|
|
62
|
+
settings=settings, session_manager=session_manager
|
|
63
|
+
)
|
|
59
64
|
self.auth_manager = auth_manager
|
|
60
65
|
self.metrics = metrics
|
|
61
66
|
self.settings = settings
|
|
@@ -66,14 +71,120 @@ class ClaudeSDKService:
|
|
|
66
71
|
metrics=self.metrics,
|
|
67
72
|
)
|
|
68
73
|
|
|
74
|
+
def _convert_messages_to_sdk_message(
|
|
75
|
+
self, messages: list[dict[str, Any]], session_id: str | None = None
|
|
76
|
+
) -> "SDKMessage":
|
|
77
|
+
"""Convert list of Anthropic messages to single SDKMessage.
|
|
78
|
+
|
|
79
|
+
Takes the last user message from the list and converts it to SDKMessage format.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
messages: List of Anthropic API messages
|
|
83
|
+
session_id: Optional session ID for conversation continuity
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
SDKMessage ready to send to Claude SDK
|
|
87
|
+
"""
|
|
88
|
+
# Find the last user message
|
|
89
|
+
last_user_message = None
|
|
90
|
+
for msg in reversed(messages):
|
|
91
|
+
if msg.get("role") == "user":
|
|
92
|
+
last_user_message = msg
|
|
93
|
+
break
|
|
94
|
+
|
|
95
|
+
if not last_user_message:
|
|
96
|
+
raise ClaudeProxyError(
|
|
97
|
+
message="No user message found in messages list",
|
|
98
|
+
error_type="invalid_request_error",
|
|
99
|
+
status_code=400,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Extract text content from the message
|
|
103
|
+
content = last_user_message.get("content", "")
|
|
104
|
+
if isinstance(content, list):
|
|
105
|
+
# Extract text from content blocks
|
|
106
|
+
text_parts = []
|
|
107
|
+
for block in content:
|
|
108
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
109
|
+
text_parts.append(block.get("text", ""))
|
|
110
|
+
content = "\n".join(text_parts)
|
|
111
|
+
elif not isinstance(content, str):
|
|
112
|
+
content = str(content)
|
|
113
|
+
|
|
114
|
+
return create_sdk_message(content=content, session_id=session_id)
|
|
115
|
+
|
|
116
|
+
async def _capture_session_metadata(
|
|
117
|
+
self,
|
|
118
|
+
ctx: RequestContext,
|
|
119
|
+
session_id: str | None,
|
|
120
|
+
options: "ClaudeCodeOptions",
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Capture session metadata for access logging.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
ctx: Request context to add metadata to
|
|
126
|
+
session_id: Optional session ID
|
|
127
|
+
options: Claude Code options
|
|
128
|
+
"""
|
|
129
|
+
if (
|
|
130
|
+
session_id
|
|
131
|
+
and hasattr(self.sdk_client, "_session_manager")
|
|
132
|
+
and self.sdk_client._session_manager
|
|
133
|
+
):
|
|
134
|
+
try:
|
|
135
|
+
session_client = (
|
|
136
|
+
await self.sdk_client._session_manager.get_session_client(
|
|
137
|
+
session_id, options
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
if session_client:
|
|
141
|
+
# Determine if session pool is enabled
|
|
142
|
+
session_pool_enabled = (
|
|
143
|
+
hasattr(self.sdk_client._session_manager, "session_pool")
|
|
144
|
+
and self.sdk_client._session_manager.session_pool is not None
|
|
145
|
+
and hasattr(
|
|
146
|
+
self.sdk_client._session_manager.session_pool, "config"
|
|
147
|
+
)
|
|
148
|
+
and self.sdk_client._session_manager.session_pool.config.enabled
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Add session metadata to context
|
|
152
|
+
ctx.add_metadata(
|
|
153
|
+
session_type="session_pool"
|
|
154
|
+
if session_pool_enabled
|
|
155
|
+
else "direct",
|
|
156
|
+
session_status=session_client.status.value,
|
|
157
|
+
session_age_seconds=session_client.metrics.age_seconds,
|
|
158
|
+
session_message_count=session_client.metrics.message_count,
|
|
159
|
+
session_client_id=session_client.client_id,
|
|
160
|
+
session_pool_enabled=session_pool_enabled,
|
|
161
|
+
session_idle_seconds=session_client.metrics.idle_seconds,
|
|
162
|
+
session_error_count=session_client.metrics.error_count,
|
|
163
|
+
session_is_new=session_client.is_newly_created,
|
|
164
|
+
)
|
|
165
|
+
except Exception as e:
|
|
166
|
+
logger.warning(
|
|
167
|
+
"failed_to_capture_session_metadata",
|
|
168
|
+
session_id=session_id,
|
|
169
|
+
error=str(e),
|
|
170
|
+
)
|
|
171
|
+
else:
|
|
172
|
+
# Add basic session metadata for direct connections (no session pool)
|
|
173
|
+
ctx.add_metadata(
|
|
174
|
+
session_type="direct",
|
|
175
|
+
session_pool_enabled=False,
|
|
176
|
+
session_is_new=True, # Direct connections are always new
|
|
177
|
+
)
|
|
178
|
+
|
|
69
179
|
async def create_completion(
|
|
70
180
|
self,
|
|
181
|
+
request_context: RequestContext,
|
|
71
182
|
messages: list[dict[str, Any]],
|
|
72
183
|
model: str,
|
|
73
184
|
temperature: float | None = None,
|
|
74
185
|
max_tokens: int | None = None,
|
|
75
186
|
stream: bool = False,
|
|
76
|
-
|
|
187
|
+
session_id: str | None = None,
|
|
77
188
|
**kwargs: Any,
|
|
78
189
|
) -> MessageResponse | AsyncIterator[dict[str, Any]]:
|
|
79
190
|
"""
|
|
@@ -85,7 +196,8 @@ class ClaudeSDKService:
|
|
|
85
196
|
temperature: Temperature for response generation
|
|
86
197
|
max_tokens: Maximum tokens in response
|
|
87
198
|
stream: Whether to stream responses
|
|
88
|
-
|
|
199
|
+
session_id: Optional session ID for Claude SDK integration
|
|
200
|
+
request_context: Existing request context to use instead of creating new one
|
|
89
201
|
**kwargs: Additional arguments
|
|
90
202
|
|
|
91
203
|
Returns:
|
|
@@ -96,20 +208,6 @@ class ClaudeSDKService:
|
|
|
96
208
|
ServiceUnavailableError: If service is unavailable
|
|
97
209
|
"""
|
|
98
210
|
|
|
99
|
-
# Validate authentication if auth manager is configured
|
|
100
|
-
if self.auth_manager and user_id:
|
|
101
|
-
try:
|
|
102
|
-
await self._validate_user_auth(user_id)
|
|
103
|
-
except Exception as e:
|
|
104
|
-
logger.error(
|
|
105
|
-
"authentication_failed",
|
|
106
|
-
user_id=user_id,
|
|
107
|
-
error=str(e),
|
|
108
|
-
error_type=type(e).__name__,
|
|
109
|
-
exc_info=True,
|
|
110
|
-
)
|
|
111
|
-
raise
|
|
112
|
-
|
|
113
211
|
# Extract system message and create options
|
|
114
212
|
system_message = self.options_handler.extract_system_message(messages)
|
|
115
213
|
|
|
@@ -121,74 +219,55 @@ class ClaudeSDKService:
|
|
|
121
219
|
temperature=temperature,
|
|
122
220
|
max_tokens=max_tokens,
|
|
123
221
|
system_message=system_message,
|
|
222
|
+
session_id=session_id,
|
|
124
223
|
**kwargs,
|
|
125
224
|
)
|
|
126
225
|
|
|
127
|
-
#
|
|
128
|
-
prompt = self.message_converter.format_messages_to_prompt(messages)
|
|
129
|
-
|
|
130
|
-
# Generate request ID for correlation
|
|
131
|
-
from uuid import uuid4
|
|
132
|
-
|
|
133
|
-
request_id = str(uuid4())
|
|
226
|
+
# Messages will be converted to SDK format in the client layer
|
|
134
227
|
|
|
135
|
-
# Use
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
# Log SDK request parameters
|
|
148
|
-
timestamp = ctx.get_log_timestamp_prefix() if ctx else None
|
|
149
|
-
await self._log_sdk_request(
|
|
150
|
-
request_id, prompt, options, model, stream, timestamp
|
|
151
|
-
)
|
|
228
|
+
# Use existing context, but update metadata for this service (preserve original service_type)
|
|
229
|
+
ctx = request_context
|
|
230
|
+
metadata = {
|
|
231
|
+
"endpoint": "messages",
|
|
232
|
+
"model": model,
|
|
233
|
+
"streaming": stream,
|
|
234
|
+
}
|
|
235
|
+
if session_id:
|
|
236
|
+
metadata["session_id"] = session_id
|
|
237
|
+
ctx.add_metadata(**metadata)
|
|
238
|
+
# Use existing request ID from context
|
|
239
|
+
request_id = ctx.request_id
|
|
152
240
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
else:
|
|
160
|
-
result = await self._complete_non_streaming(
|
|
161
|
-
prompt, options, model, request_id, ctx, timestamp
|
|
162
|
-
)
|
|
163
|
-
return result
|
|
241
|
+
try:
|
|
242
|
+
# Log SDK request parameters
|
|
243
|
+
timestamp = ctx.get_log_timestamp_prefix() if ctx else None
|
|
244
|
+
await self._log_sdk_request(
|
|
245
|
+
request_id, messages, options, model, stream, session_id, timestamp
|
|
246
|
+
)
|
|
164
247
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
error_type=type(e).__name__,
|
|
171
|
-
exc_info=True,
|
|
248
|
+
if stream:
|
|
249
|
+
# For streaming, return the async iterator directly
|
|
250
|
+
# Access logging will be handled by the stream processor when ResultMessage is received
|
|
251
|
+
return self._stream_completion(
|
|
252
|
+
ctx, messages, options, model, session_id, timestamp
|
|
172
253
|
)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
await log_request_access(
|
|
177
|
-
context=ctx,
|
|
178
|
-
method="POST",
|
|
179
|
-
error_message=str(e),
|
|
180
|
-
metrics=self.metrics,
|
|
181
|
-
error_type=type(e).__name__,
|
|
254
|
+
else:
|
|
255
|
+
result = await self._complete_non_streaming(
|
|
256
|
+
ctx, messages, options, model, session_id, timestamp
|
|
182
257
|
)
|
|
183
|
-
|
|
258
|
+
return result
|
|
259
|
+
except (ClaudeProxyError, ServiceUnavailableError) as e:
|
|
260
|
+
# Add error info to context for automatic access logging
|
|
261
|
+
ctx.add_metadata(error_message=str(e), error_type=type(e).__name__)
|
|
262
|
+
raise
|
|
184
263
|
|
|
185
264
|
async def _complete_non_streaming(
|
|
186
265
|
self,
|
|
187
|
-
|
|
266
|
+
ctx: RequestContext,
|
|
267
|
+
messages: list[dict[str, Any]],
|
|
188
268
|
options: "ClaudeCodeOptions",
|
|
189
269
|
model: str,
|
|
190
|
-
|
|
191
|
-
ctx: RequestContext | None = None,
|
|
270
|
+
session_id: str | None = None,
|
|
192
271
|
timestamp: str | None = None,
|
|
193
272
|
) -> MessageResponse:
|
|
194
273
|
"""
|
|
@@ -198,7 +277,6 @@ class ClaudeSDKService:
|
|
|
198
277
|
prompt: The formatted prompt
|
|
199
278
|
options: Claude SDK options
|
|
200
279
|
model: The model being used
|
|
201
|
-
request_id: The request ID for metrics correlation
|
|
202
280
|
|
|
203
281
|
Returns:
|
|
204
282
|
Response in Anthropic format
|
|
@@ -206,18 +284,31 @@ class ClaudeSDKService:
|
|
|
206
284
|
Raises:
|
|
207
285
|
ClaudeProxyError: If completion fails
|
|
208
286
|
"""
|
|
209
|
-
|
|
287
|
+
request_id = ctx.request_id
|
|
288
|
+
logger.debug("claude_sdk_completion_start", request_id=request_id)
|
|
210
289
|
|
|
211
|
-
messages
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
290
|
+
# Convert messages to single SDKMessage
|
|
291
|
+
sdk_message = self._convert_messages_to_sdk_message(messages, session_id)
|
|
292
|
+
|
|
293
|
+
# Get stream handle
|
|
294
|
+
stream_handle = await self.sdk_client.query_completion(
|
|
295
|
+
sdk_message, options, request_id, session_id
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# Capture session metadata for access logging
|
|
299
|
+
await self._capture_session_metadata(ctx, session_id, options)
|
|
300
|
+
|
|
301
|
+
# Create a listener and collect all messages
|
|
302
|
+
sdk_messages = []
|
|
303
|
+
async for m in stream_handle.create_listener():
|
|
304
|
+
sdk_messages.append(m)
|
|
215
305
|
|
|
216
306
|
result_message = next(
|
|
217
|
-
(m for m in
|
|
307
|
+
(m for m in sdk_messages if isinstance(m, sdk_models.ResultMessage)), None
|
|
218
308
|
)
|
|
219
309
|
assistant_message = next(
|
|
220
|
-
(m for m in
|
|
310
|
+
(m for m in sdk_messages if isinstance(m, sdk_models.AssistantMessage)),
|
|
311
|
+
None,
|
|
221
312
|
)
|
|
222
313
|
|
|
223
314
|
if result_message is None:
|
|
@@ -249,7 +340,7 @@ class ClaudeSDKService:
|
|
|
249
340
|
# Add other message types to the content block
|
|
250
341
|
all_messages = [
|
|
251
342
|
m
|
|
252
|
-
for m in
|
|
343
|
+
for m in sdk_messages
|
|
253
344
|
if not isinstance(m, sdk_models.AssistantMessage | sdk_models.ResultMessage)
|
|
254
345
|
]
|
|
255
346
|
|
|
@@ -263,7 +354,7 @@ class ClaudeSDKService:
|
|
|
263
354
|
xml_tag="system_message",
|
|
264
355
|
forward_converter=lambda obj: {
|
|
265
356
|
"type": "system_message",
|
|
266
|
-
"text": obj.model_dump_json(
|
|
357
|
+
"text": obj.model_dump_json(),
|
|
267
358
|
},
|
|
268
359
|
)
|
|
269
360
|
if content_block:
|
|
@@ -306,18 +397,18 @@ class ClaudeSDKService:
|
|
|
306
397
|
request_id=request_id,
|
|
307
398
|
)
|
|
308
399
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
400
|
+
ctx.add_metadata(
|
|
401
|
+
status_code=200,
|
|
402
|
+
tokens_input=usage.input_tokens,
|
|
403
|
+
tokens_output=usage.output_tokens,
|
|
404
|
+
cache_read_tokens=usage.cache_read_input_tokens,
|
|
405
|
+
cache_write_tokens=usage.cache_creation_input_tokens,
|
|
406
|
+
cost_usd=cost_usd,
|
|
407
|
+
session_id=result_message.session_id,
|
|
408
|
+
num_turns=result_message.num_turns,
|
|
409
|
+
)
|
|
410
|
+
# Add success status to context for automatic access logging
|
|
411
|
+
ctx.add_metadata(status_code=200)
|
|
321
412
|
|
|
322
413
|
# Log SDK response
|
|
323
414
|
if request_id:
|
|
@@ -327,11 +418,11 @@ class ClaudeSDKService:
|
|
|
327
418
|
|
|
328
419
|
async def _stream_completion(
|
|
329
420
|
self,
|
|
330
|
-
|
|
421
|
+
ctx: RequestContext,
|
|
422
|
+
messages: list[dict[str, Any]],
|
|
331
423
|
options: "ClaudeCodeOptions",
|
|
332
424
|
model: str,
|
|
333
|
-
|
|
334
|
-
ctx: RequestContext | None = None,
|
|
425
|
+
session_id: str | None = None,
|
|
335
426
|
timestamp: str | None = None,
|
|
336
427
|
) -> AsyncIterator[dict[str, Any]]:
|
|
337
428
|
"""
|
|
@@ -341,12 +432,12 @@ class ClaudeSDKService:
|
|
|
341
432
|
prompt: The formatted prompt
|
|
342
433
|
options: Claude SDK options
|
|
343
434
|
model: The model being used
|
|
344
|
-
request_id: Optional request ID for logging
|
|
345
435
|
ctx: Optional request context for metrics
|
|
346
436
|
|
|
347
437
|
Yields:
|
|
348
438
|
Response chunks in Anthropic format
|
|
349
439
|
"""
|
|
440
|
+
request_id = ctx.request_id
|
|
350
441
|
sdk_message_mode = (
|
|
351
442
|
self.settings.claude.sdk_message_mode
|
|
352
443
|
if self.settings
|
|
@@ -354,66 +445,167 @@ class ClaudeSDKService:
|
|
|
354
445
|
)
|
|
355
446
|
pretty_format = self.settings.claude.pretty_format if self.settings else True
|
|
356
447
|
|
|
357
|
-
|
|
448
|
+
# Convert messages to single SDKMessage
|
|
449
|
+
sdk_message = self._convert_messages_to_sdk_message(messages, session_id)
|
|
358
450
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
451
|
+
# Get stream handle instead of direct iterator
|
|
452
|
+
stream_handle = await self.sdk_client.query_completion(
|
|
453
|
+
sdk_message, options, request_id, session_id
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
# Store handle in session client if available for cleanup
|
|
457
|
+
if (
|
|
458
|
+
session_id
|
|
459
|
+
and hasattr(self.sdk_client, "_session_manager")
|
|
460
|
+
and self.sdk_client._session_manager
|
|
366
461
|
):
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
462
|
+
try:
|
|
463
|
+
session_client = (
|
|
464
|
+
await self.sdk_client._session_manager.get_session_client(
|
|
465
|
+
session_id, options
|
|
466
|
+
)
|
|
467
|
+
)
|
|
468
|
+
if session_client:
|
|
469
|
+
session_client.active_stream_handle = stream_handle
|
|
470
|
+
except Exception as e:
|
|
471
|
+
logger.warning(
|
|
472
|
+
"failed_to_store_stream_handle",
|
|
473
|
+
session_id=session_id,
|
|
474
|
+
error=str(e),
|
|
475
|
+
)
|
|
371
476
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
Validate user authentication.
|
|
477
|
+
# Capture session metadata for access logging
|
|
478
|
+
await self._capture_session_metadata(ctx, session_id, options)
|
|
375
479
|
|
|
376
|
-
|
|
377
|
-
|
|
480
|
+
# Create a listener for this stream
|
|
481
|
+
sdk_stream = stream_handle.create_listener()
|
|
378
482
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
483
|
+
try:
|
|
484
|
+
async for chunk in self.stream_processor.process_stream(
|
|
485
|
+
sdk_stream=sdk_stream,
|
|
486
|
+
model=model,
|
|
487
|
+
request_id=request_id,
|
|
488
|
+
ctx=ctx,
|
|
489
|
+
sdk_message_mode=sdk_message_mode,
|
|
490
|
+
pretty_format=pretty_format,
|
|
491
|
+
):
|
|
492
|
+
# Log streaming chunk
|
|
493
|
+
if request_id:
|
|
494
|
+
await self._log_sdk_streaming_chunk(request_id, chunk, timestamp)
|
|
495
|
+
yield chunk
|
|
496
|
+
except GeneratorExit:
|
|
497
|
+
# Client disconnected - log and re-raise to propagate to create_listener()
|
|
498
|
+
logger.info(
|
|
499
|
+
"claude_sdk_service_client_disconnected",
|
|
500
|
+
request_id=request_id,
|
|
501
|
+
session_id=session_id,
|
|
502
|
+
message="Client disconnected from SDK service stream, propagating to stream handle",
|
|
503
|
+
)
|
|
504
|
+
# CRITICAL: Re-raise GeneratorExit to trigger interrupt in create_listener()
|
|
505
|
+
raise
|
|
506
|
+
except StreamTimeoutError as e:
|
|
507
|
+
# Send error events to the client
|
|
508
|
+
logger.error(
|
|
509
|
+
"stream_timeout_error",
|
|
510
|
+
message=str(e),
|
|
511
|
+
session_id=e.session_id,
|
|
512
|
+
timeout_seconds=e.timeout_seconds,
|
|
513
|
+
request_id=request_id,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
# Create a unique message ID for the error response
|
|
517
|
+
from uuid import uuid4
|
|
518
|
+
|
|
519
|
+
error_message_id = f"msg_error_{uuid4()}"
|
|
520
|
+
|
|
521
|
+
# Yield message_start event
|
|
522
|
+
yield {
|
|
523
|
+
"type": "message_start",
|
|
524
|
+
"message": {
|
|
525
|
+
"id": error_message_id,
|
|
526
|
+
"type": "message",
|
|
527
|
+
"role": "assistant",
|
|
528
|
+
"model": model,
|
|
529
|
+
"content": [],
|
|
530
|
+
"stop_reason": "error",
|
|
531
|
+
"stop_sequence": None,
|
|
532
|
+
"usage": {"input_tokens": 0, "output_tokens": 0},
|
|
533
|
+
},
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
# Yield content_block_start for error message
|
|
537
|
+
yield {
|
|
538
|
+
"type": "content_block_start",
|
|
539
|
+
"index": 0,
|
|
540
|
+
"content_block": {"type": "text", "text": ""},
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
# Yield error text delta
|
|
544
|
+
error_text = f"Error: {e}"
|
|
545
|
+
yield {
|
|
546
|
+
"type": "content_block_delta",
|
|
547
|
+
"index": 0,
|
|
548
|
+
"delta": {"type": "text_delta", "text": error_text},
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
# Yield content_block_stop
|
|
552
|
+
yield {
|
|
553
|
+
"type": "content_block_stop",
|
|
554
|
+
"index": 0,
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
# Yield message_delta with stop reason
|
|
558
|
+
yield {
|
|
559
|
+
"type": "message_delta",
|
|
560
|
+
"delta": {"stop_reason": "error", "stop_sequence": None},
|
|
561
|
+
"usage": {"output_tokens": len(error_text.split())},
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
# Yield message_stop
|
|
565
|
+
yield {
|
|
566
|
+
"type": "message_stop",
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
# Update context with error status
|
|
570
|
+
ctx.add_metadata(
|
|
571
|
+
status_code=504, # Gateway Timeout
|
|
572
|
+
error_message=str(e),
|
|
573
|
+
error_type="stream_timeout",
|
|
574
|
+
session_id=e.session_id,
|
|
575
|
+
)
|
|
385
576
|
|
|
386
577
|
async def _log_sdk_request(
|
|
387
578
|
self,
|
|
388
579
|
request_id: str,
|
|
389
|
-
|
|
580
|
+
messages: list[dict[str, Any]],
|
|
390
581
|
options: "ClaudeCodeOptions",
|
|
391
582
|
model: str,
|
|
392
583
|
stream: bool,
|
|
584
|
+
session_id: str | None = None,
|
|
393
585
|
timestamp: str | None = None,
|
|
394
586
|
) -> None:
|
|
395
587
|
"""Log SDK input parameters as JSON dump.
|
|
396
588
|
|
|
397
589
|
Args:
|
|
398
590
|
request_id: Request identifier
|
|
399
|
-
|
|
591
|
+
messages: List of Anthropic API messages
|
|
400
592
|
options: Claude SDK options
|
|
401
593
|
model: The model being used
|
|
402
594
|
stream: Whether streaming is enabled
|
|
595
|
+
session_id: Optional session ID for Claude SDK integration
|
|
403
596
|
timestamp: Optional timestamp prefix
|
|
404
597
|
"""
|
|
405
598
|
# timestamp is already provided from context, no need for fallback
|
|
406
599
|
|
|
407
600
|
# JSON dump of the parameters passed to SDK completion
|
|
408
601
|
sdk_request_data = {
|
|
409
|
-
"
|
|
410
|
-
"options": options
|
|
411
|
-
if hasattr(options, "model_dump")
|
|
412
|
-
else str(options),
|
|
413
|
-
"model": model,
|
|
602
|
+
"messages": messages,
|
|
603
|
+
"options": options,
|
|
414
604
|
"stream": stream,
|
|
415
605
|
"request_id": request_id,
|
|
416
606
|
}
|
|
607
|
+
if session_id:
|
|
608
|
+
sdk_request_data["session_id"] = session_id
|
|
417
609
|
|
|
418
610
|
await write_request_log(
|
|
419
611
|
request_id=request_id,
|
|
@@ -497,6 +689,17 @@ class ClaudeSDKService:
|
|
|
497
689
|
)
|
|
498
690
|
return False
|
|
499
691
|
|
|
692
|
+
async def interrupt_session(self, session_id: str) -> bool:
|
|
693
|
+
"""Interrupt a Claude session due to client disconnection.
|
|
694
|
+
|
|
695
|
+
Args:
|
|
696
|
+
session_id: The session ID to interrupt
|
|
697
|
+
|
|
698
|
+
Returns:
|
|
699
|
+
True if session was found and interrupted, False otherwise
|
|
700
|
+
"""
|
|
701
|
+
return await self.sdk_client.interrupt_session(session_id)
|
|
702
|
+
|
|
500
703
|
async def close(self) -> None:
|
|
501
704
|
"""Close the service and cleanup resources."""
|
|
502
705
|
await self.sdk_client.close()
|