ccproxy-api 0.1.4__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 +333 -130
  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.4.dist-info → ccproxy_api-0.1.5.dist-info}/METADATA +29 -2
  50. {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.5.dist-info}/RECORD +53 -41
  51. ccproxy/config/loader.py +0 -105
  52. {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.5.dist-info}/WHEEL +0 -0
  53. {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.5.dist-info}/entry_points.txt +0 -0
  54. {ccproxy_api-0.1.4.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.access_logger import log_request_access
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
- user_id: str | None = None,
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
- user_id: User identifier for auth/metrics
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
- # Convert messages to prompt format
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 request context for observability
136
- endpoint = "messages" # Claude SDK uses messages endpoint
137
- async with request_context(
138
- method="POST",
139
- path=f"/sdk/v1/{endpoint}",
140
- endpoint=endpoint,
141
- model=model,
142
- streaming=stream,
143
- service_type="claude_sdk_service",
144
- metrics=self.metrics, # Pass metrics for active request tracking
145
- ) as ctx:
146
- try:
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
- if stream:
154
- # For streaming, return the async iterator directly
155
- # Pass context to streaming method
156
- return self._stream_completion(
157
- prompt, options, model, request_id, ctx, timestamp
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
- except AuthenticationError as e:
166
- logger.error(
167
- "authentication_failed",
168
- user_id=user_id,
169
- error=str(e),
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
- raise
174
- except (ClaudeProxyError, ServiceUnavailableError) as e:
175
- # Log error via access logger (includes metrics)
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
- raise
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
- prompt: str,
266
+ ctx: RequestContext,
267
+ messages: list[dict[str, Any]],
188
268
  options: "ClaudeCodeOptions",
189
269
  model: str,
190
- request_id: str | None = None,
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
- # SDK request already logged in create_completion
287
+ request_id = ctx.request_id
288
+ logger.debug("claude_sdk_completion_start", request_id=request_id)
210
289
 
211
- messages = [
212
- m
213
- async for m in self.sdk_client.query_completion(prompt, options, request_id)
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 messages if isinstance(m, sdk_models.ResultMessage)), None
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 messages if isinstance(m, sdk_models.AssistantMessage)), None
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 messages
343
+ for m in sdk_messages
253
344
  if not isinstance(m, sdk_models.AssistantMessage | sdk_models.ResultMessage)
254
345
  ]
255
346
 
@@ -306,18 +397,18 @@ class ClaudeSDKService:
306
397
  request_id=request_id,
307
398
  )
308
399
 
309
- if ctx:
310
- ctx.add_metadata(
311
- status_code=200,
312
- tokens_input=usage.input_tokens,
313
- tokens_output=usage.output_tokens,
314
- cache_read_tokens=usage.cache_read_input_tokens,
315
- cache_write_tokens=usage.cache_creation_input_tokens,
316
- cost_usd=cost_usd,
317
- )
318
- await log_request_access(
319
- context=ctx, status_code=200, method="POST", metrics=self.metrics
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
- prompt: str,
421
+ ctx: RequestContext,
422
+ messages: list[dict[str, Any]],
331
423
  options: "ClaudeCodeOptions",
332
424
  model: str,
333
- request_id: str | None = None,
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
- sdk_stream = self.sdk_client.query_completion(prompt, options, request_id)
448
+ # Convert messages to single SDKMessage
449
+ sdk_message = self._convert_messages_to_sdk_message(messages, session_id)
358
450
 
359
- async for chunk in self.stream_processor.process_stream(
360
- sdk_stream=sdk_stream,
361
- model=model,
362
- request_id=request_id,
363
- ctx=ctx,
364
- sdk_message_mode=sdk_message_mode,
365
- pretty_format=pretty_format,
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
- # Log streaming chunk
368
- if request_id:
369
- await self._log_sdk_streaming_chunk(request_id, chunk, timestamp)
370
- yield chunk
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
- async def _validate_user_auth(self, user_id: str) -> None:
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
- Args:
377
- user_id: User identifier
480
+ # Create a listener for this stream
481
+ sdk_stream = stream_handle.create_listener()
378
482
 
379
- Raises:
380
- AuthenticationError: If authentication fails
381
- """
382
- if not self.auth_manager:
383
- return
384
- logger.debug("user_auth_validation_start", user_id=user_id)
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
- prompt: str,
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
- prompt: The formatted prompt
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
- "prompt": prompt,
410
- "options": options.model_dump()
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()