mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-24 05:45:11 +00:00
Merge commit 'f81e657903' into litellm_fix_object_permission_partial_upsert
This commit is contained in:
commit
4ff5c941db
40 changed files with 6703 additions and 220 deletions
|
|
@ -207,7 +207,7 @@ _async_failure_callback: List[Union[str, Callable[..., object], "CustomLogger"]]
|
|||
[]
|
||||
) # internal variable - async custom callbacks are routed here.
|
||||
pre_call_rules: List[Callable] = []
|
||||
post_call_rules: List[Callable] = []
|
||||
post_call_rules: List[Callable[..., object]] = []
|
||||
turn_off_message_logging: Optional[bool] = False
|
||||
standard_logging_payload_excluded_fields: Optional[List[str]] = (
|
||||
None # Fields to exclude from StandardLoggingPayload before callbacks receive it
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ RUNTIME_UPDATABLE_ROUTER_SETTINGS: Final[frozenset[str]] = frozenset(
|
|||
"model_group_retry_policy",
|
||||
"model_group_alias",
|
||||
"enable_weighted_failover",
|
||||
"enable_mid_stream_fallback_continuation",
|
||||
"enable_tag_filtering",
|
||||
"tag_routing_prefix",
|
||||
"optional_pre_call_checks",
|
||||
|
|
@ -146,6 +147,7 @@ HF_CONFIG_FETCH_TIMEOUT_SECONDS: Final = 10.0
|
|||
# Streams exceeding this duration are terminated with a Timeout error.
|
||||
# None (default) = no limit. Set env var to a number of seconds to enable globally.
|
||||
LITELLM_MAX_STREAMING_DURATION_SECONDS: Final = get_env_float_or_none("LITELLM_MAX_STREAMING_DURATION_SECONDS")
|
||||
MID_STREAM_FAILURE_LOG_WAIT_SECONDS: Final = 1.0
|
||||
|
||||
# Maximum number of base64 characters to keep in logging payloads.
|
||||
# Data URIs exceeding this are replaced with a size placeholder.
|
||||
|
|
@ -1629,6 +1631,7 @@ INTERNAL_CALL_ORIGIN_METADATA_KEY: Final = "internal_call_origin"
|
|||
MANAGED_BACKGROUND_RESPONSE_METADATA_KEY: Final = "_managed_background_response"
|
||||
SESSION_ID_GENERATED_METADATA_KEY: Final = "litellm_session_id_generated"
|
||||
SESSION_ID_OMITTED_METADATA_KEY: Final = "litellm_session_id_omitted"
|
||||
CALLER_SESSION_ID_METADATA_KEY: Final = "litellm_caller_session_id"
|
||||
LITELLM_TRUNCATED_PAYLOAD_FIELD: Final = "litellm_truncated"
|
||||
LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE: Final = (
|
||||
"Truncation is a DB storage safeguard. "
|
||||
|
|
|
|||
|
|
@ -1217,6 +1217,7 @@ class MidStreamFallbackError(ServiceUnavailableError):
|
|||
num_retries: int | None = None,
|
||||
generated_content: str = "",
|
||||
is_pre_first_chunk: bool = False,
|
||||
emitted_disqualifying_content: bool = False,
|
||||
):
|
||||
original_status: Final = getattr(original_exception, "status_code", None)
|
||||
self.status_code = int(original_status) if original_status is not None else 503
|
||||
|
|
@ -1229,6 +1230,7 @@ class MidStreamFallbackError(ServiceUnavailableError):
|
|||
self.num_retries = num_retries
|
||||
self.generated_content = generated_content
|
||||
self.is_pre_first_chunk = is_pre_first_chunk
|
||||
self.emitted_disqualifying_content = emitted_disqualifying_content
|
||||
|
||||
# Create a response if one wasn't provided
|
||||
if response is None:
|
||||
|
|
|
|||
|
|
@ -2295,6 +2295,36 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
self.model_call_details["combined_usage_object"] = usage
|
||||
self.model_call_details["response_cost"] = response_cost
|
||||
|
||||
def record_continued_partial(self, usage: Usage, response_cost: float, response_id: str) -> None:
|
||||
"""Bill what a cut stream already consumed on this continuation's success log, under the id the caller saw."""
|
||||
self.model_call_details["continued_partial_usage"] = usage
|
||||
self.model_call_details["continued_partial_cost"] = response_cost
|
||||
self.model_call_details["continued_response_id"] = response_id
|
||||
|
||||
def _add_continued_partial_spend(self, response: object) -> None:
|
||||
partial_usage: Final = self.model_call_details.get("continued_partial_usage")
|
||||
partial_cost: Final = self.model_call_details.get("continued_partial_cost")
|
||||
own_usage: Final = getattr(response, "usage", None)
|
||||
own_cost: Final = self.model_call_details.get("response_cost")
|
||||
if not (isinstance(partial_usage, Usage) and isinstance(partial_cost, float) and isinstance(own_usage, Usage)):
|
||||
return
|
||||
from litellm.cost_calculator import BaseTokenUsageProcessor
|
||||
|
||||
self.model_call_details["combined_usage_object"] = BaseTokenUsageProcessor.combine_usage_objects(
|
||||
usage_objects=[own_usage, partial_usage]
|
||||
)
|
||||
if not isinstance(own_cost, (int, float)):
|
||||
return
|
||||
self.model_call_details["response_cost"] = own_cost + partial_cost
|
||||
if self.cost_breakdown is not None:
|
||||
self.cost_breakdown = self.cost_breakdown | CostBreakdown(
|
||||
total_cost=own_cost + partial_cost,
|
||||
additional_costs={
|
||||
**self.cost_breakdown.get("additional_costs", {}),
|
||||
"mid_stream_partial_cost": partial_cost,
|
||||
},
|
||||
)
|
||||
|
||||
def record_assembled_response_for_failure(self, assembled: ModelResponse) -> None:
|
||||
"""Bill a fully streamed response on the failure log when a post-call hook rejects it."""
|
||||
usage: Final = getattr(assembled, "usage", None)
|
||||
|
|
@ -2590,6 +2620,15 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
return response
|
||||
return response.model_copy(update={"model": model})
|
||||
|
||||
def _with_continued_response_id(
|
||||
self,
|
||||
response: ModelResponse | TextCompletionResponse | ResponsesAPIResponse | InteractionsAPIResponse,
|
||||
) -> ModelResponse | TextCompletionResponse | ResponsesAPIResponse | InteractionsAPIResponse:
|
||||
response_id: Final = self.model_call_details.get("continued_response_id")
|
||||
if not isinstance(response_id, str) or getattr(response, "id", None) == response_id:
|
||||
return response
|
||||
return response.model_copy(update={"id": response_id})
|
||||
|
||||
def _success_handler_helper_fn(
|
||||
self,
|
||||
result: object = None,
|
||||
|
|
@ -2908,8 +2947,11 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
self.model_call_details["response_cost"] = self.response_cost_calculator(
|
||||
result=complete_streaming_response
|
||||
)
|
||||
self._add_continued_partial_spend(complete_streaming_response)
|
||||
self._merge_hidden_params_from_response_into_metadata(complete_streaming_response)
|
||||
logged_streaming_response: Final = self._with_client_facing_stream_model(complete_streaming_response)
|
||||
logged_streaming_response: Final = self._with_continued_response_id(
|
||||
self._with_client_facing_stream_model(complete_streaming_response)
|
||||
)
|
||||
self.model_call_details["complete_streaming_response"] = logged_streaming_response
|
||||
## STANDARDIZED LOGGING PAYLOAD
|
||||
self.model_call_details["standard_logging_object"] = self._build_standard_logging_payload(
|
||||
|
|
@ -3445,12 +3487,15 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
self.model,
|
||||
)
|
||||
self.model_call_details["response_cost"] = None
|
||||
self._add_continued_partial_spend(complete_streaming_response)
|
||||
|
||||
self._merge_hidden_params_from_response_into_metadata(complete_streaming_response)
|
||||
|
||||
await self._prepare_baseline_cache_estimate(complete_streaming_response)
|
||||
|
||||
logged_streaming_response: Final = self._with_client_facing_stream_model(complete_streaming_response)
|
||||
logged_streaming_response: Final = self._with_continued_response_id(
|
||||
self._with_client_facing_stream_model(complete_streaming_response)
|
||||
)
|
||||
self.model_call_details["async_complete_streaming_response"] = logged_streaming_response
|
||||
|
||||
## STANDARDIZED LOGGING PAYLOAD
|
||||
|
|
|
|||
|
|
@ -1894,6 +1894,11 @@ def strip_litellm_internal_message_fields(message: AllMessageValues) -> AllMessa
|
|||
return _message_without_fields(message, LITELLM_INTERNAL_MESSAGE_FIELDS)
|
||||
|
||||
|
||||
def strip_prefill_prefix_field(message: AllMessageValues) -> AllMessageValues:
|
||||
"""Drop the ``prefix`` flag Mistral and DeepSeek read off a prefill, for endpoints that reject unknown keys."""
|
||||
return _message_without_fields(message, frozenset({"prefix"}))
|
||||
|
||||
|
||||
def filter_value_from_dict(dictionary: dict, key: str, depth: int = 0) -> Any:
|
||||
"""
|
||||
Filters a value from a dictionary
|
||||
|
|
|
|||
|
|
@ -4303,6 +4303,24 @@ def _with_text_when_document_only(message: BedrockMessageBlock) -> BedrockMessag
|
|||
return BedrockMessageBlock(role="user", content=[*blocks[:cut], placeholder, *blocks[cut:]])
|
||||
|
||||
|
||||
def _rstrip_text_block(block: BedrockContentBlock) -> BedrockContentBlock:
|
||||
return BedrockContentBlock(text=block["text"].rstrip()) if "text" in block else block
|
||||
|
||||
|
||||
def _assistant_turn_rstripped(message: BedrockMessageBlock) -> BedrockMessageBlock:
|
||||
"""Claude on Converse rejects a final assistant turn that ends in whitespace, as the Anthropic path strips it."""
|
||||
if message["role"] != "assistant":
|
||||
return message
|
||||
blocks: Final = message["content"]
|
||||
last_text_index: Final = max((index for index, block in enumerate(blocks) if "text" in block), default=None)
|
||||
return BedrockMessageBlock(
|
||||
role="assistant",
|
||||
content=[
|
||||
_rstrip_text_block(block) if index == last_text_index else block for index, block in enumerate(blocks)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _ensure_document_messages_have_text(
|
||||
contents: list[BedrockMessageBlock],
|
||||
) -> list[BedrockMessageBlock]:
|
||||
|
|
@ -4894,7 +4912,12 @@ class BedrockConverseMessagesProcessor:
|
|||
llm_provider=llm_provider,
|
||||
)
|
||||
|
||||
return _ensure_document_messages_have_text(_rename_duplicate_bedrock_document_names(contents))
|
||||
converse_messages: Final = _ensure_document_messages_have_text(
|
||||
_rename_duplicate_bedrock_document_names(contents)
|
||||
)
|
||||
if not (to_claude and converse_messages):
|
||||
return converse_messages
|
||||
return [*converse_messages[:-1], _assistant_turn_rstripped(converse_messages[-1])]
|
||||
|
||||
@staticmethod
|
||||
def translate_thinking_blocks_to_reasoning_content_blocks(
|
||||
|
|
@ -5303,7 +5326,10 @@ def _bedrock_converse_messages_pt(
|
|||
llm_provider=llm_provider,
|
||||
)
|
||||
|
||||
return _ensure_document_messages_have_text(_rename_duplicate_bedrock_document_names(contents))
|
||||
converse_messages: Final = _ensure_document_messages_have_text(_rename_duplicate_bedrock_document_names(contents))
|
||||
if not (to_claude and converse_messages):
|
||||
return converse_messages
|
||||
return [*converse_messages[:-1], _assistant_turn_rstripped(converse_messages[-1])]
|
||||
|
||||
|
||||
def make_valid_bedrock_tool_name(input_tool_name: str) -> str:
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@ class CustomStreamWrapper:
|
|||
self.completion_stream = completion_stream
|
||||
self.sent_first_chunk = False
|
||||
self.sent_last_chunk = False
|
||||
self._failure_logging: asyncio.Task[None] | None = None
|
||||
self._closed = False
|
||||
self._stream_created_time: float = time.time()
|
||||
|
||||
|
|
@ -269,6 +270,7 @@ class CustomStreamWrapper:
|
|||
self.holding_chunk = ""
|
||||
self.complete_response = ""
|
||||
self.response_uptil_now = ""
|
||||
self._emitted_disqualifying_content = False
|
||||
_model_info: Final[dict] = litellm_params.model_info or {}
|
||||
|
||||
_api_base: Final = get_api_base(
|
||||
|
|
@ -1129,6 +1131,7 @@ class CustomStreamWrapper:
|
|||
if self.merge_reasoning_content_in_choices is True:
|
||||
reasoning_content: Final = getattr(model_response.choices[0].delta, "reasoning_content", None)
|
||||
if reasoning_content:
|
||||
self._emitted_disqualifying_content = True
|
||||
if self.sent_first_thinking_block is False:
|
||||
# Ensure content is not None before concatenation
|
||||
if model_response.choices[0].delta.content is None:
|
||||
|
|
@ -1865,9 +1868,7 @@ class CustomStreamWrapper:
|
|||
if response.choices:
|
||||
choice = response.choices[0]
|
||||
if isinstance(choice, StreamingChoices):
|
||||
self.response_uptil_now += choice.delta.get("content", "") or ""
|
||||
else:
|
||||
self.response_uptil_now += ""
|
||||
self._accumulate_streamed_delta(choice.delta)
|
||||
self.rules.post_call_rules(input=self.response_uptil_now, model=self.model)
|
||||
# HANDLE STREAM OPTIONS
|
||||
self.chunks.append(response)
|
||||
|
|
@ -2084,9 +2085,7 @@ class CustomStreamWrapper:
|
|||
if processed_chunk.choices:
|
||||
choice = processed_chunk.choices[0]
|
||||
if isinstance(choice, StreamingChoices):
|
||||
self.response_uptil_now += choice.delta.get("content", "") or ""
|
||||
else:
|
||||
self.response_uptil_now += ""
|
||||
self._accumulate_streamed_delta(choice.delta)
|
||||
self.rules.post_call_rules(input=self.response_uptil_now, model=self.model)
|
||||
# Add mcp_list_tools to first chunk if present
|
||||
if not self.sent_first_chunk and processed_chunk.choices:
|
||||
|
|
@ -2156,9 +2155,7 @@ class CustomStreamWrapper:
|
|||
|
||||
choice = processed_chunk.choices[0]
|
||||
if isinstance(choice, StreamingChoices):
|
||||
self.response_uptil_now += choice.delta.get("content", "") or ""
|
||||
else:
|
||||
self.response_uptil_now += ""
|
||||
self._accumulate_streamed_delta(choice.delta)
|
||||
self.rules.post_call_rules(input=self.response_uptil_now, model=self.model)
|
||||
# RETURN RESULT
|
||||
self.chunks.append(processed_chunk)
|
||||
|
|
@ -2172,7 +2169,7 @@ class CustomStreamWrapper:
|
|||
if self.logging_obj is not None:
|
||||
self._record_partial_usage_for_failure()
|
||||
## LOGGING
|
||||
asyncio.create_task(
|
||||
self._failure_logging = asyncio.create_task(
|
||||
self.logging_obj.dispatch_failure_handlers(e, traceback_exception, prefer_async_handlers=True)
|
||||
)
|
||||
await self.aclose()
|
||||
|
|
@ -2305,11 +2302,17 @@ class CustomStreamWrapper:
|
|||
if self.logging_obj is not None:
|
||||
self._record_partial_usage_for_failure()
|
||||
## LOGGING
|
||||
asyncio.create_task(
|
||||
self._failure_logging = asyncio.create_task(
|
||||
self.logging_obj.dispatch_failure_handlers(e, traceback_exception, prefer_async_handlers=True)
|
||||
)
|
||||
self._handle_stream_fallback_error(e)
|
||||
|
||||
async def wait_for_failure_logging(self, timeout: float) -> None:
|
||||
"""Let this stream's failure log read its own deployment before a fallback reuses the logging object."""
|
||||
if self._failure_logging is None:
|
||||
return
|
||||
await asyncio.wait((self._failure_logging,), timeout=timeout)
|
||||
|
||||
def _record_partial_usage_for_failure(self) -> None:
|
||||
"""
|
||||
A stream that breaks mid-flight still billed the provider for the chunks
|
||||
|
|
@ -2346,6 +2349,35 @@ class CustomStreamWrapper:
|
|||
recover_error,
|
||||
)
|
||||
|
||||
# Delta fields a text-only prefill continuation cannot carry, so a stream
|
||||
# that emitted any of them is not eligible for mid-stream continuation.
|
||||
_CONTINUATION_DISQUALIFYING_DELTA_FIELDS: Final = (
|
||||
"tool_calls",
|
||||
"function_call",
|
||||
"reasoning_content",
|
||||
"thinking_blocks",
|
||||
"reasoning_items",
|
||||
"audio",
|
||||
"images",
|
||||
"annotations",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _delta_disqualifies_continuation(cls, delta: object) -> bool:
|
||||
get: Final = getattr(delta, "get", None)
|
||||
if not callable(get):
|
||||
return False
|
||||
return any(get(field) for field in cls._CONTINUATION_DISQUALIFYING_DELTA_FIELDS)
|
||||
|
||||
def _accumulate_streamed_delta(self, delta: object) -> None:
|
||||
# Shared by the sync, async, and non-aiohttp iteration sites so answer
|
||||
# text and the disqualifying-content latch stay in step across all three.
|
||||
get: Final = getattr(delta, "get", None)
|
||||
content: Final = get("content") if callable(get) else None
|
||||
self.response_uptil_now += content if isinstance(content, str) else ""
|
||||
if not self._emitted_disqualifying_content and self._delta_disqualifies_continuation(delta):
|
||||
self._emitted_disqualifying_content = True
|
||||
|
||||
def _handle_stream_fallback_error(self, e: Exception) -> "NoReturn":
|
||||
"""
|
||||
Common error handling for both __next__ and __anext__.
|
||||
|
|
@ -2377,6 +2409,7 @@ class CustomStreamWrapper:
|
|||
original_exception=e.original_exception,
|
||||
generated_content=self.response_uptil_now,
|
||||
is_pre_first_chunk=not self.sent_first_chunk,
|
||||
emitted_disqualifying_content=self._emitted_disqualifying_content or e.emitted_disqualifying_content,
|
||||
)
|
||||
|
||||
# Map to OpenAI exception format. Some providers' mappers (e.g.
|
||||
|
|
@ -2437,6 +2470,7 @@ class CustomStreamWrapper:
|
|||
original_exception=mapped_exception,
|
||||
generated_content=self.response_uptil_now,
|
||||
is_pre_first_chunk=not self.sent_first_chunk,
|
||||
emitted_disqualifying_content=self._emitted_disqualifying_content,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
123
litellm/llms/base_llm/videos/async_task_utils.py
Normal file
123
litellm/llms/base_llm/videos/async_task_utils.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import base64
|
||||
from collections.abc import Mapping
|
||||
from io import BufferedReader, BytesIO
|
||||
from itertools import accumulate, repeat
|
||||
from pathlib import Path, PurePath
|
||||
from typing import Final
|
||||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from litellm.images.utils import ImageEditRequestUtils
|
||||
from litellm.types.videos.utils import decode_video_id_with_provider
|
||||
|
||||
_FIELDS_ADAPTER: Final = TypeAdapter(dict[str, object])
|
||||
_FILE_TUPLE_ADAPTER: Final = TypeAdapter(tuple[object, ...])
|
||||
|
||||
|
||||
def _fields(value: object) -> Mapping[str, object] | None:
|
||||
try:
|
||||
return _FIELDS_ADAPTER.validate_python(value)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def _logged_litellm_params(logging_obj: object) -> Mapping[str, object]:
|
||||
return _fields(getattr(logging_obj, "litellm_params", None)) or {}
|
||||
|
||||
|
||||
def _id_safe_name(name: object) -> str | None:
|
||||
return name if isinstance(name, str) and name and ";" not in name else None
|
||||
|
||||
|
||||
def _stamped_models(metadata: object) -> tuple[str, str | None] | None:
|
||||
fields: Final = _fields(metadata)
|
||||
if fields is None:
|
||||
return None
|
||||
requested: Final = _id_safe_name(fields.get("model_group"))
|
||||
served: Final = _id_safe_name(fields.get("deployment_model_name"))
|
||||
if requested is None:
|
||||
return None
|
||||
return requested, None if served is None or served == requested or "*" in served else served
|
||||
|
||||
|
||||
def routed_video_models(logging_obj: object) -> tuple[str, str | None] | None:
|
||||
"""
|
||||
A later status or content request checks the key's model access against the model encoded in the id, and
|
||||
routes by it. One upstream model name can be served by several deployments (regions, accounts), so only the
|
||||
group that served the task can send its polls back to the deployment that created it, while a key may be
|
||||
scoped to the alias, routing group or wildcard name it asked for instead. The id carries both names: the one
|
||||
the router was asked for on this hop, and the group that served it when that differs and is not a wildcard.
|
||||
The router stamps both into litellm_metadata, and metadata carries a copy only when the caller sent none.
|
||||
"""
|
||||
params: Final = _logged_litellm_params(logging_obj)
|
||||
stamped: Final = (_stamped_models(params.get(key)) for key in ("litellm_metadata", "metadata"))
|
||||
return next((models for models in stamped if models), None)
|
||||
|
||||
|
||||
def polled_video_models(logging_obj: object) -> tuple[str | None, str | None]:
|
||||
"""
|
||||
The served group survives into the returned id only when the router sent this poll to it, so an edited id
|
||||
cannot write any name it likes back.
|
||||
"""
|
||||
video_id: Final = _logged_litellm_params(logging_obj).get("video_id")
|
||||
if not isinstance(video_id, str):
|
||||
return None, None
|
||||
decoded: Final = decode_video_id_with_provider(video_id)
|
||||
served: Final = decoded.get("served_model")
|
||||
routed: Final = routed_video_models(logging_obj)
|
||||
return decoded.get("model_id") or None, served if routed is None or served in routed else None
|
||||
|
||||
|
||||
def _tuple_payload(file_obj: object) -> object:
|
||||
if not isinstance(file_obj, tuple):
|
||||
return file_obj
|
||||
file_tuple: Final = _FILE_TUPLE_ADAPTER.validate_python(file_obj)
|
||||
return file_tuple[1] if len(file_tuple) >= 2 else None
|
||||
|
||||
|
||||
def _file_content(file_obj: object) -> object:
|
||||
unwrapped: Final = accumulate(repeat(None), lambda current, _: _tuple_payload(current), initial=file_obj)
|
||||
return next(item for item in unwrapped if not isinstance(item, tuple))
|
||||
|
||||
|
||||
def input_reference_bytes(file_obj: object) -> bytes:
|
||||
content: Final = _file_content(file_obj)
|
||||
if isinstance(content, PurePath):
|
||||
return Path(content).read_bytes()
|
||||
if isinstance(content, (BytesIO, BufferedReader)):
|
||||
current_position: Final = content.tell()
|
||||
_ = content.seek(0)
|
||||
data: Final = content.read()
|
||||
_ = content.seek(current_position)
|
||||
return data
|
||||
if isinstance(content, bytes):
|
||||
return content
|
||||
if isinstance(content, bytearray):
|
||||
return bytes(content)
|
||||
read: Final = getattr(content, "read", None)
|
||||
if callable(read):
|
||||
read_data: Final[object] = read()
|
||||
if isinstance(read_data, bytes):
|
||||
return read_data
|
||||
raise ValueError("input_reference must be a URL string, bytes, a path, a file object, or a file tuple")
|
||||
|
||||
|
||||
def _reference_object_url(reference: Mapping[str, object]) -> str:
|
||||
image_url: Final = reference.get("image_url")
|
||||
if isinstance(image_url, str) and image_url:
|
||||
return image_url
|
||||
raise ValueError("input_reference as an object must carry an image_url, a file_id is not supported")
|
||||
|
||||
|
||||
def input_reference_url(image: object) -> str:
|
||||
"""A string is a URL the provider fetches itself and is never opened as a local path. OpenAI's object form
|
||||
carries that URL, or a data URL, in ``image_url``."""
|
||||
if isinstance(image, str):
|
||||
return image
|
||||
reference: Final = _fields(image)
|
||||
if reference is not None:
|
||||
return _reference_object_url(reference)
|
||||
content: Final = input_reference_bytes(image)
|
||||
content_type: Final = ImageEditRequestUtils.get_image_content_type(content)
|
||||
encoded: Final = base64.b64encode(content).decode("utf-8")
|
||||
return f"data:{content_type};base64,{encoded}"
|
||||
|
|
@ -3,13 +3,10 @@ DashScope (Alibaba Model Studio) async video task API: create, poll and download
|
|||
delete or remix endpoint, so those raise.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime
|
||||
from io import BufferedReader, BytesIO
|
||||
from math import log
|
||||
from pathlib import Path, PurePath
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Annotated, Final
|
||||
|
||||
|
|
@ -19,9 +16,13 @@ from pydantic import BaseModel, Field, StrictBool, StrictInt, StrictStr, TypeAda
|
|||
|
||||
import litellm
|
||||
from litellm.exceptions import UnsupportedParamsError
|
||||
from litellm.images.utils import ImageEditRequestUtils
|
||||
from litellm.litellm_core_utils.url_utils import encode_url_path_segment
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.base_llm.videos.async_task_utils import (
|
||||
input_reference_url,
|
||||
polled_video_models,
|
||||
routed_video_models,
|
||||
)
|
||||
from litellm.llms.base_llm.videos.transformation import BaseVideoConfig
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
_get_httpx_client, # pyright: ignore[reportPrivateUsage, reportUnknownVariableType] # house cached-client factory has no public alias and its stub leaves params untyped
|
||||
|
|
@ -32,7 +33,6 @@ from litellm.types.router import GenericLiteLLMParams
|
|||
from litellm.types.utils import ModelInfo
|
||||
from litellm.types.videos.main import VideoCreateOptionalRequestParams, VideoObject
|
||||
from litellm.types.videos.utils import (
|
||||
decode_video_id_with_provider,
|
||||
encode_video_id_with_provider,
|
||||
extract_original_video_id,
|
||||
)
|
||||
|
|
@ -86,7 +86,6 @@ _EMPTY_PARAMS: Final[dict[str, object]] = {} # mutable-ok: BaseVideoConfig cont
|
|||
|
||||
_TASK_RESPONSE_ADAPTER: Final = TypeAdapter(_TaskResponse)
|
||||
_PARAMETERS_ADAPTER: Final = TypeAdapter(dict[str, object])
|
||||
_FILE_TUPLE_ADAPTER: Final = TypeAdapter(tuple[object, ...])
|
||||
|
||||
_STATUS_MAP: Final = MappingProxyType(
|
||||
{
|
||||
|
|
@ -383,39 +382,6 @@ def _is_priced_tier(resolution: object, priced_tiers: tuple[str, ...]) -> bool:
|
|||
return isinstance(resolution, str) and resolution.strip().upper() in priced_tiers
|
||||
|
||||
|
||||
def _read_all_bytes(file_obj: object) -> bytes:
|
||||
if isinstance(file_obj, tuple):
|
||||
file_tuple: Final = _FILE_TUPLE_ADAPTER.validate_python(file_obj)
|
||||
return _read_all_bytes(file_tuple[1] if len(file_tuple) >= 2 else None)
|
||||
if isinstance(file_obj, PurePath):
|
||||
return Path(file_obj).read_bytes()
|
||||
if isinstance(file_obj, (BytesIO, BufferedReader)):
|
||||
current_position: Final = file_obj.tell()
|
||||
file_obj.seek(0)
|
||||
content: Final = file_obj.read()
|
||||
file_obj.seek(current_position)
|
||||
return content
|
||||
if isinstance(file_obj, bytes):
|
||||
return file_obj
|
||||
if isinstance(file_obj, bytearray):
|
||||
return bytes(file_obj)
|
||||
read: Final = getattr(file_obj, "read", None)
|
||||
if callable(read):
|
||||
data: Final = read()
|
||||
if isinstance(data, bytes):
|
||||
return data
|
||||
raise ValueError("input_reference must be a URL string, bytes, a path, a file object, or a file tuple")
|
||||
|
||||
|
||||
def _image_url(image: object) -> str:
|
||||
if isinstance(image, str):
|
||||
return image
|
||||
content: Final = _read_all_bytes(image)
|
||||
content_type: Final = ImageEditRequestUtils.get_image_content_type(content)
|
||||
encoded: Final = base64.b64encode(content).decode("utf-8")
|
||||
return f"data:{content_type};base64,{encoded}"
|
||||
|
||||
|
||||
def _media_reference_type(model: str) -> str | None:
|
||||
"""Models without the field predate the media array and take the image on the flat ``img_url`` field."""
|
||||
info: Final = _dashscope_model_info(model)
|
||||
|
|
@ -558,58 +524,6 @@ def _video_url_from_task(task: _TaskResponse, raw_response: httpx.Response) -> s
|
|||
raise _video_error(raw_response, 502, "Video URL not found in task response. The task may not have succeeded yet.")
|
||||
|
||||
|
||||
def _logged_litellm_params(logging_obj: object) -> Mapping[str, object]:
|
||||
litellm_params: Final[object] = getattr(logging_obj, "litellm_params", None)
|
||||
try:
|
||||
return _PARAMETERS_ADAPTER.validate_python(litellm_params)
|
||||
except ValidationError:
|
||||
return _EMPTY_PARAMS
|
||||
|
||||
|
||||
def _polled_models(logging_obj: object) -> tuple[str | None, str | None]:
|
||||
"""
|
||||
The proxy routes by the models encoded in the polled id, so they must survive into the returned id. The served
|
||||
group survives only when the router sent this poll to it, so an edited id cannot write any name it likes back.
|
||||
"""
|
||||
video_id: Final = _logged_litellm_params(logging_obj).get("video_id")
|
||||
if not isinstance(video_id, str):
|
||||
return None, None
|
||||
decoded: Final = decode_video_id_with_provider(video_id)
|
||||
served: Final = decoded.get("served_model")
|
||||
routed: Final = _poll_models(logging_obj)
|
||||
return decoded.get("model_id") or None, served if routed is None or served in routed else None
|
||||
|
||||
|
||||
def _poll_models(logging_obj: object) -> tuple[str, str | None] | None:
|
||||
"""
|
||||
A later status or content request checks the key's model access against the model encoded in the id, and
|
||||
routes by it. DashScope serves the same model name from several regional hosts, so only the group that
|
||||
served the task can send its polls back to the region that created it, while a key may be scoped to the
|
||||
alias, routing group or wildcard name it asked for instead. The id carries both names: the one the router
|
||||
was asked for on this hop, and the group that served it when that differs and is not a wildcard. The router
|
||||
stamps both into litellm_metadata, and metadata carries a copy only when the caller sent none.
|
||||
"""
|
||||
params: Final = _logged_litellm_params(logging_obj)
|
||||
stamped: Final = (_stamped_poll_models(params.get(key)) for key in ("litellm_metadata", "metadata"))
|
||||
return next((models for models in stamped if models), None)
|
||||
|
||||
|
||||
def _stamped_poll_models(metadata: object) -> tuple[str, str | None] | None:
|
||||
try:
|
||||
fields: Final = _PARAMETERS_ADAPTER.validate_python(metadata)
|
||||
except ValidationError:
|
||||
return None
|
||||
requested: Final = _id_safe_name(fields.get("model_group"))
|
||||
served: Final = _id_safe_name(fields.get("deployment_model_name"))
|
||||
if requested is None:
|
||||
return None
|
||||
return requested, None if served is None or served == requested or "*" in served else served
|
||||
|
||||
|
||||
def _id_safe_name(name: object) -> str | None:
|
||||
return name if isinstance(name, str) and name and ";" not in name else None
|
||||
|
||||
|
||||
class DashScopeVideoConfig(BaseVideoConfig):
|
||||
def get_supported_openai_params(self, model: str) -> list[str]: # mutable-ok: BaseVideoConfig contract returns list
|
||||
return [
|
||||
|
|
@ -804,7 +718,7 @@ class DashScopeVideoConfig(BaseVideoConfig):
|
|||
input_reference: Final = video_create_optional_request_params.get("input_reference")
|
||||
if input_reference is None:
|
||||
return None, None
|
||||
image_url: Final = _image_url(input_reference)
|
||||
image_url: Final = input_reference_url(input_reference)
|
||||
media_type: Final = _media_reference_type(model)
|
||||
if media_type is None:
|
||||
return "img_url", image_url
|
||||
|
|
@ -823,7 +737,7 @@ class DashScopeVideoConfig(BaseVideoConfig):
|
|||
requested: Final = self._requested_parameters(request_data)
|
||||
video_obj: Final = _video_object_from_task(task, raw_response, model=model, requested=requested)
|
||||
if custom_llm_provider and video_obj.id:
|
||||
requested_model, served_model = _poll_models(logging_obj) or (model, None)
|
||||
requested_model, served_model = routed_video_models(logging_obj) or (model, None)
|
||||
video_obj.id = encode_video_id_with_provider(
|
||||
video_obj.id, custom_llm_provider, requested_model, served_model
|
||||
)
|
||||
|
|
@ -869,7 +783,7 @@ class DashScopeVideoConfig(BaseVideoConfig):
|
|||
client: "HTTPHandler | None" = None,
|
||||
) -> VideoObject:
|
||||
task: Final = _parse_task_response(raw_response)
|
||||
model, served_model = _polled_models(logging_obj)
|
||||
model, served_model = polled_video_models(logging_obj)
|
||||
video_obj: Final = _video_object_from_task(task, raw_response, model=model)
|
||||
if custom_llm_provider and video_obj.id:
|
||||
video_obj.id = encode_video_id_with_provider(video_obj.id, custom_llm_provider, model, served_model)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
|||
merge_consecutive_system_messages,
|
||||
strip_litellm_internal_message_fields,
|
||||
strip_name_from_message,
|
||||
strip_prefill_prefix_field,
|
||||
)
|
||||
from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator
|
||||
from litellm.llms.base_llm.base_utils import (
|
||||
|
|
@ -487,6 +488,7 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig):
|
|||
Databricks does not support:
|
||||
- 'name' in user message.
|
||||
- litellm's internal `thinking_blocks` / `reasoning_content` on assistant messages.
|
||||
- the `prefix` prefill flag Mistral and DeepSeek read, which Claude does not need to continue an assistant turn.
|
||||
- non-Claude json_object "json" checks read only string content, so a single text block is sent as a string.
|
||||
"""
|
||||
new_messages = []
|
||||
|
|
@ -496,7 +498,7 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig):
|
|||
else:
|
||||
_message = message
|
||||
_message = strip_name_from_message(_message, allowed_name_roles=["user"])
|
||||
_message = strip_litellm_internal_message_fields(_message)
|
||||
_message = strip_prefill_prefix_field(strip_litellm_internal_message_fields(_message))
|
||||
# Move message-level cache_control into a content block when content is a string.
|
||||
if "cache_control" in _message and isinstance(_message.get("content"), str):
|
||||
_message = self._move_cache_control_into_string_content_block(_message)
|
||||
|
|
|
|||
0
litellm/llms/volcengine/videos/__init__.py
Normal file
0
litellm/llms/volcengine/videos/__init__.py
Normal file
904
litellm/llms/volcengine/videos/transformation.py
Normal file
904
litellm/llms/volcengine/videos/transformation.py
Normal file
|
|
@ -0,0 +1,904 @@
|
|||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from math import log
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final, TypeVar
|
||||
|
||||
import httpx
|
||||
from httpx._types import RequestFiles
|
||||
from pydantic import BaseModel, StrictBool, StrictInt, StrictStr, TypeAdapter, ValidationError
|
||||
|
||||
import litellm
|
||||
from litellm.exceptions import UnsupportedParamsError
|
||||
from litellm.litellm_core_utils.url_utils import encode_url_path_segment
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
from litellm.llms.base_llm.videos.async_task_utils import (
|
||||
input_reference_url,
|
||||
polled_video_models,
|
||||
routed_video_models,
|
||||
)
|
||||
from litellm.llms.base_llm.videos.transformation import BaseVideoConfig
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
AsyncHTTPHandler,
|
||||
HTTPHandler,
|
||||
_get_httpx_client, # pyright: ignore[reportPrivateUsage, reportUnknownVariableType] # house cached-client factory has no public alias and its stub leaves params untyped
|
||||
get_async_httpx_client, # pyright: ignore[reportUnknownVariableType] # factory stub leaves params untyped
|
||||
)
|
||||
from litellm.llms.volcengine.common_utils import get_volcengine_base_url
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import ModelInfo
|
||||
from litellm.types.videos.main import VideoCreateOptionalRequestParams, VideoObject
|
||||
from litellm.types.videos.utils import encode_video_id_with_provider, extract_original_video_id
|
||||
from litellm.utils import get_model_info
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
|
||||
|
||||
_ParsedT: Final = TypeVar("_ParsedT")
|
||||
|
||||
|
||||
class VolcEngineVideoError(BaseLLMException):
|
||||
pass
|
||||
|
||||
|
||||
class _TaskError(BaseModel, frozen=True):
|
||||
code: str | None = None
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class _TaskContent(BaseModel, frozen=True):
|
||||
video_url: str | None = None
|
||||
|
||||
|
||||
class _TaskUsage(BaseModel, frozen=True):
|
||||
total_tokens: int | None = None
|
||||
|
||||
|
||||
class _Task(BaseModel, frozen=True):
|
||||
id: str
|
||||
status: str
|
||||
model: str | None = None
|
||||
error: _TaskError | None = None
|
||||
content: _TaskContent | None = None
|
||||
usage: _TaskUsage | None = None
|
||||
created_at: int | None = None
|
||||
updated_at: int | None = None
|
||||
resolution: str | None = None
|
||||
ratio: str | None = None
|
||||
duration: int | float | None = None
|
||||
|
||||
|
||||
class _TaskCreateResponse(BaseModel, frozen=True):
|
||||
id: str | None = None
|
||||
error: _TaskError | None = None
|
||||
|
||||
|
||||
class _TaskList(BaseModel, frozen=True):
|
||||
items: Sequence[_Task] = ()
|
||||
total: int | None = None
|
||||
|
||||
|
||||
class _ContentItem(BaseModel, frozen=True):
|
||||
type: str | None = None
|
||||
role: str | None = None
|
||||
|
||||
|
||||
class _KnownFields(BaseModel, frozen=True):
|
||||
resolution: StrictStr | None = None
|
||||
ratio: StrictStr | None = None
|
||||
seed: StrictInt | None = None
|
||||
camera_fixed: StrictBool | None = None
|
||||
watermark: StrictBool | None = None
|
||||
generate_audio: StrictBool | None = None
|
||||
|
||||
|
||||
VOLCENGINE_VIDEO_TASKS_PATH: Final = "/api/v3/contents/generations/tasks"
|
||||
VOLCENGINE_VIDEO_DEFAULT_DURATION_SECONDS: Final = 4
|
||||
VOLCENGINE_VIDEO_DEFAULT_RESOLUTION: Final = "720p"
|
||||
_VOLCENGINE_API_MARKER: Final = "/api/"
|
||||
_VOLCENGINE_COMPAT_SUFFIXES: Final = ("/api", "/v1")
|
||||
_FRAMES_FIELD: Final = "volcengine_video_frames"
|
||||
|
||||
_EMPTY_PARAMS: Final[dict[str, object]] = {} # mutable-ok: BaseVideoConfig contract empty params
|
||||
|
||||
_TASK_ADAPTER: Final = TypeAdapter(_Task)
|
||||
_TASK_CREATE_ADAPTER: Final = TypeAdapter(_TaskCreateResponse)
|
||||
_TASK_LIST_ADAPTER: Final = TypeAdapter(_TaskList)
|
||||
_FIELDS_ADAPTER: Final = TypeAdapter(dict[str, object])
|
||||
_CONTENT_ITEMS_ADAPTER: Final = TypeAdapter(tuple[dict[str, object], ...])
|
||||
_FRAMES_ADAPTER: Final = TypeAdapter(dict[str, dict[str, str]])
|
||||
_EMPTY_FRAMES: Final[Mapping[str, Mapping[str, str]]] = MappingProxyType({})
|
||||
|
||||
_STATUS_MAP: Final = MappingProxyType(
|
||||
{
|
||||
"queued": "queued",
|
||||
"running": "in_progress",
|
||||
"succeeded": "completed",
|
||||
"failed": "failed",
|
||||
"cancelled": "cancelled",
|
||||
"expired": "failed",
|
||||
}
|
||||
)
|
||||
_PENDING_STATUSES: Final = frozenset({"queued", "running"})
|
||||
_TERMINAL_STATUSES: Final = frozenset({"completed", "failed", "cancelled"})
|
||||
|
||||
_CREATE_PASSTHROUGH_KEYS: Final = (
|
||||
"resolution",
|
||||
"ratio",
|
||||
"duration",
|
||||
"seed",
|
||||
"camera_fixed",
|
||||
"watermark",
|
||||
"generate_audio",
|
||||
"service_tier",
|
||||
"priority",
|
||||
"output_format",
|
||||
"execution_expires_after",
|
||||
"callback_url",
|
||||
"safety_identifier",
|
||||
"tools",
|
||||
"omni_reference_task_type",
|
||||
)
|
||||
|
||||
_REQUEST_METADATA: Final = frozenset({"model", "prompt", "user", "extra_headers", "extra_query", "extra_body"})
|
||||
_UNSUPPORTED_BY_VOLCENGINE: Final = frozenset({"characters", "image"})
|
||||
_MAPPED_ELSEWHERE: Final = frozenset({*_REQUEST_METADATA, *_UNSUPPORTED_BY_VOLCENGINE, "seconds", "size", "parameters"})
|
||||
_READ_FROM_THE_REQUEST: Final = _MAPPED_ELSEWHERE - _UNSUPPORTED_BY_VOLCENGINE
|
||||
_LITELLM_CONTROLS: Final = frozenset({"drop_params", "additional_drop_params", "organization"})
|
||||
_DROP_KEYS_ADAPTER: Final = TypeAdapter(frozenset[str])
|
||||
|
||||
# litellm/videos/utils.py merges the caller's raw ``extra_body`` back over the mapped params, which returns the keys
|
||||
# this adapter maps itself a second time, as fields of their own. They are already accounted for, so they count as
|
||||
# handled rather than unknown.
|
||||
_MAPPED_EXTRA_BODY_COPIES: Final = frozenset({"size", "seconds", "user"})
|
||||
_CREATE_HANDLED_KEYS: Final = frozenset(
|
||||
{
|
||||
*_CREATE_PASSTHROUGH_KEYS,
|
||||
"content",
|
||||
"input_reference",
|
||||
"frames",
|
||||
*_MAPPED_EXTRA_BODY_COPIES,
|
||||
*_LITELLM_CONTROLS,
|
||||
}
|
||||
)
|
||||
|
||||
_VIDEO_INPUT_UNSUPPORTED: Final = (
|
||||
"{operation} is not supported through litellm: Ark bills a task with a video input at a different rate per "
|
||||
"token, and litellm bills the video when the task is created, before that cost is known"
|
||||
)
|
||||
_INPUT_VIDEO_ROLE: Final = "reference_video"
|
||||
_INPUT_VIDEO_TYPE: Final = "video_url"
|
||||
|
||||
_RATIO_SET: Final = ("16:9", "9:16", "4:3", "3:4", "1:1", "21:9")
|
||||
_RESOLUTION_SHORTEST_SIDE: Final = MappingProxyType({"480p": 480, "720p": 720, "1080p": 1080, "4k": 2160})
|
||||
_TIER_FRAME_ASPECT: Final = 16 / 9
|
||||
|
||||
|
||||
def _video_error(raw_response: httpx.Response, status_code: int, message: str) -> VolcEngineVideoError:
|
||||
"""The provider's own error status wins, and a body that failed on a 2xx answers the caller's status instead,
|
||||
since a 2xx error has the proxy retry a call the provider already rejected."""
|
||||
return VolcEngineVideoError(
|
||||
status_code=raw_response.status_code if raw_response.status_code >= 400 else status_code,
|
||||
message=message,
|
||||
headers=raw_response.headers,
|
||||
)
|
||||
|
||||
|
||||
def _parse(adapter: TypeAdapter[_ParsedT], raw_response: httpx.Response) -> _ParsedT:
|
||||
try:
|
||||
return adapter.validate_python(raw_response.json())
|
||||
except (json.JSONDecodeError, ValidationError) as e:
|
||||
raise _video_error(raw_response, 502, f"Unexpected Volcengine video response: {e}") from e
|
||||
|
||||
|
||||
def _unsupported(model: str, message: str) -> UnsupportedParamsError:
|
||||
return UnsupportedParamsError(message=message, model=model, llm_provider="volcengine")
|
||||
|
||||
|
||||
def _droppable(model: str, message: str) -> UnsupportedParamsError:
|
||||
return _unsupported(model, f"{message}, set drop_params=True to ignore it")
|
||||
|
||||
|
||||
def _normalized_api_base(api_base: str) -> str:
|
||||
"""The Ark credential is shared with the chat, responses and Anthropic surfaces, so its api_base may carry any of
|
||||
their paths, and the video paths carry their own version segment."""
|
||||
trimmed: Final = api_base.rstrip("/")
|
||||
marker_index: Final = trimmed.rfind(_VOLCENGINE_API_MARKER)
|
||||
if marker_index != -1:
|
||||
return trimmed[:marker_index]
|
||||
suffix: Final = next((suffix for suffix in _VOLCENGINE_COMPAT_SUFFIXES if trimmed.endswith(suffix)), None)
|
||||
return trimmed[: -len(suffix)] if suffix is not None else trimmed
|
||||
|
||||
|
||||
def _size_dimensions(size: str) -> tuple[int, int] | None:
|
||||
width_str, separator, height_str = size.partition("x")
|
||||
if not separator or not (width_str.isdigit() and height_str.isdigit()):
|
||||
return None
|
||||
if not int(width_str) or not int(height_str):
|
||||
return None
|
||||
return int(width_str), int(height_str)
|
||||
|
||||
|
||||
def _ratio_value(ratio: str) -> float:
|
||||
width_str, _, height_str = ratio.partition(":")
|
||||
return int(width_str) / int(height_str)
|
||||
|
||||
|
||||
def _ratio_from_size(size: str) -> str | None:
|
||||
"""Ark only takes its named ratios, and its own tiers do not reduce onto them (854x480 is 427:240), so the
|
||||
closest ratio wins."""
|
||||
dimensions: Final = _size_dimensions(size)
|
||||
if dimensions is None:
|
||||
return None
|
||||
width, height = dimensions
|
||||
return min(_RATIO_SET, key=lambda candidate: abs(log(width / height / _ratio_value(candidate))))
|
||||
|
||||
|
||||
def _resolution_from_size(size: str) -> str | None:
|
||||
"""Ark keeps a tier's pixel count across its ratios (960x960 and 1280x720 are both 720p), so the tier whose 16:9
|
||||
frame has the closest pixel count wins."""
|
||||
dimensions: Final = _size_dimensions(size)
|
||||
if dimensions is None:
|
||||
return None
|
||||
width, height = dimensions
|
||||
return min(
|
||||
_RESOLUTION_SHORTEST_SIDE,
|
||||
key=lambda tier: abs(log(width * height / (_RESOLUTION_SHORTEST_SIDE[tier] ** 2 * _TIER_FRAME_ASPECT))),
|
||||
)
|
||||
|
||||
|
||||
def _duration_param(seconds: object) -> int | None:
|
||||
if isinstance(seconds, bool):
|
||||
return None
|
||||
if isinstance(seconds, int):
|
||||
return seconds
|
||||
if not isinstance(seconds, (str, float)):
|
||||
return None
|
||||
try:
|
||||
parsed: Final = float(seconds)
|
||||
except ValueError:
|
||||
return None
|
||||
return int(parsed) if parsed.is_integer() else None
|
||||
|
||||
|
||||
def _duration_fields(seconds: object, model: str, drop_params: bool) -> Mapping[str, object]:
|
||||
if seconds is None:
|
||||
return _EMPTY_PARAMS
|
||||
duration: Final = _duration_param(seconds)
|
||||
if duration is not None:
|
||||
return MappingProxyType({"duration": duration})
|
||||
if drop_params:
|
||||
return _EMPTY_PARAMS
|
||||
raise _droppable(model, f"seconds must be a whole number of seconds, got {seconds!r}")
|
||||
|
||||
|
||||
def _size_fields(size: object, model: str, drop_params: bool) -> Mapping[str, object]:
|
||||
if size is None:
|
||||
return _EMPTY_PARAMS
|
||||
ratio: Final = _ratio_from_size(size) if isinstance(size, str) else None
|
||||
resolution: Final = _resolution_from_size(size) if isinstance(size, str) else None
|
||||
if ratio is not None and resolution is not None:
|
||||
return MappingProxyType({"ratio": ratio, "resolution": resolution})
|
||||
if drop_params:
|
||||
return _EMPTY_PARAMS
|
||||
raise _droppable(model, f"size must be WIDTHxHEIGHT, got {size!r}")
|
||||
|
||||
|
||||
def _parameters_fields(parameters: object, model: str, drop_params: bool) -> Mapping[str, object]:
|
||||
if parameters is None:
|
||||
return _EMPTY_PARAMS
|
||||
try:
|
||||
fields: Final = _FIELDS_ADAPTER.validate_python(parameters)
|
||||
except ValidationError:
|
||||
if drop_params:
|
||||
return _EMPTY_PARAMS
|
||||
raise _droppable(
|
||||
model, f"parameters must be an object of Ark request fields, got {type(parameters).__name__}"
|
||||
) from None
|
||||
misplaced: Final = tuple(sorted(key for key in fields if key in _READ_FROM_THE_REQUEST))
|
||||
if misplaced and not drop_params:
|
||||
raise _droppable(model, f"send {', '.join(misplaced)} as top-level request fields, not inside parameters")
|
||||
return MappingProxyType({key: value for key, value in fields.items() if key not in _READ_FROM_THE_REQUEST})
|
||||
|
||||
|
||||
def _user_fields(user: object, model: str, drop_params: bool) -> Mapping[str, object]:
|
||||
if isinstance(user, str) and user:
|
||||
return MappingProxyType({"safety_identifier": user})
|
||||
if user is None or isinstance(user, str) or drop_params:
|
||||
return _EMPTY_PARAMS
|
||||
raise _droppable(model, f"user must be a string, got {type(user).__name__}")
|
||||
|
||||
|
||||
def _additional_drop_keys(params: Mapping[str, object]) -> frozenset[str]:
|
||||
try:
|
||||
return _DROP_KEYS_ADAPTER.validate_python(params.get("additional_drop_params") or ())
|
||||
except ValidationError:
|
||||
return frozenset()
|
||||
|
||||
|
||||
def _without(params: Mapping[str, object], keys: frozenset[str]) -> Mapping[str, object]:
|
||||
return MappingProxyType({key: value for key, value in params.items() if key not in keys})
|
||||
|
||||
|
||||
def _malformed_fields(fields: Mapping[str, object]) -> tuple[tuple[str, str], ...]:
|
||||
try:
|
||||
_ = _KnownFields.model_validate(dict(fields))
|
||||
except ValidationError as e:
|
||||
return tuple((str(error["loc"][0]), error["msg"]) for error in e.errors() if error["loc"])
|
||||
return ()
|
||||
|
||||
|
||||
def _drops_params(litellm_params: GenericLiteLLMParams) -> bool:
|
||||
return litellm_params.drop_params is True or litellm.drop_params is True
|
||||
|
||||
|
||||
def _model_info(model: str) -> ModelInfo | None:
|
||||
try:
|
||||
return get_model_info(model=model, custom_llm_provider="volcengine")
|
||||
except Exception: # noqa: BLE001 # get_model_info raises bare Exception for unmapped models
|
||||
return None
|
||||
|
||||
|
||||
def _pricing_model(model: str, litellm_params: GenericLiteLLMParams) -> str:
|
||||
"""A deployment priced through ``model_info.base_model``, like an ``ep-`` endpoint id, bills at that model's
|
||||
tiers."""
|
||||
model_info: Final = _FIELDS_ADAPTER.validate_python(litellm_params["model_info"] or _EMPTY_PARAMS)
|
||||
base_model: Final = model_info.get("base_model")
|
||||
return base_model if isinstance(base_model, str) and base_model else model
|
||||
|
||||
|
||||
def _priced_tiers(model: str) -> tuple[str, ...] | None:
|
||||
"""A tier the model has no price for is one Ark does not offer it at, and litellm would bill it at the base rate.
|
||||
A model with no tier price listed, missing from the cost map or registered unpriced by the router, has nothing
|
||||
to check against."""
|
||||
info: Final = _model_info(model)
|
||||
if info is None:
|
||||
return None
|
||||
return (
|
||||
tuple(tier for tier in _RESOLUTION_SHORTEST_SIDE if info.get(f"output_cost_per_second_{tier}") is not None)
|
||||
or None
|
||||
)
|
||||
|
||||
|
||||
def _delivered_frames(model: str | None) -> Mapping[str, Mapping[str, str]]:
|
||||
info: Final = _model_info(model) if model else None
|
||||
provider_specific: Final[object] = info.get("provider_specific_entry") if info is not None else None
|
||||
frames: Final[object] = provider_specific.get(_FRAMES_FIELD) if isinstance(provider_specific, Mapping) else None
|
||||
try:
|
||||
return _FRAMES_ADAPTER.validate_python(frames) if frames is not None else _EMPTY_FRAMES
|
||||
except ValidationError:
|
||||
return _EMPTY_FRAMES
|
||||
|
||||
|
||||
def _size_from_geometry(resolution: str | None, ratio: str | None, model: str | None) -> str | None:
|
||||
"""Ark echoes a tier and a ratio rather than pixels, and its rendered frame does not always follow the tier's
|
||||
nominal size, so the frame listed for the model wins over scaling the tier's shortest side up the ratio."""
|
||||
if not resolution or not ratio or ":" not in ratio:
|
||||
return None
|
||||
tier: Final = resolution.strip().lower()
|
||||
normalized_ratio: Final = ratio.strip()
|
||||
listed: Final = _delivered_frames(model).get(tier, MappingProxyType({})).get(normalized_ratio)
|
||||
if listed is not None:
|
||||
return listed
|
||||
shortest_side: Final = _RESOLUTION_SHORTEST_SIDE.get(tier)
|
||||
width_str, _, height_str = normalized_ratio.partition(":")
|
||||
if shortest_side is None or not (width_str.isdigit() and height_str.isdigit()):
|
||||
return None
|
||||
ratio_width: Final = int(width_str)
|
||||
ratio_height: Final = int(height_str)
|
||||
if not ratio_width or not ratio_height:
|
||||
return None
|
||||
shortest_ratio_side: Final = min(ratio_width, ratio_height)
|
||||
return f"{-(-shortest_side * ratio_width // shortest_ratio_side)}x{-(-shortest_side * ratio_height // shortest_ratio_side)}"
|
||||
|
||||
|
||||
def _is_input_video(item: _ContentItem) -> bool:
|
||||
return item.type == _INPUT_VIDEO_TYPE or item.role == _INPUT_VIDEO_ROLE
|
||||
|
||||
|
||||
def _supplied_items(supplied: object, model: str, drop_params: bool) -> tuple[Mapping[str, object], ...]:
|
||||
try:
|
||||
items: Final = _CONTENT_ITEMS_ADAPTER.validate_python(supplied)
|
||||
entries: Final = tuple(_ContentItem.model_validate(item) for item in items)
|
||||
except ValidationError:
|
||||
if drop_params:
|
||||
return ()
|
||||
raise _droppable(model, "content must be an array of Ark content items") from None
|
||||
if any(entry.type == "text" for entry in entries) and not drop_params:
|
||||
raise _droppable(
|
||||
model,
|
||||
"A text item in content is not supported, put the text in prompt since it is the only text input "
|
||||
"guardrails scan before the request reaches Ark",
|
||||
)
|
||||
if any(_is_input_video(entry) for entry in entries) and not drop_params:
|
||||
raise _droppable(model, _VIDEO_INPUT_UNSUPPORTED.format(operation="Video input (a reference_video item)"))
|
||||
kept: Final = (item for item, entry in zip(items, entries) if entry.type != "text" and not _is_input_video(entry))
|
||||
return tuple(MappingProxyType(item) for item in kept)
|
||||
|
||||
|
||||
def _reference_items(params: Mapping[str, object], model: str, drop_params: bool) -> tuple[Mapping[str, object], ...]:
|
||||
"""A caller-supplied ``content`` array wins over ``input_reference``, since its roles are the only way to express
|
||||
reference-to-video."""
|
||||
supplied: Final = params.get("content")
|
||||
if supplied is not None:
|
||||
return _supplied_items(supplied, model, drop_params)
|
||||
reference: Final = params.get("input_reference")
|
||||
if reference is None:
|
||||
return ()
|
||||
try:
|
||||
url: Final = input_reference_url(reference)
|
||||
except ValueError as e:
|
||||
if drop_params:
|
||||
return ()
|
||||
raise _droppable(model, str(e)) from None
|
||||
return (MappingProxyType({"type": "image_url", "image_url": {"url": url}, "role": "first_frame"}),)
|
||||
|
||||
|
||||
def _sent_content(
|
||||
prompt: str, params: Mapping[str, object], model: str, drop_params: bool
|
||||
) -> tuple[Mapping[str, object], ...]:
|
||||
"""The prompt always leads, as it is the only text the input guardrails scan."""
|
||||
return (MappingProxyType({"type": "text", "text": prompt}), *_reference_items(params, model, drop_params))
|
||||
|
||||
|
||||
def _seconds_label(seconds: object) -> str | None:
|
||||
if isinstance(seconds, float) and seconds.is_integer():
|
||||
return str(int(seconds))
|
||||
return None if seconds is None else str(seconds)
|
||||
|
||||
|
||||
def _requested_usage(request_data: Mapping[str, object]) -> Mapping[str, object]:
|
||||
"""The create call is the only billed one and Ark's create response carries no usage, so the requested duration
|
||||
and tier are what the video bills at."""
|
||||
duration: Final = _duration_param(request_data.get("duration"))
|
||||
resolution: Final = request_data.get("resolution")
|
||||
return MappingProxyType(
|
||||
{
|
||||
key: value
|
||||
for key, value in (
|
||||
("duration_seconds", float(duration) if duration is not None and duration > 0 else None),
|
||||
("video_resolution", resolution.strip().lower() if isinstance(resolution, str) else None),
|
||||
)
|
||||
if value is not None
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _task_usage(task: _Task) -> Mapping[str, object]:
|
||||
resolution: Final = task.resolution.strip().lower() if task.resolution else None
|
||||
return MappingProxyType(
|
||||
{
|
||||
key: value
|
||||
for key, value in (
|
||||
("duration_seconds", float(task.duration) if task.duration is not None else None),
|
||||
("video_resolution", resolution),
|
||||
("total_tokens", task.usage.total_tokens if task.usage is not None else None),
|
||||
)
|
||||
if value is not None
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _task_status(task: _Task, raw_response: httpx.Response) -> str:
|
||||
status: Final = _STATUS_MAP.get(task.status.strip().lower())
|
||||
if status is None: # defaulting it would hand back a job the caller polls forever
|
||||
raise _video_error(raw_response, 502, f"Unknown Volcengine task status: {task.status}")
|
||||
return status
|
||||
|
||||
|
||||
def _video_object_from_task(task: _Task, raw_response: httpx.Response, model: str | None) -> VideoObject:
|
||||
status: Final = _task_status(task, raw_response)
|
||||
usage: Final = _task_usage(task)
|
||||
seconds: Final = usage.get("duration_seconds")
|
||||
error: Final[dict[str, object] | None] = ( # mutable-ok: VideoObject.error is a dict field
|
||||
{key: value for key, value in (("code", task.error.code), ("message", task.error.message)) if value is not None}
|
||||
if task.error is not None
|
||||
else None
|
||||
)
|
||||
return VideoObject(
|
||||
id=task.id,
|
||||
object="video",
|
||||
status=status,
|
||||
created_at=task.created_at,
|
||||
completed_at=task.updated_at if status in _TERMINAL_STATUSES else None,
|
||||
error=error,
|
||||
seconds=_seconds_label(seconds),
|
||||
size=_size_from_geometry(task.resolution, task.ratio, task.model),
|
||||
model=model or task.model,
|
||||
usage=dict(usage) if usage else None,
|
||||
)
|
||||
|
||||
|
||||
def _video_url_from_task(task: _Task, raw_response: httpx.Response) -> str:
|
||||
"""A download before the video is ready is the caller polling too early, not a fault of the deployment, so it
|
||||
answers 400, which litellm neither retries nor counts toward a deployment cooldown. A 404 would cool the
|
||||
deployment down, and a 409 or a 5xx would be retried against a job that is simply not finished."""
|
||||
if task.content is not None and task.content.video_url:
|
||||
return task.content.video_url
|
||||
status: Final = task.status.strip().lower()
|
||||
if status in _PENDING_STATUSES:
|
||||
raise _video_error(
|
||||
raw_response, 400, f"Video is still processing (status: {status}). Please wait and try again."
|
||||
)
|
||||
if task.error is not None and (task.error.message or task.error.code):
|
||||
raise _video_error(raw_response, 400, f"Video generation failed: {task.error.message or task.error.code}")
|
||||
if status == "expired":
|
||||
raise _video_error(raw_response, 400, "Task expired. Ark keeps task records for 7 days after creation.")
|
||||
if status in ("failed", "cancelled"):
|
||||
raise _video_error(raw_response, 400, f"Video generation ended as {status} with no video URL.")
|
||||
raise _video_error(raw_response, 502, "Video URL not found in task response.")
|
||||
|
||||
|
||||
class VolcEngineVideoConfig(BaseVideoConfig):
|
||||
"""Volcengine Ark (Doubao-Seedance) on Ark's async task API: create, poll, download from the signed
|
||||
``content.video_url``, and list."""
|
||||
|
||||
def get_supported_openai_params(self, model: str) -> list[str]: # mutable-ok: BaseVideoConfig contract returns list
|
||||
return [
|
||||
"model",
|
||||
"prompt",
|
||||
"input_reference",
|
||||
"seconds",
|
||||
"size",
|
||||
"user",
|
||||
"extra_headers",
|
||||
"content",
|
||||
"parameters",
|
||||
*_CREATE_PASSTHROUGH_KEYS,
|
||||
]
|
||||
|
||||
def map_openai_params(
|
||||
self,
|
||||
video_create_optional_params: VideoCreateOptionalRequestParams,
|
||||
model: str,
|
||||
drop_params: bool,
|
||||
) -> dict[str, object]: # mutable-ok: BaseVideoConfig contract
|
||||
requested: Final = _without(video_create_optional_params, _additional_drop_keys(video_create_optional_params))
|
||||
unsupported: Final = next(
|
||||
(key for key, value in requested.items() if key in _UNSUPPORTED_BY_VOLCENGINE and value is not None),
|
||||
None,
|
||||
)
|
||||
if unsupported is not None and not drop_params:
|
||||
raise _droppable(model, f"'{unsupported}' is not supported by Volcengine video generation")
|
||||
passthrough: Final = MappingProxyType(
|
||||
{key: value for key, value in requested.items() if value is not None and key not in _MAPPED_ELSEWHERE}
|
||||
)
|
||||
return {
|
||||
**_user_fields(requested.get("user"), model, drop_params),
|
||||
**_size_fields(requested.get("size"), model, drop_params),
|
||||
**_duration_fields(requested.get("seconds"), model, drop_params),
|
||||
**passthrough,
|
||||
**_parameters_fields(requested.get("parameters"), model, drop_params),
|
||||
}
|
||||
|
||||
def _resolve_api_key(self, api_key: str | None) -> str:
|
||||
resolved_api_key: Final = api_key or get_secret_str("ARK_API_KEY") or get_secret_str("VOLCENGINE_API_KEY")
|
||||
if resolved_api_key is None:
|
||||
raise ValueError(
|
||||
"Volcengine API key is required. Set ARK_API_KEY or VOLCENGINE_API_KEY environment variable or pass "
|
||||
"api_key parameter."
|
||||
)
|
||||
return resolved_api_key
|
||||
|
||||
def validate_environment(
|
||||
self,
|
||||
headers: dict[str, str], # mutable-ok: BaseVideoConfig contract; handler expects a mutable headers dict
|
||||
model: str,
|
||||
api_key: str | None = None,
|
||||
litellm_params: GenericLiteLLMParams | None = None,
|
||||
) -> dict[str, str]: # mutable-ok: BaseVideoConfig contract
|
||||
resolved_api_key: Final = self._resolve_api_key(
|
||||
api_key
|
||||
or (litellm_params.api_key if litellm_params is not None and litellm_params.api_key else None)
|
||||
or litellm.api_key
|
||||
)
|
||||
headers.update({"Authorization": f"Bearer {resolved_api_key}", "Content-Type": "application/json"})
|
||||
return headers
|
||||
|
||||
def get_complete_url(
|
||||
self,
|
||||
model: str,
|
||||
api_base: str | None,
|
||||
litellm_params: dict[str, object], # mutable-ok: BaseVideoConfig contract
|
||||
) -> str:
|
||||
return _normalized_api_base(
|
||||
get_volcengine_base_url(api_base or get_secret_str("VOLCENGINE_API_BASE") or get_secret_str("ARK_API_BASE"))
|
||||
)
|
||||
|
||||
def transform_video_create_request(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str,
|
||||
api_base: str,
|
||||
video_create_optional_request_params: dict[str, object], # mutable-ok: BaseVideoConfig contract
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict[str, str], # mutable-ok: BaseVideoConfig contract
|
||||
) -> tuple[dict[str, object], RequestFiles, str]: # mutable-ok: BaseVideoConfig contract
|
||||
"""
|
||||
Ark bills a video when the task finishes, while litellm bills it here at create, so every request has to
|
||||
carry a length and a tier whose cost is known now. Ark-picked length (``duration: -1``) and a video input
|
||||
(edits, extensions and reference videos, billed at a different rate per token) are rejected for that reason,
|
||||
and an omitted length is sent as OpenAI's documented default of 4 seconds, since the 2.5 family's own default
|
||||
is ``-1``.
|
||||
"""
|
||||
drop_params: Final = _drops_params(litellm_params)
|
||||
dropped_by_config: Final = _additional_drop_keys(video_create_optional_request_params)
|
||||
kept: Final = _without(video_create_optional_request_params, dropped_by_config)
|
||||
params: Final = _without(
|
||||
{
|
||||
**{key: value for key, value in kept.items() if key != "parameters"},
|
||||
**_parameters_fields(kept.get("parameters"), model, drop_params),
|
||||
},
|
||||
dropped_by_config,
|
||||
)
|
||||
content: Final = _sent_content(prompt, params, model, drop_params)
|
||||
if params.get("frames") is not None and not drop_params:
|
||||
raise _droppable(model, "frames is not supported through litellm, pass seconds instead")
|
||||
requested_duration: Final = params.get("duration")
|
||||
parsed_duration: Final = None if requested_duration is None else _duration_param(requested_duration)
|
||||
if requested_duration is not None and (parsed_duration is None or parsed_duration <= 0) and not drop_params:
|
||||
raise _droppable(
|
||||
model,
|
||||
f"duration must be a positive whole number of seconds, got {requested_duration!r}. Ark-picked length "
|
||||
"(-1) is not supported through litellm, since the video is billed when the task is created, before "
|
||||
"Ark has picked its length",
|
||||
)
|
||||
duration: Final = (
|
||||
parsed_duration
|
||||
if parsed_duration is not None and parsed_duration > 0
|
||||
else VOLCENGINE_VIDEO_DEFAULT_DURATION_SECONDS
|
||||
)
|
||||
|
||||
unknown_fields: Final = tuple(
|
||||
sorted(key for key, value in params.items() if value is not None and key not in _CREATE_HANDLED_KEYS)
|
||||
)
|
||||
if unknown_fields and not drop_params:
|
||||
raise _droppable(model, f"litellm does not send {', '.join(unknown_fields)} to Ark video generation")
|
||||
requested_fields: Final = MappingProxyType(
|
||||
{key: params[key] for key in _CREATE_PASSTHROUGH_KEYS if params.get(key) is not None}
|
||||
)
|
||||
malformed: Final = _malformed_fields(requested_fields)
|
||||
if malformed and not drop_params:
|
||||
raise _droppable(model, "; ".join(f"invalid {field}: {message}" for field, message in malformed))
|
||||
resolution: Final = requested_fields.get("resolution")
|
||||
priced_tiers: Final = _priced_tiers(_pricing_model(model, litellm_params))
|
||||
drops_resolution: Final = (
|
||||
priced_tiers is not None and isinstance(resolution, str) and resolution.strip().lower() not in priced_tiers
|
||||
)
|
||||
if drops_resolution and not drop_params:
|
||||
raise _droppable(
|
||||
model, f"{model} is not offered at resolution {resolution!r}, only at {', '.join(priced_tiers or ())}"
|
||||
)
|
||||
dropped: Final = frozenset({*(field for field, _ in malformed), *(("resolution",) if drops_resolution else ())})
|
||||
kept_fields: Final = MappingProxyType(
|
||||
{key: value for key, value in requested_fields.items() if key not in dropped}
|
||||
)
|
||||
default_tier: Final = (
|
||||
MappingProxyType({"resolution": VOLCENGINE_VIDEO_DEFAULT_RESOLUTION})
|
||||
if "resolution" not in kept_fields
|
||||
and priced_tiers is not None
|
||||
and VOLCENGINE_VIDEO_DEFAULT_RESOLUTION in priced_tiers
|
||||
else _EMPTY_PARAMS
|
||||
)
|
||||
|
||||
request_data: Final[dict[str, object]] = { # mutable-ok: request body dict, JSON-serialized by the handler
|
||||
"model": model,
|
||||
"content": [dict(item) for item in content],
|
||||
**default_tier,
|
||||
**kept_fields,
|
||||
"duration": duration,
|
||||
}
|
||||
return request_data, (), f"{api_base}{VOLCENGINE_VIDEO_TASKS_PATH}"
|
||||
|
||||
def transform_video_create_response(
|
||||
self,
|
||||
model: str,
|
||||
raw_response: httpx.Response,
|
||||
logging_obj: "Logging",
|
||||
custom_llm_provider: str | None = None,
|
||||
request_data: dict[str, object] | None = None, # mutable-ok: BaseVideoConfig contract
|
||||
) -> VideoObject:
|
||||
task: Final = _parse(_TASK_CREATE_ADAPTER, raw_response)
|
||||
if not task.id:
|
||||
message: Final = task.error.message if task.error is not None and task.error.message else "no task id"
|
||||
raise _video_error(raw_response, 400, f"Volcengine did not return a video task id: {message}")
|
||||
requested: Final = request_data or _EMPTY_PARAMS
|
||||
usage: Final = _requested_usage(requested)
|
||||
seconds: Final = usage.get("duration_seconds")
|
||||
requested_model, served_model = routed_video_models(logging_obj) or (model, None)
|
||||
resolution: Final = requested.get("resolution")
|
||||
ratio: Final = requested.get("ratio")
|
||||
return VideoObject(
|
||||
id=encode_video_id_with_provider(task.id, custom_llm_provider, requested_model, served_model)
|
||||
if custom_llm_provider
|
||||
else task.id,
|
||||
object="video",
|
||||
status="queued",
|
||||
model=model,
|
||||
seconds=_seconds_label(seconds),
|
||||
size=_size_from_geometry(
|
||||
resolution if isinstance(resolution, str) else None, ratio if isinstance(ratio, str) else None, model
|
||||
),
|
||||
usage=dict(usage) if usage else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _task_url(video_id: str, api_base: str) -> str:
|
||||
original_task_id: Final = extract_original_video_id(video_id)
|
||||
encoded_task_id: Final = encode_url_path_segment(original_task_id, field_name="video_id")
|
||||
return f"{api_base}{VOLCENGINE_VIDEO_TASKS_PATH}/{encoded_task_id}"
|
||||
|
||||
def transform_video_status_retrieve_request(
|
||||
self,
|
||||
video_id: str,
|
||||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict[str, str], # mutable-ok: BaseVideoConfig contract
|
||||
) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig contract
|
||||
return self._task_url(video_id, api_base), _EMPTY_PARAMS
|
||||
|
||||
def transform_video_status_retrieve_response(
|
||||
self,
|
||||
raw_response: httpx.Response,
|
||||
logging_obj: "Logging",
|
||||
custom_llm_provider: str | None = None,
|
||||
client: "HTTPHandler | None" = None,
|
||||
) -> VideoObject:
|
||||
task: Final = _parse(_TASK_ADAPTER, raw_response)
|
||||
model, served_model = polled_video_models(logging_obj)
|
||||
video_obj: Final = _video_object_from_task(task, raw_response, model)
|
||||
if custom_llm_provider:
|
||||
video_obj.id = encode_video_id_with_provider(task.id, custom_llm_provider, model, served_model)
|
||||
return video_obj
|
||||
|
||||
def transform_video_content_request(
|
||||
self,
|
||||
video_id: str,
|
||||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict[str, str], # mutable-ok: BaseVideoConfig contract
|
||||
variant: str | None = None,
|
||||
) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig contract
|
||||
return self._task_url(video_id, api_base), _EMPTY_PARAMS
|
||||
|
||||
def transform_video_content_response(
|
||||
self,
|
||||
raw_response: httpx.Response,
|
||||
logging_obj: "Logging",
|
||||
client: "HTTPHandler | None" = None,
|
||||
) -> bytes:
|
||||
video_url: Final = _video_url_from_task(_parse(_TASK_ADAPTER, raw_response), raw_response)
|
||||
httpx_client: Final[HTTPHandler] = client if client is not None else _get_httpx_client()
|
||||
video_response: Final = httpx_client.get(video_url) # pyright: ignore[reportUnknownMemberType] # HTTPHandler.get stub leaves params/headers untyped
|
||||
video_response.raise_for_status()
|
||||
return video_response.content
|
||||
|
||||
async def async_transform_video_content_response(
|
||||
self,
|
||||
raw_response: httpx.Response,
|
||||
logging_obj: "Logging",
|
||||
client: "AsyncHTTPHandler | None" = None,
|
||||
) -> bytes:
|
||||
video_url: Final = _video_url_from_task(_parse(_TASK_ADAPTER, raw_response), raw_response)
|
||||
async_httpx_client: Final[AsyncHTTPHandler] = (
|
||||
client if client is not None else get_async_httpx_client(llm_provider=litellm.LlmProviders.VOLCENGINE)
|
||||
)
|
||||
video_response: Final = await async_httpx_client.get(video_url) # pyright: ignore[reportUnknownMemberType] # HTTPHandler.get stub leaves params/headers untyped
|
||||
video_response.raise_for_status()
|
||||
return video_response.content
|
||||
|
||||
def transform_video_remix_request(
|
||||
self,
|
||||
video_id: str,
|
||||
prompt: str,
|
||||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict[str, str], # mutable-ok: BaseVideoConfig contract
|
||||
extra_body: dict[str, object] | None = None, # mutable-ok: BaseVideoConfig contract
|
||||
) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig contract
|
||||
raise _unsupported("", _VIDEO_INPUT_UNSUPPORTED.format(operation="Video remix"))
|
||||
|
||||
def transform_video_remix_response(
|
||||
self,
|
||||
raw_response: httpx.Response,
|
||||
logging_obj: "Logging",
|
||||
custom_llm_provider: str | None = None,
|
||||
) -> VideoObject:
|
||||
raise NotImplementedError("Video remix is not supported by Volcengine.")
|
||||
|
||||
def transform_video_edit_request(
|
||||
self,
|
||||
prompt: str,
|
||||
video_id: str,
|
||||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict[str, str], # mutable-ok: BaseVideoConfig contract
|
||||
video_file: object | None = None,
|
||||
extra_body: dict[str, object] | None = None, # mutable-ok: BaseVideoConfig contract
|
||||
prefetched_source_data: dict[str, object] | None = None, # mutable-ok: BaseVideoConfig contract
|
||||
) -> tuple[str, Mapping[str, object], RequestFiles | None]:
|
||||
raise _unsupported("", _VIDEO_INPUT_UNSUPPORTED.format(operation="Video edit"))
|
||||
|
||||
def transform_video_extension_request(
|
||||
self,
|
||||
prompt: str,
|
||||
video_id: str,
|
||||
seconds: str,
|
||||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict[str, str], # mutable-ok: BaseVideoConfig contract
|
||||
extra_body: dict[str, object] | None = None, # mutable-ok: BaseVideoConfig contract
|
||||
) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig contract
|
||||
raise _unsupported("", _VIDEO_INPUT_UNSUPPORTED.format(operation="Video extension"))
|
||||
|
||||
def transform_video_list_request(
|
||||
self,
|
||||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict[str, str], # mutable-ok: BaseVideoConfig contract
|
||||
after: str | None = None,
|
||||
limit: int | None = None,
|
||||
order: str | None = None,
|
||||
extra_query: dict[str, object] | None = None, # mutable-ok: BaseVideoConfig contract
|
||||
) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig contract
|
||||
"""Ark pages by page_num/page_size with filters and has no cursor or order."""
|
||||
unsupported: Final = tuple(name for name, value in (("after", after), ("order", order)) if value is not None)
|
||||
if unsupported and not _drops_params(litellm_params):
|
||||
raise _droppable(
|
||||
"", f"Ark's task list pages by page_num in extra_query and has no {' or '.join(unsupported)}"
|
||||
)
|
||||
return f"{api_base}{VOLCENGINE_VIDEO_TASKS_PATH}", {
|
||||
**({"page_size": str(limit)} if limit is not None else {}),
|
||||
**(extra_query or {}),
|
||||
}
|
||||
|
||||
def transform_video_list_response( # pyright: ignore[reportIncompatibleMethodOverride] # base declares dict[str, str] but the payload is a heterogeneous list body
|
||||
self,
|
||||
raw_response: httpx.Response,
|
||||
logging_obj: "Logging",
|
||||
custom_llm_provider: str | None = None,
|
||||
) -> dict[str, object]: # mutable-ok: OpenAI list body served as JSON by the proxy
|
||||
list_payload: Final = _parse(_TASK_LIST_ADAPTER, raw_response)
|
||||
videos: Final = tuple(_video_object_from_task(task, raw_response, None) for task in list_payload.items)
|
||||
data: Final = [
|
||||
video.model_dump()
|
||||
| (
|
||||
{"id": encode_video_id_with_provider(video.id, custom_llm_provider, video.model)}
|
||||
if custom_llm_provider
|
||||
else {}
|
||||
)
|
||||
for video in videos
|
||||
]
|
||||
return {
|
||||
"object": "list",
|
||||
"data": data,
|
||||
"total": list_payload.total if list_payload.total is not None else len(data),
|
||||
"has_more": False,
|
||||
**({"first_id": data[0]["id"], "last_id": data[-1]["id"]} if data else {}),
|
||||
}
|
||||
|
||||
def transform_video_delete_request(
|
||||
self,
|
||||
video_id: str,
|
||||
api_base: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
headers: dict[str, str], # mutable-ok: BaseVideoConfig contract
|
||||
) -> tuple[str, dict[str, object]]: # mutable-ok: BaseVideoConfig contract
|
||||
raise NotImplementedError("Video delete is not supported by Volcengine.")
|
||||
|
||||
def transform_video_delete_response(
|
||||
self,
|
||||
raw_response: httpx.Response,
|
||||
logging_obj: "Logging",
|
||||
) -> VideoObject:
|
||||
raise NotImplementedError("Video delete is not supported by Volcengine.")
|
||||
|
||||
def get_error_class(
|
||||
self,
|
||||
error_message: str,
|
||||
status_code: int,
|
||||
headers: dict[str, str] | httpx.Headers, # mutable-ok: BaseVideoConfig contract
|
||||
) -> BaseLLMException:
|
||||
return VolcEngineVideoError(status_code=status_code, message=error_message, headers=headers)
|
||||
|
|
@ -2773,7 +2773,7 @@
|
|||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
|
|
@ -2808,7 +2808,7 @@
|
|||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
|
|
@ -2843,7 +2843,7 @@
|
|||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
|
|
@ -2878,7 +2878,7 @@
|
|||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
|
|
@ -2913,7 +2913,7 @@
|
|||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
|
|
@ -2948,7 +2948,7 @@
|
|||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
|
|
@ -3743,7 +3743,7 @@
|
|||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 1.5e-05,
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
|
|
@ -15494,7 +15494,7 @@
|
|||
},
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
|
|
@ -20832,7 +20832,7 @@
|
|||
"output_cost_per_token": 2.5000010000000002e-05,
|
||||
"output_dbu_cost_per_token": 0.000357143,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_function_calling": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"supports_prompt_caching": true,
|
||||
|
|
@ -21047,7 +21047,7 @@
|
|||
"output_cost_per_token": 1.5000020000000002e-05,
|
||||
"output_dbu_cost_per_token": 0.000214286,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_function_calling": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"supports_prompt_caching": true,
|
||||
|
|
@ -49176,7 +49176,7 @@
|
|||
"max_tokens": 64000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2.5e-05,
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
|
|
@ -51006,7 +51006,7 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 1.5e-05,
|
||||
"output_cost_per_token_batches": 7.5e-06,
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
|
|
@ -51179,7 +51179,7 @@
|
|||
"supported_regions": [
|
||||
"global"
|
||||
],
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
|
|
@ -51200,7 +51200,7 @@
|
|||
"supported_regions": [
|
||||
"us-central1"
|
||||
],
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
|
|
@ -58822,7 +58822,7 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 1.5e-05,
|
||||
"output_cost_per_token_batches": 7.5e-06,
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
|
|
@ -60467,6 +60467,61 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"volcengine/doubao-seedance-2-5-260628": {
|
||||
"litellm_provider": "volcengine",
|
||||
"mode": "video_generation",
|
||||
"source": "https://www.volcengine.com/docs/82379/1544106",
|
||||
"output_cost_per_second": 0.216,
|
||||
"output_cost_per_second_480p": 0.096075,
|
||||
"output_cost_per_second_720p": 0.216,
|
||||
"output_cost_per_second_1080p": 0.5346,
|
||||
"supported_modalities": ["text", "image", "video", "audio"],
|
||||
"supported_output_modalities": ["video"],
|
||||
"supported_endpoints": ["/v1/videos"],
|
||||
"provider_specific_entry": {"volcengine_video_frames": {"480p": {"16:9": "854x480", "4:3": "752x560", "1:1": "640x640", "3:4": "560x752", "9:16": "480x854", "21:9": "992x432"}, "720p": {"16:9": "1280x720", "4:3": "1112x834", "1:1": "960x960", "3:4": "834x1112", "9:16": "720x1280", "21:9": "1470x630"}}},
|
||||
"metadata": {"comment": "Ark bills per output token, not per second: tokens = width * height * 24 * seconds / 1024, so a per-second rate per resolution tier is exact for the 16:9 geometry Ark publishes (1280x720 is 21600 tokens/s). List 70 CNY per million tokens at 480p and 720p and 77 at 1080p without a video input, matching Ark's per-resolution table and its 1080p 16:9 example of 3.74 CNY/s, converted at 7.0 CNY/USD like the other volcengine entries. Accepts up to 50 reference assets (30 images, 10 videos, 10 audios) and clips of 4-30s; per-asset billing beyond the free allowance is not encoded. Requests with a video input bill at a lower rate per token and are rejected, since their cost is not known at create time. provider_specific_entry.volcengine_video_frames is the frame Ark renders per resolution tier and ratio, verified with ffprobe at 480p for every ratio and at 720p for 16:9 and 4:3; the other 720p ratios are from the Seedance 2.0 model card."}
|
||||
},
|
||||
"volcengine/doubao-seedance-2-0-260128": {
|
||||
"litellm_provider": "volcengine",
|
||||
"mode": "video_generation",
|
||||
"source": "https://www.volcengine.com/docs/82379/1544106",
|
||||
"output_cost_per_second": 0.141942857,
|
||||
"output_cost_per_second_480p": 0.066003429,
|
||||
"output_cost_per_second_720p": 0.141942857,
|
||||
"output_cost_per_second_1080p": 0.354085714,
|
||||
"output_cost_per_second_4k": 0.722057143,
|
||||
"supported_modalities": ["text", "image", "video", "audio"],
|
||||
"supported_output_modalities": ["video"],
|
||||
"supported_endpoints": ["/v1/videos"],
|
||||
"provider_specific_entry": {"volcengine_video_frames": {"480p": {"16:9": "864x496", "4:3": "752x560", "1:1": "640x640", "3:4": "560x752", "9:16": "496x864", "21:9": "992x432"}, "720p": {"16:9": "1280x720", "4:3": "1112x834", "1:1": "960x960", "3:4": "834x1112", "9:16": "720x1280", "21:9": "1470x630"}}},
|
||||
"metadata": {"comment": "Ark bills per output token, not per second: tokens = width * height * 24 * seconds / 1024, so a per-second rate per resolution tier is exact for the 16:9 geometry Ark publishes (1280x720 is 21600 tokens/s; the 480p frame is 864x496, 10044 tokens/s, not the tier's nominal 854x480). List 46 CNY per million tokens at 480p and 720p, 51 at 1080p, and 26 at 4k without a video input, matching Ark's per-resolution table and its 1080p and 4k 16:9 examples of 2.48 and 5.05 CNY/s, converted at 7.0 CNY/USD like the other volcengine entries. Clips of 4-15s; 4k is exclusive to this tier. Requests with a video input bill at a lower rate per token and are rejected, since their cost is not known at create time. provider_specific_entry.volcengine_video_frames is the frame Ark renders per resolution tier and ratio, verified with ffprobe at 480p for every ratio and at 720p for 16:9 and 4:3; the other 720p ratios are from the Seedance 2.0 model card."}
|
||||
},
|
||||
"volcengine/doubao-seedance-2-0-fast-260128": {
|
||||
"litellm_provider": "volcengine",
|
||||
"mode": "video_generation",
|
||||
"source": "https://www.volcengine.com/docs/82379/1544106",
|
||||
"output_cost_per_second": 0.114171429,
|
||||
"output_cost_per_second_480p": 0.053089714,
|
||||
"output_cost_per_second_720p": 0.114171429,
|
||||
"supported_modalities": ["text", "image", "video", "audio"],
|
||||
"supported_output_modalities": ["video"],
|
||||
"supported_endpoints": ["/v1/videos"],
|
||||
"provider_specific_entry": {"volcengine_video_frames": {"480p": {"16:9": "864x496", "4:3": "752x560", "1:1": "640x640", "3:4": "560x752", "9:16": "496x864", "21:9": "992x432"}, "720p": {"16:9": "1280x720", "4:3": "1112x834", "1:1": "960x960", "3:4": "834x1112", "9:16": "720x1280", "21:9": "1470x630"}}},
|
||||
"metadata": {"comment": "Ark bills per output token, not per second: tokens = width * height * 24 * seconds / 1024, so a per-second rate per resolution tier is exact for the 16:9 geometry Ark publishes (1280x720 is 21600 tokens/s; the 480p frame is 864x496, 10044 tokens/s, not the tier's nominal 854x480). List 37 CNY per million tokens without a video input, converted at 7.0 CNY/USD like the other volcengine entries. Clips of 4-15s at 480p or 720p. Requests with a video input bill at a lower rate per token and are rejected, since their cost is not known at create time. provider_specific_entry.volcengine_video_frames is the frame Ark renders per resolution tier and ratio, verified with ffprobe at 480p for every ratio and at 720p for 16:9 and 4:3; the other 720p ratios are from the Seedance 2.0 model card."}
|
||||
},
|
||||
"volcengine/doubao-seedance-2-0-mini-260615": {
|
||||
"litellm_provider": "volcengine",
|
||||
"mode": "video_generation",
|
||||
"source": "https://www.volcengine.com/docs/82379/1544106",
|
||||
"output_cost_per_second": 0.070971429,
|
||||
"output_cost_per_second_480p": 0.033001714,
|
||||
"output_cost_per_second_720p": 0.070971429,
|
||||
"supported_modalities": ["text", "image"],
|
||||
"supported_output_modalities": ["video"],
|
||||
"supported_endpoints": ["/v1/videos"],
|
||||
"provider_specific_entry": {"volcengine_video_frames": {"480p": {"16:9": "864x496", "4:3": "752x560", "1:1": "640x640", "3:4": "560x752", "9:16": "496x864", "21:9": "992x432"}, "720p": {"16:9": "1280x720", "4:3": "1112x834", "1:1": "960x960", "3:4": "834x1112", "9:16": "720x1280", "21:9": "1470x630"}}},
|
||||
"metadata": {"comment": "Ark bills per output token, not per second: tokens = width * height * 24 * seconds / 1024, so a per-second rate per resolution tier is exact for the 16:9 geometry Ark publishes (1280x720 is 21600 tokens/s; the 480p frame is 864x496, 10044 tokens/s, not the tier's nominal 854x480). List 23 CNY per million tokens without a video input, converted at 7.0 CNY/USD like the other volcengine entries. Clips of 4-15s at 480p or 720p. Only text and image inputs are declared; the mini tier's reference video and audio caps were not verified. Requests with a video input bill at a lower rate per token and are rejected, since their cost is not known at create time. provider_specific_entry.volcengine_video_frames is the frame Ark renders per resolution tier and ratio, verified with ffprobe at 480p for every ratio and at 720p for 16:9 and 4:3; the other 720p ratios are from the Seedance 2.0 model card."}
|
||||
},
|
||||
"bedrock/us-east-1/zai.glm-5": {
|
||||
"input_cost_per_token": 1e-06,
|
||||
"output_cost_per_token": 3.2e-06,
|
||||
|
|
@ -72334,7 +72389,7 @@
|
|||
"max_output_tokens": 64000,
|
||||
"max_tokens": 64000,
|
||||
"mode": "chat",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from litellm._logging import verbose_logger, verbose_proxy_logger
|
|||
from litellm._service_logger import ServiceLogging
|
||||
from litellm._uuid import uuid
|
||||
from litellm.constants import (
|
||||
CALLER_SESSION_ID_METADATA_KEY,
|
||||
CLIENT_OUTPUT_CEILING_METADATA_KEY,
|
||||
CONSUMED_REQUEST_TAGS_METADATA_KEY,
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY,
|
||||
|
|
@ -187,6 +188,30 @@ def _caller_trace_field(data: Mapping[str, object], metadata_variable_name: str,
|
|||
return requester_value if isinstance(requester_value, str) and requester_value else None
|
||||
|
||||
|
||||
def _caller_body_session_id(data: Mapping[str, object], metadata_variable_name: str) -> str | None:
|
||||
top_level: Final = data.get("litellm_session_id")
|
||||
if isinstance(top_level, str) and top_level:
|
||||
return top_level
|
||||
return _caller_trace_field(data, metadata_variable_name, "session_id")
|
||||
|
||||
|
||||
def _caller_session_beside_trace_header(
|
||||
normalized_headers: Mapping[str, str], data: Mapping[str, object], metadata_variable_name: str
|
||||
) -> str | None:
|
||||
session_headers: Final = {key: value for key, value in normalized_headers.items() if key != "x-litellm-trace-id"}
|
||||
return get_chain_id_from_headers(session_headers) or _caller_body_session_id(data, metadata_variable_name)
|
||||
|
||||
|
||||
def _drop_client_caller_session(request_body: Mapping[str, object]) -> None:
|
||||
for metadata_key in ("metadata", "litellm_metadata"):
|
||||
if isinstance(metadata := request_body.get(metadata_key), dict):
|
||||
metadata.pop(CALLER_SESSION_ID_METADATA_KEY, None)
|
||||
|
||||
|
||||
def _requester_metadata_snapshot(metadata: Mapping[str, object]) -> Mapping[str, object]:
|
||||
return copy.deepcopy({key: value for key, value in metadata.items() if key != CALLER_SESSION_ID_METADATA_KEY})
|
||||
|
||||
|
||||
def _stampable_key_hash(user_api_key_dict: UserAPIKeyAuth) -> str | None:
|
||||
"""Only proxy-validated keys are stamped, proven by the unforgeable
|
||||
via_virtual_key marker AND a known non-secret shape: the sha256 hex digest
|
||||
|
|
@ -1623,6 +1648,8 @@ class LiteLLMProxyRequestSetup:
|
|||
from litellm.proxy._types import LitellmMetadataFromRequestHeaders
|
||||
|
||||
metadata_from_headers: Final = LitellmMetadataFromRequestHeaders()
|
||||
request_body: Final = cast(Mapping[str, object], data) # cast-ok: request body is a str-keyed JSON object
|
||||
_drop_client_caller_session(request_body)
|
||||
spend_logs_metadata: Final = LiteLLMProxyRequestSetup._get_spend_logs_metadata_from_request_headers(headers)
|
||||
if spend_logs_metadata is not None:
|
||||
metadata_from_headers["spend_logs_metadata"] = spend_logs_metadata
|
||||
|
|
@ -1631,6 +1658,9 @@ class LiteLLMProxyRequestSetup:
|
|||
# Finally update the requests metadata with the `metadata_from_headers`
|
||||
#########################################################################################
|
||||
|
||||
normalized_headers: Final = MappingProxyType(
|
||||
{k.lower(): v for k, v in headers.items() if isinstance(k, str) and isinstance(v, str)}
|
||||
)
|
||||
agent_id_from_header: Final = headers.get("x-litellm-agent-id")
|
||||
# Explicit litellm headers take precedence; fall back to any x-*-session-id header.
|
||||
chain_id: Final = get_chain_id_from_headers(dict(headers))
|
||||
|
|
@ -1639,6 +1669,13 @@ class LiteLLMProxyRequestSetup:
|
|||
metadata_from_headers["agent_id"] = agent_id_from_header
|
||||
verbose_proxy_logger.debug("Extracted agent_id from header: %s", agent_id_from_header)
|
||||
|
||||
spend_log_session_id: Final = (
|
||||
_caller_session_beside_trace_header(normalized_headers, request_body, _metadata_variable_name)
|
||||
if litellm.request_correlation_in_logs
|
||||
and chain_id is not None
|
||||
and chain_id == normalized_headers.get("x-litellm-trace-id")
|
||||
else None
|
||||
)
|
||||
if chain_id:
|
||||
metadata_from_headers["trace_id"] = chain_id
|
||||
metadata_from_headers["session_id"] = chain_id
|
||||
|
|
@ -1663,7 +1700,6 @@ class LiteLLMProxyRequestSetup:
|
|||
# - but lets a caller's existing traceparent/baggage headers (from
|
||||
# real OTel instrumentation) correlate with litellm's own logs instead
|
||||
# of generating an unrelated trace_id.
|
||||
normalized_headers: Final = MappingProxyType({k.lower(): v for k, v in headers.items() if isinstance(k, str)})
|
||||
if "litellm_trace_id" not in data:
|
||||
traceparent: Final = normalized_headers.get("traceparent")
|
||||
if isinstance(traceparent, str):
|
||||
|
|
@ -1689,11 +1725,7 @@ class LiteLLMProxyRequestSetup:
|
|||
baggage: Final = normalized_headers.get("baggage")
|
||||
if isinstance(baggage, str):
|
||||
session_id_from_baggage: Final = _session_id_from_baggage(baggage)
|
||||
caller_session_id: Final = _caller_trace_field(
|
||||
cast(Mapping[str, object], data), # cast-ok: request body is a str-keyed JSON object
|
||||
_metadata_variable_name,
|
||||
"session_id",
|
||||
)
|
||||
caller_session_id: Final = _caller_trace_field(request_body, _metadata_variable_name, "session_id")
|
||||
if session_id_from_baggage and (
|
||||
caller_session_id is None or caller_session_id == session_id_from_baggage
|
||||
):
|
||||
|
|
@ -1702,7 +1734,11 @@ class LiteLLMProxyRequestSetup:
|
|||
verbose_proxy_logger.debug("Extracted session_id from W3C baggage header")
|
||||
|
||||
if isinstance(data[_metadata_variable_name], dict):
|
||||
data[_metadata_variable_name].update(metadata_from_headers)
|
||||
data[_metadata_variable_name].update(
|
||||
{**metadata_from_headers, CALLER_SESSION_ID_METADATA_KEY: spend_log_session_id}
|
||||
if spend_log_session_id
|
||||
else metadata_from_headers
|
||||
)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -2417,7 +2453,7 @@ async def add_litellm_data_to_request(
|
|||
# them — from leaking into requester_metadata where guardrails and audit
|
||||
# paths may read from it.
|
||||
if "metadata" in data and isinstance(data["metadata"], dict):
|
||||
data[_metadata_variable_name]["requester_metadata"] = copy.deepcopy(data["metadata"])
|
||||
data[_metadata_variable_name]["requester_metadata"] = _requester_metadata_snapshot(data["metadata"])
|
||||
if _metadata_variable_name == "litellm_metadata":
|
||||
data[_metadata_variable_name].update(
|
||||
_promoted_trace_control_fields(
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError
|
|||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import (
|
||||
CALLER_SESSION_ID_METADATA_KEY,
|
||||
CLI_SESSION_KEY_PREFIX,
|
||||
EMPTY_MAPPING,
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY,
|
||||
|
|
@ -33,6 +34,8 @@ from litellm.constants import (
|
|||
from litellm.constants import (
|
||||
MAX_STRING_LENGTH_PROMPT_IN_DB as DEFAULT_MAX_STRING_LENGTH_PROMPT_IN_DB,
|
||||
)
|
||||
from litellm.integrations.otel.model.metadata import caller_session_id
|
||||
from litellm.integrations.otel.model.trace_controls import TraceControls
|
||||
from litellm.litellm_core_utils.classifier_logging import (
|
||||
CLASSIFIER_AUDIT_FIELDS,
|
||||
classifier_audit_fields,
|
||||
|
|
@ -570,11 +573,11 @@ def get_logging_payload(
|
|||
elif isinstance(_usage, dict):
|
||||
usage = _usage
|
||||
|
||||
# A request that failed mid-stream has no usable response_obj usage, but the
|
||||
# streaming handler may have recovered the usage from the chunks already
|
||||
# delivered. Honor that override so the partial usage lands in spend tracking.
|
||||
# A request that failed mid-stream has no usable response_obj usage, and a stream
|
||||
# continued on a fallback only has the fallback's. combined_usage_object holds the
|
||||
# usage of every call the client's answer took, so it wins in both cases.
|
||||
_combined_usage: Final = kwargs.get("combined_usage_object")
|
||||
if not usage and isinstance(_combined_usage, litellm.Usage):
|
||||
if (not usage or "continued_partial_usage" in kwargs) and isinstance(_combined_usage, litellm.Usage):
|
||||
usage = _combined_usage.model_dump()
|
||||
|
||||
id = get_spend_logs_id(call_type or "acompletion", response_obj_dict, kwargs, metadata)
|
||||
|
|
@ -930,6 +933,16 @@ def _get_batch_trace_session_id(call_type: str | None, request_id: str | None) -
|
|||
return request_id.removesuffix(BATCH_COST_REQUEST_ID_SUFFIX)
|
||||
|
||||
|
||||
def _caller_session_for_spend_log(kwargs: Mapping[str, object], metadata: Mapping[str, object] | None) -> str | None:
|
||||
beside_trace_header: Final = metadata.get(CALLER_SESSION_ID_METADATA_KEY) if metadata else None
|
||||
caller_session: Final = (
|
||||
beside_trace_header
|
||||
if isinstance(beside_trace_header, str) and beside_trace_header
|
||||
else caller_session_id(kwargs, TraceControls())
|
||||
)
|
||||
return caller_session if caller_session and "\x00" not in caller_session else None
|
||||
|
||||
|
||||
def _get_session_id_for_spend_log(
|
||||
kwargs: Mapping[str, object],
|
||||
metadata: Mapping[str, object] | None,
|
||||
|
|
@ -937,9 +950,12 @@ def _get_session_id_for_spend_log(
|
|||
omit_when_missing: bool,
|
||||
batch_trace_session_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""Under `omit` only `metadata.session_id`, the key Langfuse reads, counts as a session; `litellm_session_id` may
|
||||
be a copied trace id. Batch call types carry a deterministic session derived from the batch id, which outranks
|
||||
the per-request trace ids because those differ between the create call and the cost poller's row."""
|
||||
"""Resolve the session id for the spend log row: `omit` honors only metadata.session_id,
|
||||
batch sessions outrank everything, then the conversation id the caller sent (also one an x-litellm-trace-id header
|
||||
displaced from the request's session), then trace ids.
|
||||
|
||||
A session minted by `missing_session_id: generate` or copied from the request's trace id is not a conversation, so
|
||||
it yields to the trace id, which the Responses bridge points at the previous turn's session to keep a chain whole."""
|
||||
if omit_when_missing:
|
||||
session_id: Final = metadata.get("session_id") if metadata else None
|
||||
return str(session_id) if session_id else None
|
||||
|
|
@ -948,11 +964,14 @@ def _get_session_id_for_spend_log(
|
|||
|
||||
if batch_trace_session_id is not None:
|
||||
return batch_trace_session_id
|
||||
if standard_logging_payload is not None and standard_logging_payload.get("trace_id") is not None:
|
||||
return str(standard_logging_payload.get("trace_id"))
|
||||
if kwargs.get("litellm_trace_id") is not None:
|
||||
return str(kwargs.get("litellm_trace_id"))
|
||||
return str(uuid.uuid4())
|
||||
if litellm.request_correlation_in_logs and (caller_session := _caller_session_for_spend_log(kwargs, metadata)):
|
||||
return caller_session
|
||||
match standard_logging_payload:
|
||||
case {"trace_id": trace_id} if trace_id is not None:
|
||||
return str(trace_id)
|
||||
case _:
|
||||
litellm_trace_id: Final = kwargs.get("litellm_trace_id")
|
||||
return str(litellm_trace_id) if litellm_trace_id is not None else str(uuid.uuid4())
|
||||
|
||||
|
||||
def _get_request_duration_ms(start_time: datetime, end_time: datetime) -> int | None:
|
||||
|
|
|
|||
|
|
@ -281,7 +281,13 @@ class ResponsesSessionHandler:
|
|||
|
||||
SQL query
|
||||
|
||||
SELECT session_id FROM spend_logs WHERE response_id = previous_response_id, SELECT * FROM spend_logs WHERE session_id = session_id
|
||||
SELECT session_id, api_key, "user", team_id FROM spend_logs WHERE request_id = previous_response_id, then
|
||||
SELECT * FROM spend_logs with that session_id and the same owner
|
||||
|
||||
Only rows with the referenced response's owner are returned: the same user and team, and for a key with
|
||||
neither, the same key. The session id can be caller supplied, so matching on it alone would mix owners. A
|
||||
user's or team's regenerated or second key keeps the history; a key with neither does not, and turns written
|
||||
by another owner (an admin continuing the chain) are not replayed.
|
||||
|
||||
A just-finished turn gets a short second chance: the worker that served it may
|
||||
still be writing its spend log when the follow-up arrives, and an empty result
|
||||
|
|
@ -303,13 +309,24 @@ class ResponsesSessionHandler:
|
|||
|
||||
query: Final = """
|
||||
WITH matching_session AS (
|
||||
SELECT session_id
|
||||
SELECT session_id, api_key, "user", team_id
|
||||
FROM "LiteLLM_SpendLogs"
|
||||
WHERE request_id = $1
|
||||
)
|
||||
SELECT *
|
||||
FROM "LiteLLM_SpendLogs"
|
||||
WHERE session_id IN (SELECT session_id FROM matching_session)
|
||||
FROM "LiteLLM_SpendLogs" AS logs
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM matching_session
|
||||
WHERE matching_session.session_id = logs.session_id
|
||||
AND matching_session."user" IS NOT DISTINCT FROM logs."user"
|
||||
AND matching_session.team_id IS NOT DISTINCT FROM logs.team_id
|
||||
AND (
|
||||
COALESCE(matching_session."user", '') <> ''
|
||||
OR COALESCE(matching_session.team_id, '') <> ''
|
||||
OR matching_session.api_key = logs.api_key
|
||||
)
|
||||
)
|
||||
ORDER BY "endTime" ASC;
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,11 @@ from typing_extensions import ReadOnly, TypedDict
|
|||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.caching import InMemoryCache
|
||||
from litellm.constants import REDACTED_BY_LITELLM, REDACTED_TOOL_CALL_ARGUMENTS_PLACEHOLDER
|
||||
from litellm.constants import (
|
||||
CALLER_SESSION_ID_METADATA_KEY,
|
||||
REDACTED_BY_LITELLM,
|
||||
REDACTED_TOOL_CALL_ARGUMENTS_PLACEHOLDER,
|
||||
)
|
||||
from litellm.integrations.anthropic_cache_control_hook import MAX_CACHE_CONTROL_BLOCKS, AnthropicCacheControlHook
|
||||
from litellm.litellm_core_utils.core_helpers import normalize_drop_params
|
||||
from litellm.litellm_core_utils.get_supported_openai_params import (
|
||||
|
|
@ -563,6 +567,14 @@ class ChatCompletionSession(TypedDict, total=False):
|
|||
########### End of Initialize Classes used for Responses API ###########
|
||||
|
||||
|
||||
def _chain_session_metadata(chain_session: str, request_kwargs: Mapping[str, object]) -> Mapping[str, object]:
|
||||
key: Final = "litellm_metadata" if request_kwargs.get("litellm_metadata") else "metadata"
|
||||
metadata: Final = request_kwargs.get(key)
|
||||
if not isinstance(metadata, dict):
|
||||
return MappingProxyType({})
|
||||
return {key: {**_STR_KEY_DICT_ADAPTER.validate_python(metadata), CALLER_SESSION_ID_METADATA_KEY: chain_session}}
|
||||
|
||||
|
||||
class LiteLLMCompletionResponsesConfig:
|
||||
@staticmethod
|
||||
def get_supported_openai_params(model: str) -> list:
|
||||
|
|
@ -1078,8 +1090,6 @@ class LiteLLMCompletionResponsesConfig:
|
|||
else:
|
||||
# Both are empty - this likely means function_call_output had empty/invalid call_id
|
||||
# Provide a helpful error message
|
||||
import litellm
|
||||
|
||||
raise litellm.BadRequestError(
|
||||
message=(
|
||||
f"Unable to create messages for completion request. "
|
||||
|
|
@ -1094,8 +1104,14 @@ class LiteLLMCompletionResponsesConfig:
|
|||
)
|
||||
|
||||
litellm_completion_request["messages"] = combined_messages
|
||||
litellm_completion_request["litellm_trace_id"] = chat_completion_session.get("litellm_session_id")
|
||||
return litellm_completion_request
|
||||
chain_session: Final = chat_completion_session.get("litellm_session_id")
|
||||
litellm_completion_request["litellm_trace_id"] = chain_session
|
||||
chain_session_metadata: Final[Mapping[str, object]] = (
|
||||
_chain_session_metadata(chain_session, request_kwargs)
|
||||
if chain_session and litellm.request_correlation_in_logs
|
||||
else MappingProxyType({})
|
||||
)
|
||||
return {**litellm_completion_request, **chain_session_metadata}
|
||||
|
||||
@staticmethod
|
||||
def _transform_response_input_param_to_chat_completion_message(
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ from dataclasses import dataclass
|
|||
from datetime import datetime, timezone
|
||||
from functools import lru_cache, partial
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, Generic, Literal, Optional, TypeAlias, TypeVar, Union, cast
|
||||
from typing import TYPE_CHECKING, Any, Final, Generic, Literal, NoReturn, Optional, TypeAlias, TypeVar, Union, cast
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
|
|
@ -68,6 +68,7 @@ from litellm.constants import (
|
|||
DEFAULT_HEALTH_CHECK_STALENESS_MULTIPLIER,
|
||||
DEFAULT_MAX_LRU_CACHE_SIZE,
|
||||
INTERNAL_CALL_ORIGIN_METADATA_KEY,
|
||||
MID_STREAM_FAILURE_LOG_WAIT_SECONDS,
|
||||
OUTPUT_TOKEN_CEILING_PARAMS,
|
||||
PROMPT_CACHE_PIN_CACHE_MAX_SIZE,
|
||||
ROUTER_USAGE_COUNTED_TOKENS_METADATA_KEY,
|
||||
|
|
@ -75,7 +76,7 @@ from litellm.constants import (
|
|||
RUNTIME_UPDATABLE_ROUTER_SETTINGS,
|
||||
SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
|
||||
)
|
||||
from litellm.integrations.custom_guardrail import is_guardrail_intervention
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail, is_guardrail_intervention
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.integrations.otel.routing import routing_decision_attributes
|
||||
from litellm.integrations.otel.runtime import phase_attributes, phase_event, phase_span
|
||||
|
|
@ -194,6 +195,7 @@ from litellm.router_utils.clientside_credential_handler import (
|
|||
is_clientside_credential,
|
||||
)
|
||||
from litellm.router_utils.common_utils import (
|
||||
MID_STREAM_CONTINUATION_KWARG,
|
||||
filter_team_based_models,
|
||||
filter_web_search_deployments,
|
||||
format_fallback_outcome_message,
|
||||
|
|
@ -233,7 +235,10 @@ from litellm.router_utils.fallback_event_handlers import (
|
|||
check_non_standard_fallback_format,
|
||||
clear_pre_routing_selection,
|
||||
committed_retry_budget_for_request,
|
||||
continuation_fallback_targets,
|
||||
fallback_lookup_groups,
|
||||
fallback_target_name,
|
||||
fallback_target_overrides_request_params,
|
||||
fallbacks_disabled_for_request,
|
||||
get_fallback_model_group_for_lookup_groups,
|
||||
get_pre_routing_selection,
|
||||
|
|
@ -258,6 +263,17 @@ from litellm.router_utils.handle_error import (
|
|||
send_llm_exception_alert,
|
||||
)
|
||||
from litellm.router_utils.health_state_cache import DeploymentHealthCache
|
||||
from litellm.router_utils.pre_call_checks.continuation_prefill_check import (
|
||||
MID_STREAM_CONTINUATION_MARKER,
|
||||
ContinuationOutput,
|
||||
ContinuationPrefillDeploymentCheck,
|
||||
ContinuedPartial,
|
||||
constrains_continuation,
|
||||
continuation_deployments,
|
||||
continuation_output_ceilings,
|
||||
continued_text,
|
||||
deployment_can_continue,
|
||||
)
|
||||
from litellm.router_utils.pre_call_checks.deployment_affinity_check import (
|
||||
DeploymentAffinityCheck,
|
||||
warn_on_unknown_model_group_affinity_flags,
|
||||
|
|
@ -303,6 +319,8 @@ from litellm.router_utils.routing_groups import (
|
|||
)
|
||||
from litellm.router_utils.routing_read_batch import ROUTER_USAGE_TARGET, RoutingPrefetch, RoutingReadBatch
|
||||
from litellm.scheduler import FlowItem, Scheduler
|
||||
from litellm.secret_managers.main import str_to_bool
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
from litellm.types.litellm_params import RoutingStrategyName
|
||||
from litellm.types.llms.openai import (
|
||||
AllMessageValues,
|
||||
|
|
@ -371,6 +389,7 @@ from litellm.utils import (
|
|||
EmbeddingResponse,
|
||||
ModelResponse,
|
||||
Rules,
|
||||
TextCompletionResponse,
|
||||
function_setup,
|
||||
get_llm_provider,
|
||||
get_non_default_completion_params,
|
||||
|
|
@ -527,6 +546,15 @@ _ROUTING_KWARGS_ADAPTER: Final[TypeAdapter[Mapping[str, object] | None]] = TypeA
|
|||
_FALLBACK_HOP_ADAPTER: Final = TypeAdapter(Mapping[str, object])
|
||||
_OPAQUE_VALUE_ADAPTER: Final = TypeAdapter(object)
|
||||
_DEPLOYMENT_SELECTED_EVENT: Final = "litellm.request.deployment_selected"
|
||||
_CONTENT_BLOCKS_ADAPTER: Final = TypeAdapter(list[dict[str, object]])
|
||||
|
||||
|
||||
def _extend_trailing_text_block(blocks: Sequence[Mapping[str, object]], text: str) -> Sequence[Mapping[str, object]]:
|
||||
"""Continue the last text block in place so the prefill reads as the text the caller already received."""
|
||||
last: Final = blocks[-1] if blocks else None
|
||||
if last is not None and last.get("type") == "text" and isinstance(last_text := last.get("text"), str):
|
||||
return [*blocks[:-1], {**last, "text": f"{last_text}{text}"}]
|
||||
return [*blocks, {"type": "text", "text": text}]
|
||||
|
||||
|
||||
def _is_fallback_hop(request_kwargs: Mapping[str, object]) -> bool:
|
||||
|
|
@ -1119,6 +1147,7 @@ class Router:
|
|||
health_check_ignore_transient_errors: bool = False,
|
||||
background_health_check_model_groups: Sequence[str] | None = None,
|
||||
enable_weighted_failover: bool = False,
|
||||
enable_mid_stream_fallback_continuation: bool = False,
|
||||
fallback_access_check: FallbackAccessCheck | None = None,
|
||||
fallback_budget_check: FallbackBudgetCheck | None = None,
|
||||
auto_router_capability_limit: AutoRouterCapabilityLimit | None = None,
|
||||
|
|
@ -1160,6 +1189,7 @@ class Router:
|
|||
deployment_affinity_ttl_seconds (int): TTL for user-key -> deployment affinity mapping. Defaults to 3600.
|
||||
ignore_invalid_deployments (bool): Ignores invalid deployments, and continues with other deployments. Default is to raise an error.
|
||||
enable_weighted_failover (bool): When True and the routing strategy is "simple-shuffle", a retryable failure on one deployment causes the request to re-pick (weighted) across the other deployments in the same model group before any cross-group fallback runs. Bounded by `max_fallbacks`. Async-only: currently honored by `router.acompletion()` and other async entrypoints. The sync `router.completion()` path falls back to the regular fallback flow. Defaults to False.
|
||||
enable_mid_stream_fallback_continuation (bool): When True, a chat-completions stream that breaks after plain assistant text has been delivered continues on a fallback deployment via assistant prefill instead of surfacing the error. Only deployments whose model supports assistant prefill are eligible, so the partial text is continued, not regenerated; if none is, the original error is surfaced. Streams that emitted tool calls, thinking blocks, audio/images, or constrained (JSON / forced tool_choice) output are never continued. Async-only. Defaults to False.
|
||||
fallback_access_check (Optional[FallbackAccessCheck]): Awaited before each cross-model-group fallback attempt on the async path; a fallback target it rejects is skipped. Defaults to None (every configured fallback is attempted).
|
||||
fallback_budget_check (Optional[FallbackBudgetCheck]): Awaited before each cross-model-group fallback attempt on the async path; a fallback target it rejects as over budget is skipped. Defaults to None (budget is not re-checked on fallback).
|
||||
Returns:
|
||||
|
|
@ -1355,6 +1385,7 @@ class Router:
|
|||
self.fallback_on_unsupported_params = fallback_on_unsupported_params
|
||||
self.enable_health_check_routing = enable_health_check_routing
|
||||
self.enable_weighted_failover = enable_weighted_failover
|
||||
self.enable_mid_stream_fallback_continuation = self._is_flag_enabled(enable_mid_stream_fallback_continuation)
|
||||
self.health_check_ignore_transient_errors = health_check_ignore_transient_errors
|
||||
self.background_health_check_model_groups: frozenset[str] | None = (
|
||||
frozenset(background_health_check_model_groups)
|
||||
|
|
@ -1549,6 +1580,14 @@ class Router:
|
|||
default_pre_call_checks: Final[OptionalPreCallChecks] = []
|
||||
self.add_optional_pre_call_checks(default_pre_call_checks)
|
||||
|
||||
if self.enable_mid_stream_fallback_continuation:
|
||||
self._register_continuation_prefill_check()
|
||||
|
||||
@staticmethod
|
||||
def _register_continuation_prefill_check() -> None:
|
||||
# Process-global on purpose: Router.discard() must not drop a filter another router still needs.
|
||||
litellm.logging_callback_manager.add_litellm_callback(ContinuationPrefillDeploymentCheck())
|
||||
|
||||
def discard(self):
|
||||
"""
|
||||
Pseudo-destructor to be invoked to clean up global data structures when router is no longer used.
|
||||
|
|
@ -3143,15 +3182,53 @@ class Router:
|
|||
|
||||
usage: Final = cast(Usage | None, getattr(fallback_item, "usage", None))
|
||||
usage_objects: Final = [usage] if usage is not None else []
|
||||
if (
|
||||
complete_response_object_usage is not None
|
||||
and hasattr(complete_response_object_usage, "usage")
|
||||
and complete_response_object_usage.usage is not None
|
||||
):
|
||||
if complete_response_object_usage is not None:
|
||||
usage_objects.append(complete_response_object_usage)
|
||||
combined_usage: Final = BaseTokenUsageProcessor.combine_usage_objects(usage_objects=usage_objects)
|
||||
setattr(fallback_item, "usage", combined_usage)
|
||||
|
||||
@staticmethod
|
||||
def _price_cut_partial(
|
||||
cut_stream: CustomStreamWrapper,
|
||||
partial_response: ModelResponse | TextCompletionResponse | None,
|
||||
) -> "ContinuedPartial | None":
|
||||
"""Price the cut stream's partial, plus any partial it inherited, before a fallback can reuse its logging."""
|
||||
from litellm.cost_calculator import BaseTokenUsageProcessor
|
||||
|
||||
cut_logging: Final = cut_stream.logging_obj
|
||||
partial_usage: Final = getattr(partial_response, "usage", None)
|
||||
if partial_response is None or not isinstance(partial_usage, Usage):
|
||||
return None
|
||||
inherited_usage: Final = cut_logging.model_call_details.get("continued_partial_usage")
|
||||
inherited_cost: Final = cut_logging.model_call_details.get("continued_partial_cost")
|
||||
inherited_id: Final = cut_logging.model_call_details.get("continued_response_id")
|
||||
priced_partial: Final = partial_response.model_copy(
|
||||
update={"model": cut_stream.model} if cut_stream.model else {}
|
||||
)
|
||||
priced_partial._hidden_params = { # pyright: ignore[reportPrivateUsage] # no public accessor
|
||||
key: value for key, value in get_hidden_params_dict(partial_response).items() if key != "response_cost"
|
||||
}
|
||||
return ContinuedPartial(
|
||||
usage=BaseTokenUsageProcessor.combine_usage_objects(
|
||||
usage_objects=[partial_usage, inherited_usage]
|
||||
if isinstance(inherited_usage, Usage)
|
||||
else [partial_usage]
|
||||
),
|
||||
response_cost=(cut_logging.response_cost_calculator(result=priced_partial) or 0.0)
|
||||
+ (inherited_cost if isinstance(inherited_cost, float) else 0.0),
|
||||
response_id=inherited_id if isinstance(inherited_id, str) else partial_response.id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _carry_partial_spend_to_fallback(fallback_response: object, cut_partial: "ContinuedPartial | None") -> None:
|
||||
"""Bill what the cut legs consumed on the continuation's success log."""
|
||||
fallback_logging: Final = getattr(fallback_response, "logging_obj", None)
|
||||
if cut_partial is None or not isinstance(fallback_logging, LiteLLMLogging):
|
||||
return
|
||||
fallback_logging.record_continued_partial(
|
||||
usage=cut_partial.usage, response_cost=cut_partial.response_cost, response_id=cut_partial.response_id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _prepare_fallback_hidden_params(
|
||||
fallback_response: object,
|
||||
|
|
@ -3201,7 +3278,7 @@ class Router:
|
|||
self,
|
||||
model_response: CustomStreamWrapper,
|
||||
messages: list[dict[str, str]],
|
||||
initial_kwargs: dict,
|
||||
initial_kwargs: dict[str, Any],
|
||||
deployment_slot: contextlib.AsyncExitStack | None = None,
|
||||
first_chunk: _FirstChunk[AsyncIterator[object]] | None = None,
|
||||
) -> CustomStreamWrapper:
|
||||
|
|
@ -3264,16 +3341,22 @@ class Router:
|
|||
with anyio.CancelScope(shield=True):
|
||||
await close_model_response()
|
||||
await held_slot.aclose()
|
||||
if not e.is_pre_first_chunk and (
|
||||
e.generated_content or _stream_chunks_have_generated_content(model_response.chunks)
|
||||
):
|
||||
if e.original_exception is not None:
|
||||
raise e.original_exception from e
|
||||
raise
|
||||
committed: Final = bool(
|
||||
not e.is_pre_first_chunk
|
||||
and (e.generated_content or _stream_chunks_have_generated_content(model_response.chunks))
|
||||
)
|
||||
cut_hidden_params: Final = get_hidden_params(model_response) or {}
|
||||
continue_after_content: Final = committed and self._mid_stream_continuation_eligible(
|
||||
e=e, request_kwargs=initial_kwargs, cut_deployment_id=cut_hidden_params.get("model_id")
|
||||
)
|
||||
if committed and not continue_after_content:
|
||||
self._raise_original_mid_stream_error(e)
|
||||
|
||||
from litellm.main import stream_chunk_builder
|
||||
|
||||
complete_response_object: Final = stream_chunk_builder(chunks=model_response.chunks)
|
||||
complete_response_object: Final = stream_chunk_builder(
|
||||
chunks=model_response.chunks, messages=messages if continue_after_content else None
|
||||
)
|
||||
complete_response_object_usage: Final = cast(
|
||||
Usage | None,
|
||||
getattr(complete_response_object, "usage", None),
|
||||
|
|
@ -3289,7 +3372,41 @@ class Router:
|
|||
"content_policy_fallbacks", self.content_policy_fallbacks
|
||||
)
|
||||
initial_kwargs["original_function"] = self._acompletion
|
||||
initial_kwargs["messages"] = messages
|
||||
continuation_messages: Final = (
|
||||
self._build_completion_continuation_input(messages, e.generated_content)
|
||||
if continue_after_content
|
||||
else None
|
||||
)
|
||||
continuation_output: Final = (
|
||||
ContinuationOutput(
|
||||
e.generated_content,
|
||||
continuation_messages[-1],
|
||||
model_group,
|
||||
complete_response_object,
|
||||
checks_post_call_rules=initial_kwargs.get(MID_STREAM_CONTINUATION_KWARG)
|
||||
is not MID_STREAM_CONTINUATION_MARKER,
|
||||
)
|
||||
if continuation_messages is not None
|
||||
else None
|
||||
)
|
||||
cut_partial: Final = (
|
||||
Router._price_cut_partial(model_response, complete_response_object)
|
||||
if continuation_output is not None
|
||||
else None
|
||||
)
|
||||
if continue_after_content:
|
||||
emitted_tokens: Final = int(
|
||||
getattr(complete_response_object_usage, "completion_tokens", 0) or 0
|
||||
)
|
||||
reduced_ceilings: Final = continuation_output_ceilings(initial_kwargs, emitted_tokens)
|
||||
if reduced_ceilings is None or continuation_messages is None:
|
||||
self._raise_original_mid_stream_error(e)
|
||||
await model_response.wait_for_failure_logging(timeout=MID_STREAM_FAILURE_LOG_WAIT_SECONDS)
|
||||
initial_kwargs.update(reduced_ceilings)
|
||||
initial_kwargs["messages"] = continuation_messages
|
||||
initial_kwargs[MID_STREAM_CONTINUATION_KWARG] = MID_STREAM_CONTINUATION_MARKER
|
||||
else:
|
||||
initial_kwargs["messages"] = messages
|
||||
self._update_kwargs_before_fallbacks(model=model_group, kwargs=initial_kwargs)
|
||||
fallback_response = await self.async_function_with_fallbacks_common_utils(
|
||||
e=e,
|
||||
|
|
@ -3305,11 +3422,17 @@ class Router:
|
|||
|
||||
# If fallback returns a streaming response, iterate over it
|
||||
if hasattr(fallback_response, "__aiter__"):
|
||||
Router._carry_partial_spend_to_fallback(fallback_response, cut_partial)
|
||||
prepared_fallback_hidden_params = Router._adopt_fallback_response_headers(
|
||||
wrapper_ref, fallback_response
|
||||
)
|
||||
fallback_headers_are_settled = False
|
||||
async for fallback_item in fallback_response:
|
||||
fallback_items: Final = (
|
||||
continuation_output.splice(fallback_response)
|
||||
if continuation_output is not None
|
||||
else fallback_response
|
||||
)
|
||||
async for fallback_item in fallback_items:
|
||||
if not fallback_headers_are_settled:
|
||||
fallback_headers_are_settled = True
|
||||
# a fallback that failed over again only repoints itself once it yields
|
||||
|
|
@ -3322,7 +3445,10 @@ class Router:
|
|||
and isinstance(fallback_item, ModelResponseStream)
|
||||
and hasattr(fallback_item, "usage")
|
||||
):
|
||||
self._combine_fallback_usage(fallback_item, complete_response_object_usage)
|
||||
self._combine_fallback_usage(
|
||||
fallback_item,
|
||||
complete_response_object_usage if continuation_output is not None else None,
|
||||
)
|
||||
yield fallback_item
|
||||
else:
|
||||
# If fallback returns a non-streaming response, yield None
|
||||
|
|
@ -3479,6 +3605,97 @@ class Router:
|
|||
total_tokens=(partial_usage.total_tokens or 0) + (fb.total_tokens or 0),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _raise_original_mid_stream_error(e: "MidStreamFallbackError") -> NoReturn:
|
||||
"""Surface the provider error the stream wrapper carried instead of the internal MidStreamFallbackError."""
|
||||
if e.original_exception is not None:
|
||||
raise e.original_exception from e
|
||||
raise e
|
||||
|
||||
def _params_sent_to_deployment(
|
||||
self, request_kwargs: Mapping[str, object], deployment: Deployment | None
|
||||
) -> Mapping[str, object]:
|
||||
"""The request as the deployment received it, merged in the order _update_kwargs_with_deployment applies."""
|
||||
deployment_params: Final = deployment.litellm_params.model_dump(exclude_none=True) if deployment else {}
|
||||
defaults: Final = {key: value for key, value in self.default_litellm_params.items() if value is not None}
|
||||
deployment_tool_choice: Final = (
|
||||
{"tool_choice": deployment_params["tool_choice"]}
|
||||
if deployment_params.get("tool_choice") is not None
|
||||
else {}
|
||||
)
|
||||
return MappingProxyType({**deployment_params, **defaults, **deployment_tool_choice, **request_kwargs})
|
||||
|
||||
def _names_deployment_that_cannot_continue(self, target: object) -> bool:
|
||||
name: Final = fallback_target_name(target)
|
||||
if name is None or name in self.model_names or not self.has_model_id(name):
|
||||
return False
|
||||
deployment: Final = self.get_deployment(model_id=name)
|
||||
return deployment is not None and not deployment_can_continue(deployment.model_dump(exclude_none=True))
|
||||
|
||||
def _mid_stream_continuation_eligible(
|
||||
self,
|
||||
e: "MidStreamFallbackError",
|
||||
request_kwargs: Mapping[str, object],
|
||||
cut_deployment_id: object = None,
|
||||
) -> bool:
|
||||
"""Whether a stream that broke after plain assistant text may continue via prefill."""
|
||||
if not self.enable_mid_stream_fallback_continuation:
|
||||
return False
|
||||
if not e.generated_content or e.emitted_disqualifying_content:
|
||||
return False
|
||||
if isinstance(e.original_exception, litellm.ContentPolicyViolationError):
|
||||
return False
|
||||
cut_deployment: Final = (
|
||||
self.get_deployment(model_id=cut_deployment_id) if isinstance(cut_deployment_id, str) else None
|
||||
)
|
||||
if isinstance(cut_deployment_id, str) and cut_deployment is None:
|
||||
return False
|
||||
if constrains_continuation(self._params_sent_to_deployment(request_kwargs, cut_deployment)):
|
||||
return False
|
||||
requested_group: Final = request_kwargs.get("model")
|
||||
lookup_groups: Final = fallback_lookup_groups(
|
||||
request_kwargs, requested_group if isinstance(requested_group, str) else None
|
||||
)
|
||||
targets: Final = continuation_fallback_targets(request_kwargs.get("fallbacks", self.fallbacks), lookup_groups)
|
||||
if any(fallback_target_overrides_request_params(target) for target in targets):
|
||||
return False
|
||||
if any(self._names_deployment_that_cannot_continue(target) for target in targets):
|
||||
return False
|
||||
return not any(
|
||||
isinstance(callback, CustomGuardrail)
|
||||
and callback._event_hook_is_event_type( # pyright: ignore[reportPrivateUsage] # the only check of a guardrail's hooks
|
||||
GuardrailEventHooks.post_call
|
||||
)
|
||||
for callback in litellm.callbacks
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_completion_continuation_input(
|
||||
messages: Sequence[Mapping[str, object]],
|
||||
generated_content: str,
|
||||
) -> Sequence[Mapping[str, object]] | None:
|
||||
"""Append the partial output as an assistant prefill, or extend a trailing `prefix: True` assistant turn in
|
||||
place so the request never ends in two assistant messages. None when the request ends in a plain assistant
|
||||
turn, in a prefill turn whose content has a shape this cannot extend, or when the cut came mid-echo of the
|
||||
prefill."""
|
||||
last: Final = messages[-1] if messages else None
|
||||
if last is None or last.get("role") != "assistant":
|
||||
return [*messages, {"role": "assistant", "content": generated_content, "prefix": True}]
|
||||
if last.get("prefix") is not True:
|
||||
return None
|
||||
new_text: Final = continued_text(last, generated_content)
|
||||
if new_text is None:
|
||||
return None
|
||||
content: Final = last.get("content")
|
||||
if content is None or isinstance(content, str):
|
||||
return [*messages[:-1], {**last, "content": f"{content or ''}{new_text}", "prefix": True}]
|
||||
try:
|
||||
blocks: Final = _CONTENT_BLOCKS_ADAPTER.validate_python(content)
|
||||
except ValidationError:
|
||||
return None
|
||||
extended: Final = _extend_trailing_text_block(blocks, new_text)
|
||||
return [*messages[:-1], {**last, "content": extended, "prefix": True}]
|
||||
|
||||
@staticmethod
|
||||
def _build_responses_continuation_input(
|
||||
input_val: Union[str, "ResponseInputParam"] | None,
|
||||
|
|
@ -3880,13 +4097,6 @@ class Router:
|
|||
raise e.original_exception from e
|
||||
raise
|
||||
|
||||
from litellm.main import stream_chunk_builder
|
||||
|
||||
complete_response_object: Final = stream_chunk_builder(chunks=model_response.chunks)
|
||||
complete_response_object_usage: Final = cast(
|
||||
Usage | None,
|
||||
getattr(complete_response_object, "usage", None),
|
||||
)
|
||||
try:
|
||||
model_group: Final = cast(str, initial_kwargs.get("model"))
|
||||
fallbacks: Final[list | None] = initial_kwargs.get("fallbacks", router_self.fallbacks)
|
||||
|
|
@ -3932,7 +4142,7 @@ class Router:
|
|||
and isinstance(fallback_item, ModelResponseStream)
|
||||
and hasattr(fallback_item, "usage")
|
||||
):
|
||||
router_self._combine_fallback_usage(fallback_item, complete_response_object_usage)
|
||||
router_self._combine_fallback_usage(fallback_item, None)
|
||||
yield fallback_item
|
||||
else:
|
||||
yield None
|
||||
|
|
@ -13004,6 +13214,7 @@ class Router:
|
|||
"retry_policy",
|
||||
"model_group_alias",
|
||||
"enable_weighted_failover",
|
||||
"enable_mid_stream_fallback_continuation",
|
||||
"enable_tag_filtering",
|
||||
"tag_routing_prefix",
|
||||
"fallback_on_unsupported_params",
|
||||
|
|
@ -13060,12 +13271,10 @@ class Router:
|
|||
rebuild_routing_groups = True
|
||||
elif var == "optional_pre_call_checks":
|
||||
self.set_optional_pre_call_checks(kwargs[var])
|
||||
elif var == "enable_mid_stream_fallback_continuation":
|
||||
self._set_mid_stream_fallback_continuation(kwargs[var])
|
||||
elif var == "retry_policy":
|
||||
value = kwargs[var]
|
||||
if isinstance(value, dict):
|
||||
value = RetryPolicy(**value)
|
||||
if value is None or isinstance(value, RetryPolicy):
|
||||
setattr(self, var, value)
|
||||
self._set_retry_policy(kwargs[var])
|
||||
else:
|
||||
value = kwargs[var]
|
||||
# only run routing strategy init if it has changed
|
||||
|
|
@ -13092,6 +13301,20 @@ class Router:
|
|||
self._routing_groups_input = routing_groups_input
|
||||
verbose_router_logger.debug("Updated Router settings: %s", self.get_settings())
|
||||
|
||||
@staticmethod
|
||||
def _is_flag_enabled(value: object) -> bool:
|
||||
return value is True or (isinstance(value, str) and str_to_bool(value) is True)
|
||||
|
||||
def _set_mid_stream_fallback_continuation(self, enabled: object) -> None:
|
||||
self.enable_mid_stream_fallback_continuation = self._is_flag_enabled(enabled)
|
||||
if self.enable_mid_stream_fallback_continuation:
|
||||
self._register_continuation_prefill_check()
|
||||
|
||||
def _set_retry_policy(self, value: object) -> None:
|
||||
policy: Final = RetryPolicy.model_validate(value) if isinstance(value, dict) else value
|
||||
if policy is None or isinstance(policy, RetryPolicy):
|
||||
self.retry_policy = policy
|
||||
|
||||
def _get_client(self, deployment, kwargs, client_type=None):
|
||||
"""
|
||||
Returns the appropriate client based on the given deployment, kwargs, and client_type.
|
||||
|
|
@ -15119,9 +15342,8 @@ class Router:
|
|||
request_kwargs=request_kwargs,
|
||||
)
|
||||
strategy, strategy_selector = self._get_routing_context(model, request_kwargs)
|
||||
pick_attributes: Final = _deployment_pick_attributes(
|
||||
model, _ROUTING_KWARGS_ADAPTER.validate_python(request_kwargs)
|
||||
)
|
||||
routing_kwargs: Final = _ROUTING_KWARGS_ADAPTER.validate_python(request_kwargs)
|
||||
pick_attributes: Final = _deployment_pick_attributes(model, routing_kwargs)
|
||||
|
||||
if isinstance(healthy_deployments, dict):
|
||||
if (healthy_deployments.get("model_info") or {}).get("blocked") is True:
|
||||
|
|
@ -15159,7 +15381,9 @@ class Router:
|
|||
)
|
||||
healthy_deployments = _pre_cooldown_deployments
|
||||
|
||||
healthy_deployments = self._filter_blocked_deployments(healthy_deployments)
|
||||
healthy_deployments = continuation_deployments(
|
||||
self._filter_blocked_deployments(healthy_deployments), routing_kwargs
|
||||
)
|
||||
|
||||
# filter pre-call checks
|
||||
if self.enable_pre_call_checks and (messages is not None or input is not None):
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ from litellm.types.utils import LlmProviders, ProviderSpecificModelInfo
|
|||
|
||||
_V = TypeVar("_V")
|
||||
|
||||
ROUTER_ONLY_CALL_KWARGS: Final = frozenset({"silent_model", "include_fallback_errors"})
|
||||
MID_STREAM_CONTINUATION_KWARG: Final = "_mid_stream_continuation"
|
||||
ROUTER_ONLY_CALL_KWARGS: Final = frozenset({"silent_model", "include_fallback_errors", MID_STREAM_CONTINUATION_KWARG})
|
||||
_OBJECT_MAPPING_ADAPTER: Final[TypeAdapter[Mapping[object, object]]] = TypeAdapter(Mapping[object, object])
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -615,6 +615,32 @@ def get_fallback_model_group(
|
|||
return fallback_model_group, generic_fallback_idx
|
||||
|
||||
|
||||
def _resolved_fallback_chain(entries: Sequence[object], lookup_groups: tuple[str, ...]) -> Sequence[object]:
|
||||
group_entries: Final[Sequence[Mapping[str, list[str]]]] = tuple( # mutable-ok: mirrors the fallbacks config shape
|
||||
entry for entry in entries if isinstance(entry, Mapping) and entry
|
||||
)
|
||||
chain, _ = get_fallback_model_group_for_lookup_groups(fallbacks=group_entries, lookup_groups=lookup_groups)
|
||||
return chain if isinstance(chain, list) else ()
|
||||
|
||||
|
||||
def continuation_fallback_targets(fallbacks: object, lookup_groups: tuple[str, ...]) -> Sequence[object]:
|
||||
entries: Final[Sequence[object]] = fallbacks if isinstance(fallbacks, list) else ()
|
||||
return (
|
||||
entries
|
||||
if check_non_standard_fallback_format(fallbacks=entries)
|
||||
else _resolved_fallback_chain(entries, lookup_groups)
|
||||
)
|
||||
|
||||
|
||||
def fallback_target_overrides_request_params(target: object) -> bool:
|
||||
return isinstance(target, Mapping) and "model" in target and len(target) > 1
|
||||
|
||||
|
||||
def fallback_target_name(target: object) -> str | None:
|
||||
name: Final = target.get("model") if isinstance(target, Mapping) else target
|
||||
return name if isinstance(name, str) else None
|
||||
|
||||
|
||||
PROVIDER_SCOPED_RESOURCE_KEYS: Final = ("input_file_id", "training_file", "batch_id", "file_id", "fine_tuning_job_id")
|
||||
PROVIDER_SCOPED_RESOURCE_FUNCTION_NAMES: Final = frozenset(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,264 @@
|
|||
"""Keeps a mid-stream continuation on deployments whose model supports assistant prefill and whose own params leave
|
||||
the output unconstrained."""
|
||||
|
||||
from collections.abc import AsyncIterable, AsyncIterator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from itertools import accumulate
|
||||
from os.path import commonprefix
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
import litellm
|
||||
from litellm.integrations.custom_logger import CustomLogger, Span
|
||||
from litellm.litellm_core_utils.rules import Rules
|
||||
from litellm.router_utils.common_utils import MID_STREAM_CONTINUATION_KWARG
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
from litellm.types.utils import ModelResponse, ModelResponseStream, Usage
|
||||
from litellm.utils import supports_assistant_prefill
|
||||
|
||||
|
||||
class _ContinuationMarker:
|
||||
"""Only the router constructs one, so a JSON request body cannot forge the continuation flag."""
|
||||
|
||||
|
||||
MID_STREAM_CONTINUATION_MARKER: Final = _ContinuationMarker()
|
||||
|
||||
_STR_KEYED_DICT_ADAPTER: Final = TypeAdapter(dict[str, object])
|
||||
_NO_FIELDS: Final[Mapping[str, object]] = MappingProxyType({})
|
||||
_UNCONSTRAINED_RESPONSE_FORMAT: Final[Mapping[str, str]] = MappingProxyType({"type": "text"})
|
||||
_OUTPUT_CEILING_KEYS: Final = ("max_tokens", "max_completion_tokens")
|
||||
|
||||
|
||||
def _str_keyed(value: object) -> Mapping[str, object]:
|
||||
try:
|
||||
return _STR_KEYED_DICT_ADAPTER.validate_python(value)
|
||||
except ValidationError:
|
||||
return _NO_FIELDS
|
||||
|
||||
|
||||
def deployment_supports_prefill(deployment: object) -> bool:
|
||||
deployment_map: Final = _str_keyed(deployment)
|
||||
model_info: Final = _str_keyed(deployment_map.get("model_info"))
|
||||
declared: Final = model_info.get("supports_assistant_prefill")
|
||||
if isinstance(declared, bool):
|
||||
return declared
|
||||
litellm_params: Final = _str_keyed(deployment_map.get("litellm_params"))
|
||||
candidates: Final = (model_info.get("base_model"), litellm_params.get("base_model"), litellm_params.get("model"))
|
||||
model: Final = next((candidate for candidate in candidates if isinstance(candidate, str) and candidate), None)
|
||||
return model is not None and supports_assistant_prefill(model=model)
|
||||
|
||||
|
||||
def constrains_continuation(params: Mapping[str, object]) -> bool:
|
||||
sent: Final = {**params, **_str_keyed(params.get("extra_body"))}
|
||||
choice_count: Final = sent.get("n")
|
||||
response_format: Final = sent.get("response_format")
|
||||
tool_choice: Final = sent.get("tool_choice")
|
||||
return (
|
||||
(isinstance(choice_count, int) and choice_count > 1)
|
||||
or (response_format is not None and response_format != _UNCONSTRAINED_RESPONSE_FORMAT)
|
||||
or tool_choice == "required"
|
||||
or isinstance(tool_choice, Mapping)
|
||||
or sent.get("merge_reasoning_content_in_choices") is True
|
||||
)
|
||||
|
||||
|
||||
def deployment_can_continue(deployment: object) -> bool:
|
||||
litellm_params: Final = _str_keyed(_str_keyed(deployment).get("litellm_params"))
|
||||
return deployment_supports_prefill(deployment) and not constrains_continuation(litellm_params)
|
||||
|
||||
|
||||
def continuation_deployments(
|
||||
healthy_deployments: list[dict[str, object]], # mutable-ok: router deployment-list contract
|
||||
request_kwargs: Mapping[str, object] | None,
|
||||
) -> list[dict[str, object]]: # mutable-ok: returns a mutable deployment list
|
||||
marker: Final = (request_kwargs or {}).get(MID_STREAM_CONTINUATION_KWARG)
|
||||
if not isinstance(marker, _ContinuationMarker):
|
||||
return healthy_deployments
|
||||
return [deployment for deployment in healthy_deployments if deployment_can_continue(deployment)]
|
||||
|
||||
|
||||
class ContinuationPrefillDeploymentCheck(CustomLogger):
|
||||
async def async_filter_deployments(
|
||||
self,
|
||||
model: str,
|
||||
healthy_deployments: list[dict[str, object]], # mutable-ok: CustomLogger deployment-list contract
|
||||
messages: Sequence[AllMessageValues] | None,
|
||||
request_kwargs: Mapping[str, object] | None = None,
|
||||
parent_otel_span: Span | None = None,
|
||||
) -> list[dict[str, object]]: # mutable-ok: returns a mutable deployment list
|
||||
return continuation_deployments(healthy_deployments, request_kwargs)
|
||||
|
||||
|
||||
def _delta_text(chunk: ModelResponseStream) -> str:
|
||||
if not chunk.choices:
|
||||
return ""
|
||||
content: Final = chunk.choices[0].delta.content
|
||||
return content if isinstance(content, str) else ""
|
||||
|
||||
|
||||
_CONTENT_BLOCKS_ADAPTER: Final = TypeAdapter(list[dict[str, object]])
|
||||
|
||||
|
||||
def _prefill_text(prefill_message: Mapping[str, object]) -> str:
|
||||
content: Final = prefill_message.get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
try:
|
||||
blocks: Final = _CONTENT_BLOCKS_ADAPTER.validate_python(content)
|
||||
except ValidationError:
|
||||
return ""
|
||||
return "".join(
|
||||
text for block in blocks if block.get("type") == "text" and isinstance(text := block.get("text"), str)
|
||||
)
|
||||
|
||||
|
||||
def _resendable_whitespace(prefill: str) -> str:
|
||||
return prefill[len(prefill.rstrip()) :] if prefill.strip() else ""
|
||||
|
||||
|
||||
def continued_text(prefill_message: Mapping[str, object], generated_content: str) -> str | None:
|
||||
prefill: Final = _prefill_text(prefill_message)
|
||||
if generated_content and len(generated_content) < len(prefill) and prefill.startswith(generated_content):
|
||||
return None
|
||||
echo: Final = len(prefill) if prefill and generated_content.startswith(prefill) else 0
|
||||
after_echo: Final = generated_content[echo:]
|
||||
return after_echo[len(commonprefix((after_echo, _resendable_whitespace(prefill)))) :]
|
||||
|
||||
|
||||
def _reduced_ceilings(params: Mapping[str, object], emitted_tokens: int) -> Mapping[str, int]:
|
||||
return MappingProxyType(
|
||||
{key: value - emitted_tokens for key in _OUTPUT_CEILING_KEYS if isinstance(value := params.get(key), int)}
|
||||
)
|
||||
|
||||
|
||||
def continuation_output_ceilings(
|
||||
request_kwargs: Mapping[str, object], emitted_tokens: int
|
||||
) -> Mapping[str, object] | None:
|
||||
"""The caller's output ceilings, top-level and in extra_body, minus the tokens already emitted, or None once
|
||||
nothing is left."""
|
||||
extra_body: Final = _str_keyed(request_kwargs.get("extra_body"))
|
||||
top_level: Final = _reduced_ceilings(request_kwargs, emitted_tokens)
|
||||
in_extra_body: Final = _reduced_ceilings(extra_body, emitted_tokens)
|
||||
if any(ceiling <= 0 for ceiling in (*top_level.values(), *in_extra_body.values())):
|
||||
return None
|
||||
if not in_extra_body:
|
||||
return top_level
|
||||
return MappingProxyType({**top_level, "extra_body": {**extra_body, **in_extra_body}})
|
||||
|
||||
|
||||
def _with_first_delta(chunk: ModelResponseStream, update: Mapping[str, object]) -> ModelResponseStream:
|
||||
choice: Final = chunk.choices[0]
|
||||
delta: Final = choice.delta.model_copy(update=update)
|
||||
return chunk.model_copy(update={"choices": [choice.model_copy(update={"delta": delta}), *chunk.choices[1:]]})
|
||||
|
||||
|
||||
def _without_leading_text(chunk: ModelResponseStream, count: int) -> ModelResponseStream:
|
||||
if count <= 0:
|
||||
return chunk
|
||||
return _with_first_delta(chunk, {"content": _delta_text(chunk)[count:]})
|
||||
|
||||
|
||||
def _without_role(chunk: ModelResponseStream) -> ModelResponseStream:
|
||||
if not chunk.choices or chunk.choices[0].delta.role is None:
|
||||
return chunk
|
||||
return _with_first_delta(chunk, {"role": None})
|
||||
|
||||
|
||||
def _stream_identity(primary_response: object) -> Mapping[str, object]:
|
||||
if not isinstance(primary_response, ModelResponse):
|
||||
return _NO_FIELDS
|
||||
return MappingProxyType({"id": primary_response.id, "created": primary_response.created})
|
||||
|
||||
|
||||
def _drop_leading_text(chunks: tuple[ModelResponseStream, ...], count: int) -> tuple[ModelResponseStream, ...]:
|
||||
lengths: Final = tuple(len(_delta_text(chunk)) for chunk in chunks)
|
||||
offsets: Final = tuple(accumulate(lengths, initial=0))[:-1]
|
||||
return tuple(
|
||||
_without_leading_text(chunk, min(length, count - offset))
|
||||
for chunk, offset, length in zip(chunks, offsets, lengths, strict=True)
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContinuedPartial:
|
||||
usage: Usage
|
||||
response_cost: float
|
||||
response_id: str
|
||||
|
||||
|
||||
class ContinuationOutput:
|
||||
"""Splices a fallback's continuation onto the stream the client already received. Drops a leading echo of the
|
||||
prefill, since some providers (Mistral) return the prefix inside the completion, and the prefill's trailing
|
||||
whitespace sent again by a provider that strips it (Anthropic), keeps the primary's chunk id, created time and single role delta, and
|
||||
runs litellm.post_call_rules over the joined text, since each stream wrapper only sees its own fragment. A
|
||||
continuation nested in another leaves the rules to the outermost one, the only one holding the whole text."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
emitted_text: str,
|
||||
prefill_message: Mapping[str, object],
|
||||
model: str,
|
||||
primary_response: object,
|
||||
checks_post_call_rules: bool = True,
|
||||
) -> None:
|
||||
self._text = emitted_text
|
||||
self._checks_post_call_rules: Final = checks_post_call_rules
|
||||
self._prefill_text: Final = _prefill_text(prefill_message)
|
||||
self._model: Final = model
|
||||
self._identity: Final = _stream_identity(primary_response)
|
||||
self._rules: Final = Rules()
|
||||
self._held: tuple[ModelResponseStream, ...] = ()
|
||||
self._echo_settled = not self._prefill_text
|
||||
self._seam = _resendable_whitespace(self._prefill_text)
|
||||
|
||||
async def splice(self, stream: AsyncIterable[object]) -> AsyncIterator[object]:
|
||||
async for item in stream:
|
||||
if not isinstance(item, ModelResponseStream):
|
||||
yield item
|
||||
continue
|
||||
for released in self._observed(self._past_seam(self._released(item))):
|
||||
yield self._in_primary_stream(released)
|
||||
for released in self._observed(self._past_seam(self._flushed())):
|
||||
yield self._in_primary_stream(released)
|
||||
|
||||
def _in_primary_stream(self, chunk: ModelResponseStream) -> ModelResponseStream:
|
||||
return _without_role(chunk.model_copy(update=self._identity) if self._identity else chunk)
|
||||
|
||||
def _flushed(self) -> tuple[ModelResponseStream, ...]:
|
||||
held: Final = self._held
|
||||
self._held = ()
|
||||
self._echo_settled = True
|
||||
return held
|
||||
|
||||
def _released(self, chunk: ModelResponseStream) -> tuple[ModelResponseStream, ...]:
|
||||
if self._echo_settled:
|
||||
return (chunk,)
|
||||
held: Final = (*self._held, chunk)
|
||||
text: Final = "".join(_delta_text(held_chunk) for held_chunk in held)
|
||||
if len(text) < len(self._prefill_text) and self._prefill_text.startswith(text):
|
||||
self._held = held
|
||||
return ()
|
||||
self._held = ()
|
||||
self._echo_settled = True
|
||||
if not text.startswith(self._prefill_text):
|
||||
return held
|
||||
return _drop_leading_text(held, len(self._prefill_text))
|
||||
|
||||
def _past_seam(self, chunks: tuple[ModelResponseStream, ...]) -> tuple[ModelResponseStream, ...]:
|
||||
return tuple(self._without_seam(chunk) for chunk in chunks)
|
||||
|
||||
def _without_seam(self, chunk: ModelResponseStream) -> ModelResponseStream:
|
||||
text: Final = _delta_text(chunk)
|
||||
matched: Final = len(commonprefix((text, self._seam)))
|
||||
self._seam = self._seam[matched:] if matched == len(text) else ""
|
||||
return _without_leading_text(chunk, matched)
|
||||
|
||||
def _observed(self, chunks: tuple[ModelResponseStream, ...]) -> tuple[ModelResponseStream, ...]:
|
||||
if not self._checks_post_call_rules or not litellm.post_call_rules:
|
||||
return chunks
|
||||
for chunk in chunks:
|
||||
self._text += _delta_text(chunk)
|
||||
self._rules.post_call_rules(input=self._text, model=self._model)
|
||||
return chunks
|
||||
|
|
@ -4045,7 +4045,9 @@ class CustomPricingLiteLLMParams(MirroredPricingParams):
|
|||
|
||||
DEPLOYMENT_SCOPED_PRICING_FIELDS: Final[frozenset[str]] = frozenset({"off_peak_pricing"})
|
||||
|
||||
DEPLOYMENT_SCOPED_CAPABILITY_FIELDS: Final[frozenset[str]] = frozenset({"supports_regex_lookaround"})
|
||||
DEPLOYMENT_SCOPED_CAPABILITY_FIELDS: Final[frozenset[str]] = frozenset(
|
||||
{"supports_regex_lookaround", "supports_assistant_prefill"}
|
||||
)
|
||||
|
||||
SHARED_BACKEND_MODEL_INFO_FIELDS: Final[frozenset[str]] = (
|
||||
frozenset(ModelInfoBase.__required_keys__ | ModelInfoBase.__optional_keys__)
|
||||
|
|
|
|||
|
|
@ -3060,6 +3060,20 @@ def supports_prompt_cache_breakpoint(model: str, custom_llm_provider: str | None
|
|||
)
|
||||
|
||||
|
||||
def supports_assistant_prefill(model: str, custom_llm_provider: str | None = None) -> bool:
|
||||
"""
|
||||
Whether the model can continue a prefilled assistant message (Anthropic's
|
||||
``prefix: True`` trick and equivalents). Missing metadata reads as False,
|
||||
so a mid-stream fallback continuation is only routed to a model known to
|
||||
support it.
|
||||
"""
|
||||
return _supports_factory(
|
||||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
key="supports_assistant_prefill",
|
||||
)
|
||||
|
||||
|
||||
def supports_thinking_cache_preservation(model: str, custom_llm_provider: str | None = None) -> bool:
|
||||
return supports_factory(
|
||||
model=model,
|
||||
|
|
@ -9933,6 +9947,10 @@ class ProviderConfigManager:
|
|||
return MinimaxVideoConfig()
|
||||
elif LlmProviders.EDENAI == provider:
|
||||
return litellm.EdenAIVideoConfig()
|
||||
elif LlmProviders.VOLCENGINE == provider:
|
||||
from litellm.llms.volcengine.videos.transformation import VolcEngineVideoConfig
|
||||
|
||||
return VolcEngineVideoConfig()
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -2773,7 +2773,7 @@
|
|||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
|
|
@ -2808,7 +2808,7 @@
|
|||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
|
|
@ -2843,7 +2843,7 @@
|
|||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
|
|
@ -2878,7 +2878,7 @@
|
|||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
|
|
@ -2913,7 +2913,7 @@
|
|||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
|
|
@ -2948,7 +2948,7 @@
|
|||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
|
|
@ -3743,7 +3743,7 @@
|
|||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 1.5e-05,
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
|
|
@ -15494,7 +15494,7 @@
|
|||
},
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
|
|
@ -20832,7 +20832,7 @@
|
|||
"output_cost_per_token": 2.5000010000000002e-05,
|
||||
"output_dbu_cost_per_token": 0.000357143,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_function_calling": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"supports_prompt_caching": true,
|
||||
|
|
@ -21047,7 +21047,7 @@
|
|||
"output_cost_per_token": 1.5000020000000002e-05,
|
||||
"output_dbu_cost_per_token": 0.000214286,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_function_calling": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"supports_prompt_caching": true,
|
||||
|
|
@ -49176,7 +49176,7 @@
|
|||
"max_tokens": 64000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2.5e-05,
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
|
|
@ -51006,7 +51006,7 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 1.5e-05,
|
||||
"output_cost_per_token_batches": 7.5e-06,
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
|
|
@ -51179,7 +51179,7 @@
|
|||
"supported_regions": [
|
||||
"global"
|
||||
],
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
|
|
@ -51200,7 +51200,7 @@
|
|||
"supported_regions": [
|
||||
"us-central1"
|
||||
],
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
|
|
@ -58822,7 +58822,7 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 1.5e-05,
|
||||
"output_cost_per_token_batches": 7.5e-06,
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
|
|
@ -60467,6 +60467,61 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"volcengine/doubao-seedance-2-5-260628": {
|
||||
"litellm_provider": "volcengine",
|
||||
"mode": "video_generation",
|
||||
"source": "https://www.volcengine.com/docs/82379/1544106",
|
||||
"output_cost_per_second": 0.216,
|
||||
"output_cost_per_second_480p": 0.096075,
|
||||
"output_cost_per_second_720p": 0.216,
|
||||
"output_cost_per_second_1080p": 0.5346,
|
||||
"supported_modalities": ["text", "image", "video", "audio"],
|
||||
"supported_output_modalities": ["video"],
|
||||
"supported_endpoints": ["/v1/videos"],
|
||||
"provider_specific_entry": {"volcengine_video_frames": {"480p": {"16:9": "854x480", "4:3": "752x560", "1:1": "640x640", "3:4": "560x752", "9:16": "480x854", "21:9": "992x432"}, "720p": {"16:9": "1280x720", "4:3": "1112x834", "1:1": "960x960", "3:4": "834x1112", "9:16": "720x1280", "21:9": "1470x630"}}},
|
||||
"metadata": {"comment": "Ark bills per output token, not per second: tokens = width * height * 24 * seconds / 1024, so a per-second rate per resolution tier is exact for the 16:9 geometry Ark publishes (1280x720 is 21600 tokens/s). List 70 CNY per million tokens at 480p and 720p and 77 at 1080p without a video input, matching Ark's per-resolution table and its 1080p 16:9 example of 3.74 CNY/s, converted at 7.0 CNY/USD like the other volcengine entries. Accepts up to 50 reference assets (30 images, 10 videos, 10 audios) and clips of 4-30s; per-asset billing beyond the free allowance is not encoded. Requests with a video input bill at a lower rate per token and are rejected, since their cost is not known at create time. provider_specific_entry.volcengine_video_frames is the frame Ark renders per resolution tier and ratio, verified with ffprobe at 480p for every ratio and at 720p for 16:9 and 4:3; the other 720p ratios are from the Seedance 2.0 model card."}
|
||||
},
|
||||
"volcengine/doubao-seedance-2-0-260128": {
|
||||
"litellm_provider": "volcengine",
|
||||
"mode": "video_generation",
|
||||
"source": "https://www.volcengine.com/docs/82379/1544106",
|
||||
"output_cost_per_second": 0.141942857,
|
||||
"output_cost_per_second_480p": 0.066003429,
|
||||
"output_cost_per_second_720p": 0.141942857,
|
||||
"output_cost_per_second_1080p": 0.354085714,
|
||||
"output_cost_per_second_4k": 0.722057143,
|
||||
"supported_modalities": ["text", "image", "video", "audio"],
|
||||
"supported_output_modalities": ["video"],
|
||||
"supported_endpoints": ["/v1/videos"],
|
||||
"provider_specific_entry": {"volcengine_video_frames": {"480p": {"16:9": "864x496", "4:3": "752x560", "1:1": "640x640", "3:4": "560x752", "9:16": "496x864", "21:9": "992x432"}, "720p": {"16:9": "1280x720", "4:3": "1112x834", "1:1": "960x960", "3:4": "834x1112", "9:16": "720x1280", "21:9": "1470x630"}}},
|
||||
"metadata": {"comment": "Ark bills per output token, not per second: tokens = width * height * 24 * seconds / 1024, so a per-second rate per resolution tier is exact for the 16:9 geometry Ark publishes (1280x720 is 21600 tokens/s; the 480p frame is 864x496, 10044 tokens/s, not the tier's nominal 854x480). List 46 CNY per million tokens at 480p and 720p, 51 at 1080p, and 26 at 4k without a video input, matching Ark's per-resolution table and its 1080p and 4k 16:9 examples of 2.48 and 5.05 CNY/s, converted at 7.0 CNY/USD like the other volcengine entries. Clips of 4-15s; 4k is exclusive to this tier. Requests with a video input bill at a lower rate per token and are rejected, since their cost is not known at create time. provider_specific_entry.volcengine_video_frames is the frame Ark renders per resolution tier and ratio, verified with ffprobe at 480p for every ratio and at 720p for 16:9 and 4:3; the other 720p ratios are from the Seedance 2.0 model card."}
|
||||
},
|
||||
"volcengine/doubao-seedance-2-0-fast-260128": {
|
||||
"litellm_provider": "volcengine",
|
||||
"mode": "video_generation",
|
||||
"source": "https://www.volcengine.com/docs/82379/1544106",
|
||||
"output_cost_per_second": 0.114171429,
|
||||
"output_cost_per_second_480p": 0.053089714,
|
||||
"output_cost_per_second_720p": 0.114171429,
|
||||
"supported_modalities": ["text", "image", "video", "audio"],
|
||||
"supported_output_modalities": ["video"],
|
||||
"supported_endpoints": ["/v1/videos"],
|
||||
"provider_specific_entry": {"volcengine_video_frames": {"480p": {"16:9": "864x496", "4:3": "752x560", "1:1": "640x640", "3:4": "560x752", "9:16": "496x864", "21:9": "992x432"}, "720p": {"16:9": "1280x720", "4:3": "1112x834", "1:1": "960x960", "3:4": "834x1112", "9:16": "720x1280", "21:9": "1470x630"}}},
|
||||
"metadata": {"comment": "Ark bills per output token, not per second: tokens = width * height * 24 * seconds / 1024, so a per-second rate per resolution tier is exact for the 16:9 geometry Ark publishes (1280x720 is 21600 tokens/s; the 480p frame is 864x496, 10044 tokens/s, not the tier's nominal 854x480). List 37 CNY per million tokens without a video input, converted at 7.0 CNY/USD like the other volcengine entries. Clips of 4-15s at 480p or 720p. Requests with a video input bill at a lower rate per token and are rejected, since their cost is not known at create time. provider_specific_entry.volcengine_video_frames is the frame Ark renders per resolution tier and ratio, verified with ffprobe at 480p for every ratio and at 720p for 16:9 and 4:3; the other 720p ratios are from the Seedance 2.0 model card."}
|
||||
},
|
||||
"volcengine/doubao-seedance-2-0-mini-260615": {
|
||||
"litellm_provider": "volcengine",
|
||||
"mode": "video_generation",
|
||||
"source": "https://www.volcengine.com/docs/82379/1544106",
|
||||
"output_cost_per_second": 0.070971429,
|
||||
"output_cost_per_second_480p": 0.033001714,
|
||||
"output_cost_per_second_720p": 0.070971429,
|
||||
"supported_modalities": ["text", "image"],
|
||||
"supported_output_modalities": ["video"],
|
||||
"supported_endpoints": ["/v1/videos"],
|
||||
"provider_specific_entry": {"volcengine_video_frames": {"480p": {"16:9": "864x496", "4:3": "752x560", "1:1": "640x640", "3:4": "560x752", "9:16": "496x864", "21:9": "992x432"}, "720p": {"16:9": "1280x720", "4:3": "1112x834", "1:1": "960x960", "3:4": "834x1112", "9:16": "720x1280", "21:9": "1470x630"}}},
|
||||
"metadata": {"comment": "Ark bills per output token, not per second: tokens = width * height * 24 * seconds / 1024, so a per-second rate per resolution tier is exact for the 16:9 geometry Ark publishes (1280x720 is 21600 tokens/s; the 480p frame is 864x496, 10044 tokens/s, not the tier's nominal 854x480). List 23 CNY per million tokens without a video input, converted at 7.0 CNY/USD like the other volcengine entries. Clips of 4-15s at 480p or 720p. Only text and image inputs are declared; the mini tier's reference video and audio caps were not verified. Requests with a video input bill at a lower rate per token and are rejected, since their cost is not known at create time. provider_specific_entry.volcengine_video_frames is the frame Ark renders per resolution tier and ratio, verified with ffprobe at 480p for every ratio and at 720p for 16:9 and 4:3; the other 720p ratios are from the Seedance 2.0 model card."}
|
||||
},
|
||||
"bedrock/us-east-1/zai.glm-5": {
|
||||
"input_cost_per_token": 1e-06,
|
||||
"output_cost_per_token": 3.2e-06,
|
||||
|
|
@ -72334,7 +72389,7 @@
|
|||
"max_output_tokens": 64000,
|
||||
"max_tokens": 64000,
|
||||
"mode": "chat",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
|
|
|
|||
|
|
@ -2766,7 +2766,8 @@
|
|||
"batches": false,
|
||||
"rerank": false,
|
||||
"a2a": true,
|
||||
"interactions": true
|
||||
"interactions": true,
|
||||
"video_generations": true
|
||||
}
|
||||
},
|
||||
"voyage": {
|
||||
|
|
|
|||
|
|
@ -114,6 +114,12 @@ ignored_function_names = [
|
|||
"_deployment_that_just_failed", # Tested through the same-boundary hop test in test_router_order_fallback.py
|
||||
"_collect_ptu_share_teams", # Cached behind team_ptu_ceiling, tested through the live model list test in test_router.py
|
||||
"_resolve_team_ptu_ceiling", # Cached behind team_ptu_ceiling, tested through the live model list test in test_router.py
|
||||
"_register_continuation_prefill_check", # Tested through the continuation tests in test_router.py
|
||||
"_set_mid_stream_fallback_continuation", # Tested through the update_settings continuation test in test_router.py
|
||||
"_is_flag_enabled", # Tested through the string-flag continuation tests in test_router.py
|
||||
"_params_sent_to_deployment", # Tested through the configured-constraint continuation tests in test_router.py
|
||||
"_names_deployment_that_cannot_continue", # Tested through the deployment-id continuation tests in test_router.py
|
||||
"_set_retry_policy", # Tested through update_settings in test_router_retry_policy_update.py
|
||||
"_resolve_pre_call_check_input_tokens", # Tested through the token-count failure pre-call check tests in test_router.py
|
||||
"_params_neither_dropped_nor_allowed", # Tested through the allowed-or-dropped param pre-call check tests in test_router.py
|
||||
"_deployment_rejects_params", # Tested through the unsupported-param pre-call check tests in test_router.py
|
||||
|
|
|
|||
783
tests/integration/spend/test_spend_log_caller_session.py
Normal file
783
tests/integration/spend/test_spend_log_caller_session.py
Normal file
|
|
@ -0,0 +1,783 @@
|
|||
import itertools
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
import uuid
|
||||
from urllib.parse import urlsplit
|
||||
from collections import deque
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import ExitStack
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
from queue import SimpleQueue
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
import anthropic
|
||||
import httpx
|
||||
import openai
|
||||
import psutil
|
||||
import pytest
|
||||
import yaml
|
||||
from integration._support.client import (
|
||||
JSON_OBJECT,
|
||||
Gateway,
|
||||
Scenario,
|
||||
eventually,
|
||||
gateway_from_environment,
|
||||
list_value,
|
||||
object_value,
|
||||
string_value,
|
||||
)
|
||||
from integration._support.budget import readiness_seconds
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.process import OwnedProxy, graceful_stop_seconds, owned_proxy_process
|
||||
from integration._support.wire import Reply, Request, Wire, wire_server
|
||||
from pydantic import JsonValue
|
||||
|
||||
pytestmark: Final = pytest.mark.timeout(2 * readiness_seconds(2) + 2 * graceful_stop_seconds() + 240)
|
||||
|
||||
MARKER: Final = re.compile(rb"callersess-[0-9a-f]{32}")
|
||||
CALLER_SESSION_KEY: Final = "litellm_caller_session_id"
|
||||
PATHS: Final = MappingProxyType(
|
||||
{"chat": "/v1/chat/completions", "messages": "/v1/messages", "responses": "/v1/responses"}
|
||||
)
|
||||
ENDPOINT_STREAMS: Final = tuple(itertools.product(PATHS, (False, True)))
|
||||
Headers = Mapping[str, str] | Sequence[tuple[str, str]]
|
||||
|
||||
|
||||
def _marker() -> str:
|
||||
return "callersess-" + uuid.uuid4().hex
|
||||
|
||||
|
||||
def _token(label: str) -> str:
|
||||
return f"{label}-{uuid.uuid4().hex}"
|
||||
|
||||
|
||||
def _frame(payload: Mapping[str, JsonValue]) -> bytes:
|
||||
return b"data: " + json.dumps(payload).encode() + b"\n\n"
|
||||
|
||||
|
||||
def _chat_reply(text: str, stream: bool) -> Reply:
|
||||
identity: Final = "chatcmpl-" + uuid.uuid4().hex
|
||||
usage: Final[dict[str, JsonValue]] = {"prompt_tokens": 7, "completion_tokens": 2, "total_tokens": 9}
|
||||
if not stream:
|
||||
return Reply(
|
||||
body=json.dumps(
|
||||
{
|
||||
"id": identity,
|
||||
"object": "chat.completion",
|
||||
"created": 1,
|
||||
"model": "gpt-4o-mini",
|
||||
"choices": [
|
||||
{"index": 0, "message": {"role": "assistant", "content": text}, "finish_reason": "stop"}
|
||||
],
|
||||
"usage": usage,
|
||||
}
|
||||
).encode()
|
||||
)
|
||||
chunk: Final[dict[str, JsonValue]] = {
|
||||
"id": identity,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 1,
|
||||
"model": "gpt-4o-mini",
|
||||
}
|
||||
return Reply(
|
||||
content_type="text/event-stream",
|
||||
chunks=(
|
||||
_frame({**chunk, "choices": [{"index": 0, "delta": {"role": "assistant", "content": text}}]}),
|
||||
_frame({**chunk, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], "usage": usage}),
|
||||
b"data: [DONE]\n\n",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _upstream(request: Request) -> Reply:
|
||||
markers: Final = tuple(found.group(0).decode() for found in MARKER.finditer(request.body))
|
||||
if request.method != "POST" or not request.target.endswith("/chat/completions") or not markers:
|
||||
return Reply(status=404, body=b'{"error":"unexpected request"}')
|
||||
return _chat_reply(f"answer {markers[-1]}", JSON_OBJECT.validate_json(request.body).get("stream") is True)
|
||||
|
||||
|
||||
def _user_markers(body: Mapping[str, JsonValue]) -> tuple[str, ...]:
|
||||
user_contents: Final = tuple(
|
||||
json.dumps(object_value(message)["content"])
|
||||
for message in list_value(body.get("messages", []))
|
||||
if object_value(message)["role"] == "user"
|
||||
)
|
||||
return tuple(found.group(0).decode() for content in user_contents if (found := MARKER.search(content.encode())))
|
||||
|
||||
|
||||
def _spans(batch: Request) -> Iterator[dict[str, JsonValue]]:
|
||||
for resource in list_value(JSON_OBJECT.validate_json(batch.body)["resourceSpans"]):
|
||||
for scope in list_value(object_value(resource)["scopeSpans"]):
|
||||
yield from (object_value(span) for span in list_value(object_value(scope)["spans"]))
|
||||
|
||||
|
||||
def _attributes(span: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
|
||||
return {
|
||||
string_value(object_value(attribute)["key"]): object_value(attribute)["value"]
|
||||
for attribute in list_value(span.get("attributes", []))
|
||||
}
|
||||
|
||||
|
||||
def _batch_attributes(batches: Sequence[Request]) -> Iterator[dict[str, JsonValue]]:
|
||||
return (_attributes(span) for span in itertools.chain.from_iterable(_spans(batch) for batch in batches))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Collector:
|
||||
wire: Wire
|
||||
accepted: deque[Request]
|
||||
guard: threading.Lock
|
||||
|
||||
def span_attributes(self, response_id: str) -> tuple[dict[str, JsonValue], ...]:
|
||||
with self.guard:
|
||||
batches: Final = tuple(self.accepted)
|
||||
attributes: Final = tuple(_batch_attributes(batches))
|
||||
return tuple(
|
||||
found
|
||||
for found in attributes
|
||||
if object_value(found.get("gen_ai.response.id", {})).get("stringValue") == response_id
|
||||
)
|
||||
|
||||
def conversation_ids(self, conversations: frozenset[str]) -> tuple[str, ...]:
|
||||
with self.guard:
|
||||
batches: Final = tuple(self.accepted)
|
||||
responded: Final = (
|
||||
object_value(found.get("gen_ai.conversation.id", {})).get("stringValue")
|
||||
for found in _batch_attributes(batches)
|
||||
if "gen_ai.response.id" in found
|
||||
)
|
||||
return tuple(
|
||||
conversation
|
||||
for conversation in responded
|
||||
if isinstance(conversation, str) and conversation in conversations
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Rig:
|
||||
proxy: Gateway
|
||||
scenario: Scenario
|
||||
model: str
|
||||
upstream: Wire
|
||||
process: OwnedProxy
|
||||
|
||||
def body(self, endpoint: str, marker: str, extra: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
|
||||
match endpoint:
|
||||
case "chat":
|
||||
return {"model": self.model, "messages": [{"role": "user", "content": marker}], **extra}
|
||||
case "messages":
|
||||
return {
|
||||
"model": self.model,
|
||||
"max_tokens": 16,
|
||||
"messages": [{"role": "user", "content": marker}],
|
||||
**extra,
|
||||
}
|
||||
case _:
|
||||
return {"model": self.model, "input": marker, **extra}
|
||||
|
||||
def post(
|
||||
self,
|
||||
endpoint: str,
|
||||
marker: str,
|
||||
fields: Mapping[str, JsonValue] = MappingProxyType({}),
|
||||
*,
|
||||
stream: bool = False,
|
||||
headers: Headers = (),
|
||||
key: str | None = None,
|
||||
) -> httpx.Response:
|
||||
payload: Final = self.body(endpoint, marker, fields) | ({"stream": True} if stream else {})
|
||||
request_headers: Final = (
|
||||
("Authorization", f"Bearer {self.proxy.key if key is None else key}"),
|
||||
*(headers.items() if isinstance(headers, Mapping) else headers),
|
||||
)
|
||||
with self.proxy.client.stream("POST", PATHS[endpoint], json=payload, headers=request_headers) as response:
|
||||
response.read()
|
||||
return response
|
||||
|
||||
def send(
|
||||
self,
|
||||
endpoint: str,
|
||||
marker: str,
|
||||
fields: Mapping[str, JsonValue] = MappingProxyType({}),
|
||||
*,
|
||||
stream: bool = False,
|
||||
headers: Headers = (),
|
||||
key: str | None = None,
|
||||
) -> httpx.Response:
|
||||
response: Final = self.post(endpoint, marker, fields, stream=stream, headers=headers, key=key)
|
||||
assert response.status_code == 200, response.text
|
||||
return response
|
||||
|
||||
def upstream_bodies(self, marker: str) -> tuple[dict[str, JsonValue], ...]:
|
||||
bodies: Final = tuple(JSON_OBJECT.validate_json(request.body) for request in self.upstream.drain())
|
||||
return tuple(body for body in bodies if _user_markers(body)[-1:] == (marker,))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Rigs:
|
||||
flag_off: Rig
|
||||
flag_on: Rig
|
||||
sink: Collector
|
||||
|
||||
|
||||
def _spend_rows(call_id: str) -> list[dict[str, JsonValue]]:
|
||||
return read_rows(
|
||||
'SELECT session_id, request_id, metadata::text AS metadata_text FROM "LiteLLM_SpendLogs" '
|
||||
"WHERE litellm_call_id=%s",
|
||||
(call_id,),
|
||||
)
|
||||
|
||||
|
||||
def _spend_row(response: httpx.Response) -> dict[str, JsonValue]:
|
||||
call_id: Final = response.headers["x-litellm-call-id"]
|
||||
rows: Final = eventually(lambda: _spend_rows(call_id), lambda values: len(values) >= 1, seconds=70)
|
||||
assert len(rows) == 1, rows
|
||||
return rows[0]
|
||||
|
||||
|
||||
def _session(response: httpx.Response) -> JsonValue:
|
||||
return _spend_row(response)["session_id"]
|
||||
|
||||
|
||||
def _model(scenario: Scenario, upstream: Wire) -> str:
|
||||
return scenario.model(
|
||||
model="hosted_vllm/gpt-4o-mini",
|
||||
api_base=f"{upstream.url}/v1",
|
||||
use_chat_completions_api=True,
|
||||
input_cost_per_token=0.001,
|
||||
output_cost_per_token=0.002,
|
||||
)
|
||||
|
||||
|
||||
def _rig_config(
|
||||
directory: Path, name: str, litellm_settings: Mapping[str, JsonValue], extra: Mapping[str, JsonValue]
|
||||
) -> Path:
|
||||
shared: Final = object_value(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()))
|
||||
config: Final = {
|
||||
**shared,
|
||||
"general_settings": {**object_value(shared["general_settings"]), "store_prompts_in_spend_logs": True},
|
||||
"litellm_settings": {**object_value(shared["litellm_settings"]), **litellm_settings},
|
||||
**extra,
|
||||
}
|
||||
path: Final = directory / f"{name}.yaml"
|
||||
path.write_text(yaml.safe_dump(config))
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rigs(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rigs]:
|
||||
accepted: Final[deque[Request]] = deque() # mutable-ok: sink thread appends each accepted OTLP batch
|
||||
guard: Final = threading.Lock()
|
||||
|
||||
def sink(request: Request) -> Reply:
|
||||
with guard:
|
||||
accepted.append(request)
|
||||
return Reply()
|
||||
|
||||
directory: Final = tmp_path_factory.mktemp("caller-session")
|
||||
with ExitStack() as stack:
|
||||
upstream: Final = stack.enter_context(wire_server(_upstream))
|
||||
collector: Final = Collector(stack.enter_context(wire_server(sink)), accepted, guard)
|
||||
shared: Final = stack.enter_context(gateway_from_environment())
|
||||
flag_off: Final = stack.enter_context(
|
||||
owned_proxy_process(shared, directory, {}, config=_rig_config(directory, "flag-off", {}, {}), workers=2)
|
||||
)
|
||||
flag_on: Final = stack.enter_context(
|
||||
owned_proxy_process(
|
||||
shared,
|
||||
directory,
|
||||
{"LITELLM_OTEL_V2": "1", "OTEL_BSP_SCHEDULE_DELAY": "300"},
|
||||
config=_rig_config(
|
||||
directory,
|
||||
"flag-on",
|
||||
{"request_correlation_in_logs": True, "callbacks": ["otel"]},
|
||||
{
|
||||
"callback_settings": {
|
||||
"otel": {"exporter": "http/json", "endpoint": collector.wire.url, "mapper_names": ["genai"]}
|
||||
}
|
||||
},
|
||||
),
|
||||
workers=2,
|
||||
)
|
||||
)
|
||||
off_scenario: Final = stack.enter_context(flag_off.gateway.scenario())
|
||||
on_scenario: Final = stack.enter_context(flag_on.gateway.scenario())
|
||||
yield Rigs(
|
||||
Rig(flag_off.gateway, off_scenario, _model(off_scenario, upstream), upstream, flag_off),
|
||||
Rig(flag_on.gateway, on_scenario, _model(on_scenario, upstream), upstream, flag_on),
|
||||
collector,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("endpoint", "stream"), ENDPOINT_STREAMS)
|
||||
def test_spend_log_session_is_the_body_session_beside_a_trace_header(rigs: Rigs, endpoint: str, stream: bool) -> None:
|
||||
marker: Final = _marker()
|
||||
session: Final = _token("convo")
|
||||
response: Final = rigs.flag_on.send(
|
||||
endpoint,
|
||||
marker,
|
||||
stream=stream,
|
||||
headers={"x-litellm-trace-id": _token("trace")},
|
||||
fields={"litellm_session_id": session},
|
||||
)
|
||||
|
||||
row: Final = _spend_row(response)
|
||||
upstream: Final = rigs.flag_on.upstream_bodies(marker)
|
||||
|
||||
assert row["session_id"] == session, row
|
||||
assert CALLER_SESSION_KEY not in string_value(row["metadata_text"]), row
|
||||
assert len(upstream) == 1, upstream
|
||||
assert CALLER_SESSION_KEY not in json.dumps(upstream[0]), upstream
|
||||
|
||||
|
||||
def test_sdk_clients_record_the_body_session_beside_a_trace_header(rigs: Rigs) -> None:
|
||||
rig: Final = rigs.flag_on
|
||||
base_url: Final = str(rig.proxy.client.base_url)
|
||||
traces: Final = tuple(_token("trace") for _ in range(3))
|
||||
sessions: Final = tuple(_token("convo") for _ in range(3))
|
||||
markers: Final = tuple(_marker() for _ in range(3))
|
||||
chat_client: Final = openai.OpenAI(base_url=base_url + "/v1", api_key=rig.proxy.key, max_retries=0)
|
||||
messages_client: Final = anthropic.Anthropic(base_url=base_url, api_key=rig.proxy.key, max_retries=0)
|
||||
|
||||
with chat_client.chat.completions.with_streaming_response.create(
|
||||
model=rig.model,
|
||||
messages=[{"role": "user", "content": markers[0]}],
|
||||
stream=True,
|
||||
extra_headers={"x-litellm-trace-id": traces[0]},
|
||||
extra_body={"litellm_session_id": sessions[0]},
|
||||
) as chat_stream:
|
||||
chat_call: Final = chat_stream.headers["x-litellm-call-id"]
|
||||
chat_text: Final = "".join(
|
||||
chunk.choices[0].delta.content or "" for chunk in chat_stream.parse() if chunk.choices
|
||||
)
|
||||
message: Final = messages_client.messages.with_raw_response.create(
|
||||
model=rig.model,
|
||||
max_tokens=16,
|
||||
messages=[{"role": "user", "content": markers[1]}],
|
||||
extra_headers={"x-litellm-trace-id": traces[1]},
|
||||
extra_body={"litellm_session_id": sessions[1]},
|
||||
)
|
||||
with chat_client.responses.with_streaming_response.create(
|
||||
model=rig.model,
|
||||
input=markers[2],
|
||||
stream=True,
|
||||
extra_headers={"x-litellm-trace-id": traces[2]},
|
||||
extra_body={"litellm_session_id": sessions[2]},
|
||||
) as responses_stream:
|
||||
responses_call: Final = responses_stream.headers["x-litellm-call-id"]
|
||||
completed: Final = tuple(event for event in responses_stream.parse() if event.type == "response.completed")
|
||||
|
||||
first_block: Final = message.parse().content[0]
|
||||
assert chat_text == f"answer {markers[0]}", chat_text
|
||||
assert isinstance(first_block, anthropic.types.TextBlock) and first_block.text == f"answer {markers[1]}", message
|
||||
assert len(completed) == 1, completed
|
||||
calls: Final = (chat_call, message.headers["x-litellm-call-id"], responses_call)
|
||||
landed: Final = tuple(
|
||||
eventually(lambda call=call: _spend_rows(call), lambda values: len(values) == 1, seconds=70)[0]["session_id"]
|
||||
for call in calls
|
||||
)
|
||||
assert landed == sessions, (landed, calls)
|
||||
|
||||
|
||||
def test_conversation_span_keeps_the_trace_header_and_no_internal_key(rigs: Rigs) -> None:
|
||||
trace: Final = _token("trace")
|
||||
response: Final = rigs.flag_on.send(
|
||||
"chat", _marker(), headers={"x-litellm-trace-id": trace}, fields={"litellm_session_id": _token("convo")}
|
||||
)
|
||||
response_id: Final = string_value(JSON_OBJECT.validate_json(response.content)["id"])
|
||||
|
||||
spans: Final = eventually(lambda: rigs.sink.span_attributes(response_id), lambda found: len(found) == 1, seconds=30)
|
||||
|
||||
assert spans[0]["gen_ai.conversation.id"] == {"stringValue": trace}, spans
|
||||
assert CALLER_SESSION_KEY not in json.dumps(spans[0]), spans
|
||||
|
||||
|
||||
def test_a_continued_chains_conversation_spans_keep_each_turns_trace_header(rigs: Rigs) -> None:
|
||||
traces: Final = (_token("trace"), _token("trace"))
|
||||
first: Final = _turn(rigs.flag_on, _marker(), headers={"x-litellm-trace-id": traces[0]})
|
||||
_turn(
|
||||
rigs.flag_on,
|
||||
_marker(),
|
||||
first,
|
||||
headers={"x-litellm-trace-id": traces[1]},
|
||||
fields={"litellm_session_id": _token("convo")},
|
||||
)
|
||||
|
||||
conversations: Final = eventually(
|
||||
lambda: rigs.sink.conversation_ids(frozenset(traces)), lambda found: len(found) == 2, seconds=30
|
||||
)
|
||||
|
||||
assert sorted(conversations) == sorted(traces), conversations
|
||||
|
||||
|
||||
@pytest.mark.parametrize("endpoint", list(PATHS))
|
||||
def test_flag_off_keeps_the_trace_id_beside_a_body_session(rigs: Rigs, endpoint: str) -> None:
|
||||
trace: Final = _token("trace")
|
||||
|
||||
response: Final = rigs.flag_off.send(
|
||||
endpoint, _marker(), headers={"x-litellm-trace-id": trace}, fields={"litellm_session_id": _token("convo")}
|
||||
)
|
||||
|
||||
assert _session(response) == trace
|
||||
|
||||
|
||||
@pytest.mark.parametrize("endpoint", list(PATHS))
|
||||
def test_response_cache_hit_records_the_body_session_beside_a_trace_header(rigs: Rigs, endpoint: str) -> None:
|
||||
marker: Final = _marker()
|
||||
sessions: Final = (_token("convo"), _token("convo"))
|
||||
responses: Final = tuple(
|
||||
rigs.flag_on.send(
|
||||
endpoint, marker, headers={"x-litellm-trace-id": _token("trace")}, fields={"litellm_session_id": session}
|
||||
)
|
||||
for session in sessions
|
||||
)
|
||||
|
||||
assert tuple(_session(response) for response in responses) == sessions
|
||||
assert len(rigs.flag_on.upstream_bodies(marker)) == 1
|
||||
|
||||
|
||||
def test_metadata_session_beside_a_new_trace_per_turn_groups_both_turns(rigs: Rigs) -> None:
|
||||
session: Final = _token("convo")
|
||||
responses: Final = tuple(
|
||||
rigs.flag_on.send(
|
||||
"chat",
|
||||
_marker(),
|
||||
headers={"x-litellm-trace-id": _token("trace")},
|
||||
fields={"metadata": {"session_id": session}},
|
||||
)
|
||||
for _ in range(2)
|
||||
)
|
||||
|
||||
assert tuple(_session(response) for response in responses) == (session, session)
|
||||
now: Final = datetime.now(timezone.utc)
|
||||
page: Final = rigs.flag_on.proxy.get(
|
||||
"/spend/logs/ui",
|
||||
params={
|
||||
"session_id": session,
|
||||
"start_date": (now - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"end_date": (now + timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
},
|
||||
)
|
||||
listed: Final = sorted(string_value(object_value(row)["request_id"]) for row in list_value(page["data"]))
|
||||
assert listed == sorted(
|
||||
string_value(JSON_OBJECT.validate_json(response.content)["id"]) for response in responses
|
||||
), page
|
||||
|
||||
|
||||
def _shape(shape: str, trace: str, session: str, header: str) -> tuple[Headers, dict[str, JsonValue], str]:
|
||||
traced: Final = {"x-litellm-trace-id": trace}
|
||||
match shape:
|
||||
case "session-header-wins":
|
||||
return {"x-litellm-session-id": header}, {"litellm_session_id": session}, header
|
||||
case "trace-header-only":
|
||||
return traced, {}, trace
|
||||
case "body-session-only":
|
||||
return {}, {"litellm_session_id": session}, session
|
||||
case "int-session":
|
||||
return traced, {"litellm_session_id": 7}, trace
|
||||
case "list-session":
|
||||
return traced, {"litellm_session_id": [session]}, trace
|
||||
case "empty-session":
|
||||
return traced, {"litellm_session_id": ""}, trace
|
||||
case "null-session":
|
||||
return traced, {"litellm_session_id": None}, trace
|
||||
case "nul-session":
|
||||
return traced, {"litellm_session_id": session + "\x00x"}, trace
|
||||
case "two-trace-headers":
|
||||
return (
|
||||
(("x-litellm-trace-id", trace), ("x-litellm-trace-id", header)),
|
||||
{"litellm_session_id": session},
|
||||
session,
|
||||
)
|
||||
case _:
|
||||
return traced, {"litellm_session_id": session + "s" * 5120}, session + "s" * 5120
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"shape",
|
||||
[
|
||||
"session-header-wins",
|
||||
"trace-header-only",
|
||||
"body-session-only",
|
||||
"int-session",
|
||||
"list-session",
|
||||
"empty-session",
|
||||
"null-session",
|
||||
"nul-session",
|
||||
"two-trace-headers",
|
||||
"5kb-session",
|
||||
],
|
||||
)
|
||||
def test_spend_log_session_for_each_session_shape(rigs: Rigs, shape: str) -> None:
|
||||
headers, fields, expected = _shape(shape, _token("trace"), _token("convo"), _token("header"))
|
||||
neighbor_trace: Final = _token("neighbor")
|
||||
|
||||
response: Final = rigs.flag_on.send("chat", _marker(), fields, headers=headers)
|
||||
neighbor: Final = rigs.flag_on.send("chat", _marker(), headers={"x-litellm-trace-id": neighbor_trace})
|
||||
|
||||
assert _session(response) == expected
|
||||
assert _session(neighbor) == neighbor_trace
|
||||
|
||||
|
||||
@pytest.mark.parametrize("endpoint", ["chat", "messages"])
|
||||
def test_client_sent_internal_session_key_is_ignored(rigs: Rigs, endpoint: str) -> None:
|
||||
forged: Final = _token("forged")
|
||||
trace: Final = _token("trace")
|
||||
forged_metadata: Final[dict[str, JsonValue]] = {
|
||||
"metadata" if endpoint == "chat" else "litellm_metadata": {CALLER_SESSION_KEY: forged}
|
||||
}
|
||||
|
||||
untraced: Final = rigs.flag_on.send(endpoint, _marker(), forged_metadata)
|
||||
traced: Final = rigs.flag_on.send(endpoint, _marker(), forged_metadata, headers={"x-litellm-trace-id": trace})
|
||||
|
||||
assert _session(untraced) not in (forged, None)
|
||||
assert _session(traced) == trace
|
||||
|
||||
|
||||
def test_identical_requests_each_log_once_with_the_body_session(rigs: Rigs) -> None:
|
||||
session: Final = _token("convo")
|
||||
trace: Final = _token("trace")
|
||||
marker: Final = _marker()
|
||||
|
||||
responses: Final = tuple(
|
||||
rigs.flag_on.send("chat", marker, headers={"x-litellm-trace-id": trace}, fields={"litellm_session_id": session})
|
||||
for _ in range(3)
|
||||
)
|
||||
|
||||
assert len({response.headers["x-litellm-call-id"] for response in responses}) == 3
|
||||
assert tuple(_session(response) for response in responses) == (session, session, session)
|
||||
|
||||
|
||||
def _pass_through_route(rig: Rig, target: str) -> str:
|
||||
path: Final = f"/caller-session-{uuid.uuid4().hex}"
|
||||
created: Final = rig.proxy.post("/config/pass_through_endpoint", {"path": path, "target": target, "auth": True})
|
||||
endpoint_id: Final = string_value(object_value(list_value(created["endpoints"])[0])["id"])
|
||||
rig.scenario.cleanups.callback(
|
||||
lambda: rig.proxy.request("DELETE", "/config/pass_through_endpoint", params={"endpoint_id": endpoint_id})
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["litellm_session_id", "metadata"])
|
||||
def test_pass_through_spend_log_session_is_the_body_session_beside_a_trace_header(rigs: Rigs, field: str) -> None:
|
||||
marker: Final = _marker()
|
||||
session: Final = _token("convo")
|
||||
rig: Final = rigs.flag_on
|
||||
path: Final = _pass_through_route(rig, f"{rig.upstream.url}/v1/chat/completions")
|
||||
key: Final = rig.scenario.key(allowed_passthrough_routes=[path])
|
||||
fields: Final[dict[str, JsonValue]] = (
|
||||
{"litellm_session_id": session} if field == "litellm_session_id" else {"metadata": {"session_id": session}}
|
||||
)
|
||||
|
||||
response: Final = rig.proxy.request(
|
||||
"POST",
|
||||
path,
|
||||
rig.body("chat", marker, fields),
|
||||
key=key,
|
||||
headers={"x-litellm-trace-id": _token("trace")},
|
||||
)
|
||||
|
||||
rows: Final = eventually(
|
||||
lambda: read_rows(
|
||||
'SELECT session_id FROM "LiteLLM_SpendLogs" WHERE api_key=%s', (sha256(key.encode()).hexdigest(),)
|
||||
),
|
||||
lambda found: len(found) >= 1,
|
||||
seconds=70,
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert f"answer {marker}" in response.text
|
||||
assert len(rig.upstream_bodies(marker)) == 1
|
||||
assert rows == [{"session_id": session}]
|
||||
|
||||
|
||||
def _turn(
|
||||
rig: Rig,
|
||||
marker: str,
|
||||
previous: str | None = None,
|
||||
*,
|
||||
key: str | None = None,
|
||||
headers: Headers = (),
|
||||
fields: Mapping[str, JsonValue] = MappingProxyType({}),
|
||||
) -> str:
|
||||
chained: Final[dict[str, JsonValue]] = {"previous_response_id": previous} if previous else {}
|
||||
response: Final = rig.send("responses", marker, {**chained, **fields}, headers=headers, key=key)
|
||||
_spend_row(response)
|
||||
return string_value(JSON_OBJECT.validate_json(response.content)["id"])
|
||||
|
||||
|
||||
def _turn_fields(shape: str, session: str) -> tuple[dict[str, JsonValue], dict[str, JsonValue]]:
|
||||
match shape:
|
||||
case "session-every-turn":
|
||||
return {"litellm_session_id": session}, {"litellm_session_id": session}
|
||||
case "session-from-turn-2":
|
||||
return {}, {"litellm_session_id": session}
|
||||
case _:
|
||||
return {"litellm_session_id": _token("convo")}, {"litellm_session_id": _token("convo")}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("correlated", "shape", "replays_turn_1"),
|
||||
[
|
||||
(True, "session-every-turn", True),
|
||||
(True, "session-from-turn-2", True),
|
||||
(True, "new-session-per-turn", True),
|
||||
(False, "session-from-turn-2", False),
|
||||
],
|
||||
)
|
||||
def test_bridged_chain_history(rigs: Rigs, correlated: bool, shape: str, replays_turn_1: bool) -> None:
|
||||
rig: Final = rigs.flag_on if correlated else rigs.flag_off
|
||||
first_fields, second_fields = _turn_fields(shape, _token("convo"))
|
||||
markers: Final = (_marker(), _marker(), _marker())
|
||||
first: Final = _turn(rig, markers[0], headers={"x-litellm-trace-id": _token("trace")}, fields=first_fields)
|
||||
second: Final = _turn(rig, markers[1], first, headers={"x-litellm-trace-id": _token("trace")}, fields=second_fields)
|
||||
|
||||
_turn(rig, markers[2], second)
|
||||
|
||||
replayed: Final = rig.upstream_bodies(markers[2])
|
||||
assert len(replayed) == 1, replayed
|
||||
assert _user_markers(replayed[0]) == (markers if replays_turn_1 else markers[1:])
|
||||
|
||||
|
||||
def test_bridged_chains_sharing_a_metadata_session_replay_each_others_turns(rigs: Rigs) -> None:
|
||||
shared: Final[dict[str, JsonValue]] = {"metadata": {"session_id": _token("convo")}}
|
||||
markers: Final = (_marker(), _marker(), _marker())
|
||||
first: Final = _turn(rigs.flag_on, markers[0], fields=shared)
|
||||
_turn(rigs.flag_on, markers[1], fields=shared)
|
||||
|
||||
_turn(rigs.flag_on, markers[2], first)
|
||||
|
||||
replayed: Final = rigs.flag_on.upstream_bodies(markers[2])
|
||||
assert len(replayed) == 1, replayed
|
||||
assert _user_markers(replayed[0]) == markers
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Owners:
|
||||
first: str
|
||||
second: str
|
||||
|
||||
|
||||
def _owners(scenario: Scenario, model: str, shape: str) -> Owners:
|
||||
user: Final = _token("user")
|
||||
match shape:
|
||||
case "other-user":
|
||||
return Owners(
|
||||
scenario.key(user_id=user, models=[model]), scenario.key(user_id=_token("user"), models=[model])
|
||||
)
|
||||
case "same-user":
|
||||
return Owners(scenario.key(user_id=user, models=[model]), scenario.key(user_id=user, models=[model]))
|
||||
case "same-team":
|
||||
team: Final = scenario.team()
|
||||
return Owners(scenario.key(team_id=team, models=[model]), scenario.key(team_id=team, models=[model]))
|
||||
case _:
|
||||
return Owners(scenario.key(models=[model]), scenario.key(models=[model]))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("shape", "replays_first_owner"),
|
||||
[("other-user", False), ("same-user", True), ("same-team", True), ("ownerless-keys", False)],
|
||||
)
|
||||
def test_bridged_history_follows_the_previous_responses_owner(
|
||||
rigs: Rigs, shape: str, replays_first_owner: bool
|
||||
) -> None:
|
||||
rig: Final = rigs.flag_off
|
||||
owners: Final = _owners(rig.scenario, rig.model, shape)
|
||||
session: Final = _token("convo")
|
||||
markers: Final = (_marker(), _marker(), _marker())
|
||||
_turn(rig, markers[0], key=owners.first, fields={"litellm_session_id": session})
|
||||
second: Final = _turn(rig, markers[1], key=owners.second, fields={"litellm_session_id": session})
|
||||
|
||||
_turn(rig, markers[2], second, key=owners.second)
|
||||
|
||||
replayed: Final = rig.upstream_bodies(markers[2])
|
||||
assert len(replayed) == 1, replayed
|
||||
assert _user_markers(replayed[0]) == (markers if replays_first_owner else markers[1:])
|
||||
|
||||
|
||||
BURST: Final = ENDPOINT_STREAMS * 4
|
||||
|
||||
|
||||
def test_concurrent_burst_across_endpoints_logs_each_session_once(rigs: Rigs) -> None:
|
||||
sessions: Final = tuple(_token("convo") for _ in BURST)
|
||||
|
||||
def one(index: int) -> httpx.Response:
|
||||
endpoint, stream = BURST[index]
|
||||
return rigs.flag_on.send(
|
||||
endpoint,
|
||||
_marker(),
|
||||
stream=stream,
|
||||
headers={"x-litellm-trace-id": _token("trace")},
|
||||
fields={"litellm_session_id": sessions[index]},
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=12) as pool:
|
||||
responses: Final = tuple(pool.map(one, range(len(BURST))))
|
||||
|
||||
assert tuple(_session(response) for response in responses) == sessions
|
||||
|
||||
|
||||
def _open_upstream_connections(pid: int, upstream: Wire) -> int:
|
||||
port: Final = urlsplit(upstream.url).port
|
||||
return sum(
|
||||
1
|
||||
for connection in psutil.Process(pid).net_connections(kind="tcp")
|
||||
if connection.status == psutil.CONN_ESTABLISHED and connection.raddr and connection.raddr.port == port
|
||||
)
|
||||
|
||||
|
||||
def _post_or_none(rig: Rig, session: str) -> httpx.Response | None:
|
||||
try:
|
||||
return rig.post(
|
||||
"chat", _marker(), headers={"x-litellm-trace-id": _token("trace")}, fields={"litellm_session_id": session}
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
|
||||
|
||||
def test_killing_one_worker_mid_burst_keeps_logging_the_body_session(rigs: Rigs) -> None:
|
||||
release: Final = threading.Event()
|
||||
arrived: Final[SimpleQueue[bytes]] = SimpleQueue()
|
||||
|
||||
def held(request: Request) -> Reply:
|
||||
arrived.put(request.body)
|
||||
assert release.wait(timeout=60), "the burst was never released"
|
||||
return _upstream(request)
|
||||
|
||||
process: Final = rigs.flag_on.process
|
||||
sessions: Final = tuple(_token("convo") for _ in range(20))
|
||||
after_session: Final = _token("convo")
|
||||
with wire_server(held) as wire:
|
||||
rig: Final = replace(rigs.flag_on, model=_model(rigs.flag_on.scenario, wire), upstream=wire)
|
||||
workers: Final = eventually(process.worker_pids, lambda found: len(found) == 2, seconds=30)
|
||||
with ThreadPoolExecutor(max_workers=len(sessions)) as pool:
|
||||
pending: Final = tuple(pool.submit(_post_or_none, rig, session) for session in sessions)
|
||||
eventually(arrived.qsize, lambda size: size == len(sessions), seconds=60)
|
||||
held_by: Final = MappingProxyType({pid: _open_upstream_connections(pid, wire) for pid in workers})
|
||||
victim, survivor = sorted(workers, key=held_by.__getitem__)
|
||||
with process.replacing_worker(victim) as kill:
|
||||
kill.freeze()
|
||||
kill()
|
||||
release.set()
|
||||
results: Final = tuple(future.result() for future in pending)
|
||||
after: Final = rig.send(
|
||||
"chat",
|
||||
_marker(),
|
||||
headers={"x-litellm-trace-id": _token("trace")},
|
||||
fields={"litellm_session_id": after_session},
|
||||
)
|
||||
|
||||
served: Final = tuple(
|
||||
(response, session)
|
||||
for response, session in zip(results, sessions, strict=True)
|
||||
if response is not None and response.status_code == 200
|
||||
)
|
||||
assert sum(held_by.values()) == len(sessions), held_by
|
||||
assert len(served) == held_by[survivor] >= len(sessions) // 2, (held_by, len(served))
|
||||
assert _session(after) == after_session
|
||||
assert tuple(_session(response) for response, _ in served) == tuple(session for _, session in served)
|
||||
|
|
@ -0,0 +1,749 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import hashlib
|
||||
import itertools
|
||||
import json
|
||||
import math
|
||||
import operator
|
||||
import threading
|
||||
import uuid
|
||||
from collections.abc import Callable, Generator, Iterable, Iterator, Mapping, Sequence
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import openai
|
||||
import pytest
|
||||
import yaml
|
||||
from integration._support.client import JSON_OBJECT, Gateway, eventually, gateway_from_environment, object_value
|
||||
from integration._support.database import read_rows, scratch_database
|
||||
from integration._support.process import graceful_stop_seconds, owned_proxy
|
||||
from integration._support.wire import Reply, Request, Wire, wire_server
|
||||
from openai.types.chat import ChatCompletionChunk
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
pytestmark: Final = pytest.mark.timeout(4 * graceful_stop_seconds() + 120)
|
||||
|
||||
SETTING: Final = "enable_mid_stream_fallback_continuation"
|
||||
PARTIAL: Final = ("Alpha beta", " gamma")
|
||||
JOINED_PARTIAL: Final = "".join(PARTIAL)
|
||||
RELAY_TEXT: Final = " delta"
|
||||
BACKUP_TEXT: Final = " omega."
|
||||
PLAIN_TEXT: Final = "plain answer"
|
||||
TOOL_PREAMBLE: Final = "Let me check"
|
||||
BACKUP_USAGE: Final = (40, 6)
|
||||
MAX_TOKENS: Final = 200
|
||||
PRIMARY_PRICES: Final = (0.001, 0.002)
|
||||
BACKUP_PRICES: Final = (0.003, 0.005)
|
||||
SHARED_BACKEND: Final = "openai/gpt-4o-mini"
|
||||
SEPARATE_BACKEND: Final = "openai/gpt-4o"
|
||||
WEATHER_TOOL: Final[Mapping[str, JsonValue]] = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
|
||||
},
|
||||
}
|
||||
TOOL_CALL_DELTA: Final[Mapping[str, JsonValue]] = {
|
||||
"index": 0,
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "arguments": ""},
|
||||
}
|
||||
NOTHING_SEEN: Final[tuple[ChatCompletionChunk, ...]] = ()
|
||||
ALLOW_EVERYTHING: Final = "def apply_guardrail(inputs, request_data, input_type):\n return allow()\n"
|
||||
EVENT_BATCH: Final = TypeAdapter(list[dict[str, JsonValue]])
|
||||
|
||||
|
||||
@functools.cache
|
||||
def cut_gate(marker: str, hop: str, attempt: int) -> threading.Event:
|
||||
return threading.Event()
|
||||
|
||||
|
||||
@functools.cache
|
||||
def attempts(marker: str, hop: str) -> Iterator[int]:
|
||||
return itertools.count()
|
||||
|
||||
|
||||
def frame(identity: str, delta: Mapping[str, JsonValue], finish: str | None = None) -> bytes:
|
||||
body: Final = {
|
||||
"id": identity,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 1700000000,
|
||||
"model": "gpt-4o-mini",
|
||||
"choices": [{"index": 0, "delta": dict(delta), "finish_reason": finish}],
|
||||
}
|
||||
return b"data: " + json.dumps(body).encode() + b"\n\n"
|
||||
|
||||
|
||||
def complete_stream(identity: str, text: str, usage: tuple[int, int]) -> Reply:
|
||||
usage_frame: Final[dict[str, JsonValue]] = {
|
||||
"id": identity,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 1700000000,
|
||||
"model": "gpt-4o-mini",
|
||||
"choices": [],
|
||||
"usage": {"prompt_tokens": usage[0], "completion_tokens": usage[1], "total_tokens": sum(usage)},
|
||||
}
|
||||
return Reply(
|
||||
content_type="text/event-stream",
|
||||
chunks=(
|
||||
frame(identity, {"role": "assistant", "content": text}),
|
||||
frame(identity, {}, finish="stop"),
|
||||
b"data: " + json.dumps(usage_frame).encode() + b"\n\n",
|
||||
b"data: [DONE]\n\n",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def cut_after_caller_sees(marker: str, hop: str, *frames: bytes) -> Reply:
|
||||
return Reply(
|
||||
content_type="text/event-stream",
|
||||
chunks=(b"".join(frames), b"data: never sent\n\n"),
|
||||
abort_after=1,
|
||||
gate_after_first=cut_gate(marker, hop, next(attempts(marker, hop))),
|
||||
)
|
||||
|
||||
|
||||
def first_user_text(request: Request) -> str:
|
||||
messages: Final = JSON_OBJECT.validate_json(request.body)["messages"]
|
||||
assert isinstance(messages, list), messages
|
||||
first: Final = object_value(messages[0])["content"]
|
||||
assert isinstance(first, str), first
|
||||
return first
|
||||
|
||||
|
||||
def marker_of(request: Request) -> str:
|
||||
return first_user_text(request).split()[-1]
|
||||
|
||||
|
||||
def is_chat(request: Request) -> bool:
|
||||
return request.target.endswith("/chat/completions")
|
||||
|
||||
|
||||
def model_list() -> Reply:
|
||||
return Reply(body=b'{"object": "list", "data": []}')
|
||||
|
||||
|
||||
def primary(request: Request) -> Reply:
|
||||
if not is_chat(request):
|
||||
return model_list()
|
||||
scenario: Final = first_user_text(request).split()[0]
|
||||
marker: Final = marker_of(request)
|
||||
identity: Final = f"chatcmpl-{marker}-primary"
|
||||
if scenario == "reject":
|
||||
return Reply(status=500, body=b'{"error": {"message": "primary is down", "type": "server_error"}}')
|
||||
if scenario == "tool-cut":
|
||||
return cut_after_caller_sees(
|
||||
marker,
|
||||
"primary",
|
||||
frame(identity, {"role": "assistant", "content": TOOL_PREAMBLE}),
|
||||
frame(identity, {"tool_calls": [dict(TOOL_CALL_DELTA)]}),
|
||||
)
|
||||
return cut_after_caller_sees(
|
||||
marker,
|
||||
"primary",
|
||||
frame(identity, {"role": "assistant", "content": PARTIAL[0]}),
|
||||
frame(identity, {"content": PARTIAL[1]}),
|
||||
)
|
||||
|
||||
|
||||
def relay(request: Request) -> Reply:
|
||||
if not is_chat(request):
|
||||
return model_list()
|
||||
marker: Final = marker_of(request)
|
||||
return cut_after_caller_sees(
|
||||
marker, "relay", frame(f"chatcmpl-{marker}-relay", {"role": "assistant", "content": RELAY_TEXT})
|
||||
)
|
||||
|
||||
|
||||
def backup(request: Request) -> Reply:
|
||||
if not is_chat(request):
|
||||
return model_list()
|
||||
return complete_stream(f"chatcmpl-{marker_of(request)}-backup", BACKUP_TEXT, BACKUP_USAGE)
|
||||
|
||||
|
||||
def no_prefill(request: Request) -> Reply:
|
||||
if not is_chat(request):
|
||||
return model_list()
|
||||
return complete_stream(f"chatcmpl-{marker_of(request)}-plain", PLAIN_TEXT, (9, 2))
|
||||
|
||||
|
||||
def accept_events(request: Request) -> Reply:
|
||||
return Reply()
|
||||
|
||||
|
||||
def registered_guardrail(name: str, mode: str) -> dict[str, JsonValue]:
|
||||
return {
|
||||
"guardrail_name": name,
|
||||
"litellm_params": {
|
||||
"guardrail": "custom_code",
|
||||
"mode": mode,
|
||||
"default_on": False,
|
||||
"custom_code": ALLOW_EVERYTHING,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Rig:
|
||||
gateway: Gateway
|
||||
database_url: str
|
||||
primary: Wire
|
||||
relay: Wire
|
||||
backup: Wire
|
||||
no_prefill: Wire
|
||||
events: Wire
|
||||
|
||||
|
||||
def joined_text(chunks: Iterable[ChatCompletionChunk]) -> str:
|
||||
return "".join(choice.delta.content or "" for chunk in chunks for choice in chunk.choices)
|
||||
|
||||
|
||||
def has_tool_call(chunks: Iterable[ChatCompletionChunk]) -> bool:
|
||||
return any(choice.delta.tool_calls for chunk in chunks for choice in chunk.choices)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Release:
|
||||
gate: threading.Event
|
||||
ready: Callable[[tuple[ChatCompletionChunk, ...]], bool]
|
||||
|
||||
|
||||
def after_text(marker: str, hop: str, text: str, attempt: int = 0) -> Release:
|
||||
return Release(cut_gate(marker, hop, attempt), lambda seen: joined_text(seen) == text)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Streamed:
|
||||
chunks: tuple[ChatCompletionChunk, ...]
|
||||
error: openai.APIError | None
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return joined_text(self.chunks)
|
||||
|
||||
@property
|
||||
def ids(self) -> frozenset[str]:
|
||||
return frozenset(chunk.id for chunk in self.chunks)
|
||||
|
||||
@property
|
||||
def role_deltas(self) -> int:
|
||||
return sum(1 for chunk in self.chunks for choice in chunk.choices if choice.delta.role is not None)
|
||||
|
||||
@property
|
||||
def finish_reasons(self) -> tuple[str, ...]:
|
||||
return tuple(choice.finish_reason for chunk in self.chunks for choice in chunk.choices if choice.finish_reason)
|
||||
|
||||
@property
|
||||
def usage(self) -> tuple[int, int]:
|
||||
reported: Final = tuple(chunk.usage for chunk in self.chunks if chunk.usage is not None)
|
||||
assert reported, self.chunks
|
||||
return (reported[-1].prompt_tokens, reported[-1].completion_tokens)
|
||||
|
||||
|
||||
def deployment(
|
||||
name: str,
|
||||
wire: Wire,
|
||||
prices: tuple[float, float],
|
||||
*,
|
||||
backend: str = SHARED_BACKEND,
|
||||
prefill: bool | None = None,
|
||||
) -> dict[str, JsonValue]:
|
||||
return {
|
||||
"model_name": name,
|
||||
"litellm_params": {
|
||||
"model": backend,
|
||||
"api_base": wire.url + "/v1",
|
||||
"api_key": "synthetic-continuation-key",
|
||||
"input_cost_per_token": prices[0],
|
||||
"output_cost_per_token": prices[1],
|
||||
},
|
||||
**({"model_info": {"supports_assistant_prefill": prefill}} if prefill is not None else {}),
|
||||
}
|
||||
|
||||
|
||||
def write_config(
|
||||
directory: Path,
|
||||
model_entries: Sequence[Mapping[str, JsonValue]],
|
||||
fallbacks: Sequence[Mapping[str, JsonValue]],
|
||||
router_settings: Mapping[str, JsonValue],
|
||||
guardrails: Sequence[Mapping[str, JsonValue]],
|
||||
) -> Path:
|
||||
base: Final = JSON_OBJECT.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()))
|
||||
config: Final = {
|
||||
**base,
|
||||
"model_list": [dict(entry) for entry in model_entries],
|
||||
"litellm_settings": {
|
||||
**object_value(base["litellm_settings"]),
|
||||
"callbacks": ["generic_api"],
|
||||
"DEFAULT_FLUSH_INTERVAL_SECONDS": 1,
|
||||
},
|
||||
"guardrails": [dict(entry) for entry in guardrails],
|
||||
"router_settings": {
|
||||
**object_value(base["router_settings"]),
|
||||
"num_retries": 0,
|
||||
"fallbacks": [dict(entry) for entry in fallbacks],
|
||||
**router_settings,
|
||||
},
|
||||
}
|
||||
path: Final = directory / f"continuation-{uuid.uuid4().hex[:8]}.yaml"
|
||||
path.write_text(yaml.safe_dump(config))
|
||||
return path
|
||||
|
||||
|
||||
@contextmanager
|
||||
def booted_rig(
|
||||
directory: Path,
|
||||
fallbacks: Sequence[Mapping[str, JsonValue]],
|
||||
router_settings: Mapping[str, JsonValue],
|
||||
workers: int,
|
||||
guardrails: Sequence[Mapping[str, JsonValue]] = (),
|
||||
) -> Generator[Rig]:
|
||||
with ExitStack() as stack:
|
||||
shared: Final = stack.enter_context(gateway_from_environment())
|
||||
database_url: Final = stack.enter_context(scratch_database())
|
||||
wires: Final = tuple(
|
||||
stack.enter_context(wire_server(respond)) for respond in (primary, relay, backup, no_prefill, accept_events)
|
||||
)
|
||||
primary_wire, relay_wire, backup_wire, no_prefill_wire, events_wire = wires
|
||||
config: Final = write_config(
|
||||
directory,
|
||||
(
|
||||
*(
|
||||
deployment(name, primary_wire, PRIMARY_PRICES)
|
||||
for name in ("primary", "primary-skip", "primary-none", "primary-relay", "primary-shared")
|
||||
),
|
||||
deployment("relay", relay_wire, PRIMARY_PRICES, prefill=True),
|
||||
deployment("backup", backup_wire, BACKUP_PRICES, prefill=True),
|
||||
deployment("no-prefill", no_prefill_wire, PRIMARY_PRICES, backend=SEPARATE_BACKEND),
|
||||
deployment("no-prefill-shared", no_prefill_wire, PRIMARY_PRICES),
|
||||
),
|
||||
fallbacks,
|
||||
router_settings,
|
||||
guardrails,
|
||||
)
|
||||
gateway: Final = stack.enter_context(
|
||||
owned_proxy(
|
||||
shared,
|
||||
directory,
|
||||
{"DATABASE_URL": database_url, "GENERIC_LOGGER_ENDPOINT": events_wire.url},
|
||||
config=config,
|
||||
remove_environment=("DATABASE_URL_READ_REPLICA",),
|
||||
workers=workers,
|
||||
)
|
||||
)
|
||||
yield Rig(gateway, database_url, primary_wire, relay_wire, backup_wire, no_prefill_wire, events_wire)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def configured(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]:
|
||||
fallbacks: Final[tuple[Mapping[str, JsonValue], ...]] = (
|
||||
{"primary": ["backup"]},
|
||||
{"primary-skip": ["no-prefill", "backup"]},
|
||||
{"primary-none": ["no-prefill"]},
|
||||
{"primary-relay": ["relay"]},
|
||||
{"relay": ["backup"]},
|
||||
{"primary-shared": ["no-prefill-shared"]},
|
||||
)
|
||||
with booted_rig(
|
||||
tmp_path_factory.mktemp("continuation-configured"),
|
||||
fallbacks,
|
||||
{SETTING: True},
|
||||
2,
|
||||
(registered_guardrail("input-check", "pre_call"),),
|
||||
) as rig:
|
||||
yield rig
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def output_guarded(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]:
|
||||
with booted_rig(
|
||||
tmp_path_factory.mktemp("continuation-output-guarded"),
|
||||
({"primary": ["backup"]},),
|
||||
{SETTING: True},
|
||||
2,
|
||||
(registered_guardrail("output-check", "post_call"),),
|
||||
) as rig:
|
||||
yield rig
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def unset(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]:
|
||||
with booted_rig(
|
||||
tmp_path_factory.mktemp("continuation-unset"), ({"primary": ["no-prefill", "backup"]},), {}, 1
|
||||
) as rig:
|
||||
yield rig
|
||||
|
||||
|
||||
def new_key(rig: Rig) -> str:
|
||||
key: Final = rig.gateway.post("/key/generate", {})["key"]
|
||||
assert isinstance(key, str), key
|
||||
return key
|
||||
|
||||
|
||||
def appended(seen: tuple[ChatCompletionChunk, ...], chunk: ChatCompletionChunk) -> tuple[ChatCompletionChunk, ...]:
|
||||
return (*seen, chunk)
|
||||
|
||||
|
||||
def observed(
|
||||
response: Iterable[ChatCompletionChunk], releases: Sequence[Release]
|
||||
) -> Iterator[ChatCompletionChunk | openai.APIError]:
|
||||
try:
|
||||
for seen in itertools.accumulate(response, appended, initial=NOTHING_SEEN):
|
||||
for release in releases:
|
||||
if release.ready(seen):
|
||||
release.gate.set()
|
||||
yield from seen[-1:]
|
||||
except openai.APIError as error:
|
||||
yield error
|
||||
|
||||
|
||||
def stream(
|
||||
rig: Rig,
|
||||
key: str,
|
||||
model: str,
|
||||
prompt: str,
|
||||
releases: Sequence[Release] = (),
|
||||
extra: Mapping[str, JsonValue] | None = None,
|
||||
) -> Streamed:
|
||||
base_url: Final = f"{rig.gateway.client.base_url}".rstrip("/") + "/v1"
|
||||
try:
|
||||
with openai.OpenAI(base_url=base_url, api_key=key, max_retries=0, timeout=60) as client:
|
||||
response: Final = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
stream=True,
|
||||
stream_options={"include_usage": True},
|
||||
max_tokens=MAX_TOKENS,
|
||||
extra_body=dict(extra or {}),
|
||||
)
|
||||
outcome: Final = tuple(observed(response, releases))
|
||||
finally:
|
||||
for release in releases:
|
||||
release.gate.set()
|
||||
errors: Final = tuple(item for item in outcome if isinstance(item, openai.APIError))
|
||||
return Streamed(
|
||||
tuple(item for item in outcome if isinstance(item, ChatCompletionChunk)), errors[0] if errors else None
|
||||
)
|
||||
|
||||
|
||||
def chat_bodies(wire: Wire, marker: str) -> tuple[dict[str, JsonValue], ...]:
|
||||
return tuple(
|
||||
JSON_OBJECT.validate_json(request.body)
|
||||
for request in wire.drain()
|
||||
if is_chat(request) and marker_of(request) == marker
|
||||
)
|
||||
|
||||
|
||||
def spend_rows(rig: Rig, key: str) -> list[dict[str, JsonValue]]:
|
||||
return eventually(
|
||||
lambda: read_rows(
|
||||
'SELECT request_id, status, spend, prompt_tokens, completion_tokens, metadata FROM "LiteLLM_SpendLogs" '
|
||||
"WHERE api_key=%s",
|
||||
(hashlib.sha256(key.encode()).hexdigest(),),
|
||||
database_url=rig.database_url,
|
||||
),
|
||||
lambda rows: len(rows) >= 1,
|
||||
seconds=70,
|
||||
)
|
||||
|
||||
|
||||
def amount(value: JsonValue) -> float:
|
||||
assert isinstance(value, (int, float)), value
|
||||
return float(value)
|
||||
|
||||
|
||||
def key_spend(rig: Rig, key: str) -> float:
|
||||
return amount(object_value(rig.gateway.get("/key/info", {"key": key})["info"])["spend"])
|
||||
|
||||
|
||||
def close(observed_value: float, expected: float) -> bool:
|
||||
return math.isclose(observed_value, expected, rel_tol=1e-9, abs_tol=1e-12)
|
||||
|
||||
|
||||
def cost(tokens: tuple[int, int], prices: tuple[float, float]) -> float:
|
||||
return tokens[0] * prices[0] + tokens[1] * prices[1]
|
||||
|
||||
|
||||
def prefill_turn(text: str) -> dict[str, JsonValue]:
|
||||
return {"role": "assistant", "content": text, "prefix": True}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Billed:
|
||||
rows: list[dict[str, JsonValue]]
|
||||
partial: tuple[int, int]
|
||||
|
||||
|
||||
def billed_once(rig: Rig, key: str, received: Streamed) -> Billed:
|
||||
rows: Final = spend_rows(rig, key)
|
||||
assert [row["status"] for row in rows] == ["success"], rows
|
||||
row_tokens: Final = (rows[0]["prompt_tokens"], rows[0]["completion_tokens"])
|
||||
assert isinstance(row_tokens[0], int) and isinstance(row_tokens[1], int), rows
|
||||
assert received.usage == row_tokens, (received.usage, row_tokens)
|
||||
partial: Final = (row_tokens[0] - BACKUP_USAGE[0], row_tokens[1] - BACKUP_USAGE[1])
|
||||
assert partial[0] > 0 and partial[1] > 0, (row_tokens, BACKUP_USAGE)
|
||||
return Billed(rows, partial)
|
||||
|
||||
|
||||
def assert_spend_prices_every_token(rig: Rig, key: str, billed: Billed) -> None:
|
||||
partial_cost: Final = cost(billed.partial, PRIMARY_PRICES)
|
||||
breakdown: Final = object_value(object_value(billed.rows[0]["metadata"])["cost_breakdown"])
|
||||
spend: Final = amount(billed.rows[0]["spend"])
|
||||
reported_partial_cost: Final = amount(object_value(breakdown["additional_costs"])["mid_stream_partial_cost"])
|
||||
assert close(spend, cost(BACKUP_USAGE, BACKUP_PRICES) + partial_cost) and close(
|
||||
reported_partial_cost, partial_cost
|
||||
), f"partial={billed.partial} spend={spend} breakdown={json.dumps(breakdown)}"
|
||||
eventually(lambda: key_spend(rig, key), lambda value: close(value, spend), seconds=70)
|
||||
assert spend_rows(rig, key) == billed.rows
|
||||
|
||||
|
||||
def test_text_cut_continues_on_the_prefill_fallback_and_bills_both_calls_once(configured: Rig) -> None:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
key: Final = new_key(configured)
|
||||
prompt: Final = f"text-cut {marker}"
|
||||
received: Final = stream(configured, key, "primary", prompt, (after_text(marker, "primary", JOINED_PARTIAL),))
|
||||
assert received.error is None, received.error
|
||||
assert received.text == JOINED_PARTIAL + BACKUP_TEXT, received.chunks
|
||||
assert received.ids == {f"chatcmpl-{marker}-primary"}, received.ids
|
||||
assert received.role_deltas == 1, received.chunks
|
||||
assert received.finish_reasons == ("stop",), received.chunks
|
||||
assert len(chat_bodies(configured.primary, marker)) == 1
|
||||
continued: Final = chat_bodies(configured.backup, marker)
|
||||
assert len(continued) == 1, continued
|
||||
assert continued[0]["messages"] == [{"role": "user", "content": prompt}, prefill_turn(JOINED_PARTIAL)]
|
||||
assert "_mid_stream_continuation" not in continued[0], continued[0]
|
||||
billed: Final = billed_once(configured, key, received)
|
||||
assert continued[0]["max_tokens"] == MAX_TOKENS - billed.partial[1], (continued[0], billed.partial)
|
||||
assert_spend_prices_every_token(configured, key, billed)
|
||||
|
||||
|
||||
def test_spend_row_of_a_continued_stream_carries_the_id_the_caller_received(configured: Rig) -> None:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
key: Final = new_key(configured)
|
||||
received: Final = stream(
|
||||
configured, key, "primary", f"text-cut {marker}", (after_text(marker, "primary", JOINED_PARTIAL),)
|
||||
)
|
||||
assert received.error is None, received.error
|
||||
assert [row["request_id"] for row in spend_rows(configured, key)] == [received.chunks[0].id]
|
||||
|
||||
|
||||
def test_tool_call_before_the_cut_keeps_the_original_error(configured: Rig) -> None:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
received: Final = stream(
|
||||
configured,
|
||||
new_key(configured),
|
||||
"primary",
|
||||
f"tool-cut {marker}",
|
||||
(Release(cut_gate(marker, "primary", 0), has_tool_call),),
|
||||
{"tools": [dict(WEATHER_TOOL)]},
|
||||
)
|
||||
assert received.error is not None, received.chunks
|
||||
assert (received.text, has_tool_call(received.chunks)) == (TOOL_PREAMBLE, True), received.chunks
|
||||
assert len(chat_bodies(configured.primary, marker)) == 1
|
||||
assert chat_bodies(configured.backup, marker) == ()
|
||||
|
||||
|
||||
def test_continuation_skips_a_fallback_without_prefill_support(configured: Rig) -> None:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
prompt: Final = f"text-cut {marker}"
|
||||
received: Final = stream(
|
||||
configured, new_key(configured), "primary-skip", prompt, (after_text(marker, "primary", JOINED_PARTIAL),)
|
||||
)
|
||||
assert received.error is None, received.error
|
||||
assert received.text == JOINED_PARTIAL + BACKUP_TEXT, received.chunks
|
||||
assert chat_bodies(configured.no_prefill, marker) == ()
|
||||
assert [body["messages"] for body in chat_bodies(configured.backup, marker)] == [
|
||||
[{"role": "user", "content": prompt}, prefill_turn(JOINED_PARTIAL)]
|
||||
]
|
||||
|
||||
|
||||
def test_no_prefill_capable_fallback_keeps_the_original_error(configured: Rig) -> None:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
received: Final = stream(
|
||||
configured,
|
||||
new_key(configured),
|
||||
"primary-none",
|
||||
f"text-cut {marker}",
|
||||
(after_text(marker, "primary", JOINED_PARTIAL),),
|
||||
)
|
||||
assert received.error is not None, received.chunks
|
||||
assert "No deployments available" not in str(received.error), received.error
|
||||
assert received.text == JOINED_PARTIAL, received.chunks
|
||||
assert len(chat_bodies(configured.primary, marker)) == 1
|
||||
assert chat_bodies(configured.no_prefill, marker) == ()
|
||||
|
||||
|
||||
def test_undeclared_fallback_sharing_a_backend_with_a_prefill_deployment_keeps_the_original_error(
|
||||
configured: Rig,
|
||||
) -> None:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
received: Final = stream(
|
||||
configured,
|
||||
new_key(configured),
|
||||
"primary-shared",
|
||||
f"text-cut {marker}",
|
||||
(after_text(marker, "primary", JOINED_PARTIAL),),
|
||||
)
|
||||
assert (received.error is not None, received.text) == (True, JOINED_PARTIAL), received.chunks
|
||||
assert chat_bodies(configured.no_prefill, marker) == ()
|
||||
|
||||
|
||||
def test_failure_before_content_still_falls_back_to_a_fallback_without_prefill_support(configured: Rig) -> None:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
prompt: Final = f"reject {marker}"
|
||||
received: Final = stream(configured, new_key(configured), "primary-none", prompt)
|
||||
assert received.error is None, received.error
|
||||
assert received.text == PLAIN_TEXT, received.chunks
|
||||
assert [body["messages"] for body in chat_bodies(configured.no_prefill, marker)] == [
|
||||
[{"role": "user", "content": prompt}]
|
||||
]
|
||||
|
||||
|
||||
def stream_with_two_cuts(rig: Rig, key: str, marker: str) -> Streamed:
|
||||
return stream(
|
||||
rig,
|
||||
key,
|
||||
"primary-relay",
|
||||
f"text-cut {marker}",
|
||||
(after_text(marker, "primary", JOINED_PARTIAL), after_text(marker, "relay", JOINED_PARTIAL + RELAY_TEXT)),
|
||||
)
|
||||
|
||||
|
||||
def test_second_cut_continues_with_both_partials_in_one_prefill(configured: Rig) -> None:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
key: Final = new_key(configured)
|
||||
received: Final = stream_with_two_cuts(configured, key, marker)
|
||||
assert received.error is None, received.error
|
||||
assert received.text == JOINED_PARTIAL + RELAY_TEXT + BACKUP_TEXT, received.chunks
|
||||
assert received.role_deltas == 1, received.chunks
|
||||
user_turn: Final = {"role": "user", "content": f"text-cut {marker}"}
|
||||
relayed: Final = chat_bodies(configured.relay, marker)
|
||||
continued: Final = chat_bodies(configured.backup, marker)
|
||||
assert [body["messages"] for body in relayed] == [[user_turn, prefill_turn(JOINED_PARTIAL)]]
|
||||
assert [body["messages"] for body in continued] == [[user_turn, prefill_turn(JOINED_PARTIAL + RELAY_TEXT)]]
|
||||
partial: Final = billed_once(configured, key, received).partial
|
||||
relay_ceiling: Final = relayed[0]["max_tokens"]
|
||||
assert isinstance(relay_ceiling, int), relayed[0]
|
||||
assert 0 < MAX_TOKENS - relay_ceiling < partial[1], (relayed[0], partial)
|
||||
assert continued[0]["max_tokens"] == MAX_TOKENS - partial[1], (continued[0], partial)
|
||||
|
||||
|
||||
def test_second_cut_bills_every_call_on_one_row(configured: Rig) -> None:
|
||||
key: Final = new_key(configured)
|
||||
received: Final = stream_with_two_cuts(configured, key, uuid.uuid4().hex)
|
||||
assert received.error is None, received.error
|
||||
assert_spend_prices_every_token(configured, key, billed_once(configured, key, received))
|
||||
|
||||
|
||||
def test_repeating_a_continued_request_never_returns_only_the_continuation(configured: Rig) -> None:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
key: Final = new_key(configured)
|
||||
prompt: Final = f"text-cut {marker}"
|
||||
answers: Final = tuple(
|
||||
stream(configured, key, "primary", prompt, (after_text(marker, "primary", JOINED_PARTIAL, attempt),))
|
||||
for attempt in range(2)
|
||||
)
|
||||
assert tuple((answer.error, answer.text) for answer in answers) == ((None, JOINED_PARTIAL + BACKUP_TEXT),) * 2
|
||||
|
||||
|
||||
def test_without_the_setting_a_text_cut_keeps_the_original_error(unset: Rig) -> None:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
received: Final = stream(
|
||||
unset, new_key(unset), "primary", f"text-cut {marker}", (after_text(marker, "primary", JOINED_PARTIAL),)
|
||||
)
|
||||
assert received.error is not None, received.chunks
|
||||
assert received.text == JOINED_PARTIAL, received.chunks
|
||||
assert len(chat_bodies(unset.primary, marker)) == 1
|
||||
assert chat_bodies(unset.no_prefill, marker) == ()
|
||||
assert chat_bodies(unset.backup, marker) == ()
|
||||
|
||||
|
||||
def router_setting(rig: Rig) -> JsonValue:
|
||||
return object_value(rig.gateway.get("/router/settings")["current_values"]).get(SETTING)
|
||||
|
||||
|
||||
def test_turning_the_setting_on_at_runtime_continues_on_a_prefill_fallback(unset: Rig) -> None:
|
||||
rig: Final = unset
|
||||
rig.gateway.post("/config/update", {"router_settings": {SETTING: True}})
|
||||
try:
|
||||
assert router_setting(rig) is True
|
||||
marker: Final = uuid.uuid4().hex
|
||||
prompt: Final = f"text-cut {marker}"
|
||||
received: Final = stream(rig, new_key(rig), "primary", prompt, (after_text(marker, "primary", JOINED_PARTIAL),))
|
||||
assert received.error is None, received.error
|
||||
assert received.text == JOINED_PARTIAL + BACKUP_TEXT, received.chunks
|
||||
assert chat_bodies(rig.no_prefill, marker) == ()
|
||||
assert [body["messages"] for body in chat_bodies(rig.backup, marker)] == [
|
||||
[{"role": "user", "content": prompt}, prefill_turn(JOINED_PARTIAL)]
|
||||
]
|
||||
finally:
|
||||
rig.gateway.post("/config/update", {"router_settings": {SETTING: False}})
|
||||
|
||||
|
||||
def test_a_registered_output_guardrail_keeps_the_original_error(output_guarded: Rig) -> None:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
received: Final = stream(
|
||||
output_guarded,
|
||||
new_key(output_guarded),
|
||||
"primary",
|
||||
f"text-cut {marker}",
|
||||
(after_text(marker, "primary", JOINED_PARTIAL),),
|
||||
)
|
||||
assert (received.error is not None, received.text) == (True, JOINED_PARTIAL), received.chunks
|
||||
assert len(chat_bodies(output_guarded.primary, marker)) == 1
|
||||
assert chat_bodies(output_guarded.backup, marker) == ()
|
||||
|
||||
|
||||
def test_a_request_fallback_bringing_its_own_messages_keeps_the_original_error(configured: Rig) -> None:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
received: Final = stream(
|
||||
configured,
|
||||
new_key(configured),
|
||||
"primary",
|
||||
f"text-cut {marker}",
|
||||
(after_text(marker, "primary", JOINED_PARTIAL),),
|
||||
{"fallbacks": [{"model": "backup", "messages": [{"role": "user", "content": f"start over {marker}"}]}]},
|
||||
)
|
||||
assert (received.error is not None, received.text) == (True, JOINED_PARTIAL), received.chunks
|
||||
assert len(chat_bodies(configured.primary, marker)) == 1
|
||||
assert chat_bodies(configured.backup, marker) == ()
|
||||
|
||||
|
||||
def carries_tag(event: Mapping[str, JsonValue], marker: str) -> bool:
|
||||
tags: Final = event.get("request_tags")
|
||||
return isinstance(tags, list) and marker in tags
|
||||
|
||||
|
||||
def tagged_events(batches: Iterable[Request], marker: str) -> tuple[dict[str, JsonValue], ...]:
|
||||
events: Final = itertools.chain.from_iterable(EVENT_BATCH.validate_json(batch.body) for batch in batches)
|
||||
return tuple(event for event in events if carries_tag(event, marker))
|
||||
|
||||
|
||||
def logged_events(sink: Wire, marker: str) -> Iterator[tuple[dict[str, JsonValue], ...]]:
|
||||
return itertools.accumulate((tagged_events(sink.drain(), marker) for _ in itertools.count()), operator.add)
|
||||
|
||||
|
||||
def test_the_cut_call_logs_its_failure_under_its_own_deployment_and_price(configured: Rig) -> None:
|
||||
marker: Final = uuid.uuid4().hex
|
||||
received: Final = stream(
|
||||
configured,
|
||||
new_key(configured),
|
||||
"primary",
|
||||
f"text-cut {marker}",
|
||||
(after_text(marker, "primary", JOINED_PARTIAL),),
|
||||
{"metadata": {"tags": [marker]}},
|
||||
)
|
||||
assert received.error is None, received.error
|
||||
polls: Final = logged_events(configured.events, marker)
|
||||
events: Final = eventually(lambda: next(polls), lambda logged: len(logged) >= 2, seconds=30)
|
||||
assert sorted(str(event["status"]) for event in events) == ["failure", "success"], events
|
||||
failure: Final = next(event for event in events if event["status"] == "failure")
|
||||
assert failure["model_group"] == "primary", failure
|
||||
assert str(failure["api_base"]).startswith(configured.primary.url), failure
|
||||
tokens: Final = (failure["prompt_tokens"], failure["completion_tokens"])
|
||||
assert isinstance(tokens[0], int) and isinstance(tokens[1], int) and tokens[0] > 0 and tokens[1] > 0, failure
|
||||
assert close(amount(failure["response_cost"]), cost((tokens[0], tokens[1]), PRIMARY_PRICES)), failure
|
||||
|
|
@ -642,6 +642,64 @@ async def test_bedrock_converse_replays_reasoning_to_claude_only_as_base64_of_cl
|
|||
assert assistant_content == [*sent, {"text": "hello"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("use_async", [False, True])
|
||||
@pytest.mark.parametrize(
|
||||
"model, final_text",
|
||||
[("us.anthropic.claude-sonnet-4-6", "Leaves fall"), ("us.amazon.nova-pro-v1:0", "Leaves fall \n")],
|
||||
ids=["claude", "nova"],
|
||||
)
|
||||
async def test_bedrock_converse_strips_trailing_whitespace_from_a_final_assistant_turn_for_claude_only(
|
||||
use_async: bool, model: str, final_text: str
|
||||
):
|
||||
"""Claude on Converse 400s on a final assistant turn ending in whitespace, which a cut stream's prefill often has."""
|
||||
messages: Final = [
|
||||
{"role": "user", "content": "Write a haiku."},
|
||||
{"role": "assistant", "content": "Sure. "},
|
||||
{"role": "user", "content": "About autumn."},
|
||||
{"role": "assistant", "content": "Leaves fall \n"},
|
||||
]
|
||||
|
||||
result: Final = (
|
||||
await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async(
|
||||
messages=messages, model=model, llm_provider="bedrock"
|
||||
)
|
||||
if use_async
|
||||
else _bedrock_converse_messages_pt(messages=messages, model=model, llm_provider="bedrock")
|
||||
)
|
||||
|
||||
assert [m["content"] for m in result if m["role"] == "assistant"] == [
|
||||
[{"text": "Sure. "}],
|
||||
[{"text": final_text}],
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("use_async", [False, True])
|
||||
async def test_bedrock_converse_keeps_whitespace_between_text_blocks_of_a_final_claude_assistant_turn(
|
||||
use_async: bool,
|
||||
):
|
||||
messages: Final = [
|
||||
{"role": "user", "content": "Write a haiku."},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "Leaves fall "}, {"type": "text", "text": "softly \n"}],
|
||||
},
|
||||
]
|
||||
|
||||
result: Final = (
|
||||
await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async(
|
||||
messages=messages, model="us.anthropic.claude-sonnet-4-6", llm_provider="bedrock"
|
||||
)
|
||||
if use_async
|
||||
else _bedrock_converse_messages_pt(
|
||||
messages=messages, model="us.anthropic.claude-sonnet-4-6", llm_provider="bedrock"
|
||||
)
|
||||
)
|
||||
|
||||
assert result[-1]["content"] == [{"text": "Leaves fall "}, {"text": "softly"}]
|
||||
|
||||
|
||||
def test_translate_thinking_blocks_keeps_dropping_redacted_data_when_called_the_old_way():
|
||||
"""Outside callers that only pass the thinking blocks keep the old behavior, with no redactedContent sent."""
|
||||
assert BedrockConverseMessagesProcessor.translate_thinking_blocks_to_reasoning_content_blocks(
|
||||
|
|
|
|||
|
|
@ -1019,6 +1019,87 @@ async def test_bridged_stream_mid_stream_fallback_error_is_rebuilt_around_the_pr
|
|||
assert excinfo.value.generated_content == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("merge_reasoning", [True, False], ids=["merged_into_content", "kept_as_reasoning"])
|
||||
async def test_mid_stream_break_after_reasoning_disqualifies_continuation(logging_obj: Logging, merge_reasoning: bool):
|
||||
import httpx
|
||||
|
||||
from litellm.exceptions import MidStreamFallbackError
|
||||
|
||||
class _ReasoningThenCut:
|
||||
def __init__(self) -> None:
|
||||
self.chunks = iter(
|
||||
(
|
||||
ModelResponseStream(choices=[{"index": 0, "delta": {"reasoning_content": "Let me think"}}]),
|
||||
ModelResponseStream(choices=[{"index": 0, "delta": {"content": "The answer"}}]),
|
||||
)
|
||||
)
|
||||
|
||||
def __aiter__(self) -> "_ReasoningThenCut":
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> ModelResponseStream:
|
||||
next_chunk: Final = next(self.chunks, None)
|
||||
if next_chunk is None:
|
||||
raise httpx.ReadTimeout("connection dropped")
|
||||
return next_chunk
|
||||
|
||||
response: Final = CustomStreamWrapper(
|
||||
completion_stream=_ReasoningThenCut(), model="deepseek-reasoner", logging_obj=logging_obj
|
||||
)
|
||||
response.merge_reasoning_content_in_choices = merge_reasoning
|
||||
|
||||
with pytest.raises(MidStreamFallbackError) as excinfo:
|
||||
async for _ in response:
|
||||
pass
|
||||
|
||||
assert excinfo.value.is_pre_first_chunk is False
|
||||
assert excinfo.value.emitted_disqualifying_content is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"outer_delta,inner_flag,expected",
|
||||
[
|
||||
({"tool_calls": [{"index": 0}]}, False, True),
|
||||
({"content": "Let me check."}, True, True),
|
||||
({"content": "Let me check."}, False, False),
|
||||
],
|
||||
ids=["bridge_wrapper_sent_a_tool_call", "inner_stream_sent_a_tool_call", "plain_text_only"],
|
||||
)
|
||||
async def test_bridged_stream_rebuild_keeps_the_continuation_disqualifier(
|
||||
logging_obj: Logging, outer_delta: dict[str, object], inner_flag: bool, expected: bool
|
||||
):
|
||||
from litellm.exceptions import InternalServerError, MidStreamFallbackError
|
||||
|
||||
provider_error: Final = InternalServerError(message="upstream 500", llm_provider="azure", model="gpt-6.1-sol")
|
||||
inner_error: Final = MidStreamFallbackError(
|
||||
message=str(provider_error),
|
||||
model="gpt-6.1-sol",
|
||||
llm_provider="azure",
|
||||
original_exception=provider_error,
|
||||
is_pre_first_chunk=False,
|
||||
emitted_disqualifying_content=inner_flag,
|
||||
)
|
||||
|
||||
async def _raise_inner_error(**kwargs: object) -> NoReturn:
|
||||
raise inner_error
|
||||
|
||||
response: Final = CustomStreamWrapper(
|
||||
completion_stream=None,
|
||||
model="gpt-6.1-sol",
|
||||
logging_obj=logging_obj,
|
||||
custom_llm_provider="azure",
|
||||
make_call=_raise_inner_error,
|
||||
)
|
||||
response._accumulate_streamed_delta(outer_delta)
|
||||
|
||||
with pytest.raises(MidStreamFallbackError) as excinfo:
|
||||
await response.__anext__()
|
||||
|
||||
assert excinfo.value.emitted_disqualifying_content is expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("sync_mode", [True, False])
|
||||
async def test_streaming_rate_limit_midstream_fallback_keeps_provider_headers(logging_obj: Logging, sync_mode: bool):
|
||||
|
|
@ -6230,6 +6311,59 @@ class TestStableStreamingResponseId:
|
|||
assert wrapper.model_response_creator().id == "chatcmpl-from-provider"
|
||||
|
||||
|
||||
class TestContinuationDisqualifiers:
|
||||
"""The mid-stream continuation eligibility hinges on classifying which deltas
|
||||
carry output a text-only prefill cannot represent."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field",
|
||||
[
|
||||
"tool_calls",
|
||||
"function_call",
|
||||
"reasoning_content",
|
||||
"thinking_blocks",
|
||||
"reasoning_items",
|
||||
"audio",
|
||||
"images",
|
||||
"annotations",
|
||||
],
|
||||
)
|
||||
def test_disqualifying_fields_flagged(self, field):
|
||||
# reasoning_content included: it reaches the caller as visible reasoning a
|
||||
# text-only prefill cannot carry, so a reasoning fallback would re-derive it
|
||||
assert CustomStreamWrapper._delta_disqualifies_continuation({field: [{"x": 1}]}) is True
|
||||
|
||||
@pytest.mark.parametrize("delta", [{"content": "hi"}, {}, {"role": "assistant"}])
|
||||
def test_plain_text_not_flagged(self, delta):
|
||||
assert CustomStreamWrapper._delta_disqualifies_continuation(delta) is False
|
||||
|
||||
def test_non_mapping_delta_is_safe(self):
|
||||
assert CustomStreamWrapper._delta_disqualifies_continuation(object()) is False
|
||||
|
||||
def test_accumulate_grows_text_and_leaves_flag_clear_for_plain_text(self):
|
||||
wrapper = object.__new__(CustomStreamWrapper)
|
||||
wrapper.response_uptil_now = ""
|
||||
wrapper._emitted_disqualifying_content = False
|
||||
|
||||
wrapper._accumulate_streamed_delta({"content": "Hel"})
|
||||
wrapper._accumulate_streamed_delta({"content": "lo"})
|
||||
|
||||
assert wrapper.response_uptil_now == "Hello"
|
||||
assert wrapper._emitted_disqualifying_content is False
|
||||
|
||||
def test_accumulate_latches_flag_on_disqualifying_delta(self):
|
||||
wrapper = object.__new__(CustomStreamWrapper)
|
||||
wrapper.response_uptil_now = ""
|
||||
wrapper._emitted_disqualifying_content = False
|
||||
|
||||
wrapper._accumulate_streamed_delta({"content": "Hi"})
|
||||
wrapper._accumulate_streamed_delta({"tool_calls": [{"index": 0}]})
|
||||
wrapper._accumulate_streamed_delta({"content": "there"})
|
||||
|
||||
assert wrapper.response_uptil_now == "Hithere"
|
||||
assert wrapper._emitted_disqualifying_content is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_stream_without_usage_counts_tokens_off_the_event_loop():
|
||||
from tests.large_text import text
|
||||
|
|
|
|||
|
|
@ -194,8 +194,9 @@ class TestDashScopeVideoInputReference:
|
|||
lambda tmp_path: ("first.png", PNG_BYTES, "image/png"),
|
||||
lambda tmp_path: ("first.png", io.BytesIO(PNG_BYTES)),
|
||||
lambda tmp_path: _write_png(tmp_path),
|
||||
lambda tmp_path: ("first.png", ("inner.png", PNG_BYTES)),
|
||||
],
|
||||
ids=["filename-bytes-content-type", "filename-file", "path"],
|
||||
ids=["filename-bytes-content-type", "filename-file", "path", "nested-tuple"],
|
||||
)
|
||||
def test_file_tuple_and_path_references_are_encoded(self, make_reference, tmp_path):
|
||||
"""Regression: tuples and paths are valid FileTypes but raised before reaching DashScope."""
|
||||
|
|
|
|||
|
|
@ -461,6 +461,21 @@ def test_transform_messages_sanitizes_empty_content():
|
|||
assert result[1]["content"] == "Hi"
|
||||
|
||||
|
||||
def test_transform_messages_drops_the_prefill_prefix_flag():
|
||||
"""Databricks rejects unknown message keys, so a continuation prefill reaches Claude without ``prefix``."""
|
||||
messages = [
|
||||
{"role": "user", "content": "How do transformers work?"},
|
||||
{"role": "assistant", "content": "Transformers use", "prefix": True},
|
||||
]
|
||||
|
||||
result = DatabricksConfig().transform_messages(
|
||||
messages=messages, model="databricks-claude-sonnet-4-6", is_async=False
|
||||
)
|
||||
|
||||
assert result[-1] == {"role": "assistant", "content": "Transformers use"}
|
||||
assert messages[-1]["prefix"] is True
|
||||
|
||||
|
||||
def test_transform_request_preserves_unity_model_service_name():
|
||||
config = DatabricksConfig()
|
||||
result = config.transform_request(
|
||||
|
|
|
|||
1131
tests/unit/llms/volcengine/test_volcengine_video_transformation.py
Normal file
1131
tests/unit/llms/volcengine/test_volcengine_video_transformation.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -14,8 +14,8 @@ from typing_extensions import NotRequired, ReadOnly, TypedDict
|
|||
import litellm
|
||||
import litellm.constants as litellm_constants
|
||||
import litellm.proxy.spend_tracking.spend_tracking_utils as spend_tracking_utils
|
||||
from litellm.constants import LITELLM_TRUNCATED_PAYLOAD_FIELD, LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE, LITTELM_CLI_SERVICE_ACCOUNT_NAME, LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME, MAX_SPEND_LOG_MODEL_NAME_LENGTH, REDACTED_BY_LITELM_STRING, SESSION_ID_OMITTED_METADATA_KEY, UNKNOWN_MODEL_SPEND_LOG_MODEL
|
||||
from litellm.constants import INTERNAL_CALL_ORIGIN_METADATA_KEY, MANAGED_BACKGROUND_RESPONSE_METADATA_KEY
|
||||
from litellm.constants import LITELLM_TRUNCATED_PAYLOAD_FIELD, LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE, LITTELM_CLI_SERVICE_ACCOUNT_NAME, LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME, MAX_SPEND_LOG_MODEL_NAME_LENGTH, REDACTED_BY_LITELM_STRING, SESSION_ID_GENERATED_METADATA_KEY, SESSION_ID_OMITTED_METADATA_KEY, UNKNOWN_MODEL_SPEND_LOG_MODEL
|
||||
from litellm.constants import CALLER_SESSION_ID_METADATA_KEY, INTERNAL_CALL_ORIGIN_METADATA_KEY, MANAGED_BACKGROUND_RESPONSE_METADATA_KEY
|
||||
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
|
||||
from litellm.llms.base_llm.ocr.transformation import OCRResponse, OCRUsageInfo
|
||||
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
||||
|
|
@ -243,6 +243,82 @@ def test_batch_session_outranks_the_per_request_trace_id():
|
|||
assert session_id == "batch-uid-1"
|
||||
|
||||
|
||||
def _chat_session_id(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
litellm_params: Mapping[str, object],
|
||||
correlation_in_logs: bool = True,
|
||||
batch_trace_session_id: str | None = None,
|
||||
metadata: Mapping[str, object] = MappingProxyType({}),
|
||||
) -> str | None:
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", correlation_in_logs)
|
||||
return _get_session_id_for_spend_log(
|
||||
kwargs={"litellm_trace_id": "trace-abc", "litellm_params": litellm_params},
|
||||
metadata=metadata,
|
||||
standard_logging_payload=_TRACE_ONLY_STANDARD_LOGGING,
|
||||
omit_when_missing=False,
|
||||
batch_trace_session_id=batch_trace_session_id,
|
||||
)
|
||||
|
||||
|
||||
_CALLER_SESSION_PARAMS: Final = MappingProxyType({"litellm_session_id": "sess-1", "metadata": {"trace_id": "otel-1"}})
|
||||
|
||||
|
||||
def test_caller_session_wins_over_the_per_request_trace_id(monkeypatch: pytest.MonkeyPatch):
|
||||
assert _chat_session_id(monkeypatch, _CALLER_SESSION_PARAMS) == "sess-1"
|
||||
|
||||
|
||||
def test_batch_session_outranks_a_caller_session(monkeypatch: pytest.MonkeyPatch):
|
||||
assert _chat_session_id(monkeypatch, _CALLER_SESSION_PARAMS, batch_trace_session_id="batch-uid-1") == "batch-uid-1"
|
||||
|
||||
|
||||
def test_caller_session_keeps_the_legacy_trace_id_with_correlation_logging_off(monkeypatch: pytest.MonkeyPatch):
|
||||
assert _chat_session_id(monkeypatch, _CALLER_SESSION_PARAMS, correlation_in_logs=False) == "trace-abc"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("correlation_in_logs", "expected_session_id"), [(True, "convo-1"), (False, "trace-abc")])
|
||||
def test_session_displaced_by_a_trace_header_wins_only_with_correlation_logging(
|
||||
monkeypatch: pytest.MonkeyPatch, correlation_in_logs: bool, expected_session_id: str
|
||||
):
|
||||
header_chain: Final = {"litellm_session_id": "trace-abc", "metadata": {"trace_id": "trace-abc", "session_id": "trace-abc"}}
|
||||
session_id: Final = _chat_session_id(
|
||||
monkeypatch,
|
||||
header_chain,
|
||||
correlation_in_logs=correlation_in_logs,
|
||||
metadata={CALLER_SESSION_ID_METADATA_KEY: "convo-1"},
|
||||
)
|
||||
assert session_id == expected_session_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("litellm_params", "metadata"),
|
||||
[
|
||||
pytest.param({"litellm_session_id": "convo\x00x"}, {}, id="body-session"),
|
||||
pytest.param({"litellm_session_id": "trace-abc"}, {CALLER_SESSION_ID_METADATA_KEY: "convo\x00x"}, id="beside-trace-header"),
|
||||
],
|
||||
)
|
||||
def test_caller_session_postgres_cannot_store_keeps_the_trace_id(
|
||||
monkeypatch: pytest.MonkeyPatch, litellm_params: Mapping[str, object], metadata: Mapping[str, object]
|
||||
):
|
||||
assert _chat_session_id(monkeypatch, litellm_params, metadata=metadata) == "trace-abc"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"litellm_params",
|
||||
[
|
||||
pytest.param({"metadata": {}}, id="no-session"),
|
||||
pytest.param(
|
||||
{"litellm_session_id": "minted-1", "metadata": {"session_id": "minted-1", SESSION_ID_GENERATED_METADATA_KEY: True}},
|
||||
id="minted-by-generate-policy",
|
||||
),
|
||||
pytest.param({"litellm_session_id": "job-7", "metadata": {"trace_id": "job-7"}}, id="copied-from-trace-id"),
|
||||
],
|
||||
)
|
||||
def test_session_that_is_not_a_caller_conversation_keeps_the_trace_id(
|
||||
monkeypatch: pytest.MonkeyPatch, litellm_params: Mapping[str, object]
|
||||
):
|
||||
assert _chat_session_id(monkeypatch, litellm_params) == "trace-abc"
|
||||
|
||||
|
||||
def test_omit_policy_still_suppresses_batch_sessions():
|
||||
session_id: Final = _get_session_id_for_spend_log(
|
||||
kwargs={},
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from starlette.datastructures import Headers
|
|||
|
||||
import litellm
|
||||
from litellm.constants import (
|
||||
CALLER_SESSION_ID_METADATA_KEY,
|
||||
ROUTER_USAGE_COUNTED_TOKENS_METADATA_KEY,
|
||||
SERVER_STREAMING_CLASSIFICATION_KEY,
|
||||
SERVER_STREAMING_CLASSIFICATION_MARKER,
|
||||
|
|
@ -3308,6 +3309,93 @@ def test_add_litellm_metadata_from_request_headers_x_litellm_trace_id_sets_chain
|
|||
assert data["litellm_trace_id"] == "foo"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("correlation_in_logs", "expected_spend_log_session"), [(True, "convo-1"), (False, None)])
|
||||
@pytest.mark.parametrize("session_in_metadata", [False, True], ids=["top-level", "metadata"])
|
||||
def test_x_litellm_trace_id_keeps_the_body_session_for_spend_logs_only(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
correlation_in_logs: bool,
|
||||
expected_spend_log_session: str | None,
|
||||
session_in_metadata: bool,
|
||||
):
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", correlation_in_logs)
|
||||
metadata: Final[dict[str, str]] = {"session_id": "convo-1"} if session_in_metadata else {}
|
||||
data: Final[dict[str, object]] = (
|
||||
{"metadata": metadata} if session_in_metadata else {"metadata": metadata, "litellm_session_id": "convo-1"}
|
||||
)
|
||||
LiteLLMProxyRequestSetup.add_litellm_metadata_from_request_headers(
|
||||
headers={"x-litellm-trace-id": "req-trace-1"}, data=data, _metadata_variable_name="metadata"
|
||||
)
|
||||
assert metadata.get(CALLER_SESSION_ID_METADATA_KEY) == expected_spend_log_session
|
||||
assert data["litellm_session_id"] == "req-trace-1"
|
||||
assert metadata["session_id"] == "req-trace-1"
|
||||
assert data["litellm_trace_id"] == "req-trace-1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"session_header", ["x-litellm-session-id", "x-claude-code-session-id", "x-session-id"]
|
||||
)
|
||||
@pytest.mark.parametrize("with_trace_header", [False, True], ids=["session-header-only", "beside-trace-header"])
|
||||
def test_session_headers_still_beat_the_body_session_with_correlation_logging(
|
||||
monkeypatch: pytest.MonkeyPatch, session_header: str, with_trace_header: bool
|
||||
):
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
||||
metadata: Final[dict[str, str]] = {}
|
||||
data: Final[dict[str, object]] = {"metadata": metadata, "litellm_session_id": "convo-1"}
|
||||
trace_header: Final = {"x-litellm-trace-id": "req-trace-1"} if with_trace_header else {}
|
||||
LiteLLMProxyRequestSetup.add_litellm_metadata_from_request_headers(
|
||||
headers={**trace_header, session_header: "hdr-session-1"}, data=data, _metadata_variable_name="metadata"
|
||||
)
|
||||
spend_log_session: Final = metadata.get(CALLER_SESSION_ID_METADATA_KEY) or data["litellm_session_id"]
|
||||
assert spend_log_session == "hdr-session-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("forged_in", ["metadata", "litellm_metadata"])
|
||||
@pytest.mark.parametrize(
|
||||
"headers",
|
||||
[{"x-litellm-session-id": "hdr-session-1"}, {}],
|
||||
ids=["session-header", "no-headers"],
|
||||
)
|
||||
async def test_client_metadata_cannot_set_the_spend_log_caller_session(
|
||||
monkeypatch: pytest.MonkeyPatch, forged_in: str, headers: dict[str, str]
|
||||
):
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
||||
updated: Final = await add_litellm_data_to_request(
|
||||
data={
|
||||
"model": "gpt-5.4-mini",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
forged_in: {CALLER_SESSION_ID_METADATA_KEY: "forged-session"},
|
||||
},
|
||||
request=_make_request_mock("/v1/chat/completions", headers),
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={},
|
||||
version="test-version",
|
||||
)
|
||||
assert CALLER_SESSION_ID_METADATA_KEY not in updated["metadata"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spend_log_caller_session_stays_out_of_requester_metadata(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
||||
updated: Final = await add_litellm_data_to_request(
|
||||
data={
|
||||
"model": "gpt-5.4-mini",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"litellm_session_id": "convo-1",
|
||||
"metadata": {"team_label": "kept"},
|
||||
},
|
||||
request=_make_request_mock("/v1/chat/completions", {"x-litellm-trace-id": "req-trace-1"}),
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key"),
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={},
|
||||
version="test-version",
|
||||
)
|
||||
assert updated["metadata"][CALLER_SESSION_ID_METADATA_KEY] == "convo-1"
|
||||
assert updated["metadata"]["requester_metadata"]["team_label"] == "kept"
|
||||
assert CALLER_SESSION_ID_METADATA_KEY not in updated["metadata"]["requester_metadata"]
|
||||
|
||||
|
||||
def test_add_litellm_metadata_from_request_headers_x_litellm_session_id_sets_chain_id():
|
||||
"""x-litellm-session-id sets both metadata and top-level litellm_session_id/litellm_trace_id for call chaining."""
|
||||
headers = {"x-litellm-session-id": "bar"}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from openai.types.responses.response_function_web_search import (
|
|||
from pydantic import TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm.constants import CALLER_SESSION_ID_METADATA_KEY
|
||||
from litellm.integrations.anthropic_cache_control_hook import MAX_CACHE_CONTROL_BLOCKS, AnthropicCacheControlHook
|
||||
from litellm.litellm_core_utils.hidden_params import HIDDEN_PARAMS_ATTR
|
||||
from litellm.litellm_core_utils.prompt_templates.factory import (
|
||||
|
|
@ -6499,3 +6500,69 @@ def test_transform_request_reads_service_tier_from_responses_api_request() -> No
|
|||
)
|
||||
|
||||
assert result["service_tier"] == "priority"
|
||||
|
||||
|
||||
def _continued_chain_request(request_kwargs: Mapping[str, object]) -> Mapping[str, object]:
|
||||
async def session_lookup(previous_response_id: str) -> ChatCompletionSession:
|
||||
assert previous_response_id == "resp_turn_1"
|
||||
return ChatCompletionSession(
|
||||
messages=[{"role": "user", "content": "turn 1"}], litellm_session_id="chain-session"
|
||||
)
|
||||
|
||||
return asyncio.run(
|
||||
LiteLLMCompletionResponsesConfig.async_responses_api_session_handler(
|
||||
previous_response_id="resp_turn_1",
|
||||
litellm_completion_request={
|
||||
"model": _CLAUDE_ON_BEDROCK,
|
||||
"custom_llm_provider": "bedrock",
|
||||
"messages": [{"role": "user", "content": "turn 2"}],
|
||||
"metadata": request_kwargs.get("metadata"),
|
||||
},
|
||||
instructions=None,
|
||||
session_lookup=session_lookup,
|
||||
request_kwargs=request_kwargs,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("request_kwargs", "logged_metadata_key"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"metadata": {"tag": "client"},
|
||||
"litellm_metadata": {CALLER_SESSION_ID_METADATA_KEY: "turn-session", "user_api_key": "hashed"},
|
||||
},
|
||||
"litellm_metadata",
|
||||
),
|
||||
({"metadata": {"user_api_key": "hashed"}}, "metadata"),
|
||||
],
|
||||
)
|
||||
def test_a_continued_chain_logs_its_spend_under_the_chain_session_with_request_correlation(
|
||||
monkeypatch: pytest.MonkeyPatch, request_kwargs: Mapping[str, object], logged_metadata_key: str
|
||||
):
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", True)
|
||||
sent_kwargs: Final = deepcopy(request_kwargs)
|
||||
|
||||
request: Final = _continued_chain_request(request_kwargs)
|
||||
|
||||
assert request["litellm_trace_id"] == "chain-session"
|
||||
assert "litellm_session_id" not in request
|
||||
assert request[logged_metadata_key] == {
|
||||
**TypeAdapter(dict[str, object]).validate_python(request_kwargs[logged_metadata_key]),
|
||||
CALLER_SESSION_ID_METADATA_KEY: "chain-session",
|
||||
}
|
||||
assert request_kwargs == sent_kwargs
|
||||
|
||||
|
||||
def test_a_continued_chain_leaves_the_turns_metadata_alone_without_request_correlation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.setattr(litellm, "request_correlation_in_logs", False)
|
||||
caller_metadata: Final = {CALLER_SESSION_ID_METADATA_KEY: "turn-session", "user_api_key": "hashed"}
|
||||
|
||||
request: Final = _continued_chain_request({"metadata": caller_metadata, "litellm_metadata": caller_metadata})
|
||||
|
||||
assert request["litellm_trace_id"] == "chain-session"
|
||||
assert "litellm_metadata" not in request
|
||||
assert request["metadata"] == {CALLER_SESSION_ID_METADATA_KEY: "turn-session", "user_api_key": "hashed"}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import json
|
||||
import sqlite3
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
|
@ -787,3 +788,80 @@ async def test_message_history_replays_real_key_named_tool_payloads() -> None:
|
|||
tool_message: Final = result["messages"][2]
|
||||
assert json.loads(assistant_message["tool_calls"][0]["function"]["arguments"]) == function_arguments
|
||||
assert json.loads(tool_message["content"]) == function_output
|
||||
|
||||
|
||||
class _SqliteBackedPrismaDB:
|
||||
def __init__(self, rows: list[tuple[str, str, str, str, str | None, str | None]]) -> None:
|
||||
self._connection = sqlite3.connect(":memory:")
|
||||
self._connection.row_factory = sqlite3.Row
|
||||
self._connection.execute(
|
||||
'CREATE TABLE "LiteLLM_SpendLogs" '
|
||||
'(request_id TEXT, api_key TEXT, session_id TEXT, "endTime" TEXT, "user" TEXT, team_id TEXT)'
|
||||
)
|
||||
self._connection.executemany('INSERT INTO "LiteLLM_SpendLogs" VALUES (?, ?, ?, ?, ?, ?)', rows)
|
||||
|
||||
async def query_raw(self, query: str, *args: object) -> list[dict[str, object]]:
|
||||
return [dict(row) for row in self._connection.execute(query.replace("$1", "?"), args)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_lookup_does_not_return_another_keys_rows_for_a_shared_session_id():
|
||||
"""A caller-supplied litellm_session_id can collide with another key's session; history must stay per key."""
|
||||
sqlite_db: Final = _SqliteBackedPrismaDB(
|
||||
[
|
||||
("victim-1", "victim-key", "shared-session", "2026-01-01T00:00:01", "", ""),
|
||||
("victim-2", "victim-key", "shared-session", "2026-01-01T00:00:02", "", ""),
|
||||
("attacker-1", "attacker-key", "shared-session", "2026-01-01T00:00:03", "", ""),
|
||||
("attacker-2", "attacker-key", "other-session", "2026-01-01T00:00:04", "", ""),
|
||||
]
|
||||
)
|
||||
fake_prisma_client = _FakePrismaClient(results=[])
|
||||
fake_prisma_client.db = sqlite_db
|
||||
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", fake_prisma_client):
|
||||
spend_logs = await ResponsesSessionHandler.get_all_spend_logs_for_previous_response_id("attacker-1")
|
||||
victim_logs = await ResponsesSessionHandler.get_all_spend_logs_for_previous_response_id("victim-1")
|
||||
|
||||
assert [row["request_id"] for row in spend_logs] == ["attacker-1"]
|
||||
assert [row["request_id"] for row in victim_logs] == ["victim-1", "victim-2"]
|
||||
|
||||
|
||||
async def _history_request_ids(sqlite_db: _SqliteBackedPrismaDB, response_id: str) -> list[str]:
|
||||
fake_prisma_client: Final = _FakePrismaClient(results=[])
|
||||
fake_prisma_client.db = sqlite_db
|
||||
with patch("litellm.proxy.proxy_server.prisma_client", fake_prisma_client):
|
||||
spend_logs: Final = await ResponsesSessionHandler.get_all_spend_logs_for_previous_response_id(response_id)
|
||||
return [row["request_id"] for row in spend_logs]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_lookup_keeps_jwt_users_and_teams_apart_when_they_share_an_empty_api_key():
|
||||
sqlite_db: Final = _SqliteBackedPrismaDB(
|
||||
[
|
||||
("alice-1", "", "shared-session", "2026-01-01T00:00:01", "alice", "team-a"),
|
||||
("bob-1", "", "shared-session", "2026-01-01T00:00:02", "bob", "team-a"),
|
||||
("service-a-1", "", "shared-session", "2026-01-01T00:00:03", "", "team-a"),
|
||||
("service-b-1", "", "shared-session", "2026-01-01T00:00:04", "", "team-b"),
|
||||
("alice-2", "", "shared-session", "2026-01-01T00:00:05", "alice", "team-a"),
|
||||
]
|
||||
)
|
||||
|
||||
assert await _history_request_ids(sqlite_db, "alice-1") == ["alice-1", "alice-2"]
|
||||
assert await _history_request_ids(sqlite_db, "bob-1") == ["bob-1"]
|
||||
assert await _history_request_ids(sqlite_db, "service-a-1") == ["service-a-1"]
|
||||
assert await _history_request_ids(sqlite_db, "service-b-1") == ["service-b-1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_lookup_follows_the_owner_across_keys_and_keeps_ownerless_keys_apart():
|
||||
sqlite_db: Final = _SqliteBackedPrismaDB(
|
||||
[
|
||||
("alice-old-key", "hashed-old", "shared-session", "2026-01-01T00:00:01", "alice", "team-a"),
|
||||
("ownerless-1", "hashed-other", "shared-session", "2026-01-01T00:00:02", "", ""),
|
||||
("alice-new-key", "hashed-new", "shared-session", "2026-01-01T00:00:03", "alice", "team-a"),
|
||||
("ownerless-2", "hashed-another", "shared-session", "2026-01-01T00:00:04", "", ""),
|
||||
]
|
||||
)
|
||||
|
||||
assert await _history_request_ids(sqlite_db, "alice-new-key") == ["alice-old-key", "alice-new-key"]
|
||||
assert await _history_request_ids(sqlite_db, "ownerless-2") == ["ownerless-2"]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,288 @@
|
|||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.router_utils.pre_call_checks.continuation_prefill_check import (
|
||||
MID_STREAM_CONTINUATION_KWARG,
|
||||
MID_STREAM_CONTINUATION_MARKER,
|
||||
ContinuationOutput,
|
||||
ContinuationPrefillDeploymentCheck,
|
||||
continuation_output_ceilings,
|
||||
deployment_supports_prefill,
|
||||
)
|
||||
|
||||
PREFILL_MODEL = "anthropic/prefill-capable-test-model"
|
||||
NON_PREFILL_MODEL = "openai/prefill-unknown-test-model"
|
||||
UNMAPPED_MODEL = "openai/unmapped-test-model"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _synthetic_cost_map_entries() -> None:
|
||||
litellm.register_model(
|
||||
{
|
||||
PREFILL_MODEL: {"litellm_provider": "anthropic", "mode": "chat", "supports_assistant_prefill": True},
|
||||
NON_PREFILL_MODEL: {"litellm_provider": "openai", "mode": "chat"},
|
||||
},
|
||||
persist_across_reloads=False,
|
||||
)
|
||||
litellm.get_model_info.cache_clear()
|
||||
|
||||
|
||||
def _deployment(model: str, dep_id: str) -> dict[str, object]:
|
||||
return {"litellm_params": {"model": model}, "model_info": {"id": dep_id}}
|
||||
|
||||
|
||||
def test_deployment_supports_prefill_reads_capability():
|
||||
assert deployment_supports_prefill(_deployment(PREFILL_MODEL, "a")) is True
|
||||
assert deployment_supports_prefill(_deployment(NON_PREFILL_MODEL, "b")) is False
|
||||
assert deployment_supports_prefill({"litellm_params": {"model": PREFILL_MODEL}}) is True
|
||||
assert deployment_supports_prefill({"litellm_params": {"model": PREFILL_MODEL}, "model_info": "bogus"}) is True
|
||||
|
||||
|
||||
def test_deployment_model_info_override_wins_over_cost_map():
|
||||
# model_info True opts in a model that is not in the cost map
|
||||
assert (
|
||||
deployment_supports_prefill(
|
||||
{"litellm_params": {"model": "vendor/custom-model"}, "model_info": {"supports_assistant_prefill": True}}
|
||||
)
|
||||
is True
|
||||
)
|
||||
# model_info False opts out a model the cost map would otherwise allow
|
||||
assert (
|
||||
deployment_supports_prefill(
|
||||
{"litellm_params": {"model": PREFILL_MODEL}, "model_info": {"supports_assistant_prefill": False}}
|
||||
)
|
||||
is False
|
||||
)
|
||||
# model_info without the key falls through to the cost map
|
||||
assert deployment_supports_prefill(_deployment(PREFILL_MODEL, "z")) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"deployment,expected",
|
||||
[
|
||||
({"litellm_params": {"model": "bedrock/arn:custom-import", "base_model": PREFILL_MODEL}}, True),
|
||||
({"litellm_params": {"model": "azure/my-deployment"}, "model_info": {"base_model": PREFILL_MODEL}}, True),
|
||||
(
|
||||
{
|
||||
"litellm_params": {"model": "azure/my-deployment", "base_model": NON_PREFILL_MODEL},
|
||||
"model_info": {"base_model": PREFILL_MODEL},
|
||||
},
|
||||
True,
|
||||
),
|
||||
(
|
||||
{
|
||||
"litellm_params": {"model": PREFILL_MODEL, "base_model": NON_PREFILL_MODEL},
|
||||
"model_info": {"base_model": NON_PREFILL_MODEL},
|
||||
},
|
||||
False,
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"litellm_params_base_model",
|
||||
"model_info_base_model",
|
||||
"model_info_base_model_wins",
|
||||
"base_model_wins_over_model",
|
||||
],
|
||||
)
|
||||
def test_deployment_supports_prefill_reads_the_cost_map_key_of_the_base_model(deployment, expected):
|
||||
assert deployment_supports_prefill(deployment) is expected
|
||||
|
||||
|
||||
def test_deployment_supports_prefill_rejects_malformed_deployments():
|
||||
assert deployment_supports_prefill({}) is False
|
||||
assert deployment_supports_prefill({"litellm_params": {}}) is False
|
||||
assert deployment_supports_prefill("not-a-dict") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_is_noop_without_continuation_marker():
|
||||
"""A normal (non-continuation) request must be passed through untouched, even
|
||||
if some deployments cannot prefill."""
|
||||
check = ContinuationPrefillDeploymentCheck()
|
||||
deployments = [_deployment(PREFILL_MODEL, "a"), _deployment(NON_PREFILL_MODEL, "b")]
|
||||
|
||||
for request_kwargs in ({}, None, {MID_STREAM_CONTINUATION_KWARG: False}):
|
||||
result = await check.async_filter_deployments(
|
||||
model="group", healthy_deployments=deployments, messages=None, request_kwargs=request_kwargs
|
||||
)
|
||||
assert result == deployments
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_ignores_forged_client_flag():
|
||||
"""A client cannot steer routing: a plain truthy value under the marker key
|
||||
(which the proxy could forward from the request body) is not the internal
|
||||
sentinel, so the filter leaves the deployment list untouched."""
|
||||
check = ContinuationPrefillDeploymentCheck()
|
||||
deployments = [_deployment(PREFILL_MODEL, "a"), _deployment(NON_PREFILL_MODEL, "b")]
|
||||
|
||||
for forged in (True, "true", 1, {"any": "json"}):
|
||||
result = await check.async_filter_deployments(
|
||||
model="group",
|
||||
healthy_deployments=deployments,
|
||||
messages=None,
|
||||
request_kwargs={MID_STREAM_CONTINUATION_KWARG: forged},
|
||||
)
|
||||
assert result == deployments
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_keeps_only_prefill_capable_on_continuation():
|
||||
check = ContinuationPrefillDeploymentCheck()
|
||||
deployments = [_deployment(PREFILL_MODEL, "a"), _deployment(NON_PREFILL_MODEL, "b")]
|
||||
|
||||
result = await check.async_filter_deployments(
|
||||
model="group",
|
||||
healthy_deployments=deployments,
|
||||
messages=None,
|
||||
request_kwargs={MID_STREAM_CONTINUATION_KWARG: MID_STREAM_CONTINUATION_MARKER},
|
||||
)
|
||||
assert [d["model_info"]["id"] for d in result] == ["a"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_empties_group_when_no_prefill_capable_deployment():
|
||||
"""No prefill-capable deployment -> empty result, so the router advances the
|
||||
fallback chain and ultimately surfaces the original error."""
|
||||
check = ContinuationPrefillDeploymentCheck()
|
||||
deployments = [_deployment(NON_PREFILL_MODEL, "b"), _deployment(UNMAPPED_MODEL, "c")]
|
||||
|
||||
result = await check.async_filter_deployments(
|
||||
model="group",
|
||||
healthy_deployments=deployments,
|
||||
messages=None,
|
||||
request_kwargs={MID_STREAM_CONTINUATION_KWARG: MID_STREAM_CONTINUATION_MARKER},
|
||||
)
|
||||
assert result == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_drops_deployments_whose_own_params_constrain_the_continuation():
|
||||
check = ContinuationPrefillDeploymentCheck()
|
||||
plain = _deployment(PREFILL_MODEL, "plain")
|
||||
constrained = [
|
||||
{"litellm_params": {"model": PREFILL_MODEL, **params}, "model_info": {"id": dep_id}}
|
||||
for dep_id, params in (
|
||||
("json", {"response_format": {"type": "json_object"}}),
|
||||
("two_choices", {"extra_body": {"n": 2}}),
|
||||
("forced_tool", {"tool_choice": {"type": "function", "function": {"name": "f"}}}),
|
||||
("merged_reasoning", {"merge_reasoning_content_in_choices": True}),
|
||||
)
|
||||
]
|
||||
|
||||
result = await check.async_filter_deployments(
|
||||
model="group",
|
||||
healthy_deployments=[plain, *constrained],
|
||||
messages=None,
|
||||
request_kwargs={MID_STREAM_CONTINUATION_KWARG: MID_STREAM_CONTINUATION_MARKER},
|
||||
)
|
||||
assert [d["model_info"]["id"] for d in result] == ["plain"]
|
||||
|
||||
|
||||
def _text_chunk(text: str) -> litellm.ModelResponseStream:
|
||||
return litellm.ModelResponseStream(choices=[{"index": 0, "delta": {"content": text}}])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"prefill_content,fallback_texts,expected_texts",
|
||||
[
|
||||
("Hello", ("", "Hel", "lo wor", "ld"), ("", "", " wor", "ld")),
|
||||
("Hello", ("Hello",), ("",)),
|
||||
("Hello", (" world",), (" world",)),
|
||||
("Hello", ("Hel", "p me"), ("Hel", "p me")),
|
||||
("Hello", ("Hel",), ("Hel",)),
|
||||
([{"type": "text", "text": "Sure: "}, {"type": "text", "text": "Hello"}], ("Sure: Hello", " world"), ("", " world")),
|
||||
],
|
||||
ids=[
|
||||
"echo_split_across_chunks_is_dropped",
|
||||
"exact_echo_then_end_is_dropped",
|
||||
"continuation_without_echo_passes_through",
|
||||
"near_echo_is_kept_in_order",
|
||||
"stream_ending_mid_match_is_kept",
|
||||
"content_block_prefill_echo_is_dropped",
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_continuation_output_drops_only_a_full_echo_of_the_prefill(
|
||||
prefill_content, fallback_texts, expected_texts
|
||||
):
|
||||
output = ContinuationOutput(
|
||||
"Hello", {"role": "assistant", "content": prefill_content, "prefix": True}, "group", ()
|
||||
)
|
||||
|
||||
async def fallback_stream():
|
||||
for text in fallback_texts:
|
||||
yield _text_chunk(text)
|
||||
|
||||
released = [chunk async for chunk in output.splice(fallback_stream())]
|
||||
|
||||
assert tuple(chunk.choices[0].delta.content for chunk in released) == expected_texts
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"prefill,fallback_texts,expected_texts",
|
||||
[
|
||||
("Transformers use ", ("self-attention",), ("self-attention",)),
|
||||
("Hello ", ("Hello ", " world"), ("", "world")),
|
||||
("Hello \n", ("\nworld",), ("\nworld",)),
|
||||
("A.\n\n", ("\n Hi", "\nthere"), (" Hi", "\nthere")),
|
||||
("\n\n", ("\nHi",), ("\nHi",)),
|
||||
("\nHello\n", ("\n",), ("",)),
|
||||
],
|
||||
ids=[
|
||||
"continuation_without_whitespace_passes_through",
|
||||
"echo_then_re_emitted_space_are_both_dropped",
|
||||
"different_whitespace_is_kept",
|
||||
"seam_ends_at_the_first_divergence",
|
||||
"whitespace_only_prefill_has_no_seam",
|
||||
"re_emitted_whitespace_at_stream_end_is_dropped",
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_continuation_output_drops_only_whitespace_the_client_already_has(
|
||||
prefill, fallback_texts, expected_texts
|
||||
):
|
||||
output = ContinuationOutput(prefill, {"role": "assistant", "content": prefill, "prefix": True}, "group", ())
|
||||
|
||||
async def fallback_stream():
|
||||
for text in fallback_texts:
|
||||
yield _text_chunk(text)
|
||||
|
||||
released = [chunk async for chunk in output.splice(fallback_stream())]
|
||||
|
||||
assert tuple(chunk.choices[0].delta.content for chunk in released) == expected_texts
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"request_kwargs,expected",
|
||||
[
|
||||
({"max_tokens": 100}, {"max_tokens": 70}),
|
||||
({"max_tokens": 100, "max_completion_tokens": 40}, {"max_tokens": 70, "max_completion_tokens": 10}),
|
||||
({}, {}),
|
||||
(
|
||||
{"max_tokens": 100, "extra_body": {"max_tokens": 50, "top_k": 3}},
|
||||
{"max_tokens": 70, "extra_body": {"max_tokens": 20, "top_k": 3}},
|
||||
),
|
||||
({"extra_body": {"max_completion_tokens": 45}}, {"extra_body": {"max_completion_tokens": 15}}),
|
||||
({"extra_body": {"top_k": 3}}, {}),
|
||||
],
|
||||
ids=["max_tokens", "both_ceilings", "no_ceiling", "extra_body_max_tokens", "extra_body_only", "extra_body_no_ceiling"],
|
||||
)
|
||||
def test_continuation_output_ceilings_take_the_emitted_tokens_off_every_ceiling_the_request_sends(
|
||||
request_kwargs, expected
|
||||
):
|
||||
assert continuation_output_ceilings(request_kwargs, 30) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"request_kwargs",
|
||||
[
|
||||
{"max_tokens": 30},
|
||||
{"max_tokens": 20},
|
||||
{"max_tokens": 100, "max_completion_tokens": 30},
|
||||
{"max_tokens": 100, "extra_body": {"max_tokens": 30}},
|
||||
],
|
||||
ids=["max_tokens_met", "max_tokens_exceeded", "one_of_two_met", "extra_body_met"],
|
||||
)
|
||||
def test_continuation_output_ceilings_decline_once_a_ceiling_has_no_tokens_left(request_kwargs):
|
||||
assert continuation_output_ceilings(request_kwargs, 30) is None
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -544,13 +544,14 @@ def test_should_not_pollute_shared_key_with_custom_nonzero_pricing():
|
|||
)
|
||||
|
||||
|
||||
def test_regex_lookaround_flag_stays_on_the_deployment_that_set_it() -> None:
|
||||
"""A deployment's ``supports_regex_lookaround`` override must not land on the shared
|
||||
@pytest.mark.parametrize("flag", ["supports_regex_lookaround", "supports_assistant_prefill"])
|
||||
def test_capability_flag_stays_on_the_deployment_that_set_it(flag: str) -> None:
|
||||
"""A deployment's capability override must not land on the shared
|
||||
``{provider}/{model}`` key, or every sibling deployment of that model would inherit it."""
|
||||
backend_model = "bedrock/us.xai.grok-4.6"
|
||||
deploy_id = "grok-deploy-keep-regex"
|
||||
|
||||
builtin_flag = litellm.get_model_info(model=backend_model).get("supports_regex_lookaround")
|
||||
builtin_flag = litellm.get_model_info(model=backend_model).get(flag)
|
||||
model_keys = {
|
||||
deploy_id: litellm.model_cost.get(deploy_id),
|
||||
backend_model: copy.deepcopy(litellm.model_cost.get(backend_model)),
|
||||
|
|
@ -561,13 +562,13 @@ def test_regex_lookaround_flag_stays_on_the_deployment_that_set_it() -> None:
|
|||
{
|
||||
"model_name": "grok-keep-regex",
|
||||
"litellm_params": {"model": backend_model},
|
||||
"model_info": {"id": deploy_id, "supports_regex_lookaround": not builtin_flag},
|
||||
"model_info": {"id": deploy_id, flag: not builtin_flag},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert litellm.model_cost[deploy_id]["supports_regex_lookaround"] is (not builtin_flag)
|
||||
assert litellm.get_model_info(model=backend_model).get("supports_regex_lookaround") is builtin_flag
|
||||
assert litellm.model_cost[deploy_id][flag] is (not builtin_flag)
|
||||
assert litellm.get_model_info(model=backend_model).get(flag) is builtin_flag
|
||||
finally:
|
||||
_restore_model_cost_entries(model_keys)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue