mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
feat(bedrock): price native guardrailConfig trace usage into spend and x-litellm-response-cost (#48278)
* feat(bedrock): price native guardrailConfig trace usage into spend and x-litellm-response-cost Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): bill native Bedrock guardrail trace usage on chat, responses and pass-through Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(streaming): record the Bedrock guardrail trace of a streamed metadata chunk on the logging object Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(bedrock): apply guardrailConfig on /v1/messages and price its trace, record the stream trace on both builder paths, redact the native guardrail_response Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(bedrock): validate the guardrail trace with one pydantic model Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(bedrock): record the native guardrail entry when litellm_params metadata is None Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(bedrock): import the renamed messages invoke transformation module Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(bedrock): skip native guardrail billing on cache hits and redact the trace with message logging off Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(bedrock): walk the guardrail trace iteratively when redacting it Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(bedrock): record the native guardrail trace where the Bedrock response is parsed Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(bedrock): type the native guardrail trace fixtures and locals Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
c39b5a3efd
commit
0b66a27528
22 changed files with 1227 additions and 134 deletions
|
|
@ -97,8 +97,13 @@ from litellm.litellm_core_utils.internal_call_metadata import (
|
||||||
is_unbilled_non_inference_call,
|
is_unbilled_non_inference_call,
|
||||||
)
|
)
|
||||||
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import (
|
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import (
|
||||||
|
bedrock_guardrail_trace,
|
||||||
cost_breakdown_with_guardrail,
|
cost_breakdown_with_guardrail,
|
||||||
guardrail_information_cost,
|
guardrail_information_cost,
|
||||||
|
is_str_keyed_dict,
|
||||||
|
native_bedrock_guardrail_information,
|
||||||
|
object_mapping,
|
||||||
|
with_guardrail_entry,
|
||||||
)
|
)
|
||||||
from litellm.litellm_core_utils.llm_cost_calc.tool_call_cost_tracking import (
|
from litellm.litellm_core_utils.llm_cost_calc.tool_call_cost_tracking import (
|
||||||
StandardBuiltInToolCostTracking,
|
StandardBuiltInToolCostTracking,
|
||||||
|
|
@ -2725,6 +2730,31 @@ class Logging(LiteLLMLoggingBaseClass):
|
||||||
return
|
return
|
||||||
self.model_call_details["response_headers"] = headers
|
self.model_call_details["response_headers"] = headers
|
||||||
|
|
||||||
|
def record_native_bedrock_guardrail(self, result: object) -> None:
|
||||||
|
"""Bill the guardrail trace a Bedrock response carries when the deployment's ``guardrailConfig`` ran it, by
|
||||||
|
recording it as guardrail information on this call, the same entry the proxy-side Bedrock guardrail writes.
|
||||||
|
A cache hit carries the original response's trace but made no Bedrock call, so it bills nothing."""
|
||||||
|
trace: Final = bedrock_guardrail_trace(result)
|
||||||
|
if trace is None or served_from_cache(result) or self.model_call_details.get("cache_hit") is True:
|
||||||
|
return
|
||||||
|
optional_params: Final = object_mapping(getattr(self, "optional_params", None)) or {}
|
||||||
|
guardrail_config: Final = object_mapping(optional_params.get("guardrailConfig")) or {}
|
||||||
|
identifier: Final = guardrail_config.get("guardrailIdentifier")
|
||||||
|
litellm_params_in: Final = object_mapping(getattr(self, "litellm_params", None)) or {}
|
||||||
|
region: Final = optional_params.get("aws_region_name") or litellm_params_in.get("aws_region_name")
|
||||||
|
entry: Final = native_bedrock_guardrail_information(
|
||||||
|
trace,
|
||||||
|
aws_region_name=region if isinstance(region, str) else None,
|
||||||
|
guardrail_identifier=identifier if isinstance(identifier, str) else None,
|
||||||
|
)
|
||||||
|
litellm_params: Final[object] = self.model_call_details.get("litellm_params")
|
||||||
|
if entry is None or not is_str_keyed_dict(litellm_params):
|
||||||
|
return
|
||||||
|
metadata: Final[object] = litellm_params.get("metadata")
|
||||||
|
bucket: Final = metadata if is_str_keyed_dict(metadata) else {}
|
||||||
|
litellm_params["metadata"] = bucket
|
||||||
|
with_guardrail_entry(bucket, entry)
|
||||||
|
|
||||||
def _merge_hidden_params_from_response_into_metadata(self, logging_result: object) -> None:
|
def _merge_hidden_params_from_response_into_metadata(self, logging_result: object) -> None:
|
||||||
"""
|
"""
|
||||||
Copy response._hidden_params into litellm_params.metadata['hidden_params'].
|
Copy response._hidden_params into litellm_params.metadata['hidden_params'].
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,24 @@
|
||||||
import math
|
import math
|
||||||
from collections.abc import Mapping
|
from collections.abc import Iterator, Mapping, Sequence
|
||||||
|
from functools import partial
|
||||||
|
from itertools import chain
|
||||||
from typing import Annotated, Final
|
from typing import Annotated, Final
|
||||||
|
|
||||||
from pydantic import ConfigDict, Field, TypeAdapter, ValidationError
|
from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter, ValidationError
|
||||||
|
from typing_extensions import TypeIs # noqa: TID251 # TypeIs reaches typing only on 3.13
|
||||||
|
|
||||||
import litellm
|
import litellm
|
||||||
from litellm._logging import verbose_logger
|
from litellm._logging import verbose_logger
|
||||||
|
from litellm.litellm_core_utils.core_helpers import (
|
||||||
|
redact_nested_match_and_regex_keys, # pyright: ignore[reportUnknownVariableType] # signature uses bare dict
|
||||||
|
)
|
||||||
|
from litellm.types.guardrails import GuardrailEventHooks
|
||||||
from litellm.types.llms.base import LiteLLMBaseModel
|
from litellm.types.llms.base import LiteLLMBaseModel
|
||||||
from litellm.types.utils import CostBreakdown
|
from litellm.types.utils import CostBreakdown, GuardrailStatus, StandardLoggingGuardrailInformation
|
||||||
|
|
||||||
BEDROCK_GUARDRAIL_PRICING_KEY: Final = "bedrock/guardrails"
|
BEDROCK_GUARDRAIL_PRICING_KEY: Final = "bedrock/guardrails"
|
||||||
|
BEDROCK_TRACE_FIELDS: Final = ("trace", "amazon-bedrock-trace")
|
||||||
|
_INTERVENING_ACTIONS: Final = frozenset({"BLOCKED", "ANONYMIZED"})
|
||||||
|
|
||||||
|
|
||||||
class GuardrailPricing(LiteLLMBaseModel):
|
class GuardrailPricing(LiteLLMBaseModel):
|
||||||
|
|
@ -96,6 +105,161 @@ def bedrock_guardrail_cost(usage_units: Mapping[str, int], aws_region_name: str
|
||||||
return guardrail_cost_total(bedrock_guardrail_cost_by_unit(usage_units, aws_region_name))
|
return guardrail_cost_total(bedrock_guardrail_cost_by_unit(usage_units, aws_region_name))
|
||||||
|
|
||||||
|
|
||||||
|
_OBJECT_MAPPING: Final = TypeAdapter(Mapping[str, object])
|
||||||
|
_OBJECT_ITEMS: Final = TypeAdapter(tuple[object, ...])
|
||||||
|
|
||||||
|
|
||||||
|
def object_mapping(value: object) -> Mapping[str, object] | None:
|
||||||
|
"""``value`` as a str-keyed mapping, None for anything else. Vendor JSON arrives untyped, so this is the one place
|
||||||
|
that turns it into something the rest of the module can read without guessing."""
|
||||||
|
try:
|
||||||
|
return _OBJECT_MAPPING.validate_python(value)
|
||||||
|
except ValidationError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def object_items(value: object) -> tuple[object, ...]:
|
||||||
|
if isinstance(value, (str, bytes, Mapping)):
|
||||||
|
return ()
|
||||||
|
try:
|
||||||
|
return _OBJECT_ITEMS.validate_python(value)
|
||||||
|
except ValidationError:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
def is_str_keyed_dict(
|
||||||
|
value: object,
|
||||||
|
) -> TypeIs[dict[str, object]]: # guard-ok: JSON object keys are str # mutable-ok: the caller edits this dict in place
|
||||||
|
return isinstance(value, dict)
|
||||||
|
|
||||||
|
|
||||||
|
def _trace_field(response: object, field: str) -> Mapping[str, object] | None:
|
||||||
|
source: Final = object_mapping(response)
|
||||||
|
trace: Final = object_mapping(source.get(field) if source is not None else getattr(response, field, None))
|
||||||
|
if trace is None or object_mapping(trace.get("guardrail")) is None:
|
||||||
|
return None
|
||||||
|
return trace
|
||||||
|
|
||||||
|
|
||||||
|
def bedrock_guardrail_trace(response: object) -> Mapping[str, object] | None:
|
||||||
|
"""The guardrail trace a Bedrock response carries: Converse puts it under ``trace``, Invoke under
|
||||||
|
``amazon-bedrock-trace``. Returned as is, or None when the response has no guardrail section."""
|
||||||
|
traces: Final = map(partial(_trace_field, response), BEDROCK_TRACE_FIELDS)
|
||||||
|
return next((trace for trace in traces if trace is not None), None)
|
||||||
|
|
||||||
|
|
||||||
|
class _Assessment(BaseModel):
|
||||||
|
model_config = ConfigDict(frozen=True, extra="allow")
|
||||||
|
invocation_metrics: Mapping[str, JsonValue] = Field(default_factory=dict, alias="invocationMetrics")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def billable_usage(self) -> Mapping[str, int]:
|
||||||
|
usage: Final = self.invocation_metrics.get("usage")
|
||||||
|
if not isinstance(usage, dict):
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
counter: units
|
||||||
|
for counter, units in usage.items()
|
||||||
|
if counter.endswith("Units") and isinstance(units, int) and not isinstance(units, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _GuardrailTrace(BaseModel):
|
||||||
|
"""Both trace shapes: Converse ``inputAssessment`` {id: a} and ``outputAssessments`` {id: [a, ...]}, Invoke
|
||||||
|
``input`` {id: a} and ``outputs`` [{id: a}, ...]."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True, extra="allow")
|
||||||
|
input_assessment: Mapping[str, _Assessment] = Field(default_factory=dict, alias="inputAssessment")
|
||||||
|
input: Mapping[str, _Assessment] = Field(default_factory=dict)
|
||||||
|
output_assessments: Mapping[str, tuple[_Assessment, ...]] = Field(default_factory=dict, alias="outputAssessments")
|
||||||
|
outputs: tuple[Mapping[str, _Assessment], ...] = ()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def assessments(self) -> tuple[tuple[str, _Assessment], ...]:
|
||||||
|
listed: Final = (
|
||||||
|
(gid, a) for gid, items in self.output_assessments.items() for a in items
|
||||||
|
) # comprehension-ok: id then its list
|
||||||
|
return (
|
||||||
|
*self.input_assessment.items(),
|
||||||
|
*self.input.items(),
|
||||||
|
*listed,
|
||||||
|
*chain.from_iterable(by_id.items() for by_id in self.outputs),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
||||||
|
|
||||||
|
|
||||||
|
def _summed_usage(usages: Sequence[Mapping[str, int]]) -> Mapping[str, int]:
|
||||||
|
counters: Final = tuple(dict.fromkeys(chain.from_iterable(usages)))
|
||||||
|
return {counter: sum(usage.get(counter, 0) for usage in usages) for counter in counters}
|
||||||
|
|
||||||
|
|
||||||
|
def _actions(node: JsonValue) -> Iterator[str]:
|
||||||
|
if isinstance(node, list):
|
||||||
|
yield from chain.from_iterable(map(_actions, node))
|
||||||
|
return
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
return
|
||||||
|
action: Final = node.get("action")
|
||||||
|
if isinstance(action, str):
|
||||||
|
yield action
|
||||||
|
yield from chain.from_iterable(map(_actions, node.values()))
|
||||||
|
|
||||||
|
|
||||||
|
def native_bedrock_guardrail_information(
|
||||||
|
trace: Mapping[str, object], aws_region_name: str | None, guardrail_identifier: str | None
|
||||||
|
) -> StandardLoggingGuardrailInformation | None:
|
||||||
|
"""The guardrail entry for a trace Bedrock returned because the deployment's ``guardrailConfig`` ran with
|
||||||
|
``trace: enabled``: every assessment's ``invocationMetrics.usage`` summed and priced like the proxy-side Bedrock
|
||||||
|
guardrail. None when the trace carries no usage, which is what ``trace: disabled`` leaves."""
|
||||||
|
try:
|
||||||
|
guardrail: Final = _JSON_OBJECT.validate_python(trace.get("guardrail"))
|
||||||
|
pairs: Final = _GuardrailTrace.model_validate(guardrail).assessments
|
||||||
|
except ValidationError:
|
||||||
|
return None
|
||||||
|
usage: Final = _summed_usage(tuple(assessment.billable_usage for _, assessment in pairs))
|
||||||
|
if not usage:
|
||||||
|
return None
|
||||||
|
cost_by_unit: Final = bedrock_guardrail_cost_by_unit(usage, aws_region_name)
|
||||||
|
name: Final = guardrail_identifier or pairs[0][0]
|
||||||
|
status: Final[GuardrailStatus] = (
|
||||||
|
"guardrail_intervened" if any(action in _INTERVENING_ACTIONS for action in _actions(guardrail)) else "success"
|
||||||
|
)
|
||||||
|
priced: Final[StandardLoggingGuardrailInformation] = (
|
||||||
|
{"guardrail_cost_by_unit": cost_by_unit} if cost_by_unit is not None else {}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"guardrail_name": name,
|
||||||
|
"guardrail_id": name,
|
||||||
|
"guardrail_provider": "bedrock",
|
||||||
|
"guardrail_mode": GuardrailEventHooks.during_call,
|
||||||
|
"guardrail_response": redact_nested_match_and_regex_keys(guardrail),
|
||||||
|
"guardrail_status": status,
|
||||||
|
"guardrail_usage": usage,
|
||||||
|
"guardrail_cost": guardrail_cost_total(cost_by_unit),
|
||||||
|
**priced,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def guardrail_entries(raw: object) -> tuple[object, ...]:
|
||||||
|
"""The entries under ``standard_logging_guardrail_information``, which older writers stored as one dict."""
|
||||||
|
single: Final = object_mapping(raw)
|
||||||
|
return (single,) if single is not None else object_items(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def with_guardrail_entry(
|
||||||
|
metadata: dict[str, object], # mutable-ok: the proxy's own request metadata, read later for headers and spend
|
||||||
|
entry: StandardLoggingGuardrailInformation,
|
||||||
|
) -> None:
|
||||||
|
"""Append ``entry`` to the metadata's ``standard_logging_guardrail_information`` once, so the same trace recorded
|
||||||
|
from the response headers path and again from the success handler bills a single time."""
|
||||||
|
existing: Final = guardrail_entries(metadata.get("standard_logging_guardrail_information"))
|
||||||
|
if entry in existing:
|
||||||
|
return
|
||||||
|
metadata["standard_logging_guardrail_information"] = [*existing, entry] # rebind-ok: appending here is the job
|
||||||
|
|
||||||
|
|
||||||
AZURE_PROMPT_SHIELD_TEXT_RECORD_UNIT: Final = "text_records"
|
AZURE_PROMPT_SHIELD_TEXT_RECORD_UNIT: Final = "text_records"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from collections.abc import Mapping, Sequence
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from typing import TYPE_CHECKING, Any, Final
|
from typing import TYPE_CHECKING, Any, Final
|
||||||
|
|
||||||
from pydantic import TypeAdapter
|
from pydantic import JsonValue, TypeAdapter, ValidationError
|
||||||
|
|
||||||
import litellm
|
import litellm
|
||||||
from litellm.constants import REDACTED_BY_LITELLM
|
from litellm.constants import REDACTED_BY_LITELLM
|
||||||
|
|
@ -120,11 +120,55 @@ def _redacted_streaming_response_copy(streaming_response: object) -> object:
|
||||||
return _redact_streaming_response(redacted_response)
|
return _redact_streaming_response(redacted_response)
|
||||||
|
|
||||||
|
|
||||||
|
BEDROCK_GUARDRAIL_TRACE_FIELDS: Final = ("trace", "amazon-bedrock-trace")
|
||||||
|
_MATCHED_TEXT_KEYS: Final = frozenset({"match", "regex"})
|
||||||
|
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
||||||
|
|
||||||
|
|
||||||
|
def _without_matched_text(trace: Mapping[str, JsonValue]) -> Mapping[str, JsonValue]:
|
||||||
|
redacted: Final = dict(copy.deepcopy(trace))
|
||||||
|
stack: Final[list[JsonValue]] = [redacted] # mutable-ok: iterative walk, recursive_detector bans recursion here
|
||||||
|
while stack:
|
||||||
|
node = stack.pop()
|
||||||
|
if isinstance(node, dict):
|
||||||
|
for key in _MATCHED_TEXT_KEYS & node.keys():
|
||||||
|
node[key] = "[REDACTED]"
|
||||||
|
stack.extend(node.values())
|
||||||
|
elif isinstance(node, list):
|
||||||
|
stack.extend(node)
|
||||||
|
return redacted
|
||||||
|
|
||||||
|
|
||||||
|
def _redacted_bedrock_trace(trace: object) -> object:
|
||||||
|
try:
|
||||||
|
return _without_matched_text(_JSON_OBJECT.validate_python(trace))
|
||||||
|
except ValidationError:
|
||||||
|
return trace
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_bedrock_guardrail_trace(response: object) -> None:
|
||||||
|
"""A Bedrock guardrail trace echoes the matched text of its sensitive-information and word findings back."""
|
||||||
|
mapping: Final = _as_mapping(response)
|
||||||
|
if isinstance(response, dict):
|
||||||
|
traces: Final = {
|
||||||
|
field: _redacted_bedrock_trace(value)
|
||||||
|
for field, value in (mapping or {}).items()
|
||||||
|
if field in BEDROCK_GUARDRAIL_TRACE_FIELDS
|
||||||
|
}
|
||||||
|
response.update(traces)
|
||||||
|
return
|
||||||
|
for field, value in ((name, getattr(response, name, None)) for name in BEDROCK_GUARDRAIL_TRACE_FIELDS):
|
||||||
|
if value is not None:
|
||||||
|
setattr(response, field, _redacted_bedrock_trace(value))
|
||||||
|
|
||||||
|
|
||||||
def _redact_streaming_response(streaming_response):
|
def _redact_streaming_response(streaming_response):
|
||||||
if hasattr(streaming_response, "choices"):
|
if hasattr(streaming_response, "choices"):
|
||||||
for choice in streaming_response.choices:
|
for choice in streaming_response.choices:
|
||||||
_redact_choice_content(choice)
|
_redact_choice_content(choice)
|
||||||
redact_vertex_ai_metadata_from_logged_object(streaming_response)
|
redact_vertex_ai_metadata_from_logged_object(streaming_response)
|
||||||
|
if isinstance(streaming_response, litellm.ModelResponse):
|
||||||
|
_redact_bedrock_guardrail_trace(streaming_response)
|
||||||
elif isinstance(streaming_response, litellm.ResponsesAPIResponse):
|
elif isinstance(streaming_response, litellm.ResponsesAPIResponse):
|
||||||
return perform_redaction({}, streaming_response)
|
return perform_redaction({}, streaming_response)
|
||||||
elif hasattr(streaming_response, "output"):
|
elif hasattr(streaming_response, "output"):
|
||||||
|
|
@ -351,6 +395,7 @@ def _redact_standard_logging_object(
|
||||||
if isinstance(response.get("choices"), list):
|
if isinstance(response.get("choices"), list):
|
||||||
_redact_model_response_dict_choices(response["choices"], redacted_str)
|
_redact_model_response_dict_choices(response["choices"], redacted_str)
|
||||||
redact_vertex_ai_metadata_from_logged_object(response)
|
redact_vertex_ai_metadata_from_logged_object(response)
|
||||||
|
_redact_bedrock_guardrail_trace(response)
|
||||||
elif isinstance(response, str):
|
elif isinstance(response, str):
|
||||||
standard_logging_object["response"] = redacted_str
|
standard_logging_object["response"] = redacted_str
|
||||||
else:
|
else:
|
||||||
|
|
@ -523,11 +568,13 @@ def perform_redaction(model_call_details: dict, result, redact_streaming_respons
|
||||||
for choice in _result.choices:
|
for choice in _result.choices:
|
||||||
_redact_choice_content(choice)
|
_redact_choice_content(choice)
|
||||||
redact_vertex_ai_metadata_from_logged_object(_result)
|
redact_vertex_ai_metadata_from_logged_object(_result)
|
||||||
|
_redact_bedrock_guardrail_trace(_result)
|
||||||
elif isinstance(_result, dict) and "choices" in _result:
|
elif isinstance(_result, dict) and "choices" in _result:
|
||||||
# Handle dict representation of ModelResponse (e.g., from model_dump())
|
# Handle dict representation of ModelResponse (e.g., from model_dump())
|
||||||
if _result.get("choices") is not None:
|
if _result.get("choices") is not None:
|
||||||
_redact_model_response_dict_choices(_result["choices"], REDACTED_BY_LITELLM)
|
_redact_model_response_dict_choices(_result["choices"], REDACTED_BY_LITELLM)
|
||||||
redact_vertex_ai_metadata_from_logged_object(_result)
|
redact_vertex_ai_metadata_from_logged_object(_result)
|
||||||
|
_redact_bedrock_guardrail_trace(_result)
|
||||||
elif isinstance(_result, dict) and "output" in _result:
|
elif isinstance(_result, dict) and "output" in _result:
|
||||||
return _redacted_responses_api_response(_result, redact_instructions=redact_streaming_responses)
|
return _redacted_responses_api_response(_result, redact_instructions=redact_streaming_responses)
|
||||||
elif isinstance(_result, litellm.ResponsesAPIResponse):
|
elif isinstance(_result, litellm.ResponsesAPIResponse):
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import litellm
|
||||||
from litellm._logging import verbose_logger
|
from litellm._logging import verbose_logger
|
||||||
from litellm.litellm_core_utils.core_helpers import chunk_usage, provider_reported_usage_cost
|
from litellm.litellm_core_utils.core_helpers import chunk_usage, provider_reported_usage_cost
|
||||||
from litellm.litellm_core_utils.hidden_params import HIDDEN_PARAMS_ATTR, set_hidden_params
|
from litellm.litellm_core_utils.hidden_params import HIDDEN_PARAMS_ATTR, set_hidden_params
|
||||||
|
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import BEDROCK_TRACE_FIELDS, object_items, object_mapping
|
||||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||||
get_content_from_model_response, # pyright: ignore[reportUnknownVariableType] # response param accepts an untyped dict
|
get_content_from_model_response, # pyright: ignore[reportUnknownVariableType] # response param accepts an untyped dict
|
||||||
)
|
)
|
||||||
|
|
@ -1484,6 +1485,35 @@ def _simple_text_part(choices: Sequence[object]) -> str | None:
|
||||||
return content if isinstance(content, str) else ""
|
return content if isinstance(content, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
_NO_TRACE_FIELDS: Final[Mapping[str, object]] = MappingProxyType({})
|
||||||
|
|
||||||
|
|
||||||
|
def _carry_bedrock_guardrail_trace(chunks: Sequence[object], response: ModelResponse) -> None:
|
||||||
|
"""Bedrock reports a guardrail trace once per stream, on the final metadata chunk, so the built response carries it
|
||||||
|
under the same field as the non-streaming response (``trace`` on Converse, ``amazon-bedrock-trace`` on Invoke)."""
|
||||||
|
traced: Final = map(_bedrock_trace_fields, reversed(chunks))
|
||||||
|
carried: Final = next((fields for fields in traced if fields), _NO_TRACE_FIELDS)
|
||||||
|
for field, trace in carried.items():
|
||||||
|
setattr(response, field, trace)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_bedrock_guardrail_trace(
|
||||||
|
chunks: Sequence[object], response: ModelResponse, logging_obj: Optional["Logging"]
|
||||||
|
) -> None:
|
||||||
|
_carry_bedrock_guardrail_trace(chunks, response)
|
||||||
|
if logging_obj is not None:
|
||||||
|
logging_obj.record_native_bedrock_guardrail(response)
|
||||||
|
|
||||||
|
|
||||||
|
def _bedrock_trace_fields(chunk: object) -> Mapping[str, object]:
|
||||||
|
as_mapping: Final = object_mapping(chunk)
|
||||||
|
raw: Final = (
|
||||||
|
as_mapping.get("provider_specific_fields") if as_mapping else getattr(chunk, "provider_specific_fields", None)
|
||||||
|
)
|
||||||
|
fields: Final[Mapping[str, object]] = object_mapping(raw) or {}
|
||||||
|
return {field: fields[field] for field in BEDROCK_TRACE_FIELDS if field in fields}
|
||||||
|
|
||||||
|
|
||||||
def stream_chunk_builder(
|
def stream_chunk_builder(
|
||||||
chunks: list,
|
chunks: list,
|
||||||
messages: Sequence | None = None,
|
messages: Sequence | None = None,
|
||||||
|
|
@ -1565,6 +1595,11 @@ def stream_chunk_builder(
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
_record_bedrock_guardrail_trace(
|
||||||
|
object_items(chunks), # pyright: ignore[reportUnknownArgumentType] # chunks is an untyped list param
|
||||||
|
response,
|
||||||
|
logging_obj,
|
||||||
|
)
|
||||||
_stamp_streaming_usage_cost(usage, response, logging_obj)
|
_stamp_streaming_usage_cost(usage, response, logging_obj)
|
||||||
_set_stream_builder_response_cost(
|
_set_stream_builder_response_cost(
|
||||||
response,
|
response,
|
||||||
|
|
@ -1760,6 +1795,11 @@ def stream_chunk_builder(
|
||||||
chunks, # pyright: ignore[reportUnknownArgumentType] # legacy chunk list is untyped
|
chunks, # pyright: ignore[reportUnknownArgumentType] # legacy chunk list is untyped
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_record_bedrock_guardrail_trace(
|
||||||
|
object_items(chunks), # pyright: ignore[reportUnknownArgumentType] # chunks is an untyped list param
|
||||||
|
response,
|
||||||
|
logging_obj,
|
||||||
|
)
|
||||||
processor.apply_provider_assembled_streaming_metadata(response, chunks, logging_obj)
|
processor.apply_provider_assembled_streaming_metadata(response, chunks, logging_obj)
|
||||||
return response
|
return response
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -1610,6 +1610,8 @@ class CustomStreamWrapper:
|
||||||
return _ProviderChunkParsed(response_obj)
|
return _ProviderChunkParsed(response_obj)
|
||||||
|
|
||||||
def chunk_creator(self, chunk: Any):
|
def chunk_creator(self, chunk: Any):
|
||||||
|
if isinstance(chunk, ModelResponseStream) and chunk.provider_specific_fields:
|
||||||
|
self.logging_obj.record_native_bedrock_guardrail(chunk.provider_specific_fields)
|
||||||
if self.custom_llm_provider != "cached_response" and isinstance(
|
if self.custom_llm_provider != "cached_response" and isinstance(
|
||||||
chunk, (ChatCompletionChunk, ModelResponseStream)
|
chunk, (ChatCompletionChunk, ModelResponseStream)
|
||||||
):
|
):
|
||||||
|
|
@ -1717,6 +1719,8 @@ class CustomStreamWrapper:
|
||||||
model_response.usage = Usage(**chunk_usage.model_dump())
|
model_response.usage = Usage(**chunk_usage.model_dump())
|
||||||
|
|
||||||
## RETURN ARG
|
## RETURN ARG
|
||||||
|
if isinstance(chunk, ModelResponseStream) and chunk.provider_specific_fields:
|
||||||
|
self.copy_model_response_level_provider_specific_fields(chunk, model_response)
|
||||||
result: Final = self.return_processed_chunk_logic(
|
result: Final = self.return_processed_chunk_logic(
|
||||||
completion_obj=completion_obj,
|
completion_obj=completion_obj,
|
||||||
model_response=model_response,
|
model_response=model_response,
|
||||||
|
|
|
||||||
|
|
@ -2859,6 +2859,8 @@ class AmazonConverseConfig(BaseConfig):
|
||||||
# Add "trace" from Bedrock guardrails - if user has opted in to returning it
|
# Add "trace" from Bedrock guardrails - if user has opted in to returning it
|
||||||
if "trace" in completion_response:
|
if "trace" in completion_response:
|
||||||
setattr(model_response, "trace", completion_response["trace"])
|
setattr(model_response, "trace", completion_response["trace"])
|
||||||
|
if logging_obj is not None:
|
||||||
|
logging_obj.record_native_bedrock_guardrail(model_response)
|
||||||
|
|
||||||
# Add service_tier if present in Bedrock response
|
# Add service_tier if present in Bedrock response
|
||||||
# Map Bedrock serviceTier (object) to OpenAI service_tier (string)
|
# Map Bedrock serviceTier (object) to OpenAI service_tier (string)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ from litellm.caching.caching import InMemoryCache
|
||||||
from litellm.constants import RESPONSE_FORMAT_TOOL_NAME
|
from litellm.constants import RESPONSE_FORMAT_TOOL_NAME
|
||||||
from litellm.litellm_core_utils.core_helpers import map_finish_reason
|
from litellm.litellm_core_utils.core_helpers import map_finish_reason
|
||||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||||
|
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import object_mapping
|
||||||
from litellm.litellm_core_utils.llm_response_utils.get_headers import provider_request_id_headers
|
from litellm.litellm_core_utils.llm_response_utils.get_headers import provider_request_id_headers
|
||||||
from litellm.litellm_core_utils.optional_imports import ensure_optional_import
|
from litellm.litellm_core_utils.optional_imports import ensure_optional_import
|
||||||
from litellm.litellm_core_utils.prompt_templates.common_utils import native_redacted_thinking_data
|
from litellm.litellm_core_utils.prompt_templates.common_utils import native_redacted_thinking_data
|
||||||
|
|
@ -22,6 +23,7 @@ from litellm.llms.anthropic.chat.handler import (
|
||||||
from litellm.llms.anthropic.chat.transformation import JsonModeRequest
|
from litellm.llms.anthropic.chat.transformation import JsonModeRequest
|
||||||
from litellm.llms.base_llm import base_model_iterator
|
from litellm.llms.base_llm import base_model_iterator
|
||||||
from litellm.llms.base_llm.base_utils import ResponseFormatSchema
|
from litellm.llms.base_llm.base_utils import ResponseFormatSchema
|
||||||
|
from litellm.llms.bedrock.common_utils import BEDROCK_INVOKE_TRACE_FIELD
|
||||||
from litellm.llms.custom_httpx.http_handler import (
|
from litellm.llms.custom_httpx.http_handler import (
|
||||||
AsyncHTTPHandler,
|
AsyncHTTPHandler,
|
||||||
HTTPHandler,
|
HTTPHandler,
|
||||||
|
|
@ -962,8 +964,19 @@ class AmazonAnthropicClaudeStreamDecoder(AWSEventStreamDecoder):
|
||||||
response_format_schema=response_format_schema,
|
response_format_schema=response_format_schema,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _chunk_parser(self, chunk_data: dict) -> ModelResponseStream:
|
def _chunk_parser(self, chunk_data: dict[str, object]) -> ModelResponseStream:
|
||||||
return self.anthropic_model_response_iterator.chunk_parser(chunk=chunk_data)
|
parsed: Final = self.anthropic_model_response_iterator.chunk_parser(chunk=chunk_data)
|
||||||
|
trace: Final = object_mapping(chunk_data.get(BEDROCK_INVOKE_TRACE_FIELD))
|
||||||
|
if trace is None:
|
||||||
|
return parsed
|
||||||
|
return parsed.model_copy(
|
||||||
|
update={
|
||||||
|
"provider_specific_fields": {
|
||||||
|
**(parsed.provider_specific_fields or {}),
|
||||||
|
BEDROCK_INVOKE_TRACE_FIELD: trace,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
def _stream_end_chunk(self) -> ModelResponseStream | None:
|
def _stream_end_chunk(self) -> ModelResponseStream | None:
|
||||||
return self.anthropic_model_response_iterator.cut_stream_chunk()
|
return self.anthropic_model_response_iterator.cut_stream_chunk()
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import httpx
|
||||||
from pydantic import TypeAdapter
|
from pydantic import TypeAdapter
|
||||||
|
|
||||||
from litellm.anthropic_beta_headers_manager import filter_and_transform_beta_headers
|
from litellm.anthropic_beta_headers_manager import filter_and_transform_beta_headers
|
||||||
|
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import object_mapping
|
||||||
from litellm.litellm_core_utils.prompt_templates.factory import (
|
from litellm.litellm_core_utils.prompt_templates.factory import (
|
||||||
convert_to_anthropic_image_obj,
|
convert_to_anthropic_image_obj,
|
||||||
)
|
)
|
||||||
|
|
@ -22,6 +23,7 @@ from litellm.llms.bedrock.chat.invoke_transformations.base_invoke_transformation
|
||||||
AmazonInvokeConfig,
|
AmazonInvokeConfig,
|
||||||
)
|
)
|
||||||
from litellm.llms.bedrock.common_utils import (
|
from litellm.llms.bedrock.common_utils import (
|
||||||
|
BEDROCK_INVOKE_TRACE_FIELD,
|
||||||
apply_bedrock_invoke_structured_output,
|
apply_bedrock_invoke_structured_output,
|
||||||
bedrock_chat_uses_native_structured_output,
|
bedrock_chat_uses_native_structured_output,
|
||||||
bedrock_cleaned_output_schema,
|
bedrock_cleaned_output_schema,
|
||||||
|
|
@ -406,7 +408,7 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
json_mode: bool | None = None,
|
json_mode: bool | None = None,
|
||||||
) -> ModelResponse:
|
) -> ModelResponse:
|
||||||
return AnthropicConfig.transform_response(
|
response: Final = AnthropicConfig.transform_response(
|
||||||
self,
|
self,
|
||||||
model=model,
|
model=model,
|
||||||
raw_response=raw_response,
|
raw_response=raw_response,
|
||||||
|
|
@ -420,3 +422,9 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
json_mode=json_mode,
|
json_mode=json_mode,
|
||||||
)
|
)
|
||||||
|
body: Final = object_mapping(raw_response.json()) or {}
|
||||||
|
trace: Final = object_mapping(body.get(BEDROCK_INVOKE_TRACE_FIELD))
|
||||||
|
if trace is not None:
|
||||||
|
setattr(response, BEDROCK_INVOKE_TRACE_FIELD, trace)
|
||||||
|
logging_obj.record_native_bedrock_guardrail(response)
|
||||||
|
return response
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ if TYPE_CHECKING:
|
||||||
from litellm.llms.custom_httpx.http_handler import HTTPHandler
|
from litellm.llms.custom_httpx.http_handler import HTTPHandler
|
||||||
from litellm.types.llms.openai import AllMessageValues
|
from litellm.types.llms.openai import AllMessageValues
|
||||||
|
|
||||||
|
BEDROCK_INVOKE_TRACE_FIELD: Final = "amazon-bedrock-trace"
|
||||||
|
|
||||||
|
|
||||||
_ERROR_REQUEST_URL: Final = "https://docs.litellm.ai/docs"
|
_ERROR_REQUEST_URL: Final = "https://docs.litellm.ai/docs"
|
||||||
_OPENAI_FAMILY_MODEL_RE: Final = re.compile(r"(^|[./])openai\.")
|
_OPENAI_FAMILY_MODEL_RE: Final = re.compile(r"(^|[./])openai\.")
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ from litellm.llms.base_llm.anthropic_messages.transformation import (
|
||||||
from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder
|
from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder
|
||||||
from litellm.llms.bedrock.chat.invoke_transformations.base_invoke_transformation import (
|
from litellm.llms.bedrock.chat.invoke_transformations.base_invoke_transformation import (
|
||||||
AmazonInvokeConfig,
|
AmazonInvokeConfig,
|
||||||
|
bedrock_invoke_guardrail_headers,
|
||||||
)
|
)
|
||||||
from litellm.llms.bedrock.common_utils import (
|
from litellm.llms.bedrock.common_utils import (
|
||||||
BedrockError,
|
BedrockError,
|
||||||
|
|
@ -151,8 +152,14 @@ class AmazonAnthropicClaudeMessagesConfig(
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
api_base: str | None = None,
|
api_base: str | None = None,
|
||||||
) -> tuple[dict, str | None]:
|
) -> tuple[dict, str | None]:
|
||||||
|
raw_guardrail_config: Final = litellm_params.get("guardrailConfig") # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType] # litellm_params is an untyped dict param
|
||||||
|
guardrail_headers: Final = (
|
||||||
|
()
|
||||||
|
if raw_guardrail_config is None
|
||||||
|
else tuple(bedrock_invoke_guardrail_headers(raw_guardrail_config).items()) # pyright: ignore[reportUnknownArgumentType] # read from the untyped dict param
|
||||||
|
)
|
||||||
owned_names, metadata_headers = bedrock_request_metadata_headers(litellm_params)
|
owned_names, metadata_headers = bedrock_request_metadata_headers(litellm_params)
|
||||||
return merge_bedrock_invoke_headers(headers, (), metadata_headers, owned_names), api_base
|
return merge_bedrock_invoke_headers(headers, guardrail_headers, metadata_headers, owned_names), api_base # pyright: ignore[reportUnknownArgumentType] # headers is an untyped dict param
|
||||||
|
|
||||||
def sign_request(
|
def sign_request(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -2705,6 +2705,7 @@ class BaseLLMHTTPHandler:
|
||||||
raw_response=response,
|
raw_response=response,
|
||||||
logging_obj=logging_obj,
|
logging_obj=logging_obj,
|
||||||
)
|
)
|
||||||
|
logging_obj.record_native_bedrock_guardrail(initial_response)
|
||||||
|
|
||||||
return await self._finalize_anthropic_messages_response(
|
return await self._finalize_anthropic_messages_response(
|
||||||
initial_response=initial_response,
|
initial_response=initial_response,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import math
|
||||||
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Coroutine, Mapping, Sequence
|
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Coroutine, Mapping, Sequence
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
from itertools import chain
|
||||||
from types import MappingProxyType
|
from types import MappingProxyType
|
||||||
from typing import (
|
from typing import (
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
|
|
@ -69,7 +70,11 @@ from litellm.litellm_core_utils.get_supported_openai_params import (
|
||||||
from litellm.litellm_core_utils.internal_call_metadata import is_unbilled_non_inference_call_from_params
|
from litellm.litellm_core_utils.internal_call_metadata import is_unbilled_non_inference_call_from_params
|
||||||
from litellm.litellm_core_utils.litellm_logging import BilledRoundsDispatch
|
from litellm.litellm_core_utils.litellm_logging import BilledRoundsDispatch
|
||||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||||
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import guardrail_information_cost
|
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import (
|
||||||
|
guardrail_entries,
|
||||||
|
guardrail_information_cost,
|
||||||
|
object_mapping,
|
||||||
|
)
|
||||||
from litellm.litellm_core_utils.llm_response_utils.get_headers import (
|
from litellm.litellm_core_utils.llm_response_utils.get_headers import (
|
||||||
get_response_headers,
|
get_response_headers,
|
||||||
)
|
)
|
||||||
|
|
@ -306,6 +311,23 @@ async def close_guarded_stream(stream: object) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _guardrail_entries_for_headers(
|
||||||
|
request_metadata_bucket: object, logging_obj: LiteLLMLoggingObj
|
||||||
|
) -> tuple[object, ...]:
|
||||||
|
"""Guardrail entries the proxy hooks wrote on the request plus the ones the call recorded on its own logging
|
||||||
|
object (a Bedrock deployment's native guardrail trace), each once: the hooks copy theirs into the logging object."""
|
||||||
|
litellm_params: Final = object_mapping(logging_obj.model_call_details.get("litellm_params")) or {}
|
||||||
|
buckets: Final = (object_mapping(request_metadata_bucket), object_mapping(litellm_params.get("metadata")))
|
||||||
|
entries: Final = tuple(
|
||||||
|
chain.from_iterable(
|
||||||
|
guardrail_entries(metadata.get("standard_logging_guardrail_information"))
|
||||||
|
for metadata in buckets
|
||||||
|
if metadata is not None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(entry for index, entry in enumerate(entries) if entry not in entries[:index])
|
||||||
|
|
||||||
|
|
||||||
def resolve_litellm_call_id(client_call_id: str | None) -> str:
|
def resolve_litellm_call_id(client_call_id: str | None) -> str:
|
||||||
if client_call_id is not None and 0 < len(client_call_id) <= MAX_LITELLM_CALL_ID_LENGTH:
|
if client_call_id is not None and 0 < len(client_call_id) <= MAX_LITELLM_CALL_ID_LENGTH:
|
||||||
return client_call_id
|
return client_call_id
|
||||||
|
|
@ -3238,9 +3260,9 @@ class ProxyBaseLLMRequestProcessing:
|
||||||
if is_unbilled_non_inference_call_from_params(logging_obj.call_type, logging_obj.litellm_params, response)
|
if is_unbilled_non_inference_call_from_params(logging_obj.call_type, logging_obj.litellm_params, response)
|
||||||
else computed_cost_for_headers
|
else computed_cost_for_headers
|
||||||
)
|
)
|
||||||
_, request_metadata_bucket = get_or_create_metadata_bucket(self.data)
|
request_metadata_bucket: Final[object] = get_or_create_metadata_bucket(self.data)[1] # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType] # self.data is an untyped dict
|
||||||
guardrail_cost_for_headers: Final = guardrail_information_cost(
|
guardrail_cost_for_headers: Final = guardrail_information_cost(
|
||||||
request_metadata_bucket.get("standard_logging_guardrail_information")
|
_guardrail_entries_for_headers(request_metadata_bucket, logging_obj) # pyright: ignore[reportUnknownArgumentType] # bucket comes from the untyped request dict
|
||||||
)
|
)
|
||||||
response_cost_for_headers: Final = (
|
response_cost_for_headers: Final = (
|
||||||
(llm_cost_for_headers if isinstance(llm_cost_for_headers, (int, float)) else 0.0)
|
(llm_cost_for_headers if isinstance(llm_cost_for_headers, (int, float)) else 0.0)
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ from litellm.llms.anthropic.messages.stream_assembly import (
|
||||||
collapse_pure_text_chunks,
|
collapse_pure_text_chunks,
|
||||||
split_sse_chunk_into_events,
|
split_sse_chunk_into_events,
|
||||||
)
|
)
|
||||||
|
from litellm.llms.bedrock.common_utils import BEDROCK_INVOKE_TRACE_FIELD
|
||||||
from litellm.proxy._types import PassThroughEndpointLoggingTypedDict
|
from litellm.proxy._types import PassThroughEndpointLoggingTypedDict
|
||||||
from litellm.proxy.auth.auth_utils import get_end_user_id_from_request_body
|
from litellm.proxy.auth.auth_utils import get_end_user_id_from_request_body
|
||||||
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.batch_attribution import (
|
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.batch_attribution import (
|
||||||
|
|
@ -70,6 +71,17 @@ def _event_payload(event: str) -> Mapping[str, object] | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _bedrock_trace_payload(all_chunks: Sequence[str | bytes]) -> Mapping[str, object] | None:
|
||||||
|
payloads: Final = (
|
||||||
|
_event_payload(event)
|
||||||
|
for chunk in reversed(all_chunks)
|
||||||
|
for event in reversed(split_sse_chunk_into_events(chunk))
|
||||||
|
) # comprehension-ok: chunk then event, read newest first
|
||||||
|
return next(
|
||||||
|
(payload for payload in payloads if payload is not None and BEDROCK_INVOKE_TRACE_FIELD in payload), None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _message_delta_usages(all_chunks: Sequence[str | bytes]) -> Iterator[object]:
|
def _message_delta_usages(all_chunks: Sequence[str | bytes]) -> Iterator[object]:
|
||||||
for chunk in reversed(all_chunks):
|
for chunk in reversed(all_chunks):
|
||||||
yield from (
|
yield from (
|
||||||
|
|
@ -141,6 +153,7 @@ class AnthropicPassthroughLoggingHandler:
|
||||||
json_mode=False,
|
json_mode=False,
|
||||||
litellm_params={},
|
litellm_params={},
|
||||||
)
|
)
|
||||||
|
logging_obj.record_native_bedrock_guardrail(response_body) # pyright: ignore[reportUnknownArgumentType] # response_body is an untyped dict param
|
||||||
|
|
||||||
kwargs = AnthropicPassthroughLoggingHandler._create_anthropic_response_logging_payload(
|
kwargs = AnthropicPassthroughLoggingHandler._create_anthropic_response_logging_payload(
|
||||||
litellm_model_response=litellm_model_response,
|
litellm_model_response=litellm_model_response,
|
||||||
|
|
@ -546,6 +559,7 @@ class AnthropicPassthroughLoggingHandler:
|
||||||
"result": None,
|
"result": None,
|
||||||
"kwargs": {},
|
"kwargs": {},
|
||||||
}
|
}
|
||||||
|
litellm_logging_obj.record_native_bedrock_guardrail(_bedrock_trace_payload(all_chunks))
|
||||||
kwargs: Final = AnthropicPassthroughLoggingHandler._create_anthropic_response_logging_payload(
|
kwargs: Final = AnthropicPassthroughLoggingHandler._create_anthropic_response_logging_payload(
|
||||||
litellm_model_response=complete_streaming_response,
|
litellm_model_response=complete_streaming_response,
|
||||||
model=model,
|
model=model,
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,6 @@ class LiteLLMCompletionTransformationHandler:
|
||||||
litellm_completion_response: Final[ModelResponse | litellm.CustomStreamWrapper] = await litellm.acompletion(
|
litellm_completion_response: Final[ModelResponse | litellm.CustomStreamWrapper] = await litellm.acompletion(
|
||||||
**acompletion_args,
|
**acompletion_args,
|
||||||
)
|
)
|
||||||
|
|
||||||
if isinstance(litellm_completion_response, ModelResponse):
|
if isinstance(litellm_completion_response, ModelResponse):
|
||||||
responses_api_response: Final[ResponsesAPIResponse] = (
|
responses_api_response: Final[ResponsesAPIResponse] = (
|
||||||
LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
|
LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,423 @@
|
||||||
|
"""A Bedrock deployment whose ``guardrailConfig`` has ``trace: enabled`` bills the policy units Bedrock reports in the
|
||||||
|
trace as guardrail cost, on every surface the deployment serves: ``/v1/chat/completions`` and ``/v1/responses`` over
|
||||||
|
Converse and Invoke, streamed and not, and the four ``/bedrock/model/{model}`` pass-through actions. The cost rides
|
||||||
|
``x-litellm-response-cost`` on non-streaming responses and the spend row's ``spend`` and ``guardrail_information``
|
||||||
|
everywhere. ``trace: disabled`` leaves no usage, so nothing is billed beyond the tokens (LIT-10101)"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Iterator, Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from hashlib import sha256
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
from botocore.config import Config
|
||||||
|
from integration._support.budget import case_timeout_seconds
|
||||||
|
from integration._support.client import Gateway, Scenario, eventually
|
||||||
|
from integration._support.process import graceful_stop_seconds, owned_proxy
|
||||||
|
from integration._support.upstream import _aws_event_frame
|
||||||
|
from integration._support.wire import Reply, Request, Wire, wire_server
|
||||||
|
from pydantic import JsonValue, TypeAdapter
|
||||||
|
|
||||||
|
import litellm
|
||||||
|
|
||||||
|
pytestmark: Final = pytest.mark.timeout(2 * graceful_stop_seconds() + 240)
|
||||||
|
|
||||||
|
_MODEL_ID: Final = "anthropic.claude-sonnet-4-5-20250929-v1:0"
|
||||||
|
_EVENT_STREAM: Final = "application/vnd.amazon.eventstream"
|
||||||
|
_GUARDRAIL_ID: Final = "native-guardrail"
|
||||||
|
_INPUT_COST_PER_TOKEN: Final = 0.001
|
||||||
|
_OUTPUT_COST_PER_TOKEN: Final = 0.002
|
||||||
|
_PROMPT_TOKENS: Final = 5
|
||||||
|
_COMPLETION_TOKENS: Final = 1
|
||||||
|
_INPUT_USAGE: Final[Mapping[str, int]] = {"topicPolicyUnits": 1, "contentPolicyUnits": 1, "wordPolicyUnits": 1}
|
||||||
|
_OUTPUT_USAGE: Final[Mapping[str, int]] = {"topicPolicyUnits": 2, "sensitiveInformationPolicyUnits": 2}
|
||||||
|
_SUMMED_USAGE: Final[Mapping[str, int]] = {
|
||||||
|
"topicPolicyUnits": 3,
|
||||||
|
"contentPolicyUnits": 1,
|
||||||
|
"wordPolicyUnits": 1,
|
||||||
|
"sensitiveInformationPolicyUnits": 2,
|
||||||
|
}
|
||||||
|
_ACTIONS: Final = ("converse", "converse-stream", "invoke", "invoke-with-response-stream")
|
||||||
|
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
||||||
|
_SPEND_SQL: Final = 'SELECT spend, metadata FROM "LiteLLM_SpendLogs" WHERE api_key=%s ORDER BY "startTime"'
|
||||||
|
|
||||||
|
|
||||||
|
def _llm_cost() -> float:
|
||||||
|
return _PROMPT_TOKENS * _INPUT_COST_PER_TOKEN + _COMPLETION_TOKENS * _OUTPUT_COST_PER_TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
def _guardrail_cost() -> float:
|
||||||
|
"""What the proxy's own cost map prices the traced units at, so the test owns the relation, not AWS's rates."""
|
||||||
|
rates: Final = litellm.model_cost["bedrock/guardrails"]["guardrail_cost_per_unit"]
|
||||||
|
return sum(units * rates[counter] for counter, units in _SUMMED_USAGE.items())
|
||||||
|
|
||||||
|
|
||||||
|
def _action(request: Request) -> str:
|
||||||
|
return request.target.partition("?")[0].rsplit("/", 1)[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _traced(request: Request) -> bool:
|
||||||
|
if _action(request).startswith("converse"):
|
||||||
|
config: Final = json.loads(request.body).get("guardrailConfig") or {}
|
||||||
|
return config.get("trace") == "enabled"
|
||||||
|
return request.headers.get("x-amzn-bedrock-trace", "").upper() == "ENABLED"
|
||||||
|
|
||||||
|
|
||||||
|
def _assessment(usage: Mapping[str, int]) -> dict[str, JsonValue]:
|
||||||
|
return {"invocationMetrics": {"usage": dict(usage), "guardrailProcessingLatency": 7}}
|
||||||
|
|
||||||
|
|
||||||
|
def _converse_trace() -> dict[str, JsonValue]:
|
||||||
|
return {
|
||||||
|
"guardrail": {
|
||||||
|
"inputAssessment": {_GUARDRAIL_ID: _assessment(_INPUT_USAGE)},
|
||||||
|
"outputAssessments": {_GUARDRAIL_ID: [_assessment(_OUTPUT_USAGE)]},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _invoke_trace() -> dict[str, JsonValue]:
|
||||||
|
return {
|
||||||
|
"guardrail": {
|
||||||
|
"input": {_GUARDRAIL_ID: _assessment(_INPUT_USAGE)},
|
||||||
|
"outputs": [{_GUARDRAIL_ID: _assessment(_OUTPUT_USAGE)}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _bedrock(request: Request) -> Reply:
|
||||||
|
"""Bedrock's runtime as the caller sees it: a one-token answer, and the guardrail trace with its policy units only
|
||||||
|
when the request enabled tracing, in the shape of the action that served it."""
|
||||||
|
traced: Final = _traced(request)
|
||||||
|
action: Final = _action(request)
|
||||||
|
usage: Final = {"inputTokens": _PROMPT_TOKENS, "outputTokens": _COMPLETION_TOKENS, "totalTokens": 6}
|
||||||
|
converse_trace: Final = {"trace": _converse_trace()} if traced else {}
|
||||||
|
if action == "converse":
|
||||||
|
body: Final = {
|
||||||
|
"output": {"message": {"role": "assistant", "content": [{"text": "ok"}]}},
|
||||||
|
"stopReason": "end_turn",
|
||||||
|
"usage": usage,
|
||||||
|
"metrics": {"latencyMs": 3},
|
||||||
|
**converse_trace,
|
||||||
|
}
|
||||||
|
return Reply(body=json.dumps(body).encode())
|
||||||
|
if action == "converse-stream" and "toolConfig" in json.loads(request.body):
|
||||||
|
return Reply(
|
||||||
|
chunks=(
|
||||||
|
_aws_event_frame("messageStart", {"role": "assistant"}, "sc", "u"),
|
||||||
|
_aws_event_frame(
|
||||||
|
"contentBlockStart",
|
||||||
|
{"start": {"toolUse": {"toolUseId": "tool-1", "name": "lookup"}}, "contentBlockIndex": 0},
|
||||||
|
"sc",
|
||||||
|
"u",
|
||||||
|
),
|
||||||
|
_aws_event_frame(
|
||||||
|
"contentBlockDelta",
|
||||||
|
{"delta": {"toolUse": {"input": '{"q": "x"}'}}, "contentBlockIndex": 0},
|
||||||
|
"sc",
|
||||||
|
"u",
|
||||||
|
),
|
||||||
|
_aws_event_frame("contentBlockStop", {"contentBlockIndex": 0}, "sc", "u"),
|
||||||
|
_aws_event_frame("messageStop", {"stopReason": "tool_use"}, "sc", "u"),
|
||||||
|
_aws_event_frame(
|
||||||
|
"metadata", {"usage": usage, "metrics": {"latencyMs": 3}, **converse_trace}, "sc", "u"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
content_type=_EVENT_STREAM,
|
||||||
|
)
|
||||||
|
if action == "converse-stream":
|
||||||
|
return Reply(
|
||||||
|
chunks=(
|
||||||
|
_aws_event_frame("messageStart", {"role": "assistant"}, "sc", "u"),
|
||||||
|
_aws_event_frame("contentBlockDelta", {"delta": {"text": "ok"}, "contentBlockIndex": 0}, "sc", "u"),
|
||||||
|
_aws_event_frame("contentBlockStop", {"contentBlockIndex": 0}, "sc", "u"),
|
||||||
|
_aws_event_frame("messageStop", {"stopReason": "end_turn"}, "sc", "u"),
|
||||||
|
_aws_event_frame(
|
||||||
|
"metadata", {"usage": usage, "metrics": {"latencyMs": 3}, **converse_trace}, "sc", "u"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
content_type=_EVENT_STREAM,
|
||||||
|
)
|
||||||
|
invoke_trace: Final = {"amazon-bedrock-trace": _invoke_trace()} if traced else {}
|
||||||
|
if action == "invoke":
|
||||||
|
message: Final = {
|
||||||
|
"id": f"msg_{uuid.uuid4().hex[:12]}",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"model": "claude",
|
||||||
|
"content": [{"type": "text", "text": "ok"}],
|
||||||
|
"stop_reason": "end_turn",
|
||||||
|
"stop_sequence": None,
|
||||||
|
"usage": {"input_tokens": _PROMPT_TOKENS, "output_tokens": _COMPLETION_TOKENS},
|
||||||
|
**invoke_trace,
|
||||||
|
}
|
||||||
|
return Reply(body=json.dumps(message).encode())
|
||||||
|
events: Final = (
|
||||||
|
{
|
||||||
|
"type": "message_start",
|
||||||
|
"message": {
|
||||||
|
"id": f"msg_{uuid.uuid4().hex[:12]}",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"model": "claude",
|
||||||
|
"content": [],
|
||||||
|
"stop_reason": None,
|
||||||
|
"stop_sequence": None,
|
||||||
|
"usage": {"input_tokens": _PROMPT_TOKENS, "output_tokens": 0},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
|
||||||
|
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "ok"}},
|
||||||
|
{"type": "content_block_stop", "index": 0},
|
||||||
|
{
|
||||||
|
"type": "message_delta",
|
||||||
|
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
|
||||||
|
"usage": {"output_tokens": _COMPLETION_TOKENS},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "message_stop",
|
||||||
|
"amazon-bedrock-invocationMetrics": {
|
||||||
|
"inputTokenCount": _PROMPT_TOKENS,
|
||||||
|
"outputTokenCount": _COMPLETION_TOKENS,
|
||||||
|
"invocationLatency": 3,
|
||||||
|
"firstByteLatency": 1,
|
||||||
|
},
|
||||||
|
**invoke_trace,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return Reply(
|
||||||
|
chunks=tuple(
|
||||||
|
_aws_event_frame("chunk", {"bytes": base64.b64encode(json.dumps(event).encode()).decode()}, "sc", "u")
|
||||||
|
for event in events
|
||||||
|
),
|
||||||
|
content_type=_EVENT_STREAM,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _deployment(wire: Wire, model: str, trace: str) -> Mapping[str, JsonValue]:
|
||||||
|
return {
|
||||||
|
"model": model,
|
||||||
|
"api_base": wire.url,
|
||||||
|
"aws_access_key_id": "AKIASCRIPTEDPROVIDER",
|
||||||
|
"aws_secret_access_key": "scripted-secret",
|
||||||
|
"aws_region_name": "us-east-1",
|
||||||
|
"use_in_pass_through": True,
|
||||||
|
"input_cost_per_token": _INPUT_COST_PER_TOKEN,
|
||||||
|
"output_cost_per_token": _OUTPUT_COST_PER_TOKEN,
|
||||||
|
"guardrailConfig": {"guardrailIdentifier": _GUARDRAIL_ID, "guardrailVersion": "1", "trace": trace},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Rig:
|
||||||
|
proxy: Gateway
|
||||||
|
scenario: Scenario
|
||||||
|
converse_model: str
|
||||||
|
invoke_model: str
|
||||||
|
untraced_model: str
|
||||||
|
|
||||||
|
def key(self) -> str:
|
||||||
|
return self.scenario.key(key_alias=f"native-trace-{uuid.uuid4().hex[:8]}")
|
||||||
|
|
||||||
|
def runtime(self, monkeypatch: pytest.MonkeyPatch, key: str): # noqa: ANN201 # boto3 clients are untyped
|
||||||
|
monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", key)
|
||||||
|
return boto3.client(
|
||||||
|
"bedrock-runtime",
|
||||||
|
endpoint_url=f"{str(self.proxy.client.base_url).rstrip('/')}/bedrock",
|
||||||
|
region_name="us-east-1",
|
||||||
|
config=Config(read_timeout=case_timeout_seconds(), retries={"total_max_attempts": 1}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def rig(session_gateway: Gateway, tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]:
|
||||||
|
suffix: Final = uuid.uuid4().hex[:12]
|
||||||
|
converse_model: Final = f"bedrock-traced-converse-{suffix}"
|
||||||
|
invoke_model: Final = f"bedrock-traced-invoke-{suffix}"
|
||||||
|
untraced_model: Final = f"bedrock-untraced-{suffix}"
|
||||||
|
directory: Final = tmp_path_factory.mktemp("bedrock-native-guardrail-trace")
|
||||||
|
base_config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
|
||||||
|
with wire_server(_bedrock) as wire:
|
||||||
|
config: Final = {
|
||||||
|
**base_config,
|
||||||
|
"model_list": [
|
||||||
|
*base_config.get("model_list", []),
|
||||||
|
{"model_name": converse_model, "litellm_params": _deployment(wire, f"bedrock/{_MODEL_ID}", "enabled")},
|
||||||
|
{
|
||||||
|
"model_name": invoke_model,
|
||||||
|
"litellm_params": _deployment(wire, f"bedrock/invoke/{_MODEL_ID}", "enabled"),
|
||||||
|
},
|
||||||
|
{"model_name": untraced_model, "litellm_params": _deployment(wire, f"bedrock/{_MODEL_ID}", "disabled")},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
config_path: Final = directory / "bedrock-native-guardrail-trace.yaml"
|
||||||
|
config_path.write_text(yaml.safe_dump(config))
|
||||||
|
with owned_proxy(session_gateway, directory, {}, config=config_path) as proxy, proxy.scenario() as scenario:
|
||||||
|
yield Rig(
|
||||||
|
proxy=proxy,
|
||||||
|
scenario=scenario,
|
||||||
|
converse_model=converse_model,
|
||||||
|
invoke_model=invoke_model,
|
||||||
|
untraced_model=untraced_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SpendRow:
|
||||||
|
spend: float
|
||||||
|
guardrail_information: tuple[dict[str, JsonValue], ...]
|
||||||
|
|
||||||
|
|
||||||
|
def _spend_row(proxy: Gateway, key: str) -> SpendRow:
|
||||||
|
digest: Final = sha256(key.encode()).hexdigest()
|
||||||
|
(row,) = eventually(lambda: proxy.rows(_SPEND_SQL, (digest,)), lambda rows: len(rows) == 1, seconds=70)
|
||||||
|
metadata: Final = _JSON_OBJECT.validate_python(
|
||||||
|
json.loads(row["metadata"]) if isinstance(row["metadata"], str) else row["metadata"]
|
||||||
|
)
|
||||||
|
raw_information: Final = metadata.get("guardrail_information")
|
||||||
|
entries: Final = raw_information if isinstance(raw_information, list) else [raw_information]
|
||||||
|
return SpendRow(
|
||||||
|
spend=float(str(row["spend"])),
|
||||||
|
guardrail_information=tuple(_JSON_OBJECT.validate_python(entry) for entry in entries if entry is not None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_billed_as_guardrail_cost(row: SpendRow) -> None:
|
||||||
|
assert row.spend == pytest.approx(_llm_cost() + _guardrail_cost())
|
||||||
|
(entry,) = row.guardrail_information
|
||||||
|
assert entry["guardrail_provider"] == "bedrock"
|
||||||
|
assert entry["guardrail_name"] == _GUARDRAIL_ID
|
||||||
|
assert entry["guardrail_usage"] == _SUMMED_USAGE
|
||||||
|
assert isinstance(entry["guardrail_cost"], float) and entry["guardrail_cost"] == pytest.approx(_guardrail_cost())
|
||||||
|
|
||||||
|
|
||||||
|
def _chat_body(model: str, stream: bool) -> dict[str, JsonValue]:
|
||||||
|
return {"model": model, "messages": [{"role": "user", "content": "hello"}], "max_tokens": 16, "stream": stream}
|
||||||
|
|
||||||
|
|
||||||
|
def _responses_body(model: str, stream: bool) -> dict[str, JsonValue]:
|
||||||
|
return {"model": model, "input": "hello", "stream": stream}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("api", ["converse", "invoke"])
|
||||||
|
@pytest.mark.parametrize("endpoint", ["/v1/chat/completions", "/v1/responses"])
|
||||||
|
def test_non_streaming_response_prices_the_trace_into_the_cost_header_and_the_spend_row(
|
||||||
|
rig: Rig, endpoint: str, api: str
|
||||||
|
) -> None:
|
||||||
|
key: Final = rig.key()
|
||||||
|
model: Final = rig.converse_model if api == "converse" else rig.invoke_model
|
||||||
|
body: Final = _chat_body(model, False) if endpoint == "/v1/chat/completions" else _responses_body(model, False)
|
||||||
|
response: Final = rig.proxy.request("POST", endpoint, body, key=key)
|
||||||
|
assert response.is_success, response.text[:400]
|
||||||
|
assert float(response.headers["x-litellm-response-cost"]) == pytest.approx(_llm_cost() + _guardrail_cost())
|
||||||
|
_assert_billed_as_guardrail_cost(_spend_row(rig.proxy, key))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("api", ["converse", "invoke"])
|
||||||
|
@pytest.mark.parametrize("endpoint", ["/v1/chat/completions", "/v1/responses"])
|
||||||
|
def test_streamed_response_prices_the_trace_of_the_final_event_into_the_spend_row(
|
||||||
|
rig: Rig, endpoint: str, api: str
|
||||||
|
) -> None:
|
||||||
|
key: Final = rig.key()
|
||||||
|
model: Final = rig.converse_model if api == "converse" else rig.invoke_model
|
||||||
|
body: Final = _chat_body(model, True) if endpoint == "/v1/chat/completions" else _responses_body(model, True)
|
||||||
|
with rig.proxy.client.stream(
|
||||||
|
"POST", endpoint, json=body, headers={"Authorization": f"Bearer {key}"}, timeout=case_timeout_seconds()
|
||||||
|
) as response:
|
||||||
|
frames: Final = tuple(line for line in response.iter_lines() if line.startswith("data:"))
|
||||||
|
assert response.is_success and frames
|
||||||
|
_assert_billed_as_guardrail_cost(_spend_row(rig.proxy, key))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("action", _ACTIONS)
|
||||||
|
def test_passthrough_action_prices_the_trace_into_the_spend_row(
|
||||||
|
rig: Rig, monkeypatch: pytest.MonkeyPatch, action: str
|
||||||
|
) -> None:
|
||||||
|
key: Final = rig.key()
|
||||||
|
runtime: Final = rig.runtime(monkeypatch, key)
|
||||||
|
messages: Final = [{"role": "user", "content": [{"text": "hello"}]}]
|
||||||
|
invoke_body: Final = json.dumps(
|
||||||
|
{
|
||||||
|
"anthropic_version": "bedrock-2023-05-31",
|
||||||
|
"max_tokens": 16,
|
||||||
|
"messages": [{"role": "user", "content": "hello"}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if action == "converse":
|
||||||
|
getattr(runtime, "converse")(modelId=rig.converse_model, messages=messages)
|
||||||
|
elif action == "converse-stream":
|
||||||
|
list(getattr(runtime, "converse_stream")(modelId=rig.converse_model, messages=messages)["stream"])
|
||||||
|
elif action == "invoke":
|
||||||
|
getattr(runtime, "invoke_model")(modelId=rig.converse_model, body=invoke_body)["body"].read()
|
||||||
|
else:
|
||||||
|
list(
|
||||||
|
getattr(runtime, "invoke_model_with_response_stream")(modelId=rig.converse_model, body=invoke_body)["body"]
|
||||||
|
)
|
||||||
|
_assert_billed_as_guardrail_cost(_spend_row(rig.proxy, key))
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_trace_bills_only_the_tokens(rig: Rig) -> None:
|
||||||
|
key: Final = rig.key()
|
||||||
|
response: Final = rig.proxy.request("POST", "/v1/chat/completions", _chat_body(rig.untraced_model, False), key=key)
|
||||||
|
assert response.is_success, response.text[:400]
|
||||||
|
assert "trace" not in response.json()
|
||||||
|
assert float(response.headers["x-litellm-response-cost"]) == pytest.approx(_llm_cost())
|
||||||
|
row: Final = _spend_row(rig.proxy, key)
|
||||||
|
assert row.spend == pytest.approx(_llm_cost())
|
||||||
|
assert row.guardrail_information == ()
|
||||||
|
|
||||||
|
|
||||||
|
def _messages_body(model: str, stream: bool) -> dict[str, JsonValue]:
|
||||||
|
return {
|
||||||
|
"model": model,
|
||||||
|
"messages": [{"role": "user", "content": f"hello from the messages api, stream={stream}"}],
|
||||||
|
"max_tokens": 16,
|
||||||
|
"stream": stream,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_messages_non_streaming_response_applies_the_guardrail_and_prices_its_trace(rig: Rig) -> None:
|
||||||
|
key: Final = rig.key()
|
||||||
|
response: Final = rig.proxy.request("POST", "/v1/messages", _messages_body(rig.invoke_model, False), key=key)
|
||||||
|
assert response.is_success, response.text[:400]
|
||||||
|
assert "amazon-bedrock-trace" in response.json()
|
||||||
|
_assert_billed_as_guardrail_cost(_spend_row(rig.proxy, key))
|
||||||
|
|
||||||
|
|
||||||
|
def test_messages_streamed_response_prices_the_trace_of_the_message_stop_event(rig: Rig) -> None:
|
||||||
|
key: Final = rig.key()
|
||||||
|
with rig.proxy.client.stream(
|
||||||
|
"POST",
|
||||||
|
"/v1/messages",
|
||||||
|
json=_messages_body(rig.invoke_model, True),
|
||||||
|
headers={"Authorization": f"Bearer {key}"},
|
||||||
|
timeout=case_timeout_seconds(),
|
||||||
|
) as response:
|
||||||
|
body: Final = b"".join(response.iter_bytes())
|
||||||
|
assert response.is_success and b"message_stop" in body, body[:400]
|
||||||
|
_assert_billed_as_guardrail_cost(_spend_row(rig.proxy, key))
|
||||||
|
|
||||||
|
|
||||||
|
def test_passthrough_streamed_tool_call_prices_the_trace_of_the_final_event(
|
||||||
|
rig: Rig, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
key: Final = rig.key()
|
||||||
|
runtime: Final = rig.runtime(monkeypatch, key)
|
||||||
|
tool_config: Final = {
|
||||||
|
"tools": [{"toolSpec": {"name": "lookup", "inputSchema": {"json": {"type": "object", "properties": {}}}}}]
|
||||||
|
}
|
||||||
|
events: Final = list(
|
||||||
|
getattr(runtime, "converse_stream")(
|
||||||
|
modelId=rig.converse_model,
|
||||||
|
messages=[{"role": "user", "content": [{"text": "hello"}]}],
|
||||||
|
toolConfig=tool_config,
|
||||||
|
)["stream"]
|
||||||
|
)
|
||||||
|
assert any("contentBlockStart" in event for event in events)
|
||||||
|
_assert_billed_as_guardrail_cost(_spend_row(rig.proxy, key))
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
|
import json
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from pydantic import JsonValue
|
||||||
|
|
||||||
import litellm
|
import litellm
|
||||||
|
from litellm.litellm_core_utils.llm_cost_calc import guardrail_cost
|
||||||
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import (
|
from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import (
|
||||||
bedrock_guardrail_cost,
|
bedrock_guardrail_cost,
|
||||||
bedrock_guardrail_cost_by_unit,
|
bedrock_guardrail_cost_by_unit,
|
||||||
|
|
@ -230,3 +234,118 @@ def test_guardrail_information_cost_skips_malformed_entry_keeps_siblings():
|
||||||
]
|
]
|
||||||
assert guardrail_information_cost(entries) == pytest.approx(0.0003)
|
assert guardrail_information_cost(entries) == pytest.approx(0.0003)
|
||||||
assert guardrail_information_cost({"guardrail_cost": 0.5, "guardrail_cost_in_spend": "maybe"}) == 0.0
|
assert guardrail_information_cost({"guardrail_cost": 0.5, "guardrail_cost_in_spend": "maybe"}) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
_TRACE_USAGE_IN: Final = {
|
||||||
|
"topicPolicyUnits": 1,
|
||||||
|
"contentPolicyUnits": 1,
|
||||||
|
"wordPolicyUnits": 1,
|
||||||
|
"sensitiveInformationPolicyUnits": 0,
|
||||||
|
"contextualGroundingPolicyUnits": 0,
|
||||||
|
"automatedReasoningPolicies": 0,
|
||||||
|
}
|
||||||
|
_TRACE_USAGE_OUT: Final = {
|
||||||
|
**_TRACE_USAGE_IN,
|
||||||
|
"topicPolicyUnits": 2,
|
||||||
|
"contentPolicyUnits": 2,
|
||||||
|
"sensitiveInformationPolicyUnits": 2,
|
||||||
|
}
|
||||||
|
_TRACE_PRICING: Final = {
|
||||||
|
"bedrock/guardrails": {
|
||||||
|
"litellm_provider": "bedrock",
|
||||||
|
"mode": "guardrails",
|
||||||
|
"guardrail_cost_per_unit": {
|
||||||
|
"topicPolicyUnits": 0.00015,
|
||||||
|
"contentPolicyUnits": 0.00015,
|
||||||
|
"wordPolicyUnits": 0.0,
|
||||||
|
"sensitiveInformationPolicyUnits": 0.0001,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _converse_trace(action: str = "NONE") -> dict[str, JsonValue]:
|
||||||
|
return {
|
||||||
|
"guardrail": {
|
||||||
|
"inputAssessment": {"gid": {"invocationMetrics": {"usage": _TRACE_USAGE_IN}}},
|
||||||
|
"outputAssessments": {
|
||||||
|
"gid": [
|
||||||
|
{
|
||||||
|
"topicPolicy": {"topics": [{"name": "t", "type": "DENY", "action": action}]},
|
||||||
|
"invocationMetrics": {"usage": _TRACE_USAGE_OUT},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _invoke_trace() -> dict[str, JsonValue]:
|
||||||
|
return {
|
||||||
|
"guardrail": {
|
||||||
|
"input": {"gid": {"invocationMetrics": {"usage": _TRACE_USAGE_IN}}},
|
||||||
|
"outputs": [{"gid": {"invocationMetrics": {"usage": _TRACE_USAGE_OUT}}}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("trace", [_converse_trace(), _invoke_trace()], ids=["converse", "invoke"])
|
||||||
|
def test_native_trace_sums_every_assessment_and_prices_it_like_the_proxy_guardrail(monkeypatch, trace) -> None:
|
||||||
|
monkeypatch.setattr(litellm, "model_cost", _TRACE_PRICING)
|
||||||
|
entry: Final = guardrail_cost.native_bedrock_guardrail_information(
|
||||||
|
trace, aws_region_name=None, guardrail_identifier=None
|
||||||
|
)
|
||||||
|
assert entry is not None
|
||||||
|
assert entry["guardrail_usage"] == {
|
||||||
|
"topicPolicyUnits": 3,
|
||||||
|
"contentPolicyUnits": 3,
|
||||||
|
"wordPolicyUnits": 2,
|
||||||
|
"sensitiveInformationPolicyUnits": 2,
|
||||||
|
"contextualGroundingPolicyUnits": 0,
|
||||||
|
}
|
||||||
|
assert entry["guardrail_cost"] == pytest.approx(3 * 0.00015 + 3 * 0.00015 + 2 * 0.0001)
|
||||||
|
assert entry["guardrail_cost_by_unit"]["contextualGroundingPolicyUnits"] is None
|
||||||
|
assert entry["guardrail_name"] == "gid"
|
||||||
|
assert entry["guardrail_provider"] == "bedrock"
|
||||||
|
assert entry["guardrail_status"] == "success"
|
||||||
|
assert guardrail_cost.guardrail_information_cost([entry]) == pytest.approx(entry["guardrail_cost"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_trace_marks_a_blocked_assessment_intervened_and_prefers_the_configured_identifier(monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(litellm, "model_cost", _TRACE_PRICING)
|
||||||
|
entry: Final = guardrail_cost.native_bedrock_guardrail_information(
|
||||||
|
_converse_trace("BLOCKED"), aws_region_name="us-east-1", guardrail_identifier="configured-id"
|
||||||
|
)
|
||||||
|
assert entry is not None
|
||||||
|
assert entry["guardrail_status"] == "guardrail_intervened"
|
||||||
|
assert entry["guardrail_name"] == "configured-id"
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_trace_without_usage_is_not_billed(monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(litellm, "model_cost", _TRACE_PRICING)
|
||||||
|
unpriced: Final = {"guardrail": {"inputAssessment": {"gid": {"topicPolicy": {"topics": []}}}}}
|
||||||
|
assert guardrail_cost.native_bedrock_guardrail_information(unpriced, None, None) is None
|
||||||
|
assert guardrail_cost.bedrock_guardrail_trace({"usage": {}}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_with_guardrail_entry_records_the_same_trace_once() -> None:
|
||||||
|
entry: Final = {"guardrail_name": "gid", "guardrail_cost": 0.001}
|
||||||
|
metadata: dict = {"standard_logging_guardrail_information": {"guardrail_name": "hook"}}
|
||||||
|
guardrail_cost.with_guardrail_entry(metadata, entry) # pyright: ignore[reportArgumentType] # partial entry
|
||||||
|
guardrail_cost.with_guardrail_entry(metadata, entry) # pyright: ignore[reportArgumentType] # partial entry
|
||||||
|
assert metadata["standard_logging_guardrail_information"] == [{"guardrail_name": "hook"}, entry]
|
||||||
|
|
||||||
|
|
||||||
|
def test_native_trace_entry_redacts_the_pii_matches_before_they_reach_spend_logs(monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(litellm, "model_cost", _TRACE_PRICING)
|
||||||
|
trace: Final = _converse_trace()
|
||||||
|
trace["guardrail"]["inputAssessment"]["gid"]["sensitiveInformationPolicy"] = {
|
||||||
|
"piiEntities": [{"type": "US_SSN", "match": "123-45-6789", "action": "ANONYMIZED"}]
|
||||||
|
}
|
||||||
|
entry: Final = guardrail_cost.native_bedrock_guardrail_information(
|
||||||
|
trace, aws_region_name=None, guardrail_identifier=None
|
||||||
|
)
|
||||||
|
assert entry is not None
|
||||||
|
assert entry["guardrail_status"] == "guardrail_intervened"
|
||||||
|
assert "123-45-6789" not in json.dumps(entry["guardrail_response"])
|
||||||
|
assert entry["guardrail_usage"]["sensitiveInformationPolicyUnits"] == 2
|
||||||
|
|
|
||||||
|
|
@ -13642,3 +13642,54 @@ def test_native_messages_body_carries_the_reported_list_cost_into_the_priced_res
|
||||||
assert isinstance(priced, ModelResponse)
|
assert isinstance(priced, ModelResponse)
|
||||||
headers = priced._hidden_params.get("additional_headers") or {}
|
headers = priced._hidden_params.get("additional_headers") or {}
|
||||||
assert headers.get("llm_provider-x-litellm-response-cost") == expected_header
|
assert headers.get("llm_provider-x-litellm-response-cost") == expected_header
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_native_bedrock_guardrail_bills_when_the_metadata_is_none() -> None:
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||||
|
|
||||||
|
logging_obj = Logging(
|
||||||
|
model="bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||||
|
messages=[{"role": "user", "content": "hello"}],
|
||||||
|
stream=False,
|
||||||
|
call_type="completion",
|
||||||
|
start_time=datetime.datetime.now(),
|
||||||
|
litellm_call_id="call-none-metadata",
|
||||||
|
function_id="fn",
|
||||||
|
)
|
||||||
|
logging_obj.model_call_details["litellm_params"] = {"metadata": None}
|
||||||
|
trace = {"guardrail": {"inputAssessment": {"gid": {"invocationMetrics": {"usage": {"contentPolicyUnits": 2}}}}}}
|
||||||
|
|
||||||
|
logging_obj.record_native_bedrock_guardrail({"trace": trace})
|
||||||
|
|
||||||
|
(entry,) = logging_obj.model_call_details["litellm_params"]["metadata"]["standard_logging_guardrail_information"]
|
||||||
|
assert entry["guardrail_usage"] == {"contentPolicyUnits": 2}
|
||||||
|
assert entry["guardrail_cost"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_native_bedrock_guardrail_bills_nothing_for_a_response_served_from_cache() -> None:
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||||
|
|
||||||
|
logging_obj = Logging(
|
||||||
|
model="bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||||
|
messages=[{"role": "user", "content": "hello"}],
|
||||||
|
stream=False,
|
||||||
|
call_type="completion",
|
||||||
|
start_time=datetime.datetime.now(),
|
||||||
|
litellm_call_id="call-cache-hit",
|
||||||
|
function_id="fn",
|
||||||
|
)
|
||||||
|
logging_obj.model_call_details["litellm_params"] = {"metadata": {}}
|
||||||
|
trace = {"guardrail": {"inputAssessment": {"gid": {"invocationMetrics": {"usage": {"contentPolicyUnits": 2}}}}}}
|
||||||
|
cached = litellm.ModelResponse(choices=[{"message": {"role": "assistant", "content": "hi"}}], model="x")
|
||||||
|
setattr(cached, "trace", trace)
|
||||||
|
cached._hidden_params = {"cache_hit": True}
|
||||||
|
|
||||||
|
logging_obj.record_native_bedrock_guardrail(cached)
|
||||||
|
logging_obj.model_call_details["cache_hit"] = True
|
||||||
|
logging_obj.record_native_bedrock_guardrail({"trace": trace})
|
||||||
|
|
||||||
|
assert "standard_logging_guardrail_information" not in logging_obj.model_call_details["litellm_params"]["metadata"]
|
||||||
|
|
|
||||||
|
|
@ -26,15 +26,16 @@ import litellm
|
||||||
from litellm.constants import LOGGING_WORKER_MAX_TIME_PER_COROUTINE
|
from litellm.constants import LOGGING_WORKER_MAX_TIME_PER_COROUTINE
|
||||||
from litellm.integrations.custom_logger import CustomLogger
|
from litellm.integrations.custom_logger import CustomLogger
|
||||||
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
||||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
|
||||||
from litellm.litellm_core_utils.redact_messages import (
|
from litellm.litellm_core_utils.redact_messages import (
|
||||||
_redact_responses_api_output,
|
_redact_responses_api_output,
|
||||||
_redacted_responses_api_response,
|
_redacted_responses_api_response,
|
||||||
perform_redaction,
|
perform_redaction,
|
||||||
redacted_model_call_details_for_custom_logger,
|
redacted_model_call_details_for_custom_logger,
|
||||||
|
redacted_response_obj_for_custom_logger,
|
||||||
redacted_standard_logging_payload,
|
redacted_standard_logging_payload,
|
||||||
should_redact_message_logging,
|
should_redact_message_logging,
|
||||||
)
|
)
|
||||||
|
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||||
from litellm.responses.main import mock_responses_api_response
|
from litellm.responses.main import mock_responses_api_response
|
||||||
from litellm.types.router import BaselineRouteStamp
|
from litellm.types.router import BaselineRouteStamp
|
||||||
from litellm.types.utils import (
|
from litellm.types.utils import (
|
||||||
|
|
@ -176,7 +177,11 @@ def test_redaction_hides_a_compaction_summary_in_responses_api_output(shape: str
|
||||||
object="response",
|
object="response",
|
||||||
status="completed",
|
status="completed",
|
||||||
output=[
|
output=[
|
||||||
{"type": "compaction", "id": "cmp_1", "encrypted_content": json.dumps({"type": "compaction", "content": SECRET})}
|
{
|
||||||
|
"type": "compaction",
|
||||||
|
"id": "cmp_1",
|
||||||
|
"encrypted_content": json.dumps({"type": "compaction", "content": SECRET}),
|
||||||
|
}
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
result: Final = response if shape == "object" else response.model_dump()
|
result: Final = response if shape == "object" else response.model_dump()
|
||||||
|
|
@ -2041,3 +2046,32 @@ async def test_redaction_with_metadata_completion_api():
|
||||||
response = standard_logging_payload["response"]
|
response = standard_logging_payload["response"]
|
||||||
assert response["choices"][0]["message"]["content"] == "redacted-by-litellm"
|
assert response["choices"][0]["message"]["content"] == "redacted-by-litellm"
|
||||||
assert standard_logging_payload["messages"][0]["content"] == "redacted-by-litellm"
|
assert standard_logging_payload["messages"][0]["content"] == "redacted-by-litellm"
|
||||||
|
|
||||||
|
|
||||||
|
def test_perform_redaction_strips_the_matched_text_from_the_bedrock_guardrail_traces() -> None:
|
||||||
|
finding = {
|
||||||
|
"guardrail": {
|
||||||
|
"inputAssessment": {
|
||||||
|
"gid": {
|
||||||
|
"sensitiveInformationPolicy": {
|
||||||
|
"piiEntities": [{"type": "US_SSN", "match": "123-45-6789", "action": "ANONYMIZED"}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response = litellm.ModelResponse(choices=[{"message": {"role": "assistant", "content": "hi"}}], model="x")
|
||||||
|
setattr(response, "trace", copy.deepcopy(finding))
|
||||||
|
setattr(response, "amazon-bedrock-trace", copy.deepcopy(finding))
|
||||||
|
opted_out: Final = CustomLogger(message_logging=False)
|
||||||
|
|
||||||
|
redacted: Final = redacted_response_obj_for_custom_logger(response, opted_out)
|
||||||
|
redacted_dict: Final = redacted_response_obj_for_custom_logger(
|
||||||
|
{"choices": [], "trace": copy.deepcopy(finding)}, opted_out
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(redacted, litellm.ModelResponse)
|
||||||
|
assert "123-45-6789" not in redacted.model_dump_json()
|
||||||
|
assert "US_SSN" in redacted.model_dump_json(), "the finding stays, only its matched text goes"
|
||||||
|
assert "123-45-6789" not in str(redacted_dict)
|
||||||
|
assert "123-45-6789" in response.model_dump_json(), "the caller's own response is not redacted"
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,19 @@ import httpx
|
||||||
import pytest
|
import pytest
|
||||||
import respx
|
import respx
|
||||||
|
|
||||||
|
|
||||||
import litellm
|
import litellm
|
||||||
from litellm import ChatCompletionUsageBlock, stream_chunk_builder
|
from litellm import ChatCompletionUsageBlock, stream_chunk_builder
|
||||||
from litellm.types.utils import GenericStreamingChunk
|
from litellm.litellm_core_utils.streaming_chunk_builder_utils import (
|
||||||
from litellm.litellm_core_utils.streaming_chunk_builder_utils import ChunkProcessor, stream_chunk_builder_text_completion
|
ChunkProcessor,
|
||||||
|
stream_chunk_builder_text_completion,
|
||||||
|
)
|
||||||
from litellm.llms.anthropic.chat.handler import ModelResponseIterator
|
from litellm.llms.anthropic.chat.handler import ModelResponseIterator
|
||||||
from litellm.types.utils import (
|
from litellm.types.utils import (
|
||||||
ChatCompletionDeltaToolCall,
|
ChatCompletionDeltaToolCall,
|
||||||
ChatCompletionMessageToolCall,
|
ChatCompletionMessageToolCall,
|
||||||
Delta,
|
Delta,
|
||||||
Function,
|
Function,
|
||||||
|
GenericStreamingChunk,
|
||||||
ModelResponseStream,
|
ModelResponseStream,
|
||||||
PromptTokensDetails,
|
PromptTokensDetails,
|
||||||
ServerToolUse,
|
ServerToolUse,
|
||||||
|
|
@ -196,11 +198,7 @@ def test_get_combined_thinking_content_preserves_interleaved_blocks():
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
make_chunk(
|
make_chunk(thinking_blocks=[{"type": "thinking", "thinking": None, "signature": "sig_block1"}]),
|
||||||
thinking_blocks=[
|
|
||||||
{"type": "thinking", "thinking": None, "signature": "sig_block1"}
|
|
||||||
]
|
|
||||||
),
|
|
||||||
make_chunk(
|
make_chunk(
|
||||||
thinking_blocks=[
|
thinking_blocks=[
|
||||||
{
|
{
|
||||||
|
|
@ -218,16 +216,10 @@ def test_get_combined_thinking_content_preserves_interleaved_blocks():
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
make_chunk(
|
make_chunk(thinking_blocks=[{"type": "thinking", "thinking": None, "signature": "sig_block2"}]),
|
||||||
thinking_blocks=[
|
|
||||||
{"type": "thinking", "thinking": None, "signature": "sig_block2"}
|
|
||||||
]
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
thinking_chunks = [
|
thinking_chunks = [chunk for chunk in chunks if chunk["choices"][0]["delta"].get("thinking_blocks")]
|
||||||
chunk for chunk in chunks if chunk["choices"][0]["delta"].get("thinking_blocks")
|
|
||||||
]
|
|
||||||
processor = ChunkProcessor(chunks=chunks)
|
processor = ChunkProcessor(chunks=chunks)
|
||||||
result = processor.get_combined_thinking_content(thinking_chunks)
|
result = processor.get_combined_thinking_content(thinking_chunks)
|
||||||
|
|
||||||
|
|
@ -297,9 +289,7 @@ def test_cache_read_input_tokens_retained():
|
||||||
prompt_tokens=11779,
|
prompt_tokens=11779,
|
||||||
total_tokens=11784,
|
total_tokens=11784,
|
||||||
completion_tokens_details=None,
|
completion_tokens_details=None,
|
||||||
prompt_tokens_details=PromptTokensDetails(
|
prompt_tokens_details=PromptTokensDetails(audio_tokens=None, cached_tokens=11775),
|
||||||
audio_tokens=None, cached_tokens=11775
|
|
||||||
),
|
|
||||||
cache_creation_input_tokens=4,
|
cache_creation_input_tokens=4,
|
||||||
cache_read_input_tokens=11775,
|
cache_read_input_tokens=11775,
|
||||||
),
|
),
|
||||||
|
|
@ -333,9 +323,7 @@ def test_cache_read_input_tokens_retained():
|
||||||
prompt_tokens=0,
|
prompt_tokens=0,
|
||||||
total_tokens=214,
|
total_tokens=214,
|
||||||
completion_tokens_details=None,
|
completion_tokens_details=None,
|
||||||
prompt_tokens_details=PromptTokensDetails(
|
prompt_tokens_details=PromptTokensDetails(audio_tokens=None, cached_tokens=0),
|
||||||
audio_tokens=None, cached_tokens=0
|
|
||||||
),
|
|
||||||
cache_creation_input_tokens=0,
|
cache_creation_input_tokens=0,
|
||||||
cache_read_input_tokens=0,
|
cache_read_input_tokens=0,
|
||||||
),
|
),
|
||||||
|
|
@ -395,10 +383,7 @@ def test_streaming_preserves_anthropic_1hr_cache_creation_breakdown():
|
||||||
)
|
)
|
||||||
# Sanity: the delta event genuinely lacks the breakdown - this is the input
|
# Sanity: the delta event genuinely lacks the breakdown - this is the input
|
||||||
# condition that used to defeat cost calc.
|
# condition that used to defeat cost calc.
|
||||||
assert (
|
assert getattr(message_delta_usage.prompt_tokens_details, "cache_creation_token_details", None) is None
|
||||||
getattr(message_delta_usage.prompt_tokens_details, "cache_creation_token_details", None)
|
|
||||||
is None
|
|
||||||
)
|
|
||||||
|
|
||||||
def _usage_chunk(usage, finish_reason):
|
def _usage_chunk(usage, finish_reason):
|
||||||
return ModelResponseStream(
|
return ModelResponseStream(
|
||||||
|
|
@ -517,9 +502,7 @@ def test_cache_read_input_tokens_retained_genericstreamingchunk():
|
||||||
prompt_tokens=1234,
|
prompt_tokens=1234,
|
||||||
total_tokens=1239,
|
total_tokens=1239,
|
||||||
completion_tokens_details=None,
|
completion_tokens_details=None,
|
||||||
prompt_tokens_details=PromptTokensDetails(
|
prompt_tokens_details=PromptTokensDetails(audio_tokens=None, cached_tokens=543).model_dump(),
|
||||||
audio_tokens=None, cached_tokens=543
|
|
||||||
).model_dump(),
|
|
||||||
),
|
),
|
||||||
index=2,
|
index=2,
|
||||||
)
|
)
|
||||||
|
|
@ -536,6 +519,7 @@ def test_cache_read_input_tokens_retained_genericstreamingchunk():
|
||||||
|
|
||||||
assert usage.prompt_tokens_details.cached_tokens == 543
|
assert usage.prompt_tokens_details.cached_tokens == 543
|
||||||
|
|
||||||
|
|
||||||
def test_stream_chunk_builder_litellm_usage_chunks():
|
def test_stream_chunk_builder_litellm_usage_chunks():
|
||||||
"""
|
"""
|
||||||
Validate ChunkProcessor.calculate_usage uses provided usage fields from streaming chunks
|
Validate ChunkProcessor.calculate_usage uses provided usage fields from streaming chunks
|
||||||
|
|
@ -609,9 +593,7 @@ def test_stream_chunk_builder_litellm_usage_chunks():
|
||||||
chunks = [chunk1, chunk2]
|
chunks = [chunk1, chunk2]
|
||||||
processor = ChunkProcessor(chunks=chunks)
|
processor = ChunkProcessor(chunks=chunks)
|
||||||
|
|
||||||
usage = processor.calculate_usage(
|
usage = processor.calculate_usage(chunks=chunks, model="gemini/gemini-2.5-flash-lite", completion_output="")
|
||||||
chunks=chunks, model="gemini/gemini-2.5-flash-lite", completion_output=""
|
|
||||||
)
|
|
||||||
|
|
||||||
assert usage.prompt_tokens == 50
|
assert usage.prompt_tokens == 50
|
||||||
assert usage.completion_tokens == 27
|
assert usage.completion_tokens == 27
|
||||||
|
|
@ -655,15 +637,11 @@ def test_calculate_usage_honors_openai_sdk_completion_usage_chunks():
|
||||||
provider_specific_fields=None,
|
provider_specific_fields=None,
|
||||||
stream_options={"include_usage": True},
|
stream_options={"include_usage": True},
|
||||||
)
|
)
|
||||||
usage_chunk.usage = CompletionUsage(
|
usage_chunk.usage = CompletionUsage(prompt_tokens=20, completion_tokens=60, total_tokens=80, cost=0.000704)
|
||||||
prompt_tokens=20, completion_tokens=60, total_tokens=80, cost=0.000704
|
|
||||||
)
|
|
||||||
assert type(usage_chunk.usage) is CompletionUsage
|
assert type(usage_chunk.usage) is CompletionUsage
|
||||||
|
|
||||||
chunks = [content_chunk, usage_chunk]
|
chunks = [content_chunk, usage_chunk]
|
||||||
usage = ChunkProcessor(chunks=chunks).calculate_usage(
|
usage = ChunkProcessor(chunks=chunks).calculate_usage(chunks=chunks, model="mantle-claude", completion_output="")
|
||||||
chunks=chunks, model="mantle-claude", completion_output=""
|
|
||||||
)
|
|
||||||
|
|
||||||
assert usage.prompt_tokens == 20
|
assert usage.prompt_tokens == 20
|
||||||
assert usage.completion_tokens == 60
|
assert usage.completion_tokens == 60
|
||||||
|
|
@ -686,9 +664,7 @@ def test_get_model_from_chunks_azure_model_router():
|
||||||
{"model": "gpt-4.1-nano-2025-04-14", "id": "chatcmpl-123", "choices": []},
|
{"model": "gpt-4.1-nano-2025-04-14", "id": "chatcmpl-123", "choices": []},
|
||||||
]
|
]
|
||||||
|
|
||||||
result = ChunkProcessor._get_model_from_chunks(
|
result = ChunkProcessor._get_model_from_chunks(chunks=chunks, first_chunk_model="azure-model-router")
|
||||||
chunks=chunks, first_chunk_model="azure-model-router"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should return the actual model, not the request model
|
# Should return the actual model, not the request model
|
||||||
assert result == "gpt-4.1-nano-2025-04-14"
|
assert result == "gpt-4.1-nano-2025-04-14"
|
||||||
|
|
@ -699,9 +675,7 @@ def test_get_model_from_chunks_azure_model_router():
|
||||||
{"model": "gpt-4", "id": "chatcmpl-456", "choices": []},
|
{"model": "gpt-4", "id": "chatcmpl-456", "choices": []},
|
||||||
]
|
]
|
||||||
|
|
||||||
result_same = ChunkProcessor._get_model_from_chunks(
|
result_same = ChunkProcessor._get_model_from_chunks(chunks=chunks_same_model, first_chunk_model="gpt-4")
|
||||||
chunks=chunks_same_model, first_chunk_model="gpt-4"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should return the first chunk's model when all are the same
|
# Should return the first chunk's model when all are the same
|
||||||
assert result_same == "gpt-4"
|
assert result_same == "gpt-4"
|
||||||
|
|
@ -777,9 +751,7 @@ def test_stream_chunk_builder_anthropic_web_search():
|
||||||
chunks = [chunk1, chunk2]
|
chunks = [chunk1, chunk2]
|
||||||
processor = ChunkProcessor(chunks=chunks)
|
processor = ChunkProcessor(chunks=chunks)
|
||||||
|
|
||||||
usage = processor.calculate_usage(
|
usage = processor.calculate_usage(chunks=chunks, model="claude-sonnet-4-5-20250929", completion_output="")
|
||||||
chunks=chunks, model="claude-sonnet-4-5-20250929", completion_output=""
|
|
||||||
)
|
|
||||||
|
|
||||||
assert usage.prompt_tokens == 50
|
assert usage.prompt_tokens == 50
|
||||||
assert usage.completion_tokens == 27
|
assert usage.completion_tokens == 27
|
||||||
|
|
@ -931,15 +903,11 @@ def test_stream_chunk_builder_dict_snapshot_preserves_hidden_provider_fields():
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
chunk_dict = chunk.model_dump()
|
chunk_dict = chunk.model_dump()
|
||||||
chunk_dict["_hidden_params"] = {
|
chunk_dict["_hidden_params"] = {"provider_specific_fields": {"traffic_type": "default"}}
|
||||||
"provider_specific_fields": {"traffic_type": "default"}
|
|
||||||
}
|
|
||||||
|
|
||||||
response = stream_chunk_builder(chunks=[chunk_dict])
|
response = stream_chunk_builder(chunks=[chunk_dict])
|
||||||
assert response is not None
|
assert response is not None
|
||||||
assert (
|
assert response._hidden_params["provider_specific_fields"]["traffic_type"] == "default"
|
||||||
response._hidden_params["provider_specific_fields"]["traffic_type"] == "default"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_stream_chunk_builder_propagates_vertex_ai_metadata_from_chunks():
|
def test_stream_chunk_builder_propagates_vertex_ai_metadata_from_chunks():
|
||||||
|
|
@ -984,10 +952,7 @@ def test_stream_chunk_builder_propagates_vertex_ai_metadata_from_chunks():
|
||||||
assert getattr(response, "vertex_ai_grounding_metadata") == grounding_metadata
|
assert getattr(response, "vertex_ai_grounding_metadata") == grounding_metadata
|
||||||
assert getattr(response, "vertex_ai_url_context_metadata") == url_context_metadata
|
assert getattr(response, "vertex_ai_url_context_metadata") == url_context_metadata
|
||||||
assert response._hidden_params["vertex_ai_grounding_metadata"] == grounding_metadata
|
assert response._hidden_params["vertex_ai_grounding_metadata"] == grounding_metadata
|
||||||
assert (
|
assert response._hidden_params["vertex_ai_url_context_metadata"] == url_context_metadata
|
||||||
response._hidden_params["vertex_ai_url_context_metadata"]
|
|
||||||
== url_context_metadata
|
|
||||||
)
|
|
||||||
|
|
||||||
dumped = response.model_dump()
|
dumped = response.model_dump()
|
||||||
assert dumped["vertex_ai_grounding_metadata"] == grounding_metadata
|
assert dumped["vertex_ai_grounding_metadata"] == grounding_metadata
|
||||||
|
|
@ -1034,9 +999,7 @@ def test_stream_chunk_builder_uses_assembled_model_for_provider_metadata():
|
||||||
|
|
||||||
def test_stream_chunk_builder_propagates_vertex_ai_safety_results():
|
def test_stream_chunk_builder_propagates_vertex_ai_safety_results():
|
||||||
"""Assembled response must expose safety data under the non-streaming field name."""
|
"""Assembled response must expose safety data under the non-streaming field name."""
|
||||||
safety_ratings = [
|
safety_ratings = [[{"category": "HARM_CATEGORY_HATE_SPEECH", "probability": "NEGLIGIBLE"}]]
|
||||||
[{"category": "HARM_CATEGORY_HATE_SPEECH", "probability": "NEGLIGIBLE"}]
|
|
||||||
]
|
|
||||||
|
|
||||||
chunk = ModelResponseStream(
|
chunk = ModelResponseStream(
|
||||||
id="chatcmpl-vertex-safety",
|
id="chatcmpl-vertex-safety",
|
||||||
|
|
@ -1078,18 +1041,12 @@ def test_stream_chunk_builder_propagates_vertex_ai_metadata_from_dict_chunks():
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
).model_dump()
|
).model_dump()
|
||||||
chunk_dict["_hidden_params"] = {
|
chunk_dict["_hidden_params"] = {"vertex_ai_grounding_metadata": [{"webSearchQueries": ["test query"]}]}
|
||||||
"vertex_ai_grounding_metadata": [{"webSearchQueries": ["test query"]}]
|
|
||||||
}
|
|
||||||
|
|
||||||
response = stream_chunk_builder(chunks=[chunk_dict])
|
response = stream_chunk_builder(chunks=[chunk_dict])
|
||||||
assert response is not None
|
assert response is not None
|
||||||
assert getattr(response, "vertex_ai_grounding_metadata") == [
|
assert getattr(response, "vertex_ai_grounding_metadata") == [{"webSearchQueries": ["test query"]}]
|
||||||
{"webSearchQueries": ["test query"]}
|
assert response.model_dump()["vertex_ai_grounding_metadata"] == [{"webSearchQueries": ["test query"]}]
|
||||||
]
|
|
||||||
assert response.model_dump()["vertex_ai_grounding_metadata"] == [
|
|
||||||
{"webSearchQueries": ["test query"]}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_cost_field_in_usage_chunks():
|
def test_cost_field_in_usage_chunks():
|
||||||
|
|
@ -1098,29 +1055,21 @@ def test_cost_field_in_usage_chunks():
|
||||||
id="chatcmpl-1",
|
id="chatcmpl-1",
|
||||||
created=1745513206,
|
created=1745513206,
|
||||||
model="openrouter/claude",
|
model="openrouter/claude",
|
||||||
choices=[
|
choices=[StreamingChoices(finish_reason=None, index=0, delta=Delta(content="Hi"))],
|
||||||
StreamingChoices(finish_reason=None, index=0, delta=Delta(content="Hi"))
|
|
||||||
],
|
|
||||||
usage=chunk1_usage,
|
usage=chunk1_usage,
|
||||||
)
|
)
|
||||||
|
|
||||||
chunk2_usage = Usage(
|
chunk2_usage = Usage(completion_tokens=5, prompt_tokens=10, total_tokens=15, cost=0.00025)
|
||||||
completion_tokens=5, prompt_tokens=10, total_tokens=15, cost=0.00025
|
|
||||||
)
|
|
||||||
chunk2 = ModelResponseStream(
|
chunk2 = ModelResponseStream(
|
||||||
id="chatcmpl-1",
|
id="chatcmpl-1",
|
||||||
created=1745513207,
|
created=1745513207,
|
||||||
model="openrouter/claude",
|
model="openrouter/claude",
|
||||||
choices=[
|
choices=[StreamingChoices(finish_reason="stop", index=0, delta=Delta(content=""))],
|
||||||
StreamingChoices(finish_reason="stop", index=0, delta=Delta(content=""))
|
|
||||||
],
|
|
||||||
usage=chunk2_usage,
|
usage=chunk2_usage,
|
||||||
)
|
)
|
||||||
|
|
||||||
processor = ChunkProcessor(chunks=[chunk1, chunk2])
|
processor = ChunkProcessor(chunks=[chunk1, chunk2])
|
||||||
usage = processor.calculate_usage(
|
usage = processor.calculate_usage(chunks=[chunk1, chunk2], model="openrouter/claude", completion_output="Hi")
|
||||||
chunks=[chunk1, chunk2], model="openrouter/claude", completion_output="Hi"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert hasattr(usage, "cost")
|
assert hasattr(usage, "cost")
|
||||||
assert usage.cost == 0.00025
|
assert usage.cost == 0.00025
|
||||||
|
|
@ -1203,25 +1152,19 @@ def test_prompt_tokens_details_survive_later_usage_chunk_without_details():
|
||||||
id="chatcmpl-1",
|
id="chatcmpl-1",
|
||||||
created=1745513206,
|
created=1745513206,
|
||||||
model="openai/gpt-5.6-sol",
|
model="openai/gpt-5.6-sol",
|
||||||
choices=[
|
choices=[StreamingChoices(finish_reason=None, index=0, delta=Delta(content="Hi"))],
|
||||||
StreamingChoices(finish_reason=None, index=0, delta=Delta(content="Hi"))
|
|
||||||
],
|
|
||||||
usage=Usage(
|
usage=Usage(
|
||||||
prompt_tokens=6017,
|
prompt_tokens=6017,
|
||||||
completion_tokens=4,
|
completion_tokens=4,
|
||||||
total_tokens=6021,
|
total_tokens=6021,
|
||||||
prompt_tokens_details=PromptTokensDetailsWrapper(
|
prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=6004, cache_write_tokens=10),
|
||||||
cached_tokens=6004, cache_write_tokens=10
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
chunk_without_details = ModelResponseStream(
|
chunk_without_details = ModelResponseStream(
|
||||||
id="chatcmpl-1",
|
id="chatcmpl-1",
|
||||||
created=1745513207,
|
created=1745513207,
|
||||||
model="openai/gpt-5.6-sol",
|
model="openai/gpt-5.6-sol",
|
||||||
choices=[
|
choices=[StreamingChoices(finish_reason="stop", index=0, delta=Delta(content=""))],
|
||||||
StreamingChoices(finish_reason="stop", index=0, delta=Delta(content=""))
|
|
||||||
],
|
|
||||||
usage=Usage(prompt_tokens=6017, completion_tokens=4, total_tokens=6021),
|
usage=Usage(prompt_tokens=6017, completion_tokens=4, total_tokens=6021),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1559,9 +1502,7 @@ def test_calculate_usage_fills_unknown_split_from_reasoning_estimate(
|
||||||
assert usage.completion_tokens_details.text_tokens == expected_text_tokens
|
assert usage.completion_tokens_details.text_tokens == expected_text_tokens
|
||||||
|
|
||||||
|
|
||||||
def _openai_chunk(
|
def _openai_chunk(choices: Sequence[Mapping[str, object]], usage: Mapping[str, int] | None = None) -> dict[str, object]:
|
||||||
choices: Sequence[Mapping[str, object]], usage: Mapping[str, int] | None = None
|
|
||||||
) -> dict[str, object]:
|
|
||||||
base: Final = {
|
base: Final = {
|
||||||
"id": "chatcmpl-lit6552",
|
"id": "chatcmpl-lit6552",
|
||||||
"object": "chat.completion.chunk",
|
"object": "chat.completion.chunk",
|
||||||
|
|
@ -1996,7 +1937,11 @@ def test_stream_chunk_builder_litellm_tool_call(respx_mock: respx.MockRouter) ->
|
||||||
)
|
)
|
||||||
|
|
||||||
assert [tool_call.to_dict() for tool_call in response.choices[0].message.tool_calls] == [
|
assert [tool_call.to_dict() for tool_call in response.choices[0].message.tool_calls] == [
|
||||||
{"function": {"arguments": '{"city":"Boston"}', "name": "get_weather"}, "id": "call_weather", "type": "function"}
|
{
|
||||||
|
"function": {"arguments": '{"city":"Boston"}', "name": "get_weather"},
|
||||||
|
"id": "call_weather",
|
||||||
|
"type": "function",
|
||||||
|
}
|
||||||
]
|
]
|
||||||
assert (response.usage.prompt_tokens, response.usage.completion_tokens, response.usage.total_tokens) == (5, 3, 8)
|
assert (response.usage.prompt_tokens, response.usage.completion_tokens, response.usage.total_tokens) == (5, 3, 8)
|
||||||
|
|
||||||
|
|
@ -2018,7 +1963,9 @@ def test_stream_chunk_builder_litellm_tool_call_regular_message(respx_mock: resp
|
||||||
|
|
||||||
|
|
||||||
def test_stream_chunk_builder_multiple_tool_calls() -> None:
|
def test_stream_chunk_builder_multiple_tool_calls() -> None:
|
||||||
def tool_call_chunk(*tool_calls: ChatCompletionDeltaToolCall, finish_reason: str | None = None) -> ModelResponseStream:
|
def tool_call_chunk(
|
||||||
|
*tool_calls: ChatCompletionDeltaToolCall, finish_reason: str | None = None
|
||||||
|
) -> ModelResponseStream:
|
||||||
return _legacy_stream_chunk("openai/gpt-4o", tool_calls=tool_calls, finish_reason=finish_reason)
|
return _legacy_stream_chunk("openai/gpt-4o", tool_calls=tool_calls, finish_reason=finish_reason)
|
||||||
|
|
||||||
chunks: Final = (
|
chunks: Final = (
|
||||||
|
|
@ -2029,7 +1976,9 @@ def test_stream_chunk_builder_multiple_tool_calls() -> None:
|
||||||
),
|
),
|
||||||
tool_call_chunk(ChatCompletionDeltaToolCall(index=0, function=Function(arguments='{"base": 3, '))),
|
tool_call_chunk(ChatCompletionDeltaToolCall(index=0, function=Function(arguments='{"base": 3, '))),
|
||||||
tool_call_chunk(
|
tool_call_chunk(
|
||||||
ChatCompletionDeltaToolCall(id="call_add", index=1, type="function", function=Function(name="add", arguments=""))
|
ChatCompletionDeltaToolCall(
|
||||||
|
id="call_add", index=1, type="function", function=Function(name="add", arguments="")
|
||||||
|
)
|
||||||
),
|
),
|
||||||
tool_call_chunk(ChatCompletionDeltaToolCall(index=0, function=Function(arguments='"exponent": 5}'))),
|
tool_call_chunk(ChatCompletionDeltaToolCall(index=0, function=Function(arguments='"exponent": 5}'))),
|
||||||
tool_call_chunk(
|
tool_call_chunk(
|
||||||
|
|
@ -2043,7 +1992,11 @@ def test_stream_chunk_builder_multiple_tool_calls() -> None:
|
||||||
assert response is not None
|
assert response is not None
|
||||||
assert response.choices[0].finish_reason == "tool_calls"
|
assert response.choices[0].finish_reason == "tool_calls"
|
||||||
assert [tool_call.to_dict() for tool_call in response.choices[0].message.tool_calls] == [
|
assert [tool_call.to_dict() for tool_call in response.choices[0].message.tool_calls] == [
|
||||||
{"function": {"arguments": '{"base": 3, "exponent": 5}', "name": "exponentiate"}, "id": "call_exp", "type": "function"},
|
{
|
||||||
|
"function": {"arguments": '{"base": 3, "exponent": 5}', "name": "exponentiate"},
|
||||||
|
"id": "call_exp",
|
||||||
|
"type": "function",
|
||||||
|
},
|
||||||
{"function": {"arguments": '{"x": 1, "y": 2}', "name": "add"}, "id": "call_add", "type": "function"},
|
{"function": {"arguments": '{"x": 1, "y": 2}', "name": "add"}, "id": "call_add", "type": "function"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -2077,9 +2030,7 @@ def test_grok_bug() -> None:
|
||||||
response: Final = stream_chunk_builder(chunks=chunks)
|
response: Final = stream_chunk_builder(chunks=chunks)
|
||||||
|
|
||||||
assert response is not None
|
assert response is not None
|
||||||
assert json.loads(response.choices[0].message.tool_calls[0].function.arguments) == {
|
assert json.loads(response.choices[0].message.tool_calls[0].function.arguments) == {"location": "Boston"}
|
||||||
"location": "Boston"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_stream_chunk_builder_openai_audio_output_usage() -> None:
|
def test_stream_chunk_builder_openai_audio_output_usage() -> None:
|
||||||
|
|
@ -2196,7 +2147,9 @@ def test_stream_chunk_builder_keeps_usage_dict_set_on_a_typed_text_completion_ch
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("build", [litellm.stream_chunk_builder, litellm.stream_chunk_builder_text_completion])
|
@pytest.mark.parametrize("build", [litellm.stream_chunk_builder, litellm.stream_chunk_builder_text_completion])
|
||||||
def test_stream_chunk_builder_rebuilds_dict_text_completion_chunks(build: Callable[..., TextCompletionResponse]) -> None:
|
def test_stream_chunk_builder_rebuilds_dict_text_completion_chunks(
|
||||||
|
build: Callable[..., TextCompletionResponse],
|
||||||
|
) -> None:
|
||||||
def chunk(choices: list[dict[str, object]], **extra: object) -> dict[str, object]:
|
def chunk(choices: list[dict[str, object]], **extra: object) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"id": "cmpl-1",
|
"id": "cmpl-1",
|
||||||
|
|
@ -2450,7 +2403,9 @@ def test_stream_chunk_builder_rebuilds_text_completion_choice_indexes_that_are_n
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
assert tuple(choice.model_dump(include={"index", "text", "finish_reason"}) for choice in response.choices) == expected
|
assert (
|
||||||
|
tuple(choice.model_dump(include={"index", "text", "finish_reason"}) for choice in response.choices) == expected
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_stream_chunk_builder_text_completion_survives_uncountable_prompt() -> None:
|
def test_stream_chunk_builder_text_completion_survives_uncountable_prompt() -> None:
|
||||||
|
|
@ -2517,3 +2472,50 @@ def test_stream_chunk_builder_keeps_reasoning_items_on_text_only_replies():
|
||||||
assert response is not None
|
assert response is not None
|
||||||
assert response.choices[0].message.content == "Hello world"
|
assert response.choices[0].message.content == "Hello world"
|
||||||
assert response.choices[0].message.reasoning_items == reasoning_items
|
assert response.choices[0].message.reasoning_items == reasoning_items
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_chunk_builder_carries_the_bedrock_guardrail_trace_and_records_it_on_the_logging_object(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""A Bedrock stream reports its guardrail trace on the last chunk only; the built response carries it like the
|
||||||
|
non-streaming response and the call bills it as guardrail cost."""
|
||||||
|
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||||
|
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
litellm,
|
||||||
|
"model_cost",
|
||||||
|
{
|
||||||
|
"bedrock/guardrails": {
|
||||||
|
"litellm_provider": "bedrock",
|
||||||
|
"mode": "guardrails",
|
||||||
|
"guardrail_cost_per_unit": {"topicPolicyUnits": 0.00015},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
trace = {"guardrail": {"inputAssessment": {"gid": {"invocationMetrics": {"usage": {"topicPolicyUnits": 2}}}}}}
|
||||||
|
chunks = [
|
||||||
|
ModelResponseStream(id="s", model="m", choices=[StreamingChoices(index=0, delta=Delta(content="hi"))]),
|
||||||
|
ModelResponseStream(
|
||||||
|
id="s",
|
||||||
|
model="m",
|
||||||
|
choices=[StreamingChoices(index=0, delta=Delta(), finish_reason="stop")],
|
||||||
|
provider_specific_fields={"trace": trace},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
logging_obj = Logging(
|
||||||
|
model="bedrock/m",
|
||||||
|
messages=[{"role": "user", "content": "q"}],
|
||||||
|
stream=True,
|
||||||
|
call_type="acompletion",
|
||||||
|
start_time=None,
|
||||||
|
litellm_call_id="call",
|
||||||
|
function_id="fn",
|
||||||
|
)
|
||||||
|
logging_obj.model_call_details["litellm_params"] = {"metadata": {}}
|
||||||
|
built = stream_chunk_builder(chunks=chunks, messages=[{"role": "user", "content": "q"}], logging_obj=logging_obj)
|
||||||
|
assert built is not None
|
||||||
|
assert getattr(built, "trace") == trace
|
||||||
|
(entry,) = logging_obj.model_call_details["litellm_params"]["metadata"]["standard_logging_guardrail_information"]
|
||||||
|
assert entry["guardrail_usage"] == {"topicPolicyUnits": 2}
|
||||||
|
assert entry["guardrail_cost"] == pytest.approx(2 * 0.00015)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
|
||||||
from litellm.litellm_core_utils.litellm_logging import _get_provider_request_id
|
from litellm.litellm_core_utils.litellm_logging import _get_provider_request_id
|
||||||
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
|
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
|
||||||
from litellm.llms.bedrock.chat.invoke_handler import (
|
from litellm.llms.bedrock.chat.invoke_handler import (
|
||||||
|
AmazonAnthropicClaudeStreamDecoder,
|
||||||
AmazonOpenAICompatibleStreamDecoder,
|
AmazonOpenAICompatibleStreamDecoder,
|
||||||
AWSEventStreamDecoder,
|
AWSEventStreamDecoder,
|
||||||
_stream_decoder,
|
_stream_decoder,
|
||||||
|
|
@ -1120,6 +1121,7 @@ async def test_converse_streaming_completion_yields_text_and_finish_reason(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def handle_request(request: httpx.Request) -> httpx.Response:
|
def handle_request(request: httpx.Request) -> httpx.Response:
|
||||||
assert request.url.path.endswith("/converse-stream")
|
assert request.url.path.endswith("/converse-stream")
|
||||||
return httpx.Response(200, content=event_stream_body, headers=_event_stream_headers())
|
return httpx.Response(200, content=event_stream_body, headers=_event_stream_headers())
|
||||||
|
|
@ -1179,9 +1181,7 @@ def test_mistral_invoke_stream_yields_text_and_finish_reason(
|
||||||
assert "".join(chunk.choices[0].delta.content or "" for chunk in chunks) == (
|
assert "".join(chunk.choices[0].delta.content or "" for chunk in chunks) == (
|
||||||
"A court case moves through the courts."
|
"A court case moves through the courts."
|
||||||
)
|
)
|
||||||
assert [chunk.choices[0].finish_reason for chunk in chunks if chunk.choices[0].finish_reason] == [
|
assert [chunk.choices[0].finish_reason for chunk in chunks if chunk.choices[0].finish_reason] == ["stop"]
|
||||||
"stop"
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_converse_streaming_tool_call_arguments_are_valid_json(
|
def test_converse_streaming_tool_call_arguments_are_valid_json(
|
||||||
|
|
@ -1721,6 +1721,7 @@ def test_filter_headers_for_aws_signature():
|
||||||
@pytest.mark.parametrize("missing", ["botocore", "unrelated_dependency"])
|
@pytest.mark.parametrize("missing", ["botocore", "unrelated_dependency"])
|
||||||
def test_invoke_decoder_reports_only_missing_aws_dependency(missing):
|
def test_invoke_decoder_reports_only_missing_aws_dependency(missing):
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder
|
from litellm.llms.bedrock.chat.invoke_handler import AWSEventStreamDecoder
|
||||||
|
|
||||||
failure = ModuleNotFoundError("missing dependency", name=missing)
|
failure = ModuleNotFoundError("missing dependency", name=missing)
|
||||||
|
|
@ -1835,9 +1836,7 @@ async def test_converse_stream_thinking_merges_into_content_with_think_tags() ->
|
||||||
"contentBlockDelta", {"contentBlockIndex": 0, "delta": {"reasoningContent": {"signature": "c2lnbmF0dXJl"}}}
|
"contentBlockDelta", {"contentBlockIndex": 0, "delta": {"reasoningContent": {"signature": "c2lnbmF0dXJl"}}}
|
||||||
),
|
),
|
||||||
_converse_event_frame("contentBlockStop", {"contentBlockIndex": 0}),
|
_converse_event_frame("contentBlockStop", {"contentBlockIndex": 0}),
|
||||||
_converse_event_frame(
|
_converse_event_frame("contentBlockDelta", {"contentBlockIndex": 1, "delta": {"text": "Hi I am Anthropic, "}}),
|
||||||
"contentBlockDelta", {"contentBlockIndex": 1, "delta": {"text": "Hi I am Anthropic, "}}
|
|
||||||
),
|
|
||||||
_converse_event_frame(
|
_converse_event_frame(
|
||||||
"contentBlockDelta", {"contentBlockIndex": 1, "delta": {"text": "I am a helpful assistant"}}
|
"contentBlockDelta", {"contentBlockIndex": 1, "delta": {"text": "I am a helpful assistant"}}
|
||||||
),
|
),
|
||||||
|
|
@ -1919,7 +1918,11 @@ _CUT_JSON_TOOL_STREAM: Final = b"".join(
|
||||||
"content_block": {"type": "tool_use", "id": "toolu_cut_1", "name": "json_tool_call", "input": {}},
|
"content_block": {"type": "tool_use", "id": "toolu_cut_1", "name": "json_tool_call", "input": {}},
|
||||||
},
|
},
|
||||||
{"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": '{"city"'}},
|
{"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": '{"city"'}},
|
||||||
{"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ': "Paris", '}},
|
{
|
||||||
|
"type": "content_block_delta",
|
||||||
|
"index": 0,
|
||||||
|
"delta": {"type": "input_json_delta", "partial_json": ': "Paris", '},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "content_block_delta",
|
"type": "content_block_delta",
|
||||||
"index": 0,
|
"index": 0,
|
||||||
|
|
@ -1961,3 +1964,21 @@ async def test_async_anthropic_invoke_stream_cut_before_the_json_tool_block_ends
|
||||||
chunks: Final = [chunk async for chunk in _cut_json_tool_stream_decoder().aiter_bytes(_chunks())]
|
chunks: Final = [chunk async for chunk in _cut_json_tool_stream_decoder().aiter_bytes(_chunks())]
|
||||||
|
|
||||||
assert json.loads(_streamed_content(chunks)) == {"city": "Paris", "country": "France"}
|
assert json.loads(_streamed_content(chunks)) == {"city": "Paris", "country": "France"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_invoke_stream_decoder_carries_the_guardrail_trace_of_the_message_stop_chunk() -> None:
|
||||||
|
"""Bedrock Invoke reports the native guardrail trace once, on the final ``message_stop`` chunk, under
|
||||||
|
``amazon-bedrock-trace``. The chunk keeps it so the stream builder and the spend log can price it."""
|
||||||
|
decoder = AmazonAnthropicClaudeStreamDecoder(model="anthropic.claude-sonnet-5-v1:0", sync_stream=True)
|
||||||
|
trace = {"guardrail": {"input": {"gid": {"invocationMetrics": {"usage": {"topicPolicyUnits": 1}}}}}}
|
||||||
|
plain = decoder._chunk_parser({"type": "message_stop", "amazon-bedrock-invocationMetrics": {"inputTokenCount": 3}})
|
||||||
|
traced = decoder._chunk_parser(
|
||||||
|
{
|
||||||
|
"type": "message_stop",
|
||||||
|
"amazon-bedrock-invocationMetrics": {"inputTokenCount": 3},
|
||||||
|
"amazon-bedrock-trace": trace,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert (plain.provider_specific_fields or {}).get("amazon-bedrock-trace") is None
|
||||||
|
assert traced.provider_specific_fields is not None
|
||||||
|
assert traced.provider_specific_fields["amazon-bedrock-trace"] == trace
|
||||||
|
|
|
||||||
|
|
@ -4169,3 +4169,32 @@ async def test_bedrock_messages_400_is_not_retried_unless_it_rejects_opt_in_nati
|
||||||
|
|
||||||
assert isinstance(raised, litellm.BadRequestError)
|
assert isinstance(raised, litellm.BadRequestError)
|
||||||
assert len(bedrock.bodies) == 1
|
assert len(bedrock.bodies) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("trace", ["enabled", "disabled"])
|
||||||
|
def test_bedrock_messages_applies_the_deployment_guardrail_config_as_invoke_headers(trace: str) -> None:
|
||||||
|
from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude_transformation import (
|
||||||
|
AmazonAnthropicClaudeMessagesConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
headers, _ = AmazonAnthropicClaudeMessagesConfig().validate_anthropic_messages_environment(
|
||||||
|
headers={},
|
||||||
|
model="anthropic.claude-sonnet-5-v1:0",
|
||||||
|
messages=[],
|
||||||
|
optional_params={},
|
||||||
|
litellm_params={"guardrailConfig": {"guardrailIdentifier": "gid", "guardrailVersion": "2", "trace": trace}},
|
||||||
|
)
|
||||||
|
assert headers["X-Amzn-Bedrock-GuardrailIdentifier"] == "gid"
|
||||||
|
assert headers["X-Amzn-Bedrock-GuardrailVersion"] == "2"
|
||||||
|
assert headers["X-Amzn-Bedrock-Trace"] == trace.upper()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bedrock_messages_without_guardrail_config_sends_no_guardrail_headers() -> None:
|
||||||
|
from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude_transformation import (
|
||||||
|
AmazonAnthropicClaudeMessagesConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
headers, _ = AmazonAnthropicClaudeMessagesConfig().validate_anthropic_messages_environment(
|
||||||
|
headers={}, model="anthropic.claude-sonnet-5-v1:0", messages=[], optional_params={}, litellm_params={}
|
||||||
|
)
|
||||||
|
assert not any(name.startswith("X-Amzn-Bedrock-Guardrail") for name in headers)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, Final, List
|
from typing import Final
|
||||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -284,9 +284,10 @@ class TestAzureAnthropicCostCalculation:
|
||||||
@patch("litellm.completion_cost")
|
@patch("litellm.completion_cost")
|
||||||
def test_cost_calculation_with_azure_ai_custom_llm_provider(self, mock_completion_cost):
|
def test_cost_calculation_with_azure_ai_custom_llm_provider(self, mock_completion_cost):
|
||||||
"""Test that custom_llm_provider is passed to completion_cost for Azure AI Anthropic"""
|
"""Test that custom_llm_provider is passed to completion_cost for Azure AI Anthropic"""
|
||||||
from litellm.types.utils import ModelResponse
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from litellm.types.utils import ModelResponse
|
||||||
|
|
||||||
mock_completion_cost.return_value = 0.001
|
mock_completion_cost.return_value = 0.001
|
||||||
|
|
||||||
logging_obj = self._create_mock_logging_obj(
|
logging_obj = self._create_mock_logging_obj(
|
||||||
|
|
@ -319,9 +320,10 @@ class TestAzureAnthropicCostCalculation:
|
||||||
@patch("litellm.completion_cost")
|
@patch("litellm.completion_cost")
|
||||||
def test_cost_calculation_without_custom_llm_provider(self, mock_completion_cost):
|
def test_cost_calculation_without_custom_llm_provider(self, mock_completion_cost):
|
||||||
"""Test that cost calculation works without custom_llm_provider (standard Anthropic)"""
|
"""Test that cost calculation works without custom_llm_provider (standard Anthropic)"""
|
||||||
from litellm.types.utils import ModelResponse
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from litellm.types.utils import ModelResponse
|
||||||
|
|
||||||
mock_completion_cost.return_value = 0.001
|
mock_completion_cost.return_value = 0.001
|
||||||
|
|
||||||
# No custom_llm_provider in model_call_details
|
# No custom_llm_provider in model_call_details
|
||||||
|
|
@ -353,9 +355,10 @@ class TestAzureAnthropicCostCalculation:
|
||||||
@patch("litellm.completion_cost")
|
@patch("litellm.completion_cost")
|
||||||
def test_cost_calculation_does_not_duplicate_provider_prefix(self, mock_completion_cost):
|
def test_cost_calculation_does_not_duplicate_provider_prefix(self, mock_completion_cost):
|
||||||
"""Test that provider prefix is not duplicated if already present in model name"""
|
"""Test that provider prefix is not duplicated if already present in model name"""
|
||||||
from litellm.types.utils import ModelResponse
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
from litellm.types.utils import ModelResponse
|
||||||
|
|
||||||
mock_completion_cost.return_value = 0.001
|
mock_completion_cost.return_value = 0.001
|
||||||
|
|
||||||
logging_obj = self._create_mock_logging_obj(
|
logging_obj = self._create_mock_logging_obj(
|
||||||
|
|
@ -826,9 +829,10 @@ class TestAnthropicBatchPassthroughCostTracking:
|
||||||
mock_request_body,
|
mock_request_body,
|
||||||
):
|
):
|
||||||
"""Test that model gets 'anthropic/' prefix when not found in router"""
|
"""Test that model gets 'anthropic/' prefix when not found in router"""
|
||||||
|
import base64
|
||||||
|
|
||||||
from litellm.llms.anthropic.batches.transformation import AnthropicBatchesConfig
|
from litellm.llms.anthropic.batches.transformation import AnthropicBatchesConfig
|
||||||
from litellm.types.utils import LiteLLMBatch
|
from litellm.types.utils import LiteLLMBatch
|
||||||
import base64
|
|
||||||
|
|
||||||
# Model not in router - returns same model name
|
# Model not in router - returns same model name
|
||||||
mock_get_model_id.return_value = "claude-sonnet-4-5-20250929"
|
mock_get_model_id.return_value = "claude-sonnet-4-5-20250929"
|
||||||
|
|
@ -1089,7 +1093,7 @@ class TestAnthropicBatchPassthroughCostTracking:
|
||||||
class TestBuildCompleteStreamingResponseRobustness:
|
class TestBuildCompleteStreamingResponseRobustness:
|
||||||
"""_build_complete_streaming_response must tolerate non-standard SSE frames."""
|
"""_build_complete_streaming_response must tolerate non-standard SSE frames."""
|
||||||
|
|
||||||
def _build(self, chunks: List[str]):
|
def _build(self, chunks: list[str]):
|
||||||
return AnthropicPassthroughLoggingHandler._build_complete_streaming_response(
|
return AnthropicPassthroughLoggingHandler._build_complete_streaming_response(
|
||||||
all_chunks=chunks,
|
all_chunks=chunks,
|
||||||
litellm_logging_obj=MagicMock(),
|
litellm_logging_obj=MagicMock(),
|
||||||
|
|
@ -1840,9 +1844,6 @@ class TestStreamFalseDeduplication:
|
||||||
This is the _is_assembled_stream_success gate: with stream=False it
|
This is the _is_assembled_stream_success gate: with stream=False it
|
||||||
always returned False and the guard was permanently disabled.
|
always returned False and the guard was permanently disabled.
|
||||||
"""
|
"""
|
||||||
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
|
|
||||||
EndpointType,
|
|
||||||
)
|
|
||||||
from litellm.types.utils import ModelResponse
|
from litellm.types.utils import ModelResponse
|
||||||
|
|
||||||
# Simulate what pass_through_endpoints.py now does after stream detection
|
# Simulate what pass_through_endpoints.py now does after stream detection
|
||||||
|
|
@ -2642,8 +2643,6 @@ def test_create_anthropic_response_logging_payload(mock_logging_obj, metadata_pa
|
||||||
def test_handle_logging_anthropic_collected_chunks(all_chunks):
|
def test_handle_logging_anthropic_collected_chunks(all_chunks):
|
||||||
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.anthropic_passthrough_logging_handler import (
|
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.anthropic_passthrough_logging_handler import (
|
||||||
AnthropicPassthroughLoggingHandler,
|
AnthropicPassthroughLoggingHandler,
|
||||||
PassthroughStandardLoggingPayload,
|
|
||||||
EndpointType,
|
|
||||||
)
|
)
|
||||||
from litellm.types.utils import ModelResponse
|
from litellm.types.utils import ModelResponse
|
||||||
|
|
||||||
|
|
@ -2943,3 +2942,65 @@ def test_streamed_messages_from_other_providers_ignore_a_zero_usage_cost_field()
|
||||||
headers = result["result"]._hidden_params.get("additional_headers") or {}
|
headers = result["result"]._hidden_params.get("additional_headers") or {}
|
||||||
assert "llm_provider-x-litellm-response-cost" not in headers
|
assert "llm_provider-x-litellm-response-cost" not in headers
|
||||||
assert result["kwargs"].get("response_cost") != 0.0
|
assert result["kwargs"].get("response_cost") != 0.0
|
||||||
|
|
||||||
|
|
||||||
|
_BEDROCK_TRACE_PRICING: Final = {
|
||||||
|
"bedrock/guardrails": {
|
||||||
|
"litellm_provider": "bedrock",
|
||||||
|
"mode": "guardrail",
|
||||||
|
"guardrail_cost_per_unit": {"topicPolicyUnits": 0.00015, "contentPolicyUnits": 0.00015},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_BEDROCK_INVOKE_TRACE: Final = {
|
||||||
|
"guardrail": {
|
||||||
|
"input": {"gid": {"invocationMetrics": {"usage": {"topicPolicyUnits": 1, "contentPolicyUnits": 1}}}},
|
||||||
|
"outputs": [{"gid": {"invocationMetrics": {"usage": {"topicPolicyUnits": 2, "contentPolicyUnits": 2}}}}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _recorded_guardrail_entries(logging_obj: LiteLLMLoggingObj) -> list:
|
||||||
|
return logging_obj.model_call_details["litellm_params"]["metadata"]["standard_logging_guardrail_information"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_anthropic_passthrough_handler_bills_the_bedrock_guardrail_trace_of_a_messages_response(
|
||||||
|
monkeypatch, mock_httpx_response, mock_response, mock_logging_obj
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(litellm, "model_cost", _BEDROCK_TRACE_PRICING)
|
||||||
|
mock_logging_obj.model_call_details["litellm_params"] = {"metadata": {}}
|
||||||
|
AnthropicPassthroughLoggingHandler.anthropic_passthrough_handler(
|
||||||
|
httpx_response=mock_httpx_response,
|
||||||
|
response_body={**mock_response, "amazon-bedrock-trace": _BEDROCK_INVOKE_TRACE},
|
||||||
|
logging_obj=mock_logging_obj,
|
||||||
|
url_route="/v1/messages",
|
||||||
|
result="success",
|
||||||
|
start_time=datetime(2025, 1, 1),
|
||||||
|
end_time=datetime(2025, 1, 1),
|
||||||
|
cache_hit=False,
|
||||||
|
)
|
||||||
|
(entry,) = _recorded_guardrail_entries(mock_logging_obj)
|
||||||
|
assert entry["guardrail_provider"] == "bedrock"
|
||||||
|
assert entry["guardrail_usage"] == {"topicPolicyUnits": 3, "contentPolicyUnits": 3}
|
||||||
|
assert entry["guardrail_cost"] == pytest.approx(6 * 0.00015)
|
||||||
|
|
||||||
|
|
||||||
|
def test_anthropic_passthrough_stream_bills_the_bedrock_guardrail_trace_of_the_message_stop_event(
|
||||||
|
monkeypatch, mock_logging_obj, all_chunks
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(litellm, "model_cost", _BEDROCK_TRACE_PRICING)
|
||||||
|
mock_logging_obj.model_call_details["litellm_params"] = {"metadata": {}}
|
||||||
|
stop_event: Final = json.dumps({"type": "message_stop", "amazon-bedrock-trace": _BEDROCK_INVOKE_TRACE})
|
||||||
|
chunks: Final = [*all_chunks, f"event: message_stop\ndata: {stop_event}\n\n"]
|
||||||
|
AnthropicPassthroughLoggingHandler.handle_logging_anthropic_collected_chunks(
|
||||||
|
litellm_logging_obj=mock_logging_obj,
|
||||||
|
passthrough_success_handler_obj=MagicMock(),
|
||||||
|
url_route="/v1/messages",
|
||||||
|
request_body={"model": "claude", "messages": []},
|
||||||
|
endpoint_type=MagicMock(),
|
||||||
|
start_time=datetime(2025, 1, 1),
|
||||||
|
all_chunks=chunks,
|
||||||
|
end_time=datetime(2025, 1, 1),
|
||||||
|
)
|
||||||
|
(entry,) = _recorded_guardrail_entries(mock_logging_obj)
|
||||||
|
assert entry["guardrail_usage"] == {"topicPolicyUnits": 3, "contentPolicyUnits": 3}
|
||||||
|
assert entry["guardrail_cost"] == pytest.approx(6 * 0.00015)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue