Merge remote-tracking branch 'berri/litellm_internal_staging' into litellm_bedrock_realtime_staging

This commit is contained in:
mubashir1osmani 2026-08-24 16:47:07 -04:00
commit 29cbca813a
124 changed files with 5699 additions and 377 deletions

View file

@ -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

View file

@ -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

View file

@ -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: {}

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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 (

View file

@ -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,

View 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)

View file

@ -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,

View file

@ -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,

View file

@ -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()

View file

@ -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,
)

View file

@ -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

View file

@ -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]:

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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", "{}"))

View file

@ -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)

View file

@ -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.

View file

@ -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:

View file

@ -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",
}
)

View file

@ -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(

View file

@ -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

View file

@ -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,
}

View file

@ -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,
}

View file

@ -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",
}

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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

View file

@ -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,
)

View file

@ -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")

View file

@ -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")

View file

@ -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

View file

@ -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:

View file

@ -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 = []

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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.")

View file

@ -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.

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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(

View file

@ -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")

View file

@ -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 {}

View file

@ -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

View file

@ -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)-",

View file

@ -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)

View file

@ -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,

View file

@ -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(

View file

@ -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,

View file

@ -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:

View file

@ -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

View file

@ -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")

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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):

View file

@ -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)-",

View file

@ -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"
},

View file

@ -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
},

View file

@ -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",
]

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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}")

View file

@ -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"},

View 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)}"
)

View file

@ -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(

View file

@ -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"

View file

@ -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

View file

@ -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"

View 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

View 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]

View file

@ -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

View file

@ -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",
)

View file

@ -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

View file

@ -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

View file

@ -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."""

View file

@ -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"}

View file

@ -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.

View file

@ -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(

View file

@ -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"]

View file

@ -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.

View file

@ -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")

View file

@ -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"

View file

@ -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"}

View file

@ -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"

View 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

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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