mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
Merge remote-tracking branch 'upstream/litellm_internal_staging' into litellm_anthropic_wif_oidc_28607
# Conflicts: # litellm/llms/anthropic/chat/transformation.py # tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_reasoning_effort_translation.py # tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py
This commit is contained in:
commit
0602670bee
299 changed files with 10509 additions and 1214 deletions
31
.github/workflows/image-scan.yml
vendored
31
.github/workflows/image-scan.yml
vendored
|
|
@ -23,6 +23,8 @@ on:
|
|||
- tests/proxy_migration_tests/**
|
||||
- uv.lock
|
||||
- ui/litellm-dashboard/package-lock.json
|
||||
- ui/Dockerfile
|
||||
- ui/nginx.conf
|
||||
- .github/workflows/image-scan.yml
|
||||
schedule:
|
||||
- cron: "41 6 * * *"
|
||||
|
|
@ -185,6 +187,35 @@ jobs:
|
|||
python -m pip install "pytest==9.0.3"
|
||||
python -m pytest tests/proxy_migration_tests/test_component_image_serves_offline.py -v
|
||||
|
||||
ui-image:
|
||||
name: ui-image
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Build UI image
|
||||
run: docker build -f ui/Dockerfile -t litellm-ui-scan:${{ github.sha }} .
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Verify the UI serves offline as an arbitrary uid with a read-only root fs
|
||||
env:
|
||||
LITELLM_IMAGE: litellm-ui-scan:${{ github.sha }}
|
||||
run: |
|
||||
python -m pip install "pytest==9.0.3"
|
||||
python -m pytest tests/proxy_migration_tests/test_ui_image_serves_offline.py -v
|
||||
|
||||
backend-image:
|
||||
name: backend-image
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
|
|
@ -428,9 +428,11 @@ ui:
|
|||
maxUnavailable: ""
|
||||
podAnnotations: {}
|
||||
# Same shape as the gateway blocks of the same name. The nginx runtime
|
||||
# writes its pid, cache, and proxy temp files under the image's root
|
||||
# filesystem, so `securityContext.readOnlyRootFilesystem: true` here needs
|
||||
# emptyDir volumes mounted over those paths.
|
||||
# writes its pid, cache, and proxy temp files under /tmp, so it boots as
|
||||
# any (arbitrary, non-root) uid; `securityContext.readOnlyRootFilesystem:
|
||||
# true` here needs an emptyDir volume mounted over /tmp. Images before
|
||||
# the /tmp move instead need emptyDirs over /var/cache/nginx and /run to
|
||||
# run as a non-root uid at all.
|
||||
podLabels: {}
|
||||
podSecurityContext: {}
|
||||
securityContext: {}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ Custom A2A Card Resolver for LiteLLM.
|
|||
Extends the A2A SDK's card resolver to support multiple well-known paths.
|
||||
"""
|
||||
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -48,6 +49,43 @@ def is_localhost_or_internal_url(url: str | None) -> bool:
|
|||
return any(pattern in url_lower for pattern in LOCALHOST_URL_PATTERNS)
|
||||
|
||||
|
||||
_CANONICAL_PROTOCOL_BINDINGS: Final = MappingProxyType(
|
||||
{
|
||||
"jsonrpc": "JSONRPC",
|
||||
"http+json": "HTTP+JSON",
|
||||
"grpc": "GRPC",
|
||||
}
|
||||
)
|
||||
|
||||
_LEGACY_PROTOCOL_VERSION: Final = "0.3"
|
||||
|
||||
|
||||
def normalize_agent_card_interfaces(agent_card: "AgentCard") -> "AgentCard":
|
||||
"""
|
||||
Canonicalize the supported interfaces of spec-adjacent agent cards.
|
||||
|
||||
Some A2A servers (e.g. LangGraph Platform) serve agent cards with lowercase
|
||||
bindings like "jsonrpc", but a2a-sdk's ClientFactory matches bindings
|
||||
case-sensitively against its uppercase TransportProtocol constants and fails
|
||||
with "no compatible transports found." for spec-adjacent casings.
|
||||
|
||||
The same servers also speak the A2A 0.3 JSON dialect ("kind"-discriminated
|
||||
payloads) while declaring protocolVersion "1.0", which a2a-sdk's strict v1
|
||||
proto parsing rejects. A mis-cased binding fingerprints such a server, so its
|
||||
declared version is downgraded to 0.3 to route the SDK's ClientFactory onto
|
||||
its v0.3 compat transport, which speaks that dialect.
|
||||
"""
|
||||
normalized: Final = type(agent_card)()
|
||||
normalized.CopyFrom(agent_card)
|
||||
for interface in normalized.supported_interfaces:
|
||||
canonical: str | None = _CANONICAL_PROTOCOL_BINDINGS.get(interface.protocol_binding.lower())
|
||||
if canonical is None or canonical == interface.protocol_binding:
|
||||
continue
|
||||
interface.protocol_binding = canonical
|
||||
interface.protocol_version = _LEGACY_PROTOCOL_VERSION
|
||||
return normalized
|
||||
|
||||
|
||||
def get_agent_card_url(agent_card: "AgentCard") -> str | None:
|
||||
"""Return the agent endpoint URL from the resolved SDK card."""
|
||||
url: Final = getattr(agent_card, "url", None)
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ except ImportError:
|
|||
from litellm.a2a_protocol.card_resolver import (
|
||||
LiteLLMA2ACardResolver,
|
||||
get_agent_card_url,
|
||||
normalize_agent_card_interfaces,
|
||||
)
|
||||
from litellm.a2a_protocol.exception_mapping_utils import (
|
||||
handle_a2a_localhost_retry,
|
||||
|
|
@ -782,13 +783,17 @@ async def create_a2a_client(
|
|||
if extra_headers:
|
||||
verbose_proxy_logger.debug("A2A client created with extra_headers=%s", list(extra_headers.keys()))
|
||||
|
||||
resolver: Final = A2ACardResolver(httpx_client=httpx_client, base_url=base_url)
|
||||
agent_card: Final = normalize_agent_card_interfaces(
|
||||
await resolver.get_agent_card(http_kwargs={"headers": extra_headers} if extra_headers else None)
|
||||
)
|
||||
|
||||
a2a_client: Final = await create_client( # pyright: ignore[reportOptionalCall]
|
||||
base_url,
|
||||
agent_card,
|
||||
client_config=ClientConfig( # pyright: ignore[reportOptionalCall]
|
||||
httpx_client=httpx_client,
|
||||
streaming=streaming,
|
||||
),
|
||||
resolver_http_kwargs={"headers": extra_headers} if extra_headers else None,
|
||||
)
|
||||
# Stash LiteLLM-owned handles on the client so the localhost-retry path can reuse
|
||||
# the configured httpx client and this agent's headers without excavating
|
||||
|
|
@ -799,9 +804,7 @@ async def create_a2a_client(
|
|||
if extra_headers
|
||||
else None
|
||||
)
|
||||
agent_card: Final = getattr(a2a_client, "_card", None)
|
||||
if agent_card is not None:
|
||||
a2a_client._litellm_agent_card = agent_card
|
||||
a2a_client._litellm_agent_card = agent_card
|
||||
|
||||
verbose_logger.info("A2A client created for %s", base_url)
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ from pydantic import BaseModel
|
|||
import litellm
|
||||
from litellm import ModelResponse
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
responses_reasoning_item_from_thinking_blocks,
|
||||
)
|
||||
from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator
|
||||
from litellm.llms.base_llm.bridges.completion_transformation import (
|
||||
CompletionTransformationBridge,
|
||||
|
|
@ -85,6 +88,22 @@ def _get_reasoning_items(
|
|||
return []
|
||||
|
||||
|
||||
def _reasoning_input_items(msg: "AllMessageValues") -> list[dict[str, object]]: # mutable-ok: API message payload
|
||||
"""Reasoning input items for an assistant message.
|
||||
|
||||
Stored reasoning items win because they carry an id the Responses API minted; thinking
|
||||
blocks are the fallback for turns that arrived over another API surface.
|
||||
"""
|
||||
items: Final = _get_reasoning_items(msg)
|
||||
stored: Final = [_reasoning_item_to_response_input(item) for item in items] # mutable-ok: API message payload
|
||||
if stored:
|
||||
return stored
|
||||
raw_blocks: Final = msg.get("thinking_blocks") or ()
|
||||
blocks: Final = cast("Iterable[ChatCompletionThinkingBlock]", raw_blocks) # cast-ok: untyped client json
|
||||
from_thinking: Final = responses_reasoning_item_from_thinking_blocks(blocks)
|
||||
return [] if from_thinking is None else [dict(from_thinking)] # mutable-ok: API message payload
|
||||
|
||||
|
||||
def _build_reasoning_item(
|
||||
item_id: str,
|
||||
encrypted_content: str | None,
|
||||
|
|
@ -372,8 +391,15 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
)
|
||||
)
|
||||
elif role == "assistant" and tool_calls and isinstance(tool_calls, list):
|
||||
for r_item in _get_reasoning_items(msg):
|
||||
input_items.append(_reasoning_item_to_response_input(r_item))
|
||||
input_items.extend(_reasoning_input_items(msg))
|
||||
if content:
|
||||
input_items.append(
|
||||
{ # mutable-ok: API message payload
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": self._convert_content_to_responses_format(content, "assistant"),
|
||||
}
|
||||
)
|
||||
for tool_call in tool_calls:
|
||||
function = tool_call.get("function")
|
||||
custom = tool_call.get("custom")
|
||||
|
|
@ -400,15 +426,16 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
raise ValueError(f"tool call not supported: {tool_call}")
|
||||
elif content is not None:
|
||||
if role == "assistant":
|
||||
for r_item in _get_reasoning_items(msg):
|
||||
input_items.append(_reasoning_item_to_response_input(r_item))
|
||||
input_items.extend(_reasoning_input_items(msg))
|
||||
input_items.append(
|
||||
{
|
||||
{ # mutable-ok: API message payload
|
||||
"type": "message",
|
||||
"role": role,
|
||||
"content": self._convert_content_to_responses_format(content, cast(str, role)),
|
||||
}
|
||||
)
|
||||
elif role == "assistant":
|
||||
input_items.extend(_reasoning_input_items(msg))
|
||||
|
||||
return input_items, instructions
|
||||
|
||||
|
|
|
|||
|
|
@ -1563,6 +1563,19 @@ STALE_OBJECT_CLEANUP_BATCH_SIZE: Final = max(1, int(os.getenv("STALE_OBJECT_CLEA
|
|||
# installations with large numbers of stale managed objects).
|
||||
_batch_polling_env: Final = os.getenv("PROXY_BATCH_POLLING_ENABLED", "true").lower()
|
||||
PROXY_BATCH_POLLING_ENABLED: Final = _batch_polling_env == "true"
|
||||
BACKGROUND_INTERACTION_COST_POLL_INITIAL_INTERVAL_SECONDS: Final = float(
|
||||
os.getenv("BACKGROUND_INTERACTION_COST_POLL_INITIAL_INTERVAL_SECONDS", "5")
|
||||
)
|
||||
BACKGROUND_INTERACTION_COST_POLL_MAX_INTERVAL_SECONDS: Final = float(
|
||||
os.getenv("BACKGROUND_INTERACTION_COST_POLL_MAX_INTERVAL_SECONDS", "60")
|
||||
)
|
||||
BACKGROUND_INTERACTION_COST_POLL_TIMEOUT_SECONDS: Final = float(
|
||||
os.getenv("BACKGROUND_INTERACTION_COST_POLL_TIMEOUT_SECONDS", "3600")
|
||||
)
|
||||
_background_interaction_cost_polling_env: Final = os.getenv(
|
||||
"BACKGROUND_INTERACTION_COST_POLLING_ENABLED", "true"
|
||||
).lower()
|
||||
BACKGROUND_INTERACTION_COST_POLLING_ENABLED: Final = _background_interaction_cost_polling_env == "true"
|
||||
PROXY_BUDGET_RESCHEDULER_MAX_TIME: Final = int(os.getenv("PROXY_BUDGET_RESCHEDULER_MAX_TIME", 605))
|
||||
PROXY_BATCH_WRITE_AT: Final = int(os.getenv("PROXY_BATCH_WRITE_AT", 10)) # in seconds, increased from 10
|
||||
PROXY_CONFIG_RELOAD_INTERVAL_SECONDS: Final = get_env_int("PROXY_CONFIG_RELOAD_INTERVAL_SECONDS", 30)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from litellm.litellm_core_utils.llm_cost_calc.tool_call_cost_tracking import (
|
|||
StandardBuiltInToolCostTracking,
|
||||
)
|
||||
from litellm.litellm_core_utils.llm_cost_calc.usage_object_transformation import (
|
||||
InteractionsUsageObjectTransformation,
|
||||
TranscriptionUsageObjectTransformation,
|
||||
)
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import (
|
||||
|
|
@ -150,6 +151,7 @@ _VIDEO_CALL_TYPES: Final = frozenset(
|
|||
}
|
||||
)
|
||||
|
||||
|
||||
_SPEECH_CALL_TYPES: Final = frozenset(
|
||||
{
|
||||
CallTypes.speech.value,
|
||||
|
|
@ -912,6 +914,8 @@ def _get_usage_object(
|
|||
usage_obj,
|
||||
)
|
||||
)
|
||||
elif isinstance(usage_obj, dict) and InteractionsUsageObjectTransformation.is_interactions_usage_object(usage_obj):
|
||||
return InteractionsUsageObjectTransformation.transform_interactions_usage_object(usage_obj)
|
||||
elif isinstance(usage_obj, dict):
|
||||
return Usage(**usage_obj)
|
||||
elif isinstance(usage_obj, BaseModel):
|
||||
|
|
@ -1288,6 +1292,10 @@ def completion_cost(
|
|||
)
|
||||
if tr_usage is not None:
|
||||
_usage = tr_usage.model_dump()
|
||||
elif InteractionsUsageObjectTransformation.is_interactions_usage_object(_usage):
|
||||
_usage = InteractionsUsageObjectTransformation.transform_interactions_usage_object(
|
||||
_usage
|
||||
).model_dump()
|
||||
else:
|
||||
_usage = _usage
|
||||
|
||||
|
|
@ -1372,23 +1380,36 @@ def completion_cost(
|
|||
if custom_pricing and litellm_logging_obj is not None:
|
||||
_litellm_params = getattr(litellm_logging_obj, "litellm_params", None)
|
||||
if _litellm_params is not None:
|
||||
_metadata = _litellm_params.get("metadata", {}) or {}
|
||||
_video_model_info = _metadata.get("model_info", None)
|
||||
_video_model_info = next(
|
||||
(
|
||||
model_info
|
||||
for _metadata_key in ("metadata", "litellm_metadata")
|
||||
if (model_info := (_litellm_params.get(_metadata_key) or {}).get("model_info"))
|
||||
is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
usage_obj = getattr(completion_response, "usage", None)
|
||||
duration_seconds: float | None = None
|
||||
video_resolution: str | None = None
|
||||
provider_reported_cost: float | None = None
|
||||
if completion_response is not None and usage_obj:
|
||||
# Handle both dict and Pydantic Usage object
|
||||
if isinstance(usage_obj, dict):
|
||||
duration_seconds = usage_obj.get("duration_seconds", None)
|
||||
_vr = usage_obj.get("video_resolution", None)
|
||||
provider_reported_cost = usage_obj.get("provider_reported_cost_usd", None)
|
||||
else:
|
||||
duration_seconds = getattr(usage_obj, "duration_seconds", None)
|
||||
_vr = getattr(usage_obj, "video_resolution", None)
|
||||
provider_reported_cost = getattr(usage_obj, "provider_reported_cost_usd", None)
|
||||
if _vr is not None:
|
||||
video_resolution = str(_vr).strip().lower()
|
||||
|
||||
if _video_model_info is None and provider_reported_cost is not None:
|
||||
return float(provider_reported_cost)
|
||||
|
||||
if duration_seconds is not None:
|
||||
# Calculate cost based on video duration using video-specific cost calculation
|
||||
from litellm.llms.openai.cost_calculation import (
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from litellm.constants import request_timeout as DEFAULT_REQUEST_TIMEOUT
|
|||
from litellm.exceptions import LiteLLMUnknownProvider
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.litellm_core_utils.llm_request_utils import flatten_form_field_values
|
||||
from litellm.litellm_core_utils.mock_functions import mock_image_generation
|
||||
from litellm.llms.base_llm import BaseImageEditConfig, BaseImageGenerationConfig
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
|
||||
|
|
@ -422,24 +423,32 @@ def image_generation(
|
|||
aimg_generation=aimg_generation,
|
||||
)
|
||||
elif custom_llm_provider == "azure_ai":
|
||||
from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo
|
||||
from litellm.llms.azure_ai.common_utils import (
|
||||
AzureFoundryModelInfo,
|
||||
get_azure_ai_auth_headers,
|
||||
)
|
||||
|
||||
api_base = AzureFoundryModelInfo.get_api_base(api_base)
|
||||
api_key = AzureFoundryModelInfo.get_api_key(api_key)
|
||||
if extra_headers is not None:
|
||||
optional_params["extra_headers"] = extra_headers
|
||||
|
||||
default_headers = {
|
||||
caller_header_names = frozenset(name.lower() for name in headers)
|
||||
caller_set_auth = "api-key" in caller_header_names or "authorization" in caller_header_names
|
||||
auth_headers = (
|
||||
headers
|
||||
if caller_set_auth
|
||||
else get_azure_ai_auth_headers(
|
||||
api_key=api_key,
|
||||
litellm_params=litellm_params_dict,
|
||||
api_key_header="api-key",
|
||||
)
|
||||
)
|
||||
request_headers: Final = {
|
||||
"Content-Type": "application/json",
|
||||
**auth_headers,
|
||||
**headers,
|
||||
}
|
||||
# Only add api-key header if api_key is not None
|
||||
# Azure AD authentication will use Authorization header instead
|
||||
if api_key is not None:
|
||||
default_headers["api-key"] = api_key
|
||||
|
||||
for k, v in default_headers.items():
|
||||
if k not in headers:
|
||||
headers[k] = v
|
||||
|
||||
model_response = azure_chat_completions.image_generation(
|
||||
model=model,
|
||||
|
|
@ -455,7 +464,7 @@ def image_generation(
|
|||
api_version=api_version,
|
||||
aimg_generation=aimg_generation,
|
||||
client=client,
|
||||
headers=headers,
|
||||
headers=request_headers,
|
||||
litellm_params=litellm_params_dict,
|
||||
)
|
||||
elif (
|
||||
|
|
@ -846,6 +855,18 @@ def image_edit(
|
|||
additional_drop_params=kwargs.get("additional_drop_params"),
|
||||
)
|
||||
|
||||
if (
|
||||
custom_llm_provider == "openai"
|
||||
or custom_llm_provider == "azure"
|
||||
or custom_llm_provider in litellm.openai_compatible_providers
|
||||
):
|
||||
image_edit_request_params.update(
|
||||
flatten_form_field_values(
|
||||
non_default_params,
|
||||
extra_body if isinstance(extra_body, dict) else None,
|
||||
)
|
||||
)
|
||||
|
||||
# Pre Call logging
|
||||
litellm_logging_obj.update_from_kwargs(
|
||||
kwargs=kwargs,
|
||||
|
|
@ -995,6 +1016,9 @@ async def aimage_edit(
|
|||
response_format=response_format,
|
||||
size=size,
|
||||
user=user,
|
||||
extra_headers=extra_headers,
|
||||
extra_query=extra_query,
|
||||
extra_body=extra_body,
|
||||
timeout=timeout,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
**kwargs,
|
||||
|
|
|
|||
313
litellm/interactions/background_cost_polling.py
Normal file
313
litellm/interactions/background_cost_polling.py
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
"""
|
||||
Cost tracking for background interactions.
|
||||
|
||||
A create request with ``background=true`` returns ``in_progress`` with no
|
||||
usage block, and GET polls are deliberately never billed (billing them would
|
||||
double-charge every poll; the GET response also does not echo ``background``,
|
||||
so a poll cannot be told apart from a re-fetch of an already-billed
|
||||
interaction). The create call is therefore the only place that can own
|
||||
billing: it schedules a poll task that fetches the interaction until it
|
||||
reaches a terminal status and logs the final usage as a single success event
|
||||
attributed to the original request.
|
||||
|
||||
``requires_action`` is terminal for the interaction it names. The API has no
|
||||
operation that resumes one: a caller answers a tool request by creating a new
|
||||
interaction whose ``previous_interaction_id`` points at it, and that new
|
||||
interaction bills itself. The paused interaction keeps the tokens it already
|
||||
spent producing the tool request, so it is billed and settled where it stops
|
||||
rather than polled until the timeout, which would both lose that usage and
|
||||
hold its budget reservation open for the whole timeout window.
|
||||
|
||||
Deleting an interaction makes every subsequent poll fail, which would let a
|
||||
caller retrieve the completed output themselves and then delete it before the
|
||||
poll task settles, leaving the work unbilled and the budget reservation
|
||||
refunded at the poll timeout. ``adelete`` therefore settles any pending poll
|
||||
for the interaction before dispatching the delete: it fetches the current
|
||||
state with the create's credentials, bills it if it is terminal with usage,
|
||||
and releases the reservation otherwise. A settlement gate on the create's
|
||||
logging object makes the poll task and the delete path mutually exclusive, so
|
||||
the interaction is billed exactly once no matter who settles first.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable, Iterator, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Final, TypeAlias
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.constants import (
|
||||
BACKGROUND_INTERACTION_COST_POLL_INITIAL_INTERVAL_SECONDS,
|
||||
BACKGROUND_INTERACTION_COST_POLL_MAX_INTERVAL_SECONDS,
|
||||
BACKGROUND_INTERACTION_COST_POLL_TIMEOUT_SECONDS,
|
||||
BACKGROUND_INTERACTION_COST_POLLING_ENABLED,
|
||||
)
|
||||
from litellm.litellm_core_utils.core_helpers import get_litellm_metadata_from_kwargs
|
||||
from litellm.types.interactions import InteractionsAPIResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
|
||||
_TERMINAL_STATUSES: Final = frozenset(
|
||||
{"completed", "failed", "cancelled", "incomplete", "budget_exceeded", "requires_action"}
|
||||
)
|
||||
|
||||
_POLLABLE_STATUSES: Final = frozenset({"in_progress", "queued"})
|
||||
|
||||
_STATUSES_THAT_PRODUCED_OUTPUT: Final = frozenset({"completed", "requires_action"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BackgroundInteractionPollContext:
|
||||
interaction_id: str
|
||||
custom_llm_provider: str
|
||||
logging_obj: "LiteLLMLoggingObj"
|
||||
api_key: str | None = None
|
||||
api_base: str | None = None
|
||||
initial_interval_seconds: float = BACKGROUND_INTERACTION_COST_POLL_INITIAL_INTERVAL_SECONDS
|
||||
max_interval_seconds: float = BACKGROUND_INTERACTION_COST_POLL_MAX_INTERVAL_SECONDS
|
||||
timeout_seconds: float = BACKGROUND_INTERACTION_COST_POLL_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
FetchInteraction: TypeAlias = Callable[[BackgroundInteractionPollContext], Awaitable[InteractionsAPIResponse]]
|
||||
|
||||
|
||||
async def _fetch_interaction(context: BackgroundInteractionPollContext) -> InteractionsAPIResponse:
|
||||
from litellm.interactions import aget
|
||||
|
||||
return await aget(
|
||||
interaction_id=context.interaction_id,
|
||||
custom_llm_provider=context.custom_llm_provider,
|
||||
api_key=context.api_key,
|
||||
api_base=context.api_base,
|
||||
**{
|
||||
"no-log": True
|
||||
}, # mutable-ok: "no-log" is not a valid identifier, so it can only be passed through a mapping
|
||||
)
|
||||
|
||||
|
||||
def _poll_intervals(initial: float, maximum: float, timeout: float) -> Iterator[float]:
|
||||
elapsed = 0.0
|
||||
interval = initial
|
||||
while interval > 0 and elapsed + interval <= timeout:
|
||||
yield interval
|
||||
elapsed += interval
|
||||
interval = min(interval * 2, maximum)
|
||||
|
||||
|
||||
_SETTLED_KEY = "background_interaction_settled"
|
||||
|
||||
|
||||
def _is_settled(logging_obj: "LiteLLMLoggingObj") -> bool:
|
||||
return logging_obj.model_call_details.get(_SETTLED_KEY) is True
|
||||
|
||||
|
||||
def _claim_settlement(logging_obj: "LiteLLMLoggingObj") -> bool:
|
||||
"""
|
||||
Exactly-once gate between the poll task and the delete-time settlement:
|
||||
both run on the same event loop and neither awaits between reading and
|
||||
setting the flag, so whichever claims first owns billing or release.
|
||||
"""
|
||||
if _is_settled(logging_obj):
|
||||
return False
|
||||
logging_obj.model_call_details[_SETTLED_KEY] = True # rebind-ok: both settlers must see the same settlement flag
|
||||
return True
|
||||
|
||||
|
||||
async def poll_and_log_background_interaction_cost(
|
||||
context: BackgroundInteractionPollContext,
|
||||
fetch_interaction: FetchInteraction = _fetch_interaction,
|
||||
) -> None:
|
||||
last_seen_status: str | None = None
|
||||
for interval in _poll_intervals(
|
||||
initial=context.initial_interval_seconds,
|
||||
maximum=context.max_interval_seconds,
|
||||
timeout=context.timeout_seconds,
|
||||
):
|
||||
await asyncio.sleep(interval)
|
||||
if _is_settled(context.logging_obj):
|
||||
return
|
||||
try:
|
||||
response = await fetch_interaction(context)
|
||||
except Exception as e: # noqa: BLE001 # any fetch error must not kill the billing poll loop
|
||||
verbose_logger.debug(
|
||||
"Background interaction cost poll for %s failed, will retry: %s",
|
||||
context.interaction_id,
|
||||
e,
|
||||
)
|
||||
continue
|
||||
last_seen_status = response.status
|
||||
if response.status not in _TERMINAL_STATUSES:
|
||||
continue
|
||||
if not _claim_settlement(context.logging_obj):
|
||||
return
|
||||
if response.usage is not None:
|
||||
await _bill_settled_interaction(logging_obj=context.logging_obj, response=response)
|
||||
else:
|
||||
await _release_open_budget_reservation(logging_obj=context.logging_obj)
|
||||
return
|
||||
if not _claim_settlement(context.logging_obj):
|
||||
return
|
||||
if last_seen_status is not None and last_seen_status not in _POLLABLE_STATUSES:
|
||||
verbose_logger.error(
|
||||
"Gave up cost polling for background interaction %s after %ss: its last status %r is in neither "
|
||||
"the pollable nor the terminal set, so this proxy never learned how to settle it and its usage "
|
||||
"will not be tracked",
|
||||
context.interaction_id,
|
||||
context.timeout_seconds,
|
||||
last_seen_status,
|
||||
)
|
||||
else:
|
||||
verbose_logger.warning(
|
||||
"Gave up cost polling for background interaction %s after %ss; its usage will not be tracked",
|
||||
context.interaction_id,
|
||||
context.timeout_seconds,
|
||||
)
|
||||
await _release_open_budget_reservation(logging_obj=context.logging_obj)
|
||||
|
||||
|
||||
async def _release_open_budget_reservation(logging_obj: "LiteLLMLoggingObj") -> None:
|
||||
"""
|
||||
The proxy keeps the pre-call budget reservation open for an in-progress
|
||||
background interaction so concurrent creates cannot stack past the budget.
|
||||
The completion success event reconciles it to the actual cost; when the
|
||||
interaction terminates without billable usage (or polling gives up, or it
|
||||
is deleted before settling), no such event fires, so whoever claims the
|
||||
settlement must release the reservation here or the spend counters stay
|
||||
pinned at the estimated cost.
|
||||
"""
|
||||
metadata = get_litellm_metadata_from_kwargs(kwargs=logging_obj.model_call_details)
|
||||
budget_reservation = metadata.get("user_api_key_budget_reservation")
|
||||
if not isinstance(budget_reservation, dict):
|
||||
return
|
||||
|
||||
from litellm.proxy.spend_tracking.budget_reservation import release_budget_reservation
|
||||
|
||||
try:
|
||||
await release_budget_reservation(budget_reservation=budget_reservation)
|
||||
except Exception: # noqa: BLE001 # a failed release must not crash the poll task; counters expire via TTL
|
||||
verbose_logger.exception("Failed to release budget reservation for an unbilled background interaction")
|
||||
|
||||
|
||||
async def _bill_settled_interaction(logging_obj: "LiteLLMLoggingObj", response: InteractionsAPIResponse) -> None:
|
||||
"""
|
||||
Claiming the settlement makes the claimer solely responsible for the
|
||||
reservation, and no one retries a claim that is already set. A billing
|
||||
failure here must therefore release the reservation on its way out, or it
|
||||
stays pinned at the estimated cost until the whole poll times out.
|
||||
"""
|
||||
try:
|
||||
await logging_obj.async_log_background_interaction_completion(result=response)
|
||||
except Exception:
|
||||
await _release_open_budget_reservation(logging_obj=logging_obj)
|
||||
raise
|
||||
|
||||
|
||||
def is_pollable_background_interaction(response: InteractionsAPIResponse) -> bool:
|
||||
"""
|
||||
The single gate deciding whether a create's response gets a poll task.
|
||||
The proxy's success callback defers releasing the budget reservation for
|
||||
exactly these responses, on the promise that a poll task will settle them,
|
||||
so a response one site accepts and the other refuses strands its
|
||||
reservation on the spend counters with nothing left to reconcile it.
|
||||
|
||||
``queued`` belongs here alongside ``in_progress``. It is the API's
|
||||
not-started-yet state, so it reaches a terminal status the same way and
|
||||
needs polling for the same reason: nothing else in the proxy ever bills a
|
||||
create that came back without usage, so a status missing from both this
|
||||
set and ``_TERMINAL_STATUSES`` is billed nowhere and alerts nobody.
|
||||
"""
|
||||
return response.status in _POLLABLE_STATUSES and bool(response.id)
|
||||
|
||||
|
||||
def missing_usage_is_expected(response: InteractionsAPIResponse) -> bool:
|
||||
"""
|
||||
Whether a response arriving with no usage block is a normal outcome rather
|
||||
than lost billing data. An interaction that is still running, or that
|
||||
stopped at ``failed``, ``cancelled``, ``incomplete`` or ``budget_exceeded``,
|
||||
has nothing to charge for and should not raise a cost-tracking alarm.
|
||||
|
||||
``completed`` and ``requires_action`` both mean the model produced output,
|
||||
so a usage block is always expected with them. If one arrives without it
|
||||
the charge for real work has been lost, which is precisely what the
|
||||
proxy's cost-tracking alert exists to surface.
|
||||
"""
|
||||
return response.status not in _STATUSES_THAT_PRODUCED_OUTPUT
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ActiveBackgroundPoll:
|
||||
task: "asyncio.Task[None]"
|
||||
context: BackgroundInteractionPollContext
|
||||
|
||||
|
||||
_ACTIVE_POLLS: dict[str, _ActiveBackgroundPoll] = {} # mutable-ok: asyncio needs strong refs to running poll tasks
|
||||
|
||||
|
||||
def _discard_poll(interaction_id: str, task: "asyncio.Task[None]") -> None:
|
||||
entry = _ACTIVE_POLLS.get(interaction_id)
|
||||
if entry is not None and entry.task is task:
|
||||
del _ACTIVE_POLLS[interaction_id]
|
||||
|
||||
|
||||
def maybe_schedule_background_interaction_cost_polling(
|
||||
response: object,
|
||||
create_kwargs: Mapping[str, object],
|
||||
custom_llm_provider: str,
|
||||
) -> "asyncio.Task[None] | None":
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
|
||||
if not BACKGROUND_INTERACTION_COST_POLLING_ENABLED:
|
||||
return None
|
||||
if not isinstance(response, InteractionsAPIResponse):
|
||||
return None
|
||||
if not is_pollable_background_interaction(response):
|
||||
return None
|
||||
logging_obj = create_kwargs.get("litellm_logging_obj")
|
||||
if not isinstance(logging_obj, Logging):
|
||||
return None
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return None
|
||||
api_key = create_kwargs.get("api_key")
|
||||
api_base = create_kwargs.get("api_base")
|
||||
context = BackgroundInteractionPollContext(
|
||||
interaction_id=response.id,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
logging_obj=logging_obj,
|
||||
api_key=api_key if isinstance(api_key, str) else None,
|
||||
api_base=api_base if isinstance(api_base, str) else None,
|
||||
)
|
||||
task = asyncio.create_task(poll_and_log_background_interaction_cost(context))
|
||||
_ACTIVE_POLLS[context.interaction_id] = _ActiveBackgroundPoll(task=task, context=context)
|
||||
task.add_done_callback(
|
||||
lambda finished, interaction_id=context.interaction_id: _discard_poll(interaction_id, finished)
|
||||
)
|
||||
return task
|
||||
|
||||
|
||||
async def maybe_settle_background_interaction_before_delete(
|
||||
interaction_id: str,
|
||||
fetch_interaction: FetchInteraction = _fetch_interaction,
|
||||
) -> None:
|
||||
entry = _ACTIVE_POLLS.get(interaction_id)
|
||||
if entry is None:
|
||||
return
|
||||
context = entry.context
|
||||
try:
|
||||
response = await fetch_interaction(context)
|
||||
except Exception as e: # noqa: BLE001 # unfetchable pre-delete state settles by releasing the reservation
|
||||
verbose_logger.debug(
|
||||
"Could not fetch background interaction %s before delete, releasing its reservation: %s",
|
||||
interaction_id,
|
||||
e,
|
||||
)
|
||||
if _claim_settlement(context.logging_obj):
|
||||
await _release_open_budget_reservation(logging_obj=context.logging_obj)
|
||||
return
|
||||
if not _claim_settlement(context.logging_obj):
|
||||
return
|
||||
if response.status in _TERMINAL_STATUSES and response.usage is not None:
|
||||
await _bill_settled_interaction(logging_obj=context.logging_obj, response=response)
|
||||
return
|
||||
await _release_open_budget_reservation(logging_obj=context.logging_obj)
|
||||
|
|
@ -40,6 +40,10 @@ from typing import Any, Final
|
|||
import httpx
|
||||
|
||||
import litellm
|
||||
from litellm.interactions.background_cost_polling import (
|
||||
maybe_schedule_background_interaction_cost_polling,
|
||||
maybe_settle_background_interaction_before_delete,
|
||||
)
|
||||
from litellm.interactions.http_handler import interactions_http_handler
|
||||
from litellm.interactions.utils import (
|
||||
InteractionsAPIRequestUtils,
|
||||
|
|
@ -171,6 +175,12 @@ async def acreate(
|
|||
else:
|
||||
response = init_response
|
||||
|
||||
maybe_schedule_background_interaction_cost_polling(
|
||||
response=response,
|
||||
create_kwargs=kwargs,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
)
|
||||
|
||||
return response
|
||||
except Exception as e:
|
||||
raise litellm.exception_type(
|
||||
|
|
@ -462,6 +472,8 @@ async def adelete(
|
|||
loop: Final = asyncio.get_event_loop()
|
||||
kwargs["adelete_interaction"] = True
|
||||
|
||||
await maybe_settle_background_interaction_before_delete(interaction_id=interaction_id)
|
||||
|
||||
func: Final = partial(
|
||||
delete,
|
||||
interaction_id=interaction_id,
|
||||
|
|
|
|||
|
|
@ -2301,6 +2301,7 @@ def exception_type(
|
|||
or custom_llm_provider == "custom_openai"
|
||||
or custom_llm_provider in litellm.openai_compatible_providers
|
||||
or custom_llm_provider == "mistral"
|
||||
or custom_llm_provider == "runwayml"
|
||||
):
|
||||
_map_openai_exception(
|
||||
model=model,
|
||||
|
|
|
|||
|
|
@ -71,6 +71,9 @@ from litellm.litellm_core_utils.llm_cost_calc.guardrail_cost import (
|
|||
from litellm.litellm_core_utils.llm_cost_calc.tool_call_cost_tracking import (
|
||||
StandardBuiltInToolCostTracking,
|
||||
)
|
||||
from litellm.litellm_core_utils.llm_cost_calc.usage_object_transformation import (
|
||||
InteractionsUsageObjectTransformation,
|
||||
)
|
||||
from litellm.litellm_core_utils.logging_utils import truncate_base64_in_messages
|
||||
from litellm.litellm_core_utils.model_param_helper import ModelParamHelper
|
||||
from litellm.litellm_core_utils.redact_messages import (
|
||||
|
|
@ -83,6 +86,10 @@ from litellm.llms.base_llm.search.transformation import SearchResponse
|
|||
from litellm.responses.utils import ResponseAPILoggingUtils
|
||||
from litellm.types.agents import LiteLLMSendMessageResponse
|
||||
from litellm.types.containers.main import ContainerObject
|
||||
from litellm.types.interactions import (
|
||||
InteractionsAPIResponse,
|
||||
InteractionsAPIStreamingResponse,
|
||||
)
|
||||
from litellm.types.llms.openai import (
|
||||
AllMessageValues,
|
||||
Batch,
|
||||
|
|
@ -2145,6 +2152,11 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
or isinstance(logging_result, OpenAIModerationResponse)
|
||||
or isinstance(logging_result, OCRResponse) # OCR
|
||||
or isinstance(logging_result, SearchResponse) # Search API
|
||||
or (
|
||||
isinstance(logging_result, InteractionsAPIResponse)
|
||||
and logging_result.usage is not None
|
||||
and self._is_interactions_create_call_type()
|
||||
)
|
||||
or isinstance(logging_result, dict)
|
||||
and logging_result.get("object") == "vector_store.search_results.page"
|
||||
or isinstance(logging_result, dict)
|
||||
|
|
@ -2157,6 +2169,87 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
return True
|
||||
return False
|
||||
|
||||
def _is_interactions_create_call_type(self) -> bool:
|
||||
"""
|
||||
Only interaction creation is billable. GET polls, deletes, and cancels
|
||||
also return an ``InteractionsAPIResponse`` (with usage once completed),
|
||||
so recognizing those would write spend on every poll of a background
|
||||
interaction. The proxy sets ``call_type`` from its route_type
|
||||
(``create_interaction``/``acreate_interaction``); the SDK sets it from
|
||||
the decorated function name (``create``/``acreate``).
|
||||
|
||||
Recognition additionally requires a usage block (checked at the call
|
||||
site): a ``background=true`` create returns ``in_progress`` without
|
||||
usage, and billing it would write a $0 spend log under the interaction
|
||||
id that collides with the row the background poll task writes once the
|
||||
interaction completes (see
|
||||
``litellm.interactions.background_cost_polling``).
|
||||
"""
|
||||
return self.call_type in (
|
||||
CallTypes.create_interaction.value,
|
||||
CallTypes.acreate_interaction.value,
|
||||
"create",
|
||||
"acreate",
|
||||
)
|
||||
|
||||
async def async_log_background_interaction_completion(
|
||||
self,
|
||||
result: InteractionsAPIResponse,
|
||||
) -> None:
|
||||
"""
|
||||
Log the terminal result of a background interaction as a fresh success
|
||||
event. The create request already ran success logging for its
|
||||
``in_progress`` response (no usage, so no cost was tracked); clearing
|
||||
the dedup flags lets the completed result flow through cost calculation
|
||||
and spend tracking exactly once, spanning create to completion.
|
||||
|
||||
The poll fetched this body through its own client call, which priced it
|
||||
against a throwaway logging object holding none of this request's
|
||||
deployment context: no ``model_info``, no router ``model_id``, no
|
||||
deployment ``litellm_params``. Keeping that price would bill a
|
||||
custom-priced deployment at the wrong rate, and it would also satisfy
|
||||
the "already calculated" shortcut and skip repricing here, leaving the
|
||||
cost breakdown at the zeros the usage-less create stamped and writing
|
||||
those zeros to the spend log. Dropping it makes this event price the
|
||||
settled body itself, against the deployment that served the create.
|
||||
|
||||
The same throwaway call stamped the deployment identity that travels
|
||||
with the price, so ``model_id`` and ``litellm_model_name`` go with it.
|
||||
Left in place they overwrite the create's real deployment with the
|
||||
poll's empty one in the payload every logging integration reads.
|
||||
"""
|
||||
settled_hidden_params: Final = getattr(result, "_hidden_params", None)
|
||||
if isinstance(settled_hidden_params, dict):
|
||||
for poll_scoped_key in ("response_cost", "model_id", "litellm_model_name"):
|
||||
settled_hidden_params.pop(poll_scoped_key, None)
|
||||
self._reset_success_emission_dedupe()
|
||||
await self.async_success_handler(result=result)
|
||||
|
||||
def _reset_success_emission_dedupe(self) -> None:
|
||||
"""
|
||||
Success callbacks dedupe per request, because the sync and async
|
||||
handlers both fire on some paths and would otherwise report one call
|
||||
twice. A settled background interaction is a genuinely second success
|
||||
event on the same request, so every such marker has to be cleared or
|
||||
the completion, the only event that carries usage and cost, is
|
||||
discarded as a duplicate of the in-progress create.
|
||||
"""
|
||||
self.model_call_details.pop("has_logged_async_success", None)
|
||||
litellm_params = self.model_call_details.get("litellm_params")
|
||||
if not isinstance(litellm_params, dict):
|
||||
return
|
||||
metadata = litellm_params.get("metadata")
|
||||
if not isinstance(metadata, dict):
|
||||
return
|
||||
otel_internal = metadata.get("_otel_internal")
|
||||
if not isinstance(otel_internal, dict):
|
||||
return
|
||||
spans_logged = otel_internal.get("spans_logged")
|
||||
if not isinstance(spans_logged, dict):
|
||||
return
|
||||
for scope in [key for key in spans_logged if isinstance(key, tuple) and key[-1:] == ("success",)]:
|
||||
del spans_logged[scope]
|
||||
|
||||
def _flush_passthrough_collected_chunks_helper(
|
||||
self,
|
||||
raw_bytes: list[bytes],
|
||||
|
|
@ -2282,7 +2375,9 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
is_sync_request: Final = self._is_sync_litellm_request(litellm_params)
|
||||
try:
|
||||
## BUILD COMPLETE STREAMED RESPONSE
|
||||
complete_streaming_response: ModelResponse | TextCompletionResponse | ResponsesAPIResponse | None = None
|
||||
complete_streaming_response: (
|
||||
ModelResponse | TextCompletionResponse | ResponsesAPIResponse | InteractionsAPIResponse | None
|
||||
) = None
|
||||
if "complete_streaming_response" in self.model_call_details:
|
||||
return # break out of this.
|
||||
complete_streaming_response = self._get_assembled_streaming_response(
|
||||
|
|
@ -2768,14 +2863,14 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
## BUILD COMPLETE STREAMED RESPONSE
|
||||
if "async_complete_streaming_response" in self.model_call_details:
|
||||
return # break out of this.
|
||||
complete_streaming_response: Final[ModelResponse | TextCompletionResponse | ResponsesAPIResponse | None] = (
|
||||
self._get_assembled_streaming_response(
|
||||
result=result,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
is_async=True,
|
||||
streaming_chunks=self.streaming_chunks,
|
||||
)
|
||||
complete_streaming_response: Final[
|
||||
ModelResponse | TextCompletionResponse | ResponsesAPIResponse | InteractionsAPIResponse | None
|
||||
] = self._get_assembled_streaming_response(
|
||||
result=result,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
is_async=True,
|
||||
streaming_chunks=self.streaming_chunks,
|
||||
)
|
||||
|
||||
if complete_streaming_response is not None:
|
||||
|
|
@ -3558,7 +3653,7 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
end_time: datetime.datetime,
|
||||
is_async: bool,
|
||||
streaming_chunks: list[object],
|
||||
) -> ModelResponse | TextCompletionResponse | ResponsesAPIResponse | None:
|
||||
) -> ModelResponse | TextCompletionResponse | ResponsesAPIResponse | InteractionsAPIResponse | None:
|
||||
if self.stream is not True:
|
||||
return None
|
||||
if isinstance(result, ModelResponse) or isinstance(result, TextCompletionResponse):
|
||||
|
|
@ -3583,9 +3678,40 @@ class Logging(LiteLLMLoggingBaseClass):
|
|||
),
|
||||
)
|
||||
return result.response
|
||||
elif isinstance(result, InteractionsAPIStreamingResponse):
|
||||
return self._assemble_completed_interaction_response(result)
|
||||
else:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _assemble_completed_interaction_response(
|
||||
result: InteractionsAPIStreamingResponse,
|
||||
) -> InteractionsAPIResponse | None:
|
||||
"""
|
||||
The Interactions API streaming iterator hands the terminal event to the
|
||||
success handlers: the new schema (Api-Revision: 2026-05-20) emits
|
||||
``interaction.completed`` carrying the full interaction object, the
|
||||
legacy schema (2026-05-07) emits a chunk with ``status="completed"``
|
||||
and usage on the chunk itself. Build the equivalent non-streaming
|
||||
response so cost calculation and spend tracking see one shape.
|
||||
"""
|
||||
if result.event_type == "interaction.completed" and result.interaction is not None:
|
||||
return InteractionsAPIResponse(**result.interaction)
|
||||
if result.status == "completed":
|
||||
return InteractionsAPIResponse(
|
||||
**result.model_dump(
|
||||
exclude={ # mutable-ok: pydantic types exclude as set[str], which a frozenset does not satisfy
|
||||
"event_type",
|
||||
"delta",
|
||||
"index",
|
||||
"step",
|
||||
"interaction_id",
|
||||
"interaction",
|
||||
}
|
||||
)
|
||||
)
|
||||
return None
|
||||
|
||||
def _handle_anthropic_messages_response_logging(self, result: Any) -> ModelResponse:
|
||||
"""
|
||||
Handles logging for Anthropic messages responses.
|
||||
|
|
@ -5092,6 +5218,8 @@ class StandardLoggingPayloadSetup:
|
|||
elif isinstance(usage, dict):
|
||||
if ResponseAPILoggingUtils._is_response_api_usage(usage):
|
||||
return ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage)
|
||||
if InteractionsUsageObjectTransformation.is_interactions_usage_object(usage):
|
||||
return InteractionsUsageObjectTransformation.transform_interactions_usage_object(usage)
|
||||
return Usage(**usage)
|
||||
|
||||
raise ValueError(f"usage is required, got={usage} of type {type(usage)}")
|
||||
|
|
@ -5118,6 +5246,8 @@ class StandardLoggingPayloadSetup:
|
|||
if isinstance(_raw, dict):
|
||||
if ResponseAPILoggingUtils._is_response_api_usage(_raw):
|
||||
return ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(_raw).model_dump()
|
||||
if InteractionsUsageObjectTransformation.is_interactions_usage_object(_raw):
|
||||
return InteractionsUsageObjectTransformation.transform_interactions_usage_object(_raw).model_dump()
|
||||
return _raw
|
||||
if isinstance(_raw, Usage):
|
||||
return _raw.model_dump()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
from collections.abc import Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
from litellm.types.utils import (
|
||||
CompletionTokensDetailsWrapper,
|
||||
PromptTokensDetailsWrapper,
|
||||
TranscriptionUsageDurationObject,
|
||||
TranscriptionUsageTokensObject,
|
||||
|
|
@ -34,3 +37,127 @@ class TranscriptionUsageObjectTransformation:
|
|||
),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
_INTERACTIONS_MODALITY_FIELDS: Mapping[str, str] = MappingProxyType(
|
||||
{
|
||||
"text": "text_tokens",
|
||||
"audio": "audio_tokens",
|
||||
"image": "image_tokens",
|
||||
"video": "video_tokens",
|
||||
"document": "text_tokens",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _modality_field(entry: Mapping[str, Any]) -> str | None:
|
||||
return _INTERACTIONS_MODALITY_FIELDS.get(str(entry.get("modality", "")).lower())
|
||||
|
||||
|
||||
def _token_count(value: object) -> int:
|
||||
return value if isinstance(value, int) else 0
|
||||
|
||||
|
||||
def _modality_token_sums(entries: Sequence[Mapping[str, Any]]) -> Mapping[str, int]:
|
||||
fields = frozenset(field for entry in entries if (field := _modality_field(entry)) is not None)
|
||||
return MappingProxyType(
|
||||
{
|
||||
field: sum(_token_count(entry.get("tokens")) for entry in entries if _modality_field(entry) == field)
|
||||
for field in fields
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _google_search_query_count(usage_object: Mapping[str, Any]) -> int:
|
||||
return sum(
|
||||
_token_count(entry.get("count"))
|
||||
for entry in tuple(usage_object.get("grounding_tool_count") or ())
|
||||
if isinstance(entry, Mapping) and entry.get("type") == "google_search" # pyright: ignore[reportUnnecessaryIsInstance] # provider JSON, not the empty tuple inferred from `or ()`
|
||||
)
|
||||
|
||||
|
||||
def _subtract_cached_from_input(
|
||||
input_sums: Mapping[str, int],
|
||||
cached_sums: Mapping[str, int],
|
||||
total_cached_tokens: int,
|
||||
) -> Mapping[str, int]:
|
||||
if cached_sums:
|
||||
return MappingProxyType(
|
||||
{field: max(0, tokens - cached_sums.get(field, 0)) for field, tokens in input_sums.items()}
|
||||
)
|
||||
if total_cached_tokens and "text_tokens" in input_sums:
|
||||
return MappingProxyType(
|
||||
{
|
||||
**input_sums,
|
||||
"text_tokens": max(0, input_sums["text_tokens"] - total_cached_tokens),
|
||||
}
|
||||
)
|
||||
return input_sums
|
||||
|
||||
|
||||
class InteractionsUsageObjectTransformation:
|
||||
"""
|
||||
Maps the Google Interactions API usage block (total_input_tokens,
|
||||
output_tokens_by_modality, ...) into LiteLLM's chat-format ``Usage`` so the
|
||||
generic cost calculator and spend tracking can bill it.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def is_interactions_usage_object(usage_object: object) -> bool:
|
||||
if not isinstance(usage_object, dict):
|
||||
return False
|
||||
if "prompt_tokens" in usage_object or "input_tokens" in usage_object:
|
||||
return False
|
||||
return "total_input_tokens" in usage_object or "total_output_tokens" in usage_object
|
||||
|
||||
@staticmethod
|
||||
def transform_interactions_usage_object(usage_object: Mapping[str, Any]) -> Usage:
|
||||
input_entries = tuple(usage_object.get("input_tokens_by_modality") or ()) + tuple(
|
||||
usage_object.get("tool_use_tokens_by_modality") or ()
|
||||
)
|
||||
cached_sums = _modality_token_sums(tuple(usage_object.get("cached_tokens_by_modality") or ()))
|
||||
output_sums = _modality_token_sums(tuple(usage_object.get("output_tokens_by_modality") or ()))
|
||||
|
||||
total_cached_tokens = _token_count(usage_object.get("total_cached_tokens"))
|
||||
input_sums = _subtract_cached_from_input(
|
||||
input_sums=_modality_token_sums(input_entries),
|
||||
cached_sums=cached_sums,
|
||||
total_cached_tokens=total_cached_tokens,
|
||||
)
|
||||
|
||||
reasoning_tokens = _token_count(usage_object.get("total_reasoning_tokens")) or _token_count(
|
||||
usage_object.get("total_thought_tokens")
|
||||
)
|
||||
prompt_tokens = _token_count(usage_object.get("total_input_tokens")) + _token_count(
|
||||
usage_object.get("total_tool_use_tokens")
|
||||
)
|
||||
completion_tokens = _token_count(usage_object.get("total_output_tokens")) + reasoning_tokens
|
||||
total_tokens = _token_count(usage_object.get("total_tokens")) or (prompt_tokens + completion_tokens)
|
||||
|
||||
web_search_requests = _google_search_query_count(usage_object)
|
||||
prompt_tokens_details = (
|
||||
PromptTokensDetailsWrapper(
|
||||
cached_tokens=total_cached_tokens or None,
|
||||
web_search_requests=web_search_requests or None,
|
||||
**input_sums,
|
||||
)
|
||||
if input_sums or total_cached_tokens or web_search_requests
|
||||
else None
|
||||
)
|
||||
completion_tokens_details = (
|
||||
CompletionTokensDetailsWrapper(
|
||||
reasoning_tokens=reasoning_tokens or None,
|
||||
**output_sums,
|
||||
)
|
||||
if output_sums or reasoning_tokens
|
||||
else None
|
||||
)
|
||||
|
||||
return Usage(
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=total_tokens,
|
||||
prompt_tokens_details=prompt_tokens_details,
|
||||
completion_tokens_details=completion_tokens_details,
|
||||
cache_read_input_tokens=total_cached_tokens or None,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,88 @@
|
|||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
|
||||
import litellm
|
||||
|
||||
|
||||
def _form_field_value(value: object) -> str:
|
||||
if value is True:
|
||||
return "true"
|
||||
if value is False:
|
||||
return "false"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _flatten_form_field(key: str, value: object) -> tuple[tuple[str, str], ...]:
|
||||
if isinstance(value, Mapping):
|
||||
return tuple(
|
||||
item for subkey, subvalue in value.items() for item in _flatten_form_field(f"{key}[{subkey}]", subvalue)
|
||||
)
|
||||
if isinstance(value, (list, tuple)):
|
||||
return tuple(item for entry in value for item in _flatten_form_field(f"{key}[]", entry))
|
||||
if value is None:
|
||||
return ()
|
||||
serialized: Final = _form_field_value(value)
|
||||
if not serialized:
|
||||
return ()
|
||||
return ((key, serialized),)
|
||||
|
||||
|
||||
def _is_form_scalar(value: object) -> bool:
|
||||
return value is not None and not isinstance(value, (Mapping, list, tuple))
|
||||
|
||||
|
||||
def _flatten_form_data_field(key: str, value: object) -> tuple[tuple[str, str | tuple[str, ...]], ...]:
|
||||
if isinstance(value, Mapping):
|
||||
return tuple(
|
||||
item
|
||||
for subkey, subvalue in value.items()
|
||||
for item in _flatten_form_data_field(f"{key}[{subkey}]", subvalue)
|
||||
)
|
||||
if isinstance(value, (list, tuple)):
|
||||
if all(_is_form_scalar(entry) for entry in value):
|
||||
serialized_fields: Final = tuple(field for entry in value if (field := _form_field_value(entry)))
|
||||
return ((key, serialized_fields),) if serialized_fields else ()
|
||||
return tuple(item for entry in value for item in _flatten_form_data_field(f"{key}[]", entry))
|
||||
if value is None:
|
||||
return ()
|
||||
serialized: Final = _form_field_value(value)
|
||||
if not serialized:
|
||||
return ()
|
||||
return ((key, serialized),)
|
||||
|
||||
|
||||
def flatten_form_field_values(*sources: Mapping[str, object] | None) -> tuple[tuple[str, str | tuple[str, ...]], ...]:
|
||||
"""
|
||||
Flatten JSON-shaped bodies into ``(name, value)`` form fields for a ``dict``-backed
|
||||
multipart body, applying ``sources`` in order so a later source wins on a key collision
|
||||
under ``dict.update``. Nested objects become ``key[subkey]`` fields the way the OpenAI SDK
|
||||
serializes them, so provider params reach a multipart request without handing the httpx
|
||||
encoder a nested value it rejects with ``Invalid type for value``. A scalar list becomes a
|
||||
single field carrying a tuple value, which httpx emits as one repeated part per element, so
|
||||
every element survives instead of collapsing to the last under ``dict.update``.
|
||||
"""
|
||||
return tuple(
|
||||
pair
|
||||
for source in sources
|
||||
if source is not None
|
||||
for top_key, top_value in source.items()
|
||||
for pair in _flatten_form_data_field(top_key, top_value)
|
||||
)
|
||||
|
||||
|
||||
def serialize_multipart_form_fields(data: Mapping[str, object]) -> tuple[tuple[str, tuple[None, str]], ...]:
|
||||
"""
|
||||
Encode a JSON-shaped body as OpenAI-SDK-style multipart file-tuples so a file-less
|
||||
request is still sent as multipart/form-data, working around httpx downgrading a
|
||||
file-less ``data=`` payload to application/x-www-form-urlencoded.
|
||||
"""
|
||||
return tuple(
|
||||
(key, (None, serialized))
|
||||
for top_key, top_value in data.items()
|
||||
for key, serialized in _flatten_form_field(top_key, top_value)
|
||||
)
|
||||
|
||||
|
||||
def _ensure_extra_body_is_safe(extra_body: dict | None) -> dict | None:
|
||||
"""
|
||||
Ensure that the extra_body sent in the request is safe, otherwise users will see this error
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import asyncio
|
|||
import atexit
|
||||
import contextvars
|
||||
import logging
|
||||
from collections.abc import Coroutine
|
||||
from collections.abc import Coroutine, Iterator
|
||||
from typing import Final
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
|
@ -61,6 +61,19 @@ class LoggingWorker:
|
|||
# Register cleanup handler to flush remaining events on exit
|
||||
atexit.register(self._flush_on_exit)
|
||||
|
||||
@staticmethod
|
||||
def _drain_pending(queue: "asyncio.Queue[LoggingTask]") -> tuple[LoggingTask, ...]:
|
||||
"""Pop every task still queued, without awaiting them, so they can be moved to another queue."""
|
||||
|
||||
def _pop_until_empty() -> Iterator[LoggingTask]:
|
||||
while True:
|
||||
try:
|
||||
yield queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
return
|
||||
|
||||
return tuple(_pop_until_empty())
|
||||
|
||||
def _ensure_queue(self) -> None:
|
||||
"""Initialize the queue if it doesn't exist or if event loop has changed."""
|
||||
try:
|
||||
|
|
@ -69,14 +82,27 @@ class LoggingWorker:
|
|||
# No running loop, can't initialize
|
||||
return
|
||||
|
||||
# Check if we need to reinitialize due to event loop change
|
||||
# The queue, semaphore and worker task are all bound to the loop that created them. On a
|
||||
# loop change we hand the still-pending tasks to a fresh queue instead of dropping them,
|
||||
# so queued spend-logging coroutines are not silently discarded (and never left un-awaited).
|
||||
if self._queue is not None and self._bound_loop is not current_loop:
|
||||
verbose_logger.debug("LoggingWorker: Event loop changed, reinitializing queue and worker")
|
||||
# Clear old state - these are bound to the old loop
|
||||
self._queue = None
|
||||
carried_over: Final = self._drain_pending(self._queue)
|
||||
new_queue: Final[asyncio.Queue[LoggingTask]] = asyncio.Queue(maxsize=self.max_queue_size)
|
||||
for carried_task in carried_over:
|
||||
new_queue.put_nowait(carried_task)
|
||||
if carried_over:
|
||||
verbose_logger.warning(
|
||||
"LoggingWorker: event loop changed; carried %d pending logging task(s) onto the new loop",
|
||||
len(carried_over),
|
||||
)
|
||||
else:
|
||||
verbose_logger.debug("LoggingWorker: Event loop changed, reinitializing queue and worker")
|
||||
self._sem = None
|
||||
self._worker_task = None
|
||||
self._running_tasks.clear()
|
||||
self._queue = new_queue
|
||||
self._bound_loop = current_loop
|
||||
return
|
||||
|
||||
if self._queue is None:
|
||||
self._queue = asyncio.Queue(maxsize=self.max_queue_size)
|
||||
|
|
|
|||
|
|
@ -28,8 +28,12 @@ from litellm.types.llms.openai import (
|
|||
ChatCompletionAssistantMessage,
|
||||
ChatCompletionFileObject,
|
||||
ChatCompletionImageObject,
|
||||
ChatCompletionReasoningItem,
|
||||
ChatCompletionReasoningSummaryTextBlock,
|
||||
ChatCompletionRedactedThinkingBlock,
|
||||
ChatCompletionResponseMessage,
|
||||
ChatCompletionTextObject,
|
||||
ChatCompletionThinkingBlock,
|
||||
ChatCompletionToolParam,
|
||||
ChatCompletionUserMessage,
|
||||
)
|
||||
|
|
@ -1549,6 +1553,44 @@ def _extract_reasoning_content(message: dict) -> tuple[str | None, str | None]:
|
|||
return None, message_content
|
||||
|
||||
|
||||
def _readable_thinking_text(
|
||||
block: ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock,
|
||||
) -> str:
|
||||
"""The text a chat model can read back, empty for redacted blocks and malformed ones."""
|
||||
if block.get("type") != "thinking":
|
||||
return ""
|
||||
thinking: Final = cast(ChatCompletionThinkingBlock, block).get("thinking") # cast-ok: narrowed by the type tag
|
||||
return str(thinking or "")
|
||||
|
||||
|
||||
def reasoning_content_from_thinking_blocks(
|
||||
thinking_blocks: Iterable[ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock],
|
||||
) -> str:
|
||||
"""Flatten Anthropic thinking blocks into the `reasoning_content` string chat models expect.
|
||||
|
||||
Redacted blocks carry no readable text, so they contribute nothing.
|
||||
"""
|
||||
return "\n".join(text for block in thinking_blocks if (text := _readable_thinking_text(block)))
|
||||
|
||||
|
||||
def responses_reasoning_item_from_thinking_blocks(
|
||||
thinking_blocks: Iterable[ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock],
|
||||
) -> ChatCompletionReasoningItem | None:
|
||||
"""Build a Responses API `reasoning` input item from Anthropic thinking blocks.
|
||||
|
||||
The item carries no `id`: the Responses API rejects an empty one and 404s on any id it
|
||||
did not mint itself, while an item without an id is always accepted.
|
||||
"""
|
||||
summary: Final[list[ChatCompletionReasoningSummaryTextBlock]] = [ # mutable-ok: API message payload
|
||||
ChatCompletionReasoningSummaryTextBlock(type="summary_text", text=text)
|
||||
for block in thinking_blocks
|
||||
if (text := _readable_thinking_text(block))
|
||||
]
|
||||
if not summary:
|
||||
return None
|
||||
return ChatCompletionReasoningItem(type="reasoning", summary=summary)
|
||||
|
||||
|
||||
def _parse_content_for_reasoning(
|
||||
message_text: str | None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import json
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, 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_
|
||||
|
|
@ -567,9 +594,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
|
||||
|
|
|
|||
|
|
@ -549,6 +549,16 @@ class AnthropicModelInfo(BaseLLMModelInfo):
|
|||
"""
|
||||
return AnthropicModelInfo._supports_model_capability(model, "thinking_always_on", custom_llm_provider)
|
||||
|
||||
@staticmethod
|
||||
def _supports_legacy_thinking(model: str, custom_llm_provider: str) -> bool:
|
||||
"""Whether ``model`` is an adaptive-thinking model that still accepts legacy
|
||||
``thinking.type=enabled`` with ``budget_tokens`` (the Claude 4.6 family).
|
||||
The model cost map is authoritative: an explicit ``supports_legacy_thinking``
|
||||
entry resolved under ``custom_llm_provider``, or a ``fallback_generalizations``
|
||||
rule for unmapped 4.6 ids. Absent flag means the model rejects the legacy shape.
|
||||
"""
|
||||
return AnthropicModelInfo._supports_model_capability(model, "supports_legacy_thinking", custom_llm_provider)
|
||||
|
||||
@staticmethod
|
||||
def maybe_drop_disabled_thinking(
|
||||
model: str,
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ from openai.types.chat.chat_completion_chunk import Choice as OpenAIStreamingCho
|
|||
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
parse_tool_call_arguments,
|
||||
reasoning_content_from_thinking_blocks,
|
||||
with_prompt_cache_breakpoint,
|
||||
)
|
||||
from litellm.litellm_core_utils.prompt_templates.factory import (
|
||||
|
|
@ -592,6 +593,9 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
assistant_message["tool_calls"] = tool_calls
|
||||
if len(thinking_blocks) > 0:
|
||||
assistant_message["thinking_blocks"] = thinking_blocks
|
||||
reasoning_content = reasoning_content_from_thinking_blocks(thinking_blocks)
|
||||
if reasoning_content:
|
||||
assistant_message["reasoning_content"] = reasoning_content
|
||||
new_messages.append(assistant_message)
|
||||
|
||||
return new_messages
|
||||
|
|
|
|||
|
|
@ -455,13 +455,19 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
|
|||
def _translate_legacy_thinking_for_adaptive_model(
|
||||
model: str, optional_params: dict, custom_llm_provider: str
|
||||
) -> None:
|
||||
"""Translate legacy ``thinking.type=enabled`` to adaptive for 4.6/4.7.
|
||||
Caller-provided ``output_config.effort`` is never overridden.
|
||||
"""Translate legacy ``thinking.type=enabled`` to adaptive for the
|
||||
adaptive-thinking models that reject it (4.7+ and the 5 families).
|
||||
Models flagged ``supports_legacy_thinking`` (the 4.6 family) accept the
|
||||
legacy shape natively, so it is forwarded verbatim and the caller's
|
||||
``budget_tokens`` cap keeps applying. Caller-provided
|
||||
``output_config.effort`` is never overridden.
|
||||
"""
|
||||
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
|
||||
|
||||
if not AnthropicModelInfo._is_adaptive_thinking_model(model, custom_llm_provider):
|
||||
return
|
||||
if AnthropicModelInfo._supports_legacy_thinking(model, custom_llm_provider):
|
||||
return
|
||||
thinking: Final = optional_params.get("thinking")
|
||||
if not isinstance(thinking, dict) or thinking.get("type") != "enabled":
|
||||
return
|
||||
|
|
|
|||
|
|
@ -152,7 +152,10 @@ class AnthropicResponsesStreamWrapper:
|
|||
if block_idx < 0:
|
||||
if not delta:
|
||||
return
|
||||
block_idx = self._open_block(item_id, {"type": "thinking", "thinking": ""})
|
||||
block_idx = self._open_block(
|
||||
item_id,
|
||||
{"type": "thinking", "thinking": "", "signature": ""}, # mutable-ok: API message payload
|
||||
)
|
||||
self._chunk_queue.append(
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@ path used for OpenAI and Azure models.
|
|||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Iterable, Mapping
|
||||
from itertools import groupby
|
||||
from typing import Any, Final, cast
|
||||
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
TOOL_RESULT_IMAGE_BOUNDARY,
|
||||
TOOL_RESULT_IMAGE_PLACEHOLDER,
|
||||
responses_reasoning_item_from_thinking_blocks,
|
||||
with_prompt_cache_breakpoint,
|
||||
)
|
||||
from litellm.litellm_core_utils.reasoning_effort_utils import (
|
||||
|
|
@ -36,7 +38,11 @@ from litellm.types.llms.anthropic_messages.anthropic_response import (
|
|||
AnthropicMessagesResponse,
|
||||
AnthropicUsage,
|
||||
)
|
||||
from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse
|
||||
from litellm.types.llms.openai import (
|
||||
ChatCompletionThinkingBlock,
|
||||
ResponseAPIUsage,
|
||||
ResponsesAPIResponse,
|
||||
)
|
||||
|
||||
|
||||
class LiteLLMAnthropicToResponsesAPIAdapter:
|
||||
|
|
@ -100,6 +106,58 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
if isinstance(block, dict) and block.get("type") == "text" and (text := block.get("text")) # pyright: ignore[reportUnnecessaryIsInstance] # untrusted client payload
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _summary_part_text(part: object) -> str:
|
||||
if isinstance(part, Mapping):
|
||||
mapping: Final = cast(Mapping[str, Any], part) # cast-ok: summary parts are untyped provider json
|
||||
return str(mapping.get("text") or "")
|
||||
return str(getattr(part, "text", None) or "")
|
||||
|
||||
@classmethod
|
||||
def _thinking_blocks_from_reasoning_item(
|
||||
cls,
|
||||
summary: Iterable[object],
|
||||
) -> tuple[dict[str, Any], ...]: # mutable-ok: API message payload
|
||||
"""Anthropic thinking blocks for one Responses reasoning item.
|
||||
|
||||
The signature stays empty: only Anthropic can sign a thinking block, and a stand-in
|
||||
value would be replayed as a real one and rejected by every backend that verifies it.
|
||||
"""
|
||||
return tuple(
|
||||
AnthropicResponseContentBlockThinking(
|
||||
type="thinking",
|
||||
thinking=text,
|
||||
signature=None,
|
||||
).model_dump()
|
||||
for part in summary
|
||||
if (text := cls._summary_part_text(part))
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _assistant_block_group_key(indexed_block: tuple[int, Mapping[str, Any]]) -> str:
|
||||
"""Group a run of consecutive thinking blocks together; keep every other block alone."""
|
||||
index, block = indexed_block
|
||||
return "thinking" if block.get("type") == "thinking" else f"block:{index}"
|
||||
|
||||
@classmethod
|
||||
def _assistant_group_to_input_item(
|
||||
cls, group: tuple[Mapping[str, Any], ...]
|
||||
) -> dict[str, Any] | None: # mutable-ok: API message payload
|
||||
first: Final = group[0]
|
||||
btype: Final = first.get("type")
|
||||
if btype == "thinking":
|
||||
blocks: Final = cast(tuple[ChatCompletionThinkingBlock, ...], group) # cast-ok: untrusted client payload
|
||||
reasoning_item: Final = responses_reasoning_item_from_thinking_blocks(blocks)
|
||||
return None if reasoning_item is None else dict(reasoning_item) # mutable-ok: API message payload
|
||||
if btype == "tool_use":
|
||||
return { # mutable-ok: API message payload
|
||||
"type": "function_call",
|
||||
"call_id": first.get("id", ""),
|
||||
"name": first.get("name", ""),
|
||||
"arguments": json.dumps(first.get("input", {})), # mutable-ok: API message payload
|
||||
}
|
||||
return None
|
||||
|
||||
def translate_messages_to_responses_input(
|
||||
self,
|
||||
messages: list[AllAnthropicPassThroughMessageValues],
|
||||
|
|
@ -113,6 +171,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
user image -> message(role=user, input_image)
|
||||
user tool_result -> function_call_output
|
||||
assistant text -> message(role=assistant, output_text)
|
||||
assistant thinking -> reasoning
|
||||
assistant tool_use -> function_call
|
||||
"""
|
||||
input_items: Final[list[dict[str, Any]]] = []
|
||||
|
|
@ -233,27 +292,17 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
}
|
||||
)
|
||||
elif isinstance(content, list):
|
||||
asst_parts: list[dict[str, Any]] = []
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
btype = block.get("type")
|
||||
if btype == "text":
|
||||
asst_parts.append({"type": "output_text", "text": block.get("text", "")})
|
||||
elif btype == "tool_use":
|
||||
# tool_use becomes a top-level function_call item
|
||||
input_items.append(
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": block.get("id", ""),
|
||||
"name": block.get("name", ""),
|
||||
"arguments": json.dumps(block.get("input", {})),
|
||||
}
|
||||
)
|
||||
elif btype == "thinking":
|
||||
thinking_text = block.get("thinking", "")
|
||||
if thinking_text:
|
||||
asst_parts.append({"type": "output_text", "text": thinking_text})
|
||||
blocks = tuple(block for block in content if isinstance(block, dict))
|
||||
input_items.extend(
|
||||
item
|
||||
for _, group in groupby(enumerate(blocks), key=self._assistant_block_group_key)
|
||||
if (item := self._assistant_group_to_input_item(tuple(block for _, block in group))) is not None
|
||||
)
|
||||
asst_parts: list[dict[str, Any]] = [ # mutable-ok: API message payload
|
||||
{"type": "output_text", "text": block.get("text", "")} # mutable-ok: API message payload
|
||||
for block in blocks
|
||||
if block.get("type") == "text"
|
||||
]
|
||||
if asst_parts:
|
||||
input_items.append(
|
||||
{
|
||||
|
|
@ -514,16 +563,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
|
||||
for item in response.output:
|
||||
if isinstance(item, ResponseReasoningItem):
|
||||
for summary in item.summary:
|
||||
text = getattr(summary, "text", "")
|
||||
if text:
|
||||
content.append(
|
||||
AnthropicResponseContentBlockThinking(
|
||||
type="thinking",
|
||||
thinking=text,
|
||||
signature=None,
|
||||
).model_dump()
|
||||
)
|
||||
content.extend(self._thinking_blocks_from_reasoning_item(item.summary))
|
||||
|
||||
elif isinstance(item, ResponseOutputMessage):
|
||||
for part in item.content:
|
||||
|
|
@ -555,6 +595,12 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
content.append(
|
||||
AnthropicResponseContentBlockText(type="text", text=part.get("text", "")).model_dump()
|
||||
)
|
||||
elif item_type == "reasoning":
|
||||
content.extend(
|
||||
self._thinking_blocks_from_reasoning_item(
|
||||
cast(Iterable[object], item.get("summary") or ()), # cast-ok: untyped provider json
|
||||
)
|
||||
)
|
||||
elif item_type == "function_call":
|
||||
try:
|
||||
input_data = json.loads(item.get("arguments", "{}"))
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import hashlib
|
|||
import json
|
||||
import os
|
||||
from collections.abc import Callable, Mapping
|
||||
from functools import lru_cache
|
||||
from typing import Any, Final, Literal, NamedTuple, cast
|
||||
|
||||
import httpx
|
||||
|
|
@ -75,6 +76,24 @@ def process_azure_headers(headers: httpx.Headers | dict) -> dict:
|
|||
return {**llm_response_headers, **openai_headers}
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def _cached_entra_id_token_provider(
|
||||
tenant_id: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
scope: str,
|
||||
) -> Callable[[], str]:
|
||||
"""Build (once per credential set) a bearer token provider backed by a `ClientSecretCredential`.
|
||||
|
||||
The credential caches the access token internally and only talks to Entra ID when it is close
|
||||
to expiry, so reusing the provider keeps one AAD round trip per token lifetime instead of one
|
||||
per request.
|
||||
"""
|
||||
from azure.identity import ClientSecretCredential, get_bearer_token_provider
|
||||
|
||||
return get_bearer_token_provider(ClientSecretCredential(tenant_id, client_id, client_secret), scope)
|
||||
|
||||
|
||||
def get_azure_ad_token_from_entra_id(
|
||||
tenant_id: str,
|
||||
client_id: str,
|
||||
|
|
@ -93,8 +112,6 @@ def get_azure_ad_token_from_entra_id(
|
|||
Returns:
|
||||
callable that returns a bearer token.
|
||||
"""
|
||||
from azure.identity import ClientSecretCredential, get_bearer_token_provider
|
||||
|
||||
verbose_logger.debug("Getting Azure AD Token from Entra ID")
|
||||
|
||||
if tenant_id.startswith("os.environ/"):
|
||||
|
|
@ -120,9 +137,13 @@ def get_azure_ad_token_from_entra_id(
|
|||
)
|
||||
if _tenant_id is None or _client_id is None or _client_secret is None:
|
||||
raise ValueError("tenant_id, client_id, and client_secret must be provided")
|
||||
credential: Final = ClientSecretCredential(_tenant_id, _client_id, _client_secret)
|
||||
|
||||
token_provider: Final = get_bearer_token_provider(credential, scope)
|
||||
token_provider: Final = _cached_entra_id_token_provider(
|
||||
tenant_id=_tenant_id,
|
||||
client_id=_client_id,
|
||||
client_secret=_client_secret,
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
verbose_logger.debug("token_provider %s", token_provider)
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,12 @@ class AzureFoundryErrorStrings(str, enum.Enum):
|
|||
SET_EXTRA_PARAMETERS_TO_PASS_THROUGH = "Set extra-parameters to 'pass-through'"
|
||||
|
||||
|
||||
NON_OPENAI_SPEC_MESSAGE_FIELDS: Final = ("thinking_blocks", "provider_specific_fields", "cache_control")
|
||||
NON_OPENAI_SPEC_MESSAGE_FIELDS: Final = (
|
||||
"thinking_blocks",
|
||||
"reasoning_content",
|
||||
"provider_specific_fields",
|
||||
"cache_control",
|
||||
)
|
||||
|
||||
|
||||
class AzureAIStudioConfig(OpenAIConfig):
|
||||
|
|
@ -173,7 +178,8 @@ class AzureAIStudioConfig(OpenAIConfig):
|
|||
"""
|
||||
- Azure AI Studio doesn't support content as a list. This handles:
|
||||
1. Strips message fields that are not part of the OpenAI chat-completions
|
||||
schema (thinking_blocks, provider_specific_fields, cache_control).
|
||||
schema (thinking_blocks, reasoning_content, provider_specific_fields,
|
||||
cache_control).
|
||||
Azure AI Foundry backends set additionalProperties=false and reject
|
||||
these with "Extra inputs are not permitted", which breaks multi-turn
|
||||
Anthropic-format clients that echo thinking blocks back as history.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,54 @@
|
|||
from collections.abc import Mapping
|
||||
from typing import Final, Literal
|
||||
|
||||
import litellm
|
||||
from litellm.llms.base_llm.base_utils import BaseLLMModelInfo, BaseTokenCounter
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.llms.openai import AllMessageValues
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
|
||||
AzureAIApiKeyHeader = Literal["Authorization", "api-key", "Api-Key", "Ocp-Apim-Subscription-Key"]
|
||||
|
||||
|
||||
def get_azure_ai_entra_token(litellm_params: Mapping[str, object] | None = None) -> str | None:
|
||||
"""
|
||||
Resolve an Entra ID / OAuth access token for an Azure AI Foundry deployment.
|
||||
|
||||
Accepts the same credential set as the `azure` provider: service principal
|
||||
(`tenant_id` / `client_id` / `client_secret`), a pre-fetched `azure_ad_token`, an OIDC
|
||||
federated token, username/password, or `DefaultAzureCredential` / managed identity.
|
||||
"""
|
||||
from litellm.llms.azure.common_utils import get_azure_ad_token
|
||||
|
||||
params = GenericLiteLLMParams.model_validate(litellm_params) if litellm_params else GenericLiteLLMParams()
|
||||
|
||||
return get_azure_ad_token(params)
|
||||
|
||||
|
||||
def get_azure_ai_auth_headers(
|
||||
api_key: str | None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
api_key_header: AzureAIApiKeyHeader = "Authorization",
|
||||
api_key_env_var: str = "AZURE_AI_API_KEY",
|
||||
) -> Mapping[str, str]:
|
||||
"""
|
||||
Build the auth headers for an Azure AI Foundry route.
|
||||
|
||||
Prefers the API key when one is configured, and otherwise falls back to Entra ID / OAuth,
|
||||
sending the access token as a bearer token.
|
||||
"""
|
||||
if api_key:
|
||||
return {api_key_header: f"Bearer {api_key}" if api_key_header == "Authorization" else api_key}
|
||||
|
||||
azure_ad_token = get_azure_ai_entra_token(litellm_params=litellm_params)
|
||||
if azure_ad_token:
|
||||
return {"Authorization": f"Bearer {azure_ad_token}"}
|
||||
|
||||
raise ValueError(
|
||||
f"Missing Azure AI credentials - set an API key (`api_key` or {api_key_env_var}), or Entra ID / OAuth "
|
||||
"credentials (`tenant_id` + `client_id` + `client_secret`, `azure_ad_token`, an OIDC token, or a managed "
|
||||
"identity with `litellm.enable_azure_ad_token_refresh = True`)"
|
||||
)
|
||||
|
||||
|
||||
class AzureFoundryModelInfo(BaseLLMModelInfo):
|
||||
|
|
@ -43,7 +88,7 @@ class AzureFoundryModelInfo(BaseLLMModelInfo):
|
|||
|
||||
@staticmethod
|
||||
def get_api_key(api_key: str | None = None) -> str | None:
|
||||
return api_key or litellm.api_key or litellm.openai_key or get_secret_str("AZURE_AI_API_KEY")
|
||||
return api_key or litellm.api_key or get_secret_str("AZURE_AI_API_KEY")
|
||||
|
||||
@property
|
||||
def api_version(self, api_version: str | None = None) -> str | None:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ from typing import Any, Final
|
|||
from httpx._types import RequestFiles
|
||||
|
||||
import litellm
|
||||
from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo
|
||||
from litellm.llms.azure_ai.common_utils import (
|
||||
AzureFoundryModelInfo,
|
||||
get_azure_ai_auth_headers,
|
||||
)
|
||||
from litellm.llms.azure_ai.image_generation.flux_transformation import (
|
||||
AzureFoundryFluxImageGenerationConfig,
|
||||
)
|
||||
|
|
@ -71,16 +74,13 @@ class AzureFoundryFlux2ImageEditConfig(OpenAIImageEditConfig):
|
|||
"""
|
||||
Validate Azure AI Foundry environment and set up authentication
|
||||
"""
|
||||
api_key = AzureFoundryModelInfo.get_api_key(api_key)
|
||||
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
f"Azure AI API key is required for model {model}. Set AZURE_AI_API_KEY environment variable or pass api_key parameter."
|
||||
)
|
||||
|
||||
headers.update(
|
||||
{
|
||||
"Api-Key": api_key,
|
||||
**get_azure_ai_auth_headers(
|
||||
api_key=AzureFoundryModelInfo.get_api_key(api_key),
|
||||
litellm_params=litellm_params,
|
||||
api_key_header="Api-Key",
|
||||
),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ from typing import TYPE_CHECKING, Any, Final, cast
|
|||
import httpx
|
||||
from httpx._types import RequestFiles
|
||||
|
||||
from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo
|
||||
from litellm.llms.azure_ai.common_utils import (
|
||||
AzureFoundryModelInfo,
|
||||
get_azure_ai_auth_headers,
|
||||
)
|
||||
from litellm.llms.azure_ai.image_generation.mai_transformation import (
|
||||
AzureFoundryMAIImageGenerationConfig,
|
||||
)
|
||||
|
|
@ -91,15 +94,13 @@ class AzureFoundryMAIImageEditConfig(OpenAIImageEditConfig):
|
|||
litellm_params: dict | None = None,
|
||||
api_base: str | None = None,
|
||||
) -> dict:
|
||||
api_key = AzureFoundryModelInfo.get_api_key(api_key)
|
||||
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
f"Azure AI API key is required for model {model}. "
|
||||
"Set AZURE_AI_API_KEY environment variable or pass api_key parameter."
|
||||
headers.update(
|
||||
get_azure_ai_auth_headers(
|
||||
api_key=AzureFoundryModelInfo.get_api_key(api_key),
|
||||
litellm_params=litellm_params,
|
||||
api_key_header="api-key",
|
||||
)
|
||||
|
||||
headers.update({"api-key": api_key})
|
||||
)
|
||||
return headers
|
||||
|
||||
def get_complete_url(
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ from typing import Final
|
|||
import httpx
|
||||
|
||||
import litellm
|
||||
from litellm.llms.azure_ai.common_utils import AzureFoundryModelInfo
|
||||
from litellm.llms.azure_ai.common_utils import (
|
||||
AzureFoundryModelInfo,
|
||||
get_azure_ai_auth_headers,
|
||||
)
|
||||
from litellm.llms.openai.image_edit.transformation import OpenAIImageEditConfig
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.utils import _add_path_to_api_base
|
||||
|
|
@ -30,19 +33,14 @@ class AzureFoundryFluxImageEditConfig(OpenAIImageEditConfig):
|
|||
) -> dict:
|
||||
"""
|
||||
Validate Azure AI Foundry environment and set up authentication
|
||||
Uses Api-Key header format
|
||||
Uses the Api-Key header format, or an Entra ID / OAuth bearer token when no key is set
|
||||
"""
|
||||
api_key = AzureFoundryModelInfo.get_api_key(api_key)
|
||||
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
f"Azure AI API key is required for model {model}. Set AZURE_AI_API_KEY environment variable or pass api_key parameter."
|
||||
)
|
||||
|
||||
headers.update(
|
||||
{
|
||||
"Api-Key": api_key, # Azure AI Foundry uses Api-Key header format
|
||||
}
|
||||
get_azure_ai_auth_headers(
|
||||
api_key=AzureFoundryModelInfo.get_api_key(api_key),
|
||||
litellm_params=litellm_params,
|
||||
api_key_header="Api-Key",
|
||||
)
|
||||
)
|
||||
return headers
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ from litellm.constants import (
|
|||
)
|
||||
from litellm.exceptions import UnsupportedParamsError
|
||||
from litellm.litellm_core_utils.url_utils import SSRFError, assert_same_origin, encode_url_path_segment
|
||||
from litellm.llms.azure_ai.common_utils import get_azure_ai_auth_headers
|
||||
from litellm.llms.base_llm.ocr.transformation import (
|
||||
OCR_REQUEST_FORMAT_PARAM,
|
||||
BaseOCRConfig,
|
||||
|
|
@ -236,17 +237,13 @@ class AzureDocumentIntelligenceOCRConfig(BaseOCRConfig):
|
|||
"""
|
||||
Validate environment and return headers for Azure Document Intelligence.
|
||||
|
||||
Authentication uses Ocp-Apim-Subscription-Key header.
|
||||
Authentication uses the Ocp-Apim-Subscription-Key header, or an Entra ID / OAuth bearer
|
||||
token when no subscription key is set.
|
||||
"""
|
||||
# Get API key from environment if not provided
|
||||
if api_key is None:
|
||||
api_key = get_secret_str(AZURE_DOCUMENT_INTELLIGENCE_API_KEY_ENV_VAR)
|
||||
|
||||
if api_key is None:
|
||||
raise ValueError(
|
||||
"Missing Azure Document Intelligence API Key - Set AZURE_DOCUMENT_INTELLIGENCE_API_KEY environment variable or pass api_key parameter"
|
||||
)
|
||||
|
||||
# Validate API base/endpoint is provided
|
||||
if api_base is None:
|
||||
api_base = get_secret_str("AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT")
|
||||
|
|
@ -257,7 +254,12 @@ class AzureDocumentIntelligenceOCRConfig(BaseOCRConfig):
|
|||
)
|
||||
|
||||
headers = {
|
||||
"Ocp-Apim-Subscription-Key": api_key,
|
||||
**get_azure_ai_auth_headers(
|
||||
api_key=api_key,
|
||||
litellm_params=litellm_params,
|
||||
api_key_header="Ocp-Apim-Subscription-Key",
|
||||
api_key_env_var=AZURE_DOCUMENT_INTELLIGENCE_API_KEY_ENV_VAR,
|
||||
),
|
||||
"Content-Type": "application/json",
|
||||
**headers,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from litellm.litellm_core_utils.prompt_templates.image_handling import (
|
|||
async_convert_url_to_base64,
|
||||
convert_url_to_base64,
|
||||
)
|
||||
from litellm.llms.azure_ai.common_utils import get_azure_ai_auth_headers
|
||||
from litellm.llms.base_llm.ocr.transformation import DocumentType, OCRRequestData
|
||||
from litellm.llms.mistral.ocr.transformation import MistralOCRConfig
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
|
|
@ -47,17 +48,12 @@ class AzureAIOCRConfig(MistralOCRConfig):
|
|||
"""
|
||||
Validate environment and return headers for Azure AI OCR.
|
||||
|
||||
Azure AI uses Bearer token authentication with AZURE_AI_API_KEY.
|
||||
Authenticates with AZURE_AI_API_KEY, or with an Entra ID / OAuth token when no key is set.
|
||||
"""
|
||||
# Get API key from environment if not provided
|
||||
if api_key is None:
|
||||
api_key = get_secret_str(AZURE_AI_OCR_API_KEY_ENV_VAR)
|
||||
|
||||
if api_key is None:
|
||||
raise ValueError(
|
||||
"Missing Azure AI API Key - A call is being made to Azure AI but no key is set either in the environment variables or via params"
|
||||
)
|
||||
|
||||
# Validate API base is provided
|
||||
if api_base is None:
|
||||
api_base = get_secret_str("AZURE_AI_API_BASE")
|
||||
|
|
@ -68,7 +64,7 @@ class AzureAIOCRConfig(MistralOCRConfig):
|
|||
)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
**get_azure_ai_auth_headers(api_key=api_key, litellm_params=litellm_params),
|
||||
"Content-Type": "application/json",
|
||||
**headers,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@
|
|||
Translate between Cohere's `/rerank` format and Azure AI's `/rerank` format.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.llms.azure_ai.common_utils import get_azure_ai_auth_headers
|
||||
from litellm.llms.cohere.rerank.transformation import CohereRerankConfig
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.utils import RerankResponse
|
||||
|
|
@ -64,15 +66,13 @@ class AzureAIRerankConfig(CohereRerankConfig):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
if api_key is None:
|
||||
api_key = get_secret_str("AZURE_AI_API_KEY") or litellm.azure_key
|
||||
|
||||
if api_key is None:
|
||||
raise ValueError("Azure AI API key is required. Please set 'AZURE_AI_API_KEY' or 'litellm.azure_key'")
|
||||
|
||||
default_headers: Final = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
**get_azure_ai_auth_headers(api_key=api_key, litellm_params=litellm_params),
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -24,6 +25,7 @@ class BaseRerankConfig(ABC):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -91,6 +91,14 @@ class BaseVideoConfig(ABC):
|
|||
raise ValueError("api_base is required")
|
||||
return api_base
|
||||
|
||||
def use_multipart_form_data(self) -> bool:
|
||||
"""
|
||||
Whether video create requests without files must still be sent as
|
||||
multipart/form-data (the encoding the OpenAI SDK always uses for
|
||||
/videos), instead of falling back to JSON.
|
||||
"""
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
def transform_video_create_request(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -567,6 +567,10 @@ class AWSEventStreamDecoder:
|
|||
if thinking_blocks:
|
||||
self._thinking_ran = True
|
||||
|
||||
carries_message_content: Final = any(
|
||||
key in chunk_data for key in ("start", "delta", "contentBlockIndex", "stopReason", "trace")
|
||||
)
|
||||
|
||||
model_response_provider_specific_fields: Final = {}
|
||||
if "trace" in chunk_data:
|
||||
trace: Final = chunk_data.get("trace")
|
||||
|
|
@ -577,8 +581,8 @@ class AWSEventStreamDecoder:
|
|||
finish_reason=finish_reason,
|
||||
index=0, # Always 0 - Bedrock never returns multiple choices
|
||||
delta=Delta(
|
||||
content=text,
|
||||
role="assistant",
|
||||
content=text if carries_message_content else None,
|
||||
role="assistant" if carries_message_content else None,
|
||||
tool_calls=[tool_use] if tool_use else None,
|
||||
provider_specific_fields=(provider_specific_fields if provider_specific_fields else None),
|
||||
thinking_blocks=thinking_blocks,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from collections.abc import Mapping
|
||||
from typing import Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -81,6 +82,7 @@ class CohereRerankConfig(BaseRerankConfig):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
if api_key is None:
|
||||
api_key = get_secret_str("COHERE_API_KEY") or get_secret_str("CO_API_KEY") or litellm.cohere_key
|
||||
|
|
|
|||
|
|
@ -38,6 +38,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
|
||||
|
|
@ -1171,6 +1172,7 @@ class BaseLLMHTTPHandler:
|
|||
headers=headers or {},
|
||||
model=model,
|
||||
optional_params=optional_rerank_params,
|
||||
litellm_params=litellm_params,
|
||||
)
|
||||
|
||||
api_base = provider_config.get_complete_url(
|
||||
|
|
@ -7117,9 +7119,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,
|
||||
|
|
@ -7127,9 +7127,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,
|
||||
|
|
@ -7221,20 +7226,26 @@ class BaseLLMHTTPHandler:
|
|||
)
|
||||
|
||||
try:
|
||||
# Use JSON when no files, otherwise use form data with files
|
||||
if files is None or len(files) == 0:
|
||||
if files and len(files) > 0:
|
||||
response = await async_httpx_client.post(
|
||||
url=api_base,
|
||||
headers=headers,
|
||||
json=data,
|
||||
data=data,
|
||||
files=files,
|
||||
timeout=timeout,
|
||||
)
|
||||
elif video_generation_provider_config.use_multipart_form_data():
|
||||
response = await async_httpx_client.post( # rebind-ok: one of three mutually-exclusive branches
|
||||
url=api_base,
|
||||
headers=headers,
|
||||
files=serialize_multipart_form_fields(data),
|
||||
timeout=timeout,
|
||||
)
|
||||
else:
|
||||
response = await async_httpx_client.post(
|
||||
url=api_base,
|
||||
headers=headers,
|
||||
data=data,
|
||||
files=files,
|
||||
json=data,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ as supported only for gte-rerank-v2 / qwen3-vl-rerank.
|
|||
Docs - https://help.aliyun.com/zh/model-studio/text-rerank-api
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -85,6 +86,7 @@ class DashScopeRerankConfig(BaseRerankConfig):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
if api_key is None:
|
||||
api_key = get_secret_str("DASHSCOPE_API_KEY")
|
||||
|
|
|
|||
|
|
@ -3,10 +3,31 @@ Helper util for handling databricks-specific cost calculation
|
|||
- e.g.: handling 'dbrx-instruct-*'
|
||||
"""
|
||||
|
||||
from types import MappingProxyType
|
||||
from typing import Final
|
||||
|
||||
from litellm.litellm_core_utils.llm_cost_calc.utils import generic_cost_per_token
|
||||
from litellm.types.utils import Usage
|
||||
from litellm.utils import get_model_info
|
||||
|
||||
_LEGACY_ENDPOINT_NAMES: Final = MappingProxyType(
|
||||
{
|
||||
"dbrx-instruct": "databricks-dbrx-instruct",
|
||||
"meta-llama-3.1-70b-instruct": "databricks-meta-llama-3-1-70b-instruct",
|
||||
"meta-llama-3.1-405b-instruct": "databricks-meta-llama-3-1-405b-instruct",
|
||||
"mixtral-8x7b-instruct-v0.1": "databricks-mixtral-8x7b-instruct",
|
||||
"bge-large-en": "databricks-bge-large-en",
|
||||
"gte-large-en": "databricks-gte-large-en",
|
||||
"llama-2-70b-chat": "databricks-llama-2-70b-chat",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _registry_key(model: str) -> str:
|
||||
name: Final = model.removeprefix("databricks/")
|
||||
return next(
|
||||
(key for prefix, key in _LEGACY_ENDPOINT_NAMES.items() if name.startswith(prefix)),
|
||||
name,
|
||||
)
|
||||
|
||||
|
||||
def cost_per_token(model: str, usage: Usage) -> tuple[float, float]:
|
||||
|
|
@ -20,36 +41,8 @@ def cost_per_token(model: str, usage: Usage) -> tuple[float, float]:
|
|||
Returns:
|
||||
Tuple[float, float] - prompt_cost_in_usd, completion_cost_in_usd
|
||||
"""
|
||||
base_model = model
|
||||
if model.startswith("databricks/dbrx-instruct") or model.startswith("dbrx-instruct"):
|
||||
base_model = "databricks-dbrx-instruct"
|
||||
elif model.startswith("databricks/meta-llama-3.1-70b-instruct") or model.startswith("meta-llama-3.1-70b-instruct"):
|
||||
base_model = "databricks-meta-llama-3-1-70b-instruct"
|
||||
elif model.startswith("databricks/meta-llama-3.1-405b-instruct") or model.startswith(
|
||||
"meta-llama-3.1-405b-instruct"
|
||||
):
|
||||
base_model = "databricks-meta-llama-3-1-405b-instruct"
|
||||
elif (
|
||||
model.startswith("databricks/mixtral-8x7b-instruct-v0.1")
|
||||
or model.startswith("mixtral-8x7b-instruct-v0.1")
|
||||
or model.startswith("databricks/mixtral-8x7b-instruct-v0.1")
|
||||
or model.startswith("mixtral-8x7b-instruct-v0.1")
|
||||
):
|
||||
base_model = "databricks-mixtral-8x7b-instruct"
|
||||
elif model.startswith("databricks/bge-large-en") or model.startswith("bge-large-en"):
|
||||
base_model = "databricks-bge-large-en"
|
||||
elif model.startswith("databricks/gte-large-en") or model.startswith("gte-large-en"):
|
||||
base_model = "databricks-gte-large-en"
|
||||
elif model.startswith("databricks/llama-2-70b-chat") or model.startswith("llama-2-70b-chat"):
|
||||
base_model = "databricks-llama-2-70b-chat"
|
||||
## GET MODEL INFO
|
||||
model_info: Final = get_model_info(model=base_model, custom_llm_provider="databricks")
|
||||
|
||||
## CALCULATE INPUT COST
|
||||
|
||||
prompt_cost: Final[float] = usage["prompt_tokens"] * model_info["input_cost_per_token"]
|
||||
|
||||
## CALCULATE OUTPUT COST
|
||||
completion_cost: Final = usage["completion_tokens"] * model_info["output_cost_per_token"]
|
||||
|
||||
return prompt_cost, completion_cost
|
||||
return generic_cost_per_token(
|
||||
model=_registry_key(model),
|
||||
usage=usage,
|
||||
custom_llm_provider="databricks",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
Translate between Cohere's `/rerank` format and Deepinfra's `/rerank` format.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -67,6 +68,7 @@ class DeepinfraRerankConfig(BaseRerankConfig):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
if api_key is None:
|
||||
api_key = get_secret_str("DEEPINFRA_API_KEY")
|
||||
|
|
|
|||
|
|
@ -504,6 +504,7 @@ class FireworksAIConfig(FireworksAIMixin, OpenAIGPTConfig):
|
|||
m = cast(dict, message)
|
||||
m.pop("provider_specific_fields", None)
|
||||
m.pop("thinking_blocks", None)
|
||||
m.pop("reasoning_content", None)
|
||||
|
||||
return messages
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ Fireworks AI Rerank API transformation
|
|||
Reference: https://docs.fireworks.ai/inference-api-reference/rerank
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -102,6 +103,7 @@ class FireworksAIRerankConfig(FireworksAIMixin, BaseRerankConfig):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
api_key = self._get_api_key(api_key)
|
||||
if api_key is None:
|
||||
|
|
|
|||
|
|
@ -164,12 +164,13 @@ class HostedVLLMChatConfig(OpenAIGPTConfig):
|
|||
"""
|
||||
Support translating:
|
||||
- video files from file_id or file_data to video_url
|
||||
- thinking_blocks on assistant messages are removed, and content lists
|
||||
are converted to strings for vLLM compatibility
|
||||
- thinking_blocks and reasoning_content on assistant messages are removed,
|
||||
and content lists are converted to strings for vLLM compatibility
|
||||
"""
|
||||
for message in messages:
|
||||
if message["role"] == "assistant":
|
||||
message.pop("thinking_blocks", None)
|
||||
message.pop("reasoning_content", None)
|
||||
existing_content = message.get("content")
|
||||
if isinstance(existing_content, list):
|
||||
text_parts = []
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
Transformation logic for Hosted VLLM rerank
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -107,6 +108,7 @@ class HostedVLLMRerankConfig(BaseRerankConfig):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
if api_key is None:
|
||||
api_key = get_secret_str("HOSTED_VLLM_API_KEY") or "fake-api-key"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import os
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -123,6 +124,7 @@ class HuggingFaceRerankConfig(BaseRerankConfig):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
api_base: str | None = None,
|
||||
) -> dict:
|
||||
# Get API credentials
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ Transformation logic from Cohere's /v1/rerank format to Infinity's `/v1/rerank`
|
|||
Why separate file? Make it easy to see how transformation works
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -46,6 +47,7 @@ class InfinityRerankConfig(CohereRerankConfig):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
if api_key is None:
|
||||
api_key = get_secret_str("INFINITY_API_KEY") or get_secret_str("INFINITY_API_KEY") or litellm.infinity_key
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ Why separate file? Make it easy to see how transformation works
|
|||
Docs - https://jina.ai/reranker
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Final
|
||||
|
||||
from httpx import URL, Response
|
||||
|
|
@ -139,6 +140,7 @@ class JinaAIRerankConfig(BaseRerankConfig):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
if api_key is None:
|
||||
raise ValueError("api_key is required. Set via `api_key` parameter or `JINA_API_KEY` environment variable.")
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from collections.abc import Mapping
|
||||
from typing import Any, Final, Literal
|
||||
|
||||
import httpx
|
||||
|
|
@ -152,6 +153,7 @@ class NvidiaNimRerankConfig(BaseRerankConfig):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Validate that the Nvidia NIM API key is present.
|
||||
|
|
|
|||
|
|
@ -134,14 +134,7 @@ def cost_per_second(model: str, custom_llm_provider: str | None, duration: float
|
|||
|
||||
|
||||
def _video_resolution_to_cost_field_suffix(resolution: str) -> str | None:
|
||||
"""
|
||||
Map usage resolution to a safe suffix for ``output_cost_per_second_<suffix>`` keys.
|
||||
|
||||
Note: Currently only ``output_cost_per_second_1080p`` is explicitly declared in
|
||||
ModelInfo (types/utils.py). Other resolution tiers (e.g., 720p, 4k) can be added
|
||||
to model_prices_and_context_window.json but are not exposed via get_model_info()
|
||||
until added to the ModelInfo TypedDict.
|
||||
"""
|
||||
"""Map usage resolution to a safe suffix for ``output_cost_per_second_<suffix>`` keys."""
|
||||
r: Final = resolution.strip().lower()
|
||||
if not r:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -101,6 +101,9 @@ class OpenAIVideoConfig(BaseVideoConfig):
|
|||
|
||||
return f"{api_base.rstrip('/')}/videos"
|
||||
|
||||
def use_multipart_form_data(self) -> bool:
|
||||
return True
|
||||
|
||||
def transform_video_create_request(
|
||||
self,
|
||||
model: str,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal
|
||||
|
||||
import httpx
|
||||
|
|
@ -33,6 +34,10 @@ else:
|
|||
LiteLLMLoggingObj = Any
|
||||
|
||||
|
||||
class RunwayMLError(BaseLLMException):
|
||||
pass
|
||||
|
||||
|
||||
class _RunwayTaskResponse(TypedDict, total=False):
|
||||
id: ReadOnly[str]
|
||||
status: ReadOnly[str]
|
||||
|
|
@ -41,7 +46,8 @@ class _RunwayTaskResponse(TypedDict, total=False):
|
|||
output: ReadOnly[Sequence[str] | str]
|
||||
failureCode: ReadOnly[str]
|
||||
failure: ReadOnly[str]
|
||||
progress: ReadOnly[int]
|
||||
progress: ReadOnly[float]
|
||||
estimatedCost: ReadOnly[Mapping[str, float]]
|
||||
|
||||
|
||||
class _VideoObjectData(TypedDict, extra_items=object):
|
||||
|
|
@ -56,12 +62,54 @@ def _parse_runway_task_response(raw_response: httpx.Response) -> _RunwayTaskResp
|
|||
return response_data
|
||||
|
||||
|
||||
_USD_PER_CREDIT: Final = 0.01
|
||||
|
||||
_RESOLUTION_AREA_TIERS: Final[tuple[tuple[int, str], ...]] = (
|
||||
(600_000, "480p"),
|
||||
(1_500_000, "720p"),
|
||||
(4_000_000, "1080p"),
|
||||
)
|
||||
|
||||
|
||||
def _ratio_to_resolution(ratio: object) -> str | None:
|
||||
if not isinstance(ratio, str) or ":" not in ratio:
|
||||
return None
|
||||
width_str, _, height_str = ratio.partition(":")
|
||||
if not (width_str.isdigit() and height_str.isdigit()):
|
||||
return None
|
||||
area: Final = int(width_str) * int(height_str)
|
||||
return next((label for threshold, label in _RESOLUTION_AREA_TIERS if area < threshold), "4k")
|
||||
|
||||
|
||||
def _duration_seconds(seconds: str | None) -> float | None:
|
||||
if not seconds:
|
||||
return None
|
||||
try:
|
||||
return float(seconds)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _estimated_cost_usd(response_data: _RunwayTaskResponse) -> float | None:
|
||||
estimated_cost: Final = response_data.get("estimatedCost")
|
||||
if not isinstance(estimated_cost, Mapping):
|
||||
return None
|
||||
credits: Final = estimated_cost.get("credits")
|
||||
if not isinstance(credits, (int, float)):
|
||||
return None
|
||||
return float(credits) * _USD_PER_CREDIT
|
||||
|
||||
|
||||
def _progress_percent(progress: float) -> int:
|
||||
return min(100, max(0, round(float(progress) * 100)))
|
||||
|
||||
|
||||
class RunwayMLVideoConfig(BaseVideoConfig):
|
||||
"""
|
||||
Configuration class for RunwayML video generation.
|
||||
|
||||
RunwayML uses a task-based API where:
|
||||
1. POST /v1/image_to_video creates a task
|
||||
1. POST /v1/text_to_video, /v1/image_to_video, or /v1/video_to_video creates a task
|
||||
2. The task returns immediately with a task ID
|
||||
3. Client must poll or wait for task completion
|
||||
"""
|
||||
|
|
@ -195,31 +243,36 @@ class RunwayMLVideoConfig(BaseVideoConfig):
|
|||
"""
|
||||
Transform the video creation request for RunwayML API.
|
||||
|
||||
RunwayML expects:
|
||||
{
|
||||
"model": "gen4_turbo",
|
||||
"promptImage": "https://... or data:image/...",
|
||||
"promptText": "description",
|
||||
"ratio": "1280:720",
|
||||
"duration": 5
|
||||
}
|
||||
RunwayML has three generation endpoints discriminated by which input is
|
||||
present, and each request body rejects unknown fields:
|
||||
- /text_to_video: promptText only (rejects promptImage)
|
||||
- /image_to_video: promptImage (+ optional promptText)
|
||||
- /video_to_video: promptVideo or videoUri (rejects promptImage)
|
||||
"""
|
||||
# Build the request data
|
||||
merged_params: Final = MappingProxyType(
|
||||
{
|
||||
"model": model,
|
||||
"promptText": prompt,
|
||||
**video_create_optional_request_params,
|
||||
}
|
||||
)
|
||||
|
||||
endpoint: Final = self._select_generation_endpoint(merged_params)
|
||||
|
||||
request_data: Final[dict[str, object]] = {
|
||||
"model": model,
|
||||
"promptText": prompt,
|
||||
key: value for key, value in merged_params.items() if endpoint == "image_to_video" or key != "promptImage"
|
||||
}
|
||||
|
||||
# Add mapped parameters
|
||||
request_data.update(video_create_optional_request_params)
|
||||
|
||||
# RunwayML uses JSON body, no files multipart
|
||||
files_list: Final[RequestFiles] = []
|
||||
|
||||
# Append the specific endpoint for video generation
|
||||
full_api_base: Final = f"{api_base}/image_to_video"
|
||||
return request_data, files_list, f"{api_base}/{endpoint}"
|
||||
|
||||
return request_data, files_list, full_api_base
|
||||
def _select_generation_endpoint(self, request_data: Mapping[str, object]) -> str:
|
||||
if request_data.get("promptVideo") is not None or request_data.get("videoUri") is not None:
|
||||
return "video_to_video"
|
||||
if request_data.get("promptImage") is not None:
|
||||
return "image_to_video"
|
||||
return "text_to_video"
|
||||
|
||||
def transform_video_create_response(
|
||||
self,
|
||||
|
|
@ -285,13 +338,15 @@ class RunwayMLVideoConfig(BaseVideoConfig):
|
|||
video_obj.id = encode_video_id_with_provider(video_obj.id, custom_llm_provider, model)
|
||||
|
||||
# Add usage data for cost tracking
|
||||
usage_data: Final = {}
|
||||
if video_obj and hasattr(video_obj, "seconds") and video_obj.seconds:
|
||||
try:
|
||||
usage_data["duration_seconds"] = float(video_obj.seconds)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
video_obj.usage = usage_data
|
||||
video_obj.usage = {
|
||||
key: value
|
||||
for key, value in (
|
||||
("duration_seconds", _duration_seconds(video_obj.seconds)),
|
||||
("video_resolution", _ratio_to_resolution(request_data.get("ratio") if request_data else None)),
|
||||
("provider_reported_cost_usd", _estimated_cost_usd(response_data)),
|
||||
)
|
||||
if value is not None
|
||||
}
|
||||
|
||||
return video_obj
|
||||
|
||||
|
|
@ -581,8 +636,9 @@ class RunwayMLVideoConfig(BaseVideoConfig):
|
|||
if "completedAt" in response_data:
|
||||
video_data["completed_at"] = self._parse_runway_timestamp(response_data.get("completedAt"))
|
||||
|
||||
if "progress" in response_data:
|
||||
video_data["progress"] = response_data["progress"]
|
||||
progress_value: Final = response_data.get("progress")
|
||||
if progress_value is not None:
|
||||
video_data["progress"] = _progress_percent(progress_value)
|
||||
|
||||
if "failureCode" in response_data or "failure" in response_data:
|
||||
video_data["error"] = {
|
||||
|
|
@ -646,9 +702,7 @@ class RunwayMLVideoConfig(BaseVideoConfig):
|
|||
raise NotImplementedError("video extension is not supported for RunwayML")
|
||||
|
||||
def get_error_class(self, error_message: str, status_code: int, headers: dict | httpx.Headers) -> BaseLLMException:
|
||||
from ...base_llm.chat.transformation import BaseLLMException
|
||||
|
||||
raise BaseLLMException(
|
||||
return RunwayMLError(
|
||||
status_code=status_code,
|
||||
message=error_message,
|
||||
headers=headers,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ Translates from Cohere's `/v1/rerank` input format to Vertex AI Discovery Engine
|
|||
Why separate file? Make it easy to see how transformation works
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -74,14 +75,15 @@ class VertexAIRerankConfig(BaseRerankConfig, VertexBase):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Validate and set up authentication for Vertex AI Discovery Engine API
|
||||
"""
|
||||
# Get credentials and project info from optional_params (which contains vertex_credentials, etc.)
|
||||
litellm_params: Final = optional_params.copy() if optional_params else {}
|
||||
vertex_credentials: Final = self.safe_get_vertex_ai_credentials(litellm_params)
|
||||
vertex_project: Final = self.safe_get_vertex_ai_project(litellm_params)
|
||||
vertex_params: Final = optional_params.copy() if optional_params else {}
|
||||
vertex_credentials: Final = self.safe_get_vertex_ai_credentials(vertex_params)
|
||||
vertex_project: Final = self.safe_get_vertex_ai_project(vertex_params)
|
||||
|
||||
# Get access token using the base class method
|
||||
access_token, project_id = self._ensure_access_token(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ Transformation logic for Voyage AI's /v1/rerank endpoint.
|
|||
Docs - https://docs.voyageai.com/docs/reranker
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -137,6 +138,7 @@ class VoyageRerankConfig(BaseRerankConfig):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
if api_key is None:
|
||||
api_key = get_secret_str("VOYAGE_API_KEY") or get_secret_str("VOYAGE_AI_API_KEY")
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Docs - https://cloud.ibm.com/apidocs/watsonx-ai#text-rerank
|
|||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Final, cast
|
||||
|
||||
import httpx
|
||||
|
|
@ -60,6 +61,7 @@ class IBMWatsonXRerankConfig(IBMWatsonXMixin, BaseRerankConfig):
|
|||
model: str,
|
||||
api_key: str | None = None,
|
||||
optional_params: dict | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
) -> dict:
|
||||
optional_params = optional_params or {}
|
||||
|
||||
|
|
|
|||
|
|
@ -6828,6 +6828,8 @@ def embedding(
|
|||
aembedding=aembedding,
|
||||
)
|
||||
elif custom_llm_provider == "azure_ai":
|
||||
from litellm.llms.azure_ai.common_utils import get_azure_ai_entra_token
|
||||
|
||||
api_base = (
|
||||
api_base # for deepinfra/perplexity/anyscale/groq/friendliai we check in get_llm_provider and pass in the api base from there
|
||||
or litellm.api_base
|
||||
|
|
@ -6837,8 +6839,8 @@ def embedding(
|
|||
api_key = (
|
||||
api_key
|
||||
or litellm.api_key # for deepinfra/perplexity/anyscale/friendliai we check in get_llm_provider and pass in the api key from there
|
||||
or litellm.openai_key
|
||||
or get_secret_str("AZURE_AI_API_KEY")
|
||||
or get_azure_ai_entra_token(litellm_params=litellm_params_dict)
|
||||
)
|
||||
|
||||
## EMBEDDING CALL
|
||||
|
|
@ -8566,7 +8568,7 @@ def stream_chunk_builder(
|
|||
if len(chunks) == 0:
|
||||
return None
|
||||
## Route to the text completion logic
|
||||
first_chunk_with_choices: Final = next((c for c in chunks if c["choices"]), None)
|
||||
first_chunk_with_choices: Final = next((c for c in chunks if c.get("choices")), None)
|
||||
if first_chunk_with_choices is not None and isinstance(
|
||||
first_chunk_with_choices["choices"][0], litellm.utils.TextChoices
|
||||
): # route to the text completion logic
|
||||
|
|
@ -8581,7 +8583,7 @@ def stream_chunk_builder(
|
|||
simple_content_parts: Final[list[str]] = []
|
||||
is_simple_text_stream = True
|
||||
for chunk in chunks:
|
||||
if len(chunk["choices"]) == 0:
|
||||
if not chunk.get("choices"):
|
||||
continue
|
||||
|
||||
choice = chunk["choices"][0]
|
||||
|
|
@ -8647,7 +8649,7 @@ def stream_chunk_builder(
|
|||
tool_call_chunks: Final = [
|
||||
chunk
|
||||
for chunk in chunks
|
||||
if len(chunk["choices"]) > 0
|
||||
if chunk.get("choices")
|
||||
and "tool_calls" in chunk["choices"][0]["delta"]
|
||||
and chunk["choices"][0]["delta"]["tool_calls"] is not None
|
||||
]
|
||||
|
|
@ -8661,7 +8663,7 @@ def stream_chunk_builder(
|
|||
function_call_chunks: Final = [
|
||||
chunk
|
||||
for chunk in chunks
|
||||
if len(chunk["choices"]) > 0
|
||||
if chunk.get("choices")
|
||||
and "function_call" in chunk["choices"][0]["delta"]
|
||||
and chunk["choices"][0]["delta"]["function_call"] is not None
|
||||
]
|
||||
|
|
@ -8674,7 +8676,7 @@ def stream_chunk_builder(
|
|||
content_chunks: Final = [
|
||||
chunk
|
||||
for chunk in chunks
|
||||
if len(chunk["choices"]) > 0
|
||||
if chunk.get("choices")
|
||||
and "content" in chunk["choices"][0]["delta"]
|
||||
and chunk["choices"][0]["delta"]["content"] is not None
|
||||
]
|
||||
|
|
@ -8685,7 +8687,7 @@ def stream_chunk_builder(
|
|||
thinking_blocks: Final = [
|
||||
chunk
|
||||
for chunk in chunks
|
||||
if len(chunk["choices"]) > 0
|
||||
if chunk.get("choices")
|
||||
and "thinking_blocks" in chunk["choices"][0]["delta"]
|
||||
and chunk["choices"][0]["delta"]["thinking_blocks"] is not None
|
||||
]
|
||||
|
|
@ -8698,7 +8700,7 @@ def stream_chunk_builder(
|
|||
reasoning_chunks: Final = [
|
||||
chunk
|
||||
for chunk in chunks
|
||||
if len(chunk["choices"]) > 0
|
||||
if chunk.get("choices")
|
||||
and "reasoning_content" in chunk["choices"][0]["delta"]
|
||||
and chunk["choices"][0]["delta"]["reasoning_content"] is not None
|
||||
]
|
||||
|
|
@ -8711,7 +8713,7 @@ def stream_chunk_builder(
|
|||
annotation_chunks: Final = [
|
||||
chunk
|
||||
for chunk in chunks
|
||||
if len(chunk["choices"]) > 0
|
||||
if chunk.get("choices")
|
||||
and "annotations" in chunk["choices"][0]["delta"]
|
||||
and chunk["choices"][0]["delta"]["annotations"] is not None
|
||||
]
|
||||
|
|
@ -8728,7 +8730,7 @@ def stream_chunk_builder(
|
|||
audio_chunks: Final = [
|
||||
chunk
|
||||
for chunk in chunks
|
||||
if len(chunk["choices"]) > 0
|
||||
if chunk.get("choices")
|
||||
and "audio" in chunk["choices"][0]["delta"]
|
||||
and chunk["choices"][0]["delta"]["audio"] is not None
|
||||
]
|
||||
|
|
@ -8742,7 +8744,7 @@ def stream_chunk_builder(
|
|||
image_chunks: Final = [
|
||||
chunk
|
||||
for chunk in chunks
|
||||
if len(chunk["choices"]) > 0
|
||||
if chunk.get("choices")
|
||||
and "images" in chunk["choices"][0]["delta"]
|
||||
and chunk["choices"][0]["delta"]["images"] is not None
|
||||
]
|
||||
|
|
@ -8759,7 +8761,7 @@ def stream_chunk_builder(
|
|||
provider_specific_chunks: Final = [
|
||||
chunk
|
||||
for chunk in chunks
|
||||
if len(chunk["choices"]) > 0
|
||||
if chunk.get("choices")
|
||||
and "provider_specific_fields" in chunk["choices"][0]["delta"]
|
||||
and chunk["choices"][0]["delta"]["provider_specific_fields"] is not None
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -14551,6 +14567,8 @@
|
|||
]
|
||||
},
|
||||
"databricks/databricks-bge-large-en": {
|
||||
"cache_creation_input_token_cost": 1.0003e-07,
|
||||
"cache_read_input_token_cost": 1.0003e-07,
|
||||
"input_cost_per_token": 1.0003e-07,
|
||||
"input_dbu_cost_per_token": 1.429e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14566,6 +14584,8 @@
|
|||
"source": "https://www.databricks.com/product/pricing/foundation-model-serving"
|
||||
},
|
||||
"databricks/databricks-claude-3-7-sonnet": {
|
||||
"cache_creation_input_token_cost": 3.74997e-06,
|
||||
"cache_read_input_token_cost": 3.0002e-07,
|
||||
"input_cost_per_token": 2.9999900000000002e-06,
|
||||
"input_dbu_cost_per_token": 4.2857e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14581,10 +14601,41 @@
|
|||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-claude-fable-5": {
|
||||
"cache_creation_input_token_cost": 1.250004e-05,
|
||||
"cache_read_input_token_cost": 1.00002e-06,
|
||||
"input_cost_per_token": 1.000006e-05,
|
||||
"input_dbu_cost_per_token": 0.000142858,
|
||||
"litellm_provider": "databricks",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"metadata": {
|
||||
"notes": "Costs per token are the published Global DBU rates times $0.070 per DBU. The '*_dbu_cost_per_token' fields are provided for reference; cost calculation reads the dollar '*_cost_per_token' fields."
|
||||
},
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 5.000002e-05,
|
||||
"output_dbu_cost_per_token": 0.000714286,
|
||||
"prompt_cache_min_tokens": 512,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_function_calling": true,
|
||||
"supports_mid_conversation_system": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_sampling_params": false,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": false,
|
||||
"thinking_always_on": true
|
||||
},
|
||||
"databricks/databricks-claude-haiku-4-5": {
|
||||
"cache_creation_input_token_cost": 1.24999e-06,
|
||||
"cache_read_input_token_cost": 1.0003e-07,
|
||||
"input_cost_per_token": 1.00002e-06,
|
||||
"input_dbu_cost_per_token": 1.4286e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14600,10 +14651,13 @@
|
|||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-claude-opus-4": {
|
||||
"cache_creation_input_token_cost": 1.874999e-05,
|
||||
"cache_read_input_token_cost": 1.50003e-06,
|
||||
"input_cost_per_token": 1.5000020000000002e-05,
|
||||
"input_dbu_cost_per_token": 0.000214286,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14619,10 +14673,13 @@
|
|||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-claude-opus-4-1": {
|
||||
"cache_creation_input_token_cost": 1.874999e-05,
|
||||
"cache_read_input_token_cost": 1.50003e-06,
|
||||
"input_cost_per_token": 1.5000020000000002e-05,
|
||||
"input_dbu_cost_per_token": 0.000214286,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14638,10 +14695,13 @@
|
|||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-claude-opus-4-5": {
|
||||
"cache_creation_input_token_cost": 6.25002e-06,
|
||||
"cache_read_input_token_cost": 5.0001e-07,
|
||||
"input_cost_per_token": 5.00003e-06,
|
||||
"input_dbu_cost_per_token": 7.1429e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14657,11 +14717,14 @@
|
|||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_output_config": true
|
||||
},
|
||||
"databricks/databricks-claude-opus-4-6": {
|
||||
"cache_creation_input_token_cost": 6.25002e-06,
|
||||
"cache_read_input_token_cost": 5.0001e-07,
|
||||
"input_cost_per_token": 5.00003e-06,
|
||||
"input_dbu_cost_per_token": 7.1429e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14677,10 +14740,94 @@
|
|||
"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
|
||||
},
|
||||
"databricks/databricks-claude-opus-4-7": {
|
||||
"cache_creation_input_token_cost": 6.25002e-06,
|
||||
"cache_read_input_token_cost": 5.0001e-07,
|
||||
"input_cost_per_token": 5.00003e-06,
|
||||
"input_dbu_cost_per_token": 7.1429e-05,
|
||||
"litellm_provider": "databricks",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"metadata": {
|
||||
"notes": "Costs per token are the published Global DBU rates times $0.070 per DBU. The '*_dbu_cost_per_token' fields are provided for reference; cost calculation reads the dollar '*_cost_per_token' fields."
|
||||
},
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2.500001e-05,
|
||||
"output_dbu_cost_per_token": 0.000357143,
|
||||
"prompt_cache_min_tokens": 2048,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_sampling_params": false,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"databricks/databricks-claude-opus-4-8": {
|
||||
"cache_creation_input_token_cost": 6.25002e-06,
|
||||
"cache_read_input_token_cost": 5.0001e-07,
|
||||
"input_cost_per_token": 5.00003e-06,
|
||||
"input_dbu_cost_per_token": 7.1429e-05,
|
||||
"litellm_provider": "databricks",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"metadata": {
|
||||
"notes": "Costs per token are the published Global DBU rates times $0.070 per DBU. The '*_dbu_cost_per_token' fields are provided for reference; cost calculation reads the dollar '*_cost_per_token' fields."
|
||||
},
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2.500001e-05,
|
||||
"output_dbu_cost_per_token": 0.000357143,
|
||||
"prompt_cache_min_tokens": 1024,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_function_calling": true,
|
||||
"supports_mid_conversation_system": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_sampling_params": false,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"databricks/databricks-claude-opus-5": {
|
||||
"cache_creation_input_token_cost": 6.25002e-06,
|
||||
"cache_read_input_token_cost": 5.0001e-07,
|
||||
"input_cost_per_token": 5.00003e-06,
|
||||
"input_dbu_cost_per_token": 7.1429e-05,
|
||||
"litellm_provider": "databricks",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"metadata": {
|
||||
"notes": "Costs per token are the published Global DBU rates times $0.070 per DBU. The '*_dbu_cost_per_token' fields are provided for reference; cost calculation reads the dollar '*_cost_per_token' fields."
|
||||
},
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2.500001e-05,
|
||||
"output_dbu_cost_per_token": 0.000357143,
|
||||
"prompt_cache_min_tokens": 512,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_function_calling": true,
|
||||
"supports_mid_conversation_system": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_sampling_params": false,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"databricks/databricks-claude-sonnet-4": {
|
||||
"cache_creation_input_token_cost": 3.74997e-06,
|
||||
"cache_read_input_token_cost": 3.0002e-07,
|
||||
"input_cost_per_token": 2.9999900000000002e-06,
|
||||
"input_dbu_cost_per_token": 4.2857e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14696,10 +14843,13 @@
|
|||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-claude-sonnet-4-1": {
|
||||
"cache_creation_input_token_cost": 3.74997e-06,
|
||||
"cache_read_input_token_cost": 3.0002e-07,
|
||||
"input_cost_per_token": 2.9999900000000002e-06,
|
||||
"input_dbu_cost_per_token": 4.2857e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14715,10 +14865,13 @@
|
|||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-claude-sonnet-4-5": {
|
||||
"cache_creation_input_token_cost": 3.74997e-06,
|
||||
"cache_read_input_token_cost": 3.0002e-07,
|
||||
"input_cost_per_token": 2.9999900000000002e-06,
|
||||
"input_dbu_cost_per_token": 4.2857e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14734,10 +14887,13 @@
|
|||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-claude-sonnet-4-6": {
|
||||
"cache_creation_input_token_cost": 3.74997e-06,
|
||||
"cache_read_input_token_cost": 3.0002e-07,
|
||||
"input_cost_per_token": 2.9999900000000002e-06,
|
||||
"input_dbu_cost_per_token": 4.2857e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14753,10 +14909,41 @@
|
|||
"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
|
||||
},
|
||||
"databricks/databricks-claude-sonnet-5": {
|
||||
"cache_creation_input_token_cost": 3.74997e-06,
|
||||
"cache_read_input_token_cost": 3.0002e-07,
|
||||
"input_cost_per_token": 2.99999e-06,
|
||||
"input_dbu_cost_per_token": 4.2857e-05,
|
||||
"litellm_provider": "databricks",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"metadata": {
|
||||
"notes": "Costs per token are the published Global DBU rates times $0.070 per DBU. The '*_dbu_cost_per_token' fields are provided for reference; cost calculation reads the dollar '*_cost_per_token' fields. Introductory launch rates of 28.571 input / 142.857 output / 35.714 cache write / 2.857 cache read DBU run through 2026-08-31; the standard rates are listed here because entries carry no expiry date."
|
||||
},
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 1.500002e-05,
|
||||
"output_dbu_cost_per_token": 0.000214286,
|
||||
"prompt_cache_min_tokens": 1024,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_function_calling": true,
|
||||
"supports_mid_conversation_system": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_sampling_params": false,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"databricks/databricks-gemini-2-5-flash": {
|
||||
"cache_creation_input_token_cost": 3.0002e-07,
|
||||
"cache_read_input_token_cost": 3.0002e-08,
|
||||
"input_cost_per_token": 3.0001999999999996e-07,
|
||||
"input_dbu_cost_per_token": 4.285999999999999e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14771,9 +14958,12 @@
|
|||
"output_dbu_cost_per_token": 3.5714e-05,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-gemini-2-5-pro": {
|
||||
"cache_creation_input_token_cost": 1.24999e-06,
|
||||
"cache_read_input_token_cost": 1.24999e-07,
|
||||
"input_cost_per_token": 1.24999e-06,
|
||||
"input_dbu_cost_per_token": 1.7857e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14788,9 +14978,12 @@
|
|||
"output_dbu_cost_per_token": 0.000142857,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-gemini-3-1-flash-lite": {
|
||||
"cache_creation_input_token_cost": 3.1248e-07,
|
||||
"cache_read_input_token_cost": 3.122e-08,
|
||||
"input_cost_per_token": 3.1248e-07,
|
||||
"input_dbu_cost_per_token": 4.464e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14805,9 +14998,12 @@
|
|||
"output_dbu_cost_per_token": 2.6786e-05,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-gemini-3-1-pro": {
|
||||
"cache_creation_input_token_cost": 2.49998e-06,
|
||||
"cache_read_input_token_cost": 2.4997e-07,
|
||||
"input_cost_per_token": 2.49998e-06,
|
||||
"input_dbu_cost_per_token": 3.5714e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14822,9 +15018,12 @@
|
|||
"output_dbu_cost_per_token": 0.000214286,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-gemini-3-flash": {
|
||||
"cache_creation_input_token_cost": 6.2503e-07,
|
||||
"cache_read_input_token_cost": 6.251e-08,
|
||||
"input_cost_per_token": 6.2503e-07,
|
||||
"input_dbu_cost_per_token": 8.929e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14839,9 +15038,12 @@
|
|||
"output_dbu_cost_per_token": 5.3571e-05,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-gemini-3-pro": {
|
||||
"cache_creation_input_token_cost": 2.49998e-06,
|
||||
"cache_read_input_token_cost": 2.4997e-07,
|
||||
"input_cost_per_token": 2.49998e-06,
|
||||
"input_dbu_cost_per_token": 3.5714e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14856,9 +15058,12 @@
|
|||
"output_dbu_cost_per_token": 0.000214286,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-gemma-3-12b": {
|
||||
"cache_creation_input_token_cost": 1.5001e-07,
|
||||
"cache_read_input_token_cost": 1.5001e-07,
|
||||
"input_cost_per_token": 1.5000999999999998e-07,
|
||||
"input_dbu_cost_per_token": 2.1429999999999996e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14874,6 +15079,8 @@
|
|||
"source": "https://www.databricks.com/product/pricing/foundation-model-serving"
|
||||
},
|
||||
"databricks/databricks-gpt-5": {
|
||||
"cache_creation_input_token_cost": 1.24999e-06,
|
||||
"cache_read_input_token_cost": 1.2502e-07,
|
||||
"input_cost_per_token": 1.24999e-06,
|
||||
"input_dbu_cost_per_token": 1.7857e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14886,9 +15093,12 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 9.999990000000002e-06,
|
||||
"output_dbu_cost_per_token": 0.000142857,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving"
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_prompt_caching": true
|
||||
},
|
||||
"databricks/databricks-gpt-5-1": {
|
||||
"cache_creation_input_token_cost": 1.24999e-06,
|
||||
"cache_read_input_token_cost": 1.2502e-07,
|
||||
"input_cost_per_token": 1.24999e-06,
|
||||
"input_dbu_cost_per_token": 1.7857e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14901,9 +15111,12 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 9.999990000000002e-06,
|
||||
"output_dbu_cost_per_token": 0.000142857,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving"
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_prompt_caching": true
|
||||
},
|
||||
"databricks/databricks-gpt-5-1-codex-max": {
|
||||
"cache_creation_input_token_cost": 1.24999e-06,
|
||||
"cache_read_input_token_cost": 1.2502e-07,
|
||||
"input_cost_per_token": 1.24999e-06,
|
||||
"input_dbu_cost_per_token": 1.7857e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14916,9 +15129,12 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 9.999990000000002e-06,
|
||||
"output_dbu_cost_per_token": 0.000142857,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving"
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_prompt_caching": true
|
||||
},
|
||||
"databricks/databricks-gpt-5-1-codex-mini": {
|
||||
"cache_creation_input_token_cost": 2.4997e-07,
|
||||
"cache_read_input_token_cost": 2.499e-08,
|
||||
"input_cost_per_token": 2.4997e-07,
|
||||
"input_dbu_cost_per_token": 3.571e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14931,9 +15147,12 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 1.99997e-06,
|
||||
"output_dbu_cost_per_token": 2.8571e-05,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving"
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_prompt_caching": true
|
||||
},
|
||||
"databricks/databricks-gpt-5-2": {
|
||||
"cache_creation_input_token_cost": 1.75e-06,
|
||||
"cache_read_input_token_cost": 1.75e-07,
|
||||
"input_cost_per_token": 1.75e-06,
|
||||
"input_dbu_cost_per_token": 2.5e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14946,9 +15165,12 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 1.4e-05,
|
||||
"output_dbu_cost_per_token": 0.0002,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving"
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_prompt_caching": true
|
||||
},
|
||||
"databricks/databricks-gpt-5-2-codex": {
|
||||
"cache_creation_input_token_cost": 1.75e-06,
|
||||
"cache_read_input_token_cost": 1.75e-07,
|
||||
"input_cost_per_token": 1.75e-06,
|
||||
"input_dbu_cost_per_token": 2.5e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14961,9 +15183,12 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 1.4e-05,
|
||||
"output_dbu_cost_per_token": 0.0002,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving"
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_prompt_caching": true
|
||||
},
|
||||
"databricks/databricks-gpt-5-3-codex": {
|
||||
"cache_creation_input_token_cost": 1.75e-06,
|
||||
"cache_read_input_token_cost": 1.75e-07,
|
||||
"input_cost_per_token": 1.75e-06,
|
||||
"input_dbu_cost_per_token": 2.5e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14976,9 +15201,12 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 1.4e-05,
|
||||
"output_dbu_cost_per_token": 0.0002,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving"
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_prompt_caching": true
|
||||
},
|
||||
"databricks/databricks-gpt-5-4": {
|
||||
"cache_creation_input_token_cost": 2.49998e-06,
|
||||
"cache_read_input_token_cost": 2.4997e-07,
|
||||
"input_cost_per_token": 2.49998e-06,
|
||||
"input_dbu_cost_per_token": 3.5714e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14991,9 +15219,12 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 1.5000020000000002e-05,
|
||||
"output_dbu_cost_per_token": 0.000214286,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving"
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_prompt_caching": true
|
||||
},
|
||||
"databricks/databricks-gpt-5-4-mini": {
|
||||
"cache_creation_input_token_cost": 7.4998e-07,
|
||||
"cache_read_input_token_cost": 7.497e-08,
|
||||
"input_cost_per_token": 7.4998e-07,
|
||||
"input_dbu_cost_per_token": 1.0714e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15006,9 +15237,12 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 4.50002e-06,
|
||||
"output_dbu_cost_per_token": 6.4286e-05,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving"
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_prompt_caching": true
|
||||
},
|
||||
"databricks/databricks-gpt-5-4-nano": {
|
||||
"cache_creation_input_token_cost": 1.9999e-07,
|
||||
"cache_read_input_token_cost": 2.002e-08,
|
||||
"input_cost_per_token": 1.9999e-07,
|
||||
"input_dbu_cost_per_token": 2.857e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15021,9 +15255,12 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 1.24999e-06,
|
||||
"output_dbu_cost_per_token": 1.7857e-05,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving"
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_prompt_caching": true
|
||||
},
|
||||
"databricks/databricks-gpt-5-mini": {
|
||||
"cache_creation_input_token_cost": 2.4997e-07,
|
||||
"cache_read_input_token_cost": 2.499e-08,
|
||||
"input_cost_per_token": 2.4997000000000006e-07,
|
||||
"input_dbu_cost_per_token": 3.571e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15036,9 +15273,12 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 1.9999700000000004e-06,
|
||||
"output_dbu_cost_per_token": 2.8571e-05,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving"
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_prompt_caching": true
|
||||
},
|
||||
"databricks/databricks-gpt-5-nano": {
|
||||
"cache_creation_input_token_cost": 4.998e-08,
|
||||
"cache_read_input_token_cost": 4.97e-09,
|
||||
"input_cost_per_token": 4.998e-08,
|
||||
"input_dbu_cost_per_token": 7.14e-07,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15051,9 +15291,12 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 3.9998000000000007e-07,
|
||||
"output_dbu_cost_per_token": 5.714000000000001e-06,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving"
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_prompt_caching": true
|
||||
},
|
||||
"databricks/databricks-gpt-oss-120b": {
|
||||
"cache_creation_input_token_cost": 1.5001e-07,
|
||||
"cache_read_input_token_cost": 1.5001e-07,
|
||||
"input_cost_per_token": 1.5000999999999998e-07,
|
||||
"input_dbu_cost_per_token": 2.1429999999999996e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15069,6 +15312,8 @@
|
|||
"source": "https://www.databricks.com/product/pricing/foundation-model-serving"
|
||||
},
|
||||
"databricks/databricks-gpt-oss-20b": {
|
||||
"cache_creation_input_token_cost": 7e-08,
|
||||
"cache_read_input_token_cost": 7e-08,
|
||||
"input_cost_per_token": 7e-08,
|
||||
"input_dbu_cost_per_token": 1e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15084,6 +15329,8 @@
|
|||
"source": "https://www.databricks.com/product/pricing/foundation-model-serving"
|
||||
},
|
||||
"databricks/databricks-gte-large-en": {
|
||||
"cache_creation_input_token_cost": 1.2999e-07,
|
||||
"cache_read_input_token_cost": 1.2999e-07,
|
||||
"input_cost_per_token": 1.2999000000000001e-07,
|
||||
"input_dbu_cost_per_token": 1.857e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15099,6 +15346,8 @@
|
|||
"source": "https://www.databricks.com/product/pricing/foundation-model-serving"
|
||||
},
|
||||
"databricks/databricks-llama-2-70b-chat": {
|
||||
"cache_creation_input_token_cost": 5.0001e-07,
|
||||
"cache_read_input_token_cost": 5.0001e-07,
|
||||
"input_cost_per_token": 5.0001e-07,
|
||||
"input_dbu_cost_per_token": 7.143e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15115,6 +15364,8 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-llama-4-maverick": {
|
||||
"cache_creation_input_token_cost": 5.0001e-07,
|
||||
"cache_read_input_token_cost": 5.0001e-07,
|
||||
"input_cost_per_token": 5.0001e-07,
|
||||
"input_dbu_cost_per_token": 7.143e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15131,6 +15382,8 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-meta-llama-3-1-405b-instruct": {
|
||||
"cache_creation_input_token_cost": 5.00003e-06,
|
||||
"cache_read_input_token_cost": 5.00003e-06,
|
||||
"input_cost_per_token": 5.00003e-06,
|
||||
"input_dbu_cost_per_token": 7.1429e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15147,6 +15400,8 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-meta-llama-3-1-8b-instruct": {
|
||||
"cache_creation_input_token_cost": 1.5001e-07,
|
||||
"cache_read_input_token_cost": 1.5001e-07,
|
||||
"input_cost_per_token": 1.5000999999999998e-07,
|
||||
"input_dbu_cost_per_token": 2.1429999999999996e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15162,6 +15417,8 @@
|
|||
"source": "https://www.databricks.com/product/pricing/foundation-model-serving"
|
||||
},
|
||||
"databricks/databricks-meta-llama-3-3-70b-instruct": {
|
||||
"cache_creation_input_token_cost": 5.0001e-07,
|
||||
"cache_read_input_token_cost": 5.0001e-07,
|
||||
"input_cost_per_token": 5.0001e-07,
|
||||
"input_dbu_cost_per_token": 7.143e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15178,6 +15435,8 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-meta-llama-3-70b-instruct": {
|
||||
"cache_creation_input_token_cost": 1.00002e-06,
|
||||
"cache_read_input_token_cost": 1.00002e-06,
|
||||
"input_cost_per_token": 1.00002e-06,
|
||||
"input_dbu_cost_per_token": 1.4286e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15194,6 +15453,8 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-mixtral-8x7b-instruct": {
|
||||
"cache_creation_input_token_cost": 5.0001e-07,
|
||||
"cache_read_input_token_cost": 5.0001e-07,
|
||||
"input_cost_per_token": 5.0001e-07,
|
||||
"input_dbu_cost_per_token": 7.143e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15210,6 +15471,8 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-mpt-30b-instruct": {
|
||||
"cache_creation_input_token_cost": 1.00002e-06,
|
||||
"cache_read_input_token_cost": 1.00002e-06,
|
||||
"input_cost_per_token": 1.00002e-06,
|
||||
"input_dbu_cost_per_token": 1.4286e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15226,6 +15489,8 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-mpt-7b-instruct": {
|
||||
"cache_creation_input_token_cost": 5.0001e-07,
|
||||
"cache_read_input_token_cost": 5.0001e-07,
|
||||
"input_cost_per_token": 5.0001e-07,
|
||||
"input_dbu_cost_per_token": 7.143e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -23180,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,
|
||||
|
|
@ -33563,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,
|
||||
|
|
@ -33607,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,
|
||||
|
|
@ -35681,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,
|
||||
|
|
@ -39056,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,
|
||||
|
|
@ -40315,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,
|
||||
|
|
@ -40347,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,
|
||||
|
|
@ -40712,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,
|
||||
|
|
@ -43795,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",
|
||||
|
|
@ -43808,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",
|
||||
|
|
@ -43824,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": {
|
||||
|
|
@ -48481,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,
|
||||
|
|
@ -49265,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,
|
||||
|
|
@ -50255,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)-",
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1 +0,0 @@
|
|||
(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,346328,e=>{"use strict";var t=e.i(843476),r=e.i(271645),l=e.i(618566),i=e.i(434166);let s=()=>{let e=(0,l.useSearchParams)(),s=(0,r.useMemo)(()=>e?{type:"litellm-mcp-oauth",code:e.get("code"),state:e.get("state"),error:e.get("error"),error_description:e.get("error_description")}:null,[e]);return(0,r.useEffect)(()=>{if(!s)return;try{let e=JSON.stringify(s);(0,i.setSecureItem)("litellm-mcp-oauth-result",e),(0,i.setSecureItem)("litellm-user-mcp-oauth-result",e),(0,i.setSecureItem)("litellm-tools-mcp-oauth-result",e)}catch(e){}let e=(0,i.getSecureItem)("litellm-mcp-oauth-return-url"),t=(()=>{let e=window.location.pathname||"",t=e.indexOf("/ui");if(t>=0){let r=e.slice(0,t+3);return r.endsWith("/")?r:`${r}`}return"/"})();if(e)try{let r=new URL(e,window.location.origin);r.origin===window.location.origin&&(t=r.href)}catch{}window.location.replace(t)},[s]),(0,t.jsx)("div",{className:"min-h-screen flex items-center justify-center bg-muted p-6",children:(0,t.jsxs)("div",{className:"max-w-lg w-full rounded-lg bg-card shadow-md p-8 text-center space-y-4",children:[(0,t.jsx)("h1",{className:"text-xl font-semibold text-foreground",children:"LiteLLM MCP OAuth"}),(0,t.jsx)("p",{className:"text-sm text-foreground",children:"Authorization complete. You may close this window and return to the LiteLLM dashboard."}),(0,t.jsx)("p",{className:"text-xs text-muted-foreground",children:"If the window does not close automatically, everything is still saved—you can close it manually."})]})})};e.s(["default",0,()=>(0,t.jsx)(r.Suspense,{fallback:(0,t.jsx)("div",{className:"min-h-screen flex items-center justify-center",children:"Loading..."}),children:(0,t.jsx)(s,{})})])}]);
|
||||
|
|
@ -1 +0,0 @@
|
|||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DBRX</title><path d="M21.821 9.894l-9.81 5.595L1.505 9.511 1 9.787v4.34l11.01 6.256 9.811-5.574v2.297l-9.81 5.596-10.506-5.979L1 17v.745L12.01 24 23 17.745v-4.34l-.505-.277-10.484 5.957-9.832-5.574v-2.298l9.832 5.574L23 10.532V6.255l-.547-.319-10.442 5.936-9.327-5.276 9.327-5.298 7.663 4.362.673-.383v-.532L12.011 0 1 6.255v.681l11.01 6.255 9.811-5.595z" fill="#EE3D2C" fill-rule="nonzero"></path></svg>
|
||||
|
Before Width: | Height: | Size: 528 B After Width: | Height: | Size: 0 B |
File diff suppressed because one or more lines are too long
|
|
@ -1,4 +0,0 @@
|
|||
:HL["/litellm-asset-prefix/_next/static/chunks/1kid9zr1--h6y.css","style"]
|
||||
:HL["/litellm-asset-prefix/_next/static/chunks/2hicgq-mjp8vy.css","style"]
|
||||
:HL["/litellm-asset-prefix/_next/static/media/83afe278b6a6bb3c-s.p.2bn3s6zvc0dyp.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
|
||||
0:{"tree":{"name":"","param":null,"prefetchHints":16,"slots":{"children":{"name":"(dashboard)","param":null,"prefetchHints":0,"slots":{"children":{"name":"teams","param":null,"prefetchHints":0,"slots":{"children":{"name":"__PAGE__","param":null,"prefetchHints":0,"slots":null}}}}}}},"staleTime":300,"buildId":"0cE25rDXvGu3tj4HWOGKy"}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -6,21 +6,21 @@ Plugins are stored as metadata + git source references in LiteLLM database.
|
|||
Actual plugin files are hosted on GitHub/GitLab/Bitbucket.
|
||||
|
||||
Endpoints:
|
||||
/claude-code/marketplace.json - GET - List plugins for Claude Code discovery
|
||||
/claude-code/plugins - POST - Register a new plugin (create-only)
|
||||
/claude-code/plugins - GET - List plugins (admin)
|
||||
/claude-code/plugins/{name} - GET - Get plugin details
|
||||
/claude-code/plugins/{name} - PUT - Update an existing plugin
|
||||
/claude-code/plugins/{name}/enable - POST - Enable a plugin
|
||||
/claude-code/plugins/{name}/disable - POST - Disable a plugin
|
||||
/claude-code/plugins/{name} - DELETE - Delete a plugin
|
||||
/claude-code/marketplace.json - GET - List plugins for Claude Code discovery (unauthenticated)
|
||||
/claude-code/plugins - POST - Register a new plugin (create-only, proxy admin only)
|
||||
/claude-code/plugins - GET - List plugins (any authenticated key)
|
||||
/claude-code/plugins/{name} - GET - Get plugin details (any authenticated key)
|
||||
/claude-code/plugins/{name} - PUT - Update an existing plugin (proxy admin only)
|
||||
/claude-code/plugins/{name}/enable - POST - Enable a plugin (proxy admin only)
|
||||
/claude-code/plugins/{name}/disable - POST - Disable a plugin (proxy admin only)
|
||||
/claude-code/plugins/{name} - DELETE - Delete a plugin (proxy admin only)
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timezone
|
||||
from typing import Final, Protocol, TypedDict
|
||||
from typing import Annotated, Final, Protocol, TypedDict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
|
@ -28,6 +28,7 @@ from fastapi.responses import JSONResponse
|
|||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_utils.resource_ownership import is_proxy_admin
|
||||
from litellm.repositories.table_repositories import ClaudeCodePluginRepository
|
||||
from litellm.types.proxy.claude_code_endpoints import (
|
||||
ListPluginsResponse,
|
||||
|
|
@ -221,6 +222,18 @@ def _name_conflict_error(name: str) -> HTTPException:
|
|||
)
|
||||
|
||||
|
||||
def _require_proxy_admin(user_api_key_dict: UserAPIKeyAuth) -> None:
|
||||
"""Catalog mutations are restricted to proxy admins: marketplace.json is served
|
||||
unauthenticated and any registered/updated entry is immediately installable by
|
||||
every user, so a non-admin key must never be able to add or overwrite one.
|
||||
"""
|
||||
if not is_proxy_admin(user_api_key_dict):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={"error": "Only proxy admins may modify the Claude Code plugin marketplace."},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/claude-code/plugins",
|
||||
tags=["Claude Code Marketplace"],
|
||||
|
|
@ -242,6 +255,8 @@ async def register_plugin(
|
|||
the same name already exists it returns 409 Conflict; use
|
||||
PUT /claude-code/plugins/{plugin_name} to update an existing plugin.
|
||||
|
||||
Requires a proxy admin API key.
|
||||
|
||||
Parameters:
|
||||
- name: Plugin name (kebab-case)
|
||||
- source: Git source reference (github, url, or git-subdir format)
|
||||
|
|
@ -271,6 +286,8 @@ async def register_plugin(
|
|||
from prisma.errors import UniqueViolationError
|
||||
|
||||
try:
|
||||
_require_proxy_admin(user_api_key_dict)
|
||||
|
||||
prisma_client: Final = await _get_prisma_client()
|
||||
|
||||
if not re.match(r"^[a-z0-9-]+$", request.name):
|
||||
|
|
@ -468,6 +485,7 @@ async def get_plugin(
|
|||
async def update_plugin(
|
||||
plugin_name: str,
|
||||
request: UpdatePluginRequest,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
):
|
||||
"""
|
||||
Update an existing plugin in the LiteLLM marketplace.
|
||||
|
|
@ -481,6 +499,8 @@ async def update_plugin(
|
|||
Returns 404 if no plugin with the given name exists; use
|
||||
POST /claude-code/plugins to create a new plugin.
|
||||
|
||||
Requires a proxy admin API key.
|
||||
|
||||
Parameters:
|
||||
- plugin_name: Name of the plugin to update (path parameter)
|
||||
- source: Git source reference (github, url, or git-subdir format)
|
||||
|
|
@ -509,6 +529,8 @@ async def update_plugin(
|
|||
from prisma.errors import PrismaError
|
||||
|
||||
try:
|
||||
_require_proxy_admin(user_api_key_dict)
|
||||
|
||||
prisma_client: Final = await _get_prisma_client()
|
||||
|
||||
_validate_plugin_source(request.source)
|
||||
|
|
@ -566,10 +588,14 @@ async def enable_plugin(
|
|||
"""
|
||||
Enable a disabled plugin.
|
||||
|
||||
Requires a proxy admin API key.
|
||||
|
||||
Parameters:
|
||||
- plugin_name: The name of the plugin to enable
|
||||
"""
|
||||
try:
|
||||
_require_proxy_admin(user_api_key_dict)
|
||||
|
||||
prisma_client: Final = await _get_prisma_client()
|
||||
|
||||
plugin: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique(
|
||||
|
|
@ -611,10 +637,14 @@ async def disable_plugin(
|
|||
"""
|
||||
Disable a plugin without deleting it.
|
||||
|
||||
Requires a proxy admin API key.
|
||||
|
||||
Parameters:
|
||||
- plugin_name: The name of the plugin to disable
|
||||
"""
|
||||
try:
|
||||
_require_proxy_admin(user_api_key_dict)
|
||||
|
||||
prisma_client: Final = await _get_prisma_client()
|
||||
|
||||
plugin: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique(
|
||||
|
|
@ -656,10 +686,14 @@ async def delete_plugin(
|
|||
"""
|
||||
Delete a plugin from the marketplace.
|
||||
|
||||
Requires a proxy admin API key.
|
||||
|
||||
Parameters:
|
||||
- plugin_name: The name of the plugin to delete
|
||||
"""
|
||||
try:
|
||||
_require_proxy_admin(user_api_key_dict)
|
||||
|
||||
prisma_client: Final = await _get_prisma_client()
|
||||
|
||||
plugin: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.find_unique(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from litellm.constants import (
|
|||
DEFAULT_HEALTH_CHECK_PROMPT,
|
||||
HEALTH_CHECK_TIMEOUT_SECONDS,
|
||||
)
|
||||
from litellm.router_utils.auto_router_model_naming import classify_strategy_router_model
|
||||
|
||||
ILLEGAL_DISPLAY_PARAMS: Final = [
|
||||
"messages",
|
||||
|
|
@ -182,30 +183,17 @@ async def run_with_timeout(task, timeout):
|
|||
return {"error": "Timeout exceeded", "exception": timeout_exception}
|
||||
|
||||
|
||||
def _is_semantic_auto_router_deployment(litellm_params: dict) -> bool:
|
||||
"""
|
||||
True for semantic auto_router deployments (auto_router/<name>) that are not
|
||||
sub-strategies (complexity_router, adaptive_router, quality_router).
|
||||
|
||||
These are meta-routers that select among real LLM deployments at request time;
|
||||
they have no LLM endpoint to health-check.
|
||||
"""
|
||||
def _is_strategy_router_deployment(litellm_params: dict) -> bool:
|
||||
"""True for strategy-router deployments."""
|
||||
model: Final[object] = litellm_params.get("model", "")
|
||||
if not isinstance(model, str):
|
||||
return False
|
||||
if not model.startswith("auto_router/"):
|
||||
return False
|
||||
for sub_strategy in ("complexity_router", "adaptive_router", "quality_router"):
|
||||
if model.startswith(f"auto_router/{sub_strategy}"):
|
||||
return False
|
||||
return True
|
||||
return isinstance(model, str) and classify_strategy_router_model(model) is not None
|
||||
|
||||
|
||||
async def _run_model_health_check(model: dict):
|
||||
litellm_params = model["litellm_params"]
|
||||
model_info: Final = model.get("model_info", {})
|
||||
|
||||
if _is_semantic_auto_router_deployment(litellm_params):
|
||||
if _is_strategy_router_deployment(litellm_params):
|
||||
return {}
|
||||
|
||||
mode: Final = _resolve_health_check_mode(
|
||||
|
|
@ -445,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
|
||||
|
|
@ -459,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,
|
||||
|
|
|
|||
|
|
@ -1889,6 +1889,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
|
||||
|
|
@ -1952,13 +1954,18 @@ 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,
|
||||
|
|
@ -1967,11 +1974,6 @@ async def test_model_connection(
|
|||
# state is model_params itself; there is no separate incoming patch here.
|
||||
incoming_params=None,
|
||||
)
|
||||
# Include health_check_params if provided
|
||||
litellm_params = _update_litellm_params_for_health_check(
|
||||
model_info={},
|
||||
litellm_params=litellm_params,
|
||||
)
|
||||
mode = mode or litellm_params.pop("mode", None)
|
||||
|
||||
result: Final = await run_with_timeout(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from typing import Any, Final, cast
|
|||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import BACKGROUND_INTERACTION_COST_POLLING_ENABLED
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.core_helpers import (
|
||||
_get_parent_otel_span_from_kwargs,
|
||||
|
|
@ -318,6 +319,21 @@ class _ProxyDBLogger(CustomLogger):
|
|||
elif budget_reservation is not None:
|
||||
await _release_budget_reservation(budget_reservation=budget_reservation)
|
||||
else:
|
||||
if _is_unbilled_interaction_response(completion_response):
|
||||
if BACKGROUND_INTERACTION_COST_POLLING_ENABLED and _is_unbilled_in_progress_interaction(
|
||||
completion_response
|
||||
):
|
||||
verbose_proxy_logger.debug(
|
||||
"Cost tracking deferred for in-progress background interaction; "
|
||||
"the budget reservation stays open until the poll task logs the final usage"
|
||||
)
|
||||
return
|
||||
await _release_budget_reservation(budget_reservation=budget_reservation)
|
||||
verbose_proxy_logger.debug(
|
||||
"Released the budget reservation for an interaction create with no usage "
|
||||
"that no poll task will settle"
|
||||
)
|
||||
return
|
||||
await _release_budget_reservation(budget_reservation=budget_reservation)
|
||||
# Non-model call types (health checks, afile_delete) have no model or standard_logging_object.
|
||||
# Use .get() for "stream" to avoid KeyError on health checks.
|
||||
|
|
@ -463,6 +479,24 @@ def _write_spend_metadata_to_kwargs(kwargs: dict, metadata: dict) -> None:
|
|||
bucket[key] = value
|
||||
|
||||
|
||||
def _is_unbilled_interaction_response(completion_response: object) -> bool:
|
||||
from litellm.interactions.background_cost_polling import missing_usage_is_expected
|
||||
from litellm.types.interactions import InteractionsAPIResponse
|
||||
|
||||
if not isinstance(completion_response, InteractionsAPIResponse):
|
||||
return False
|
||||
return completion_response.usage is None and missing_usage_is_expected(completion_response)
|
||||
|
||||
|
||||
def _is_unbilled_in_progress_interaction(completion_response: object) -> bool:
|
||||
from litellm.interactions.background_cost_polling import is_pollable_background_interaction
|
||||
from litellm.types.interactions import InteractionsAPIResponse
|
||||
|
||||
if not isinstance(completion_response, InteractionsAPIResponse):
|
||||
return False
|
||||
return completion_response.usage is None and is_pollable_background_interaction(completion_response)
|
||||
|
||||
|
||||
def _should_track_cost_callback(
|
||||
user_api_key: str | None,
|
||||
user_id: str | None,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
|
|||
from litellm.repositories.base_repository import SupportsModelDump
|
||||
from litellm.repositories.team_repository import TeamRepository
|
||||
from litellm.router_strategy.complexity_router import ComplexityRouter
|
||||
from litellm.router_utils.auto_router_model_naming import classify_strategy_router_model
|
||||
from litellm.types.management_endpoints.auto_router_endpoints import (
|
||||
SHADOW_EVAL_TURN_VALVE,
|
||||
AutoRouterBenchmarkGroup,
|
||||
|
|
@ -510,6 +511,53 @@ def _summed_agg_row(rows: Sequence[_SessionAggRow]) -> _SessionAggRow:
|
|||
)
|
||||
|
||||
|
||||
def _strategy_router_key(deployment: object) -> tuple[str, str] | None:
|
||||
"""``(model_name, kind)`` for a deployment whose routing the session rollup records.
|
||||
|
||||
Kinds come from ``classify_strategy_router_model``, the same rule the Router registers a
|
||||
deployment by, so this arm cannot disagree with the arm that stamped ``router_type`` onto
|
||||
the session rows. Semantic auto-routers return None: they record no routing decision, so
|
||||
they can never own a session row, and ``AutoRouterBenchmarkGroup.router_type`` has no
|
||||
value for them. A permanent zero would read as "no traffic" rather than "not instrumented".
|
||||
"""
|
||||
if not isinstance(deployment, Mapping):
|
||||
return None
|
||||
litellm_params: Final = deployment.get("litellm_params")
|
||||
router_name: Final = deployment.get("model_name")
|
||||
if not (isinstance(litellm_params, Mapping) and isinstance(router_name, str) and router_name):
|
||||
return None
|
||||
model: Final = litellm_params.get("model")
|
||||
if not isinstance(model, str):
|
||||
return None
|
||||
kind: Final = classify_strategy_router_model(model)
|
||||
return None if kind is None or kind == "semantic" else (router_name, kind)
|
||||
|
||||
|
||||
def _idle_router_groups(
|
||||
llm_router: "Router | None", covered: frozenset[tuple[str, str]]
|
||||
) -> tuple[AutoRouterBenchmarkGroup, ...]:
|
||||
"""Zeroed groups for configured strategy routers the window's traffic did not cover.
|
||||
|
||||
The dashboard's router picker has to list a router the moment it is created rather than
|
||||
once it has spent something, so the registry drives the list and the rollup only supplies
|
||||
the measures. ``_summed_agg_row`` over no sessions is already the zero element of the
|
||||
fold, so a group with every measure at zero costs one relabel rather than a literal that
|
||||
would go stale the next time the response grows a field.
|
||||
"""
|
||||
if llm_router is None:
|
||||
return ()
|
||||
zero: Final = _summed_agg_row(())
|
||||
idle: Final = frozenset(
|
||||
key
|
||||
for key in (_strategy_router_key(deployment) for deployment in llm_router.model_list or ())
|
||||
if key is not None and key not in covered
|
||||
)
|
||||
return tuple(
|
||||
_benchmark_group(zero.model_copy(update=MappingProxyType({"router_name": name, "router_type": kind})))
|
||||
for name, kind in sorted(idle)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/auto_router/benchmarks",
|
||||
tags=("auto router",),
|
||||
|
|
@ -532,8 +580,13 @@ async def get_auto_router_benchmarks(
|
|||
overlaps it: its last turn is on or after start_date and its first turn is on or before
|
||||
end_date. Overall hit rate is over telemetry-bearing turns; each bucket's hit rate is
|
||||
over that bucket's turns.
|
||||
|
||||
The rollup supplies the measures, never the list. Which routers appear comes from the
|
||||
model registry, so one shows up as soon as it is configured and reads zero until it
|
||||
serves traffic, and `routers_in_scope` counts those too rather than only the routers the
|
||||
window recorded.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
from litellm.proxy.proxy_server import llm_router, prisma_client
|
||||
|
||||
_require_admin_viewer(user_api_key_dict, "view auto-router benchmarks across the deployment")
|
||||
if prisma_client is None:
|
||||
|
|
@ -555,11 +608,14 @@ async def get_auto_router_benchmarks(
|
|||
(end_day + timedelta(days=1)).isoformat(),
|
||||
)
|
||||
rows: Final = _SESSION_AGG_ROWS.validate_python(raw_rows or ())
|
||||
groups: Final = tuple(_benchmark_group(row) for row in rows)
|
||||
groups: Final = (
|
||||
*(_benchmark_group(row) for row in rows),
|
||||
*_idle_router_groups(llm_router, frozenset((row.router_name, row.router_type) for row in rows)),
|
||||
)
|
||||
return AutoRouterBenchmarksResponse(
|
||||
start_date=start_day.strftime("%Y-%m-%d"),
|
||||
end_date=end_day.strftime("%Y-%m-%d"),
|
||||
routers_in_scope=len(rows),
|
||||
routers_in_scope=len(groups),
|
||||
totals=_benchmark_totals(_summed_agg_row(rows)),
|
||||
groups=groups,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -33,7 +33,11 @@ from litellm.llms.anthropic.common_utils import (
|
|||
from litellm.llms.vertex_ai.vertex_llm_base import VertexBase
|
||||
from litellm.proxy._types import *
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth, user_api_key_auth_websocket
|
||||
from litellm.proxy.auth.user_api_key_auth import (
|
||||
_get_bearer_token,
|
||||
user_api_key_auth,
|
||||
user_api_key_auth_websocket,
|
||||
)
|
||||
from litellm.proxy.common_utils.http_parsing_utils import (
|
||||
_read_request_body,
|
||||
_safe_get_request_headers,
|
||||
|
|
@ -110,6 +114,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,
|
||||
|
|
@ -350,6 +384,7 @@ async def vllm_proxy_route(
|
|||
params=None,
|
||||
headers=None,
|
||||
cookies=None,
|
||||
litellm_metadata=get_passthrough_router_request_metadata(user_api_key_dict),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -1535,6 +1570,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:
|
||||
|
|
@ -1786,6 +1822,154 @@ def _override_vertex_params_from_router_credentials(
|
|||
return vertex_project, vertex_location
|
||||
|
||||
|
||||
_CREDENTIALLESS_VERTEX_MISSING_CREDENTIAL_DETAIL: Final = (
|
||||
"No Vertex AI credential is configured on this proxy and the request carried no upstream "
|
||||
"Google credential. The LiteLLM virtual key is not forwarded to Google. Configure a Vertex "
|
||||
"credential (DEFAULT_VERTEXAI_PROJECT / DEFAULT_VERTEXAI_LOCATION / DEFAULT_VERTEXAI_CREDENTIALS, "
|
||||
"or a model with use_in_pass_through: true), or send your own Google OAuth token in the "
|
||||
"Authorization header."
|
||||
)
|
||||
|
||||
|
||||
def _normalize_credential_value(value: str) -> str:
|
||||
"""Reduce a header value to the bare token, matching how ``user_api_key_auth``
|
||||
reads a caller's key.
|
||||
|
||||
Reuses the auth module's ``_get_bearer_token`` so the caller-key comparison
|
||||
strips exactly the schemes authentication accepts (``Bearer`` / ``bearer`` /
|
||||
``Basic`` / ``AWS4-HMAC-SHA256`` credential), rather than re-deriving a
|
||||
narrower normalization here. ``_get_bearer_token`` returns ``""`` for a value
|
||||
with no recognized scheme prefix, so a bare token (or a real Google
|
||||
credential that carries no scheme) falls back to its own value.
|
||||
"""
|
||||
return _get_bearer_token(value) or value
|
||||
|
||||
|
||||
_VERTEX_UPSTREAM_CREDENTIAL_HEADERS: Final = frozenset({"authorization", "x-goog-api-key"})
|
||||
_HEADERS_NEVER_FORWARDED_TO_VERTEX: Final = frozenset({"content-length", "host"}) | (
|
||||
SpecialHeaders.litellm_credential_header_names() - _VERTEX_UPSTREAM_CREDENTIAL_HEADERS
|
||||
)
|
||||
|
||||
|
||||
_VERTEX_CALLER_KEY_HEADER_PRECEDENCE: Final = (
|
||||
SpecialHeaders.custom_litellm_api_key.value.lower(),
|
||||
SpecialHeaders.openai_authorization.value.lower(),
|
||||
SpecialHeaders.azure_authorization.value.lower(),
|
||||
SpecialHeaders.anthropic_authorization.value.lower(),
|
||||
SpecialHeaders.google_ai_studio_authorization.value.lower(),
|
||||
SpecialHeaders.azure_apim_authorization.value.lower(),
|
||||
)
|
||||
|
||||
_MAPPED_ROUTE_CALLER_KEY_HEADER: Final = "litellm_user_api_key"
|
||||
|
||||
|
||||
def _operator_configured_caller_key_header_names() -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||
"""Operator-configured caller-key header names, as (override, pass_through).
|
||||
|
||||
``user_api_key_auth`` accepts the caller's key from two runtime-configured
|
||||
header sources beyond the built-in ones, at opposite ends of its precedence.
|
||||
``general_settings.litellm_key_header_name`` overrides every built-in source
|
||||
(it replaces the resolved key after ``get_api_key`` runs), so it is highest
|
||||
precedence. Each ``general_settings.pass_through_endpoints`` entry's
|
||||
``headers.litellm_user_api_key`` is checked last inside ``get_api_key``, so it
|
||||
is lowest. Google never consumes either, so both are also dropped by name.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import general_settings
|
||||
|
||||
custom_key_header: Final = general_settings.get("litellm_key_header_name")
|
||||
override: Final = (custom_key_header.lower(),) if isinstance(custom_key_header, str) else ()
|
||||
pass_through_endpoints: Final = general_settings.get("pass_through_endpoints")
|
||||
endpoints: Final = pass_through_endpoints if isinstance(pass_through_endpoints, list) else ()
|
||||
pass_through: Final = tuple(
|
||||
dict.fromkeys(
|
||||
headers["litellm_user_api_key"].lower()
|
||||
for endpoint in endpoints
|
||||
if isinstance(endpoint, dict)
|
||||
for headers in (endpoint.get("headers"),)
|
||||
if isinstance(headers, dict) and isinstance(headers.get("litellm_user_api_key"), str)
|
||||
)
|
||||
)
|
||||
return override, pass_through
|
||||
|
||||
|
||||
def _authenticated_caller_key_values(request: Request) -> frozenset[str]:
|
||||
"""The value ``user_api_key_auth`` would accept as this caller's LiteLLM key.
|
||||
|
||||
The Vertex route authenticates through ``Depends(user_api_key_auth)``, which
|
||||
resolves the key by precedence, matched here exactly. The ``/vertex_ai`` route
|
||||
is a mapped pass-through route, so a header literally named
|
||||
``litellm_user_api_key`` overrides every other source (``user_api_key_auth``
|
||||
applies it last), making it highest precedence. Then an operator
|
||||
``litellm_key_header_name``, then the built-in headers in ``get_api_key`` order,
|
||||
then a ``pass_through_endpoints`` ``litellm_user_api_key`` header which
|
||||
``get_api_key`` checks last. Some of those headers (``Authorization``,
|
||||
``x-goog-api-key``) are also kept as genuine bring-your-own Google credentials,
|
||||
so returning only the value that actually authenticated lets the filter strip
|
||||
that value wherever it appears while leaving a real Google credential in place.
|
||||
An empty set means no caller key was found, so nothing is value-stripped.
|
||||
"""
|
||||
incoming: Final = _safe_get_request_headers(request)
|
||||
override_headers, pass_through_headers = _operator_configured_caller_key_header_names()
|
||||
ordered_names: Final = (
|
||||
(_MAPPED_ROUTE_CALLER_KEY_HEADER,)
|
||||
+ override_headers
|
||||
+ _VERTEX_CALLER_KEY_HEADER_PRECEDENCE
|
||||
+ pass_through_headers
|
||||
)
|
||||
present_values: Final = (incoming[name] for name in ordered_names if incoming.get(name))
|
||||
authenticated_key: Final = next(
|
||||
(stripped for value in present_values if (stripped := _normalize_credential_value(value))),
|
||||
"",
|
||||
)
|
||||
return frozenset({authenticated_key}) if authenticated_key else frozenset()
|
||||
|
||||
|
||||
def _forwarded_headers_for_credentialless_vertex_passthrough(request: Request) -> Mapping[str, str]:
|
||||
"""
|
||||
Header set to forward on the bring-your-own-credentials Vertex passthrough
|
||||
branch, used when the proxy has no Vertex credential configured.
|
||||
|
||||
No credential the proxy accepts for caller authentication is forwarded to
|
||||
Google. ``user_api_key_auth`` reads the caller's key from every header in
|
||||
``SpecialHeaders.litellm_credential_header_names()``, and Vertex only ever
|
||||
authenticates with an OAuth token in ``Authorization`` or an API key in
|
||||
``x-goog-api-key``. So the proxy-only auth headers Google never consumes
|
||||
(everything in that set except those two, e.g. ``x-litellm-api-key`` /
|
||||
``api-key`` / ``x-api-key`` / ``Ocp-Apim-Subscription-Key``, plus the mapped
|
||||
pass-through ``litellm_user_api_key`` header and any operator-configured
|
||||
``litellm_key_header_name`` / ``pass_through_endpoints`` key header) are dropped
|
||||
by name. ``Authorization`` and ``x-goog-api-key`` may
|
||||
instead carry a genuine bring-your-own Google credential, so they are kept
|
||||
unless their value is the caller's authenticated LiteLLM key, which is dropped
|
||||
by value (normalizing any ``Bearer`` / ``Basic`` / ``AWS4`` auth-scheme prefix
|
||||
the same way authentication does). Because the value that authenticated is
|
||||
resolved by the same precedence ``user_api_key_auth`` uses, a virtual key sent
|
||||
only in ``x-goog-api-key`` (or in an operator-configured key header) is dropped
|
||||
too, while a real Google key in ``x-goog-api-key`` alongside a virtual key in a
|
||||
higher-precedence header is preserved. When neither a surviving
|
||||
``Authorization`` nor ``x-goog-api-key`` remains the request is rejected so the
|
||||
virtual key cannot leak upstream.
|
||||
"""
|
||||
incoming: Final = _safe_get_request_headers(request)
|
||||
caller_key_values: Final = _authenticated_caller_key_values(request)
|
||||
override_headers, pass_through_headers = _operator_configured_caller_key_header_names()
|
||||
never_forwarded: Final = (
|
||||
_HEADERS_NEVER_FORWARDED_TO_VERTEX.union((_MAPPED_ROUTE_CALLER_KEY_HEADER,))
|
||||
.union(override_headers)
|
||||
.union(pass_through_headers)
|
||||
)
|
||||
forwarded: Final = MappingProxyType(
|
||||
{
|
||||
name: value
|
||||
for name, value in incoming.items()
|
||||
if name not in never_forwarded and _normalize_credential_value(value) not in caller_key_values
|
||||
}
|
||||
)
|
||||
if "authorization" not in forwarded and "x-goog-api-key" not in forwarded:
|
||||
raise HTTPException(status_code=401, detail=_CREDENTIALLESS_VERTEX_MISSING_CREDENTIAL_DETAIL)
|
||||
return forwarded
|
||||
|
||||
|
||||
async def _prepare_vertex_auth_headers(
|
||||
request: Request,
|
||||
vertex_credentials: Any | None,
|
||||
|
|
@ -1794,7 +1978,7 @@ async def _prepare_vertex_auth_headers(
|
|||
vertex_location: str | None,
|
||||
base_target_url: str | None,
|
||||
get_vertex_pass_through_handler: BaseVertexAIPassThroughHandler,
|
||||
) -> tuple[dict, str | None, bool, str | None, str | None]:
|
||||
) -> tuple[Mapping[str, str], str | None, bool, str | None, str | None]:
|
||||
"""
|
||||
Prepare authentication headers for Vertex AI pass-through requests.
|
||||
|
||||
|
|
@ -1820,11 +2004,11 @@ async def _prepare_vertex_auth_headers(
|
|||
|
||||
# Use headers from the incoming request if no vertex credentials are found
|
||||
if (vertex_credentials is None or vertex_credentials.vertex_project is None) and router_credentials is None:
|
||||
headers = _safe_get_request_headers(request).copy()
|
||||
headers = _forwarded_headers_for_credentialless_vertex_passthrough(request)
|
||||
headers_passed_through = True
|
||||
verbose_proxy_logger.debug("default_vertex_config not set, incoming request headers %s", headers)
|
||||
headers.pop("content-length", None)
|
||||
headers.pop("host", None)
|
||||
verbose_proxy_logger.debug(
|
||||
"default_vertex_config not set, forwarding caller-provided headers %s", tuple(headers.keys())
|
||||
)
|
||||
else:
|
||||
if router_credentials is not None:
|
||||
vertex_credentials_str = None
|
||||
|
|
@ -1910,7 +2094,7 @@ async def _base_vertex_proxy_route(
|
|||
|
||||
encoded_endpoint = httpx.URL(endpoint).path
|
||||
verbose_proxy_logger.debug("requested endpoint %s", endpoint)
|
||||
headers: dict = {}
|
||||
headers: Mapping[str, str] = {}
|
||||
api_key_to_use = get_litellm_virtual_key(request=request)
|
||||
user_api_key_dict = await user_api_key_auth(
|
||||
request=request,
|
||||
|
|
|
|||
|
|
@ -470,7 +470,10 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
|
|||
``items()`` collapses duplicate keys to the last value. Files go out as a
|
||||
list of ``(field_name, (filename, content, content_type))`` tuples and
|
||||
repeated non-file fields are grouped into list values, both of which httpx
|
||||
encodes as separate multipart parts.
|
||||
encodes as separate multipart parts. A form with no file parts is sent
|
||||
entirely through ``files`` as ``(field_name, (None, value))`` tuples,
|
||||
because httpx downgrades a file-less ``data=`` payload to
|
||||
application/x-www-form-urlencoded.
|
||||
"""
|
||||
form_items: Final = (await request.form()).multi_items()
|
||||
|
||||
|
|
@ -500,6 +503,11 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
|
|||
)
|
||||
}
|
||||
|
||||
multipart_files: Final = (
|
||||
files if files else tuple((field_name, (None, field_value)) for field_name, field_value in non_file_items)
|
||||
)
|
||||
multipart_data: Final = form_data_dict if files else None
|
||||
|
||||
# Remove content-type header - httpx will set it correctly with the new boundary
|
||||
# when it creates the multipart body from files/data parameters
|
||||
headers_copy: Final = headers.copy()
|
||||
|
|
@ -512,8 +520,8 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
|
|||
url,
|
||||
headers=headers_copy,
|
||||
params=requested_query_params,
|
||||
files=files,
|
||||
data=form_data_dict,
|
||||
files=multipart_files,
|
||||
data=multipart_data,
|
||||
)
|
||||
return await async_client.send(req, stream=True)
|
||||
|
||||
|
|
@ -522,8 +530,8 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
|
|||
url=url,
|
||||
headers=headers_copy,
|
||||
params=requested_query_params,
|
||||
files=files,
|
||||
data=form_data_dict,
|
||||
files=multipart_files,
|
||||
data=multipart_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -27,6 +27,13 @@ from litellm.proxy.common_utils.http_parsing_utils import (
|
|||
_safe_get_request_headers,
|
||||
get_form_data,
|
||||
)
|
||||
from litellm.proxy.rag_endpoints.upload_security import (
|
||||
MAX_UPLOAD_SIZE_BYTES,
|
||||
EicarTestMalwareScanner,
|
||||
MalwareScanner,
|
||||
RejectedUpload,
|
||||
validate_upload,
|
||||
)
|
||||
from litellm.proxy.vector_store_endpoints.utils import (
|
||||
assert_user_can_access_vector_store_id,
|
||||
)
|
||||
|
|
@ -287,8 +294,22 @@ async def _save_vector_store_to_db_from_rag_ingest(
|
|||
verbose_proxy_logger.exception("Failed to save vector store %s to database: %s", vector_store_id, db_error)
|
||||
|
||||
|
||||
def _secure_uploaded_file(
|
||||
file_data: tuple[str, bytes, str],
|
||||
scanner: MalwareScanner,
|
||||
) -> tuple[str, bytes, str]:
|
||||
validation: Final = validate_upload(content=file_data[1], scanner=scanner)
|
||||
if isinstance(validation, RejectedUpload):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": validation.message, "reason": validation.reason.value},
|
||||
)
|
||||
return validation.safe_filename, file_data[1], validation.content_type
|
||||
|
||||
|
||||
async def parse_rag_ingest_request(
|
||||
request: Request,
|
||||
scanner: MalwareScanner,
|
||||
) -> tuple[dict[str, Any], tuple[str, bytes, str] | None, str | None, str | None]:
|
||||
"""
|
||||
Parse RAG ingest request.
|
||||
|
|
@ -297,6 +318,11 @@ async def parse_rag_ingest_request(
|
|||
- Form: file + request JSON in form field
|
||||
- JSON body for URL-based ingestion
|
||||
|
||||
Uploaded file bytes are validated against the vector-store upload controls
|
||||
(size limit, format allowlist with content inspection, archive rejection,
|
||||
and the injected malware scanner) and given a server-generated filename
|
||||
before they are returned.
|
||||
|
||||
Returns:
|
||||
Tuple of (ingest_options, file_data, file_url, file_id)
|
||||
"""
|
||||
|
|
@ -315,7 +341,7 @@ async def parse_rag_ingest_request(
|
|||
# Get file
|
||||
file_obj = form_data.get("file")
|
||||
if file_obj is not None and hasattr(file_obj, "read"):
|
||||
file_content = await file_obj.read()
|
||||
file_content = await file_obj.read(MAX_UPLOAD_SIZE_BYTES + 1)
|
||||
file_data = (file_obj.filename, file_content, file_obj.content_type)
|
||||
|
||||
# Parse JSON from 'request' form field (contains full request body as JSON)
|
||||
|
|
@ -357,6 +383,10 @@ async def parse_rag_ingest_request(
|
|||
detail={"error": "Must provide file, file_url, or file_id"},
|
||||
)
|
||||
|
||||
secured_file_data: Final[tuple[str, bytes, str] | None] = (
|
||||
_secure_uploaded_file(file_data, scanner) if file_data is not None else None
|
||||
)
|
||||
|
||||
if "vector_store" not in ingest_options:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
|
|
@ -398,7 +428,7 @@ async def parse_rag_ingest_request(
|
|||
},
|
||||
)
|
||||
|
||||
return ingest_options, file_data, file_url, file_id
|
||||
return ingest_options, secured_file_data, file_url, file_id
|
||||
|
||||
|
||||
@router.post(
|
||||
|
|
@ -461,7 +491,9 @@ async def rag_ingest(
|
|||
|
||||
try:
|
||||
# Parse request
|
||||
ingest_options, file_data, file_url, file_id = await parse_rag_ingest_request(request)
|
||||
ingest_options, file_data, file_url, file_id = await parse_rag_ingest_request(
|
||||
request, scanner=EicarTestMalwareScanner()
|
||||
)
|
||||
|
||||
# INTERNAL_USER_VIEW_ONLY can ingest to existing vector stores only
|
||||
if user_api_key_dict.user_role == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value and not ingest_options.get(
|
||||
|
|
|
|||
289
litellm/proxy/rag_endpoints/upload_security.py
Normal file
289
litellm/proxy/rag_endpoints/upload_security.py
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
"""Security controls for vector-store file uploads.
|
||||
|
||||
Content is classified by inspecting its actual bytes (magic signatures and a
|
||||
strict UTF-8 decode), never by trusting the client-supplied filename or
|
||||
content-type. Uploads are restricted to an allowlist of non-executable formats,
|
||||
capped in size, screened for archives, and passed through a dependency-injected
|
||||
malware scanner before they are accepted. Accepted uploads are given a
|
||||
server-generated filename so the client-controlled name never reaches storage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Protocol, TypeAlias, runtime_checkable
|
||||
|
||||
from typing_extensions import assert_never
|
||||
|
||||
MAX_UPLOAD_SIZE_BYTES: Final = 512 * 1024 * 1024
|
||||
|
||||
EICAR_TEST_SIGNATURE: Final = b"X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"
|
||||
|
||||
_ARCHIVE_MAGIC_PREFIXES: Final[tuple[bytes, ...]] = (
|
||||
b"PK\x03\x04",
|
||||
b"PK\x05\x06",
|
||||
b"PK\x07\x08",
|
||||
b"\x1f\x8b",
|
||||
b"\xfd7zXZ\x00",
|
||||
b"7z\xbc\xaf\x27\x1c",
|
||||
b"Rar!\x1a\x07\x00",
|
||||
b"Rar!\x1a\x07\x01\x00",
|
||||
b"\x04\x22\x4d\x18",
|
||||
b"\x28\xb5\x2f\xfd",
|
||||
)
|
||||
|
||||
_ARCHIVE_MAGIC_PREFIXES_ASCII_AMBIGUOUS: Final[tuple[bytes, ...]] = (b"BZh",)
|
||||
|
||||
_EXECUTABLE_MAGIC_PREFIXES: Final[tuple[bytes, ...]] = (
|
||||
b"\x7fELF",
|
||||
b"\xca\xfe\xba\xbe",
|
||||
b"\xfe\xed\xfa\xce",
|
||||
b"\xfe\xed\xfa\xcf",
|
||||
b"\xce\xfa\xed\xfe",
|
||||
b"\xcf\xfa\xed\xfe",
|
||||
b"\x00asm",
|
||||
)
|
||||
|
||||
_EXECUTABLE_MAGIC_PREFIXES_ASCII_AMBIGUOUS: Final[tuple[bytes, ...]] = (b"MZ", b"dex\n")
|
||||
|
||||
_TAR_USTAR_MAGIC: Final = b"ustar"
|
||||
_TAR_USTAR_OFFSET: Final = 257
|
||||
|
||||
_UTF8_BOM: Final = b"\xef\xbb\xbf"
|
||||
|
||||
|
||||
class DetectedFormat(str, Enum):
|
||||
PDF = "pdf"
|
||||
TEXT = "text"
|
||||
|
||||
|
||||
class DisallowedKind(str, Enum):
|
||||
ARCHIVE = "archive"
|
||||
EXECUTABLE = "executable"
|
||||
UNKNOWN_BINARY = "unknown_binary"
|
||||
|
||||
|
||||
class RejectionReason(str, Enum):
|
||||
EMPTY_FILE = "empty_file"
|
||||
FILE_TOO_LARGE = "file_too_large"
|
||||
ARCHIVE_NOT_ALLOWED = "archive_not_allowed"
|
||||
EXECUTABLE_NOT_ALLOWED = "executable_not_allowed"
|
||||
UNSUPPORTED_FORMAT = "unsupported_format"
|
||||
MALWARE_DETECTED = "malware_detected"
|
||||
MALWARE_SCAN_ERROR = "malware_scan_error"
|
||||
|
||||
|
||||
class ScanVerdict(str, Enum):
|
||||
CLEAN = "clean"
|
||||
INFECTED = "infected"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ScanResult:
|
||||
verdict: ScanVerdict
|
||||
signature: str | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class MalwareScanner(Protocol):
|
||||
def scan(self, content: bytes) -> ScanResult: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EicarTestMalwareScanner:
|
||||
"""Placeholder scanner that only flags the EICAR anti-malware test file.
|
||||
|
||||
It exists to prove the scan hook is wired end to end and to satisfy the
|
||||
EICAR retest; it provides no real protection. Inject a scanner backed by a
|
||||
real engine through the ``scanner`` parameter of :func:`validate_upload` to
|
||||
screen production uploads.
|
||||
"""
|
||||
|
||||
def scan(self, content: bytes) -> ScanResult:
|
||||
if EICAR_TEST_SIGNATURE in content:
|
||||
return ScanResult(verdict=ScanVerdict.INFECTED, signature="EICAR-STANDARD-ANTIVIRUS-TEST-FILE")
|
||||
return ScanResult(verdict=ScanVerdict.CLEAN)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AllowedContent:
|
||||
format: DetectedFormat
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DisallowedContent:
|
||||
kind: DisallowedKind
|
||||
|
||||
|
||||
ContentInspection: TypeAlias = AllowedContent | DisallowedContent
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SecuredUpload:
|
||||
safe_filename: str
|
||||
content_type: str
|
||||
detected_format: DetectedFormat
|
||||
size_bytes: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RejectedUpload:
|
||||
reason: RejectionReason
|
||||
message: str
|
||||
|
||||
|
||||
UploadValidation: TypeAlias = SecuredUpload | RejectedUpload
|
||||
|
||||
_SAFE_EXTENSION: Final[Mapping[DetectedFormat, str]] = MappingProxyType(
|
||||
{
|
||||
DetectedFormat.PDF: "pdf",
|
||||
DetectedFormat.TEXT: "txt",
|
||||
}
|
||||
)
|
||||
|
||||
_SAFE_CONTENT_TYPE: Final[Mapping[DetectedFormat, str]] = MappingProxyType(
|
||||
{
|
||||
DetectedFormat.PDF: "application/pdf",
|
||||
DetectedFormat.TEXT: "text/plain",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _starts_with_any(content: bytes, prefixes: tuple[bytes, ...]) -> bool:
|
||||
return any(content.startswith(prefix) for prefix in prefixes)
|
||||
|
||||
|
||||
def _is_archive(content: bytes) -> bool:
|
||||
if _starts_with_any(content, _ARCHIVE_MAGIC_PREFIXES):
|
||||
return True
|
||||
tar_magic_end: Final = _TAR_USTAR_OFFSET + len(_TAR_USTAR_MAGIC)
|
||||
if len(content) >= tar_magic_end and content[_TAR_USTAR_OFFSET:tar_magic_end] == _TAR_USTAR_MAGIC:
|
||||
return True
|
||||
return _starts_with_any(content, _ARCHIVE_MAGIC_PREFIXES_ASCII_AMBIGUOUS) and not _is_utf8_text(content)
|
||||
|
||||
|
||||
def _is_utf8_text(content: bytes) -> bool:
|
||||
if b"\x00" in content:
|
||||
return False
|
||||
try:
|
||||
content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _is_executable_binary(content: bytes) -> bool:
|
||||
if _starts_with_any(content, _EXECUTABLE_MAGIC_PREFIXES):
|
||||
return True
|
||||
return _starts_with_any(content, _EXECUTABLE_MAGIC_PREFIXES_ASCII_AMBIGUOUS) and not _is_utf8_text(content)
|
||||
|
||||
|
||||
def _looks_like_shebang(content: bytes) -> bool:
|
||||
body: Final = content.removeprefix(_UTF8_BOM).lstrip()
|
||||
return body.startswith(b"#!")
|
||||
|
||||
|
||||
def inspect_content(content: bytes) -> ContentInspection:
|
||||
if _looks_like_shebang(content):
|
||||
return DisallowedContent(DisallowedKind.EXECUTABLE)
|
||||
if content.startswith(b"%PDF-"):
|
||||
return AllowedContent(DetectedFormat.PDF)
|
||||
if _is_archive(content):
|
||||
return DisallowedContent(DisallowedKind.ARCHIVE)
|
||||
if _is_executable_binary(content):
|
||||
return DisallowedContent(DisallowedKind.EXECUTABLE)
|
||||
if _is_utf8_text(content):
|
||||
return AllowedContent(DetectedFormat.TEXT)
|
||||
return DisallowedContent(DisallowedKind.UNKNOWN_BINARY)
|
||||
|
||||
|
||||
def generate_safe_filename(detected_format: DetectedFormat) -> str:
|
||||
return f"{uuid.uuid4().hex}.{_SAFE_EXTENSION[detected_format]}"
|
||||
|
||||
|
||||
def _reject_disallowed(kind: DisallowedKind) -> RejectedUpload:
|
||||
match kind:
|
||||
case DisallowedKind.ARCHIVE:
|
||||
return RejectedUpload(
|
||||
RejectionReason.ARCHIVE_NOT_ALLOWED,
|
||||
"Archive uploads are not allowed.",
|
||||
)
|
||||
case DisallowedKind.EXECUTABLE:
|
||||
return RejectedUpload(
|
||||
RejectionReason.EXECUTABLE_NOT_ALLOWED,
|
||||
"Executable uploads are not allowed.",
|
||||
)
|
||||
case DisallowedKind.UNKNOWN_BINARY:
|
||||
return RejectedUpload(
|
||||
RejectionReason.UNSUPPORTED_FORMAT,
|
||||
"Only PDF and UTF-8 text documents are accepted.",
|
||||
)
|
||||
assert_never(kind)
|
||||
|
||||
|
||||
def _scan_rejection(content: bytes, scanner: MalwareScanner) -> RejectedUpload | None:
|
||||
result: Final = scanner.scan(content)
|
||||
match result.verdict:
|
||||
case ScanVerdict.CLEAN:
|
||||
return None
|
||||
case ScanVerdict.INFECTED:
|
||||
return RejectedUpload(
|
||||
RejectionReason.MALWARE_DETECTED,
|
||||
f"Uploaded file was flagged by malware scanning ({result.signature or 'unknown signature'}).",
|
||||
)
|
||||
case ScanVerdict.ERROR:
|
||||
return RejectedUpload(
|
||||
RejectionReason.MALWARE_SCAN_ERROR,
|
||||
"Malware scanning could not complete; upload rejected.",
|
||||
)
|
||||
assert_never(result.verdict)
|
||||
|
||||
|
||||
def validate_upload(
|
||||
*,
|
||||
content: bytes,
|
||||
scanner: MalwareScanner,
|
||||
max_size_bytes: int = MAX_UPLOAD_SIZE_BYTES,
|
||||
) -> UploadValidation:
|
||||
size: Final = len(content)
|
||||
if size == 0:
|
||||
return RejectedUpload(RejectionReason.EMPTY_FILE, "Uploaded file is empty.")
|
||||
if size > max_size_bytes:
|
||||
return RejectedUpload(
|
||||
RejectionReason.FILE_TOO_LARGE,
|
||||
f"Uploaded file is {size} bytes, exceeding the {max_size_bytes}-byte limit.",
|
||||
)
|
||||
|
||||
inspection: Final = inspect_content(content)
|
||||
if isinstance(inspection, DisallowedContent):
|
||||
return _reject_disallowed(inspection.kind)
|
||||
|
||||
scan_rejection: Final = _scan_rejection(content, scanner)
|
||||
if scan_rejection is not None:
|
||||
return scan_rejection
|
||||
|
||||
return SecuredUpload(
|
||||
safe_filename=generate_safe_filename(inspection.format),
|
||||
content_type=_SAFE_CONTENT_TYPE[inspection.format],
|
||||
detected_format=inspection.format,
|
||||
size_bytes=size,
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_header_filename(filename: str) -> str:
|
||||
stripped: Final = "".join(char for char in filename if char not in '"\\\r\n').strip()
|
||||
return stripped or "download"
|
||||
|
||||
|
||||
def safe_download_headers(filename: str) -> Mapping[str, str]:
|
||||
return MappingProxyType(
|
||||
{
|
||||
"Content-Disposition": f'attachment; filename="{_sanitize_header_filename(filename)}"',
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}
|
||||
)
|
||||
|
|
@ -17,6 +17,7 @@ from litellm.proxy.openai_files_endpoints.common_utils import (
|
|||
handle_model_based_routing,
|
||||
prepare_data_with_credentials,
|
||||
)
|
||||
from litellm.proxy.rag_endpoints.upload_security import safe_download_headers
|
||||
from litellm.proxy.vector_store_endpoints.utils import (
|
||||
assert_user_can_access_vector_store_id,
|
||||
is_allowed_to_call_vector_store_files_endpoint,
|
||||
|
|
@ -885,6 +886,9 @@ async def vector_store_file_content(
|
|||
if original_managed_file_id:
|
||||
response = _replace_file_id_in_response(response, original_managed_file_id)
|
||||
|
||||
for header_name, header_value in safe_download_headers(file_id).items():
|
||||
fastapi_response.headers[header_name] = header_value
|
||||
|
||||
return response
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise await processor._handle_llm_api_exception(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
from typing import Any, Final
|
||||
|
||||
import orjson
|
||||
from fastapi import APIRouter, Depends, File, Form, Request, Response, UploadFile
|
||||
from fastapi.responses import ORJSONResponse
|
||||
|
||||
|
|
@ -20,6 +19,7 @@ from litellm.proxy.video_endpoints.utils import (
|
|||
encode_character_id_in_response,
|
||||
extract_model_from_target_model_names,
|
||||
get_custom_provider_from_data,
|
||||
video_reference_to_id,
|
||||
)
|
||||
from litellm.types.videos.utils import (
|
||||
decode_character_id_with_provider,
|
||||
|
|
@ -451,9 +451,7 @@ async def video_remix(
|
|||
version,
|
||||
)
|
||||
|
||||
# Read request body
|
||||
body: Final = await request.body()
|
||||
data: Final = orjson.loads(body)
|
||||
data: Final = await _read_request_body(request=request)
|
||||
data["video_id"] = video_id
|
||||
|
||||
decoded: Final = decode_video_id_with_provider(video_id)
|
||||
|
|
@ -760,15 +758,10 @@ async def video_edit(
|
|||
version,
|
||||
)
|
||||
|
||||
body: Final = await request.body()
|
||||
data: Final = orjson.loads(body)
|
||||
data: Final = await _read_request_body(request=request)
|
||||
data["video_id"] = video_reference_to_id(data.pop("video", None))
|
||||
|
||||
# Extract video_id from nested video object
|
||||
video_ref: Final = data.pop("video", {})
|
||||
video_id: Final = video_ref.get("id", "") if isinstance(video_ref, dict) else ""
|
||||
data["video_id"] = video_id
|
||||
|
||||
decoded: Final = decode_video_id_with_provider(video_id)
|
||||
decoded: Final = decode_video_id_with_provider(data["video_id"])
|
||||
provider_from_id: Final = decoded.get("custom_llm_provider")
|
||||
model_id_from_decoded: Final = decoded.get("model_id")
|
||||
|
||||
|
|
@ -860,15 +853,10 @@ async def video_extension(
|
|||
version,
|
||||
)
|
||||
|
||||
body: Final = await request.body()
|
||||
data: Final = orjson.loads(body)
|
||||
data: Final = await _read_request_body(request=request)
|
||||
data["video_id"] = video_reference_to_id(data.pop("video", None))
|
||||
|
||||
# Extract video_id from nested video object
|
||||
video_ref: Final = data.pop("video", {})
|
||||
video_id: Final = video_ref.get("id", "") if isinstance(video_ref, dict) else ""
|
||||
data["video_id"] = video_id
|
||||
|
||||
decoded: Final = decode_video_id_with_provider(video_id)
|
||||
decoded: Final = decode_video_id_with_provider(data["video_id"])
|
||||
provider_from_id: Final = decoded.get("custom_llm_provider")
|
||||
model_id_from_decoded: Final = decoded.get("model_id")
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,18 @@ def extract_model_from_target_model_names(target_model_names: Any) -> str | None
|
|||
return target_model_names[0] if target_model_names else None
|
||||
|
||||
|
||||
def video_reference_to_id(video_ref: object) -> str:
|
||||
if isinstance(video_ref, dict):
|
||||
return video_ref.get("id", "")
|
||||
if not isinstance(video_ref, str):
|
||||
return ""
|
||||
try:
|
||||
parsed_ref: Final = orjson.loads(video_ref)
|
||||
except orjson.JSONDecodeError:
|
||||
return video_ref
|
||||
return parsed_ref.get("id", "") if isinstance(parsed_ref, dict) else video_ref
|
||||
|
||||
|
||||
def get_custom_provider_from_data(data: dict[str, Any]) -> str | None:
|
||||
custom_llm_provider: Final = data.get("custom_llm_provider")
|
||||
if custom_llm_provider:
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ from litellm.litellm_core_utils.request_timeout_resolver import (
|
|||
from litellm.litellm_core_utils.secret_redaction import redact_string
|
||||
from litellm.litellm_core_utils.sensitive_data_masker import (
|
||||
SensitiveDataMasker,
|
||||
mask_credentials_in_payload,
|
||||
mask_sensitive_structure,
|
||||
)
|
||||
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
|
||||
|
|
@ -375,10 +376,17 @@ def _replay_live_router_model_cost() -> None:
|
|||
set_live_deployment_replay(_replay_live_router_model_cost)
|
||||
|
||||
|
||||
# Kwargs that log_retry must not copy into a retry breadcrumb. The breadcrumbs reach spend
|
||||
# logs and logging callbacks, and these carry either the request payload or router-internal
|
||||
# walk state rather than anything that identifies the failed attempt.
|
||||
RETRY_BREADCRUMB_EXCLUDED_KWARGS: Final = frozenset(("messages", "original_function", "attempted_targets"))
|
||||
# Kwargs that carry no signal about the failed attempt, so log_retry drops them from a
|
||||
# breadcrumb entirely: the request payload and the router-internal walk state. Credentials are
|
||||
# handled separately by mask_credentials_in_payload, which scrubs credential-named values from
|
||||
# whatever kwargs remain rather than trying to enumerate every credential-bearing key here.
|
||||
RETRY_BREADCRUMB_EXCLUDED_KWARGS: Final = frozenset(
|
||||
(
|
||||
"messages",
|
||||
"original_function",
|
||||
"attempted_targets",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class Router:
|
||||
|
|
@ -7347,7 +7355,8 @@ class Router:
|
|||
if len(self.previous_models) > 3:
|
||||
self.previous_models.pop(0)
|
||||
|
||||
self.previous_models.append(previous_model)
|
||||
scrubbed_previous_model: Final = mask_credentials_in_payload(previous_model)
|
||||
self.previous_models.append(scrubbed_previous_model)
|
||||
kwargs[_metadata_var]["previous_models"] = self.previous_models
|
||||
return kwargs
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -274,6 +274,7 @@ _REMINDER_CLOSE: Final = "</system-reminder>"
|
|||
_DEFAULT_REMINDER_MARKERS: Final = ((_REMINDER_OPEN, _REMINDER_CLOSE),)
|
||||
|
||||
_TRUNCATION_MARKER: Final = "..."
|
||||
_TRUNCATION_HEAD_FRACTION: Final = 0.3
|
||||
|
||||
_CJK_CHARACTER: Final = re.compile("[-ヿㇰ-ㇿ㐀-䶿一-鿿豈-ヲ-ン\U00020000-\U0003ffff]")
|
||||
|
||||
|
|
@ -552,8 +553,21 @@ def _matched_plan_mode_sentinel(
|
|||
|
||||
|
||||
def _truncate(text: str, limit: int) -> str:
|
||||
"""Cap text at limit characters, marking it so the classifier can tell the turn was cut short."""
|
||||
return text if len(text) <= limit else f"{text[:limit]}{_TRUNCATION_MARKER}"
|
||||
"""Cap text at limit characters, keeping both ends and eliding the middle.
|
||||
|
||||
A chat turn states its ask at the end, so cutting the tail keeps the preamble and discards the
|
||||
request the turn exists to make: a turn opening with an incident report and closing with "rewrite
|
||||
the retry path and prove it cannot livelock" reached the classifier as the incident report alone.
|
||||
Keeping both ends costs nothing at the same budget and is what the truncation literature finds
|
||||
best for classifying long text, head+tail measuring above both head-only and tail-only in Sun et
|
||||
al. 2019. The marker sits at the cut, so the turn reads as having its middle removed rather than
|
||||
as trailing off mid-thought.
|
||||
"""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
head_chars: Final = max(int(limit * _TRUNCATION_HEAD_FRACTION), 0)
|
||||
tail_chars: Final = max(limit - head_chars, 0)
|
||||
return f"{text[:head_chars]}{_TRUNCATION_MARKER}{text[len(text) - tail_chars :]}"
|
||||
|
||||
|
||||
def _iter_context_turns_newest_first(
|
||||
|
|
|
|||
|
|
@ -158,9 +158,18 @@ class AutoRouterBenchmarksResponse(BaseModel):
|
|||
|
||||
start_date: str = Field(description="Window start day, YYYY-MM-DD UTC, inclusive")
|
||||
end_date: str = Field(description="Window end day, YYYY-MM-DD UTC, inclusive")
|
||||
routers_in_scope: int
|
||||
routers_in_scope: int = Field(
|
||||
description="How many groups this response carries. Every auto-router configured on the "
|
||||
"proxy counts, whether or not it served anything in the window. To count only the routers "
|
||||
"that did serve traffic, filter `groups` to the entries whose `sessions` is above zero"
|
||||
)
|
||||
totals: AutoRouterBenchmarkTotals
|
||||
groups: tuple[AutoRouterBenchmarkGroup, ...]
|
||||
groups: tuple[AutoRouterBenchmarkGroup, ...] = Field(
|
||||
description="One entry per auto-router, listed from the model registry rather than from "
|
||||
"the rollup, so a router appears as soon as it is configured and reads zero until it "
|
||||
"serves traffic. Semantic auto-routers are absent: they record no routing decision, so no "
|
||||
"session can ever be attributed to them"
|
||||
)
|
||||
|
||||
|
||||
ShadowEvalStatus: TypeAlias = Literal["running", "completed", "stopped"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -530,7 +530,9 @@ class LiteLLMParamsTypedDict(TypedDict, total=False):
|
|||
output_cost_per_token: float | None
|
||||
input_cost_per_second: float | None
|
||||
output_cost_per_second: float | None
|
||||
output_cost_per_second_480p: ReadOnly[float | None]
|
||||
output_cost_per_second_1080p: float | None
|
||||
output_cost_per_second_4k: ReadOnly[float | None]
|
||||
num_retries: int | None
|
||||
## MOCK RESPONSES ##
|
||||
mock_response: str | ModelResponse | Exception | None
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ class ProviderSpecificModelInfo(TypedDict, total=False):
|
|||
supports_web_search: bool | None
|
||||
supports_reasoning: bool | None
|
||||
supports_adaptive_thinking: bool | None
|
||||
supports_legacy_thinking: ReadOnly[bool | None]
|
||||
thinking_always_on: ReadOnly[bool | None]
|
||||
supports_tool_search: bool | None
|
||||
supports_mid_conversation_system: bool | None
|
||||
|
|
@ -277,6 +278,8 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False):
|
|||
output_cost_per_second_1080p: (
|
||||
float | None
|
||||
) # video_generation tier: key output_cost_per_second_<resolution> (e.g. 1080p, 720p)
|
||||
output_cost_per_second_480p: ReadOnly[float | None]
|
||||
output_cost_per_second_4k: ReadOnly[float | None]
|
||||
ocr_cost_per_page: float | None # for OCR models
|
||||
ocr_cost_per_credit: float | None # for OCR models priced by credit
|
||||
annotation_cost_per_page: float | None # for OCR models
|
||||
|
|
@ -440,6 +443,12 @@ class CallTypes(str, Enum):
|
|||
query = "query"
|
||||
aquery = "aquery"
|
||||
|
||||
#########################################################
|
||||
# Google Interactions API Call Types
|
||||
#########################################################
|
||||
create_interaction = "create_interaction"
|
||||
acreate_interaction = "acreate_interaction"
|
||||
|
||||
#########################################################
|
||||
# Container Call Types
|
||||
#########################################################
|
||||
|
|
@ -3331,6 +3340,8 @@ class CustomPricingLiteLLMParams(MirroredPricingParams):
|
|||
input_cost_per_second: float | None = None
|
||||
output_cost_per_second: float | None = None
|
||||
output_cost_per_second_1080p: float | None = None
|
||||
output_cost_per_second_480p: float | None = None
|
||||
output_cost_per_second_4k: float | None = None
|
||||
input_cost_per_pixel: float | None = None
|
||||
output_cost_per_pixel: float | None = None
|
||||
|
||||
|
|
|
|||
|
|
@ -5726,6 +5726,8 @@ def _get_model_info_helper(
|
|||
),
|
||||
output_cost_per_second=_model_info.get("output_cost_per_second", None),
|
||||
output_cost_per_second_1080p=_model_info.get("output_cost_per_second_1080p", None),
|
||||
output_cost_per_second_480p=_model_info.get("output_cost_per_second_480p", None),
|
||||
output_cost_per_second_4k=_model_info.get("output_cost_per_second_4k", None),
|
||||
output_cost_per_video_per_second=_model_info.get("output_cost_per_video_per_second", None),
|
||||
output_cost_per_image=_model_info.get("output_cost_per_image", None),
|
||||
output_cost_per_image_token=_model_info.get("output_cost_per_image_token", None),
|
||||
|
|
@ -5753,6 +5755,7 @@ def _get_model_info_helper(
|
|||
supports_url_context=_model_info.get("supports_url_context", None),
|
||||
supports_reasoning=_model_info.get("supports_reasoning", None),
|
||||
supports_adaptive_thinking=_model_info.get("supports_adaptive_thinking", None),
|
||||
supports_legacy_thinking=_model_info.get("supports_legacy_thinking", None),
|
||||
thinking_always_on=_model_info.get("thinking_always_on", None),
|
||||
supports_tool_search=_model_info.get("supports_tool_search", None),
|
||||
supports_mid_conversation_system=_model_info.get("supports_mid_conversation_system", None),
|
||||
|
|
@ -6525,24 +6528,6 @@ def acreate(*args, **kwargs): ## Thin client to handle the acreate langchain ca
|
|||
return litellm.acompletion(*args, **kwargs)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def valid_model(model):
|
||||
try:
|
||||
# for a given model name, check if the user has the right permissions to access the model
|
||||
|
|
|
|||
|
|
@ -1019,6 +1019,7 @@
|
|||
},
|
||||
"anthropic.claude-opus-4-6-v1": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 6.25e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 1e-05,
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
|
|
@ -1053,6 +1054,7 @@
|
|||
},
|
||||
"global.anthropic.claude-opus-4-6-v1": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 6.25e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 1e-05,
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
|
|
@ -1087,6 +1089,7 @@
|
|||
},
|
||||
"us.anthropic.claude-opus-4-6-v1": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 6.875e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 1.1e-05,
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
|
|
@ -1121,6 +1124,7 @@
|
|||
},
|
||||
"eu.anthropic.claude-opus-4-6-v1": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 6.875e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 1.1e-05,
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
|
|
@ -1155,6 +1159,7 @@
|
|||
},
|
||||
"au.anthropic.claude-opus-4-6-v1": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 6.875e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 1.1e-05,
|
||||
"cache_read_input_token_cost": 5.5e-07,
|
||||
|
|
@ -2233,6 +2238,7 @@
|
|||
},
|
||||
"anthropic.claude-sonnet-4-6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 3.75e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 6e-06,
|
||||
"cache_read_input_token_cost": 3e-07,
|
||||
|
|
@ -2266,6 +2272,7 @@
|
|||
},
|
||||
"global.anthropic.claude-sonnet-4-6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 3.75e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 6e-06,
|
||||
"cache_read_input_token_cost": 3e-07,
|
||||
|
|
@ -2299,6 +2306,7 @@
|
|||
},
|
||||
"us.anthropic.claude-sonnet-4-6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 4.125e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 6.6e-06,
|
||||
"cache_read_input_token_cost": 3.3e-07,
|
||||
|
|
@ -2332,6 +2340,7 @@
|
|||
},
|
||||
"eu.anthropic.claude-sonnet-4-6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 4.125e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 6.6e-06,
|
||||
"cache_read_input_token_cost": 3.3e-07,
|
||||
|
|
@ -2365,6 +2374,7 @@
|
|||
},
|
||||
"au.anthropic.claude-sonnet-4-6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 4.125e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 6.6e-06,
|
||||
"cache_read_input_token_cost": 3.3e-07,
|
||||
|
|
@ -2398,6 +2408,7 @@
|
|||
},
|
||||
"jp.anthropic.claude-sonnet-4-6": {
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 4.125e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 6.6e-06,
|
||||
"cache_read_input_token_cost": 3.3e-07,
|
||||
|
|
@ -2950,6 +2961,7 @@
|
|||
"azure_ai/claude-opus-4-6": {
|
||||
"deprecation_date": "2027-02-02",
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"input_cost_per_token": 5e-06,
|
||||
"output_cost_per_token": 2.5e-05,
|
||||
"litellm_provider": "azure_ai",
|
||||
|
|
@ -3181,6 +3193,7 @@
|
|||
"azure_ai/claude-sonnet-4-6": {
|
||||
"deprecation_date": "2027-02-10",
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"cache_creation_input_token_cost": 3.75e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 6e-06,
|
||||
"cache_read_input_token_cost": 3e-07,
|
||||
|
|
@ -12489,6 +12502,7 @@
|
|||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
|
|
@ -12698,6 +12712,7 @@
|
|||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
|
|
@ -12735,6 +12750,7 @@
|
|||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_legacy_thinking": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
|
|
@ -14551,6 +14567,8 @@
|
|||
]
|
||||
},
|
||||
"databricks/databricks-bge-large-en": {
|
||||
"cache_creation_input_token_cost": 1.0003e-07,
|
||||
"cache_read_input_token_cost": 1.0003e-07,
|
||||
"input_cost_per_token": 1.0003e-07,
|
||||
"input_dbu_cost_per_token": 1.429e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14566,6 +14584,8 @@
|
|||
"source": "https://www.databricks.com/product/pricing/foundation-model-serving"
|
||||
},
|
||||
"databricks/databricks-claude-3-7-sonnet": {
|
||||
"cache_creation_input_token_cost": 3.74997e-06,
|
||||
"cache_read_input_token_cost": 3.0002e-07,
|
||||
"input_cost_per_token": 2.9999900000000002e-06,
|
||||
"input_dbu_cost_per_token": 4.2857e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14581,10 +14601,41 @@
|
|||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-claude-fable-5": {
|
||||
"cache_creation_input_token_cost": 1.250004e-05,
|
||||
"cache_read_input_token_cost": 1.00002e-06,
|
||||
"input_cost_per_token": 1.000006e-05,
|
||||
"input_dbu_cost_per_token": 0.000142858,
|
||||
"litellm_provider": "databricks",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"metadata": {
|
||||
"notes": "Costs per token are the published Global DBU rates times $0.070 per DBU. The '*_dbu_cost_per_token' fields are provided for reference; cost calculation reads the dollar '*_cost_per_token' fields."
|
||||
},
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 5.000002e-05,
|
||||
"output_dbu_cost_per_token": 0.000714286,
|
||||
"prompt_cache_min_tokens": 512,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_function_calling": true,
|
||||
"supports_mid_conversation_system": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_sampling_params": false,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": false,
|
||||
"thinking_always_on": true
|
||||
},
|
||||
"databricks/databricks-claude-haiku-4-5": {
|
||||
"cache_creation_input_token_cost": 1.24999e-06,
|
||||
"cache_read_input_token_cost": 1.0003e-07,
|
||||
"input_cost_per_token": 1.00002e-06,
|
||||
"input_dbu_cost_per_token": 1.4286e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14600,10 +14651,13 @@
|
|||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-claude-opus-4": {
|
||||
"cache_creation_input_token_cost": 1.874999e-05,
|
||||
"cache_read_input_token_cost": 1.50003e-06,
|
||||
"input_cost_per_token": 1.5000020000000002e-05,
|
||||
"input_dbu_cost_per_token": 0.000214286,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14619,10 +14673,13 @@
|
|||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-claude-opus-4-1": {
|
||||
"cache_creation_input_token_cost": 1.874999e-05,
|
||||
"cache_read_input_token_cost": 1.50003e-06,
|
||||
"input_cost_per_token": 1.5000020000000002e-05,
|
||||
"input_dbu_cost_per_token": 0.000214286,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14638,10 +14695,13 @@
|
|||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-claude-opus-4-5": {
|
||||
"cache_creation_input_token_cost": 6.25002e-06,
|
||||
"cache_read_input_token_cost": 5.0001e-07,
|
||||
"input_cost_per_token": 5.00003e-06,
|
||||
"input_dbu_cost_per_token": 7.1429e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14657,11 +14717,14 @@
|
|||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_output_config": true
|
||||
},
|
||||
"databricks/databricks-claude-opus-4-6": {
|
||||
"cache_creation_input_token_cost": 6.25002e-06,
|
||||
"cache_read_input_token_cost": 5.0001e-07,
|
||||
"input_cost_per_token": 5.00003e-06,
|
||||
"input_dbu_cost_per_token": 7.1429e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14677,10 +14740,94 @@
|
|||
"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
|
||||
},
|
||||
"databricks/databricks-claude-opus-4-7": {
|
||||
"cache_creation_input_token_cost": 6.25002e-06,
|
||||
"cache_read_input_token_cost": 5.0001e-07,
|
||||
"input_cost_per_token": 5.00003e-06,
|
||||
"input_dbu_cost_per_token": 7.1429e-05,
|
||||
"litellm_provider": "databricks",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"metadata": {
|
||||
"notes": "Costs per token are the published Global DBU rates times $0.070 per DBU. The '*_dbu_cost_per_token' fields are provided for reference; cost calculation reads the dollar '*_cost_per_token' fields."
|
||||
},
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2.500001e-05,
|
||||
"output_dbu_cost_per_token": 0.000357143,
|
||||
"prompt_cache_min_tokens": 2048,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_sampling_params": false,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"databricks/databricks-claude-opus-4-8": {
|
||||
"cache_creation_input_token_cost": 6.25002e-06,
|
||||
"cache_read_input_token_cost": 5.0001e-07,
|
||||
"input_cost_per_token": 5.00003e-06,
|
||||
"input_dbu_cost_per_token": 7.1429e-05,
|
||||
"litellm_provider": "databricks",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"metadata": {
|
||||
"notes": "Costs per token are the published Global DBU rates times $0.070 per DBU. The '*_dbu_cost_per_token' fields are provided for reference; cost calculation reads the dollar '*_cost_per_token' fields."
|
||||
},
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2.500001e-05,
|
||||
"output_dbu_cost_per_token": 0.000357143,
|
||||
"prompt_cache_min_tokens": 1024,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_function_calling": true,
|
||||
"supports_mid_conversation_system": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_sampling_params": false,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"databricks/databricks-claude-opus-5": {
|
||||
"cache_creation_input_token_cost": 6.25002e-06,
|
||||
"cache_read_input_token_cost": 5.0001e-07,
|
||||
"input_cost_per_token": 5.00003e-06,
|
||||
"input_dbu_cost_per_token": 7.1429e-05,
|
||||
"litellm_provider": "databricks",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"metadata": {
|
||||
"notes": "Costs per token are the published Global DBU rates times $0.070 per DBU. The '*_dbu_cost_per_token' fields are provided for reference; cost calculation reads the dollar '*_cost_per_token' fields."
|
||||
},
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 2.500001e-05,
|
||||
"output_dbu_cost_per_token": 0.000357143,
|
||||
"prompt_cache_min_tokens": 512,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_function_calling": true,
|
||||
"supports_mid_conversation_system": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_sampling_params": false,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"databricks/databricks-claude-sonnet-4": {
|
||||
"cache_creation_input_token_cost": 3.74997e-06,
|
||||
"cache_read_input_token_cost": 3.0002e-07,
|
||||
"input_cost_per_token": 2.9999900000000002e-06,
|
||||
"input_dbu_cost_per_token": 4.2857e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14696,10 +14843,13 @@
|
|||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-claude-sonnet-4-1": {
|
||||
"cache_creation_input_token_cost": 3.74997e-06,
|
||||
"cache_read_input_token_cost": 3.0002e-07,
|
||||
"input_cost_per_token": 2.9999900000000002e-06,
|
||||
"input_dbu_cost_per_token": 4.2857e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14715,10 +14865,13 @@
|
|||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-claude-sonnet-4-5": {
|
||||
"cache_creation_input_token_cost": 3.74997e-06,
|
||||
"cache_read_input_token_cost": 3.0002e-07,
|
||||
"input_cost_per_token": 2.9999900000000002e-06,
|
||||
"input_dbu_cost_per_token": 4.2857e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14734,10 +14887,13 @@
|
|||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-claude-sonnet-4-6": {
|
||||
"cache_creation_input_token_cost": 3.74997e-06,
|
||||
"cache_read_input_token_cost": 3.0002e-07,
|
||||
"input_cost_per_token": 2.9999900000000002e-06,
|
||||
"input_dbu_cost_per_token": 4.2857e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14753,10 +14909,41 @@
|
|||
"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
|
||||
},
|
||||
"databricks/databricks-claude-sonnet-5": {
|
||||
"cache_creation_input_token_cost": 3.74997e-06,
|
||||
"cache_read_input_token_cost": 3.0002e-07,
|
||||
"input_cost_per_token": 2.99999e-06,
|
||||
"input_dbu_cost_per_token": 4.2857e-05,
|
||||
"litellm_provider": "databricks",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"metadata": {
|
||||
"notes": "Costs per token are the published Global DBU rates times $0.070 per DBU. The '*_dbu_cost_per_token' fields are provided for reference; cost calculation reads the dollar '*_cost_per_token' fields. Introductory launch rates of 28.571 input / 142.857 output / 35.714 cache write / 2.857 cache read DBU run through 2026-08-31; the standard rates are listed here because entries carry no expiry date."
|
||||
},
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 1.500002e-05,
|
||||
"output_dbu_cost_per_token": 0.000214286,
|
||||
"prompt_cache_min_tokens": 1024,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_adaptive_thinking": true,
|
||||
"supports_assistant_prefill": false,
|
||||
"supports_function_calling": true,
|
||||
"supports_mid_conversation_system": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_sampling_params": false,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true
|
||||
},
|
||||
"databricks/databricks-gemini-2-5-flash": {
|
||||
"cache_creation_input_token_cost": 3.0002e-07,
|
||||
"cache_read_input_token_cost": 3.0002e-08,
|
||||
"input_cost_per_token": 3.0001999999999996e-07,
|
||||
"input_dbu_cost_per_token": 4.285999999999999e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14771,9 +14958,12 @@
|
|||
"output_dbu_cost_per_token": 3.5714e-05,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-gemini-2-5-pro": {
|
||||
"cache_creation_input_token_cost": 1.24999e-06,
|
||||
"cache_read_input_token_cost": 1.24999e-07,
|
||||
"input_cost_per_token": 1.24999e-06,
|
||||
"input_dbu_cost_per_token": 1.7857e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14788,9 +14978,12 @@
|
|||
"output_dbu_cost_per_token": 0.000142857,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-gemini-3-1-flash-lite": {
|
||||
"cache_creation_input_token_cost": 3.1248e-07,
|
||||
"cache_read_input_token_cost": 3.122e-08,
|
||||
"input_cost_per_token": 3.1248e-07,
|
||||
"input_dbu_cost_per_token": 4.464e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14805,9 +14998,12 @@
|
|||
"output_dbu_cost_per_token": 2.6786e-05,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-gemini-3-1-pro": {
|
||||
"cache_creation_input_token_cost": 2.49998e-06,
|
||||
"cache_read_input_token_cost": 2.4997e-07,
|
||||
"input_cost_per_token": 2.49998e-06,
|
||||
"input_dbu_cost_per_token": 3.5714e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14822,9 +15018,12 @@
|
|||
"output_dbu_cost_per_token": 0.000214286,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-gemini-3-flash": {
|
||||
"cache_creation_input_token_cost": 6.2503e-07,
|
||||
"cache_read_input_token_cost": 6.251e-08,
|
||||
"input_cost_per_token": 6.2503e-07,
|
||||
"input_dbu_cost_per_token": 8.929e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14839,9 +15038,12 @@
|
|||
"output_dbu_cost_per_token": 5.3571e-05,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-gemini-3-pro": {
|
||||
"cache_creation_input_token_cost": 2.49998e-06,
|
||||
"cache_read_input_token_cost": 2.4997e-07,
|
||||
"input_cost_per_token": 2.49998e-06,
|
||||
"input_dbu_cost_per_token": 3.5714e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14856,9 +15058,12 @@
|
|||
"output_dbu_cost_per_token": 0.000214286,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_function_calling": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-gemma-3-12b": {
|
||||
"cache_creation_input_token_cost": 1.5001e-07,
|
||||
"cache_read_input_token_cost": 1.5001e-07,
|
||||
"input_cost_per_token": 1.5000999999999998e-07,
|
||||
"input_dbu_cost_per_token": 2.1429999999999996e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14874,6 +15079,8 @@
|
|||
"source": "https://www.databricks.com/product/pricing/foundation-model-serving"
|
||||
},
|
||||
"databricks/databricks-gpt-5": {
|
||||
"cache_creation_input_token_cost": 1.24999e-06,
|
||||
"cache_read_input_token_cost": 1.2502e-07,
|
||||
"input_cost_per_token": 1.24999e-06,
|
||||
"input_dbu_cost_per_token": 1.7857e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14886,9 +15093,12 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 9.999990000000002e-06,
|
||||
"output_dbu_cost_per_token": 0.000142857,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving"
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_prompt_caching": true
|
||||
},
|
||||
"databricks/databricks-gpt-5-1": {
|
||||
"cache_creation_input_token_cost": 1.24999e-06,
|
||||
"cache_read_input_token_cost": 1.2502e-07,
|
||||
"input_cost_per_token": 1.24999e-06,
|
||||
"input_dbu_cost_per_token": 1.7857e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14901,9 +15111,12 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 9.999990000000002e-06,
|
||||
"output_dbu_cost_per_token": 0.000142857,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving"
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_prompt_caching": true
|
||||
},
|
||||
"databricks/databricks-gpt-5-1-codex-max": {
|
||||
"cache_creation_input_token_cost": 1.24999e-06,
|
||||
"cache_read_input_token_cost": 1.2502e-07,
|
||||
"input_cost_per_token": 1.24999e-06,
|
||||
"input_dbu_cost_per_token": 1.7857e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14916,9 +15129,12 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 9.999990000000002e-06,
|
||||
"output_dbu_cost_per_token": 0.000142857,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving"
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_prompt_caching": true
|
||||
},
|
||||
"databricks/databricks-gpt-5-1-codex-mini": {
|
||||
"cache_creation_input_token_cost": 2.4997e-07,
|
||||
"cache_read_input_token_cost": 2.499e-08,
|
||||
"input_cost_per_token": 2.4997e-07,
|
||||
"input_dbu_cost_per_token": 3.571e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14931,9 +15147,12 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 1.99997e-06,
|
||||
"output_dbu_cost_per_token": 2.8571e-05,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving"
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_prompt_caching": true
|
||||
},
|
||||
"databricks/databricks-gpt-5-2": {
|
||||
"cache_creation_input_token_cost": 1.75e-06,
|
||||
"cache_read_input_token_cost": 1.75e-07,
|
||||
"input_cost_per_token": 1.75e-06,
|
||||
"input_dbu_cost_per_token": 2.5e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14946,9 +15165,12 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 1.4e-05,
|
||||
"output_dbu_cost_per_token": 0.0002,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving"
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_prompt_caching": true
|
||||
},
|
||||
"databricks/databricks-gpt-5-2-codex": {
|
||||
"cache_creation_input_token_cost": 1.75e-06,
|
||||
"cache_read_input_token_cost": 1.75e-07,
|
||||
"input_cost_per_token": 1.75e-06,
|
||||
"input_dbu_cost_per_token": 2.5e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14961,9 +15183,12 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 1.4e-05,
|
||||
"output_dbu_cost_per_token": 0.0002,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving"
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_prompt_caching": true
|
||||
},
|
||||
"databricks/databricks-gpt-5-3-codex": {
|
||||
"cache_creation_input_token_cost": 1.75e-06,
|
||||
"cache_read_input_token_cost": 1.75e-07,
|
||||
"input_cost_per_token": 1.75e-06,
|
||||
"input_dbu_cost_per_token": 2.5e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14976,9 +15201,12 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 1.4e-05,
|
||||
"output_dbu_cost_per_token": 0.0002,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving"
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_prompt_caching": true
|
||||
},
|
||||
"databricks/databricks-gpt-5-4": {
|
||||
"cache_creation_input_token_cost": 2.49998e-06,
|
||||
"cache_read_input_token_cost": 2.4997e-07,
|
||||
"input_cost_per_token": 2.49998e-06,
|
||||
"input_dbu_cost_per_token": 3.5714e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -14991,9 +15219,12 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 1.5000020000000002e-05,
|
||||
"output_dbu_cost_per_token": 0.000214286,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving"
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_prompt_caching": true
|
||||
},
|
||||
"databricks/databricks-gpt-5-4-mini": {
|
||||
"cache_creation_input_token_cost": 7.4998e-07,
|
||||
"cache_read_input_token_cost": 7.497e-08,
|
||||
"input_cost_per_token": 7.4998e-07,
|
||||
"input_dbu_cost_per_token": 1.0714e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15006,9 +15237,12 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 4.50002e-06,
|
||||
"output_dbu_cost_per_token": 6.4286e-05,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving"
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_prompt_caching": true
|
||||
},
|
||||
"databricks/databricks-gpt-5-4-nano": {
|
||||
"cache_creation_input_token_cost": 1.9999e-07,
|
||||
"cache_read_input_token_cost": 2.002e-08,
|
||||
"input_cost_per_token": 1.9999e-07,
|
||||
"input_dbu_cost_per_token": 2.857e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15021,9 +15255,12 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 1.24999e-06,
|
||||
"output_dbu_cost_per_token": 1.7857e-05,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving"
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_prompt_caching": true
|
||||
},
|
||||
"databricks/databricks-gpt-5-mini": {
|
||||
"cache_creation_input_token_cost": 2.4997e-07,
|
||||
"cache_read_input_token_cost": 2.499e-08,
|
||||
"input_cost_per_token": 2.4997000000000006e-07,
|
||||
"input_dbu_cost_per_token": 3.571e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15036,9 +15273,12 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 1.9999700000000004e-06,
|
||||
"output_dbu_cost_per_token": 2.8571e-05,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving"
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_prompt_caching": true
|
||||
},
|
||||
"databricks/databricks-gpt-5-nano": {
|
||||
"cache_creation_input_token_cost": 4.998e-08,
|
||||
"cache_read_input_token_cost": 4.97e-09,
|
||||
"input_cost_per_token": 4.998e-08,
|
||||
"input_dbu_cost_per_token": 7.14e-07,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15051,9 +15291,12 @@
|
|||
"mode": "chat",
|
||||
"output_cost_per_token": 3.9998000000000007e-07,
|
||||
"output_dbu_cost_per_token": 5.714000000000001e-06,
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving"
|
||||
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
|
||||
"supports_prompt_caching": true
|
||||
},
|
||||
"databricks/databricks-gpt-oss-120b": {
|
||||
"cache_creation_input_token_cost": 1.5001e-07,
|
||||
"cache_read_input_token_cost": 1.5001e-07,
|
||||
"input_cost_per_token": 1.5000999999999998e-07,
|
||||
"input_dbu_cost_per_token": 2.1429999999999996e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15069,6 +15312,8 @@
|
|||
"source": "https://www.databricks.com/product/pricing/foundation-model-serving"
|
||||
},
|
||||
"databricks/databricks-gpt-oss-20b": {
|
||||
"cache_creation_input_token_cost": 7e-08,
|
||||
"cache_read_input_token_cost": 7e-08,
|
||||
"input_cost_per_token": 7e-08,
|
||||
"input_dbu_cost_per_token": 1e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15084,6 +15329,8 @@
|
|||
"source": "https://www.databricks.com/product/pricing/foundation-model-serving"
|
||||
},
|
||||
"databricks/databricks-gte-large-en": {
|
||||
"cache_creation_input_token_cost": 1.2999e-07,
|
||||
"cache_read_input_token_cost": 1.2999e-07,
|
||||
"input_cost_per_token": 1.2999000000000001e-07,
|
||||
"input_dbu_cost_per_token": 1.857e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15099,6 +15346,8 @@
|
|||
"source": "https://www.databricks.com/product/pricing/foundation-model-serving"
|
||||
},
|
||||
"databricks/databricks-llama-2-70b-chat": {
|
||||
"cache_creation_input_token_cost": 5.0001e-07,
|
||||
"cache_read_input_token_cost": 5.0001e-07,
|
||||
"input_cost_per_token": 5.0001e-07,
|
||||
"input_dbu_cost_per_token": 7.143e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15115,6 +15364,8 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-llama-4-maverick": {
|
||||
"cache_creation_input_token_cost": 5.0001e-07,
|
||||
"cache_read_input_token_cost": 5.0001e-07,
|
||||
"input_cost_per_token": 5.0001e-07,
|
||||
"input_dbu_cost_per_token": 7.143e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15131,6 +15382,8 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-meta-llama-3-1-405b-instruct": {
|
||||
"cache_creation_input_token_cost": 5.00003e-06,
|
||||
"cache_read_input_token_cost": 5.00003e-06,
|
||||
"input_cost_per_token": 5.00003e-06,
|
||||
"input_dbu_cost_per_token": 7.1429e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15147,6 +15400,8 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-meta-llama-3-1-8b-instruct": {
|
||||
"cache_creation_input_token_cost": 1.5001e-07,
|
||||
"cache_read_input_token_cost": 1.5001e-07,
|
||||
"input_cost_per_token": 1.5000999999999998e-07,
|
||||
"input_dbu_cost_per_token": 2.1429999999999996e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15162,6 +15417,8 @@
|
|||
"source": "https://www.databricks.com/product/pricing/foundation-model-serving"
|
||||
},
|
||||
"databricks/databricks-meta-llama-3-3-70b-instruct": {
|
||||
"cache_creation_input_token_cost": 5.0001e-07,
|
||||
"cache_read_input_token_cost": 5.0001e-07,
|
||||
"input_cost_per_token": 5.0001e-07,
|
||||
"input_dbu_cost_per_token": 7.143e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15178,6 +15435,8 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-meta-llama-3-70b-instruct": {
|
||||
"cache_creation_input_token_cost": 1.00002e-06,
|
||||
"cache_read_input_token_cost": 1.00002e-06,
|
||||
"input_cost_per_token": 1.00002e-06,
|
||||
"input_dbu_cost_per_token": 1.4286e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15194,6 +15453,8 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-mixtral-8x7b-instruct": {
|
||||
"cache_creation_input_token_cost": 5.0001e-07,
|
||||
"cache_read_input_token_cost": 5.0001e-07,
|
||||
"input_cost_per_token": 5.0001e-07,
|
||||
"input_dbu_cost_per_token": 7.143e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15210,6 +15471,8 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-mpt-30b-instruct": {
|
||||
"cache_creation_input_token_cost": 1.00002e-06,
|
||||
"cache_read_input_token_cost": 1.00002e-06,
|
||||
"input_cost_per_token": 1.00002e-06,
|
||||
"input_dbu_cost_per_token": 1.4286e-05,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -15226,6 +15489,8 @@
|
|||
"supports_tool_choice": true
|
||||
},
|
||||
"databricks/databricks-mpt-7b-instruct": {
|
||||
"cache_creation_input_token_cost": 5.0001e-07,
|
||||
"cache_read_input_token_cost": 5.0001e-07,
|
||||
"input_cost_per_token": 5.0001e-07,
|
||||
"input_dbu_cost_per_token": 7.143e-06,
|
||||
"litellm_provider": "databricks",
|
||||
|
|
@ -23180,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,
|
||||
|
|
@ -33563,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,
|
||||
|
|
@ -33607,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,
|
||||
|
|
@ -35681,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,
|
||||
|
|
@ -39056,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,
|
||||
|
|
@ -40315,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,
|
||||
|
|
@ -40347,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,
|
||||
|
|
@ -40712,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,
|
||||
|
|
@ -43795,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",
|
||||
|
|
@ -43808,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",
|
||||
|
|
@ -43824,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": {
|
||||
|
|
@ -48481,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,
|
||||
|
|
@ -49265,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,
|
||||
|
|
@ -50255,6 +50669,14 @@
|
|||
"supports_adaptive_thinking": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "claude-legacy-thinking",
|
||||
"pattern": "claude-[a-z]+-4[-._]6(?!\\d)",
|
||||
"description": "Claude at version 4.6 exactly, in any id shape that contains claude-<family>-4-6 (dotted and underscored minors included, dated releases such as claude-sonnet-4-6-20260219 too). The 4.6 family is adaptive-thinking yet still accepts legacy thinking.type=enabled with budget_tokens, so the caller's hard budget cap is forwarded verbatim instead of being rewritten to an uncapped output_config.effort. The lookahead keeps two-digit minors such as 4-60 from matching. 4.7+ and 5+ majors reject the legacy shape and stay on the adaptive translation.",
|
||||
"model_info": {
|
||||
"supports_legacy_thinking": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "claude-always-on-thinking",
|
||||
"pattern": "claude-(?:fable|mythos)-",
|
||||
|
|
|
|||
|
|
@ -428,6 +428,14 @@
|
|||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"output_cost_per_second_480p": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"output_cost_per_second_4k": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"output_cost_per_token": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
|
|
@ -625,6 +633,9 @@
|
|||
"supports_image_size": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"supports_legacy_thinking": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"supports_low_reasoning_effort": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"ANN001": {
|
||||
"limit": 3020
|
||||
"limit": 3018
|
||||
},
|
||||
"ANN002": {
|
||||
"limit": 71
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
"limit": 827
|
||||
},
|
||||
"ANN201": {
|
||||
"limit": 2017
|
||||
"limit": 2016
|
||||
},
|
||||
"ANN202": {
|
||||
"limit": 852
|
||||
|
|
@ -57,7 +57,7 @@
|
|||
"limit": 3
|
||||
},
|
||||
"BLE001": {
|
||||
"limit": 2920
|
||||
"limit": 2919
|
||||
},
|
||||
"C401": {
|
||||
"limit": 8
|
||||
|
|
@ -108,7 +108,7 @@
|
|||
"limit": 3
|
||||
},
|
||||
"F401": {
|
||||
"limit": 17
|
||||
"limit": 14
|
||||
},
|
||||
"LOG015": {
|
||||
"limit": 5
|
||||
|
|
@ -152,9 +152,6 @@
|
|||
"PLW0127": {
|
||||
"limit": 57
|
||||
},
|
||||
"PLW0133": {
|
||||
"limit": 1
|
||||
},
|
||||
"PLW0602": {
|
||||
"limit": 215
|
||||
},
|
||||
|
|
|
|||
|
|
@ -40,6 +40,17 @@
|
|||
# later binding makes the name local for the whole body, so the read raises
|
||||
# UnboundLocalError, and in an autouse fixture that takes every test in the
|
||||
# directory down with it
|
||||
# F601 the same key literal twice in one dict. Python keeps the last value, so the
|
||||
# first is dropped before the test ever runs, and a fixture that looks like it
|
||||
# covers two cases covers one
|
||||
# B023 a closure over a loop variable. Every closure sees the last iteration's value,
|
||||
# so a per-case callback built in a loop checks the last case N times. Bind the
|
||||
# value as a parameter instead
|
||||
# B025 an `except` for a type an earlier `except` already catches. The second handler
|
||||
# is unreachable, so the recovery or skip written there never happens
|
||||
# F632 `is` against a literal. It compares identity, so it passes only where CPython
|
||||
# happens to intern the value and stops meaning what it says the moment the
|
||||
# value is built at runtime
|
||||
#
|
||||
# No target-version here on purpose: it resolves from requires-python (>=3.10), so
|
||||
# 3.11-only builtins like BaseExceptionGroup are correctly flagged in a tree that
|
||||
|
|
@ -63,4 +74,8 @@ lint.select = [
|
|||
"PLW0127",
|
||||
"RUF043",
|
||||
"F823",
|
||||
"F601",
|
||||
"B023",
|
||||
"B025",
|
||||
"F632",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ lint.ignore = ["F405", "E402", "F403"]
|
|||
lint.extend-select = [
|
||||
"T20", "PGH004", "RUF008", "RUF009", "RUF100",
|
||||
"B033", "FURB136", "FURB168", "FURB188", "I001", "PERF402", "PIE790", "PIE800", "PLC0208",
|
||||
"PLR0402", "PLR1711", "PLR1730", "PLR2044", "PYI030", "PYI041", "PYI064", "RET501", "RUF010",
|
||||
"RUF022", "RUF023", "RUF051", "SIM114", "SIM118", "TC005", "UP006", "UP007", "UP008", "UP012",
|
||||
"UP018", "UP024", "UP032", "UP034", "UP035", "UP037", "UP045",
|
||||
"PLR0402", "PLR1711", "PLR1730", "PLR2044", "PLW0133", "PYI030", "PYI041", "PYI064", "RET501",
|
||||
"RUF010", "RUF022", "RUF023", "RUF051", "SIM114", "SIM118", "TC005", "UP006", "UP007", "UP008",
|
||||
"UP012", "UP018", "UP024", "UP032", "UP034", "UP035", "UP037", "UP045",
|
||||
]
|
||||
# RUF100 (unused-noqa) only knows the rules enabled in THIS config, so it would strip
|
||||
# `# noqa` directives that protect rules enforced elsewhere. List those codes as external
|
||||
|
|
|
|||
|
|
@ -14,6 +14,14 @@ longer signal it.
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **team**: `soft_budget`, `tags`, and `soft_budget_alerting_emails` attributes on `litellm_team`, matching what `/team/new` and `/team/update` already accept; `soft_budget_alerting_emails` is sent under `metadata`, where the proxy reads it
|
||||
|
||||
### Fixed
|
||||
|
||||
- **team**: Read now decodes the `team_info` envelope `/team/info` actually returns, so team attributes refresh from the proxy instead of always falling back to the prior state
|
||||
|
||||
### Changed
|
||||
|
||||
- **Versioning**: the provider is now published at the LiteLLM version, from the same commit as the proxy, on every LiteLLM release (dev, rc, stable). The `0.x` line ends at `0.4.0`; a `~> 0.4` constraint will not receive further releases, so re-pin to the LiteLLM version your proxy runs (for example `~> 1.99.0`). Existing `0.x` versions remain in the registry and keep verifying
|
||||
|
|
|
|||
|
|
@ -24,11 +24,18 @@ resource "litellm_team" "advanced_team" {
|
|||
|
||||
# Budget and rate limiting
|
||||
max_budget = 1000.0
|
||||
soft_budget = 800.0
|
||||
budget_duration = "1mo"
|
||||
tpm_limit = 500000
|
||||
rpm_limit = 5000
|
||||
blocked = false
|
||||
|
||||
# Who gets paged when spend crosses soft_budget
|
||||
soft_budget_alerting_emails = ["finops@example.com"]
|
||||
|
||||
# Tags for spend tracking and tag-based routing
|
||||
tags = ["team:ai-research", "environment:production"]
|
||||
|
||||
# Team member permissions
|
||||
team_member_permissions = [
|
||||
"create_key",
|
||||
|
|
@ -91,7 +98,9 @@ The following arguments are supported:
|
|||
|
||||
* `models` - (Optional) List of model names that this team can access.
|
||||
|
||||
* `metadata` - (Optional) A map of metadata key-value pairs associated with the team.
|
||||
* `metadata` - (Optional) A map of string metadata key-value pairs associated with the team. `tags` and `soft_budget_alerting_emails` are stored by the proxy under metadata but are managed through their own attributes below, not this map.
|
||||
|
||||
* `tags` - (Optional) List of tags applied to the team, used for [spend tracking](https://docs.litellm.ai/docs/proxy/enterprise#tracking-spend-for-custom-tags) and [tag-based routing](https://docs.litellm.ai/docs/proxy/tag_routing).
|
||||
|
||||
* `blocked` - (Optional) Whether the team is blocked from making requests. Default is `false`.
|
||||
|
||||
|
|
@ -101,6 +110,10 @@ The following arguments are supported:
|
|||
|
||||
* `max_budget` - (Optional) Maximum budget allocated to the team.
|
||||
|
||||
* `soft_budget` - (Optional) Spend threshold at which the proxy sends a soft budget alert without blocking requests.
|
||||
|
||||
* `soft_budget_alerting_emails` - (Optional) List of email addresses notified when the team's spend crosses `soft_budget`.
|
||||
|
||||
* `budget_duration` - (Optional) Duration for the budget cycle. Valid values are:
|
||||
* `daily`
|
||||
* `weekly`
|
||||
|
|
|
|||
|
|
@ -53,6 +53,11 @@ func ResourceLiteLLMTeam() *schema.Resource {
|
|||
Type: schema.TypeFloat,
|
||||
Optional: true,
|
||||
},
|
||||
"soft_budget": {
|
||||
Type: schema.TypeFloat,
|
||||
Optional: true,
|
||||
Description: "Spend threshold that triggers a soft budget alert without blocking requests",
|
||||
},
|
||||
"budget_duration": {
|
||||
Type: schema.TypeString,
|
||||
Optional: true,
|
||||
|
|
@ -72,6 +77,18 @@ func ResourceLiteLLMTeam() *schema.Resource {
|
|||
Elem: &schema.Schema{Type: schema.TypeString},
|
||||
Description: "List of permissions granted to team members",
|
||||
},
|
||||
"tags": {
|
||||
Type: schema.TypeList,
|
||||
Optional: true,
|
||||
Elem: &schema.Schema{Type: schema.TypeString},
|
||||
Description: "Tags for spend tracking and tag-based routing",
|
||||
},
|
||||
"soft_budget_alerting_emails": {
|
||||
Type: schema.TypeList,
|
||||
Optional: true,
|
||||
Elem: &schema.Schema{Type: schema.TypeString},
|
||||
Description: "Email addresses alerted when the team crosses soft_budget",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -117,21 +134,20 @@ func resourceLiteLLMTeamRead(d *schema.ResourceData, m interface{}) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
var teamResp TeamResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&teamResp); err != nil {
|
||||
var infoResp TeamInfoResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&infoResp); err != nil {
|
||||
return fmt.Errorf("error decoding team info response: %w", err)
|
||||
}
|
||||
teamResp := infoResp.TeamInfo
|
||||
|
||||
// Update the state with values from the response or fall back to the data passed in during creation
|
||||
d.Set("team_alias", GetStringValue(teamResp.TeamAlias, d.Get("team_alias").(string)))
|
||||
d.Set("organization_id", GetStringValue(teamResp.OrganizationID, d.Get("organization_id").(string)))
|
||||
|
||||
// Handle metadata separately as it's a map
|
||||
if teamResp.Metadata != nil {
|
||||
d.Set("metadata", teamResp.Metadata)
|
||||
} else {
|
||||
d.Set("metadata", d.Get("metadata"))
|
||||
}
|
||||
metadata, tags, alertEmails := splitTeamMetadata(teamResp.Metadata)
|
||||
d.Set("metadata", metadata)
|
||||
d.Set("tags", tags)
|
||||
d.Set("soft_budget_alerting_emails", alertEmails)
|
||||
|
||||
if teamResp.TPMLimit != nil {
|
||||
d.Set("tpm_limit", *teamResp.TPMLimit)
|
||||
|
|
@ -142,6 +158,7 @@ func resourceLiteLLMTeamRead(d *schema.ResourceData, m interface{}) error {
|
|||
if teamResp.MaxBudget != nil {
|
||||
d.Set("max_budget", *teamResp.MaxBudget)
|
||||
}
|
||||
d.Set("soft_budget", teamResp.SoftBudget)
|
||||
d.Set("budget_duration", GetStringValue(teamResp.BudgetDuration, d.Get("budget_duration").(string)))
|
||||
|
||||
// Handle models separately as it's a list
|
||||
|
|
@ -240,15 +257,77 @@ func buildTeamData(d *schema.ResourceData, teamID string) map[string]interface{}
|
|||
"team_alias": d.Get("team_alias").(string),
|
||||
}
|
||||
|
||||
for _, key := range []string{"organization_id", "metadata", "tpm_limit", "rpm_limit", "max_budget", "budget_duration", "models", "blocked", "team_member_permissions"} {
|
||||
for _, key := range []string{"organization_id", "tpm_limit", "rpm_limit", "max_budget", "budget_duration", "models", "blocked", "team_member_permissions"} {
|
||||
if v, ok := d.GetOk(key); ok {
|
||||
teamData[key] = v
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := d.GetOk("soft_budget"); ok {
|
||||
teamData["soft_budget"] = v
|
||||
} else if d.HasChange("soft_budget") {
|
||||
teamData["soft_budget"] = nil
|
||||
}
|
||||
|
||||
if v, ok := d.GetOk("tags"); ok || d.HasChange("tags") {
|
||||
teamData["tags"] = v
|
||||
}
|
||||
|
||||
if metadata := buildTeamMetadata(d); metadata != nil {
|
||||
teamData["metadata"] = metadata
|
||||
}
|
||||
|
||||
return teamData
|
||||
}
|
||||
|
||||
// /team/update replaces metadata wholesale, so the full map must go out whenever either half changed.
|
||||
func buildTeamMetadata(d *schema.ResourceData) map[string]interface{} {
|
||||
metadata := map[string]interface{}{}
|
||||
for k, v := range d.Get("metadata").(map[string]interface{}) {
|
||||
metadata[k] = v
|
||||
}
|
||||
if v, ok := d.GetOk("soft_budget_alerting_emails"); ok {
|
||||
metadata["soft_budget_alerting_emails"] = v
|
||||
}
|
||||
if len(metadata) == 0 && !d.HasChange("metadata") && !d.HasChange("soft_budget_alerting_emails") {
|
||||
return nil
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func splitTeamMetadata(raw map[string]interface{}) (map[string]string, []string, []string) {
|
||||
metadata := map[string]string{}
|
||||
var tags, alertEmails []string
|
||||
for k, v := range raw {
|
||||
switch k {
|
||||
case "tags":
|
||||
tags = toStringSlice(v)
|
||||
case "soft_budget_alerting_emails":
|
||||
alertEmails = toStringSlice(v)
|
||||
case "team_member_budget_id":
|
||||
default:
|
||||
if s, ok := v.(string); ok {
|
||||
metadata[k] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
return metadata, tags, alertEmails
|
||||
}
|
||||
|
||||
func toStringSlice(v interface{}) []string {
|
||||
items, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if s, ok := item.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func handleResponse(resp *http.Response, action string) error {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
|
|
|||
184
terraform/provider/litellm/resource_team_test.go
Normal file
184
terraform/provider/litellm/resource_team_test.go
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
package litellm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
|
||||
"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
|
||||
)
|
||||
|
||||
func newTeamTestServer(t *testing.T, captured *map[string]interface{}, infoBody string) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case endpointTeamNew, endpointTeamUpdate:
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
json.Unmarshal(body, captured)
|
||||
w.Write([]byte(`{}`))
|
||||
case endpointTeamInfo:
|
||||
w.Write([]byte(infoBody))
|
||||
case endpointTeamPermissionsList:
|
||||
w.Write([]byte(`{"team_id":"team-1","team_member_permissions":[],"all_available_permissions":[]}`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
const teamInfoWithSoftBudget = `{
|
||||
"team_id": "team-1",
|
||||
"team_info": {
|
||||
"team_id": "team-1",
|
||||
"team_alias": "insights",
|
||||
"max_budget": 750.0,
|
||||
"soft_budget": 600.0,
|
||||
"models": ["claude-haiku-4-5"],
|
||||
"metadata": {
|
||||
"department": "customer-insights",
|
||||
"tags": ["team:customer-insights", "environment:production"],
|
||||
"soft_budget_alerting_emails": ["finops@example.com"],
|
||||
"team_member_budget_id": "budget-1"
|
||||
}
|
||||
},
|
||||
"keys": [],
|
||||
"team_memberships": []
|
||||
}`
|
||||
|
||||
func TestTeamCreateSendsSoftBudgetTagsAndAlertEmails(t *testing.T) {
|
||||
var captured map[string]interface{}
|
||||
srv := newTeamTestServer(t, &captured, teamInfoWithSoftBudget)
|
||||
defer srv.Close()
|
||||
|
||||
d := schema.TestResourceDataRaw(t, ResourceLiteLLMTeam().Schema, map[string]interface{}{
|
||||
"team_alias": "insights",
|
||||
"max_budget": 750.0,
|
||||
"soft_budget": 600.0,
|
||||
"tags": []interface{}{"team:customer-insights", "environment:production"},
|
||||
"soft_budget_alerting_emails": []interface{}{"finops@example.com"},
|
||||
"metadata": map[string]interface{}{"department": "customer-insights"},
|
||||
})
|
||||
|
||||
if err := resourceLiteLLMTeamCreate(d, NewClient(srv.URL, "test-key", true)); err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
|
||||
if got := captured["soft_budget"]; got != 600.0 {
|
||||
t.Fatalf("payload soft_budget = %v, want 600", got)
|
||||
}
|
||||
wantTags := []interface{}{"team:customer-insights", "environment:production"}
|
||||
if got := captured["tags"]; !reflect.DeepEqual(got, wantTags) {
|
||||
t.Fatalf("payload tags = %v, want %v", got, wantTags)
|
||||
}
|
||||
wantMetadata := map[string]interface{}{
|
||||
"department": "customer-insights",
|
||||
"soft_budget_alerting_emails": []interface{}{"finops@example.com"},
|
||||
}
|
||||
if got := captured["metadata"]; !reflect.DeepEqual(got, wantMetadata) {
|
||||
t.Fatalf("payload metadata = %v, want %v", got, wantMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamReadMapsTeamInfoEnvelope(t *testing.T) {
|
||||
var captured map[string]interface{}
|
||||
srv := newTeamTestServer(t, &captured, teamInfoWithSoftBudget)
|
||||
defer srv.Close()
|
||||
|
||||
d := schema.TestResourceDataRaw(t, ResourceLiteLLMTeam().Schema, map[string]interface{}{})
|
||||
d.SetId("team-1")
|
||||
|
||||
if err := resourceLiteLLMTeamRead(d, NewClient(srv.URL, "test-key", true)); err != nil {
|
||||
t.Fatalf("read failed: %v", err)
|
||||
}
|
||||
|
||||
if got := d.Get("team_alias"); got != "insights" {
|
||||
t.Fatalf("team_alias = %v, want insights", got)
|
||||
}
|
||||
if got := d.Get("soft_budget"); got != 600.0 {
|
||||
t.Fatalf("soft_budget = %v, want 600", got)
|
||||
}
|
||||
if got := d.Get("max_budget"); got != 750.0 {
|
||||
t.Fatalf("max_budget = %v, want 750", got)
|
||||
}
|
||||
wantTags := []interface{}{"team:customer-insights", "environment:production"}
|
||||
if got := d.Get("tags"); !reflect.DeepEqual(got, wantTags) {
|
||||
t.Fatalf("tags = %v, want %v", got, wantTags)
|
||||
}
|
||||
wantEmails := []interface{}{"finops@example.com"}
|
||||
if got := d.Get("soft_budget_alerting_emails"); !reflect.DeepEqual(got, wantEmails) {
|
||||
t.Fatalf("soft_budget_alerting_emails = %v, want %v", got, wantEmails)
|
||||
}
|
||||
wantMetadata := map[string]interface{}{"department": "customer-insights"}
|
||||
if got := d.Get("metadata"); !reflect.DeepEqual(got, wantMetadata) {
|
||||
t.Fatalf("metadata = %v, want %v (server-managed team_member_budget_id dropped)", got, wantMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamUpdateClearsRemovedTagsAndSoftBudget(t *testing.T) {
|
||||
var captured map[string]interface{}
|
||||
srv := newTeamTestServer(t, &captured, `{"team_id":"team-1","team_info":{"team_id":"team-1","team_alias":"insights"},"keys":[],"team_memberships":[]}`)
|
||||
defer srv.Close()
|
||||
|
||||
res := ResourceLiteLLMTeam()
|
||||
priorData := schema.TestResourceDataRaw(t, res.Schema, map[string]interface{}{
|
||||
"team_alias": "insights",
|
||||
"soft_budget": 600.0,
|
||||
"tags": []interface{}{"team:to-be-removed"},
|
||||
"soft_budget_alerting_emails": []interface{}{"ops@example.com"},
|
||||
"metadata": map[string]interface{}{"department": "eng"},
|
||||
})
|
||||
priorData.SetId("team-1")
|
||||
prior := priorData.State()
|
||||
config := terraform.NewResourceConfigRaw(map[string]interface{}{
|
||||
"team_alias": "insights",
|
||||
"metadata": map[string]interface{}{"department": "eng"},
|
||||
})
|
||||
diff, err := res.Diff(context.Background(), prior, config, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("diff failed: %v", err)
|
||||
}
|
||||
d, err := schema.InternalMap(res.Schema).Data(prior, diff)
|
||||
if err != nil {
|
||||
t.Fatalf("data failed: %v", err)
|
||||
}
|
||||
|
||||
if err := resourceLiteLLMTeamUpdate(d, NewClient(srv.URL, "test-key", true)); err != nil {
|
||||
t.Fatalf("update failed: %v", err)
|
||||
}
|
||||
|
||||
if got, ok := captured["soft_budget"]; !ok || got != nil {
|
||||
t.Fatalf("payload soft_budget = %v (present=%v), want explicit null", got, ok)
|
||||
}
|
||||
if got := captured["tags"]; !reflect.DeepEqual(got, []interface{}{}) {
|
||||
t.Fatalf("payload tags = %v, want []", got)
|
||||
}
|
||||
if got := captured["metadata"]; !reflect.DeepEqual(got, map[string]interface{}{"department": "eng"}) {
|
||||
t.Fatalf("payload metadata = %v, want department only", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamReadClearsSoftBudgetWhenProxyReturnsNull(t *testing.T) {
|
||||
var captured map[string]interface{}
|
||||
srv := newTeamTestServer(t, &captured, `{"team_id":"team-1","team_info":{"team_id":"team-1","team_alias":"insights","soft_budget":null},"keys":[],"team_memberships":[]}`)
|
||||
defer srv.Close()
|
||||
|
||||
d := schema.TestResourceDataRaw(t, ResourceLiteLLMTeam().Schema, map[string]interface{}{
|
||||
"team_alias": "insights",
|
||||
"soft_budget": 600.0,
|
||||
})
|
||||
d.SetId("team-1")
|
||||
|
||||
if err := resourceLiteLLMTeamRead(d, NewClient(srv.URL, "test-key", true)); err != nil {
|
||||
t.Fatalf("read failed: %v", err)
|
||||
}
|
||||
|
||||
if got := d.Get("soft_budget"); got != 0.0 {
|
||||
t.Fatalf("soft_budget = %v, want cleared after the proxy returned null", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +33,11 @@ type ModelRequest struct {
|
|||
Additional map[string]interface{} `json:"additional"`
|
||||
}
|
||||
|
||||
type TeamInfoResponse struct {
|
||||
TeamID string `json:"team_id"`
|
||||
TeamInfo TeamResponse `json:"team_info"`
|
||||
}
|
||||
|
||||
// TeamResponse represents a response from the API containing team information.
|
||||
type TeamResponse struct {
|
||||
TeamID string `json:"team_id,omitempty"`
|
||||
|
|
@ -42,6 +47,7 @@ type TeamResponse struct {
|
|||
TPMLimit *int `json:"tpm_limit,omitempty"`
|
||||
RPMLimit *int `json:"rpm_limit,omitempty"`
|
||||
MaxBudget *float64 `json:"max_budget,omitempty"`
|
||||
SoftBudget *float64 `json:"soft_budget,omitempty"`
|
||||
BudgetDuration string `json:"budget_duration,omitempty"`
|
||||
Models []string `json:"models"`
|
||||
Blocked bool `json:"blocked,omitempty"`
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ IGNORE_FUNCTIONS = [
|
|||
"json_string_leaves", # max depth set (MAX_STRUCTURED_CONTENT_SCAN_DEPTH); fails closed by raising at the cap so nothing goes unscanned.
|
||||
"with_json_string_leaves", # transitively bounded: only runs on a tree json_string_leaves already walked under the cap.
|
||||
"json_unrewritable_labels", # max depth set (MAX_STRUCTURED_CONTENT_SCAN_DEPTH); returns the None sentinel at the cap so the caller blocks.
|
||||
"_flatten_form_field", # bounded by the nesting depth of the already-parsed request body (a finite JSON tree, no cycles possible).
|
||||
"_flatten_form_data_field", # bounded by the nesting depth of the already-parsed request body (a finite JSON tree, no cycles possible).
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ Mark live tests with `@pytest.mark.e2e` (on the class or the module). Pure cover
|
|||
|
||||
The seam is `provider_edge.py`: `start_provider_edge` boots an in-process HTTP server (one shared instance per pytest process, `e2e_config.provider_edge_base` is the accessor) that mounts each supported provider under a path prefix (`EDGE_MOUNTS`: `/openai` -> `https://api.openai.com`, `/anthropic` -> `https://api.anthropic.com`). A test participates by registering its deployment with `api_base=provider_edge_base("openai")` plus the provider's path suffix; `quota_management/spend_tracking/test_provider_edge_spend_e2e.py` is the reference. In live mode the accessor returns None and the deployment defaults to the real provider, so an edge-wired test runs in all three modes unchanged. Non-wired tests hit their providers live in every mode. The edge binds `E2E_PROVIDER_EDGE_BIND_HOST` (default 127.0.0.1) and advertises `E2E_PROVIDER_EDGE_ADVERTISE_HOST` in the api_base it hands out, for proxies running in containers
|
||||
|
||||
A bundle (default `tests/e2e/.fixtures`, override with `E2E_FIXTURE_DIR`) is a directory: `manifest.json` carries the record timestamp, harness git version, and format version, and each test gets a subdirectory holding one JSON file per provider call in call order (`0000-post-openai-v1-chat-completions.json`). Request headers are never stored (provider credentials never touch disk), non-JSON request bodies store a canonicalized sha256 digest instead of the bytes, `multipart/form-data` bodies store their ordinary fields plus a JSON list of the uploaded parts' `[field, filename, content-type]` triples and a digest of their content, so the per-request random boundary and the envelope never reach the key, and responses store status, filtered headers, and the verbatim body base64-encoded, which is part of why bundles are gitignored. `fixture_bundle.py` owns the format. Record serves the proxy the same filtered stored response replay will serve later, so the two modes are byte-identical from the proxy's side of the socket
|
||||
A bundle (default `tests/e2e/.fixtures`, override with `E2E_FIXTURE_DIR`) is a directory: `manifest.json` carries the record timestamp, harness git version, and format version, and each test gets a subdirectory holding one JSON file per provider call in call order (`0000-post-openai-v1-chat-completions.json`). Request headers are never stored (provider credentials never touch disk), non-JSON request bodies store a canonicalized sha256 digest instead of the bytes, `multipart/form-data` bodies store their ordinary fields plus a JSON list of the uploaded parts' `[field, filename, content-type]` triples and a digest of their content, so the per-request random boundary and the envelope never reach the key, and responses store status, filtered headers, and the verbatim body base64-encoded, which is part of why bundles are gitignored. Responses come in two shapes told apart by a `kind` tag: an ordinary one holding a single base64 body, and, for a response the provider streamed (`content-type: text/event-stream`), one holding its transfer chunks in order plus why the stream ended early if it did, so replay reproduces the split points the provider chose instead of one coalesced body. `fixture_bundle.py` owns the format, and `BUNDLE_FORMAT_VERSION` is checked on load, so a bundle recorded under older rules is refused by name rather than partially read. Record serves the proxy the same filtered stored response replay will serve later, chunk for chunk on a stream, so the two modes are byte-identical from the proxy's side of the socket
|
||||
|
||||
Multipart identity is the fiddly corner, and the rules exist because each one had a collision behind it. A part counts as an upload when it carries a filename or declares its own content type, and everything else is an ordinary field. Field names get a `name[n]` suffix on repeats, with a literal `[` doubled first, so a form that repeats `purpose` never keys the same as one that literally sends `purpose[1]`. A field whose name reads as a credential is stored as `<secret>`, which stays key-preserving because the key is recomputed from the stored request rather than saved alongside it, so the live request carrying the real value still matches its redacted fixture. A field value that is not UTF-8 is stored as a base64 sha256 digest, base64 and not hex because the canonicalizer rewrites any 64-character hex run to `<sha256>` and would fold every binary value onto one key. The uploaded parts contribute a JSON list rather than a `field:filename` string, so a separator inside a filename cannot impersonate a field boundary, and their byte length is stored for a reader's benefit but deliberately left out of the key, since the canonicalizer absorbs timestamp and id drift inside a file that changes its length
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ A replayed response carries the recorded provider response id, and `LiteLLM_Spen
|
|||
|
||||
The same id reuse reaches the managed-object tables. A replayed `/v1/files` or `/v1/batches` response carries the recorded provider object id, and `LiteLLM_ManagedObjectTable.model_object_id` is unique, so a unified batch create replayed against a database that still holds the record run's row fails on a Prisma unique-constraint violation, which surfaces as a 500, makes the router retry, and exhausts the recording. Replay the batches suite against a fresh database, or truncate `LiteLLM_ManagedObjectTable` and `LiteLLM_ManagedFileTable` before the run
|
||||
|
||||
Edge-wired today: `quota_management/spend_tracking/test_provider_edge_spend_e2e.py` (the reference), `llm_translation/test_chat_completions_contract_e2e.py`, the OpenAI registrations in `llm_translation/test_embeddings_endpoint_e2e.py`, the Anthropic deployments in `llm_translation/test_messages_e2e.py` except the streaming test, and the OpenAI batch deployment behind `batches/` (`capabilities.openai_batch_params`). The mount base is not the same for both providers: OpenAI deployments register `f"{base}/v1"`, Anthropic deployments register `base` on its own, because litellm's Anthropic handler appends `/v1/messages` to `api_base` itself where the OpenAI handler appends only `/chat/completions`. Recording one suite locally is two runs against a proxy you already have up:
|
||||
Edge-wired today: `quota_management/spend_tracking/test_provider_edge_spend_e2e.py` (the reference), `llm_translation/test_chat_completions_contract_e2e.py`, the OpenAI registrations in `llm_translation/test_embeddings_endpoint_e2e.py`, the Anthropic deployments in `llm_translation/test_messages_e2e.py` including the streaming test, and the OpenAI batch deployment behind `batches/` (`capabilities.openai_batch_params`). The mount base is not the same for both providers: OpenAI deployments register `f"{base}/v1"`, Anthropic deployments register `base` on its own, because litellm's Anthropic handler appends `/v1/messages` to `api_base` itself where the OpenAI handler appends only `/chat/completions`. Recording one suite locally is two runs against a proxy you already have up:
|
||||
|
||||
```bash
|
||||
E2E_FIXTURE_MODE=record E2E_FIXTURE_DIR=/tmp/e2e-fixtures E2E_RESET_SPEND_LOGS=1 uv run pytest tests/e2e/llm_translation/test_chat_completions_contract_e2e.py
|
||||
|
|
@ -96,7 +96,7 @@ E2E_FIXTURE_MODE=replay E2E_FIXTURE_DIR=/tmp/e2e-fixtures E2E_RESET_SPEND_LOGS=1
|
|||
|
||||
Point the proxy at bogus provider credentials for the replay run and it still has to pass: that is the whole proof that nothing left the process. Bundles are never committed. `tests/e2e/.fixtures` is gitignored because a bundle holds verbatim provider response bodies and hard-fails after seven days, and publishing one for CI is LIT-5748
|
||||
|
||||
Current limits: streaming chunk fidelity is LIT-5742 (a streamed response records as one buffered body), CI wiring is LIT-5748, Bedrock cannot be mounted (SigV4 signs the Host header, so a rewritten api_base fails signature verification), deployments baked into the proxy's config file cannot be edge-wired (only `/model/new` registrations can carry the edge api_base), and a file upload routed by `custom_llm_provider` through the proxy's `files_settings` block never passes a deployment at all, so the batches `model_param` and `provider_fallback` scenarios keep uploading live in every mode
|
||||
Current limits: CI wiring is LIT-5748, Bedrock cannot be mounted (SigV4 signs the Host header, so a rewritten api_base fails signature verification), deployments baked into the proxy's config file cannot be edge-wired (only `/model/new` registrations can carry the edge api_base), and a file upload routed by `custom_llm_provider` through the proxy's `files_settings` block never passes a deployment at all, so the batches `model_param` and `provider_fallback` scenarios keep uploading live in every mode
|
||||
|
||||
## Typing
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ Bundles stay local. `tests/e2e/.fixtures` is gitignored because a bundle holds v
|
|||
|
||||
One sharp edge: a replayed response reuses the recorded provider response id, and that id is the primary key of `LiteLLM_SpendLogs`, so replaying against a database that still holds the record run's rows silently dedupes the spend writes and a spend assertion fails with zero rows. Run both commands above with `E2E_RESET_SPEND_LOGS=1` (and `DATABASE_URL` set in the pytest env) so each session truncates the spend log table after itself, or point replay at a fresh database
|
||||
|
||||
Replay answers any provider call that drifted from the recording with an HTTP 599 whose body names the computed and closest recorded keys, so the test fails loudly instead of silently going live, and a bundle older than seven days fails at collection time naming its age; either way the fix is to re-record. Only tests that register edge-wired deployments participate: everything else hits its provider live in every mode, so record exactly the suite you replay. If the proxy runs in a container, set `E2E_PROVIDER_EDGE_ADVERTISE_HOST` (e.g. `host.docker.internal`) so the api_base the proxy stores can reach the edge on the pytest host, and `E2E_PROVIDER_EDGE_BIND_HOST=0.0.0.0` so the edge accepts it. The suites wired to the edge today are `quota_management/spend_tracking/test_provider_edge_spend_e2e.py`, `llm_translation/test_chat_completions_contract_e2e.py`, the OpenAI registrations in `llm_translation/test_embeddings_endpoint_e2e.py`, the non-streaming Anthropic tests in `llm_translation/test_messages_e2e.py`, and the OpenAI batch deployment behind `batches/`. See `CLAUDE.md` in this directory for the bundle format, the edge design, and the current limits (streaming, Bedrock)
|
||||
Replay answers any provider call that drifted from the recording with an HTTP 599 whose body names the computed and closest recorded keys, so the test fails loudly instead of silently going live, and a bundle older than seven days fails at collection time naming its age; either way the fix is to re-record. Only tests that register edge-wired deployments participate: everything else hits its provider live in every mode, so record exactly the suite you replay. If the proxy runs in a container, set `E2E_PROVIDER_EDGE_ADVERTISE_HOST` (e.g. `host.docker.internal`) so the api_base the proxy stores can reach the edge on the pytest host, and `E2E_PROVIDER_EDGE_BIND_HOST=0.0.0.0` so the edge accepts it. The suites wired to the edge today are `quota_management/spend_tracking/test_provider_edge_spend_e2e.py`, `llm_translation/test_chat_completions_contract_e2e.py`, the OpenAI registrations in `llm_translation/test_embeddings_endpoint_e2e.py`, the Anthropic tests in `llm_translation/test_messages_e2e.py`, streamed and not, and the OpenAI batch deployment behind `batches/`. A streamed response replays as the chunk sequence the provider sent rather than one buffered body. See `CLAUDE.md` in this directory for the bundle format, the edge design, and the current limits (Bedrock, CI wiring)
|
||||
|
||||
Tests marked `@pytest.mark.e2e` hard-fail when no proxy answers `/health/liveliness`, so a run that goes red with `No live proxy` at setup means the proxy isn't up; they never skip for a missing proxy, so an absent proxy can't be mistaken for a pass
|
||||
|
||||
|
|
|
|||
|
|
@ -81,6 +81,21 @@ File delete asserts `object=="file"` and `deleted==True`.
|
|||
| `capabilities.py` | the provider x scenario matrix + per-provider /model/new params + id-shape classifiers + per-provider raw-id assertion |
|
||||
| `conftest.py` | session-scoped batch deployment registration and teardown |
|
||||
| `test_batches_e2e.py` | parametrized lifecycle with per-endpoint output assertions, file upload/delete outputs, key-model-access denial, per-backend content download, failure paths, second-hop routing, terminal state + cost |
|
||||
| `test_managed_files_enforcement_e2e.py` | require_managed_files enforcement pins; deselected unless `E2E_MANAGED_FILES_STACK` is set (see below) |
|
||||
|
||||
## require_managed_files enforcement (separate stack phase)
|
||||
|
||||
`litellm_settings.require_managed_files` is a boot-time module global with no per-key
|
||||
or runtime override, and turning it on 400s every upload that lacks
|
||||
`target_model_names`, including the files_settings-routed `provider_fallback`
|
||||
scenario above. So its pins cannot share a proxy with the rest of this suite:
|
||||
`test_managed_files_enforcement_e2e.py` carries the `managed_files` marker, is
|
||||
deselected unless `E2E_MANAGED_FILES_STACK` is set (the same pattern as the `weekly`
|
||||
marker), and the PR gate runs it in a sequential phase after the main suite, against
|
||||
the same ephemeral stack redeployed with the flag on. The pins: upload without
|
||||
`target_model_names` is a 400, upload carrying a `model` param is a 400, a raw
|
||||
provider file id on retrieve is a 400, and another user's managed unified id is a
|
||||
403 while the owning user still retrieves it.
|
||||
|
||||
## Failure paths
|
||||
|
||||
|
|
|
|||
|
|
@ -12,12 +12,14 @@ the proxy config.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
from batch_client import BatchClient, build_client
|
||||
from capabilities import PROVIDERS
|
||||
from e2e_config import MANAGED_FILES_OPT_IN_ENV
|
||||
from e2e_http import NoBody
|
||||
from proxy_client import ProxyClient
|
||||
|
||||
|
|
@ -29,6 +31,22 @@ def pytest_configure(config: pytest.Config) -> None:
|
|||
)
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(
|
||||
config: pytest.Config, items: list[pytest.Item]
|
||||
) -> None:
|
||||
if os.environ.get(MANAGED_FILES_OPT_IN_ENV):
|
||||
return
|
||||
deselected = [
|
||||
item for item in items if item.get_closest_marker("managed_files") is not None
|
||||
]
|
||||
if not deselected:
|
||||
return
|
||||
config.hook.pytest_deselected(items=deselected)
|
||||
items[:] = [
|
||||
item for item in items if item.get_closest_marker("managed_files") is None
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def client(proxy: ProxyClient) -> BatchClient:
|
||||
return build_client(proxy)
|
||||
|
|
|
|||
118
tests/e2e/batches/test_managed_files_enforcement_e2e.py
Normal file
118
tests/e2e/batches/test_managed_files_enforcement_e2e.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""Live e2e pins for litellm_settings.require_managed_files enforcement.
|
||||
|
||||
require_managed_files is a boot-time module global, so these tests need a proxy
|
||||
whose config enables it. The main ephemeral stack can never run with it on: the
|
||||
flag would 400 every files_settings-routed upload in the rest of the suite. The
|
||||
PR gate instead reconfigures the same stack sequentially after the main run and
|
||||
executes only this file with E2E_MANAGED_FILES_STACK set; without that env every
|
||||
test here is deselected (see conftest.py, mirroring the weekly marker).
|
||||
|
||||
Pins: an upload without target_model_names is rejected 400, an upload that also
|
||||
carries a model param is rejected 400, a raw provider file id is rejected 400 on
|
||||
retrieve, and another user's managed unified file id is denied 403 while the
|
||||
owning user still retrieves it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
from batch_client import BatchClient, FileObject
|
||||
from capabilities import batch_model_name, is_managed_id, openai_batch_params
|
||||
from e2e_config import unique_marker
|
||||
from e2e_http import FileUploadForm, Result, UnknownApiError, unwrap
|
||||
from lifecycle import ResourceManager
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.managed_files]
|
||||
|
||||
UPLOAD_ROW = "llm.files.openai.require_managed_files_upload.nonstream.works"
|
||||
ISOLATION_ROW = "llm.files.openai.require_managed_files_isolation.nonstream.works"
|
||||
|
||||
|
||||
def batch_jsonl(model: str) -> bytes:
|
||||
line = {
|
||||
"custom_id": "req-1",
|
||||
"method": "POST",
|
||||
"url": "/v1/chat/completions",
|
||||
"body": {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": "ping"}],
|
||||
"max_tokens": 8,
|
||||
},
|
||||
}
|
||||
return (json.dumps(line) + "\n").encode()
|
||||
|
||||
|
||||
def expect_api_error(result: Result[FileObject], status: int, needle: str) -> None:
|
||||
match result:
|
||||
case UnknownApiError(status_code=code, body=body) if code == status:
|
||||
assert needle in body, f"expected {needle!r} in HTTP {status} body: {body[:300]}"
|
||||
case _:
|
||||
raise AssertionError(f"expected HTTP {status} containing {needle!r}, got: {result}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def managed_model(client: BatchClient) -> Iterator[str]:
|
||||
model_name = batch_model_name("managed-files-openai")
|
||||
model_id = client.create_model(model_name, openai_batch_params())
|
||||
yield model_name
|
||||
client.delete_model(model_id)
|
||||
|
||||
|
||||
@pytest.mark.covers(UPLOAD_ROW)
|
||||
def test_upload_without_target_model_names_rejected(
|
||||
client: BatchClient, scoped_key: str, managed_model: str
|
||||
) -> None:
|
||||
result = client.upload_file(
|
||||
content=batch_jsonl(managed_model),
|
||||
form=FileUploadForm(purpose="batch"),
|
||||
key=scoped_key,
|
||||
)
|
||||
expect_api_error(result, 400, "target_model_names is required")
|
||||
|
||||
|
||||
@pytest.mark.covers(UPLOAD_ROW)
|
||||
def test_upload_with_model_param_rejected(
|
||||
client: BatchClient, scoped_key: str, managed_model: str
|
||||
) -> None:
|
||||
result = client.upload_file(
|
||||
content=batch_jsonl(managed_model),
|
||||
form=FileUploadForm(purpose="batch", target_model_names=managed_model),
|
||||
model=managed_model,
|
||||
key=scoped_key,
|
||||
)
|
||||
expect_api_error(result, 400, "model is not allowed")
|
||||
|
||||
|
||||
@pytest.mark.covers(ISOLATION_ROW)
|
||||
def test_raw_provider_file_id_rejected(client: BatchClient, scoped_key: str) -> None:
|
||||
result = client.retrieve_file("file-e2e-raw-provider-id", key=scoped_key)
|
||||
expect_api_error(result, 400, "Raw provider file ids cannot be used")
|
||||
|
||||
|
||||
@pytest.mark.covers(ISOLATION_ROW)
|
||||
def test_cross_user_managed_id_denied_owner_allowed(
|
||||
client: BatchClient, resources: ResourceManager, managed_model: str
|
||||
) -> None:
|
||||
run = unique_marker()
|
||||
owner_key = resources.key(user_id=f"managed-files-owner-{run}")
|
||||
other_key = resources.key(user_id=f"managed-files-other-{run}")
|
||||
|
||||
uploaded = unwrap(
|
||||
client.upload_file(
|
||||
content=batch_jsonl(managed_model),
|
||||
form=FileUploadForm(purpose="batch", target_model_names=managed_model),
|
||||
key=owner_key,
|
||||
)
|
||||
)
|
||||
resources.defer(lambda: client.delete_file(uploaded.id, key=owner_key))
|
||||
assert is_managed_id(uploaded.id), f"expected a managed unified file id, got {uploaded.id}"
|
||||
|
||||
denied = client.retrieve_file(uploaded.id, key=other_key)
|
||||
expect_api_error(denied, 403, "does not have access to this managed file")
|
||||
|
||||
retrieved = unwrap(client.retrieve_file(uploaded.id, key=owner_key))
|
||||
assert retrieved.id == uploaded.id
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue