mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-23 00:41:40 +00:00
Merge remote-tracking branch 'origin/main' into litellm_mcp_client_allowlist
This commit is contained in:
commit
fb99e3dde3
48 changed files with 1564 additions and 401 deletions
|
|
@ -53,6 +53,8 @@ ALTER INDEX IF EXISTS "LiteLLM_SpendLogs_end_user_idx"
|
|||
RENAME TO "LiteLLM_SpendLogs_legacy_end_user_idx";
|
||||
ALTER INDEX IF EXISTS "LiteLLM_SpendLogs_session_id_idx"
|
||||
RENAME TO "LiteLLM_SpendLogs_legacy_session_id_idx";
|
||||
ALTER INDEX IF EXISTS "LiteLLM_SpendLogs_api_key_startTime_idx"
|
||||
RENAME TO "LiteLLM_SpendLogs_legacy_api_key_startTime_idx";
|
||||
|
||||
CREATE TABLE "LiteLLM_SpendLogs" (
|
||||
LIKE "LiteLLM_SpendLogs_legacy" INCLUDING DEFAULTS INCLUDING GENERATED
|
||||
|
|
@ -78,6 +80,9 @@ CREATE INDEX IF NOT EXISTS "LiteLLM_SpendLogs_end_user_idx"
|
|||
CREATE INDEX IF NOT EXISTS "LiteLLM_SpendLogs_session_id_idx"
|
||||
ON "LiteLLM_SpendLogs" ("session_id");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "LiteLLM_SpendLogs_api_key_startTime_idx"
|
||||
ON "LiteLLM_SpendLogs" ("api_key", "startTime");
|
||||
|
||||
-- Safety net: any row whose startTime has no explicit partition lands here so
|
||||
-- writes never fail. The cleanup job never drops the DEFAULT partition.
|
||||
CREATE TABLE IF NOT EXISTS "LiteLLM_SpendLogs_pdefault"
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ ALTER INDEX IF EXISTS "LiteLLM_SpendLogs_end_user_idx"
|
|||
RENAME TO "LiteLLM_SpendLogs_partitioned_end_user_idx";
|
||||
ALTER INDEX IF EXISTS "LiteLLM_SpendLogs_session_id_idx"
|
||||
RENAME TO "LiteLLM_SpendLogs_partitioned_session_id_idx";
|
||||
ALTER INDEX IF EXISTS "LiteLLM_SpendLogs_api_key_startTime_idx"
|
||||
RENAME TO "LiteLLM_SpendLogs_partitioned_api_key_startTime_idx";
|
||||
|
||||
CREATE TABLE "LiteLLM_SpendLogs" (
|
||||
LIKE "LiteLLM_SpendLogs_partitioned" INCLUDING DEFAULTS INCLUDING GENERATED
|
||||
|
|
@ -60,6 +62,9 @@ CREATE INDEX IF NOT EXISTS "LiteLLM_SpendLogs_end_user_idx"
|
|||
CREATE INDEX IF NOT EXISTS "LiteLLM_SpendLogs_session_id_idx"
|
||||
ON "LiteLLM_SpendLogs" ("session_id");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "LiteLLM_SpendLogs_api_key_startTime_idx"
|
||||
ON "LiteLLM_SpendLogs" ("api_key", "startTime");
|
||||
|
||||
INSERT INTO "LiteLLM_SpendLogs"
|
||||
SELECT * FROM "LiteLLM_SpendLogs_partitioned"
|
||||
ON CONFLICT ("request_id") DO NOTHING;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "LiteLLM_SpendLogs_api_key_startTime_idx" ON "LiteLLM_SpendLogs"("api_key", "startTime");
|
||||
|
|
@ -678,6 +678,7 @@ model LiteLLM_SpendLogs {
|
|||
@@index([end_user])
|
||||
@@index([session_id])
|
||||
@@index([litellm_call_id])
|
||||
@@index([api_key, startTime])
|
||||
}
|
||||
|
||||
model LiteLLM_BudgetWindowSpend {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "litellm-proxy-extras"
|
||||
version = "0.4.98"
|
||||
version = "0.4.99"
|
||||
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
|
@ -26,7 +26,7 @@ required-version = ">=0.10.9"
|
|||
module-root = ""
|
||||
|
||||
[tool.commitizen]
|
||||
version = "0.4.98"
|
||||
version = "0.4.99"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
"../pyproject.toml:litellm-proxy-extras==",
|
||||
|
|
|
|||
|
|
@ -521,6 +521,18 @@ class DualCache(BaseCache):
|
|||
if self.redis_cache is not None:
|
||||
await self.redis_cache.async_delete_cache(key)
|
||||
|
||||
async def async_delete_cache_keys(self, keys: Sequence[str]) -> None:
|
||||
"""Batch twin of ``async_delete_cache``, chunked because Redis takes the
|
||||
whole list as one DELETE command."""
|
||||
if not keys:
|
||||
return
|
||||
for key in keys:
|
||||
self.in_memory_cache.delete_cache(key)
|
||||
if self.redis_cache is None:
|
||||
return
|
||||
for start in range(0, len(keys), DEFAULT_MAX_REDIS_BATCH_CACHE_SIZE):
|
||||
await self.redis_cache.delete_cache_keys(keys[start : start + DEFAULT_MAX_REDIS_BATCH_CACHE_SIZE])
|
||||
|
||||
async def async_get_ttl(self, key: str) -> int | None:
|
||||
"""
|
||||
Get the remaining TTL of a key in in-memory cache or redis
|
||||
|
|
|
|||
|
|
@ -321,6 +321,8 @@ WEBSOCKET_CLOSE_REASON_MAX_BYTES: Final = 123
|
|||
BEDROCK_REALTIME_PENDING_SESSION_UPDATE_SCOPE_KEY: Final = "litellm.bedrock_realtime.pending_session_update"
|
||||
BEDROCK_REALTIME_SESSION_COMMITTED_SCOPE_KEY: Final = "litellm.bedrock_realtime.session_committed"
|
||||
BEDROCK_REALTIME_COMMITTED_FAILURE_SCOPE_KEY: Final = "litellm.bedrock_realtime.committed_failure"
|
||||
BEDROCK_REALTIME_SDK_DISTRIBUTION: Final = "aws-sdk-bedrock-runtime"
|
||||
BEDROCK_REALTIME_SDK_SUPPORTED_RANGE: Final = ">=0.10.0,<0.12.0"
|
||||
CLIENT_REQUESTED_MODEL_SCOPE_KEY: Final = "litellm.client_requested_model"
|
||||
MODEL_GROUP_ALIAS_RESOLVED_SCOPE_KEY: Final = "litellm.model_group_alias_resolved"
|
||||
REALTIME_SESSION_SUCCESS_LOGGED_KEY: Final = "realtime_session_success_logged"
|
||||
|
|
|
|||
|
|
@ -508,7 +508,8 @@ class AnthropicMessagesHandler(BaseTranslation):
|
|||
chat_completion_compatible_request,
|
||||
_tool_name_mapping,
|
||||
) = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai(
|
||||
anthropic_message_request=cast(AnthropicMessagesRequest, data.copy())
|
||||
anthropic_message_request=cast(AnthropicMessagesRequest, data.copy()),
|
||||
preserve_midturn_system=True,
|
||||
)
|
||||
return chat_completion_compatible_request
|
||||
|
||||
|
|
|
|||
|
|
@ -118,6 +118,10 @@ from litellm.llms.anthropic.common_utils import (
|
|||
from litellm.llms.anthropic.experimental_pass_through.context_management import (
|
||||
PolyfillResult,
|
||||
)
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.mid_conversation_system import (
|
||||
convert_mid_conversation_system_turns,
|
||||
is_system_role_message,
|
||||
)
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.utils import (
|
||||
openai_chat_refusal_text,
|
||||
refusal_stop_details,
|
||||
|
|
@ -176,6 +180,7 @@ from litellm.types.llms.openai import (
|
|||
ToolMessageContentPart,
|
||||
)
|
||||
from litellm.types.utils import Choices, ModelResponse, StreamingChoices, Usage
|
||||
from litellm.utils import supports_mid_conversation_system
|
||||
|
||||
from .streaming_iterator import AnthropicStreamWrapper
|
||||
|
||||
|
|
@ -186,6 +191,12 @@ if TYPE_CHECKING:
|
|||
ToolResultContent: TypeAlias = str | list[ToolMessageContentPart]
|
||||
|
||||
|
||||
def target_supports_mid_conversation_system(model: str | None, custom_llm_provider: str | None) -> bool:
|
||||
if not model:
|
||||
return False
|
||||
return supports_mid_conversation_system(model=model, custom_llm_provider=custom_llm_provider)
|
||||
|
||||
|
||||
class AnthropicAdapter:
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
|
@ -418,10 +429,28 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
self,
|
||||
messages: list[AllAnthropicPassThroughMessageValues],
|
||||
model: str | None = None,
|
||||
*,
|
||||
custom_llm_provider: str | None = None,
|
||||
preserve_midturn_system: bool = False,
|
||||
) -> list:
|
||||
new_messages: Final[list[AllMessageValues]] = []
|
||||
replayable_messages: Final = strip_encrypted_reasoning_blocks_from_anthropic_messages(messages)
|
||||
for m in replayable_messages:
|
||||
leading_count: Final = next(
|
||||
(i for i, m in enumerate(replayable_messages) if not is_system_role_message(m)),
|
||||
len(replayable_messages),
|
||||
)
|
||||
trailing_messages: Final = replayable_messages[leading_count:]
|
||||
keeps_midturn_system: Final = (
|
||||
preserve_midturn_system
|
||||
or not any(is_system_role_message(m) for m in trailing_messages)
|
||||
or target_supports_mid_conversation_system(model, custom_llm_provider)
|
||||
)
|
||||
ordered_messages: Final = (
|
||||
replayable_messages
|
||||
if keeps_midturn_system
|
||||
else (*replayable_messages[:leading_count], *convert_mid_conversation_system_turns(trailing_messages))
|
||||
)
|
||||
for m in ordered_messages:
|
||||
user_message: ChatCompletionUserMessage | None = None
|
||||
tool_message_list: list[ChatCompletionToolMessage] = []
|
||||
new_user_content_list: list[ChatCompletionTextObject | ChatCompletionImageObject] = []
|
||||
|
|
@ -494,7 +523,7 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
if isinstance(m.get("content"), str):
|
||||
assistant_message_str = str(m.get("content", ""))
|
||||
elif isinstance(m.get("content"), list):
|
||||
for content in m.get("content", []):
|
||||
for content in cast(list, m.get("content", [])): # cast-ok: untrusted client payload
|
||||
if isinstance(content, str):
|
||||
assistant_message_str = str(content)
|
||||
elif isinstance(content, dict):
|
||||
|
|
@ -1154,6 +1183,7 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
anthropic_message_request: AnthropicMessagesRequest,
|
||||
*,
|
||||
custom_llm_provider: str | None = None,
|
||||
preserve_midturn_system: bool = False,
|
||||
) -> tuple[ChatCompletionRequest, dict[str, str]]:
|
||||
"""
|
||||
This is used by the beta Anthropic Adapter, for translating anthropic `/v1/messages` requests to the openai format.
|
||||
|
|
@ -1175,6 +1205,8 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
new_messages = self.translate_anthropic_messages_to_openai(
|
||||
messages=messages_list,
|
||||
model=anthropic_message_request.get("model"),
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
preserve_midturn_system=preserve_midturn_system,
|
||||
)
|
||||
## ADD SYSTEM MESSAGE TO MESSAGES
|
||||
self._add_system_message_to_messages(new_messages, anthropic_message_request)
|
||||
|
|
|
|||
|
|
@ -765,7 +765,8 @@ def _count_effective_tokens(
|
|||
messages=cast(
|
||||
"list[AllAnthropicPassThroughMessageValues]",
|
||||
messages_without_compaction,
|
||||
)
|
||||
),
|
||||
preserve_midturn_system=True,
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_logger.debug(
|
||||
|
|
@ -920,7 +921,8 @@ def _build_summary_messages(
|
|||
messages=cast(
|
||||
"list[AllAnthropicPassThroughMessageValues]",
|
||||
stripped,
|
||||
)
|
||||
),
|
||||
preserve_midturn_system=True,
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_logger.warning(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
from collections.abc import Mapping, Sequence
|
||||
from itertools import groupby
|
||||
from typing import Final
|
||||
|
||||
CONVERTED_SYSTEM_NOTE: Final = (
|
||||
"Operator note (not from the user): the following was originally a mid-conversation system-role reminder."
|
||||
)
|
||||
|
||||
|
||||
def as_system_content_blocks(value: object) -> list[object]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return list(value)
|
||||
if isinstance(value, str):
|
||||
return [{"type": "text", "text": value}]
|
||||
return [value]
|
||||
|
||||
|
||||
def is_system_role_message(message: object) -> bool:
|
||||
return isinstance(message, dict) and message.get("role") == "system"
|
||||
|
||||
|
||||
def system_role_message_as_user(message: Mapping[str, object]) -> Mapping[str, object]:
|
||||
return {
|
||||
"role": "user",
|
||||
"content": as_system_content_blocks(CONVERTED_SYSTEM_NOTE) + as_system_content_blocks(message.get("content")),
|
||||
}
|
||||
|
||||
|
||||
def opens_with_tool_results(message: object) -> bool:
|
||||
if not isinstance(message, dict) or message.get("role") != "user":
|
||||
return False
|
||||
content: Final = message.get("content")
|
||||
return (
|
||||
isinstance(content, list)
|
||||
and len(content) > 0
|
||||
and isinstance(content[0], dict)
|
||||
and content[0].get("type") == "tool_result"
|
||||
)
|
||||
|
||||
|
||||
def system_run_placed_after_tool_results(
|
||||
system_run: Sequence[Mapping[str, object]], follower_run: Sequence[Mapping[str, object]]
|
||||
) -> tuple[Mapping[str, object], ...]:
|
||||
if follower_run and opens_with_tool_results(follower_run[0]):
|
||||
return (follower_run[0], *system_run, *follower_run[1:])
|
||||
return (*system_run, *follower_run)
|
||||
|
||||
|
||||
def system_turns_after_tool_results(
|
||||
messages: Sequence[Mapping[str, object]],
|
||||
) -> tuple[Mapping[str, object], ...]:
|
||||
runs: Final = tuple(tuple(run) for _, run in groupby(messages, key=is_system_role_message))
|
||||
if not runs:
|
||||
return ()
|
||||
first_system_run: Final = 0 if is_system_role_message(runs[0][0]) else 1
|
||||
paired_runs: Final = tuple(
|
||||
(runs[i], runs[i + 1] if i + 1 < len(runs) else ()) for i in range(first_system_run, len(runs), 2)
|
||||
)
|
||||
return (
|
||||
*(runs[0] if first_system_run else ()),
|
||||
*(
|
||||
m
|
||||
for system_run, follower_run in paired_runs
|
||||
for m in system_run_placed_after_tool_results(system_run, follower_run)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def convert_mid_conversation_system_turns(
|
||||
messages: Sequence[Mapping[str, object]],
|
||||
) -> tuple[Mapping[str, object], ...]:
|
||||
return tuple(
|
||||
system_role_message_as_user(m) if is_system_role_message(m) else m
|
||||
for m in system_turns_after_tool_results(messages)
|
||||
)
|
||||
|
|
@ -27,6 +27,11 @@ from ...common_utils import (
|
|||
strip_advisor_blocks_from_messages,
|
||||
strip_encrypted_reasoning_blocks_from_anthropic_messages,
|
||||
)
|
||||
from .mid_conversation_system import (
|
||||
as_system_content_blocks,
|
||||
convert_mid_conversation_system_turns,
|
||||
is_system_role_message,
|
||||
)
|
||||
|
||||
DEFAULT_ANTHROPIC_API_VERSION: Final = "2023-06-01"
|
||||
|
||||
|
|
@ -151,73 +156,6 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
|
|||
else:
|
||||
return system_param
|
||||
|
||||
@staticmethod
|
||||
def _as_system_content_blocks(value: object) -> list:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return list(value)
|
||||
if isinstance(value, str):
|
||||
return [{"type": "text", "text": value}]
|
||||
return [value]
|
||||
|
||||
@staticmethod
|
||||
def _is_system_role_message(message: object) -> bool:
|
||||
return isinstance(message, dict) and message.get("role") == "system"
|
||||
|
||||
_CONVERTED_SYSTEM_NOTE: Final = (
|
||||
"Operator note (not from the user): the following was originally a mid-conversation system-role reminder."
|
||||
)
|
||||
|
||||
def _system_role_message_as_user(self, message: Mapping) -> Mapping:
|
||||
return {
|
||||
"role": "user",
|
||||
"content": self._as_system_content_blocks(self._CONVERTED_SYSTEM_NOTE)
|
||||
+ self._as_system_content_blocks(message.get("content")),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _opens_with_tool_results(message: object) -> bool:
|
||||
if not isinstance(message, dict) or message.get("role") != "user":
|
||||
return False
|
||||
content: Final = message.get("content")
|
||||
return (
|
||||
isinstance(content, list)
|
||||
and len(content) > 0
|
||||
and isinstance(content[0], dict)
|
||||
and content[0].get("type") == "tool_result"
|
||||
)
|
||||
|
||||
def _system_run_before(self, messages: Sequence, index: int) -> Sequence:
|
||||
start: Final = next(
|
||||
(j + 1 for j in range(index - 1, -1, -1) if not self._is_system_role_message(messages[j])),
|
||||
0,
|
||||
)
|
||||
return messages[start:index]
|
||||
|
||||
def _system_run_end(self, messages: Sequence, index: int) -> int:
|
||||
return next(
|
||||
(j for j in range(index, len(messages)) if not self._is_system_role_message(messages[j])),
|
||||
len(messages),
|
||||
)
|
||||
|
||||
def _reordered_around_tool_results(self, messages: Sequence, index: int) -> tuple:
|
||||
message: Final = messages[index]
|
||||
if self._opens_with_tool_results(message):
|
||||
return (message, *self._system_run_before(messages, index))
|
||||
if not self._is_system_role_message(message):
|
||||
return (message,)
|
||||
run_end: Final = self._system_run_end(messages, index)
|
||||
follower: Final = messages[run_end] if run_end < len(messages) else None
|
||||
return () if self._opens_with_tool_results(follower) else (message,)
|
||||
|
||||
def _system_turns_after_tool_results(self, messages: Sequence) -> tuple:
|
||||
return tuple(
|
||||
message
|
||||
for index in range(len(messages))
|
||||
for message in self._reordered_around_tool_results(messages, index)
|
||||
)
|
||||
|
||||
def _normalize_system_role_messages(self, anthropic_messages_request: dict, model: str) -> None:
|
||||
"""Normalize ``role: "system"`` entries in ``messages`` per the Anthropic
|
||||
``/v1/messages`` contract, which the first-party API, Bedrock Invoke,
|
||||
|
|
@ -254,7 +192,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
|
|||
if not isinstance(messages, list):
|
||||
return
|
||||
leading_count: Final = next(
|
||||
(i for i, m in enumerate(messages) if not self._is_system_role_message(m)),
|
||||
(i for i, m in enumerate(messages) if not is_system_role_message(m)),
|
||||
len(messages),
|
||||
)
|
||||
hoisted: Final = messages[:leading_count]
|
||||
|
|
@ -265,10 +203,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
|
|||
custom_llm_provider=self.custom_llm_provider,
|
||||
key="supports_mid_conversation_system",
|
||||
)
|
||||
else [
|
||||
self._system_role_message_as_user(m) if self._is_system_role_message(m) else m
|
||||
for m in self._system_turns_after_tool_results(messages[leading_count:])
|
||||
]
|
||||
else list(convert_mid_conversation_system_turns(messages[leading_count:]))
|
||||
)
|
||||
if hoisted or remaining != messages:
|
||||
anthropic_messages_request["messages"] = remaining
|
||||
|
|
@ -278,7 +213,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
|
|||
anthropic_messages_request.get("system"),
|
||||
*(m.get("content") for m in hoisted),
|
||||
)
|
||||
for block in self._as_system_content_blocks(source)
|
||||
for block in as_system_content_blocks(source)
|
||||
]
|
||||
filtered_system: Final = self._filter_billing_headers_from_system(system_content)
|
||||
if filtered_system:
|
||||
|
|
|
|||
|
|
@ -6,11 +6,12 @@ This uses aws_sdk_bedrock_runtime for bidirectional streaming with Nova Sonic.
|
|||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import importlib.metadata
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Mapping, MutableMapping
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, MutableMapping
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Final, NoReturn, Protocol
|
||||
from typing import Final, NoReturn, Protocol, runtime_checkable
|
||||
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
|
|
@ -19,6 +20,8 @@ from litellm._logging import _redact_string, verbose_proxy_logger
|
|||
from litellm.constants import (
|
||||
BEDROCK_REALTIME_COMMITTED_FAILURE_SCOPE_KEY,
|
||||
BEDROCK_REALTIME_PENDING_SESSION_UPDATE_SCOPE_KEY,
|
||||
BEDROCK_REALTIME_SDK_DISTRIBUTION,
|
||||
BEDROCK_REALTIME_SDK_SUPPORTED_RANGE,
|
||||
BEDROCK_REALTIME_SESSION_COMMITTED_SCOPE_KEY,
|
||||
REALTIME_SESSION_SUCCESS_LOGGED_KEY,
|
||||
)
|
||||
|
|
@ -121,6 +124,38 @@ class BedrockBidirectionalStream(Protocol):
|
|||
async def await_output(self) -> tuple[object, BedrockOutputStream]: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ClosableBedrockRuntimeClient(Protocol):
|
||||
async def close(self) -> None: ...
|
||||
|
||||
|
||||
def _installed_sdk_version() -> str | None:
|
||||
try:
|
||||
return importlib.metadata.version(BEDROCK_REALTIME_SDK_DISTRIBUTION)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def _sdk_import_error(installed_version: str | None, cause: ImportError) -> ImportError:
|
||||
install_hint: Final = "pip install 'litellm[bedrock-realtime]'"
|
||||
requirement: Final = f"{BEDROCK_REALTIME_SDK_DISTRIBUTION}[awscrt]{BEDROCK_REALTIME_SDK_SUPPORTED_RANGE}"
|
||||
verbose_proxy_logger.error("Bedrock Realtime: SDK import failed (installed=%s): %s", installed_version, cause)
|
||||
if installed_version is None:
|
||||
return ImportError(f"Missing aws_sdk_bedrock_runtime: {install_hint} ({requirement})")
|
||||
return ImportError(
|
||||
f"{BEDROCK_REALTIME_SDK_DISTRIBUTION} {installed_version} is installed but Bedrock realtime needs "
|
||||
f"[awscrt]{BEDROCK_REALTIME_SDK_SUPPORTED_RANGE}: {install_hint}"
|
||||
)
|
||||
|
||||
|
||||
async def _close_bedrock_client(bedrock_client: object) -> None:
|
||||
if not isinstance(bedrock_client, ClosableBedrockRuntimeClient):
|
||||
return
|
||||
with contextlib.suppress(Exception):
|
||||
await bedrock_client.close()
|
||||
verbose_proxy_logger.debug("Bedrock Realtime: closed SDK client")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _BridgeOutcome:
|
||||
logged_events: tuple[OpenAIRealtimeEvents, ...]
|
||||
|
|
@ -199,8 +234,9 @@ async def _ack_session_update(
|
|||
class BedrockRealtime(BaseAWSLLM):
|
||||
"""Handler for Bedrock Nova Sonic realtime speech-to-speech API."""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, sdk_version_lookup: Callable[[], str | None] = _installed_sdk_version):
|
||||
super().__init__()
|
||||
self._sdk_version_lookup: Final = sdk_version_lookup
|
||||
|
||||
async def async_realtime(
|
||||
self,
|
||||
|
|
@ -234,14 +270,13 @@ class BedrockRealtime(BaseAWSLLM):
|
|||
Various AWS authentication parameters
|
||||
"""
|
||||
try:
|
||||
from aws_sdk_bedrock_runtime.client import (
|
||||
BedrockRuntimeClient,
|
||||
InvokeModelWithBidirectionalStreamOperationInput,
|
||||
)
|
||||
from aws_sdk_bedrock_runtime.config import Config
|
||||
from smithy_aws_core.identity import StaticCredentialsResolver
|
||||
except ImportError:
|
||||
raise ImportError("Missing aws_sdk_bedrock_runtime. Install with: pip install aws-sdk-bedrock-runtime")
|
||||
from aws_sdk_bedrock_runtime.client import AsyncBedrockRuntimeClient
|
||||
from aws_sdk_bedrock_runtime.config import AsyncBedrockRuntimeConfig
|
||||
from aws_sdk_bedrock_runtime.models import InvokeModelWithBidirectionalStreamOperationInput
|
||||
from smithy_aws_core.identity import AWSCredentialsIdentity, StaticCredentialsResolver
|
||||
from smithy_http.aio.crt import AWSCRTHTTPClient
|
||||
except ImportError as e:
|
||||
raise _sdk_import_error(self._sdk_version_lookup(), e) from e
|
||||
|
||||
pending_session_update: Final = _pending_session_update(websocket.scope)
|
||||
|
||||
|
|
@ -285,22 +320,37 @@ class BedrockRealtime(BaseAWSLLM):
|
|||
)
|
||||
frozen_credentials: Final = await run_aws_signing(credentials.get_frozen_credentials)
|
||||
|
||||
# Initialize Bedrock client with aws_sdk_bedrock_runtime
|
||||
config: Final = Config(
|
||||
credentials_identity: Final = AWSCredentialsIdentity(
|
||||
access_key_id=frozen_credentials.access_key,
|
||||
secret_access_key=frozen_credentials.secret_key,
|
||||
session_token=frozen_credentials.token,
|
||||
)
|
||||
config: Final = await AsyncBedrockRuntimeConfig.resolve(
|
||||
endpoint_uri=endpoint_uri,
|
||||
region=aws_region_name,
|
||||
aws_access_key_id=frozen_credentials.access_key,
|
||||
aws_secret_access_key=frozen_credentials.secret_key,
|
||||
aws_session_token=frozen_credentials.token,
|
||||
aws_credentials_identity_resolver=StaticCredentialsResolver(),
|
||||
aws_credentials_identity_resolver=StaticCredentialsResolver(identity=credentials_identity),
|
||||
transport=AWSCRTHTTPClient(),
|
||||
)
|
||||
bedrock_client: Final = BedrockRuntimeClient(config=config)
|
||||
bedrock_client: Final = AsyncBedrockRuntimeClient(config=config)
|
||||
|
||||
async def open_bidirectional_stream() -> BedrockBidirectionalStream:
|
||||
return await bedrock_client.invoke_model_with_bidirectional_stream(
|
||||
InvokeModelWithBidirectionalStreamOperationInput(model_id=model)
|
||||
)
|
||||
|
||||
try:
|
||||
await self._run_session(websocket, open_bidirectional_stream, model, logging_obj, pending_session_update)
|
||||
finally:
|
||||
await _close_bedrock_client(bedrock_client)
|
||||
|
||||
async def _run_session(
|
||||
self,
|
||||
websocket: RealtimeClientWebSocket,
|
||||
open_bidirectional_stream: Callable[[], Awaitable[BedrockBidirectionalStream]],
|
||||
model: str,
|
||||
logging_obj: LiteLLMLogging,
|
||||
pending_session_update: str | None,
|
||||
) -> None:
|
||||
transformation_config: Final = BedrockRealtimeConfig()
|
||||
|
||||
bedrock_stream: Final = await open_bidirectional_stream()
|
||||
|
|
|
|||
|
|
@ -7605,7 +7605,7 @@
|
|||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"source": "https://learn.microsoft.com/en-us/azure/ai-foundry/openai/concepts/models",
|
||||
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/batch",
|
||||
|
|
@ -7733,7 +7733,7 @@
|
|||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"source": "https://learn.microsoft.com/en-us/azure/ai-foundry/openai/concepts/models",
|
||||
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/batch",
|
||||
|
|
@ -7887,7 +7887,7 @@
|
|||
"supports_none_reasoning_effort": true,
|
||||
"supports_xhigh_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": false,
|
||||
"source": "https://learn.microsoft.com/en-us/azure/ai-foundry/openai/concepts/models"
|
||||
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
|
||||
},
|
||||
"azure/gpt-6-astra": {
|
||||
"cache_creation_input_token_cost": 1.25e-05,
|
||||
|
|
@ -7956,7 +7956,7 @@
|
|||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"source": "https://learn.microsoft.com/en-us/azure/ai-foundry/openai/concepts/models",
|
||||
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/responses"
|
||||
|
|
@ -8856,7 +8856,7 @@
|
|||
"supports_none_reasoning_effort": true,
|
||||
"supports_xhigh_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": false,
|
||||
"source": "https://learn.microsoft.com/en-us/azure/ai-foundry/openai/concepts/models"
|
||||
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
|
||||
},
|
||||
"azure/us/gpt-5.5-2026-04-23": {
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
|
|
@ -8955,7 +8955,7 @@
|
|||
"supports_none_reasoning_effort": true,
|
||||
"supports_xhigh_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": false,
|
||||
"source": "https://learn.microsoft.com/en-us/azure/ai-foundry/openai/concepts/models"
|
||||
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
|
||||
},
|
||||
"azure/eu/gpt-5.5-2026-04-23": {
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
|
|
@ -9054,7 +9054,7 @@
|
|||
"supports_none_reasoning_effort": true,
|
||||
"supports_xhigh_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": false,
|
||||
"source": "https://learn.microsoft.com/en-us/azure/ai-foundry/openai/concepts/models"
|
||||
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
|
||||
},
|
||||
"azure/gpt-5.5-pro": {
|
||||
"cache_read_input_token_cost": 3e-06,
|
||||
|
|
@ -10987,14 +10987,14 @@
|
|||
"supports_vision": true
|
||||
},
|
||||
"azure_ai/FW-Kimi-K3": {
|
||||
"cache_read_input_token_cost": 3.3e-07,
|
||||
"input_cost_per_token": 3.3e-06,
|
||||
"cache_read_input_token_cost": 3e-07,
|
||||
"input_cost_per_token": 3e-06,
|
||||
"litellm_provider": "azure_ai",
|
||||
"max_input_tokens": 1048576,
|
||||
"max_output_tokens": 131072,
|
||||
"max_tokens": 131072,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 1.65e-05,
|
||||
"output_cost_per_token": 1.5e-05,
|
||||
"reasoning_effort_levels": [
|
||||
"low",
|
||||
"high",
|
||||
|
|
@ -23788,7 +23788,7 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": false
|
||||
},
|
||||
"fireworks_ai/accounts/fireworks/models/mixtral-8x22b-instruct-hf": {
|
||||
"input_cost_per_token": 1.2e-06,
|
||||
|
|
@ -24114,7 +24114,7 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": false
|
||||
},
|
||||
"fireworks_ai/qwen3p7-plus": {
|
||||
"cache_read_input_token_cost": 8e-08,
|
||||
|
|
@ -45245,7 +45245,7 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"together_ai/openai/gpt-oss-20b": {
|
||||
"deprecation_date": "2026-09-15",
|
||||
"deprecation_date": "2026-09-14",
|
||||
"input_cost_per_token": 5e-08,
|
||||
"litellm_provider": "together_ai",
|
||||
"max_input_tokens": 131072,
|
||||
|
|
@ -45482,6 +45482,7 @@
|
|||
},
|
||||
"together_ai/deepseek-ai/DeepSeek-V4-Flash-0731": {
|
||||
"cache_read_input_token_cost": 3e-08,
|
||||
"deprecation_date": "2026-09-29",
|
||||
"input_cost_per_token": 1.4e-07,
|
||||
"litellm_provider": "together_ai",
|
||||
"max_input_tokens": 1048576,
|
||||
|
|
@ -45503,7 +45504,7 @@
|
|||
"max_tokens": 1048576,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 1.2e-06,
|
||||
"source": "https://api.together.xyz/v1/models",
|
||||
"source": "https://api.together.ai/v1/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_response_schema": true,
|
||||
|
|
@ -45528,6 +45529,7 @@
|
|||
},
|
||||
"together_ai/deepseek-ai/DeepSeek-V4-Pro-0813": {
|
||||
"cache_read_input_token_cost": 1.3e-07,
|
||||
"deprecation_date": "2026-09-29",
|
||||
"input_cost_per_token": 1.32e-06,
|
||||
"litellm_provider": "together_ai",
|
||||
"max_input_tokens": 1048576,
|
||||
|
|
@ -45552,7 +45554,7 @@
|
|||
"source": "https://docs.together.ai/docs/serverless-models"
|
||||
},
|
||||
"together_ai/google/gemma-4-31B-it": {
|
||||
"deprecation_date": "2026-09-15",
|
||||
"deprecation_date": "2026-09-14",
|
||||
"input_cost_per_token": 3.9e-07,
|
||||
"litellm_provider": "together_ai",
|
||||
"max_input_tokens": 262144,
|
||||
|
|
@ -45567,7 +45569,7 @@
|
|||
"supports_vision": true
|
||||
},
|
||||
"together_ai/intfloat/multilingual-e5-large-instruct": {
|
||||
"deprecation_date": "2026-09-15",
|
||||
"deprecation_date": "2026-09-14",
|
||||
"input_cost_per_token": 2e-08,
|
||||
"litellm_provider": "together_ai",
|
||||
"max_input_tokens": 514,
|
||||
|
|
@ -45680,7 +45682,7 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"together_ai/thinkingmachines/Inkling-Small": {
|
||||
"deprecation_date": "2026-09-15",
|
||||
"deprecation_date": "2026-09-14",
|
||||
"cache_read_input_token_cost": 1e-07,
|
||||
"input_cost_per_token": 5e-07,
|
||||
"litellm_provider": "together_ai",
|
||||
|
|
@ -50573,7 +50575,7 @@
|
|||
"wandb/openai/gpt-oss-120b": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 131072,
|
||||
"max_input_tokens": 131072,
|
||||
"max_input_tokens": 131000,
|
||||
"max_output_tokens": 131072,
|
||||
"input_cost_per_token": 3e-08,
|
||||
"output_cost_per_token": 1.7e-07,
|
||||
|
|
@ -50584,7 +50586,7 @@
|
|||
"wandb/openai/gpt-oss-20b": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 131072,
|
||||
"max_input_tokens": 131072,
|
||||
"max_input_tokens": 131000,
|
||||
"max_output_tokens": 131072,
|
||||
"input_cost_per_token": 3e-08,
|
||||
"output_cost_per_token": 1.3e-07,
|
||||
|
|
@ -50593,6 +50595,7 @@
|
|||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
"wandb/zai-org/GLM-4.5": {
|
||||
"deprecation_date": "2026-03-04",
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 131072,
|
||||
"max_input_tokens": 131072,
|
||||
|
|
@ -50603,6 +50606,7 @@
|
|||
"mode": "chat"
|
||||
},
|
||||
"wandb/Qwen/Qwen3-235B-A22B-Instruct-2507": {
|
||||
"deprecation_date": "2026-08-04",
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_output_tokens": 262144,
|
||||
|
|
@ -50612,6 +50616,7 @@
|
|||
"mode": "chat"
|
||||
},
|
||||
"wandb/Qwen/Qwen3-Coder-480B-A35B-Instruct": {
|
||||
"deprecation_date": "2026-08-25",
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_output_tokens": 262144,
|
||||
|
|
@ -50622,6 +50627,7 @@
|
|||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
"wandb/Qwen/Qwen3-235B-A22B-Thinking-2507": {
|
||||
"deprecation_date": "2026-08-04",
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
|
|
@ -50632,6 +50638,7 @@
|
|||
"mode": "chat"
|
||||
},
|
||||
"wandb/moonshotai/Kimi-K2-Instruct": {
|
||||
"deprecation_date": "2026-03-04",
|
||||
"max_tokens": 128000,
|
||||
"max_input_tokens": 128000,
|
||||
"max_output_tokens": 128000,
|
||||
|
|
@ -50656,6 +50663,7 @@
|
|||
"supports_vision": true
|
||||
},
|
||||
"wandb/MiniMaxAI/MiniMax-M2.5": {
|
||||
"deprecation_date": "2026-08-25",
|
||||
"max_tokens": 197000,
|
||||
"max_input_tokens": 197000,
|
||||
"max_output_tokens": 197000,
|
||||
|
|
@ -50670,7 +50678,7 @@
|
|||
},
|
||||
"wandb/meta-llama/Llama-3.1-8B-Instruct": {
|
||||
"max_tokens": 128000,
|
||||
"max_input_tokens": 128000,
|
||||
"max_input_tokens": 131000,
|
||||
"max_output_tokens": 128000,
|
||||
"input_cost_per_token": 2.2e-07,
|
||||
"output_cost_per_token": 2.2e-07,
|
||||
|
|
@ -50690,6 +50698,7 @@
|
|||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
"wandb/deepseek-ai/DeepSeek-R1-0528": {
|
||||
"deprecation_date": "2026-03-04",
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 161000,
|
||||
"max_input_tokens": 161000,
|
||||
|
|
@ -50700,6 +50709,7 @@
|
|||
"mode": "chat"
|
||||
},
|
||||
"wandb/deepseek-ai/DeepSeek-V3-0324": {
|
||||
"deprecation_date": "2026-03-04",
|
||||
"max_tokens": 161000,
|
||||
"max_input_tokens": 161000,
|
||||
"max_output_tokens": 161000,
|
||||
|
|
@ -50719,6 +50729,7 @@
|
|||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
"wandb/meta-llama/Llama-4-Scout-17B-16E-Instruct": {
|
||||
"deprecation_date": "2026-04-21",
|
||||
"max_tokens": 64000,
|
||||
"max_input_tokens": 64000,
|
||||
"max_output_tokens": 64000,
|
||||
|
|
@ -50728,6 +50739,7 @@
|
|||
"mode": "chat"
|
||||
},
|
||||
"wandb/microsoft/Phi-4-mini-instruct": {
|
||||
"deprecation_date": "2026-08-04",
|
||||
"max_tokens": 128000,
|
||||
"max_input_tokens": 128000,
|
||||
"max_output_tokens": 128000,
|
||||
|
|
@ -56692,7 +56704,8 @@
|
|||
"supports_function_calling": true,
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true,
|
||||
"gemini_audio_only_live": true
|
||||
"gemini_audio_only_live": true,
|
||||
"supports_response_schema": false
|
||||
},
|
||||
"gemini-3.8-live-extended-thinking": {
|
||||
"input_cost_per_audio_token": 3e-06,
|
||||
|
|
@ -56726,7 +56739,8 @@
|
|||
"supports_vision": true,
|
||||
"supports_web_search": true,
|
||||
"gemini_audio_only_live": true,
|
||||
"supports_reasoning": true
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": false
|
||||
},
|
||||
"gemini/gemini-2.5-flash-native-audio-latest": {
|
||||
"input_cost_per_audio_token": 3e-06,
|
||||
|
|
@ -60970,10 +60984,11 @@
|
|||
"wandb/deepseek-ai/DeepSeek-V4-Flash": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 1048576,
|
||||
"max_input_tokens": 1048576,
|
||||
"max_input_tokens": 1049000,
|
||||
"input_cost_per_token": 1.4e-07,
|
||||
"output_cost_per_token": 2.8e-07,
|
||||
"cache_read_input_token_cost": 7e-08,
|
||||
"deprecation_date": "2026-10-05",
|
||||
"supports_prompt_caching": true,
|
||||
"litellm_provider": "wandb",
|
||||
"mode": "chat",
|
||||
|
|
@ -60983,7 +60998,7 @@
|
|||
"wandb/deepseek-ai/DeepSeek-V4-Flash-0731": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_input_tokens": 262000,
|
||||
"input_cost_per_token": 1.3e-07,
|
||||
"output_cost_per_token": 2.8e-07,
|
||||
"cache_read_input_token_cost": 7e-08,
|
||||
|
|
@ -60996,10 +61011,11 @@
|
|||
"wandb/deepseek-ai/DeepSeek-V4-Pro": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 1048576,
|
||||
"max_input_tokens": 1048576,
|
||||
"max_input_tokens": 1049000,
|
||||
"input_cost_per_token": 1.15e-06,
|
||||
"output_cost_per_token": 2.55e-06,
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
"deprecation_date": "2026-10-05",
|
||||
"supports_prompt_caching": true,
|
||||
"litellm_provider": "wandb",
|
||||
"mode": "chat",
|
||||
|
|
@ -61009,7 +61025,7 @@
|
|||
"wandb/google/gemma-4-31B-it": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_input_tokens": 262000,
|
||||
"input_cost_per_token": 1e-07,
|
||||
"output_cost_per_token": 3.4e-07,
|
||||
"litellm_provider": "wandb",
|
||||
|
|
@ -61018,8 +61034,9 @@
|
|||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
"wandb/ibm-granite/granite-4.1-8b": {
|
||||
"deprecation_date": "2026-10-05",
|
||||
"max_tokens": 131072,
|
||||
"max_input_tokens": 131072,
|
||||
"max_input_tokens": 131000,
|
||||
"input_cost_per_token": 5e-08,
|
||||
"output_cost_per_token": 1e-07,
|
||||
"litellm_provider": "wandb",
|
||||
|
|
@ -61028,8 +61045,9 @@
|
|||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
"wandb/JetBrains/Mellum2-12B-A2.5B-Instruct": {
|
||||
"deprecation_date": "2026-10-05",
|
||||
"max_tokens": 131072,
|
||||
"max_input_tokens": 131072,
|
||||
"max_input_tokens": 131000,
|
||||
"input_cost_per_token": 5e-08,
|
||||
"output_cost_per_token": 1e-07,
|
||||
"litellm_provider": "wandb",
|
||||
|
|
@ -61038,8 +61056,9 @@
|
|||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
"wandb/meta-llama/Llama-3.1-70B-Instruct": {
|
||||
"deprecation_date": "2026-10-05",
|
||||
"max_tokens": 128000,
|
||||
"max_input_tokens": 128000,
|
||||
"max_input_tokens": 131000,
|
||||
"input_cost_per_token": 8e-07,
|
||||
"output_cost_per_token": 8e-07,
|
||||
"litellm_provider": "wandb",
|
||||
|
|
@ -61050,7 +61069,7 @@
|
|||
"wandb/MiniMaxAI/MiniMax-M3": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_input_tokens": 262000,
|
||||
"input_cost_per_token": 2.3e-07,
|
||||
"output_cost_per_token": 9.6e-07,
|
||||
"cache_read_input_token_cost": 5e-08,
|
||||
|
|
@ -61063,7 +61082,7 @@
|
|||
"wandb/moonshotai/Kimi-K2.7-Code": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_input_tokens": 262000,
|
||||
"input_cost_per_token": 7.1e-07,
|
||||
"output_cost_per_token": 3.5e-06,
|
||||
"cache_read_input_token_cost": 1.5e-07,
|
||||
|
|
@ -61076,7 +61095,7 @@
|
|||
"wandb/moonshotai/Kimi-K2.6": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_input_tokens": 262000,
|
||||
"input_cost_per_token": 6.5e-07,
|
||||
"output_cost_per_token": 3.41e-06,
|
||||
"cache_read_input_token_cost": 1.5e-07,
|
||||
|
|
@ -61089,10 +61108,10 @@
|
|||
"wandb/nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"input_cost_per_token": 1e-07,
|
||||
"output_cost_per_token": 2.5e-07,
|
||||
"cache_read_input_token_cost": 5e-08,
|
||||
"max_input_tokens": 262000,
|
||||
"input_cost_per_token": 7e-08,
|
||||
"output_cost_per_token": 2e-07,
|
||||
"cache_read_input_token_cost": 4e-08,
|
||||
"supports_prompt_caching": true,
|
||||
"litellm_provider": "wandb",
|
||||
"mode": "chat",
|
||||
|
|
@ -61102,10 +61121,10 @@
|
|||
"wandb/nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"input_cost_per_token": 7.5e-07,
|
||||
"output_cost_per_token": 2.75e-06,
|
||||
"cache_read_input_token_cost": 1.5e-07,
|
||||
"max_input_tokens": 262000,
|
||||
"input_cost_per_token": 5e-07,
|
||||
"output_cost_per_token": 2.15e-06,
|
||||
"cache_read_input_token_cost": 1e-07,
|
||||
"supports_prompt_caching": true,
|
||||
"litellm_provider": "wandb",
|
||||
"mode": "chat",
|
||||
|
|
@ -61113,8 +61132,9 @@
|
|||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
"wandb/OpenPipe/Qwen3-14B-Instruct": {
|
||||
"deprecation_date": "2026-10-05",
|
||||
"max_tokens": 32768,
|
||||
"max_input_tokens": 32768,
|
||||
"max_input_tokens": 32800,
|
||||
"input_cost_per_token": 5e-08,
|
||||
"output_cost_per_token": 2.2e-07,
|
||||
"litellm_provider": "wandb",
|
||||
|
|
@ -61125,7 +61145,7 @@
|
|||
"wandb/Qwen/Qwen3.8-27B": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_input_tokens": 262000,
|
||||
"input_cost_per_token": 4e-07,
|
||||
"output_cost_per_token": 3e-06,
|
||||
"cache_read_input_token_cost": 1.5e-07,
|
||||
|
|
@ -61138,7 +61158,7 @@
|
|||
"wandb/Qwen/Qwen3.6-35B-A3B": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_input_tokens": 262000,
|
||||
"input_cost_per_token": 2.5e-07,
|
||||
"output_cost_per_token": 1.25e-06,
|
||||
"litellm_provider": "wandb",
|
||||
|
|
@ -61149,10 +61169,11 @@
|
|||
"wandb/Qwen/Qwen3.6-27B": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_input_tokens": 262000,
|
||||
"input_cost_per_token": 6e-07,
|
||||
"output_cost_per_token": 3.6e-06,
|
||||
"cache_read_input_token_cost": 1.2e-07,
|
||||
"deprecation_date": "2026-10-05",
|
||||
"supports_prompt_caching": true,
|
||||
"litellm_provider": "wandb",
|
||||
"mode": "chat",
|
||||
|
|
@ -61160,9 +61181,10 @@
|
|||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
"wandb/Qwen/Qwen3.5-35B-A3B": {
|
||||
"deprecation_date": "2026-10-05",
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_input_tokens": 262000,
|
||||
"input_cost_per_token": 2.5e-07,
|
||||
"output_cost_per_token": 1.25e-06,
|
||||
"litellm_provider": "wandb",
|
||||
|
|
@ -61171,8 +61193,9 @@
|
|||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
"wandb/Qwen/Qwen3-30B-A3B-Instruct-2507": {
|
||||
"deprecation_date": "2026-10-05",
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_input_tokens": 262000,
|
||||
"input_cost_per_token": 1e-07,
|
||||
"output_cost_per_token": 3e-07,
|
||||
"litellm_provider": "wandb",
|
||||
|
|
@ -61187,6 +61210,7 @@
|
|||
"input_cost_per_token": 1.31e-06,
|
||||
"output_cost_per_token": 3.96e-06,
|
||||
"cache_read_input_token_cost": 4.4e-08,
|
||||
"max_input_tokens": 1049000,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
|
|
@ -61197,13 +61221,14 @@
|
|||
"input_cost_per_token": 1e-07,
|
||||
"output_cost_per_token": 1.5e-07,
|
||||
"cache_read_input_token_cost": 5e-08,
|
||||
"max_input_tokens": 131000,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
"wandb/zai-org/GLM-5.2": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_input_tokens": 1049000,
|
||||
"input_cost_per_token": 7.6e-07,
|
||||
"output_cost_per_token": 2.42e-06,
|
||||
"cache_read_input_token_cost": 1.4e-07,
|
||||
|
|
@ -62866,7 +62891,7 @@
|
|||
"max_tokens": 1048576,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 6.6e-06,
|
||||
"source": "https://docs.fireworks.ai/serverless/pricing",
|
||||
"source": "https://api.fireworks.ai/v1/serverless/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
|
|
@ -69158,5 +69183,19 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"wandb/zai-org/GLM-5.3-Flash": {
|
||||
"cache_read_input_token_cost": 5e-08,
|
||||
"input_cost_per_token": 1.5e-07,
|
||||
"litellm_provider": "wandb",
|
||||
"max_input_tokens": 1049000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 5e-07,
|
||||
"source": "https://wandb.ai/site/pricing/tokens/",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1937,6 +1937,14 @@ class ProxyBaseLLMRequestProcessing:
|
|||
) -> tuple[dict, LiteLLMLoggingObj]:
|
||||
start_time: Final = datetime.now() # start before calling guardrail hooks
|
||||
|
||||
requested_model: Final = self.data.get("model")
|
||||
if requested_model is not None and not isinstance(requested_model, str):
|
||||
raise ProxyException(
|
||||
message="'model' must be a string.",
|
||||
type=ProxyErrorTypes.bad_request_error,
|
||||
param="model",
|
||||
code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
self.data = await add_litellm_data_to_request(
|
||||
data=self.data,
|
||||
request=request,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ from litellm.litellm_core_utils.duration_parser import duration_in_seconds
|
|||
from litellm.proxy._types import (
|
||||
DB_RETRY_SAFE_ERROR_TYPES,
|
||||
LiteLLM_BudgetTableFull,
|
||||
LiteLLM_EndUserTable,
|
||||
Litellm_EntityType,
|
||||
LiteLLM_TeamTable,
|
||||
LiteLLM_UserTable,
|
||||
|
|
@ -193,13 +192,6 @@ def _enduser_cache_keys(row: _EndUserRow) -> tuple[str, ...]:
|
|||
return (end_user_cache_key(row.user_id),)
|
||||
|
||||
|
||||
def _enduser_carried_spend(row: _EndUserRow, caps: Mapping[str, float]) -> float:
|
||||
if not caps:
|
||||
return 0.0
|
||||
effective_budget_id: Final[str | None] = row.budget_id or litellm.max_end_user_budget_id
|
||||
return _carried_spend(row.spend, caps.get(effective_budget_id) if effective_budget_id is not None else None)
|
||||
|
||||
|
||||
def _budget_link_where(
|
||||
budget_ids: Sequence[str],
|
||||
extra: Mapping[str, object] = MappingProxyType({}),
|
||||
|
|
@ -207,6 +199,19 @@ def _budget_link_where(
|
|||
return {"budget_id": {"in": list(budget_ids)}, **extra}
|
||||
|
||||
|
||||
def _enduser_invalidation_where(budget_ids: Sequence[str]) -> dict[str, object]:
|
||||
"""Customers whose cached spend a committed reset of these tiers invalidated.
|
||||
|
||||
Mirrors ``_queue_enduser_resets`` without its ``spend > 0`` filter, which
|
||||
post-commit would match nobody.
|
||||
"""
|
||||
linked: Final = _budget_link_where(budget_ids)
|
||||
default_budget_id: Final = litellm.max_end_user_budget_id
|
||||
if default_budget_id is None or default_budget_id not in budget_ids:
|
||||
return linked
|
||||
return {"OR": [linked, {"budget_id": None}]} # mutable-ok: prisma where filter must be a dict
|
||||
|
||||
|
||||
def _queue_budget_linked_resets(
|
||||
writes: LinkedSpendResetWrites,
|
||||
cascade: "_BudgetCascade",
|
||||
|
|
@ -265,16 +270,29 @@ class _BudgetCascade:
|
|||
budgets: tuple[LiteLLM_BudgetTableFull, ...] = ()
|
||||
budget_ids: tuple[str, ...] = ()
|
||||
budget_resets: tuple[tuple[str, datetime], ...] = ()
|
||||
endusers: tuple[_EndUserRow, ...] = ()
|
||||
counter_resets: tuple[tuple[str, float], ...] = ()
|
||||
cache_keys: tuple[str, ...] = ()
|
||||
rollover_caps: Mapping[str, float] = field(default_factory=lambda: MappingProxyType({}))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _EndUserWalk:
|
||||
"""Where the customer walk stands. ``cursor`` is None once it is done, and
|
||||
``truncated`` says a failed page read cut it short of the tail."""
|
||||
|
||||
cursor: str | None = ""
|
||||
invalidated: int = 0
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
_ENDUSER_WALK_DONE: Final = _EndUserWalk(cursor=None)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _BudgetCascadeCommitted:
|
||||
cascade: _BudgetCascade
|
||||
advanced: int
|
||||
endusers: _EndUserWalk
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -285,6 +303,8 @@ class _BudgetCascadeFailed:
|
|||
|
||||
_EMPTY_CASCADE: Final = _BudgetCascade()
|
||||
|
||||
_InvalidatedCache = Literal["spend counter", "user_api_key_cache"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ChunkOutcome:
|
||||
|
|
@ -416,10 +436,12 @@ _WINDOW_SOURCES: Final[tuple[_WindowSource, ...]] = (
|
|||
)
|
||||
|
||||
|
||||
def _budget_cascade_event_metadata(cascade: _BudgetCascade) -> dict[str, object]:
|
||||
def _budget_cascade_event_metadata(
|
||||
cascade: _BudgetCascade, endusers: _EndUserWalk = _ENDUSER_WALK_DONE
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"num_budgets_found": len(cascade.budgets),
|
||||
"num_endusers_found": len(cascade.endusers),
|
||||
"num_endusers_found": endusers.invalidated,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -593,6 +615,38 @@ class ResetBudgetJob:
|
|||
e,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _invalidate_caches(counter_keys: Sequence[str], cache_keys: Sequence[str]) -> None:
|
||||
"""Batch twin of ``_invalidate_spend_counter`` and
|
||||
``_invalidate_user_api_key_cache_entry``, after the commit like both:
|
||||
one round trip per chunk where a tier's dependents are unbounded."""
|
||||
await ResetBudgetJob._invalidate_cache("spend counter", counter_keys)
|
||||
await ResetBudgetJob._invalidate_cache("user_api_key_cache", cache_keys)
|
||||
|
||||
@staticmethod
|
||||
async def _invalidate_cache(cache: _InvalidatedCache, keys: Sequence[str]) -> None:
|
||||
"""One cache's share of a batch, awaited separately so either failing
|
||||
still leaves the other invalidated."""
|
||||
if not keys:
|
||||
return
|
||||
try:
|
||||
from litellm.proxy.proxy_server import spend_counter_cache, user_api_key_cache
|
||||
|
||||
match cache:
|
||||
case "spend counter":
|
||||
await spend_counter_cache.async_delete_cache_keys(keys)
|
||||
case "user_api_key_cache":
|
||||
await user_api_key_cache.async_delete_cache_keys(keys)
|
||||
case _:
|
||||
assert_never(cache)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.warning(
|
||||
"Failed to invalidate %d %s entries: %s. Budgets may be over-enforced until they expire.",
|
||||
len(keys),
|
||||
cache,
|
||||
e,
|
||||
)
|
||||
|
||||
async def _fetch_linked_rows(
|
||||
self,
|
||||
table: SpendLinkedTable[_RowT],
|
||||
|
|
@ -612,18 +666,57 @@ class ResetBudgetJob:
|
|||
verbose_proxy_logger.warning("Failed to fetch %s for counter invalidation: %s", log_subject, e)
|
||||
return ()
|
||||
|
||||
async def _collect_endusers_to_reset(self, budget_ids: Sequence[str]) -> tuple[_EndUserRow, ...]:
|
||||
linked: Final[Sequence[_EndUserRow] | None] = await self._with_db_retry(
|
||||
lambda: self.prisma_client.get_data(
|
||||
table_name="enduser",
|
||||
query_type="find_all",
|
||||
budget_id_list=list(budget_ids),
|
||||
),
|
||||
reason="reset_budget_read_endusers_failure",
|
||||
async def _invalidate_enduser_caches(self, budget_ids: Sequence[str]) -> _EndUserWalk:
|
||||
"""Drop the cached spend of every customer the committed tier reset zeroed.
|
||||
|
||||
Paged like ``_reset_windows_for``, and capless for its reason too: the
|
||||
customers on one tier are unbounded, and a cap cannot keep its position
|
||||
across pod elections, so it would restart at the first customer forever.
|
||||
"""
|
||||
if not budget_ids:
|
||||
return _ENDUSER_WALK_DONE
|
||||
where: Final = _enduser_invalidation_where(budget_ids)
|
||||
walk = _EndUserWalk()
|
||||
while walk.cursor is not None:
|
||||
walk = await self._invalidate_enduser_page(where=where, cursor=walk.cursor, reached=walk.invalidated)
|
||||
return walk
|
||||
|
||||
async def _invalidate_enduser_page(self, where: Mapping[str, object], cursor: str, reached: int) -> _EndUserWalk:
|
||||
"""Invalidate one page of customers and say where the walk goes next."""
|
||||
try:
|
||||
rows: Final = await self._fetch_enduser_page(where=where, cursor=cursor)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.warning(
|
||||
"Failed to fetch end users for cache invalidation after %s customers (cursor %r): %s. "
|
||||
"The customers past that page keep their cached spend until it expires.",
|
||||
reached,
|
||||
cursor,
|
||||
e,
|
||||
)
|
||||
return _EndUserWalk(cursor=None, invalidated=reached, truncated=True)
|
||||
if not rows:
|
||||
return _EndUserWalk(cursor=None, invalidated=reached)
|
||||
await self._invalidate_caches(
|
||||
counter_keys=tuple(_enduser_counter_key(row) for row in rows),
|
||||
cache_keys=tuple(key for row in rows for key in _enduser_cache_keys(row)),
|
||||
)
|
||||
walked: Final = reached + len(rows)
|
||||
if len(rows) < RESET_BUDGET_JOB_BATCH_SIZE:
|
||||
return _EndUserWalk(cursor=None, invalidated=walked)
|
||||
return _EndUserWalk(cursor=rows[-1].user_id, invalidated=walked)
|
||||
|
||||
async def _fetch_enduser_page(self, where: Mapping[str, object], cursor: str) -> tuple[_EndUserRow, ...]:
|
||||
"""One keyset page of customers, ordered by primary key so the cursor never repeats a row."""
|
||||
return tuple(
|
||||
await self._with_db_retry(
|
||||
lambda: EndUserRepository(self.prisma_client).table.find_many(
|
||||
where={**where, "user_id": {"gt": cursor}}, # mutable-ok: prisma where filter must be a dict
|
||||
order={"user_id": "asc"}, # mutable-ok: prisma order filter must be a dict
|
||||
take=RESET_BUDGET_JOB_BATCH_SIZE,
|
||||
),
|
||||
reason="reset_budget_read_endusers_failure",
|
||||
)
|
||||
)
|
||||
if litellm.max_end_user_budget_id is None or litellm.max_end_user_budget_id not in budget_ids:
|
||||
return tuple(linked or ())
|
||||
return (*(linked or ()), *await self._get_endusers_with_no_budget_id())
|
||||
|
||||
async def _collect_budget_cascade(self, budgets_to_reset: Sequence[LiteLLM_BudgetTableFull]) -> _BudgetCascade:
|
||||
"""Resolve every row the expiring budget tiers gate, before any write.
|
||||
|
|
@ -670,7 +763,6 @@ class ResetBudgetJob:
|
|||
if _rollover_enabled()
|
||||
else {} # mutable-ok: empty sentinel immediately frozen by MappingProxyType
|
||||
)
|
||||
endusers: Final[tuple[_EndUserRow, ...]] = await self._collect_endusers_to_reset(budget_ids)
|
||||
return _BudgetCascade(
|
||||
budgets=tuple(budgets_to_reset),
|
||||
budget_ids=budget_ids,
|
||||
|
|
@ -682,7 +774,6 @@ class ResetBudgetJob:
|
|||
for b in budgets_to_reset
|
||||
if b.budget_id is not None and b.budget_duration is not None
|
||||
),
|
||||
endusers=endusers,
|
||||
counter_resets=(
|
||||
*(
|
||||
(_team_membership_counter_key(row), _row_carried_spend(row, rollover_caps))
|
||||
|
|
@ -695,7 +786,6 @@ class ResetBudgetJob:
|
|||
(_model_access_group_counter_key(row), _row_carried_spend(row, rollover_caps))
|
||||
for row in model_access_groups
|
||||
),
|
||||
*((_enduser_counter_key(row), _enduser_carried_spend(row, rollover_caps)) for row in endusers),
|
||||
),
|
||||
rollover_caps=rollover_caps,
|
||||
cache_keys=(
|
||||
|
|
@ -704,7 +794,6 @@ class ResetBudgetJob:
|
|||
*(key for row in orgs for key in _org_cache_keys(row)),
|
||||
*(key for row in tags for key in _tag_cache_keys(row)),
|
||||
*(key for row in model_access_groups for key in _model_access_group_cache_keys(row)),
|
||||
*(key for row in endusers for key in _enduser_cache_keys(row)),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -736,10 +825,10 @@ class ResetBudgetJob:
|
|||
uow.budgets.queue_window_advance(budget_id=budget_id, budget_reset_at=budget_reset_at)
|
||||
|
||||
async def _invalidate_budget_cascade_caches(self, cascade: _BudgetCascade) -> None:
|
||||
for counter_key, _ in cascade.counter_resets:
|
||||
await self._invalidate_spend_counter(counter_key)
|
||||
for cache_key in cascade.cache_keys:
|
||||
await self._invalidate_user_api_key_cache_entry(cache_key)
|
||||
await self._invalidate_caches(
|
||||
counter_keys=tuple(counter_key for counter_key, _ in cascade.counter_resets),
|
||||
cache_keys=cascade.cache_keys,
|
||||
)
|
||||
|
||||
async def _reset_expired_budget_cascade(self) -> _BudgetCascadeCommitted | _BudgetCascadeFailed:
|
||||
now: Final = datetime.now(timezone.utc)
|
||||
|
|
@ -769,6 +858,7 @@ class ResetBudgetJob:
|
|||
(reset_at for _, reset_at in cascade.budget_resets),
|
||||
cutoff=datetime.now(timezone.utc),
|
||||
),
|
||||
endusers=await self._invalidate_enduser_caches(cascade.budget_ids),
|
||||
)
|
||||
|
||||
async def reset_budget_for_litellm_budget_table(self) -> None:
|
||||
|
|
@ -788,7 +878,7 @@ class ResetBudgetJob:
|
|||
end_time: Final = time.time()
|
||||
|
||||
match outcome:
|
||||
case _BudgetCascadeCommitted(cascade=cascade, advanced=advanced):
|
||||
case _BudgetCascadeCommitted() as committed:
|
||||
asyncio.create_task(
|
||||
self.proxy_logging_obj.service_logging_obj.async_service_success_hook(
|
||||
service=ServiceTypes.RESET_BUDGET_JOB,
|
||||
|
|
@ -797,13 +887,14 @@ class ResetBudgetJob:
|
|||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
event_metadata={
|
||||
**_budget_cascade_event_metadata(cascade),
|
||||
"num_endusers_updated": len(cascade.endusers),
|
||||
**_budget_cascade_event_metadata(committed.cascade, committed.endusers),
|
||||
"num_endusers_updated": committed.endusers.invalidated,
|
||||
"num_endusers_failed": 0,
|
||||
"enduser_invalidation_truncated": committed.endusers.truncated,
|
||||
},
|
||||
)
|
||||
)
|
||||
return _ChunkOutcome(fetched=len(cascade.budgets), advanced=advanced)
|
||||
return _ChunkOutcome(fetched=len(committed.cascade.budgets), advanced=committed.advanced)
|
||||
case _BudgetCascadeFailed(cascade=cascade, error=error):
|
||||
verbose_proxy_logger.exception(
|
||||
"Failed to reset the budget table cascade (team member, enduser, org, tag and model access "
|
||||
|
|
@ -827,27 +918,6 @@ class ResetBudgetJob:
|
|||
case _:
|
||||
assert_never(outcome)
|
||||
|
||||
async def _get_endusers_with_no_budget_id(
|
||||
self,
|
||||
) -> list[LiteLLM_EndUserTable]:
|
||||
"""
|
||||
Fetch end users that have no explicit budget_id set (NULL) and have
|
||||
accumulated spend > 0. These are implicitly-created end users that
|
||||
rely on the default budget (litellm.max_end_user_budget_id) applied
|
||||
in-memory during auth checks.
|
||||
"""
|
||||
table: Final = EndUserRepository(self.prisma_client).table
|
||||
rows: Final = await self._with_db_retry(
|
||||
lambda: table.find_many(
|
||||
where={
|
||||
"budget_id": None,
|
||||
"spend": {"gt": 0},
|
||||
},
|
||||
),
|
||||
reason="reset_budget_read_endusers_without_budget_id_failure",
|
||||
)
|
||||
return [LiteLLM_EndUserTable.model_validate(row.model_dump()) for row in rows]
|
||||
|
||||
async def _write_key_reset_updates(self, updated_keys: Sequence[_RowReset[LiteLLM_VerificationToken]]) -> None:
|
||||
"""
|
||||
Write per-row {spend, budget_reset_at} updates for keys.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from typing import TYPE_CHECKING, Any, Final, TypeVar, cast, overload
|
||||
|
|
@ -221,6 +222,24 @@ class UserApiKeyCache(DualCache):
|
|||
return
|
||||
await super().async_delete_cache(key)
|
||||
|
||||
async def async_delete_cache_keys(self, keys: Sequence[str]) -> None:
|
||||
"""Batch twin of ``async_delete_cache``, partitioned like
|
||||
``async_set_cache_pipeline``.
|
||||
|
||||
Both partitions are cleared even when one raises, because a caller
|
||||
batching these has already committed the rows they cache.
|
||||
"""
|
||||
key_object_keys: Final = tuple(key for key in keys if is_user_key_cache_key(key))
|
||||
other_keys: Final = tuple(key for key in keys if not is_user_key_cache_key(key))
|
||||
outcomes: Final = await asyncio.gather(
|
||||
self.key_object_cache.async_delete_cache_keys(key_object_keys),
|
||||
super().async_delete_cache_keys(other_keys),
|
||||
return_exceptions=True,
|
||||
)
|
||||
failed: Final = tuple(outcome for outcome in outcomes if isinstance(outcome, BaseException))
|
||||
if failed:
|
||||
raise failed[0]
|
||||
|
||||
def flush_cache(self) -> None:
|
||||
super().flush_cache()
|
||||
self.key_object_cache.in_memory_cache.flush_cache()
|
||||
|
|
|
|||
|
|
@ -678,6 +678,7 @@ model LiteLLM_SpendLogs {
|
|||
@@index([end_user])
|
||||
@@index([session_id])
|
||||
@@index([litellm_call_id])
|
||||
@@index([api_key, startTime])
|
||||
}
|
||||
|
||||
model LiteLLM_BudgetWindowSpend {
|
||||
|
|
|
|||
|
|
@ -485,10 +485,13 @@ def get_logging_payload(
|
|||
or None
|
||||
)
|
||||
custom_llm_provider: Final = logged_provider or _model_group_provider(_model_group, llm_router)
|
||||
raw_model: Final = cast(str, kwargs.get("model") or "")
|
||||
resolved_model: Final = (
|
||||
standard_logging_payload.get("model") if standard_logging_payload is not None else None
|
||||
) or reconstruct_model_name(raw_model, logged_provider, metadata or {})
|
||||
requested_model: Final = cast(object, kwargs.get("model"))
|
||||
raw_model: Final = requested_model if isinstance(requested_model, str) else ""
|
||||
model_is_malformed: Final = requested_model is not None and not isinstance(requested_model, str)
|
||||
logged_model: Final = standard_logging_payload.get("model") if standard_logging_payload is not None else None
|
||||
resolved_model: Final = (logged_model if isinstance(logged_model, str) else None) or reconstruct_model_name(
|
||||
raw_model, logged_provider, metadata or {}
|
||||
)
|
||||
failed_with_prompt_shaped_model: Final = (
|
||||
_get_status_for_spend_log(metadata=metadata) == "failure"
|
||||
and not _model_group
|
||||
|
|
@ -496,7 +499,7 @@ def get_logging_payload(
|
|||
)
|
||||
model_name: Final = (
|
||||
UNKNOWN_MODEL_SPEND_LOG_MODEL
|
||||
if rejected_as_unknown_model or failed_with_prompt_shaped_model
|
||||
if rejected_as_unknown_model or failed_with_prompt_shaped_model or model_is_malformed
|
||||
else resolved_model
|
||||
)
|
||||
litellm_call_id: Final = cast(
|
||||
|
|
|
|||
|
|
@ -2916,6 +2916,15 @@ def supports_none_reasoning_effort(model: str, custom_llm_provider: str | None =
|
|||
return _supports_factory(model=model, custom_llm_provider=custom_llm_provider, key="supports_none_reasoning_effort")
|
||||
|
||||
|
||||
def supports_mid_conversation_system(model: str, custom_llm_provider: str | None = None) -> bool:
|
||||
"""
|
||||
Check if the given model accepts a system role message after the leading system block and return a boolean value.
|
||||
"""
|
||||
return _supports_factory(
|
||||
model=model, custom_llm_provider=custom_llm_provider, key="supports_mid_conversation_system"
|
||||
)
|
||||
|
||||
|
||||
def supports_native_structured_output(model: str, custom_llm_provider: str | None = None) -> bool:
|
||||
"""
|
||||
Check if the given model supports native structured outputs and return a boolean value.
|
||||
|
|
|
|||
|
|
@ -7605,7 +7605,7 @@
|
|||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"source": "https://learn.microsoft.com/en-us/azure/ai-foundry/openai/concepts/models",
|
||||
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/batch",
|
||||
|
|
@ -7733,7 +7733,7 @@
|
|||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"source": "https://learn.microsoft.com/en-us/azure/ai-foundry/openai/concepts/models",
|
||||
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/batch",
|
||||
|
|
@ -7887,7 +7887,7 @@
|
|||
"supports_none_reasoning_effort": true,
|
||||
"supports_xhigh_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": false,
|
||||
"source": "https://learn.microsoft.com/en-us/azure/ai-foundry/openai/concepts/models"
|
||||
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
|
||||
},
|
||||
"azure/gpt-6-astra": {
|
||||
"cache_creation_input_token_cost": 1.25e-05,
|
||||
|
|
@ -7956,7 +7956,7 @@
|
|||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"source": "https://learn.microsoft.com/en-us/azure/ai-foundry/openai/concepts/models",
|
||||
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'",
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/responses"
|
||||
|
|
@ -8856,7 +8856,7 @@
|
|||
"supports_none_reasoning_effort": true,
|
||||
"supports_xhigh_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": false,
|
||||
"source": "https://learn.microsoft.com/en-us/azure/ai-foundry/openai/concepts/models"
|
||||
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
|
||||
},
|
||||
"azure/us/gpt-5.5-2026-04-23": {
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
|
|
@ -8955,7 +8955,7 @@
|
|||
"supports_none_reasoning_effort": true,
|
||||
"supports_xhigh_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": false,
|
||||
"source": "https://learn.microsoft.com/en-us/azure/ai-foundry/openai/concepts/models"
|
||||
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
|
||||
},
|
||||
"azure/eu/gpt-5.5-2026-04-23": {
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
|
|
@ -9054,7 +9054,7 @@
|
|||
"supports_none_reasoning_effort": true,
|
||||
"supports_xhigh_reasoning_effort": true,
|
||||
"supports_minimal_reasoning_effort": false,
|
||||
"source": "https://learn.microsoft.com/en-us/azure/ai-foundry/openai/concepts/models"
|
||||
"source": "https://prices.azure.com/api/retail/prices?$filter=serviceName%20eq%20'Foundry%20Models'%20and%20armRegionName%20eq%20'eastus'%20and%20priceType%20eq%20'Consumption'"
|
||||
},
|
||||
"azure/gpt-5.5-pro": {
|
||||
"cache_read_input_token_cost": 3e-06,
|
||||
|
|
@ -10987,14 +10987,14 @@
|
|||
"supports_vision": true
|
||||
},
|
||||
"azure_ai/FW-Kimi-K3": {
|
||||
"cache_read_input_token_cost": 3.3e-07,
|
||||
"input_cost_per_token": 3.3e-06,
|
||||
"cache_read_input_token_cost": 3e-07,
|
||||
"input_cost_per_token": 3e-06,
|
||||
"litellm_provider": "azure_ai",
|
||||
"max_input_tokens": 1048576,
|
||||
"max_output_tokens": 131072,
|
||||
"max_tokens": 131072,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 1.65e-05,
|
||||
"output_cost_per_token": 1.5e-05,
|
||||
"reasoning_effort_levels": [
|
||||
"low",
|
||||
"high",
|
||||
|
|
@ -23788,7 +23788,7 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": false
|
||||
},
|
||||
"fireworks_ai/accounts/fireworks/models/mixtral-8x22b-instruct-hf": {
|
||||
"input_cost_per_token": 1.2e-06,
|
||||
|
|
@ -24114,7 +24114,7 @@
|
|||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
"supports_vision": false
|
||||
},
|
||||
"fireworks_ai/qwen3p7-plus": {
|
||||
"cache_read_input_token_cost": 8e-08,
|
||||
|
|
@ -45245,7 +45245,7 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"together_ai/openai/gpt-oss-20b": {
|
||||
"deprecation_date": "2026-09-15",
|
||||
"deprecation_date": "2026-09-14",
|
||||
"input_cost_per_token": 5e-08,
|
||||
"litellm_provider": "together_ai",
|
||||
"max_input_tokens": 131072,
|
||||
|
|
@ -45482,6 +45482,7 @@
|
|||
},
|
||||
"together_ai/deepseek-ai/DeepSeek-V4-Flash-0731": {
|
||||
"cache_read_input_token_cost": 3e-08,
|
||||
"deprecation_date": "2026-09-29",
|
||||
"input_cost_per_token": 1.4e-07,
|
||||
"litellm_provider": "together_ai",
|
||||
"max_input_tokens": 1048576,
|
||||
|
|
@ -45503,7 +45504,7 @@
|
|||
"max_tokens": 1048576,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 1.2e-06,
|
||||
"source": "https://api.together.xyz/v1/models",
|
||||
"source": "https://api.together.ai/v1/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_response_schema": true,
|
||||
|
|
@ -45528,6 +45529,7 @@
|
|||
},
|
||||
"together_ai/deepseek-ai/DeepSeek-V4-Pro-0813": {
|
||||
"cache_read_input_token_cost": 1.3e-07,
|
||||
"deprecation_date": "2026-09-29",
|
||||
"input_cost_per_token": 1.32e-06,
|
||||
"litellm_provider": "together_ai",
|
||||
"max_input_tokens": 1048576,
|
||||
|
|
@ -45552,7 +45554,7 @@
|
|||
"source": "https://docs.together.ai/docs/serverless-models"
|
||||
},
|
||||
"together_ai/google/gemma-4-31B-it": {
|
||||
"deprecation_date": "2026-09-15",
|
||||
"deprecation_date": "2026-09-14",
|
||||
"input_cost_per_token": 3.9e-07,
|
||||
"litellm_provider": "together_ai",
|
||||
"max_input_tokens": 262144,
|
||||
|
|
@ -45567,7 +45569,7 @@
|
|||
"supports_vision": true
|
||||
},
|
||||
"together_ai/intfloat/multilingual-e5-large-instruct": {
|
||||
"deprecation_date": "2026-09-15",
|
||||
"deprecation_date": "2026-09-14",
|
||||
"input_cost_per_token": 2e-08,
|
||||
"litellm_provider": "together_ai",
|
||||
"max_input_tokens": 514,
|
||||
|
|
@ -45680,7 +45682,7 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"together_ai/thinkingmachines/Inkling-Small": {
|
||||
"deprecation_date": "2026-09-15",
|
||||
"deprecation_date": "2026-09-14",
|
||||
"cache_read_input_token_cost": 1e-07,
|
||||
"input_cost_per_token": 5e-07,
|
||||
"litellm_provider": "together_ai",
|
||||
|
|
@ -50573,7 +50575,7 @@
|
|||
"wandb/openai/gpt-oss-120b": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 131072,
|
||||
"max_input_tokens": 131072,
|
||||
"max_input_tokens": 131000,
|
||||
"max_output_tokens": 131072,
|
||||
"input_cost_per_token": 3e-08,
|
||||
"output_cost_per_token": 1.7e-07,
|
||||
|
|
@ -50584,7 +50586,7 @@
|
|||
"wandb/openai/gpt-oss-20b": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 131072,
|
||||
"max_input_tokens": 131072,
|
||||
"max_input_tokens": 131000,
|
||||
"max_output_tokens": 131072,
|
||||
"input_cost_per_token": 3e-08,
|
||||
"output_cost_per_token": 1.3e-07,
|
||||
|
|
@ -50593,6 +50595,7 @@
|
|||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
"wandb/zai-org/GLM-4.5": {
|
||||
"deprecation_date": "2026-03-04",
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 131072,
|
||||
"max_input_tokens": 131072,
|
||||
|
|
@ -50603,6 +50606,7 @@
|
|||
"mode": "chat"
|
||||
},
|
||||
"wandb/Qwen/Qwen3-235B-A22B-Instruct-2507": {
|
||||
"deprecation_date": "2026-08-04",
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_output_tokens": 262144,
|
||||
|
|
@ -50612,6 +50616,7 @@
|
|||
"mode": "chat"
|
||||
},
|
||||
"wandb/Qwen/Qwen3-Coder-480B-A35B-Instruct": {
|
||||
"deprecation_date": "2026-08-25",
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_output_tokens": 262144,
|
||||
|
|
@ -50622,6 +50627,7 @@
|
|||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
"wandb/Qwen/Qwen3-235B-A22B-Thinking-2507": {
|
||||
"deprecation_date": "2026-08-04",
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
|
|
@ -50632,6 +50638,7 @@
|
|||
"mode": "chat"
|
||||
},
|
||||
"wandb/moonshotai/Kimi-K2-Instruct": {
|
||||
"deprecation_date": "2026-03-04",
|
||||
"max_tokens": 128000,
|
||||
"max_input_tokens": 128000,
|
||||
"max_output_tokens": 128000,
|
||||
|
|
@ -50656,6 +50663,7 @@
|
|||
"supports_vision": true
|
||||
},
|
||||
"wandb/MiniMaxAI/MiniMax-M2.5": {
|
||||
"deprecation_date": "2026-08-25",
|
||||
"max_tokens": 197000,
|
||||
"max_input_tokens": 197000,
|
||||
"max_output_tokens": 197000,
|
||||
|
|
@ -50670,7 +50678,7 @@
|
|||
},
|
||||
"wandb/meta-llama/Llama-3.1-8B-Instruct": {
|
||||
"max_tokens": 128000,
|
||||
"max_input_tokens": 128000,
|
||||
"max_input_tokens": 131000,
|
||||
"max_output_tokens": 128000,
|
||||
"input_cost_per_token": 2.2e-07,
|
||||
"output_cost_per_token": 2.2e-07,
|
||||
|
|
@ -50690,6 +50698,7 @@
|
|||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
"wandb/deepseek-ai/DeepSeek-R1-0528": {
|
||||
"deprecation_date": "2026-03-04",
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 161000,
|
||||
"max_input_tokens": 161000,
|
||||
|
|
@ -50700,6 +50709,7 @@
|
|||
"mode": "chat"
|
||||
},
|
||||
"wandb/deepseek-ai/DeepSeek-V3-0324": {
|
||||
"deprecation_date": "2026-03-04",
|
||||
"max_tokens": 161000,
|
||||
"max_input_tokens": 161000,
|
||||
"max_output_tokens": 161000,
|
||||
|
|
@ -50719,6 +50729,7 @@
|
|||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
"wandb/meta-llama/Llama-4-Scout-17B-16E-Instruct": {
|
||||
"deprecation_date": "2026-04-21",
|
||||
"max_tokens": 64000,
|
||||
"max_input_tokens": 64000,
|
||||
"max_output_tokens": 64000,
|
||||
|
|
@ -50728,6 +50739,7 @@
|
|||
"mode": "chat"
|
||||
},
|
||||
"wandb/microsoft/Phi-4-mini-instruct": {
|
||||
"deprecation_date": "2026-08-04",
|
||||
"max_tokens": 128000,
|
||||
"max_input_tokens": 128000,
|
||||
"max_output_tokens": 128000,
|
||||
|
|
@ -56692,7 +56704,8 @@
|
|||
"supports_function_calling": true,
|
||||
"supports_vision": true,
|
||||
"supports_web_search": true,
|
||||
"gemini_audio_only_live": true
|
||||
"gemini_audio_only_live": true,
|
||||
"supports_response_schema": false
|
||||
},
|
||||
"gemini-3.8-live-extended-thinking": {
|
||||
"input_cost_per_audio_token": 3e-06,
|
||||
|
|
@ -56726,7 +56739,8 @@
|
|||
"supports_vision": true,
|
||||
"supports_web_search": true,
|
||||
"gemini_audio_only_live": true,
|
||||
"supports_reasoning": true
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": false
|
||||
},
|
||||
"gemini/gemini-2.5-flash-native-audio-latest": {
|
||||
"input_cost_per_audio_token": 3e-06,
|
||||
|
|
@ -60970,10 +60984,11 @@
|
|||
"wandb/deepseek-ai/DeepSeek-V4-Flash": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 1048576,
|
||||
"max_input_tokens": 1048576,
|
||||
"max_input_tokens": 1049000,
|
||||
"input_cost_per_token": 1.4e-07,
|
||||
"output_cost_per_token": 2.8e-07,
|
||||
"cache_read_input_token_cost": 7e-08,
|
||||
"deprecation_date": "2026-10-05",
|
||||
"supports_prompt_caching": true,
|
||||
"litellm_provider": "wandb",
|
||||
"mode": "chat",
|
||||
|
|
@ -60983,7 +60998,7 @@
|
|||
"wandb/deepseek-ai/DeepSeek-V4-Flash-0731": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_input_tokens": 262000,
|
||||
"input_cost_per_token": 1.3e-07,
|
||||
"output_cost_per_token": 2.8e-07,
|
||||
"cache_read_input_token_cost": 7e-08,
|
||||
|
|
@ -60996,10 +61011,11 @@
|
|||
"wandb/deepseek-ai/DeepSeek-V4-Pro": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 1048576,
|
||||
"max_input_tokens": 1048576,
|
||||
"max_input_tokens": 1049000,
|
||||
"input_cost_per_token": 1.15e-06,
|
||||
"output_cost_per_token": 2.55e-06,
|
||||
"cache_read_input_token_cost": 2e-07,
|
||||
"deprecation_date": "2026-10-05",
|
||||
"supports_prompt_caching": true,
|
||||
"litellm_provider": "wandb",
|
||||
"mode": "chat",
|
||||
|
|
@ -61009,7 +61025,7 @@
|
|||
"wandb/google/gemma-4-31B-it": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_input_tokens": 262000,
|
||||
"input_cost_per_token": 1e-07,
|
||||
"output_cost_per_token": 3.4e-07,
|
||||
"litellm_provider": "wandb",
|
||||
|
|
@ -61018,8 +61034,9 @@
|
|||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
"wandb/ibm-granite/granite-4.1-8b": {
|
||||
"deprecation_date": "2026-10-05",
|
||||
"max_tokens": 131072,
|
||||
"max_input_tokens": 131072,
|
||||
"max_input_tokens": 131000,
|
||||
"input_cost_per_token": 5e-08,
|
||||
"output_cost_per_token": 1e-07,
|
||||
"litellm_provider": "wandb",
|
||||
|
|
@ -61028,8 +61045,9 @@
|
|||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
"wandb/JetBrains/Mellum2-12B-A2.5B-Instruct": {
|
||||
"deprecation_date": "2026-10-05",
|
||||
"max_tokens": 131072,
|
||||
"max_input_tokens": 131072,
|
||||
"max_input_tokens": 131000,
|
||||
"input_cost_per_token": 5e-08,
|
||||
"output_cost_per_token": 1e-07,
|
||||
"litellm_provider": "wandb",
|
||||
|
|
@ -61038,8 +61056,9 @@
|
|||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
"wandb/meta-llama/Llama-3.1-70B-Instruct": {
|
||||
"deprecation_date": "2026-10-05",
|
||||
"max_tokens": 128000,
|
||||
"max_input_tokens": 128000,
|
||||
"max_input_tokens": 131000,
|
||||
"input_cost_per_token": 8e-07,
|
||||
"output_cost_per_token": 8e-07,
|
||||
"litellm_provider": "wandb",
|
||||
|
|
@ -61050,7 +61069,7 @@
|
|||
"wandb/MiniMaxAI/MiniMax-M3": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_input_tokens": 262000,
|
||||
"input_cost_per_token": 2.3e-07,
|
||||
"output_cost_per_token": 9.6e-07,
|
||||
"cache_read_input_token_cost": 5e-08,
|
||||
|
|
@ -61063,7 +61082,7 @@
|
|||
"wandb/moonshotai/Kimi-K2.7-Code": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_input_tokens": 262000,
|
||||
"input_cost_per_token": 7.1e-07,
|
||||
"output_cost_per_token": 3.5e-06,
|
||||
"cache_read_input_token_cost": 1.5e-07,
|
||||
|
|
@ -61076,7 +61095,7 @@
|
|||
"wandb/moonshotai/Kimi-K2.6": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_input_tokens": 262000,
|
||||
"input_cost_per_token": 6.5e-07,
|
||||
"output_cost_per_token": 3.41e-06,
|
||||
"cache_read_input_token_cost": 1.5e-07,
|
||||
|
|
@ -61089,10 +61108,10 @@
|
|||
"wandb/nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"input_cost_per_token": 1e-07,
|
||||
"output_cost_per_token": 2.5e-07,
|
||||
"cache_read_input_token_cost": 5e-08,
|
||||
"max_input_tokens": 262000,
|
||||
"input_cost_per_token": 7e-08,
|
||||
"output_cost_per_token": 2e-07,
|
||||
"cache_read_input_token_cost": 4e-08,
|
||||
"supports_prompt_caching": true,
|
||||
"litellm_provider": "wandb",
|
||||
"mode": "chat",
|
||||
|
|
@ -61102,10 +61121,10 @@
|
|||
"wandb/nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"input_cost_per_token": 7.5e-07,
|
||||
"output_cost_per_token": 2.75e-06,
|
||||
"cache_read_input_token_cost": 1.5e-07,
|
||||
"max_input_tokens": 262000,
|
||||
"input_cost_per_token": 5e-07,
|
||||
"output_cost_per_token": 2.15e-06,
|
||||
"cache_read_input_token_cost": 1e-07,
|
||||
"supports_prompt_caching": true,
|
||||
"litellm_provider": "wandb",
|
||||
"mode": "chat",
|
||||
|
|
@ -61113,8 +61132,9 @@
|
|||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
"wandb/OpenPipe/Qwen3-14B-Instruct": {
|
||||
"deprecation_date": "2026-10-05",
|
||||
"max_tokens": 32768,
|
||||
"max_input_tokens": 32768,
|
||||
"max_input_tokens": 32800,
|
||||
"input_cost_per_token": 5e-08,
|
||||
"output_cost_per_token": 2.2e-07,
|
||||
"litellm_provider": "wandb",
|
||||
|
|
@ -61125,7 +61145,7 @@
|
|||
"wandb/Qwen/Qwen3.8-27B": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_input_tokens": 262000,
|
||||
"input_cost_per_token": 4e-07,
|
||||
"output_cost_per_token": 3e-06,
|
||||
"cache_read_input_token_cost": 1.5e-07,
|
||||
|
|
@ -61138,7 +61158,7 @@
|
|||
"wandb/Qwen/Qwen3.6-35B-A3B": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_input_tokens": 262000,
|
||||
"input_cost_per_token": 2.5e-07,
|
||||
"output_cost_per_token": 1.25e-06,
|
||||
"litellm_provider": "wandb",
|
||||
|
|
@ -61149,10 +61169,11 @@
|
|||
"wandb/Qwen/Qwen3.6-27B": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_input_tokens": 262000,
|
||||
"input_cost_per_token": 6e-07,
|
||||
"output_cost_per_token": 3.6e-06,
|
||||
"cache_read_input_token_cost": 1.2e-07,
|
||||
"deprecation_date": "2026-10-05",
|
||||
"supports_prompt_caching": true,
|
||||
"litellm_provider": "wandb",
|
||||
"mode": "chat",
|
||||
|
|
@ -61160,9 +61181,10 @@
|
|||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
"wandb/Qwen/Qwen3.5-35B-A3B": {
|
||||
"deprecation_date": "2026-10-05",
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_input_tokens": 262000,
|
||||
"input_cost_per_token": 2.5e-07,
|
||||
"output_cost_per_token": 1.25e-06,
|
||||
"litellm_provider": "wandb",
|
||||
|
|
@ -61171,8 +61193,9 @@
|
|||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
"wandb/Qwen/Qwen3-30B-A3B-Instruct-2507": {
|
||||
"deprecation_date": "2026-10-05",
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_input_tokens": 262000,
|
||||
"input_cost_per_token": 1e-07,
|
||||
"output_cost_per_token": 3e-07,
|
||||
"litellm_provider": "wandb",
|
||||
|
|
@ -61187,6 +61210,7 @@
|
|||
"input_cost_per_token": 1.31e-06,
|
||||
"output_cost_per_token": 3.96e-06,
|
||||
"cache_read_input_token_cost": 4.4e-08,
|
||||
"max_input_tokens": 1049000,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
|
|
@ -61197,13 +61221,14 @@
|
|||
"input_cost_per_token": 1e-07,
|
||||
"output_cost_per_token": 1.5e-07,
|
||||
"cache_read_input_token_cost": 5e-08,
|
||||
"max_input_tokens": 131000,
|
||||
"supports_prompt_caching": true,
|
||||
"source": "https://wandb.ai/site/pricing/tokens/"
|
||||
},
|
||||
"wandb/zai-org/GLM-5.2": {
|
||||
"supports_reasoning": true,
|
||||
"max_tokens": 262144,
|
||||
"max_input_tokens": 262144,
|
||||
"max_input_tokens": 1049000,
|
||||
"input_cost_per_token": 7.6e-07,
|
||||
"output_cost_per_token": 2.42e-06,
|
||||
"cache_read_input_token_cost": 1.4e-07,
|
||||
|
|
@ -62866,7 +62891,7 @@
|
|||
"max_tokens": 1048576,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 6.6e-06,
|
||||
"source": "https://docs.fireworks.ai/serverless/pricing",
|
||||
"source": "https://api.fireworks.ai/v1/serverless/models",
|
||||
"supports_function_calling": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
|
|
@ -69158,5 +69183,19 @@
|
|||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"wandb/zai-org/GLM-5.3-Flash": {
|
||||
"cache_read_input_token_cost": 5e-08,
|
||||
"input_cost_per_token": 1.5e-07,
|
||||
"litellm_provider": "wandb",
|
||||
"max_input_tokens": 1049000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 5e-07,
|
||||
"source": "https://wandb.ai/site/pricing/tokens/",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_tool_choice": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ proxy = [
|
|||
"azure-identity>=1.25.2,<2.0",
|
||||
"azure-storage-blob>=12.28.0,<13.0",
|
||||
"mcp>=1.28.1,<2.0",
|
||||
"litellm-proxy-extras==0.4.98",
|
||||
"litellm-proxy-extras==0.4.99",
|
||||
"litellm-enterprise==0.1.68",
|
||||
"RestrictedPython>=8.5,<9.0",
|
||||
"rich>=13.9.4,<14.0",
|
||||
|
|
@ -143,8 +143,9 @@ bedrock-realtime = [
|
|||
# InvokeModelWithBidirectionalStream API, which boto3 cannot do. This
|
||||
# experimental AWS SDK (with its smithy-* deps, pulled transitively)
|
||||
# provides the bidirectional stream; imported lazily in the realtime
|
||||
# handler so litellm core stays usable without it.
|
||||
"aws-sdk-bedrock-runtime>=0.7.0,<0.8.0; python_version >= '3.12'",
|
||||
# handler so litellm core stays usable without it. The awscrt extra is
|
||||
# required: the SDK's default aiohttp transport has no duplex streaming.
|
||||
"aws-sdk-bedrock-runtime[awscrt]>=0.10.0,<0.12.0; python_version >= '3.12'",
|
||||
]
|
||||
proxy-runtime = [
|
||||
# Historically bundled in the proxy Docker images via requirements.txt.
|
||||
|
|
|
|||
|
|
@ -678,6 +678,7 @@ model LiteLLM_SpendLogs {
|
|||
@@index([end_user])
|
||||
@@index([session_id])
|
||||
@@index([litellm_call_id])
|
||||
@@index([api_key, startTime])
|
||||
}
|
||||
|
||||
model LiteLLM_BudgetWindowSpend {
|
||||
|
|
|
|||
|
|
@ -10,9 +10,10 @@ from hashlib import sha256
|
|||
from typing import Final, TypeVar
|
||||
|
||||
import httpx
|
||||
from integration._support.database import read_rows
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
from tests.integration._support.database import read_rows
|
||||
|
||||
JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
||||
T = TypeVar("T")
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from contextlib import contextmanager
|
|||
import httpx
|
||||
from hypothesis import Phase, settings
|
||||
|
||||
from integration._support.client import Gateway
|
||||
from tests.integration._support.client import Gateway
|
||||
|
||||
LIFECYCLE_SETTINGS: Final = settings(
|
||||
max_examples=20,
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ from pydantic import JsonValue
|
|||
from hypothesis import strategies as st
|
||||
from hypothesis.stateful import RuleBasedStateMachine, invariant, rule, run_state_machine_as_test
|
||||
|
||||
from integration._support.client import Gateway, eventually, object_value
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.generation import LIFECYCLE_SETTINGS, bounded_http_requests
|
||||
from tests.integration._support.client import Gateway, eventually, object_value
|
||||
from tests.integration._support.database import read_rows
|
||||
from tests.integration._support.generation import LIFECYCLE_SETTINGS, bounded_http_requests
|
||||
|
||||
|
||||
def assert_serving(gateway: Gateway, model: str, key: str, status: int, error_type: str = "auth_error") -> None:
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ from typing import Final
|
|||
import httpx
|
||||
import pytest
|
||||
|
||||
from integration._support.client import Gateway, object_value, string_value
|
||||
from integration._support.database import read_rows
|
||||
from tests.integration._support.client import Gateway, object_value, string_value
|
||||
from tests.integration._support.database import read_rows
|
||||
|
||||
|
||||
def model_identity(gateway: Gateway, alias: str) -> str:
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ import pytest
|
|||
import httpx
|
||||
from redis import Redis
|
||||
|
||||
from integration._support.client import Gateway, eventually, gateway_from_environment
|
||||
from integration._support.manifest import OWNED_DIRECTORIES, contracts
|
||||
from integration._support.generation import LIFECYCLE_SETTINGS
|
||||
from tests.integration._support.client import Gateway, eventually, gateway_from_environment
|
||||
from tests.integration._support.manifest import OWNED_DIRECTORIES, contracts
|
||||
from tests.integration._support.generation import LIFECYCLE_SETTINGS
|
||||
|
||||
COLLECTED: Final = pytest.StashKey[tuple[str, ...]]()
|
||||
REPORTS: Final = pytest.StashKey[list[pytest.TestReport]]()
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ from hashlib import sha256
|
|||
|
||||
import pytest
|
||||
|
||||
from integration._support.client import Gateway, object_value
|
||||
from integration._support.database import read_rows
|
||||
from tests.integration._support.client import Gateway, object_value
|
||||
from tests.integration._support.database import read_rows
|
||||
|
||||
|
||||
@pytest.mark.covers("mgmt.key.update.preserves_independent_fields")
|
||||
|
|
|
|||
|
|
@ -5,11 +5,12 @@ from typing import Final
|
|||
import pytest
|
||||
from hypothesis import strategies as st
|
||||
from hypothesis.stateful import RuleBasedStateMachine, invariant, rule, run_state_machine_as_test
|
||||
from integration._support.client import Gateway, object_value
|
||||
from integration._support.database import read_rows
|
||||
from integration._support.generation import LIFECYCLE_SETTINGS, bounded_http_requests
|
||||
from pydantic import JsonValue
|
||||
|
||||
from tests.integration._support.client import Gateway, object_value
|
||||
from tests.integration._support.database import read_rows
|
||||
from tests.integration._support.generation import LIFECYCLE_SETTINGS, bounded_http_requests
|
||||
|
||||
|
||||
def _key_rows(digest: str) -> list[dict[str, JsonValue]]:
|
||||
return read_rows(
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import uuid
|
|||
import pytest
|
||||
import yaml
|
||||
|
||||
from integration._support.client import Gateway, eventually, object_value, string_value
|
||||
from integration._support.database import read_rows
|
||||
from tests.integration._support.client import Gateway, eventually, object_value, string_value
|
||||
from tests.integration._support.database import read_rows
|
||||
|
||||
|
||||
@pytest.mark.covers("quota_management.spend_tracking.custom_price.matches_input_rates")
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from typing import Final
|
|||
import httpx
|
||||
import pytest
|
||||
|
||||
from integration._support.client import Gateway, JSON_OBJECT, object_value
|
||||
from tests.integration._support.client import Gateway, JSON_OBJECT, object_value
|
||||
|
||||
|
||||
@pytest.mark.covers("other.provider_wire.internal_parameters_filtered")
|
||||
|
|
|
|||
|
|
@ -102,21 +102,24 @@ def _wire_batcher_for_test(prisma_client, fail_commit=False):
|
|||
return batch_calls
|
||||
|
||||
|
||||
def _wire_cascade_reads_for_test(prisma_client):
|
||||
def _wire_cascade_reads_for_test(prisma_client, endusers=()):
|
||||
"""
|
||||
The budget tier's cascade reads the rows it is about to zero, so their
|
||||
spend counters can be invalidated after the commit. Give each of those
|
||||
tables an awaitable find_many so the reads resolve instead of falling into
|
||||
the job's warn-and-continue path.
|
||||
|
||||
End users are read by the post-commit invalidation walk rather than by
|
||||
``get_data``, so callers that care about customers pass them here.
|
||||
"""
|
||||
for table in (
|
||||
"litellm_teammembership",
|
||||
"litellm_verificationtoken",
|
||||
"litellm_organizationtable",
|
||||
"litellm_tagtable",
|
||||
"litellm_endusertable",
|
||||
):
|
||||
getattr(prisma_client.db, table).find_many = AsyncMock(return_value=[])
|
||||
prisma_client.db.litellm_endusertable.find_many = AsyncMock(return_value=list(endusers))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -556,7 +559,7 @@ async def test_reset_budget_continues_other_categories_on_failure():
|
|||
**{u["user_id"]: u["spend"] for u in [user2]},
|
||||
**{t["team_id"]: t["spend"] for t in [team1, team2]},
|
||||
}
|
||||
_wire_cascade_reads_for_test(prisma_client)
|
||||
_wire_cascade_reads_for_test(prisma_client, endusers=[enduser1])
|
||||
|
||||
proxy_logging_obj = MagicMock()
|
||||
proxy_logging_obj.service_logging_obj = MagicMock()
|
||||
|
|
@ -607,7 +610,10 @@ async def test_reset_budget_continues_other_categories_on_failure():
|
|||
called_tables = {
|
||||
call.kwargs.get("table_name") for call in prisma_client.get_data.await_args_list
|
||||
}
|
||||
assert called_tables == {"key", "user", "team", "budget", "enduser"}
|
||||
assert called_tables == {"key", "user", "team", "budget"}
|
||||
# Customers are not part of that set: the cascade zeroes them by budget link
|
||||
# and reads them only afterwards, to invalidate their cached spend.
|
||||
prisma_client.db.litellm_endusertable.find_many.assert_awaited()
|
||||
|
||||
# Every category writes through the batch path now, so update_data is unused.
|
||||
prisma_client.update_data.assert_not_awaited()
|
||||
|
|
@ -1029,7 +1035,7 @@ async def test_service_logger_endusers_success():
|
|||
prisma_client.get_data = AsyncMock(side_effect=fake_get_data)
|
||||
prisma_client.update_data = AsyncMock()
|
||||
batch_calls = _wire_batcher_for_test(prisma_client)
|
||||
_wire_cascade_reads_for_test(prisma_client)
|
||||
_wire_cascade_reads_for_test(prisma_client, endusers=endusers)
|
||||
|
||||
proxy_logging_obj = MagicMock()
|
||||
proxy_logging_obj.service_logging_obj = MagicMock()
|
||||
|
|
@ -1094,7 +1100,7 @@ async def test_service_logger_endusers_failure():
|
|||
prisma_client.get_data = AsyncMock(side_effect=fake_get_data)
|
||||
prisma_client.update_data = AsyncMock()
|
||||
_wire_batcher_for_test(prisma_client, fail_commit=True)
|
||||
_wire_cascade_reads_for_test(prisma_client)
|
||||
_wire_cascade_reads_for_test(prisma_client, endusers=endusers)
|
||||
|
||||
proxy_logging_obj = MagicMock()
|
||||
proxy_logging_obj.service_logging_obj = MagicMock()
|
||||
|
|
@ -1121,7 +1127,9 @@ async def test_service_logger_endusers_failure():
|
|||
) = proxy_logging_obj.service_logging_obj.async_service_failure_hook.call_args
|
||||
event_metadata = kwargs.get("event_metadata", {})
|
||||
assert event_metadata.get("num_budgets_found") == len(budgets)
|
||||
assert event_metadata.get("num_endusers_found") == len(endusers)
|
||||
# Customers are read by the post-commit invalidation walk, which a failed
|
||||
# commit never reaches, so a failure reports none touched.
|
||||
assert event_metadata.get("num_endusers_found") == 0
|
||||
assert "endusers_found" not in event_metadata
|
||||
assert "budgets_found" not in event_metadata
|
||||
proxy_logging_obj.service_logging_obj.async_service_success_hook.assert_not_called()
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ import pytest
|
|||
|
||||
IMAGE: Final = os.getenv("LITELLM_IMAGE")
|
||||
NON_ROOT_UID: Final = "12345:0"
|
||||
IMPORT_PROBE: Final = "import aws_sdk_bedrock_runtime, smithy_aws_core; print('bedrock-realtime ok')"
|
||||
IMPORT_PROBE: Final = (
|
||||
"import aws_sdk_bedrock_runtime, smithy_aws_core, smithy_http.aio.crt; print('bedrock-realtime ok')"
|
||||
)
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.skipif(IMAGE is None, reason="requires a built image (set LITELLM_IMAGE)"),
|
||||
|
|
@ -52,7 +54,7 @@ def test_image_imports_bedrock_realtime_sdk():
|
|||
)
|
||||
|
||||
assert probe.returncode == 0 and "bedrock-realtime ok" in probe.stdout, (
|
||||
f"{IMAGE} cannot import aws_sdk_bedrock_runtime as uid {NON_ROOT_UID}, so Bedrock Nova Sonic "
|
||||
"/v1/realtime sessions fail with 'Missing aws_sdk_bedrock_runtime'. Is `--extra bedrock-realtime` "
|
||||
f"{IMAGE} cannot import aws_sdk_bedrock_runtime with its awscrt transport as uid {NON_ROOT_UID}, so "
|
||||
"Bedrock Nova Sonic /v1/realtime sessions fail at SDK import. Is `--extra bedrock-realtime` "
|
||||
f"passed to every `uv sync` in its Dockerfile?\nstdout:\n{probe.stdout}\nstderr:\n{probe.stderr}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||
|
||||
import pytest
|
||||
|
||||
from litellm.constants import DEFAULT_MAX_REDIS_BATCH_CACHE_SIZE
|
||||
from litellm.caching.dual_cache import DualCache
|
||||
from litellm.caching.in_memory_cache import InMemoryCache
|
||||
from litellm.caching.redis_cache import RedisCache, _redis_circuit_breaker_guard, _redis_circuit_breaker_guard_sync
|
||||
|
|
@ -759,3 +760,34 @@ async def test_redis_timeouts_falling_back_to_memory_log_once_per_interval(caplo
|
|||
" (199 more Redis timeouts since the previous Redis timeout line were logged at DEBUG)",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_delete_cache_keys_drops_memory_and_chunks_redis():
|
||||
"""Batch delete clears both layers, and chunks Redis so one caller's large
|
||||
key list cannot become a single oversized DELETE command."""
|
||||
redis_cache = MagicMock(spec=RedisCache)
|
||||
redis_cache.delete_cache_keys = AsyncMock()
|
||||
dual_cache = DualCache(in_memory_cache=InMemoryCache(), redis_cache=redis_cache)
|
||||
keys = [f"key-{i}" for i in range(DEFAULT_MAX_REDIS_BATCH_CACHE_SIZE + 7)]
|
||||
for key in keys:
|
||||
dual_cache.in_memory_cache.set_cache(key=key, value=1)
|
||||
|
||||
await dual_cache.async_delete_cache_keys(keys)
|
||||
|
||||
assert all(dual_cache.in_memory_cache.get_cache(key=key) is None for key in keys)
|
||||
sent = [call.args[0] for call in redis_cache.delete_cache_keys.await_args_list]
|
||||
assert [len(chunk) for chunk in sent] == [DEFAULT_MAX_REDIS_BATCH_CACHE_SIZE, 7]
|
||||
assert [key for chunk in sent for key in chunk] == keys
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_delete_cache_keys_on_empty_list_touches_no_backend():
|
||||
"""An empty page must not reach Redis: DELETE with no arguments is an error."""
|
||||
redis_cache = MagicMock(spec=RedisCache)
|
||||
redis_cache.delete_cache_keys = AsyncMock()
|
||||
dual_cache = DualCache(in_memory_cache=InMemoryCache(), redis_cache=redis_cache)
|
||||
|
||||
await dual_cache.async_delete_cache_keys([])
|
||||
|
||||
redis_cache.delete_cache_keys.assert_not_awaited()
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ from litellm.llms.anthropic.experimental_pass_through.adapters.transformation im
|
|||
create_tool_name_mapping,
|
||||
truncate_tool_name,
|
||||
)
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.mid_conversation_system import (
|
||||
CONVERTED_SYSTEM_NOTE,
|
||||
)
|
||||
from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig
|
||||
from litellm.types.llms.anthropic import (
|
||||
AnthopicMessagesAssistantMessageParam,
|
||||
|
|
@ -563,10 +566,19 @@ def test_translate_anthropic_messages_to_openai_tool_message_placement():
|
|||
@pytest.mark.parametrize(
|
||||
("system_content", "expected_content"),
|
||||
[
|
||||
("Use the corrected result.", "Use the corrected result."),
|
||||
(
|
||||
"Use the corrected result.",
|
||||
[
|
||||
{"type": "text", "text": CONVERTED_SYSTEM_NOTE},
|
||||
{"type": "text", "text": "Use the corrected result."},
|
||||
],
|
||||
),
|
||||
(
|
||||
[{"type": "text", "text": "Use the corrected result."}],
|
||||
[{"type": "text", "text": "Use the corrected result."}],
|
||||
[
|
||||
{"type": "text", "text": CONVERTED_SYSTEM_NOTE},
|
||||
{"type": "text", "text": "Use the corrected result."},
|
||||
],
|
||||
),
|
||||
(
|
||||
[
|
||||
|
|
@ -576,7 +588,11 @@ def test_translate_anthropic_messages_to_openai_tool_message_placement():
|
|||
},
|
||||
{"type": "text", "text": "Use the corrected result."},
|
||||
],
|
||||
[{"type": "text", "text": "Use the corrected result."}],
|
||||
[
|
||||
{"type": "text", "text": CONVERTED_SYSTEM_NOTE},
|
||||
{"type": "image_url", "image_url": {"url": "https://example.com/a.png"}},
|
||||
{"type": "text", "text": "Use the corrected result."},
|
||||
],
|
||||
),
|
||||
(
|
||||
[
|
||||
|
|
@ -584,13 +600,14 @@ def test_translate_anthropic_messages_to_openai_tool_message_placement():
|
|||
{"type": "text", "text": "Second correction."},
|
||||
],
|
||||
[
|
||||
{"type": "text", "text": CONVERTED_SYSTEM_NOTE},
|
||||
{"type": "text", "text": "First correction."},
|
||||
{"type": "text", "text": "Second correction."},
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_translate_anthropic_messages_to_openai_preserves_midturn_system_correction(
|
||||
def test_translate_anthropic_messages_to_openai_converts_midturn_system_correction(
|
||||
system_content: object,
|
||||
expected_content: object,
|
||||
):
|
||||
|
|
@ -646,7 +663,7 @@ def test_translate_anthropic_messages_to_openai_preserves_midturn_system_correct
|
|||
"tool_call_id": "toolu_01234",
|
||||
"content": "Rainy, 55°F",
|
||||
},
|
||||
{"role": "system", "content": expected_content},
|
||||
{"role": "user", "content": expected_content},
|
||||
{"role": "user", "content": "Continue."},
|
||||
]
|
||||
|
||||
|
|
@ -752,8 +769,8 @@ def test_translate_anthropic_messages_to_openai_drops_empty_midturn_system(
|
|||
def test_translate_anthropic_to_openai_orders_top_level_and_midturn_system():
|
||||
"""
|
||||
Request level: the trusted top-level prompt is hoisted to index 0 exactly once and the
|
||||
in-sequence correction keeps its own position and `role: "system"` -- no duplication of
|
||||
either, and no reordering of the surrounding turns.
|
||||
in-sequence correction keeps its own position as a user turn prefixed with the operator
|
||||
note -- no duplication of either, and no reordering of the surrounding turns.
|
||||
"""
|
||||
openai_request, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai(
|
||||
anthropic_message_request={
|
||||
|
|
@ -773,11 +790,140 @@ def test_translate_anthropic_to_openai_orders_top_level_and_midturn_system():
|
|||
{"role": "system", "content": "Trusted top-level prompt."},
|
||||
{"role": "user", "content": "First question."},
|
||||
{"role": "assistant", "content": "First answer.", "thinking_blocks": None},
|
||||
{"role": "system", "content": "Use the corrected result."},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": CONVERTED_SYSTEM_NOTE},
|
||||
{"type": "text", "text": "Use the corrected result."},
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "Continue."},
|
||||
]
|
||||
|
||||
|
||||
_CLAUDE_CODE_MIDTURN_SYSTEM_REQUEST: Final = {
|
||||
"max_tokens": 128,
|
||||
"system": [{"type": "text", "text": "You are Claude Code."}],
|
||||
"messages": [
|
||||
{"role": "user", "content": "say hi"},
|
||||
{
|
||||
"role": "system",
|
||||
"content": [{"type": "text", "text": "<system-reminder>Keep answers to one sentence.</system-reminder>"}],
|
||||
},
|
||||
{"role": "assistant", "content": "Hi."},
|
||||
{"role": "user", "content": "say bye"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("custom_llm_provider", [None, "hosted_vllm"])
|
||||
def test_translate_anthropic_to_openai_converts_claude_code_midturn_system_turn(custom_llm_provider: str | None):
|
||||
"""
|
||||
Claude Code appends a system-role harness reminder after the user turn. On a chat-completions
|
||||
target that does not declare ``supports_mid_conversation_system`` (a self-hosted model the cost
|
||||
map knows nothing about) the outbound request must have exactly one system message, at index 0,
|
||||
and the converted turn must carry the operator note first.
|
||||
"""
|
||||
openai_request, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai(
|
||||
anthropic_message_request={"model": "qwen3.8-27B", **_CLAUDE_CODE_MIDTURN_SYSTEM_REQUEST},
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
)
|
||||
|
||||
roles = [m["role"] for m in openai_request["messages"]]
|
||||
assert roles == ["system", "user", "user", "assistant", "user"]
|
||||
converted = openai_request["messages"][2]
|
||||
assert converted["content"][0]["text"] == CONVERTED_SYSTEM_NOTE
|
||||
assert converted["content"][1]["text"] == "<system-reminder>Keep answers to one sentence.</system-reminder>"
|
||||
|
||||
|
||||
def test_translate_anthropic_to_openai_keeps_midturn_system_when_target_declares_support(monkeypatch):
|
||||
"""
|
||||
A chat-completions target flagged ``supports_mid_conversation_system`` in the cost map accepts
|
||||
the role anywhere, so the harness reminder is forwarded in place with its role and content
|
||||
untouched, the same rule the native Anthropic Messages path applies.
|
||||
"""
|
||||
model: Final = "system-role-anywhere-chat-model"
|
||||
monkeypatch.setitem(
|
||||
litellm.model_cost,
|
||||
model,
|
||||
{"litellm_provider": "openai", "mode": "chat", "supports_mid_conversation_system": True},
|
||||
)
|
||||
|
||||
openai_request, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai(
|
||||
anthropic_message_request={"model": model, **_CLAUDE_CODE_MIDTURN_SYSTEM_REQUEST},
|
||||
custom_llm_provider="openai",
|
||||
)
|
||||
|
||||
assert openai_request["messages"] == [
|
||||
{"role": "system", "content": [{"type": "text", "text": "You are Claude Code."}]},
|
||||
{"role": "user", "content": "say hi"},
|
||||
{
|
||||
"role": "system",
|
||||
"content": [{"type": "text", "text": "<system-reminder>Keep answers to one sentence.</system-reminder>"}],
|
||||
},
|
||||
{"role": "assistant", "content": "Hi.", "thinking_blocks": None},
|
||||
{"role": "user", "content": "say bye"},
|
||||
]
|
||||
|
||||
|
||||
def test_translate_anthropic_to_openai_moves_midturn_system_after_tool_result():
|
||||
"""
|
||||
A system entry wedged between an assistant tool_use turn and its tool_result turn is
|
||||
emitted after the role: "tool" message, so the tool call stays paired with its result.
|
||||
"""
|
||||
result = LiteLLMAnthropicMessagesAdapter().translate_anthropic_messages_to_openai(
|
||||
messages=[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01234",
|
||||
"name": "get_weather",
|
||||
"input": {"location": "Boston"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "system", "content": "Use the corrected result."},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_01234",
|
||||
"content": "Rainy, 55°F",
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
model="claude-3-5-sonnet-20240620",
|
||||
)
|
||||
|
||||
assert [m["role"] for m in result] == ["assistant", "tool", "user"]
|
||||
assert result[2]["content"][0]["text"] == CONVERTED_SYSTEM_NOTE
|
||||
|
||||
|
||||
def test_translate_anthropic_messages_to_openai_converts_string_midturn_system():
|
||||
result = LiteLLMAnthropicMessagesAdapter().translate_anthropic_messages_to_openai(
|
||||
messages=[
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "system", "content": "Keep it short."},
|
||||
],
|
||||
model="claude-3-5-sonnet-20240620",
|
||||
)
|
||||
|
||||
assert result == [
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": CONVERTED_SYSTEM_NOTE},
|
||||
{"type": "text", "text": "Keep it short."},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _claude_code_user_id(session_id: str) -> str:
|
||||
return json.dumps({"device_id": "d" * 64, "account_uuid": "", "session_id": session_id})
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
from collections import Counter
|
||||
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.mid_conversation_system import (
|
||||
CONVERTED_SYSTEM_NOTE,
|
||||
convert_mid_conversation_system_turns,
|
||||
)
|
||||
|
||||
|
||||
class RoleReadCountingMessage(dict):
|
||||
def __init__(self, role: str, content: object, reads: Counter):
|
||||
super().__init__(role=role, content=content)
|
||||
self.reads = reads
|
||||
|
||||
def get(self, key, default=None):
|
||||
self.reads[key] += 1
|
||||
return super().get(key, default)
|
||||
|
||||
|
||||
def test_convert_mid_conversation_system_turns_converts_system_to_user_in_place():
|
||||
result = convert_mid_conversation_system_turns(
|
||||
[
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "system", "content": [{"type": "text", "text": "Keep it short."}]},
|
||||
{"role": "assistant", "content": "Hi."},
|
||||
]
|
||||
)
|
||||
|
||||
assert result == (
|
||||
{"role": "user", "content": "hi"},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": CONVERTED_SYSTEM_NOTE},
|
||||
{"type": "text", "text": "Keep it short."},
|
||||
],
|
||||
},
|
||||
{"role": "assistant", "content": "Hi."},
|
||||
)
|
||||
|
||||
|
||||
def test_convert_mid_conversation_system_turns_wraps_string_content():
|
||||
result = convert_mid_conversation_system_turns(
|
||||
[
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "system", "content": "Keep it short."},
|
||||
]
|
||||
)
|
||||
|
||||
assert result[1] == {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": CONVERTED_SYSTEM_NOTE},
|
||||
{"type": "text", "text": "Keep it short."},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_convert_mid_conversation_system_turns_moves_system_after_tool_result():
|
||||
assistant_tool_use = {
|
||||
"role": "assistant",
|
||||
"content": [{"type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": {}}],
|
||||
}
|
||||
wedged_system = {"role": "system", "content": "Use the corrected result."}
|
||||
tool_result = {
|
||||
"role": "user",
|
||||
"content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "Rainy"}],
|
||||
}
|
||||
|
||||
result = convert_mid_conversation_system_turns([assistant_tool_use, wedged_system, tool_result])
|
||||
|
||||
assert result[0] is assistant_tool_use
|
||||
assert result[1] is tool_result
|
||||
assert result[2]["role"] == "user"
|
||||
assert result[2]["content"][0]["text"] == CONVERTED_SYSTEM_NOTE
|
||||
|
||||
|
||||
def test_convert_mid_conversation_system_turns_reads_each_role_a_bounded_number_of_times():
|
||||
reads = Counter()
|
||||
system_run = [RoleReadCountingMessage("system", f"reminder {i}", reads) for i in range(2_000)]
|
||||
tool_result = RoleReadCountingMessage(
|
||||
"user", [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "Rainy"}], reads
|
||||
)
|
||||
messages = [RoleReadCountingMessage("user", "hi", reads), *system_run, tool_result]
|
||||
|
||||
result = convert_mid_conversation_system_turns(messages)
|
||||
|
||||
assert reads["role"] <= 3 * len(messages)
|
||||
assert result[1] is tool_result
|
||||
assert [m["content"][1]["text"] for m in result[2:]] == [m["content"] for m in system_run]
|
||||
|
|
@ -23,6 +23,9 @@ from litellm.constants import (
|
|||
DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET,
|
||||
DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET,
|
||||
)
|
||||
from litellm.llms.anthropic.experimental_pass_through.messages.mid_conversation_system import (
|
||||
as_system_content_blocks,
|
||||
)
|
||||
from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation import (
|
||||
AmazonAnthropicClaudeMessagesConfig,
|
||||
AmazonAnthropicClaudeMessagesStreamDecoder,
|
||||
|
|
@ -2533,20 +2536,16 @@ def test_bedrock_claude_4_8_plus_cost_map_entries_carry_mid_conversation_system_
|
|||
|
||||
|
||||
def test_as_system_content_blocks_handles_each_shape():
|
||||
"""``_as_system_content_blocks`` normalizes every system shape: ``None`` -> empty,
|
||||
"""``as_system_content_blocks`` normalizes every system shape: ``None`` -> empty,
|
||||
a string -> a single text block, a list -> a shallow copy, and any other value
|
||||
(e.g. a bare content-block dict) -> wrapped in a single-element list."""
|
||||
block = {"type": "text", "text": "x"}
|
||||
assert AmazonAnthropicClaudeMessagesConfig._as_system_content_blocks(None) == []
|
||||
assert AmazonAnthropicClaudeMessagesConfig._as_system_content_blocks("hello") == [
|
||||
{"type": "text", "text": "hello"}
|
||||
]
|
||||
assert as_system_content_blocks(None) == []
|
||||
assert as_system_content_blocks("hello") == [{"type": "text", "text": "hello"}]
|
||||
blocks = [block]
|
||||
out = AmazonAnthropicClaudeMessagesConfig._as_system_content_blocks(blocks)
|
||||
out = as_system_content_blocks(blocks)
|
||||
assert out == blocks and out is not blocks
|
||||
assert AmazonAnthropicClaudeMessagesConfig._as_system_content_blocks(block) == [
|
||||
block
|
||||
]
|
||||
assert as_system_content_blocks(block) == [block]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ from unittest.mock import MagicMock
|
|||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.constants import REALTIME_SESSION_SUCCESS_LOGGED_KEY
|
||||
from litellm.constants import (
|
||||
BEDROCK_REALTIME_SDK_SUPPORTED_RANGE,
|
||||
REALTIME_SESSION_SUCCESS_LOGGED_KEY,
|
||||
WEBSOCKET_CLOSE_REASON_MAX_BYTES,
|
||||
)
|
||||
from litellm.llms.bedrock.common_utils import BedrockError
|
||||
from litellm.llms.bedrock.realtime.handler import BedrockRealtime
|
||||
from litellm.llms.bedrock.realtime.transformation import BedrockRealtimeConfig
|
||||
|
|
@ -207,7 +211,19 @@ class ScriptedBedrockStream:
|
|||
return (None, self._receiver)
|
||||
|
||||
|
||||
class FakeAWSCredentialsIdentity:
|
||||
def __init__(self, access_key_id, secret_access_key, session_token=None):
|
||||
self.access_key_id = access_key_id
|
||||
self.secret_access_key = secret_access_key
|
||||
self.session_token = session_token
|
||||
|
||||
|
||||
class FakeStaticCredentialsResolver:
|
||||
def __init__(self, identity=None):
|
||||
self.identity = identity
|
||||
|
||||
|
||||
class FakeAWSCRTHTTPClient:
|
||||
pass
|
||||
|
||||
|
||||
|
|
@ -227,48 +243,32 @@ class StubCredentialsBedrockRealtime(BedrockRealtime):
|
|||
return SimpleNamespace(get_frozen_credentials=lambda: self.frozen_credentials)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stub_aws_sdk_client(monkeypatch):
|
||||
captured = {}
|
||||
class FakeOperationInput:
|
||||
def __init__(self, model_id):
|
||||
self.model_id = model_id
|
||||
|
||||
class CapturingConfig:
|
||||
def __init__(self, **kwargs):
|
||||
captured["config_kwargs"] = kwargs
|
||||
self.kwargs = kwargs
|
||||
|
||||
class FakeOperationInput:
|
||||
def __init__(self, model_id):
|
||||
self.model_id = model_id
|
||||
|
||||
class FakeBedrockRuntimeClient:
|
||||
def __init__(self, config):
|
||||
captured["client_config"] = config
|
||||
|
||||
async def invoke_model_with_bidirectional_stream(self, operation_input):
|
||||
captured["operation_input"] = operation_input
|
||||
if captured.get("streams"):
|
||||
stream = captured["streams"].pop(0)
|
||||
if isinstance(stream, Exception):
|
||||
raise stream
|
||||
return stream
|
||||
return ScriptedBedrockStream(captured.get("scripted_payloads", []))
|
||||
|
||||
def _install_fake_sdk_modules(monkeypatch, client_module, config_module):
|
||||
"""Wire fake aws_sdk_bedrock_runtime / smithy packages into sys.modules for the handler's lazy imports."""
|
||||
package = types.ModuleType("aws_sdk_bedrock_runtime")
|
||||
client_module = types.ModuleType("aws_sdk_bedrock_runtime.client")
|
||||
client_module.BedrockRuntimeClient = FakeBedrockRuntimeClient
|
||||
client_module.InvokeModelWithBidirectionalStreamOperationInput = FakeOperationInput
|
||||
config_module = types.ModuleType("aws_sdk_bedrock_runtime.config")
|
||||
config_module.Config = CapturingConfig
|
||||
models_module = types.ModuleType("aws_sdk_bedrock_runtime.models")
|
||||
models_module.BidirectionalInputPayloadPart = FakePayloadPart
|
||||
models_module.InvokeModelWithBidirectionalStreamInputChunk = FakeInputChunk
|
||||
models_module.InvokeModelWithBidirectionalStreamOperationInput = FakeOperationInput
|
||||
package.client = client_module
|
||||
package.config = config_module
|
||||
package.models = models_module
|
||||
smithy_package = types.ModuleType("smithy_aws_core")
|
||||
identity_module = types.ModuleType("smithy_aws_core.identity")
|
||||
identity_module.AWSCredentialsIdentity = FakeAWSCredentialsIdentity
|
||||
identity_module.StaticCredentialsResolver = FakeStaticCredentialsResolver
|
||||
smithy_package.identity = identity_module
|
||||
smithy_http_package = types.ModuleType("smithy_http")
|
||||
smithy_http_aio = types.ModuleType("smithy_http.aio")
|
||||
crt_module = types.ModuleType("smithy_http.aio.crt")
|
||||
crt_module.AWSCRTHTTPClient = FakeAWSCRTHTTPClient
|
||||
smithy_http_aio.crt = crt_module
|
||||
smithy_http_package.aio = smithy_http_aio
|
||||
|
||||
stubbed_modules = {
|
||||
"aws_sdk_bedrock_runtime": package,
|
||||
|
|
@ -277,10 +277,56 @@ def stub_aws_sdk_client(monkeypatch):
|
|||
"aws_sdk_bedrock_runtime.models": models_module,
|
||||
"smithy_aws_core": smithy_package,
|
||||
"smithy_aws_core.identity": identity_module,
|
||||
"smithy_http": smithy_http_package,
|
||||
"smithy_http.aio": smithy_http_aio,
|
||||
"smithy_http.aio.crt": crt_module,
|
||||
}
|
||||
for module_name, module in stubbed_modules.items():
|
||||
monkeypatch.setitem(sys.modules, module_name, module)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stub_aws_sdk_client(monkeypatch):
|
||||
"""Fake of the aws-sdk-bedrock-runtime 0.10/0.11 surface: async config resolve, async client with close()"""
|
||||
captured = {}
|
||||
|
||||
class FakeAsyncBedrockRuntimeConfig:
|
||||
def __init__(self, kwargs):
|
||||
self.kwargs = kwargs
|
||||
|
||||
@classmethod
|
||||
async def resolve(cls, **kwargs):
|
||||
captured["config_kwargs"] = kwargs
|
||||
return cls(kwargs)
|
||||
|
||||
class FakeAsyncBedrockRuntimeClient:
|
||||
def __init__(self, config):
|
||||
captured["client_config"] = config
|
||||
captured["client_closed"] = False
|
||||
|
||||
async def invoke_model_with_bidirectional_stream(self, operation_input):
|
||||
captured["operation_input"] = operation_input
|
||||
if captured.get("streams"):
|
||||
stream = captured["streams"].pop(0)
|
||||
if isinstance(stream, Exception):
|
||||
raise stream
|
||||
captured["open_stream"] = stream
|
||||
return stream
|
||||
stream = ScriptedBedrockStream(captured.get("scripted_payloads", []))
|
||||
captured["open_stream"] = stream
|
||||
return stream
|
||||
|
||||
async def close(self):
|
||||
open_stream = captured.get("open_stream")
|
||||
captured["input_closed_before_client_close"] = open_stream is None or open_stream.input_stream.closed
|
||||
captured["client_closed"] = True
|
||||
|
||||
client_module = types.ModuleType("aws_sdk_bedrock_runtime.client")
|
||||
client_module.AsyncBedrockRuntimeClient = FakeAsyncBedrockRuntimeClient
|
||||
config_module = types.ModuleType("aws_sdk_bedrock_runtime.config")
|
||||
config_module.AsyncBedrockRuntimeConfig = FakeAsyncBedrockRuntimeConfig
|
||||
_install_fake_sdk_modules(monkeypatch, client_module, config_module)
|
||||
|
||||
for env_var in (
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
|
|
@ -764,15 +810,33 @@ class TestBedrockRealtimeAwsAuth:
|
|||
)
|
||||
|
||||
config_kwargs = stub_aws_sdk_client["config_kwargs"]
|
||||
assert config_kwargs["aws_access_key_id"] == "litellm-params-access-key"
|
||||
assert config_kwargs["aws_secret_access_key"] == "litellm-params-secret-key"
|
||||
assert config_kwargs["aws_session_token"] == "litellm-params-session-token"
|
||||
assert isinstance(config_kwargs["aws_credentials_identity_resolver"], FakeStaticCredentialsResolver)
|
||||
resolver = config_kwargs["aws_credentials_identity_resolver"]
|
||||
assert isinstance(resolver, FakeStaticCredentialsResolver)
|
||||
assert resolver.identity.access_key_id == "litellm-params-access-key"
|
||||
assert resolver.identity.secret_access_key == "litellm-params-secret-key"
|
||||
assert resolver.identity.session_token == "litellm-params-session-token"
|
||||
assert config_kwargs["region"] == "us-east-1"
|
||||
assert config_kwargs["endpoint_uri"] == "https://bedrock-runtime.us-east-1.amazonaws.com"
|
||||
assert isinstance(config_kwargs["transport"], FakeAWSCRTHTTPClient)
|
||||
assert stub_aws_sdk_client["client_config"].kwargs is config_kwargs
|
||||
assert stub_aws_sdk_client["operation_input"].model_id == "amazon.nova-sonic-v1:0"
|
||||
assert websocket.closed
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_base_overrides_default_endpoint(self, stub_aws_sdk_client):
|
||||
await BedrockRealtime().async_realtime(
|
||||
model="amazon.nova-sonic-v1:0",
|
||||
websocket=RealtimeClientWS(),
|
||||
logging_obj=FakeLogging(),
|
||||
aws_region_name="us-east-1",
|
||||
aws_access_key_id="k",
|
||||
aws_secret_access_key="s",
|
||||
api_base="https://vpce-bedrock.example.internal",
|
||||
aws_bedrock_runtime_endpoint="https://ignored.example.internal",
|
||||
)
|
||||
|
||||
assert stub_aws_sdk_client["config_kwargs"]["endpoint_uri"] == "https://vpce-bedrock.example.internal"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_role_assumption_params_forwarded_to_get_credentials(self, stub_aws_sdk_client):
|
||||
handler = StubCredentialsBedrockRealtime(
|
||||
|
|
@ -805,11 +869,11 @@ class TestBedrockRealtimeAwsAuth:
|
|||
"aws_sts_endpoint": None,
|
||||
"aws_external_id": "realtime-external-id",
|
||||
}
|
||||
config_kwargs = stub_aws_sdk_client["config_kwargs"]
|
||||
assert config_kwargs["aws_access_key_id"] == "assumed-access-key"
|
||||
assert config_kwargs["aws_secret_access_key"] == "assumed-secret-key"
|
||||
assert config_kwargs["aws_session_token"] == "assumed-session-token"
|
||||
assert isinstance(config_kwargs["aws_credentials_identity_resolver"], FakeStaticCredentialsResolver)
|
||||
resolver = stub_aws_sdk_client["config_kwargs"]["aws_credentials_identity_resolver"]
|
||||
assert isinstance(resolver, FakeStaticCredentialsResolver)
|
||||
assert resolver.identity.access_key_id == "assumed-access-key"
|
||||
assert resolver.identity.secret_access_key == "assumed-secret-key"
|
||||
assert resolver.identity.session_token == "assumed-session-token"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unresolvable_credentials_raise_clear_auth_error(self, stub_aws_sdk_client):
|
||||
|
|
@ -826,5 +890,118 @@ class TestBedrockRealtimeAwsAuth:
|
|||
assert "config_kwargs" not in stub_aws_sdk_client
|
||||
|
||||
|
||||
class TestBedrockRealtimeSdkLifecycle:
|
||||
"""aws-sdk-bedrock-runtime 0.10/0.11: async config, async client, CRT transport, close() (LIT-7938 regression)"""
|
||||
|
||||
AWS_ARGS = {
|
||||
"model": "amazon.nova-sonic-v1:0",
|
||||
"aws_region_name": "us-east-1",
|
||||
"aws_access_key_id": "k",
|
||||
"aws_secret_access_key": "s",
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_closed_after_input_stream_on_normal_completion(self, stub_aws_sdk_client):
|
||||
await BedrockRealtime().async_realtime(websocket=RealtimeClientWS(), logging_obj=FakeLogging(), **self.AWS_ARGS)
|
||||
|
||||
assert stub_aws_sdk_client["client_closed"]
|
||||
assert stub_aws_sdk_client["input_closed_before_client_close"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_closed_when_stream_open_fails(self, stub_aws_sdk_client):
|
||||
stub_aws_sdk_client["streams"] = [ServiceUnavailableException("bedrock unavailable")]
|
||||
|
||||
with pytest.raises(ServiceUnavailableException):
|
||||
await BedrockRealtime().async_realtime(
|
||||
websocket=RealtimeClientWS(), logging_obj=FakeLogging(), **self.AWS_ARGS
|
||||
)
|
||||
|
||||
assert stub_aws_sdk_client["client_closed"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_closed_when_provider_stream_fails_mid_session(self, stub_aws_sdk_client):
|
||||
stub_aws_sdk_client["streams"] = [ScriptedBedrockStream([], receiver_type=BreakingBedrockReceiver)]
|
||||
|
||||
with pytest.raises(BedrockError):
|
||||
await BedrockRealtime().async_realtime(
|
||||
websocket=ConnectedClientWS([]), logging_obj=FakeLogging(), **self.AWS_ARGS
|
||||
)
|
||||
|
||||
assert stub_aws_sdk_client["client_closed"]
|
||||
assert stub_aws_sdk_client["input_closed_before_client_close"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_without_close_completes_session(self, monkeypatch):
|
||||
class ClientWithoutClose:
|
||||
def __init__(self, config):
|
||||
pass
|
||||
|
||||
async def invoke_model_with_bidirectional_stream(self, operation_input):
|
||||
return ScriptedBedrockStream([])
|
||||
|
||||
class ConfigWithoutCapture:
|
||||
@classmethod
|
||||
async def resolve(cls, **kwargs):
|
||||
return cls()
|
||||
|
||||
client_module = types.ModuleType("aws_sdk_bedrock_runtime.client")
|
||||
client_module.AsyncBedrockRuntimeClient = ClientWithoutClose
|
||||
config_module = types.ModuleType("aws_sdk_bedrock_runtime.config")
|
||||
config_module.AsyncBedrockRuntimeConfig = ConfigWithoutCapture
|
||||
_install_fake_sdk_modules(monkeypatch, client_module, config_module)
|
||||
websocket = RealtimeClientWS()
|
||||
|
||||
await BedrockRealtime().async_realtime(websocket=websocket, logging_obj=FakeLogging(), **self.AWS_ARGS)
|
||||
|
||||
assert websocket.closed
|
||||
|
||||
|
||||
class TestBedrockRealtimeSdkImportErrors:
|
||||
"""Init errors must tell 'SDK not installed' apart from 'SDK installed but unsupported version' (LIT-7938)"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_absent_sdk_names_install_extra(self, monkeypatch):
|
||||
monkeypatch.setitem(sys.modules, "aws_sdk_bedrock_runtime", None)
|
||||
handler = BedrockRealtime(sdk_version_lookup=lambda: None)
|
||||
|
||||
with pytest.raises(ImportError) as exc_info:
|
||||
await handler.async_realtime(
|
||||
model="amazon.nova-sonic-v1:0", websocket=RealtimeClientWS(), logging_obj=FakeLogging()
|
||||
)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert message.startswith("Missing aws_sdk_bedrock_runtime")
|
||||
assert "litellm[bedrock-realtime]" in message
|
||||
assert "is installed but" not in message
|
||||
close_reason = message.encode()[:WEBSOCKET_CLOSE_REASON_MAX_BYTES].decode()
|
||||
assert BEDROCK_REALTIME_SDK_SUPPORTED_RANGE in close_reason
|
||||
assert "pip install 'litellm[bedrock-realtime]'" in close_reason
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_incompatible_sdk_names_installed_version_and_supported_range(self, monkeypatch):
|
||||
legacy_client_module = types.ModuleType("aws_sdk_bedrock_runtime.client")
|
||||
legacy_client_module.BedrockRuntimeClient = object
|
||||
legacy_config_module = types.ModuleType("aws_sdk_bedrock_runtime.config")
|
||||
legacy_config_module.Config = object
|
||||
_install_fake_sdk_modules(monkeypatch, legacy_client_module, legacy_config_module)
|
||||
handler = BedrockRealtime(sdk_version_lookup=lambda: "0.7.0")
|
||||
|
||||
with pytest.raises(ImportError) as exc_info:
|
||||
await handler.async_realtime(
|
||||
model="amazon.nova-sonic-v1:0", websocket=RealtimeClientWS(), logging_obj=FakeLogging()
|
||||
)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "aws-sdk-bedrock-runtime 0.7.0 is installed but" in message
|
||||
assert ">=0.10.0,<0.12.0" in message
|
||||
assert not message.startswith("Missing aws_sdk_bedrock_runtime")
|
||||
assert isinstance(exc_info.value.__cause__, ImportError)
|
||||
assert str(exc_info.value.__cause__) not in message
|
||||
assert "cannot import name" not in message
|
||||
close_reason = message.encode()[:WEBSOCKET_CLOSE_REASON_MAX_BYTES].decode()
|
||||
assert "0.7.0 is installed" in close_reason
|
||||
assert BEDROCK_REALTIME_SDK_SUPPORTED_RANGE in close_reason
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import sys
|
|||
import types
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import time as dt_time
|
||||
from typing import Any, Dict, Final, List
|
||||
from typing import Any, Dict, Final, List, Optional
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
|
|
@ -16,6 +16,7 @@ from litellm.proxy._types import LiteLLM_VerificationToken
|
|||
from litellm.proxy.common_utils import reset_budget_job as reset_budget_job_module
|
||||
from litellm.constants import (
|
||||
PROXY_BUDGET_RESCHEDULER_MIN_TIME,
|
||||
RESET_BUDGET_JOB_BATCH_SIZE,
|
||||
RESET_BUDGET_JOB_LOCK_TTL_SECONDS,
|
||||
RESET_BUDGET_JOB_NAME,
|
||||
)
|
||||
|
|
@ -31,13 +32,36 @@ class MockTable:
|
|||
self.find_many_calls: List[Dict[str, Any]] = []
|
||||
self.update_many_calls: List[Dict[str, Any]] = []
|
||||
self._find_many_results: List[Any] = []
|
||||
self._find_many_error: Optional[tuple[int, Exception]] = None
|
||||
|
||||
def set_find_many_results(self, results: List[Any]):
|
||||
self._find_many_results = results
|
||||
|
||||
async def find_many(self, where: Dict[str, Any]) -> List[Any]:
|
||||
self.find_many_calls.append({"where": where})
|
||||
return self._find_many_results
|
||||
def set_find_many_error(self, after_reads: int, error: Exception):
|
||||
"""Fail every read past the first ``after_reads``, the way a connection
|
||||
dropping partway through a paged walk does."""
|
||||
self._find_many_error = (after_reads, error)
|
||||
|
||||
async def find_many(
|
||||
self,
|
||||
where: Dict[str, Any],
|
||||
order: Optional[Dict[str, str]] = None,
|
||||
take: Optional[int] = None,
|
||||
) -> List[Any]:
|
||||
"""Replays canned rows, honouring the keyset cursor + ``take`` a paged
|
||||
caller relies on: without that a paged walk never advances and the
|
||||
test would hang instead of failing."""
|
||||
if self._find_many_error is not None and len(self.find_many_calls) >= self._find_many_error[0]:
|
||||
raise self._find_many_error[1]
|
||||
paging = {k: v for k, v in (("order", order), ("take", take)) if v is not None}
|
||||
self.find_many_calls.append({"where": where, **paging})
|
||||
rows = list(self._find_many_results)
|
||||
for field, condition in where.items():
|
||||
if isinstance(condition, dict) and "gt" in condition and field != "spend":
|
||||
rows = [row for row in rows if getattr(row, field, "") > condition["gt"]]
|
||||
for field, direction in (order or {}).items():
|
||||
rows.sort(key=lambda row: getattr(row, field, ""), reverse=direction == "desc")
|
||||
return rows[:take] if take is not None else rows
|
||||
|
||||
async def update_many(self, where: Dict[str, Any], data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
self.update_many_calls.append({"where": where, "data": data})
|
||||
|
|
@ -801,10 +825,16 @@ def test_reset_budget_resets_endusers_with_null_budget_id(reset_budget_job, mock
|
|||
},
|
||||
]
|
||||
|
||||
# Verify find_many was called to fetch NULL-budget-id end users
|
||||
# The post-commit invalidation walk covers both branches, so implicitly
|
||||
# created customers on the default tier get their cached spend dropped too,
|
||||
# and it is paged rather than reading the whole customer population.
|
||||
find_many_calls = mock_prisma_client.db.litellm_endusertable.find_many_calls
|
||||
assert len(find_many_calls) == 1
|
||||
assert find_many_calls[0]["where"] == {"budget_id": None, "spend": {"gt": 0}}
|
||||
assert find_many_calls[0]["where"]["OR"] == [
|
||||
{"budget_id": {"in": [default_budget_id]}},
|
||||
{"budget_id": None},
|
||||
]
|
||||
assert find_many_calls[0]["take"] == RESET_BUDGET_JOB_BATCH_SIZE
|
||||
|
||||
litellm.max_end_user_budget_id = None
|
||||
|
||||
|
|
@ -835,9 +865,12 @@ def test_reset_budget_skips_null_budget_id_endusers_when_default_not_configured(
|
|||
|
||||
asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table())
|
||||
|
||||
# Should NOT have queried for NULL-budget-id end users
|
||||
# The invalidation walk must not reach for NULL-budget-id customers: they
|
||||
# ride a default tier that is not expiring, so their spend stays put.
|
||||
find_many_calls = mock_prisma_client.db.litellm_endusertable.find_many_calls
|
||||
assert len(find_many_calls) == 0
|
||||
assert [call["where"] for call in find_many_calls] == [
|
||||
{"budget_id": {"in": ["some-budget"]}, "user_id": {"gt": ""}}
|
||||
]
|
||||
|
||||
litellm.max_end_user_budget_id = None
|
||||
|
||||
|
|
@ -872,9 +905,12 @@ def test_reset_budget_skips_null_budget_id_endusers_when_default_not_in_reset_li
|
|||
|
||||
asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table())
|
||||
|
||||
# Should NOT have queried for NULL-budget-id end users
|
||||
# The invalidation walk must not reach for NULL-budget-id customers: they
|
||||
# ride a default tier that is not expiring, so their spend stays put.
|
||||
find_many_calls = mock_prisma_client.db.litellm_endusertable.find_many_calls
|
||||
assert len(find_many_calls) == 0
|
||||
assert [call["where"] for call in find_many_calls] == [
|
||||
{"budget_id": {"in": ["other-budget"]}, "user_id": {"gt": ""}}
|
||||
]
|
||||
|
||||
litellm.max_end_user_budget_id = None
|
||||
|
||||
|
|
@ -1252,6 +1288,21 @@ def _make_counter_invalidation_job(monkeypatch):
|
|||
user_api_key_cache = MagicMock()
|
||||
user_api_key_cache.async_delete_cache = AsyncMock()
|
||||
|
||||
# Batch deletes fan out to the same per-key calls the real DualCache makes,
|
||||
# so an assertion reads "this key was invalidated" whether the caller went
|
||||
# one key at a time or a page at a time.
|
||||
async def _delete_counter_keys(keys):
|
||||
for key in keys:
|
||||
spend_counter_cache.in_memory_cache.delete_cache(key=key)
|
||||
await spend_counter_cache.redis_cache.async_delete_cache(key=key)
|
||||
|
||||
async def _delete_management_keys(keys):
|
||||
for key in keys:
|
||||
await user_api_key_cache.async_delete_cache(key=key)
|
||||
|
||||
spend_counter_cache.async_delete_cache_keys = AsyncMock(side_effect=_delete_counter_keys)
|
||||
user_api_key_cache.async_delete_cache_keys = AsyncMock(side_effect=_delete_management_keys)
|
||||
|
||||
fake_module = types.ModuleType("litellm.proxy.proxy_server")
|
||||
fake_module.spend_counter_cache = spend_counter_cache
|
||||
fake_module.user_api_key_cache = user_api_key_cache
|
||||
|
|
@ -1586,7 +1637,7 @@ def test_budget_table_reset_invalidates_enduser_counter_and_cache(reset_budget_j
|
|||
"user_id": "customer-42",
|
||||
},
|
||||
)
|
||||
mock_prisma_client.data["enduser"] = [test_enduser]
|
||||
mock_prisma_client.db.litellm_endusertable.set_find_many_results([test_enduser])
|
||||
|
||||
asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table())
|
||||
|
||||
|
|
@ -1596,6 +1647,107 @@ def test_budget_table_reset_invalidates_enduser_counter_and_cache(reset_budget_j
|
|||
assert "end_user_id:customer-42" in deleted
|
||||
|
||||
|
||||
def test_enduser_invalidation_is_paged_and_batched(reset_budget_job, mock_prisma_client, monkeypatch):
|
||||
"""The post-commit invalidation walk stays bounded in memory and in round trips.
|
||||
|
||||
Reading every customer on an expiring tier into one result set puts a
|
||||
customer-count-sized list in the proxy's heap on every tick, which is an OOM
|
||||
on a large enough deployment rather than a slow tick. Awaiting one cache call
|
||||
per customer makes the last customer wait out every customer ahead of it.
|
||||
Both regress silently, so pin the page size, the strictly advancing cursor,
|
||||
and one batched call per page.
|
||||
"""
|
||||
counter_cache: Final = _make_counter_invalidation_job(monkeypatch)
|
||||
mock_prisma_client.data["budget"] = [_budget_row(budget_id="budget-1")]
|
||||
population: Final = RESET_BUDGET_JOB_BATCH_SIZE * 2 + 3
|
||||
mock_prisma_client.db.litellm_endusertable.set_find_many_results(
|
||||
[
|
||||
type("EndUser", (), {"user_id": f"cust-{i:06d}", "spend": 5.0, "budget_id": "budget-1"})
|
||||
for i in range(population)
|
||||
]
|
||||
)
|
||||
|
||||
asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table())
|
||||
|
||||
reads: Final = mock_prisma_client.db.litellm_endusertable.find_many_calls
|
||||
assert [read["take"] for read in reads] == [RESET_BUDGET_JOB_BATCH_SIZE] * 3
|
||||
assert [read["where"]["user_id"]["gt"] for read in reads] == [
|
||||
"",
|
||||
f"cust-{RESET_BUDGET_JOB_BATCH_SIZE - 1:06d}",
|
||||
f"cust-{RESET_BUDGET_JOB_BATCH_SIZE * 2 - 1:06d}",
|
||||
]
|
||||
|
||||
assert counter_cache.async_delete_cache_keys.await_count == 3
|
||||
assert counter_cache.user_api_key_cache.async_delete_cache_keys.await_count == 3
|
||||
counter_cache.async_delete_cache.assert_not_called()
|
||||
|
||||
invalidated: Final = {
|
||||
key for call in counter_cache.async_delete_cache_keys.await_args_list for key in call.args[0]
|
||||
}
|
||||
assert invalidated == {f"spend:end_user:cust-{i:06d}" for i in range(population)}
|
||||
evicted: Final = {
|
||||
key for call in counter_cache.user_api_key_cache.async_delete_cache_keys.await_args_list for key in call.args[0]
|
||||
}
|
||||
assert evicted == {f"end_user_id:cust-{i:06d}" for i in range(population)}
|
||||
|
||||
|
||||
|
||||
def test_enduser_invalidation_reports_a_page_read_failure_instead_of_a_clean_finish(
|
||||
mock_prisma_client, monkeypatch
|
||||
):
|
||||
"""A page that fails to read is not the end of the customer list.
|
||||
|
||||
The tier's window is already advanced by the time this walk runs, so no later
|
||||
tick comes back for the customers past the page that failed: their cached
|
||||
spend goes on rejecting requests until it expires. Returning the same empty
|
||||
page normal end-of-data returns hid that behind a report of a clean pass.
|
||||
"""
|
||||
_make_counter_invalidation_job(monkeypatch)
|
||||
mock_prisma_client.data["budget"] = [_budget_row(budget_id="budget-1")]
|
||||
endusers: Final = mock_prisma_client.db.litellm_endusertable
|
||||
endusers.set_find_many_results(
|
||||
[
|
||||
type("EndUser", (), {"user_id": f"cust-{i:06d}", "spend": 5.0, "budget_id": "budget-1"})
|
||||
for i in range(RESET_BUDGET_JOB_BATCH_SIZE + 3)
|
||||
]
|
||||
)
|
||||
endusers.set_find_many_error(1, RuntimeError("connection reset while paging customers"))
|
||||
logging_obj: Final = RecordingProxyLogging()
|
||||
job: Final = ResetBudgetJob(proxy_logging_obj=logging_obj, prisma_client=mock_prisma_client)
|
||||
|
||||
_run_and_drain_hooks(job.reset_budget_for_litellm_budget_table)
|
||||
|
||||
metadata: Final = logging_obj.service_logging_obj.success_calls[0]["event_metadata"]
|
||||
assert metadata["enduser_invalidation_truncated"] is True
|
||||
assert metadata["num_endusers_updated"] == RESET_BUDGET_JOB_BATCH_SIZE
|
||||
|
||||
|
||||
def test_a_failed_counter_batch_still_evicts_the_management_cache(
|
||||
reset_budget_job, mock_prisma_client, monkeypatch
|
||||
):
|
||||
"""The spend counters and the management cache are invalidated independently.
|
||||
|
||||
Sharing one handler meant a Redis failure on the counters returned before the
|
||||
management cache was touched at all. The commit has already zeroed those rows
|
||||
by then, so the cached objects keep authorizing against their pre-reset spend
|
||||
until they expire.
|
||||
"""
|
||||
counter_cache: Final = _make_counter_invalidation_job(monkeypatch)
|
||||
counter_cache.async_delete_cache_keys = AsyncMock(side_effect=RuntimeError("redis unavailable"))
|
||||
mock_prisma_client.data["budget"] = [_budget_row(budget_id="budget-1")]
|
||||
mock_prisma_client.db.litellm_endusertable.set_find_many_results(
|
||||
[type("EndUser", (), {"user_id": "customer-42", "spend": 5.0, "budget_id": "budget-1"})]
|
||||
)
|
||||
|
||||
asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table())
|
||||
|
||||
evicted: Final = {
|
||||
key
|
||||
for call in counter_cache.user_api_key_cache.async_delete_cache_keys.await_args_list
|
||||
for key in call.args[0]
|
||||
}
|
||||
assert "end_user_id:customer-42" in evicted
|
||||
|
||||
|
||||
def test_budget_table_reset_commits_even_when_cache_eviction_fails(reset_budget_job, mock_prisma_client, monkeypatch):
|
||||
"""Eviction runs after the commit, so a broken cache cannot undo the write."""
|
||||
|
|
|
|||
|
|
@ -82,6 +82,19 @@ class FakeRedisCache(RedisCache):
|
|||
async def async_delete_cache(self, key: str): # type: ignore[override]
|
||||
self._store.pop(key, None)
|
||||
|
||||
async def delete_cache_keys(self, keys): # type: ignore[override]
|
||||
for key in keys:
|
||||
self._store.pop(key, None)
|
||||
|
||||
|
||||
class PartitionFailingRedisCache(FakeRedisCache):
|
||||
"""Fails the batch delete for the key-object partition and no other."""
|
||||
|
||||
async def delete_cache_keys(self, keys): # type: ignore[override]
|
||||
if any(is_user_key_cache_key(key) for key in keys):
|
||||
raise ConnectionError("redis unavailable")
|
||||
await super().delete_cache_keys(keys)
|
||||
|
||||
|
||||
def _make_key_obj(token: str = "tok") -> UserAPIKeyAuth:
|
||||
# Minimal object (UserAPIKeyAuth inherits token from base view).
|
||||
|
|
@ -331,6 +344,46 @@ class TestUserKeyObjectPartition:
|
|||
assert await cache.async_get_cache(HASHED_TOKEN, model_type=UserAPIKeyAuth) is None
|
||||
assert await redis.async_get_cache(HASHED_TOKEN) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_delete_routes_each_key_to_its_partition(self):
|
||||
"""A batch delete has to clear the same partition the single delete does.
|
||||
|
||||
``DualCache``'s batch delete only knows about the main in-memory cache, so
|
||||
inheriting it unchanged leaves a key object sitting in ``key_object_cache``
|
||||
with its pre-reset spend, and the next request is authorized against that
|
||||
stale copy until the local entry expires.
|
||||
"""
|
||||
redis = FakeRedisCache()
|
||||
cache = UserApiKeyCache(redis_cache=redis)
|
||||
await cache.async_set_cache(HASHED_TOKEN, _make_key_obj(HASHED_TOKEN), model_type=UserAPIKeyAuth)
|
||||
await cache.async_set_cache(end_user_cache_key("u1"), {"user_id": "u1"})
|
||||
|
||||
await cache.async_delete_cache_keys([HASHED_TOKEN, end_user_cache_key("u1")])
|
||||
|
||||
assert await cache.async_get_cache(HASHED_TOKEN, model_type=UserAPIKeyAuth) is None
|
||||
assert await cache.async_get_cache(end_user_cache_key("u1")) is None
|
||||
assert await redis.async_get_cache(HASHED_TOKEN) is None
|
||||
assert await redis.async_get_cache(end_user_cache_key("u1")) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_delete_clears_the_other_partition_when_one_fails(self):
|
||||
"""One partition failing must not cost the other its deletions.
|
||||
|
||||
A caller batching these has already committed the rows they cache, so a
|
||||
partition that is skipped keeps authorizing against pre-reset spend until
|
||||
the entry expires. The failure is still raised for the caller to report.
|
||||
"""
|
||||
redis = PartitionFailingRedisCache()
|
||||
cache = UserApiKeyCache(redis_cache=redis)
|
||||
await cache.async_set_cache(HASHED_TOKEN, _make_key_obj(HASHED_TOKEN), model_type=UserAPIKeyAuth)
|
||||
await cache.async_set_cache(end_user_cache_key("u1"), {"user_id": "u1"})
|
||||
|
||||
with pytest.raises(ConnectionError):
|
||||
await cache.async_delete_cache_keys([HASHED_TOKEN, end_user_cache_key("u1")])
|
||||
|
||||
assert await cache.async_get_cache(end_user_cache_key("u1")) is None
|
||||
assert await redis.async_get_cache(end_user_cache_key("u1")) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_write_routes_each_entry_to_its_partition(self):
|
||||
cache = UserApiKeyCache(in_memory_cache=InMemoryCache(max_size_in_memory=2))
|
||||
|
|
|
|||
|
|
@ -1049,6 +1049,27 @@ def test_get_logging_payload_replaces_rejected_or_prompt_shaped_models_with_the_
|
|||
assert payload["model"] == expected_model
|
||||
|
||||
|
||||
@pytest.mark.parametrize("requested_model", [{"bad": "value"}, ["gpt-5.2"], 1])
|
||||
def test_get_logging_payload_replaces_a_non_string_model_with_the_placeholder(
|
||||
requested_model: dict[str, str] | list[str] | int,
|
||||
):
|
||||
kwargs: Final = {
|
||||
"model": requested_model,
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"call_type": "acompletion",
|
||||
"litellm_params": {"metadata": {"user_api_key": "sk-test", "status": "failure"}},
|
||||
}
|
||||
|
||||
payload: Final = get_logging_payload(
|
||||
kwargs=kwargs,
|
||||
response_obj=ValueError("model must be a string"),
|
||||
start_time=datetime.datetime.now(timezone.utc),
|
||||
end_time=datetime.datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
assert payload["model"] == UNKNOWN_MODEL_SPEND_LOG_MODEL
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("metadata", "response_obj"),
|
||||
[
|
||||
|
|
|
|||
|
|
@ -327,6 +327,35 @@ class TestProxyBaseLLMRequestProcessing:
|
|||
pytest.fail("litellm_call_id is not a valid UUID")
|
||||
assert data_passed["litellm_call_id"] == returned_data["litellm_call_id"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("requested_model", [{"bad": "value"}, ["gpt-5.2"], 1])
|
||||
async def test_common_processing_pre_call_logic_rejects_a_non_string_model_with_400(
|
||||
self, monkeypatch, requested_model: dict[str, str] | list[str] | int
|
||||
):
|
||||
processing_obj = ProxyBaseLLMRequestProcessing(
|
||||
data={"model": requested_model, "messages": [{"role": "user", "content": "hi"}]}
|
||||
)
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers = {}
|
||||
add_litellm_data_to_request = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
litellm.proxy.common_request_processing, "add_litellm_data_to_request", add_litellm_data_to_request
|
||||
)
|
||||
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await processing_obj.common_processing_pre_call_logic(
|
||||
request=mock_request,
|
||||
general_settings={},
|
||||
user_api_key_dict=MagicMock(spec=UserAPIKeyAuth),
|
||||
proxy_logging_obj=MagicMock(spec=ProxyLogging),
|
||||
proxy_config=MagicMock(spec=ProxyConfig),
|
||||
route_type="acompletion",
|
||||
)
|
||||
|
||||
assert exc_info.value.code == str(status.HTTP_400_BAD_REQUEST)
|
||||
assert exc_info.value.param == "model"
|
||||
add_litellm_data_to_request.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_common_processing_pre_call_logic_refreshes_proxy_server_request_body_after_guardrails(
|
||||
self, monkeypatch
|
||||
|
|
|
|||
|
|
@ -4,15 +4,23 @@ Static checks that every proxy Docker image installs the `bedrock-realtime` extr
|
|||
Bedrock Nova Sonic speech-to-speech (`/v1/realtime`) needs `aws-sdk-bedrock-runtime`,
|
||||
which only ships in the `bedrock-realtime` extra. An image whose `uv sync` stages
|
||||
omit the extra fails every Nova Sonic realtime session with
|
||||
"Missing aws_sdk_bedrock_runtime. Install with: pip install aws-sdk-bedrock-runtime".
|
||||
"Missing aws_sdk_bedrock_runtime: pip install 'litellm[bedrock-realtime]' ...".
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.constants import BEDROCK_REALTIME_SDK_DISTRIBUTION, BEDROCK_REALTIME_SDK_SUPPORTED_RANGE
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
import tomllib
|
||||
else:
|
||||
import tomli as tomllib
|
||||
|
||||
REPO_ROOT: Final = os.path.join(os.path.dirname(__file__), "..", "..")
|
||||
|
||||
PROXY_DOCKERFILES: Final = (
|
||||
|
|
@ -54,3 +62,16 @@ def test_every_uv_sync_installs_bedrock_realtime_extra(relative_path: str):
|
|||
"`--extra bedrock-realtime`, so aws-sdk-bedrock-runtime is absent and Bedrock Nova Sonic "
|
||||
"/v1/realtime sessions fail with 'Missing aws_sdk_bedrock_runtime'"
|
||||
)
|
||||
|
||||
|
||||
def test_bedrock_realtime_extra_pins_the_range_named_in_the_runtime_error():
|
||||
with open(os.path.join(REPO_ROOT, "pyproject.toml"), "rb") as f:
|
||||
extra_specs: Final = tomllib.load(f)["project"]["optional-dependencies"]["bedrock-realtime"]
|
||||
|
||||
sdk_specs: Final = tuple(spec for spec in extra_specs if spec.startswith(BEDROCK_REALTIME_SDK_DISTRIBUTION))
|
||||
assert len(sdk_specs) == 1, f"expected exactly one {BEDROCK_REALTIME_SDK_DISTRIBUTION} spec, got {extra_specs}"
|
||||
requirement: Final = sdk_specs[0].split(";")[0].strip()
|
||||
assert requirement == f"{BEDROCK_REALTIME_SDK_DISTRIBUTION}[awscrt]{BEDROCK_REALTIME_SDK_SUPPORTED_RANGE}", (
|
||||
f"pyproject pins {requirement!r} but the handler's install hint names "
|
||||
f"{BEDROCK_REALTIME_SDK_SUPPORTED_RANGE!r} with the awscrt extra; keep them in sync"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ def _successor(info: dict[str, object]) -> str | None:
|
|||
return successor if isinstance(successor, str) else None
|
||||
|
||||
|
||||
def test_together_successor_metadata_points_at_live_models(cost_map: CostMap):
|
||||
def test_together_successor_metadata_points_at_known_models(cost_map: CostMap):
|
||||
successors = {
|
||||
model: successor
|
||||
for model, info in cost_map.items()
|
||||
|
|
@ -103,9 +103,7 @@ def test_together_successor_metadata_points_at_live_models(cost_map: CostMap):
|
|||
}
|
||||
assert len(successors) >= 10
|
||||
for model, successor in successors.items():
|
||||
target = cost_map.get(successor)
|
||||
assert target is not None, f"{model} names successor {successor} that is not in the map"
|
||||
assert "deprecation_date" not in target, f"{model} names deprecated successor {successor}"
|
||||
assert successor in cost_map, f"{model} names successor {successor} that is not in the map"
|
||||
|
||||
|
||||
def test_together_backup_cost_map_in_sync(cost_map: CostMap):
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders, screen, waitFor } from "../../../../tests/test-utils";
|
||||
import { renderWithProviders, screen, waitFor, within } from "../../../../tests/test-utils";
|
||||
import {
|
||||
GuardrailInformation,
|
||||
makeBedrockResponse,
|
||||
|
|
@ -24,6 +24,42 @@ const skippedPreCall: Partial<GuardrailInformation> = {
|
|||
duration: null,
|
||||
};
|
||||
|
||||
const untimedPreCall: Partial<GuardrailInformation> = {
|
||||
guardrail_name: "conduct",
|
||||
guardrail_status: "success",
|
||||
guardrail_mode: "pre_call",
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
duration: null,
|
||||
};
|
||||
|
||||
const timedPreCall: Partial<GuardrailInformation> = {
|
||||
guardrail_name: "timed-pre-rail",
|
||||
guardrail_status: "success",
|
||||
guardrail_mode: "pre_call",
|
||||
start_time: 1_700_000_000,
|
||||
end_time: 1_700_000_000.1,
|
||||
duration: 0.1,
|
||||
};
|
||||
|
||||
const latePreCall: Partial<GuardrailInformation> = {
|
||||
guardrail_name: "late-pre-rail",
|
||||
guardrail_status: "success",
|
||||
guardrail_mode: "pre_call",
|
||||
start_time: 1_700_000_500,
|
||||
end_time: 1_700_000_500.1,
|
||||
duration: 0.1,
|
||||
};
|
||||
|
||||
const untimedPostCall: Partial<GuardrailInformation> = {
|
||||
guardrail_name: "untimed-post-rail",
|
||||
guardrail_status: "success",
|
||||
guardrail_mode: "post_call",
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
duration: null,
|
||||
};
|
||||
|
||||
const ranPostCall: Partial<GuardrailInformation> = {
|
||||
guardrail_name: "ran-rail",
|
||||
guardrail_status: "success",
|
||||
|
|
@ -98,6 +134,67 @@ describe("GuardrailViewer", () => {
|
|||
expect(screen.getByText("—")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps a guardrail that ran without any timing on the lifecycle", () => {
|
||||
renderWithProviders(<GuardrailViewer data={makeGuardrailInformation(untimedPreCall)} />);
|
||||
|
||||
expect(screen.getByText("Request received")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Pre-call guardrail: conduct/)).toBeInTheDocument();
|
||||
expect(screen.getByText("LLM call")).toBeInTheDocument();
|
||||
expect(screen.getByText("Response returned")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/^T\+/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps an untimed guardrail ahead of a timed one recorded after it in the same phase", () => {
|
||||
const untimed = makeGuardrailInformation(untimedPreCall);
|
||||
const timedPre = makeGuardrailInformation(timedPreCall);
|
||||
renderWithProviders(<GuardrailViewer data={[untimed, timedPre]} />);
|
||||
|
||||
const rows = screen.getAllByTestId("lifecycle-row");
|
||||
const rowIndex = (label: RegExp): number => rows.findIndex((r) => within(r).queryByText(label) !== null);
|
||||
const untimedIndex = rowIndex(/Pre-call guardrail: conduct/);
|
||||
const timedIndex = rowIndex(/Pre-call guardrail: timed-pre-rail/);
|
||||
|
||||
expect(untimedIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(timedIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(untimedIndex).toBeLessThan(timedIndex);
|
||||
});
|
||||
|
||||
it("orders each phase on its own clock when a later pre-call outlives an earlier post-call", () => {
|
||||
const latePre = makeGuardrailInformation(latePreCall);
|
||||
const untimedPost = makeGuardrailInformation(untimedPostCall);
|
||||
const earlyPost = makeGuardrailInformation(ranPostCall);
|
||||
renderWithProviders(<GuardrailViewer data={[latePre, untimedPost, earlyPost]} />);
|
||||
|
||||
const rows = screen.getAllByTestId("lifecycle-row");
|
||||
const rowIndex = (label: RegExp): number => rows.findIndex((r) => within(r).queryByText(label) !== null);
|
||||
const untimedIndex = rowIndex(/Post-call guardrail: untimed-post-rail/);
|
||||
const earlyIndex = rowIndex(/Post-call guardrail: ran-rail/);
|
||||
|
||||
expect(untimedIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(earlyIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(untimedIndex).toBeLessThan(earlyIndex);
|
||||
});
|
||||
|
||||
it("anchors offsets on the timed entries and gives the untimed one no fabricated offset", () => {
|
||||
const untimed = makeGuardrailInformation(untimedPreCall);
|
||||
const ran = makeGuardrailInformation(ranPostCall);
|
||||
renderWithProviders(<GuardrailViewer data={[untimed, ran]} />);
|
||||
|
||||
const lifecycleRow = (label: string | RegExp): HTMLElement => {
|
||||
const row = screen.getAllByTestId("lifecycle-row").find((r) => within(r).queryByText(label) !== null);
|
||||
if (row === undefined) throw new Error(`no lifecycle row labelled ${label}`);
|
||||
return row;
|
||||
};
|
||||
|
||||
expect(within(lifecycleRow("Request received")).getByText("T+0ms")).toBeInTheDocument();
|
||||
expect(within(lifecycleRow(/Post-call guardrail: ran-rail/)).getByText("T+250ms")).toBeInTheDocument();
|
||||
expect(within(lifecycleRow("Response returned")).getByText("T+251ms")).toBeInTheDocument();
|
||||
|
||||
const untimedRow = within(lifecycleRow(/Pre-call guardrail: conduct/));
|
||||
expect(untimedRow.getByText("—")).toBeInTheDocument();
|
||||
expect(untimedRow.queryByText(/^T\+/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calculates and displays masked entity totals", async () => {
|
||||
const user = userEvent.setup();
|
||||
const data = makeGuardrailInformation({
|
||||
|
|
|
|||
|
|
@ -361,7 +361,7 @@ const GenericGuardrailResponse = ({ response }: { response: any }) => {
|
|||
interface TimelineEntry {
|
||||
type: "request" | "guardrail" | "llm" | "response";
|
||||
label: string;
|
||||
offsetMs: number;
|
||||
offsetMs: number | null;
|
||||
outcome?: EntryOutcome;
|
||||
}
|
||||
|
||||
|
|
@ -370,73 +370,85 @@ type TimedGuardrailInformation = GuardrailInformation & { start_time: number; en
|
|||
const isTimed = (e: GuardrailInformation): e is TimedGuardrailInformation =>
|
||||
typeof e.start_time === "number" && typeof e.end_time === "number";
|
||||
|
||||
const belongsOnLifecycle = (e: GuardrailInformation): boolean => isTimed(e) || getEntryOutcome(e) !== "not_run";
|
||||
|
||||
// Sorts a phase's timed entries by start time while leaving its untimed entries in the
|
||||
// slots they were recorded in. Applied per phase, never globally: an entry can land in
|
||||
// more than one phase bucket, so a global pass can reorder one phase by another's clock.
|
||||
const orderWithinPhase = (group: GuardrailInformation[]): GuardrailInformation[] => {
|
||||
const byStart = group.filter(isTimed).sort((a, b) => a.start_time - b.start_time);
|
||||
const timedSlots = new Map(group.flatMap((e, i) => (isTimed(e) ? [i] : [])).map((slot, k) => [slot, byStart[k]]));
|
||||
return group.map((e, i) => timedSlots.get(i) ?? e);
|
||||
};
|
||||
|
||||
const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
|
||||
const sorted = useMemo(() => entries.filter(isTimed).sort((a, b) => a.start_time - b.start_time), [entries]);
|
||||
const sorted = useMemo(() => entries.filter(belongsOnLifecycle), [entries]);
|
||||
|
||||
const timeline = useMemo(() => {
|
||||
if (sorted.length === 0) return [];
|
||||
|
||||
const baseTime = sorted[0].start_time;
|
||||
const timed = sorted.filter(isTimed);
|
||||
const baseTime = timed.length > 0 ? Math.min(...timed.map((e) => e.start_time)) : null;
|
||||
const offsetOf = (e: GuardrailInformation): number | null =>
|
||||
baseTime === null || !isTimed(e) ? null : Math.round((e.end_time - baseTime) * 1000);
|
||||
const items: TimelineEntry[] = [];
|
||||
|
||||
// Request received
|
||||
items.push({ type: "request", label: "Request received", offsetMs: 0 });
|
||||
items.push({ type: "request", label: "Request received", offsetMs: baseTime === null ? null : 0 });
|
||||
|
||||
// Pre-call guardrails — use modeMatches so array modes (e.g. ["pre_call", "post_call"])
|
||||
// place the entry in every matching bucket.
|
||||
const preCalls = sorted.filter((e) => modeMatches(e.guardrail_mode, "pre_call"));
|
||||
const postCalls = sorted.filter(
|
||||
(e) => modeMatches(e.guardrail_mode, "post_call") || modeMatches(e.guardrail_mode, "logging_only"),
|
||||
const preCalls = orderWithinPhase(sorted.filter((e) => modeMatches(e.guardrail_mode, "pre_call")));
|
||||
const postCalls = orderWithinPhase(
|
||||
sorted.filter((e) => modeMatches(e.guardrail_mode, "post_call") || modeMatches(e.guardrail_mode, "logging_only")),
|
||||
);
|
||||
const duringCalls = sorted.filter((e) => modeMatches(e.guardrail_mode, "during_call"));
|
||||
const duringCalls = orderWithinPhase(sorted.filter((e) => modeMatches(e.guardrail_mode, "during_call")));
|
||||
|
||||
for (const e of preCalls) {
|
||||
const offsetMs = Math.round((e.end_time - baseTime) * 1000);
|
||||
items.push({
|
||||
type: "guardrail",
|
||||
label: `Pre-call guardrail: ${getDisplayName(e)}`,
|
||||
offsetMs,
|
||||
offsetMs: offsetOf(e),
|
||||
outcome: getEntryOutcome(e),
|
||||
});
|
||||
}
|
||||
|
||||
// LLM call — infer from gap between pre-call end and post-call start
|
||||
const lastPreEnd = preCalls.length > 0 ? Math.max(...preCalls.map((e) => e.end_time)) : baseTime;
|
||||
const firstPostStart = postCalls.length > 0 ? Math.min(...postCalls.map((e) => e.start_time)) : undefined;
|
||||
const llmEndTime = firstPostStart ?? lastPreEnd + 1;
|
||||
const llmOffsetMs = Math.round((llmEndTime - baseTime) * 1000);
|
||||
const timedPre = preCalls.filter(isTimed);
|
||||
const timedPost = postCalls.filter(isTimed);
|
||||
const lastPreEnd = timedPre.length > 0 ? Math.max(...timedPre.map((e) => e.end_time)) : baseTime;
|
||||
const firstPostStart = timedPost.length > 0 ? Math.min(...timedPost.map((e) => e.start_time)) : undefined;
|
||||
const llmEndTime = firstPostStart ?? (lastPreEnd === null ? null : lastPreEnd + 1);
|
||||
|
||||
items.push({
|
||||
type: "llm",
|
||||
label: "LLM call",
|
||||
offsetMs: llmOffsetMs,
|
||||
offsetMs: llmEndTime === null || baseTime === null ? null : Math.round((llmEndTime - baseTime) * 1000),
|
||||
});
|
||||
|
||||
// During-call guardrails (rare)
|
||||
for (const e of duringCalls) {
|
||||
const offsetMs = Math.round((e.end_time - baseTime) * 1000);
|
||||
items.push({
|
||||
type: "guardrail",
|
||||
label: `During-call guardrail: ${getDisplayName(e)}`,
|
||||
offsetMs,
|
||||
offsetMs: offsetOf(e),
|
||||
outcome: getEntryOutcome(e),
|
||||
});
|
||||
}
|
||||
|
||||
// Post-call guardrails
|
||||
for (const e of postCalls) {
|
||||
const offsetMs = Math.round((e.end_time - baseTime) * 1000);
|
||||
items.push({
|
||||
type: "guardrail",
|
||||
label: `Post-call guardrail: ${getDisplayName(e)}`,
|
||||
offsetMs,
|
||||
offsetMs: offsetOf(e),
|
||||
outcome: getEntryOutcome(e),
|
||||
});
|
||||
}
|
||||
|
||||
// Response returned
|
||||
const maxEnd = Math.max(...sorted.map((e) => e.end_time));
|
||||
const responseOffsetMs = Math.round((maxEnd - baseTime) * 1000) + 1;
|
||||
const maxEnd = timed.length > 0 ? Math.max(...timed.map((e) => e.end_time)) : null;
|
||||
const responseOffsetMs = maxEnd === null || baseTime === null ? null : Math.round((maxEnd - baseTime) * 1000) + 1;
|
||||
items.push({ type: "response", label: "Response returned", offsetMs: responseOffsetMs });
|
||||
|
||||
return items;
|
||||
|
|
@ -447,7 +459,7 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
|
|||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-4">Request Lifecycle</h4>
|
||||
<div className="relative">
|
||||
{timeline.map((item, idx) => (
|
||||
<div key={idx} className="flex items-start gap-3 relative">
|
||||
<div key={idx} data-testid="lifecycle-row" className="flex items-start gap-3 relative">
|
||||
{/* Vertical line */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="shrink-0">
|
||||
|
|
@ -475,7 +487,9 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => {
|
|||
{OUTCOME_LABEL[item.outcome]}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground font-mono ml-auto shrink-0">T+{item.offsetMs}ms</span>
|
||||
<span className="text-xs text-muted-foreground font-mono ml-auto shrink-0">
|
||||
{item.offsetMs === null ? "—" : `T+${item.offsetMs}ms`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
49
uv.lock
generated
49
uv.lock
generated
|
|
@ -10,7 +10,7 @@ resolution-markers = [
|
|||
]
|
||||
|
||||
[options]
|
||||
exclude-newer = "2026-09-12T22:48:38.53978Z"
|
||||
exclude-newer = "2026-09-14T20:32:38.482736111Z"
|
||||
exclude-newer-span = "P3D"
|
||||
|
||||
[manifest]
|
||||
|
|
@ -535,16 +535,21 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "aws-sdk-bedrock-runtime"
|
||||
version = "0.7.0"
|
||||
version = "0.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "smithy-aws-core", extra = ["eventstream", "json"], marker = "python_full_version >= '3.12'" },
|
||||
{ name = "smithy-core", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "smithy-http", extra = ["awscrt"], marker = "python_full_version >= '3.12'" },
|
||||
{ name = "smithy-http", extra = ["aiohttp"], marker = "python_full_version >= '3.12'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/67/8a/ed3fd98775273b0b7f6006b4970aa876d506668b7fe29145f54fcb941c3b/aws_sdk_bedrock_runtime-0.7.0.tar.gz", hash = "sha256:0cb172cbc03ff060e5c1d6f9cfa9a8ac5e71d9e0d58d3117006ebf614cbb4677", size = 170304, upload-time = "2026-06-23T04:04:52.382Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/b3/9c225cbfe9f17ea2e3d75a0fdd0b325ef79839b9c09a376bda63a7bf3bb3/aws_sdk_bedrock_runtime-0.11.0.tar.gz", hash = "sha256:f2c45d34625bf6a7b56375e29a53a16b376880bda771e4bbf7d84491622eb193", size = 173854, upload-time = "2026-08-24T21:17:16.304Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/e1/f86d50f0ad9c8200645f315c524d285e86b30b94bb65118e1108597714e6/aws_sdk_bedrock_runtime-0.7.0-py3-none-any.whl", hash = "sha256:de67ede6f441bbb77ef61c237945d559513843fc827abe1af12535c2519650c5", size = 94948, upload-time = "2026-06-23T04:04:51.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/0c/9512304ed017ce49992df6661eac2b914550247e13bccb55be6ca594170d/aws_sdk_bedrock_runtime-0.11.0-py3-none-any.whl", hash = "sha256:ef01c26ddfd83a5d3e438ab72ebb3c13b41fc0ef11d81095b22c8016f97e9795", size = 97112, upload-time = "2026-08-24T21:17:17.396Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
awscrt = [
|
||||
{ name = "smithy-http", extra = ["awscrt"], marker = "python_full_version >= '3.12'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4483,7 +4488,7 @@ dependencies = [
|
|||
|
||||
[package.optional-dependencies]
|
||||
bedrock-realtime = [
|
||||
{ name = "aws-sdk-bedrock-runtime", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "aws-sdk-bedrock-runtime", extra = ["awscrt"], marker = "python_full_version >= '3.12'" },
|
||||
]
|
||||
caching = [
|
||||
{ name = "diskcache" },
|
||||
|
|
@ -4693,7 +4698,7 @@ requires-dist = [
|
|||
{ name = "apscheduler", marker = "extra == 'proxy'", specifier = ">=3.11.2,<4.0" },
|
||||
{ name = "audioread", marker = "extra == 'stt-nvidia-riva'", specifier = ">=3.0.1" },
|
||||
{ name = "aurelio-sdk", marker = "python_full_version < '3.14' and extra == 'semantic-router'", specifier = ">=0.0.19,<1.0" },
|
||||
{ name = "aws-sdk-bedrock-runtime", marker = "python_full_version >= '3.12' and extra == 'bedrock-realtime'", specifier = ">=0.7.0,<0.8.0" },
|
||||
{ name = "aws-sdk-bedrock-runtime", extras = ["awscrt"], marker = "python_full_version >= '3.12' and extra == 'bedrock-realtime'", specifier = ">=0.10.0,<0.12.0" },
|
||||
{ name = "azure-ai-contentsafety", marker = "extra == 'proxy-runtime'", specifier = ">=1.0.0,<2.0" },
|
||||
{ name = "azure-identity", marker = "extra == 'extra-proxy'", specifier = ">=1.25.2,<2.0" },
|
||||
{ name = "azure-identity", marker = "extra == 'proxy'", specifier = ">=1.25.2,<2.0" },
|
||||
|
|
@ -4884,7 +4889,7 @@ source = { editable = "enterprise" }
|
|||
|
||||
[[package]]
|
||||
name = "litellm-proxy-extras"
|
||||
version = "0.4.98"
|
||||
version = "0.4.99"
|
||||
source = { editable = "litellm-proxy-extras" }
|
||||
|
||||
[[package]]
|
||||
|
|
@ -9126,16 +9131,16 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "smithy-aws-core"
|
||||
version = "0.7.0"
|
||||
version = "0.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aws-sdk-signers", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "smithy-core", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "smithy-http", marker = "python_full_version >= '3.12'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/a8/37bfde59519f45d2047d0033b791aca6574d867aaf57bb56a6de42ab5c26/smithy_aws_core-0.7.0.tar.gz", hash = "sha256:34e82d09fc808acd5ffc80f03828d0609c6a211f49f0884dc6ee7ca095a1b6af", size = 15670, upload-time = "2026-06-23T04:04:50.365Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/d3/501c0023548173416109ac42298ca33b708469dc922005770811a597949f/smithy_aws_core-0.11.0.tar.gz", hash = "sha256:29ee89976a520a87e3db557e03e115fdc21a0a60b81161e95174395a1b064da1", size = 38791, upload-time = "2026-08-24T21:16:59.631Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/54/2d06dd9a3972a380d71bb8c3312e317aa8f1ea68dd28cffc06955ccf0220/smithy_aws_core-0.7.0-py3-none-any.whl", hash = "sha256:6c60c8fbb9431c60e80ea7f2d37e7ae48409cc1541f587fe073f202eca067e92", size = 24894, upload-time = "2026-06-23T04:04:49.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/f6/fefda9aab809fa1a62bf7073bd6d8ab427bd9989f39b13c0d6e29d4d1045/smithy_aws_core-0.11.0-py3-none-any.whl", hash = "sha256:77cf130c22deac14a8cbeb8ccc4bcfe5a91798f4b38cb53a987080ec58c89f23", size = 58855, upload-time = "2026-08-24T21:16:58.657Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
|
@ -9160,41 +9165,45 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "smithy-core"
|
||||
version = "0.6.0"
|
||||
version = "0.8.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/45/688d52c61cd4d843bb230694259e91d4c7d6954eeecbadf452a168001d45/smithy_core-0.6.0.tar.gz", hash = "sha256:ba2e5d860d716aff75004a23f53e09dfaca3e2b94f8a00c1f76dcb355b769ce0", size = 52095, upload-time = "2026-06-23T04:04:44.687Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7c/c6/93e9eea3c6163228dfe972c3e989e0553047858805ab7aa4a59f074ba129/smithy_core-0.8.1.tar.gz", hash = "sha256:3d2f8fca5960d74bd7ef380f70901c7bcdebe53f929d2d3d2fa6cb790b3f5214", size = 54259, upload-time = "2026-08-20T17:55:30.354Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/b6/06795faa9844b9667ae492e6293370393e19e7f0c2df8da1b4bf7e5f6ed9/smithy_core-0.6.0-py3-none-any.whl", hash = "sha256:51e347ed309d60ab9d36b783dbf88de614c460d51bec79d39cd403956b00f063", size = 66879, upload-time = "2026-06-23T04:04:43.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/23/c6430bbf406477fc7d16254b9908a723b299a4a21a94c99db9d12c84a8bf/smithy_core-0.8.1-py3-none-any.whl", hash = "sha256:44bd9bdf702f76919af58e44a6a1bb3dc136a745b2f955281743022ce767e347", size = 68805, upload-time = "2026-08-20T17:55:29.366Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smithy-http"
|
||||
version = "0.4.2"
|
||||
version = "0.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "smithy-core", marker = "python_full_version >= '3.12'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/58/5a772d212e066d6fc1398946c4aae19bcdaa75209879d776f641b6a06b5b/smithy_http-0.4.2.tar.gz", hash = "sha256:50d11b6a55e42448450a01e3d0f605ccee65a72abf52d02eed82862a15be5937", size = 29616, upload-time = "2026-06-23T04:04:45.687Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/78/b5f3113d6c8f0bc1f9777a7f5ca84b892d29efac05850e14f7d4f7e645b5/smithy_http-0.5.0.tar.gz", hash = "sha256:bb4a19672f7c7eeb872a308f777eb505281a5bafb1ee3d1ea9c760c06c352510", size = 31122, upload-time = "2026-08-24T21:16:56.488Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/57/3e/7b2464d40893bec0b5d1f479d25116d4aa09f9f66536b4c4b3126202215d/smithy_http-0.4.2-py3-none-any.whl", hash = "sha256:a158f107e9fab925289d20772c2e38b0bba94e55c05d0edc9290310f22a60454", size = 41025, upload-time = "2026-06-23T04:04:46.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/27/e414082643028846b73afa52a1a8f934548196ee12b187a06803f02a3e66/smithy_http-0.5.0-py3-none-any.whl", hash = "sha256:af273d5f42e7733ce7a6e9bd6fdd6a59ef1b61f6cd1f4a89dd53dfce99da7bef", size = 42198, upload-time = "2026-08-24T21:16:57.52Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
aiohttp = [
|
||||
{ name = "aiohttp", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "yarl", marker = "python_full_version >= '3.12'" },
|
||||
]
|
||||
awscrt = [
|
||||
{ name = "awscrt", marker = "python_full_version >= '3.12'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smithy-json"
|
||||
version = "0.2.3"
|
||||
version = "0.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "ijson", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "smithy-core", marker = "python_full_version >= '3.12'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/6c/418b5687d8933b7a135d5e1a98c61fe814b98f72517dbae0e666860cb876/smithy_json-0.2.3.tar.gz", hash = "sha256:686e9b55a36dacb08e472732b358573ef78009055e05e9fce2e806d61490b2b3", size = 7805, upload-time = "2026-06-23T04:04:47.71Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/ac/04164eefb3da7479f52f6535b4b39cc8384c292cb2bb74279f2acc4f4b4d/smithy_json-0.3.0.tar.gz", hash = "sha256:c81c7034587e01bc64767cbbecb05a7d65ca9070612fd94e8a03e80540290a22", size = 7956, upload-time = "2026-08-20T17:55:32.177Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/14/eabb26b355415bcd9feef27fb5b18f1dad3fabd4208cfcbaf152025fa9ae/smithy_json-0.2.3-py3-none-any.whl", hash = "sha256:594e1bbe3d480963237f8fd0fc648dbd4e988b4503fea90157b5f07706796327", size = 10252, upload-time = "2026-06-23T04:04:48.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/cf/0104c40a0e18fa307ea3da4310eba949f474a5bc1df3cc2b5851a72e8486/smithy_json-0.3.0-py3-none-any.whl", hash = "sha256:ffb73d2e60cf5e616e5d0a1019e7b9f518edba076cb423f10981457725dcddc4", size = 10252, upload-time = "2026-08-20T17:55:31.204Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue