mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge remote-tracking branch 'berri/litellm_internal_staging' into litellm_bedrock_realtime_staging
This commit is contained in:
commit
29cbca813a
124 changed files with 5699 additions and 377 deletions
31
.github/workflows/image-scan.yml
vendored
31
.github/workflows/image-scan.yml
vendored
|
|
@ -23,6 +23,8 @@ on:
|
|||
- tests/proxy_migration_tests/**
|
||||
- uv.lock
|
||||
- ui/litellm-dashboard/package-lock.json
|
||||
- ui/Dockerfile
|
||||
- ui/nginx.conf
|
||||
- .github/workflows/image-scan.yml
|
||||
schedule:
|
||||
- cron: "41 6 * * *"
|
||||
|
|
@ -185,6 +187,35 @@ jobs:
|
|||
python -m pip install "pytest==9.0.3"
|
||||
python -m pytest tests/proxy_migration_tests/test_component_image_serves_offline.py -v
|
||||
|
||||
ui-image:
|
||||
name: ui-image
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Build UI image
|
||||
run: docker build -f ui/Dockerfile -t litellm-ui-scan:${{ github.sha }} .
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Verify the UI serves offline as an arbitrary uid with a read-only root fs
|
||||
env:
|
||||
LITELLM_IMAGE: litellm-ui-scan:${{ github.sha }}
|
||||
run: |
|
||||
python -m pip install "pytest==9.0.3"
|
||||
python -m pytest tests/proxy_migration_tests/test_ui_image_serves_offline.py -v
|
||||
|
||||
backend-image:
|
||||
name: backend-image
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
6
.github/workflows/test-unit.yml
vendored
6
.github/workflows/test-unit.yml
vendored
|
|
@ -211,7 +211,7 @@ jobs:
|
|||
workers: 2
|
||||
reruns: 2
|
||||
timeout-minutes: 20
|
||||
job-timeout-minutes: 55
|
||||
job-timeout-minutes: 60
|
||||
|
||||
- shard: proxy-extras
|
||||
artifact-name: proxy-extras
|
||||
|
|
@ -219,7 +219,7 @@ jobs:
|
|||
workers: 2
|
||||
reruns: 2
|
||||
timeout-minutes: 20
|
||||
job-timeout-minutes: 55
|
||||
job-timeout-minutes: 60
|
||||
|
||||
- shard: enterprise-package
|
||||
artifact-name: enterprise-package
|
||||
|
|
@ -227,7 +227,7 @@ jobs:
|
|||
workers: 4
|
||||
reruns: 2
|
||||
timeout-minutes: 20
|
||||
job-timeout-minutes: 55
|
||||
job-timeout-minutes: 60
|
||||
|
||||
- shard: responses-caching-types
|
||||
artifact-name: responses-caching-types
|
||||
|
|
|
|||
|
|
@ -428,9 +428,11 @@ ui:
|
|||
maxUnavailable: ""
|
||||
podAnnotations: {}
|
||||
# Same shape as the gateway blocks of the same name. The nginx runtime
|
||||
# writes its pid, cache, and proxy temp files under the image's root
|
||||
# filesystem, so `securityContext.readOnlyRootFilesystem: true` here needs
|
||||
# emptyDir volumes mounted over those paths.
|
||||
# writes its pid, cache, and proxy temp files under /tmp, so it boots as
|
||||
# any (arbitrary, non-root) uid; `securityContext.readOnlyRootFilesystem:
|
||||
# true` here needs an emptyDir volume mounted over /tmp. Images before
|
||||
# the /tmp move instead need emptyDirs over /var/cache/nginx and /run to
|
||||
# run as a non-root uid at all.
|
||||
podLabels: {}
|
||||
podSecurityContext: {}
|
||||
securityContext: {}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ Custom A2A Card Resolver for LiteLLM.
|
|||
Extends the A2A SDK's card resolver to support multiple well-known paths.
|
||||
"""
|
||||
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -48,6 +49,43 @@ def is_localhost_or_internal_url(url: str | None) -> bool:
|
|||
return any(pattern in url_lower for pattern in LOCALHOST_URL_PATTERNS)
|
||||
|
||||
|
||||
_CANONICAL_PROTOCOL_BINDINGS: Final = MappingProxyType(
|
||||
{
|
||||
"jsonrpc": "JSONRPC",
|
||||
"http+json": "HTTP+JSON",
|
||||
"grpc": "GRPC",
|
||||
}
|
||||
)
|
||||
|
||||
_LEGACY_PROTOCOL_VERSION: Final = "0.3"
|
||||
|
||||
|
||||
def normalize_agent_card_interfaces(agent_card: "AgentCard") -> "AgentCard":
|
||||
"""
|
||||
Canonicalize the supported interfaces of spec-adjacent agent cards.
|
||||
|
||||
Some A2A servers (e.g. LangGraph Platform) serve agent cards with lowercase
|
||||
bindings like "jsonrpc", but a2a-sdk's ClientFactory matches bindings
|
||||
case-sensitively against its uppercase TransportProtocol constants and fails
|
||||
with "no compatible transports found." for spec-adjacent casings.
|
||||
|
||||
The same servers also speak the A2A 0.3 JSON dialect ("kind"-discriminated
|
||||
payloads) while declaring protocolVersion "1.0", which a2a-sdk's strict v1
|
||||
proto parsing rejects. A mis-cased binding fingerprints such a server, so its
|
||||
declared version is downgraded to 0.3 to route the SDK's ClientFactory onto
|
||||
its v0.3 compat transport, which speaks that dialect.
|
||||
"""
|
||||
normalized: Final = type(agent_card)()
|
||||
normalized.CopyFrom(agent_card)
|
||||
for interface in normalized.supported_interfaces:
|
||||
canonical: str | None = _CANONICAL_PROTOCOL_BINDINGS.get(interface.protocol_binding.lower())
|
||||
if canonical is None or canonical == interface.protocol_binding:
|
||||
continue
|
||||
interface.protocol_binding = canonical
|
||||
interface.protocol_version = _LEGACY_PROTOCOL_VERSION
|
||||
return normalized
|
||||
|
||||
|
||||
def get_agent_card_url(agent_card: "AgentCard") -> str | None:
|
||||
"""Return the agent endpoint URL from the resolved SDK card."""
|
||||
url: Final = getattr(agent_card, "url", None)
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ except ImportError:
|
|||
from litellm.a2a_protocol.card_resolver import (
|
||||
LiteLLMA2ACardResolver,
|
||||
get_agent_card_url,
|
||||
normalize_agent_card_interfaces,
|
||||
)
|
||||
from litellm.a2a_protocol.exception_mapping_utils import (
|
||||
handle_a2a_localhost_retry,
|
||||
|
|
@ -782,13 +783,17 @@ async def create_a2a_client(
|
|||
if extra_headers:
|
||||
verbose_proxy_logger.debug("A2A client created with extra_headers=%s", list(extra_headers.keys()))
|
||||
|
||||
resolver: Final = A2ACardResolver(httpx_client=httpx_client, base_url=base_url)
|
||||
agent_card: Final = normalize_agent_card_interfaces(
|
||||
await resolver.get_agent_card(http_kwargs={"headers": extra_headers} if extra_headers else None)
|
||||
)
|
||||
|
||||
a2a_client: Final = await create_client( # pyright: ignore[reportOptionalCall]
|
||||
base_url,
|
||||
agent_card,
|
||||
client_config=ClientConfig( # pyright: ignore[reportOptionalCall]
|
||||
httpx_client=httpx_client,
|
||||
streaming=streaming,
|
||||
),
|
||||
resolver_http_kwargs={"headers": extra_headers} if extra_headers else None,
|
||||
)
|
||||
# Stash LiteLLM-owned handles on the client so the localhost-retry path can reuse
|
||||
# the configured httpx client and this agent's headers without excavating
|
||||
|
|
@ -799,9 +804,7 @@ async def create_a2a_client(
|
|||
if extra_headers
|
||||
else None
|
||||
)
|
||||
agent_card: Final = getattr(a2a_client, "_card", None)
|
||||
if agent_card is not None:
|
||||
a2a_client._litellm_agent_card = agent_card
|
||||
a2a_client._litellm_agent_card = agent_card
|
||||
|
||||
verbose_logger.info("A2A client created for %s", base_url)
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ from pydantic import BaseModel
|
|||
import litellm
|
||||
from litellm import ModelResponse
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
responses_reasoning_item_from_thinking_blocks,
|
||||
)
|
||||
from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator
|
||||
from litellm.llms.base_llm.bridges.completion_transformation import (
|
||||
CompletionTransformationBridge,
|
||||
|
|
@ -85,6 +88,22 @@ def _get_reasoning_items(
|
|||
return []
|
||||
|
||||
|
||||
def _reasoning_input_items(msg: "AllMessageValues") -> list[dict[str, object]]: # mutable-ok: API message payload
|
||||
"""Reasoning input items for an assistant message.
|
||||
|
||||
Stored reasoning items win because they carry an id the Responses API minted; thinking
|
||||
blocks are the fallback for turns that arrived over another API surface.
|
||||
"""
|
||||
items: Final = _get_reasoning_items(msg)
|
||||
stored: Final = [_reasoning_item_to_response_input(item) for item in items] # mutable-ok: API message payload
|
||||
if stored:
|
||||
return stored
|
||||
raw_blocks: Final = msg.get("thinking_blocks") or ()
|
||||
blocks: Final = cast("Iterable[ChatCompletionThinkingBlock]", raw_blocks) # cast-ok: untyped client json
|
||||
from_thinking: Final = responses_reasoning_item_from_thinking_blocks(blocks)
|
||||
return [] if from_thinking is None else [dict(from_thinking)] # mutable-ok: API message payload
|
||||
|
||||
|
||||
def _build_reasoning_item(
|
||||
item_id: str,
|
||||
encrypted_content: str | None,
|
||||
|
|
@ -372,8 +391,15 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
)
|
||||
)
|
||||
elif role == "assistant" and tool_calls and isinstance(tool_calls, list):
|
||||
for r_item in _get_reasoning_items(msg):
|
||||
input_items.append(_reasoning_item_to_response_input(r_item))
|
||||
input_items.extend(_reasoning_input_items(msg))
|
||||
if content:
|
||||
input_items.append(
|
||||
{ # mutable-ok: API message payload
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": self._convert_content_to_responses_format(content, "assistant"),
|
||||
}
|
||||
)
|
||||
for tool_call in tool_calls:
|
||||
function = tool_call.get("function")
|
||||
custom = tool_call.get("custom")
|
||||
|
|
@ -400,15 +426,16 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
raise ValueError(f"tool call not supported: {tool_call}")
|
||||
elif content is not None:
|
||||
if role == "assistant":
|
||||
for r_item in _get_reasoning_items(msg):
|
||||
input_items.append(_reasoning_item_to_response_input(r_item))
|
||||
input_items.extend(_reasoning_input_items(msg))
|
||||
input_items.append(
|
||||
{
|
||||
{ # mutable-ok: API message payload
|
||||
"type": "message",
|
||||
"role": role,
|
||||
"content": self._convert_content_to_responses_format(content, cast(str, role)),
|
||||
}
|
||||
)
|
||||
elif role == "assistant":
|
||||
input_items.extend(_reasoning_input_items(msg))
|
||||
|
||||
return input_items, instructions
|
||||
|
||||
|
|
|
|||
|
|
@ -1563,6 +1563,19 @@ STALE_OBJECT_CLEANUP_BATCH_SIZE: Final = max(1, int(os.getenv("STALE_OBJECT_CLEA
|
|||
# installations with large numbers of stale managed objects).
|
||||
_batch_polling_env: Final = os.getenv("PROXY_BATCH_POLLING_ENABLED", "true").lower()
|
||||
PROXY_BATCH_POLLING_ENABLED: Final = _batch_polling_env == "true"
|
||||
BACKGROUND_INTERACTION_COST_POLL_INITIAL_INTERVAL_SECONDS: Final = float(
|
||||
os.getenv("BACKGROUND_INTERACTION_COST_POLL_INITIAL_INTERVAL_SECONDS", "5")
|
||||
)
|
||||
BACKGROUND_INTERACTION_COST_POLL_MAX_INTERVAL_SECONDS: Final = float(
|
||||
os.getenv("BACKGROUND_INTERACTION_COST_POLL_MAX_INTERVAL_SECONDS", "60")
|
||||
)
|
||||
BACKGROUND_INTERACTION_COST_POLL_TIMEOUT_SECONDS: Final = float(
|
||||
os.getenv("BACKGROUND_INTERACTION_COST_POLL_TIMEOUT_SECONDS", "3600")
|
||||
)
|
||||
_background_interaction_cost_polling_env: Final = os.getenv(
|
||||
"BACKGROUND_INTERACTION_COST_POLLING_ENABLED", "true"
|
||||
).lower()
|
||||
BACKGROUND_INTERACTION_COST_POLLING_ENABLED: Final = _background_interaction_cost_polling_env == "true"
|
||||
PROXY_BUDGET_RESCHEDULER_MAX_TIME: Final = int(os.getenv("PROXY_BUDGET_RESCHEDULER_MAX_TIME", 605))
|
||||
PROXY_BATCH_WRITE_AT: Final = int(os.getenv("PROXY_BATCH_WRITE_AT", 10)) # in seconds, increased from 10
|
||||
PROXY_CONFIG_RELOAD_INTERVAL_SECONDS: Final = get_env_int("PROXY_CONFIG_RELOAD_INTERVAL_SECONDS", 30)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from litellm.litellm_core_utils.llm_cost_calc.tool_call_cost_tracking import (
|
|||
StandardBuiltInToolCostTracking,
|
||||
)
|
||||
from litellm.litellm_core_utils.llm_cost_calc.usage_object_transformation import (
|
||||
InteractionsUsageObjectTransformation,
|
||||
TranscriptionUsageObjectTransformation,
|
||||
)
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import (
|
||||
|
|
@ -150,6 +151,7 @@ _VIDEO_CALL_TYPES: Final = frozenset(
|
|||
}
|
||||
)
|
||||
|
||||
|
||||
_SPEECH_CALL_TYPES: Final = frozenset(
|
||||
{
|
||||
CallTypes.speech.value,
|
||||
|
|
@ -912,6 +914,8 @@ def _get_usage_object(
|
|||
usage_obj,
|
||||
)
|
||||
)
|
||||
elif isinstance(usage_obj, dict) and InteractionsUsageObjectTransformation.is_interactions_usage_object(usage_obj):
|
||||
return InteractionsUsageObjectTransformation.transform_interactions_usage_object(usage_obj)
|
||||
elif isinstance(usage_obj, dict):
|
||||
return Usage(**usage_obj)
|
||||
elif isinstance(usage_obj, BaseModel):
|
||||
|
|
@ -1288,6 +1292,10 @@ def completion_cost(
|
|||
)
|
||||
if tr_usage is not None:
|
||||
_usage = tr_usage.model_dump()
|
||||
elif InteractionsUsageObjectTransformation.is_interactions_usage_object(_usage):
|
||||
_usage = InteractionsUsageObjectTransformation.transform_interactions_usage_object(
|
||||
_usage
|
||||
).model_dump()
|
||||
else:
|
||||
_usage = _usage
|
||||
|
||||
|
|
@ -1372,23 +1380,36 @@ def completion_cost(
|
|||
if custom_pricing and litellm_logging_obj is not None:
|
||||
_litellm_params = getattr(litellm_logging_obj, "litellm_params", None)
|
||||
if _litellm_params is not None:
|
||||
_metadata = _litellm_params.get("metadata", {}) or {}
|
||||
_video_model_info = _metadata.get("model_info", None)
|
||||
_video_model_info = next(
|
||||
(
|
||||
model_info
|
||||
for _metadata_key in ("metadata", "litellm_metadata")
|
||||
if (model_info := (_litellm_params.get(_metadata_key) or {}).get("model_info"))
|
||||
is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
usage_obj = getattr(completion_response, "usage", None)
|
||||
duration_seconds: float | None = None
|
||||
video_resolution: str | None = None
|
||||
provider_reported_cost: float | None = None
|
||||
if completion_response is not None and usage_obj:
|
||||
# Handle both dict and Pydantic Usage object
|
||||
if isinstance(usage_obj, dict):
|
||||
duration_seconds = usage_obj.get("duration_seconds", None)
|
||||
_vr = usage_obj.get("video_resolution", None)
|
||||
provider_reported_cost = usage_obj.get("provider_reported_cost_usd", None)
|
||||
else:
|
||||
duration_seconds = getattr(usage_obj, "duration_seconds", None)
|
||||
_vr = getattr(usage_obj, "video_resolution", None)
|
||||
provider_reported_cost = getattr(usage_obj, "provider_reported_cost_usd", None)
|
||||
if _vr is not None:
|
||||
video_resolution = str(_vr).strip().lower()
|
||||
|
||||
if _video_model_info is None and provider_reported_cost is not None:
|
||||
return float(provider_reported_cost)
|
||||
|
||||
if duration_seconds is not None:
|
||||
# Calculate cost based on video duration using video-specific cost calculation
|
||||
from litellm.llms.openai.cost_calculation import (
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from litellm.constants import request_timeout as DEFAULT_REQUEST_TIMEOUT
|
|||
from litellm.exceptions import LiteLLMUnknownProvider
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.litellm_core_utils.llm_request_utils import flatten_form_field_values
|
||||
from litellm.litellm_core_utils.mock_functions import mock_image_generation
|
||||
from litellm.llms.base_llm import BaseImageEditConfig, BaseImageGenerationConfig
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
|
|
@ -422,24 +423,32 @@ def image_generation(
|
|||
aimg_generation=aimg_generation,
|
||||
)
|
||||
elif custom_llm_provider == "azure_ai":
|
||||
from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo
|
||||
from litellm.llms.azure_ai.common_utils import (
|
||||
AzureFoundryModelInfo,
|
||||
get_azure_ai_auth_headers,
|
||||
)
|
||||
|
||||
api_base = AzureFoundryModelInfo.get_api_base(api_base)
|
||||
api_key = AzureFoundryModelInfo.get_api_key(api_key)
|
||||
if extra_headers is not None:
|
||||
optional_params["extra_headers"] = extra_headers
|
||||
|
||||
default_headers = {
|
||||
caller_header_names = frozenset(name.lower() for name in headers)
|
||||
caller_set_auth = "api-key" in caller_header_names or "authorization" in caller_header_names
|
||||
auth_headers = (
|
||||
headers
|
||||
if caller_set_auth
|
||||
else get_azure_ai_auth_headers(
|
||||
api_key=api_key,
|
||||
litellm_params=litellm_params_dict,
|
||||
api_key_header="api-key",
|
||||
)
|
||||
)
|
||||
request_headers: Final = {
|
||||
"Content-Type": "application/json",
|
||||
**auth_headers,
|
||||
**headers,
|
||||
}
|
||||
# Only add api-key header if api_key is not None
|
||||
# Azure AD authentication will use Authorization header instead
|
||||
if api_key is not None:
|
||||
default_headers["api-key"] = api_key
|
||||
|
||||
for k, v in default_headers.items():
|
||||
if k not in headers:
|
||||
headers[k] = v
|
||||
|
||||
model_response = azure_chat_completions.image_generation(
|
||||
model=model,
|
||||
|
|
@ -455,7 +464,7 @@ def image_generation(
|
|||
api_version=api_version,
|
||||
aimg_generation=aimg_generation,
|
||||
client=client,
|
||||
headers=headers,
|
||||
headers=request_headers,
|
||||
litellm_params=litellm_params_dict,
|
||||
)
|
||||
elif (
|
||||
|
|
@ -846,6 +855,18 @@ def image_edit(
|
|||
additional_drop_params=kwargs.get("additional_drop_params"),
|
||||
)
|
||||
|
||||
if (
|
||||
custom_llm_provider == "openai"
|
||||
or custom_llm_provider == "azure"
|
||||
or custom_llm_provider in litellm.openai_compatible_providers
|
||||
):
|
||||
image_edit_request_params.update(
|
||||
flatten_form_field_values(
|
||||
non_default_params,
|
||||
extra_body if isinstance(extra_body, dict) else None,
|
||||
)
|
||||
)
|
||||
|
||||
# Pre Call logging
|
||||
litellm_logging_obj.update_from_kwargs(
|
||||
kwargs=kwargs,
|
||||
|
|
@ -995,6 +1016,9 @@ async def aimage_edit(
|
|||
response_format=response_format,
|
||||
size=size,
|
||||
user=user,
|
||||
extra_headers=extra_headers,
|
||||
extra_query=extra_query,
|
||||
extra_body=extra_body,
|
||||
timeout=timeout,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
**kwargs,
|
||||
|
|
|
|||
313
litellm/interactions/background_cost_polling.py
Normal file
313
litellm/interactions/background_cost_polling.py
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
"""
|
||||
Cost tracking for background interactions.
|
||||
|
||||
A create request with ``background=true`` returns ``in_progress`` with no
|
||||
usage block, and GET polls are deliberately never billed (billing them would
|
||||
double-charge every poll; the GET response also does not echo ``background``,
|
||||
so a poll cannot be told apart from a re-fetch of an already-billed
|
||||
interaction). The create call is therefore the only place that can own
|
||||
billing: it schedules a poll task that fetches the interaction until it
|
||||
reaches a terminal status and logs the final usage as a single success event
|
||||
attributed to the original request.
|
||||
|
||||
``requires_action`` is terminal for the interaction it names. The API has no
|
||||
operation that resumes one: a caller answers a tool request by creating a new
|
||||
interaction whose ``previous_interaction_id`` points at it, and that new
|
||||
interaction bills itself. The paused interaction keeps the tokens it already
|
||||
spent producing the tool request, so it is billed and settled where it stops
|
||||
rather than polled until the timeout, which would both lose that usage and
|
||||
hold its budget reservation open for the whole timeout window.
|
||||
|
||||
Deleting an interaction makes every subsequent poll fail, which would let a
|
||||
caller retrieve the completed output themselves and then delete it before the
|
||||
poll task settles, leaving the work unbilled and the budget reservation
|
||||
refunded at the poll timeout. ``adelete`` therefore settles any pending poll
|
||||
for the interaction before dispatching the delete: it fetches the current
|
||||
state with the create's credentials, bills it if it is terminal with usage,
|
||||
and releases the reservation otherwise. A settlement gate on the create's
|
||||
logging object makes the poll task and the delete path mutually exclusive, so
|
||||
the interaction is billed exactly once no matter who settles first.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable, Iterator, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Final, TypeAlias
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.constants import (
|
||||
BACKGROUND_INTERACTION_COST_POLL_INITIAL_INTERVAL_SECONDS,
|
||||
BACKGROUND_INTERACTION_COST_POLL_MAX_INTERVAL_SECONDS,
|
||||
BACKGROUND_INTERACTION_COST_POLL_TIMEOUT_SECONDS,
|
||||
BACKGROUND_INTERACTION_COST_POLLING_ENABLED,
|
||||
)
|
||||
from litellm.litellm_core_utils.core_helpers import get_litellm_metadata_from_kwargs
|
||||
from litellm.types.interactions import InteractionsAPIResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
|
||||
_TERMINAL_STATUSES: Final = frozenset(
|
||||
{"completed", "failed", "cancelled", "incomplete", "budget_exceeded", "requires_action"}
|
||||
)
|
||||
|
||||
_POLLABLE_STATUSES: Final = frozenset({"in_progress", "queued"})
|
||||
|
||||
_STATUSES_THAT_PRODUCED_OUTPUT: Final = frozenset({"completed", "requires_action"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BackgroundInteractionPollContext:
|
||||
interaction_id: str
|
||||
custom_llm_provider: str
|
||||
logging_obj: "LiteLLMLoggingObj"
|
||||
api_key: str | None = None
|
||||
api_base: str | None = None
|
||||
initial_interval_seconds: float = BACKGROUND_INTERACTION_COST_POLL_INITIAL_INTERVAL_SECONDS
|
||||
max_interval_seconds: float = BACKGROUND_INTERACTION_COST_POLL_MAX_INTERVAL_SECONDS
|
||||
timeout_seconds: float = BACKGROUND_INTERACTION_COST_POLL_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
FetchInteraction: TypeAlias = Callable[[BackgroundInteractionPollContext], Awaitable[InteractionsAPIResponse]]
|
||||
|
||||
|
||||
async def _fetch_interaction(context: BackgroundInteractionPollContext) -> InteractionsAPIResponse:
|
||||
from litellm.interactions import aget
|
||||
|
||||
return await aget(
|
||||
interaction_id=context.interaction_id,
|
||||
custom_llm_provider=context.custom_llm_provider,
|
||||
api_key=context.api_key,
|
||||
api_base=context.api_base,
|
||||
**{
|
||||
"no-log": True
|
||||
}, # mutable-ok: "no-log" is not a valid identifier, so it can only be passed through a mapping
|
||||
)
|
||||
|
||||
|
||||
def _poll_intervals(initial: float, maximum: float, timeout: float) -> Iterator[float]:
|
||||
elapsed = 0.0
|
||||
interval = initial
|
||||
while interval > 0 and elapsed + interval <= timeout:
|
||||
yield interval
|
||||
elapsed += interval
|
||||
interval = min(interval * 2, maximum)
|
||||
|
||||
|
||||
_SETTLED_KEY = "background_interaction_settled"
|
||||
|
||||
|
||||
def _is_settled(logging_obj: "LiteLLMLoggingObj") -> bool:
|
||||
return logging_obj.model_call_details.get(_SETTLED_KEY) is True
|
||||
|
||||
|
||||
def _claim_settlement(logging_obj: "LiteLLMLoggingObj") -> bool:
|
||||
"""
|
||||
Exactly-once gate between the poll task and the delete-time settlement:
|
||||
both run on the same event loop and neither awaits between reading and
|
||||
setting the flag, so whichever claims first owns billing or release.
|
||||
"""
|
||||
if _is_settled(logging_obj):
|
||||
return False
|
||||
logging_obj.model_call_details[_SETTLED_KEY] = True # rebind-ok: both settlers must see the same settlement flag
|
||||
return True
|
||||
|
||||
|
||||
async def poll_and_log_background_interaction_cost(
|
||||
context: BackgroundInteractionPollContext,
|
||||
fetch_interaction: FetchInteraction = _fetch_interaction,
|
||||
) -> None:
|
||||
last_seen_status: str | None = None
|
||||
for interval in _poll_intervals(
|
||||
initial=context.initial_interval_seconds,
|
||||
maximum=context.max_interval_seconds,
|
||||
timeout=context.timeout_seconds,
|
||||
):
|
||||
await asyncio.sleep(interval)
|
||||
if _is_settled(context.logging_obj):
|
||||
return
|
||||
try:
|
||||
response = await fetch_interaction(context)
|
||||
except Exception as e: # noqa: BLE001 # any fetch error must not kill the billing poll loop
|
||||
verbose_logger.debug(
|
||||
"Background interaction cost poll for %s failed, will retry: %s",
|
||||
context.interaction_id,
|
||||
e,
|
||||
)
|
||||
continue
|
||||
last_seen_status = response.status
|
||||
if response.status not in _TERMINAL_STATUSES:
|
||||
continue
|
||||
if not _claim_settlement(context.logging_obj):
|
||||
return
|
||||
if response.usage is not None:
|
||||
await _bill_settled_interaction(logging_obj=context.logging_obj, response=response)
|
||||
else:
|
||||
await _release_open_budget_reservation(logging_obj=context.logging_obj)
|
||||
return
|
||||
if not _claim_settlement(context.logging_obj):
|
||||
return
|
||||
if last_seen_status is not None and last_seen_status not in _POLLABLE_STATUSES:
|
||||
verbose_logger.error(
|
||||
"Gave up cost polling for background interaction %s after %ss: its last status %r is in neither "
|
||||
"the pollable nor the terminal set, so this proxy never learned how to settle it and its usage "
|
||||
"will not be tracked",
|
||||
context.interaction_id,
|
||||
context.timeout_seconds,
|
||||
last_seen_status,
|
||||
)
|
||||
else:
|
||||
verbose_logger.warning(
|
||||
"Gave up cost polling for background interaction %s after %ss; its usage will not be tracked",
|
||||
context.interaction_id,
|
||||
context.timeout_seconds,
|
||||
)
|
||||
await _release_open_budget_reservation(logging_obj=context.logging_obj)
|
||||
|
||||
|
||||
async def _release_open_budget_reservation(logging_obj: "LiteLLMLoggingObj") -> None:
|
||||
"""
|
||||
The proxy keeps the pre-call budget reservation open for an in-progress
|
||||
background interaction so concurrent creates cannot stack past the budget.
|
||||
The completion success event reconciles it to the actual cost; when the
|
||||
interaction terminates without billable usage (or polling gives up, or it
|
||||
is deleted before settling), no such event fires, so whoever claims the
|
||||
settlement must release the reservation here or the spend counters stay
|
||||
pinned at the estimated cost.
|
||||
"""
|
||||
metadata = get_litellm_metadata_from_kwargs(kwargs=logging_obj.model_call_details)
|
||||
budget_reservation = metadata.get("user_api_key_budget_reservation")
|
||||
if not isinstance(budget_reservation, dict):
|
||||
return
|
||||
|
||||
from litellm.proxy.spend_tracking.budget_reservation import release_budget_reservation
|
||||
|
||||
try:
|
||||
await release_budget_reservation(budget_reservation=budget_reservation)
|
||||
except Exception: # noqa: BLE001 # a failed release must not crash the poll task; counters expire via TTL
|
||||
verbose_logger.exception("Failed to release budget reservation for an unbilled background interaction")
|
||||
|
||||
|
||||
async def _bill_settled_interaction(logging_obj: "LiteLLMLoggingObj", response: InteractionsAPIResponse) -> None:
|
||||
"""
|
||||
Claiming the settlement makes the claimer solely responsible for the
|
||||
reservation, and no one retries a claim that is already set. A billing
|
||||
failure here must therefore release the reservation on its way out, or it
|
||||
stays pinned at the estimated cost until the whole poll times out.
|
||||
"""
|
||||
try:
|
||||
await logging_obj.async_log_background_interaction_completion(result=response)
|
||||
except Exception:
|
||||
await _release_open_budget_reservation(logging_obj=logging_obj)
|
||||
raise
|
||||
|
||||
|
||||
def is_pollable_background_interaction(response: InteractionsAPIResponse) -> bool:
|
||||
"""
|
||||
The single gate deciding whether a create's response gets a poll task.
|
||||
The proxy's success callback defers releasing the budget reservation for
|
||||
exactly these responses, on the promise that a poll task will settle them,
|
||||
so a response one site accepts and the other refuses strands its
|
||||
reservation on the spend counters with nothing left to reconcile it.
|
||||
|
||||
``queued`` belongs here alongside ``in_progress``. It is the API's
|
||||
not-started-yet state, so it reaches a terminal status the same way and
|
||||
needs polling for the same reason: nothing else in the proxy ever bills a
|
||||
create that came back without usage, so a status missing from both this
|
||||
set and ``_TERMINAL_STATUSES`` is billed nowhere and alerts nobody.
|
||||
"""
|
||||
return response.status in _POLLABLE_STATUSES and bool(response.id)
|
||||
|
||||
|
||||
def missing_usage_is_expected(response: InteractionsAPIResponse) -> bool:
|
||||
"""
|
||||
Whether a response arriving with no usage block is a normal outcome rather
|
||||
than lost billing data. An interaction that is still running, or that
|
||||
stopped at ``failed``, ``cancelled``, ``incomplete`` or ``budget_exceeded``,
|
||||
has nothing to charge for and should not raise a cost-tracking alarm.
|
||||
|
||||
``completed`` and ``requires_action`` both mean the model produced output,
|
||||
so a usage block is always expected with them. If one arrives without it
|
||||
the charge for real work has been lost, which is precisely what the
|
||||
proxy's cost-tracking alert exists to surface.
|
||||
"""
|
||||
return response.status not in _STATUSES_THAT_PRODUCED_OUTPUT
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ActiveBackgroundPoll:
|
||||
task: "asyncio.Task[None]"
|
||||
context: BackgroundInteractionPollContext
|
||||
|
||||
|
||||
_ACTIVE_POLLS: dict[str, _ActiveBackgroundPoll] = {} # mutable-ok: asyncio needs strong refs to running poll tasks
|
||||
|
||||
|
||||
def _discard_poll(interaction_id: str, task: "asyncio.Task[None]") -> None:
|
||||
entry = _ACTIVE_POLLS.get(interaction_id)
|
||||
if entry is not None and entry.task is task:
|
||||
del _ACTIVE_POLLS[interaction_id]
|
||||
|
||||
|
||||
def maybe_schedule_background_interaction_cost_polling(
|
||||
response: object,
|
||||
create_kwargs: Mapping[str, object],
|
||||
custom_llm_provider: str,
|
||||
) -> "asyncio.Task[None] | None":
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
|
||||
if not BACKGROUND_INTERACTION_COST_POLLING_ENABLED:
|
||||
return None
|
||||
if not isinstance(response, InteractionsAPIResponse):
|
||||
return None
|
||||
if not is_pollable_background_interaction(response):
|
||||
return None
|
||||
logging_obj = create_kwargs.get("litellm_logging_obj")
|
||||
if not isinstance(logging_obj, Logging):
|
||||
return None
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return None
|
||||
api_key = create_kwargs.get("api_key")
|
||||
api_base = create_kwargs.get("api_base")
|
||||
context = BackgroundInteractionPollContext(
|
||||
interaction_id=response.id,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
logging_obj=logging_obj,
|
||||
api_key=api_key if isinstance(api_key, str) else None,
|
||||
api_base=api_base if isinstance(api_base, str) else None,
|
||||
)
|
||||
task = asyncio.create_task(poll_and_log_background_interaction_cost(context))
|
||||
_ACTIVE_POLLS[context.interaction_id] = _ActiveBackgroundPoll(task=task, context=context)
|
||||
task.add_done_callback(
|
||||
lambda finished, interaction_id=context.interaction_id: _discard_poll(interaction_id, finished)
|
||||
)
|
||||
return task
|
||||
|
||||
|
||||
async def maybe_settle_background_interaction_before_delete(
|
||||
interaction_id: str,
|
||||
fetch_interaction: FetchInteraction = _fetch_interaction,
|
||||
) -> None:
|
||||
entry = _ACTIVE_POLLS.get(interaction_id)
|
||||
if entry is None:
|
||||
return
|
||||
context = entry.context
|
||||
try:
|
||||
response = await fetch_interaction(context)
|
||||
except Exception as e: # noqa: BLE001 # unfetchable pre-delete state settles by releasing the reservation
|
||||
verbose_logger.debug(
|
||||
"Could not fetch background interaction %s before delete, releasing its reservation: %s",
|
||||
interaction_id,
|
||||
e,
|
||||
)
|
||||
if _claim_settlement(context.logging_obj):
|
||||
await _release_open_budget_reservation(logging_obj=context.logging_obj)
|
||||
return
|
||||
if not _claim_settlement(context.logging_obj):
|
||||
return
|
||||
if response.status in _TERMINAL_STATUSES and response.usage is not None:
|
||||
await _bill_settled_interaction(logging_obj=context.logging_obj, response=response)
|
||||
return
|
||||
await _release_open_budget_reservation(logging_obj=context.logging_obj)
|
||||
|
|
@ -40,6 +40,10 @@ from typing import Any, Final
|
|||
import httpx
|
||||
|
||||
import litellm
|
||||
from litellm.interactions.background_cost_polling import (
|
||||
maybe_schedule_background_interaction_cost_polling,
|
||||
maybe_settle_background_interaction_before_delete,
|
||||
)
|
||||
from litellm.interactions.http_handler import interactions_http_handler
|
||||
from litellm.interactions.utils import (
|
||||
InteractionsAPIRequestUtils,
|
||||
|
|
@ -171,6 +175,12 @@ async def acreate(
|
|||
else:
|
||||
response = init_response
|
||||
|
||||
maybe_schedule_background_interaction_cost_polling(
|
||||
response=response,
|
||||
create_kwargs=kwargs,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
)
|
||||
|
||||
return response
|
||||
except Exception as e:
|
||||
raise litellm.exception_type(
|
||||
|
|
@ -462,6 +472,8 @@ async def adelete(
|
|||
loop: Final = asyncio.get_event_loop()
|
||||
kwargs["adelete_interaction"] = True
|
||||
|
||||
await maybe_settle_background_interaction_before_delete(interaction_id=interaction_id)
|
||||
|
||||
func: Final = partial(
|
||||
delete,
|
||||
interaction_id=interaction_id,
|
||||
|
|
|
|||
|
|
@ -2301,6 +2301,7 @@ def exception_type(
|
|||
or custom_llm_provider == "custom_openai"
|
||||
or custom_llm_provider in litellm.openai_compatible_providers
|
||||
or custom_llm_provider == "mistral"
|
||||
or custom_llm_provider == "runwayml"
|
||||
):
|
||||
_map_openai_exception(
|
||||
model=model,
|
||||
|
|
|
|||
|
|
@ -71,6 +71,9 @@ from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import (
|
|||
from litellm.litellm_core_utils.llm_cost_calc.tool_call_cost_tracking import (
|
||||
StandardBuiltInToolCostTracking,
|
||||
)
|
||||
from litellm.litellm_core_utils.llm_cost_calc.usage_object_transformation import (
|
||||
InteractionsUsageObjectTransformation,
|
||||
)
|
||||
from litellm.litellm_core_utils.logging_utils import truncate_base64_in_messages
|
||||
from litellm.litellm_core_utils.model_param_helper import ModelParamHelper
|
||||
from litellm.litellm_core_utils.redact_messages import (
|
||||
|
|
@ -83,6 +86,10 @@ from litellm.llms.base_llm.search.transformation import SearchResponse
|
|||
from litellm.responses.utils import ResponseAPILoggingUtils
|
||||
from litellm.types.agents import LiteLLMSendMessageResponse
|
||||
from litellm.types.containers.main import ContainerObject
|
||||
from litellm.types.interactions import (
|
||||
InteractionsAPIResponse,
|
||||
InteractionsAPIStreamingResponse,
|
||||
)
|
||||
from litellm.types.llms.openai import (
|
||||
AllMessageValues,
|
||||
Batch,
|
||||
|
|
@ -2145,6 +2152,11 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
or isinstance(logging_result, OpenAIModerationResponse)
|
||||
or isinstance(logging_result, OCRResponse) # OCR
|
||||
or isinstance(logging_result, SearchResponse) # Search API
|
||||
or (
|
||||
isinstance(logging_result, InteractionsAPIResponse)
|
||||
and logging_result.usage is not None
|
||||
and self._is_interactions_create_call_type()
|
||||
)
|
||||
or isinstance(logging_result, dict)
|
||||
and logging_result.get("object") == "vector_store.search_results.page"
|
||||
or isinstance(logging_result, dict)
|
||||
|
|
@ -2157,6 +2169,87 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
return True
|
||||
return False
|
||||
|
||||
def _is_interactions_create_call_type(self) -> bool:
|
||||
"""
|
||||
Only interaction creation is billable. GET polls, deletes, and cancels
|
||||
also return an ``InteractionsAPIResponse`` (with usage once completed),
|
||||
so recognizing those would write spend on every poll of a background
|
||||
interaction. The proxy sets ``call_type`` from its route_type
|
||||
(``create_interaction``/``acreate_interaction``); the SDK sets it from
|
||||
the decorated function name (``create``/``acreate``).
|
||||
|
||||
Recognition additionally requires a usage block (checked at the call
|
||||
site): a ``background=true`` create returns ``in_progress`` without
|
||||
usage, and billing it would write a $0 spend log under the interaction
|
||||
id that collides with the row the background poll task writes once the
|
||||
interaction completes (see
|
||||
``litellm.interactions.background_cost_polling``).
|
||||
"""
|
||||
return self.call_type in (
|
||||
CallTypes.create_interaction.value,
|
||||
CallTypes.acreate_interaction.value,
|
||||
"create",
|
||||
"acreate",
|
||||
)
|
||||
|
||||
async def async_log_background_interaction_completion(
|
||||
self,
|
||||
result: InteractionsAPIResponse,
|
||||
) -> None:
|
||||
"""
|
||||
Log the terminal result of a background interaction as a fresh success
|
||||
event. The create request already ran success logging for its
|
||||
``in_progress`` response (no usage, so no cost was tracked); clearing
|
||||
the dedup flags lets the completed result flow through cost calculation
|
||||
and spend tracking exactly once, spanning create to completion.
|
||||
|
||||
The poll fetched this body through its own client call, which priced it
|
||||
against a throwaway logging object holding none of this request's
|
||||
deployment context: no ``model_info``, no router ``model_id``, no
|
||||
deployment ``litellm_params``. Keeping that price would bill a
|
||||
custom-priced deployment at the wrong rate, and it would also satisfy
|
||||
the "already calculated" shortcut and skip repricing here, leaving the
|
||||
cost breakdown at the zeros the usage-less create stamped and writing
|
||||
those zeros to the spend log. Dropping it makes this event price the
|
||||
settled body itself, against the deployment that served the create.
|
||||
|
||||
The same throwaway call stamped the deployment identity that travels
|
||||
with the price, so ``model_id`` and ``litellm_model_name`` go with it.
|
||||
Left in place they overwrite the create's real deployment with the
|
||||
poll's empty one in the payload every logging integration reads.
|
||||
"""
|
||||
settled_hidden_params: Final = getattr(result, "_hidden_params", None)
|
||||
if isinstance(settled_hidden_params, dict):
|
||||
for poll_scoped_key in ("response_cost", "model_id", "litellm_model_name"):
|
||||
settled_hidden_params.pop(poll_scoped_key, None)
|
||||
self._reset_success_emission_dedupe()
|
||||
await self.async_success_handler(result=result)
|
||||
|
||||
def _reset_success_emission_dedupe(self) -> None:
|
||||
"""
|
||||
Success callbacks dedupe per request, because the sync and async
|
||||
handlers both fire on some paths and would otherwise report one call
|
||||
twice. A settled background interaction is a genuinely second success
|
||||
event on the same request, so every such marker has to be cleared or
|
||||
the completion, the only event that carries usage and cost, is
|
||||
discarded as a duplicate of the in-progress create.
|
||||
"""
|
||||
self.model_call_details.pop("has_logged_async_success", None)
|
||||
litellm_params = self.model_call_details.get("litellm_params")
|
||||
if not isinstance(litellm_params, dict):
|
||||
return
|
||||
metadata = litellm_params.get("metadata")
|
||||
if not isinstance(metadata, dict):
|
||||
return
|
||||
otel_internal = metadata.get("_otel_internal")
|
||||
if not isinstance(otel_internal, dict):
|
||||
return
|
||||
spans_logged = otel_internal.get("spans_logged")
|
||||
if not isinstance(spans_logged, dict):
|
||||
return
|
||||
for scope in [key for key in spans_logged if isinstance(key, tuple) and key[-1:] == ("success",)]:
|
||||
del spans_logged[scope]
|
||||
|
||||
def _flush_passthrough_collected_chunks_helper(
|
||||
self,
|
||||
raw_bytes: list[bytes],
|
||||
|
|
@ -2282,7 +2375,9 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
is_sync_request: Final = self._is_sync_litellm_request(litellm_params)
|
||||
try:
|
||||
## BUILD COMPLETE STREAMED RESPONSE
|
||||
complete_streaming_response: ModelResponse | TextCompletionResponse | ResponsesAPIResponse | None = None
|
||||
complete_streaming_response: (
|
||||
ModelResponse | TextCompletionResponse | ResponsesAPIResponse | InteractionsAPIResponse | None
|
||||
) = None
|
||||
if "complete_streaming_response" in self.model_call_details:
|
||||
return # break out of this.
|
||||
complete_streaming_response = self._get_assembled_streaming_response(
|
||||
|
|
@ -2768,14 +2863,14 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
## BUILD COMPLETE STREAMED RESPONSE
|
||||
if "async_complete_streaming_response" in self.model_call_details:
|
||||
return # break out of this.
|
||||
complete_streaming_response: Final[ModelResponse | TextCompletionResponse | ResponsesAPIResponse | None] = (
|
||||
self._get_assembled_streaming_response(
|
||||
result=result,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
is_async=True,
|
||||
streaming_chunks=self.streaming_chunks,
|
||||
)
|
||||
complete_streaming_response: Final[
|
||||
ModelResponse | TextCompletionResponse | ResponsesAPIResponse | InteractionsAPIResponse | None
|
||||
] = self._get_assembled_streaming_response(
|
||||
result=result,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
is_async=True,
|
||||
streaming_chunks=self.streaming_chunks,
|
||||
)
|
||||
|
||||
if complete_streaming_response is not None:
|
||||
|
|
@ -3558,7 +3653,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
end_time: datetime.datetime,
|
||||
is_async: bool,
|
||||
streaming_chunks: list[object],
|
||||
) -> ModelResponse | TextCompletionResponse | ResponsesAPIResponse | None:
|
||||
) -> ModelResponse | TextCompletionResponse | ResponsesAPIResponse | InteractionsAPIResponse | None:
|
||||
if self.stream is not True:
|
||||
return None
|
||||
if isinstance(result, ModelResponse) or isinstance(result, TextCompletionResponse):
|
||||
|
|
@ -3583,9 +3678,40 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
),
|
||||
)
|
||||
return result.response
|
||||
elif isinstance(result, InteractionsAPIStreamingResponse):
|
||||
return self._assemble_completed_interaction_response(result)
|
||||
else:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _assemble_completed_interaction_response(
|
||||
result: InteractionsAPIStreamingResponse,
|
||||
) -> InteractionsAPIResponse | None:
|
||||
"""
|
||||
The Interactions API streaming iterator hands the terminal event to the
|
||||
success handlers: the new schema (Api-Revision: 2026-05-20) emits
|
||||
``interaction.completed`` carrying the full interaction object, the
|
||||
legacy schema (2026-05-07) emits a chunk with ``status="completed"``
|
||||
and usage on the chunk itself. Build the equivalent non-streaming
|
||||
response so cost calculation and spend tracking see one shape.
|
||||
"""
|
||||
if result.event_type == "interaction.completed" and result.interaction is not None:
|
||||
return InteractionsAPIResponse(**result.interaction)
|
||||
if result.status == "completed":
|
||||
return InteractionsAPIResponse(
|
||||
**result.model_dump(
|
||||
exclude={ # mutable-ok: pydantic types exclude as set[str], which a frozenset does not satisfy
|
||||
"event_type",
|
||||
"delta",
|
||||
"index",
|
||||
"step",
|
||||
"interaction_id",
|
||||
"interaction",
|
||||
}
|
||||
)
|
||||
)
|
||||
return None
|
||||
|
||||
def _handle_anthropic_messages_response_logging(self, result: Any) -> ModelResponse:
|
||||
"""
|
||||
Handles logging for Anthropic messages responses.
|
||||
|
|
@ -5092,6 +5218,8 @@ class StandardLoggingPayloadSetup:
|
|||
elif isinstance(usage, dict):
|
||||
if ResponseAPILoggingUtils._is_response_api_usage(usage):
|
||||
return ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage)
|
||||
if InteractionsUsageObjectTransformation.is_interactions_usage_object(usage):
|
||||
return InteractionsUsageObjectTransformation.transform_interactions_usage_object(usage)
|
||||
return Usage(**usage)
|
||||
|
||||
raise ValueError(f"usage is required, got={usage} of type {type(usage)}")
|
||||
|
|
@ -5118,6 +5246,8 @@ class StandardLoggingPayloadSetup:
|
|||
if isinstance(_raw, dict):
|
||||
if ResponseAPILoggingUtils._is_response_api_usage(_raw):
|
||||
return ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(_raw).model_dump()
|
||||
if InteractionsUsageObjectTransformation.is_interactions_usage_object(_raw):
|
||||
return InteractionsUsageObjectTransformation.transform_interactions_usage_object(_raw).model_dump()
|
||||
return _raw
|
||||
if isinstance(_raw, Usage):
|
||||
return _raw.model_dump()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
from collections.abc import Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
from litellm.types.utils import (
|
||||
CompletionTokensDetailsWrapper,
|
||||
PromptTokensDetailsWrapper,
|
||||
TranscriptionUsageDurationObject,
|
||||
TranscriptionUsageTokensObject,
|
||||
|
|
@ -34,3 +37,127 @@ class TranscriptionUsageObjectTransformation:
|
|||
),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
_INTERACTIONS_MODALITY_FIELDS: Mapping[str, str] = MappingProxyType(
|
||||
{
|
||||
"text": "text_tokens",
|
||||
"audio": "audio_tokens",
|
||||
"image": "image_tokens",
|
||||
"video": "video_tokens",
|
||||
"document": "text_tokens",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _modality_field(entry: Mapping[str, Any]) -> str | None:
|
||||
return _INTERACTIONS_MODALITY_FIELDS.get(str(entry.get("modality", "")).lower())
|
||||
|
||||
|
||||
def _token_count(value: object) -> int:
|
||||
return value if isinstance(value, int) else 0
|
||||
|
||||
|
||||
def _modality_token_sums(entries: Sequence[Mapping[str, Any]]) -> Mapping[str, int]:
|
||||
fields = frozenset(field for entry in entries if (field := _modality_field(entry)) is not None)
|
||||
return MappingProxyType(
|
||||
{
|
||||
field: sum(_token_count(entry.get("tokens")) for entry in entries if _modality_field(entry) == field)
|
||||
for field in fields
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _google_search_query_count(usage_object: Mapping[str, Any]) -> int:
|
||||
return sum(
|
||||
_token_count(entry.get("count"))
|
||||
for entry in tuple(usage_object.get("grounding_tool_count") or ())
|
||||
if isinstance(entry, Mapping) and entry.get("type") == "google_search" # pyright: ignore[reportUnnecessaryIsInstance] # provider JSON, not the empty tuple inferred from `or ()`
|
||||
)
|
||||
|
||||
|
||||
def _subtract_cached_from_input(
|
||||
input_sums: Mapping[str, int],
|
||||
cached_sums: Mapping[str, int],
|
||||
total_cached_tokens: int,
|
||||
) -> Mapping[str, int]:
|
||||
if cached_sums:
|
||||
return MappingProxyType(
|
||||
{field: max(0, tokens - cached_sums.get(field, 0)) for field, tokens in input_sums.items()}
|
||||
)
|
||||
if total_cached_tokens and "text_tokens" in input_sums:
|
||||
return MappingProxyType(
|
||||
{
|
||||
**input_sums,
|
||||
"text_tokens": max(0, input_sums["text_tokens"] - total_cached_tokens),
|
||||
}
|
||||
)
|
||||
return input_sums
|
||||
|
||||
|
||||
class InteractionsUsageObjectTransformation:
|
||||
"""
|
||||
Maps the Google Interactions API usage block (total_input_tokens,
|
||||
output_tokens_by_modality, ...) into LiteLLM's chat-format ``Usage`` so the
|
||||
generic cost calculator and spend tracking can bill it.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def is_interactions_usage_object(usage_object: object) -> bool:
|
||||
if not isinstance(usage_object, dict):
|
||||
return False
|
||||
if "prompt_tokens" in usage_object or "input_tokens" in usage_object:
|
||||
return False
|
||||
return "total_input_tokens" in usage_object or "total_output_tokens" in usage_object
|
||||
|
||||
@staticmethod
|
||||
def transform_interactions_usage_object(usage_object: Mapping[str, Any]) -> Usage:
|
||||
input_entries = tuple(usage_object.get("input_tokens_by_modality") or ()) + tuple(
|
||||
usage_object.get("tool_use_tokens_by_modality") or ()
|
||||
)
|
||||
cached_sums = _modality_token_sums(tuple(usage_object.get("cached_tokens_by_modality") or ()))
|
||||
output_sums = _modality_token_sums(tuple(usage_object.get("output_tokens_by_modality") or ()))
|
||||
|
||||
total_cached_tokens = _token_count(usage_object.get("total_cached_tokens"))
|
||||
input_sums = _subtract_cached_from_input(
|
||||
input_sums=_modality_token_sums(input_entries),
|
||||
cached_sums=cached_sums,
|
||||
total_cached_tokens=total_cached_tokens,
|
||||
)
|
||||
|
||||
reasoning_tokens = _token_count(usage_object.get("total_reasoning_tokens")) or _token_count(
|
||||
usage_object.get("total_thought_tokens")
|
||||
)
|
||||
prompt_tokens = _token_count(usage_object.get("total_input_tokens")) + _token_count(
|
||||
usage_object.get("total_tool_use_tokens")
|
||||
)
|
||||
completion_tokens = _token_count(usage_object.get("total_output_tokens")) + reasoning_tokens
|
||||
total_tokens = _token_count(usage_object.get("total_tokens")) or (prompt_tokens + completion_tokens)
|
||||
|
||||
web_search_requests = _google_search_query_count(usage_object)
|
||||
prompt_tokens_details = (
|
||||
PromptTokensDetailsWrapper(
|
||||
cached_tokens=total_cached_tokens or None,
|
||||
web_search_requests=web_search_requests or None,
|
||||
**input_sums,
|
||||
)
|
||||
if input_sums or total_cached_tokens or web_search_requests
|
||||
else None
|
||||
)
|
||||
completion_tokens_details = (
|
||||
CompletionTokensDetailsWrapper(
|
||||
reasoning_tokens=reasoning_tokens or None,
|
||||
**output_sums,
|
||||
)
|
||||
if output_sums or reasoning_tokens
|
||||
else None
|
||||
)
|
||||
|
||||
return Usage(
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=total_tokens,
|
||||
prompt_tokens_details=prompt_tokens_details,
|
||||
completion_tokens_details=completion_tokens_details,
|
||||
cache_read_input_tokens=total_cached_tokens or None,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,88 @@
|
|||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
|
||||
import litellm
|
||||
|
||||
|
||||
def _form_field_value(value: object) -> str:
|
||||
if value is True:
|
||||
return "true"
|
||||
if value is False:
|
||||
return "false"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _flatten_form_field(key: str, value: object) -> tuple[tuple[str, str], ...]:
|
||||
if isinstance(value, Mapping):
|
||||
return tuple(
|
||||
item for subkey, subvalue in value.items() for item in _flatten_form_field(f"{key}[{subkey}]", subvalue)
|
||||
)
|
||||
if isinstance(value, (list, tuple)):
|
||||
return tuple(item for entry in value for item in _flatten_form_field(f"{key}[]", entry))
|
||||
if value is None:
|
||||
return ()
|
||||
serialized: Final = _form_field_value(value)
|
||||
if not serialized:
|
||||
return ()
|
||||
return ((key, serialized),)
|
||||
|
||||
|
||||
def _is_form_scalar(value: object) -> bool:
|
||||
return value is not None and not isinstance(value, (Mapping, list, tuple))
|
||||
|
||||
|
||||
def _flatten_form_data_field(key: str, value: object) -> tuple[tuple[str, str | tuple[str, ...]], ...]:
|
||||
if isinstance(value, Mapping):
|
||||
return tuple(
|
||||
item
|
||||
for subkey, subvalue in value.items()
|
||||
for item in _flatten_form_data_field(f"{key}[{subkey}]", subvalue)
|
||||
)
|
||||
if isinstance(value, (list, tuple)):
|
||||
if all(_is_form_scalar(entry) for entry in value):
|
||||
serialized_fields: Final = tuple(field for entry in value if (field := _form_field_value(entry)))
|
||||
return ((key, serialized_fields),) if serialized_fields else ()
|
||||
return tuple(item for entry in value for item in _flatten_form_data_field(f"{key}[]", entry))
|
||||
if value is None:
|
||||
return ()
|
||||
serialized: Final = _form_field_value(value)
|
||||
if not serialized:
|
||||
return ()
|
||||
return ((key, serialized),)
|
||||
|
||||
|
||||
def flatten_form_field_values(*sources: Mapping[str, object] | None) -> tuple[tuple[str, str | tuple[str, ...]], ...]:
|
||||
"""
|
||||
Flatten JSON-shaped bodies into ``(name, value)`` form fields for a ``dict``-backed
|
||||
multipart body, applying ``sources`` in order so a later source wins on a key collision
|
||||
under ``dict.update``. Nested objects become ``key[subkey]`` fields the way the OpenAI SDK
|
||||
serializes them, so provider params reach a multipart request without handing the httpx
|
||||
encoder a nested value it rejects with ``Invalid type for value``. A scalar list becomes a
|
||||
single field carrying a tuple value, which httpx emits as one repeated part per element, so
|
||||
every element survives instead of collapsing to the last under ``dict.update``.
|
||||
"""
|
||||
return tuple(
|
||||
pair
|
||||
for source in sources
|
||||
if source is not None
|
||||
for top_key, top_value in source.items()
|
||||
for pair in _flatten_form_data_field(top_key, top_value)
|
||||
)
|
||||
|
||||
|
||||
def serialize_multipart_form_fields(data: Mapping[str, object]) -> tuple[tuple[str, tuple[None, str]], ...]:
|
||||
"""
|
||||
Encode a JSON-shaped body as OpenAI-SDK-style multipart file-tuples so a file-less
|
||||
request is still sent as multipart/form-data, working around httpx downgrading a
|
||||
file-less ``data=`` payload to application/x-www-form-urlencoded.
|
||||
"""
|
||||
return tuple(
|
||||
(key, (None, serialized))
|
||||
for top_key, top_value in data.items()
|
||||
for key, serialized in _flatten_form_field(top_key, top_value)
|
||||
)
|
||||
|
||||
|
||||
def _ensure_extra_body_is_safe(extra_body: dict | None) -> dict | None:
|
||||
"""
|
||||
Ensure that the extra_body sent in the request is safe, otherwise users will see this error
|
||||
|
|
|
|||
|
|
@ -28,8 +28,12 @@ from litellm.types.llms.openai import (
|
|||
ChatCompletionAssistantMessage,
|
||||
ChatCompletionFileObject,
|
||||
ChatCompletionImageObject,
|
||||
ChatCompletionReasoningItem,
|
||||
ChatCompletionReasoningSummaryTextBlock,
|
||||
ChatCompletionRedactedThinkingBlock,
|
||||
ChatCompletionResponseMessage,
|
||||
ChatCompletionTextObject,
|
||||
ChatCompletionThinkingBlock,
|
||||
ChatCompletionToolParam,
|
||||
ChatCompletionUserMessage,
|
||||
)
|
||||
|
|
@ -1549,6 +1553,44 @@ def _extract_reasoning_content(message: dict) -> tuple[str | None, str | None]:
|
|||
return None, message_content
|
||||
|
||||
|
||||
def _readable_thinking_text(
|
||||
block: ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock,
|
||||
) -> str:
|
||||
"""The text a chat model can read back, empty for redacted blocks and malformed ones."""
|
||||
if block.get("type") != "thinking":
|
||||
return ""
|
||||
thinking: Final = cast(ChatCompletionThinkingBlock, block).get("thinking") # cast-ok: narrowed by the type tag
|
||||
return str(thinking or "")
|
||||
|
||||
|
||||
def reasoning_content_from_thinking_blocks(
|
||||
thinking_blocks: Iterable[ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock],
|
||||
) -> str:
|
||||
"""Flatten Anthropic thinking blocks into the `reasoning_content` string chat models expect.
|
||||
|
||||
Redacted blocks carry no readable text, so they contribute nothing.
|
||||
"""
|
||||
return "\n".join(text for block in thinking_blocks if (text := _readable_thinking_text(block)))
|
||||
|
||||
|
||||
def responses_reasoning_item_from_thinking_blocks(
|
||||
thinking_blocks: Iterable[ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock],
|
||||
) -> ChatCompletionReasoningItem | None:
|
||||
"""Build a Responses API `reasoning` input item from Anthropic thinking blocks.
|
||||
|
||||
The item carries no `id`: the Responses API rejects an empty one and 404s on any id it
|
||||
did not mint itself, while an item without an id is always accepted.
|
||||
"""
|
||||
summary: Final[list[ChatCompletionReasoningSummaryTextBlock]] = [ # mutable-ok: API message payload
|
||||
ChatCompletionReasoningSummaryTextBlock(type="summary_text", text=text)
|
||||
for block in thinking_blocks
|
||||
if (text := _readable_thinking_text(block))
|
||||
]
|
||||
if not summary:
|
||||
return None
|
||||
return ChatCompletionReasoningItem(type="reasoning", summary=summary)
|
||||
|
||||
|
||||
def _parse_content_for_reasoning(
|
||||
message_text: str | None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import json
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, NoReturn, cast
|
||||
|
||||
import httpx
|
||||
|
|
@ -121,6 +122,32 @@ else:
|
|||
# response side.
|
||||
_ANTHROPIC_TOOL_NAME_INVALID_CHARS: Final = re.compile(r"[^a-zA-Z0-9_-]")
|
||||
_ANTHROPIC_TOOL_NAME_MAX_LEN: Final = 128
|
||||
|
||||
_ENUM_TYPE_CHECKS: Final[Mapping[str, Callable[[Any], bool]]] = MappingProxyType(
|
||||
{
|
||||
"null": lambda v: v is None,
|
||||
"boolean": lambda v: isinstance(v, bool),
|
||||
"integer": lambda v: isinstance(v, int) and not isinstance(v, bool),
|
||||
"number": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
|
||||
"string": lambda v: isinstance(v, str),
|
||||
"array": lambda v: isinstance(v, list),
|
||||
"object": lambda v: isinstance(v, dict),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _enum_conflicts_with_declared_type(schema: Mapping[str, Any]) -> bool:
|
||||
"""Whether ``schema``'s ``enum`` cannot match its declared ``type``."""
|
||||
enum_values: Final = schema.get("enum")
|
||||
declared_type: Final = schema.get("type")
|
||||
if not isinstance(enum_values, list) or declared_type is None:
|
||||
return False
|
||||
if isinstance(declared_type, list):
|
||||
return True
|
||||
check: Final = _ENUM_TYPE_CHECKS.get(declared_type)
|
||||
return check is not None and not all(check(value) for value in enum_values)
|
||||
|
||||
|
||||
# Single, internal-only key on ``litellm_params`` used to thread the per-
|
||||
# request reverse map (sanitized -> original) from request build to response
|
||||
# parsing. ``litellm_params`` is never serialized to a provider; ``optional_
|
||||
|
|
@ -565,9 +592,13 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
|
|||
else:
|
||||
result["description"] = constraint_note
|
||||
|
||||
drops_conflicting_type: Final = _enum_conflicts_with_declared_type(schema)
|
||||
|
||||
for key, value in schema.items():
|
||||
if key in unsupported_fields:
|
||||
continue
|
||||
if key == "type" and drops_conflicting_type:
|
||||
continue
|
||||
if key == "description" and "description" in result:
|
||||
# Already handled above
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -440,6 +440,16 @@ class AnthropicModelInfo(BaseLLMModelInfo):
|
|||
"""
|
||||
return AnthropicModelInfo._supports_model_capability(model, "thinking_always_on", custom_llm_provider)
|
||||
|
||||
@staticmethod
|
||||
def _supports_legacy_thinking(model: str, custom_llm_provider: str) -> bool:
|
||||
"""Whether ``model`` is an adaptive-thinking model that still accepts legacy
|
||||
``thinking.type=enabled`` with ``budget_tokens`` (the Claude 4.6 family).
|
||||
The model cost map is authoritative: an explicit ``supports_legacy_thinking``
|
||||
entry resolved under ``custom_llm_provider``, or a ``fallback_generalizations``
|
||||
rule for unmapped 4.6 ids. Absent flag means the model rejects the legacy shape.
|
||||
"""
|
||||
return AnthropicModelInfo._supports_model_capability(model, "supports_legacy_thinking", custom_llm_provider)
|
||||
|
||||
@staticmethod
|
||||
def maybe_drop_disabled_thinking(
|
||||
model: str,
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ from openai.types.chat.chat_completion_chunk import Choice as OpenAIStreamingCho
|
|||
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
parse_tool_call_arguments,
|
||||
reasoning_content_from_thinking_blocks,
|
||||
with_prompt_cache_breakpoint,
|
||||
)
|
||||
from litellm.litellm_core_utils.prompt_templates.factory import (
|
||||
|
|
@ -592,6 +593,9 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
assistant_message["tool_calls"] = tool_calls
|
||||
if len(thinking_blocks) > 0:
|
||||
assistant_message["thinking_blocks"] = thinking_blocks
|
||||
reasoning_content = reasoning_content_from_thinking_blocks(thinking_blocks)
|
||||
if reasoning_content:
|
||||
assistant_message["reasoning_content"] = reasoning_content
|
||||
new_messages.append(assistant_message)
|
||||
|
||||
return new_messages
|
||||
|
|
|
|||
|
|
@ -379,13 +379,19 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
|
|||
def _translate_legacy_thinking_for_adaptive_model(
|
||||
model: str, optional_params: dict, custom_llm_provider: str
|
||||
) -> None:
|
||||
"""Translate legacy ``thinking.type=enabled`` to adaptive for 4.6/4.7.
|
||||
Caller-provided ``output_config.effort`` is never overridden.
|
||||
"""Translate legacy ``thinking.type=enabled`` to adaptive for the
|
||||
adaptive-thinking models that reject it (4.7+ and the 5 families).
|
||||
Models flagged ``supports_legacy_thinking`` (the 4.6 family) accept the
|
||||
legacy shape natively, so it is forwarded verbatim and the caller's
|
||||
``budget_tokens`` cap keeps applying. Caller-provided
|
||||
``output_config.effort`` is never overridden.
|
||||
"""
|
||||
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
|
||||
|
||||
if not AnthropicModelInfo._is_adaptive_thinking_model(model, custom_llm_provider):
|
||||
return
|
||||
if AnthropicModelInfo._supports_legacy_thinking(model, custom_llm_provider):
|
||||
return
|
||||
thinking: Final = optional_params.get("thinking")
|
||||
if not isinstance(thinking, dict) or thinking.get("type") != "enabled":
|
||||
return
|
||||
|
|
|
|||
|
|
@ -152,7 +152,10 @@ class AnthropicResponsesStreamWrapper:
|
|||
if block_idx < 0:
|
||||
if not delta:
|
||||
return
|
||||
block_idx = self._open_block(item_id, {"type": "thinking", "thinking": ""})
|
||||
block_idx = self._open_block(
|
||||
item_id,
|
||||
{"type": "thinking", "thinking": "", "signature": ""}, # mutable-ok: API message payload
|
||||
)
|
||||
self._chunk_queue.append(
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@ path used for OpenAI and Azure models.
|
|||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Iterable, Mapping
|
||||
from itertools import groupby
|
||||
from typing import Any, Final, cast
|
||||
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
TOOL_RESULT_IMAGE_BOUNDARY,
|
||||
TOOL_RESULT_IMAGE_PLACEHOLDER,
|
||||
responses_reasoning_item_from_thinking_blocks,
|
||||
with_prompt_cache_breakpoint,
|
||||
)
|
||||
from litellm.litellm_core_utils.reasoning_effort_utils import (
|
||||
|
|
@ -36,7 +38,11 @@ from litellm.types.llms.anthropic_messages.anthropic_response import (
|
|||
AnthropicMessagesResponse,
|
||||
AnthropicUsage,
|
||||
)
|
||||
from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse
|
||||
from litellm.types.llms.openai import (
|
||||
ChatCompletionThinkingBlock,
|
||||
ResponseAPIUsage,
|
||||
ResponsesAPIResponse,
|
||||
)
|
||||
|
||||
|
||||
class LiteLLMAnthropicToResponsesAPIAdapter:
|
||||
|
|
@ -100,6 +106,58 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
if isinstance(block, dict) and block.get("type") == "text" and (text := block.get("text")) # pyright: ignore[reportUnnecessaryIsInstance] # untrusted client payload
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _summary_part_text(part: object) -> str:
|
||||
if isinstance(part, Mapping):
|
||||
mapping: Final = cast(Mapping[str, Any], part) # cast-ok: summary parts are untyped provider json
|
||||
return str(mapping.get("text") or "")
|
||||
return str(getattr(part, "text", None) or "")
|
||||
|
||||
@classmethod
|
||||
def _thinking_blocks_from_reasoning_item(
|
||||
cls,
|
||||
summary: Iterable[object],
|
||||
) -> tuple[dict[str, Any], ...]: # mutable-ok: API message payload
|
||||
"""Anthropic thinking blocks for one Responses reasoning item.
|
||||
|
||||
The signature stays empty: only Anthropic can sign a thinking block, and a stand-in
|
||||
value would be replayed as a real one and rejected by every backend that verifies it.
|
||||
"""
|
||||
return tuple(
|
||||
AnthropicResponseContentBlockThinking(
|
||||
type="thinking",
|
||||
thinking=text,
|
||||
signature=None,
|
||||
).model_dump()
|
||||
for part in summary
|
||||
if (text := cls._summary_part_text(part))
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _assistant_block_group_key(indexed_block: tuple[int, Mapping[str, Any]]) -> str:
|
||||
"""Group a run of consecutive thinking blocks together; keep every other block alone."""
|
||||
index, block = indexed_block
|
||||
return "thinking" if block.get("type") == "thinking" else f"block:{index}"
|
||||
|
||||
@classmethod
|
||||
def _assistant_group_to_input_item(
|
||||
cls, group: tuple[Mapping[str, Any], ...]
|
||||
) -> dict[str, Any] | None: # mutable-ok: API message payload
|
||||
first: Final = group[0]
|
||||
btype: Final = first.get("type")
|
||||
if btype == "thinking":
|
||||
blocks: Final = cast(tuple[ChatCompletionThinkingBlock, ...], group) # cast-ok: untrusted client payload
|
||||
reasoning_item: Final = responses_reasoning_item_from_thinking_blocks(blocks)
|
||||
return None if reasoning_item is None else dict(reasoning_item) # mutable-ok: API message payload
|
||||
if btype == "tool_use":
|
||||
return { # mutable-ok: API message payload
|
||||
"type": "function_call",
|
||||
"call_id": first.get("id", ""),
|
||||
"name": first.get("name", ""),
|
||||
"arguments": json.dumps(first.get("input", {})), # mutable-ok: API message payload
|
||||
}
|
||||
return None
|
||||
|
||||
def translate_messages_to_responses_input(
|
||||
self,
|
||||
messages: list[AllAnthropicPassThroughMessageValues],
|
||||
|
|
@ -113,6 +171,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
user image -> message(role=user, input_image)
|
||||
user tool_result -> function_call_output
|
||||
assistant text -> message(role=assistant, output_text)
|
||||
assistant thinking -> reasoning
|
||||
assistant tool_use -> function_call
|
||||
"""
|
||||
input_items: Final[list[dict[str, Any]]] = []
|
||||
|
|
@ -233,27 +292,17 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
}
|
||||
)
|
||||
elif isinstance(content, list):
|
||||
asst_parts: list[dict[str, Any]] = []
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
btype = block.get("type")
|
||||
if btype == "text":
|
||||
asst_parts.append({"type": "output_text", "text": block.get("text", "")})
|
||||
elif btype == "tool_use":
|
||||
# tool_use becomes a top-level function_call item
|
||||
input_items.append(
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": block.get("id", ""),
|
||||
"name": block.get("name", ""),
|
||||
"arguments": json.dumps(block.get("input", {})),
|
||||
}
|
||||
)
|
||||
elif btype == "thinking":
|
||||
thinking_text = block.get("thinking", "")
|
||||
if thinking_text:
|
||||
asst_parts.append({"type": "output_text", "text": thinking_text})
|
||||
blocks = tuple(block for block in content if isinstance(block, dict))
|
||||
input_items.extend(
|
||||
item
|
||||
for _, group in groupby(enumerate(blocks), key=self._assistant_block_group_key)
|
||||
if (item := self._assistant_group_to_input_item(tuple(block for _, block in group))) is not None
|
||||
)
|
||||
asst_parts: list[dict[str, Any]] = [ # mutable-ok: API message payload
|
||||
{"type": "output_text", "text": block.get("text", "")} # mutable-ok: API message payload
|
||||
for block in blocks
|
||||
if block.get("type") == "text"
|
||||
]
|
||||
if asst_parts:
|
||||
input_items.append(
|
||||
{
|
||||
|
|
@ -514,16 +563,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
|
||||
for item in response.output:
|
||||
if isinstance(item, ResponseReasoningItem):
|
||||
for summary in item.summary:
|
||||
text = getattr(summary, "text", "")
|
||||
if text:
|
||||
content.append(
|
||||
AnthropicResponseContentBlockThinking(
|
||||
type="thinking",
|
||||
thinking=text,
|
||||
signature=None,
|
||||
).model_dump()
|
||||
)
|
||||
content.extend(self._thinking_blocks_from_reasoning_item(item.summary))
|
||||
|
||||
elif isinstance(item, ResponseOutputMessage):
|
||||
for part in item.content:
|
||||
|
|
@ -555,6 +595,12 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
content.append(
|
||||
AnthropicResponseContentBlockText(type="text", text=part.get("text", "")).model_dump()
|
||||
)
|
||||
elif item_type == "reasoning":
|
||||
content.extend(
|
||||
self._thinking_blocks_from_reasoning_item(
|
||||
cast(Iterable[object], item.get("summary") or ()), # cast-ok: untyped provider json
|
||||
)
|
||||
)
|
||||
elif item_type == "function_call":
|
||||
try:
|
||||
input_data = json.loads(item.get("arguments", "{}"))
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import hashlib
|
|||
import json
|
||||
import os
|
||||
from collections.abc import Callable, Mapping
|
||||
from functools import lru_cache
|
||||
from typing import Any, Final, Literal, NamedTuple, cast
|
||||
|
||||
import httpx
|
||||
|
|
@ -75,6 +76,24 @@ def process_azure_headers(headers: httpx.Headers | dict) -> dict:
|
|||
return {**llm_response_headers, **openai_headers}
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def _cached_entra_id_token_provider(
|
||||
tenant_id: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
scope: str,
|
||||
) -> Callable[[], str]:
|
||||
"""Build (once per credential set) a bearer token provider backed by a `ClientSecretCredential`.
|
||||
|
||||
The credential caches the access token internally and only talks to Entra ID when it is close
|
||||
to expiry, so reusing the provider keeps one AAD round trip per token lifetime instead of one
|
||||
per request.
|
||||
"""
|
||||
from azure.identity import ClientSecretCredential, get_bearer_token_provider
|
||||
|
||||
return get_bearer_token_provider(ClientSecretCredential(tenant_id, client_id, client_secret), scope)
|
||||
|
||||
|
||||
def get_azure_ad_token_from_entra_id(
|
||||
tenant_id: str,
|
||||
client_id: str,
|
||||
|
|
@ -93,8 +112,6 @@ def get_azure_ad_token_from_entra_id(
|
|||
Returns:
|
||||
callable that returns a bearer token.
|
||||
"""
|
||||
from azure.identity import ClientSecretCredential, get_bearer_token_provider
|
||||
|
||||
verbose_logger.debug("Getting Azure AD Token from Entra ID")
|
||||
|
||||
if tenant_id.startswith("os.environ/"):
|
||||
|
|
@ -120,9 +137,13 @@ def get_azure_ad_token_from_entra_id(
|
|||
)
|
||||
if _tenant_id is None or _client_id is None or _client_secret is None:
|
||||
raise ValueError("tenant_id, client_id, and client_secret must be provided")
|
||||
credential: Final = ClientSecretCredential(_tenant_id, _client_id, _client_secret)
|
||||
|
||||
token_provider: Final = get_bearer_token_provider(credential, scope)
|
||||
token_provider: Final = _cached_entra_id_token_provider(
|
||||
tenant_id=_tenant_id,
|
||||
client_id=_client_id,
|
||||
client_secret=_client_secret,
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
verbose_logger.debug("token_provider %s", token_provider)
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,12 @@ class AzureFoundryErrorStrings(str, enum.Enum):
|
|||
SET_EXTRA_PARAMETERS_TO_PASS_THROUGH = "Set extra-parameters to 'pass-through'"
|
||||
|
||||
|
||||
NON_OPENAI_SPEC_MESSAGE_FIELDS: Final = ("thinking_blocks", "provider_specific_fields", "cache_control")
|
||||
NON_OPENAI_SPEC_MESSAGE_FIELDS: Final = (
|
||||
"thinking_blocks",
|
||||
"reasoning_content",
|
||||
"provider_specific_fields",
|
||||
"cache_control",
|
||||
)
|
||||
|
||||
|
||||
class AzureAIStudioConfig(OpenAIConfig):
|
||||
|
|
@ -173,7 +178,8 @@ class AzureAIStudioConfig(OpenAIConfig):
|
|||
"""
|
||||
- Azure AI Studio doesn't support content as a list. This handles:
|
||||
1. Strips message fields that are not part of the OpenAI chat-completions
|
||||
schema (thinking_blocks, provider_specific_fields, cache_control).
|
||||
schema (thinking_blocks, reasoning_content, provider_specific_fields,
|
||||
cache_control).
|
||||
Azure AI Foundry backends set additionalProperties=false and reject
|
||||
these with "Extra inputs are not permitted", which breaks multi-turn
|
||||
Anthropic-format clients that echo thinking blocks back as history.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,54 @@
|
|||
from collections.abc import Mapping
|
||||
from typing import Final, Literal
|
||||
|
||||
import litellm
|
||||
from litellm.llms.base_llm.base_utils import BaseLLMModelInfo, BaseTokenCounter
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
|
||||
AzureAIApiKeyHeader = Literal["Authorization", "api-key", "Api-Key", "Ocp-Apim-Subscription-Key"]
|
||||
|
||||
|
||||
def get_azure_ai_entra_token(litellm_params: Mapping[str, object] | None = None) -> str | None:
|
||||
"""
|
||||
Resolve an Entra ID / OAuth access token for an Azure AI Foundry deployment.
|
||||
|
||||
Accepts the same credential set as the `azure` provider: service principal
|
||||
(`tenant_id` / `client_id` / `client_secret`), a pre-fetched `azure_ad_token`, an OIDC
|
||||
federated token, username/password, or `DefaultAzureCredential` / managed identity.
|
||||
"""
|
||||
from litellm.llms.azure.common_utils import get_azure_ad_token
|
||||
|
||||
params = GenericLiteLLMParams.model_validate(litellm_params) if litellm_params else GenericLiteLLMParams()
|
||||
|
||||
return get_azure_ad_token(params)
|
||||
|
||||
|
||||
def get_azure_ai_auth_headers(
|
||||
api_key: str | None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
api_key_header: AzureAIApiKeyHeader = "Authorization",
|
||||
api_key_env_var: str = "AZURE_AI_API_KEY",
|
||||
) -> Mapping[str, str]:
|
||||
"""
|
||||
Build the auth headers for an Azure AI Foundry route.
|
||||
|
||||
Prefers the API key when one is configured, and otherwise falls back to Entra ID / OAuth,
|
||||
sending the access token as a bearer token.
|
||||
"""
|
||||
if api_key:
|
||||
return {api_key_header: f"Bearer {api_key}" if api_key_header == "Authorization" else api_key}
|
||||
|
||||
azure_ad_token = get_azure_ai_entra_token(litellm_params=litellm_params)
|
||||
if azure_ad_token:
|
||||
return {"Authorization": f"Bearer {azure_ad_token}"}
|
||||
|
||||
raise ValueError(
|
||||
f"Missing Azure AI credentials - set an API key (`api_key` or {api_key_env_var}), or Entra ID / OAuth "
|
||||
"credentials (`tenant_id` + `client_id` + `client_secret`, `azure_ad_token`, an OIDC token, or a managed "
|
||||
"identity with `litellm.enable_azure_ad_token_refresh = True`)"
|
||||
)
|
||||
|
||||
|
||||
class AzureFoundryModelInfo(BaseLLMModelInfo):
|
||||
|
|
@ -43,7 +88,7 @@ class AzureFoundryModelInfo(BaseLLMModelInfo):
|
|||
|
||||
@staticmethod
|
||||
def get_api_key(api_key: str | None = None) -> str | None:
|
||||
return api_key or litellm.api_key or litellm.openai_key or get_secret_str("AZURE_AI_API_KEY")
|
||||
return api_key or litellm.api_key or get_secret_str("AZURE_AI_API_KEY")
|
||||
|
||||
@property
|
||||
def api_version(self, api_version: str | None = None) -> str | None:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ from typing import Any, Final
|
|||
from httpx._types import RequestFiles
|
||||
|
||||
import litellm
|
||||
from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo
|
||||
from litellm.llms.azure_ai.common_utils import (
|
||||
AzureFoundryModelInfo,
|
||||
get_azure_ai_auth_headers,
|
||||
)
|
||||
from litellm.llms.azure_ai.image_generation.flux_transformation import (
|
||||
AzureFoundryFluxImageGenerationConfig,
|
||||
)
|
||||
|
|
@ -71,16 +74,13 @@ class AzureFoundryFlux2ImageEditConfig(OpenAIImageEditConfig):
|
|||
"""
|
||||
Validate Azure AI Foundry environment and set up authentication
|
||||
"""
|
||||
api_key = AzureFoundryModelInfo.get_api_key(api_key)
|
||||
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
f"Azure AI API key is required for model {model}. Set AZURE_AI_API_KEY environment variable or pass api_key parameter."
|
||||
)
|
||||
|
||||
headers.update(
|
||||
{
|
||||
"Api-Key": api_key,
|
||||
**get_azure_ai_auth_headers(
|
||||
api_key=AzureFoundryModelInfo.get_api_key(api_key),
|
||||
litellm_params=litellm_params,
|
||||
api_key_header="Api-Key",
|
||||
),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ from typing import TYPE_CHECKING, Any, Final, cast
|
|||
import httpx
|
||||
from httpx._types import RequestFiles
|
||||
|
||||
from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo
|
||||
from litellm.llms.azure_ai.common_utils import (
|
||||
AzureFoundryModelInfo,
|
||||
get_azure_ai_auth_headers,
|
||||
)
|
||||
from litellm.llms.azure_ai.image_generation.mai_transformation import (
|
||||
AzureFoundryMAIImageGenerationConfig,
|
||||
)
|
||||
|
|
@ -91,15 +94,13 @@ class AzureFoundryMAIImageEditConfig(OpenAIImageEditConfig):
|
|||
litellm_params: dict | None = None,
|
||||
api_base: str | None = None,
|
||||
) -> dict:
|
||||
api_key = AzureFoundryModelInfo.get_api_key(api_key)
|
||||
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
f"Azure AI API key is required for model {model}. "
|
||||
"Set AZURE_AI_API_KEY environment variable or pass api_key parameter."
|
||||
headers.update(
|
||||
get_azure_ai_auth_headers(
|
||||
api_key=AzureFoundryModelInfo.get_api_key(api_key),
|
||||
litellm_params=litellm_params,
|
||||
api_key_header="api-key",
|
||||
)
|
||||
|
||||
headers.update({"api-key": api_key})
|
||||
)
|
||||
return headers
|
||||
|
||||
def get_complete_url(
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ from typing import Final
|
|||
import httpx
|
||||
|
||||
import litellm
|
||||
from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo
|
||||
from litellm.llms.azure_ai.common_utils import (
|
||||
AzureFoundryModelInfo,
|
||||
get_azure_ai_auth_headers,
|
||||
)
|
||||
from litellm.llms.openai.image_edit.transformation import OpenAIImageEditConfig
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.utils import _add_path_to_api_base
|
||||
|
|
@ -30,19 +33,14 @@ class AzureFoundryFluxImageEditConfig(OpenAIImageEditConfig):
|
|||
) -> dict:
|
||||
"""
|
||||
Validate Azure AI Foundry environment and set up authentication
|
||||
Uses Api-Key header format
|
||||
Uses the Api-Key header format, or an Entra ID / OAuth bearer token when no key is set
|
||||
"""
|
||||
api_key = AzureFoundryModelInfo.get_api_key(api_key)
|
||||
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
f"Azure AI API key is required for model {model}. Set AZURE_AI_API_KEY environment variable or pass api_key parameter."
|
||||
)
|
||||
|
||||
headers.update(
|
||||
{
|
||||
"Api-Key": api_key, # Azure AI Foundry uses Api-Key header format
|
||||
}
|
||||
get_azure_ai_auth_headers(
|
||||
api_key=AzureFoundryModelInfo.get_api_key(api_key),
|
||||
litellm_params=litellm_params,
|
||||
api_key_header="Api-Key",
|
||||
)
|
||||
)
|
||||
return headers
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from litellm.constants import (
|
|||
)
|
||||
from litellm.exceptions import UnsupportedParamsError
|
||||
from litellm.litellm_core_utils.url_utils import SSRFError, assert_same_origin, encode_url_path_segment
|
||||
from litellm.llms.azure_ai.common_utils import get_azure_ai_auth_headers
|
||||
from litellm.llms.base_llm.ocr.transformation import (
|
||||
OCR_REQUEST_FORMAT_PARAM,
|
||||
BaseOCRConfig,
|
||||
|
|
@ -236,17 +237,13 @@ class AzureDocumentIntelligenceOCRConfig(BaseOCRConfig):
|
|||
"""
|
||||
Validate environment and return headers for Azure Document Intelligence.
|
||||
|
||||
Authentication uses Ocp-Apim-Subscription-Key header.
|
||||
Authentication uses the Ocp-Apim-Subscription-Key header, or an Entra ID / OAuth bearer
|
||||
token when no subscription key is set.
|
||||
"""
|
||||
# Get API key from environment if not provided
|
||||
if api_key is None:
|
||||
api_key = get_secret_str(AZURE_DOCUMENT_INTELLIGENCE_API_KEY_ENV_VAR)
|
||||
|
||||
if api_key is None:
|
||||
raise ValueError(
|
||||
"Missing Azure Document Intelligence API Key - Set AZURE_DOCUMENT_INTELLIGENCE_API_KEY environment variable or pass api_key parameter"
|
||||
)
|
||||
|
||||
# Validate API base/endpoint is provided
|
||||
if api_base is None:
|
||||
api_base = get_secret_str("AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT")
|
||||
|
|
@ -257,7 +254,12 @@ class AzureDocumentIntelligenceOCRConfig(BaseOCRConfig):
|
|||
)
|
||||
|
||||
headers = {
|
||||
"Ocp-Apim-Subscription-Key": api_key,
|
||||
**get_azure_ai_auth_headers(
|
||||
api_key=api_key,
|
||||
litellm_params=litellm_params,
|
||||
api_key_header="Ocp-Apim-Subscription-Key",
|
||||
api_key_env_var=AZURE_DOCUMENT_INTELLIGENCE_API_KEY_ENV_VAR,
|
||||
),
|
||||
"Content-Type": "application/json",
|
||||
**headers,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from litellm.litellm_core_utils.prompt_templates.image_handling import (
|
|||
async_convert_url_to_base64,
|
||||
convert_url_to_base64,
|
||||
)
|
||||
from litellm.llms.azure_ai.common_utils import get_azure_ai_auth_headers
|
||||
from litellm.llms.base_llm.ocr.transformation import DocumentType, OCRRequestData
|
||||
from litellm.llms.mistral.ocr.transformation import MistralOCRConfig
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
|
|
@ -47,17 +48,12 @@ class AzureAIOCRConfig(MistralOCRConfig):
|
|||
"""
|
||||
Validate environment and return headers for Azure AI OCR.
|
||||
|
||||
Azure AI uses Bearer token authentication with AZURE_AI_API_KEY.
|
||||
Authenticates with AZURE_AI_API_KEY, or with an Entra ID / OAuth token when no key is set.
|
||||
"""
|
||||
# Get API key from environment if not provided
|
||||
if api_key is None:
|
||||
api_key = get_secret_str(AZURE_AI_OCR_API_KEY_ENV_VAR)
|
||||
|
||||
if api_key is None:
|
||||
raise ValueError(
|
||||
"Missing Azure AI API Key - A call is being made to Azure AI but no key is set either in the environment variables or via params"
|
||||
)
|
||||
|
||||
# Validate API base is provided
|
||||
if api_base is None:
|
||||
api_base = get_secret_str("AZURE_AI_API_BASE")
|
||||
|
|
@ -68,7 +64,7 @@ class AzureAIOCRConfig(MistralOCRConfig):
|
|||
)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
**get_azure_ai_auth_headers(api_key=api_key, litellm_params=litellm_params),
|
||||
"Content-Type": "application/json",
|
||||
**headers,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@
|
|||
Translate between Cohere's `/rerank` format and Azure AI's `/rerank` format.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.llms.azure_ai.common_utils import get_azure_ai_auth_headers
|
||||
from litellm.llms.cohere.rerank.transformation import CohereRerankConfig
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.utils import RerankResponse
|
||||
|
|
@ -64,15 +66,13 @@ class AzureAIRerankConfig(CohereRerankConfig):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
if api_key is None:
|
||||
api_key = get_secret_str("AZURE_AI_API_KEY") or litellm.azure_key
|
||||
|
||||
if api_key is None:
|
||||
raise ValueError("Azure AI API key is required. Please set 'AZURE_AI_API_KEY' or 'litellm.azure_key'")
|
||||
|
||||
default_headers: Final = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
**get_azure_ai_auth_headers(api_key=api_key, litellm_params=litellm_params),
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -24,6 +25,7 @@ class BaseRerankConfig(ABC):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -91,6 +91,14 @@ class BaseVideoConfig(ABC):
|
|||
raise ValueError("api_base is required")
|
||||
return api_base
|
||||
|
||||
def use_multipart_form_data(self) -> bool:
|
||||
"""
|
||||
Whether video create requests without files must still be sent as
|
||||
multipart/form-data (the encoding the OpenAI SDK always uses for
|
||||
/videos), instead of falling back to JSON.
|
||||
"""
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
def transform_video_create_request(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -567,6 +567,10 @@ class AWSEventStreamDecoder:
|
|||
if thinking_blocks:
|
||||
self._thinking_ran = True
|
||||
|
||||
carries_message_content: Final = any(
|
||||
key in chunk_data for key in ("start", "delta", "contentBlockIndex", "stopReason", "trace")
|
||||
)
|
||||
|
||||
model_response_provider_specific_fields: Final = {}
|
||||
if "trace" in chunk_data:
|
||||
trace: Final = chunk_data.get("trace")
|
||||
|
|
@ -577,8 +581,8 @@ class AWSEventStreamDecoder:
|
|||
finish_reason=finish_reason,
|
||||
index=0, # Always 0 - Bedrock never returns multiple choices
|
||||
delta=Delta(
|
||||
content=text,
|
||||
role="assistant",
|
||||
content=text if carries_message_content else None,
|
||||
role="assistant" if carries_message_content else None,
|
||||
tool_calls=[tool_use] if tool_use else None,
|
||||
provider_specific_fields=(provider_specific_fields if provider_specific_fields else None),
|
||||
thinking_blocks=thinking_blocks,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from collections.abc import Mapping
|
||||
from typing import Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -81,6 +82,7 @@ class CohereRerankConfig(BaseRerankConfig):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
if api_key is None:
|
||||
api_key = get_secret_str("COHERE_API_KEY") or get_secret_str("CO_API_KEY") or litellm.cohere_key
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from litellm.litellm_core_utils.agentic_loop_settings import (
|
|||
validated_max_agentic_loops,
|
||||
)
|
||||
from litellm.litellm_core_utils.asyncify import run_async_function
|
||||
from litellm.litellm_core_utils.llm_request_utils import serialize_multipart_form_fields
|
||||
from litellm.litellm_core_utils.realtime_errors import realtime_error_event, websocket_close_reason
|
||||
from litellm.litellm_core_utils.realtime_streaming import RealTimeStreaming
|
||||
from litellm.litellm_core_utils.url_utils import encode_url_path_segment
|
||||
|
|
@ -1108,6 +1109,7 @@ class BaseLLMHTTPHandler:
|
|||
headers=headers or {},
|
||||
model=model,
|
||||
optional_params=optional_rerank_params,
|
||||
litellm_params=litellm_params,
|
||||
)
|
||||
|
||||
api_base = provider_config.get_complete_url(
|
||||
|
|
@ -7050,9 +7052,7 @@ class BaseLLMHTTPHandler:
|
|||
)
|
||||
|
||||
try:
|
||||
# Use JSON when no files, otherwise use form data with files
|
||||
if files and len(files) > 0:
|
||||
# Use multipart/form-data when files are present
|
||||
response = sync_httpx_client.post(
|
||||
url=api_base,
|
||||
headers=headers,
|
||||
|
|
@ -7060,9 +7060,14 @@ class BaseLLMHTTPHandler:
|
|||
files=files,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
elif video_generation_provider_config.use_multipart_form_data():
|
||||
response = sync_httpx_client.post( # rebind-ok: one of three mutually-exclusive branches
|
||||
url=api_base,
|
||||
headers=headers,
|
||||
files=serialize_multipart_form_fields(data),
|
||||
timeout=timeout,
|
||||
)
|
||||
else:
|
||||
# Use JSON content type for POST requests without files
|
||||
response = sync_httpx_client.post(
|
||||
url=api_base,
|
||||
headers=headers,
|
||||
|
|
@ -7154,20 +7159,26 @@ class BaseLLMHTTPHandler:
|
|||
)
|
||||
|
||||
try:
|
||||
# Use JSON when no files, otherwise use form data with files
|
||||
if files is None or len(files) == 0:
|
||||
if files and len(files) > 0:
|
||||
response = await async_httpx_client.post(
|
||||
url=api_base,
|
||||
headers=headers,
|
||||
json=data,
|
||||
data=data,
|
||||
files=files,
|
||||
timeout=timeout,
|
||||
)
|
||||
elif video_generation_provider_config.use_multipart_form_data():
|
||||
response = await async_httpx_client.post( # rebind-ok: one of three mutually-exclusive branches
|
||||
url=api_base,
|
||||
headers=headers,
|
||||
files=serialize_multipart_form_fields(data),
|
||||
timeout=timeout,
|
||||
)
|
||||
else:
|
||||
response = await async_httpx_client.post(
|
||||
url=api_base,
|
||||
headers=headers,
|
||||
data=data,
|
||||
files=files,
|
||||
json=data,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ as supported only for gte-rerank-v2 / qwen3-vl-rerank.
|
|||
Docs - https://help.aliyun.com/zh/model-studio/text-rerank-api
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -85,6 +86,7 @@ class DashScopeRerankConfig(BaseRerankConfig):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
if api_key is None:
|
||||
api_key = get_secret_str("DASHSCOPE_API_KEY")
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
Translate between Cohere's `/rerank` format and Deepinfra's `/rerank` format.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -67,6 +68,7 @@ class DeepinfraRerankConfig(BaseRerankConfig):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
if api_key is None:
|
||||
api_key = get_secret_str("DEEPINFRA_API_KEY")
|
||||
|
|
|
|||
|
|
@ -504,6 +504,7 @@ class FireworksAIConfig(FireworksAIMixin, OpenAIGPTConfig):
|
|||
m = cast(dict, message)
|
||||
m.pop("provider_specific_fields", None)
|
||||
m.pop("thinking_blocks", None)
|
||||
m.pop("reasoning_content", None)
|
||||
|
||||
return messages
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ Fireworks AI Rerank API transformation
|
|||
Reference: https://docs.fireworks.ai/inference-api-reference/rerank
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -102,6 +103,7 @@ class FireworksAIRerankConfig(FireworksAIMixin, BaseRerankConfig):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
api_key = self._get_api_key(api_key)
|
||||
if api_key is None:
|
||||
|
|
|
|||
|
|
@ -164,12 +164,13 @@ class HostedVLLMChatConfig(OpenAIGPTConfig):
|
|||
"""
|
||||
Support translating:
|
||||
- video files from file_id or file_data to video_url
|
||||
- thinking_blocks on assistant messages are removed, and content lists
|
||||
are converted to strings for vLLM compatibility
|
||||
- thinking_blocks and reasoning_content on assistant messages are removed,
|
||||
and content lists are converted to strings for vLLM compatibility
|
||||
"""
|
||||
for message in messages:
|
||||
if message["role"] == "assistant":
|
||||
message.pop("thinking_blocks", None)
|
||||
message.pop("reasoning_content", None)
|
||||
existing_content = message.get("content")
|
||||
if isinstance(existing_content, list):
|
||||
text_parts = []
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
Transformation logic for Hosted VLLM rerank
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -107,6 +108,7 @@ class HostedVLLMRerankConfig(BaseRerankConfig):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
if api_key is None:
|
||||
api_key = get_secret_str("HOSTED_VLLM_API_KEY") or "fake-api-key"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import os
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -123,6 +124,7 @@ class HuggingFaceRerankConfig(BaseRerankConfig):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
api_base: str | None = None,
|
||||
) -> dict:
|
||||
# Get API credentials
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ Transformation logic from Cohere's /v1/rerank format to Infinity's `/v1/rerank`
|
|||
Why separate file? Make it easy to see how transformation works
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -46,6 +47,7 @@ class InfinityRerankConfig(CohereRerankConfig):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
if api_key is None:
|
||||
api_key = get_secret_str("INFINITY_API_KEY") or get_secret_str("INFINITY_API_KEY") or litellm.infinity_key
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ Why separate file? Make it easy to see how transformation works
|
|||
Docs - https://jina.ai/reranker
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Final
|
||||
|
||||
from httpx import URL, Response
|
||||
|
|
@ -139,6 +140,7 @@ class JinaAIRerankConfig(BaseRerankConfig):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
if api_key is None:
|
||||
raise ValueError("api_key is required. Set via `api_key` parameter or `JINA_API_KEY` environment variable.")
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from collections.abc import Mapping
|
||||
from typing import Any, Final, Literal
|
||||
|
||||
import httpx
|
||||
|
|
@ -152,6 +153,7 @@ class NvidiaNimRerankConfig(BaseRerankConfig):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Validate that the Nvidia NIM API key is present.
|
||||
|
|
|
|||
|
|
@ -134,14 +134,7 @@ def cost_per_second(model: str, custom_llm_provider: str | None, duration: float
|
|||
|
||||
|
||||
def _video_resolution_to_cost_field_suffix(resolution: str) -> str | None:
|
||||
"""
|
||||
Map usage resolution to a safe suffix for ``output_cost_per_second_<suffix>`` keys.
|
||||
|
||||
Note: Currently only ``output_cost_per_second_1080p`` is explicitly declared in
|
||||
ModelInfo (types/utils.py). Other resolution tiers (e.g., 720p, 4k) can be added
|
||||
to model_prices_and_context_window.json but are not exposed via get_model_info()
|
||||
until added to the ModelInfo TypedDict.
|
||||
"""
|
||||
"""Map usage resolution to a safe suffix for ``output_cost_per_second_<suffix>`` keys."""
|
||||
r: Final = resolution.strip().lower()
|
||||
if not r:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -101,6 +101,9 @@ class OpenAIVideoConfig(BaseVideoConfig):
|
|||
|
||||
return f"{api_base.rstrip('/')}/videos"
|
||||
|
||||
def use_multipart_form_data(self) -> bool:
|
||||
return True
|
||||
|
||||
def transform_video_create_request(
|
||||
self,
|
||||
model: str,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal
|
||||
|
||||
import httpx
|
||||
|
|
@ -33,6 +34,10 @@ else:
|
|||
LiteLLMLoggingObj = Any
|
||||
|
||||
|
||||
class RunwayMLError(BaseLLMException):
|
||||
pass
|
||||
|
||||
|
||||
class _RunwayTaskResponse(TypedDict, total=False):
|
||||
id: ReadOnly[str]
|
||||
status: ReadOnly[str]
|
||||
|
|
@ -41,7 +46,8 @@ class _RunwayTaskResponse(TypedDict, total=False):
|
|||
output: ReadOnly[Sequence[str] | str]
|
||||
failureCode: ReadOnly[str]
|
||||
failure: ReadOnly[str]
|
||||
progress: ReadOnly[int]
|
||||
progress: ReadOnly[float]
|
||||
estimatedCost: ReadOnly[Mapping[str, float]]
|
||||
|
||||
|
||||
class _VideoObjectData(TypedDict, extra_items=object):
|
||||
|
|
@ -56,12 +62,54 @@ def _parse_runway_task_response(raw_response: httpx.Response) -> _RunwayTaskResp
|
|||
return response_data
|
||||
|
||||
|
||||
_USD_PER_CREDIT: Final = 0.01
|
||||
|
||||
_RESOLUTION_AREA_TIERS: Final[tuple[tuple[int, str], ...]] = (
|
||||
(600_000, "480p"),
|
||||
(1_500_000, "720p"),
|
||||
(4_000_000, "1080p"),
|
||||
)
|
||||
|
||||
|
||||
def _ratio_to_resolution(ratio: object) -> str | None:
|
||||
if not isinstance(ratio, str) or ":" not in ratio:
|
||||
return None
|
||||
width_str, _, height_str = ratio.partition(":")
|
||||
if not (width_str.isdigit() and height_str.isdigit()):
|
||||
return None
|
||||
area: Final = int(width_str) * int(height_str)
|
||||
return next((label for threshold, label in _RESOLUTION_AREA_TIERS if area < threshold), "4k")
|
||||
|
||||
|
||||
def _duration_seconds(seconds: str | None) -> float | None:
|
||||
if not seconds:
|
||||
return None
|
||||
try:
|
||||
return float(seconds)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _estimated_cost_usd(response_data: _RunwayTaskResponse) -> float | None:
|
||||
estimated_cost: Final = response_data.get("estimatedCost")
|
||||
if not isinstance(estimated_cost, Mapping):
|
||||
return None
|
||||
credits: Final = estimated_cost.get("credits")
|
||||
if not isinstance(credits, (int, float)):
|
||||
return None
|
||||
return float(credits) * _USD_PER_CREDIT
|
||||
|
||||
|
||||
def _progress_percent(progress: float) -> int:
|
||||
return min(100, max(0, round(float(progress) * 100)))
|
||||
|
||||
|
||||
class RunwayMLVideoConfig(BaseVideoConfig):
|
||||
"""
|
||||
Configuration class for RunwayML video generation.
|
||||
|
||||
RunwayML uses a task-based API where:
|
||||
1. POST /v1/image_to_video creates a task
|
||||
1. POST /v1/text_to_video, /v1/image_to_video, or /v1/video_to_video creates a task
|
||||
2. The task returns immediately with a task ID
|
||||
3. Client must poll or wait for task completion
|
||||
"""
|
||||
|
|
@ -195,31 +243,36 @@ class RunwayMLVideoConfig(BaseVideoConfig):
|
|||
"""
|
||||
Transform the video creation request for RunwayML API.
|
||||
|
||||
RunwayML expects:
|
||||
{
|
||||
"model": "gen4_turbo",
|
||||
"promptImage": "https://... or data:image/...",
|
||||
"promptText": "description",
|
||||
"ratio": "1280:720",
|
||||
"duration": 5
|
||||
}
|
||||
RunwayML has three generation endpoints discriminated by which input is
|
||||
present, and each request body rejects unknown fields:
|
||||
- /text_to_video: promptText only (rejects promptImage)
|
||||
- /image_to_video: promptImage (+ optional promptText)
|
||||
- /video_to_video: promptVideo or videoUri (rejects promptImage)
|
||||
"""
|
||||
# Build the request data
|
||||
merged_params: Final = MappingProxyType(
|
||||
{
|
||||
"model": model,
|
||||
"promptText": prompt,
|
||||
**video_create_optional_request_params,
|
||||
}
|
||||
)
|
||||
|
||||
endpoint: Final = self._select_generation_endpoint(merged_params)
|
||||
|
||||
request_data: Final[dict[str, object]] = {
|
||||
"model": model,
|
||||
"promptText": prompt,
|
||||
key: value for key, value in merged_params.items() if endpoint == "image_to_video" or key != "promptImage"
|
||||
}
|
||||
|
||||
# Add mapped parameters
|
||||
request_data.update(video_create_optional_request_params)
|
||||
|
||||
# RunwayML uses JSON body, no files multipart
|
||||
files_list: Final[RequestFiles] = []
|
||||
|
||||
# Append the specific endpoint for video generation
|
||||
full_api_base: Final = f"{api_base}/image_to_video"
|
||||
return request_data, files_list, f"{api_base}/{endpoint}"
|
||||
|
||||
return request_data, files_list, full_api_base
|
||||
def _select_generation_endpoint(self, request_data: Mapping[str, object]) -> str:
|
||||
if request_data.get("promptVideo") is not None or request_data.get("videoUri") is not None:
|
||||
return "video_to_video"
|
||||
if request_data.get("promptImage") is not None:
|
||||
return "image_to_video"
|
||||
return "text_to_video"
|
||||
|
||||
def transform_video_create_response(
|
||||
self,
|
||||
|
|
@ -285,13 +338,15 @@ class RunwayMLVideoConfig(BaseVideoConfig):
|
|||
video_obj.id = encode_video_id_with_provider(video_obj.id, custom_llm_provider, model)
|
||||
|
||||
# Add usage data for cost tracking
|
||||
usage_data: Final = {}
|
||||
if video_obj and hasattr(video_obj, "seconds") and video_obj.seconds:
|
||||
try:
|
||||
usage_data["duration_seconds"] = float(video_obj.seconds)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
video_obj.usage = usage_data
|
||||
video_obj.usage = {
|
||||
key: value
|
||||
for key, value in (
|
||||
("duration_seconds", _duration_seconds(video_obj.seconds)),
|
||||
("video_resolution", _ratio_to_resolution(request_data.get("ratio") if request_data else None)),
|
||||
("provider_reported_cost_usd", _estimated_cost_usd(response_data)),
|
||||
)
|
||||
if value is not None
|
||||
}
|
||||
|
||||
return video_obj
|
||||
|
||||
|
|
@ -581,8 +636,9 @@ class RunwayMLVideoConfig(BaseVideoConfig):
|
|||
if "completedAt" in response_data:
|
||||
video_data["completed_at"] = self._parse_runway_timestamp(response_data.get("completedAt"))
|
||||
|
||||
if "progress" in response_data:
|
||||
video_data["progress"] = response_data["progress"]
|
||||
progress_value: Final = response_data.get("progress")
|
||||
if progress_value is not None:
|
||||
video_data["progress"] = _progress_percent(progress_value)
|
||||
|
||||
if "failureCode" in response_data or "failure" in response_data:
|
||||
video_data["error"] = {
|
||||
|
|
@ -646,9 +702,7 @@ class RunwayMLVideoConfig(BaseVideoConfig):
|
|||
raise NotImplementedError("video extension is not supported for RunwayML")
|
||||
|
||||
def get_error_class(self, error_message: str, status_code: int, headers: dict | httpx.Headers) -> BaseLLMException:
|
||||
from ...base_llm.chat.transformation import BaseLLMException
|
||||
|
||||
raise BaseLLMException(
|
||||
return RunwayMLError(
|
||||
status_code=status_code,
|
||||
message=error_message,
|
||||
headers=headers,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ Translates from Cohere's `/v1/rerank` input format to Vertex AI Discovery Engine
|
|||
Why separate file? Make it easy to see how transformation works
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -74,14 +75,15 @@ class VertexAIRerankConfig(BaseRerankConfig, VertexBase):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Validate and set up authentication for Vertex AI Discovery Engine API
|
||||
"""
|
||||
# Get credentials and project info from optional_params (which contains vertex_credentials, etc.)
|
||||
litellm_params: Final = optional_params.copy() if optional_params else {}
|
||||
vertex_credentials: Final = self.safe_get_vertex_ai_credentials(litellm_params)
|
||||
vertex_project: Final = self.safe_get_vertex_ai_project(litellm_params)
|
||||
vertex_params: Final = optional_params.copy() if optional_params else {}
|
||||
vertex_credentials: Final = self.safe_get_vertex_ai_credentials(vertex_params)
|
||||
vertex_project: Final = self.safe_get_vertex_ai_project(vertex_params)
|
||||
|
||||
# Get access token using the base class method
|
||||
access_token, project_id = self._ensure_access_token(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ Transformation logic for Voyage AI's /v1/rerank endpoint.
|
|||
Docs - https://docs.voyageai.com/docs/reranker
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -137,6 +138,7 @@ class VoyageRerankConfig(BaseRerankConfig):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
if api_key is None:
|
||||
api_key = get_secret_str("VOYAGE_API_KEY") or get_secret_str("VOYAGE_AI_API_KEY")
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Docs - https://cloud.ibm.com/apidocs/watsonx-ai#text-rerank
|
|||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Final, cast
|
||||
|
||||
import httpx
|
||||
|
|
@ -60,6 +61,7 @@ class IBMWatsonXRerankConfig(IBMWatsonXMixin, BaseRerankConfig):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
optional_params = optional_params or {}
|
||||
|
||||
|
|
|
|||
|
|
@ -6828,6 +6828,8 @@ def embedding(
|
|||
aembedding=aembedding,
|
||||
)
|
||||
elif custom_llm_provider == "azure_ai":
|
||||
from litellm.llms.azure_ai.common_utils import get_azure_ai_entra_token
|
||||
|
||||
api_base = (
|
||||
api_base # for deepinfra/perplexity/anyscale/groq/friendliai we check in get_llm_provider and pass in the api base from there
|
||||
or litellm.api_base
|
||||
|
|
@ -6837,8 +6839,8 @@ def embedding(
|
|||
api_key = (
|
||||
api_key
|
||||
or litellm.api_key # for deepinfra/perplexity/anyscale/friendliai we check in get_llm_provider and pass in the api key from there
|
||||
or litellm.openai_key
|
||||
or get_secret_str("AZURE_AI_API_KEY")
|
||||
or get_azure_ai_entra_token(litellm_params=litellm_params_dict)
|
||||
)
|
||||
|
||||
## EMBEDDING CALL
|
||||
|
|
|
|||
|
|
@ -1019,6 +1019,7 @@
|
|||
},
|
||||
"anthropic.claude-opus-4-6-v1": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 6.25e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 1e-05,
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
|
|
@ -1053,6 +1054,7 @@
|
|||
},
|
||||
"global.anthropic.claude-opus-4-6-v1": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 6.25e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 1e-05,
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
|
|
@ -1087,6 +1089,7 @@
|
|||
},
|
||||
"us.anthropic.claude-opus-4-6-v1": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 6.875e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 1.1e-05,
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
|
|
@ -1121,6 +1124,7 @@
|
|||
},
|
||||
"eu.anthropic.claude-opus-4-6-v1": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 6.875e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 1.1e-05,
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
|
|
@ -1155,6 +1159,7 @@
|
|||
},
|
||||
"au.anthropic.claude-opus-4-6-v1": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 6.875e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 1.1e-05,
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
|
|
@ -2233,6 +2238,7 @@
|
|||
},
|
||||
"anthropic.claude-sonnet-4-6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 3.75e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 6e-06,
|
||||
"cache_read_input_token_cost": 3e-07,
|
||||
|
|
@ -2266,6 +2272,7 @@
|
|||
},
|
||||
"global.anthropic.claude-sonnet-4-6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 3.75e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 6e-06,
|
||||
"cache_read_input_token_cost": 3e-07,
|
||||
|
|
@ -2299,6 +2306,7 @@
|
|||
},
|
||||
"us.anthropic.claude-sonnet-4-6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 4.125e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 6.6e-06,
|
||||
"cache_read_input_token_cost": 3.3e-07,
|
||||
|
|
@ -2332,6 +2340,7 @@
|
|||
},
|
||||
"eu.anthropic.claude-sonnet-4-6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 4.125e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 6.6e-06,
|
||||
"cache_read_input_token_cost": 3.3e-07,
|
||||
|
|
@ -2365,6 +2374,7 @@
|
|||
},
|
||||
"au.anthropic.claude-sonnet-4-6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 4.125e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 6.6e-06,
|
||||
"cache_read_input_token_cost": 3.3e-07,
|
||||
|
|
@ -2398,6 +2408,7 @@
|
|||
},
|
||||
"jp.anthropic.claude-sonnet-4-6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 4.125e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 6.6e-06,
|
||||
"cache_read_input_token_cost": 3.3e-07,
|
||||
|
|
@ -2950,6 +2961,7 @@
|
|||
"azure_ai/claude-opus-4-6": {
|
||||
"deprecation_date": "2027-02-02",
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"input_cost_per_token": 5e-06,
|
||||
"output_cost_per_token": 2.5e-05,
|
||||
"litellm_provider": "azure_ai",
|
||||
|
|
@ -3181,6 +3193,7 @@
|
|||
"azure_ai/claude-sonnet-4-6": {
|
||||
"deprecation_date": "2027-02-10",
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 3.75e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 6e-06,
|
||||
"cache_read_input_token_cost": 3e-07,
|
||||
|
|
@ -12489,6 +12502,7 @@
|
|||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
|
|
@ -12698,6 +12712,7 @@
|
|||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
|
|
@ -12735,6 +12750,7 @@
|
|||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
|
|
@ -14724,6 +14740,7 @@
|
|||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
|
|
@ -14892,6 +14909,7 @@
|
|||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
|
|
@ -23427,6 +23445,7 @@
|
|||
},
|
||||
"github_copilot/claude-opus-4.6-fast": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"litellm_provider": "github_copilot",
|
||||
"max_input_tokens": 128000,
|
||||
"max_output_tokens": 16000,
|
||||
|
|
@ -33810,6 +33829,7 @@
|
|||
},
|
||||
"openrouter/anthropic/claude-sonnet-4.6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 3.75e-06,
|
||||
"cache_creation_input_token_cost_above_200k_tokens": 7.5e-06,
|
||||
"cache_read_input_token_cost": 3e-07,
|
||||
|
|
@ -33854,6 +33874,7 @@
|
|||
},
|
||||
"openrouter/anthropic/claude-opus-4.6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 6.25e-06,
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
"input_cost_per_token": 5e-06,
|
||||
|
|
@ -35928,6 +35949,7 @@
|
|||
},
|
||||
"perplexity/anthropic/claude-opus-4-6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"litellm_provider": "perplexity",
|
||||
"mode": "responses",
|
||||
"supports_web_search": true,
|
||||
|
|
@ -39303,6 +39325,7 @@
|
|||
},
|
||||
"vercel_ai_gateway/anthropic/claude-opus-4.6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 6.25e-06,
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
"input_cost_per_token": 5e-06,
|
||||
|
|
@ -40562,6 +40585,7 @@
|
|||
"deprecation_date": "2027-02-05",
|
||||
"regional_endpoint_uplift_multiplier": 1.1,
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 6.25e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 1e-05,
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
|
|
@ -40594,6 +40618,7 @@
|
|||
"deprecation_date": "2027-02-05",
|
||||
"regional_endpoint_uplift_multiplier": 1.1,
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 6.25e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 1e-05,
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
|
|
@ -40959,6 +40984,7 @@
|
|||
"vertex_ai/claude-sonnet-4-6": {
|
||||
"regional_endpoint_uplift_multiplier": 1.1,
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 3.75e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 6e-06,
|
||||
"cache_read_input_token_cost": 3e-07,
|
||||
|
|
@ -44042,10 +44068,10 @@
|
|||
"comment": "5 credits per second @ $0.01 per credit = $0.05 per second"
|
||||
}
|
||||
},
|
||||
"runwayml/gen4_aleph": {
|
||||
"runwayml/gen4.5": {
|
||||
"litellm_provider": "runwayml",
|
||||
"mode": "video_generation",
|
||||
"output_cost_per_video_per_second": 0.15,
|
||||
"output_cost_per_second": 0.12,
|
||||
"source": "https://docs.dev.runwayml.com/guides/pricing/",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
|
|
@ -44055,13 +44081,136 @@
|
|||
"video"
|
||||
],
|
||||
"metadata": {
|
||||
"comment": "15 credits per second @ $0.01 per credit = $0.15 per second"
|
||||
"comment": "12 credits per second @ $0.01 per credit = $0.12 per second"
|
||||
}
|
||||
},
|
||||
"runwayml/gen3a_turbo": {
|
||||
"runwayml/aleph2": {
|
||||
"litellm_provider": "runwayml",
|
||||
"mode": "video_generation",
|
||||
"output_cost_per_video_per_second": 0.05,
|
||||
"output_cost_per_second": 0.28,
|
||||
"source": "https://docs.dev.runwayml.com/guides/pricing/",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"video"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"video"
|
||||
],
|
||||
"metadata": {
|
||||
"comment": "28 credits per second @ $0.01 per credit = $0.28 per second; 56 credit minimum per task not modeled"
|
||||
}
|
||||
},
|
||||
"runwayml/seedance2": {
|
||||
"litellm_provider": "runwayml",
|
||||
"mode": "video_generation",
|
||||
"output_cost_per_second": 0.36,
|
||||
"output_cost_per_second_1080p": 0.4,
|
||||
"output_cost_per_second_4k": 1.5,
|
||||
"source": "https://docs.dev.runwayml.com/guides/pricing/",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"video"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"video"
|
||||
],
|
||||
"metadata": {
|
||||
"comment": "36 credits per second at 480p/720p, 40 at 1080p, 150 at 4K @ $0.01 per credit"
|
||||
}
|
||||
},
|
||||
"runwayml/seedance2_fast": {
|
||||
"litellm_provider": "runwayml",
|
||||
"mode": "video_generation",
|
||||
"output_cost_per_second": 0.29,
|
||||
"source": "https://docs.dev.runwayml.com/guides/pricing/",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"video"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"video"
|
||||
],
|
||||
"metadata": {
|
||||
"comment": "29 credits per second at 480p/720p @ $0.01 per credit = $0.29 per second"
|
||||
}
|
||||
},
|
||||
"runwayml/seedance2_mini": {
|
||||
"litellm_provider": "runwayml",
|
||||
"mode": "video_generation",
|
||||
"output_cost_per_second": 0.16,
|
||||
"source": "https://docs.dev.runwayml.com/guides/pricing/",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"video"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"video"
|
||||
],
|
||||
"metadata": {
|
||||
"comment": "16 credits per second @ $0.01 per credit = $0.16 per second; 64 credit minimum per task not modeled"
|
||||
}
|
||||
},
|
||||
"runwayml/seedance2_5": {
|
||||
"litellm_provider": "runwayml",
|
||||
"mode": "video_generation",
|
||||
"output_cost_per_second": 0.3,
|
||||
"output_cost_per_second_480p": 0.2,
|
||||
"output_cost_per_second_1080p": 0.68,
|
||||
"source": "https://docs.dev.runwayml.com/guides/pricing/",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"video"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"video"
|
||||
],
|
||||
"metadata": {
|
||||
"comment": "Output: 20/30/68 credits per second at 480p/720p/1080p @ $0.01 per credit; input video billed additionally at 10/15/34 credits per input second and the 80 credit minimum per task are not modeled"
|
||||
}
|
||||
},
|
||||
"runwayml/hailuo3": {
|
||||
"litellm_provider": "runwayml",
|
||||
"mode": "video_generation",
|
||||
"output_cost_per_second": 0.1,
|
||||
"output_cost_per_second_1080p": 0.15,
|
||||
"source": "https://docs.dev.runwayml.com/guides/pricing/",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"video"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"video"
|
||||
],
|
||||
"metadata": {
|
||||
"comment": "10 credits per second at 768P, 15 at 2K (mapped to the 1080p tier) @ $0.01 per credit; 2 credits per reference image not modeled"
|
||||
}
|
||||
},
|
||||
"runwayml/gemini_omni_flash": {
|
||||
"litellm_provider": "runwayml",
|
||||
"mode": "video_generation",
|
||||
"output_cost_per_second": 0.1,
|
||||
"source": "https://docs.dev.runwayml.com/guides/pricing/",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"video"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"video"
|
||||
],
|
||||
"metadata": {
|
||||
"comment": "10 credits per second @ $0.01 per credit = $0.10 per second"
|
||||
}
|
||||
},
|
||||
"runwayml/veo3.1": {
|
||||
"litellm_provider": "runwayml",
|
||||
"mode": "video_generation",
|
||||
"output_cost_per_second": 0.4,
|
||||
"source": "https://docs.dev.runwayml.com/guides/pricing/",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
|
|
@ -44071,7 +44220,23 @@
|
|||
"video"
|
||||
],
|
||||
"metadata": {
|
||||
"comment": "5 credits per second @ $0.01 per credit = $0.05 per second"
|
||||
"comment": "40 credits per second with audio, 20 without @ $0.01 per credit; priced at the with-audio rate"
|
||||
}
|
||||
},
|
||||
"runwayml/veo3.1_fast": {
|
||||
"litellm_provider": "runwayml",
|
||||
"mode": "video_generation",
|
||||
"output_cost_per_second": 0.15,
|
||||
"source": "https://docs.dev.runwayml.com/guides/pricing/",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"video"
|
||||
],
|
||||
"metadata": {
|
||||
"comment": "15 credits per second with audio, 10 without @ $0.01 per credit; priced at the with-audio rate"
|
||||
}
|
||||
},
|
||||
"runwayml/gen4_image": {
|
||||
|
|
@ -48728,6 +48893,7 @@
|
|||
"vertex_ai/claude-sonnet-4-6@default": {
|
||||
"regional_endpoint_uplift_multiplier": 1.1,
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 3.75e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 6e-06,
|
||||
"cache_read_input_token_cost": 3e-07,
|
||||
|
|
@ -49512,6 +49678,7 @@
|
|||
},
|
||||
"snowflake/claude-sonnet-4-6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"max_tokens": 16384,
|
||||
"max_input_tokens": 200000,
|
||||
"max_output_tokens": 16384,
|
||||
|
|
@ -50502,6 +50669,14 @@
|
|||
"supports_adaptive_thinking": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "claude-legacy-thinking",
|
||||
"pattern": "claude-[a-z]+-4[-._]6(?!\\d)",
|
||||
"description": "Claude at version 4.6 exactly, in any id shape that contains claude-<family>-4-6 (dotted and underscored minors included, dated releases such as claude-sonnet-4-6-20260219 too). The 4.6 family is adaptive-thinking yet still accepts legacy thinking.type=enabled with budget_tokens, so the caller's hard budget cap is forwarded verbatim instead of being rewritten to an uncapped output_config.effort. The lookahead keeps two-digit minors such as 4-60 from matching. 4.7+ and 5+ majors reject the legacy shape and stay on the adaptive translation.",
|
||||
"model_info": {
|
||||
"supports_legacy_thinking": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "claude-always-on-thinking",
|
||||
"pattern": "claude-(?:fable|mythos)-",
|
||||
|
|
|
|||
|
|
@ -274,10 +274,8 @@ async def get_form_data(request: Request) -> dict[str, Any]:
|
|||
Handles when OpenAI SDKs pass form keys as `timestamp_granularities[]="word"` instead of `timestamp_granularities=["word", "sentence"]`
|
||||
"""
|
||||
form: Final = await request.form()
|
||||
form_data: Final = dict(form)
|
||||
parsed_form_data: Final[dict[str, Any]] = {}
|
||||
for key, value in form_data.items():
|
||||
# OpenAI SDKs pass form keys as `timestamp_granularities[]="word"` instead of `timestamp_granularities=["word", "sentence"]`
|
||||
for key, value in form.multi_items(): # not dict(form), which keeps only the last repeat
|
||||
if key.endswith("[]"):
|
||||
clean_key = key[:-2]
|
||||
parsed_form_data.setdefault(clean_key, []).append(value)
|
||||
|
|
|
|||
|
|
@ -433,6 +433,9 @@ def _update_litellm_params_for_health_check(model_info: dict, litellm_params: di
|
|||
"""
|
||||
Update the litellm params for health check.
|
||||
|
||||
- merges `model_info.health_check_params` into the probe request, so a deployment whose provider
|
||||
requires a payload field litellm does not synthesize (e.g. `mediaSource` for Bedrock TwelveLabs
|
||||
Pegasus) can supply it. The dedicated knobs below are applied afterwards and win on conflict.
|
||||
- gets a short `messages` param for health check
|
||||
- adds a bounded `max_tokens` when the deployment is a chat-style mode
|
||||
(`chat`, `completion`, `responses`) or the operator explicitly opts in
|
||||
|
|
@ -447,6 +450,16 @@ def _update_litellm_params_for_health_check(model_info: dict, litellm_params: di
|
|||
model_info,
|
||||
litellm_params, # any-ok: untyped router config dict
|
||||
)
|
||||
_health_check_params: Final = model_info.get("health_check_params", None)
|
||||
if isinstance(_health_check_params, dict):
|
||||
litellm_params.update(_health_check_params)
|
||||
elif _health_check_params is not None:
|
||||
logger.warning(
|
||||
"health_check_params for model %s is a %s, expected a dict. Ignoring it.",
|
||||
litellm_params.get("model"),
|
||||
type(_health_check_params).__name__,
|
||||
)
|
||||
|
||||
litellm_params["messages"] = _get_random_llm_message()
|
||||
if _should_inject_health_check_max_tokens(
|
||||
model_info,
|
||||
|
|
|
|||
|
|
@ -1888,6 +1888,8 @@ async def test_model_connection(
|
|||
# already resolved before reaching this endpoint; any remaining
|
||||
# reference must have come from the request body.
|
||||
_reject_os_environ_references(request_litellm_params)
|
||||
if model_info:
|
||||
_reject_os_environ_references(model_info)
|
||||
model_name: Final = request_litellm_params.get("model")
|
||||
|
||||
# Look up model configuration from router if model name is provided
|
||||
|
|
@ -1950,23 +1952,23 @@ async def test_model_connection(
|
|||
**request_litellm_params,
|
||||
}
|
||||
|
||||
## Auth check
|
||||
auth_model_info: Final = loaded_model_info if loaded_model_info is not None else model_info
|
||||
resolved_model_info: Final = loaded_model_info if loaded_model_info is not None else model_info
|
||||
litellm_params = _update_litellm_params_for_health_check(
|
||||
model_info=resolved_model_info or {},
|
||||
litellm_params=litellm_params,
|
||||
)
|
||||
|
||||
## Auth check, on the final probe params so health_check_params cannot retarget it afterwards
|
||||
await ModelManagementAuthChecks.can_user_make_model_call(
|
||||
model_params=Deployment(
|
||||
model_name="test_model",
|
||||
litellm_params=LiteLLM_Params(**litellm_params),
|
||||
model_info=auth_model_info,
|
||||
model_info=resolved_model_info,
|
||||
),
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
prisma_client=prisma_client,
|
||||
premium_user=premium_user,
|
||||
)
|
||||
# Include health_check_params if provided
|
||||
litellm_params = _update_litellm_params_for_health_check(
|
||||
model_info={},
|
||||
litellm_params=litellm_params,
|
||||
)
|
||||
mode = mode or litellm_params.pop("mode", None)
|
||||
|
||||
result: Final = await run_with_timeout(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from typing import Any, Final, cast
|
|||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import BACKGROUND_INTERACTION_COST_POLLING_ENABLED
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.core_helpers import (
|
||||
_get_parent_otel_span_from_kwargs,
|
||||
|
|
@ -318,6 +319,21 @@ class _ProxyDBLogger(CustomLogger):
|
|||
elif budget_reservation is not None:
|
||||
await _release_budget_reservation(budget_reservation=budget_reservation)
|
||||
else:
|
||||
if _is_unbilled_interaction_response(completion_response):
|
||||
if BACKGROUND_INTERACTION_COST_POLLING_ENABLED and _is_unbilled_in_progress_interaction(
|
||||
completion_response
|
||||
):
|
||||
verbose_proxy_logger.debug(
|
||||
"Cost tracking deferred for in-progress background interaction; "
|
||||
"the budget reservation stays open until the poll task logs the final usage"
|
||||
)
|
||||
return
|
||||
await _release_budget_reservation(budget_reservation=budget_reservation)
|
||||
verbose_proxy_logger.debug(
|
||||
"Released the budget reservation for an interaction create with no usage "
|
||||
"that no poll task will settle"
|
||||
)
|
||||
return
|
||||
await _release_budget_reservation(budget_reservation=budget_reservation)
|
||||
# Non-model call types (health checks, afile_delete) have no model or standard_logging_object.
|
||||
# Use .get() for "stream" to avoid KeyError on health checks.
|
||||
|
|
@ -463,6 +479,24 @@ def _write_spend_metadata_to_kwargs(kwargs: dict, metadata: dict) -> None:
|
|||
bucket[key] = value
|
||||
|
||||
|
||||
def _is_unbilled_interaction_response(completion_response: object) -> bool:
|
||||
from litellm.interactions.background_cost_polling import missing_usage_is_expected
|
||||
from litellm.types.interactions import InteractionsAPIResponse
|
||||
|
||||
if not isinstance(completion_response, InteractionsAPIResponse):
|
||||
return False
|
||||
return completion_response.usage is None and missing_usage_is_expected(completion_response)
|
||||
|
||||
|
||||
def _is_unbilled_in_progress_interaction(completion_response: object) -> bool:
|
||||
from litellm.interactions.background_cost_polling import is_pollable_background_interaction
|
||||
from litellm.types.interactions import InteractionsAPIResponse
|
||||
|
||||
if not isinstance(completion_response, InteractionsAPIResponse):
|
||||
return False
|
||||
return completion_response.usage is None and is_pollable_background_interaction(completion_response)
|
||||
|
||||
|
||||
def _should_track_cost_callback(
|
||||
user_api_key: str | None,
|
||||
user_id: str | None,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ Use litellm with Anthropic SDK, Vertex AI SDK, Cohere SDK, etc.
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Final, cast
|
||||
|
||||
|
|
@ -106,6 +106,36 @@ def is_passthrough_request_streaming(request_body: object) -> bool:
|
|||
return bool(request_body.get("stream", False))
|
||||
|
||||
|
||||
def get_passthrough_router_request_metadata(user_api_key_dict: UserAPIKeyAuth) -> Mapping[str, Any]:
|
||||
"""
|
||||
Build the request metadata carrying key-level spend attribution and the
|
||||
pre-call budget reservation for a router-model passthrough request.
|
||||
|
||||
Router-model passthrough branches call ``allm_passthrough_route`` directly,
|
||||
bypassing ``add_litellm_data_to_request``. Without this metadata the cost
|
||||
callback cannot attribute spend to the calling key and never releases the
|
||||
budget reservation minted at auth time, so the shared spend counter drifts
|
||||
up until the key falsely trips ``BudgetExceededError``.
|
||||
|
||||
The payload rides the ``litellm_metadata`` bucket, not ``metadata``: the
|
||||
router hop ``_ageneric_api_call_with_fallbacks`` canonicalises this call
|
||||
type into ``litellm_metadata``, and the cost callback reads spend
|
||||
attribution from that bucket while only backfilling ``user_api_key*`` keys
|
||||
from ``metadata``. Passing ``metadata=`` would silently drop the secondary
|
||||
attribution fields the helper sets (``agent_id``,
|
||||
``user_api_end_user_max_budget``) before the callback ever sees them.
|
||||
"""
|
||||
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
|
||||
|
||||
request_data: Final = {"litellm_metadata": {}} # mutable-ok: builder + litellm mutate this in place
|
||||
LiteLLMProxyRequestSetup.add_user_api_key_auth_to_request_metadata(
|
||||
data=request_data,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
_metadata_variable_name="litellm_metadata",
|
||||
)
|
||||
return request_data["litellm_metadata"]
|
||||
|
||||
|
||||
async def llm_passthrough_factory_proxy_route(
|
||||
custom_llm_provider: str,
|
||||
endpoint: str,
|
||||
|
|
@ -346,6 +376,7 @@ async def vllm_proxy_route(
|
|||
params=None,
|
||||
headers=None,
|
||||
cookies=None,
|
||||
litellm_metadata=get_passthrough_router_request_metadata(user_api_key_dict),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -1475,6 +1506,7 @@ async def azure_proxy_route(
|
|||
params=None,
|
||||
headers=None,
|
||||
cookies=None,
|
||||
litellm_metadata=get_passthrough_router_request_metadata(user_api_key_dict),
|
||||
)
|
||||
|
||||
if is_streaming_request:
|
||||
|
|
|
|||
|
|
@ -470,7 +470,10 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
|
|||
``items()`` collapses duplicate keys to the last value. Files go out as a
|
||||
list of ``(field_name, (filename, content, content_type))`` tuples and
|
||||
repeated non-file fields are grouped into list values, both of which httpx
|
||||
encodes as separate multipart parts.
|
||||
encodes as separate multipart parts. A form with no file parts is sent
|
||||
entirely through ``files`` as ``(field_name, (None, value))`` tuples,
|
||||
because httpx downgrades a file-less ``data=`` payload to
|
||||
application/x-www-form-urlencoded.
|
||||
"""
|
||||
form_items: Final = (await request.form()).multi_items()
|
||||
|
||||
|
|
@ -500,6 +503,11 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
|
|||
)
|
||||
}
|
||||
|
||||
multipart_files: Final = (
|
||||
files if files else tuple((field_name, (None, field_value)) for field_name, field_value in non_file_items)
|
||||
)
|
||||
multipart_data: Final = form_data_dict if files else None
|
||||
|
||||
# Remove content-type header - httpx will set it correctly with the new boundary
|
||||
# when it creates the multipart body from files/data parameters
|
||||
headers_copy: Final = headers.copy()
|
||||
|
|
@ -512,8 +520,8 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
|
|||
url,
|
||||
headers=headers_copy,
|
||||
params=requested_query_params,
|
||||
files=files,
|
||||
data=form_data_dict,
|
||||
files=multipart_files,
|
||||
data=multipart_data,
|
||||
)
|
||||
return await async_client.send(req, stream=True)
|
||||
|
||||
|
|
@ -522,8 +530,8 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
|
|||
url=url,
|
||||
headers=headers_copy,
|
||||
params=requested_query_params,
|
||||
files=files,
|
||||
data=form_data_dict,
|
||||
files=multipart_files,
|
||||
data=multipart_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
from typing import Any, Final
|
||||
|
||||
import orjson
|
||||
from fastapi import APIRouter, Depends, File, Form, Request, Response, UploadFile
|
||||
from fastapi.responses import ORJSONResponse
|
||||
|
||||
|
|
@ -20,6 +19,7 @@ from litellm.proxy.video_endpoints.utils import (
|
|||
encode_character_id_in_response,
|
||||
extract_model_from_target_model_names,
|
||||
get_custom_provider_from_data,
|
||||
video_reference_to_id,
|
||||
)
|
||||
from litellm.types.videos.utils import (
|
||||
decode_character_id_with_provider,
|
||||
|
|
@ -451,9 +451,7 @@ async def video_remix(
|
|||
version,
|
||||
)
|
||||
|
||||
# Read request body
|
||||
body: Final = await request.body()
|
||||
data: Final = orjson.loads(body)
|
||||
data: Final = await _read_request_body(request=request)
|
||||
data["video_id"] = video_id
|
||||
|
||||
decoded: Final = decode_video_id_with_provider(video_id)
|
||||
|
|
@ -760,15 +758,10 @@ async def video_edit(
|
|||
version,
|
||||
)
|
||||
|
||||
body: Final = await request.body()
|
||||
data: Final = orjson.loads(body)
|
||||
data: Final = await _read_request_body(request=request)
|
||||
data["video_id"] = video_reference_to_id(data.pop("video", None))
|
||||
|
||||
# Extract video_id from nested video object
|
||||
video_ref: Final = data.pop("video", {})
|
||||
video_id: Final = video_ref.get("id", "") if isinstance(video_ref, dict) else ""
|
||||
data["video_id"] = video_id
|
||||
|
||||
decoded: Final = decode_video_id_with_provider(video_id)
|
||||
decoded: Final = decode_video_id_with_provider(data["video_id"])
|
||||
provider_from_id: Final = decoded.get("custom_llm_provider")
|
||||
model_id_from_decoded: Final = decoded.get("model_id")
|
||||
|
||||
|
|
@ -860,15 +853,10 @@ async def video_extension(
|
|||
version,
|
||||
)
|
||||
|
||||
body: Final = await request.body()
|
||||
data: Final = orjson.loads(body)
|
||||
data: Final = await _read_request_body(request=request)
|
||||
data["video_id"] = video_reference_to_id(data.pop("video", None))
|
||||
|
||||
# Extract video_id from nested video object
|
||||
video_ref: Final = data.pop("video", {})
|
||||
video_id: Final = video_ref.get("id", "") if isinstance(video_ref, dict) else ""
|
||||
data["video_id"] = video_id
|
||||
|
||||
decoded: Final = decode_video_id_with_provider(video_id)
|
||||
decoded: Final = decode_video_id_with_provider(data["video_id"])
|
||||
provider_from_id: Final = decoded.get("custom_llm_provider")
|
||||
model_id_from_decoded: Final = decoded.get("model_id")
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,18 @@ def extract_model_from_target_model_names(target_model_names: Any) -> str | None
|
|||
return target_model_names[0] if target_model_names else None
|
||||
|
||||
|
||||
def video_reference_to_id(video_ref: object) -> str:
|
||||
if isinstance(video_ref, dict):
|
||||
return video_ref.get("id", "")
|
||||
if not isinstance(video_ref, str):
|
||||
return ""
|
||||
try:
|
||||
parsed_ref: Final = orjson.loads(video_ref)
|
||||
except orjson.JSONDecodeError:
|
||||
return video_ref
|
||||
return parsed_ref.get("id", "") if isinstance(parsed_ref, dict) else video_ref
|
||||
|
||||
|
||||
def get_custom_provider_from_data(data: dict[str, Any]) -> str | None:
|
||||
custom_llm_provider: Final = data.get("custom_llm_provider")
|
||||
if custom_llm_provider:
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from typing import Any, ClassVar, Final, Generic, Literal, TypeVar, get_type_hin
|
|||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
from typing_extensions import Protocol, Required, TypedDict, runtime_checkable
|
||||
from typing_extensions import Protocol, ReadOnly, Required, TypedDict, runtime_checkable
|
||||
|
||||
from litellm._uuid import uuid
|
||||
|
||||
|
|
@ -480,7 +480,9 @@ class LiteLLMParamsTypedDict(TypedDict, total=False):
|
|||
output_cost_per_token: float | None
|
||||
input_cost_per_second: float | None
|
||||
output_cost_per_second: float | None
|
||||
output_cost_per_second_480p: ReadOnly[float | None]
|
||||
output_cost_per_second_1080p: float | None
|
||||
output_cost_per_second_4k: ReadOnly[float | None]
|
||||
num_retries: int | None
|
||||
## MOCK RESPONSES ##
|
||||
mock_response: str | ModelResponse | Exception | None
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ class ProviderSpecificModelInfo(TypedDict, total=False):
|
|||
supports_web_search: bool | None
|
||||
supports_reasoning: bool | None
|
||||
supports_adaptive_thinking: bool | None
|
||||
supports_legacy_thinking: ReadOnly[bool | None]
|
||||
thinking_always_on: ReadOnly[bool | None]
|
||||
supports_tool_search: bool | None
|
||||
supports_mid_conversation_system: bool | None
|
||||
|
|
@ -277,6 +278,8 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False):
|
|||
output_cost_per_second_1080p: (
|
||||
float | None
|
||||
) # video_generation tier: key output_cost_per_second_<resolution> (e.g. 1080p, 720p)
|
||||
output_cost_per_second_480p: ReadOnly[float | None]
|
||||
output_cost_per_second_4k: ReadOnly[float | None]
|
||||
ocr_cost_per_page: float | None # for OCR models
|
||||
ocr_cost_per_credit: float | None # for OCR models priced by credit
|
||||
annotation_cost_per_page: float | None # for OCR models
|
||||
|
|
@ -440,6 +443,12 @@ class CallTypes(str, Enum):
|
|||
query = "query"
|
||||
aquery = "aquery"
|
||||
|
||||
#########################################################
|
||||
# Google Interactions API Call Types
|
||||
#########################################################
|
||||
create_interaction = "create_interaction"
|
||||
acreate_interaction = "acreate_interaction"
|
||||
|
||||
#########################################################
|
||||
# Container Call Types
|
||||
#########################################################
|
||||
|
|
@ -3331,6 +3340,8 @@ class CustomPricingLiteLLMParams(MirroredPricingParams):
|
|||
input_cost_per_second: float | None = None
|
||||
output_cost_per_second: float | None = None
|
||||
output_cost_per_second_1080p: float | None = None
|
||||
output_cost_per_second_480p: float | None = None
|
||||
output_cost_per_second_4k: float | None = None
|
||||
input_cost_per_pixel: float | None = None
|
||||
output_cost_per_pixel: float | None = None
|
||||
|
||||
|
|
|
|||
|
|
@ -5726,6 +5726,8 @@ def _get_model_info_helper(
|
|||
),
|
||||
output_cost_per_second=_model_info.get("output_cost_per_second", None),
|
||||
output_cost_per_second_1080p=_model_info.get("output_cost_per_second_1080p", None),
|
||||
output_cost_per_second_480p=_model_info.get("output_cost_per_second_480p", None),
|
||||
output_cost_per_second_4k=_model_info.get("output_cost_per_second_4k", None),
|
||||
output_cost_per_video_per_second=_model_info.get("output_cost_per_video_per_second", None),
|
||||
output_cost_per_image=_model_info.get("output_cost_per_image", None),
|
||||
output_cost_per_image_token=_model_info.get("output_cost_per_image_token", None),
|
||||
|
|
@ -5753,6 +5755,7 @@ def _get_model_info_helper(
|
|||
supports_url_context=_model_info.get("supports_url_context", None),
|
||||
supports_reasoning=_model_info.get("supports_reasoning", None),
|
||||
supports_adaptive_thinking=_model_info.get("supports_adaptive_thinking", None),
|
||||
supports_legacy_thinking=_model_info.get("supports_legacy_thinking", None),
|
||||
thinking_always_on=_model_info.get("thinking_always_on", None),
|
||||
supports_tool_search=_model_info.get("supports_tool_search", None),
|
||||
supports_mid_conversation_system=_model_info.get("supports_mid_conversation_system", None),
|
||||
|
|
@ -6526,21 +6529,10 @@ def acreate(*args, **kwargs): ## Thin client to handle the acreate langchain ca
|
|||
|
||||
|
||||
def prompt_token_calculator(model, messages):
|
||||
# use tiktoken or anthropic's tokenizer depending on the model
|
||||
text: Final = " ".join(message["content"] for message in messages)
|
||||
num_tokens = 0
|
||||
if "claude" in model:
|
||||
try:
|
||||
import anthropic
|
||||
except Exception:
|
||||
Exception("Anthropic import failed please run `pip install anthropic`")
|
||||
from anthropic import AI_PROMPT, HUMAN_PROMPT, Anthropic
|
||||
|
||||
anthropic_obj: Final = Anthropic()
|
||||
num_tokens = anthropic_obj.count_tokens(text)
|
||||
else:
|
||||
num_tokens = len(_get_default_encoding().encode(text))
|
||||
return num_tokens
|
||||
return token_counter(model=model, text=text)
|
||||
return len(_get_default_encoding().encode(text))
|
||||
|
||||
|
||||
def valid_model(model):
|
||||
|
|
|
|||
|
|
@ -1019,6 +1019,7 @@
|
|||
},
|
||||
"anthropic.claude-opus-4-6-v1": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 6.25e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 1e-05,
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
|
|
@ -1053,6 +1054,7 @@
|
|||
},
|
||||
"global.anthropic.claude-opus-4-6-v1": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 6.25e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 1e-05,
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
|
|
@ -1087,6 +1089,7 @@
|
|||
},
|
||||
"us.anthropic.claude-opus-4-6-v1": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 6.875e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 1.1e-05,
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
|
|
@ -1121,6 +1124,7 @@
|
|||
},
|
||||
"eu.anthropic.claude-opus-4-6-v1": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 6.875e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 1.1e-05,
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
|
|
@ -1155,6 +1159,7 @@
|
|||
},
|
||||
"au.anthropic.claude-opus-4-6-v1": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 6.875e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 1.1e-05,
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
|
|
@ -2233,6 +2238,7 @@
|
|||
},
|
||||
"anthropic.claude-sonnet-4-6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 3.75e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 6e-06,
|
||||
"cache_read_input_token_cost": 3e-07,
|
||||
|
|
@ -2266,6 +2272,7 @@
|
|||
},
|
||||
"global.anthropic.claude-sonnet-4-6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 3.75e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 6e-06,
|
||||
"cache_read_input_token_cost": 3e-07,
|
||||
|
|
@ -2299,6 +2306,7 @@
|
|||
},
|
||||
"us.anthropic.claude-sonnet-4-6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 4.125e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 6.6e-06,
|
||||
"cache_read_input_token_cost": 3.3e-07,
|
||||
|
|
@ -2332,6 +2340,7 @@
|
|||
},
|
||||
"eu.anthropic.claude-sonnet-4-6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 4.125e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 6.6e-06,
|
||||
"cache_read_input_token_cost": 3.3e-07,
|
||||
|
|
@ -2365,6 +2374,7 @@
|
|||
},
|
||||
"au.anthropic.claude-sonnet-4-6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 4.125e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 6.6e-06,
|
||||
"cache_read_input_token_cost": 3.3e-07,
|
||||
|
|
@ -2398,6 +2408,7 @@
|
|||
},
|
||||
"jp.anthropic.claude-sonnet-4-6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 4.125e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 6.6e-06,
|
||||
"cache_read_input_token_cost": 3.3e-07,
|
||||
|
|
@ -2950,6 +2961,7 @@
|
|||
"azure_ai/claude-opus-4-6": {
|
||||
"deprecation_date": "2027-02-02",
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"input_cost_per_token": 5e-06,
|
||||
"output_cost_per_token": 2.5e-05,
|
||||
"litellm_provider": "azure_ai",
|
||||
|
|
@ -3181,6 +3193,7 @@
|
|||
"azure_ai/claude-sonnet-4-6": {
|
||||
"deprecation_date": "2027-02-10",
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 3.75e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 6e-06,
|
||||
"cache_read_input_token_cost": 3e-07,
|
||||
|
|
@ -12489,6 +12502,7 @@
|
|||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
|
|
@ -12698,6 +12712,7 @@
|
|||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
|
|
@ -12735,6 +12750,7 @@
|
|||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
|
|
@ -14724,6 +14740,7 @@
|
|||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
|
|
@ -14892,6 +14909,7 @@
|
|||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
|
|
@ -23427,6 +23445,7 @@
|
|||
},
|
||||
"github_copilot/claude-opus-4.6-fast": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"litellm_provider": "github_copilot",
|
||||
"max_input_tokens": 128000,
|
||||
"max_output_tokens": 16000,
|
||||
|
|
@ -33810,6 +33829,7 @@
|
|||
},
|
||||
"openrouter/anthropic/claude-sonnet-4.6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 3.75e-06,
|
||||
"cache_creation_input_token_cost_above_200k_tokens": 7.5e-06,
|
||||
"cache_read_input_token_cost": 3e-07,
|
||||
|
|
@ -33854,6 +33874,7 @@
|
|||
},
|
||||
"openrouter/anthropic/claude-opus-4.6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 6.25e-06,
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
"input_cost_per_token": 5e-06,
|
||||
|
|
@ -35928,6 +35949,7 @@
|
|||
},
|
||||
"perplexity/anthropic/claude-opus-4-6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"litellm_provider": "perplexity",
|
||||
"mode": "responses",
|
||||
"supports_web_search": true,
|
||||
|
|
@ -39303,6 +39325,7 @@
|
|||
},
|
||||
"vercel_ai_gateway/anthropic/claude-opus-4.6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 6.25e-06,
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
"input_cost_per_token": 5e-06,
|
||||
|
|
@ -40562,6 +40585,7 @@
|
|||
"deprecation_date": "2027-02-05",
|
||||
"regional_endpoint_uplift_multiplier": 1.1,
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 6.25e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 1e-05,
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
|
|
@ -40594,6 +40618,7 @@
|
|||
"deprecation_date": "2027-02-05",
|
||||
"regional_endpoint_uplift_multiplier": 1.1,
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 6.25e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 1e-05,
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
|
|
@ -40959,6 +40984,7 @@
|
|||
"vertex_ai/claude-sonnet-4-6": {
|
||||
"regional_endpoint_uplift_multiplier": 1.1,
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 3.75e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 6e-06,
|
||||
"cache_read_input_token_cost": 3e-07,
|
||||
|
|
@ -44042,10 +44068,10 @@
|
|||
"comment": "5 credits per second @ $0.01 per credit = $0.05 per second"
|
||||
}
|
||||
},
|
||||
"runwayml/gen4_aleph": {
|
||||
"runwayml/gen4.5": {
|
||||
"litellm_provider": "runwayml",
|
||||
"mode": "video_generation",
|
||||
"output_cost_per_video_per_second": 0.15,
|
||||
"output_cost_per_second": 0.12,
|
||||
"source": "https://docs.dev.runwayml.com/guides/pricing/",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
|
|
@ -44055,13 +44081,136 @@
|
|||
"video"
|
||||
],
|
||||
"metadata": {
|
||||
"comment": "15 credits per second @ $0.01 per credit = $0.15 per second"
|
||||
"comment": "12 credits per second @ $0.01 per credit = $0.12 per second"
|
||||
}
|
||||
},
|
||||
"runwayml/gen3a_turbo": {
|
||||
"runwayml/aleph2": {
|
||||
"litellm_provider": "runwayml",
|
||||
"mode": "video_generation",
|
||||
"output_cost_per_video_per_second": 0.05,
|
||||
"output_cost_per_second": 0.28,
|
||||
"source": "https://docs.dev.runwayml.com/guides/pricing/",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"video"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"video"
|
||||
],
|
||||
"metadata": {
|
||||
"comment": "28 credits per second @ $0.01 per credit = $0.28 per second; 56 credit minimum per task not modeled"
|
||||
}
|
||||
},
|
||||
"runwayml/seedance2": {
|
||||
"litellm_provider": "runwayml",
|
||||
"mode": "video_generation",
|
||||
"output_cost_per_second": 0.36,
|
||||
"output_cost_per_second_1080p": 0.4,
|
||||
"output_cost_per_second_4k": 1.5,
|
||||
"source": "https://docs.dev.runwayml.com/guides/pricing/",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"video"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"video"
|
||||
],
|
||||
"metadata": {
|
||||
"comment": "36 credits per second at 480p/720p, 40 at 1080p, 150 at 4K @ $0.01 per credit"
|
||||
}
|
||||
},
|
||||
"runwayml/seedance2_fast": {
|
||||
"litellm_provider": "runwayml",
|
||||
"mode": "video_generation",
|
||||
"output_cost_per_second": 0.29,
|
||||
"source": "https://docs.dev.runwayml.com/guides/pricing/",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"video"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"video"
|
||||
],
|
||||
"metadata": {
|
||||
"comment": "29 credits per second at 480p/720p @ $0.01 per credit = $0.29 per second"
|
||||
}
|
||||
},
|
||||
"runwayml/seedance2_mini": {
|
||||
"litellm_provider": "runwayml",
|
||||
"mode": "video_generation",
|
||||
"output_cost_per_second": 0.16,
|
||||
"source": "https://docs.dev.runwayml.com/guides/pricing/",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"video"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"video"
|
||||
],
|
||||
"metadata": {
|
||||
"comment": "16 credits per second @ $0.01 per credit = $0.16 per second; 64 credit minimum per task not modeled"
|
||||
}
|
||||
},
|
||||
"runwayml/seedance2_5": {
|
||||
"litellm_provider": "runwayml",
|
||||
"mode": "video_generation",
|
||||
"output_cost_per_second": 0.3,
|
||||
"output_cost_per_second_480p": 0.2,
|
||||
"output_cost_per_second_1080p": 0.68,
|
||||
"source": "https://docs.dev.runwayml.com/guides/pricing/",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"video"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"video"
|
||||
],
|
||||
"metadata": {
|
||||
"comment": "Output: 20/30/68 credits per second at 480p/720p/1080p @ $0.01 per credit; input video billed additionally at 10/15/34 credits per input second and the 80 credit minimum per task are not modeled"
|
||||
}
|
||||
},
|
||||
"runwayml/hailuo3": {
|
||||
"litellm_provider": "runwayml",
|
||||
"mode": "video_generation",
|
||||
"output_cost_per_second": 0.1,
|
||||
"output_cost_per_second_1080p": 0.15,
|
||||
"source": "https://docs.dev.runwayml.com/guides/pricing/",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"video"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"video"
|
||||
],
|
||||
"metadata": {
|
||||
"comment": "10 credits per second at 768P, 15 at 2K (mapped to the 1080p tier) @ $0.01 per credit; 2 credits per reference image not modeled"
|
||||
}
|
||||
},
|
||||
"runwayml/gemini_omni_flash": {
|
||||
"litellm_provider": "runwayml",
|
||||
"mode": "video_generation",
|
||||
"output_cost_per_second": 0.1,
|
||||
"source": "https://docs.dev.runwayml.com/guides/pricing/",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image",
|
||||
"video"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"video"
|
||||
],
|
||||
"metadata": {
|
||||
"comment": "10 credits per second @ $0.01 per credit = $0.10 per second"
|
||||
}
|
||||
},
|
||||
"runwayml/veo3.1": {
|
||||
"litellm_provider": "runwayml",
|
||||
"mode": "video_generation",
|
||||
"output_cost_per_second": 0.4,
|
||||
"source": "https://docs.dev.runwayml.com/guides/pricing/",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
|
|
@ -44071,7 +44220,23 @@
|
|||
"video"
|
||||
],
|
||||
"metadata": {
|
||||
"comment": "5 credits per second @ $0.01 per credit = $0.05 per second"
|
||||
"comment": "40 credits per second with audio, 20 without @ $0.01 per credit; priced at the with-audio rate"
|
||||
}
|
||||
},
|
||||
"runwayml/veo3.1_fast": {
|
||||
"litellm_provider": "runwayml",
|
||||
"mode": "video_generation",
|
||||
"output_cost_per_second": 0.15,
|
||||
"source": "https://docs.dev.runwayml.com/guides/pricing/",
|
||||
"supported_modalities": [
|
||||
"text",
|
||||
"image"
|
||||
],
|
||||
"supported_output_modalities": [
|
||||
"video"
|
||||
],
|
||||
"metadata": {
|
||||
"comment": "15 credits per second with audio, 10 without @ $0.01 per credit; priced at the with-audio rate"
|
||||
}
|
||||
},
|
||||
"runwayml/gen4_image": {
|
||||
|
|
@ -48728,6 +48893,7 @@
|
|||
"vertex_ai/claude-sonnet-4-6@default": {
|
||||
"regional_endpoint_uplift_multiplier": 1.1,
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 3.75e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 6e-06,
|
||||
"cache_read_input_token_cost": 3e-07,
|
||||
|
|
@ -49512,6 +49678,7 @@
|
|||
},
|
||||
"snowflake/claude-sonnet-4-6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"max_tokens": 16384,
|
||||
"max_input_tokens": 200000,
|
||||
"max_output_tokens": 16384,
|
||||
|
|
@ -50502,6 +50669,14 @@
|
|||
"supports_adaptive_thinking": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "claude-legacy-thinking",
|
||||
"pattern": "claude-[a-z]+-4[-._]6(?!\\d)",
|
||||
"description": "Claude at version 4.6 exactly, in any id shape that contains claude-<family>-4-6 (dotted and underscored minors included, dated releases such as claude-sonnet-4-6-20260219 too). The 4.6 family is adaptive-thinking yet still accepts legacy thinking.type=enabled with budget_tokens, so the caller's hard budget cap is forwarded verbatim instead of being rewritten to an uncapped output_config.effort. The lookahead keeps two-digit minors such as 4-60 from matching. 4.7+ and 5+ majors reject the legacy shape and stay on the adaptive translation.",
|
||||
"model_info": {
|
||||
"supports_legacy_thinking": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "claude-always-on-thinking",
|
||||
"pattern": "claude-(?:fable|mythos)-",
|
||||
|
|
|
|||
|
|
@ -428,6 +428,14 @@
|
|||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"output_cost_per_second_480p": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"output_cost_per_second_4k": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"output_cost_per_token": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
|
|
@ -625,6 +633,9 @@
|
|||
"supports_image_size": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"supports_legacy_thinking": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"supports_low_reasoning_effort": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@
|
|||
"limit": 3
|
||||
},
|
||||
"BLE001": {
|
||||
"limit": 2920
|
||||
"limit": 2919
|
||||
},
|
||||
"C401": {
|
||||
"limit": 8
|
||||
|
|
@ -108,7 +108,7 @@
|
|||
"limit": 3
|
||||
},
|
||||
"F401": {
|
||||
"limit": 17
|
||||
"limit": 14
|
||||
},
|
||||
"LOG015": {
|
||||
"limit": 5
|
||||
|
|
@ -152,9 +152,6 @@
|
|||
"PLW0127": {
|
||||
"limit": 57
|
||||
},
|
||||
"PLW0133": {
|
||||
"limit": 1
|
||||
},
|
||||
"PLW0602": {
|
||||
"limit": 215
|
||||
},
|
||||
|
|
|
|||
|
|
@ -40,6 +40,17 @@
|
|||
# later binding makes the name local for the whole body, so the read raises
|
||||
# UnboundLocalError, and in an autouse fixture that takes every test in the
|
||||
# directory down with it
|
||||
# F601 the same key literal twice in one dict. Python keeps the last value, so the
|
||||
# first is dropped before the test ever runs, and a fixture that looks like it
|
||||
# covers two cases covers one
|
||||
# B023 a closure over a loop variable. Every closure sees the last iteration's value,
|
||||
# so a per-case callback built in a loop checks the last case N times. Bind the
|
||||
# value as a parameter instead
|
||||
# B025 an `except` for a type an earlier `except` already catches. The second handler
|
||||
# is unreachable, so the recovery or skip written there never happens
|
||||
# F632 `is` against a literal. It compares identity, so it passes only where CPython
|
||||
# happens to intern the value and stops meaning what it says the moment the
|
||||
# value is built at runtime
|
||||
#
|
||||
# No target-version here on purpose: it resolves from requires-python (>=3.10), so
|
||||
# 3.11-only builtins like BaseExceptionGroup are correctly flagged in a tree that
|
||||
|
|
@ -63,4 +74,8 @@ lint.select = [
|
|||
"PLW0127",
|
||||
"RUF043",
|
||||
"F823",
|
||||
"F601",
|
||||
"B023",
|
||||
"B025",
|
||||
"F632",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ lint.ignore = ["F405", "E402", "F403"]
|
|||
lint.extend-select = [
|
||||
"T20", "PGH004", "RUF008", "RUF009", "RUF100",
|
||||
"B033", "FURB136", "FURB168", "FURB188", "I001", "PERF402", "PIE790", "PIE800", "PLC0208",
|
||||
"PLR0402", "PLR1711", "PLR1730", "PLR2044", "PYI030", "PYI041", "PYI064", "RET501", "RUF010",
|
||||
"RUF022", "RUF023", "RUF051", "SIM114", "SIM118", "TC005", "UP006", "UP007", "UP008", "UP012",
|
||||
"UP018", "UP024", "UP032", "UP034", "UP035", "UP037", "UP045",
|
||||
"PLR0402", "PLR1711", "PLR1730", "PLR2044", "PLW0133", "PYI030", "PYI041", "PYI064", "RET501",
|
||||
"RUF010", "RUF022", "RUF023", "RUF051", "SIM114", "SIM118", "TC005", "UP006", "UP007", "UP008",
|
||||
"UP012", "UP018", "UP024", "UP032", "UP034", "UP035", "UP037", "UP045",
|
||||
]
|
||||
# RUF100 (unused-noqa) only knows the rules enabled in THIS config, so it would strip
|
||||
# `# noqa` directives that protect rules enforced elsewhere. List those codes as external
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ def get_bedrock_pricing(url, providers):
|
|||
else:
|
||||
# General logic for other providers
|
||||
section = soup.find(
|
||||
"h2", text=lambda t: t and provider.lower() in t.lower()
|
||||
"h2", text=lambda t, needle=provider.lower(): t and needle in t.lower()
|
||||
)
|
||||
if not section:
|
||||
pricing_data[provider] = "Provider section not found"
|
||||
|
|
|
|||
|
|
@ -64,11 +64,6 @@ def test_langsmith_logging_async():
|
|||
except Exception as e:
|
||||
pytest.fail(f"An exception occurred - {e}")
|
||||
|
||||
except litellm.Timeout as e:
|
||||
pass
|
||||
except Exception as e:
|
||||
pytest.fail(f"An exception occurred - {e}")
|
||||
|
||||
|
||||
async def make_async_calls(metadata=None, **completion_kwargs):
|
||||
total_tasks = 300
|
||||
|
|
|
|||
|
|
@ -4202,13 +4202,7 @@ def test_gemini_google_maps_tool_simple():
|
|||
)
|
||||
print(f"Response: {response.model_dump_json(indent=4)}")
|
||||
assert response.choices[0].message.content is not None
|
||||
except (litellm.RateLimitError, litellm.InternalServerError):
|
||||
# Transient Vertex-side failures (rate limiting, 500 INTERNAL from the
|
||||
# Google Maps grounding backend) are not LiteLLM bugs — don't fail CI.
|
||||
pass
|
||||
except litellm.InternalServerError:
|
||||
pytest.skip(
|
||||
"Google Maps Platform returned a transient 500 (upstream flake); skipping."
|
||||
)
|
||||
except (litellm.RateLimitError, litellm.InternalServerError) as e:
|
||||
pytest.skip(f"Transient Vertex-side failure, not a LiteLLM bug: {e}")
|
||||
except Exception as e:
|
||||
pytest.fail(f"Error occurred: {e}")
|
||||
|
|
|
|||
|
|
@ -143,7 +143,6 @@ def test_spend_logs_payload(model_id: Optional[str]):
|
|||
"completion_start_time": datetime.datetime(2024, 6, 7, 12, 43, 30, 954146),
|
||||
"max_tokens": 10,
|
||||
"extra_body": {},
|
||||
"custom_llm_provider": "azure",
|
||||
"input": [
|
||||
{"role": "system", "content": "you are a helpful assistant.\n"},
|
||||
{"role": "user", "content": "bom dia"},
|
||||
|
|
|
|||
132
tests/proxy_migration_tests/test_ui_image_serves_offline.py
Normal file
132
tests/proxy_migration_tests/test_ui_image_serves_offline.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"""Image-level regression net for arbitrary-uid boot of the UI image.
|
||||
|
||||
OpenShift ``restricted-v2`` ignores the image ``USER`` and assigns an
|
||||
arbitrary uid in GID 0. The stock nginx base expects to start as root, so
|
||||
its cache (``/var/cache/nginx``) and pid (``/run``) paths are root-owned
|
||||
755 and the master process dies at startup with
|
||||
``mkdir() "/var/cache/nginx/client_temp" failed (13: Permission denied)``.
|
||||
The fix anchors everything nginx writes under ``/tmp`` in ``ui/nginx.conf``.
|
||||
|
||||
Booting the image the way that deployment does, with a read-only root
|
||||
filesystem and ``/tmp`` as the only writable mount, is what catches the
|
||||
whole class: a boot as the default (root) uid passes even on the broken
|
||||
config.
|
||||
|
||||
Gated on LITELLM_IMAGE so it is skipped in the normal unit-test run and
|
||||
exercised only where an image has been built (the image-scan workflow).
|
||||
Requires a working docker CLI.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
IMAGE = os.getenv("LITELLM_IMAGE")
|
||||
CURL_IMAGE = os.getenv("LITELLM_TEST_CURL_IMAGE", "curlimages/curl:8.11.1")
|
||||
UI_PORT = os.getenv("LITELLM_UI_PORT", "3000")
|
||||
ARBITRARY_UID = "1001200000:0"
|
||||
STARTUP_TIMEOUT_SECONDS = int(os.getenv("LITELLM_UI_STARTUP_TIMEOUT", "60"))
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.skipif(IMAGE is None, reason="requires a built image (set LITELLM_IMAGE)"),
|
||||
pytest.mark.skipif(shutil.which("docker") is None, reason="requires the docker CLI"),
|
||||
]
|
||||
|
||||
|
||||
def _docker(*args: str, check: bool = True) -> "subprocess.CompletedProcess[str]":
|
||||
return subprocess.run(["docker", *args], capture_output=True, text=True, check=check)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ui_container() -> Iterator[tuple[str, str]]:
|
||||
"""The UI container as an arbitrary uid in GID 0 on a network with no egress.
|
||||
|
||||
``--read-only`` with a tmpfs on ``/tmp`` mirrors the strictest supported
|
||||
deployment: ``readOnlyRootFilesystem: true`` with an emptyDir on ``/tmp``.
|
||||
A config that writes anywhere else fails here exactly like it does on
|
||||
OpenShift.
|
||||
"""
|
||||
run_id = f"uiserve-{uuid.uuid4().hex[:8]}"
|
||||
network = f"{run_id}-net"
|
||||
container = f"{run_id}-ui"
|
||||
|
||||
_docker("pull", "--quiet", CURL_IMAGE)
|
||||
_docker("network", "create", "--internal", network)
|
||||
try:
|
||||
assert IMAGE is not None
|
||||
_docker(
|
||||
"run", "-d", "--name", container, "--network", network,
|
||||
"--user", ARBITRARY_UID,
|
||||
"--read-only", "--tmpfs", "/tmp",
|
||||
IMAGE,
|
||||
)
|
||||
yield network, container
|
||||
finally:
|
||||
_docker("logs", container, check=False)
|
||||
_docker("rm", "-f", container, check=False)
|
||||
_docker("network", "rm", network, check=False)
|
||||
|
||||
|
||||
def _container_logs(container: str) -> str:
|
||||
logs = _docker("logs", container, check=False)
|
||||
return f"stdout:\n{logs.stdout}\nstderr:\n{logs.stderr}"
|
||||
|
||||
|
||||
def _is_running(container: str) -> bool:
|
||||
return bool(
|
||||
_docker(
|
||||
"ps", "--filter", f"name={container}", "--filter", "status=running",
|
||||
"--format", "{{.Names}}", check=False,
|
||||
).stdout.strip()
|
||||
)
|
||||
|
||||
|
||||
def _probe(network: str, container: str, path: str) -> "subprocess.CompletedProcess[str]":
|
||||
return _docker(
|
||||
"run", "--rm", "--network", network, CURL_IMAGE,
|
||||
"--silent", "--show-error", "--max-time", "10",
|
||||
"--output", "/dev/null", "--write-out", "%{http_code}",
|
||||
f"http://{container}:{UI_PORT}{path}",
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def test_ui_serves_as_arbitrary_uid_read_only(ui_container: tuple[str, str]) -> None:
|
||||
"""nginx boots and serves as an arbitrary uid with a read-only root fs.
|
||||
|
||||
On the pre-fix config nginx exits during startup with
|
||||
``mkdir() "/var/cache/nginx/client_temp" failed (13: Permission denied)``
|
||||
and the running-check below fails; it never reaches the probes.
|
||||
"""
|
||||
network, container = ui_container
|
||||
|
||||
deadline = time.time() + STARTUP_TIMEOUT_SECONDS
|
||||
healthz = None
|
||||
while time.time() < deadline:
|
||||
if not _is_running(container):
|
||||
pytest.fail(
|
||||
f"the UI container exited during startup as uid {ARBITRARY_UID} with a "
|
||||
f"read-only root filesystem. nginx writes outside /tmp.\n"
|
||||
f"{_container_logs(container)}"
|
||||
)
|
||||
healthz = _probe(network, container, "/healthz")
|
||||
if healthz.returncode == 0 and healthz.stdout.strip() == "200":
|
||||
break
|
||||
time.sleep(2)
|
||||
|
||||
assert healthz is not None and healthz.stdout.strip() == "200", (
|
||||
f"/healthz never answered 200 within {STARTUP_TIMEOUT_SECONDS}s as uid "
|
||||
f"{ARBITRARY_UID}.\n{_container_logs(container)}"
|
||||
)
|
||||
|
||||
for path in ("/", "/ui", "/ui/login"):
|
||||
page = _probe(network, container, path)
|
||||
assert page.stdout.strip() == "200", (
|
||||
f"GET {path} returned {page.stdout.strip()!r} as uid {ARBITRARY_UID}.\n"
|
||||
f"{_container_logs(container)}"
|
||||
)
|
||||
|
|
@ -3323,16 +3323,17 @@ async def test_team_access_groups(prisma_client):
|
|||
|
||||
request._url = URL(url="/chat/completions")
|
||||
|
||||
def body_reader(requested_model: str):
|
||||
async def return_body() -> bytes:
|
||||
return f'{{"model": "{requested_model}"}}'.encode()
|
||||
|
||||
return return_body
|
||||
|
||||
for model in ["gpt-4o", "gemini-pro-vision"]:
|
||||
# Expect these to pass
|
||||
async def return_body():
|
||||
return_string = f'{{"model": "{model}"}}'
|
||||
# return string as bytes
|
||||
return return_string.encode()
|
||||
|
||||
request = Request(scope={"type": "http"})
|
||||
request._url = URL(url="/chat/completions")
|
||||
request.body = return_body
|
||||
request.body = body_reader(model)
|
||||
|
||||
# use generated key to auth in
|
||||
print(
|
||||
|
|
@ -3342,14 +3343,9 @@ async def test_team_access_groups(prisma_client):
|
|||
|
||||
for model in ["gpt-4", "gpt-4o-mini", "gemini-experimental"]:
|
||||
# Expect these to fail
|
||||
async def return_body_2():
|
||||
return_string = f'{{"model": "{model}"}}'
|
||||
# return string as bytes
|
||||
return return_string.encode()
|
||||
|
||||
request = Request(scope={"type": "http"})
|
||||
request._url = URL(url="/chat/completions")
|
||||
request.body = return_body_2
|
||||
request.body = body_reader(model)
|
||||
|
||||
# use generated key to auth in
|
||||
print(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from litellm.a2a_protocol.card_resolver import (
|
|||
LiteLLMA2ACardResolver,
|
||||
fix_agent_card_url,
|
||||
is_localhost_or_internal_url,
|
||||
normalize_agent_card_interfaces,
|
||||
set_agent_card_url,
|
||||
)
|
||||
|
||||
|
|
@ -114,3 +115,26 @@ def test_fix_agent_card_url_updates_interface_when_top_level_is_localhost():
|
|||
|
||||
assert result.url == "https://my-public-agent.example.com/"
|
||||
assert result.supported_interfaces[0].url == "https://my-public-agent.example.com/"
|
||||
|
||||
|
||||
def test_normalize_agent_card_interfaces_downgrades_miscased_interfaces_to_the_0_3_dialect():
|
||||
pb2 = pytest.importorskip("a2a.types.a2a_pb2")
|
||||
|
||||
card = pb2.AgentCard(
|
||||
name="langgraph",
|
||||
supported_interfaces=[
|
||||
pb2.AgentInterface(url="http://a/", protocol_binding="jsonrpc", protocol_version="1.0"),
|
||||
pb2.AgentInterface(url="http://b/", protocol_binding="JSONRPC", protocol_version="1.0"),
|
||||
pb2.AgentInterface(url="http://c/", protocol_binding="websocket", protocol_version="1.0"),
|
||||
],
|
||||
)
|
||||
|
||||
normalized = normalize_agent_card_interfaces(card)
|
||||
|
||||
assert [(i.protocol_binding, i.protocol_version) for i in normalized.supported_interfaces] == [
|
||||
("JSONRPC", "0.3"),
|
||||
("JSONRPC", "1.0"),
|
||||
("websocket", "1.0"),
|
||||
]
|
||||
assert card.supported_interfaces[0].protocol_binding == "jsonrpc"
|
||||
assert card.supported_interfaces[0].protocol_version == "1.0"
|
||||
|
|
|
|||
|
|
@ -176,10 +176,62 @@ _AGENT_A_HEADERS = {"x-agent-token": "token-for-a", "x-tenant": "tenant-a"}
|
|||
_AGENT_B_HEADERS = {"x-agent-token": "token-for-b", "x-tenant": "tenant-b"}
|
||||
|
||||
|
||||
_LANGGRAPH_TASK_REPLY = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "reply",
|
||||
"result": {
|
||||
"kind": "task",
|
||||
"id": "run-1:task-1",
|
||||
"contextId": "thread-1",
|
||||
"history": [
|
||||
{
|
||||
"kind": "message",
|
||||
"role": "user",
|
||||
"parts": [{"kind": "text", "text": "hi"}],
|
||||
"messageId": "m-user",
|
||||
"taskId": "run-1:task-1",
|
||||
"contextId": "thread-1",
|
||||
},
|
||||
{
|
||||
"kind": "message",
|
||||
"role": "agent",
|
||||
"parts": [{"kind": "text", "text": "langgraph echo: hi"}],
|
||||
"messageId": "m-agent",
|
||||
"taskId": "run-1:task-1",
|
||||
"contextId": "thread-1",
|
||||
},
|
||||
],
|
||||
"status": {"state": "completed", "timestamp": "2026-08-24T00:00:00+00:00"},
|
||||
"artifacts": [
|
||||
{
|
||||
"artifactId": "art-1",
|
||||
"name": "Assistant Response",
|
||||
"parts": [{"kind": "text", "text": "langgraph echo: hi"}],
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
_LOWERCASE_BINDING_CARD = {
|
||||
"name": "langgraph-agent",
|
||||
"version": "1.0.0",
|
||||
"capabilities": {"streaming": True},
|
||||
"defaultInputModes": ["text/plain"],
|
||||
"defaultOutputModes": ["text/plain"],
|
||||
"skills": [],
|
||||
"supportedInterfaces": [
|
||||
{"url": "http://127.0.0.1:9/", "protocolBinding": "jsonrpc", "protocolVersion": "1.0"}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class _RequestRecorder:
|
||||
"""Records the headers httpx put on the wire, per outbound request."""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, card=_AGENT_CARD, rpc_reply=_RPC_REPLY):
|
||||
self.card = card
|
||||
self.rpc_reply = rpc_reply
|
||||
self.card_requests = []
|
||||
self.rpc_requests = []
|
||||
self.client = None
|
||||
|
|
@ -188,23 +240,23 @@ class _RequestRecorder:
|
|||
headers = {k.lower(): v for k, v in request.headers.items()}
|
||||
if request.method == "GET":
|
||||
self.card_requests.append(headers)
|
||||
return httpx.Response(200, json=_AGENT_CARD)
|
||||
return httpx.Response(200, json=self.card)
|
||||
self.rpc_requests.append(headers)
|
||||
return httpx.Response(200, json=_RPC_REPLY)
|
||||
return httpx.Response(200, json=self.rpc_reply)
|
||||
|
||||
|
||||
def _a2a_client_cache_key(timeout: float) -> str:
|
||||
return "async_httpx_client" + f"timeout_{timeout}" + httpxSpecialProvider.A2AProvider
|
||||
|
||||
|
||||
async def _seed_shared_a2a_client() -> _RequestRecorder:
|
||||
async def _seed_shared_a2a_client(card=_AGENT_CARD, rpc_reply=_RPC_REPLY) -> _RequestRecorder:
|
||||
"""Put the one A2A client the cache will hand out behind a mock transport.
|
||||
|
||||
Seeding has to happen on the test's own event loop, because the client cache keys on
|
||||
it. The injected client is a real httpx.AsyncClient, so the merge of per-request
|
||||
headers over client defaults, which is what these tests are about, stays real.
|
||||
"""
|
||||
recorder = _RequestRecorder()
|
||||
recorder = _RequestRecorder(card=card, rpc_reply=rpc_reply)
|
||||
handler = AsyncHTTPHandler(timeout=DEFAULT_A2A_AGENT_TIMEOUT)
|
||||
owned_client = handler.client
|
||||
handler.client = httpx.AsyncClient(transport=httpx.MockTransport(recorder))
|
||||
|
|
@ -311,6 +363,25 @@ async def test_streaming_send_carries_only_its_own_caller_headers(isolated_clien
|
|||
assert received["b"]["x-tenant"] == "tenant-b"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lowercase_protocol_binding_card_round_trips_the_langgraph_dialect(isolated_client_cache):
|
||||
"""LangGraph Platform serves cards with protocolBinding "jsonrpc" and answers in the
|
||||
A2A 0.3 JSON dialect ("kind"-discriminated) while declaring protocolVersion "1.0".
|
||||
Without binding normalization client creation raises ValueError("no compatible
|
||||
transports found."); without the version downgrade the SDK's strict v1 transport
|
||||
rejects the reply with 'Message type "lf.a2a.v1.Task" has no field named "kind"'."""
|
||||
await _seed_shared_a2a_client(card=_LOWERCASE_BINDING_CARD, rpc_reply=_LANGGRAPH_TASK_REPLY)
|
||||
|
||||
a2a_client = await create_a2a_client(base_url="http://127.0.0.1:9")
|
||||
response = await _send_message(a2a_client, _send_request("lc"))
|
||||
|
||||
assert type(response.root.result).__name__ == "Task"
|
||||
assert response.root.result.artifacts[0].parts[0].root.text == "langgraph echo: hi"
|
||||
interface = a2a_client._litellm_agent_card.supported_interfaces[0]
|
||||
assert interface.protocol_binding == "JSONRPC"
|
||||
assert interface.protocol_version == "0.3"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_card_fetch_carries_the_callers_headers(isolated_client_cache):
|
||||
"""Agent cards can sit behind the same auth as the agent, so the card fetch must stay
|
||||
|
|
|
|||
|
|
@ -3762,3 +3762,112 @@ def test_response_incomplete_stream_event_without_details_defaults_to_length():
|
|||
result = iterator.chunk_parser(chunk)
|
||||
|
||||
assert result.choices[0].finish_reason == "length"
|
||||
|
||||
|
||||
def test_assistant_message_with_tool_calls_keeps_its_content():
|
||||
"""Regression for https://github.com/BerriAI/litellm/issues/24985.
|
||||
|
||||
An assistant turn that both answered and called a tool used to lose its whole message:
|
||||
the branch handling tool_calls emitted the calls and dropped the text.
|
||||
"""
|
||||
handler = LiteLLMResponsesTransformationHandler()
|
||||
messages = [
|
||||
{"role": "user", "content": "What is the weather in Denver?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Let me look that up.",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "arguments": '{"city": "Denver"}'},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "88F"},
|
||||
]
|
||||
|
||||
input_items, _ = handler.convert_chat_completion_messages_to_responses_api(messages)
|
||||
|
||||
assistant_message = next(
|
||||
item for item in input_items if item.get("type") == "message" and item.get("role") == "assistant"
|
||||
)
|
||||
assert assistant_message["content"] == [{"type": "output_text", "text": "Let me look that up."}]
|
||||
assert [item.get("type") for item in input_items] == [
|
||||
"message",
|
||||
"message",
|
||||
"function_call",
|
||||
"function_call_output",
|
||||
]
|
||||
|
||||
|
||||
def test_assistant_thinking_blocks_become_a_reasoning_input_item():
|
||||
"""Thinking blocks are how an Anthropic-shaped turn carries reasoning into this bridge."""
|
||||
handler = LiteLLMResponsesTransformationHandler()
|
||||
messages = [
|
||||
{"role": "user", "content": "What is the weather in Denver?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Denver is sunny.",
|
||||
"thinking_blocks": [
|
||||
{"type": "thinking", "thinking": "August in Denver is dry.", "signature": "sig1"},
|
||||
{"type": "redacted_thinking", "data": "REDACTED"},
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "Why?"},
|
||||
]
|
||||
|
||||
input_items, _ = handler.convert_chat_completion_messages_to_responses_api(messages)
|
||||
|
||||
reasoning_item = next(item for item in input_items if item.get("type") == "reasoning")
|
||||
assert reasoning_item["summary"] == [{"type": "summary_text", "text": "August in Denver is dry."}]
|
||||
assert "id" not in reasoning_item
|
||||
|
||||
|
||||
def test_thinking_only_assistant_turn_still_sends_its_reasoning():
|
||||
"""An assistant turn can be pure reasoning, with no visible text and no tool call."""
|
||||
handler = LiteLLMResponsesTransformationHandler()
|
||||
messages = [
|
||||
{"role": "user", "content": "What is the weather in Denver?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"thinking_blocks": [
|
||||
{"type": "thinking", "thinking": "August in Denver is dry.", "signature": "sig1"}
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "Why?"},
|
||||
]
|
||||
|
||||
input_items, _ = handler.convert_chat_completion_messages_to_responses_api(messages)
|
||||
|
||||
reasoning_items = [item for item in input_items if item.get("type") == "reasoning"]
|
||||
assert len(reasoning_items) == 1
|
||||
assert reasoning_items[0]["summary"] == [{"type": "summary_text", "text": "August in Denver is dry."}]
|
||||
|
||||
|
||||
def test_stored_reasoning_items_win_over_thinking_blocks():
|
||||
"""A minted reasoning id beats a re-derived one, so the two must not both be sent."""
|
||||
handler = LiteLLMResponsesTransformationHandler()
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Denver is sunny.",
|
||||
"reasoning_items": [
|
||||
{
|
||||
"type": "reasoning",
|
||||
"id": "rs_real",
|
||||
"summary": [{"type": "summary_text", "text": "August in Denver is dry."}],
|
||||
}
|
||||
],
|
||||
"thinking_blocks": [
|
||||
{"type": "thinking", "thinking": "August in Denver is dry.", "signature": "rs_real"}
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
input_items, _ = handler.convert_chat_completion_messages_to_responses_api(messages)
|
||||
|
||||
reasoning_items = [item for item in input_items if item.get("type") == "reasoning"]
|
||||
assert len(reasoning_items) == 1
|
||||
assert reasoning_items[0]["id"] == "rs_real"
|
||||
|
|
|
|||
147
tests/test_litellm/images/test_image_edit_extra_params.py
Normal file
147
tests/test_litellm/images/test_image_edit_extra_params.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"""
|
||||
Regression tests for https://github.com/BerriAI/litellm/issues/36493
|
||||
|
||||
/v1/images/edits on the openai path silently dropped unknown provider params
|
||||
(e.g. seed) and the extra_body escape hatch, unlike /v1/images/generations.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
|
||||
PNG_BYTES = b"\x89PNG\r\n\x1a\nfakepng"
|
||||
|
||||
|
||||
def _capture_image_edit_request(captured):
|
||||
def respond(request):
|
||||
captured["content_type"] = request.headers.get("content-type")
|
||||
captured["body"] = request.content
|
||||
return httpx.Response(200, json={"created": 1712697600, "data": [{"b64_json": "aW1n"}]})
|
||||
|
||||
return respond
|
||||
|
||||
|
||||
def _multipart_text_fields(content_type: str, body: bytes) -> dict:
|
||||
boundary = content_type.split("boundary=")[1].encode()
|
||||
return {
|
||||
part.split(b'name="')[1].split(b'"')[0].decode(): part.partition(b"\r\n\r\n")[2].rstrip(b"\r\n-").decode()
|
||||
for part in body.split(b"--" + boundary)
|
||||
if b'name="' in part and b"filename=" not in part
|
||||
}
|
||||
|
||||
|
||||
def test_image_edit_forwards_provider_params_and_extra_body():
|
||||
captured = {}
|
||||
client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_image_edit_request(captured))))
|
||||
|
||||
response = litellm.image_edit(
|
||||
model="openai/gpt-image-1",
|
||||
image=PNG_BYTES,
|
||||
prompt="add a hat",
|
||||
api_key="sk-test",
|
||||
api_base="https://edit.example/v1",
|
||||
client=client,
|
||||
seed=42,
|
||||
extra_body={"quality_level": "high"},
|
||||
)
|
||||
|
||||
assert captured["content_type"].startswith("multipart/form-data")
|
||||
fields = _multipart_text_fields(captured["content_type"], captured["body"])
|
||||
assert fields["seed"] == "42"
|
||||
assert fields["quality_level"] == "high"
|
||||
assert "extra_body" not in fields
|
||||
assert fields["model"] == "gpt-image-1"
|
||||
assert fields["prompt"] == "add a hat"
|
||||
assert b'name="image[]"' in captured["body"]
|
||||
assert response.data
|
||||
|
||||
|
||||
def test_image_edit_extra_body_takes_precedence_over_kwargs():
|
||||
captured = {}
|
||||
client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_image_edit_request(captured))))
|
||||
|
||||
litellm.image_edit(
|
||||
model="openai/gpt-image-1",
|
||||
image=PNG_BYTES,
|
||||
prompt="add a hat",
|
||||
api_key="sk-test",
|
||||
api_base="https://edit.example/v1",
|
||||
client=client,
|
||||
seed=42,
|
||||
extra_body={"seed": 7},
|
||||
)
|
||||
|
||||
assert _multipart_text_fields(captured["content_type"], captured["body"])["seed"] == "7"
|
||||
|
||||
|
||||
def test_image_edit_flattens_nested_provider_params():
|
||||
"""A nested value in extra_body (or a nested unknown kwarg) must be
|
||||
serialized as OpenAI-SDK bracket form fields (key[subkey]) rather than
|
||||
handed to the httpx multipart encoder, which raises 'Invalid type for
|
||||
value. Expected primitive type' on a dict and 500s the request."""
|
||||
captured = {}
|
||||
client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_image_edit_request(captured))))
|
||||
|
||||
litellm.image_edit(
|
||||
model="openai/gpt-image-1",
|
||||
image=PNG_BYTES,
|
||||
prompt="add a hat",
|
||||
api_key="sk-test",
|
||||
api_base="https://edit.example/v1",
|
||||
client=client,
|
||||
extra_body={"generation_config": {"steps": 30, "guidance": True}},
|
||||
)
|
||||
|
||||
fields = _multipart_text_fields(captured["content_type"], captured["body"])
|
||||
assert fields["generation_config[steps]"] == "30"
|
||||
assert fields["generation_config[guidance]"] == "true"
|
||||
assert "generation_config" not in fields
|
||||
|
||||
|
||||
def test_image_edit_forwards_scalar_array_as_repeated_fields():
|
||||
"""A list-valued provider param must reach the backend as one repeated part
|
||||
per element, not collapse to its last element under dict.update."""
|
||||
captured = {}
|
||||
client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_image_edit_request(captured))))
|
||||
|
||||
litellm.image_edit(
|
||||
model="openai/gpt-image-1",
|
||||
image=PNG_BYTES,
|
||||
prompt="add a hat",
|
||||
api_key="sk-test",
|
||||
api_base="https://edit.example/v1",
|
||||
client=client,
|
||||
loras=["style_a", "style_b", "style_c"],
|
||||
)
|
||||
|
||||
body = captured["body"]
|
||||
assert body.count(b'name="loras"') == 3
|
||||
assert b"style_a" in body and b"style_b" in body and b"style_c" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aimage_edit_forwards_extra_body():
|
||||
"""aimage_edit used to drop extra_headers/extra_query/extra_body when
|
||||
building its partial, so they never reached image_edit."""
|
||||
captured = {}
|
||||
client = AsyncHTTPHandler()
|
||||
client.client = httpx.AsyncClient(transport=httpx.MockTransport(_capture_image_edit_request(captured)))
|
||||
|
||||
response = await litellm.aimage_edit(
|
||||
model="openai/gpt-image-1",
|
||||
image=PNG_BYTES,
|
||||
prompt="add a hat",
|
||||
api_key="sk-test",
|
||||
api_base="https://edit.example/v1",
|
||||
client=client,
|
||||
seed=42,
|
||||
extra_body={"quality_level": "high"},
|
||||
)
|
||||
|
||||
fields = _multipart_text_fields(captured["content_type"], captured["body"])
|
||||
assert fields["seed"] == "42"
|
||||
assert fields["quality_level"] == "high"
|
||||
assert "extra_body" not in fields
|
||||
assert response.data
|
||||
545
tests/test_litellm/interactions/test_background_cost_polling.py
Normal file
545
tests/test_litellm/interactions/test_background_cost_polling.py
Normal file
|
|
@ -0,0 +1,545 @@
|
|||
import asyncio
|
||||
import time
|
||||
from itertools import islice
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.interactions.background_cost_polling import (
|
||||
_SETTLED_KEY,
|
||||
_poll_intervals,
|
||||
BackgroundInteractionPollContext,
|
||||
maybe_schedule_background_interaction_cost_polling,
|
||||
maybe_settle_background_interaction_before_delete,
|
||||
poll_and_log_background_interaction_cost,
|
||||
)
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LitellmLogging
|
||||
from litellm.types.interactions import InteractionsAPIResponse
|
||||
|
||||
USAGE_BLOCK = {
|
||||
"total_tokens": 175,
|
||||
"total_input_tokens": 100,
|
||||
"input_tokens_by_modality": [{"modality": "text", "tokens": 100}],
|
||||
"total_cached_tokens": 0,
|
||||
"total_output_tokens": 50,
|
||||
"output_tokens_by_modality": [{"modality": "text", "tokens": 50}],
|
||||
"total_tool_use_tokens": 0,
|
||||
"total_thought_tokens": 25,
|
||||
}
|
||||
|
||||
|
||||
def _logging_obj(
|
||||
call_type: str = "acreate_interaction",
|
||||
litellm_params: Optional[dict] = None,
|
||||
) -> LitellmLogging:
|
||||
logging_obj = LitellmLogging(
|
||||
model="gemini-2.5-flash",
|
||||
messages=[],
|
||||
stream=False,
|
||||
call_type=call_type,
|
||||
start_time=time.time(),
|
||||
litellm_call_id="bg-interactions-call-id",
|
||||
function_id="bg-interactions-fn-id",
|
||||
)
|
||||
logging_obj.update_environment_variables(
|
||||
litellm_params=litellm_params or {},
|
||||
optional_params={},
|
||||
model="gemini-2.5-flash",
|
||||
custom_llm_provider="gemini",
|
||||
input="hi",
|
||||
)
|
||||
return logging_obj
|
||||
|
||||
|
||||
def _reservation() -> dict:
|
||||
return {"reserved_cost": 0.05, "entries": [], "finalized": False, "input_cost": 0.001}
|
||||
|
||||
|
||||
def _logging_obj_with_reservation(reservation: dict) -> LitellmLogging:
|
||||
return _logging_obj(litellm_params={"metadata": {"user_api_key_budget_reservation": reservation}})
|
||||
|
||||
|
||||
async def _raise_on_billing(result: InteractionsAPIResponse) -> None:
|
||||
raise RuntimeError("cost calculation failed for a settled background interaction")
|
||||
|
||||
|
||||
def _context(logging_obj: LitellmLogging, timeout_seconds: float = 1.0) -> BackgroundInteractionPollContext:
|
||||
return BackgroundInteractionPollContext(
|
||||
interaction_id="interactions/bg-abc",
|
||||
custom_llm_provider="gemini",
|
||||
logging_obj=logging_obj,
|
||||
initial_interval_seconds=0.001,
|
||||
max_interval_seconds=0.002,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
|
||||
|
||||
def _response(status: str, with_usage: bool) -> InteractionsAPIResponse:
|
||||
return InteractionsAPIResponse(
|
||||
id="interactions/bg-abc",
|
||||
model="gemini-2.5-flash",
|
||||
status=status,
|
||||
steps=[],
|
||||
usage=dict(USAGE_BLOCK) if with_usage else None,
|
||||
)
|
||||
|
||||
|
||||
def _fetch_sequence(*responses):
|
||||
remaining = list(responses)
|
||||
calls = []
|
||||
|
||||
async def fetch(context):
|
||||
calls.append(context.interaction_id)
|
||||
item = remaining.pop(0) if len(remaining) > 1 else remaining[0]
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
return item
|
||||
|
||||
return fetch, calls
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"initial, maximum",
|
||||
[(0.0, 0.002), (0.001, 0.0), (-1.0, 0.002), (0.0, 0.0)],
|
||||
)
|
||||
def test_poll_intervals_stops_instead_of_looping_on_a_non_positive_interval(initial, maximum):
|
||||
intervals = list(islice(_poll_intervals(initial=initial, maximum=maximum, timeout=3600.0), 10))
|
||||
|
||||
assert len(intervals) < 10
|
||||
assert all(interval > 0 for interval in intervals)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poller_bills_once_when_interaction_completes():
|
||||
logging_obj = _logging_obj()
|
||||
fetch, calls = _fetch_sequence(
|
||||
_response("in_progress", with_usage=False),
|
||||
_response("completed", with_usage=True),
|
||||
)
|
||||
|
||||
await poll_and_log_background_interaction_cost(_context(logging_obj), fetch_interaction=fetch)
|
||||
|
||||
assert len(calls) == 2
|
||||
assert logging_obj.model_call_details["response_cost"] > 0
|
||||
assert logging_obj.model_call_details["standard_logging_object"]["total_tokens"] == 175
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poller_bills_an_interaction_paused_for_a_tool_result():
|
||||
logging_obj = _logging_obj()
|
||||
fetch, calls = _fetch_sequence(
|
||||
_response("in_progress", with_usage=False),
|
||||
_response("requires_action", with_usage=True),
|
||||
)
|
||||
|
||||
await poll_and_log_background_interaction_cost(_context(logging_obj), fetch_interaction=fetch)
|
||||
|
||||
assert len(calls) == 2
|
||||
assert logging_obj.model_call_details["response_cost"] > 0
|
||||
assert logging_obj.model_call_details["standard_logging_object"]["total_tokens"] == 175
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poller_does_not_pin_the_budget_for_an_interaction_paused_for_a_tool_result():
|
||||
reservation = _reservation()
|
||||
logging_obj = _logging_obj_with_reservation(reservation)
|
||||
fetch, _ = _fetch_sequence(_response("requires_action", with_usage=True))
|
||||
|
||||
await poll_and_log_background_interaction_cost(_context(logging_obj), fetch_interaction=fetch)
|
||||
|
||||
assert logging_obj.model_call_details["response_cost"] > 0
|
||||
assert reservation["finalized"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poller_stops_without_billing_on_terminal_status_without_usage():
|
||||
logging_obj = _logging_obj()
|
||||
fetch, calls = _fetch_sequence(_response("failed", with_usage=False))
|
||||
|
||||
await poll_and_log_background_interaction_cost(_context(logging_obj), fetch_interaction=fetch)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert logging_obj.model_call_details.get("response_cost") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poller_gives_up_after_timeout_without_billing():
|
||||
logging_obj = _logging_obj()
|
||||
fetch, calls = _fetch_sequence(_response("in_progress", with_usage=False))
|
||||
|
||||
await poll_and_log_background_interaction_cost(
|
||||
_context(logging_obj, timeout_seconds=0.01),
|
||||
fetch_interaction=fetch,
|
||||
)
|
||||
|
||||
assert len(calls) >= 2
|
||||
assert logging_obj.model_call_details.get("response_cost") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poller_releases_budget_reservation_when_interaction_ends_without_usage():
|
||||
reservation = _reservation()
|
||||
logging_obj = _logging_obj_with_reservation(reservation)
|
||||
fetch, _ = _fetch_sequence(_response("failed", with_usage=False))
|
||||
|
||||
await poll_and_log_background_interaction_cost(_context(logging_obj), fetch_interaction=fetch)
|
||||
|
||||
assert reservation["finalized"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poller_releases_budget_reservation_on_timeout_give_up():
|
||||
reservation = _reservation()
|
||||
logging_obj = _logging_obj_with_reservation(reservation)
|
||||
fetch, _ = _fetch_sequence(_response("in_progress", with_usage=False))
|
||||
|
||||
await poll_and_log_background_interaction_cost(
|
||||
_context(logging_obj, timeout_seconds=0.01),
|
||||
fetch_interaction=fetch,
|
||||
)
|
||||
|
||||
assert reservation["finalized"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poller_releases_budget_reservation_when_billing_raises():
|
||||
reservation = _reservation()
|
||||
logging_obj = _logging_obj_with_reservation(reservation)
|
||||
fetch, _ = _fetch_sequence(_response("completed", with_usage=True))
|
||||
logging_obj.async_log_background_interaction_completion = _raise_on_billing
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await poll_and_log_background_interaction_cost(_context(logging_obj), fetch_interaction=fetch)
|
||||
|
||||
assert reservation["finalized"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poller_leaves_reservation_reconciliation_to_the_completion_event():
|
||||
reservation = _reservation()
|
||||
logging_obj = _logging_obj_with_reservation(reservation)
|
||||
fetch, _ = _fetch_sequence(
|
||||
_response("in_progress", with_usage=False),
|
||||
_response("completed", with_usage=True),
|
||||
)
|
||||
|
||||
await poll_and_log_background_interaction_cost(_context(logging_obj), fetch_interaction=fetch)
|
||||
|
||||
assert logging_obj.model_call_details["response_cost"] > 0
|
||||
assert reservation["finalized"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poller_retries_after_fetch_error_and_still_bills():
|
||||
logging_obj = _logging_obj()
|
||||
fetch, calls = _fetch_sequence(
|
||||
RuntimeError("transient network error"),
|
||||
_response("completed", with_usage=True),
|
||||
)
|
||||
|
||||
await poll_and_log_background_interaction_cost(_context(logging_obj), fetch_interaction=fetch)
|
||||
|
||||
assert len(calls) == 2
|
||||
assert logging_obj.model_call_details["response_cost"] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_creates_poll_task_for_in_progress_create():
|
||||
logging_obj = _logging_obj()
|
||||
task = maybe_schedule_background_interaction_cost_polling(
|
||||
response=_response("in_progress", with_usage=False),
|
||||
create_kwargs={"litellm_logging_obj": logging_obj},
|
||||
custom_llm_provider="gemini",
|
||||
)
|
||||
|
||||
assert isinstance(task, asyncio.Task)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"response,create_kwargs",
|
||||
[
|
||||
(_response("completed", with_usage=True), {"litellm_logging_obj": "placeholder"}),
|
||||
(_response("in_progress", with_usage=False), {}),
|
||||
("not a response", {"litellm_logging_obj": "placeholder"}),
|
||||
],
|
||||
)
|
||||
async def test_schedule_skips_non_pollable_results(response, create_kwargs):
|
||||
if create_kwargs.get("litellm_logging_obj") == "placeholder":
|
||||
create_kwargs = {"litellm_logging_obj": _logging_obj()}
|
||||
|
||||
task = maybe_schedule_background_interaction_cost_polling(
|
||||
response=response,
|
||||
create_kwargs=create_kwargs,
|
||||
custom_llm_provider="gemini",
|
||||
)
|
||||
|
||||
assert task is None
|
||||
|
||||
|
||||
def _register_poll(logging_obj: LitellmLogging, poll_fetch=None) -> asyncio.Task:
|
||||
import litellm.interactions.background_cost_polling as bg
|
||||
|
||||
if poll_fetch is None:
|
||||
poll_fetch, _ = _fetch_sequence(_response("in_progress", with_usage=False))
|
||||
context = _context(logging_obj)
|
||||
task = asyncio.create_task(poll_and_log_background_interaction_cost(context, fetch_interaction=poll_fetch))
|
||||
bg._ACTIVE_POLLS[context.interaction_id] = bg._ActiveBackgroundPoll(task=task, context=context)
|
||||
task.add_done_callback(lambda finished: bg._discard_poll(context.interaction_id, finished))
|
||||
return task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_settlement_bills_an_interaction_paused_for_a_tool_result():
|
||||
logging_obj = _logging_obj()
|
||||
task = _register_poll(logging_obj)
|
||||
fetch, calls = _fetch_sequence(_response("requires_action", with_usage=True))
|
||||
|
||||
await maybe_settle_background_interaction_before_delete(
|
||||
interaction_id="interactions/bg-abc",
|
||||
fetch_interaction=fetch,
|
||||
)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert logging_obj.model_call_details["response_cost"] > 0
|
||||
await asyncio.wait_for(task, timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_settlement_bills_pending_background_interaction():
|
||||
logging_obj = _logging_obj()
|
||||
task = _register_poll(logging_obj)
|
||||
fetch, calls = _fetch_sequence(_response("completed", with_usage=True))
|
||||
|
||||
await maybe_settle_background_interaction_before_delete(
|
||||
interaction_id="interactions/bg-abc",
|
||||
fetch_interaction=fetch,
|
||||
)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert logging_obj.model_call_details["response_cost"] > 0
|
||||
assert logging_obj.model_call_details["standard_logging_object"]["total_tokens"] == 175
|
||||
await asyncio.wait_for(task, timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_settlement_releases_reservation_when_still_in_progress():
|
||||
reservation = _reservation()
|
||||
logging_obj = _logging_obj_with_reservation(reservation)
|
||||
task = _register_poll(logging_obj)
|
||||
fetch, _ = _fetch_sequence(_response("in_progress", with_usage=False))
|
||||
|
||||
await maybe_settle_background_interaction_before_delete(
|
||||
interaction_id="interactions/bg-abc",
|
||||
fetch_interaction=fetch,
|
||||
)
|
||||
|
||||
assert reservation["finalized"] is True
|
||||
assert logging_obj.model_call_details.get("response_cost") is None
|
||||
await asyncio.wait_for(task, timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_settlement_releases_reservation_when_prefetch_fails():
|
||||
reservation = _reservation()
|
||||
logging_obj = _logging_obj_with_reservation(reservation)
|
||||
task = _register_poll(logging_obj)
|
||||
fetch, _ = _fetch_sequence(RuntimeError("interaction already deleted"))
|
||||
|
||||
await maybe_settle_background_interaction_before_delete(
|
||||
interaction_id="interactions/bg-abc",
|
||||
fetch_interaction=fetch,
|
||||
)
|
||||
|
||||
assert reservation["finalized"] is True
|
||||
assert logging_obj.model_call_details.get("response_cost") is None
|
||||
await asyncio.wait_for(task, timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_settlement_releases_reservation_when_billing_raises():
|
||||
reservation = _reservation()
|
||||
logging_obj = _logging_obj_with_reservation(reservation)
|
||||
task = _register_poll(logging_obj)
|
||||
fetch, _ = _fetch_sequence(_response("completed", with_usage=True))
|
||||
logging_obj.async_log_background_interaction_completion = _raise_on_billing
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await maybe_settle_background_interaction_before_delete(
|
||||
interaction_id="interactions/bg-abc",
|
||||
fetch_interaction=fetch,
|
||||
)
|
||||
|
||||
assert reservation["finalized"] is True
|
||||
await asyncio.wait_for(task, timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_settlement_ignores_interactions_without_pending_poll():
|
||||
fetch, calls = _fetch_sequence(_response("completed", with_usage=True))
|
||||
|
||||
await maybe_settle_background_interaction_before_delete(
|
||||
interaction_id="interactions/never-polled",
|
||||
fetch_interaction=fetch,
|
||||
)
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_settlement_noop_after_poll_task_finished():
|
||||
logging_obj = _logging_obj()
|
||||
poll_fetch, _ = _fetch_sequence(_response("completed", with_usage=True))
|
||||
task = _register_poll(logging_obj, poll_fetch=poll_fetch)
|
||||
await asyncio.wait_for(task, timeout=5)
|
||||
assert logging_obj.model_call_details["response_cost"] > 0
|
||||
|
||||
settle_fetch, settle_calls = _fetch_sequence(_response("completed", with_usage=True))
|
||||
await maybe_settle_background_interaction_before_delete(
|
||||
interaction_id="interactions/bg-abc",
|
||||
fetch_interaction=settle_fetch,
|
||||
)
|
||||
|
||||
assert settle_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_settlement_does_not_rebill_when_gate_already_claimed():
|
||||
logging_obj = _logging_obj()
|
||||
logging_obj.model_call_details[_SETTLED_KEY] = True
|
||||
task = _register_poll(logging_obj)
|
||||
fetch, calls = _fetch_sequence(_response("completed", with_usage=True))
|
||||
|
||||
await maybe_settle_background_interaction_before_delete(
|
||||
interaction_id="interactions/bg-abc",
|
||||
fetch_interaction=fetch,
|
||||
)
|
||||
|
||||
assert len(calls) == 1
|
||||
assert logging_obj.model_call_details.get("response_cost") is None
|
||||
await asyncio.wait_for(task, timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poller_exits_without_billing_once_settled_elsewhere():
|
||||
logging_obj = _logging_obj()
|
||||
logging_obj.model_call_details[_SETTLED_KEY] = True
|
||||
fetch, calls = _fetch_sequence(_response("completed", with_usage=True))
|
||||
|
||||
await poll_and_log_background_interaction_cost(_context(logging_obj), fetch_interaction=fetch)
|
||||
|
||||
assert calls == []
|
||||
assert logging_obj.model_call_details.get("response_cost") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_respects_kill_switch(monkeypatch):
|
||||
import litellm.interactions.background_cost_polling as module
|
||||
|
||||
monkeypatch.setattr(module, "BACKGROUND_INTERACTION_COST_POLLING_ENABLED", False)
|
||||
|
||||
task = maybe_schedule_background_interaction_cost_polling(
|
||||
response=_response("in_progress", with_usage=False),
|
||||
create_kwargs={"litellm_logging_obj": _logging_obj()},
|
||||
custom_llm_provider="gemini",
|
||||
)
|
||||
|
||||
assert task is None
|
||||
|
||||
|
||||
def test_every_status_the_api_can_return_is_either_pollable_or_terminal():
|
||||
"""
|
||||
The proxy bills a usage-less create in exactly two ways: it polls the
|
||||
interaction until it settles, or it recognises the status as terminal and
|
||||
settles immediately. A status in neither set is billed by nobody, alerts
|
||||
nobody, and releases its budget reservation, which is the zero-spend bug
|
||||
this whole module exists to fix.
|
||||
|
||||
Pinned against the generated spec enum rather than a hand-written list, so
|
||||
a status Google adds later breaks this test instead of silently shipping
|
||||
another unbilled path.
|
||||
"""
|
||||
from litellm.interactions.background_cost_polling import _POLLABLE_STATUSES, _TERMINAL_STATUSES
|
||||
from litellm.types.interactions.generated import Status1
|
||||
|
||||
spec_statuses = {member.value for member in Status1}
|
||||
handled = _POLLABLE_STATUSES | _TERMINAL_STATUSES
|
||||
|
||||
assert spec_statuses - handled == set()
|
||||
assert handled - spec_statuses == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_creates_poll_task_for_queued_create():
|
||||
"""
|
||||
``queued`` is the API's not-started-yet state. It carries no usage, so the
|
||||
create cannot bill it, and it is not terminal, so nothing settles it:
|
||||
without a poll task it is never charged at all.
|
||||
"""
|
||||
logging_obj = _logging_obj()
|
||||
task = maybe_schedule_background_interaction_cost_polling(
|
||||
response=_response("queued", with_usage=False),
|
||||
create_kwargs={"litellm_logging_obj": logging_obj},
|
||||
custom_llm_provider="gemini",
|
||||
)
|
||||
|
||||
assert isinstance(task, asyncio.Task)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poller_bills_an_interaction_that_started_out_queued():
|
||||
logging_obj = _logging_obj()
|
||||
fetch, calls = _fetch_sequence(
|
||||
_response("queued", with_usage=False),
|
||||
_response("in_progress", with_usage=False),
|
||||
_response("completed", with_usage=True),
|
||||
)
|
||||
|
||||
await poll_and_log_background_interaction_cost(_context(logging_obj), fetch_interaction=fetch)
|
||||
|
||||
assert len(calls) == 3
|
||||
assert logging_obj.model_call_details["response_cost"] > 0
|
||||
assert logging_obj.model_call_details["standard_logging_object"]["total_tokens"] == 175
|
||||
|
||||
|
||||
def test_poll_intervals_double_up_to_the_cap_and_stay_inside_the_timeout():
|
||||
"""
|
||||
The degenerate cases are covered above; this pins the shape the proxy
|
||||
actually ships, so an off-by-one in the doubling or in the remaining-budget
|
||||
check cannot pass green.
|
||||
"""
|
||||
intervals = list(_poll_intervals(initial=5.0, maximum=60.0, timeout=3600.0))
|
||||
|
||||
assert intervals[:6] == [5.0, 10.0, 20.0, 40.0, 60.0, 60.0]
|
||||
assert max(intervals) == 60.0
|
||||
assert sum(intervals) <= 3600.0
|
||||
assert sum(intervals) + 60.0 > 3600.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_giving_up_on_an_unrecognized_status_says_which_status_it_was(monkeypatch):
|
||||
"""
|
||||
A status outside both sets polls for the full timeout and then gives up.
|
||||
The give-up line is the only trace it leaves, so it has to name the status
|
||||
rather than reporting it as an interaction that was merely still running.
|
||||
"""
|
||||
import litellm.interactions.background_cost_polling as bg
|
||||
|
||||
errors = []
|
||||
monkeypatch.setattr(bg.verbose_logger, "error", lambda *args, **kwargs: errors.append(args))
|
||||
|
||||
logging_obj = _logging_obj()
|
||||
fetch, _ = _fetch_sequence(_response("halted_for_review", with_usage=False))
|
||||
|
||||
await poll_and_log_background_interaction_cost(
|
||||
_context(logging_obj, timeout_seconds=0.01), fetch_interaction=fetch
|
||||
)
|
||||
|
||||
assert len(errors) == 1
|
||||
assert "halted_for_review" in errors[0]
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
from litellm.litellm_core_utils.llm_cost_calc.usage_object_transformation import (
|
||||
InteractionsUsageObjectTransformation,
|
||||
)
|
||||
from litellm.types.utils import Usage
|
||||
|
||||
OMNI_VIDEO_USAGE = {
|
||||
"total_tokens": 18247,
|
||||
"total_input_tokens": 16,
|
||||
"input_tokens_by_modality": [{"modality": "text", "tokens": 16}],
|
||||
"total_cached_tokens": 0,
|
||||
"total_output_tokens": 17937,
|
||||
"output_tokens_by_modality": [{"modality": "video", "tokens": 17376}],
|
||||
"total_tool_use_tokens": 0,
|
||||
"total_thought_tokens": 294,
|
||||
}
|
||||
|
||||
|
||||
def test_detects_interactions_usage_object():
|
||||
assert InteractionsUsageObjectTransformation.is_interactions_usage_object(OMNI_VIDEO_USAGE) is True
|
||||
|
||||
|
||||
def test_rejects_chat_and_responses_api_usage_objects():
|
||||
chat_usage = {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30}
|
||||
responses_api_usage = {"input_tokens": 10, "output_tokens": 20, "total_tokens": 30}
|
||||
assert InteractionsUsageObjectTransformation.is_interactions_usage_object(chat_usage) is False
|
||||
assert InteractionsUsageObjectTransformation.is_interactions_usage_object(responses_api_usage) is False
|
||||
assert InteractionsUsageObjectTransformation.is_interactions_usage_object(None) is False
|
||||
assert InteractionsUsageObjectTransformation.is_interactions_usage_object("usage") is False
|
||||
|
||||
|
||||
def test_transforms_real_omni_video_usage_block():
|
||||
usage = InteractionsUsageObjectTransformation.transform_interactions_usage_object(OMNI_VIDEO_USAGE)
|
||||
|
||||
assert isinstance(usage, Usage)
|
||||
assert usage.prompt_tokens == 16
|
||||
assert usage.completion_tokens == 17937 + 294
|
||||
assert usage.total_tokens == 18247
|
||||
assert usage.prompt_tokens_details is not None
|
||||
assert usage.prompt_tokens_details.text_tokens == 16
|
||||
assert usage.completion_tokens_details is not None
|
||||
assert usage.completion_tokens_details.video_tokens == 17376
|
||||
assert usage.completion_tokens_details.reasoning_tokens == 294
|
||||
|
||||
|
||||
def test_transforms_reasoning_tokens_spec_field_name():
|
||||
usage = InteractionsUsageObjectTransformation.transform_interactions_usage_object(
|
||||
{
|
||||
"total_input_tokens": 10,
|
||||
"total_output_tokens": 20,
|
||||
"total_reasoning_tokens": 5,
|
||||
}
|
||||
)
|
||||
assert usage.completion_tokens == 25
|
||||
assert usage.completion_tokens_details is not None
|
||||
assert usage.completion_tokens_details.reasoning_tokens == 5
|
||||
assert usage.total_tokens == 35
|
||||
|
||||
|
||||
def test_cached_tokens_subtracted_from_text_input():
|
||||
usage = InteractionsUsageObjectTransformation.transform_interactions_usage_object(
|
||||
{
|
||||
"total_input_tokens": 1000,
|
||||
"input_tokens_by_modality": [{"modality": "text", "tokens": 1000}],
|
||||
"total_cached_tokens": 400,
|
||||
"total_output_tokens": 50,
|
||||
}
|
||||
)
|
||||
assert usage.prompt_tokens == 1000
|
||||
assert usage.prompt_tokens_details is not None
|
||||
assert usage.prompt_tokens_details.text_tokens == 600
|
||||
assert usage.prompt_tokens_details.cached_tokens == 400
|
||||
assert usage._cache_read_input_tokens == 400
|
||||
|
||||
|
||||
def test_cached_tokens_subtracted_per_modality_when_breakdown_present():
|
||||
usage = InteractionsUsageObjectTransformation.transform_interactions_usage_object(
|
||||
{
|
||||
"total_input_tokens": 1500,
|
||||
"input_tokens_by_modality": [
|
||||
{"modality": "text", "tokens": 1000},
|
||||
{"modality": "audio", "tokens": 500},
|
||||
],
|
||||
"total_cached_tokens": 300,
|
||||
"cached_tokens_by_modality": [{"modality": "audio", "tokens": 300}],
|
||||
"total_output_tokens": 50,
|
||||
}
|
||||
)
|
||||
assert usage.prompt_tokens_details is not None
|
||||
assert usage.prompt_tokens_details.text_tokens == 1000
|
||||
assert usage.prompt_tokens_details.audio_tokens == 200
|
||||
assert usage.prompt_tokens_details.cached_tokens == 300
|
||||
|
||||
|
||||
def test_tool_use_tokens_billed_as_input():
|
||||
usage = InteractionsUsageObjectTransformation.transform_interactions_usage_object(
|
||||
{
|
||||
"total_input_tokens": 100,
|
||||
"input_tokens_by_modality": [{"modality": "text", "tokens": 100}],
|
||||
"total_tool_use_tokens": 40,
|
||||
"tool_use_tokens_by_modality": [{"modality": "text", "tokens": 40}],
|
||||
"total_output_tokens": 10,
|
||||
}
|
||||
)
|
||||
assert usage.prompt_tokens == 140
|
||||
assert usage.prompt_tokens_details is not None
|
||||
assert usage.prompt_tokens_details.text_tokens == 140
|
||||
|
||||
|
||||
def test_google_search_grounding_count_maps_to_web_search_requests():
|
||||
usage = InteractionsUsageObjectTransformation.transform_interactions_usage_object(
|
||||
{
|
||||
"total_input_tokens": 103,
|
||||
"input_tokens_by_modality": [{"modality": "text", "tokens": 103}],
|
||||
"total_output_tokens": 226,
|
||||
"total_thought_tokens": 351,
|
||||
"grounding_tool_count": [
|
||||
{"type": "google_search", "count": 3},
|
||||
{"type": "url_context", "count": 2},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert usage.prompt_tokens_details is not None
|
||||
assert usage.prompt_tokens_details.web_search_requests == 3
|
||||
|
||||
|
||||
def test_no_grounding_leaves_web_search_requests_unset():
|
||||
usage = InteractionsUsageObjectTransformation.transform_interactions_usage_object(
|
||||
{
|
||||
"total_input_tokens": 10,
|
||||
"input_tokens_by_modality": [{"modality": "text", "tokens": 10}],
|
||||
"total_output_tokens": 5,
|
||||
}
|
||||
)
|
||||
assert usage.prompt_tokens_details is not None
|
||||
assert getattr(usage.prompt_tokens_details, "web_search_requests", None) is None
|
||||
|
||||
|
||||
def test_document_modality_folds_into_text():
|
||||
usage = InteractionsUsageObjectTransformation.transform_interactions_usage_object(
|
||||
{
|
||||
"total_input_tokens": 80,
|
||||
"input_tokens_by_modality": [
|
||||
{"modality": "text", "tokens": 30},
|
||||
{"modality": "document", "tokens": 50},
|
||||
],
|
||||
"total_output_tokens": 10,
|
||||
}
|
||||
)
|
||||
assert usage.prompt_tokens_details is not None
|
||||
assert usage.prompt_tokens_details.text_tokens == 80
|
||||
|
|
@ -867,6 +867,7 @@ PROVIDERS_WITH_A_HANDLER = (
|
|||
"openrouter",
|
||||
"perplexity",
|
||||
"replicate",
|
||||
"runwayml",
|
||||
"sagemaker",
|
||||
"together_ai",
|
||||
"vertex_ai",
|
||||
|
|
@ -956,6 +957,7 @@ PROVIDERS_THAT_RECOGNISE_A_FULL_CONTEXT_WINDOW = (
|
|||
"mistral",
|
||||
"openai",
|
||||
"perplexity",
|
||||
"runwayml",
|
||||
"together_ai",
|
||||
"vertex_ai",
|
||||
"xai",
|
||||
|
|
@ -971,6 +973,7 @@ PROVIDERS_THAT_RECOGNISE_A_CONTENT_POLICY_BLOCK = (
|
|||
"mistral",
|
||||
"openai",
|
||||
"perplexity",
|
||||
"runwayml",
|
||||
"together_ai",
|
||||
"xai",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4546,6 +4546,323 @@ def test_zero_token_video_usage_preserves_duration_seconds(logging_obj):
|
|||
assert payload["completion_tokens"] == 0
|
||||
|
||||
|
||||
INTERACTIONS_USAGE_BLOCK = {
|
||||
"total_tokens": 175,
|
||||
"total_input_tokens": 100,
|
||||
"input_tokens_by_modality": [{"modality": "text", "tokens": 100}],
|
||||
"total_cached_tokens": 0,
|
||||
"total_output_tokens": 50,
|
||||
"output_tokens_by_modality": [{"modality": "text", "tokens": 50}],
|
||||
"total_tool_use_tokens": 0,
|
||||
"total_thought_tokens": 25,
|
||||
}
|
||||
|
||||
|
||||
def _interactions_logging_obj(stream: bool, call_type: str = "acreate"):
|
||||
logging_obj = LitellmLogging(
|
||||
model="gemini-2.5-flash",
|
||||
messages=[],
|
||||
stream=stream,
|
||||
call_type=call_type,
|
||||
start_time=time.time(),
|
||||
litellm_call_id="interactions-call-id",
|
||||
function_id="interactions-fn-id",
|
||||
)
|
||||
logging_obj.update_environment_variables(
|
||||
litellm_params={},
|
||||
optional_params={},
|
||||
model="gemini-2.5-flash",
|
||||
custom_llm_provider="gemini",
|
||||
input="hi",
|
||||
)
|
||||
return logging_obj
|
||||
|
||||
|
||||
@pytest.mark.parametrize("call_type", ["create", "acreate", "create_interaction", "acreate_interaction"])
|
||||
def test_interactions_response_is_recognized_for_logging(call_type):
|
||||
from litellm.types.interactions import InteractionsAPIResponse
|
||||
|
||||
logging_obj = _interactions_logging_obj(stream=False, call_type=call_type)
|
||||
response = InteractionsAPIResponse(
|
||||
id="interactions/abc",
|
||||
model="gemini-2.5-flash",
|
||||
status="completed",
|
||||
usage=dict(INTERACTIONS_USAGE_BLOCK),
|
||||
)
|
||||
assert logging_obj._is_recognized_call_type_for_logging(logging_result=response) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("call_type", ["acreate", "acreate_interaction"])
|
||||
def test_in_progress_background_create_is_not_billed(call_type):
|
||||
import datetime as dt
|
||||
|
||||
from litellm.types.interactions import InteractionsAPIResponse
|
||||
|
||||
logging_obj = _interactions_logging_obj(stream=False, call_type=call_type)
|
||||
response = InteractionsAPIResponse(id="interactions/abc", model="gemini-2.5-flash", status="in_progress")
|
||||
|
||||
assert logging_obj._is_recognized_call_type_for_logging(logging_result=response) is False
|
||||
|
||||
logging_obj._success_handler_helper_fn(
|
||||
result=response,
|
||||
start_time=dt.datetime.now(),
|
||||
end_time=dt.datetime.now(),
|
||||
cache_hit=False,
|
||||
)
|
||||
|
||||
assert logging_obj.model_call_details.get("response_cost") is None
|
||||
assert logging_obj.model_call_details.get("standard_logging_object") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_interaction_completion_rebills_after_in_progress_success():
|
||||
import datetime as dt
|
||||
|
||||
from litellm.types.interactions import InteractionsAPIResponse
|
||||
|
||||
logging_obj = _interactions_logging_obj(stream=False)
|
||||
in_progress = InteractionsAPIResponse(id="interactions/abc", model="gemini-2.5-flash", status="in_progress")
|
||||
await logging_obj.async_success_handler(
|
||||
result=in_progress,
|
||||
start_time=dt.datetime.now(),
|
||||
end_time=dt.datetime.now(),
|
||||
)
|
||||
|
||||
assert logging_obj.model_call_details.get("response_cost") is None
|
||||
assert logging_obj.should_run_logging(event_type="async_success") is False
|
||||
|
||||
completed = InteractionsAPIResponse(
|
||||
id="interactions/abc",
|
||||
model="gemini-2.5-flash",
|
||||
status="completed",
|
||||
steps=[],
|
||||
usage=dict(INTERACTIONS_USAGE_BLOCK),
|
||||
)
|
||||
await logging_obj.async_log_background_interaction_completion(result=completed)
|
||||
|
||||
assert logging_obj.model_call_details["response_cost"] > 0
|
||||
assert logging_obj.model_call_details["standard_logging_object"]["total_tokens"] == 175
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_interaction_completion_prices_the_settled_body_itself():
|
||||
"""
|
||||
The poll fetches the settled body through its own client call, which
|
||||
prices it against a throwaway logging object holding none of this
|
||||
request's deployment context. Adopting that price would bill a
|
||||
custom-priced deployment at the wrong rate, and it would also satisfy the
|
||||
"already calculated" shortcut and skip repricing, leaving the breakdown at
|
||||
the zeros the usage-less create stamped and writing those to the spend log.
|
||||
"""
|
||||
import datetime as dt
|
||||
|
||||
from litellm.types.interactions import InteractionsAPIResponse
|
||||
|
||||
logging_obj = _interactions_logging_obj(stream=False)
|
||||
in_progress = InteractionsAPIResponse(id="interactions/abc", model="gemini-2.5-flash", status="in_progress")
|
||||
await logging_obj.async_success_handler(
|
||||
result=in_progress,
|
||||
start_time=dt.datetime.now(),
|
||||
end_time=dt.datetime.now(),
|
||||
)
|
||||
|
||||
completed = InteractionsAPIResponse(
|
||||
id="interactions/abc",
|
||||
model="gemini-2.5-flash",
|
||||
status="completed",
|
||||
steps=[],
|
||||
usage=dict(INTERACTIONS_USAGE_BLOCK),
|
||||
)
|
||||
completed._hidden_params = {"response_cost": 99.0}
|
||||
|
||||
await logging_obj.async_log_background_interaction_completion(result=completed)
|
||||
|
||||
response_cost = logging_obj.model_call_details["response_cost"]
|
||||
assert response_cost != 99.0
|
||||
assert response_cost > 0
|
||||
|
||||
cost_breakdown = logging_obj.model_call_details["standard_logging_object"]["cost_breakdown"]
|
||||
assert cost_breakdown["total_cost"] == response_cost
|
||||
assert cost_breakdown["input_cost"] > 0
|
||||
assert cost_breakdown["output_cost"] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_interaction_completion_lets_otel_emit_the_cost_span():
|
||||
"""
|
||||
OTEL, and every integration that derives from it, dedupes span emission on
|
||||
a marker kept in the request's own metadata. The in-progress create claims
|
||||
that marker, so without clearing it the settled completion, the only event
|
||||
carrying usage and cost, is discarded as a duplicate and every
|
||||
OTEL-family backend shows the interaction as a span with no cost at all.
|
||||
"""
|
||||
import datetime as dt
|
||||
|
||||
from litellm.integrations.opentelemetry import OpenTelemetry, OpenTelemetryConfig
|
||||
from litellm.types.interactions import InteractionsAPIResponse
|
||||
|
||||
otel = OpenTelemetry(config=OpenTelemetryConfig(exporter="console"))
|
||||
logging_obj = _interactions_logging_obj(stream=False)
|
||||
in_progress = InteractionsAPIResponse(id="interactions/abc", model="gemini-2.5-flash", status="in_progress")
|
||||
await logging_obj.async_success_handler(
|
||||
result=in_progress,
|
||||
start_time=dt.datetime.now(),
|
||||
end_time=dt.datetime.now(),
|
||||
)
|
||||
|
||||
assert otel._emit_once(logging_obj.model_call_details, "success") is True
|
||||
assert otel._emit_once(logging_obj.model_call_details, "success") is False
|
||||
|
||||
completed = InteractionsAPIResponse(
|
||||
id="interactions/abc",
|
||||
model="gemini-2.5-flash",
|
||||
status="completed",
|
||||
steps=[],
|
||||
usage=dict(INTERACTIONS_USAGE_BLOCK),
|
||||
)
|
||||
await logging_obj.async_log_background_interaction_completion(result=completed)
|
||||
|
||||
assert otel._emit_once(logging_obj.model_call_details, "success") is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"call_type",
|
||||
["aget", "get", "aget_interaction", "adelete_interaction", "acancel_interaction"],
|
||||
)
|
||||
def test_interactions_get_poll_is_not_billed(call_type):
|
||||
import datetime as dt
|
||||
|
||||
from litellm.types.interactions import InteractionsAPIResponse
|
||||
|
||||
logging_obj = _interactions_logging_obj(stream=False, call_type=call_type)
|
||||
response = InteractionsAPIResponse(
|
||||
id="interactions/abc",
|
||||
model="gemini-2.5-flash",
|
||||
status="completed",
|
||||
steps=[],
|
||||
usage=dict(INTERACTIONS_USAGE_BLOCK),
|
||||
)
|
||||
|
||||
assert logging_obj._is_recognized_call_type_for_logging(logging_result=response) is False
|
||||
|
||||
logging_obj._success_handler_helper_fn(
|
||||
result=response,
|
||||
start_time=dt.datetime.now(),
|
||||
end_time=dt.datetime.now(),
|
||||
cache_hit=False,
|
||||
)
|
||||
|
||||
assert logging_obj.model_call_details.get("response_cost") is None
|
||||
assert logging_obj.model_call_details.get("standard_logging_object") is None
|
||||
|
||||
|
||||
def test_non_streaming_interactions_success_sets_response_cost_and_usage():
|
||||
import datetime as dt
|
||||
|
||||
from litellm.types.interactions import InteractionsAPIResponse
|
||||
|
||||
logging_obj = _interactions_logging_obj(stream=False)
|
||||
response = InteractionsAPIResponse(
|
||||
id="interactions/abc",
|
||||
model="gemini-2.5-flash",
|
||||
status="completed",
|
||||
steps=[],
|
||||
usage=dict(INTERACTIONS_USAGE_BLOCK),
|
||||
)
|
||||
|
||||
logging_obj._success_handler_helper_fn(
|
||||
result=response,
|
||||
start_time=dt.datetime.now(),
|
||||
end_time=dt.datetime.now(),
|
||||
cache_hit=False,
|
||||
)
|
||||
|
||||
assert logging_obj.model_call_details["response_cost"] > 0
|
||||
standard_logging_object = logging_obj.model_call_details["standard_logging_object"]
|
||||
assert standard_logging_object["prompt_tokens"] == 100
|
||||
assert standard_logging_object["completion_tokens"] == 75
|
||||
assert standard_logging_object["total_tokens"] == 175
|
||||
assert standard_logging_object["response_cost"] == logging_obj.model_call_details["response_cost"]
|
||||
|
||||
|
||||
def test_assembled_streaming_response_from_completed_interaction_event():
|
||||
import datetime as dt
|
||||
|
||||
from litellm.types.interactions import (
|
||||
InteractionsAPIResponse,
|
||||
InteractionsAPIStreamingResponse,
|
||||
)
|
||||
|
||||
logging_obj = _interactions_logging_obj(stream=True)
|
||||
completed_event = InteractionsAPIStreamingResponse(
|
||||
event_type="interaction.completed",
|
||||
interaction={
|
||||
"id": "interactions/abc",
|
||||
"model": "gemini-2.5-flash",
|
||||
"status": "completed",
|
||||
"steps": [],
|
||||
"usage": dict(INTERACTIONS_USAGE_BLOCK),
|
||||
},
|
||||
)
|
||||
|
||||
assembled = logging_obj._get_assembled_streaming_response(
|
||||
result=completed_event,
|
||||
start_time=dt.datetime.now(),
|
||||
end_time=dt.datetime.now(),
|
||||
is_async=True,
|
||||
streaming_chunks=[],
|
||||
)
|
||||
|
||||
assert isinstance(assembled, InteractionsAPIResponse)
|
||||
assert assembled.usage == INTERACTIONS_USAGE_BLOCK
|
||||
|
||||
in_progress_event = InteractionsAPIStreamingResponse(event_type="interaction.in_progress")
|
||||
assert (
|
||||
logging_obj._get_assembled_streaming_response(
|
||||
result=in_progress_event,
|
||||
start_time=dt.datetime.now(),
|
||||
end_time=dt.datetime.now(),
|
||||
is_async=True,
|
||||
streaming_chunks=[],
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_assembled_streaming_response_from_legacy_completed_chunk():
|
||||
from litellm.types.interactions import (
|
||||
InteractionsAPIResponse,
|
||||
InteractionsAPIStreamingResponse,
|
||||
)
|
||||
|
||||
legacy_chunk = InteractionsAPIStreamingResponse(
|
||||
event_type="interaction.complete",
|
||||
id="interactions/legacy",
|
||||
model="gemini-2.5-flash",
|
||||
status="completed",
|
||||
outputs=[],
|
||||
usage=dict(INTERACTIONS_USAGE_BLOCK),
|
||||
)
|
||||
|
||||
assembled = LitellmLogging._assemble_completed_interaction_response(legacy_chunk)
|
||||
|
||||
assert isinstance(assembled, InteractionsAPIResponse)
|
||||
assert assembled.id == "interactions/legacy"
|
||||
assert assembled.usage == INTERACTIONS_USAGE_BLOCK
|
||||
|
||||
|
||||
def test_standard_logging_payload_maps_interactions_usage():
|
||||
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
|
||||
|
||||
usage = StandardLoggingPayloadSetup.get_usage_from_response_obj(
|
||||
response_obj={"usage": dict(INTERACTIONS_USAGE_BLOCK)}
|
||||
)
|
||||
|
||||
assert usage.prompt_tokens == 100
|
||||
assert usage.completion_tokens == 75
|
||||
assert usage.total_tokens == 175
|
||||
|
||||
|
||||
def test_pre_call_does_not_pin_request_in_module_state(logging_obj):
|
||||
"""
|
||||
pre_call/post_call must not stash their locals (full messages, the Logging
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
import httpx
|
||||
|
||||
from litellm.litellm_core_utils.llm_request_utils import (
|
||||
flatten_form_field_values,
|
||||
serialize_multipart_form_fields,
|
||||
)
|
||||
|
||||
|
||||
def _multipart_field_names(data: dict) -> list[str]:
|
||||
request = httpx.Request(
|
||||
"POST",
|
||||
"http://backend/v1/images/edits",
|
||||
data=data,
|
||||
files=[("image[]", ("in.png", b"stub", "image/png"))],
|
||||
)
|
||||
request.read()
|
||||
body = request.content.decode("utf-8", "replace")
|
||||
prefix = 'Content-Disposition: form-data; name="'
|
||||
return [line[len(prefix) : line.index('"', len(prefix))] for line in body.splitlines() if line.startswith(prefix)]
|
||||
|
||||
|
||||
def test_serialize_multipart_form_fields_flattens_like_the_openai_sdk():
|
||||
fields = serialize_multipart_form_fields(
|
||||
{
|
||||
"model": "sora-2",
|
||||
"prompt": "a cat surfing",
|
||||
"hd": True,
|
||||
"watermark": False,
|
||||
"seconds": 4,
|
||||
"size": None,
|
||||
"metadata": {"trace": {"id": "t1"}},
|
||||
"characters": [{"id": "char_1", "name": "Mia"}, "solo"],
|
||||
}
|
||||
)
|
||||
|
||||
assert fields == (
|
||||
("model", (None, "sora-2")),
|
||||
("prompt", (None, "a cat surfing")),
|
||||
("hd", (None, "true")),
|
||||
("watermark", (None, "false")),
|
||||
("seconds", (None, "4")),
|
||||
("metadata[trace][id]", (None, "t1")),
|
||||
("characters[][id]", (None, "char_1")),
|
||||
("characters[][name]", (None, "Mia")),
|
||||
("characters[]", (None, "solo")),
|
||||
)
|
||||
|
||||
|
||||
def test_serialize_multipart_form_fields_drops_empty_strings():
|
||||
assert serialize_multipart_form_fields({"prompt": "", "model": "sora-2"}) == (("model", (None, "sora-2")),)
|
||||
|
||||
|
||||
def test_serialize_multipart_form_fields_empty_body():
|
||||
assert serialize_multipart_form_fields({}) == ()
|
||||
|
||||
|
||||
def test_flatten_form_field_values_flattens_nested_and_drops_empty():
|
||||
assert flatten_form_field_values(
|
||||
{
|
||||
"seed": 42,
|
||||
"hd": True,
|
||||
"size": None,
|
||||
"prompt": "",
|
||||
"generation_config": {"steps": 30, "guidance": True},
|
||||
}
|
||||
) == (
|
||||
("seed", "42"),
|
||||
("hd", "true"),
|
||||
("generation_config[steps]", "30"),
|
||||
("generation_config[guidance]", "true"),
|
||||
)
|
||||
|
||||
|
||||
def test_flatten_form_field_values_later_source_wins_on_collision():
|
||||
assert flatten_form_field_values({"seed": 1}, None, {"seed": 2}) == (
|
||||
("seed", "1"),
|
||||
("seed", "2"),
|
||||
)
|
||||
assert dict(flatten_form_field_values({"seed": 1}, {"seed": 2}))["seed"] == "2"
|
||||
|
||||
|
||||
def test_flatten_form_field_values_keeps_scalar_lists_as_repeated_fields():
|
||||
assert flatten_form_field_values(
|
||||
{"loras": ["a", "b", "c"], "generation_config": {"tags": [1, 2]}, "seed": 42}
|
||||
) == (
|
||||
("loras", ("a", "b", "c")),
|
||||
("generation_config[tags]", ("1", "2")),
|
||||
("seed", "42"),
|
||||
)
|
||||
|
||||
|
||||
def test_flatten_form_field_values_scalar_list_survives_update_into_multipart():
|
||||
request_params: dict = {"model": "my-edit-model"}
|
||||
request_params.update(flatten_form_field_values({"loras": ["style_a", "style_b"]}))
|
||||
|
||||
names = _multipart_field_names(request_params)
|
||||
|
||||
assert names.count("loras") == 2
|
||||
assert names.count("model") == 1
|
||||
|
|
@ -359,6 +359,48 @@ def test_translate_anthropic_messages_to_openai_thinking_blocks():
|
|||
assert result[1]["tool_calls"][0]["id"] == "toolu_01234"
|
||||
|
||||
|
||||
def test_translate_anthropic_messages_to_openai_sets_reasoning_content():
|
||||
"""Reasoning-aware chat providers read reasoning_content, so thinking text must land there.
|
||||
|
||||
Without it Moonshot and DeepSeek fill in a single-space placeholder and the model gets
|
||||
a blank where its own prior reasoning belongs.
|
||||
"""
|
||||
|
||||
anthropic_messages = [
|
||||
AnthropicMessagesUserMessageParam(
|
||||
role="user",
|
||||
content=[{"type": "text", "text": "Which city is best for a picnic?"}],
|
||||
),
|
||||
AnthopicMessagesAssistantMessageParam(
|
||||
role="assistant",
|
||||
content=[
|
||||
{"type": "thinking", "thinking": "Denver is dry in August.", "signature": "sig1"},
|
||||
{"type": "thinking", "thinking": "San Francisco is foggy.", "signature": "sig2"},
|
||||
{"type": "redacted_thinking", "data": "REDACTED"},
|
||||
{"type": "text", "text": "Denver."},
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
result = LiteLLMAnthropicMessagesAdapter().translate_anthropic_messages_to_openai(messages=anthropic_messages)
|
||||
|
||||
assert result[1]["reasoning_content"] == "Denver is dry in August.\nSan Francisco is foggy."
|
||||
assert result[1]["content"] == "Denver."
|
||||
|
||||
|
||||
def test_translate_anthropic_messages_to_openai_sets_no_reasoning_content_without_thinking():
|
||||
anthropic_messages = [
|
||||
AnthopicMessagesAssistantMessageParam(
|
||||
role="assistant",
|
||||
content=[{"type": "text", "text": "Denver."}],
|
||||
),
|
||||
]
|
||||
|
||||
result = LiteLLMAnthropicMessagesAdapter().translate_anthropic_messages_to_openai(messages=anthropic_messages)
|
||||
|
||||
assert "reasoning_content" not in result[0]
|
||||
|
||||
|
||||
def test_translate_anthropic_messages_to_openai_tool_message_placement():
|
||||
"""Test that tool result messages are placed before user messages in the conversation order."""
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.constants import (
|
||||
DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET,
|
||||
DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET,
|
||||
|
|
@ -17,7 +16,6 @@ from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_tran
|
|||
)
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reasoning_effort,expected_effort",
|
||||
[
|
||||
|
|
@ -258,19 +256,22 @@ def test_reasoning_effort_in_supported_params():
|
|||
"model",
|
||||
[
|
||||
"claude-sonnet-4-6",
|
||||
"bedrock/invoke/us.anthropic.claude-sonnet-4-6",
|
||||
"vertex_ai/claude-sonnet-4-6",
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6-20260219",
|
||||
"bedrock/invoke/us.anthropic.claude-sonnet-4-6",
|
||||
"bedrock/invoke/us.anthropic.claude-opus-4-6-v1:0",
|
||||
"vertex_ai/claude-sonnet-4-6",
|
||||
"vertex_ai/claude-opus-4-6",
|
||||
"azure_ai/claude-sonnet-4-6",
|
||||
],
|
||||
)
|
||||
def test_legacy_thinking_high_budget_clamps_to_high_when_xhigh_unsupported(
|
||||
local_model_cost_map, model
|
||||
):
|
||||
"""Claude Code sends ``thinking.budget_tokens=31999``; Sonnet 4.6 and Opus 4.6
|
||||
have no ``xhigh`` tier, so the translator must emit ``high`` rather than the
|
||||
provider-invalid ``xhigh`` (regression for issue #29282)."""
|
||||
def test_legacy_thinking_budget_preserved_verbatim_on_46(local_model_cost_map, model):
|
||||
"""Regression for the passthrough silently dropping a caller's hard thinking
|
||||
budget: the 4.6 family accepts ``thinking.type=enabled`` with ``budget_tokens``
|
||||
natively, so rewriting it to ``thinking.type=adaptive`` + ``output_config.effort``
|
||||
(which carries no ceiling) let reasoning run past the requested cap. The legacy
|
||||
shape must be forwarded verbatim, in every 4.6 id shape including unmapped dated
|
||||
releases resolved by the ``claude-legacy-thinking`` fallback rule."""
|
||||
config = AnthropicMessagesConfig()
|
||||
optional_params = {
|
||||
"max_tokens": 1024,
|
||||
|
|
@ -285,8 +286,8 @@ def test_legacy_thinking_high_budget_clamps_to_high_when_xhigh_unsupported(
|
|||
headers={},
|
||||
)
|
||||
|
||||
assert result.get("thinking") == {"type": "adaptive"}
|
||||
assert result.get("output_config") == {"effort": "high"}
|
||||
assert result.get("thinking") == {"type": "enabled", "budget_tokens": 31999}
|
||||
assert "output_config" not in result
|
||||
|
||||
|
||||
def test_legacy_thinking_high_budget_keeps_xhigh_when_supported():
|
||||
|
|
@ -343,11 +344,44 @@ def test_legacy_thinking_translates_to_adaptive_for_opus_48(
|
|||
assert result.get("output_config") == {"effort": "xhigh"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model,expected_effort",
|
||||
[
|
||||
("claude-sonnet-5", "xhigh"),
|
||||
("claude-opus-5", "xhigh"),
|
||||
("claude-newfamily-6", "high"),
|
||||
],
|
||||
)
|
||||
def test_legacy_thinking_translates_to_adaptive_for_5_and_future_models(
|
||||
local_model_cost_map, model, expected_effort
|
||||
):
|
||||
"""The 5 families reject ``thinking.type=enabled``, so the adaptive translation
|
||||
stays the safe default for every adaptive model not flagged
|
||||
``supports_legacy_thinking``, unmapped future ids included. An unmapped id
|
||||
cannot prove ``xhigh`` support, so its high-budget bucket clamps to ``high``."""
|
||||
config = AnthropicMessagesConfig()
|
||||
optional_params = {
|
||||
"max_tokens": 1024,
|
||||
"thinking": {"type": "enabled", "budget_tokens": 31999},
|
||||
}
|
||||
|
||||
result = config.transform_anthropic_messages_request(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
anthropic_messages_optional_request_params=optional_params,
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert result.get("thinking") == {"type": "adaptive"}
|
||||
assert result.get("output_config") == {"effort": expected_effort}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"budget_tokens,expected_effort",
|
||||
[
|
||||
(DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET * 2, "high"),
|
||||
(DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET, "high"),
|
||||
(DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET * 2, "xhigh"),
|
||||
(DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET, "xhigh"),
|
||||
(DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET, "high"),
|
||||
(DEFAULT_REASONING_EFFORT_HIGH_THINKING_BUDGET - 1, "medium"),
|
||||
(DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET, "medium"),
|
||||
|
|
@ -355,7 +389,9 @@ def test_legacy_thinking_translates_to_adaptive_for_opus_48(
|
|||
(1, "low"),
|
||||
],
|
||||
)
|
||||
def test_legacy_thinking_budget_buckets_on_sonnet_46(budget_tokens, expected_effort):
|
||||
def test_legacy_thinking_budget_buckets_on_opus_48(
|
||||
local_model_cost_map, budget_tokens, expected_effort
|
||||
):
|
||||
config = AnthropicMessagesConfig()
|
||||
optional_params = {
|
||||
"max_tokens": 1024,
|
||||
|
|
@ -363,7 +399,7 @@ def test_legacy_thinking_budget_buckets_on_sonnet_46(budget_tokens, expected_eff
|
|||
}
|
||||
|
||||
result = config.transform_anthropic_messages_request(
|
||||
model="claude-sonnet-4-6",
|
||||
model="claude-opus-4-8",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
anthropic_messages_optional_request_params=optional_params,
|
||||
litellm_params={},
|
||||
|
|
@ -373,7 +409,29 @@ def test_legacy_thinking_budget_buckets_on_sonnet_46(budget_tokens, expected_eff
|
|||
assert result.get("output_config") == {"effort": expected_effort}
|
||||
|
||||
|
||||
def test_legacy_thinking_does_not_override_explicit_output_config():
|
||||
def test_legacy_thinking_does_not_override_explicit_output_config(local_model_cost_map):
|
||||
config = AnthropicMessagesConfig()
|
||||
optional_params = {
|
||||
"max_tokens": 1024,
|
||||
"thinking": {"type": "enabled", "budget_tokens": 31999},
|
||||
"output_config": {"effort": "low"},
|
||||
}
|
||||
|
||||
result = config.transform_anthropic_messages_request(
|
||||
model="claude-opus-4-8",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
anthropic_messages_optional_request_params=optional_params,
|
||||
litellm_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert result.get("thinking") == {"type": "adaptive"}
|
||||
assert result.get("output_config") == {"effort": "low"}
|
||||
|
||||
|
||||
def test_legacy_thinking_with_explicit_output_config_untouched_on_46(
|
||||
local_model_cost_map,
|
||||
):
|
||||
config = AnthropicMessagesConfig()
|
||||
optional_params = {
|
||||
"max_tokens": 1024,
|
||||
|
|
@ -389,6 +447,7 @@ def test_legacy_thinking_does_not_override_explicit_output_config():
|
|||
headers={},
|
||||
)
|
||||
|
||||
assert result.get("thinking") == {"type": "enabled", "budget_tokens": 31999}
|
||||
assert result.get("output_config") == {"effort": "low"}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -144,9 +144,15 @@ class TestReasoningItemWithoutSummaryText:
|
|||
("content_block_delta", 1),
|
||||
("content_block_stop", 1),
|
||||
]
|
||||
assert chunks[1]["content_block"] == {"type": "thinking", "thinking": ""}
|
||||
assert chunks[1]["content_block"] == {"type": "thinking", "thinking": "", "signature": ""}
|
||||
assert "".join(c["delta"]["thinking"] for c in chunks[2:4]) == "Weighing options"
|
||||
|
||||
def test_the_reasoning_item_id_is_never_streamed_as_a_signature(self):
|
||||
"""A stand-in signature would be replayed as a real one, so none is ever sent."""
|
||||
chunks = _drain_async(self._gpt_turn(reasoning_summary_deltas=["Weighing options"]))
|
||||
|
||||
assert not [c for c in chunks if c.get("delta", {}).get("type") == "signature_delta"]
|
||||
|
||||
|
||||
class TestToolUseBlockClosedExactlyOnce:
|
||||
"""Regression for https://github.com/BerriAI/litellm/issues/37273.
|
||||
|
|
|
|||
|
|
@ -486,8 +486,8 @@ class TestTranslateMessagesToResponsesInput:
|
|||
}
|
||||
]
|
||||
|
||||
def test_assistant_thinking_block_becomes_output_text(self):
|
||||
"""Assistant thinking block text is included as output_text."""
|
||||
def test_assistant_thinking_block_becomes_reasoning_item(self):
|
||||
"""Assistant thinking block becomes a reasoning item, never visible assistant prose."""
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
|
|
@ -495,7 +495,77 @@ class TestTranslateMessagesToResponsesInput:
|
|||
}
|
||||
]
|
||||
result = _translate_messages(messages)
|
||||
assert result[0]["content"] == [{"type": "output_text", "text": "Let me reason step by step."}]
|
||||
assert result == [
|
||||
{
|
||||
"type": "reasoning",
|
||||
"summary": [{"type": "summary_text", "text": "Let me reason step by step."}],
|
||||
}
|
||||
]
|
||||
|
||||
def test_reasoning_item_carries_no_id(self):
|
||||
"""A fabricated reasoning id 404s upstream, so the item must go out without one."""
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "thinking", "thinking": "Private reasoning.", "signature": "rs_abc123"}],
|
||||
}
|
||||
]
|
||||
result = _translate_messages(messages)
|
||||
assert "id" not in result[0]
|
||||
|
||||
def test_consecutive_thinking_blocks_become_one_reasoning_item(self):
|
||||
"""Summary parts of one upstream reasoning item are regrouped into that item."""
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "First part."},
|
||||
{"type": "thinking", "thinking": "Second part."},
|
||||
],
|
||||
}
|
||||
]
|
||||
result = _translate_messages(messages)
|
||||
assert result == [
|
||||
{
|
||||
"type": "reasoning",
|
||||
"summary": [
|
||||
{"type": "summary_text", "text": "First part."},
|
||||
{"type": "summary_text", "text": "Second part."},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
def test_a_tool_call_splits_the_reasoning_items_around_it(self):
|
||||
"""Thinking on either side of a tool call belongs to two different reasoning items."""
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "Before the call."},
|
||||
{"type": "tool_use", "id": "call_1", "name": "get_weather", "input": {"city": "Denver"}},
|
||||
{"type": "thinking", "thinking": "After the call."},
|
||||
],
|
||||
}
|
||||
]
|
||||
result = _translate_messages(messages)
|
||||
assert [item["type"] for item in result] == ["reasoning", "function_call", "reasoning"]
|
||||
assert result[0]["summary"] == [{"type": "summary_text", "text": "Before the call."}]
|
||||
assert result[2]["summary"] == [{"type": "summary_text", "text": "After the call."}]
|
||||
|
||||
def test_thinking_and_text_stay_separate(self):
|
||||
"""The visible answer stays the only thing in the assistant message."""
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "The user wants Denver."},
|
||||
{"type": "text", "text": "Denver is the best pick."},
|
||||
],
|
||||
}
|
||||
]
|
||||
result = _translate_messages(messages)
|
||||
assert [item["type"] for item in result] == ["reasoning", "message"]
|
||||
assert result[1]["content"] == [{"type": "output_text", "text": "Denver is the best pick."}]
|
||||
|
||||
def test_assistant_empty_thinking_block_skipped(self):
|
||||
"""Assistant thinking block with empty thinking text is skipped."""
|
||||
|
|
@ -1094,7 +1164,7 @@ def _make_function_call_item(call_id: str, name: str, arguments: str) -> MagicMo
|
|||
return item
|
||||
|
||||
|
||||
def _make_reasoning_item(summaries: List[str]) -> MagicMock:
|
||||
def _make_reasoning_item(summaries: List[str], item_id: str = "rs_test_1") -> MagicMock:
|
||||
"""Build a mock ResponseReasoningItem."""
|
||||
from openai.types.responses import ResponseReasoningItem # type: ignore[import]
|
||||
|
||||
|
|
@ -1105,6 +1175,7 @@ def _make_reasoning_item(summaries: List[str]) -> MagicMock:
|
|||
summary_mocks.append(s)
|
||||
|
||||
item = MagicMock(spec=ResponseReasoningItem)
|
||||
item.id = item_id
|
||||
item.summary = summary_mocks
|
||||
return item
|
||||
|
||||
|
|
@ -1178,6 +1249,53 @@ class TestTranslateResponse:
|
|||
result: Any = _ADAPTER.translate_response(response)
|
||||
assert result["content"] == []
|
||||
|
||||
def test_null_summary_text_skipped_rather_than_stringified(self):
|
||||
"""A summary part whose text is null must not reach the client as the word "None"."""
|
||||
response = _make_mock_response(
|
||||
output=[
|
||||
{
|
||||
"type": "reasoning",
|
||||
"id": "rs_null_1",
|
||||
"summary": [{"type": "summary_text", "text": None}],
|
||||
}
|
||||
]
|
||||
)
|
||||
result: Any = _ADAPTER.translate_response(response)
|
||||
assert result["content"] == []
|
||||
|
||||
def test_reasoning_item_id_never_becomes_a_thinking_signature(self):
|
||||
"""Only Anthropic can sign a thinking block, so a stand-in signature is never invented."""
|
||||
reasoning = _make_reasoning_item(["Part one.", "Part two."], item_id="rs_abc123")
|
||||
response = _make_mock_response(output=[reasoning])
|
||||
result: Any = _ADAPTER.translate_response(response)
|
||||
assert [block["signature"] for block in result["content"]] == [None, None]
|
||||
|
||||
def test_dict_reasoning_item_becomes_thinking_block(self):
|
||||
"""A reasoning item arriving as a plain dict is kept, not dropped."""
|
||||
response = _make_mock_response(
|
||||
output=[
|
||||
{
|
||||
"type": "reasoning",
|
||||
"id": "rs_dict_1",
|
||||
"summary": [{"type": "summary_text", "text": "Weighing the options."}],
|
||||
}
|
||||
]
|
||||
)
|
||||
result: Any = _ADAPTER.translate_response(response)
|
||||
assert result["content"] == [
|
||||
{"type": "thinking", "thinking": "Weighing the options.", "signature": None}
|
||||
]
|
||||
|
||||
def test_thinking_blocks_are_dropped_when_replayed_to_anthropic(self):
|
||||
"""Replaying this turn to an Anthropic model must not send a signature it cannot verify."""
|
||||
from litellm.litellm_core_utils.prompt_templates.factory import (
|
||||
_drop_unsignable_thinking_blocks,
|
||||
)
|
||||
|
||||
response = _make_mock_response(output=[_make_reasoning_item(["Part one."], item_id="rs_abc123")])
|
||||
result: Any = _ADAPTER.translate_response(response)
|
||||
assert _drop_unsignable_thinking_blocks(result["content"]) == []
|
||||
|
||||
def test_usage_mapped_correctly(self):
|
||||
"""Input/output tokens from ResponseAPIUsage are mapped to AnthropicUsage."""
|
||||
response = _make_mock_response(
|
||||
|
|
|
|||
|
|
@ -417,3 +417,94 @@ class TestFilterAnthropicOutputSchema:
|
|||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
assert result["additionalProperties"] is False
|
||||
|
||||
def test_drops_union_type_alongside_enum(self):
|
||||
"""A union ``type`` can never match a single declared type.
|
||||
|
||||
Anthropic rejects it with "Invalid schema: Enum value 'low' does not
|
||||
match declared type '['string', 'null']'". ``enum`` is the tighter
|
||||
constraint, so the conflicting ``type`` is dropped.
|
||||
"""
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"confidence": {
|
||||
"enum": ["low", "medium", "high", None],
|
||||
"type": ["string", "null"],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
assert "type" not in result["properties"]["confidence"]
|
||||
assert result["properties"]["confidence"]["enum"] == [
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
None,
|
||||
]
|
||||
|
||||
def test_drops_type_when_an_enum_value_does_not_match_it(self):
|
||||
"""``enum: ["x", None]`` with ``type: "string"`` is rejected too."""
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"a": {"enum": ["x", None], "type": "string"}},
|
||||
}
|
||||
|
||||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
assert "type" not in result["properties"]["a"]
|
||||
|
||||
def test_preserves_type_when_every_enum_value_matches(self):
|
||||
"""The non-conflicting case must be left exactly as-is."""
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"a": {"enum": ["x", "y"], "type": "string"}},
|
||||
}
|
||||
|
||||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
assert result["properties"]["a"]["type"] == "string"
|
||||
assert result["properties"]["a"]["enum"] == ["x", "y"]
|
||||
|
||||
def test_integer_enum_satisfies_number_type(self):
|
||||
"""JSON Schema ``number`` accepts integers, so this is not a conflict."""
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"a": {"enum": [1, 2], "type": "number"}},
|
||||
}
|
||||
|
||||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
assert result["properties"]["a"]["type"] == "number"
|
||||
|
||||
def test_bool_enum_does_not_satisfy_integer_type(self):
|
||||
"""``bool`` is a Python ``int`` subclass but is not a JSON integer."""
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"a": {"enum": [True], "type": "integer"}},
|
||||
}
|
||||
|
||||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
assert "type" not in result["properties"]["a"]
|
||||
|
||||
def test_normalizes_enum_type_inside_array_items(self):
|
||||
"""Normalization applies at every recursion site, not just top level."""
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rows": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {"c": {"enum": ["a", None], "type": ["string", "null"]}},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
result = AnthropicConfig.filter_anthropic_output_schema(schema)
|
||||
|
||||
assert "type" not in result["properties"]["rows"]["items"]["properties"]["c"]
|
||||
|
|
|
|||
|
|
@ -7,7 +7,12 @@ from unittest.mock import MagicMock, patch
|
|||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.llms.azure.common_utils import BaseAzureLLM, get_azure_ad_token
|
||||
from litellm.llms.azure.common_utils import (
|
||||
BaseAzureLLM,
|
||||
_cached_entra_id_token_provider,
|
||||
get_azure_ad_token,
|
||||
get_azure_ad_token_from_entra_id,
|
||||
)
|
||||
from litellm.secret_managers.get_azure_ad_token_provider import (
|
||||
get_azure_ad_token_provider,
|
||||
)
|
||||
|
|
@ -413,6 +418,7 @@ def test_select_azure_base_url_called(setup_mocks):
|
|||
"avector_store_create",
|
||||
"avector_store_search",
|
||||
"acreate_skill",
|
||||
"acreate_interaction",
|
||||
]
|
||||
],
|
||||
)
|
||||
|
|
@ -2001,6 +2007,62 @@ def test_azure_traditional_api_uses_azure_openai_client():
|
|||
), f"Expected AsyncAzureOpenAI client for api_version={api_version}"
|
||||
|
||||
|
||||
class TestEntraIdTokenProviderCache:
|
||||
def setup_method(self):
|
||||
_cached_entra_id_token_provider.cache_clear()
|
||||
|
||||
def teardown_method(self):
|
||||
_cached_entra_id_token_provider.cache_clear()
|
||||
|
||||
def test_reuses_credential_for_the_same_service_principal(self):
|
||||
with (
|
||||
patch("azure.identity.ClientSecretCredential") as mock_credential,
|
||||
patch("azure.identity.get_bearer_token_provider", side_effect=lambda credential, scope: lambda: "token"),
|
||||
):
|
||||
first = get_azure_ad_token_from_entra_id(
|
||||
tenant_id="tenant",
|
||||
client_id="client",
|
||||
client_secret="secret",
|
||||
scope="https://cognitiveservices.azure.com/.default",
|
||||
)
|
||||
second = get_azure_ad_token_from_entra_id(
|
||||
tenant_id="tenant",
|
||||
client_id="client",
|
||||
client_secret="secret",
|
||||
scope="https://cognitiveservices.azure.com/.default",
|
||||
)
|
||||
|
||||
assert first is second
|
||||
assert mock_credential.call_count == 1
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"second_call_kwargs",
|
||||
[
|
||||
{"tenant_id": "other-tenant"},
|
||||
{"client_id": "other-client"},
|
||||
{"client_secret": "other-secret"},
|
||||
{"scope": "https://ai.azure.com/.default"},
|
||||
],
|
||||
)
|
||||
def test_does_not_share_a_provider_across_credentials_or_scopes(self, second_call_kwargs):
|
||||
base_kwargs = {
|
||||
"tenant_id": "tenant",
|
||||
"client_id": "client",
|
||||
"client_secret": "secret",
|
||||
"scope": "https://cognitiveservices.azure.com/.default",
|
||||
}
|
||||
|
||||
with (
|
||||
patch("azure.identity.ClientSecretCredential") as mock_credential,
|
||||
patch("azure.identity.get_bearer_token_provider", side_effect=lambda credential, scope: lambda: "token"),
|
||||
):
|
||||
first = get_azure_ad_token_from_entra_id(**base_kwargs)
|
||||
second = get_azure_ad_token_from_entra_id(**{**base_kwargs, **second_call_kwargs})
|
||||
|
||||
assert first is not second
|
||||
assert mock_credential.call_count == 2
|
||||
|
||||
|
||||
def test_evicting_an_azure_client_built_on_the_callers_session_leaves_it_open(monkeypatch):
|
||||
"""`initialize_azure_sdk_client` puts `litellm.aclient_session` on the SDK client.
|
||||
|
||||
|
|
|
|||
|
|
@ -300,6 +300,7 @@ def test_azure_ai_strips_non_openai_spec_message_fields():
|
|||
"cache_control": {"type": "ephemeral"},
|
||||
}
|
||||
],
|
||||
"reasoning_content": "The user wants me to read a file.",
|
||||
"provider_specific_fields": {"thought_signature": "sig-top"},
|
||||
"tool_calls": [
|
||||
{
|
||||
|
|
@ -327,6 +328,7 @@ def test_azure_ai_strips_non_openai_spec_message_fields():
|
|||
transformed_messages = request["messages"]
|
||||
|
||||
assert not _find_key_anywhere(transformed_messages, "thinking_blocks")
|
||||
assert not _find_key_anywhere(transformed_messages, "reasoning_content")
|
||||
assert not _find_key_anywhere(transformed_messages, "provider_specific_fields")
|
||||
assert not _find_key_anywhere(transformed_messages, "cache_control")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
|
||||
|
||||
import litellm
|
||||
from litellm.llms.azure_ai.image_edit.flux2_transformation import (
|
||||
AzureFoundryFlux2ImageEditConfig,
|
||||
)
|
||||
from litellm.llms.azure_ai.image_edit.transformation import (
|
||||
AzureFoundryFluxImageEditConfig,
|
||||
)
|
||||
|
|
@ -27,3 +31,32 @@ def test_azure_ai_url_generation():
|
|||
)
|
||||
expected_url = f"{api_base}/openai/deployments/FLUX.1-Kontext-pro/images/edits?api-version=2025-04-01-preview"
|
||||
assert complete_url == expected_url
|
||||
|
||||
|
||||
def test_azure_ai_validate_environment_with_entra_token(monkeypatch):
|
||||
monkeypatch.delenv("AZURE_AI_API_KEY", raising=False)
|
||||
monkeypatch.setattr(litellm, "api_key", None)
|
||||
config = AzureFoundryFluxImageEditConfig()
|
||||
|
||||
headers = config.validate_environment(
|
||||
{},
|
||||
"FLUX.1-Kontext-pro",
|
||||
litellm_params={"azure_ad_token": "entra-token"},
|
||||
)
|
||||
|
||||
assert headers == {"Authorization": "Bearer entra-token"}
|
||||
|
||||
|
||||
def test_flux2_validate_environment_with_entra_token(monkeypatch):
|
||||
monkeypatch.delenv("AZURE_AI_API_KEY", raising=False)
|
||||
monkeypatch.setattr(litellm, "api_key", None)
|
||||
config = AzureFoundryFlux2ImageEditConfig()
|
||||
|
||||
headers = config.validate_environment(
|
||||
{},
|
||||
"flux.2-pro",
|
||||
litellm_params={"azure_ad_token": "entra-token"},
|
||||
)
|
||||
|
||||
assert headers["Authorization"] == "Bearer entra-token"
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import httpx
|
|||
import pytest
|
||||
|
||||
|
||||
import litellm
|
||||
from litellm.llms.azure_ai.image_edit import (
|
||||
AzureFoundryMAIImageEditConfig,
|
||||
get_azure_ai_image_edit_config,
|
||||
|
|
@ -166,3 +167,16 @@ class TestAzureMAIImageEdit:
|
|||
assert image_response.data[0].b64_json == "abc123"
|
||||
assert image_response.usage.output_tokens == 1024
|
||||
assert image_response.usage.total_tokens == 1024
|
||||
|
||||
|
||||
def test_mai_validate_environment_with_entra_token(monkeypatch):
|
||||
monkeypatch.delenv("AZURE_AI_API_KEY", raising=False)
|
||||
monkeypatch.setattr(litellm, "api_key", None)
|
||||
|
||||
headers = AzureFoundryMAIImageEditConfig().validate_environment(
|
||||
headers={},
|
||||
model="MAI-Image-2.5",
|
||||
litellm_params={"azure_ad_token": "entra-token"},
|
||||
)
|
||||
|
||||
assert headers == {"Authorization": "Bearer entra-token"}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import pytest
|
||||
|
||||
|
||||
import litellm
|
||||
from litellm.llms.azure_ai.rerank.transformation import AzureAIRerankConfig
|
||||
|
||||
|
||||
|
|
@ -92,3 +93,26 @@ class TestAzureAIRerankConfigGetCompleteUrl:
|
|||
model=self.model,
|
||||
)
|
||||
assert url == "https://my-resource.services.ai.azure.com/v1/rerank?r=1"
|
||||
|
||||
|
||||
class TestAzureAIRerankConfigValidateEnvironment:
|
||||
def test_uses_api_key_when_set(self):
|
||||
headers = AzureAIRerankConfig().validate_environment(
|
||||
headers={},
|
||||
model="azure_ai/cohere-rerank-v3-english",
|
||||
api_key="my-key",
|
||||
)
|
||||
|
||||
assert headers["Authorization"] == "Bearer my-key"
|
||||
|
||||
def test_falls_back_to_entra_token(self, monkeypatch):
|
||||
monkeypatch.delenv("AZURE_AI_API_KEY", raising=False)
|
||||
monkeypatch.setattr(litellm, "azure_key", None)
|
||||
|
||||
headers = AzureAIRerankConfig().validate_environment(
|
||||
headers={},
|
||||
model="azure_ai/cohere-rerank-v3-english",
|
||||
litellm_params={"azure_ad_token": "entra-token"},
|
||||
)
|
||||
|
||||
assert headers["Authorization"] == "Bearer entra-token"
|
||||
|
|
|
|||
154
tests/test_litellm/llms/azure_ai/test_azure_ai_entra_auth.py
Normal file
154
tests/test_litellm/llms/azure_ai/test_azure_ai_entra_auth.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
"""
|
||||
Entra ID / OAuth auth for Azure AI Foundry routes.
|
||||
|
||||
Every azure_ai route must authenticate with an Entra ID token when no API key is configured,
|
||||
instead of requiring an API key.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.llms.azure_ai.common_utils import get_azure_ai_auth_headers
|
||||
from litellm.llms.azure_ai.ocr.transformation import AzureAIOCRConfig
|
||||
|
||||
ENTRA_PARAMS = {"azure_ad_token": "entra-token"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_azure_env(monkeypatch):
|
||||
for env_var in (
|
||||
"AZURE_AI_API_KEY",
|
||||
"AZURE_API_KEY",
|
||||
"AZURE_AD_TOKEN",
|
||||
"AZURE_TENANT_ID",
|
||||
"AZURE_CLIENT_ID",
|
||||
"AZURE_CLIENT_SECRET",
|
||||
"AZURE_SCOPE",
|
||||
"OPENAI_API_KEY",
|
||||
"AZURE_DOCUMENT_INTELLIGENCE_API_KEY",
|
||||
):
|
||||
monkeypatch.delenv(env_var, raising=False)
|
||||
monkeypatch.setattr(litellm, "api_key", None)
|
||||
monkeypatch.setattr(litellm, "openai_key", None)
|
||||
|
||||
|
||||
def test_api_key_wins_over_entra_credentials():
|
||||
headers = get_azure_ai_auth_headers(api_key="my-key", litellm_params=ENTRA_PARAMS, api_key_header="Api-Key")
|
||||
|
||||
assert headers == {"Api-Key": "my-key"}
|
||||
|
||||
|
||||
def test_entra_token_used_when_no_api_key():
|
||||
headers = get_azure_ai_auth_headers(api_key=None, litellm_params=ENTRA_PARAMS, api_key_header="Api-Key")
|
||||
|
||||
assert headers == {"Authorization": "Bearer entra-token"}
|
||||
|
||||
|
||||
def test_service_principal_token_is_requested_with_the_configured_scope():
|
||||
with patch("litellm.llms.azure.common_utils.get_azure_ad_token_from_entra_id") as mock_entra_id: # test-quality-ok: stubs the Entra token fetch to assert the SP credential+scope plumbing and the returned Bearer header; live SP path proven by the PR's Azure Foundry e2e QA
|
||||
mock_entra_id.return_value = lambda: "sp-token"
|
||||
|
||||
headers = get_azure_ai_auth_headers(
|
||||
api_key=None,
|
||||
litellm_params={
|
||||
"tenant_id": "tenant",
|
||||
"client_id": "client",
|
||||
"client_secret": "secret",
|
||||
"azure_scope": "https://ai.azure.com/.default",
|
||||
},
|
||||
)
|
||||
|
||||
mock_entra_id.assert_called_once_with(
|
||||
tenant_id="tenant",
|
||||
client_id="client",
|
||||
client_secret="secret",
|
||||
scope="https://ai.azure.com/.default",
|
||||
)
|
||||
assert headers == {"Authorization": "Bearer sp-token"}
|
||||
|
||||
|
||||
def test_error_mentions_both_credential_types_when_nothing_is_configured():
|
||||
with pytest.raises(ValueError, match="AZURE_AI_API_KEY") as exc_info:
|
||||
get_azure_ai_auth_headers(api_key=None, litellm_params={})
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "AZURE_AI_API_KEY" in message
|
||||
assert "client_secret" in message
|
||||
|
||||
|
||||
def test_ocr_authenticates_with_entra_token():
|
||||
headers = AzureAIOCRConfig().validate_environment(
|
||||
headers={},
|
||||
model="azure_ai/mistral-ocr",
|
||||
api_base="https://my-resource.services.ai.azure.com",
|
||||
litellm_params=ENTRA_PARAMS,
|
||||
)
|
||||
|
||||
assert headers["Authorization"] == "Bearer entra-token"
|
||||
|
||||
|
||||
def test_embedding_falls_back_to_entra_token_instead_of_openai_key(monkeypatch): # test-quality-ok: asserts the embedding handler is authed with the Entra token, not the OpenAI key fallback; live path proven by the PR's Azure Foundry e2e QA
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-key")
|
||||
|
||||
with patch.object(litellm.main.azure_ai_embedding, "embedding") as mock_embedding: # test-quality-ok: no injection seam for the embedding handler through the public embedding() API; live path proven by the PR's Azure Foundry e2e QA
|
||||
mock_embedding.return_value = litellm.EmbeddingResponse()
|
||||
|
||||
litellm.embedding(
|
||||
model="azure_ai/cohere-embed-v3-english",
|
||||
input=["hello"],
|
||||
api_base="https://my-resource.services.ai.azure.com",
|
||||
azure_ad_token="entra-token",
|
||||
)
|
||||
|
||||
assert mock_embedding.call_args.kwargs["api_key"] == "entra-token"
|
||||
|
||||
|
||||
def test_image_generation_authenticates_with_entra_token():
|
||||
with patch.object(litellm.images.main.azure_chat_completions, "image_generation") as mock_image_generation: # test-quality-ok: asserts image_generation forwards the computed Entra bearer header; no injection seam through the public API; live path proven by the PR's Azure Foundry e2e QA
|
||||
mock_image_generation.return_value = litellm.ImageResponse()
|
||||
|
||||
litellm.image_generation(
|
||||
model="azure_ai/FLUX-1.1-pro",
|
||||
prompt="a red circle",
|
||||
api_base="https://my-resource.services.ai.azure.com",
|
||||
azure_ad_token="entra-token",
|
||||
)
|
||||
|
||||
headers = mock_image_generation.call_args.kwargs["headers"]
|
||||
assert headers["Authorization"] == "Bearer entra-token"
|
||||
assert "api-key" not in headers
|
||||
|
||||
|
||||
@pytest.mark.parametrize("header_name", ["Authorization", "authorization", "api-key", "API-KEY"])
|
||||
def test_image_generation_keeps_caller_supplied_auth_header(header_name):
|
||||
with patch.object(litellm.images.main.azure_chat_completions, "image_generation") as mock_image_generation: # test-quality-ok: asserts a caller-supplied auth header is preserved over Entra; no injection seam through the public API; live path proven by the PR's Azure Foundry e2e QA
|
||||
mock_image_generation.return_value = litellm.ImageResponse()
|
||||
|
||||
litellm.image_generation(
|
||||
model="azure_ai/FLUX-1.1-pro",
|
||||
prompt="a red circle",
|
||||
api_base="https://my-resource.services.ai.azure.com",
|
||||
headers={header_name: "caller-credential"},
|
||||
)
|
||||
|
||||
headers = mock_image_generation.call_args.kwargs["headers"]
|
||||
assert headers[header_name] == "caller-credential"
|
||||
assert len(headers) == 2
|
||||
|
||||
|
||||
def test_image_generation_still_uses_api_key_header():
|
||||
with patch.object(litellm.images.main.azure_chat_completions, "image_generation") as mock_image_generation: # test-quality-ok: asserts the api-key header path still works alongside Entra; no injection seam through the public API; live path proven by the PR's Azure Foundry e2e QA
|
||||
mock_image_generation.return_value = litellm.ImageResponse()
|
||||
|
||||
litellm.image_generation(
|
||||
model="azure_ai/FLUX-1.1-pro",
|
||||
prompt="a red circle",
|
||||
api_base="https://my-resource.services.ai.azure.com",
|
||||
api_key="my-key",
|
||||
)
|
||||
|
||||
headers = mock_image_generation.call_args.kwargs["headers"]
|
||||
assert headers["api-key"] == "my-key"
|
||||
assert "Authorization" not in headers
|
||||
|
|
@ -344,3 +344,30 @@ def test_get_complete_url_combines_pages_and_features():
|
|||
|
||||
assert "&pages=1,2,3" in url
|
||||
assert "&features=keyValuePairs,languages" in url
|
||||
|
||||
|
||||
def test_validate_environment_uses_subscription_key(monkeypatch):
|
||||
monkeypatch.delenv("AZURE_DOCUMENT_INTELLIGENCE_API_KEY", raising=False)
|
||||
|
||||
headers = AzureDocumentIntelligenceOCRConfig().validate_environment(
|
||||
headers={},
|
||||
model="prebuilt-layout",
|
||||
api_key="my-key",
|
||||
api_base="https://example.cognitiveservices.azure.com",
|
||||
)
|
||||
|
||||
assert headers["Ocp-Apim-Subscription-Key"] == "my-key"
|
||||
|
||||
|
||||
def test_validate_environment_falls_back_to_entra_token(monkeypatch):
|
||||
monkeypatch.delenv("AZURE_DOCUMENT_INTELLIGENCE_API_KEY", raising=False)
|
||||
|
||||
headers = AzureDocumentIntelligenceOCRConfig().validate_environment(
|
||||
headers={},
|
||||
model="prebuilt-layout",
|
||||
api_base="https://example.cognitiveservices.azure.com",
|
||||
litellm_params={"azure_ad_token": "entra-token"},
|
||||
)
|
||||
|
||||
assert headers["Authorization"] == "Bearer entra-token"
|
||||
assert "Ocp-Apim-Subscription-Key" not in headers
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
|
||||
from litellm.llms.bedrock.chat.invoke_handler import (
|
||||
AWSEventStreamDecoder,
|
||||
make_call,
|
||||
|
|
@ -292,6 +294,139 @@ def test_make_sync_call_honors_explicit_stream_chunk_size():
|
|||
response.iter_bytes.assert_called_once_with(chunk_size=2048)
|
||||
|
||||
|
||||
CONVERSE_MODEL = "anthropic.claude-sonnet-4-6"
|
||||
CONVERSE_METADATA_EVENT = {
|
||||
"usage": {"inputTokens": 10, "outputTokens": 5, "totalTokens": 15},
|
||||
"metrics": {"latencyMs": 100},
|
||||
}
|
||||
|
||||
|
||||
def _converse_stream_wrapper(events):
|
||||
async def bedrock_stream():
|
||||
decoder = AWSEventStreamDecoder(model=CONVERSE_MODEL)
|
||||
for event in events:
|
||||
yield decoder._chunk_parser(chunk_data=event)
|
||||
|
||||
return CustomStreamWrapper(
|
||||
completion_stream=bedrock_stream(),
|
||||
model=CONVERSE_MODEL,
|
||||
custom_llm_provider="bedrock",
|
||||
logging_obj=LiteLLMLoggingObj(
|
||||
model=CONVERSE_MODEL,
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=True,
|
||||
call_type="completion",
|
||||
start_time=datetime.datetime.now(),
|
||||
litellm_call_id="1234",
|
||||
function_id="1234",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"events, expected_finish_reason",
|
||||
[
|
||||
pytest.param(
|
||||
(
|
||||
{"role": "assistant"},
|
||||
{"contentBlockIndex": 0, "delta": {"text": "Hello"}},
|
||||
{"contentBlockIndex": 0, "delta": {"text": " world"}},
|
||||
{"contentBlockIndex": 0},
|
||||
{"stopReason": "end_turn"},
|
||||
CONVERSE_METADATA_EVENT,
|
||||
),
|
||||
"stop",
|
||||
id="text",
|
||||
),
|
||||
pytest.param(
|
||||
(
|
||||
{"role": "assistant"},
|
||||
{"contentBlockIndex": 0, "start": {"toolUse": {"toolUseId": "t1", "name": "get_weather"}}},
|
||||
{"contentBlockIndex": 0, "delta": {"toolUse": {"input": '{"city": "SF"}'}}},
|
||||
{"contentBlockIndex": 0},
|
||||
{"contentBlockIndex": 1, "start": {"toolUse": {"toolUseId": "t2", "name": "get_time"}}},
|
||||
{"contentBlockIndex": 1, "delta": {"toolUse": {"input": '{"tz": "PT"}'}}},
|
||||
{"contentBlockIndex": 1},
|
||||
{"stopReason": "tool_use"},
|
||||
CONVERSE_METADATA_EVENT,
|
||||
),
|
||||
"tool_calls",
|
||||
id="multiple_tool_calls",
|
||||
),
|
||||
pytest.param(
|
||||
(
|
||||
{"role": "assistant"},
|
||||
{"contentBlockIndex": 0, "start": {}},
|
||||
{"contentBlockIndex": 0, "delta": {"text": "Let me check."}},
|
||||
{"contentBlockIndex": 0},
|
||||
{"contentBlockIndex": 1, "start": {"toolUse": {"toolUseId": "t1", "name": "get_weather"}}},
|
||||
{"contentBlockIndex": 1, "delta": {"toolUse": {"input": '{"city": "SF"}'}}},
|
||||
{"contentBlockIndex": 1},
|
||||
{"stopReason": "tool_use"},
|
||||
CONVERSE_METADATA_EVENT,
|
||||
),
|
||||
"tool_calls",
|
||||
id="text_then_tool_call",
|
||||
),
|
||||
pytest.param(
|
||||
(
|
||||
{"role": "assistant"},
|
||||
{"contentBlockIndex": 0, "start": {}},
|
||||
{"contentBlockIndex": 0, "delta": {"reasoningContent": {"text": "thinking hard"}}},
|
||||
{"contentBlockIndex": 0, "delta": {"reasoningContent": {"signature": "sig123"}}},
|
||||
{"contentBlockIndex": 0},
|
||||
{"contentBlockIndex": 1, "start": {}},
|
||||
{"contentBlockIndex": 1, "delta": {"text": "Answer"}},
|
||||
{"contentBlockIndex": 1},
|
||||
{"stopReason": "end_turn"},
|
||||
CONVERSE_METADATA_EVENT,
|
||||
),
|
||||
"stop",
|
||||
id="reasoning_then_text",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_converse_stream_ends_on_finish_reason_chunk(events, expected_finish_reason):
|
||||
"""The usage-only metadata event Bedrock sends after messageStop must not reach the caller as an extra
|
||||
assistant delta following the finish_reason chunk."""
|
||||
wrapper = _converse_stream_wrapper(events)
|
||||
|
||||
chunks = [chunk async for chunk in wrapper]
|
||||
|
||||
finish_reasons = [choice.finish_reason for chunk in chunks for choice in chunk.choices if choice.finish_reason]
|
||||
assert finish_reasons == [expected_finish_reason]
|
||||
assert chunks[-1].choices[0].finish_reason == expected_finish_reason, (
|
||||
f"stream must end on the finish_reason chunk, got trailing {chunks[-1].model_dump(exclude_none=True)}"
|
||||
)
|
||||
roles = [choice.delta.role for chunk in chunks for choice in chunk.choices if choice.delta.role]
|
||||
assert roles == ["assistant"]
|
||||
assert any(getattr(chunk, "usage", None) is not None for chunk in wrapper.chunks)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_converse_stream_still_emits_guardrail_trace_after_finish_reason():
|
||||
"""Guardrail metadata events carry a trace payload alongside usage; that chunk must still reach the caller
|
||||
after the finish_reason chunk, as it did before the regression."""
|
||||
trace = {"guardrail": {"inputAssessment": {"g1": {}}}}
|
||||
events = (
|
||||
{"role": "assistant"},
|
||||
{"contentBlockIndex": 0, "delta": {"text": "Hello"}},
|
||||
{"contentBlockIndex": 0},
|
||||
{"stopReason": "end_turn"},
|
||||
{**CONVERSE_METADATA_EVENT, "trace": trace},
|
||||
)
|
||||
wrapper = _converse_stream_wrapper(events)
|
||||
|
||||
chunks = [chunk async for chunk in wrapper]
|
||||
|
||||
finish_reasons = [choice.finish_reason for chunk in chunks for choice in chunk.choices if choice.finish_reason]
|
||||
assert finish_reasons == ["stop"]
|
||||
assert chunks[-1].provider_specific_fields == {"trace": trace}
|
||||
assert chunks[-1].choices[0].delta.content == ""
|
||||
assert chunks[-1].choices[0].delta.role == "assistant"
|
||||
|
||||
|
||||
def test_invoke_streaming_forwards_bedrock_response_headers():
|
||||
response = MagicMock()
|
||||
response.status_code = 200
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ from litellm.llms.custom_httpx.llm_http_handler import (
|
|||
_has_pre_call_deployment_hook,
|
||||
_rust_responses_websocket_enabled,
|
||||
)
|
||||
from litellm.llms.azure.videos.transformation import AzureVideoConfig
|
||||
from litellm.llms.openai.videos.transformation import OpenAIVideoConfig
|
||||
from litellm.types.llms.openai import ResponsesAPIResponse
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import TranscriptionResponse
|
||||
|
|
@ -2524,3 +2526,132 @@ def test_only_callbacks_that_can_charge_a_frame_are_collected_for_ws_quota(monke
|
|||
|
||||
monkeypatch.setattr(litellm, "callbacks", [plain, quota, decoy])
|
||||
assert _collect_ws_project_quota_callbacks() == (quota,)
|
||||
|
||||
|
||||
class _JSONBodyVideoConfig(OpenAIVideoConfig):
|
||||
def use_multipart_form_data(self) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _video_create_call_kwargs(config, **optional_params):
|
||||
return {
|
||||
"model": "sora-2",
|
||||
"prompt": "a cat surfing",
|
||||
"video_generation_provider_config": config,
|
||||
"video_generation_optional_request_params": {"seconds": "4", **optional_params},
|
||||
"custom_llm_provider": "openai",
|
||||
"litellm_params": GenericLiteLLMParams(api_key="sk-test", api_base="https://video.example/v1"),
|
||||
"logging_obj": Mock(),
|
||||
"timeout": 10.0,
|
||||
}
|
||||
|
||||
|
||||
def _capture_video_create_request(captured):
|
||||
def respond(request):
|
||||
captured["content_type"] = request.headers.get("content-type")
|
||||
captured["body"] = request.content
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"id": "video_123", "object": "video", "status": "queued", "created_at": 1712697600, "model": "sora-2"},
|
||||
)
|
||||
|
||||
return respond
|
||||
|
||||
|
||||
def _multipart_text_fields(content_type: str, body: bytes) -> dict:
|
||||
boundary = content_type.split("boundary=")[1].encode()
|
||||
return {
|
||||
part.split(b'name="')[1].split(b'"')[0].decode(): part.partition(b"\r\n\r\n")[2].rstrip(b"\r\n-").decode()
|
||||
for part in body.split(b"--" + boundary)
|
||||
if b'name="' in part and b"filename=" not in part
|
||||
}
|
||||
|
||||
|
||||
def test_video_generation_without_file_sends_multipart_form_data():
|
||||
"""Regression for #36493: the OpenAI SDK always sends /videos requests as
|
||||
multipart/form-data, so OpenAI-compatible backends (SGLang Diffusion,
|
||||
vLLM-Omni) reject the JSON body LiteLLM used to send when no
|
||||
input_reference file was attached."""
|
||||
captured = {}
|
||||
client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_video_create_request(captured))))
|
||||
|
||||
result = BaseLLMHTTPHandler().video_generation_handler(client=client, **_video_create_call_kwargs(OpenAIVideoConfig()))
|
||||
|
||||
assert captured["content_type"].startswith("multipart/form-data")
|
||||
assert _multipart_text_fields(captured["content_type"], captured["body"]) == {
|
||||
"model": "sora-2",
|
||||
"prompt": "a cat surfing",
|
||||
"seconds": "4",
|
||||
}
|
||||
assert result.status == "queued"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_video_generation_without_file_sends_multipart_form_data():
|
||||
captured = {}
|
||||
client = AsyncHTTPHandler()
|
||||
client.client = httpx.AsyncClient(transport=httpx.MockTransport(_capture_video_create_request(captured)))
|
||||
|
||||
result = await BaseLLMHTTPHandler().async_video_generation_handler(
|
||||
client=client, **_video_create_call_kwargs(OpenAIVideoConfig())
|
||||
)
|
||||
|
||||
assert captured["content_type"].startswith("multipart/form-data")
|
||||
assert _multipart_text_fields(captured["content_type"], captured["body"]) == {
|
||||
"model": "sora-2",
|
||||
"prompt": "a cat surfing",
|
||||
"seconds": "4",
|
||||
}
|
||||
assert result.status == "queued"
|
||||
|
||||
|
||||
def test_azure_video_generation_without_file_sends_multipart_form_data():
|
||||
"""AzureVideoConfig subclasses OpenAIVideoConfig, so it inherits the
|
||||
file-less multipart behavior. Azure's /openai/v1/videos surface is
|
||||
OpenAI-SDK-compatible (the SDK sends multipart there too), so this is
|
||||
intentional; lock it so the inherited flip can't silently regress to JSON."""
|
||||
assert AzureVideoConfig().use_multipart_form_data() is True
|
||||
|
||||
captured = {}
|
||||
client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_video_create_request(captured))))
|
||||
|
||||
result = BaseLLMHTTPHandler().video_generation_handler(client=client, **_video_create_call_kwargs(AzureVideoConfig()))
|
||||
|
||||
assert captured["content_type"].startswith("multipart/form-data")
|
||||
assert _multipart_text_fields(captured["content_type"], captured["body"]) == {
|
||||
"model": "sora-2",
|
||||
"prompt": "a cat surfing",
|
||||
"seconds": "4",
|
||||
}
|
||||
assert result.status == "queued"
|
||||
|
||||
|
||||
def test_video_generation_json_provider_keeps_json_body():
|
||||
captured = {}
|
||||
client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_video_create_request(captured))))
|
||||
|
||||
result = BaseLLMHTTPHandler().video_generation_handler(client=client, **_video_create_call_kwargs(_JSONBodyVideoConfig()))
|
||||
|
||||
assert captured["content_type"] == "application/json"
|
||||
assert json.loads(captured["body"]) == {"model": "sora-2", "prompt": "a cat surfing", "seconds": "4"}
|
||||
assert result.status == "queued"
|
||||
|
||||
|
||||
def test_video_generation_with_input_reference_keeps_file_multipart():
|
||||
captured = {}
|
||||
client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(_capture_video_create_request(captured))))
|
||||
|
||||
result = BaseLLMHTTPHandler().video_generation_handler(
|
||||
client=client,
|
||||
**_video_create_call_kwargs(OpenAIVideoConfig(), input_reference=b"\x89PNG\r\n\x1a\nfakepng"),
|
||||
)
|
||||
|
||||
assert captured["content_type"].startswith("multipart/form-data")
|
||||
assert b'name="input_reference"' in captured["body"]
|
||||
assert b'filename="input_reference.png"' in captured["body"]
|
||||
assert _multipart_text_fields(captured["content_type"], captured["body"]) == {
|
||||
"model": "sora-2",
|
||||
"prompt": "a cat surfing",
|
||||
"seconds": "4",
|
||||
}
|
||||
assert result.status == "queued"
|
||||
|
|
|
|||
|
|
@ -473,12 +473,14 @@ def test_transform_messages_helper_strips_thinking_blocks():
|
|||
"thinking_blocks": [
|
||||
{"type": "thinking", "thinking": "internal", "signature": ""}
|
||||
],
|
||||
"reasoning_content": "internal",
|
||||
},
|
||||
]
|
||||
out = config._transform_messages_helper(
|
||||
messages, model="accounts/fireworks/models/glm-5p1", litellm_params={}
|
||||
)
|
||||
assert "thinking_blocks" not in out[1]
|
||||
assert "reasoning_content" not in out[1]
|
||||
assert out[1]["content"] == "I can help."
|
||||
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue