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.
- ccproxy/_version.py +2 -2
- ccproxy/adapters/codex/__init__.py +11 -0
- ccproxy/adapters/openai/adapter.py +1 -1
- ccproxy/adapters/openai/models.py +1 -1
- ccproxy/adapters/openai/response_adapter.py +355 -0
- ccproxy/adapters/openai/response_models.py +178 -0
- ccproxy/adapters/openai/streaming.py +1 -0
- ccproxy/api/app.py +150 -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/codex.py +1231 -0
- ccproxy/api/routes/health.py +228 -3
- ccproxy/api/routes/proxy.py +25 -6
- ccproxy/api/services/permission_service.py +2 -2
- ccproxy/auth/openai/__init__.py +13 -0
- ccproxy/auth/openai/credentials.py +166 -0
- ccproxy/auth/openai/oauth_client.py +334 -0
- ccproxy/auth/openai/storage.py +184 -0
- 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 +6 -1
- 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/auth.py +398 -1
- ccproxy/cli/commands/serve.py +99 -1
- ccproxy/cli/options/claude_options.py +47 -0
- ccproxy/config/__init__.py +0 -3
- ccproxy/config/claude.py +171 -23
- ccproxy/config/codex.py +100 -0
- ccproxy/config/discovery.py +10 -1
- ccproxy/config/scheduler.py +2 -2
- ccproxy/config/settings.py +38 -1
- ccproxy/core/codex_transformers.py +389 -0
- ccproxy/core/http_transformers.py +458 -75
- ccproxy/core/logging.py +108 -12
- ccproxy/core/transformers.py +5 -0
- ccproxy/models/claude_sdk.py +57 -0
- ccproxy/models/detection.py +208 -0
- ccproxy/models/requests.py +22 -0
- ccproxy/models/responses.py +16 -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 +333 -130
- ccproxy/services/codex_detection_service.py +263 -0
- ccproxy/services/proxy_service.py +618 -197
- ccproxy/utils/__init__.py +9 -1
- ccproxy/utils/disconnection_monitor.py +83 -0
- ccproxy/utils/id_generator.py +12 -0
- ccproxy/utils/model_mapping.py +7 -5
- ccproxy/utils/startup_helpers.py +470 -0
- ccproxy_api-0.1.6.dist-info/METADATA +615 -0
- {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/RECORD +70 -47
- ccproxy/config/loader.py +0 -105
- ccproxy_api-0.1.4.dist-info/METADATA +0 -369
- {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/WHEEL +0 -0
- {ccproxy_api-0.1.4.dist-info → ccproxy_api-0.1.6.dist-info}/entry_points.txt +0 -0
- {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
|
+
)
|