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,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)