Merge pull request #41515 from BerriAI/litellm_transcribe_passthrough

feat(proxy): add Amazon Transcribe pass-through with completion-time job pricing
This commit is contained in:
Yassin Kortam 2026-09-18 11:35:06 -07:00 committed by GitHub
commit f129e7e2d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 2540 additions and 36 deletions

View file

@ -85,6 +85,7 @@ GATEWAY_PATH_PREFIXES: tuple[str, ...] = (
"/aws/",
"/bedrock/",
"/comprehendmedical",
"/transcribe",
"/cohere/",
"/gemini/",
"/gigachat/",

View file

@ -66,7 +66,7 @@
"/v1/video" "/v1/videos" "/video" "/videos" "/v1/search" "/search"
"/v1/containers" "/containers" "/v1/evals" "/v1/memory" "/queue/chat"
"/v1beta" "/interactions"
"/anthropic" "/azure" "/azure_ai" "/aws" "/bedrock" "/comprehendmedical" "/cohere" "/gemini" "/google"
"/anthropic" "/azure" "/azure_ai" "/aws" "/bedrock" "/comprehendmedical" "/transcribe" "/cohere" "/gemini" "/google"
"/vertex_ai" "/vertex-ai" "/assemblyai" "/eu.assemblyai" "/langfuse" "/vllm"
"/mistral" "/groq" "/voyage" "/cursor" "/milvus" "/openai_passthrough"
"/toolset"

View file

@ -1575,6 +1575,15 @@ PASS_THROUGH_HEADER_PREFIX: Final = "x-pass-"
BASE_MCP_ROUTE: Final = "/mcp"
TRANSCRIBE_JOB_POLLING_INTERVAL_SECONDS: Final = 10.0
TRANSCRIBE_JOB_MAX_POLLING_ATTEMPTS: Final = 720 # 2 hours
TRANSCRIBE_MAX_MEDIA_DURATION_SECONDS: Final = 28800 # Amazon Transcribe quota: maximum audio file length
TRANSCRIBE_MAX_MEDIA_BYTES: Final = 2 * 1024**3 # Amazon Transcribe quota: maximum audio file size
TRANSCRIBE_MEDIA_DOWNLOAD_CONCURRENCY: Final = 1
TRANSCRIBE_MEDIA_FETCH_ATTEMPTS: Final = 3
TRANSCRIBE_MEDIA_LAST_MODIFIED_TOLERANCE_SECONDS: Final = 1.0 # S3 Last-Modified carries whole seconds only
TRANSCRIBE_MEASURABLE_MEDIA_FORMATS: Final = frozenset({"flac", "mp3", "ogg", "wav"}) # what libsndfile can read
BATCH_STATUS_POLL_INTERVAL_SECONDS: Final = int(os.getenv("BATCH_STATUS_POLL_INTERVAL_SECONDS", 3600)) # 1 hour
BATCH_STATUS_POLL_MAX_ATTEMPTS: Final = int(os.getenv("BATCH_STATUS_POLL_MAX_ATTEMPTS", 24)) # for 24 hours
BATCH_TPD_WINDOW_SECONDS: Final = 86400

View file

@ -46324,6 +46324,16 @@
"/v1/audio/speech"
]
},
"transcribe/StartTranscriptionJob": {
"input_cost_per_second": 0.0001,
"litellm_provider": "transcribe",
"mode": "audio_transcription",
"output_cost_per_second": 0.0,
"source": "https://aws.amazon.com/transcribe/pricing/",
"metadata": {
"notes": "Amazon Transcribe standard batch transcription, billed per second of audio with no minimum. Same rate in every region of the AWS Price List offer file for transcribe (checked 2026-09-17)"
}
},
"aws_polly/standard": {
"input_cost_per_character": 4e-06,
"litellm_provider": "aws_polly",

View file

@ -208,6 +208,7 @@ LAZY_FEATURES: Final[tuple[LazyFeature, ...]] = (
"/nvidia_nim/",
"/openai/",
"/openai_passthrough/",
"/transcribe",
"/typesafe/",
"/vertex-ai/",
"/vertex_ai/",

View file

@ -20373,6 +20373,77 @@
]
}
},
"/transcribe": {
"post": {
"description": "AWS-SDK-shaped pass-through for Amazon Transcribe: point the SDK's `endpoint_url`\nat `/transcribe` and the operation is read from the `X-Amz-Target` header, per the\nAWS JSON 1.1 protocol.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/transcribe)",
"operationId": "transcribe_sdk_proxy_route_transcribe_post",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
},
"description": "Successful Response"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Transcribe Sdk Proxy Route",
"tags": [
"llm_passthrough"
]
}
},
"/transcribe/{operation}": {
"post": {
"description": "Pass-through for the Amazon Transcribe API, e.g. `POST /transcribe/StartTranscriptionJob`.\n\nThe request body is forwarded to the AWS JSON 1.1 API and signed with SigV4 using the\nproxy's AWS credentials. Standard jobs are tagged with the calling key's owner so that\nonly that owner (or a proxy admin) can read or delete them, and keys other than proxy\nadmins may only read media from and write transcripts to the S3 buckets listed in\n`general_settings.transcribe_media_buckets`; account-wide operations\nsuch as ListTranscriptionJobs are limited to proxy admins. Streaming transcription\n(`transcribestreaming`) uses a separate HTTP/2 event-stream protocol and is not served\nby this route.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/transcribe)",
"operationId": "transcribe_proxy_route_transcribe__operation__post",
"parameters": [
{
"in": "path",
"name": "operation",
"required": true,
"schema": {
"title": "Operation",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Transcribe Proxy Route",
"tags": [
"llm_passthrough"
]
}
},
"/typesafe/{endpoint}": {
"delete": {
"description": "[Docs](https://docs.litellm.ai/docs/pass_through/typesafe)",

View file

@ -469,6 +469,7 @@ class LiteLLMRoutes(enum.Enum):
mapped_pass_through_routes = [
"/bedrock",
"/comprehendmedical",
"/transcribe",
"/vertex-ai",
"/vertex_ai",
"/cohere",
@ -2785,6 +2786,10 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
default=None,
description="Serve the OpenAI pass-through WebSocket route, which relays frames to OpenAI under the proxy's own provider credential without reading them. Off by default.",
)
transcribe_media_buckets: list[str] | None = Field(
default=None,
description="S3 bucket names that keys other than proxy admins may read media from and write transcripts to through the Amazon Transcribe pass-through. Unset means only proxy admins can start transcription jobs.",
)
user_header_name: str | None = Field(
None,
description="[DEPRECATED] Use 'user_header_mappings' instead. When set, the header value is treated as the end user id unless overridden by user_header_mappings.",

View file

@ -93,6 +93,7 @@ _LLM_ROUTE_EXACT: Final[tuple[str, ...]] = (
"/interactions", # Google Interactions create; /{id} reads and /cancel do not match
"/v1beta/interactions",
"/comprehendmedical", # AWS-SDK-shaped passthrough: the operation rides in the X-Amz-Target header
"/transcribe",
)
# Provider passthrough prefixes (e.g. /bedrock/..., /vertex-ai/...) carry real

View file

@ -1235,7 +1235,13 @@ async def bedrock_proxy_route(
COMPREHEND_MEDICAL_TARGET_PREFIX: Final = "ComprehendMedical_20181030"
def _resolve_comprehend_medical_region() -> str | None:
def _proxy_general_settings() -> Mapping[str, object]:
from litellm.proxy.proxy_server import general_settings
return general_settings
def _resolve_aws_passthrough_region() -> str | None:
region_candidates: Final = (
get_secret_str(secret_name="AWS_REGION_NAME"),
get_secret_str(secret_name="AWS_REGION"),
@ -1275,7 +1281,7 @@ async def comprehend_medical_proxy_route(
),
)
aws_region_name: Final = _resolve_comprehend_medical_region()
aws_region_name: Final = _resolve_aws_passthrough_region()
if aws_region_name is None:
raise HTTPException(
status_code=400,
@ -1352,6 +1358,167 @@ async def comprehend_medical_sdk_proxy_route(
)
@router.post(
"/transcribe/{operation}",
tags=["Amazon Transcribe Pass-through", "pass-through"], # mutable-ok: fastapi route tags must be a list
)
async def transcribe_proxy_route(
operation: str,
request: Request,
fastapi_response: Response,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
general_settings: Annotated[Mapping[str, object], Depends(_proxy_general_settings)],
):
"""
Pass-through for the Amazon Transcribe API, e.g. `POST /transcribe/StartTranscriptionJob`.
The request body is forwarded to the AWS JSON 1.1 API and signed with SigV4 using the
proxy's AWS credentials. Standard jobs are tagged with the calling key's owner so that
only that owner (or a proxy admin) can read or delete them, and keys other than proxy
admins may only read media from and write transcripts to the S3 buckets listed in
`general_settings.transcribe_media_buckets`; account-wide operations
such as ListTranscriptionJobs are limited to proxy admins. Streaming transcription
(`transcribestreaming`) uses a separate HTTP/2 event-stream protocol and is not served
by this route.
[Docs](https://docs.litellm.ai/docs/pass_through/transcribe)
"""
from .llm_provider_handlers.transcribe_passthrough_logging_handler import (
TRANSCRIBE_CUSTOM_LLM_PROVIDER,
TRANSCRIBE_OWNED_JOB_OPERATIONS,
TRANSCRIBE_PRICED_OPERATION,
TRANSCRIBE_TARGET_PREFIX,
TranscribeRefusal,
transcribe_admin_only_refusal,
transcribe_cost_per_second,
transcribe_job_access_refusal,
transcribe_job_lookup,
transcribe_media_buckets,
transcribe_owned_start_request,
transcribe_storage_refusal,
transcribe_supported_operations,
transcribe_unpriceable_request_reason,
)
if operation not in transcribe_supported_operations():
raise HTTPException(
status_code=400,
detail=(
f"Unsupported Amazon Transcribe operation: {operation}. "
f"Supported operations: {', '.join(sorted(transcribe_supported_operations()))}"
),
)
aws_region_name: Final = _resolve_aws_passthrough_region()
if aws_region_name is None:
raise HTTPException(
status_code=400,
detail="AWS region not found. Set AWS_REGION_NAME in the proxy environment.",
)
try:
data: Final = await _json_request_body(request)
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Request body must be valid JSON: {e}")
if not isinstance(data, dict):
raise HTTPException(status_code=400, detail="Request body must be a JSON object")
if "stream" in data:
raise HTTPException(status_code=400, detail="'stream' is not an Amazon Transcribe request member")
unpriceable_reason: Final = transcribe_unpriceable_request_reason(operation, data, transcribe_cost_per_second())
if unpriceable_reason is not None:
raise HTTPException(status_code=400, detail=unpriceable_reason)
admin_only_refusal: Final = transcribe_admin_only_refusal(operation, user_api_key_dict)
if admin_only_refusal is not None:
raise HTTPException(status_code=admin_only_refusal.status_code, detail=admin_only_refusal.detail)
storage_refusal: Final = (
transcribe_storage_refusal(data, transcribe_media_buckets(general_settings), user_api_key_dict)
if operation == TRANSCRIBE_PRICED_OPERATION
else None
)
if storage_refusal is not None:
raise HTTPException(status_code=storage_refusal.status_code, detail=storage_refusal.detail)
request_body: Final = (
transcribe_owned_start_request(data, user_api_key_dict) if operation == TRANSCRIBE_PRICED_OPERATION else data
)
if isinstance(request_body, TranscribeRefusal):
raise HTTPException(status_code=request_body.status_code, detail=request_body.detail)
access_refusal: Final = (
await transcribe_job_access_refusal(
data.get("TranscriptionJobName"), user_api_key_dict, transcribe_job_lookup(aws_region_name)
)
if operation in TRANSCRIBE_OWNED_JOB_OPERATIONS
else None
)
if access_refusal is not None:
raise HTTPException(status_code=access_refusal.status_code, detail=access_refusal.detail)
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM, run_aws_signing, sign_aws_json_post
target_url: Final = f"https://transcribe.{aws_region_name}.{get_aws_dns_suffix(aws_region_name)}/"
prepped: Final = await run_aws_signing(
sign_aws_json_post,
get_credentials=partial(BaseAWSLLM().get_credentials, aws_region_name=aws_region_name),
service_name="transcribe",
aws_region_name=aws_region_name,
url=target_url,
body=json.dumps(request_body),
headers=MappingProxyType(
{
"Content-Type": "application/x-amz-json-1.1",
"X-Amz-Target": f"{TRANSCRIBE_TARGET_PREFIX}.{operation}",
}
),
)
endpoint_func: Final = create_pass_through_route(
endpoint=operation,
target=str(prepped.url),
custom_headers=prepped.headers,
custom_llm_provider=TRANSCRIBE_CUSTOM_LLM_PROVIDER,
)
setattr(request.state, LITELLM_PASS_THROUGH_CUSTOM_BODY_STATE_KEY, request_body)
setattr(request.state, LITELLM_PASS_THROUGH_RAW_BODY_STATE_KEY, prepped.body)
return await endpoint_func(request, fastapi_response, user_api_key_dict)
@router.post(
"/transcribe",
tags=["Amazon Transcribe Pass-through", "pass-through"], # mutable-ok: fastapi route tags must be a list
)
async def transcribe_sdk_proxy_route(
request: Request,
fastapi_response: Response,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
general_settings: Annotated[Mapping[str, object], Depends(_proxy_general_settings)],
):
"""
AWS-SDK-shaped pass-through for Amazon Transcribe: point the SDK's `endpoint_url`
at `/transcribe` and the operation is read from the `X-Amz-Target` header, per the
AWS JSON 1.1 protocol.
[Docs](https://docs.litellm.ai/docs/pass_through/transcribe)
"""
from .llm_provider_handlers.transcribe_passthrough_logging_handler import (
TRANSCRIBE_TARGET_PREFIX,
)
target_header: Final = request.headers.get("x-amz-target", "")
target_prefix, _, operation = target_header.partition(".")
if target_prefix != TRANSCRIBE_TARGET_PREFIX or not operation:
raise HTTPException(
status_code=400,
detail=f"Expected an X-Amz-Target header of the form {TRANSCRIBE_TARGET_PREFIX}.<Operation>",
)
return await transcribe_proxy_route(
operation=operation,
request=request,
fastapi_response=fastapi_response,
user_api_key_dict=user_api_key_dict,
general_settings=general_settings,
)
def _resolve_vertex_model_from_router(
model_id: str,
llm_router: litellm.Router | None,
@ -2623,12 +2790,6 @@ class _OpenAIWebsocketRelay(Protocol):
) -> None: ...
def _proxy_general_settings() -> Mapping[str, object]:
from litellm.proxy.proxy_server import general_settings
return general_settings
def _openai_websocket_relay() -> _OpenAIWebsocketRelay:
return websocket_passthrough_request

View file

@ -0,0 +1,733 @@
import asyncio
import json
import math
import tempfile
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from datetime import datetime
from email.utils import parsedate_to_datetime
from functools import lru_cache, partial
from pathlib import Path
from types import MappingProxyType
from typing import IO, Final, Protocol, TypeAlias
from urllib.parse import quote
import httpx
import soundfile
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
from typing_extensions import ReadOnly, TypedDict
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.constants import (
TRANSCRIBE_JOB_MAX_POLLING_ATTEMPTS,
TRANSCRIBE_JOB_POLLING_INTERVAL_SECONDS,
TRANSCRIBE_MAX_MEDIA_BYTES,
TRANSCRIBE_MAX_MEDIA_DURATION_SECONDS,
TRANSCRIBE_MEASURABLE_MEDIA_FORMATS,
TRANSCRIBE_MEDIA_DOWNLOAD_CONCURRENCY,
TRANSCRIBE_MEDIA_FETCH_ATTEMPTS,
TRANSCRIBE_MEDIA_LAST_MODIFIED_TOLERANCE_SECONDS,
)
from litellm.litellm_core_utils.aws_partition import get_aws_dns_suffix
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.litellm_logging import (
get_standard_logging_object_payload,
)
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.proxy._types import (
PassThroughEndpointLoggingResultValues,
PassThroughEndpointLoggingTypedDict,
UserAPIKeyAuth,
)
from litellm.proxy.common_utils.resource_ownership import (
get_primary_resource_owner_scope,
is_proxy_admin,
user_can_access_resource_owner,
)
from litellm.types.llms.custom_http import httpxSpecialProvider
from litellm.types.utils import StandardPassThroughResponseObject
TRANSCRIBE_TARGET_PREFIX: Final = "Transcribe"
TRANSCRIBE_CUSTOM_LLM_PROVIDER: Final = "transcribe"
TRANSCRIBE_PRICED_OPERATION: Final = "StartTranscriptionJob"
TRANSCRIBE_PRICED_MODEL: Final = f"{TRANSCRIBE_CUSTOM_LLM_PROVIDER}/{TRANSCRIBE_PRICED_OPERATION}"
TRANSCRIBE_UNPRICED_OPERATIONS: Final = frozenset(
{"StartCallAnalyticsJob", "StartMedicalScribeJob", "StartMedicalTranscriptionJob"}
)
TRANSCRIBE_SURCHARGE_MEMBERS: Final = ("ContentRedaction", "ToxicityDetection")
TRANSCRIBE_TERMINAL_JOB_STATUSES: Final = frozenset({"COMPLETED", "FAILED"})
TRANSCRIBE_MISSING_JOB_ERRORS: Final = frozenset({"BadRequestException", "NotFoundException"})
TRANSCRIBE_OWNER_TAG: Final = "litellm-owner"
TRANSCRIBE_OWNED_JOB_OPERATIONS: Final = frozenset({"GetTranscriptionJob", "DeleteTranscriptionJob"})
TRANSCRIBE_MEDIA_BUCKETS_SETTING: Final = "transcribe_media_buckets"
TRANSCRIBE_ROLE_MEMBERS: Final = ("DataAccessRoleArn", "JobExecutionSettings")
TRANSCRIBE_MEDIA_URI_MEMBERS: Final = ("MediaFileUri", "RedactedMediaFileUri")
JobLookup: TypeAlias = Callable[[str], Awaitable[Mapping[str, object]]] # mutable-ok: Callable parameter syntax
MediaDurationProbe: TypeAlias = Callable[[str, float], Awaitable[float | None]] # mutable-ok: Callable parameter syntax
class GetTranscriptionJobRequest(TypedDict):
TranscriptionJobName: ReadOnly[str]
class _MediaRef(BaseModel):
model_config = ConfigDict(frozen=True)
MediaFileUri: str | None = None
class _JobTag(BaseModel):
model_config = ConfigDict(frozen=True)
Key: str | None = None
Value: str | None = None
class TranscriptionJobRecord(BaseModel):
model_config = ConfigDict(frozen=True)
TranscriptionJobStatus: str | None = None
CreationTime: float | None = None
Media: _MediaRef | None = None
Tags: tuple[_JobTag, ...] = ()
class _TranscriptionJobResponse(BaseModel):
model_config = ConfigDict(frozen=True)
TranscriptionJob: TranscriptionJobRecord | None = None
@dataclass(frozen=True, slots=True)
class MissingJob:
"""Transcribe no longer knows the job, so polling it again can never reach a terminal status."""
StartedJob: TypeAlias = TranscriptionJobRecord | None
JobPricer: TypeAlias = Callable[[str, str, float, StartedJob], Awaitable[float]] # mutable-ok: Callable params
class _PricedCostMapEntry(BaseModel):
model_config = ConfigDict(frozen=True, strict=True)
input_cost_per_second: float
_JSON_OBJECT: Final = TypeAdapter(Mapping[str, object])
_JSON_OBJECTS: Final = TypeAdapter(tuple[Mapping[str, object], ...])
_BUCKET_NAMES: Final = TypeAdapter(frozenset[str])
@dataclass(frozen=True, slots=True)
class TranscribeRefusal:
status_code: int
detail: str
class PassThroughLogDispatch(Protocol):
def __call__(
self,
*,
logging_obj: LiteLLMLoggingObj,
standard_logging_response_object: PassThroughEndpointLoggingResultValues | None,
result: str,
start_time: datetime,
end_time: datetime,
cache_hit: bool,
**kwargs: object, # kwargs-ok: mirrors the shared pass-through logging dispatch signature
) -> Awaitable[None]: ...
@lru_cache(maxsize=1)
def transcribe_supported_operations() -> frozenset[str]:
"""
Operation names of the Amazon Transcribe JSON 1.1 API, read from the botocore
service model so the allowlist tracks the installed SDK instead of a hand-typed copy.
"""
from botocore.session import get_session
return frozenset(get_session().get_service_model("transcribe").operation_names)
def transcribe_cost_per_second() -> float | None:
try:
return _PricedCostMapEntry.model_validate(litellm.model_cost.get(TRANSCRIBE_PRICED_MODEL)).input_cost_per_second
except ValidationError:
return None
def transcribe_unpriceable_request_reason(
operation: str,
request_body: Mapping[str, object],
cost_per_second: float | None,
) -> str | None:
if operation in TRANSCRIBE_UNPRICED_OPERATIONS:
return (
f"{operation} is billed per second of audio at a rate LiteLLM does not price yet, so it cannot be"
f" submitted through this route; only {TRANSCRIBE_PRICED_OPERATION} is priced and budgeted"
)
if operation != TRANSCRIBE_PRICED_OPERATION:
return None
if cost_per_second is None:
return (
f"{TRANSCRIBE_PRICED_MODEL} has no input_cost_per_second in the LiteLLM model cost map, so billable"
" transcription jobs cannot be submitted through this route"
)
surcharges: Final = tuple(m for m in TRANSCRIBE_SURCHARGE_MEMBERS if m in request_body) + tuple(
_custom_language_model_members(request_body)
)
if surcharges:
return (
f"{TRANSCRIBE_PRICED_OPERATION} with {', '.join(surcharges)} adds a per-second surcharge LiteLLM does not"
" price yet; remove it to submit the job through this route"
)
if requested_media_format(request_body) not in TRANSCRIBE_MEASURABLE_MEDIA_FORMATS:
return (
"LiteLLM bills a transcription job by reading the length of the media file, which it can only do for"
f" {', '.join(sorted(TRANSCRIBE_MEASURABLE_MEDIA_FORMATS))}; set MediaFormat to one of those or point"
" Media.MediaFileUri at a file with that extension"
)
return None
def _custom_language_model_members(request_body: Mapping[str, object]) -> tuple[str, ...]:
model_settings: Final = request_body.get("ModelSettings")
language_id_settings: Final = request_body.get("LanguageIdSettings")
from_model_settings: Final = (
("ModelSettings.LanguageModelName",)
if isinstance(model_settings, Mapping) and "LanguageModelName" in model_settings
else ()
)
from_language_id: Final = (
tuple(
f"LanguageIdSettings.{language}.LanguageModelName"
for language, settings in _JSON_OBJECT.validate_python(language_id_settings).items()
if isinstance(settings, Mapping) and "LanguageModelName" in settings
)
if isinstance(language_id_settings, Mapping)
else ()
)
return from_model_settings + from_language_id
def requested_media_format(request_body: Mapping[str, object]) -> str | None:
media_format: Final = request_body.get("MediaFormat")
if isinstance(media_format, str):
return media_format.lower()
media: Final = request_body.get("Media")
media_uri: Final = _JSON_OBJECT.validate_python(media).get("MediaFileUri") if isinstance(media, Mapping) else None
if not isinstance(media_uri, str):
return None
path: Final = httpx.URL(media_uri).path if "://" in media_uri else media_uri
_, dot, suffix = path.rpartition(".")
return suffix.lower() if dot else None
def transcribe_admin_only_refusal(operation: str, user_api_key_dict: UserAPIKeyAuth) -> TranscribeRefusal | None:
if (
operation == TRANSCRIBE_PRICED_OPERATION
or operation in TRANSCRIBE_OWNED_JOB_OPERATIONS
or is_proxy_admin(user_api_key_dict)
):
return None
return TranscribeRefusal(
403,
f"{operation} reaches every Amazon Transcribe resource in the AWS account, so only a proxy admin may call it;"
f" other keys may {TRANSCRIBE_PRICED_OPERATION} and {' or '.join(sorted(TRANSCRIBE_OWNED_JOB_OPERATIONS))}"
" for the jobs they started",
)
def transcribe_media_buckets(general_settings: Mapping[str, object]) -> frozenset[str] | None:
try:
return _BUCKET_NAMES.validate_python(general_settings.get(TRANSCRIBE_MEDIA_BUCKETS_SETTING))
except ValidationError:
return None
def s3_bucket_name(uri: object) -> str | None:
if not isinstance(uri, str) or not uri.startswith("s3://"):
return None
bucket, _, _ = uri.removeprefix("s3://").partition("/")
return bucket or None
def transcribe_storage_refusal(
request_body: Mapping[str, object],
allowed_buckets: frozenset[str] | None,
user_api_key_dict: UserAPIKeyAuth,
) -> TranscribeRefusal | None:
"""
Transcribe reads the media and writes the transcript with the proxy's own AWS credentials, so a
non-admin key may only point a job at buckets the operator listed; otherwise any object those
credentials can reach could be transcribed and read back through the caller's own job.
"""
if is_proxy_admin(user_api_key_dict):
return None
if allowed_buckets is None:
return TranscribeRefusal(
403,
f"general_settings.{TRANSCRIBE_MEDIA_BUCKETS_SETTING} is not a list of S3 bucket names, so only a proxy"
f" admin may {TRANSCRIBE_PRICED_OPERATION}; list the buckets other keys may read media from and write"
" transcripts to",
)
roles: Final = tuple(m for m in TRANSCRIBE_ROLE_MEMBERS if m in request_body)
if roles:
return TranscribeRefusal(
403,
f"{', '.join(roles)} would run the job under a role other than the proxy's own AWS credentials, so"
" only a proxy admin may set it",
)
media: Final = request_body.get("Media")
media_uris: Final = (
tuple((f"Media.{m}", s3_bucket_name(media.get(m))) for m in TRANSCRIBE_MEDIA_URI_MEMBERS if m in media)
if isinstance(media, Mapping)
else ()
)
output: Final = request_body.get("OutputBucketName")
locations: Final = media_uris + (
(("OutputBucketName", output if isinstance(output, str) else None),)
if "OutputBucketName" in request_body
else ()
)
offending: Final = tuple(member for member, bucket in locations if bucket not in allowed_buckets)
if offending:
return TranscribeRefusal(
403,
f"{', '.join(offending)} must name one of the S3 buckets in general_settings."
f"{TRANSCRIBE_MEDIA_BUCKETS_SETTING} ({', '.join(sorted(allowed_buckets))}), as s3://bucket/key for media",
)
return None
def transcribe_owned_start_request(
request_body: Mapping[str, object], user_api_key_dict: UserAPIKeyAuth
) -> dict[str, object] | TranscribeRefusal:
owner: Final = get_primary_resource_owner_scope(user_api_key_dict)
if owner is None:
return TranscribeRefusal(400, "The calling key has no identity to record as the owner of the transcription job")
try:
tags: Final = _JSON_OBJECTS.validate_python(request_body.get("Tags", ()))
except ValidationError:
return TranscribeRefusal(400, "Tags must be a list of objects with Key and Value members")
if any(tag.get("Key") == TRANSCRIBE_OWNER_TAG for tag in tags):
return TranscribeRefusal(
400, f"The {TRANSCRIBE_OWNER_TAG} tag is assigned by LiteLLM and cannot be supplied by the caller"
)
owner_tag: Final = _JobTag(Key=TRANSCRIBE_OWNER_TAG, Value=owner).model_dump()
return {**request_body, "Tags": (*tags, owner_tag)} # mutable-ok: json.dumps and the body state key take a dict
async def transcribe_job_access_refusal(
job_name: object, user_api_key_dict: UserAPIKeyAuth, get_job: JobLookup
) -> TranscribeRefusal | None:
if is_proxy_admin(user_api_key_dict):
return None
if not isinstance(job_name, str):
return TranscribeRefusal(400, "TranscriptionJobName must be a string")
not_found: Final = TranscribeRefusal(
404, f"No transcription job named {job_name} was started through this proxy by the calling key"
)
try:
job: Final = _TranscriptionJobResponse.model_validate(await get_job(job_name)).TranscriptionJob
except Exception as e: # noqa: BLE001 # a job that cannot be read cannot be shown to belong to the caller
verbose_proxy_logger.warning("Looking up Transcribe job %s for an ownership check failed: %s", job_name, e)
return not_found
owner: Final = (
next((tag.Value for tag in job.Tags if tag.Key == TRANSCRIBE_OWNER_TAG), None) if job is not None else None
)
return None if user_can_access_resource_owner(owner, user_api_key_dict) else not_found
def transcription_job_cost(audio_seconds: float, cost_per_second: float) -> float:
return math.ceil(audio_seconds) * cost_per_second
def transcribe_max_job_cost(cost_per_second: float) -> float:
return transcription_job_cost(TRANSCRIBE_MAX_MEDIA_DURATION_SECONDS, cost_per_second)
def started_transcription_job(response_body: Mapping[str, object] | None) -> TranscriptionJobRecord | None:
try:
return _TranscriptionJobResponse.model_validate(response_body).TranscriptionJob
except ValidationError:
return None
def aws_error_type(response: httpx.Response) -> str | None:
try:
error_type: Final = _JSON_OBJECT.validate_python(response.json()).get("__type")
except (ValueError, ValidationError):
return None
return error_type.rsplit("#", 1)[-1] if isinstance(error_type, str) else None
async def _poll_transcription_job(job_name: str, get_job: JobLookup) -> TranscriptionJobRecord | MissingJob | None:
try:
job: Final = _TranscriptionJobResponse.model_validate(await get_job(job_name)).TranscriptionJob
except httpx.HTTPStatusError as e:
if aws_error_type(e.response) in TRANSCRIBE_MISSING_JOB_ERRORS:
verbose_proxy_logger.warning(
"Transcribe job %s no longer exists, pricing the media it was started with", job_name
)
return MissingJob()
verbose_proxy_logger.warning("Polling Transcribe job %s failed, retrying: %s", job_name, e)
return None
except Exception as e: # noqa: BLE001 # a failed poll is retried on the next tick instead of ending pricing
verbose_proxy_logger.warning("Polling Transcribe job %s failed, retrying: %s", job_name, e)
return None
return job if job is not None and job.TranscriptionJobStatus in TRANSCRIBE_TERMINAL_JOB_STATUSES else None
async def await_transcription_job(
job_name: str,
get_job: JobLookup,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
max_attempts: int = TRANSCRIBE_JOB_MAX_POLLING_ATTEMPTS,
) -> TranscriptionJobRecord | MissingJob | None:
for _ in range(max_attempts):
job = await _poll_transcription_job(job_name, get_job)
if job is not None:
return job
await sleep(TRANSCRIBE_JOB_POLLING_INTERVAL_SECONDS)
return None
async def measure_media_seconds(
media_uri: str,
job_created_at: float,
media_seconds: MediaDurationProbe,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
attempts: int = TRANSCRIBE_MEDIA_FETCH_ATTEMPTS,
) -> float | None:
for attempt in range(1, attempts + 1):
try:
return await media_seconds(media_uri, job_created_at)
except Exception as e: # noqa: BLE001 # the media is retried, then charged at the maximum if still unreadable
verbose_proxy_logger.warning("Measuring Transcribe media %s failed (attempt %d): %s", media_uri, attempt, e)
if attempt < attempts:
await sleep(TRANSCRIBE_JOB_POLLING_INTERVAL_SECONDS)
return None
async def price_transcription_job(
job_name: str,
cost_per_second: float,
get_job: JobLookup,
media_seconds: MediaDurationProbe,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
max_attempts: int = TRANSCRIBE_JOB_MAX_POLLING_ATTEMPTS,
started_job: TranscriptionJobRecord | None = None,
) -> float:
"""
Amazon Transcribe bills every second of the media file, silence included, and reports no
duration itself, so the job is polled to completion and the media it transcribed is measured.
The measurement only counts when the object has not been rewritten since the job was created,
which is what ties it to the bytes Transcribe read. A job deleted before it is polled is
measured from the media named in its StartTranscriptionJob response. Anything that stops the
duration from being read is charged as the longest media AWS accepts.
"""
outcome: Final = await await_transcription_job(job_name, get_job, sleep=sleep, max_attempts=max_attempts)
if outcome is None:
verbose_proxy_logger.warning("Transcribe job %s did not finish while polling, charging maximum", job_name)
return transcribe_max_job_cost(cost_per_second)
if isinstance(outcome, TranscriptionJobRecord) and outcome.TranscriptionJobStatus == "FAILED":
return 0.0
job: Final = outcome if isinstance(outcome, TranscriptionJobRecord) else started_job
media_uri: Final = job.Media.MediaFileUri if job is not None and job.Media is not None else None
if job is None or media_uri is None or job.CreationTime is None:
return transcribe_max_job_cost(cost_per_second)
audio_seconds: Final = await measure_media_seconds(media_uri, job.CreationTime, media_seconds, sleep=sleep)
if audio_seconds is None:
return transcribe_max_job_cost(cost_per_second)
return transcription_job_cost(audio_seconds, cost_per_second)
def _as_json_object(response: httpx.Response) -> Mapping[str, object]:
return _JSON_OBJECT.validate_python(response.raise_for_status().json())
def transcribe_job_lookup(aws_region_name: str) -> JobLookup:
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM, run_aws_signing, sign_aws_json_post
url: Final = f"https://transcribe.{aws_region_name}.{get_aws_dns_suffix(aws_region_name)}/"
headers: Final = MappingProxyType(
{
"Content-Type": "application/x-amz-json-1.1",
"X-Amz-Target": f"{TRANSCRIBE_TARGET_PREFIX}.GetTranscriptionJob",
}
)
async def get_job(job_name: str) -> Mapping[str, object]:
body: Final[GetTranscriptionJobRequest] = {"TranscriptionJobName": job_name}
payload: Final = json.dumps(body)
prepped: Final = await run_aws_signing(
sign_aws_json_post,
get_credentials=partial(BaseAWSLLM().get_credentials, aws_region_name=aws_region_name),
service_name="transcribe",
aws_region_name=aws_region_name,
url=url,
body=payload,
headers=headers,
)
client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.PassThroughEndpoint)
signed_headers: Final = dict(prepped.headers.items()) # mutable-ok: AsyncHTTPHandler.post takes a dict
return _as_json_object(await client.post(str(prepped.url), data=payload, headers=signed_headers))
return get_job
def s3_media_url(media_uri: str, aws_region_name: str) -> str | None:
"""
Transcribe accepts media as s3://bucket/key or as an https S3 URL; the bucket is required to
live in the job's region, so the s3 form maps onto that region's endpoint. Buckets with dots in
their name use the path-style form because they cannot match the virtual-hosted wildcard
certificate. The proxy's AWS signature is only ever sent to that partition's own hosts.
"""
dns_suffix: Final = get_aws_dns_suffix(aws_region_name)
if not media_uri.startswith("s3://"):
url: Final = httpx.URL(media_uri)
return media_uri if url.scheme == "https" and url.host.endswith(f".{dns_suffix}") else None
bucket, _, key = media_uri.removeprefix("s3://").partition("/")
if "." in bucket:
return f"https://s3.{aws_region_name}.{dns_suffix}/{bucket}/{quote(key)}"
return f"https://{bucket}.s3.{aws_region_name}.{dns_suffix}/{quote(key)}"
def media_predates_job(headers: Mapping[str, str], job_created_at: float) -> bool:
try:
modified_at: Final = parsedate_to_datetime(headers["last-modified"]).timestamp()
except (KeyError, TypeError, ValueError):
return False
return modified_at <= job_created_at + TRANSCRIBE_MEDIA_LAST_MODIFIED_TOLERANCE_SECONDS
async def write_media_within_limit(response: httpx.Response, media_file: IO[bytes], max_bytes: int) -> bool:
if int(response.headers.get("content-length", "0")) > max_bytes:
return False
async for chunk in response.aiter_bytes():
_ = media_file.write(chunk)
if media_file.tell() > max_bytes:
return False
return True
def media_file_seconds(path: Path) -> float | None:
try:
with soundfile.SoundFile(str(path)) as audio:
return len(audio) / audio.samplerate
except (RuntimeError, ValueError, OSError) as e:
verbose_proxy_logger.warning("Transcribe media could not be decoded for its duration: %s", e)
return None
def transcribe_media_duration_probe(aws_region_name: str, download_slots: asyncio.Semaphore) -> MediaDurationProbe:
from botocore.auth import S3SigV4Auth
from botocore.awsrequest import AWSRequest
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM, run_aws_signing
def sign_s3_get(url: str) -> dict[str, str]: # mutable-ok: httpx request headers take a dict
aws_request: Final = AWSRequest(method="GET", url=url)
credentials: Final = BaseAWSLLM().get_credentials(aws_region_name=aws_region_name)
S3SigV4Auth(credentials, "s3", aws_region_name).add_auth(aws_request)
return dict(aws_request.prepare().headers.items()) # mutable-ok: httpx request headers take a dict
async def media_seconds(media_uri: str, job_created_at: float) -> float | None:
url: Final = s3_media_url(media_uri, aws_region_name)
if url is None:
return None
headers: Final = await run_aws_signing(sign_s3_get, url)
client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.PassThroughEndpoint).client
async with download_slots:
with tempfile.NamedTemporaryFile() as media_file:
async with client.stream("GET", url, headers=headers) as response:
_ = response.raise_for_status()
if not media_predates_job(response.headers, job_created_at):
verbose_proxy_logger.warning(
"Transcribe media %s was rewritten after the job was created, charging maximum", media_uri
)
return None
if not await write_media_within_limit(response, media_file, TRANSCRIBE_MAX_MEDIA_BYTES):
verbose_proxy_logger.warning(
"Transcribe media %s exceeds the size cap, charging maximum", media_uri
)
return None
media_file.flush()
return await asyncio.to_thread(media_file_seconds, Path(media_file.name))
return media_seconds
async def price_transcription_job_live(
job_name: str,
aws_region_name: str,
cost_per_second: float,
started_job: TranscriptionJobRecord | None,
download_slots: asyncio.Semaphore,
) -> float:
try:
return await price_transcription_job(
job_name,
cost_per_second,
get_job=transcribe_job_lookup(aws_region_name),
media_seconds=transcribe_media_duration_probe(aws_region_name, download_slots),
started_job=started_job,
)
except Exception as e: # noqa: BLE001 # an unreadable job must still be charged, so fail closed at the maximum
verbose_proxy_logger.exception("Pricing Transcribe job %s failed, charging maximum: %s", job_name, e)
return transcribe_max_job_cost(cost_per_second)
class TranscribePassthroughLoggingHandler:
def __init__(self, job_pricer: JobPricer | None = None) -> None:
self._job_pricer: Final = (
job_pricer
if job_pricer is not None
else partial(
price_transcription_job_live,
download_slots=asyncio.Semaphore(TRANSCRIBE_MEDIA_DOWNLOAD_CONCURRENCY),
)
)
self._pricing_tasks: Final[set[asyncio.Task[None]]] = set() # mutable-ok: asyncio holds tasks weakly
@staticmethod
def _operation_from_response(httpx_response: httpx.Response) -> str:
headers: Final[Mapping[str, str]] = httpx_response.request.headers
target: Final = headers.get("x-amz-target", "")
return target.split(".")[-1]
@staticmethod
def is_priced_job_start(httpx_response: httpx.Response) -> bool:
return (
TranscribePassthroughLoggingHandler._operation_from_response(httpx_response) == TRANSCRIBE_PRICED_OPERATION
)
def schedule_priced_job_logging(
self,
httpx_response: httpx.Response,
response_body: Mapping[str, object] | None,
logging_obj: LiteLLMLoggingObj,
url_route: str,
result: str,
start_time: datetime,
end_time: datetime,
cache_hit: bool,
request_body: Mapping[str, object],
log: PassThroughLogDispatch,
**kwargs: object, # kwargs-ok: the passthrough logging dispatch forwards shared logging kwargs to every handler
) -> asyncio.Task[None]:
task: Final = asyncio.create_task(
self._price_then_log(
httpx_response=httpx_response,
started_job=started_transcription_job(response_body),
logging_obj=logging_obj,
url_route=url_route,
result=result,
start_time=start_time,
end_time=end_time,
cache_hit=cache_hit,
request_body=request_body,
log=log,
**kwargs,
)
)
self._pricing_tasks.add(task)
task.add_done_callback(self._pricing_tasks.discard)
return task
async def _price_then_log(
self,
httpx_response: httpx.Response,
started_job: TranscriptionJobRecord | None,
logging_obj: LiteLLMLoggingObj,
url_route: str,
result: str,
start_time: datetime,
end_time: datetime,
cache_hit: bool,
request_body: Mapping[str, object],
log: PassThroughLogDispatch,
**kwargs: object, # kwargs-ok: the passthrough logging dispatch forwards shared logging kwargs to every handler
) -> None:
cost_per_second: Final = transcribe_cost_per_second()
if cost_per_second is None:
verbose_proxy_logger.error("%s left the model cost map, spend not recorded", TRANSCRIBE_PRICED_MODEL)
return
job_name: Final = request_body.get("TranscriptionJobName")
aws_region_name: Final = httpx_response.request.url.host.split(".")[1]
response_cost: Final = await self._job_pricer(
job_name if isinstance(job_name, str) else "",
aws_region_name,
cost_per_second,
started_job,
)
payload: Final = self.transcribe_passthrough_handler(
httpx_response=httpx_response,
logging_obj=logging_obj,
url_route=url_route,
result=result,
start_time=start_time,
end_time=end_time,
cache_hit=cache_hit,
request_body=request_body,
response_cost=response_cost,
**kwargs,
)
await log(
logging_obj=logging_obj,
standard_logging_response_object=payload["result"],
result=result,
start_time=start_time,
end_time=end_time,
cache_hit=cache_hit,
**payload["kwargs"],
)
@staticmethod
def transcribe_passthrough_handler(
httpx_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
url_route: str,
result: str,
start_time: datetime,
end_time: datetime,
cache_hit: bool,
request_body: Mapping[str, object],
response_cost: float = 0.0,
**kwargs: object, # kwargs-ok: the passthrough logging dispatch forwards shared logging kwargs to every handler
) -> PassThroughEndpointLoggingTypedDict:
try:
operation: Final = TranscribePassthroughLoggingHandler._operation_from_response(httpx_response)
model_name: Final = f"{TRANSCRIBE_CUSTOM_LLM_PROVIDER}/{operation}"
updated_kwargs: Final = { # mutable-ok: the logging pipeline requires a plain kwargs dict
**kwargs,
"model": model_name,
"custom_llm_provider": TRANSCRIBE_CUSTOM_LLM_PROVIDER,
"response_cost": response_cost,
}
logging_obj.model_call_details.update(
model=model_name,
custom_llm_provider=TRANSCRIBE_CUSTOM_LLM_PROVIDER,
response_cost=response_cost,
)
standard_logging_object: Final = get_standard_logging_object_payload(
kwargs=updated_kwargs,
init_response_obj=StandardPassThroughResponseObject(response=result),
start_time=start_time,
end_time=end_time,
logging_obj=logging_obj,
status="success",
)
handler_payload: Final[PassThroughEndpointLoggingTypedDict] = {
"result": StandardPassThroughResponseObject(response=result),
"kwargs": {**updated_kwargs, "standard_logging_object": standard_logging_object},
}
except Exception as e: # noqa: BLE001 # logging must never fail the forwarded request
verbose_proxy_logger.exception("Error in Amazon Transcribe passthrough logging handler: %s", e)
fallback_payload: Final[PassThroughEndpointLoggingTypedDict] = {
"result": StandardPassThroughResponseObject(response=result),
"kwargs": kwargs,
}
return fallback_payload
return handler_payload

View file

@ -2669,17 +2669,22 @@ def _should_buffer_passthrough_response(response: httpx.Response) -> bool:
"""
Decide from the response headers whether the body must be read into memory.
JSON bodies (and upstream errors) stay buffered: spend logging, guardrails and
managed-id rewriting inspect them, and they are small in practice. Everything
else (jsonl batch results, octet-stream files, ...) is relayed to the client
chunk by chunk so a large body is never resident in full (LIT-4009). A missing
content-type is buffered because the body cannot be classified.
JSON bodies (including the AWS JSON protocol media types) and upstream errors
stay buffered: spend logging, guardrails and managed-id rewriting inspect them,
and they are small in practice. Everything else (jsonl batch results,
octet-stream files, ...) is relayed to the client chunk by chunk so a large
body is never resident in full (LIT-4009). A missing content-type is buffered
because the body cannot be classified.
"""
if response.status_code >= 400:
return True
content_type_header: Final[str] = response.headers.get("content-type", "")
media_type: Final = content_type_header.split(";")[0].strip().lower()
return media_type in ("", "application/json") or media_type.endswith("+json")
return (
media_type in ("", "application/json")
or media_type.endswith("+json")
or media_type.startswith("application/x-amz-json")
)
async def _relay_passthrough_response_bytes(

View file

@ -28,6 +28,11 @@ from .llm_provider_handlers.cursor_passthrough_logging_handler import (
from .llm_provider_handlers.gemini_passthrough_logging_handler import (
GeminiPassthroughLoggingHandler,
)
from .llm_provider_handlers.transcribe_passthrough_logging_handler import (
TRANSCRIBE_CUSTOM_LLM_PROVIDER,
PassThroughLogDispatch,
TranscribePassthroughLoggingHandler,
)
from .llm_provider_handlers.vertex_passthrough_logging_handler import (
VertexPassthroughLoggingHandler,
)
@ -49,7 +54,15 @@ def _safe_response_text(httpx_response: httpx.Response) -> str:
class PassThroughEndpointLogging:
def __init__(self):
def __init__(
self,
transcribe_handler: TranscribePassthroughLoggingHandler | None = None,
log_dispatch: PassThroughLogDispatch | None = None,
):
self.transcribe_passthrough_logging_handler: Final = (
transcribe_handler if transcribe_handler is not None else TranscribePassthroughLoggingHandler()
)
self._injected_log_dispatch: Final = log_dispatch
self.TRACKED_VERTEX_METHOD_ROUTES = (
"generateContent",
"streamGenerateContent",
@ -91,6 +104,10 @@ class PassThroughEndpointLogging:
# Vertex AI Live API WebSocket
self.TRACKED_VERTEX_AI_LIVE_ROUTES = ["/vertex_ai/live"]
@property
def _log_dispatch(self) -> PassThroughLogDispatch:
return self._injected_log_dispatch if self._injected_log_dispatch is not None else self._handle_logging
async def _handle_logging(
self,
logging_obj: LiteLLMLoggingObj,
@ -257,6 +274,20 @@ class PassThroughEndpointLogging:
)
standard_logging_response_object = comprehend_medical_handler_result["result"] # rebind-ok: elif-chain
kwargs = comprehend_medical_handler_result["kwargs"] # rebind-ok: elif-chain contract
elif self.is_transcribe_route(custom_llm_provider):
transcribe_handler_result: Final = TranscribePassthroughLoggingHandler.transcribe_passthrough_handler(
httpx_response=httpx_response,
logging_obj=logging_obj,
url_route=url_route,
result=result,
start_time=start_time,
end_time=end_time,
cache_hit=cache_hit,
request_body=request_body,
**kwargs,
)
standard_logging_response_object = transcribe_handler_result["result"] # rebind-ok: elif-chain
kwargs = transcribe_handler_result["kwargs"] # rebind-ok: elif-chain contract
elif self.is_typesafe_route(custom_llm_provider):
from .llm_provider_handlers.typesafe_passthrough_logging_handler import (
TypeSafePassthroughLoggingHandler,
@ -338,6 +369,24 @@ class PassThroughEndpointLogging:
elif self.is_langfuse_route(url_route):
# Don't log langfuse pass-through requests
return
elif self.is_transcribe_route(custom_llm_provider) and TranscribePassthroughLoggingHandler.is_priced_job_start(
httpx_response
):
self.transcribe_passthrough_logging_handler.schedule_priced_job_logging(
httpx_response=httpx_response,
response_body=response_body if isinstance(response_body, dict) else None,
logging_obj=logging_obj,
url_route=url_route,
result=result,
start_time=start_time,
end_time=end_time,
cache_hit=cache_hit,
request_body=request_body,
log=self._log_dispatch,
standard_pass_through_logging_payload=passthrough_logging_payload,
**kwargs,
)
return
else:
normalized_llm_passthrough_logging_payload: Final = self.normalize_llm_passthrough_logging_payload(
httpx_response=httpx_response,
@ -367,7 +416,7 @@ class PassThroughEndpointLogging:
kwargs=kwargs,
)
await self._handle_logging(
await self._log_dispatch(
logging_obj=logging_obj,
standard_logging_response_object=standard_logging_response_object,
result=result,
@ -409,6 +458,9 @@ class PassThroughEndpointLogging:
def is_comprehend_medical_route(self, custom_llm_provider: str | None) -> bool:
return custom_llm_provider == "comprehendmedical"
def is_transcribe_route(self, custom_llm_provider: str | None) -> bool:
return custom_llm_provider == TRANSCRIBE_CUSTOM_LLM_PROVIDER
def is_typesafe_route(self, custom_llm_provider: str | None) -> bool:
return custom_llm_provider == "typesafe"

View file

@ -17128,6 +17128,7 @@ _GENERAL_SETTINGS_CONFIG_LIST_FIELD_TYPES: Final[Mapping[str, str]] = MappingPro
"disable_auto_add_proxy_admin_to_teams": "Boolean",
"apply_user_budget_to_team_keys": "Boolean",
"user_api_key_cache_max_size": "Integer",
"transcribe_media_buckets": "List",
}
)

View file

@ -46324,6 +46324,16 @@
"/v1/audio/speech"
]
},
"transcribe/StartTranscriptionJob": {
"input_cost_per_second": 0.0001,
"litellm_provider": "transcribe",
"mode": "audio_transcription",
"output_cost_per_second": 0.0,
"source": "https://aws.amazon.com/transcribe/pricing/",
"metadata": {
"notes": "Amazon Transcribe standard batch transcription, billed per second of audio with no minimum. Same rate in every region of the AWS Price List offer file for transcribe (checked 2026-09-17)"
}
},
"aws_polly/standard": {
"input_cost_per_character": 4e-06,
"litellm_provider": "aws_polly",

View file

@ -86,7 +86,7 @@ locals {
"/queue/chat/*",
"/v1beta/*",
"/interactions/*",
"/anthropic/*", "/azure/*", "/azure_ai/*", "/aws/*", "/bedrock/*", "/comprehendmedical*",
"/anthropic/*", "/azure/*", "/azure_ai/*", "/aws/*", "/bedrock/*", "/comprehendmedical*", "/transcribe*",
"/cohere/*", "/gemini/*", "/google/*",
"/vertex_ai/*", "/vertex-ai/*",
"/assemblyai/*", "/eu.assemblyai/*",

View file

@ -55,7 +55,7 @@ locals {
"/queue/chat/*",
"/v1beta/*",
"/interactions/*",
"/anthropic/*", "/azure/*", "/azure_ai/*", "/aws/*", "/bedrock/*", "/comprehendmedical*",
"/anthropic/*", "/azure/*", "/azure_ai/*", "/aws/*", "/bedrock/*", "/comprehendmedical*", "/transcribe*",
"/cohere/*", "/gemini/*", "/google/*",
"/vertex_ai/*", "/vertex-ai/*",
"/assemblyai/*", "/eu.assemblyai/*",

View file

@ -411,6 +411,8 @@ async def test_pass_through_request_logging_failure_with_stream(
PROTOCOL_CONSTRAINED_PASS_THROUGH_ROUTES = {
"/comprehendmedical": {"POST"},
"/comprehendmedical/{operation}": {"POST"},
"/transcribe": {"POST"},
"/transcribe/{operation}": {"POST"},
}
@ -418,9 +420,7 @@ def test_pass_through_routes_support_all_methods():
"""
A pass-through route fronts a whole provider API, so narrowing its method
set turns a request the upstream would have accepted into a 405. The
exceptions are providers whose wire protocol admits only one method: Amazon
Comprehend Medical speaks AWS JSON 1.1, which is POST-only, so there is no
other method to forward.
exceptions are the POST-only protocol routes listed above.
"""
from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
router as llm_router,

View file

@ -116,6 +116,8 @@ def test_is_pure_asgi_not_base_http_middleware():
# Bare AWS-SDK-shaped route carries the operation in X-Amz-Target and writes SpendLogs
("/comprehendmedical", (BillableCategory.LLM, "/comprehendmedical")),
("/comprehendmedical/DetectEntitiesV2", (BillableCategory.LLM, "/comprehendmedical")),
("/transcribe", (BillableCategory.LLM, "/transcribe")),
("/transcribe/StartTranscriptionJob", (BillableCategory.LLM, "/transcribe")),
("/mcp", (BillableCategory.MCP, "/mcp")),
("/mcp/", (BillableCategory.MCP, "/mcp")),
("/mcp/tools/list", (BillableCategory.MCP, "/mcp")),

View file

@ -0,0 +1,942 @@
import asyncio
import io
import json
import wave
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock
import httpx
import pytest
import litellm
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.transcribe_passthrough_logging_handler import (
TRANSCRIBE_MAX_MEDIA_DURATION_SECONDS,
TRANSCRIBE_OWNER_TAG,
TranscribePassthroughLoggingHandler,
TranscribeRefusal,
TranscriptionJobRecord,
media_file_seconds,
media_predates_job,
price_transcription_job,
requested_media_format,
s3_media_url,
started_transcription_job,
transcribe_admin_only_refusal,
transcribe_cost_per_second,
transcribe_job_access_refusal,
transcribe_media_buckets,
transcribe_owned_start_request,
transcribe_storage_refusal,
transcribe_supported_operations,
transcribe_unpriceable_request_reason,
write_media_within_limit,
)
from litellm.proxy.pass_through_endpoints.success_handler import (
PassThroughEndpointLogging,
)
COST_PER_SECOND = 0.0001
def _make_response(operation: str) -> httpx.Response:
request = httpx.Request(
"POST",
"https://transcribe.us-west-2.amazonaws.com/",
headers={"X-Amz-Target": f"Transcribe.{operation}"},
)
return httpx.Response(200, request=request, text='{"TranscriptionJob": {}}')
async def _relayed_response(operation: str, body: bytes) -> httpx.Response:
response = httpx.Response(
200,
request=_make_response(operation).request,
headers={"content-type": "application/x-amz-json-1.1"},
stream=httpx.ByteStream(body),
)
async for _ in response.aiter_bytes():
pass
await response.aclose()
return response
def _make_logging_obj() -> MagicMock:
logging_obj = MagicMock()
logging_obj.litellm_call_id = "test-call-id"
logging_obj.model_call_details = {}
return logging_obj
async def _no_sleep(_: float) -> None:
return None
MEDIA_URI = "s3://b/a.wav"
CREATED_AT = 1_789_682_363.696
def _job(
status: str, media_uri: str | None = MEDIA_URI, created_at: float | None = CREATED_AT, **members: object
) -> dict[str, object]:
media = {"Media": {"MediaFileUri": media_uri}} if media_uri else {}
created = {"CreationTime": created_at} if created_at is not None else {}
return {"TranscriptionJob": {"TranscriptionJobStatus": status, **media, **created, **members}}
async def _no_media(uri: str, created_at: float) -> float | None:
raise AssertionError("the media must not be measured on this path")
def _media_probe(*durations: float | None | Exception):
remaining = list(durations)
measured: list[tuple[str, float]] = []
async def media_seconds(uri: str, created_at: float) -> float | None:
measured.append((uri, created_at))
outcome = remaining.pop(0) if len(remaining) > 1 else remaining[0]
if isinstance(outcome, Exception):
raise outcome
return outcome
return media_seconds, measured
def _sequence(*jobs: dict[str, object]):
remaining = list(jobs)
seen: list[str] = []
async def get_job(job_name: str) -> dict[str, object]:
seen.append(job_name)
return remaining.pop(0) if len(remaining) > 1 else remaining[0]
return get_job, seen
def _aws_error(error_type: str) -> httpx.HTTPStatusError:
request = httpx.Request("POST", "https://transcribe.us-west-2.amazonaws.com/")
response = httpx.Response(400, request=request, json={"__type": error_type, "message": "nope"})
return httpx.HTTPStatusError("400", request=request, response=response)
def _missing_job(error_type: str):
seen: list[str] = []
async def get_job(job_name: str) -> dict[str, object]:
seen.append(job_name)
raise _aws_error(error_type)
return get_job, seen
class TestTranscribeSupportedOperations:
def test_matches_the_installed_botocore_service_model(self):
from botocore.session import get_session
assert transcribe_supported_operations() == frozenset(
get_session().get_service_model("transcribe").operation_names
)
class TestTranscribeCostMap:
def test_start_transcription_job_is_priced_per_second_of_audio(self):
entry = litellm.model_cost["transcribe/StartTranscriptionJob"]
assert entry["litellm_provider"] == "transcribe"
assert entry["mode"] == "audio_transcription"
assert transcribe_cost_per_second() == entry["input_cost_per_second"] > 0
def test_missing_or_malformed_entry_yields_no_rate(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setitem(litellm.model_cost, "transcribe/StartTranscriptionJob", {"input_cost_per_second": "x"})
assert transcribe_cost_per_second() is None
monkeypatch.delitem(litellm.model_cost, "transcribe/StartTranscriptionJob")
assert transcribe_cost_per_second() is None
class TestTranscribeUnpriceableRequestReason:
def test_plain_start_transcription_job_is_allowed(self):
body = {"TranscriptionJobName": "j", "Media": {"MediaFileUri": MEDIA_URI}}
assert transcribe_unpriceable_request_reason("StartTranscriptionJob", body, COST_PER_SECOND) is None
@pytest.mark.parametrize(
"body",
[
{"Media": {"MediaFileUri": "s3://b/a.mp4"}},
{"Media": {"MediaFileUri": "s3://b/a.wav"}, "MediaFormat": "webm"},
{"Media": {"MediaFileUri": "s3://b/recording"}},
{"TranscriptionJobName": "j"},
],
)
def test_media_whose_length_cannot_be_read_is_rejected(self, body: dict[str, object]):
reason = transcribe_unpriceable_request_reason("StartTranscriptionJob", body, COST_PER_SECOND)
assert reason is not None and "MediaFormat" in reason
@pytest.mark.parametrize(
"body",
[
{"Media": {"MediaFileUri": "s3://b/a.mp4"}, "MediaFormat": "mp3"},
{"Media": {"MediaFileUri": "https://s3.us-west-2.amazonaws.com/b/a.FLAC?x=1"}},
{"Media": {"MediaFileUri": "s3://b/dir.v2/a.ogg"}},
],
)
def test_measurable_media_is_allowed(self, body: dict[str, object]):
assert transcribe_unpriceable_request_reason("StartTranscriptionJob", body, COST_PER_SECOND) is None
def test_read_only_operations_are_allowed_without_a_rate(self):
assert transcribe_unpriceable_request_reason("GetTranscriptionJob", {}, None) is None
assert transcribe_unpriceable_request_reason("ListTranscriptionJobs", {}, None) is None
def test_start_transcription_job_needs_a_rate(self):
reason = transcribe_unpriceable_request_reason("StartTranscriptionJob", {"TranscriptionJobName": "j"}, None)
assert reason is not None and "model cost map" in reason
@pytest.mark.parametrize(
"operation", ["StartCallAnalyticsJob", "StartMedicalScribeJob", "StartMedicalTranscriptionJob"]
)
def test_unpriced_job_classes_are_rejected(self, operation: str):
reason = transcribe_unpriceable_request_reason(operation, {}, COST_PER_SECOND)
assert reason is not None and operation in reason
@pytest.mark.parametrize(
("body", "member"),
[
({"ContentRedaction": {"RedactionType": "PII", "RedactionOutput": "redacted"}}, "ContentRedaction"),
({"ToxicityDetection": [{"ToxicityCategories": ["ALL"]}]}, "ToxicityDetection"),
({"ModelSettings": {"LanguageModelName": "clm"}}, "ModelSettings.LanguageModelName"),
(
{
"IdentifyLanguage": True,
"LanguageIdSettings": {"en-US": {"VocabularyName": "v"}, "fr-FR": {"LanguageModelName": "clm"}},
},
"LanguageIdSettings.fr-FR.LanguageModelName",
),
],
)
def test_surcharged_features_are_rejected(self, body: dict[str, object], member: str):
reason = transcribe_unpriceable_request_reason(
"StartTranscriptionJob", {**body, "Media": {"MediaFileUri": MEDIA_URI}}, COST_PER_SECOND
)
assert reason is not None and member in reason
def test_settings_without_a_custom_model_are_allowed(self):
body = {
"ModelSettings": {},
"LanguageIdSettings": {"en-US": {"VocabularyName": "v"}},
"Media": {"MediaFileUri": MEDIA_URI},
}
assert transcribe_unpriceable_request_reason("StartTranscriptionJob", body, COST_PER_SECOND) is None
class TestRequestedMediaFormat:
def test_explicit_media_format_wins_over_the_extension(self):
assert requested_media_format({"MediaFormat": "MP3", "Media": {"MediaFileUri": "s3://b/a.wav"}}) == "mp3"
def test_extension_is_read_from_the_uri_path_only(self):
assert requested_media_format({"Media": {"MediaFileUri": "https://h/b/a.wav?sig=x.y"}}) == "wav"
assert requested_media_format({"Media": {"MediaFileUri": "s3://b.name/a"}}) is None
assert requested_media_format({"Media": {"MediaFileUri": 7}}) is None
class TestS3MediaUrl:
def test_s3_uri_maps_to_the_regional_virtual_hosted_endpoint(self):
assert (
s3_media_url("s3://my-bucket/dir/a b.wav", "us-west-2")
== "https://my-bucket.s3.us-west-2.amazonaws.com/dir/a%20b.wav"
)
def test_dotted_bucket_maps_to_the_regional_path_style_endpoint(self):
assert (
s3_media_url("s3://media.example.com/dir/a b.wav", "us-west-2")
== "https://s3.us-west-2.amazonaws.com/media.example.com/dir/a%20b.wav"
)
@pytest.mark.parametrize(
"media_uri",
[
"https://evil.example.com/a.wav",
"https://my-bucket.s3.us-west-2.amazonaws.com@evil.example.com/a.wav",
"https://amazonaws.com/a.wav",
"http://my-bucket.s3.us-west-2.amazonaws.com/a.wav",
],
)
def test_hosts_outside_the_aws_partition_or_off_https_are_never_signed_for(self, media_uri: str):
assert s3_media_url(media_uri, "us-west-2") is None
def test_https_uri_is_used_as_given(self):
assert (
s3_media_url("https://my-bucket.s3.eu-west-1.amazonaws.com/a.wav", "us-west-2")
== "https://my-bucket.s3.eu-west-1.amazonaws.com/a.wav"
)
class _ChunkedStream(httpx.AsyncByteStream):
def __init__(self, *chunks: bytes) -> None:
self._chunks = chunks
async def __aiter__(self):
for chunk in self._chunks:
yield chunk
def _media_response(*chunks: bytes, content_length: int | None) -> httpx.Response:
headers = {"content-length": str(content_length)} if content_length is not None else {}
return httpx.Response(200, headers=headers, stream=_ChunkedStream(*chunks))
class TestWriteMediaWithinLimit:
@pytest.mark.asyncio
async def test_media_within_the_cap_is_written_whole(self):
media_file = io.BytesIO()
assert await write_media_within_limit(_media_response(b"abc", b"def", content_length=6), media_file, 6) is True
assert media_file.getvalue() == b"abcdef"
@pytest.mark.asyncio
async def test_advertised_size_over_the_cap_is_refused_before_downloading(self):
media_file = io.BytesIO()
assert await write_media_within_limit(_media_response(b"abcdef", content_length=7), media_file, 6) is False
assert media_file.getvalue() == b""
@pytest.mark.asyncio
async def test_stream_growing_past_the_cap_is_cut_off(self):
media_file = io.BytesIO()
response = _media_response(b"abc", b"def", b"ghi", content_length=None)
assert await write_media_within_limit(response, media_file, 5) is False
assert media_file.getvalue() == b"abcdef"
class TestPriceTranscriptionJob:
@pytest.mark.asyncio
async def test_polls_until_completed_then_charges_the_media_length_rounded_up(self):
get_job, seen = _sequence(_job("IN_PROGRESS"), _job("IN_PROGRESS"), _job("COMPLETED"))
media_seconds, measured = _media_probe(17.577)
cost = await price_transcription_job("job-1", COST_PER_SECOND, get_job, media_seconds, sleep=_no_sleep)
assert cost == pytest.approx(18 * COST_PER_SECOND)
assert seen == ["job-1", "job-1", "job-1"]
assert measured == [(MEDIA_URI, CREATED_AT)]
@pytest.mark.asyncio
async def test_a_failed_poll_is_retried_instead_of_ending_pricing(self):
remaining = [httpx.ConnectError("aws blip"), None]
async def get_job(job_name: str) -> dict[str, object]:
outcome = remaining.pop(0)
if outcome is not None:
raise outcome
return _job("COMPLETED")
media_seconds, _ = _media_probe(3.0)
cost = await price_transcription_job("job-1", COST_PER_SECOND, get_job, media_seconds, sleep=_no_sleep)
assert cost == pytest.approx(3 * COST_PER_SECOND)
assert remaining == []
@pytest.mark.asyncio
async def test_failed_job_costs_nothing(self):
get_job, _ = _sequence(_job("FAILED"))
assert await price_transcription_job("job-1", COST_PER_SECOND, get_job, _no_media, sleep=_no_sleep) == 0.0
@pytest.mark.asyncio
async def test_job_deleted_before_it_is_polled_is_charged_for_the_media_it_was_started_with(self):
get_job, seen = _missing_job("BadRequestException")
media_seconds, measured = _media_probe(17.577)
started = started_transcription_job(
{"TranscriptionJob": {"Media": {"MediaFileUri": "s3://b/started.wav"}, "CreationTime": 5.0}}
)
cost = await price_transcription_job(
"job-1", COST_PER_SECOND, get_job, media_seconds, sleep=_no_sleep, started_job=started
)
assert cost == pytest.approx(18 * COST_PER_SECOND)
assert seen == ["job-1"]
assert measured == [("s3://b/started.wav", 5.0)]
@pytest.mark.asyncio
async def test_job_not_found_by_transcribe_is_charged_the_maximum_without_a_start_record(self):
get_job, seen = _missing_job("com.amazonaws.transcribe#NotFoundException")
cost = await price_transcription_job("job-1", COST_PER_SECOND, get_job, _no_media, sleep=_no_sleep)
assert cost == pytest.approx(TRANSCRIBE_MAX_MEDIA_DURATION_SECONDS * COST_PER_SECOND)
assert seen == ["job-1"]
@pytest.mark.asyncio
async def test_throttled_poll_is_retried_rather_than_treated_as_a_missing_job(self):
remaining = ["LimitExceededException", None]
async def get_job(job_name: str) -> dict[str, object]:
error_type = remaining.pop(0)
if error_type is not None:
raise _aws_error(error_type)
return _job("COMPLETED")
media_seconds, _ = _media_probe(3.0)
cost = await price_transcription_job("job-1", COST_PER_SECOND, get_job, media_seconds, sleep=_no_sleep)
assert cost == pytest.approx(3 * COST_PER_SECOND)
assert remaining == []
@pytest.mark.asyncio
async def test_job_that_never_finishes_is_charged_the_maximum(self):
get_job, seen = _sequence(_job("IN_PROGRESS"))
cost = await price_transcription_job(
"job-1", COST_PER_SECOND, get_job, _no_media, sleep=_no_sleep, max_attempts=3
)
assert cost == pytest.approx(TRANSCRIBE_MAX_MEDIA_DURATION_SECONDS * COST_PER_SECOND)
assert len(seen) == 3
@pytest.mark.asyncio
async def test_media_that_cannot_be_read_is_charged_the_maximum(self):
get_job, _ = _sequence(_job("COMPLETED"))
media_seconds, measured = _media_probe(None)
cost = await price_transcription_job("job-1", COST_PER_SECOND, get_job, media_seconds, sleep=_no_sleep)
assert cost == pytest.approx(TRANSCRIBE_MAX_MEDIA_DURATION_SECONDS * COST_PER_SECOND)
assert measured == [(MEDIA_URI, CREATED_AT)]
@pytest.mark.asyncio
async def test_media_fetch_is_retried_then_charged_the_maximum(self):
get_job, _ = _sequence(_job("COMPLETED"))
media_seconds, measured = _media_probe(httpx.ReadTimeout("s3 slow"))
cost = await price_transcription_job("job-1", COST_PER_SECOND, get_job, media_seconds, sleep=_no_sleep)
assert cost == pytest.approx(TRANSCRIBE_MAX_MEDIA_DURATION_SECONDS * COST_PER_SECOND)
assert len(measured) == 3
@pytest.mark.asyncio
async def test_media_fetch_recovers_after_a_transient_failure(self):
get_job, _ = _sequence(_job("COMPLETED"))
media_seconds, measured = _media_probe(httpx.ReadTimeout("s3 slow"), 60.0)
cost = await price_transcription_job("job-1", COST_PER_SECOND, get_job, media_seconds, sleep=_no_sleep)
assert cost == pytest.approx(60 * COST_PER_SECOND)
assert len(measured) == 2
@pytest.mark.asyncio
async def test_completed_job_without_media_uri_is_charged_the_maximum(self):
get_job, _ = _sequence(_job("COMPLETED", media_uri=None))
cost = await price_transcription_job("job-1", COST_PER_SECOND, get_job, _no_media, sleep=_no_sleep)
assert cost == pytest.approx(TRANSCRIBE_MAX_MEDIA_DURATION_SECONDS * COST_PER_SECOND)
@pytest.mark.asyncio
async def test_completed_job_without_creation_time_is_charged_the_maximum_unmeasured(self):
get_job, _ = _sequence(_job("COMPLETED", created_at=None))
media_seconds, measured = _media_probe(60.0)
cost = await price_transcription_job("job-1", COST_PER_SECOND, get_job, media_seconds, sleep=_no_sleep)
assert cost == pytest.approx(TRANSCRIBE_MAX_MEDIA_DURATION_SECONDS * COST_PER_SECOND)
assert measured == []
class TestMediaFileSeconds:
def test_reads_the_duration_from_the_file_on_disk(self, tmp_path: Path):
media = tmp_path / "a.wav"
with wave.open(str(media), "wb") as out:
out.setnchannels(1)
out.setsampwidth(2)
out.setframerate(8000)
out.writeframes(bytes(2 * 12_000))
assert media_file_seconds(media) == pytest.approx(1.5)
def test_undecodable_media_yields_no_duration(self, tmp_path: Path):
media = tmp_path / "a.wav"
_ = media.write_bytes(b"not audio at all")
assert media_file_seconds(media) is None
class TestStartedTranscriptionJob:
def test_reads_the_media_and_creation_time_from_the_start_response(self):
started = started_transcription_job(
{
"TranscriptionJob": {
"TranscriptionJobName": "j",
"Media": {"MediaFileUri": "s3://b/a.wav"},
"CreationTime": 1.5,
"TranscriptionJobStatus": "IN_PROGRESS",
}
}
)
assert started == TranscriptionJobRecord(
TranscriptionJobStatus="IN_PROGRESS", CreationTime=1.5, Media={"MediaFileUri": "s3://b/a.wav"}
)
@pytest.mark.parametrize("body", [None, {"Message": "throttled"}, {"TranscriptionJob": {"CreationTime": "soon"}}])
def test_unreadable_start_response_yields_no_record(self, body: dict[str, object] | None):
assert started_transcription_job(body) is None
class TestMediaPredatesJob:
LAST_MODIFIED = "Thu, 17 Sep 2026 17:45:00 GMT"
LAST_MODIFIED_EPOCH = 1_789_667_100.0
def test_object_written_before_the_job_counts(self):
assert media_predates_job(httpx.Headers({"Last-Modified": self.LAST_MODIFIED}), self.LAST_MODIFIED_EPOCH + 30)
def test_object_written_in_the_same_second_as_the_job_counts(self):
assert media_predates_job(httpx.Headers({"Last-Modified": self.LAST_MODIFIED}), self.LAST_MODIFIED_EPOCH - 0.4)
def test_object_rewritten_after_the_job_does_not_count(self):
assert not media_predates_job(
httpx.Headers({"Last-Modified": self.LAST_MODIFIED}), self.LAST_MODIFIED_EPOCH - 30
)
@pytest.mark.parametrize("headers", [{}, {"Last-Modified": "yesterday"}])
def test_unknown_modification_time_does_not_count(self, headers: dict[str, str]):
assert not media_predates_job(httpx.Headers(headers), self.LAST_MODIFIED_EPOCH + 30)
VIRTUAL_KEY = UserAPIKeyAuth(api_key="hashed-key-a", user_id="user-a", team_id="team-a")
OTHER_VIRTUAL_KEY = UserAPIKeyAuth(api_key="hashed-key-b", user_id="user-b", team_id="team-b")
ADMIN_KEY = UserAPIKeyAuth(api_key="hashed-admin", user_role=LitellmUserRoles.PROXY_ADMIN)
class TestTranscribeAdminOnlyRefusal:
@pytest.mark.parametrize("operation", ["StartTranscriptionJob", "GetTranscriptionJob", "DeleteTranscriptionJob"])
def test_job_scoped_operations_are_open_to_virtual_keys(self, operation: str):
assert transcribe_admin_only_refusal(operation, VIRTUAL_KEY) is None
@pytest.mark.parametrize("operation", ["ListTranscriptionJobs", "ListVocabularies", "DeleteVocabulary"])
def test_account_wide_operations_are_refused_for_virtual_keys(self, operation: str):
refusal = transcribe_admin_only_refusal(operation, VIRTUAL_KEY)
assert refusal is not None
assert refusal.status_code == 403
assert operation in refusal.detail
@pytest.mark.parametrize("operation", ["ListTranscriptionJobs", "DeleteVocabulary"])
def test_account_wide_operations_are_open_to_proxy_admins(self, operation: str):
assert transcribe_admin_only_refusal(operation, ADMIN_KEY) is None
ALLOWED_BUCKETS = frozenset({"tenant-media", "tenant-transcripts"})
def _start_body(media_uri: str = "s3://tenant-media/call.wav", **members: object) -> dict[str, object]:
return {"TranscriptionJobName": "j", "Media": {"MediaFileUri": media_uri}, **members}
class TestTranscribeMediaBuckets:
def test_a_list_of_bucket_names_is_read_from_general_settings(self):
assert transcribe_media_buckets({"transcribe_media_buckets": ["a", "b"]}) == frozenset({"a", "b"})
@pytest.mark.parametrize("settings", [{}, {"transcribe_media_buckets": "a"}, {"transcribe_media_buckets": [1]}])
def test_a_missing_or_malformed_setting_reads_as_unset(self, settings: dict[str, object]):
assert transcribe_media_buckets(settings) is None
class TestTranscribeStorageRefusal:
def test_media_and_output_in_listed_buckets_are_allowed(self):
body = _start_body(OutputBucketName="tenant-transcripts", OutputKey="out/")
assert transcribe_storage_refusal(body, ALLOWED_BUCKETS, VIRTUAL_KEY) is None
@pytest.mark.parametrize(
"media_uri",
[
"s3://other-tenant/call.wav",
"https://tenant-media.s3.us-west-2.amazonaws.com/call.wav",
"s3://",
],
)
def test_media_outside_the_listed_buckets_is_refused(self, media_uri: str):
refusal = transcribe_storage_refusal(_start_body(media_uri), ALLOWED_BUCKETS, VIRTUAL_KEY)
assert refusal is not None
assert refusal.status_code == 403
assert "Media.MediaFileUri" in refusal.detail
def test_redacted_media_outside_the_listed_buckets_is_refused(self):
body = {
"TranscriptionJobName": "j",
"Media": {"MediaFileUri": "s3://tenant-media/call.wav", "RedactedMediaFileUri": "s3://other-tenant/c.wav"},
}
refusal = transcribe_storage_refusal(body, ALLOWED_BUCKETS, VIRTUAL_KEY)
assert refusal is not None
assert "Media.RedactedMediaFileUri" in refusal.detail
@pytest.mark.parametrize("output", ["other-tenant", 7])
def test_an_output_bucket_outside_the_listed_buckets_is_refused(self, output: object):
refusal = transcribe_storage_refusal(_start_body(OutputBucketName=output), ALLOWED_BUCKETS, VIRTUAL_KEY)
assert refusal is not None
assert refusal.status_code == 403
assert "OutputBucketName" in refusal.detail
@pytest.mark.parametrize("member", ["DataAccessRoleArn", "JobExecutionSettings"])
def test_a_caller_chosen_role_is_refused(self, member: str):
refusal = transcribe_storage_refusal(_start_body(**{member: "x"}), ALLOWED_BUCKETS, VIRTUAL_KEY)
assert refusal is not None
assert refusal.status_code == 403
assert member in refusal.detail
def test_an_unset_bucket_list_refuses_virtual_keys(self):
refusal = transcribe_storage_refusal(_start_body(), None, VIRTUAL_KEY)
assert refusal is not None
assert refusal.status_code == 403
assert "transcribe_media_buckets" in refusal.detail
@pytest.mark.parametrize("allowed", [None, ALLOWED_BUCKETS])
def test_proxy_admins_are_not_restricted(self, allowed: frozenset[str] | None):
body = _start_body("s3://other-tenant/call.wav", DataAccessRoleArn="arn:aws:iam::1:role/r")
assert transcribe_storage_refusal(body, allowed, ADMIN_KEY) is None
class TestTranscribeOwnedStartRequest:
def test_the_caller_identity_is_appended_to_the_job_tags(self):
body = {"TranscriptionJobName": "j", "Tags": [{"Key": "env", "Value": "qa"}]}
owned = transcribe_owned_start_request(body, VIRTUAL_KEY)
assert owned == {
"TranscriptionJobName": "j",
"Tags": ({"Key": "env", "Value": "qa"}, {"Key": TRANSCRIBE_OWNER_TAG, "Value": "user-a"}),
}
assert body == {"TranscriptionJobName": "j", "Tags": [{"Key": "env", "Value": "qa"}]}
def test_a_request_without_tags_gets_the_owner_tag(self):
owned = transcribe_owned_start_request({"TranscriptionJobName": "j"}, VIRTUAL_KEY)
assert owned == {"TranscriptionJobName": "j", "Tags": ({"Key": TRANSCRIBE_OWNER_TAG, "Value": "user-a"},)}
def test_the_caller_cannot_supply_the_owner_tag(self):
owned = transcribe_owned_start_request(
{"TranscriptionJobName": "j", "Tags": [{"Key": TRANSCRIBE_OWNER_TAG, "Value": "user-b"}]}, VIRTUAL_KEY
)
assert isinstance(owned, TranscribeRefusal)
assert owned.status_code == 400
@pytest.mark.parametrize("tags", ["env=qa", ["env"], {"Key": "env"}])
def test_malformed_tags_are_refused(self, tags: object):
owned = transcribe_owned_start_request({"TranscriptionJobName": "j", "Tags": tags}, VIRTUAL_KEY)
assert isinstance(owned, TranscribeRefusal)
assert owned.status_code == 400
def test_a_key_without_any_identity_is_refused(self):
owned = transcribe_owned_start_request({"TranscriptionJobName": "j"}, UserAPIKeyAuth())
assert isinstance(owned, TranscribeRefusal)
assert owned.status_code == 400
def _tagged(owner: str | None) -> dict[str, object]:
tags = {"Tags": [{"Key": TRANSCRIBE_OWNER_TAG, "Value": owner}]} if owner is not None else {}
return _job("COMPLETED", **tags)
class TestTranscribeJobAccessRefusal:
@pytest.mark.asyncio
async def test_the_key_that_started_the_job_may_read_it(self):
get_job, seen = _sequence(_tagged("user-a"))
assert await transcribe_job_access_refusal("job-1", VIRTUAL_KEY, get_job) is None
assert seen == ["job-1"]
@pytest.mark.asyncio
async def test_a_job_started_by_another_key_is_reported_missing(self):
get_job, _ = _sequence(_tagged("user-b"))
refusal = await transcribe_job_access_refusal("job-1", VIRTUAL_KEY, get_job)
assert refusal is not None
assert refusal.status_code == 404
@pytest.mark.asyncio
async def test_a_job_started_outside_the_proxy_is_reported_missing(self):
get_job, _ = _sequence(_tagged(None))
refusal = await transcribe_job_access_refusal("job-1", OTHER_VIRTUAL_KEY, get_job)
assert refusal is not None
assert refusal.status_code == 404
@pytest.mark.asyncio
async def test_a_job_that_cannot_be_looked_up_is_reported_missing(self):
async def get_job(job_name: str) -> dict[str, object]:
raise httpx.HTTPStatusError("boom", request=MagicMock(), response=MagicMock())
refusal = await transcribe_job_access_refusal("job-1", VIRTUAL_KEY, get_job)
assert refusal is not None
assert refusal.status_code == 404
@pytest.mark.asyncio
async def test_a_non_string_job_name_is_refused_before_any_lookup(self):
get_job, seen = _sequence(_tagged("user-a"))
refusal = await transcribe_job_access_refusal(["job-1"], VIRTUAL_KEY, get_job)
assert refusal is not None
assert refusal.status_code == 400
assert seen == []
@pytest.mark.asyncio
async def test_a_proxy_admin_reads_any_job_without_a_lookup(self):
get_job, seen = _sequence(_tagged("user-b"))
assert await transcribe_job_access_refusal("job-1", ADMIN_KEY, get_job) is None
assert seen == []
class TestTranscribePassthroughHandler:
def test_records_model_provider_and_the_given_cost(self):
logging_obj = _make_logging_obj()
request_body = {"TranscriptionJobName": "litellm-job-1"}
handler_result = TranscribePassthroughLoggingHandler.transcribe_passthrough_handler(
httpx_response=_make_response("StartTranscriptionJob"),
logging_obj=logging_obj,
url_route="https://transcribe.us-west-2.amazonaws.com/",
result='{"TranscriptionJob": {}}',
start_time=datetime.now(),
end_time=datetime.now(),
cache_hit=False,
request_body=request_body,
response_cost=0.0018,
)
assert handler_result["result"] == {"response": '{"TranscriptionJob": {}}'}
assert handler_result["kwargs"]["model"] == "transcribe/StartTranscriptionJob"
assert handler_result["kwargs"]["custom_llm_provider"] == "transcribe"
assert handler_result["kwargs"]["response_cost"] == 0.0018
assert handler_result["kwargs"]["standard_logging_object"]["response_cost"] == 0.0018
assert logging_obj.model_call_details["model"] == "transcribe/StartTranscriptionJob"
assert logging_obj.model_call_details["custom_llm_provider"] == "transcribe"
assert logging_obj.model_call_details["response_cost"] == 0.0018
assert request_body == {"TranscriptionJobName": "litellm-job-1"}
def test_read_only_operations_default_to_zero_cost(self):
handler_result = TranscribePassthroughLoggingHandler.transcribe_passthrough_handler(
httpx_response=_make_response("GetTranscriptionJob"),
logging_obj=_make_logging_obj(),
url_route="https://transcribe.us-west-2.amazonaws.com/",
result='{"TranscriptionJob": {}}',
start_time=datetime.now(),
end_time=datetime.now(),
cache_hit=False,
request_body={"TranscriptionJobName": "litellm-job-1"},
)
assert handler_result["kwargs"]["response_cost"] == 0.0
class TestStartTranscriptionJobIsLoggedAtJobCost:
@pytest.mark.asyncio
async def test_success_handler_defers_logging_until_the_job_is_priced(self):
priced: list[tuple[str, str, float, TranscriptionJobRecord | None]] = []
async def job_pricer(
job_name: str, aws_region_name: str, cost_per_second: float, started_job: TranscriptionJobRecord | None
) -> float:
priced.append((job_name, aws_region_name, cost_per_second, started_job))
return 0.0018
logged: list[dict[str, object]] = []
async def log(**kwargs: object) -> None:
logged.append(kwargs)
handler = TranscribePassthroughLoggingHandler(job_pricer=job_pricer)
logging_obj = _make_logging_obj()
task = handler.schedule_priced_job_logging(
httpx_response=_make_response("StartTranscriptionJob"),
response_body={"TranscriptionJob": {}},
logging_obj=logging_obj,
url_route="https://transcribe.us-west-2.amazonaws.com/",
result='{"TranscriptionJob": {}}',
start_time=datetime.now(),
end_time=datetime.now(),
cache_hit=False,
request_body={"TranscriptionJobName": "litellm-job-1"},
log=log,
standard_pass_through_logging_payload={"cost_per_request": None},
)
await task
assert priced == [("litellm-job-1", "us-west-2", transcribe_cost_per_second(), TranscriptionJobRecord())]
assert len(logged) == 1
assert logged[0]["response_cost"] == 0.0018
assert logged[0]["model"] == "transcribe/StartTranscriptionJob"
assert logged[0]["standard_pass_through_logging_payload"] == {"cost_per_request": None}
assert logging_obj.model_call_details["response_cost"] == 0.0018
@pytest.mark.asyncio
async def test_job_is_not_logged_for_free_when_the_rate_leaves_the_cost_map(self, monkeypatch: pytest.MonkeyPatch):
async def job_pricer(
job_name: str, aws_region_name: str, cost_per_second: float, started_job: TranscriptionJobRecord | None
) -> float:
raise AssertionError("pricer must not run without a rate")
logged: list[dict[str, object]] = []
async def log(**kwargs: object) -> None:
logged.append(kwargs)
monkeypatch.delitem(litellm.model_cost, "transcribe/StartTranscriptionJob")
await TranscribePassthroughLoggingHandler(job_pricer=job_pricer).schedule_priced_job_logging(
httpx_response=_make_response("StartTranscriptionJob"),
response_body={"TranscriptionJob": {}},
logging_obj=_make_logging_obj(),
url_route="https://transcribe.us-west-2.amazonaws.com/",
result='{"TranscriptionJob": {}}',
start_time=datetime.now(),
end_time=datetime.now(),
cache_hit=False,
request_body={"TranscriptionJobName": "litellm-job-1"},
log=log,
)
assert logged == []
@pytest.mark.asyncio
async def test_pass_through_success_handler_routes_job_starts_to_the_pricer(self):
scheduled: list[str] = []
async def job_pricer(
job_name: str, aws_region_name: str, cost_per_second: float, started_job: TranscriptionJobRecord | None
) -> float:
scheduled.append(job_name)
return 0.0
immediate: list[dict[str, object]] = []
async def log_dispatch(**kwargs: object) -> None:
immediate.append(kwargs)
logging = PassThroughEndpointLogging(
TranscribePassthroughLoggingHandler(job_pricer=job_pricer), log_dispatch=log_dispatch
)
await logging.pass_through_async_success_handler(
httpx_response=_make_response("StartTranscriptionJob"),
response_body={"TranscriptionJob": {}},
logging_obj=_make_logging_obj(),
url_route="https://transcribe.us-west-2.amazonaws.com/",
result='{"TranscriptionJob": {}}',
start_time=datetime.now(),
end_time=datetime.now(),
cache_hit=False,
request_body={"TranscriptionJobName": "litellm-job-1"},
passthrough_logging_payload={"url": "https://transcribe.us-west-2.amazonaws.com/"},
custom_llm_provider="transcribe",
)
await asyncio.gather(*logging.transcribe_passthrough_logging_handler._pricing_tasks)
assert scheduled == ["litellm-job-1"]
assert [entry["response_cost"] for entry in immediate] == [0.0]
@pytest.mark.asyncio
async def test_pass_through_success_handler_prices_a_relayed_start_response_from_its_parsed_body(self):
started_jobs: list[TranscriptionJobRecord | None] = []
logged_costs: list[object] = []
async def job_pricer(
job_name: str, aws_region_name: str, cost_per_second: float, started_job: TranscriptionJobRecord | None
) -> float:
started_jobs.append(started_job)
return 18 * COST_PER_SECOND
async def log_dispatch(**kwargs: object) -> None:
logged_costs.append(kwargs["response_cost"])
start_response = {
"TranscriptionJob": {
"TranscriptionJobName": "litellm-job-1",
"TranscriptionJobStatus": "IN_PROGRESS",
"Media": {"MediaFileUri": "s3://b/started.wav"},
"CreationTime": 5.0,
}
}
logging = PassThroughEndpointLogging(
TranscribePassthroughLoggingHandler(job_pricer=job_pricer), log_dispatch=log_dispatch
)
await logging.pass_through_async_success_handler(
httpx_response=await _relayed_response("StartTranscriptionJob", json.dumps(start_response).encode()),
response_body=start_response,
logging_obj=_make_logging_obj(),
url_route="https://transcribe.us-west-2.amazonaws.com/",
result="",
start_time=datetime.now(),
end_time=datetime.now(),
cache_hit=False,
request_body={"TranscriptionJobName": "litellm-job-1"},
passthrough_logging_payload={"url": "https://transcribe.us-west-2.amazonaws.com/"},
custom_llm_provider="transcribe",
)
await asyncio.gather(*logging.transcribe_passthrough_logging_handler._pricing_tasks)
assert started_jobs == [
TranscriptionJobRecord(
TranscriptionJobStatus="IN_PROGRESS", CreationTime=5.0, Media={"MediaFileUri": "s3://b/started.wav"}
)
]
assert logged_costs == [pytest.approx(18 * COST_PER_SECOND)]
class TestIsTranscribeRoute:
def test_matches_by_provider_tag(self):
assert PassThroughEndpointLogging().is_transcribe_route("transcribe")
def test_does_not_match_other_providers(self):
assert not PassThroughEndpointLogging().is_transcribe_route("comprehendmedical")
def test_dispatch_reaches_transcribe_handler(self):
logging_obj = _make_logging_obj()
normalized = PassThroughEndpointLogging().normalize_llm_passthrough_logging_payload(
httpx_response=_make_response("GetTranscriptionJob"),
response_body={"TranscriptionJob": {}},
request_body={"TranscriptionJobName": "litellm-job-1"},
logging_obj=logging_obj,
url_route="https://transcribe.us-west-2.amazonaws.com/",
result='{"TranscriptionJob": {}}',
start_time=datetime.now(),
end_time=datetime.now(),
cache_hit=False,
custom_llm_provider="transcribe",
)
assert normalized["kwargs"]["model"] == "transcribe/GetTranscriptionJob"
assert normalized["kwargs"]["response_cost"] == 0.0
def test_config_driven_passthrough_to_transcribe_host_is_not_claimed(self):
logging_obj = _make_logging_obj()
normalized = PassThroughEndpointLogging().normalize_llm_passthrough_logging_payload(
httpx_response=_make_response("GetTranscriptionJob"),
response_body={"TranscriptionJob": {}},
request_body={"TranscriptionJobName": "litellm-job-1"},
logging_obj=logging_obj,
url_route="https://transcribe.us-west-2.amazonaws.com/",
result='{"TranscriptionJob": {}}',
start_time=datetime.now(),
end_time=datetime.now(),
cache_hit=False,
custom_llm_provider=None,
)
assert normalized["kwargs"].get("model") != "transcribe/GetTranscriptionJob"

View file

@ -29,6 +29,7 @@ from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
BaseOpenAIPassThroughHandler,
RouteChecks,
_join_url_paths,
_proxy_general_settings,
anthropic_proxy_route,
azure_proxy_route,
bedrock_llm_proxy_route,
@ -5275,6 +5276,304 @@ class TestComprehendMedicalProxyRoute:
assert exc_info.value.status_code == 400
TRANSCRIBE_UPSTREAM = "https://transcribe.us-west-2.amazonaws.com/"
@pytest.fixture
def transcribe_client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
from litellm.proxy.proxy_server import app
monkeypatch.setenv("AWS_REGION_NAME", "us-west-2")
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "test-access-key")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "test-secret-key")
monkeypatch.delenv("AWS_SESSION_TOKEN", raising=False)
monkeypatch.delenv("SERVER_ROOT_PATH", raising=False)
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
litellm.in_memory_llm_clients_cache.flush_cache()
monkeypatch.setitem(
app.dependency_overrides, user_api_key_auth, lambda: UserAPIKeyAuth(api_key="sk-virtual", user_id="user-a")
)
monkeypatch.setitem(
app.dependency_overrides, _proxy_general_settings, lambda: {"transcribe_media_buckets": ["bucket"]}
)
yield TestClient(app)
def _owned_job(owner: str | None, status: str = "COMPLETED") -> dict[str, object]:
tags = {"Tags": [{"Key": "litellm-owner", "Value": owner}]} if owner is not None else {}
return {"TranscriptionJob": {"TranscriptionJobName": "litellm-job-1", "TranscriptionJobStatus": status, **tags}}
class TestTranscribeProxyRoute:
START_JOB_BODY: Final = MappingProxyType(
{
"TranscriptionJobName": "litellm-job-1",
"LanguageCode": "en-US",
"Media": {"MediaFileUri": "s3://bucket/audio.wav"},
}
)
OWNER_TAG: Final = MappingProxyType({"Key": "litellm-owner", "Value": "user-a"})
def test_signs_and_forwards_start_transcription_job(self, transcribe_client: TestClient) -> None:
upstream_body = {
"TranscriptionJob": {"TranscriptionJobName": "litellm-job-1", "TranscriptionJobStatus": "IN_PROGRESS"}
}
with respx.mock(assert_all_called=True) as upstream:
route = upstream.post(TRANSCRIBE_UPSTREAM).mock(return_value=httpx.Response(200, json=upstream_body))
response = transcribe_client.post(
"/transcribe/StartTranscriptionJob",
json=dict(self.START_JOB_BODY),
headers={"Authorization": "Bearer sk-virtual"},
)
assert (response.status_code, response.json()) == (200, upstream_body)
targets = [call.request.headers["x-amz-target"] for call in route.calls]
assert targets[0] == "Transcribe.StartTranscriptionJob"
assert set(targets[1:]) <= {"Transcribe.GetTranscriptionJob"}
sent = route.calls[0].request
assert json.loads(sent.content) == {**dict(self.START_JOB_BODY), "Tags": [dict(self.OWNER_TAG)]}
assert sent.headers["content-type"] == "application/x-amz-json-1.1"
assert sent.headers["authorization"].startswith("AWS4-HMAC-SHA256 Credential=test-access-key/")
assert "/us-west-2/transcribe/aws4_request" in sent.headers["authorization"]
assert "x-amz-date" in sent.headers
@pytest.mark.parametrize(
"body, member",
[
({"Media": {"MediaFileUri": "s3://other-tenant/audio.wav"}}, "Media.MediaFileUri"),
({"OutputBucketName": "other-tenant"}, "OutputBucketName"),
({"DataAccessRoleArn": "arn:aws:iam::123456789012:role/reader"}, "DataAccessRoleArn"),
],
)
def test_storage_outside_the_listed_buckets_is_refused_before_signing(
self, transcribe_client: TestClient, body: dict[str, object], member: str
) -> None:
with respx.mock(assert_all_called=False) as upstream:
route = upstream.post(TRANSCRIBE_UPSTREAM)
response = transcribe_client.post("/transcribe/StartTranscriptionJob", json={**dict(self.START_JOB_BODY), **body})
assert response.status_code == 403
assert member in response.json()["detail"]
assert not route.called
def test_start_needs_a_bucket_list_unless_the_caller_is_a_proxy_admin(
self, transcribe_client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
from litellm.proxy.proxy_server import app
monkeypatch.setitem(app.dependency_overrides, _proxy_general_settings, lambda: {})
with respx.mock(assert_all_called=False) as upstream:
route = upstream.post(TRANSCRIBE_UPSTREAM).mock(return_value=httpx.Response(200, json=_owned_job("admin")))
refused = transcribe_client.post("/transcribe/StartTranscriptionJob", json=dict(self.START_JOB_BODY))
monkeypatch.setitem(
app.dependency_overrides,
user_api_key_auth,
lambda: UserAPIKeyAuth(api_key="sk-admin", user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN),
)
allowed = transcribe_client.post("/transcribe/StartTranscriptionJob", json=dict(self.START_JOB_BODY))
assert refused.status_code == 403
assert "transcribe_media_buckets" in refused.json()["detail"]
assert allowed.status_code == 200
assert route.calls[0].request.headers["x-amz-target"] == "Transcribe.StartTranscriptionJob"
def test_the_caller_cannot_forge_the_owner_tag(self, transcribe_client: TestClient) -> None:
with respx.mock(assert_all_called=False) as upstream:
route = upstream.post(TRANSCRIBE_UPSTREAM)
response = transcribe_client.post(
"/transcribe/StartTranscriptionJob",
json={**dict(self.START_JOB_BODY), "Tags": [{"Key": "litellm-owner", "Value": "user-b"}]},
)
assert response.status_code == 400
assert "litellm-owner" in response.json()["detail"]
assert not route.called
def test_sdk_route_reads_operation_from_x_amz_target_and_resigns(self, transcribe_client: TestClient) -> None:
with respx.mock(assert_all_called=True) as upstream:
route = upstream.post(TRANSCRIBE_UPSTREAM).mock(return_value=httpx.Response(200, json=_owned_job("user-a")))
response = transcribe_client.post(
"/transcribe",
json={"TranscriptionJobName": "litellm-job-1"},
headers={
"Authorization": "AWS4-HMAC-SHA256 Credential=sk-virtual/20260101/us-west-2/transcribe/aws4_request",
"X-Amz-Target": "Transcribe.GetTranscriptionJob",
"Content-Type": "application/x-amz-json-1.1",
},
)
assert (response.status_code, response.json()) == (200, _owned_job("user-a"))
assert [call.request.headers["x-amz-target"] for call in route.calls] == ["Transcribe.GetTranscriptionJob"] * 2
sent = route.calls.last.request
assert "Credential=test-access-key/" in sent.headers["authorization"]
assert "sk-virtual" not in sent.headers["authorization"]
@pytest.mark.parametrize("operation", ["GetTranscriptionJob", "DeleteTranscriptionJob"])
@pytest.mark.parametrize("owner", ["user-b", None])
def test_jobs_started_by_others_are_not_reachable(
self, transcribe_client: TestClient, operation: str, owner: str | None
) -> None:
with respx.mock(assert_all_called=True) as upstream:
route = upstream.post(TRANSCRIBE_UPSTREAM).mock(return_value=httpx.Response(200, json=_owned_job(owner)))
response = transcribe_client.post(
f"/transcribe/{operation}", json={"TranscriptionJobName": "litellm-job-1"}
)
assert response.status_code == 404
assert [call.request.headers["x-amz-target"] for call in route.calls] == ["Transcribe.GetTranscriptionJob"]
def test_the_owner_may_delete_the_job(self, transcribe_client: TestClient) -> None:
with respx.mock(assert_all_called=True) as upstream:
route = upstream.post(TRANSCRIBE_UPSTREAM)
route.side_effect = [httpx.Response(200, json=_owned_job("user-a")), httpx.Response(200, json={})]
response = transcribe_client.post(
"/transcribe/DeleteTranscriptionJob", json={"TranscriptionJobName": "litellm-job-1"}
)
assert (response.status_code, response.json()) == (200, {})
assert [call.request.headers["x-amz-target"] for call in route.calls] == [
"Transcribe.GetTranscriptionJob",
"Transcribe.DeleteTranscriptionJob",
]
@pytest.mark.parametrize("operation", ["ListTranscriptionJobs", "ListVocabularies", "DeleteVocabulary"])
def test_account_wide_operations_need_a_proxy_admin(self, transcribe_client: TestClient, operation: str) -> None:
with respx.mock(assert_all_called=False) as upstream:
route = upstream.post(TRANSCRIBE_UPSTREAM)
response = transcribe_client.post(f"/transcribe/{operation}", json={})
assert response.status_code == 403
assert operation in response.json()["detail"]
assert not route.called
def test_a_proxy_admin_reaches_account_wide_operations(
self, transcribe_client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
from litellm.proxy._types import LitellmUserRoles
from litellm.proxy.proxy_server import app
monkeypatch.setitem(
app.dependency_overrides,
user_api_key_auth,
lambda: UserAPIKeyAuth(api_key="sk-admin", user_role=LitellmUserRoles.PROXY_ADMIN),
)
with respx.mock(assert_all_called=True) as upstream:
upstream.post(TRANSCRIBE_UPSTREAM).mock(
return_value=httpx.Response(200, json={"TranscriptionJobSummaries": []})
)
response = transcribe_client.post("/transcribe/ListTranscriptionJobs", json={})
assert (response.status_code, response.json()) == (200, {"TranscriptionJobSummaries": []})
def test_upstream_error_status_and_body_are_returned(self, transcribe_client: TestClient) -> None:
aws_error = {"__type": "BadRequestException", "Message": "The requested job couldn't be found."}
with respx.mock(assert_all_called=True) as upstream:
upstream.post(TRANSCRIBE_UPSTREAM).mock(return_value=httpx.Response(400, json=aws_error))
response = transcribe_client.post(
"/transcribe/StartTranscriptionJob",
json={**dict(self.START_JOB_BODY), "TranscriptionJobName": "missing"},
)
assert (response.status_code, response.json()) == (400, aws_error)
@pytest.mark.parametrize(
"operation",
[
"Start-Transcription-Job",
"Transcribe.StartTranscriptionJob",
"a" * 200,
"starttranscriptionjob",
"DetectEntitiesV2",
],
)
def test_rejects_unsupported_operations_without_calling_aws(
self, transcribe_client: TestClient, operation: str
) -> None:
with respx.mock(assert_all_called=False) as upstream:
route = upstream.post(TRANSCRIBE_UPSTREAM)
response = transcribe_client.post(f"/transcribe/{operation}", json={})
assert response.status_code == 400
assert "Unsupported Amazon Transcribe operation" in response.json()["detail"]
assert not route.called
@pytest.mark.parametrize(
"raw_body",
['{"MaxResults": 5, "stream": true}', '{"MaxResults": 5, "stream": false}', '["x"]', "not json"],
)
def test_rejects_bad_bodies_without_calling_aws(self, transcribe_client: TestClient, raw_body: str) -> None:
with respx.mock(assert_all_called=False) as upstream:
route = upstream.post(TRANSCRIBE_UPSTREAM)
response = transcribe_client.post(
"/transcribe/GetTranscriptionJob", content=raw_body, headers={"Content-Type": "application/json"}
)
assert response.status_code == 400
assert not route.called
def test_missing_region_returns_400_without_calling_aws(
self, transcribe_client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
for name in ("AWS_REGION_NAME", "AWS_REGION", "AWS_DEFAULT_REGION"):
monkeypatch.delenv(name, raising=False)
with respx.mock(assert_all_called=False) as upstream:
route = upstream.post(TRANSCRIBE_UPSTREAM)
response = transcribe_client.post("/transcribe/GetTranscriptionJob", json={})
assert response.status_code == 400
assert "AWS region" in response.json()["detail"]
assert not route.called
@pytest.mark.parametrize(
("operation", "body", "detail_fragment"),
[
("StartMedicalTranscriptionJob", {"MedicalTranscriptionJobName": "j"}, "StartMedicalTranscriptionJob"),
("StartCallAnalyticsJob", {"CallAnalyticsJobName": "j"}, "StartCallAnalyticsJob"),
("StartMedicalScribeJob", {"MedicalScribeJobName": "j"}, "StartMedicalScribeJob"),
("StartTranscriptionJob", {"ContentRedaction": {"RedactionType": "PII"}}, "ContentRedaction"),
("StartTranscriptionJob", {"ToxicityDetection": [{"ToxicityCategories": ["ALL"]}]}, "ToxicityDetection"),
("StartTranscriptionJob", {"ModelSettings": {"LanguageModelName": "clm"}}, "LanguageModelName"),
],
)
def test_rejects_unpriced_billable_jobs_without_calling_aws(
self, transcribe_client: TestClient, operation: str, body: dict[str, object], detail_fragment: str
) -> None:
with respx.mock(assert_all_called=False) as upstream:
route = upstream.post(TRANSCRIBE_UPSTREAM)
response = transcribe_client.post(f"/transcribe/{operation}", json={**dict(self.START_JOB_BODY), **body})
assert response.status_code == 400
assert detail_fragment in response.json()["detail"]
assert not route.called
def test_rejects_start_transcription_job_when_the_cost_map_has_no_rate(
self, transcribe_client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delitem(litellm.model_cost, "transcribe/StartTranscriptionJob")
with respx.mock(assert_all_called=False) as upstream:
route = upstream.post(TRANSCRIBE_UPSTREAM)
response = transcribe_client.post("/transcribe/StartTranscriptionJob", json=dict(self.START_JOB_BODY))
assert response.status_code == 400
assert "model cost map" in response.json()["detail"]
assert not route.called
@pytest.mark.parametrize("target_header", ["", "Transcribe", "ComprehendMedical_20181030.DetectPHI", "Transcribe."])
def test_sdk_route_rejects_bad_x_amz_target(self, transcribe_client: TestClient, target_header: str) -> None:
with respx.mock(assert_all_called=False) as upstream:
route = upstream.post(TRANSCRIBE_UPSTREAM)
response = transcribe_client.post("/transcribe", json={}, headers={"X-Amz-Target": target_header})
assert response.status_code == 400
assert "X-Amz-Target" in response.json()["detail"]
assert not route.called
def test_transcribe_is_a_mapped_pass_through_route(self) -> None:
from litellm.proxy._types import LiteLLMRoutes
assert "/transcribe" in LiteLLMRoutes.mapped_pass_through_routes.value
LIVE_RESOURCE_PATH = "projects/proj-db/locations/global/publishers/google/models/gemini-live-2.5-flash"
@ -5315,9 +5614,7 @@ class TestVertexAILiveWebsocketPassthrough:
]
)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router)
monkeypatch.setattr(
passthrough_module.passthrough_endpoint_router, "default_vertex_config", None
)
monkeypatch.setattr(passthrough_module.passthrough_endpoint_router, "default_vertex_config", None)
self._clear_vertex_env(monkeypatch)
websocket = self._websocket()
ensure_token = AsyncMock(return_value=("token-abc", "proj-db"))
@ -5459,9 +5756,7 @@ class TestVertexAILiveWebsocketPassthrough:
)
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", None)
monkeypatch.setattr(
passthrough_module.passthrough_endpoint_router, "default_vertex_config", None
)
monkeypatch.setattr(passthrough_module.passthrough_endpoint_router, "default_vertex_config", None)
self._clear_vertex_env(monkeypatch)
websocket = self._websocket()
ensure_token = AsyncMock(side_effect=Exception("Unable to find your credentials"))

View file

@ -4407,12 +4407,14 @@ async def test_pass_through_request_relays_non_json_body_without_buffering():
@pytest.mark.asyncio
async def test_pass_through_request_json_response_stays_buffered_for_logging():
@pytest.mark.parametrize("content_type", ["application/json", "application/x-amz-json-1.1"])
async def test_pass_through_request_json_response_stays_buffered_for_logging(content_type: str):
"""
JSON responses (content-type application/json) must keep the buffered
behavior: spend logging and guardrails inspect the parsed body, so the
handler reads the full upstream body and passes the parsed dict to the
success handler.
JSON responses (content-type application/json, and the AWS JSON protocol
media types AWS services such as Amazon Transcribe answer with) must keep
the buffered behavior: spend logging and guardrails inspect the parsed body,
so the handler reads the full upstream body and passes the parsed dict to
the success handler instead of handing it a relayed, already closed response.
"""
from fastapi.responses import StreamingResponse
@ -4423,7 +4425,7 @@ async def test_pass_through_request_json_response_stays_buffered_for_logging():
fake_client, cleanup = _inject_fake_passthrough_client(
_FakeUpstreamTransport(
status_code=200,
headers={"content-type": "application/json"},
headers={"content-type": content_type},
stream=upstream_stream,
),
timeout=312.0,

View file

@ -3746,6 +3746,27 @@ async def test_ProxyConfig__update_general_settings_yaml_allowed_file_extensions
assert ps.general_settings.get("allowed_file_extensions") == [".pdf"]
@pytest.mark.asyncio
async def test_ProxyConfig__update_general_settings_applies_db_transcribe_media_buckets(monkeypatch):
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})
pc = ProxyConfig()
await pc._update_general_settings({"transcribe_media_buckets": ["team-audio"]})
from litellm.proxy import proxy_server as ps
assert ps.general_settings.get("transcribe_media_buckets") == ["team-audio"]
@pytest.mark.asyncio
async def test_ProxyConfig__update_general_settings_yaml_transcribe_media_buckets_wins_over_db(monkeypatch):
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"transcribe_media_buckets": ["yaml-audio"]})
pc = ProxyConfig()
pc._yaml_general_settings_keys = {"transcribe_media_buckets"}
await pc._update_general_settings({"transcribe_media_buckets": ["team-audio"]})
from litellm.proxy import proxy_server as ps
assert ps.general_settings.get("transcribe_media_buckets") == ["yaml-audio"]
@pytest.mark.asyncio
async def test_ProxyConfig__update_general_settings_none_input_noop():
pc = ProxyConfig()

View file

@ -1,4 +1,4 @@
import { renderWithProviders, screen, within } from "../../../../../tests/test-utils";
import { fireEvent, renderWithProviders, screen, within } from "../../../../../tests/test-utils";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import GeneralSettings from "./general_settings";
@ -159,6 +159,56 @@ describe("GeneralSettings tabs", () => {
});
});
it("persists a List setting typed as comma-separated text as a trimmed string array", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{
field_name: "transcribe_media_buckets",
field_type: "List",
field_value: ["old-bucket"],
field_description: "buckets",
stored_in_db: true,
},
]);
vi.mocked(updateConfigFieldSetting).mockClear();
const user = userEvent.setup();
renderWithProviders(<GeneralSettings accessToken="token" userRole="Admin" userID="user" />);
await user.click(screen.getByRole("tab", { name: "General" }));
const input = await screen.findByRole("textbox", { name: "transcribe_media_buckets" });
expect(input).toHaveValue("old-bucket");
fireEvent.change(input, { target: { value: " team-audio, shared.audio ,, " } });
await user.click(
within(screen.getByRole("row", { name: /transcribe_media_buckets/ })).getByRole("button", { name: "Update" }),
);
expect(vi.mocked(updateConfigFieldSetting).mock.calls).toEqual([
["token", "transcribe_media_buckets", ["team-audio", "shared.audio"]],
]);
});
it("clears a stored List setting when Update is clicked on an emptied input", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{
field_name: "transcribe_media_buckets",
field_type: "List",
field_value: ["old-bucket"],
field_description: "buckets",
stored_in_db: true,
},
]);
vi.mocked(updateConfigFieldSetting).mockClear();
vi.mocked(deleteConfigFieldSetting).mockClear();
const user = userEvent.setup();
renderWithProviders(<GeneralSettings accessToken="token" userRole="Admin" userID="user" />);
await user.click(screen.getByRole("tab", { name: "General" }));
const input = await screen.findByRole("textbox", { name: "transcribe_media_buckets" });
fireEvent.change(input, { target: { value: " , " } });
await user.click(
within(screen.getByRole("row", { name: /transcribe_media_buckets/ })).getByRole("button", { name: "Update" }),
);
expect(vi.mocked(deleteConfigFieldSetting).mock.calls).toEqual([["token", "transcribe_media_buckets"]]);
expect(updateConfigFieldSetting).not.toHaveBeenCalled();
expect(screen.getByRole("textbox", { name: "transcribe_media_buckets" })).toHaveValue("");
});
it("should delete only the Default setting and retain explicit false and zero", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{

View file

@ -43,6 +43,16 @@ const NUMERIC_INPUT_WIDTH = "w-36";
const toNumericValue = (raw: string): number | null => (raw === "" ? null : Number(raw));
const toListValue = (raw: string): string[] | null => {
const items = raw
.split(",")
.map((item) => item.trim())
.filter((item) => item !== "");
return items.length === 0 ? null : items;
};
const fromListValue = (value: unknown): string => (Array.isArray(value) ? value.join(", ") : "");
const SettingValueEditor: React.FC<{
setting: generalSettingsItem;
onChange: (fieldName: string, newValue: any) => void;
@ -93,6 +103,17 @@ const SettingValueEditor: React.FC<{
</InputGroup>
);
}
if (setting.field_type === "List") {
return (
<Input
key={String(setting.stored_in_db)}
aria-label={setting.field_name}
placeholder="Comma-separated values"
defaultValue={fromListValue(setting.field_value)}
onChange={(event) => onChange(setting.field_name, toListValue(event.target.value))}
/>
);
}
if (setting.field_type === "Select") {
return (
<Select value={setting.field_value ?? null} onValueChange={(newValue) => onChange(setting.field_name, newValue)}>
@ -228,7 +249,7 @@ const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, user
const fieldValue = setting?.field_value;
if (fieldValue == null) {
if (setting?.field_type === "Select") handleResetField(fieldName);
if (setting?.field_type === "Select" || setting?.field_type === "List") handleResetField(fieldName);
return;
}
try {

View file

@ -16476,6 +16476,61 @@ export interface paths {
patch: operations["toolset_mcp_route_toolset__toolset_name__mcp_patch"];
trace?: never;
};
"/transcribe": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Transcribe Sdk Proxy Route
* @description AWS-SDK-shaped pass-through for Amazon Transcribe: point the SDK's `endpoint_url`
* at `/transcribe` and the operation is read from the `X-Amz-Target` header, per the
* AWS JSON 1.1 protocol.
*
* [Docs](https://docs.litellm.ai/docs/pass_through/transcribe)
*/
post: operations["transcribe_sdk_proxy_route_transcribe_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/transcribe/{operation}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Transcribe Proxy Route
* @description Pass-through for the Amazon Transcribe API, e.g. `POST /transcribe/StartTranscriptionJob`.
*
* The request body is forwarded to the AWS JSON 1.1 API and signed with SigV4 using the
* proxy's AWS credentials. Standard jobs are tagged with the calling key's owner so that
* only that owner (or a proxy admin) can read or delete them, and keys other than proxy
* admins may only read media from and write transcripts to the S3 buckets listed in
* `general_settings.transcribe_media_buckets`; account-wide operations
* such as ListTranscriptionJobs are limited to proxy admins. Streaming transcription
* (`transcribestreaming`) uses a separate HTTP/2 event-stream protocol and is not served
* by this route.
*
* [Docs](https://docs.litellm.ai/docs/pass_through/transcribe)
*/
post: operations["transcribe_proxy_route_transcribe__operation__post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/typesafe/{endpoint}": {
parameters: {
query?: never;
@ -26829,6 +26884,11 @@ export interface components {
* @description Fine-grained control over which object types to load from the database when store_model_in_db is True. Available types: 'models', 'mcp', 'guardrails', 'vector_stores', 'pass_through_endpoints', 'prompts', 'model_cost_map', 'tools', 'config_overrides'. If not set, all objects are loaded (default behavior).
*/
supported_db_objects?: components["schemas"]["SupportedDBObjectType"][] | null;
/**
* Transcribe Media Buckets
* @description S3 bucket names that keys other than proxy admins may read media from and write transcripts to through the Amazon Transcribe pass-through. Unset means only proxy admins can start transcription jobs.
*/
transcribe_media_buckets?: string[] | null;
/**
* Trusted Proxy Ranges
* @description CIDR ranges of trusted reverse proxies allowed to provide identity headers for header-based auth paths such as enable_oauth2_proxy_auth and custom_ui_sso_sign_in_handler.
@ -61698,6 +61758,57 @@ export interface operations {
};
};
};
transcribe_sdk_proxy_route_transcribe_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
};
};
transcribe_proxy_route_transcribe__operation__post: {
parameters: {
query?: never;
header?: never;
path: {
operation: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
typesafe_proxy_route_typesafe__endpoint__get: {
parameters: {
query?: never;