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,538 @@
1
+ """Stream handle for managing worker lifecycle and providing listeners."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ import uuid
8
+ from collections.abc import AsyncIterator
9
+ from typing import Any
10
+
11
+ import structlog
12
+
13
+ from ccproxy.claude_sdk.message_queue import QueueListener
14
+ from ccproxy.claude_sdk.session_client import SessionClient
15
+ from ccproxy.claude_sdk.stream_worker import StreamWorker, WorkerStatus
16
+ from ccproxy.config.claude import SessionPoolSettings
17
+
18
+
19
+ logger = structlog.get_logger(__name__)
20
+
21
+
22
+ class StreamHandle:
23
+ """Handle for a streaming response that manages worker and listeners."""
24
+
25
+ def __init__(
26
+ self,
27
+ message_iterator: AsyncIterator[Any],
28
+ session_id: str | None = None,
29
+ request_id: str | None = None,
30
+ session_client: SessionClient | None = None,
31
+ session_config: SessionPoolSettings | None = None,
32
+ ):
33
+ """Initialize the stream handle.
34
+
35
+ Args:
36
+ message_iterator: The SDK message iterator
37
+ session_id: Optional session ID
38
+ request_id: Optional request ID
39
+ session_client: Optional session client
40
+ session_config: Optional session pool configuration
41
+ """
42
+ self.handle_id = str(uuid.uuid4())
43
+ self._message_iterator = message_iterator
44
+ self.session_id = session_id
45
+ self.request_id = request_id
46
+ self._session_client = session_client
47
+
48
+ # Timeout configuration
49
+ self._session_config = session_config
50
+ self._first_chunk_timeout = (
51
+ session_config.stream_first_chunk_timeout if session_config else 3.0
52
+ )
53
+ self._ongoing_timeout = (
54
+ session_config.stream_ongoing_timeout if session_config else 60.0
55
+ )
56
+ self._interrupt_timeout = (
57
+ session_config.stream_interrupt_timeout if session_config else 10.0
58
+ )
59
+
60
+ # Worker management
61
+ self._worker: StreamWorker | None = None
62
+ self._worker_lock = asyncio.Lock()
63
+ self._listeners: dict[str, QueueListener] = {}
64
+ self._created_at = time.time()
65
+ self._first_listener_at: float | None = None
66
+
67
+ # Message lifecycle tracking for stale detection
68
+ self._first_chunk_received_at: float | None = None
69
+ self._completed_at: float | None = None
70
+ self._has_result_message = False
71
+ self._last_activity_at = time.time()
72
+
73
+ async def create_listener(self) -> AsyncIterator[Any]:
74
+ """Create a new listener for this stream.
75
+
76
+ This method starts the worker on first listener and returns
77
+ an async iterator for consuming messages.
78
+
79
+ Yields:
80
+ Messages from the stream
81
+ """
82
+ # Start worker if needed
83
+ await self._ensure_worker_started()
84
+
85
+ if not self._worker:
86
+ raise RuntimeError("Failed to start stream worker")
87
+
88
+ # Create listener
89
+ queue = self._worker.get_message_queue()
90
+ listener = await queue.create_listener()
91
+ self._listeners[listener.listener_id] = listener
92
+
93
+ if self._first_listener_at is None:
94
+ self._first_listener_at = time.time()
95
+
96
+ logger.debug(
97
+ "stream_handle_listener_created",
98
+ handle_id=self.handle_id,
99
+ listener_id=listener.listener_id,
100
+ total_listeners=len(self._listeners),
101
+ worker_status=self._worker.status.value,
102
+ )
103
+
104
+ try:
105
+ # Yield messages from listener
106
+ async for message in listener:
107
+ yield message
108
+
109
+ except GeneratorExit:
110
+ # Client disconnected
111
+ logger.debug(
112
+ "stream_handle_listener_disconnected",
113
+ handle_id=self.handle_id,
114
+ listener_id=listener.listener_id,
115
+ )
116
+
117
+ # Check if this will be the last listener after removal
118
+ remaining_listeners = len(self._listeners) - 1
119
+ if remaining_listeners == 0 and self._session_client:
120
+ logger.debug(
121
+ "stream_handle_last_listener_disconnected",
122
+ handle_id=self.handle_id,
123
+ listener_id=listener.listener_id,
124
+ message="Last listener disconnected, will trigger SDK interrupt in cleanup",
125
+ )
126
+
127
+ raise
128
+
129
+ finally:
130
+ # Remove listener
131
+ await self._remove_listener(listener.listener_id)
132
+
133
+ # Check if we should trigger cleanup
134
+ await self._check_cleanup()
135
+
136
+ async def _ensure_worker_started(self) -> None:
137
+ """Ensure the worker is started, creating it if needed."""
138
+ async with self._worker_lock:
139
+ if self._worker is None:
140
+ # Create worker
141
+ worker_id = f"{self.handle_id}-worker"
142
+ self._worker = StreamWorker(
143
+ worker_id=worker_id,
144
+ message_iterator=self._message_iterator,
145
+ session_id=self.session_id,
146
+ request_id=self.request_id,
147
+ session_client=self._session_client,
148
+ stream_handle=self, # Pass self for message tracking
149
+ )
150
+
151
+ # Start worker
152
+ await self._worker.start()
153
+
154
+ logger.debug(
155
+ "stream_handle_worker_created",
156
+ handle_id=self.handle_id,
157
+ worker_id=worker_id,
158
+ session_id=self.session_id,
159
+ )
160
+
161
+ async def _remove_listener(self, listener_id: str) -> None:
162
+ """Remove a listener and clean it up.
163
+
164
+ Args:
165
+ listener_id: ID of the listener to remove
166
+ """
167
+ if listener_id in self._listeners:
168
+ listener = self._listeners.pop(listener_id)
169
+ listener.close()
170
+
171
+ if self._worker:
172
+ queue = self._worker.get_message_queue()
173
+ await queue.remove_listener(listener_id)
174
+
175
+ logger.debug(
176
+ "stream_handle_listener_removed",
177
+ handle_id=self.handle_id,
178
+ listener_id=listener_id,
179
+ remaining_listeners=len(self._listeners),
180
+ )
181
+
182
+ async def _check_cleanup(self) -> None:
183
+ """Check if cleanup is needed when no listeners remain."""
184
+ async with self._worker_lock:
185
+ if len(self._listeners) == 0 and self._worker:
186
+ worker_status = self._worker.status.value
187
+
188
+ # Check if worker has already completed naturally
189
+ if worker_status in ("completed", "error"):
190
+ logger.debug(
191
+ "stream_handle_worker_already_finished",
192
+ handle_id=self.handle_id,
193
+ worker_status=worker_status,
194
+ message="Worker already finished, no interrupt needed",
195
+ )
196
+ return
197
+
198
+ # Send shutdown signal to any remaining queue listeners before interrupt
199
+ logger.debug(
200
+ "stream_handle_shutting_down_queue_before_interrupt",
201
+ handle_id=self.handle_id,
202
+ message="Sending shutdown signal to queue listeners before interrupt",
203
+ )
204
+ queue = self._worker.get_message_queue()
205
+ await queue.broadcast_shutdown()
206
+
207
+ # No more listeners - trigger interrupt if session client available and worker is still running
208
+ if self._session_client:
209
+ # Check if worker is already stopped/interrupted - no need to interrupt SDK
210
+ if self._worker and self._worker.status.value in (
211
+ "interrupted",
212
+ "completed",
213
+ "error",
214
+ ):
215
+ logger.debug(
216
+ "stream_handle_worker_already_stopped",
217
+ handle_id=self.handle_id,
218
+ worker_status=worker_status,
219
+ message="Worker already stopped, skipping SDK interrupt entirely",
220
+ )
221
+ # Still stop the worker to ensure cleanup
222
+ if self._worker:
223
+ logger.info(
224
+ "stream_handle_stopping_worker_direct",
225
+ handle_id=self.handle_id,
226
+ message="Stopping worker directly since SDK interrupt not needed",
227
+ )
228
+ try:
229
+ await self._worker.stop(timeout=self._interrupt_timeout)
230
+ except Exception as worker_error:
231
+ logger.warning(
232
+ "stream_handle_worker_stop_error",
233
+ handle_id=self.handle_id,
234
+ error=str(worker_error),
235
+ message="Worker stop failed but continuing",
236
+ )
237
+ else:
238
+ logger.debug(
239
+ "stream_handle_all_listeners_disconnected",
240
+ handle_id=self.handle_id,
241
+ worker_status=worker_status,
242
+ message="All listeners disconnected, triggering SDK interrupt",
243
+ )
244
+
245
+ # Schedule interrupt using a background task with timeout control
246
+ try:
247
+ # Create a background task with proper timeout and error handling
248
+ asyncio.create_task(self._safe_interrupt_with_timeout())
249
+ logger.debug(
250
+ "stream_handle_interrupt_scheduled",
251
+ handle_id=self.handle_id,
252
+ message="SDK interrupt scheduled with timeout control",
253
+ )
254
+ except Exception as e:
255
+ logger.error(
256
+ "stream_handle_interrupt_schedule_error",
257
+ handle_id=self.handle_id,
258
+ error=str(e),
259
+ message="Failed to schedule SDK interrupt",
260
+ )
261
+ else:
262
+ # No more listeners - worker continues but messages are discarded
263
+ logger.debug(
264
+ "stream_handle_no_listeners",
265
+ handle_id=self.handle_id,
266
+ worker_status=worker_status,
267
+ message="Worker continues without listeners",
268
+ )
269
+
270
+ # Don't stop the worker - let it complete naturally
271
+ # This ensures proper stream completion and interrupt capability
272
+
273
+ async def _safe_interrupt_with_timeout(self) -> None:
274
+ """Safely trigger session client interrupt with proper timeout and error handling."""
275
+ if not self._session_client:
276
+ return
277
+
278
+ try:
279
+ # Call SDK interrupt first - let it handle stream cleanup gracefully
280
+ logger.debug(
281
+ "stream_handle_calling_sdk_interrupt",
282
+ handle_id=self.handle_id,
283
+ message="Calling SDK interrupt to gracefully stop stream",
284
+ )
285
+
286
+ await asyncio.wait_for(
287
+ self._session_client.interrupt(),
288
+ timeout=self._interrupt_timeout, # Configurable timeout for stream handle initiated interrupts
289
+ )
290
+ logger.debug(
291
+ "stream_handle_interrupt_completed",
292
+ handle_id=self.handle_id,
293
+ message="SDK interrupt completed successfully",
294
+ )
295
+
296
+ # Stop our worker after SDK interrupt to ensure it's not blocking the session
297
+ if self._worker:
298
+ logger.info(
299
+ "stream_handle_stopping_worker_after_interrupt",
300
+ handle_id=self.handle_id,
301
+ message="Stopping worker to free up session for reuse",
302
+ )
303
+ try:
304
+ await self._worker.stop(timeout=self._interrupt_timeout)
305
+ except Exception as worker_error:
306
+ logger.warning(
307
+ "stream_handle_worker_stop_error",
308
+ handle_id=self.handle_id,
309
+ error=str(worker_error),
310
+ message="Worker stop failed but continuing",
311
+ )
312
+
313
+ except TimeoutError:
314
+ logger.warning(
315
+ "stream_handle_interrupt_timeout",
316
+ handle_id=self.handle_id,
317
+ message=f"SDK interrupt timed out after {self._interrupt_timeout} seconds, falling back to worker stop",
318
+ )
319
+
320
+ # Fallback: Stop our worker manually if SDK interrupt timed out
321
+ if self._worker:
322
+ logger.info(
323
+ "stream_handle_fallback_worker_stop",
324
+ handle_id=self.handle_id,
325
+ message="SDK interrupt timed out, stopping worker as fallback",
326
+ )
327
+ try:
328
+ await self._worker.stop(timeout=self._interrupt_timeout)
329
+ except Exception as worker_error:
330
+ logger.warning(
331
+ "stream_handle_fallback_worker_stop_error",
332
+ handle_id=self.handle_id,
333
+ error=str(worker_error),
334
+ message="Fallback worker stop also failed",
335
+ )
336
+
337
+ except Exception as e:
338
+ logger.error(
339
+ "stream_handle_interrupt_failed",
340
+ handle_id=self.handle_id,
341
+ error=str(e),
342
+ error_type=type(e).__name__,
343
+ message="SDK interrupt failed with error",
344
+ )
345
+
346
+ # Fallback: Stop our worker manually if SDK interrupt failed
347
+ if self._worker:
348
+ logger.info(
349
+ "stream_handle_fallback_worker_stop_after_error",
350
+ handle_id=self.handle_id,
351
+ message="SDK interrupt failed, stopping worker as fallback",
352
+ )
353
+ try:
354
+ await self._worker.stop(timeout=self._interrupt_timeout)
355
+ except Exception as worker_error:
356
+ logger.warning(
357
+ "stream_handle_fallback_worker_stop_error",
358
+ handle_id=self.handle_id,
359
+ error=str(worker_error),
360
+ message="Fallback worker stop also failed",
361
+ )
362
+
363
+ async def interrupt(self) -> bool:
364
+ """Interrupt the stream.
365
+
366
+ Returns:
367
+ True if interrupted successfully
368
+ """
369
+ if not self._worker:
370
+ logger.warning(
371
+ "stream_handle_interrupt_no_worker",
372
+ handle_id=self.handle_id,
373
+ )
374
+ return False
375
+
376
+ logger.debug(
377
+ "stream_handle_interrupting",
378
+ handle_id=self.handle_id,
379
+ worker_status=self._worker.status.value,
380
+ active_listeners=len(self._listeners),
381
+ )
382
+
383
+ try:
384
+ # Stop the worker
385
+ await self._worker.stop(timeout=self._interrupt_timeout)
386
+
387
+ # Close all listeners
388
+ for listener in self._listeners.values():
389
+ listener.close()
390
+ self._listeners.clear()
391
+
392
+ logger.info(
393
+ "stream_handle_interrupted",
394
+ handle_id=self.handle_id,
395
+ )
396
+ return True
397
+
398
+ except Exception as e:
399
+ logger.error(
400
+ "stream_handle_interrupt_error",
401
+ handle_id=self.handle_id,
402
+ error=str(e),
403
+ )
404
+ return False
405
+
406
+ async def wait_for_completion(self, timeout: float | None = None) -> bool:
407
+ """Wait for the stream to complete.
408
+
409
+ Args:
410
+ timeout: Optional timeout in seconds
411
+
412
+ Returns:
413
+ True if completed, False if timed out
414
+ """
415
+ if not self._worker:
416
+ return True
417
+
418
+ return await self._worker.wait_for_completion(timeout)
419
+
420
+ def get_stats(self) -> dict[str, Any]:
421
+ """Get stream handle statistics.
422
+
423
+ Returns:
424
+ Dictionary of statistics
425
+ """
426
+ stats = {
427
+ "handle_id": self.handle_id,
428
+ "session_id": self.session_id,
429
+ "request_id": self.request_id,
430
+ "active_listeners": len(self._listeners),
431
+ "lifetime_seconds": time.time() - self._created_at,
432
+ "time_to_first_listener": (
433
+ self._first_listener_at - self._created_at
434
+ if self._first_listener_at
435
+ else None
436
+ ),
437
+ }
438
+
439
+ if self._worker:
440
+ worker_stats = self._worker.get_stats()
441
+ stats["worker_stats"] = worker_stats # type: ignore[assignment]
442
+ else:
443
+ stats["worker_stats"] = None
444
+
445
+ return stats
446
+
447
+ @property
448
+ def has_active_listeners(self) -> bool:
449
+ """Check if there are any active listeners."""
450
+ return len(self._listeners) > 0
451
+
452
+ @property
453
+ def worker_status(self) -> WorkerStatus | None:
454
+ """Get the worker status if worker exists."""
455
+ return self._worker.status if self._worker else None
456
+
457
+ # Message lifecycle tracking methods for stale detection
458
+
459
+ def on_first_chunk_received(self) -> None:
460
+ """Called when SystemMessage(init) is received - first chunk."""
461
+ if self._first_chunk_received_at is None:
462
+ self._first_chunk_received_at = time.time()
463
+ self._last_activity_at = self._first_chunk_received_at
464
+ logger.debug(
465
+ "stream_handle_first_chunk_received",
466
+ handle_id=self.handle_id,
467
+ session_id=self.session_id,
468
+ )
469
+
470
+ def on_message_received(self, message: Any) -> None:
471
+ """Called when any message is received to update activity."""
472
+ self._last_activity_at = time.time()
473
+
474
+ def on_completion(self) -> None:
475
+ """Called when ResultMessage is received - stream completed."""
476
+ if not self._has_result_message:
477
+ self._has_result_message = True
478
+ self._completed_at = time.time()
479
+ self._last_activity_at = self._completed_at
480
+ logger.debug(
481
+ "stream_handle_completed",
482
+ handle_id=self.handle_id,
483
+ session_id=self.session_id,
484
+ )
485
+
486
+ @property
487
+ def is_completed(self) -> bool:
488
+ """Check if stream has completed (received ResultMessage)."""
489
+ return self._has_result_message
490
+
491
+ @property
492
+ def has_first_chunk(self) -> bool:
493
+ """Check if stream has received first chunk (SystemMessage init)."""
494
+ return self._first_chunk_received_at is not None
495
+
496
+ @property
497
+ def idle_seconds(self) -> float:
498
+ """Get seconds since last activity."""
499
+ return time.time() - self._last_activity_at
500
+
501
+ def is_stale(self) -> bool:
502
+ """Check if stream is stale based on configurable timeout logic.
503
+
504
+ Returns:
505
+ True if stream should be considered stale
506
+ """
507
+ if self.is_completed:
508
+ # Completed streams are never stale
509
+ return False
510
+
511
+ if not self.has_first_chunk:
512
+ # No first chunk received - configurable timeout
513
+ return self.idle_seconds > self._first_chunk_timeout
514
+ else:
515
+ # First chunk received but not completed - configurable timeout
516
+ return self.idle_seconds > self._ongoing_timeout
517
+
518
+ def is_first_chunk_timeout(self) -> bool:
519
+ """Check if this is specifically a first chunk timeout.
520
+
521
+ Returns:
522
+ True if no first chunk received and timeout exceeded
523
+ """
524
+ return (
525
+ not self.has_first_chunk and self.idle_seconds > self._first_chunk_timeout
526
+ )
527
+
528
+ def is_ongoing_timeout(self) -> bool:
529
+ """Check if this is an ongoing stream timeout.
530
+
531
+ Returns:
532
+ True if first chunk received but ongoing timeout exceeded
533
+ """
534
+ return (
535
+ self.has_first_chunk
536
+ and not self.is_completed
537
+ and self.idle_seconds > self._ongoing_timeout
538
+ )