mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
feat(custom-logger-v2): add types, dispatcher and route callback call sites (step 0) (#51214)
This commit is contained in:
parent
7b0a37ba74
commit
20b6e855bd
53 changed files with 9155 additions and 1603 deletions
|
|
@ -3,6 +3,8 @@
|
|||
import re
|
||||
import traceback
|
||||
from collections.abc import AsyncGenerator, Mapping, Sequence
|
||||
from functools import cache
|
||||
from itertools import takewhile
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Final, Optional
|
||||
|
||||
|
|
@ -1120,3 +1122,9 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
|
|||
msg[key] = self._redact_base64(value=val, max_depth=max_depth)
|
||||
filtered_messages.append(msg)
|
||||
return filtered_messages
|
||||
|
||||
|
||||
@cache
|
||||
def subclass_overrides_hook(cls: type, hook_name: str, base: type = CustomLogger) -> bool:
|
||||
below_base: Final = takewhile(lambda klass: klass is not base, cls.__mro__)
|
||||
return any(hook_name in vars(klass) for klass in below_base)
|
||||
|
|
|
|||
74
litellm/integrations/custom_logger_v2/__init__.py
Normal file
74
litellm/integrations/custom_logger_v2/__init__.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
from litellm.integrations.custom_logger_v2.call import CallContext, CallFacts, CallIds, CallKind, CallTarget, CallTiming
|
||||
from litellm.integrations.custom_logger_v2.dispatcher import Dispatcher, HookResult, RequestScope
|
||||
from litellm.integrations.custom_logger_v2.enforcement import ContractViolation, UndeclaredReadError
|
||||
from litellm.integrations.custom_logger_v2.enums import Activation, Field, FieldTier, Hook, OptOutBy
|
||||
from litellm.integrations.custom_logger_v2.hooks import (
|
||||
HOOK_SPECS,
|
||||
HookSpec,
|
||||
InvalidDeclarationError,
|
||||
Subscription,
|
||||
)
|
||||
from litellm.integrations.custom_logger_v2.logger import CustomLoggerV2, on
|
||||
from litellm.integrations.custom_logger_v2.manifest import CallbackManifest, build_manifest
|
||||
from litellm.integrations.custom_logger_v2.offpath import InProcessSink, OffPathSink
|
||||
from litellm.integrations.custom_logger_v2.outcomes import (
|
||||
Abort,
|
||||
InvalidPatchError,
|
||||
JsonValue,
|
||||
MergeField,
|
||||
Outcome,
|
||||
Patch,
|
||||
Reroute,
|
||||
Respond,
|
||||
SetField,
|
||||
)
|
||||
from litellm.integrations.custom_logger_v2.patches import apply_patch, apply_patches
|
||||
from litellm.integrations.custom_logger_v2.planner import HookPlan, plan_hook
|
||||
from litellm.integrations.custom_logger_v2.registry import CallbackRegistry
|
||||
from litellm.integrations.custom_logger_v2.selection import RequestSelection, Selection, is_selected
|
||||
|
||||
__all__ = [
|
||||
"HOOK_SPECS",
|
||||
"Abort",
|
||||
"Activation",
|
||||
"CallContext",
|
||||
"CallFacts",
|
||||
"CallIds",
|
||||
"CallKind",
|
||||
"CallTarget",
|
||||
"CallTiming",
|
||||
"CallbackManifest",
|
||||
"CallbackRegistry",
|
||||
"ContractViolation",
|
||||
"CustomLoggerV2",
|
||||
"Dispatcher",
|
||||
"Field",
|
||||
"FieldTier",
|
||||
"Hook",
|
||||
"HookPlan",
|
||||
"HookResult",
|
||||
"HookSpec",
|
||||
"InProcessSink",
|
||||
"InvalidDeclarationError",
|
||||
"InvalidPatchError",
|
||||
"JsonValue",
|
||||
"MergeField",
|
||||
"OffPathSink",
|
||||
"OptOutBy",
|
||||
"Outcome",
|
||||
"Patch",
|
||||
"RequestScope",
|
||||
"RequestSelection",
|
||||
"Reroute",
|
||||
"Respond",
|
||||
"Selection",
|
||||
"SetField",
|
||||
"Subscription",
|
||||
"UndeclaredReadError",
|
||||
"apply_patch",
|
||||
"apply_patches",
|
||||
"build_manifest",
|
||||
"is_selected",
|
||||
"on",
|
||||
"plan_hook",
|
||||
]
|
||||
55
litellm/integrations/custom_logger_v2/call.py
Normal file
55
litellm/integrations/custom_logger_v2/call.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Final, Generic, TypeVar
|
||||
|
||||
from litellm.integrations.custom_logger_v2.enums import Hook
|
||||
|
||||
StateT: Final = TypeVar("StateT")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CallIds:
|
||||
call_id: str
|
||||
trace_id: str | None = None
|
||||
session_id: str | None = None
|
||||
parent_call_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CallKind:
|
||||
call_type: str
|
||||
route: str | None = None
|
||||
stream: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CallTarget:
|
||||
model: str
|
||||
model_group: str | None = None
|
||||
provider: str | None = None
|
||||
api_base: str | None = None
|
||||
deployment_id: str | None = None
|
||||
attempt: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CallTiming:
|
||||
start: float
|
||||
provider_start: float | None = None
|
||||
first_token: float | None = None
|
||||
end: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CallFacts:
|
||||
ids: CallIds
|
||||
kind: CallKind
|
||||
target: CallTarget
|
||||
timing: CallTiming
|
||||
cache_hit: bool | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CallContext(Generic[StateT]):
|
||||
hook: Hook
|
||||
call: CallFacts | None
|
||||
state: StateT
|
||||
400
litellm/integrations/custom_logger_v2/dispatcher.py
Normal file
400
litellm/integrations/custom_logger_v2/dispatcher.py
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
from collections import Counter
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from itertools import chain
|
||||
from operator import itemgetter
|
||||
from types import TracebackType
|
||||
from typing import Final, Protocol, TypeAlias, TypeVar
|
||||
|
||||
from typing_extensions import Self
|
||||
|
||||
from litellm.integrations.custom_logger_v2.call import CallContext, CallFacts
|
||||
from litellm.integrations.custom_logger_v2.enforcement import ContractViolation, Enforced, Policy, Unchecked
|
||||
from litellm.integrations.custom_logger_v2.enums import Field, Hook
|
||||
from litellm.integrations.custom_logger_v2.hooks import HOOK_SPECS
|
||||
from litellm.integrations.custom_logger_v2.manifest import LegacyBinding
|
||||
from litellm.integrations.custom_logger_v2.offpath import InProcessSink, OffPathSink
|
||||
from litellm.integrations.custom_logger_v2.outcomes import Abort, Outcome, Patch, Reroute, Respond
|
||||
from litellm.integrations.custom_logger_v2.patches import apply_patch, apply_patches
|
||||
from litellm.integrations.custom_logger_v2.planner import EMPTY_PLAN, Entry, HookPlan
|
||||
from litellm.integrations.custom_logger_v2.registry import CallbackRegistry, RegistrySnapshot
|
||||
from litellm.integrations.custom_logger_v2.selection import RequestSelection, is_selected
|
||||
|
||||
LegacyT: Final = TypeVar("LegacyT")
|
||||
|
||||
Between: TypeAlias = Callable[[Mapping[Field, object]], Awaitable[Mapping[Field, object]]]
|
||||
SyncBinding: TypeAlias = Callable[[object, object], object]
|
||||
|
||||
SWALLOWING_HOOKS: Final = frozenset({Hook.response_headers, Hook.post_call_failure})
|
||||
|
||||
_LOGGER: Final = logging.getLogger("LiteLLM")
|
||||
|
||||
|
||||
class _Handler(Protocol):
|
||||
def __call__(self, view: object, ctx: CallContext[object], /) -> object: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HookResult:
|
||||
fields: Mapping[Field, object]
|
||||
patches: tuple[Patch, ...] = ()
|
||||
outcome: Outcome | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Chain:
|
||||
fields: Mapping[Field, object]
|
||||
patches: tuple[tuple[int, Patch], ...] = ()
|
||||
reroute: Reroute | None = None
|
||||
terminal: Abort | Respond | None = None
|
||||
|
||||
@property
|
||||
def ordered_patches(self) -> tuple[Patch, ...]:
|
||||
return tuple(patch for _, patch in sorted(self.patches, key=itemgetter(0)))
|
||||
|
||||
|
||||
def _handler(logger: object, name: str) -> _Handler:
|
||||
found: Final[object] = getattr(logger, name, None)
|
||||
if not callable(found):
|
||||
raise ContractViolation(f"{name} is not callable")
|
||||
return found
|
||||
|
||||
|
||||
async def _awaited(returned: object) -> object:
|
||||
return await returned if inspect.isawaitable(returned) else returned
|
||||
|
||||
|
||||
def _decides(hook: Hook, result: object) -> bool:
|
||||
match result:
|
||||
case Abort() | Respond():
|
||||
return True
|
||||
case ContractViolation():
|
||||
return True
|
||||
case Exception():
|
||||
return hook not in SWALLOWING_HOOKS
|
||||
case BaseException():
|
||||
return True
|
||||
case _:
|
||||
return False
|
||||
|
||||
|
||||
def _fatal(error: BaseException) -> bool:
|
||||
return isinstance(error, ContractViolation) or not isinstance(error, Exception)
|
||||
|
||||
|
||||
def _split(
|
||||
stages: tuple[tuple[Entry, ...], ...],
|
||||
) -> tuple[tuple[tuple[Entry, ...], ...], tuple[tuple[Entry, ...], ...]]:
|
||||
if stages and stages[-1][0].subscription.parallel:
|
||||
return stages[:-1], stages[-1:]
|
||||
return stages, ()
|
||||
|
||||
|
||||
def _legacy_start(
|
||||
entry: Entry, binding: Callable[[object, object], LegacyT] | None, fields: Mapping[Field, object]
|
||||
) -> LegacyT | None:
|
||||
call: Final = entry.manifest.legacy_call
|
||||
if call is None or binding is None:
|
||||
raise ContractViolation(
|
||||
f"{entry.manifest.selection.name} is a legacy entry, so the hook point must pass a binding"
|
||||
)
|
||||
return call(binding, fields)
|
||||
|
||||
|
||||
async def _run_legacy(entry: Entry, binding: LegacyBinding | None, fields: Mapping[Field, object]) -> object:
|
||||
started: Final = _legacy_start(entry, binding, fields)
|
||||
return None if started is None else await started
|
||||
|
||||
|
||||
async def _gather_started(started: tuple[Awaitable[object] | None, ...], fail_fast: bool) -> tuple[object, ...]:
|
||||
pending: Final = tuple(run for run in started if run is not None)
|
||||
if not pending:
|
||||
return tuple(None for _ in started)
|
||||
done: Final = iter(
|
||||
await asyncio.gather(*pending) if fail_fast else await asyncio.gather(*pending, return_exceptions=True)
|
||||
)
|
||||
return tuple(None if run is None else next(done) for run in started)
|
||||
|
||||
|
||||
def _fail_fast(hook: Hook, stage: tuple[Entry, ...]) -> bool:
|
||||
return HOOK_SPECS[hook].fail_fast_parallel and all(entry.subscription.parallel for entry in stage)
|
||||
|
||||
|
||||
async def run_legacy_plan(
|
||||
hook: Hook,
|
||||
plan: HookPlan,
|
||||
fields: Mapping[Field, object],
|
||||
binding: LegacyBinding,
|
||||
between: Between | None = None,
|
||||
) -> Mapping[Field, object]:
|
||||
serial, parallel = _split(plan.stages)
|
||||
current: Mapping[Field, object] = fields # rebind-ok: each legacy entry may replace the kwargs
|
||||
for stage in serial:
|
||||
for entry in stage:
|
||||
result = await _run_legacy(entry, binding, current)
|
||||
if isinstance(result, Patch):
|
||||
current = apply_patch(current, result)
|
||||
if between is not None:
|
||||
current = await between(current)
|
||||
for stage in parallel:
|
||||
started = tuple(_legacy_start(entry, binding, current) for entry in stage)
|
||||
results = await _gather_started(started, _fail_fast(hook, stage))
|
||||
failure = next((result for result in results if isinstance(result, BaseException)), None)
|
||||
if failure is not None:
|
||||
raise failure
|
||||
for entry in plan.inline:
|
||||
_ = await _run_legacy(entry, binding, current)
|
||||
return current
|
||||
|
||||
|
||||
def run_legacy_plan_sync(
|
||||
plan: HookPlan, fields: Mapping[Field, object], binding: SyncBinding
|
||||
) -> Mapping[Field, object]:
|
||||
staged: Final = tuple(chain.from_iterable(plan.stages))
|
||||
if any(entry.subscription.parallel for entry in staged):
|
||||
raise ContractViolation("a synchronous hook cannot run parallel legacy entries")
|
||||
current: Mapping[Field, object] = fields # rebind-ok: each legacy entry may replace the kwargs
|
||||
for entry in (*staged, *plan.inline):
|
||||
result = _legacy_start(entry, binding, current)
|
||||
if isinstance(result, Patch):
|
||||
current = apply_patch(current, result)
|
||||
return current
|
||||
|
||||
|
||||
class Dispatcher:
|
||||
def __init__(
|
||||
self,
|
||||
registry: CallbackRegistry,
|
||||
sink: OffPathSink | None = None,
|
||||
enforce: bool = False,
|
||||
) -> None:
|
||||
self.registry: Final = registry
|
||||
self.sink: Final[OffPathSink] = sink or InProcessSink()
|
||||
self.policy: Final[Policy] = Enforced() if enforce else Unchecked()
|
||||
self.failures: Final[Counter[tuple[str, Hook]]] = Counter() # mutable-ok: process-wide failure counts
|
||||
|
||||
def open_scope(self, selection: RequestSelection) -> "RequestScope":
|
||||
return RequestScope(self, self.registry.snapshot, selection)
|
||||
|
||||
def record_failure(self, entry: Entry, hook: Hook, error: BaseException) -> None:
|
||||
self.failures[(entry.manifest.selection.name, hook)] += 1
|
||||
_LOGGER.warning("custom logger v2 %s failed on %s: %r", entry.manifest.selection.name, hook.value, error)
|
||||
|
||||
|
||||
class RequestScope:
|
||||
def __init__(self, dispatcher: Dispatcher, snapshot: RegistrySnapshot, selection: RequestSelection) -> None:
|
||||
self._dispatcher: Final = dispatcher
|
||||
self._snapshot: Final = snapshot
|
||||
self._selection: Final = selection
|
||||
self._states: dict[int, object] = {} # mutable-ok: request_state instances are created lazily per logger
|
||||
self.reroute: Reroute | None = None
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self, kind: type[BaseException] | None, error: BaseException | None, trace: TracebackType | None
|
||||
) -> None:
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
self._states.clear()
|
||||
|
||||
def selects(self, entry: Entry) -> bool:
|
||||
return is_selected(entry.manifest.selection, entry.activation, self._selection)
|
||||
|
||||
def plan(self, hook: Hook) -> HookPlan:
|
||||
entries: Final = self._snapshot.entries[hook]
|
||||
if not entries:
|
||||
return EMPTY_PLAN
|
||||
selected: Final = tuple(index for index, entry in enumerate(entries) if self.selects(entry))
|
||||
return self._snapshot.plan(hook, selected) if selected else EMPTY_PLAN
|
||||
|
||||
def reads(self, hook: Hook) -> frozenset[Field]:
|
||||
return self.plan(hook).reads
|
||||
|
||||
async def run_hook(
|
||||
self,
|
||||
hook: Hook,
|
||||
fields: Mapping[Field, object],
|
||||
call: CallFacts | None,
|
||||
) -> HookResult:
|
||||
plan: Final = self.plan(hook)
|
||||
if plan is EMPTY_PLAN:
|
||||
return HookResult(fields=fields)
|
||||
chain: Final = await self._run_stages(hook, plan.stages, _Chain(fields=fields), call, None)
|
||||
patches: Final = chain.ordered_patches
|
||||
if chain.terminal is not None:
|
||||
return HookResult(fields=chain.fields, patches=patches, outcome=chain.terminal)
|
||||
await self._run_awaited(hook, plan.awaited, chain.fields, call)
|
||||
if plan.off_path:
|
||||
self._dispatcher.sink.submit(self._deliver_off_path(hook, plan.off_path, chain.fields, call))
|
||||
if chain.reroute is not None:
|
||||
self.reroute = chain.reroute
|
||||
return HookResult(fields=chain.fields, patches=patches, outcome=chain.reroute)
|
||||
|
||||
async def run_plan(
|
||||
self,
|
||||
hook: Hook,
|
||||
plan: HookPlan,
|
||||
fields: Mapping[Field, object],
|
||||
call: CallFacts | None,
|
||||
binding: LegacyBinding,
|
||||
between: Between | None = None,
|
||||
) -> HookResult:
|
||||
serial, parallel = _split(plan.stages)
|
||||
chain: Final = await self._run_stages(hook, serial, _Chain(fields=fields), call, binding)
|
||||
if chain.terminal is not None:
|
||||
return HookResult(fields=chain.fields, patches=chain.ordered_patches, outcome=chain.terminal)
|
||||
joined: Final = (
|
||||
chain
|
||||
if between is None
|
||||
else _Chain(fields=await between(chain.fields), patches=chain.patches, reroute=chain.reroute)
|
||||
)
|
||||
done: Final = await self._run_stages(hook, parallel, joined, call, binding)
|
||||
if done.terminal is not None:
|
||||
return HookResult(fields=done.fields, patches=done.ordered_patches, outcome=done.terminal)
|
||||
for entry in plan.inline:
|
||||
_ = await _run_legacy(entry, binding, done.fields)
|
||||
await self._run_awaited(hook, plan.awaited, done.fields, call)
|
||||
if plan.off_path:
|
||||
self._dispatcher.sink.submit(self._deliver_off_path(hook, plan.off_path, done.fields, call))
|
||||
if done.reroute is not None:
|
||||
self.reroute = done.reroute
|
||||
return HookResult(fields=done.fields, patches=done.ordered_patches, outcome=done.reroute)
|
||||
|
||||
def run_plan_sync(
|
||||
self,
|
||||
hook: Hook,
|
||||
plan: HookPlan,
|
||||
fields: Mapping[Field, object],
|
||||
call: CallFacts | None,
|
||||
binding: SyncBinding,
|
||||
) -> Mapping[Field, object]:
|
||||
v2_entries: Final = tuple(entry for entry in chain.from_iterable(plan.stages) if not entry.subscription.legacy)
|
||||
if v2_entries or plan.awaited:
|
||||
raise ContractViolation(
|
||||
f"{hook.value} runs synchronously here, so only off-path V2 callbacks may subscribe"
|
||||
)
|
||||
result: Final = run_legacy_plan_sync(plan, fields, binding)
|
||||
if plan.off_path:
|
||||
self._dispatcher.sink.submit(self._deliver_off_path(hook, plan.off_path, result, call))
|
||||
return result
|
||||
|
||||
def _state(self, entry: Entry) -> object:
|
||||
factory: Final = entry.manifest.request_state
|
||||
if factory is None:
|
||||
return None
|
||||
key: Final = id(entry.manifest.logger)
|
||||
if key not in self._states:
|
||||
self._states[key] = factory()
|
||||
return self._states[key]
|
||||
|
||||
async def _invoke(
|
||||
self, hook: Hook, entry: Entry, fields: Mapping[Field, object], call: CallFacts | None, state: object
|
||||
) -> object:
|
||||
policy: Final = self._dispatcher.policy
|
||||
handler: Final = _handler(entry.manifest.logger, entry.subscription.handler)
|
||||
context: Final = CallContext[object](hook=hook, call=call, state=state)
|
||||
view: Final = policy.view(entry.subscription, fields)
|
||||
result: Final = (
|
||||
await _awaited(handler(view, context))
|
||||
if inspect.iscoroutinefunction(handler)
|
||||
else await asyncio.to_thread(handler, view, context)
|
||||
)
|
||||
policy.check(entry.subscription, result)
|
||||
return result
|
||||
|
||||
def _start(
|
||||
self,
|
||||
hook: Hook,
|
||||
entry: Entry,
|
||||
fields: Mapping[Field, object],
|
||||
call: CallFacts | None,
|
||||
live: bool,
|
||||
binding: LegacyBinding | None,
|
||||
) -> Awaitable[object] | None:
|
||||
if entry.subscription.legacy:
|
||||
return _legacy_start(entry, binding, fields)
|
||||
return self._invoke(hook, entry, fields, call, self._state(entry) if live else None)
|
||||
|
||||
async def _gather(
|
||||
self,
|
||||
hook: Hook,
|
||||
entries: Sequence[Entry],
|
||||
fields: Mapping[Field, object],
|
||||
call: CallFacts | None,
|
||||
live: bool,
|
||||
binding: LegacyBinding | None = None,
|
||||
) -> tuple[object, ...]:
|
||||
if len(entries) == 1 and entries[0].subscription.legacy:
|
||||
return (await _run_legacy(entries[0], binding, fields),)
|
||||
started: Final = tuple(self._start(hook, entry, fields, call, live, binding) for entry in entries)
|
||||
return await _gather_started(started, _fail_fast(hook, tuple(entries)))
|
||||
|
||||
async def _run_stages(
|
||||
self,
|
||||
hook: Hook,
|
||||
stages: tuple[tuple[Entry, ...], ...],
|
||||
chain: _Chain,
|
||||
call: CallFacts | None,
|
||||
binding: LegacyBinding | None,
|
||||
) -> _Chain:
|
||||
if not stages:
|
||||
return chain
|
||||
stage: Final = stages[0]
|
||||
results: Final = await self._gather(hook, stage, chain.fields, call, live=True, binding=binding)
|
||||
folded: Final = self._fold(hook, stage, results, chain)
|
||||
if folded.terminal is not None:
|
||||
return folded
|
||||
return await self._run_stages(hook, stages[1:], folded, call, binding)
|
||||
|
||||
def _fold(self, hook: Hook, stage: tuple[Entry, ...], results: tuple[object, ...], chain: _Chain) -> _Chain:
|
||||
pairs: Final = tuple(zip(stage, results, strict=True))
|
||||
decisive: Final = next((pair for pair in pairs if _decides(hook, pair[1])), None)
|
||||
self._count_failures(hook, tuple(pair for pair in pairs if pair is not decisive))
|
||||
match decisive:
|
||||
case (_, BaseException() as error):
|
||||
raise error
|
||||
case (_, Abort() | Respond() as outcome):
|
||||
return _Chain(fields=chain.fields, patches=chain.patches, reroute=chain.reroute, terminal=outcome)
|
||||
case _:
|
||||
return self._merge(pairs, chain)
|
||||
|
||||
def _merge(self, pairs: tuple[tuple[Entry, object], ...], chain: _Chain) -> _Chain:
|
||||
stage_patches: Final = tuple((entry.position, result) for entry, result in pairs if isinstance(result, Patch))
|
||||
reroutes: Final = tuple(result for _, result in pairs if isinstance(result, Reroute))
|
||||
return _Chain(
|
||||
fields=apply_patches(chain.fields, (patch for _, patch in stage_patches)),
|
||||
patches=(*chain.patches, *stage_patches),
|
||||
reroute=chain.reroute or next(iter(reroutes), None),
|
||||
)
|
||||
|
||||
def _count_failures(self, hook: Hook, pairs: Sequence[tuple[Entry, object]]) -> None:
|
||||
for entry, result in pairs:
|
||||
if isinstance(result, BaseException) and not entry.subscription.legacy:
|
||||
self._dispatcher.record_failure(entry, hook, result)
|
||||
|
||||
def _swallow(self, hook: Hook, entries: Sequence[Entry], results: tuple[object, ...]) -> None:
|
||||
pairs: Final = tuple(zip(entries, results, strict=True))
|
||||
fatal: Final = next(
|
||||
(result for _, result in pairs if isinstance(result, BaseException) and _fatal(result)),
|
||||
None,
|
||||
)
|
||||
self._count_failures(hook, tuple(pair for pair in pairs if pair[1] is not fatal))
|
||||
if fatal is not None:
|
||||
raise fatal
|
||||
|
||||
async def _run_awaited(
|
||||
self, hook: Hook, entries: tuple[Entry, ...], fields: Mapping[Field, object], call: CallFacts | None
|
||||
) -> None:
|
||||
if entries:
|
||||
self._swallow(hook, entries, await self._gather(hook, entries, fields, call, live=True))
|
||||
|
||||
async def _deliver_off_path(
|
||||
self, hook: Hook, entries: tuple[Entry, ...], fields: Mapping[Field, object], call: CallFacts | None
|
||||
) -> None:
|
||||
self._swallow(hook, entries, await self._gather(hook, entries, fields, call, live=False))
|
||||
95
litellm/integrations/custom_logger_v2/enforcement.py
Normal file
95
litellm/integrations/custom_logger_v2/enforcement.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
from collections.abc import Mapping
|
||||
from typing import Final, Protocol
|
||||
|
||||
from litellm.integrations.custom_logger_v2.enums import Field, Hook
|
||||
from litellm.integrations.custom_logger_v2.hooks import HOOK_SPECS, Subscription
|
||||
from litellm.integrations.custom_logger_v2.outcomes import Abort, Patch, Reroute, Respond
|
||||
|
||||
|
||||
class ContractViolation(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UndeclaredReadError(ContractViolation, AttributeError):
|
||||
pass
|
||||
|
||||
|
||||
def _field_named(name: str) -> Field | None:
|
||||
member: Final = Field._value2member_map_.get(name)
|
||||
return member if isinstance(member, Field) else None
|
||||
|
||||
|
||||
class FieldView:
|
||||
__slots__ = ("_fields",)
|
||||
|
||||
def __init__(self, fields: Mapping[Field, object]) -> None:
|
||||
self._fields = fields
|
||||
|
||||
def __getattr__(self, name: str) -> object:
|
||||
field: Final = _field_named(name)
|
||||
if field is None or field not in self._fields:
|
||||
raise AttributeError(name)
|
||||
return self._fields[field]
|
||||
|
||||
|
||||
class DeclaredView:
|
||||
__slots__ = ("_fields", "_reads")
|
||||
|
||||
def __init__(self, fields: Mapping[Field, object], reads: frozenset[Field]) -> None:
|
||||
self._fields = fields
|
||||
self._reads = reads
|
||||
|
||||
def __getattr__(self, name: str) -> object:
|
||||
field: Final = _field_named(name)
|
||||
if field is None or field not in self._reads:
|
||||
raise UndeclaredReadError(f"{name} is not among the declared reads")
|
||||
if field not in self._fields:
|
||||
raise AttributeError(name)
|
||||
return self._fields[field]
|
||||
|
||||
|
||||
class Policy(Protocol):
|
||||
def view(self, subscription: Subscription, fields: Mapping[Field, object]) -> object: ...
|
||||
|
||||
def check(self, subscription: Subscription, result: object) -> None: ...
|
||||
|
||||
|
||||
class Unchecked:
|
||||
def view(self, subscription: Subscription, fields: Mapping[Field, object]) -> object:
|
||||
return FieldView(fields)
|
||||
|
||||
def check(self, subscription: Subscription, result: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _outcome_problem(hook: Hook, subscription: Subscription, outcome: Abort | Respond | Reroute) -> str | None:
|
||||
if not subscription.may_abort:
|
||||
return f"returned {type(outcome).__name__} without may_abort"
|
||||
if type(outcome) not in HOOK_SPECS[hook].outcomes:
|
||||
return f"returned {type(outcome).__name__}, which {hook.value} does not allow"
|
||||
return None
|
||||
|
||||
|
||||
def _result_problem(subscription: Subscription, result: object) -> str | None:
|
||||
match result:
|
||||
case None:
|
||||
return None
|
||||
case Patch():
|
||||
undeclared: Final = result.fields - subscription.writes
|
||||
if not undeclared:
|
||||
return None
|
||||
return f"patched {', '.join(sorted(field.value for field in undeclared))} outside its declared writes"
|
||||
case Abort() | Respond() | Reroute():
|
||||
return _outcome_problem(subscription.hook, subscription, result)
|
||||
case _:
|
||||
return f"returned {type(result).__name__}, which is neither None, a Patch nor an outcome"
|
||||
|
||||
|
||||
class Enforced:
|
||||
def view(self, subscription: Subscription, fields: Mapping[Field, object]) -> object:
|
||||
return DeclaredView(fields, subscription.reads)
|
||||
|
||||
def check(self, subscription: Subscription, result: object) -> None:
|
||||
problem: Final = _result_problem(subscription, result)
|
||||
if problem is not None:
|
||||
raise ContractViolation(f"{subscription.handler} on {subscription.hook.value} {problem}")
|
||||
97
litellm/integrations/custom_logger_v2/enums.py
Normal file
97
litellm/integrations/custom_logger_v2/enums.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
from enum import Enum, IntEnum
|
||||
from typing import Final
|
||||
|
||||
|
||||
class Hook(str, Enum):
|
||||
prompt_resolve = "prompt_resolve"
|
||||
pre_route = "pre_route"
|
||||
filter_deployments = "filter_deployments"
|
||||
pre_call = "pre_call"
|
||||
during_call = "during_call"
|
||||
pre_deployment_call = "pre_deployment_call"
|
||||
post_deployment_success = "post_deployment_success"
|
||||
post_deployment_failure = "post_deployment_failure"
|
||||
post_call_success = "post_call_success"
|
||||
post_call_failure = "post_call_failure"
|
||||
response_headers = "response_headers"
|
||||
stream_chunk = "stream_chunk"
|
||||
list_models = "list_models"
|
||||
mcp_tool_result = "mcp_tool_result"
|
||||
log_prepare = "log_prepare"
|
||||
provider_request_sent = "provider_request_sent"
|
||||
provider_response_received = "provider_response_received"
|
||||
attempt_ended = "attempt_ended"
|
||||
call_ended = "call_ended"
|
||||
router_event = "router_event"
|
||||
audit_event = "audit_event"
|
||||
|
||||
|
||||
class FieldTier(IntEnum):
|
||||
small_record = 1
|
||||
content = 2
|
||||
wire = 3
|
||||
compatibility = 4
|
||||
|
||||
|
||||
class Field(str, Enum):
|
||||
_value_: str
|
||||
bit: int
|
||||
tier: FieldTier
|
||||
|
||||
def __new__(cls, value: str, bit: int, tier: FieldTier) -> "Field":
|
||||
member: Final = str.__new__(cls, value)
|
||||
member._value_ = value
|
||||
member.bit = bit
|
||||
member.tier = tier
|
||||
return member
|
||||
|
||||
model = ("model", 0, FieldTier.small_record)
|
||||
key_identity = ("key_identity", 1, FieldTier.small_record)
|
||||
deployment = ("deployment", 2, FieldTier.small_record)
|
||||
candidate_deployments = ("candidate_deployments", 3, FieldTier.small_record)
|
||||
request_tags = ("request_tags", 4, FieldTier.small_record)
|
||||
dynamic_params = ("dynamic_params", 5, FieldTier.small_record)
|
||||
guardrail_info = ("guardrail_info", 6, FieldTier.small_record)
|
||||
outcome = ("outcome", 7, FieldTier.small_record)
|
||||
error = ("error", 8, FieldTier.small_record)
|
||||
usage = ("usage", 9, FieldTier.small_record)
|
||||
cost = ("cost", 10, FieldTier.small_record)
|
||||
metadata = ("metadata", 11, FieldTier.content)
|
||||
messages = ("messages", 12, FieldTier.content)
|
||||
input = ("input", 13, FieldTier.content)
|
||||
tools = ("tools", 14, FieldTier.content)
|
||||
optional_params = ("optional_params", 15, FieldTier.content)
|
||||
request_headers = ("request_headers", 16, FieldTier.content)
|
||||
response = ("response", 17, FieldTier.content)
|
||||
response_headers = ("response_headers", 18, FieldTier.content)
|
||||
stream_chunk = ("stream_chunk", 19, FieldTier.content)
|
||||
assembled_stream = ("assembled_stream", 20, FieldTier.content)
|
||||
tool_result = ("tool_result", 21, FieldTier.content)
|
||||
model_list = ("model_list", 22, FieldTier.content)
|
||||
request_body = ("request_body", 23, FieldTier.wire)
|
||||
provider_request_body = ("provider_request_body", 24, FieldTier.wire)
|
||||
provider_response_raw = ("provider_response_raw", 25, FieldTier.wire)
|
||||
provider_response_headers = ("provider_response_headers", 26, FieldTier.wire)
|
||||
standard_logging_payload = ("standard_logging_payload", 27, FieldTier.compatibility)
|
||||
deprecated_kwargs = ("deprecated_kwargs", 28, FieldTier.compatibility)
|
||||
deprecated_response_obj = ("deprecated_response_obj", 29, FieldTier.compatibility)
|
||||
|
||||
|
||||
class Activation(str, Enum):
|
||||
required = "required"
|
||||
default_on = "default_on"
|
||||
opt_in = "opt_in"
|
||||
|
||||
|
||||
class OptOutBy(str, Enum):
|
||||
admin = "admin"
|
||||
caller = "caller"
|
||||
|
||||
|
||||
ALL_FIELDS_MASK: Final = sum(1 << field.bit for field in Field)
|
||||
|
||||
|
||||
def field_mask(fields: frozenset[Field]) -> int:
|
||||
if Field.deprecated_kwargs in fields:
|
||||
return ALL_FIELDS_MASK
|
||||
return sum(1 << field.bit for field in fields)
|
||||
98
litellm/integrations/custom_logger_v2/hook_point_fields.py
Normal file
98
litellm/integrations/custom_logger_v2/hook_point_fields.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
from collections.abc import Iterator, Mapping
|
||||
from functools import reduce
|
||||
from typing import Final
|
||||
|
||||
from litellm.integrations.custom_logger_v2.enums import Field
|
||||
from litellm.integrations.custom_logger_v2.legacy import KwargsFields
|
||||
from litellm.integrations.custom_logger_v2.outcomes import InvalidPatchError, Patch, PatchOp, SetField
|
||||
from litellm.integrations.custom_logger_v2.patches import LiveFields
|
||||
|
||||
_CANDIDATE_FIELDS: Final = (Field.deprecated_kwargs, Field.candidate_deployments, Field.model)
|
||||
|
||||
|
||||
class CandidateFields(LiveFields):
|
||||
__slots__ = ("_deployments", "_model")
|
||||
|
||||
def __init__(self, deployments: object, model: str) -> None:
|
||||
self._deployments: Final = deployments
|
||||
self._model: Final = model
|
||||
|
||||
@property
|
||||
def deployments(self) -> object:
|
||||
return self._deployments
|
||||
|
||||
def __getitem__(self, field: Field) -> object:
|
||||
if field is Field.deprecated_kwargs:
|
||||
return self._deployments
|
||||
if field is Field.model:
|
||||
return self._model
|
||||
if field is Field.candidate_deployments and isinstance(self._deployments, list | tuple):
|
||||
return tuple(self._deployments) # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType, reportUnknownVariableType] # untyped deployment dicts
|
||||
raise KeyError(field)
|
||||
|
||||
def __iter__(self) -> Iterator[Field]:
|
||||
return iter(tuple(field for field in _CANDIDATE_FIELDS if self._has(field)))
|
||||
|
||||
def __len__(self) -> int:
|
||||
return sum(1 for field in _CANDIDATE_FIELDS if self._has(field))
|
||||
|
||||
def _has(self, field: Field) -> bool:
|
||||
return field is not Field.candidate_deployments or isinstance(self._deployments, list | tuple)
|
||||
|
||||
def apply(self, patch: Patch) -> Mapping[Field, object]:
|
||||
return reduce(_narrow, patch.ops, self)
|
||||
|
||||
|
||||
def _narrow(fields: Mapping[Field, object], op: PatchOp) -> Mapping[Field, object]:
|
||||
if not isinstance(fields, CandidateFields) or not isinstance(op, SetField):
|
||||
raise InvalidPatchError("candidate deployments are replaced whole, never merged")
|
||||
if op.field is Field.deprecated_kwargs:
|
||||
return CandidateFields(op.value, str(fields[Field.model]))
|
||||
if op.field is not Field.candidate_deployments:
|
||||
raise InvalidPatchError(f"{op.field.value} has no place in filter_deployments")
|
||||
return CandidateFields(_narrowed(fields.deployments, op.value), str(fields[Field.model]))
|
||||
|
||||
|
||||
def _narrowed(current: object, proposed: object) -> list[object]: # mutable-ok: legacy filters take a list
|
||||
if not isinstance(current, list | tuple) or not isinstance(proposed, list | tuple):
|
||||
raise InvalidPatchError("candidate_deployments must stay a sequence of the current candidates")
|
||||
allowed: Final = frozenset(map(id, current)) # pyright: ignore[reportUnknownArgumentType] # untyped deployment dicts
|
||||
picked: Final = tuple(map(id, proposed)) # pyright: ignore[reportUnknownArgumentType] # see above
|
||||
if not allowed.issuperset(picked) or len(frozenset(picked)) != len(picked):
|
||||
raise InvalidPatchError("filter_deployments may only narrow the candidates, never add or repeat one")
|
||||
return list(proposed) # pyright: ignore[reportUnknownArgumentType] # see above
|
||||
|
||||
|
||||
class ResultFields(LiveFields):
|
||||
__slots__ = ("_request", "_slot", "value")
|
||||
|
||||
def __init__(self, request: object, keys: Mapping[Field, str], slot: Field, value: object) -> None:
|
||||
self._request: Final = KwargsFields(request, keys)
|
||||
self._slot: Final = slot
|
||||
self.value = value
|
||||
|
||||
def __getitem__(self, field: Field) -> object:
|
||||
if field is self._slot or field is Field.deprecated_response_obj:
|
||||
return self.value
|
||||
return self._request[field]
|
||||
|
||||
def __iter__(self) -> Iterator[Field]:
|
||||
return iter((*self._request, self._slot, Field.deprecated_response_obj))
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._request) + 2
|
||||
|
||||
def apply(self, patch: Patch) -> Mapping[Field, object]:
|
||||
values: Final = tuple(_slot_value(op, self._slot) for op in patch.ops)
|
||||
if values:
|
||||
self.value = values[-1]
|
||||
return self
|
||||
|
||||
|
||||
def _slot_value(op: PatchOp, slot: Field) -> object:
|
||||
if not isinstance(op, SetField) or op.field not in (slot, Field.deprecated_response_obj):
|
||||
raise InvalidPatchError(f"{op.field.value} cannot be patched here, only {slot.value}")
|
||||
return op.value
|
||||
|
||||
|
||||
__all__ = ["CandidateFields", "ResultFields"]
|
||||
211
litellm/integrations/custom_logger_v2/hooks.py
Normal file
211
litellm/integrations/custom_logger_v2/hooks.py
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Literal, TypeAlias
|
||||
|
||||
from litellm.integrations.custom_logger_v2.enums import Activation, Field, Hook
|
||||
from litellm.integrations.custom_logger_v2.outcomes import Abort, Reroute, Respond
|
||||
|
||||
LegacyGuardrailEvent: TypeAlias = Literal["pre_call", "during_call", "post_call", "post_mcp_call"]
|
||||
OutcomeType: TypeAlias = type[Abort] | type[Respond] | type[Reroute]
|
||||
|
||||
|
||||
class InvalidDeclarationError(TypeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HookSpec:
|
||||
legacy_methods: tuple[str, ...]
|
||||
request_path: bool
|
||||
writes: frozenset[Field] = frozenset()
|
||||
outcomes: frozenset[OutcomeType] = frozenset()
|
||||
legacy_guardrail_events: frozenset[LegacyGuardrailEvent] = frozenset()
|
||||
has_call: bool = True
|
||||
subscribable: bool = True
|
||||
fail_fast_parallel: bool = False
|
||||
|
||||
|
||||
_NO_OUTCOME: Final[frozenset[OutcomeType]] = frozenset()
|
||||
_ABORT: Final[frozenset[OutcomeType]] = frozenset({Abort})
|
||||
_ABORT_OR_RESPOND: Final[frozenset[OutcomeType]] = frozenset({Abort, Respond})
|
||||
_ANY_OUTCOME: Final[frozenset[OutcomeType]] = frozenset({Abort, Respond, Reroute})
|
||||
|
||||
HOOK_SPECS: Final[Mapping[Hook, HookSpec]] = MappingProxyType(
|
||||
{
|
||||
Hook.prompt_resolve: HookSpec(
|
||||
legacy_methods=("get_chat_completion_prompt", "async_get_chat_completion_prompt"),
|
||||
request_path=True,
|
||||
writes=frozenset({Field.messages, Field.optional_params, Field.model}),
|
||||
outcomes=_ABORT_OR_RESPOND,
|
||||
),
|
||||
Hook.pre_route: HookSpec(
|
||||
legacy_methods=("async_pre_routing_hook",),
|
||||
request_path=True,
|
||||
writes=frozenset({Field.model, Field.messages}),
|
||||
outcomes=_ABORT_OR_RESPOND,
|
||||
),
|
||||
Hook.filter_deployments: HookSpec(
|
||||
legacy_methods=("async_filter_deployments",),
|
||||
request_path=True,
|
||||
writes=frozenset({Field.candidate_deployments}),
|
||||
outcomes=_ABORT,
|
||||
),
|
||||
Hook.pre_call: HookSpec(
|
||||
legacy_methods=("async_pre_call_hook",),
|
||||
request_path=True,
|
||||
writes=frozenset(
|
||||
{
|
||||
Field.messages,
|
||||
Field.optional_params,
|
||||
Field.metadata,
|
||||
Field.request_headers,
|
||||
Field.guardrail_info,
|
||||
Field.tools,
|
||||
Field.model,
|
||||
Field.input,
|
||||
}
|
||||
),
|
||||
outcomes=_ANY_OUTCOME,
|
||||
legacy_guardrail_events=frozenset({"pre_call"}),
|
||||
),
|
||||
Hook.during_call: HookSpec(
|
||||
legacy_methods=("async_moderation_hook",),
|
||||
request_path=True,
|
||||
outcomes=_ABORT_OR_RESPOND,
|
||||
legacy_guardrail_events=frozenset({"during_call"}),
|
||||
fail_fast_parallel=True,
|
||||
),
|
||||
Hook.pre_deployment_call: HookSpec(
|
||||
legacy_methods=("async_pre_call_deployment_hook", "async_pre_request_hook"),
|
||||
request_path=True,
|
||||
writes=frozenset({Field.provider_request_body, Field.request_headers}),
|
||||
outcomes=_ABORT_OR_RESPOND,
|
||||
),
|
||||
Hook.post_deployment_success: HookSpec(
|
||||
legacy_methods=("async_post_call_success_deployment_hook", "async_post_call_streaming_deployment_hook"),
|
||||
request_path=True,
|
||||
writes=frozenset({Field.response}),
|
||||
outcomes=_ABORT_OR_RESPOND,
|
||||
),
|
||||
Hook.post_deployment_failure: HookSpec(
|
||||
legacy_methods=("async_post_call_failure_deployment_hook",),
|
||||
request_path=True,
|
||||
writes=frozenset({Field.error}),
|
||||
outcomes=_ABORT,
|
||||
),
|
||||
Hook.post_call_success: HookSpec(
|
||||
legacy_methods=("async_post_call_success_hook",),
|
||||
request_path=True,
|
||||
writes=frozenset({Field.response, Field.guardrail_info}),
|
||||
outcomes=_ABORT_OR_RESPOND,
|
||||
legacy_guardrail_events=frozenset({"post_call"}),
|
||||
),
|
||||
Hook.post_call_failure: HookSpec(
|
||||
legacy_methods=("async_post_call_failure_hook",),
|
||||
request_path=True,
|
||||
writes=frozenset({Field.error}),
|
||||
outcomes=_ABORT,
|
||||
),
|
||||
Hook.response_headers: HookSpec(
|
||||
legacy_methods=("async_post_call_response_headers_hook",),
|
||||
request_path=True,
|
||||
writes=frozenset({Field.response_headers}),
|
||||
outcomes=_NO_OUTCOME,
|
||||
),
|
||||
Hook.stream_chunk: HookSpec(
|
||||
legacy_methods=(
|
||||
"async_post_call_streaming_hook",
|
||||
"async_post_call_streaming_iterator_hook",
|
||||
"log_stream_event",
|
||||
),
|
||||
request_path=True,
|
||||
writes=frozenset({Field.stream_chunk}),
|
||||
outcomes=_ABORT,
|
||||
subscribable=False,
|
||||
),
|
||||
Hook.list_models: HookSpec(
|
||||
legacy_methods=("async_filter_listed_models",),
|
||||
request_path=True,
|
||||
writes=frozenset({Field.model_list}),
|
||||
outcomes=_ABORT,
|
||||
has_call=False,
|
||||
),
|
||||
Hook.mcp_tool_result: HookSpec(
|
||||
legacy_methods=("async_post_mcp_tool_call_hook",),
|
||||
request_path=True,
|
||||
writes=frozenset({Field.tool_result}),
|
||||
outcomes=_ABORT_OR_RESPOND,
|
||||
legacy_guardrail_events=frozenset({"post_mcp_call"}),
|
||||
),
|
||||
Hook.log_prepare: HookSpec(
|
||||
legacy_methods=(
|
||||
"logging_hook",
|
||||
"async_logging_hook",
|
||||
"redact_standard_logging_payload_from_model_call_details",
|
||||
),
|
||||
request_path=False,
|
||||
writes=frozenset({Field.standard_logging_payload}),
|
||||
),
|
||||
Hook.provider_request_sent: HookSpec(legacy_methods=("log_pre_api_call",), request_path=False),
|
||||
Hook.provider_response_received: HookSpec(legacy_methods=("log_post_api_call",), request_path=False),
|
||||
Hook.attempt_ended: HookSpec(
|
||||
legacy_methods=(
|
||||
"log_success_event",
|
||||
"log_failure_event",
|
||||
"async_log_success_event",
|
||||
"async_log_failure_event",
|
||||
),
|
||||
request_path=False,
|
||||
),
|
||||
Hook.call_ended: HookSpec(legacy_methods=(), request_path=False),
|
||||
Hook.router_event: HookSpec(
|
||||
legacy_methods=(
|
||||
"log_success_fallback_event",
|
||||
"log_failure_fallback_event",
|
||||
"log_model_group_rate_limit_error",
|
||||
),
|
||||
request_path=False,
|
||||
),
|
||||
Hook.audit_event: HookSpec(
|
||||
legacy_methods=("async_log_audit_log_event", "async_log_batch_line_items"),
|
||||
request_path=False,
|
||||
has_call=False,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Subscription:
|
||||
hook: Hook
|
||||
handler: str
|
||||
reads: frozenset[Field] = frozenset()
|
||||
writes: frozenset[Field] = frozenset()
|
||||
may_abort: bool = False
|
||||
parallel: bool = False
|
||||
awaited: bool = False
|
||||
activation: Activation | None = None
|
||||
legacy: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
problem: Final = subscription_problem(self)
|
||||
if problem is not None:
|
||||
raise InvalidDeclarationError(f"{self.handler} on {self.hook.value}: {problem}")
|
||||
|
||||
|
||||
def subscription_problem(subscription: Subscription) -> str | None:
|
||||
spec: Final = HOOK_SPECS[subscription.hook]
|
||||
if not spec.subscribable:
|
||||
return "this hook point has no V2 subscription support yet"
|
||||
legacy_writes: Final = frozenset({Field.deprecated_kwargs}) if subscription.legacy else frozenset[Field]()
|
||||
forbidden: Final = subscription.writes - spec.writes - legacy_writes
|
||||
if forbidden:
|
||||
return f"writes {', '.join(sorted(field.value for field in forbidden))}, which this hook point cannot apply"
|
||||
if subscription.may_abort and not spec.outcomes:
|
||||
return "may_abort is set, but this hook point allows no outcome"
|
||||
if subscription.awaited and (subscription.writes or subscription.may_abort):
|
||||
return "an awaited subscription must neither write nor abort"
|
||||
if subscription.parallel and (subscription.writes or not subscription.may_abort):
|
||||
return "parallel is only for subscriptions that may abort and write nothing"
|
||||
return None
|
||||
62
litellm/integrations/custom_logger_v2/host.py
Normal file
62
litellm/integrations/custom_logger_v2/host.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import Final
|
||||
|
||||
from litellm.integrations.custom_logger_v2.dispatcher import Dispatcher, RequestScope
|
||||
from litellm.integrations.custom_logger_v2.outcomes import Outcome, Patch
|
||||
from litellm.integrations.custom_logger_v2.registry import CallbackRegistry
|
||||
from litellm.integrations.custom_logger_v2.selection import RequestSelection
|
||||
|
||||
_NO_SELECTION: Final = RequestSelection()
|
||||
_CURRENT_SCOPE: Final[ContextVar[RequestScope | None]] = ContextVar("custom_logger_v2_scope", default=None)
|
||||
|
||||
|
||||
class V2Outcome(Exception):
|
||||
def __init__(self, outcome: Outcome | None, patches: tuple[Patch, ...]) -> None:
|
||||
super().__init__(f"a V2 callback ended the hook with {type(outcome).__name__}")
|
||||
self.outcome: Final = outcome
|
||||
self.patches: Final = patches
|
||||
|
||||
|
||||
class _Host:
|
||||
def __init__(self) -> None:
|
||||
self._dispatcher: Dispatcher | None = None
|
||||
|
||||
def dispatcher(self) -> Dispatcher:
|
||||
if self._dispatcher is None:
|
||||
self._dispatcher = Dispatcher(CallbackRegistry())
|
||||
return self._dispatcher
|
||||
|
||||
def reset(self) -> None:
|
||||
self._dispatcher = None
|
||||
|
||||
|
||||
_HOST: Final = _Host()
|
||||
|
||||
|
||||
def get_dispatcher() -> Dispatcher:
|
||||
return _HOST.dispatcher()
|
||||
|
||||
|
||||
def reset_host_for_tests() -> None:
|
||||
_HOST.reset()
|
||||
|
||||
|
||||
def current_scope() -> RequestScope | None:
|
||||
return _CURRENT_SCOPE.get()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def bind_scope(scope: RequestScope) -> Generator[RequestScope]:
|
||||
token: Final = _CURRENT_SCOPE.set(scope)
|
||||
try:
|
||||
yield scope
|
||||
finally:
|
||||
_CURRENT_SCOPE.reset(token)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def request_scope(selection: RequestSelection = _NO_SELECTION) -> Generator[RequestScope]:
|
||||
with _HOST.dispatcher().open_scope(selection) as scope, bind_scope(scope):
|
||||
yield scope
|
||||
434
litellm/integrations/custom_logger_v2/legacy.py
Normal file
434
litellm/integrations/custom_logger_v2/legacy.py
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
import weakref
|
||||
from collections.abc import Callable, Iterator, Mapping, MutableMapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from functools import lru_cache, reduce
|
||||
from itertools import count
|
||||
from types import MappingProxyType
|
||||
from typing import Final, TypeAlias, TypeVar
|
||||
|
||||
from typing_extensions import TypeIs # noqa: TID251 # narrows the untyped legacy kwargs without copying them
|
||||
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.integrations.custom_logger import CustomLogger, subclass_overrides_hook
|
||||
from litellm.integrations.custom_logger_v2.call import CallFacts
|
||||
from litellm.integrations.custom_logger_v2.dispatcher import (
|
||||
Between,
|
||||
HookResult,
|
||||
RequestScope,
|
||||
run_legacy_plan,
|
||||
run_legacy_plan_sync,
|
||||
)
|
||||
from litellm.integrations.custom_logger_v2.enums import Activation, Field, Hook
|
||||
from litellm.integrations.custom_logger_v2.hooks import HOOK_SPECS, Subscription
|
||||
from litellm.integrations.custom_logger_v2.host import V2Outcome, current_scope, get_dispatcher
|
||||
from litellm.integrations.custom_logger_v2.logger import CustomLoggerV2
|
||||
from litellm.integrations.custom_logger_v2.manifest import CallbackManifest, LegacyBinding, build_manifest
|
||||
from litellm.integrations.custom_logger_v2.outcomes import Abort, InvalidPatchError, MergeField, Patch, PatchOp, Respond
|
||||
from litellm.integrations.custom_logger_v2.patches import LiveFields
|
||||
from litellm.integrations.custom_logger_v2.planner import Entry, HookPlan, plan_hook
|
||||
from litellm.integrations.custom_logger_v2.selection import RequestSelection, Selection
|
||||
|
||||
ResultT: Final = TypeVar("ResultT")
|
||||
|
||||
SyncLegacyBinding: TypeAlias = Callable[[object, object], object]
|
||||
|
||||
# These hook points are the only ones where v1 custom logger subclasses are
|
||||
# checked to see if they run in parallel
|
||||
PARALLEL_GUARDRAIL_HOOKS: Final = frozenset({Hook.pre_call, Hook.post_call_success})
|
||||
LEGACY_READS: Final = frozenset({Field.deprecated_kwargs, Field.deprecated_response_obj})
|
||||
PROXY_REQUEST_KEYS: Final[Mapping[Field, str]] = MappingProxyType(
|
||||
{
|
||||
Field.model: "model",
|
||||
Field.messages: "messages",
|
||||
Field.input: "input",
|
||||
Field.tools: "tools",
|
||||
}
|
||||
)
|
||||
SUBSCRIBERS_CACHE_SIZE: Final = 128
|
||||
PLAN_CACHE_SIZE: Final = 64
|
||||
|
||||
_SERIALS: Final = count()
|
||||
|
||||
|
||||
class Admit(str, Enum):
|
||||
overriders = "overriders"
|
||||
every_logger = "every_logger"
|
||||
every_entry = "every_entry"
|
||||
|
||||
|
||||
_ABSENT: Final = object()
|
||||
_NO_SELECTION: Final = RequestSelection()
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def legacy_subscription(hook: Hook, parallel: bool) -> Subscription:
|
||||
spec: Final = HOOK_SPECS[hook]
|
||||
may_abort: Final = bool(spec.outcomes)
|
||||
gathered: Final = may_abort and (parallel or hook is Hook.during_call)
|
||||
return Subscription(
|
||||
hook=hook,
|
||||
handler="call",
|
||||
reads=LEGACY_READS,
|
||||
writes=frozenset({Field.deprecated_kwargs}) if spec.writes and not gathered else frozenset(),
|
||||
may_abort=may_abort,
|
||||
parallel=gathered,
|
||||
legacy=True,
|
||||
)
|
||||
|
||||
|
||||
def _subscribes(cls: type, guardrail: bool, hook: Hook) -> bool:
|
||||
spec: Final = HOOK_SPECS[hook]
|
||||
if not spec.subscribable:
|
||||
return False
|
||||
if guardrail and spec.legacy_guardrail_events:
|
||||
return True
|
||||
return any(subclass_overrides_hook(cls, method) for method in spec.legacy_methods)
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def _derived_subscriptions(cls: type, guardrail: bool, parallel: bool) -> Mapping[Hook, Subscription]:
|
||||
return MappingProxyType(
|
||||
{
|
||||
hook: legacy_subscription(hook, parallel and hook in PARALLEL_GUARDRAIL_HOOKS)
|
||||
for hook in Hook
|
||||
if _subscribes(cls, guardrail, hook)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class LegacyLogger(CustomLoggerV2):
|
||||
def __init__(self, inner: CustomLogger) -> None:
|
||||
guardrail: Final = isinstance(inner, CustomGuardrail)
|
||||
self.parallel = guardrail and bool(getattr(inner, "run_in_parallel", False))
|
||||
self.serial: Final = next(_SERIALS)
|
||||
self._inner: Final = weakref.ref(inner)
|
||||
self.manifest: Final = CallbackManifest(
|
||||
logger=self,
|
||||
selection=Selection(name=type(inner).__name__, activation=Activation.required),
|
||||
subscriptions=_derived_subscriptions(type(inner), guardrail, self.parallel),
|
||||
request_state=None,
|
||||
legacy_call=self.call,
|
||||
)
|
||||
|
||||
@property
|
||||
def inner(self) -> CustomLogger | None:
|
||||
return self._inner()
|
||||
|
||||
def subscription(self, hook: Hook, admit: Admit) -> Subscription | None:
|
||||
derived: Final = self.manifest.subscriptions.get(hook)
|
||||
if derived is not None or admit is Admit.overriders or not HOOK_SPECS[hook].subscribable:
|
||||
return derived
|
||||
return legacy_subscription(hook, self.parallel and hook in PARALLEL_GUARDRAIL_HOOKS)
|
||||
|
||||
def call(self, binding: Callable[[object, object], ResultT], fields: Mapping[Field, object]) -> ResultT | None:
|
||||
inner: Final = self._inner()
|
||||
return None if inner is None else binding(inner, fields[Field.deprecated_kwargs])
|
||||
|
||||
|
||||
_ADAPTERS: Final[dict[int, LegacyLogger]] = {} # mutable-ok: adapters live as long as the logger they wrap
|
||||
|
||||
|
||||
def adapter_for(inner: CustomLogger) -> LegacyLogger:
|
||||
key: Final = id(inner)
|
||||
cached: Final = _ADAPTERS.get(key)
|
||||
if cached is not None and cached.inner is inner:
|
||||
return cached
|
||||
adapter: Final = LegacyLogger(inner)
|
||||
_ADAPTERS[key] = adapter
|
||||
_ = weakref.finalize(inner, _ADAPTERS.pop, key, None)
|
||||
return adapter
|
||||
|
||||
|
||||
def _readable(value: object) -> TypeIs[Mapping[str, object]]: # guard-ok: legacy kwargs are an untyped dict
|
||||
return isinstance(value, Mapping)
|
||||
|
||||
|
||||
def _writable(
|
||||
value: object,
|
||||
) -> TypeIs[MutableMapping[str, object]]: # guard-ok: legacy kwargs are an untyped dict # mutable-ok: written in place
|
||||
return isinstance(value, MutableMapping)
|
||||
|
||||
|
||||
class OpaqueLegacy(CustomLoggerV2):
|
||||
"""Holds a callback list entry that is not a CustomLogger (a string name or a plain function) at its list
|
||||
position, for hook points that admit every entry. There is no class to derive subscriptions from, so it
|
||||
subscribes to the one hook it was built for and runs serially."""
|
||||
|
||||
def __init__(self, inner: object, hook: Hook) -> None:
|
||||
self.inner: Final = inner
|
||||
self.serial: Final = next(_SERIALS)
|
||||
self.manifest: Final = CallbackManifest(
|
||||
logger=self,
|
||||
selection=Selection(name=type(inner).__name__, activation=Activation.required),
|
||||
subscriptions=MappingProxyType({hook: legacy_subscription(hook, False)}),
|
||||
request_state=None,
|
||||
legacy_call=self.call,
|
||||
)
|
||||
|
||||
def call(self, binding: Callable[[object, object], ResultT], fields: Mapping[Field, object]) -> ResultT | None:
|
||||
return binding(self.inner, fields[Field.deprecated_kwargs])
|
||||
|
||||
|
||||
class KwargsFields(LiveFields):
|
||||
"""A live Field view over a legacy hook's untyped kwargs dict, so the dispatcher can run over it without copying.
|
||||
|
||||
Two fields cover the whole call: deprecated_kwargs is the dict itself, and deprecated_response_obj is the
|
||||
response, when the hook point passes one. Patching either one swaps in the new value.
|
||||
|
||||
Every other field maps to one key in the dict through the hook point's key table (PROXY_REQUEST_KEYS or
|
||||
LOGGING_KEYS), and is present only when that key is. Patching one writes straight into the dict, so later
|
||||
callbacks and the request see the change."""
|
||||
|
||||
__slots__ = ("_keys", "_kwargs", "_response")
|
||||
|
||||
def __init__(self, kwargs: object, keys: Mapping[Field, str], response: object = _ABSENT) -> None:
|
||||
self._kwargs: Final = kwargs
|
||||
self._keys: Final = keys
|
||||
self._response: Final = response
|
||||
|
||||
def _key(self, field: Field) -> str | None:
|
||||
key: Final = self._keys.get(field)
|
||||
return key if key is not None and _readable(self._kwargs) and key in self._kwargs else None
|
||||
|
||||
def __getitem__(self, field: Field) -> object:
|
||||
if field is Field.deprecated_kwargs:
|
||||
return self._kwargs
|
||||
if field is Field.deprecated_response_obj and self._response is not _ABSENT:
|
||||
return self._response
|
||||
key: Final = self._key(field)
|
||||
if key is None or not _readable(self._kwargs):
|
||||
raise KeyError(field)
|
||||
return self._kwargs[key]
|
||||
|
||||
def __iter__(self) -> Iterator[Field]:
|
||||
return iter(tuple(field for field in Field if self._has(field)))
|
||||
|
||||
def __len__(self) -> int:
|
||||
return sum(1 for field in Field if self._has(field))
|
||||
|
||||
def _has(self, field: Field) -> bool:
|
||||
if field is Field.deprecated_kwargs:
|
||||
return True
|
||||
if field is Field.deprecated_response_obj:
|
||||
return self._response is not _ABSENT
|
||||
return self._key(field) is not None
|
||||
|
||||
def apply(self, patch: Patch) -> Mapping[Field, object]:
|
||||
return reduce(_apply_op, patch.ops, self)
|
||||
|
||||
def replaced(self, field: Field, value: object) -> "KwargsFields":
|
||||
if field is Field.deprecated_kwargs:
|
||||
return KwargsFields(value, self._keys, self._response)
|
||||
return KwargsFields(self._kwargs, self._keys, value)
|
||||
|
||||
def write(self, field: Field, value: object) -> None:
|
||||
target: Final = self._target(field)
|
||||
target[self._keys[field]] = value
|
||||
|
||||
def merge(self, field: Field, keys: Mapping[str, object]) -> None:
|
||||
target: Final = self._target(field)
|
||||
current: Final = target.get(self._keys[field])
|
||||
if current is None:
|
||||
target[self._keys[field]] = dict(keys)
|
||||
return
|
||||
if not _writable(current):
|
||||
raise InvalidPatchError(f"{field.value} holds no mapping here, so keys cannot be merged into it")
|
||||
current.update(keys)
|
||||
|
||||
def _target(self, field: Field) -> MutableMapping[str, object]: # mutable-ok: legacy kwargs are the live request
|
||||
if field not in self._keys:
|
||||
raise InvalidPatchError(f"{field.value} has no place in the legacy kwargs of this hook")
|
||||
if not _writable(self._kwargs):
|
||||
raise InvalidPatchError(f"{field.value} cannot be written because the legacy kwargs are not a mapping")
|
||||
return self._kwargs
|
||||
|
||||
|
||||
def _apply_op(fields: Mapping[Field, object], op: PatchOp) -> Mapping[Field, object]:
|
||||
if not isinstance(fields, KwargsFields):
|
||||
raise InvalidPatchError("legacy kwargs patches need KwargsFields")
|
||||
if op.field in (Field.deprecated_kwargs, Field.deprecated_response_obj):
|
||||
if isinstance(op, MergeField):
|
||||
raise InvalidPatchError(f"{op.field.value} is replaced whole, never merged")
|
||||
return fields.replaced(op.field, op.value)
|
||||
if isinstance(op, MergeField):
|
||||
fields.merge(op.field, op.keys)
|
||||
return fields
|
||||
fields.write(op.field, op.value)
|
||||
return fields
|
||||
|
||||
|
||||
def kwargs_patch(before: object, after: object) -> Patch | None:
|
||||
return None if after is before else Patch().set(Field.deprecated_kwargs, after)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, eq=False)
|
||||
class HookSubscribers:
|
||||
hook: Hook
|
||||
entries: tuple[Entry, ...]
|
||||
plan: HookPlan
|
||||
has_v2: bool
|
||||
plans: Callable[[tuple[int, ...]], HookPlan]
|
||||
|
||||
@property
|
||||
def legacy(self) -> tuple[object, ...]:
|
||||
return tuple(inner for entry in self.entries if (inner := _wrapped(entry.manifest.logger)) is not None)
|
||||
|
||||
def plan_for(self, scope: RequestScope) -> HookPlan:
|
||||
selected: Final = tuple(
|
||||
index for index, entry in enumerate(self.entries) if entry.subscription.legacy or scope.selects(entry)
|
||||
)
|
||||
return self.plan if len(selected) == len(self.entries) else self.plans(selected)
|
||||
|
||||
|
||||
def _wrapped(logger: CustomLoggerV2) -> object:
|
||||
if isinstance(logger, LegacyLogger | OpaqueLegacy):
|
||||
return logger.inner
|
||||
return None
|
||||
|
||||
|
||||
def _v2_entry(v2_callback: CustomLoggerV2, hook: Hook) -> tuple[CallbackManifest, Subscription] | None:
|
||||
manifest: Final = build_manifest(v2_callback)
|
||||
subscription: Final = manifest.subscriptions.get(hook)
|
||||
return None if subscription is None else (manifest, subscription)
|
||||
|
||||
|
||||
def _legacy_entry(
|
||||
inner: CustomLogger, hook: Hook, admit: Admit, parallel: bool
|
||||
) -> tuple[CallbackManifest, Subscription] | None:
|
||||
adapter: Final = adapter_for(inner)
|
||||
subscription: Final = adapter.subscription(hook, admit)
|
||||
if subscription is None:
|
||||
return None
|
||||
if subscription.parallel and not parallel:
|
||||
return adapter.manifest, legacy_subscription(hook, False)
|
||||
return adapter.manifest, subscription
|
||||
|
||||
|
||||
def _opaque_entry(item: object, hook: Hook) -> tuple[CallbackManifest, Subscription] | None:
|
||||
if not HOOK_SPECS[hook].subscribable:
|
||||
return None
|
||||
opaque: Final = OpaqueLegacy(item, hook)
|
||||
return opaque.manifest, opaque.manifest.subscriptions[hook]
|
||||
|
||||
|
||||
def _subscriber(
|
||||
item: object, hook: Hook, admit: Admit, include_v2: bool, parallel: bool
|
||||
) -> tuple[CallbackManifest, Subscription] | None:
|
||||
if isinstance(item, CustomLoggerV2):
|
||||
return _v2_entry(item, hook) if include_v2 else None
|
||||
if isinstance(item, CustomLogger):
|
||||
return _legacy_entry(item, hook, admit, parallel)
|
||||
return _opaque_entry(item, hook) if admit is Admit.every_entry else None
|
||||
|
||||
|
||||
def _build_subscribers(
|
||||
hook: Hook, callbacks: Sequence[object], admit: Admit, include_v2: bool, parallel: bool
|
||||
) -> HookSubscribers:
|
||||
members: Final = tuple(
|
||||
member for item in callbacks if (member := _subscriber(item, hook, admit, include_v2, parallel)) is not None
|
||||
)
|
||||
entries: Final = tuple(
|
||||
Entry(position, manifest, subscription) for position, (manifest, subscription) in enumerate(members)
|
||||
)
|
||||
|
||||
def subset_plan(selected: tuple[int, ...]) -> HookPlan:
|
||||
return plan_hook(tuple(entries[index] for index in selected))
|
||||
|
||||
return HookSubscribers(
|
||||
hook=hook,
|
||||
entries=entries,
|
||||
plan=plan_hook(entries),
|
||||
has_v2=any(not entry.subscription.legacy for entry in entries),
|
||||
plans=lru_cache(maxsize=PLAN_CACHE_SIZE)(subset_plan),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, eq=False)
|
||||
class _CachedSubscribers:
|
||||
subscribers: HookSubscribers
|
||||
pinned: tuple[object, ...]
|
||||
|
||||
|
||||
_SUBSCRIBERS: Final[dict[tuple[object, ...], _CachedSubscribers]] = {} # mutable-ok: bounded subscribers cache
|
||||
|
||||
|
||||
def _pinned(item: object) -> bool:
|
||||
return not isinstance(item, CustomLogger) or isinstance(item, CustomLoggerV2)
|
||||
|
||||
|
||||
def _cache_subscribers(key: tuple[object, ...], subscribers: HookSubscribers, callbacks: Sequence[object]) -> None:
|
||||
if len(_SUBSCRIBERS) >= SUBSCRIBERS_CACHE_SIZE:
|
||||
_SUBSCRIBERS.clear()
|
||||
_SUBSCRIBERS[key] = _CachedSubscribers(
|
||||
subscribers=subscribers, pinned=tuple(item for item in callbacks if _pinned(item))
|
||||
)
|
||||
for item in callbacks:
|
||||
if not _pinned(item):
|
||||
_ = weakref.finalize(item, _SUBSCRIBERS.pop, key, None)
|
||||
|
||||
|
||||
def hook_subscribers(
|
||||
hook: Hook,
|
||||
callbacks: Sequence[object],
|
||||
*,
|
||||
admit: Admit = Admit.overriders,
|
||||
include_v2: bool = True,
|
||||
parallel: bool = True,
|
||||
) -> HookSubscribers:
|
||||
key: Final = (hook, admit, include_v2, parallel, *map(id, callbacks))
|
||||
cached: Final = _SUBSCRIBERS.get(key)
|
||||
if cached is not None:
|
||||
return cached.subscribers
|
||||
subscribers: Final = _build_subscribers(hook, callbacks, admit, include_v2, parallel)
|
||||
_cache_subscribers(key, subscribers, callbacks)
|
||||
return subscribers
|
||||
|
||||
|
||||
def clear_subscribers_cache_for_tests() -> None:
|
||||
_SUBSCRIBERS.clear()
|
||||
|
||||
|
||||
async def run_subscribers(
|
||||
subscribers: HookSubscribers,
|
||||
fields: Mapping[Field, object],
|
||||
binding: LegacyBinding,
|
||||
*,
|
||||
between: Between | None = None,
|
||||
call: CallFacts | None = None,
|
||||
scope: RequestScope | None = None,
|
||||
) -> HookResult:
|
||||
if not subscribers.has_v2:
|
||||
return HookResult(fields=await run_legacy_plan(subscribers.hook, subscribers.plan, fields, binding, between))
|
||||
bound: Final = scope or current_scope()
|
||||
if bound is not None:
|
||||
return await bound.run_plan(subscribers.hook, subscribers.plan_for(bound), fields, call, binding, between)
|
||||
with get_dispatcher().open_scope(_NO_SELECTION) as transient:
|
||||
return await transient.run_plan(
|
||||
subscribers.hook, subscribers.plan_for(transient), fields, call, binding, between
|
||||
)
|
||||
|
||||
|
||||
def run_subscribers_sync(
|
||||
subscribers: HookSubscribers,
|
||||
fields: Mapping[Field, object],
|
||||
binding: SyncLegacyBinding,
|
||||
*,
|
||||
call: CallFacts | None = None,
|
||||
scope: RequestScope | None = None,
|
||||
) -> Mapping[Field, object]:
|
||||
if not subscribers.has_v2:
|
||||
return run_legacy_plan_sync(subscribers.plan, fields, binding)
|
||||
bound: Final = scope or current_scope()
|
||||
if bound is not None:
|
||||
return bound.run_plan_sync(subscribers.hook, subscribers.plan_for(bound), fields, call, binding)
|
||||
with get_dispatcher().open_scope(_NO_SELECTION) as transient:
|
||||
return transient.run_plan_sync(subscribers.hook, subscribers.plan_for(transient), fields, call, binding)
|
||||
|
||||
|
||||
def kwargs_after(result: HookResult) -> object:
|
||||
match result.outcome:
|
||||
case Abort() | Respond() as terminal:
|
||||
raise V2Outcome(terminal, result.patches)
|
||||
case _:
|
||||
return result.fields[Field.deprecated_kwargs]
|
||||
109
litellm/integrations/custom_logger_v2/logger.py
Normal file
109
litellm/integrations/custom_logger_v2/logger.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import inspect
|
||||
from collections.abc import Callable, Mapping, Sequence, Set
|
||||
from dataclasses import replace
|
||||
from types import MappingProxyType
|
||||
from typing import ClassVar, Final, TypeVar
|
||||
|
||||
from litellm.integrations.custom_logger_v2.enums import Activation, Field, Hook
|
||||
from litellm.integrations.custom_logger_v2.hooks import InvalidDeclarationError, Subscription
|
||||
from litellm.integrations.custom_logger_v2.selection import Selection
|
||||
|
||||
HandlerT: Final = TypeVar("HandlerT", bound=Callable[..., object])
|
||||
|
||||
_SUBSCRIPTION_ATTR: Final = "__custom_logger_v2_subscription__"
|
||||
|
||||
|
||||
def on(
|
||||
hook: Hook,
|
||||
*,
|
||||
reads: Set[Field] = frozenset(),
|
||||
writes: Set[Field] = frozenset(),
|
||||
may_abort: bool = False,
|
||||
parallel: bool | None = None,
|
||||
awaited: bool = False,
|
||||
) -> Callable[[HandlerT], HandlerT]:
|
||||
def decorate(handler: HandlerT) -> HandlerT:
|
||||
subscription: Final = Subscription(
|
||||
hook=hook,
|
||||
handler=handler.__name__,
|
||||
reads=frozenset(reads),
|
||||
writes=frozenset(writes),
|
||||
may_abort=may_abort,
|
||||
parallel=(hook is Hook.during_call and may_abort) if parallel is None else parallel,
|
||||
awaited=awaited,
|
||||
)
|
||||
setattr(handler, _SUBSCRIPTION_ATTR, subscription)
|
||||
return handler
|
||||
|
||||
return decorate
|
||||
|
||||
|
||||
def _declared_subscription(value: object) -> Subscription | None:
|
||||
marker: Final[object] = getattr(value, _SUBSCRIPTION_ATTR, None)
|
||||
return marker if isinstance(marker, Subscription) else None
|
||||
|
||||
|
||||
def _namespace(cls: type) -> Mapping[str, object]:
|
||||
return vars(cls)
|
||||
|
||||
|
||||
def _declared_in(cls: type) -> Mapping[Hook, Subscription]:
|
||||
declared: Final = tuple(
|
||||
replace(subscription, handler=name)
|
||||
for name, value in _namespace(cls).items()
|
||||
if (subscription := _declared_subscription(value)) is not None
|
||||
)
|
||||
hooks: Final = tuple(subscription.hook for subscription in declared)
|
||||
duplicated: Final = sorted({hook.value for hook in hooks if hooks.count(hook) > 1})
|
||||
if duplicated:
|
||||
raise InvalidDeclarationError(f"{cls.__name__} declares more than one handler for {', '.join(duplicated)}")
|
||||
return MappingProxyType({subscription.hook: subscription for subscription in declared})
|
||||
|
||||
|
||||
def _nearest(layers: Sequence[Mapping[Hook, Subscription]], hook: Hook) -> Subscription | None:
|
||||
return next((layer[hook] for layer in layers if hook in layer), None)
|
||||
|
||||
|
||||
def _handler_problem(cls: type, subscription: Subscription) -> str | None:
|
||||
handler: Final[object] = getattr(cls, subscription.handler, None)
|
||||
if not callable(handler):
|
||||
return f"{subscription.handler} handles {subscription.hook.value} but is not callable"
|
||||
redeclared: Final = _declared_subscription(handler)
|
||||
if redeclared is not None and redeclared.hook is not subscription.hook:
|
||||
return (
|
||||
f"{subscription.handler} is decorated for {redeclared.hook.value} but also inherits "
|
||||
f"the {subscription.hook.value} subscription"
|
||||
)
|
||||
blocking: Final = bool(subscription.writes) or subscription.may_abort or subscription.awaited
|
||||
if blocking and not inspect.iscoroutinefunction(handler):
|
||||
return f"{subscription.handler} must be async because it writes, may abort or is awaited"
|
||||
return None
|
||||
|
||||
|
||||
class CustomLoggerV2:
|
||||
_own_subscriptions: ClassVar[Mapping[Hook, Subscription]] = MappingProxyType({})
|
||||
subscriptions: ClassVar[Mapping[Hook, Subscription]] = MappingProxyType({})
|
||||
request_state: ClassVar[type | None] = None
|
||||
declared_hooks: ClassVar[frozenset[Hook]] = frozenset()
|
||||
|
||||
selection: Selection | None = None
|
||||
enabled_hooks: frozenset[Hook] | None = None
|
||||
parallel: bool = False
|
||||
activation_overrides: Mapping[Hook, Activation] = MappingProxyType({})
|
||||
|
||||
def __init_subclass__(cls) -> None:
|
||||
super().__init_subclass__()
|
||||
cls._own_subscriptions = _declared_in(cls)
|
||||
layers: Final = tuple(base._own_subscriptions for base in cls.__mro__ if issubclass(base, CustomLoggerV2))
|
||||
resolved: Final = MappingProxyType(
|
||||
{hook: subscription for hook in Hook if (subscription := _nearest(layers, hook)) is not None}
|
||||
)
|
||||
problems: Final = tuple(
|
||||
problem
|
||||
for subscription in resolved.values()
|
||||
if (problem := _handler_problem(cls, subscription)) is not None
|
||||
)
|
||||
if problems:
|
||||
raise InvalidDeclarationError(f"{cls.__name__}: {'; '.join(problems)}")
|
||||
cls.subscriptions = resolved
|
||||
CustomLoggerV2.declared_hooks = CustomLoggerV2.declared_hooks | resolved.keys()
|
||||
73
litellm/integrations/custom_logger_v2/manifest.py
Normal file
73
litellm/integrations/custom_logger_v2/manifest.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass, replace
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Protocol, TypeAlias, TypeVar
|
||||
|
||||
from litellm.integrations.custom_logger_v2.enums import Activation, Field, Hook, OptOutBy
|
||||
from litellm.integrations.custom_logger_v2.hooks import InvalidDeclarationError, Subscription
|
||||
from litellm.integrations.custom_logger_v2.logger import CustomLoggerV2
|
||||
from litellm.integrations.custom_logger_v2.selection import Selection
|
||||
|
||||
ResultT: Final = TypeVar("ResultT")
|
||||
|
||||
LegacyBinding: TypeAlias = Callable[[object, object], Awaitable[object] | None]
|
||||
|
||||
|
||||
class LegacyCall(Protocol):
|
||||
def __call__(
|
||||
self, binding: Callable[[object, object], ResultT], fields: Mapping[Field, object], /
|
||||
) -> ResultT | None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CallbackManifest:
|
||||
logger: CustomLoggerV2
|
||||
selection: Selection
|
||||
subscriptions: Mapping[Hook, Subscription]
|
||||
request_state: type | None
|
||||
legacy_call: LegacyCall | None = None
|
||||
|
||||
|
||||
def _instance_subscription(subscription: Subscription, parallel: bool, activation: Activation | None) -> Subscription:
|
||||
return replace(
|
||||
subscription,
|
||||
parallel=subscription.parallel or (parallel and subscription.may_abort),
|
||||
activation=activation,
|
||||
)
|
||||
|
||||
|
||||
def _caller_can_remove(selection: Selection) -> bool:
|
||||
return selection.honors_no_log or OptOutBy.caller in selection.opt_out_by
|
||||
|
||||
|
||||
def build_manifest(logger: CustomLoggerV2) -> CallbackManifest:
|
||||
name: Final = type(logger).__name__
|
||||
declared: Final = type(logger).subscriptions
|
||||
enabled: Final = frozenset(declared) if logger.enabled_hooks is None else logger.enabled_hooks
|
||||
not_implemented: Final = enabled - declared.keys()
|
||||
if not_implemented:
|
||||
hooks: Final = ", ".join(sorted(hook.value for hook in not_implemented))
|
||||
raise InvalidDeclarationError(f"{name} enables {hooks}, which it does not implement")
|
||||
stray_overrides: Final = logger.activation_overrides.keys() - enabled
|
||||
if stray_overrides:
|
||||
overridden: Final = ", ".join(sorted(hook.value for hook in stray_overrides))
|
||||
raise InvalidDeclarationError(f"{name} overrides activation for {overridden}, which it does not enable")
|
||||
subscriptions: Final = MappingProxyType(
|
||||
{
|
||||
hook: _instance_subscription(declared[hook], logger.parallel, logger.activation_overrides.get(hook))
|
||||
for hook in Hook
|
||||
if hook in enabled
|
||||
}
|
||||
)
|
||||
selection: Final = logger.selection or Selection(name=name)
|
||||
blocking: Final = any(subscription.writes or subscription.may_abort for subscription in subscriptions.values())
|
||||
if blocking and _caller_can_remove(selection):
|
||||
raise InvalidDeclarationError(
|
||||
f"{selection.name} writes or may abort, so neither no-log nor a caller opt-out may remove it"
|
||||
)
|
||||
return CallbackManifest(
|
||||
logger=logger,
|
||||
selection=selection,
|
||||
subscriptions=subscriptions,
|
||||
request_state=type(logger).request_state,
|
||||
)
|
||||
27
litellm/integrations/custom_logger_v2/offpath.py
Normal file
27
litellm/integrations/custom_logger_v2/offpath.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import asyncio
|
||||
from collections.abc import Coroutine
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Final, Protocol
|
||||
|
||||
|
||||
class OffPathSink(Protocol):
|
||||
def submit(self, delivery: Coroutine[object, object, None]) -> None: ...
|
||||
|
||||
|
||||
class InProcessSink:
|
||||
def __init__(self) -> None:
|
||||
self._pending: set[asyncio.Task[None]] = set() # mutable-ok: strong references keep running tasks alive
|
||||
self._loopless: Final = ThreadPoolExecutor(max_workers=1, thread_name_prefix="custom-logger-v2-off-path")
|
||||
|
||||
def submit(self, delivery: Coroutine[object, object, None]) -> None:
|
||||
try:
|
||||
loop: Final = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
_ = self._loopless.submit(asyncio.run, delivery)
|
||||
return
|
||||
task: Final[asyncio.Task[None]] = loop.create_task(delivery)
|
||||
self._pending.add(task)
|
||||
task.add_done_callback(self._pending.discard)
|
||||
|
||||
async def drain(self) -> None:
|
||||
await asyncio.gather(*self._pending)
|
||||
106
litellm/integrations/custom_logger_v2/outcomes.py
Normal file
106
litellm/integrations/custom_logger_v2/outcomes.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import ClassVar, Final, TypeAlias
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from litellm.integrations.custom_logger_v2.enums import Field
|
||||
|
||||
JsonValue: TypeAlias = "str | int | float | bool | None | Sequence[JsonValue] | Mapping[str, JsonValue]"
|
||||
|
||||
MERGEABLE_FIELDS: Final = frozenset(
|
||||
{
|
||||
Field.dynamic_params,
|
||||
Field.guardrail_info,
|
||||
Field.metadata,
|
||||
Field.optional_params,
|
||||
Field.request_headers,
|
||||
Field.response_headers,
|
||||
Field.provider_request_body,
|
||||
Field.standard_logging_payload,
|
||||
}
|
||||
)
|
||||
OUTCOME_PATCH_FIELDS: Final = frozenset({Field.guardrail_info, Field.metadata})
|
||||
|
||||
|
||||
class InvalidPatchError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SetField:
|
||||
field: Field
|
||||
value: object
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MergeField:
|
||||
field: Field
|
||||
keys: Mapping[str, object]
|
||||
|
||||
|
||||
PatchOp: TypeAlias = SetField | MergeField
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Patch:
|
||||
ops: tuple[PatchOp, ...] = ()
|
||||
|
||||
def set(self, field: Field, value: object) -> "Patch":
|
||||
return Patch((*self.ops, SetField(field, value)))
|
||||
|
||||
def merge(self, field: Field, keys: Mapping[str, object]) -> "Patch":
|
||||
if field not in MERGEABLE_FIELDS:
|
||||
raise InvalidPatchError(f"{field.value} is not a mapping field, so it cannot be merged by key")
|
||||
return Patch((*self.ops, MergeField(field, MappingProxyType(dict(keys)))))
|
||||
|
||||
@property
|
||||
def fields(self) -> frozenset[Field]:
|
||||
return frozenset(op.field for op in self.ops)
|
||||
|
||||
|
||||
def _require_outcome_patch(patch: Patch | None) -> None:
|
||||
if patch is None:
|
||||
return
|
||||
outside: Final = patch.fields - OUTCOME_PATCH_FIELDS
|
||||
if outside:
|
||||
names: Final = ", ".join(sorted(field.value for field in outside))
|
||||
raise InvalidPatchError(f"an outcome may only patch guardrail_info and metadata, not {names}")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Abort:
|
||||
terminal: ClassVar[bool] = True
|
||||
|
||||
status: int
|
||||
message: str
|
||||
code: str | None = None
|
||||
headers: Mapping[str, str] | None = None
|
||||
body: JsonValue = None
|
||||
patch: Patch | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_outcome_patch(self.patch)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Respond:
|
||||
terminal: ClassVar[bool] = True
|
||||
|
||||
response: "JsonValue | BaseModel"
|
||||
status: int = 200
|
||||
patch: Patch | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_require_outcome_patch(self.patch)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Reroute:
|
||||
terminal: ClassVar[bool] = False
|
||||
|
||||
model: str
|
||||
|
||||
|
||||
Outcome: TypeAlias = Abort | Respond | Reroute
|
||||
70
litellm/integrations/custom_logger_v2/patches.py
Normal file
70
litellm/integrations/custom_logger_v2/patches.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
from abc import abstractmethod
|
||||
from collections.abc import Iterable, Iterator, Mapping
|
||||
from functools import reduce
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm.integrations.custom_logger_v2.enums import Field
|
||||
from litellm.integrations.custom_logger_v2.outcomes import InvalidPatchError, MergeField, Patch, PatchOp, SetField
|
||||
|
||||
_STRING_KEYED: Final = TypeAdapter(Mapping[str, object])
|
||||
|
||||
|
||||
class LiveFields(Mapping[Field, object]):
|
||||
__slots__ = ()
|
||||
|
||||
@abstractmethod
|
||||
def apply(self, patch: Patch) -> Mapping[Field, object]: ...
|
||||
|
||||
|
||||
class PatchedFields(Mapping[Field, object]):
|
||||
__slots__ = ("_base", "_changed")
|
||||
|
||||
def __init__(self, base: Mapping[Field, object], changed: Mapping[Field, object]) -> None:
|
||||
self._base = base
|
||||
self._changed = changed
|
||||
|
||||
def with_value(self, field: Field, value: object) -> "PatchedFields":
|
||||
return PatchedFields(self._base, MappingProxyType({**self._changed, field: value}))
|
||||
|
||||
def __getitem__(self, field: Field) -> object:
|
||||
return self._changed[field] if field in self._changed else self._base[field]
|
||||
|
||||
def __iter__(self) -> Iterator[Field]:
|
||||
return iter(self._changed.keys() | self._base.keys())
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._changed.keys() | self._base.keys())
|
||||
|
||||
|
||||
def _with_value(fields: Mapping[Field, object], field: Field, value: object) -> PatchedFields:
|
||||
patched: Final = fields if isinstance(fields, PatchedFields) else PatchedFields(fields, MappingProxyType({}))
|
||||
return patched.with_value(field, value)
|
||||
|
||||
|
||||
def _merged(current: object, field: Field, keys: Mapping[str, object]) -> Mapping[str, object]:
|
||||
if current is None:
|
||||
return MappingProxyType(dict(keys))
|
||||
if not isinstance(current, Mapping):
|
||||
raise InvalidPatchError(f"{field.value} holds no mapping, so keys cannot be merged into it")
|
||||
return MappingProxyType({**_STRING_KEYED.validate_python(current), **keys})
|
||||
|
||||
|
||||
def _apply_op(fields: Mapping[Field, object], op: PatchOp) -> Mapping[Field, object]:
|
||||
match op:
|
||||
case SetField(field=field, value=value):
|
||||
return _with_value(fields, field, value)
|
||||
case MergeField(field=field, keys=keys):
|
||||
return _with_value(fields, field, _merged(fields.get(field), field, keys))
|
||||
|
||||
|
||||
def apply_patch(fields: Mapping[Field, object], patch: Patch) -> Mapping[Field, object]:
|
||||
if isinstance(fields, LiveFields):
|
||||
return fields.apply(patch)
|
||||
return reduce(_apply_op, patch.ops, fields)
|
||||
|
||||
|
||||
def apply_patches(fields: Mapping[Field, object], patches: Iterable[Patch]) -> Mapping[Field, object]:
|
||||
return reduce(apply_patch, patches, fields)
|
||||
77
litellm/integrations/custom_logger_v2/planner.py
Normal file
77
litellm/integrations/custom_logger_v2/planner.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from functools import reduce
|
||||
from typing import Final
|
||||
|
||||
from litellm.integrations.custom_logger_v2.enums import Activation, Field, field_mask
|
||||
from litellm.integrations.custom_logger_v2.hooks import Subscription
|
||||
from litellm.integrations.custom_logger_v2.manifest import CallbackManifest
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, eq=False)
|
||||
class Entry:
|
||||
position: int
|
||||
manifest: CallbackManifest
|
||||
subscription: Subscription
|
||||
|
||||
@property
|
||||
def activation(self) -> Activation:
|
||||
return self.subscription.activation or self.manifest.selection.activation
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HookPlan:
|
||||
stages: tuple[tuple[Entry, ...], ...] = ()
|
||||
inline: tuple[Entry, ...] = ()
|
||||
awaited: tuple[Entry, ...] = ()
|
||||
off_path: tuple[Entry, ...] = ()
|
||||
reads: frozenset[Field] = frozenset()
|
||||
|
||||
|
||||
EMPTY_PLAN: Final = HookPlan()
|
||||
|
||||
|
||||
def _blocks(subscription: Subscription) -> bool:
|
||||
return bool(subscription.writes) or subscription.may_abort
|
||||
|
||||
|
||||
def _level(placed: tuple[tuple[Subscription, int], ...], subscription: Subscription) -> int:
|
||||
if subscription.may_abort:
|
||||
return max((level for _, level in placed), default=-1) + 1
|
||||
touched: Final = field_mask(subscription.reads | subscription.writes)
|
||||
writes: Final = field_mask(subscription.writes)
|
||||
after_writers: Final = tuple(level + 1 for prior, level in placed if field_mask(prior.writes) & touched)
|
||||
not_before_readers: Final = tuple(level for prior, level in placed if field_mask(prior.reads) & writes)
|
||||
return max((*after_writers, *not_before_readers), default=0)
|
||||
|
||||
|
||||
def _place(
|
||||
placed: tuple[tuple[Subscription, int], ...], subscription: Subscription
|
||||
) -> tuple[tuple[Subscription, int], ...]:
|
||||
return (*placed, (subscription, _level(placed, subscription)))
|
||||
|
||||
|
||||
def _levels(entries: Sequence[Entry]) -> tuple[tuple[Entry, ...], ...]:
|
||||
placed: Final = reduce(_place, (entry.subscription for entry in entries), ())
|
||||
levels: Final = tuple(level for _, level in placed)
|
||||
return tuple(_at_level(entries, levels, level) for level in range(max(levels, default=-1) + 1))
|
||||
|
||||
|
||||
def _at_level(entries: Sequence[Entry], levels: tuple[int, ...], level: int) -> tuple[Entry, ...]:
|
||||
return tuple(entry for entry, placed_at in zip(entries, levels, strict=True) if placed_at == level)
|
||||
|
||||
|
||||
def plan_hook(entries: Sequence[Entry]) -> HookPlan:
|
||||
if not entries:
|
||||
return EMPTY_PLAN
|
||||
blocking: Final = tuple(entry for entry in entries if _blocks(entry.subscription))
|
||||
serial: Final = tuple(entry for entry in blocking if not entry.subscription.parallel)
|
||||
parallel: Final = tuple(entry for entry in blocking if entry.subscription.parallel)
|
||||
passive: Final = tuple(entry for entry in entries if not _blocks(entry.subscription))
|
||||
return HookPlan(
|
||||
stages=(*_levels(serial), *((parallel,) if parallel else ())),
|
||||
inline=tuple(entry for entry in passive if entry.subscription.legacy),
|
||||
awaited=tuple(entry for entry in passive if entry.subscription.awaited and not entry.subscription.legacy),
|
||||
off_path=tuple(entry for entry in passive if not (entry.subscription.awaited or entry.subscription.legacy)),
|
||||
reads=frozenset[Field]().union(*(entry.subscription.reads for entry in entries)),
|
||||
)
|
||||
51
litellm/integrations/custom_logger_v2/registry.py
Normal file
51
litellm/integrations/custom_logger_v2/registry.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
from collections.abc import Callable, Mapping, Sequence
|
||||
from functools import lru_cache
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from litellm.integrations.custom_logger_v2.enums import Hook
|
||||
from litellm.integrations.custom_logger_v2.logger import CustomLoggerV2
|
||||
from litellm.integrations.custom_logger_v2.manifest import CallbackManifest
|
||||
from litellm.integrations.custom_logger_v2.planner import Entry, HookPlan, plan_hook
|
||||
|
||||
DEFAULT_PLAN_CACHE_SIZE: Final = 256
|
||||
|
||||
|
||||
def _hook_entries(manifests: tuple[CallbackManifest, ...], hook: Hook) -> tuple[Entry, ...]:
|
||||
return tuple(
|
||||
Entry(position, manifest, manifest.subscriptions[hook])
|
||||
for position, manifest in enumerate(manifests)
|
||||
if hook in manifest.subscriptions
|
||||
)
|
||||
|
||||
|
||||
class RegistrySnapshot:
|
||||
__slots__ = ("entries", "manifests", "plan")
|
||||
|
||||
def __init__(self, manifests: tuple[CallbackManifest, ...], plan_cache_size: int) -> None:
|
||||
self.manifests: Final = manifests
|
||||
self.entries: Final[Mapping[Hook, tuple[Entry, ...]]] = MappingProxyType(
|
||||
{hook: _hook_entries(manifests, hook) for hook in Hook}
|
||||
)
|
||||
self.plan: Final[Callable[[Hook, tuple[int, ...]], HookPlan]] = lru_cache(maxsize=plan_cache_size)(
|
||||
self._build_plan
|
||||
)
|
||||
|
||||
def _build_plan(self, hook: Hook, selected: tuple[int, ...]) -> HookPlan:
|
||||
hook_entries: Final = self.entries[hook]
|
||||
return plan_hook(tuple(hook_entries[index] for index in selected))
|
||||
|
||||
|
||||
class CallbackRegistry:
|
||||
def __init__(
|
||||
self, manifests: Sequence[CallbackManifest] = (), plan_cache_size: int = DEFAULT_PLAN_CACHE_SIZE
|
||||
) -> None:
|
||||
self._plan_cache_size: Final = plan_cache_size
|
||||
self.snapshot: RegistrySnapshot = RegistrySnapshot(tuple(manifests), plan_cache_size)
|
||||
|
||||
def register(self, manifest: CallbackManifest) -> None:
|
||||
self.snapshot = RegistrySnapshot((*self.snapshot.manifests, manifest), self._plan_cache_size)
|
||||
|
||||
def unregister(self, logger: CustomLoggerV2) -> None:
|
||||
kept: Final = tuple(manifest for manifest in self.snapshot.manifests if manifest.logger is not logger)
|
||||
self.snapshot = RegistrySnapshot(kept, self._plan_cache_size)
|
||||
36
litellm/integrations/custom_logger_v2/selection.py
Normal file
36
litellm/integrations/custom_logger_v2/selection.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
from litellm.integrations.custom_logger_v2.enums import Activation, OptOutBy
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Selection:
|
||||
name: str
|
||||
aliases: frozenset[str] = frozenset()
|
||||
activation: Activation = Activation.default_on
|
||||
opt_out_by: frozenset[OptOutBy] = frozenset({OptOutBy.admin})
|
||||
honors_no_log: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RequestSelection:
|
||||
requested: frozenset[str] = frozenset()
|
||||
admin_opt_outs: frozenset[str] = frozenset()
|
||||
caller_opt_outs: frozenset[str] = frozenset()
|
||||
no_log: bool = False
|
||||
|
||||
|
||||
def _opted_out(selection: Selection, names: frozenset[str], request: RequestSelection) -> bool:
|
||||
by_admin: Final = OptOutBy.admin in selection.opt_out_by and not names.isdisjoint(request.admin_opt_outs)
|
||||
by_caller: Final = OptOutBy.caller in selection.opt_out_by and not names.isdisjoint(request.caller_opt_outs)
|
||||
return by_admin or by_caller or (request.no_log and selection.honors_no_log)
|
||||
|
||||
|
||||
def is_selected(selection: Selection, activation: Activation, request: RequestSelection) -> bool:
|
||||
if activation is Activation.required:
|
||||
return True
|
||||
names: Final = selection.aliases | {selection.name}
|
||||
if _opted_out(selection, names, request):
|
||||
return False
|
||||
return activation is Activation.default_on or not names.isdisjoint(request.requested)
|
||||
|
|
@ -20,14 +20,27 @@ from litellm.integrations.otel.plumbing.context import request_root_span
|
|||
if TYPE_CHECKING:
|
||||
from opentelemetry.trace import Span
|
||||
|
||||
from litellm.caching.dual_cache import DualCache
|
||||
from litellm.types.proxy.auth.user_api_key_auth import UserAPIKeyAuth
|
||||
from litellm.types.utils import ModelResponseStream
|
||||
from litellm.types.utils import CallTypesLiteral, ModelResponseStream
|
||||
|
||||
|
||||
class LangfuseOpenTelemetryV2(OpenTelemetryV2):
|
||||
"""Stamps the caller's trace controls (name, user, session, tags) on the request. Langfuse reads them off
|
||||
the root observation, and the proxy's root span is still recording when the LLM call starts."""
|
||||
|
||||
async def async_pre_call_hook(
|
||||
self,
|
||||
user_api_key_dict: "UserAPIKeyAuth",
|
||||
cache: "DualCache",
|
||||
data: dict,
|
||||
call_type: "CallTypesLiteral",
|
||||
) -> dict:
|
||||
# Through v1.111.0 the proxy's leaf-only override check never ran OpenTelemetryV2's pre-call identity
|
||||
# seeding for the Langfuse subclasses. The MRO-aware check that replaced it would, so this no-op keeps them
|
||||
# as they were.
|
||||
return data
|
||||
|
||||
def log_pre_api_call(self, model: str, messages: object, kwargs: Mapping[str, object]) -> None:
|
||||
root: Final = request_root_span()
|
||||
if root is not None and root.is_recording():
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -19,6 +19,19 @@ from litellm._logging import verbose_logger
|
|||
from litellm.anthropic_beta_headers_manager import aload_beta_headers_config
|
||||
from litellm.constants import PROVIDERS_THAT_AUTHENTICATE_ON_PROVIDER_INFO
|
||||
from litellm.integrations.anthropic_cache_control_hook import AnthropicCacheControlHook
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.integrations.custom_logger_v2.enums import Hook
|
||||
from litellm.integrations.custom_logger_v2.legacy import (
|
||||
PROXY_REQUEST_KEYS,
|
||||
Admit,
|
||||
KwargsFields,
|
||||
hook_subscribers,
|
||||
kwargs_after,
|
||||
kwargs_patch,
|
||||
run_subscribers,
|
||||
)
|
||||
from litellm.integrations.custom_logger_v2.logger import CustomLoggerV2
|
||||
from litellm.integrations.custom_logger_v2.outcomes import Patch
|
||||
from litellm.litellm_core_utils.core_helpers import as_str_mapping, drops_unsupported_params
|
||||
from litellm.litellm_core_utils.exception_mapping_utils import exception_type
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
|
|
@ -374,7 +387,7 @@ async def _execute_pre_request_hooks(
|
|||
pass
|
||||
|
||||
# Build complete request kwargs dict
|
||||
request_kwargs = {
|
||||
request_kwargs: dict[str, object] = { # mutable-ok: the hooks take and may replace the request dict
|
||||
"tools": tools,
|
||||
"stream": stream,
|
||||
"litellm_params": {
|
||||
|
|
@ -386,10 +399,21 @@ async def _execute_pre_request_hooks(
|
|||
if not litellm.callbacks:
|
||||
return request_kwargs
|
||||
|
||||
from litellm.integrations.custom_logger import CustomLogger as _CustomLogger
|
||||
callbacks: Final = litellm.callbacks
|
||||
if (
|
||||
Hook.pre_deployment_call in CustomLoggerV2.declared_hooks
|
||||
and (subscribers := hook_subscribers(Hook.pre_deployment_call, callbacks, admit=Admit.every_logger)).has_v2
|
||||
):
|
||||
return kwargs_after( # pyright: ignore[reportReturnType] # whatever the last hook returned, as before
|
||||
await run_subscribers(
|
||||
subscribers,
|
||||
KwargsFields(request_kwargs, PROXY_REQUEST_KEYS),
|
||||
partial(_start_pre_request_hook, model, messages),
|
||||
)
|
||||
)
|
||||
|
||||
for callback in litellm.callbacks:
|
||||
if not isinstance(callback, _CustomLogger):
|
||||
for callback in callbacks:
|
||||
if not isinstance(callback, CustomLogger):
|
||||
continue
|
||||
|
||||
# Call the pre-request hook
|
||||
|
|
@ -402,6 +426,27 @@ async def _execute_pre_request_hooks(
|
|||
return request_kwargs
|
||||
|
||||
|
||||
def _start_pre_request_hook(
|
||||
model: str,
|
||||
messages: list[dict[str, object]], # mutable-ok: the hook takes the messages list
|
||||
callback: object,
|
||||
request_kwargs: object,
|
||||
) -> Awaitable[Patch | None] | None:
|
||||
if not isinstance(callback, CustomLogger):
|
||||
return None
|
||||
return _pre_request_hook(callback, model, messages, request_kwargs)
|
||||
|
||||
|
||||
async def _pre_request_hook(
|
||||
callback: CustomLogger,
|
||||
model: str,
|
||||
messages: list[dict[str, object]], # mutable-ok: see above
|
||||
request_kwargs: object,
|
||||
) -> Patch | None:
|
||||
modified_kwargs: Final = await callback.async_pre_request_hook(model, messages, request_kwargs) # pyright: ignore[reportArgumentType, reportUnknownMemberType, reportUnknownVariableType] # the request dict as the previous hook left it
|
||||
return None if modified_kwargs is None else kwargs_patch(request_kwargs, modified_kwargs) # pyright: ignore[reportUnknownArgumentType] # see above
|
||||
|
||||
|
||||
async def _try_websearch_short_circuit(
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ from litellm.anthropic_beta_headers_manager import (
|
|||
)
|
||||
from litellm.constants import MAX_FILE_LIST_LIMIT, REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES
|
||||
from litellm.files.types import FileContentStreamingResult
|
||||
from litellm.integrations.custom_logger_v2.enums import Hook
|
||||
from litellm.integrations.custom_logger_v2.legacy import Admit, hook_subscribers
|
||||
from litellm.integrations.custom_logger_v2.logger import CustomLoggerV2
|
||||
from litellm.litellm_core_utils.agentic_followup_kwargs import build_agentic_followup_kwargs
|
||||
from litellm.litellm_core_utils.agentic_loop_refusal import (
|
||||
called_tool_names,
|
||||
|
|
@ -562,6 +565,14 @@ def _run_responses_deployment_hook(
|
|||
"custom_llm_provider": custom_llm_provider,
|
||||
},
|
||||
CallTypes.responses.value,
|
||||
include_v2=True,
|
||||
)
|
||||
|
||||
|
||||
def _has_v2_deployment_hook() -> bool:
|
||||
return (
|
||||
Hook.pre_deployment_call in CustomLoggerV2.declared_hooks
|
||||
and hook_subscribers(Hook.pre_deployment_call, litellm.callbacks, admit=Admit.every_logger).has_v2
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -2940,7 +2951,7 @@ class BaseLLMHTTPHandler:
|
|||
dict[str, object],
|
||||
GenericLiteLLMParams,
|
||||
]:
|
||||
if not _has_pre_call_deployment_hook(logging_obj):
|
||||
if not _has_pre_call_deployment_hook(logging_obj) and not _has_v2_deployment_hook():
|
||||
return (
|
||||
model,
|
||||
input,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ from collections.abc import (
|
|||
)
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from functools import lru_cache
|
||||
from functools import lru_cache, partial
|
||||
from http import HTTPStatus
|
||||
from itertools import chain
|
||||
from types import MappingProxyType
|
||||
|
|
@ -66,6 +66,9 @@ from litellm.constants import (
|
|||
UNSAFE_PROXY_RESPONSE_HEADERS,
|
||||
)
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail, ModifyResponseException
|
||||
from litellm.integrations.custom_logger import subclass_overrides_hook
|
||||
from litellm.integrations.custom_logger_v2.enums import Field, Hook
|
||||
from litellm.integrations.custom_logger_v2.legacy import hook_subscribers
|
||||
from litellm.integrations.otel.runtime import phase_event
|
||||
from litellm.litellm_core_utils.add_retry_fallback_headers import get_hidden_params_dict
|
||||
from litellm.litellm_core_utils.agentic_followup_failure import is_agentic_followup_failure
|
||||
|
|
@ -177,9 +180,13 @@ from litellm.proxy.route_llm_request import (
|
|||
from litellm.proxy.spend_tracking.cost_finalization import finalize_proxy_response_cost, prepare_response_cost
|
||||
from litellm.proxy.telemetry.request_context import bind_call_id as bind_telemetry_call_id
|
||||
from litellm.proxy.utils import ( # noqa: F401 # legacy module exports
|
||||
HookRun,
|
||||
ProxyLogging,
|
||||
_check_and_merge_model_level_guardrails, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export
|
||||
check_and_merge_model_level_guardrails,
|
||||
hook_fields,
|
||||
routes_through_apply_guardrail,
|
||||
run_hook_subscribers,
|
||||
)
|
||||
from litellm.responses.mcp.parked_stream_dispatch import in_flight_round_biller, parked_success
|
||||
from litellm.responses.streaming_iterator import partial_responses_incomplete_event_on_disconnect, spawn_logging_task
|
||||
|
|
@ -343,6 +350,7 @@ from litellm.proxy.litellm_pre_call_utils import (
|
|||
from litellm.proxy.policy_engine.response_retrieval import attach_post_call_pipelines_to_retrieval
|
||||
from litellm.types.litellm_params import STREAM_USAGE_INJECTED_FIELD
|
||||
from litellm.types.utils import (
|
||||
LLMResponseTypes,
|
||||
ModelResponse,
|
||||
ModelResponseStream,
|
||||
StandardLoggingPayloadErrorInformation,
|
||||
|
|
@ -4496,11 +4504,9 @@ class ProxyBaseLLMRequestProcessing:
|
|||
is not True
|
||||
):
|
||||
continue
|
||||
leaf_attrs = type(cb).__dict__
|
||||
holds_until_moderated = "async_post_call_streaming_iterator_hook" in leaf_attrs
|
||||
holds_until_moderated = subclass_overrides_hook(type(cb), "async_post_call_streaming_iterator_hook")
|
||||
if (
|
||||
"apply_guardrail" not in leaf_attrs
|
||||
or cb.use_native_lifecycle_hooks
|
||||
not routes_through_apply_guardrail(cb)
|
||||
or cb.mask_response_content
|
||||
or cb.run_in_parallel
|
||||
or ProxyBaseLLMRequestProcessing._releases_held_stream_on_scan(cb, holds_until_moderated)
|
||||
|
|
@ -4946,7 +4952,7 @@ class ProxyBaseLLMRequestProcessing:
|
|||
Extracted as a static method so tests can call the production
|
||||
implementation directly rather than reimplementing the closure.
|
||||
"""
|
||||
_response = assembled_response
|
||||
run: Final[HookRun[LLMResponseTypes]] = HookRun(response=assembled_response)
|
||||
try:
|
||||
from litellm.proxy.proxy_server import llm_router as _global_llm_router
|
||||
from litellm.proxy.utils import (
|
||||
|
|
@ -4958,47 +4964,18 @@ class ProxyBaseLLMRequestProcessing:
|
|||
data=captured_data, llm_router=_global_llm_router
|
||||
)
|
||||
stream_gated: Final = stream_gated_guardrail_names(captured_data, captured_user_api_key_dict)
|
||||
for cb in litellm.callbacks:
|
||||
if not isinstance(cb, CustomGuardrail):
|
||||
continue
|
||||
if cb.guardrail_name in stream_gated:
|
||||
continue
|
||||
if not cb.should_run_guardrail(
|
||||
data=guardrail_data,
|
||||
event_type=GuardrailEventHooks.post_call,
|
||||
):
|
||||
continue
|
||||
try:
|
||||
guardrail_result = None
|
||||
if "apply_guardrail" in type(cb).__dict__ and not cb.use_native_lifecycle_hooks:
|
||||
# Skip — unified-routed guardrails already ran via
|
||||
# unified_guardrail's end-of-stream block in the
|
||||
# streaming iterator pipeline. Running them again
|
||||
# here would duplicate the guardrail API call
|
||||
# (e.g. double OpenAI Moderation charges).
|
||||
continue
|
||||
if "async_post_call_streaming_iterator_hook" in type(cb).__dict__:
|
||||
# Skip — the guardrail already scanned the assembled
|
||||
# response via its own streaming iterator hook in the
|
||||
# streaming pipeline. re running this function async_post_call_success_hook
|
||||
# here would duplicate the scan and can spuriously block the guardrail that already passed / failed.
|
||||
continue
|
||||
else:
|
||||
guardrail_result = await cb.async_post_call_success_hook(
|
||||
user_api_key_dict=captured_user_api_key_dict,
|
||||
data=guardrail_data,
|
||||
response=_response,
|
||||
)
|
||||
if guardrail_result is not None:
|
||||
_response = guardrail_result
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(
|
||||
"Error running post-call guardrail %s on streaming response: %s",
|
||||
getattr(cb, "guardrail_name", type(cb).__name__),
|
||||
e,
|
||||
)
|
||||
if isinstance(e, HTTPException) and hasattr(captured_logging_obj, "model_call_details"):
|
||||
captured_logging_obj.model_call_details.setdefault("metadata", {})["guardrail_blocked"] = True
|
||||
await run_hook_subscribers(
|
||||
hook_subscribers(Hook.post_call_success, litellm.callbacks, parallel=False),
|
||||
hook_fields(guardrail_data, run, Field.response),
|
||||
partial(
|
||||
ProxyBaseLLMRequestProcessing._deferred_stream_guardrail,
|
||||
run,
|
||||
guardrail_data,
|
||||
stream_gated,
|
||||
captured_user_api_key_dict,
|
||||
captured_logging_obj,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(
|
||||
"Error in deferred streaming guardrail initialization: %s",
|
||||
|
|
@ -5013,7 +4990,7 @@ class ProxyBaseLLMRequestProcessing:
|
|||
# async markers stored in litellm_params).
|
||||
asyncio.create_task(
|
||||
_as_success_dispatcher(captured_logging_obj).dispatch_success_handlers(
|
||||
_response,
|
||||
run.response,
|
||||
cache_hit=cache_hit,
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
|
|
@ -5026,6 +5003,55 @@ class ProxyBaseLLMRequestProcessing:
|
|||
e,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _deferred_stream_guardrail(
|
||||
run: HookRun[LLMResponseTypes],
|
||||
guardrail_data: dict[str, object], # mutable-ok: the captured request merged with model-level guardrails
|
||||
stream_gated: frozenset[str],
|
||||
captured_user_api_key_dict: "UserAPIKeyAuth",
|
||||
captured_logging_obj: LiteLLMLoggingObj,
|
||||
cb: object,
|
||||
request: object,
|
||||
) -> None:
|
||||
if not isinstance(cb, CustomGuardrail):
|
||||
return
|
||||
if cb.guardrail_name in stream_gated:
|
||||
return
|
||||
if not cb.should_run_guardrail(
|
||||
data=guardrail_data,
|
||||
event_type=GuardrailEventHooks.post_call,
|
||||
):
|
||||
return
|
||||
try:
|
||||
if routes_through_apply_guardrail(cb):
|
||||
# Skip — unified-routed guardrails already ran via
|
||||
# unified_guardrail's end-of-stream block in the
|
||||
# streaming iterator pipeline. Running them again
|
||||
# here would duplicate the guardrail API call
|
||||
# (e.g. double OpenAI Moderation charges).
|
||||
return
|
||||
if subclass_overrides_hook(type(cb), "async_post_call_streaming_iterator_hook"):
|
||||
# Skip — the guardrail already scanned the assembled
|
||||
# response via its own streaming iterator hook in the
|
||||
# streaming pipeline. re running this function async_post_call_success_hook
|
||||
# here would duplicate the scan and can spuriously block the guardrail that already passed / failed.
|
||||
return
|
||||
run.adopt(
|
||||
await cb.async_post_call_success_hook(
|
||||
user_api_key_dict=captured_user_api_key_dict,
|
||||
data=guardrail_data,
|
||||
response=run.response,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception(
|
||||
"Error running post-call guardrail %s on streaming response: %s",
|
||||
getattr(cb, "guardrail_name", type(cb).__name__),
|
||||
e,
|
||||
)
|
||||
if isinstance(e, HTTPException) and hasattr(captured_logging_obj, "model_call_details"):
|
||||
captured_logging_obj.model_call_details.setdefault("metadata", {})["guardrail_blocked"] = True
|
||||
|
||||
def _apply_router_cooldown_retry_after(self, headers: dict, e: Exception) -> None:
|
||||
if isinstance(e, RouterRateLimitError) and e.cooldown_time > 0:
|
||||
headers["retry-after"] = str(math.ceil(e.cooldown_time))
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ from litellm.constants import (
|
|||
MAX_COMPETITOR_NAMES,
|
||||
)
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.integrations.custom_logger import subclass_overrides_hook
|
||||
from litellm.litellm_core_utils.offline_mode import is_offline_mode
|
||||
from litellm.llms.openai.chat.guardrail_translation.handler import (
|
||||
OpenAIChatCompletionsHandler,
|
||||
|
|
@ -181,7 +182,7 @@ async def apply_policies(
|
|||
continue
|
||||
if not isinstance(callback, CustomGuardrail):
|
||||
continue
|
||||
if "apply_guardrail" not in type(callback).__dict__:
|
||||
if not subclass_overrides_hook(type(callback), "apply_guardrail", CustomGuardrail):
|
||||
verbose_proxy_logger.debug(
|
||||
"apply_policies: guardrail '%s' has no apply_guardrail, skipping",
|
||||
guardrail_name,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ from litellm.integrations.custom_guardrail import (
|
|||
CustomGuardrail,
|
||||
ModifyResponseException,
|
||||
)
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.integrations.custom_logger import CustomLogger, subclass_overrides_hook
|
||||
from litellm.litellm_core_utils.core_helpers import (
|
||||
get_metadata_variable_name_from_kwargs,
|
||||
get_or_create_metadata_bucket,
|
||||
|
|
@ -699,7 +699,10 @@ class PipelineExecutor:
|
|||
@staticmethod
|
||||
def supports_unified_execution(callback: CustomGuardrail) -> bool:
|
||||
"""Whether this guardrail runs through the unified apply_guardrail path."""
|
||||
return "apply_guardrail" in type(callback).__dict__ and not callback.use_native_lifecycle_hooks
|
||||
return (
|
||||
subclass_overrides_hook(type(callback), "apply_guardrail", CustomGuardrail)
|
||||
and not callback.use_native_lifecycle_hooks
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def supports_streaming_execution(callback: CustomGuardrail) -> bool:
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -17,7 +17,8 @@ which outcome callbacks the Python router would still have running for a request
|
|||
router core.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from collections.abc import Awaitable, Iterable, Mapping, Sequence
|
||||
from functools import partial
|
||||
from itertools import chain
|
||||
from typing import TYPE_CHECKING, Final
|
||||
from weakref import WeakSet
|
||||
|
|
@ -27,6 +28,17 @@ from litellm.caching.dual_cache import DualCache
|
|||
from litellm.caching.in_memory_cache import InMemoryCache
|
||||
from litellm.constants import PROMPT_CACHE_PIN_CACHE_MAX_SIZE
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.integrations.custom_logger_v2.enums import Hook
|
||||
from litellm.integrations.custom_logger_v2.hook_point_fields import CandidateFields
|
||||
from litellm.integrations.custom_logger_v2.legacy import (
|
||||
Admit,
|
||||
hook_subscribers,
|
||||
kwargs_after,
|
||||
kwargs_patch,
|
||||
run_subscribers,
|
||||
)
|
||||
from litellm.integrations.custom_logger_v2.logger import CustomLoggerV2
|
||||
from litellm.integrations.custom_logger_v2.outcomes import Patch
|
||||
from litellm.litellm_core_utils.core_helpers import as_str_mapping
|
||||
from litellm.litellm_core_utils.routing_common_utils import get_request_team_id
|
||||
from litellm.router_strategy.base_routing_strategy import BaseRoutingStrategy
|
||||
|
|
@ -460,7 +472,11 @@ class OptionalChecks:
|
|||
|
||||
def has_filters(self) -> bool:
|
||||
"""Whether ``filter_deployments`` would run any callback: without one it keeps every deployment."""
|
||||
return any(filters_deployments(callback) for callback in self.routing_callbacks())
|
||||
callbacks: Final = tuple(self.routing_callbacks())
|
||||
return any(filters_deployments(callback) for callback in callbacks) or (
|
||||
Hook.filter_deployments in CustomLoggerV2.declared_hooks
|
||||
and hook_subscribers(Hook.filter_deployments, callbacks, admit=Admit.every_logger).has_v2
|
||||
)
|
||||
|
||||
async def filter_deployments(
|
||||
self,
|
||||
|
|
@ -473,25 +489,25 @@ class OptionalChecks:
|
|||
turn, then the prompt caching check names the deployment it prefers among the rest, which
|
||||
the Rust core takes only if its later filters keep it; a filter's error is raised for the
|
||||
Rust core to decide on."""
|
||||
remaining: list[object] = list(deployments) # mutable-ok: filter contract # rebind-ok: narrowed
|
||||
remaining: object = list(deployments) # rebind-ok: narrowed by each filter
|
||||
request_kwargs: Final = dict(request)
|
||||
callbacks: Final = tuple(self.routing_callbacks())
|
||||
for callback in callbacks:
|
||||
if not isinstance(callback, CustomLogger) or not filters_deployments(callback):
|
||||
continue
|
||||
if isinstance(callback, PromptCachingDeploymentCheck):
|
||||
continue
|
||||
remaining = await callback.async_filter_deployments( # pyright: ignore[reportUnknownMemberType, reportAssignmentType, reportUnknownVariableType] # see above
|
||||
model=model,
|
||||
healthy_deployments=remaining,
|
||||
messages=messages, # pyright: ignore[reportArgumentType] # the request's messages, as given
|
||||
request_kwargs=request_kwargs,
|
||||
parent_otel_span=None,
|
||||
)
|
||||
if (
|
||||
Hook.filter_deployments in CustomLoggerV2.declared_hooks
|
||||
and (subscribers := hook_subscribers(Hook.filter_deployments, callbacks, admit=Admit.every_logger)).has_v2
|
||||
):
|
||||
filters: Final = partial(_start_filter, model, messages, request_kwargs)
|
||||
narrowed: Final = await run_subscribers(subscribers, CandidateFields(remaining, model), filters)
|
||||
remaining = kwargs_after(narrowed) # rebind-ok: narrowed by the hook's subscribers
|
||||
else:
|
||||
for callback in callbacks:
|
||||
started = _filter_call(callback, model, messages, request_kwargs, remaining)
|
||||
if started is not None:
|
||||
remaining = await started
|
||||
prompt_caching: Final = next((cb for cb in callbacks if isinstance(cb, PromptCachingDeploymentCheck)), None)
|
||||
if prompt_caching is None:
|
||||
return remaining, None
|
||||
return remaining, await prompt_caching.async_preferred_deployment_id(
|
||||
return remaining, None # pyright: ignore[reportReturnType] # whatever the last filter returned
|
||||
return remaining, await prompt_caching.async_preferred_deployment_id( # pyright: ignore[reportReturnType] # see above
|
||||
model,
|
||||
remaining, # pyright: ignore[reportArgumentType] # RustCore's deployment dicts
|
||||
messages, # pyright: ignore[reportArgumentType] # the request's messages, as given
|
||||
|
|
@ -580,6 +596,41 @@ def _ids_of(value: object) -> Sequence[object]:
|
|||
return value if isinstance(value, list | tuple) else () # pyright: ignore[reportUnknownVariableType] # the step's id list
|
||||
|
||||
|
||||
def _filter_call(
|
||||
callback: object,
|
||||
model: str,
|
||||
messages: object,
|
||||
request_kwargs: dict[str, object], # mutable-ok: the filters take the request dict
|
||||
remaining: object,
|
||||
) -> Awaitable[object] | None:
|
||||
if not isinstance(callback, CustomLogger) or not filters_deployments(callback):
|
||||
return None
|
||||
if isinstance(callback, PromptCachingDeploymentCheck):
|
||||
return None
|
||||
return callback.async_filter_deployments( # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # untyped deployment dicts
|
||||
model=model,
|
||||
healthy_deployments=remaining, # pyright: ignore[reportArgumentType] # whatever the previous filter returned
|
||||
messages=messages, # pyright: ignore[reportArgumentType] # the request's messages, as given
|
||||
request_kwargs=request_kwargs,
|
||||
parent_otel_span=None,
|
||||
)
|
||||
|
||||
|
||||
def _start_filter(
|
||||
model: str,
|
||||
messages: object,
|
||||
request_kwargs: dict[str, object], # mutable-ok: the filters take the request dict
|
||||
callback: object,
|
||||
remaining: object,
|
||||
) -> Awaitable[Patch | None] | None:
|
||||
started: Final = _filter_call(callback, model, messages, request_kwargs, remaining)
|
||||
return None if started is None else _narrowed_by(remaining, started)
|
||||
|
||||
|
||||
async def _narrowed_by(remaining: object, started: Awaitable[object]) -> Patch | None:
|
||||
return kwargs_patch(remaining, await started)
|
||||
|
||||
|
||||
def filters_deployments(callback: object) -> bool:
|
||||
"""A routing callback that overrides ``async_filter_deployments``: the base filter returns its
|
||||
input unchanged, so only overriding filters are run."""
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import hashlib
|
||||
import json
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from functools import partial, reduce
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final
|
||||
from typing import TYPE_CHECKING, Final, cast # noqa: TID251 # the router passes the request kwargs as an untyped dict
|
||||
|
||||
import httpx
|
||||
from pydantic import TypeAdapter
|
||||
|
|
@ -13,6 +14,9 @@ from pydantic import TypeAdapter
|
|||
import litellm
|
||||
from litellm._logging import verbose_router_logger
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.integrations.custom_logger_v2.enums import Field, Hook
|
||||
from litellm.integrations.custom_logger_v2.legacy import Admit, hook_subscribers, kwargs_after, run_subscribers
|
||||
from litellm.integrations.custom_logger_v2.logger import CustomLoggerV2
|
||||
from litellm.litellm_core_utils.add_retry_fallback_headers import (
|
||||
add_fallback_headers_to_response,
|
||||
get_fallback_error_info,
|
||||
|
|
@ -975,60 +979,99 @@ async def run_async_fallback(
|
|||
raise error_from_fallbacks
|
||||
|
||||
|
||||
async def log_success_fallback_event(original_model_group: str, kwargs: dict, original_exception: Exception):
|
||||
"""
|
||||
Log a successful fallback event to all registered callbacks.
|
||||
async def log_success_fallback_event(
|
||||
original_model_group: str,
|
||||
kwargs: dict, # mutable-ok: the request kwargs the custom loggers receive
|
||||
original_exception: Exception,
|
||||
):
|
||||
await _log_fallback_event(
|
||||
True,
|
||||
original_model_group,
|
||||
cast( # cast-ok: the request kwargs dict, keyed by parameter name
|
||||
dict[str, object], kwargs
|
||||
),
|
||||
original_exception,
|
||||
)
|
||||
|
||||
Uses LoggingCallbackManager.get_custom_loggers_for_type() to get deduplicated
|
||||
CustomLogger instances from all callback lists.
|
||||
|
||||
Args:
|
||||
original_model_group (str): The original model group before fallback.
|
||||
kwargs (dict): kwargs for the request
|
||||
async def log_failure_fallback_event(
|
||||
original_model_group: str,
|
||||
kwargs: dict, # mutable-ok: the request kwargs the custom loggers receive
|
||||
original_exception: Exception,
|
||||
):
|
||||
await _log_fallback_event(
|
||||
False,
|
||||
original_model_group,
|
||||
cast( # cast-ok: the request kwargs dict, keyed by parameter name
|
||||
dict[str, object], kwargs
|
||||
),
|
||||
original_exception,
|
||||
)
|
||||
|
||||
Note:
|
||||
Errors during logging are caught and reported but do not interrupt the process.
|
||||
"""
|
||||
# Get deduplicated CustomLogger instances from all callback lists
|
||||
custom_loggers: Final = litellm.logging_callback_manager.get_custom_loggers_for_type(CustomLogger)
|
||||
|
||||
for _callback_custom_logger in custom_loggers:
|
||||
try:
|
||||
await _callback_custom_logger.log_success_fallback_event(
|
||||
original_model_group=original_model_group,
|
||||
kwargs=kwargs,
|
||||
original_exception=original_exception,
|
||||
async def _log_fallback_event(
|
||||
succeeded: bool,
|
||||
original_model_group: str,
|
||||
kwargs: dict[str, object], # mutable-ok: the request kwargs the custom loggers receive
|
||||
original_exception: Exception,
|
||||
) -> None:
|
||||
callbacks: Final = reduce(_with_event_logger, litellm.logging_callback_manager.get_all_callbacks(), ())
|
||||
fields: Final = MappingProxyType(
|
||||
{
|
||||
Field.deprecated_kwargs: kwargs,
|
||||
Field.model: original_model_group,
|
||||
Field.error: original_exception,
|
||||
Field.outcome: "fallback_success" if succeeded else "fallback_failure",
|
||||
}
|
||||
)
|
||||
subscribers: Final = hook_subscribers(Hook.router_event, callbacks, admit=Admit.every_logger)
|
||||
_ = kwargs_after(
|
||||
await run_subscribers(
|
||||
subscribers, fields, partial(_start_fallback_event, succeeded, original_model_group, original_exception)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _with_event_logger(kept: tuple[object, ...], callback: object) -> tuple[object, ...]:
|
||||
if not isinstance(callback, CustomLogger | CustomLoggerV2) or callback in kept:
|
||||
return kept
|
||||
return (*kept, callback)
|
||||
|
||||
|
||||
def _start_fallback_event(
|
||||
succeeded: bool, original_model_group: str, original_exception: Exception, callback: object, kwargs: object
|
||||
) -> Awaitable[None] | None:
|
||||
if not isinstance(callback, CustomLogger):
|
||||
return None
|
||||
return _deliver_fallback_event(
|
||||
callback,
|
||||
succeeded,
|
||||
original_model_group,
|
||||
kwargs, # pyright: ignore[reportArgumentType] # the request kwargs as the router passed them
|
||||
original_exception,
|
||||
)
|
||||
|
||||
|
||||
async def _deliver_fallback_event(
|
||||
callback: CustomLogger,
|
||||
succeeded: bool,
|
||||
original_model_group: str,
|
||||
kwargs: dict[str, object], # mutable-ok: the request kwargs the custom loggers receive
|
||||
original_exception: Exception,
|
||||
) -> None:
|
||||
try:
|
||||
if succeeded:
|
||||
await callback.log_success_fallback_event(
|
||||
original_model_group=original_model_group, kwargs=kwargs, original_exception=original_exception
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_router_logger.error("Error in log_success_fallback_event: %s", e)
|
||||
|
||||
|
||||
async def log_failure_fallback_event(original_model_group: str, kwargs: dict, original_exception: Exception):
|
||||
"""
|
||||
Log a failed fallback event to all registered callbacks.
|
||||
|
||||
Uses LoggingCallbackManager.get_custom_loggers_for_type() to get deduplicated
|
||||
CustomLogger instances from all callback lists.
|
||||
|
||||
Args:
|
||||
original_model_group (str): The original model group before fallback.
|
||||
kwargs (dict): kwargs for the request
|
||||
|
||||
Note:
|
||||
Errors during logging are caught and reported but do not interrupt the process.
|
||||
"""
|
||||
# Get deduplicated CustomLogger instances from all callback lists
|
||||
custom_loggers: Final = litellm.logging_callback_manager.get_custom_loggers_for_type(CustomLogger)
|
||||
|
||||
for _callback_custom_logger in custom_loggers:
|
||||
try:
|
||||
await _callback_custom_logger.log_failure_fallback_event(
|
||||
original_model_group=original_model_group,
|
||||
kwargs=kwargs,
|
||||
original_exception=original_exception,
|
||||
else:
|
||||
await callback.log_failure_fallback_event(
|
||||
original_model_group=original_model_group, kwargs=kwargs, original_exception=original_exception
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_router_logger.error("Error in log_failure_fallback_event: %s", e)
|
||||
except Exception as e:
|
||||
verbose_router_logger.error(
|
||||
"Error in log_success_fallback_event: %s" if succeeded else "Error in log_failure_fallback_event: %s", e
|
||||
)
|
||||
|
||||
|
||||
def check_non_standard_fallback_format(fallbacks: Sequence[object] | None) -> bool:
|
||||
|
|
|
|||
|
|
@ -229,15 +229,19 @@ class StreamingLogBuilder(Protocol):
|
|||
|
||||
|
||||
class DeploymentHook(Protocol):
|
||||
def __call__(self, kwargs: dict[str, object], call_type: str) -> Awaitable[object]: ...
|
||||
def __call__(self, kwargs: dict[str, object], call_type: str, *, include_v2: bool) -> Awaitable[object]: ...
|
||||
|
||||
|
||||
class DeploymentSuccessHook(Protocol):
|
||||
def __call__(self, request_data: dict[str, object], response: object, call_type: object) -> Awaitable[object]: ...
|
||||
def __call__(
|
||||
self, request_data: dict[str, object], response: object, call_type: object, *, include_v2: bool
|
||||
) -> Awaitable[object]: ...
|
||||
|
||||
|
||||
class DeploymentFailureHook(Protocol):
|
||||
def __call__(self, request_data: Mapping[str, object], exception: Exception, call_type: str) -> Awaitable[None]: ...
|
||||
def __call__(
|
||||
self, request_data: Mapping[str, object], exception: Exception, call_type: str, *, include_v2: bool
|
||||
) -> Awaitable[None]: ...
|
||||
|
||||
|
||||
def update_logging(
|
||||
|
|
@ -517,7 +521,7 @@ def before_deployment_call(kwargs: dict[str, object], call_type: str) -> Awaitab
|
|||
hook: Final = cast( # cast-ok: bounded adapter for the untyped deployment hook
|
||||
DeploymentHook, utils.async_pre_call_deployment_hook
|
||||
)
|
||||
return hook(kwargs, call_type)
|
||||
return hook(kwargs, call_type, include_v2=True)
|
||||
|
||||
|
||||
def after_deployment_success(kwargs: dict[str, object], response: object, call_type: str) -> Awaitable[object]:
|
||||
|
|
@ -528,7 +532,12 @@ def after_deployment_success(kwargs: dict[str, object], response: object, call_t
|
|||
DeploymentSuccessHook, utils.async_post_call_success_deployment_hook
|
||||
)
|
||||
typed: Final = CallTypes(call_type)
|
||||
return hook(kwargs, _messages_stream(response) if typed == CallTypes.anthropic_messages else response, typed)
|
||||
return hook(
|
||||
kwargs,
|
||||
_messages_stream(response) if typed == CallTypes.anthropic_messages else response,
|
||||
typed,
|
||||
include_v2=True,
|
||||
)
|
||||
|
||||
|
||||
def _messages_stream(response: object) -> object:
|
||||
|
|
@ -558,7 +567,7 @@ def after_deployment_failure(kwargs: dict[str, object], error: Exception, call_t
|
|||
hook: Final = cast( # cast-ok: bounded adapter for the untyped deployment hook
|
||||
DeploymentFailureHook, utils.async_post_call_failure_deployment_hook
|
||||
)
|
||||
return hook(kwargs, error, call_type)
|
||||
return hook(kwargs, error, call_type, include_v2=True)
|
||||
|
||||
|
||||
_STREAM_HEADERS: Final = TypeAdapter(Mapping[str, str])
|
||||
|
|
|
|||
231
litellm/utils.py
231
litellm/utils.py
|
|
@ -83,6 +83,7 @@ from litellm.constants import (
|
|||
PROVIDERS_THAT_AUTHENTICATE_ON_PROVIDER_INFO,
|
||||
TOOL_CHOICE_OBJECT_TOKEN_COUNT,
|
||||
)
|
||||
from litellm.integrations.custom_logger_v2.enums import Field, Hook
|
||||
from litellm.litellm_core_utils.agentic_followup_failure import is_agentic_followup_failure
|
||||
from litellm.litellm_core_utils.allowed_tools_tool_choice import (
|
||||
HOSTED_TOOL_CHOICE_TYPES,
|
||||
|
|
@ -134,21 +135,6 @@ from litellm.rust_bridge.configuration import Decision
|
|||
_CachingHandlerResponse = None
|
||||
_LLMCachingHandler = None
|
||||
_CustomGuardrail = None
|
||||
_CustomLogger = None
|
||||
|
||||
|
||||
def _get_cached_custom_logger():
|
||||
"""
|
||||
Get cached CustomLogger class.
|
||||
Lazy imports on first call to avoid loading custom_logger at import time.
|
||||
Subsequent calls use cached class for better performance.
|
||||
"""
|
||||
global _CustomLogger
|
||||
if _CustomLogger is None:
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
|
||||
_CustomLogger = CustomLogger
|
||||
return _CustomLogger
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
|
|
@ -335,7 +321,7 @@ except (ImportError, AttributeError, TypeError):
|
|||
# Convert to str (if necessary)
|
||||
claude_json_str = json.dumps(json_data)
|
||||
import importlib.metadata
|
||||
from collections.abc import AsyncIterator, Callable, Collection, Iterable, Iterator, Mapping, Sequence, Sized
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Collection, Iterable, Iterator, Mapping, Sequence, Sized
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, TypeAlias, TypeVar, cast, runtime_checkable
|
||||
|
||||
from typing_extensions import assert_never
|
||||
|
|
@ -551,6 +537,21 @@ from .exceptions import (
|
|||
if TYPE_CHECKING:
|
||||
from litellm import MockException
|
||||
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.integrations.custom_logger_v2.hook_point_fields import ResultFields
|
||||
from litellm.integrations.custom_logger_v2.legacy import (
|
||||
PROXY_REQUEST_KEYS,
|
||||
Admit,
|
||||
KwargsFields,
|
||||
hook_subscribers,
|
||||
kwargs_after,
|
||||
kwargs_patch,
|
||||
run_subscribers,
|
||||
)
|
||||
from litellm.integrations.custom_logger_v2.logger import CustomLoggerV2
|
||||
from litellm.integrations.custom_logger_v2.outcomes import Patch
|
||||
|
||||
####### ENVIRONMENT VARIABLES ####################
|
||||
# Adjust to your specific application needs / system capabilities.
|
||||
sentry_sdk_instance: Final = None
|
||||
|
|
@ -1842,7 +1843,9 @@ def _deployment_provider(kwargs: Mapping[str, object]) -> str:
|
|||
return ""
|
||||
|
||||
|
||||
async def async_pre_call_deployment_hook(kwargs: Mapping[str, object], call_type: str) -> dict[str, Any]:
|
||||
async def async_pre_call_deployment_hook(
|
||||
kwargs: Mapping[str, object], call_type: str, *, include_v2: bool = False
|
||||
) -> dict[str, Any]: # mutable-ok: callers merge the returned kwargs
|
||||
"""
|
||||
Allow modifying the request just before it's sent to the deployment.
|
||||
|
||||
|
|
@ -1863,15 +1866,40 @@ async def async_pre_call_deployment_hook(kwargs: Mapping[str, object], call_type
|
|||
body={"type": "invalid_request_error", "param": resolved.field},
|
||||
)
|
||||
modified_kwargs = dict(resolved)
|
||||
callbacks: Final = litellm.callbacks
|
||||
if (
|
||||
include_v2
|
||||
and Hook.pre_deployment_call in CustomLoggerV2.declared_hooks
|
||||
and (subscribers := hook_subscribers(Hook.pre_deployment_call, callbacks, admit=Admit.every_logger)).has_v2
|
||||
):
|
||||
modified_kwargs = kwargs_after( # rebind-ok: whatever the last hook returned
|
||||
await run_subscribers(
|
||||
subscribers,
|
||||
KwargsFields(modified_kwargs, PROXY_REQUEST_KEYS),
|
||||
partial(_start_pre_deployment, typed_call_type),
|
||||
)
|
||||
)
|
||||
else:
|
||||
for callback in callbacks:
|
||||
if isinstance(callback, CustomLogger):
|
||||
result = await callback.async_pre_call_deployment_hook(modified_kwargs, typed_call_type)
|
||||
if result is not None:
|
||||
modified_kwargs = result
|
||||
|
||||
custom_logger_class: Final = _get_cached_custom_logger()
|
||||
for callback in litellm.callbacks:
|
||||
if isinstance(callback, custom_logger_class):
|
||||
result = await callback.async_pre_call_deployment_hook(modified_kwargs, typed_call_type)
|
||||
if result is not None:
|
||||
modified_kwargs = result
|
||||
return _unstreamed_when_post_call_guardrails_apply(modified_kwargs, typed_call_type) # pyright: ignore[reportArgumentType] # whatever the last hook returned, as before
|
||||
|
||||
return _unstreamed_when_post_call_guardrails_apply(modified_kwargs, typed_call_type)
|
||||
|
||||
def _start_pre_deployment(
|
||||
call_type: CallTypes | None, callback: object, kwargs: object
|
||||
) -> Awaitable[Patch | None] | None:
|
||||
if not isinstance(callback, CustomLogger):
|
||||
return None
|
||||
return _pre_deployment(callback, kwargs, call_type)
|
||||
|
||||
|
||||
async def _pre_deployment(callback: CustomLogger, kwargs: object, call_type: CallTypes | None) -> Patch | None:
|
||||
result: Final = await callback.async_pre_call_deployment_hook(kwargs, call_type) # pyright: ignore[reportArgumentType, reportUnknownMemberType, reportUnknownVariableType] # the request dict as the previous hook left it
|
||||
return None if result is None else kwargs_patch(kwargs, result) # pyright: ignore[reportUnknownArgumentType] # see above
|
||||
|
||||
|
||||
def _unstreamed_when_post_call_guardrails_apply(
|
||||
|
|
@ -1896,7 +1924,11 @@ def _unstreamed_when_post_call_guardrails_apply(
|
|||
|
||||
|
||||
async def async_post_call_success_deployment_hook(
|
||||
request_data: dict, response: object, call_type: CallTypes | None
|
||||
request_data: dict, # mutable-ok: the attempt's request dict the hooks receive
|
||||
response: object,
|
||||
call_type: CallTypes | None,
|
||||
*,
|
||||
include_v2: bool = False,
|
||||
) -> object:
|
||||
"""
|
||||
Allow modifying / reviewing the response just after it's received from the deployment.
|
||||
|
|
@ -1906,33 +1938,65 @@ async def async_post_call_success_deployment_hook(
|
|||
except ValueError:
|
||||
typed_call_type = None # unknown call type
|
||||
|
||||
modified_response = response
|
||||
callbacks: Final = litellm.callbacks
|
||||
if (
|
||||
include_v2
|
||||
and Hook.post_deployment_success in CustomLoggerV2.declared_hooks
|
||||
and (subscribers := hook_subscribers(Hook.post_deployment_success, callbacks, admit=Admit.every_logger)).has_v2
|
||||
):
|
||||
fields: Final = ResultFields(request_data, PROXY_REQUEST_KEYS, Field.response, response) # pyright: ignore[reportUnknownArgumentType] # the attempt's request dict
|
||||
_ = kwargs_after(
|
||||
await run_subscribers(subscribers, fields, partial(_start_post_deployment_success, fields, typed_call_type))
|
||||
)
|
||||
return fields.value
|
||||
|
||||
custom_logger_class: Final = _get_cached_custom_logger()
|
||||
CustomGuardrail: Final = _get_cached_custom_guardrail()
|
||||
for callback in litellm.callbacks:
|
||||
if isinstance(callback, custom_logger_class):
|
||||
try:
|
||||
result = await callback.async_post_call_success_deployment_hook(
|
||||
request_data, cast(LLMResponseTypes, modified_response), typed_call_type
|
||||
)
|
||||
except Exception: # noqa: BLE001 # a broken callback must not fail a completed request
|
||||
if isinstance(callback, CustomGuardrail):
|
||||
raise
|
||||
verbose_logger.exception(
|
||||
"async_post_call_success_deployment_hook error in %s for call_type=%s",
|
||||
type(callback).__name__,
|
||||
typed_call_type,
|
||||
)
|
||||
continue
|
||||
modified_response = response # rebind-ok: each hook may replace it
|
||||
for callback in callbacks:
|
||||
if isinstance(callback, CustomLogger):
|
||||
result = await _post_deployment_success(callback, request_data, modified_response, typed_call_type)
|
||||
if result is not None:
|
||||
modified_response = result
|
||||
|
||||
return modified_response
|
||||
|
||||
|
||||
def _start_post_deployment_success(
|
||||
fields: ResultFields, call_type: CallTypes | None, callback: object, request_data: object
|
||||
) -> Awaitable[Patch | None] | None:
|
||||
if not isinstance(callback, CustomLogger):
|
||||
return None
|
||||
return _post_deployment_success_patch(callback, request_data, fields.value, call_type)
|
||||
|
||||
|
||||
async def _post_deployment_success_patch(
|
||||
callback: CustomLogger, request_data: object, response: object, call_type: CallTypes | None
|
||||
) -> Patch | None:
|
||||
result: Final = await _post_deployment_success(callback, request_data, response, call_type)
|
||||
return None if result is None else Patch().set(Field.deprecated_response_obj, result)
|
||||
|
||||
|
||||
async def _post_deployment_success(
|
||||
callback: CustomLogger, request_data: object, response: object, call_type: CallTypes | None
|
||||
) -> object:
|
||||
try:
|
||||
return await callback.async_post_call_success_deployment_hook(
|
||||
request_data, # pyright: ignore[reportArgumentType] # the attempt's request dict
|
||||
cast(LLMResponseTypes, response),
|
||||
call_type,
|
||||
)
|
||||
except Exception: # noqa: BLE001 # a broken callback must not fail a completed request
|
||||
if isinstance(callback, CustomGuardrail):
|
||||
raise
|
||||
verbose_logger.exception(
|
||||
"async_post_call_success_deployment_hook error in %s for call_type=%s",
|
||||
type(callback).__name__,
|
||||
call_type,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def async_post_call_failure_deployment_hook(
|
||||
request_data: Mapping[str, object], exception: Exception, call_type: str
|
||||
request_data: Mapping[str, object], exception: Exception, call_type: str, *, include_v2: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
Notify CustomLogger callbacks that a deployment attempt failed.
|
||||
|
|
@ -1964,24 +2028,63 @@ async def async_post_call_failure_deployment_hook(
|
|||
safe_request_data: Final = MappingProxyType({k: v for k, v in request_data.items() if k != "attempted_targets"})
|
||||
safe_exception: Final = _snapshot_exception_for_hook(exception)
|
||||
|
||||
custom_logger_class: Final = _get_cached_custom_logger()
|
||||
for callback in litellm.callbacks:
|
||||
if isinstance(callback, custom_logger_class):
|
||||
try:
|
||||
if _accepts_fallback_depth_kwarg_for_class(type(callback)):
|
||||
await callback.async_post_call_failure_deployment_hook(
|
||||
safe_request_data, safe_exception, typed_call_type, fallback_depth=fallback_depth
|
||||
)
|
||||
else:
|
||||
await callback.async_post_call_failure_deployment_hook(
|
||||
safe_request_data, safe_exception, typed_call_type
|
||||
)
|
||||
except Exception as callback_error: # noqa: BLE001 # a broken callback must not mask the real failure
|
||||
verbose_logger.debug(
|
||||
"async_post_call_failure_deployment_hook error in %s: %s",
|
||||
type(callback).__name__,
|
||||
callback_error,
|
||||
)
|
||||
callbacks: Final = litellm.callbacks
|
||||
if (
|
||||
include_v2
|
||||
and Hook.post_deployment_failure in CustomLoggerV2.declared_hooks
|
||||
and (subscribers := hook_subscribers(Hook.post_deployment_failure, callbacks, admit=Admit.every_logger)).has_v2
|
||||
):
|
||||
fields: Final = ResultFields(safe_request_data, PROXY_REQUEST_KEYS, Field.error, safe_exception)
|
||||
_ = kwargs_after(
|
||||
await run_subscribers(
|
||||
subscribers, fields, partial(_start_post_deployment_failure, fields, typed_call_type, fallback_depth)
|
||||
)
|
||||
)
|
||||
return
|
||||
for callback in callbacks:
|
||||
if isinstance(callback, CustomLogger):
|
||||
await _post_deployment_failure(callback, safe_request_data, safe_exception, typed_call_type, fallback_depth)
|
||||
|
||||
|
||||
def _start_post_deployment_failure(
|
||||
fields: ResultFields,
|
||||
call_type: CallTypes | None,
|
||||
fallback_depth: int | None,
|
||||
callback: object,
|
||||
request_data: object,
|
||||
) -> Awaitable[None] | None:
|
||||
if not isinstance(callback, CustomLogger):
|
||||
return None
|
||||
return _post_deployment_failure(callback, request_data, fields.value, call_type, fallback_depth)
|
||||
|
||||
|
||||
async def _post_deployment_failure(
|
||||
callback: CustomLogger,
|
||||
request_data: object,
|
||||
exception: object,
|
||||
call_type: CallTypes | None,
|
||||
fallback_depth: int | None,
|
||||
) -> None:
|
||||
try:
|
||||
if _accepts_fallback_depth_kwarg_for_class(type(callback)):
|
||||
await callback.async_post_call_failure_deployment_hook(
|
||||
request_data, # pyright: ignore[reportArgumentType] # the attempt's read-only request view
|
||||
exception, # pyright: ignore[reportArgumentType] # the exception snapshot
|
||||
call_type,
|
||||
fallback_depth=fallback_depth,
|
||||
)
|
||||
else:
|
||||
await callback.async_post_call_failure_deployment_hook(
|
||||
request_data, # pyright: ignore[reportArgumentType] # see above
|
||||
exception, # pyright: ignore[reportArgumentType] # see above
|
||||
call_type,
|
||||
)
|
||||
except Exception as callback_error: # noqa: BLE001 # a broken callback must not mask the real failure
|
||||
verbose_logger.debug(
|
||||
"async_post_call_failure_deployment_hook error in %s: %s",
|
||||
type(callback).__name__,
|
||||
callback_error,
|
||||
)
|
||||
|
||||
|
||||
def post_call_processing(
|
||||
|
|
@ -2439,7 +2542,7 @@ def client(original_function):
|
|||
bind_budget_reservation_to_callbacks(logging_obj.litellm_params)
|
||||
|
||||
kwargs["litellm_logging_obj"] = logging_obj
|
||||
modified_kwargs: Final = await async_pre_call_deployment_hook(kwargs, call_type)
|
||||
modified_kwargs: Final = await async_pre_call_deployment_hook(kwargs, call_type, include_v2=True)
|
||||
transcription_runs_unstreamed: Final = _transcription_runs_unstreamed(call_type, kwargs, modified_kwargs)
|
||||
if modified_kwargs is not None:
|
||||
kwargs = modified_kwargs
|
||||
|
|
@ -2519,6 +2622,7 @@ def client(original_function):
|
|||
request_data=call_kwargs,
|
||||
exception=deployment_error,
|
||||
call_type=call_type,
|
||||
include_v2=True,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
|
@ -2582,6 +2686,7 @@ def client(original_function):
|
|||
else call_kwargs,
|
||||
response=result,
|
||||
call_type=_call_type_enum,
|
||||
include_v2=True,
|
||||
)
|
||||
|
||||
## Add response to cache
|
||||
|
|
|
|||
0
tests/unit/integrations/custom_logger_v2/__init__.py
Normal file
0
tests/unit/integrations/custom_logger_v2/__init__.py
Normal file
547
tests/unit/integrations/custom_logger_v2/test_dispatcher.py
Normal file
547
tests/unit/integrations/custom_logger_v2/test_dispatcher.py
Normal file
|
|
@ -0,0 +1,547 @@
|
|||
import asyncio
|
||||
import gc
|
||||
import threading
|
||||
import weakref
|
||||
from collections.abc import Awaitable, Callable, Coroutine, Mapping, Set
|
||||
from dataclasses import dataclass, field
|
||||
from types import MappingProxyType
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.integrations.custom_logger_v2 import (
|
||||
Abort,
|
||||
Activation,
|
||||
CallbackManifest,
|
||||
CallbackRegistry,
|
||||
CallContext,
|
||||
ContractViolation,
|
||||
CustomLoggerV2,
|
||||
Dispatcher,
|
||||
Field,
|
||||
Hook,
|
||||
Patch,
|
||||
RequestSelection,
|
||||
Reroute,
|
||||
Respond,
|
||||
Selection,
|
||||
UndeclaredReadError,
|
||||
build_manifest,
|
||||
on,
|
||||
)
|
||||
|
||||
F = Field
|
||||
Behaviour = Callable[[object, CallContext[object]], Awaitable[object]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Calls:
|
||||
calls: int = 0
|
||||
|
||||
|
||||
async def _nothing(view: object, ctx: CallContext[object]) -> object:
|
||||
return None
|
||||
|
||||
|
||||
def v2_callback(
|
||||
name: str,
|
||||
hook: Hook = Hook.pre_call,
|
||||
behaviour: Behaviour = _nothing,
|
||||
*,
|
||||
reads: Set[Field] = frozenset(),
|
||||
writes: Set[Field] = frozenset(),
|
||||
may_abort: bool = False,
|
||||
parallel: bool | None = None,
|
||||
awaited: bool = False,
|
||||
selection: Selection | None = None,
|
||||
state: type | None = None,
|
||||
) -> CallbackManifest:
|
||||
async def handle(self: CustomLoggerV2, view: object, ctx: CallContext[object]) -> object:
|
||||
return await behaviour(view, ctx)
|
||||
|
||||
decorated = on(hook, reads=reads, writes=writes, may_abort=may_abort, parallel=parallel, awaited=awaited)(handle)
|
||||
namespace = {"handle": decorated, "selection": selection or Selection(name), "request_state": state}
|
||||
return build_manifest(type(name, (CustomLoggerV2,), namespace)())
|
||||
|
||||
|
||||
def returning(result: object, journal: list[str] | None = None, name: str = "") -> Behaviour:
|
||||
async def behaviour(view: object, ctx: CallContext[object]) -> object:
|
||||
if journal is not None:
|
||||
journal.append(f"{name}+")
|
||||
await asyncio.sleep(0)
|
||||
journal.append(f"{name}-")
|
||||
return result
|
||||
|
||||
return behaviour
|
||||
|
||||
|
||||
class RecordingSink:
|
||||
def __init__(self) -> None:
|
||||
self.deliveries: list[Coroutine[object, object, None]] = []
|
||||
|
||||
def submit(self, delivery: Coroutine[object, object, None]) -> None:
|
||||
self.deliveries.append(delivery)
|
||||
|
||||
async def run_all(self) -> None:
|
||||
for delivery in self.deliveries:
|
||||
await delivery
|
||||
self.deliveries.clear()
|
||||
|
||||
|
||||
def dispatcher(*manifests: CallbackManifest, sink: RecordingSink | None = None, enforce: bool = False) -> Dispatcher:
|
||||
return Dispatcher(CallbackRegistry(manifests), sink=sink or RecordingSink(), enforce=enforce)
|
||||
|
||||
|
||||
MESSAGES = (MappingProxyType({"role": "user", "content": "my card is 4111"}),)
|
||||
FIELDS: Mapping[Field, object] = MappingProxyType({F.messages: MESSAGES, F.metadata: MappingProxyType({"a": 1})})
|
||||
|
||||
|
||||
async def test_v2_callbacks_on_other_hooks_leave_this_hook_a_passthrough() -> None:
|
||||
elsewhere = v2_callback("elsewhere", Hook.post_call_success, returning(Patch()), writes={F.response})
|
||||
|
||||
result = await dispatcher(elsewhere).open_scope(RequestSelection()).run_hook(Hook.pre_call, FIELDS, None)
|
||||
|
||||
assert result.fields is FIELDS
|
||||
assert result.outcome is None
|
||||
assert result.patches == ()
|
||||
|
||||
|
||||
async def test_design_doc_worked_example_runs_in_its_stages_and_audit_sees_the_final_messages() -> None:
|
||||
journal: list[str] = []
|
||||
masked = (MappingProxyType({"role": "user", "content": "my card is [CARD]"}),)
|
||||
scanned = (MappingProxyType({"role": "user", "content": "[SCANNED]"}),)
|
||||
seen_by_audit: list[object] = []
|
||||
|
||||
async def audit(view: object, ctx: CallContext[object]) -> None:
|
||||
seen_by_audit.append(getattr(view, "messages"))
|
||||
|
||||
async def secret_scan(view: object, ctx: CallContext[object]) -> Patch:
|
||||
journal.append("secret_scan+")
|
||||
assert getattr(view, "messages") == masked
|
||||
return Patch().set(F.messages, scanned)
|
||||
|
||||
sink = RecordingSink()
|
||||
scope = dispatcher(
|
||||
v2_callback(
|
||||
"pii_mask",
|
||||
behaviour=returning(Patch().set(F.messages, masked), journal, "pii_mask"),
|
||||
reads={F.messages},
|
||||
writes={F.messages},
|
||||
may_abort=True,
|
||||
),
|
||||
v2_callback(
|
||||
"tagger",
|
||||
behaviour=returning(Patch().merge(F.metadata, {"tag": "x"}), journal, "tagger"),
|
||||
writes={F.metadata},
|
||||
),
|
||||
v2_callback("limiter", behaviour=returning(None, journal, "limiter"), reads={F.key_identity}, may_abort=True),
|
||||
v2_callback("secret_scan", behaviour=secret_scan, reads={F.messages}, writes={F.messages}, may_abort=True),
|
||||
v2_callback("audit", behaviour=audit, reads={F.messages}),
|
||||
sink=sink,
|
||||
).open_scope(RequestSelection())
|
||||
|
||||
result = await scope.run_hook(Hook.pre_call, FIELDS, None)
|
||||
|
||||
assert journal == ["pii_mask+", "tagger+", "pii_mask-", "tagger-", "limiter+", "limiter-", "secret_scan+"]
|
||||
assert result.fields[F.messages] == scanned
|
||||
assert result.fields[F.metadata] == {"a": 1, "tag": "x"}
|
||||
assert seen_by_audit == []
|
||||
await sink.run_all()
|
||||
assert seen_by_audit == [scanned]
|
||||
|
||||
|
||||
async def test_patches_come_back_in_list_order_even_when_stages_run_out_of_it() -> None:
|
||||
first = Patch().set(F.messages, ())
|
||||
second = Patch().set(F.optional_params, {})
|
||||
third = Patch().merge(F.metadata, {"b": 2})
|
||||
scope = dispatcher(
|
||||
v2_callback("first", behaviour=returning(first), writes={F.messages}, may_abort=True),
|
||||
v2_callback("second", behaviour=returning(second), writes={F.optional_params}, may_abort=True),
|
||||
v2_callback("third", behaviour=returning(third), writes={F.metadata}),
|
||||
).open_scope(RequestSelection())
|
||||
|
||||
result = await scope.run_hook(Hook.pre_call, FIELDS, None)
|
||||
|
||||
assert result.patches == (first, second, third)
|
||||
|
||||
|
||||
async def test_a_later_writer_never_shows_an_earlier_reader_its_patch() -> None:
|
||||
seen: list[object] = []
|
||||
|
||||
async def scanner(view: object, ctx: CallContext[object]) -> None:
|
||||
seen.append(getattr(view, "messages"))
|
||||
await asyncio.sleep(0)
|
||||
|
||||
scope = dispatcher(
|
||||
v2_callback("tagger", behaviour=returning(Patch().merge(F.metadata, {"t": 1})), writes={F.metadata}),
|
||||
v2_callback("scanner", behaviour=scanner, reads={F.messages}, may_abort=True),
|
||||
v2_callback("rewriter", behaviour=returning(Patch().set(F.messages, ())), writes={F.messages}),
|
||||
).open_scope(RequestSelection())
|
||||
|
||||
result = await scope.run_hook(Hook.pre_call, FIELDS, None)
|
||||
|
||||
assert seen == [MESSAGES]
|
||||
assert result.fields[F.messages] == ()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("outcome", [Abort(status=400, message="blocked"), Respond(response={"ok": True})])
|
||||
async def test_terminal_outcome_stops_new_starts_and_discards_in_flight_patches(
|
||||
outcome: Abort | Respond,
|
||||
) -> None:
|
||||
journal: list[str] = []
|
||||
scope = dispatcher(
|
||||
v2_callback("guard", behaviour=returning(outcome, journal, "guard"), reads={F.messages}, may_abort=True),
|
||||
v2_callback(
|
||||
"tagger", behaviour=returning(Patch().merge(F.metadata, {"t": 1}), journal, "tagger"), writes={F.metadata}
|
||||
),
|
||||
v2_callback("later", behaviour=returning(None, journal, "later"), may_abort=True),
|
||||
).open_scope(RequestSelection())
|
||||
|
||||
result = await scope.run_hook(Hook.pre_call, FIELDS, None)
|
||||
|
||||
assert result.outcome is outcome
|
||||
assert result.patches == ()
|
||||
assert journal == ["guard+", "tagger+", "guard-", "tagger-"]
|
||||
|
||||
|
||||
async def test_patches_from_finished_stages_survive_a_later_abort() -> None:
|
||||
patch = Patch().set(F.messages, ())
|
||||
abort = Abort(status=429, message="slow down")
|
||||
scope = dispatcher(
|
||||
v2_callback("masker", behaviour=returning(patch), writes={F.messages}, may_abort=True),
|
||||
v2_callback("limiter", behaviour=returning(abort), may_abort=True),
|
||||
).open_scope(RequestSelection())
|
||||
|
||||
result = await scope.run_hook(Hook.pre_call, FIELDS, None)
|
||||
|
||||
assert result.outcome is abort
|
||||
assert result.patches == (patch,)
|
||||
|
||||
|
||||
async def test_reroute_is_recorded_and_the_rest_of_the_list_still_runs() -> None:
|
||||
journal: list[str] = []
|
||||
|
||||
scope = dispatcher(
|
||||
v2_callback("router", behaviour=returning(Reroute(model="safe-model")), may_abort=True),
|
||||
v2_callback("second_router", behaviour=returning(Reroute(model="other")), may_abort=True),
|
||||
v2_callback("scanner", behaviour=returning(None, journal, "scanner"), may_abort=True),
|
||||
).open_scope(RequestSelection())
|
||||
|
||||
result = await scope.run_hook(Hook.pre_call, FIELDS, None)
|
||||
|
||||
assert result.outcome == Reroute(model="safe-model")
|
||||
assert scope.reroute == Reroute(model="safe-model")
|
||||
assert journal == ["scanner+", "scanner-"]
|
||||
|
||||
|
||||
async def test_a_later_abort_beats_an_earlier_reroute() -> None:
|
||||
abort = Abort(status=400, message="blocked")
|
||||
scope = dispatcher(
|
||||
v2_callback("router", behaviour=returning(Reroute(model="safe-model")), may_abort=True),
|
||||
v2_callback("guard", behaviour=returning(abort), may_abort=True),
|
||||
).open_scope(RequestSelection())
|
||||
|
||||
result = await scope.run_hook(Hook.pre_call, FIELDS, None)
|
||||
|
||||
assert result.outcome is abort
|
||||
assert scope.reroute is None
|
||||
|
||||
|
||||
async def test_unexpected_exception_from_a_blocking_v2_callback_fails_the_request_closed() -> None:
|
||||
error = ValueError("vendor down")
|
||||
later = Calls()
|
||||
|
||||
async def broken(view: object, ctx: CallContext[object]) -> None:
|
||||
raise error
|
||||
|
||||
async def counted(view: object, ctx: CallContext[object]) -> None:
|
||||
later.calls += 1
|
||||
|
||||
scope = dispatcher(
|
||||
v2_callback("guard", behaviour=broken, may_abort=True),
|
||||
v2_callback("later", behaviour=counted, may_abort=True),
|
||||
).open_scope(RequestSelection())
|
||||
|
||||
with pytest.raises(ValueError, match="vendor down") as raised:
|
||||
await scope.run_hook(Hook.pre_call, FIELDS, None)
|
||||
|
||||
assert raised.value is error
|
||||
assert later.calls == 0
|
||||
|
||||
|
||||
async def test_swallowing_hooks_count_the_failure_and_keep_the_other_patches() -> None:
|
||||
async def broken(view: object, ctx: CallContext[object]) -> None:
|
||||
raise ValueError("header lookup failed")
|
||||
|
||||
patch = Patch().merge(F.response_headers, {"x-limit": "10"})
|
||||
owner = dispatcher(
|
||||
v2_callback("broken", Hook.response_headers, broken, writes={F.response_headers}),
|
||||
v2_callback("limits", Hook.response_headers, returning(patch), writes={F.response_headers}),
|
||||
)
|
||||
|
||||
result = await owner.open_scope(RequestSelection()).run_hook(Hook.response_headers, {}, None)
|
||||
|
||||
assert result.patches == (patch,)
|
||||
assert owner.failures[("broken", Hook.response_headers)] == 1
|
||||
|
||||
|
||||
async def test_awaited_readers_run_after_blocking_work_in_parallel_and_never_fail_the_request() -> None:
|
||||
journal: list[str] = []
|
||||
|
||||
async def failing(view: object, ctx: CallContext[object]) -> None:
|
||||
journal.append("failing+")
|
||||
await asyncio.sleep(0)
|
||||
raise RuntimeError("redis down")
|
||||
|
||||
owner = dispatcher(
|
||||
v2_callback("release", behaviour=returning(None, journal, "release"), reads={F.key_identity}, awaited=True),
|
||||
v2_callback("failing", behaviour=failing, awaited=True),
|
||||
v2_callback("writer", behaviour=returning(Patch().set(F.messages, ()), journal, "writer"), writes={F.messages}),
|
||||
)
|
||||
|
||||
result = await owner.open_scope(RequestSelection()).run_hook(Hook.pre_call, FIELDS, None)
|
||||
|
||||
assert journal == ["writer+", "writer-", "release+", "failing+", "release-"]
|
||||
assert result.fields[F.messages] == ()
|
||||
assert owner.failures[("failing", Hook.pre_call)] == 1
|
||||
|
||||
|
||||
async def test_during_call_abortables_run_in_parallel_and_list_order_picks_the_winner() -> None:
|
||||
journal: list[str] = []
|
||||
first = Abort(status=400, message="first")
|
||||
second = Abort(status=400, message="second")
|
||||
scope = dispatcher(
|
||||
v2_callback("slow", Hook.during_call, returning(None, journal, "slow"), may_abort=True),
|
||||
v2_callback("first", Hook.during_call, returning(first, journal, "first"), may_abort=True),
|
||||
v2_callback("second", Hook.during_call, returning(second, journal, "second"), may_abort=True),
|
||||
).open_scope(RequestSelection())
|
||||
|
||||
result = await scope.run_hook(Hook.during_call, FIELDS, None)
|
||||
|
||||
assert journal == ["slow+", "first+", "second+", "slow-", "first-", "second-"]
|
||||
assert result.outcome is first
|
||||
|
||||
|
||||
async def test_parallel_pre_call_checks_see_the_patches_of_the_serial_chain() -> None:
|
||||
seen: list[object] = []
|
||||
|
||||
async def check(view: object, ctx: CallContext[object]) -> None:
|
||||
seen.append(getattr(view, "messages"))
|
||||
|
||||
scope = dispatcher(
|
||||
v2_callback("check", behaviour=check, reads={F.messages}, may_abort=True, parallel=True),
|
||||
v2_callback("masker", behaviour=returning(Patch().set(F.messages, ())), writes={F.messages}, may_abort=True),
|
||||
).open_scope(RequestSelection())
|
||||
|
||||
await scope.run_hook(Hook.pre_call, FIELDS, None)
|
||||
|
||||
assert seen == [()]
|
||||
|
||||
|
||||
async def test_selection_mask_removes_opted_out_v2_callbacks_and_their_reads() -> None:
|
||||
called: list[str] = []
|
||||
|
||||
async def note(view: object, ctx: CallContext[object]) -> None:
|
||||
called.append("guard")
|
||||
|
||||
owner = dispatcher(
|
||||
v2_callback(
|
||||
"guard",
|
||||
behaviour=note,
|
||||
reads={F.messages},
|
||||
may_abort=True,
|
||||
selection=Selection("guard", activation=Activation.opt_in),
|
||||
),
|
||||
v2_callback("limiter", reads={F.key_identity}, may_abort=True),
|
||||
)
|
||||
|
||||
unrequested = owner.open_scope(RequestSelection())
|
||||
requested = owner.open_scope(RequestSelection(requested=frozenset({"guard"})))
|
||||
|
||||
assert unrequested.reads(Hook.pre_call) == {F.key_identity}
|
||||
assert requested.reads(Hook.pre_call) == {F.key_identity, F.messages}
|
||||
await unrequested.run_hook(Hook.pre_call, FIELDS, None)
|
||||
assert called == []
|
||||
await requested.run_hook(Hook.pre_call, FIELDS, None)
|
||||
assert called == ["guard"]
|
||||
|
||||
|
||||
async def test_a_subscription_activation_override_beats_the_instance_activation() -> None:
|
||||
class Masker(CustomLoggerV2):
|
||||
selection = Selection("masker", activation=Activation.opt_in)
|
||||
activation_overrides = MappingProxyType({Hook.log_prepare: Activation.required})
|
||||
|
||||
@on(Hook.pre_call, reads={F.messages}, may_abort=True)
|
||||
async def check(self, view: object, ctx: CallContext[object]) -> None: ...
|
||||
|
||||
@on(Hook.log_prepare, writes={F.standard_logging_payload})
|
||||
async def redact(self, view: object, ctx: CallContext[object]) -> None: ...
|
||||
|
||||
scope = dispatcher(build_manifest(Masker())).open_scope(RequestSelection(admin_opt_outs=frozenset({"masker"})))
|
||||
|
||||
assert scope.plan(Hook.pre_call).stages == ()
|
||||
assert len(scope.plan(Hook.log_prepare).stages) == 1
|
||||
|
||||
|
||||
async def test_off_path_v2_callbacks_are_delivered_once_per_hook_when_it_finishes_without_state() -> None:
|
||||
@dataclass
|
||||
class Spans:
|
||||
open: list[str] = field(default_factory=list)
|
||||
|
||||
states: list[object] = []
|
||||
|
||||
async def remember(view: object, ctx: CallContext[object]) -> None:
|
||||
states.append(ctx.state)
|
||||
|
||||
sink = RecordingSink()
|
||||
scope = dispatcher(
|
||||
v2_callback("otel_pre", Hook.pre_call, remember, reads={F.messages}, state=Spans),
|
||||
v2_callback("otel_awaited", Hook.pre_call, remember, awaited=True, state=Spans),
|
||||
sink=sink,
|
||||
).open_scope(RequestSelection())
|
||||
|
||||
await scope.run_hook(Hook.pre_call, FIELDS, None)
|
||||
|
||||
assert len(sink.deliveries) == 1
|
||||
assert len(states) == 1
|
||||
assert isinstance(states[0], Spans)
|
||||
await sink.run_all()
|
||||
assert states[1] is None
|
||||
|
||||
|
||||
def test_sync_off_path_handler_runs_on_a_worker_thread() -> None:
|
||||
threads: list[int] = []
|
||||
|
||||
class SyncLogger(CustomLoggerV2):
|
||||
@on(Hook.attempt_ended, reads={F.usage})
|
||||
def ended(self, view: object, ctx: CallContext[object]) -> None:
|
||||
threads.append(threading.get_ident())
|
||||
|
||||
sink = RecordingSink()
|
||||
scope = dispatcher(build_manifest(SyncLogger()), sink=sink).open_scope(RequestSelection())
|
||||
|
||||
async def run() -> None:
|
||||
await scope.run_hook(Hook.attempt_ended, {F.usage: 3}, None)
|
||||
await sink.run_all()
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
assert len(threads) == 1
|
||||
assert threads[0] != threading.get_ident()
|
||||
|
||||
|
||||
async def test_request_state_is_shared_across_hooks_per_request_and_freed_on_close() -> None:
|
||||
@dataclass
|
||||
class Tokens:
|
||||
tokens: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
states: list[object] = []
|
||||
|
||||
async def remember(view: object, ctx: CallContext[object]) -> None:
|
||||
states.append(ctx.state)
|
||||
|
||||
class PiiMask(CustomLoggerV2):
|
||||
request_state = Tokens
|
||||
|
||||
@on(Hook.pre_call, writes={F.messages})
|
||||
async def mask(self, view: object, ctx: CallContext[object]) -> None:
|
||||
await remember(view, ctx)
|
||||
|
||||
@on(Hook.post_call_success, writes={F.response})
|
||||
async def unmask(self, view: object, ctx: CallContext[object]) -> None:
|
||||
await remember(view, ctx)
|
||||
|
||||
owner = dispatcher(build_manifest(PiiMask()))
|
||||
with owner.open_scope(RequestSelection()) as first:
|
||||
await first.run_hook(Hook.pre_call, FIELDS, None)
|
||||
await first.run_hook(Hook.post_call_success, {}, None)
|
||||
with owner.open_scope(RequestSelection()) as second:
|
||||
await second.run_hook(Hook.pre_call, FIELDS, None)
|
||||
|
||||
assert isinstance(states[0], Tokens)
|
||||
assert states[0] is states[1]
|
||||
assert states[2] is not states[0]
|
||||
freed = weakref.ref(states[0])
|
||||
states.clear()
|
||||
gc.collect()
|
||||
assert freed() is None
|
||||
|
||||
|
||||
async def test_registry_change_reaches_new_scopes_and_leaves_open_ones_alone() -> None:
|
||||
registry = CallbackRegistry()
|
||||
owner = Dispatcher(registry, sink=RecordingSink())
|
||||
before = owner.open_scope(RequestSelection())
|
||||
late = v2_callback("late", reads={F.messages}, may_abort=True)
|
||||
|
||||
registry.register(late)
|
||||
after = owner.open_scope(RequestSelection())
|
||||
|
||||
assert before.plan(Hook.pre_call).stages == ()
|
||||
assert len(after.plan(Hook.pre_call).stages) == 1
|
||||
registry.unregister(late.logger)
|
||||
assert owner.open_scope(RequestSelection()).plan(Hook.pre_call).stages == ()
|
||||
|
||||
|
||||
async def test_production_mode_applies_patches_without_checking_declarations() -> None:
|
||||
sneaky = Patch().set(F.model, "other")
|
||||
scope = dispatcher(v2_callback("sneaky", behaviour=returning(sneaky), writes={F.messages})).open_scope(
|
||||
RequestSelection()
|
||||
)
|
||||
|
||||
result = await scope.run_hook(Hook.pre_call, FIELDS, None)
|
||||
|
||||
assert result.fields[F.model] == "other"
|
||||
|
||||
|
||||
async def test_enforced_mode_restricts_views_to_declared_reads() -> None:
|
||||
async def peek(view: object, ctx: CallContext[object]) -> None:
|
||||
getattr(view, "metadata")
|
||||
|
||||
scope = dispatcher(
|
||||
v2_callback("peek", behaviour=peek, reads={F.messages}, may_abort=True), enforce=True
|
||||
).open_scope(RequestSelection())
|
||||
|
||||
with pytest.raises(UndeclaredReadError):
|
||||
await scope.run_hook(Hook.pre_call, FIELDS, None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("result", "may_abort", "hook"),
|
||||
[
|
||||
(Patch().set(F.model, "other"), False, Hook.pre_call),
|
||||
(Abort(status=400, message="no"), False, Hook.pre_call),
|
||||
(Reroute(model="other"), True, Hook.during_call),
|
||||
("not an outcome", True, Hook.pre_call),
|
||||
],
|
||||
)
|
||||
async def test_enforced_mode_rejects_results_outside_the_declaration(
|
||||
result: object, may_abort: bool, hook: Hook
|
||||
) -> None:
|
||||
writes = frozenset({F.messages}) if hook is Hook.pre_call else frozenset()
|
||||
scope = dispatcher(
|
||||
v2_callback("liar", hook, returning(result), writes=writes, may_abort=may_abort, parallel=False), enforce=True
|
||||
).open_scope(RequestSelection())
|
||||
|
||||
with pytest.raises(ContractViolation):
|
||||
await scope.run_hook(hook, FIELDS, None)
|
||||
|
||||
|
||||
async def test_enforced_mode_violations_escape_the_awaited_swallow() -> None:
|
||||
scope = dispatcher(
|
||||
v2_callback("awaited", behaviour=returning(Patch().set(F.messages, ())), awaited=True), enforce=True
|
||||
).open_scope(RequestSelection())
|
||||
|
||||
with pytest.raises(ContractViolation):
|
||||
await scope.run_hook(Hook.pre_call, FIELDS, None)
|
||||
|
||||
|
||||
async def test_enforced_mode_accepts_declared_behaviour() -> None:
|
||||
async def masker(view: object, ctx: CallContext[object]) -> Patch:
|
||||
return Patch().set(F.messages, (*getattr(view, "messages"), "extra"))
|
||||
|
||||
scope = dispatcher(
|
||||
v2_callback("masker", behaviour=masker, reads={F.messages}, writes={F.messages}, may_abort=True), enforce=True
|
||||
).open_scope(RequestSelection())
|
||||
|
||||
result = await scope.run_hook(Hook.pre_call, FIELDS, None)
|
||||
|
||||
assert result.fields[F.messages] == (*MESSAGES, "extra")
|
||||
73
tests/unit/integrations/custom_logger_v2/test_enums.py
Normal file
73
tests/unit/integrations/custom_logger_v2/test_enums.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
from collections import Counter
|
||||
from enum import Enum
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.integrations.custom_logger_v2 import Activation, Field, FieldTier, Hook, OptOutBy
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enum_type", [Hook, Field, Activation, OptOutBy])
|
||||
def test_enum_values_are_their_lowercase_names_so_config_and_manifest_share_one_string(enum_type: type[Enum]) -> None:
|
||||
mismatched = [(member.name, member.value) for member in enum_type if member.value != member.name.lower()]
|
||||
assert mismatched == []
|
||||
assert all(isinstance(member, str) for member in enum_type)
|
||||
|
||||
|
||||
def test_field_round_trips_from_its_config_string() -> None:
|
||||
assert Field("provider_request_body") is Field.provider_request_body
|
||||
assert Field.messages == "messages"
|
||||
|
||||
|
||||
def test_field_bits_are_distinct_and_fit_a_u64_bitset() -> None:
|
||||
bits = [member.bit for member in Field]
|
||||
assert len(set(bits)) == len(bits) == 30
|
||||
assert all(0 <= bit < 64 for bit in bits)
|
||||
|
||||
|
||||
def test_fields_fall_in_the_four_cost_tiers_of_the_design() -> None:
|
||||
assert Counter(member.tier for member in Field) == {
|
||||
FieldTier.small_record: 11,
|
||||
FieldTier.content: 12,
|
||||
FieldTier.wire: 4,
|
||||
FieldTier.compatibility: 3,
|
||||
}
|
||||
assert {member for member in Field if member.tier is FieldTier.compatibility} == {
|
||||
Field.standard_logging_payload,
|
||||
Field.deprecated_kwargs,
|
||||
Field.deprecated_response_obj,
|
||||
}
|
||||
|
||||
|
||||
def test_field_bits_are_the_wire_contract_shared_with_the_rust_manifest() -> None:
|
||||
assert {member.value: member.bit for member in Field} == {
|
||||
"model": 0,
|
||||
"key_identity": 1,
|
||||
"deployment": 2,
|
||||
"candidate_deployments": 3,
|
||||
"request_tags": 4,
|
||||
"dynamic_params": 5,
|
||||
"guardrail_info": 6,
|
||||
"outcome": 7,
|
||||
"error": 8,
|
||||
"usage": 9,
|
||||
"cost": 10,
|
||||
"metadata": 11,
|
||||
"messages": 12,
|
||||
"input": 13,
|
||||
"tools": 14,
|
||||
"optional_params": 15,
|
||||
"request_headers": 16,
|
||||
"response": 17,
|
||||
"response_headers": 18,
|
||||
"stream_chunk": 19,
|
||||
"assembled_stream": 20,
|
||||
"tool_result": 21,
|
||||
"model_list": 22,
|
||||
"request_body": 23,
|
||||
"provider_request_body": 24,
|
||||
"provider_response_raw": 25,
|
||||
"provider_response_headers": 26,
|
||||
"standard_logging_payload": 27,
|
||||
"deprecated_kwargs": 28,
|
||||
"deprecated_response_obj": 29,
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.integrations.custom_logger_v2 import Field, InvalidPatchError, Patch, apply_patch
|
||||
from litellm.integrations.custom_logger_v2.legacy import PROXY_REQUEST_KEYS
|
||||
from litellm.integrations.custom_logger_v2.hook_point_fields import CandidateFields, ResultFields
|
||||
|
||||
|
||||
def test_candidate_fields_show_legacy_the_list_and_v2_callbacks_a_tuple_and_keep_only_a_narrowing() -> None:
|
||||
first: Final[dict[str, object]] = {"id": "a"}
|
||||
second: Final[dict[str, object]] = {"id": "b"}
|
||||
deployments: Final[list[object]] = [first, second]
|
||||
fields: Final = CandidateFields(deployments, "group")
|
||||
|
||||
narrowed: Final = apply_patch(fields, Patch().set(Field.candidate_deployments, (second,)))
|
||||
|
||||
assert fields[Field.deprecated_kwargs] is deployments
|
||||
assert fields[Field.candidate_deployments] == (first, second)
|
||||
assert fields[Field.model] == "group"
|
||||
assert narrowed[Field.deprecated_kwargs] == [second]
|
||||
assert narrowed[Field.deprecated_kwargs][0] is second
|
||||
assert set(narrowed) == {Field.deprecated_kwargs, Field.candidate_deployments, Field.model}
|
||||
|
||||
|
||||
def test_candidate_fields_carry_whatever_a_legacy_filter_returned() -> None:
|
||||
fields: Final = apply_patch(CandidateFields([{"id": "a"}], "group"), Patch().set(Field.deprecated_kwargs, None))
|
||||
|
||||
assert fields[Field.deprecated_kwargs] is None
|
||||
assert Field.candidate_deployments not in fields
|
||||
with pytest.raises(InvalidPatchError, match="stay a sequence"):
|
||||
apply_patch(fields, Patch().set(Field.candidate_deployments, []))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"patch",
|
||||
[
|
||||
pytest.param(Patch().set(Field.candidate_deployments, [{"id": "a"}]), id="an equal copy"),
|
||||
pytest.param(Patch().set(Field.model, "other"), id="another field"),
|
||||
],
|
||||
)
|
||||
def test_candidate_fields_refuse_anything_but_a_narrowing(patch: Patch) -> None:
|
||||
with pytest.raises(InvalidPatchError):
|
||||
apply_patch(CandidateFields([{"id": "a"}], "group"), patch)
|
||||
|
||||
|
||||
def test_result_fields_keep_one_live_slot_that_legacy_and_v2_callback_patches_both_replace() -> None:
|
||||
request: Final[dict[str, object]] = {"model": "m", "messages": ["hi"]}
|
||||
fields: Final = ResultFields(request, PROXY_REQUEST_KEYS, Field.response, "original")
|
||||
|
||||
after_legacy: Final = apply_patch(fields, Patch().set(Field.deprecated_response_obj, "from legacy"))
|
||||
seen_by_v2: Final = fields[Field.response]
|
||||
after_v2: Final = apply_patch(after_legacy, Patch().set(Field.response, "from v2"))
|
||||
|
||||
assert after_legacy is fields
|
||||
assert after_v2 is fields
|
||||
assert seen_by_v2 == "from legacy"
|
||||
assert fields.value == "from v2"
|
||||
assert fields[Field.deprecated_kwargs] is request
|
||||
assert fields[Field.model] == "m"
|
||||
assert set(fields) == {
|
||||
Field.deprecated_kwargs,
|
||||
Field.model,
|
||||
Field.messages,
|
||||
Field.response,
|
||||
Field.deprecated_response_obj,
|
||||
}
|
||||
|
||||
|
||||
def test_result_fields_refuse_a_patch_outside_the_slot_and_leave_it_unchanged() -> None:
|
||||
fields: Final = ResultFields({"model": "m"}, PROXY_REQUEST_KEYS, Field.error, "original")
|
||||
|
||||
with pytest.raises(InvalidPatchError, match="only error"):
|
||||
apply_patch(fields, Patch().set(Field.error, "changed").set(Field.model, "other"))
|
||||
|
||||
assert fields.value == "original"
|
||||
129
tests/unit/integrations/custom_logger_v2/test_hooks.py
Normal file
129
tests/unit/integrations/custom_logger_v2/test_hooks.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
from collections import Counter
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.integrations.custom_logger_v2 import (
|
||||
HOOK_SPECS,
|
||||
Abort,
|
||||
Field,
|
||||
Hook,
|
||||
InvalidDeclarationError,
|
||||
Reroute,
|
||||
Respond,
|
||||
Subscription,
|
||||
)
|
||||
|
||||
F = Field
|
||||
|
||||
_LIFECYCLE_HOOKS = [hook for hook in Hook if not HOOK_SPECS[hook].request_path]
|
||||
|
||||
|
||||
def test_every_hook_has_exactly_one_table_row() -> None:
|
||||
assert set(HOOK_SPECS) == set(Hook)
|
||||
|
||||
|
||||
def test_each_legacy_method_maps_onto_one_hook_point() -> None:
|
||||
counts = Counter(method for spec in HOOK_SPECS.values() for method in spec.legacy_methods)
|
||||
assert [method for method, count in counts.items() if count > 1] == []
|
||||
|
||||
|
||||
def test_legacy_methods_are_real_custom_logger_methods() -> None:
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
|
||||
missing = sorted(
|
||||
method
|
||||
for spec in HOOK_SPECS.values()
|
||||
for method in spec.legacy_methods
|
||||
if not callable(getattr(CustomLogger, method, None))
|
||||
)
|
||||
assert missing == []
|
||||
|
||||
|
||||
def test_legacy_guardrail_events_are_real_guardrail_event_hooks() -> None:
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
|
||||
events = {event for spec in HOOK_SPECS.values() for event in spec.legacy_guardrail_events}
|
||||
assert events <= {member.value for member in GuardrailEventHooks}
|
||||
assert events == {"pre_call", "during_call", "post_call", "post_mcp_call"}
|
||||
|
||||
|
||||
def test_outcomes_are_allowed_only_on_request_path_hooks() -> None:
|
||||
assert {hook for hook in Hook if HOOK_SPECS[hook].outcomes} <= {
|
||||
hook for hook in Hook if HOOK_SPECS[hook].request_path
|
||||
}
|
||||
|
||||
|
||||
def test_reroute_is_allowed_only_before_the_provider_call_on_pre_call() -> None:
|
||||
assert {hook for hook in Hook if Reroute in HOOK_SPECS[hook].outcomes} == {Hook.pre_call}
|
||||
|
||||
|
||||
def test_reroute_is_the_only_non_terminal_outcome() -> None:
|
||||
assert (Abort.terminal, Respond.terminal, Reroute.terminal) == (True, True, False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hook", _LIFECYCLE_HOOKS)
|
||||
def test_abort_on_a_lifecycle_hook_is_rejected(hook: Hook) -> None:
|
||||
with pytest.raises(InvalidDeclarationError, match="allows no outcome"):
|
||||
Subscription(hook=hook, handler="h", may_abort=True)
|
||||
|
||||
|
||||
def test_abort_on_response_headers_is_rejected_because_its_errors_are_swallowed() -> None:
|
||||
with pytest.raises(InvalidDeclarationError, match="allows no outcome"):
|
||||
Subscription(hook=Hook.response_headers, handler="h", writes=frozenset({F.response_headers}), may_abort=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("hook", "field"),
|
||||
[
|
||||
(Hook.during_call, F.messages),
|
||||
(Hook.post_call_success, F.messages),
|
||||
(Hook.pre_call, F.response),
|
||||
(Hook.pre_call, F.deprecated_kwargs),
|
||||
(Hook.attempt_ended, F.metadata),
|
||||
(Hook.provider_request_sent, F.provider_request_body),
|
||||
(Hook.log_prepare, F.messages),
|
||||
],
|
||||
)
|
||||
def test_write_the_hook_cannot_apply_is_rejected(hook: Hook, field: Field) -> None:
|
||||
with pytest.raises(InvalidDeclarationError, match=f"writes {field.value}, which this hook point cannot apply"):
|
||||
Subscription(hook=hook, handler="h", writes=frozenset({field}))
|
||||
|
||||
|
||||
def test_pre_call_may_write_tools_model_and_input() -> None:
|
||||
writes = frozenset({F.tools, F.model, F.input, F.messages, F.metadata, F.guardrail_info})
|
||||
assert Subscription(hook=Hook.pre_call, handler="h", writes=writes, may_abort=True).writes == writes
|
||||
|
||||
|
||||
def test_log_prepare_may_rewrite_the_standard_logging_payload() -> None:
|
||||
writes = frozenset({F.standard_logging_payload})
|
||||
assert Subscription(hook=Hook.log_prepare, handler="h", writes=writes).writes == writes
|
||||
|
||||
|
||||
def test_stream_chunk_cannot_be_subscribed_while_streams_are_out_of_scope() -> None:
|
||||
with pytest.raises(InvalidDeclarationError, match="no V2 subscription support"):
|
||||
Subscription(hook=Hook.stream_chunk, handler="h", reads=frozenset({F.stream_chunk}))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("writes", "may_abort"),
|
||||
[(frozenset({F.metadata}), False), (frozenset[Field](), True)],
|
||||
)
|
||||
def test_awaited_subscription_that_writes_or_aborts_is_rejected(writes: frozenset[Field], may_abort: bool) -> None:
|
||||
with pytest.raises(InvalidDeclarationError, match="awaited subscription must neither write nor abort"):
|
||||
Subscription(hook=Hook.pre_call, handler="h", writes=writes, may_abort=may_abort, awaited=True)
|
||||
|
||||
|
||||
def test_awaited_read_only_subscription_is_accepted_on_a_lifecycle_hook() -> None:
|
||||
subscription = Subscription(
|
||||
hook=Hook.call_ended, handler="release", reads=frozenset({F.key_identity}), awaited=True
|
||||
)
|
||||
assert (subscription.awaited, subscription.writes, subscription.may_abort) == (True, frozenset(), False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("writes", "may_abort"),
|
||||
[(frozenset[Field](), False), (frozenset({F.messages}), True)],
|
||||
)
|
||||
def test_parallel_is_only_for_abort_only_subscriptions(writes: frozenset[Field], may_abort: bool) -> None:
|
||||
with pytest.raises(InvalidDeclarationError, match="parallel is only for"):
|
||||
Subscription(hook=Hook.pre_call, handler="h", writes=writes, may_abort=may_abort, parallel=True)
|
||||
145
tests/unit/integrations/custom_logger_v2/test_host.py
Normal file
145
tests/unit/integrations/custom_logger_v2/test_host.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import asyncio
|
||||
import threading
|
||||
from collections.abc import Awaitable, Callable, Generator
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.integrations.custom_logger_v2 import (
|
||||
Abort,
|
||||
CallContext,
|
||||
CustomLoggerV2,
|
||||
Field,
|
||||
Hook,
|
||||
Patch,
|
||||
on,
|
||||
)
|
||||
from litellm.integrations.custom_logger_v2.dispatcher import RequestScope
|
||||
from litellm.integrations.custom_logger_v2.host import (
|
||||
V2Outcome,
|
||||
bind_scope,
|
||||
current_scope,
|
||||
request_scope,
|
||||
reset_host_for_tests,
|
||||
)
|
||||
from litellm.integrations.custom_logger_v2.legacy import (
|
||||
PROXY_REQUEST_KEYS,
|
||||
KwargsFields,
|
||||
clear_subscribers_cache_for_tests,
|
||||
hook_subscribers,
|
||||
kwargs_after,
|
||||
run_subscribers,
|
||||
)
|
||||
|
||||
F = Field
|
||||
ABORT = Abort(status=403, message="blocked")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_host() -> Generator[None]:
|
||||
reset_host_for_tests()
|
||||
clear_subscribers_cache_for_tests()
|
||||
yield
|
||||
reset_host_for_tests()
|
||||
clear_subscribers_cache_for_tests()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Seen:
|
||||
states: list[object] = field(default_factory=list)
|
||||
|
||||
|
||||
class Masker(CustomLoggerV2):
|
||||
@dataclass
|
||||
class request_state:
|
||||
hits: int = 0
|
||||
|
||||
def __init__(self, seen: Seen, outcome: Abort | None = None) -> None:
|
||||
self.seen = seen
|
||||
self.outcome = outcome
|
||||
|
||||
@on(Hook.pre_call, reads={F.messages}, writes={F.messages}, may_abort=True)
|
||||
async def mask(self, view: object, ctx: CallContext[object]) -> Patch | Abort:
|
||||
self.seen.states.append(ctx.state)
|
||||
return self.outcome or Patch().set(F.messages, ("masked",))
|
||||
|
||||
|
||||
class PreCallOnly(CustomLogger):
|
||||
async def async_pre_call_hook(self, *args: object, **kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _recording(ran: list[object]) -> Callable[[object, object], Awaitable[None]]:
|
||||
def start(logger: object, data: object) -> Awaitable[None]:
|
||||
async def run() -> None:
|
||||
ran.append(logger)
|
||||
|
||||
return run()
|
||||
|
||||
return start
|
||||
|
||||
|
||||
async def _run_pre_call(callbacks: list[object], ran: list[object], scope: RequestScope | None = None) -> object:
|
||||
subscribers: Final = hook_subscribers(Hook.pre_call, callbacks)
|
||||
fields: Final = KwargsFields({"messages": ("raw",)}, PROXY_REQUEST_KEYS)
|
||||
return kwargs_after(await run_subscribers(subscribers, fields, _recording(ran), scope=scope))
|
||||
|
||||
|
||||
async def test_a_v2_callback_abort_raises_v2_callback_outcome_and_skips_the_legacy_entry_after_it() -> None:
|
||||
ran: Final[list[object]] = []
|
||||
callbacks: Final[list[object]] = [Masker(Seen(), ABORT), PreCallOnly()]
|
||||
|
||||
with pytest.raises(V2Outcome) as raised:
|
||||
await _run_pre_call(callbacks, ran)
|
||||
|
||||
assert raised.value.outcome is ABORT
|
||||
assert ran == []
|
||||
|
||||
|
||||
async def test_request_scope_is_current_inside_and_shares_state_across_hook_runs() -> None:
|
||||
seen: Final = Seen()
|
||||
ran: Final[list[object]] = []
|
||||
callbacks: Final[list[object]] = [Masker(seen), PreCallOnly()]
|
||||
|
||||
with request_scope() as scope:
|
||||
assert current_scope() is scope
|
||||
await _run_pre_call(callbacks, ran)
|
||||
await _run_pre_call(callbacks, ran)
|
||||
await _run_pre_call(callbacks, ran)
|
||||
|
||||
assert current_scope() is None
|
||||
assert len(ran) == 3
|
||||
assert seen.states[0] is seen.states[1]
|
||||
assert seen.states[2] is not seen.states[0]
|
||||
|
||||
|
||||
async def test_an_explicit_scope_is_used_where_no_scope_is_current() -> None:
|
||||
seen: Final = Seen()
|
||||
callbacks: Final[list[object]] = [Masker(seen)]
|
||||
|
||||
with request_scope() as scope:
|
||||
pass
|
||||
await _run_pre_call(callbacks, [], scope=scope)
|
||||
await _run_pre_call(callbacks, [], scope=scope)
|
||||
|
||||
assert seen.states[0] is seen.states[1]
|
||||
|
||||
|
||||
async def test_bind_scope_carries_a_scope_into_a_thread_that_did_not_inherit_it() -> None:
|
||||
found: list[object] = []
|
||||
|
||||
def worker(scope: object) -> None:
|
||||
found.append(current_scope())
|
||||
with request_scope() as other, bind_scope(other):
|
||||
found.append(current_scope() is other)
|
||||
|
||||
with request_scope() as scope:
|
||||
thread = threading.Thread(target=worker, args=(scope,))
|
||||
thread.start()
|
||||
await asyncio.to_thread(thread.join)
|
||||
with bind_scope(scope):
|
||||
assert current_scope() is scope
|
||||
|
||||
assert found == [None, True]
|
||||
332
tests/unit/integrations/custom_logger_v2/test_legacy.py
Normal file
332
tests/unit/integrations/custom_logger_v2/test_legacy.py
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
import asyncio
|
||||
import gc
|
||||
from collections.abc import Awaitable, Generator
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.integrations.custom_logger_v2 import (
|
||||
CallContext,
|
||||
ContractViolation,
|
||||
CustomLoggerV2,
|
||||
Field,
|
||||
Hook,
|
||||
InvalidPatchError,
|
||||
Patch,
|
||||
on,
|
||||
)
|
||||
from litellm.integrations.custom_logger_v2.host import reset_host_for_tests
|
||||
from litellm.integrations.custom_logger_v2.legacy import (
|
||||
PROXY_REQUEST_KEYS,
|
||||
Admit,
|
||||
KwargsFields,
|
||||
adapter_for,
|
||||
clear_subscribers_cache_for_tests,
|
||||
hook_subscribers,
|
||||
kwargs_after,
|
||||
kwargs_patch,
|
||||
run_subscribers,
|
||||
run_subscribers_sync,
|
||||
)
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
|
||||
F = Field
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh() -> Generator[None]:
|
||||
reset_host_for_tests()
|
||||
clear_subscribers_cache_for_tests()
|
||||
yield
|
||||
reset_host_for_tests()
|
||||
clear_subscribers_cache_for_tests()
|
||||
|
||||
|
||||
class PreCallOnly(CustomLogger):
|
||||
async def async_pre_call_hook(self, *args: object, **kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class InheritsPreCall(PreCallOnly):
|
||||
pass
|
||||
|
||||
|
||||
class Moderator(CustomLogger):
|
||||
async def async_moderation_hook(self, *args: object, **kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class Exporter(CustomLogger):
|
||||
def log_success_event(self, *args: object, **kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class Masking(CustomLogger):
|
||||
def logging_hook(
|
||||
self, kwargs: dict[str, object], result: object, call_type: str
|
||||
) -> tuple[dict[str, object], object]:
|
||||
return kwargs, result
|
||||
|
||||
|
||||
class Writer(CustomLoggerV2):
|
||||
def __init__(self, journal: list[object], replacement: object) -> None:
|
||||
self.journal = journal
|
||||
self.replacement = replacement
|
||||
|
||||
@on(Hook.pre_call, reads={F.messages}, writes={F.messages}, may_abort=True)
|
||||
async def pre_call(self, view: object, ctx: CallContext[object]) -> Patch:
|
||||
self.journal.append(("v2", getattr(view, "messages")))
|
||||
return Patch().set(F.messages, self.replacement)
|
||||
|
||||
|
||||
class Tagger(CustomLoggerV2):
|
||||
def __init__(self, journal: list[object]) -> None:
|
||||
self.journal = journal
|
||||
|
||||
@on(Hook.pre_call, writes={F.model})
|
||||
async def pre_call(self, view: object, ctx: CallContext[object]) -> Patch:
|
||||
self.journal.append("tagger")
|
||||
return Patch().set(F.model, "tagged")
|
||||
|
||||
|
||||
class Observer(CustomLoggerV2):
|
||||
def __init__(self, journal: list[object]) -> None:
|
||||
self.journal = journal
|
||||
|
||||
@on(Hook.attempt_ended, reads={F.deprecated_kwargs})
|
||||
async def ended(self, view: object, ctx: CallContext[object]) -> None:
|
||||
self.journal.append("observer")
|
||||
|
||||
|
||||
class BlockingAttemptEnded(CustomLoggerV2):
|
||||
@on(Hook.log_prepare, writes={F.standard_logging_payload})
|
||||
async def prepare(self, view: object, ctx: CallContext[object]) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def test_manifest_subscribes_only_the_hooks_the_class_overrides_through_its_mro() -> None:
|
||||
direct: Final = adapter_for(PreCallOnly()).manifest.subscriptions
|
||||
inherited: Final = adapter_for(InheritsPreCall()).manifest.subscriptions
|
||||
|
||||
assert set(direct) == {Hook.pre_call}
|
||||
assert set(inherited) == {Hook.pre_call}
|
||||
assert direct[Hook.pre_call].reads == {F.deprecated_kwargs, F.deprecated_response_obj}
|
||||
assert direct[Hook.pre_call].writes == {F.deprecated_kwargs}
|
||||
assert (direct[Hook.pre_call].may_abort, direct[Hook.pre_call].parallel) == (True, False)
|
||||
|
||||
|
||||
def test_during_call_and_parallel_guardrails_gather_and_write_nothing() -> None:
|
||||
moderation: Final = adapter_for(Moderator()).manifest.subscriptions[Hook.during_call]
|
||||
guardrail: Final = adapter_for(
|
||||
CustomGuardrail(guardrail_name="g", event_hook=GuardrailEventHooks.pre_call, run_in_parallel=True)
|
||||
).manifest.subscriptions
|
||||
|
||||
assert (moderation.writes, moderation.may_abort, moderation.parallel) == (frozenset(), True, True)
|
||||
assert (guardrail[Hook.pre_call].writes, guardrail[Hook.pre_call].parallel) == (frozenset(), True)
|
||||
assert guardrail[Hook.during_call].parallel
|
||||
assert guardrail[Hook.mcp_tool_result].writes == {F.deprecated_kwargs}
|
||||
|
||||
|
||||
def test_log_hooks_neither_write_nor_abort_but_log_prepare_keeps_its_kwargs_rewrite() -> None:
|
||||
attempt: Final = adapter_for(Exporter()).manifest.subscriptions[Hook.attempt_ended]
|
||||
prepare: Final = adapter_for(Masking()).manifest.subscriptions[Hook.log_prepare]
|
||||
|
||||
assert (attempt.writes, attempt.may_abort) == (frozenset(), False)
|
||||
assert (prepare.writes, prepare.may_abort) == ({F.deprecated_kwargs}, False)
|
||||
|
||||
|
||||
def test_every_logger_admits_loggers_that_do_not_override_the_hook() -> None:
|
||||
exporter: Final = Exporter()
|
||||
|
||||
assert hook_subscribers(Hook.pre_call, [exporter]).entries == ()
|
||||
assert len(hook_subscribers(Hook.pre_call, [exporter], admit=Admit.every_logger).entries) == 1
|
||||
|
||||
|
||||
def test_adapters_are_cached_per_instance_and_dropped_with_it() -> None:
|
||||
logger = PreCallOnly()
|
||||
adapter = adapter_for(logger)
|
||||
|
||||
assert adapter_for(logger) is adapter
|
||||
assert adapter.inner is logger
|
||||
del logger
|
||||
gc.collect()
|
||||
assert adapter.inner is None
|
||||
|
||||
|
||||
def test_subscribers_are_cached_per_list_content_and_rebuilt_when_it_changes() -> None:
|
||||
first: Final = PreCallOnly()
|
||||
callbacks: Final[list[object]] = ["a-string-callback", first]
|
||||
subscribers: Final = hook_subscribers(Hook.pre_call, callbacks)
|
||||
|
||||
assert hook_subscribers(Hook.pre_call, callbacks) is subscribers
|
||||
callbacks.append(PreCallOnly())
|
||||
grown: Final = hook_subscribers(Hook.pre_call, callbacks)
|
||||
assert grown is not subscribers
|
||||
assert len(grown.entries) == 2
|
||||
|
||||
|
||||
def test_a_cached_subscribers_is_dropped_when_a_legacy_logger_in_it_dies() -> None:
|
||||
for _ in range(5):
|
||||
dying = PreCallOnly()
|
||||
_ = hook_subscribers(Hook.pre_call, [dying])
|
||||
del dying
|
||||
gc.collect()
|
||||
replacement = PreCallOnly()
|
||||
|
||||
assert hook_subscribers(Hook.pre_call, [replacement]).legacy == (replacement,)
|
||||
|
||||
|
||||
def test_legacy_kwargs_conflict_with_every_field_so_list_order_is_kept() -> None:
|
||||
journal: Final[list[object]] = []
|
||||
subscribers: Final = hook_subscribers(Hook.pre_call, [Tagger(journal), PreCallOnly(), Tagger(journal)])
|
||||
|
||||
assert [len(stage) for stage in subscribers.plan.stages] == [1, 1, 1]
|
||||
assert [entry.position for stage in subscribers.plan.stages for entry in stage] == [0, 1, 2]
|
||||
|
||||
|
||||
def _recording(journal: list[object], name: str, replacement: object = None):
|
||||
def start(logger: object, data: object) -> Awaitable[Patch | None]:
|
||||
async def run() -> Patch | None:
|
||||
journal.append((name, KwargsFields(data, PROXY_REQUEST_KEYS)[F.messages]))
|
||||
return None if replacement is None else kwargs_patch(data, replacement)
|
||||
|
||||
return run()
|
||||
|
||||
return start
|
||||
|
||||
|
||||
async def test_a_v2_callback_between_two_legacy_entries_sees_the_first_edit_and_patches_what_the_second_sees() -> None:
|
||||
journal: Final[list[object]] = []
|
||||
first: Final = PreCallOnly()
|
||||
second: Final = PreCallOnly()
|
||||
subscribers: Final = hook_subscribers(Hook.pre_call, [first, Writer(journal, ["v2"]), second])
|
||||
replaced: Final[dict[str, object]] = {"messages": ["first"]}
|
||||
|
||||
def start(logger: object, data: object) -> Awaitable[Patch | None]:
|
||||
async def run() -> Patch | None:
|
||||
journal.append(
|
||||
("first" if logger is first else "second", KwargsFields(data, PROXY_REQUEST_KEYS)[F.messages])
|
||||
)
|
||||
return kwargs_patch(data, replaced) if logger is first else None
|
||||
|
||||
return run()
|
||||
|
||||
result: Final = await run_subscribers(
|
||||
subscribers, KwargsFields({"messages": ["original"]}, PROXY_REQUEST_KEYS), start
|
||||
)
|
||||
|
||||
assert journal == [("first", ["original"]), ("v2", ["first"]), ("second", ["v2"])]
|
||||
assert kwargs_after(result) is replaced
|
||||
assert replaced["messages"] == ["v2"]
|
||||
|
||||
|
||||
async def test_a_v2_callback_writer_without_abort_still_waits_for_the_legacy_entry_before_it() -> None:
|
||||
journal: Final[list[object]] = []
|
||||
callbacks: Final[list[object]] = [PreCallOnly(), Tagger(journal)]
|
||||
subscribers: Final = hook_subscribers(Hook.pre_call, callbacks)
|
||||
data: Final[dict[str, object]] = {"model": "m", "messages": []}
|
||||
|
||||
def start(logger: object, kwargs: object) -> Awaitable[None]:
|
||||
async def run() -> None:
|
||||
journal.append("legacy")
|
||||
await asyncio.sleep(0)
|
||||
|
||||
return run()
|
||||
|
||||
await run_subscribers(subscribers, KwargsFields(data, PROXY_REQUEST_KEYS), start)
|
||||
|
||||
assert journal == ["legacy", "tagger"]
|
||||
assert data["model"] == "tagged"
|
||||
|
||||
|
||||
async def test_legacy_only_and_dispatched_subscribers_give_the_same_result() -> None:
|
||||
first: Final = PreCallOnly()
|
||||
second: Final = PreCallOnly()
|
||||
replacement: Final[dict[str, object]] = {"messages": ["replaced"]}
|
||||
|
||||
async def run(callbacks: list[object]) -> tuple[list[object], object]:
|
||||
journal: list[object] = []
|
||||
subscribers = hook_subscribers(Hook.pre_call, callbacks)
|
||||
result = await run_subscribers(
|
||||
subscribers, KwargsFields({"messages": ["original"]}, PROXY_REQUEST_KEYS), _recording(journal, "x", replacement)
|
||||
)
|
||||
return journal, kwargs_after(result)
|
||||
|
||||
plain: Final = await run([first, second])
|
||||
dispatched: Final = await run([first, second, Observer([])])
|
||||
|
||||
assert plain == dispatched
|
||||
assert plain[1] is replacement
|
||||
|
||||
|
||||
async def test_a_legacy_exception_propagates_unchanged_through_both_paths() -> None:
|
||||
error: Final = RuntimeError("legacy said no")
|
||||
|
||||
def start(logger: object, data: object) -> Awaitable[None]:
|
||||
async def run() -> None:
|
||||
raise error
|
||||
|
||||
return run()
|
||||
|
||||
for callbacks in ([PreCallOnly()], [PreCallOnly(), Writer([], [])]):
|
||||
with pytest.raises(RuntimeError) as raised:
|
||||
await run_subscribers(
|
||||
hook_subscribers(Hook.pre_call, callbacks), KwargsFields({"messages": []}, PROXY_REQUEST_KEYS), start
|
||||
)
|
||||
assert raised.value is error
|
||||
|
||||
|
||||
def test_kwargs_fields_write_through_to_the_live_request() -> None:
|
||||
metadata: Final[dict[str, object]] = {"kept": 1}
|
||||
data: Final[dict[str, object]] = {"model": "m", "metadata": metadata}
|
||||
fields: Final = KwargsFields(data, {**PROXY_REQUEST_KEYS, F.metadata: "metadata"})
|
||||
|
||||
patched: Final = fields.apply(Patch().set(F.model, "n").merge(F.metadata, {"added": 2}))
|
||||
|
||||
assert patched[F.deprecated_kwargs] is data
|
||||
assert data == {"model": "n", "metadata": {"kept": 1, "added": 2}}
|
||||
assert data["metadata"] is metadata
|
||||
assert F.messages not in fields
|
||||
with pytest.raises(InvalidPatchError, match="no place"):
|
||||
fields.apply(Patch().set(F.request_headers, {}))
|
||||
|
||||
|
||||
def test_sync_subscribers_run_legacy_inline_in_order_and_hand_v2_callbacks_to_the_off_path() -> None:
|
||||
journal: Final[list[object]] = []
|
||||
first: Final = Exporter()
|
||||
second: Final = Exporter()
|
||||
subscribers: Final = hook_subscribers(Hook.attempt_ended, [first, Observer(journal), second])
|
||||
|
||||
def start(logger: object, data: object) -> None:
|
||||
journal.append("first" if logger is first else "second")
|
||||
|
||||
run_subscribers_sync(subscribers, KwargsFields({}, {}), start)
|
||||
|
||||
assert journal[:2] == ["first", "second"]
|
||||
|
||||
|
||||
def test_sync_subscribers_reject_a_blocking_v2_callback() -> None:
|
||||
subscribers: Final = hook_subscribers(Hook.log_prepare, [Masking(), BlockingAttemptEnded()])
|
||||
|
||||
with pytest.raises(ContractViolation, match="synchronously"):
|
||||
run_subscribers_sync(subscribers, KwargsFields({}, {}), lambda logger, data: None)
|
||||
|
||||
|
||||
def test_every_entry_keeps_string_and_callable_callbacks_in_list_order() -> None:
|
||||
journal: Final[list[object]] = []
|
||||
|
||||
def plain_callback() -> None:
|
||||
return None
|
||||
|
||||
exporter: Final = Exporter()
|
||||
callbacks: Final[list[object]] = ["langfuse", plain_callback, exporter]
|
||||
|
||||
def start(logger: object, data: object) -> None:
|
||||
journal.append(logger)
|
||||
|
||||
run_subscribers_sync(hook_subscribers(Hook.attempt_ended, callbacks, admit=Admit.every_entry), KwargsFields({}, {}), start)
|
||||
|
||||
assert journal == ["langfuse", plain_callback, exporter]
|
||||
assert hook_subscribers(Hook.attempt_ended, callbacks).legacy == (exporter,)
|
||||
242
tests/unit/integrations/custom_logger_v2/test_logger.py
Normal file
242
tests/unit/integrations/custom_logger_v2/test_logger.py
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.integrations.custom_logger_v2 import (
|
||||
Abort,
|
||||
Activation,
|
||||
CallContext,
|
||||
CustomLoggerV2,
|
||||
Field,
|
||||
Hook,
|
||||
InvalidDeclarationError,
|
||||
Patch,
|
||||
Selection,
|
||||
Subscription,
|
||||
build_manifest,
|
||||
on,
|
||||
)
|
||||
|
||||
F = Field
|
||||
|
||||
|
||||
class Prometheus(CustomLoggerV2):
|
||||
@on(Hook.attempt_ended, reads={F.outcome, F.deployment, F.key_identity, F.usage, F.cost, F.request_tags})
|
||||
async def attempt_ended(self, view: object, ctx: CallContext[None]) -> None: ...
|
||||
|
||||
@on(Hook.call_ended, reads={F.outcome, F.deployment, F.key_identity, F.error})
|
||||
async def call_ended(self, view: object, ctx: CallContext[None]) -> None: ...
|
||||
|
||||
|
||||
class PiiMask(CustomLoggerV2):
|
||||
@dataclass
|
||||
class request_state:
|
||||
tokens: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
@on(Hook.pre_call, reads={F.messages}, writes={F.messages}, may_abort=True)
|
||||
async def mask(self, view: object, ctx: CallContext[object]) -> Patch | None: ...
|
||||
|
||||
@on(Hook.post_call_success, reads={F.response}, writes={F.response})
|
||||
async def unmask(self, view: object, ctx: CallContext[object]) -> Patch | None: ...
|
||||
|
||||
|
||||
class ParallelRequestLimiter(CustomLoggerV2):
|
||||
selection = Selection("parallel_request_limiter", activation=Activation.required)
|
||||
|
||||
@on(Hook.pre_call, reads={F.key_identity}, may_abort=True)
|
||||
async def acquire(self, view: object, ctx: CallContext[None]) -> Abort | None: ...
|
||||
|
||||
@on(Hook.response_headers, reads={F.key_identity}, writes={F.response_headers})
|
||||
async def headers(self, view: object, ctx: CallContext[None]) -> Patch: ...
|
||||
|
||||
@on(Hook.call_ended, reads={F.key_identity, F.usage, F.outcome})
|
||||
async def release(self, view: object, ctx: CallContext[None]) -> None: ...
|
||||
|
||||
|
||||
def test_prometheus_example_declares_two_read_only_lifecycle_subscriptions() -> None:
|
||||
assert dict(Prometheus.subscriptions) == {
|
||||
Hook.attempt_ended: Subscription(
|
||||
hook=Hook.attempt_ended,
|
||||
handler="attempt_ended",
|
||||
reads=frozenset({F.outcome, F.deployment, F.key_identity, F.usage, F.cost, F.request_tags}),
|
||||
),
|
||||
Hook.call_ended: Subscription(
|
||||
hook=Hook.call_ended,
|
||||
handler="call_ended",
|
||||
reads=frozenset({F.outcome, F.deployment, F.key_identity, F.error}),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def test_pii_mask_example_declares_its_writes_and_request_state() -> None:
|
||||
assert dict(PiiMask.subscriptions) == {
|
||||
Hook.pre_call: Subscription(
|
||||
hook=Hook.pre_call,
|
||||
handler="mask",
|
||||
reads=frozenset({F.messages}),
|
||||
writes=frozenset({F.messages}),
|
||||
may_abort=True,
|
||||
),
|
||||
Hook.post_call_success: Subscription(
|
||||
hook=Hook.post_call_success,
|
||||
handler="unmask",
|
||||
reads=frozenset({F.response}),
|
||||
writes=frozenset({F.response}),
|
||||
),
|
||||
}
|
||||
assert build_manifest(PiiMask()).request_state is PiiMask.request_state
|
||||
|
||||
|
||||
def test_parallel_request_limiter_example_is_required_and_aborts_without_writing() -> None:
|
||||
manifest = build_manifest(ParallelRequestLimiter())
|
||||
assert manifest.selection == Selection("parallel_request_limiter", activation=Activation.required)
|
||||
assert dict(manifest.subscriptions) == {
|
||||
Hook.pre_call: Subscription(
|
||||
hook=Hook.pre_call, handler="acquire", reads=frozenset({F.key_identity}), may_abort=True
|
||||
),
|
||||
Hook.response_headers: Subscription(
|
||||
hook=Hook.response_headers,
|
||||
handler="headers",
|
||||
reads=frozenset({F.key_identity}),
|
||||
writes=frozenset({F.response_headers}),
|
||||
),
|
||||
Hook.call_ended: Subscription(
|
||||
hook=Hook.call_ended, handler="release", reads=frozenset({F.key_identity, F.usage, F.outcome})
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def test_a_declaration_the_hook_rejects_fails_when_the_class_is_defined() -> None:
|
||||
with pytest.raises(InvalidDeclarationError, match="writes response, which this hook point cannot apply"):
|
||||
|
||||
class Broken(CustomLoggerV2):
|
||||
@on(Hook.pre_call, writes={F.response})
|
||||
async def handle(self, view: object, ctx: CallContext[None]) -> None: ...
|
||||
|
||||
|
||||
def test_during_call_abort_subscriptions_are_parallel_unless_they_opt_out() -> None:
|
||||
class Moderation(CustomLoggerV2):
|
||||
@on(Hook.during_call, reads={F.messages}, may_abort=True)
|
||||
async def moderate(self, view: object, ctx: CallContext[None]) -> Abort | None: ...
|
||||
|
||||
class SerialModeration(CustomLoggerV2):
|
||||
@on(Hook.during_call, reads={F.messages}, may_abort=True, parallel=False)
|
||||
async def moderate(self, view: object, ctx: CallContext[None]) -> Abort | None: ...
|
||||
|
||||
class Watcher(CustomLoggerV2):
|
||||
@on(Hook.during_call, reads={F.messages})
|
||||
async def watch(self, view: object, ctx: CallContext[None]) -> None: ...
|
||||
|
||||
assert Moderation.subscriptions[Hook.during_call].parallel is True
|
||||
assert SerialModeration.subscriptions[Hook.during_call].parallel is False
|
||||
assert Watcher.subscriptions[Hook.during_call].parallel is False
|
||||
|
||||
|
||||
def test_pre_call_abort_subscriptions_stay_serial_by_default() -> None:
|
||||
assert ParallelRequestLimiter.subscriptions[Hook.pre_call].parallel is False
|
||||
|
||||
|
||||
def test_two_handlers_for_one_hook_in_one_class_are_rejected() -> None:
|
||||
with pytest.raises(InvalidDeclarationError, match="more than one handler for pre_call"):
|
||||
|
||||
class Twice(CustomLoggerV2):
|
||||
@on(Hook.pre_call, reads={F.messages})
|
||||
async def first(self, view: object, ctx: CallContext[None]) -> None: ...
|
||||
|
||||
@on(Hook.pre_call, reads={F.metadata})
|
||||
async def second(self, view: object, ctx: CallContext[None]) -> None: ...
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("hook", "writes", "may_abort", "awaited"),
|
||||
[
|
||||
(Hook.pre_call, frozenset({F.metadata}), False, False),
|
||||
(Hook.pre_call, frozenset[Field](), True, False),
|
||||
(Hook.call_ended, frozenset[Field](), False, True),
|
||||
],
|
||||
)
|
||||
def test_sync_handler_that_blocks_the_request_is_rejected(
|
||||
hook: Hook, writes: frozenset[Field], may_abort: bool, awaited: bool
|
||||
) -> None:
|
||||
with pytest.raises(InvalidDeclarationError, match="handle must be async"):
|
||||
|
||||
class Sync(CustomLoggerV2):
|
||||
@on(hook, writes=writes, may_abort=may_abort, awaited=awaited)
|
||||
def handle(self, view: object, ctx: CallContext[None]) -> None: ...
|
||||
|
||||
|
||||
def test_sync_handler_is_allowed_for_off_path_reads() -> None:
|
||||
class SyncLogger(CustomLoggerV2):
|
||||
@on(Hook.attempt_ended, reads={F.usage})
|
||||
def log(self, view: object, ctx: CallContext[None]) -> None: ...
|
||||
|
||||
assert SyncLogger.subscriptions[Hook.attempt_ended].handler == "log"
|
||||
|
||||
|
||||
async def test_undecorated_override_keeps_the_parent_subscription_and_runs_the_override() -> None:
|
||||
override_patch = Patch().set(F.messages, ["masked"])
|
||||
|
||||
class Masked(PiiMask):
|
||||
async def mask(self, view: object, ctx: CallContext[object]) -> Patch | None:
|
||||
return override_patch
|
||||
|
||||
subscription = Masked.subscriptions[Hook.pre_call]
|
||||
handler: Callable[[object, CallContext[object]], Awaitable[Patch | None]] = getattr(Masked(), subscription.handler)
|
||||
assert subscription is PiiMask.subscriptions[Hook.pre_call]
|
||||
assert await handler(None, CallContext(hook=Hook.pre_call, call=None, state=None)) is override_patch
|
||||
|
||||
|
||||
def test_redecorated_override_replaces_the_parent_subscription() -> None:
|
||||
class WiderMask(PiiMask):
|
||||
@on(Hook.pre_call, reads={F.messages, F.tools}, writes={F.messages, F.tools}, may_abort=True)
|
||||
async def mask(self, view: object, ctx: CallContext[object]) -> Patch | None: ...
|
||||
|
||||
assert WiderMask.subscriptions[Hook.pre_call].writes == frozenset({F.messages, F.tools})
|
||||
assert WiderMask.subscriptions[Hook.post_call_success] is PiiMask.subscriptions[Hook.post_call_success]
|
||||
|
||||
|
||||
def test_new_handler_for_an_inherited_hook_replaces_the_parent_handler() -> None:
|
||||
class Renamed(PiiMask):
|
||||
@on(Hook.pre_call, reads={F.messages})
|
||||
async def inspect_only(self, view: object, ctx: CallContext[object]) -> None: ...
|
||||
|
||||
assert Renamed.subscriptions[Hook.pre_call] == Subscription(
|
||||
hook=Hook.pre_call, handler="inspect_only", reads=frozenset({F.messages})
|
||||
)
|
||||
|
||||
|
||||
def test_redecorating_an_inherited_handler_for_another_hook_is_rejected() -> None:
|
||||
with pytest.raises(InvalidDeclarationError, match="mask is decorated for pre_route but also inherits"):
|
||||
|
||||
class Moved(PiiMask):
|
||||
@on(Hook.pre_route, reads={F.messages})
|
||||
async def mask(self, view: object, ctx: CallContext[object]) -> Patch | None: ...
|
||||
|
||||
|
||||
def test_replacing_an_inherited_handler_with_a_non_callable_is_rejected() -> None:
|
||||
with pytest.raises(InvalidDeclarationError, match="unmask handles post_call_success but is not callable"):
|
||||
|
||||
class Removed(PiiMask):
|
||||
unmask = None # pyright: ignore[reportAssignmentType] # the test removes the handler on purpose
|
||||
|
||||
|
||||
def test_multiple_inheritance_merges_hooks_and_the_nearest_class_wins() -> None:
|
||||
class Masking(CustomLoggerV2):
|
||||
@on(Hook.pre_call, reads={F.messages}, writes={F.messages})
|
||||
async def mask(self, view: object, ctx: CallContext[None]) -> Patch | None: ...
|
||||
|
||||
class Auditing(CustomLoggerV2):
|
||||
@on(Hook.call_ended, reads={F.outcome})
|
||||
async def audit(self, view: object, ctx: CallContext[None]) -> None: ...
|
||||
|
||||
@on(Hook.pre_call, reads={F.metadata})
|
||||
async def peek(self, view: object, ctx: CallContext[None]) -> None: ...
|
||||
|
||||
class MaskingAudit(Masking, Auditing):
|
||||
pass
|
||||
|
||||
assert MaskingAudit.subscriptions == {
|
||||
Hook.pre_call: Masking.subscriptions[Hook.pre_call],
|
||||
Hook.call_ended: Auditing.subscriptions[Hook.call_ended],
|
||||
}
|
||||
133
tests/unit/integrations/custom_logger_v2/test_manifest.py
Normal file
133
tests/unit/integrations/custom_logger_v2/test_manifest.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import pytest
|
||||
|
||||
from litellm.integrations.custom_logger_v2 import (
|
||||
Abort,
|
||||
Activation,
|
||||
CallContext,
|
||||
CustomLoggerV2,
|
||||
Field,
|
||||
Hook,
|
||||
InvalidDeclarationError,
|
||||
OptOutBy,
|
||||
Patch,
|
||||
Selection,
|
||||
build_manifest,
|
||||
on,
|
||||
)
|
||||
|
||||
F = Field
|
||||
|
||||
|
||||
class ContentGuardrail(CustomLoggerV2):
|
||||
def __init__(
|
||||
self,
|
||||
selection: Selection,
|
||||
enabled_hooks: frozenset[Hook] | None = None,
|
||||
parallel: bool = False,
|
||||
) -> None:
|
||||
self.selection = selection
|
||||
self.enabled_hooks = enabled_hooks
|
||||
self.parallel = parallel
|
||||
|
||||
@on(Hook.pre_call, reads={F.messages}, may_abort=True)
|
||||
async def check_input(self, view: object, ctx: CallContext[None]) -> Abort | None: ...
|
||||
|
||||
@on(Hook.post_call_success, reads={F.response}, writes={F.response, F.guardrail_info}, may_abort=True)
|
||||
async def check_output(self, view: object, ctx: CallContext[None]) -> Patch | Abort | None: ...
|
||||
|
||||
@on(Hook.log_prepare, reads={F.standard_logging_payload}, writes={F.standard_logging_payload})
|
||||
async def mask_logs(self, view: object, ctx: CallContext[None]) -> Patch | None: ...
|
||||
|
||||
|
||||
class PureLogger(CustomLoggerV2):
|
||||
@on(Hook.attempt_ended, reads={F.standard_logging_payload})
|
||||
async def export(self, view: object, ctx: CallContext[None]) -> None: ...
|
||||
|
||||
@on(Hook.call_ended, reads={F.key_identity}, awaited=True)
|
||||
async def settle(self, view: object, ctx: CallContext[None]) -> None: ...
|
||||
|
||||
|
||||
def test_manifest_keeps_only_the_hooks_the_instance_enables() -> None:
|
||||
manifest = build_manifest(ContentGuardrail(Selection("input-only"), enabled_hooks=frozenset({Hook.pre_call})))
|
||||
assert dict(manifest.subscriptions) == {Hook.pre_call: ContentGuardrail.subscriptions[Hook.pre_call]}
|
||||
|
||||
|
||||
def test_manifest_defaults_to_every_declared_hook_and_the_class_name() -> None:
|
||||
manifest = build_manifest(PureLogger())
|
||||
assert set(manifest.subscriptions) == {Hook.attempt_ended, Hook.call_ended}
|
||||
assert manifest.selection == Selection("PureLogger")
|
||||
|
||||
|
||||
def test_two_instances_of_one_class_get_independent_manifests() -> None:
|
||||
first = build_manifest(ContentGuardrail(Selection("a", activation=Activation.opt_in)))
|
||||
second = build_manifest(ContentGuardrail(Selection("b"), enabled_hooks=frozenset({Hook.log_prepare})))
|
||||
assert (first.selection.name, first.selection.activation) == ("a", Activation.opt_in)
|
||||
assert (second.selection.name, set(second.subscriptions)) == ("b", {Hook.log_prepare})
|
||||
|
||||
|
||||
def test_manifest_is_frozen_against_later_instance_changes() -> None:
|
||||
guardrail = ContentGuardrail(Selection("before"))
|
||||
manifest = build_manifest(guardrail)
|
||||
guardrail.selection = Selection("after")
|
||||
guardrail.enabled_hooks = frozenset()
|
||||
assert manifest.selection.name == "before"
|
||||
assert set(manifest.subscriptions) == {Hook.pre_call, Hook.post_call_success, Hook.log_prepare}
|
||||
|
||||
|
||||
def test_enabling_a_hook_the_class_does_not_implement_is_rejected() -> None:
|
||||
guardrail = ContentGuardrail(Selection("g"), enabled_hooks=frozenset({Hook.pre_call, Hook.during_call}))
|
||||
with pytest.raises(InvalidDeclarationError, match="enables during_call, which it does not implement"):
|
||||
build_manifest(guardrail)
|
||||
|
||||
|
||||
def test_instance_parallel_flag_applies_to_its_abort_only_subscriptions() -> None:
|
||||
guardrail = ContentGuardrail(
|
||||
Selection("g"), enabled_hooks=frozenset({Hook.pre_call, Hook.log_prepare}), parallel=True
|
||||
)
|
||||
manifest = build_manifest(guardrail)
|
||||
assert manifest.subscriptions[Hook.pre_call].parallel is True
|
||||
assert manifest.subscriptions[Hook.log_prepare].parallel is False
|
||||
assert ContentGuardrail.subscriptions[Hook.pre_call].parallel is False
|
||||
|
||||
|
||||
def test_instance_parallel_flag_on_an_abortable_writer_is_rejected() -> None:
|
||||
guardrail = ContentGuardrail(Selection("g"), enabled_hooks=frozenset({Hook.post_call_success}), parallel=True)
|
||||
with pytest.raises(InvalidDeclarationError, match="parallel is only for"):
|
||||
build_manifest(guardrail)
|
||||
|
||||
|
||||
def test_logging_only_step_can_be_required_while_the_instance_is_opt_in() -> None:
|
||||
guardrail = ContentGuardrail(Selection("g", activation=Activation.opt_in))
|
||||
guardrail.activation_overrides = {Hook.log_prepare: Activation.required}
|
||||
manifest = build_manifest(guardrail)
|
||||
assert manifest.subscriptions[Hook.log_prepare].activation is Activation.required
|
||||
assert manifest.subscriptions[Hook.pre_call].activation is None
|
||||
assert manifest.selection.activation is Activation.opt_in
|
||||
|
||||
|
||||
def test_activation_override_for_a_hook_the_instance_does_not_enable_is_rejected() -> None:
|
||||
guardrail = ContentGuardrail(Selection("g"), enabled_hooks=frozenset({Hook.pre_call}))
|
||||
guardrail.activation_overrides = {Hook.log_prepare: Activation.required}
|
||||
with pytest.raises(InvalidDeclarationError, match="overrides activation for log_prepare"):
|
||||
build_manifest(guardrail)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("enabled_hooks", "selection"),
|
||||
[
|
||||
(frozenset({Hook.pre_call}), Selection("g", honors_no_log=True)),
|
||||
(frozenset({Hook.pre_call}), Selection("g", opt_out_by=frozenset({OptOutBy.admin, OptOutBy.caller}))),
|
||||
(frozenset({Hook.log_prepare}), Selection("g", honors_no_log=True)),
|
||||
],
|
||||
)
|
||||
def test_callers_cannot_switch_off_a_callback_that_writes_or_aborts(
|
||||
enabled_hooks: frozenset[Hook], selection: Selection
|
||||
) -> None:
|
||||
with pytest.raises(InvalidDeclarationError, match="neither no-log nor a caller opt-out may remove it"):
|
||||
build_manifest(ContentGuardrail(selection, enabled_hooks=enabled_hooks))
|
||||
|
||||
|
||||
def test_read_only_logger_may_honor_no_log_and_caller_opt_out() -> None:
|
||||
logger = PureLogger()
|
||||
logger.selection = Selection("exporter", opt_out_by=frozenset(OptOutBy), honors_no_log=True)
|
||||
assert build_manifest(logger).selection.honors_no_log is True
|
||||
52
tests/unit/integrations/custom_logger_v2/test_outcomes.py
Normal file
52
tests/unit/integrations/custom_logger_v2/test_outcomes.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import pytest
|
||||
|
||||
from litellm.integrations.custom_logger_v2 import (
|
||||
Abort,
|
||||
Field,
|
||||
InvalidPatchError,
|
||||
MergeField,
|
||||
Patch,
|
||||
Respond,
|
||||
SetField,
|
||||
)
|
||||
|
||||
F = Field
|
||||
|
||||
|
||||
def test_patch_builders_return_new_patches_in_call_order() -> None:
|
||||
empty = Patch()
|
||||
patch = empty.set(F.messages, ["masked"]).merge(F.metadata, {"pii": True})
|
||||
assert empty.ops == ()
|
||||
assert patch.ops == (SetField(F.messages, ["masked"]), MergeField(F.metadata, {"pii": True}))
|
||||
assert patch.fields == frozenset({F.messages, F.metadata})
|
||||
|
||||
|
||||
def test_merge_keeps_its_own_copy_of_the_keys() -> None:
|
||||
keys = {"tag": "a"}
|
||||
patch = Patch().merge(F.metadata, keys)
|
||||
keys["tag"] = "b"
|
||||
assert patch.ops == (MergeField(F.metadata, {"tag": "a"}),)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", [F.messages, F.response, F.model])
|
||||
def test_merge_on_a_non_mapping_field_is_rejected(field: Field) -> None:
|
||||
with pytest.raises(InvalidPatchError, match=f"{field.value} is not a mapping field"):
|
||||
Patch().merge(field, {"k": "v"})
|
||||
|
||||
|
||||
def test_abort_carries_a_json_body_and_a_guardrail_info_patch() -> None:
|
||||
body = {"error": {"message": "blocked", "violations": ["pii"]}}
|
||||
patch = Patch().merge(F.guardrail_info, {"pii_guard": "intervened"}).merge(F.metadata, {"blocked": True})
|
||||
abort = Abort(status=400, message="blocked", body=body, patch=patch)
|
||||
assert (abort.body, abort.patch) == (body, patch)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"patch",
|
||||
[Patch().set(F.messages, []), Patch().merge(F.guardrail_info, {"g": 1}).set(F.response, None)],
|
||||
)
|
||||
def test_outcome_patch_outside_guardrail_info_and_metadata_is_rejected(patch: Patch) -> None:
|
||||
with pytest.raises(InvalidPatchError, match="may only patch guardrail_info and metadata"):
|
||||
Abort(status=400, message="blocked", patch=patch)
|
||||
with pytest.raises(InvalidPatchError, match="may only patch guardrail_info and metadata"):
|
||||
Respond(response={"choices": []}, patch=patch)
|
||||
39
tests/unit/integrations/custom_logger_v2/test_patches.py
Normal file
39
tests/unit/integrations/custom_logger_v2/test_patches.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from types import MappingProxyType
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.integrations.custom_logger_v2 import Field, InvalidPatchError, Patch, apply_patch, apply_patches
|
||||
|
||||
F = Field
|
||||
BASE = MappingProxyType({F.model: "gpt", F.metadata: MappingProxyType({"keep": 1, "replace": 1})})
|
||||
|
||||
|
||||
def test_set_replaces_one_field_and_leaves_the_base_untouched() -> None:
|
||||
patched = apply_patch(BASE, Patch().set(F.model, "claude"))
|
||||
|
||||
assert patched == {F.model: "claude", F.metadata: {"keep": 1, "replace": 1}}
|
||||
assert BASE[F.model] == "gpt"
|
||||
|
||||
|
||||
def test_merge_keeps_keys_it_does_not_name() -> None:
|
||||
patched = apply_patch(BASE, Patch().merge(F.metadata, {"replace": 2, "new": 3}))
|
||||
|
||||
assert patched[F.metadata] == {"keep": 1, "replace": 2, "new": 3}
|
||||
|
||||
|
||||
def test_merge_into_an_absent_field_starts_from_its_keys() -> None:
|
||||
patched = apply_patch(BASE, Patch().merge(F.guardrail_info, {"blocked": True}))
|
||||
|
||||
assert patched[F.guardrail_info] == {"blocked": True}
|
||||
assert len(patched) == 3
|
||||
|
||||
|
||||
def test_patches_apply_in_order_so_the_later_set_wins() -> None:
|
||||
patched = apply_patches(BASE, (Patch().set(F.model, "first"), Patch().set(F.model, "second")))
|
||||
|
||||
assert patched[F.model] == "second"
|
||||
|
||||
|
||||
def test_merge_into_a_non_mapping_value_is_rejected() -> None:
|
||||
with pytest.raises(InvalidPatchError):
|
||||
apply_patch({F.metadata: "text"}, Patch().merge(F.metadata, {"a": 1}))
|
||||
142
tests/unit/integrations/custom_logger_v2/test_planner.py
Normal file
142
tests/unit/integrations/custom_logger_v2/test_planner.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
from collections.abc import Set
|
||||
|
||||
from litellm.integrations.custom_logger_v2 import (
|
||||
CallbackManifest,
|
||||
CustomLoggerV2,
|
||||
Field,
|
||||
Hook,
|
||||
HookPlan,
|
||||
Selection,
|
||||
Subscription,
|
||||
plan_hook,
|
||||
)
|
||||
from litellm.integrations.custom_logger_v2.planner import Entry
|
||||
|
||||
F = Field
|
||||
|
||||
|
||||
def _entry(
|
||||
position: int,
|
||||
name: str,
|
||||
*,
|
||||
reads: Set[Field] = frozenset(),
|
||||
writes: Set[Field] = frozenset(),
|
||||
may_abort: bool = False,
|
||||
parallel: bool = False,
|
||||
awaited: bool = False,
|
||||
hook: Hook = Hook.pre_call,
|
||||
) -> Entry:
|
||||
subscription = Subscription(
|
||||
hook=hook,
|
||||
handler=name,
|
||||
reads=frozenset(reads),
|
||||
writes=frozenset(writes),
|
||||
may_abort=may_abort,
|
||||
parallel=parallel,
|
||||
awaited=awaited,
|
||||
)
|
||||
manifest = CallbackManifest(
|
||||
logger=CustomLoggerV2(),
|
||||
selection=Selection(name),
|
||||
subscriptions={hook: subscription},
|
||||
request_state=None,
|
||||
)
|
||||
return Entry(position, manifest, subscription)
|
||||
|
||||
|
||||
def _names(plan: HookPlan) -> tuple[tuple[str, ...], ...]:
|
||||
return tuple(tuple(entry.subscription.handler for entry in stage) for stage in plan.stages)
|
||||
|
||||
|
||||
def test_design_doc_worked_example() -> None:
|
||||
entries = (
|
||||
_entry(0, "pii_mask", reads={F.messages}, writes={F.messages}, may_abort=True),
|
||||
_entry(1, "tagger", writes={F.metadata}),
|
||||
_entry(2, "limiter", reads={F.key_identity}, may_abort=True),
|
||||
_entry(3, "secret_scan", reads={F.messages}, writes={F.messages}, may_abort=True),
|
||||
_entry(4, "audit", reads={F.messages}),
|
||||
)
|
||||
|
||||
plan = plan_hook(entries)
|
||||
|
||||
assert _names(plan) == (("pii_mask", "tagger"), ("limiter",), ("secret_scan",))
|
||||
assert plan.off_path == (entries[4],)
|
||||
assert plan.awaited == ()
|
||||
assert plan.reads == {F.messages, F.key_identity}
|
||||
|
||||
|
||||
def test_rule_one_only_writers_and_abortables_block() -> None:
|
||||
reader = _entry(0, "reader", reads={F.messages})
|
||||
awaited = _entry(1, "awaited", reads={F.key_identity}, awaited=True)
|
||||
writer = _entry(2, "writer", writes={F.metadata})
|
||||
|
||||
plan = plan_hook((reader, awaited, writer))
|
||||
|
||||
assert _names(plan) == (("writer",),)
|
||||
assert plan.off_path == (reader,)
|
||||
assert plan.awaited == (awaited,)
|
||||
|
||||
|
||||
def test_rule_two_every_abortable_waits_for_all_earlier_blocking_entries() -> None:
|
||||
plan = plan_hook(
|
||||
(
|
||||
_entry(0, "tagger", writes={F.metadata}),
|
||||
_entry(1, "first", reads={F.key_identity}, may_abort=True),
|
||||
_entry(2, "second", reads={F.key_identity}, may_abort=True),
|
||||
)
|
||||
)
|
||||
|
||||
assert _names(plan) == (("tagger",), ("first",), ("second",))
|
||||
|
||||
|
||||
def test_rule_three_writers_wait_only_on_conflicting_earlier_writers() -> None:
|
||||
plan = plan_hook(
|
||||
(
|
||||
_entry(0, "slow_messages", writes={F.messages}),
|
||||
_entry(1, "metadata", writes={F.metadata}),
|
||||
_entry(2, "reads_metadata", reads={F.metadata}, writes={F.guardrail_info}),
|
||||
_entry(3, "rewrites_messages", reads={F.messages}, writes={F.messages}),
|
||||
)
|
||||
)
|
||||
|
||||
assert _names(plan) == (("slow_messages", "metadata"), ("reads_metadata", "rewrites_messages"))
|
||||
|
||||
|
||||
def test_rule_three_writer_never_lands_before_an_earlier_reader_of_its_field() -> None:
|
||||
plan = plan_hook(
|
||||
(
|
||||
_entry(0, "tagger", writes={F.metadata}),
|
||||
_entry(1, "scanner", reads={F.messages}, may_abort=True),
|
||||
_entry(2, "rewriter", writes={F.messages}),
|
||||
)
|
||||
)
|
||||
|
||||
assert _names(plan) == (("tagger",), ("scanner", "rewriter"))
|
||||
|
||||
|
||||
def test_rule_three_writer_without_conflicts_joins_the_first_stage_past_abortables() -> None:
|
||||
plan = plan_hook(
|
||||
(
|
||||
_entry(0, "limiter", reads={F.key_identity}, may_abort=True),
|
||||
_entry(1, "scanner", reads={F.key_identity}, may_abort=True),
|
||||
_entry(2, "tagger", writes={F.metadata}),
|
||||
)
|
||||
)
|
||||
|
||||
assert _names(plan) == (("limiter", "tagger"), ("scanner",))
|
||||
|
||||
|
||||
def test_parallel_abort_only_entries_run_together_after_the_serial_chain() -> None:
|
||||
plan = plan_hook(
|
||||
(
|
||||
_entry(0, "fast_check", reads={F.messages}, may_abort=True, parallel=True),
|
||||
_entry(1, "masker", reads={F.messages}, writes={F.messages}, may_abort=True),
|
||||
_entry(2, "slow_check", reads={F.messages}, may_abort=True, parallel=True),
|
||||
)
|
||||
)
|
||||
|
||||
assert _names(plan) == (("masker",), ("fast_check", "slow_check"))
|
||||
|
||||
|
||||
def test_no_entries_plan_nothing() -> None:
|
||||
assert plan_hook(()) == HookPlan()
|
||||
55
tests/unit/integrations/custom_logger_v2/test_selection.py
Normal file
55
tests/unit/integrations/custom_logger_v2/test_selection.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import pytest
|
||||
|
||||
from litellm.integrations.custom_logger_v2 import Activation, OptOutBy, RequestSelection, Selection, is_selected
|
||||
|
||||
ADMIN = frozenset({OptOutBy.admin})
|
||||
BOTH = frozenset({OptOutBy.admin, OptOutBy.caller})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("selection", "activation", "request_selection", "expected"),
|
||||
[
|
||||
(Selection("limiter"), Activation.required, RequestSelection(admin_opt_outs=frozenset({"limiter"})), True),
|
||||
(
|
||||
Selection("limiter", honors_no_log=True, opt_out_by=BOTH),
|
||||
Activation.required,
|
||||
RequestSelection(caller_opt_outs=frozenset({"limiter"}), no_log=True),
|
||||
True,
|
||||
),
|
||||
(Selection("langfuse"), Activation.default_on, RequestSelection(), True),
|
||||
(Selection("langfuse"), Activation.default_on, RequestSelection(admin_opt_outs=frozenset({"langfuse"})), False),
|
||||
(Selection("langfuse"), Activation.default_on, RequestSelection(caller_opt_outs=frozenset({"langfuse"})), True),
|
||||
(
|
||||
Selection("langfuse", opt_out_by=BOTH),
|
||||
Activation.default_on,
|
||||
RequestSelection(caller_opt_outs=frozenset({"langfuse"})),
|
||||
False,
|
||||
),
|
||||
(
|
||||
Selection("v2_langfuse", aliases=frozenset({"langfuse"})),
|
||||
Activation.default_on,
|
||||
RequestSelection(admin_opt_outs=frozenset({"langfuse"})),
|
||||
False,
|
||||
),
|
||||
(Selection("guard"), Activation.opt_in, RequestSelection(), False),
|
||||
(Selection("guard"), Activation.opt_in, RequestSelection(requested=frozenset({"guard"})), True),
|
||||
(
|
||||
Selection("guard", opt_out_by=ADMIN),
|
||||
Activation.opt_in,
|
||||
RequestSelection(requested=frozenset({"guard"}), admin_opt_outs=frozenset({"guard"})),
|
||||
False,
|
||||
),
|
||||
(Selection("langfuse", honors_no_log=True), Activation.default_on, RequestSelection(no_log=True), False),
|
||||
(Selection("langfuse"), Activation.default_on, RequestSelection(no_log=True), True),
|
||||
(
|
||||
Selection("langfuse", honors_no_log=True, opt_out_by=frozenset()),
|
||||
Activation.default_on,
|
||||
RequestSelection(no_log=True),
|
||||
False,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_is_selected(
|
||||
selection: Selection, activation: Activation, request_selection: RequestSelection, expected: bool
|
||||
) -> None:
|
||||
assert is_selected(selection, activation, request_selection) is expected
|
||||
|
|
@ -310,7 +310,6 @@ PROXY_CASES: Final = (
|
|||
pytest.param(
|
||||
ProxyCase(primary=(), request=("G",), style=InheritedApplyGuardrailG),
|
||||
id="K-I-request-chat-inherited-apply-guardrail",
|
||||
marks=_known_gap("AI-2"),
|
||||
),
|
||||
pytest.param(ProxyCase(primary=("other",), default_on=True), id="L-default-on"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import pytest_asyncio
|
|||
|
||||
import litellm
|
||||
from litellm.constants import LOGGING_WORKER_MAX_TIME_PER_COROUTINE
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.integrations.custom_logger import CustomLogger, subclass_overrides_hook
|
||||
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
||||
from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome
|
||||
|
||||
|
|
@ -497,3 +497,48 @@ def setup_and_teardown():
|
|||
if hasattr(litellm, "in_memory_llm_clients_cache"):
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
yield
|
||||
|
||||
|
||||
class _DefinesPreCallHook(CustomLogger):
|
||||
async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type):
|
||||
return data
|
||||
|
||||
|
||||
class _InheritsPreCallHookFromBase(_DefinesPreCallHook):
|
||||
pass
|
||||
|
||||
|
||||
class _PreCallHookMixin:
|
||||
async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type):
|
||||
return data
|
||||
|
||||
|
||||
class _LoggerBeforeMixin(CustomLogger, _PreCallHookMixin):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("cls", "expected"),
|
||||
[
|
||||
(CustomLogger, False),
|
||||
(_DefinesPreCallHook, True),
|
||||
(_InheritsPreCallHookFromBase, True),
|
||||
(_LoggerBeforeMixin, False),
|
||||
],
|
||||
)
|
||||
def test_subclass_overrides_hook_walks_mro_up_to_custom_logger(cls, expected):
|
||||
assert subclass_overrides_hook(cls, "async_pre_call_hook") is expected
|
||||
|
||||
|
||||
def test_subclass_overrides_hook_stops_at_the_given_base():
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
|
||||
class _AppliesGuardrail(CustomGuardrail):
|
||||
async def apply_guardrail(self, inputs, request_data, input_type, logging_obj=None):
|
||||
return inputs
|
||||
|
||||
class _InheritsApplyGuardrail(_AppliesGuardrail):
|
||||
pass
|
||||
|
||||
assert subclass_overrides_hook(CustomGuardrail, "apply_guardrail", CustomGuardrail) is False
|
||||
assert subclass_overrides_hook(_InheritsApplyGuardrail, "apply_guardrail", CustomGuardrail) is True
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from collections.abc import AsyncIterator, Callable, Iterator, Mapping, Sequence
|
|||
from datetime import datetime as datetime_standard_logging, datetime as datetime_unit_test, datetime as dt_object
|
||||
from importlib.machinery import ModuleSpec
|
||||
from types import MappingProxyType, ModuleType
|
||||
from typing import Final, Literal
|
||||
from typing import Final, Literal, TypeAlias
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
|
|
@ -40,6 +40,8 @@ from litellm.constants import (
|
|||
)
|
||||
from litellm.cost_calculator import ocr_batch_cost
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.integrations.custom_logger_v2 import Abort, CallContext, CustomLoggerV2, Field, Hook, Patch, on
|
||||
from litellm.integrations.custom_logger_v2.host import V2Outcome
|
||||
from litellm.llms.custom_llm import CustomLLM, CustomLLMError
|
||||
from litellm.litellm_core_utils import litellm_logging
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LitellmLogging
|
||||
|
|
@ -61,7 +63,9 @@ from litellm.proxy.hooks.cache_control_check import PROXY_CacheControlCheck
|
|||
from litellm.proxy.hooks.max_iterations_limiter import PROXY_MaxIterationsHandler
|
||||
from litellm.proxy.hooks.parallel_request_limiter_v3 import PROXY_MaxParallelRequestsHandler_v3
|
||||
from litellm.proxy.utils import InternalUsageCache
|
||||
from litellm.types.llms.openai import ResponseAPIUsage, ResponseCompletedEvent, ResponsesAPIResponse
|
||||
from litellm.types.mcp import MCPPostCallResponseObject
|
||||
from litellm.types.llms.openai import AllMessageValues, ResponseAPIUsage, ResponseCompletedEvent, ResponsesAPIResponse
|
||||
from litellm.types.prompts.init_prompts import PromptSpec
|
||||
from litellm.types.rerank import RerankResponse
|
||||
from litellm.types.utils import (
|
||||
CallTypes,
|
||||
|
|
@ -69,6 +73,7 @@ from litellm.types.utils import (
|
|||
ImageResponse,
|
||||
LiteLLMRealtimeStreamLoggingObject,
|
||||
ModelResponse,
|
||||
StandardCallbackDynamicParams,
|
||||
StandardLoggingHiddenParams,
|
||||
StandardLoggingMetadata,
|
||||
StandardLoggingModelInformation,
|
||||
|
|
@ -15195,3 +15200,492 @@ async def test_every_callback_runs_under_the_span_an_sdk_caller_passed(
|
|||
await asyncio.wait_for(asyncio.to_thread(logger.logged.wait, 10), timeout=10)
|
||||
|
||||
assert (logger.seen.get(hook), current_request_parent_otel_span()) == (span, None)
|
||||
|
||||
|
||||
_PromptResult: TypeAlias = tuple[str, list[AllMessageValues], dict[str, object]]
|
||||
|
||||
|
||||
class _Inert(CustomLoggerV2):
|
||||
@on(Hook.provider_request_sent)
|
||||
def provider_request_sent(self, view: object, ctx: CallContext[object]) -> None:
|
||||
return None
|
||||
|
||||
@on(Hook.provider_response_received)
|
||||
def provider_response_received(self, view: object, ctx: CallContext[object]) -> None:
|
||||
return None
|
||||
|
||||
@on(Hook.log_prepare)
|
||||
def log_prepare(self, view: object, ctx: CallContext[object]) -> None:
|
||||
return None
|
||||
|
||||
@on(Hook.attempt_ended)
|
||||
def attempt_ended(self, view: object, ctx: CallContext[object]) -> None:
|
||||
return None
|
||||
|
||||
@on(Hook.mcp_tool_result)
|
||||
def mcp_tool_result(self, view: object, ctx: CallContext[object]) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _Observer(CustomLoggerV2):
|
||||
def __init__(self, journal: list[object]) -> None:
|
||||
self.journal = journal
|
||||
self.done = threading.Event()
|
||||
|
||||
def _note(self) -> None:
|
||||
self.journal.append("v2")
|
||||
self.done.set()
|
||||
|
||||
@on(Hook.provider_request_sent)
|
||||
def provider_request_sent(self, view: object, ctx: CallContext[object]) -> None:
|
||||
self._note()
|
||||
|
||||
@on(Hook.provider_response_received)
|
||||
def provider_response_received(self, view: object, ctx: CallContext[object]) -> None:
|
||||
self._note()
|
||||
|
||||
@on(Hook.attempt_ended, awaited=True)
|
||||
async def attempt_ended(self, view: object, ctx: CallContext[object]) -> None:
|
||||
self._note()
|
||||
|
||||
|
||||
class _PayloadStamp(CustomLoggerV2):
|
||||
def __init__(self) -> None:
|
||||
self.seen: list[object] = []
|
||||
|
||||
@on(Hook.log_prepare, reads={Field.standard_logging_payload}, writes={Field.standard_logging_payload})
|
||||
async def log_prepare(self, view: object, ctx: CallContext[object]) -> Patch | None:
|
||||
payload: Final[object] = getattr(view, "standard_logging_payload", None)
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
self.seen.append(payload.get("stamped_by"))
|
||||
return Patch().set(Field.standard_logging_payload, {**payload, "stamped_by": "v2"})
|
||||
|
||||
|
||||
class _ToolGate(CustomLoggerV2):
|
||||
def __init__(self, journal: list[object], outcome: Abort | None = None) -> None:
|
||||
self.journal = journal
|
||||
self.outcome = outcome
|
||||
|
||||
@on(Hook.mcp_tool_result, may_abort=True)
|
||||
async def mcp_tool_result(self, view: object, ctx: CallContext[object]) -> Abort | None:
|
||||
self.journal.append("v2")
|
||||
return self.outcome
|
||||
|
||||
|
||||
class _Entry(CustomLogger):
|
||||
def __init__(self, name: str, journal: list[object], error: Exception | None = None) -> None:
|
||||
super().__init__()
|
||||
self.name = name
|
||||
self.journal = journal
|
||||
self.error = error
|
||||
self.kwargs: list[object] = []
|
||||
|
||||
def _note(self, event: str, detail: object, kwargs: object) -> None:
|
||||
self.journal.append((self.name, event, detail))
|
||||
self.kwargs.append(kwargs)
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
|
||||
def log_pre_api_call(self, model: object, messages: object, kwargs: object) -> None:
|
||||
self._note("pre", None, kwargs)
|
||||
|
||||
def log_post_api_call(self, kwargs: object, response_obj: object, start_time: object, end_time: object) -> None:
|
||||
self._note("post", None, kwargs)
|
||||
|
||||
def logging_hook(
|
||||
self, kwargs: dict[str, object], result: object, call_type: str
|
||||
) -> tuple[dict[str, object], object]:
|
||||
self.journal.append((self.name, "hook", getattr(result, "id", None)))
|
||||
return {**kwargs, f"hooked_by_{self.name}": True}, ModelResponse(id=self.name)
|
||||
|
||||
async def async_logging_hook(
|
||||
self, kwargs: dict[str, object], result: object, call_type: str
|
||||
) -> tuple[dict[str, object], object]:
|
||||
payload: Final = kwargs.get("standard_logging_object")
|
||||
self.journal.append((self.name, "hook", payload.get("stamped_by") if isinstance(payload, dict) else None))
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
if isinstance(payload, dict):
|
||||
payload["stamped_by"] = self.name
|
||||
return {**kwargs, f"hooked_by_{self.name}": True}, ModelResponse(id=self.name)
|
||||
|
||||
def log_success_event(
|
||||
self, kwargs: dict[str, object], response_obj: object, start_time: object, end_time: object
|
||||
) -> None:
|
||||
self._note("success", getattr(response_obj, "id", None), kwargs)
|
||||
|
||||
async def async_log_success_event(
|
||||
self, kwargs: dict[str, object], response_obj: object, start_time: object, end_time: object
|
||||
) -> None:
|
||||
self._note("success", getattr(response_obj, "id", None), kwargs)
|
||||
|
||||
def log_failure_event(
|
||||
self, kwargs: dict[str, object], response_obj: object, start_time: object, end_time: object
|
||||
) -> None:
|
||||
self._note("failure", None, kwargs)
|
||||
|
||||
async def async_log_failure_event(
|
||||
self, kwargs: dict[str, object], response_obj: object, start_time: object, end_time: object
|
||||
) -> None:
|
||||
self._note("failure", None, kwargs)
|
||||
|
||||
async def async_post_mcp_tool_call_hook(
|
||||
self,
|
||||
kwargs: dict[str, object],
|
||||
response_obj: MCPPostCallResponseObject,
|
||||
start_time: datetime.datetime,
|
||||
end_time: datetime.datetime,
|
||||
) -> MCPPostCallResponseObject | None:
|
||||
content: Final = response_obj.mcp_tool_call_response
|
||||
self.journal.append((self.name, "mcp", [getattr(item, "text", None) for item in content]))
|
||||
response_obj.mcp_tool_call_response = [TextContent(type="text", text=f"{self.name} edit")]
|
||||
return response_obj
|
||||
|
||||
|
||||
def _plane_lists(monkeypatch: pytest.MonkeyPatch, **lists: Sequence[object]) -> None:
|
||||
monkeypatch.setattr(litellm, "callbacks", [])
|
||||
for name in ("input_callback", "success_callback", "failure_callback"):
|
||||
monkeypatch.setattr(litellm, name, list(lists.get(name, ())))
|
||||
for name in ("_async_success_callback", "_async_failure_callback"):
|
||||
monkeypatch.setattr(litellm, name, list(lists.get(name, ())))
|
||||
|
||||
|
||||
def _with_inert(callbacks: Sequence[object], inert: bool) -> list[object]:
|
||||
return [*callbacks, _Inert()] if inert else list(callbacks)
|
||||
|
||||
|
||||
def _ran(journal: Sequence[object], event: str) -> list[object]:
|
||||
return [entry for entry in journal if isinstance(entry, tuple) and entry[1] == event]
|
||||
|
||||
|
||||
def _fire_provider_event(logging_obj: LitellmLogging, hook: Hook) -> None:
|
||||
logging_obj.update_environment_variables(litellm_params={}, optional_params={})
|
||||
if hook is Hook.provider_request_sent:
|
||||
logging_obj.pre_call(input="", api_key="")
|
||||
else:
|
||||
logging_obj.post_call(original_response="ok", input="", api_key="")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("inert", [False, True])
|
||||
@pytest.mark.parametrize(
|
||||
("hook", "event"), [(Hook.provider_request_sent, "pre"), (Hook.provider_response_received, "post")]
|
||||
)
|
||||
def test_provider_events_reach_every_input_callback_in_order_past_a_failing_one(
|
||||
logging_obj: LitellmLogging, monkeypatch: pytest.MonkeyPatch, hook: Hook, event: str, inert: bool
|
||||
) -> None:
|
||||
journal: Final[list[object]] = []
|
||||
failing: Final = _Entry("failing", journal, error=RuntimeError("logger down"))
|
||||
second: Final = _Entry("second", journal)
|
||||
_plane_lists(monkeypatch, input_callback=_with_inert([failing, second], inert))
|
||||
|
||||
_fire_provider_event(logging_obj, hook)
|
||||
|
||||
assert journal == [("failing", event, None), ("second", event, None)]
|
||||
assert failing.kwargs[0] is logging_obj.model_call_details
|
||||
assert second.kwargs[0] is logging_obj.model_call_details
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hook", [Hook.provider_request_sent, Hook.provider_response_received])
|
||||
def test_a_v2_callback_input_callback_observes_provider_events_after_the_legacy_ones(
|
||||
logging_obj: LitellmLogging, monkeypatch: pytest.MonkeyPatch, hook: Hook
|
||||
) -> None:
|
||||
journal: Final[list[object]] = []
|
||||
observer: Final = _Observer(journal)
|
||||
_plane_lists(monkeypatch, input_callback=[_Entry("first", journal), observer, _Entry("second", journal)])
|
||||
|
||||
_fire_provider_event(logging_obj, hook)
|
||||
|
||||
assert observer.done.wait(timeout=30)
|
||||
assert [entry[0] if isinstance(entry, tuple) else entry for entry in journal] == ["first", "second", "v2"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("inert", [False, True])
|
||||
def test_sync_success_chains_logging_hooks_and_delivers_past_a_failing_logger(
|
||||
logging_obj: LitellmLogging, monkeypatch: pytest.MonkeyPatch, inert: bool
|
||||
) -> None:
|
||||
journal: Final[list[object]] = []
|
||||
function_calls: Final[list[object]] = []
|
||||
|
||||
def function_callback(kwargs: object, response: object, start_time: object, end_time: object) -> None:
|
||||
function_calls.append(getattr(response, "id", None))
|
||||
journal.append(("function", "success", getattr(response, "id", None)))
|
||||
|
||||
first: Final = _Entry("first", journal)
|
||||
failing: Final = _Entry("failing", journal, error=RuntimeError("logger down"))
|
||||
last: Final = _Entry("last", journal)
|
||||
_plane_lists(monkeypatch, success_callback=_with_inert([first, function_callback, failing, last], inert))
|
||||
logging_obj.stream = False
|
||||
logging_obj.update_environment_variables(litellm_params={}, optional_params={})
|
||||
now: Final = datetime.datetime.now()
|
||||
|
||||
logging_obj.success_handler(result=ModelResponse(id="served"), start_time=now, end_time=now)
|
||||
|
||||
assert _ran(journal, "hook") == [
|
||||
("first", "hook", "served"),
|
||||
("failing", "hook", "first"),
|
||||
("last", "hook", "failing"),
|
||||
]
|
||||
assert _ran(journal, "success") == [
|
||||
("first", "success", "last"),
|
||||
("function", "success", "last"),
|
||||
("failing", "success", "last"),
|
||||
("last", "success", "last"),
|
||||
]
|
||||
delivered: Final = last.kwargs[0]
|
||||
assert isinstance(delivered, dict)
|
||||
assert all(delivered.get(f"hooked_by_{name}") for name in ("first", "failing", "last"))
|
||||
assert logging_obj.model_call_details.get("hooked_by_last") is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("inert", [False, True])
|
||||
async def test_async_success_chains_logging_hooks_past_a_failing_hook_and_delivers_in_order(
|
||||
logging_obj: LitellmLogging, monkeypatch: pytest.MonkeyPatch, inert: bool
|
||||
) -> None:
|
||||
journal: Final[list[object]] = []
|
||||
|
||||
async def function_callback(kwargs: object, response: object, start_time: object, end_time: object) -> None:
|
||||
journal.append(("function", "success", getattr(response, "id", None)))
|
||||
|
||||
first: Final = _Entry("first", journal)
|
||||
failing: Final = _Entry("failing", journal, error=RuntimeError("hook down"))
|
||||
last: Final = _Entry("last", journal)
|
||||
_plane_lists(monkeypatch, _async_success_callback=_with_inert([first, failing, function_callback, last], inert))
|
||||
logging_obj.stream = False
|
||||
logging_obj.update_environment_variables(litellm_params={"acompletion": True}, optional_params={})
|
||||
now: Final = datetime.datetime.now()
|
||||
|
||||
await logging_obj.async_success_handler(result=ModelResponse(id="served"), start_time=now, end_time=now)
|
||||
|
||||
assert [entry[0] for entry in _ran(journal, "hook")] == ["first", "failing", "last"]
|
||||
assert _ran(journal, "success") == [
|
||||
("first", "success", "last"),
|
||||
("failing", "success", "last"),
|
||||
("function", "success", "last"),
|
||||
("last", "success", "last"),
|
||||
]
|
||||
assert logging_obj.model_call_details.get("hooked_by_first") is True
|
||||
assert "hooked_by_failing" not in logging_obj.model_call_details
|
||||
assert logging_obj.model_call_details.get("hooked_by_last") is True
|
||||
|
||||
|
||||
async def test_a_v2_callback_between_two_async_logging_hooks_sees_the_first_edit_and_the_second_sees_its_patch(
|
||||
logging_obj: LitellmLogging, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
journal: Final[list[object]] = []
|
||||
stamp: Final = _PayloadStamp()
|
||||
_plane_lists(monkeypatch, _async_success_callback=[_Entry("first", journal), stamp, _Entry("second", journal)])
|
||||
logging_obj.stream = False
|
||||
logging_obj.update_environment_variables(litellm_params={"acompletion": True}, optional_params={})
|
||||
now: Final = datetime.datetime.now()
|
||||
|
||||
await logging_obj.async_success_handler(result=ModelResponse(id="served"), start_time=now, end_time=now)
|
||||
|
||||
assert stamp.seen == ["first"]
|
||||
assert _ran(journal, "hook") == [("first", "hook", None), ("second", "hook", "v2")]
|
||||
payload: Final[object] = logging_obj.model_call_details["standard_logging_object"]
|
||||
assert isinstance(payload, dict)
|
||||
assert payload["stamped_by"] == "second"
|
||||
|
||||
|
||||
async def test_an_attempt_ended_v2_callback_between_two_async_loggers_runs_after_both_deliveries(
|
||||
logging_obj: LitellmLogging, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
journal: Final[list[object]] = []
|
||||
observer: Final = _Observer(journal)
|
||||
_plane_lists(monkeypatch, _async_success_callback=[_Entry("first", journal), observer, _Entry("second", journal)])
|
||||
logging_obj.stream = False
|
||||
logging_obj.update_environment_variables(litellm_params={"acompletion": True}, optional_params={})
|
||||
now: Final = datetime.datetime.now()
|
||||
|
||||
await logging_obj.async_success_handler(result=ModelResponse(id="served"), start_time=now, end_time=now)
|
||||
|
||||
assert [
|
||||
entry[0] if isinstance(entry, tuple) else entry for entry in journal if entry not in _ran(journal, "hook")
|
||||
] == [
|
||||
"first",
|
||||
"second",
|
||||
"v2",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_async", [False, True])
|
||||
@pytest.mark.parametrize("inert", [False, True])
|
||||
async def test_failure_handlers_deliver_to_every_callback_in_order_past_a_failing_one(
|
||||
logging_obj: LitellmLogging, monkeypatch: pytest.MonkeyPatch, use_async: bool, inert: bool
|
||||
) -> None:
|
||||
journal: Final[list[object]] = []
|
||||
failing: Final = _Entry("failing", journal, error=RuntimeError("logger down"))
|
||||
second: Final = _Entry("second", journal)
|
||||
callbacks: Final = _with_inert([failing, second], inert)
|
||||
if use_async:
|
||||
_plane_lists(monkeypatch, _async_failure_callback=callbacks)
|
||||
else:
|
||||
_plane_lists(monkeypatch, failure_callback=callbacks)
|
||||
logging_obj.stream = False
|
||||
logging_obj.update_environment_variables(litellm_params={}, optional_params={})
|
||||
error: Final = ValueError("provider failed")
|
||||
|
||||
if use_async:
|
||||
await logging_obj.async_failure_handler(error, "trace")
|
||||
else:
|
||||
await asyncio.to_thread(logging_obj.failure_handler, error, "trace")
|
||||
|
||||
assert journal == [("failing", "failure", None), ("second", "failure", None)]
|
||||
assert second.kwargs[0] is logging_obj.model_call_details
|
||||
assert logging_obj.model_call_details["exception"] is error
|
||||
|
||||
|
||||
async def test_an_attempt_ended_v2_callback_between_two_async_failure_loggers_runs_after_both(
|
||||
logging_obj: LitellmLogging, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
journal: Final[list[object]] = []
|
||||
observer: Final = _Observer(journal)
|
||||
_plane_lists(monkeypatch, _async_failure_callback=[_Entry("first", journal), observer, _Entry("second", journal)])
|
||||
logging_obj.update_environment_variables(litellm_params={}, optional_params={})
|
||||
|
||||
await logging_obj.async_failure_handler(ValueError("provider failed"), "trace")
|
||||
|
||||
assert journal == [("first", "failure", None), ("second", "failure", None), "v2"]
|
||||
|
||||
|
||||
def _tool_result() -> CallToolResult:
|
||||
return CallToolResult(content=[TextContent(type="text", text="original")])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("inert", [False, True])
|
||||
async def test_mcp_tool_result_callbacks_each_see_the_previous_edit(
|
||||
logging_obj: LitellmLogging, monkeypatch: pytest.MonkeyPatch, inert: bool
|
||||
) -> None:
|
||||
journal: Final[list[object]] = []
|
||||
_plane_lists(
|
||||
monkeypatch, success_callback=_with_inert([_Entry("first", journal), _Entry("second", journal)], inert)
|
||||
)
|
||||
result: Final = _tool_result()
|
||||
now: Final = datetime.datetime.now()
|
||||
|
||||
returned: Final = await logging_obj.async_post_mcp_tool_call_hook({}, result, now, now)
|
||||
|
||||
assert returned is result
|
||||
assert journal == [("first", "mcp", ["original"]), ("second", "mcp", ["first edit"])]
|
||||
assert [getattr(item, "text", None) for item in result.content] == ["second edit"]
|
||||
|
||||
|
||||
async def test_an_mcp_tool_result_v2_callback_between_two_loggers_runs_between_them_and_its_abort_stops_the_rest(
|
||||
logging_obj: LitellmLogging, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
journal: Final[list[object]] = []
|
||||
_plane_lists(
|
||||
monkeypatch, success_callback=[_Entry("first", journal), _ToolGate(journal), _Entry("second", journal)]
|
||||
)
|
||||
now: Final = datetime.datetime.now()
|
||||
|
||||
await logging_obj.async_post_mcp_tool_call_hook({}, _tool_result(), now, now)
|
||||
|
||||
assert journal == [("first", "mcp", ["original"]), "v2", ("second", "mcp", ["first edit"])]
|
||||
abort: Final = Abort(status=403, message="tool result blocked")
|
||||
journal.clear()
|
||||
_plane_lists(
|
||||
monkeypatch, success_callback=[_Entry("first", journal), _ToolGate(journal, abort), _Entry("second", journal)]
|
||||
)
|
||||
|
||||
with pytest.raises(V2Outcome) as raised:
|
||||
await logging_obj.async_post_mcp_tool_call_hook({}, _tool_result(), now, now)
|
||||
|
||||
assert raised.value.outcome is abort
|
||||
assert journal == [("first", "mcp", ["original"]), "v2"]
|
||||
|
||||
|
||||
class _PromptManager(CustomLogger):
|
||||
def __init__(self, resolved: _PromptResult, error: Exception | None = None) -> None:
|
||||
super().__init__()
|
||||
self.resolved = resolved
|
||||
self.error = error
|
||||
self.prompt_ids: list[str | None] = []
|
||||
|
||||
def get_chat_completion_prompt(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[AllMessageValues],
|
||||
non_default_params: dict[str, object],
|
||||
prompt_id: str | None,
|
||||
prompt_variables: dict[str, object] | None,
|
||||
dynamic_callback_params: StandardCallbackDynamicParams,
|
||||
prompt_spec: PromptSpec | None = None,
|
||||
prompt_label: str | None = None,
|
||||
prompt_version: int | None = None,
|
||||
ignore_prompt_manager_model: bool | None = False,
|
||||
ignore_prompt_manager_optional_params: bool | None = False,
|
||||
) -> _PromptResult:
|
||||
self.prompt_ids.append(prompt_id)
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return self.resolved
|
||||
|
||||
async def async_get_chat_completion_prompt(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[AllMessageValues],
|
||||
non_default_params: dict[str, object],
|
||||
prompt_id: str | None,
|
||||
prompt_variables: dict[str, object] | None,
|
||||
dynamic_callback_params: StandardCallbackDynamicParams,
|
||||
litellm_logging_obj: LitellmLogging,
|
||||
prompt_spec: PromptSpec | None = None,
|
||||
tools: list[dict[str, object]] | None = None,
|
||||
prompt_label: str | None = None,
|
||||
prompt_version: int | None = None,
|
||||
ignore_prompt_manager_model: bool | None = False,
|
||||
ignore_prompt_manager_optional_params: bool | None = False,
|
||||
) -> _PromptResult:
|
||||
return self.get_chat_completion_prompt(
|
||||
model, messages, non_default_params, prompt_id, prompt_variables, dynamic_callback_params
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_prompt(logging_obj: LitellmLogging, manager: CustomLogger, use_async: bool) -> _PromptResult:
|
||||
messages: Final[list[AllMessageValues]] = [{"role": "user", "content": "raw"}]
|
||||
if use_async:
|
||||
return await logging_obj.async_get_chat_completion_prompt(
|
||||
model="requested-model",
|
||||
messages=messages,
|
||||
non_default_params={},
|
||||
prompt_variables=None,
|
||||
prompt_id="greeting",
|
||||
prompt_management_logger=manager,
|
||||
)
|
||||
return await asyncio.to_thread(
|
||||
logging_obj.get_chat_completion_prompt,
|
||||
model="requested-model",
|
||||
messages=messages,
|
||||
non_default_params={},
|
||||
prompt_variables=None,
|
||||
prompt_id="greeting",
|
||||
prompt_management_logger=manager,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_async", [False, True])
|
||||
async def test_prompt_resolve_returns_the_managers_prompt_unchanged(
|
||||
logging_obj: LitellmLogging, use_async: bool
|
||||
) -> None:
|
||||
resolved: Final[_PromptResult] = ("rendered-model", [{"role": "system", "content": "rendered"}], {"seed": 1})
|
||||
manager: Final = _PromptManager(resolved)
|
||||
|
||||
model, messages, params = await _resolve_prompt(logging_obj, manager, use_async)
|
||||
|
||||
assert model == resolved[0]
|
||||
assert messages is resolved[1]
|
||||
assert params is resolved[2]
|
||||
assert logging_obj.messages is resolved[1]
|
||||
assert manager.prompt_ids == ["greeting"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_async", [False, True])
|
||||
async def test_prompt_resolve_propagates_the_managers_error(logging_obj: LitellmLogging, use_async: bool) -> None:
|
||||
error: Final = LookupError("unknown prompt")
|
||||
|
||||
with pytest.raises(LookupError) as raised:
|
||||
await _resolve_prompt(logging_obj, _PromptManager(("m", [], {}), error), use_async)
|
||||
|
||||
assert raised.value is error
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import copy
|
|||
import json
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Iterator, Mapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Final, List
|
||||
|
|
@ -23,6 +23,8 @@ import litellm
|
|||
from litellm.llms.anthropic.messages.native import handler as python_messages
|
||||
from litellm.anthropic_interface import messages
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.integrations.custom_logger_v2 import CallContext, CustomLoggerV2, Field, Hook, on
|
||||
from litellm.llms.anthropic.messages.native.handler import _execute_pre_request_hooks
|
||||
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
||||
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
||||
|
|
@ -2996,3 +2998,136 @@ async def test_azure_messages_replay_encrypted_reasoning_only_to_responses(
|
|||
else:
|
||||
assert route.calls.last.request.url.path.endswith("/chat/completions")
|
||||
assert "qa-opaque-reasoning" not in json.dumps(body)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("with_v2", [False, True])
|
||||
async def test_messages_send_the_request_the_pre_request_hook_returned(
|
||||
monkeypatch: pytest.MonkeyPatch, with_v2: bool
|
||||
) -> None:
|
||||
seen: Final[list[str]] = []
|
||||
|
||||
class MockRewriter(CustomLogger):
|
||||
async def async_pre_request_hook(
|
||||
self, model: str, messages: list[dict[str, object]], kwargs: dict[str, object]
|
||||
) -> dict[str, object] | None:
|
||||
seen.append("legacy")
|
||||
return {**kwargs, "mock_response": "rewritten by the pre-request hook"}
|
||||
|
||||
class Gate(CustomLoggerV2):
|
||||
@on(Hook.pre_deployment_call, may_abort=True)
|
||||
async def gate(self, view: object, ctx: CallContext[object]) -> None:
|
||||
seen.append("v2")
|
||||
|
||||
monkeypatch.setattr(litellm, "callbacks", [*([Gate()] if with_v2 else []), MockRewriter()])
|
||||
|
||||
response: Final = await messages.acreate(
|
||||
model="anthropic/claude-sonnet-4-5",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
max_tokens=10,
|
||||
mock_response="original",
|
||||
)
|
||||
|
||||
assert isinstance(response, dict)
|
||||
assert response["content"][0]["text"] == "rewritten by the pre-request hook"
|
||||
assert seen == (["v2", "v2", "legacy"] if with_v2 else ["legacy"])
|
||||
|
||||
|
||||
class _PreRequestHook(CustomLogger):
|
||||
def __init__(self, name: str, journal: list[object], reply: object = None, error: Exception | None = None) -> None:
|
||||
super().__init__()
|
||||
self.name = name
|
||||
self.journal = journal
|
||||
self.reply = reply
|
||||
self.error = error
|
||||
self.seen: list[object] = []
|
||||
|
||||
async def async_pre_request_hook(
|
||||
self, model: str, messages: list[dict[str, object]], kwargs: dict[str, object]
|
||||
) -> dict[str, object] | None:
|
||||
self.journal.append(self.name)
|
||||
self.seen.append(kwargs)
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return self.reply if not callable(self.reply) else self.reply(kwargs)
|
||||
|
||||
|
||||
class _ToolsWatcher(CustomLoggerV2):
|
||||
def __init__(self, journal: list[object]) -> None:
|
||||
self.journal = journal
|
||||
|
||||
@on(Hook.pre_deployment_call, reads={Field.tools}, may_abort=True)
|
||||
async def watch(self, view: object, ctx: CallContext[object]) -> None:
|
||||
self.journal.append(("v2", getattr(view, "tools"))) # noqa: B009 # the view's declared field
|
||||
|
||||
|
||||
async def _pre_request_hooks() -> dict[str, object]:
|
||||
return await _execute_pre_request_hooks(
|
||||
model="claude-sonnet-4-5",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
tools=[{"name": "original"}],
|
||||
stream=False,
|
||||
custom_llm_provider="anthropic",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("inert", [False, True])
|
||||
async def test_pre_request_hooks_run_in_order_and_a_returned_dict_replaces_the_request(
|
||||
monkeypatch: pytest.MonkeyPatch, inert: bool
|
||||
) -> None:
|
||||
journal: Final[list[object]] = []
|
||||
replaced: Final[dict[str, object]] = {"tools": [{"name": "replaced"}]}
|
||||
first: Final = _PreRequestHook("first", journal, reply=replaced)
|
||||
keeper: Final = _PreRequestHook("keeper", journal)
|
||||
last: Final = _PreRequestHook("last", journal)
|
||||
monkeypatch.setattr(litellm, "callbacks", [first, keeper, last, *([_ToolsWatcher([])] if inert else [])])
|
||||
|
||||
result: Final = await _pre_request_hooks()
|
||||
|
||||
assert journal == ["first", "keeper", "last"]
|
||||
assert first.seen[0] == {
|
||||
"tools": [{"name": "original"}],
|
||||
"stream": False,
|
||||
"litellm_params": {"custom_llm_provider": "anthropic"},
|
||||
}
|
||||
assert keeper.seen[0] is replaced
|
||||
assert result is replaced
|
||||
|
||||
|
||||
@pytest.mark.parametrize("inert", [False, True])
|
||||
async def test_a_pre_request_hook_error_propagates_unchanged_and_stops_the_hook(
|
||||
monkeypatch: pytest.MonkeyPatch, inert: bool
|
||||
) -> None:
|
||||
journal: Final[list[object]] = []
|
||||
error: Final = ValueError("request refused")
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"callbacks",
|
||||
[
|
||||
_PreRequestHook("first", journal, error=error),
|
||||
_PreRequestHook("next", journal),
|
||||
*([_ToolsWatcher([])] if inert else []),
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="request refused") as raised:
|
||||
await _pre_request_hooks()
|
||||
|
||||
assert raised.value is error
|
||||
assert journal == ["first"]
|
||||
|
||||
|
||||
async def test_a_v2_callback_between_pre_request_hooks_sees_the_first_rewrite(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
journal: Final[list[object]] = []
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"callbacks",
|
||||
[
|
||||
_PreRequestHook("first", journal, reply=lambda kwargs: {**kwargs, "tools": [{"name": "rewritten"}]}),
|
||||
_ToolsWatcher(journal),
|
||||
_PreRequestHook("second", journal),
|
||||
],
|
||||
)
|
||||
|
||||
await _pre_request_hooks()
|
||||
|
||||
assert journal == ["first", ("v2", [{"name": "rewritten"}]), "second"]
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import json
|
|||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
|
@ -26,6 +26,7 @@ from litellm.integrations.code_interpreter_interception.handler import (
|
|||
LITELLM_CODE_EXECUTION_TOOL_NAME,
|
||||
)
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.integrations.custom_logger_v2 import CallContext, CustomLoggerV2, Field, Hook, on
|
||||
from litellm.integrations.websearch_interception.handler import WebSearchInterceptionLogger
|
||||
from litellm.llms.azure.responses.transformation import AzureOpenAIResponsesAPIConfig
|
||||
from litellm.llms.azure.vector_stores.transformation import AzureOpenAIVectorStoreConfig
|
||||
|
|
@ -73,6 +74,7 @@ from litellm.types.integrations.custom_logger import AgenticLoopPlan, AgenticLoo
|
|||
from litellm.types.llms.openai import HttpxBinaryResponseContent, ResponsesAPIResponse
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import (
|
||||
CallTypes,
|
||||
ImageObject,
|
||||
ImageResponse,
|
||||
ModelResponse,
|
||||
|
|
@ -6902,3 +6904,67 @@ async def test_a_stream_answered_with_one_json_body_is_not_replayed_for_a_provid
|
|||
)
|
||||
with pytest.raises(litellm.exceptions.MidStreamFallbackError, match="before any terminal event"):
|
||||
_ = tuple([event async for event in stream])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("with_v2", [False, True])
|
||||
def test_sync_responses_send_the_params_the_pre_deployment_hook_returned(
|
||||
monkeypatch: pytest.MonkeyPatch, with_v2: bool
|
||||
) -> None:
|
||||
seen: Final[list[str]] = []
|
||||
|
||||
class TemperatureRewriter(CustomLogger):
|
||||
async def async_pre_call_deployment_hook(
|
||||
self, kwargs: dict[str, object], call_type: CallTypes | None
|
||||
) -> dict[str, object] | None:
|
||||
seen.append("legacy")
|
||||
return {**kwargs, "temperature": 0.25}
|
||||
|
||||
class Gate(CustomLoggerV2):
|
||||
@on(Hook.pre_deployment_call, may_abort=True)
|
||||
async def gate(self, view: object, ctx: CallContext[object]) -> None:
|
||||
seen.append("v2")
|
||||
|
||||
monkeypatch.setattr(litellm, "callbacks", [*([Gate()] if with_v2 else []), TemperatureRewriter()])
|
||||
logging_obj: Final = Mock()
|
||||
logging_obj.dynamic_success_callbacks = []
|
||||
|
||||
_, _, _, response_params, _ = BaseLLMHTTPHandler()._run_sync_responses_pre_call_deployment_hook(
|
||||
model="gpt-5.5",
|
||||
input="hi",
|
||||
custom_llm_provider="openai",
|
||||
response_api_optional_request_params={"temperature": 1.0},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
logging_obj=logging_obj,
|
||||
)
|
||||
|
||||
assert response_params["temperature"] == 0.25
|
||||
assert seen == (["v2", "legacy"] if with_v2 else ["legacy"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("with_v2", [False, True])
|
||||
def test_sync_responses_run_a_v2_callback_pre_deployment_hook_without_any_legacy_override(
|
||||
monkeypatch: pytest.MonkeyPatch, with_v2: bool
|
||||
) -> None:
|
||||
seen: Final[list[object]] = []
|
||||
|
||||
class Gate(CustomLoggerV2):
|
||||
@on(Hook.pre_deployment_call, reads={Field.model}, may_abort=True)
|
||||
async def gate(self, view: object, ctx: CallContext[object]) -> None:
|
||||
seen.append(getattr(view, "model")) # noqa: B009 # the view's declared field
|
||||
|
||||
monkeypatch.setattr(litellm, "callbacks", [CustomLogger(), *([Gate()] if with_v2 else [])])
|
||||
logging_obj: Final = Mock()
|
||||
logging_obj.dynamic_success_callbacks = []
|
||||
params: Final[dict[str, object]] = {"temperature": 1.0}
|
||||
|
||||
_, _, _, response_params, _ = BaseLLMHTTPHandler()._run_sync_responses_pre_call_deployment_hook(
|
||||
model="gpt-5.5",
|
||||
input="hi",
|
||||
custom_llm_provider="openai",
|
||||
response_api_optional_request_params=params,
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
logging_obj=logging_obj,
|
||||
)
|
||||
|
||||
assert seen == (["gpt-5.5"] if with_v2 else [])
|
||||
assert response_params["temperature"] == 1.0
|
||||
|
|
|
|||
|
|
@ -1295,3 +1295,60 @@ async def test_parallel_post_call_guardrails_keep_native_hook_when_opted_out(mon
|
|||
|
||||
assert opted_out.native_hooks_ran == ["post_call"]
|
||||
assert routed.native_hooks_ran == []
|
||||
|
||||
|
||||
class _TagsRequestBeforeCall(CustomLogger):
|
||||
async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type):
|
||||
data["tagged_by"] = type(self).__name__
|
||||
return data
|
||||
|
||||
|
||||
class _InheritsPreCallHook(_TagsRequestBeforeCall):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pre_call_hook_runs_hook_inherited_from_intermediate_base(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "callbacks", [_InheritsPreCallHook()])
|
||||
|
||||
result = await ProxyLogging(user_api_key_cache=DualCache()).pre_call_hook(
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="sk-9876"),
|
||||
data={"model": "gpt-5", "messages": [{"role": "user", "content": "hi"}]},
|
||||
call_type="acompletion",
|
||||
)
|
||||
|
||||
assert result["tagged_by"] == "_InheritsPreCallHook"
|
||||
|
||||
|
||||
class _RewritesSuccessResponse(CustomLogger):
|
||||
async def async_post_call_success_hook(self, data, user_api_key_dict, response):
|
||||
return {"rewritten_by": type(self).__name__}
|
||||
|
||||
|
||||
class _InheritsSuccessHook(_RewritesSuccessResponse):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_call_success_hook_awaits_only_loggers_whose_class_overrides_it(monkeypatch):
|
||||
from litellm.types.utils import Choices, Message, ModelResponse
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
async def _instance_level_hook(data, user_api_key_dict, response):
|
||||
calls.append("instance")
|
||||
return {"rewritten_by": "instance"}
|
||||
|
||||
default_logger = CustomLogger()
|
||||
default_logger.async_post_call_success_hook = _instance_level_hook
|
||||
monkeypatch.setattr(litellm, "callbacks", [_InheritsSuccessHook(), default_logger])
|
||||
response = ModelResponse(choices=[Choices(message=Message(role="assistant", content="hello"))])
|
||||
|
||||
result = await ProxyLogging(user_api_key_cache=DualCache()).post_call_success_hook(
|
||||
data={"messages": [{"role": "user", "content": "hi"}]},
|
||||
response=response,
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="sk-9876"),
|
||||
)
|
||||
|
||||
assert calls == []
|
||||
assert result == {"rewritten_by": "_InheritsSuccessHook"}
|
||||
|
|
|
|||
1212
tests/unit/proxy/test_v2_hook_routing.py
Normal file
1212
tests/unit/proxy/test_v2_hook_routing.py
Normal file
File diff suppressed because it is too large
Load diff
141
tests/unit/router_utils/test_fallback_event_v2_dispatch.py
Normal file
141
tests/unit/router_utils/test_fallback_event_v2_dispatch.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import asyncio
|
||||
import threading
|
||||
from collections.abc import Awaitable, Callable, Generator
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.integrations.custom_logger_v2 import CallContext, CustomLoggerV2, Field, Hook, on
|
||||
from litellm.router_utils import fallback_event_handlers as handlers
|
||||
|
||||
EventLogger = Callable[[str, dict[str, object], Exception], Awaitable[None]]
|
||||
EVENTS: Final = (
|
||||
("log_success_fallback_event", "fallback_success"),
|
||||
("log_failure_fallback_event", "fallback_failure"),
|
||||
)
|
||||
CALLBACK_LISTS: Final = (
|
||||
"callbacks",
|
||||
"success_callback",
|
||||
"failure_callback",
|
||||
"_async_success_callback",
|
||||
"_async_failure_callback",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def empty_callback_lists(monkeypatch: pytest.MonkeyPatch) -> Generator[None]:
|
||||
for name in CALLBACK_LISTS:
|
||||
monkeypatch.setattr(litellm, name, [])
|
||||
yield
|
||||
|
||||
|
||||
class Recorder(CustomLogger):
|
||||
def __init__(
|
||||
self, name: str, journal: list[object], error: Exception | None = None
|
||||
) -> None: # mutable-ok: the shared call log
|
||||
super().__init__() # pyright: ignore[reportUnknownMemberType] # CustomLogger's untyped **kwargs
|
||||
self.name = name
|
||||
self.journal = journal
|
||||
self.error = error
|
||||
self.calls: list[tuple[str, str, dict[str, object], Exception]] = [] # mutable-ok: each call's arguments
|
||||
|
||||
async def _record(
|
||||
self, event: str, group: str, kwargs: dict[str, object], exception: Exception
|
||||
) -> None: # mutable-ok: the handler's kwargs dict
|
||||
self.journal.append(self.name)
|
||||
self.calls.append((event, group, kwargs, exception))
|
||||
await asyncio.sleep(0)
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
|
||||
async def log_success_fallback_event(
|
||||
self, original_model_group: str, kwargs: dict[str, object], original_exception: Exception
|
||||
) -> None: # mutable-ok: the handler's kwargs dict
|
||||
await self._record("success", original_model_group, kwargs, original_exception)
|
||||
|
||||
async def log_failure_fallback_event(
|
||||
self, original_model_group: str, kwargs: dict[str, object], original_exception: Exception
|
||||
) -> None: # mutable-ok: the handler's kwargs dict
|
||||
await self._record("failure", original_model_group, kwargs, original_exception)
|
||||
|
||||
|
||||
class Watcher(CustomLoggerV2):
|
||||
def __init__(self, journal: list[object], done: threading.Event) -> None: # mutable-ok: the shared call log
|
||||
self.journal = journal
|
||||
self.done = done
|
||||
|
||||
@on(Hook.router_event, reads={Field.model, Field.outcome, Field.error})
|
||||
async def watch(self, view: object, ctx: CallContext[object]) -> None:
|
||||
self.journal.append(
|
||||
("v2", getattr(view, "model"), getattr(view, "outcome"), str(getattr(view, "error"))) # noqa: B009 # the view's declared fields
|
||||
)
|
||||
self.done.set()
|
||||
|
||||
|
||||
def _public(name: str) -> EventLogger:
|
||||
found: Final[EventLogger] = getattr(handlers, name) # pyright: ignore[reportAny] # the module's async handlers
|
||||
return found
|
||||
|
||||
|
||||
def _with_inert(callbacks: list[object], inert: bool) -> list[object]: # mutable-ok: a process list
|
||||
return [*callbacks, Watcher([], threading.Event())] if inert else callbacks
|
||||
|
||||
|
||||
@pytest.mark.parametrize("inert", [False, True])
|
||||
@pytest.mark.parametrize(("public", "_outcome"), EVENTS)
|
||||
async def test_every_logger_in_the_merged_lists_runs_once_in_order_with_the_same_arguments(
|
||||
public: str, _outcome: str, inert: bool, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
journal: Final[list[object]] = [] # mutable-ok: the shared call log
|
||||
first: Final = Recorder("first", journal)
|
||||
second: Final = Recorder("second", journal)
|
||||
third: Final = Recorder("third", journal)
|
||||
monkeypatch.setattr(litellm, "callbacks", _with_inert([first, "langfuse", second], inert))
|
||||
monkeypatch.setattr(litellm, "_async_success_callback", [second, third, first])
|
||||
kwargs: Final[dict[str, object]] = {"model": "fallback"} # mutable-ok: the handler's kwargs dict
|
||||
error: Final = RuntimeError("original")
|
||||
|
||||
result: Final = await _public(public)("primary", kwargs, error)
|
||||
|
||||
assert result is None
|
||||
assert journal == ["first", "second", "third"]
|
||||
event: Final = "success" if public.startswith("log_success") else "failure"
|
||||
assert first.calls == [(event, "primary", kwargs, error)]
|
||||
assert first.calls[0][2] is kwargs
|
||||
assert first.calls[0][3] is error
|
||||
|
||||
|
||||
@pytest.mark.parametrize("inert", [False, True])
|
||||
@pytest.mark.parametrize(("public", "_outcome"), EVENTS)
|
||||
async def test_a_logger_error_is_logged_and_the_next_logger_still_runs(
|
||||
public: str, _outcome: str, inert: bool, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
journal: Final[list[object]] = [] # mutable-ok: the shared call log
|
||||
logged: Final[list[tuple[object, ...]]] = [] # mutable-ok: the router logger's error calls
|
||||
monkeypatch.setattr(handlers.verbose_router_logger, "error", lambda *args: logged.append(args))
|
||||
error: Final = ValueError("logger raised")
|
||||
monkeypatch.setattr(
|
||||
litellm, "callbacks", _with_inert([Recorder("broken", journal, error), Recorder("next", journal)], inert)
|
||||
)
|
||||
|
||||
await _public(public)("primary", {}, RuntimeError("original"))
|
||||
|
||||
assert journal == ["broken", "next"]
|
||||
assert logged == [(f"Error in {public}: %s", error)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("public", "outcome"), EVENTS)
|
||||
async def test_a_v2_callback_in_the_lists_is_delivered_off_path_after_the_legacy_loggers(
|
||||
public: str, outcome: str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
journal: Final[list[object]] = [] # mutable-ok: the shared call log
|
||||
done: Final = threading.Event()
|
||||
monkeypatch.setattr(litellm, "callbacks", [Recorder("before", journal), Watcher(journal, done)])
|
||||
monkeypatch.setattr(litellm, "_async_failure_callback", [Recorder("after", journal)])
|
||||
|
||||
await _public(public)("primary", {"model": "fallback"}, RuntimeError("original"))
|
||||
|
||||
assert await asyncio.to_thread(done.wait, 30)
|
||||
assert journal == ["before", "after", ("v2", "primary", outcome, "original")]
|
||||
232
tests/unit/test_router/core/test_callback_filters_v2_dispatch.py
Normal file
232
tests/unit/test_router/core/test_callback_filters_v2_dispatch.py
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
from collections.abc import Callable, Sequence
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.integrations.custom_logger_v2 import (
|
||||
Abort,
|
||||
CallContext,
|
||||
CustomLoggerV2,
|
||||
Field,
|
||||
Hook,
|
||||
InvalidPatchError,
|
||||
Patch,
|
||||
on,
|
||||
)
|
||||
from litellm.integrations.custom_logger_v2.host import V2Outcome
|
||||
from litellm.router_rust.steps.callbacks import OptionalChecks
|
||||
from tests.unit.test_router.core.registry_helpers import MakeCore
|
||||
from tests.unit.test_router.core.scripted_host import ScriptedHost, deployment
|
||||
|
||||
GROUP: Final = [deployment("g", "a"), deployment("g", "b"), deployment("g", "c")]
|
||||
|
||||
Deployments = list[dict[str, object]] # mutable-ok: the filter contract
|
||||
|
||||
|
||||
def _id(deployment: object) -> object:
|
||||
return deployment["model_info"]["id"] if isinstance(deployment, dict) else None # pyright: ignore[reportUnknownVariableType] # a deployment dict
|
||||
|
||||
|
||||
def _ids(deployments: object) -> tuple[object, ...]:
|
||||
return tuple(map(_id, deployments)) if isinstance(deployments, list | tuple) else () # pyright: ignore[reportUnknownArgumentType] # see above
|
||||
|
||||
|
||||
class Narrow(CustomLogger):
|
||||
def __init__(
|
||||
self, name: str, journal: list[object], dropped: str = "", error: Exception | None = None
|
||||
) -> None: # mutable-ok: the shared call log
|
||||
super().__init__() # pyright: ignore[reportUnknownMemberType] # CustomLogger's untyped **kwargs
|
||||
self.name = name
|
||||
self.journal = journal
|
||||
self.dropped = dropped
|
||||
self.error = error
|
||||
self.seen: list[object] = [] # mutable-ok: each call's deployments
|
||||
|
||||
async def async_filter_deployments(
|
||||
self,
|
||||
model: str,
|
||||
healthy_deployments: Deployments,
|
||||
messages: object,
|
||||
request_kwargs: object = None,
|
||||
parent_otel_span: object = None,
|
||||
) -> Deployments:
|
||||
self.journal.append(self.name)
|
||||
self.seen.append(healthy_deployments)
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return [d for d in healthy_deployments if _id(d) != self.dropped]
|
||||
|
||||
|
||||
class Bystander(CustomLogger):
|
||||
def __init__(self, journal: list[object]) -> None: # mutable-ok: the shared call log
|
||||
super().__init__() # pyright: ignore[reportUnknownMemberType] # see above
|
||||
self.journal = journal
|
||||
|
||||
|
||||
class Picker(CustomLoggerV2):
|
||||
def __init__(
|
||||
self,
|
||||
journal: list[object],
|
||||
keep: Callable[[Sequence[object]], object] | None = None,
|
||||
outcome: Abort | None = None,
|
||||
) -> None: # mutable-ok: the shared call log
|
||||
self.journal = journal
|
||||
self.keep = keep
|
||||
self.outcome = outcome
|
||||
|
||||
@on(
|
||||
Hook.filter_deployments,
|
||||
reads={Field.candidate_deployments, Field.model},
|
||||
writes={Field.candidate_deployments},
|
||||
may_abort=True,
|
||||
)
|
||||
async def pick(self, view: object, ctx: CallContext[object]) -> Patch | Abort | None:
|
||||
candidates: Final = getattr(view, "candidate_deployments") # noqa: B009 # the view's declared field
|
||||
self.journal.append(("v2", getattr(view, "model"), _ids(candidates))) # noqa: B009 # see above
|
||||
if self.outcome is not None:
|
||||
return self.outcome
|
||||
return None if self.keep is None else Patch().set(Field.candidate_deployments, self.keep(candidates))
|
||||
|
||||
|
||||
class Inert(CustomLoggerV2):
|
||||
@on(Hook.pre_call)
|
||||
async def ignore(self, view: object, ctx: CallContext[object]) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _checks(make_core: MakeCore) -> OptionalChecks:
|
||||
core: Final = make_core(model_list=list(GROUP))
|
||||
checks: Final[OptionalChecks] = core.__dict__["_checks"] # pyright: ignore[reportAny] # RustCore's own attribute
|
||||
return checks
|
||||
|
||||
|
||||
def _install(host: ScriptedHost, callbacks: Sequence[object], inert: bool) -> None:
|
||||
host.lists["callbacks"].extend((*callbacks, Picker([])) if inert else callbacks)
|
||||
|
||||
|
||||
def _deployments() -> Deployments:
|
||||
return [{"model_info": {"id": name}} for name in ("a", "b", "c")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("inert", [False, True])
|
||||
async def test_filters_run_in_list_order_and_each_sees_what_the_previous_one_kept(
|
||||
make_core: MakeCore, host: ScriptedHost, inert: bool
|
||||
) -> None:
|
||||
journal: Final[list[object]] = [] # mutable-ok: the shared call log
|
||||
first: Final = Narrow("first", journal, dropped="a")
|
||||
second: Final = Narrow("second", journal, dropped="c")
|
||||
checks: Final = _checks(make_core)
|
||||
_install(host, [first, Bystander(journal), "a-string-callback", second], inert)
|
||||
deployments: Final = _deployments()
|
||||
|
||||
remaining, preferred = await checks.filter_deployments("g", deployments, None, {"model": "g"})
|
||||
|
||||
assert journal == ["first", "second"]
|
||||
assert first.seen[0] is not deployments
|
||||
assert _ids(first.seen[0]) == ("a", "b", "c")
|
||||
assert _ids(second.seen[0]) == ("b", "c")
|
||||
assert _ids(remaining) == ("b",)
|
||||
assert preferred is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("inert", [False, True])
|
||||
async def test_a_filter_error_reaches_the_caller_unchanged_and_stops_later_filters(
|
||||
make_core: MakeCore, host: ScriptedHost, inert: bool
|
||||
) -> None:
|
||||
journal: Final[list[object]] = [] # mutable-ok: the shared call log
|
||||
error: Final = RuntimeError("a filter raised")
|
||||
checks: Final = _checks(make_core)
|
||||
_install(host, [Narrow("first", journal, error=error), Narrow("second", journal)], inert)
|
||||
|
||||
with pytest.raises(RuntimeError) as raised:
|
||||
await checks.filter_deployments("g", _deployments(), None, {})
|
||||
|
||||
assert raised.value is error
|
||||
assert journal == ["first"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("inert", [False, True])
|
||||
async def test_an_empty_result_is_returned_for_the_core_to_fail_the_pick(
|
||||
make_core: MakeCore, host: ScriptedHost, inert: bool
|
||||
) -> None:
|
||||
checks: Final = _checks(make_core)
|
||||
_install(
|
||||
host, [Narrow("only", [], dropped="a"), Narrow("all", [], dropped="b"), Narrow("rest", [], dropped="c")], inert
|
||||
)
|
||||
|
||||
remaining, _ = await checks.filter_deployments("g", _deployments(), None, {})
|
||||
|
||||
assert remaining == []
|
||||
|
||||
|
||||
async def test_a_v2_callback_between_two_filters_sees_the_first_cut_and_the_second_sees_its_own(
|
||||
make_core: MakeCore, host: ScriptedHost
|
||||
) -> None:
|
||||
journal: Final[list[object]] = [] # mutable-ok: the shared call log
|
||||
second: Final = Narrow("second", journal)
|
||||
checks: Final = _checks(make_core)
|
||||
v2_callback: Final = Picker(journal, keep=lambda candidates: [d for d in candidates if _id(d) != "b"])
|
||||
_install(host, [Narrow("first", journal, dropped="a"), v2_callback, second], inert=False)
|
||||
|
||||
remaining, _ = await checks.filter_deployments("g", _deployments(), None, {})
|
||||
|
||||
assert journal == ["first", ("v2", "g", ("b", "c")), "second"]
|
||||
assert _ids(second.seen[0]) == ("c",)
|
||||
assert _ids(remaining) == ("c",)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"keep",
|
||||
[
|
||||
pytest.param(lambda candidates: [*candidates, {"model_info": {"id": "z"}}], id="adds"),
|
||||
pytest.param(lambda candidates: [candidates[0], candidates[0]], id="repeats"),
|
||||
],
|
||||
)
|
||||
async def test_a_v2_callback_may_only_narrow_the_candidates(
|
||||
make_core: MakeCore, host: ScriptedHost, keep: Callable[[Sequence[object]], object]
|
||||
) -> None:
|
||||
journal: Final[list[object]] = [] # mutable-ok: the shared call log
|
||||
checks: Final = _checks(make_core)
|
||||
_install(host, [Picker(journal, keep=keep), Narrow("after", journal)], inert=False)
|
||||
|
||||
with pytest.raises(InvalidPatchError, match="only narrow"):
|
||||
await checks.filter_deployments("g", _deployments(), None, {})
|
||||
|
||||
assert journal == [("v2", "g", ("a", "b", "c"))]
|
||||
|
||||
|
||||
async def test_a_v2_callback_abort_ends_the_filters_as_an_error(make_core: MakeCore, host: ScriptedHost) -> None:
|
||||
journal: Final[list[object]] = [] # mutable-ok: the shared call log
|
||||
abort: Final = Abort(status=429, message="no capacity")
|
||||
checks: Final = _checks(make_core)
|
||||
_install(host, [Narrow("before", journal), Picker(journal, outcome=abort), Narrow("after", journal)], inert=False)
|
||||
|
||||
with pytest.raises(V2Outcome) as raised:
|
||||
await checks.filter_deployments("g", _deployments(), None, {})
|
||||
|
||||
assert raised.value.outcome is abort
|
||||
assert journal == ["before", ("v2", "g", ("a", "b", "c"))]
|
||||
|
||||
|
||||
async def test_only_a_v2_callback_that_filters_makes_the_core_ask_for_filters(
|
||||
make_core: MakeCore, host: ScriptedHost
|
||||
) -> None:
|
||||
checks: Final = _checks(make_core)
|
||||
host.lists["callbacks"].append(Inert())
|
||||
without_filter: Final = checks.has_filters()
|
||||
host.lists["callbacks"].append(Picker([]))
|
||||
|
||||
assert (without_filter, checks.has_filters()) == (False, True)
|
||||
|
||||
|
||||
async def test_the_pick_honours_a_v2_callback_between_legacy_filters(make_core: MakeCore, host: ScriptedHost) -> None:
|
||||
journal: Final[list[object]] = [] # mutable-ok: the shared call log
|
||||
v2_callback: Final = Picker(journal, keep=lambda candidates: [d for d in candidates if _id(d) != "c"])
|
||||
host.lists["callbacks"].extend((Narrow("first", journal, dropped="a"), v2_callback, Narrow("second", journal)))
|
||||
core: Final = make_core(model_list=list(GROUP))
|
||||
|
||||
picked: Final = {_id(await core.select("g")) for _ in range(10)}
|
||||
|
||||
assert picked == {"b"}
|
||||
assert journal[:3] == ["first", ("v2", "g", ("b", "c")), "second"]
|
||||
|
|
@ -13,7 +13,7 @@ import queue
|
|||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable, Iterator, Mapping
|
||||
from collections.abc import Awaitable, Callable, Iterator, Mapping
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import partial
|
||||
|
|
@ -44,6 +44,8 @@ from litellm.caching.in_memory_cache import InMemoryCache
|
|||
from litellm.constants import DEFAULT_MOCK_RESPONSE_COMPLETION_TOKEN_COUNT
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.integrations.custom_logger_v2 import Abort, CallContext, CustomLoggerV2, Field, Hook, Patch, on
|
||||
from litellm.integrations.custom_logger_v2.host import V2Outcome
|
||||
from litellm.integrations.humanloop import HumanloopLogger
|
||||
from litellm.llms.base_llm.audio_transcription.transformation import BaseAudioTranscriptionConfig
|
||||
from litellm.integrations.langfuse.langfuse_prompt_management import LangfusePromptManagement
|
||||
|
|
@ -11910,3 +11912,390 @@ async def test_pre_call_deployment_hook_hands_responses_compact_its_call_type(mo
|
|||
await async_pre_call_deployment_hook({"model": "gpt-5.4-mini"}, "acompact_responses")
|
||||
|
||||
assert hook.seen_call_types == (CallTypes.acompact_responses,)
|
||||
|
||||
|
||||
_REWRITTEN_RESPONSE: Final = ModelResponse(id="rewritten-by-the-deployment-hook")
|
||||
|
||||
|
||||
class _DeploymentLogger(CustomLogger):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
journal: list[object],
|
||||
replacement: object = None,
|
||||
error: Exception | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.name = name
|
||||
self.journal = journal
|
||||
self.replacement = replacement
|
||||
self.error = error
|
||||
self.seen: list[tuple[object, ...]] = []
|
||||
|
||||
def _record(self, *seen: object) -> object:
|
||||
self.journal.append(self.name)
|
||||
self.seen.append(seen)
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return self.replacement
|
||||
|
||||
async def async_pre_call_deployment_hook(
|
||||
self, kwargs: dict[str, object], call_type: CallTypes | None
|
||||
) -> dict[str, object] | None:
|
||||
return cast("dict[str, object] | None", self._record(kwargs, call_type))
|
||||
|
||||
async def async_post_call_success_deployment_hook(
|
||||
self, request_data: dict[str, object], response: object, call_type: CallTypes | None
|
||||
) -> object:
|
||||
return self._record(request_data, response, call_type)
|
||||
|
||||
async def async_post_call_failure_deployment_hook(
|
||||
self,
|
||||
request_data: Mapping[str, object],
|
||||
exception: Exception,
|
||||
call_type: CallTypes | None,
|
||||
fallback_depth: int | None = None,
|
||||
) -> None:
|
||||
self._record(request_data, exception, call_type, fallback_depth)
|
||||
|
||||
|
||||
class _DeploymentGuardrail(CustomGuardrail):
|
||||
def __init__(self, error: Exception) -> None:
|
||||
super().__init__(guardrail_name="deployment-guard", default_on=True)
|
||||
self.error = error
|
||||
|
||||
async def async_post_call_success_deployment_hook(
|
||||
self, request_data: dict[str, object], response: object, call_type: CallTypes | None
|
||||
) -> object:
|
||||
raise self.error
|
||||
|
||||
|
||||
class _DeploymentV2(CustomLoggerV2):
|
||||
def __init__(self, journal: list[object], replacement: object = None, outcome: Abort | None = None) -> None:
|
||||
self.journal = journal
|
||||
self.replacement = replacement
|
||||
self.outcome = outcome
|
||||
|
||||
@on(Hook.pre_deployment_call, reads={Field.model}, may_abort=True)
|
||||
async def pre_deployment(self, view: object, ctx: CallContext[object]) -> Abort | None:
|
||||
self.journal.append(("v2", getattr(view, "model"))) # noqa: B009 # the view's declared field
|
||||
return self.outcome
|
||||
|
||||
@on(Hook.post_deployment_success, reads={Field.response}, writes={Field.response}, may_abort=True)
|
||||
async def post_success(self, view: object, ctx: CallContext[object]) -> Patch | Abort | None:
|
||||
self.journal.append(("v2", getattr(view, "response"))) # noqa: B009 # see above
|
||||
if self.outcome is not None:
|
||||
return self.outcome
|
||||
return None if self.replacement is None else Patch().set(Field.response, self.replacement)
|
||||
|
||||
@on(Hook.post_deployment_failure, reads={Field.error}, may_abort=True)
|
||||
async def post_failure(self, view: object, ctx: CallContext[object]) -> Abort | None:
|
||||
self.journal.append(("v2", str(getattr(view, "error")))) # noqa: B009 # see above
|
||||
return self.outcome
|
||||
|
||||
|
||||
def _with_inert_v2(callbacks: list[object], inert: bool) -> list[object]:
|
||||
return [*callbacks, _DeploymentV2([])] if inert else callbacks
|
||||
|
||||
|
||||
@pytest.mark.parametrize("inert", [False, True])
|
||||
async def test_pre_deployment_hooks_run_in_order_and_a_returned_dict_replaces_the_kwargs(
|
||||
monkeypatch: pytest.MonkeyPatch, inert: bool
|
||||
) -> None:
|
||||
journal: Final[list[object]] = []
|
||||
replaced: Final[dict[str, object]] = {"model": "replaced"}
|
||||
first: Final = _DeploymentLogger("first", journal, replacement=replaced)
|
||||
keeper: Final = _DeploymentLogger("keeper", journal)
|
||||
last: Final = _DeploymentLogger("last", journal)
|
||||
monkeypatch.setattr(litellm, "callbacks", _with_inert_v2([first, "a-string-callback", keeper, last], inert))
|
||||
|
||||
result: Final = await async_pre_call_deployment_hook({"model": "original"}, "acompletion", include_v2=True)
|
||||
|
||||
assert journal == ["first", "keeper", "last"]
|
||||
assert first.seen[0][0] == {"model": "original"}
|
||||
assert keeper.seen[0][0] is replaced
|
||||
assert last.seen[0][0] is replaced
|
||||
assert result is replaced
|
||||
assert first.seen[0][1] is CallTypes.acompletion
|
||||
|
||||
|
||||
@pytest.mark.parametrize("inert", [False, True])
|
||||
async def test_a_pre_deployment_hook_error_propagates_unchanged_and_stops_the_hook(
|
||||
monkeypatch: pytest.MonkeyPatch, inert: bool
|
||||
) -> None:
|
||||
journal: Final[list[object]] = []
|
||||
error: Final = ValueError("rejected before the deployment")
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"callbacks",
|
||||
_with_inert_v2([_DeploymentLogger("first", journal, error=error), _DeploymentLogger("next", journal)], inert),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="rejected before the deployment") as raised:
|
||||
await async_pre_call_deployment_hook({"model": "m"}, "acompletion", include_v2=True)
|
||||
|
||||
assert raised.value is error
|
||||
assert journal == ["first"]
|
||||
|
||||
|
||||
async def test_a_pre_deployment_v2_callback_between_legacy_hooks_sees_the_first_rewrite_and_its_abort_stops_the_rest(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
journal: Final[list[object]] = []
|
||||
abort: Final = Abort(status=403, message="deployment not allowed")
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"callbacks",
|
||||
[
|
||||
_DeploymentLogger("first", journal, replacement={"model": "rewritten"}),
|
||||
_DeploymentV2(journal, outcome=abort),
|
||||
_DeploymentLogger("after", journal),
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(V2Outcome) as raised:
|
||||
await async_pre_call_deployment_hook({"model": "m"}, "acompletion", include_v2=True)
|
||||
|
||||
assert raised.value.outcome is abort
|
||||
assert journal == ["first", ("v2", "rewritten")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hook_call",
|
||||
[
|
||||
pytest.param(lambda: async_pre_call_deployment_hook({"model": "m"}, "acompletion"), id="pre"),
|
||||
pytest.param(
|
||||
lambda: async_post_call_success_deployment_hook({"model": "m"}, ModelResponse(), CallTypes.acompletion),
|
||||
id="success",
|
||||
),
|
||||
pytest.param(
|
||||
lambda: async_post_call_failure_deployment_hook({"model": "m"}, ValueError("x"), "acompletion"),
|
||||
id="failure",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_callers_that_do_not_ask_for_v2_callbacks_run_only_the_legacy_hooks(
|
||||
monkeypatch: pytest.MonkeyPatch, hook_call: Callable[[], object]
|
||||
) -> None:
|
||||
journal: Final[list[object]] = []
|
||||
monkeypatch.setattr(litellm, "callbacks", [_DeploymentV2(journal), _DeploymentLogger("legacy", journal)])
|
||||
|
||||
await cast("Awaitable[object]", hook_call())
|
||||
|
||||
assert journal == ["legacy"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("inert", [False, True])
|
||||
async def test_post_deployment_success_chains_replacements_and_swallows_a_logger_error(
|
||||
monkeypatch: pytest.MonkeyPatch, inert: bool
|
||||
) -> None:
|
||||
journal: Final[list[object]] = []
|
||||
replacement: Final = ModelResponse(id="replaced")
|
||||
first: Final = _DeploymentLogger("first", journal, replacement=replacement)
|
||||
broken: Final = _DeploymentLogger("broken", journal, error=RuntimeError("logger bug"))
|
||||
last: Final = _DeploymentLogger("last", journal)
|
||||
monkeypatch.setattr(litellm, "callbacks", _with_inert_v2([first, broken, last], inert))
|
||||
request: Final[dict[str, object]] = {"model": "m"}
|
||||
response: Final = ModelResponse(id="original")
|
||||
|
||||
result: Final = await async_post_call_success_deployment_hook(
|
||||
request, response, CallTypes.acompletion, include_v2=True
|
||||
)
|
||||
|
||||
assert journal == ["first", "broken", "last"]
|
||||
assert first.seen[0][0] is request
|
||||
assert first.seen[0][1] is response
|
||||
assert broken.seen[0][1] is replacement
|
||||
assert last.seen[0][1] is replacement
|
||||
assert result is replacement
|
||||
|
||||
|
||||
@pytest.mark.parametrize("inert", [False, True])
|
||||
async def test_post_deployment_success_reraises_a_guardrail_error_and_stops_the_hook(
|
||||
monkeypatch: pytest.MonkeyPatch, inert: bool
|
||||
) -> None:
|
||||
journal: Final[list[object]] = []
|
||||
error: Final = ValueError("response blocked")
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"callbacks",
|
||||
_with_inert_v2([_DeploymentGuardrail(error), _DeploymentLogger("next", journal)], inert),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="response blocked") as raised:
|
||||
await async_post_call_success_deployment_hook({}, ModelResponse(), CallTypes.acompletion, include_v2=True)
|
||||
|
||||
assert raised.value is error
|
||||
assert journal == []
|
||||
|
||||
|
||||
async def test_a_post_deployment_success_v2_callback_between_legacy_hooks_sees_and_replaces_the_response(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
journal: Final[list[object]] = []
|
||||
first_reply: Final = ModelResponse(id="first")
|
||||
native_reply: Final = ModelResponse(id="v2")
|
||||
second: Final = _DeploymentLogger("second", journal)
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"callbacks",
|
||||
[
|
||||
_DeploymentLogger("first", journal, replacement=first_reply),
|
||||
_DeploymentV2(journal, replacement=native_reply),
|
||||
second,
|
||||
],
|
||||
)
|
||||
|
||||
result: Final = await async_post_call_success_deployment_hook(
|
||||
{}, ModelResponse(id="original"), CallTypes.acompletion, include_v2=True
|
||||
)
|
||||
|
||||
assert journal == ["first", ("v2", first_reply), "second"]
|
||||
assert second.seen[0][1] is native_reply
|
||||
assert result is native_reply
|
||||
|
||||
|
||||
@pytest.mark.parametrize("inert", [False, True])
|
||||
async def test_post_deployment_failure_hands_every_logger_a_snapshot_and_swallows_their_errors(
|
||||
monkeypatch: pytest.MonkeyPatch, inert: bool
|
||||
) -> None:
|
||||
journal: Final[list[object]] = []
|
||||
broken: Final = _DeploymentLogger("broken", journal, error=RuntimeError("logger bug"))
|
||||
last: Final = _DeploymentLogger("last", journal)
|
||||
monkeypatch.setattr(litellm, "callbacks", _with_inert_v2([broken, last], inert))
|
||||
error: Final = ValueError("deployment failed")
|
||||
|
||||
await async_post_call_failure_deployment_hook(
|
||||
{"model": "m", "fallback_depth": 2, "attempted_targets": object()}, error, "acompletion", include_v2=True
|
||||
)
|
||||
|
||||
assert journal == ["broken", "last"]
|
||||
request, seen_error, call_type, depth = last.seen[0]
|
||||
assert dict(cast("Mapping[str, object]", request)) == {"model": "m", "fallback_depth": 2}
|
||||
assert type(seen_error) is ValueError
|
||||
assert seen_error is not error
|
||||
assert broken.seen[0][1] is seen_error
|
||||
assert (call_type, depth) == (CallTypes.acompletion, 2)
|
||||
|
||||
|
||||
async def test_a_post_deployment_failure_v2_callback_between_legacy_hooks_runs_in_list_order(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
journal: Final[list[object]] = []
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"callbacks",
|
||||
[_DeploymentLogger("first", journal), _DeploymentV2(journal), _DeploymentLogger("second", journal)],
|
||||
)
|
||||
|
||||
await async_post_call_failure_deployment_hook({}, ValueError("deployment failed"), "acompletion", include_v2=True)
|
||||
|
||||
assert journal == ["first", ("v2", "deployment failed"), "second"]
|
||||
|
||||
|
||||
class _PreDeploymentRewriter(CustomLogger):
|
||||
def __init__(self, seen: list[str]) -> None:
|
||||
super().__init__()
|
||||
self.seen = seen
|
||||
|
||||
async def async_pre_call_deployment_hook(
|
||||
self, kwargs: dict[str, object], call_type: CallTypes | None
|
||||
) -> dict[str, object] | None:
|
||||
self.seen.append("legacy")
|
||||
return {**kwargs, "mock_response": "rewritten by the pre-deployment hook"}
|
||||
|
||||
|
||||
class _PostDeploymentSuccessRewriter(CustomLogger):
|
||||
def __init__(self, seen: list[str]) -> None:
|
||||
super().__init__()
|
||||
self.seen = seen
|
||||
|
||||
async def async_post_call_success_deployment_hook(
|
||||
self, request_data: dict[str, object], response: object, call_type: CallTypes | None
|
||||
) -> object:
|
||||
self.seen.append("legacy")
|
||||
return _REWRITTEN_RESPONSE
|
||||
|
||||
|
||||
class _PostDeploymentFailureRecorder(CustomLogger):
|
||||
def __init__(self, seen: list[str]) -> None:
|
||||
super().__init__()
|
||||
self.seen = seen
|
||||
self.failures: tuple[Exception, ...] = ()
|
||||
|
||||
async def async_post_call_failure_deployment_hook(
|
||||
self, request_data: Mapping[str, object], exception: Exception, call_type: CallTypes | None
|
||||
) -> None:
|
||||
self.seen.append("legacy")
|
||||
self.failures = (*self.failures, exception)
|
||||
|
||||
|
||||
def _deployment_gate(hook: Hook, seen: list[str]) -> CustomLoggerV2:
|
||||
class Gate(CustomLoggerV2):
|
||||
@on(hook, may_abort=True)
|
||||
async def gate(self, view: object, ctx: CallContext[object]) -> None:
|
||||
seen.append("v2")
|
||||
|
||||
return Gate()
|
||||
|
||||
|
||||
def _install_deployment_hook(
|
||||
monkeypatch: pytest.MonkeyPatch, legacy: CustomLogger, v2_hook: Hook | None, seen: list[str]
|
||||
) -> None:
|
||||
gate: Final = [] if v2_hook is None else [_deployment_gate(v2_hook, seen)]
|
||||
monkeypatch.setattr(litellm, "callbacks", [*gate, legacy])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("with_v2", [False, True])
|
||||
async def test_pre_deployment_call_sends_the_kwargs_the_legacy_hook_returned(
|
||||
monkeypatch: pytest.MonkeyPatch, with_v2: bool
|
||||
) -> None:
|
||||
seen: Final[list[str]] = []
|
||||
_install_deployment_hook(
|
||||
monkeypatch, _PreDeploymentRewriter(seen), Hook.pre_deployment_call if with_v2 else None, seen
|
||||
)
|
||||
|
||||
response: Final = await litellm.acompletion(
|
||||
model="gpt-4o-mini", messages=[{"role": "user", "content": "hi"}], mock_response="original", num_retries=0
|
||||
)
|
||||
|
||||
assert isinstance(response, ModelResponse)
|
||||
assert response.choices[0].message.content == "rewritten by the pre-deployment hook"
|
||||
assert seen == (["v2", "legacy"] if with_v2 else ["legacy"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("with_v2", [False, True])
|
||||
async def test_post_deployment_success_returns_what_the_legacy_hook_returned(
|
||||
monkeypatch: pytest.MonkeyPatch, with_v2: bool
|
||||
) -> None:
|
||||
seen: Final[list[str]] = []
|
||||
_install_deployment_hook(
|
||||
monkeypatch, _PostDeploymentSuccessRewriter(seen), Hook.post_deployment_success if with_v2 else None, seen
|
||||
)
|
||||
|
||||
response: Final = await litellm.acompletion(
|
||||
model="gpt-4o-mini", messages=[{"role": "user", "content": "hi"}], mock_response="original", num_retries=0
|
||||
)
|
||||
|
||||
assert response is _REWRITTEN_RESPONSE
|
||||
assert seen == (["v2", "legacy"] if with_v2 else ["legacy"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("with_v2", [False, True])
|
||||
async def test_post_deployment_failure_reaches_the_legacy_hook_and_keeps_the_real_error(
|
||||
monkeypatch: pytest.MonkeyPatch, with_v2: bool
|
||||
) -> None:
|
||||
seen: Final[list[str]] = []
|
||||
recorder: Final = _PostDeploymentFailureRecorder(seen)
|
||||
_install_deployment_hook(monkeypatch, recorder, Hook.post_deployment_failure if with_v2 else None, seen)
|
||||
error: Final = litellm.AuthenticationError(message="bad key", llm_provider="openai", model="gpt-4o-mini")
|
||||
|
||||
with pytest.raises(litellm.AuthenticationError, match="bad key") as raised:
|
||||
await litellm.acompletion(
|
||||
model="gpt-4o-mini", messages=[{"role": "user", "content": "hi"}], mock_response=error, num_retries=0
|
||||
)
|
||||
|
||||
assert len(recorder.failures) == 1
|
||||
assert type(recorder.failures[0]) is type(raised.value)
|
||||
assert recorder.failures[0] is not raised.value
|
||||
assert seen == (["v2", "legacy"] if with_v2 else ["legacy"])
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue