vantage-litellm-callback 0.0.3__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.
vantage_callback.py ADDED
@@ -0,0 +1,3 @@
1
+ from vantage_litellm_callback import callback_instance
2
+
3
+ __all__ = ["callback_instance"]
@@ -0,0 +1,11 @@
1
+ from .callback import VantageLiteLLMCallback
2
+ from .delivery import DeliveryGapTracker, DeliveryWorker
3
+
4
+ callback_instance = VantageLiteLLMCallback()
5
+
6
+ __all__ = [
7
+ "DeliveryGapTracker",
8
+ "DeliveryWorker",
9
+ "VantageLiteLLMCallback",
10
+ "callback_instance",
11
+ ]
@@ -0,0 +1,361 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections import OrderedDict
6
+ from collections.abc import AsyncGenerator, AsyncIterable, Mapping
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from time import monotonic
10
+ from typing import Any
11
+ from uuid import uuid4
12
+
13
+ from litellm.integrations.custom_logger import CustomLogger
14
+
15
+ from .client import EventSink, UnixSocketClient
16
+ from .delivery import DeliveryGapTracker, DeliveryWorker
17
+ from .projection import (
18
+ is_terminal_stream_chunk,
19
+ merge_usage,
20
+ project_event,
21
+ resolve_event_timestamp,
22
+ usage_from_standard_logging,
23
+ usage_from_stream_chunk,
24
+ )
25
+ from .schema import Usage
26
+
27
+ ATTEMPT_TTL_SECONDS = 60 * 60
28
+ MAX_TRACKED_ATTEMPTS = 10_000
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class _Attempt:
35
+ event_id: str
36
+ created_at: float
37
+ request_start_time: datetime
38
+
39
+
40
+ class VantageLiteLLMCallback(CustomLogger):
41
+ def __init__(
42
+ self,
43
+ *,
44
+ event_sink: EventSink | None = None,
45
+ queue_size: int | None = None,
46
+ recovery_probe_seconds: float | None = None,
47
+ gap_tracker: DeliveryGapTracker | None = None,
48
+ ) -> None:
49
+ super().__init__(turn_off_message_logging=True)
50
+ self._delivery = DeliveryWorker(
51
+ event_sink or UnixSocketClient(),
52
+ queue_size=queue_size,
53
+ recovery_probe_seconds=recovery_probe_seconds,
54
+ gap_tracker=gap_tracker,
55
+ )
56
+ self._attempts: OrderedDict[str, _Attempt] = OrderedDict()
57
+
58
+ @property
59
+ def gap_tracker(self) -> DeliveryGapTracker:
60
+ return self._delivery.gaps
61
+
62
+ async def wait_for_delivery(self) -> None:
63
+ await self._delivery.wait_for_idle()
64
+
65
+ async def close(self) -> None:
66
+ await self._delivery.close()
67
+
68
+ async def async_pre_call_deployment_hook(
69
+ self, kwargs: dict[str, Any], call_type: Any
70
+ ) -> dict[str, Any]:
71
+ del call_type
72
+ event_id = f"lta_{uuid4().hex}"
73
+ try:
74
+ request_start_time = resolve_event_timestamp(kwargs)
75
+ call_id = _litellm_call_id(kwargs)
76
+ if call_id is not None:
77
+ self._remember_attempt(call_id, event_id, request_start_time)
78
+ event = project_event(
79
+ kwargs,
80
+ event_id=event_id,
81
+ status="started",
82
+ request_start_time=request_start_time,
83
+ )
84
+ self._delivery.enqueue(event)
85
+ except asyncio.CancelledError:
86
+ raise
87
+ except Exception: # noqa: BLE001 - callback hooks must fail open
88
+ self._record_gap("projection", event_id=event_id, status="started")
89
+ return kwargs
90
+
91
+ async def async_post_call_success_deployment_hook(
92
+ self,
93
+ request_data: dict[str, Any],
94
+ response: Any,
95
+ call_type: Any,
96
+ ) -> Any:
97
+ del call_type
98
+ attempt: _Attempt | None = None
99
+ try:
100
+ attempt = self._take_attempt(request_data)
101
+ if attempt is None:
102
+ return response
103
+ event = project_event(
104
+ request_data,
105
+ event_id=attempt.event_id,
106
+ status="success",
107
+ response=response,
108
+ request_start_time=attempt.request_start_time,
109
+ )
110
+ self._delivery.enqueue(event)
111
+ except asyncio.CancelledError:
112
+ raise
113
+ except Exception: # noqa: BLE001 - callback hooks must fail open
114
+ event_id = attempt.event_id if attempt is not None else None
115
+ self._record_gap("projection", event_id=event_id, status="success")
116
+ return response
117
+
118
+ async def async_log_success_event(
119
+ self,
120
+ kwargs: dict[str, Any],
121
+ response_obj: Any,
122
+ start_time: Any,
123
+ end_time: Any,
124
+ ) -> None:
125
+ """Close attempts the deployment and iterator hooks could not.
126
+
127
+ LiteLLM builds ``standard_logging_object`` only after those hooks
128
+ fire, and hands it exclusively to this hook (``kwargs`` is the logging
129
+ object's ``model_call_details``). The LiteLLM call ID finds the
130
+ in-memory attempt. Removing that attempt keeps this a no-op whenever
131
+ a primary hook already emitted.
132
+ """
133
+ del start_time, end_time
134
+ attempt: _Attempt | None = None
135
+ try:
136
+ attempt = self._take_attempt(kwargs)
137
+ if attempt is None:
138
+ return
139
+ standard_logging_object = kwargs.get("standard_logging_object")
140
+ event = project_event(
141
+ kwargs,
142
+ event_id=attempt.event_id,
143
+ status="success",
144
+ response=response_obj,
145
+ usage_override=usage_from_standard_logging(standard_logging_object),
146
+ standard_logging_object=standard_logging_object,
147
+ request_start_time=attempt.request_start_time,
148
+ )
149
+ self._delivery.enqueue(event)
150
+ except asyncio.CancelledError:
151
+ raise
152
+ except Exception: # noqa: BLE001 - callback hooks must fail open
153
+ event_id = attempt.event_id if attempt is not None else None
154
+ self._record_gap("projection", event_id=event_id, status="success")
155
+
156
+ async def async_post_call_failure_hook(
157
+ self,
158
+ request_data: dict[str, Any],
159
+ original_exception: Exception,
160
+ user_api_key_dict: Any,
161
+ traceback_str: str | None = None,
162
+ ) -> None:
163
+ del original_exception, user_api_key_dict, traceback_str
164
+ attempt: _Attempt | None = None
165
+ try:
166
+ attempt = self._take_attempt(request_data)
167
+ if attempt is None:
168
+ return
169
+ event = project_event(
170
+ request_data,
171
+ event_id=attempt.event_id,
172
+ status="failure",
173
+ request_start_time=attempt.request_start_time,
174
+ )
175
+ self._delivery.enqueue(event)
176
+ except asyncio.CancelledError:
177
+ raise
178
+ except Exception: # noqa: BLE001 - callback hooks must fail open
179
+ event_id = attempt.event_id if attempt is not None else None
180
+ self._record_gap("projection", event_id=event_id, status="failure")
181
+
182
+ async def async_post_call_streaming_iterator_hook(
183
+ self,
184
+ user_api_key_dict: Any,
185
+ response: AsyncIterable[object],
186
+ request_data: dict[str, Any],
187
+ ) -> AsyncGenerator[object, None]:
188
+ del user_api_key_dict
189
+ try:
190
+ attempt = self._find_attempt(request_data)
191
+ except asyncio.CancelledError:
192
+ raise
193
+ except Exception: # noqa: BLE001 - callback hooks must fail open
194
+ attempt = None
195
+ if attempt is None:
196
+ async for chunk in response:
197
+ yield chunk
198
+ return
199
+
200
+ terminal_seen = False
201
+ usage = Usage()
202
+ try:
203
+ async for chunk in response:
204
+ try:
205
+ usage = merge_usage(usage, usage_from_stream_chunk(chunk))
206
+ if not terminal_seen and is_terminal_stream_chunk(chunk):
207
+ terminal_seen = True
208
+ status = _stream_terminal_status(chunk)
209
+ terminal_attempt = self._take_attempt(
210
+ request_data, expected=attempt
211
+ )
212
+ if terminal_attempt is None:
213
+ yield chunk
214
+ continue
215
+ event = project_event(
216
+ request_data,
217
+ event_id=terminal_attempt.event_id,
218
+ status=status,
219
+ response=chunk,
220
+ usage_override=usage,
221
+ request_start_time=terminal_attempt.request_start_time,
222
+ )
223
+ self._delivery.enqueue(event)
224
+ except asyncio.CancelledError:
225
+ raise
226
+ except Exception: # noqa: BLE001 - callback hooks must fail open
227
+ self._record_gap(
228
+ "projection",
229
+ event_id=attempt.event_id,
230
+ status="success",
231
+ )
232
+ yield chunk
233
+ finally:
234
+ if not terminal_seen:
235
+ self._close_unterminated_stream(request_data, attempt, usage)
236
+
237
+ def _close_unterminated_stream(
238
+ self,
239
+ request_data: Mapping[str, Any],
240
+ attempt: _Attempt,
241
+ usage: Usage,
242
+ ) -> None:
243
+ """Close a stream that ended without a terminal chunk.
244
+
245
+ With no usage accumulated the attempt stays in the map so
246
+ ``async_log_success_event`` can close it with LiteLLM's assembled
247
+ usage, or the failure hook can close an error. With usage in hand
248
+ this emits success itself.
249
+ """
250
+ if usage == Usage():
251
+ logger.warning(
252
+ "Vantage callback: stream ended without a terminal marker or "
253
+ "usage; deferring to the success-event safety net"
254
+ )
255
+ return
256
+
257
+ logger.warning(
258
+ "Vantage callback: stream ended without a terminal marker; "
259
+ "emitting success with usage merged from chunks"
260
+ )
261
+ try:
262
+ terminal_attempt = self._take_attempt(request_data, expected=attempt)
263
+ if terminal_attempt is None:
264
+ return
265
+ event = project_event(
266
+ request_data,
267
+ event_id=terminal_attempt.event_id,
268
+ status="success",
269
+ usage_override=usage,
270
+ request_start_time=terminal_attempt.request_start_time,
271
+ )
272
+ self._delivery.enqueue(event)
273
+ except asyncio.CancelledError:
274
+ raise
275
+ except Exception: # noqa: BLE001 - callback hooks must fail open
276
+ self._record_gap(
277
+ "projection",
278
+ event_id=attempt.event_id,
279
+ status="success",
280
+ )
281
+
282
+ def _record_gap(
283
+ self,
284
+ category: str,
285
+ *,
286
+ event_id: str | None,
287
+ status: str,
288
+ ) -> None:
289
+ try:
290
+ self._delivery.record_gap(
291
+ category, # type: ignore[arg-type]
292
+ event_id=event_id,
293
+ status=status, # type: ignore[arg-type]
294
+ )
295
+ except asyncio.CancelledError:
296
+ raise
297
+ except Exception: # noqa: BLE001 - gap tracking must fail open
298
+ # Gap accounting itself must not affect provider traffic.
299
+ return
300
+
301
+ def _remember_attempt(
302
+ self, call_id: str, event_id: str, request_start_time: datetime
303
+ ) -> None:
304
+ now = monotonic()
305
+ self._evict_attempts(now)
306
+ self._attempts.pop(call_id, None)
307
+ while len(self._attempts) >= MAX_TRACKED_ATTEMPTS:
308
+ self._attempts.popitem(last=False)
309
+ self._attempts[call_id] = _Attempt(
310
+ event_id=event_id,
311
+ created_at=now,
312
+ request_start_time=request_start_time,
313
+ )
314
+
315
+ def _find_attempt(self, request_data: Mapping[str, Any]) -> _Attempt | None:
316
+ self._evict_attempts(monotonic())
317
+ call_id = _litellm_call_id(request_data)
318
+ if call_id is None:
319
+ return None
320
+ return self._attempts.get(call_id)
321
+
322
+ def _take_attempt(
323
+ self,
324
+ request_data: Mapping[str, Any],
325
+ *,
326
+ expected: _Attempt | None = None,
327
+ ) -> _Attempt | None:
328
+ attempt = self._find_attempt(request_data)
329
+ if attempt is None or (expected is not None and attempt is not expected):
330
+ return None
331
+ call_id = _litellm_call_id(request_data)
332
+ if call_id is None:
333
+ return None
334
+ return self._attempts.pop(call_id, None)
335
+
336
+ def _evict_attempts(self, now: float) -> None:
337
+ while self._attempts:
338
+ _, attempt = next(iter(self._attempts.items()))
339
+ if now - attempt.created_at < ATTEMPT_TTL_SECONDS:
340
+ break
341
+ self._attempts.popitem(last=False)
342
+
343
+
344
+ def _litellm_call_id(request_data: Mapping[str, Any]) -> str | None:
345
+ call_id = request_data.get("litellm_call_id")
346
+ if isinstance(call_id, str) and call_id:
347
+ return call_id
348
+ return None
349
+
350
+
351
+ def _stream_terminal_status(response: object) -> str:
352
+ if isinstance(response, Mapping):
353
+ status = response.get("status")
354
+ event_type = response.get("type")
355
+ if (
356
+ status in {"failed", "cancelled", "incomplete"}
357
+ or event_type in {"response.failed", "response.incomplete"}
358
+ or response.get("error") is not None
359
+ ):
360
+ return "failure"
361
+ return "success"
@@ -0,0 +1,185 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ from dataclasses import dataclass
6
+ from typing import Protocol
7
+
8
+ from pydantic import ValidationError
9
+
10
+ from .schema import DeliveryGap, DeliveryGapAck, Frame, GapCategory, UsageAck
11
+
12
+ DEFAULT_SOCKET_PATH = "/var/run/vantage-collector/collector.sock"
13
+ DEFAULT_ACK_TIMEOUT_SECONDS = 5.0
14
+ DEFAULT_CONNECTION_POOL_SIZE = 16
15
+ MAX_ACK_BYTES = 4096
16
+
17
+
18
+ class DeliveryError(RuntimeError):
19
+ def __init__(
20
+ self,
21
+ message: str,
22
+ *,
23
+ category: GapCategory = "unknown",
24
+ ) -> None:
25
+ super().__init__(message)
26
+ self.category = category
27
+
28
+
29
+ class EventSink(Protocol):
30
+ async def send(self, frame: Frame) -> None: ...
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class _Connection:
35
+ reader: asyncio.StreamReader | None = None
36
+ writer: asyncio.StreamWriter | None = None
37
+
38
+ async def close(self) -> None:
39
+ writer = self.writer
40
+ self.reader = None
41
+ self.writer = None
42
+ if writer is None:
43
+ return
44
+ writer.close()
45
+ await writer.wait_closed()
46
+
47
+
48
+ class UnixSocketClient:
49
+ def __init__(
50
+ self,
51
+ socket_path: str | None = None,
52
+ ack_timeout_seconds: float | None = None,
53
+ connection_pool_size: int | None = None,
54
+ ) -> None:
55
+ self.socket_path = socket_path or os.getenv(
56
+ "VANTAGE_COLLECTOR_SOCKET_PATH", DEFAULT_SOCKET_PATH
57
+ )
58
+ self.ack_timeout_seconds = ack_timeout_seconds or _positive_float_from_env(
59
+ "VANTAGE_COLLECTOR_ACK_TIMEOUT_SECONDS",
60
+ DEFAULT_ACK_TIMEOUT_SECONDS,
61
+ )
62
+ pool_size = connection_pool_size or _positive_int_from_env(
63
+ "VANTAGE_COLLECTOR_CONNECTION_POOL_SIZE",
64
+ DEFAULT_CONNECTION_POOL_SIZE,
65
+ )
66
+ self._connections = asyncio.LifoQueue[_Connection](maxsize=pool_size)
67
+ for connection in tuple(_Connection() for _ in range(pool_size)):
68
+ self._connections.put_nowait(connection)
69
+
70
+ async def send(self, frame: Frame) -> None:
71
+ connection = await self._connections.get()
72
+ try:
73
+ await self._send_on_connection(connection, frame)
74
+ except asyncio.CancelledError:
75
+ await connection.close()
76
+ raise
77
+ except asyncio.TimeoutError as error:
78
+ await connection.close()
79
+ raise DeliveryError(
80
+ f"Vantage collector acknowledgement timed out for {_frame_id(frame)}",
81
+ category="timeout",
82
+ ) from error
83
+ except OSError as error:
84
+ await connection.close()
85
+ raise DeliveryError(
86
+ f"Vantage collector socket unavailable for {_frame_id(frame)}",
87
+ category="socket_unavailable",
88
+ ) from error
89
+ except CollectorRejection as error:
90
+ await connection.close()
91
+ raise DeliveryError(
92
+ f"Vantage collector rejected {_frame_id(frame)}",
93
+ category="collector_rejection",
94
+ ) from error
95
+ except (ValidationError, UnicodeDecodeError, ValueError) as error:
96
+ await connection.close()
97
+ raise DeliveryError(
98
+ f"Invalid Vantage collector acknowledgement for {_frame_id(frame)}",
99
+ category="protocol_ack",
100
+ ) from error
101
+ finally:
102
+ self._connections.put_nowait(connection)
103
+
104
+ async def close(self) -> None:
105
+ connections = tuple(
106
+ await asyncio.gather(
107
+ *(self._connections.get() for _ in range(self._connections.maxsize))
108
+ )
109
+ )
110
+ await asyncio.gather(*(connection.close() for connection in connections))
111
+ for connection in connections:
112
+ self._connections.put_nowait(connection)
113
+
114
+ async def _send_on_connection(self, connection: _Connection, frame: Frame) -> None:
115
+ if (
116
+ connection.reader is None
117
+ or connection.writer is None
118
+ or connection.writer.is_closing()
119
+ ):
120
+ connection.reader, connection.writer = await asyncio.wait_for(
121
+ asyncio.open_unix_connection(self.socket_path),
122
+ timeout=self.ack_timeout_seconds,
123
+ )
124
+
125
+ payload = (
126
+ frame.model_dump_json(exclude_none=True, by_alias=True).encode("utf-8")
127
+ + b"\n"
128
+ )
129
+ connection.writer.write(payload)
130
+ await asyncio.wait_for(
131
+ connection.writer.drain(), timeout=self.ack_timeout_seconds
132
+ )
133
+ ack_line = await asyncio.wait_for(
134
+ connection.reader.readline(),
135
+ timeout=self.ack_timeout_seconds,
136
+ )
137
+ if (
138
+ not ack_line
139
+ or len(ack_line) > MAX_ACK_BYTES
140
+ or not ack_line.endswith(b"\n")
141
+ ):
142
+ raise ValueError("invalid collector acknowledgement frame")
143
+
144
+ if isinstance(frame, DeliveryGap):
145
+ ack = DeliveryGapAck.model_validate_json(ack_line)
146
+ expected_id = frame.gap_id
147
+ acknowledged_id = ack.gap_id
148
+ else:
149
+ ack = UsageAck.model_validate_json(ack_line)
150
+ expected_id = frame.event_id
151
+ acknowledged_id = ack.event_id
152
+ if ack.error:
153
+ raise CollectorRejection(ack.error)
154
+ if acknowledged_id != expected_id:
155
+ raise ValueError("collector acknowledgement ID mismatch")
156
+ if ack.collector_sequence is None:
157
+ raise ValueError("collector acknowledgement omitted durable sequence")
158
+
159
+
160
+ class CollectorRejection(ValueError):
161
+ pass
162
+
163
+
164
+ def _frame_id(frame: Frame) -> str:
165
+ return frame.gap_id if isinstance(frame, DeliveryGap) else frame.event_id
166
+
167
+
168
+ def _positive_float_from_env(name: str, default: float) -> float:
169
+ raw_value = os.getenv(name)
170
+ if raw_value is None:
171
+ return default
172
+ value = float(raw_value)
173
+ if value <= 0:
174
+ raise ValueError(f"{name} must be greater than zero")
175
+ return value
176
+
177
+
178
+ def _positive_int_from_env(name: str, default: int) -> int:
179
+ raw_value = os.getenv(name)
180
+ if raw_value is None:
181
+ return default
182
+ value = int(raw_value)
183
+ if value <= 0:
184
+ raise ValueError(f"{name} must be greater than zero")
185
+ return value